Skip to content

W-019: DND-F01 grid, party, monsters, movement

Type: feature · Size: M · Epic: E-dnd-m1 · Target: apps/demo-dnd · Breaking: no Depends: W-010, W-012, W-013, W-038, W-039, W-040 Refs: brief DND-F01; demo-dnd spec DND-001..006, DND-010..013, DND-015..019 (no 007..009, 014); ecs-prefab spec PREFAB-001, PREFAB-003, PREFAB-005, PREFAB-010..012, PREFAB-014, PREFAB-030, PREFAB-032, PREFAB-070, PREFAB-071, PREFAB-073

Goal

Build the first playable slice of apps/demo-dnd: the Encounter world trait and Active tag, setup of the party, the monster templates and instances from content, the labelled grid, the focusable game area with arrow-key and click movement, and the "Moves left" display. Movement is orthogonal, one cell per arrow key. Initiative and turns are W-020, monster AI is W-022.

Steps

  • S1 (DND-001, DND-005, DND-006): content party.ts (Fighter, Rogue, Wizard with stats and start cells), monsters.ts (Goblin, Orc), encounter.ts (grid size, monster list: 3 Goblins, 1 Orc with start cells); traits Name, Position, Health, ArmorClass, Speed, Attack, Hero, Monster, Defeated, Active and world trait Encounter { round, status, movesLeft, acted }; setupEncounter(world, content?) adds Encounter (round 1, status 'ongoing', movesLeft 0, acted false), validates all content positions (in grid, no shared cell, grid ≤ 12×12, throws naming setupEncounter and the entry) and spawns the heroes from content. Test with default and custom content, no stat literals in src/ecs/; files: apps/demo-dnd/src/content/{party,monsters,encounter,index}.ts, apps/demo-dnd/src/ecs/traits.ts, apps/demo-dnd/src/ecs/actions.ts, apps/demo-dnd/src/ecs/setup.test.ts
  • S2 (DND-002, DND-003, DND-004, DND-006; PREFAB-001, PREFAB-003, PREFAB-005, PREFAB-010, PREFAB-011, PREFAB-012, PREFAB-014, PREFAB-030, PREFAB-032, PREFAB-070, PREFAB-071, PREFAB-073): add @workspace/ecs-prefab (workspace:*) to apps/demo-dnd dependencies (needs user approval); setup creates one template per distinct monster kind with createPrefab and one instance per encounter entry with instantiate, overriding Name (unique, "Goblin 2") and Position. Tests: world.query(Health) is exactly heroes + instances; a world.onQueryAdd([Health], …) registered before setup never sees a template; damaging one Goblin leaves siblings and the template unchanged; each instance has IsA(template); custom monster content yields the content values; files: apps/demo-dnd/package.json, apps/demo-dnd/src/ecs/actions.ts, apps/demo-dnd/src/ecs/monsters.test.ts
  • S3 (DND-010, DND-011, DND-012, DND-013): moveActive(world, dir) in actions.ts (plain function, also exposed through createActions): one orthogonal cell, Position written with set, movesLeft − 1, returns true/false per DND-010. Tests set Active and Encounter directly: success, each of the eight rejection cases of DND-011 (nothing changes), exactly Speed moves incl. moving back then the (s+1)th fails, moving onto a Defeated combatant's cell; files: apps/demo-dnd/src/ecs/actions.ts, apps/demo-dnd/src/ecs/move.test.ts
  • S4 (DND-015, DND-018): Grid view: region "Grid" with width × height cells, each with the text label "x,y Name (hero|monster)" or "x,y empty", defeated combatants hidden, side as text (not colour only), cells not tab stops; clicking a cell orthogonally adjacent to the active hero calls moveActive with the matching direction, any other cell does nothing. Component test with a real world in WorldProvider; files: apps/demo-dnd/src/ui/grid/Grid.tsx, apps/demo-dnd/src/ui/grid/Grid.test.tsx
  • S5 (DND-016, DND-017): GameArea (focusable tabIndex=0, label "Game", focused after mount) holding the grid and the actions area; keydown on the game area only, acting only when event.target is the game area itself, ignoring event.repeat but still preventDefaulting repeated arrows; arrows call moveActive and preventDefault. MovesLeft shows the active hero's name and "Moves left: N" during a hero turn. App.tsx runs setupEncounter on the app world and renders GameArea; the W-010 heading test stays green. Tests: arrow moves and updates "Moves left", no preventDefault/move for keys from a button inside the game area, held arrow (repeat) moves nothing but is preventDefaulted, focus after mount; files: apps/demo-dnd/src/ui/input/GameArea.tsx, apps/demo-dnd/src/ui/actions/MovesLeft.tsx, apps/demo-dnd/src/App.tsx, apps/demo-dnd/src/ecs/world.ts, apps/demo-dnd/src/ui/input/GameArea.test.tsx
  • S6 (acceptance): run pnpm turbo run typecheck lint test --filter=demo-dnd, then the spec's acceptance checklist item A1 (DND-019) for the grid and action controls (the initiative panel part of A1 step 1 is checked again in W-020)

Log

  • 2026-11-01 created from the approved spec (Triage). Meets ready criteria (size M, 5 steps); held in Later until Depends are done
  • 2026-11-01 open deps, not done: W-038 (prefab entities), W-039 (instantiate, PREFAB-010..012), W-040 (overrides, PREFAB-030/032) for S2. The board Depends lists only W-010, W-012, W-013 (all done), so the item cannot start yet. Triage question: add W-038..W-040 to Depends
  • 2026-11-01 spec defaults applied: OQ3 orthogonal steps, distance |dx| + |dy|, adjacent is 1 (DND-010, DND-018); OQ11 content values only in content (DND-006); OQ12 key map, arrows only in this item (DND-016)
  • 2026-11-01 OQ8 (ecs-prefab OQ6, relation-backed traits) is not blocking M1 because templates carry no pairs. Settle it before W-038..W-042
  • 2026-11-01 Depends completed, steps handed to Planner (Triage)
  • 2026-11-01 steps S1..S6 written (Planner). Found W-038..W-040 still in Next and packages/ecs-prefab/src/index.ts exporting nothing, so S2 cannot run yet. S2 adds @workspace/ecs-prefab to demo-dnd (needs user approval)
  • 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-038, W-039 and W-040 are done. The split of W-020 and W-021 does not change this item's Depends.
  • 2026-11-01 Dependency approval (user): @workspace/ecs-prefab (workspace:*) may be added to apps/demo-dnd for S2. Approved, not yet added. Stays in Later until W-038, W-039 and W-040 are done (Triage)
  • 2026-11-01 held: waits on the OQ6 decision after the W-039 S1 spike (decision logged in W-039 first) (Triage)