diff --git a/.github/workflows/sync-upstream.yml b/.github/workflows/sync-upstream.yml new file mode 100644 index 00000000..244356a0 --- /dev/null +++ b/.github/workflows/sync-upstream.yml @@ -0,0 +1,18 @@ +name: Sync upstream + +on: + schedule: + - cron: '*/30 * * * *' # 每 30 分钟检测上游新提交 + workflow_dispatch: # 也可手动触发 + +permissions: + contents: write + +jobs: + sync: + runs-on: ubuntu-latest + steps: + - name: Sync fork with upstream + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: gh repo sync ${{ github.repository }} --force diff --git a/autowsgr/server/main.py b/autowsgr/server/main.py index 5f8b5c2e..59f7c6a4 100644 --- a/autowsgr/server/main.py +++ b/autowsgr/server/main.py @@ -1,14 +1,15 @@ -"""FastAPI 主应用 — HTTP REST API 和 WebSocket 端点。 - -本模块提供以下接口: -- POST /api/task/start — 启动任务 (异步执行) -- POST /api/task/stop — 停止任务 -- GET /api/task/status — 查询状态 -- POST /api/expedition/check — 检查并收取远征 -- GET /api/game/acquisition — OCR 识别今日舰船/战利品获取数量 -- GET /api/game/context — 查询游戏运行时计数器 -- WS /ws/logs — 实时日志流 -- WS /ws/task — 任务状态更新 +"""FastAPI 主应用 — 应用入口、生命周期管理和 WebSocket 端点。 + +路由按功能拆分到 routes/ 子包: +- routes/system.py — /api/system/* 系统管理 +- routes/task.py — /api/task/* 任务执行 +- routes/game.py — /api/game/* 游戏状态查询 +- routes/ops.py — /api/expedition/* /api/build/* 等操作 +- routes/health.py — /api/health 健康检查 + +WebSocket 端点保留在此文件: +- WS /ws/logs — 实时日志流 +- WS /ws/task — 任务状态更新 使用方式: uvicorn autowsgr.server.main:app --host 0.0.0.0 --port 8000 @@ -19,21 +20,12 @@ import asyncio import json from contextlib import asynccontextmanager -from typing import Annotated, Any +from typing import Any -from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect +from fastapi import FastAPI, WebSocket, WebSocketDisconnect from fastapi.middleware.cors import CORSMiddleware -from pydantic import BaseModel, Discriminator from autowsgr.infra.logger import get_logger -from autowsgr.server.schemas import ( - ApiResponse, - CampaignRequest, - DecisiveRequest, - EventFightRequest, - ExerciseRequest, - NormalFightRequest, -) from autowsgr.server.task_manager import task_manager from autowsgr.server.ws_manager import ws_manager @@ -76,7 +68,6 @@ async def lifespan(app: FastAPI): global _ctx if _ctx is not None: _log.info('[Server] 断开模拟器连接') - # 如果需要,可以在这里调用清理逻辑 _log.info('[Server] HTTP Server 已关闭') @@ -100,438 +91,19 @@ async def lifespan(app: FastAPI): allow_headers=['*'], ) +# ── 注册路由模块 ── +from autowsgr.server.routes.game import router as game_router # noqa: E402 +from autowsgr.server.routes.health import router as health_router # noqa: E402 +from autowsgr.server.routes.ops import router as ops_router # noqa: E402 +from autowsgr.server.routes.system import router as system_router # noqa: E402 +from autowsgr.server.routes.task import router as task_router # noqa: E402 -# ═══════════════════════════════════════════════════════════════════════════════ -# 系统管理接口 -# ═══════════════════════════════════════════════════════════════════════════════ - - -class SystemStartRequest(BaseModel): - """系统启动请求。""" - - config_path: str | None = None - - -@app.post('/api/system/start', response_model=ApiResponse) -async def system_start(request: SystemStartRequest): - """启动系统 (连接模拟器、启动游戏)。""" - global _ctx - - if _ctx is not None: - return ApiResponse(success=True, message='系统已启动') - - try: - from autowsgr.scheduler import launch - - config_path = request.config_path or 'usersettings.yaml' - _log.info('[System] 正在启动, 配置: {}', config_path) - _ctx = launch(config_path) - _log.info('[System] 启动成功') - - return ApiResponse(success=True, message='系统启动成功') - - except Exception as e: - _log.error('[System] 启动失败: {}', e) - return ApiResponse(success=False, error=str(e)) - - -@app.post('/api/system/stop', response_model=ApiResponse) -async def system_stop(): - """停止系统。""" - global _ctx - if _ctx is None: - return ApiResponse(success=True, message='系统未运行') - - # 先停止正在运行的任务 - if task_manager.is_running: - task_manager.stop_task() - - _ctx = None - _log.info('[System] 系统已停止') - return ApiResponse(success=True, message='系统已停止') - - -@app.get('/api/system/status', response_model=ApiResponse) -async def system_status(): - """获取系统状态。""" - return ApiResponse( - success=True, - data={ - 'status': task_manager.current_task.status.value - if task_manager.current_task - else 'idle', - 'emulator_connected': _ctx is not None, - 'game_running': _ctx is not None, - 'current_task': task_manager.current_task.task_id - if task_manager.current_task - else None, - }, - ) - - -# ═══════════════════════════════════════════════════════════════════════════════ -# 任务执行接口 -# ═══════════════════════════════════════════════════════════════════════════════ - - -TaskRequestUnion = Annotated[ - NormalFightRequest | EventFightRequest | CampaignRequest | ExerciseRequest | DecisiveRequest, - Discriminator('type'), -] - - -@app.post('/api/task/start', response_model=ApiResponse) -async def task_start( - request: TaskRequestUnion, -): # type: ignore - """启动任务 (异步执行,立即返回)。""" - if task_manager.is_running: - raise HTTPException(status_code=409, detail='已有任务正在运行') - - try: - ctx = get_context() - except RuntimeError as e: - raise HTTPException(status_code=503, detail=str(e)) - - # 将任务管理器的停止事件绑定到游戏上下文 - 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='未知的任务类型') - - -async def _start_normal_fight(ctx: Any, request: NormalFightRequest) -> ApiResponse: - """启动常规战任务。""" - from autowsgr.combat import CombatPlan - from autowsgr.ops import run_normal_fight - - def executor(task_info: Any) -> list[dict[str, Any]]: - """执行常规战。""" - results = [] - - # 构建或加载计划 - if request.plan_id: - plan = CombatPlan.from_yaml(request.plan_id) - elif request.plan: - plan = _build_combat_plan(request.plan) - else: - raise ValueError('必须提供 plan 或 plan_id') - - for i in range(request.times): - if task_manager.should_stop(): - break - - task_manager.update_progress(current_round=i + 1) - _log.info('[Task] 常规战第 {}/{} 轮', i + 1, request.times) - - try: - result = run_normal_fight(ctx, plan, times=1)[0] - results.append(_convert_combat_result(result, i + 1)) - task_manager.add_result(results[-1]) - except Exception as e: - _log.error('[Task] 第 {} 轮失败: {}', i + 1, e) - results.append({'round': i + 1, 'success': False, 'error': str(e)}) - - return results - - task_id = task_manager.start_task( - task_type='normal_fight', - total_rounds=request.times, - executor=executor, - ) - - return ApiResponse( - success=True, - data={'task_id': task_id, 'status': 'running'}, - message='任务已启动', - ) - - -async def _start_event_fight(ctx: Any, request: EventFightRequest) -> ApiResponse: - """启动活动战任务。""" - from autowsgr.combat import CombatPlan - from autowsgr.ops import run_event_fight - - def executor(task_info: Any) -> list[dict[str, Any]]: - results = [] - - if request.plan_id: - plan = CombatPlan.from_yaml(request.plan_id) - elif request.plan: - plan = _build_combat_plan(request.plan) - else: - raise ValueError('必须提供 plan 或 plan_id') - - fleet_id = request.fleet_id or plan.fleet_id - - for i in range(request.times): - if task_manager.should_stop(): - break - - task_manager.update_progress(current_round=i + 1) - _log.info('[Task] 活动战第 {}/{} 轮', i + 1, request.times) - - try: - result = run_event_fight(ctx, plan, times=1, fleet_id=fleet_id)[0] - results.append(_convert_combat_result(result, i + 1)) - task_manager.add_result(results[-1]) - except Exception as e: - _log.error('[Task] 第 {} 轮失败: {}', i + 1, e) - results.append({'round': i + 1, 'success': False, 'error': str(e)}) - - return results - - task_id = task_manager.start_task( - task_type='event_fight', - total_rounds=request.times, - executor=executor, - ) - - return ApiResponse( - success=True, - data={'task_id': task_id, 'status': 'running'}, - message='任务已启动', - ) - - -async def _start_campaign(ctx: Any, request: CampaignRequest) -> ApiResponse: - """启动战役任务。""" - from autowsgr.ops import CampaignRunner - - def executor(task_info: Any) -> list[dict[str, Any]]: - runner = CampaignRunner( - ctx, - campaign_name=request.campaign_name, - times=request.times, - ) - - results = [] - for i in range(request.times): - if task_manager.should_stop(): - break - - task_manager.update_progress(current_round=i + 1) - _log.info('[Task] 战役第 {}/{} 轮', i + 1, request.times) - - try: - result = runner.run() - # CampaignRunner.run() 返回完整结果列表 - results.append( - { - 'round': i + 1, - 'success': True, - } - ) - task_manager.add_result(results[-1]) - except Exception as e: - _log.error('[Task] 第 {} 轮失败: {}', i + 1, e) - results.append({'round': i + 1, 'success': False, 'error': str(e)}) - - return results - - task_id = task_manager.start_task( - task_type='campaign', - total_rounds=request.times, - executor=executor, - ) - - return ApiResponse( - success=True, - data={'task_id': task_id, 'status': 'running'}, - message='任务已启动', - ) - - -async def _start_exercise(ctx: Any, request: ExerciseRequest) -> ApiResponse: - """启动演习任务。""" - from autowsgr.ops import ExerciseRunner - - def executor(task_info: Any) -> list[dict[str, Any]]: - runner = ExerciseRunner(ctx, fleet_id=request.fleet_id) - task_manager.update_progress(current_round=1, current_node='演习') - - try: - results = runner.run() - return [{'round': i + 1, 'success': True} for i in range(len(results))] - except Exception as e: - return [{'round': 1, 'success': False, 'error': str(e)}] - - task_id = task_manager.start_task( - task_type='exercise', - total_rounds=1, - executor=executor, - ) - - return ApiResponse( - success=True, - data={'task_id': task_id, 'status': 'running'}, - message='任务已启动', - ) - - -async def _start_decisive(ctx: Any, request: DecisiveRequest) -> ApiResponse: - """启动决战任务。""" - from autowsgr.infra import DecisiveConfig - from autowsgr.ops import DecisiveController - - def executor(task_info: Any) -> list[dict[str, Any]]: - config = DecisiveConfig( - chapter=request.chapter, - level1=request.level1, - level2=request.level2, - flagship_priority=request.flagship_priority, - ) - - controller = DecisiveController(ctx, config) - task_manager.update_progress(current_round=1, current_node='决战') - - try: - result = controller.run() - return [{'round': 1, 'success': True, 'result': result.value}] - except Exception as e: - return [{'round': 1, 'success': False, 'error': str(e)}] - - task_id = task_manager.start_task( - task_type='decisive', - total_rounds=1, - executor=executor, - ) - - return ApiResponse( - success=True, - data={'task_id': task_id, 'status': 'running'}, - message='任务已启动', - ) - - -@app.post('/api/task/stop', response_model=ApiResponse) -async def task_stop(): - """停止当前任务。""" - if not task_manager.is_running: - return ApiResponse(success=True, message='没有正在运行的任务') - - success = task_manager.stop_task() - if success: - return ApiResponse( - success=True, - data={ - 'task_id': task_manager.current_task.task_id, - 'status': 'stopped', - }, - message='已请求停止任务', - ) - else: - return ApiResponse(success=False, error='停止失败') - - -@app.get('/api/task/status', response_model=ApiResponse) -async def task_status(): - """查询当前任务状态。""" - status = task_manager.get_status() - return ApiResponse(success=True, data=status) - - -# ═══════════════════════════════════════════════════════════════════════════════ -# 远征接口 -# ═══════════════════════════════════════════════════════════════════════════════ - - -@app.post('/api/expedition/check', response_model=ApiResponse) -async def expedition_check(): - """检查并收取已完成的远征。""" - try: - ctx = get_context() - 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.expedition import collect_expedition - - try: - result = await asyncio.to_thread(collect_expedition, ctx) - return ApiResponse( - success=True, - data={'collected': result}, - message='远征检查完成', - ) - except Exception as e: - _log.opt(exception=True).warning('[API] 远征检查失败: {}', e) - return ApiResponse(success=False, error=str(e)) - - -# ═══════════════════════════════════════════════════════════════════════════════ -# 游戏状态查询接口 -# ═══════════════════════════════════════════════════════════════════════════════ - - -@app.get('/api/game/acquisition', response_model=ApiResponse) -async def game_acquisition(): - """从出征面板截图 OCR 识别今日舰船 (X/500) 与战利品 (X/50) 获取数量。 - - 仅在空闲时可用 (需要控制画面导航到出征面板)。 - """ - try: - ctx = get_context() - 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.ui.map.page import MapPage - - def _recognize() -> dict[str, int | None]: - map_page = MapPage(ctx) - counts = map_page.get_acquisition_counts() - return { - 'ship_count': counts.ship_count, - 'ship_max': counts.ship_max, - 'loot_count': counts.loot_count, - 'loot_max': counts.loot_max, - } - - try: - data = await asyncio.to_thread(_recognize) - return ApiResponse(success=True, data=data, message='获取数量识别完成') - except Exception as e: - _log.opt(exception=True).warning('[API] 获取数量识别失败: {}', e) - return ApiResponse(success=False, error=str(e)) - - -@app.get('/api/game/context', response_model=ApiResponse) -async def game_context_info(): - """返回当前游戏上下文中的运行时计数器和状态。 - - 不需要截图或画面操作,直接读取内存中的计数器。 - """ - try: - ctx = get_context() - except RuntimeError as e: - raise HTTPException(status_code=503, detail=str(e)) from e - - return ApiResponse( - success=True, - data={ - 'dropped_ship_count': ctx.dropped_ship_count, - 'dropped_loot_count': ctx.dropped_loot_count, - 'quick_repair_used': ctx.quick_repair_used, - 'current_page': ctx.current_page, - }, - ) +app.include_router(system_router) +app.include_router(task_router) +app.include_router(game_router) +app.include_router(ops_router) +app.include_router(health_router) # ═══════════════════════════════════════════════════════════════════════════════ @@ -545,9 +117,7 @@ async def ws_logs(websocket: WebSocket): await ws_manager.connect(websocket) try: while True: - # 保持连接,等待客户端消息或断开 data = await websocket.receive_text() - # 可以处理客户端发来的控制消息 try: msg = json.loads(data) if msg.get('type') == 'ping': @@ -575,73 +145,6 @@ async def ws_task(websocket: WebSocket): await ws_manager.disconnect(websocket) -# ═══════════════════════════════════════════════════════════════════════════════ -# 辅助函数 -# ═══════════════════════════════════════════════════════════════════════════════ - - -def _build_combat_plan(request: Any) -> Any: - """从请求构建 CombatPlan 对象。""" - from autowsgr.combat import CombatPlan, NodeDecision - from autowsgr.types import Formation, RepairMode - - # 转换节点决策 - def build_node_decision(node_req: Any) -> NodeDecision: - return NodeDecision( - formation=Formation(node_req.formation), - night=node_req.night, - proceed=node_req.proceed, - proceed_stop=[RepairMode(r) for r in node_req.proceed_stop], - detour=node_req.detour, - ) - - node_args = {k: build_node_decision(v) for k, v in request.node_args.items()} - - return CombatPlan( - name=request.name, - mode=request.mode, - chapter=request.chapter, - map_id=request.map, - fleet_id=request.fleet_id, - fleet=request.fleet, - repair_mode=[RepairMode(r) for r in request.repair_mode], - fight_condition=request.fight_condition, - selected_nodes=request.selected_nodes, - default_node=build_node_decision(request.node_defaults), - nodes=node_args, - ) - - -def _convert_combat_result(result: Any, round_num: int) -> dict[str, Any]: - """转换 CombatResult 为响应格式。""" - # 提取节点列表 - nodes = [] - if result.history: - for event in result.history.events: - if event.node and event.node not in nodes: - nodes.append(event.node) - - # 提取 MVP - mvp = None - if result.history: - fight_results = result.history.get_fight_results() - if isinstance(fight_results, dict): - for fr in fight_results.values(): - if fr.mvp and fr.mvp > 0: - # MVP 是位置 (1-6),需要转换 - mvp = f'位置{fr.mvp}' - break - - return { - 'round': round_num, - 'success': result.flag.value == 'success', - 'nodes': nodes, - 'mvp': mvp, - 'ship_damage': [s.value for s in result.ship_stats] if result.ship_stats else [], - 'node_count': result.node_count, - } - - # ═══════════════════════════════════════════════════════════════════════════════ # 入口 # ═══════════════════════════════════════════════════════════════════════════════ diff --git a/autowsgr/server/routes/__init__.py b/autowsgr/server/routes/__init__.py new file mode 100644 index 00000000..ca2e4702 --- /dev/null +++ b/autowsgr/server/routes/__init__.py @@ -0,0 +1,9 @@ +"""服务端路由模块。 + +按功能拆分为: +- system: 系统管理 (/api/system/*) +- task: 任务执行 (/api/task/*) +- game: 游戏状态查询 (/api/game/*, /api/expedition/status, /api/build/status) +- ops: 操作端点 (/api/expedition/check, /api/build/*, /api/reward/*, /api/cook, /api/repair/*, /api/destroy) +- health: 健康检查 (/api/health) +""" diff --git a/autowsgr/server/routes/game.py b/autowsgr/server/routes/game.py new file mode 100644 index 00000000..d770b39a --- /dev/null +++ b/autowsgr/server/routes/game.py @@ -0,0 +1,113 @@ +"""游戏状态查询路由 — /api/game/*, /api/expedition/status, /api/build/status""" + +from __future__ import annotations + +import asyncio + +from fastapi import APIRouter, HTTPException + +from autowsgr.infra.logger import get_logger +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 + + +_log = get_logger('server') + +router = APIRouter(tags=['game']) + + +@router.get('/api/game/acquisition', response_model=ApiResponse) +async def game_acquisition(): + """从出征面板截图 OCR 识别今日舰船 (X/500) 与战利品 (X/50) 获取数量。 + + 仅在空闲时可用 (需要控制画面导航到出征面板)。 + """ + try: + ctx = get_context() + 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.ui.map.page import MapPage + + def _recognize() -> dict[str, int | None]: + map_page = MapPage(ctx) + counts = map_page.get_acquisition_counts() + return { + 'ship_count': counts.ship_count, + 'ship_max': counts.ship_max, + 'loot_count': counts.loot_count, + 'loot_max': counts.loot_max, + } + + try: + data = await asyncio.to_thread(_recognize) + return ApiResponse(success=True, data=data, message='获取数量识别完成') + except Exception as e: + _log.opt(exception=True).warning('[API] 获取数量识别失败: {}', e) + return ApiResponse(success=False, error=str(e)) + + +@router.get('/api/game/context', response_model=ApiResponse) +async def game_context_info(): + """返回当前游戏上下文中的运行时状态。 + + 包含资源、舰队、远征、建造等完整游戏状态数据。 + 不需要截图或画面操作,直接读取内存中的状态。 + """ + try: + ctx = get_context() + except RuntimeError as e: + raise HTTPException(status_code=503, detail=str(e)) from e + + return ApiResponse( + success=True, + data={ + 'dropped_ship_count': ctx.dropped_ship_count, + 'dropped_loot_count': ctx.dropped_loot_count, + 'quick_repair_used': ctx.quick_repair_used, + 'current_page': ctx.current_page, + 'resources': serialize_resources(ctx.resources), + 'fleets': [serialize_fleet(f) for f in ctx.fleets], + 'expeditions': serialize_expedition_queue(ctx.expeditions), + 'build_queue': serialize_build_queue(ctx.build_queue), + }, + ) + + +@router.get('/api/expedition/status', response_model=ApiResponse) +async def expedition_status(): + """查询远征槽位状态(4 个槽位的章节、节点、剩余时间等)。""" + try: + ctx = get_context() + except RuntimeError as e: + raise HTTPException(status_code=503, detail=str(e)) from e + + return ApiResponse( + success=True, + data=serialize_expedition_queue(ctx.expeditions), + ) + + +@router.get('/api/build/status', response_model=ApiResponse) +async def build_status(): + """查询建造队列状态。""" + try: + ctx = get_context() + except RuntimeError as e: + raise HTTPException(status_code=503, detail=str(e)) from e + + return ApiResponse( + success=True, + data=serialize_build_queue(ctx.build_queue), + ) diff --git a/autowsgr/server/routes/health.py b/autowsgr/server/routes/health.py new file mode 100644 index 00000000..b18d65df --- /dev/null +++ b/autowsgr/server/routes/health.py @@ -0,0 +1,39 @@ +"""健康检查路由 — /api/health""" + +from __future__ import annotations + +import time + +from fastapi import APIRouter + +from autowsgr.server.schemas import ApiResponse +from autowsgr.server.task_manager import task_manager + +from .. import main as _main + + +router = APIRouter(tags=['health']) + +_server_start_time = time.monotonic() + + +@router.get('/api/health', response_model=ApiResponse) +async def health_check(): + """健康检查端点。""" + uptime = int(time.monotonic() - _server_start_time) + task_info = None + if task_manager.current_task and task_manager.is_running: + task_info = { + 'task_id': task_manager.current_task.task_id, + 'status': task_manager.current_task.status.value, + } + + return ApiResponse( + success=True, + data={ + 'status': 'ok', + 'uptime_seconds': uptime, + 'emulator_connected': _main._ctx is not None, + 'current_task': task_info, + }, + ) diff --git a/autowsgr/server/routes/ops.py b/autowsgr/server/routes/ops.py new file mode 100644 index 00000000..a0960643 --- /dev/null +++ b/autowsgr/server/routes/ops.py @@ -0,0 +1,237 @@ +"""操作端点路由 — 远征收取、建造、奖励、烹饪、修理、解装。""" + +from __future__ import annotations + +import asyncio + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel + +from autowsgr.infra.logger import get_logger +from autowsgr.server.schemas import ApiResponse +from autowsgr.server.task_manager import task_manager + +from ..main import get_context + + +_log = get_logger('server') + +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) +async def expedition_check(): + """检查并收取已完成的远征。""" + 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: + result = await asyncio.to_thread(collect_expedition, ctx) + return ApiResponse( + success=True, + data={'collected': result}, + message='远征检查完成', + ) + except Exception as e: + _log.opt(exception=True).warning('[API] 远征检查失败: {}', e) + return ApiResponse(success=False, error=str(e)) + + +# ── 建造操作 ── + + +class BuildStartRequest(BaseModel): + """建造请求。""" + + fuel: int = 30 + ammo: int = 30 + steel: int = 30 + bauxite: int = 30 + build_type: str = 'ship' + allow_fast_build: bool = False + + +@router.post('/api/build/collect', response_model=ApiResponse) +async def build_collect(): + """收取已完成的建造。""" + 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: + count = await asyncio.to_thread(collect_built_ships, ctx) + return ApiResponse(success=True, data={'collected': count}, message=f'收取了 {count} 艘') + except Exception as e: + _log.opt(exception=True).warning('[API] 收取建造失败: {}', e) + return ApiResponse(success=False, error=str(e)) + + +@router.post('/api/build/start', response_model=ApiResponse) +async def build_start(request: BuildStartRequest): + """开始建造。""" + 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( + fuel=request.fuel, + ammo=request.ammo, + steel=request.steel, + bauxite=request.bauxite, + ) + + try: + await asyncio.to_thread( + build_ship, + ctx, + recipe=recipe, + build_type=request.build_type, + allow_fast_build=request.allow_fast_build, + ) + return ApiResponse(success=True, message='建造已开始') + except Exception as e: + _log.opt(exception=True).warning('[API] 建造失败: {}', e) + return ApiResponse(success=False, error=str(e)) + + +# ── 任务奖励 ── + + +@router.post('/api/reward/collect', response_model=ApiResponse) +async def reward_collect(): + """收取任务奖励。""" + 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: + collected = await asyncio.to_thread(collect_rewards, ctx) + return ApiResponse(success=True, data={'collected': collected}, message='奖励收取完成') + except Exception as e: + _log.opt(exception=True).warning('[API] 收取奖励失败: {}', e) + return ApiResponse(success=False, error=str(e)) + + +# ── 食堂烹饪 ── + + +class CookRequest(BaseModel): + """烹饪请求。""" + + position: int = 1 + force_cook: bool = False + + +@router.post('/api/cook', response_model=ApiResponse) +async def cook_action(request: CookRequest): + """食堂烹饪。""" + 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: + result = await asyncio.to_thread( + cook, ctx, position=request.position, force_cook=request.force_cook + ) + return ApiResponse(success=True, data={'cooked': result}, message='烹饪完成') + except Exception as e: + _log.opt(exception=True).warning('[API] 烹饪失败: {}', e) + return ApiResponse(success=False, error=str(e)) + + +# ── 浴室修理 ── + + +@router.post('/api/repair/bath', response_model=ApiResponse) +async def repair_bath(): + """浴室修理。""" + 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: + await asyncio.to_thread(repair_in_bath, ctx) + return ApiResponse(success=True, message='浴室修理完成') + except Exception as e: + _log.opt(exception=True).warning('[API] 浴室修理失败: {}', e) + return ApiResponse(success=False, error=str(e)) + + +# ── 解装 / 解体 ── + + +class DestroyRequest(BaseModel): + """解装请求。""" + + ship_types: list[str] | None = None + remove_equipment: bool = True + + +@router.post('/api/destroy', response_model=ApiResponse) +async def destroy_action(request: DestroyRequest): + """解装/解体舰船。""" + 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 + + ship_types = None + if request.ship_types: + ship_types = [ShipType(t) for t in request.ship_types] + + try: + await asyncio.to_thread( + destroy_ships, + ctx, + ship_types=ship_types, + remove_equipment=request.remove_equipment, + ) + return ApiResponse(success=True, message='解装完成') + except Exception as e: + _log.opt(exception=True).warning('[API] 解装失败: {}', e) + return ApiResponse(success=False, error=str(e)) diff --git a/autowsgr/server/routes/system.py b/autowsgr/server/routes/system.py new file mode 100644 index 00000000..17c26146 --- /dev/null +++ b/autowsgr/server/routes/system.py @@ -0,0 +1,76 @@ +"""系统管理路由 — /api/system/*""" + +from __future__ import annotations + +from fastapi import APIRouter +from pydantic import BaseModel + +from autowsgr.infra.logger import get_logger +from autowsgr.server.schemas import ApiResponse +from autowsgr.server.task_manager import task_manager + +from .. import main as _main + + +_log = get_logger('server') + +router = APIRouter(prefix='/api/system', tags=['system']) + + +class SystemStartRequest(BaseModel): + """系统启动请求。""" + + config_path: str | None = None + + +@router.post('/start', response_model=ApiResponse) +async def system_start(request: SystemStartRequest): + """启动系统 (连接模拟器、启动游戏)。""" + if _main._ctx is not None: + return ApiResponse(success=True, message='系统已启动') + + try: + from autowsgr.scheduler import launch + + config_path = request.config_path or 'usersettings.yaml' + _log.info('[System] 正在启动, 配置: {}', config_path) + _main._ctx = launch(config_path) + _log.info('[System] 启动成功') + + return ApiResponse(success=True, message='系统启动成功') + + except Exception as e: + _log.error('[System] 启动失败: {}', e) + return ApiResponse(success=False, error=str(e)) + + +@router.post('/stop', response_model=ApiResponse) +async def system_stop(): + """停止系统。""" + if _main._ctx is None: + return ApiResponse(success=True, message='系统未运行') + + if task_manager.is_running: + task_manager.stop_task() + + _main._ctx = None + _log.info('[System] 系统已停止') + return ApiResponse(success=True, message='系统已停止') + + +@router.get('/status', response_model=ApiResponse) +async def system_status(): + """获取系统状态。""" + return ApiResponse( + success=True, + data={ + 'status': task_manager.current_task.status.value + if task_manager.current_task + else 'idle', + 'emulator_connected': _main._ctx is not None, + 'game_running': _main._ctx is not None, + 'current_task': task_manager.current_task.task_id + if task_manager.current_task + else None, + }, + ) diff --git a/autowsgr/server/routes/task.py b/autowsgr/server/routes/task.py new file mode 100644 index 00000000..b75c7b07 --- /dev/null +++ b/autowsgr/server/routes/task.py @@ -0,0 +1,290 @@ +"""任务执行路由 — /api/task/*""" + +from __future__ import annotations + +from typing import Annotated, Any + +from fastapi import APIRouter, HTTPException +from pydantic import Discriminator + +from autowsgr.infra.logger import get_logger +from autowsgr.server.schemas import ( + ApiResponse, + CampaignRequest, + DecisiveRequest, + EventFightRequest, + ExerciseRequest, + NormalFightRequest, +) +from autowsgr.server.serializers import build_combat_plan, convert_combat_result +from autowsgr.server.task_manager import task_manager + +from ..main import get_context + + +_log = get_logger('server') + +router = APIRouter(prefix='/api/task', tags=['task']) + + +TaskRequestUnion = Annotated[ + NormalFightRequest | EventFightRequest | CampaignRequest | ExerciseRequest | DecisiveRequest, + Discriminator('type'), +] + + +@router.post('/start', response_model=ApiResponse) +async def task_start(request: TaskRequestUnion): # type: ignore + """启动任务 (异步执行,立即返回)。""" + if task_manager.is_running: + raise HTTPException(status_code=409, detail='已有任务正在运行') + + try: + ctx = get_context() + except RuntimeError as e: + raise HTTPException(status_code=503, detail=str(e)) + + 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='未知的任务类型') + + +@router.post('/stop', response_model=ApiResponse) +async def task_stop(): + """停止当前任务。""" + if not task_manager.is_running: + return ApiResponse(success=True, message='没有正在运行的任务') + + success = task_manager.stop_task() + if success: + return ApiResponse( + success=True, + data={ + 'task_id': task_manager.current_task.task_id, + 'status': 'stopped', + }, + message='已请求停止任务', + ) + else: + return ApiResponse(success=False, error='停止失败') + + +@router.get('/status', response_model=ApiResponse) +async def task_status(): + """查询当前任务状态。""" + status = task_manager.get_status() + return ApiResponse(success=True, data=status) + + +# ═══════════════════════════════════════════════════════════════════════════════ +# 任务启动辅助 +# ═══════════════════════════════════════════════════════════════════════════════ + + +async def _start_normal_fight(ctx: Any, request: NormalFightRequest) -> ApiResponse: + """启动常规战任务。""" + from autowsgr.combat import CombatPlan + from autowsgr.ops import run_normal_fight + + def executor(task_info: Any) -> list[dict[str, Any]]: + results = [] + + if request.plan_id: + plan = CombatPlan.from_yaml(request.plan_id) + elif request.plan: + plan = build_combat_plan(request.plan) + else: + raise ValueError('必须提供 plan 或 plan_id') + + for i in range(request.times): + if task_manager.should_stop(): + break + + task_manager.update_progress(current_round=i + 1) + _log.info('[Task] 常规战第 {}/{} 轮', i + 1, request.times) + + try: + result = run_normal_fight(ctx, plan, times=1)[0] + results.append(convert_combat_result(result, i + 1)) + task_manager.add_result(results[-1]) + except Exception as e: + _log.error('[Task] 第 {} 轮失败: {}', i + 1, e) + results.append({'round': i + 1, 'success': False, 'error': str(e)}) + + return results + + task_id = task_manager.start_task( + task_type='normal_fight', + total_rounds=request.times, + executor=executor, + ) + + return ApiResponse( + success=True, + data={'task_id': task_id, 'status': 'running'}, + message='任务已启动', + ) + + +async def _start_event_fight(ctx: Any, request: EventFightRequest) -> ApiResponse: + """启动活动战任务。""" + from autowsgr.combat import CombatPlan + from autowsgr.ops import run_event_fight + + def executor(task_info: Any) -> list[dict[str, Any]]: + results = [] + + if request.plan_id: + plan = CombatPlan.from_yaml(request.plan_id) + elif request.plan: + plan = build_combat_plan(request.plan) + else: + raise ValueError('必须提供 plan 或 plan_id') + + fleet_id = request.fleet_id or plan.fleet_id + + for i in range(request.times): + if task_manager.should_stop(): + break + + task_manager.update_progress(current_round=i + 1) + _log.info('[Task] 活动战第 {}/{} 轮', i + 1, request.times) + + try: + result = run_event_fight(ctx, plan, times=1, fleet_id=fleet_id)[0] + results.append(convert_combat_result(result, i + 1)) + task_manager.add_result(results[-1]) + except Exception as e: + _log.error('[Task] 第 {} 轮失败: {}', i + 1, e) + results.append({'round': i + 1, 'success': False, 'error': str(e)}) + + return results + + task_id = task_manager.start_task( + task_type='event_fight', + total_rounds=request.times, + executor=executor, + ) + + return ApiResponse( + success=True, + data={'task_id': task_id, 'status': 'running'}, + message='任务已启动', + ) + + +async def _start_campaign(ctx: Any, request: CampaignRequest) -> ApiResponse: + """启动战役任务。""" + from autowsgr.ops import CampaignRunner + + def executor(task_info: Any) -> list[dict[str, Any]]: + runner = CampaignRunner( + ctx, + campaign_name=request.campaign_name, + times=request.times, + ) + + results = [] + for i in range(request.times): + if task_manager.should_stop(): + break + + task_manager.update_progress(current_round=i + 1) + _log.info('[Task] 战役第 {}/{} 轮', i + 1, request.times) + + try: + result = runner.run() + for j, r in enumerate(result): + converted = convert_combat_result(r, i * len(result) + j + 1) + results.append(converted) + task_manager.add_result(converted) + except Exception as e: + _log.error('[Task] 第 {} 轮失败: {}', i + 1, e) + results.append({'round': i + 1, 'success': False, 'error': str(e)}) + + return results + + task_id = task_manager.start_task( + task_type='campaign', + total_rounds=request.times, + executor=executor, + ) + + return ApiResponse( + success=True, + data={'task_id': task_id, 'status': 'running'}, + message='任务已启动', + ) + + +async def _start_exercise(ctx: Any, request: ExerciseRequest) -> ApiResponse: + """启动演习任务。""" + from autowsgr.ops import ExerciseRunner + + def executor(task_info: Any) -> list[dict[str, Any]]: + runner = ExerciseRunner(ctx, fleet_id=request.fleet_id) + task_manager.update_progress(current_round=1, current_node='演习') + + try: + results = runner.run() + return [convert_combat_result(r, i + 1) for i, r in enumerate(results)] + except Exception as e: + return [{'round': 1, 'success': False, 'error': str(e)}] + + task_id = task_manager.start_task( + task_type='exercise', + total_rounds=1, + executor=executor, + ) + + return ApiResponse( + success=True, + data={'task_id': task_id, 'status': 'running'}, + message='任务已启动', + ) + + +async def _start_decisive(ctx: Any, request: DecisiveRequest) -> ApiResponse: + """启动决战任务。""" + from autowsgr.infra import DecisiveConfig + from autowsgr.ops import DecisiveController + + def executor(task_info: Any) -> list[dict[str, Any]]: + config = DecisiveConfig( + chapter=request.chapter, + level1=request.level1, + level2=request.level2, + flagship_priority=request.flagship_priority, + ) + + controller = DecisiveController(ctx, config) + task_manager.update_progress(current_round=1, current_node='决战') + + try: + result = controller.run() + return [{'round': 1, 'success': True, 'result': result.value}] + except Exception as e: + return [{'round': 1, 'success': False, 'error': str(e)}] + + task_id = task_manager.start_task( + task_type='decisive', + total_rounds=1, + executor=executor, + ) + + return ApiResponse( + success=True, + data={'task_id': task_id, 'status': 'running'}, + message='任务已启动', + ) diff --git a/autowsgr/server/serializers.py b/autowsgr/server/serializers.py new file mode 100644 index 00000000..5f52f51e --- /dev/null +++ b/autowsgr/server/serializers.py @@ -0,0 +1,169 @@ +"""游戏对象序列化辅助函数。 + +将内部数据模型 (Resources, Fleet, Ship, ExpeditionQueue, BuildQueue, CombatResult) +转换为 JSON 可序列化的 dict,供 API 端点使用。 +""" + +from __future__ import annotations + +from typing import Any + + +def serialize_resources(resources: Any) -> dict[str, int]: + """序列化 Resources 对象。""" + return { + 'fuel': resources.fuel, + 'ammo': resources.ammo, + 'steel': resources.steel, + 'aluminum': resources.aluminum, + 'diamond': resources.diamond, + 'fast_repair': resources.fast_repair, + 'fast_build': resources.fast_build, + 'ship_blueprint': resources.ship_blueprint, + 'equipment_blueprint': resources.equipment_blueprint, + } + + +def serialize_ship(ship: Any) -> dict[str, Any]: + """序列化 Ship 对象。""" + return { + 'name': ship.name, + 'ship_type': ship.ship_type.value if ship.ship_type else None, + 'level': ship.level, + 'health': ship.health, + 'max_health': ship.max_health, + 'damage_state': ship.damage_state.value, + 'locked': ship.locked, + } + + +def serialize_fleet(fleet: Any) -> dict[str, Any]: + """序列化 Fleet 对象。""" + return { + 'fleet_id': fleet.fleet_id, + 'ships': [serialize_ship(s) for s in fleet.ships], + 'size': fleet.size, + 'has_severely_damaged': fleet.has_severely_damaged, + } + + +def serialize_expedition_queue(expeditions: Any) -> dict[str, Any]: + """序列化 ExpeditionQueue 对象。""" + return { + 'slots': [ + { + 'chapter': e.chapter, + 'node': e.node, + 'fleet_id': e.fleet.fleet_id if e.fleet else None, + 'is_active': e.is_active, + 'remaining_seconds': e.remaining_seconds, + } + for e in expeditions.expeditions + ], + 'active_count': expeditions.active_count, + 'idle_count': expeditions.idle_count, + } + + +def serialize_build_queue(build_queue: Any) -> dict[str, Any]: + """序列化 BuildQueue 对象。""" + return { + 'slots': [ + { + 'occupied': s.occupied, + 'remaining_seconds': s.remaining_seconds, + 'is_complete': s.is_complete, + 'is_idle': s.is_idle, + } + for s in build_queue.slots + ], + 'idle_count': build_queue.idle_count, + 'complete_count': build_queue.complete_count, + } + + +def convert_combat_result(result: Any, round_num: int) -> dict[str, Any]: + """转换 CombatResult 为响应格式。""" + nodes: list[str] = [] + mvp = None + grade = None + enemies_per_node: dict[str, dict[str, int]] = {} + events: list[dict[str, Any]] = [] + + if result.history: + for event in result.history.events: + if event.node and event.node not in nodes: + nodes.append(event.node) + + fight_results = result.history.get_fight_results() + if isinstance(fight_results, dict): + for fr in fight_results.values(): + if fr.mvp and fr.mvp > 0 and mvp is None: + mvp = f'位置{fr.mvp}' + if fr.grade and grade is None: + grade = fr.grade + elif isinstance(fight_results, list): + for fr in fight_results: + if fr.mvp and fr.mvp > 0 and mvp is None: + mvp = f'位置{fr.mvp}' + if fr.grade and grade is None: + grade = fr.grade + + for event in result.history.events: + ev: dict[str, Any] = { + 'type': event.event_type.name, + 'node': event.node, + 'action': event.action, + } + if event.result: + ev['result'] = event.result + if event.enemies: + ev['enemies'] = event.enemies + if event.node: + enemies_per_node[event.node] = event.enemies + if event.ship_stats: + ev['ship_stats'] = [s.value for s in event.ship_stats] + events.append(ev) + + return { + 'round': round_num, + 'success': result.flag.value == 'success', + 'nodes': nodes, + 'mvp': mvp, + 'grade': grade, + 'ship_damage': [s.value for s in result.ship_stats] if result.ship_stats else [], + 'node_count': result.node_count, + 'enemies': enemies_per_node, + 'events': events, + } + + +def build_combat_plan(request: Any) -> Any: + """从请求构建 CombatPlan 对象。""" + from autowsgr.combat import CombatPlan, NodeDecision + from autowsgr.types import Formation, RepairMode + + def _build_node_decision(node_req: Any) -> NodeDecision: + return NodeDecision( + formation=Formation(node_req.formation), + night=node_req.night, + proceed=node_req.proceed, + proceed_stop=[RepairMode(r) for r in node_req.proceed_stop], + detour=node_req.detour, + ) + + node_args = {k: _build_node_decision(v) for k, v in request.node_args.items()} + + return CombatPlan( + name=request.name, + mode=request.mode, + chapter=request.chapter, + map_id=request.map, + fleet_id=request.fleet_id, + fleet=request.fleet, + repair_mode=[RepairMode(r) for r in request.repair_mode], + fight_condition=request.fight_condition, + selected_nodes=request.selected_nodes, + default_node=_build_node_decision(request.node_defaults), + nodes=node_args, + )