Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
ee7b4ac
Task file written
tsg21 May 21, 2026
59512e5
Step 1: Update PRDs for auto-resolve and Firestore realtime notificat…
tsg21 May 21, 2026
7850e09
Step 2: GameDirectory abstraction — base, memory, Firestore, factory,…
tsg21 May 21, 2026
e2a9485
Allow rag index
tsg21 May 21, 2026
fb2ef89
Steps 3+5: auto-resolve on last command submit, wire GameDirectory in…
tsg21 May 21, 2026
e31d7df
Step 4: Firebase custom-token endpoint POST /api/v1/auth/firebase-token
tsg21 May 22, 2026
ab8b357
Step 6: remove save_game_meta/load_game_meta/list_games from storage …
tsg21 May 22, 2026
30ced9f
Step 7: Terraform for Firestore, Firebase Auth, and IAM
tsg21 May 22, 2026
68e8ef4
Step 8: Firebase emulators in docker-compose
tsg21 May 22, 2026
046c577
Step 9: add Firebase SDK setup for frontend
tsg21 May 24, 2026
9a5df40
Step 10: add useFirebaseAuth hook and firebase-token API client
tsg21 May 24, 2026
a8066b5
Step 11: add useGameNotifications hook for Firestore realtime listener
tsg21 May 24, 2026
0815c07
Step 12: rewire useGameState to Firestore notifications, remove polling
tsg21 May 24, 2026
dc4c323
Step 13: integration verification — update int_tests for auto-resolve
tsg21 May 25, 2026
26d2b14
Simpler parsing.
tsg21 May 25, 2026
f5da4f8
Tweaks to the schema objects
tsg21 May 25, 2026
2e738de
Switch to single region
tsg21 May 25, 2026
ff92099
Tidy up the firebase emulators
tsg21 May 25, 2026
b32afe8
Fix Firestore emulator auth in CI
tsg21 May 25, 2026
69bd5ce
Address Codex review: byte-limit claims truncation and stable listene…
tsg21 May 25, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion .claude/settings.json
Original file line number Diff line number Diff line change
@@ -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)",
Expand All @@ -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 *)"
]
}
}
12 changes: 10 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
9 changes: 8 additions & 1 deletion backend/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand All @@ -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

Expand Down
6 changes: 0 additions & 6 deletions backend/int_tests/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@
CreateGameResponse,
GameDetail,
GameListResponse,
ResolveResponse,
SubmitCommandsResponse,
)

Expand Down Expand Up @@ -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,
Expand Down
16 changes: 16 additions & 0 deletions backend/int_tests/docker-compose.yaml
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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
2 changes: 0 additions & 2 deletions backend/int_tests/test_colonisation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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):
Expand Down
2 changes: 0 additions & 2 deletions backend/int_tests/test_combat.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 0 additions & 2 deletions backend/int_tests/test_freight.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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()
Expand Down
29 changes: 12 additions & 17 deletions backend/int_tests/test_game_lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -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) --

Expand All @@ -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 --

Expand Down Expand Up @@ -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 --

Expand Down
11 changes: 3 additions & 8 deletions backend/int_tests/test_merge_split.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Integration tests for merge/split fleet commands."""

import pytest
from client import GameClient

from openstars.engine.models import (
Expand Down Expand Up @@ -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)
Expand All @@ -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()
Expand Down Expand Up @@ -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=[])
27 changes: 4 additions & 23 deletions backend/int_tests/test_race_selection.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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(
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand All @@ -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"
2 changes: 0 additions & 2 deletions backend/int_tests/test_ship_production_fleet.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand All @@ -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,
Expand Down
Empty file.
Loading
Loading