W-022: DND-F04 monster turn AI (lowest-HP hero)¶
Type: feature · Size: S · Epic: E-dnd-m1 · Target: apps/demo-dnd · Breaking: no Depends: W-020, W-021 Refs: brief DND-F04; demo-dnd spec DND-060..069; ecs-schedule spec SCH-118, SCH-126, SCH-127, SCH-132
Goal¶
Make monsters act on their own in apps/demo-dnd: decideMonster as a pure read (lowest-HP living hero, ties by entity id), ai/decide setting the attack intent when that hero is in reach, ai/move taking greedy orthogonal steps within Speed, a 'move' log entry per moving turn, and deterministic behaviour with a 100-round scenario. Attack resolution is W-021's combat/act, with its roll rules.
Steps¶
- S1 (DND-060, DND-066): pure helper
decideMonster(world, monster)returning{ target }: the living hero (noDefeated) with the lowestHealth.hp, ties by lowestentity.id(),nullif none; reads only, no world writes. Tests: lowest-HP pick, id tie-break independent of spawn/query order,Defeatedhero never chosen,nullwith no living hero, world unchanged after the call (compare positions, healths,Intent,CombatLog), a later call after a hero's HP changes returns the new lowest; files:apps/demo-dnd/src/ecs/actions.ts,apps/demo-dnd/src/ecs/decide-monster.test.ts - S2 (DND-061, DND-062, DND-063, DND-065, DND-067, SCH-126, SCH-127, SCH-132, SCH-118): body of
ai/move(Move, stays in setdnd/monster-turnfrom W-020, so it runs only for an active living monster while'ongoing'): get the target only throughdecideMonster, then step one orthogonal cell at a time, at mostSpeedsteps, into a free in-bounds cell that strictly reduces|dx| + |dy|, preferred axis = larger absolute difference (ties on x), the other axis as fallback; stop atdistance ≤ Attack.rangeor when no such cell exists; updateEncounter.movesLeft; append one'move'log entry ("Goblin 2 moves from (6,3) to (4,3)") only if the position changed. Tests (drive withrunPhases(world, Move, Move, 0)on a scripted setup): step count capped bySpeed, axis preference and tie on x, fallback axis, stop atAttack.rangefrom content, no move when already in reach and no'move'entry, fully blocked monster stays put without throwing andadvancestill ends its turn,movesLeftupdated, hero positions never change, only the active monster moves, a template never moves or acts; files:apps/demo-dnd/src/ecs/systems.ts,apps/demo-dnd/src/ecs/ai-move.test.ts - S3 (DND-064, DND-066, DND-067, DND-068, DND-069): body of
ai/decide(Act, setdnd/monster-turn, beforecombat/act):decideMonsterafter moving; setIntent { kind: 'attack', target }iff that target is valid and in reach (DND-040), else leave'none'; no dice of its own, resolution stays incombat/act. Tests: attacks the lowest-HP hero in reach, does not attack a higher-HP hero in reach when the lowest is out of reach (OQ4), attack on aHasEffect(Poisoned)hero rolls two d20 viacombat/act(DND-045, pair added directly), retargets after hero HP changes between two monster turns, determinism (two worlds, same seed and hero inputs, equal positions, healths, and log), and the scenario: seededRng, heroes only callendTurn, status reaches'defeat'within 100 rounds and every monster turn uses at mostSpeedsteps and one attack; files:apps/demo-dnd/src/ecs/systems.ts,apps/demo-dnd/src/ecs/ai-decide.test.ts
Log¶
- 2026-11-01 created from the approved spec (Triage). Meets ready criteria (size S, 3 steps); held in Later until Depends are done
- 2026-11-01 open deps: W-021 (not done), and through it W-020 and the ecs-schedule items listed in the W-020 Log
- 2026-11-01 OQ4 default applied (DND-060, DND-064): target is always the lowest-HP living hero, and the monster attacks only if that hero is in reach after moving. Changing this needs a spec change first
- 2026-11-01 OQ3 default applied (DND-061): greedy orthogonal steps, no pathfinding
- 2026-11-01 Depends completed, steps handed to Planner (Triage)
- 2026-11-01 planned, 3 steps (Planner); no
(acceptance)step because DND-F04 has no[manual]requirement (A2 covers DND-039, W-020); open: board still lists W-021 in Next andsrc/ecs/has onlyworld.ts - 2026-11-01 Depends changed from W-021 to W-020, W-021 (Triage): the
dnd/monster-turnset and the AI system slots come from W-020, andcombat/actfrom W-021. Theai/moveandai/decidebodies stay here. - 2026-11-01 Correction (Triage): the earlier "Depends completed" line only meant the Depends list was completed, not that the dependencies are done. Stays in Later until W-020 and W-021 are done.
- 2026-11-01 held: waits on the OQ6 decision after the W-039 S1 spike (decision logged in W-039 first) (Triage)