From ccba223d92fb462e5e405a2b2ada2a7b7767fc64 Mon Sep 17 00:00:00 2001 From: David Goldstein Date: Mon, 26 Oct 2026 17:09:39 -0400 Subject: [PATCH] Row 231: the Remnants of Horned Tail (3714): Nine Spirit's Baby Dragon trades each egg for a Dragon Stone and 42,000 EXP through its start script, which completes the quest at once, not while a stone is held; Act map and message are inert (b200: the client reads neither), so Dragon Squad's Mission (3706) is supported --- agent_docs/map-travel.md | 2 +- agent_docs/quests.md | 12 ++- game-data/placeholder_text.toml | 3 + game-data/server_content.toml | 3 + game-data/src/quests.rs | 17 +++-- game-data/src/server_content.rs | 15 +++- game-data/supported_quests.txt | 2 + integration-harness/tests/nine_spirit_egg.rs | 73 ++++++++++++++++++ net/src/npc_scripts.rs | 78 ++++++++++++++++++++ 9 files changed, 194 insertions(+), 11 deletions(-) create mode 100644 integration-harness/tests/nine_spirit_egg.rs diff --git a/agent_docs/map-travel.md b/agent_docs/map-travel.md index 795e27be..f12c1eac 100644 --- a/agent_docs/map-travel.md +++ b/agent_docs/map-travel.md @@ -268,7 +268,7 @@ nothing. A script returns effects that the client actor sends: | `foxLaidy_map` | Fox Ridge's (`222010300`) `west00`: to Tiger Ridge (`222010200` `east00`), or with 3647 taken and an Old Fox's Tail (`4031793`), into the Gloomy Forest (`922220000` `east00`). Cosmic also starts quest 23647, which isn't in `Quest.nx` | | `move_elin`, `minar_job4`, `gryphius`, `mayong`, `Pianus`, `hontale_morph2`, `eliza_Garden`, `catPriest_map`, `enterInfo` | Ways through (`PLAIN_WARPS`): the Time Control Room's `in01` into Ellin Forest (`300000100` `out00`, with a pink line), Leafre's to the Forest of the Priest, Griffey's and Manon's forests (`240010501`, `240020101`, `240020401` `out00`), Aqua Road's to the Cave of Pianus (`230040420` `out00`), the Horntail cave's back to the Peak of the Big Nest (`240040600` portal 4), Eliza's Garden (`920020000` portal 2), the Forest of the Ghost Priest (`925000000` portal 2), Lith Harbor's Info Shop (`104000004` portal 1). Cosmic's Aran-quest monster in the Info Shop (21733) isn't in | | `enterGym`, `rienCaveEnter` | The second Dangerous Forest's (`140010100`) way to Puo's training grounds (`914010000`/`914010100`/`914010200` portal 1 while 21701/21702/21703 is taken), and the Mirror Cave's (`140030000` portal 1, while 21201 or 21302 is taken); else a pink line | -| `dragonNest`, `go_secretroom`, `tristanEnter`, `q2073`, `enterDollWay`, `ariant_Agit`, `s4resur_enter`, `s4rush` | Doors a quest opens, else a pink line: the Nine Spirit's Nest (`240040612` `out00`, 3706 completed; Cosmic's egg event isn't in), the Mushroom Castle's Security Room (`106021001` portal 1, 2335 completed, or taken with its key `4032405`, which completes it, gives 5000 EXP and takes the key, once 2335 is in the quest catalog), Tristan's Resting Place (`105100101` `in00`, 2238 completed), Utah's Pig Farm (`900000000`, 2073 taken), the Cave of Evil Eye III (`105070300` portal 3, 20730 or 21734 completed) or the Puppeteer's Secret Passage (`910510100`, 21734 taken), the Sand Bandits' house (`260000201` portal 1, 3928, 3931 and 3934 completed), the Forgotten Darkness (`922020000`, 6134 taken), the Secret Shrine (`910500100`, 6110 taken). Several of these quests aren't supported yet (2238, 2335, 3706, 21201, 21302), so their doors stay shut for now | +| `dragonNest`, `go_secretroom`, `tristanEnter`, `q2073`, `enterDollWay`, `ariant_Agit`, `s4resur_enter`, `s4rush` | Doors a quest opens, else a pink line: the Nine Spirit's Nest (`240040612` `out00`, 3706 completed; Cosmic's egg event isn't in), the Mushroom Castle's Security Room (`106021001` portal 1, 2335 completed, or taken with its key `4032405`, which completes it, gives 5000 EXP and takes the key, once 2335 is in the quest catalog), Tristan's Resting Place (`105100101` `in00`, 2238 completed), Utah's Pig Farm (`900000000`, 2073 taken), the Cave of Evil Eye III (`105070300` portal 3, 20730 or 21734 completed) or the Puppeteer's Secret Passage (`910510100`, 21734 taken), the Sand Bandits' house (`260000201` portal 1, 3928, 3931 and 3934 completed), the Forgotten Darkness (`922020000`, 6134 taken), the Secret Shrine (`910500100`, 6110 taken). Several of these quests aren't supported yet (2238, 2335, 21201, 21302), so their doors stay shut for now | | `tutorialNPC`, `TD_chat_enter` | Open an NPC's conversation without a click (`Effect::OpenNpc`), unless one is open already: the `tutorial` portal in each job instructor's room (`101000003`, `100000201`, `102000003`, `103000003`, `120000101`) has a beginner up to level 10 hear the instructor (`1032001`, `1012100`, `1022000`, `1052001`, `1090000`); Leafre's `TD_neo` (`240070000`) opens the Time Gate (`2083006`) | | `enterDollcave` | The Cave of Evil Eye III's (`105070300`) `in00`: with 20730 or 21734 completed, portal sound and back to `105040201` `in00`; else nothing. Not yet: Cosmic's password wall (`1063011`, `PupeteerPassword`), and `thief_in1`'s (`ThiefPassword`) | | `s4firehawk`, `s4iceeagle`, `s4tornado_enter`, `s4resurrection`, `s4nest`, `s4hitman`, `s4berserk`, `s4berserk_move` | The fourth job's solo rooms (`scripts::SOLO_MAPS`), portal 0 unless said, one player at a time (a pink line while someone is there), each starting afresh when left: the Fire Hawk's nest (`211042200` `in00` to `921100200`, 6240 taken) and the Ice Eagle's (`211040700` `in00` to `921100210`, 6242 taken), else a pink line; the Tornado room (`220010001` `pt00` to `922020200`, 6230 or 6231 taken, or holding `4001110`), else nothing; the resurrection mound (`230040300` `in01` to `923000100`, holding a Cold Flame `4001108`, quest or not, as in Cosmic) and the nest of the eggs (`240010700` `pt00` to `924000100`, 6241 or 6243 taken), else a pink line. Their reactors take items (`combat.md`). The Hitman's hideout (`101030104` `pt00` to `910200000`, 6201 taken), else a pink line; its boxes lead up its floors. The Berserk room (`105090800` `in00` to `910500200` `out01`, 6153 taken and holding its key `4031475`), else a pink line; its way up to the statues, `s4berserk_move` (`in00` to `out00` within the map), stays shut with a pink line while a monster is left (`Effect::RequireNoMobs`, asking the field for its live mob count) | diff --git a/agent_docs/quests.md b/agent_docs/quests.md index 137cb920..ad49e3e6 100644 --- a/agent_docs/quests.md +++ b/agent_docs/quests.md @@ -177,7 +177,7 @@ itself ("Etc item inventory is full."). `startscript`/ `endscript` are allowed only for scripts the server implements (`game_data::quests::SCRIPTS`: `q1021s`, `q1021e`, Bartos's secret `q4647e`, Garnox's pet evolutions `q8185e`, `q8189e` and `q4659e` (see -[Pets](./pets.md)), Meren's Maker lessons and Dyle's informants below); those steps go through +[Pets](./pets.md)), Meren's Maker lessons, Dyle's informants and the baby dragon below); those steps go through QUEST_ACTION 4/5 and the server drives the dialogue (see [NPC dialogue](./npc-dialogue.md)). `money` pays (or, if negative, takes) mesos on completion; a quest that takes mesos needs them held, or the @@ -260,6 +260,16 @@ list. `game_data::quests::FINISHED_BY_START_SCRIPT` lets these four through without a completion NPC. The can's and the informants' lines are unsourced and stay placeholder (`dyle.*`). +"The Remnants of Horned Tail" (3714) works the same way. Nine Spirit's Baby +Dragon (2081011), in the nest past Leafre's `dragonNest` portal (240040612, +open once 3706 is done), has a start script (`q3714s`) and no end. It +takes a Nine Spirit's Egg (4001094) and gives a Dragon Stone (2041200) and +Cosmic's 42,000 EXP, completing the quest at once. The quest repeats +(`interval 0`), once for each egg, but not while a Dragon Stone is held, +as in Cosmic. Its lines are placeholders (`nine_spirit.baby.*`). 3706's +`Act/1/map` and `message` (and 2282-2284's `Act/0/message`) are read by +neither v83's client nor Cosmic, so they are ignored (`INERT_ACTIONS`). + The `game-data` asset test compares the server's result with the committed list `game-data/supported_quests.txt`. HeavenClient doesn't classify quests: the build copies that list next to the executable, and the client offers diff --git a/game-data/placeholder_text.toml b/game-data/placeholder_text.toml index 8ff945a4..a9662371 100644 --- a/game-data/placeholder_text.toml +++ b/game-data/placeholder_text.toml @@ -768,6 +768,9 @@ "dyle.informant.1" = "[placeholder] Shumi tells you what she knows about Dyle." "dyle.informant.2" = "[placeholder] Nella tells you what she knows about Dyle." "dyle.informant.3" = "[placeholder] Jake says Dyle was once a Ligator that grew from Growth Powder." +"nine_spirit.baby.hatched" = "[placeholder] The egg hatches on the nest. The baby dragon coughs up a Dragon Stone and asks you to take it away." +"nine_spirit.baby.stone_held" = "[placeholder] The Dragon Stone in your bag glows brighter here, and the baby dragon glares at it." +"nine_spirit.baby.full" = "[placeholder] You need a free USE slot for the Dragon Stone." "family.entitlement.0.name" = "[placeholder] Go to a relative" "family.entitlement.0.description" = "[placeholder] Go to a family member of your choice." diff --git a/game-data/server_content.toml b/game-data/server_content.toml index 56adb726..c9d53e56 100644 --- a/game-data/server_content.toml +++ b/game-data/server_content.toml @@ -1441,6 +1441,9 @@ unsourced = [ "dyle.informant.1", "dyle.informant.2", "dyle.informant.3", + "nine_spirit.baby.hatched", + "nine_spirit.baby.stone_held", + "nine_spirit.baby.full", ] [text."glTutoMsg0.message"] diff --git a/game-data/src/quests.rs b/game-data/src/quests.rs index 68f0dd70..23584088 100644 --- a/game-data/src/quests.rs +++ b/game-data/src/quests.rs @@ -64,13 +64,14 @@ pub const INERT_END_REQUIREMENTS: &[&str] = &["start", "end"]; /// Quest scripts (`startscript`/`endscript`) the server implements. A quest /// naming any other script is unsupported. pub const SCRIPTS: &[&str] = &[ - "q1021s", "q1021e", "q2214e", "q2215e", "q2216s", "q2217s", "q2218s", "q2219s", "q4647e", - "q4659e", "q6030e", "q6031e", "q6032e", "q6033e", "q6036e", "q8185e", "q8189e", + "q1021s", "q1021e", "q2214e", "q2215e", "q2216s", "q2217s", "q2218s", "q2219s", "q3714s", + "q4647e", "q4659e", "q6030e", "q6031e", "q6032e", "q6033e", "q6036e", "q8185e", "q8189e", ]; /// Quests with nothing to complete them by (an empty `Check/1`) whose start -/// script completes them as it starts them: JM's informants (2216-2219), -/// each heard from once (`Effect::FinishQuest`). -pub const FINISHED_BY_START_SCRIPT: &[i16] = &[2216, 2217, 2218, 2219]; +/// script completes them as it starts them (`Effect::FinishQuest`): JM's +/// informants (2216-2219), each heard from once, and the Remnants of Horned +/// Tail (3714), once for each Nine Spirit's Egg. +pub const FINISHED_BY_START_SCRIPT: &[i16] = &[2216, 2217, 2218, 2219, 3714]; /// Meren's Maker classes (quests 6030-6032) and the quest they make up /// (6029) share 6029's quest-info string, one digit per class: `Act/0 info` /// starts it at "000", a class starts only while its own digit is 0 @@ -133,9 +134,11 @@ pub const END_ACTIONS: &[&str] = &[ /// `quest`, a date or an `interval`). v83's client reads only `item`, /// `skill`, `exp`, `money`, `pop`, `pettameness` and `nextQuest` under /// `Act`, and Cosmic applies none of these, so they do nothing here either; -/// the `Check` copy is what counts. +/// the `Check` copy is what counts. `map` (a list of maps, only 3706) and +/// `message` (an untranslated line: 3706, 2282-2284) are read by neither +/// the client (report b200) nor Cosmic, so they are ignored too. pub const INERT_ACTIONS: &[&str] = &[ - "npc", "lvmin", "lvmax", "job", "quest", "interval", "start", "end", + "npc", "lvmin", "lvmax", "job", "quest", "interval", "start", "end", "map", "message", ]; /// Keys of an `Act//item/` entry the server applies. /// `var` (1 or 2, on equipment only) is read by neither v83's client nor diff --git a/game-data/src/server_content.rs b/game-data/src/server_content.rs index ce11791b..19f7dc97 100644 --- a/game-data/src/server_content.rs +++ b/game-data/src/server_content.rs @@ -757,8 +757,8 @@ pub mod keys { pub const MAKER_GOLD_ANVIL: [&str; 2] = ["maker.gold_anvil.0", "maker.gold_anvil.1"]; /// JM's list of informants (Kerning City, quests 2214-2219): the Knocked - /// Trash Can in the Hut in the Swamp (NPC 1052108) holds it from 5 to 8 p.m. (`q2214e`, and - /// `q2215e` for a lost list with its fee), then each informant tells + /// Trash Can in the Hut in the Swamp (NPC 1052108) holds it from 5 to 8 + /// p.m. (`q2214e`, and `q2215e` for a lost list with its fee), then each informant tells /// what they know about Dyle (`q2216s` Mr. Pickall, `q2217s` Shumi, /// `q2218s` Nella, `q2219s` Jake). No licensed script has their text, /// so these keep their placeholders. @@ -774,6 +774,14 @@ pub mod keys { "dyle.informant.3", ]; + /// The Remnants of Horned Tail (quest 3714, `q3714s`): Nine Spirit's + /// Baby Dragon (NPC 2081011) in its nest hatches from an egg and gives a + /// Dragon Stone, unless one is still held. No licensed script has the + /// text, so these keep their placeholders. + pub const NINE_SPIRIT_HATCHED: &str = "nine_spirit.baby.hatched"; + pub const NINE_SPIRIT_STONE_HELD: &str = "nine_spirit.baby.stone_held"; + pub const NINE_SPIRIT_FULL: &str = "nine_spirit.baby.full"; + /// Trainer Bartos's Follow the Lead (quests 4646 and 4647): the hidden /// note of his test (NPC 9102100, `multipet_success`), then his end /// script `q4647e`, which takes a Pet Snack. @@ -2975,6 +2983,9 @@ mod tests { .chain(&keys::MAKER_GOLD_ANVIL) .chain(&keys::DYLE_INFORMANTS) .chain(&[ + keys::NINE_SPIRIT_HATCHED, + keys::NINE_SPIRIT_STONE_HELD, + keys::NINE_SPIRIT_FULL, keys::DYLE_TRASH_CAN_NOT_YET, keys::DYLE_TRASH_CAN_FULL, keys::DYLE_TRASH_CAN_FOUND, diff --git a/game-data/supported_quests.txt b/game-data/supported_quests.txt index 486d0627..63ffb291 100644 --- a/game-data/supported_quests.txt +++ b/game-data/supported_quests.txt @@ -715,6 +715,7 @@ 3703 3704 3705 +3706 3707 3708 3709 @@ -722,6 +723,7 @@ 3711 3712 3713 +3714 3715 3716 3717 diff --git a/integration-harness/tests/nine_spirit_egg.rs b/integration-harness/tests/nine_spirit_egg.rs new file mode 100644 index 00000000..c2240f69 --- /dev/null +++ b/integration-harness/tests/nine_spirit_egg.rs @@ -0,0 +1,73 @@ +//! The Remnants of Horned Tail (3714): in Nine Spirit's nest, each egg +//! (4001094) hatches into a Dragon Stone (2041200) and 42,000 EXP through +//! the baby dragon's start script, which completes the quest at once. It +//! repeats for another egg, but not while a Dragon Stone is held. + +use integration_harness::connection::MapleTestConnection; +use integration_harness::database::{grant_stack, replace_item, set_job, set_quest}; +use integration_harness::hunting::walk_to; +use integration_harness::preconditions::load_harness_config_or_fail; +use integration_harness::quests::{answer, build_quest_action, SCRIPT_START}; +use integration_harness::trace::{step, warp, Trace}; +use integration_harness::{login_to_world_session, relogin}; +use std::time::Duration; + +const QUEST: i16 = 3714; +const BABY_DRAGON: i32 = 2_081_011; +const NEST: i32 = 240_040_612; +const EGG: i32 = 4_001_094; +const DRAGON_STONE: i32 = 2_041_200; +const USE: u8 = 2; +const ETC: u8 = 4; + +async fn hatch(c: &mut MapleTestConnection, m: &str) -> Trace { + let mut trace = Trace::default(); + assert_eq!(warp(c, NEST, "", &mut trace).await, NEST); + step(c, m, walk_to(-8, -227)).await; + let t = step(c, m, build_quest_action(SCRIPT_START, QUEST, BABY_DRAGON)).await; + assert_eq!(t.prompts, [(BABY_DRAGON, 0)], "the baby dragon speaks"); + step(c, m, answer(0, 1, None)).await +} + +#[tokio::test] +async fn each_egg_hatches_into_a_dragon_stone() { + let config = load_harness_config_or_fail().await; + let m = config.character_name.clone(); + let id = login_to_world_session(&config) + .await + .expect("first login failed") + .character_id; + tokio::time::sleep(Duration::from_millis(500)).await; + set_job(id, 110, 80, 100).await.expect("a level 80 fighter"); + set_quest(id, 3706, 2, "").await.expect("the nest open"); + grant_stack(id, ETC as i16, 1, EGG, 2) + .await + .expect("two eggs"); + + let mut session = relogin(&config).await; + let c = &mut session.connection; + let t = hatch(c, &m).await; + assert_eq!(t.progress, [(QUEST, 2)], "done at once"); + assert_eq!(t.gains, [42_000]); + assert_eq!(t.quantities, [(ETC, 1, 1)], "one egg taken"); + assert_eq!(t.added.len(), 1); + let (inventory, slot, item) = t.added[0]; + assert_eq!((inventory, item), (USE, DRAGON_STONE)); + // Not while the stone is held. + let t = hatch(c, &m).await; + assert!(t.progress.is_empty() && t.gains.is_empty() && t.quantities.is_empty()); + drop(session); + tokio::time::sleep(Duration::from_millis(500)).await; + + // With the stone gone, the second egg hatches too. + replace_item(id, USE as i16, slot, 2_000_000) + .await + .expect("the stone gone"); + let mut session = relogin(&config).await; + assert_eq!(session.quests.get(&QUEST), Some(&2)); + let c = &mut session.connection; + let t = hatch(c, &m).await; + assert_eq!(t.progress, [(QUEST, 2)], "{QUEST} repeats"); + assert_eq!(t.gains, [42_000]); + assert_eq!(t.removed, [(ETC, 1)], "the last egg taken"); +} diff --git a/net/src/npc_scripts.rs b/net/src/npc_scripts.rs index 0df389f9..32441486 100644 --- a/net/src/npc_scripts.rs +++ b/net/src/npc_scripts.rs @@ -2340,6 +2340,7 @@ impl NpcScript { "q2217s" => informant(player, 1), "q2218s" => informant(player, 2), "q2219s" => informant(player, 3), + "q3714s" => Some(baby_dragon(player)), "q6030e" => Some(maker_class(CARSON, keys::MAKER_ALCHEMY, 6030)), "q6031e" => Some(maker_class(HUGHES, keys::MAKER_SCIENCE, 6031)), "q6032e" => Some(maker_class(MEREN, keys::MAKER_PRACTICE, 6032)), @@ -2761,6 +2762,42 @@ fn trash_can(player: &Player, lost: bool, hour: u8) -> NpcScript { /// `q2216s`-`q2219s`: the informant at `index` tells what they know about /// Dyle, which completes the quest as it starts, for 7,000 EXP (Cosmic's; /// the data has none). The last of the four heard takes back JM's list. +/// Nine Spirit's Baby Dragon, in its nest (240040612). +const BABY_DRAGON: i32 = 2081011; +const NINE_SPIRIT_EGG: i32 = 4001094; +const DRAGON_STONE: i32 = 2041200; + +/// `q3714s`, the Remnants of Horned Tail: the egg from Horned Tail hatches +/// on the nest and the baby dragon gives up a Dragon Stone, with Cosmic's +/// 42,000 EXP (the data has no rewards). The quest repeats (`interval 0`), +/// once for each egg, but not while a Dragon Stone is held, as Cosmic. +fn baby_dragon(player: &Player) -> NpcScript { + if player.inventory.count(DRAGON_STONE) > 0 { + return NpcScript::new( + BABY_DRAGON, + vec![Page::Say(text(keys::NINE_SPIRIT_STONE_HELD))], + ); + } + let mut script = NpcScript::new( + BABY_DRAGON, + vec![Page::Say(text(keys::NINE_SPIRIT_HATCHED))], + ); + script.on_end = vec![ + Effect::FinishQuest(3714), + Effect::TakeItem { + item_id: NINE_SPIRIT_EGG, + quantity: 1, + }, + Effect::GiveItem { + item_id: DRAGON_STONE, + quantity: 1, + }, + Effect::GainExp(42_000), + ]; + script.inventory_full = Some(text(keys::NINE_SPIRIT_FULL)); + script +} + fn informant(player: &Player, index: usize) -> Option { let (quest, npc) = INFORMANTS[index]; let mut script = NpcScript::new(npc, vec![Page::Say(text(keys::DYLE_INFORMANTS[index]))]); @@ -9923,6 +9960,47 @@ mod tests { assert_eq!(read_through(early), (1052006, heard(2219))); } + #[test] + fn the_baby_dragon_trades_each_egg_for_a_dragon_stone() { + let mut player = with_quests(&[(3706, true)]); + player.inventory.insert( + game::InventoryType::Etc, + 1, + game::Item::stack(NINE_SPIRIT_EGG, 2), + ); + assert!(NpcScript::for_quest("q3714s", &player) + .unwrap() + .inventory_full() + .is_some()); + let hatched = NpcScript::for_quest("q3714s", &player).unwrap(); + assert_eq!( + read_through(hatched), + ( + BABY_DRAGON, + vec![ + Effect::FinishQuest(3714), + Effect::TakeItem { + item_id: NINE_SPIRIT_EGG, + quantity: 1 + }, + Effect::GiveItem { + item_id: DRAGON_STONE, + quantity: 1 + }, + Effect::GainExp(42_000), + ] + ) + ); + // Not while a stone is held. + player.inventory.insert( + game::InventoryType::Use, + 1, + game::Item::stack(DRAGON_STONE, 1), + ); + let held = NpcScript::for_quest("q3714s", &player).unwrap(); + assert_eq!(read_through(held), (BABY_DRAGON, vec![])); + } + #[test] fn a_maker_class_ends_with_its_teacher() { for (script, npc, quest) in [