From 5f43c9c092eaed281a1fc1f822ba4cf3f29a4880 Mon Sep 17 00:00:00 2001 From: David Goldstein Date: Mon, 26 Oct 2026 15:19:47 -0400 Subject: [PATCH] Row 198: Romeo and Juliet PQ slice 6: the boss's room, where Yulete brings out the Frankenroid (the mighty one if caught), the escort's fainting costs EXP and gifts, and its fall wins the run (90,000/105,000/140,000); Romeo or Juliet give the gift and send members past Yulete's traces to the thanks, which give one of 23 rewards on the way home --- agent_docs/party-quests.md | 41 ++- game-data/placeholder_text.toml | 20 ++ game-data/server_content.toml | 20 ++ game-data/src/server_content.rs | 40 +++ integration-harness/tests/magatia_boss.rs | 304 ++++++++++++++++++++++ net/src/events.rs | 213 ++++++++++++++- net/src/npc_scripts.rs | 253 ++++++++++++++++++ net/src/scripts.rs | 14 + runtime/src/actor/client.rs | 8 + runtime/src/actor/client/dialogue.rs | 12 + runtime/src/actor/client/events.rs | 52 +++- runtime/src/actor/event.rs | 41 +++ runtime/src/actor/field.rs | 89 ++++++- runtime/src/message.rs | 15 +- 14 files changed, 1110 insertions(+), 12 deletions(-) create mode 100644 integration-harness/tests/magatia_boss.rs diff --git a/agent_docs/party-quests.md b/agent_docs/party-quests.md index b877ecc4..bd736cfd 100644 --- a/agent_docs/party-quests.md +++ b/agent_docs/party-quests.md @@ -1326,8 +1326,38 @@ room draws its right ropes the first time anyone climbs row's right rope differs in each of the four rooms; ours draws each room on its own, as Cosmic's Alcadno script does. The first to reach the top clears stage 6 for 20,000 EXP each, with no clear effect. The rooms' way -on (`out00`, a plain portal at the top) leads to the boss's room -(x00400), which has nothing in it yet. +on (`out00`, a plain portal at the top) leads to the corridor (x00400), +whose way in (`rnj12_in`, `jnr12_in`, never shut) leads to the boss's +room (x00401). + +There Yulete (2112000, 2112010) comes out at (200, 100) the first time +anyone enters (`npc_scripts::magatia_waiting`), beside the side's escort +(Zenumist 9300137, Alcadno 9300138) and two guards (9300150), whom the +map's spawner brings back as Cosmic's `respawnStages` does every 15 s. +The first time he is talked to (`FieldMessage::Yulete`, the field's +`frankenroid`, `events::yulete_in_lab`) he brings out his Frankenroid at +(250, 100): the mighty one (Zenumist 9300140, Alcadno 9300152) if he was +caught out in his office, otherwise the plain one (9300139, 9300151); +everyone in the run hears of it in pink (`EventCommand::FrankenroidOut`). +After that he bids them behold it, and once the run is won says he is +beaten. A mob walked into the escort hurts it, and its fainting is told +to the room (`magatia.juliet.fainted`, `magatia.romeo.fainted`) and kept +by the run (`EventCommand::EscortFailed`). The Frankenroid's fall clears +stage 7, which wins the run (`events::magatia_win_exp`): 90,000 EXP each +if the escort fainted, otherwise 105,000 for the plain one and 140,000 +for the mighty one. Cosmic's Alcadno script pays 140,000 either way and +never sends anyone straight on (below); both sides do as its Zenumist +script does. The guards go, and Romeo (2112006) or Juliet (2112005) +comes out at (-370, -150). If the escort fainted they send each member +on to the last room (x00600); otherwise they give the side's gift +(Zenumist 4001159, Alcadno 4001160, which needs room) and send the member +on: to the last room, or, if the Frankenroid was the mighty one, first to +Yulete's traces (2112001, 2112011) in x00500, who send them on in turn. +In the last room Romeo & Juliet (2112018) thank each member and give +one of 23 rewards (`events::MAGATIA_REWARDS`, Cosmic's +`setEventRewards`), which needs a free slot in each of the Equip and Use +inventories, on the way back to the side's room. Cosmic's traces also complete quest 7770; ours don't +yet. Inside, Romeo or Juliet asks whether a member would rather retreat (`magatia.romeo.retreat`, `magatia.juliet.retreat`) and sends them to the @@ -1399,11 +1429,18 @@ nothing yet. Its lines are placeholders (`magatia.*`). | Romeo 2112004 | 261000011 | Runtime (`talk_about_event`): a menu of going in and what happened | | Romeo 2112004 | 926100000-400 | Asks whether to retreat, to 926100700 | | Romeo 2112006 | 926100200 | Runtime (`magatia_hub`): the letter, the leader's files, the way on, or whether to retreat | +| Romeo 2112006 | 926100401 | Runtime (`magatia_saved`): once won, the gift and on to 926100600, or past 926100500 | | Juliet 2112003 | 261000021 | Runtime (`talk_about_event`): a menu of going in and what happened | | Juliet 2112003 | 926110000-400 | Asks whether to retreat, to 926110700 | | Juliet 2112005 | 926110200 | Runtime (`magatia_hub`): the same on the Alcadno side | | Yulete 2112000 | 926100203 | Runtime (`FieldMessage::Yulete`): caught out, watched, sneaky or leaving | | Yulete 2112010 | 926110203 | The same on the Alcadno side | +| Yulete 2112000 | 926100401 | Runtime (`FieldMessage::Yulete`): brings out the Frankenroid, then bids them behold it, then is beaten | +| Yulete 2112010 | 926110401 | The same on the Alcadno side | +| Juliet 2112005 | 926110401 | Runtime (`magatia_saved`): the same on the Alcadno side | +| Yulete's traces 2112001 | 926100500 | Runtime (`yulete_traces`): on to 926100600 | +| Yulete's traces 2112011 | 926110500 | The same on the Alcadno side | +| Romeo & Juliet 2112018 | 926100600, 926110600 | Runtime (`magatia_thanks`): one of the rewards, and home to the side's room | | Books 2112007 | 926100000 | Runtime (`BookOpened`): mesos, EXP, the letter, the switch or nothing | | Books 2112013 | 926110000 | The same; in the hall (926110001) they hold nothing | diff --git a/game-data/placeholder_text.toml b/game-data/placeholder_text.toml index 4ad9f229..dddec712 100644 --- a/game-data/placeholder_text.toml +++ b/game-data/placeholder_text.toml @@ -1342,6 +1342,26 @@ "magatia.yulete.disgusted" = "[placeholder] Yulete: You won't stop my alchemy!" "magatia.yulete.fooled" = "[placeholder] Yulete: Did you think I wasn't ready for you? Fools!" "magatia.room_taken" = "[placeholder] Someone is already in this room." +"magatia.yulete.presents" = "[placeholder] You again? Then meet my newest creation, the Frankenroid." +"magatia.yulete.mighty" = "[placeholder] You again? Then meet my finest creation, the mighty Frankenroid, made of both societies' alchemy." +"magatia.yulete.behold" = "[placeholder] Look upon my creation and despair!" +"magatia.yulete.beaten" = "[placeholder] Beaten... All of it was for the sake of alchemy." +"magatia.frankenroid" = "[placeholder] Yulete: Meet my newest creation, the Frankenroid!" +"magatia.frankenroid_mighty" = "[placeholder] Yulete: Meet my finest creation, the mighty Frankenroid!" +"magatia.juliet.fainted" = "[placeholder] Juliet fainted in the fight." +"magatia.romeo.fainted" = "[placeholder] Romeo fainted in the fight." +"magatia.romeo.saved" = "[placeholder] Juliet is safe, thanks to you. Yulete will answer to Magatia for this." +"magatia.juliet.saved" = "[placeholder] Romeo is safe, thanks to you. Yulete will answer to Magatia for this." +"magatia.romeo.saved_hurt" = "[placeholder] Juliet is safe, though the fight hurt her. Thank you." +"magatia.juliet.saved_hurt" = "[placeholder] Romeo is safe, though the fight hurt him. Thank you." +"magatia.gift" = "[placeholder] Please take this as a token of our thanks." +"magatia.gift_full" = "[placeholder] You need a free slot in your ETC inventory." +"magatia.traces.defeated" = "[placeholder] Beaten... Is this how my work ends?" +"magatia.traces.cheer" = "[placeholder] Cheer up; it isn't over for you." +"magatia.traces.sorry" = "[placeholder] You would forgive me? Then I will help the societies again. I'm sorry." +"magatia.thanks.rehab" = "[placeholder] Thanks to you we are together again, and Yulete will be helped back onto the right path." +"magatia.thanks.jail" = "[placeholder] Thanks to you we are together again, and Yulete will be jailed." +"magatia.thanks.full" = "[placeholder] Free a slot in each of your inventories first." "pirate.menu" = "[placeholder] The Lord Pirate has taken Wu Yang, my son, onto his ship. Will your party board it and bring him back?" "pirate.enter" = "[placeholder] Board the Pirate Ship." "pirate.about" = "[placeholder] What waits on the ship?" diff --git a/game-data/server_content.toml b/game-data/server_content.toml index e9d88443..79fe6eae 100644 --- a/game-data/server_content.toml +++ b/game-data/server_content.toml @@ -1192,6 +1192,26 @@ unsourced = [ "magatia.yulete.disgusted", "magatia.yulete.fooled", "magatia.room_taken", + "magatia.yulete.presents", + "magatia.yulete.mighty", + "magatia.yulete.behold", + "magatia.yulete.beaten", + "magatia.frankenroid", + "magatia.frankenroid_mighty", + "magatia.juliet.fainted", + "magatia.romeo.fainted", + "magatia.romeo.saved", + "magatia.juliet.saved", + "magatia.romeo.saved_hurt", + "magatia.juliet.saved_hurt", + "magatia.gift", + "magatia.gift_full", + "magatia.traces.defeated", + "magatia.traces.cheer", + "magatia.traces.sorry", + "magatia.thanks.rehab", + "magatia.thanks.jail", + "magatia.thanks.full", "pirate.menu", "pirate.enter", "pirate.about", diff --git a/game-data/src/server_content.rs b/game-data/src/server_content.rs index 999c0885..465d573d 100644 --- a/game-data/src/server_content.rs +++ b/game-data/src/server_content.rs @@ -1843,6 +1843,26 @@ pub mod keys { pub const MAGATIA_YULETE_DISGUSTED: &str = "magatia.yulete.disgusted"; pub const MAGATIA_YULETE_FOOLED: &str = "magatia.yulete.fooled"; pub const MAGATIA_ROOM_TAKEN: &str = "magatia.room_taken"; + pub const MAGATIA_YULETE_PRESENTS: &str = "magatia.yulete.presents"; + pub const MAGATIA_YULETE_MIGHTY: &str = "magatia.yulete.mighty"; + pub const MAGATIA_YULETE_BEHOLD: &str = "magatia.yulete.behold"; + pub const MAGATIA_YULETE_BEATEN: &str = "magatia.yulete.beaten"; + pub const MAGATIA_FRANKENROID: &str = "magatia.frankenroid"; + pub const MAGATIA_FRANKENROID_MIGHTY: &str = "magatia.frankenroid_mighty"; + pub const MAGATIA_JULIET_FAINTED: &str = "magatia.juliet.fainted"; + pub const MAGATIA_ROMEO_FAINTED: &str = "magatia.romeo.fainted"; + pub const MAGATIA_ROMEO_SAVED: &str = "magatia.romeo.saved"; + pub const MAGATIA_JULIET_SAVED: &str = "magatia.juliet.saved"; + pub const MAGATIA_ROMEO_SAVED_HURT: &str = "magatia.romeo.saved_hurt"; + pub const MAGATIA_JULIET_SAVED_HURT: &str = "magatia.juliet.saved_hurt"; + pub const MAGATIA_GIFT: &str = "magatia.gift"; + pub const MAGATIA_GIFT_FULL: &str = "magatia.gift_full"; + pub const MAGATIA_TRACES_DEFEATED: &str = "magatia.traces.defeated"; + pub const MAGATIA_TRACES_CHEER: &str = "magatia.traces.cheer"; + pub const MAGATIA_TRACES_SORRY: &str = "magatia.traces.sorry"; + pub const MAGATIA_THANKS_REHAB: &str = "magatia.thanks.rehab"; + pub const MAGATIA_THANKS_JAIL: &str = "magatia.thanks.jail"; + pub const MAGATIA_THANKS_FULL: &str = "magatia.thanks.full"; pub const PIRATE_MENU: &str = "pirate.menu"; pub const PIRATE_ENTER: &str = "pirate.enter"; pub const PIRATE_ABOUT: &str = "pirate.about"; @@ -3077,6 +3097,26 @@ mod tests { keys::MAGATIA_YULETE_DISGUSTED, keys::MAGATIA_YULETE_FOOLED, keys::MAGATIA_ROOM_TAKEN, + keys::MAGATIA_YULETE_PRESENTS, + keys::MAGATIA_YULETE_MIGHTY, + keys::MAGATIA_YULETE_BEHOLD, + keys::MAGATIA_YULETE_BEATEN, + keys::MAGATIA_FRANKENROID, + keys::MAGATIA_FRANKENROID_MIGHTY, + keys::MAGATIA_JULIET_FAINTED, + keys::MAGATIA_ROMEO_FAINTED, + keys::MAGATIA_ROMEO_SAVED, + keys::MAGATIA_JULIET_SAVED, + keys::MAGATIA_ROMEO_SAVED_HURT, + keys::MAGATIA_JULIET_SAVED_HURT, + keys::MAGATIA_GIFT, + keys::MAGATIA_GIFT_FULL, + keys::MAGATIA_TRACES_DEFEATED, + keys::MAGATIA_TRACES_CHEER, + keys::MAGATIA_TRACES_SORRY, + keys::MAGATIA_THANKS_REHAB, + keys::MAGATIA_THANKS_JAIL, + keys::MAGATIA_THANKS_FULL, keys::PIRATE_MENU, keys::PIRATE_ENTER, keys::PIRATE_ABOUT, diff --git a/integration-harness/tests/magatia_boss.rs b/integration-harness/tests/magatia_boss.rs new file mode 100644 index 00000000..988cd868 --- /dev/null +++ b/integration-harness/tests/magatia_boss.rs @@ -0,0 +1,304 @@ +//! Romeo and Juliet's party quest, its last stage and the rooms after: the +//! corridor's way leads into the boss's room, where Yulete waits; talked +//! to, he brings out his Frankenroid, telling everyone. Its fall wins the +//! run, paying each member by whether the escort fainted, sends the guards +//! away, and brings out Romeo or Juliet, who give the side's gift and send +//! each member on; in the last room each is thanked and rewarded on the +//! way home. + +use game_data::server_content::keys; +use integration_harness::events::talk_to; +use integration_harness::events::{armed_in, armed_with, cleared, maps, party, talk, told}; +use integration_harness::hunting::{attack, move_mob, walk_to}; +use integration_harness::quests::answer; +use integration_harness::trace::{read_for, send, until, warp, Trace}; +use integration_harness::WorldSession; +use net::events::{EventKind, MagatiaSide, MAGATIA_REWARDS}; +use net::npc_scripts::{magatia_hub_host, yulete, JULIET, MAGATIA_THANKS, ROMEO}; +use net::packet::op::RecvOpcode; +use packet::io::write::PktWrite; +use packet::Packet; +use tokio::time::Duration; + +/// The boss room's guards. +const GUARD: i32 = 9300150; +const PINK: u8 = 5; + +fn text(key: &str) -> String { + net::content::text().get(key, &[]) +} + +/// Four at level 75 in `side`'s lobby, partied up and taken in by `host`. +async fn four_in(side: MagatiaSide, host: i32) -> Vec { + let event = EventKind::Magatia(side).event(); + let mut team = vec![armed_with(event.lobby, 75, &[]).await]; + for _ in 0..3 { + team.push(armed_in(event.lobby, 75).await); + } + party(&mut team).await; + let mut seen = None; + for _ in 0..60 { + told(&mut team[0], talk(event.lobby, host)).await; + let trace = told(&mut team[0], answer(4, 1, Some(0))).await; + if !trace.prompts.iter().any(|&(from, _)| from == host) { + seen = Some(trace); + break; + } + tokio::time::sleep(Duration::from_secs(1)).await; + } + assert!(seen.is_some(), "{host} never let the party in"); + for player in team.iter_mut() { + let mut trace = seen.take().unwrap_or_default(); + until(&mut player.connection, &mut trace, |t| { + t.warps.contains(&event.entry) + }) + .await; + } + team +} + +/// MOB_DAMAGE_MOB_FRIENDLY as the v83 client sends it: mob `attacker` ran +/// into the friendly mob `victim` near `character_id`. +fn mob_damage_friendly(attacker: i32, character_id: i32, victim: i32) -> Packet { + let mut p = Packet::new_empty(); + p.write_short(RecvOpcode::MobDamageMobFriendly as i16) + .unwrap(); + p.write_int(attacker).unwrap(); + p.write_int(character_id).unwrap(); + p.write_int(victim).unwrap(); + p +} + +/// The leader goes from the corridor into `side`'s boss room and waits +/// for Yulete, the escort and the guards; what was seen. +async fn into_the_lab(leader: &mut WorldSession, side: MagatiaSide) -> Trace { + let lab = side.stages()[6]; + let corridor = lab - 1; + let mut seen = Trace::default(); + let c = &mut leader.connection; + assert_eq!(warp(c, corridor, "", &mut seen).await, corridor); + // Its portal `pt00` runs `rnj12_in` (`jnr12_in`). + let name = "pt00"; + let portal = maps() + .field(corridor) + .unwrap() + .portal_by_name(name) + .unwrap(); + send(c, walk_to(portal.x as i16, portal.y as i16)).await; + let mut p = Packet::new_empty(); + p.write_short(RecvOpcode::ChangeMapSpecial as i16).unwrap(); + p.write_byte(0).unwrap(); + p.write_str_with_length(name).unwrap(); + p.write_short(0).unwrap(); + let mut seen = Trace::default(); + send(c, p).await; + until(c, &mut seen, |t| { + t.warps.contains(&lab) + && t.field.npcs.iter().any(|&(_, id)| id == yulete(side)) + && !t.field.alive(side.escort()).is_empty() + && t.field.alive(GUARD).len() == 2 + }) + .await; + assert_eq!(seen.portal_sounds, 1, "{seen:?}"); + seen +} + +/// The object id of the NPC `npc` the trace saw. +fn npc(seen: &Trace, npc: i32) -> i32 { + let (oid, _) = *(seen.field.npcs.iter()) + .find(|&&(_, id)| id == npc) + .unwrap_or_else(|| panic!("{npc}: {:?}", seen.field.npcs)); + oid +} + +/// Yulete is talked to and brings out his Frankenroid `boss`, which every +/// member hears of; the leader kills it, which wins the run for `exp` +/// each, sends the guards away and brings out the side's host. +async fn beat_the_frankenroid( + team: &mut [WorldSession], + side: MagatiaSide, + seen: &mut Trace, + exp: i32, +) { + let said = told(&mut team[0], talk_to(npc(seen, yulete(side)))).await; + assert_eq!( + said.prompt_texts, + [text(keys::MAGATIA_YULETE_PRESENTS)], + "{said:?}" + ); + send(&mut team[0].connection, answer(0, 1, None)).await; + // It and the line may come before the talk's own trace ends. + let boss = side.frankenroid(false); + let called = (PINK, text(keys::MAGATIA_FRANKENROID)); + let c = &mut team[0].connection; + if !said.broadcasts.contains(&called) { + until(c, seen, |t| t.broadcasts.contains(&called)).await; + } + read_for(c, seen, 300).await; + let frankenroid = *(said.field.alive(boss).iter()) + .chain(&seen.field.alive(boss)) + .next() + .unwrap_or_else(|| panic!("no Frankenroid: {said:?}")); + for player in team[1..].iter_mut() { + let mut trace = Trace::default(); + until(&mut player.connection, &mut trace, |t| { + t.broadcasts.contains(&called) + }) + .await; + } + // Asked again, he bids them behold it. + let again = told(&mut team[0], talk_to(npc(seen, yulete(side)))).await; + assert_eq!( + again.prompt_texts, + [text(keys::MAGATIA_YULETE_BEHOLD)], + "{again:?}" + ); + send(&mut team[0].connection, answer(0, 1, None)).await; + + // A hit is capped by the leader's stats, so it takes a few. + let guards = seen.field.alive(GUARD); + let c = &mut team[0].connection; + for _ in 0..300 { + if seen.field.killed.contains(&frankenroid) { + break; + } + send(c, attack(frankenroid, 20_000)).await; + read_for(c, seen, 20).await; + } + until(c, seen, |t| { + t.field.killed.contains(&frankenroid) + && cleared(t) + && t.gains.contains(&exp) + && guards.iter().all(|oid| t.field.killed.contains(oid)) + && t.field + .npcs + .iter() + .any(|&(_, id)| id == magatia_hub_host(side)) + }) + .await; + for player in team[1..].iter_mut() { + let mut trace = Trace::default(); + until(&mut player.connection, &mut trace, |t| { + t.gains.contains(&exp) + }) + .await; + } + // No more guards come. + read_for(&mut team[0].connection, seen, 1500).await; + assert!(seen.field.alive(GUARD).is_empty(), "{:?}", seen.field.mobs); +} + +/// In the last room, where `seen` began, the one who thanks the party says +/// `key` and gives one of the rewards on the way back to `side`'s lobby. +async fn thanked(player: &mut WorldSession, side: MagatiaSide, mut seen: Trace, key: &str) { + until(&mut player.connection, &mut seen, |t| { + t.field.npcs.iter().any(|&(_, id)| id == MAGATIA_THANKS) + }) + .await; + let said = told(player, talk_to(npc(&seen, MAGATIA_THANKS))).await; + assert_eq!(said.prompt_texts, [text(key)], "{said:?}"); + let lobby = EventKind::Magatia(side).event().lobby; + let mut home = Trace::default(); + let c = &mut player.connection; + send(c, answer(0, 1, None)).await; + until(c, &mut home, |t| t.warps.contains(&lobby)).await; + let rewards: Vec = MAGATIA_REWARDS.iter().map(|&(id, _)| id).collect(); + assert!( + home.added + .iter() + .any(|&(_, _, item)| rewards.contains(&item)), + "{home:?}" + ); +} + +#[tokio::test] +async fn the_frankenroid_falls_and_romeo_sends_the_party_home_with_gifts() { + let side = MagatiaSide::Zenumist; + let mut team = four_in(side, ROMEO).await; + let mut seen = into_the_lab(&mut team[0], side).await; + beat_the_frankenroid(&mut team, side, &mut seen, 105_000).await; + + // Won, Yulete is beaten. + let beaten = told(&mut team[0], talk_to(npc(&seen, yulete(side)))).await; + assert_eq!( + beaten.prompt_texts, + [text(keys::MAGATIA_YULETE_BEATEN)], + "{beaten:?}" + ); + send(&mut team[0].connection, answer(0, 1, None)).await; + + // Romeo gives the gift and, Yulete not having been caught, sends the + // leader straight to the last room. + let host = npc(&seen, magatia_hub_host(side)); + let saved = told(&mut team[0], talk_to(host)).await; + assert_eq!( + saved.prompt_texts, + [text(keys::MAGATIA_ROMEO_SAVED)], + "{saved:?}" + ); + let gift = told(&mut team[0], answer(0, 1, None)).await; + assert_eq!(gift.prompt_texts, [text(keys::MAGATIA_GIFT)], "{gift:?}"); + let mut on = Trace::default(); + let c = &mut team[0].connection; + send(c, answer(0, 1, None)).await; + until(c, &mut on, |t| { + t.warps.contains(&side.thanks_room()) + && t.added.iter().any(|&(_, _, item)| item == side.gift()) + }) + .await; + thanked(&mut team[0], side, on, keys::MAGATIA_THANKS_REHAB).await; +} + +#[tokio::test] +async fn juliet_fainting_costs_the_run_its_gifts_and_some_exp() { + let side = MagatiaSide::Alcadno; + let mut team = four_in(side, JULIET).await; + let mut seen = into_the_lab(&mut team[0], side).await; + + // A guard the leader controls is walked into the escort until it + // faints, which the room hears. + let escort = seen.field.alive(side.escort())[0]; + let at = seen.field.mob_positions[&escort]; + let mut attacker = None; + for _ in 0..10 { + for guard in seen.field.alive(GUARD) { + let c = &mut team[0].connection; + send(c, move_mob(guard, at, false, 0, 0)).await; + read_for(c, &mut seen, 100).await; + if seen.field.move_responses.contains(&guard) { + attacker = Some(guard); + break; + } + } + if attacker.is_some() { + break; + } + } + let attacker = attacker.expect("the leader controls a guard"); + let fainted = (text(keys::MAGATIA_ROMEO_FAINTED), 0); + let character_id = team[0].character_id; + let c = &mut team[0].connection; + for _ in 0..2000 { + if seen.field.killed.contains(&escort) { + break; + } + send(c, mob_damage_friendly(attacker, character_id, escort)).await; + read_for(c, &mut seen, 20).await; + } + until(c, &mut seen, |t| t.item_lines.contains(&fainted)).await; + assert_eq!(seen.field.leave_types[&escort], 0); + + beat_the_frankenroid(&mut team, side, &mut seen, 90_000).await; + + // Juliet, hurt, gives nothing and sends the leader to the last room. + let host = npc(&seen, magatia_hub_host(side)); + let saved = told(&mut team[0], talk_to(host)).await; + let hurt = text(keys::MAGATIA_JULIET_SAVED_HURT); + assert_eq!(saved.prompt_texts, [hurt], "{saved:?}"); + let mut on = Trace::default(); + let c = &mut team[0].connection; + send(c, answer(0, 1, None)).await; + until(c, &mut on, |t| t.warps.contains(&side.thanks_room())).await; + assert!(!on.added.iter().any(|&(_, _, item)| item == side.gift())); + thanked(&mut team[0], side, on, keys::MAGATIA_THANKS_JAIL).await; +} diff --git a/net/src/events.rs b/net/src/events.rs index 4690421f..7b0b155d 100644 --- a/net/src/events.rs +++ b/net/src/events.rs @@ -165,6 +165,47 @@ impl MagatiaSide { MagatiaSide::Alcadno => 4_001_130, } } + + /// The one the party escorts in the boss's room: Juliet for + /// Zenumist, Romeo for Alcadno (Mob.nx 9300137 and 9300138). + pub fn escort(self) -> i32 { + match self { + MagatiaSide::Zenumist => 9_300_137, + MagatiaSide::Alcadno => 9_300_138, + } + } + + /// The Frankenroid Yulete brings out, the mighty one if he was + /// `caught` off guard (Cosmic `2112000` and `2112010`). + pub fn frankenroid(self, caught: bool) -> i32 { + match (self, caught) { + (MagatiaSide::Zenumist, false) => 9_300_139, + (MagatiaSide::Zenumist, true) => 9_300_140, + (MagatiaSide::Alcadno, false) => 9_300_151, + (MagatiaSide::Alcadno, true) => 9_300_152, + } + } + + /// What Romeo or Juliet gives each member after the win, unless the + /// escort fainted (Cosmic `2112006` and `2112005`). + pub fn gift(self) -> i32 { + match self { + MagatiaSide::Zenumist => 4_001_159, + MagatiaSide::Alcadno => 4_001_160, + } + } + + /// The room of Yulete's traces, where a member goes after the win + /// unless the plain Frankenroid was beaten without the escort + /// fainting. + pub fn traces_room(self) -> i32 { + self.stages()[0] + 500 + } + + /// The room where the party is thanked and rewarded, last. + pub fn thanks_room(self) -> i32 { + self.stages()[0] + 600 + } } /// Nett's Pyramid's modes, in Duarte's order (Cosmic `PyramidMode`); each @@ -1826,7 +1867,14 @@ const fn magatia(lobby: i32, entry: i32, stages: &'static [i32; 7]) -> Event { stride: 1, stage_maps: stages, talk_clears: &[MAGATIA_FILES_STAGE], - member_clears: &[1, 2, 3, MAGATIA_OFFICE_STAGE, MAGATIA_ROPES_STAGE], + member_clears: &[ + 1, + 2, + 3, + MAGATIA_OFFICE_STAGE, + MAGATIA_ROPES_STAGE, + MAGATIA_BOSS_STAGE, + ], clear_portals: &[], gated: &[], puzzles: 1..=0, @@ -1903,6 +1951,112 @@ pub const MAGATIA_ROPE_ROOMS: u8 = 4; pub const MAGATIA_ROPE_ROWS: usize = 10; pub const MAGATIA_ROPES_STAGE: u8 = 6; +/// The boss's room (`stages()[6]`, Cosmic `MagatiaPQ_Z`): Yulete waits +/// there from the first arrival, and talked to brings out his +/// Frankenroid where it stands; its death wins the run (the last stage, +/// which gives no EXP of its own but the run's, `magatia_win_exp`), and +/// the side's Romeo or Juliet comes out where they stand. In the rooms +/// after it, Yulete's traces and the one who thanks the party wait where +/// they stand. +pub const MAGATIA_BOSS_STAGE: u8 = 7; +pub const LAB_YULETE_AT: (i16, i16) = (200, 100); +pub const FRANKENROID_AT: (i16, i16) = (250, 100); +pub const MAGATIA_SAVED_AT: (i16, i16) = (-370, -150); +pub const MAGATIA_THANKS_AT: (i16, i16) = (200, 100); +/// The EXP each member gets for the win (Cosmic `monsterKilled`'s +/// `giveEventPlayersExp`): less if the escort fainted, and more for the +/// mighty Frankenroid that a caught Yulete brings out. +pub const MAGATIA_ESCORT_FAILED_EXP: i32 = 90_000; +pub const MAGATIA_FRANKENROID_EXP: i32 = 105_000; +pub const MAGATIA_MIGHTY_EXP: i32 = 140_000; +/// What the one who thanks the party gives each member: one of these +/// items, and how many, all equally likely (Cosmic `MagatiaPQ_Z`'s +/// `setEventRewards`). +pub const MAGATIA_REWARDS: [(i32, i16); 23] = [ + (2000003, 100), + (2000002, 100), + (2000004, 20), + (2000005, 10), + (2022003, 50), + (1032016, 1), + (1032015, 1), + (1032014, 1), + (2041212, 1), + (2041020, 1), + (2040502, 1), + (2041016, 1), + (2044701, 1), + (2040301, 1), + (2043201, 1), + (2040501, 1), + (2040704, 1), + (2044001, 1), + (2043701, 1), + (2040803, 1), + (1102026, 1), + (1102028, 1), + (1102029, 1), +]; + +/// The run's EXP for beating the Frankenroid, by whether the escort +/// fainted and whether Yulete was caught, which made it the mighty one. +/// Cosmic's Alcadno script pays the mighty one's either way; both sides +/// pay as its Zenumist script does. +pub fn magatia_win_exp(escort_failed: bool, caught: bool) -> i32 { + match (escort_failed, caught) { + (true, _) => MAGATIA_ESCORT_FAILED_EXP, + (false, false) => MAGATIA_FRANKENROID_EXP, + (false, true) => MAGATIA_MIGHTY_EXP, + } +} + +/// Whether `mob_id` is either side's Frankenroid, of either kind. +pub fn is_frankenroid(mob_id: i32) -> bool { + MagatiaSide::ALL.into_iter().any(|side| { + [false, true] + .map(|caught| side.frankenroid(caught)) + .contains(&mob_id) + }) +} + +/// What Yulete says in the boss's room, as text key (Cosmic `2112000`): +/// once the run is won, that he is beaten; once he brought out his +/// Frankenroid, to behold it; otherwise he brings it out, the mighty one +/// if he was caught off guard in his office. +pub fn yulete_in_lab(won: bool, called: bool, caught: bool) -> &'static str { + use game_data::server_content::keys; + match (won, called, caught) { + (true, ..) => keys::MAGATIA_YULETE_BEATEN, + (_, true, _) => keys::MAGATIA_YULETE_BEHOLD, + (_, _, false) => keys::MAGATIA_YULETE_PRESENTS, + (_, _, true) => keys::MAGATIA_YULETE_MIGHTY, + } +} + +/// What the whole run hears as Yulete brings out his Frankenroid, as +/// text key (Cosmic `2112000`'s `dropMessage(5, ..)`). +pub fn frankenroid_called(caught: bool) -> &'static str { + use game_data::server_content::keys; + match caught { + true => keys::MAGATIA_FRANKENROID_MIGHTY, + false => keys::MAGATIA_FRANKENROID, + } +} + +/// What everyone in the boss's room is told as the escort `mob_id` +/// faints, as text key, if it is one (Cosmic +/// `MobDamageMobFriendlyHandler`). +pub fn magatia_fainted(mob_id: i32) -> Option<&'static str> { + use game_data::server_content::keys; + match MagatiaSide::ALL + .into_iter() + .find(|side| side.escort() == mob_id)? + { + MagatiaSide::Zenumist => Some(keys::MAGATIA_JULIET_FAINTED), + MagatiaSide::Alcadno => Some(keys::MAGATIA_ROMEO_FAINTED), + } +} + /// What Yulete in his office makes of the party (Cosmic `yuleteTalked`): /// no one has talked to him yet, someone caught him off guard, or he is /// leaving. @@ -2835,6 +2989,12 @@ pub enum EventCommand { /// Yulete left his office, caught off guard or not /// (`yulete_leaves`): the team hears what he said, once. YuleteLeft(bool), + /// Yulete brought out his Frankenroid, the mighty one if he was + /// caught off guard (`frankenroid_called`): the team hears it. + FrankenroidOut(bool), + /// The escort in the boss's room fainted (Cosmic `friendlyKilled`'s + /// `escortFail`). + EscortFailed, /// A member's attack in a Monster Carnival battle killed mobs worth /// this many CP (their `getCP`). CarnivalCp(i32), @@ -4155,6 +4315,57 @@ mod tests { } } + #[test] + fn yulete_brings_out_his_frankenroid_whose_fall_wins_the_run() { + use game_data::server_content::keys; + assert_eq!(magatia_win_exp(false, false), 105_000); + assert_eq!(magatia_win_exp(false, true), 140_000); + assert_eq!(magatia_win_exp(true, false), 90_000); + assert_eq!(magatia_win_exp(true, true), 90_000); + assert_eq!(yulete_in_lab(true, true, true), keys::MAGATIA_YULETE_BEATEN); + assert_eq!( + yulete_in_lab(false, true, false), + keys::MAGATIA_YULETE_BEHOLD + ); + assert_eq!( + yulete_in_lab(false, false, false), + keys::MAGATIA_YULETE_PRESENTS + ); + assert_eq!( + yulete_in_lab(false, false, true), + keys::MAGATIA_YULETE_MIGHTY + ); + assert_eq!(frankenroid_called(false), keys::MAGATIA_FRANKENROID); + assert_eq!(frankenroid_called(true), keys::MAGATIA_FRANKENROID_MIGHTY); + assert_eq!(magatia_fainted(9300137), Some(keys::MAGATIA_JULIET_FAINTED)); + assert_eq!(magatia_fainted(9300138), Some(keys::MAGATIA_ROMEO_FAINTED)); + assert_eq!(magatia_fainted(9300139), None); + let z = MagatiaSide::Zenumist; + let a = MagatiaSide::Alcadno; + assert_eq!( + [z.frankenroid(false), z.frankenroid(true)], + [9300139, 9300140] + ); + assert_eq!( + [a.frankenroid(false), a.frankenroid(true)], + [9300151, 9300152] + ); + for id in [9300139, 9300140, 9300151, 9300152] { + assert!(is_frankenroid(id)); + } + assert!(!is_frankenroid(9300150)); + assert_eq!([z.gift(), a.gift()], [4001159, 4001160]); + for side in MagatiaSide::ALL { + let event = EventKind::Magatia(side).event(); + assert_eq!(event.last_stage(), MAGATIA_BOSS_STAGE); + assert!(event.member_clears.contains(&MAGATIA_BOSS_STAGE)); + assert_eq!( + side.stages()[usize::from(MAGATIA_BOSS_STAGE) - 1] % 1000, + 401 + ); + } + } + #[test] fn a_rope_room_sends_a_row_up_or_back_down() { let room = MagatiaSide::Zenumist.stages()[5] + 1; diff --git a/net/src/npc_scripts.rs b/net/src/npc_scripts.rs index 5586964b..07cd7f01 100644 --- a/net/src/npc_scripts.rs +++ b/net/src/npc_scripts.rs @@ -910,6 +910,11 @@ pub const ALCADNO_BOOK: i32 = 2112013; /// Yulete, in each side's office until he leaves it. pub const YULETE: i32 = 2112000; pub const YULETE_ALCADNO: i32 = 2112010; +/// Yulete's traces, in each side's room of them after the win, and the +/// one who thanks the party in the last room. +pub const YULETE_TRACES: i32 = 2112001; +pub const YULETE_TRACES_ALCADNO: i32 = 2112011; +pub const MAGATIA_THANKS: i32 = 2112018; /// Guon Over the Pirate Ship, who sends a party aboard against the Lord /// Pirate, and Guon on each of the ship's maps and its way out. pub const GUON: i32 = 2094000; @@ -1647,6 +1652,29 @@ impl NpcScript { magatia_hub(side, player, leader, cleared, door_shut) } + /// Romeo or Juliet in `side`'s boss's room once it is won, by whether + /// the escort fainted and whether Yulete was `caught`, which made his + /// Frankenroid the mighty one. + pub fn magatia_saved(side: MagatiaSide, escort_failed: bool, caught: bool) -> Self { + magatia_saved(side, escort_failed, caught) + } + + /// Yulete's traces in `side`'s room of them. + pub fn yulete_traces(side: MagatiaSide) -> Self { + yulete_traces(side) + } + + /// The one who thanks the party in `side`'s last room, to `player`, + /// by whether the escort fainted. + pub fn magatia_thanks( + side: MagatiaSide, + player: &Player, + escort_failed: bool, + rng: &mut impl Rng, + ) -> Self { + magatia_thanks(side, player, escort_failed, rng) + } + /// Eak (Cosmic `2013001`) to a player in the Orbis party quest on /// `map_id`, who may be its `leader`, whose team may have `cleared` the /// cloud stage. None elsewhere. @@ -7435,6 +7463,129 @@ pub fn magatia_hub_host(side: MagatiaSide) -> i32 { } } +/// Who waits in `map_id` from the first arrival in a run's copy of it, +/// and where (Cosmic `MagatiaPQ_Z.afterSetup`'s `spawnNpc`): Yulete in the +/// boss's room, his traces in theirs, and the one who thanks the party in +/// the last room. +pub fn magatia_waiting(map_id: i32) -> Option<(i32, (i16, i16))> { + let side = MagatiaSide::of(map_id)?; + if map_id == side.stages()[6] { + Some((yulete(side), events::LAB_YULETE_AT)) + } else if map_id == side.traces_room() { + Some((traces(side), events::MAGATIA_THANKS_AT)) + } else if map_id == side.thanks_room() { + Some((MAGATIA_THANKS, events::MAGATIA_THANKS_AT)) + } else { + None + } +} + +/// Yulete's traces on `side`. +pub fn traces(side: MagatiaSide) -> i32 { + match side { + MagatiaSide::Zenumist => YULETE_TRACES, + MagatiaSide::Alcadno => YULETE_TRACES_ALCADNO, + } +} + +/// Romeo (Cosmic `2112006`) or Juliet (`2112005`) in the boss's room once +/// it is won. If the escort fainted they thank the party and send each +/// member on to the last room; otherwise they give each the side's gift +/// (`MagatiaSide::gift`), which needs room, and send them on: to the last +/// room if Yulete was not caught, so the Frankenroid was the plain one, +/// or else past his traces first. Cosmic's Alcadno script never sends +/// anyone straight on; both sides do as its Zenumist script does. +fn magatia_saved(side: MagatiaSide, escort_failed: bool, caught: bool) -> NpcScript { + let npc = magatia_hub_host(side); + let zenumist = side == MagatiaSide::Zenumist; + if escort_failed { + let key = match zenumist { + true => keys::MAGATIA_ROMEO_SAVED_HURT, + false => keys::MAGATIA_JULIET_SAVED_HURT, + }; + return NpcScript::new( + npc, + vec![ + Page::Say(text(key)), + Page::Done(vec![Effect::WarpToPortal(side.thanks_room(), 0)]), + ], + ); + } + let key = match zenumist { + true => keys::MAGATIA_ROMEO_SAVED, + false => keys::MAGATIA_JULIET_SAVED, + }; + let next = match caught { + false => side.thanks_room(), + true => side.traces_room(), + }; + let mut script = NpcScript::new( + npc, + vec![ + Page::Say(text(key)), + Page::Say(text(keys::MAGATIA_GIFT)), + Page::Done(vec![ + Effect::GiveItem { + item_id: side.gift(), + quantity: 1, + }, + Effect::WarpToPortal(next, 0), + ]), + ], + ); + script.inventory_full = Some(text(keys::MAGATIA_GIFT_FULL)); + script +} + +/// Yulete's traces (Cosmic `2112001`, `2112011`): beaten, he is cheered +/// up, says he is sorry, and sends the member on to the last room. +fn yulete_traces(side: MagatiaSide) -> NpcScript { + let on = vec![ + Page::Say(text(keys::MAGATIA_TRACES_SORRY)), + Page::Done(vec![Effect::WarpToPortal(side.thanks_room(), 0)]), + ]; + NpcScript::new( + traces(side), + vec![Page::Menu { + text: text(keys::MAGATIA_TRACES_DEFEATED), + choices: vec![(text(keys::MAGATIA_TRACES_CHEER), on)], + }], + ) +} + +/// The one who thanks the party in the last room (Cosmic `2112018`, +/// `giveEventReward`): their thanks, by whether the escort fainted, then +/// one of `MAGATIA_REWARDS` and the way home to the side's lobby. A +/// member without a free slot in each kind of inventory the rewards come +/// in gets nothing yet, and stays. +fn magatia_thanks( + side: MagatiaSide, + player: &Player, + escort_failed: bool, + rng: &mut impl Rng, +) -> NpcScript { + let key = match escort_failed { + true => keys::MAGATIA_THANKS_JAIL, + false => keys::MAGATIA_THANKS_REHAB, + }; + let lobby = EventKind::Magatia(side).event().lobby; + let room = events::MAGATIA_REWARDS.iter().all(|&(item_id, _)| { + InventoryType::for_item(item_id).is_none_or(|kind| { + player.inventory.items(kind).count() < usize::from(player.inventory.slot_limit(kind)) + }) + }); + let last = match events::MAGATIA_REWARDS.choose(rng).filter(|_| room) { + Some(&(item_id, quantity)) => Page::Done(vec![ + Effect::GiveItem { item_id, quantity }, + Effect::Warp(lobby), + ]), + None => Page::Say(text(keys::MAGATIA_THANKS_FULL)), + }; + let mut script = NpcScript::new(MAGATIA_THANKS, vec![Page::Say(text(key)), last]); + script.inventory_full = Some(text(keys::MAGATIA_THANKS_FULL)); + script +} + /// Romeo (Cosmic `2112006`) or Juliet (`2112005`) in their side's hub of /// files: the side's letter from the library shocks them, which everyone /// hears; both files, from the leader, clear the stage, send the hub's @@ -15697,6 +15848,108 @@ mod tests { } } + #[test] + fn after_the_frankenroid_the_party_is_thanked_and_rewarded_on_the_way_home() { + for side in MagatiaSide::ALL { + let stages = side.stages(); + let (traces_room, thanks_room) = (side.traces_room(), side.thanks_room()); + assert_eq!(traces_room, stages[0] + 500); + assert_eq!(thanks_room, stages[0] + 600); + assert_eq!( + magatia_waiting(stages[6]), + Some((yulete(side), events::LAB_YULETE_AT)) + ); + assert_eq!( + magatia_waiting(traces_room), + Some((traces(side), events::MAGATIA_THANKS_AT)) + ); + assert_eq!( + magatia_waiting(thanks_room), + Some((MAGATIA_THANKS, events::MAGATIA_THANKS_AT)) + ); + assert_eq!(magatia_waiting(stages[5]), None); + + // Saved, Romeo or Juliet gives the gift and sends the member + // on: past Yulete's traces if he was caught, the mighty one + // having come out. + for (caught, next) in [(false, thanks_room), (true, traces_room)] { + let mut saved = NpcScript::magatia_saved(side, false, caught); + assert_eq!(saved.npc_id(), magatia_hub_host(side)); + saved.begin(); + let gift = saved.respond(ScriptInput::Next).unwrap(); + assert_eq!(page_text(&gift), text(keys::MAGATIA_GIFT)); + let given = saved.respond(ScriptInput::Next).unwrap(); + assert_eq!( + given.effects, + [ + Effect::GiveItem { + item_id: side.gift(), + quantity: 1 + }, + Effect::WarpToPortal(next, 0) + ] + ); + let full = saved.inventory_full().unwrap(); + assert_eq!(page_text(&full), text(keys::MAGATIA_GIFT_FULL)); + assert!(full.effects.is_empty()); + } + // With the escort fainted, there is no gift. + let mut hurt = NpcScript::magatia_saved(side, true, true); + hurt.begin(); + let on = hurt.respond(ScriptInput::Next).unwrap(); + assert_eq!(on.effects, [Effect::WarpToPortal(thanks_room, 0)]); + + let mut traced = NpcScript::yulete_traces(side); + let asked = traced.begin(); + let asked = format!("{:?}", asked.prompt); + assert!( + asked.contains(&text(keys::MAGATIA_TRACES_DEFEATED)), + "{asked}" + ); + assert!(asked.contains(&text(keys::MAGATIA_TRACES_CHEER)), "{asked}"); + let sorry = traced.respond(ScriptInput::Select(0)).unwrap(); + assert_eq!(page_text(&sorry), text(keys::MAGATIA_TRACES_SORRY)); + let on = traced.respond(ScriptInput::Next).unwrap(); + assert_eq!(on.effects, [Effect::WarpToPortal(thanks_room, 0)]); + + // The thanks, then one of the rewards and the way home. + let lobby = EventKind::Magatia(side).event().lobby; + let mut rng = rand::thread_rng(); + for (escort_failed, key) in [ + (false, keys::MAGATIA_THANKS_REHAB), + (true, keys::MAGATIA_THANKS_JAIL), + ] { + let mut member = player(50); + member.stats.map_id = thanks_room; + let mut thanks = NpcScript::magatia_thanks(side, &member, escort_failed, &mut rng); + assert_eq!(thanks.npc_id(), MAGATIA_THANKS); + assert_eq!(page_text(&thanks.begin()), text(key)); + let given = thanks.respond(ScriptInput::Next).unwrap(); + let [Effect::GiveItem { item_id, quantity }, Effect::Warp(to)] = given.effects[..] + else { + panic!("{given:?}"); + }; + assert!(events::MAGATIA_REWARDS.contains(&(item_id, quantity))); + assert_eq!(to, lobby); + } + // A full Use inventory gets nothing yet. + let mut member = player(50); + let slots = member.inventory.slot_limit(InventoryType::Use); + for slot in 1..=i16::from(slots) { + (member.inventory).insert( + InventoryType::Use, + slot, + game::Item::stack(2_000_000, 1), + ); + } + let mut thanks = NpcScript::magatia_thanks(side, &member, false, &mut rng); + thanks.begin(); + let full = thanks.respond(ScriptInput::Next).unwrap(); + assert_eq!(page_text(&full), text(keys::MAGATIA_THANKS_FULL)); + assert!(full.effects.is_empty()); + } + } + #[test] fn guon_takes_the_party_aboard_and_lets_a_member_off() { let mut ready = NpcScript::guon(Entry::Ready); diff --git a/net/src/scripts.rs b/net/src/scripts.rs index 51ee044b..26f28ace 100644 --- a/net/src/scripts.rs +++ b/net/src/scripts.rs @@ -1337,6 +1337,12 @@ pub fn portal(script: &str, player: &Player) -> Option { occupied: Some(content::text().get(keys::MAGATIA_PORTAL_SHUT, &[])), } } + // The corridor's way into the boss's room, never shut (Cosmic + // `rnj12_in` and `jnr12_in`). + "rnj12_in" | "jnr12_in" => { + let side = crate::events::MagatiaSide::of(player.stats.map_id)?; + through(Effect::WarpToPortal(side.stages()[6], 0)) + } // The hub's doors into the rooms beside it, once a Card Key opened // them, those rooms' ways back, once a door opened them too, and // the hub's way on, once Romeo or Juliet opened it for the files @@ -3236,6 +3242,14 @@ mod tests { ); assert_eq!(outcome.occupied.as_ref(), Some(&shut)); } + // The corridor's way into the boss's room is never shut. + player.stats.map_id = stages[6] - 1; + let outcome = portal(&format!("{prefix}12_in"), &player).unwrap(); + assert_eq!( + outcome.effects, + [Effect::PortalSound, Effect::WarpToPortal(stages[6], 0)] + ); + assert_eq!(outcome.occupied, None); } player.stats.map_id = crate::events::ZENUMIST_LOBBY; assert!(portal("rnj1_pt00", &player).is_none()); diff --git a/runtime/src/actor/client.rs b/runtime/src/actor/client.rs index 61581949..1fef69a3 100644 --- a/runtime/src/actor/client.rs +++ b/runtime/src/actor/client.rs @@ -1750,6 +1750,14 @@ impl ClientActor { self.tell_event(net::events::EventCommand::YuleteLeft(caught)) .await } + ServerMessage::FrankenroidOut(caught) => { + self.tell_event(net::events::EventCommand::FrankenroidOut(caught)) + .await + } + ServerMessage::EscortFailed => { + self.tell_event(net::events::EventCommand::EscortFailed) + .await + } ServerMessage::OpenEventReactor { map_id, name } => { self.tell_event(net::events::EventCommand::OpenReactor { map_id, name }) .await diff --git a/runtime/src/actor/client/dialogue.rs b/runtime/src/actor/client/dialogue.rs index e99fcf72..c4e26eb5 100644 --- a/runtime/src/actor/client/dialogue.rs +++ b/runtime/src/actor/client/dialogue.rs @@ -166,6 +166,18 @@ impl ClientActor { return self.start_script(script).await; } } + if matches!( + npc_id, + net::npc_scripts::ROMEO_HUB + | net::npc_scripts::JULIET_HUB + | net::npc_scripts::YULETE_TRACES + | net::npc_scripts::YULETE_TRACES_ALCADNO + | net::npc_scripts::MAGATIA_THANKS + ) { + if let Some(script) = self.magatia_after(npc_id).await? { + return self.start_script(script).await; + } + } if npc_id == net::npc_scripts::GUON_ABOARD { if let Some(script) = self.guon_aboard().await? { return self.start_script(script).await; diff --git a/runtime/src/actor/client/events.rs b/runtime/src/actor/client/events.rs index 07c7e8a2..5f1f158c 100644 --- a/runtime/src/actor/client/events.rs +++ b/runtime/src/actor/client/events.rs @@ -438,8 +438,9 @@ impl ClientActor { /// What Yulete, `npc_id`, says to the player in his office in their /// run of Romeo and Juliet's party quest, as the field decides by where - /// everyone stands and whether Romeo or Juliet read the letter; `None` - /// anywhere else, or once he has left. + /// everyone stands and whether Romeo or Juliet read the letter, or in + /// the boss's room, where he may bring out his Frankenroid; `None` + /// anywhere else, or once he has left his office. pub(super) async fn yulete(&mut self, npc_id: i32) -> Result, RuntimeError> { let map_id = self.in_game()?.player.stats.map_id; let Some(ticket) = self @@ -449,10 +450,15 @@ impl ClientActor { else { return Ok(None); }; - let shocked = ticket.shocked(); + let (shocked, caught, won) = (ticket.shocked(), ticket.caught_yulete(), ticket.won()); let (reply, said) = oneshot::channel(); - self.send_to_field(FieldMessage::Yulete { shocked, reply }) - .await?; + self.send_to_field(FieldMessage::Yulete { + shocked, + caught, + won, + reply, + }) + .await?; Ok(said .await .ok() @@ -460,6 +466,42 @@ impl ClientActor { .map(|key| NpcScript::yulete(npc_id, key))) } + /// What Romeo or Juliet in the boss's room once it is won, Yulete's + /// traces, or the one who thanks the party, `npc_id`, says to the + /// player in their run of Romeo and Juliet's party quest; `None` + /// anywhere else. + pub(super) async fn magatia_after( + &mut self, + npc_id: i32, + ) -> Result, RuntimeError> { + let map_id = self.in_game()?.player.stats.map_id; + let Some(ticket) = self.event.as_ref().filter(|ticket| ticket.holds(map_id)) else { + return Ok(None); + }; + let EventKind::Magatia(side) = ticket.kind else { + return Ok(None); + }; + let (escort_failed, caught) = (ticket.escort_failed(), ticket.caught_yulete()); + let saved = net::npc_scripts::magatia_hub_host(side); + if npc_id == saved && map_id == side.stages()[6] && ticket.won() { + return Ok(Some(NpcScript::magatia_saved(side, escort_failed, caught))); + } + if npc_id == net::npc_scripts::traces(side) && map_id == side.traces_room() { + return Ok(Some(NpcScript::yulete_traces(side))); + } + if npc_id == net::npc_scripts::MAGATIA_THANKS && map_id == side.thanks_room() { + let player = &self.in_game()?.player; + let mut rng = rand::thread_rng(); + return Ok(Some(NpcScript::magatia_thanks( + side, + player, + escort_failed, + &mut rng, + ))); + } + Ok(None) + } + /// What a portal of Romeo and Juliet's party quest, `portal` running /// `script`, does for the player: one of the landing's ways into the /// rope rooms takes them in unless another member took that room diff --git a/runtime/src/actor/event.rs b/runtime/src/actor/event.rs index a233a912..73fd6c12 100644 --- a/runtime/src/actor/event.rs +++ b/runtime/src/actor/event.rs @@ -120,6 +120,9 @@ pub struct Cleared { /// Yulete left his office caught off guard /// (`EventCommand::YuleteLeft`). caught_yulete: AtomicBool, + /// The escort in the Magatia boss's room fainted + /// (`EventCommand::EscortFailed`). + escort_failed: AtomicBool, /// The Magatia rope rooms taken, one bit each from the first. rooms: AtomicU8, } @@ -150,6 +153,10 @@ impl Cleared { self.caught_yulete.load(Ordering::Acquire) } + pub fn escort_failed(&self) -> bool { + self.escort_failed.load(Ordering::Acquire) + } + /// Take rope room `room`; returns whether no one had. pub fn take_room(&self, room: u8) -> bool { let Some(bit) = 1u8.checked_shl(room.into()) else { @@ -253,6 +260,11 @@ impl EventTicket { self.cleared.caught_yulete() } + /// Whether the escort in the Magatia boss's room fainted. + pub fn escort_failed(&self) -> bool { + self.cleared.escort_failed() + } + /// Take the Magatia rope room `room`, from 0, for the member; returns /// whether no one had (Cosmic `rnj4_r1`'s `statusStg5`). pub fn take_room(&self, room: u8) -> bool { @@ -1255,6 +1267,28 @@ impl EventActor { } } EventCommand::YuleteLeft(_) => debug!(client_id, "Yulete outside Magatia"), + // Everyone hears Yulete bring out his Frankenroid (Cosmic + // `2112000`'s `dropMessage(5, ..)`). + EventCommand::FrankenroidOut(caught) if matches!(self.kind, EventKind::Magatia(_)) => { + let key = net::events::frankenroid_called(caught); + let text = net::content::text().get(key, &[]); + if let Ok(line) = build_broadcast(Broadcast::Pink, &text) { + for member in self.team.members() { + if let Some(handle) = self.members.get(member) { + handle.send(ServerMessage::EventPackets { + id: self.id, + packets: vec![line.clone()], + }); + } + } + } + } + EventCommand::FrankenroidOut(_) => debug!(client_id, "Frankenroid outside Magatia"), + // Whenever it happens, as Cosmic's `friendlyKilled`. + EventCommand::EscortFailed if matches!(self.kind, EventKind::Magatia(_)) => { + self.cleared.escort_failed.store(true, Ordering::Release); + } + EventCommand::EscortFailed => debug!(client_id, "Escort outside Magatia"), EventCommand::Massacre(hits) if self.kind.pyramid_mode().is_some() => { let seconds = self.stage_seconds(); let mut reached = Vec::new(); @@ -1865,6 +1899,13 @@ impl EventActor { if self.kind == EventKind::Pirate { self.win_exp = Some(net::events::pirate_win_exp(self.cleared.chests())); } + // Romeo and Juliet's pays by the escort and the Frankenroid. + if let EventKind::Magatia(_) = self.kind { + self.win_exp = Some(net::events::magatia_win_exp( + self.cleared.escort_failed(), + self.cleared.caught_yulete(), + )); + } if !self.clear(self.kind.event().last_stage()) { return; } diff --git a/runtime/src/actor/field.rs b/runtime/src/actor/field.rs index 3d6e009d..adbfb730 100644 --- a/runtime/src/actor/field.rs +++ b/runtime/src/actor/field.rs @@ -287,6 +287,11 @@ pub struct FieldActor { /// In a run's copy of Yulete's office, once anyone has come in, what /// he makes of the party and when he leaves; no time once he has. yulete: Option<(net::events::Yulete, Option)>, + /// In a run's copy of the Magatia boss's room, whether Yulete has + /// brought out his Frankenroid. + frankenroid: bool, + /// In a run's copy of Magatia's boss room, whether the escort fainted. + escort_fainted: bool, /// In a jail under the sealed room, the levers that open its way on /// (`net::events::draw_jail`), once anyone has tried it; none once /// they have. @@ -373,6 +378,8 @@ impl FieldActor { books: None, beakers: BTreeSet::new(), yulete: None, + frankenroid: false, + escort_fainted: false, jail: None, dojo_boss: net::events::dojo_boss(key.map_id).filter(|_| key.instance_id != 0), massacre: net::events::pyramid_stage(key.map_id).is_some() && key.instance_id != 0, @@ -844,8 +851,17 @@ impl FieldActor { }); let _ = reply.send(climb.clone()); } - FieldMessage::Yulete { shocked, reply } => { - let _ = reply.send(self.talk_to_yulete(shocked)); + FieldMessage::Yulete { + shocked, + caught, + won, + reply, + } => { + let said = match self.call_frankenroid(caught, won) { + Some(key) => Some(key), + None => self.talk_to_yulete(shocked), + }; + let _ = reply.send(said); } FieldMessage::Jail { reply } => { let _ = reply.send(self.check_jail()); @@ -2049,6 +2065,7 @@ impl FieldActor { (net::mobs::ZAKUM_LAST, net::mobs::ZAKUM_ALTAR_MAP) => self.zakum_falls(), (net::mobs::HORNTAIL, net::events::HORNTAILS_CAVE) => self.horntail_falls(), (net::mobs::PAPULATUS_LAST, net::events::ORIGIN_OF_CLOCKTOWER) => self.boss_falls(), + (mob_id, _) if net::events::is_frankenroid(mob_id) => self.frankenroid_falls(), (mob_id, map_id) if net::events::is_lord_pirate(mob_id) && map_id == net::events::PIRATE_STAGES[5] @@ -2153,6 +2170,55 @@ impl FieldActor { Some(key) } + /// What Yulete says to someone who talks to him in a run's copy of the + /// boss's room, as text key (`net::events::yulete_in_lab`): the first + /// time before the run is won, he brings out his Frankenroid on the + /// ground below where it stands, the mighty one if he was `caught` off + /// guard in his office, and the run hears of it. `None` anywhere else. + fn call_frankenroid(&mut self, caught: bool, won: bool) -> Option<&'static str> { + let map_id = self.key.map_id; + let side = net::events::MagatiaSide::of(map_id) + .filter(|side| side.stages()[6] == map_id && self.key.instance_id != 0)?; + let key = net::events::yulete_in_lab(won, self.frankenroid, caught); + if won || self.frankenroid { + return Some(key); + } + self.frankenroid = true; + self.board(side.frankenroid(caught), 1, net::events::FRANKENROID_AT); + if let Some(occupant) = self.occupants.values().next() { + occupant.handle.send(ServerMessage::FrankenroidOut(caught)); + } + Some(key) + } + + /// A Frankenroid died in a run's copy of the boss's room: every mob + /// but the escort goes and no more come, the side's Romeo or Juliet + /// comes out, and the run is won, its clear shown and paid to every + /// member (Cosmic `MagatiaPQ_Z.monsterKilled`). + fn frankenroid_falls(&mut self) { + let map_id = self.key.map_id; + let side = net::events::MagatiaSide::of(map_id) + .filter(|side| side.stages()[6] == map_id && self.key.instance_id != 0); + let Some(side) = side else { + return; + }; + let saved = net::npc_scripts::magatia_hub_host(side); + if !self.map_npcs.iter().any(|npc| npc.npc_id == saved) { + self.appear(saved, net::events::MAGATIA_SAVED_AT); + } + self.clear_mobs(); + // Each says again whether the escort fainted before the clear, so + // the run knows it whichever member's clear comes first. + for occupant in self.occupants.values() { + if self.escort_fainted { + occupant.handle.send(ServerMessage::EscortFailed); + } + occupant.handle.send(ServerMessage::ClearEventStage( + net::events::MAGATIA_BOSS_STAGE, + )); + } + } + /// The Lord Pirate is beaten: everyone here is told, and Wu Yang comes /// out where he stood, once (Cosmic `PiratePQ`'s `monsterKilled`). fn lord_pirate_falls(&mut self) { @@ -3684,6 +3750,17 @@ impl FieldActor { if bunny && self.key.instance_id != 0 { self.lost_at = Some(now + net::events::scaled(FRIENDLY_LOST_AFTER)); } + // The Magatia escort's fainting only costs the run some of its + // EXP and gifts (Cosmic `friendlyKilled`). + let fainted = + net::events::magatia_fainted(hit.mob_id).filter(|_| self.key.instance_id != 0); + if let Some(key) = fainted { + self.item_line(&text.get(key, &[])); + self.escort_fainted = true; + if let Some(occupant) = self.occupants.values().next() { + occupant.handle.send(ServerMessage::EscortFailed); + } + } if net::events::protected(hit.mob_id) && self.key.instance_id != 0 { self.fail_event(); } @@ -4224,6 +4301,14 @@ impl FieldActor { let leaves = Instant::now() + net::events::YULETE_STAYS; self.yulete = Some((net::events::Yulete::Unmet, Some(leaves))); } + // And in the boss's room, as do his traces and the one who thanks + // the party in the rooms after it. + let waiting = net::npc_scripts::magatia_waiting(map_id).filter(|&(npc_id, _)| { + self.key.instance_id != 0 && !self.map_npcs.iter().any(|npc| npc.npc_id == npc_id) + }); + if let Some((npc_id, at)) = waiting { + self.appear(npc_id, at); + } for map_npc in &self.map_npcs { match build_spawn_npc(&to_foreign_npc(map_npc)) { diff --git a/runtime/src/message.rs b/runtime/src/message.rs index 8362b8b2..37dbb043 100644 --- a/runtime/src/message.rs +++ b/runtime/src/message.rs @@ -382,6 +382,13 @@ pub enum ServerMessage { /// Yulete left the player's field, his office, caught off guard or /// not (`net::events::EventCommand::YuleteLeft`): tell the event. YuleteLeft(bool), + /// Yulete brought out his Frankenroid on the player's field, the + /// mighty one if he was caught off guard + /// (`net::events::EventCommand::FrankenroidOut`): tell the event. + FrankenroidOut(bool), + /// The escort on the player's field, the Magatia boss's room, fainted + /// (`net::events::EventCommand::EscortFailed`): tell the event. + EscortFailed, /// This player's attack on a copy of a Nett's Pyramid stage did this to /// its mobs, for their run's tally. Massacre(net::pyramid::Hits), @@ -753,10 +760,14 @@ pub enum FieldMessage { reply: tokio::sync::oneshot::Sender>, }, /// Someone talked to Yulete in his office, after Romeo or Juliet read - /// the letter or not: what he says, as text key - /// (`net::events::yulete_talk`); `None` if he isn't here. + /// the letter or not, or in the boss's room, the run won or not and + /// Yulete `caught` off guard in his office or not: what he says, as + /// text key (`net::events::yulete_talk`, `yulete_in_lab`); `None` if + /// he isn't here. Yulete { shocked: bool, + caught: bool, + won: bool, reply: tokio::sync::oneshot::Sender>, }, /// The two of the tall room's levers Eak wants up