257 lines
17 KiB
Markdown
257 lines
17 KiB
Markdown
# AGENTS.md
|
|
|
|
Battle for 'Tismo — HTML5 port of the strategy game. Pure JavaScript: shared
|
|
game logic runs both in Node (authoritative server) and in the browser (client).
|
|
There is no build step and no runtime dependency; the client vendors jQuery and
|
|
everything else (transport included) is hand-written.
|
|
|
|
## Commands
|
|
|
|
- Install dev dependencies once: `npm install` (only `c8` and `jsdom`, used by
|
|
the test suite — nothing the game runs needs them).
|
|
- Run the test suite: `node tests/run_tests.js` (alias `npm test`; discovers
|
|
every `tests/*_test.js` and runs each in its own worker process, prints a
|
|
summary and exits non-zero on failure). `--jobs N` caps the pool and
|
|
`--file <name>` runs a single suite in-process; the worker marker is internal.
|
|
- Never run the full test suite: the commit hook runs it anyway. During
|
|
development run only the single affected suite with `--file <name>`.
|
|
- Coverage report: `npm run coverage` (c8, writes `coverage/`).
|
|
- Start the game server and client locally:
|
|
`node server/server.js --port 27015 --bind 127.0.0.1`. Then open
|
|
`http://127.0.0.1:27015/` (redirects to `/client/index.html`). The browser
|
|
connects to `ws(s)://<page-origin>/ws`. Before it listens the server settles
|
|
the world until commodity prices hold steady over a seven-day window, so the
|
|
economy opens established; pass `--warmup <days>` (or set `WARMUP`) to cap how
|
|
many days the settle may run and `--warmup 0` to skip it. The settle logs how
|
|
many days it took and usually stops well short of the cap on the standard map.
|
|
- Install the versioned git hooks (runs the tests on every commit):
|
|
`./scripts/install-git-hooks.sh`; uninstall with
|
|
`git config --unset core.hooksPath`.
|
|
- Build the deployable packages: `nix build .#server` and `nix build .#web`.
|
|
- If `node` is not on PATH, use the pinned one: `nix-shell -p nodejs --run "node tests/run_tests.js"`.
|
|
|
|
There is no linter, formatter, or CI. Do not invent commands beyond these.
|
|
|
|
### Visual changes
|
|
|
|
- A change that alters what the player sees is reviewed by eye before it is
|
|
committed. Build a **minimal standalone HTML example** of the change first —
|
|
the isolated element and each of its states, not a whole mocked-up screen —
|
|
open it in a browser and show it to the user. Only commit once the user has
|
|
seen and approved the preview.
|
|
- Whenever an HTML page is shown, open it in the user's Firefox directly
|
|
(`firefox <path>`); never render a screenshot and never just print the path
|
|
for them to find.
|
|
- Keep the examples small and self-contained: link the stylesheet and copy in
|
|
the few assets they need. A short gallery of one small example per visual
|
|
change is the right size. Do not add the examples to the repository.
|
|
|
|
### Local testing mode
|
|
|
|
- The server creates its game at startup; add `--testing` to
|
|
`node server/server.js --testing` to make it a test game, where every city's
|
|
train and build rows gain a second "Free & instant" button and every technology
|
|
card an "Unlock now" button.
|
|
- The free shortcut is gated on the server, never trusted from the client:
|
|
`server/server.js` strips a `free` flag from any non-loopback order
|
|
(`isLoopback`, handling `127.x`, `::1` and `::ffff:127.0.0.1`), and `GameServer`
|
|
honours it only while `this.testing` is set.
|
|
`GameState.requestTrain/requestBuild(..., free)` then spawn or raise
|
|
immediately without cost or queue (placement rules still apply), and
|
|
`GameState.unlockTechnology(civ, index)` grants a technology outright
|
|
(prerequisites included).
|
|
- The `testing` flag is shipped in the snapshot so joiners see the same buttons.
|
|
|
|
## Gotchas
|
|
|
|
- Keep the shared modules framework-free: they are imported unchanged by both
|
|
`server/` (Node, ESM) and `client/js/` (browser). No Node built-ins and no DOM
|
|
APIs in `shared/` (`server/websocket.js` is the only place Node built-ins are
|
|
expected).
|
|
- Do not add JavaScript libraries. The only third-party code allowed is the
|
|
vendored jQuery under `client/vendor/`; implement everything else by hand
|
|
(see `server/websocket.js`, a dependency-free RFC 6455 server).
|
|
- The game server also serves the app as static files from the repository root,
|
|
so modules resolve as `/client/...` and `/shared/...`. Preserve the
|
|
`client/` + `shared/` layout when packaging (`nix/package.nix`).
|
|
- WebSocket state snapshots can be hundreds of KiB; the custom server handles
|
|
fragmented and large frames (`server/websocket.js`). Do not lower the buffer
|
|
limits without checking the snapshot path.
|
|
- Indent `.js` files with 2 spaces. `.editorconfig` only enforces UTF-8.
|
|
- The simulation ticks hourly, but the player reads rates per day: money income
|
|
and upkeep, research and culture effects, campaign spending and the opinion
|
|
drift are all scaled by `HOURS_PER_DAY` where they are shown
|
|
(`client/js/modals/format.js` `signedMoney`/`effectNodes`, `budget.js`,
|
|
`nation.js`, and the approval tooltip in `game_screen/panels.js`). Write new
|
|
rate copy per day, and leave durations (build time, cooldowns, endurance) in
|
|
hours.
|
|
- `description` fields under `shared/data/` are player-facing and must match the
|
|
simulation: they surface in tooltips — city building cards and resource
|
|
producers (`client/js/ui/card.js` `tooltipSections`), spy operations and
|
|
minority policies (`game_screen/panels.js`, `modals/nation.js`) — so correct
|
|
them when the behaviour they describe changes. Never type a figure into that
|
|
prose: interpolate the field or constant the simulation uses (a
|
|
`get description()`/`get targetEffect()`, not a literal), and render it with
|
|
the shared formatters so the description quotes exactly what the game shows.
|
|
The amount formatters live in `shared/data/resources.js` (`formatEnergy`,
|
|
`formatResourceAmount`, re-exported by `shared/resources.js`); `text_format.js`
|
|
has `formatNumber`/`formatPercent`. `tests/descriptions_test.js` recomputes each
|
|
figure and fails when the prose drifts from its source.
|
|
- Keep aircraft out of the melee: `_battleTiles`/`_combatTarget` skip them and
|
|
their strikes are routed explicitly. Mirror the air rules (airport-only
|
|
rebasing, strike radius, ferry fuel) on both client and server, or prediction
|
|
and the authoritative state will disagree.
|
|
- Keep the snapshot path cheap. `_broadcastState` rebuilds the shared snapshot
|
|
and every viewer's stats whenever the state is dirty (orders, hourly ticks),
|
|
not only when the economy moves, so a walk that touches every tile (headline
|
|
population/GDP, ethnic makeup, transport counts, a region's tiles) must be
|
|
memoised against the counters that actually change it: `_gdpEpoch`
|
|
(`_clearTileGdpCache`/`_dropTileGdpCache`), `_populationVersion`,
|
|
`_territoryVersion`, `_modifiersVersion`, `_tileImprovementVersion`. Do not
|
|
cache a whole enemy/own region's walk on nothing, and do not memoise
|
|
`getCityEconomy` outside a snapshot -- a test pins that a simulation read
|
|
reflects a direct `tilePopulation` edit at once.
|
|
- `DELTA_COLLECTIONS` in `server/game_server.js` pairs each large collection
|
|
with the `versions` key that reports it (note `tileEthnicity` is versioned as
|
|
`ethnicity`). If the pair drifts, the whole collection is silently shipped on
|
|
every broadcast -- the opening production baseline is static, so getting this
|
|
wrong re-sent thousands of entries ten times a second.
|
|
|
|
## Architecture
|
|
|
|
- Game logic is server-authoritative. `shared/game_state.js` (`GameState`) is a
|
|
framework-free model that owns the world, units, cities, territory, economy,
|
|
training and visibility. `server/game_server.js` (`GameServer`) owns a
|
|
`GameState`, validates orders and broadcasts snapshots over the transport in
|
|
`server/server.js`. The browser client is a view: `client/js/map_view.js`
|
|
rebuilds terrain from the shared seed, renders snapshots and sends orders.
|
|
The map geometry (terrain, roads, borders, fog and the zoomed-out overview)
|
|
is drawn on a single WebGL canvas by `client/js/map_view/webgl.js`, batched
|
|
into per-layer buffers and row-banded so only visible tile rows are drawn;
|
|
the unit, city, label, path and target icons stay DOM elements on top.
|
|
`MapView` picks WebGL in `renderer` `"auto"` mode and falls back to the DOM
|
|
chunk renderer when there is no context (jsdom), when the context is backed
|
|
by a software rasteriser, or when constructed with `{ renderer: "dom" }`.
|
|
- Movement is continuous: `server/server.js` advances movement every tick and
|
|
strikes an in-game hour every `SECONDS_PER_HOUR` real seconds. The client
|
|
extrapolates each unit along its snapshot path and predicts move orders
|
|
locally (`map_view.js`), mirroring `GameState.find_path` so a click feels
|
|
instant. **Any pathfinding or movement-cost change must be mirrored on both
|
|
sides** or prediction will fight the server. Roads (`shared/roads.js`,
|
|
pre-generated into `GameState.roads`) flatten a tile's movement cost on both
|
|
sides through `_tileTravelHours` / `_stepHours`; the road set is shipped in
|
|
the snapshot and drawn in the chunked `layer-roads`.
|
|
- Air and artillery are both ranged strikers (`GameState._isStrikeUnit`). A
|
|
ground or naval battery ordered to attack opens a **sustained hourly
|
|
bombardment**: it stays put and `_tickStrikes` removes a small share of its
|
|
attack damage each hour (`BOMBARD.hourlyDamageFraction`), clearing the target
|
|
when it dies or slips out of reach. An aircraft ordered to attack flies a
|
|
**sortie** (`requestAirStrike`): it bombs once on arrival in `advanceMovement`
|
|
and then returns to a friendly airport, where landing arms a two-day
|
|
`strikeReadyHour` cooldown. The strike is charged up front per aircraft
|
|
(`AIR_STRIKE`). Aircraft may only be rebased to one of their own airports and
|
|
the ferry is bounded by fuel, not the combat `missionRange`; they never join
|
|
melee. A strike may target a garrison inside a hostile city — the plane reaches
|
|
that tile only to bomb and turns for home, and captures skip
|
|
aircraft, so it never occupies or takes the city. A strike is only accepted
|
|
when the whole round trip fits the remaining
|
|
fuel (out to the target, then home to a friendly airport), and the browser
|
|
mirrors that check so an unreachable target is refused with a message instead
|
|
of an order the server would drop. A standing `strikeTarget` is shipped in the
|
|
snapshot and
|
|
`map_view/motion.js` draws the red dashed line and target ring on
|
|
`#layer-targets` while either endpoint is selected.
|
|
- **Aircraft and launched missiles fly straight lines, not hexes**
|
|
(`GameState._isFlier`). Their `path` is a list of world-pixel points (the
|
|
two-point `flightLine` from `shared/hex.js`, using `nearestCopyPoint` for the
|
|
wrapped seam), and `_advanceFlier`/`_completeFlightLeg` in `movement.js` fly
|
|
them in `advanceMovement`; `_segmentHours` is the single source for flight
|
|
time and fuel, so a plane can never run dry on a route its own check accepted.
|
|
`findPath` returns a straight line for a flier and never runs A*. The browser
|
|
mirrors all of this in `map_view/motion.js` (`_planMove`, `_flightHours`,
|
|
`_motionTile`, `motion.flight`): a mismatch makes the icon fight the server.
|
|
A missile (`requestMissileStrike`) now flies to its target and detonates on
|
|
arrival in `_detonateMissile`, so air defense can shoot it down first.
|
|
Air defense is a **corridor**, not a tile-entry volley: `_airDefenseVolley`
|
|
fires once per in-game hour from every hostile battery within
|
|
`AIR_DEFENSE.rangeTiles` of the flier, charged per firing battery.
|
|
- Terrain generation is deterministic from `(seed, mapConfig)`, so
|
|
`GameState._buildTerrain` and the settlement/road step cache the world shape
|
|
(`shared/game_state/world.js`) rather than regenerating it for every game with
|
|
a known seed. The snapshot ships `mapConfig` and `MapView.ensureTerrain`
|
|
rebuilds the exact same terrain from it (not the global `MAP_CONFIG`), which
|
|
also lets the DOM tests use a small map fixture.
|
|
- The economy advances on an hourly tick (`shared/game_state/resources.js`
|
|
`_tickResources`). Within one tick the city-market-access and node-population
|
|
lookups are cached on `GameState` and dropped at the end, so an order that
|
|
lands mid-tick still sees fresh data. The trade graph is the expensive part — a
|
|
walk from each city floods the ocean, since a sea lane runs any distance — so
|
|
it is kept across ticks *and* days. `_tradeGraph` caches each `(city, mode)`
|
|
walk under a stamp built from the road/railway, territory and storage-node
|
|
counters, so any network change rebuilds lazily while a quiet world reuses it;
|
|
a unit move only drops it while a war is on (a hostile camp can sever a
|
|
route). The tile-improvement version is deliberately *not* in the stamp: a
|
|
mine, mill or plant rebuilt in place bumps it many times a day but moves no
|
|
route, and folding it in threw every city's cached walk away each time. This
|
|
is what stops a settled world re-flooding every sea lane each day.
|
|
`GameState.warmUp(days)` runs a fixed number of days without players, while
|
|
`warmUpToStability({ maxDays })` settles until `pricesAreSteady` (every
|
|
commodity within 10% over the trailing seven days) or the cap, and the server
|
|
calls the latter from `configureGame` with the `warmupDays` that
|
|
`startServer`/the `--warmup` flag supplies (0 skips it).
|
|
- Food is the one good a region synthesises from energy, so its price signal must
|
|
come from the reserve, not the day's meal: `_tickResources` grows toward
|
|
`need + (stockpileTarget - store) - harvest`, and `_consumeCityResources` books
|
|
the empty-pantry appetite (`reserveGap / stockpileDays`) as demand. Without the
|
|
reserve term a region lives hand-to-mouth with stores near zero and the food
|
|
price stays flat. Keep the two in step: raising one without the other only
|
|
forces synthesis or only bids the price, not both.
|
|
- `shared/` holds the reusable algorithms so they stay testable without a
|
|
server: `hex.js` (topology/geometry), `map_generator.js`, `hex_pathfinder.js`,
|
|
`terrain_stats.js`, `rules.js`, `economy` figures in `game_state.js`,
|
|
`game_clock.js`, `rng.js`, `noise.js`, `text_format.js`, `login_manager.js`.
|
|
Data-driven content lives in `shared/data/` (one small module per topic:
|
|
civilisations, units, buildings, governments, technologies, terrain, ...).
|
|
`shared/data.js` is a barrel re-exporting the whole catalogue, so keep
|
|
importing from `shared/data.js` and edit the file under `shared/data/`.
|
|
`shared/game_state.js` is likewise a thin class composed from the mixins in
|
|
`shared/game_state/` (world, territory, economy, movement, combat, air,
|
|
status, diplomacy, orders, visibility, serialization).
|
|
- `client/js/` splits the UI: `app.js` (screens and flow), `net.js` (transport
|
|
wrapper), `game_screen.js` + `game_screen/` (HUD and panels), `map_view.js` +
|
|
`map_view/` (DOM map), `modals.js` + `modals/` (city
|
|
training/buildings/regional budget, nation/technology, confirm, news).
|
|
- Tests live in `tests/` as `*_test.js` files extending the `TestCase` base in
|
|
`tests/framework/`; `tests/run_tests.js` discovers and runs them. Add a new
|
|
`tests/<topic>_test.js` for each new pure behaviour.
|
|
|
|
## Deployment
|
|
|
|
- `flake.nix` exposes `packages.<system>.server` and `.web`, plus
|
|
`nixosModules.default`. There is no compiler step: `nix/package.nix` copies
|
|
the app and wraps `node server/server.js`; `web` is the static `client/` and
|
|
`shared/` tree.
|
|
- `nix/module.nix` runs the server as a `services.battle-for-tismo` systemd unit
|
|
and serves the static client through nginx, redirecting `/` to
|
|
`/client/index.html` and proxying `/ws` to the local Node server. The browser
|
|
client connects to the page's own origin, so HTTPS gives it `wss://`.
|
|
- Build locally with `nix build .#server` and `nix build .#web`.
|
|
- Updating without a rebuild: set `services.battle-for-tismo.appDirectory` to a
|
|
writable checkout (e.g. `/opt/battle-for-tismo`); the unit then runs
|
|
`node <dir>/server/server.js` instead of the Nix-store copy. Serve nginx
|
|
`client/` and `shared/` from that same checkout (e.g. `alias` locations), then
|
|
`git pull` and `systemctl restart battle-for-tismo`. Keep the client and
|
|
server trees on the same commit: the client mirrors the server's movement
|
|
model, so a mismatched pair makes prediction fight the server.
|
|
|
|
<!-- CODEGRAPH_START -->
|
|
## CodeGraph
|
|
|
|
In repositories indexed by CodeGraph (a `.codegraph/` directory exists at the repo root), reach for it BEFORE grep/find or reading files when you need to understand or locate code:
|
|
|
|
- **MCP tool** (when available): `codegraph_explore` answers most code questions in one call — the relevant symbols' verbatim source plus the call paths between them, including dynamic-dispatch hops grep can't follow. Name a file or symbol in the query to read its current line-numbered source. If it's listed but deferred, load it by name via tool search.
|
|
- **Shell** (always works): `codegraph explore "<symbol names or question>"` prints the same output.
|
|
|
|
If there is no `.codegraph/` directory, skip CodeGraph entirely — indexing is the user's decision.
|
|
<!-- CODEGRAPH_END -->
|