Skip to content

W-021: DND-F03 ECS combat

Type: feature · Size: M · Epic: E-dnd-m1 · Target: apps/demo-dnd · Breaking: no Depends: W-019, W-020 Refs: brief DND-F03; demo-dnd spec DND-040..054; ecs-prefab spec PREFAB-005, PREFAB-008, PREFAB-073; ecs-devtools spec L9

Goal

Add attacks to apps/demo-dnd on the W-020 turn loop: the Attack range rules and validTargets, the d20 attack roll with the Poisoned advantage rule (DND-045, defined only in the spec), crits, natural 1, damage with the hp floor, dice consumption order, appendLog and the CombatLog attack entries, the combat/act body, the defeat observer (DND-052), and the targeting state and actions (DND-054). validTargets and confirmTarget must also support the 'poison' mode range (PoisonStrike.range), which W-023 relies on. Targeting keys and click controls (DND-055..059) are W-051. Monster attacks resolve in combat/act, but their intent is set by W-022.

Steps

  • S1 (DND-040, DND-041): distance(a, b) and validTargets(world, attacker, range) as pure reads in combat.ts. A target is valid iff both are living combatants, opposing, and 1 ≤ distance ≤ range; result sorted by distance, then entity.id(); never defeated, same-side, or template entities. range is a parameter, so the 'poison' range (PoisonStrike.range) works with no change. Tests with fresh worlds and combatants spawned by the test: hero and monster ranges 1 and 4 (Wizard content value), distance 0 and range + 1 excluded, Defeated excluded, same side excluded, a prefab template excluded (PREFAB-003), tie order by id, custom content ranges (DND-006); files: apps/demo-dnd/src/ecs/combat.ts, apps/demo-dnd/src/ecs/combat.test.ts
  • S2 (DND-050, DND-052, DND-053; PREFAB-005, PREFAB-008, PREFAB-073; devtools L9): LogEntry type and appendLog(world, kind, round, text, data) writing CombatLog with world.set and a new array (ids strictly increasing, current Encounter.round, kinds per DND-050; CombatLog itself exists since W-020 and is empty after setup and restart). registerObservers(world) is called once by setupEncounter and registers world.onQueryAdd([Defeated], …) that appends one 'defeat' entry "X is defeated" per combatant, none for a template that gets Defeated. It must not use world.onAdd (fires for prefabs, PREFAB-008). Tests: ids and order, a new array each append, useTrait-safe replacement (the old entries array is unchanged), one defeat entry per defeated combatant, none for a template, defeat after restartEncounter logs exactly one entry (not zero, not two, DND-053), also after two restarts; files: apps/demo-dnd/src/ecs/log.ts, apps/demo-dnd/src/ecs/observers.ts, apps/demo-dnd/src/ecs/actions.ts, apps/demo-dnd/src/ecs/log.test.ts
  • S3 (DND-043, DND-044, DND-045, DND-046, DND-047, DND-048, DND-049, DND-051): body of the combat/act system registered by W-020: for the active combatant (hero or monster) with Intent.kind === 'attack', resolve one attack with rollDie and append one 'attack' entry through appendLog (data and text per DND-051), then reset Intent. An intent whose target is no longer valid is dropped with no dice and no log entry. Advantage iff the target has HasEffect(Poisoned) when the roll is made (DND-045): two d20, keep the higher. Hit rules: kept 20 crits, kept 1 misses, otherwise total ≥ ArmorClass. Crit rolls 2 × diceCount damage dice and adds damageBonus once. Health.hp = max(0, hp − damage) written with set; no Defeated here (DND-048). Intents of kind 'poison' are left untouched (W-023). Tests drive combat/act through schedule.runPhase with a scripted Rng and Intent set directly (monster intent too, W-022 is not needed): hit, miss, crit, nat 1 with and without advantage (two 1s), advantage from a Poisoned entity and HasEffect pair that the test creates (hero and monster attacker), no advantage after the pair is removed, hp floor at 0, Health.max unchanged, exact dice consumption order (d20s, then damage dice only on a hit), log data fields and text words (both names, every d20, bonus, total, AC, "advantage" iff advantage, hit/miss/critical hit, damage on a hit), custom content values; files: apps/demo-dnd/src/ecs/combat.ts, apps/demo-dnd/src/ecs/systems.ts, apps/demo-dnd/src/ecs/attack.test.ts
  • S4 (DND-042, DND-049): attack(world, target) action in actions.ts (plain function, also in createActions): when status is 'ongoing', the active combatant is a hero that has not acted, and target is in validTargets for Attack.range, it clears Cues first (DND-029), sets Intent { kind: 'attack', target } and Encounter.acted = true, then ends the turn through the same driver as endTurn (runPhases(Act, EndOfTurn) then advance, reuse W-020's helper, extract it there only if it is not exposed) and returns true. Otherwise returns false with no dice, no log entry, and Cues untouched. Tests with a scripted Rng: accepted attack resolves, logs, ends the turn and the next combatant is active; a defeating attack leaves the monster in Cues.defeated after the following monster turns; one rejection test each (out of range, defeated target, friendly target, already acted, monster active, no Active, status not 'ongoing') asserting the world and the Rng position are unchanged; files: apps/demo-dnd/src/ecs/actions.ts, apps/demo-dnd/src/ecs/attack-action.test.ts
  • S5 (DND-054): Targeting actions in actions.ts and createActions: beginTargeting(world, mode) selects the first of validTargets for the mode's range (Attack.range, or PoisonStrike.range for 'poison') and stays 'none' when there is no valid target, it is not a hero turn, the hero has acted, or (poison) the hero has no PoisonStrike; cycleTarget(world, ±1) steps through validTargets with wrap-around; confirmTarget(world) calls attack for mode 'attack' or usePoison for mode 'poison' with the selected target, then resets Targeting; cancelTargeting(world) resets it. Targeting is changed only through these actions. The PoisonStrike { range, turns, damage } trait (DND-080, no content entry) and a usePoison(world, target) placeholder that returns false are the only W-023 seams added here (see Log); W-023 S1 and S2 build on them. Tests: begin selects the nearest valid target and stays 'none' in each refusal case, cycle forward and back with wrap-around, cancel resets, confirm in attack mode attacks the selected target and resets Targeting, 'poison' mode uses the PoisonStrike range and selects a target that is out of attack range but in poison range (Rogue entity with a test-defined PoisonStrike), turn/begin still clears Targeting (DND-027, one regression check); files: apps/demo-dnd/src/ecs/actions.ts, apps/demo-dnd/src/ecs/traits.ts, apps/demo-dnd/src/ecs/targeting.test.ts
  • S6 (acceptance): run pnpm turbo run typecheck lint test --filter=demo-dnd and confirm that every test name starts with its DND ID and that the full suite, including the W-019 and W-020 tests, is green. DND-040..054 have no [manual] requirement, so there is no checklist item (A3 belongs to W-051)

Log

  • 2026-11-01 created from the approved spec (Triage). Meets ready criteria (size M, 6 steps); held in Later until Depends are done
  • 2026-11-01 open deps, not done: W-019 and W-020 (and through them the ecs-prefab and ecs-schedule items listed in those items' Logs)
  • 2026-11-01 DND-045 reads HasEffect(Poisoned), and the Poisoned entity is spawned by DND-081 (W-023 S1). If W-023 has not started when S2 runs, spawn that entity in S2 as a minimal step; W-023 keeps the rest. Triage decides before that happens
  • 2026-11-01 spec default OQ12 applied to S6 (keys A, Escape, Tab, Enter); OQ5 applies (an accepted attack ends the hero turn, DND-042)
  • 2026-11-01 DND-053 (observers registered once, not lost on restart) is checked together with W-020 S6
  • 2026-11-01 Depends completed, steps handed to Planner (Triage)
  • 2026-11-01 not planned: DND-040..059 needs at least 7 steps at one test file per step (5 ECS: validTargets, attack + combat/act, log entries, defeat observer, Targeting; UI: keys + click, Attack button, log view; plus acceptance), above the M limit of 6. Must be split: ECS combat DND-040..054 (M, 6 steps incl. a green-run step) and combat UI DND-055..059 (M, 5 steps incl. A3). No dependency on W-023 either way: DND-045 is tested with a Poisoned entity and HasEffect pair set up in the test. Back to Triage (Planner)
  • 2026-11-01 Split (Triage), accepting the Planner's draft: this item keeps the ECS combat DND-040..054 (M). The combat UI DND-055..059 moves to W-051 (M, depends on this item, W-019 and W-050). Goal and Refs updated.
  • 2026-11-01 Ownership (Triage): this item owns appendLog, the combat/act body, the DND-045 advantage rule (tested with a Poisoned entity that the test creates), the defeat observer (DND-052), and its restart re-registration test (DND-053). The DND-089 observers stay with W-023.
  • 2026-11-01 Supersedes the earlier line about spawning Poisoned in S2: this item does not spawn it. W-023 S1 spawns the real entity.
  • 2026-11-01 Assumption for W-023 (Triage): validTargets and confirmTarget support the 'poison' mode range, covered here (see Goal).
  • 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-019 and W-020 are done.
  • 2026-11-01 steps S1..S6 written (Planner). Open: S5 needs the PoisonStrike trait and usePoison, which DND-080 and DND-082 give to W-023. Planned seam: S5 declares only the trait and a usePoison placeholder returning false, W-023 replaces it. File paths assume the W-020 layout (systems.ts, actions.ts, shared hero-turn-end driver). Triage confirms
  • 2026-11-01 Poison seam decided, option A (user, Triage): S5 adds the PoisonStrike trait and a placeholder usePoison that returns false. W-023 replaces the placeholder with the real usePoison. Steps not edited
  • 2026-11-01 S5 checked against option A (Planner): it matched; only the trait fields { range, turns, damage } (DND-080) and the usePoison(world, target) signature were added, so W-023 S1 can rely on them. Still 6 steps, size M
  • 2026-11-01 Correction (Triage): the DND-053 restart check relates to W-020 S5 (restart), not S6. The earlier line about W-020 S6 is superseded by this line
  • 2026-11-01 held: waits on the OQ6 decision after the W-039 S1 spike (decision logged in W-039 first) (Triage)