Files
Battle-for-Tismo/STACKS.md
T

12 KiB

Stacks & unit management — design brief

A working document for a session on unit stacks: how a player splits them ergonomically, and how a stack moves as one body through the world.


1. Shared project context (read this first)

Battle for 'Tismo is an HTML5 port of a turn-of-the-millennium strategy game. Pure JavaScript, no build step, no runtime dependencies.

  • Server-authoritative. shared/game_state.js (GameState) is a framework-free model; server/game_server.js (GameServer) owns one and validates orders; server/server.js is the transport.
  • Shared logic runs in Node and the browser. shared/ stays framework-free (no Node built-ins, no DOM).
  • GameState is mixins. shared/game_state.js imports each shared/game_state/<topic>.js and Object.assigns its methods onto the prototype. A new method file must be imported and added there.
  • Client mirroring. Any pathfinding or movement-cost change must be mirrored between shared/game_state/movement.js and the browser prediction in client/js/map_view/motion.js, or prediction fights the server.
  • Data lives in shared/data/ (barrel: shared/data.js).

Commands

  • Install once: npm install.
  • One suite: node tests/run_tests.js --file <name>.js. Never run the full suite during development — the commit hook does that.
  • Server: node server/server.js --port 27015 --bind 127.0.0.1 (--testing adds free/instant buttons).

Conventions

  • 2-space indent; no new libraries (only vendored jQuery).
  • Missing icon → dummy SVG dummy icon - <name>.
  • Tests in tests/<topic>_test.js extending TestCase; fixtures in tests/framework/helpers.js (smallState(), defaultState(), adjacentLand(), grantBuilding()). Force RNG with state._random = () => value.
  • Visual changes are previewed as a small standalone HTML page linking client/css/style.css, opened in Firefox, before commit.
  • Commit only when asked; messages are one imperative sentence.

2. What exists today

  • The stack panel was just reworked (commit "Grouped the unit stack by type and let ranged units pick a target"): one line per unit type, tri-state group checkbox, opaque icon cards with healthbars, overlap-to-fit, cards to the left on top, deselected cards stay and sort to the back — client/js/game_screen/panels.js (_renderStackList, _layoutStackDeck, _stackSelectionChanged) and the _stackMembers whole-stack concept in client/js/game_screen.js.
  • Selecting a unit is by left-click on the map (client/js/map_view/input.js _onLeftClick) or from the right-click stack menu: client/js/game_screen/panels.js _openStackMenu lists each unit on a tile ("Move here", per-unit select, "Select all").
  • Right-click only moves now (the ranged Attack action is a panel button); moving onto a hostile stack triggers the fight server-side.
  • Group movement: _groupMovePlan and _groupSpeed (slowest member sets the pace); find_path is called per unit with a per-type route cache.
  • Territory rules: shared/game_state/movement.js _canUnitEnter treats foreign territory as a wall on through-routes except the civilisation targeted by the move (targetCiv = owner of the goal). _zoneOfControl denies the ring around hostile ground units. Mirrored in client/js/map_view/motion.js _canEnter / _zoneOfControl / _targetCivFor.
  • The client has the diplomacy matrix in this.snapshot.diplomacy and a helper _isAtWar(a, b) in client/js/game_screen/panels.js, but the MapView does not hold relations yet (needed for prediction — see §5).

3. Requirements (from ROADMAP.md)

  • Easy and ergonomic way to split unit stacks.
  • Give stacks with range units a dedicated button to perform ranged attacks, right-clicking will now ONLY move (obviously triggers a battle when moving on the same tile) (the ranged button is done; this line can be ticked)
  • Stacks must behave as single units when it comes to pathfinding
    • Cannot go through neutral third-party territory, allied and at-war territory can be walked through
  • Airplanes move in straight lines as they are not bound by tiles. (see AIR_MOVEMENT.md)

4. Splitting a stack — design space (settle before coding)

The problem: a player wants two units out of a five-unit stack to go one way and the rest another, without hunting each one down in the right-click menu.

Candidate mechanics (pick one primary, maybe one shortcut):

  1. Split button + destination. With a tile or stack selected, a "Split" action opens the stack panel with per-type count steppers ("move 2 of 3 infantry"), then a target pick. Good for precise splits.
  2. Shift/drag cards. In the stack panel, drag a card (or shift-click) to detach it into a new sub-selection, then right-click to move the detached group. Fast, but needs clear feedback for "this card is now moving".
  3. Halve / split by type. Buttons "Split in half" and "Split by type" (each type into its own stack) — one-click, no destination.
  4. Stack menu "Split off". Extend _openStackMenu: checkboxes per row to pick the units to detach, then a "Move selected" action.

Open questions:

  • Does a "split" create a persistent sub-stack, or is it just a temporary multi-selection that disperses after one order? (There is no persistent stack entity today — units sharing a tile are the stack.)
  • How does splitting interact with the group-speed rule (slowest member sets the pace for the whole move)? If you split off a slow artillery and send the fast infantry alone, the infantry should move at its own speed — confirm the group move only groups the selected units.
  • Where does the split land if the destination is one tile (all units together) versus a route (do they arrive as a stack)?
  • Undo/confirm: splitting should not be a mis-click trap.

5. Stack pathfinding as one unit

Goal: a stack routes as a body and never cuts through a neutral third party, while allied and at-war territory may be crossed.

Current rule (_canUnitEnter, through-route, non-air):

if (!air && !isGoal && owner >= 0 && owner !== unit.civ && owner !== targetCiv) return false;

Desired rule: block only neutral third parties; allow allied and at-war territory.

Open questions:

  1. Where does the alliance/war state come from on the server? GameState has relations and (with the treaties work) alliance treaties. A helper like _canTraverseTerritory(civ, owner) reading _relation(a, b) / alliance membership would centralise it. Decide whether "allied" means an alliance treaty specifically or also non-aggression/peace.
  2. Client mirror. The browser predicts paths, so MapView needs the same answer. this.snapshot.diplomacy is available when a snapshot is applied; add a relations/at-war/allied structure to MapView (set in _setupWorld or onState) and have _canEnter consult it. Without this, prediction will draw a route the server refuses.
  3. "Stacks behave as single units." Today each unit is pathfound separately with a shared route cache per type and the slowest speed wins. Decide whether the whole stack should share one route (computed once, followed by all) so units never fan out, and what happens if a unit is blocked mid-route by a zone of control that another unit is exempt from.
  4. Zone of control vs stack. Should a stack be able to force a crossing a single unit could not, or does the ZoC apply per unit? The roadmap is silent; keep per-unit unless asked.
  5. War declaration side effect. _groupMovePlan already records the owners a route crosses and _orderMove declares war on them. With allied/at-war traversal allowed, make sure a permitted crossing does not declare war on an ally (and that crossing an at-war nation is already legal).
  6. Air and spies are exceptions. Aircraft ignore territory entirely; spies cross foreign land (already implemented). The new rule should not regress either.

6. Suggested order of work

  1. Decide the split interaction (§4) and build it in the stack panel + menu, with client tests.
  2. Add the server helper for traversable territory and wire _canUnitEnter.
  3. Mirror it in MapView (_canEnter) with the relations the snapshot ships; add a prediction test in tests/map_view_input_test.js / map_view_test.js.
  4. Decide the one-route-per-stack question; if yes, change _groupMovePlan and the server's group move together.
  5. Tick the roadmap lines.

7. Tests to add

  • Split: selecting a subset splits correctly; the split group moves at its own (slowest member) speed; a mis-click is recoverable.
  • Territory: a route crosses an ally's land but not a neutral's; crossing an ally's land does not declare war; crossing an at-war nation's land is allowed; client prediction matches the server for each case.
  • Regression: spies and aircraft keep their exceptions; zone of control still applies.

8. Gotchas

  • Mirror server and client or prediction fights the server (this is the single most common bug in this area).
  • Do not add a per-broadcast walk. If relations are shipped, they are small; if you add a derived structure, memoise it against the diplomacy version.
  • The stack panel's _stackMembers (whole stack) vs selectedUnitIds (ticked) distinction is load-bearing; read client/js/game_screen/panels.js _stackSelectionChanged before changing selection semantics.
  • _orderMove/_groupMovePlan also handle transport (embark/disembark) and escort; check those paths when changing group movement.

9. Relevant files

  • client/js/game_screen/panels.js — stack panel, stack menu, _groupMovePlan, _groupSpeed, _isAtWar.
  • client/js/game_screen.js — _stackMembers, target/selection state.
  • client/js/map_view/input.js — click handling, _onRightClick.
  • client/js/map_view/motion.js — prediction _canEnter, _zoneOfControl, _targetCivFor, planMove.
  • client/js/map_view.js — where relations would be stored for prediction.
  • shared/game_state/movement.js — _canUnitEnter, _zoneOfControl, pathfinding.
  • shared/game_state/diplomacy.js, shared/game_state/treaties.js — relations and alliances.
  • Tests: tests/game_screen_test.js, tests/map_view_input_test.js, tests/map_view_test.js, tests/stacking_test.js, tests/orders_test.js.

INTELLIGENCE.md, AIR_MOVEMENT.md, ECONOMY_BALANCE.md, POLITICS_PERFORMANCE.md, TRAINING_UI.md, ICONS.md (repo root).

11. Decisions taken (implemented)

  • Split (§4): shift-click detach. A plain left-click still takes the whole stack; shift + left-click on a map unit (or a panel card, which already toggled) adds or removes just that unit from the moving group. Right-click moves the ticked subset at its own slowest-member pace; unticked cards stay in the panel so a mis-click is recoverable. No persistent sub-stack entity was introduced. client/js/map_view/input.js (_onLeftClick) forwards the modifier and client/js/game_screen.js (map.onUnitToggled) edits selectedUnitIds in place.
  • Territory (§5): neutral third parties are the only wall. GameState._canTraverseTerritory(civ, owner, targetCiv) allows a nation's own land, the land of the nation a route is aimed at, an ally's, and a nation already at war; _canUnitEnter uses it and _orderMove no longer declares war on an ally or an at-war nation it crosses. "Allied" means an alliance treaty specifically (areAllied). The browser mirrors this in map_view/motion.js _canTraverseTerritory, with the alliance/war pairs read from the snapshot in map_view/entities.js _applyRelations.
  • One route per stack (§5.3): deliberately not changed. Mixed stacks (land and naval, say) have different traversable terrain, so a single shared path would be wrong; the existing per-type route cache already collapses identical searches, and every member is pinned to the slowest speed. "Behave as a single unit" is therefore the shared traversal policy plus formation speed, not a literal one-path-fits-all.