From 82952582d30544bfb64f713634073f9ac2ebf757 Mon Sep 17 00:00:00 2001 From: David Goldstein Date: Mon, 26 Oct 2026 16:58:16 -0400 Subject: [PATCH] Row 231: JM's informants (2214-2219): the Knocked Trash Can gives his list from 5 to 8 p.m. (q2214e, q2215e for 2,000 mesos), and each informant's start script starts and completes their quest at once (Effect::FinishQuest, db::quest::finish), the last taking the list --- agent_docs/quests.md | 17 +- db/src/quest.rs | 34 +++- game-data/placeholder_text.toml | 9 + game-data/server_content.toml | 9 + game-data/src/quests.rs | 25 ++- game-data/src/server_content.rs | 26 +++ game-data/supported_quests.txt | 6 + integration-harness/tests/dyle_informants.rs | 70 ++++++++ net/src/npc_scripts.rs | 166 +++++++++++++++++++ net/src/scripts.rs | 4 + runtime/src/actor/client/npc_script.rs | 1 + runtime/src/actor/client/quest.rs | 18 +- 12 files changed, 372 insertions(+), 13 deletions(-) create mode 100644 integration-harness/tests/dyle_informants.rs diff --git a/agent_docs/quests.md b/agent_docs/quests.md index 78cc8d1e..137cb920 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)), and Meren's Maker lessons below); those steps go through +[Pets](./pets.md)), Meren's Maker lessons and Dyle's informants 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 @@ -245,6 +245,21 @@ repeats its start requirements with a `skill` entry that teaches nothing, so the server ignores it. The scripts' text is unsourced and stays placeholder. +Dyle's informants in Kerning City (2214-2219, before JM's 2220). The Knocked +Trash Can (1052108) in the Hut in the Swamp (107000301) ends 2214 only from 5 to 8 p.m. on the +stations' wall clock (`conti::time_of_day`, as the ships keep it): its +script (`q2214e`) gives JM's list of informants (4031894) and Cosmic's +20,000 EXP; at other hours the can is empty. A player who lost the +list gets another there on 2215 (`q2215e`) for 2,000 mesos. Each informant +(Mr. Pickall 9000008, Shumi 1052102, Nella 1052103 and Jake 1052006 for +2216-2219) has a start script and no end: talking to them through +QUEST_ACTION 4 starts and completes their quest at once +(`Effect::FinishQuest`, saved by `db::quest::finish` with the start's +checks) and gives Cosmic's 7,000 EXP, and the last of the four takes the +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 `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/db/src/quest.rs b/db/src/quest.rs index 82c3e0b5..349f20ae 100644 --- a/db/src/quest.rs +++ b/db/src/quest.rs @@ -42,6 +42,33 @@ pub fn start( prerequisites: &[(i16, QuestState)], repeatable: bool, ) -> crate::Result { + begin(player, qid, prerequisites, repeatable, false) +} + +/// Start a quest and complete it at once, as a start script that completes +/// its quest does (the informants of quests 2216-2219): the checks of +/// [`start`], then the record is saved completed. +pub fn finish( + player: &Player, + qid: i16, + prerequisites: &[(i16, QuestState)], + repeatable: bool, +) -> crate::Result { + begin(player, qid, prerequisites, repeatable, true) +} + +fn begin( + player: &Player, + qid: i16, + prerequisites: &[(i16, QuestState)], + repeatable: bool, + finished: bool, +) -> crate::Result { + let (status, completed_at) = if finished { + (2, Some(SystemTime::now())) + } else { + (1, None) + }; let cid = player.id(); let mut conn = crate::connection()?; conn.transaction(|conn| -> diesel::QueryResult<_> { @@ -78,14 +105,15 @@ pub fn start( .values(( character_quests::character_id.eq(cid), character_quests::quest_id.eq(qid), - character_quests::status.eq(1), + character_quests::status.eq(status), + character_quests::completed_at.eq(completed_at), character_quests::progress.eq(&progress), )) .on_conflict((character_quests::character_id, character_quests::quest_id)) .do_update() .set(( - character_quests::status.eq(1), - character_quests::completed_at.eq(None::), + character_quests::status.eq(status), + character_quests::completed_at.eq(completed_at), character_quests::progress.eq(&progress), )) .get_result(conn)?; diff --git a/game-data/placeholder_text.toml b/game-data/placeholder_text.toml index 5a201434..8ff945a4 100644 --- a/game-data/placeholder_text.toml +++ b/game-data/placeholder_text.toml @@ -759,6 +759,15 @@ "maker.crystal_test.1" = "[placeholder] Meren is satisfied and teaches you the Maker's next level. Keep the crystal." "maker.gold_anvil.0" = "[placeholder] Meren sees the Gold Anvil you made and can hardly believe it." "maker.gold_anvil.1" = "[placeholder] Meren takes the Gold Anvil and teaches you the Maker's last level." +"dyle.trash_can.not_yet" = "[placeholder] You search the trash can but find nothing. Perhaps it is not yet time (5 p.m. to 8 p.m.)." +"dyle.trash_can.full" = "[placeholder] You need a free ETC slot to take the paper." +"dyle.trash_can.found" = "[placeholder] You find a crumpled piece of paper listing JM's informants." +"dyle.trash_can.short" = "[placeholder] You don't have the 2,000 mesos for the fee." +"dyle.trash_can.paid" = "[placeholder] You leave the 2,000 mesos in the trash can and take the paper." +"dyle.informant.0" = "[placeholder] Mr. Pickall says Dyle looks like a Ligator, only bigger." +"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." "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 89c72cb8..56adb726 100644 --- a/game-data/server_content.toml +++ b/game-data/server_content.toml @@ -1432,6 +1432,15 @@ unsourced = [ "ola.notice.8", "jewel.notice.0", "jewel.notice.1", + "dyle.trash_can.not_yet", + "dyle.trash_can.full", + "dyle.trash_can.found", + "dyle.trash_can.short", + "dyle.trash_can.paid", + "dyle.informant.0", + "dyle.informant.1", + "dyle.informant.2", + "dyle.informant.3", ] [text."glTutoMsg0.message"] diff --git a/game-data/src/quests.rs b/game-data/src/quests.rs index 6c07b536..68f0dd70 100644 --- a/game-data/src/quests.rs +++ b/game-data/src/quests.rs @@ -64,9 +64,13 @@ 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", "q4647e", "q4659e", "q6030e", "q6031e", "q6032e", "q6033e", "q6036e", - "q8185e", "q8189e", + "q1021s", "q1021e", "q2214e", "q2215e", "q2216s", "q2217s", "q2218s", "q2219s", "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]; /// 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 @@ -380,6 +384,8 @@ pub struct QuestKeys { pub item_keys: Vec, /// Keys used by any `Act/1/skill` entry. pub skill_keys: Vec, + /// Its start script completes it ([`FINISHED_BY_START_SCRIPT`]). + pub finished_by_start_script: bool, } /// `Ok` if every declared requirement and action is supported, otherwise the @@ -396,7 +402,10 @@ 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. - if !end.iter().any(|k| k == "npc" || k == "item" || k == "mob") { + // Unless the start script completes it. + if !keys.finished_by_start_script + && !end.iter().any(|k| k == "npc" || k == "item" || k == "mob") + { return Err("no completion NPC".into()); } unsupported( @@ -501,6 +510,12 @@ impl QuestCatalog { keys.retain(|key| key != "infoex"); } } + keys.finished_by_start_script = FINISHED_BY_START_SCRIPT.contains(&id) + && keys + .start_check + .iter() + .flatten() + .any(|k| k == "startscript"); if id == REQUIREMENTS_IN_START_ACTIONS { keys.start_act.clear(); } @@ -1080,6 +1095,10 @@ mod tests { let mut no_end = keys(&["npc"], &[], &[], &[]); no_end.end_check = None; assert!(classify(&no_end).is_err()); + let mut informant = keys(&["npc", "startscript"], &[], &[], &[]); + assert!(classify(&informant).is_err()); + informant.finished_by_start_script = true; + assert_eq!(classify(&informant), Ok(())); } /// The supported set must match `supported_quests.txt`, which HeavenClient diff --git a/game-data/src/server_content.rs b/game-data/src/server_content.rs index d0e91ebe..ce11791b 100644 --- a/game-data/src/server_content.rs +++ b/game-data/src/server_content.rs @@ -756,6 +756,24 @@ pub mod keys { pub const MAKER_CRYSTAL_TEST: [&str; 2] = ["maker.crystal_test.0", "maker.crystal_test.1"]; 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 + /// 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. + pub const DYLE_TRASH_CAN_NOT_YET: &str = "dyle.trash_can.not_yet"; + pub const DYLE_TRASH_CAN_FULL: &str = "dyle.trash_can.full"; + pub const DYLE_TRASH_CAN_FOUND: &str = "dyle.trash_can.found"; + pub const DYLE_TRASH_CAN_SHORT: &str = "dyle.trash_can.short"; + pub const DYLE_TRASH_CAN_PAID: &str = "dyle.trash_can.paid"; + pub const DYLE_INFORMANTS: [&str; 4] = [ + "dyle.informant.0", + "dyle.informant.1", + "dyle.informant.2", + "dyle.informant.3", + ]; + /// 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. @@ -2955,6 +2973,14 @@ mod tests { .chain(&keys::MAKER_PRACTICE) .chain(&keys::MAKER_CRYSTAL_TEST) .chain(&keys::MAKER_GOLD_ANVIL) + .chain(&keys::DYLE_INFORMANTS) + .chain(&[ + keys::DYLE_TRASH_CAN_NOT_YET, + keys::DYLE_TRASH_CAN_FULL, + keys::DYLE_TRASH_CAN_FOUND, + keys::DYLE_TRASH_CAN_SHORT, + keys::DYLE_TRASH_CAN_PAID, + ]) .chain(&keys::MILK_TANGYOON) .chain(&keys::DOJO_TAUNT) .chain(&keys::FITNESS_NOTICE) diff --git a/game-data/supported_quests.txt b/game-data/supported_quests.txt index 3698ee82..486d0627 100644 --- a/game-data/supported_quests.txt +++ b/game-data/supported_quests.txt @@ -242,6 +242,12 @@ 2211 2212 2213 +2214 +2215 +2216 +2217 +2218 +2219 2220 2221 2222 diff --git a/integration-harness/tests/dyle_informants.rs b/integration-harness/tests/dyle_informants.rs new file mode 100644 index 00000000..65d73a83 --- /dev/null +++ b/integration-harness/tests/dyle_informants.rs @@ -0,0 +1,70 @@ +//! JM's informants in Kerning City (2216-2219): with his list (4031894) +//! from 2214, each informant's start script starts and completes their +//! quest at once, and the last of the four takes the list. + +use integration_harness::connection::MapleTestConnection; +use integration_harness::database::{grant_stack, 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 LIST: i32 = 4_031_894; +const ETC: u8 = 4; +/// Each informant's quest, NPC, map and place. +const INFORMANTS: [(i16, i32, i32, i16, i16); 4] = [ + (2216, 9_000_008, 103_000_000, 1112, 400), + (2217, 1_052_102, 103_000_000, 445, 426), + (2218, 1_052_103, 103_000_000, 71, -54), + (2219, 1_052_006, 103_000_100, 71, 187), +]; + +async fn go(c: &mut MapleTestConnection, m: &str, map: i32, x: i16, y: i16) { + let mut trace = Trace::default(); + assert_eq!(warp(c, map, "", &mut trace).await, map); + step(c, m, walk_to(x, y)).await; +} + +#[tokio::test] +async fn each_informant_completes_their_quest_at_once() { + 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, 400, 30, 100).await.expect("a level 30 thief"); + set_quest(id, 2214, 2, "").await.expect("2214 done"); + grant_stack(id, ETC as i16, 1, LIST, 1) + .await + .expect("JM's list"); + + let mut session = relogin(&config).await; + let c = &mut session.connection; + for (i, (quest, npc, map, x, y)) in INFORMANTS.into_iter().enumerate() { + go(c, &m, map, x, y).await; + let t = step(c, &m, build_quest_action(SCRIPT_START, quest, npc)).await; + assert_eq!(t.prompts, [(npc, 0)], "{quest} has its start script"); + assert!(t.progress.is_empty(), "{quest} waits for the dialogue"); + let t = step(c, &m, answer(0, 1, None)).await; + assert_eq!(t.progress, [(quest, 2)], "{quest} is done at once"); + assert_eq!(t.gains, [7_000]); + if i < 3 { + assert!(t.removed.is_empty(), "{quest} leaves the list"); + } else { + assert_eq!(t.removed, [(ETC, 1)], "the last informant takes it"); + } + // Done once. + let t = step(c, &m, build_quest_action(SCRIPT_START, quest, npc)).await; + assert!(t.prompts.is_empty() && t.progress.is_empty()); + } + drop(session); + + let restored = relogin(&config).await; + for (quest, ..) in INFORMANTS { + 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 24eaed12..0df389f9 100644 --- a/net/src/npc_scripts.rs +++ b/net/src/npc_scripts.rs @@ -2332,6 +2332,14 @@ impl NpcScript { match script { "q1021s" => Some(roger_start(player)), "q1021e" => Some(roger_end(player)), + "q2214e" | "q2215e" => { + let hour = conti::time_of_day(conti::now()).0; + Some(trash_can(player, script == "q2215e", hour)) + } + "q2216s" => informant(player, 0), + "q2217s" => informant(player, 1), + "q2218s" => informant(player, 2), + "q2219s" => informant(player, 3), "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)), @@ -2701,6 +2709,75 @@ fn pet_secret(player: &Player) -> NpcScript { script } +/// JM's list of informants (Kerning City, quests 2214-2219). +const INFORMANT_LIST: i32 = 4031894; +/// The Knocked Trash Can in the Hut in the Swamp (107000301), where JM +/// leaves it. +const TRASH_CAN: i32 = 1052108; +/// The fee for a lost list (`q2215e`, `Act/1 money`). +const INFORMANT_LIST_FEE: i32 = 2000; +/// JM's informants and their quests: Mr. Pickall, Shumi, Nella and Jake. +const INFORMANTS: [(i16, i32); 4] = [ + (2216, 9000008), + (2217, 1052102), + (2218, 1052103), + (2219, 1052006), +]; + +/// `q2214e`, and `q2215e` for a `lost` list: the trash can in the Hut in +/// the Swamp holds JM's list from 5 to 8 p.m. (QuestInfo 2214), by the hour the stations' +/// clocks show. The first list comes with 20,000 EXP (Cosmic's; the data +/// gives only the list); a lost one costs the fee. +fn trash_can(player: &Player, lost: bool, hour: u8) -> NpcScript { + let say = |key| NpcScript::new(TRASH_CAN, vec![Page::Say(text(key))]); + if !(17..20).contains(&hour) { + return say(keys::DYLE_TRASH_CAN_NOT_YET); + } + if lost && player.meso < INFORMANT_LIST_FEE { + return say(keys::DYLE_TRASH_CAN_SHORT); + } + let (mut script, quest) = if lost { + (say(keys::DYLE_TRASH_CAN_PAID), 2215) + } else { + (say(keys::DYLE_TRASH_CAN_FOUND), 2214) + }; + if lost { + script.on_end.push(Effect::SpendMeso(INFORMANT_LIST_FEE)); + } + script.on_end.extend([ + Effect::CompleteQuest(quest), + Effect::GiveItem { + item_id: INFORMANT_LIST, + quantity: 1, + }, + ]); + if !lost { + script.on_end.push(Effect::GainExp(20_000)); + } + script.inventory_full = Some(text(keys::DYLE_TRASH_CAN_FULL)); + 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]))]); + script.on_end = vec![Effect::FinishQuest(quest), Effect::GainExp(7000)]; + let rest_heard = INFORMANTS + .iter() + .filter(|&&(other, _)| other != quest) + .all(|&(other, _)| player.quests.state(other) == QuestState::Completed); + if rest_heard && player.inventory.count(INFORMANT_LIST) > 0 { + script.on_end.push(Effect::TakeItem { + item_id: INFORMANT_LIST, + quantity: 1, + }); + } + Some(script) +} + /// `q8185e`, `q4659e` and `q8189e`: Garnox spends a Rock of Evolution and /// his fee to evolve the first pet out (the one the quest checked) into a /// kind picked by weight, and the quest completes; 4659 also takes its @@ -9757,6 +9834,95 @@ mod tests { (npc, end.effects) } + /// What a one-page script applies once read. + fn read_through(mut script: NpcScript) -> (i32, Vec) { + let npc = script.npc_id(); + assert!(script.begin().prompt.is_some()); + let end = script.respond(ScriptInput::Next).unwrap(); + assert_eq!(end.prompt, None); + (npc, end.effects) + } + + #[test] + fn the_trash_can_holds_jms_list_from_5_to_8_pm() { + let list = Effect::GiveItem { + item_id: INFORMANT_LIST, + quantity: 1, + }; + for hour in [16, 20, 0] { + for lost in [false, true] { + let script = trash_can(&traveller(30, 5000, false), lost, hour); + assert_eq!(read_through(script), (TRASH_CAN, vec![]), "{hour}h"); + } + } + for hour in [17, 19] { + let found = trash_can(&traveller(30, 0, false), false, hour); + assert_eq!( + read_through(found), + ( + TRASH_CAN, + vec![ + Effect::CompleteQuest(2214), + list.clone(), + Effect::GainExp(20_000) + ] + ) + ); + assert!(trash_can(&traveller(30, 0, false), false, hour) + .inventory_full() + .is_some()); + } + // A lost list costs the fee, which must be held. + let short = trash_can(&traveller(30, INFORMANT_LIST_FEE - 1, false), true, 18); + assert_eq!(read_through(short), (TRASH_CAN, vec![])); + let paid = trash_can(&traveller(30, INFORMANT_LIST_FEE, false), true, 18); + assert_eq!( + read_through(paid), + ( + TRASH_CAN, + vec![ + Effect::SpendMeso(INFORMANT_LIST_FEE), + Effect::CompleteQuest(2215), + list + ] + ) + ); + } + + #[test] + fn each_informant_completes_their_quest_and_the_last_takes_the_list() { + let heard = |quest: i16| vec![Effect::FinishQuest(quest), Effect::GainExp(7000)]; + let holding = |done: &[i16]| { + let mut player = with_quests(&done.iter().map(|&q| (q, true)).collect::>()); + player.inventory.insert( + game::InventoryType::Etc, + 1, + game::Item::stack(INFORMANT_LIST, 1), + ); + player + }; + for (script, index) in [("q2216s", 0), ("q2217s", 1), ("q2218s", 2), ("q2219s", 3)] { + let (quest, npc) = INFORMANTS[index]; + let first = NpcScript::for_quest(script, &holding(&[])).unwrap(); + assert_eq!(read_through(first), (npc, heard(quest))); + let others: Vec = INFORMANTS + .iter() + .map(|&(other, _)| other) + .filter(|&other| other != quest) + .collect(); + let mut last = heard(quest); + last.push(Effect::TakeItem { + item_id: INFORMANT_LIST, + quantity: 1, + }); + let script = NpcScript::for_quest(script, &holding(&others)).unwrap(); + assert_eq!(read_through(script), (npc, last)); + } + // Two still to hear: the list stays. + let early = NpcScript::for_quest("q2219s", &holding(&[2216, 2217])).unwrap(); + assert_eq!(read_through(early), (1052006, heard(2219))); + } + #[test] fn a_maker_class_ends_with_its_teacher() { for (script, npc, quest) in [ diff --git a/net/src/scripts.rs b/net/src/scripts.rs index 248123f0..4a503756 100644 --- a/net/src/scripts.rs +++ b/net/src/scripts.rs @@ -477,6 +477,10 @@ pub enum Effect { GainExp(i32), StartQuest(i16), CompleteQuest(i16), + /// Start a quest not yet started and complete it at once: a start script + /// that completes its own quest (Cosmic's `forceCompleteQuest`), as the + /// informants of quests 2216-2219 do. Checked like a start. + FinishQuest(i16), /// Set a started quest's own record to `count`, toward its completion /// (`QuestTemplate::end_record`); nothing is applied unless it is started. QuestRecord { diff --git a/runtime/src/actor/client/npc_script.rs b/runtime/src/actor/client/npc_script.rs index 0f93e95c..bcd3b99f 100644 --- a/runtime/src/actor/client/npc_script.rs +++ b/runtime/src/actor/client/npc_script.rs @@ -561,6 +561,7 @@ impl ClientActor { } } Effect::CompleteQuest(id) => change = Some(QuestChange::Complete(template(*id)?)), + Effect::FinishQuest(id) => change = Some(QuestChange::Finish(template(*id)?)), Effect::Warp(map_id) => warp = Some((*map_id, None)), Effect::WarpToPortal(map_id, portal) => warp = Some((*map_id, Some(*portal))), Effect::EnterDungeon { diff --git a/runtime/src/actor/client/quest.rs b/runtime/src/actor/client/quest.rs index 3c0e60fe..8814ef4f 100644 --- a/runtime/src/actor/client/quest.rs +++ b/runtime/src/actor/client/quest.rs @@ -25,6 +25,8 @@ use tracing::{debug, warn}; pub(super) enum QuestChange { Start(&'static QuestTemplate), Complete(&'static QuestTemplate), + /// Started and completed at once, by a start script. + Finish(&'static QuestTemplate), } impl ClientActor { @@ -376,7 +378,7 @@ impl ClientActor { if let Some(change) = change { let snapshot = next.clone(); let progress = match change { - QuestChange::Start(template) => { + QuestChange::Start(template) | QuestChange::Finish(template) => { let prerequisites: Vec<_> = template .start .quests @@ -384,10 +386,12 @@ impl ClientActor { .map(|p| (p.quest_id, p.state)) .collect(); let repeatable = template.start.repeat_interval.is_some(); - spawn_db(move || { - db::quest::start(&snapshot, template.id, &prerequisites, repeatable) - }) - .await + let begin = match change { + QuestChange::Finish(_) => db::quest::finish, + _ => db::quest::start, + }; + spawn_db(move || begin(&snapshot, template.id, &prerequisites, repeatable)) + .await } QuestChange::Complete(template) => { spawn_db(move || db::quest::complete(&snapshot, template.id)).await @@ -410,7 +414,9 @@ impl ClientActor { next.mark_saved(); match change { QuestChange::Start(template) => self.start_quest_clock(template.id, packets)?, - QuestChange::Complete(template) => self.end_quest_clock(template.id, packets)?, + QuestChange::Complete(template) | QuestChange::Finish(template) => { + self.end_quest_clock(template.id, packets)? + } } } else { next.mark_dirty();