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) <noreply@anthropic.com>
This commit is contained in:
4 files changed
+248
No files matched your search
@@ -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)
|
||||||
@@ -19,6 +19,7 @@ from . import (
|
|||||||
corp,
|
corp,
|
||||||
corp_economy,
|
corp_economy,
|
||||||
fittings,
|
fittings,
|
||||||
|
fleet,
|
||||||
industry,
|
industry,
|
||||||
killmails,
|
killmails,
|
||||||
market,
|
market,
|
||||||
@@ -26,6 +27,7 @@ from . import (
|
|||||||
profile,
|
profile,
|
||||||
structures,
|
structures,
|
||||||
trade,
|
trade,
|
||||||
|
ui,
|
||||||
universe,
|
universe,
|
||||||
writes,
|
writes,
|
||||||
)
|
)
|
||||||
@@ -93,3 +95,7 @@ comms.register(mcp)
|
|||||||
# Phase 4: contacts & fittings (read + guarded writes).
|
# Phase 4: contacts & fittings (read + guarded writes).
|
||||||
contacts.register(mcp)
|
contacts.register(mcp)
|
||||||
fittings.register(mcp)
|
fittings.register(mcp)
|
||||||
|
|
||||||
|
# Phase 4: fleet (read + guarded writes) and UI commands (guarded writes).
|
||||||
|
fleet.register(mcp)
|
||||||
|
ui.register(mcp)
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
Reference in new issue
Block a user