From d78df16aaf1be7653e280557ccd30869affc1bc2 Mon Sep 17 00:00:00 2001 From: pjerra <71784785+pjerra@users.noreply.github.com> Date: Sun, 18 Oct 2026 21:24:25 +0200 Subject: [PATCH 01/70] Give Brother Wystan and Duke Dreadmoore boss steps in the SM drafts Both are Turtle additions that the world export already carries as map 189 spawn rows, but neither was in a plan's boss list: the Scarlet Monastery drafts only pulled them as members of a pack, so a bot run neither names them as a boss nor keeps its step count in line with the wing it clears. scarlet_library: bosses 3974, 61983, 6487. Brother Wystan (EventAI, spell list 180317 with Fireball 8402, movement_type 2) stands on the way to Arcanist Doan, so the new leg sits between Loksey's and Doan's. scarlet_graveyard: bosses 3983, 61972, 14693, 4543. Duke Dreadmoore lies in the crypt level (z -3.7, script npc_duke_dreadmoore sets him to 20 % health on reset and yells on aggro and death); his crypt pack stays needs_walk until that part is mapped. Generated with tools/dungeongen from the spec (no hand edits to the drafts). plan-check at origin/dev e50dc28: scarlet_library 88 points, 2 warnings, exit 0 -> 89 points, 1 warning (a detour on leg brother_wystan), exit 0 scarlet_graveyard 1 of 141 points fail, exit 11 -> 1 of 143, exit 11 (the same needs_walk crypt group, now leg duke_dreadmoore); the plan stays on planCheckKnownFailing Legs: library 18/24/2, graveyard 14/6/22/11. --- docs/DUNGEON-PROGRESS.md | 4 +- docs/PLANS.md | 4 +- dungeons/scarlet_graveyard.json | 120 +++++++++--------- dungeons/scarlet_library.json | 211 ++++++++++++++++---------------- tools/dungeongen/plans.json | 8 +- 5 files changed, 183 insertions(+), 164 deletions(-) diff --git a/docs/DUNGEON-PROGRESS.md b/docs/DUNGEON-PROGRESS.md index 5837a78d..a8b0bcaf 100644 --- a/docs/DUNGEON-PROGRESS.md +++ b/docs/DUNGEON-PROGRESS.md @@ -33,8 +33,8 @@ Legend: ✓ done, — not done. PR numbers are in the `pjerra/turtle-agent` repo | The Stormwind Stockade | 23-28 | [zone 717](https://www.wowhead.com/classic/zone=717) (22-30) | `dungeons/stormwind_stockade.json` (5 legs) | — | — | — | — | Not started (draft plan only) | | Razorfen Kraul | 26-31 | [zone 491](https://www.wowhead.com/classic/zone=491) (24-40) | `dungeons/razorfen_kraul.json` (7 legs) | — | — | — | — | Not started (draft plan only) | | Gnomeregan | 27-33 | [zone 721](https://www.wowhead.com/classic/zone=721) (24-40) | `dungeons/gnomeregan.json` (5 legs) | — | — | — | — | Not started (draft plan only) | -| Scarlet Monastery: Graveyard | 30-35 | [zone 796](https://www.wowhead.com/classic/zone=796) (29-45) | `dungeons/scarlet_graveyard.json` (3 legs) | — | — | — | — | Not started (draft plan only) | -| Scarlet Monastery: Library | 33-38 | [zone 796](https://www.wowhead.com/classic/zone=796) (29-45) | `dungeons/scarlet_library.json` (2 legs) | — | — | — | — | Not started (draft plan only) | +| Scarlet Monastery: Graveyard | 30-35 | [zone 796](https://www.wowhead.com/classic/zone=796) (29-45) | `dungeons/scarlet_graveyard.json` (4 legs) | — | — | — | — | Not started (draft plan only) | +| Scarlet Monastery: Library | 33-38 | [zone 796](https://www.wowhead.com/classic/zone=796) (29-45) | `dungeons/scarlet_library.json` (3 legs) | — | — | — | — | Not started (draft plan only) | | Scarlet Monastery: Armory | 35-40 | [zone 796](https://www.wowhead.com/classic/zone=796) (29-45) | `dungeons/scarlet_armory.json` (2 legs) | — | — | — | — | Not started (draft plan only) | | Razorfen Downs | 35-40 | [zone 722](https://www.wowhead.com/classic/zone=722) (33-47) | `dungeons/razorfen_downs.json` (4 legs) | — | — | — | — | Not started (draft plan only) | | Scarlet Monastery: Cathedral | 37-42 | [zone 796](https://www.wowhead.com/classic/zone=796) (29-45) | `dungeons/scarlet_cathedral.json` (3 legs) | — | — | — | — | Not started (draft plan only) | diff --git a/docs/PLANS.md b/docs/PLANS.md index ce4d7a17..5d5be52e 100644 --- a/docs/PLANS.md +++ b/docs/PLANS.md @@ -42,9 +42,9 @@ positions are the creatures' own, so one up to 4 yd off the floor or 2.5 yd to t | 47 | `razorfen_kraul`: Razorfen Kraul | 26-31 | 7 | draft | yes | Agathelos' Ward (GO_AGATHELOS_WARD) is opened by the keep script before Agathelos the Raging; Willix the Importer escort (optional) | | 816 | `dragonmaw_retreat`: Dragonmaw Retreat | 26-31 | 5 | draft | yes | none known | | 90 | `gnomeregan`: Gnomeregan | 27-33 | 5 | draft | yes | the gnome-face doors and their six buttons (GO_GNOME_FACE_1-6, GO_BUTTON_1-6) and the bomb faces before Thermaplugg; Explosive Charges (GO_EXPLOSIVE_CHARGE) to open the north and south cave-ins (GO_CAVE_IN_NORTH/SOUTH) on the way in; Grubbis (7361) has no spawn row | -| 189 | `scarlet_graveyard`: Scarlet Monastery: Graveyard | 30-35 | 3 | draft | yes | none known | +| 189 | `scarlet_graveyard`: Scarlet Monastery: Graveyard | 30-35 | 4 | draft | yes | none known | | 802 | `crescent_grove`: Crescent Grove | 31-36 | 5 | draft | yes | Keeper Ranathos carries npc_flags (questgiver): a gossip or dialogue starts his fight | -| 189 | `scarlet_library`: Scarlet Monastery: Library | 33-38 | 2 | draft | yes | Houndmaster Loksey's hounds (SPELL_SUMMONSCARLETHOUND) | +| 189 | `scarlet_library`: Scarlet Monastery: Library | 33-38 | 3 | draft | yes | Houndmaster Loksey's hounds (SPELL_SUMMONSCARLETHOUND); Brother Wystan (61983) is a Turtle addition: EventAI, spell list 180317 (Fireball 8402); he patrols (movement_type 2), the boss step meets him on his path | | 818 | `stormwrought_ruins`: Stormwrought Ruins | 33-38 | 11 | draft | yes | secret door and four switches (GO_SECRET_DOOR_GUID, GO_SECRET_SWITCH_1-4), Nether altar (GO_NETHER_ALTAR_GUID); Librarian Theodorus' elemental (SPELL_SUMMON_THEODORUS_ELEMENTAL), volatile fungus (GO_VOLATILE_FUNGUS) | | 189 | `scarlet_armory`: Scarlet Monastery: Armory | 35-40 | 2 | draft | yes | Herod's door (GO_HEROD_DOOR) opens through the armory script | | 129 | `razorfen_downs`: Razorfen Downs | 35-40 | 4 | draft | yes | Tuten'kash (7355) has no spawn row: the Gong (GO_GONG) summons him; Belnistrasz idol-room ritual (GO_BELNISTRASZ_BRAZIER, GO_IDOL_*_FIRE) summons Plaguemaw the Rotting; escort quest, optional | diff --git a/dungeons/scarlet_graveyard.json b/dungeons/scarlet_graveyard.json index e83397af..5c74261b 100644 --- a/dungeons/scarlet_graveyard.json +++ b/dungeons/scarlet_graveyard.json @@ -25,7 +25,8 @@ "notes": [ "DRAFT: a skeleton from the world database export of map 189 (tools/dungeongen, spec tools/dungeongen/plans.json). The boss order is the usual clear order, the packs are the spawn clusters nearest each boss; walls, doors, stairs and events are unknown to it. Not mapped, not live-checked: do the GM-invisible mapping walk and correct the route before the first run (AUTO-IMPROVE step 0); dungeon_run refuses it without allow_draft.", "A wait_for on a door a lever or key opens needs timeout_s 60 (the default): a linked door can open 30 s after the use.", - "One of four wings of map 189; the packs are the ones inside the wing's box." + "One of four wings of map 189; the packs are the ones inside the wing's box.", + "Duke Dreadmoore (61972) is a Turtle addition in the crypt level (z -3.7): script npc_duke_dreadmoore sets him to 20 % health on reset and yells on aggro and death; his crypt pack is needs_walk until the mapping walk" ], "legs": [ { @@ -117,35 +118,40 @@ ] }, { - "entries": [4283, 4075], + "entries": [6547], "positions": [ - {"x": 1842.88, "y": 1132.93, "z": -3.69593}, - {"x": 1851.36, "y": 1132.14, "z": -3.69674} + {"x": 1841.81, "y": 1144.84, "z": -2.65429} ] }, { - "entries": [6547], + "entries": [4293, 4293], "positions": [ - {"x": 1841.81, "y": 1144.84, "z": -2.65429} + {"x": 1807.84, "y": 1165.98, "z": 6.90374}, + {"x": 1807.48, "y": 1170.39, "z": 6.90373} ] }, { - "entries": [6547, 4075, 6547, 4306, 6547, 4306, 4293, 4293], + "entries": [4075, 4293], "positions": [ - {"x": 1843.91, "y": 1161.27, "z": -3.20069}, - {"x": 1855.6, "y": 1154.76, "z": -3.69664}, - {"x": 1858.79, "y": 1146.48, "z": -2.60957}, - {"x": 1860.63, "y": 1146.78, "z": -3.69654}, - {"x": 1852.55, "y": 1160.99, "z": -2.59734}, - {"x": 1853.09, "y": 1162.9, "z": -3.69651}, - {"x": 1860.12, "y": 1162.95, "z": -3.69654}, - {"x": 1863.07, "y": 1162.91, "z": -3.69654} + {"x": 1782.75, "y": 1121.98, "z": 7.57362}, + {"x": 1784.35, "y": 1118.79, "z": 7.49035} ] - }, + } + ], + "boss": { + "entry": 3983, + "position": {"x": 1786.55, "y": 1124.44, "z": 7.57362} + } + }, + { + "name": "duke_dreadmoore", + "to": {"x": 1842.88, "y": 1132.93, "z": -3.69593}, + "pull_groups": [ { - "entries": [61971], + "entries": [4283, 4075], "positions": [ - {"x": 1871.03, "y": 1154.02, "z": -3.69666} + {"x": 1842.88, "y": 1132.93, "z": -3.69593}, + {"x": 1851.36, "y": 1132.14, "z": -3.69674} ] }, { @@ -155,7 +161,7 @@ ] }, { - "entries": [4293, 6547, 4306, 61971, 4306, 4293, 61971, 4075, 4306, 61971, 6547, 6547, 4306, 4306, 6547, 4306, 6547, 4293, 6547, 4306, 4293, 61971, 4306, 6547, 4075, 4306, 4293, 61971, 61971, 61971, 61972, 4075, 6547, 4306, 61971, 4075, 61971], + "entries": [4293, 6547, 4306, 61971, 4306, 4293, 61971, 4075, 4306, 61971, 6547, 6547, 4306, 4306, 6547, 4306, 6547, 4293, 6547, 4306, 4293, 61971, 4306, 6547, 4075, 4306, 4293, 61971, 61971, 61971, 4075, 6547, 4306, 61971, 4075, 61971], "positions": [ {"x": 1875.51, "y": 1119.35, "z": -3.69628}, {"x": 1876.82, "y": 1121.16, "z": -2.60685}, @@ -187,7 +193,6 @@ {"x": 1872.53, "y": 1085.2, "z": -3.69665}, {"x": 1873.45, "y": 1156.85, "z": -3.69652}, {"x": 1850.61, "y": 1085.48, "z": -3.6965}, - {"x": 1881.82, "y": 1087.25, "z": -3.6965}, {"x": 1853.25, "y": 1082.63, "z": -3.69659}, {"x": 1865.08, "y": 1080.05, "z": -2.61565}, {"x": 1863.26, "y": 1079.79, "z": -3.69677}, @@ -205,35 +210,38 @@ ] }, { - "entries": [4293, 4075], + "entries": [6547, 4306, 4075, 6547, 6547, 4306, 4293, 4293], "positions": [ - {"x": 1784.35, "y": 1118.79, "z": 7.49035}, - {"x": 1782.75, "y": 1121.98, "z": 7.57362} + {"x": 1858.79, "y": 1146.48, "z": -2.60957}, + {"x": 1860.63, "y": 1146.78, "z": -3.69654}, + {"x": 1855.6, "y": 1154.76, "z": -3.69664}, + {"x": 1852.55, "y": 1160.99, "z": -2.59734}, + {"x": 1843.91, "y": 1161.27, "z": -3.20069}, + {"x": 1853.09, "y": 1162.9, "z": -3.69651}, + {"x": 1860.12, "y": 1162.95, "z": -3.69654}, + {"x": 1863.07, "y": 1162.91, "z": -3.69654} ] }, { - "entries": [4293, 4293], + "entries": [61971], "positions": [ - {"x": 1807.84, "y": 1165.98, "z": 6.90374}, - {"x": 1807.48, "y": 1170.39, "z": 6.90373} + {"x": 1871.03, "y": 1154.02, "z": -3.69666} ] } ], "boss": { - "entry": 3983, - "position": {"x": 1786.55, "y": 1124.44, "z": 7.57362} + "entry": 61972, + "position": {"x": 1881.82, "y": 1087.25, "z": -3.6965} } }, { "name": "scorn", - "to": {"x": 1798.83, "y": 1246.83, "z": 18.4078}, + "to": {"x": 1837.69, "y": 1249.07, "z": 18.2407}, "pull_groups": [ { - "entries": [4308, 4308, 4308], + "entries": [6426], "positions": [ - {"x": 1798.83, "y": 1246.83, "z": 18.4078}, - {"x": 1796.2, "y": 1247.53, "z": 18.4118}, - {"x": 1800.67, "y": 1249.87, "z": 18.425} + {"x": 1837.69, "y": 1249.07, "z": 18.2407} ] }, { @@ -243,23 +251,34 @@ ] }, { - "entries": [6426], + "entries": [4308, 4308, 4308], "positions": [ - {"x": 1837.69, "y": 1249.07, "z": 18.2407} + {"x": 1800.67, "y": 1249.87, "z": 18.425}, + {"x": 1798.83, "y": 1246.83, "z": 18.4078}, + {"x": 1796.2, "y": 1247.53, "z": 18.4118} ] }, { - "entries": [6426], + "entries": [4308, 4308, 4308, 4075], "positions": [ - {"x": 1834.93, "y": 1279.38, "z": 18.2407} + {"x": 1781.75, "y": 1265.2, "z": 17.7005}, + {"x": 1777.24, "y": 1262.44, "z": 17.6542}, + {"x": 1778.28, "y": 1267.77, "z": 17.6649}, + {"x": 1778.59, "y": 1275.54, "z": 17.6707} + ] + }, + { + "entries": [6427], + "positions": [ + {"x": 1797.23, "y": 1283.1, "z": 18.613} ] }, { "entries": [4308, 4308, 4308], "positions": [ - {"x": 1819.47, "y": 1300.64, "z": 17.5862}, + {"x": 1811.79, "y": 1302.46, "z": 17.8061}, {"x": 1815.85, "y": 1300.29, "z": 17.6923}, - {"x": 1811.79, "y": 1302.46, "z": 17.8061} + {"x": 1819.47, "y": 1300.64, "z": 17.5862} ] }, { @@ -333,18 +352,18 @@ ] }, { - "entries": [4075, 4308, 4308, 4308], + "entries": [6427, 6489, 6488, 6490], "positions": [ - {"x": 1778.59, "y": 1275.54, "z": 17.6707}, - {"x": 1778.28, "y": 1267.77, "z": 17.6649}, - {"x": 1777.24, "y": 1262.44, "z": 17.6542}, - {"x": 1781.75, "y": 1265.2, "z": 17.7005} + {"x": 1757.61, "y": 1248.3, "z": 18.1575}, + {"x": 1749.61, "y": 1247.4, "z": 18.1576}, + {"x": 1749.61, "y": 1247.4, "z": 18.1576}, + {"x": 1749.61, "y": 1247.4, "z": 18.1576} ] }, { - "entries": [6427], + "entries": [6426], "positions": [ - {"x": 1797.23, "y": 1283.1, "z": 18.613} + {"x": 1834.93, "y": 1279.38, "z": 18.2407} ] }, { @@ -379,15 +398,6 @@ "positions": [ {"x": 1795.38, "y": 1362.76, "z": 18.9709} ] - }, - { - "entries": [6427, 6489, 6488, 6490], - "positions": [ - {"x": 1757.61, "y": 1248.3, "z": 18.1575}, - {"x": 1749.61, "y": 1247.4, "z": 18.1576}, - {"x": 1749.61, "y": 1247.4, "z": 18.1576}, - {"x": 1749.61, "y": 1247.4, "z": 18.1576} - ] } ], "boss": { diff --git a/dungeons/scarlet_library.json b/dungeons/scarlet_library.json index d0f26a24..129bdf03 100644 --- a/dungeons/scarlet_library.json +++ b/dungeons/scarlet_library.json @@ -26,6 +26,7 @@ "DRAFT: a skeleton from the world database export of map 189 (tools/dungeongen, spec tools/dungeongen/plans.json). The boss order is the usual clear order, the packs are the spawn clusters nearest each boss; walls, doors, stairs and events are unknown to it. Not mapped, not live-checked: do the GM-invisible mapping walk and correct the route before the first run (AUTO-IMPROVE step 0); dungeon_run refuses it without allow_draft.", "A wait_for on a door a lever or key opens needs timeout_s 60 (the default): a linked door can open 30 s after the use.", "Needs: Houndmaster Loksey's hounds (SPELL_SUMMONSCARLETHOUND)", + "Needs: Brother Wystan (61983) is a Turtle addition: EventAI, spell list 180317 (Fireball 8402); he patrols (movement_type 2), the boss step meets him on his path", "One of four wings of map 189." ], "legs": [ @@ -104,165 +105,163 @@ { "entries": [4287], "positions": [ - {"x": 211.489, "y": -312.366, "z": 18.6126} + {"x": 174.665, "y": -312.681, "z": 18.6126} ] }, { - "entries": [4287, 4287], + "entries": [4287], "positions": [ - {"x": 228.658, "y": -310.084, "z": 18.6126}, - {"x": 229.038, "y": -304.547, "z": 18.6126} + {"x": 155.754, "y": -285.667, "z": 19.2011} ] }, { - "entries": [4299, 4540, 4299], + "entries": [4287], "positions": [ - {"x": 240.758, "y": -301.743, "z": 20.1611}, - {"x": 243.046, "y": -303.767, "z": 20.1611}, - {"x": 247.023, "y": -304.425, "z": 20.1611} + {"x": 153.936, "y": -268.826, "z": 19.2844} ] }, { - "entries": [4540, 4540, 4291], + "entries": [4288, 4304], "positions": [ - {"x": 263.371, "y": -302.978, "z": 20.1611}, - {"x": 265.073, "y": -312.405, "z": 20.079}, - {"x": 267.932, "y": -303.047, "z": 20.1611} + {"x": 167.109, "y": -267.366, "z": 18.6246}, + {"x": 168.105, "y": -268.833, "z": 18.6996} ] }, { - "entries": [4291], + "entries": [4287], "positions": [ - {"x": 232.174, "y": -325.762, "z": 18.617} + {"x": 203.703, "y": -268.734, "z": 19.2844} ] }, { - "entries": [4287, 4296, 4287], + "entries": [4287, 4287, 4296], "positions": [ + {"x": 218.136, "y": -286.012, "z": 19.2829}, {"x": 220.711, "y": -288.573, "z": 19.2829}, - {"x": 223.447, "y": -286.047, "z": 19.2829}, - {"x": 218.136, "y": -286.012, "z": 19.2829} + {"x": 223.447, "y": -286.047, "z": 19.2829} ] }, { - "entries": [4287], + "entries": [4287, 4287], "positions": [ - {"x": 203.703, "y": -268.734, "z": 19.2844} + {"x": 267.888, "y": -233.814, "z": 18.614}, + {"x": 271.381, "y": -233.791, "z": 18.614} ] }, { - "entries": [4304, 4288], + "entries": [4304, 4304, 4304], "positions": [ - {"x": 168.105, "y": -268.833, "z": 18.6996}, - {"x": 167.109, "y": -267.366, "z": 18.6246} + {"x": 123.754, "y": -261.435, "z": 18.6299}, + {"x": 123.09, "y": -258.452, "z": 18.6299}, + {"x": 120.958, "y": -257.75, "z": 18.6299} ] - }, + } + ], + "boss": { + "entry": 3974, + "position": {"x": 119.938, "y": -260.869, "z": 18.6299} + } + }, + { + "name": "brother_wystan", + "to": {"x": 211.489, "y": -312.366, "z": 18.6126}, + "pull_groups": [ { "entries": [4287], "positions": [ - {"x": 153.936, "y": -268.826, "z": 19.2844} + {"x": 211.489, "y": -312.366, "z": 18.6126} ] }, { - "entries": [4287], + "entries": [4287, 4287], "positions": [ - {"x": 155.754, "y": -285.667, "z": 19.2011} + {"x": 228.658, "y": -310.084, "z": 18.6126}, + {"x": 229.038, "y": -304.547, "z": 18.6126} ] }, { - "entries": [4287], + "entries": [4299, 4540, 4299], "positions": [ - {"x": 174.665, "y": -312.681, "z": 18.6126} + {"x": 240.758, "y": -301.743, "z": 20.1611}, + {"x": 243.046, "y": -303.767, "z": 20.1611}, + {"x": 247.023, "y": -304.425, "z": 20.1611} ] }, { - "entries": [4304, 4304, 4304], + "entries": [4540, 4540, 4291], "positions": [ - {"x": 123.754, "y": -261.435, "z": 18.6299}, - {"x": 123.09, "y": -258.452, "z": 18.6299}, - {"x": 120.958, "y": -257.75, "z": 18.6299} + {"x": 263.371, "y": -302.978, "z": 20.1611}, + {"x": 265.073, "y": -312.405, "z": 20.079}, + {"x": 267.932, "y": -303.047, "z": 20.1611} ] }, { - "entries": [4287, 4287], + "entries": [4540, 4299], "positions": [ - {"x": 267.888, "y": -233.814, "z": 18.614}, - {"x": 271.381, "y": -233.791, "z": 18.614} + {"x": 272.412, "y": -326.647, "z": 18.6172}, + {"x": 272.568, "y": -331.08, "z": 18.6172} ] - } - ], - "boss": { - "entry": 3974, - "position": {"x": 119.938, "y": -260.869, "z": 18.6299} - } - }, - { - "name": "arcanist_doan", - "to": {"x": 211.785, "y": -326.959, "z": 18.5334}, - "pull_groups": [ + }, { - "entries": [4291, 4299, 4540, 4540], + "entries": [4291, 4291], "positions": [ - {"x": 211.785, "y": -326.959, "z": 18.5334}, - {"x": 211.974, "y": -332.415, "z": 18.5356}, - {"x": 218.595, "y": -338.548, "z": 18.5348}, - {"x": 223.27, "y": -338.545, "z": 18.5335} + {"x": 253.912, "y": -332.617, "z": 18.6172}, + {"x": 247.954, "y": -335.179, "z": 18.6174} ] }, { - "entries": [4291, 4291], + "entries": [4291], "positions": [ - {"x": 247.954, "y": -335.179, "z": 18.6174}, - {"x": 253.912, "y": -332.617, "z": 18.6172} + {"x": 232.174, "y": -325.762, "z": 18.617} ] }, { - "entries": [4299, 4540], + "entries": [4540, 4540, 4291, 4299], "positions": [ - {"x": 272.568, "y": -331.08, "z": 18.6172}, - {"x": 272.412, "y": -326.647, "z": 18.6172} + {"x": 223.27, "y": -338.545, "z": 18.5335}, + {"x": 218.595, "y": -338.548, "z": 18.5348}, + {"x": 211.785, "y": -326.959, "z": 18.5334}, + {"x": 211.974, "y": -332.415, "z": 18.5356} ] }, { - "entries": [4540, 4291], + "entries": [4540, 4540], "positions": [ - {"x": 251.835, "y": -363.646, "z": 20.4781}, - {"x": 253.363, "y": -367.029, "z": 20.482} + {"x": 230.638, "y": -375.109, "z": 23.8613}, + {"x": 234.038, "y": -380.116, "z": 23.8826} ] }, { - "entries": [4299], + "entries": [4540], "positions": [ - {"x": 265.031, "y": -379.047, "z": 20.078} + {"x": 220.82, "y": -386.671, "z": 23.8437} ] }, { - "entries": [4540, 4291], + "entries": [4540, 4540], "positions": [ - {"x": 265.949, "y": -386.511, "z": 20.1611}, - {"x": 263.192, "y": -386.943, "z": 20.1605} + {"x": 208.394, "y": -404.917, "z": 23.8828}, + {"x": 210.902, "y": -407.296, "z": 23.8744} ] }, { - "entries": [4299, 4291], + "entries": [4291, 4540], "positions": [ - {"x": 277.677, "y": -373.477, "z": 20.4933}, - {"x": 279.837, "y": -369.762, "z": 20.487} + {"x": 216.476, "y": -418.544, "z": 18.6147}, + {"x": 215.304, "y": -421.982, "z": 18.6203} ] }, { - "entries": [4540, 4291], + "entries": [4291, 4291], "positions": [ - {"x": 279.506, "y": -405.837, "z": 20.4875}, - {"x": 277.415, "y": -410.155, "z": 20.4948} + {"x": 231.985, "y": -425.421, "z": 18.6153}, + {"x": 231.714, "y": -432.81, "z": 18.6163} ] }, { - "entries": [4540, 4299, 4540], + "entries": [4291], "positions": [ - {"x": 256.803, "y": -409.171, "z": 20.4936}, - {"x": 253.815, "y": -405.001, "z": 20.4863}, - {"x": 251.522, "y": -408.955, "z": 20.4928} + {"x": 253.05, "y": -432.902, "z": 18.6185} ] }, { @@ -278,49 +277,45 @@ ] }, { - "entries": [4291], - "positions": [ - {"x": 253.05, "y": -432.902, "z": 18.6185} - ] - }, - { - "entries": [4291, 4291], + "entries": [4291, 4540], "positions": [ - {"x": 231.714, "y": -432.81, "z": 18.6163}, - {"x": 231.985, "y": -425.421, "z": 18.6153} + {"x": 277.415, "y": -410.155, "z": 20.4948}, + {"x": 279.506, "y": -405.837, "z": 20.4875} ] }, { - "entries": [4540, 4291], + "entries": [4540, 4299, 4540], "positions": [ - {"x": 215.304, "y": -421.982, "z": 18.6203}, - {"x": 216.476, "y": -418.544, "z": 18.6147} + {"x": 256.803, "y": -409.171, "z": 20.4936}, + {"x": 253.815, "y": -405.001, "z": 20.4863}, + {"x": 251.522, "y": -408.955, "z": 20.4928} ] }, { - "entries": [4540, 4540], + "entries": [4291, 4540], "positions": [ - {"x": 210.902, "y": -407.296, "z": 23.8744}, - {"x": 208.394, "y": -404.917, "z": 23.8828} + {"x": 263.192, "y": -386.943, "z": 20.1605}, + {"x": 265.949, "y": -386.511, "z": 20.1611} ] }, { - "entries": [61983], + "entries": [4299], "positions": [ - {"x": 209.491, "y": -401.61, "z": 23.8661} + {"x": 265.031, "y": -379.047, "z": 20.078} ] }, { - "entries": [4540], + "entries": [4299, 4291], "positions": [ - {"x": 220.82, "y": -386.671, "z": 23.8437} + {"x": 277.677, "y": -373.477, "z": 20.4933}, + {"x": 279.837, "y": -369.762, "z": 20.487} ] }, { - "entries": [4540, 4540], + "entries": [4291, 4540], "positions": [ - {"x": 234.038, "y": -380.116, "z": 23.8826}, - {"x": 230.638, "y": -375.109, "z": 23.8613} + {"x": 253.363, "y": -367.029, "z": 20.482}, + {"x": 251.835, "y": -363.646, "z": 20.4781} ] }, { @@ -331,6 +326,22 @@ {"x": 182.002, "y": -416.543, "z": 18.9408} ] }, + { + "entries": [4540], + "positions": [ + {"x": 197.231, "y": -432.247, "z": 18.5317} + ] + } + ], + "boss": { + "entry": 61983, + "position": {"x": 209.491, "y": -401.61, "z": 23.8661} + } + }, + { + "name": "arcanist_doan", + "to": {"x": 173.965, "y": -427.5, "z": 18.6164}, + "pull_groups": [ { "entries": [4540, 4540], "positions": [ @@ -345,12 +356,6 @@ {"x": 183.061, "y": -443.497, "z": 18.9388}, {"x": 180.243, "y": -445.043, "z": 18.9434} ] - }, - { - "entries": [4540], - "positions": [ - {"x": 197.231, "y": -432.247, "z": 18.5317} - ] } ], "boss": { diff --git a/tools/dungeongen/plans.json b/tools/dungeongen/plans.json index ea365a89..ffe9fea5 100644 --- a/tools/dungeongen/plans.json +++ b/tools/dungeongen/plans.json @@ -165,6 +165,7 @@ "trigger": 45, "bosses": [ 3983, + 61972, 14693, 4543 ], @@ -179,7 +180,8 @@ "entrance_tele": "smgy", "outside_tele": "ScarletMonastery", "notes": [ - "One of four wings of map 189; the packs are the ones inside the wing's box." + "One of four wings of map 189; the packs are the ones inside the wing's box.", + "Duke Dreadmoore (61972) is a Turtle addition in the crypt level (z -3.7): script npc_duke_dreadmoore sets him to 20 % health on reset and yells on aggro and death; his crypt pack is needs_walk until the mapping walk" ] }, { @@ -191,6 +193,7 @@ "trigger": 614, "bosses": [ 3974, + 61983, 6487 ], "bbox": [ @@ -204,7 +207,8 @@ "entrance_tele": "smlib", "outside_tele": "ScarletMonastery", "special": [ - "Houndmaster Loksey's hounds (SPELL_SUMMONSCARLETHOUND)" + "Houndmaster Loksey's hounds (SPELL_SUMMONSCARLETHOUND)", + "Brother Wystan (61983) is a Turtle addition: EventAI, spell list 180317 (Fireball 8402); he patrols (movement_type 2), the boss step meets him on his path" ], "notes": [ "One of four wings of map 189." From 5523aaa3d425747408dcec7269f4b33bab968108 Mon Sep 17 00:00:00 2001 From: pjerra <71784785+pjerra@users.noreply.github.com> Date: Sun, 18 Oct 2026 22:15:25 +0200 Subject: [PATCH 02/70] plan-export: write exact via points as move flag 64 and warn about every plan attribute the rows cannot carry An exact via (the launch point of the direct step after it, Wailing Caverns' Serpentis foot) becomes a move row with flags 64, TortoiseBots' new exact waypoint: its run leader counts it done only once the lead bot stands within 1.5 yd of it, still for 2 s, and only then sends the move_direct. Validate refuses the flag on any other kind and an exact move that is not followed by its move_direct (nothing inserted between). Every plan attribute the rows cannot carry is now a warning, one per attribute, naming where it stands and what the run leader does instead (skip, leftover_entries, leg rest, needs_walk, patrol, boss tactics and companions_loot, a dropped companions_loot false, fleers, no_cc, summoned_kill_first, hire_composition, level_band, raid, watchdog, the entrance and teleports, avoid without a navmesh, a loot or ritual stand, an object's spawn or range, a loot batch, an area trigger's expect check). check_skip (plan check only), equiv and notes are not warned. --- README.md | 4 +- internal/planexport/planexport.go | 26 ++++- internal/planexport/planexport_test.go | 121 +++++++++++++++++++ internal/planexport/unexpressed.go | 156 +++++++++++++++++++++++++ 4 files changed, 304 insertions(+), 3 deletions(-) create mode 100644 internal/planexport/unexpressed.go diff --git a/README.md b/README.md index b06fd516..b0dc21c1 100644 --- a/README.md +++ b/README.md @@ -878,7 +878,7 @@ turtle-agent plan-export --all-live --out ./migrations/ --stamp 20261014200000 | `--gm-cross-direct` | export a `gm_cross` waypoint as a `move_direct` (a straight walk to the landing point) instead of refusing the plan; the run leader has no GM move. Written for Wailing Caverns' ramp to Lord Serpentis (6 yd down over 3.9 yd), which the plan now crosses with a plain direct step and a `check_skip`, so no plan needs it now; the row's comment and a warning say it is not tried live | | `--rev`, `--stamp` | the commit named in `ai_dungeon_plan.source` (default: `git rev-parse` of the plan folder, `+local` when it has changes) and the file's timestamp (default now) | -Per leg the rows are, in the order the driver does them: the `via` waypoints (`move`, or `move_direct` for `direct`), `to` (`move`), each pull group (`pull`: the centre is the +Per leg the rows are, in the order the driver does them: the `via` waypoints (`move`, or `move_direct` for `direct`; an `exact` via is a `move` with flags 64, the run leader's exact waypoint: done only once the lead bot stands within 1.5 yd of it, still for 2 s, and only then the `move_direct` after it goes out, TortoiseBots `docs/guides/dungeon-run-leader.md` "Exact waypoints"; the export refuses rows where anything comes between the two; a via's `check_skip` is the plan check's only and is not written, without a warning), `to` (`move`), each pull group (`pull`: the centre is the mean of its positions, the radius is its `radius` or the farthest position plus 10 yd, `entries` once each in first-seen order, flags 1 ambush / 2 alliance / 4 horde from `ambush` and `team`), the `steps` (`use_object` as a `move` to its `stand` and an `open` whose `item` is `requires_item`; `wait_for.gameobject` as `wait_object` with `param` 0/1/2 for ready/active/active_alt; `hostiles_gone` as `wait_clear`, centred on its `center` or on the last point of the leg; `loot_object` as `loot` with `item` the `expect_item`; `use_item` with @@ -888,7 +888,7 @@ done by instance data 8 + encounter states 32, `radius`, `timeout_s`, `entries` own spot is left out (the hold walks there, and the fight that starts with a ritual stays the hold's), and a `wait_for.gameobject` straight after it waits at most 60 s (the event's DONE opens the door). A `ritual` row carries its `participants` in `param`. With `--mmaps` a long hop is measured from the last pull, boss or hold row when one comes after the last move (the party fought there), so a leg's first move after a boss is not walked back from the move before the fight. Every `run leader hop` warning of the plan check is printed too. **Avoid boxes**: the run leader does not know a plan's `avoid` boxes (the rows have no column for them), so with `--mmaps` and a plan that has boxes the export checks every hop between two anchors (the entrance, move, pull, boss and hold rows) on the plain navmesh, the one the server walks: a hop whose path enters a box gets the corners of the avoid-aware path as `move` rows (comment `path (keeps out of an avoid box)`, hops of at most 25 yd, refined until each hop's plain path is clear), and a hop that cannot be made safe fails the export. Ragefire Chasm needs none (its plain paths already keep out of the basin). -`skip`, `rest` and `watchdog` are not carried over, as the guide says. A **leg's `companions_loot: false`** cannot be carried either (the rows have no column for it, so the run leader's bots would loot the Zul'Farrak graveyard's chests): such a plan is **refused**, naming the leg, unless `--drop-companions-loot` says to export it without. An `area_trigger` is exported as a `move`, and its comment says `needs AiPlayerbot.BotAreaTriggers = 962 (TortoiseBots #1002)`. A row the module's loader would skip (radius over 100, timeout over 600, more than 16 entries, a +`skip`, `rest` and `watchdog` are not carried over, as the guide says. **Nothing is dropped silently**: every plan attribute the rows cannot carry is a warning on stderr, one per attribute, naming each place it stands and what the run leader does instead (`skip`, `leftover_entries`, a leg's `rest`, `needs_walk`, `patrol`, `boss.tactics`, `boss.companions_loot`, a dropped `companions_loot: false`, `fleers`, `no_cc`, `summoned_kill_first`, `hire_composition`, `level_band`, `raid`, `watchdog`, the entrance and teleports, `avoid` without `--mmaps`, a `stand` of a `loot_object` or `ritual`, an object's `spawn` or `range`, a `loot_object` `batch`, an `area_trigger`'s expect check and timeout); the migration's header should list them. Not warned: what is no run attribute (name, version, status, kind, map, notes, a via's `check_skip`, a pull group's `equiv`, which only the pull-test scenario reads). A **leg's `companions_loot: false`** cannot be carried either (the rows have no column for it, so the run leader's bots would loot the Zul'Farrak graveyard's chests): such a plan is **refused**, naming the leg, unless `--drop-companions-loot` says to export it without. An `area_trigger` is exported as a `move`, and its comment says `needs AiPlayerbot.BotAreaTriggers = 962 (TortoiseBots #1002)`. A row the module's loader would skip (radius over 100, timeout over 600, more than 16 entries, a step without its entry) is refused here. The written SQL is read back and compared with the rows before the file is written. Uldaman exports 148 rows (14 legs, 133 steps, two of them holds); with its approach points and the hop measured from the last fight no move is more than 60 yd from the party, so `--mmaps` cuts nothing (the export of dd05724 cut 7 hops into 220 rows, walking the party back from the leg before's last move). diff --git a/internal/planexport/planexport.go b/internal/planexport/planexport.go index f2123de6..cc211ff7 100644 --- a/internal/planexport/planexport.go +++ b/internal/planexport/planexport.go @@ -43,6 +43,10 @@ const ( // (DungeonPlanPolicy.h kPlanFlagDoneData, kPlanFlagEncounter). FlagDoneData = 8 FlagEncounter = 32 + // FlagExact marks a move row as an exact waypoint (the plan's Waypoint.Exact): the run leader counts it done only once the lead bot + // stands within 1.5 yd of it and has stood still there for 2 s, and only then sends the move_direct after it (TortoiseBots + // DungeonPlanPolicy.h kPlanFlagExact, DungeonRunPolicy.h ExactWalkTo). The loader refuses it on any other kind. + FlagExact = 64 // EncounterDone is the encounter state DONE: a hold's param. EncounterDone = 3 ) @@ -230,6 +234,11 @@ func Build(p *dungeon.Plan, opt Options) (Result, error) { b.warnings = append(b.warnings, w.String()) } } + upto := len(p.Legs) + if cut != "" { + upto = cutLeg + } + b.warnings = append(b.warnings, unexpressed(p, opt, upto)...) if err := Validate(b.rows); err != nil { return Result{}, err } @@ -297,6 +306,13 @@ func (b *builder) leg(li int) error { if w.GMCross { return fmt.Errorf("via %.1f %.1f %.1f is a gm_cross: a GM moves the party there in a remote run, and the TortoiseBots run leader has no such step", w.X, w.Y, w.Z) } + if w.Exact { + // The plan allows exact only on a plain via whose next via is a direct step (dungeon.Parse refuses any other), so this row is + // always followed by its move_direct; Validate checks the rows keep it so. + b.add(StepRow{Kind: KindMove, X: w.X, Y: w.Y, Z: w.Z, Flags: FlagExact, Comment: "via (exact: the direct step's launch point)"}) + b.last, b.haveLast = w.Point, true + continue + } b.move(w.Point, w.Direct, "via") } b.move(l.To, false, "to") @@ -559,7 +575,7 @@ func nameOr(a, b string) string { // Validate checks rows the way the module's loader would, so that a row it would skip is a refusal here. func Validate(r Rows) error { - for _, s := range r.Steps { + for i, s := range r.Steps { where := fmt.Sprintf("plan %s leg %d step %d (%s)", s.Plan, s.Leg, s.Seq, s.Kind) bad := func(format string, a ...any) error { return fmt.Errorf("%s: %s", where, fmt.Sprintf(format, a...)) } if s.Radius < 0 || s.Radius > MaxRadius { @@ -598,6 +614,14 @@ func Validate(r Rows) error { if s.Kind == KindWaitObject && s.Param > 2 { return bad("param %d is above 2", s.Param) } + if s.Flags&FlagExact != 0 { + if s.Kind != KindMove { + return bad("the exact flag is for a move row (the loader refuses it on a %s)", s.Kind) + } + if i+1 >= len(r.Steps) || r.Steps[i+1].Plan != s.Plan || r.Steps[i+1].Leg != s.Leg || r.Steps[i+1].Kind != KindMoveDirect { + return bad("an exact move must be followed by the move_direct it launches (a row came between them, or there is none)") + } + } if len(s.Comment) > MaxComment { return bad("comment longer than %d", MaxComment) } diff --git a/internal/planexport/planexport_test.go b/internal/planexport/planexport_test.go index a6933eee..35b7dff9 100644 --- a/internal/planexport/planexport_test.go +++ b/internal/planexport/planexport_test.go @@ -726,3 +726,124 @@ func TestLongHopStartsAtTheLastFight(t *testing.T) { t.Fatalf("leg 1 %s", got) } } + +// TestExactViaIsAFlaggedMove: an exact via point is a move row with FlagExact, right before its move_direct; the flag survives the SQL +// round trip; Validate refuses the flag on any other kind and an exact move that is not followed by its move_direct. +func TestExactViaIsAFlaggedMove(t *testing.T) { + p := parsePlan(t, strings.Replace(small, `{"x":1,"y":0,"z":0}`, `{"x":1,"y":0,"z":0,"exact":true}`, 1)) + r := build(t, p) + if s := r.Steps[0]; s.Kind != KindMove || s.X != 1 || s.Flags != FlagExact || !strings.Contains(s.Comment, "exact") { + t.Fatalf("exact via %+v", s) + } + if s := r.Steps[1]; s.Kind != KindMoveDirect || s.Flags != 0 { + t.Fatalf("its direct step %+v", s) + } + for i, s := range r.Steps { + if i != 0 && s.Flags&FlagExact != 0 { + t.Fatalf("step %d carries the exact flag: %+v", i, s) + } + } + back, err := Parse(WriteSQL([]Rows{r})) + if err != nil { + t.Fatal(err) + } + if err := Equal([]Rows{r}, back); err != nil || back[0].Steps[0].Flags != FlagExact { + t.Fatalf("round trip: %v %+v", err, back[0].Steps[0]) + } + // Without exact in the plan, no row has the flag (the plain mapping is unchanged). + for _, s := range build(t, parsePlan(t, small)).Steps { + if s.Flags&FlagExact != 0 { + t.Fatalf("flag without exact: %+v", s) + } + } + bad := r + bad.Steps = slices.Clone(r.Steps) + bad.Steps[1].Flags = FlagExact // on the move_direct + if err := Validate(bad); err == nil || !strings.Contains(err.Error(), "the exact flag is for a move row") { + t.Fatalf("exact on a move_direct: %v", err) + } + bad.Steps = slices.Clone(r.Steps) + bad.Steps = append(bad.Steps[:1:1], append([]StepRow{{Plan: r.Steps[0].Plan, Leg: 0, Kind: KindMove, X: 5}}, r.Steps[1:]...)...) + if err := Validate(bad); err == nil || !strings.Contains(err.Error(), "followed by the move_direct") { + t.Fatalf("a row between the exact move and its direct step: %v", err) + } +} + +// TestWailingCavernsSerpentisFootIsExact: the export of the real plan carries the live-proven hop as the exact foot, then the +// move_direct to the landing, in the serpentis leg. +func TestWailingCavernsSerpentisFootIsExact(t *testing.T) { + p, err := dungeon.Load("../../dungeons/wailing_caverns.json") + if err != nil { + t.Fatal(err) + } + res, err := Build(p, Options{Source: "test", DropCompanionsLoot: true}) + if err != nil { + t.Fatal(err) + } + legOf := map[int]string{} + for _, l := range res.Rows.Legs { + legOf[l.Leg] = l.Name + } + var exact []StepRow + for i, s := range res.Rows.Steps { + if s.Flags&FlagExact == 0 { + continue + } + exact = append(exact, s) + n := res.Rows.Steps[i+1] + if legOf[s.Leg] != "serpentis" || s.X != -293.3 || s.Y != 0.5 || s.Z != -58 { + t.Fatalf("exact row %+v (leg %s)", s, legOf[s.Leg]) + } + if n.Kind != KindMoveDirect || n.X != -287.5 || n.Y != 5 || n.Z != -63.6 { + t.Fatalf("after the foot: %+v", n) + } + } + if len(exact) != 1 { + t.Fatalf("%d exact rows", len(exact)) + } + w := strings.Join(res.Warnings, "\n") + for _, want := range []string{"skip (leg 0 (", "patrol (", "boss.tactics (", "fleers (plan", "not in the rows"} { + if !strings.Contains(w, want) { + t.Fatalf("warnings lack %q:\n%s", want, w) + } + } + if strings.Contains(w, "check_skip") { + t.Fatalf("check_skip is the plan check's only and is not warned:\n%s", w) + } +} + +// TestUnexpressedAttributesAreWarned: every plan attribute the rows cannot carry is one warning naming where it stands; what is no run +// attribute (notes, check_skip, equiv) is not; an exported leg only (a cut leg's attributes are not the rows'). +func TestUnexpressedAttributesAreWarned(t *testing.T) { + const js = `{"version":1,"name":"attrs","status":"live-checked (test)","map":70,"entrance":{"x":0,"y":0,"z":0}, + "level_band":[20,25],"fleers":[300],"no_cc":[301],"notes":["a note"], + "legs":[ + {"name":"a","to":{"x":10,"y":0,"z":0},"skip":[200],"leftover_entries":[201],"rest":{"health":70},"notes":["leg note"], + "via":[{"x":5,"y":0,"z":0,"check_skip":"a hole"}], + "pull_groups":[{"entries":[100],"positions":[{"x":20,"y":0,"z":0}],"equiv":"e1"}], + "steps":[{"loot_object":{"entry":902,"near":{"x":21,"y":0,"z":0},"stand":{"x":20,"y":0,"z":0}}}, + {"ritual":{"entry":903,"position":{"x":22,"y":0,"z":0},"participants":3,"stand":{"x":21,"y":0,"z":0}}}]}, + {"name":"b","to":{"x":30,"y":0,"z":0},"skip":[202],"boss":{"entry":7000,"position":{"x":40,"y":0,"z":0}}}]}` + res, err := Build(parsePlan(t, js), Options{Source: "test"}) + if err != nil { + t.Fatal(err) + } + w := strings.Join(res.Warnings, "\n") + for _, want := range []string{ + "level_band (plan [20 25])", "fleers (plan [300])", "no_cc (plan [301])", + "skip (leg 0 (a) [200]; leg 1 (b) [202]): not in the rows", "leftover_entries (leg 0 (a) [201])", "rest (leg 0 (a))", + "stand (loot_object, ritual) (leg 0 (a) steps[0]; leg 0 (a) steps[1])", + } { + if !strings.Contains(w, want) { + t.Fatalf("warnings lack %q:\n%s", want, w) + } + } + for _, not := range []string{"check_skip", "equiv", "note"} { + if strings.Contains(w, not) { + t.Fatalf("%q is no run attribute and is not warned:\n%s", not, w) + } + } + if n := strings.Count(w, "skip ("); n != 1 { + t.Fatalf("one warning per attribute, %d for skip:\n%s", n, w) + } +} diff --git a/internal/planexport/unexpressed.go b/internal/planexport/unexpressed.go new file mode 100644 index 00000000..a27908f8 --- /dev/null +++ b/internal/planexport/unexpressed.go @@ -0,0 +1,156 @@ +package planexport + +import ( + "fmt" + "strings" + + "github.com/pjerra/turtle-agent/internal/dungeon" +) + +// The plan attributes the run leader's rows cannot carry: each one the plan sets is a warning (TortoiseBots #984: nothing is dropped +// silently), naming every place it stands and what the run leader does instead. The attributes the rows do carry (the points, pull +// groups, bosses, holds, steps, unit lists, team and ambush flags, direct and exact via points) are not listed, nor are those that are +// no run attribute at all: the plan's name, version, status, kind, map and notes, a via's check_skip (turtle-agent's plan check only), +// a pull group's equiv (the pull-test scenario only), a step's fail_text and a use_item's name (both in the row's comment). +var unexpressedWhy = map[string]string{ + "entrance/teleports/portal": "the run leader starts inside the instance (`.bot run start` checks the party is in it); getting there is the caller's", + "level_band": "the run leader leads the bound party at whatever level it is", + "hire_composition": "the run leader leads the bound party as it is", + "fleers": "no column; the bots' own AI chases or lets go a fleeing mob", + "no_cc": "no column; the run leader's crowd control picks only from cc_priority and cc_always", + "summoned_kill_first": "no column; only kill_first is focused", + "raid": "no column; the run leader leads one party", + "watchdog": "the agent's watchdog; the run leader has its own ceilings", + "avoid": "the run leader does not know avoid boxes; export with --mmaps to route its hops around them (a fight can still drag the party in)", + "skip": "no skip list column: the run leader pulls a hostile within 15 yd of a hop point that no later step owns, a skipped unit too", + "leftover_entries": "no sweep of idle summoned units; the bots fight what aggroes", + "rest": "the run leader's own rest lines apply", + "companions_loot false": "dropped (--drop-companions-loot): the bots loot as they like on this leg", + "needs_walk": "the point was not snapped to the navmesh by a mapping walk; the run leader walks to it as given", + "patrol": "no patrol path or period: the run leader pulls the pack where it stands when it comes", + "boss.tactics": "the plan's tactics are the agent's; a bound party uses the module's boss tactics (docs/guides/boss-tactics.md)", + "boss.companions_loot": "no column; the bots loot the boss as they like", + "stand (loot_object, ritual)": "no stand point in the row: the bots walk to the object themselves", + "object spawn/range": "the row names the object by entry near its point only (the module's own search range)", + "loot_object batch": "no batches: the chests are opened one step at a time", + "area_trigger expect/timeout": "only the walk into the zone is a row (a move to the stand point); the expect check and its timeout are not run (the comment names the expect)", +} + +// unexpressed lists the attributes of the plan's exported legs (legs before `upto`, the optional ones left out) that the rows cannot carry. +func unexpressed(p *dungeon.Plan, opt Options, upto int) []string { + var order []string + where := map[string][]string{} + add := func(attr, at string) { + if _, ok := where[attr]; !ok { + order = append(order, attr) + } + where[attr] = append(where[attr], at) + } + if p.EntranceTrigger != 0 || p.EntranceTele != "" || p.OutsideTele != "" || p.Portal != nil { + add("entrance/teleports/portal", "plan") + } + if len(p.LevelBand) > 0 { + add("level_band", fmt.Sprintf("plan %v", p.LevelBand)) + } + if len(p.HireComposition) > 0 { + add("hire_composition", "plan "+strings.Join(p.HireComposition, ",")) + } + if len(p.Fleers) > 0 { + add("fleers", fmt.Sprintf("plan %v", p.Fleers)) + } + if len(p.NoCC) > 0 { + add("no_cc", fmt.Sprintf("plan %v", p.NoCC)) + } + if len(p.SummonedKillFirst) > 0 { + add("summoned_kill_first", fmt.Sprintf("plan %v", p.SummonedKillFirst)) + } + if p.Raid != nil { + add("raid", "plan") + } + if p.Watchdog != nil { + add("watchdog", "plan") + } + if len(p.Avoid) > 0 && opt.Route == nil { + add("avoid", fmt.Sprintf("plan, %d boxes", len(p.Avoid))) + } + for li, l := range p.Legs { + if li >= upto { + break + } + if l.Optional != nil { + continue // left out of the export, with its own warning + } + leg := fmt.Sprintf("leg %d (%s)", li, l.Name) + if len(l.Skip) > 0 { + add("skip", fmt.Sprintf("%s %v", leg, l.Skip)) + } + if len(l.LeftoverEntries) > 0 { + add("leftover_entries", fmt.Sprintf("%s %v", leg, l.LeftoverEntries)) + } + if l.Rest != nil { + add("rest", leg) + } + if l.CompanionsLoot != nil && !*l.CompanionsLoot && opt.DropCompanionsLoot { + add("companions_loot false", leg) + } + if l.NeedsWalk { + add("needs_walk", leg+" to") + } + for gi, g := range l.PullGroups { + if g.Patrol != nil { + add("patrol", fmt.Sprintf("%s pull group %d", leg, gi)) + } + if g.NeedsWalk { + add("needs_walk", fmt.Sprintf("%s pull group %d", leg, gi)) + } + } + if b := l.Boss; b != nil { + if b.Tactics != nil { + add("boss.tactics", fmt.Sprintf("%s boss %d", leg, b.Entry)) + } + if b.CompanionsLoot != nil { + add("boss.companions_loot", fmt.Sprintf("%s boss %d", leg, b.Entry)) + } + if b.NeedsWalk { + add("needs_walk", fmt.Sprintf("%s boss %d", leg, b.Entry)) + } + } + steps := func(list string, ss []dungeon.Step) { + for si, st := range ss { + at := fmt.Sprintf("%s %s[%d]", leg, list, si) + ref := func(r dungeon.ObjectRef) { + if r.Spawn != 0 || r.Range != 0 { + add("object spawn/range", at) + } + } + switch { + case st.UseObject != nil: + ref(st.UseObject.ObjectRef) + case st.LootObject != nil: + ref(st.LootObject.ObjectRef) + if st.LootObject.Stand != nil { + add("stand (loot_object, ritual)", at) + } + if st.LootObject.Batch != "" { + add("loot_object batch", at) + } + case st.WaitFor != nil && st.WaitFor.GameObject != nil: + ref(st.WaitFor.GameObject.ObjectRef) + case st.Ritual != nil: + if st.Ritual.Stand != nil { + add("stand (loot_object, ritual)", at) + } + case st.AreaTrigger != nil: + add("area_trigger expect/timeout", at) + } + } + } + steps("steps", l.Steps) + steps("after_boss", l.AfterBoss) + } + out := make([]string, 0, len(order)) + for _, attr := range order { + out = append(out, fmt.Sprintf("%s (%s): not in the rows: %s", attr, strings.Join(where[attr], "; "), unexpressedWhy[attr])) + } + return out +} From 94da3f11317d08255940ee139215e7843d5b4710 Mon Sep 17 00:00:00 2001 From: pjerra <71784785+pjerra@users.noreply.github.com> Date: Tue, 20 Oct 2026 09:55:18 +0200 Subject: [PATCH 03/70] plan-export: the hop after an exact via is kind move_direct_exact, so a rolled-back module fails closed The direct step right after an exact via is written as move_direct_exact. A TortoiseBots with exact waypoints reads it as a move_direct launched from the exact move before it; an older one does not know the kind and its loader skips the row (UnknownKind), so a binary rollback that walks the foot as a plain move never sends the hop over the hole from short of it. Validate pairs them both ways (flag 64 move right before a move_direct_exact, a move_direct_exact right after a flag 64 move), the avoid-box router and the long-hop cutter treat it as a direct step. --- README.md | 2 +- internal/planexport/planexport.go | 51 +++++++++++++++++--------- internal/planexport/planexport_test.go | 50 +++++++++++++++++++++---- 3 files changed, 78 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index b0dc21c1..522e22bb 100644 --- a/README.md +++ b/README.md @@ -878,7 +878,7 @@ turtle-agent plan-export --all-live --out ./migrations/ --stamp 20261014200000 | `--gm-cross-direct` | export a `gm_cross` waypoint as a `move_direct` (a straight walk to the landing point) instead of refusing the plan; the run leader has no GM move. Written for Wailing Caverns' ramp to Lord Serpentis (6 yd down over 3.9 yd), which the plan now crosses with a plain direct step and a `check_skip`, so no plan needs it now; the row's comment and a warning say it is not tried live | | `--rev`, `--stamp` | the commit named in `ai_dungeon_plan.source` (default: `git rev-parse` of the plan folder, `+local` when it has changes) and the file's timestamp (default now) | -Per leg the rows are, in the order the driver does them: the `via` waypoints (`move`, or `move_direct` for `direct`; an `exact` via is a `move` with flags 64, the run leader's exact waypoint: done only once the lead bot stands within 1.5 yd of it, still for 2 s, and only then the `move_direct` after it goes out, TortoiseBots `docs/guides/dungeon-run-leader.md` "Exact waypoints"; the export refuses rows where anything comes between the two; a via's `check_skip` is the plan check's only and is not written, without a warning), `to` (`move`), each pull group (`pull`: the centre is the +Per leg the rows are, in the order the driver does them: the `via` waypoints (`move`, or `move_direct` for `direct`; an `exact` via is a `move` with flags 64, the run leader's exact waypoint: done only once the lead bot stands within 1.5 yd of it, still for 2 s, and only then the direct step after it goes out, TortoiseBots `docs/guides/dungeon-run-leader.md` "Exact waypoints"; that direct step is written as kind `move_direct_exact`, which an older TortoiseBots does not know: its loader skips the row, so a rolled-back binary never sends the hop from short of the foot (it fails closed); the export refuses rows where anything comes between the two, an exact move before a plain `move_direct`, or a `move_direct_exact` not right after an exact move; a via's `check_skip` is the plan check's only and is not written, without a warning), `to` (`move`), each pull group (`pull`: the centre is the mean of its positions, the radius is its `radius` or the farthest position plus 10 yd, `entries` once each in first-seen order, flags 1 ambush / 2 alliance / 4 horde from `ambush` and `team`), the `steps` (`use_object` as a `move` to its `stand` and an `open` whose `item` is `requires_item`; `wait_for.gameobject` as `wait_object` with `param` 0/1/2 for ready/active/active_alt; `hostiles_gone` as `wait_clear`, centred on its `center` or on the last point of the leg; `loot_object` as `loot` with `item` the `expect_item`; `use_item` with diff --git a/internal/planexport/planexport.go b/internal/planexport/planexport.go index cc211ff7..3c256bc3 100644 --- a/internal/planexport/planexport.go +++ b/internal/planexport/planexport.go @@ -22,16 +22,20 @@ import ( const ( KindMove = "move" KindMoveDirect = "move_direct" - KindPull = "pull" - KindBoss = "boss" - KindLoot = "loot" - KindOpen = "open" - KindUseItem = "use_item" - KindWaitObject = "wait_object" - KindWaitClear = "wait_clear" - KindRest = "rest" - KindRitual = "ritual" - KindHold = "hold" + // KindMoveDirectExact is the direct step right after an exact via (FlagExact): a move_direct in every way for a module that knows + // exact waypoints, and a kind an older module does not know, so its loader skips the row (UnknownKind) and no direct order goes + // out over the hole from a foot it walks as a plain move (fail closed against a binary rollback). + KindMoveDirectExact = "move_direct_exact" + KindPull = "pull" + KindBoss = "boss" + KindLoot = "loot" + KindOpen = "open" + KindUseItem = "use_item" + KindWaitObject = "wait_object" + KindWaitClear = "wait_clear" + KindRest = "rest" + KindRitual = "ritual" + KindHold = "hold" ) // Flags of ai_dungeon_step.flags. @@ -297,7 +301,7 @@ func (b *builder) leg(li int) error { b.rows.Legs = append(b.rows.Legs, LegRow{Plan: b.name, Leg: b.outLeg, Name: clip(l.Name, MaxLegName)}) defer func() { b.outLeg++ }() // The leg in the order the driver does it: the via waypoints, the To, the pull groups, the steps, the boss, the steps after it. - for _, w := range l.Via { + for vi, w := range l.Via { if w.GMCross && b.opt.GMCrossDirect { b.warnings = append(b.warnings, fmt.Sprintf("leg %d (%s): via %.1f %.1f %.1f is a gm_cross; exported as a move_direct (a straight walk to the landing point), not tried live: the run leader has no GM move", li, l.Name, w.X, w.Y, w.Z)) b.move(w.Point, true, "via (gm_cross in the plan; move_direct, not tried live)") @@ -313,6 +317,11 @@ func (b *builder) leg(li int) error { b.last, b.haveLast = w.Point, true continue } + if w.Direct && vi > 0 && l.Via[vi-1].Exact { + b.add(StepRow{Kind: KindMoveDirectExact, X: w.X, Y: w.Y, Z: w.Z, Comment: "via (direct from the exact point before it)"}) + b.last, b.haveLast = w.Point, true + continue + } b.move(w.Point, w.Direct, "via") } b.move(l.To, false, "to") @@ -588,7 +597,7 @@ func Validate(r Rows) error { return bad("%d entries (at most %d)", len(s.Entries), MaxEntries) } switch s.Kind { - case KindMove, KindMoveDirect, KindRest: + case KindMove, KindMoveDirect, KindMoveDirectExact, KindRest: case KindPull: if len(s.Entries) == 0 { return bad("a pull needs entries") @@ -618,8 +627,16 @@ func Validate(r Rows) error { if s.Kind != KindMove { return bad("the exact flag is for a move row (the loader refuses it on a %s)", s.Kind) } - if i+1 >= len(r.Steps) || r.Steps[i+1].Plan != s.Plan || r.Steps[i+1].Leg != s.Leg || r.Steps[i+1].Kind != KindMoveDirect { - return bad("an exact move must be followed by the move_direct it launches (a row came between them, or there is none)") + if i+1 >= len(r.Steps) || r.Steps[i+1].Plan != s.Plan || r.Steps[i+1].Leg != s.Leg || r.Steps[i+1].Kind != KindMoveDirectExact { + return bad("an exact move must be followed by the move_direct_exact it launches (a row came between them, or there is none)") + } + } + if s.Kind == KindMoveDirectExact { + if i == 0 || r.Steps[i-1].Plan != s.Plan || r.Steps[i-1].Leg != s.Leg || r.Steps[i-1].Kind != KindMove || r.Steps[i-1].Flags&FlagExact == 0 { + return bad("a move_direct_exact must follow the exact move (flags %d) it is launched from", FlagExact) + } + if s.Flags != 0 { + return bad("a move_direct_exact carries no flags") } } if len(s.Comment) > MaxComment { @@ -659,7 +676,7 @@ func (b *builder) cutLongHops() error { cp := StepRow{Plan: s.Plan, Leg: s.Leg, Kind: s.Kind, X: s.X, Y: s.Y, Z: s.Z} prev = &cp } - if s.Kind == KindMove || s.Kind == KindMoveDirect { + if s.Kind == KindMove || s.Kind == KindMoveDirect || s.Kind == KindMoveDirectExact { if prev != nil { a, c := dungeon.Point{X: prev.X, Y: prev.Y, Z: prev.Z}, dungeon.Point{X: s.X, Y: s.Y, Z: s.Z} if d := dist3(a, c); d > MoveGapWarnYd { @@ -704,13 +721,13 @@ func (b *builder) routeAroundAvoid() error { added := map[int]int{} for _, s := range b.rows.Steps { switch s.Kind { - case KindMove, KindMoveDirect, KindPull, KindBoss, KindHold: + case KindMove, KindMoveDirect, KindMoveDirectExact, KindPull, KindBoss, KindHold: default: out = append(out, s) continue } to := dungeon.Point{X: s.X, Y: s.Y, Z: s.Z} - if s.Kind != KindMoveDirect { + if s.Kind != KindMoveDirect && s.Kind != KindMoveDirectExact { via, err := b.opt.Route(b.plan.Map, prev, to) if err != nil { return fmt.Errorf("leg %d step %d (%s): %w", s.Leg, s.Seq, s.Kind, err) diff --git a/internal/planexport/planexport_test.go b/internal/planexport/planexport_test.go index 35b7dff9..44e2e720 100644 --- a/internal/planexport/planexport_test.go +++ b/internal/planexport/planexport_test.go @@ -735,7 +735,7 @@ func TestExactViaIsAFlaggedMove(t *testing.T) { if s := r.Steps[0]; s.Kind != KindMove || s.X != 1 || s.Flags != FlagExact || !strings.Contains(s.Comment, "exact") { t.Fatalf("exact via %+v", s) } - if s := r.Steps[1]; s.Kind != KindMoveDirect || s.Flags != 0 { + if s := r.Steps[1]; s.Kind != KindMoveDirectExact || s.Flags != 0 || s.X != 10 { t.Fatalf("its direct step %+v", s) } for i, s := range r.Steps { @@ -750,15 +750,30 @@ func TestExactViaIsAFlaggedMove(t *testing.T) { if err := Equal([]Rows{r}, back); err != nil || back[0].Steps[0].Flags != FlagExact { t.Fatalf("round trip: %v %+v", err, back[0].Steps[0]) } - // Without exact in the plan, no row has the flag (the plain mapping is unchanged). - for _, s := range build(t, parsePlan(t, small)).Steps { - if s.Flags&FlagExact != 0 { - t.Fatalf("flag without exact: %+v", s) + // Without exact in the plan, no row has the flag and the direct step stays a move_direct (the plain mapping is unchanged). + plain := build(t, parsePlan(t, small)) + for _, s := range plain.Steps { + if s.Flags&FlagExact != 0 || s.Kind == KindMoveDirectExact { + t.Fatalf("exact without exact: %+v", s) } } + if plain.Steps[1].Kind != KindMoveDirect { + t.Fatalf("plain direct step %+v", plain.Steps[1]) + } + // The pairing both ways: an exact move before a plain move_direct, and a move_direct_exact after a plain move, are refused. bad := r bad.Steps = slices.Clone(r.Steps) - bad.Steps[1].Flags = FlagExact // on the move_direct + bad.Steps[1].Kind = KindMoveDirect + if err := Validate(bad); err == nil || !strings.Contains(err.Error(), "followed by the move_direct_exact") { + t.Fatalf("exact move before a plain move_direct: %v", err) + } + bad.Steps = slices.Clone(r.Steps) + bad.Steps[0].Flags = 0 + if err := Validate(bad); err == nil || !strings.Contains(err.Error(), "must follow the exact move") { + t.Fatalf("move_direct_exact after a plain move: %v", err) + } + bad.Steps = slices.Clone(r.Steps) + bad.Steps[1].Flags = FlagExact // on the move_direct_exact if err := Validate(bad); err == nil || !strings.Contains(err.Error(), "the exact flag is for a move row") { t.Fatalf("exact on a move_direct: %v", err) } @@ -794,7 +809,7 @@ func TestWailingCavernsSerpentisFootIsExact(t *testing.T) { if legOf[s.Leg] != "serpentis" || s.X != -293.3 || s.Y != 0.5 || s.Z != -58 { t.Fatalf("exact row %+v (leg %s)", s, legOf[s.Leg]) } - if n.Kind != KindMoveDirect || n.X != -287.5 || n.Y != 5 || n.Z != -63.6 { + if n.Kind != KindMoveDirectExact || n.X != -287.5 || n.Y != 5 || n.Z != -63.6 { t.Fatalf("after the foot: %+v", n) } } @@ -847,3 +862,24 @@ func TestUnexpressedAttributesAreWarned(t *testing.T) { t.Fatalf("one warning per attribute, %d for skip:\n%s", n, w) } } + +// TestRouteLeavesTheExactHopAlone: the avoid-box router puts no via point before a move_direct_exact (it is a straight line, like a +// move_direct): the exact foot stays right before its hop. +func TestRouteLeavesTheExactHopAlone(t *testing.T) { + const js = `{"version":1,"name":"lavahop","status":"live-checked (test)","map":389,"entrance":{"x":0,"y":0,"z":0}, + "avoid":[{"name":"basin","min":{"x":200,"y":-5,"z":-9},"max":{"x":210,"y":5,"z":-1},"why":"lava"}], + "legs":[{"name":"a","via":[{"x":5,"y":0,"z":0,"exact":true},{"x":10,"y":0,"z":-4,"direct":true}],"to":{"x":20,"y":0,"z":-4}, + "boss":{"entry":7000,"position":{"x":30,"y":0,"z":-4}}}]}` + route := func(_ uint32, a, b dungeon.Point) ([]dungeon.Point, error) { + return []dungeon.Point{{X: (a.X + b.X) / 2, Y: 1, Z: b.Z}}, nil // a via point on every hop it is asked about + } + res, err := Build(parsePlan(t, js), Options{Source: "test", Route: route}) + if err != nil { + t.Fatal(err) + } + for i, s := range res.Rows.Steps { + if s.Flags&FlagExact != 0 && res.Rows.Steps[i+1].Kind != KindMoveDirectExact { + t.Fatalf("a row between the foot and its hop: %+v", res.Rows.Steps[i+1]) + } + } +} From 15a9e88beb492b1ce1bf85ac51a46df7ed564327 Mon Sep 17 00:00:00 2001 From: pjerra <71784785+pjerra@users.noreply.github.com> Date: Tue, 20 Oct 2026 10:02:32 +0200 Subject: [PATCH 04/70] plan-export: name the exact hop's kind move_hop_exact (fits kind varchar(16)) and refuse longer kinds move_direct_exact is 17 characters; ai_dungeon_step.kind is a varchar(16), so the INSERT would fail in strict mode or cut the kind. The kind is now move_hop_exact (14). Validate refuses any kind longer than the column (MaxKind), and a test holds every kind the exporter writes to it. --- README.md | 2 +- internal/planexport/planexport.go | 48 ++++++++++++++------------ internal/planexport/planexport_test.go | 35 +++++++++++++------ 3 files changed, 52 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index 522e22bb..db4b2fbc 100644 --- a/README.md +++ b/README.md @@ -878,7 +878,7 @@ turtle-agent plan-export --all-live --out ./migrations/ --stamp 20261014200000 | `--gm-cross-direct` | export a `gm_cross` waypoint as a `move_direct` (a straight walk to the landing point) instead of refusing the plan; the run leader has no GM move. Written for Wailing Caverns' ramp to Lord Serpentis (6 yd down over 3.9 yd), which the plan now crosses with a plain direct step and a `check_skip`, so no plan needs it now; the row's comment and a warning say it is not tried live | | `--rev`, `--stamp` | the commit named in `ai_dungeon_plan.source` (default: `git rev-parse` of the plan folder, `+local` when it has changes) and the file's timestamp (default now) | -Per leg the rows are, in the order the driver does them: the `via` waypoints (`move`, or `move_direct` for `direct`; an `exact` via is a `move` with flags 64, the run leader's exact waypoint: done only once the lead bot stands within 1.5 yd of it, still for 2 s, and only then the direct step after it goes out, TortoiseBots `docs/guides/dungeon-run-leader.md` "Exact waypoints"; that direct step is written as kind `move_direct_exact`, which an older TortoiseBots does not know: its loader skips the row, so a rolled-back binary never sends the hop from short of the foot (it fails closed); the export refuses rows where anything comes between the two, an exact move before a plain `move_direct`, or a `move_direct_exact` not right after an exact move; a via's `check_skip` is the plan check's only and is not written, without a warning), `to` (`move`), each pull group (`pull`: the centre is the +Per leg the rows are, in the order the driver does them: the `via` waypoints (`move`, or `move_direct` for `direct`; an `exact` via is a `move` with flags 64, the run leader's exact waypoint: done only once the lead bot stands within 1.5 yd of it, still for 2 s, and only then the direct step after it goes out, TortoiseBots `docs/guides/dungeon-run-leader.md` "Exact waypoints"; that direct step is written as kind `move_hop_exact`, which an older TortoiseBots does not know: its loader skips the row, so a rolled-back binary never sends the hop from short of the foot (it fails closed); the export refuses rows where anything comes between the two, an exact move before a plain `move_direct`, or a `move_hop_exact` not right after an exact move; a via's `check_skip` is the plan check's only and is not written, without a warning), `to` (`move`), each pull group (`pull`: the centre is the mean of its positions, the radius is its `radius` or the farthest position plus 10 yd, `entries` once each in first-seen order, flags 1 ambush / 2 alliance / 4 horde from `ambush` and `team`), the `steps` (`use_object` as a `move` to its `stand` and an `open` whose `item` is `requires_item`; `wait_for.gameobject` as `wait_object` with `param` 0/1/2 for ready/active/active_alt; `hostiles_gone` as `wait_clear`, centred on its `center` or on the last point of the leg; `loot_object` as `loot` with `item` the `expect_item`; `use_item` with diff --git a/internal/planexport/planexport.go b/internal/planexport/planexport.go index 3c256bc3..e9a0510e 100644 --- a/internal/planexport/planexport.go +++ b/internal/planexport/planexport.go @@ -22,20 +22,20 @@ import ( const ( KindMove = "move" KindMoveDirect = "move_direct" - // KindMoveDirectExact is the direct step right after an exact via (FlagExact): a move_direct in every way for a module that knows + // KindMoveHopExact is the direct step right after an exact via (FlagExact): a move_direct in every way for a module that knows // exact waypoints, and a kind an older module does not know, so its loader skips the row (UnknownKind) and no direct order goes // out over the hole from a foot it walks as a plain move (fail closed against a binary rollback). - KindMoveDirectExact = "move_direct_exact" - KindPull = "pull" - KindBoss = "boss" - KindLoot = "loot" - KindOpen = "open" - KindUseItem = "use_item" - KindWaitObject = "wait_object" - KindWaitClear = "wait_clear" - KindRest = "rest" - KindRitual = "ritual" - KindHold = "hold" + KindMoveHopExact = "move_hop_exact" + KindPull = "pull" + KindBoss = "boss" + KindLoot = "loot" + KindOpen = "open" + KindUseItem = "use_item" + KindWaitObject = "wait_object" + KindWaitClear = "wait_clear" + KindRest = "rest" + KindRitual = "ritual" + KindHold = "hold" ) // Flags of ai_dungeon_step.flags. @@ -60,6 +60,7 @@ const ( MaxRadius = 100 // yards MaxTimeoutS = 600 // seconds MaxEntries = 16 // creature entries of a pull or boss + MaxKind = 16 // ai_dungeon_step.kind is a varchar(16): a longer kind fails the INSERT (or is cut) MaxPlanName = 32 // ai_dungeon_plan.plan MaxLegName = 32 // ai_dungeon_leg.name MaxPlanList = 255 // ai_dungeon_plan.cc_priority, cc_always, kill_first (varchar(255)) @@ -318,7 +319,7 @@ func (b *builder) leg(li int) error { continue } if w.Direct && vi > 0 && l.Via[vi-1].Exact { - b.add(StepRow{Kind: KindMoveDirectExact, X: w.X, Y: w.Y, Z: w.Z, Comment: "via (direct from the exact point before it)"}) + b.add(StepRow{Kind: KindMoveHopExact, X: w.X, Y: w.Y, Z: w.Z, Comment: "via (direct from the exact point before it)"}) b.last, b.haveLast = w.Point, true continue } @@ -590,6 +591,9 @@ func Validate(r Rows) error { if s.Radius < 0 || s.Radius > MaxRadius { return bad("radius %.1f is outside 0-%d", s.Radius, MaxRadius) } + if len(s.Kind) > MaxKind { + return bad("the kind is longer than ai_dungeon_step.kind holds (%d)", MaxKind) + } if s.TimeoutS < 0 || s.TimeoutS > MaxTimeoutS { return bad("timeout_s %d is outside 0-%d", s.TimeoutS, MaxTimeoutS) } @@ -597,7 +601,7 @@ func Validate(r Rows) error { return bad("%d entries (at most %d)", len(s.Entries), MaxEntries) } switch s.Kind { - case KindMove, KindMoveDirect, KindMoveDirectExact, KindRest: + case KindMove, KindMoveDirect, KindMoveHopExact, KindRest: case KindPull: if len(s.Entries) == 0 { return bad("a pull needs entries") @@ -627,16 +631,16 @@ func Validate(r Rows) error { if s.Kind != KindMove { return bad("the exact flag is for a move row (the loader refuses it on a %s)", s.Kind) } - if i+1 >= len(r.Steps) || r.Steps[i+1].Plan != s.Plan || r.Steps[i+1].Leg != s.Leg || r.Steps[i+1].Kind != KindMoveDirectExact { - return bad("an exact move must be followed by the move_direct_exact it launches (a row came between them, or there is none)") + if i+1 >= len(r.Steps) || r.Steps[i+1].Plan != s.Plan || r.Steps[i+1].Leg != s.Leg || r.Steps[i+1].Kind != KindMoveHopExact { + return bad("an exact move must be followed by the move_hop_exact it launches (a row came between them, or there is none)") } } - if s.Kind == KindMoveDirectExact { + if s.Kind == KindMoveHopExact { if i == 0 || r.Steps[i-1].Plan != s.Plan || r.Steps[i-1].Leg != s.Leg || r.Steps[i-1].Kind != KindMove || r.Steps[i-1].Flags&FlagExact == 0 { - return bad("a move_direct_exact must follow the exact move (flags %d) it is launched from", FlagExact) + return bad("a move_hop_exact must follow the exact move (flags %d) it is launched from", FlagExact) } if s.Flags != 0 { - return bad("a move_direct_exact carries no flags") + return bad("a move_hop_exact carries no flags") } } if len(s.Comment) > MaxComment { @@ -676,7 +680,7 @@ func (b *builder) cutLongHops() error { cp := StepRow{Plan: s.Plan, Leg: s.Leg, Kind: s.Kind, X: s.X, Y: s.Y, Z: s.Z} prev = &cp } - if s.Kind == KindMove || s.Kind == KindMoveDirect || s.Kind == KindMoveDirectExact { + if s.Kind == KindMove || s.Kind == KindMoveDirect || s.Kind == KindMoveHopExact { if prev != nil { a, c := dungeon.Point{X: prev.X, Y: prev.Y, Z: prev.Z}, dungeon.Point{X: s.X, Y: s.Y, Z: s.Z} if d := dist3(a, c); d > MoveGapWarnYd { @@ -721,13 +725,13 @@ func (b *builder) routeAroundAvoid() error { added := map[int]int{} for _, s := range b.rows.Steps { switch s.Kind { - case KindMove, KindMoveDirect, KindMoveDirectExact, KindPull, KindBoss, KindHold: + case KindMove, KindMoveDirect, KindMoveHopExact, KindPull, KindBoss, KindHold: default: out = append(out, s) continue } to := dungeon.Point{X: s.X, Y: s.Y, Z: s.Z} - if s.Kind != KindMoveDirect && s.Kind != KindMoveDirectExact { + if s.Kind != KindMoveDirect && s.Kind != KindMoveHopExact { via, err := b.opt.Route(b.plan.Map, prev, to) if err != nil { return fmt.Errorf("leg %d step %d (%s): %w", s.Leg, s.Seq, s.Kind, err) diff --git a/internal/planexport/planexport_test.go b/internal/planexport/planexport_test.go index 44e2e720..5a538c36 100644 --- a/internal/planexport/planexport_test.go +++ b/internal/planexport/planexport_test.go @@ -735,7 +735,7 @@ func TestExactViaIsAFlaggedMove(t *testing.T) { if s := r.Steps[0]; s.Kind != KindMove || s.X != 1 || s.Flags != FlagExact || !strings.Contains(s.Comment, "exact") { t.Fatalf("exact via %+v", s) } - if s := r.Steps[1]; s.Kind != KindMoveDirectExact || s.Flags != 0 || s.X != 10 { + if s := r.Steps[1]; s.Kind != KindMoveHopExact || s.Flags != 0 || s.X != 10 { t.Fatalf("its direct step %+v", s) } for i, s := range r.Steps { @@ -753,33 +753,33 @@ func TestExactViaIsAFlaggedMove(t *testing.T) { // Without exact in the plan, no row has the flag and the direct step stays a move_direct (the plain mapping is unchanged). plain := build(t, parsePlan(t, small)) for _, s := range plain.Steps { - if s.Flags&FlagExact != 0 || s.Kind == KindMoveDirectExact { + if s.Flags&FlagExact != 0 || s.Kind == KindMoveHopExact { t.Fatalf("exact without exact: %+v", s) } } if plain.Steps[1].Kind != KindMoveDirect { t.Fatalf("plain direct step %+v", plain.Steps[1]) } - // The pairing both ways: an exact move before a plain move_direct, and a move_direct_exact after a plain move, are refused. + // The pairing both ways: an exact move before a plain move_direct, and a move_hop_exact after a plain move, are refused. bad := r bad.Steps = slices.Clone(r.Steps) bad.Steps[1].Kind = KindMoveDirect - if err := Validate(bad); err == nil || !strings.Contains(err.Error(), "followed by the move_direct_exact") { + if err := Validate(bad); err == nil || !strings.Contains(err.Error(), "followed by the move_hop_exact") { t.Fatalf("exact move before a plain move_direct: %v", err) } bad.Steps = slices.Clone(r.Steps) bad.Steps[0].Flags = 0 if err := Validate(bad); err == nil || !strings.Contains(err.Error(), "must follow the exact move") { - t.Fatalf("move_direct_exact after a plain move: %v", err) + t.Fatalf("move_hop_exact after a plain move: %v", err) } bad.Steps = slices.Clone(r.Steps) - bad.Steps[1].Flags = FlagExact // on the move_direct_exact + bad.Steps[1].Flags = FlagExact // on the move_hop_exact if err := Validate(bad); err == nil || !strings.Contains(err.Error(), "the exact flag is for a move row") { t.Fatalf("exact on a move_direct: %v", err) } bad.Steps = slices.Clone(r.Steps) bad.Steps = append(bad.Steps[:1:1], append([]StepRow{{Plan: r.Steps[0].Plan, Leg: 0, Kind: KindMove, X: 5}}, r.Steps[1:]...)...) - if err := Validate(bad); err == nil || !strings.Contains(err.Error(), "followed by the move_direct") { + if err := Validate(bad); err == nil || !strings.Contains(err.Error(), "followed by the move_hop_exact") { t.Fatalf("a row between the exact move and its direct step: %v", err) } } @@ -809,7 +809,7 @@ func TestWailingCavernsSerpentisFootIsExact(t *testing.T) { if legOf[s.Leg] != "serpentis" || s.X != -293.3 || s.Y != 0.5 || s.Z != -58 { t.Fatalf("exact row %+v (leg %s)", s, legOf[s.Leg]) } - if n.Kind != KindMoveDirectExact || n.X != -287.5 || n.Y != 5 || n.Z != -63.6 { + if n.Kind != KindMoveHopExact || n.X != -287.5 || n.Y != 5 || n.Z != -63.6 { t.Fatalf("after the foot: %+v", n) } } @@ -863,7 +863,7 @@ func TestUnexpressedAttributesAreWarned(t *testing.T) { } } -// TestRouteLeavesTheExactHopAlone: the avoid-box router puts no via point before a move_direct_exact (it is a straight line, like a +// TestRouteLeavesTheExactHopAlone: the avoid-box router puts no via point before a move_hop_exact (it is a straight line, like a // move_direct): the exact foot stays right before its hop. func TestRouteLeavesTheExactHopAlone(t *testing.T) { const js = `{"version":1,"name":"lavahop","status":"live-checked (test)","map":389,"entrance":{"x":0,"y":0,"z":0}, @@ -878,8 +878,23 @@ func TestRouteLeavesTheExactHopAlone(t *testing.T) { t.Fatal(err) } for i, s := range res.Rows.Steps { - if s.Flags&FlagExact != 0 && res.Rows.Steps[i+1].Kind != KindMoveDirectExact { + if s.Flags&FlagExact != 0 && res.Rows.Steps[i+1].Kind != KindMoveHopExact { t.Fatalf("a row between the foot and its hop: %+v", res.Rows.Steps[i+1]) } } } + +// TestKindsFitTheColumn: ai_dungeon_step.kind is a varchar(16); every kind the exporter writes fits it, and Validate refuses one that +// does not (move_direct_exact, 17 characters, was the first name of the exact hop's kind). +func TestKindsFitTheColumn(t *testing.T) { + for _, k := range []string{KindMove, KindMoveDirect, KindMoveHopExact, KindPull, KindBoss, KindLoot, KindOpen, KindUseItem, KindWaitObject, + KindWaitClear, KindRest, KindRitual, KindHold} { + if len(k) > MaxKind { + t.Fatalf("kind %q is %d characters", k, len(k)) + } + } + r := Rows{Plan: PlanRow{Plan: "q", Map: 1}, Legs: []LegRow{{Plan: "q"}}, Steps: []StepRow{{Plan: "q", Kind: "move_direct_exact"}}} + if err := Validate(r); err == nil || !strings.Contains(err.Error(), "varchar") && !strings.Contains(err.Error(), "kind is longer") { + t.Fatalf("%v", err) + } +} From f558c91c739ad60659c1f716b079ae1301942cc1 Mon Sep 17 00:00:00 2001 From: pjerra <71784785+pjerra@users.noreply.github.com> Date: Tue, 20 Oct 2026 10:07:55 +0200 Subject: [PATCH 05/70] plan-export: the long-hop cutter never splits a move_hop_exact from its exact move The cutter measured a move_hop_exact like any move and, for a hop longer than MoveGapWarnYd, put its navmesh points between the exact move and the hop, which Validate then refused. The hop is now indivisible there (no Cut call, nothing inserted before it; it stays the next hop's anchor), as the avoid-box router already had it. dungeon.Parse holds a direct via to 25 yd, so the test sets a 65 yd hop on the parsed plan. --- internal/planexport/planexport.go | 4 +++- internal/planexport/planexport_test.go | 25 +++++++++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/internal/planexport/planexport.go b/internal/planexport/planexport.go index e9a0510e..64e63dcb 100644 --- a/internal/planexport/planexport.go +++ b/internal/planexport/planexport.go @@ -681,7 +681,9 @@ func (b *builder) cutLongHops() error { prev = &cp } if s.Kind == KindMove || s.Kind == KindMoveDirect || s.Kind == KindMoveHopExact { - if prev != nil { + // The hop after an exact move is indivisible: no cut point may come between the exact move and it (the run leader launches + // it from that move only; Validate refuses rows that separate them). It is still the anchor for the next hop. + if prev != nil && s.Kind != KindMoveHopExact { a, c := dungeon.Point{X: prev.X, Y: prev.Y, Z: prev.Z}, dungeon.Point{X: s.X, Y: s.Y, Z: s.Z} if d := dist3(a, c); d > MoveGapWarnYd { if b.opt.Cut == nil { diff --git a/internal/planexport/planexport_test.go b/internal/planexport/planexport_test.go index 5a538c36..018f56df 100644 --- a/internal/planexport/planexport_test.go +++ b/internal/planexport/planexport_test.go @@ -898,3 +898,28 @@ func TestKindsFitTheColumn(t *testing.T) { t.Fatalf("%v", err) } } + +// TestCutterLeavesTheExactHopAlone: with a cutter, a move_hop_exact farther than MoveGapWarnYd from its exact move gets no cut points +// (the hop is indivisible): Build succeeds and the exact move stays right before its hop; the hop is the anchor of the next hop. +func TestCutterLeavesTheExactHopAlone(t *testing.T) { + const js = `{"version":1,"name":"farhop","status":"live-checked (test)","map":43,"entrance":{"x":0,"y":0,"z":0}, + "legs":[{"name":"a","via":[{"x":5,"y":0,"z":0,"exact":true},{"x":20,"y":0,"z":-4,"direct":true}],"to":{"x":75,"y":0,"z":-4}, + "boss":{"entry":7000,"position":{"x":80,"y":0,"z":-4}}}]}` + // dungeon.Parse holds a direct via to 25 yd, so the 65 yd hop is set on the parsed plan: the exporter must not rely on that limit. + p := parsePlan(t, js) + p.Legs[0].Via[1].X = 70 + calls := 0 + cut := func(_ uint32, a, b dungeon.Point) ([]dungeon.Point, error) { + calls++ + return []dungeon.Point{{X: (a.X + b.X) / 2, Y: 1, Z: b.Z}}, nil + } + res, err := Build(p, Options{Source: "test", Cut: cut}) + if err != nil { + t.Fatal(err) + } + for i, s := range res.Rows.Steps { + if s.Flags&FlagExact != 0 && (res.Rows.Steps[i+1].Kind != KindMoveHopExact || res.Rows.Steps[i+1].X != 70) { + t.Fatalf("a row between the foot and its hop: %+v", res.Rows.Steps[i+1]) + } + } +} From f181872788fb7e5971daab211b716912cbcb82fd Mon Sep 17 00:00:00 2001 From: pjerra <71784785+pjerra@users.noreply.github.com> Date: Tue, 20 Oct 2026 15:01:51 +0200 Subject: [PATCH 06/70] dashboard: list the three map picture slots with reasons, as Zul'Farrak has them /api/minimap/ now answers World map (and each world-map floor), Minimap and Navmesh in that order, every slot with kind, floor, label and available, and a one-line reason instead of picture fields when the folder has none. A map the folder has nothing for answers 200 with three missing slots; only an unset --minimap-dir answers 404. A slot's reason comes from an optional .status.json (64 KiB, 160 characters a reason), with a built-in line per kind otherwise. The main .json stands in only for a source without a file of its own, so a picture is never listed twice. The page's picker shows all three slots, disables a missing one as "