Skip to content

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 (no Defeated) with the lowest Health.hp, ties by lowest entity.id(), null if none; reads only, no world writes. Tests: lowest-HP pick, id tie-break independent of spawn/query order, Defeated hero never chosen, null with 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 set dnd/monster-turn from W-020, so it runs only for an active living monster while 'ongoing'): get the target only through decideMonster, then step one orthogonal cell at a time, at most Speed steps, 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 at distance ≤ Attack.range or when no such cell exists; update Encounter.movesLeft; append one 'move' log entry ("Goblin 2 moves from (6,3) to (4,3)") only if the position changed. Tests (drive with runPhases(world, Move, Move, 0) on a scripted setup): step count capped by Speed, axis preference and tie on x, fallback axis, stop at Attack.range from content, no move when already in reach and no 'move' entry, fully blocked monster stays put without throwing and advance still ends its turn, movesLeft updated, 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, set dnd/monster-turn, before combat/act): decideMonster after moving; set Intent { kind: 'attack', target } iff that target is valid and in reach (DND-040), else leave 'none'; no dice of its own, resolution stays in combat/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 a HasEffect(Poisoned) hero rolls two d20 via combat/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: seeded Rng, heroes only call endTurn, status reaches 'defeat' within 100 rounds and every monster turn uses at most Speed steps 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 and src/ecs/ has only world.ts
  • 2026-11-01 Depends changed from W-021 to W-020, W-021 (Triage): the dnd/monster-turn set and the AI system slots come from W-020, and combat/act from W-021. The ai/move and ai/decide bodies 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)