Skip to content

W-020: DND-F02 ECS turn loop

Type: feature · Size: M · Epic: E-dnd-m1 · Target: apps/demo-dnd · Breaking: no Depends: W-010, W-011, W-013, W-019, W-027, W-028, W-029, W-032, W-033, W-034, W-036, W-042 Refs: brief DND-F02; demo-dnd spec DND-020..035; ecs-schedule spec SCH-013, SCH-042, SCH-065, SCH-100, SCH-104, SCH-107..109, SCH-118, SCH-126, SCH-127, SCH-132, SCH-140, SCH-143; ecs-prefab spec PREFAB-014, PREFAB-030, PREFAB-066

Goal

Make the headless turn loop of apps/demo-dnd work: seeded Rng and initiative, the detached dnd/turn schedule with five phases and nine systems, the advance driver, endTurn, round and active rotation, defeat and outcome, the Cues trait, and restart. This item owns the Intent, Targeting, and Cues world traits, the Effect tag, the HasEffect relation, an empty CombatLog trait, and the registration of all nine dnd/turn systems. The bodies of ai/move and ai/decide (W-022), combat/act (W-021), and effects/tick (W-023) stay empty here. The turn UI (initiative panel, End turn button, victory or defeat banner, restart button) is W-050. Attacks (DND-042) and Poison Strike (DND-082) come in W-021 and W-023, so only endTurn ends a hero turn here.

Steps

  • S1 (DND-020, DND-021, DND-033; PREFAB-014, PREFAB-030): content gains initiativeBonus per party entry and monster kind; trait Initiative { value }; world trait Rng (AoS { next(): number }), createSeededRng(seed) and rollDie(world, sides) = 1 + floor(next() * sides); setupEncounter(world, content?, { seed? }) installs a seeded Rng when given a seed, and rolls one d20 + initiativeBonus for each hero and, as an Initiative override at instantiate, for each monster instance; initiativeOrder(world) returns living combatants (no templates) by Initiative.value descending, ties by entity.id() ascending. Tests with a scripted Rng: values equal d20 + bonus from custom content, two Goblins differ, the template's Initiative is unchanged, order and tie-break, templates and Defeated excluded, same seed gives the same sequence; files: apps/demo-dnd/src/content/{party,monsters}.ts, apps/demo-dnd/src/ecs/{traits,rng,actions}.ts, apps/demo-dnd/src/ecs/initiative.test.ts
  • S2 (DND-024, DND-025, DND-026; SCH-013, SCH-100, SCH-104, SCH-118, SCH-126, SCH-127, SCH-132, SCH-140, SCH-143): add @workspace/ecs-schedule (workspace:*) to apps/demo-dnd dependencies if missing (needs user approval); traits Effect (tag), Intent, Targeting, Cues, CombatLog (empty entries) and relation HasEffect (relation({ store: { turns: 0, damage: 0 } })); setup adds Intent ('none'), Targeting ('none'), Cues (three empty arrays) and CombatLog to the world; src/ecs/systems.ts defines phases StartOfTurn → Move → Act → Resolve → EndOfTurn (dependsOn chain, no builtin edge), the set dnd/monster-turn (exported, runIf: status 'ongoing' and the active combatant is a monster with hp > 0) and registers all nine systems with after edges and empty bodies (ai/move and ai/decide in the set), in the schedule dnd/turn. Tests: every phase is manual (isManual) and depends on the previous one only, systemsOf per phase returns the DND-025 names in order, the set runIf is true only for an active living monster during 'ongoing', runPhases(world, Move, Act, 0) with a hero active leaves positions, healths and CombatLog unchanged, schedule.name is dnd/turn; files: apps/demo-dnd/package.json, apps/demo-dnd/src/ecs/{traits,relations,systems,actions}.ts, apps/demo-dnd/src/ecs/pipeline.test.ts
  • S3 (DND-022, DND-023, DND-024, DND-027, DND-028, DND-029, DND-030; SCH-042, SCH-107, SCH-108, SCH-109): bodies of turn/begin (movesLeft = active Speed, acted false, Intent and Targeting reset) and turn/end (next living combatant after the current one in the DND-021 order including defeated, wrap-around increments round; with status not 'ongoing' only removes Active); advance(world) (loop runPhase(StartOfTurn); monster active: runPhases(Move, EndOfTurn, 0) and continue; hero active: stop; never clears Cues); endTurn(world) runs runPhases(Act, EndOfTurn, 0) then advance and returns false with no change unless status is 'ongoing' and a hero is active, consumes no dice, logs nothing; an internal finishHeroTurn(world) helper that W-021 and W-023 reuse for acting; setup sets round: 1, Active on the first of initiativeOrder and runs advance. Tests with dt = 0: after setup a hero is active or the encounter is over, exactly one Active while 'ongoing' and none otherwise, schedule.run(world, 0) leaves movesLeft and Active unchanged, consecutive monster turns resolve in one call in initiative order, A/B/C with B defeated goes A to C and C to A with round + 1, endTurn returns false for a monster active or status over, scripted Rng shows no dice used; files: apps/demo-dnd/src/ecs/{systems,actions}.ts, apps/demo-dnd/src/ecs/turns.test.ts
  • S4 (DND-029, DND-031, DND-032, DND-035): bodies of combat/defeat (adds Defeated at hp 0), encounter/outcome ('victory' when every monster is Defeated, 'defeat' when every hero is), turn/cues (owned createAdded(Defeated), createAdded(HasEffect) and createRemoved(HasEffect) queries; each run appends to the Cues arrays through world.set with new arrays, in order, no duplicates); moveActive and endTurn clear Cues first when they return true and leave it untouched when they return false; setup clears Cues. Tests: defeated combatant leaves initiativeOrder, frees its cell (DND-013), gets no turn, victory and defeat set status, advance stops, moveActive and endTurn return false once over, Cues.defeated still holds a monster defeated before an endTurn after two monster turns follow in the same call, and is cleared after the next accepted action; files: apps/demo-dnd/src/ecs/{systems,actions}.ts, apps/demo-dnd/src/ecs/resolve.test.ts
  • S5 (DND-033, DND-034; PREFAB-066, SCH-065): restartEncounter(world) calls world.reset() then setupEncounter with the content and seed remembered by setupEncounter in a WeakMap keyed by world in actions.ts (a scripted Rng is not kept; no seed means an unseeded restart). Tests: old entity handles are dead (PREFAB-066), round 1, status 'ongoing', CombatLog and Cues empty, new templates, world.get(schedule.state) back to defaults after world.reset() (SCH-065), and a seeded scripted fight (moveActive and endTurn inputs) played, restarted and replayed ends with the same combatant state, Cues and CombatLog entries, and two worlds with the same seed and inputs also match; files: apps/demo-dnd/src/ecs/actions.ts, apps/demo-dnd/src/ecs/restart.test.ts
  • S6 (acceptance): run pnpm turbo run typecheck lint test --filter=demo-dnd (no [manual] requirement in this item)

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: ecs-schedule W-027..W-034 and W-036 (phases, systems, run and stepping, conditions, sets, per-world state, systemsOf) for S2 and S3. ecs-prefab W-042 (handles dead after reset, PREFAB-066) for S6. The board Depends lists W-010, W-011, W-013, W-019 only
  • 2026-11-01 spec defaults applied: OQ5 acting ends the hero turn with no move after it (DND-029); OQ6 consecutive monster turns resolve in one action call (DND-028); OQ10 initiative is d20 plus bonus rolled at setup, ties by entity id (DND-020, DND-021)
  • 2026-11-01 OQ13 (restart via world.reset()): DND-037 is the canary. If the banner does not show the new encounter, the restart design changes (DND-034). Triage decides, not this item
  • 2026-11-01 DND-034 requires observers of DND-052 and DND-089 to be registered again. Those observers come with W-021 and W-023, so that part of S6 is checked when they land
  • 2026-11-01 Depends completed, steps handed to Planner (Triage)
  • 2026-11-01 not planned: DND-020..039 needs 8 steps at one test file per step (5 ECS, 2 UI, 1 acceptance), above the M limit of 6. Must be split: ECS turn loop DND-020..035 (M, 5 steps) and turn UI DND-036..039 (S, 3 steps). Back to Triage (Planner)
  • 2026-11-01 Split (Triage), accepting the Planner's draft: this item keeps the ECS turn loop DND-020..035 (M). The turn UI DND-036..039 moves to W-050 (S, depends on this item). Goal and Refs updated.
  • 2026-11-01 Ownership (Triage): this item owns the Intent, Targeting, and Cues world traits, the Effect tag, the HasEffect relation, an empty CombatLog trait, and the registration of all nine dnd/turn systems. The bodies of ai/move and ai/decide (W-022), combat/act (W-021), and effects/tick (W-023) stay empty 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 every Depends item is done.
  • 2026-11-01 steps S1..S6 written (Planner). Final ordering is ECS only: S1 rng and initiative, S2 pipeline and traits, S3 turn flow, S4 defeat, outcome and Cues, S5 restart, S6 check run. S2 adds @workspace/ecs-schedule to demo-dnd if W-019 or W-010 did not (needs user approval)
  • 2026-11-01 Dependency approval (user): @workspace/ecs-schedule (workspace:*) may be added to apps/demo-dnd for S2. Approved, not yet added (Triage)
  • 2026-11-01 Correction (Triage): the 2026-11-01 line on DND-034 observer re-registration says S6. The restart work is S5 (restartEncounter), so that line should read S5. S6 is only the acceptance run
  • 2026-11-01 held: waits on the OQ6 decision after the W-039 S1 spike (decision logged in W-039 first) (Triage)