diff --git a/common/sql/seed/093_man0u1_scene_casts.sql b/common/sql/seed/093_man0u1_scene_casts.sql new file mode 100644 index 00000000..e148f294 --- /dev/null +++ b/common/sql/seed/093_man0u1_scene_casts.sql @@ -0,0 +1,218 @@ +-- Man0u1 "Court in the Sands" — scene-cast eventnpc spawns +-- (Garlemald-Server #53). +-- +-- Seeds every quest-only NPC station man0u1.lua addresses that has no +-- upstream spawn row: the Amajina & Sons miner-scene cast, the Coliseum +-- lobby/echo cast, and the Phrontistery ward cast — all in the ONE +-- Ul'dah-half private area the client provably loads (209, +-- 'PrivateAreaMasterPast', 5 — pmeteor's pgl200 echo area; the three +-- scene groups share it at disjoint coordinates, SetENpc arming keeps +-- them sequence-scoped) — plus the public zone-209 and zone-170 +-- additions (Yoyobina, the Concern-stage/gate-muster F'lhaminn, +-- Faustigeant, the Camp Black Brush echo cast). +-- +-- Coordinate evidence: the client quest_marker.csv 110010xx block's x/z +-- pairs are literal world coordinates (verified: 11001009 == Linette's +-- public spawn row, 11001018 == Nogeloix's); floor y values come from +-- the neighbouring seed-031/059 rows (Amajina interior 195.6, coliseum +-- lobby 193.2-195.05, Phrontistery ward 229.5, camp 201.0). Rows +-- without a marker are clustered plausibly around the scene anchor and +-- tagged "clustered" — rotations are eyeballed toward the scene centre +-- and all such rows are flagged for in-client tuning. +-- +-- (1) classPath fills: most of this cast ships STRIPPED in gamedata +-- (classPath '' / propertyFlags 0 / null eventConditions) in BOTH +-- upstreams — an actor spawned from a classPath-less class hard-crashes +-- the client (the seed/061 Wine-crash family, populace flavour; +-- seed/076 is the identical fix for the man0g1 CNJ cast). Fill = the +-- standard populace recipe: PopulaceStandard + propertyFlags 19 + +-- talkDefault/noticeEvent. displayNameIds already correct in seed/003 — +-- untouched. Guarded so a future upstream refresh with real rows wins. +-- (1000842/1001283/1001284 get their emote-enabled condition superset +-- in seed/095 — the fill here is the crash-safety baseline.) +-- +-- Spawn ids 2400-2441: explicit clean block above the highest explicit +-- seed id (2264, seed/059) AND the ~57 AUTOINCREMENT rows seeds +-- 056-087 add (≈2265-2321). uniqueId prefix 'man0u1_'. +-- +-- Idempotent: UPDATEs guarded, INSERTs OR IGNORE on id. No schema change. + +-- ---- (1) classPath fills for the stripped cast (seed/076 recipe) ----- + +UPDATE "gamedata_actor_class" SET + "classPath" = '/Chara/Npc/Populace/PopulaceStandard', + "propertyFlags" = 19, + "eventConditions" = '{ + "talkEventConditions": [ + { + "unknown1": 4, + "unknown2": 0, + "conditionName": "talkDefault" + } + ], + "noticeEventConditions": [ + { + "unknown1": 0, + "unknown2": 1, + "conditionName": "noticeEvent" + } + ] +}' +WHERE "id" IN ( + 1000842, -- F'lhaminn (scene/teach variant; emote superset in seed/095) + 1001054, -- Corguevais (beaten-in-the-mines scene variant) + 1001283, -- Manic Miner (emote superset in seed/095) + 1001284, -- Maddened Miner (emote superset in seed/095) + 1001286, -- Nittma Guttma + 1001287, -- Maudlin Miner + 1001288, -- Mocking Miner + 1001289, -- Monitoring Miner + 1001290, -- Displeased Dancer + 1000690, -- Muscular Miner + 1000981, -- Close-fisted Woman + 1000895, -- Astonished Adventurer + 1000857, -- Popokkuli + 1000858, -- Seserukka + 1000039, -- Greinfarr + 1000037, -- Niellefresne (base class; GLD echo scene) + 1000699, -- Qualmish Miner + 1001207, -- Worrisome Assistant + 1001210, -- Well-washed Leech + 1000852, -- Master Faustigeant + 1000927, -- Yoyobina + 1000038, -- F'lhaminn (base populace; gate muster + Concern stage) + 1001515, -- Ascilia (camp-scene variant) + 1000043, -- Corguevais (present-day; camp echo) + 1000808, -- Untidy Outlander + 1000809, -- Malnourished Midlander + 1000817 -- Helpless Hyur (the camp leader) +) AND "classPath" = ''; + +-- Thancred's camp class already carries talk/notice conditions in +-- seed/003 — restore classPath/flags ONLY (nothing else clobbered). +UPDATE "gamedata_actor_class" SET + "classPath" = '/Chara/Npc/Populace/PopulaceStandard', + "propertyFlags" = 19 +WHERE "id" = 1000185 AND "classPath" = ''; + +-- Slow-witted Miner has a classPath but flags 0 / null conditions — +-- the ward crowd needs him talkable (processEvent210_6). +UPDATE "gamedata_actor_class" SET + "propertyFlags" = 19, + "eventConditions" = '{ + "talkEventConditions": [ + { + "unknown1": 4, + "unknown2": 0, + "conditionName": "talkDefault" + } + ], + "noticeEventConditions": [ + { + "unknown1": 0, + "unknown2": 1, + "conditionName": "noticeEvent" + } + ] +}' +WHERE "id" = 1000689 AND "eventConditions" IS NULL; + +-- ---- (2) Miner scene — Amajina & Sons interior (PA 209/5, y=195.6) --- + +-- Anchored rows: markers 11001009/10/11/12/13; Corguevais lies beaten +-- on the floor between the miners (retail CS8 — animationId 1000, the +-- seed/059 collapsed-corpse motion; flag for live check). Everyone else +-- clustered (-95..-110, 310..328) facing the scene centre. +INSERT OR IGNORE INTO "server_spawn_locations" + ("id", "actorClassId", "uniqueId", "zoneId", "privateAreaName", "privateAreaLevel", + "positionX", "positionY", "positionZ", "rotation", "actorState", "animationId", "customDisplayName") +VALUES + (2400, 1000861, 'man0u1_linette_scene', 209, 'PrivateAreaMasterPast', 5, -92.38, 195.6, 313.43, -0.97, 0, 0, null), -- marker 11001009 + (2401, 1000842, 'man0u1_flhaminn_teach', 209, 'PrivateAreaMasterPast', 5, -96.68, 195.6, 313.17, -0.66, 0, 0, null), -- marker 11001010 + (2402, 1001054, 'man0u1_corguevais_scene', 209, 'PrivateAreaMasterPast', 5, -106.50, 195.6, 320.00, 1.57, 0, 1000, null), -- between the miners (clustered; collapsed pose) + (2403, 1001283, 'man0u1_manic_miner', 209, 'PrivateAreaMasterPast', 5, -108.41, 195.6, 326.53, 2.36, 0, 0, null), -- marker 11001011 + (2404, 1001284, 'man0u1_maddened_miner', 209, 'PrivateAreaMasterPast', 5, -107.48, 195.6, 324.55, 2.27, 0, 0, null), -- marker 11001012 + (2405, 1001286, 'man0u1_nittma_guttma', 209, 'PrivateAreaMasterPast', 5, -104.00, 195.6, 318.00, 0.79, 0, 0, null), -- near marker 11001013 + (2406, 1001287, 'man0u1_maudlin_miner', 209, 'PrivateAreaMasterPast', 5, -109.50, 195.6, 322.00, 1.20, 0, 0, null), -- clustered + (2407, 1001288, 'man0u1_mocking_miner', 209, 'PrivateAreaMasterPast', 5, -110.00, 195.6, 318.50, 1.50, 0, 0, null), -- clustered + (2408, 1001289, 'man0u1_monitoring_miner', 209, 'PrivateAreaMasterPast', 5, -108.50, 195.6, 315.00, 1.90, 0, 0, null), -- clustered + (2409, 1001290, 'man0u1_displeased_dancer', 209, 'PrivateAreaMasterPast', 5, -102.50, 195.6, 322.50, -0.60, 0, 0, null), -- clustered + (2410, 1000690, 'man0u1_muscular_miner', 209, 'PrivateAreaMasterPast', 5, -105.50, 195.6, 327.50, 0.40, 0, 0, null), -- clustered + (2411, 1000981, 'man0u1_closefisted_woman', 209, 'PrivateAreaMasterPast', 5, -98.50, 195.6, 316.50, -1.30, 0, 0, null), -- clustered + (2412, 1000895, 'man0u1_astonished_adv', 209, 'PrivateAreaMasterPast', 5, -97.50, 195.6, 319.50, -1.10, 0, 0, null), -- clustered + (2413, 1001203, 'man0u1_tyago_moui', 209, 'PrivateAreaMasterPast', 5, -100.50, 195.6, 312.00, -2.60, 0, 0, null), -- clustered + (2414, 1000637, 'man0u1_shilgen', 209, 'PrivateAreaMasterPast', 5, -95.50, 195.6, 310.50, -2.90, 0, 0, null), -- clustered + (2415, 1600042, 'man0u1_nortmoen', 209, 'PrivateAreaMasterPast', 5, -94.00, 195.6, 312.50, -2.30, 0, 0, null), -- clustered + (2416, 1000857, 'man0u1_popokkuli', 209, 'PrivateAreaMasterPast', 5, -99.00, 195.6, 324.00, -0.30, 0, 0, null), -- clustered + (2417, 1000858, 'man0u1_seserukka', 209, 'PrivateAreaMasterPast', 5, -98.00, 195.6, 325.50, -0.45, 0, 0, null); -- clustered + +-- ---- (3) Coliseum lobby + post-match echo (PA 209/5) ----------------- + +-- Lulutsu copies his public seed-031 spot (row 178); Papawa's stand is +-- explicit; the crowd is clustered (-180..-190, 193..195, 190..210). +-- Greinfarr + Niellefresne carry the man0u140 gathering echo. +INSERT OR IGNORE INTO "server_spawn_locations" + ("id", "actorClassId", "uniqueId", "zoneId", "privateAreaName", "privateAreaLevel", + "positionX", "positionY", "positionZ", "rotation", "actorState", "animationId", "customDisplayName") +VALUES + (2418, 1000863, 'man0u1_lulutsu_lobby', 209, 'PrivateAreaMasterPast', 5, -193.46, 195.05, 183.65, 0.61, 0, 0, null), -- copy of public row 178 + (2419, 1000962, 'man0u1_papawa_lobby', 209, 'PrivateAreaMasterPast', 5, -175.05, 193.20, 201.17, -2.10, 0, 0, null), + (2420, 1000965, 'man0u1_abylgo_hamylgo', 209, 'PrivateAreaMasterPast', 5, -183.00, 194.00, 196.00, 0.80, 0, 0, null), -- clustered + (2421, 1000964, 'man0u1_fruhybolg', 209, 'PrivateAreaMasterPast', 5, -186.50, 194.50, 203.00, -1.20, 0, 0, null), -- clustered + (2422, 1000967, 'man0u1_swerdahrm', 209, 'PrivateAreaMasterPast', 5, -181.00, 193.50, 207.50, 2.80, 0, 0, null), -- clustered + (2423, 1000039, 'man0u1_greinfarr_echo', 209, 'PrivateAreaMasterPast', 5, -185.50, 195.00, 193.00, -0.64, 0, 0, null), + (2424, 1000037, 'man0u1_niellefresne_echo', 209, 'PrivateAreaMasterPast', 5, -187.00, 195.00, 195.00, 2.50, 0, 0, null); + +-- ---- (4) Phrontistery sickroom ward (PA 209/5, y=229.5) -------------- + +-- Ward crowd clustered (-210..-224, 300..315); Faustigeant explicit; +-- the ward door is the PA copy of class 1090119's public row 250 (the +-- SEQ_100/105 exit push — seed/094 gives the class its push condition). +INSERT OR IGNORE INTO "server_spawn_locations" + ("id", "actorClassId", "uniqueId", "zoneId", "privateAreaName", "privateAreaLevel", + "positionX", "positionY", "positionZ", "rotation", "actorState", "animationId", "customDisplayName") +VALUES + (2425, 1000689, 'man0u1_slowwitted_miner', 209, 'PrivateAreaMasterPast', 5, -212.50, 229.5, 303.50, 1.20, 0, 0, null), -- clustered + (2426, 1000690, 'man0u1_muscular_miner_ward', 209, 'PrivateAreaMasterPast', 5, -214.50, 229.5, 306.00, 0.90, 0, 0, null), -- clustered + (2427, 1000699, 'man0u1_qualmish_miner', 209, 'PrivateAreaMasterPast', 5, -218.00, 229.5, 309.50, 0.60, 0, 0, null), -- clustered + (2428, 1001207, 'man0u1_worrisome_assistant', 209, 'PrivateAreaMasterPast', 5, -221.00, 229.5, 311.50, 0.30, 0, 0, null), -- clustered + (2429, 1001210, 'man0u1_wellwashed_leech', 209, 'PrivateAreaMasterPast', 5, -223.00, 229.5, 313.00, 0.10, 0, 0, null), -- clustered + (2430, 1000852, 'man0u1_faustigeant_ward', 209, 'PrivateAreaMasterPast', 5, -220.00, 229.5, 308.00, 2.60, 0, 0, null), + (2431, 1090119, 'man0u1_ward_door_pa', 209, 'PrivateAreaMasterPast', 5, -216.38, 229.5, 302.07, 0.00, 0, 0, null); -- PA copy of public row 250 + +-- ---- (5) PUBLIC zone 209 additions ----------------------------------- + +INSERT OR IGNORE INTO "server_spawn_locations" + ("id", "actorClassId", "uniqueId", "zoneId", "privateAreaName", "privateAreaLevel", + "positionX", "positionY", "positionZ", "rotation", "actorState", "animationId", "customDisplayName") +VALUES + -- Yoyobina at the coliseum entrance (marker 11001006 — the exact + -- spot of the public 1090283 row 257; rotation copied from it, + -- facing out toward the walk-up). + (2432, 1000927, 'man0u1_yoyobina', 209, '', 0, -185.26, 195.00, 179.74, -2.28, 0, 0, null), + -- F'lhaminn at the Concern stage (marker 11001017 — the SEQ_075 + -- escort-payment talk). + (2433, 1000038, 'man0u1_flhaminn_stage', 209, '', 0, -91.35, 196.02, 323.18, -0.50, 0, 0, null), + -- Faustigeant near the ward door, public floor (the SEQ_085 talk; + -- clustered off marker 11001019's door at -216.38/302.07). + (2434, 1000852, 'man0u1_faustigeant_public', 209, '', 0, -214.00, 229.60, 296.00, -0.37, 0, 0, null); + +-- ---- (6) PUBLIC zone 170 additions ----------------------------------- + +INSERT OR IGNORE INTO "server_spawn_locations" + ("id", "actorClassId", "uniqueId", "zoneId", "privateAreaName", "privateAreaLevel", + "positionX", "positionY", "positionZ", "rotation", "actorState", "animationId", "customDisplayName") +VALUES + -- F'lhaminn at the Gate of Nald muster (marker 11001014; y=183.0 + -- interpolated from the nearby seed-031 zone-170 rows — FLAG FOR + -- IN-CLIENT TUNING). + (2435, 1000038, 'man0u1_flhaminn_gate', 170, '', 0, -34.50, 183.0, -68.90, 0.00, 0, 0, null), + -- Camp Black Brush echo cast (public — design-doc checkpoint 4; + -- camp floor y=201.0 from the seed-031 camp anchors). Ascilia at + -- marker 11001015, the camp leader at 11001016; the rest clustered. + (2436, 1001515, 'man0u1_ascilia_camp', 170, '', 0, 41.10, 201.0, -480.00, 1.68, 0, 0, null), -- marker 11001015 + (2437, 1000043, 'man0u1_corguevais_camp', 170, '', 0, 42.50, 201.0, -481.50, 1.40, 0, 0, null), -- clustered + (2438, 1000185, 'man0u1_thancred_camp', 170, '', 0, 44.00, 201.0, -479.00, 1.20, 0, 0, null), -- clustered + (2439, 1000808, 'man0u1_untidy_outlander', 170, '', 0, 39.50, 201.0, -478.50, 2.20, 0, 0, null), -- clustered + (2440, 1000809, 'man0u1_malnourished_mid', 170, '', 0, 40.50, 201.0, -482.50, 1.00, 0, 0, null), -- clustered + (2441, 1000817, 'man0u1_helpless_hyur', 170, '', 0, 50.43, 201.0, -480.99, -1.46, 0, 0, null); -- marker 11001016 diff --git a/common/sql/seed/094_man0u1_triggers.sql b/common/sql/seed/094_man0u1_triggers.sql new file mode 100644 index 00000000..570d3ab6 --- /dev/null +++ b/common/sql/seed/094_man0u1_triggers.sql @@ -0,0 +1,120 @@ +-- Man0u1 "Court in the Sands" — quest push-triggers (Garlemald-Server #53). +-- +-- (1) Gate of Nald muster trigger 1090285 (SEQ_060 duty-join push, +-- public zone 170): a net-new pushWithCircle class on the seed/074 +-- trigger recipe (PopulaceStandard + a disabled-until-armed circle; +-- the quest arms it with SetENpc(GATE_OF_NALD_TRIGGER, QFLAG_PUSH, +-- false, true)). 1090285 ships stripped in seed/003 (classPath ''), +-- same as 074's 1090200-1090204 band. +-- (2) Arena-gate trigger 1090283 (SEQ_015 subseqGLD==2, "Now go and +-- wait within."): Slerp Lederp's dev capture leaks this class as +-- the ">Pushed GLD!" push. Its seed/003 row already carries +-- talkDefault + noticeEvent but NO pushWithCircle — merge one in +-- (the seed/074 1090046 keep-existing-conditions recipe), then +-- spawn a PA (209/5) copy inside the lobby at marker 11001007. +-- (3) Ward door 1090119 (ObjectEventDoor; Slerp's SEQ 90/95/100 push +-- class): seed/003 ships it with NULL eventConditions, so no push +-- could ever fire. Give it the man0u0 exit-door recipe (1099046: +-- empty talk + noticeEvent + a radius-6 pushDefault circle), with +-- "isEnabled": "false" so both the public door (seed/031 row 250) +-- and the PA copy (seed/093 row 2431) stay inert until the quest +-- arms them (SetENpc(WARD_DOOR, QFLAG_PUSH, false, true) at +-- SEQ_090/100/105). classPath/propertyFlags untouched. +-- +-- Spawn ids 2450-2451 continue seed/093's clean explicit block. +-- +-- Idempotent: UPDATEs guarded, INSERTs OR IGNORE on id. No schema change. + +-- ---- (1) Gate of Nald muster trigger (net-new class) ----------------- + +UPDATE "gamedata_actor_class" SET + "classPath" = '/Chara/Npc/Populace/PopulaceStandard', + "propertyFlags" = 1, + "eventConditions" = '{ + "pushWithCircleEventConditions": [ + { + "isEnabled": "false", + "radius": "10.0", + "outwards": "false", + "silent": "false", + "conditionName": "pushDefault" + } + ] +}' +WHERE "id" = 1090285 AND "classPath" = ''; + +INSERT OR IGNORE INTO "server_spawn_locations" + ("id", "actorClassId", "uniqueId", "zoneId", "privateAreaName", "privateAreaLevel", + "positionX", "positionY", "positionZ", "rotation", "actorState", "animationId", "customDisplayName") +VALUES + -- Marker 11001014 (the muster point just outside the Gate of Nald; + -- y=183.0 interpolated like the F'lhaminn row beside it — FLAG FOR + -- IN-CLIENT TUNING). + (2450, 1090285, 'man0u1_gate_trig', 170, '', 0, -34.54, 183.0, -68.88, 0, 0, 0, null); + +-- ---- (2) Arena-gate trigger: merge a push circle into 1090283 -------- + +-- Existing seed/003 conditions (talkDefault + noticeEvent) are KEPT +-- verbatim; only the circle is new. Guarded so it applies once and so a +-- future upstream refresh that already carries a circle wins. +UPDATE "gamedata_actor_class" SET + "eventConditions" = '{ + "talkEventConditions": [ + { + "unknown1": 4, + "unknown2": 0, + "conditionName": "talkDefault" + } + ], + "noticeEventConditions": [ + { + "unknown1": 0, + "unknown2": 1, + "conditionName": "noticeEvent" + } + ], + "pushWithCircleEventConditions": [ + { + "isEnabled": "false", + "radius": "12.0", + "outwards": "false", + "silent": "false", + "conditionName": "pushDefault" + } + ] +}' +WHERE "id" = 1090283 AND "eventConditions" NOT LIKE '%pushWithCircle%'; + +INSERT OR IGNORE INTO "server_spawn_locations" + ("id", "actorClassId", "uniqueId", "zoneId", "privateAreaName", "privateAreaLevel", + "positionX", "positionY", "positionZ", "rotation", "actorState", "animationId", "customDisplayName") +VALUES + -- Marker 11001007 — inside the lobby, the "wait within" gate the + -- SEQ_015 duty-join push fires from. (The class's public row 257 at + -- the coliseum entrance keeps its dormant circle — the quest only + -- arms it while the player is inside the lobby PA.) + (2451, 1090283, 'man0u1_arena_gate_trig', 209, 'PrivateAreaMasterPast', 5, -187.23, 192.5, 219.73, 0, 0, 0, null); + +-- ---- (3) Ward door 1090119: the 1099046 exit-door condition recipe --- + +UPDATE "gamedata_actor_class" SET + "eventConditions" = '{ + "talkEventConditions": [], + "noticeEventConditions": [ + { + "unknown1": 0, + "unknown2": 1, + "conditionName": "noticeEvent" + } + ], + "pushWithCircleEventConditions": [ + { + "isEnabled": "false", + "radius": "6.0", + "outwards": "false", + "silent": "false", + "conditionName": "pushDefault" + } + ] +}' +WHERE "id" = 1090119 AND "eventConditions" IS NULL; diff --git a/common/sql/seed/095_man0u1_emote_conditions.sql b/common/sql/seed/095_man0u1_emote_conditions.sql new file mode 100644 index 00000000..53080bab --- /dev/null +++ b/common/sql/seed/095_man0u1_emote_conditions.sql @@ -0,0 +1,34 @@ +-- Man0u1 "Court in the Sands" — SEQ_050/057 emote-mechanic conditions +-- (Garlemald-Server #53; the seed/080 man0g1 dance-drill recipe). +-- +-- A player emote only fires the quest onEmote hook when the TARGET +-- actor's gamedata_actor_class carries an emoteEventConditions entry +-- whose emoteId matches the emote performed (seed/080 root-cause; +-- otherwise the client routes it to the free EmoteStandardCommand and +-- the quest never hears it). man0u1's teach/calm mechanic needs all SIX +-- taught emotes on F'lhaminn (teach phase) AND both miners (calm +-- phase): Furious 103, Beckon 108, Laugh 121, Deny 125, Upset 140, +-- Soothe 135 — the 100-range EmoteStandardCommand ids, order-locked to +-- emoteDefault1..6 because man0u1.lua's EMOTE_STEPS table indexes the +-- conditionName suffix (Maddened wants emoteDefault2, Manic wants +-- 6→1→3). Emote-id evidence: the client-side F'lhaminn demo schedulers +-- encode exactly these ids (0x5000000 | ((id-100) << 12)); the six-id +-- set is verbatim in the pmeteor header table. +-- +-- REPLACED conditions note: all three classes ship with NULL +-- eventConditions in seed/003 AND both upstreams (nothing load-bearing +-- existed); seed/093's crash-safety fill gives them the plain +-- talkDefault + noticeEvent baseline, which this seed re-states inside +-- the superset — the talks (processEvent051_x / 050_13 / 050_14) keep +-- working and the names light as quest targets. Untaught emotes match +-- no condition and simply never fire (retail behaviour). +-- +-- Idempotent: the UPDATE converges (same JSON on re-run). No schema change. + +UPDATE "gamedata_actor_class" SET "eventConditions" = +'{"talkEventConditions":[{"unknown1":4,"unknown2":0,"conditionName":"talkDefault"}],"noticeEventConditions":[{"unknown1":0,"unknown2":1,"conditionName":"noticeEvent"}],"emoteEventConditions":[{"unknown1":4,"unknown2":0,"emoteId":103,"conditionName":"emoteDefault1"},{"unknown1":4,"unknown2":0,"emoteId":108,"conditionName":"emoteDefault2"},{"unknown1":4,"unknown2":0,"emoteId":121,"conditionName":"emoteDefault3"},{"unknown1":4,"unknown2":0,"emoteId":125,"conditionName":"emoteDefault4"},{"unknown1":4,"unknown2":0,"emoteId":140,"conditionName":"emoteDefault5"},{"unknown1":4,"unknown2":0,"emoteId":135,"conditionName":"emoteDefault6"}]}' +WHERE "id" IN ( + 1000842, -- F'lhaminn (scene/teach variant — SEQ_050 accepts all six in teach order) + 1001283, -- Manic Miner (SEQ_057 wants Soothe→Furious→Laugh = emoteDefault6→1→3) + 1001284 -- Maddened Miner (SEQ_057 wants Beckon = emoteDefault2) +); diff --git a/common/sql/seed/096_man0u1_content_npcs.sql b/common/sql/seed/096_man0u1_content_npcs.sql new file mode 100644 index 00000000..d6419c76 --- /dev/null +++ b/common/sql/seed/096_man0u1_content_npcs.sql @@ -0,0 +1,130 @@ +-- Man0u1 "Court in the Sands" — content battle-npc data (Garlemald-Server +-- #53): the SEQ_015 Coliseum match (SimpleContentMan0u101 spawns bnpc +-- 42) and the SEQ_060 F'lhaminn escort (SimpleContentMan0u102 spawns +-- bnpc 43 + chinchillas 44-51). Mirrors seed/074's trigger+escort bnpc +-- sections and seed/082's kid-class recipe. +-- +-- (1) Tourney Gladiator (class 2280157, display 3280156 — bahamut +-- gamedata; humanoid, appearance row shipped): the UNWINNABLE 1v1 +-- opponent. Retail (both full playthroughs + the beta uploader's +-- notes): hits a level-1-3 player for 25-79 (Light Stab / Heavy +-- Stab / Red Lotus on film) and cannot die — the content script +-- MinimumHpLocks him; losing IS completing. Class ships stripped +-- (classPath '') in both trees — fill with the PROVEN +-- FighterAllyOpeningAttacker kit (its class script supplies the +-- battleCommon tuple; a battle npc without one crashes the +-- client's DepictionJudge — the man0l1 chigoe / seed/082 lesson; +-- "Ally" is only the classPath name, hostility comes from the +-- group's allegiance 0 + ChangeState(2) in-script). Level 5 per +-- the 053/055 pacing rationale (a level-1-3 player must LOSE the +-- ~25-79-damage exchange); HP is cosmetic under MinimumHpLock. +-- DAMAGE BAND FLAGGED for in-client tuning against the retail +-- 25-79 window. +-- (2) F'lhaminn escortee: the seed/082 recipe verbatim — the game +-- already ships an ally-band class 2290008 carrying her REAL +-- display name (1900054) and an appearance row byte-identical to +-- her scene class 1000842, merely stripped. Fill classPath only; +-- no appearance clone needed (full retail fidelity, zero render +-- risk). Allegiance 1 like Sisipu/Powle (excluded from live +-- hostile counts); 200 HP (the kids' band) — she takes real +-- damage, her wounding is the retail fail condition. +-- (3) Chinchillas (class 2204010, display 3204010 — the retail combat +-- log names "chinchilla" throughout): appearance row shipped +-- (model 10502 = the Lemming family; wharf rat 2104001 carries +-- LemmingStandard on the same model), class stripped — fill with +-- LemmingStandard (the seed/056 chigoe family-model recipe). +-- Genus 12 'Rodent' per the same wharf-rat precedent. Weak per +-- the seed/074 biter band: level 5-7, 60 HP (retail: 60-80-damage +-- weaponskills one-shot them, 511-650 EXP each on film). +-- +-- Ids: pools 15-17, groups 16-18, bnpc spawn rows 42-51 — all next-free +-- (Sisipu's escort used bnpc 25-33, man0g1's 34-41; pools/groups topped +-- out at 14/15 in seed/074). groupId.zoneId is metadata only (the live +-- spawn zone comes from the content area's parent zone) but is kept +-- agreeing with where each duty runs (209 arena / 170 escort). +-- +-- Chinchilla coordinates are ROUTE-INTERPOLATED, not captured (no +-- surviving 1.x recording renders XYZ): three clusters on the straight +-- line gate (-34.5, 183, -68.9) → camp (34.8, 201, -480.5) at t≈0.3 / +-- 0.6 / 0.85 of the run, ±4-unit offsets, y linear (183→201) — FLAG for +-- the RECORD_MODE re-capture pass the siblings got (#41/#46). The arena +-- anchor (-205.4, 192.0, 150.9) is the GC-quest coliseum marker +-- (11092101), the only in-data arena anchor — y FLAGGED for in-client +-- tuning. +-- +-- Idempotent: UPDATEs guarded, INSERTs OR IGNORE. No schema change. + +-- ---- (1) classPath fills --------------------------------------------- + +-- Tourney Gladiator — humanoid battle npc on the proven Fighter kit. +UPDATE "gamedata_actor_class" SET + "classPath" = '/Chara/Npc/Monster/Fighter/FighterAllyOpeningAttacker' +WHERE "id" = 2280157 AND "classPath" = ''; + +-- F'lhaminn's shipped ally-band class (seed/082 recipe). +UPDATE "gamedata_actor_class" SET + "classPath" = '/Chara/Npc/Monster/Fighter/FighterAllyOpeningAttacker' +WHERE "id" = 2290008 AND "classPath" = ''; + +-- Chinchilla — Lemming family model path (seed/056 chigoe recipe). +UPDATE "gamedata_actor_class" SET + "classPath" = '/Chara/Npc/Monster/Lemming/LemmingStandard' +WHERE "id" = 2204010 AND "classPath" = ''; + +-- ---- (2) Pools -------------------------------------------------------- + +-- Gladiator: genus 29 'Person', currentJob 3 (GLA — cosmetic; the melee +-- spawn path treats melee jobs identically, the 054 caster-note analog). +INSERT OR IGNORE INTO "server_battlenpc_pools" ("poolId", "actorClassId", "name", "genusId", "currentJob", "combatSkill", "combatDelay", "combatDmgMult", "aggroType", "immunity", "linkType", "spellListId", "skillListId") VALUES + (15, 2280157, 'tourney_gladiator', 29, 3, 1, 4200, 1, 0, 0, 0, 0, 0); +INSERT OR IGNORE INTO "server_battlenpc_pools" ("poolId", "actorClassId", "name", "genusId", "currentJob", "combatSkill", "combatDelay", "combatDmgMult", "aggroType", "immunity", "linkType", "spellListId", "skillListId") VALUES + (16, 2290008, 'escort_flhaminn', 29, 2, 1, 4200, 1, 0, 0, 0, 0, 0); +INSERT OR IGNORE INTO "server_battlenpc_pools" ("poolId", "actorClassId", "name", "genusId", "currentJob", "combatSkill", "combatDelay", "combatDmgMult", "aggroType", "immunity", "linkType", "spellListId", "skillListId") VALUES + (17, 2204010, 'chinchilla', 12, 0, 1, 4200, 1, 0, 0, 0, 0, 0); + +-- ---- (3) Groups ------------------------------------------------------- + +-- Gladiator: hostile (allegiance 0), level 5, HP cosmetic under the +-- in-script MinimumHpLock. +INSERT OR IGNORE INTO "server_battlenpc_groups" ("groupId", "poolId", "scriptName", "minLevel", "maxLevel", "respawnTime", "hp", "mp", "dropListId", "allegiance", "spawnType", "animationId", "actorState", "privateAreaName", "privateAreaLevel", "zoneId") VALUES + (16, 15, 'tourney_gladiator', 5, 5, 0, 1000, 0, 0, 0, 1, 0, 0, '', 0, 209); +-- F'lhaminn: ally (allegiance 1), kids'-band HP — her wounding fails +-- the duty (content-script live-roster check). +INSERT OR IGNORE INTO "server_battlenpc_groups" ("groupId", "poolId", "scriptName", "minLevel", "maxLevel", "respawnTime", "hp", "mp", "dropListId", "allegiance", "spawnType", "animationId", "actorState", "privateAreaName", "privateAreaLevel", "zoneId") VALUES + (17, 16, 'escort_flhaminn', 1, 1, 0, 200, 0, 0, 1, 1, 0, 0, '', 0, 170); +-- Chinchillas: the man0l1/man0g1 biter band (lvl 5-7, hp 60 — one-shot +-- at opener weaponskill damage, per the retail recording). +INSERT OR IGNORE INTO "server_battlenpc_groups" ("groupId", "poolId", "scriptName", "minLevel", "maxLevel", "respawnTime", "hp", "mp", "dropListId", "allegiance", "spawnType", "animationId", "actorState", "privateAreaName", "privateAreaLevel", "zoneId") VALUES + (18, 17, 'chinchilla', 5, 7, 0, 60, 0, 0, 0, 1, 0, 0, '', 0, 170); + +-- ---- (4) Spawn points (SimpleContentMan0u101/102 spawn these ids) ----- + +-- The bloodsands anchor (GC-quest coliseum marker 11092101; y FLAGGED). +INSERT OR IGNORE INTO "server_battlenpc_spawn_locations" ("bnpcId", "customDisplayName", "groupId", "positionX", "positionY", "positionZ", "rotation") VALUES + (42, 'tourney_gladiator', 16, -205.4, 192.0, 150.9, 0.0); + +-- F'lhaminn beside the gate warp-in (the escort starts with her in tow). +INSERT OR IGNORE INTO "server_battlenpc_spawn_locations" ("bnpcId", "customDisplayName", "groupId", "positionX", "positionY", "positionZ", "rotation") VALUES + (43, 'escort_flhaminn', 17, -31.0, 183.0, -66.0, 2.0); + +-- Chinchilla cluster A — route point t≈0.3 (2 mobs). ROUTE-INTERPOLATED. +INSERT OR IGNORE INTO "server_battlenpc_spawn_locations" ("bnpcId", "customDisplayName", "groupId", "positionX", "positionY", "positionZ", "rotation") VALUES + (44, 'chinchilla', 18, -17.7, 188.4, -190.4, 0.0); +INSERT OR IGNORE INTO "server_battlenpc_spawn_locations" ("bnpcId", "customDisplayName", "groupId", "positionX", "positionY", "positionZ", "rotation") VALUES + (45, 'chinchilla', 18, -9.7, 188.4, -194.4, 0.0); + +-- Chinchilla cluster B — route point t≈0.6 (3 mobs). ROUTE-INTERPOLATED. +INSERT OR IGNORE INTO "server_battlenpc_spawn_locations" ("bnpcId", "customDisplayName", "groupId", "positionX", "positionY", "positionZ", "rotation") VALUES + (46, 'chinchilla', 18, 3.1, 193.8, -313.9, 0.0); +INSERT OR IGNORE INTO "server_battlenpc_spawn_locations" ("bnpcId", "customDisplayName", "groupId", "positionX", "positionY", "positionZ", "rotation") VALUES + (47, 'chinchilla', 18, 11.1, 193.8, -317.9, 0.0); +INSERT OR IGNORE INTO "server_battlenpc_spawn_locations" ("bnpcId", "customDisplayName", "groupId", "positionX", "positionY", "positionZ", "rotation") VALUES + (48, 'chinchilla', 18, 7.1, 193.8, -311.9, 0.0); + +-- Chinchilla cluster C — route point t≈0.85 (3 mobs). ROUTE-INTERPOLATED. +INSERT OR IGNORE INTO "server_battlenpc_spawn_locations" ("bnpcId", "customDisplayName", "groupId", "positionX", "positionY", "positionZ", "rotation") VALUES + (49, 'chinchilla', 18, 20.4, 198.3, -416.8, 0.0); +INSERT OR IGNORE INTO "server_battlenpc_spawn_locations" ("bnpcId", "customDisplayName", "groupId", "positionX", "positionY", "positionZ", "rotation") VALUES + (50, 'chinchilla', 18, 28.4, 198.3, -420.8, 0.0); +INSERT OR IGNORE INTO "server_battlenpc_spawn_locations" ("bnpcId", "customDisplayName", "groupId", "positionX", "positionY", "positionZ", "rotation") VALUES + (51, 'chinchilla', 18, 24.4, 198.3, -422.8, 0.0); diff --git a/common/sql/seed/097_man0u1_quicksand_cast.sql b/common/sql/seed/097_man0u1_quicksand_cast.sql new file mode 100644 index 00000000..6120945a --- /dev/null +++ b/common/sql/seed/097_man0u1_quicksand_cast.sql @@ -0,0 +1,76 @@ +-- Man0u1 "Court in the Sands" — the SEQ_000 Quicksand intro-instance +-- ambient cast (Garlemald-Server #53, branch-review follow-up). +-- +-- man0u1.lua's SEQ_000 arms MOMODI plus nine ambient NPCs with +-- dedicated talk branches (processEvent000_2..000_10 — the retail +-- "objective-gated instance" crowd), but PA (175, +-- 'PrivateAreaMasterPast', 4) shipped with ONLY Momodi (seed/031 row +-- 952) — eight of the nine classes had ZERO spawn rows anywhere and +-- Otopa Pottopa only public-175/PA-3 rows. This seed makes the armed +-- cast reachable, honouring the same "an armed class must have a spawn +-- row in the area the script expects" invariant seeds 093-096 enforce +-- for the later scenes. +-- +-- Positions: no marker rows exist for the intro crowd (the 110010xx +-- block starts at Momodi) — the nine are clustered around the Quicksand +-- tavern floor (Momodi PA-4 anchor -73.33/195/78.531; player warp-in +-- -75.242/195.009/74.572), rotations eyeballed toward the room centre; +-- all rows flagged for in-client tuning. Thancred stands beside Momodi +-- (the retail scene has him at the bar). +-- +-- classPath fills: the eight row-less classes also ship STRIPPED in +-- seed/003 (classPath '' / propertyFlags 0 / NULL eventConditions) — +-- spawning a classPath-less class is the seed/061 client hard-crash +-- family, so they get the standard populace fill (the seed/076/093 +-- recipe), guarded so a future upstream refresh with real rows wins. +-- +-- Spawn ids 2452-2460: continues the man0u1 block (093 used 2400-2441, +-- 094 used 2450-2451). uniqueId prefix 'man0u1_'. +-- +-- Idempotent: UPDATE guarded, INSERTs OR IGNORE on id. No schema change. + +UPDATE "gamedata_actor_class" SET + "classPath" = '/Chara/Npc/Populace/PopulaceStandard', + "propertyFlags" = 19, + "eventConditions" = '{ + "talkEventConditions": [ + { + "unknown1": 4, + "unknown2": 0, + "conditionName": "talkDefault" + } + ], + "noticeEventConditions": [ + { + "unknown1": 0, + "unknown2": 1, + "conditionName": "noticeEvent" + } + ] +}' +WHERE "id" IN ( + 1000936, -- Undaunted Adventurer + 1000937, -- Greedy Merchant + 1000938, -- Lionhearted Adventurer + 1000939, -- Spry Salesman + 1000940, -- Upbeat Adventurer + 1000941, -- Seemingly Calm Adventurer + 1000807, -- Overcompetitive Adventurer + 1000948 -- Thancred (Quicksand-scene variant; display borrows 1000010) +) AND "classPath" = ''; + +-- ---- PA (175, 'PrivateAreaMasterPast', 4) — the intro-instance crowd ---- + +INSERT OR IGNORE INTO "server_spawn_locations" + ("id", "actorClassId", "uniqueId", "zoneId", "privateAreaName", "privateAreaLevel", + "positionX", "positionY", "positionZ", "rotation", "actorState", "animationId", "customDisplayName") +VALUES + (2452, 1000864, 'man0u1_qs_otopa_pottopa', 175, 'PrivateAreaMasterPast', 4, -70.5, 195.0, 76.0, -2.2, 0, 0, NULL), + (2453, 1000936, 'man0u1_qs_undaunted_adventurer', 175, 'PrivateAreaMasterPast', 4, -79.0, 195.0, 70.0, 0.8, 0, 0, NULL), + (2454, 1000937, 'man0u1_qs_greedy_merchant', 175, 'PrivateAreaMasterPast', 4, -82.5, 195.0, 76.5, 1.6, 0, 0, NULL), + (2455, 1000938, 'man0u1_qs_lionhearted_adv', 175, 'PrivateAreaMasterPast', 4, -68.0, 195.0, 82.0, -2.6, 0, 0, NULL), + (2456, 1000939, 'man0u1_qs_spry_salesman', 175, 'PrivateAreaMasterPast', 4, -77.5, 195.0, 84.5, 2.9, 0, 0, NULL), + (2457, 1000940, 'man0u1_qs_upbeat_adventurer', 175, 'PrivateAreaMasterPast', 4, -71.0, 195.0, 68.5, -0.6, 0, 0, NULL), + (2458, 1000941, 'man0u1_qs_seemingly_calm_adv', 175, 'PrivateAreaMasterPast', 4, -84.0, 195.0, 70.5, 1.2, 0, 0, NULL), + (2459, 1000807, 'man0u1_qs_overcompetitive_adv', 175, 'PrivateAreaMasterPast', 4, -66.5, 195.0, 72.5, -1.8, 0, 0, NULL), + (2460, 1000948, 'man0u1_qs_thancred', 175, 'PrivateAreaMasterPast', 4, -76.5, 195.0, 80.5, 2.4, 0, 0, NULL); diff --git a/common/sql/seed/098_uldah_pa3_bundle_crash.sql b/common/sql/seed/098_uldah_pa3_bundle_crash.sql new file mode 100644 index 00000000..87eaedca --- /dev/null +++ b/common/sql/seed/098_uldah_pa3_bundle_crash.sql @@ -0,0 +1,42 @@ +-- The Ul'dah post-battle-tutorial Wine crash — the 175/PA_3 zone-in +-- bundle (Garlemald-Server #26, live playtest 2026-07-10 01:38). +-- +-- With the teardown disarm + arena stopper already fixed, the client +-- STILL died seconds into the QuestDirectorMan0u001 handoff warp +-- (DoZoneChange 184 → 175 'PrivateAreaMasterPast' 3). Packet forensics +-- against the surviving Gridania/Limsa handoffs (identical warp +-- machinery, identical player-stream opcode order) pinned the delta to +-- the DESTINATION spawn set — three seed-031 rows unique to Ul'dah's +-- PA_3: +-- +-- 1. Row 943 `mumpish_miqote`, actorClassId = 0 — the only class-0 +-- spawn row in the DB. Class 0 exists as a junk gamedata row +-- (classPath ''), so the spawn pass materialized a real actor whose +-- 0x00CC ActorInstantiate carries an EMPTY class name and script +-- path. That instantiate appears exactly FOUR times in the retained +-- packet logs (2026-06-17, 2026-07-01, 2026-07-09 ×2) and every +-- occurrence shows the same death signature (client pings stop +-- instantly, type-0x1001 close ~15 s later) — including the +-- 2026-06-17 session that survived the Gridania handoff nine +-- minutes earlier. The intended class (man0u0.lua: 1000992) is +-- documented "Unused on this client version" — delete, don't +-- reclass. (A matching engine guard now skips any empty-classPath +-- spawn row — map-server npc/spawner.rs.) +-- +-- 2. Rows 936/937 `door1`/`door2`, class 5900004 DoorStandard, with +-- NEITHER a server_eventnpc_mapobj row NOR a gamedata_actor_ +-- appearance row — the spawn builder falls to the appearance branch +-- and emits 0x00D6 with model_id 0 + all-zero slots, the documented +-- nil-deref-on-model-load Wine crash family. Zone 184's three +-- 5900004 doors all carry mapobj rows (SetActorBGProperties branch) +-- and survive; the Gridania PA door that once matched this shape +-- was removed by migration 092 for the same reason. Neither the +-- Limsa nor Gridania handoff destination ships a bare door. PA +-- containment is already handled by blocker1/blocker2 (1090372 +-- push rows) + uldah_opening_exit — mirroring 092's "never attach +-- the prop" rationale, the rows go. +-- +-- DELETE-only migration (the migration-content guard accepts these). +-- Idempotent by construction. + +DELETE FROM "server_spawn_locations" WHERE "id" IN (936, 937, 943); diff --git a/content-test/src/walkthrough.rs b/content-test/src/walkthrough.rs index 7f95deff..a6053401 100644 --- a/content-test/src/walkthrough.rs +++ b/content-test/src/walkthrough.rs @@ -38,9 +38,13 @@ //! snapshot, so nothing but this harness advances the flags between //! steps. //! -//! Drivers (`onStart`/`stateChange`/`talk`/`push`) fire a hook and reset the -//! "current step" command buffer; resumes (`ack`/`answer`) feed a simulated -//! client `EventUpdate` into the parked coroutine and *extend* the buffer. +//! Drivers (`onStart`/`stateChange`/`talk`/`push`/`emote`) fire a hook and +//! reset the "current step" command buffer; resumes (`ack`/`answer`) feed a +//! simulated client `EventUpdate` into the parked coroutine and *extend* the +//! buffer. The `emote` driver additionally resumes through `wait(n)` time +//! parks (man0u1's `onEmote` opens with `wait(2.5)`) by fast-forwarding the +//! scheduler clock and ticking the engine — the headless mirror of the live +//! game-loop ticker. //! Every quest-state command that comes back is applied to the tracked state. //! Assertions (`expect*`) check the current step's commands (or the tracked //! state) and raise a Lua error on mismatch, which the runner reports as a @@ -196,6 +200,37 @@ impl Walkthrough { Ok(()) } + /// Resume through any `wait(n)` time parks the current step left behind: + /// fast-forward every parked deadline, then tick the engine and fold the + /// resumed batches into the step buffer. This is the headless mirror of + /// the live game-loop ticker (`LuaEngine::tick` once per tick) and the + /// testkit's `advance_one_time_park`, minus the wall-clock sleep. Bounded + /// so a pathological wait-loop fails the spec instead of hanging the run. + fn drive_time_parks(&mut self) -> Result<(), String> { + const MAX_WAIT_HOPS: usize = 8; + for _ in 0..MAX_WAIT_HOPS { + let pending = self + .engine + .scheduler() + .lock() + .map(|s| s.pending_time_count()) + .unwrap_or(0); + if pending == 0 { + return Ok(()); + } + if let Ok(mut scheduler) = self.engine.scheduler().lock() { + scheduler.expire_time_parks(); + } + for (_owner, commands) in self.engine.tick() { + self.apply(&commands); + self.step.extend(commands); + } + } + Err(format!( + "coroutines still parked on wait() after {MAX_WAIT_HOPS} scheduler hops" + )) + } + /// Resume a parked coroutine with a simulated client `EventUpdate`, /// extending the current step's command buffer. fn resume(&mut self, params: &[LuaParam]) -> Result<(), String> { @@ -364,6 +399,17 @@ impl Walkthrough { )) } } + + fn expect_completed(&self, quest_id: u32) -> Result<(), String> { + if self.completed.contains(&quest_id) { + Ok(()) + } else { + Err(format!( + "expected CompleteQuest({quest_id}); completed={:?} added={:?}", + self.completed, self.added + )) + } + } } /// Resolve a quest class code to `(quest_id, default_zone_id)`. Only the three @@ -454,6 +500,24 @@ impl UserData for Walkthrough { } Ok(this) }); + // `emote(classId, "emoteDefault1")` fires the quest's `onEmote` hook + // with the Meteor calling convention `(player, quest, npc, eventName)`, + // then resumes through any `wait(n)` park the hook opened with (the + // DoEmote animation delay) so the spec observes the post-wait commands + // in the same step. + methods.add_function( + "emote", + |_, (this, class_id, event_name): (AnyUserData, u32, String)| { + { + let mut w = this.borrow_mut::()?; + let npc = w.make_npc(class_id); + w.fire("onEmote", vec![npc, QuestHookArg::Str(event_name)]) + .map_err(lua_err)?; + w.drive_time_parks().map_err(lua_err)?; + } + Ok(this) + }, + ); // --- resumes (extend the step buffer) -------------------------- methods.add_function("ack", |_, this: AnyUserData| { @@ -530,5 +594,14 @@ impl UserData for Walkthrough { .map_err(lua_err)?; Ok(this) }); + methods.add_function( + "expectCompleted", + |_, (this, quest_id): (AnyUserData, u32)| { + this.borrow::()? + .expect_completed(quest_id) + .map_err(lua_err)?; + Ok(this) + }, + ); } } diff --git a/docs/implementation/quest_man0u0_seq000_qflag_none.md b/docs/implementation/quest_man0u0_seq000_qflag_none.md index 11ab4b3c..f81728d0 100644 --- a/docs/implementation/quest_man0u0_seq000_qflag_none.md +++ b/docs/implementation/quest_man0u0_seq000_qflag_none.md @@ -159,7 +159,7 @@ Ported the same twins develop now ships for Gridania (#28) and Limsa party-added allies for HUD HP bars, `MinimumHpLock` no-die guarantees, the engagement-latch `onUpdate` battle driver (latch re-armed in `onCreate` — the VM is process-cached), and the - upstream `1090385` arena stopper prop kept. + upstream `1090385` arena stopper prop kept (later dropped — Round 7). - Seed `055_uldah_seq005_tutorial.sql`: pools/groups 8-10 + spawn rows 13-15 — Escaped Goobbue (2203301, genus 6, 500 HP per the 053 pacing rationale scaled to a single mob) and Thancred/Niellefresne @@ -170,6 +170,94 @@ Branch rebased onto develop v0.1.5 (PR #35 + the Limsa #25 routing fixes — resumed-burst login-applier routing, EventUpdate LuaParams threading — all of which this flow rides). +## Rounds 6-8 — the post-tutorial teardown crash (ported from PR #156) + +These three rounds were live-tested on the unmerged PR #156 branch +(closed 2026-06-12). The findings are load-bearing — without them a +fresh reader re-derives the teardown disarm as correct — so they are +recorded here with per-fix status against the current tree (#53 +branch re-landed the Round-7 halves; the rest landed elsewhere or was +superseded). + +### Round 6 — instant crash at the SEQ_010 warp + dead-path HUD fixes + +Retest of the Round-5 HUD work: ally names went blue (the zone-in +roster fix landed) but the party list stayed empty, the goobbue gauge +stayed empty, and the client closed INSTANTLY at the post-battle warp +(previously ~4 s after arriving in zone 175). Log forensics: + +1. **Enemy gauge — the claim sat on a dead path.** The tutorial fight + engages via `quest_apply::apply_actor_engage` (content-driver + `ActorEngage` LuaCommands), never `BattleEvent::Engage`, so the + hateType=3 + 0x0187 emission never fired. *Status: superseded — the + 2026-07-01 DepictionJudge decomp findings (dispatcher.rs) showed + hateType drives nameplate colour only and settled on hateType=2 + with no 0x0187 claim; do not re-land the PR #156 flip.* +2. **Party shrink against deleted actors.** The first session in which + the 3-member tutorial party survived to the teardown crashed when + the zone-in solo trio member-diffed 3→1 against ally actors the + client had already destroyed. *Status: superseded — the #46 round-5 + wire finding (groups.rs) proved the client registers a group id + ONCE and ignores roster re-sends under a known id; the current + fresh-ordinal group-id scheme (`party_group_ordinal` bump at + teardown) removes the member-diff hazard by construction.* +3. **Ghost QuestDirector followed the player to the city.** + ContentFinished never cleared `session.login_director`, so every + later zone-in re-spawned a director whose content group was gone. + *Status: landed via 86c8df2 (ContentFinished clears the + login-director reference), since extended.* + +### Round 7 — the teardown disarm WAS the instant crash; stopper dropped + +Retest of Round 6 WITH packet capture: the pre-despawn party shrink +and the director clear both verified on the wire — and the client +still closed instantly (zero IN packets after the burst). With those +two eliminated, the one byte-level delta common to BOTH instant-death +bursts (06:50:36.94x and 08:07:42.57x) versus the run that survived +the load is the disarm itself: the SetEventStatus disables (`0x0136` +×1 per ally + ×3 for the arena stopper) landing in the same burst as +the EndEvent + RemoveActors. pmeteor's `Npc.Despawn` never emits +disables. + +Fix, both halves pmeteor/twin-faithful (*status: re-landed on the #53 +branch*): + +- `apply_despawn_actor` reverted to RemoveActor-only (the parity note + documents the two captured instant-crashes). +- `SimpleContent30079.lua` no longer spawns the 1090385 arena stopper — + the ONLY circle-bearing prop in the content area, i.e. the source of + the original +4s ghost-"exit" crash the disarm was trying to fix. + Limsa's SimpleContent30002 (the upstream-tested twin) ships no + stopper; the engagement latch + arena geometry contain the fight. + (Gridania's 30010 still spawns 1090384 — with the disarm gone it + carries the same latent +4s ghost-circle risk after its battle; + the event/dispatcher.rs missing-owner EndEvent release may cover it, + but that path is untested against mid-warp timing. Flagged for a + follow-up issue, out of #26's scope.) + +Also confirmed this round: the 0x0187 claim DID fire at engage (type +30006) and the goobbue gauge still didn't render — the claim alone is +not the gauge/party-list activator (consistent with the later +DepictionJudge finding that the overhead gauge is a RET-stub in +1.23b). + +### Round 8 — the party panel's missing repaint trigger + +From the 1.23b client decomp corpus: the decompiled +`PartyGroupBaseClass:_onUpdateWork` repaints the desktop party panel +ONLY on a `partyGroupWork` work-sync with `sub == "_init"` (or a +`leader` tag sync). The 0x017C/D/F/E member trio updates the group +object silently. pmeteor's WORLD server answers the party group's +`/_init` with `partyGroupWork._globalTemp.owner = +(leader << 32) | 0xB36F92` (World `Party.SendInitWorkValues`); our map +server only answered content groups. *Status: HELD — the #46 +round-4/5 party-row fixes may already repaint the panel; retest +before re-landing. If re-landing: reuse `group/party.rs::pack_owner`, +gate the 0x0133 reply on `(high & 0xF000_0000) == 0x8000_0000`, and +resolve the leader via the session/registry — the PR #156 extraction +from the group id's low u32 is wrong under the current +`party_group_index` multi-member layout `(leader << 16) | ordinal`.* + ## Next test with client 1. Boot, create an Ul'dah character (Man0u0 active, SEQ_000). diff --git a/docs/implementation/quest_man0u1_court_in_the_sands.md b/docs/implementation/quest_man0u1_court_in_the_sands.md new file mode 100644 index 00000000..522fb5b6 --- /dev/null +++ b/docs/implementation/quest_man0u1_court_in_the_sands.md @@ -0,0 +1,167 @@ +# Man0u1 "Court in the Sands" (110010) — implementation design + +Issue #53. Ul'dah MSQ #2, follows Man0u0 "Flowers for All" (110009, #26). +Sibling ports used as structural templates: man0l1 (#46, Limsa) and +man0g1 (#41, Gridania). This document is the evidence-backed spec the +port was built from; every retail claim below cites its source. + +## Evidence base + +- **Retail playthroughs (OCR'd via youtube-watcher):** WNRLrwZ3BJY + + eZgcq-FMpfw (Otto Watt, Lancer, 2-part full run), pGZKU1SHi9M / + Jcv9I2Bk46w / 6eWqFhITeeM / XbDE5OQ_Y2g / cZvjV5Ilxao (Siddeon + Tergaru, 5-part full run), 4Brc8QgS4bA (Quicksand intro), + WlKVCvRgQs0 (17-cutscene compilation, verbatim dialogue). +- **Slerp Lederp dev captures:** sTXkNoVNlRc (his 1.23b Project Meteor + implementation demo — leaks the internal SEQ numbers via the debug + `Sequence: NN Class Id: NNNNNNN` onPush line and the six retail + journal objective texts, verbatim), JQDihRIvkpI (emote segment). +- **Client data:** `bahamut-client-data/lua/scripts/quest/scenario/man/man0u1.lua` + (113 event functions, text sheet 1359), `csv/man0u1.csv` (full text), + `csv/quest_marker.csv:198-220` (the 110010xx marker block — doubles + as a world-coordinate map), `csv/quest_new_reward.csv:13` (rewards). +- **Upstream:** pmeteor quest_system `man0u1.lua` (skeleton + the + phase-45/50/51/55 header table), `AetheryteParent.lua` 110010 arm. +- **Lore dumps:** Mirke Menagerie Loremonger transcript (61308-62712), + classic-wiki processEvent table (1681-1810). + +## Retail flow → sequence machine + +| SEQ | State | Advance | Client events | +|-----|-------|---------|---------------| +| 000 | Quicksand intro instance (PA 175/4). Momodi + 9 ambient NPCs. | Momodi talk → drop to public 175 | `processEvent010` (man0u110 CS), `000_2..10` ambient, `000_1` instance explainer (onNotice) | +| 005 | Run out the Gate of Nald, attune at Camp Black Brush | aetheryte touch (AetheryteParent 110010 arm — already wired) | `processEvent013` (Momodi linkpearl guildleve chain), re-talk `010_2` | +| 010 | Return to Momodi | Momodi talk | `processEvent015` ("The prodigal returns!" + "speak to me again") | +| 012 | Momodi again | Momodi talk → Coliseum Pass 11000126 toast | `processEvent017` (marks Eshtaime's + Coliseum, gives pass); re-talks `017_2/3/4` | +| 015 | Parallel guild legs, counters GSM + GLD | both legs done → NpcLS glow → LS read → 045 | GSM: Elecotte `020` (Niellefresne echo CS, +2,000 gil), re-talk `020_2`. GLD: Yoyobina `030` (man0u130 recruit CS) → GLD lobby PA → Lulutsu `030_2` (pass check) → arena trigger push → `contentsJoinAskInBasaClass` → **coliseum content** (Man0u101) → defeat → `035` (splendid-match CS) → echo PA → Greinfarr `040` (gathering CS) → back to public. Ambient `030_3..11`, `032_2/3/4`, `1000_1/2` | +| 045 | Go to Amajina & Sons | Linette talk | `processEvent050` (man0u150 brawl-intro CS) → miner scene PA | +| 050 | Miner instance #1 — F'lhaminn teaches 6 emotes | 6 emotes done → 057 | teach `051_1..6` (Furious 103, Beckon 108, Laugh 121, Deny 125, Upset 140, Soothe 135 — in order), forgot-replay `051_7(quest,player,npc,51..56)`, go-signal `051_8`; crowd `050_2..14` | +| 057 | Calm the miners: Maddened = Beckon; Manic = Soothe→Furious→Laugh | both calm → ending says (109,107,108,110,111,112,113) → 058 | rebuffs Manic `055_1..3`, Maddened `056_1..3` | +| 058 | Miner instance #2 (reload) — Corguevais recovered | Corguevais talk | `processEvent060` (man0u160 CS: thanks + Linette assignment + F'lhaminn volunteers) → public | +| 060 | Meet F'lhaminn at the Gate of Nald (zone 170) | gate trigger push → confirm → **escort content** (Man0u102) | gate talks (says 144/145/146), duty-start `processEvent070` (man0u170 CS) | +| — | Escort duty: protect F'lhaminn, Gate of Nald → Camp Black Brush. 30-min timer, chinchilla (2204010) ambushes. Runs at SEQ 060 (relog → gate re-arms → retry, no rollback state needed) | arrival → director | banners 51005/50011/25018; barks 365-370/374/375; fail 371/372 | +| 065 | Camp Black Brush (public 170) — the Ascilia echo | Ascilia talk → `080` echo CS → same-zone reload; then camp leader talk → says 176-183 → warp Ul'dah | `processEvent075` (arrival CS, fired by the escort director), `080` (Ascilia/Thancred echo CS), crowd `080_2..12` | +| 070 | Momodi gossip | Momodi talk | `processEvent200_2` (texts 389/390) | +| 075 | F'lhaminn at the Concern stage — escort payment | F'lhaminn talk → +3,000 gil → NpcLS glow → LS read → 080 | `processEvent200` (man0u200 CS, in-place fade — safe) | +| 080 | Frondale's Phrontistery | Nogeloix talk | `processEvent205` (man0u205 CS, in place); re-talk `210_2` | +| 085 | Master Faustigeant | Faustigeant talk → ward door armed | `processEvent210` (man0u210 CS, in place) | +| 090 | Sickroom ward instance (PA 209/5 at the Phrontistery) | ward door push → Warburton echo | ward talks `210_3..8`; `processEvent220` (man0u220 echo CS) | +| 095 | Report to Faustigeant (no pay) | Faustigeant talk → NpcLS glow | `processEvent230` (man0u230 CS, texts 218/219) | +| 100 | Momodi's linkpearl call | LS read → 105 | (server NpcLS texts 307/308) | +| 105 | Turn-in at Momodi | `processEventComplete` + `sqrwa 300,1,1,2` + 200 EXP + 6,000 gil + CompleteQuest | | + +SEQ_110 is declared upstream but unused (completion happens at 105, +matching man0g1's shape and Slerp's capture where `Sequence: 105` is +the last pre-completion state). + +### NpcLS (linkpearl) message packs — man0u1 sheet ids, sender Momodi (1500014) + +1. Post-guilds (SEQ_015 both legs done) → `{301, 302, 303}` (Amajina + recruiting pitch — verbatim in the retail call) → `SEQ_045`. +2. Cave-in news (SEQ_075 after payment) → `{184, 185, 186}` → `SEQ_080`. +3. Come-see-me (SEQ_100) → `{307, 308}` → `SEQ_105`. + +### Rewards (client `quest_new_reward.csv` row 110010 + retail toasts) + +- Completion: **200 EXP + 6,000 gil** (`"You obtain 6,000 [gil]"` on + camera, cZvjV5Ilxao 08:30; the 6,000/200 pair is also in Slerp's + journal Reward block). +- Mid-quest: **2,000 gil** (Niellefresne, flower — text 44 + retail + toast) and **3,000 gil** (F'lhaminn, escort — retail toast + XbDE5OQ_Y2g 10:17). +- Key items: Velodyna Cosmos **11000089** (from man0u0), Coliseum Pass + **11000126** (granted at `processEvent017`, toast 25117). + +## Instance architecture + +Retail runs seven "instances" in this quest. Ports: + +| Retail instance | Garlemald mechanism | +|---|---| +| Quicksand intro | PA (175, PrivateAreaMasterPast, 4) — already exists | +| GLD lobby + post-match echo | PA (209, PrivateAreaMasterPast, 5) at the Coliseum coords | +| Coliseum arena fight | **content area** `Man0u101` / `SimpleContentMan0u101` / `Quest/QuestDirectorMan0u101` on zone 209 | +| Miner instances #1/#2 | PA (209, PrivateAreaMasterPast, 5) at the Concern coords | +| Escort | **content area** `Man0u102` / `SimpleContentMan0u102` / `Quest/QuestDirectorMan0u102` on zone 170 | +| Camp Black Brush echo | public zone 170 (no PA exists client-side for wil0Fld01 that we can prove; SetENpc gates the cast per-player) | +| Phrontistery ward | PA (209, PrivateAreaMasterPast, 5) at the Phrontistery coords | + +PA (209, PrivateAreaMasterPast, 5) is the only Ul'dah-half PA +registered upstream (pmeteor uses it for the pgl200 echo — so the +client provably loads it). All three scene groups share it at disjoint +coordinates; SetENpc arming keeps the casts sequence-scoped. + +The client-side `QuestDirectorMan0u101/102` are 8-line empty shells +(same as the shipped Man0l101/Man0g101/102) — all direction is +server-side. + +### Coliseum fight (unwinnable by design) + +Retail: "You have entered an instance" → *"Duty calls. Do you wish to +proceed with 'Court in the Sands'?"* → arena vs **Tourney Gladiator** +(class 2280157, display 3280156; humanoid — Greinfarr killed the +tourney beasts) → the player IS defeated ("Otto/Siddeon is defeated.") +→ "A splendid match! … Don't let losing get to you." Mirke footnote: +*"There is no way to win."* Uploader: the gladiator is immortal; you +lose by KO or timeout. + +Port: the gladiator gets `MinimumHpLock` (can't die); the player gets +`MinimumHpLock` too (no real KO/death UI). The content script treats +player-HP==1 (or a 5-minute timeout) as the loss: emits the worldMaster +" is defeated." line (30121 family), signals the director, which +plays `processEvent035`, bumps the GLD counter, tears down, and warps +to the echo PA. Loss is the completion condition. + +### Emote machinery + +A player emote reaches the quest `onEmote` hook only when the TARGET's +actor class carries a matching `emoteEventConditions` entry (seed/080 +finding). Emote ids are the 100-range EmoteStandardCommand table ids; +the F'lhaminn client-side demo schedulers (0x5000000|((id-100)<<12)) +confirm: Furious 103, Beckon 108, Laugh 121, Deny 125, Upset 140, +Soothe 135. `DoEmote(npc, id-100, 21000+(id-101)*10+11)` plays the +player's animation + motion line (formula cross-checked against four +sibling data points: 105→(5,21041), 107→(7,21061), 118→(18,21171), +116→(16,21151)). + +- F'lhaminn (teach): all six as emoteDefault1..6 (teach order). +- Manic + Maddened Miner: same six conditions; the script checks the + step — Maddened wants Beckon (emoteDefault2), Manic wants + Soothe→Furious→Laugh (emoteDefault6→1→3); wrong taught-emote → + rebuff bark (`055_x`/`056_x`); untaught emotes never fire (retail + behavior — the free EmoteStandardCommand handles them). + +## Journal map markers (client quest_marker.csv 110010xx block) + +11001001 Momodi (-73.4, 78.5) · 11001002 Camp Black Brush (34.8, +-480.5, field map) · 11001005 Eshtaime's/Elecotte (-131.5, 257.0) · +11001006 Coliseum (-185.3, 179.7) · 11001009 Linette (-92.4, 313.4) · +11001010/13 F'lhaminn scene (-96.7, 313.2 / -106.2, 318.0) · 11001011 +Manic (-108.4, 326.5) · 11001012 Maddened (-107.5, 324.6) · 11001014 +Gate of Nald muster (-34.5, -68.9, field map) · 11001015/16 camp +leader (41.1/50.4, -480.0/-481.0) · 11001017 Concern stage (-91.4, +323.2) · 11001018 Phrontistery (-211.7, 279.0) · 11001019 ward door +(-216.4, 302.1). These x/z pairs are literal world coordinates +(verified: 11001009 == Linette's spawn row, 11001018 == Nogeloix's). + +## Live-test checkpoints (unprovable statically) + +1. `processEvent220` (Warburton echo) is followed by a same-PA reload + to resolve its after-warp fade; retail appears to leave the player + free in the same instance. If the reload flickers badly, try the + in-place `startFadeInCutSceneDefault` neutralizer recipe instead. +2. The gathering echo (`processEvent040`) fires from a Greinfarr talk + in the echo PA; retail auto-played it on arrival. If auto-play is + wanted, stage an AfterQuestWarpDirector kick at the 035→echo warp. +3. `processEvent075` vs `080` split: 075 is fired by the escort + director as the arrival cutscene, 080 by the Ascilia talk at camp. + If 075 renders as the wrong scene, swap them (both are + fadeInAfterWarp cutscene drivers). +4. Camp echo runs in PUBLIC zone 170 — the story cast (Ascilia, + Corguevais, Thancred, camp crowd) is seeded there and visible to + passers-by. Retail instanced this; acceptable divergence for now. +5. Coliseum "duty party" — retail party list showed the player + + Abylgo Hamylgo + Papawa during the pre-fight instance. The port + keeps the arena solo; cosmetic divergence. +6. DoEmote motion-message ids for Laugh/Deny/Upset/Soothe + (21201/21241/21391/21341) are formula-derived, not capture-proven. diff --git a/map-server/src/lua/scheduler.rs b/map-server/src/lua/scheduler.rs index b50ffbfc..395963ee 100644 --- a/map-server/src/lua/scheduler.rs +++ b/map-server/src/lua/scheduler.rs @@ -304,6 +304,21 @@ impl CoroutineScheduler { self.sleeping_on_time.iter().map(|(t, _)| *t).min() } + /// Testkit-only: fast-forward every time-parked deadline to *now* so a + /// following [`super::LuaEngine::tick`] resumes straight through script + /// `wait(n)` calls without wall-clock sleeping. The content-test + /// walkthrough bridge drives quest hooks that park on time (e.g. + /// man0u1's `onEmote` → `wait(2.5)`) through this — the same + /// expire-then-tick shape as `testkit::advance_one_time_park`, minus + /// the real sleep. + #[cfg(feature = "testkit")] + pub fn expire_time_parks(&mut self) { + let now = common::utils::millis_unix_timestamp(); + for (deadline, _) in self.sleeping_on_time.iter_mut() { + *deadline = now; + } + } + pub fn pending_signal_count(&self) -> usize { self.sleeping_on_signal.values().map(|v| v.len()).sum() } diff --git a/map-server/src/lua/userdata.rs b/map-server/src/lua/userdata.rs index 5bf9bd6f..0a9559a1 100644 --- a/map-server/src/lua/userdata.rs +++ b/map-server/src/lua/userdata.rs @@ -5511,19 +5511,33 @@ impl UserData for LuaQuestDataHandle { ); Ok(()) }); - methods.add_method("SetCounter", |_, this, (idx, value): (u32, u32)| { + // The counter mutators follow the same write-through-the-mirror rule + // as SetFlag/ClearFlag above: the queued command owns persistence, + // but the in-VM `this.counters` copy must update IMMEDIATELY so a + // script that mutates then re-reads the same counter in the SAME + // hook call sees the new value. Without it, man0u1's SEQ_057 calming + // check (`data:IncCounter(CNTR_MANIC_CALM)` … then + // `data:GetCounter(CNTR_MANIC_CALM) >= #MANIC_SEQUENCE`) read the + // pre-inc snapshot and the both-becalmed advance never fired — + // the exact counter twin of the #41 SetFlag/GetFlags bug. + methods.add_method_mut("SetCounter", |_, this, (idx, value): (u32, u32)| { + let clamped = value.min(u16::MAX as u32) as u16; + let idx_u = idx as usize; + if idx_u < this.counters.len() { + this.counters[idx_u] = clamped; + } push( &this.queue, LuaCommand::QuestSetCounter { player_id: this.player_id, quest_id: this.quest_id, idx: idx as u8, - value: value.min(u16::MAX as u32) as u16, + value: clamped, }, ); Ok(()) }); - methods.add_method("IncCounter", |_, this, idx: u32| { + methods.add_method_mut("IncCounter", |_, this, idx: u32| { // DIAGNOSTIC (Garlemald-Server #46): confirm the binding runs on // resume AND log the queue Arc it pushes to, so we can compare it // against the queue drained by fire_player_event_and_drain. If the @@ -5542,18 +5556,18 @@ impl UserData for LuaQuestDataHandle { idx: idx as u8, }, ); - // Meteor's `IncCounter` returns the post-inc value; without a - // live read of the mutated counter we echo the snapshot+1 so - // scripts comparing against the return value see a reasonable - // number. The processor applies the real wrapping increment. + // Meteor's `IncCounter` returns the post-inc value. The mirror + // update keeps same-call `GetCounter` reads consistent; the + // processor still applies the real persisted increment. let idx_u = idx as usize; if idx_u < this.counters.len() { - Ok(this.counters[idx_u].wrapping_add(1)) + this.counters[idx_u] = this.counters[idx_u].wrapping_add(1); + Ok(this.counters[idx_u]) } else { Ok(0u16) } }); - methods.add_method("DecCounter", |_, this, idx: u32| { + methods.add_method_mut("DecCounter", |_, this, idx: u32| { push( &this.queue, LuaCommand::QuestDecCounter { @@ -5564,7 +5578,8 @@ impl UserData for LuaQuestDataHandle { ); let idx_u = idx as usize; if idx_u < this.counters.len() { - Ok(this.counters[idx_u].wrapping_sub(1)) + this.counters[idx_u] = this.counters[idx_u].wrapping_sub(1); + Ok(this.counters[idx_u]) } else { Ok(0u16) } diff --git a/map-server/src/npc/spawner.rs b/map-server/src/npc/spawner.rs index ff22e2c3..f0a9529c 100644 --- a/map-server/src/npc/spawner.rs +++ b/map-server/src/npc/spawner.rs @@ -114,6 +114,22 @@ impl SpawnContext<'_> { let Some(class) = self.actor_classes.get(&seed.class_id) else { continue; }; + // A class with an EMPTY classPath ships a 0x00CC + // ActorInstantiate with no class name and no client-script + // path — the 1.23b client dies parsing it (the #26 + // post-tutorial crash: spawn row 943's class-0 + // `mumpish_miqote` in the 175/PA_3 bundle, 4/4 lethal + // across packet history). seed/098 deletes the known rows; + // this guard covers the whole family. + if class.class_path.is_empty() { + tracing::warn!( + class = seed.class_id, + unique = %seed.unique_id, + zone = seed.zone_id, + "spawn skipped — empty classPath (client-fatal instantiate)", + ); + continue; + } let actor_number = next_number; next_number += 1; let is_battle = self.battle_class_ids.contains(&seed.class_id); diff --git a/map-server/src/runtime/quest_apply.rs b/map-server/src/runtime/quest_apply.rs index 0b2fc9b3..21faef60 100644 --- a/map-server/src/runtime/quest_apply.rs +++ b/map-server/src/runtime/quest_apply.rs @@ -1467,37 +1467,18 @@ pub(crate) async fn apply_despawn_actor( let Some(zone_arc) = world.zone(zone).await else { return; }; - // Disarm the actor's event-condition triggers BEFORE the remove. - // The 1.23b client does NOT clear armed push circles when an actor - // is deleted — live-confirmed in the Ul'dah opener teardown (#26): - // the arena fence's outwards "exit" circle (radius 40) survived the - // stopper's RemoveActor, fired against the ghost when the SEQ_010 - // warp repositioned the player >40y away, and the orphan event - // exchange (client EventStart vs our EndEvent echoes, mid - // zone-change) hard-crashed the client ~3 s into the warp. - let conditions = { - let c = handle.character.read().await; - c.base.event_conditions.clone() - }; - if !conditions.is_empty() { - for sub in crate::packets::send::build_actor_event_status_packets( - actor_id, - &conditions, - false, - false, - Some(false), - false, - ) { - crate::runtime::broadcast::broadcast_around_actor( - world, - registry, - &zone_arc, - actor_id, - sub.to_bytes(), - ) - .await; - } - } + // pmeteor parity: despawn is RemoveActor ONLY — no SetEventStatus + // disables first. A disarm pass was tried here (#26, the arena + // fence's ghost "exit" circle surviving RemoveActor) and it + // hard-closed the client INSTANTLY in two captured runs: the + // event-status disables landing in the same burst as the + // EndEvent + teardown removes are the one byte-level delta vs the + // run that survived the warp (packet forensics 2026-06-12, bursts + // at 06:50:36.94x and 08:07:42.57x vs the surviving load — PR #156 + // Round 7). The ghost-circle problem is solved at the source + // instead: content areas must not spawn circle-bearing props + // (SimpleContent30079's stopper dropped — Limsa's 30002, the + // upstream-tested twin, never had one). let sub = crate::packets::send::actor::build_remove_actor(actor_id); let recipients = crate::runtime::broadcast::broadcast_around_actor( world, diff --git a/scripts/lua/content/SimpleContent30079.lua b/scripts/lua/content/SimpleContent30079.lua index b799e628..b4000746 100644 --- a/scripts/lua/content/SimpleContent30079.lua +++ b/scripts/lua/content/SimpleContent30079.lua @@ -46,9 +46,15 @@ function onCreate(starterPlayer, contentArea, director) thancred:SetMod(modifiersGlobal.MinimumHpLock, 1); niellefresne:SetMod(modifiersGlobal.MinimumHpLock, 1); - -- Arena fence prop, kept from the upstream import (same role as - -- Gridania's 1090384 stopper inside SimpleContent30010). - openingStoper = contentArea:SpawnActor(1090385, "openingstoper", -24.34, 192, 34.22, 0); + -- NO arena fence prop. The upstream import spawned 1090385 here + -- ("openingstoper") and its armed push circles outlived the + -- teardown's RemoveActor on the 1.23b client: the orphan "exit" + -- circle fired against the ghost after the SEQ_010 warp (client + -- crash ~4s into zone 175), and the disarm-before-remove attempt + -- crashed the client even earlier (see apply_despawn_actor's + -- parity note). Limsa's SimpleContent30002 — the upstream-tested + -- twin — ships no stopper either; the engagement latch plus the + -- tiny arena already contain the fight. (#26, PR #156 Rounds 6-7.) director:AddMember(starterPlayer); director:AddMember(director); diff --git a/scripts/lua/content/SimpleContentMan0u101.lua b/scripts/lua/content/SimpleContentMan0u101.lua new file mode 100644 index 00000000..0f58364e --- /dev/null +++ b/scripts/lua/content/SimpleContentMan0u101.lua @@ -0,0 +1,154 @@ +require ("global") +require ("modifiers") +-- onUpdate drives engagement through allyGlobal.EngageTarget; without +-- this require the global is nil and every tick errors (swallowed by +-- the ticker drain). Same shape as the sibling content scripts. +require ("ally") +-- SEQ_* constants (proven loadable outside the quest runtime — the +-- sibling directors/content require their quest scripts the same way). +require ("quests/man/man0u1") + +-- Man0u1 SEQ_015 — the Coliseum first-round match (Garlemald-Server +-- #53). Retail (both full playthroughs + the beta uploader's notes): +-- a 1v1 on the bloodsands vs the TOURNEY GLADIATOR — an immortal +-- humanoid (Light Stab / Heavy Stab / Red Lotus / Light Slash on +-- film) — the player LOSES by KO or timeout ("Otto/Siddeon is +-- defeated."), and the loss IS the quest's completion condition +-- (Mirke Loremonger footnote: "There is no way to win."). +-- +-- Port: the gladiator carries MinimumHpLock (unkillable, retail), and +-- the PLAYER does too (garlemald's death/return flow isn't wired for +-- content — the opener-tutorial precedent). "Defeat" is detected as +-- the player pinned at the 1-HP floor (or the round timer expiring), +-- which emits the retail defeat line and hands the completion beat to +-- QuestDirectorMan0u101 (post-match cutscene + teardown). + +TEXT_BOUND_BY_DUTY = 50011; -- "You are now bound by duty." + +TICKS_PER_SECOND = 2; +-- No timer line was OCR'd in either retail arena capture; five +-- minutes is a generous stand-in for the "timing out" loss path the +-- beta uploader describes. +ROUND_LIMIT_TICKS = 5 * 60 * TICKS_PER_SECOND; +ARENA_DUTY_MUSIC = 37; -- the 1.x duty theme (sibling escorts) + +-- Per-player fight state (the VM is process-cached per script path). +arenaState = {}; + +function onCreate(starterPlayer, contentArea, director) + arenaState[starterPlayer.actorId] = { + done = false, + startTick = nil, + bannerShown = false, + gladiatorId = nil, + }; + + -- The Tourney Gladiator (class 2280157, display 3280156 — + -- bahamut gamedata; seed/096 spawn row). + local gladiator = GetWorldManager().SpawnBattleNpcById(42, contentArea); + arenaState[starterPlayer.actorId].gladiatorId = gladiator.actorId; + + -- Hostile battle stance; immortal (retail: "the gladiator is + -- immortal, you will lose"). + gladiator:ChangeState(2); + gladiator:SetMod(modifiersGlobal.MinimumHpLock, 1); + + -- The player's no-die floor — pinned there = the loss (see + -- onUpdate). + starterPlayer:SetMod(modifiersGlobal.MinimumHpLock, 1); + + director:AddMember(starterPlayer); + director:AddMember(director); + director:AddMember(gladiator); +end + +-- Load-gap-SAFE music only; the banners live in onUpdate's +-- first-sighting latch (0x0157 messages shipped into the Now-Loading +-- gap crash the client). +function onZoneIn(player, contentArea, director) + player:ChangeMusic(ARENA_DUTY_MUSIC); +end + +function onDestroy() +end + +function onUpdate(tick, area) + if not area then return end + local players = area:GetPlayers() + local mobs = area:GetMonsters() + + local owner = nil + for player in players do + if player then owner = owner or player end + end + if not owner then return end + local state = arenaState[owner.actorId] + if not state or state.done then return end + + local gladiator = nil + for i = 1, #mobs do + local mob = mobs[i] + if mob and mob.actorId == state.gladiatorId then + gladiator = mob + end + end + if not gladiator then return end + + -- First-sighting latch: the duty banner, then the gladiator opens + -- on the player (retail: he engages immediately — the match starts + -- the moment the sands load). + if not state.bannerShown then + state.bannerShown = true; + state.startTick = tick; + owner:SendGameMessage(GetWorldMaster(), TEXT_BOUND_BY_DUTY, 0x20); + end + if not gladiator:IsEngaged() then + allyGlobal.EngageTarget(gladiator, owner); + end + + -- The loss: the player pinned at the MinimumHpLock floor, or the + -- round expiring. Either way the retail defeat line fires and the + -- director takes over (post-match cutscene + teardown) — losing IS + -- completing. + local elapsed = tick - (state.startTick or tick); + if (owner:GetHP() <= 1 or elapsed >= ROUND_LIMIT_TICKS) then + state.done = true; + -- Pacify the gladiator IN the completion tick: the sibling + -- escorts structurally forbid a cutscene landing in the same + -- drain as live combat packets (the arrival gate is + -- nearLive==0 + not anyEngaged; the director's wait(2) + -- render-settle exists for exactly this). The arena's loss + -- happens mid-combat by definition, so the disengage must be + -- explicit — civilian MainState stops the swings before the + -- director's kick + processEvent035 go out. (Branch review + -- finding, 2026-07-09.) + gladiator:ChangeState(0); + -- Retail revives the loser for the post-match scene ("Otto is + -- defeated." then straight to Greinfarr, no weakness). Restore + -- before the teardown clears the 1-HP pin — a 1-HP exit would + -- persist into open-world zone 170 later in the quest. + owner:SetHP(owner:GetMaxHP()); + -- Defeat line. Retail renders " is defeated." through + -- the client's own KO machinery, which this pinned loss never + -- triggers; 30121 with a runtime actor id renders a BLANK + -- subject (the #199 DispId-sender finding), and player names + -- have no static sheet id — so the literal-fallback shape + -- (the sibling escorts' undecoded-bark recipe) carries the + -- beat until a live probe finds the right wire family. + owner:SendMessage(0x20, "", "You are defeated."); + sendSignal("coliseumFought"); + return; + end +end + +-- Leave-duty teardown: retail offers no abandon mid-match, but the +-- command surface can still fire it — treat it as the loss (the fight +-- cannot be failed harder than losing). +function onAbort(player, contentArea, director) + local state = arenaState[player.actorId]; + if state == nil or state.done then + return; + end + state.done = true; + sendSignal("coliseumFought"); +end diff --git a/scripts/lua/content/SimpleContentMan0u102.lua b/scripts/lua/content/SimpleContentMan0u102.lua new file mode 100644 index 00000000..0e09b5d4 --- /dev/null +++ b/scripts/lua/content/SimpleContentMan0u102.lua @@ -0,0 +1,359 @@ +require ("global") +require ("modifiers") +-- onUpdate drives mob engagement through allyGlobal.EngageTarget; +-- without this require the global is nil and every tick errors +-- (swallowed by the ticker drain). Same shape as the sibling escorts. +require ("ally") +-- SEQ_* constants (proven loadable outside the quest runtime). +require ("quests/man/man0u1") + +-- Man0u1 SEQ_060 — the F'lhaminn escort, Gate of Nald → Camp Black +-- Brush (Garlemald-Server #53). Re-instantiation of the wire-proven +-- man0l1/man0g1 escort machinery (SimpleContentMan0l101/Man0g101) +-- with Ul'dah parameters. +-- +-- Retail shape (both full playthroughs): a 30-minute duty on zone 170 +-- — "You have entered an instance. / You are now bound by duty. / +-- Protect F'lhaminn from harm. / There are 30 minutes remaining." — +-- chinchilla ambushes that one-shot-die at opener levels (60-80 dmg +-- Light Slash kills, 511-650 EXP each on film); F'lhaminn barks +-- between waves (man0u1 QUEST-sheet texts 365-370/374/375, decoded +-- from csv/man0u1.csv); on her wounding, the retreat-and-retry fail +-- (371/372 — "let us beat the retreat... The camp will wait for us"). +-- +-- F'lhaminn FOLLOWS the player (the sibling player-leads model); the +-- route reference below is marker-anchored interpolation — gate +-- (-34.5, -68.9) → camp (34.8, -480.5), y from the seed-031 zone-170 +-- anchors — flagged for the RECORD_MODE re-capture pass the siblings +-- got (#41/#46). + +-- worldMaster duty-block text ids (sheet-decoded during the man0l1 +-- escort work). +TEXT_PROTECT_ESCORTEE = 51005; -- "Protect from harm." +TEXT_BOUND_BY_DUTY = 50011; -- "You are now bound by duty." +TEXT_UNBOUND_FROM_DUTY = 50012; -- "You are no longer bound by duty." +TEXT_TIME_REMAINING = 25018; -- "There ~is/are~ ~minute/minutes~ remaining." +TEXT_MOB_ENGAGED = 30120; -- " is engaged." +TEXT_MOB_DEFEATED = 30121; -- " is defeated." + +-- Escort pacing / geometry — the man0l1/man0g1 constants verbatim. +RUN_STEP = 3.4; +HOLD_RADIUS = 28.0; +ENGAGE_RADIUS = 18.0; +WAYPOINT_RADIUS = 4.0; +ARRIVAL_RADIUS = 20.0; +LEAD_DISTANCE = 5.0; +TICKS_PER_SECOND = 2; +ESCORT_LIMIT_MINUTES = 30; -- "There are 30 minutes remaining." on film +ESCORT_LIMIT_TICKS = ESCORT_LIMIT_MINUTES * 60 * TICKS_PER_SECOND; +REMIND_20MIN_TICKS = 10 * 60 * TICKS_PER_SECOND; +REMIND_10MIN_TICKS = 20 * 60 * TICKS_PER_SECOND; +REMIND_5MIN_TICKS = 25 * 60 * TICKS_PER_SECOND; +BARK_INTERVAL_TICKS = 40; +ESCORT_DUTY_MUSIC = 37; +ESCORT_RING_CLASS = 1290003; -- minimap duty halo (seed/073 gamedata row) +RING_MOVE_TICKS = 1; +RECORD_MODE = false; -- one-shot trail-capture toggle (see Man0g101) + +-- Chinchilla — class 2204010, displayNameId 3204010 (bahamut +-- gamedata; the retail combat log names "chinchilla" throughout). +CHINCHILLA_DISPLAY_ID = 3204010; + +-- Route reference: gate → camp, marker-anchored linear interpolation +-- (y anchors: gate ≈183, camp = 201 from the seed-031 zone-170 rows). +-- Reference + ambush/arrival anchor only — F'lhaminn follows the +-- player, she does not trace it. +ARRIVAL_GOAL = { x = 34.8, y = 201.0, z = -480.5 }; +WARP_IN = { x = -34.5, y = 183.0, z = -68.9, rot = -3.0 }; +GATE_EJECT = { x = WARP_IN.x, y = WARP_IN.y, z = WARP_IN.z, rot = WARP_IN.rot }; + +-- F'lhaminn's march/outcome barks — man0u1 QUEST-sheet say ids (all +-- verbatim on film; SendGameMessage against the quest sheet is the +-- man0l1 seq007_endSequence delivery shape). +SAY_DANGERS_AHEAD = 365; -- "There may be dangers along the way..." +SAY_NOT_TOO_STRONG = 366; -- "It doesn't appear to be too strong. What say you?" +SAY_WELL_DONE = 367; -- "Well done! I expected nothing less." +SAY_AMAZING = 368; -- "Amazing! ... Come, let us push on." +SAY_ARE_YOU_HURT = 369; -- "My thanks. Tell me, are you hurt?" +SAY_ATTACKED_AGAIN = 370; -- "The gods are not with us this day..." +SAY_IM_HURT = 371; -- "I-I'm hurt. My wounds are...grievous, I fear." +SAY_RETREAT = 372; -- "...let us beat the retreat... The camp will wait for us." +SAY_SO_CLOSE = 374; -- "We are close. So very close..." +SAY_JUST_AHEAD = 375; -- "There! It is just ahead!" + +-- Per-player escort state, keyed by actor id (the VM is process-cached +-- per script path). +escortState = {}; + +function onCreate(starterPlayer, contentArea, director) + escortState[starterPlayer.actorId] = { + done = false, + startTick = nil, + lastBarkTick = nil, + lastRingTick = nil, + lastNear = 0, + reminded20 = false, + reminded10 = false, + reminded5 = false, + sawEscortee = false, + calledNearGoal = false, + saidSoClose = false, + mobsLive = {}, + mobsEngaged = {}, + ringActorId = nil, + flhaminnId = nil, + }; + + -- Zone-170 spawns (seed/096): 43 = F'lhaminn, 44-51 = chinchillas + -- spaced along the gate→camp route. + local flhaminn = GetWorldManager().SpawnBattleNpcById(43, contentArea); + escortState[starterPlayer.actorId].flhaminnId = flhaminn.actorId; + local mobs = {}; + for bnpcId = 44, 51 do + table.insert(mobs, GetWorldManager().SpawnBattleNpcById(bnpcId, contentArea)); + end + + -- Minimap duty halo riding F'lhaminn. + local ring = contentArea:SpawnActor(ESCORT_RING_CLASS, "escortAreaRange", WARP_IN.x, WARP_IN.y, WARP_IN.z, 0); + escortState[starterPlayer.actorId].ringActorId = ring.actorId; + + -- F'lhaminn is a civilian who never fights (retail: only the + -- player attacks); civilian idle avoids the weaponless-model + -- T-pose (the man0g1 kids finding). The chinchillas render + -- hostile. + flhaminn:ChangeState(0); + for i = 1, #mobs do + mobs[i]:ChangeState(2); + end + + -- The player keeps the tutorial-style 1-HP floor. F'lhaminn takes + -- real damage — her wounding is the retail fail condition + -- ("I-I'm hurt... let us beat the retreat"). + starterPlayer:SetMod(modifiersGlobal.MinimumHpLock, 1); + + director:AddMember(starterPlayer); + director:AddMember(director); + director:AddMember(flhaminn); + director:AddMember(ring); + for i = 1, #mobs do + director:AddMember(mobs[i]); + end +end + +-- Load-gap-SAFE music only; the entry banners live in onUpdate's +-- first-sighting latch. +function onZoneIn(player, contentArea, director) + player:ChangeMusic(ESCORT_DUTY_MUSIC); +end + +function onDestroy() +end + +local function dist2d(ax, az, bx, bz) + local dx = ax - bx; + local dz = az - bz; + return math.sqrt(dx * dx + dz * dz); +end + +local function sayFlhaminn(owner, sayId) + local quest = owner:GetQuest("Man0u1"); + owner:SendGameMessage(quest, sayId, 0x20); +end + +-- FAIL flow (F'lhaminn wounded / 30-minute expiry / confirmed +-- leave-duty): her retreat barks, unbind, teardown, eject to the gate. +-- The sequence is still SEQ_060 — its onStateChange re-arms the gate +-- trigger, so the duty is retryable immediately (retail: "Do not +-- despair. The camp will wait for us."). +local function escortFail(owner, area, state) + state.done = true; + sayFlhaminn(owner, SAY_IM_HURT); + sayFlhaminn(owner, SAY_RETREAT); + owner:SendGameMessage(GetWorldMaster(), TEXT_UNBOUND_FROM_DUTY, 0x20); + -- ContentFinished BEFORE the warp-out (tutorial teardown order). + area:ContentFinished(); + GetWorldManager():WarpToPublicArea(owner, GATE_EJECT.x, GATE_EJECT.y, GATE_EJECT.z, GATE_EJECT.rot); + -- Drain the director coroutine parked on "man0u1EscortComplete" so + -- a stale park can't double-fire the arrival flow on a retry. + sendSignal("man0u1EscortComplete"); +end + +function onUpdate(tick, area) + if not area then return end + local players = area:GetPlayers() + local mobs = area:GetMonsters() -- live-only (dead filtered) + local allies = area:GetAllies() + + local owner = nil + for player in players do + if player then owner = owner or player end + end + if not owner then return end + local state = escortState[owner.actorId] + if not state or state.done then return end + + -- ---- Timer (the 30-minute ladder, on film for this duty) ---- + state.startTick = state.startTick or tick; + local elapsed = tick - state.startTick; + if elapsed >= ESCORT_LIMIT_TICKS then + escortFail(owner, area, state); + return; + end + if not state.reminded20 and elapsed >= REMIND_20MIN_TICKS then + state.reminded20 = true; + owner:SendGameMessage(GetWorldMaster(), TEXT_TIME_REMAINING, 0x20, 20); + end + if not state.reminded10 and elapsed >= REMIND_10MIN_TICKS then + state.reminded10 = true; + owner:SendGameMessage(GetWorldMaster(), TEXT_TIME_REMAINING, 0x20, 10); + end + if not state.reminded5 and elapsed >= REMIND_5MIN_TICKS then + state.reminded5 = true; + owner:SendGameMessage(GetWorldMaster(), TEXT_TIME_REMAINING, 0x20, 5); + end + + -- ---- Escortee roster + wounding-fail (rosters are live-only) ---- + local flhaminn = nil; + for i = 1, #allies do + local ally = allies[i]; + if ally and ally.actorId == state.flhaminnId then + flhaminn = ally; + end + end + if (flhaminn ~= nil) then + if not state.sawEscortee then + -- First live sighting = the duty is underway. Retail entry + -- order: protect banner → bound-by-duty → timer → her + -- "dangers along the way" opener. + owner:SendGameMessage(GetWorldMaster(), TEXT_PROTECT_ESCORTEE, 0x20, flhaminn.actorId); + owner:SendGameMessage(GetWorldMaster(), TEXT_BOUND_BY_DUTY, 0x20); + owner:SendGameMessage(GetWorldMaster(), TEXT_TIME_REMAINING, 0x20, ESCORT_LIMIT_MINUTES); + sayFlhaminn(owner, SAY_DANGERS_AHEAD); + end + state.sawEscortee = true; + elseif state.sawEscortee then + -- F'lhaminn fell → retreat-and-retry. + escortFail(owner, area, state); + return; + else + -- Pre-onCreate tick (roster not populated yet) — wait. + return; + end + + -- Skip actors whose roster position is still unsynced (0,0,0) — on + -- the spawn tick every distance reads ~0 (the man0l1 round-7h + -- invisible-enemies rule). + local function positionLive(a) + return a and not (a.positionX == 0 and a.positionY == 0 and a.positionZ == 0); + end + if not positionLive(flhaminn) or not positionLive(owner) then + return; + end + + -- ---- Ambushes: a live chinchilla inside ENGAGE_RADIUS of + -- F'lhaminn or the player pulls onto the PLAYER (retail: only the + -- player fights). ---- + local nearLive = 0 + local anyEngaged = false + local ring = nil + local liveIds = {} + for i = 1, #mobs do + local mob = mobs[i] + if mob and state.ringActorId ~= nil and mob.actorId == state.ringActorId then + ring = mob; + elseif mob and positionLive(mob) then + liveIds[mob.actorId] = true; + local dMob = math.min( + dist2d(mob.positionX, mob.positionZ, flhaminn.positionX, flhaminn.positionZ), + dist2d(mob.positionX, mob.positionZ, owner.positionX, owner.positionZ)) + if dMob <= HOLD_RADIUS then + nearLive = nearLive + 1 + end + if mob:IsEngaged() then + anyEngaged = true + if not state.mobsEngaged[mob.actorId] then + state.mobsEngaged[mob.actorId] = true; + owner:SendGameMessageLocalizedDisplayName(GetWorldMaster(), TEXT_MOB_ENGAGED, 0x20, CHINCHILLA_DISPLAY_ID); + sayFlhaminn(owner, SAY_NOT_TOO_STRONG); + end + end + if dMob <= ENGAGE_RADIUS and not mob:IsEngaged() then + allyGlobal.EngageTarget(mob, owner) + end + end + end + -- Defeat announcements: a previously-seen chinchilla vanishing from + -- the live-only roster died this tick. + for id in pairs(state.mobsLive) do + if not liveIds[id] then + owner:SendGameMessageLocalizedDisplayName(GetWorldMaster(), TEXT_MOB_DEFEATED, 0x20, CHINCHILLA_DISPLAY_ID); + end + end + state.mobsLive = liveIds; + + -- Minimap halo follows F'lhaminn EVERY tick (moveState 2 — the + -- client glides state-0 moves and the halo falls off the minimap). + if ring ~= nil and (state.lastRingTick == nil or tick - state.lastRingTick >= RING_MOVE_TICKS) then + state.lastRingTick = tick; + if RECORD_MODE then + ring:MoveTo(owner.positionX, owner.positionY, owner.positionZ, 0.0, 2); + else + ring:MoveTo(flhaminn.positionX, flhaminn.positionY, flhaminn.positionZ, 0.0, 2); + end + end + + -- Wave-outcome beat: contested count dropping back to zero = this + -- ambush cleared (her celebration rotation is on film). + if nearLive == 0 and state.lastNear > 0 then + sayFlhaminn(owner, (tick % 2 == 0) and SAY_WELL_DONE or SAY_ARE_YOU_HURT); + sayFlhaminn(owner, SAY_AMAZING); + end + state.lastNear = nearLive; + + -- ---- Hold while contested ---- + if nearLive > 0 or anyEngaged then + return + end + + -- ---- Arrival: the player reaches the camp, F'lhaminn in tow → + -- duty complete, the director takes the arrival beat. ---- + if dist2d(owner.positionX, owner.positionZ, ARRIVAL_GOAL.x, ARRIVAL_GOAL.z) <= ARRIVAL_RADIUS then + state.done = true; + sayFlhaminn(owner, SAY_JUST_AHEAD); + sendSignal("man0u1EscortComplete"); + return; + end + + -- ---- F'lhaminn FOLLOWS the player at run pace, at the player's + -- real ground Y (the sibling player-leads model). ---- + local dPlayer = dist2d(flhaminn.positionX, flhaminn.positionZ, owner.positionX, owner.positionZ); + if dPlayer > LEAD_DISTANCE then + local d = math.max(dPlayer, 0.001); + local dx = (owner.positionX - flhaminn.positionX) / d; + local dz = (owner.positionZ - flhaminn.positionZ) / d; + local step = math.min(RUN_STEP, dPlayer); + flhaminn:MoveTo(flhaminn.positionX + dx * step, owner.positionY, flhaminn.positionZ + dz * step, + math.atan(dx, dz), 2); + end + + -- Near-goal call-out as the player closes on the camp. + if not state.saidSoClose + and dist2d(owner.positionX, owner.positionZ, ARRIVAL_GOAL.x, ARRIVAL_GOAL.z) <= 60.0 then + state.saidSoClose = true; + sayFlhaminn(owner, SAY_SO_CLOSE); + end +end + +-- Leave-duty teardown (the commandContent confirmed-leave): same +-- eject-and-retry flow as a wounding/timeout fail. +function onAbort(player, contentArea, director) + local state = escortState[player.actorId]; + if state == nil then + state = { done = false }; + escortState[player.actorId] = state; + end + if state.done then + return; + end + escortFail(player, contentArea, state); +end diff --git a/scripts/lua/directors/Quest/QuestDirectorMan0u101.lua b/scripts/lua/directors/Quest/QuestDirectorMan0u101.lua new file mode 100644 index 00000000..3f653031 --- /dev/null +++ b/scripts/lua/directors/Quest/QuestDirectorMan0u101.lua @@ -0,0 +1,71 @@ +require ("global") +require ("quests/man/man0u1") + +-- Man0u1 SEQ_015 Coliseum first-round match director (Garlemald-Server +-- #53) — the QuestDirectorMan0l101/Man0g101 completion-beat coroutine +-- (every ordering rule is wire-proven on those legs), adapted to the +-- arena's lose-to-complete shape: +-- +-- client noticeEvent kick (startMan0u1Coliseum's KickEvent) → +-- onEventStarted dismisses the staging veil (questBaseRewardSeting = +-- the ONLY Now-Loading dismissal after a kick-at-entry) → EndEvent → +-- parks on "coliseumFought" → the content script signals the loss +-- (the fight is unwinnable by design — Mirke: "There is no way to +-- win.") → post-match cutscene (processEvent035, retail CS5 — "A +-- splendid match! I expected no less. / Don't let losing get to +-- you.") → GLD counter 3 → 4 → ContentFinished teardown → warp into +-- the Coliseum echo PA, where Greinfarr's talk plays the "gathering" +-- echo (processEvent040) and hands back to the Quicksand. +-- +-- The content script has no fail flow — the loss IS completion; a +-- session death mid-fight is covered by the quest script's +-- FLAG_ARENA_HANDOFF relog rescue (GLD 3 → 2, trigger re-arms). + +function init() + return "/Director/Quest/QuestDirectorMan0u101"; +end + +function onEventStarted(player, actor, triggerName) + man0u1Quest = player:GetQuest("Man0u1"); + + -- Post-warp Now-Loading dismissal (MyPlayer slot 66 — the man0l1 + -- round-7c decomp-final finding). + callClientFunction(player, "delegateEvent", player, man0u1Quest, "questBaseRewardSeting"); + + -- Close the kick — post-load, like the tutorials. The duty-bound + -- banner rides the content script's first-sighting latch. + player:EndEvent(); + + waitForSignal("coliseumFought"); + + -- Render-settle beat (Man0l001 pattern). + wait(2); + + -- Reopen the event context BEFORE delegating — a bare delegate + -- ships with owner=0 and the client echo-drops it. + kickEventContinue(player, actor, "noticeEvent", "noticeEvent"); + -- man0u135: the post-match Greinfarr scene (retail CS5). Ends by + -- arming an after-warp veil — the echo-PA warp below resolves it. + callClientFunction(player, "delegateEvent", player, man0u1Quest, "processEvent035"); + -- Fight done: GLD 3 → 4 (the echo-PA Greinfarr talk gates on 4). + -- The unbind line goes out BEFORE the EndEvent + warp (a 0x0157 + -- game message queued after the warp ships into the Now-Loading + -- gap — the man0l0 Hob-crash shape). 50012 = "You are no longer + -- bound by duty." — on film right at this transition. + man0u1Quest:GetData():SetCounter(CNTR_SEQ15_GLD, 4); + -- SetCounter does NOT re-run onStateChange (only StartSequence/ + -- UpdateENPCs do), and the echo-PA zone-in replays the LAST ENPC + -- snapshot — without this re-derivation Greinfarr lands un-iconed + -- in the echo scene (QFLAG_TALK gates on GLD==4). Branch review + -- finding, 2026-07-09. + man0u1Quest:UpdateENPCs(); + player:SendGameMessage(GetWorldMaster(), 50012, 0x20); + player:EndEvent(); + player:GetZone():ContentFinished(); + -- The post-match echo scene PA (Greinfarr + Niellefresne cast, + -- seed/094) — same lobby PA at the Coliseum coords. + GetWorldManager():DoZoneChange(player, 209, "PrivateAreaMasterPast", 5, 15, -185.0, 195.0, 190.0, -2.3); +end + +function main() +end diff --git a/scripts/lua/directors/Quest/QuestDirectorMan0u102.lua b/scripts/lua/directors/Quest/QuestDirectorMan0u102.lua new file mode 100644 index 00000000..f5c7a4c8 --- /dev/null +++ b/scripts/lua/directors/Quest/QuestDirectorMan0u102.lua @@ -0,0 +1,60 @@ +require ("global") +require ("quests/man/man0u1") + +-- Man0u1 SEQ_060 F'lhaminn escort director (Garlemald-Server #53) — +-- clone of the proven QuestDirectorMan0l101/Man0g101 completion-beat +-- coroutine (every ordering rule is wire-proven on those legs): +-- +-- client noticeEvent kick (startMan0u1Escort's KickEvent) → +-- onEventStarted dismisses the staging veil (questBaseRewardSeting) +-- → EndEvent → parks on "man0u1EscortComplete" → the content script +-- signals arrival at Camp Black Brush → arrival cutscene +-- (processEvent075 — man0u175; retail: "Thank you. We have arrived +-- in Camp Black Brush.") → SEQ_065 journal update + unbind → +-- ContentFinished teardown → drop to PUBLIC zone 170 at the camp +-- (no client-provable PA exists for wil0Fld01 — the Ascilia echo +-- cast is SetENpc-gated there instead; design-doc checkpoint 4). +-- +-- The content script ALSO fires "man0u1EscortComplete" on its FAIL +-- flow purely to DRAIN this park (a stale park would double-fire the +-- arrival on a retry). In that case the sequence is still SEQ_060 — +-- this coroutine's kickEventContinue targets a wiped director actor, +-- the client drops it, and the tail never runs. + +function init() + return "/Director/Quest/QuestDirectorMan0u102"; +end + +function onEventStarted(player, actor, triggerName) + man0u1Quest = player:GetQuest("Man0u1"); + + -- Post-warp Now-Loading dismissal (MyPlayer slot 66). + callClientFunction(player, "delegateEvent", player, man0u1Quest, "questBaseRewardSeting"); + + -- Close the kick — post-load. The duty banners (protect/bound/ + -- timer) ride the content script's first-sighting latch (it alone + -- holds F'lhaminn's actor id for the 51005 param). + player:EndEvent(); + + waitForSignal("man0u1EscortComplete"); + + -- Render-settle beat. + wait(2); + + kickEventContinue(player, actor, "noticeEvent", "noticeEvent"); + -- man0u175: the camp-arrival cutscene. Ends by arming an + -- after-warp veil — the public drop below resolves it. + callClientFunction(player, "delegateEvent", player, man0u1Quest, "processEvent075"); + -- Camp handoff (retail order: journal update → unbind → reload). + man0u1Quest:StartSequence(SEQ_065); + player:SendGameMessage(GetWorldMaster(), 50012, 0x20); + player:EndEvent(); + player:GetZone():ContentFinished(); + -- Camp Black Brush, public zone 170 (camp anchor y=201 from the + -- seed-031 camp rows; the marker sheet puts the leader beat at + -- 41.1/-480.0). + GetWorldManager():DoZoneChange(player, 170, nil, 0, 15, 34.8, 201.0, -480.5, -1.5); +end + +function main() +end diff --git a/scripts/lua/quests/man/man0u1.lua b/scripts/lua/quests/man/man0u1.lua index eb60a874..07297429 100644 --- a/scripts/lua/quests/man/man0u1.lua +++ b/scripts/lua/quests/man/man0u1.lua @@ -5,84 +5,52 @@ require ("quest") Quest Script -Name: Court in the Sands +Name: Court in the Sands Code: Man0u1 Id: 110010 Prereq: Flowers for All (Man0u0 - 110009) -Notes: +Full port for Garlemald-Server #53. The retail flow, sequence numbers, +text ids, casts, and coordinates are evidence-backed — see +docs/implementation/quest_man0u1_court_in_the_sands.md for the source +map (retail OCR playthroughs, Slerp Lederp's dev captures, the decoded +client script + csv sheets, and the pmeteor phase-table header this +skeleton shipped with). -Vid refs - -https://www.youtube.com/watch?v=WNRLrwZ3BJY&t=284s -https://www.youtube.com/watch?v=eZgcq-FMpfw&t=504s - -Coliseum fight - https://www.youtube.com/watch?v=Jcv9I2Bk46w - -A LOT - https://www.youtube.com/watch?v=gySHO1Be9OM +Vid refs - +https://www.youtube.com/watch?v=WNRLrwZ3BJY (retail part 1) +https://www.youtube.com/watch?v=eZgcq-FMpfw (retail part 2) +https://www.youtube.com/watch?v=Jcv9I2Bk46w (coliseum fight) +https://www.youtube.com/watch?v=sTXkNoVNlRc (Slerp Lederp dev demo) ]] - ---[[ - -Phase: - -45 (Miner's Guild) - Linette processEvent050 (initial CS) - Change phase to 50 after interaction - -50 (Miner's Guild Instance #1) - Name DisplayName ActorClass Event - Linette 1100016 1000861 processEvent050_2 - Corguevais 1200025 1000043/1001054 processEvent050_11 - Nittma Guttma 1400127 1001286 processEvent050_10 - Nortmoen 1600127 1600042 - F'lhaminn 1900054 1000038/1000842/1001514/2290008 processEvent051_1 - Tyago Moui 1900130 1001203 processEvent050_12 - Shilgen 2200216 1000637 - Muscular Miner 4000202 1000690/1700013 processEvent050_7 - Close-fisted Woman 4000366 1000981 processEvent050_8 - Astonished Adventurer 4000377 1000895 processEvent050_9 - Manic Miner 4000444 1001283 processEvent050_13 - Maddened Miner 4000445 1001284 processEvent050_14 - Maudlin Miner 4000446 1001287 processEvent050_3 - Mocking Miner 4000447 1001288 processEvent050_4 - Monitoring Miner 4000448 1001289 processEvent050_5 - Displeased Dancer 4000449 1001290 processEvent050_6 - - Emotes 103, 108, 121, 125, 140, 135 in that order @ F'lhaminn, then change phase to 51 -51 - Emotes 108 @ Maddened Miner - Emotes 135, 103, 121 @ Manic Miner - Check both for clear state after each interaction and change phase to 55 - -55 (Miner's Guild Instance #2) - ---]] - -- Sequence Numbers -SEQ_000 = 0; -- Ul'dah Adventurer's Guild -SEQ_005 = 5; -- Run to Camp Black Brush & Attune -SEQ_010 = 10; -- Return to the Guild -SEQ_012 = 12; -- Speak to Momodi -SEQ_015 = 15; -- Visiting guilds (GSM, GLD) -SEQ_045 = 45; -SEQ_050 = 50; -SEQ_057 = 57; -SEQ_058 = 58; -SEQ_060 = 60; -SEQ_065 = 65; -SEQ_070 = 70; -SEQ_075 = 75; -SEQ_080 = 80; -SEQ_085 = 85; -SEQ_090 = 90; -SEQ_095 = 95; -SEQ_100 = 100; -SEQ_105 = 105; -SEQ_110 = 110; - --- Actor Class Ids +SEQ_000 = 0; -- Ul'dah Adventurer's Guild (Quicksand intro instance) +SEQ_005 = 5; -- Run to Camp Black Brush & Attune +SEQ_010 = 10; -- Return to the Guild +SEQ_012 = 12; -- Speak to Momodi again (guild schooling + Coliseum Pass) +SEQ_015 = 15; -- Visiting guilds (GSM Eshtaime's, GLD Coliseum) — parallel counters +SEQ_045 = 45; -- Go to Amajina & Sons Mineral Concern (Linette) +SEQ_050 = 50; -- Miner's Guild Instance #1 — F'lhaminn teaches the six emotes +SEQ_057 = 57; -- Calm the two miners (Maddened: Beckon; Manic: Soothe, Furious, Laugh) +SEQ_058 = 58; -- Miner's Guild Instance #2 — Corguevais / Linette assignment +SEQ_060 = 60; -- Meet F'lhaminn at the Gate of Nald; the escort duty runs here +SEQ_065 = 65; -- Camp Black Brush — the Ascilia echo + the camp leader +SEQ_070 = 70; -- Return to Ul'dah — Momodi gossip +SEQ_075 = 75; -- F'lhaminn at the Concern stage — escort payment +SEQ_080 = 80; -- Frondale's Phrontistery — Nogeloix +SEQ_085 = 85; -- Master Faustigeant — sickroom survey assignment +SEQ_090 = 90; -- Survey the sickrooms (ward instance) → the Warburton echo +SEQ_095 = 95; -- Report to Faustigeant (no pay) +SEQ_100 = 100; -- Momodi's linkpearl call +SEQ_105 = 105; -- Turn-in at Momodi +SEQ_110 = 110; -- declared upstream, unused — completion fires at SEQ_105 + -- (Slerp's capture shows Sequence: 105 as the last + -- pre-completion state; man0g1 completes on its final + -- declared sequence the same way) + +-- Actor Class Ids — SEQ_000 Quicksand intro cast (upstream skeleton) OVERCOMPETITIVE_ADVENTURER = 1000807; MOMODI = 1000841; OTOPA_POTTOPA = 1000864; @@ -90,29 +58,150 @@ UNDAUNTED_ADVENTURER = 1000936; GREEDY_MERCHANT = 1000937; LIONHEARTED_ADVENTURER = 1000938; SPRY_SALESMAN = 1000939; - UPBEAT_ADVENTURER = 1000940; SEEMINGLY_CALM_ADVENTURER = 1000941; -UNKNOWN1 = 0; -UNKNOWN2 = 0; +-- Class 1000948 is a bare gamedata row whose display_name_id is +-- 1000010 — i.e. it borrows the canonical Thancred class's name (the +-- upstream "-- 1000010" comment was pointing at exactly that; bahamut +-- gamedata_actor_class.sql:964). It is the Quicksand-scene Thancred. +THANCRED = 1000948; + +-- SEQ_015 — Eshtaime's Lapidaries (GSM leg) +ELECOTTE = 1000950; +HNAUFRID = 1000635; +PAMISOLAUX = 1600040; -THANCRED = 1000948; -- 1000010 +-- SEQ_015 — the Coliseum (GLD leg). Public entrance + lobby/echo PA +-- cast (display-name → class resolutions via bahamut +-- gamedata_actor_class.sql; see the design doc). +YOYOBINA = 1000927; +LULUTSU = 1000863; +PAPAWA = 1000962; +ABYLGO_HAMYLGO = 1000965; +FRUHYBOLG = 1000964; +SWERDAHRM = 1000967; +GREINFARR = 1000039; +NIELLEFRESNE_GLD = 1000037; -- echo scene; base Niellefresne class +ARENA_TRIGGER = 1090283; -- pushWithCircle at the arena gate (Slerp's ">Pushed GLD!" class) +-- SEQ_045..058 — Amajina & Sons Mineral Concern (public + scene PA) +LINETTE = 1000861; +SHILGEN = 1000637; +TYAGO_MOUI = 1001203; +NORTMOEN = 1600042; +NITTMA_GUTTMA = 1001286; +CORGUEVAIS_SCENE = 1001054; -- beaten-in-the-mines scene variant (header: 1000043/1001054) +CORGUEVAIS = 1000043; -- present-day (camp echo) +FLHAMINN_SCENE = 1000842; -- the teaching/instance F'lhaminn (header roster) +FLHAMINN = 1000038; -- base populace (gate muster + Concern stage) +MANIC_MINER = 1001283; +MADDENED_MINER = 1001284; +MAUDLIN_MINER = 1001287; +MOCKING_MINER = 1001288; +MONITORING_MINER = 1001289; +DISPLEASED_DANCER = 1001290; +MUSCULAR_MINER = 1000690; +CLOSE_FISTED_WOMAN = 1000981; +ASTONISHED_ADVENTURER = 1000895; +POPOKKULI = 1000857; +SESERUKKA = 1000858; --- Quest Markers +-- SEQ_060 — Gate of Nald (zone 170) +GATE_OF_NALD_TRIGGER = 1090285; -- net-new pushWithCircle class (seed/095) + +-- SEQ_065 — Camp Black Brush echo cast (public zone 170) +ASCILIA_CAMP = 1001515; -- camp-scene variant (1000042 is man0u0's parade Ascilia) +THANCRED_CAMP = 1000185; +UNTIDY_OUTLANDER = 1000808; +MALNOURISHED_MIDLANDER = 1000809; +HELPLESS_HYUR = 1000817; -- the camp leader (retail placeholder name) + +-- SEQ_080..100 — Frondale's Phrontistery +NOGELOIX = 1000597; +KYLENE = 1000634; +FAUSTIGEANT = 1000852; +WARD_DOOR = 1090119; -- ObjectEventDoor (Slerp's SEQ 90/95/100 push class) +SLOW_WITTED_MINER = 1000689; +QUALMISH_MINER = 1000699; +WORRISOME_ASSISTANT = 1001207; +WELL_WASHED_LEECH = 1001210; + +-- Quest Markers — client quest_marker.csv 110010xx block. The sheet's +-- x/z pairs are literal world coordinates (11001009 == Linette's spawn +-- row, 11001018 == Nogeloix's), which is also where the seed/094 scene +-- casts take their positions from. MRKR_MOMODI = 11001001; MRKR_CAMP_BLACK_BRUSH = 11001002; +MRKR_ESHTAIME = 11001005; +MRKR_COLISEUM = 11001006; +MRKR_LINETTE = 11001009; +MRKR_FLHAMINN_TEACH = 11001010; +MRKR_MANIC_MINER = 11001011; +MRKR_MADDENED_MINER = 11001012; +MRKR_GATE_OF_NALD = 11001014; +MRKR_CAMP_LEADER = 11001015; +MRKR_CONCERN_STAGE = 11001017; +MRKR_PHRONTISTERY = 11001018; +MRKR_WARD_DOOR = 11001019; -- Quest Items -ITEM_VELODYNA_COSMOS = 0; -- Seq_000 : 2nd journal arg. >=5 doesn't have. -ITEM_COLISEUM_PASS = 0; -- Seq_015 : 3rd journal arg. >=5 doesn't have +ITEM_VELODYNA_COSMOS = 11000089; -- mozk-tabetai + man0u1.csv @SHEET refs +ITEM_COLISEUM_PASS = 11000126; -- granted at processEvent017 (toast 25117) + +-- Quest Counters +CNTR_SEQ15_GSM = 0; -- 0 = Eshtaime's pending, 1 = flower sold +CNTR_SEQ15_GLD = 1; -- 0 pending, 1 recruit CS seen (in lobby), 2 pass + -- checked (arena trigger armed), 3 fight handed + -- off, 4 fight done (echo PA), 5 leg complete +CNTR_EMOTE_TEACH = 2; -- SEQ_050: teach steps completed (0..6) +CNTR_MANIC_CALM = 3; -- SEQ_057: Manic Miner sequence progress (0..3) -- Quest Flags -FLAG_SEQ000 = 0; +-- One-shot handoff latch for the arena content (the man0l1/man0g1 +-- FLAG_ESCORT_HANDOFF shape, adapted to a counter-state machine): set +-- by startMan0u1Coliseum right before the content warp. A later +-- onStateChange arrival at SEQ_015 with GLD == 3 and the flag CLEAR is +-- the relog re-arm for a player whose arena content died with the +-- session — roll the counter back to 2 so the arena trigger re-arms. +FLAG_ARENA_HANDOFF = 0; +FLAG_CALM_MADDENED = 1; -- SEQ_057: Maddened Miner becalmed (Beckon) +FLAG_CAMP_ECHO_SEEN = 2; -- SEQ_065: the Ascilia echo CS has played -function onStart(player, quest) +-- The six taught emotes (EmoteStandardCommand 100-range ids — the +-- client-side F'lhaminn demo schedulers encode exactly these: +-- 0x5000000 | ((id-100) << 12)). DoEmote takes (id-100) and the motion +-- message id 21000+(id-101)*10+11; formula cross-checked against four +-- sibling data points (105→21041, 107→21061, 118→21171, 116→21151). +-- Laugh/Deny/Upset/Soothe motion ids are formula-derived — flagged in +-- the design doc as a live-test checkpoint. +EMOTE_STEPS = { + { eventName = "emoteDefault1", doEmoteId = 3, motionMsg = 21021 }, -- Furious (103) + { eventName = "emoteDefault2", doEmoteId = 8, motionMsg = 21071 }, -- Beckon (108) + { eventName = "emoteDefault3", doEmoteId = 21, motionMsg = 21201 }, -- Laugh (121) + { eventName = "emoteDefault4", doEmoteId = 25, motionMsg = 21241 }, -- Deny (125) + { eventName = "emoteDefault5", doEmoteId = 40, motionMsg = 21391 }, -- Upset (140) + { eventName = "emoteDefault6", doEmoteId = 35, motionMsg = 21341 }, -- Soothe (135) +}; +-- Manic Miner wants Soothe → Furious → Laugh, in that order (the +-- pmeteor header's "Emotes 135, 103, 121 @ Manic Miner"). +MANIC_SEQUENCE = { 6, 1, 3 }; +-- Maddened Miner wants Beckon (header: "Emotes 108 @ Maddened Miner"). +MADDENED_STEP = 2; + +-- NpcLS message packs — man0u1 QUEST-sheet text ids, sender Momodi +-- (display 1500014). Pack 1 = the post-guilds Amajina recruiting call +-- (verbatim in the retail capture), pack 2 = the cave-in news, pack 3 +-- = "come and see me at the Quicksand". +NPCLS_MSGS = { + {301, 302, 303}, + {184, 185, 186}, + {307, 308} +}; +MOMODI_DISPLAY_ID = 1500014; + +function onStart(player, quest) quest:StartSequence(SEQ_000); - + -- Immediately move to the Adventurer's Guild private area callClientFunction(player, "delegateEvent", player, quest, "processEventMomodiStart"); -- pmeteor's script order: arrival texts BEFORE the warp (texts @@ -129,59 +218,322 @@ function onFinish(player, quest) end function onStateChange(player, quest, sequence) + local data = quest:GetData(); if (sequence == SEQ_000) then - -- Setup states incase we loaded in. - - --SetENpc(classId, byte flagType=0,isTalkEnabled, isPushEnabled, isEmoteEnabled, isSpawned) quest:SetENpc(MOMODI, QFLAG_TALK); quest:SetENpc(OTOPA_POTTOPA); - - elseif (sequence == SEQ_005) then + quest:SetENpc(UNDAUNTED_ADVENTURER); + quest:SetENpc(GREEDY_MERCHANT); + quest:SetENpc(SPRY_SALESMAN); + quest:SetENpc(LIONHEARTED_ADVENTURER); + quest:SetENpc(UPBEAT_ADVENTURER); + quest:SetENpc(SEEMINGLY_CALM_ADVENTURER); + quest:SetENpc(OVERCOMPETITIVE_ADVENTURER); + quest:SetENpc(THANCRED); + elseif (sequence == SEQ_005) then quest:SetENpc(MOMODI); + elseif (sequence == SEQ_010) then + quest:SetENpc(MOMODI, QFLAG_TALK); + elseif (sequence == SEQ_012) then + quest:SetENpc(MOMODI, QFLAG_TALK); + elseif (sequence == SEQ_015) then + local subseqGSM = data:GetCounter(CNTR_SEQ15_GSM); + local subseqGLD = data:GetCounter(CNTR_SEQ15_GLD); + -- Relog rescue for a player whose arena content died with the + -- session (content instances don't persist): the handoff latch + -- is only SET on the live start path, so a re-arm arrival at + -- GLD == 3 with it CLEAR rolls back to 2 and the arena trigger + -- re-arms for a retry. (man0l1 FLAG_ESCORT_HANDOFF pattern.) + if (subseqGLD == 3) then + if (data:GetFlag(FLAG_ARENA_HANDOFF)) then + data:ClearFlag(FLAG_ARENA_HANDOFF); + else + data:SetCounter(CNTR_SEQ15_GLD, 2); + subseqGLD = 2; + end + end + -- Momodi is always talkable for the 017_2/3/4 reminders. + quest:SetENpc(MOMODI); + -- GSM leg — Eshtaime's Lapidaries (public 209 half). + quest:SetENpc(ELECOTTE, (subseqGSM == 0) and QFLAG_TALK or QFLAG_NONE); + quest:SetENpc(HNAUFRID); + quest:SetENpc(PAMISOLAUX); + -- GLD leg — Coliseum entrance (public), lobby + echo (PA 209/5). + quest:SetENpc(YOYOBINA, (subseqGLD == 0) and QFLAG_TALK or QFLAG_NONE); + quest:SetENpc(LULUTSU, (subseqGLD == 1) and QFLAG_TALK or QFLAG_NONE); + quest:SetENpc(ARENA_TRIGGER, (subseqGLD == 2) and QFLAG_PUSH or QFLAG_NONE, false, (subseqGLD == 2)); + quest:SetENpc(PAPAWA); + quest:SetENpc(ABYLGO_HAMYLGO); + quest:SetENpc(FRUHYBOLG); + quest:SetENpc(SWERDAHRM); + quest:SetENpc(GREINFARR, (subseqGLD == 4) and QFLAG_TALK or QFLAG_NONE); + quest:SetENpc(NIELLEFRESNE_GLD); + elseif (sequence == SEQ_045) then + quest:SetENpc(LINETTE, QFLAG_TALK); + quest:SetENpc(SHILGEN); + quest:SetENpc(TYAGO_MOUI); + quest:SetENpc(NORTMOEN); + elseif (sequence == SEQ_050) then + -- Miner instance #1 (PA 209/5, Concern interior) — the teach + -- phase. F'lhaminn is talk + emote enabled; the whole crowd is + -- talkable per the pmeteor header's event table. + quest:SetENpc(FLHAMINN_SCENE, QFLAG_TALK, true, false, true); + seq050_common_cast(quest); + elseif (sequence == SEQ_057) then + -- The calming phase: each miner is emote-enabled until becalmed. + local manicDone = data:GetCounter(CNTR_MANIC_CALM) >= #MANIC_SEQUENCE; + local maddenedDone = data:GetFlag(FLAG_CALM_MADDENED); + quest:SetENpc(FLHAMINN_SCENE, QFLAG_NONE, true, false, true); + quest:SetENpc(MANIC_MINER, (not manicDone) and QFLAG_TALK or QFLAG_NONE, true, false, not manicDone); + quest:SetENpc(MADDENED_MINER, (not maddenedDone) and QFLAG_TALK or QFLAG_NONE, true, false, not maddenedDone); + seq050_common_cast(quest, true); + elseif (sequence == SEQ_058) then + -- Miner instance #2 (same PA, reloaded) — Corguevais recovered. + quest:SetENpc(CORGUEVAIS_SCENE, QFLAG_TALK); + quest:SetENpc(FLHAMINN_SCENE); + quest:SetENpc(LINETTE); + quest:SetENpc(POPOKKULI); + quest:SetENpc(SESERUKKA); + quest:SetENpc(NITTMA_GUTTMA); + quest:SetENpc(MAUDLIN_MINER); + quest:SetENpc(MONITORING_MINER); + quest:SetENpc(MUSCULAR_MINER); + elseif (sequence == SEQ_060) then + -- The Gate of Nald muster (zone 170, north of the city). + -- F'lhaminn waits at the gate; the trigger starts the escort. + -- The escort itself runs at THIS sequence: if the content dies + -- with the session, this re-arm puts the player back at the + -- gate with the trigger live — retry, no rollback state needed. + quest:SetENpc(FLHAMINN, QFLAG_TALK); + quest:SetENpc(GATE_OF_NALD_TRIGGER, QFLAG_PUSH, false, true); + elseif (sequence == SEQ_065) then + -- Camp Black Brush, public zone 170 (no client-provable PA for + -- wil0Fld01 — design-doc checkpoint 4). The Ascilia echo plays + -- from her talk; the leader beat unlocks after it. + local echoSeen = data:GetFlag(FLAG_CAMP_ECHO_SEEN); + quest:SetENpc(ASCILIA_CAMP, (not echoSeen) and QFLAG_TALK or QFLAG_NONE); + quest:SetENpc(CORGUEVAIS); + quest:SetENpc(THANCRED_CAMP); + quest:SetENpc(UNTIDY_OUTLANDER); + quest:SetENpc(MALNOURISHED_MIDLANDER); + quest:SetENpc(HELPLESS_HYUR, echoSeen and QFLAG_TALK or QFLAG_NONE); + elseif (sequence == SEQ_070) then + quest:SetENpc(MOMODI, QFLAG_TALK); + elseif (sequence == SEQ_075) then + quest:SetENpc(FLHAMINN, QFLAG_TALK); + quest:SetENpc(LINETTE); + elseif (sequence == SEQ_080) then + quest:SetENpc(NOGELOIX, QFLAG_TALK); + quest:SetENpc(KYLENE); + elseif (sequence == SEQ_085) then + quest:SetENpc(FAUSTIGEANT, QFLAG_TALK); + quest:SetENpc(NOGELOIX); + elseif (sequence == SEQ_090) then + -- The sickroom ward (PA 209/5 at the Phrontistery). The door + -- (armed here) leads to the Warburton echo. + quest:SetENpc(WARD_DOOR, QFLAG_PUSH, false, true); + quest:SetENpc(SLOW_WITTED_MINER); + quest:SetENpc(MUSCULAR_MINER); + quest:SetENpc(QUALMISH_MINER); + quest:SetENpc(WORRISOME_ASSISTANT); + quest:SetENpc(WELL_WASHED_LEECH); + elseif (sequence == SEQ_095) then + quest:SetENpc(FAUSTIGEANT, QFLAG_TALK); + quest:SetENpc(WARD_DOOR); + quest:SetENpc(SLOW_WITTED_MINER); + quest:SetENpc(MUSCULAR_MINER); + quest:SetENpc(QUALMISH_MINER); + quest:SetENpc(WORRISOME_ASSISTANT); + quest:SetENpc(WELL_WASHED_LEECH); + elseif (sequence == SEQ_100) then + -- Waiting on the linkpearl read; the ward door doubles as the + -- exit back to the Phrontistery public floor. + quest:SetENpc(WARD_DOOR, QFLAG_PUSH, false, true); + quest:SetENpc(FAUSTIGEANT); + elseif (sequence == SEQ_105) then + quest:SetENpc(MOMODI, QFLAG_REWARD); + quest:SetENpc(WARD_DOOR, QFLAG_PUSH, false, true); end end +-- The miner-instance crowd shared by SEQ_050/057 (pmeteor header's +-- name/class/event table). +function seq050_common_cast(quest, skipMiners) + quest:SetENpc(LINETTE); + quest:SetENpc(CORGUEVAIS_SCENE); + quest:SetENpc(NITTMA_GUTTMA); + quest:SetENpc(TYAGO_MOUI); + quest:SetENpc(SHILGEN); + quest:SetENpc(NORTMOEN); + quest:SetENpc(MAUDLIN_MINER); + quest:SetENpc(MOCKING_MINER); + quest:SetENpc(MONITORING_MINER); + quest:SetENpc(DISPLEASED_DANCER); + quest:SetENpc(MUSCULAR_MINER); + quest:SetENpc(CLOSE_FISTED_WOMAN); + quest:SetENpc(ASTONISHED_ADVENTURER); + if (not skipMiners) then + quest:SetENpc(MANIC_MINER); + quest:SetENpc(MADDENED_MINER); + end +end + function onTalk(player, quest, npc) local sequence = quest:getSequence(); local classId = npc:GetActorClassId(); - - if (sequence == SEQ_000) then - seq000_onTalk(player, quest, npc, classId); - elseif (sequence == SEQ_005) then - seq005_onTalk(player, quest, npc, classId); - end - quest:UpdateENPCs(); -end + local data = quest:GetData(); -function onPush(player, quest, npc) - - local sequence = quest:getSequence(); - local classId = npc:GetActorClassId(); - player:SendMessage(0x20, "", "Sequence: "..sequence.." Class Id: "..classId); if (sequence == SEQ_000) then - - elseif (sequence == SEQ_010) then - + if (seq000_onTalk(player, quest, npc, classId) == true) then + return; -- warped — nothing may follow (man0g1 SEQ_000 rule) + end + elseif (sequence == SEQ_005) then + if (classId == MOMODI) then + callClientFunction(player, "delegateEvent", player, quest, "processEvent010_2"); + player:EndEvent(); + end + elseif (sequence == SEQ_010) then + if (classId == MOMODI) then + callClientFunction(player, "delegateEvent", player, quest, "processEvent015"); + player:EndEvent(); + quest:StartSequence(SEQ_012); + end + elseif (sequence == SEQ_012) then + if (classId == MOMODI) then + callClientFunction(player, "delegateEvent", player, quest, "processEvent017"); + -- "You obtain a Coliseum pass." — the key-item toast shape + -- man0l1 uses for Baderon's Recommendation (25117). + player:SendGameMessage(GetWorldMaster(), 25117, 0x20, ITEM_COLISEUM_PASS); + player:EndEvent(); + quest:StartSequence(SEQ_015); + end + elseif (sequence == SEQ_015) then + if (seq015_onTalk(player, quest, npc, classId) == true) then + return; + end + elseif (sequence == SEQ_045) then + if (classId == LINETTE) then + -- man0u150: Linette's reception + the brawl-echo intro + -- cutscene; ends fadeInAfterWarp → the PA warp below + -- resolves the veil (retail: Now Loading → instance #1). + callClientFunction(player, "delegateEvent", player, quest, "processEvent050"); + quest:StartSequence(SEQ_050); + player:EndEvent(); + GetWorldManager():DoZoneChange(player, 209, "PrivateAreaMasterPast", 5, 15, -97.0, 195.6, 308.0, -0.5); + return; + end + elseif (sequence == SEQ_050) then + seq050_onTalk(player, quest, npc, classId); + elseif (sequence == SEQ_057) then + seq057_onTalk(player, quest, npc, classId); + elseif (sequence == SEQ_058) then + if (classId == CORGUEVAIS_SCENE) then + -- man0u160: Corguevais's thanks + Linette's Camp Black + -- Brush assignment + F'lhaminn volunteering, one cutscene + -- (retail CS9); veil resolved by the exit warp to public. + callClientFunction(player, "delegateEvent", player, quest, "processEvent060"); + quest:StartSequence(SEQ_060); + player:EndEvent(); + GetWorldManager():DoZoneChange(player, 209, nil, 0, 15, -95.0, 195.6, 305.0, 1.6); + return; + else + seq058_onTalk(player, quest, npc, classId); + end + elseif (sequence == SEQ_060) then + if (classId == FLHAMINN) then + -- Gate-side wait talk (texts 144-146 — no client event + -- exists for these; server-side says, man0l1 + -- seq007_endSequence precedent). + player:SendGameMessage(quest, 144, 0x20); + player:SendGameMessage(quest, 145, 0x20); + player:SendGameMessage(quest, 146, 0x20); + end + player:EndEvent(); + elseif (sequence == SEQ_065) then + if (seq065_onTalk(player, quest, npc, classId) == true) then + return; + end + elseif (sequence == SEQ_070) then + if (classId == MOMODI) then + -- Momodi's F'lhaminn gossip (texts 389/390). + callClientFunction(player, "delegateEvent", player, quest, "processEvent200_2"); + player:EndEvent(); + quest:StartSequence(SEQ_075); + end + elseif (sequence == SEQ_075) then + if (classId == FLHAMINN) then + -- man0u200: the escort payment at the Concern stage — an + -- in-place cutscene (fadeInCutSceneDefault; no warp + -- needed). "You obtain 3,000 gil." is on camera. + callClientFunction(player, "delegateEvent", player, quest, "processEvent200"); + player:AddGil(3000); + player:EndEvent(); + quest:NewNpcLsMsg(1); + end + elseif (sequence == SEQ_080) then + if (classId == NOGELOIX) then + -- man0u205: reception (in-place cutscene). + callClientFunction(player, "delegateEvent", player, quest, "processEvent205"); + player:EndEvent(); + quest:StartSequence(SEQ_085); + end + elseif (sequence == SEQ_085) then + if (classId == FAUSTIGEANT) then + -- man0u210: the Checheroya cave-in exchange + the sickroom + -- survey assignment (in-place cutscene). The ward door + -- arms at SEQ_090. + callClientFunction(player, "delegateEvent", player, quest, "processEvent210"); + player:EndEvent(); + quest:StartSequence(SEQ_090); + elseif (classId == NOGELOIX) then + callClientFunction(player, "delegateEvent", player, quest, "processEvent210_2"); + player:EndEvent(); + end + elseif (sequence == SEQ_090) then + seq090_onTalk(player, quest, npc, classId); + elseif (sequence == SEQ_095) then + if (classId == FAUSTIGEANT) then + -- man0u230: the no-pay report ("It doesn't even warrant a + -- wage", texts 218/219) → Momodi's glow. + callClientFunction(player, "delegateEvent", player, quest, "processEvent230"); + player:EndEvent(); + quest:NewNpcLsMsg(1); + quest:StartSequence(SEQ_100); + else + seq090_onTalk(player, quest, npc, classId); + end + elseif (sequence == SEQ_105) then + if (classId == MOMODI) then + callClientFunction(player, "delegateEvent", player, quest, "processEventComplete"); + callClientFunction(player, "delegateEvent", player, quest, "sqrwa", 300, 1, 1, 2); + -- Completion: 200 EXP + 6,000 gil — client + -- quest_new_reward.csv row 110010; the 6,000-gil toast is + -- on camera at the retail turn-in. + player:AddExp(200, player.charaWork.parameterSave.state_mainSkill[0], 0); + player:AddGil(6000); + player:EndEvent(); + player:CompleteQuest(quest); + return; + end end - quest:UpdateENPCs(); -end - -function onNotice(player, quest, target) - callClientFunction(player, "delegateEvent", player, quest, "processEvent000_1"); -- Describes what an Instance is - player:EndEvent(); + -- Blanket close: registered-but-unhandled talks (bare SetENpc + -- flavor NPCs) must still get their EndEvent or the client is + -- event-locked; a second EndEvent after a branch's own close is + -- proven harmless (the live man0g1 seq000 shape). + player:EndEvent(); quest:UpdateENPCs(); end function seq000_onTalk(player, quest, npc, classId) - if (classId == MOMODI) then callClientFunction(player, "delegateEvent", player, quest, "processEvent010"); player:EndEvent(); quest:StartSequence(SEQ_005); + quest:UpdateENPCs(); GetWorldManager():DoZoneChange(player, 175, nil, 0, 15, player.positionX, player.positionY, player.positionZ, player.rotation); - return; + return true; elseif (classId == UNDAUNTED_ADVENTURER) then callClientFunction (player, "delegateEvent", player, quest, "processEvent000_2"); elseif (classId == GREEDY_MERCHANT) then @@ -195,45 +547,603 @@ function seq000_onTalk(player, quest, npc, classId) elseif (classId == SEEMINGLY_CALM_ADVENTURER) then callClientFunction(player, "delegateEvent", player, quest, "processEvent000_7"); elseif (classId == OVERCOMPETITIVE_ADVENTURER) then - callClientFunction(player, "delegateEvent", player, quest, "processEvent000_8"); + callClientFunction(player, "delegateEvent", player, quest, "processEvent000_8"); elseif (classId == OTOPA_POTTOPA) then callClientFunction(player, "delegateEvent", player, quest, "processEvent000_9"); elseif (classId == THANCRED) then - callClientFunction(player, "delegateEvent", player, quest, "processEvent000_10"); + callClientFunction(player, "delegateEvent", player, quest, "processEvent000_10"); end player:EndEvent(); end -function seq005_onTalk(player, quest, npc, classId) - if (classId == MOMODI) then - callClientFunction(player, "delegateEvent", player, quest, "processEvent010_2"); - end +function seq015_onTalk(player, quest, npc, classId) + local data = quest:GetData(); + local subseqGSM = data:GetCounter(CNTR_SEQ15_GSM); + local subseqGLD = data:GetCounter(CNTR_SEQ15_GLD); - player:EndEvent(); + if (classId == MOMODI) then + if (subseqGSM >= 1 and subseqGLD < 5) then + callClientFunction(player, "delegateEvent", player, quest, "processEvent017_3"); + elseif (subseqGLD >= 5 and subseqGSM == 0) then + callClientFunction(player, "delegateEvent", player, quest, "processEvent017_4"); + else + callClientFunction(player, "delegateEvent", player, quest, "processEvent017_2"); + end + -- GSM leg + elseif (classId == ELECOTTE) then + if (subseqGSM == 0) then + -- man0u120: the Niellefresne appraisal echo — an in-place + -- cutscene (fadeInCutSceneDefault). "the sum of two + -- thousand gil" (text 44) + the retail 2,000-gil toast. + callClientFunction(player, "delegateEvent", player, quest, "processEvent020"); + data:IncCounter(CNTR_SEQ15_GSM); + player:AddGil(2000); + if (data:GetCounter(CNTR_SEQ15_GLD) >= 5) then + seq015_endSequence(player, quest); + end + else + callClientFunction(player, "delegateEvent", player, quest, "processEvent020_2"); + end + -- GLD leg + elseif (classId == YOYOBINA) then + if (subseqGLD == 0) then + -- man0u130: the Yoyobina briefing + Greinfarr recruitment + -- echo (retail CS4); ends fadeInAfterWarp → resolved by + -- the lobby PA warp ("You have entered an instance"). + callClientFunction(player, "delegateEvent", player, quest, "processEvent030"); + data:IncCounter(CNTR_SEQ15_GLD); + player:EndEvent(); + quest:UpdateENPCs(); + GetWorldManager():DoZoneChange(player, 209, "PrivateAreaMasterPast", 5, 15, -185.0, 195.0, 185.0, -2.3); + return true; + else + callClientFunction(player, "delegateEvent", player, quest, "processEvent030_3"); + end + elseif (classId == LULUTSU) then + if (subseqGLD == 1) then + -- The Coliseum Pass check ("another bloody Coliseum Pass, + -- is it? ... Now go and wait within." — texts 56-58). Arms + -- the arena-gate trigger. + callClientFunction(player, "delegateEvent", player, quest, "processEvent030_2"); + data:IncCounter(CNTR_SEQ15_GLD); + elseif (subseqGLD >= 2) then + callClientFunction(player, "delegateEvent", player, quest, "processEvent030_2"); + else + -- Pre-briefing (only reachable via a relog inside the PA): + -- generic parade-gossip line rather than a pass check the + -- story hasn't set up yet. + callClientFunction(player, "delegateEvent", player, quest, "processEvent1000_1"); + end + elseif (classId == GREINFARR) then + if (subseqGLD == 4) then + -- man0u140: the Greinfarr/Niellefresne "gathering" echo + -- (retail CS6; design-doc checkpoint 2 — retail auto-plays + -- it on arrival, we fire it from his talk). Ends + -- fadeInAfterWarp → resolved by the warp back to the + -- Quicksand, where Momodi's linkpearl glow fires. + callClientFunction(player, "delegateEvent", player, quest, "processEvent040"); + data:IncCounter(CNTR_SEQ15_GLD); + player:EndEvent(); + if (data:GetCounter(CNTR_SEQ15_GSM) >= 1) then + seq015_endSequence(player, quest); + end + quest:UpdateENPCs(); + GetWorldManager():DoZoneChange(player, 175, nil, 0, 15, -75.242, 195.009, 74.572, -0.046); + return true; + end + -- Ambient Coliseum crowd (classic-wiki processEvent table). + elseif (classId == PAPAWA) then + callClientFunction(player, "delegateEvent", player, quest, "processEvent030_4"); + elseif (classId == ABYLGO_HAMYLGO) then + callClientFunction(player, "delegateEvent", player, quest, "processEvent030_8"); + elseif (classId == FRUHYBOLG) then + callClientFunction(player, "delegateEvent", player, quest, "processEvent030_6"); + elseif (classId == SWERDAHRM) then + callClientFunction(player, "delegateEvent", player, quest, "processEvent030_7"); + elseif (classId == NIELLEFRESNE_GLD) then + callClientFunction(player, "delegateEvent", player, quest, "processEvent030_9"); + elseif (classId == HNAUFRID) then + callClientFunction(player, "delegateEvent", player, quest, "processEvent032_2"); + elseif (classId == PAMISOLAUX) then + callClientFunction(player, "delegateEvent", player, quest, "processEvent032_3"); + end + + player:EndEvent(); end +-- Both SEQ_015 legs done → queue Momodi's linkpearl call (pack 1: the +-- Amajina recruiting pitch). The LS read advances to SEQ_045. +function seq015_endSequence(player, quest) + quest:NewNpcLsMsg(1); +end -function getJournalInformation(player, quest) - return 0, ITEM_VELODYNA_COSMOS, ITEM_COLISEUM_PASS; +function seq050_onTalk(player, quest, npc, classId) + local data = quest:GetData(); + + if (classId == FLHAMINN_SCENE) then + local step = data:GetCounter(CNTR_EMOTE_TEACH); + if (step < 6) then + -- Teach (or re-teach) the current emote: 051_1..051_6 play + -- her demo via the client chara scheduler and say the + -- teach line (344-349). + callClientFunction(player, "delegateEvent", player, quest, "processEvent051_" .. (step + 1)); + else + -- All six taught — the go-signal (text 350). + callClientFunction(player, "delegateEvent", player, quest, "processEvent051_8"); + end + elseif (classId == LINETTE) then + callClientFunction(player, "delegateEvent", player, quest, "processEvent050_2"); + elseif (classId == MAUDLIN_MINER) then + callClientFunction(player, "delegateEvent", player, quest, "processEvent050_3"); + elseif (classId == MOCKING_MINER) then + callClientFunction(player, "delegateEvent", player, quest, "processEvent050_4"); + elseif (classId == MONITORING_MINER) then + callClientFunction(player, "delegateEvent", player, quest, "processEvent050_5"); + elseif (classId == DISPLEASED_DANCER) then + callClientFunction(player, "delegateEvent", player, quest, "processEvent050_6"); + elseif (classId == MUSCULAR_MINER) then + callClientFunction(player, "delegateEvent", player, quest, "processEvent050_7"); + elseif (classId == CLOSE_FISTED_WOMAN) then + callClientFunction(player, "delegateEvent", player, quest, "processEvent050_8"); + elseif (classId == ASTONISHED_ADVENTURER) then + callClientFunction(player, "delegateEvent", player, quest, "processEvent050_9"); + elseif (classId == NITTMA_GUTTMA) then + callClientFunction(player, "delegateEvent", player, quest, "processEvent050_10"); + elseif (classId == CORGUEVAIS_SCENE) then + callClientFunction(player, "delegateEvent", player, quest, "processEvent050_11"); + elseif (classId == TYAGO_MOUI) then + callClientFunction(player, "delegateEvent", player, quest, "processEvent050_12"); + elseif (classId == MANIC_MINER) then + callClientFunction(player, "delegateEvent", player, quest, "processEvent050_13"); + elseif (classId == MADDENED_MINER) then + callClientFunction(player, "delegateEvent", player, quest, "processEvent050_14"); + end + + player:EndEvent(); end +function seq057_onTalk(player, quest, npc, classId) + if (classId == FLHAMINN_SCENE) then + callClientFunction(player, "delegateEvent", player, quest, "processEvent051_8"); + elseif (classId == MANIC_MINER) then + callClientFunction(player, "delegateEvent", player, quest, "processEvent050_13"); + elseif (classId == MADDENED_MINER) then + callClientFunction(player, "delegateEvent", player, quest, "processEvent050_14"); + else + seq050_onTalk(player, quest, npc, classId); + return; + end + player:EndEvent(); +end + +function seq058_onTalk(player, quest, npc, classId) + -- Instance #2 crowd (texts 114-118, 136 → events 060_2..060_7). + if (classId == MAUDLIN_MINER) then + callClientFunction(player, "delegateEvent", player, quest, "processEvent060_2"); + elseif (classId == MONITORING_MINER) then + callClientFunction(player, "delegateEvent", player, quest, "processEvent060_3"); + elseif (classId == MUSCULAR_MINER) then + callClientFunction(player, "delegateEvent", player, quest, "processEvent060_4"); + elseif (classId == NITTMA_GUTTMA) then + callClientFunction(player, "delegateEvent", player, quest, "processEvent060_5"); + elseif (classId == POPOKKULI) then + callClientFunction(player, "delegateEvent", player, quest, "processEvent060_6"); + elseif (classId == SESERUKKA) then + callClientFunction(player, "delegateEvent", player, quest, "processEvent060_7"); + elseif (classId == FLHAMINN_SCENE) then + callClientFunction(player, "delegateEvent", player, quest, "processEvent1000_3"); + elseif (classId == LINETTE) then + callClientFunction(player, "delegateEvent", player, quest, "processEvent050_2"); + end + player:EndEvent(); +end + +function seq065_onTalk(player, quest, npc, classId) + local data = quest:GetData(); + + if (classId == ASCILIA_CAMP and not data:GetFlag(FLAG_CAMP_ECHO_SEEN)) then + -- man0u180: the Ascilia/Corguevais/Thancred echo (retail + -- CS11+CS12). Ends fadeInAfterWarp — the same-zone public + -- reload below resolves the veil (the man0l1 seq000 same-zone + -- warp recipe; design-doc checkpoint 3). + callClientFunction(player, "delegateEvent", player, quest, "processEvent080"); + data:SetFlag(FLAG_CAMP_ECHO_SEEN); + -- F'lhaminn's post-echo pivot to the camp leader (text 176). + player:SendGameMessage(quest, 176, 0x20); + player:EndEvent(); + quest:UpdateENPCs(); + GetWorldManager():DoZoneChange(player, 170, nil, 0, 15, player.positionX, player.positionY, player.positionZ, player.rotation); + return true; + elseif (classId == HELPLESS_HYUR and data:GetFlag(FLAG_CAMP_ECHO_SEEN)) then + -- The camp leader rebuffs F'lhaminn (texts 177-183 — no client + -- event exists for the exchange; server-side says), then the + -- pair returns to Ul'dah (retail: Now Loading straight after + -- "Let us return"). + player:SendGameMessage(quest, 177, 0x20); + player:SendGameMessage(quest, 178, 0x20); + player:SendGameMessage(quest, 179, 0x20); + player:SendGameMessage(quest, 180, 0x20); + player:SendGameMessage(quest, 181, 0x20); + player:SendGameMessage(quest, 182, 0x20); + player:SendGameMessage(quest, 183, 0x20); + quest:StartSequence(SEQ_070); + player:EndEvent(); + quest:UpdateENPCs(); + GetWorldManager():DoZoneChange(player, 175, nil, 0, 15, -75.242, 195.009, 74.572, -0.046); + return true; + elseif (classId == UNTIDY_OUTLANDER) then + callClientFunction(player, "delegateEvent", player, quest, "processEvent080_2"); + elseif (classId == MALNOURISHED_MIDLANDER) then + callClientFunction(player, "delegateEvent", player, quest, "processEvent080_3"); + elseif (classId == CORGUEVAIS) then + callClientFunction(player, "delegateEvent", player, quest, "processEvent080_10"); + elseif (classId == THANCRED_CAMP) then + callClientFunction(player, "delegateEvent", player, quest, "processEvent080_11"); + elseif (classId == HELPLESS_HYUR) then + callClientFunction(player, "delegateEvent", player, quest, "processEvent080_9"); + end + + player:EndEvent(); +end + +function seq090_onTalk(player, quest, npc, classId) + -- The sickroom ward survey (texts 386-388, 198-200). + if (classId == SLOW_WITTED_MINER) then + callClientFunction(player, "delegateEvent", player, quest, "processEvent210_6"); + elseif (classId == MUSCULAR_MINER) then + callClientFunction(player, "delegateEvent", player, quest, "processEvent210_7"); + elseif (classId == QUALMISH_MINER) then + callClientFunction(player, "delegateEvent", player, quest, "processEvent210_8"); + elseif (classId == WORRISOME_ASSISTANT) then + callClientFunction(player, "delegateEvent", player, quest, "processEvent210_3"); + elseif (classId == WELL_WASHED_LEECH) then + callClientFunction(player, "delegateEvent", player, quest, "processEvent210_4"); + end + player:EndEvent(); +end + +function onPush(player, quest, npc) + local sequence = quest:getSequence(); + local classId = npc:GetActorClassId(); + local data = quest:GetData(); + + if (sequence == SEQ_015) then + if (classId == ARENA_TRIGGER and data:GetCounter(CNTR_SEQ15_GLD) == 2) then + -- Retail: "Duty calls. Do you wish to proceed with 'Court + -- in the Sands'?" — the same duty-join dialog as the + -- sibling escorts. + local result = callClientFunction(player, "delegateEvent", player, quest, "contentsJoinAskInBasaClass"); + if (result == 1) then + startMan0u1Coliseum(player, quest); + return; + end + player:EndEvent(); + end + elseif (sequence == SEQ_060) then + if (classId == GATE_OF_NALD_TRIGGER) then + local result = callClientFunction(player, "delegateEvent", player, quest, "contentsJoinAskInBasaClass"); + if (result == 1) then + startMan0u1Escort(player, quest); + return; + end + player:EndEvent(); + end + elseif (sequence == SEQ_090) then + if (classId == WARD_DOOR) then + -- man0u220: the Warburton-death echo (retail CS17). Ends + -- fadeInAfterWarp — resolved by the same-PA reload below + -- (retail stays instanced through the echo; design-doc + -- checkpoint 1). Momodi's glow fires at SEQ_095's report. + callClientFunction(player, "delegateEvent", player, quest, "processEvent220"); + quest:StartSequence(SEQ_095); + player:EndEvent(); + quest:UpdateENPCs(); + GetWorldManager():DoZoneChange(player, 209, "PrivateAreaMasterPast", 5, 15, -218.0, 229.5, 306.0, 0.0); + return; + end + elseif (sequence == SEQ_100 or sequence == SEQ_105) then + if (classId == WARD_DOOR) then + -- Post-report the door is the exit back to the public + -- Phrontistery floor. + player:EndEvent(); + quest:UpdateENPCs(); + GetWorldManager():DoZoneChange(player, 209, nil, 0, 15, -211.7, 229.6, 279.0, -2.7); + return; + end + end + quest:UpdateENPCs(); +end + +function onEmote(player, quest, npc, eventName) + local data = quest:GetData(); + local sequence = quest:getSequence(); + local classId = npc:GetActorClassId(); + + -- Which of the six taught emotes did the player perform? (The + -- emoteDefaultN conditions are seeded in teach order on F'lhaminn + -- and both miners — seed/095.) + local performed = nil; + for i = 1, #EMOTE_STEPS do + if (eventName == EMOTE_STEPS[i].eventName) then + performed = i; + break; + end + end + if (performed ~= nil) then + player:DoEmote(npc.Id, EMOTE_STEPS[performed].doEmoteId, EMOTE_STEPS[performed].motionMsg); + wait(2.5); + end + + if (sequence == SEQ_050 and classId == FLHAMINN_SCENE and performed ~= nil) then + local step = data:GetCounter(CNTR_EMOTE_TEACH); + if (step < 6) then + if (performed == step + 1) then + data:IncCounter(CNTR_EMOTE_TEACH); + if (step + 1 < 6) then + -- Next lesson (she acks the success inside the + -- next 051_x teach line — texts 345-349 all open + -- with the "Yes, just so" family). + callClientFunction(player, "delegateEvent", player, quest, "processEvent051_" .. (step + 2)); + else + -- Soothe done — the go-signal (text 350), then the + -- calming phase. + callClientFunction(player, "delegateEvent", player, quest, "processEvent051_8"); + player:EndEvent(); + quest:StartSequence(SEQ_057); + return; + end + else + -- Wrong emote: "What? You've forgotten already!?" + + -- she replays the demo for the step the player is + -- stuck on (the only 4-arg delegate in the client + -- script — selector 51..56). + callClientFunction(player, "delegateEvent", player, quest, "processEvent051_7", 50 + step + 1); + end + end + elseif (sequence == SEQ_057 and performed ~= nil) then + if (classId == MADDENED_MINER and not data:GetFlag(FLAG_CALM_MADDENED)) then + if (performed == MADDENED_STEP) then + data:SetFlag(FLAG_CALM_MADDENED); + else + -- Rebuff (056_1..3, texts 353/355/357), rotating on + -- the wrong-emote index so the barks vary. + callClientFunction(player, "delegateEvent", player, quest, "processEvent056_" .. ((performed % 3) + 1)); + end + elseif (classId == MANIC_MINER and data:GetCounter(CNTR_MANIC_CALM) < #MANIC_SEQUENCE) then + local manicStep = data:GetCounter(CNTR_MANIC_CALM); + if (performed == MANIC_SEQUENCE[manicStep + 1]) then + data:IncCounter(CNTR_MANIC_CALM); + else + -- Rebuff (055_1..3, texts 352/354/356). + callClientFunction(player, "delegateEvent", player, quest, "processEvent055_" .. ((performed % 3) + 1)); + end + end + -- Both becalmed → the Popokkuli & Seserukka ending (texts + -- 109, 107-113 — no client event exists for the scene; + -- server-side says, the man0l1 seq007_endSequence recipe), + -- then the reload into instance #2. + if (data:GetFlag(FLAG_CALM_MADDENED) and data:GetCounter(CNTR_MANIC_CALM) >= #MANIC_SEQUENCE) then + player:SendGameMessage(quest, 109, 0x20); + player:SendGameMessage(quest, 107, 0x20); + player:SendGameMessage(quest, 108, 0x20); + player:SendGameMessage(quest, 110, 0x20); + player:SendGameMessage(quest, 111, 0x20); + player:SendGameMessage(quest, 112, 0x20); + player:SendGameMessage(quest, 113, 0x20); + quest:StartSequence(SEQ_058); + player:EndEvent(); + quest:UpdateENPCs(); + GetWorldManager():DoZoneChange(player, 209, "PrivateAreaMasterPast", 5, 15, -97.0, 195.6, 308.0, -0.5); + return; + end + end + + player:EndEvent(); + quest:UpdateENPCs(); +end + +function onNotice(player, quest, target) + local sequence = quest:getSequence(); + + if (sequence == SEQ_000) then + -- The "what an Instance is" explainer (worldMaster says + -- 329/330 client-side) — pmeteor's implemented behavior, + -- sequence-gated so stray kicks can't replay it. Driven with + -- the RAW non-parking RunEventFunction: the client body is two + -- bare worldMaster.say calls with NO talk-turn/cutscene/_wait, + -- i.e. the processEventTu_001 no-EventUpdate shape whose + -- callClientFunction park-forever event-lock is documented at + -- man0l1's onNotice — the raw form lets the EndEvent below + -- close the notice. (Currently no kick routes to 110010, so + -- this arm is dormant; kept park-safe for when the instance- + -- explainer beat gets wired.) + player:RunEventFunction("delegateEvent", player, quest, "processEvent000_1"); + player:EndEvent(); + else + -- Safety net: any stray noticeEvent kick (e.g. a content + -- director's) landing after the sequence advanced only needs + -- the EndEvent to close — leaving it open event-locks the + -- client (man0l1/man0g1 onNotice rule). + player:EndEvent(); + end + quest:UpdateENPCs(); +end + +function onNpcLS(player, quest, from, msgStep) + local sequence = quest:getSequence(); + local msgPack; + + if (from == 1) then + if (sequence == SEQ_015 or sequence == SEQ_045) then + msgPack = 1; + elseif (sequence == SEQ_075 or sequence == SEQ_080) then + msgPack = 2; + elseif (sequence == SEQ_100 or sequence == SEQ_105) then + msgPack = 3; + end + if (msgPack == nil) then + player:EndEvent(); + return; + end + + player:SendGameMessageLocalizedDisplayName(quest, NPCLS_MSGS[msgPack][msgStep], MESSAGE_TYPE_NPC_LINKSHELL, MOMODI_DISPLAY_ID); + if (msgStep >= #NPCLS_MSGS[msgPack]) then + quest:EndOfNpcLsMsgs(); + else + quest:ReadNpcLsMsg(); + end + + if (sequence == SEQ_015) then + quest:StartSequenceForNpcLs(SEQ_045); + elseif (sequence == SEQ_075) then + quest:StartSequenceForNpcLs(SEQ_080); + elseif (sequence == SEQ_100) then + quest:StartSequenceForNpcLs(SEQ_105); + end + end + + player:EndEvent(); +end + +-- ===== The Coliseum first-round match (Garlemald-Server #53) ===== +-- Retail shape (both full playthroughs): instance entry → "Duty +-- calls" confirm → Now Loading → "You are now bound by duty." → an +-- UNWINNABLE 1v1 vs the Tourney Gladiator (2280157) — "Otto/Siddeon +-- is defeated." — → "A splendid match! I expected no less. / Don't +-- let losing get to you." Loss IS the completion condition (Mirke +-- footnote: "There is no way to win."; the beta uploader: the +-- gladiator is immortal, you lose by KO or timeout). +-- +-- Content shape mirrors the wire-proven sibling escorts: rescue latch +-- → CreateContentArea → director kick (Rust defers the 0x012F TX past +-- the warp ack) → EndEvent BEFORE the warp → DoZoneChangeContent +-- (spawnType 16). +function startMan0u1Coliseum(player, quest) + local data = quest:GetData(); + data:SetFlag(FLAG_ARENA_HANDOFF); + data:IncCounter(CNTR_SEQ15_GLD); -- 2 → 3 (fight handed off) + -- Consume the latch NOW: UpdateENPCs re-runs onStateChange, whose + -- GLD==3 arm sees the flag SET and clears it (the StartSequence- + -- fired consume of the sibling escorts, which don't apply here + -- because the arena doesn't advance the sequence). Any LATER + -- arrival at GLD==3 — the relog re-arm after the content died with + -- the session — sees it CLEAR and rolls back to 2 so the arena + -- trigger re-arms for a retry. + quest:UpdateENPCs(); + + local contentArea = player.CurrentArea:CreateContentArea(player, "/Area/PrivateArea/Content/PrivateAreaMasterSimpleContent", "Man0u101", "SimpleContentMan0u101", "Quest/QuestDirectorMan0u101", 209); + if (contentArea == nil) then + return; + end + local director = contentArea:GetContentDirector(); + player:AddDirector(director); + director:StartDirector(false); + player:KickEvent(director, "noticeEvent", true); + player:SetLoginDirector(director); + + -- Close the push event BEFORE the warp (0x0131 ahead of the + -- 0x00E2 reload latch — retail invariant; a mid-reload EndEvent is + -- the desktopWidgetMode-16 menu lock). No pre-warp cutscene here: + -- retail cuts straight from the confirm to the Now-Loading into + -- the arena. + player:EndEvent(); + + -- The bloodsands. Anchored at the GC-quest coliseum marker + -- (11092101 → -205.4, 150.9; floor y from the lobby rows) — the + -- only in-data arena anchor; flagged for in-client tuning + -- (seed/096). Offset a few units from the gladiator's spawn point + -- (the sibling escorts' player-vs-duty-NPC spacing) so the player + -- doesn't materialize inside the opponent's model. + GetWorldManager():DoZoneChangeContent(player, contentArea, -209.9, 192.0, 146.4, 0.8, 16); +end + +-- ===== The F'lhaminn escort (Garlemald-Server #53) ===== +-- Gate of Nald → Camp Black Brush, zone 170 (both endpoints on the +-- field map per the quest_marker sheet: gate -34.5/-68.9 → camp +-- 34.8/-480.5). 30-minute duty, chinchilla (2204010) ambushes that +-- one-shot-die at opener levels (retail: 60-80 dmg Light Slash kills, +-- 511-650 EXP each). The startMan0l1Content/startMan0g1Content +-- 10-step shape, wire-proven on both siblings. +function startMan0u1Escort(player, quest) + local contentArea = player.CurrentArea:CreateContentArea(player, "/Area/PrivateArea/Content/PrivateAreaMasterSimpleContent", "Man0u102", "SimpleContentMan0u102", "Quest/QuestDirectorMan0u102", 170); + if (contentArea == nil) then + return; + end + local director = contentArea:GetContentDirector(); + player:AddDirector(director); + director:StartDirector(false); + player:KickEvent(director, "noticeEvent", true); + player:SetLoginDirector(director); + + -- man0u170: the gate-side duty-start cutscene IN PLACE (arms the + -- after-warp veil; the director's questBaseRewardSeting dismisses + -- it post-warp). + callClientFunction(player, "delegateEvent", player, quest, "processEvent070"); + + player:EndEvent(); + + -- Duty warp to the gate-side start of the escort route + -- (spawnType 16 → wipe + 0x00E2(0x10) + 34108 "You have entered + -- an instance." + zone-in bundle). NOTHING may follow this call. + GetWorldManager():DoZoneChangeContent(player, contentArea, -34.5, 183.0, -68.9, -3.0, 16); +end + +function getJournalInformation(player, quest) + local sequence = quest:getSequence(); + local data = quest:GetData(); + -- Journal qtdata args (pmeteor stub shape): the Velodyna Cosmos + -- shows while the flower is still in hand (sold at the GSM leg), + -- the Coliseum Pass from its grant until the fight is done. + local velodyna = 0; + local pass = 0; + if (sequence <= SEQ_015 and data:GetCounter(CNTR_SEQ15_GSM) == 0) then + velodyna = ITEM_VELODYNA_COSMOS; + end + if (sequence >= SEQ_012 and sequence <= SEQ_015 and data:GetCounter(CNTR_SEQ15_GLD) < 4) then + pass = ITEM_COLISEUM_PASS; + end + return 0, velodyna, pass; +end function getJournalMapMarkerList(player, quest) local sequence = quest:getSequence(); + local data = quest:GetData(); local possibleMarkers = {}; - if (sequence == SEQ_000) then + if (sequence == SEQ_000 or sequence == SEQ_010 or sequence == SEQ_012 or sequence == SEQ_070 or sequence == SEQ_105) then table.insert(possibleMarkers, MRKR_MOMODI); - elseif (sequence == SEQ_010) then - if (not quest:GetFlag(FLAG_SEQ010_TALK0)) then - table.insert(possibleMarkers, MRKR_YAYATOKI) - else - table.insert(possibleMarkers, MRKR_ADV_GUILD); - end + elseif (sequence == SEQ_005) then + table.insert(possibleMarkers, MRKR_CAMP_BLACK_BRUSH); + elseif (sequence == SEQ_015) then + if (data:GetCounter(CNTR_SEQ15_GSM) == 0) then + table.insert(possibleMarkers, MRKR_ESHTAIME); + end + if (data:GetCounter(CNTR_SEQ15_GLD) < 5) then + table.insert(possibleMarkers, MRKR_COLISEUM); + end + elseif (sequence == SEQ_045) then + table.insert(possibleMarkers, MRKR_LINETTE); + elseif (sequence == SEQ_050) then + table.insert(possibleMarkers, MRKR_FLHAMINN_TEACH); + elseif (sequence == SEQ_057) then + if (data:GetCounter(CNTR_MANIC_CALM) < #MANIC_SEQUENCE) then + table.insert(possibleMarkers, MRKR_MANIC_MINER); + end + if (not data:GetFlag(FLAG_CALM_MADDENED)) then + table.insert(possibleMarkers, MRKR_MADDENED_MINER); + end + elseif (sequence == SEQ_060) then + table.insert(possibleMarkers, MRKR_GATE_OF_NALD); + elseif (sequence == SEQ_065) then + table.insert(possibleMarkers, MRKR_CAMP_LEADER); + elseif (sequence == SEQ_075) then + table.insert(possibleMarkers, MRKR_CONCERN_STAGE); + elseif (sequence == SEQ_080 or sequence == SEQ_085) then + table.insert(possibleMarkers, MRKR_PHRONTISTERY); + elseif (sequence == SEQ_090 or sequence == SEQ_095) then + table.insert(possibleMarkers, MRKR_WARD_DOOR); end - return unpack(possibleMarkers) + return unpack(possibleMarkers); end - - diff --git a/scripts/lua/unique/wil0Battle01/PopulaceStandard/exit_trigger.lua b/scripts/lua/unique/wil0Battle01/PopulaceStandard/exit_trigger.lua index 1118f89d..f32bb4e2 100644 --- a/scripts/lua/unique/wil0Battle01/PopulaceStandard/exit_trigger.lua +++ b/scripts/lua/unique/wil0Battle01/PopulaceStandard/exit_trigger.lua @@ -4,26 +4,39 @@ require ("quests/man/man0u0") function onSpawn(player, npc) man0u0Quest = player:GetQuest("Man0u0"); - + if (man0u0Quest ~= nil) then player:SetEventStatus(npc, "pushDefault", true, 0x2); - if (man0u0Quest ~= nil and man0u0Quest:GetQuestFlag(MAN0U0_FLAG_MINITUT_DONE1) == true and man0U0Quest:GetQuestFlag(MAN0U0_FLAG_MINITUT_DONE2) == true and man0u0Quest:GetQuestFlag(MAN0U0_FLAG_MINITUT_DONE3) == true) then + -- (was `man0U0Quest` on the DONE2 read — a nil-index typo) + if (man0u0Quest ~= nil and man0u0Quest:GetQuestFlag(MAN0U0_FLAG_MINITUT_DONE1) == true and man0u0Quest:GetQuestFlag(MAN0U0_FLAG_MINITUT_DONE2) == true and man0u0Quest:GetQuestFlag(MAN0U0_FLAG_MINITUT_DONE3) == true) then npc:SetQuestGraphic(player, 0x3); - else + else npc:SetQuestGraphic(player, 0x0); end end - + end -function onEventStarted(player, npc, triggerName) - man0u0Quest = GetStaticActor("Man0u0"); - - if (man0u0Quest:GetQuestFlag(MAN0U0_FLAG_MINITUT_DONE1) ~= true) then - print "AAAA" +function onEventStarted(player, npc, triggerName) + -- player:GetQuest, NOT GetStaticActor: LuaStaticActor exposes no + -- GetQuestFlag (the "attempt to call a nil value" error on every + -- post-claim push of this trigger — live 2026-07-10 01:36; the + -- Limsa twin exit_door.lua uses GetQuest). This legacy arm only + -- fires when the quest no longer claims the push (SEQ_005+), so + -- nil-guard and bail rather than re-run the content handoff. + man0u0Quest = player:GetQuest("Man0u0"); + -- Only meaningful at SEQ_000 (where the quest's own onPush claims + -- the armed trigger first anyway). Post-advance strays — like the + -- duplicate client push right after the content warp — must NOT + -- re-create the tutorial content (the flags stay 0xF forever, so + -- without the sequence gate this arm would spawn a second content + -- area mid-tutorial). Close and bail. + if (man0u0Quest == nil or man0u0Quest:GetSequence() ~= SEQ_000) then + player:EndEvent(); + return; end - - if (man0u0Quest ~= nil and man0u0Quest:GetQuestFlag(MAN0U0_FLAG_MINITUT_DONE1) == true and man0u0Quest:GetQuestFlag(MAN0U0_FLAG_MINITUT_DONE2) == true and man0u0Quest:GetQuestFlag(MAN0U0_FLAG_MINITUT_DONE3) == true) then + + if (man0u0Quest:GetQuestFlag(MAN0U0_FLAG_MINITUT_DONE1) == true and man0u0Quest:GetQuestFlag(MAN0U0_FLAG_MINITUT_DONE2) == true and man0u0Quest:GetQuestFlag(MAN0U0_FLAG_MINITUT_DONE3) == true) then player:EndEvent(); contentArea = player:GetZone():CreateContentArea(player, "/Area/PrivateArea/Content/PrivateAreaMasterSimpleContent", "man0u01", "SimpleContent30079", "Quest/QuestDirectorMan0u001"); diff --git a/scripts/tests/quests/uldah_msq2.lua b/scripts/tests/quests/uldah_msq2.lua new file mode 100644 index 00000000..7cb82e2f --- /dev/null +++ b/scripts/tests/quests/uldah_msq2.lua @@ -0,0 +1,210 @@ +-- Ul'dah Main Story route #2 - "Court in the Sands" (Man0u1). +-- +-- Every leg here is a pure quest-hook walk (the `walkthrough` bridge). The +-- two duty handoffs (coliseum, escort) are asserted up to the +-- CreateContentArea/DoZoneChangeContent burst; the fights themselves run on +-- the content substrate and the next leg picks up seeded past them. Legs +-- that advance outside the quest hooks can't be driven by the bridge either +-- and are seeded the same way, with a comment at each seam: +-- * SEQ_005 -> 010 rides the Camp Black Brush aetheryte touch +-- (AetheryteParent's 110010 arm), +-- * SEQ_015 -> 045, SEQ_075 -> 080, SEQ_100 -> 105 ride Momodi's NpcLS +-- linkpearl reads (onNpcLS). + +-- Actor class ids (from scripts/lua/quests/man/man0u1.lua). +local MOMODI = 1000841 +local ELECOTTE = 1000950 +local YOYOBINA = 1000927 +local LULUTSU = 1000863 +local ARENA_TRIGGER = 1090283 +local LINETTE = 1000861 +local FLHAMINN_SCENE = 1000842 +local FLHAMINN = 1000038 +local MANIC_MINER = 1001283 +local MADDENED_MINER = 1001284 +local CORGUEVAIS_SCENE = 1001054 +local GATE_OF_NALD = 1090285 +local ASCILIA_CAMP = 1001515 +local HELPLESS_HYUR = 1000817 +local NOGELOIX = 1000597 +local FAUSTIGEANT = 1000852 +local WARD_DOOR = 1090119 + +-- Quest flag bits. +local FLAG_ARENA_HANDOFF = 0 +local FLAG_CALM_MADDENED = 1 +local FLAG_CAMP_ECHO_SEEN = 2 + +describe("Ul'dah - Court in the Sands (Man0u1)", function() + it("SEQ_000: the Quicksand intro drops to public Ul'dah", function() + local w = walkthrough.start("Man0u1") + + -- onStart plays the arrival delegate, then warps into the intro PA. + w:onStart():expectStartSequence(0) + :expectDelegate("processEventMomodiStart"):ack() + + w:stateChange(0):expectEnpc(MOMODI, QFLAG_TALK) + + -- Momodi's briefing (man0u110) -> SEQ_005 (run to Camp Black Brush). + w:talk(MOMODI):expectDelegate("processEvent010"):ack():expectStartSequence(5) + end) + + -- SEQ_005 -> SEQ_010 advances on the Camp Black Brush aetheryte touch + -- (AetheryteParent's 110010 arm), not a quest hook - seed SEQ_010. + it("SEQ_010/SEQ_012: the Momodi double-talk arms the guild legs", function() + local w = walkthrough.start("Man0u1", 10) + + w:stateChange(10):expectEnpc(MOMODI, QFLAG_TALK) + w:talk(MOMODI):expectDelegate("processEvent015"):ack():expectStartSequence(12) + + -- Second talk: guild schooling + the Coliseum Pass grant. + w:talk(MOMODI):expectDelegate("processEvent017"):ack():expectStartSequence(15) + end) + + it("SEQ_015: GSM leg - Elecotte's appraisal echo", function() + local w = walkthrough.start("Man0u1", 15) + + -- Both guild counters fresh: both first beats are lit. + w:stateChange(15) + :expectEnpc(ELECOTTE, QFLAG_TALK) + :expectEnpc(YOYOBINA, QFLAG_TALK) + + -- man0u120 (in-place cutscene) + the 2,000-gil flower payment. + w:talk(ELECOTTE):expectDelegate("processEvent020"):ack() + end) + + it("SEQ_015: GLD leg - the Coliseum lobby into the arena handoff", function() + local w = walkthrough.start("Man0u1", 15) + + -- Yoyobina's recruitment echo (man0u130) -> the lobby PA warp. + w:talk(YOYOBINA):expectDelegate("processEvent030"):ack() + + -- Lulutsu's Coliseum Pass check arms the arena-gate trigger. + w:talk(LULUTSU):expectDelegate("processEvent030_2"):ack() + w:stateChange(15):expectEnpc(ARENA_TRIGGER, QFLAG_PUSH) + + -- The arena gate: duty confirm -> the coliseum content burst (the + -- handoff latch is set right before the warp). + w:push(ARENA_TRIGGER):expectDelegate("contentsJoinAskInBasaClass") + :answer(1) + :expectFlagSet(FLAG_ARENA_HANDOFF) + :expectCreateContentArea() + :expectZoneChangeContent() + end) + + -- The coliseum defeat (processEvent035), the echo PA (Greinfarr's + -- processEvent040), and the SEQ_015 -> 045 NpcLS advance all run past + -- the hook bridge - seed SEQ_045. + it("SEQ_045/SEQ_050: Linette -> F'lhaminn's emote school", function() + local w = walkthrough.start("Man0u1", 45) + + w:stateChange(45):expectEnpc(LINETTE, QFLAG_TALK) + + -- man0u150 brawl-intro cutscene -> miner instance #1. + w:talk(LINETTE):expectDelegate("processEvent050"):ack():expectStartSequence(50) + + -- Wrong emote first: "You've forgotten already!?" - she replays the + -- demo for the stuck step (the 4-arg 051_7 delegate). + w:emote(FLHAMINN_SCENE, "emoteDefault2") + :expectDelegate("processEvent051_7"):ack() + + -- The six lessons in teach order: each correct emote bumps the + -- counter and plays the next teach line (051_2..051_6). + w:emote(FLHAMINN_SCENE, "emoteDefault1"):expectDelegate("processEvent051_2"):ack() + w:emote(FLHAMINN_SCENE, "emoteDefault2"):expectDelegate("processEvent051_3"):ack() + w:emote(FLHAMINN_SCENE, "emoteDefault3"):expectDelegate("processEvent051_4"):ack() + w:emote(FLHAMINN_SCENE, "emoteDefault4"):expectDelegate("processEvent051_5"):ack() + w:emote(FLHAMINN_SCENE, "emoteDefault5"):expectDelegate("processEvent051_6"):ack() + + -- Soothe completes the set: the go-signal, then the calming phase. + w:emote(FLHAMINN_SCENE, "emoteDefault6"):expectDelegate("processEvent051_8") + :ack():expectStartSequence(57) + end) + + it("SEQ_057..SEQ_060: calming emotes -> Corguevais -> the escort handoff", function() + local w = walkthrough.start("Man0u1", 57) + + -- Both miners are emote targets until becalmed. + w:stateChange(57) + :expectEnpc(MADDENED_MINER, QFLAG_TALK) + :expectEnpc(MANIC_MINER, QFLAG_TALK) + + -- The Maddened Miner wants Beckon (emoteDefault2). + w:emote(MADDENED_MINER, "emoteDefault2"):expectFlagSet(FLAG_CALM_MADDENED) + + -- The Manic Miner wants Soothe -> Furious -> Laugh; the third emote + -- becalms both and reloads into miner instance #2. + w:emote(MANIC_MINER, "emoteDefault6") + w:emote(MANIC_MINER, "emoteDefault1") + w:emote(MANIC_MINER, "emoteDefault3"):expectStartSequence(58) + + -- SEQ_058: Corguevais's thanks (man0u160) -> the Gate of Nald muster. + w:talk(CORGUEVAIS_SCENE):expectDelegate("processEvent060") + :ack():expectStartSequence(60) + + -- SEQ_060: the gate trigger -> duty confirm -> the escort content + -- burst (man0u170 plays in place before the duty warp). + w:push(GATE_OF_NALD):expectDelegate("contentsJoinAskInBasaClass") + :answer(1) + :expectCreateContentArea() + :expectDelegate("processEvent070") + :ack() + :expectZoneChangeContent() + end) + + -- The escort duty runs on the content substrate; its director advances + -- the quest to SEQ_065 at the camp arrival - seed there. + it("SEQ_065..SEQ_075: the Ascilia echo -> Momodi gossip -> the payment", function() + local w = walkthrough.start("Man0u1", 65) + + w:stateChange(65) + :expectEnpc(ASCILIA_CAMP, QFLAG_TALK) + :expectEnpc(HELPLESS_HYUR, QFLAG_OFF) + + -- man0u180 echo -> the camp-leader beat unlocks. + w:talk(ASCILIA_CAMP):expectDelegate("processEvent080") + :ack():expectFlagSet(FLAG_CAMP_ECHO_SEEN) + w:stateChange(65) + :expectEnpc(ASCILIA_CAMP, QFLAG_OFF) + :expectEnpc(HELPLESS_HYUR, QFLAG_TALK) + + -- The leader's rebuff (server-side says, no delegate) -> back to + -- Ul'dah at SEQ_070. + w:talk(HELPLESS_HYUR):expectStartSequence(70) + + -- SEQ_070: Momodi's F'lhaminn gossip -> SEQ_075. + w:talk(MOMODI):expectDelegate("processEvent200_2"):ack():expectStartSequence(75) + + -- SEQ_075: the escort payment. The advance to SEQ_080 rides Momodi's + -- NpcLS cave-in pack (onNpcLS), not drivable from the hook bridge. + w:talk(FLHAMINN):expectDelegate("processEvent200"):ack() + end) + + -- SEQ_075 -> SEQ_080 advances on the NpcLS read - seed SEQ_080. + it("SEQ_080..SEQ_095: the Phrontistery survey", function() + local w = walkthrough.start("Man0u1", 80) + + w:stateChange(80):expectEnpc(NOGELOIX, QFLAG_TALK) + w:talk(NOGELOIX):expectDelegate("processEvent205"):ack():expectStartSequence(85) + + w:talk(FAUSTIGEANT):expectDelegate("processEvent210"):ack():expectStartSequence(90) + + -- The ward door -> the Warburton echo (man0u220) -> the report due. + w:stateChange(90):expectEnpc(WARD_DOOR, QFLAG_PUSH) + w:push(WARD_DOOR):expectDelegate("processEvent220"):ack():expectStartSequence(95) + + -- The no-pay report (man0u230) -> Momodi's linkpearl glow. + w:talk(FAUSTIGEANT):expectDelegate("processEvent230"):ack():expectStartSequence(100) + end) + + -- SEQ_100 -> SEQ_105 advances on the NpcLS read - seed the turn-in. + it("SEQ_105: the Momodi turn-in completes the quest", function() + local w = walkthrough.start("Man0u1", 105) + + w:stateChange(105):expectEnpc(MOMODI, QFLAG_REWARD) + + w:talk(MOMODI):expectDelegate("processEventComplete") + :ack():expectDelegate("sqrwa") + :ack():expectCompleted(110010) + end) +end)