Commit Graph

2 Commits

Author SHA1 Message Date
Evan 181368f962 Add live game stats endpoint to the admin bot API (#4399)
## Summary

The game simulation runs **client-side**, so the server can't directly
see what's happening in a running game. This adds a way for the admin
bot to observe a live game: clients report a live stats snapshot every
~10s, the server reaches consensus on it (reusing the winner's vote
mechanism), and a new admin-bot endpoint serves it.

## How it works

1. **`LiveStatsController`** (client) emits a snapshot every **100
turns** (~10s at 100ms/turn) — only deterministic sim values, with
players sorted by clientID, so in-sync clients produce an identical
payload.
2. The snapshot is sent as a new **`live_stats`** wire message wrapping
a `LiveStats` object (`turn` + per-human-player
`tilesOwned`/`troops`/`gold`/`isAlive`/`team`).
3. **`GameServer.handleLiveStats`** tallies a per-turn **IP-weighted
majority vote** — the same consensus the winner uses — and keeps the
latest agreed snapshot.
4. **`GET /api/adminbot/game/:id/stats`** returns it, enriched with
usernames the server already holds. `liveStats` is `null` until the
first consensus.

The winner's vote tally was extracted into a small reusable
**`VoteRound`** (`src/server/VoteTally.ts`) and is now used for both
winner and live-stats consensus.

Names are deliberately **excluded** from the voted payload (they vary
per client under name anonymization, which would break exact-match
consensus); the server joins `clientID → username` instead.

## Changes

- `src/server/VoteTally.ts` *(new)* — reusable IP-weighted `VoteRound`
- `src/core/Schemas.ts` — `PlayerLiveStatsSchema`, `LiveStatsSchema`,
`ClientSendLiveStatsSchema` + unions
- `src/client/controllers/LiveStatsController.ts` *(new)* — per-100-turn
snapshot reporter
- `src/client/Transport.ts` — `SendLiveStatsEvent` + sender
- `src/client/hud/GameRenderer.ts` — register the controller
- `src/server/GameServer.ts` — refactor winner onto `VoteRound`; add
live-stats consensus + `liveStats()` accessor
- `src/server/AdminBotRoutes.ts` — `GET …/stats` endpoint

## Testing

- **Unit:** `tests/server/VoteTally.test.ts` (majority/dedup/ties),
`tests/server/LiveStats.test.ts` (consensus, disagreement, per-client
dedup, stale-turn rejection, turn advance, out-of-sync exclusion, +
endpoint 200/404/400). Full suite green (`npm test`), typecheck + lint
clean.
- **Manual e2e** against the dev server: created an admin-bot game,
joined it in a browser, force-started via `toggle_game_start_timer`, and
confirmed `GET …/stats` returned the consensus snapshot with username
enrichment and an advancing `turn`. Also verified wrong-worker → 400 and
missing-key → 401.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 15:21:52 -07:00
Evan 67f7d09fe5 Add admin bot HTTP API for managing private games (#4388)
## What

A trusted, server-side HTTP API so a bot authenticated with a shared
secret can **create private games, change their settings, start them,
kick players, and pause/resume** — without opening a WebSocket or
joining as a player.

Two endpoints under `/api/adminbot/`, reaching the owning worker via the
existing `/wN/` nginx routing. They reuse the existing Zod schemas and
`GameServer` methods, mirroring the WebSocket intent flow rather than
inventing a new wire protocol.

| Endpoint | Purpose |
| --- | --- |
| `POST /api/adminbot/create_game` | Create a private game; the worker
mints a self-owned id and returns it (body:
`GameConfigSchema.partial()`) |
| `POST /api/adminbot/game/:id/intent` | Send a lobby-management intent
(body: base `IntentSchema`) |

## How it works

- **Auth:** `ADMIN_BOT_API_KEY` env var via the `x-admin-bot-key` header
(timing-safe compare). The whole API is **disabled — 404 — when the var
is unset**, so non-configured environments expose nothing. It's distinct
from the per-instance `ADMIN_TOKEN`, which an external bot can't know.
- **`GameServer.handleIntent`** is the unified intent dispatch for both
the WebSocket `case "intent"` path and the admin-bot HTTP API. An
`IntentActor` carries identity + authority (per-connection
lobby-creator/role checks for the WS path; admin authority for the bot).
It honors `update_game_config`, `toggle_game_start_timer`,
`kick_player`, and `toggle_pause` — **on private games only**
(`isPublic()` → 403). Gameplay intents and `mark_disconnected` are
rejected (400).
- **Private games only.** `create_game` rejects any `gameType` other
than `Private` (Public *and* Singleplayer → 400); an omitted `gameType`
defaults to `Private`.
- **The bot is never a player.** It sends no `clientID`; the server
stamps a placeholder `ADMIN_BOT_CLIENT_ID = "ADMINBOT"` (collision-proof
— contains `I`/`O`, which `generateID()` never emits). A gameplay intent
stamped with it would resolve to no player, so puppeteering is
structurally impossible on top of the explicit 400.
- **Determinism unchanged:** the only intent that reaches the sim is
`toggle_pause`, via the same `addIntent` → turn queue →
`ServerTurnMessage` path the WS uses.

## Notable details for review

- **`hostCheats` is assigned unconditionally — on purpose.**
`updateGameConfig` sets `this.gameConfig.hostCheats =
gameConfig.hostCheats` unconditionally, unlike its sibling fields (which
are guarded on `!== undefined`). The WS host clears cheats by re-sending
the *full* config with `hostCheats: undefined`, so here `undefined` must
mean "clear", not "leave unchanged". **Caveat for the admin bot**, which
is a *partial*-update client: a partial `update_game_config` that omits
`hostCheats` will clear it — the bot should send `hostCheats` explicitly
(or a full config) when it wants to keep a previously-set value.
- **Deploy wiring:** `ADMIN_BOT_API_KEY` is piped through the deploy
steps' `env:` in `deploy.yml`/`release.yml` → `deploy.sh` heredoc →
container via `update.sh`'s `--env-file`. The remaining manual step is
creating the GitHub secret itself.

## Tests

19 new tests:
- `GameServer.handleIntent` admin-bot behavior (per-intent,
private-only, post-start guards, placeholder clientID, rejected
gameplay/`mark_disconnected` intents).
- `create_game` gameType guard (Public and Singleplayer both rejected).
- `requireAdminBotKey` middleware (404 disabled / 401 missing / 401
wrong / pass).

tsc + eslint clean.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 19:09:14 -07:00