diff --git a/agent_docs/party-quests.md b/agent_docs/party-quests.md index c88fc0ed..b877ecc4 100644 --- a/agent_docs/party-quests.md +++ b/agent_docs/party-quests.md @@ -1291,7 +1291,43 @@ the hub's mobs away (`Effect::ClearMobs`) and open its way on `rnj3_out`/`jnr3_out` lead to x00203 only while it is open. After the clear they open it again whenever it has shut. A member holding the files is told to let the leader hand them in; anyone else is asked whether they -would rather retreat. The later stages can't be cleared yet. +would rather retreat. + +Yulete (Zenumist 2112000, Alcadno 2112010) comes out at (252, 243) in his +office (x00203) when the first member arrives there, and the field +keeps his mood (`net::events::Yulete`, the field's `yulete`). Talked to +(`FieldMessage::Yulete`, `events::yulete_talk`, Cosmic `2112000`), he +says he is leaving if he already means to; with anyone within 310 pixels +of him he says he is being watched and means to leave; if Romeo or Juliet +never read the letter (`EventTicket::shocked`) he calls the party sneaky +and means to leave; otherwise he is caught out. Ten seconds after the +first arrival, not scaled with the event, and only while someone is in the +office, he leaves (`build_uncontrol_npc`, then REMOVE_NPC 0x102), and +everyone in the run hears why in pink (`ServerMessage::YuleteLeft`, +`EventCommand::YuleteLeft`, `magatia.yulete.disgusted` if he was caught, +`magatia.yulete.fooled` if not); the event keeps which +(`EventTicket::caught_yulete`) for the boss's room. He leaves five of each +of his guards (9300143, 9300144) at each of (-455, 135), (0, 135) and +(360, 135). The last kill clears stage 5 (`cleared_by_killing`) for +20,000 EXP each and opens the office's door (`rnj6_out`, `jnr6_out`, +2618005, state 1); until then its way on (the same names) is shut, in +pink, and then leads to the landing (x00300). + +On the landing, its four ways (`rnj4_r1`-`rnj4_r4`, `jnr4_r1`-`jnr4_r4`) +each lead into one rope room (x00301-x00304) the first time, and only +then (`EventTicket::take_room`): anyone else is told, in pink, that the +room is taken (`magatia.room_taken`). In a rope room each of ten rows +has four ropes, `pt` (`rnj5_rp`, `jnr5_rp`), of which one is +right; it leads to the row above (portal id + 4), and from the top row to +the top (`success`); any other leads back to the bottom (`fail`). Each +room draws its right ropes the first time anyone climbs +(`events::draw_climb`, `events::climb`; the harness pins them with +`RUSTMS_MAGATIA_ROPES`). Cosmic's Zenumist script draws them so that each +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. Inside, Romeo or Juliet asks whether a member would rather retreat (`magatia.romeo.retreat`, `magatia.juliet.retreat`) and sends them to the @@ -1366,6 +1402,8 @@ nothing yet. Its lines are placeholders (`magatia.*`). | 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 | | 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 7f5f496a..4ad9f229 100644 --- a/game-data/placeholder_text.toml +++ b/game-data/placeholder_text.toml @@ -1335,6 +1335,13 @@ "magatia.juliet.hurry" = "[placeholder] Hurry, Romeo needs us." "magatia.files" = "[placeholder] Both files! Now we can go on." "magatia.files_leader" = "[placeholder] Let your leader hand me the files." +"magatia.yulete.leaving" = "[placeholder] You have company now; I'll take my leave." +"magatia.yulete.watched" = "[placeholder] I watched you all the way here. My helpers will see to you; I'm leaving." +"magatia.yulete.sneaky" = "[placeholder] Sneaky, but I saw you coming. My helpers will see to you; I'm leaving." +"magatia.yulete.caught" = "[placeholder] How did you get in here? Guards! I'm retreating for now." +"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." "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 e5d73000..e9d88443 100644 --- a/game-data/server_content.toml +++ b/game-data/server_content.toml @@ -1185,6 +1185,13 @@ unsourced = [ "magatia.juliet.hurry", "magatia.files", "magatia.files_leader", + "magatia.yulete.leaving", + "magatia.yulete.watched", + "magatia.yulete.sneaky", + "magatia.yulete.caught", + "magatia.yulete.disgusted", + "magatia.yulete.fooled", + "magatia.room_taken", "pirate.menu", "pirate.enter", "pirate.about", diff --git a/game-data/src/server_content.rs b/game-data/src/server_content.rs index 659832d8..999c0885 100644 --- a/game-data/src/server_content.rs +++ b/game-data/src/server_content.rs @@ -1836,6 +1836,13 @@ pub mod keys { pub const MAGATIA_JULIET_HURRY: &str = "magatia.juliet.hurry"; pub const MAGATIA_FILES: &str = "magatia.files"; pub const MAGATIA_FILES_LEADER: &str = "magatia.files_leader"; + pub const MAGATIA_YULETE_LEAVING: &str = "magatia.yulete.leaving"; + pub const MAGATIA_YULETE_WATCHED: &str = "magatia.yulete.watched"; + pub const MAGATIA_YULETE_SNEAKY: &str = "magatia.yulete.sneaky"; + pub const MAGATIA_YULETE_CAUGHT: &str = "magatia.yulete.caught"; + 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 PIRATE_MENU: &str = "pirate.menu"; pub const PIRATE_ENTER: &str = "pirate.enter"; pub const PIRATE_ABOUT: &str = "pirate.about"; @@ -3063,6 +3070,13 @@ mod tests { keys::MAGATIA_JULIET_HURRY, keys::MAGATIA_FILES, keys::MAGATIA_FILES_LEADER, + keys::MAGATIA_YULETE_LEAVING, + keys::MAGATIA_YULETE_WATCHED, + keys::MAGATIA_YULETE_SNEAKY, + keys::MAGATIA_YULETE_CAUGHT, + keys::MAGATIA_YULETE_DISGUSTED, + keys::MAGATIA_YULETE_FOOLED, + keys::MAGATIA_ROOM_TAKEN, keys::PIRATE_MENU, keys::PIRATE_ENTER, keys::PIRATE_ABOUT, diff --git a/integration-harness/docker-compose.test.yml b/integration-harness/docker-compose.test.yml index 411c680f..16f553ca 100644 --- a/integration-harness/docker-compose.test.yml +++ b/integration-harness/docker-compose.test.yml @@ -82,6 +82,9 @@ services: RUSTMS_OPQ_LOUNGE: "23" RUSTMS_OPQ_TOWER: "1234123412341234" RUSTMS_OPQ_LEVERS: "14" + # Magatia's rope rooms want ropes 0, 1, 2, 3, 0, 1, 2, 3, 0, 1 from + # the bottom up (see tests/magatia_ropes.rs). + RUSTMS_MAGATIA_ROPES: "0123012301" # Its first jail opens to levers 1, 3 and 5 (see tests/opq_jails.rs). RUSTMS_OPQ_JAIL1: "135" # Every Gachapon roll is the same uncommon prize, which the world hears diff --git a/integration-harness/src/hunting.rs b/integration-harness/src/hunting.rs index 602ac030..08cf23d8 100644 --- a/integration-harness/src/hunting.rs +++ b/integration-harness/src/hunting.rs @@ -437,6 +437,8 @@ pub struct Field { pub npcs: Vec<(i32, i32)>, /// Where each NPC spawned. pub npc_positions: BTreeMap, + /// Object ids of NPCs taken off the map. + pub npcs_removed: Vec, /// Where each mob spawned. pub mob_positions: BTreeMap, /// Statuses put on mobs: (object id, stats, first one's value, first @@ -737,6 +739,8 @@ impl Field { self.npcs.push((object_id, npc_id)); let at = (r.read_short().unwrap(), r.read_short().unwrap()); self.npc_positions.insert(object_id, at); + } else if op == SendOpcode::RemoveNpc as i16 { + self.npcs_removed.push(r.read_int().unwrap()); } else if op == SendOpcode::ShowForeignEffect as i16 { self.effects .push((r.read_int().unwrap(), r.read_byte().unwrap())); diff --git a/integration-harness/src/packets.rs b/integration-harness/src/packets.rs index e83ad798..cfd7c15a 100644 --- a/integration-harness/src/packets.rs +++ b/integration-harness/src/packets.rs @@ -284,6 +284,7 @@ pub fn opcode_name(opcode: i16) -> &'static str { x if x == SendOpcode::Whisper as i16 => "Whisper", x if x == SendOpcode::SpawnPlayer as i16 => "SpawnPlayer", x if x == SendOpcode::SpawnNpc as i16 => "SpawnNpc", + x if x == SendOpcode::RemoveNpc as i16 => "RemoveNpc", x if x == SendOpcode::RemovePlayerFromMap as i16 => "RemovePlayerFromMap", x if x == SendOpcode::ChatText as i16 => "ChatText", x if x == SendOpcode::MovePlayer as i16 => "MovePlayer", diff --git a/integration-harness/tests/magatia_ropes.rs b/integration-harness/tests/magatia_ropes.rs new file mode 100644 index 00000000..fc8bd919 --- /dev/null +++ b/integration-harness/tests/magatia_ropes.rs @@ -0,0 +1,259 @@ +//! Romeo and Juliet's party quest, its fifth and sixth stages: Yulete waits +//! in his office, and what he says depends on whether Romeo read the +//! letter and on who stands near him; ten seconds after the party first +//! comes in he leaves, saying so to everyone, and sets his guards on them. +//! Their last kill clears the stage and opens the office's way on. On the +//! landing beyond, each of the four ways leads one member into a rope room +//! of their own; in it the right rope of each row leads a row up and any +//! other back down, and the first to reach the top clears the stage. +//! The harness pins the right ropes (`RUSTMS_MAGATIA_ROPES`). + +use game_data::server_content::keys; +use integration_harness::events::told; +use integration_harness::events::{armed_in, armed_with, cleared, maps, party, talk, talk_to}; +use integration_harness::hunting::{attack, walk_to}; +use integration_harness::quests::answer; +use integration_harness::trace::{read_for, send, until, until_within, warp, Trace}; +use integration_harness::WorldSession; +use net::events::{EventKind, MagatiaSide}; +use net::npc_scripts::{JULIET, ROMEO, ROMEO_HUB, YULETE}; +use net::packet::op::RecvOpcode; +use packet::io::write::PktWrite; +use packet::Packet; +use tokio::time::Duration; + +const LETTER: i32 = 4001131; +/// Yulete's guards. +const GUARDS: [i32; 2] = [9300143, 9300144]; +/// The office's way on (`rnj6_out`). +const OFFICE_DOOR: i32 = 2618005; +/// The pinned right rope of each row, from the bottom. +const ROPES: [u8; 10] = [0, 1, 2, 3, 0, 1, 2, 3, 0, 1]; +const PINK: u8 = 5; + +fn text(key: &str) -> String { + net::content::text().get(key, &[]) +} + +/// Four at level 75 in `side`'s lobby, the leader holding `items`, partied +/// up and taken in by `host`. +async fn four_in(side: MagatiaSide, host: i32, items: &[(i32, i16)]) -> Vec { + let event = EventKind::Magatia(side).event(); + let mut team = vec![armed_with(event.lobby, 75, items).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 +} + +/// CHANGE_MAP_SPECIAL: press Up on the scripted portal `name`. +fn enter(name: &str) -> Packet { + 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(); + p +} + +/// Walk to `map`'s portal `name` and press Up; what came of it. +async fn take(player: &mut WorldSession, map: i32, name: &str) -> Trace { + let portal = maps().field(map).unwrap().portal_by_name(name).unwrap(); + send( + &mut player.connection, + walk_to(portal.x as i16, portal.y as i16), + ) + .await; + let mut trace = Trace::default(); + let c = &mut player.connection; + send(c, enter(name)).await; + until(c, &mut trace, |t| { + !t.warps.is_empty() || !t.broadcasts.is_empty() || !t.arrivals.is_empty() + }) + .await; + read_for(c, &mut trace, 300).await; + trace +} + +/// The portal of the rope room `room` that the rope `name` led to, after +/// its sound. +async fn climb(player: &mut WorldSession, room: i32, name: &str) -> u8 { + let trace = take(player, room, name).await; + assert_eq!(trace.portal_sounds, 1, "{name}: {trace:?}"); + let [(to, portal, _)] = trace.arrivals[..] else { + panic!("{name}: {:?}", trace.arrivals); + }; + assert_eq!(to, room, "{name}"); + portal +} + +#[tokio::test] +async fn yulete_caught_out_leaves_his_guards_whose_last_kill_opens_his_office() { + let side = MagatiaSide::Zenumist; + let stages = side.stages(); + let mut team = four_in(side, ROMEO, &[(LETTER, 1)]).await; + + // Romeo reads the letter in the hub, so Yulete has been found out. + let mut seen = Trace::default(); + let c = &mut team[0].connection; + assert_eq!(warp(c, stages[3], "", &mut seen).await, stages[3]); + let read = told(&mut team[0], talk(stages[3], ROMEO_HUB)).await; + assert_eq!(read.prompt_texts, [text(keys::MAGATIA_LETTER)], "{read:?}"); + send(&mut team[0].connection, answer(0, 1, None)).await; + + // In his office, talked to from across the room, he is caught. + let office = stages[4]; + let mut seen = Trace::default(); + let c = &mut team[0].connection; + assert_eq!(warp(c, office, "", &mut seen).await, office); + until(c, &mut seen, |t| { + t.field.npcs.iter().any(|&(_, id)| id == YULETE) + && t.field.reactors.iter().any(|r| r.reactor_id == OFFICE_DOOR) + }) + .await; + let (yulete, _) = *(seen.field.npcs.iter()) + .find(|&&(_, id)| id == YULETE) + .unwrap(); + let door = *(seen.field.reactors.iter()) + .find(|r| r.reactor_id == OFFICE_DOOR) + .unwrap(); + let said = told(&mut team[0], talk_to(yulete)).await; + assert_eq!(said.prompts, [(YULETE, 0)], "{said:?}"); + assert_eq!(said.prompt_texts, [text(keys::MAGATIA_YULETE_CAUGHT)]); + send(&mut team[0].connection, answer(0, 1, None)).await; + + // He leaves on his own, telling everyone, and five of each guard come + // out at each of three spots. + let left = (PINK, text(keys::MAGATIA_YULETE_DISGUSTED)); + let c = &mut team[0].connection; + until_within(c, &mut seen, Duration::from_secs(20), |t| { + t.broadcasts.contains(&left) + && t.field.npcs_removed.contains(&yulete) + && GUARDS + .iter() + .map(|&id| t.field.alive(id).len()) + .sum::() + == 30 + }) + .await; + for player in team[1..].iter_mut() { + let mut trace = Trace::default(); + until(&mut player.connection, &mut trace, |t| { + t.broadcasts.contains(&left) + }) + .await; + } + let shut = take(&mut team[0], office, "out00").await; + let line = (PINK, text(keys::MAGATIA_PORTAL_SHUT)); + assert!( + shut.warps.is_empty() && shut.broadcasts.contains(&line), + "{shut:?}" + ); + + // The last kill clears the stage, for 20000 EXP each, and opens the + // way on. + let guards: Vec = (GUARDS.iter()) + .flat_map(|&id| seen.field.alive(id)) + .collect(); + let c = &mut team[0].connection; + for &oid in &guards { + send(c, attack(oid, 20_000)).await; + send(c, attack(oid, 20_000)).await; + } + until(c, &mut seen, |t| { + guards.iter().all(|oid| t.field.killed.contains(oid)) + && cleared(t) + && t.gains.contains(&20_000) + && t.field.reactor_hits.contains(&(door.object_id, 1)) + }) + .await; + for player in team[1..].iter_mut() { + let mut trace = Trace::default(); + until(&mut player.connection, &mut trace, |t| { + t.gains.contains(&20_000) + }) + .await; + } + let went = take(&mut team[0], office, "out00").await; + assert_eq!(went.warps, [stages[5]], "{went:?}"); +} + +#[tokio::test] +async fn each_member_takes_a_rope_room_and_the_first_to_the_top_clears_the_ropes() { + let side = MagatiaSide::Alcadno; + let stages = side.stages(); + let landing = stages[5]; + let mut team = four_in(side, JULIET, &[]).await; + for player in team.iter_mut() { + let mut seen = Trace::default(); + let c = &mut player.connection; + assert_eq!(warp(c, landing, "", &mut seen).await, landing); + } + + // Each way leads into its own room, and only once. + let went = take(&mut team[0], landing, "pt00").await; + assert_eq!(went.warps, [landing + 1], "{went:?}"); + let taken = take(&mut team[1], landing, "pt00").await; + assert!( + taken.warps.is_empty() + && (taken.broadcasts).contains(&(PINK, text(keys::MAGATIA_ROOM_TAKEN))), + "{taken:?}" + ); + for (n, player) in team.iter_mut().enumerate().skip(1) { + let went = take(player, landing, &format!("pt0{n}")).await; + assert_eq!(went.warps, [landing + 1 + n as i32], "{went:?}"); + } + + // A wrong rope leads back down; the right one a row up, and from the + // top row to the top. + let room = landing + 1; + let p = &mut team[0]; + assert_eq!(climb(p, room, "pt01").await, 2); + for (row, &rope) in ROPES.iter().enumerate().take(ROPES.len() - 1) { + let up = climb(p, room, &format!("pt{row}{rope}")).await; + assert_eq!(up, 15 + 4 * (row as u8 + 1) + rope, "row {row}"); + } + let last = ROPES.len() - 1; + let mut seen = Trace::default(); + let name = format!("pt{last}{}", ROPES[last]); + let portal = maps().field(room).unwrap().portal_by_name(&name).unwrap(); + let c = &mut team[0].connection; + send(c, walk_to(portal.x as i16, portal.y as i16)).await; + send(c, enter(&name)).await; + until(c, &mut seen, |t| { + t.arrivals + .iter() + .any(|&(to, portal, _)| (to, portal) == (room, 1)) + && t.gains.contains(&20_000) + }) + .await; + // The ropes' clear is quiet, and pays everyone. + read_for(&mut team[0].connection, &mut seen, 300).await; + assert!(!cleared(&seen), "{:?}", seen.field_effects); + for player in team[1..].iter_mut() { + let mut trace = Trace::default(); + until(&mut player.connection, &mut trace, |t| { + t.gains.contains(&20_000) + }) + .await; + } +} diff --git a/net/src/events.rs b/net/src/events.rs index 6b367c04..4690421f 100644 --- a/net/src/events.rs +++ b/net/src/events.rs @@ -139,6 +139,24 @@ impl MagatiaSide { } } + /// The way out of Yulete's office (`stages()[4]`), which opens as its + /// stage clears (Cosmic `2618005`, `rnj6_out`). + pub fn office_door(self) -> &'static str { + match self { + MagatiaSide::Zenumist => "rnj6_out", + MagatiaSide::Alcadno => "jnr6_out", + } + } + + /// Which of its four rope rooms, beside the landing (`stages()[5]`), + /// `map_id` is, from 0. + pub fn rope_room(self, map_id: i32) -> Option { + let first = self.stages()[5] + 1; + (first..first + MAGATIA_ROPE_ROOMS as i32) + .contains(&map_id) + .then(|| (map_id - first) as u8) + } + /// The letter one of its library's books may hide (Cosmic `2112007` /// and `2112013`). pub fn letter(self) -> i32 { @@ -1569,8 +1587,8 @@ pub fn cleared_by_boss(map_id: i32, mob_id: i32) -> Option { /// The stage cleared once every mob on `map_id`, in an event's copy of /// it, is dead, and every reactor `seals` names there set: the first of /// the Forest of Poison Haze, the Pirate Ship's first, decks and doors, -/// and the hall below each Magatia library (Cosmic `EllinPQ`, -/// `PiratePQ` and `MagatiaPQ_Z`'s `monsterKilled`). +/// the hall below each Magatia library, and Yulete's office (Cosmic +/// `EllinPQ`, `PiratePQ` and `MagatiaPQ_Z`'s `monsterKilled`). pub fn cleared_by_killing(map_id: i32) -> Option { [ (ELLIN_STAGES[0], 1), @@ -1580,6 +1598,8 @@ pub fn cleared_by_killing(map_id: i32) -> Option { (PIRATE_STAGES[4], 5), (ZENUMIST_STAGES[1], 2), (ALCADNO_STAGES[1], 2), + (ZENUMIST_STAGES[4], MAGATIA_OFFICE_STAGE), + (ALCADNO_STAGES[4], MAGATIA_OFFICE_STAGE), ] .into_iter() .find_map(|(map, stage)| (map == map_id).then_some(stage)) @@ -1806,7 +1826,7 @@ 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], + member_clears: &[1, 2, 3, MAGATIA_OFFICE_STAGE, MAGATIA_ROPES_STAGE], clear_portals: &[], gated: &[], puzzles: 1..=0, @@ -1861,6 +1881,64 @@ pub const MAGATIA_CARD_KEY: i32 = 4_001_133; pub const MAGATIA_FILES: [i32; 2] = [4_001_134, 4_001_135]; pub const MAGATIA_FILES_STAGE: u8 = 4; +/// Yulete's office (`stages()[4]`): where he stands until he leaves +/// (Cosmic `MagatiaPQ_Z.afterSetup`'s `spawnNpc`), how near him anyone +/// in the office may be when he is talked to without his having seen +/// them coming (`2112000`'s `playersTooClose`), how long after the first +/// member arrives he leaves (`yuleteAction`, on the clock, not the event's +/// scale), and the mobs he leaves behind, five of each at each of three +/// spots. Killing them all clears the stage. +pub const YULETE_AT: (i16, i16) = (252, 243); +pub const YULETE_SEES: i32 = 310; +pub const YULETE_STAYS: Duration = Duration::from_secs(10); +pub const YULETE_GUARDS: [i32; 2] = [9_300_143, 9_300_144]; +pub const YULETE_GUARD_SPOTS: [(i16, i16); 3] = [(-455, 135), (0, 135), (360, 135)]; +pub const MAGATIA_OFFICE_STAGE: u8 = 5; + +/// The rope rooms beside the landing, each taken by one member (Cosmic +/// `rnj4_r1`-`rnj4_r4`'s `statusStg5`), their rows of four ropes, and the +/// stage the first to reach a room's top clears (`rnj5_rp`'s +/// `statusStg6`), which shows nothing but gives its EXP. +pub const MAGATIA_ROPE_ROOMS: u8 = 4; +pub const MAGATIA_ROPE_ROWS: usize = 10; +pub const MAGATIA_ROPES_STAGE: u8 = 6; + +/// 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. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Yulete { + Unmet, + Caught, + Leaving, +} + +/// What Yulete says to a player who talks to him, as text key, and what +/// he makes of the party after (Cosmic `2112000`): once leaving, that he +/// is; with anyone in the office within `YULETE_SEES` of him, or before +/// Romeo or Juliet read the letter, that he saw the party coming, and he +/// is leaving; otherwise he is caught off guard. +pub fn yulete_talk(mood: Yulete, too_close: bool, shocked: bool) -> (&'static str, Yulete) { + use game_data::server_content::keys; + match mood { + Yulete::Leaving => (keys::MAGATIA_YULETE_LEAVING, Yulete::Leaving), + _ if too_close => (keys::MAGATIA_YULETE_WATCHED, Yulete::Leaving), + _ if !shocked => (keys::MAGATIA_YULETE_SNEAKY, Yulete::Leaving), + _ => (keys::MAGATIA_YULETE_CAUGHT, Yulete::Caught), + } +} + +/// What Yulete tells the whole party as he leaves his office, as text +/// key, by whether he was `caught` off guard (Cosmic `yuleteAction`'s +/// `yuletePassed`, which later decides the Frankenroid the party meets). +pub fn yulete_leaves(caught: bool) -> &'static str { + use game_data::server_content::keys; + match caught { + true => keys::MAGATIA_YULETE_DISGUSTED, + false => keys::MAGATIA_YULETE_FOOLED, + } +} + /// What each of a library's books holds, in the order of the map's books, /// drawn for each run (Cosmic `MagatiaPQ_Z.afterSetup`): seven of mesos, /// seven of EXP, the letter, the switch, and ten with nothing. @@ -2754,6 +2832,9 @@ pub enum EventCommand { /// A member gave Romeo or Juliet their side's letter: the team hears /// it shook them, once. Shocked, + /// Yulete left his office, caught off guard or not + /// (`yulete_leaves`): the team hears what he said, once. + YuleteLeft(bool), /// A member's attack in a Monster Carnival battle killed mobs worth /// this many CP (their `getCP`). CarnivalCp(i32), @@ -3007,12 +3088,26 @@ fn pinned_digits(var: &str, range: RangeInclusive) -> Option } /// The right pad of each row of a climb, drawn the first time anyone -/// steps on one (Cosmic `party3_r4pt`, `party3_r6pt`): on the lounge two -/// rows of three, on the tall room sixteen rows of four. For testing, -/// `RUSTMS_OPQ_LOUNGE` (`"23"`) and `RUSTMS_OPQ_TOWER` (16 digits) pin them. +/// steps on one (Cosmic `party3_r4pt`, `party3_r6pt`, `jnr5_rp`): on the +/// lounge two rows of three, on the tall room sixteen rows of four, and +/// in a Magatia rope room ten rows of four ropes, numbered from 0. For +/// testing, `RUSTMS_OPQ_LOUNGE` (`"23"`), `RUSTMS_OPQ_TOWER` (16 digits) +/// and `RUSTMS_MAGATIA_ROPES` (10) pin them. pub fn draw_climb(map_id: i32, rng: &mut impl rand::Rng) -> Vec { static LOUNGE: OnceLock> = OnceLock::new(); static TOWER: OnceLock> = OnceLock::new(); + static ROPES: OnceLock> = OnceLock::new(); + if MagatiaSide::ALL + .iter() + .any(|side| side.rope_room(map_id).is_some()) + { + let pinned = ROPES.get_or_init(|| pinned_digits("RUSTMS_MAGATIA_ROPES", 0..=3)); + return pinned.map(|pin| pin.to_vec()).unwrap_or_else(|| { + (0..MAGATIA_ROPE_ROWS) + .map(|_| rng.gen_range(0, MAGATIA_ROPE_ROOMS)) + .collect() + }); + } let (rows, pads, pinned) = if map_id == OPQ_LOUNGE { let pinned = LOUNGE.get_or_init(|| pinned_digits("RUSTMS_OPQ_LOUNGE", 1..=3)); (2, 3, pinned.map(|pin| pin.to_vec())) @@ -3036,8 +3131,26 @@ pub fn draw_climb(map_id: i32, rng: &mut impl rand::Rng) -> Vec { /// of its section, the landing below it or, in the first, `r6fail` (5). /// Cosmic's script means that but divides as JavaScript does, so from /// rows 5-8, 10-12 and 14-16 its portal is a fraction or the very bottom. +/// +/// In a Magatia rope room (`pt`, from 0) the right rope leads +/// to the same rope a row up, four ids on, and that of the last row to +/// `success` (1); a wrong one to `fail` (2), at the bottom (Cosmic +/// `rnj5_rp`). pub fn climb(map_id: i32, combo: &[u8], portal_id: u8, name: &str) -> Option { let digits = |s: &str| s.parse::().ok(); + if MagatiaSide::ALL + .iter() + .any(|side| side.rope_room(map_id).is_some()) + { + let rest = name.strip_prefix("pt").filter(|rest| rest.len() == 2)?; + let (row, rope) = (digits(&rest[..1])?, digits(&rest[1..])?); + let right = *combo.get(row)?; + return Some(match (usize::from(right) == rope, row + 1 < combo.len()) { + (true, true) => portal_id + 4, + (true, false) => 1, + (false, _) => 2, + }); + } if map_id == OPQ_LOUNGE { let rest = name.strip_prefix("r4rp").filter(|rest| rest.len() == 2)?; let (row, pad) = (digits(&rest[..1])?, digits(&rest[1..])?); @@ -4001,6 +4114,68 @@ mod tests { assert_eq!(MagatiaSide::of(ZENUMIST_LOBBY), None); } + #[test] + fn yulete_minds_who_is_near_and_whether_the_letter_was_read() { + use game_data::server_content::keys; + let talk = |mood, near, shocked| yulete_talk(mood, near, shocked); + assert_eq!( + talk(Yulete::Unmet, false, true), + (keys::MAGATIA_YULETE_CAUGHT, Yulete::Caught) + ); + assert_eq!( + talk(Yulete::Caught, false, true), + (keys::MAGATIA_YULETE_CAUGHT, Yulete::Caught) + ); + assert_eq!( + talk(Yulete::Caught, true, true), + (keys::MAGATIA_YULETE_WATCHED, Yulete::Leaving) + ); + assert_eq!( + talk(Yulete::Unmet, false, false), + (keys::MAGATIA_YULETE_SNEAKY, Yulete::Leaving) + ); + for near in [false, true] { + assert_eq!( + talk(Yulete::Leaving, near, true), + (keys::MAGATIA_YULETE_LEAVING, Yulete::Leaving) + ); + } + assert_eq!(yulete_leaves(true), keys::MAGATIA_YULETE_DISGUSTED); + assert_eq!(yulete_leaves(false), keys::MAGATIA_YULETE_FOOLED); + for side in MagatiaSide::ALL { + let event = EventKind::Magatia(side).event(); + let stages = side.stages(); + assert_eq!(cleared_by_killing(stages[4]), Some(MAGATIA_OFFICE_STAGE)); + assert!(event.member_clears.contains(&MAGATIA_OFFICE_STAGE)); + assert!(event.member_clears.contains(&MAGATIA_ROPES_STAGE)); + assert_eq!(side.rope_room(stages[5]), None); + assert_eq!(side.rope_room(stages[5] + 1), Some(0)); + assert_eq!(side.rope_room(stages[5] + 4), Some(3)); + assert_eq!(side.rope_room(stages[5] + 5), None); + } + } + + #[test] + fn a_rope_room_sends_a_row_up_or_back_down() { + let room = MagatiaSide::Zenumist.stages()[5] + 1; + let mut rng = rand::thread_rng(); + let combo = draw_climb(room, &mut rng); + assert_eq!(combo.len(), MAGATIA_ROPE_ROWS); + assert!(combo.iter().all(|&rope| rope < MAGATIA_ROPE_ROOMS)); + let combo = [2, 0, 1, 3, 0, 1, 2, 3, 0, 1]; + // `pt` is portal 15 + 4 row + rope. + let step = + |row: u8, rope: u8| climb(room, &combo, 15 + 4 * row + rope, &format!("pt{row}{rope}")); + assert_eq!(step(0, 2), Some(21)); + assert_eq!(step(0, 1), Some(2)); + assert_eq!(step(8, 0), Some(51)); + assert_eq!(step(9, 1), Some(1)); + assert_eq!(step(9, 0), Some(2)); + assert_eq!(climb(room, &combo, 3, "np00"), None); + let alcadno = MagatiaSide::Alcadno.stages()[5] + 4; + assert_eq!(climb(alcadno, &combo, 19, "pt10"), Some(23)); + } + #[test] fn the_ship_takes_three_to_six_of_55_to_100_and_gives_more_time_at_each_stage() { let three: Vec<_> = (1..=3).map(|id| at(id, 55, PIRATE_LOBBY)).collect(); diff --git a/net/src/npc_scripts.rs b/net/src/npc_scripts.rs index ba1dcb4b..5586964b 100644 --- a/net/src/npc_scripts.rs +++ b/net/src/npc_scripts.rs @@ -907,6 +907,9 @@ pub const JULIET_HUB: i32 = 2112005; /// clears its first stage. pub const ZENUMIST_BOOK: i32 = 2112007; 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; /// 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; @@ -1430,6 +1433,12 @@ impl NpcScript { magatia_book(npc_id, find) } + /// Yulete, `npc_id`, in his office, saying the line his field chose + /// (`events::yulete_talk`), as text key. + pub fn yulete(npc_id: i32, key: &str) -> Self { + NpcScript::new(npc_id, vec![Page::Say(text(key))]) + } + /// Guon Over the Pirate Ship, for a player whose party may or may not /// board it. pub fn guon(entry: Entry) -> Self { @@ -7410,6 +7419,14 @@ fn magatia_book(npc_id: i32, find: Option) -> NpcScript { script } +/// Yulete in `side`'s office. +pub fn yulete(side: MagatiaSide) -> i32 { + match side { + MagatiaSide::Zenumist => YULETE, + MagatiaSide::Alcadno => YULETE_ALCADNO, + } +} + /// The hub's Romeo or Juliet on `side`. pub fn magatia_hub_host(side: MagatiaSide) -> i32 { match side { diff --git a/net/src/packet/build/world/npc.rs b/net/src/packet/build/world/npc.rs index a7e58bb6..1d290ccd 100644 --- a/net/src/packet/build/world/npc.rs +++ b/net/src/packet/build/world/npc.rs @@ -104,6 +104,24 @@ pub fn build_control_npc(npc: &ForeignNpc) -> Result { Ok(packet) } +/// The client stops picking NPC `object_id`'s idle actions: mode 0, then +/// its object id. +pub fn build_uncontrol_npc(object_id: i32) -> Result { + let mut packet = Packet::new_empty(); + packet.write_short(SendOpcode::SpawnNpcRequestController as i16)?; + packet.write_byte(0)?; + packet.write_int(object_id)?; + Ok(packet) +} + +/// REMOVE_NPC: NPC `object_id` is gone from the map. +pub fn build_remove_npc(object_id: i32) -> Result { + let mut packet = Packet::new_empty(); + packet.write_short(SendOpcode::RemoveNpc as i16)?; + packet.write_int(object_id)?; + Ok(packet) +} + /// NPC_ACTION: everyone, the controller too, plays the action and line it /// chose. `action` is what followed the object id in its request. pub fn build_npc_action(object_id: i32, action: &[u8]) -> Result { @@ -232,6 +250,20 @@ mod tests { expect.end(); } + #[test] + fn an_npc_taken_away_is_let_go_then_removed() { + let packet = build_uncontrol_npc(1_000_000_001).unwrap(); + let mut expect = Expect::new(&packet.bytes); + expect.short(SendOpcode::SpawnNpcRequestController as i16); + expect.byte(0).int(1_000_000_001); + expect.end(); + + let packet = build_remove_npc(1_000_000_001).unwrap(); + let mut expect = Expect::new(&packet.bytes); + expect.short(0x102).int(1_000_000_001); + expect.end(); + } + #[test] fn npc_action_relays_the_choice() { let packet = build_npc_action(1_000_000_001, &[3, 0xff]).unwrap(); diff --git a/net/src/packet/op/send.rs b/net/src/packet/op/send.rs index b0c1e33d..d2773e2e 100644 --- a/net/src/packet/op/send.rs +++ b/net/src/packet/op/send.rs @@ -214,6 +214,7 @@ pub enum SendOpcode { GuildNameChanged = 0xCA, GuildMarkChanged = 0xCB, SpawnNpc = 0x101, + RemoveNpc = 0x102, SpawnNpcRequestController = 0x103, NpcAction = 0x104, /// Show an NPC, or hide it, with an effect (261; report b095 ยง2). diff --git a/net/src/scripts.rs b/net/src/scripts.rs index 4fbaa508..51ee044b 100644 --- a/net/src/scripts.rs +++ b/net/src/scripts.rs @@ -184,6 +184,14 @@ const OPQ_ROOMS_OUT: [(i32, u8); 7] = [ /// the client actor answers from the field's draw (`events::climb`). pub const OPQ_LOUNGE_CLIMB: &str = "party3_r4pt"; pub const OPQ_TOWER_CLIMB: &str = "party3_r6pt"; +/// Romeo and Juliet's party quest's rope rooms, by side: the landing's +/// ways into them, each taken by one member, and their ropes, which the +/// client actor answers from the run's claims and the field's draw. +pub const MAGATIA_ROOMS_IN: [[&str; 4]; 2] = [ + ["rnj4_r1", "rnj4_r2", "rnj4_r3", "rnj4_r4"], + ["jnr4_r1", "jnr4_r2", "jnr4_r3", "jnr4_r4"], +]; +pub const MAGATIA_ROPES: [&str; 2] = ["rnj5_rp", "jnr5_rp"]; /// The Orbis party quest's way out of the garden, back to the hub. pub const OPQ_GARDEN_OUT: &str = "party3_gardenin"; /// The hub's door to the hidden storage (`in06`), open once the event is @@ -1303,14 +1311,17 @@ pub fn portal(script: &str, player: &Player) -> Option { } // A Magatia library's way down to its hall, once one of its books // held the switch, the hall's way on, once its monsters are all - // gone, and the beaker room's, once its three beakers were full - // (Cosmic `rnj1_pt00`, `rnj1_out`, `rnj2_out` and their Alcadno + // gone, the beaker room's, once its three beakers were full, and + // Yulete's office's, once the mobs he left are all gone (Cosmic + // `rnj1_pt00`, `rnj1_out`, `rnj2_out`, `rnj6_out` and their Alcadno // twins). - "rnj1_pt00" | "jnr1_pt00" | "rnj1_out" | "jnr1_out" | "rnj2_out" | "jnr2_out" => { + "rnj1_pt00" | "jnr1_pt00" | "rnj1_out" | "jnr1_out" | "rnj2_out" | "jnr2_out" + | "rnj6_out" | "jnr6_out" => { let side = crate::events::MagatiaSide::of(player.stats.map_id)?; let stage = match &script[3..] { "1_pt00" => 1, "1_out" => 2, + "6_out" => crate::events::MAGATIA_OFFICE_STAGE, _ => 3, }; ScriptOutcome { @@ -3209,7 +3220,7 @@ mod tests { (MagatiaSide::Alcadno, "jnr"), ] { let stages = side.stages(); - for (stage, script) in [(1u8, "1_pt00"), (2, "1_out"), (3, "2_out")] { + for (stage, script) in [(1u8, "1_pt00"), (2, "1_out"), (3, "2_out"), (5, "6_out")] { player.stats.map_id = stages[usize::from(stage) - 1]; let outcome = portal(&format!("{prefix}{script}"), &player).unwrap(); assert_eq!( diff --git a/runtime/src/actor/client.rs b/runtime/src/actor/client.rs index 8bb033ec..61581949 100644 --- a/runtime/src/actor/client.rs +++ b/runtime/src/actor/client.rs @@ -1746,6 +1746,10 @@ impl ClientActor { self.tell_event(net::events::EventCommand::ClearStage(stage)) .await } + ServerMessage::YuleteLeft(caught) => { + self.tell_event(net::events::EventCommand::YuleteLeft(caught)) + .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 67029e52..e99fcf72 100644 --- a/runtime/src/actor/client/dialogue.rs +++ b/runtime/src/actor/client/dialogue.rs @@ -156,6 +156,11 @@ impl ClientActor { return self.start_script(script).await; } } + if npc_id == net::npc_scripts::YULETE || npc_id == net::npc_scripts::YULETE_ALCADNO { + if let Some(script) = self.yulete(npc_id).await? { + return self.start_script(script).await; + } + } if npc_id == net::npc_scripts::ROMEO_HUB || npc_id == net::npc_scripts::JULIET_HUB { if let Some(script) = self.magatia_hub(npc_id).await? { return self.start_script(script).await; diff --git a/runtime/src/actor/client/events.rs b/runtime/src/actor/client/events.rs index 877d6f06..07c7e8a2 100644 --- a/runtime/src/actor/client/events.rs +++ b/runtime/src/actor/client/events.rs @@ -436,6 +436,85 @@ 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. + 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 + .event + .as_ref() + .filter(|ticket| matches!(ticket.kind, EventKind::Magatia(_)) && ticket.holds(map_id)) + else { + return Ok(None); + }; + let shocked = ticket.shocked(); + let (reply, said) = oneshot::channel(); + self.send_to_field(FieldMessage::Yulete { shocked, reply }) + .await?; + Ok(said + .await + .ok() + .flatten() + .map(|key| NpcScript::yulete(npc_id, key))) + } + + /// 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 + /// (Cosmic `rnj4_r1`), and a rope sends them a row up, or back down if + /// it is the wrong one; reaching the top clears the ropes' stage the + /// first time anyone does (Cosmic `rnj5_rp`). `None` for any other + /// portal. + pub(super) async fn magatia_portal( + &mut self, + script: &str, + portal: &PortalTemplate, + ) -> 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 landing = side.stages()[5]; + let room = (scripts::MAGATIA_ROOMS_IN.iter()) + .find_map(|ways| ways.iter().position(|&way| way == script)); + if let Some(room) = room.filter(|_| map_id == landing) { + let room = room as u8; + return Ok(Some(if ticket.take_room(room) { + vec![ + Effect::PortalSound, + Effect::WarpToPortal(landing + 1 + i32::from(room), 0), + ] + } else { + let line = net::content::text().get(keys::MAGATIA_ROOM_TAKEN, &[]); + vec![Effect::Pink(line)] + })); + } + if !scripts::MAGATIA_ROPES.contains(&script) || side.rope_room(map_id).is_none() { + return Ok(None); + } + let (reply, combo) = oneshot::channel(); + self.send_to_field(FieldMessage::Climb { reply }).await?; + let (Ok(combo), Ok(id)) = (combo.await, u8::try_from(portal.id)) else { + return Ok(None); + }; + let Some(to) = events::climb(map_id, &combo, id, &portal.name) else { + return Ok(None); + }; + if to == 1 { + self.tell_event(EventCommand::ClearStage(events::MAGATIA_ROPES_STAGE)) + .await; + } + Ok(Some(vec![ + Effect::PortalSound, + Effect::WarpToPortal(map_id, to), + ])) + } + /// What Eak says to the player in the Orbis party quest, if anything: /// in the music room whether its music box plays, in the sealed room /// how the team stands on its platforms (a wrong count shows as a @@ -1301,7 +1380,9 @@ impl ClientActor { let quiet = zpq || zakum || ticket.kind == EventKind::LudiMaze - || (ticket.kind == EventKind::Pirate && won); + || (ticket.kind == EventKind::Pirate && won) + || (matches!(ticket.kind, EventKind::Magatia(_)) + && stage == events::MAGATIA_ROPES_STAGE); let exp = if let Some(exp) = exp { exp } else if zpq && ticket.cleared(events::ZPQ_DOCUMENTS) { diff --git a/runtime/src/actor/client/script.rs b/runtime/src/actor/client/script.rs index 2eca957f..edb2a83d 100644 --- a/runtime/src/actor/client/script.rs +++ b/runtime/src/actor/client/script.rs @@ -68,7 +68,10 @@ impl ClientActor { .or(self.ellin_portal(script)?) { None => match self.htpq_portal(script).await? { - None => self.dojo_portal(script).await?, + None => match self.dojo_portal(script).await? { + None => self.magatia_portal(script, portal).await?, + effects => effects, + }, effects => effects, }, effects => effects, diff --git a/runtime/src/actor/event.rs b/runtime/src/actor/event.rs index 9c59fc29..a233a912 100644 --- a/runtime/src/actor/event.rs +++ b/runtime/src/actor/event.rs @@ -48,7 +48,7 @@ use net::packet::build::world::field::{build_pyramid_gauge, build_session_value} use net::packet::build::world::messaging::{build_broadcast, build_item_line, Broadcast}; use net::ClientId; use std::collections::{HashMap, HashSet}; -use std::sync::atomic::{AtomicI32, AtomicU64, AtomicU8, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicI32, AtomicU64, AtomicU8, Ordering}; use std::sync::{Arc, Mutex}; use std::time::Duration; use tokio::sync::{mpsc, oneshot}; @@ -107,13 +107,21 @@ pub enum EventMessage { /// The stages an event's party has cleared, one bit each, how many of the /// Pirate Ship's Marks it has handed in (`EventCommand::HandInMarks`), and -/// how many of its chests it has opened (`EventCommand::ChestOpened`). Only -/// the event writes it. +/// how many of its chests it has opened (`EventCommand::ChestOpened`). +/// Only the event writes it, but for the Magatia rope rooms that members +/// take. #[derive(Debug, Default)] pub struct Cleared { stages: AtomicU64, marks: AtomicU8, chests: AtomicU8, + /// Romeo or Juliet read the other's letter (`EventCommand::Shocked`). + shocked: AtomicBool, + /// Yulete left his office caught off guard + /// (`EventCommand::YuleteLeft`). + caught_yulete: AtomicBool, + /// The Magatia rope rooms taken, one bit each from the first. + rooms: AtomicU8, } impl Cleared { @@ -133,6 +141,22 @@ impl Cleared { pub fn chests(&self) -> u8 { self.chests.load(Ordering::Acquire) } + + pub fn shocked(&self) -> bool { + self.shocked.load(Ordering::Acquire) + } + + pub fn caught_yulete(&self) -> bool { + self.caught_yulete.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 { + return false; + }; + self.rooms.fetch_or(bit, Ordering::AcqRel) & bit == 0 + } } fn bit(stage: u8) -> Option { @@ -219,6 +243,22 @@ impl EventTicket { self.cleared.chests() } + /// Whether Romeo or Juliet has read the other's letter. + pub fn shocked(&self) -> bool { + self.cleared.shocked() + } + + /// Whether Yulete left his office caught off guard. + pub fn caught_yulete(&self) -> bool { + self.cleared.caught_yulete() + } + + /// 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 { + self.cleared.take_room(room) + } + /// Whether the party has cleared the last stage, so has no time limit. pub fn won(&self) -> bool { self.cleared(self.kind.event().last_stage()) @@ -428,7 +468,6 @@ pub fn spawn( moved_to: Vec::new(), reached: HashSet::new(), lord_pirate_out: false, - shocked: false, win_exp: None, blessing: start.blessing, pyramid_stage: 1, @@ -549,9 +588,6 @@ struct EventActor { reached: HashSet, /// The Lord Pirate is out (`EventCommand::PastTheDoors`). lord_pirate_out: bool, - /// Romeo or Juliet has read the other's letter - /// (`EventCommand::Shocked`). - shocked: bool, blessing: Option, /// The EXP every member gets for the win rather than the last stage's /// (`EventCommand::WinWithCoupons`). @@ -1177,7 +1213,7 @@ impl EventActor { // Once a run, everyone hears that Romeo or Juliet read the // letter (Cosmic `2112005`/`2112006`'s `mapMessage(6, ..)` to // the party's maps). - EventCommand::Shocked if !self.shocked && !won => { + EventCommand::Shocked if !self.cleared.shocked() && !won => { let key = match self.kind { EventKind::Magatia(net::events::MagatiaSide::Zenumist) => { keys::MAGATIA_ROMEO_SHOCKED @@ -1187,7 +1223,7 @@ impl EventActor { } _ => return debug!(client_id, "Shock outside Magatia"), }; - self.shocked = true; + self.cleared.shocked.store(true, Ordering::Release); let text = net::content::text().get(key, &[]); if let Ok(line) = build_item_line(&text, 0) { for member in self.team.members() { @@ -1201,6 +1237,24 @@ impl EventActor { } } EventCommand::Shocked => debug!(client_id, "Already shocked"), + // Everyone hears what Yulete said as he left (Cosmic + // `yuleteAction`'s `dropMessage(5, ..)`). + EventCommand::YuleteLeft(caught) if matches!(self.kind, EventKind::Magatia(_)) => { + self.cleared.caught_yulete.store(caught, Ordering::Release); + let key = net::events::yulete_leaves(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::YuleteLeft(_) => debug!(client_id, "Yulete outside Magatia"), EventCommand::Massacre(hits) if self.kind.pyramid_mode().is_some() => { let seconds = self.stage_seconds(); let mut reached = Vec::new(); diff --git a/runtime/src/actor/field.rs b/runtime/src/actor/field.rs index d39473c4..3d6e009d 100644 --- a/runtime/src/actor/field.rs +++ b/runtime/src/actor/field.rs @@ -55,7 +55,8 @@ use net::packet::build::world::mob::{ CatchFail, MobMoveView, MobView, }; use net::packet::build::world::npc::{ - build_control_npc, build_npc_action, build_spawn_npc, ForeignNpc, + build_control_npc, build_npc_action, build_remove_npc, build_spawn_npc, build_uncontrol_npc, + ForeignNpc, }; use net::packet::build::world::ox_quiz::{build_gm_event_instructions, build_ox_quiz}; use net::packet::build::world::pet::build_move_pet; @@ -283,6 +284,9 @@ pub struct FieldActor { books: Option<[Option; net::events::MAGATIA_BOOKS]>, /// In a Magatia beaker room, the beakers filled so far. beakers: BTreeSet, + /// 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 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. @@ -368,6 +372,7 @@ impl FieldActor { levers: None, books: None, beakers: BTreeSet::new(), + yulete: None, 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, @@ -445,6 +450,7 @@ impl FieldActor { fn on_tick(&mut self, now: Instant) { self.run_trips(); self.tick_merchants(now); + self.tick_yulete(now); self.friendly_drops(now); if self.lost_at.is_some_and(|at| at <= now) { self.lost_at = None; @@ -838,6 +844,9 @@ impl FieldActor { }); let _ = reply.send(climb.clone()); } + FieldMessage::Yulete { shocked, reply } => { + let _ = reply.send(self.talk_to_yulete(shocked)); + } FieldMessage::Jail { reply } => { let _ = reply.send(self.check_jail()); } @@ -2069,9 +2078,81 @@ impl FieldActor { for occupant in self.occupants.values() { occupant.handle.send(ServerMessage::ClearEventStage(stage)); } + // Yulete's office's way out opens with its clear (Cosmic + // `MagatiaPQ_Z.monsterKilled`'s `forceHitReactor(1)`). + let map_id = self.key.map_id; + let office = + net::events::MagatiaSide::of(map_id).filter(|side| side.stages()[4] == map_id); + if let Some(side) = office { + for view in self.reactors.set_state(side.office_door(), 1) { + if let Ok(packet) = build_hit_reactor(&view, 0, 0) { + self.broadcast(None, packet); + } + } + } + } + } + + /// Yulete, there since the first came into a run's copy of his office, + /// leaves once his time is up and anyone is there to see it: the run + /// hears what he said, by whether he was caught off guard, and five of + /// each of the mobs he left come out at each of their spots (Cosmic + /// `MagatiaPQ_Z.yuleteAction`). + fn tick_yulete(&mut self, now: Instant) { + let Some((mood, Some(leaves))) = self.yulete else { + return; + }; + if leaves > now || self.occupants.is_empty() { + return; + } + self.yulete = Some((net::events::Yulete::Leaving, None)); + let npc_id = net::events::MagatiaSide::of(self.key.map_id).map(net::npc_scripts::yulete); + if let Some(index) = self + .map_npcs + .iter() + .position(|npc| Some(npc.npc_id) == npc_id) + { + let object_id = self.map_npcs.remove(index).object_id; + for packet in [build_uncontrol_npc(object_id), build_remove_npc(object_id)] { + if let Ok(packet) = packet { + self.broadcast(None, packet); + } + } + } + for at in net::events::YULETE_GUARD_SPOTS { + for mob_id in net::events::YULETE_GUARDS { + self.board(mob_id, 5, at); + } + } + let caught = mood == net::events::Yulete::Caught; + if let Some(occupant) = self.occupants.values().next() { + occupant.handle.send(ServerMessage::YuleteLeft(caught)); } } + /// What Yulete says to someone who talks to him in his office, as text + /// key, by whether anyone here stands within `YULETE_SEES` of where he + /// came out and whether Romeo or Juliet read the letter + /// (`net::events::yulete_talk`); `None` once he has left, or anywhere + /// else. + fn talk_to_yulete(&mut self, shocked: bool) -> Option<&'static str> { + let (mood, leaves) = self.yulete?; + leaves?; + // Measured from where he was put, not the ground he stands on, as + // Cosmic's `2112000` does. + let (x, y) = net::events::YULETE_AT; + let (x, y) = (i32::from(x), i32::from(y)); + let sees = net::events::YULETE_SEES; + let too_close = self.occupants.values().any(|occupant| { + let dx = i32::from(occupant.avatar.x) - x; + let dy = i32::from(occupant.avatar.y) - y; + dx * dx + dy * dy < sees * sees + }); + let (key, mood) = net::events::yulete_talk(mood, too_close, shocked); + self.yulete = Some((mood, leaves)); + Some(key) + } + /// 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) { @@ -4133,6 +4214,17 @@ impl FieldActor { self.remove_summon(&summon.view, Gone::Silently); } + // Yulete waits in a run's copy of his office for the first to come + // in, and leaves a little after (Cosmic `MagatiaPQ_Z.changedMap`). + let map_id = self.key.map_id; + let office = net::events::MagatiaSide::of(map_id) + .filter(|side| side.stages()[4] == map_id && self.key.instance_id != 0); + if let (Some(side), None) = (office, self.yulete) { + self.appear(net::npc_scripts::yulete(side), net::events::YULETE_AT); + let leaves = Instant::now() + net::events::YULETE_STAYS; + self.yulete = Some((net::events::Yulete::Unmet, Some(leaves))); + } + for map_npc in &self.map_npcs { match build_spawn_npc(&to_foreign_npc(map_npc)) { Ok(packet) => { diff --git a/runtime/src/message.rs b/runtime/src/message.rs index 1aaf395a..8362b8b2 100644 --- a/runtime/src/message.rs +++ b/runtime/src/message.rs @@ -379,6 +379,9 @@ pub enum ServerMessage { map_id: i32, name: &'static str, }, + /// Yulete left the player's field, his office, caught off guard or + /// not (`net::events::EventCommand::YuleteLeft`): tell the event. + YuleteLeft(bool), /// 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), @@ -749,6 +752,13 @@ pub enum FieldMessage { Climb { 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. + Yulete { + shocked: bool, + reply: tokio::sync::oneshot::Sender>, + }, /// The two of the tall room's levers Eak wants up /// (`net::events::draw_levers`), drawn when first asked. Levers {