Skip to content

fix: enable invading the enemy hive (closes #14)#29

Merged
LightAxe merged 1 commit into
mainfrom
fix/14-invade-enemy-hive
Apr 29, 2026
Merged

fix: enable invading the enemy hive (closes #14)#29
LightAxe merged 1 commit into
mainfrom
fix/14-invade-enemy-hive

Conversation

@LightAxe
Copy link
Copy Markdown
Owner

Summary

Phase 09.1 already shipped the cross-grid invasion sim path (REQ-C3a / REQ-C4a) — but the player had no UI to actually use it. This PR adds the four input + HUD seams that surface the mechanic:

  • Left-click on an enemy entrance sets the player rally point there. Existing rally-on-entrance carve-out + descent-intent gate handle the rest (fighters walk to the tile, descend into the enemy grid, target the nearest hostile, kill the queen → Victory).
  • The X-keybind that flips between viewing your hive and the enemy's hive is now also a clickable HUD button (it was always there but undiscoverable).
  • The enemy underground view is now read-only — clicks during the enemy view used to silently dispatch dig-marks against the player grid at the matching coords (invisible scribbling). Now they no-op.
  • Enemy entrances on the surface view get a 1-pixel red perimeter ring so the player reads them as "rally here to invade" targets.

Closes #14.

What changed

Subsystem File Change
Surface input src/input/surface-input.ts New isForeignColonyEntrance helper; new branch in handleSurfaceLeftClick between food-pile mark and empty-tile rally.
Underground input src/input/underground-input.ts All three handlers no-op when viewState.activeUndergroundColonyId !== PLAYER_COLONY_ID. Defensive state.isDragging = false on the new guard.
HUD layout src/render/sprites.ts New HUD.UNDERGROUND_COLONY_TOGGLE zone (112×22 above VIEW_TOGGLE).
HUD pointer block src/input/camera-input.ts isPointerOverHUD gains optional viewState; the toggle zone is masked only when activeView === 'underground'.
HUD render src/render/ui-scene.ts Existing colony label upgraded to a button (background rect + click handler dispatching toggleUndergroundColony). Added to ant-activity panel dismiss allowlist.
Surface render src/render/draw-surface.ts Enemy-only 4-fillRect perimeter ring overlaid on the entrance dirt rim.

Verification

  • npm run verify — exit 0 (lint + typecheck + sim-boundary + asset-path + 1533/1533 tests).
  • 14 new regression tests across surface-input.test.ts, underground-input.test.ts, camera-input.test.ts, draw-surface.test.ts, sprites.test.ts.
  • 3 internal review rounds against the diff; clean exit.

Test plan

  • npm run dev and verify the new "Your Colony (X)" button is clickable from the underground view; it flips to "Enemy Colony (X)" and renders the enemy hive.
  • On the surface view, the enemy entrance shows a thin red perimeter ring.
  • Left-clicking the enemy entrance places a rally marker (the existing white crosshair) on that tile.
  • Allocate fighters via the slider, watch them route to the enemy entrance, descend, and kill the enemy queen — Victory triggers.
  • Right-clicking the rally tile clears it (existing flow, unchanged).
  • Confirm CI green.

🤖 Generated with Claude Code

)

Phase 09.1 already shipped the cross-grid invasion sim path: a Fighting
ant standing on an open enemy entrance descends into the enemy
underground grid, targets the nearest hostile via
pickNearestHostileUnderground, and resolving combat against the enemy
queen triggers Victory (REQ-C3a, REQ-C4a). Issue #14 was the missing
input + HUD seams that left this mechanic invisible to the player.

Four touches:

1. Surface input — left-click on an enemy colony's entrance tile sets
   the player's rally point there. New `isForeignColonyEntrance` helper
   + a new branch in `handleSurfaceLeftClick` between food-pile mark and
   empty-tile rally. Own-colony entrance left-click stays a no-op so the
   right-click → left-click entrance designation flow is undisturbed.
   The rally-on-entrance carve-out in `updateFightAntTargets` (already
   in place) walks fighters onto the exact entrance tile, and the
   descent-intent gate in `tickAntMovement` (already in place) crosses
   them into the enemy grid.

2. Underground input — `handleUndergroundLeftClick`,
   `handleUndergroundDrag`, `handleUndergroundRightClick` now no-op
   when `viewState.activeUndergroundColonyId !== PLAYER_COLONY_ID`.
   Without this, clicks on the enemy view at screen coords (sx, sy)
   would silently dispatch dig-marks against the player's grid at the
   matching world coords — invisible scribbling. The enemy view is
   spectator-only. Defensive `state.isDragging = false` reset on the
   guard path covers focus-loss aborted gestures.

3. HUD — the X-keybind was undiscoverable; added a clickable
   "Your Colony (X)" / "Enemy Colony (X)" toggle button in a new
   HUD.UNDERGROUND_COLONY_TOGGLE zone, stacked just above VIEW_TOGGLE.
   Clicking dispatches the same `toggleUndergroundColony` reducer the
   X key calls. The new zone is included in the HUD pointer-block mask
   ONLY while activeView === 'underground' (the button is invisible on
   the surface view; masking unconditionally would create a 112×22
   dead zone above the minimap that swallows rally / food-mark /
   entrance-designation clicks). isPointerOverHUD gains an optional
   viewState param to gate the conditional mask. Ant-activity panel
   dismiss allowlist updated so a click on the new button doesn't
   simultaneously dismiss the panel and drop the toggle.

4. Render — enemy entrances on the surface view get a 1-pixel
   COLOR_ENEMY_COLONY perimeter ring overlaid on the existing dirt
   rim, so the player reads them as "rally here to invade" targets.
   Player entrances are unchanged. Render-only — no sim impact.

REQUIREMENTS, sim, and Phase 09.1 invasion + cross-grid combat tests
unchanged. New input/HUD/render regression tests cover the four
seams. 1533/1533 tests green; lint, typecheck, sim-boundary, and
asset-path checks all clean.

Closes #14

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@LightAxe LightAxe merged commit 51537a0 into main Apr 29, 2026
1 check passed
@LightAxe LightAxe deleted the fix/14-invade-enemy-hive branch April 29, 2026 23:09
LightAxe added a commit that referenced this pull request Apr 30, 2026
Codex flagged that the new tileY === UNDERGROUND_CEILING_ROW_Y guard in
handleUndergroundLeftClick returned without clearing state.isDragging.
If a prior gesture left isDragging=true (focus-loss / missed pointerup),
the next pointermove would resume the stale stroke from the stale
lastMarkedTile and emit hidden marks — exactly the "invisible marking"
behavior issue #30 is fixing.

Reset the flag before returning, symmetric with the enemy-view guard's
defensive reset (issue #14 PR #29).

New regression test pre-seeds isDragging=true and asserts the ceiling-
row guard clears it.

1541/1541 tests green; lint, typecheck, sim-boundary, asset-path clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
LightAxe added a commit that referenced this pull request Apr 30, 2026
…ths (closes #30 #31) (#32)

* fix: reject ceiling-strip clicks + dig demand checks reachability

Two related bugs surfaced from a debug snapshot where the player saw an
ant "sitting at a food chamber doing nothing" while four orphan Marked
tiles sat in the topmost underground row.

**Issue #30** — In the underground view, the topmost row (`tileY === 0`)
is rendered as a grass-textured ceiling strip — the renderer's "this is
the surface boundary, not a diggable wall" cue. But the underground click
handler accepted clicks anywhere inside grid bounds, so a click on the
visible grass dispatched MarkDigTile against the tile beneath. The mark
was real but the renderer kept painting grass on top, so the player got
zero feedback that anything happened.

Fix: new `UNDERGROUND_CEILING_ROW_Y` constant; both
`handleUndergroundLeftClick` and `handleUndergroundDrag` reject
`tileY === UNDERGROUND_CEILING_ROW_Y`. The drag's emit path skips row-0
tiles per-iteration (Bresenham continues), and the sentinel-fallback
path rebases the cursor without emitting.

**Issue #31** — `computeDigDemand` returned 1 the moment any Marked tile
existed, regardless of whether a digger could actually reach it. Result:
an Idle worker got assigned to dig an unreachable Marked island each
tick, the dig flow-field reported -2 (unreachable), the worker bounced
back to Idle, and the cycle locked one worker out of forage/nurse.

Fix: a Marked tile counts toward dig demand only if it has at least one
4-connected `Open` or `BeingDug` neighbor — the same reachability
predicate `computeDigFlowField`'s BFS uses to expand. Cheap
extra check inside the existing single linear scan.

Tests: 4 new for issue #30 (ceiling click rejection, drag-cross-ceiling
continuation, sentinel rebase) + 2 for issue #31 (isolated Marked island
suppresses dig demand, mixed reachable+isolated marks still fire). Added
a `beforeEach(resetFlowFieldCaches)` to the auto-dig describe block to
prevent the module-level dig flow-field cache from leaking across tests
that mutate the underground grid via `ugSet` (which bypasses the
`digFlowFieldDirty` flag).

1540/1540 tests green; lint, typecheck, sim-boundary, asset-path checks
all clean.

Closes #30
Closes #31

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: clear stale isDragging when ceiling-row guard rejects (codex P2)

Codex flagged that the new tileY === UNDERGROUND_CEILING_ROW_Y guard in
handleUndergroundLeftClick returned without clearing state.isDragging.
If a prior gesture left isDragging=true (focus-loss / missed pointerup),
the next pointermove would resume the stale stroke from the stale
lastMarkedTile and emit hidden marks — exactly the "invisible marking"
behavior issue #30 is fixing.

Reset the flag before returning, symmetric with the enemy-view guard's
defensive reset (issue #14 PR #29).

New regression test pre-seeds isDragging=true and asserts the ceiling-
row guard clears it.

1541/1541 tests green; lint, typecheck, sim-boundary, asset-path clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: reject ceiling-row dig + chamber placement at sim layer

UAT screenshot from PR #32 showed enemy AI building chambers straddling
the grass strip even after the player-input ceiling gate landed. Root
cause: the input-layer gate (#30 first commit) only covers player
clicks. The AI controller pushes MarkDigTileCommand directly to the
queue, every chamber's top-border perimeter mark hits ty=0 when
chTileY=1 (the typical near-surface case), and the sim's MarkDigTile
handler accepted ceiling-row tiles. Same gap for PlaceChamber.

Three layered fixes (defense in depth):

1. Sim-layer MarkDigTile reject — handler rejects
   `cmd.tileY === UNDERGROUND_CEILING_ROW_Y` before the Solid-state
   read. Authoritative for any current or future MarkDigTile dispatch
   source. CLNY-08-compliant; no isPlayer branching.

2. Sim-layer PlaceChamber reject — handler rejects
   `cmd.anchorTileY === UNDERGROUND_CEILING_ROW_Y`. CHAMBER_DIMENSIONS
   extend down from the anchor, so the equality check covers all
   footprint-overlap-with-ceiling cases as long as the ceiling stays a
   single row.

3. AI controller pre-filter — `isDirtTileUnderground` returns false for
   `ty === UNDERGROUND_CEILING_ROW_Y` so the AI never queues dead-on-
   arrival ceiling commands every AI_DIG_INTERVAL ticks. Performance /
   queue-cleanliness; the sim gate above is the source of truth.

Carve-out (intentional, regression-pinned): DesignateEntrance writes
Marked at the entrance column from sy=0 down via direct ugSet, bypassing
the MarkDigTile gate. That's correct — entrance columns are exempt
because the renderer paints them as the gold-tinted "way in" hole, not
as the grass ceiling. New test pins entrance shaft excavation still
works.

3 new regression tests: MarkDigTile rejects tileY=0; PlaceChamber
rejects anchorTileY=0; AI controller chamber at chTileY=1 doesn't push
row-0 marks. Plus the entrance-shaft carve-out test.

1545/1545 tests green; lint, typecheck, sim-boundary, asset-path clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
LightAxe added a commit that referenced this pull request May 1, 2026
…ntier dig (closes #33) (#37)

* feat(ai): spread enemy colony layout — depth gate + spread bias + frontier dig (closes #33)

Pre-fix the enemy AI placed every chamber at the entrance shaft floor
(F9 snapshot: 5 chambers wedged into a 12×6 area at Y=1..6). Three
compounding causes:

1. AI_QUEEN_CHAMBER_DEPTH=10 was shallow, but the BFS had no depth gate
   so the Queen anchored at Y≈1 on tick 0 because that's the first
   reachable Open tile.
2. The FoodStorage gate (food >= threshold && no FS) fired on tick 0
   too — starting foodStored=1280 ≫ threshold 8 — so a shallow FS
   landed before the Queen ever got to depth.
3. The bootstrap dig was gated on chambers.length === 0, so once that
   first shallow chamber landed the deep-dig path turned off
   permanently.

Fix
- Bump AI_QUEEN_CHAMBER_DEPTH from 10 to 18 (Queen footprint extends to
  Y=20, well past the >15 acceptance criterion).
- Add AI_PLACEMENT_DEPTH_TOLERANCE=4 depth gate to findOpenChamberSpot:
  refuse to issue a placement until at least one candidate is within
  ±tolerance of preferredDepth. Holds the Queen until bootstrap reaches
  Y≈14.
- Re-gate the bootstrap on "no Queen chamber AND no Queen pending"
  (helper hasChamberOrPending), so deep digging continues until the
  Queen actually lands.
- Re-gate FS placement on Queen-pending-or-completed. Mirrors a human
  player's natural build sequence (Queen first); blocks the pre-fix
  shallow-FS race.
- Tighten FS uniqueness check from completed-only to chamber-or-pending,
  closing a duplicate-FS-pending window that opens between the Queen
  pending and FS pending transitions.
- Add findOpenChamberSpot anchor scoring: among same-depth candidates,
  prefer anchors with the LARGEST Manhattan distance to existing
  chambers. Wins horizontal spread once the dug area expands laterally.
- Add aiDigHeuristic frontier-extension pass: on each cycle, before the
  legacy full-perimeter pass, mark up to AI_DIG_OUTWARD_BUDGET=3 tiles
  whose 4-neighborhood touches no other chamber's footprint —
  preferentially extending dirt outward from the colony centroid
  instead of re-marking tiles between existing chambers. Activates only
  once chambers.length > 1 (single-chamber perimeter is uniformly
  outward).

Acceptance (issue #33)
- After 18000 ticks (15 minutes @ 20Hz, default scenario seed 42):
  Queen at (98, 14), max chamber Y = 17 (was 6) — meets criterion
  "max chamber Y > 15".
- Layout is byte-identical across seeded runs (verified by integration
  test).
- All AI behavior remains deterministic: pure integer arithmetic,
  no PRNG, no Math.random, Set instances accessed only via has/add.

Trade-off
- Slower bootstrap. Pre-fix Queen placed by tick ≈100; post-fix Queen
  places by tick ≈3000-5000 because the dig must reach Y=14+ first.
  Integration test budget extended from 3000 to 6000 ticks. The
  player-facing impact is "enemy colony visible later"; in exchange,
  the colony is significantly more interesting to invade once it
  appears (per the Phase 09.1 invasion mechanic in PR #29).

Tests
- 5 new ai-controller.test.ts cases: depth-gate accept/reject, spread
  bias picks farther anchor, Queen-first FS gate, Queen-pending gate
  for FS.
- 2 new ai-controller.integration.test.ts cases: 18000-tick acceptance
  (max chamber Y > 15 OR 30% width spread), determinism (two seeded
  runs produce byte-identical chamber positions).
- Existing REQ-C1 integration test budget extended from 3000 to 6000
  ticks.
- AI_QUEEN_CHAMBER_DEPTH constant test updated from 10 to 18.

1576/1576 tests green; tsc and full vitest pass; 2 internal review
rounds — final pass clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ai): bounds-check footprint probes in collectFrontierTiles (codex P2)

`collectFrontierTiles` built footprint membership keys as
`ty * grid.width + tx` and probed neighbors via `isFootprint(tx + 1, ty)`
and friends without bounds-checking. For candidates on the right edge
(`tx === grid.width - 1`), `tx + 1 === grid.width` aliased the key onto
`(0, ty + 1)` — a chamber footprint hugging the LEFT edge one row below
would falsely "block" right-edge frontier tiles. The same hazard
applied to (-1, ty) wrapping into (width-1, ty-1).

Add the bounds guard inside `isFootprint`, mirroring
`canEnterUndergroundTile`'s out-of-bounds rejection (off-grid cells are
treated as not-in-any-footprint, so they never block frontier emission).

Regression test: two 1×1 chambers at opposite ends of the grid (X=0 and
X=63) in the same row. Without the fix the right-edge chamber's top tile
(63, 4) is wrongly classified as adjacent to the left chamber and never
makes the frontier dig pass; the legacy perimeter pass also exhausts the
remaining budget before reaching it. With the fix (63, 4) appears in the
dig commands as expected.

1577/1577 tests green; 1 internal review round on the fix — clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ai): bootstrap-while-Queen-pending + Queen-only depth gate (codex P1+P2)

Two follow-ups from the codex round-2 review:

P1 — bootstrap deadlock when Queen anchor is unreachable
========================================================
The previous gate stopped bootstrap as soon as Queen was PENDING. If the
Queen anchor sat behind unreachable Solid tiles, workers couldn't reach
it, and with bootstrap halted no further dig marks were issued — the
Queen never completed and the colony was stuck. Restrict the gate to
"Queen ChamberRecord exists" so bootstrap continues marking neighbors
of the deepest Open tile until the Queen actually lands. The extra dig
marks around the Queen anchor's vicinity guarantee workers can punch
their way to the anchor as needed; once Queen completes, bootstrap
stops and steady-state perimeter dig takes over.

P2 — depth gate silently rejected FS/Nursery placements
=======================================================
The depth gate in findOpenChamberSpot rejected ALL chamber types when
no candidate was within ±tolerance of preferredDepth. For Queen this is
the intended behavior (issue #33's whole point — wait for the bootstrap
dig). For FoodStorage (preferredDepth=5) and Nursery (preferredDepth=7),
restricting valid anchors to ±4 rows could silently never place the
chamber if the dug area extended deeper than ±tolerance before the gate
fired. Restrict the gate to Queen placement only; FS and Nursery now
fall through to the closest-available-Y sort and land at whatever depth
the dug area provides.

Also tightens the Queen placement gate to use hasChamberOrPending
(matches FS / Nursery uniqueness pattern) — pre-fix the sim layer
rejected duplicate Queen PlaceChamber commands during the
pending→completed window, but no point spamming the queue with
rejected commands.

Tests
- New test: bootstrap dig continues while Queen is pending (regression
  for the P1 deadlock scenario).
- New test: depth gate does NOT apply to FoodStorage when only deep
  Open tiles exist (regression for the P2 silent-stall scenario).

1579/1579 tests green; 1 internal review round on the fix — clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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.

[Bug]: Can't attack inside enemy hive

1 participant