Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions autowsgr/server/device_lease.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
"""Exclusive ownership for operations that drive the shared emulator."""

from __future__ import annotations

import threading
from dataclasses import dataclass
from functools import wraps
from typing import TYPE_CHECKING, Any


if TYPE_CHECKING:
from collections.abc import Awaitable, Callable


class DeviceOperationBusyError(RuntimeError):
"""Raised when another operation already owns the shared device."""


@dataclass(frozen=True)
class DeviceOperationToken:
"""Opaque ownership token used for compare-and-release semantics."""

owner: str
identity: object


class DeviceOperationLease:
"""A non-blocking, token-owned lease for the shared emulator."""

def __init__(self) -> None:
self._lock = threading.Lock()
self._token: DeviceOperationToken | None = None

def acquire(self, owner: str) -> DeviceOperationToken:
"""Acquire ownership immediately or report that the device is busy."""
with self._lock:
if self._token is not None:
raise DeviceOperationBusyError(f'设备正由 {self._token.owner} 使用')
token = DeviceOperationToken(owner=owner, identity=object())
self._token = token
return token

def release(self, token: DeviceOperationToken) -> None:
"""Release ownership only when the exact active token is supplied."""
with self._lock:
if self._token is token:
self._token = None

@property
def owner(self) -> str | None:
"""Return the current owner for status and diagnostics."""
with self._lock:
return self._token.owner if self._token is not None else None


device_operation_lease = DeviceOperationLease()


def exclusive_device_operation(
owner: str,
) -> Callable[[Callable[..., Awaitable[Any]]], Callable[..., Awaitable[Any]]]:
"""Reject concurrent HTTP device operations with a consistent 409 response."""
from fastapi import HTTPException

def decorator(
handler: Callable[..., Awaitable[Any]],
) -> Callable[..., Awaitable[Any]]:
@wraps(handler)
async def wrapped(*args: Any, **kwargs: Any) -> Any:
try:
token = device_operation_lease.acquire(owner)
except DeviceOperationBusyError as error:
raise HTTPException(status_code=409, detail=str(error)) from error
try:
return await handler(*args, **kwargs)
finally:
device_operation_lease.release(token)

return wrapped

return decorator
6 changes: 2 additions & 4 deletions autowsgr/server/routes/game.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,14 @@
from fastapi import APIRouter, HTTPException

from autowsgr.infra.logger import get_logger
from autowsgr.server.device_lease import exclusive_device_operation
from autowsgr.server.schemas import ApiResponse
from autowsgr.server.serializers import (
serialize_build_queue,
serialize_expedition_queue,
serialize_fleet,
serialize_resources,
)
from autowsgr.server.task_manager import task_manager

from ..main import get_context

Expand All @@ -25,6 +25,7 @@


@router.get('/api/game/acquisition', response_model=ApiResponse)
@exclusive_device_operation('api:game-acquisition')
async def game_acquisition() -> ApiResponse:
"""从出征面板截图 OCR 识别今日舰船 (X/500) 与战利品 (X/50) 获取数量。

Expand All @@ -35,9 +36,6 @@ async def game_acquisition() -> ApiResponse:
except RuntimeError as e:
raise HTTPException(status_code=503, detail=str(e)) from e

if task_manager.is_running:
raise HTTPException(status_code=409, detail='任务执行中,无法查询获取数量')

from autowsgr.ops.navigate import goto_page
from autowsgr.ui.map.page import MapPage

Expand Down
41 changes: 12 additions & 29 deletions autowsgr/server/routes/ops.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@
from pydantic import BaseModel

from autowsgr.infra.logger import get_logger
from autowsgr.server.device_lease import exclusive_device_operation
from autowsgr.server.schemas import ApiResponse
from autowsgr.server.task_manager import task_manager

from ..main import get_context

Expand All @@ -20,25 +20,18 @@
router = APIRouter(tags=['ops'])


def _require_idle() -> None:
"""检查是否有任务正在运行。"""
if task_manager.is_running:
raise HTTPException(status_code=409, detail='任务执行中,无法操作')


# ── 远征收取 ──


@router.post('/api/expedition/check', response_model=ApiResponse)
@exclusive_device_operation('api:expedition-check')
async def expedition_check() -> ApiResponse:
"""检查并收取已完成的远征。"""
try:
ctx = get_context()
except RuntimeError as e:
raise HTTPException(status_code=503, detail=str(e)) from e

_require_idle()

from autowsgr.ops.expedition import collect_expedition

try:
Expand All @@ -61,10 +54,11 @@ class ExpeditionAutoCheckRequest(BaseModel):


@router.post('/api/expedition/auto_check', response_model=ApiResponse)
@exclusive_device_operation('api:expedition-auto-check')
async def expedition_auto_check(request: ExpeditionAutoCheckRequest) -> ApiResponse:
"""自动远征检查(挂机专用)。

不受 _require_idle 限制,顺带领取任务奖励并根据战斗任务状态智能执行浴室维修
顺带领取任务奖励并根据调用方配置决定是否执行浴室维修
"""
try:
ctx = get_context()
Expand Down Expand Up @@ -92,11 +86,7 @@ async def expedition_auto_check(request: ExpeditionAutoCheckRequest) -> ApiRespo
results['rewards_error'] = str(e)

# 3. 浴室维修
if task_manager.is_running:
_log.info('[API] 自动远征检查: 战斗任务进行中,跳过浴室维修')
results['repair_skipped'] = True
results['repair_reason'] = '战斗任务进行中'
elif not request.allow_repair:
if not request.allow_repair:
_log.info('[API] 自动远征检查: 前端禁止维修(队列中还有后续任务),跳过浴室维修')
results['repair_skipped'] = True
results['repair_reason'] = '队列中有后续战斗任务'
Expand Down Expand Up @@ -130,15 +120,14 @@ class BuildStartRequest(BaseModel):


@router.post('/api/build/collect', response_model=ApiResponse)
@exclusive_device_operation('api:build-collect')
async def build_collect() -> ApiResponse:
"""收取已完成的建造。"""
try:
ctx = get_context()
except RuntimeError as e:
raise HTTPException(status_code=503, detail=str(e)) from e

_require_idle()

from autowsgr.ops import collect_built_ships

try:
Expand All @@ -150,15 +139,14 @@ async def build_collect() -> ApiResponse:


@router.post('/api/build/start', response_model=ApiResponse)
@exclusive_device_operation('api:build-start')
async def build_start(request: BuildStartRequest) -> ApiResponse:
"""开始建造。"""
try:
ctx = get_context()
except RuntimeError as e:
raise HTTPException(status_code=503, detail=str(e)) from e

_require_idle()

from autowsgr.ops import BuildRecipe, build_ship

recipe = BuildRecipe(
Expand Down Expand Up @@ -186,15 +174,14 @@ async def build_start(request: BuildStartRequest) -> ApiResponse:


@router.post('/api/reward/collect', response_model=ApiResponse)
@exclusive_device_operation('api:reward-collect')
async def reward_collect() -> ApiResponse:
"""收取任务奖励。"""
try:
ctx = get_context()
except RuntimeError as e:
raise HTTPException(status_code=503, detail=str(e)) from e

_require_idle()

from autowsgr.ops import collect_rewards

try:
Expand All @@ -216,15 +203,14 @@ class CookRequest(BaseModel):


@router.post('/api/cook', response_model=ApiResponse)
@exclusive_device_operation('api:cook')
async def cook_action(request: CookRequest) -> ApiResponse:
"""食堂烹饪。"""
try:
ctx = get_context()
except RuntimeError as e:
raise HTTPException(status_code=503, detail=str(e)) from e

_require_idle()

from autowsgr.ops import cook

try:
Expand All @@ -241,15 +227,14 @@ async def cook_action(request: CookRequest) -> ApiResponse:


@router.post('/api/repair/bath', response_model=ApiResponse)
@exclusive_device_operation('api:repair-bath')
async def repair_bath() -> ApiResponse:
"""浴室修理。"""
try:
ctx = get_context()
except RuntimeError as e:
raise HTTPException(status_code=503, detail=str(e)) from e

_require_idle()

from autowsgr.ops import repair_in_bath

try:
Expand All @@ -267,6 +252,7 @@ class RepairShipRequest(BaseModel):


@router.post('/api/repair/ship', response_model=ApiResponse)
@exclusive_device_operation('api:repair-ship')
async def repair_ship(request: RepairShipRequest) -> ApiResponse:
"""使用浴室修理指定名称的舰船。

Expand All @@ -278,8 +264,6 @@ async def repair_ship(request: RepairShipRequest) -> ApiResponse:
except RuntimeError as e:
raise HTTPException(status_code=503, detail=str(e)) from e

_require_idle()

from autowsgr.ops.repair import repair_ship_by_name

try:
Expand Down Expand Up @@ -310,15 +294,14 @@ class DestroyRequest(BaseModel):


@router.post('/api/destroy', response_model=ApiResponse)
@exclusive_device_operation('api:destroy')
async def destroy_action(request: DestroyRequest) -> ApiResponse:
"""解装/解体舰船。"""
try:
ctx = get_context()
except RuntimeError as e:
raise HTTPException(status_code=503, detail=str(e)) from e

_require_idle()

from autowsgr.ops import destroy_ships
from autowsgr.types import ShipType

Expand Down
21 changes: 17 additions & 4 deletions autowsgr/server/routes/system.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,15 @@

import asyncio

from fastapi import APIRouter
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel

from autowsgr.infra.logger import get_logger
from autowsgr.server.device_lease import (
DeviceOperationBusyError,
device_operation_lease,
exclusive_device_operation,
)
from autowsgr.server.schemas import ApiResponse
from autowsgr.server.task_manager import task_manager

Expand All @@ -27,6 +32,7 @@ class SystemStartRequest(BaseModel):


@router.post('/start', response_model=ApiResponse)
@exclusive_device_operation('api:system-start')
async def system_start(request: SystemStartRequest) -> ApiResponse:
"""启动系统 (连接模拟器、启动游戏)。"""
async with _main.lifecycle_lock:
Expand Down Expand Up @@ -69,9 +75,16 @@ async def system_stop() -> ApiResponse:
error='任务未在超时前停止,系统上下文仍保持活动状态',
)

_main._ctx = None
_log.info('[System] 系统已停止')
return ApiResponse(success=True, message='系统已停止')
try:
lease_token = device_operation_lease.acquire('api:system-stop')
except DeviceOperationBusyError as error:
raise HTTPException(status_code=409, detail=str(error)) from error
try:
_main._ctx = None
_log.info('[System] 系统已停止')
return ApiResponse(success=True, message='系统已停止')
finally:
device_operation_lease.release(lease_token)


@router.get('/status', response_model=ApiResponse)
Expand Down
28 changes: 16 additions & 12 deletions autowsgr/server/routes/task.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from pydantic import Discriminator

from autowsgr.infra.logger import get_logger
from autowsgr.server.device_lease import DeviceOperationBusyError
from autowsgr.server.schemas import (
ApiResponse,
CampaignRequest,
Expand Down Expand Up @@ -47,18 +48,21 @@ async def task_start(request: TaskRequestUnion) -> ApiResponse: # type: ignore[

ctx.stop_event = task_manager.stop_event

if isinstance(request, NormalFightRequest):
return await _start_normal_fight(ctx, request)
elif isinstance(request, EventFightRequest):
return await _start_event_fight(ctx, request)
elif isinstance(request, CampaignRequest):
return await _start_campaign(ctx, request)
elif isinstance(request, ExerciseRequest):
return await _start_exercise(ctx, request)
elif isinstance(request, DecisiveRequest):
return await _start_decisive(ctx, request)
else:
raise HTTPException(status_code=400, detail='未知的任务类型')
try:
if isinstance(request, NormalFightRequest):
return await _start_normal_fight(ctx, request)
elif isinstance(request, EventFightRequest):
return await _start_event_fight(ctx, request)
elif isinstance(request, CampaignRequest):
return await _start_campaign(ctx, request)
elif isinstance(request, ExerciseRequest):
return await _start_exercise(ctx, request)
elif isinstance(request, DecisiveRequest):
return await _start_decisive(ctx, request)
else:
raise HTTPException(status_code=400, detail='未知的任务类型')
except DeviceOperationBusyError as error:
raise HTTPException(status_code=409, detail=str(error)) from error


@router.post('/stop', response_model=ApiResponse)
Expand Down
Loading
Loading