Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions agent_docs/families.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 13 additions & 1 deletion agent_docs/quests.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions game-data/placeholder_text.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
6 changes: 6 additions & 0 deletions game-data/server_content.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
12 changes: 8 additions & 4 deletions game-data/src/quests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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());
}
Expand Down
21 changes: 21 additions & 0 deletions game-data/src/server_content.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions game-data/supported_quests.txt
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,9 @@
2227
2229
2231
2232
2233
2234
2235
2237
2239
Expand Down
37 changes: 37 additions & 0 deletions integration-harness/src/database.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<Integer, _>(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::<Integer, _>(junior)
.bind::<Integer, _>(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::<Integer, _>(character_id)
.bind::<Integer, _>(reputation)
.bind::<Integer, _>(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| {
Expand Down
100 changes: 100 additions & 0 deletions integration-harness/tests/captain_al_family.rs
Original file line number Diff line number Diff line change
@@ -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}");
}
}
Loading
Loading