From 0a828af4df988e2e6388a92528c76c2c48f3c3cc Mon Sep 17 00:00:00 2001 From: Latte Date: Sat, 27 Jun 2026 20:18:59 +0200 Subject: [PATCH] =?UTF-8?q?Phase=204:=20fleet=20&=20UI=20commands=20?= =?UTF-8?q?=E2=80=94=20read=20+=20guarded=20writes=20(#27,=20#28)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fleet: get_fleet (read, handles not-in-fleet); guarded invite_to_fleet and update_fleet. UI: guarded set_waypoint and open_window (market/info/contract). All writes go through write-safety. Closes #27, #28. 128 tests, coverage 89%. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/familiar/fleet.py | 85 ++++++++++++++++++++++++++++++++++++++++++ src/familiar/server.py | 6 +++ src/familiar/ui.py | 72 +++++++++++++++++++++++++++++++++++ tests/test_fleet_ui.py | 85 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 248 insertions(+) create mode 100644 src/familiar/fleet.py create mode 100644 src/familiar/ui.py create mode 100644 tests/test_fleet_ui.py diff --git a/src/familiar/fleet.py b/src/familiar/fleet.py new file mode 100644 index 0000000..cae1c8c --- /dev/null +++ b/src/familiar/fleet.py @@ -0,0 +1,85 @@ +"""Fleet: read current fleet plus guarded fleet-command writes.""" + +from __future__ import annotations + +from typing import Any + +from fastmcp import FastMCP + +from . import auth, writes +from .charutil import run_per_character +from .esi import EsiError, client + + +async def get_fleet(character: str | int | None = None) -> dict[str, Any]: + """Current fleet (settings + member count) for one character or all.""" + + async def fetch(token: str, cid: int) -> dict[str, Any]: + try: + fleet = await client().get(f"/characters/{cid}/fleet/", token=token) + except EsiError as exc: + if exc.status == 404: + return {"in_fleet": False} + raise + fleet_id = fleet["fleet_id"] + members = await client().get(f"/fleets/{fleet_id}/members/", token=token) or [] + return { + "in_fleet": True, + "fleet_id": fleet_id, + "role": fleet.get("role"), + "member_count": len(members), + } + + return await run_per_character(character, fetch) + + +async def invite_to_fleet( + character: str | int, + fleet_id: int, + invitee_id: int, + role: str = "squad_member", + confirm: bool = False, +) -> dict[str, Any]: + """Invite a character to a fleet. WRITE — guarded by write-safety.""" + cid = auth.resolve_character(character) + invitation = {"character_id": invitee_id, "role": role} + + async def execute() -> Any: + token, _ = await auth.get_auth(cid) + return await client().post(f"/fleets/{fleet_id}/members/", json=invitation, token=token) + + return await writes.guard( + "invite_to_fleet", + {"character_id": cid, "fleet_id": fleet_id, "invitee_id": invitee_id, "role": role}, + confirm, + execute, + ) + + +async def update_fleet( + character: str | int, + fleet_id: int, + motd: str | None = None, + is_free_move: bool | None = None, + confirm: bool = False, +) -> dict[str, Any]: + """Update fleet MOTD / free-move setting. WRITE — guarded by write-safety.""" + cid = auth.resolve_character(character) + body = {k: v for k, v in {"motd": motd, "is_free_move": is_free_move}.items() if v is not None} + + async def execute() -> Any: + token, _ = await auth.get_auth(cid) + return await client().put(f"/fleets/{fleet_id}/", json=body, token=token) + + return await writes.guard( + "update_fleet", {"character_id": cid, "fleet_id": fleet_id, **body}, confirm, execute + ) + + +TOOLS = [get_fleet, invite_to_fleet, update_fleet] + + +def register(mcp: FastMCP) -> None: + """Register the fleet tools onto the given server.""" + for fn in TOOLS: + mcp.tool(fn) diff --git a/src/familiar/server.py b/src/familiar/server.py index e5082e2..f6e66ef 100644 --- a/src/familiar/server.py +++ b/src/familiar/server.py @@ -19,6 +19,7 @@ from . import ( corp, corp_economy, fittings, + fleet, industry, killmails, market, @@ -26,6 +27,7 @@ from . import ( profile, structures, trade, + ui, universe, writes, ) @@ -93,3 +95,7 @@ comms.register(mcp) # Phase 4: contacts & fittings (read + guarded writes). contacts.register(mcp) fittings.register(mcp) + +# Phase 4: fleet (read + guarded writes) and UI commands (guarded writes). +fleet.register(mcp) +ui.register(mcp) diff --git a/src/familiar/ui.py b/src/familiar/ui.py new file mode 100644 index 0000000..ac19ba7 --- /dev/null +++ b/src/familiar/ui.py @@ -0,0 +1,72 @@ +"""In-game UI commands (waypoints, open windows) — all guarded writes. + +These drive the EVE client that is logged in as the given character. They are +commands, not reads, so they always go through write-safety. +""" + +from __future__ import annotations + +from typing import Any + +from fastmcp import FastMCP + +from . import auth, writes +from .esi import client + +# open-window kind -> (endpoint suffix, query parameter name) +_WINDOWS = { + "market": ("marketdetails", "type_id"), + "information": ("information", "target_id"), + "contract": ("contract", "contract_id"), +} + + +async def set_waypoint( + character: str | int, + destination_id: int, + add_to_beginning: bool = False, + clear_other_waypoints: bool = False, + confirm: bool = False, +) -> dict[str, Any]: + """Set/append an autopilot waypoint in the client. WRITE — guarded.""" + cid = auth.resolve_character(character) + params = { + "destination_id": destination_id, + "add_to_beginning": add_to_beginning, + "clear_other_waypoints": clear_other_waypoints, + } + + async def execute() -> Any: + token, _ = await auth.get_auth(cid) + return await client().post("/ui/autopilot/waypoint/", params=params, token=token) + + return await writes.guard("set_waypoint", {"character_id": cid, **params}, confirm, execute) + + +async def open_window( + character: str | int, kind: str, target_id: int, confirm: bool = False +) -> dict[str, Any]: + """Open an in-game window (kind: market, information, contract). WRITE — guarded.""" + cid = auth.resolve_character(character) + if kind not in _WINDOWS: + raise ValueError(f"Unknown window kind {kind!r}. Use one of {sorted(_WINDOWS)}.") + suffix, param = _WINDOWS[kind] + + async def execute() -> Any: + token, _ = await auth.get_auth(cid) + return await client().post( + f"/ui/openwindow/{suffix}/", params={param: target_id}, token=token + ) + + return await writes.guard( + "open_window", {"character_id": cid, "kind": kind, "target_id": target_id}, confirm, execute + ) + + +TOOLS = [set_waypoint, open_window] + + +def register(mcp: FastMCP) -> None: + """Register the UI command tools onto the given server.""" + for fn in TOOLS: + mcp.tool(fn) diff --git a/tests/test_fleet_ui.py b/tests/test_fleet_ui.py new file mode 100644 index 0000000..090ff25 --- /dev/null +++ b/tests/test_fleet_ui.py @@ -0,0 +1,85 @@ +"""Fleet (read + write) and UI command tools.""" + +import asyncio + +import httpx +import pytest + +from conftest import route +from familiar import auth, fleet, ui +from familiar.server import mcp + +CHARS = [{"character_id": 99, "character_name": "Alpha", "scopes": []}] + + +@pytest.fixture +def fake_auth(monkeypatch): + monkeypatch.setattr(auth, "list_characters", lambda: list(CHARS)) + + async def get_auth(cid, **kw): + return f"token-{cid}", cid + + monkeypatch.setattr(auth, "get_auth", get_auth) + monkeypatch.setattr(auth, "resolve_character", lambda i: 99) + return CHARS + + +@pytest.fixture +def writes_on(tmp_path, monkeypatch): + monkeypatch.setenv("FAMILIAR_DATA_DIR", str(tmp_path)) + monkeypatch.setenv("FAMILIAR_ENABLE_WRITES", "true") + + +def test_get_fleet_when_in_fleet(mock_esi, fake_auth): + fixtures = { + "/characters/99/fleet/": {"fleet_id": 123, "role": "fleet_commander"}, + "/members/": [{"character_id": 1}, {"character_id": 2}], + } + mock_esi(lambda r: route(r, fixtures)) + res = asyncio.run(fleet.get_fleet()) + entry = res["characters"][0] + assert entry["in_fleet"] is True + assert entry["member_count"] == 2 + + +def test_get_fleet_not_in_fleet(mock_esi, fake_auth): + mock_esi(lambda r: httpx.Response(404, json={"error": "not in fleet"})) + res = asyncio.run(fleet.get_fleet()) + assert res["characters"][0]["in_fleet"] is False + + +def test_invite_dry_run(mock_esi, fake_auth): + posted = [] + mock_esi(lambda r: (posted.append(r), httpx.Response(204, content=b""))[1]) + res = asyncio.run(fleet.invite_to_fleet(99, 123, 5)) + assert res["dry_run"] is True + assert not posted + + +def test_update_fleet_executes(mock_esi, fake_auth, writes_on): + mock_esi(lambda r: httpx.Response(204, content=b"")) + res = asyncio.run(fleet.update_fleet(99, 123, motd="form up", confirm=True)) + assert res["executed"] is True + + +def test_set_waypoint_executes(mock_esi, fake_auth, writes_on): + mock_esi(lambda r: httpx.Response(204, content=b"")) + res = asyncio.run(ui.set_waypoint(99, 30000142, confirm=True)) + assert res["executed"] is True + assert res["action"] == "set_waypoint" + + +def test_open_window_validates_kind(mock_esi, fake_auth, writes_on): + mock_esi(lambda r: httpx.Response(204, content=b"")) + with pytest.raises(ValueError): + asyncio.run(ui.open_window(99, "banana", 1, confirm=True)) + + +def test_open_window_dry_run(mock_esi, fake_auth): + res = asyncio.run(ui.open_window(99, "market", 34)) + assert res["dry_run"] is True + + +def test_fleet_ui_tools_registered(): + tools = {t.name for t in asyncio.run(mcp.list_tools())} + assert {"get_fleet", "invite_to_fleet", "update_fleet", "set_waypoint", "open_window"} <= tools