Skip to content

Refactor combat to staged attrition with sticky tile holder#84

Open
ShadyMccoy wants to merge 6 commits into
masterfrom
claude/limit-move-budget-oJCbL
Open

Refactor combat to staged attrition with sticky tile holder#84
ShadyMccoy wants to merge 6 commits into
masterfrom
claude/limit-move-budget-oJCbL

Conversation

@ShadyMccoy

Copy link
Copy Markdown
Owner

Summary

This PR replaces the instantaneous winner-take-all combat model with a staged attrition system where multiple armies can coexist on contested tiles. A new "sticky holder" mechanic tracks which player controls a tile across ticks, providing structural defender advantage without global attacker bonuses.

Key Changes

  • Staged Attrition Combat: Tiles with multiple players' armies now resolve losses per-tick rather than instantly. Each army loses min(myStr, rate × pressure + floor) raw strength per tick, scaled by enemy tech multipliers. Fights take multiple ticks to resolve, creating persistent "brackish" contested zones.

  • Sticky Tile Holder: Added tile._holderPid to track which player "owns" a tile across ticks. The holder only flips when they lose all armies on the tile or when a new player clears it. This provides defender advantage through positional control rather than a global attackerBonus multiplier.

  • Lanchester Square Law (Default): Changed default combat model from "linear" to "lanchester". Pressure is computed as sum(enemy_strength²), so concentrating force compounds — a 2x ratio yields ~4x damage advantage. This replaces the flat 1.4 attacker bonus (now defaults to 1.0).

  • Role Derivation: Removed per-army isAttacker flag. Roles are now derived from tile holder: an army is a defender iff it belongs to the holder; all others are attackers. This simplifies grouping logic and makes roles contextual to the tile state.

  • Contested Tile Rendering: Updated renderer to paint contested tiles with concentric squares for each non-holder occupant, sized by their strength share. Holder gets the base tint; multiple armies create a marbled effect. Army glyphs offset on a ring to avoid stacking.

  • Territory Attribution: ownerArmy() now returns the holder's army (or null if holder has no army), fixing territory counts on contested tiles. Previously credited armies[0] regardless of actual control.

  • Conflict Re-queuing: GameMap.resolveConflicts() now re-queues tiles that remain contested (multiple players with armies) for the next tick, allowing staged attrition to continue. Tiles that resolve to single-player or empty are dropped from the queue.

  • Attrition Rate Configuration: Added attritionRate (default 0.06) and attritionFloor (default 3× rate) to Game config. Rate is the map-level "Attrition" setting; floor scales with rate so small fights snap while large fights can brackish longer at low rates.

Implementation Details

  • Tech multipliers (atk, def) now apply symmetrically regardless of attacker/defender role — the structural advantage comes from the sticky holder, not doubled bonuses.
  • Pressure is capped at the defending army's own strength to prevent impossible losses.
  • Armies below 0.5 strength die; the floor ensures small fights resolve in 1–2 ticks.
  • Budget reset (for "budget" movement mode) triggers when tile holder changes, matching the conquest semantics.
  • isContested() helper returns true iff multiple distinct players have alive armies on the tile.

https://claude.ai/code/session_011aTf1dnDhhXsatb8g5vuxA

claude added 6 commits May 11, 2026 02:09
Contested tiles now persist across ticks instead of annihilating to a
single survivor: each side loses min(myEff, k*pressure + floor)
effective strength per tick (defaults k=0.15, floor=0.5). Two 6v6
stacks resolve in ~5 ticks, a 1v1 snaps in 1-2 (the floor dominates),
8v2 still resolves decisively. Tile holder is now explicit state
(Tile._holderPid) seeded on first occupation in Game.spawnArmy, with
sticky semantics: ownership transfers only when the prior holder has
no army left on the tile, and stays null/in-flux when multiple
non-holder armies remain after that. Combat roles are derived from
the holder (defender uses tech.def, invader uses tech.atk) rather
than from a per-army isAttacker flag; the flag is gone. Default
combat model switches to lanchester (square law gives concentration
its advantage) and attackerBonus defaults to 1.0 since the perpetual
invader role would compound badly with a flat coefficient.

Renderer paints contested tiles as brackish: holder tint as base
plus concentric pips for each non-holder occupant sized by their
share of total strength; armies on contested tiles offset around a
small ring so their glyphs don't overlap. GameMap.resolveConflicts
re-queues still-contested tiles into the dirty list so the fight
continues next tick.
Combat slowness is now a map-level knob ("Attrition" input next to
Growth / Max army). Default rate drops from 0.15 to 0.06 — fair
neutral 6v6 stretches from ~5 to ~13 ticks. The per-tick attrition
floor scales with rate (3 × rate, clamped to 0.1) so the slider
actually moves the needle on long fights: at rate=0.02 a 6v6
brackishes for ~27 ticks, at rate=0.30 it snaps in ~3. Small fights
still resolve via the floor in 1–2 ticks regardless of rate.

atk and def tech are reinterpreted as symmetric "causing losses" /
"taking losses" modifiers, applied regardless of holder role: damage
to me = base × enemy.atk / my.def. With the sticky-holder model the
old role-keyed application doubled up the defender bonus, made the
invader's atk fire perpetually mid-brackish, and produced lopsided
outcomes purely from tech. Pressure now uses raw strength;
Lanchester's square law alone encodes "concentrate force wins".

Wire: HTML form input -> MapEditor.read/write -> applyMapForm ->
loadCustomMap -> mapConfig spread into the Game constructor.
attritionRate round-trips through URL/save info paths and the
custom-mode description string.
Master introduced simultaneous-move resolution (4-phase step with a
_pendingMoves queue) and N-way symmetric Lanchester combat. Combined
with our staged attrition + sticky holder:

- Game.step: keep master's Phase A/B/C/D (grow -> decide -> commit
  -> resolveConflicts). attack() now only enqueues; _commitMove
  spawns destination armies post-decision, so bots see start-of-tick
  state during act().
- Game._commitMove: replicate spawnArmy's sticky-holder seed on
  empty-tile arrivals (the commit path deliberately bypasses
  spawnArmy so friendlies fold via resolveConflicts, but we still
  need the holder seeded for territory). Dropped the dead
  newArmy.isAttacker = true write — staged combat derives roles
  from the holder, not from a per-army flag.
- Game constructor: keep attackerBonus=1.0 default (Lanchester's
  square law encodes concentration; a perpetual invader role would
  compound badly with master's 1.4 default) and attritionRate=0.06.
- Tile.resolveConflicts: keep our staged attrition + sticky holder +
  symmetric atk/def (causing/taking losses) over master's instant
  N-way one-shot resolver.
- Army.js: take master's verbatim (runGrowth/runAct split,
  _queuedSpend bookkeeping, attack-as-enqueue).
- RULES_VERSION -> v11 (staged combat is a rule change vs master v10).
Adds player.orders[] and a new plan(game, player) bot API alongside
the existing act(army, game). Orders are rectangles with a vector,
intensity (0-1), TTL, and commitment tag; they live across ticks and
auto-expire when TTL hits zero.

Game.step Phase B now runs plan() once per player whose strategy
defines it (mutates player.orders via game.issueOrder /
game.cancelOrder), then expands every active order into per-army
_pendingMoves via _expandOrders. Multiple orders covering the same
army merge: intensities sum (clamped at 1), vectors weighted-average,
single emission per army. Armies whose owner uses plan() skip the
legacy per-army act() loop entirely; the two paths coexist per
player so bots can migrate one at a time.

orderBudget (default 4) gates issueOrder — over-budget calls return
null. Will be surfaced as a map setting in the UI commit. RULES
unchanged for legacy bots; this is purely additive on the engine
side.
Engine serializes each player's active orders into the snapshot
(id, playerId, region, vector, intensity, ttl, commitment, birthTick)
and GameView mirrors them. Renderer.drawOrders paints each order as
a translucent rectangle in the player's color with an accent border
and a directional arrow whose length scales with intensity. Alpha
nudges up with TTL so a fresh campaign reads brighter than one
about to expire; the rectangle replicates across the wrap seam when
needed.

The new layer sits between drawMoves and drawArmies so order
strokes appear under the army glyphs but over the in-flight move
shapes. Toggled by Renderer.showOrders (default on); will hook up
to the View panel checkbox in the next commit.
Adds an "Order budget" form input (1-12, default 4) next to
Attrition. Wired through MapEditor.read/write -> loadCustomMap ->
mapConfig spread into the Game constructor; round-trips through URL
and saved-match restore paths.

Painter is the first plan-style bot: each tick, if it has no active
order, it issues one TTL-30 push order over its whole territory
toward the wrap-aware centroid of enemy strength. Demonstrates the
plan(game, player) -> issueOrder loop end to end. Not competitive
with the bred Frontier/Conqueror lineages (one global order vs per-
army tactical logic), but it shows that ~30 lines of declarative
intent can drive an entire empire's behavior across hundreds of
ticks.

docs/engine-api.md: new "Order-based bots" section with the API
surface and a minimal example.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants