From b860ad13299f8a8d195b0306650fa7444c16af6d Mon Sep 17 00:00:00 2001 From: pjerra <71784785+pjerra@users.noreply.github.com> Date: Tue, 20 Oct 2026 20:49:34 +0200 Subject: [PATCH 1/4] zulfarrak: approach the pool stairs from the lower floor The zf-full-19z16 run stopped at the four-mob pack on the west road: the lead stood at 1629 1099, the foot of the pool stairs, which is a navmesh island, and three followers plus the healer went up to z 31-46.8 and took six minutes to gather back. Give the last two packs approach points on the lower floor west of the stair mouth so the run leader's party stays there. Pin the real Gong of Zul'Farrak (141832) in a test, not the Turtle decoration copy 20901. Record where the Mallet comes from: item 9240 is made by using 9241, and 9241 drops only from Qiaga the Keeper (7996) on map 0, so no bot run can acquire it and the leg stays optional. Note the missing pyramid phase gate (instance data 1, value 7): the plan format has no instance-data wait, so the stairs leg keeps its Nekrum-seen proxy. --- README.md | 8 ++- docs/PLANS.md | 2 +- dungeons/zulfarrak.json | 19 +++++-- internal/dungeon/zulfarrak_test.go | 73 ++++++++++++++++++++++++++ internal/planexport/planexport_test.go | 51 ++++++++++++++++++ tools/dungeongen/plans.json | 4 +- 6 files changed, 149 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 677411ea..d037901e 100644 --- a/README.md +++ b/README.md @@ -913,10 +913,16 @@ The terrace exemptions (`ragefireExemptions` in `ragefire_test.go`, settled with ### Zul'Farrak events: Gahz'rilla, the courtyard and Zum'rah +**The pool stairs (`velratha` pull groups 5 and 6, 2026-10-20).** In the full observe run (`zf-full-19z16`, RUN-LESSONS) the run leader stopped at the pack of four at 1601-1610 1104-1111 and then stood at 1629.2 1099.0 z 9.7, a navmesh **island** at the foot of the stairs: a path from 1595.5 1092.4 to it is `partial` and ends 8.8 yd short at 1621.3 1102.9. Three followers and the healer reached the upper level instead (1629-1636 1070-1099, z 31-46.8, 46 yd from the lead, "reach spell" at a mob 37 yd below) and the gather took 6 min to summon them back. The two groups now carry `approach` rows — 1584 1050 and 1582 1070 for the five-mob pack, 1588 1080 and 1593.6 1089.9 for the four-mob pack — so the run leader's party walks to a stand on the lower floor west of the stair mouth (path corners of the 209 mmaps; not live-run yet). The refused pullback of the same run (144 s) is the run leader's own retry, not the plan. + +**The pyramid phase is not in the plan format yet.** The stairs event's phase is instance data **1** (`EVENT_PYRAMID`), value **7** = `PYRAMID_WAVE_3` (0-8 in `zulfarrak.h`). A `wait_for` can hold on units, objects and hostiles only; a pull group's or boss's `hold` carries an `instance_data` index but the plan has no field for the value it must read, and plan-export writes the encounter DONE (3) condition. The module-side hold-until-instance-data flag 128 is planned (issue #984), not in the snapshot this plan exports against. `stairs_top` therefore waits on the observable proxy (Nekrum 7796 seen at the foot) and its note describes the gap; a true gate needs `instance_data: 1, done: 7`. + **Gahz'rilla (PR A, stacked on PR B).** The optional `gahzrilla` leg sits between `velratha_b` and `courtyard`: it walks to the gong stand (1652.84 1168.99 10.67, 3.5 yd from the gong), pulls the eight packs on and near the walk Gahz'rilla takes out of his pool, then the `use_object` step with `via_item` rings the Gong of Zul'Farrak (141832, spawn row 27140 at 1650.91 1171.88 10.9, lock 459): a bot that carries the Mallet of Zul'Farrak (9240) gets `cmd use 9240 entry:141832@1650.9,1171.9,10.9` (the Mallet is used ON the gong: its on-use spell 11434 is an open-lock effect aimed at a game object; round 3 of the live test sent `cmd open` of the gong, which did nothing and answered nothing); a `wait_for` waits up to 30 s for a living Gahz'rilla (7273) in the bots' view, and the boss fight kills him at 1702.58 1224.19 8.88. Without a Mallet in the lead bot's or the carrier's bags the leg is skipped (see `optional` above); a test gives it with `give_items`. -What the world database says (tortoise-wow-cbspin 4f133ab): the gong is a goober with lock 459 and event 2488, the client shows it usable, so the Mallet is checked on the server when it is used. Event 2488 respawns the other gong object (guid 209001) and 2 s later summons Gahz'rilla (7273, level 46, faction 107, no spawn row) +What the world database says (tortoise-wow-cbspin 4f133ab): the gong is a goober with lock 459 and event 2488, the client shows it usable, so the Mallet is checked on the server when it is used. Event 2488 respawns the other gong object (guid 209001, entry 20901, a Turtle decoration copy with no lock and no event; the plan never targets it) and 2 s later summons Gahz'rilla (7273, level 46, faction 107, no spawn row) at 1664.65 1187.67 -2.89 for 3600 s: the pit under the pool, 22 yd from the stand. His AI casts spell 33007, which fires event 2489: six moves one second apart, 1672.66 1195.93, 1681.74 1204.13, 1686.09 1207.3, 1690.54 1211.53, 1692.57 1213.66 and, 6 s after the summon, 1702.58 1224.19 8.88, which is where he stands (event 2489 has no summon: the "six trolls" of the first draft were those six rows). +**The Mallet's source (tw_world, 2026-10-20, SELECT only).** `Lock.dbc` row 459 is type 1 (item required) with 9240, so the gong needs item 9240 and no skill or key. Item 9240 is not a quest reward, vendor item, chest or loot row anywhere: it is created by using item **9241 Sacred Mallet** (bind on pickup), whose on-use spell 11435 "Create Mallet of Zul'Farrak" (Effect 24) puts 9240 in the bags. 9241's only source is `creature_loot_template` entry 7996 at 100 %: **Qiaga the Keeper**, level 50 elite, faction 37, one spawn on map 0 at -284.792 -3447.45 188.426 (The Hinterlands); 9239 (the mace of the same name) has no source either. A ZF plan starts inside map 209 with hired level 46 bots and has no step kind for leaving the instance, travelling to map 0, killing a level-50 elite and returning, so **no bot run can obtain the Mallet**: the leg is skipped honestly, and only a test's `give_items` (`additem 9240`) reaches it. + The packs: the Velratha leg clears only the strip (and the pack of five at 1712-1720 1181-1190); eight groups remain within about 45 yd of his walk, each member checked against the export: [5649 5649 5650] at 1621-1625 1192-1197, [5650] 1643.6 1200.1, [7246 7246 8095] 1649-1654 1210-1215 (radius 30: the Sandcrawler patrols), [5649] 1652.1 1219.9, [5649] 1667 1233.6, [5649] 1699.8 1233.8, [5650 7246] at 1700.3 1248.3 and 1713.2 1247.8 and [5649] 1714.4 1218.6 (the one the tank found no firing position for twice in the Velratha leg's batch-2 runs). The four casters at 1662-1667 1254-1260 (47 yd from the walk) are left out; the design list had them as two Shadowhunters and a Witch Doctor, the export has three Shadowhunters (rows 81594, 81592, 81595) and the Witch Doctor 81590. The gong stand, every pack position and the boss position are on the navmesh and reachable (`TestZulfarrakOnNavmesh`, `TestZulfarrakPlanCheck`, `TestZulfarrakRouteHasNoUnplannedPack`). **Not yet run live with the Mallet.** diff --git a/docs/PLANS.md b/docs/PLANS.md index 5d5be52e..4407d104 100644 --- a/docs/PLANS.md +++ b/docs/PLANS.md @@ -51,7 +51,7 @@ positions are the creatures' own, so one up to 4 yd off the floor or 2.5 yd to t | 189 | `scarlet_cathedral`: Scarlet Monastery: Cathedral | 37-42 | 3 | draft | yes | chapel door (GO_CHAPPEL_DOOR) before Mograine and Whitemane; Mograine and Whitemane are one event: Whitemane resurrects Mograine | | 70 | `uldaman`: Uldaman | 41-45 | 14 | live-checked | yes | Keystone (needs the Staff of Prehistoria, made from the Gni'kiv Medallion and the Shaft of Tsol) opens the Seal of Khaz'Mul for Ironaya; Altar of the Keepers and Altar of Archaedas: ritual of three bots | | 815 | `gilneas_city`: Gilneas City | 42-47 | 7 | draft | yes | Harlow family chest (GO_HARLOW_FAMILY_CHEST) after the Regent-Lord and Regent-Lady; Genn Greymane (61418) is script-driven (genn_greymane): his role (final fight or event) is unverified | -| 209 | `zulfarrak`: Zul'Farrak | 44-48 | 18 | live-checked | yes | Zum'rah: area trigger 962 makes him hostile; stairs event with Sergeant Bly's crew, Nekrum and Sezz'ziz; Weegli's gossip (or Bly's) has him blow the End Door for Ukorz and Ruuzlu; Gahz'rilla: the optional gahzrilla leg (Gong with the Mallet of Zul'Farrak, 9240; skipped without it) | +| 209 | `zulfarrak`: Zul'Farrak | 44-48 | 18 | live-checked | yes | Zum'rah: area trigger 962 makes him hostile; stairs event with Sergeant Bly's crew, Nekrum and Sezz'ziz (its phase is instance data 1 = 7; the plan waits on Nekrum seen, no phase step yet); Weegli's gossip (or Bly's) has him blow the End Door for Ukorz and Ruuzlu; Gahz'rilla: the optional gahzrilla leg (Gong with the Mallet of Zul'Farrak, 9240; it drops from Qiaga the Keeper 7996 outside the instance and 9241 must be used to make 9240, so no bot run can obtain it; skipped without give_items) | | 349 | `maraudon_purple`: Maraudon: Purple | 44-49 | 6 | draft | yes | Celebras the Cursed's Scepter of Celebras event is optional; Vylestem vines (GO_VYLESTEM_VINE, GO_HEALED_CELEBRIAN_VINE) round Celebras' room | | 349 | `maraudon_orange`: Maraudon: Orange | 45-50 | 6 | draft | yes | Celebras the Cursed's Scepter of Celebras event is optional and not on this route; Princess Theradras: the party enters her cave by the inner passage (join of the orange and purple routes unverified) | | 109 | `sunken_temple`: The Temple of Atal'Hakkar | 47-52 | 6 | draft | yes | six Atal'ai statues (GO_ATALAI_STATUE_1-6) lit in order summon Atal'alarion; the six troll mini-bosses (5712-5717) are the statues' guards; four Eternal Flames (GO_ETERNAL_FLAME_1-4) summon the Avatar of Hakkar (8443, no spawn row); Jammal'an's barrier (GO_JAMMALAN_BARRIER) and the Hakkar doors (GO_HAKKAR_DOOR_1-2); Shade of Eranikus needs the Essence of Eranikus path and the Altar of Hakkar | diff --git a/dungeons/zulfarrak.json b/dungeons/zulfarrak.json index 47f54ccd..f62b5abe 100644 --- a/dungeons/zulfarrak.json +++ b/dungeons/zulfarrak.json @@ -508,7 +508,11 @@ {"x": 1586.1, "y": 1087.3, "z": 8.9}, {"x": 1590.3, "y": 1090.3, "z": 8.9} ], - "radius": 30 + "radius": 30, + "approach": [ + {"x": 1584, "y": 1050, "z": 9}, + {"x": 1582, "y": 1070, "z": 9} + ] }, { "entries": [7246, 5650, 7246, 5650], @@ -518,14 +522,19 @@ {"x": 1608.3, "y": 1110.8, "z": 9}, {"x": 1610.4, "y": 1108.3, "z": 9} ], - "radius": 15 + "radius": 15, + "approach": [ + {"x": 1588, "y": 1080, "z": 9.1}, + {"x": 1593.6, "y": 1089.9, "z": 9.3} + ] } ], "skip": [7269, 10080, 10082], "notes": [ "zf1-run6 (2026-10-15): two lone Shadowhunter groups are dropped. 1591 1047.7 (7246): three pulls drew no fight (the tank stood 0.7 yd from it and targeted it, the mob stayed at 100 % out of combat, the hunter and the warlock reported out of range); the same Shadowhunter fought as part of the pack at 1576 1079. 1626.5 1136 (7246): nothing of it in view and the look walk to 21 yd of it was not reachable (lead 37 yd away). The run finished without either.", "TestZulfarrakRouteHasNoUnplannedPack (2026-10-14) found three mobs within 20 yd of this leg's route in no pull group: the Shadowhunter 44177 (1662.3 929.7) with the pack of rows 81440/81443/81444 behind it (1657-1662 938-943; a group of its own after the first), the patrolling Shadowcaster 1246219 (1589.4 985.4, a group of its own) and the Blood Drinker 81575 (1632.5 1166.8, 19 yd from Velratha's spot, added to the group at 1648 1152).", - "Route edit Z4 NOT applied (2026-10-16): a planned group for the Shadowhunter / Sandcrawler pair at 1565 1019 (the export has no spawn row there: they are roaming members of the patrol group at 1576-1590 1079-1092) puts the look point 21 yd off it within 20 yd of the static pack of two Witch Doctors and a Shadowhunter at 1545-1556 995-1007 (81618, 81619, 81624), which stands in a pit 4 yd below the mesh, off the navmesh and out of any pull (TestZulfarrakRouteHasNoUnplannedPack fails). The pair is still met on the way and the walk's pull takes it." + "Route edit Z4 NOT applied (2026-10-16): a planned group for the Shadowhunter / Sandcrawler pair at 1565 1019 (the export has no spawn row there: they are roaming members of the patrol group at 1576-1590 1079-1092) puts the look point 21 yd off it within 20 yd of the static pack of two Witch Doctors and a Shadowhunter at 1545-1556 995-1007 (81618, 81619, 81624), which stands in a pit 4 yd below the mesh, off the navmesh and out of any pull (TestZulfarrakRouteHasNoUnplannedPack fails). The pair is still met on the way and the walk's pull takes it.", + "The pool stairs (zf-full-19z16, 2026-10-20, STUCK #2): after the pack of four at 1601-1610 1104-1111 the run leader stood at 1629.2 1099.0 z 9.7 and three followers (and the healer at z 31.8) were on the UPPER level at 1629-1636 1070-1099, z 45.9-46.8, 46 yd from the lead, last_action \"reach spell\" at a mob 37 yd below; the gather summoned them one by one over 6 min (15:27:36-15:33:40) and the next pull started with the DPS 26-35 yd out. The stair base is a navmesh island: a path from 1595.5 1092.4 to 1629.2 1099.0 is Partial and ends at 1621.3 1102.9, 8.8 yd short (the 209 mmaps of 2026-10-20), so a follower walked to the lead's stand there has to go up and around. The approach rows of pull group 4 (1584 1050, 1582 1070) and group 5 (1588 1080, 1593.6 1089.9) keep the run leader's party on the lower floor, west of the stair mouth (planned from the path corners and the STUCK #2 positions; not live-run). The refused pullback of STUCK #1 (144 s at the same pack) is a run-leader retry matter, not this plan." ] }, { @@ -678,6 +687,7 @@ "Event 2488 (event_scripts, world database of tortoise-wow-cbspin 4f133ab): delay 0 respawns the other gong object (guid 209001, 3600 s), delay 2 s summons Gahz'rilla (7273, level 46, faction 107, EventAI 20901, no spawn row) at 1664.65 1187.67 -2.89 (o 0.81) for 3600 s: the pit under the pool, 22 yd from the stand. His own AI casts spell 33007, which sends event 2489 (creature_ai_scripts 2090101): six MOVE_TO commands, one second apart, out of the pit and up the ramp: 1672.66 1195.93 (1 s), 1681.74 1204.13 (2 s), 1686.09 1207.3 (3 s), 1690.54 1211.53 (4 s), 1692.57 1213.66 (5 s) and 1702.58 1224.19 8.88 (6 s). The boss position is the last of them (1702.6 1224.2): he stands there, or comes at the party before. The brief of the leg said 1692.6 1213.7, which is only the fifth point of the walk. The step after the gong waits up to 30 s for a living 7273 in the bots' view; the boss fight then looks for him within 80 yd of the lead bot. Event 2489 in this database holds only those six moves (no summon): the six Sandfury trolls the first draft of the extra plan listed for it were these six rows misread, and nothing else appears at the pit.", "THE PACKS. The pool ring still has about nine packs within 35 yd of his walk after the Velratha leg, which clears only the strip (and the five at 1712-1720 1181-1190, 32 yd from the walk). The eight groups here are the ones on or near the walk-out (every member checked against the world export, creatures.tsv, to the decimal): the Witch Doctor at 1643.6 1200.1 (24 yd from the walk), the two Shadowhunters with the patrolling Sandcrawler of 1649-1654 1210-1215 (spawn rows 81583, 81582, 81581: 24-30 yd; the Sandcrawler walks a patrol, so the group has the 30 yd radius), the Blood Drinkers at 1652.1 1219.9 (31 yd), 1667 1233.6 (32 yd), 1699.8 1233.8 (10 yd from where he ends) and 1714.4 1218.6 (13 yd from it), and the Witch Doctor and the Shadowhunter at 1700.3 1248.3 and 1713.2 1247.8 (24-26 yd, two groups of one in the export, pulled as one). Left out on purpose: the four at 1662-1667 1254-1260 (47 yd from the walk, 41 yd from where he ends; the design list said two Shadowhunters and a Witch Doctor, the export has THREE Shadowhunters, rows 81594, 81592, 81595, with the Witch Doctor 81590). The pack of three at 1621-1625 1192-1197 (40 yd west of the walk) IS pulled, first: the walk from the stand to the first Witch Doctor passes 20 yd from it, and TestZulfarrakRouteHasNoUnplannedPack flags a mob that near a route. The Scarabs (7269) and Elder Wildmane (15578, friendly, absent live) are in skip. The Blood Drinker at 1714.4 1218.6 is the one the Velratha leg's notes say the tank found no firing position for in two batch-2 runs: it is the last pull here, and a refusal on it is the first thing to look at in a live run.", "The first run with the Mallet: not yet done. Gahz'rilla's abilities (spells 11836 Freeze Solid, 11902 and 11131) are not in the plan's tactics; his first fight sets them.", + "WHERE THE MALLET COMES FROM (world DB tw_world, 2026-10-20, SELECT only): Lock.dbc 459 is type 1 (item required) with index 9240, so the gong's lock needs item 9240 and nothing else. Item 9240 Mallet of Zul'Farrak (class 12 quest item, bind on pickup) is not a quest reward, a vendor good, a chest or a loot row anywhere; it is created by USING item 9241 Sacred Mallet (also a bind-on-pickup quest item), whose on-use spell 11435 \"Create Mallet of Zul'Farrak\" has Effect 24 and creates 9240. Item 9241's only source is creature_loot_template entry 7996 at 100 %: Qiaga the Keeper, level 50 elite, faction 37, one spawn on map 0 at -284.792 -3447.45 188.426 (The Hinterlands). No other creature, object, quest, vendor or reference loot row gives 9239/9240/9241. A ZF run starts inside map 209 with hired level 46 bots and the plan has no step kind for leaving the instance, travelling to another map, killing a level-50 elite and coming back, so the Mallet is NOT obtainable in a bot run: the leg stays optional and is skipped honestly, and only a test's `.additem 9240` reaches it. The real gong is 141832 (type 10 goober, guid 27140, data0 459, data2 event 2488); 20901 is the Turtle decoration copy (type 5, guid 209001, no lock, no event) at the same 1650.91 1171.88 10.901 and is never targeted.", "THE FIGHT (Wowhead strategy guide, round 4 briefing; the server script wins where they disagree, and no spell of his was read from the database here): Gahz'rilla's Slam throws players high into the air, so the tank should hold him with its back to a wall and the party stacked on the tank, not spread out; he casts Icicle and Freeze Solid at anyone out of his melee range, so ranged members belong close to him rather than behind; the room is cleared of packs first (this leg's pull groups). The plan cannot express a tank stand or a gather-on-the-tank order for a boss fight (a leg has `to`, pull groups, steps and the boss position, and the bots stack and spread as the module's own boss tactics do), so none is given: the boss position is where event 2489 leaves him (1702.58 1224.19 8.88), and a wall-backed tank spot has to be found live (the pool room's edge near 1700 1224, which this plan has not measured). `boss.tactics` is left empty for the same reason: his spells (11836 Freeze Solid, 11902, 11131 in his template) are not named in the spell tables the tactics check reads. First live run: record where the tank stood, how often Slam threw someone, and who was frozen out of melee range." ] }, @@ -1164,7 +1174,8 @@ "max_s": 10 }, "notes": [ - "NEEDS A LIVE RUN. Leg 2 of 3 of the stairs event: the party holds 1887 1274 42, the spot the climbing trolls are sent to (instance_zulfarrak.cpp SendAddsUpStairs: MovePoint 1880+0..10, 1274, 42), with Bly's crew 5-10 yd below it at 1883-1891 1263-1273. Wave 1 (23 trolls: Slaves 7787, Drudges 7788, Cretins 7789, Acolytes 8876, Zealots 8877) spawns at the foot of the stairs (1874-1899, 1202-1226, z 8.87) and groups of 2, 3, 4, ... run up every 10 s (the last of the 23 after about 50 s); 10 s after the last one dies wave 2 (24 trolls, the same entries) spawns and comes the same way. A wait does not fight: the bots defend themselves at the top and the tank takes the trolls where they end (no formation can be asked of them). Wave 3 spawns the instant wave 2 is dead and is the only one with Nekrum Gutchewer (7796, no spawn row): the wait ends when he is in view. The agent's own character sees creatures out to the server's visibility distance, so he is seen from the top; a driver that cannot see units fails the step at once. Slaves (7787), Cretins (7789) and Acolytes (8876) flee at 15 % health (EventAI) and every unit of a wave must die for the next to start: a runner that has gone off holds the event until it comes back. The leg's rest gate is short on purpose (10 s): the first group of wave 1 is on its way as the party arrives. Wowhead (the Classic guide and the NPC comments, read 2026-10-15; data only) agrees with the script on where to stand: its comments say to STAY ON TOP OF THE TEMPLE while the waves climb, and that after a number of waves the crew goes down the steps while more come. Wowhead says nothing of a formation either." + "NEEDS A LIVE RUN. Leg 2 of 3 of the stairs event: the party holds 1887 1274 42, the spot the climbing trolls are sent to (instance_zulfarrak.cpp SendAddsUpStairs: MovePoint 1880+0..10, 1274, 42), with Bly's crew 5-10 yd below it at 1883-1891 1263-1273. Wave 1 (23 trolls: Slaves 7787, Drudges 7788, Cretins 7789, Acolytes 8876, Zealots 8877) spawns at the foot of the stairs (1874-1899, 1202-1226, z 8.87) and groups of 2, 3, 4, ... run up every 10 s (the last of the 23 after about 50 s); 10 s after the last one dies wave 2 (24 trolls, the same entries) spawns and comes the same way. A wait does not fight: the bots defend themselves at the top and the tank takes the trolls where they end (no formation can be asked of them). Wave 3 spawns the instant wave 2 is dead and is the only one with Nekrum Gutchewer (7796, no spawn row): the wait ends when he is in view. The agent's own character sees creatures out to the server's visibility distance, so he is seen from the top; a driver that cannot see units fails the step at once. Slaves (7787), Cretins (7789) and Acolytes (8876) flee at 15 % health (EventAI) and every unit of a wave must die for the next to start: a runner that has gone off holds the event until it comes back. The leg's rest gate is short on purpose (10 s): the first group of wave 1 is on its way as the party arrives. Wowhead (the Classic guide and the NPC comments, read 2026-10-15; data only) agrees with the script on where to stand: its comments say to STAY ON TOP OF THE TEMPLE while the waves climb, and that after a number of waves the crew goes down the steps while more come. Wowhead says nothing of a formation either.", + "THE PHASE CHECK (missing vocabulary, 2026-10-20). The pyramid phase is instance data 1, not 0: Tortoise's `EVENT_PYRAMID = 1` (zulfarrak.h) and `GetData(1)` returns `PyramidPhase`, while `GetData(0)` returns 0 (instance_zulfarrak.cpp). The phase values are NOT_STARTED 0, CAGES_OPEN 1, ARRIVED_AT_STAIR 2, WAVE_1 3, PRE_WAVE_2 4, WAVE_2 5, PRE_WAVE_3 6 (wave 3 is spawned in this case), WAVE_3 7 (5 s later, the crew moves to the foot) and KILLED_ALL_TROLLS 8, so 7 is the value this leg ends on. The plan cannot read it: a `wait_for` holds on units, objects and hostiles only; the `hold` field of a pull group or a boss carries an `instance_data` index but no value, and plan-export writes its done condition as the encounter state DONE (3) with flags 8|32 (an arbitrary value with flag 8 only is accepted by the current module loader, but the JSON format has no field for it and the agent's own driver does not read holds). The module's planned hold-until-instance-data flag 128 is not in the snapshot this plan is exported against. This leg therefore waits on the observable proxy (Nekrum 7796, summoned with wave 3 at phase 6, seen at the foot); that is correct while wave 3 is alive or about to spawn, but a resumed run that starts the leg after wave 3 is dead sees no Nekrum and fails the wait at 600 s. Needed for a true gate: a wait/hold step with `instance_data` N and value V (the issue's flag 128), used here as N=1, V=7." ] }, { diff --git a/internal/dungeon/zulfarrak_test.go b/internal/dungeon/zulfarrak_test.go index e0e0486e..9888b5aa 100644 --- a/internal/dungeon/zulfarrak_test.go +++ b/internal/dungeon/zulfarrak_test.go @@ -190,6 +190,79 @@ func TestZulfarrakStairsEventOrder(t *testing.T) { } } +// The pool-stairs fix of zf-full-19z16 (RUN-LESSONS, STUCK #2): the last two packs of the west road are pulled from approach points on the +// lower floor, west of the stair mouth. The stair base at 1629 1099 z 9.7 is an island on the 209 navmesh (a path to it ends 8.8 yd short), +// and followers sent there to gather went up to z 31-46.8 before the approach rows. The last point of each approach is within the run +// leader's reach of the pack (RunLeaderReachYd), and no point is on the upper level or near the island. +func TestZulfarrakPoolStairsApproach(t *testing.T) { + p := loadPlan(t, zulfarrakPlan) + v := zfLeg(t, p, "velratha") + if len(v.PullGroups) != 6 { + t.Fatalf("%d pull groups", len(v.PullGroups)) + } + stair := Point{1629.2, 1099, 9.7} + for _, tc := range []struct { + group int + first Point + last Point + packAt Point + }{ + {4, Point{1584, 1050, 9}, Point{1582, 1070, 9}, Point{1576.2, 1079.1, 9}}, + {5, Point{1588, 1080, 9.1}, Point{1593.6, 1089.9, 9.3}, Point{1601.7, 1104.4, 9}}, + } { + g := v.PullGroups[tc.group] + if len(g.Approach) != 2 || g.Approach[0] != tc.first || g.Approach[1] != tc.last { + t.Errorf("pull group %d approach %v, want %v %v", tc.group, g.Approach, tc.first, tc.last) + continue + } + if d := dist3(tc.last, tc.packAt); d > RunLeaderReachYd { + t.Errorf("pull group %d: the last approach point is %.1f yd from the pack (at most %d)", tc.group, d, RunLeaderReachYd) + } + for _, a := range g.Approach { + if a.Z > 15 { + t.Errorf("pull group %d: approach point %v is on the upper level", tc.group, a) + } + if d := dist3(a, stair); d < 20 { + t.Errorf("pull group %d: approach point %v is %.1f yd from the stair island", tc.group, a, d) + } + } + } +} + +// The gong step rings the real Gong of Zul'Farrak (141832, the type-10 goober with lock 459 and event 2488), never the Turtle decoration copy +// 20901 at the same place, with the Mallet used ON it (via_item) from within use range; the optional gate is the Mallet, and the leg waits for +// Gahz'rilla before its boss fight. +func TestZulfarrakGongStep(t *testing.T) { + p := loadPlan(t, zulfarrakPlan) + g := zfLeg(t, p, "gahzrilla") + if g.Optional == nil || g.Optional.Item != 9240 || g.Optional.Name != "Mallet of Zul'Farrak" { + t.Fatalf("optional %+v", g.Optional) + } + if len(g.Steps) != 2 { + t.Fatalf("%d steps", len(g.Steps)) + } + u := g.Steps[0].UseObject + near := Point{1650.9, 1171.9, 10.9} + if u == nil || u.Entry != 141832 || !u.ViaItem || u.RequiresItem == nil || u.RequiresItem.Entry != 9240 || u.Near == nil || *u.Near != near { + t.Fatalf("gong step %+v", u) + } + if u.Stand == nil || dist3(*u.Stand, near) > UseRange { + t.Errorf("the stand %v is not within %.0f yd of the gong: the item's spell has a short range", u.Stand, UseRange) + } + w := g.Steps[1].WaitFor + if w == nil || w.Unit == nil || fmt.Sprint(w.Unit.Entries) != "[7273]" || w.Unit.State != UnitSeen || w.TimeoutS != 30 { + t.Errorf("the wait for Gahz'rilla %+v", w) + } + for _, s := range g.Steps { + if r := objRef(s); r != nil && r.Entry == 20901 { + t.Errorf("a step targets the decoration copy 20901, not the real gong 141832") + } + } + if g.Boss == nil || g.Boss.Entry != 7273 { + t.Errorf("boss %+v", g.Boss) + } +} + // Every pack member and boss the plans name is a spawn of the export at that place, and the Troll Cage and the Gong are objects of it. // What the instance script summons (Gahz'rilla, Nekrum, Sezz'ziz, the waves) has no row and is not looked for. func TestZulfarrakPlansMatchTheWorldExport(t *testing.T) { diff --git a/internal/planexport/planexport_test.go b/internal/planexport/planexport_test.go index 172b47b8..158b5ee8 100644 --- a/internal/planexport/planexport_test.go +++ b/internal/planexport/planexport_test.go @@ -479,6 +479,57 @@ func TestZulfarrakWithZumrahExports(t *testing.T) { } } +// The pool-stairs approach rows reach the run leader: in the exported velratha leg the two packs each have their two approach moves right +// before the pull row (the STUCK #2 fix). The live 86-step plan's step 83 was this pull group; with the plan's approach points the exported +// leg grows by four move rows and the pull is the fourth row later. +func TestZulfarrakPoolStairsApproachRows(t *testing.T) { + p := zulfarrakThroughZumrah(t) + res, err := Build(p, Options{Source: "test", DropCompanionsLoot: true}) + if err != nil { + t.Fatal(err) + } + leg := -1 + for i, l := range res.Rows.Legs { + if l.Name == "velratha" { + leg = i + } + } + if leg < 0 { + t.Fatal("no velratha leg in the rows") + } + idx := func(entries string) int { + for i, s := range res.Rows.Steps { + if s.Leg == leg && s.Kind == KindPull && rowsEntries(s.Entries) == entries { + return i + } + } + return -1 + } + for _, tc := range []struct{ entries, first, last string }{ + {"5650,7246,8095,5649", "1584.0 1050.0 9.0", "1582.0 1070.0 9.0"}, + {"7246,5650", "1588.0 1080.0 9.1", "1593.6 1089.9 9.3"}, + } { + i := idx(tc.entries) + if i < 2 { + t.Fatalf("pull %s at %d", tc.entries, i) + } + a, b := res.Rows.Steps[i-2], res.Rows.Steps[i-1] + if a.Kind != KindMove || b.Kind != KindMove || !strings.HasSuffix(a.Comment, " approach") || + fmt.Sprintf("%.1f %.1f %.1f", a.X, a.Y, a.Z) != tc.first || fmt.Sprintf("%.1f %.1f %.1f", b.X, b.Y, b.Z) != tc.last || a.Seq+2 != res.Rows.Steps[i].Seq { + t.Fatalf("pull %s: %+v %+v before %+v", tc.entries, a, b, res.Rows.Steps[i]) + } + } +} + +// rowsEntries is a step row's entries as the plan's comma list. +func rowsEntries(e []uint32) string { + s := make([]string, len(e)) + for i, v := range e { + s[i] = fmt.Sprint(v) + } + return strings.Join(s, ",") +} + func buildDropLoot(t *testing.T, p *dungeon.Plan) Rows { t.Helper() res, err := Build(p, Options{Source: "test", DropCompanionsLoot: true}) diff --git a/tools/dungeongen/plans.json b/tools/dungeongen/plans.json index ffe9fea5..c1aa2a1c 100644 --- a/tools/dungeongen/plans.json +++ b/tools/dungeongen/plans.json @@ -311,9 +311,9 @@ "handwritten": true, "special": [ "Zum'rah: area trigger 962 makes him hostile", - "stairs event with Sergeant Bly's crew, Nekrum and Sezz'ziz", + "stairs event with Sergeant Bly's crew, Nekrum and Sezz'ziz (its phase is instance data 1 = 7; the plan waits on Nekrum seen, no phase step yet)", "Weegli's gossip (or Bly's) has him blow the End Door for Ukorz and Ruuzlu", - "Gahz'rilla: the optional gahzrilla leg (Gong with the Mallet of Zul'Farrak, 9240; skipped without it)" + "Gahz'rilla: the optional gahzrilla leg (Gong with the Mallet of Zul'Farrak, 9240; it drops from Qiaga the Keeper 7996 outside the instance and 9241 must be used to make 9240, so no bot run can obtain it; skipped without give_items)" ] }, { From f11df6dc0d5aefcb8487e4368d22a9f949f910fc Mon Sep 17 00:00:00 2001 From: pjerra <71784785+pjerra@users.noreply.github.com> Date: Wed, 21 Oct 2026 02:58:59 +0200 Subject: [PATCH 2/4] plan-export: a wait's hold and a hold's script value carry the ZF stairs event to the run leader The exported Zul'Farrak rows ended before the stairs leg: its Weegli wait and the stairs_top Nekrum wait are unit waits, which the TortoiseBots run leader has no step for. The run leader can do the event itself: its loader takes a hold done by instance data with flag 8 alone and any param, and ends it when the data reads that value (DungeonPlanPolicy.h NormalizePlanStep, DungeonRunPolicy.h HoldDoneNote). The pyramid phase is instance data 1 (tortoise-wow zulfarrak.h). Plan format: - hold.value: the instance data value that ends the event, read as a script value (flags 8, param value); without it the encounter DONE as before (40, 3). - wait_for.hold: the wait's run leader form. plan-export writes the hold row in place of the wait; the agent's driver still runs the wait as written. zulfarrak.json: the cage leg's Weegli wait holds the stairs top to phase 3 (WAVE_1; phase 2 lasts one instance update), stairs_top's Nekrum wait holds it to phase 7 (WAVE_3: wave 2 dead, wave 3 up). Wave entries 7787 7788 7789 8876 8877. Notes say what a module run needs (key 8444 to the lead bot as a fallback, no Mallet: the gahzrilla leg stays optional and out of the rows). Export: 14 legs, 122 steps through stairs_base (Nekrum, Sezz'ziz, the foot clear); cut before weegli_door (a unit wait with no hold, then a gossip). Validate takes a hold param above 4 only without the encounter flag. The run leader reach check counts a wait hold's spot as a stand. A wait with a hold is listed among the attributes the rows do not carry (its own condition). Tests: TestWaitHoldIsTheRowAndValueIsAScriptValue, TestValidateHoldParamByFlags, TestZulfarrakStairsEventRows, TestZulfarrakIsCutAfterStairsBase (was ...AfterZumrah), TestWaitForHoldAndHoldValueParse, TestRunLeaderReachCountsAWaitHoldSpot; seven mutations (each change reverted on its own) each fail at least one of them. --- dungeons/zulfarrak.json | 25 ++++- internal/dungeon/plan.go | 20 +++- internal/dungeon/plan_waithold_test.go | 55 +++++++++++ internal/dungeon/runleader.go | 3 + internal/planexport/hold_value_test.go | 128 +++++++++++++++++++++++++ internal/planexport/planexport.go | 23 ++++- internal/planexport/planexport_test.go | 38 ++++++-- internal/planexport/unexpressed.go | 3 + 8 files changed, 275 insertions(+), 20 deletions(-) create mode 100644 internal/dungeon/plan_waithold_test.go create mode 100644 internal/planexport/hold_value_test.go diff --git a/dungeons/zulfarrak.json b/dungeons/zulfarrak.json index f62b5abe..f320e3e1 100644 --- a/dungeons/zulfarrak.json +++ b/dungeons/zulfarrak.json @@ -1141,12 +1141,21 @@ "state": "seen" }, "timeout_s": 60, - "fail_text": "The cage was used but Weegli Blastfuse did not walk to the top of the stairs within 60 s: the stairs event (instance_zulfarrak.cpp) starts when he arrives there, so it has not started (the cage did not open, or Weegli is dead)." + "fail_text": "The cage was used but Weegli Blastfuse did not walk to the top of the stairs within 60 s: the stairs event (instance_zulfarrak.cpp) starts when he arrives there, so it has not started (the cage did not open, or Weegli is dead).", + "hold": { + "instance_data": 1, + "value": 3, + "spot": {"x": 1887, "y": 1274, "z": 42}, + "radius": 30, + "timeout_s": 90, + "entries": [7787, 7788, 7789, 8876, 8877] + } } } ], "notes": [ - "NEEDS A LIVE RUN. Leg 1 of 3 of the stairs event (instance_zulfarrak.cpp, zulfarrak.cpp go_troll_cage): the Sandfury Executioner (7274, spawn row 81552 at 1886.79 1286.76 44.19) is the one pack at the cages; then any of the five Troll Cages (141070-141074; the plan uses 141073 at 1886.88 1298.94 48.2) is used: its script frees Sergeant Bly (7604), Raven (7605), Oro Eyegouge (7606), Weegli Blastfuse (7607) and Murta Grimgut (7608) (faction 250, hostile to trolls, friendly to the party) and sends them to the top of the stairs (1883-1891 1263-1273 z 41-42). When Weegli reaches 1883.9 1263.5 the event is PYRAMID_ARRIVED_AT_STAIR and the next instance update spawns wave 1: the wait ends when he is in view near there. The stand is 3.6 yd from the cage on the platform at z 48.3 (a use is refused beyond about 5 yd). The leg also has the bots' looting off from its start to its end (companions_loot false, the zumrah leg's rule): the shortest walk from Zum'rah's spot (1912.2 1016.1) to the courtyard crosses the graveyard (TestZulfarrakOnNavmesh: 1904 1018, 1899 1019, 1859 1058), where the three graves left to him (1887.9 1029.1, 1892.6 1006.4, 1891.6 1023.1) may still be shut, and a bot that loots opens a Shallow Grave chest and wakes what lies in it. The Executioner is the leg's boss (zf-r5, round 5: the cage open was refused \"no-item\" because nothing looted him): the carrier takes his Executioner's Key (8444, 100% drop, creature_loot_template) with cmd loot after the kill, and the cage (lock 420, which needs that key) is used in after_boss. The leg's mute stays on for the walk; the loader accepts it together with the boss's loot_item." + "NEEDS A LIVE RUN. Leg 1 of 3 of the stairs event (instance_zulfarrak.cpp, zulfarrak.cpp go_troll_cage): the Sandfury Executioner (7274, spawn row 81552 at 1886.79 1286.76 44.19) is the one pack at the cages; then any of the five Troll Cages (141070-141074; the plan uses 141073 at 1886.88 1298.94 48.2) is used: its script frees Sergeant Bly (7604), Raven (7605), Oro Eyegouge (7606), Weegli Blastfuse (7607) and Murta Grimgut (7608) (faction 250, hostile to trolls, friendly to the party) and sends them to the top of the stairs (1883-1891 1263-1273 z 41-42). When Weegli reaches 1883.9 1263.5 the event is PYRAMID_ARRIVED_AT_STAIR and the next instance update spawns wave 1: the wait ends when he is in view near there. The stand is 3.6 yd from the cage on the platform at z 48.3 (a use is refused beyond about 5 yd). The leg also has the bots' looting off from its start to its end (companions_loot false, the zumrah leg's rule): the shortest walk from Zum'rah's spot (1912.2 1016.1) to the courtyard crosses the graveyard (TestZulfarrakOnNavmesh: 1904 1018, 1899 1019, 1859 1058), where the three graves left to him (1887.9 1029.1, 1892.6 1006.4, 1891.6 1023.1) may still be shut, and a bot that loots opens a Shallow Grave chest and wakes what lies in it. The Executioner is the leg's boss (zf-r5, round 5: the cage open was refused \"no-item\" because nothing looted him): the carrier takes his Executioner's Key (8444, 100% drop, creature_loot_template) with cmd loot after the kill, and the cage (lock 420, which needs that key) is used in after_boss. The leg's mute stays on for the walk; the loader accepts it together with the boss's loot_item.", + "MODULE RUN (TortoiseBots run leader, the rows plan-export writes; 2026-10-21). The rows are: the walk up, the Executioner's boss row, his loot row (item 8444), a move to the stand, the cage's open (item 8444; any party bot that carries the key is its carrier), then the Weegli wait's hold (instance data 1 to value 3) on the stairs top. The open is the event's start step for the run leader; the holds read a script value (flag 8 without 32), so the run leader reads no FAIL or reset into them and a wipe in them regroups and holds the spot again (RearmEventOnWipe goes back to a start step only for an encounter hold): the stairs event is not restartable anyway. Companions loot freely in the module run (--drop-companions-loot): the run leader's loot step counts the key done when ANY party bot carries it. Setup for a module run: give the Executioner's Key 8444 to the lead bot before `.bot run start` (GM additem, the owner's test-run shortcut of 2026-10-21) so a key looted by someone else, or a corpse looted empty, cannot stop the cage; the loot row then reads 'already carries' and goes on. No Mallet is needed: the optional gahzrilla leg is not in the rows." ] }, { @@ -1163,7 +1172,15 @@ "state": "seen" }, "timeout_s": 600, - "fail_text": "Waves 1 and 2 of the stairs event were not over in 600 s (Nekrum Gutchewer, the first unit of wave 3, was not seen at the foot of the stairs): the party may have lost the top of the stairs, or a wave is still running. The event is not restartable." + "fail_text": "Waves 1 and 2 of the stairs event were not over in 600 s (Nekrum Gutchewer, the first unit of wave 3, was not seen at the foot of the stairs): the party may have lost the top of the stairs, or a wave is still running. The event is not restartable.", + "hold": { + "instance_data": 1, + "value": 7, + "spot": {"x": 1887, "y": 1274, "z": 42}, + "radius": 30, + "timeout_s": 600, + "entries": [7787, 7788, 7789, 8876, 8877] + } } } ], @@ -1175,7 +1192,7 @@ }, "notes": [ "NEEDS A LIVE RUN. Leg 2 of 3 of the stairs event: the party holds 1887 1274 42, the spot the climbing trolls are sent to (instance_zulfarrak.cpp SendAddsUpStairs: MovePoint 1880+0..10, 1274, 42), with Bly's crew 5-10 yd below it at 1883-1891 1263-1273. Wave 1 (23 trolls: Slaves 7787, Drudges 7788, Cretins 7789, Acolytes 8876, Zealots 8877) spawns at the foot of the stairs (1874-1899, 1202-1226, z 8.87) and groups of 2, 3, 4, ... run up every 10 s (the last of the 23 after about 50 s); 10 s after the last one dies wave 2 (24 trolls, the same entries) spawns and comes the same way. A wait does not fight: the bots defend themselves at the top and the tank takes the trolls where they end (no formation can be asked of them). Wave 3 spawns the instant wave 2 is dead and is the only one with Nekrum Gutchewer (7796, no spawn row): the wait ends when he is in view. The agent's own character sees creatures out to the server's visibility distance, so he is seen from the top; a driver that cannot see units fails the step at once. Slaves (7787), Cretins (7789) and Acolytes (8876) flee at 15 % health (EventAI) and every unit of a wave must die for the next to start: a runner that has gone off holds the event until it comes back. The leg's rest gate is short on purpose (10 s): the first group of wave 1 is on its way as the party arrives. Wowhead (the Classic guide and the NPC comments, read 2026-10-15; data only) agrees with the script on where to stand: its comments say to STAY ON TOP OF THE TEMPLE while the waves climb, and that after a number of waves the crew goes down the steps while more come. Wowhead says nothing of a formation either.", - "THE PHASE CHECK (missing vocabulary, 2026-10-20). The pyramid phase is instance data 1, not 0: Tortoise's `EVENT_PYRAMID = 1` (zulfarrak.h) and `GetData(1)` returns `PyramidPhase`, while `GetData(0)` returns 0 (instance_zulfarrak.cpp). The phase values are NOT_STARTED 0, CAGES_OPEN 1, ARRIVED_AT_STAIR 2, WAVE_1 3, PRE_WAVE_2 4, WAVE_2 5, PRE_WAVE_3 6 (wave 3 is spawned in this case), WAVE_3 7 (5 s later, the crew moves to the foot) and KILLED_ALL_TROLLS 8, so 7 is the value this leg ends on. The plan cannot read it: a `wait_for` holds on units, objects and hostiles only; the `hold` field of a pull group or a boss carries an `instance_data` index but no value, and plan-export writes its done condition as the encounter state DONE (3) with flags 8|32 (an arbitrary value with flag 8 only is accepted by the current module loader, but the JSON format has no field for it and the agent's own driver does not read holds). The module's planned hold-until-instance-data flag 128 is not in the snapshot this plan is exported against. This leg therefore waits on the observable proxy (Nekrum 7796, summoned with wave 3 at phase 6, seen at the foot); that is correct while wave 3 is alive or about to spawn, but a resumed run that starts the leg after wave 3 is dead sees no Nekrum and fails the wait at 600 s. Needed for a true gate: a wait/hold step with `instance_data` N and value V (the issue's flag 128), used here as N=1, V=7." + "THE PHASE CHECK (missing vocabulary, 2026-10-20). The pyramid phase is instance data 1, not 0: Tortoise's `EVENT_PYRAMID = 1` (zulfarrak.h) and `GetData(1)` returns `PyramidPhase`, while `GetData(0)` returns 0 (instance_zulfarrak.cpp). The phase values are NOT_STARTED 0, CAGES_OPEN 1, ARRIVED_AT_STAIR 2, WAVE_1 3, PRE_WAVE_2 4, WAVE_2 5, PRE_WAVE_3 6 (wave 3 is spawned in this case), WAVE_3 7 (5 s later, the crew moves to the foot) and KILLED_ALL_TROLLS 8, so 7 is the value this leg ends on. The agent's driver cannot read it: its `wait_for` holds on units, objects and hostiles only, so for the agent this leg waits on the observable proxy (Nekrum 7796, summoned with wave 3 at phase 6, seen at the foot); that is correct while wave 3 is alive or about to spawn, but a resumed run that starts the leg after wave 3 is dead sees no Nekrum and fails the wait at 600 s. The TortoiseBots run leader CAN read it (2026-10-21, feat/zf-full-module-plan): its loader takes a `hold` done by instance data with flag 8 alone (no encounter flag 32) and any param, and ends the hold when the data reads exactly that value (DungeonRunPolicy.h HoldDoneNote). The wait therefore carries a `hold` with `value` 7 (instance data 1, the stairs top 1887 1274 42, radius 30, 600 s, the wave entries 7787 7788 7789 8876 8877), which plan-export writes in place of the wait: the run leader's party holds the top and fights the waves until phase 7 (wave 2 dead, wave 3 spawned, the crew moving down). 7 lasts until every unit of wave 3 is dead, so the 1 s tick cannot miss it; a run resumed after wave 3 is dead reads 8 and the hold runs out at 600 s (the run leader has no 'at least' compare: that is the module's planned flag 128). The cage leg's Weegli wait carries a hold to value 3 (WAVE_1: Weegli arrived, wave 1 spawned; phase 2 lasts one instance update and is not a value to wait for)." ] }, { diff --git a/internal/dungeon/plan.go b/internal/dungeon/plan.go index 9ba0a4fa..d22640e5 100644 --- a/internal/dungeon/plan.go +++ b/internal/dungeon/plan.go @@ -309,8 +309,18 @@ type PullGroup struct { // first and Entries) as the event's mobs. A move row straight before it to the same spot is left out (the hold walks there itself), and // a game object wait straight after it waits at most HoldDoorWaitS (the event's DONE is what opens the door). The agent's own driver // does not read it: it fights the group (or the boss) and waits for the steps as written. +// +// A wait_for may carry one too (WaitFor.Hold): it is that wait's run leader form, for an event whose script keeps its progress in an +// instance data value the agent's driver does not read (Zul'Farrak's pyramid phase, instance data 1). plan-export then writes the hold +// row in place of the wait (which the run leader could not run), and the agent's driver runs the wait as written. type Hold struct { - Data uint32 `json:"instance_data"` + Data uint32 `json:"instance_data"` + // Value, when set, is the value of instance data Data that ends the event, read as the script's own number, not as an encounter state: + // plan-export writes param Value with flags 8 alone (done by instance data), without the encounter flag 32, so the run leader neither + // reads a FAIL nor a reset into it (TortoiseBots DungeonPlanPolicy.h: a hold with flag 8 and not 32 takes any param). The run leader + // ends the hold when the data reads exactly Value, so it must be a value the script keeps for a while (a wave that is being fought), + // not one it passes through in a single update. Nil: the encounter state DONE (3), flags 40, as before. + Value *uint32 `json:"value,omitempty"` Spot Point `json:"spot"` Radius float32 `json:"radius"` TimeoutS int `json:"timeout_s"` @@ -610,6 +620,9 @@ type WaitFor struct { // FailText is added to the failure when the wait times out: the reason a person should read (Uldaman's altar waits say that the // ritual needs three participants). FailText string `json:"fail_text,omitempty"` + // Hold is this wait's form for the TortoiseBots run leader (see Hold): plan-export writes it as a hold row instead of the wait. The + // agent's own driver does not read it. + Hold *Hold `json:"hold,omitempty"` } // ObjectState waits for a game object to be in a state: ready (a door closed), active (open or used) or active_alt. @@ -766,6 +779,7 @@ type rawHold struct { Radius float32 `json:"radius"` TimeoutS int `json:"timeout_s"` Entries []uint32 `json:"entries"` + Value *uint32 `json:"value"` } type rawPatrol struct { @@ -894,6 +908,7 @@ type rawWait struct { Unit *rawUnitWait `json:"unit"` TimeoutS *int `json:"timeout_s"` FailText string `json:"fail_text"` + Hold *rawHold `json:"hold"` } type rawObjState struct { @@ -1357,7 +1372,7 @@ func (c *checker) hold(path string, r *rawHold) *Hold { if r == nil { return nil } - h := &Hold{Data: r.Data, Radius: r.Radius, TimeoutS: r.TimeoutS, Entries: r.Entries} + h := &Hold{Data: r.Data, Radius: r.Radius, TimeoutS: r.TimeoutS, Entries: r.Entries, Value: r.Value} h.Spot = c.point(path+".spot", r.Spot) if r.Data == 0 { c.fail(path+".instance_data", "required (the index of the event's instance data, 1 or more)") @@ -1654,6 +1669,7 @@ func (c *checker) wait(path string, r *rawWait) *WaitFor { } w.HostilesGone = h } + w.Hold = c.hold(path+".hold", r.Hold) return w } diff --git a/internal/dungeon/plan_waithold_test.go b/internal/dungeon/plan_waithold_test.go new file mode 100644 index 00000000..13dde4bf --- /dev/null +++ b/internal/dungeon/plan_waithold_test.go @@ -0,0 +1,55 @@ +package dungeon + +import ( + "bytes" + "strings" + "testing" +) + +// A wait_for may carry a hold (its run leader form) and a hold may carry a value (a script value of its instance data): both parse, keep +// their form through Marshal, and a bad hold on a wait is refused like one on a pull group. +func TestWaitForHoldAndHoldValueParse(t *testing.T) { + p := mustPlan(t, eventPlan(`{"wait_for":{"unit":{"entries":[7796],"state":"seen"},"timeout_s":600,"hold":{"instance_data":1,"value":7,"spot":{"x":5,"y":6,"z":7},"radius":30,"timeout_s":600,"entries":[7787,7788]}}},{"wait_for":{"unit":{"entries":[7],"state":"seen"}}}`, "")) + h := p.Legs[0].Steps[0].WaitFor.Hold + if h == nil || h.Data != 1 || h.Value == nil || *h.Value != 7 || h.Spot != (Point{5, 6, 7}) || h.Radius != 30 || h.TimeoutS != 600 || len(h.Entries) != 2 { + t.Fatalf("hold %+v", h) + } + if p.Legs[0].Steps[0].WaitFor.Unit == nil { + t.Fatal("the wait's own unit condition must stay (the agent's driver runs it)") + } + if p.Legs[0].Steps[1].WaitFor.Hold != nil { + t.Fatal("a wait without a hold has none") + } + out, err := Marshal(p) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(out), `"instance_data": 1,`+"\n"+` "value": 7,`) { + t.Fatalf("the value is not next to its instance_data:\n%s", out) + } + q, err := Parse("again.json", out) + if err != nil { + t.Fatalf("%v\n%s", err, out) + } + if again, _ := Marshal(q); !bytes.Equal(out, again) { + t.Fatalf("%s", out) + } + if _, err := Parse("bad.json", []byte(eventPlan(`{"wait_for":{"unit":{"entries":[7796],"state":"seen"},"hold":{"instance_data":0,"spot":{"x":5,"y":6,"z":7},"radius":30,"timeout_s":600}}}`, ""))); err == nil || !strings.Contains(err.Error(), "wait_for.hold.instance_data") { + t.Fatalf("a hold without its instance data must be refused: %v", err) + } +} + +// The run leader's party stands on a wait hold's spot (plan-export writes the wait as its hold row), so the reach of a boss after it is +// measured from there, not from the leg's to. +func TestRunLeaderReachCountsAWaitHoldSpot(t *testing.T) { + plan := func(hold string) *Plan { + return mustPlan(t, `{"version":1,"name":"t","map":1,"entrance":{"x":0,"y":0,"z":0},"legs":[{"to":{"x":0,"y":0,"z":0}, + "steps":[{"wait_for":{"unit":{"entries":[7],"state":"seen"}`+hold+`}}],"boss":{"entry":9,"position":{"x":100,"y":0,"z":0}}}]}`) + } + if n := len(plan("").RunLeaderReach(0)); n != 1 { + t.Fatalf("without a hold the boss is 100 yd from the to: %d warnings", n) + } + if w := plan(`,"hold":{"instance_data":1,"value":7,"spot":{"x":80,"y":0,"z":0},"radius":30,"timeout_s":600}`).RunLeaderReach(0); len(w) != 0 { + t.Fatalf("the hold spot is 20 yd from the boss: %v", w) + } +} diff --git a/internal/dungeon/runleader.go b/internal/dungeon/runleader.go index 418ed571..200cccfa 100644 --- a/internal/dungeon/runleader.go +++ b/internal/dungeon/runleader.go @@ -45,6 +45,9 @@ func (p *Plan) runLeaderWalk(from int, move func(RunLeaderPoint), fight func(leg move(RunLeaderPoint{li, fmt.Sprintf("%s[%d].use_object.stand", kind, si), *st.UseObject.Stand}) case st.AreaTrigger != nil && st.AreaTrigger.By != TriggerAgent: move(RunLeaderPoint{li, fmt.Sprintf("%s[%d].area_trigger.stand", kind, si), st.AreaTrigger.Stand}) + case st.WaitFor != nil && st.WaitFor.Hold != nil: + // plan-export writes such a wait as its hold row: the run leader's party stands on the hold's spot. + move(RunLeaderPoint{li, fmt.Sprintf("%s[%d].wait_for.hold spot", kind, si), st.WaitFor.Hold.Spot}) } } } diff --git a/internal/planexport/hold_value_test.go b/internal/planexport/hold_value_test.go new file mode 100644 index 00000000..b2a7ab31 --- /dev/null +++ b/internal/planexport/hold_value_test.go @@ -0,0 +1,128 @@ +package planexport + +import ( + "fmt" + "strings" + "testing" +) + +// A wait_for that carries a hold is exported as that hold row (the wait itself, a unit wait, has no run leader step), and a hold with a +// value is done by that instance data value read as a script value: flag 8 alone (no encounter flag 32), param = the value. A hold without +// a value keeps the encounter DONE (param 3, flags 40). Zul'Farrak's pyramid phase (instance data 1) is the case. +func TestWaitHoldIsTheRowAndValueIsAScriptValue(t *testing.T) { + const js = `{"version":1,"name":"zf","status":"live-checked (test)","map":209,"entrance":{"x":0,"y":0,"z":0},"legs":[ + {"name":"cage","to":{"x":10,"y":0,"z":0}, + "steps":[{"use_object":{"entry":141073,"near":{"x":12,"y":0,"z":0},"requires_item":{"entry":8444,"name":"Key"}}}, + {"wait_for":{"unit":{"entries":[7607],"near":{"x":20,"y":0,"z":0},"radius":15,"state":"seen"},"timeout_s":60, + "hold":{"instance_data":1,"value":3,"spot":{"x":20,"y":0,"z":0},"radius":30,"timeout_s":90,"entries":[7787,7788,7787]}}}]}, + {"name":"top","to":{"x":20,"y":0,"z":0}, + "steps":[{"wait_for":{"unit":{"entries":[7796],"state":"seen"},"timeout_s":600, + "hold":{"instance_data":1,"value":7,"spot":{"x":20,"y":0,"z":0},"radius":30,"timeout_s":600}}}]}, + {"name":"group","to":{"x":40,"y":0,"z":0}, + "pull_groups":[{"entries":[4857],"positions":[{"x":45,"y":0,"z":0}],"hold":{"instance_data":4,"value":5,"spot":{"x":45,"y":0,"z":0},"radius":20,"timeout_s":300}}, + {"entries":[4858],"positions":[{"x":50,"y":0,"z":0}],"hold":{"instance_data":2,"spot":{"x":50,"y":0,"z":0},"radius":20,"timeout_s":300}}]}]}` + res, err := Build(parsePlan(t, js), Options{Source: "test"}) + if err != nil { + t.Fatal(err) + } + if res.Cut != "" { + t.Fatalf("a unit wait with a hold must not cut the plan: %s", res.Cut) + } + r := res.Rows + if got := strings.Join(kinds(r, 0), " "); got != "move open hold" { + t.Fatalf("leg 0: %s", got) + } + // The move to the hold's own spot (leg 1's to) is left out: the hold walks there. + if got := strings.Join(kinds(r, 1), " "); got != "hold" { + t.Fatalf("leg 1: %s", got) + } + row := func(leg, seq int) string { + for _, s := range r.Steps { + if s.Leg == leg && s.Seq == seq { + return fmt.Sprintf("%s %d %g %g %g r%g p%d f%d t%d %v", s.Kind, s.Entry, s.X, s.Y, s.Z, s.Radius, s.Param, s.Flags, s.TimeoutS, s.Entries) + } + } + return "none" + } + for _, tc := range []struct { + leg, seq int + want string + }{ + {0, 2, "hold 1 20 0 0 r30 p3 f8 t90 [7787 7788]"}, + {1, 0, "hold 1 20 0 0 r30 p7 f8 t600 []"}, + {2, 1, "hold 4 45 0 0 r20 p5 f8 t300 [4857]"}, + {2, 2, "hold 2 50 0 0 r20 p3 f40 t300 [4858]"}, + } { + if got := row(tc.leg, tc.seq); got != tc.want { + t.Errorf("leg %d step %d: %s, want %s", tc.leg, tc.seq, got, tc.want) + } + } + for _, s := range r.Steps { + if s.Kind == KindHold && s.Flags == FlagDoneData && !strings.Contains(s.Comment, "(a script value, not an encounter state)") { + t.Errorf("a value hold's comment does not say so: %q", s.Comment) + } + } + if w := strings.Join(res.Warnings, "\n"); !strings.Contains(w, "wait_for with a hold (leg 0 (cage) steps[1]; leg 1 (top) steps[0]): not in the rows") { + t.Errorf("no warning that the waits' own conditions are the agent's only: %s", w) + } + back, err := Parse(WriteSQL([]Rows{r})) + if err != nil { + t.Fatal(err) + } + if err := Equal([]Rows{r}, back); err != nil { + t.Fatal(err) + } +} + +// Validate takes a hold's param above 4 only as a script value (flag 8 without 32), the module loader's rule (NormalizePlanStep): an +// encounter state is 0-4. +func TestValidateHoldParamByFlags(t *testing.T) { + hold := func(param, flags uint32) Rows { + return Rows{Steps: []StepRow{{Plan: "p", Kind: KindHold, Entry: 1, Param: param, Flags: flags}}} + } + if err := Validate(hold(7, FlagDoneData)); err != nil { + t.Errorf("a script value 7: %v", err) + } + if err := Validate(hold(7, FlagDoneData|FlagEncounter)); err == nil || !strings.Contains(err.Error(), "no encounter state") { + t.Errorf("an encounter state 7 must be refused: %v", err) + } + if err := Validate(hold(3, FlagDoneData|FlagEncounter)); err != nil { + t.Errorf("DONE: %v", err) + } +} + +// The shipped Zul'Farrak plan's stairs event in the rows: the Executioner, his key, the cage with the key, then the two holds on the stairs +// top (pyramid phase 3, wave 1 under way; phase 7, wave 2 dead and wave 3 up), then Nekrum and Sezz'ziz at the foot and the wait until the +// foot is clear. Instance data 1 is the pyramid phase (tortoise-wow zulfarrak.h EVENT_PYRAMID, instance_zulfarrak.cpp GetData). +func TestZulfarrakStairsEventRows(t *testing.T) { + res, err := Build(zulfarrakThrough(t, "stairs_base"), Options{Source: "test", DropCompanionsLoot: true}) + if err != nil { + t.Fatal(err) + } + leg := map[string]int{} + for _, l := range res.Rows.Legs { + leg[l.Name] = l.Leg + } + var got []string + for _, s := range res.Rows.Steps { + if s.Leg < leg["stairs"] || s.Kind == KindMove && (s.Comment == "path" || s.Comment == "via") { + continue + } + got = append(got, fmt.Sprintf("%d %s %d %g %g %g i%d p%d f%d t%d %v", s.Leg-leg["stairs"], s.Kind, s.Entry, s.X, s.Y, s.Z, s.Item, s.Param, s.Flags, s.TimeoutS, s.Entries)) + } + want := []string{ + "0 move 0 1887 1274 42 i0 p0 f0 t0 []", + "0 boss 7274 1886.8 1286.8 44.2 i0 p0 f0 t0 []", + "0 loot 7274 1886.8 1286.8 44.2 i8444 p0 f0 t0 []", + "0 move 0 1886.9 1295.4 48.3 i0 p0 f0 t0 []", + "0 open 141073 1886.9 1298.9 48.2 i8444 p0 f0 t0 []", + "0 hold 1 1887 1274 42 i0 p3 f8 t90 [7787 7788 7789 8876 8877]", + "1 hold 1 1887 1274 42 i0 p7 f8 t600 [7787 7788 7789 8876 8877]", + "2 move 0 1885 1232 12.6 i0 p0 f0 t0 []", + "2 boss 7275 1889.23 1207.72 8.87 i0 p0 f0 t0 [7796]", + "2 wait_clear 0 1886 1215 9 i0 p0 f0 t180 []", + } + if strings.Join(got, "\n") != strings.Join(want, "\n") { + t.Fatalf("stairs rows:\n%s\nwant:\n%s", strings.Join(got, "\n"), strings.Join(want, "\n")) + } +} diff --git a/internal/planexport/planexport.go b/internal/planexport/planexport.go index 38640cb1..a40954f2 100644 --- a/internal/planexport/planexport.go +++ b/internal/planexport/planexport.go @@ -433,9 +433,17 @@ func (b *builder) hold(h *dungeon.Hold, entries []uint32, comment string) { b.seq-- } } - b.add(StepRow{Kind: KindHold, Entry: h.Data, X: h.Spot.X, Y: h.Spot.Y, Z: h.Spot.Z, Radius: h.Radius, Param: EncounterDone, - Flags: FlagDoneData | FlagEncounter, TimeoutS: h.TimeoutS, Entries: entries, - Comment: clip(fmt.Sprintf("%s: the event's mobs are fought on the spot until instance data %d reads %d (DONE)", comment, h.Data, EncounterDone), MaxComment)}) + param, flags := uint32(EncounterDone), uint32(FlagDoneData|FlagEncounter) + done := fmt.Sprintf("%d (DONE)", EncounterDone) + if h.Value != nil { + // A script value (Zul'Farrak's pyramid phase), not an encounter state: flag 8 alone, so the run leader reads no FAIL or reset + // into it and its loader takes a param above 4 (DungeonPlanPolicy.h NormalizePlanStep). + param, flags = *h.Value, FlagDoneData + done = fmt.Sprintf("%d (a script value, not an encounter state)", *h.Value) + } + b.add(StepRow{Kind: KindHold, Entry: h.Data, X: h.Spot.X, Y: h.Spot.Y, Z: h.Spot.Z, Radius: h.Radius, Param: param, + Flags: flags, TimeoutS: h.TimeoutS, Entries: entries, + Comment: clip(fmt.Sprintf("%s: the event's mobs are fought on the spot until instance data %d reads %s", comment, h.Data, done), MaxComment)}) b.last, b.haveLast = h.Spot, true } @@ -514,6 +522,12 @@ func (b *builder) step(st dungeon.Step) error { func (b *builder) wait(w *dungeon.WaitFor) error { timeout := w.TimeoutS + if w.Hold != nil { + // The wait's run leader form (dungeon.Hold on a wait_for): the event's hold row in place of the wait, which the run leader may not + // be able to run at all (a unit wait). The agent's driver still runs the wait as written. + b.hold(w.Hold, uniq(w.Hold.Entries), "wait hold") + return nil + } switch { case w.GameObject != nil: g := w.GameObject @@ -614,7 +628,8 @@ func Validate(r Rows) error { if s.Entry == 0 || s.Flags&FlagDoneData == 0 { return bad("a hold needs its instance data (entry) and done flag") } - if s.Param > 4 { + // An encounter state is 0-4; a plain script value (flag 8 without 32) may be any number (the loader's rule). + if s.Flags&FlagEncounter != 0 && s.Param > 4 { return bad("param %d is no encounter state (0-4)", s.Param) } case KindWaitClear: diff --git a/internal/planexport/planexport_test.go b/internal/planexport/planexport_test.go index 158b5ee8..23724bbe 100644 --- a/internal/planexport/planexport_test.go +++ b/internal/planexport/planexport_test.go @@ -352,8 +352,7 @@ func TestAreaTriggerBecomesAMoveToTheStand(t *testing.T) { } } -// zulfarrakThroughZumrah is the shipped Zul'Farrak plan cut after the zumrah leg: the legs after it (the stairs event, Bly, the End Door, -// Ukorz) use the gossip step and unit waits, which the run leader has no step for. +// zulfarrakThroughZumrah is the shipped Zul'Farrak plan cut after the zumrah leg (the tests of the legs up to Zum'rah). func zulfarrakThroughZumrah(t *testing.T) *dungeon.Plan { t.Helper() p, err := dungeon.Load("../../dungeons/zulfarrak.json") @@ -370,9 +369,11 @@ func zulfarrakThroughZumrah(t *testing.T) *dungeon.Plan { return nil } -// The whole shipped Zul'Farrak plan is exported up to the zumrah leg: the legs after it (the stairs event) use a unit wait and the gossip -// step, which the run leader has no step for, so the export is cut before the first of them, with a note that names the leg and the step. -func TestZulfarrakIsCutAfterZumrah(t *testing.T) { +// The whole shipped Zul'Farrak plan is exported up to the stairs_base leg (Nekrum and Sezz'ziz): the two waits of the stairs event carry +// their holds (instance data 1, the pyramid phase), so the stairs legs export; the legs after stairs_base (the End Door, Bly, Ukorz) use a +// unit wait without a hold and the gossip step, which the run leader has no step for, so the export is cut before the first of them, with a +// note that names the leg and the step. +func TestZulfarrakIsCutAfterStairsBase(t *testing.T) { p, err := dungeon.Load("../../dungeons/zulfarrak.json") if err != nil { t.Fatal(err) @@ -381,16 +382,16 @@ func TestZulfarrakIsCutAfterZumrah(t *testing.T) { if err != nil { t.Fatal(err) } - whole := buildWhole(t, zulfarrakThroughZumrah(t)) - if len(res.Rows.Legs) != 11 || res.Rows.Legs[10].Name != "zumrah" || len(res.Rows.Steps) != len(whole.Rows.Steps) || res.Rows.Count() != whole.Rows.Count() { - t.Fatalf("%d legs, %d steps (the plan through zumrah: %d)", len(res.Rows.Legs), len(res.Rows.Steps), len(whole.Rows.Steps)) + whole := buildWhole(t, zulfarrakThrough(t, "stairs_base")) + if len(res.Rows.Legs) != 14 || res.Rows.Legs[13].Name != "stairs_base" || len(res.Rows.Steps) != len(whole.Rows.Steps) || res.Rows.Count() != whole.Rows.Count() { + t.Fatalf("%d legs, %d steps (the plan through stairs_base: %d)", len(res.Rows.Legs), len(res.Rows.Steps), len(whole.Rows.Steps)) } - for _, want := range []string{"exported legs 0-10 (zumrah) only", "cut before leg 11 (stairs)", "after_boss[1]: wait_for.unit has no run leader step (unit 7607 seen)", "5 after it"} { + for _, want := range []string{"exported legs 0-13 (stairs_base) only", "cut before leg 14 (weegli_door)", "steps[0]: wait_for.unit has no run leader step (unit 7607 seen)", "2 after it"} { if !strings.Contains(res.Cut, want) { t.Errorf("the cut note lacks %q: %s", want, res.Cut) } } - if !slices.Contains(res.Warnings, res.Cut) || !strings.Contains(res.Rows.Plan.Comment, "cut before leg 11 (stairs)") { + if !slices.Contains(res.Warnings, res.Cut) || !strings.Contains(res.Rows.Plan.Comment, "cut before leg 14 (weegli_door)") { t.Errorf("warnings %v, plan comment %q", res.Warnings, res.Rows.Plan.Comment) } back, err := Parse(WriteSQL([]Rows{res.Rows})) @@ -399,6 +400,23 @@ func TestZulfarrakIsCutAfterZumrah(t *testing.T) { } } +// zulfarrakThrough is the shipped Zul'Farrak plan cut after the leg named last. +func zulfarrakThrough(t *testing.T, last string) *dungeon.Plan { + t.Helper() + p, err := dungeon.Load("../../dungeons/zulfarrak.json") + if err != nil { + t.Fatal(err) + } + for i, l := range p.Legs { + if l.Name == last { + p.Legs = p.Legs[:i+1] + return p + } + } + t.Fatalf("the plan has no %s leg", last) + return nil +} + // buildWhole builds a plan that has to export whole. func buildWhole(t *testing.T, p *dungeon.Plan) Result { t.Helper() diff --git a/internal/planexport/unexpressed.go b/internal/planexport/unexpressed.go index a27908f8..2a93ca34 100644 --- a/internal/planexport/unexpressed.go +++ b/internal/planexport/unexpressed.go @@ -33,6 +33,7 @@ var unexpressedWhy = map[string]string{ "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", + "wait_for with a hold": "the row is the wait's hold (the event's spot, fought until its instance data value); the wait's own condition (a unit, an object, hostiles gone) and its fail_text are the agent's only", "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)", } @@ -134,6 +135,8 @@ func unexpressed(p *dungeon.Plan, opt Options, upto int) []string { if st.LootObject.Batch != "" { add("loot_object batch", at) } + case st.WaitFor != nil && st.WaitFor.Hold != nil: + add("wait_for with a hold", at) case st.WaitFor != nil && st.WaitFor.GameObject != nil: ref(st.WaitFor.GameObject.ObjectRef) case st.Ritual != nil: From 848693b270317938f770f46a71166fe36b88955c Mon Sep 17 00:00:00 2001 From: pjerra <71784785+pjerra@users.noreply.github.com> Date: Wed, 21 Oct 2026 03:21:23 +0200 Subject: [PATCH 3/4] zulfarrak: velratha pull stands 20 yd clear of their packs; a hold value only on a wait, never 0 Cold review of f11df6d (2026-10-21): - The last approach points of velratha pull groups 4 and 5 stood inside their packs' reach: 1582 1070 was 10.8 yd from the Witch Doctor 81609, 1593.6 1089.9 16.6 yd from the Shadowhunter 81606, so the walk there pulled the pack before the Polymorph (the STUCK #1 situation). New: group 4 1603 1043 9.8 (44.0 yd or more from every member's spawn, 29.3 yd or more from each waypoint of the 81587 patrol less the 5.3 yd follower offset, 45 yd from the Witch Doctor, a complete 21 yd navmesh path from 1584 1050); group 5 1584 1088 9.1 (24.1 yd or more from the four). TestZulfarrakPoolStairsApproach checks the last point against every member of the group in the world export (spawn less wander) and the patrol's creature_movement waypoints (less the follower offset), at least 20 yd. - hold.value is refused on a pull group's or a boss's hold and when it is 0 (the value instance data starts at: the hold would end at once): TestHoldValueIsRefusedWhereItCannotWork. - README: the pyramid phase paragraph says what the plan carries now, and a compatibility note: an agent built before f11df6d refuses this zulfarrak.json (unknown fields); the format version stays 1. --- README.md | 4 +- dungeons/zulfarrak.json | 6 +-- internal/dungeon/plan.go | 20 ++++++--- internal/dungeon/plan_waithold_test.go | 24 ++++++++++ internal/dungeon/zulfarrak_test.go | 62 +++++++++++++++++++++++++- internal/planexport/hold_value_test.go | 10 ++--- internal/planexport/planexport_test.go | 4 +- 7 files changed, 110 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index d037901e..f626b1ab 100644 --- a/README.md +++ b/README.md @@ -915,7 +915,9 @@ The terrace exemptions (`ragefireExemptions` in `ragefire_test.go`, settled with **The pool stairs (`velratha` pull groups 5 and 6, 2026-10-20).** In the full observe run (`zf-full-19z16`, RUN-LESSONS) the run leader stopped at the pack of four at 1601-1610 1104-1111 and then stood at 1629.2 1099.0 z 9.7, a navmesh **island** at the foot of the stairs: a path from 1595.5 1092.4 to it is `partial` and ends 8.8 yd short at 1621.3 1102.9. Three followers and the healer reached the upper level instead (1629-1636 1070-1099, z 31-46.8, 46 yd from the lead, "reach spell" at a mob 37 yd below) and the gather took 6 min to summon them back. The two groups now carry `approach` rows — 1584 1050 and 1582 1070 for the five-mob pack, 1588 1080 and 1593.6 1089.9 for the four-mob pack — so the run leader's party walks to a stand on the lower floor west of the stair mouth (path corners of the 209 mmaps; not live-run yet). The refused pullback of the same run (144 s) is the run leader's own retry, not the plan. -**The pyramid phase is not in the plan format yet.** The stairs event's phase is instance data **1** (`EVENT_PYRAMID`), value **7** = `PYRAMID_WAVE_3` (0-8 in `zulfarrak.h`). A `wait_for` can hold on units, objects and hostiles only; a pull group's or boss's `hold` carries an `instance_data` index but the plan has no field for the value it must read, and plan-export writes the encounter DONE (3) condition. The module-side hold-until-instance-data flag 128 is planned (issue #984), not in the snapshot this plan exports against. `stairs_top` therefore waits on the observable proxy (Nekrum 7796 seen at the foot) and its note describes the gap; a true gate needs `instance_data: 1, done: 7`. +**The pyramid phase in the plan (2026-10-21, feat/zf-full-module-plan).** The stairs event's phase is instance data **1** (`EVENT_PYRAMID`, 0-8 in `zulfarrak.h`: 3 = `PYRAMID_WAVE_1`, 7 = `PYRAMID_WAVE_3`). The agent's own driver still cannot read it: its `wait_for` holds on units, objects and hostiles, so `stairs_top` waits for the proxy (Nekrum 7796 seen at the foot). For the TortoiseBots run leader the waits carry a `hold` (`wait_for.hold`, the wait's run leader form: plan-export writes the hold row in place of the wait) with a `value` (`hold.value`: the instance data value that ends the event, written as a script value, flags 8 without the encounter flag 32): the cage leg's Weegli wait holds the stairs top to 3, `stairs_top`'s Nekrum wait to 7. The run leader compares for equality, so a value must last (3 and 7 last a whole wave; 2 lasts one instance update). A value is refused on a pull group's or a boss's hold and when it is 0. + +**Plan format compatibility (2026-10-21).** `wait_for.hold` and `hold.value` are new fields of plan format version 1; the version number is unchanged (every plan file would have to change with it). The plan reader refuses unknown fields (`DisallowUnknownFields`), so a turtle-agent binary built before feat/zf-full-module-plan (f11df6d) refuses `dungeons/zulfarrak.json` from that branch on (`unknown field "hold"` / `"value"`): run Zul'Farrak with a binary built from this branch or later. Other plan files do not use the fields and load in either build. **Gahz'rilla (PR A, stacked on PR B).** The optional `gahzrilla` leg sits between `velratha_b` and `courtyard`: it walks to the gong stand (1652.84 1168.99 10.67, 3.5 yd from the gong), pulls the eight packs on and near the walk Gahz'rilla takes out of his pool, then the `use_object` step with `via_item` rings the Gong of Zul'Farrak (141832, spawn row 27140 at 1650.91 1171.88 10.9, lock 459): a bot that carries the Mallet of Zul'Farrak (9240) gets `cmd use 9240 entry:141832@1650.9,1171.9,10.9` (the Mallet is used ON the gong: its on-use spell 11434 is an open-lock effect aimed at a game object; round 3 of the live test sent `cmd open` of the gong, which did nothing and answered nothing); a `wait_for` waits up to 30 s for a living Gahz'rilla (7273) in the bots' view, and the boss fight kills him at 1702.58 1224.19 8.88. Without a Mallet in the lead bot's or the carrier's bags the leg is skipped (see `optional` above); a test gives it with `give_items`. diff --git a/dungeons/zulfarrak.json b/dungeons/zulfarrak.json index f320e3e1..562ee13d 100644 --- a/dungeons/zulfarrak.json +++ b/dungeons/zulfarrak.json @@ -511,7 +511,7 @@ "radius": 30, "approach": [ {"x": 1584, "y": 1050, "z": 9}, - {"x": 1582, "y": 1070, "z": 9} + {"x": 1603, "y": 1043, "z": 9.8} ] }, { @@ -525,7 +525,7 @@ "radius": 15, "approach": [ {"x": 1588, "y": 1080, "z": 9.1}, - {"x": 1593.6, "y": 1089.9, "z": 9.3} + {"x": 1584, "y": 1088, "z": 9.1} ] } ], @@ -534,7 +534,7 @@ "zf1-run6 (2026-10-15): two lone Shadowhunter groups are dropped. 1591 1047.7 (7246): three pulls drew no fight (the tank stood 0.7 yd from it and targeted it, the mob stayed at 100 % out of combat, the hunter and the warlock reported out of range); the same Shadowhunter fought as part of the pack at 1576 1079. 1626.5 1136 (7246): nothing of it in view and the look walk to 21 yd of it was not reachable (lead 37 yd away). The run finished without either.", "TestZulfarrakRouteHasNoUnplannedPack (2026-10-14) found three mobs within 20 yd of this leg's route in no pull group: the Shadowhunter 44177 (1662.3 929.7) with the pack of rows 81440/81443/81444 behind it (1657-1662 938-943; a group of its own after the first), the patrolling Shadowcaster 1246219 (1589.4 985.4, a group of its own) and the Blood Drinker 81575 (1632.5 1166.8, 19 yd from Velratha's spot, added to the group at 1648 1152).", "Route edit Z4 NOT applied (2026-10-16): a planned group for the Shadowhunter / Sandcrawler pair at 1565 1019 (the export has no spawn row there: they are roaming members of the patrol group at 1576-1590 1079-1092) puts the look point 21 yd off it within 20 yd of the static pack of two Witch Doctors and a Shadowhunter at 1545-1556 995-1007 (81618, 81619, 81624), which stands in a pit 4 yd below the mesh, off the navmesh and out of any pull (TestZulfarrakRouteHasNoUnplannedPack fails). The pair is still met on the way and the walk's pull takes it.", - "The pool stairs (zf-full-19z16, 2026-10-20, STUCK #2): after the pack of four at 1601-1610 1104-1111 the run leader stood at 1629.2 1099.0 z 9.7 and three followers (and the healer at z 31.8) were on the UPPER level at 1629-1636 1070-1099, z 45.9-46.8, 46 yd from the lead, last_action \"reach spell\" at a mob 37 yd below; the gather summoned them one by one over 6 min (15:27:36-15:33:40) and the next pull started with the DPS 26-35 yd out. The stair base is a navmesh island: a path from 1595.5 1092.4 to 1629.2 1099.0 is Partial and ends at 1621.3 1102.9, 8.8 yd short (the 209 mmaps of 2026-10-20), so a follower walked to the lead's stand there has to go up and around. The approach rows of pull group 4 (1584 1050, 1582 1070) and group 5 (1588 1080, 1593.6 1089.9) keep the run leader's party on the lower floor, west of the stair mouth (planned from the path corners and the STUCK #2 positions; not live-run). The refused pullback of STUCK #1 (144 s at the same pack) is a run-leader retry matter, not this plan." + "The pool stairs (zf-full-19z16, 2026-10-20, STUCK #2): after the pack of four at 1601-1610 1104-1111 the run leader stood at 1629.2 1099.0 z 9.7 and three followers (and the healer at z 31.8) were on the UPPER level at 1629-1636 1070-1099, z 45.9-46.8, 46 yd from the lead, last_action \"reach spell\" at a mob 37 yd below; the gather summoned them one by one over 6 min (15:27:36-15:33:40) and the next pull started with the DPS 26-35 yd out. The stair base is a navmesh island: a path from 1595.5 1092.4 to 1629.2 1099.0 is Partial and ends at 1621.3 1102.9, 8.8 yd short (the 209 mmaps of 2026-10-20), so a follower walked to the lead's stand there has to go up and around. The approach rows of pull group 4 (1584 1050, 1603 1043) and group 5 (1588 1080, 1584 1088) keep the run leader's party on the lower floor, west of the stair mouth (not live-run). The LAST point of each (where the party stands to pull) is at least 20 yd from every member of its group, TestZulfarrakPoolStairsApproach checks it (cold review 2026-10-21: the first version, 1582 1070 and 1593.6 1089.9, stood 10.8 yd from the Witch Doctor of group 4 and 16.6 yd from a Shadowhunter of group 5, so the walk there would pull the pack by proximity, before the Polymorph: the STUCK #1 situation). Group 4 is the patrol of the Sandcrawler 81587 (creature_movement: 1586.1 1087.7, 1569.6 1052.1, 1563.3 1014.3, 1574.3 1065.2, 1598.0 1104.8, 1622.5 1131.7; Shadowhunter 81588 and Blood Drinker 81601 follow at 3.5 and 5.3 yd) with the static Witch Doctor 81609 and Shadowhunter 81608: 1603 1043 is 44.0 yd or more from every member's spawn (less the Blood Drinker's 5 yd wander), 29.3 yd or more from every patrol waypoint less the 5.3 yd follower offset, 32.8 yd from the patrol's path, 45 yd from the Witch Doctor and 48 yd from the group's centre (the run leader's 50 yd reach), on the 209 navmesh at z 9.85, a complete 21 yd path from 1584 1050. It is 12.9 yd from the lone Shadowhunter 81610 (1591 1047.7, in no group of the plan): the run leader pulls it at the hop to 1584 1050 (7.4 yd, within its 15 yd hop pull), before this point; no lower-floor point within 50 yd of the pack is 20 yd from 81610 and from the patrol. Group 5 (the static pack of four, 81604-81607) is 24.1 yd or more from 1584 1088 (z 9.1, a complete path from 1588 1080). The refused pullback of STUCK #1 (144 s at the same pack) is otherwise a run-leader retry matter." ] }, { diff --git a/internal/dungeon/plan.go b/internal/dungeon/plan.go index d22640e5..068fece1 100644 --- a/internal/dungeon/plan.go +++ b/internal/dungeon/plan.go @@ -319,7 +319,8 @@ type Hold struct { // plan-export writes param Value with flags 8 alone (done by instance data), without the encounter flag 32, so the run leader neither // reads a FAIL nor a reset into it (TortoiseBots DungeonPlanPolicy.h: a hold with flag 8 and not 32 takes any param). The run leader // ends the hold when the data reads exactly Value, so it must be a value the script keeps for a while (a wave that is being fought), - // not one it passes through in a single update. Nil: the encounter state DONE (3), flags 40, as before. + // not one it passes through in a single update. Nil: the encounter state DONE (3), flags 40, as before. Only a wait_for's hold takes a + // value, and never 0 (the value instance data starts at): the plan check refuses both. Value *uint32 `json:"value,omitempty"` Spot Point `json:"spot"` Radius float32 `json:"radius"` @@ -1257,7 +1258,7 @@ func (c *checker) leg(path string, r *rawLeg) Leg { pg.Patrol = pat } pg.Approach = c.approach(gp+".approach", g.Approach) - pg.Hold = c.hold(gp+".hold", g.Hold) + pg.Hold = c.hold(gp+".hold", g.Hold, false) l.PullGroups = append(l.PullGroups, pg) } c.entries(path+".skip", r.Skip, false) @@ -1300,7 +1301,7 @@ func (c *checker) leg(path string, r *rawLeg) Leg { } } b.Approach = c.approach(bp+".approach", r.Boss.Approach) - b.Hold = c.hold(bp+".hold", r.Boss.Hold) + b.Hold = c.hold(bp+".hold", r.Boss.Hold, false) if b.Hold != nil && b.LootItem != nil { c.fail(bp+".hold", "a boss with a hold has no loot_item: the run leader's hold row has no loot") } @@ -1367,8 +1368,9 @@ func (c *checker) approach(path string, ps []rawPoint) []Point { return out } -// hold reads a pull group's or a boss's hold. -func (c *checker) hold(path string, r *rawHold) *Hold { +// hold reads a pull group's, a boss's or a wait's hold. A value is allowed on a wait's hold only (a pull group's or a boss's hold is an +// encounter that ends DONE), and never 0: an instance data value starts at 0, so such a hold would end the moment it begins. +func (c *checker) hold(path string, r *rawHold, valueAllowed bool) *Hold { if r == nil { return nil } @@ -1384,6 +1386,12 @@ func (c *checker) hold(path string, r *rawHold) *Hold { c.fail(path+".timeout_s", "%d s is outside 1-%d", r.TimeoutS, MaxTimeoutS) } c.entries(path+".entries", r.Entries, false) + switch { + case r.Value != nil && !valueAllowed: + c.fail(path+".value", "only a wait_for's hold takes a value; a pull group's or a boss's hold ends on the encounter state DONE") + case r.Value != nil && *r.Value == 0: + c.fail(path+".value", "0 is the value instance data starts at, so the hold would end at once: give the value the event reaches") + } return h } @@ -1669,7 +1677,7 @@ func (c *checker) wait(path string, r *rawWait) *WaitFor { } w.HostilesGone = h } - w.Hold = c.hold(path+".hold", r.Hold) + w.Hold = c.hold(path+".hold", r.Hold, true) return w } diff --git a/internal/dungeon/plan_waithold_test.go b/internal/dungeon/plan_waithold_test.go index 13dde4bf..af1f4930 100644 --- a/internal/dungeon/plan_waithold_test.go +++ b/internal/dungeon/plan_waithold_test.go @@ -53,3 +53,27 @@ func TestRunLeaderReachCountsAWaitHoldSpot(t *testing.T) { t.Fatalf("the hold spot is 20 yd from the boss: %v", w) } } + +// A hold's value: never 0 (instance data starts at 0, so the hold would end at once), and only on a wait's hold (a pull group's or a boss's +// hold is an encounter that ends DONE). +func TestHoldValueIsRefusedWhereItCannotWork(t *testing.T) { + const hold = `"hold":{"instance_data":1,%s"spot":{"x":5,"y":6,"z":7},"radius":30,"timeout_s":600}` + h := func(value string) string { return strings.Replace(hold, "%s", value, 1) } + for _, tc := range []struct{ name, js, want string }{ + {"value 0 on a wait", eventPlan(`{"wait_for":{"unit":{"entries":[7],"state":"seen"},`+h(`"value":0,`)+`}}`, ""), "wait_for.hold.value: 0 is the value instance data starts at"}, + {"value on a pull group", `{"version":1,"name":"t","map":1,"entrance":{"x":0,"y":0,"z":0},"legs":[{"to":{"x":0,"y":0,"z":0},"pull_groups":[{"entries":[4857],"positions":[{"x":5,"y":6,"z":7}],` + h(`"value":5,`) + `}]}]}`, "hold.value: only a wait_for's hold takes a value"}, + {"value on a boss", `{"version":1,"name":"t","map":1,"entrance":{"x":0,"y":0,"z":0},"legs":[{"to":{"x":0,"y":0,"z":0},"boss":{"entry":2748,"position":{"x":5,"y":6,"z":7},` + h(`"value":5,`) + `}}]}`, "hold.value: only a wait_for's hold takes a value"}, + } { + if _, err := Parse("bad.json", []byte(tc.js)); err == nil || !strings.Contains(err.Error(), tc.want) { + t.Errorf("%s: %v", tc.name, err) + } + } + for _, ok := range []string{ + eventPlan(`{"wait_for":{"unit":{"entries":[7],"state":"seen"},`+h(`"value":7,`)+`}}`, ""), + `{"version":1,"name":"t","map":1,"entrance":{"x":0,"y":0,"z":0},"legs":[{"to":{"x":0,"y":0,"z":0},"boss":{"entry":2748,"position":{"x":5,"y":6,"z":7},` + h(``) + `}}]}`, + } { + if _, err := Parse("ok.json", []byte(ok)); err != nil { + t.Errorf("%v", err) + } + } +} diff --git a/internal/dungeon/zulfarrak_test.go b/internal/dungeon/zulfarrak_test.go index 9888b5aa..629f9a8c 100644 --- a/internal/dungeon/zulfarrak_test.go +++ b/internal/dungeon/zulfarrak_test.go @@ -207,8 +207,8 @@ func TestZulfarrakPoolStairsApproach(t *testing.T) { last Point packAt Point }{ - {4, Point{1584, 1050, 9}, Point{1582, 1070, 9}, Point{1576.2, 1079.1, 9}}, - {5, Point{1588, 1080, 9.1}, Point{1593.6, 1089.9, 9.3}, Point{1601.7, 1104.4, 9}}, + {4, Point{1584, 1050, 9}, Point{1603, 1043, 9.8}, Point{1576.2, 1079.1, 9}}, + {5, Point{1588, 1080, 9.1}, Point{1584, 1088, 9.1}, Point{1601.7, 1104.4, 9}}, } { g := v.PullGroups[tc.group] if len(g.Approach) != 2 || g.Approach[0] != tc.first || g.Approach[1] != tc.last { @@ -226,9 +226,67 @@ func TestZulfarrakPoolStairsApproach(t *testing.T) { t.Errorf("pull group %d: approach point %v is %.1f yd from the stair island", tc.group, a, d) } } + // Where the party stands to pull must not pull the pack by itself (cold review 2026-10-21: 1582 1070 stood 10.8 yd from the + // Witch Doctor, 1593.6 1089.9 16.6 yd from a Shadowhunter): at least approachClearYd from every member of the group, at its + // spawn and, for the patrol, at every waypoint less the follower's offset. + for _, m := range zfGroupMembers(t, g) { + if d := groundDist(tc.last, m.at) - m.slack; d < approachClearYd { + t.Errorf("pull group %d: the last approach point %v is %.1f yd from %s", tc.group, tc.last, d, m.what) + } else { + t.Logf("pull group %d: %v is %.1f yd from %s", tc.group, tc.last, d, m.what) + } + } + } +} + +// approachClearYd is how far the run leader's stand before a pull keeps from every member of the pack (a pack's aggro reach at level 46 +// against the party is under 20 yd). +const approachClearYd = 20 + +// zfPatrol81587 is the waypoint path of the Sul'lithuz Sandcrawler 81587 (tortoise-wow sql/base creature_movement, 2026-10-21), the +// patrol leader of velratha pull group 4; its followers 81588 and 81601 walk at 3.46 and 5.34 yd from it (creature_groups dist). +var zfPatrol81587 = []Point{{1586.05, 1087.71, 8.88}, {1569.59, 1052.09, 8.88}, {1563.27, 1014.31, 8.88}, {1574.27, 1065.17, 8.88}, {1597.99, 1104.79, 8.88}, {1622.52, 1131.7, 8.88}} + +const zfPatrolFollowYd = 5.34 + +type zfMember struct { + what string + at Point + slack float64 // yards to take off the distance: a wanderer's wander radius, a follower's offset from its leader +} + +// zfGroupMembers are the spawns of the world export that a pull group stands for (its entries, within 3 yd of one of its positions), each +// at its spawn (less its wander radius), and a patrol leader's waypoints (less the followers' offset). +func zfGroupMembers(t *testing.T, g PullGroup) []zfMember { + t.Helper() + var out []zfMember + for _, m := range zfTSV(t, "creatures.tsv") { + f := func(k string) float64 { v, _ := strconv.ParseFloat(m[k], 64); return v } + at := Point{float32(f("x")), float32(f("y")), float32(f("z"))} + mine := false + for i, pos := range g.Positions { + if strconv.Itoa(int(g.Entries[i])) == m["entry"] && groundDist(pos, at) < 3 { + mine = true + } + } + if !mine { + continue + } + out = append(out, zfMember{fmt.Sprintf("%s %s (spawn %s)", m["entry"], m["name"], m["guid"]), at, f("wander")}) + if m["guid"] == "81587" { + for i, w := range zfPatrol81587 { + out = append(out, zfMember{fmt.Sprintf("the patrol of 81587 at waypoint %d (with its followers)", i+1), w, zfPatrolFollowYd}) + } + } + } + if len(out) < len(g.Positions) { + t.Fatalf("only %d of the group's %d positions are spawns of the export", len(out), len(g.Positions)) } + return out } +func groundDist(a, b Point) float64 { return math.Hypot(float64(a.X-b.X), float64(a.Y-b.Y)) } + // The gong step rings the real Gong of Zul'Farrak (141832, the type-10 goober with lock 459 and event 2488), never the Turtle decoration copy // 20901 at the same place, with the Mallet used ON it (via_item) from within use range; the optional gate is the Mallet, and the leg waits for // Gahz'rilla before its boss fight. diff --git a/internal/planexport/hold_value_test.go b/internal/planexport/hold_value_test.go index b2a7ab31..aa05adb7 100644 --- a/internal/planexport/hold_value_test.go +++ b/internal/planexport/hold_value_test.go @@ -7,8 +7,8 @@ import ( ) // A wait_for that carries a hold is exported as that hold row (the wait itself, a unit wait, has no run leader step), and a hold with a -// value is done by that instance data value read as a script value: flag 8 alone (no encounter flag 32), param = the value. A hold without -// a value keeps the encounter DONE (param 3, flags 40). Zul'Farrak's pyramid phase (instance data 1) is the case. +// value is done by that instance data value read as a script value: flag 8 alone (no encounter flag 32), param = the value. A pull group's +// hold (no value: the plan refuses one there) keeps the encounter DONE (param 3, flags 40). Zul'Farrak's pyramid phase (instance data 1) is the case. func TestWaitHoldIsTheRowAndValueIsAScriptValue(t *testing.T) { const js = `{"version":1,"name":"zf","status":"live-checked (test)","map":209,"entrance":{"x":0,"y":0,"z":0},"legs":[ {"name":"cage","to":{"x":10,"y":0,"z":0}, @@ -19,8 +19,7 @@ func TestWaitHoldIsTheRowAndValueIsAScriptValue(t *testing.T) { "steps":[{"wait_for":{"unit":{"entries":[7796],"state":"seen"},"timeout_s":600, "hold":{"instance_data":1,"value":7,"spot":{"x":20,"y":0,"z":0},"radius":30,"timeout_s":600}}}]}, {"name":"group","to":{"x":40,"y":0,"z":0}, - "pull_groups":[{"entries":[4857],"positions":[{"x":45,"y":0,"z":0}],"hold":{"instance_data":4,"value":5,"spot":{"x":45,"y":0,"z":0},"radius":20,"timeout_s":300}}, - {"entries":[4858],"positions":[{"x":50,"y":0,"z":0}],"hold":{"instance_data":2,"spot":{"x":50,"y":0,"z":0},"radius":20,"timeout_s":300}}]}]}` + "pull_groups":[{"entries":[4858],"positions":[{"x":50,"y":0,"z":0}],"hold":{"instance_data":2,"spot":{"x":50,"y":0,"z":0},"radius":20,"timeout_s":300}}]}]}` res, err := Build(parsePlan(t, js), Options{Source: "test"}) if err != nil { t.Fatal(err) @@ -50,8 +49,7 @@ func TestWaitHoldIsTheRowAndValueIsAScriptValue(t *testing.T) { }{ {0, 2, "hold 1 20 0 0 r30 p3 f8 t90 [7787 7788]"}, {1, 0, "hold 1 20 0 0 r30 p7 f8 t600 []"}, - {2, 1, "hold 4 45 0 0 r20 p5 f8 t300 [4857]"}, - {2, 2, "hold 2 50 0 0 r20 p3 f40 t300 [4858]"}, + {2, 1, "hold 2 50 0 0 r20 p3 f40 t300 [4858]"}, } { if got := row(tc.leg, tc.seq); got != tc.want { t.Errorf("leg %d step %d: %s, want %s", tc.leg, tc.seq, got, tc.want) diff --git a/internal/planexport/planexport_test.go b/internal/planexport/planexport_test.go index 23724bbe..930f727f 100644 --- a/internal/planexport/planexport_test.go +++ b/internal/planexport/planexport_test.go @@ -524,8 +524,8 @@ func TestZulfarrakPoolStairsApproachRows(t *testing.T) { return -1 } for _, tc := range []struct{ entries, first, last string }{ - {"5650,7246,8095,5649", "1584.0 1050.0 9.0", "1582.0 1070.0 9.0"}, - {"7246,5650", "1588.0 1080.0 9.1", "1593.6 1089.9 9.3"}, + {"5650,7246,8095,5649", "1584.0 1050.0 9.0", "1603.0 1043.0 9.8"}, + {"7246,5650", "1588.0 1080.0 9.1", "1584.0 1088.0 9.1"}, } { i := idx(tc.entries) if i < 2 { From 6e9b55f048583ea30414fa97d845e6dd15562329 Mon Sep 17 00:00:00 2001 From: pjerra Date: Wed, 21 Oct 2026 03:30:01 +0200 Subject: [PATCH 4/4] README: the Velratha approach points are the moved ones --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index f626b1ab..7d1eb8ab 100644 --- a/README.md +++ b/README.md @@ -913,7 +913,7 @@ The terrace exemptions (`ragefireExemptions` in `ragefire_test.go`, settled with ### Zul'Farrak events: Gahz'rilla, the courtyard and Zum'rah -**The pool stairs (`velratha` pull groups 5 and 6, 2026-10-20).** In the full observe run (`zf-full-19z16`, RUN-LESSONS) the run leader stopped at the pack of four at 1601-1610 1104-1111 and then stood at 1629.2 1099.0 z 9.7, a navmesh **island** at the foot of the stairs: a path from 1595.5 1092.4 to it is `partial` and ends 8.8 yd short at 1621.3 1102.9. Three followers and the healer reached the upper level instead (1629-1636 1070-1099, z 31-46.8, 46 yd from the lead, "reach spell" at a mob 37 yd below) and the gather took 6 min to summon them back. The two groups now carry `approach` rows — 1584 1050 and 1582 1070 for the five-mob pack, 1588 1080 and 1593.6 1089.9 for the four-mob pack — so the run leader's party walks to a stand on the lower floor west of the stair mouth (path corners of the 209 mmaps; not live-run yet). The refused pullback of the same run (144 s) is the run leader's own retry, not the plan. +**The pool stairs (`velratha` pull groups 5 and 6, 2026-10-20).** In the full observe run (`zf-full-19z16`, RUN-LESSONS) the run leader stopped at the pack of four at 1601-1610 1104-1111 and then stood at 1629.2 1099.0 z 9.7, a navmesh **island** at the foot of the stairs: a path from 1595.5 1092.4 to it is `partial` and ends 8.8 yd short at 1621.3 1102.9. Three followers and the healer reached the upper level instead (1629-1636 1070-1099, z 31-46.8, 46 yd from the lead, "reach spell" at a mob 37 yd below) and the gather took 6 min to summon them back. The two groups now carry `approach` rows — 1584 1050 and 1603 1043 for the five-mob pack (the last point moved from 1582 1070 on 2026-10-21 because that spot was 10.8 yd from the Witch Doctor and inside the patrol's reach), 1588 1080 and 1584 1088 for the four-mob pack (moved from 1593.6 1089.9, 16.6 yd from a Shadowhunter); the distances are in the plan note — so the run leader's party walks to a stand on the lower floor west of the stair mouth (path corners of the 209 mmaps; not live-run yet). The refused pullback of the same run (144 s) is the run leader's own retry, not the plan. **The pyramid phase in the plan (2026-10-21, feat/zf-full-module-plan).** The stairs event's phase is instance data **1** (`EVENT_PYRAMID`, 0-8 in `zulfarrak.h`: 3 = `PYRAMID_WAVE_1`, 7 = `PYRAMID_WAVE_3`). The agent's own driver still cannot read it: its `wait_for` holds on units, objects and hostiles, so `stairs_top` waits for the proxy (Nekrum 7796 seen at the foot). For the TortoiseBots run leader the waits carry a `hold` (`wait_for.hold`, the wait's run leader form: plan-export writes the hold row in place of the wait) with a `value` (`hold.value`: the instance data value that ends the event, written as a script value, flags 8 without the encounter flag 32): the cage leg's Weegli wait holds the stairs top to 3, `stairs_top`'s Nekrum wait to 7. The run leader compares for equality, so a value must last (3 and 7 last a whole wave; 2 lasts one instance update). A value is refused on a pull group's or a boss's hold and when it is 0.