17 KiB
17 KiB
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(onlyc8andjsdom, used by the test suite — nothing the game runs needs them). - Run the test suite:
node tests/run_tests.js(aliasnpm test; discovers everytests/*_test.jsand runs each in its own worker process, prints a summary and exits non-zero on failure).--jobs Ncaps 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, writescoverage/). - Start the game server and client locally:
node server/server.js --port 27015 --bind 127.0.0.1. Then openhttp://127.0.0.1:27015/(redirects to/client/index.html). The browser connects tows(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 setWARMUP) to cap how many days the settle may run and--warmup 0to 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 withgit config --unset core.hooksPath. - Build the deployable packages:
nix build .#serverandnix build .#web. - If
nodeis 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
--testingtonode server/server.js --testingto 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.jsstrips afreeflag from any non-loopback order (isLoopback, handling127.x,::1and::ffff:127.0.0.1), andGameServerhonours it only whilethis.testingis set.GameState.requestTrain/requestBuild(..., free)then spawn or raise immediately without cost or queue (placement rules still apply), andGameState.unlockTechnology(civ, index)grants a technology outright (prerequisites included). - The
testingflag 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) andclient/js/(browser). No Node built-ins and no DOM APIs inshared/(server/websocket.jsis 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 (seeserver/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 theclient/+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
.jsfiles with 2 spaces..editorconfigonly 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_DAYwhere they are shown (client/js/modals/format.jssignedMoney/effectNodes,budget.js,nation.js, and the approval tooltip ingame_screen/panels.js). Write new rate copy per day, and leave durations (build time, cooldowns, endurance) in hours. descriptionfields undershared/data/are player-facing and must match the simulation: they surface in tooltips — city building cards and resource producers (client/js/ui/card.jstooltipSections), 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 (aget 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 inshared/data/resources.js(formatEnergy,formatResourceAmount, re-exported byshared/resources.js);text_format.jshasformatNumber/formatPercent.tests/descriptions_test.jsrecomputes each figure and fails when the prose drifts from its source.- Keep aircraft out of the melee:
_battleTiles/_combatTargetskip 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.
_broadcastStaterebuilds 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 memoisegetCityEconomyoutside a snapshot -- a test pins that a simulation read reflects a directtilePopulationedit at once. DELTA_COLLECTIONSinserver/game_server.jspairs each large collection with theversionskey that reports it (notetileEthnicityis versioned asethnicity). 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 aGameState, validates orders and broadcasts snapshots over the transport inserver/server.js. The browser client is a view:client/js/map_view.jsrebuilds 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 byclient/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.MapViewpicks WebGL inrenderer"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.jsadvances movement every tick and strikes an in-game hour everySECONDS_PER_HOURreal seconds. The client extrapolates each unit along its snapshot path and predicts move orders locally (map_view.js), mirroringGameState.find_pathso 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 intoGameState.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 chunkedlayer-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_tickStrikesremoves 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 inadvanceMovementand then returns to a friendly airport, where landing arms a two-daystrikeReadyHourcooldown. 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 combatmissionRange; 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 standingstrikeTargetis shipped in the snapshot andmap_view/motion.jsdraws the red dashed line and target ring on#layer-targetswhile either endpoint is selected. - Aircraft and launched missiles fly straight lines, not hexes
(
GameState._isFlier). Theirpathis a list of world-pixel points (the two-pointflightLinefromshared/hex.js, usingnearestCopyPointfor the wrapped seam), and_advanceFlier/_completeFlightLeginmovement.jsfly them inadvanceMovement;_segmentHoursis the single source for flight time and fuel, so a plane can never run dry on a route its own check accepted.findPathreturns a straight line for a flier and never runs A*. The browser mirrors all of this inmap_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:_airDefenseVolleyfires once per in-game hour from every hostile battery withinAIR_DEFENSE.rangeTilesof the flier, charged per firing battery. - Terrain generation is deterministic from
(seed, mapConfig), soGameState._buildTerrainand 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 shipsmapConfigandMapView.ensureTerrainrebuilds the exact same terrain from it (not the globalMAP_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 onGameStateand 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._tradeGraphcaches 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, whilewarmUpToStability({ maxDays })settles untilpricesAreSteady(every commodity within 10% over the trailing seven days) or the cap, and the server calls the latter fromconfigureGamewith thewarmupDaysthatstartServer/the--warmupflag 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:
_tickResourcesgrows towardneed + (stockpileTarget - store) - harvest, and_consumeCityResourcesbooks 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,economyfigures ingame_state.js,game_clock.js,rng.js,noise.js,text_format.js,login_manager.js. Data-driven content lives inshared/data/(one small module per topic: civilisations, units, buildings, governments, technologies, terrain, ...).shared/data.jsis a barrel re-exporting the whole catalogue, so keep importing fromshared/data.jsand edit the file undershared/data/.shared/game_state.jsis likewise a thin class composed from the mixins inshared/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.jsfiles extending theTestCasebase intests/framework/;tests/run_tests.jsdiscovers and runs them. Add a newtests/<topic>_test.jsfor each new pure behaviour.
Deployment
flake.nixexposespackages.<system>.serverand.web, plusnixosModules.default. There is no compiler step:nix/package.nixcopies the app and wrapsnode server/server.js;webis the staticclient/andshared/tree.nix/module.nixruns the server as aservices.battle-for-tismosystemd unit and serves the static client through nginx, redirecting/to/client/index.htmland proxying/wsto the local Node server. The browser client connects to the page's own origin, so HTTPS gives itwss://.- Build locally with
nix build .#serverandnix build .#web. - Updating without a rebuild: set
services.battle-for-tismo.appDirectoryto a writable checkout (e.g./opt/battle-for-tismo); the unit then runsnode <dir>/server/server.jsinstead of the Nix-store copy. Serve nginxclient/andshared/from that same checkout (e.g.aliaslocations), thengit pullandsystemctl 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
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_exploreanswers 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.