diff --git a/.claude/settings.json b/.claude/settings.json index eea16703..1261a73a 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -1,4 +1,17 @@ { + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "cmd=$(jq -r '.tool_input.command'); if echo \"$cmd\" | grep -qE '\\b(python3?|pytest)\\b' && ! echo \"$cmd\" | grep -qE '\\buv\\s+run\\b'; then echo '{\"hookSpecificOutput\": {\"hookEventName\": \"PreToolUse\", \"additionalContext\": \"Reminder: use uv run pytest / uv run python, not bare pytest/python (AGENTS.md gotcha).\"}}'; fi" + } + ] + } + ] + }, "permissions": { "allow": [ "WebFetch(domain:starsautohost.org)", @@ -8,7 +21,11 @@ "Bash(gsutil ls *)", "Bash(gh run *)", "Bash(grep *)", - "Bash(npm install *)" + "Bash(npm install *)", + "Bash(scripts/rag-index)", + "Bash(git add *)", + "Bash(git commit *)", + "Bash(git status *)" ] } } diff --git a/AGENTS.md b/AGENTS.md index de5b11df..d6a9f0ba 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -143,10 +143,18 @@ The original Stars! (1995) game mechanics are extensively documented: | Service | Command | Port | Notes | |---------|---------|------|-------| -| Backend API | `cd backend && STORAGE_BACKEND=memory uv run uvicorn openstars.server.main:app --host 127.0.0.1 --port 8080` | 8080 | Use `STORAGE_BACKEND=memory` for dev (no database needed) | +| Backend API | `cd backend && STORAGE_BACKEND=memory GAME_DIRECTORY_BACKEND=memory uv run uvicorn openstars.server.main:app --host 127.0.0.1 --port 8080` | 8080 | `GAME_DIRECTORY_BACKEND=memory` keeps the directory in-process; no Firebase needed | | Frontend dev server | `cd frontend && npm run dev` | 5173 | Vite proxies `/api/*` to `http://localhost:8080` | +| Firebase emulators | `docker compose up firebase-emulators` | 8085 (Firestore), 9099 (Auth), 4001 (UI) | Required when running `GAME_DIRECTORY_BACKEND=firestore`; emulator UI at http://localhost:4001 | +| Full local stack | `docker compose up` | 3000, 8080, 8085, 9099 | Starts emulators + backend (firestore mode) + frontend | -No databases, caches, or external infrastructure required for local development. The backend stores game state in memory (or local filesystem with `STORAGE_BACKEND=local`). +No databases or external infrastructure required for unit/integration tests. For end-to-end dev with realtime notifications, run `docker compose up` to start Firebase emulators alongside the backend and frontend. + +Backend env vars for full local stack (set automatically by docker-compose): +- `GAME_DIRECTORY_BACKEND=firestore` — use Firestore via the emulator +- `FIREBASE_PROJECT_ID=openstars-local` +- `FIRESTORE_EMULATOR_HOST=firebase-emulators:8085` +- `FIREBASE_AUTH_EMULATOR_HOST=firebase-emulators:9099` ### Runtime requirements diff --git a/backend/AGENTS.md b/backend/AGENTS.md index 524b0c6a..19996c54 100644 --- a/backend/AGENTS.md +++ b/backend/AGENTS.md @@ -19,6 +19,8 @@ These notes apply to backend changes under `backend/`. ## Testing +**IMPORTANT: Always use `uv run` — never `python`, `python3`, `pytest`, `python -m pytest`, or `python3 -m pytest` directly.** + - Backend unit tests: `cd backend && uv run pytest` - Backend integration tests: `./backend/int_tests/run.sh` @@ -32,9 +34,14 @@ These notes apply to backend changes under `backend/`. ## Code Quality +**IMPORTANT: The CI ruff check will fail the PR if these don't pass. Always run both before pushing:** + - Linting: `cd backend && uv run ruff check .` - Format check: `cd backend && uv run ruff format --check .` -- Always run the backend linter & format check at the end of backend implementation work and fix any issues before considering the work complete. +- Auto-fix format issues: `cd backend && uv run ruff format .` +- Auto-fix lint issues: `cd backend && uv run ruff check --fix .` + +Run both checks at the end of every backend implementation task and fix any issues before considering the work complete. ## Style: don't paper over impossible states diff --git a/backend/int_tests/client.py b/backend/int_tests/client.py index d777ff25..e20bb6fe 100644 --- a/backend/int_tests/client.py +++ b/backend/int_tests/client.py @@ -19,7 +19,6 @@ CreateGameResponse, GameDetail, GameListResponse, - ResolveResponse, SubmitCommandsResponse, ) @@ -132,11 +131,6 @@ def get_commands(self, game_id: str) -> dict: self._raise_for_error(r) return r.json() - def resolve(self, game_id: str) -> ResolveResponse: - r = requests.post(f"{self._api}/games/{game_id}/resolve", headers=self._headers()) - self._raise_for_error(r) - return ResolveResponse.model_validate(r.json()) - def select_humanoid_race(self, game_id: str) -> SubmitCommandsResponse: return self.submit_commands( game_id, diff --git a/backend/int_tests/docker-compose.yaml b/backend/int_tests/docker-compose.yaml index 4feb8666..42c7700e 100644 --- a/backend/int_tests/docker-compose.yaml +++ b/backend/int_tests/docker-compose.yaml @@ -1,4 +1,14 @@ services: + firebase-emulators: + image: andreysenov/firebase-tools + command: firebase emulators:start --only auth,firestore --project openstars-local + ports: + - "4001:4001" + - "8085:8085" + - "9099:9099" + volumes: + - ../../firebase:/home/node + backend: build: .. ports: @@ -8,9 +18,15 @@ services: - GAME_DATA_PATH=/data - LOG_LEVEL=DEBUG - LOG_FORMAT=text + - GAME_DIRECTORY_BACKEND=firestore + - FIREBASE_PROJECT_ID=openstars-local + - FIRESTORE_EMULATOR_HOST=firebase-emulators:8085 + - FIREBASE_AUTH_EMULATOR_HOST=firebase-emulators:9099 healthcheck: test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8080/api/v1/health')"] interval: 2s timeout: 5s retries: 10 start_period: 5s + depends_on: + - firebase-emulators diff --git a/backend/int_tests/test_colonisation.py b/backend/int_tests/test_colonisation.py index 7b9bc836..18d76805 100644 --- a/backend/int_tests/test_colonisation.py +++ b/backend/int_tests/test_colonisation.py @@ -29,7 +29,6 @@ def _create_game(self, name: str) -> str: ) client1.select_humanoid_race(game.game_id) client2.select_humanoid_race(game.game_id) - client1.resolve(game.game_id) return game.game_id def _state(self, game_id: str): @@ -39,7 +38,6 @@ def _submit_and_resolve(self, game_id: str, commands): turn = self._state(game_id).turn client1.submit_commands(game_id, turn=turn, commands=commands) client2.submit_commands(game_id, turn=turn, commands=[]) - client1.resolve(game_id) return self._state(game_id) def _resolve_until(self, game_id: str, predicate, max_turns: int = 200): diff --git a/backend/int_tests/test_combat.py b/backend/int_tests/test_combat.py index 26cd7fb8..ad8a93fb 100644 --- a/backend/int_tests/test_combat.py +++ b/backend/int_tests/test_combat.py @@ -28,7 +28,6 @@ def _submit_and_resolve( turn = client1.get_state(game_id).turn client1.submit_commands(game_id, turn=turn, commands=commands_p1 or []) client2.submit_commands(game_id, turn=turn, commands=commands_p2 or []) - client1.resolve(game_id) def _resolve_until(self, game_id: str, predicate, max_turns: int = 60): for _ in range(max_turns): @@ -59,7 +58,6 @@ def test_fleets_meeting_triggers_combat_resolved_event(self): game_id = game.game_id client1.select_humanoid_race(game_id) client2.select_humanoid_race(game_id) - client1.resolve(game_id) state1 = client1.get_state(game_id) state2 = client2.get_state(game_id) diff --git a/backend/int_tests/test_freight.py b/backend/int_tests/test_freight.py index 6d8da07a..be093787 100644 --- a/backend/int_tests/test_freight.py +++ b/backend/int_tests/test_freight.py @@ -37,7 +37,6 @@ def setup_class(cls): cls.game_id = game.game_id client1.select_humanoid_race(cls.game_id) client2.select_humanoid_race(cls.game_id) - client1.resolve(cls.game_id) def _state(self): return client1.get_state(self.game_id) @@ -49,7 +48,6 @@ def _submit_and_resolve(self, commands): turn = self._state().turn client1.submit_commands(self.game_id, turn=turn, commands=commands) client2.submit_commands(self.game_id, turn=turn, commands=[]) - client1.resolve(self.game_id) def _step_turn0_has_small_freighter_with_owner_cargo_fields(self): state = self._state() diff --git a/backend/int_tests/test_game_lifecycle.py b/backend/int_tests/test_game_lifecycle.py index 7ab5b4b0..c2c6bfb1 100644 --- a/backend/int_tests/test_game_lifecycle.py +++ b/backend/int_tests/test_game_lifecycle.py @@ -100,14 +100,11 @@ def test_08_get_commands(self): assert body["turn"] == 0 assert len(body["commands"]) == 1 - # -- 9. Resolve fails if not all submitted -- + # -- 9. Single submit in a 2-player game does not advance the turn -- - def test_09_resolve_fails_not_all_submitted(self): - with pytest.raises(GameAPIError) as exc_info: - client1.resolve(self.game_id) - assert exc_info.value.status_code == 409 - assert exc_info.value.error_code == "TURN_ZERO_INCOMPLETE" - assert PLAYER_2 in exc_info.value.message + def test_09_single_submit_does_not_advance_turn(self): + game = client1.get_game(self.game_id) + assert game.turn == 0, "Turn should not advance until all players submit" # -- 10. Submit commands (player 2: select race) -- @@ -119,12 +116,11 @@ def test_10_submit_commands_player2(self): ) assert result.command_count == 1 - # -- 11. Resolve succeeds -- + # -- 11. Turn auto-resolved when last player submitted -- - def test_11_resolve_turn(self): - result = client1.resolve(self.game_id) - assert result.turn == 1 - assert result.status == "resolved" + def test_11_auto_resolved_when_last_player_submits(self): + state = client1.get_state(self.game_id) + assert state.turn == 1 # -- 12. Verify turn advanced -- @@ -213,12 +209,11 @@ def test_16_submit_production_turn_1(self): result_other = client2.submit_commands(self.game_id, turn=1, commands=[]) assert result_other.command_count == 0 - # -- 17. Resolve turn 1 -> 2 -- + # -- 17. Turn 1 auto-resolved when both players submitted in test_16 -- - def test_17_resolve_turn_again(self): - result = client1.resolve(self.game_id) - assert result.turn == 2 - assert result.status == "resolved" + def test_17_turn_1_auto_resolved(self): + state = client1.get_state(self.game_id) + assert state.turn == 2 # -- 18. Event envelope contains mining + production code families -- diff --git a/backend/int_tests/test_merge_split.py b/backend/int_tests/test_merge_split.py index d2d625c7..c8af7844 100644 --- a/backend/int_tests/test_merge_split.py +++ b/backend/int_tests/test_merge_split.py @@ -1,5 +1,6 @@ """Integration tests for merge/split fleet commands.""" +import pytest from client import GameClient from openstars.engine.models import ( @@ -32,7 +33,6 @@ def setup_class(cls): cls.game_id = game.game_id client1.select_humanoid_race(cls.game_id) client2.select_humanoid_race(cls.game_id) - client1.resolve(cls.game_id) def _state(self): return client1.get_state(self.game_id) @@ -41,7 +41,6 @@ def _submit_and_resolve(self, commands): turn = self._state().turn client1.submit_commands(self.game_id, turn=turn, commands=commands) client2.submit_commands(self.game_id, turn=turn, commands=[]) - client1.resolve(self.game_id) def test_merge_split_and_tmp_waypoint(self): state = self._state() @@ -100,9 +99,5 @@ def test_invalid_ship_totals_rejected(self): turn = state.turn client1.submit_commands(self.game_id, turn=turn, commands=[bad]) - client2.submit_commands(self.game_id, turn=turn, commands=[]) - try: - client1.resolve(self.game_id) - assert False, "expected resolve error" - except Exception: - pass + with pytest.raises(Exception): + client2.submit_commands(self.game_id, turn=turn, commands=[]) diff --git a/backend/int_tests/test_race_selection.py b/backend/int_tests/test_race_selection.py index 9fbd69e6..06c1ea78 100644 --- a/backend/int_tests/test_race_selection.py +++ b/backend/int_tests/test_race_selection.py @@ -155,9 +155,8 @@ def test_preset_selection_flow(self): assert saved["race"]["prt"] == "JOAT" assert saved["cost_breakdown"]["points_left"] == 25 # 53 - 28 (JOAT PRT cost) - resolved = client1.resolve(game_id) - assert resolved.turn == 1 - assert resolved.status == "resolved" + state = client1.get_state(game_id) + assert state.turn == 1 for client, player in ((client1, PLAYER_1), (client2, PLAYER_2)): state = client.get_state(game_id) @@ -204,7 +203,6 @@ def test_custom_race_round_trip(self): assert saved["race"]["economy"]["colonists_per_resource"] == 900 assert saved["cost_breakdown"]["points_left"] >= 0 - client1.resolve(game_id) state = client1.get_state(game_id) home = next(planet for planet in state.planets if planet.owner == PLAYER_1) assert home.population == 25_000 @@ -230,14 +228,10 @@ def test_phase_enforcement(self): assert exc_info.value.status_code == 400 assert exc_info.value.error_code == "COMMAND_TURN_ZERO_RACE_ONLY" - with pytest.raises(GameAPIError) as exc_info: - client1.resolve(game_id) - assert exc_info.value.status_code == 409 - assert exc_info.value.error_code == "TURN_ZERO_INCOMPLETE" - assert PLAYER_2 in exc_info.value.message + assert client1.get_game(game_id).turn == 0 client2.select_humanoid_race(game_id) - assert client1.resolve(game_id).turn == 1 + assert client1.get_state(game_id).turn == 1 with pytest.raises(GameAPIError) as exc_info: client1.submit_commands( @@ -267,9 +261,7 @@ def test_economy_plumbing_uses_custom_factory_output(self): turn=0, commands=[SelectRaceCommand(race=race)], ) - client1.resolve(game_id) client1.submit_commands(game_id, turn=1, commands=[]) - client1.resolve(game_id) state = client1.get_state(game_id) assert state.race.economy.factory_output_per_10 == 12 @@ -299,7 +291,6 @@ def test_ife_custom_race_round_trip(self): saved = client1.get_race(game_id) assert saved["race"]["lrts"] == ["IFE"] - client1.resolve(game_id) state = client1.get_state(game_id) assert "IFE" in state.race.lrts assert state.research.levels["propulsion"] == 3 @@ -322,7 +313,6 @@ def test_ife_custom_race_round_trip(self): turn=0, commands=[SelectRaceCommand(race=he_ife)], ) - client1.resolve(he_game_id) he_state = client1.get_state(he_game_id) assert he_state.research.levels["propulsion"] == 1 @@ -350,9 +340,6 @@ def test_ife_fuel_consumption_observable_end_to_end(self): turn=0, commands=[SelectRaceCommand(race=ife_race)], ) - client1.resolve(non_ife_game.game_id) - client1.resolve(ife_game.game_id) - non_ife_state = client1.get_state(non_ife_game.game_id) ife_state = client1.get_state(ife_game.game_id) non_ife_scout = next(fleet for fleet in non_ife_state.fleets if fleet.name == "Scout") @@ -390,9 +377,6 @@ def test_ife_fuel_consumption_observable_end_to_end(self): ) ], ) - client1.resolve(non_ife_game.game_id) - client1.resolve(ife_game.game_id) - moved_non_ife = next( fleet for fleet in client1.get_state(non_ife_game.game_id).fleets @@ -420,8 +404,6 @@ def test_ife_catalogue_restriction_enforced_server_side(self): turn=0, commands=[SelectRaceCommand(race=_base_race(max_growth_rate=10))], ) - client1.resolve(no_ife_game.game_id) - payload = { "name": "Fuel Mizer Scout", "hull": "scout", @@ -445,6 +427,5 @@ def test_ife_catalogue_restriction_enforced_server_side(self): turn=0, commands=[SelectRaceCommand(race=_base_race(lrts=["IFE"], max_growth_rate=10))], ) - client1.resolve(ife_game.game_id) created = client1.create_ship_design(ife_game.game_id, **payload) assert created["components"][0]["component_id"] == "fuel_mizer" diff --git a/backend/int_tests/test_ship_production_fleet.py b/backend/int_tests/test_ship_production_fleet.py index ff6250de..ece4b85e 100644 --- a/backend/int_tests/test_ship_production_fleet.py +++ b/backend/int_tests/test_ship_production_fleet.py @@ -29,7 +29,6 @@ def _submit_and_resolve(self, game_id: str, commands): turn = state.turn client1.submit_commands(game_id, turn=turn, commands=commands) client2.submit_commands(game_id, turn=turn, commands=[]) - client1.resolve(game_id) def _fleet_ids_for_player(self, state, username: str) -> set[str]: return {f.id for f in state.fleets if f.owner == username} @@ -43,7 +42,6 @@ def test_new_design_production_creates_fleet(self): game_id = game.game_id client1.select_humanoid_race(game_id) client2.select_humanoid_race(game_id) - client1.resolve(game_id) design = client1.create_ship_design( game_id, diff --git a/backend/openstars/game_directory/__init__.py b/backend/openstars/game_directory/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/openstars/game_directory/base.py b/backend/openstars/game_directory/base.py new file mode 100644 index 00000000..0508fb63 --- /dev/null +++ b/backend/openstars/game_directory/base.py @@ -0,0 +1,87 @@ +"""Abstract GameDirectory interface and shared models.""" + +from abc import ABC, abstractmethod +from datetime import UTC, datetime + +from pydantic import BaseModel + + +class GameNotFoundError(Exception): + pass + + +class GameSummary(BaseModel): + game_id: str + name: str + galaxy_size: str + seed: int + players: list[str] + current_turn: int + players_submitted: list[str] + created_at: datetime + updated_at: datetime + + @property + def all_turns_submitted(self) -> bool: + return set(self.players_submitted) >= set(self.players) + + @classmethod + def new( + cls, + game_id: str, + name: str, + galaxy_size: str, + seed: int, + players: list[str], + ) -> "GameSummary": + now = datetime.now(UTC) + return cls( + game_id=game_id, + name=name, + galaxy_size=galaxy_size, + seed=seed, + players=players, + current_turn=0, + players_submitted=[], + created_at=now, + updated_at=now, + ) + + +class GameDirectory(ABC): + """Abstract interface for game-summary metadata and realtime notification writes.""" + + @abstractmethod + def create_game(self, game_id: str, summary: GameSummary) -> None: + """Write the initial Firestore document. Raises if game_id already exists.""" + ... + + @abstractmethod + def get_game(self, game_id: str) -> GameSummary: + """Read the game document. Raises GameNotFoundError if missing.""" + ... + + @abstractmethod + def list_games_for_player(self, username: str, limit: int = 200) -> list[GameSummary]: + """Return games the player participates in, most-recent first.""" + ... + + @abstractmethod + def list_all_games(self, limit: int = 200) -> list[GameSummary]: + """Return all games, most-recent first.""" + ... + + @abstractmethod + def player_submitted(self, game_id: str, players_submitted: list[str]) -> None: + """Merge-update players_submitted and updated_at.""" + ... + + @abstractmethod + def turn_resolved(self, game_id: str, new_turn: int) -> None: + """Advance current_turn and reset players_submitted to [].""" + ... + + @abstractmethod + def delete_game(self, game_id: str) -> None: + """Delete the game document. Used only by cleanup tooling and tests.""" + ... diff --git a/backend/openstars/game_directory/factory.py b/backend/openstars/game_directory/factory.py new file mode 100644 index 00000000..25d8b54f --- /dev/null +++ b/backend/openstars/game_directory/factory.py @@ -0,0 +1,56 @@ +"""Build a GameDirectory from env vars.""" + +import os + +from openstars.game_directory.base import GameDirectory + + +def build_game_directory() -> GameDirectory: + """Construct the configured GameDirectory. + + GAME_DIRECTORY_BACKEND: 'firestore' | 'memory' + Defaults to 'memory' when STORAGE_BACKEND=memory and GAME_DIRECTORY_BACKEND is unset. + """ + backend = os.environ.get("GAME_DIRECTORY_BACKEND") + if not backend: + storage_backend = os.environ.get("STORAGE_BACKEND", "") + backend = "memory" if storage_backend == "memory" else "firestore" + + if backend == "memory": + from openstars.game_directory.memory import InMemoryGameDirectory + + return InMemoryGameDirectory() + + if backend == "firestore": + project_id = os.environ.get("FIREBASE_PROJECT_ID") + if not project_id: + raise RuntimeError( + "FIREBASE_PROJECT_ID must be set when GAME_DIRECTORY_BACKEND=firestore" + ) + import firebase_admin + from firebase_admin import credentials as fb_credentials + from firebase_admin import firestore as admin_firestore + + if not firebase_admin._apps: + if os.environ.get("FIRESTORE_EMULATOR_HOST"): + # Emulator mode: credentials are not used for requests (the emulator + # bypasses auth entirely), but firebase_admin still calls get_credential() + # during client construction. Provide a stub that satisfies the interface + # without attempting to load Application Default Credentials. + class _EmulatorCredential(fb_credentials.Base): + def get_credential(self): + from google.auth.credentials import AnonymousCredentials + + return AnonymousCredentials() + + cred: fb_credentials.Base | None = _EmulatorCredential() + else: + cred = None # Use Application Default Credentials in production. + + firebase_admin.initialize_app(cred, options={"projectId": project_id}) + + from openstars.game_directory.firestore import FirestoreGameDirectory + + return FirestoreGameDirectory(admin_firestore.client()) + + raise RuntimeError(f"Unsupported GAME_DIRECTORY_BACKEND: {backend!r}") diff --git a/backend/openstars/game_directory/firestore.py b/backend/openstars/game_directory/firestore.py new file mode 100644 index 00000000..108e02a3 --- /dev/null +++ b/backend/openstars/game_directory/firestore.py @@ -0,0 +1,93 @@ +"""Firestore-backed GameDirectory.""" + +from datetime import UTC, datetime + +from google.cloud import firestore + +from openstars.game_directory.base import GameDirectory, GameNotFoundError, GameSummary + +_COLLECTION = "games" + + +class FirestoreGameDirectory(GameDirectory): + def __init__(self, client: firestore.Client) -> None: + self._client = client + + def _doc_ref(self, game_id: str): + return self._client.collection(_COLLECTION).document(game_id) + + def create_game(self, game_id: str, summary: GameSummary) -> None: + self._doc_ref(game_id).set( + { + "name": summary.name, + "galaxy_size": summary.galaxy_size, + "seed": summary.seed, + "players": summary.players, + "current_turn": summary.current_turn, + "players_submitted": summary.players_submitted, + "created_at": firestore.SERVER_TIMESTAMP, + "updated_at": firestore.SERVER_TIMESTAMP, + } + ) + + def get_game(self, game_id: str) -> GameSummary: + snap = self._doc_ref(game_id).get() + if not snap.exists: + raise GameNotFoundError(game_id) + return self._snap_to_summary(game_id, snap.to_dict()) + + def list_games_for_player(self, username: str, limit: int = 200) -> list[GameSummary]: + query = ( + self._client.collection(_COLLECTION) + .where(filter=firestore.FieldFilter("players", "array-contains", username)) + .order_by("created_at", direction=firestore.Query.DESCENDING) + .limit(limit) + ) + return [self._snap_to_summary(doc.id, doc.to_dict()) for doc in query.stream()] + + def list_all_games(self, limit: int = 200) -> list[GameSummary]: + query = ( + self._client.collection(_COLLECTION) + .order_by("created_at", direction=firestore.Query.DESCENDING) + .limit(limit) + ) + return [self._snap_to_summary(doc.id, doc.to_dict()) for doc in query.stream()] + + def player_submitted(self, game_id: str, players_submitted: list[str]) -> None: + self._doc_ref(game_id).set( + {"players_submitted": players_submitted, "updated_at": firestore.SERVER_TIMESTAMP}, + merge=True, + ) + + def turn_resolved(self, game_id: str, new_turn: int) -> None: + self._doc_ref(game_id).set( + { + "current_turn": new_turn, + "players_submitted": [], + "updated_at": firestore.SERVER_TIMESTAMP, + }, + merge=True, + ) + + def delete_game(self, game_id: str) -> None: + self._doc_ref(game_id).delete() + + @staticmethod + def _snap_to_summary(game_id: str, data: dict) -> GameSummary: + def _to_dt(val) -> datetime: + if isinstance(val, datetime): + return val.astimezone(UTC) if val.tzinfo else val.replace(tzinfo=UTC) + # Firestore DatetimeWithNanoseconds + try: + return val.astimezone(UTC) + except Exception: + return datetime.now(UTC) + + return GameSummary.model_validate( + { + **data, + "game_id": game_id, + "created_at": _to_dt(data["created_at"]), + "updated_at": _to_dt(data["updated_at"]), + } + ) diff --git a/backend/openstars/game_directory/memory.py b/backend/openstars/game_directory/memory.py new file mode 100644 index 00000000..dbb2f4d1 --- /dev/null +++ b/backend/openstars/game_directory/memory.py @@ -0,0 +1,43 @@ +"""In-memory GameDirectory for unit tests and GAME_DIRECTORY_BACKEND=memory.""" + +from datetime import UTC, datetime + +from openstars.game_directory.base import GameDirectory, GameNotFoundError, GameSummary + + +class InMemoryGameDirectory(GameDirectory): + def __init__(self) -> None: + self._store: dict[str, GameSummary] = {} + + def create_game(self, game_id: str, summary: GameSummary) -> None: + self._store[game_id] = summary.model_copy(deep=True) + + def get_game(self, game_id: str) -> GameSummary: + if game_id not in self._store: + raise GameNotFoundError(game_id) + return self._store[game_id].model_copy(deep=True) + + def list_games_for_player(self, username: str, limit: int = 200) -> list[GameSummary]: + results = [s for s in self._store.values() if username in s.players] + results.sort(key=lambda s: s.created_at, reverse=True) + return [s.model_copy(deep=True) for s in results[:limit]] + + def list_all_games(self, limit: int = 200) -> list[GameSummary]: + results = sorted(self._store.values(), key=lambda s: s.created_at, reverse=True) + return [s.model_copy(deep=True) for s in results[:limit]] + + def player_submitted(self, game_id: str, players_submitted: list[str]) -> None: + if game_id not in self._store: + raise GameNotFoundError(game_id) + self._store[game_id].players_submitted = list(players_submitted) + self._store[game_id].updated_at = datetime.now(UTC) + + def turn_resolved(self, game_id: str, new_turn: int) -> None: + if game_id not in self._store: + raise GameNotFoundError(game_id) + self._store[game_id].current_turn = new_turn + self._store[game_id].players_submitted = [] + self._store[game_id].updated_at = datetime.now(UTC) + + def delete_game(self, game_id: str) -> None: + self._store.pop(game_id, None) diff --git a/backend/openstars/server/deps.py b/backend/openstars/server/deps.py index 55681208..e7046141 100644 --- a/backend/openstars/server/deps.py +++ b/backend/openstars/server/deps.py @@ -1,14 +1,23 @@ -"""FastAPI dependency injection for storage.""" +"""FastAPI dependency injection for storage and game directory.""" import os from functools import lru_cache +from openstars.game_directory.base import GameDirectory from openstars.storage.base import GameStorage from openstars.storage.gcs import GCSStorage from openstars.storage.local import LocalStorage from openstars.storage.memory import MemoryStorage +@lru_cache +def get_game_directory() -> GameDirectory: + """Get the configured GameDirectory (cached per-process).""" + from openstars.game_directory.factory import build_game_directory + + return build_game_directory() + + @lru_cache def get_storage() -> GameStorage: """Get the configured storage backend.""" diff --git a/backend/openstars/server/main.py b/backend/openstars/server/main.py index 8ebbde56..22822d22 100644 --- a/backend/openstars/server/main.py +++ b/backend/openstars/server/main.py @@ -11,6 +11,7 @@ from openstars.server.routers.combat_altair_prototype import ( router as combat_altair_router, ) +from openstars.server.routes.auth import router as auth_router from openstars.server.routes.designs import router as designs_router from openstars.server.routes.games import router as games_router from openstars.server.routes.play import router as play_router @@ -23,6 +24,7 @@ app = FastAPI(title="OpenStars!", version="0.1.0") +app.include_router(auth_router) app.include_router(games_router) app.include_router(play_router) app.include_router(combat_altair_router) diff --git a/backend/openstars/server/resolution.py b/backend/openstars/server/resolution.py new file mode 100644 index 00000000..427f045c --- /dev/null +++ b/backend/openstars/server/resolution.py @@ -0,0 +1,81 @@ +"""Shared turn-resolution logic used by submit_commands.""" + +import logging +from dataclasses import dataclass + +from openstars.engine.component_catalogue import load_component_catalogue +from openstars.engine.fog import derive_player_state +from openstars.engine.resolve import resolve_turn +from openstars.engine.state_context import StateContext +from openstars.game_directory.base import GameDirectory, GameSummary +from openstars.server.game_designs import list_all_designs_for_players +from openstars.storage.base import GameStorage + +log = logging.getLogger(__name__) + + +@dataclass +class ResolveOutcome: + new_turn: int + resolved: bool + + +def resolve_current_turn( + storage: GameStorage, + directory: GameDirectory, + game_id: str, + summary: GameSummary, +) -> ResolveOutcome: + """Run the turn resolution pipeline and persist the results. + + Calls directory.turn_resolved *after* all state files are durable so that + a Firestore listener firing on current_turn change always finds readable state. + + Raises on engine error — the caller should return 500 and leave the command + file in place for resubmission. + """ + current_turn = summary.current_turn + players = summary.players + + global_state = storage.load_global_state(game_id, current_turn) + galaxy = storage.load_galaxy(game_id) + designs = list_all_designs_for_players(storage, game_id, players) + + all_commands = {p: storage.load_commands(game_id, p, current_turn) for p in players} + + new_state = resolve_turn( + global_state, + galaxy, + all_commands, + designs, + game_id=game_id, + storage=storage, + ) + new_turn = new_state.game.turn + + # Idempotent: if another resolver already wrote this turn, treat as success. + try: + storage.create_global_state(game_id, new_turn, new_state) + except FileExistsError: + log.warning("resolution.turn_already_exists game_id=%s new_turn=%d", game_id, new_turn) + directory.turn_resolved(game_id, new_turn) + return ResolveOutcome(new_turn=new_turn, resolved=True) + + component_catalogue = load_component_catalogue() + state_ctx = StateContext(game_id, new_state, galaxy, designs, component_catalogue) + + if current_turn == 0: + designs = list_all_designs_for_players(storage, game_id, players) + state_ctx = StateContext(game_id, new_state, galaxy, designs, component_catalogue) + + for p in players: + previous_player_state = ( + None if current_turn == 0 else storage.load_player_state(game_id, p, current_turn) + ) + ps = derive_player_state(state_ctx, p, previous_player_state=previous_player_state) + storage.save_player_state(game_id, p, new_turn, ps) + + # GCS writes are durable — now advance the Firestore record. + directory.turn_resolved(game_id, new_turn) + + return ResolveOutcome(new_turn=new_turn, resolved=True) diff --git a/backend/openstars/server/routes/auth.py b/backend/openstars/server/routes/auth.py new file mode 100644 index 00000000..0cb3679f --- /dev/null +++ b/backend/openstars/server/routes/auth.py @@ -0,0 +1,87 @@ +"""Firebase custom-token endpoint.""" + +import json +import logging +from datetime import UTC, datetime, timedelta +from functools import lru_cache + +from fastapi import APIRouter, Depends, Header + +from openstars.game_directory.base import GameDirectory +from openstars.server.deps import get_game_directory + +router = APIRouter(prefix="/api/v1/auth", tags=["auth"]) +log = logging.getLogger(__name__) + +# Fetch up to this many games from the directory before byte-based clipping. +_GAMES_FETCH_LIMIT = 200 +# Firebase ID-token custom claims hard limit is 1 000 bytes; leave a small margin. +_CLAIMS_BYTE_LIMIT = 960 +_TOKEN_TTL = timedelta(hours=1) + + +def _fit_game_ids(game_ids: list[str], byte_limit: int = _CLAIMS_BYTE_LIMIT) -> list[str]: + """Return the longest prefix of *game_ids* whose serialised claims payload fits in *byte_limit*. + + Count-based truncation is unreliable: even a handful of long IDs can push the + ``{"games": [...]}`` payload over Firebase's 1 000-byte custom-claims limit, breaking + token minting for that player. Measuring bytes directly prevents the failure mode. + """ + result: list[str] = [] + for game_id in game_ids: + candidate = result + [game_id] + if len(json.dumps({"games": candidate}, separators=(",", ":")).encode()) > byte_limit: + break + result = candidate + return result + + +@lru_cache +def _firebase_app(): + """Initialise the default Firebase app once per process.""" + import os + + import firebase_admin + + project_id = os.environ.get("FIREBASE_PROJECT_ID") + if not project_id: + raise RuntimeError("FIREBASE_PROJECT_ID must be set") + + options = {"projectId": project_id} + try: + return firebase_admin.get_app() + except ValueError: + return firebase_admin.initialize_app(options=options) + + +@router.post("/firebase-token") +async def firebase_token( + x_player: str = Header(...), + directory: GameDirectory = Depends(get_game_directory), +): + """Mint a Firebase custom token for the caller, with a `games` claim.""" + import firebase_admin.auth + + _firebase_app() + + summaries = directory.list_games_for_player(x_player, limit=_GAMES_FETCH_LIMIT) + all_game_ids = [s.game_id for s in summaries] + game_ids = _fit_game_ids(all_game_ids) + + if len(game_ids) < len(all_game_ids): + log.warning( + "firebase_token.games_truncated player=%s kept=%d total=%d byte_limit=%d", + x_player, + len(game_ids), + len(all_game_ids), + _CLAIMS_BYTE_LIMIT, + ) + + token_bytes = firebase_admin.auth.create_custom_token( + uid=x_player, + developer_claims={"games": game_ids}, + ) + token = token_bytes.decode() if isinstance(token_bytes, bytes) else token_bytes + expires_at = (datetime.now(UTC) + _TOKEN_TTL).isoformat() + + return {"token": token, "expires_at": expires_at} diff --git a/backend/openstars/server/routes/designs.py b/backend/openstars/server/routes/designs.py index 4ee9b393..a53c35e1 100644 --- a/backend/openstars/server/routes/designs.py +++ b/backend/openstars/server/routes/designs.py @@ -19,7 +19,8 @@ ) from openstars.engine.ids import create_id from openstars.engine.models import Design -from openstars.server.deps import get_storage +from openstars.game_directory.base import GameDirectory, GameNotFoundError +from openstars.server.deps import get_game_directory, get_storage from openstars.server.errors import error_response from openstars.storage.base import GameStorage @@ -31,18 +32,18 @@ def _catalogue(): return load_component_catalogue() -def _validate_player(storage: GameStorage, game_id: str, username: str): +def _validate_player(directory: GameDirectory, game_id: str, username: str): try: - meta = storage.load_game_meta(game_id) - except FileNotFoundError: + summary = directory.get_game(game_id) + except GameNotFoundError: return None, error_response(404, "GAME_NOT_FOUND", f"Game {game_id!r} not found") - if username not in meta.get("players", []): + if username not in summary.players: return None, error_response( 403, "NOT_PARTICIPANT", "You are not a participant in this game", ) - return meta, None + return summary, None def _summarise_design(design: Design) -> dict: @@ -66,10 +67,14 @@ def _hulls_for_domain( ] -def _player_for_game(storage: GameStorage, game_id: str, username: str): - meta = storage.load_game_meta(game_id) - current_turn = int(meta.get("current_turn", 0)) - global_state = storage.load_global_state(game_id, current_turn) +def _player_for_game(storage: GameStorage, directory: GameDirectory, game_id: str, username: str): + from openstars.game_directory.base import GameNotFoundError + + try: + summary = directory.get_game(game_id) + except GameNotFoundError: + return None + global_state = storage.load_global_state(game_id, summary.current_turn) return next((player for player in global_state.players if player.username == username), None) @@ -88,9 +93,10 @@ async def get_design_reference_data( game_id: str, domain: str = "ship", storage: GameStorage = Depends(get_storage), + directory: GameDirectory = Depends(get_game_directory), x_player: str = Header(...), ): - _, err = _validate_player(storage, game_id, x_player) + _, err = _validate_player(directory, game_id, x_player) if err: return err if domain not in {"ship", "starbase"}: @@ -101,7 +107,7 @@ async def get_design_reference_data( except CatalogueLoadError as exc: return error_response(500, "CATALOGUE_LOAD_ERROR", str(exc)) - player = _player_for_game(storage, game_id, x_player) + player = _player_for_game(storage, directory, game_id, x_player) if player is None: return error_response(403, "NOT_PARTICIPANT", "You are not a participant in this game") @@ -125,9 +131,10 @@ async def get_design_reference_data( async def get_designs( game_id: str, storage: GameStorage = Depends(get_storage), + directory: GameDirectory = Depends(get_game_directory), x_player: str = Header(...), ): - _, err = _validate_player(storage, game_id, x_player) + _, err = _validate_player(directory, game_id, x_player) if err: return err return { @@ -140,9 +147,10 @@ async def get_design_detail( game_id: str, design_id: str, storage: GameStorage = Depends(get_storage), + directory: GameDirectory = Depends(get_game_directory), x_player: str = Header(...), ): - _, err = _validate_player(storage, game_id, x_player) + _, err = _validate_player(directory, game_id, x_player) if err: return err try: @@ -157,9 +165,10 @@ async def create_design( game_id: str, payload: dict, storage: GameStorage = Depends(get_storage), + directory: GameDirectory = Depends(get_game_directory), x_player: str = Header(...), ): - meta, err = _validate_player(storage, game_id, x_player) + summary, err = _validate_player(directory, game_id, x_player) if err: return err @@ -168,9 +177,9 @@ async def create_design( except CatalogueLoadError as exc: return error_response(500, "CATALOGUE_LOAD_ERROR", str(exc)) - game_seed = int(meta.get("seed", hash(game_id) & 0xFFFFFFFF)) + game_seed = summary.seed design_id = _next_design_id(storage, game_id, x_player, game_seed) - player = _player_for_game(storage, game_id, x_player) + player = _player_for_game(storage, directory, game_id, x_player) if player is None: return error_response(403, "NOT_PARTICIPANT", "You are not a participant in this game") diff --git a/backend/openstars/server/routes/games.py b/backend/openstars/server/routes/games.py index b64d6607..2e3aa865 100644 --- a/backend/openstars/server/routes/games.py +++ b/backend/openstars/server/routes/games.py @@ -10,18 +10,19 @@ from openstars.engine.create_game import create_initial_state from openstars.engine.fog import derive_player_state from openstars.engine.galaxy import generate_galaxy -from openstars.server.deps import get_storage +from openstars.game_directory.base import GameDirectory, GameNotFoundError, GameSummary +from openstars.server.deps import get_game_directory, get_storage from openstars.server.errors import error_response from openstars.server.schemas import ( CreateGameRequest, CreateGameResponse, GameDetail, GameListResponse, - GameSummary, + GameSummaryResponse, PlayerInfo, PlayerSubmissionInfo, ) -from openstars.server.turns import get_current_turn, player_submitted +from openstars.server.turns import get_current_turn from openstars.storage.base import GameStorage router = APIRouter(prefix="/api/v1/games", tags=["games"]) @@ -56,6 +57,7 @@ def _slugify(name: str) -> str: async def create_game( req: CreateGameRequest, storage: GameStorage = Depends(get_storage), + directory: GameDirectory = Depends(get_game_directory), ): """Create a new game.""" valid_sizes = {"small", "medium", "large", "huge"} @@ -111,18 +113,28 @@ async def create_game( storage.save_player_state(game_id, player.username, 0, ps) created_at = datetime.now(UTC) - storage.save_game_meta( - game_id, - { - "name": req.name, - "galaxy_size": req.galaxy_size, - "seed": game_seed, - "players": [p.username for p in state.players], - "current_turn": 0, - "created_at": created_at.isoformat(), - }, + summary = GameSummary( + game_id=game_id, + name=req.name, + galaxy_size=req.galaxy_size, + seed=game_seed, + players=[p.username for p in state.players], + current_turn=0, + players_submitted=[], + created_at=created_at, + updated_at=created_at, ) + # GCS writes are durable — now write Firestore. On failure, GCS blobs are orphaned + # (invisible to users) and can be reconciled by a cleanup pass. + try: + directory.create_game(game_id, summary) + except Exception as exc: + log.error( + "create_game.firestore_failed game_id=%s error=%s (GCS blobs orphaned)", game_id, exc + ) + return error_response(500, "DIRECTORY_WRITE_FAILED", "Failed to register game") + return CreateGameResponse( game_id=game_id, name=req.name, @@ -135,44 +147,27 @@ async def create_game( @router.get("") async def list_games( - storage: GameStorage = Depends(get_storage), + directory: GameDirectory = Depends(get_game_directory), x_player: str | None = Header(None), ): """List games, optionally filtered to a player.""" - game_ids = storage.list_games() - games = [] - for gid in game_ids: - try: - meta = storage.load_game_meta(gid) - except FileNotFoundError: - continue - - players = meta.get("players", []) - if x_player and x_player not in players: - continue - - # Check current turn - try: - state = storage.load_global_state(gid, get_current_turn(storage, gid, meta)) - turn = state.game.turn - except FileNotFoundError: - turn = 0 - - # Check submission status - all_submitted = all(player_submitted(storage, gid, p, turn) for p in players) - - games.append( - GameSummary( - game_id=gid, - name=meta["name"], - galaxy_size=meta["galaxy_size"], - turn=turn, - players=players, - all_turns_submitted=all_submitted, - created_at=meta.get("created_at", ""), - ) + if x_player: + summaries = directory.list_games_for_player(x_player) + else: + summaries = directory.list_all_games() + + games = [ + GameSummaryResponse( + game_id=s.game_id, + name=s.name, + galaxy_size=s.galaxy_size, + turn=s.current_turn, + players=s.players, + all_turns_submitted=s.all_turns_submitted, + created_at=s.created_at, ) - + for s in summaries + ] return GameListResponse(games=games) @@ -180,36 +175,34 @@ async def list_games( async def get_game( game_id: str, storage: GameStorage = Depends(get_storage), + directory: GameDirectory = Depends(get_game_directory), x_player: str = Header(...), ): """Get game detail with per-player submission status.""" try: - meta = storage.load_game_meta(game_id) - except FileNotFoundError: + summary = directory.get_game(game_id) + except GameNotFoundError: return error_response(404, "GAME_NOT_FOUND", f"Game {game_id!r} not found") - players = meta.get("players", []) - if x_player not in players: + if x_player not in summary.players: return error_response(403, "NOT_PARTICIPANT", "You are not a participant in this game") - turn = get_current_turn(storage, game_id, meta) + turn = get_current_turn(summary) - player_info = [] - for p in players: - submitted = player_submitted(storage, game_id, p, turn) - player_info.append( - PlayerSubmissionInfo( - username=p, - name=p, - submitted=submitted, - ) + player_info = [ + PlayerSubmissionInfo( + username=p, + name=p, + submitted=p in summary.players_submitted, ) + for p in summary.players + ] return GameDetail( game_id=game_id, - name=meta["name"], - galaxy_size=meta["galaxy_size"], + name=summary.name, + galaxy_size=summary.galaxy_size, turn=turn, players=player_info, - created_at=meta.get("created_at", ""), + created_at=summary.created_at, ) diff --git a/backend/openstars/server/routes/play.py b/backend/openstars/server/routes/play.py index f514677a..867c1325 100644 --- a/backend/openstars/server/routes/play.py +++ b/backend/openstars/server/routes/play.py @@ -1,24 +1,22 @@ """Player gameplay endpoints (PRD 09).""" +import logging + from fastapi import APIRouter, Depends, Header +from fastapi.responses import JSONResponse -from openstars.engine.component_catalogue import load_component_catalogue -from openstars.engine.fog import derive_player_state -from openstars.engine.models import ( - PlayerCommands, -) -from openstars.engine.resolve import resolve_turn +from openstars.engine.galaxy import GALAXY_SIZES +from openstars.engine.models import PlayerCommands from openstars.engine.resolve_steps.commands.parsing import ( CommandParseContext, parse_registered_command, ) -from openstars.engine.state_context import StateContext -from openstars.server.deps import get_storage +from openstars.game_directory.base import GameDirectory, GameNotFoundError +from openstars.server.deps import get_game_directory, get_storage from openstars.server.errors import GameError, error_response -from openstars.server.game_designs import list_all_designs_for_players from openstars.server.log_context import game_id as game_id_log_context +from openstars.server.resolution import resolve_current_turn from openstars.server.schemas import ( - ResolveResponse, SubmitCommandsRequest, SubmitCommandsResponse, TurnStatusResponse, @@ -26,38 +24,42 @@ from openstars.server.turns import get_current_turn, player_submitted from openstars.storage.base import GameStorage +log = logging.getLogger(__name__) + router = APIRouter(prefix="/api/v1/games/{game_id}", tags=["play"]) -def _validate_player(storage: GameStorage, game_id: str, username: str): - """Check game exists and player is a participant. Returns (meta, error_response).""" +def _validate_player(directory: GameDirectory, game_id: str, username: str): + """Check game exists and player is a participant. Returns (summary, error_response).""" try: - meta = storage.load_game_meta(game_id) - except FileNotFoundError: + summary = directory.get_game(game_id) + except GameNotFoundError: return None, error_response(404, "GAME_NOT_FOUND", f"Game {game_id!r} not found") - if username not in meta.get("players", []): + if username not in summary.players: return None, error_response( 403, "NOT_PARTICIPANT", "You are not a participant in this game" ) - return meta, None + return summary, None @router.get("/turn-status") async def get_turn_status( game_id: str, storage: GameStorage = Depends(get_storage), + directory: GameDirectory = Depends(get_game_directory), x_player: str = Header(...), ) -> TurnStatusResponse: """Get the current turn number and the list of players still to submit.""" - meta, err = _validate_player(storage, game_id, x_player) + summary, err = _validate_player(directory, game_id, x_player) if err: return err - current_turn = get_current_turn(storage, game_id, meta) - players = meta.get("players", []) - awaiting = [p for p in players if not player_submitted(storage, game_id, p, current_turn)] + current_turn = get_current_turn(summary) + awaiting = [ + p for p in summary.players if not player_submitted(storage, game_id, p, current_turn) + ] return TurnStatusResponse(turn=current_turn, players_awaiting_submission=awaiting) @@ -65,10 +67,11 @@ async def get_turn_status( async def get_galaxy( game_id: str, storage: GameStorage = Depends(get_storage), + directory: GameDirectory = Depends(get_game_directory), x_player: str = Header(...), ): """Get the static galaxy definition.""" - meta, err = _validate_player(storage, game_id, x_player) + _, err = _validate_player(directory, game_id, x_player) if err: return err @@ -85,15 +88,16 @@ async def get_state( game_id: str, turn: int | None = None, storage: GameStorage = Depends(get_storage), + directory: GameDirectory = Depends(get_game_directory), x_player: str = Header(...), ): """Get the player's state for a given turn (default: current).""" - meta, err = _validate_player(storage, game_id, x_player) + summary, err = _validate_player(directory, game_id, x_player) if err: return err if turn is None: - turn = get_current_turn(storage, game_id, meta) + turn = get_current_turn(summary) try: ps = storage.load_player_state(game_id, x_player, turn) @@ -108,14 +112,15 @@ async def submit_commands( game_id: str, req: SubmitCommandsRequest, storage: GameStorage = Depends(get_storage), + directory: GameDirectory = Depends(get_game_directory), x_player: str = Header(...), ): - """Submit commands for the current turn.""" - meta, err = _validate_player(storage, game_id, x_player) + """Submit commands for the current turn. Auto-resolves if this is the last submission.""" + summary, err = _validate_player(directory, game_id, x_player) if err: return err - current_turn = get_current_turn(storage, game_id, meta) + current_turn = get_current_turn(summary) if req.turn != current_turn: return error_response( 409, @@ -139,19 +144,14 @@ async def submit_commands( f"select_race is only valid at turn 0; current turn is {current_turn}", ) - # Parse and validate commands try: global_state = storage.load_global_state(game_id, current_turn) except FileNotFoundError: return error_response(404, "GAME_NOT_FOUND", "Game state not found") - # Load galaxy for bounds checking galaxy = storage.load_galaxy(game_id) - from openstars.engine.galaxy import GALAXY_SIZES - max_coord = (1 << GALAXY_SIZES.get(galaxy.galaxy.size, 40)) - 1 - # Build owned entity lookups for command validation. owned_fleets = {f.id for f in global_state.fleets if f.owner == x_player} owned_planets = {p.id: p for p in global_state.planets if p.owner == x_player} queue_state_by_planet = { @@ -174,15 +174,43 @@ async def submit_commands( parsed_command = parse_registered_command(cmd_dict, parse_ctx) except GameError as exc: return error_response(exc.status_code, exc.error_code, exc.error_message) - parsed_commands.append(parsed_command) player_commands = PlayerCommands(commands=parsed_commands) storage.save_commands(game_id, x_player, current_turn, player_commands) + # Compute which players have now submitted. + players_submitted_list = [ + p for p in summary.players if player_submitted(storage, game_id, p, current_turn) + ] + directory.player_submitted(game_id, players_submitted_list) + + # Auto-resolve if all players have submitted. + if set(players_submitted_list) >= set(summary.players): + token = game_id_log_context.set(game_id) + try: + outcome = resolve_current_turn(storage, directory, game_id, summary) + except Exception as exc: + log.error("resolution.failed game_id=%s error=%s", game_id, exc) + return JSONResponse( + status_code=500, + content={"error": {"code": "RESOLUTION_FAILED", "message": str(exc)}}, + ) + finally: + game_id_log_context.reset(token) + + return SubmitCommandsResponse( + turn=current_turn, + command_count=len(parsed_commands), + turn_resolved=True, + new_turn=outcome.new_turn, + ) + return SubmitCommandsResponse( turn=current_turn, command_count=len(parsed_commands), + turn_resolved=False, + new_turn=None, ) @@ -190,14 +218,15 @@ async def submit_commands( async def get_commands( game_id: str, storage: GameStorage = Depends(get_storage), + directory: GameDirectory = Depends(get_game_directory), x_player: str = Header(...), ): """Get the player's submitted commands for the current turn.""" - meta, err = _validate_player(storage, game_id, x_player) + summary, err = _validate_player(directory, game_id, x_player) if err: return err - current_turn = get_current_turn(storage, game_id, meta) + current_turn = get_current_turn(summary) try: cmds = storage.load_commands(game_id, x_player, current_turn) @@ -205,90 +234,3 @@ async def get_commands( return {"turn": current_turn, "commands": []} return {"turn": current_turn, "commands": [c.model_dump() for c in cmds.commands]} - - -@router.post("/resolve") -async def resolve( - game_id: str, - storage: GameStorage = Depends(get_storage), - x_player: str = Header(...), -): - token = game_id_log_context.set(game_id) - try: - """Trigger turn resolution.""" - meta, err = _validate_player(storage, game_id, x_player) - if err: - return err - - current_turn = get_current_turn(storage, game_id, meta) - players = meta.get("players", []) - - # Check all players have submitted - missing = [p for p in players if not player_submitted(storage, game_id, p, current_turn)] - if missing: - if current_turn == 0: - return error_response( - 409, - "TURN_ZERO_INCOMPLETE", - f"Players have not submitted a race selection: {', '.join(missing)}", - ) - return error_response( - 409, - "NOT_ALL_SUBMITTED", - f"Not all players have submitted commands (waiting for: {missing[0]})", - ) - - # Load current state and all commands - global_state = storage.load_global_state(game_id, current_turn) - galaxy = storage.load_galaxy(game_id) - designs = list_all_designs_for_players(storage, game_id, players) - - all_commands = {} - for p in players: - all_commands[p] = storage.load_commands(game_id, p, current_turn) - - # Resolve - new_state = resolve_turn( - global_state, - galaxy, - all_commands, - designs, - game_id=game_id, - storage=storage, - ) - new_turn = new_state.game.turn - - # Save new state. If another resolver already persisted this turn, - # treat it as an expected race and return success idempotently. - try: - storage.create_global_state(game_id, new_turn, new_state) - except FileExistsError: - current_meta = storage.load_game_meta(game_id) - if int(current_meta.get("current_turn", 0)) < new_turn: - current_meta["current_turn"] = new_turn - storage.save_game_meta(game_id, current_meta) - return ResolveResponse(turn=new_turn) - - component_catalogue = load_component_catalogue() - state_ctx = StateContext(game_id, new_state, galaxy, designs, component_catalogue) - - # Turn-0 resolution writes starting designs into the registry; reload before deriving. - if current_turn == 0: - designs = list_all_designs_for_players(storage, game_id, players) - state_ctx = StateContext(game_id, new_state, galaxy, designs, component_catalogue) - - # Derive and save player states - for p in players: - if current_turn == 0: - previous_player_state = None - else: - previous_player_state = storage.load_player_state(game_id, p, current_turn) - ps = derive_player_state(state_ctx, p, previous_player_state=previous_player_state) - storage.save_player_state(game_id, p, new_turn, ps) - - meta["current_turn"] = new_turn - storage.save_game_meta(game_id, meta) - - return ResolveResponse(turn=new_turn) - finally: - game_id_log_context.reset(token) diff --git a/backend/openstars/server/routes/race.py b/backend/openstars/server/routes/race.py index 04f75357..a27bb036 100644 --- a/backend/openstars/server/routes/race.py +++ b/backend/openstars/server/routes/race.py @@ -7,7 +7,8 @@ from openstars.engine.race.costs import race_cost_breakdown from openstars.engine.race.models import Race from openstars.engine.race.presets import PREDEFINED_RACES -from openstars.server.deps import get_storage +from openstars.game_directory.base import GameDirectory, GameNotFoundError +from openstars.server.deps import get_game_directory, get_storage from openstars.server.errors import error_response from openstars.server.turns import get_current_turn from openstars.storage.base import GameStorage @@ -53,18 +54,19 @@ async def preview_race(req: RacePreviewRequest, x_player: str = Header(...)): async def get_game_race( game_id: str, storage: GameStorage = Depends(get_storage), + directory: GameDirectory = Depends(get_game_directory), x_player: str = Header(...), ): """Return the caller's saved race selection for this game.""" try: - meta = storage.load_game_meta(game_id) - except FileNotFoundError: + summary = directory.get_game(game_id) + except GameNotFoundError: return error_response(404, "GAME_NOT_FOUND", f"Game {game_id!r} not found") - if x_player not in meta.get("players", []): + if x_player not in summary.players: return error_response(403, "NOT_PLAYER", "You are not a player in this game") - current_turn = get_current_turn(storage, game_id, meta) + current_turn = get_current_turn(summary) if current_turn == 0: race = _last_saved_race_selection(storage, game_id, x_player, current_turn) else: diff --git a/backend/openstars/server/schemas.py b/backend/openstars/server/schemas.py index bad44616..6b0ecfa3 100644 --- a/backend/openstars/server/schemas.py +++ b/backend/openstars/server/schemas.py @@ -42,7 +42,7 @@ class CreateGameResponse(BaseModel): # --- Game listing --- -class GameSummary(BaseModel): +class GameSummaryResponse(BaseModel): game_id: str name: str galaxy_size: str @@ -53,7 +53,7 @@ class GameSummary(BaseModel): class GameListResponse(BaseModel): - games: list[GameSummary] + games: list[GameSummaryResponse] # --- Game detail --- @@ -109,11 +109,5 @@ class SubmitCommandsResponse(BaseModel): status: str = "submitted" turn: int command_count: int - - -# --- Resolution --- - - -class ResolveResponse(BaseModel): - turn: int - status: str = "resolved" + turn_resolved: bool = False + new_turn: int | None = None diff --git a/backend/openstars/server/turns.py b/backend/openstars/server/turns.py index 6e72f696..696662fc 100644 --- a/backend/openstars/server/turns.py +++ b/backend/openstars/server/turns.py @@ -1,15 +1,13 @@ """Shared helpers for resolving the authoritative current turn.""" from openstars.engine.models import SelectRaceCommand +from openstars.game_directory.base import GameSummary from openstars.storage.base import GameStorage -def get_current_turn(storage: GameStorage, game_id: str, meta: dict) -> int: - """Find the current turn, using metadata directly when available.""" - try: - return int(meta["current_turn"]) - except (KeyError, TypeError, ValueError): - pass +def get_current_turn(summary: GameSummary) -> int: + """Return the current turn from a GameSummary.""" + return summary.current_turn def player_submitted(storage: GameStorage, game_id: str, username: str, turn: int) -> bool: diff --git a/backend/openstars/storage/base.py b/backend/openstars/storage/base.py index 21813de1..e9ef9571 100644 --- a/backend/openstars/storage/base.py +++ b/backend/openstars/storage/base.py @@ -1,6 +1,5 @@ """Abstract storage interface for game data.""" -import json from abc import ABC, abstractmethod from openstars.combat.altair.models import CombatLog @@ -127,29 +126,6 @@ def has_commands(self, game_id: str, username: str, turn: int) -> bool: game_object_name(game_id, "commands", f"player-command-{username}-T{turn}{BLOB_SUFFIX}") ) - def list_games(self) -> list[str]: - game_ids = set() - for name in self._list_blob_names(): - name = name.rstrip("/") - if not name.endswith(f"/meta{BLOB_SUFFIX}"): - continue - game_id = name[: -len(f"/meta{BLOB_SUFFIX}")] - if "/" in game_id: - continue - validate_segment(game_id, "game_id") - game_ids.add(game_id) - return sorted(game_ids) - - def save_game_meta(self, game_id: str, meta: dict) -> None: - """Save lightweight game metadata (name, galaxy_size, created_at, players).""" - self._update_json_blob( - game_object_name(game_id, f"meta{BLOB_SUFFIX}"), - json.dumps(meta, indent=2, default=str), - ) - - def load_game_meta(self, game_id: str) -> dict: - return json.loads(self._load_json_blob(game_object_name(game_id, f"meta{BLOB_SUFFIX}"))) - def save_design(self, game_id: str, username: str, design: Design) -> None: validate_segment(username, "username") self._update_json_blob( diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 5176995c..eb055f47 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -10,6 +10,7 @@ dependencies = [ "python-json-logger>=4.1.0", "google-cloud-storage>=3.1.1", "pyyaml>=6.0.3", + "firebase-admin>=7.4.0", ] [project.optional-dependencies] diff --git a/backend/tests/engine/test_storage.py b/backend/tests/engine/test_storage.py index 0e849bc2..6676e796 100644 --- a/backend/tests/engine/test_storage.py +++ b/backend/tests/engine/test_storage.py @@ -178,22 +178,6 @@ def test_has_commands(storage, sample_commands): assert storage.has_commands("game1", "tim", 0) -def test_list_games(storage, sample_galaxy): - assert storage.list_games() == [] - storage.save_galaxy("game1", sample_galaxy) - storage.save_game_meta("game1", {"name": "Game 1"}) - storage.save_galaxy("game2", sample_galaxy) - storage.save_game_meta("game2", {"name": "Game 2"}) - assert storage.list_games() == ["game1", "game2"] - - -def test_game_meta_round_trip(storage): - meta = {"name": "Test Game", "galaxy_size": "small", "players": ["tim", "matt"]} - storage.save_game_meta("game1", meta) - loaded = storage.load_game_meta("game1") - assert loaded == meta - - def test_design_round_trip(storage): design = Design( id="DEdesign1", diff --git a/backend/tests/engine/test_storage_gcs.py b/backend/tests/engine/test_storage_gcs.py index b3de81ea..7af5201b 100644 --- a/backend/tests/engine/test_storage_gcs.py +++ b/backend/tests/engine/test_storage_gcs.py @@ -25,7 +25,7 @@ Waypoint, ) from openstars.storage import gcs as gcs_module -from openstars.storage.compression import decode_json, encode_json +from openstars.storage.compression import decode_json from openstars.storage.gcs import GCSStorage from openstars.storage.state_versioning import UnsupportedStateVersionError @@ -257,41 +257,6 @@ def test_has_commands(storage, sample_commands): assert storage.has_commands("game1", "tim", 0) -def test_list_games(storage, sample_galaxy): - storage.save_galaxy("game1", sample_galaxy) - storage.save_game_meta("game1", {"name": "Game 1"}) - storage.save_galaxy("game2", sample_galaxy) - storage.save_game_meta("game2", {"name": "Game 2"}) - storage.bucket.objects["nested/game3/meta.json.gz"] = { - "data": encode_json("{}"), - "content_type": "application/json", - "content_encoding": "gzip", - "generation": 1, - } - - assert storage.list_games() == ["game1", "game2"] - - -def test_game_meta_round_trip(storage): - meta = {"name": "Test Game", "galaxy_size": "small", "players": ["tim", "matt"]} - storage.save_game_meta("game1", meta) - - loaded = storage.load_game_meta("game1") - - assert loaded == meta - - -def test_load_uses_raw_gcs_download_for_gzipped_json(storage): - meta = {"name": "Test Game"} - storage.save_game_meta("game1", meta) - storage.bucket.objects["game1/meta.json.gz"]["content_encoding"] = "gzip" - - blob = storage.bucket.blob("game1/meta.json.gz") - assert blob.download_as_bytes().startswith(b"{") - - assert storage.load_game_meta("game1") == meta - - def test_load_missing_file_raises(storage): with pytest.raises(FileNotFoundError): storage.load_galaxy("nonexistent") diff --git a/backend/tests/engine/test_storage_memory.py b/backend/tests/engine/test_storage_memory.py index 4b41762f..135d95b3 100644 --- a/backend/tests/engine/test_storage_memory.py +++ b/backend/tests/engine/test_storage_memory.py @@ -153,14 +153,6 @@ def test_has_commands(storage, sample_commands): assert storage.has_commands("game1", "tim", 0) -def test_list_games_and_meta(storage): - storage.save_game_meta("game1", {"name": "Game 1"}) - storage.save_game_meta("game2", {"name": "Game 2"}) - - assert storage.list_games() == ["game1", "game2"] - assert storage.load_game_meta("game1") == {"name": "Game 1"} - - def test_design_registry_round_trip(storage, sample_global_state): design = Design( id="DEabc123", diff --git a/backend/tests/server/test_api.py b/backend/tests/server/test_api.py index 9da6eca3..a0024d47 100644 --- a/backend/tests/server/test_api.py +++ b/backend/tests/server/test_api.py @@ -13,17 +13,20 @@ @pytest.fixture(autouse=True) def _setup_storage(tmp_path): - """Point storage to a temp directory for each test.""" + """Point storage to a temp directory and use an in-memory game directory for each test.""" os.environ["STORAGE_BACKEND"] = "local" os.environ["GAME_DATA_PATH"] = str(tmp_path) - # Clear the lru_cache so it picks up the new path - from openstars.server.deps import get_storage + os.environ["GAME_DIRECTORY_BACKEND"] = "memory" + from openstars.server.deps import get_game_directory, get_storage get_storage.cache_clear() + get_game_directory.cache_clear() yield os.environ.pop("STORAGE_BACKEND", None) os.environ.pop("GAME_DATA_PATH", None) + os.environ.pop("GAME_DIRECTORY_BACKEND", None) get_storage.cache_clear() + get_game_directory.cache_clear() @pytest.fixture @@ -34,7 +37,7 @@ def client(): def _create_game(client, name="Test Game", players=None, *, advance=True): - """Create a game; by default, submit Humanoid for each player and resolve T=0 → T=1.""" + """Create a game; by default, submit Humanoid for each player to auto-resolve T=0 → T=1.""" if players is None: players = ["tim", "matt"] resp = client.post( @@ -59,11 +62,7 @@ def _create_game(client, name="Test Game", players=None, *, advance=True): headers={"X-Player": player}, ) assert submit.status_code == 200, submit.text - resolve = client.post( - f"/api/v1/games/{game_id}/resolve", - headers={"X-Player": players[0]}, - ) - assert resolve.status_code == 200, resolve.text + # The last player's submission auto-resolves to T=1 return resp @@ -236,7 +235,7 @@ def test_get_turn_status(self, client): assert body["turn"] == 0 assert sorted(body["players_awaiting_submission"]) == ["matt", "tim"] - def test_turn_status_advances_after_resolve(self, client): + def test_turn_status_advances_after_all_submit(self, client): create_resp = _create_game(client) game_id = create_resp.json()["game_id"] @@ -245,12 +244,12 @@ def test_turn_status_advances_after_resolve(self, client): json={"turn": 1, "commands": []}, headers={"X-Player": "tim"}, ) + # Second (last) player's submit triggers auto-resolve. client.post( f"/api/v1/games/{game_id}/commands", json={"turn": 1, "commands": []}, headers={"X-Player": "matt"}, ) - client.post(f"/api/v1/games/{game_id}/resolve", headers={"X-Player": "tim"}) resp = client.get(f"/api/v1/games/{game_id}/turn-status", headers={"X-Player": "tim"}) assert resp.status_code == 200 @@ -542,12 +541,12 @@ def test_get_designs_is_stable_across_turn_resolution(self, client): "designs" ] + # Single-player game — submitting triggers auto-resolve. client.post( f"/api/v1/games/{game_id}/commands", json={"turn": 1, "commands": []}, headers={"X-Player": "tim"}, ) - client.post(f"/api/v1/games/{game_id}/resolve", headers={"X-Player": "tim"}) after = client.get(f"/api/v1/games/{game_id}/designs", headers={"X-Player": "tim"}).json()[ "designs" @@ -627,13 +626,12 @@ def test_ship_production_builds_ship_and_emits_event(self, client): gs.planet_resources[p.id] = 100 storage.update_global_state(game_id, 1, gs) - # normal resolve path recalculates resources; submit empty commands and resolve. + # Single-player game — submitting triggers auto-resolve. client.post( f"/api/v1/games/{game_id}/commands", json={"turn": 1, "commands": []}, headers={"X-Player": "tim"}, ) - client.post(f"/api/v1/games/{game_id}/resolve", headers={"X-Player": "tim"}) new_state = client.get(f"/api/v1/games/{game_id}/state", headers={"X-Player": "tim"}).json() assert any(event["code"] == "production.ship_built" for event in new_state["events"]) @@ -692,12 +690,12 @@ def test_production_queue_visible_only_on_owned_planets(self, client): }, headers={"X-Player": "tim"}, ) + # Last player's submit triggers auto-resolve. client.post( f"/api/v1/games/{game_id}/commands", json={"turn": 1, "commands": []}, headers={"X-Player": "matt"}, ) - client.post(f"/api/v1/games/{game_id}/resolve", headers={"X-Player": "tim"}) tim_state_t1 = client.get( f"/api/v1/games/{game_id}/state?turn=2", @@ -1073,15 +1071,13 @@ def test_submit_planetary_scanner_rejects_when_already_installed(self, client): json={"turn": turn, "commands": []}, headers={"X-Player": "tim"}, ) - client.post( + # Last player's submit triggers auto-resolve. + resolve_resp = client.post( f"/api/v1/games/{game_id}/commands", json={"turn": turn, "commands": []}, headers={"X-Player": "matt"}, ) - resolve_resp = client.post( - f"/api/v1/games/{game_id}/resolve", headers={"X-Player": "tim"} - ) - assert resolve_resp.status_code == 200 + assert resolve_resp.json()["turn_resolved"] is True resp = client.post( f"/api/v1/games/{game_id}/commands", @@ -1117,13 +1113,12 @@ def test_rename_fleet_command(self, client): ) assert resp.status_code == 200 - # Submit empty commands for matt then resolve + # Matt's submit triggers auto-resolve. client.post( f"/api/v1/games/{game_id}/commands", json={"turn": 1, "commands": []}, headers={"X-Player": "matt"}, ) - client.post(f"/api/v1/games/{game_id}/resolve", headers={"X-Player": "tim"}) state = client.get( f"/api/v1/games/{game_id}/state?turn=2", headers={"X-Player": "tim"} @@ -1308,10 +1303,10 @@ def test_turn_one_rejects_select_race(self, client): assert rejected.status_code == 400 assert rejected.json()["error"]["code"] == "COMMAND_NOT_VALID_AT_THIS_TURN" - def test_resolve_turn_zero_incomplete_lists_missing_player(self, client): + def test_turn_zero_partial_submit_does_not_resolve(self, client): game_id = self._create(client).json()["game_id"] - # Only Tim submits. - client.post( + # Only Tim submits — turn should NOT advance. + resp = client.post( f"/api/v1/games/{game_id}/commands", json={ "turn": 0, @@ -1319,16 +1314,19 @@ def test_resolve_turn_zero_incomplete_lists_missing_player(self, client): }, headers={"X-Player": "tim"}, ) - resp = client.post(f"/api/v1/games/{game_id}/resolve", headers={"X-Player": "tim"}) - assert resp.status_code == 409 - body = resp.json() - assert body["error"]["code"] == "TURN_ZERO_INCOMPLETE" - assert "matt" in body["error"]["message"] + assert resp.status_code == 200 + assert resp.json()["turn_resolved"] is False + assert resp.json()["new_turn"] is None + # Turn is still 0. + status = client.get( + f"/api/v1/games/{game_id}/turn-status", headers={"X-Player": "tim"} + ).json() + assert status["turn"] == 0 - def test_resolve_turn_zero_succeeds_when_all_submitted(self, client): + def test_turn_zero_auto_resolves_when_all_submitted(self, client): game_id = self._create(client).json()["game_id"] for player in ("tim", "matt"): - client.post( + resp = client.post( f"/api/v1/games/{game_id}/commands", json={ "turn": 0, @@ -1336,9 +1334,10 @@ def test_resolve_turn_zero_succeeds_when_all_submitted(self, client): }, headers={"X-Player": player}, ) - resp = client.post(f"/api/v1/games/{game_id}/resolve", headers={"X-Player": "tim"}) - assert resp.status_code == 200 - assert resp.json()["turn"] == 1 + assert resp.status_code == 200 + # Last player's response reports the resolved turn. + assert resp.json()["turn_resolved"] is True + assert resp.json()["new_turn"] == 1 def test_turn_status_lists_players_awaiting_submission(self, client): game_id = self._create(client).json()["game_id"] @@ -1359,7 +1358,8 @@ def test_turn_status_lists_players_awaiting_submission(self, client): mid = client.get(f"/api/v1/games/{game_id}/turn-status", headers={"X-Player": "tim"}).json() assert mid["players_awaiting_submission"] == ["matt"] - client.post( + # Matt's submission triggers auto-resolve (T=0 → T=1). + matt_resp = client.post( f"/api/v1/games/{game_id}/commands", json={ "turn": 0, @@ -1367,10 +1367,13 @@ def test_turn_status_lists_players_awaiting_submission(self, client): }, headers={"X-Player": "matt"}, ) + assert matt_resp.json()["turn_resolved"] is True + # After resolve, turn is 1 — both players awaiting again for the new turn. after = client.get( f"/api/v1/games/{game_id}/turn-status", headers={"X-Player": "tim"} ).json() - assert after["players_awaiting_submission"] == [] + assert after["turn"] == 1 + assert sorted(after["players_awaiting_submission"]) == ["matt", "tim"] def test_game_detail_submitted_reflects_select_race(self, client): game_id = self._create(client).json()["game_id"] @@ -1409,44 +1412,51 @@ def test_game_detail_submitted_reflects_select_race(self, client): } -class TestResolve: +class TestAutoResolve: def _submit_empty(self, client, game_id, player, turn=1): - client.post( + return client.post( f"/api/v1/games/{game_id}/commands", json={"turn": turn, "commands": []}, headers={"X-Player": player}, ) - def test_resolve_success(self, client): + def test_last_submit_auto_resolves(self, client): create_resp = _create_game(client) game_id = create_resp.json()["game_id"] - # Both players submit empty commands - self._submit_empty(client, game_id, "tim") - self._submit_empty(client, game_id, "matt") + # First player: no resolution yet. + first = self._submit_empty(client, game_id, "tim") + assert first.json()["turn_resolved"] is False + assert first.json()["new_turn"] is None - # Resolve - resp = client.post(f"/api/v1/games/{game_id}/resolve", headers={"X-Player": "tim"}) - assert resp.status_code == 200 - assert resp.json()["turn"] == 2 - assert resp.json()["status"] == "resolved" + # Second (last) player triggers auto-resolve. + last = self._submit_empty(client, game_id, "matt") + assert last.status_code == 200 + assert last.json()["turn_resolved"] is True + assert last.json()["new_turn"] == 2 - def test_resolve_not_all_submitted(self, client): + def test_partial_submit_does_not_resolve(self, client): create_resp = _create_game(client) game_id = create_resp.json()["game_id"] - # Only Tim submits self._submit_empty(client, game_id, "tim") + status = client.get( + f"/api/v1/games/{game_id}/turn-status", headers={"X-Player": "tim"} + ).json() + assert status["turn"] == 1 # still on T=1 + + def test_resolve_endpoint_removed(self, client): + create_resp = _create_game(client) + game_id = create_resp.json()["game_id"] resp = client.post(f"/api/v1/games/{game_id}/resolve", headers={"X-Player": "tim"}) - assert resp.status_code == 409 + assert resp.status_code == 404 def test_full_lifecycle(self, client): - """Create → get state → submit commands → resolve → get new state.""" + """Create → get state → submit commands → auto-resolve → get new state.""" create_resp = _create_game(client) game_id = create_resp.json()["game_id"] - # Get Tim's initial state state = client.get(f"/api/v1/games/{game_id}/state", headers={"X-Player": "tim"}).json() assert state["turn"] == 1 tim_fleet = [f for f in state["fleets"] if f["owner"] == "tim"][0] @@ -1454,7 +1464,6 @@ def test_full_lifecycle(self, client): start_x = tim_fleet["position"]["x"] start_y = tim_fleet["position"]["y"] - # Set waypoints — move east dest_x = start_x + 50 * LIGHT_YEAR client.post( f"/api/v1/games/{game_id}/commands", @@ -1471,56 +1480,38 @@ def test_full_lifecycle(self, client): headers={"X-Player": "tim"}, ) - # Matt submits empty - self._submit_empty(client, game_id, "matt") - - # Resolve - resp = client.post(f"/api/v1/games/{game_id}/resolve", headers={"X-Player": "tim"}) - assert resp.json()["turn"] == 2 + # Matt's submit triggers auto-resolve. + resp = self._submit_empty(client, game_id, "matt") + assert resp.json()["turn_resolved"] is True + assert resp.json()["new_turn"] == 2 - # Get new state new_state = client.get(f"/api/v1/games/{game_id}/state", headers={"X-Player": "tim"}).json() assert new_state["turn"] == 2 new_fleet = next(f for f in new_state["fleets"] if f["id"] == fleet_id) - # Fleet should have moved at warp-1 budget (1 light-year) east. assert new_fleet["position"]["x"] == start_x + LIGHT_YEAR assert new_fleet["position"]["y"] == start_y - def test_resolve_conflict_is_idempotent(self, client, monkeypatch): + def test_idempotent_resubmit_does_not_double_resolve(self, client): + """Resubmitting after the turn has resolved returns TURN_MISMATCH, not a double-resolve.""" create_resp = _create_game(client) game_id = create_resp.json()["game_id"] - # Both players submit empty commands self._submit_empty(client, game_id, "tim") - self._submit_empty(client, game_id, "matt") - - from openstars.server.deps import get_storage + last = self._submit_empty(client, game_id, "matt") # auto-resolves to T=2 + assert last.json()["turn_resolved"] is True - storage = get_storage() - original_create = storage.create_global_state - call_count = 0 - - def flaky_create_global_state(*args, **kwargs): - nonlocal call_count - call_count += 1 - if call_count == 1: - raise FileExistsError("Object already exists") - return original_create(*args, **kwargs) - - monkeypatch.setattr(storage, "create_global_state", flaky_create_global_state) - - resp = client.post(f"/api/v1/games/{game_id}/resolve", headers={"X-Player": "tim"}) - assert resp.status_code == 200 - assert resp.json()["turn"] == 2 - assert resp.json()["status"] == "resolved" + # Resubmitting for T=1 now fails — current turn is T=2. + stale = self._submit_empty(client, game_id, "tim", turn=1) + assert stale.status_code == 409 + assert stale.json()["error"]["code"] == "TURN_MISMATCH" class TestScanners: """Integration tests for scanner mechanics (PRD 11).""" def _submit_empty(self, client, game_id, player, turn=1): - client.post( + return client.post( f"/api/v1/games/{game_id}/commands", json={"turn": turn, "commands": []}, headers={"X-Player": player}, @@ -1673,12 +1664,12 @@ def test_own_fleet_bearing_present_after_movement(self, client): }, headers={"X-Player": "tim"}, ) + # Matt's submit triggers auto-resolve. client.post( f"/api/v1/games/{game_id}/commands", json={"turn": 1, "commands": []}, headers={"X-Player": "matt"}, ) - client.post(f"/api/v1/games/{game_id}/resolve", headers={"X-Player": "tim"}) state_t1 = client.get(f"/api/v1/games/{game_id}/state", headers={"X-Player": "tim"}).json() moved_fleet = next(f for f in state_t1["fleets"] if f["id"] == fleet_id) @@ -1739,13 +1730,15 @@ def dist_sq(p): }, headers={"X-Player": "tim"}, ) - self._submit_empty(client, game_id, "matt") + # Matt's submit triggers auto-resolve of T=1. + matt_resp = self._submit_empty(client, game_id, "matt") + assert matt_resp.json()["turn_resolved"] is True + current_turn = matt_resp.json()["new_turn"] - # Resolve turns until the fleet reaches scanner range of a new planet. + # Advance turns until the fleet reaches scanner range of a new planet. num_turns = 50 new_scans: set[str] = set() - for turn in range(num_turns): - client.post(f"/api/v1/games/{game_id}/resolve", headers={"X-Player": "tim"}) + for _ in range(num_turns): state_now = client.get( f"/api/v1/games/{game_id}/state", headers={"X-Player": "tim"} ).json() @@ -1753,9 +1746,10 @@ def dist_sq(p): new_scans = scanned_now - scanned_t0 if new_scans: break - # Waypoints persist — just submit empty for both players - self._submit_empty(client, game_id, "tim", turn=turn + 1) - self._submit_empty(client, game_id, "matt", turn=turn + 1) + # Waypoints persist — submit empty for both to advance to the next turn. + self._submit_empty(client, game_id, "tim", turn=current_turn) + matt_resp = self._submit_empty(client, game_id, "matt", turn=current_turn) + current_turn = matt_resp.json()["new_turn"] assert new_scans, ( f"Expected new planets scanned after moving toward nearest unscanned. " @@ -1828,25 +1822,22 @@ def dist_sq_from_origin(planet): headers={"X-Player": "tim"}, ) assert tim_submit.status_code == 200 - self._submit_empty(client, game_id, "matt", turn=state_t0["turn"]) + # Matt's submit triggers auto-resolve. + matt_resp = self._submit_empty(client, game_id, "matt", turn=state_t0["turn"]) + assert matt_resp.json()["turn_resolved"] is True + current_turn = matt_resp.json()["new_turn"] scanned_state = None for _ in range(80): - resolve_resp = client.post( - f"/api/v1/games/{game_id}/resolve", - headers={"X-Player": "tim"}, - ) - assert resolve_resp.status_code == 200 - resolved_turn = resolve_resp.json()["turn"] - state = client.get(f"/api/v1/games/{game_id}/state", headers={"X-Player": "tim"}).json() planet = next(planet for planet in state["planets"] if planet["id"] == target_planet_id) if planet["scan_level"] == "basic" and planet["scan_age"] == 0: scanned_state = state break - self._submit_empty(client, game_id, "tim", turn=resolved_turn) - self._submit_empty(client, game_id, "matt", turn=resolved_turn) + self._submit_empty(client, game_id, "tim", turn=current_turn) + matt_resp = self._submit_empty(client, game_id, "matt", turn=current_turn) + current_turn = matt_resp.json()["new_turn"] if scanned_state is None: pytest.fail("Target planet never became freshly scanned before the fleet moved away") @@ -1899,17 +1890,13 @@ def dist_sq_from_origin(planet): headers={"X-Player": "tim"}, ) assert tim_submit.status_code == 200 - self._submit_empty(client, game_id, "matt", turn=scanned_state["turn"]) + # Matt's submit triggers auto-resolve. + matt_resp = self._submit_empty(client, game_id, "matt", turn=scanned_state["turn"]) + assert matt_resp.json()["turn_resolved"] is True + current_turn = matt_resp.json()["new_turn"] stale_state = None for _ in range(60): - resolve_resp = client.post( - f"/api/v1/games/{game_id}/resolve", - headers={"X-Player": "tim"}, - ) - assert resolve_resp.status_code == 200 - resolved_turn = resolve_resp.json()["turn"] - state = client.get(f"/api/v1/games/{game_id}/state", headers={"X-Player": "tim"}).json() planet = next(planet for planet in state["planets"] if planet["id"] == target_planet_id) if planet.get("scan_age") is not None and planet["scan_age"] > 0: @@ -1918,8 +1905,9 @@ def dist_sq_from_origin(planet): if planet["scan_level"] not in {"basic", "detailed"}: pytest.fail(f"Unexpected scan_level while moving away: {planet['scan_level']}") - self._submit_empty(client, game_id, "tim", turn=resolved_turn) - self._submit_empty(client, game_id, "matt", turn=resolved_turn) + self._submit_empty(client, game_id, "tim", turn=current_turn) + matt_resp = self._submit_empty(client, game_id, "matt", turn=current_turn) + current_turn = matt_resp.json()["new_turn"] if stale_state is None: pytest.fail("Planet never became stale after moving away") @@ -1931,10 +1919,10 @@ def dist_sq_from_origin(planet): first_scan_age = stale_planet["scan_age"] assert first_scan_age >= 1 + # One more advance to verify scan_age increments. self._submit_empty(client, game_id, "tim", turn=stale_state["turn"]) - self._submit_empty(client, game_id, "matt", turn=stale_state["turn"]) - resolve_again = client.post(f"/api/v1/games/{game_id}/resolve", headers={"X-Player": "tim"}) - assert resolve_again.status_code == 200 + matt_resp = self._submit_empty(client, game_id, "matt", turn=stale_state["turn"]) + assert matt_resp.json()["turn_resolved"] is True state_after = client.get( f"/api/v1/games/{game_id}/state", headers={"X-Player": "tim"} @@ -1956,13 +1944,13 @@ def test_resolve_cycle_writes_only_gzipped_json_blobs(self, client, tmp_path): json={"turn": 1, "commands": []}, headers={"X-Player": "tim"}, ) - client.post( + # Matt's submit triggers auto-resolve. + resolve_resp = client.post( f"/api/v1/games/{game_id}/commands", json={"turn": 1, "commands": []}, headers={"X-Player": "matt"}, ) - resolve_resp = client.post(f"/api/v1/games/{game_id}/resolve", headers={"X-Player": "tim"}) - assert resolve_resp.status_code == 200 + assert resolve_resp.json()["turn_resolved"] is True game_dir = tmp_path / game_id all_files = [path for path in game_dir.rglob("*") if path.is_file()] diff --git a/backend/tests/server/test_auth_routes.py b/backend/tests/server/test_auth_routes.py new file mode 100644 index 00000000..efb3b679 --- /dev/null +++ b/backend/tests/server/test_auth_routes.py @@ -0,0 +1,192 @@ +"""Unit tests for POST /api/v1/auth/firebase-token.""" + +import json +from datetime import UTC, datetime +from unittest.mock import MagicMock + +import pytest +from fastapi.testclient import TestClient + + +@pytest.fixture(autouse=True) +def _setup(tmp_path, monkeypatch): + monkeypatch.setenv("STORAGE_BACKEND", "local") + monkeypatch.setenv("GAME_DATA_PATH", str(tmp_path)) + monkeypatch.setenv("GAME_DIRECTORY_BACKEND", "memory") + monkeypatch.setenv("FIREBASE_PROJECT_ID", "test-project") + from openstars.server.deps import get_game_directory, get_storage + from openstars.server.routes.auth import _firebase_app + + get_storage.cache_clear() + get_game_directory.cache_clear() + _firebase_app.cache_clear() + yield + get_storage.cache_clear() + get_game_directory.cache_clear() + _firebase_app.cache_clear() + + +@pytest.fixture +def client(): + from openstars.server.main import app + + return TestClient(app) + + +def _patch_firebase(monkeypatch, token_return=b"mock-token"): + """Patch firebase_admin so tests don't need a real SDK init.""" + mock_app = MagicMock() + monkeypatch.setattr("firebase_admin.get_app", lambda: mock_app) + monkeypatch.setattr("firebase_admin.initialize_app", lambda **kw: mock_app) + monkeypatch.setattr( + "firebase_admin.auth.create_custom_token", lambda uid, developer_claims: token_return + ) + return mock_app + + +class TestFitGameIds: + """Unit tests for the byte-based _fit_game_ids helper.""" + + def test_empty_list(self): + from openstars.server.routes.auth import _fit_game_ids + + assert _fit_game_ids([]) == [] + + def test_all_ids_fit(self): + from openstars.server.routes.auth import _fit_game_ids + + ids = ["g1", "g2", "g3"] + assert _fit_game_ids(ids) == ids + + def test_result_never_exceeds_byte_limit(self): + from openstars.server.routes.auth import _fit_game_ids + + ids = [f"game-{i}" for i in range(200)] + result = _fit_game_ids(ids, byte_limit=960) + payload = json.dumps({"games": result}, separators=(",", ":")).encode() + assert len(payload) <= 960 + + def test_one_more_would_exceed_limit(self): + """The entry just beyond the returned prefix must push the payload over the limit.""" + from openstars.server.routes.auth import _fit_game_ids + + ids = [f"game-{i}" for i in range(200)] + result = _fit_game_ids(ids, byte_limit=960) + # If all ids fit, there is no "one more" to check. + if len(result) < len(ids): + next_id = ids[len(result)] + over = json.dumps({"games": result + [next_id]}, separators=(",", ":")).encode() + assert len(over) > 960 + + def test_truncates_by_bytes_not_count(self): + """Long IDs cause truncation even when item count is small.""" + from openstars.server.routes.auth import _fit_game_ids + + # Each ID is 200 bytes; even 5 of them (+ JSON wrapper) exceed 960 bytes. + long_ids = ["x" * 200 for _ in range(5)] + result = _fit_game_ids(long_ids, byte_limit=960) + assert len(result) < 5 + + +class TestFirebaseTokenEndpoint: + def test_missing_x_player_returns_422(self, client, monkeypatch): + _patch_firebase(monkeypatch) + resp = client.post("/api/v1/auth/firebase-token") + assert resp.status_code == 422 + + def test_returns_token_and_expires_at(self, client, monkeypatch): + _patch_firebase(monkeypatch, token_return=b"test-jwt") + resp = client.post("/api/v1/auth/firebase-token", headers={"X-Player": "tim"}) + assert resp.status_code == 200 + data = resp.json() + assert data["token"] == "test-jwt" + # expires_at should be an ISO-8601 string ~1 hour from now + expires = datetime.fromisoformat(data["expires_at"]) + diff = expires - datetime.now(UTC) + assert 3500 < diff.total_seconds() < 3700 + + def test_create_custom_token_called_with_correct_args(self, client, monkeypatch): + calls = [] + + def fake_create(uid, developer_claims): + calls.append({"uid": uid, "claims": developer_claims}) + return b"tok" + + mock_app = MagicMock() + monkeypatch.setattr("firebase_admin.get_app", lambda: mock_app) + monkeypatch.setattr("firebase_admin.initialize_app", lambda **kw: mock_app) + monkeypatch.setattr("firebase_admin.auth.create_custom_token", fake_create) + + # Create a game so the player has one entry + client.post( + "/api/v1/games", + json={"name": "G", "galaxy_size": "small", "players": ["tim"]}, + ) + resp = client.post("/api/v1/auth/firebase-token", headers={"X-Player": "tim"}) + assert resp.status_code == 200 + + assert len(calls) == 1 + assert calls[0]["uid"] == "tim" + assert len(calls[0]["claims"]["games"]) == 1 + + def test_no_games_returns_empty_list(self, client, monkeypatch): + calls = [] + + def fake_create(uid, developer_claims): + calls.append(developer_claims) + return b"tok" + + mock_app = MagicMock() + monkeypatch.setattr("firebase_admin.get_app", lambda: mock_app) + monkeypatch.setattr("firebase_admin.initialize_app", lambda **kw: mock_app) + monkeypatch.setattr("firebase_admin.auth.create_custom_token", fake_create) + + resp = client.post("/api/v1/auth/firebase-token", headers={"X-Player": "nobody"}) + assert resp.status_code == 200 + assert calls[0]["games"] == [] + + def test_string_token_returned_as_is(self, client, monkeypatch): + """Tokens that come back as str (not bytes) are passed through unchanged.""" + _patch_firebase(monkeypatch, token_return="already-a-string") + resp = client.post("/api/v1/auth/firebase-token", headers={"X-Player": "tim"}) + assert resp.json()["token"] == "already-a-string" + + def test_truncation_warning_logged_at_limit(self, client, monkeypatch, caplog): + import logging + + mock_app = MagicMock() + monkeypatch.setattr("firebase_admin.get_app", lambda: mock_app) + monkeypatch.setattr("firebase_admin.initialize_app", lambda **kw: mock_app) + monkeypatch.setattr( + "firebase_admin.auth.create_custom_token", lambda uid, developer_claims: b"t" + ) + + # Inject 200 fake game summaries into the directory + from datetime import UTC, datetime + + from openstars.game_directory.base import GameSummary + from openstars.server.deps import get_game_directory + + directory = get_game_directory() + now = datetime.now(UTC) + for i in range(200): + directory.create_game( + f"game-{i}", + GameSummary( + game_id=f"game-{i}", + name=f"Game {i}", + galaxy_size="small", + seed=i, + players=["tim"], + current_turn=0, + players_submitted=[], + created_at=now, + updated_at=now, + ), + ) + + with caplog.at_level(logging.WARNING, logger="openstars.server.routes.auth"): + resp = client.post("/api/v1/auth/firebase-token", headers={"X-Player": "tim"}) + + assert resp.status_code == 200 + assert any("truncated" in r.message for r in caplog.records) diff --git a/backend/tests/server/test_designer_api.py b/backend/tests/server/test_designer_api.py index 08311149..5043deff 100644 --- a/backend/tests/server/test_designer_api.py +++ b/backend/tests/server/test_designer_api.py @@ -8,20 +8,24 @@ from openstars.engine.race.models import LRT from openstars.engine.race.presets import default_race from openstars.engine.research.costs import FIELDS -from openstars.server.deps import get_storage +from openstars.server.deps import get_game_directory, get_storage @pytest.fixture(autouse=True) def _setup_storage(tmp_path): os.environ["STORAGE_BACKEND"] = "local" os.environ["GAME_DATA_PATH"] = str(tmp_path) - from openstars.server.deps import get_storage + os.environ["GAME_DIRECTORY_BACKEND"] = "memory" + from openstars.server.deps import get_game_directory, get_storage get_storage.cache_clear() + get_game_directory.cache_clear() yield os.environ.pop("STORAGE_BACKEND", None) os.environ.pop("GAME_DATA_PATH", None) + os.environ.pop("GAME_DIRECTORY_BACKEND", None) get_storage.cache_clear() + get_game_directory.cache_clear() @pytest.fixture @@ -42,8 +46,7 @@ def _create_game(client): def _set_player_catalogue_context(game_id: str, username: str, *, tech_level: int, lrts=()) -> None: storage = get_storage() - meta = storage.load_game_meta(game_id) - turn = int(meta.get("current_turn", 0)) + turn = get_game_directory().get_game(game_id).current_turn state = storage.load_global_state(game_id, turn) player = next(player for player in state.players if player.username == username) player.research_state.levels = {field: tech_level for field in FIELDS} diff --git a/backend/tests/server/test_game_directory.py b/backend/tests/server/test_game_directory.py new file mode 100644 index 00000000..c59c342d --- /dev/null +++ b/backend/tests/server/test_game_directory.py @@ -0,0 +1,205 @@ +"""Unit tests for the GameDirectory abstraction (Step 2).""" + +from datetime import UTC, datetime +from unittest.mock import MagicMock, patch + +import pytest + +from openstars.game_directory.base import GameNotFoundError, GameSummary +from openstars.game_directory.memory import InMemoryGameDirectory + + +def _summary(game_id: str = "g1", players: list[str] | None = None, created_offset: int = 0): + from datetime import timedelta + + now = datetime.now(UTC) + timedelta(seconds=created_offset) + return GameSummary( + game_id=game_id, + name=f"Game {game_id}", + galaxy_size="small", + seed=42, + players=players or ["alice", "bob"], + current_turn=0, + players_submitted=[], + created_at=now, + updated_at=now, + ) + + +# --- Factory --- + + +class TestBuildGameDirectory: + def test_memory_backend(self, monkeypatch): + monkeypatch.setenv("GAME_DIRECTORY_BACKEND", "memory") + from openstars.game_directory.factory import build_game_directory + from openstars.game_directory.memory import InMemoryGameDirectory + + d = build_game_directory() + assert isinstance(d, InMemoryGameDirectory) + + def test_storage_memory_implies_memory(self, monkeypatch): + monkeypatch.delenv("GAME_DIRECTORY_BACKEND", raising=False) + monkeypatch.setenv("STORAGE_BACKEND", "memory") + from openstars.game_directory.factory import build_game_directory + from openstars.game_directory.memory import InMemoryGameDirectory + + d = build_game_directory() + assert isinstance(d, InMemoryGameDirectory) + + def test_firestore_backend_missing_project_id(self, monkeypatch): + monkeypatch.setenv("GAME_DIRECTORY_BACKEND", "firestore") + monkeypatch.delenv("FIREBASE_PROJECT_ID", raising=False) + from openstars.game_directory.factory import build_game_directory + + with pytest.raises(RuntimeError, match="FIREBASE_PROJECT_ID"): + build_game_directory() + + def test_unknown_backend_raises(self, monkeypatch): + monkeypatch.setenv("GAME_DIRECTORY_BACKEND", "bogus") + from openstars.game_directory.factory import build_game_directory + + with pytest.raises(RuntimeError, match="Unsupported"): + build_game_directory() + + def test_firestore_backend_returns_firestore_directory(self, monkeypatch): + monkeypatch.setenv("GAME_DIRECTORY_BACKEND", "firestore") + monkeypatch.setenv("FIREBASE_PROJECT_ID", "test-project") + with ( + patch("firebase_admin._apps", {}), + patch("firebase_admin.initialize_app"), + patch("firebase_admin.firestore.client", return_value=MagicMock()), + ): + from openstars.game_directory.factory import build_game_directory + from openstars.game_directory.firestore import FirestoreGameDirectory + + d = build_game_directory() + assert isinstance(d, FirestoreGameDirectory) + + +# --- InMemoryGameDirectory --- + + +class TestInMemoryGameDirectory: + def setup_method(self): + self.dir = InMemoryGameDirectory() + + def test_create_and_get_round_trips(self): + s = _summary() + self.dir.create_game("g1", s) + got = self.dir.get_game("g1") + assert got.name == s.name + assert got.players == s.players + assert got.current_turn == 0 + assert got.players_submitted == [] + + def test_get_missing_raises(self): + with pytest.raises(GameNotFoundError): + self.dir.get_game("nope") + + def test_list_games_for_player_filters_by_membership(self): + self.dir.create_game("g1", _summary("g1", ["alice", "bob"])) + self.dir.create_game("g2", _summary("g2", ["carol"])) + results = self.dir.list_games_for_player("alice") + assert len(results) == 1 + assert results[0].game_id == "g1" + + def test_list_games_for_player_orders_most_recent_first(self): + self.dir.create_game("g1", _summary("g1", ["alice"], created_offset=0)) + self.dir.create_game("g2", _summary("g2", ["alice"], created_offset=10)) + results = self.dir.list_games_for_player("alice") + assert results[0].game_id == "g2" + assert results[1].game_id == "g1" + + def test_player_submitted_updates_field_and_bumps_updated_at(self): + s = _summary() + self.dir.create_game("g1", s) + before = self.dir.get_game("g1").updated_at + self.dir.player_submitted("g1", ["alice"]) + after = self.dir.get_game("g1") + assert after.players_submitted == ["alice"] + assert after.updated_at >= before + + def test_turn_resolved_advances_turn_and_resets_submitted(self): + s = _summary() + self.dir.create_game("g1", s) + self.dir.player_submitted("g1", ["alice", "bob"]) + self.dir.turn_resolved("g1", 1) + got = self.dir.get_game("g1") + assert got.current_turn == 1 + assert got.players_submitted == [] + + +# --- GameSummary.all_turns_submitted --- + + +class TestAllTurnsSubmitted: + def test_true_when_all_submitted(self): + s = _summary() + s.players_submitted = ["alice", "bob"] + assert s.all_turns_submitted is True + + def test_false_when_partial(self): + s = _summary() + s.players_submitted = ["alice"] + assert s.all_turns_submitted is False + + def test_false_when_empty(self): + s = _summary() + assert s.all_turns_submitted is False + + def test_true_with_superset_submitted(self): + """Order-independent: submitted is a superset of players.""" + s = _summary() + s.players_submitted = ["bob", "alice"] + assert s.all_turns_submitted is True + + +# --- FirestoreGameDirectory (mock client) --- + + +class TestFirestoreGameDirectoryMock: + def _make_dir(self): + from openstars.game_directory.firestore import FirestoreGameDirectory + + mock_client = MagicMock() + return FirestoreGameDirectory(mock_client), mock_client + + def test_create_game_calls_set_with_expected_payload(self): + d, client = self._make_dir() + s = _summary() + d.create_game("g1", s) + doc_ref = client.collection.return_value.document.return_value + doc_ref.set.assert_called_once() + payload = doc_ref.set.call_args[0][0] + assert payload["name"] == s.name + assert payload["players"] == s.players + assert payload["current_turn"] == 0 + assert payload["players_submitted"] == [] + + def test_player_submitted_calls_set_merge(self): + d, client = self._make_dir() + d.player_submitted("g1", ["alice"]) + doc_ref = client.collection.return_value.document.return_value + doc_ref.set.assert_called_once() + _, kwargs = doc_ref.set.call_args + assert kwargs.get("merge") is True + payload = doc_ref.set.call_args[0][0] + assert payload["players_submitted"] == ["alice"] + + def test_turn_resolved_calls_set_merge_with_new_turn(self): + d, client = self._make_dir() + d.turn_resolved("g1", 2) + doc_ref = client.collection.return_value.document.return_value + doc_ref.set.assert_called_once() + _, kwargs = doc_ref.set.call_args + assert kwargs.get("merge") is True + payload = doc_ref.set.call_args[0][0] + assert payload["current_turn"] == 2 + assert payload["players_submitted"] == [] + + def test_delete_game_calls_delete(self): + d, client = self._make_dir() + d.delete_game("g1") + doc_ref = client.collection.return_value.document.return_value + doc_ref.delete.assert_called_once() diff --git a/backend/tests/server/test_global_state_designs.py b/backend/tests/server/test_global_state_designs.py index dbb16097..1a374655 100644 --- a/backend/tests/server/test_global_state_designs.py +++ b/backend/tests/server/test_global_state_designs.py @@ -14,13 +14,17 @@ def _setup_storage(tmp_path): os.environ["STORAGE_BACKEND"] = "local" os.environ["GAME_DATA_PATH"] = str(tmp_path) - from openstars.server.deps import get_storage + os.environ["GAME_DIRECTORY_BACKEND"] = "memory" + from openstars.server.deps import get_game_directory, get_storage get_storage.cache_clear() + get_game_directory.cache_clear() yield os.environ.pop("STORAGE_BACKEND", None) os.environ.pop("GAME_DATA_PATH", None) + os.environ.pop("GAME_DIRECTORY_BACKEND", None) get_storage.cache_clear() + get_game_directory.cache_clear() @pytest.fixture diff --git a/backend/tests/server/test_race_routes.py b/backend/tests/server/test_race_routes.py index f91b0ada..05c529bb 100644 --- a/backend/tests/server/test_race_routes.py +++ b/backend/tests/server/test_race_routes.py @@ -20,14 +20,18 @@ def _setup_storage(tmp_path): os.environ["STORAGE_BACKEND"] = "local" os.environ["GAME_DATA_PATH"] = str(tmp_path) + os.environ["GAME_DIRECTORY_BACKEND"] = "memory" - from openstars.server.deps import get_storage + from openstars.server.deps import get_game_directory, get_storage get_storage.cache_clear() + get_game_directory.cache_clear() yield os.environ.pop("STORAGE_BACKEND", None) os.environ.pop("GAME_DATA_PATH", None) + os.environ.pop("GAME_DIRECTORY_BACKEND", None) get_storage.cache_clear() + get_game_directory.cache_clear() @pytest.fixture diff --git a/backend/tests/server/test_submit_auto_resolve.py b/backend/tests/server/test_submit_auto_resolve.py new file mode 100644 index 00000000..96ce4aa8 --- /dev/null +++ b/backend/tests/server/test_submit_auto_resolve.py @@ -0,0 +1,191 @@ +"""Unit tests for the auto-resolve flow in POST /commands (Step 3).""" + +from datetime import UTC, datetime + +import pytest +from fastapi.testclient import TestClient + +from openstars.game_directory.base import GameSummary + + +def _summary(game_id: str = "g1", players: list[str] | None = None, turn: int = 0) -> GameSummary: + now = datetime.now(UTC) + return GameSummary( + game_id=game_id, + name="Test", + galaxy_size="small", + seed=42, + players=players or ["tim", "matt"], + current_turn=turn, + players_submitted=[], + created_at=now, + updated_at=now, + ) + + +@pytest.fixture(autouse=True) +def _setup(tmp_path, monkeypatch): + """Local storage + in-memory directory for each test.""" + monkeypatch.setenv("STORAGE_BACKEND", "local") + monkeypatch.setenv("GAME_DATA_PATH", str(tmp_path)) + monkeypatch.setenv("GAME_DIRECTORY_BACKEND", "memory") + from openstars.server.deps import get_game_directory, get_storage + + get_storage.cache_clear() + get_game_directory.cache_clear() + yield + get_storage.cache_clear() + get_game_directory.cache_clear() + + +@pytest.fixture +def client(): + from openstars.server.main import app + + return TestClient(app) + + +def _create_game(client, players=("tim", "matt")): + """POST /games and advance through T=0 race selection (auto-resolves to T=1).""" + resp = client.post( + "/api/v1/games", + json={"name": "Test Game", "galaxy_size": "small", "players": list(players)}, + ) + assert resp.status_code == 201 + game_id = resp.json()["game_id"] + for p in players: + r = client.post( + f"/api/v1/games/{game_id}/commands", + json={"turn": 0, "commands": [{"type": "select_race", "predefined_id": "humanoid"}]}, + headers={"X-Player": p}, + ) + assert r.status_code == 200 + return game_id + + +class TestAutoResolveUnit: + def test_partial_submit_returns_not_resolved(self, client): + game_id = _create_game(client) + resp = client.post( + f"/api/v1/games/{game_id}/commands", + json={"turn": 1, "commands": []}, + headers={"X-Player": "tim"}, + ) + assert resp.status_code == 200 + assert resp.json()["turn_resolved"] is False + assert resp.json()["new_turn"] is None + + def test_last_submit_returns_resolved_with_new_turn(self, client): + game_id = _create_game(client) + client.post( + f"/api/v1/games/{game_id}/commands", + json={"turn": 1, "commands": []}, + headers={"X-Player": "tim"}, + ) + resp = client.post( + f"/api/v1/games/{game_id}/commands", + json={"turn": 1, "commands": []}, + headers={"X-Player": "matt"}, + ) + assert resp.status_code == 200 + assert resp.json()["turn_resolved"] is True + assert resp.json()["new_turn"] == 2 + + def test_last_submit_advances_directory_turn(self, client): + game_id = _create_game(client) + from openstars.server.deps import get_game_directory + + directory = get_game_directory() + assert directory.get_game(game_id).current_turn == 1 + + client.post( + f"/api/v1/games/{game_id}/commands", + json={"turn": 1, "commands": []}, + headers={"X-Player": "tim"}, + ) + client.post( + f"/api/v1/games/{game_id}/commands", + json={"turn": 1, "commands": []}, + headers={"X-Player": "matt"}, + ) + assert directory.get_game(game_id).current_turn == 2 + assert directory.get_game(game_id).players_submitted == [] + + def test_directory_player_submitted_called_on_each_submit(self, client): + game_id = _create_game(client) + from openstars.server.deps import get_game_directory + + directory = get_game_directory() + + client.post( + f"/api/v1/games/{game_id}/commands", + json={"turn": 1, "commands": []}, + headers={"X-Player": "tim"}, + ) + assert "tim" in directory.get_game(game_id).players_submitted + + def test_idempotent_resubmit_does_not_advance_turn(self, client): + game_id = _create_game(client) + from openstars.server.deps import get_game_directory + + directory = get_game_directory() + + client.post( + f"/api/v1/games/{game_id}/commands", + json={"turn": 1, "commands": []}, + headers={"X-Player": "tim"}, + ) + # Resubmit — still only tim submitted, turn does not advance. + client.post( + f"/api/v1/games/{game_id}/commands", + json={"turn": 1, "commands": []}, + headers={"X-Player": "tim"}, + ) + assert directory.get_game(game_id).current_turn == 1 + + def test_turn_zero_auto_resolves_on_last_race_select(self, client): + resp = client.post( + "/api/v1/games", + json={"name": "T0 Game", "galaxy_size": "small", "players": ["tim", "matt"]}, + ) + assert resp.status_code == 201 + game_id = resp.json()["game_id"] + + client.post( + f"/api/v1/games/{game_id}/commands", + json={"turn": 0, "commands": [{"type": "select_race", "predefined_id": "humanoid"}]}, + headers={"X-Player": "tim"}, + ) + last = client.post( + f"/api/v1/games/{game_id}/commands", + json={"turn": 0, "commands": [{"type": "select_race", "predefined_id": "humanoid"}]}, + headers={"X-Player": "matt"}, + ) + assert last.json()["turn_resolved"] is True + assert last.json()["new_turn"] == 1 + + def test_resolution_failure_returns_500_with_code(self, client, monkeypatch): + game_id = _create_game(client) + + def boom(*args, **kwargs): + raise RuntimeError("engine on fire") + + monkeypatch.setattr("openstars.server.routes.play.resolve_current_turn", boom) + + client.post( + f"/api/v1/games/{game_id}/commands", + json={"turn": 1, "commands": []}, + headers={"X-Player": "tim"}, + ) + resp = client.post( + f"/api/v1/games/{game_id}/commands", + json={"turn": 1, "commands": []}, + headers={"X-Player": "matt"}, + ) + assert resp.status_code == 500 + assert resp.json()["error"]["code"] == "RESOLUTION_FAILED" + + def test_resolve_endpoint_returns_404(self, client): + game_id = _create_game(client) + resp = client.post(f"/api/v1/games/{game_id}/resolve", headers={"X-Player": "tim"}) + assert resp.status_code == 404 diff --git a/backend/uv.lock b/backend/uv.lock index fc52447a..7dbdb2ee 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -2,7 +2,8 @@ version = 1 revision = 3 requires-python = ">=3.12" resolution-markers = [ - "python_full_version >= '3.13'", + "python_full_version >= '3.14'", + "python_full_version == '3.13.*'", "python_full_version < '3.13'", ] @@ -37,6 +38,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, ] +[[package]] +name = "cachecontrol" +version = "0.14.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "msgpack" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2d/f6/c972b32d80760fb79d6b9eeb0b3010a46b89c0b23cf6329417ff7886cd22/cachecontrol-0.14.4.tar.gz", hash = "sha256:e6220afafa4c22a47dd0badb319f84475d79108100d04e26e8542ef7d3ab05a1", size = 16150, upload-time = "2025-11-14T04:32:13.138Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/79/c45f2d53efe6ada1110cf6f9fca095e4ff47a0454444aefdde6ac4789179/cachecontrol-0.14.4-py3-none-any.whl", hash = "sha256:b7ac014ff72ee199b5f8af1de29d60239954f223e948196fa3d84adaffc71d2b", size = 22247, upload-time = "2025-11-14T04:32:11.733Z" }, +] + [[package]] name = "certifi" version = "2026.2.25" @@ -266,6 +280,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5a/ff/2e4eca3ade2c22fe1dea7043b8ee9dabe47753349eb1b56a202de8af6349/fastapi-0.136.1-py3-none-any.whl", hash = "sha256:a6e9d7eeada96c93a4d69cb03836b44fa34e2854accb7244a1ece36cd4781c3f", size = 117683, upload-time = "2026-04-23T16:49:42.437Z" }, ] +[[package]] +name = "firebase-admin" +version = "7.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cachecontrol" }, + { name = "google-api-core", extra = ["grpc"], marker = "platform_python_implementation != 'PyPy'" }, + { name = "google-cloud-firestore", marker = "platform_python_implementation != 'PyPy'" }, + { name = "google-cloud-storage" }, + { name = "httpx", extra = ["http2"] }, + { name = "pyjwt", extra = ["crypto"] }, +] +sdist = { url = "https://files.pythonhosted.org/packages/07/ab/2c551264041ddaff7dedd53893ab4642b19008bfb986567fb6ded6380831/firebase_admin-7.4.0.tar.gz", hash = "sha256:08d7550efdba32fbd306141ce82150f9ffb91c1550e07aa4ed5757af4895d1ff", size = 201086, upload-time = "2026-04-09T20:53:08.065Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/40/87f412ce2df5e57feb456b3b9b3427b2af3a35e1eaa14addfb283f5247fb/firebase_admin-7.4.0-py3-none-any.whl", hash = "sha256:416967d1aacd30cfece2a5b0c606a52ba7614eb3093f054399be3868489cd7a8", size = 140987, upload-time = "2026-04-09T20:53:06.568Z" }, +] + [[package]] name = "google-api-core" version = "2.30.2" @@ -282,6 +313,12 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/84/e1/ebd5100cbb202e561c0c8b59e485ef3bd63fa9beb610f3fdcaea443f0288/google_api_core-2.30.2-py3-none-any.whl", hash = "sha256:a4c226766d6af2580577db1f1a51bf53cd262f722b49731ce7414c43068a9594", size = 173236, upload-time = "2026-04-02T21:23:06.395Z" }, ] +[package.optional-dependencies] +grpc = [ + { name = "grpcio" }, + { name = "grpcio-status" }, +] + [[package]] name = "google-auth" version = "2.49.1" @@ -308,6 +345,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/73/d9/5bb050cb32826466aa9b25f79e2ca2879fe66cb76782d4ed798dd7506151/google_cloud_core-2.5.1-py3-none-any.whl", hash = "sha256:ea62cdf502c20e3e14be8a32c05ed02113d7bef454e40ff3fab6fe1ec9f1f4e7", size = 29452, upload-time = "2026-03-30T22:48:31.567Z" }, ] +[[package]] +name = "google-cloud-firestore" +version = "2.27.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-api-core", extra = ["grpc"] }, + { name = "google-auth" }, + { name = "google-cloud-core" }, + { name = "grpcio" }, + { name = "proto-plus" }, + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4c/83/bdfd4387fadc5f44de1d0f97b456c648b6f87ec0b1818a9f7f477e6e6eab/google_cloud_firestore-2.27.0.tar.gz", hash = "sha256:5633cb164ef56ca6c73a807822191a56a98f6f10e76978c4f2eb197ae03383d2", size = 649244, upload-time = "2026-04-13T22:55:43.931Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/db/fc72c4279887cc95011f16e70200d021030f0261e6391fa52e3adfaaea25/google_cloud_firestore-2.27.0-py3-none-any.whl", hash = "sha256:cc2ea78bc2d4dcc928016d56802deacfda3c9bbda0a7d691ee73b41a2f1a80d7", size = 429806, upload-time = "2026-04-13T22:55:41.726Z" }, +] + [[package]] name = "google-cloud-storage" version = "3.10.1" @@ -372,6 +426,61 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dc/82/fcb6520612bec0c39b973a6c0954b6a0d948aadfe8f7e9487f60ceb8bfa6/googleapis_common_protos-1.73.1-py3-none-any.whl", hash = "sha256:e51f09eb0a43a8602f5a915870972e6b4a394088415c79d79605a46d8e826ee8", size = 297556, upload-time = "2026-03-26T22:15:58.455Z" }, ] +[[package]] +name = "grpcio" +version = "1.80.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b7/48/af6173dbca4454f4637a4678b67f52ca7e0c1ed7d5894d89d434fecede05/grpcio-1.80.0.tar.gz", hash = "sha256:29aca15edd0688c22ba01d7cc01cb000d72b2033f4a3c72a81a19b56fd143257", size = 12978905, upload-time = "2026-03-30T08:49:10.502Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/e8/a2b749265eb3415abc94f2e619bbd9e9707bebdda787e61c593004ec927a/grpcio-1.80.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:c624cc9f1008361014378c9d776de7182b11fe8b2e5a81bc69f23a295f2a1ad0", size = 6015616, upload-time = "2026-03-30T08:47:13.428Z" }, + { url = "https://files.pythonhosted.org/packages/3e/97/b1282161a15d699d1e90c360df18d19165a045ce1c343c7f313f5e8a0b77/grpcio-1.80.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:f49eddcac43c3bf350c0385366a58f36bed8cc2c0ec35ef7b74b49e56552c0c2", size = 12014204, upload-time = "2026-03-30T08:47:15.873Z" }, + { url = "https://files.pythonhosted.org/packages/6e/5e/d319c6e997b50c155ac5a8cb12f5173d5b42677510e886d250d50264949d/grpcio-1.80.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d334591df610ab94714048e0d5b4f3dd5ad1bee74dfec11eee344220077a79de", size = 6563866, upload-time = "2026-03-30T08:47:18.588Z" }, + { url = "https://files.pythonhosted.org/packages/ae/f6/fdd975a2cb4d78eb67769a7b3b3830970bfa2e919f1decf724ae4445f42c/grpcio-1.80.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:0cb517eb1d0d0aaf1d87af7cc5b801d686557c1d88b2619f5e31fab3c2315921", size = 7273060, upload-time = "2026-03-30T08:47:21.113Z" }, + { url = "https://files.pythonhosted.org/packages/db/f0/a3deb5feba60d9538a962913e37bd2e69a195f1c3376a3dd44fe0427e996/grpcio-1.80.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4e78c4ac0d97dc2e569b2f4bcbbb447491167cb358d1a389fc4af71ab6f70411", size = 6782121, upload-time = "2026-03-30T08:47:23.827Z" }, + { url = "https://files.pythonhosted.org/packages/ca/84/36c6dcfddc093e108141f757c407902a05085e0c328007cb090d56646cdf/grpcio-1.80.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2ed770b4c06984f3b47eb0517b1c69ad0b84ef3f40128f51448433be904634cd", size = 7383811, upload-time = "2026-03-30T08:47:26.517Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ef/f3a77e3dc5b471a0ec86c564c98d6adfa3510d38f8ee99010410858d591e/grpcio-1.80.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:256507e2f524092f1473071a05e65a5b10d84b82e3ff24c5b571513cfaa61e2f", size = 8393860, upload-time = "2026-03-30T08:47:29.439Z" }, + { url = "https://files.pythonhosted.org/packages/9b/8d/9d4d27ed7f33d109c50d6b5ce578a9914aa68edab75d65869a17e630a8d1/grpcio-1.80.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9a6284a5d907c37db53350645567c522be314bac859a64a7a5ca63b77bb7958f", size = 7830132, upload-time = "2026-03-30T08:47:33.254Z" }, + { url = "https://files.pythonhosted.org/packages/14/e4/9990b41c6d7a44e1e9dee8ac11d7a9802ba1378b40d77468a7761d1ad288/grpcio-1.80.0-cp312-cp312-win32.whl", hash = "sha256:c71309cfce2f22be26aa4a847357c502db6c621f1a49825ae98aa0907595b193", size = 4140904, upload-time = "2026-03-30T08:47:35.319Z" }, + { url = "https://files.pythonhosted.org/packages/2f/2c/296f6138caca1f4b92a31ace4ae1b87dab692fc16a7a3417af3bb3c805bf/grpcio-1.80.0-cp312-cp312-win_amd64.whl", hash = "sha256:9fe648599c0e37594c4809d81a9e77bd138cc82eb8baa71b6a86af65426723ff", size = 4880944, upload-time = "2026-03-30T08:47:37.831Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/7c3c25789e3f069e581dc342e03613c5b1cb012c4e8c7d9d5cf960a75856/grpcio-1.80.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:e9e408fc016dffd20661f0126c53d8a31c2821b5c13c5d67a0f5ed5de93319ad", size = 6017243, upload-time = "2026-03-30T08:47:40.075Z" }, + { url = "https://files.pythonhosted.org/packages/04/19/21a9806eb8240e174fd1ab0cd5b9aa948bb0e05c2f2f55f9d5d7405e6d08/grpcio-1.80.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:92d787312e613754d4d8b9ca6d3297e69994a7912a32fa38c4c4e01c272974b0", size = 12010840, upload-time = "2026-03-30T08:47:43.11Z" }, + { url = "https://files.pythonhosted.org/packages/18/3a/23347d35f76f639e807fb7a36fad3068aed100996849a33809591f26eca6/grpcio-1.80.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8ac393b58aa16991a2f1144ec578084d544038c12242da3a215966b512904d0f", size = 6567644, upload-time = "2026-03-30T08:47:46.806Z" }, + { url = "https://files.pythonhosted.org/packages/ff/40/96e07ecb604a6a67ae6ab151e3e35b132875d98bc68ec65f3e5ab3e781d7/grpcio-1.80.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:68e5851ac4b9afe07e7f84483803ad167852570d65326b34d54ca560bfa53fb6", size = 7277830, upload-time = "2026-03-30T08:47:49.643Z" }, + { url = "https://files.pythonhosted.org/packages/9b/e2/da1506ecea1f34a5e365964644b35edef53803052b763ca214ba3870c856/grpcio-1.80.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:873ff5d17d68992ef6605330127425d2fc4e77e612fa3c3e0ed4e668685e3140", size = 6783216, upload-time = "2026-03-30T08:47:52.817Z" }, + { url = "https://files.pythonhosted.org/packages/44/83/3b20ff58d0c3b7f6caaa3af9a4174d4023701df40a3f39f7f1c8e7c48f9d/grpcio-1.80.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2bea16af2750fd0a899bf1abd9022244418b55d1f37da2202249ba4ba673838d", size = 7385866, upload-time = "2026-03-30T08:47:55.687Z" }, + { url = "https://files.pythonhosted.org/packages/47/45/55c507599c5520416de5eefecc927d6a0d7af55e91cfffb2e410607e5744/grpcio-1.80.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba0db34f7e1d803a878284cd70e4c63cb6ae2510ba51937bf8f45ba997cefcf7", size = 8391602, upload-time = "2026-03-30T08:47:58.303Z" }, + { url = "https://files.pythonhosted.org/packages/10/bb/dd06f4c24c01db9cf11341b547d0a016b2c90ed7dbbb086a5710df7dd1d7/grpcio-1.80.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8eb613f02d34721f1acf3626dfdb3545bd3c8505b0e52bf8b5710a28d02e8aa7", size = 7826752, upload-time = "2026-03-30T08:48:01.311Z" }, + { url = "https://files.pythonhosted.org/packages/f9/1e/9d67992ba23371fd63d4527096eb8c6b76d74d52b500df992a3343fd7251/grpcio-1.80.0-cp313-cp313-win32.whl", hash = "sha256:93b6f823810720912fd131f561f91f5fed0fda372b6b7028a2681b8194d5d294", size = 4142310, upload-time = "2026-03-30T08:48:04.594Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e6/283326a27da9e2c3038bc93eeea36fb118ce0b2d03922a9cda6688f53c5b/grpcio-1.80.0-cp313-cp313-win_amd64.whl", hash = "sha256:e172cf795a3ba5246d3529e4d34c53db70e888fa582a8ffebd2e6e48bc0cba50", size = 4882833, upload-time = "2026-03-30T08:48:07.363Z" }, + { url = "https://files.pythonhosted.org/packages/c5/6d/e65307ce20f5a09244ba9e9d8476e99fb039de7154f37fb85f26978b59c3/grpcio-1.80.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:3d4147a97c8344d065d01bbf8b6acec2cf86fb0400d40696c8bdad34a64ffc0e", size = 6017376, upload-time = "2026-03-30T08:48:10.005Z" }, + { url = "https://files.pythonhosted.org/packages/69/10/9cef5d9650c72625a699c549940f0abb3c4bfdb5ed45a5ce431f92f31806/grpcio-1.80.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d8e11f167935b3eb089ac9038e1a063e6d7dbe995c0bb4a661e614583352e76f", size = 12018133, upload-time = "2026-03-30T08:48:12.927Z" }, + { url = "https://files.pythonhosted.org/packages/04/82/983aabaad82ba26113caceeb9091706a0696b25da004fe3defb5b346e15b/grpcio-1.80.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f14b618fc30de822681ee986cfdcc2d9327229dc4c98aed16896761cacd468b9", size = 6574748, upload-time = "2026-03-30T08:48:16.386Z" }, + { url = "https://files.pythonhosted.org/packages/07/d7/031666ef155aa0bf399ed7e19439656c38bbd143779ae0861b038ce82abd/grpcio-1.80.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:4ed39fbdcf9b87370f6e8df4e39ca7b38b3e5e9d1b0013c7b6be9639d6578d14", size = 7277711, upload-time = "2026-03-30T08:48:19.627Z" }, + { url = "https://files.pythonhosted.org/packages/e8/43/f437a78f7f4f1d311804189e8f11fb311a01049b2e08557c1068d470cb2e/grpcio-1.80.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2dcc70e9f0ba987526e8e8603a610fb4f460e42899e74e7a518bf3c68fe1bf05", size = 6785372, upload-time = "2026-03-30T08:48:22.373Z" }, + { url = "https://files.pythonhosted.org/packages/93/3d/f6558e9c6296cb4227faa5c43c54a34c68d32654b829f53288313d16a86e/grpcio-1.80.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:448c884b668b868562b1bda833c5fce6272d26e1926ec46747cda05741d302c1", size = 7395268, upload-time = "2026-03-30T08:48:25.638Z" }, + { url = "https://files.pythonhosted.org/packages/06/21/0fdd77e84720b08843c371a2efa6f2e19dbebf56adc72df73d891f5506f0/grpcio-1.80.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a1dc80fe55685b4a543555e6eef975303b36c8db1023b1599b094b92aa77965f", size = 8392000, upload-time = "2026-03-30T08:48:28.974Z" }, + { url = "https://files.pythonhosted.org/packages/f5/68/67f4947ed55d2e69f2cc199ab9fd85e0a0034d813bbeef84df6d2ba4d4b7/grpcio-1.80.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:31b9ac4ad1aa28ffee5503821fafd09e4da0a261ce1c1281c6c8da0423c83b6e", size = 7828477, upload-time = "2026-03-30T08:48:32.054Z" }, + { url = "https://files.pythonhosted.org/packages/44/b6/8d4096691b2e385e8271911a0de4f35f0a6c7d05aff7098e296c3de86939/grpcio-1.80.0-cp314-cp314-win32.whl", hash = "sha256:367ce30ba67d05e0592470428f0ec1c31714cab9ef19b8f2e37be1f4c7d32fae", size = 4218563, upload-time = "2026-03-30T08:48:34.538Z" }, + { url = "https://files.pythonhosted.org/packages/e5/8c/bbe6baf2557262834f2070cf668515fa308b2d38a4bbf771f8f7872a7036/grpcio-1.80.0-cp314-cp314-win_amd64.whl", hash = "sha256:3b01e1f5464c583d2f567b2e46ff0d516ef979978f72091fd81f5ab7fa6e2e7f", size = 5019457, upload-time = "2026-03-30T08:48:37.308Z" }, +] + +[[package]] +name = "grpcio-status" +version = "1.80.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "googleapis-common-protos" }, + { name = "grpcio" }, + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/ed/105f619bdd00cb47a49aa2feea6232ea2bbb04199d52a22cc6a7d603b5cb/grpcio_status-1.80.0.tar.gz", hash = "sha256:df73802a4c89a3ea88aa2aff971e886fccce162bc2e6511408b3d67a144381cd", size = 13901, upload-time = "2026-03-30T08:54:34.784Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/80/58cd2dfc19a07d022abe44bde7c365627f6c7cb6f692ada6c65ca437d09a/grpcio_status-1.80.0-py3-none-any.whl", hash = "sha256:4b56990363af50dbf2c2ebb80f1967185c07d87aa25aa2bea45ddb75fc181dbe", size = 14638, upload-time = "2026-03-30T08:54:01.569Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -381,6 +490,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, ] +[[package]] +name = "h2" +version = "4.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "hpack" }, + { name = "hyperframe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1d/17/afa56379f94ad0fe8defd37d6eb3f89a25404ffc71d4d848893d270325fc/h2-4.3.0.tar.gz", hash = "sha256:6c59efe4323fa18b47a632221a1888bd7fde6249819beda254aeca909f221bf1", size = 2152026, upload-time = "2025-08-23T18:12:19.778Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/b2/119f6e6dcbd96f9069ce9a2665e0146588dc9f88f29549711853645e736a/h2-4.3.0-py3-none-any.whl", hash = "sha256:c438f029a25f7945c69e0ccf0fb951dc3f73a5f6412981daee861431b70e2bdd", size = 61779, upload-time = "2025-08-23T18:12:17.779Z" }, +] + +[[package]] +name = "hpack" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/48/71de9ed269fdae9c8057e5a4c0aa7402e8bb16f2c6e90b3aa53327b113f8/hpack-4.1.0.tar.gz", hash = "sha256:ec5eca154f7056aa06f196a557655c5b009b382873ac8d1e66e79e87535f1dca", size = 51276, upload-time = "2025-01-22T21:44:58.347Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/c6/80c95b1b2b94682a72cbdbfb85b81ae2daffa4291fbfa1b1464502ede10d/hpack-4.1.0-py3-none-any.whl", hash = "sha256:157ac792668d995c657d93111f46b4535ed114f0c9c8d672271bbec7eae1b496", size = 34357, upload-time = "2025-01-22T21:44:56.92Z" }, +] + [[package]] name = "httpcore" version = "1.0.9" @@ -409,6 +540,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, ] +[package.optional-dependencies] +http2 = [ + { name = "h2" }, +] + +[[package]] +name = "hyperframe" +version = "6.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/02/e7/94f8232d4a74cc99514c13a9f995811485a6903d48e5d952771ef6322e30/hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08", size = 26566, upload-time = "2025-01-22T21:41:49.302Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5", size = 13007, upload-time = "2025-01-22T21:41:47.295Z" }, +] + [[package]] name = "idna" version = "3.11" @@ -427,12 +572,57 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "msgpack" +version = "1.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4d/f2/bfb55a6236ed8725a96b0aa3acbd0ec17588e6a2c3b62a93eb513ed8783f/msgpack-1.1.2.tar.gz", hash = "sha256:3b60763c1373dd60f398488069bcdc703cd08a711477b5d480eecc9f9626f47e", size = 173581, upload-time = "2025-10-08T09:15:56.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ad/bd/8b0d01c756203fbab65d265859749860682ccd2a59594609aeec3a144efa/msgpack-1.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:70a0dff9d1f8da25179ffcf880e10cf1aad55fdb63cd59c9a49a1b82290062aa", size = 81939, upload-time = "2025-10-08T09:15:01.472Z" }, + { url = "https://files.pythonhosted.org/packages/34/68/ba4f155f793a74c1483d4bdef136e1023f7bcba557f0db4ef3db3c665cf1/msgpack-1.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:446abdd8b94b55c800ac34b102dffd2f6aa0ce643c55dfc017ad89347db3dbdb", size = 85064, upload-time = "2025-10-08T09:15:03.764Z" }, + { url = "https://files.pythonhosted.org/packages/f2/60/a064b0345fc36c4c3d2c743c82d9100c40388d77f0b48b2f04d6041dbec1/msgpack-1.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c63eea553c69ab05b6747901b97d620bb2a690633c77f23feb0c6a947a8a7b8f", size = 417131, upload-time = "2025-10-08T09:15:05.136Z" }, + { url = "https://files.pythonhosted.org/packages/65/92/a5100f7185a800a5d29f8d14041f61475b9de465ffcc0f3b9fba606e4505/msgpack-1.1.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:372839311ccf6bdaf39b00b61288e0557916c3729529b301c52c2d88842add42", size = 427556, upload-time = "2025-10-08T09:15:06.837Z" }, + { url = "https://files.pythonhosted.org/packages/f5/87/ffe21d1bf7d9991354ad93949286f643b2bb6ddbeab66373922b44c3b8cc/msgpack-1.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2929af52106ca73fcb28576218476ffbb531a036c2adbcf54a3664de124303e9", size = 404920, upload-time = "2025-10-08T09:15:08.179Z" }, + { url = "https://files.pythonhosted.org/packages/ff/41/8543ed2b8604f7c0d89ce066f42007faac1eaa7d79a81555f206a5cdb889/msgpack-1.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:be52a8fc79e45b0364210eef5234a7cf8d330836d0a64dfbb878efa903d84620", size = 415013, upload-time = "2025-10-08T09:15:09.83Z" }, + { url = "https://files.pythonhosted.org/packages/41/0d/2ddfaa8b7e1cee6c490d46cb0a39742b19e2481600a7a0e96537e9c22f43/msgpack-1.1.2-cp312-cp312-win32.whl", hash = "sha256:1fff3d825d7859ac888b0fbda39a42d59193543920eda9d9bea44d958a878029", size = 65096, upload-time = "2025-10-08T09:15:11.11Z" }, + { url = "https://files.pythonhosted.org/packages/8c/ec/d431eb7941fb55a31dd6ca3404d41fbb52d99172df2e7707754488390910/msgpack-1.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:1de460f0403172cff81169a30b9a92b260cb809c4cb7e2fc79ae8d0510c78b6b", size = 72708, upload-time = "2025-10-08T09:15:12.554Z" }, + { url = "https://files.pythonhosted.org/packages/c5/31/5b1a1f70eb0e87d1678e9624908f86317787b536060641d6798e3cf70ace/msgpack-1.1.2-cp312-cp312-win_arm64.whl", hash = "sha256:be5980f3ee0e6bd44f3a9e9dea01054f175b50c3e6cdb692bc9424c0bbb8bf69", size = 64119, upload-time = "2025-10-08T09:15:13.589Z" }, + { url = "https://files.pythonhosted.org/packages/6b/31/b46518ecc604d7edf3a4f94cb3bf021fc62aa301f0cb849936968164ef23/msgpack-1.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4efd7b5979ccb539c221a4c4e16aac1a533efc97f3b759bb5a5ac9f6d10383bf", size = 81212, upload-time = "2025-10-08T09:15:14.552Z" }, + { url = "https://files.pythonhosted.org/packages/92/dc/c385f38f2c2433333345a82926c6bfa5ecfff3ef787201614317b58dd8be/msgpack-1.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:42eefe2c3e2af97ed470eec850facbe1b5ad1d6eacdbadc42ec98e7dcf68b4b7", size = 84315, upload-time = "2025-10-08T09:15:15.543Z" }, + { url = "https://files.pythonhosted.org/packages/d3/68/93180dce57f684a61a88a45ed13047558ded2be46f03acb8dec6d7c513af/msgpack-1.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1fdf7d83102bf09e7ce3357de96c59b627395352a4024f6e2458501f158bf999", size = 412721, upload-time = "2025-10-08T09:15:16.567Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ba/459f18c16f2b3fc1a1ca871f72f07d70c07bf768ad0a507a698b8052ac58/msgpack-1.1.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fac4be746328f90caa3cd4bc67e6fe36ca2bf61d5c6eb6d895b6527e3f05071e", size = 424657, upload-time = "2025-10-08T09:15:17.825Z" }, + { url = "https://files.pythonhosted.org/packages/38/f8/4398c46863b093252fe67368b44edc6c13b17f4e6b0e4929dbf0bdb13f23/msgpack-1.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:fffee09044073e69f2bad787071aeec727183e7580443dfeb8556cbf1978d162", size = 402668, upload-time = "2025-10-08T09:15:19.003Z" }, + { url = "https://files.pythonhosted.org/packages/28/ce/698c1eff75626e4124b4d78e21cca0b4cc90043afb80a507626ea354ab52/msgpack-1.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5928604de9b032bc17f5099496417f113c45bc6bc21b5c6920caf34b3c428794", size = 419040, upload-time = "2025-10-08T09:15:20.183Z" }, + { url = "https://files.pythonhosted.org/packages/67/32/f3cd1667028424fa7001d82e10ee35386eea1408b93d399b09fb0aa7875f/msgpack-1.1.2-cp313-cp313-win32.whl", hash = "sha256:a7787d353595c7c7e145e2331abf8b7ff1e6673a6b974ded96e6d4ec09f00c8c", size = 65037, upload-time = "2025-10-08T09:15:21.416Z" }, + { url = "https://files.pythonhosted.org/packages/74/07/1ed8277f8653c40ebc65985180b007879f6a836c525b3885dcc6448ae6cb/msgpack-1.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:a465f0dceb8e13a487e54c07d04ae3ba131c7c5b95e2612596eafde1dccf64a9", size = 72631, upload-time = "2025-10-08T09:15:22.431Z" }, + { url = "https://files.pythonhosted.org/packages/e5/db/0314e4e2db56ebcf450f277904ffd84a7988b9e5da8d0d61ab2d057df2b6/msgpack-1.1.2-cp313-cp313-win_arm64.whl", hash = "sha256:e69b39f8c0aa5ec24b57737ebee40be647035158f14ed4b40e6f150077e21a84", size = 64118, upload-time = "2025-10-08T09:15:23.402Z" }, + { url = "https://files.pythonhosted.org/packages/22/71/201105712d0a2ff07b7873ed3c220292fb2ea5120603c00c4b634bcdafb3/msgpack-1.1.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e23ce8d5f7aa6ea6d2a2b326b4ba46c985dbb204523759984430db7114f8aa00", size = 81127, upload-time = "2025-10-08T09:15:24.408Z" }, + { url = "https://files.pythonhosted.org/packages/1b/9f/38ff9e57a2eade7bf9dfee5eae17f39fc0e998658050279cbb14d97d36d9/msgpack-1.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6c15b7d74c939ebe620dd8e559384be806204d73b4f9356320632d783d1f7939", size = 84981, upload-time = "2025-10-08T09:15:25.812Z" }, + { url = "https://files.pythonhosted.org/packages/8e/a9/3536e385167b88c2cc8f4424c49e28d49a6fc35206d4a8060f136e71f94c/msgpack-1.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:99e2cb7b9031568a2a5c73aa077180f93dd2e95b4f8d3b8e14a73ae94a9e667e", size = 411885, upload-time = "2025-10-08T09:15:27.22Z" }, + { url = "https://files.pythonhosted.org/packages/2f/40/dc34d1a8d5f1e51fc64640b62b191684da52ca469da9cd74e84936ffa4a6/msgpack-1.1.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:180759d89a057eab503cf62eeec0aa61c4ea1200dee709f3a8e9397dbb3b6931", size = 419658, upload-time = "2025-10-08T09:15:28.4Z" }, + { url = "https://files.pythonhosted.org/packages/3b/ef/2b92e286366500a09a67e03496ee8b8ba00562797a52f3c117aa2b29514b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:04fb995247a6e83830b62f0b07bf36540c213f6eac8e851166d8d86d83cbd014", size = 403290, upload-time = "2025-10-08T09:15:29.764Z" }, + { url = "https://files.pythonhosted.org/packages/78/90/e0ea7990abea5764e4655b8177aa7c63cdfa89945b6e7641055800f6c16b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8e22ab046fa7ede9e36eeb4cfad44d46450f37bb05d5ec482b02868f451c95e2", size = 415234, upload-time = "2025-10-08T09:15:31.022Z" }, + { url = "https://files.pythonhosted.org/packages/72/4e/9390aed5db983a2310818cd7d3ec0aecad45e1f7007e0cda79c79507bb0d/msgpack-1.1.2-cp314-cp314-win32.whl", hash = "sha256:80a0ff7d4abf5fecb995fcf235d4064b9a9a8a40a3ab80999e6ac1e30b702717", size = 66391, upload-time = "2025-10-08T09:15:32.265Z" }, + { url = "https://files.pythonhosted.org/packages/6e/f1/abd09c2ae91228c5f3998dbd7f41353def9eac64253de3c8105efa2082f7/msgpack-1.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:9ade919fac6a3e7260b7f64cea89df6bec59104987cbea34d34a2fa15d74310b", size = 73787, upload-time = "2025-10-08T09:15:33.219Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b0/9d9f667ab48b16ad4115c1935d94023b82b3198064cb84a123e97f7466c1/msgpack-1.1.2-cp314-cp314-win_arm64.whl", hash = "sha256:59415c6076b1e30e563eb732e23b994a61c159cec44deaf584e5cc1dd662f2af", size = 66453, upload-time = "2025-10-08T09:15:34.225Z" }, + { url = "https://files.pythonhosted.org/packages/16/67/93f80545eb1792b61a217fa7f06d5e5cb9e0055bed867f43e2b8e012e137/msgpack-1.1.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:897c478140877e5307760b0ea66e0932738879e7aa68144d9b78ea4c8302a84a", size = 85264, upload-time = "2025-10-08T09:15:35.61Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/33c8a24959cf193966ef11a6f6a2995a65eb066bd681fd085afd519a57ce/msgpack-1.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a668204fa43e6d02f89dbe79a30b0d67238d9ec4c5bd8a940fc3a004a47b721b", size = 89076, upload-time = "2025-10-08T09:15:36.619Z" }, + { url = "https://files.pythonhosted.org/packages/fc/6b/62e85ff7193663fbea5c0254ef32f0c77134b4059f8da89b958beb7696f3/msgpack-1.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5559d03930d3aa0f3aacb4c42c776af1a2ace2611871c84a75afe436695e6245", size = 435242, upload-time = "2025-10-08T09:15:37.647Z" }, + { url = "https://files.pythonhosted.org/packages/c1/47/5c74ecb4cc277cf09f64e913947871682ffa82b3b93c8dad68083112f412/msgpack-1.1.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:70c5a7a9fea7f036b716191c29047374c10721c389c21e9ffafad04df8c52c90", size = 432509, upload-time = "2025-10-08T09:15:38.794Z" }, + { url = "https://files.pythonhosted.org/packages/24/a4/e98ccdb56dc4e98c929a3f150de1799831c0a800583cde9fa022fa90602d/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f2cb069d8b981abc72b41aea1c580ce92d57c673ec61af4c500153a626cb9e20", size = 415957, upload-time = "2025-10-08T09:15:40.238Z" }, + { url = "https://files.pythonhosted.org/packages/da/28/6951f7fb67bc0a4e184a6b38ab71a92d9ba58080b27a77d3e2fb0be5998f/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d62ce1f483f355f61adb5433ebfd8868c5f078d1a52d042b0a998682b4fa8c27", size = 422910, upload-time = "2025-10-08T09:15:41.505Z" }, + { url = "https://files.pythonhosted.org/packages/f0/03/42106dcded51f0a0b5284d3ce30a671e7bd3f7318d122b2ead66ad289fed/msgpack-1.1.2-cp314-cp314t-win32.whl", hash = "sha256:1d1418482b1ee984625d88aa9585db570180c286d942da463533b238b98b812b", size = 75197, upload-time = "2025-10-08T09:15:42.954Z" }, + { url = "https://files.pythonhosted.org/packages/15/86/d0071e94987f8db59d4eeb386ddc64d0bb9b10820a8d82bcd3e53eeb2da6/msgpack-1.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:5a46bf7e831d09470ad92dff02b8b1ac92175ca36b087f904a0519857c6be3ff", size = 85772, upload-time = "2025-10-08T09:15:43.954Z" }, + { url = "https://files.pythonhosted.org/packages/81/f2/08ace4142eb281c12701fc3b93a10795e4d4dc7f753911d836675050f886/msgpack-1.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d99ef64f349d5ec3293688e91486c5fdb925ed03807f64d98d205d2713c60b46", size = 70868, upload-time = "2025-10-08T09:15:44.959Z" }, +] + [[package]] name = "openstars" version = "0.1.0" source = { virtual = "." } dependencies = [ { name = "fastapi" }, + { name = "firebase-admin" }, { name = "google-cloud-storage" }, { name = "pydantic" }, { name = "python-json-logger" }, @@ -456,6 +646,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "fastapi", specifier = ">=0.115" }, + { name = "firebase-admin", specifier = ">=7.4.0" }, { name = "google-cloud-storage", specifier = ">=3.1.1" }, { name = "httpx", marker = "extra == 'dev'", specifier = ">=0.28" }, { name = "pydantic", specifier = ">=2.10" }, @@ -647,6 +838,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] +[[package]] +name = "pyjwt" +version = "2.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + [[package]] name = "pytest" version = "9.0.3" diff --git a/docker-compose.yaml b/docker-compose.yaml index fa3e86fb..bd0950f7 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,4 +1,14 @@ services: + firebase-emulators: + image: andreysenov/firebase-tools + command: firebase emulators:start --only auth,firestore --project openstars-local + ports: + - "4001:4001" + - "8085:8085" + - "9099:9099" + volumes: + - ./firebase:/home/node + frontend: build: ./frontend pull_policy: build @@ -6,6 +16,10 @@ services: - "3000:8080" environment: - BACKEND_URL=http://backend:8080 + - VITE_FIREBASE_PROJECT_ID=openstars-local + - VITE_FIREBASE_API_KEY=fake-api-key + - VITE_FIREBASE_AUTH_DOMAIN=openstars-local.firebaseapp.com + - VITE_FIREBASE_USE_EMULATORS=true backend: build: ./backend @@ -15,5 +29,11 @@ services: environment: - STORAGE_BACKEND=local - GAME_DATA_PATH=/data + - GAME_DIRECTORY_BACKEND=firestore + - FIREBASE_PROJECT_ID=openstars-local + - FIRESTORE_EMULATOR_HOST=firebase-emulators:8085 + - FIREBASE_AUTH_EMULATOR_HOST=firebase-emulators:9099 volumes: - ./local-data:/data + depends_on: + - firebase-emulators diff --git a/docs/prd/03-turn-lifecycle.md b/docs/prd/03-turn-lifecycle.md index 684cd29d..61719fb3 100644 --- a/docs/prd/03-turn-lifecycle.md +++ b/docs/prd/03-turn-lifecycle.md @@ -104,9 +104,11 @@ Players then open the game, view their starting position, and submit commands fo ## Command Submission -- Players may **resubmit** commands at any time before the turn resolves. The latest submission overwrites any previous one. +- Players may **resubmit** commands at any time before the turn resolves. The latest submission overwrites any previous one. Once the last remaining player's submission triggers resolution, resubmission is no longer possible for that turn. +- **The server resolves the turn automatically as soon as every player has submitted commands.** There is no separate resolution trigger — the `POST /commands` call from the last remaining player runs the resolution pipeline synchronously before responding. The response from that call reflects the new turn (see PRD 50 for the updated response schema). - There is **no deadline enforcement** in Phase 1. The server waits until all players have submitted. (Deadlines and auto-skip are future enhancements.) - A player who has not yet submitted commands for the current turn is shown as "pending" to the server. +- **Turn-0 race selection** resolves via the same mechanism: when every player has submitted a `select_race` command, the turn auto-resolves and turn 1 begins. ## Player State Contents diff --git a/docs/prd/06-technical-platform.md b/docs/prd/06-technical-platform.md index f608cec3..12a5ac8a 100644 --- a/docs/prd/06-technical-platform.md +++ b/docs/prd/06-technical-platform.md @@ -18,13 +18,15 @@ AWS alternatives (Lambda containers, App Runner, Fargate) either lack true scale │ (Cloud Run) │────▶│ (Cloud Run) │────▶│ (Game State)│ │ Static SPA │ │ API Server │ │ JSON files │ └─────────────┘ └─────────────┘ └─────────────┘ - │ │ - │ │ - ▼ ▼ + │ │ + │◀────onSnapshot──┐ │────write──────────▶┌─────────────┐ + │ │ │ │ Firestore │ + │ └──┤◀───────────────────│ (Game Dir) │ + ▼ ▼ └─────────────┘ ┌─────────────┐ ┌─────────────┐ │ Artifact │ │ Google │ -│ Registry │ │ Identity │ -│ (Images) │ │ (Auth) │ +│ Registry │ │ Identity / │ +│ (Images) │ │ Firebase │ └─────────────┘ └─────────────┘ ``` @@ -78,7 +80,6 @@ Blobs are stored gzip-compressed with a `.json.gz` suffix. In GCS, each object s openstars-games/ {game_id}/ galaxy.json.gz - meta.json.gz state/ global-state-T0.json.gz global-state-T1.json.gz @@ -99,7 +100,8 @@ This maps directly to the three-file turn cycle from PRD 03: - `state/` — server-only global state (one per turn) - `players/` — per-player filtered views (generated each turn) - `commands/` — player-submitted orders (one per player per turn) -- `meta.json.gz` — lightweight game metadata used for game listings and lobby views + +Game-summary metadata (`name`, `galaxy_size`, `players`, `current_turn`, etc.) now lives in Firestore, not GCS. The `meta.json.gz` file has been removed. `preferences/` is reserved for future per-player settings. It is not part of the Phase 1 storage interface yet. @@ -112,8 +114,8 @@ Phase 1 storage is defined by an abstract `GameStorage` interface with these res - Save/load `players/player-state-{username}-T{N}.json.gz` - Save/load `commands/player-command-{username}-T{N}.json.gz` - Check whether a player's command file exists for a turn -- Save/load `meta.json.gz` -- List game IDs that have valid metadata + +Game-summary metadata (listing, lobby data) is no longer the storage adapter's responsibility — it is owned by the `GameDirectory` abstraction backed by Firestore. `storage/local.py` is the development implementation. `storage/gcs.py` will implement the same contract for production, using the bucket root directly rather than an additional configurable object prefix. @@ -133,6 +135,62 @@ Turn command submission is inherently safe — each player writes to their own c For safety, the backend should use GCS **preconditions** (`ifGenerationMatch`) when writing the authoritative global state file for a turn (`state/global-state-T{N}.json.gz`) to prevent double-resolution of the same turn. Command submission and derived player-state writes can use normal overwrite behaviour because they are not the single source of truth for turn advancement. +### Realtime Notifications & Game Directory — Firestore + +A single Firestore document per game (`games/{game_id}`) serves two purposes: + +1. **System of record for game-summary metadata** — replaces `meta.json.gz`. The lobby (`GET /games`) and game-detail (`GET /games/{id}`) endpoints now read from Firestore instead of GCS. +2. **Realtime notification channel** — the frontend subscribes to this document via `onSnapshot`; any field change (turn advance, new submission) triggers an immediate UI update with no polling. + +#### Document model + +``` +games/{game_id}: { + name: string + galaxy_size: string + seed: int + players: string[] // usernames + current_turn: int + players_submitted: string[] // usernames who submitted this turn + created_at: timestamp + updated_at: timestamp +} +``` + +`all_turns_submitted` is a derived property (not stored): `set(players_submitted) >= set(players)`. + +#### Writer + +The backend writes via the Firebase Admin SDK. Three write paths: + +- **Game creation** (`create_game`) — writes the initial document with `players_submitted = []` after GCS writes succeed. GCS first, Firestore last: a Firestore failure leaves GCS blobs as orphans but keeps the game invisible (a cleanup pass can reconcile). +- **Command submission** (`player_submitted`) — merge-updates `players_submitted` and `updated_at`. +- **Turn resolution** (`turn_resolved`) — merge-updates `current_turn`, resets `players_submitted` to `[]`, and bumps `updated_at`. This write happens *after* new global-state and player-state files are durable in GCS, so a listener that fires on `current_turn` change is guaranteed to find readable state. + +#### Reader + +The frontend subscribes using the Firebase JS SDK (`onSnapshot`). All players in the game share the same document and receive changes in real time. + +#### Security model + +Firestore security rules gate access by the custom-claim `games` list minted in the Firebase custom token: + +``` +rules_version = '2'; +service cloud.firestore { + match /databases/{database}/documents { + match /games/{gameId} { + allow read: if request.auth != null + && request.auth.token.games is list + && gameId in request.auth.token.games; + allow write: if false; + } + } +} +``` + +Admin SDK writes from the backend bypass these rules. + ### Authentication — Google Identity Platform Players authenticate via Google Sign-In. This aligns with PRD 04's decision that player identity is an email address (via Google Auth). @@ -144,6 +202,16 @@ Players authenticate via Google Sign-In. This aligns with PRD 04's decision that For local development, auth can be bypassed or mocked (see Local Development below). +#### Firebase custom tokens (Firestore read access) + +To read Firestore documents, the frontend must hold a Firebase identity. The backend mints **Firebase custom tokens** on demand: + +- **Endpoint:** `POST /api/v1/auth/firebase-token` (see PRD 50) +- **Mechanism:** `firebase_admin.auth.create_custom_token(uid=x_player, developer_claims={"games": [...]})`, signed by the Cloud Run service account. +- **Claim:** `games: [game_id, ...]` — the Firestore security rules check this list to gate per-game document reads. +- **Frontend flow:** on load (and on expiry), the frontend calls the token endpoint, then calls `signInWithCustomToken(firebaseAuth, token)`. The resulting Firebase session is used only for Firestore reads. +- **`X-Player` remains the API identity header for Phase 1.** The Firebase token is an additional credential for Firestore access only; it does not replace `X-Player` on backend API calls. + ## Docker Strategy Both services are Dockerised with multi-stage builds. @@ -157,6 +225,10 @@ Key local dev features: - **`GAME_DATA_PATH=./local-data`** — local storage root on disk - **`./local-data`** mounted volume — game state files are visible on the host filesystem for inspection and manual editing. - **`docker compose up`** — one command to run everything. +- **Firebase emulator suite** — a `firebase-emulators` docker-compose service runs the Firestore and Auth emulators locally. No real GCP project is involved. Ports: `8085` (Firestore), `9099` (Auth), `4001` (Emulator UI). +- **`FIRESTORE_EMULATOR_HOST=firebase-emulators:8085`** — set on the backend service so `firebase_admin` talks to the local emulator. +- **`FIREBASE_AUTH_EMULATOR_HOST=firebase-emulators:9099`** — set on the backend service so custom-token minting targets the emulator. +- **`GAME_DIRECTORY_BACKEND=firestore`** — set on the backend service in the full local stack; use `memory` for backend-only unit/integration runs. `STORAGE_BACKEND` must be set explicitly. The backend should fail fast on startup if it is missing or set to an unknown value, rather than silently defaulting to local storage. @@ -334,5 +406,5 @@ The frontend is a static SPA — no server-side logging needed. Browser errors a - **Custom domain / HTTPS setup** — will be needed but is a deployment task, not a design decision. - **Alerting** — log-based alerts and error rate notifications can be layered on when needed. - **Database** — not needed in Phase 1. Can be layered in later for cross-game queries. -- **WebSockets / real-time updates** — turn-based polling is sufficient. If we want push notifications for "it's your turn", Cloud Run supports WebSockets but that's a future concern. +- **Per-player private push channels** — e.g. diplomatic messages; can be added as `games/{game_id}/players/{username}` Firestore subcollections later. - **Multi-region** — single region is fine at this scale. diff --git a/docs/prd/50-api.md b/docs/prd/50-api.md index 7f2e19f5..bd4861f1 100644 --- a/docs/prd/50-api.md +++ b/docs/prd/50-api.md @@ -29,7 +29,7 @@ The backend trusts this value — there is no token validation or identity verif - `POST /api/v1/games/my-game/commands` with `X-Player: matt` — submit commands as Matt - `GET /api/v1/games/my-game/commands` with `X-Player: tim` — retrieve Tim's submitted commands -The `X-Player` header is required on all player-scoped and participant-gated endpoints: `GET /games/{game_id}`, `GET /state`, `GET /galaxy`, `GET /commands`, `POST /commands`, and `POST /resolve`. It is optional on `GET /games` (filters to games containing that player; omit to list all games). +The `X-Player` header is required on all player-scoped and participant-gated endpoints: `GET /games/{game_id}`, `GET /state`, `GET /galaxy`, `GET /commands`, and `POST /commands`. It is optional on `GET /games` (filters to games containing that player; omit to list all games). Authentication (Google Identity) will be added in Phase 5 (Multiplayer), replacing `X-Player` with an `Authorization: Bearer ` header and server-side identity extraction. The switch is a single middleware change — no endpoint signatures need updating. @@ -89,7 +89,7 @@ The server generates the galaxy, creates `global-state-T0.json`, and derives ini #### `GET /api/v1/games` -List games the authenticated user is a player in. +List games the authenticated user is a player in. Game-summary fields (`name`, `galaxy_size`, `turn`, `players`, `all_turns_submitted`, `created_at`) are sourced from Firestore — the API surface and JSON shape are unchanged. **Response: `200 OK`** @@ -117,7 +117,7 @@ List games the authenticated user is a player in. #### `GET /api/v1/games/{game_id}` -Get game metadata. +Get game metadata. Game-summary fields are sourced from Firestore — the API surface and JSON shape are unchanged. **Response: `200 OK`** @@ -308,10 +308,29 @@ This matches the `PlayerCommands` schema from PRD 07. Phase 1 supports `set_wayp { "status": "submitted", "turn": 3, - "command_count": 1 + "command_count": 1, + "turn_resolved": false, + "new_turn": null } ``` +When this submission was the last one needed (all players have now submitted), the turn resolves synchronously before the response is sent: + +```json +{ + "status": "submitted", + "turn": 3, + "command_count": 1, + "turn_resolved": true, + "new_turn": 4 +} +``` + +| Field | Type | Description | +|-------|------|-------------| +| `turn_resolved` | boolean | Whether this submission triggered turn resolution. | +| `new_turn` | int \| null | The new turn number if `turn_resolved` is true; otherwise `null`. | + **Errors:** | Status | Condition | @@ -320,6 +339,7 @@ This matches the `PlayerCommands` schema from PRD 07. Phase 1 supports `set_wayp | `403` | Player is not a participant in this game | | `404` | Game not found | | `409` | Turn mismatch — submitted `turn` doesn't match the game's current turn (stale client or turn already resolved) | +| `500` | Turn resolution failed after commands were saved (code: `RESOLUTION_FAILED`). The command file is retained; the player may resubmit once the issue is resolved. | --- @@ -355,32 +375,37 @@ Returns an empty `commands` array if the player hasn't submitted yet this turn. --- -### Turn Resolution +### Auth -#### `POST /api/v1/games/{game_id}/resolve` +#### `POST /api/v1/auth/firebase-token` -Trigger turn resolution. Only succeeds if all players have submitted commands for the current turn. +Mint a Firebase custom token for the requesting player. The frontend uses this token to authenticate with Firestore via `signInWithCustomToken`, enabling realtime game-state listeners. + +**Headers:** `X-Player: {username}` (required) + +**Request body:** empty **Response: `200 OK`** ```json { - "turn": 4, - "status": "resolved" + "token": "", + "expires_at": "2026-05-21T15:00:00Z" } ``` -The server runs the resolution pipeline (PRD 07), writes the new global state, and derives fresh player states. After this call returns, clients can `GET /state` for the new turn. +| Field | Type | Description | +|-------|------|-------------| +| `token` | string | Firebase custom JWT, valid for 1 hour. | +| `expires_at` | string | ISO-8601 absolute expiry time. Frontend should refresh ~5 minutes before this. | + +The token carries a custom claim `games: [game_id, ...]` listing every game the player participates in. Firestore security rules use this claim to gate read access to `games/{game_id}` documents. **Errors:** | Status | Condition | |--------|-----------| -| `403` | Player is not a participant in this game | -| `404` | Game not found | -| `409` | Not all players have submitted commands yet | - -**Phase 1 note:** Resolution is manually triggered. There is no auto-resolution when the last player submits — that's a future enhancement for convenience. The frontend can poll `GET /games/{game_id}` to check `all_turns_submitted` and show a "Resolve" button. +| `422` | Missing `X-Player` header | --- @@ -414,13 +439,13 @@ The backend is Python (FastAPI + Pydantic), and the game state JSON uses snake_c The galaxy is immutable for the lifetime of a game. Separating it lets the frontend fetch it once and cache indefinitely, reducing payload size on every turn refresh. The player state endpoint only carries mutable data. -### Why manual resolution trigger? - -Phase 1 is a development/testing tool — being able to control exactly when turns resolve is more useful than auto-resolution. It also avoids race conditions where a player submits and the turn instantly resolves before they can review. Auto-resolution (with optional countdown/deadline) will be added in Phase 5 (Multiplayer). - ### Why not WebSockets? -Turn-based games don't need real-time push. Polling `GET /games/{game_id}` every few seconds is simple, reliable, and sufficient. WebSockets add complexity (reconnection logic, Cloud Run session affinity) with minimal UX benefit at this scale. They can be layered in later for "it's your turn" notifications. +Firestore realtime listeners (via `onSnapshot`) are used instead of WebSockets or Server-Sent Events. Key advantages over WebSockets: + +- **No session affinity** — Cloud Run routes each request independently; WebSockets require sticky sessions, which break scale-to-zero and multi-instance deployments. +- **Free tier and managed infrastructure** — Firestore at hobby scale costs nothing and is fully managed; no custom push channel to operate. +- **Identical local dev via emulator** — the Firebase emulator suite replicates the production listener behaviour locally with no special test harness. --- @@ -429,8 +454,6 @@ Turn-based games don't need real-time push. Polling `GET /games/{game_id}` every - **Game deletion/archiving** — future admin functionality - **Player invitations/lobby system** — Phase 5 (Multiplayer) - **Turn deadlines and auto-skip** — Phase 5 -- **Auto-resolution on last submission** — Phase 5 -- **WebSocket push notifications** — future enhancement - **Pagination** — not needed at Phase 1 scale (few games, few events) - **Rate limiting** — Cloud Run provides basic protection; app-level limits are a future concern - **OpenAPI spec generation** — FastAPI generates this automatically from the Pydantic models; no manual spec file needed diff --git a/firebase/.firebaserc b/firebase/.firebaserc new file mode 100644 index 00000000..ab8f89ef --- /dev/null +++ b/firebase/.firebaserc @@ -0,0 +1,5 @@ +{ + "projects": { + "default": "openstars-local" + } +} diff --git a/firebase/README.md b/firebase/README.md new file mode 100644 index 00000000..2a7dae1d --- /dev/null +++ b/firebase/README.md @@ -0,0 +1,30 @@ +# Firebase Local Emulator Config + +This directory contains configuration for the [Firebase Local Emulator Suite](https://firebase.google.com/docs/emulator-suite), used for local development and integration tests. + +## Files + +- **`firebase.json`** — tells `firebase emulators:start` which emulators to run and what ports to bind +- **`.firebaserc`** — sets the default project alias (`openstars-local`) for local CLI use + +## Emulators + +| Emulator | Port | +|----------|------| +| Auth | 9099 | +| Firestore | 8085 | +| Emulator UI | 4001 | + +## Usage + +The emulators are started automatically by Docker Compose: + +```bash +docker compose up firebase-emulators +# or the full stack: +docker compose up +``` + +The emulator UI is available at http://localhost:4001 when running. + +These emulators are **not used in production** — they are purely a local dev/test tool. diff --git a/firebase/firebase.json b/firebase/firebase.json new file mode 100644 index 00000000..37f7ec21 --- /dev/null +++ b/firebase/firebase.json @@ -0,0 +1,17 @@ +{ + "emulators": { + "auth": { + "host": "0.0.0.0", + "port": 9099 + }, + "firestore": { + "host": "0.0.0.0", + "port": 8085 + }, + "ui": { + "enabled": true, + "host": "0.0.0.0", + "port": 4001 + } + } +} diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 00000000..664a55ca --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1,10 @@ +# Backend API base URL (empty = same-origin; set when running frontend standalone) +VITE_API_URL= + +# Firebase / Firestore configuration +VITE_FIREBASE_PROJECT_ID=openstars-local +VITE_FIREBASE_API_KEY=fake-api-key +VITE_FIREBASE_AUTH_DOMAIN=openstars-local.firebaseapp.com + +# Set to "true" to connect Firebase SDK to local emulators (localhost:8085 / :9099) +VITE_FIREBASE_USE_EMULATORS=true diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a719a917..d4e9c527 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -14,6 +14,7 @@ "@tailwindcss/vite": "^4.2.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "firebase": "^12.13.0", "lucide-react": "^0.577.0", "react": "^19.2.5", "react-dom": "^19.2.5", @@ -750,6 +751,648 @@ } } }, + "node_modules/@firebase/ai": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@firebase/ai/-/ai-2.12.0.tgz", + "integrity": "sha512-b+OL4vdyiSLZL/7dLd67V55CjKJvU9MpNmwnday7eA6GG2+J4iwUEsEHgw0/jKY3A41FfkF0SrnYFvtKbQZ65A==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-check-interop-types": "0.3.4", + "@firebase/component": "0.7.3", + "@firebase/logger": "0.5.1", + "@firebase/util": "1.15.1", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x", + "@firebase/app-types": "0.x" + } + }, + "node_modules/@firebase/analytics": { + "version": "0.10.22", + "resolved": "https://registry.npmjs.org/@firebase/analytics/-/analytics-0.10.22.tgz", + "integrity": "sha512-8BSaq/QRGU1+xyi8L2PTLTJU7MH9aMA72RQdIxrbhWFauOZY9OXo8f2YDN/972xA8d588tlnNVEQ2Mo69pT9Ow==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.3", + "@firebase/installations": "0.6.22", + "@firebase/logger": "0.5.1", + "@firebase/util": "1.15.1", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/analytics-compat": { + "version": "0.2.28", + "resolved": "https://registry.npmjs.org/@firebase/analytics-compat/-/analytics-compat-0.2.28.tgz", + "integrity": "sha512-lIAlqUUbBu93FJMlQfslryQtBwwzdzvp23ePC6FNgymXk6Ook5v4Uvc0vdutvoIeqmyA3LfP0ZeRFK8+11kOOQ==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/analytics": "0.10.22", + "@firebase/analytics-types": "0.8.4", + "@firebase/component": "0.7.3", + "@firebase/util": "1.15.1", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/analytics-types": { + "version": "0.8.4", + "resolved": "https://registry.npmjs.org/@firebase/analytics-types/-/analytics-types-0.8.4.tgz", + "integrity": "sha512-zQ+XTgkwH6CY/eUSHJRP7e4LxM30RCxlCmob5sy2axs25GE3Ny0XdgpDscMTHHQIGqWkxPXad4w2Mw9sCgT8zQ==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/app": { + "version": "0.14.12", + "resolved": "https://registry.npmjs.org/@firebase/app/-/app-0.14.12.tgz", + "integrity": "sha512-FT+HoNp1NdaZ/N26hCwV3WbxS1m6gTn3p2QRBQ3KH7YqyCQqJx0iT7126RgVk68/Rq+9DeL/zCFnHZ0C4u1nLQ==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.3", + "@firebase/logger": "0.5.1", + "@firebase/util": "1.15.1", + "idb": "7.1.1", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/app-check": { + "version": "0.11.3", + "resolved": "https://registry.npmjs.org/@firebase/app-check/-/app-check-0.11.3.tgz", + "integrity": "sha512-aJ4DfubWfTO8/2vhEhIAizOoOmiycESTU32e+OUgbWcS/G3PA4Vxlr/9zaiN2wfUG2AptQ7DTvj00tyuFZP5Bg==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.3", + "@firebase/logger": "0.5.1", + "@firebase/util": "1.15.1", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/app-check-compat": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@firebase/app-check-compat/-/app-check-compat-0.4.3.tgz", + "integrity": "sha512-L3AKIRTJxT9b7cDUH3OyV8gWTnmW3vYkwdzRsukWt4kbPBTct12xalnyvHDkm1lKkr+cQq/4uzBx1bOWsQ2ciw==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-check": "0.11.3", + "@firebase/app-check-types": "0.5.4", + "@firebase/component": "0.7.3", + "@firebase/logger": "0.5.1", + "@firebase/util": "1.15.1", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/app-check-interop-types": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@firebase/app-check-interop-types/-/app-check-interop-types-0.3.4.tgz", + "integrity": "sha512-zz3i6e13B8BfWiLy8MABtTh8aGIACgKbf9UVnyHcWs+yQzJXgQcl8A46b0zfaiJHdQ+niF0ouAfcpuf+3LMPQg==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/app-check-types": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/@firebase/app-check-types/-/app-check-types-0.5.4.tgz", + "integrity": "sha512-xV7JsIyzVr15aA7f3Pi0rB9gdBuVubs89FGA8VkRYA4g0l78poADgdfrScgf7NndSg9mm7cR7PJyY0+t22KaGw==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/app-compat": { + "version": "0.5.12", + "resolved": "https://registry.npmjs.org/@firebase/app-compat/-/app-compat-0.5.12.tgz", + "integrity": "sha512-Pe513OBerK/CIBxz4/za9atd5MsZtd6DzHz4cmqkvkrcDWhQChAoHBpZ3McuZNuSP8YZiKwfX/J1frR07l15/w==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app": "0.14.12", + "@firebase/component": "0.7.3", + "@firebase/logger": "0.5.1", + "@firebase/util": "1.15.1", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/app-types": { + "version": "0.9.5", + "resolved": "https://registry.npmjs.org/@firebase/app-types/-/app-types-0.9.5.tgz", + "integrity": "sha512-YevqTjvo7Iujsa9Dwowmd6dSoElhzmD63ZSrq6bzjvQ6POjYgNjOFHLmNIgJs48eNO093NCERibuFnxbfOvU7A==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/logger": "0.5.1" + } + }, + "node_modules/@firebase/auth": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/@firebase/auth/-/auth-1.13.1.tgz", + "integrity": "sha512-/1nkKY/MicI+I9WWcx6R4NKs77AaW9NQ0IwsFdUBomWrW0/cXEmopfM2dtLm2oI1qG6z6vom3CXZDHJIJXoMuw==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.3", + "@firebase/logger": "0.5.1", + "@firebase/util": "1.15.1", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x", + "@react-native-async-storage/async-storage": "^2.2.0 || ^3.0.0" + }, + "peerDependenciesMeta": { + "@react-native-async-storage/async-storage": { + "optional": true + } + } + }, + "node_modules/@firebase/auth-compat": { + "version": "0.6.6", + "resolved": "https://registry.npmjs.org/@firebase/auth-compat/-/auth-compat-0.6.6.tgz", + "integrity": "sha512-KDJ/GAf/rt7galOpn3DRb2buFfGkZCsHTryKjXDG0eeRnok4+2B4nnkMOMdjRnPkElmcJv2Ao0vEA6kp5m98PQ==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/auth": "1.13.1", + "@firebase/auth-types": "0.13.1", + "@firebase/component": "0.7.3", + "@firebase/util": "1.15.1", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/auth-interop-types": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/@firebase/auth-interop-types/-/auth-interop-types-0.2.5.tgz", + "integrity": "sha512-1Li/YuBDBAXcKv7BzY4U28gontUmAaw53sYiqbaVOMCFb2lFKK/c3CGMUWqtwe7+TXrl3poWnTCL5umYBg85Eg==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/auth-types": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/@firebase/auth-types/-/auth-types-0.13.1.tgz", + "integrity": "sha512-0c1Mnid0uMDfGJHeUS4zfvBa4/CedJXotGy/n/NZJnBjwiJawt0ZYU+wH2VAVLiRCEfG2ncCkAX3yd1/2nrB7g==", + "license": "Apache-2.0", + "peerDependencies": { + "@firebase/app-types": "0.x", + "@firebase/util": "1.x" + } + }, + "node_modules/@firebase/component": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/@firebase/component/-/component-0.7.3.tgz", + "integrity": "sha512-wFofIaa2879ogD/WvkjYXJxRmfnL0scen6ORgaC3na1FNOR9ASIUANQdhqQcmWu/h77/pVHY7ch5flewa5Bcew==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/util": "1.15.1", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/data-connect": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@firebase/data-connect/-/data-connect-0.7.0.tgz", + "integrity": "sha512-ar9sNOJh5poQCSMSVlnVE8eo8+usTD1POWDCv65omkKUvnFMcdXaQ7J/e7WGKqJzcEMgiezSX/TZiKHZkItMbQ==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/auth-interop-types": "0.2.5", + "@firebase/component": "0.7.3", + "@firebase/logger": "0.5.1", + "@firebase/util": "1.15.1", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/database": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@firebase/database/-/database-1.1.3.tgz", + "integrity": "sha512-XwWCa+E4TvNGpGwXrycLRNfdogADwFcvuhyow6wDWma9W54roaQIhe+4PM0KiLsIftBdSCGI7OKCXrdSRHbIhw==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-check-interop-types": "0.3.4", + "@firebase/auth-interop-types": "0.2.5", + "@firebase/component": "0.7.3", + "@firebase/logger": "0.5.1", + "@firebase/util": "1.15.1", + "faye-websocket": "0.11.4", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/database-compat": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@firebase/database-compat/-/database-compat-2.1.4.tgz", + "integrity": "sha512-3pK35F1MAgmqFJQlf2nhQl44vtAXQO1uaCaQOEUI9kCRtLFqi7N+QRKR7lFZPg+xIZIyubgxQaxY69YgfZRZWg==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.3", + "@firebase/database": "1.1.3", + "@firebase/database-types": "1.0.20", + "@firebase/logger": "0.5.1", + "@firebase/util": "1.15.1", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/database-types": { + "version": "1.0.20", + "resolved": "https://registry.npmjs.org/@firebase/database-types/-/database-types-1.0.20.tgz", + "integrity": "sha512-kegbOk/w8iU64pr0q6k2ItyNGjnQBMHFhwS7ohdWI4W+pc0/zhhdGXTdFj6X1oxItRjPoYOsSQmERgBkn/ihxw==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-types": "0.9.5", + "@firebase/util": "1.15.1" + } + }, + "node_modules/@firebase/firestore": { + "version": "4.14.1", + "resolved": "https://registry.npmjs.org/@firebase/firestore/-/firestore-4.14.1.tgz", + "integrity": "sha512-PouS0NJZ3NYOZE/tPDvXa8VUeJ10Ll//7jIdFvMYdhQkd/P3O7nlqhyoTmY0h8Xa9hxg+H0j6gxUytJcoZ9YOg==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.3", + "@firebase/logger": "0.5.1", + "@firebase/util": "1.15.1", + "@firebase/webchannel-wrapper": "1.0.6", + "@grpc/grpc-js": "~1.9.0", + "@grpc/proto-loader": "^0.7.8", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/firestore-compat": { + "version": "0.4.9", + "resolved": "https://registry.npmjs.org/@firebase/firestore-compat/-/firestore-compat-0.4.9.tgz", + "integrity": "sha512-NPtBuFr79BbIQJXFWhW4xFC6rBksK8/ewqCTYbbAYfZBDDx0/iHTUj4WpKi5D4d0Pn2Md/3T/e5V9379G5N/Zg==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.3", + "@firebase/firestore": "4.14.1", + "@firebase/firestore-types": "3.0.4", + "@firebase/util": "1.15.1", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/firestore-types": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@firebase/firestore-types/-/firestore-types-3.0.4.tgz", + "integrity": "sha512-jGn+JSS4X9zZsrfu7Yw66v5YRdOLD1oyQh4USR0xWl4CUqV/DA6bNIXRPpxH/cUl3iVTNiP6MN7g+EL42A4qfA==", + "license": "Apache-2.0", + "peerDependencies": { + "@firebase/app-types": "0.x", + "@firebase/util": "1.x" + } + }, + "node_modules/@firebase/functions": { + "version": "0.13.4", + "resolved": "https://registry.npmjs.org/@firebase/functions/-/functions-0.13.4.tgz", + "integrity": "sha512-oB5rpm2Emxn2+IS1gRelAeT/5tSZMwM/KhqC5LnJsmTNnS1ZDhD7ZMZNgCI8vchTW6PbaXIwEnpUryGuIQsNbg==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-check-interop-types": "0.3.4", + "@firebase/auth-interop-types": "0.2.5", + "@firebase/component": "0.7.3", + "@firebase/messaging-interop-types": "0.2.4", + "@firebase/util": "1.15.1", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/functions-compat": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/@firebase/functions-compat/-/functions-compat-0.4.4.tgz", + "integrity": "sha512-Be+MwhseVf/eFAZwGrFJGok6S7cmsLrAPK8MgyM8LjM0MewTsx2n01WOOca9jio1UsCZOJ0aVyQobnINcdNuIQ==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.3", + "@firebase/functions": "0.13.4", + "@firebase/functions-types": "0.6.4", + "@firebase/util": "1.15.1", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/functions-types": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/@firebase/functions-types/-/functions-types-0.6.4.tgz", + "integrity": "sha512-zV6kgqtduR4rUAdC/ilS7kmb93XD7bEZoJDlVBZqlOw2uGGGCNBQBuleww2rr0Ulr3L9o2TDjumEt68/l1f9DQ==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/installations": { + "version": "0.6.22", + "resolved": "https://registry.npmjs.org/@firebase/installations/-/installations-0.6.22.tgz", + "integrity": "sha512-ef6nn3GGQTdReCfotRMG77PJZu8CqEbiK5pEoBnM0gTu/Z9v0i/az2p3HABsa/1beQmmyh1OsOjf7P5+pgwdZw==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.3", + "@firebase/util": "1.15.1", + "idb": "7.1.1", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/installations-compat": { + "version": "0.2.22", + "resolved": "https://registry.npmjs.org/@firebase/installations-compat/-/installations-compat-0.2.22.tgz", + "integrity": "sha512-C/zpAuTP5S9OgKSPvXRupw3hoY/JZSlA1wFjD/Sb7LIQE0FNbcMdO8Y4KXVEkjVzma/DDDDIAzxEXqKMAzc88w==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.3", + "@firebase/installations": "0.6.22", + "@firebase/installations-types": "0.5.4", + "@firebase/util": "1.15.1", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/installations-types": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/@firebase/installations-types/-/installations-types-0.5.4.tgz", + "integrity": "sha512-U2eFapdHwjb43Vx9o+Pmj4dFfvcHEK1IirEFLqMtWrTHvmdrS3gBpBD1kmJk/9HjsOtoHZxJ2Paoe79e+L1ZPg==", + "license": "Apache-2.0", + "peerDependencies": { + "@firebase/app-types": "0.x" + } + }, + "node_modules/@firebase/logger": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/@firebase/logger/-/logger-0.5.1.tgz", + "integrity": "sha512-vZKLsqE1ABOy8OjQiE7cUTFn4gvaqlk88yp8N94Pk/sDpq61YqZGqmVFZTvOyflTwuYFcWirBdYGoJgbDaXKYQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/messaging": { + "version": "0.12.26", + "resolved": "https://registry.npmjs.org/@firebase/messaging/-/messaging-0.12.26.tgz", + "integrity": "sha512-lHVTO9uLofymHVWkYeUtMddIPcmJvSzVbHRB88W6XKfxbcKF+p3QrfqKhDxremSB4NQjUla1Gwn7d9umSMmt/w==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.3", + "@firebase/installations": "0.6.22", + "@firebase/messaging-interop-types": "0.2.4", + "@firebase/util": "1.15.1", + "idb": "7.1.1", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/messaging-compat": { + "version": "0.2.26", + "resolved": "https://registry.npmjs.org/@firebase/messaging-compat/-/messaging-compat-0.2.26.tgz", + "integrity": "sha512-fn0XvWOfK4tsDLSipwJUW9Cp6ahWA6z+iJHxZ0pHp9MzMSUNQx85yuxZAuI7gkGXfqs7+DqEDHyyS7jDGswrmQ==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.3", + "@firebase/messaging": "0.12.26", + "@firebase/util": "1.15.1", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/messaging-interop-types": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@firebase/messaging-interop-types/-/messaging-interop-types-0.2.4.tgz", + "integrity": "sha512-wrzITQq+xw5LtygX7O0fu43/k9ABQ4x5H9/sR5m1SbNnhIRI5xd3+raSNJaJkYC4BUhM9A4ZNSnyR2sjhxnb2Q==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/performance": { + "version": "0.7.12", + "resolved": "https://registry.npmjs.org/@firebase/performance/-/performance-0.7.12.tgz", + "integrity": "sha512-fe7nV8teUU3OBHlMUZ9Lw4gLhCW2k4m5Uc3pfWGV+fl8uwJQBGp9Q3lqsJ+HSrFu3Q2pJyLAgrClPGSKyDeYgQ==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.3", + "@firebase/installations": "0.6.22", + "@firebase/logger": "0.5.1", + "@firebase/util": "1.15.1", + "tslib": "^2.1.0", + "web-vitals": "^4.2.4" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/performance-compat": { + "version": "0.2.25", + "resolved": "https://registry.npmjs.org/@firebase/performance-compat/-/performance-compat-0.2.25.tgz", + "integrity": "sha512-q6NjTXpIPoFuUmCmMN/maCdTgzT6aExs9xZo+PxfVLj6uLVGvpyAD6XWjmcrb7jChsFBYbq7E5dyNDF7Zhy9kA==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.3", + "@firebase/logger": "0.5.1", + "@firebase/performance": "0.7.12", + "@firebase/performance-types": "0.2.4", + "@firebase/util": "1.15.1", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/performance-types": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@firebase/performance-types/-/performance-types-0.2.4.tgz", + "integrity": "sha512-kJSEk7b0uhpcPRyL4SQ/GPujLqk52XNKcXlnsKDbWGAb9vugcLvOU3u6zfEdwd+d8hWJb5S5ZizV1JFFI0nkKg==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/remote-config": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/@firebase/remote-config/-/remote-config-0.8.3.tgz", + "integrity": "sha512-ggGKAaLy9YNOvpFoQZgm5p5SiFw3ZFtwti08dojnBQmQicpThTxvG5xZMSpCTYMj2o3gM/yK9CVd2w+kZub8YA==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.3", + "@firebase/installations": "0.6.22", + "@firebase/logger": "0.5.1", + "@firebase/util": "1.15.1", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/remote-config-compat": { + "version": "0.2.24", + "resolved": "https://registry.npmjs.org/@firebase/remote-config-compat/-/remote-config-compat-0.2.24.tgz", + "integrity": "sha512-EWZTt6fJ7YmPHodQNsSxAIDZY2x8P5kRPvXAc5CmzzBm+NyPFhODbfDsNllDXDL8jlzp50bVWjDY+BXepZS9Mg==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.3", + "@firebase/logger": "0.5.1", + "@firebase/remote-config": "0.8.3", + "@firebase/remote-config-types": "0.5.1", + "@firebase/util": "1.15.1", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/remote-config-types": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/@firebase/remote-config-types/-/remote-config-types-0.5.1.tgz", + "integrity": "sha512-cX/1LT6KQwkXzck2eSzeKnuvXZCyr8qaPpDcikoJs7jmI+oBOXixpDLeDtWj1U6GNMkIoXrEDNoyT2Ypcyp5/A==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/storage": { + "version": "0.14.3", + "resolved": "https://registry.npmjs.org/@firebase/storage/-/storage-0.14.3.tgz", + "integrity": "sha512-YX4/YL6P6/fufSSeGnVhjWddcIXbFq2cWIhMKFTZo1E/Rtcl2mJj/BYUQTwJfcE1Tl8un1FOya4L05jcSLN/Eg==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.3", + "@firebase/util": "1.15.1", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/storage-compat": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@firebase/storage-compat/-/storage-compat-0.4.3.tgz", + "integrity": "sha512-gruVqjtUGX8tEoeNbaWXZm0Zfcfcb7fvmDmBxV8yPAbWvExRnZYLO2+qw9idxNE7BvPXt5csyjSYHy//dAizxw==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.3", + "@firebase/storage": "0.14.3", + "@firebase/storage-types": "0.8.4", + "@firebase/util": "1.15.1", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/storage-types": { + "version": "0.8.4", + "resolved": "https://registry.npmjs.org/@firebase/storage-types/-/storage-types-0.8.4.tgz", + "integrity": "sha512-BT7cwxJOx8SWwlQfrlC+bD/Sk3Cw+1odCi8UZNFNWTVZoPsBnA5W+mqtZzVnvsdJpXCFGSGQ7R7vOR6dtM/BRA==", + "license": "Apache-2.0", + "peerDependencies": { + "@firebase/app-types": "0.x", + "@firebase/util": "1.x" + } + }, + "node_modules/@firebase/util": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@firebase/util/-/util-1.15.1.tgz", + "integrity": "sha512-LUdM4Wg7YM9Pq/49nGYySJA0CSQEKnGffFzWV8+6gXN7mGxn+FL1IqvFbuZUtAQcfZgHYDwCE1wwlK7rB7gl2g==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/webchannel-wrapper": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@firebase/webchannel-wrapper/-/webchannel-wrapper-1.0.6.tgz", + "integrity": "sha512-Vr/Mqu79dMwGRAyGbJ4uN4+BtXB3/mRTdzetD1daWNeG8QaWuzhhbG77GltO5c0yYmYls8i250iX73624GJd7Q==", + "license": "Apache-2.0" + }, + "node_modules/@grpc/grpc-js": { + "version": "1.9.16", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.9.16.tgz", + "integrity": "sha512-wE4Ut/olIzfKqp631XrG+wbF0v1vWFN4YL9FyXC2LJiG33DsV7PLzURjrCvY/6je2ntdRkeLpPDluzSRGaVltQ==", + "license": "Apache-2.0", + "dependencies": { + "@grpc/proto-loader": "^0.7.8", + "@types/node": ">=12.12.47" + }, + "engines": { + "node": "^8.13.0 || >=10.10.0" + } + }, + "node_modules/@grpc/proto-loader": { + "version": "0.7.15", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.15.tgz", + "integrity": "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==", + "license": "Apache-2.0", + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.2.5", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -874,6 +1517,69 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.2.tgz", + "integrity": "sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz", + "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", + "license": "BSD-3-Clause" + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.0.0-rc.13", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.13.tgz", @@ -1517,7 +2223,6 @@ "version": "24.12.2", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz", "integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==", - "devOptional": true, "license": "MIT", "dependencies": { "undici-types": "~7.16.0" @@ -2021,9 +2726,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=8" } @@ -2032,7 +2735,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -2216,6 +2918,20 @@ "url": "https://polar.sh/cva" } }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -2229,7 +2945,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -2242,7 +2957,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, "license": "MIT" }, "node_modules/concat-map": { @@ -2382,6 +3096,12 @@ "dev": true, "license": "ISC" }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, "node_modules/enhanced-resolve": { "version": "5.20.1", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", @@ -2419,7 +3139,6 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -2663,6 +3382,18 @@ "dev": true, "license": "MIT" }, + "node_modules/faye-websocket": { + "version": "0.11.4", + "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz", + "integrity": "sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==", + "license": "Apache-2.0", + "dependencies": { + "websocket-driver": ">=0.5.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -2710,6 +3441,42 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/firebase": { + "version": "12.13.0", + "resolved": "https://registry.npmjs.org/firebase/-/firebase-12.13.0.tgz", + "integrity": "sha512-iutR8ejvAqk6qUClnsPz3U3VIjTWp243AX4cD3iifak5t56to1J29xUIQgSDDzaAqKvhshZerzSahwMQj2TlvA==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/ai": "2.12.0", + "@firebase/analytics": "0.10.22", + "@firebase/analytics-compat": "0.2.28", + "@firebase/app": "0.14.12", + "@firebase/app-check": "0.11.3", + "@firebase/app-check-compat": "0.4.3", + "@firebase/app-compat": "0.5.12", + "@firebase/app-types": "0.9.5", + "@firebase/auth": "1.13.1", + "@firebase/auth-compat": "0.6.6", + "@firebase/data-connect": "0.7.0", + "@firebase/database": "1.1.3", + "@firebase/database-compat": "2.1.4", + "@firebase/firestore": "4.14.1", + "@firebase/firestore-compat": "0.4.9", + "@firebase/functions": "0.13.4", + "@firebase/functions-compat": "0.4.4", + "@firebase/installations": "0.6.22", + "@firebase/installations-compat": "0.2.22", + "@firebase/messaging": "0.12.26", + "@firebase/messaging-compat": "0.2.26", + "@firebase/performance": "0.7.12", + "@firebase/performance-compat": "0.2.25", + "@firebase/remote-config": "0.8.3", + "@firebase/remote-config-compat": "0.2.24", + "@firebase/storage": "0.14.3", + "@firebase/storage-compat": "0.4.3", + "@firebase/util": "1.15.1" + } + }, "node_modules/flat-cache": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", @@ -2755,6 +3522,15 @@ "node": ">=6.9.0" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -2827,6 +3603,18 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/http-parser-js": { + "version": "0.5.10", + "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.10.tgz", + "integrity": "sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==", + "license": "MIT" + }, + "node_modules/idb": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz", + "integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==", + "license": "ISC" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -2884,6 +3672,15 @@ "node": ">=0.10.0" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -3327,6 +4124,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -3334,6 +4137,12 @@ "dev": true, "license": "MIT" }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -3642,6 +4451,30 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/protobufjs": { + "version": "7.6.1", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.1.tgz", + "integrity": "sha512-4K0myLaWL5EteuSAro91EGFgcfVgxb64Jx+7oDAY6GOkXD4M69yuSEljNcInGVCA5sOPxmZ/EqDLj2x0Q0+Ygg==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -3695,6 +4528,15 @@ "node": ">=8" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -3754,6 +4596,26 @@ "integrity": "sha512-3ngTAv6F/Py35BsYbeeLeecvhMKdsKm4AoOETVhAA+Qc8nrA2I0kF7oa93mE9qnIurngOSpMnQ0x2nQY2FPviA==", "license": "MIT" }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/saxes": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", @@ -3836,6 +4698,32 @@ "dev": true, "license": "MIT" }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-indent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", @@ -4084,7 +4972,6 @@ "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", - "devOptional": true, "license": "MIT" }, "node_modules/update-browserslist-db": { @@ -4308,6 +5195,12 @@ "node": ">=18" } }, + "node_modules/web-vitals": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-4.2.4.tgz", + "integrity": "sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw==", + "license": "Apache-2.0" + }, "node_modules/webidl-conversions": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", @@ -4318,6 +5211,29 @@ "node": ">=20" } }, + "node_modules/websocket-driver": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz", + "integrity": "sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==", + "license": "Apache-2.0", + "dependencies": { + "http-parser-js": ">=0.5.1", + "safe-buffer": ">=5.1.0", + "websocket-extensions": ">=0.1.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/websocket-extensions": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.4.tgz", + "integrity": "sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/whatwg-mimetype": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", @@ -4386,6 +5302,23 @@ "node": ">=0.10.0" } }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/xml-name-validator": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", @@ -4403,6 +5336,15 @@ "dev": true, "license": "MIT" }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", @@ -4410,6 +5352,33 @@ "dev": true, "license": "ISC" }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 7a8066e4..74ebd2f5 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -21,6 +21,7 @@ "@tailwindcss/vite": "^4.2.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "firebase": "^12.13.0", "lucide-react": "^0.577.0", "react": "^19.2.5", "react-dom": "^19.2.5", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3d29f759..0b503207 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -12,6 +12,7 @@ import { DesktopGate, Button, GameLobby, + ErrorBox, } from "./components"; import type { WaypointEditorState } from "./components"; import { GameCommandsContext } from "./contexts/gameCommandsContext"; @@ -277,16 +278,11 @@ function App() { ? (() => { const total = gameState.gameDetail.players.length; const submitted = total - playersAwaitingSubmission.length; - if (submitted === total) return "All submitted"; return `Waiting: ${submitted} of ${total} submitted`; })() : ""; - const allSubmitted = playersAwaitingSubmission.length === 0 && Boolean(gameState.gameDetail); - const waitingForNextTurn = gameState.submitted && !allSubmitted; - const resolveDisabledReason = isTurnZeroRacePhase && playersAwaitingSubmission.length > 0 - ? `Waiting for ${playersAwaitingSubmission.join(", ")}` - : null; + const waitingForNextTurn = gameState.submitted && playersAwaitingSubmission.length > 0; const pendingResearchCommand = gameState.commands.commands.find((command): command is SetResearchCommand => command.type === "set_research") ?? null; const ownedPlanets = gameState.workingPlayerState.planets.filter((planet) => planet.owner === player); const ownedPlanetsLeftoverOnlyCount = ownedPlanets.filter((planet) => planet.contributeOnlyLeftoverToResearch === true).length; @@ -311,6 +307,17 @@ function App() { }} >
+ {gameState.notificationsError && ( + + Realtime updates disconnected.{" "} + + + )} {/* Top Bar */} { - return request( - `/api/v1/games/${gameId}/resolve`, +): Promise { + return request( + "/api/v1/auth/firebase-token", { method: "POST" }, player, ); diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index b38448b4..5489f7e2 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -10,7 +10,7 @@ export { getPredefinedRaces, submitCommands, getCommands, - resolveTurn, + fetchFirebaseToken, ApiError, } from "./client"; @@ -21,6 +21,6 @@ export type { PlayerSubmissionInfo, CreateGameResponse, SubmitCommandsResponse, - ResolveResponse, + FirebaseTokenResponse, CommandsResponse, } from "./client"; diff --git a/frontend/src/components/panels/TopBar.test.tsx b/frontend/src/components/panels/TopBar.test.tsx index 82877c6b..45bc6314 100644 --- a/frontend/src/components/panels/TopBar.test.tsx +++ b/frontend/src/components/panels/TopBar.test.tsx @@ -49,8 +49,6 @@ function renderTopBar( onModeChange={onModeChange} onSubmit={vi.fn()} submissionStatus="Waiting for the next turn" - allSubmitted={false} - onResolve={vi.fn()} onLeave={vi.fn()} playerName="tim" error={null} diff --git a/frontend/src/components/panels/TopBar.tsx b/frontend/src/components/panels/TopBar.tsx index 1762d6d7..c9ed415a 100644 --- a/frontend/src/components/panels/TopBar.tsx +++ b/frontend/src/components/panels/TopBar.tsx @@ -12,10 +12,6 @@ interface TopBarProps { productionEnabled?: boolean; onSubmit: () => void; submissionStatus: string; - allSubmitted: boolean; - onResolve: () => void; - resolveLabel?: string; - resolveDisabledReason?: string | null; onLeave: () => void; playerName: string; error: string | null; @@ -32,10 +28,6 @@ export function TopBar({ productionEnabled = true, onSubmit, submissionStatus, - allSubmitted, - onResolve, - resolveLabel = "Resolve Turn", - resolveDisabledReason = null, onLeave, playerName, error, @@ -124,19 +116,6 @@ export function TopBar({ {submissionStatus} - {(allSubmitted || resolveDisabledReason) && ( - - )} -