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)andvalidTargets(world, attacker, range)as pure reads incombat.ts. A target is valid iff both are living combatants, opposing, and1 ≤ distance ≤ range; result sorted by distance, thenentity.id(); never defeated, same-side, or template entities.rangeis 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 andrange + 1excluded,Defeatedexcluded, 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):
LogEntrytype andappendLog(world, kind, round, text, data)writingCombatLogwithworld.setand a new array (ids strictly increasing, currentEncounter.round, kinds per DND-050;CombatLogitself exists since W-020 and is empty after setup and restart).registerObservers(world)is called once bysetupEncounterand registersworld.onQueryAdd([Defeated], …)that appends one'defeat'entry "X is defeated" per combatant, none for a template that getsDefeated. It must not useworld.onAdd(fires for prefabs, PREFAB-008). Tests: ids and order, a new array each append,useTrait-safe replacement (the oldentriesarray is unchanged), one defeat entry per defeated combatant, none for a template, defeat afterrestartEncounterlogs 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/actsystem registered by W-020: for the active combatant (hero or monster) withIntent.kind === 'attack', resolve one attack withrollDieand append one'attack'entry throughappendLog(dataandtextper DND-051), then resetIntent. An intent whose target is no longer valid is dropped with no dice and no log entry. Advantage iff the target hasHasEffect(Poisoned)when the roll is made (DND-045): two d20, keep the higher. Hit rules: kept 20 crits, kept 1 misses, otherwisetotal ≥ ArmorClass. Crit rolls2 × diceCountdamage dice and addsdamageBonusonce.Health.hp = max(0, hp − damage)written withset; noDefeatedhere (DND-048). Intents of kind'poison'are left untouched (W-023). Tests drivecombat/actthroughschedule.runPhasewith a scriptedRngandIntentset directly (monster intent too, W-022 is not needed): hit, miss, crit, nat 1 with and without advantage (two 1s), advantage from aPoisonedentity andHasEffectpair that the test creates (hero and monster attacker), no advantage after the pair is removed, hp floor at 0,Health.maxunchanged, exact dice consumption order (d20s, then damage dice only on a hit), logdatafields andtextwords (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 inactions.ts(plain function, also increateActions): when status is'ongoing', the active combatant is a hero that has not acted, andtargetis invalidTargetsforAttack.range, it clearsCuesfirst (DND-029), setsIntent { kind: 'attack', target }andEncounter.acted = true, then ends the turn through the same driver asendTurn(runPhases(Act, EndOfTurn)thenadvance, reuse W-020's helper, extract it there only if it is not exposed) and returnstrue. Otherwise returnsfalsewith no dice, no log entry, andCuesuntouched. Tests with a scriptedRng: accepted attack resolves, logs, ends the turn and the next combatant is active; a defeating attack leaves the monster inCues.defeatedafter the following monster turns; one rejection test each (out of range, defeated target, friendly target, already acted, monster active, noActive, status not'ongoing') asserting the world and theRngposition are unchanged; files:apps/demo-dnd/src/ecs/actions.ts,apps/demo-dnd/src/ecs/attack-action.test.ts - S5 (DND-054):
Targetingactions inactions.tsandcreateActions:beginTargeting(world, mode)selects the first ofvalidTargetsfor the mode's range (Attack.range, orPoisonStrike.rangefor'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 noPoisonStrike;cycleTarget(world, ±1)steps throughvalidTargetswith wrap-around;confirmTarget(world)callsattackfor mode'attack'orusePoisonfor mode'poison'with the selected target, then resetsTargeting;cancelTargeting(world)resets it.Targetingis changed only through these actions. ThePoisonStrike { range, turns, damage }trait (DND-080, no content entry) and ausePoison(world, target)placeholder that returnsfalseare 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 resetsTargeting,'poison'mode uses thePoisonStrikerange and selects a target that is out of attack range but in poison range (Rogue entity with a test-definedPoisonStrike),turn/beginstill clearsTargeting(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-dndand 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
Poisonedentity andHasEffectpair 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, thecombat/actbody, the DND-045 advantage rule (tested with aPoisonedentity 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
Poisonedin S2: this item does not spawn it. W-023 S1 spawns the real entity. - 2026-11-01 Assumption for W-023 (Triage):
validTargetsandconfirmTargetsupport 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
PoisonStriketrait andusePoison, which DND-080 and DND-082 give to W-023. Planned seam: S5 declares only the trait and ausePoisonplaceholder returningfalse, 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
PoisonStriketrait and a placeholderusePoisonthat returnsfalse. W-023 replaces the placeholder with the realusePoison. Steps not edited - 2026-11-01 S5 checked against option A (Planner): it matched; only the trait fields
{ range, turns, damage }(DND-080) and theusePoison(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)