From d0273721e6564c90aef3b3cb202c40846f846338 Mon Sep 17 00:00:00 2001 From: David Goldstein Date: Mon, 26 Oct 2026 17:23:25 -0400 Subject: [PATCH] Row 231: Captain Al's family lessons (2232-2234) end through his end scripts at him, by the Family window's numbers: a junior taken (3,000 EXP), 1,000 Rep earned, then 2,000 earned with under 500 left; a Check/1 end script counts as a way to complete --- agent_docs/families.md | 3 + agent_docs/quests.md | 14 ++- game-data/placeholder_text.toml | 6 ++ game-data/server_content.toml | 6 ++ game-data/src/quests.rs | 12 ++- game-data/src/server_content.rs | 21 ++++ game-data/supported_quests.txt | 3 + integration-harness/src/database.rs | 37 +++++++ .../tests/captain_al_family.rs | 100 ++++++++++++++++++ net/src/npc_scripts.rs | 78 +++++++++++++- runtime/src/actor/client/quest.rs | 18 +++- 11 files changed, 287 insertions(+), 11 deletions(-) create mode 100644 integration-harness/tests/captain_al_family.rs diff --git a/agent_docs/families.md b/agent_docs/families.md index 826c2db2c..2276675eb 100644 --- a/agent_docs/families.md +++ b/agent_docs/families.md @@ -38,6 +38,9 @@ with the character, read again once the character is in the directory (a change made while they were entering the game reached no one), and copied into the directory's `Presence`. +Captain Al's three family quests (2232-2234) end by these numbers: a +junior, then the Rep earned and left (see [Quests](./quests.md)). + ### Requests The handlers are in `net/src/packet/handle/world/family.rs`. They turn diff --git a/agent_docs/quests.md b/agent_docs/quests.md index ad49e3e66..79cab853b 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, Dyle's informants and the baby dragon below); those steps go through +[Pets](./pets.md)), Meren's Maker lessons, Dyle's informants, the baby dragon and Captain Al's family lessons 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 @@ -270,6 +270,18 @@ 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`). +Captain Al's lessons in leading a family (2232-2234, "Find a Junior!", +"Raise the Rep!" and "Enjoy the Entitlement!") start at Captain Al +(1002103, in Lith Harbor and Orbis Park) and have an end script +(`q2232e`-`q2234e`) and no end NPC, so they end at him too: `classify` +lets a quest whose `Check/1` names an end script through without a +completion NPC, and both sides default the end NPC to the start NPC. His +scripts read the Family window's numbers (`db::family::info`, see +[Families](./families.md)): 2232 needs a junior and gives Cosmic's 3,000 +EXP; 2233 needs 1,000 Rep earned in all; 2234 needs 2,000 earned and less +than 500 left to spend. The data gives no rewards, and Cosmic has a +script for 2232 only. The lines are placeholders (`family.captain_al.*`). + 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 a96623715..8274b7e43 100644 --- a/game-data/placeholder_text.toml +++ b/game-data/placeholder_text.toml @@ -771,6 +771,12 @@ "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.captain_al.junior.done" = "[placeholder] Captain Al is pleased you have taken a Junior, and teaches you how a Senior earns Rep." +"family.captain_al.junior.not_yet" = "[placeholder] Captain Al says you have no Junior yet. Add one from the Family window." +"family.captain_al.rep.done" = "[placeholder] Captain Al sees you have earned 1,000 Rep, and praises how you look after your Juniors." +"family.captain_al.rep.not_yet" = "[placeholder] Captain Al says you have not earned 1,000 Rep yet. Help your Juniors gain EXP." +"family.captain_al.entitlement.done" = "[placeholder] Captain Al sees you have earned 2,000 Rep and spent it on Entitlements, and calls you a fine Senior." +"family.captain_al.entitlement.not_yet" = "[placeholder] Captain Al asks you to earn 2,000 Rep in all, and use Entitlements until less than 500 is left." "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 c9d53e562..0b9ac79de 100644 --- a/game-data/server_content.toml +++ b/game-data/server_content.toml @@ -1444,6 +1444,12 @@ unsourced = [ "nine_spirit.baby.hatched", "nine_spirit.baby.stone_held", "nine_spirit.baby.full", + "family.captain_al.junior.done", + "family.captain_al.junior.not_yet", + "family.captain_al.rep.done", + "family.captain_al.rep.not_yet", + "family.captain_al.entitlement.done", + "family.captain_al.entitlement.not_yet", ] [text."glTutoMsg0.message"] diff --git a/game-data/src/quests.rs b/game-data/src/quests.rs index 235840884..22d02b112 100644 --- a/game-data/src/quests.rs +++ b/game-data/src/quests.rs @@ -64,8 +64,9 @@ 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", "q3714s", - "q4647e", "q4659e", "q6030e", "q6031e", "q6032e", "q6033e", "q6036e", "q8185e", "q8189e", + "q1021s", "q1021e", "q2214e", "q2215e", "q2232e", "q2233e", "q2234e", "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 (`Effect::FinishQuest`): JM's @@ -405,9 +406,12 @@ pub fn classify(keys: &QuestKeys) -> Result<(), String> { // Without a completion NPC, a quest that asks for items or kills is // handed in to the NPC that gave it (Pio's 1008). Without either, it is // a record kept by some event, not something an NPC completes. - // Unless the start script completes it. + // Unless the start script completes it, or an end script does, at the + // start NPC (Captain Al's 2232-2234). if !keys.finished_by_start_script - && !end.iter().any(|k| k == "npc" || k == "item" || k == "mob") + && !end + .iter() + .any(|k| k == "npc" || k == "item" || k == "mob" || k == "endscript") { return Err("no completion NPC".into()); } diff --git a/game-data/src/server_content.rs b/game-data/src/server_content.rs index 19f7dc977..ab22ccb8e 100644 --- a/game-data/src/server_content.rs +++ b/game-data/src/server_content.rs @@ -782,6 +782,24 @@ pub mod keys { pub const NINE_SPIRIT_STONE_HELD: &str = "nine_spirit.baby.stone_held"; pub const NINE_SPIRIT_FULL: &str = "nine_spirit.baby.full"; + /// Captain Al's lessons in leading a family (quests 2232-2234), each + /// ended by his script (NPC 1002103, `q2232e`-`q2234e`): done, then not + /// yet, for a junior taken, 1,000 Rep earned, and 2,000 earned with the + /// Rep to spend used down below 500. No licensed script has the text, + /// so these keep their placeholders. + pub const CAPTAIN_AL_JUNIOR: [&str; 2] = [ + "family.captain_al.junior.done", + "family.captain_al.junior.not_yet", + ]; + pub const CAPTAIN_AL_REP: [&str; 2] = [ + "family.captain_al.rep.done", + "family.captain_al.rep.not_yet", + ]; + pub const CAPTAIN_AL_ENTITLEMENT: [&str; 2] = [ + "family.captain_al.entitlement.done", + "family.captain_al.entitlement.not_yet", + ]; + /// 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. @@ -2982,6 +3000,9 @@ mod tests { .chain(&keys::MAKER_CRYSTAL_TEST) .chain(&keys::MAKER_GOLD_ANVIL) .chain(&keys::DYLE_INFORMANTS) + .chain(&keys::CAPTAIN_AL_JUNIOR) + .chain(&keys::CAPTAIN_AL_REP) + .chain(&keys::CAPTAIN_AL_ENTITLEMENT) .chain(&[ keys::NINE_SPIRIT_HATCHED, keys::NINE_SPIRIT_STONE_HELD, diff --git a/game-data/supported_quests.txt b/game-data/supported_quests.txt index 63ffb2917..8939c3b17 100644 --- a/game-data/supported_quests.txt +++ b/game-data/supported_quests.txt @@ -258,6 +258,9 @@ 2227 2229 2231 +2232 +2233 +2234 2235 2237 2239 diff --git a/integration-harness/src/database.rs b/integration-harness/src/database.rs index 24ac9ff9b..b198363eb 100644 --- a/integration-harness/src/database.rs +++ b/integration-harness/src/database.rs @@ -573,6 +573,43 @@ pub async fn set_family_reputation(character_id: i32, reputation: i32) -> Result .await } +/// Make `junior` the junior of `senior`, both logged out and in no family. +pub async fn join_family(senior: i32, junior: i32) -> Result<(), HarnessError> { + change_one_row("seat the senior", move |conn| { + diesel::sql_query("INSERT INTO family_members (character_id) VALUES ($1)") + .bind::(senior) + .execute(conn) + }) + .await?; + change_one_row("seat the junior", move |conn| { + diesel::sql_query("INSERT INTO family_members (character_id, senior_id) VALUES ($1, $2)") + .bind::(junior) + .bind::(senior) + .execute(conn) + }) + .await +} + +/// Give `character_id`, who is in a family, `reputation` Rep to spend and +/// `total` earned in all. +pub async fn set_family_reputations( + character_id: i32, + reputation: i32, + total: i32, +) -> Result<(), HarnessError> { + change_one_row("set family reputations", move |conn| { + diesel::sql_query( + "UPDATE family_members SET reputation = $2, total_reputation = $3 \ + WHERE character_id = $1", + ) + .bind::(character_id) + .bind::(reputation) + .bind::(total) + .execute(conn) + }) + .await +} + /// Start quest `quest_id` for the logged-out character `character_id`. pub async fn start_quest(character_id: i32, quest_id: i16) -> Result<(), HarnessError> { change_one_row("start quest", move |conn| { diff --git a/integration-harness/tests/captain_al_family.rs b/integration-harness/tests/captain_al_family.rs new file mode 100644 index 000000000..b3a457bd5 --- /dev/null +++ b/integration-harness/tests/captain_al_family.rs @@ -0,0 +1,100 @@ +//! Captain Al's lessons in leading a family (2232-2234): each ends through +//! his end script, at Captain Al, once the Family window shows a junior +//! taken (2232, for 3,000 EXP), 1,000 Rep earned (2233), then 2,000 earned +//! with under 500 left to spend (2234). + +use integration_harness::connection::MapleTestConnection; +use integration_harness::database::{join_family, set_family_reputations, 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_END}; +use integration_harness::trace::{step, warp, Trace}; +use integration_harness::{login_to_world_session, relogin, HarnessConfig, WorldSession}; +use std::time::Duration; + +const CAPTAIN_AL: i32 = 1_002_103; +const LITH_HARBOR: i32 = 104_000_000; + +async fn new_character(config: &HarnessConfig, level: i16) -> i32 { + let session = login_to_world_session(config).await.expect("first login"); + let id = session.character_id; + drop(session); + tokio::time::sleep(Duration::from_millis(500)).await; + set_job(id, 0, level, 50).await.expect("level"); + id +} + +/// Log in, then go to Captain Al in Lith Harbor. +async fn visit(config: &HarnessConfig) -> WorldSession { + let mut session = relogin(config).await; + let c = &mut session.connection; + let mut trace = Trace::default(); + assert_eq!(warp(c, LITH_HARBOR, "", &mut trace).await, LITH_HARBOR); + step(c, &config.character_name, walk_to(1309, -146)).await; + session +} + +/// Report `quest` to Captain Al; what his script did. +async fn report(c: &mut MapleTestConnection, m: &str, quest: i16) -> Trace { + let t = step(c, m, build_quest_action(SCRIPT_END, quest, CAPTAIN_AL)).await; + assert_eq!(t.prompts, [(CAPTAIN_AL, 0)], "{quest}: Captain Al speaks"); + step(c, m, answer(0, 1, None)).await +} + +#[tokio::test] +async fn captain_al_ends_each_lesson_by_the_family_window() { + let (config, junior_config) = ( + load_harness_config_or_fail().await, + load_harness_config_or_fail().await, + ); + let m = config.character_name.clone(); + let id = new_character(&config, 30).await; + let junior = new_character(&junior_config, 15).await; + for (quest, status) in [(2231, 2), (2232, 1), (2233, 1), (2234, 1)] { + set_quest(id, quest, status, "").await.expect("the lessons"); + } + + // No junior and no Rep yet: nothing ends. + let mut session = visit(&config).await; + let c = &mut session.connection; + for quest in [2232, 2233, 2234] { + let t = report(c, &m, quest).await; + assert!(t.progress.is_empty() && t.gains.is_empty(), "{quest}"); + } + drop(session); + tokio::time::sleep(Duration::from_millis(500)).await; + + // A junior, and 1,500 Rep earned with all of it left. + join_family(id, junior).await.expect("a junior"); + set_family_reputations(id, 1500, 1500).await.expect("rep"); + let mut session = visit(&config).await; + let c = &mut session.connection; + let t = report(c, &m, 2232).await; + assert_eq!(t.progress, [(2232, 2)]); + assert_eq!(t.gains, [3000]); + let t = report(c, &m, 2233).await; + assert_eq!(t.progress, [(2233, 2)]); + assert!(t.gains.is_empty()); + let t = report(c, &m, 2234).await; + assert!(t.progress.is_empty(), "not 2,000 earned"); + drop(session); + tokio::time::sleep(Duration::from_millis(500)).await; + + // 2,000 earned, but 500 left: not yet; 499 left: done. + set_family_reputations(id, 500, 2000).await.expect("rep"); + let mut session = visit(&config).await; + let c = &mut session.connection; + assert!(report(c, &m, 2234).await.progress.is_empty(), "500 left"); + drop(session); + tokio::time::sleep(Duration::from_millis(500)).await; + set_family_reputations(id, 499, 2000).await.expect("rep"); + let mut session = visit(&config).await; + let c = &mut session.connection; + assert_eq!(report(c, &m, 2234).await.progress, [(2234, 2)]); + drop(session); + + let restored = relogin(&config).await; + for quest in [2232, 2233, 2234] { + assert_eq!(restored.quests.get(&quest), Some(&2), "quest {quest}"); + } +} diff --git a/net/src/npc_scripts.rs b/net/src/npc_scripts.rs index 32441486f..8c18bdf6e 100644 --- a/net/src/npc_scripts.rs +++ b/net/src/npc_scripts.rs @@ -25,6 +25,7 @@ use game::alliance::{self, Alliance}; use game::character::{ CARNIVAL_RETURN, DOJO_RETURN, EVENT_RETURN, FLORINA_RETURN, PYRAMID_RETURN, WORLD_TOUR_RETURN, }; +use game::family::FamilyInfo; use game::guild::{self, Guild}; use game::marriage; use game::wedding::{self, GuestRefusal, PartnerRefusal, Reservation, Venue, Vows, WeddingStage}; @@ -2354,6 +2355,34 @@ impl NpcScript { } } + /// Captain Al's end scripts for his lessons in leading a family + /// (`q2232e`-`q2234e`, quests 2232-2234), which read the Family window's + /// numbers (`family`): a junior taken (2232), 1,000 Rep earned in all + /// (2233), then 2,000 earned with the Rep to spend used below 500 + /// (2234). The data gives no rewards; 2232 gives Cosmic's 3,000 EXP, + /// and Cosmic has no script for the other two. + pub fn for_family_quest(script: &str, family: &FamilyInfo) -> Option { + let (quest, done, [done_key, not_yet_key]) = match script { + "q2232e" => (2232, family.juniors > 0, keys::CAPTAIN_AL_JUNIOR), + "q2233e" => (2233, family.total_reputation >= 1000, keys::CAPTAIN_AL_REP), + "q2234e" => ( + 2234, + family.total_reputation >= 2000 && family.reputation < 500, + keys::CAPTAIN_AL_ENTITLEMENT, + ), + _ => return None, + }; + if !done { + return Some(Self::new(CAPTAIN_AL, vec![Page::Say(text(not_yet_key))])); + } + let mut script = Self::new(CAPTAIN_AL, vec![Page::Say(text(done_key))]); + script.on_end.push(Effect::CompleteQuest(quest)); + if quest == 2232 { + script.on_end.push(Effect::GainExp(3000)); + } + Some(script) + } + pub fn npc_id(&self) -> i32 { self.npc_id } @@ -2759,9 +2788,10 @@ fn trash_can(player: &Player, lost: bool, hour: u8) -> NpcScript { script } -/// `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. +/// Captain Al, in Lith Harbor (104000000) and Orbis Park (200000200). +const CAPTAIN_AL: i32 = 1002103; +/// Captain Al's end scripts ([`NpcScript::for_family_quest`]). +pub const FAMILY_QUEST_SCRIPTS: [&str; 3] = ["q2232e", "q2233e", "q2234e"]; /// Nine Spirit's Baby Dragon, in its nest (240040612). const BABY_DRAGON: i32 = 2081011; const NINE_SPIRIT_EGG: i32 = 4001094; @@ -2798,6 +2828,9 @@ fn baby_dragon(player: &Player) -> NpcScript { script } +/// `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. 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]))]); @@ -9851,7 +9884,9 @@ mod tests { fn every_quest_script_the_catalog_supports_exists() { for script in game_data::quests::SCRIPTS { assert!( - NpcScript::for_quest(script, &player(50)).is_some(), + NpcScript::for_quest(script, &player(50)) + .or_else(|| NpcScript::for_family_quest(script, &FamilyInfo::default())) + .is_some(), "{script} has no script" ); } @@ -10001,6 +10036,41 @@ mod tests { assert_eq!(read_through(held), (BABY_DRAGON, vec![])); } + #[test] + fn captain_al_ends_each_lesson_by_the_family_window() { + let family = |juniors, reputation, total_reputation| FamilyInfo { + juniors, + reputation, + total_reputation, + ..FamilyInfo::default() + }; + let ends = |script, family: &FamilyInfo| { + read_through(NpcScript::for_family_quest(script, family).unwrap()) + }; + let done = |quest| (CAPTAIN_AL, vec![Effect::CompleteQuest(quest)]); + let not_yet = (CAPTAIN_AL, vec![]); + // A junior taken, for 3,000 EXP. + assert_eq!(ends("q2232e", &family(0, 0, 0)), not_yet); + assert_eq!( + ends("q2232e", &family(1, 0, 0)), + ( + CAPTAIN_AL, + vec![Effect::CompleteQuest(2232), Effect::GainExp(3000)] + ) + ); + // 1,000 Rep earned, spent or not. + assert_eq!(ends("q2233e", &family(1, 999, 999)), not_yet); + assert_eq!(ends("q2233e", &family(1, 0, 1000)), done(2233)); + // 2,000 earned, under 500 left to spend. + assert_eq!(ends("q2234e", &family(1, 499, 1999)), not_yet); + assert_eq!(ends("q2234e", &family(1, 500, 2000)), not_yet); + assert_eq!(ends("q2234e", &family(1, 499, 2000)), done(2234)); + for script in FAMILY_QUEST_SCRIPTS { + assert!(NpcScript::for_quest(script, &player(50)).is_none()); + } + assert!(NpcScript::for_family_quest("q2214e", &family(1, 0, 0)).is_none()); + } + #[test] fn a_maker_class_ends_with_its_teacher() { for (script, npc, quest) in [ diff --git a/runtime/src/actor/client/quest.rs b/runtime/src/actor/client/quest.rs index 8814ef4fb..fb5addf36 100644 --- a/runtime/src/actor/client/quest.rs +++ b/runtime/src/actor/client/quest.rs @@ -6,7 +6,7 @@ use crate::{ }; use game::{Player, QuestRecord, QuestState, QuestStatus}; use game_data::server_content::keys; -use net::npc_scripts::NpcScript; +use net::npc_scripts::{NpcScript, FAMILY_QUEST_SCRIPTS}; use net::packet::build::world::effect::{self, CharEffect}; use net::packet::build::world::messaging::{build_broadcast, Broadcast}; use net::packet::build::world::{drop, inventory, pet, quest, skill, stat}; @@ -138,8 +138,22 @@ impl ClientActor { } async fn run_quest_script(&mut self, script: Option<&str>) -> Result<(), RuntimeError> { + let Some(script) = script else { + return Ok(()); + }; + // Captain Al's read the Family window's numbers. + let cid = self.client_id; + let family = if FAMILY_QUEST_SCRIPTS.contains(&script) { + Some(spawn_db(move || db::family::info(cid)).await?) + } else { + None + }; let player = &self.in_game()?.player; - match script.and_then(|script| NpcScript::for_quest(script, player)) { + let script = match family { + Some(family) => NpcScript::for_family_quest(script, &family), + None => NpcScript::for_quest(script, player), + }; + match script { Some(script) => self.start_script(script).await, None => Ok(()), }