diff --git a/migration/placement-plan/REPORT.md b/migration/placement-plan/REPORT.md index 19fa525..930265e 100644 --- a/migration/placement-plan/REPORT.md +++ b/migration/placement-plan/REPORT.md @@ -1,37 +1,49 @@ # Atrinik overworld-plane placement plan (version 2) -Generated by `scripts/build_plan.py` from `/home/zoey/dev/worktrees/content-plan-v2-input` at git `3ad20150da76` (inputs digest `fadd4780f4b88028` over 15994 files under `arch/`, `maps/`). Nothing in the content repository was modified. +Generated by `scripts/build_plan.py` from `/home/zoey/dev/worktrees/content-plan-v2-input` at git `3828651987a5` (inputs digest `fadd4780f4b88028` over 15994 files under `arch/`, `maps/`). Nothing in the content repository was modified. Machine-readable plan: `placement-plan.json`. Rerun: see [Reproducing](#reproducing). -Version 2 applies the owner's decisions of 2026-10-18 on top of version 1 (PR #44): the relayering is applied after its conflicts are fixed, every stairwell is drawn only on the player's layer, sloped stairwells replace near-aligned portals, and the dangling exits are wired. Every map that moves, every new object and every judgement call is listed for review; the decisions still open are in [Open owner decisions](#open-owner-decisions). +Version 2 applies the owner's decisions of 2026-10-18 on top of version 1 (PR #44): the relayering is applied after its conflicts are fixed, every stairwell is drawn only on the player's layer, sloped stairwells replace near-aligned portals, and the dangling exits are wired. The owner's answers of 2026-10-19 to the six questions it left open, and to the one this version raised, are applied too ([Decided 2026-10-19](#decided-2026-10-19)). Every map that moves, every new object and every judgement call is listed for review; no owner decision is open ([Open owner decisions](#open-owner-decisions)). ## Summary - 3629 map files, 59 groups (connected by tile links / filename coordinates): the overworld (3155 maps, legacy layers -3: 17, -2: 84, -1: 116, 0: 2700, 1: 139, 2: 49, 3: 28, 4: 11, 5: 10, 6: 1) plus **58 other groups**. -- **Placed: 27 groups (182 maps)** on overworld layers, none overlapping, every one keeping the 16-tile view gap from unrelated areas. -- **Separate planes: 31 groups (292 maps)**: 13 per_player, 7 other_world, 5 ship, 3 guild, 2 system, 1 unreachable. 1 of them also has a tentative placement: #26 `strakewood_island/rockforge/rockforge_c`. +- **Placed: 28 groups (207 maps)** on overworld layers, none overlapping, every one keeping the 16-tile view gap from unrelated areas. +- **Separate planes: 30 groups (267 maps)**: 13 per_player, 7 other_world, 5 ship, 3 guild, 2 system. - **Relayering applied**: 35,044 overworld tiles with a support height of 50 or more are relayered in 12 regions; 63 storey units move up with the ground under them; 2 caves move down as a whole and 1 cave region is lowered in place, so no cave reaches the surface layer. 173 maps now have tiles on another layer. Of the plain stairs whose ends move, 98 stay plain stairs and 33 become stairwells; 46 new stair pairs keep every tile on 26 cut walkable seams connected. See [Relayering](#relayering-applied). - Exit objects crossing group boundaries after placement (both directions, every exit object counted): **66 become plain stairs** (5 of them wired dangling exits), **52 are replaced by stairwells**, **19 stay portals**. Another 190 exit objects lead into separate planes and stay portals by definition. - **Stairwells: 40**: 14 for placed groups (SW), 4 sloped (SL), 22 for overworld exits the relayering put 2 or more layers apart (SR). Every one is drawn only on the layer the player is on. - **Sloped stairwells**: 16 of the 17 near-aligned portal exit objects become 4 sloped stairwells; 1 cannot (see [Sloped stairwells](#sloped-stairwells)). - **Wired exits**: 5 legacy exits that led nowhere now lead to the content on the next layer (3 confirmed, 2 plausible). The 6 Greyton jail cell ladders stay bunk-bed decoration without an exit (owner decision 2026-10-18). See [Wired exits](#wired-exits). - Placement changes from version 1: `ellamin` layer offset -4 -> -5; `rockforge_a` layer offset -4 -> -5; `rockforge_b` layer offset -5 -> -6; `rockforge_c` layer offset -6 -> -7. +- **Owner decisions of 2026-10-19 applied**: Zechna's first hole stays a portal; stair art for stairwells without stairs: SR17 and SR18 show `ladder_down`; SW6 shows `stairs_stone_down`; #26 `rockforge_c` is placed on layer -7 under `rockforge_b`, without a footprint while its maps are empty; 'below' confirmed for 4 groups, version 1's classification of 9 groups and the 11 relayering judgement calls. Open owner decisions: none. - Why portals remain: 17 other-entrance-aligned; 1 slope-blocked; 1 stairwell-blocked. See [Portals that remain](#portals-that-remain). - View radius: **16 tiles** (the classic server's limit; the shipped client sees 10). With radius 10 the plan would have 65 stairs, 48 stairwell exits and 19 portals; see [View radius](#view-radius). - Independent check (`scripts/verify_plan.py`, JSON only): **PASS**: 207 placed member maps, 7 nearby owner pairs measured (closest unrelated pair 22 tiles apart, gap rule needs more than 16), 40 stairwells with 76 intermediate tiles, 1,007 exit record copies matched against the inventory of 1,200 exit objects, 1456 exit endpoints re-derived; relayering re-derived from 242,824 overworld tiles and 82,133 tile heights (12 regions, 243 units, 47,594 moved tiles, 904 cut edges in 44 seam pieces). | classification | reason | groups | maps | |---|---|---:|---:| -| place | place | 27 | 182 | +| place | place | 28 | 207 | | separate | guild | 3 | 3 | | separate | other_world | 7 | 243 | | separate | per_player | 13 | 14 | | separate | ship | 5 | 5 | | separate | system | 2 | 2 | -| separate | unreachable | 1 | 25 | ## Owner decisions applied +### Decided 2026-10-19 + +The owner's answers to the six questions version 2 left open, and decision 7, the answer to the question this version raised. Decisions 1-3 change the plan; 4-6 confirm what the rules derived. Each one is an input of `build_plan.py`, recorded with its date in the JSON settings and bound to the evidence it was made on (the evidence itself, or its premise: the SHA-256 of the evidence record's canonical JSON). The generator refuses to publish when that evidence changes, so the owner is asked again, and `verify_plan.py` checks each binding in the JSON. + +1. **Zechna's first hole stays a portal** (`settings.owner_portals`): world_7_66_-1 (13,15) hole drops 3 layers into #28 `zechna_temple`, and `zechna_temple_1_1` on L-3 occupies its column. No stairwell is built through zechna_temple_1_1, the floor in its column: the exit stays a portal (`stairwell-blocked`), and its blocked site carries the decision (`owner_decision`). +2. **Stair art for stairwells without stairs** (`settings.stairwell_appearance_fallbacks`): a stairwell whose exits belong to no stair family shows, on its intermediate tiles, the archetype chosen for the first of its exit archetypes: `well_bucket` and `well_stone` -> `ladder_down` (`arch/connect/exits/ladder/ladder.arc`); `invis_exit` -> `stairs_stone_down` (`arch/connect/stairs/stairs_stone/stairs_stone.arc`). Applied to SR17 and SR18 (`ladder_down`); SW6 (`stairs_stone_down`). The rule is an input of the generator (`STAIRWELL_APPEARANCE_FALLBACKS`), which refuses any archetype it adds that the content does not define. +3. **The Rockforge mines are placed** (`settings.owner_alignments`): #26 `rockforge_c` on layer -7, 1 layer under #25 `rockforge_b` at the same x/y (offset -13, 1347, -7). It is a proposed placement, no longer tentative; the spot is the decision, so no other spot is tried, and the plan fails verification if it breaks the gap rule. None of its 25 maps holds an object yet, so it occupies no tile (decision 7). +4. **'Below' is confirmed** (`settings.owner_orientations`) for the groups entered only through neutral exits, whose orientation came from a name keyword: #3 `cave_morlarg`, #16 `lab`, #17 `dark_cave` and #18 `giant_stronghold`. +5. **Version 1's judgement calls are confirmed** (`settings.owner_classifications`): other_world: `berri_lur/temple_loki/desert_plane/desert_plane`, `berri_lur/temple_loki/desert_plane/entrance`, `berri_lur/temple_loki/desert_plane/passage`, `berri_lur/temple_loki/desert_plane/unusual_garden` and `everlink/everlink`; ship: `incuna/ship_lower_deck`, `incuna/ship_lower_deck_to_incuna` and `incuna/ship_lower_deck_to_strakewood`; system: `/hall_of_dms`. +6. **The relayering judgement calls are confirmed** (`settings.owner_relayering_judgement_calls`: all 11, as listed in [Relayering judgement calls](#relayering-judgement-calls)): R3's cave is lowered in place; U64 and U65 move down as whole caves, pushing `ellamin` (-4 -> -5), `rockforge_a` (-4 -> -5), `rockforge_b` (-5 -> -6) and `rockforge_c` (-6 -> -7) one layer deeper; the 46 new seam stairs keep the default archetypes `stairs_stone_up` / `stairs_stone_down` (`settings.seam_stair_archetypes`); the storeys U2 and U3, partly over unmoved ground, move up. +7. **#26 `rockforge_c` stays placed without a footprint** (`settings.owner_no_footprint`, the answer to the question this version raised): none of its 25 maps holds an object, so its placement on layer -7 occupies no tile. The owner's reasons: their 25 maps are empty headers, a footprint appears automatically when content is added and the plan is regenerated, and the spot is fixed, so a later collision makes the generator refuse. + ### Decided 2026-10-18 (new in version 2) 1. **Relayering: fix the conflicts, then relayer.** Storeys standing on raised ground move up with it, caves that would rise into the surface move down, and then every region of z 50 or more moves up a layer per 50. Every map that moves, every cut slope and every judgement call is listed in [Relayering](#relayering-applied). @@ -76,7 +88,7 @@ Numbers (#) match the labels in the layer PNGs. Plane bounding boxes, occupied r |---:|---|---:|---|---|---|---|---| | 1 | `berri_lur/temple_loki/tol` | 2 | (268,1474,L0) world_11_61 (4,10) -> tol_0201 (4,10) [stairs_stone_down] | -1 | 240, 1464, -1 | 1 stairs, 1 portal | - | | 2 | `deserted_tutorial_island/ship_lower_deck` | 1 | return exit ship_lower_deck (1,6) -> (-160,1838,L0) world_-7_76 (8,14) [stairs_wood_up2] | -1 | -161, 1832, -1 | 3 stairs | - | -| 3 | `eld_woods_island/cave_morlarg` | 1 | (271,1903,L0) world_11_79 (7,7) -> cave_morlarg (21,4) [invis_exit] | -1 | 250, 1899, -1 | 4 stairs | orientation below by name keyword (below) | +| 3 | `eld_woods_island/cave_morlarg` | 1 | (271,1903,L0) world_11_79 (7,7) -> cave_morlarg (21,4) [invis_exit] | -1 | 250, 1899, -1 | 4 stairs | orientation below by name keyword (below); owner decision 2026-10-19: 'below' confirmed for a group entered only through neutral exits | | 4 | `eld_woods_island/clearhaven/mine/mine_entrance` | 1 | (198,1895,L0) world_8_78 (6,23) -> mine_entrance (1,1) [well_stone] | -1 | 197, 1894, -1 | 6 stairs | - | | 5 | `eld_woods_island/clearhaven/mine/mine_entrance_a` | 1 | (212,1899,L-1) mine_entrance (15,5) of #4 -> mine_entrance_a (17,14) [ladder_down] | -2 | 195, 1885, -2 | 3 stairs, 2 stairwell, 1 portal | - | | 6 | `eld_woods_island/clearhaven/mine/mine_unique` | 1 | (203,1886,L-2) mine_entrance_a (8,1) of #5 -> mine_unique (37,16) [invis_exit] | -3 | 166, 1870, -3 | 1 stairs, 2 stairwell, 1 portal | - | @@ -89,9 +101,9 @@ Numbers (#) match the labels in the layer PNGs. Plane bounding boxes, occupied r | 13 | `eld_woods_island/hemlock_cave/hemlock_cave` | 9 | (350,1708,L0) world_14_71 (14,4) -> hemlock_cave_0101 (2,22) [stairs_stone_down] | -1 | 348, 1686, -1 | 4 stairs | - | | 14 | `eld_woods_island/vielumin_ruins/ruins` | 9 | (456,1732,L0) world_19_72 (0,4) -> ruins_0201 (9,22) [stairs_stone_down] | -2 | 423, 1710, -2 | 4 stairwell | ideal L-1 blocked by hemlock_cave (gap); moved 1 layer(s) | | 15 | `incuna/ellamin` | 1 | (109,2004,L0) world_4_83 (13,12) -> ellamin (4,13) [stairs_wood_down] | -5 | 105, 1991, -5 | 2 stairwell, 2 portal | ideal L-1 blocked by world_3_82_-1 (gap), world_3_83_-1 (gap), world_4_82_-1 (gap); moved 4 layer(s) | -| 16 | `strakewood_island/brynknot/sewers/lab` | 1 | (95,1596,L-2) world_3_66_-2 (23,12) -> lab (19,13) [exit] | -3 | 76, 1583, -3 | 2 stairs, 1 portal | orientation below by name keyword (below) | -| 17 | `strakewood_island/dark_cave/dark_cave` | 5 | (78,1681,L0) world_3_70 (6,1) -> dark_cave_0102 (1,1) [invis_exit] | -3 | 77, 1704, -3 | 2 stairwell | orientation below by name keyword (below); ideal L-1 blocked by world_2_69_-1 (gap), world_2_71_-2 (gap); moved 2 layer(s) | -| 18 | `strakewood_island/giant_stronghold/giant_stronghold` | 2 | (17,1569,L0) world_0_65 (17,9) -> giant_stronghold_0201 (21,22) [invis_exit] | -1 | -28, 1547, -1 | 3 stairs | orientation below by name keyword (below) | +| 16 | `strakewood_island/brynknot/sewers/lab` | 1 | (95,1596,L-2) world_3_66_-2 (23,12) -> lab (19,13) [exit] | -3 | 76, 1583, -3 | 2 stairs, 1 portal | orientation below by name keyword (below); owner decision 2026-10-19: 'below' confirmed for a group entered only through neutral exits | +| 17 | `strakewood_island/dark_cave/dark_cave` | 5 | (78,1681,L0) world_3_70 (6,1) -> dark_cave_0102 (1,1) [invis_exit] | -3 | 77, 1704, -3 | 2 stairwell | orientation below by name keyword (below); owner decision 2026-10-19: 'below' confirmed for a group entered only through neutral exits; ideal L-1 blocked by world_2_69_-1 (gap), world_2_71_-2 (gap); moved 2 layer(s) | +| 18 | `strakewood_island/giant_stronghold/giant_stronghold` | 2 | (17,1569,L0) world_0_65 (17,9) -> giant_stronghold_0201 (21,22) [invis_exit] | -1 | -28, 1547, -1 | 3 stairs | orientation below by name keyword (below); owner decision 2026-10-19: 'below' confirmed for a group entered only through neutral exits | | 19 | `strakewood_island/greyton/jail/jail` | 1 | (280,1615,L0) world_11_67 (16,7) -> jail (1,19) [stairs_stone_down] | -1 | 279, 1596, -1 | 8 stairs | - | | 20 | `strakewood_island/greyton/jail/jail_a` | 1 | (291,1615,L-1) jail (12,19) of #19 -> jail_a (5,11) [stairs_red_down] | -2 | 286, 1604, -2 | 4 stairs | - | | 21 | `strakewood_island/old_outpost/old_outpost` | 3 | (127,1371,L2) world_5_57 (7,3) -> old_outpost_0101 (1,1) [stairs_stone_down] | -3 | 126, 1370, -3 | 4 stairs, 6 stairwell | ideal L1 blocked by world_4_56 (gap), world_4_57_-2 (gap), world_6_55_-1 (gap); moved 4 layer(s) | @@ -99,6 +111,7 @@ Numbers (#) match the labels in the layer PNGs. Plane bounding boxes, occupied r | 23 | `strakewood_island/rockforge/rockforge` | 2 | (-2,1369,L0) world_-1_57 (22,1) -> rockforge_0101 (11,21) [invis_exit] | -1 | -13, 1348, -1 | 10 stairs, 16 stairwell | - | | 24 | `strakewood_island/rockforge/rockforge_a` | 25 | (-7,1355,L-1) rockforge_0101 (6,7) of #23 -> rockforge_a_0101 (6,8) [stairs_stone_down] | -5 | -13, 1347, -5 | 12 stairs, 32 stairwell | ideal L-2 blocked by world_-4_56_-2 (gap), world_1_51_-3 (gap), world_2_52_-3 (gap); moved 3 layer(s) | | 25 | `strakewood_island/rockforge/rockforge_b` | 25 | (-1,1290,L-5) rockforge_a_0104 (12,15) of #24 -> rockforge_b_0104 (12,15) [invis_exit] | -6 | -13, 1347, -6 | 12 stairs | - | +| 26 | `strakewood_island/rockforge/rockforge_c` | 25 | none: owner alignment, 1 layer under #25 `rockforge_b` at its x/y | -7 | -13, 1347, -7 | - | owner decision 2026-10-19: the Rockforge mines ('Rockforge: Mines') are placed on the layer directly under rockforge_b at the same x/y (rockforge_a/_b/_c share the same 5x5 sheet naming 0101..ab05, and the rockforge_a<->rockforge_b stairs connect identical local coordinates) | | 27 | `strakewood_island/underground_city/underground_city` | 65 | (103,1625,L-1) world_4_67_-1 (7,17) -> underground_city_5_2_-1 (2,3) [stairs_stone_down] | -7..-4 | -19, 1574, -4 | 2 stairwell, 7 portal | ideal L-1 blocked by dark_cave (gap), lab (contact); moved 3 layer(s) | | 28 | `strakewood_island/zechna_temple/zechna_temple` | 16 | (181,1599,L-1) world_7_66_-1 (13,15) -> zechna_temple_1_1_-1 (2,17) [hole] | -6..-3 | 155, 1558, -3 | 4 portal | ideal L-1 blocked by underground_city (gap), world_7_64_-2 (gap), zechna_temple (stairwell-blocked); moved 2 layer(s); main stairwell blocked (see Stairwells) | @@ -142,17 +155,10 @@ Entrance exit objects grouped by source map -> target map (count, outcome). Retu - #23 `strakewood_island/rockforge/rockforge`: world_-1_57 (22,1) -> rockforge_0101: 4x **stairs**; rockforge_a_0101 (6,7) -> rockforge_0101: 2x **stairwell SW10** (4 layers); rockforge_a_0101 (7,7) -> rockforge_0101: 2x **stairwell SW11** (4 layers); rockforge_a_0101 (16,7) -> rockforge_0101: 2x **stairwell SW12** (4 layers); rockforge_a_0101 (17,7) -> rockforge_0101: 2x **stairwell SW13** (4 layers) - #24 `strakewood_island/rockforge/rockforge_a`: world_-1_57 (18,1) -> rockforge_a_0101: 2x **stairwell SL1** (5 layers); world_-1_57 (19,1) -> rockforge_a_0101: 2x **stairwell SL2** (5 layers); world_0_57 (2,1) -> rockforge_a_0101: 2x **stairwell SL3** (5 layers); world_0_57 (3,1) -> rockforge_a_0101: 2x **stairwell SL4** (5 layers); rockforge_0101 (6,7) -> rockforge_a_0101: 2x **stairwell SW10** (4 layers); rockforge_0101 (7,7) -> rockforge_a_0101: 2x **stairwell SW11** (4 layers); rockforge_0101 (16,7) -> rockforge_a_0101: 2x **stairwell SW12** (4 layers); rockforge_0101 (17,7) -> rockforge_a_0101: 2x **stairwell SW13** (4 layers); rockforge_b_0104 (12,15) -> rockforge_a_0104: 4x **stairs**; rockforge_b_aa04 (2,5) -> rockforge_a_aa04: 2x **stairs** - #25 `strakewood_island/rockforge/rockforge_b`: rockforge_a_0104 (12,15) -> rockforge_b_0104: 4x **stairs**; rockforge_a_aa04 (2,5) -> rockforge_b_aa04: 2x **stairs** +- #26 `strakewood_island/rockforge/rockforge_c`: no entrance from placed things - #27 `strakewood_island/underground_city/underground_city`: world_2_69 (0,6) -> underground_city_0_1: 1x portal (51 tiles, 4 layers apart; other-entrance-aligned); world_4_67_-1 (7,17) -> underground_city_5_2_-1: 1x **stairwell SW14** (4 layers); world_5_65 (2,2) -> underground_city_0_0: 1x portal (140 tiles, 4 layers apart; other-entrance-aligned); world_6_67_-1 (8,9) -> underground_city_0_1_-2: 1x portal (149 tiles, 5 layers apart; other-entrance-aligned); lab (11,12) -> underground_city_0_1: 1x portal (83 tiles, 1 layers apart; other-entrance-aligned) - #28 `strakewood_island/zechna_temple/zechna_temple`: world_7_66_-1 (13,15) -> zechna_temple_1_1_-1: 1x portal (0 tiles, 3 layers apart; stairwell-blocked); world_7_66_-1 (16,15) -> zechna_temple_1_0_-2: 1x portal (39 tiles, 4 layers apart; other-entrance-aligned); world_7_66_-1 (19,15) -> zechna_temple_0_0_-3: 1x portal (28 tiles, 5 layers apart; other-entrance-aligned) -## Tentative placements - -Only used if the owner accepts them. - -| # | group | maps | main connection: plane (x,y,layer) old map (x,y) -> target | plane layers | x_off, y_off, layer_off | crossing exits after placement | notes | -|---:|---|---:|---|---|---|---|---| -| 26 | `strakewood_island/rockforge/rockforge_c` | 25 | none (forced alignment) | -7 | -13, 1347, -7 | - | orientation below by name keyword (below); rockforge_a/_b/_c share the same 5x5 sheet naming (0101..ab05) and rockforge_a<->rockforge_b stairs connect identical local coordinates; rockforge_c ('Rockforge: Mines') is assumed to be the next sheet below rockforge_b at the same x/y. | - ## Relayering (applied) Owner decision 2026-10-18: fix the conflicts, then relayer. JSON key `relayering`; every tile of the overworld is on its final layer everywhere else in the plan. The before/after images are `relayer_.png` ([Layer images](#layer-images)). @@ -507,7 +513,7 @@ They become the stairwells SR1-SR22 (one per stair column, built through the voi ### Cut walkable slopes -904 walkable edges are cut in 26 seams. Each seam piece (the part of a seam inside one 24x24 cell) keeps continuity through an existing exit between its two sides, or gets a pair of plain stairs on its representative edge (orthogonal first, then nearest the piece's centre). Then every cut edge is checked: its two tiles must still be connected (walking on the final layers, legacy exits, the new stairs), and an edge whose tiles are still apart, a pocket the piece's stairs do not reach, gets stairs of its own. **Every cut is flagged here**: 46 new stair pairs in 44 pieces (2 for pockets), 0 pieces already joined by an exit. The stair objects default to `stairs_stone_up` on the low tile and `stairs_stone_down` on the high tile. +904 walkable edges are cut in 26 seams. Each seam piece (the part of a seam inside one 24x24 cell) keeps continuity through an existing exit between its two sides, or gets a pair of plain stairs on its representative edge (orthogonal first, then nearest the piece's centre). Then every cut edge is checked: its two tiles must still be connected (walking on the final layers, legacy exits, the new stairs), and an edge whose tiles are still apart, a pocket the piece's stairs do not reach, gets stairs of its own. **Every cut is flagged here**: 46 new stair pairs in 44 pieces (2 for pockets), 0 pieces already joined by an exit. The stair objects are `stairs_stone_up` on the low tile and `stairs_stone_down` on the high tile, the default archetypes, kept by owner decision 2026-10-19 (`settings.seam_stair_archetypes`). | seam | regions | layers | cut edges | maps | new stairs | |---|---|---|---:|---|---| @@ -596,6 +602,8 @@ They become the stairwells SR1-SR22 (one per stair column, built through the voi ### Relayering judgement calls +Confirmed by the owner on 2026-10-19 ([decision 6](#decided-2026-10-19)), every call as listed here. + - storey partly over unmoved ground: U2 (world_3_45_1) moves up 1 layer(s); 17 of its 87 tiles stand on ground that stays. - storey partly over unmoved ground: U3 (world_3_45_1, world_3_46_1) moves up 1 layer(s); 18 of its 96 tiles stand on ground that stays. - cave resolution: U64 (3411 tiles, regions R7): lower whole cave; lowering the whole cave breaks 0 stair(s) and makes 0 steep step(s); lowering only the raised regions breaks 0 and makes 14. @@ -610,7 +618,7 @@ They become the stairwells SR1-SR22 (one per stair column, built through the voi ## Stairwells -Each row is one stairwell (`stairwells[]` in the JSON): one stair tile per layer from the top end to the bottom end. Top and bottom are the existing exit objects; the intermediate tiles are new decorations that borrow the appearance of the family's `*_down` archetype, are not exits and not walkable, so the stairwell stays one exit. **Every stairwell is drawn only on the player's layer** (`segments_drawn_only_on_player_layer`), so its **view conflicts** (intermediate tiles within 16 tiles of an unrelated area on that layer) are listed but not seen. The chain search also avoids tiles within 1 tile of a related area (the contact margin); **contacts** it cannot avoid are listed. A stairwell that replaces a legacy one-way exit is used only that way (**use**: down or up); its far end is a landing without an exit. Ids: SW = built for a placed group, SL = sloped, SR = for an overworld exit the relayering put 2 or more layers apart. +Each row is one stairwell (`stairwells[]` in the JSON): one stair tile per layer from the top end to the bottom end. Top and bottom are the existing exit objects; the intermediate tiles are new decorations that borrow the appearance of the family's `*_down` archetype (or, when no exit belongs to a stair family, of the archetype the owner chose for its exits: `invis_exit` -> `stairs_stone_down`; `well_bucket` -> `ladder_down`; `well_stone` -> `ladder_down`), are not exits and not walkable, so the stairwell stays one exit. **Every stairwell is drawn only on the player's layer** (`segments_drawn_only_on_player_layer`), so its **view conflicts** (intermediate tiles within 16 tiles of an unrelated area on that layer) are listed but not seen. The chain search also avoids tiles within 1 tile of a related area (the contact margin); **contacts** it cannot avoid are listed. A stairwell that replaces a legacy one-way exit is used only that way (**use**: down or up); its far end is a landing without an exit. Ids: SW = built for a placed group, SL = sloped, SR = for an overworld exit the relayering put 2 or more layers apart. | id | owner | connects to | top (x, y) | layers | span | shape | use | top / intermediate / bottom objects | replaces | view conflicts | contacts | |---|---|---|---|---|---:|---|---|---|---|---|---| @@ -619,7 +627,7 @@ Each row is one stairwell (`stairwells[]` in the JSON): one stair tile per layer | SW3 | #14 `ruins` | `world_19_72` | (456, 1732) = world_19_72 (0,4) | 0..-2 | 2 | column | both ways | stairs_stone_down / stairs_stone_down (look only) / stairs_stone_up | ruins_0201 stairs_stone_up, world_19_72 stairs_stone_down | none | none | | SW4 | #14 `ruins` | `world_19_72` | (457, 1732) = world_19_72 (1,4) | 0..-2 | 2 | column | both ways | stairs_stone_down / stairs_stone_down (look only) / stairs_stone_up | ruins_0201 stairs_stone_up, world_19_72 stairs_stone_down | none | none | | SW5 | #15 `ellamin` | `world_4_83` | (109, 2004) = world_4_83 (13,12) | 0..-5 | 5 | column | both ways | stairs_wood_down / stairs_wood_down (look only) / stairs_wood_up | ellamin stairs_wood_up, world_4_83 stairs_wood_down | L-1: `world_3_83_-1`, `world_3_84_-1`, `world_4_83_-1`, `world_4_84_-1`, `world_5_84_-1`; L-2: `world_3_83_-2`, `world_4_83_-2`, `world_4_84_-2`, `world_5_84_-2` | none | -| SW6 | #17 `dark_cave` | `world_3_70` | (78, 1681) = world_3_70 (6,1) | 0..-3 | 3 | column, bends 1 tile | both ways | invis_exit / **none (owner to choose)** / invis_exit | dark_cave_0102 invis_exit, world_3_70 invis_exit | none | none | +| SW6 | #17 `dark_cave` | `world_3_70` | (78, 1681) = world_3_70 (6,1) | 0..-3 | 3 | column, bends 1 tile | both ways | invis_exit / stairs_stone_down (look only, owner's choice) / invis_exit | dark_cave_0102 invis_exit, world_3_70 invis_exit | none | none | | SW7 | #21 `old_outpost` | `world_5_57` | (127, 1371) = world_5_57 (7,3) | 2..-3 | 5 | column | both ways | stairs_stone_down / stairs_stone_down (look only) / stairs_stone_up | old_outpost_0101 stairs_stone_up, world_5_57 stairs_stone_down | L1: `world_4_56`; L0: `world_4_56`, `world_5_56`; L-2: `world_4_57_-2`, `world_5_57_-2` | none | | SW8 | #21 `old_outpost` | `world_5_57` | (128, 1371) = world_5_57 (8,3) | 2..-3 | 5 | column | both ways | stairs_stone_down / stairs_stone_down (look only) / stairs_stone_up | old_outpost_0101 stairs_stone_up, world_5_57 stairs_stone_down | L1: `world_4_56`, `world_6_57`; L0: `world_4_56`, `world_5_56`, `world_6_56`; L-1: `world_6_56_-1`, `world_6_57_-1`; L-2: `world_4_57_-2`, `world_5_57_-2` | none | | SW9 | #21 `old_outpost` | `world_5_57` | (129, 1371) = world_5_57 (9,3) | 2..-3 | 5 | column | both ways | stairs_stone_down / stairs_stone_down (look only) / stairs_stone_up | old_outpost_0101 stairs_stone_up, world_5_57 stairs_stone_down | L1: `world_4_56`, `world_6_57`; L0: `world_4_56`, `world_5_56`, `world_6_56`; L-1: `world_6_56_-1`, `world_6_57_-1`; L-2: `world_4_57_-2`, `world_5_57_-2` | none | @@ -648,8 +656,8 @@ Each row is one stairwell (`stairwells[]` in the JSON): one stair tile per layer | SR14 | `world_7_47` | `world_7_47_-1` | (177, 1133) = world_7_47 (9,5) | 1..-1 | 2 | column | up only | landing / ladder_down (look only) / ladder_up | world_7_47_-1 ladder_up | L0: `world_6_47`, `world_7_47`, `world_8_47` | none | | SR15 | `world_7_47` | `world_7_47_-1` | (177, 1137) = world_7_47 (9,9) | 1..-1 | 2 | column | both ways | stairs_stone_down / stairs_stone_down (look only) / stairs_stone_up | world_7_47 stairs_stone_down, world_7_47_-1 stairs_stone_up | L0: `world_6_47`, `world_6_48`, `world_7_47`, `world_7_48`, `world_8_47`, `world_8_48` | none | | SR16 | `world_7_47` | `world_7_47_-1` | (169, 1147) = world_7_47 (1,19) | 1..-1 | 2 | column | both ways | sewer_hole2 / ladder_down (look only) / ladder_up | world_7_47 sewer_hole2, world_7_47_-1 ladder_up | L0: `world_6_47`, `world_6_48`, `world_7_47`, `world_7_48` | none | -| SR17 | `world_3_56` | `world_3_56_-2` | (85, 1366) = world_3_56 (13,22) | 1..-2 | 3 | column | both ways | well_stone / **none (owner to choose)** / well_bucket | world_3_56 well_stone, world_3_56_-2 well_bucket | none | none | -| SR18 | `world_6_57` | `world_6_57_-1` | (153, 1382) = world_6_57 (9,14) | 1..-1 | 2 | column | up only | landing / **none (owner to choose)** / well_bucket | world_6_57_-1 well_bucket | L0: `world_5_56`, `world_5_58`, `world_6_56`, `world_6_58`, `world_7_57`, `world_7_58` | none | +| SR17 | `world_3_56` | `world_3_56_-2` | (85, 1366) = world_3_56 (13,22) | 1..-2 | 3 | column | both ways | well_stone / ladder_down (look only, owner's choice) / well_bucket | world_3_56 well_stone, world_3_56_-2 well_bucket | none | none | +| SR18 | `world_6_57` | `world_6_57_-1` | (153, 1382) = world_6_57 (9,14) | 1..-1 | 2 | column | up only | landing / ladder_down (look only, owner's choice) / well_bucket | world_6_57_-1 well_bucket | L0: `world_5_56`, `world_5_58`, `world_6_56`, `world_6_58`, `world_7_57`, `world_7_58` | none | | SR19 | `world_7_64` | `world_7_64_-1` | (189, 1549) = world_7_64 (21,13) | 1..-1 | 2 | column | down only | stairs_down / stairs_down (look only) / landing | world_7_64 stairs_down | none | none | | SR20 | `world_7_64` | `world_7_64_-1` | (190, 1549) = world_7_64 (22,13) | 1..-1 | 2 | column | down only | stairs_down / stairs_down (look only) / landing | world_7_64 stairs_down | L0: `world_8_65` | none | | SR21 | `world_7_64` | `world_7_64_-1` | (189, 1550) = world_7_64 (21,14) | 1..-1 | 2 | column | both ways | stairs_stone_down / stairs_stone_down (look only) / stairs_stone_up, stairs_stone_up2 | world_7_64 stairs_stone_down, world_7_64_-1 stairs_stone_up, world_7_64_-1 stairs_stone_up2 | L0: `world_8_65` | none | @@ -657,7 +665,7 @@ Each row is one stairwell (`stairwells[]` in the JSON): one stair tile per layer Stairwells that could not be built (the exit stays a portal): -- #28 `zechna_temple` <- `world_7_66_-1`: world_7_66_-1 (13,15) hole at plane (181, 1599, L-1) down to L-4; the column is occupied on L-3 by `zechna_temple_1_1`; no void chain within 2 tile(s) of the column. +- #28 `zechna_temple` <- `world_7_66_-1`: world_7_66_-1 (13,15) hole at plane (181, 1599, L-1) down to L-4; the column is occupied on L-3 by `zechna_temple_1_1`; no void chain within 2 tile(s) of the column. Owner decision 2026-10-19: **Zechna's first hole stays a portal: no stairwell is built through zechna_temple_1_1, the floor in its column**. ### Sloped stairwells @@ -723,7 +731,7 @@ Exit objects between placed things that are still teleports after placement, gro | world_7_66_-1 -> zechna_temple_1_1_-1 | 1 | 0 | 3 | stairwell-blocked | | zechna_temple_0_0 -> world_7_67_-1 | 1 | 52 | 2 | other-entrance-aligned | -Exits into the 31 separate planes (190 exit objects) are portals by definition and are not listed. +Exits into the 30 separate planes (190 exit objects) are portals by definition and are not listed. ## Stairwell follow-up (engine and format) @@ -764,11 +772,11 @@ Read-only source: `atrinik/classic@b651c55fd0fe`. | `/guilds/quick_wolves/storage` | 1 | guild | names ['Quick Wolves Storage']; regions ['strakewood_island']; no exit object in the overworld leads here; path under /guilds/; guild maps are entered through python/Guild.py / guild scripts | - | | `/planes/bone_cairn/bone_cairn_0_0` | 1 | other_world | names ['Bone Cairn']; regions ['bone_cairn']; no exit object in the overworld leads here; path under /planes/ (separate world) | - | | `/planes/creation/creation` | 225 | other_world | names ['Plane of Creation']; regions ['creation']; no exit object in the overworld leads here; path under /planes/ (separate world) | - | -| `berri_lur/temple_loki/desert_plane/desert_plane` | 4 | other_world | names ['Desert Plane']; regions ['berri_lur']; no exit object in the overworld leads here; part of the Temple of Loki 'Desert Plane' sequence (path contains /desert_plane/); owner directive: desert planes stay separate | - | -| `berri_lur/temple_loki/desert_plane/entrance` | 1 | other_world | names ['Desert Plane']; regions ['berri_lur']; no exit object in the overworld leads here; part of the Temple of Loki 'Desert Plane' sequence (path contains /desert_plane/); owner directive: desert planes stay separate | - | -| `berri_lur/temple_loki/desert_plane/passage` | 3 | other_world | names ['Desert Plane']; regions ['berri_lur']; no exit object in the overworld leads here; part of the Temple of Loki 'Desert Plane' sequence (path contains /desert_plane/); owner directive: desert planes stay separate | - | -| `berri_lur/temple_loki/desert_plane/unusual_garden` | 1 | other_world | names ['An Unusual Garden']; regions ['berri_lur']; no exit object in the overworld leads here; part of the Temple of Loki 'Desert Plane' sequence (path contains /desert_plane/); owner directive: desert planes stay separate | out to world_12_59 | -| `everlink/everlink` | 8 | other_world | names ['Everlink']; regions ['everlink']; no exit object in the overworld leads here; not reachable from the overworld through exit objects; no exit object leads here at all; referenced by scripts: ['maps/python/House.py']; region 'everlink' description: 'No one knows where this place is in the world, but only proud house owners can access it.'; only entered by scripts (python/houses/exit.py via House.py) | - | +| `berri_lur/temple_loki/desert_plane/desert_plane` | 4 | other_world | names ['Desert Plane']; regions ['berri_lur']; no exit object in the overworld leads here; part of the Temple of Loki 'Desert Plane' sequence (path contains /desert_plane/); owner directive: desert planes stay separate; owner decision 2026-10-19: other_world confirmed (a version 1 judgement call) | - | +| `berri_lur/temple_loki/desert_plane/entrance` | 1 | other_world | names ['Desert Plane']; regions ['berri_lur']; no exit object in the overworld leads here; part of the Temple of Loki 'Desert Plane' sequence (path contains /desert_plane/); owner directive: desert planes stay separate; owner decision 2026-10-19: other_world confirmed (a version 1 judgement call) | - | +| `berri_lur/temple_loki/desert_plane/passage` | 3 | other_world | names ['Desert Plane']; regions ['berri_lur']; no exit object in the overworld leads here; part of the Temple of Loki 'Desert Plane' sequence (path contains /desert_plane/); owner directive: desert planes stay separate; owner decision 2026-10-19: other_world confirmed (a version 1 judgement call) | - | +| `berri_lur/temple_loki/desert_plane/unusual_garden` | 1 | other_world | names ['An Unusual Garden']; regions ['berri_lur']; no exit object in the overworld leads here; part of the Temple of Loki 'Desert Plane' sequence (path contains /desert_plane/); owner directive: desert planes stay separate; owner decision 2026-10-19: other_world confirmed (a version 1 judgement call) | out to world_12_59 | +| `everlink/everlink` | 8 | other_world | names ['Everlink']; regions ['everlink']; no exit object in the overworld leads here; not reachable from the overworld through exit objects; no exit object leads here at all; referenced by scripts: ['maps/python/House.py']; region 'everlink' description: 'No one knows where this place is in the world, but only proud house owners can access it.'; only entered by scripts (python/houses/exit.py via House.py); owner decision 2026-10-19: other_world confirmed (a version 1 judgement call) | - | | `deserted_tutorial_island/mushroom_cavern` | 1 | per_player | names ['Mushroom Cavern']; regions ['deserted_tutorial_island']; 3 exit object(s) from the overworld lead here; 3 entrance exit(s) carry last_eat 1 (MAP_PLAYER_MAP: per-player copy), e.g. /shattered_islands/world_-5_76 (12,20); owner decision 5 (settled): per-player copies stay separate planes | in from world_-5_76 x3; out to world_-5_76 | | `eld_woods_island/apartments/apartment_cheap` | 1 | per_player | names ['Cheap Treehouse']; regions ['clearhaven']; no exit object in the overworld leads here; listed in maps/python/Apartments.py:69 (apartment registry); python/generic/apartment_teleport.py enters it with GetPath(path, True, player) = per-player copy | - | | `eld_woods_island/apartments/apartment_expensive` | 1 | per_player | names ['Expensive Treehouse']; regions ['clearhaven']; no exit object in the overworld leads here; listed in maps/python/Apartments.py:85 (apartment registry); python/generic/apartment_teleport.py enters it with GetPath(path, True, player) = per-player copy | - | @@ -782,25 +790,19 @@ Read-only source: `atrinik/classic@b651c55fd0fe`. | `strakewood_island/brynknot/abyss/abyss` | 1 | per_player | names ['In the Abyss']; regions ['brynknot']; 96 exit object(s) from the overworld lead here; 96 entrance exit(s) carry last_eat 1 (MAP_PLAYER_MAP: per-player copy), e.g. /shattered_islands/world_0_66 (6,10); owner decision 5 (settled): per-player copies stay separate planes | in from world_0_66 x82, world_0_67 x14; out to world_0_66 | | `strakewood_island/brynknot/sewers/lab_nyhelobo` | 1 | per_player | names ["Nyhelobo's Lab"]; regions ['brynknot']; no exit object in the overworld leads here; entered only via maps/shattered_islands/strakewood_island/brynknot/sewers/scripts/nyhelobo_portal.py which teleports to slaying+suffix with GetPath(..., True) (per-player copy) | out to world_4_66_-2 | | `strakewood_island/greyton/house/luxury_house_0_0` | 2 | per_player | names ['Greyton Luxury House']; regions ['greyton']; no exit object in the overworld leads here; listed in maps/python/House.py:17 with unique flag 1; python/houses/exit.py enters it with GetPath(path, 1, player) = per-player house | - | -| `incuna/ship_lower_deck` | 1 | ship | names ['Lower Deck']; regions ['incuna']; 1 exit object(s) from the overworld lead here; ship lower deck used for sea travel: entered from ['/shattered_islands/world_-6_79'] but exits to ['/shattered_islands/world_4_85']; NPC dialog teleports here (maps/interfaces/ocean/sam_goodberry.xml) | in from world_-6_79; out to world_4_85 x2 | -| `incuna/ship_lower_deck_to_incuna` | 1 | ship | names ['Lower Deck']; regions ['incuna']; 1 exit object(s) from the overworld lead here; ship lower deck used for sea travel: entered from ['/shattered_islands/world_2_87'] but exits to ['/shattered_islands/world_4_85']; NPC dialog teleports here (maps/interfaces/ocean/sam_goodberry_to_incuna.xml) | in from world_2_87; out to world_4_85 x2 | -| `incuna/ship_lower_deck_to_strakewood` | 1 | ship | names ['Lower Deck']; regions ['strakewood_island']; 1 exit object(s) from the overworld lead here; ship lower deck used for sea travel: entered from ['/shattered_islands/world_0_83'] but exits to ['/shattered_islands/world_0_70']; NPC dialog teleports here (maps/interfaces/ocean/sam_goodberry_to_strakewood.xml) | in from world_0_83; out to world_0_70 x2 | +| `incuna/ship_lower_deck` | 1 | ship | names ['Lower Deck']; regions ['incuna']; 1 exit object(s) from the overworld lead here; ship lower deck used for sea travel: entered from ['/shattered_islands/world_-6_79'] but exits to ['/shattered_islands/world_4_85']; NPC dialog teleports here (maps/interfaces/ocean/sam_goodberry.xml); owner decision 2026-10-19: ship confirmed (a version 1 judgement call) | in from world_-6_79; out to world_4_85 x2 | +| `incuna/ship_lower_deck_to_incuna` | 1 | ship | names ['Lower Deck']; regions ['incuna']; 1 exit object(s) from the overworld lead here; ship lower deck used for sea travel: entered from ['/shattered_islands/world_2_87'] but exits to ['/shattered_islands/world_4_85']; NPC dialog teleports here (maps/interfaces/ocean/sam_goodberry_to_incuna.xml); owner decision 2026-10-19: ship confirmed (a version 1 judgement call) | in from world_2_87; out to world_4_85 x2 | +| `incuna/ship_lower_deck_to_strakewood` | 1 | ship | names ['Lower Deck']; regions ['strakewood_island']; 1 exit object(s) from the overworld lead here; ship lower deck used for sea travel: entered from ['/shattered_islands/world_0_83'] but exits to ['/shattered_islands/world_0_70']; NPC dialog teleports here (maps/interfaces/ocean/sam_goodberry_to_strakewood.xml); owner decision 2026-10-19: ship confirmed (a version 1 judgement call) | in from world_0_83; out to world_0_70 x2 | | `ships/ship_1` | 1 | ship | names ['Ship']; regions ['ship']; 10 exit object(s) from the overworld lead here; path under /shattered_islands/ships/; one ship map is entered from 9 different overworld docks | in from world_0_70, world_12_68 x2, world_14_64, world_17_3, world_2_74, world_6_78, world_9_41, world_9_42, world_9_56; out to world_0_70, world_12_68 x2, world_14_64, world_17_3, world_2_74, world_6_78, world_9_41, world_9_42, world_9_56 | | `ships/ship_2` | 1 | ship | names ['Ship']; regions ['ship']; 4 exit object(s) from the overworld lead here; path under /shattered_islands/ships/; one ship map is entered from 4 different overworld docks | in from world_15_4, world_18_3, world_2_70, world_6_51; out to world_15_4, world_18_3, world_2_70, world_6_51 | | `/emergency` | 1 | system | names ['Emergency Map']; regions ['creation']; no exit object in the overworld leads here; Server emergency fallback map (EMERGENCY_MAPPATH in classic/server object_enter_map); used by scripts as a fallback. | out to world_2_68 | -| `/hall_of_dms` | 1 | system | names ['Hall of DMs']; regions ['creation']; 1 exit object(s) from the overworld lead here; DM hall; hub with exits to the Plane of Creation, guild oracle and several overworld towns (a DM/system map). | in from world_1_68; out to world_1_68, world_7_78, world_9_68 | -| `strakewood_island/rockforge/rockforge_c` | 25 | unreachable | names ['Rockforge: Mines']; regions ['rockforge_mines']; no exit object in the overworld leads here; not reachable from the overworld through exit objects; no exit object leads here at all | - | +| `/hall_of_dms` | 1 | system | names ['Hall of DMs']; regions ['creation']; 1 exit object(s) from the overworld lead here; DM hall; hub with exits to the Plane of Creation, guild oracle and several overworld towns (a DM/system map).; owner decision 2026-10-19: system confirmed (a version 1 judgement call) | in from world_1_68; out to world_1_68, world_7_78, world_9_68 | ## Open owner decisions -1. **Blocked stairwell at #28 `zechna_temple`** (Zechna's first hole): the world_7_66_-1 (13,15) hole drops 3 layers, but `zechna_temple_1_1` on L-3 occupies the column. Keep it a portal, or let the stairwell pass through that floor. -2. **Stair archetype for stairwells without stairs**: SW6 (#17 `dark_cave`: invis_exit), SR17 (well_bucket / well_stone), SR18 (well_bucket) join exits that have no stair archetype to reuse. Pick one (for example `stairs_stone_down`) or leave the intermediate tiles empty. -3. **Tentative `strakewood_island/rockforge/rockforge_c`** (#26): rockforge_a/_b/_c share the same 5x5 sheet naming (0101..ab05) and rockforge_a<->rockforge_b stairs connect identical local coordinates; rockforge_c ('Rockforge: Mines') is assumed to be the next sheet below rockforge_b at the same x/y. -4. **Confirm 'below' for groups entered only through neutral exits** (invis_exit/exit, so the direction came from a name keyword): #3 `cave_morlarg`, #16 `lab`, #17 `dark_cave`, #18 `giant_stronghold`. A building interior would rather go above. -5. **Confirm the judgement calls**: Everlink is other_world (no exit leads in, its region text says nobody knows where it is, only house scripts reach it); the Temple of Loki desert-plane chain incl. `unusual_garden` is other_world (the garden's hole lands on the same (12,15) of world_12_59, so it could instead sit above that map); the three Incuna `ship_lower_deck*` maps are ship (travel decks entered at one port and left at another, also reached by NPC dialog teleports); `hall_of_dms` is system although world_1_68 has an exit into it. -6. **New: confirm the relayering judgement calls** ([list](#relayering-judgement-calls)): in particular the cave lowered in place (R3), the caves moved down as a whole and the placements they push one layer deeper (`ellamin`, `rockforge_a`, `rockforge_b`, `rockforge_c`), and the 46 new seam stairs with their default archetype. +None. The six decisions version 2 left open were answered on 2026-10-19 (see [Decided 2026-10-19](#decided-2026-10-19)), and so was the one this version raised (decision 7). -Resolved by the 2026-10-18 decisions: the layer-height relayering (applied), stairwells in view of unrelated areas (hidden in the engine), sloped staircases (built where free), the dangling stairs (wired) and the Greyton jail ladders (kept as decoration). +Resolved by the 2026-10-18 decisions: the layer-height relayering (applied), stairwells in view of unrelated areas (hidden in the engine), sloped staircases (built where free), the dangling stairs (wired) and the Greyton jail ladders (kept as decoration). Resolved by the 2026-10-19 decisions: Zechna's first hole (kept a portal), the stair art of the stairwells without stairs (chosen), the Rockforge mines (placed, also without a footprint), and the orientations and judgement calls of version 1 and of the relayering (confirmed). ## Data problems found @@ -839,6 +841,7 @@ Resolved by the 2026-10-18 decisions: the layer-height relayering (applied), sta - `world_6_42`: (2,17) ladder_down - `world_6_66`: (18,15) hole - `world_8_63_-1`: (2,4) hole2 +- Empty maps outside the overworld (a map header and tile links, no object, so no footprint): `strakewood_island/rockforge/rockforge_b` 23 of 25; `strakewood_island/rockforge/rockforge_c` 25 of 25. - Connectivity: `strakewood_island/brynknot/sewers/lab_nyhelobo`: no exit object leads in. - Return exits are often not the exact reverse of their entrance (e.g. Temple of Loki stairs land 6 tiles away). These are listed per exit in the JSON (`xy_distance`), not treated as errors. @@ -874,7 +877,7 @@ Each image covers its regions and every storey or cave unit that moved because o | -4 | 0 | 5,441 | #8 #22 #27 #28 | SW1 SW5 SW10 SW11 SW12 SW13 SW14 SL1 SL2 SL3 SL4 | | -5 | 0 | 0 | #7 #15 #24 #27 #28 | - | | -6 | 0 | 0 | #25 #27 #28 | - | -| -7 | 0 | 0 | #26* #27 | - | +| -7 | 0 | 0 | #26 #27 | - | ## Reproducing @@ -886,8 +889,8 @@ python3 -I scripts/test_placement_plan.py python3 -I scripts/build_plan.py --content /home/zoey/dev/worktrees/content-plan-v2-input --out . --check ``` -`--content` must be a clean checkout of `atrinik/content` at `3ad20150da76` (the shared checkout was behind `main`, so this run used a separate clone at the current `main`). The scripts need only the Python standard library (the PNGs are written by `raster.py`). The content checkout must be clean under `arch/` and `maps/` (`--allow-dirty` is for experiments and marks the JSON). Artifacts are generated in a staging directory, the staged JSON is verified again, and only then are `placement-plan.json`, `REPORT.md` and the PNGs replaced; a failed run leaves the old artifacts in place. A group that cannot be placed stays unplaced and fails verification, so such a plan is never published. `test_placement_plan.py` tampers with copies of the committed JSON and checks that the verifier rejects each one; it needs no content checkout. `verify_plan.py` makes two checks. Without `--content` it re-derives the placement rules from the JSON alone. With `--content` it also rebuilds the plan from the content with the generator and requires the published JSON to equal the rebuild field for field (the checkout path aside), so an edit that keeps the JSON consistent with itself (an exit dropped from `exit_inventory` together with its count, say) fails there. `build_plan.py --check` regenerates every artifact in a temporary directory and fails unless each is byte-identical (run it with the `--content` path the plan records). +`--content` must be a clean checkout of `atrinik/content` at `3828651987a5` (the shared checkout was behind `main`, so this run used a separate clone at the current `main`). The scripts need only the Python standard library (the PNGs are written by `raster.py`). The content checkout must be clean under `arch/` and `maps/` (`--allow-dirty` is for experiments and marks the JSON). Artifacts are generated in a staging directory, the staged JSON is verified again, and only then are `placement-plan.json`, `REPORT.md` and the PNGs replaced; a failed run leaves the old artifacts in place. A group that cannot be placed stays unplaced and fails verification, so such a plan is never published. `test_placement_plan.py` tampers with copies of the committed JSON and checks that the verifier rejects each one; it needs no content checkout. `verify_plan.py` makes two checks. Without `--content` it re-derives the placement rules from the JSON alone. With `--content` it also rebuilds the plan from the content with the generator and requires the published JSON to equal the rebuild field for field (the checkout path aside), so an edit that keeps the JSON consistent with itself (an exit dropped from `exit_inventory` together with its count, say) fails there. `build_plan.py --check` regenerates every artifact in a temporary directory and fails unless each is byte-identical (run it with the `--content` path the plan records). -The run is deterministic (sorted inputs, lexicographic tie-breaks) and read-only against the content checkout; rerunning with the same arguments on the same content rewrites `placement-plan.json`, `REPORT.md` and the PNGs byte-for-byte. `content_git_head`, `content_worktree_clean` and `content_inputs_digest` (SHA-256 over every file under `arch/` and `maps/`, checked again after the run) in the JSON identify the input. Scripts: `mapdata.py` (read-only reader, tile links, occupied tiles, floors per sub-layer, `no_pass` tiles), `analysis.py` (components, layout, exit resolution), `relayer.py` (the relayering: regions, units, conflicts, seams), `footprint.py` (gap rule, column and sloped stairwell chains), `build_plan.py` (classification, relayering, placement, stairwells, wiring, JSON, staged publication), `report.py`, `render.py` and `raster.py` (images), and `verify_plan.py` (independent check of the JSON). Tunables (`VIEW_RADIUS`, `CONTACT_MARGIN`, `MAX_EXTRA_LAYERS`, `MAX_SHIFT`, `STAIR_TOLERANCE`, `STAIRWELL_MAX_BEND`, `LAYER_HEIGHT_Z` in `build_plan.py`; `MIN_REGION_TILES`, `WALKABLE_DZ`, `SEAM_CELL` in `relayer.py`) and the hand-written inputs (system maps, owner decisions, the Rockforge mines alignment hint, version 1's placements for the comparison) are constants. +The run is deterministic (sorted inputs, lexicographic tie-breaks) and read-only against the content checkout; rerunning with the same arguments on the same content rewrites `placement-plan.json`, `REPORT.md` and the PNGs byte-for-byte. `content_git_head`, `content_worktree_clean` and `content_inputs_digest` (SHA-256 over every file under `arch/` and `maps/`, checked again after the run) in the JSON identify the input. Scripts: `mapdata.py` (read-only reader, tile links, occupied tiles, floors per sub-layer, `no_pass` tiles), `analysis.py` (components, layout, exit resolution), `relayer.py` (the relayering: regions, units, conflicts, seams), `footprint.py` (gap rule, column and sloped stairwell chains), `build_plan.py` (classification, relayering, placement, stairwells, wiring, JSON, staged publication), `report.py`, `render.py` and `raster.py` (images), and `verify_plan.py` (independent check of the JSON). Tunables (`VIEW_RADIUS`, `CONTACT_MARGIN`, `MAX_EXTRA_LAYERS`, `MAX_SHIFT`, `STAIR_TOLERANCE`, `STAIRWELL_MAX_BEND`, `LAYER_HEIGHT_Z` in `build_plan.py`; `MIN_REGION_TILES`, `WALKABLE_DZ`, `SEAM_CELL` in `relayer.py`) and the hand-written inputs (system maps, the owner's decisions of 2026-10-18 and 2026-10-19 with their dates, version 1's placements for the comparison) are constants. `mapdata.py` is a read-only reader for this analysis, not the toolkit's parser. At content `b9657299a6e7` it was cross-checked against the canonical `atrinik_source::Document::parse` records (with `atrinik-catalog`'s rule that an `arch` field opens an embedded object and `end` closes it): all 3629 map files (2,058,974 top-level objects) and 1358 archetype files (3,805 objects) agree on every object name, field value and child count, with 0 differences. Content `main` has since changed only `maps/python/Merchant.py`, which the reader does not parse. The migration tool that applies this plan must read content through the toolkit's parser and catalog itself. diff --git a/migration/placement-plan/placement-plan.json b/migration/placement-plan/placement-plan.json index 769a586..ef65d96 100644 --- a/migration/placement-plan/placement-plan.json +++ b/migration/placement-plan/placement-plan.json @@ -1,8 +1,8 @@ { - "schema": "atrinik-overworld-placement-plan/4", + "schema": "atrinik-overworld-placement-plan/5", "generated_by": "scripts/build_plan.py", "content_root": "/home/zoey/dev/worktrees/content-plan-v2-input", - "content_git_head": "3ad20150da7656516dae06d4f504d09af4cba344", + "content_git_head": "3828651987a5c975148fee58e057074df4722b29", "content_worktree_clean": true, "content_inputs_digest": { "trees": [ @@ -73,14 +73,319 @@ "ladder_up" ] ], - "tentative_alignments": [ + "owner_portals": [ + { + "source_map": "/shattered_islands/world_7_66_-1", + "line": 938, + "source_tile": [ + 13, + 15 + ], + "arch": "hole", + "premise": "ad3f43acaee60f6c1459523c8af820f3fdbe384f780a3468ef8f0fab23e0cf20", + "decided": "2026-10-19", + "decision": "Zechna's first hole stays a portal: no stairwell is built through zechna_temple_1_1, the floor in its column" + } + ], + "stairwell_appearance_fallbacks": [ + { + "exit_arch": "invis_exit", + "appearance": "stairs_stone_down", + "appearance_defined_in": "arch/connect/stairs/stairs_stone/stairs_stone.arc", + "stairwells": [ + { + "id": "SW6", + "premise": "f2adfb3c19b1fb4bee44f296e5071e1ddb6c7390cd0e8539bec6c4f5cb0f55f4" + } + ], + "decided": "2026-10-19", + "decision": "stairwells whose exits have no stair archetype: the wells (SR17, SR18) show ladder_down and the invisible exit of #17 dark_cave (SW6) shows stairs_stone_down on their intermediate tiles" + }, + { + "exit_arch": "well_bucket", + "appearance": "ladder_down", + "appearance_defined_in": "arch/connect/exits/ladder/ladder.arc", + "stairwells": [ + { + "id": "SR18", + "premise": "7c7394a4451176950e74cbf647c9038d0b8ba5d30d6aa0668e9db95ec5733adc" + } + ], + "decided": "2026-10-19", + "decision": "stairwells whose exits have no stair archetype: the wells (SR17, SR18) show ladder_down and the invisible exit of #17 dark_cave (SW6) shows stairs_stone_down on their intermediate tiles" + }, + { + "exit_arch": "well_stone", + "appearance": "ladder_down", + "appearance_defined_in": "arch/connect/exits/ladder/ladder.arc", + "stairwells": [ + { + "id": "SR17", + "premise": "696b3636a020a3d31e0e6af411d9429923edcde41d65a6da37560788f1b0c5a7" + } + ], + "decided": "2026-10-19", + "decision": "stairwells whose exits have no stair archetype: the wells (SR17, SR18) show ladder_down and the invisible exit of #17 dark_cave (SW6) shows stairs_stone_down on their intermediate tiles" + } + ], + "owner_alignments": [ { "group": "/shattered_islands/strakewood_island/rockforge/rockforge_c", "anchor": "/shattered_islands/strakewood_island/rockforge/rockforge_b", "layer_delta": -1, - "why": "rockforge_a/_b/_c share the same 5x5 sheet naming (0101..ab05) and rockforge_a<->rockforge_b stairs connect identical local coordinates; rockforge_c ('Rockforge: Mines') is assumed to be the next sheet below rockforge_b at the same x/y." + "evidence_groups": [ + "/shattered_islands/strakewood_island/rockforge/rockforge_a", + "/shattered_islands/strakewood_island/rockforge/rockforge_b", + "/shattered_islands/strakewood_island/rockforge/rockforge_c" + ], + "premise": "b5e862b1b9db3eaa0363e85151637719769b24f8f3f5aba55654db8474b86ccb", + "decided": "2026-10-19", + "why": "owner decision 2026-10-19: the Rockforge mines ('Rockforge: Mines') are placed on the layer directly under rockforge_b at the same x/y (rockforge_a/_b/_c share the same 5x5 sheet naming 0101..ab05, and the rockforge_a<->rockforge_b stairs connect identical local coordinates)" + } + ], + "owner_no_footprint": [ + { + "group": "/shattered_islands/strakewood_island/rockforge/rockforge_c", + "decided": "2026-10-19", + "decision": "keep the Rockforge mines placed with no footprint: their 25 maps are empty headers, a footprint appears automatically when content is added and the plan is regenerated, and the spot is fixed, so a later collision makes the generator refuse" + } + ], + "owner_orientations": [ + { + "group": "/shattered_islands/eld_woods_island/cave_morlarg", + "orientation": "below", + "evidence": [ + "name keyword (below)" + ], + "premise": "927d5882eee6c9f761a0f044dd7b25623338f55ecebdaeda5658c9a83997bd3d", + "decided": "2026-10-19", + "decision": "'below' confirmed for a group entered only through neutral exits" + }, + { + "group": "/shattered_islands/strakewood_island/brynknot/sewers/lab", + "orientation": "below", + "evidence": [ + "name keyword (below)" + ], + "premise": "1b7505d0b301c9d656d628251139e1078688dccb2590d5eceb663f10683670b6", + "decided": "2026-10-19", + "decision": "'below' confirmed for a group entered only through neutral exits" + }, + { + "group": "/shattered_islands/strakewood_island/dark_cave/dark_cave", + "orientation": "below", + "evidence": [ + "name keyword (below)" + ], + "premise": "77a1804f4a83e6b4083293c1fa74e2b5893afa281bc0914bc64520fbbf46dafd", + "decided": "2026-10-19", + "decision": "'below' confirmed for a group entered only through neutral exits" + }, + { + "group": "/shattered_islands/strakewood_island/giant_stronghold/giant_stronghold", + "orientation": "below", + "evidence": [ + "name keyword (below)" + ], + "premise": "74e02789aba87bb33804b7ce5c4574786d5bb8118930b8b9b1783f7a0e0ea8a9", + "decided": "2026-10-19", + "decision": "'below' confirmed for a group entered only through neutral exits" } ], + "owner_classifications": [ + { + "group": "/hall_of_dms", + "classification": "separate", + "reason": "system", + "premise": "f62856ef0a335de5becf7234da9ea37232a19522b6df6a98133aad8cc796a224", + "decided": "2026-10-19", + "decision": "version 1 judgement call confirmed (Everlink and the Temple of Loki desert-plane chain are other_world, the Incuna lower decks are ships, hall_of_dms is a system map)" + }, + { + "group": "/shattered_islands/berri_lur/temple_loki/desert_plane/desert_plane", + "classification": "separate", + "reason": "other_world", + "premise": "25be0f8f36831865c4d557607e1b483fb981ff5bc31ab8acf6bd6f56d1e888d9", + "decided": "2026-10-19", + "decision": "version 1 judgement call confirmed (Everlink and the Temple of Loki desert-plane chain are other_world, the Incuna lower decks are ships, hall_of_dms is a system map)" + }, + { + "group": "/shattered_islands/berri_lur/temple_loki/desert_plane/entrance", + "classification": "separate", + "reason": "other_world", + "premise": "3d319765bc0e62816c33067f7c10ac845f05802a5f5e602dd9c0463f5e6ad252", + "decided": "2026-10-19", + "decision": "version 1 judgement call confirmed (Everlink and the Temple of Loki desert-plane chain are other_world, the Incuna lower decks are ships, hall_of_dms is a system map)" + }, + { + "group": "/shattered_islands/berri_lur/temple_loki/desert_plane/passage", + "classification": "separate", + "reason": "other_world", + "premise": "1e5bc4e079a004ba0c4c4969ca55ce1d8caa784f6e9922fe382fcec539569ef2", + "decided": "2026-10-19", + "decision": "version 1 judgement call confirmed (Everlink and the Temple of Loki desert-plane chain are other_world, the Incuna lower decks are ships, hall_of_dms is a system map)" + }, + { + "group": "/shattered_islands/berri_lur/temple_loki/desert_plane/unusual_garden", + "classification": "separate", + "reason": "other_world", + "premise": "7bf7f6471a24f33c0a01463f3f058f97533f88d2eb164d2fde80473c0c09125b", + "decided": "2026-10-19", + "decision": "version 1 judgement call confirmed (Everlink and the Temple of Loki desert-plane chain are other_world, the Incuna lower decks are ships, hall_of_dms is a system map)" + }, + { + "group": "/shattered_islands/everlink/everlink", + "classification": "separate", + "reason": "other_world", + "premise": "be278e9eeea6d8524a0c9f962507b450958575d20694d59dc1dd74776472fb3c", + "decided": "2026-10-19", + "decision": "version 1 judgement call confirmed (Everlink and the Temple of Loki desert-plane chain are other_world, the Incuna lower decks are ships, hall_of_dms is a system map)" + }, + { + "group": "/shattered_islands/incuna/ship_lower_deck", + "classification": "separate", + "reason": "ship", + "premise": "1f2cc9c8cb181ba6308e2fb69db18f6b936e0afedad44941cbb3a822e9c77d2f", + "decided": "2026-10-19", + "decision": "version 1 judgement call confirmed (Everlink and the Temple of Loki desert-plane chain are other_world, the Incuna lower decks are ships, hall_of_dms is a system map)" + }, + { + "group": "/shattered_islands/incuna/ship_lower_deck_to_incuna", + "classification": "separate", + "reason": "ship", + "premise": "d74a65d957119c1216d6a783b634e504f42a0165439c56e39eabb1b32117f00b", + "decided": "2026-10-19", + "decision": "version 1 judgement call confirmed (Everlink and the Temple of Loki desert-plane chain are other_world, the Incuna lower decks are ships, hall_of_dms is a system map)" + }, + { + "group": "/shattered_islands/incuna/ship_lower_deck_to_strakewood", + "classification": "separate", + "reason": "ship", + "premise": "21ee6f53b9f6c430728cfae798ef365ea15d379f03c991dcb430f536b9c2aa53", + "decided": "2026-10-19", + "decision": "version 1 judgement call confirmed (Everlink and the Temple of Loki desert-plane chain are other_world, the Incuna lower decks are ships, hall_of_dms is a system map)" + } + ], + "owner_relayering_judgement_calls": { + "decided": "2026-10-19", + "decision": "relayering judgement calls confirmed: R3's cave is lowered in place; U64 and U65 move down as whole caves, pushing ellamin and rockforge_a, _b and _c one layer deeper; the new seam stairs use the default archetypes; the storeys U2 and U3, partly over unmoved ground, move up", + "calls": [ + { + "kind": "storey partly over unmoved ground", + "unit": "U2", + "text": "U2 (world_3_45_1) moves up 1 layer(s); 17 of its 87 tiles stand on ground that stays" + }, + { + "kind": "storey partly over unmoved ground", + "unit": "U3", + "text": "U3 (world_3_45_1, world_3_46_1) moves up 1 layer(s); 18 of its 96 tiles stand on ground that stays" + }, + { + "kind": "cave resolution", + "unit": "U64", + "text": "U64 (3411 tiles, regions R7): lower whole cave; lowering the whole cave breaks 0 stair(s) and makes 0 steep step(s); lowering only the raised regions breaks 0 and makes 14" + }, + { + "kind": "cave resolution", + "unit": "U65", + "text": "U65 (2453 tiles, regions R8, R10, R12): lower whole cave; lowering the whole cave breaks 0 stair(s) and makes 0 steep step(s); lowering only the raised regions breaks 0 and makes 23" + }, + { + "kind": "cave resolution", + "unit": "R3's cave", + "text": "R3's cave (3047 tiles, regions R3): lower regions in place; lowering the whole cave would push 1 unit(s) below it down; lowering the whole cave breaks 22 stair(s) and makes 0 steep step(s); lowering only the raised regions breaks 18 and makes 4" + }, + { + "kind": "one-way stairwells", + "text": "12 (SR3, SR5, SR6, SR7, SR9, SR10, SR11, SR13, SR14, SR18, SR19, SR20) replace legacy exits that only go one way, so each has exits at one end and a landing at the other; making them two-way would add a way in that legacy does not have" + }, + { + "kind": "steep steps", + "text": "4 walkable edge(s) become a fall on the same layer" + }, + { + "kind": "new contacts", + "text": "2 tile pair(s) become neighbours on a layer" + }, + { + "kind": "split tiles", + "text": "200 tile(s) carry a low floor on sub-layer 0 and a raised floor on a higher sub-layer; the raised part moves, the ground part stays" + }, + { + "kind": "kept regions", + "text": "7 small raised region(s) (58 tiles) keep z as tile height" + }, + { + "kind": "new stairs", + "text": "46 new stair pair(s) keep the 44 cut seam piece(s) and every pocket they leave walkable; archetypes default to stairs_stone_up/down" + } + ], + "premise": "b98a773ceeb3cd3acb1a88967274cf0ddbdc168a977600979ccbc53dda92dce1", + "placement_changes_from_v1": [ + { + "group": "/shattered_islands/incuna/ellamin", + "v1": [ + 105, + 1991, + -4 + ], + "v2": [ + 105, + 1991, + -5 + ] + }, + { + "group": "/shattered_islands/strakewood_island/rockforge/rockforge_a", + "v1": [ + -13, + 1347, + -4 + ], + "v2": [ + -13, + 1347, + -5 + ] + }, + { + "group": "/shattered_islands/strakewood_island/rockforge/rockforge_b", + "v1": [ + -13, + 1347, + -5 + ], + "v2": [ + -13, + 1347, + -6 + ] + }, + { + "group": "/shattered_islands/strakewood_island/rockforge/rockforge_c", + "v1": [ + -13, + 1347, + -6 + ], + "v2": [ + -13, + 1347, + -7 + ] + } + ] + }, + "seam_stair_archetypes": { + "low": "stairs_stone_up", + "high": "stairs_stone_down", + "decided": "2026-10-19", + "decision": "the new seam stairs keep their default archetypes", + "defined_in": { + "high": "arch/connect/stairs/stairs_stone/stairs_stone.arc", + "low": "arch/connect/stairs/stairs_stone/stairs_stone.arc" + } + }, "overworld_map_size": 24, "excluded_trees": [ "python", @@ -106,6 +411,8 @@ "relayering": "overworld tiles are on their final layer everywhere in this plan (see relayering); occupied_rows_by_layer gives each map's tiles per final layer", "wired_exits": "an exit with form 'wired' led nowhere in legacy; it now targets the content on the layer above or below (see its 'wired' evidence)", "exits_kept_as_decoration": "dangling exits the owner keeps as decoration (no exit): not wired, with the landing they would have had", + "stairwell_appearance": "a stairwell's intermediate tiles show the down archetype of the first stair family among its exit archetypes (down-going exits first); failing that, the owner's fallback for the first exit archetype settings.stairwell_appearance_fallbacks lists (decision 2026-10-19)", + "owner_decisions": "settings.owner_* and the seam stair and appearance settings record the owner's decisions with their dates; a blocked stairwell site carries owner_decision when the owner keeps its exits portals (settings.owner_portals), and a placement with alignment_reason is the owner's fixed spot under its anchor (settings.owner_alignments), kept even without a footprint (settings.owner_no_footprint); confirmations (orientations, classifications, relayering judgement calls) equal what the plan derives; each decision is bound to the evidence it was made on (the evidence itself, or its premise: the SHA-256 of the evidence record's canonical JSON) and no longer applies when that evidence changes", "exit_inventory": "every exit object read from the content (inventory exits excluded), keyed by source_map and line: the content fields of its exit record with its legacy form and resolved target (none for an exit that leads nowhere), plus the facts its data problems come from; the exit records and data problems are checked against it" }, "summary": { @@ -113,13 +420,12 @@ "groups_total": 59, "non_overworld_groups": 58, "by_classification": { - "place:place": 27, + "place:place": 28, "separate:guild": 3, "separate:other_world": 7, "separate:per_player": 13, "separate:ship": 5, - "separate:system": 2, - "separate:unreachable": 1 + "separate:system": 2 }, "exit_objects": 1200, "inventory_exit_objects_ignored": 14, @@ -190,11 +496,10 @@ "/shattered_islands/eld_woods_island/clearhaven/treehome_3", "/shattered_islands/eld_woods_island/clearhaven/mine/mine_unique_a", "/shattered_islands/eld_woods_island/clearhaven/treehome_4", - "/shattered_islands/deserted_tutorial_island/ship_lower_deck" - ], - "tentative_placement_order": [ + "/shattered_islands/deserted_tutorial_island/ship_lower_deck", "/shattered_islands/strakewood_island/rockforge/rockforge_c" ], + "tentative_placement_order": [], "unplaced_place_groups": [], "unplaced_uncertain_groups": [], "failed_placements": [] @@ -921,8 +1226,8 @@ "mode": "clear", "max_bend": 1, "segments_drawn_only_on_player_layer": true, - "intermediate_appearance": null, - "intermediate_appearance_source": "no stair archetype among ['invis_exit', 'invis_exit']; owner to choose", + "intermediate_appearance": "stairs_stone_down", + "intermediate_appearance_source": "owner decision 2026-10-19: no stair archetype among ['invis_exit'], so the invis_exit exits show stairs_stone_down; the intermediate objects are decorations, not exits", "tiles": [ { "layer": 0, @@ -943,7 +1248,14 @@ "y": 1680, "role": "intermediate", "existing": false, - "objects": [] + "objects": [ + { + "archetype": "stairs_stone_down", + "exit": false, + "walkable": false, + "use": "appearance only" + } + ] }, { "layer": -2, @@ -951,7 +1263,14 @@ "y": 1681, "role": "intermediate", "existing": false, - "objects": [] + "objects": [ + { + "archetype": "stairs_stone_down", + "exit": false, + "walkable": false, + "use": "appearance only" + } + ] }, { "layer": -3, @@ -5365,8 +5684,8 @@ "mode": "clear", "max_bend": 0, "segments_drawn_only_on_player_layer": true, - "intermediate_appearance": null, - "intermediate_appearance_source": "no stair archetype among ['well_stone', 'well_bucket']; owner to choose", + "intermediate_appearance": "ladder_down", + "intermediate_appearance_source": "owner decision 2026-10-19: no stair archetype among ['well_stone', 'well_bucket'], so the well_stone exits show ladder_down; the intermediate objects are decorations, not exits", "tiles": [ { "layer": 1, @@ -5387,7 +5706,14 @@ "y": 1366, "role": "intermediate", "existing": false, - "objects": [] + "objects": [ + { + "archetype": "ladder_down", + "exit": false, + "walkable": false, + "use": "appearance only" + } + ] }, { "layer": -1, @@ -5395,7 +5721,14 @@ "y": 1366, "role": "intermediate", "existing": false, - "objects": [] + "objects": [ + { + "archetype": "ladder_down", + "exit": false, + "walkable": false, + "use": "appearance only" + } + ] }, { "layer": -2, @@ -5487,8 +5820,8 @@ "mode": "void-only", "max_bend": 0, "segments_drawn_only_on_player_layer": true, - "intermediate_appearance": null, - "intermediate_appearance_source": "no stair archetype among ['well_bucket']; owner to choose", + "intermediate_appearance": "ladder_down", + "intermediate_appearance_source": "owner decision 2026-10-19: no stair archetype among ['well_bucket'], so the well_bucket exits show ladder_down; the intermediate objects are decorations, not exits", "tiles": [ { "layer": 1, @@ -5504,7 +5837,14 @@ "y": 1382, "role": "intermediate", "existing": false, - "objects": [] + "objects": [ + { + "archetype": "ladder_down", + "exit": false, + "walkable": false, + "use": "appearance only" + } + ] }, { "layer": -1, @@ -6110,7 +6450,11 @@ "map": "/shattered_islands/strakewood_island/zechna_temple/zechna_temple_1_1" } ], - "reason": "no void chain within 2 tile(s) of the column" + "reason": "no void chain within 2 tile(s) of the column", + "owner_decision": { + "decided": "2026-10-19", + "decision": "Zechna's first hole stays a portal: no stairwell is built through zechna_temple_1_1, the floor in its column" + } } ], "sloped_stairwell_sites_blocked": [ @@ -26283,7 +26627,8 @@ "evidence": [ "names=['Hall of DMs']; regions=['creation']", "1 exit object(s) from the overworld lead here", - "DM hall; hub with exits to the Plane of Creation, guild oracle and several overworld towns (a DM/system map)." + "DM hall; hub with exits to the Plane of Creation, guild oracle and several overworld towns (a DM/system map).", + "owner decision 2026-10-19: system confirmed (a version 1 judgement call)" ], "reach_depth_from_overworld": null, "layout_root": "/hall_of_dms", @@ -31669,7 +32014,8 @@ "evidence": [ "names=['Desert Plane']; regions=['berri_lur']", "no exit object in the overworld leads here", - "part of the Temple of Loki 'Desert Plane' sequence (path contains /desert_plane/); owner directive: desert planes stay separate" + "part of the Temple of Loki 'Desert Plane' sequence (path contains /desert_plane/); owner directive: desert planes stay separate", + "owner decision 2026-10-19: other_world confirmed (a version 1 judgement call)" ], "reach_depth_from_overworld": null, "layout_root": "/shattered_islands/berri_lur/temple_loki/desert_plane/desert_plane_0101", @@ -31816,7 +32162,8 @@ "evidence": [ "names=['Desert Plane']; regions=['berri_lur']", "no exit object in the overworld leads here", - "part of the Temple of Loki 'Desert Plane' sequence (path contains /desert_plane/); owner directive: desert planes stay separate" + "part of the Temple of Loki 'Desert Plane' sequence (path contains /desert_plane/); owner directive: desert planes stay separate", + "owner decision 2026-10-19: other_world confirmed (a version 1 judgement call)" ], "reach_depth_from_overworld": null, "layout_root": "/shattered_islands/berri_lur/temple_loki/desert_plane/entrance", @@ -32316,7 +32663,8 @@ "evidence": [ "names=['Desert Plane']; regions=['berri_lur']", "no exit object in the overworld leads here", - "part of the Temple of Loki 'Desert Plane' sequence (path contains /desert_plane/); owner directive: desert planes stay separate" + "part of the Temple of Loki 'Desert Plane' sequence (path contains /desert_plane/); owner directive: desert planes stay separate", + "owner decision 2026-10-19: other_world confirmed (a version 1 judgement call)" ], "reach_depth_from_overworld": null, "layout_root": "/shattered_islands/berri_lur/temple_loki/desert_plane/passage_0101", @@ -32888,7 +33236,8 @@ "evidence": [ "names=['An Unusual Garden']; regions=['berri_lur']", "no exit object in the overworld leads here", - "part of the Temple of Loki 'Desert Plane' sequence (path contains /desert_plane/); owner directive: desert planes stay separate" + "part of the Temple of Loki 'Desert Plane' sequence (path contains /desert_plane/); owner directive: desert planes stay separate", + "owner decision 2026-10-19: other_world confirmed (a version 1 judgement call)" ], "reach_depth_from_overworld": null, "layout_root": "/shattered_islands/berri_lur/temple_loki/desert_plane/unusual_garden", @@ -34128,7 +34477,8 @@ }, "orientation": "below", "orientation_evidence": [ - "name keyword (below)" + "name keyword (below)", + "owner decision 2026-10-19: 'below' confirmed for a group entered only through neutral exits" ], "vertical_bounds_from_exit_semantics": [ null, @@ -38085,7 +38435,8 @@ "no exit object in the overworld leads here", "not reachable from the overworld through exit objects; no exit object leads here at all", "referenced by scripts: ['maps/python/House.py']", - "region 'everlink' description: 'No one knows where this place is in the world, but only proud house owners can access it.'; only entered by scripts (python/houses/exit.py via House.py)" + "region 'everlink' description: 'No one knows where this place is in the world, but only proud house owners can access it.'; only entered by scripts (python/houses/exit.py via House.py)", + "owner decision 2026-10-19: other_world confirmed (a version 1 judgement call)" ], "reach_depth_from_overworld": null, "layout_root": "/shattered_islands/everlink/everlink_0101", @@ -38672,7 +39023,8 @@ "evidence": [ "names=['Lower Deck']; regions=['incuna']", "1 exit object(s) from the overworld lead here", - "ship lower deck used for sea travel: entered from ['/shattered_islands/world_-6_79'] but exits to ['/shattered_islands/world_4_85']; NPC dialog teleports here (maps/interfaces/ocean/sam_goodberry.xml)" + "ship lower deck used for sea travel: entered from ['/shattered_islands/world_-6_79'] but exits to ['/shattered_islands/world_4_85']; NPC dialog teleports here (maps/interfaces/ocean/sam_goodberry.xml)", + "owner decision 2026-10-19: ship confirmed (a version 1 judgement call)" ], "reach_depth_from_overworld": null, "layout_root": "/shattered_islands/incuna/ship_lower_deck", @@ -38827,7 +39179,8 @@ "evidence": [ "names=['Lower Deck']; regions=['incuna']", "1 exit object(s) from the overworld lead here", - "ship lower deck used for sea travel: entered from ['/shattered_islands/world_2_87'] but exits to ['/shattered_islands/world_4_85']; NPC dialog teleports here (maps/interfaces/ocean/sam_goodberry_to_incuna.xml)" + "ship lower deck used for sea travel: entered from ['/shattered_islands/world_2_87'] but exits to ['/shattered_islands/world_4_85']; NPC dialog teleports here (maps/interfaces/ocean/sam_goodberry_to_incuna.xml)", + "owner decision 2026-10-19: ship confirmed (a version 1 judgement call)" ], "reach_depth_from_overworld": null, "layout_root": "/shattered_islands/incuna/ship_lower_deck_to_incuna", @@ -38982,7 +39335,8 @@ "evidence": [ "names=['Lower Deck']; regions=['strakewood_island']", "1 exit object(s) from the overworld lead here", - "ship lower deck used for sea travel: entered from ['/shattered_islands/world_0_83'] but exits to ['/shattered_islands/world_0_70']; NPC dialog teleports here (maps/interfaces/ocean/sam_goodberry_to_strakewood.xml)" + "ship lower deck used for sea travel: entered from ['/shattered_islands/world_0_83'] but exits to ['/shattered_islands/world_0_70']; NPC dialog teleports here (maps/interfaces/ocean/sam_goodberry_to_strakewood.xml)", + "owner decision 2026-10-19: ship confirmed (a version 1 judgement call)" ], "reach_depth_from_overworld": null, "layout_root": "/shattered_islands/incuna/ship_lower_deck_to_strakewood", @@ -42994,7 +43348,8 @@ }, "orientation": "below", "orientation_evidence": [ - "name keyword (below)" + "name keyword (below)", + "owner decision 2026-10-19: 'below' confirmed for a group entered only through neutral exits" ], "vertical_bounds_from_exit_semantics": [ null, @@ -43501,7 +43856,8 @@ }, "orientation": "below", "orientation_evidence": [ - "name keyword (below)" + "name keyword (below)", + "owner decision 2026-10-19: 'below' confirmed for a group entered only through neutral exits" ], "vertical_bounds_from_exit_semantics": [ null, @@ -43824,7 +44180,8 @@ }, "orientation": "below", "orientation_evidence": [ - "name keyword (below)" + "name keyword (below)", + "owner decision 2026-10-19: 'below' confirmed for a group entered only through neutral exits" ], "vertical_bounds_from_exit_semantics": [ null, @@ -51710,12 +52067,12 @@ { "id": "/shattered_islands/strakewood_island/rockforge/rockforge_c", "map_count": 25, - "classification": "separate", - "reason": "unreachable", + "classification": "place", + "reason": "place", "evidence": [ "names=['Rockforge: Mines']; regions=['rockforge_mines']", "no exit object in the overworld leads here", - "not reachable from the overworld through exit objects; no exit object leads here at all" + "no exit object leads here or out; owner decision 2026-10-19: the Rockforge mines ('Rockforge: Mines') are placed on the layer directly under rockforge_b at the same x/y (rockforge_a/_b/_c share the same 5x5 sheet naming 0101..ab05, and the rockforge_a<->rockforge_b stairs connect identical local coordinates)" ], "reach_depth_from_overworld": null, "layout_root": "/shattered_islands/strakewood_island/rockforge/rockforge_c_0101", @@ -52907,7 +53264,7 @@ "entrances_from_other_groups": [], "exits_to_other_groups": [], "placement": { - "status": "tentative", + "status": "proposed", "layer_offset": -7, "x_offset": -13, "y_offset": 1347, @@ -53073,7 +53430,7 @@ "placement_index": 27, "main_stairwell_blocked": false, "crossing_exits_after_placement": {}, - "tentative_reason": "rockforge_a/_b/_c share the same 5x5 sheet naming (0101..ab05) and rockforge_a<->rockforge_b stairs connect identical local coordinates; rockforge_c ('Rockforge: Mines') is assumed to be the next sheet below rockforge_b at the same x/y." + "alignment_reason": "owner decision 2026-10-19: the Rockforge mines ('Rockforge: Mines') are placed on the layer directly under rockforge_b at the same x/y (rockforge_a/_b/_c share the same 5x5 sheet naming 0101..ab05, and the rockforge_a<->rockforge_b stairs connect identical local coordinates)" } }, { diff --git a/migration/placement-plan/scripts/build_plan.py b/migration/placement-plan/scripts/build_plan.py index 50d20fb..51deb03 100644 --- a/migration/placement-plan/scripts/build_plan.py +++ b/migration/placement-plan/scripts/build_plan.py @@ -4,10 +4,12 @@ Read-only against the content checkout, which must be clean under arch/ and maps/. Relayers the overworld (owner decision 2026-10-18: fix the conflicts, then relayer), places the groups on the result, builds stairwells (aligned, -sloped and relayering ones) and wires dangling exits. Generates -placement-plan.json, REPORT.md, layer_[_zoom].png and relayer_.png in a -staging directory, verifies the staged plan, and only then replaces the -artifacts in the output directory. Standard library only. +sloped and relayering ones) and wires dangling exits. Applies the owner +decisions of 2026-10-19 (the OWNER_* constants) and refuses to publish when one +no longer matches what it decides. Generates placement-plan.json, REPORT.md, +layer_[_zoom].png and relayer_.png in a staging directory, verifies the +staged plan, and only then replaces the artifacts in the output directory. +Standard library only. python3 -I scripts/build_plan.py [--content DIR] [--out DIR] [--allow-dirty] [--check] @@ -77,17 +79,6 @@ OTHER_WORLD_PATH_PARTS = ("/desert_plane/",) # owner: "desert planes" stay separate GUILD_PREFIXES = ("/guilds/",) SHIP_PREFIXES = ("/shattered_islands/ships/",) -# Tentative alignment for groups that have no exit connection at all but whose -# naming shows they belong under another group (used only for a tentative -# placement the owner may accept or reject). -TENTATIVE_ALIGN = { - "/shattered_islands/strakewood_island/rockforge/rockforge_c": ( - "/shattered_islands/strakewood_island/rockforge/rockforge_b", -1, - "rockforge_a/_b/_c share the same 5x5 sheet naming (0101..ab05) and " - "rockforge_a<->rockforge_b stairs connect identical local coordinates; " - "rockforge_c ('Rockforge: Mines') is assumed to be the next sheet below " - "rockforge_b at the same x/y."), -} # Settled owner decisions (2026-10): these groups are placed in the world. OWNER_PLACE = { @@ -110,6 +101,151 @@ for (x, y) in ((2, 1), (2, 7), (2, 13), (13, 1), (13, 7), (13, 13)) } +# ------------------------------------------------ owner decisions 2026-10-19 +# The owner's answers to the six questions version 2 left open. Each is an +# input here and is recorded with its date in the JSON settings. Decisions 1-3 +# change the plan; decisions 4-6 confirm what the rules derived. A decision is +# bound to the evidence it was made on (the record itself, or its premise(): a +# SHA-256 of the record's canonical JSON); the generator refuses to publish when +# that evidence changed (the owner must be asked again; the refusal names the +# new premise), and verify_plan.py re-checks the binding in the JSON. +DECIDED_2026_10_19 = "2026-10-19" +ROCKFORGE = "/shattered_islands/strakewood_island/rockforge/" +# Decision 1: these exits stay portals where their stairwell is blocked; no +# stairwell is forced through the content in its way. Each is one exit object +# (source map, line, tile, archetype); its premise covers portal_evidence(): the +# blocked site record (owners, ends, exits, column occupants) and the records of +# its exits (targets included). +OWNER_PORTALS = { + ("/shattered_islands/world_7_66_-1", 938, (13, 15), "hole"): { + "premise": "ad3f43acaee60f6c1459523c8af820f3fdbe384f780a3468ef8f0fab23e0cf20", + "decision": "Zechna's first hole stays a portal: no stairwell is built through zechna_temple_1_1, the floor " + "in its column"}, +} +# Decision 2: a stairwell whose exits belong to no stair family (STAIR_FAMILIES) +# shows, on its intermediate tiles, the archetype chosen for the first of its +# exit archetypes listed here (down-going exits first, each set sorted). Each +# fallback is bound to the stairwells the owner chose it for: (id as the owner +# saw it, stairwell_premise()). An id is positional; the premise is the identity. +STAIRWELL_APPEARANCE_FALLBACKS = { + "invis_exit": ("stairs_stone_down", (("SW6", "f2adfb3c19b1fb4bee44f296e5071e1ddb6c7390cd0e8539bec6c4f5cb0f55f4"),)), + "well_bucket": ("ladder_down", (("SR18", "7c7394a4451176950e74cbf647c9038d0b8ba5d30d6aa0668e9db95ec5733adc"),)), + "well_stone": ("ladder_down", (("SR17", "696b3636a020a3d31e0e6af411d9429923edcde41d65a6da37560788f1b0c5a7"),)), +} +# The evidence a stairwell's premise covers: its kind, owners, direction and every exit it replaces (tiles, +# targets, archetypes, plane ends). +STAIRWELL_PREMISE_FIELDS = ("kind", "group", "owner", "other", "connects", "direction", "replaces") +STAIRWELL_APPEARANCE_DECISION = ( + "stairwells whose exits have no stair archetype: the wells (SR17, SR18) show ladder_down and the invisible " + "exit of #17 dark_cave (SW6) shows stairs_stone_down on their intermediate tiles") +# Decision 3: groups without an exit connection placed at a fixed layer from an +# anchor group, at the anchor's x/y (no search: the spot is the decision). The +# premise covers alignment_evidence() of the evidence groups the reason rests +# on: their sheet layouts and the exit records between them. +OWNER_ALIGN = { + ROCKFORGE + "rockforge_c": { + "anchor": ROCKFORGE + "rockforge_b", "layer_delta": -1, + "evidence_groups": (ROCKFORGE + "rockforge_a", ROCKFORGE + "rockforge_b", ROCKFORGE + "rockforge_c"), + "premise": "b5e862b1b9db3eaa0363e85151637719769b24f8f3f5aba55654db8474b86ccb", + "why": "owner decision 2026-10-19: the Rockforge mines ('Rockforge: Mines') are placed on the layer directly " + "under rockforge_b at the same x/y (rockforge_a/_b/_c share the same 5x5 sheet naming 0101..ab05, and " + "the rockforge_a<->rockforge_b stairs connect identical local coordinates)"}, +} +ALIGN_LAYOUT_FIELDS = ("path", "width", "height", "local_offset", "tile_links") # per member map, in group order +# Decision 7 (2026-10-19, the owner's answer to the question this plan raised): +# these placed groups stay placed although no member map holds an object, so +# they occupy no tile. By the owner's reasoning the decision stays valid when +# content is added: the footprint then appears when the plan is regenerated, +# and the spot is fixed (decision 3), so a collision makes the generator refuse. +# Any other placed group without a footprint needs an owner decision first. +OWNER_NO_FOOTPRINT = { + ROCKFORGE + "rockforge_c": + "keep the Rockforge mines placed with no footprint: their 25 maps are empty headers, a footprint appears " + "automatically when content is added and the plan is regenerated, and the spot is fixed, so a later " + "collision makes the generator refuse", +} +# Decision 4: the orientation derived for these groups is confirmed, with the +# evidence it came from: a name keyword, no exit with a direction. +# (orientation, derived evidence, premise of the group's content: group_evidence()) +KEYWORD_BELOW = ("name keyword (below)",) +OWNER_ORIENTATIONS = { + "/shattered_islands/eld_woods_island/cave_morlarg": ("below", KEYWORD_BELOW, "927d5882eee6c9f761a0f044dd7b25623338f55ecebdaeda5658c9a83997bd3d"), + "/shattered_islands/strakewood_island/brynknot/sewers/lab": ("below", KEYWORD_BELOW, "1b7505d0b301c9d656d628251139e1078688dccb2590d5eceb663f10683670b6"), + "/shattered_islands/strakewood_island/dark_cave/dark_cave": ("below", KEYWORD_BELOW, "77a1804f4a83e6b4083293c1fa74e2b5893afa281bc0914bc64520fbbf46dafd"), + "/shattered_islands/strakewood_island/giant_stronghold/giant_stronghold": ("below", KEYWORD_BELOW, "74e02789aba87bb33804b7ce5c4574786d5bb8118930b8b9b1783f7a0e0ea8a9"), +} +ORIENTATION_DECISION = "'below' confirmed for a group entered only through neutral exits" +# Decision 5: version 1's classification judgement calls are confirmed, each +# with the premise of {"evidence": the evidence the rules gave for it, "group": +# group_evidence() of its record} (its maps, exits and script references). +LOKI_DESERT = "/shattered_islands/berri_lur/temple_loki/desert_plane/" +OWNER_CLASSIFICATIONS = { + "/shattered_islands/everlink/everlink": + ("separate", "other_world", "be278e9eeea6d8524a0c9f962507b450958575d20694d59dc1dd74776472fb3c"), + LOKI_DESERT + "desert_plane": + ("separate", "other_world", "25be0f8f36831865c4d557607e1b483fb981ff5bc31ab8acf6bd6f56d1e888d9"), + LOKI_DESERT + "entrance": + ("separate", "other_world", "3d319765bc0e62816c33067f7c10ac845f05802a5f5e602dd9c0463f5e6ad252"), + LOKI_DESERT + "passage": + ("separate", "other_world", "1e5bc4e079a004ba0c4c4969ca55ce1d8caa784f6e9922fe382fcec539569ef2"), + LOKI_DESERT + "unusual_garden": + ("separate", "other_world", "7bf7f6471a24f33c0a01463f3f058f97533f88d2eb164d2fde80473c0c09125b"), + "/shattered_islands/incuna/ship_lower_deck": + ("separate", "ship", "1f2cc9c8cb181ba6308e2fb69db18f6b936e0afedad44941cbb3a822e9c77d2f"), + "/shattered_islands/incuna/ship_lower_deck_to_incuna": + ("separate", "ship", "d74a65d957119c1216d6a783b634e504f42a0165439c56e39eabb1b32117f00b"), + "/shattered_islands/incuna/ship_lower_deck_to_strakewood": + ("separate", "ship", "21ee6f53b9f6c430728cfae798ef365ea15d379f03c991dcb430f536b9c2aa53"), + "/hall_of_dms": ("separate", "system", "f62856ef0a335de5becf7234da9ea37232a19522b6df6a98133aad8cc796a224"), +} +CLASSIFICATION_DECISION = ("version 1 judgement call confirmed (Everlink and the Temple of Loki desert-plane chain " + "are other_world, the Incuna lower decks are ships, hall_of_dms is a system map)") +# Decision 6: the relayering judgement calls are confirmed as listed, and with +# them the placements the lowered caves push one layer deeper. The new seam +# stairs keep their default archetypes. +SEAM_STAIR_ARCHETYPES = {"low": "stairs_stone_up", "high": "stairs_stone_down"} +RELAYERING_DECISION = ("relayering judgement calls confirmed: R3's cave is lowered in place; U64 and U65 move down as " + "whole caves, pushing ellamin and rockforge_a, _b and _c one layer deeper; the new seam stairs " + "use the default archetypes; the storeys U2 and U3, partly over unmoved ground, move up") +OWNER_RELAYERING_JUDGEMENT_CALLS = ( + ("storey partly over unmoved ground", "U2", + "U2 (world_3_45_1) moves up 1 layer(s); 17 of its 87 tiles stand on ground that stays"), + ("storey partly over unmoved ground", "U3", + "U3 (world_3_45_1, world_3_46_1) moves up 1 layer(s); 18 of its 96 tiles stand on ground that stays"), + ("cave resolution", "U64", + "U64 (3411 tiles, regions R7): lower whole cave; lowering the whole cave breaks 0 stair(s) and makes 0 steep " + "step(s); lowering only the raised regions breaks 0 and makes 14"), + ("cave resolution", "U65", + "U65 (2453 tiles, regions R8, R10, R12): lower whole cave; lowering the whole cave breaks 0 stair(s) and makes " + "0 steep step(s); lowering only the raised regions breaks 0 and makes 23"), + ("cave resolution", "R3's cave", + "R3's cave (3047 tiles, regions R3): lower regions in place; lowering the whole cave would push 1 unit(s) below " + "it down; lowering the whole cave breaks 22 stair(s) and makes 0 steep step(s); lowering only the raised " + "regions breaks 18 and makes 4"), + ("one-way stairwells", None, + "12 (SR3, SR5, SR6, SR7, SR9, SR10, SR11, SR13, SR14, SR18, SR19, SR20) replace legacy exits that only go one " + "way, so each has exits at one end and a landing at the other; making them two-way would add a way in that " + "legacy does not have"), + ("steep steps", None, "4 walkable edge(s) become a fall on the same layer"), + ("new contacts", None, "2 tile pair(s) become neighbours on a layer"), + ("split tiles", None, + "200 tile(s) carry a low floor on sub-layer 0 and a raised floor on a higher sub-layer; the raised part moves, " + "the ground part stays"), + ("kept regions", None, "7 small raised region(s) (58 tiles) keep z as tile height"), + ("new stairs", None, + "46 new stair pair(s) keep the 44 cut seam piece(s) and every pocket they leave walkable; archetypes default to " + "stairs_stone_up/down"), +) +# The premise of relayering_evidence(): the calls and every record they summarise. +OWNER_RELAYERING_PREMISE = "b98a773ceeb3cd3acb1a88967274cf0ddbdc168a977600979ccbc53dda92dce1" +# (x_offset, y_offset, layer_offset) of version 1 -> this plan, under /shattered_islands/ +OWNER_PLACEMENT_CHANGES_FROM_V1 = ( + ("incuna/ellamin", (105, 1991, -4), (105, 1991, -5)), + ("strakewood_island/rockforge/rockforge_a", (-13, 1347, -4), (-13, 1347, -5)), + ("strakewood_island/rockforge/rockforge_b", (-13, 1347, -5), (-13, 1347, -6)), + ("strakewood_island/rockforge/rockforge_c", (-13, 1347, -6), (-13, 1347, -7)), +) + # Placements of plan v1 (PR #44, toolkit 7ba54b0, content b9657299a6e7): # (x_offset, y_offset, layer_offset) per placed group id under /shattered_islands/. # Used only to report which placements this version changes. @@ -298,6 +434,167 @@ def stair_family_down(arch: str): return None +def premise(evidence) -> str: + """Fingerprint of the evidence an owner decision was made on: SHA-256 of its canonical JSON (taken as the + plan's JSON holds it, so the generator and the verifier hash the same text).""" + canon = json.loads(json.dumps(evidence)) + return hashlib.sha256(json.dumps(canon, sort_keys=True, separators=(",", ":")).encode()).hexdigest() + + +# What a premise covers, from the plan's JSON records (verify_plan.py has its own copy of each). +EXIT_LISTS = ("entrances_from_overworld", "exits_to_overworld", "entrances_from_other_groups", "exits_to_other_groups", + "internal_exits") +EXIT_CONTENT_FIELDS = ("source_map", "line", "source_tile", "target_map", "target_tile", "arch", "form", "vertical", + "vertical_inferred_from") +GROUP_MAP_FIELDS = ("path", "name", "region", "width", "height", "local_offset", "tile_links", "occupied_tiles") + + +def group_evidence(rec: dict) -> dict: + """A group's content as the rules see it: its maps (names, regions, sizes, layout, tile counts), the content + fields of every exit record it holds, and its script references.""" + exits = {} + for key in EXIT_LISTS[:4]: + for e in rec.get(key, []): + exits[(e["source_map"], e["line"])] = {f: e.get(f) for f in EXIT_CONTENT_FIELDS} + return {"maps": [{k: m.get(k) for k in GROUP_MAP_FIELDS} for m in rec["maps"]], + "exits": [exits[k] for k in sorted(exits)], + "scripts": rec.get("script_and_dialog_references", []), "redirects": rec.get("script_redirects", [])} + + +def portal_evidence(site: dict, records: dict) -> dict: + """A blocked stairwell site as a portal decision sees it: the site record (owners, ends, exits, column + occupants, reason) and the full exit record of each of its exits.""" + return {"site": {k: v for k, v in site.items() if k != "owner_decision"}, + "exits": [records.get((x["source_map"], x["line"])) for x in site["exits"]]} + + +def relayering_evidence(rl: dict, stairwells: list) -> dict: + """What the relayering judgement calls summarise: the calls and every record behind them (regions, kept + regions, moved units, caves, split tiles, seams, new stairs, steep steps, new contacts) and the one-way + relayering stairwells.""" + out = {k: rl[k] for k in ("judgement_calls", "regions", "kept_regions", "units_moved", "caves", "split_tiles", + "seams", "new_stairs", "steep_steps", "new_contacts")} + out["one_way_stairwells"] = [{k: sw[k] for k in STAIRWELL_PREMISE_FIELDS} for sw in stairwells + if sw["kind"] == "relayering" and sw["direction"] != "both"] + return out + + +def alignment_evidence(recs: dict, gids) -> dict: + """What an owner alignment rests on, from the JSON group records `recs` (id -> record) of the groups `gids`: + each one's sheet layout (ALIGN_LAYOUT_FIELDS per member map) and the content fields of every exit record + between two of them.""" + gids = list(gids) + exits = {} + for gid in gids: + for key in ("entrances_from_other_groups", "exits_to_other_groups"): + for e in recs[gid].get(key, []): + if e["source_group"] in gids and e["target_group"] in gids: + exits[(e["source_map"], e["line"])] = [e["source_map"], e["source_tile"], e["target_map"], + e["target_tile"], e["arch"]] + return {"layouts": {gid: [{k: m[k] for k in ALIGN_LAYOUT_FIELDS} for m in recs[gid]["maps"]] for gid in gids}, + "exits": [exits[k] for k in sorted(exits)]} + + +def stairwell_premise(sw: dict) -> str: + """The premise of a stairwell record: what a decision about it was made on (STAIRWELL_PREMISE_FIELDS).""" + return premise({k: sw[k] for k in STAIRWELL_PREMISE_FIELDS}) + + +def stairwell_appearance(exit_archs: list): + """(archetype, source text) shown on a stairwell's intermediate tiles, or (None, text) when there is none. + + `exit_archs` are the archetypes of the stairwell's exits, the down-going ones + first, each set sorted. The first one that belongs to a stair family gives the + family's down archetype. Failing that, the first one with an owner-chosen + fallback (decision 2026-10-19) gives that fallback. The third value is the + exit archetype whose fallback was used, else None.""" + exit_archs = list(dict.fromkeys(exit_archs)) # an archetype at both ends is named once + for a in exit_archs: + fam = stair_family_down(a) + if fam: + return fam, ("appearance of the down archetype of the {} family used by this stairwell's exits; the " + "intermediate objects are decorations, not exits".format(fam.rsplit("_", 1)[0])), None + for a in exit_archs: + app = STAIRWELL_APPEARANCE_FALLBACKS.get(a.casefold(), (None,))[0] + if app: + return app, ("owner decision {}: no stair archetype among {}, so the {} exits show {}; the intermediate " + "objects are decorations, not exits".format(DECIDED_2026_10_19, exit_archs, a, app)), \ + a.casefold() + return None, "no stair archetype among {}; owner to choose".format(exit_archs), None + + +def seam_stairs_text() -> str: + """The seam stair archetypes as the judgement call names them (stairs_stone_up/down).""" + low, high = SEAM_STAIR_ARCHETYPES["low"], SEAM_STAIR_ARCHETYPES["high"] + common = os.path.commonprefix([low, high]) + common = common[:common.rfind("_") + 1] + return "{}/{}".format(low, high[len(common):]) + + +def check_decision_groups(group_ids: set): + """Every group an owner decision names (aligned groups, their anchors and evidence groups, groups kept placed + without a footprint, confirmed orientations and classifications) must still exist: SystemExit otherwise.""" + named = set(OWNER_ALIGN) | set(OWNER_NO_FOOTPRINT) | set(OWNER_ORIENTATIONS) | set(OWNER_CLASSIFICATIONS) + for d in OWNER_ALIGN.values(): + named |= {d["anchor"], *d["evidence_groups"]} + missing = sorted(named - set(group_ids)) + if missing: + raise SystemExit("owner decisions of {} name groups the content no longer has: {}; ask the owner " + "again".format(DECIDED_2026_10_19, missing)) + + +def portal_key(x: dict) -> tuple: + """The identity an owner portal decision names: one exit object (source map, line, tile, archetype).""" + return x["source_map"], x["line"], tuple(x["source_tile"]), x["arch"] + + +def apply_owner_portals(sites: list, decisions: dict, records: dict): + """Owner decision 1 (2026-10-19): mark the blocked stairwell sites whose exits the owner keeps portals. + + `decisions` maps portal_key() to the decision: its text and the premise() of + the portal_evidence() it was made on (the site record and the exit records, + from `records`, keyed by source map and line). A site whose exits are all + decided, on that same evidence, carries the decision (owner_decision). A + site decided for only some of its exits or whose evidence changed (other + ends, owners, exits, targets or column occupants), or a decision for an exit + at no blocked site (no longer blocked, or not the same object), no longer + applies: SystemExit.""" + decided_at = set() + for site in sites: + keys = [portal_key(x) for x in site["exits"]] + found = [decisions.get(k) for k in keys] + if not any(found): + continue + texts = {d["decision"] if d else None for d in found} + now = premise(portal_evidence(site, records)) + if len(texts) != 1 or any(d["premise"] != now for d in found): + raise SystemExit("owner decision {} keeps the blocked stairwell site {} -> {} a portal only for some of " + "its exits {} or for another site record (its premise is now {}); ask the owner " + "again".format(DECIDED_2026_10_19, site["top"], site["bottom"], keys, now)) + site["owner_decision"] = {"decided": DECIDED_2026_10_19, "decision": texts.pop()} + decided_at.update(keys) + stale = sorted(k for k in decisions if k not in decided_at) + if stale: + raise SystemExit("owner decision {} keeps exits a portal that are not at a blocked stairwell site (no longer " + "blocked, or not the same exit object): {}; ask the owner again".format( + DECIDED_2026_10_19, stale)) + + +def check_judgement_calls(judgement: list, confirmed: tuple) -> list: + """Owner decision 6 (2026-10-19): the relayering judgement calls must be exactly the confirmed ones. + + `confirmed` holds (kind, unit or None, text); returns them as judgement-call + records, or raises SystemExit naming the calls that differ.""" + calls = [dict({"kind": k}, **({"unit": u} if u is not None else {}), text=t) for k, u, t in confirmed] + if judgement != calls: + new_ = [j for j in judgement if j not in calls] + gone = [j for j in calls if j not in judgement] + raise SystemExit("owner decision {} confirmed the relayering judgement calls as they were; they now differ " + "(new or changed: {}; no longer made: {}); ask the owner again".format( + DECIDED_2026_10_19, new_[:3], gone[:3])) + return calls + + def occupied_rows(rec) -> list: return ["".join("#" if (x, y) in rec.occupied else "." for x in range(rec.width)) for y in range(rec.height)] @@ -393,6 +690,7 @@ def build(content: Path, allow_dirty: bool = False): gof[m] = gid ow_id = gof[an.OVERWORLD_ROOT] G = {g["id"]: g for g in groups} + check_decision_groups(set(G)) link_problems = an.link_reciprocity(maps) for g in groups: @@ -498,6 +796,11 @@ def classify(g): if start_map and start_map in mem: extra.append("NEW-CHARACTER START MAP (arch/intern/map.arc slaying {})".format(start_map)) return "place", "place", ev + extra + [OWNER_PLACE[gid]] + if gid in OWNER_ALIGN: + if g["entrances"] or g["exits_out"]: + raise SystemExit("owner decision {} aligns {} under {}, but the group now has exits of its own; ask " + "the owner again".format(DECIDED_2026_10_19, gid, OWNER_ALIGN[gid]["anchor"])) + return "place", "place", ev + ["no exit object leads here or out; {}".format(OWNER_ALIGN[gid]["why"])] base = posixpath.basename(gid) if base.startswith("ship_") or "/ship_" in gid: dialog = [r for r in g["script_refs"] if r["file"].startswith("maps/interfaces/")] @@ -586,6 +889,16 @@ def bfs(allowed): g["evidence"].append("region '{}' description: {!r}; only entered by scripts " "(python/houses/exit.py via House.py)".format( rg, re.search(r"msg\n(.*?)\nendmsg", txt, re.S).group(1))) + # Owner decision 5 (2026-10-19): the classifications the owner confirmed must still be what the rules derive + # (on the same evidence: checked with the group's JSON record below). + for gid, (cls, rsn, _premise) in sorted(OWNER_CLASSIFICATIONS.items()): + got = (G[gid]["classification"], G[gid]["reason"]) if gid in G else None + if got != (cls, rsn): + raise SystemExit("owner decision {} confirmed {} as {}/{}, but the rules now give {}; ask the owner " + "again".format(DECIDED_2026_10_19, gid, cls, rsn, "/".join(got) if got else + "no such group")) + G[gid]["evidence"].append("owner decision {}: {} confirmed (a version 1 judgement call)".format( + DECIDED_2026_10_19, rsn)) # ---------------------------------------------------------- relayering # Owner decision (2026-10-18): fix the conflicts, then relayer. Every group @@ -693,6 +1006,7 @@ class S: # mutable state of the current placement run slope_failed: list = [] slope_failed_exit: dict = {} relayer_blocked: list = [] + fallback_of: dict = {} def local_tile(gid, m, x, y): ox, oy, _oz = G[gid]["layout"]["offsets"][m] @@ -731,6 +1045,19 @@ def upstream(other): d = G[other].get("reach_depth") if other != ow_id else 0 return d is not None and depth is not None and d < depth + orient, why = derived_orientation(g, votes_for, upstream) + # Owner decision 4 (2026-10-19): a confirmed orientation must still be the derived one, from the same + # evidence (a name keyword, no exit with a direction). + decided = OWNER_ORIENTATIONS.get(g["id"]) + if decided is not None: + if decided[:2] != (orient, tuple(why)): + raise SystemExit("owner decision {} confirmed '{}' for {} from {}, but the rules now give '{}' from " + "{}; ask the owner again".format(DECIDED_2026_10_19, decided[0], g["id"], + list(decided[1]), orient, why)) + why = why + ["owner decision {}: {}".format(DECIDED_2026_10_19, ORIENTATION_DECISION)] + return orient, why + + def derived_orientation(g, votes_for, upstream): why = [] votes = votes_for(upstream) if votes: @@ -930,10 +1257,11 @@ def score(c): # Preference: (1) unshifted with a buildable stairwell (any extra # depth); (2) unshifted, footprint fits but the main stairwell is # blocked (reported; a shift would turn it into a portal anyway and - # lose the x/y alignment); (3) horizontal shifts. + # lose the x/y alignment); (3) horizontal shifts. A forced spot (an + # owner alignment) is the decision itself: it is tried alone. fallback = None - for r in range(0, MAX_SHIFT + 1): - for k in range(0, MAX_EXTRA_LAYERS + 1): + for r in range(0, (0 if forced is not None else MAX_SHIFT) + 1): + for k in range(0, (0 if forced is not None else MAX_EXTRA_LAYERS) + 1): L = ideal["L"] + step * k if not lo <= L <= hi: continue # never cross a vertical bound from the exit semantics @@ -1013,13 +1341,9 @@ def make_stairwell(kind, owner, other, top, bottom, site, mode, chain): "plane_source": list(o["src"]), "plane_target": list(o["dst"])} for o in site] top_objs = sorted({o["e"]["arch"] for o in site if o["src_is_upper"]}) bot_objs = sorted({o["e"]["arch"] for o in site if not o["src_is_upper"]}) - fam = None - for a in top_objs + bot_objs: - fam = stair_family_down(a) - if fam: - break + fam, fam_source, fallback = stairwell_appearance(top_objs + bot_objs) # Top and bottom keep their existing exit objects. Intermediate tiles get - # a decoration that only borrows the family archetype's appearance: it is + # a decoration that only borrows the chosen archetype's appearance: it is # not an exit (no exit type, destination or tiled link) and not walkable, # so the stairwell stays ONE exit. tiles = [{"layer": top[2], "x": top[0], "y": top[1], "role": "top", "existing": True, @@ -1043,12 +1367,11 @@ def make_stairwell(kind, owner, other, top, bottom, site, mode, chain): "mode": mode, "max_bend": max([cheb((x, y), top) for (x, y, _z) in chain] or [0]), "segments_drawn_only_on_player_layer": True, "intermediate_appearance": fam, - "intermediate_appearance_source": ( - "appearance of the down archetype of the {} family used by this stairwell's exits; the " - "intermediate objects are decorations, not exits".format(fam.rsplit("_", 1)[0]) - if fam else "no stair archetype among {}; owner to choose".format(top_objs + bot_objs)), + "intermediate_appearance_source": fam_source, "tiles": tiles, "view_conflicts": [], "replaces": exits_rec} S.stairwells.append(sw) + if fallback: + S.fallback_of[id(sw)] = fallback # the stairwell's id is given after the run for o in site: S.sw_of_exit[o["e"]["key"]] = sw return sw @@ -1139,7 +1462,8 @@ def build_stairwells(gid, conns): continue make_stairwell("sloped" if slope else "placement", gid, canon["other"], top, bottom, site, mode, chain) - to_place = [g["id"] for g in groups if g["classification"] == "place"] + # groups the owner aligned to an anchor (decision 3) have no exit to place them by: they are placed after the pool + to_place = [g["id"] for g in groups if g["classification"] == "place" and g["id"] not in OWNER_ALIGN] tentative_ids = [g["id"] for g in groups if g["classification"] == "uncertain"] def above_below_blockers(gid, pool): @@ -1231,7 +1555,7 @@ def strict(p, owner=owner): def run_placement(radius): S.placed, S.placements, S.order = {}, {}, [] S.stairwells, S.blocked_sites, S.sw_of_exit = [], [], {} - S.slope_failed, S.slope_failed_exit, S.relayer_blocked = [], {}, [] + S.slope_failed, S.slope_failed_exit, S.relayer_blocked, S.fallback_of = [], {}, [], {} S.occ = fp.Occupancy(radius, CONTACT_MARGIN) for m in G[ow_id]["maps"]: for L in sorted(ow_pieces.get(m, {})): @@ -1240,22 +1564,21 @@ def run_placement(radius): S.occ.add(m, L, functools.partial(_piece, m, L), bbox=fp.bbox_of(ts)) build_relayer_stairwells() o1, f1, l1 = run_pool(to_place, False) + # Owner alignments (decision 3, 2026-10-19), right after the pool: the anchor's x/y, the decided layer + # distance, nothing else tried. A group whose anchor or spot fails stays unplaced and fails verification. + oa, fa = [], [] + for gid, dec in sorted(OWNER_ALIGN.items()): + anchor, dl, why = dec["anchor"], dec["layer_delta"], dec["why"] + a = S.placed.get(anchor) + res = None if a is None else place_group(gid, forced={"dx": a["dx"], "dy": a["dy"], "L": a["L"] + dl}) + if placement_failed(res): + S.placements[gid] = {"failed": True, "ideal": (res or {}).get("ideal")} + fa.append(gid) + continue + res["alignment_reason"] = why + S.placements[gid] = res + oa.append(gid) o2, f2, l2 = run_pool(tentative_ids, True) - o3, f3, s3 = [], [], [] - for gid, (anchor, dl, why) in sorted(TENTATIVE_ALIGN.items()): - if gid in G and gid not in S.placements and anchor not in S.placed: - s3.append(gid) # its anchor is not placed: the group stays unplaced - # only for groups no pool tried: a group whose own placement failed stays failed - if gid in G and gid not in S.placements and anchor in S.placed: - a = S.placed[anchor] - res = place_group(gid, tentative=True, forced={"dx": a["dx"], "dy": a["dy"], "L": a["L"] + dl}) - if placement_failed(res): - S.placements[gid] = {"failed": True, "ideal": (res or {}).get("ideal")} - f3.append(gid) - continue - res["tentative_reason"] = why - S.placements[gid] = res - o3.append(gid) # view conflicts against the final state (stairwell tiles count as their owner's tiles); # every stairwell is drawn only on the player's layer (owner decision 2026-10-18), so # the conflicts are listed but hidden in the engine. @@ -1283,8 +1606,7 @@ def run_placement(radius): n[sw["kind"]] += 1 sw["id"] = "{}{}".format(prefix[sw["kind"]], n[sw["kind"]]) # A failed group stays unplaced (and fails verification, so the plan is not published). - # an uncertain group no pool reached may still get its tentative spot from an alignment hint - return o1, sorted(l1 + f1), o2 + o3, sorted((set(l2) - set(o3)) | set(f2)), sorted(f1 + f2 + f3), s3 + return o1 + oa, sorted(l1 + f1 + fa), o2, sorted(l2 + f2), sorted(f1 + fa + f2) def becomes_counts(): c = collections.Counter() @@ -1369,7 +1691,7 @@ def exit_record(e, with_eval=True): return r # Sensitivity: the same placement with the shipped client's view radius. - _o, sens_left1, _t, sens_left2, sens_failed, sens_skipped = run_placement(CLIENT_VIEW_RADIUS) + _o, sens_left1, _t, sens_left2, sens_failed = run_placement(CLIENT_VIEW_RADIUS) sens_placed = {gid: dict(p) for gid, p in S.placed.items()} sensitivity = { "view_radius_tiles": CLIENT_VIEW_RADIUS, @@ -1378,10 +1700,20 @@ def exit_record(e, with_eval=True): "crossing_exit_objects_after_placement": dict(sorted(becomes_counts().items())), "stairwells": len(S.stairwells), "stairwell_sites_blocked": len(S.blocked_sites), - "groups_unplaced": sorted(set(sens_left1) | set(sens_left2) | set(sens_failed) | set(sens_skipped)), + "groups_unplaced": sorted(set(sens_left1) | set(sens_left2) | set(sens_failed)), } - # an alignment group skipped for want of its anchor fails verification (its hint is not applied) - order1, left1, order_t, left2, failed_placements, _skipped = run_placement(VIEW_RADIUS) + order1, left1, order_t, left2, failed_placements = run_placement(VIEW_RADIUS) + # Owner decision 2 (2026-10-19): every fallback dresses exactly the stairwells the owner chose it for (the + # same records, by premise) + dressed = collections.defaultdict(list) + for sw in S.stairwells: + if id(sw) in S.fallback_of: + dressed[S.fallback_of[id(sw)]].append((sw["id"], stairwell_premise(sw))) + for a, (_app, chosen) in sorted(STAIRWELL_APPEARANCE_FALLBACKS.items()): + if dressed.get(a, []) != list(chosen): + raise SystemExit("owner decision {} chose stair art for the {} exits of {}, but it now dresses {} (id, " + "premise); ask the owner again".format(DECIDED_2026_10_19, a, list(chosen), + dressed.get(a, []))) place_index = {gid: i for i, gid in enumerate(S.order)} placements = S.placements sensitivity["groups_placed_differently"] = sorted( @@ -1511,6 +1843,11 @@ def tile_objects(m, x, y): if stale: raise SystemExit("owner decision lists exits to keep as decoration that are no longer dangling exits " "landing on content: {}".format(stale)) + # Owner decision 3 (2026-10-19) aligns groups that have no exit of their own; a wired exit would give one + for gid in sorted(OWNER_ALIGN): + if G[gid]["entrances"] or G[gid]["exits_out"]: + raise SystemExit("owner decision {} aligns {} under {}, but wiring gave the group an exit; ask the owner " + "again".format(DECIDED_2026_10_19, gid, OWNER_ALIGN[gid]["anchor"])) stats = collections.defaultdict(collections.Counter) portal_reasons = collections.Counter() @@ -1696,8 +2033,10 @@ def finals_count(keys): "low": st_["low"], "high": st_["high"], "low_map": R.tiles[tuple(st_["low_tile"])][0], "high_map": R.tiles[tuple(st_["high_tile"])][0], "low_tile_legacy": st_["low_tile"], "high_tile_legacy": st_["high_tile"], "why": st_["why"], - "objects": [{"tile": "low", "archetype": "stairs_stone_up", "exit": True, "exit_to": "high"}, - {"tile": "high", "archetype": "stairs_stone_down", "exit": True, "exit_to": "low"}]} + "objects": [{"tile": "low", "archetype": SEAM_STAIR_ARCHETYPES["low"], "exit": True, + "exit_to": "high"}, + {"tile": "high", "archetype": SEAM_STAIR_ARCHETYPES["high"], "exit": True, + "exit_to": "low"}]} new_stairs.append(rec) d["stairs"].append(rec["id"]) if "exit" in pc: @@ -1808,9 +2147,11 @@ def height_rows(m): sum(r["tiles"] for r in kept_out))}) if new_stairs: judgement.append({"kind": "new stairs", "text": "{} new stair pair(s) keep the {} cut seam piece(s) and every " - "pocket they leave walkable; archetypes default to " - "stairs_stone_up/down".format( - len(new_stairs), sum(len(sm["pieces"]) for sm in seams_out))}) + "pocket they leave walkable; archetypes default to {}".format( + len(new_stairs), sum(len(sm["pieces"]) for sm in seams_out), + seam_stairs_text())}) + # Owner decision 6 (2026-10-19): the relayering judgement calls are confirmed exactly as listed + confirmed_calls = check_judgement_calls(judgement, OWNER_RELAYERING_JUDGEMENT_CALLS) group_regions = {gid: [{"layer": r["layer"], "q": r["q"], "tiles": len(r["tiles"]), "maps": sorted({Rg.tiles[k][0] for k in r["tiles"]}), "relayered": r["relayered"]} for r in Rg.regions] for gid, Rg in sorted(relay.items()) if gid != ow_id and Rg.regions} @@ -1838,6 +2179,12 @@ def height_rows(m): "its sub-layer 0 floor, then b when it holds a no_pass object; . = no floor", } + # Owner decision 6 (2026-10-19): the judgement calls, and every record they summarise, are as confirmed + now = premise(relayering_evidence(relayering, S.stairwells)) + if now != OWNER_RELAYERING_PREMISE: + raise SystemExit("owner decision {} confirmed the relayering judgement calls on other records (their premise " + "is now {}); ask the owner again".format(DECIDED_2026_10_19, now)) + # -------------------------------------------------------------- output def bbox_layers(gid, dx=0, dy=0, L=0): bl = {} @@ -1950,12 +2297,36 @@ def bbox_layers(gid, dx=0, dy=0, L=0): "main_stairwell_blocked": bool(c.get("main_stairwell_blocked")), "crossing_exits_after_placement": dict(sorted(stats[gid].items())), } - if p.get("tentative_reason"): - rec["placement"]["tentative_reason"] = p["tentative_reason"] + if p.get("alignment_reason"): + rec["placement"]["alignment_reason"] = p["alignment_reason"] elif p and p.get("failed"): rec["placement"] = {"status": "failed", "ideal": p["ideal"]} groups_out.append(rec) + # Owner decisions checked against the JSON records they were made on + recs_out = {r["id"]: r for r in groups_out} + records_out = {(e["source_map"], e["line"]): e for r in groups_out for key in EXIT_LISTS for e in r.get(key, [])} + # 1: the blocked sites of the exits the owner keeps portals carry the decision + apply_owner_portals(S.blocked_sites + S.slope_failed + S.relayer_blocked, OWNER_PORTALS, records_out) + # 4 and 5: the confirmed orientations and classifications, on the same group content + for gid, (_o, _ev, want) in sorted(OWNER_ORIENTATIONS.items()): + now = premise(group_evidence(recs_out[gid])) + if now != want: + raise SystemExit("owner decision {} confirmed the orientation of {} on other content (its premise is now " + "{}); ask the owner again".format(DECIDED_2026_10_19, gid, now)) + for gid, (_c, _r, want) in sorted(OWNER_CLASSIFICATIONS.items()): + now = premise({"evidence": recs_out[gid]["evidence"][:-1], "group": group_evidence(recs_out[gid])}) + if now != want: + raise SystemExit("owner decision {} confirmed the classification of {} on other evidence (its premise is " + "now {}); ask the owner again".format(DECIDED_2026_10_19, gid, now)) + # 3: the alignment, on the evidence groups' sheets and the stairs between them + for gid, dec in sorted(OWNER_ALIGN.items()): + now = premise(alignment_evidence(recs_out, dec["evidence_groups"])) + if gid not in dec["evidence_groups"] or dec["anchor"] not in dec["evidence_groups"] or now != dec["premise"]: + raise SystemExit("owner decision {} aligns {} under {} on the layout of {} and the exits between them; " + "that evidence changed (its premise is now {}); ask the owner again".format( + DECIDED_2026_10_19, gid, dec["anchor"], list(dec["evidence_groups"]), now)) + changes_v1 = [] for gid in sorted(S.placed): short_id = gid[len("/shattered_islands/"):] if gid.startswith("/shattered_islands/") else gid @@ -1969,6 +2340,39 @@ def bbox_layers(gid, dx=0, dy=0, L=0): "blocked_by": sorted({"{} ({}, layer {})".format(base_name(x["blocked_by"]), x["rule"], x["plane_layer"]) for x in pl["conflicts_resolved"]})}) + # Owner decision 6 (2026-10-19) confirmed the placements the lowered caves push deeper; decision 4 the + # orientation of four groups. Both must still be what this run produced. + got_changes = [(x["group"][len("/shattered_islands/"):], tuple(x["v1"] or ()), tuple(x["v2"])) for x in changes_v1] + if got_changes != list(OWNER_PLACEMENT_CHANGES_FROM_V1): + raise SystemExit("owner decision {} confirmed the placement changes from version 1 {}, but this run makes " + "{}; ask the owner again".format(DECIDED_2026_10_19, list(OWNER_PLACEMENT_CHANGES_FROM_V1), + got_changes)) + # Owner decision 7 (2026-10-19) keeps groups placed that may have no footprint: they must be placed, and every + # placed group without a footprint needs that decision + for gid in sorted(OWNER_NO_FOOTPRINT): + if gid not in S.placed: + raise SystemExit("owner decision {} keeps {} placed without a footprint, but it is not placed; ask the " + "owner again".format(DECIDED_2026_10_19, gid)) + for gid in sorted(S.placed): + if not local_tiles[gid] and gid not in OWNER_NO_FOOTPRINT: + raise SystemExit("placed group {} has no footprint (none of its maps holds an object); ask the owner " + "whether to keep it placed (OWNER_NO_FOOTPRINT)".format(gid)) + for gid, (want, _evidence, _premise) in sorted(OWNER_ORIENTATIONS.items()): + got = (placements.get(gid) or {}).get("orientation") + if got != want: + raise SystemExit("owner decision {} confirmed '{}' for {}, which is {}; ask the owner again".format( + DECIDED_2026_10_19, want, gid, "placed '{}'".format(got) if got else "not placed")) + # Every archetype the plan adds must exist in the content: stairwell decorations, the owner's fallbacks + # and the seam stairs. + added = {sw["intermediate_appearance"] for sw in S.stairwells if sw["intermediate_appearance"]} + added |= {app for app, _ids in STAIRWELL_APPEARANCE_FALLBACKS.values()} | set(SEAM_STAIR_ARCHETYPES.values()) + missing = sorted(a for a in added if a.casefold() not in archs) + if missing: + raise SystemExit("the plan adds archetypes the content does not define: {}".format(missing)) + + def defined_in(a): + return archs[a.casefold()]["__file__"] + counts = collections.Counter() for g in groups: if g["id"] == ow_id: @@ -1976,7 +2380,7 @@ def bbox_layers(gid, dx=0, dy=0, L=0): counts["{}:{}".format(g["classification"], g["reason"])] += 1 sw_spans = collections.Counter(sw["top_layer"] - sw["bottom_layer"] for sw in S.stairwells) plan = { - "schema": "atrinik-overworld-placement-plan/4", + "schema": "atrinik-overworld-placement-plan/5", "generated_by": "scripts/build_plan.py", "content_root": str(content), "content_git_head": revision["head"], @@ -1988,8 +2392,37 @@ def bbox_layers(gid, dx=0, dy=0, L=0): "stairwell_max_bend_tiles": STAIRWELL_MAX_BEND, "layer_height_z": LAYER_HEIGHT_Z, "relayer_min_region_tiles": relayer.MIN_REGION_TILES, "walkable_dz": relayer.WALKABLE_DZ, "owner_keep_as_decoration": [[m, list(t), a] for (m, t, a) in sorted(OWNER_KEEP_AS_DECORATION)], - "tentative_alignments": [{"group": gid, "anchor": anchor, "layer_delta": dl, "why": why} - for gid, (anchor, dl, why) in sorted(TENTATIVE_ALIGN.items())], + # owner decisions of 2026-10-19 (see the constants in build_plan.py) + "owner_portals": [{"source_map": m, "line": ln, "source_tile": list(t), "arch": a, + "premise": d["premise"], "decided": DECIDED_2026_10_19, + "decision": d["decision"]} + for (m, ln, t, a), d in sorted(OWNER_PORTALS.items())], + "stairwell_appearance_fallbacks": [ + {"exit_arch": a, "appearance": app, "appearance_defined_in": defined_in(app), + "stairwells": [{"id": i, "premise": pr} for i, pr in chosen], "decided": DECIDED_2026_10_19, + "decision": STAIRWELL_APPEARANCE_DECISION} + for a, (app, chosen) in sorted(STAIRWELL_APPEARANCE_FALLBACKS.items())], + "owner_alignments": [{"group": gid, "anchor": d["anchor"], "layer_delta": d["layer_delta"], + "evidence_groups": list(d["evidence_groups"]), "premise": d["premise"], + "decided": DECIDED_2026_10_19, "why": d["why"]} + for gid, d in sorted(OWNER_ALIGN.items())], + "owner_no_footprint": [{"group": gid, "decided": DECIDED_2026_10_19, "decision": why} + for gid, why in sorted(OWNER_NO_FOOTPRINT.items())], + "owner_orientations": [{"group": gid, "orientation": o, "evidence": list(ev), "premise": pr, + "decided": DECIDED_2026_10_19, "decision": ORIENTATION_DECISION} + for gid, (o, ev, pr) in sorted(OWNER_ORIENTATIONS.items())], + "owner_classifications": [{"group": gid, "classification": c, "reason": r, "premise": ev, + "decided": DECIDED_2026_10_19, "decision": CLASSIFICATION_DECISION} + for gid, (c, r, ev) in sorted(OWNER_CLASSIFICATIONS.items())], + "owner_relayering_judgement_calls": { + "decided": DECIDED_2026_10_19, "decision": RELAYERING_DECISION, "calls": confirmed_calls, + "premise": OWNER_RELAYERING_PREMISE, + "placement_changes_from_v1": [{"group": "/shattered_islands/" + g, "v1": list(a), "v2": list(b)} + for g, a, b in OWNER_PLACEMENT_CHANGES_FROM_V1]}, + "seam_stair_archetypes": dict(SEAM_STAIR_ARCHETYPES, decided=DECIDED_2026_10_19, + decision="the new seam stairs keep their default archetypes", + defined_in={k: defined_in(a) for k, a in + sorted(SEAM_STAIR_ARCHETYPES.items())}), "overworld_map_size": 24, "excluded_trees": list(md.EXCLUDED_TOP)}, "conventions": { "plane": "overworld plane; layer 0 surface, negative below, positive above", @@ -2030,6 +2463,19 @@ def bbox_layers(gid, dx=0, dy=0, L=0): "layer above or below (see its 'wired' evidence)", "exits_kept_as_decoration": "dangling exits the owner keeps as decoration (no exit): not wired, with the " "landing they would have had", + "stairwell_appearance": "a stairwell's intermediate tiles show the down archetype of the first stair " + "family among its exit archetypes (down-going exits first); failing that, the " + "owner's fallback for the first exit archetype settings.stairwell_appearance_" + "fallbacks lists (decision 2026-10-19)", + "owner_decisions": "settings.owner_* and the seam stair and appearance settings record the owner's " + "decisions with their dates; a blocked stairwell site carries owner_decision when the " + "owner keeps its exits portals (settings.owner_portals), and a placement with " + "alignment_reason is the owner's fixed spot under its anchor (settings.owner_" + "alignments), kept even without a footprint (settings.owner_no_footprint); " + "confirmations (orientations, classifications, relayering judgement " + "calls) equal what the plan derives; each decision is bound to the evidence it was " + "made on (the evidence itself, or its premise: the SHA-256 of the evidence record's " + "canonical JSON) and no longer applies when that evidence changes", "exit_inventory": "every exit object read from the content (inventory exits excluded), keyed by " "source_map and line: the content fields of its exit record with its legacy form " "and resolved target (none for an exit that leads nowhere), plus the facts its data " diff --git a/migration/placement-plan/scripts/report.py b/migration/placement-plan/scripts/report.py index fe5dc9e..a04fd22 100644 --- a/migration/placement-plan/scripts/report.py +++ b/migration/placement-plan/scripts/report.py @@ -117,11 +117,17 @@ def write_report(plan, out: Path): dig["sha256"][:16], dig["files"], ", ".join("`{}/`".format(t) for t in dig["trees"]))) w("Machine-readable plan: `placement-plan.json`. Rerun: see [Reproducing](#reproducing).") w("") + open_dec = open_decisions(plan, lab) w("Version 2 applies the owner's decisions of 2026-10-18 on top of version 1 (PR #44): the relayering is " "applied after its conflicts are fixed, every stairwell is drawn only on the player's layer, sloped " - "stairwells replace near-aligned portals, and the dangling exits are wired. Every map that moves, every new " - "object and every judgement call is listed for review; the decisions still open are in " - "[Open owner decisions](#open-owner-decisions).") + "stairwells replace near-aligned portals, and the dangling exits are wired. The owner's answers of " + "2026-10-19 to the six questions it left open{} are applied too " + "([Decided 2026-10-19](#decided-2026-10-19)). " + "Every map that moves, every new object and every judgement call is listed for review; {}.".format( + ", and to the one this version raised," if plan["settings"]["owner_no_footprint"] else "", + "no owner decision is open ([Open owner decisions](#open-owner-decisions))" if not open_dec else + "{} owner decision{} open ([Open owner decisions](#open-owner-decisions))".format( + len(open_dec), " is" if len(open_dec) == 1 else "s are"))) w("") # ---------------------------------------------------------- summary w("## Summary") @@ -188,6 +194,8 @@ def write_report(plan, out: Path): "`{}` layer offset {} -> {}".format(base(x["group"]), x["v1"][2], x["v2"][2]) if x["v1"] else "`{}` newly placed at layer offset {}".format(base(x["group"]), x["v2"][2]) for x in ch) or "none")) pr = s["portal_reasons"] + w("- **Owner decisions of 2026-10-19 applied**: {}. Open owner decisions: {}.".format( + "; ".join(decided_summary(plan, lab)), len(open_dec) or "none")) w("- Why portals remain: {}. See [Portals that remain](#portals-that-remain).".format( "; ".join("{} {}".format(v, k) for k, v in sorted(pr.items(), key=lambda kv: (-kv[1], kv[0]))))) w("- View radius: **{} tiles** (the classic server's limit; the shipped client sees 10). " @@ -220,6 +228,7 @@ def write_report(plan, out: Path): # ---------------------------------------------------------- decisions w("## Owner decisions applied") w("") + write_decided_2026_10_19(plan, w, lab) w("### Decided 2026-10-18 (new in version 2)") w("") w("1. **Relayering: fix the conflicts, then relayer.** Storeys standing on raised ground move up with it, caves " @@ -330,11 +339,16 @@ def write_report(plan, out: Path): "teleport, with `portal_reason` in the JSON.".format(st["stair_tolerance_tiles"])) w("") # ---------------------------------------------------------- placed table + aligned_to = {a["group"]: a for a in st["owner_alignments"]} + def main_cell(g): p = g["placement"] mc = p.get("main_connection") if not mc: - return "none (forced alignment)" + a = aligned_to.get(g["id"]) + return "none: owner alignment, {} {} #{} `{}` at its x/y".format( + layers(a["layer_delta"]), "under" if a["layer_delta"] < 0 else "over", lab[a["anchor"]], + base(a["anchor"])) if a else "none" for _key, e in crossing(g): if e["source_map"] == mc["source_map"] and e["source_tile"] == mc["source_tile"]: ps, pt = e.get("plane_source"), e.get("plane_target") @@ -353,7 +367,7 @@ def notes_cell(g): p = g["placement"] n = [] ev = "; ".join(p["orientation_evidence"]) - if "keyword" in ev or "default" in ev: + if ("keyword" in ev or "default" in ev) and p.get("main_connection"): n.append("orientation {} by {}".format(p["orientation"], ev)) if p["extra_layers_from_ideal"] or p["shift_from_ideal"] != [0, 0]: col = sorted({"{} ({})".format(base(c["blocked_by"]), c["rule"]) for c in p["conflicts_resolved"]}) @@ -364,8 +378,8 @@ def notes_cell(g): n.append("main stairwell blocked (see Stairwells)") if p.get("constraint_note"): n.append(p["constraint_note"]) - if p.get("tentative_reason"): - n.append(p["tentative_reason"]) + if p.get("alignment_reason"): + n.append(p["alignment_reason"]) return "; ".join(n) or "-" def placed_table(rows): @@ -486,7 +500,7 @@ def placed_table(rows): w("| `{}` | {} | {} | {}; {} | {} |".format(short(g["id"]), g["map_count"], g["reason"], names, "; ".join(ev).replace("|", "/"), "; ".join(conn) or "-")) w("") - write_open_decisions(plan, w, lab, placed, tent, wired) + write_open_decisions(plan, w, lab) write_problems(plan, w, wired) write_images(plan, w, lab, groups, ow, sws) write_reproducing(plan, w) @@ -670,11 +684,13 @@ def write_relayering(plan, w, lab): "its two tiles must still be connected (walking on the final layers, legacy exits, the new stairs), and an " "edge whose tiles are still apart, a pocket the piece's stairs do not reach, gets stairs of its own. **Every " "cut is flagged here**: {} new stair pairs in {} pieces ({} for pockets), {} pieces already joined by an " - "exit. The stair objects default to `stairs_stone_up` on the low tile and `stairs_stone_down` on the high " - "tile.".format( + "exit. The stair objects are `{}` on the low tile and `{}` on the high tile, the default archetypes, kept " + "by owner decision {} (`settings.seam_stair_archetypes`).".format( sum(sm["cut_edges"] for sm in rl["seams"]), len(rl["seams"]), len(rl["new_stairs"]), sum(len(sm["pieces"]) for sm in rl["seams"]), len(pockets), - sum(1 for sm in rl["seams"] for p in sm["pieces"] if not p["stairs"]))) + sum(1 for sm in rl["seams"] for p in sm["pieces"] if not p["stairs"]), + st["seam_stair_archetypes"]["low"], st["seam_stair_archetypes"]["high"], + st["seam_stair_archetypes"]["decided"])) w("") w("| seam | regions | layers | cut edges | maps | new stairs |") w("|---|---|---|---:|---|---|") @@ -715,6 +731,11 @@ def write_relayering(plan, w, lab): w("") w("### Relayering judgement calls") w("") + rj = st["owner_relayering_judgement_calls"] + if rj["calls"] == rl["judgement_calls"]: + w("Confirmed by the owner on {} ([decision 6](#decided-{})), every call as listed here.".format( + rj["decided"], rj["decided"])) + w("") for j in rl["judgement_calls"]: w("- {}: {}.".format(j["kind"], j["text"])) w("") @@ -727,14 +748,17 @@ def write_stairwells(plan, w, lab, sw_kind): w("") w("Each row is one stairwell (`stairwells[]` in the JSON): one stair tile per layer from the top end to the " "bottom end. Top and bottom are the existing exit objects; the intermediate tiles are new decorations that " - "borrow the appearance of the family's `*_down` archetype, are not exits and not walkable, so the stairwell " - "stays one exit. **Every stairwell is drawn only on the player's layer** " + "borrow the appearance of the family's `*_down` archetype (or, when no exit belongs to a stair family, of the " + "archetype the owner chose for its exits: {}), are not exits and not walkable, so the stairwell stays one " + "exit. **Every stairwell is drawn only on the player's layer** " "(`segments_drawn_only_on_player_layer`), so its **view conflicts** (intermediate tiles within {} tiles " "of an unrelated area on that layer) are listed but not seen. The chain search also avoids tiles within {} " "tile of a related area (the contact margin); **contacts** it cannot avoid are listed. A stairwell that " "replaces a legacy one-way exit is used only that way (**use**: down or up); its far end is a landing " "without an exit. Ids: SW = built for a placed group, SL = sloped, SR = for an overworld exit the " - "relayering put 2 or more layers apart.".format(R, st["contact_margin_tiles"])) + "relayering put 2 or more layers apart.".format( + "; ".join("`{}` -> `{}`".format(f["exit_arch"], f["appearance"]) for f in st["stairwell_appearance_fallbacks"]), + R, st["contact_margin_tiles"])) w("") w("| id | owner | connects to | top (x, y) | layers | span | shape | use | top / intermediate / bottom objects | " "replaces | view conflicts | contacts |") @@ -756,8 +780,9 @@ def write_stairwells(plan, w, lab, sw_kind): w("| {} | {} | {} | {} | {}..{} | {} | {} | {} | {} / {} / {} | {} | {} | {} |".format( sw["id"], own, other, col, sw["top_layer"], sw["bottom_layer"], sw["top_layer"] - sw["bottom_layer"], shape, use, ", ".join(o["archetype"] for o in top["objects"]) or "landing", - "{} (look only)".format(sw["intermediate_appearance"]) if sw["intermediate_appearance"] - else "**none (owner to choose)**", + "{} (look only{})".format(sw["intermediate_appearance"], ", owner's choice" + if sw["intermediate_appearance_source"].startswith("owner decision") else "") + if sw["intermediate_appearance"] else "**none (owner to choose)**", ", ".join(o["archetype"] for o in bot["objects"]) or "landing", ", ".join("{}{}".format(k, "" if v == 1 else " x{}".format(v)) for k, v in sorted(reps.items())), conf, touch)) @@ -768,10 +793,11 @@ def write_stairwells(plan, w, lab, sw_kind): for b in plan["stairwell_sites_blocked"]: occ = "; ".join("L{} by `{}`".format(o["layer"], base(o["map"] or o["occupied_by"])) for o in b["column_occupied"]) - w("- #{} `{}` <- {}: {} at plane ({}, {}, L{}) down to L{}; the column is occupied on {}; {}.".format( - lab[b["group"]], base(b["group"]), owner_name(b["other"], lab), - ", ".join(sorted({"{} ({},{}) {}".format(base(e["source_map"]), *e["source_tile"], e["arch"]) - for e in b["exits"]})), *b["top"], b["bottom"][2], occ or "no layer", b["reason"])) + dec = b.get("owner_decision") + w("- #{} `{}` <- {}: {} at plane ({}, {}, L{}) down to L{}; the column is occupied on {}; {}.{}".format( + lab[b["group"]], base(b["group"]), owner_name(b["other"], lab), site_exits(b), *b["top"], + b["bottom"][2], occ or "no layer", b["reason"], + " Owner decision {}: **{}**.".format(dec["decided"], dec["decision"]) if dec else "")) w("") w("### Sloped stairwells") w("") @@ -922,70 +948,217 @@ def write_evidence(plan, w): w("") -def write_open_decisions(plan, w, lab, placed, tent, wired): +def layers(delta: int) -> str: + return "{} layer{}".format(abs(delta), "" if abs(delta) == 1 else "s") + + +def and_list(items) -> str: + items = list(items) + if len(items) < 2: + return "".join(items) + return "{} and {}".format(", ".join(items[:-1]), items[-1]) + + +def site_exits(b) -> str: + return ", ".join(sorted({"{} ({},{}) {}".format(base(e["source_map"]), *e["source_tile"], e["arch"]) + for e in b["exits"]})) + + +def decided_sites(plan): + """Blocked stairwell sites whose exits the owner keeps portals (they carry owner_decision).""" + return [b for b in plan["stairwell_sites_blocked"] + plan["sloped_stairwell_sites_blocked"] + + plan["relayering"]["stairwells_blocked"] if b.get("owner_decision")] + + +def fallback_stairwells(plan): + """appearance -> ids of the stairwells that show an owner-chosen fallback (decision 2026-10-19).""" + out = collections.defaultdict(list) + for sw in plan["stairwells"]: + if sw["intermediate_appearance"] and sw["intermediate_appearance_source"].startswith("owner decision"): + out[sw["intermediate_appearance"]].append(sw["id"]) + return out + + +def decided_summary(plan, lab): + """One clause per owner decision of 2026-10-19, for the summary.""" + st = plan["settings"] + byid = {g["id"]: g for g in plan["groups"]} + out = [b["owner_decision"]["decision"].split(":")[0] for b in decided_sites(plan)] + fb = fallback_stairwells(plan) + if fb: + out.append("stair art for stairwells without stairs: {}".format("; ".join( + "{} show{} `{}`".format(and_list(ids), "s" if len(ids) == 1 else "", a) for a, ids in sorted(fb.items())))) + for a in st["owner_alignments"]: + p = byid[a["group"]].get("placement") or {} + out.append("#{} `{}` is placed on layer {} under `{}`{}".format( + lab.get(a["group"], "?"), base(a["group"]), p.get("layer_offset"), base(a["anchor"]), + "" if p.get("occupied_tiles_by_layer") else ", without a footprint while its maps are empty")) + out.append("'below' confirmed for {} groups, version 1's classification of {} groups and the {} relayering " + "judgement calls".format(len(st["owner_orientations"]), len(st["owner_classifications"]), + len(st["owner_relayering_judgement_calls"]["calls"]))) + return out + + +def write_decided_2026_10_19(plan, w, lab): + st = plan["settings"] rl = plan["relayering"] - w("## Open owner decisions") + byid = {g["id"]: g for g in plan["groups"]} + w("### Decided 2026-10-19") + w("") + w("The owner's answers to the six questions version 2 left open{}. Decisions 1-3 change the plan; 4-6 confirm " + "what the rules derived. Each one is an input of `build_plan.py`, recorded with its date in the JSON settings " + "and bound to the evidence it was made on (the evidence itself, or its premise: the SHA-256 of the evidence " + "record's canonical JSON). The generator refuses to publish when that evidence changes, so the owner is asked " + "again, and `verify_plan.py` checks each binding in the JSON.".format( + ", and decision 7, the answer to the question this version raised" if st["owner_no_footprint"] else "")) + w("") + items = [] + for b in decided_sites(plan): + title, _, rest = b["owner_decision"]["decision"].partition(": ") + into = "#{} `{}`".format(lab[b["group"]], base(b["group"])) if b.get("group") in lab else \ + "`{}`".format(base(b.get("bottom_map"))) + occ = sorted({"`{}` on L{}".format(base(o["map"] or o["occupied_by"]), o["layer"]) + for o in b.get("column_occupied", [])}) + items.append("**{}** (`settings.owner_portals`): {} drops {} layers into {}{}. {}: the exit stays a portal " + "({}), and its blocked site carries the decision (`owner_decision`).".format( + title, site_exits(b), b["top"][2] - b["bottom"][2], into, + ", and {} occupies its column".format(and_list(occ)) if occ else "", + rest[:1].upper() + rest[1:] if rest else "No stairwell is built", + "`slope-blocked`" if b.get("shape") == "sloped" else "`stairwell-blocked`")) + fbs = collections.defaultdict(list) + for f in st["stairwell_appearance_fallbacks"]: + fbs[(f["appearance"], f["appearance_defined_in"])].append("`{}`".format(f["exit_arch"])) + applied = fallback_stairwells(plan) + items.append("**Stair art for stairwells without stairs** (`settings.stairwell_appearance_fallbacks`): a " + "stairwell whose exits belong to no stair family shows, on its intermediate tiles, the archetype " + "chosen for the first of its exit archetypes: {}. Applied to {}. The rule is an input of the " + "generator (`STAIRWELL_APPEARANCE_FALLBACKS`), which refuses any archetype it adds that the content " + "does not define.".format( + "; ".join("{} -> `{}` (`{}`)".format(and_list(sorted(archs)), a, f_) + for (a, f_), archs in sorted(fbs.items())), + "; ".join("{} (`{}`)".format(and_list(ids), a) for a, ids in sorted(applied.items())) or "none")) + for a in st["owner_alignments"]: + g = byid[a["group"]] + p = g.get("placement") or {} + items.append("**The Rockforge mines are placed** (`settings.owner_alignments`): #{} `{}` on layer {}, {} " + "{} #{} `{}` at the same x/y (offset {}, {}, {}). It is a proposed placement, no longer " + "tentative; the spot is the decision, so no other spot is tried, and the plan fails verification " + "if it breaks the gap rule.".format( + lab.get(g["id"], "?"), base(g["id"]), p.get("layer_offset"), layers(a["layer_delta"]), + "under" if a["layer_delta"] < 0 else "over", lab.get(a["anchor"], "?"), base(a["anchor"]), + p.get("x_offset"), p.get("y_offset"), p.get("layer_offset")) + + ("" if p.get("occupied_tiles_by_layer") else " None of its {} maps holds an object yet, so it " + "occupies no tile ({}).".format( + g["map_count"], "decision 7" if g["id"] in {d["group"] for d in st["owner_no_footprint"]} + else "[Open owner decisions](#open-owner-decisions)"))) + orient = sorted(st["owner_orientations"], key=lambda d: lab.get(d["group"], 0)) + items.append("**{} is confirmed** (`settings.owner_orientations`) for the groups entered only through neutral " + "exits, whose orientation came from a name keyword: {}.".format( + and_list(["'{}'".format(o.capitalize()) for o in sorted({d["orientation"] for d in orient})]), + and_list(["#{} `{}`".format(lab.get(d["group"], "?"), base(d["group"])) for d in orient]))) + cls = collections.defaultdict(list) + for d in sorted(st["owner_classifications"], key=lambda d: short(d["group"])): + cls[d["reason"]].append("`{}`".format(short(d["group"]))) + items.append("**Version 1's judgement calls are confirmed** (`settings.owner_classifications`): {}.".format( + "; ".join("{}: {}".format(r, and_list(v)) for r, v in sorted(cls.items())))) + rj = st["owner_relayering_judgement_calls"] + in_place = [c["unit"] for c in rl["caves"] if c["choice"] == "lower-regions-in-place"] + whole = [c["unit"] for c in rl["caves"] if c["choice"] == "lower-whole-cave"] + storeys = [j["unit"] for j in rl["judgement_calls"] if j["kind"] == "storey partly over unmoved ground"] + ssa = st["seam_stair_archetypes"] + items.append("**The relayering judgement calls are confirmed** (`settings.owner_relayering_judgement_calls`: all " + "{}, as listed in [Relayering judgement calls](#relayering-judgement-calls)): {} {} lowered in " + "place; {} move down as whole caves, pushing {} one layer deeper; the {} new seam stairs keep the " + "default archetypes `{}` / `{}` (`settings.seam_stair_archetypes`); the storeys {}, partly over " + "unmoved ground, move up.".format( + len(rj["calls"]), and_list(in_place) or "no cave", "is" if len(in_place) == 1 else "are", + and_list(whole) or "no cave", + and_list(["`{}` ({} -> {})".format(base(x["group"]), x["v1"][2], x["v2"][2]) + for x in rj["placement_changes_from_v1"]]) or "no placement", + len(rl["new_stairs"]), ssa["low"], ssa["high"], and_list(storeys) or "none")) + for d in st["owner_no_footprint"]: + g = byid[d["group"]] + p = g.get("placement") or {} + occupied = sum((p.get("occupied_tiles_by_layer") or {}).values()) + now = ("none of its {} maps holds an object, so its placement on layer {} occupies no tile".format( + g["map_count"], layers_of(p.get("plane_bounding_boxes") or {})) if not occupied else + "it was kept placed while its maps were empty; it now occupies {} tiles, held to the gap rule like " + "any placement".format(occupied)) + items.append("**#{} `{}` stays placed{}** (`settings.owner_no_footprint`, the answer to the question this " + "version raised): {}. The owner's reasons: {}.".format( + lab.get(g["id"], "?"), base(g["id"]), "" if occupied else " without a footprint", now, + d["decision"].split(": ", 1)[-1])) + for i, d in enumerate(items, 1): + w("{}. {}".format(i, d)) w("") + + +def open_decisions(plan, lab): + """Questions the plan still needs the owner to answer, one REPORT item each.""" + st = plan["settings"] dec = [] for b in plan["stairwell_sites_blocked"]: + if b.get("owner_decision"): + continue own = [o for o in b["column_occupied"] if o["occupied_by"] == b["group"]] - dec.append("**Blocked stairwell at #{} `{}`** (Zechna's first hole): the {} drops {} layers, but {} " - "occupies the column. Keep it a portal, or let the stairwell pass through that floor.".format( - lab[b["group"]], base(b["group"]), - ", ".join(sorted({"{} ({},{}) {}".format(base(e["source_map"]), *e["source_tile"], e["arch"]) - for e in b["exits"]})), - b["top"][2] - b["bottom"][2], + dec.append("**Blocked stairwell at #{} `{}`**: the {} drops {} layers, but {} occupies the column. Keep it a " + "portal, or let the stairwell pass through that floor.".format( + lab[b["group"]], base(b["group"]), site_exits(b), b["top"][2] - b["bottom"][2], ", ".join(sorted({"`{}` on L{}".format(base(o["map"] or o["occupied_by"]), o["layer"]) for o in (own or b["column_occupied"])})))) nofam = [sw for sw in plan["stairwells"] if not sw["intermediate_appearance"]] if nofam: dec.append("**Stair archetype for stairwells without stairs**: {} {} exits that have no stair archetype " - "to reuse. Pick one (for example `stairs_stone_down`) or leave the intermediate tiles " - "empty.".format( + "and no owner fallback to reuse. Pick one or leave the intermediate tiles empty.".format( ", ".join("{} ({}{})".format(sw["id"], "#{} `{}`: ".format(lab[sw["group"]], base(sw["group"])) if sw["group"] in lab else "", " / ".join(sorted({r["arch"] for r in sw["replaces"]}))) for sw in nofam), "joins" if len(nofam) == 1 else "join")) - for g in tent: - dec.append("**Tentative `{}`** (#{}): {}".format(short(g["id"]), lab[g["id"]], - g["placement"].get("tentative_reason", ""))) - orient = sorted(("#{} `{}`".format(lab[g["id"]], base(g["id"])) for g in placed - if any(k in "; ".join(g["placement"]["orientation_evidence"]) for k in ("keyword", "default"))), + for g in plan["groups"]: + if (g.get("placement") or {}).get("status") == "tentative": + dec.append("**Tentative `{}`** (#{}): {}".format(short(g["id"]), lab[g["id"]], g["evidence"][-1])) + confirmed = {d["group"] for d in st["owner_orientations"]} + orient = sorted(("#{} `{}`".format(lab[g["id"]], base(g["id"])) for g in plan["groups"] + if (g.get("placement") or {}).get("status") == "proposed" and g["placement"]["main_connection"] + and g["id"] not in confirmed + and any(k in "; ".join(g["placement"]["orientation_evidence"]) for k in ("keyword", "default"))), key=lambda t: int(t[1:].split()[0])) if orient: - dec.append("**Confirm 'below' for groups entered only through neutral exits** (invis_exit/exit, so the " - "direction came from a name keyword): {}. A building interior would rather go above.".format( - ", ".join(orient))) - dec.append("**Confirm the judgement calls**: Everlink is other_world (no exit leads in, its region text says " - "nobody knows where it is, only house scripts reach it); the Temple of Loki desert-plane chain incl. " - "`unusual_garden` is other_world (the garden's hole lands on the same (12,15) of world_12_59, so it " - "could instead sit above that map); the three Incuna `ship_lower_deck*` maps are ship (travel decks " - "entered at one port and left at another, also reached by NPC dialog teleports); `hall_of_dms` is " - "system although world_1_68 has an exit into it.") + dec.append("**Confirm the orientation of groups entered only through neutral exits** (the direction came " + "from a name keyword or the default): {}.".format(", ".join(orient))) + wired = wired_exits(plan) doubtful = [e for e in wired if e["wired"]["verdict"] == "doubtful"] if doubtful: - beds = sorted({o for e in wired for o in e["wired"]["source_tile_objects"] if o.startswith("bed")}) - dec.append("**New: wired exits that land where nobody can walk**: {} land on {} (a walking player cannot " - "enter it). They are wired as instructed{}. Keep them wired, retarget them, or keep them as " - "decoration (no destination)?".format( + dec.append("**Wired exits that land where nobody can walk**: {} land on {} (a walking player cannot enter " + "it). Keep them wired, retarget them, or keep them as decoration (no destination)?".format( ", ".join(tile_name(e["source_map"], e["source_tile"]) for e in doubtful), - "/".join(sorted({t for e in doubtful for t in e["wired"]["landing_floor_terrain"]})), - "; every one shares its tile with {}, so they may be bunk-bed ladders".format( - ", ".join("`{}`".format(b) for b in beds)) if beds else "")) - dec.append("**New: confirm the relayering judgement calls** ([list](#relayering-judgement-calls)): in " - "particular the cave lowered in place ({}), the caves moved down as a whole and the placements they " - "push one layer deeper ({}), and the {} new seam stairs with their default archetype.".format( - ", ".join(", ".join(c["regions"]) for c in rl["caves"] - if c["choice"] == "lower-regions-in-place") or "none", - ", ".join("`{}`".format(base(x["group"])) for x in plan["placement_changes_from_v1"]) or "none", - len(rl["new_stairs"]))) + "/".join(sorted({t for e in doubtful for t in e["wired"]["landing_floor_terrain"]})))) + return dec + + +def write_open_decisions(plan, w, lab): + w("## Open owner decisions") + w("") + dec = open_decisions(plan, lab) for i, d in enumerate(dec, 1): w("{}. {}".format(i, d)) + if not dec: + w("None. The six decisions version 2 left open were answered on 2026-10-19 (see " + "[Decided 2026-10-19](#decided-2026-10-19)), {}.".format( + "and so was the one this version raised (decision 7)" if plan["settings"]["owner_no_footprint"] + else "and no new one came up")) + else: + w("") + w("The six decisions version 2 left open were answered on 2026-10-19 (see " + "[Decided 2026-10-19](#decided-2026-10-19)); the items above came up since.") w("") w("Resolved by the 2026-10-18 decisions: the layer-height relayering (applied), stairwells in view of " "unrelated areas (hidden in the engine), sloped staircases (built where free), the dangling stairs (wired) " - "and the Greyton jail ladders (kept as decoration).") + "and the Greyton jail ladders (kept as decoration). Resolved by the 2026-10-19 decisions: Zechna's first hole " + "(kept a portal), the stair art of the stairwells without stairs (chosen), the Rockforge mines (placed{}), " + "and the orientations and judgement calls of version 1 and of the relayering (confirmed).".format( + ", also without a footprint" if plan["settings"]["owner_no_footprint"] else "")) w("") @@ -1032,6 +1205,12 @@ def write_problems(plan, w, wired): bym[x["source_map"]].append("({},{}) {}{}".format(*x["source_tile"], x["arch"], tag)) for m in sorted(bym): w(" - `{}`: {}".format(short(m), ", ".join(bym[m]))) + empty = [(g, sum(1 for m in g["maps"] if not m["occupied_tiles"])) for g in plan["groups"] + if g["classification"] != "overworld"] + empty = [(g, n) for g, n in empty if n] + if empty: + w("- Empty maps outside the overworld (a map header and tile links, no object, so no footprint): {}.".format( + "; ".join("`{}` {} of {}".format(short(g["id"]), n, g["map_count"]) for g, n in empty))) for x in dp["connectivity"]: w("- Connectivity: `{}`: {}.".format(short(x["group"]), x["kind"].replace("-", " "))) w("- Return exits are often not the exact reverse of their entrance (e.g. Temple of Loki stairs land 6 " @@ -1131,8 +1310,8 @@ def write_reproducing(plan, w): "(independent check of the JSON). Tunables (`VIEW_RADIUS`, `CONTACT_MARGIN`, `MAX_EXTRA_LAYERS`, " "`MAX_SHIFT`, `STAIR_TOLERANCE`, `STAIRWELL_MAX_BEND`, `LAYER_HEIGHT_Z` in `build_plan.py`; " "`MIN_REGION_TILES`, `WALKABLE_DZ`, `SEAM_CELL` in `relayer.py`) and the hand-written inputs (system maps, " - "owner decisions, the Rockforge mines alignment hint, version 1's placements for the comparison) are " - "constants.") + "the owner's decisions of 2026-10-18 and 2026-10-19 with their dates, version 1's placements for the " + "comparison) are constants.") w("") w("`mapdata.py` is a read-only reader for this analysis, not the toolkit's parser. At content " "`{rev}` it was cross-checked against the canonical `atrinik_source::Document::parse` records (with " diff --git a/migration/placement-plan/scripts/test_placement_plan.py b/migration/placement-plan/scripts/test_placement_plan.py index d09bb86..88e09b7 100644 --- a/migration/placement-plan/scripts/test_placement_plan.py +++ b/migration/placement-plan/scripts/test_placement_plan.py @@ -33,6 +33,9 @@ "internal_exits") MINE = "/shattered_islands/eld_woods_island/clearhaven/mine/" CROSSING = (MINE + "mine_entrance_a", 641) # ladder_up to mine_entrance: one copy in each group +ROCKFORGE = "/shattered_islands/strakewood_island/rockforge/" +MINES = ROCKFORGE + "rockforge_c" # placed under rockforge_b by owner decision 2026-10-19 +ZECHNA_HOLE = ("/shattered_islands/world_7_66_-1", 938) # kept a portal by owner decision 2026-10-19 def group(plan, gid): @@ -157,8 +160,8 @@ def test_the_plan_identifies_its_input(self): def test_another_schema(self): p = self.plan() - p["schema"] = "atrinik-overworld-placement-plan/3" - self.assertCaught(p, "schema atrinik-overworld-placement-plan/3 is not atrinik-overworld-placement-plan/4") + p["schema"] = "atrinik-overworld-placement-plan/4" + self.assertCaught(p, "schema atrinik-overworld-placement-plan/4 is not atrinik-overworld-placement-plan/5") class ExitInventory(VerifierTest): @@ -310,17 +313,16 @@ def test_main_connections(self): p = self.plan() group(p, deck)["placement"]["main_connection"]["kind"] = "sideways" self.assertCaught(p, "main connection of {} has kind sideways".format(deck)) + p = self.plan() # an alignment nobody configured, on a group placed by its exits + group(p, deck)["placement"]["alignment_reason"] = "hint" + self.assertCaught(p, "owner alignment of {} is not its configured spot under a placed anchor".format(deck), + "placement of {} has both a main connection and an owner alignment".format(deck)) p = self.plan() - group(p, deck)["placement"]["tentative_reason"] = "hint" - self.assertCaught(p, "proposed placement of {} carries an alignment hint".format(deck)) + group(p, deck)["placement"].update(main_connection=None, alignment_reason="hint") + self.assertCaught(p, "owner alignment of {} is not its configured spot under a placed anchor".format(deck)) p = self.plan() - group(p, deck)["placement"].update(main_connection=None, tentative_reason="hint") - self.assertCaught(p, "proposed placement of {} carries an alignment hint".format(deck), - "placement of {} has no main connection".format(deck)) - p = self.plan() - tent = group(p, p["summary"]["tentative_placement_order"][0])["placement"] - tent["main_connection"] = dict(group(p, deck)["placement"]["main_connection"]) - self.assertCaught(p, "has both a main connection and an alignment hint") + group(p, MINES)["placement"]["main_connection"] = dict(group(p, deck)["placement"]["main_connection"]) + self.assertCaught(p, "placement of {} has both a main connection and an owner alignment".format(MINES)) # mine_entrance_a is entered from mine_entrance, placed before it; claim it was placed after p = self.plan() a, b = group(p, MINE + "mine_entrance_a")["placement"], group(p, MINE + "mine_entrance")["placement"] @@ -716,41 +718,56 @@ def test_failed_group_kept_unplaced(self): self.assertFalse(any("summary unplaced" in e or "failed_placements" in e or "placement order" in e for e in res["errors"]), res["errors"][:8]) - def test_failed_tentative_group(self): - p = self.plan() - gid = p["summary"]["tentative_placement_order"][0] - self.fail(p, gid) - self.assertCaught(p, "placement of {} failed".format(gid)) - - def test_tentative_placement_only_by_classification_or_hint(self): - p = self.plan() - gid = p["summary"]["tentative_placement_order"][0] - g = group(p, gid) - self.assertEqual((g["classification"], g["reason"]), ("separate", "unreachable")) - g["placement"].pop("tentative_reason") - self.assertCaught(p, "separate group {} (unreachable) has a tentative placement without an alignment " - "hint".format(gid)) + def test_failed_aligned_group(self): p = self.plan() - group(p, gid)["reason"] = "per_player" - self.assertCaught(p, "separate group {} (per_player) has a tentative placement".format(gid)) + self.fail(p, MINES) + self.assertCaught(p, "placement of {} failed".format(MINES)) - def test_a_fabricated_alignment_hint(self): - # the last proposed placement turned into an uncertain group with a made-up hint instead of a connection + def test_a_tentative_placement_only_for_an_uncertain_group(self): + # what version 2 published for the Rockforge mines: unreachable, tentative, hinted p = self.plan() + g = group(p, MINES) + self.assertEqual((g["classification"], g["placement"]["status"]), ("place", "proposed")) + g.update(classification="separate", reason="unreachable") + g["placement"]["status"] = "tentative" s = p["summary"] - gid = s["placement_order"].pop() - s["tentative_placement_order"].insert(0, gid) + s["placement_order"].remove(MINES) + s["tentative_placement_order"] = [MINES] s["by_classification"]["place:place"] -= 1 - s["by_classification"]["uncertain:uncertain"] = 1 - g = group(p, gid) - g.update(classification="uncertain", reason="uncertain") - g["placement"].update(status="tentative", main_connection=None, tentative_reason="made up") - self.assertCaught(p, "alignment hint of {} is not a configured alignment to a placed anchor".format(gid)) - # a configured hint whose layer distance does not match the placement + s["by_classification"]["separate:unreachable"] = 1 + self.assertCaught(p, "separate group {} (unreachable) has a tentative placement".format(MINES), + "tentative placement of {} carries an owner alignment".format(MINES), + "owner alignment of {} to {} is not applied".format(MINES, ROCKFORGE + "rockforge_b")) + p = self.plan() + group(p, MINES)["placement"]["status"] = "tentative" + self.assertCaught(p, "place group {} has no proposed placement".format(MINES), + "place group {} (place) has a tentative placement".format(MINES)) + + def test_a_fabricated_owner_alignment(self): + # a group placed by its exits claimed to be an owner alignment instead + p = self.plan() + group(p, self.GID)["placement"].update(main_connection=None, alignment_reason="made up") + self.assertCaught(p, "owner alignment of {} is not its configured spot under a placed anchor".format(self.GID)) + # a configured alignment whose layer distance does not match the placement + p = self.plan() + p["settings"]["owner_alignments"][0]["layer_delta"] -= 1 + self.assertCaught(p, "owner alignment of {} is not its configured spot".format(MINES)) + # the decided spot is exact: no extra layer, even one the search might have taken + p = self.plan() + pl = group(p, MINES)["placement"] + pl["layer_offset"] -= 1 + pl["extra_layers_from_ideal"] = 1 + for o in pl["member_origins"].values(): + o[2] -= 1 + self.assertCaught(p, "owner alignment of {} is not its configured spot".format(MINES)) + # an alignment with no decision date, or another date than the owner's + for date in ("soon", "2099-12-31", "9999-99-99"): + p = self.plan() + p["settings"]["owner_alignments"][0]["decided"] = date + self.assertCaught(p, "owner alignment of {} to {} is not applied".format(MINES, ROCKFORGE + "rockforge_b")) p = self.plan() - p["settings"]["tentative_alignments"][0]["layer_delta"] -= 1 - self.assertCaught(p, "alignment hint of {} is not a configured alignment".format( - p["settings"]["tentative_alignments"][0]["group"])) + p["settings"]["owner_relayering_judgement_calls"]["decided"] = "9999-99-99" + self.assertCaught(p, "the relayering judgement calls differ from the ones the owner confirmed") def test_a_layer_outside_the_vertical_bounds(self): p = self.plan() @@ -777,17 +794,64 @@ def test_search_limits(self): p["settings"]["max_shift_tiles"] = -1 self.assertCaught(p, "past the search limits") + def test_kept_placed_without_a_footprint(self): + p = self.plan() + self.assertEqual(group(p, MINES)["placement"]["occupied_tiles_by_layer"], {}) + self.assertEqual([d["group"] for d in p["settings"]["owner_no_footprint"]], [MINES]) + for change in (lambda d: d.update(group="/shattered_islands/everlink/everlink"), # not placed + lambda d: d.update(decided="later")): + p = self.plan() + change(p["settings"]["owner_no_footprint"][0]) + self.assertCaught(p, "to keep a group placed without a footprint does not hold in the plan", + "placed group {} has no footprint and no owner decision to keep it placed".format(MINES)) + # the decision dropped or moved to another placed group + p = self.plan() + p["settings"]["owner_no_footprint"] = [] + self.assertCaught(p, "placed group {} has no footprint and no owner decision to keep it placed".format(MINES)) + # ... or a footprint claimed that its rows do not hold + p = self.plan() + p["settings"]["owner_no_footprint"] = [] + group(p, MINES)["placement"]["occupied_tiles_by_layer"] = {"-7": 1} + self.assertCaught(p, "occupied_tiles_by_layer of {} is not what its occupied rows claim ({{}})".format(MINES), + "placed group {} has no footprint and no owner decision to keep it placed".format(MINES)) + p = self.plan() + p["settings"]["owner_no_footprint"][0]["group"] = self.GID + self.assertCaught(p, "placed group {} has no footprint and no owner decision to keep it placed".format(MINES)) + p = self.plan() + p["settings"]["owner_no_footprint"].append(dict(p["settings"]["owner_no_footprint"][0])) + self.assertCaught(p, "to keep a group placed without a footprint does not hold in the plan") + + def test_an_alignment_of_a_group_with_exits(self): + p = self.plan() + deck = "/shattered_islands/deserted_tutorial_island/ship_lower_deck" + group(p, MINES)["entrances_from_other_groups"] = [group(p, deck)["entrances_from_overworld"][0]] + self.assertCaught(p, "owner alignment of {} applies to a group with exits of its own".format(MINES)) + # the decision was made on the sheets of rockforge_a/_b/_c as they were laid out and the a<->b stairs + for change in (lambda p: group(p, MINES)["maps"][0]["tile_links"].pop("N"), + lambda p: group(p, ROCKFORGE + "rockforge_b")["maps"][0]["local_offset"].__setitem__(0, 1), + lambda p: p["settings"]["owner_alignments"][0]["evidence_groups"].remove( + ROCKFORGE + "rockforge_a")): + p = self.plan() + change(p) + self.assertCaught(p, "owner alignment of {} was decided on other evidence".format(MINES)) + p = self.plan() + k = next((e["source_map"], e["line"]) for e in group(p, ROCKFORGE + "rockforge_a")["exits_to_other_groups"] + if e["target_group"] == ROCKFORGE + "rockforge_b") + for _g, _lst, e in copies(p, k): + e["target_tile"] = [e["target_tile"][0] + 1, e["target_tile"][1]] + self.assertCaught(p, "owner alignment of {} was decided on other evidence".format(MINES)) + def test_a_dangling_alignment(self): p = self.plan() - p["settings"]["tentative_alignments"].append( - {"group": "/not/a/group", "anchor": "/also/missing", "layer_delta": 0, "why": "fabricated"}) - self.assertCaught(p, "tentative alignment of /not/a/group to /also/missing is not the hint of a tentative " - "placement") + p["settings"]["owner_alignments"].append({"group": "/not/a/group", "anchor": "/also/missing", "layer_delta": 0, + "decided": "2026-10-19", "why": "fabricated"}) + self.assertCaught(p, "owner alignment of /not/a/group to /also/missing is not applied") # an entry between two ordinary proposed groups is not applied anywhere p = self.plan() a, b = p["summary"]["placement_order"][:2] - p["settings"]["tentative_alignments"].append({"group": b, "anchor": a, "layer_delta": -1, "why": "stale"}) - self.assertCaught(p, "tentative alignment of {} to {} is not the hint of a tentative placement".format(b, a)) + p["settings"]["owner_alignments"].append({"group": b, "anchor": a, "layer_delta": -1, "decided": "2026-10-19", + "why": "stale"}) + self.assertCaught(p, "owner alignment of {} to {} is not applied".format(b, a)) def test_the_overworld_grid(self): p = self.plan() @@ -807,11 +871,11 @@ def test_a_placement_without_status(self): def test_alignment_reasons_are_text(self): p = self.plan() - a = p["settings"]["tentative_alignments"][0] + a = p["settings"]["owner_alignments"][0] a["why"] = ["x"] - group(p, a["group"])["placement"]["tentative_reason"] = ["x"] - self.assertCaught(p, "tentative_reason of {} is not a text".format(a["group"]), - "tentative alignment of {} to {} is not the hint".format(a["group"], a["anchor"])) + group(p, a["group"])["placement"]["alignment_reason"] = ["x"] + self.assertCaught(p, "alignment_reason of {} is not a text".format(a["group"]), + "owner alignment of {} to {} is not applied".format(a["group"], a["anchor"])) def test_place_group_without_a_placement(self): p = self.plan() @@ -833,6 +897,189 @@ def test_summary_lists(self): self.assertCaught(p, "summary failed_placements") +class OwnerDecisions(VerifierTest): + """The owner decisions of 2026-10-19 are re-checked from the JSON (settings.owner_* and their effects).""" + + def sw(self, p, sid): + return next(sw for sw in p["stairwells"] if sw["id"] == sid) + + def test_a_decided_portal_stays_a_portal(self): + p = self.plan() + for _g, _lst, e in copies(p, ZECHNA_HOLE): + self.assertEqual((e["becomes"], e["portal_reason"]), ("portal", "stairwell-blocked")) + e.update(becomes="stairwell", stairwell="SW1") + for k in ("portal_reason", "portal_reason_text", "slope_reachable"): + e.pop(k, None) + self.assertCaught(p, "exit {}:{} is kept a portal by the owner but its record is stairwell".format(*ZECHNA_HOLE)) + + def test_the_blocked_site_carries_the_decision(self): + p = self.plan() + site = p["stairwell_sites_blocked"][0] + decision = site.pop("owner_decision") + self.assertEqual(decision["decided"], "2026-10-19") + self.assertCaught(p, "blocked column stairwell site at [181, 1599, -1] records owner decision None, " + "settings.owner_portals give") + p = self.plan() + p["settings"]["owner_portals"] = [] + self.assertCaught(p, "blocked column stairwell site at [181, 1599, -1] records owner decision {", + "settings.owner_portals give None") + p = self.plan() + p["stairwell_sites_blocked"][0]["owner_decision"]["decision"] = "made up" + self.assertCaught(p, "blocked column stairwell site at [181, 1599, -1] records owner decision") + # the decision was made on this site record: another obstruction in the column, another target end or + # another owner is another premise + for change in (lambda b: b["column_occupied"][0].update(map=b["column_occupied"][0]["map"] + "_x"), + lambda b: b["bottom"].__setitem__(2, -5), + lambda b: b.update(other="/shattered_islands/world_7_66")): + p = self.plan() + change(p["stairwell_sites_blocked"][0]) + self.assertCaught(p, "is kept a portal for only some of its exits or on another site record") + + def test_a_portal_decision_for_an_exit_at_no_blocked_site(self): + p = self.plan() + e = copies(p, CROSSING)[0][2] + p["settings"]["owner_portals"].append(dict(p["settings"]["owner_portals"][0], source_map=e["source_map"], + line=e["line"], source_tile=e["source_tile"], arch=e["arch"])) + self.assertCaught(p, "owner portal {} {}:{} is no exit at a blocked stairwell site".format(e["arch"], *CROSSING), + "exit {}:{} is kept a portal by the owner but its record is stairs".format(*CROSSING)) + # the decision names one exit object: another line (a duplicate hole on the tile, say) is not decided + p = self.plan() + p["settings"]["owner_portals"][0]["line"] += 1 + self.assertCaught(p, "owner portal hole {}:{} is no exit at a blocked stairwell site".format( + ZECHNA_HOLE[0], ZECHNA_HOLE[1] + 1), "records owner decision {") + p = self.plan() + p["settings"]["owner_portals"][0]["decided"] = "" + self.assertCaught(p, "is not an exit at a blocked site with a dated owner decision") + p = self.plan() + p["settings"]["owner_portals"].append(dict(p["settings"]["owner_portals"][0])) + self.assertCaught(p, "is configured twice") + + def test_the_stairwell_appearance_rule(self): + p = self.plan() + self.assertEqual([self.sw(p, i)["intermediate_appearance"] for i in ("SW6", "SR17", "SR18")], + ["stairs_stone_down", "ladder_down", "ladder_down"]) + sw6 = self.sw(p, "SW6") + sw6["intermediate_appearance"] = "ladder_down" + for t in sw6["tiles"][1:-1]: + t["objects"][0]["archetype"] = "ladder_down" + self.assertCaught(p, "SW6 intermediate tiles do not show stairs_stone_down") + # the verifier follows the recorded rule; a stair family still wins over a fallback (SW10: invis_exit and + # stairs_stone_* exits keeps stairs_stone_down) + p = self.plan() + next(f for f in p["settings"]["stairwell_appearance_fallbacks"] + if f["exit_arch"] == "invis_exit")["appearance"] = "ladder_down" + res = verify_plan.verify(p) + self.assertIn("SW6 intermediate tiles do not show ladder_down as a decoration", res["errors"]) + self.assertFalse([e for e in res["errors"] if "SW10" in e], res["errors"][:8]) + p = self.plan() + p["settings"]["stairwell_appearance_fallbacks"] = [] + self.assertCaught(p, *("{} intermediate tiles do not show None".format(i) for i in ("SW6", "SR17", "SR18"))) + # a fallback no stairwell uses is a stale decision + p = self.plan() + p["settings"]["stairwell_appearance_fallbacks"].append( + dict(p["settings"]["stairwell_appearance_fallbacks"][0], exit_arch="teleporter")) + self.assertCaught(p, "stairwell appearance fallback for teleporter dresses [], the owner chose it for " + "['SW6:f2adfb3c19b1']") + # each fallback is bound to the stairwells the owner chose it for, by premise, not by positional id + p = self.plan() + next(f for f in p["settings"]["stairwell_appearance_fallbacks"] + if f["exit_arch"] == "well_bucket")["stairwells"].append({"id": "SR17", "premise": "0" * 64}) + self.assertCaught(p, "stairwell appearance fallback for well_bucket dresses ['SR18:7c7394a44511'], the owner " + "chose it for ['SR18:7c7394a44511', 'SR17:000000000000']") + p = self.plan() # another stairwell in SW6's place: same id, other exits + self.sw(p, "SW6")["direction"] = "down" + self.assertCaught(p, "stairwell appearance fallback for invis_exit dresses ['SW6:") + + def test_appearance_fallbacks_are_dated_exit_archetypes(self): + for change, needle in ((lambda f: f.update(exit_arch="Invis_Exit"), "is not an exit archetype"), + (lambda f: f.update(appearance=""), "is not an exit archetype"), + (lambda f: f.pop("decided"), "is not an exit archetype")): + p = self.plan() + change(p["settings"]["stairwell_appearance_fallbacks"][0]) + self.assertCaught(p, needle) + p = self.plan() + fbs = p["settings"]["stairwell_appearance_fallbacks"] + fbs.append(dict(fbs[0])) + self.assertCaught(p, "stairwell appearance fallback for {} is configured twice".format(fbs[0]["exit_arch"])) + + def test_confirmed_orientations_and_classifications(self): + dark = "/shattered_islands/strakewood_island/dark_cave/dark_cave" + p = self.plan() + group(p, dark)["placement"]["orientation"] = "above" + self.assertCaught(p, "owner orientation decision {{'group': '{}'".format(dark)) + p = self.plan() + p["settings"]["owner_orientations"].append(dict(p["settings"]["owner_orientations"][0])) + self.assertCaught(p, "owner orientation decision", "does not hold in the plan") + # 'below' still, but no longer from a name keyword alone: a directional exit voted + p = self.plan() + group(p, dark)["placement"]["orientation_evidence"].insert( + 0, "exit semantics with upstream groups/overworld: {'below': 1}") + self.assertCaught(p, "owner orientation decision {{'group': '{}'".format(dark)) + # the same classification on other evidence + p = self.plan() + group(p, "/shattered_islands/everlink/everlink")["evidence"][1] = "1 exit object(s) from the overworld lead here" + self.assertCaught(p, "owner classification decision {'group': '/shattered_islands/everlink/everlink'") + # the garden's hole retargeted: the same evidence strings, other content + p = self.plan() + garden = "/shattered_islands/berri_lur/temple_loki/desert_plane/unusual_garden" + hole = group(p, garden)["exits_to_overworld"][0] + for _g, _lst, e in copies(p, (hole["source_map"], hole["line"])): + e["target_tile"] = [e["target_tile"][0] + 1, e["target_tile"][1]] + self.assertCaught(p, "owner classification decision {{'group': '{}'".format(garden)) + # a confirmed orientation on other content: another map name (the keyword's source) + p = self.plan() + cave = "/shattered_islands/eld_woods_island/cave_morlarg" + group(p, cave)["maps"][0]["name"] = "A Tower" + self.assertCaught(p, "owner orientation decision {{'group': '{}'".format(cave)) + # a confirmation the plan carries needs its settings entry + p = self.plan() + p["settings"]["owner_orientations"].pop(0) + p["settings"]["owner_classifications"] = [] + self.assertCaught(p, "/shattered_islands/eld_woods_island/cave_morlarg carries an owner orientation " + "confirmation without its settings entry", + "/hall_of_dms carries an owner classification confirmation without its settings entry") + p = self.plan() + group(p, "/shattered_islands/everlink/everlink")["reason"] = "unreachable" + self.assertCaught(p, "owner classification decision {'group': '/shattered_islands/everlink/everlink'") + p = self.plan() + p["settings"]["owner_classifications"][0]["decided"] = "2026-19-10x" + self.assertCaught(p, "owner classification decision") + + def test_confirmed_relayering_judgement_calls(self): + p = self.plan() + p["relayering"]["judgement_calls"][0]["text"] += " (changed)" + self.assertCaught(p, "the relayering judgement calls differ from the ones the owner confirmed") + # the same call texts over other records: a cave's recorded reasoning (not re-derived elsewhere) + p = self.plan() + p["relayering"]["caves"][0]["why"] += " (changed)" + res = verify_plan.verify(p) + self.assertEqual(res["errors"], ["the relayering judgement calls differ from the ones the owner confirmed (or " + "the records they summarise do)"]) + p = self.plan() + p["settings"]["owner_relayering_judgement_calls"]["calls"].pop() + self.assertCaught(p, "the relayering judgement calls differ from the ones the owner confirmed") + p = self.plan() + p["placement_changes_from_v1"].pop() + self.assertCaught(p, "the placement changes from version 1 differ from the ones the owner confirmed") + # both copies agree, but the group is not placed there + p = self.plan() + p["placement_changes_from_v1"][0]["v2"][2] = 999 + p["settings"]["owner_relayering_judgement_calls"]["placement_changes_from_v1"][0]["v2"][2] = 999 + self.assertCaught(p, "the placement changes from version 1 differ from the ones the owner confirmed") + + def test_the_seam_stair_archetypes(self): + p = self.plan() + ns = p["relayering"]["new_stairs"][0] + self.assertEqual([(o["tile"], o["archetype"]) for o in ns["objects"]], + [("low", "stairs_stone_up"), ("high", "stairs_stone_down")]) + ns["objects"][0]["archetype"] = "stairs_wood_up" # still an up stair, but not the decided one + self.assertCaught(p, "new stairs {} do not use the seam stair archetypes stairs_stone_up/stairs_stone_down" + .format(ns["id"])) + p = self.plan() + p["settings"]["seam_stair_archetypes"]["decided"] = None + self.assertCaught(p, "settings.seam_stair_archetypes records no dated owner decision") + + @unittest.skipUnless(os.environ.get("PLACEMENT_PLAN_CONTENT"), "set PLACEMENT_PLAN_CONTENT to a content checkout") class ContentFidelity(VerifierTest): """Edits that keep the JSON consistent with itself pass verify() but not verify_content().""" @@ -869,6 +1116,25 @@ def test_edits_the_json_only_check_cannot_see(self): group(p, "/emergency")["evidence"][0] = "made up" self.assertUnfaithful(p) + def test_an_owner_decision_dropped_with_its_effect(self): + # which decisions apply is an input of the generator: a decision removed from settings together with its + # effect keeps the JSON consistent with itself, and the rebuild from the generator's constants differs + p = self.plan() + st = p["settings"] + st["stairwell_appearance_fallbacks"] = [f for f in st["stairwell_appearance_fallbacks"] + if f["exit_arch"] != "invis_exit"] + sw6 = next(sw for sw in p["stairwells"] if sw["id"] == "SW6") + sw6.update(intermediate_appearance=None, + intermediate_appearance_source="no stair archetype among ['invis_exit']; owner to choose") + for t in sw6["tiles"][1:-1]: + t["objects"] = [] + self.assertUnfaithful(p) + p = self.plan() + cave = "/shattered_islands/eld_woods_island/cave_morlarg" + p["settings"]["owner_orientations"] = [d for d in p["settings"]["owner_orientations"] if d["group"] != cave] + group(p, cave)["placement"]["orientation_evidence"].pop() + self.assertUnfaithful(p) + def test_type_strict_comparison(self): p = self.plan() # caught by both checks, and by the rebuild comparison type for type p["summary"]["wired_exits"] = 5.0 @@ -895,6 +1161,140 @@ def test_an_overworld_wired_exit_is_no_crossing_stairs(self): self.assertIn("**66 become plain stairs** (5 of them wired dangling exits)", text) self.assertIn("**Wired exits**: 6 legacy exits", text) + def write(self, p): + import tempfile + + import report + + with tempfile.TemporaryDirectory() as tmp: + report.write_report(p, Path(tmp)) + return (Path(tmp) / "REPORT.md").read_text(encoding="utf-8") + + def test_open_decisions_follow_the_plan(self): + p = self.plan() + text = self.write(p) + self.assertIn("### Decided 2026-10-19", text) + self.assertIn("7. **#26 `rockforge_c` stays placed without a footprint**", text) + self.assertIn("Open owner decisions: none.", text) + self.assertIn("## Open owner decisions\n\nNone.", text) + # without the owner's answers, an undecided blocked site and a stairwell without stair art are open + # decisions again + p["stairwell_sites_blocked"][0].pop("owner_decision") + next(sw for sw in p["stairwells"] if sw["id"] == "SW6")["intermediate_appearance"] = None + text = self.write(p) + self.assertIn("1. **Blocked stairwell at #28 `zechna_temple`**", text) + self.assertIn("2. **Stair archetype for stairwells without stairs**: SW6 (#17 `dark_cave`: invis_exit)", text) + self.assertIn("2 owner decisions are open", text) + # once content gives the mines a footprint, the report says so instead of calling them empty + p = self.plan() + group(p, MINES)["placement"]["occupied_tiles_by_layer"] = {"-7": 5} + text = self.write(p) + self.assertIn("7. **#26 `rockforge_c` stays placed** (`settings.owner_no_footprint`", text) + self.assertIn("it now occupies 5 tiles, held to the gap rule like any placement", text) + self.assertNotIn("without a footprint while its maps are empty", text) + self.assertNotIn("holds an object yet", text) + + +class OwnerDecisionRules(unittest.TestCase): + """The generator's owner decisions of 2026-10-19 apply as recorded and fail closed when they no longer do.""" + + HOLE = ("/m", 7, (1, 2), "hole") + + @staticmethod + def site(*exits, bottom=(0, 0, -3)): + return {"top": [0, 0, 0], "bottom": list(bottom), + "exits": [{"source_map": m, "line": ln, "source_tile": list(t), "arch": a} for m, ln, t, a in exits]} + + def decision(self, site): + return {"premise": build_plan.premise(build_plan.portal_evidence(site, {})), "decision": "kept"} + + def test_stairwell_appearance(self): + app = build_plan.stairwell_appearance + self.assertEqual(app(["invis_exit", "stairs_stone_down", "invis_exit"])[0], "stairs_stone_down") + self.assertEqual(app(["invis_exit", "stairs_wood_up"])[0], "stairs_wood_down") # a family wins + self.assertEqual(app(["well_stone", "well_bucket"])[0], "ladder_down") + self.assertEqual(app(["invis_exit", "invis_exit"]), ( + "stairs_stone_down", "owner decision 2026-10-19: no stair archetype among ['invis_exit'], so the " + "invis_exit exits show stairs_stone_down; the intermediate objects are decorations, " + "not exits", "invis_exit")) + self.assertEqual(app(["Well_Bucket"])[2], "well_bucket") # the fallback used, for the staleness check + self.assertIsNone(app(["invis_exit", "stairs_stone_up"])[2]) + self.assertEqual(app(["hole", "teleporter"]), + (None, "no stair archetype among ['hole', 'teleporter']; owner to choose", None)) + + def test_decisions_name_existing_groups(self): + names = set(build_plan.OWNER_CLASSIFICATIONS) | set(build_plan.OWNER_ORIENTATIONS) | \ + set(build_plan.OWNER_NO_FOOTPRINT) | {x for d in build_plan.OWNER_ALIGN.values() + for x in (*d["evidence_groups"], d["anchor"])} + build_plan.check_decision_groups(names | set(build_plan.OWNER_ALIGN)) + with self.assertRaisesRegex(SystemExit, "name groups the content no longer has: \\['{}'\\]".format(MINES)): + build_plan.check_decision_groups(names - {MINES}) + + def test_portal_decisions_mark_their_sites(self): + sites = [self.site(self.HOLE), self.site(("/m", 9, (5, 5), "ladder_down"))] + build_plan.apply_owner_portals(sites, {self.HOLE: self.decision(self.site(self.HOLE))}, {}) + self.assertEqual(sites[0]["owner_decision"], {"decided": "2026-10-19", "decision": "kept"}) + self.assertNotIn("owner_decision", sites[1]) + + def test_a_portal_decision_that_no_longer_applies(self): + decisions = {self.HOLE: self.decision(self.site(self.HOLE))} + for sites, needle in (([self.site(("/m", 9, (5, 5), "ladder_down"))], "not at a blocked stairwell site"), + # another object on the same tile, with the same archetype: not the decided exit + ([self.site(("/m", 8, (1, 2), "hole"))], "not at a blocked stairwell site"), + ([self.site(self.HOLE, ("/m", 8, (1, 3), "hole"))], "only for some of its exits"), + # the same exit at a site whose record changed (here its bottom end) + ([self.site(self.HOLE, bottom=(0, 0, -4))], "or for another site record")): + with self.subTest(needle=needle), self.assertRaisesRegex(SystemExit, needle): + build_plan.apply_owner_portals(sites, decisions, {}) + # the same site whose exit now leads elsewhere (its record changed) + with self.assertRaisesRegex(SystemExit, "or for another site record"): + build_plan.apply_owner_portals([self.site(self.HOLE)], decisions, + {("/m", 7): {"target_map": "/elsewhere"}}) + + def test_the_confirmed_judgement_calls(self): + confirmed = (("k", "U1", "t"), ("j", None, "u")) + calls = build_plan.check_judgement_calls([{"kind": "k", "unit": "U1", "text": "t"}, {"kind": "j", "text": "u"}], + confirmed) + self.assertEqual(calls, [{"kind": "k", "unit": "U1", "text": "t"}, {"kind": "j", "text": "u"}]) + with self.assertRaisesRegex(SystemExit, "they now differ"): + build_plan.check_judgement_calls([{"kind": "k", "unit": "U1", "text": "t2"}, {"kind": "j", "text": "u"}], + confirmed) + + def test_the_committed_plan_records_the_constants(self): + st = json.loads(PLAN.read_text(encoding="utf-8"))["settings"] + self.assertEqual({(d["source_map"], d["line"], tuple(d["source_tile"]), d["arch"]): { + "premise": d["premise"], "decision": d["decision"]} for d in st["owner_portals"]}, build_plan.OWNER_PORTALS) + self.assertEqual({f["exit_arch"]: (f["appearance"], tuple((x["id"], x["premise"]) for x in f["stairwells"])) + for f in st["stairwell_appearance_fallbacks"]}, build_plan.STAIRWELL_APPEARANCE_FALLBACKS) + self.assertEqual(verify_plan.STAIRWELL_PREMISE_FIELDS, build_plan.STAIRWELL_PREMISE_FIELDS) + self.assertEqual({a["group"]: {"anchor": a["anchor"], "layer_delta": a["layer_delta"], + "evidence_groups": tuple(a["evidence_groups"]), "premise": a["premise"], + "why": a["why"]} for a in st["owner_alignments"]}, build_plan.OWNER_ALIGN) + plan = json.loads(PLAN.read_text(encoding="utf-8")) + recs = {g["id"]: g for g in plan["groups"]} + gids = st["owner_alignments"][0]["evidence_groups"] + self.assertEqual(verify_plan.alignment_evidence(recs, gids), build_plan.alignment_evidence(recs, gids)) + # the verifier's copies of the evidence functions agree with the generator's + records = {(e["source_map"], e["line"]): e for g in plan["groups"] for k in build_plan.EXIT_LISTS + for e in g.get(k, [])} + for gid in list(build_plan.OWNER_CLASSIFICATIONS) + list(build_plan.OWNER_ORIENTATIONS): + self.assertEqual(verify_plan.group_evidence(recs[gid]), build_plan.group_evidence(recs[gid])) + site = plan["stairwell_sites_blocked"][0] + self.assertEqual(verify_plan.portal_evidence(site, records), build_plan.portal_evidence(site, records)) + self.assertEqual(verify_plan.relayering_evidence(plan["relayering"], plan["stairwells"]), + build_plan.relayering_evidence(plan["relayering"], plan["stairwells"])) + self.assertEqual({d["group"]: (d["orientation"], tuple(d["evidence"]), d["premise"]) + for d in st["owner_orientations"]}, build_plan.OWNER_ORIENTATIONS) + self.assertEqual(st["owner_relayering_judgement_calls"]["premise"], build_plan.OWNER_RELAYERING_PREMISE) + self.assertEqual({d["group"]: (d["classification"], d["reason"], d["premise"]) + for d in st["owner_classifications"]}, build_plan.OWNER_CLASSIFICATIONS) + self.assertEqual({d["group"]: d["decision"] for d in st["owner_no_footprint"]}, build_plan.OWNER_NO_FOOTPRINT) + self.assertEqual(verify_plan.ALIGN_LAYOUT_FIELDS, build_plan.ALIGN_LAYOUT_FIELDS) + self.assertEqual(verify_plan.premise({"b": [1, "x"], "a": None}), build_plan.premise({"a": None, "b": [1, "x"]})) + self.assertEqual(st["owner_relayering_judgement_calls"]["calls"], build_plan.check_judgement_calls( + st["owner_relayering_judgement_calls"]["calls"], build_plan.OWNER_RELAYERING_JUDGEMENT_CALLS)) + self.assertEqual({k: st["seam_stair_archetypes"][k] for k in ("low", "high")}, build_plan.SEAM_STAIR_ARCHETYPES) + class PlacePool(unittest.TestCase): """Fix 3 in the generator: a failed group stays unplaced and nothing is placed through it.""" diff --git a/migration/placement-plan/scripts/verify_plan.py b/migration/placement-plan/scripts/verify_plan.py index acbdddd..be78e8a 100644 --- a/migration/placement-plan/scripts/verify_plan.py +++ b/migration/placement-plan/scripts/verify_plan.py @@ -34,18 +34,24 @@ (the overworld's map and per-layer counts from its cells) Placement (every placed group, proposed and tentative): * every place group has a proposed placement and every uncertain group a - tentative one (an unreachable group may have one from an alignment hint); - a failed placement fails the plan; the summary's placement order, unplaced - groups and failed placements are re-derived + tentative one (no other group has one); a failed placement fails the + plan; the summary's placement order, unplaced groups and failed + placements are re-derived * offsets = ideal + shift_from_ideal and extra_layers_from_ideal in the orientation's direction; the ideal follows from the main connection (its far end, one layer away in the exit's direction, within the recorded vertical bounds), which is one of the group's exits to the overworld or - an earlier placed group; only an alignment-hinted tentative placement - has none (a proposed placement carries no hint), and its hint is one of - settings.tentative_alignments: its ideal is the anchor's offsets, the - configured layers away; every configured alignment is applied (its - group's tentative placement carries it) + an earlier placed group; only a placement the owner fixed under an + anchor has none: it is proposed, carries one of settings.owner_alignments + (alignment_reason) and sits exactly at the anchor's offsets, the + configured layers away, with no shift or extra layer; every configured + alignment is applied (its group's proposed placement carries it) to a + group without exit records of its own, on the evidence the owner decided + on (the alignment's premise: the sheet layouts of its evidence groups, + the group and its anchor among them, and the exits between them); every + placed group without a footprint has an owner decision to keep it placed + (settings.owner_no_footprint), and every such decision names a proposed + placement * no placement goes further from its ideal spot than max_extra_layers and max_shift_tiles allow, or outside its vertical bounds * member plane origin == local_offset + (x_offset, y_offset, layer_offset); @@ -78,8 +84,12 @@ (the same limit the blocked-site chain search uses) * kind, shape (from its ends), id series, max_bend, owners (group, owner, other, connects: from the exits it replaces) and the intermediate tiles' - decoration (the exits' stair family) are re-derived; so are the summary's - stairwell and blocked-site counts + decoration (the exits' stair family, else the owner's fallback in + settings.stairwell_appearance_fallbacks for the first exit archetype that + has one; each fallback dresses exactly the stairwells recorded with it, + each by id and by the premise of its kind, owners, direction and replaced + exits) are re-derived; so are the summary's stairwell and blocked-site + counts Exits: * exit_inventory lists every exit object of the content once, with values of the right types, a known form and direction, a target exactly when its @@ -114,6 +124,11 @@ * every exit 2+ layers and at most one tile per layer apart is a stairwell (crossing exits always, overworld exits when the relayering changed their span), unless a blocked site lists it; a blocked site has no void chain + * every exit the owner keeps a portal (settings.owner_portals: one exit + object by source map, line, tile and archetype) is at a blocked site whose + record, with the records of its exits, is the one the decision was made on + (its premise) and becomes a portal; a blocked site carries that owner_decision exactly when all of its + exits are kept portals by it Relayering (re-derived from the overworld rows and tile heights): * regions: 8-connected tiles of one layer with the same q = H // layer height >= 1; relayered when large enough or touching a relayered region @@ -123,8 +138,20 @@ adjustment (adjustment <= 0), split tiles keep their ground part * column order is kept in every column, no cave tile reaches layer 0 and no moved tile ends on layer 0 - * cut walkable edges, seams, seam pieces and their new stairs; steep steps; - new contacts; moved maps and the before/after layer deltas of exits + * cut walkable edges, seams, seam pieces and their new stairs (with the + archetypes of settings.seam_stair_archetypes); steep steps; new contacts; + moved maps and the before/after layer deltas of exits +Owner confirmations (settings.owner_*): every decision is dated with the date +schema /5 records (OWNER_DECISION_DATES), and the plan is still what the owner +confirmed, on the same evidence: the confirmed +groups' orientations with their evidence and the premise of the group's +content (maps, exit records, script references), their classifications with +the premise of their evidence and content, the relayering judgement calls with +the premise of every record behind them (regions, units, caves, split tiles, +seams, new stairs, steep steps, contacts, one-way stairwells), and the placement +changes from version 1 the lowered caves cause (each ending at its group's +offsets); every confirmation the groups carry has its settings entry. A +premise is the SHA-256 of the evidence record's canonical JSON. Not re-derived: the generator's explanations and evidence, which the migration does not act on. That is classification evidence, reach depth and script references; the placement rationale beyond the consistency above @@ -139,7 +166,8 @@ connectivity data problems. This check uses only the JSON, so it cannot see an edit that keeps the JSON consistent with itself (an exit dropped from exit_inventory together with -summary.exit_objects, say). `build_plan.py --check` covers that: it +summary.exit_objects, say, or an owner decision dropped from settings together +with its effect: which decisions apply is an input of the generator). `build_plan.py --check` covers that: it regenerates from the content and fails unless every artifact is byte-identical. Exit status 0 when every check passes. @@ -159,7 +187,7 @@ WORLD_RE = re.compile(r"/world_(-?\d+)_(-?\d+)(?:_(-?\d+))?$") OW_SIZE = 24 # overworld maps are 24 x 24; plane tile = 24 * filename coordinate + tile -SCHEMA = "atrinik-overworld-placement-plan/4" # /4 adds exit_inventory and the failed-placement bookkeeping +SCHEMA = "atrinik-overworld-placement-plan/5" # /5 records the owner decisions of 2026-10-19 (settings.owner_*) MAX_PLAN_BYTES = 64 * 1024 * 1024 # the plan is about 3 MB; a larger file is not a plan # The plan's numbers are integers; JSON booleans appear only under these flag keys, and no value is a float. BOOL_KEYS = {"content_worktree_clean", "segments_drawn_only_on_player_layer", "existing", "exit", "walkable", @@ -235,6 +263,81 @@ def cheb(a, b): return max(abs(a[0] - b[0]), abs(a[1] - b[1])) +def premise(evidence) -> str: + """Fingerprint of the evidence an owner decision was made on: SHA-256 of its canonical JSON + (build_plan.premise).""" + canon = json.loads(json.dumps(evidence)) + return hashlib.sha256(json.dumps(canon, sort_keys=True, separators=(",", ":")).encode()).hexdigest() + + +# What each premise covers (build_plan's EXIT_CONTENT_FIELDS, GROUP_MAP_FIELDS and *_evidence functions). +EXIT_CONTENT_FIELDS = ("source_map", "line", "source_tile", "target_map", "target_tile", "arch", "form", "vertical", + "vertical_inferred_from") +GROUP_MAP_FIELDS = ("path", "name", "region", "width", "height", "local_offset", "tile_links", "occupied_tiles") +GROUP_EXIT_LISTS = ("entrances_from_overworld", "exits_to_overworld", "entrances_from_other_groups", + "exits_to_other_groups") + + +def group_evidence(rec: dict) -> dict: + """A group's maps, the content fields of every exit record it holds, and its script references.""" + exits = {} + for key in GROUP_EXIT_LISTS: + for e in rec.get(key, []): + exits[(e["source_map"], e["line"])] = {f: e.get(f) for f in EXIT_CONTENT_FIELDS} + return {"maps": [{k: m.get(k) for k in GROUP_MAP_FIELDS} for m in rec["maps"]], + "exits": [exits[k] for k in sorted(exits)], + "scripts": rec.get("script_and_dialog_references", []), "redirects": rec.get("script_redirects", [])} + + +def portal_evidence(site: dict, records: dict) -> dict: + """A blocked site record and the full record of each of its exits.""" + return {"site": {k: v for k, v in site.items() if k != "owner_decision"}, + "exits": [records.get((x["source_map"], x["line"])) for x in site["exits"]]} + + +def relayering_evidence(rl: dict, stairwells: list) -> dict: + """The relayering judgement calls, every record behind them and the one-way relayering stairwells.""" + out = {k: rl[k] for k in ("judgement_calls", "regions", "kept_regions", "units_moved", "caves", "split_tiles", + "seams", "new_stairs", "steep_steps", "new_contacts")} + out["one_way_stairwells"] = [{k: sw.get(k) for k in STAIRWELL_PREMISE_FIELDS} for sw in stairwells + if sw.get("kind") == "relayering" and sw.get("direction") != "both"] + return out + + +# The fields of each member map an owner alignment's premise covers, in group order (build_plan.ALIGN_LAYOUT_FIELDS), +# and of a stairwell record its premise covers (build_plan.STAIRWELL_PREMISE_FIELDS). +ALIGN_LAYOUT_FIELDS = ("path", "width", "height", "local_offset", "tile_links") +STAIRWELL_PREMISE_FIELDS = ("kind", "group", "owner", "other", "connects", "direction", "replaces") +# The evidence an owner confirmation appends: to a placement's orientation_evidence, to a group's evidence. +ORIENTATION_MARK = re.compile(r"owner decision \d{4}-\d{2}-\d{2}: .*") +CLASSIFICATION_MARK = re.compile(r"owner decision \d{4}-\d{2}-\d{2}: .* confirmed \(a version 1 judgement call\)") + + +def alignment_evidence(recs: dict, gids) -> dict: + """What an owner alignment rests on (build_plan.alignment_evidence): each evidence group's sheet layout and the + content fields of every exit record between two of them.""" + gids = list(gids) + exits = {} + for gid in gids: + for key in ("entrances_from_other_groups", "exits_to_other_groups"): + for e in recs[gid].get(key, []): + if e["source_group"] in gids and e["target_group"] in gids: + exits[(e["source_map"], e["line"])] = [e["source_map"], e["source_tile"], e["target_map"], + e["target_tile"], e["arch"]] + return {"layouts": {gid: [{k: m.get(k) for k in ALIGN_LAYOUT_FIELDS} for m in recs[gid]["maps"]] for gid in gids}, + "exits": [exits[k] for k in sorted(exits)]} + + +# The dates of the owner decisions schema /5 records (REPORT.md presents them as the owner's answers of these days). +OWNER_DECISION_DATES = {"2026-10-19"} + + +def dated(d, text="decision") -> bool: + """Whether a settings entry records an owner decision: one of OWNER_DECISION_DATES and a non-empty text.""" + return isinstance(d, dict) and d.get("decided") in OWNER_DECISION_DATES and isinstance(d.get(text), str) and \ + bool(d[text]) + + class Rows: """Occupied tiles of one owner on one layer, indexed by row for range queries.""" @@ -650,6 +753,18 @@ def _verify(plan: dict) -> dict: ow = next(g for g in plan["groups"] if g["classification"] == "overworld") ow_id = ow["id"] rl = plan["relayering"] + # the owner's stairwell appearance fallbacks (decision 2026-10-19), by exit archetype + fallbacks = {} + for f in st["stairwell_appearance_fallbacks"]: + a = f.get("exit_arch") if isinstance(f, dict) else None + if not (isinstance(a, str) and a and a == a.casefold() and isinstance(f.get("appearance"), str) + and f["appearance"] and dated(f)): + errors.append("stairwell appearance fallback {} is not an exit archetype with an appearance and a dated " + "owner decision".format(f)) + continue + if a in fallbacks: + errors.append("stairwell appearance fallback for {} is configured twice".format(a)) + fallbacks[a] = f # ------------------------------------------------------------ occupancy owners = collections.defaultdict(dict) # layer -> owner -> Rows @@ -753,12 +868,9 @@ def put(owner, mapname, layer, x0, y0, rows): errors.append("{} group {} has no {} placement".format(g["classification"], gid, want)) if s == "proposed" and g["classification"] != "place": errors.append("{} group {} has a proposed placement".format(g["classification"], gid)) - # tentative: an uncertain group, or an unreachable group placed only by an alignment hint - if s == "tentative" and g["classification"] != "uncertain" and not ( - g["classification"] == "separate" and g["reason"] == "unreachable" - and g["placement"].get("tentative_reason")): - errors.append("{} group {} ({}) has a tentative placement without an alignment hint".format( - g["classification"], gid, g["reason"])) + # tentative: an uncertain group only (an owner alignment is a proposed placement) + if s == "tentative" and g["classification"] != "uncertain": + errors.append("{} group {} ({}) has a tentative placement".format(g["classification"], gid, g["reason"])) index = {g["id"]: g["placement"].get("placement_index") for g in placed} order = summ["placement_order"] + summ["tentative_placement_order"] if sorted(order) != sorted(index) or [index[gid] for gid in order] != list(range(len(order))) or \ @@ -1118,6 +1230,7 @@ def tile_ok(t_): sw_tiles = {} intermediate = 0 kinds = collections.Counter() + fallbacks_used = collections.defaultdict(list) # exit archetype -> the stairwells its fallback dresses sr_owners = {sw["owner"] for sw in plan["stairwells"] if sw["kind"] == "relayering"} for sw in plan["stairwells"]: kinds[sw["kind"]] += 1 @@ -1177,14 +1290,24 @@ def tile_ok(t_): errors.append("{} owners (group {}, owner {}, other {}, connects {}) do not follow from the exits it " "replaces".format(sw["id"], sw.get("group"), sw.get("owner"), sw.get("other"), sw.get("connects"))) - # intermediate tiles show the down archetype of the exits' stair family; they are not exits - ends_arch = sorted({r["arch"] for r in sw["replaces"] if r["goes"] == "down"}) + \ - sorted({r["arch"] for r in sw["replaces"] if r["goes"] == "up"}) + # intermediate tiles show the down archetype of the exits' stair family, else the owner's fallback for + # the first exit archetype that has one; they are not exits + ends_arch = list(dict.fromkeys(sorted({r["arch"] for r in sw["replaces"] if r["goes"] == "down"}) + + sorted({r["arch"] for r in sw["replaces"] if r["goes"] == "up"}))) fam = next((down for a in ends_arch for prefix, down in STAIR_FAMILIES if a.casefold().startswith(prefix)), None) - app_src = ("appearance of the down archetype of the {} family used by this stairwell's exits; the " - "intermediate objects are decorations, not exits".format(fam.rsplit("_", 1)[0]) if fam else - "no stair archetype among {}; owner to choose".format(ends_arch)) + if fam: + app_src = ("appearance of the down archetype of the {} family used by this stairwell's exits; the " + "intermediate objects are decorations, not exits".format(fam.rsplit("_", 1)[0])) + else: + fb = next(((a, fallbacks[a.casefold()]) for a in ends_arch if a.casefold() in fallbacks), None) + fam = fb[1]["appearance"] if fb else None + if fb: + fallbacks_used[fb[0].casefold()].append( + {"id": sw["id"], "premise": premise({k: sw.get(k) for k in STAIRWELL_PREMISE_FIELDS})}) + app_src = ("owner decision {}: no stair archetype among {}, so the {} exits show {}; the intermediate " + "objects are decorations, not exits".format(fb[1]["decided"], ends_arch, fb[0], fam) if fb + else "no stair archetype among {}; owner to choose".format(ends_arch)) want_objs_mid = [{"archetype": fam, "exit": False, "walkable": False, "use": "appearance only"}] if fam else [] if sw.get("intermediate_appearance") != fam or sw.get("intermediate_appearance_source") != app_src or \ any(x_.get("objects") != want_objs_mid or x_.get("existing") is not False for x_ in t[1:-1]): @@ -1252,6 +1375,11 @@ def tile_ok(t_): errors.append("{} tile {} contacts {} != recorded {}".format(sw["id"], key, touch, touched.get(key, []))) if sw.get("mode") != ("clear" if not sw["view_conflicts"] and not sw["contacts"] else "void-only"): errors.append("{} mode {} disagrees with its view conflicts and contacts".format(sw["id"], sw["mode"])) + for a, f in sorted(fallbacks.items()): + if fallbacks_used.get(a, []) != f.get("stairwells"): + errors.append("stairwell appearance fallback for {} dresses {}, the owner chose it for {} (id:premise)".format( + a, ["{}:{}".format(x["id"], x["premise"][:12]) for x in fallbacks_used.get(a, [])], + ["{}:{}".format(x.get("id"), str(x.get("premise"))[:12]) for x in f.get("stairwells") or []])) # ---------------------------------------------------------------- exits replaced_by = collections.defaultdict(list) # exit key -> stairwells listing it in replaces @@ -1311,6 +1439,50 @@ def needs_stairwell(e): key_, b.get("group"), b.get("other"), *k)) blocked_keys[k] = (key_, b) + # Owner decision 2026-10-19: the exits the owner keeps portals (settings.owner_portals) are exit objects + # (source map, line, tile, archetype) at blocked stairwell sites with the recorded ends, every one of them a + # portal; a site carries the decision exactly when all its exits are kept portals by it, and no other site + # carries one. + def portal_key(x): + return x.get("source_map"), x.get("line"), tuple(x.get("source_tile") or ()), x.get("arch") + + decided_portals = {} + for d in st["owner_portals"]: + k = portal_key(d) if isinstance(d, dict) else None + if k is None or type(k[1]) is not int or not dated(d) or not isinstance(d.get("premise"), str): + errors.append("owner portal {} is not an exit at a blocked site with a dated owner decision".format(d)) + continue + if k in decided_portals: + errors.append("owner portal {} is configured twice".format(k)) + decided_portals[k] = d + at_site = set() + for key_, sites in (("column", plan["stairwell_sites_blocked"]), ("sloped", plan["sloped_stairwell_sites_blocked"]), + ("relayering", rl["stairwells_blocked"])): + for b in sites: + ks = [portal_key(x) for x in b["exits"]] + ds = [decided_portals.get(k) for k in ks] + want = None + if any(ds): + now = premise(portal_evidence(b, records)) + if not all(ds) or len({(d["decided"], d["decision"]) for d in ds}) != 1 or \ + any(d["premise"] != now for d in ds): + errors.append("blocked {} stairwell site {} -> {} is kept a portal for only some of its exits or " + "on another site record".format(key_, b["top"], b["bottom"])) + else: + want = {"decided": ds[0]["decided"], "decision": ds[0]["decision"]} + at_site.update(k for k, d in zip(ks, ds) if d) + if b.get("owner_decision") != want: + errors.append("blocked {} stairwell site at {} records owner decision {}, settings.owner_portals give " + "{}".format(key_, b["top"], b.get("owner_decision"), want)) + for k in sorted(decided_portals, key=repr): + if k not in at_site: + errors.append("owner portal {} {}:{} is no exit at a blocked stairwell site".format(k[3], k[0], k[1])) + e = records.get((k[0], k[1])) + if e is None or (tuple(e["source_tile"]), e["arch"]) != (k[2], k[3]) or e.get("becomes") != "portal": + errors.append("exit {}:{} is kept a portal by the owner but its record {}".format( + k[0], k[1], "is missing" if e is None else "is {} ({} at {})".format( + e.get("becomes"), e["arch"], e["source_tile"]))) + def portal_reason(e, k, s, dist, dl): """The generator's reason an exit stays a portal, its text, and whether a sloped stairwell would reach.""" if k in blocked_keys and blocked_keys[k][0] == "sloped": @@ -1337,18 +1509,31 @@ def portal_reason(e, k, s, dist, dl): placed_by_id = {g["id"]: g for g in placed} aligns = {} - for a in st.get("tentative_alignments", []): + for a in st["owner_alignments"]: if a.get("group") in aligns: - errors.append("tentative alignment of {} is configured twice".format(a.get("group"))) + errors.append("owner alignment of {} is configured twice".format(a.get("group"))) aligns[a.get("group")] = a - # a configured alignment names real groups and was applied: its group's tentative placement carries + # a configured alignment names real groups and was applied: its group's proposed placement carries # it (checked against the anchor below), so a stale or made-up entry fails g_ = placed_by_id.get(a.get("group")) - if g_ is None or a.get("anchor") not in placed_by_id or not isinstance(a.get("layer_delta"), int) or \ - not isinstance(a.get("why"), str) or not a["why"] or g_["placement"].get("status") != "tentative" or \ - g_["placement"].get("tentative_reason") != a["why"]: - errors.append("tentative alignment of {} to {} is not the hint of a tentative placement".format( - a.get("group"), a.get("anchor"))) + if g_ is None or a.get("anchor") not in placed_by_id or type(a.get("layer_delta")) is not int or \ + not dated(a, "why") or g_["placement"].get("status") != "proposed" or \ + g_["placement"].get("alignment_reason") != a["why"]: + errors.append("owner alignment of {} to {} is not applied: the group has no proposed placement that " + "carries it".format(a.get("group"), a.get("anchor"))) + # the decision is for a group with no exit of its own (one with exits is placed by them), laid out as + # it was when the owner decided + elif any(g_.get(key) for key in ("entrances_from_overworld", "exits_to_overworld", + "entrances_from_other_groups", "exits_to_other_groups")): + errors.append("owner alignment of {} applies to a group with exits of its own".format(a.get("group"))) + # ... and on the evidence its reason rests on: the evidence groups' sheets and the exits between them + elif not isinstance(a.get("evidence_groups"), list) or a["group"] not in a["evidence_groups"] or \ + a["anchor"] not in a["evidence_groups"] or \ + any(x not in {g["id"] for g in plan["groups"]} for x in a["evidence_groups"]) or \ + premise(alignment_evidence({g["id"]: g for g in plan["groups"]}, a["evidence_groups"])) != \ + a.get("premise"): + errors.append("owner alignment of {} was decided on other evidence (the layout of {} and the exits " + "between them)".format(a.get("group"), a.get("evidence_groups"))) exits_checked = 0 for (src_map, line), e in sorted(records.items()): live = all(x == ow_id or x in placed_ids for x in (e["source_group"], e["target_group"])) @@ -1507,27 +1692,28 @@ def counted(es): g["id"], ideal, sh, extra, orient)) continue # the main connection is one of the group's exit records, to something placed before it; - # only a placement forced by an alignment hint has none + # only a placement the owner fixed under an anchor (an owner alignment) has none mc = pl.get("main_connection") - hinted = bool(pl.get("tentative_reason")) - if "tentative_reason" in pl and not (isinstance(pl["tentative_reason"], str) and pl["tentative_reason"]): - errors.append("tentative_reason of {} is not a text".format(g["id"])) - if hinted and pl.get("status") != "tentative": - errors.append("{} placement of {} carries an alignment hint".format(pl.get("status"), g["id"])) - hinted = False - if hinted: - # a configured alignment hint: the group's ideal spot is its anchor's, the given layers away + aligned = "alignment_reason" in pl + if aligned and not (isinstance(pl["alignment_reason"], str) and pl["alignment_reason"]): + errors.append("alignment_reason of {} is not a text".format(g["id"])) + if aligned and pl.get("status") != "proposed": + errors.append("{} placement of {} carries an owner alignment".format(pl.get("status"), g["id"])) + aligned = False + if aligned: + # a configured owner alignment is the spot itself: the anchor's x/y, the given layers away a = aligns.get(g["id"]) anchor = placed_by_id.get(a["anchor"]) if a else None - if anchor is None or pl["tentative_reason"] != a["why"] or ideal != { - "dx": anchor["placement"]["x_offset"], "dy": anchor["placement"]["y_offset"], - "L": anchor["placement"]["layer_offset"] + a["layer_delta"]} or \ + spot = None if anchor is None else { + "dx": anchor["placement"]["x_offset"], "dy": anchor["placement"]["y_offset"], + "L": anchor["placement"]["layer_offset"] + a["layer_delta"]} + if anchor is None or pl["alignment_reason"] != a["why"] or ideal != spot or sh != [0, 0] or extra != 0 or \ not index.get(a["anchor"], len(index)) < index.get(g["id"], -1): - errors.append("alignment hint of {} is not a configured alignment to a placed anchor".format(g["id"])) - if mc is None or hinted: - if (mc is None) != hinted: + errors.append("owner alignment of {} is not its configured spot under a placed anchor".format(g["id"])) + if mc is None or aligned: + if (mc is None) != aligned: errors.append("placement of {} has {}".format( - g["id"], "no main connection" if mc is None else "both a main connection and an alignment hint")) + g["id"], "no main connection" if mc is None else "both a main connection and an owner alignment")) continue if mc.get("kind") not in ("in", "out"): errors.append("main connection of {} has kind {}".format(g["id"], mc.get("kind"))) @@ -1666,6 +1852,77 @@ def moved_end(g, mp, t): "each".format(g["id"])) if rl["unresolved"] or rl["overlaps"]: errors.append("relayering left unresolved conflicts or overlaps") + + # ------------------------------------------------- owner confirmations + # Decisions of 2026-10-19 that confirm what the rules derived: the plan must still be what was confirmed. + by_id = {g["id"]: g for g in plan["groups"]} + for what, entries, mark, check in ( + # the derived orientation and its evidence, then the decision + ("orientation", st["owner_orientations"], ORIENTATION_MARK, + lambda d, g: (g.get("placement") or {}).get("status") == "proposed" + and g["placement"].get("orientation") == d.get("orientation") and isinstance(d.get("evidence"), list) + and g["placement"].get("orientation_evidence") == d["evidence"] + [ + "owner decision {}: {}".format(d["decided"], d["decision"])] + and premise(group_evidence(g)) == d.get("premise")), + # the derived classification on the same evidence (its premise), then the confirmation + ("classification", st["owner_classifications"], CLASSIFICATION_MARK, + lambda d, g: [g["classification"], g["reason"]] == [d.get("classification"), d.get("reason")] + and g["evidence"][-1:] == ["owner decision {}: {} confirmed (a version 1 judgement call)".format( + d["decided"], d.get("reason"))] + and premise({"evidence": g["evidence"][:-1], "group": group_evidence(g)}) == d.get("premise"))): + seen_ = set() + for d in entries: + g = by_id.get(d.get("group")) if isinstance(d, dict) else None + if g is None or not dated(d) or d["group"] in seen_ or not check(d, g): + errors.append("owner {} decision {} does not hold in the plan".format(what, d)) + else: + seen_.add(d["group"]) + # every confirmation the plan carries is one of these settings entries + marked = {g["id"] for g in plan["groups"] if any(isinstance(t, str) and mark.fullmatch(t) for t in ( + (g.get("placement") or {}).get("orientation_evidence") or [] if what == "orientation" else g["evidence"]))} + for gid in sorted(marked - seen_): + errors.append("{} carries an owner {} confirmation without its settings entry".format(gid, what)) + # groups the owner keeps placed though they may have no footprint (they stay valid when content arrives: + # the footprint then follows from the rows and the gap rule above applies) + kept_placed = set() + for d in st["owner_no_footprint"]: + g = by_id.get(d.get("group")) if isinstance(d, dict) else None + if g is None or not dated(d) or d["group"] in kept_placed or \ + (g.get("placement") or {}).get("status") != "proposed": + errors.append("owner decision {} to keep a group placed without a footprint does not hold in the " + "plan".format(d)) + else: + kept_placed.add(d["group"]) + # ... and every placed group without a footprint has that decision (a decided group may gain one later); the + # footprint is the one its member rows claim, which occupied_tiles_by_layer must repeat + for g in placed: + rows_count = {str(z): len(set(by[g["id"]].tiles())) for z, by in sorted(owners.items()) + if g["id"] in by and by[g["id"]].count} + if g["placement"].get("occupied_tiles_by_layer") != rows_count: + errors.append("occupied_tiles_by_layer of {} is not what its occupied rows claim ({})".format( + g["id"], rows_count)) + if not rows_count and g["id"] not in kept_placed: + errors.append("placed group {} has no footprint and no owner decision to keep it placed".format(g["id"])) + rj = st["owner_relayering_judgement_calls"] + if not dated(rj) or rj.get("calls") != rl["judgement_calls"] or \ + premise(relayering_evidence(rl, plan["stairwells"])) != rj.get("premise"): + errors.append("the relayering judgement calls differ from the ones the owner confirmed (or the records they " + "summarise do)") + # each confirmed change ends where its group is placed now (its version 1 offsets are not in the plan) + if rj.get("placement_changes_from_v1") != [{"group": x["group"], "v1": x["v1"], "v2": x["v2"]} + for x in plan["placement_changes_from_v1"]] or any( + x["v2"] != [(by_id.get(x["group"], {}).get("placement") or {}).get(f) + for f in ("x_offset", "y_offset", "layer_offset")] + for x in rj.get("placement_changes_from_v1") or []): + errors.append("the placement changes from version 1 differ from the ones the owner confirmed") + ssa = st["seam_stair_archetypes"] + if not dated(ssa): + errors.append("settings.seam_stair_archetypes records no dated owner decision") + for n in rl["new_stairs"]: + if sorted((o.get("tile"), o.get("archetype")) for o in n["objects"]) != [("high", ssa.get("high")), + ("low", ssa.get("low"))]: + errors.append("new stairs {} do not use the seam stair archetypes {}/{}".format( + n["id"], ssa.get("low"), ssa.get("high"))) return {"ok": not errors, "errors": errors, "member_maps": len(origin), "owner_pairs_checked": pairs, "closest_pairs": closest, "stairwells_checked": len(plan["stairwells"]), "stairwells_by_kind": dict(sorted(kinds.items())),