Refactor combat to staged attrition with sticky tile holder#84
Open
ShadyMccoy wants to merge 6 commits into
Open
Refactor combat to staged attrition with sticky tile holder#84ShadyMccoy wants to merge 6 commits into
ShadyMccoy wants to merge 6 commits into
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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._holderPidto 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 globalattackerBonusmultiplier.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
isAttackerflag. 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) andattritionFloor(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
atk,def) now apply symmetrically regardless of attacker/defender role — the structural advantage comes from the sticky holder, not doubled bonuses.isContested()helper returns true iff multiple distinct players have alive armies on the tile.https://claude.ai/code/session_011aTf1dnDhhXsatb8g5vuxA