Skip to content

feat(29-7): jaquayed layout — cycle detection, ring placement, closure validation#443

Merged
slabgorb merged 4 commits intodevelopfrom
feat/29-7-jaquayed-layout
Apr 13, 2026
Merged

feat(29-7): jaquayed layout — cycle detection, ring placement, closure validation#443
slabgorb merged 4 commits intodevelopfrom
feat/29-7-jaquayed-layout

Conversation

@slabgorb
Copy link
Copy Markdown
Owner

Summary

  • Adds cycle-aware dungeon layout built on top of the 29-6 shared-wall tree placer. Three new public functions in sidequest_game::tactical::layout: detect_cycles, layout_cycle, layout_dungeon, plus LayoutError::CycleClosureFailed.
  • layout_dungeon delegates to layout_tree for acyclic graphs (behaviour-preserving per the explicit layout_dungeon_linear_chain_matches_tree_behaviour test), handles single-cycle dungeons via ring placement + BFS tree-branch attachment, and fails loud on multi-cycle graphs with an actionable "not yet supported" detail.
  • Wires the sidequest-validate CLI from layout_tree to layout_dungeon so cyclic rooms.yaml files validate correctly — the no-half-wired-features rule.

Implementation highlights

  • detect_cycles uses iterative three-colour DFS with an explicit stack to keep the current path visible for back-edge cycle extraction. Undirected handling: parent-edge skip to treat the graph as undirected, Black-node skip to prevent re-extracting cycles through already-completed subtrees. BTreeMap-sorted adjacency for deterministic output.
  • layout_cycle walks the cycle in input order, placing each successor via the first-fit opposite-wall exit pair, then validates the closing edge by computing the hypothetical offset of the first room from the last room and comparing to (0, 0). Mismatch → CycleClosureFailed with actionable detail.
  • layout_dungeon short-circuits to layout_tree for acyclic graphs, places single cycles as rings + BFS-attaches branches using the existing 29-6 overlap-aware logic, and fails loud for multi-cycle graphs (deferred capability gap).
  • LayoutError remains #[non_exhaustive]; Display and std::error::Error extend to the new variant.

Test plan

  • 24/24 story tests pass in layout_story_29_7_tests.rs (cycle detection, ring placement, closure validation, tree-branch attachment, multi-cycle fail-loud, Rust rule enforcement, wiring signatures)
  • 26/26 stack-parent tests pass in layout_story_29_6_tests.rs — no regressions
  • Full sidequest-game test suite (2065 tests) + sidequest-validate suite (30 tests) pass
  • cargo clippy -p sidequest-game -p sidequest-validate -- -D warnings clean
  • Validate CLI wiring verified: sidequest-validate/src/tactical.rs:9,317 imports and calls layout_dungeon

Review cycle

Ran the reviewer fan-out (preflight, silent-failure, test-analyzer, comment-analyzer, type-design, rule-checker). Applied 7 in-scope fixes (stale module doc, misleading multi-cycle rustdoc, wrong undirected-dedup explanation, semantic error-variant misuse in BFS failure path, tautological Display test, vacuous multi-cycle test, stale RED-phase test header). Deferred OTEL instrumentation (validate-only subsystem, not runtime dispatch), as u32 cast cleanup (pre-existing pattern, cell_at safety net), and a real geometric-overlap fixture for AC-6 as follow-ups.

Epic 29 follow-ups identified:

  • Multi-cycle dungeon support in layout_dungeon
  • Wiring layout_dungeon into server session init / room loader (currently only consumed by the validate CLI)
  • Extracting the shared BFS placement helper between layout_tree and layout_dungeon (95% duplicated)

…e validation

Adds cycle-aware dungeon layout built on top of the 29-6 shared-wall tree
placer. Three new public functions in sidequest-game::tactical::layout:

- detect_cycles: iterative three-colour DFS on the room graph, returning
  fundamental cycles from back edges. Undirected traversal skips the
  immediate-parent edge and ignores Black neighbours to prevent double-
  counting. Determinism via BTreeMap-sorted adjacency.

- layout_cycle: walks a cycle in order, placing each successor using the
  first-fit opposite-wall exit pair. After N rooms are placed, validates
  the closing edge from the last room back to the first — if the computed
  offset does not match the committed offset, returns CycleClosureFailed
  with the cycle members and an actionable detail message.

- layout_dungeon: top-level entry point. Delegates to layout_tree for
  acyclic graphs (behaviour-preserving), places a single cycle as a ring
  then BFS-attaches tree branches via the existing 29-6 overlap-aware
  placement logic, and fails loudly on multi-cycle graphs (deferred scope).

LayoutError gains a CycleClosureFailed { cycle_rooms, detail } variant,
preserving #[non_exhaustive] and std::error::Error. Display carries the
participating rooms and detail for authoring feedback.

Wires the validate CLI (sidequest-validate/src/tactical.rs) to call
layout_dungeon instead of layout_tree so cyclic rooms.yaml files validate
correctly — the no-half-wired-features rule.

Tests: 24/24 in layout_story_29_7_tests.rs. Two test fixtures fixed during
green (1-cell east exit width mismatch, and the oversize-grid test hitting
the 10000-cell parser limit).
Applied 4 high-confidence findings from the simplify fan-out:

1. Stale module doc in layout.rs said 'cycle handling is deferred to 29-7',
   but 29-7 is the story that implemented it. Updated to point at
   layout_dungeon as the cyclic entry point.

2. Stale docstring on validate_layout() called the engine 'tree-topology'
   — now dispatches to layout_dungeon, which handles cyclic topologies too.

3. Redundant .clone() on used_gaps entries in layout_cycle (two places)
   and layout_dungeon (one place). The pattern .entry(k).or_default().clone()
   inserts an empty HashSet into the map as a side effect just to read it
   back. Replaced with .get(k).cloned().unwrap_or_default() — non-mutating
   read, same semantics. The subsequent success-path .entry().or_default()
   .insert() calls still use the entry API where mutation is intended.

Deferred findings (flagged, not applied):

- High-confidence: extract shared BFS placement helper between layout_tree
  (29-6) and layout_dungeon (29-7). 60-line refactor that touches 29-6
  code and is out of scope for this story.
- High-confidence: same redundant-clone pattern in layout_tree and unused
  _b_exit parameter on shared_boundary_positions — both pure 29-6 touches.
- Medium-confidence: duplicate exit-pair search in layout_tree's error
  reporting path.
Applied 7 fixes from the reviewer fan-out (preflight, silent-failure,
test-analyzer, comment-analyzer, type-design, rule-checker):

**Documentation**

1. Module doc no longer claims 'BFS from the entrance room' as the universal
   rule — that's only true for layout_tree. layout_dungeon places the cycle
   ring in cycle-walk order from cycle[0], which is not guaranteed to be the
   entrance. Doc now distinguishes the two entry points.

2. detect_cycles rustdoc misattributed undirected-graph deduplication to
   the Black-node skip. The actual undirected dedup is the parent-edge skip;
   the Black-node skip prevents re-extracting cycles through already-
   completed DFS subtrees. Doc now names both mechanisms separately.

3. layout_dungeon rustdoc previously said multi-cycle graphs 'fail loudly
   with LayoutError::CycleClosureFailed' without clarifying that multi-cycle
   is a capability gap, not a geometric impossibility. A reader parsing
   production errors would have misdiagnosed a 'not yet implemented' as a
   genuine authoring closure mismatch. Now explicit.

4. Test file header dropped the stale 'RED phase — failing tests' language.

**Semantics**

5. layout_dungeon's BFS branch-attachment failure path used
   LayoutError::Overlap { cells: vec![] } to report 'no opposite-wall exit
   pair'. LayoutError::Overlap's cells field is documented as 'positions
   where non-void cells collide' — an empty vec for a topology/authoring
   error is a semantic mismatch. Replaced with CycleClosureFailed carrying
   the ring room and branch room plus a descriptive detail.

**Tests**

6. cycle_closure_error_display_is_non_empty used room IDs 'a'/'d' and then
   asserted the Display output contained 'a' and 'd' — a tautology since
   single characters appear in virtually any non-empty string and the
   detail field already contained both. Rewrote to use non-overlapping
   tokens (xyzzy, plover, fumble) and the expected comma separator, so
   the assertion actually proves cycle_rooms is rendered.

7. layout_dungeon_places_two_disjoint_cycles_without_overlap accepted both
   Ok and Err as valid outcomes. Since the current impl unconditionally
   returns Err for multi-cycle graphs, the Ok branch was unreachable and
   the Err branch only asserted non-empty message — a guaranteed pass
   against an unimplemented AC. Renamed to
   layout_dungeon_fails_loud_on_multi_cycle_graphs_not_yet_supported and
   rewrote to assert the CycleClosureFailed variant, that detail contains
   'multi-cycle ... not yet supported', and that cycle_rooms names members
   from both rings. A future implementation of multi-cycle support will
   be forced to update this test rather than silently change behaviour.

**Deferred findings** (tracked for follow-up, not fixed):
- OTEL spans for layout decisions (rule-checker #4 — layout is validate-
  only plumbing, not a runtime dispatch subsystem; OTEL requirement is
  scoped to subsystems the GM panel observes)
- as u32 casts in bounding box + overlap filter (type-design / rule #7 —
  same pre-existing pattern used throughout layout_tree, cell_at safety
  net catches the negative case)
- Typed CycleFailureReason enum replacing detail: String (type-design #3)
- Extract shared BFS placement helper between layout_tree and layout_dungeon
  (verify-phase reuse finding)
- Real geometric-overlap fixture for AC-6 (current test exercises the
  no-pair path, not an actual cell collision)
@slabgorb slabgorb merged commit a469f35 into develop Apr 13, 2026
@slabgorb slabgorb deleted the feat/29-7-jaquayed-layout branch April 13, 2026 18:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant