Spaces:
Paused
Paused
Commit History
-
Get CI green, and make the AI's opponent belief fade the way it says it does
Claude +1: Claude Opus 4.8Unverified signatureActs on the first three findings of docs/code-review-2026-08.md. CI has been red since 2026-08-05 and two PRs merged through it. Two causes, both fixed here: - competitionLedger.js had two wrong JSDoc signatures. addRosterEntry never declared the `genome` field its body reads and writes (the RosterEntry typedef omitted it too), and recordCompetition typed its match rows as bare `object`, so every row.aName / row.winner / row.margin read in the file was an error the moment a checker looked. Adds a real CompetitionResultRow typedef and narrows the one ternary TypeScript was widening to `string`. - CI installed TypeScript unpinned via `npx --package typescript`, so the same tree reported 2 errors under 5.7 and 21 under 7.0.2 — a green commit could go red overnight with no code change. The invocation is pinned to 5.7.2; the compiler still stays out of package.json, it is just no longer a moving target. Verified clean on both 5.7.2 and latest. The third is a real behavi…15f3a03 -
Phases 2-3: the AI answers what it reads
Claude +1: Claude Opus 5Unverified signaturePHASE 2 -- the raid stops being a metronome. It fired on `waveCount % RAID_EVERY` and only on Hard: harassment on a schedule, regardless of whether harassment was the right answer. It now ALSO fires when the read says the enemy is visibly investing in economy over defence and enough of them has been scouted to act on it. That is harassment as a response: punish greed, and specifically punish the hoarder docs/ai-evolution-design.md found winning by never committing its army. An opponent that can be SEEN to be undefended should not be safe -- which removes that degenerate strategy structurally instead of penalising turtling inside a fitness function, where evolution would just route around it. The read is deliberately not gated on controller.micro. Reacting to what you scouted is ordinary competence, not the unit-level micro Hard buys, and an Easy/Medium AI that never once punishes a greedy opening is the passivity the bench keeps measuring. PHASE 3 -- a damped stance. posture is far t…5d60e36 -
Phase 1: the AI gets a belief about its opponent
Claude +1: Claude Opus 5Unverified signatureengine/aiIntel.js. Every other reactive read in the AI answers a question about right now -- are there enemies at my base, how many can I see, what do they field most -- and none of them survives losing sight of the enemy, because entity sighting goes through the LIVE fog layer. Kill the scout and the AI's picture of its opponent is empty, and it has no way to know it. This is the other kind of knowledge: what sort of game the opponent is playing. It persists between sightings and decays when unrefreshed. It is the first piece of AI state that can be WRONG -- everything else is a fact about the current frame -- and that is the point. An opponent model you can deceive is what makes scouting worth its cost and killing the scout a real counter-play. Three numbers, and they are three for a reason: mil / eco ore-value of what has been SEEN, split by whether it shoots posture mil / (mil + eco): 0 is a pure economy game, 1 is a pure war game confidence how much has been see…2991d58
-
Archetype lookup: reject reserved keys instead of resolving to Object.prototype
Claude +1: Claude Opus 5Unverified signaturePhase 2's own final-check pass found this on its own initiative: createAiController's opts.archetype resolution and competition.js's buildJob/shapeRosterRow used a truthy `ARCHETYPES[key]` bracket-access. For a plain object, "__proto__" is always truthy and resolves to the object's own prototype rather than "not a real key" -- so an archetype key of "__proto__" silently became Object.prototype instead of falling back as documented, and "constructor"/"prototype" hit the same inherited-property hazard. Not reachable through the shipped UI today (the Archetype picker only offers real keys, and competitionLedger.js's own roster guard already blocks a hostile value from ever being persisted), but a real contract violation in two exported, reusable functions -- exactly the class of latent bug this effort has been careful to close at the source rather than leave for a future caller to trip over (see the same-name Elo collision fixed in Phase 1, and the roster forbidden-name guard added earli…c0f45f5 -
Phase 2 (part 2): roster + persistent ladder wired into Quick Duel, archetype threaded end-to-end
Claude +1: Claude Sonnet 5Unverified signatureTurns Phase 1's one-off Quick Duel into the persistent competition mode docs/competitions-and-elo.md Phase 2 describes: Quick Duel / Roster / Standings, one coherent mode behind a small tab row, all three reading and writing the SAME competitionLedger.js ledger. THE KEY UX CHANGE: ad-hoc, free-typed entrants are retired. Each Quick Duel entrant is now either picked "From Roster" (a <select> of competitionLedger's current roster) or drafted "New Entrant" (name + strategy + archetype + faction) -- a new draft joins the roster, via addRosterEntry, the instant startDuel() actually commits to running (after buildJob's own validation passes, before the Worker spins up), never on a keystroke, so an abandoned draft never pollutes the roster. A finished duel's rows are folded into the ledger for the pinned difficulty bracket via recordCompetition alone -- no second, hand-rolled elo.js applySeries call in competition.js. The results view's old "Elo isn't saved" line is gone, replaced by "Elo up…628afc1 -
Phase 2 (part 1): per-entrant archetype override (D3) and the competition ledger (D8)
Claude +1: Claude Sonnet 5Unverified signatureengine/state.js's createAiController(planetId, opts) gains an optional opts.archetype: a STRING KEY into engine/aiArchetypes.js's ARCHETYPES table, resolved via ARCHETYPES[opts.archetype] when valid, falling back to archetypeFor(planetId) exactly as before otherwise -- every existing call site (none of which pass it) stays byte-identical. This is what lets a future competition entrant carry its own doctrine instead of both seats sharing whatever temperament the world hands out ("Rusher vs Turtle" becomes a real matchup). elo.js gains a small additive PROVISIONAL_GAMES=10 export (INITIAL_RATING's own precedent) so kFactor and the new ledger's standings read-back share one constant instead of two copies of the number 10. New root-level competitionLedger.js implements D8: a versioned, sanitized, untrusted-input-hardened roster + per-bracket Elo ladder, mirroring engine/persist.js's sanitizeSave/cleanEntity discipline deliberately, not by analogy -- an exact-match COMPETITION_VERSION gate…925767d
-
Tier 1, step 2: make the type layer real (D1, D2, D4)
Claude +1: Claude Opus 5Unverified signatureengine/types.js had drifted from the shapes it claims to model. A tsc probe against the seven typedefs returned 20 errors and zero on the control fields: State.popCap, State.playerAi AiState.strategy, AiState.lastThreatAt Galaxy.lanes, Galaxy.laneSeq GalaxySettings.startId, GalaxySettings.popCap Diplomacy.provokedAt/goodwill/request/lastFavorBucket Order.tx/ty/explore/patrol Archetype.wantsRefinery/turretCount/maxBarracks/garrison State.playerAi is the central field of the self-play feature, left undeclared by the very commit that added it. The drift was invisible because every factory assigns to a const before returning, so TypeScript never runs an excess-property check — and it actively BLOCKED adoption: annotating one function in engine/supply.js with @param {State} produced three errors in correct code, which teaches contributors to distrust the checker. All 20 now resolve; typecheck stays clean. Added a Lane typedef (createLane's real shape) and declared the lazil…6db3533
-
Make the AI controller owner-parametric: true two-sided self-play
Claude +1: Claude Sonnet 5Unverified signaturerunAI(state, dt, owner = "ai") and every AI phase module now resolve their controller, opponent, and fog grid from `owner` instead of hardcoding "ai"/ state.fogAI — 100% backward compatible (every pre-existing call site keeps the default and behaves byte-for-byte as before). A new, independently configured controller slot (state.playerAi, null by default) lets a second real AI drive the "player" seat in a skirmish, headless-only (tools/ selfplay.js) — nothing in setup.js/boot.js/the shipped game changed. Independent adversarial review (fairness lens + correctness lens) caught a real bug before this was accepted: Hard difficulty's economic edge (hardEdge) was seeded onto state.players.ai only, never a self-play "player" controller configured for Hard. Fixed via seedDifficultyEdge (engine/state.js), called for "player" once state.playerAi exists. engine/persist.js's save/load extension is purely additive (no SAVE_VERSION bump) — old saves round-trip unaffected. Co-Authored-By: Claude …916c3ad
-
Add a configurable population cap (200/250/300/Max) for both sides
ClaudeUnverified signatureNo hard ceiling existed before this — supplyCap (engine/supply.js) was purely additive from completed buildings (Command Center +10, Habitat +8), unbounded above. Added a new setup.js option, Population cap, alongside the existing economy dials (Faction/Resources), offered in both Skirmish and Odyssey (the two modes with a real economy) — Max (the default) is byte-identical to today's always-uncapped behavior. - engine/supply.js: split the old supplyCap into buildingSupplyCap (the raw, unclamped additive total) and a new supplyCap that clamps it to state.popCap when set. Every existing caller (production.js's enforcement gate, hud.js's display) keeps calling supplyCap unchanged and transparently gets the clamped behavior. - state.popCap threads through createGameState, boot.js's startGame AND startOdyssey, and engine/galaxy.js's createGalaxy/addPlanet — applied unconditionally to every world (active seat and background neighbours alike, exactly like sizeMult/resourceMult…dadc0b2
-
Reactive opening checklist + an honest, configurable endgame clock
Claude +1: Claude Sonnet 5Unverified signatureReactive opening checklist (replaces the static objectives strip): overlays.js's showObjectives() used to print one static sentence that auto-dismissed after 30s regardless of what the player had actually done. renderObjectives(state, galaxy) now walks a small per-mode predicate table (5 items for skirmish, 4 for Odyssey) and paints a live checklist; items check off with a flash as their predicate is satisfied. updateObjectives(game), called from hud.js's renderHUD every tick behind a cheap signature guard, keeps it reactive and retires the strip (remembering the completion via a per-mode localStorage flag) once every item is done, instead of a blind 30-second timeout. The strip is suppressed for a scripted scenario, whose own scenario bar already states that mode's objective. Make the clock endgame visible, honest, and configurable: - engine/victory.js's finish() now records state.winReason ("elimination" | "mutual-wipe-score" | "timeout-score") at each of checkWinCondition's …5bffdf0 -
Let the player pick their side of asymmetric matchups (Oort, Nimbus)
Claude +1: Claude Sonnet 5Unverified signaturegenerateMap accepts opts.swapAsym: when set and the world carries an asym block, it attaches a shallow COPY of modifiers with the player/ai asym halves exchanged. Copying (not mutating) matters because generateMap otherwise attaches the shared PLANET_MODIFIERS[planetId] object by reference — every game on that world reads it, so mutating it in place would corrupt the next game. Swapping consumes no rng draw, so map layout/node generation is byte-identical either way. Threaded through createGameState (a new opts.swapAsym, defaulting false) and persisted as an additive field next to sizeMult/ resourceMult in engine/persist.js's serPlanet/rehydratePlanet — no SAVE_VERSION bump, an old save without the field loads as unswapped. engine/types.js's State typedef gained the new field. Tests: engine/map.js's opts.swapAsym behavior (exchange, no mutation of the shared table, no-op on a world with no asym block, no rng/ node-layout perturbation) in test/map.test.js; the flag's persistence + reg…8338353
-
Tier 1: worker target, war patience, and a seeded economic edge by difficulty
Claude +1: Claude Sonnet 5Unverified signatureThree economic dials on top of Tier 0's scaffold, each composing multiplicatively with the archetype/strategy layers already in place: - workerTargetMult (aiEconomy.js) scales the AI's worker headcount target — Easy 0.8x, Hard 1.25x. - graceMult/grievanceMult (diplomacy.js) nudge the Odyssey war-onset timing — Easy a touch more patient, Hard a touch less; deliberately mild since it's a third multiplicative layer, and GRACE_FLOOR still bounds the opening window regardless. - economicEdge (Hard only) seeds a synthetic `hardEdge` UPGRADES entry (gatherYieldMult/produceTimeMult) straight onto the AI's own upgrades at creation. gather.js/production.js need no changes at all to pick it up — they already read any upgrade field generically via upgradeMult. That last one required hardening committedDoctrine (entities.js), which used to return on ANY UPGRADES membership rather than checking `.doctrine` specifically — harmless while every entry had a doctrine, but hardEdge doesn't…b28c2f0 -
Move DIFFICULTY_OPTIONS into engine/aiDifficulty.js, add state.ai.difficulty
Claude +1: Claude Sonnet 5Unverified signatureScaffold for tiered difficulty economics: the Easy/Medium/Hard table now lives in engine/ (mirroring how aiStrategy.js already holds STRATEGIES) with a difficultyFor(state) resolver alongside it, and every state.ai now carries the picked difficulty as a plain string, threaded through boot.js's skirmish/Odyssey paths and galaxy.js's per-planet seeding, with save/load round-trip support. setup.js re-exports DIFFICULTY_OPTIONS so boot.js and existing tests are unaffected. No economic field is added to any difficulty tier yet — every new/updated test proves this lands as a pure no-op. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G73oK4p3bnreizCcMUHVEy53ce1fb
-
Remove dead allEntities export; test techtree's stale-id silent-drop
Claude +1: Claude Sonnet 5Unverified signatureallEntities had zero callers anywhere in the codebase - dead API surface, deleted rather than given speculative coverage. Separately, updateResearch's silent-drop of a queued job whose techId no longer resolves in TECHS (e.g. a save written before a tech was renamed) was never exercised: proves it doesn't throw, drops the stale entry, and the Datacenter keeps developing real research normally afterward. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F4Z8xu1oLPhhJsPnb3bgg3da87105
-
Add a player-picked AI Strategy (Aggressive/Economic/Force Parity) and Helium Bomb use
Claude +1: Claude Sonnet 5Unverified signatureThe AI never touched the Helium Bomb (built at the Star Dock alongside the Leviathan) despite having full logic for the Plasma Rig. engine/aiSuperweapon.js gives every strategy a use for one: armed as a home-defense trap the instant the base is attacked, or — Aggressive only — walked to the attack target and triggered there, mirroring the player's own "Detonate Now". engine/aiStrategy.js adds a new player-selectable stance, orthogonal to the planet-driven archetype (which stays purely a flavor pick): Aggressive presses the attack earlier/smaller and forces the issue in Odyssey instead of waiting out diplomacy; Economic keeps a minimal standing army until it's actually attacked, then surges, and leans into the deep industry chain (Plasma Rig included) regardless of archetype; Force Parity continuously mirrors whatever it's seen of the enemy's army and never picks the fight itself. The default strategy is a byte-identical no-op, so every existing archetype/resolve guarantee is untouched…be1f1f9
-
Implement Phase 1 of battle wreckage: kills leave minable wreck sites
ClaudeUnverified signatureEvery unit, worker, or building destroyed in combat (engine/combat.js) or by a Helium Bomb blast (engine/bomb.js) now deposits ~80% of its own construction cost into a nearby wreck site (engine/wreckage.js), which matures into real ResourceNodes after a delay -- one node per commodity, reusing the crater's pending-timer -> mature-into-node pipeline. Nearby deaths consolidate into a single site instead of scattering tiny deposits. This replaces the old instant, combat-unit-only 25% salvage refund (grantSalvage/SALVAGE_FRAC), which gave workers and buildings nothing. Wreckage is neutral, like every other resource node -- no ownership gate. See docs/battle-wreckage-design.md for the full design and remaining phases.3620efb
-
Helium Bomb: a detonation also terraforms - the crater matures into ore
Claude +1: Claude Sonnet 5Unverified signatureA detonation now schedules a crater (state.craters) at the blast center in addition to erasing everything in range. CRATER_SPAWN_DELAY (60s of sim time) later, engine/sim.js's per-tick updateCraters call turns it into a real ResourceNode - deterministically picked (hashStr off the crater's own id, no engine randomness) from every Raw-tier commodity, not just what this planet's own deposit table naturally rolled. It's a plain node once it exists: gather.js, fog/discovery, and rendering need zero special-casing, since node type is already derived purely from COM[com].extract. The one real complication is persistence. A normal map-generated node is regenerated fresh from the seed on load and only needs its `amount` saved - but a crater node doesn't exist in that regeneration at all. serPlanet now saves a crater node's whole shape (tagged `crater: true`) instead of just {id, amount}, and rehydratePlanet re-adds it to the regenerated map after the fact, sanitized the same way a tampered en…5330c74 -
Two-owner scaffold: structural generalization (owner-generic, 1v1 byte-identical)
Claude +1: Claude Opus 4.8Unverified signatureThe `state.players = {player, ai}` scaffold was hardcoded across the engine — two named literals for the player map, two `createFog` calls, two `seedPlayer` calls, two `updateFog` calls, a two-branch victory check, and the save serialize/rehydrate. Combat, movement, separation and the spatial grid were already owner-generic (`owner !== self`); only the scaffold named the two sides by hand, so adding a side meant a sweep across ~15 files. Generalize the scaffold to ITERATE `state.owners` instead of naming the pair: - state.js: an `ownerDefs` list builds `state.owners`, `state.players` and a per-owner `state.fogs` map by loop; `state.fog` / `state.fogAI` become aliases into `state.fogs` so the many existing fog consumers keep working unchanged; seeding and initial fog run as loops over `owners`. - victory.js: `checkWinCondition` reads last-side-standing over `state.owners` (one side left ⇒ it wins; none ⇒ score); `scoreLeader` picks the highest score with a first-listed tie-b…b2772b8
-
Odyssey freighters: a player-managed cargo hold (load/unload), shipped on a jump
Claude +1: Claude Opus 4.8Unverified signatureCargo ships used to be capacity-only — a jump auto-picked the most valuable goods and the player had no say in what shipped. Each freighter now carries a `freight` hold (commodity → qty, capped at its cargoHold) that the player fills and empties by hand: - loadFreighter/unloadFreighter (engine/galaxy.js) move goods between the current world's stockpile and a freighter's hold, clamped to room and stock; freightUsed / freightRoom report fullness. - A jump now delivers each staged freighter's hold to the destination colony. A freighter left EMPTY still auto-fills most-valuable-first (the zero-effort default is unchanged — all existing cargo tests pass), while a hand-loaded one ships exactly what the player chose (e.g. cheap spice over machinery). - The hold rides on the unit, so it serializes for free; cleanEntity sanitizes it on load (real commodities only, positive, clamped to cargoHold) since a save is untrusted. Named `freight`, not `hold`, to stay clear of the combat ho…6325132 -
Add JSDoc typedefs + // @ts-check for the core sim shapes (no build step)
Claude +1: Claude Opus 4.8Unverified signatureAddress the top architectural-review finding — an untyped mutable `state` bag threaded through ~360 functions, where a mistyped or renamed field reads undefined silently and the same-seed determinism test can't see it. Now that class of bug is a check-time error. Zero runtime cost and no build step: the shipped code stays plain ES modules. - engine/types.js: JSDoc @typedefs for the core shapes (State, Unit, Building, Player, AiState, Resources, Cargo, Order, ResourceNode, GameMap, Fog, Market, Diplomacy, Galaxy, …). No runtime code, never imported — global typedefs the checker and any editor's TS language service resolve project-wide. - jsconfig.json + `npm run typecheck` (tsc, noEmit): checking is OPT-IN per file via a `// @ts-check` pragma, so coverage grows file-by-file. Editors check the annotated files live with no install; the script needs a tsc available. - Enabled + annotated on the core data + hot path: state.js (the factories bind the types to the real objects), …e3658fa -
Tier 5: group the AI controller's bookkeeping under state.ai (no behaviour change)
Claude +1: Claude Opus 4.8Unverified signatureMove the 13 flat state.aiThink / state.aiScoutId / state.aiWaveCount / … fields into a single grouped state.ai object, decluttering the top-level state (which is the shared sim world) from the AI *controller's* private scratch state. This is the safe half of the two deferred Tier-5 refactors; the entity-id-counter move was deliberately skipped (moving it per-state would reintroduce cross-planet id collisions — the current single reset-per-construct counter is correct). Kept strictly behaviour- and format-neutral: - The save WIRE shape is unchanged: serPlanet still writes `ai:{aiThink,…}` and rehydrate still reads `P.ai.aiThink`, so every existing save loads untouched — no GALAXY_SAVE_VERSION / SAVE_VERSION bump. Only the live in-memory shape nests. - The think/wave/attack-schedule fields (previously set lazily on first AI tick and read `|| 0` / `?? …`) are now initialised in createGameState, so state.ai has a complete, self-documenting shape. Values are identical to the old …f1048a5
-
Odyssey: colony ships — start with a mobile ship, deploy it into a base
Claude +1: Claude Opus 4.8Unverified signatureReframes Odyssey expansion. Both sides now START with a mobile Colony Ship instead of a placed Command Center; you DEPLOY it in place to found your first base (which can then be upgraded to the Capital). New bases are no longer built from scratch by a worker — you build a Colony Ship at a CC, move it to the site, and deploy it there. Core (engine/colony.js, engine/entities.js, engine/state.js): - colonyship UNIT — odysseyOnly, non-combat (unarmed, never auto-acquires, transparent to the army/threat scans, but still shootable so it's a real escort risk), hp 600, cost 500 ore (a mobility premium over the CC's 400), supply 3. - deployColonyShip(state, id): validates the footprint (canPlaceBuilding), consumes the ship, mints a COMPLETED Command Center + disembarking colonists (3 workers). Instant and pure — no clock/RNG. hasColonyShip is the shared foothold predicate. - seedPlayer seeds a colony ship for endless; the SKIRMISH branch (a placed CC + 3 workers) is byte-identical, s…24f7ec5
-
Add Odyssey open-world mode (Phase 1): endless single-planet foundation
Claude +1: Claude Opus 4.8Unverified signatureThe first slice of the "Space Exploration" meta-game — the open-world campaign the project is named for. Phase 1 stands up the galaxy scaffold and a playable, endless single-planet sandbox with one relocatable Command Center; the multi-planet jump, market, and diplomacy build on this in later phases. Engine: - state.js: new `endless` flag on a game state. - victory.js: checkEndlessLoss() — an Odyssey never ends by conquest or clock; it only ends in defeat when the player loses their single Command Center (their capital). Razing a neighbour's CC just leaves that world running. - sim.js: tick() routes the terminal check three ways now — scenarios settle themselves, endless uses checkEndlessLoss, a normal match uses checkWinCondition. - engine/galaxy.js (new): the meta-layer. createGalaxy() picks a random start world from the galaxy seed and builds its planet state (a normal createGameState flagged endless); holds universal `credits`, the active planet id, the world roste…cae0952 -
Faction asymmetry — pick a side with its own passive identity
Claude +1: Claude Opus 4.8Unverified signatureThree playable factions, each a small bundle of passive trait multipliers — so a faction changes how a side plays without touching the shared roster, the rock-paper-scissors triangle, the tech tree, or the AI's build logic: • Frontier — mobility & vision (speed +8%, sight +6%): strikes first, holds the map. • Miners — industry (haul +15%, build/train −10%): out-scales in the long game. • Syndicate — firepower on a lean economy (damage +10%, haul −8%): win the fight, fast. Distinct axes with a real opportunity cost (Syndicate pays economy for its punch), so the pick is a strategic fork, not power creep. How it plugs in (engine/factions.js): A faction's traits are keyed on the exact side-modifier names the engine already reads, so they fold straight into map.js's sideMod — one seam, and every existing consumer (movement speed, fog/aggro sight, gather yield, build/train time) picks up the faction edge for free, stacking multiplicatively with a world's own per-side mod…ec7c7c8 -
Tier 3: save / load — resume a game exactly where you left off
Claude +1: Claude Fable 5Unverified signatureThe payoff of Tier 1's seeding. Because the sim is deterministic and seed-driven, a save doesn't need the whole world — the map (terrain, node positions) regenerates from the seed, so we persist only the seed + the DYNAMIC state: entities, economies, fog memory, and the AI's bookkeeping. - engine/persist.js: serializeGame(state) -> a detached, JSON-safe snapshot; deserializeGame(save) rebuilds a state that ticks on from exactly where it was saved. visible fog is recomputed on load (only permanent `explored` memory is stored); the entity-id counter is snapshotted/restored (new state.js peekEntityId/restoreEntityId) so a loaded game keeps minting non-colliding ids. - state.js records planetId/sizeMult/resourceMult so the map can regenerate. - main.js: Save/Load buttons in the topbar and a "Resume saved game" button on the splash; startGame's boot path is factored into a shared bootState() that both fresh and loaded games funnel through. localStorage-backed. Additive — it ne…dfed415 -
Tier 2: Tactical AI focus-fire + an AI-tactics setup toggle
Claude +1: Claude Fable 5Unverified signatureThe single biggest AI upgrade: an opt-in "Tactical" mode where the opponent micros its army like a skilled player instead of leaving every shot to the dispersed auto-acquire. Focus-fire (engine/ai.js applyFocusFire + combat.js): when Tactical and it can SEE enemy combat, the AI points its whole nearby army at one target — the lowest-HP visible enemy, tie-broken toward the more dangerous one — so kills land faster and incoming damage drops sooner (Lanchester's square law). combat.js reads a new unit.focusId and prefers it while it's a live enemy in aggro, else falls straight through to the normal dispersed acquire, so a stale or too-far focus never freezes a unit and orders/wave-tracking are untouched. In a 10v10 skiff mirror this flips a 0-4 loss under dispersed fire into a decisive 5-0 win. Crucially it's a NO-OP unless real enemy combat is in sight, so razing an undefended base — every resolve test — is unchanged. Difficulty is now more than an APM dial: a new "AI tactics" setup o…e42e5cb -
Tier 1: seed the sim, attack-move/hotkeys, onboarding, rally-to-mine, tiebreak
Claude +1: Claude Fable 5Unverified signatureSix high-leverage quick wins from the improvement review. Seed the sim (engine/rng.js, state.js, main.js): production now draws a seed at match start and flows it through a shared mulberry32, so a match is reproducible — the seed shows on a topbar chip (click to copy) and the game-over screen, and can be re-entered on the setup screen to replay the exact same world. This is the keystone that later unlocks replays/save-load. Engine determinism is now guarded by a test that scans engine/ for Math.random/Date/performance.now (the two sanctioned defaults + the render clock are marked deterministic-exempt). Standard army verbs (input.js): A-then-click issues attack-move (the missing genre default — plain right-click still moves and ignores enemies); the waypoint queue moves from Ctrl to Shift so Ctrl is add-to-selection only; Esc cancels a pending attack-move or build placement. A crosshair cursor shows when armed. A is no longer a pan key (pan left with arrows/edge-scroll). Onboarding (…4f1cb54 -
AI army-retreat: pull a losing attack back so engagements trade
Claude +1: Claude Fable 5Unverified signatureA committed, non-desperation attack that's been ground down to under 40% of what was sent — and can still SEE enemy combat units at the fight — now pulls its survivors home (a plain move, so they disengage rather than stop to trade) instead of feeding the last of them into a lost engagement. The saved veterans re-form into the next wave, so a repelled attack becomes a rebuild-and-retry rather than a total loss: engagements genuinely trade now, with both sides keeping a remnant. Two guards keep the resolves-to-a-winner guarantee intact, by construction: - A desperation (timeout) commit never retreats — it fights to the death, so the game always resolves. - A retreat only fires while the AI can see live opposition near the fight. Against an undefended/passive base (every headless resolve test) that count is zero, so the AI never retreats and razes the base exactly as before — verified that all nine worlds resolve at byte-identical timings to pre-retreat. engine/state.js carries…672f44c -
Combat: spread-targeting + target stickiness (anti-dogpile)
Claude +1: Claude Fable 5Unverified signatureInstead of the whole line re-locking the single nearest enemy every tick, attackers now commit to a target (sticky auto-target) and fan out across the nearest few enemies in the local engagement band. This distributes fire the way a player micros manually — less overkill, units engaging distinct foes — and modestly softens the focus-fire snowball (the winning side bleeds a little more). - combat.js: spreadEnemy() distributes attackers across the SPREAD_TARGETS (3) nearest enemies within range+60, dispersed by a per-unit id hash; falls back to plain nearest while still closing in. updateCombat keeps last tick's autoTarget while it's a live enemy in aggro range, only re-acquiring when it dies or slips away — the stickiness that makes the spread hold. Deterministic (id-hashed, stable sort). - state.js: makeUnit carries an autoTarget field. Honest scope note (measured with a duel harness): this does NOT prevent the loser being wiped in a to-the-death fight — that's intrinsic to…1882245 -
Tier 0: fix latent defects, add determinism/perf tests and CI
Claude +1: Claude Fable 5Unverified signature- Building construction no longer heals away combat damage every tick: updateBuildingConstruction now ADDS the hp built this tick (capped at the progress ceiling) instead of overwriting hp downward, so sustained fire can actually raze a half-built structure — raiding buildings-in-progress works for both sides now. - createGameState resets the entity-id counter so a fresh game is a pure function of its seed; same-seed runs replay byte-identically. - A worker re-tasked to a different-commodity node hauls its existing load home instead of silently discarding the mined resources. - render.js prunes dead entities from the facing Map each frame and exposes resetFacing(), wired into new-game startup — no more unbounded growth. - New tests: full-sim determinism, id-reset reproducibility, a perf guard, construction-under-fire persistence + destruction, cargo preservation. - Add GitHub Actions workflow running `node --test` on push/PR. Co-Authored-By: Claude Fable 5 <noreply@ant…430c532 -
Configurable skirmish: AI speed, map size, and resource abundance
Claude +1: Claude Fable 5Unverified signatureThe splash screen now configures the match before you pick a world. AI speed is a 1–150 actions-per-minute cap on the opponent. Every command it issues — produce a unit, place a building, expand, research, send the scout — spends one action from a budget that accrues at APM/60 per second and caps at a few seconds' burst; when it runs out it waits. So a low setting is a sluggish, forgiving foe and a high one is relentless. The attack commit is deliberately exempt, so even a crawling AI still throws its army at you and the game always resolves. Default (and every headless test) is unthrottled, so existing behaviour is unchanged. Map size scales the field self-similarly — Small (1600×1000), Standard, Large, Gigantic (4×) — with more hidden caches seeded into the bigger contested space, and the camera can now zoom out far enough to take a Gigantic map fully into view (and opens on your own base, not the map centre). At sizeMult 1 the layout is byte-identical to the original. Resources (…19ca109 -
Give the AI its own fog of war — it must scout for intel too
Claude +1: Claude Fable 5Unverified signatureThe AI is no longer omniscient. It now plays under its own fog grid (state.fogAI, updated every tick just like the player's), and its intel-dependent decisions are gated by what it has actually seen: - Counter-building only counts player combat units currently in the AI's vision. No sight of your army means no counter-pick — it falls back to its archetype mix until it scouts or fights you. - Worker assignment and expansion only consider nodes the AI has discovered: charted surface deposits always (map knowledge for both sides), hidden caches only once scouted. It can't mine or expand to a cache it hasn't found. To earn that intel it keeps one spare unit scouting once its army can lend one — a box sweep of the contested middle (plain-move waypoints, so it reveals fog rather than diving into a fight), re-issued whenever the scout falls idle or dies, and kept out of the attack wave. The base-march attack is unchanged: in a mirror 1v1 both sides know the enemy start, so that's …816885c -
Use Ctrl instead of Shift as the waypoint-queue modifier
Claude +1: Claude Fable 5Unverified signatureRebind the queue-a-waypoint gesture from Shift+right-click to Ctrl+right-click, and update the in-game hint, README controls, and engine comments to match. The command layer stays modifier-agnostic (it takes a queue boolean); only the input layer names the key. Ctrl also sidesteps the Firefox quirk that let Shift+right-click bypass the page's context-menu suppression, so the window-level preventDefault now holds across every browser — dropped that stale caveat from the comment. Verified in headless Chromium: Ctrl+right-click chains waypoints and the dashed path renders; 162 tests pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AEQUVetLk31Jj7niDQAsXL3f927e2
-
Waypoint queueing: shift+right-click chains orders into a path
Claude +1: Claude Fable 5Unverified signatureUnits now carry an order queue. A plain right-click issues its order immediately and clears the queue (unchanged feel); holding Shift appends the order as a waypoint instead, so a sequence of Shift+right-clicks lays down a path the units run through in order. The queued order is context-sensitive, exactly like a normal right-click — move/attack-move on open ground (combat units keep their aggressive-advance flavor, workers a plain move), attack on an enemy, gather on a node, assist on a construction site — so a chain can mix destinations, targets, and jobs. A Shift command to a fully idle unit still takes effect immediately, so the first waypoint of a chain is never a dead step. The sim pulls the next queued order in the moment the active one finishes (arrival, target killed, node drained), and a combat unit only falls back to auto-acquiring once its whole chain is exhausted. render.js draws the remaining path — a dashed line through each stop with a marker at each — for any selected …845de25 -
Tier 4 combat: Sentinel Turret and the Breacher siege unit
Claude +1: Claude Fable 5Unverified signatureStatic defense and the answer to it, shipped together so neither creates a degenerate strategy on its own. Sentinel Turret: a crystal-costed building (the first repeatable crystal sink) that acquires and fires through a new updateBuildingCombat in the sim's building loop. Combat's fire path is refactored into a shared performAttack used by units and turrets alike, so Overcharged Weapons already multiplies turret damage and all kill/event bookkeeping stays in one place. attackDamage stays strictly additive — a new bonusVsBuildings class bonus is 0 for every unit-vs-unit pairing. Breacher: a radioactive-costed Barracks unit deliberately outside the Skiff/Bastion/Lancer triangle. It outranges the turret and hits every building for a flat class bonus (prefersBuildings targeting), but has the worst anti-unit DPS in the game and dies to a single Skiff at half its cost — a turtle-breaker that is helpless without an escort. acquireTarget is refactored around a nearestEnemy helper; the defau…e6d8701 -
Tier 2: rally points, Refinery upgrades, AI archetypes, and sound
Claude +1: Claude Sonnet 5Unverified signatureFour features, in dependency order (smallest/most self-contained first): ## Rally-point UI (engine/commands.js, input.js, render.js) Right-click a single selected production building (Command Center or Barracks) to set where it sends newly produced units — the engine already stored an arbitrary rally point per building and read it fresh at spawn time, there was just no way to change it. Shows a dashed line + marker for the currently selected building only, to avoid cluttering the view with every building's rally line at once. ## Refinery + upgrades (engine/entities.js, production.js, combat.js) A new building (built by a Worker like the Barracks) researches two one-time, player-wide purchases: Reinforced Plating (crystals, -15% damage taken) and Overcharged Weapons (radioactives, +20% damage dealt). Both are live multipliers applied in combat.js's damage calculation rather than baked into unit stats at spawn, so they affect a player's whole army immediately, not just future product…9eded26 -
Tier 1: camera + zoom, fog of war, and a Skiff-countering unit
Claude +1: Claude Sonnet 5Unverified signatureThree independent features, the biggest-leverage items from a brainstormed improvement list, built in dependency order (fog of war's reveal effect only means something once you can actually pan around to see it). ## Camera + zoom (camera.js, new) The whole 1600x1000 map used to be squeezed into one screen — units were tiny dots. The canvas is now a fixed-resolution viewport with a camera (pan via WASD/arrows, zoom via mouse wheel toward the cursor, clamped to map bounds) instead of a 1:1 map-to-canvas mapping. render.js draws through a camera transform; input.js's toWorld() converts screen->world through it too, so selection/commands keep working exactly as before. Fixed a latent bug while wiring this up: attachInput() was never cleaning up its event listeners, so every "Choose another battlefield" replay stacked a fresh full set on the same canvas/window — added an AbortController-based destroy(). ## Fog of war (engine/fog.js, new) A coarse grid tracks which cells are currently in…34ea8bc -
First playable slice: 1v1 skirmish vs scripted AI on Ferros Prime
Claude +1: Claude Sonnet 5Unverified signatureGather ore/crystals/radioactives with Workers, build a Barracks, produce Skiffs, fight, win by destroying the enemy Command Center. engine/ holds the sim: a fixed-timestep loop decoupled from render rate, movement, the gather/deposit loop, combat with auto-acquire, building construction + unit production queues, a scripted AI opponent, and the win condition. render.js/input.js are the canvas view and mouse controls (drag-select, right-click context orders, build placement); main.js wires it to a DOM HUD. Converted data.js's top-level tables to ES module exports so the engine can import them directly. Covered by 23 node:test cases including a full simulated skirmish that plays to a winner, and manually playtested end-to-end in a real browser (gather -> build -> produce -> victory/defeat UI) via Playwright. One real bug caught in that process: constructed buildings never got a `radius` copied onto the instance, which went on to poison unit spawn points and building hit-testing with Na…17641e3