diff --git a/AGENTS.md b/AGENTS.md index 7bdd60f13..4aee2bd0c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,6 +15,7 @@ This repository is a Rust workspace for a MapleStory server plus a nested C++ cl - `agent_docs/parties.md`: party state and flows, EXP sharing, party chat, and the client's party window and HP displays - `agent_docs/buddies.md`: buddy list storage, requests, channel visibility, buddy chat, and the client's Friend tab - `agent_docs/notes.md`: notes (memos) left for offline characters, gift thank-yous, and the client's inbox and send dialog +- `agent_docs/families.md`: family storage, juniors and seniors, the Family window's numbers, the Family Tree, and login notices - `agent_docs/guilds.md`: guild storage, founding at Heracle, invitations, leaving and expelling, ranks, titles and the notice, guild chat, name tags, and the client's Guild tab - `agent_docs/pets.md`: pet items and storage, summoning, the list of pets out, their packets, and the client's side - `agent_docs/inventory.md`: inventory operations, item data, item moves, NPC shops, storage, Duey parcels, the Rock, Paper, Scissors game, and their packets diff --git a/agent_docs/families.md b/agent_docs/families.md new file mode 100644 index 000000000..5b8ec75ba --- /dev/null +++ b/agent_docs/families.md @@ -0,0 +1,103 @@ +# Families + +A family is a tree of characters. Each character can have one senior and up +to two juniors, who can have juniors of their own. The senior at the top +leads the family, which goes by the leader's name and has the leader's +message (the precepts). The protocol comes from report b039 in the v83 +reference notes. + +## Server + +The `family_members` table (`db/src/family.rs`) has one row per character +who has ever been in a family: + +- `senior_id` points at the character's senior; the juniors are the rows + that point at them. +- It also holds the rep counters: current, total, today, and rep recorded + to the senior. Nothing earns rep yet. +- `precepts` matters only on the leader's row. + +`game::family` holds the rules and the types: + +- `MAX_JUNIORS` is 2. +- A junior must be level 10 or above, below the senior's level, and at + most 20 levels below the senior (`check_levels`). +- A family may span at most 1000 generations. +- `Refusal` maps each refusal to the `FAMILY_RESULT` code that picks the + client's line. + +`Player.family` (`Relatives`) holds the senior and juniors. It is loaded +with the character, read again once the character is in the directory (a +change made while they were entering the game reached no one), and copied +into the directory's `Presence`. + +### Requests + +The handlers are in `net/src/packet/handle/world/family.rs`. They turn +packets into `FamilyRequest`s, which the client actor +(`runtime/src/actor/client/family.rs`) carries out: + +- **Entering the game, and `OPEN_FAMILY`:** the server sends + `FAMILY_INFO_RESULT` and `FAMILY_PRIVILEGE_LIST`. The client sends + nothing except `OPEN_FAMILY` and `OPEN_FAMILY_PEDIGREE` until it has + received INFO. The privilege list is empty for now. +- **`ADD_FAMILY` (name):** + 1. The actor checks the database rules (`db::family::may_add`): not + yourself, the junior has no senior and isn't one of yours, you have a + free junior slot, and the generation limit holds. + 2. The world directory (`runtime/src/actor/directory/family.rs`) checks + that the junior is online, on the same channel and map, and of the + right level. + 3. `GuildInvites`, keyed by the senior's id, lets one senior ask at a + time, for 180 s. A second senior gets "busy". The same senior asking + again while the card is up is ignored, because the junior's client + would decline the second card. A senior's requests end when they + leave the channel. + 4. The junior gets `FAMILY_JOIN_REQUEST`. +- **`ACCEPT_FAMILY` (inviter id, name, yes/no):** + - A no reaches the senior as `FAMILY_JOIN_REQUEST_RESULT` 0. + - A yes is checked again for the map and levels, since either may have + moved or levelled meanwhile. It then goes to the senior's actor + (`ServerMessage::AddJunior`), which + runs `db::family::add_junior`. That function checks the rules again + inside a transaction that locks both rows. On success: + - the senior gets `FAMILY_JOIN_REQUEST_RESULT` 1; + - the junior gets `FAMILY_JOIN_ACCEPTED`; + - everyone whose family changed (the senior, and the junior's whole + branch) reloads `Relatives` and gets INFO again + (`DirectoryMessage::FamilyChanged`). +- **`OPEN_FAMILY_PEDIGREE` (name):** the server sends + `FAMILY_CHART_RESULT` for the named character (`db::family::chart`). + - The chart lists the leader, the two seniors above the character, the + character, their sibling, their juniors, and their juniors' juniors, + each parent before its children. These are the 11 slots of the window. + - A senior's senior whose own senior isn't shown hangs from the leader. + - The directory marks who is online (a channel change counts) and fills + in their current level, job and map. + - Head counts: + - key -1: the whole family; + - key 0: the character's seniors; + - each junior's id: that junior's branch. + - Out of a family the chart is empty, and the client draws the player + alone. + - When INFO arrives while the player's own tree is open, the client asks + for the chart again. + +### Login notices + +Relatives (the senior and the juniors) get `FAMILY_NOTIFY_LOGIN_OR_LOGOUT` +when a member enters or leaves the game. A channel change is held for +`CHANNEL_CHANGE_GRACE` (`family_away`), as with guilds, so it says nothing. + +### Not yet + +These are not implemented yet: + +- separating from a senior or a junior; +- entitlements (`USE_FAMILY`) and the summon; +- changing the precepts; +- earning rep. + +## Client + +HeavenClient has no family windows yet. diff --git a/db/migrations/2026-11-08-000000_families/down.sql b/db/migrations/2026-11-08-000000_families/down.sql new file mode 100644 index 000000000..32a401327 --- /dev/null +++ b/db/migrations/2026-11-08-000000_families/down.sql @@ -0,0 +1 @@ +DROP TABLE family_members; diff --git a/db/migrations/2026-11-08-000000_families/up.sql b/db/migrations/2026-11-08-000000_families/up.sql new file mode 100644 index 000000000..957566bb6 --- /dev/null +++ b/db/migrations/2026-11-08-000000_families/up.sql @@ -0,0 +1,17 @@ +-- Families: each character has at most one senior and a few juniors, and +-- the senior at the top leads the family, which goes by their name. A +-- character gets a row on first joining one; a row with neither a senior +-- nor juniors is in no family. +CREATE TABLE family_members ( + character_id INTEGER PRIMARY KEY REFERENCES characters(id) ON DELETE CASCADE, + senior_id INTEGER REFERENCES family_members(character_id) ON DELETE SET NULL, + -- Rep to spend, and all ever earned. + reputation INTEGER NOT NULL DEFAULT 0, + total_reputation INTEGER NOT NULL DEFAULT 0, + -- Rep earned today, and given to the senior. + today_reputation INTEGER NOT NULL DEFAULT 0, + senior_reputation INTEGER NOT NULL DEFAULT 0, + -- The leader's message to the family (the precepts). + precepts VARCHAR(200) NOT NULL DEFAULT '' +); +CREATE INDEX family_members_senior ON family_members (senior_id); diff --git a/db/src/family.rs b/db/src/family.rs new file mode 100644 index 000000000..7bacf6771 --- /dev/null +++ b/db/src/family.rs @@ -0,0 +1,272 @@ +//! Families (report b039). A row's `senior_id` is the character's senior; +//! juniors are the rows naming them. The senior at the top leads the family, +//! which goes by their name, and keeps its message. + +use crate::schema::{characters, family_members}; +use diesel::prelude::*; +use game::family::{ + ChartMember, FamilyChart, FamilyInfo, Refusal, Relatives, MAX_GENERATIONS, MAX_JUNIORS, +}; + +diesel::define_sql_function!(fn lower(x: diesel::sql_types::Text) -> diesel::sql_types::Text); + +/// The id of the character named `name`, ignoring case. +pub fn character(name: &str) -> crate::Result> { + Ok(characters::table + .filter(lower(characters::name).eq(lower(name))) + .select(characters::id) + .first(&mut crate::connection()?) + .optional()?) +} + +/// The character's senior and juniors, read again. +pub fn relatives_of(character_id: i32) -> crate::Result { + let mut conn = crate::connection()?; + Ok(relatives(&mut conn, character_id)?) +} + +/// The character's senior and juniors, juniors in the order they joined +/// the table. +pub fn relatives(conn: &mut PgConnection, character_id: i32) -> QueryResult { + let senior = family_members::table + .find(character_id) + .select(family_members::senior_id) + .first::>(conn) + .optional()? + .flatten(); + Ok(Relatives { + senior, + juniors: juniors(conn, character_id)?, + }) +} + +fn juniors(conn: &mut PgConnection, character_id: i32) -> QueryResult> { + family_members::table + .filter(family_members::senior_id.eq(character_id)) + .order(family_members::character_id) + .select(family_members::character_id) + .load(conn) +} + +fn senior_of(conn: &mut PgConnection, character_id: i32) -> QueryResult> { + Ok(family_members::table + .find(character_id) + .select(family_members::senior_id) + .first::>(conn) + .optional()? + .flatten()) +} + +/// The character's seniors, nearest first. +fn seniors(conn: &mut PgConnection, character_id: i32) -> QueryResult> { + let mut seniors = Vec::new(); + let mut at = character_id; + while let Some(senior) = senior_of(conn, at)? { + // A loop would be a bug; stop at the most a family may span. + if seniors.len() >= MAX_GENERATIONS || senior == character_id { + break; + } + seniors.push(senior); + at = senior; + } + Ok(seniors) +} + +/// The character and everyone below them, generation by generation, and +/// how many generations that is. +fn descendants(conn: &mut PgConnection, character_id: i32) -> QueryResult<(Vec, usize)> { + let mut all = vec![character_id]; + let mut generation = vec![character_id]; + let mut generations = 1; + while generations < MAX_GENERATIONS { + generation = family_members::table + .filter(family_members::senior_id.eq_any(&generation)) + .select(family_members::character_id) + .load(conn)?; + if generation.is_empty() { + break; + } + all.extend(&generation); + generations += 1; + } + Ok((all, generations)) +} + +/// What the Family window shows the character. Out of a family, only their +/// own rep, with no family name. +pub fn info(character_id: i32) -> crate::Result { + let mut conn = crate::connection()?; + let row = family_members::table + .find(character_id) + .select(( + family_members::reputation, + family_members::total_reputation, + family_members::today_reputation, + )) + .first::<(i32, i32, i32)>(&mut conn) + .optional()?; + let (reputation, total_reputation, today_reputation) = row.unwrap_or_default(); + let juniors = juniors(&mut conn, character_id)?; + let seniors = seniors(&mut conn, character_id)?; + let mut info = FamilyInfo { + reputation, + total_reputation, + today_reputation, + juniors: juniors.len() as u16, + ..FamilyInfo::default() + }; + if seniors.is_empty() && juniors.is_empty() { + return Ok(info); + } + let leader = seniors.last().copied().unwrap_or(character_id); + let (name, precepts) = characters::table + .inner_join(family_members::table) + .filter(characters::id.eq(leader)) + .select((characters::name, family_members::precepts)) + .first::<(String, String)>(&mut conn)?; + info.leader_id = leader; + info.name = name; + info.precepts = precepts; + Ok(info) +} + +/// The Family Tree around `viewed`, without who is online. +pub fn chart(viewed: i32) -> crate::Result { + let mut conn = crate::connection()?; + let conn = &mut conn; + let mut chart = FamilyChart { + viewed, + ..FamilyChart::default() + }; + let seniors = seniors(conn, viewed)?; + let juniors = juniors(conn, viewed)?; + if seniors.is_empty() && juniors.is_empty() { + return Ok(chart); + } + let leader = seniors.last().copied().unwrap_or(viewed); + let mut ids = vec![leader]; + ids.extend(seniors.iter().take(2).rev()); + ids.push(viewed); + if let Some(&senior) = seniors.first() { + ids.extend(self::juniors(conn, senior)?); + } + ids.extend(&juniors); + for &junior in &juniors { + ids.extend(self::juniors(conn, junior)?); + } + let mut seen = std::collections::HashSet::new(); + ids.retain(|id| seen.insert(*id)); + + let rows = characters::table + .inner_join(family_members::table) + .filter(characters::id.eq_any(&ids)) + .select(( + characters::id, + family_members::senior_id, + characters::job, + characters::level, + family_members::reputation, + family_members::total_reputation, + family_members::senior_reputation, + family_members::today_reputation, + characters::map_id, + characters::name, + )) + .load::<(i32, Option, i16, i16, i32, i32, i32, i32, i32, String)>(conn)?; + let mut rows: std::collections::HashMap<_, _> = + rows.into_iter().map(|row| (row.0, row)).collect(); + chart.members = ids + .iter() + .filter_map(|id| rows.remove(id)) + .map(|row| ChartMember { + id: row.0, + senior_id: row.1.unwrap_or(0), + job: row.2, + level: row.3 as u8, + online: false, + reputation: row.4, + total_reputation: row.5, + senior_reputation: row.6, + today_reputation: row.7, + map_id: row.8, + name: row.9, + }) + .collect(); + // The senior's senior hangs from the leader when generations between + // aren't shown. + let shown: Vec = chart.members.iter().map(|member| member.id).collect(); + for member in chart.members.iter_mut().skip(1) { + if !shown.contains(&member.senior_id) { + member.senior_id = leader; + } + } + + let family = descendants(conn, leader)?.0.len(); + chart.counts = vec![(-1, family as i32), (0, seniors.len() as i32)]; + for &junior in &juniors { + let branch = descendants(conn, junior)?.0.len(); + chart.counts.push((junior, branch as i32)); + } + Ok(chart) +} + +/// Whether `senior` may take `junior`, by the family rules (levels and +/// the map are the caller's). +fn check(conn: &mut PgConnection, senior: i32, junior: i32) -> QueryResult> { + if senior == junior { + return Ok(Err(Refusal::CannotAdd)); + } + if senior_of(conn, junior)?.is_some() { + return Ok(Err(Refusal::HasSenior)); + } + let seniors = seniors(conn, senior)?; + if seniors.contains(&junior) { + return Ok(Err(Refusal::SameFamily)); + } + if juniors(conn, senior)?.len() >= usize::from(MAX_JUNIORS) { + return Ok(Err(Refusal::CannotAdd)); + } + let (_, below) = descendants(conn, junior)?; + if seniors.len() + 1 + below > MAX_GENERATIONS { + return Ok(Err(Refusal::Generations)); + } + Ok(Ok(())) +} + +/// Whether `senior` may ask `junior`, as it stands. +pub fn may_add(senior: i32, junior: i32) -> crate::Result> { + let mut conn = crate::connection()?; + Ok(check(&mut conn, senior, junior)?) +} + +/// Make `junior` (and everyone below them) `senior`'s. On success, everyone +/// whose family changed: the senior and the junior's whole branch. +pub fn add_junior(senior: i32, junior: i32) -> crate::Result, Refusal>> { + let mut conn = crate::connection()?; + Ok(conn.transaction(|conn| -> QueryResult<_> { + let mut both = [senior, junior]; + both.sort_unstable(); + for id in both { + diesel::insert_into(family_members::table) + .values(family_members::character_id.eq(id)) + .on_conflict_do_nothing() + .execute(conn)?; + } + // Two adds to one senior, or each of two to the other, take turns. + family_members::table + .filter(family_members::character_id.eq_any(both)) + .order(family_members::character_id) + .select(family_members::character_id) + .for_update() + .load::(conn)?; + if let Err(refusal) = check(conn, senior, junior)? { + return Ok(Err(refusal)); + } + diesel::update(family_members::table.find(junior)) + .set(family_members::senior_id.eq(senior)) + .execute(conn)?; + let (mut changed, _) = descendants(conn, junior)?; + changed.insert(0, senior); + Ok(Ok(changed)) + })?) +} diff --git a/db/src/lib.rs b/db/src/lib.rs index f535b1bf3..f98397111 100644 --- a/db/src/lib.rs +++ b/db/src/lib.rs @@ -24,6 +24,7 @@ pub mod buddy; pub mod cash_shop; pub mod character; pub mod fame; +pub mod family; pub mod guild; pub mod guild_bbs; pub mod keybinding; diff --git a/db/src/player.rs b/db/src/player.rs index 4c7af7268..f780d74ba 100644 --- a/db/src/player.rs +++ b/db/src/player.rs @@ -205,6 +205,7 @@ pub fn load_player(character_id: i32) -> crate::Result { player.gm_level = gm_level; player.pets = crate::pet::summoned(&mut conn, &player.inventory)?; player.guild = crate::guild::tag_of(&mut conn, character_id)?; + player.family = crate::family::relatives(&mut conn, character_id)?; player.monster_book = MonsterBook::new( character.monster_book_cover, monster_book_cards::table diff --git a/db/src/schema.rs b/db/src/schema.rs index 09ee521b1..0a2137180 100644 --- a/db/src/schema.rs +++ b/db/src/schema.rs @@ -518,6 +518,19 @@ diesel::table! { } diesel::joinable!(parcels -> characters (receiver_id)); diesel::joinable!(parcel_equipment_stats -> parcels (parcel_id)); +diesel::table! { + family_members (character_id) { + character_id -> Int4, + senior_id -> Nullable, + reputation -> Int4, + total_reputation -> Int4, + today_reputation -> Int4, + senior_reputation -> Int4, + #[max_length = 200] + precepts -> Varchar, + } +} +diesel::joinable!(family_members -> characters (character_id)); diesel::allow_tables_to_appear_in_same_query!( accounts, buddies, @@ -553,4 +566,5 @@ diesel::allow_tables_to_appear_in_same_query!( alliances, parcels, parcel_equipment_stats, + family_members, ); diff --git a/game/src/character.rs b/game/src/character.rs index 6ce514f5d..11b6e27bc 100644 --- a/game/src/character.rs +++ b/game/src/character.rs @@ -222,6 +222,9 @@ pub struct Player { /// The guild the character belongs to; saved by `db::guild`, not with /// the player. pub guild: Option, + /// The character's senior and juniors; saved by `db::family`, not with + /// the player. + pub family: crate::family::Relatives, /// The cash serials of the summoned pets, in the client's list order; /// saved with the pets. pub pets: Vec, @@ -303,6 +306,7 @@ impl Player { buffs: Buffs::default(), chair: 0, guild: None, + family: Default::default(), pets: Vec::new(), pets_named: Vec::new(), chalkboard: None, diff --git a/game/src/family.rs b/game/src/family.rs new file mode 100644 index 000000000..ea03902c1 --- /dev/null +++ b/game/src/family.rs @@ -0,0 +1,147 @@ +//! Families: a senior guides up to two juniors, who may have juniors of +//! their own. The senior at the top leads, and the family goes by their +//! name (report b039). + +/// Juniors a character may have. INFO and CHART tell the client, which +/// hides Add Junior at this many. +pub const MAX_JUNIORS: u16 = 2; +/// The lowest level a junior may be (pool 0x11fd: "must be over Level 10"). +pub const MIN_JUNIOR_LEVEL: u8 = 10; +/// How far below the senior a junior may be (0x11fc: "within 20 levels"). +pub const MAX_LEVEL_GAP: u8 = 20; +/// The most generations a family may span, top to bottom (0x1202). +pub const MAX_GENERATIONS: usize = 1000; + +/// Why a junior wasn't added: FAMILY_RESULT's code, which picks the +/// client's line. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Refusal { + /// 0x11f5: "You cannot add this character as a Junior." + CannotAdd, + /// 0x11f4: the name is wrong or they aren't online. + NotFound, + /// 0x11f6: "You belong to the same family." + SameFamily, + /// 0x11f9: they must be in the same map. + NotSameMap, + /// 0x11fa: they are already another's junior. + HasSenior, + /// 0x11fb: the junior must be of a lower level. + NotLower, + /// 0x11fc: more than 20 levels apart. + LevelGap, + /// 0x11fe: someone else is asking them already. + Busy, + /// 0x1202: the family would span more than 1000 generations. + Generations, + /// 0x11fd: the junior is below level 10. + TooLow, +} + +impl Refusal { + pub fn code(self) -> i32 { + match self { + Refusal::CannotAdd => 0x40, + Refusal::NotFound => 0x41, + Refusal::SameFamily => 0x42, + Refusal::NotSameMap => 0x45, + Refusal::HasSenior => 0x46, + Refusal::NotLower => 0x47, + Refusal::LevelGap => 0x48, + Refusal::Busy => 0x49, + Refusal::Generations => 0x4c, + Refusal::TooLow => 0x4d, + } + } +} + +/// Whether a senior of level `senior` may take a junior of level `junior`. +pub fn check_levels(senior: u8, junior: u8) -> Result<(), Refusal> { + if junior < MIN_JUNIOR_LEVEL { + Err(Refusal::TooLow) + } else if junior >= senior { + Err(Refusal::NotLower) + } else if senior - junior > MAX_LEVEL_GAP { + Err(Refusal::LevelGap) + } else { + Ok(()) + } +} + +/// A character's senior and juniors, by character id; both empty out of a +/// family. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Relatives { + pub senior: Option, + pub juniors: Vec, +} + +impl Relatives { + /// The senior, then the juniors. + pub fn ids(&self) -> impl Iterator + '_ { + self.senior.into_iter().chain(self.juniors.iter().copied()) + } +} + +/// What the Family window shows of the character's family (FAMILY_INFO). +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct FamilyInfo { + /// Rep to spend, and all ever earned. + pub reputation: i32, + pub total_reputation: i32, + /// Rep earned today. + pub today_reputation: i32, + pub juniors: u16, + /// The leader; 0 out of a family. + pub leader_id: i32, + /// The leader's name; empty out of a family. + pub name: String, + /// The leader's message to the family. + pub precepts: String, +} + +/// A member as the Family Tree draws them (FAMILY_CHART). +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct ChartMember { + pub id: i32, + /// 0 for the leader. + pub senior_id: i32, + pub job: i16, + pub level: u8, + pub online: bool, + pub reputation: i32, + pub total_reputation: i32, + /// Rep recorded to their senior. + pub senior_reputation: i32, + pub today_reputation: i32, + pub map_id: i32, + pub name: String, +} + +/// The Family Tree around `viewed`: the leader, the two seniors above them, +/// their sibling, juniors and juniors' juniors, parents before children. +/// Empty out of a family. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct FamilyChart { + pub viewed: i32, + pub members: Vec, + /// Head counts: -1 for the whole family, 0 for `viewed`'s seniors, and a + /// junior's id for their branch. + pub counts: Vec<(i32, i32)>, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn juniors_are_at_least_ten_and_within_twenty_levels_below() { + assert_eq!(check_levels(30, 9), Err(Refusal::TooLow)); + assert_eq!(check_levels(30, 10), Ok(())); + assert_eq!(check_levels(30, 29), Ok(())); + assert_eq!(check_levels(30, 30), Err(Refusal::NotLower)); + assert_eq!(check_levels(30, 31), Err(Refusal::NotLower)); + assert_eq!(check_levels(50, 30), Ok(())); + assert_eq!(check_levels(51, 30), Err(Refusal::LevelGap)); + } +} diff --git a/game/src/lib.rs b/game/src/lib.rs index b96f97cb6..6daeb3e5a 100644 --- a/game/src/lib.rs +++ b/game/src/lib.rs @@ -9,6 +9,7 @@ pub mod ap_reset; pub mod buff; pub mod character; pub mod exp; +pub mod family; pub mod guild; pub mod guild_bbs; pub mod inventory; diff --git a/integration-harness/src/trace.rs b/integration-harness/src/trace.rs index dcf106201..9f12dbab1 100644 --- a/integration-harness/src/trace.rs +++ b/integration-harness/src/trace.rs @@ -192,6 +192,9 @@ pub struct Trace { pub parcels: Vec>, /// Every RPS_GAME body (mode first), in order. pub rps: Vec>, + /// Every family packet (FAMILY_CHART_RESULT to + /// FAMILY_NOTIFY_LOGIN_OR_LOGOUT): (opcode, body), in order. + pub family: Vec<(i16, Vec)>, /// Every PLAYER_INTERACTION body (mode first), in order. pub rooms: Vec>, /// Every UPDATE_CHAR_BOX body (character id first), in order. @@ -566,6 +569,10 @@ impl Trace { self.parcels.push(p.bytes[2..].to_vec()); } else if op == SendOpcode::RpsGame as i16 { self.rps.push(p.bytes[2..].to_vec()); + } else if (SendOpcode::FamilyChart as i16..=SendOpcode::FamilyNotifyLoginOrLogout as i16) + .contains(&op) + { + self.family.push((op, p.bytes[2..].to_vec())); } else if op == SendOpcode::PlayerInteraction as i16 { self.rooms.push(p.bytes[2..].to_vec()); } else if op == SendOpcode::UpdateCharBox as i16 { diff --git a/integration-harness/tests/families.rs b/integration-harness/tests/families.rs new file mode 100644 index 000000000..26e539b92 --- /dev/null +++ b/integration-harness/tests/families.rs @@ -0,0 +1,213 @@ +//! Families (report b039): the client gets the Family window's numbers on +//! entering the game. A senior asks a character on their map to be their +//! junior (ADD_FAMILY), who gets one card however often they are asked, and +//! may say no or yes (ACCEPT_FAMILY). The server refuses a name not online, +//! another map, too low a level, a junior who has a senior, and a senior of +//! the same family. The Family Tree (OPEN_FAMILY_PEDIGREE) shows who is in +//! it and online, and relatives hear who logs in or out. + +use integration_harness::database::set_job; +use integration_harness::preconditions::load_harness_config_or_fail; +use integration_harness::trace::{read_for, send, until, warp, Trace}; +use integration_harness::{login_to_world_session, relogin, HarnessConfig, WorldSession}; +use net::packet::op::{RecvOpcode, SendOpcode}; +use packet::io::write::PktWrite; +use packet::Packet; +use tokio::time::Duration; + +const LITH_HARBOR: i32 = 104_000_000; +const HENESYS: i32 = 100_000_000; + +const NOT_FOUND: i32 = 0x41; +const SAME_FAMILY: i32 = 0x42; +const NOT_SAME_MAP: i32 = 0x45; +const HAS_SENIOR: i32 = 0x46; +const TOO_LOW: i32 = 0x4d; + +fn request(op: RecvOpcode, name: &str) -> Packet { + let mut p = Packet::new_empty(); + p.write_short(op as i16).unwrap(); + p.write_str_with_length(name).unwrap(); + p +} + +fn answer(inviter: i32, name: &str, accept: bool) -> Packet { + let mut p = Packet::new_empty(); + p.write_short(RecvOpcode::AcceptFamily as i16).unwrap(); + p.write_int(inviter).unwrap(); + p.write_str_with_length(name).unwrap(); + p.write_byte(u8::from(accept)).unwrap(); + p +} + +fn str_bytes(text: &str) -> Vec { + let mut bytes = (text.len() as u16).to_le_bytes().to_vec(); + bytes.extend(text.as_bytes()); + bytes +} + +/// FAMILY_INFO_RESULT with no rep yet. +fn info(juniors: u16, leader: i32, name: &str) -> (i16, Vec) { + let mut body = [0i32; 3].map(i32::to_le_bytes).concat(); + body.extend(juniors.to_le_bytes()); + body.extend(2u16.to_le_bytes()); + body.extend(0u16.to_le_bytes()); + body.extend(leader.to_le_bytes()); + body.extend(str_bytes(name)); + body.extend(str_bytes("")); + body.extend(0i32.to_le_bytes()); + (SendOpcode::FamilyInfo as i16, body) +} + +fn refusal(code: i32) -> (i16, Vec) { + let body = [code, 0].map(i32::to_le_bytes).concat(); + (SendOpcode::FamilyResult as i16, body) +} + +fn with_name(op: SendOpcode, head: &[u8], name: &str) -> (i16, Vec) { + let mut body = head.to_vec(); + body.extend(str_bytes(name)); + (op as i16, body) +} + +/// Make the character behind `config` at `level` and log it out; its id. +async fn new_character(config: &HarnessConfig, level: i16) -> i32 { + let session = login_to_world_session(config).await.expect("first login"); + let id = session.character_id; + drop(session); + tokio::time::sleep(Duration::from_millis(500)).await; + set_job(id, 0, level, 50).await.expect("level"); + id +} + +/// Log in and go to `map`; the family packets of entering the game. +async fn enter(config: &HarnessConfig, map: i32) -> (WorldSession, Trace) { + let mut session = relogin(config).await; + let mut t = Trace::default(); + until(&mut session.connection, &mut t, |t| t.family.len() >= 2).await; + assert_eq!(warp(&mut session.connection, map, "", &mut t).await, map); + (session, t) +} + +/// The next `count` family packets `session` gets. +async fn family(session: &mut WorldSession, count: usize) -> Vec<(i16, Vec)> { + let mut t = Trace::default(); + until(&mut session.connection, &mut t, |t| t.family.len() >= count).await; + read_for(&mut session.connection, &mut t, 300).await; + t.family +} + +#[tokio::test] +async fn a_senior_takes_a_junior_on_their_map_who_says_yes() { + let (a_config, b_config, c_config) = ( + load_harness_config_or_fail().await, + load_harness_config_or_fail().await, + load_harness_config_or_fail().await, + ); + let a_id = new_character(&a_config, 30).await; + let b_id = new_character(&b_config, 15).await; + new_character(&c_config, 1).await; + let (an, bn, cn) = ( + a_config.character_name.clone(), + b_config.character_name.clone(), + c_config.character_name.clone(), + ); + + // Out of a family: no leader and no name, and nothing else on login. + let mut a = relogin(&a_config).await; + let mut at = Trace::default(); + until(&mut a.connection, &mut at, |t| t.family.len() >= 2).await; + read_for(&mut a.connection, &mut at, 300).await; + assert_eq!( + at.family, + [ + info(0, 0, ""), + (SendOpcode::FamilyPrivilegeList as i16, vec![0; 4]) + ] + ); + assert_eq!( + warp(&mut a.connection, LITH_HARBOR, "", &mut at).await, + LITH_HARBOR + ); + let (mut b, _) = enter(&b_config, HENESYS).await; + let (c, _) = enter(&c_config, LITH_HARBOR).await; + + send( + &mut a.connection, + request(RecvOpcode::AddFamily, "nobody here"), + ) + .await; + assert_eq!(family(&mut a, 1).await, [refusal(NOT_FOUND)]); + send(&mut a.connection, request(RecvOpcode::AddFamily, &bn)).await; + assert_eq!(family(&mut a, 1).await, [refusal(NOT_SAME_MAP)]); + send(&mut a.connection, request(RecvOpcode::AddFamily, &cn)).await; + assert_eq!(family(&mut a, 1).await, [refusal(TOO_LOW)]); + drop(c); + + // B says no, then yes. + let mut bt = Trace::default(); + assert_eq!( + warp(&mut b.connection, LITH_HARBOR, "", &mut bt).await, + LITH_HARBOR + ); + let asked = with_name(SendOpcode::FamilyJoinRequest, &a_id.to_le_bytes(), &an); + // Asking again while the card is up shows no second card. + send(&mut a.connection, request(RecvOpcode::AddFamily, &bn)).await; + send(&mut a.connection, request(RecvOpcode::AddFamily, &bn)).await; + assert_eq!(family(&mut b, 1).await, [asked.clone()]); + send(&mut b.connection, answer(a_id, &an, false)).await; + let declined = with_name(SendOpcode::FamilyJoinRequestResult, &[0], &bn); + assert_eq!(family(&mut a, 1).await, [declined]); + + send(&mut a.connection, request(RecvOpcode::AddFamily, &bn)).await; + assert_eq!(family(&mut b, 1).await, [asked]); + send(&mut b.connection, answer(a_id, &an, true)).await; + let accepted = with_name(SendOpcode::FamilyJoinRequestResult, &[1], &bn); + assert_eq!(family(&mut a, 2).await, [accepted, info(1, a_id, &an)]); + let joined = with_name(SendOpcode::FamilyJoinAccepted, &[], &an); + assert_eq!(family(&mut b, 2).await, [joined, info(0, a_id, &an)]); + + // Neither may ask the other again. + send(&mut a.connection, request(RecvOpcode::AddFamily, &bn)).await; + assert_eq!(family(&mut a, 1).await, [refusal(HAS_SENIOR)]); + send(&mut b.connection, request(RecvOpcode::AddFamily, &an)).await; + assert_eq!(family(&mut b, 1).await, [refusal(SAME_FAMILY)]); + + // A's tree: A leads, B below, both online in Lith Harbor. + send( + &mut a.connection, + request(RecvOpcode::OpenFamilyPedigree, &an), + ) + .await; + let mut chart = a_id.to_le_bytes().to_vec(); + chart.extend(2i32.to_le_bytes()); + for (id, senior, level, name) in [(a_id, 0, 30u8, &an), (b_id, a_id, 15, &bn)] { + chart.extend(id.to_le_bytes()); + chart.extend(senior.to_le_bytes()); + chart.extend(0i16.to_le_bytes()); + chart.extend([level, 1]); + chart.extend([0i32; 4].map(i32::to_le_bytes).concat()); + chart.extend(LITH_HARBOR.to_le_bytes()); + chart.extend(0i32.to_le_bytes()); + chart.extend(str_bytes(name)); + } + chart.extend(3i32.to_le_bytes()); + for (key, count) in [(-1, 2), (0, 0), (b_id, 1)] { + chart.extend([key, count].map(i32::to_le_bytes).concat()); + } + chart.extend(0i32.to_le_bytes()); + chart.extend(2u16.to_le_bytes()); + assert_eq!( + family(&mut a, 1).await, + [(SendOpcode::FamilyChart as i16, chart)] + ); + + // A hears B leave and come back, who has their family on login. + drop(b); + let online = |online| with_name(SendOpcode::FamilyNotifyLoginOrLogout, &[online], &bn); + assert_eq!(family(&mut a, 1).await, [online(0)]); + let mut b = relogin(&b_config).await; + assert_eq!(family(&mut a, 1).await, [online(1)]); + let entered = family(&mut b, 2).await; + assert_eq!(entered[0], info(0, a_id, &an)); +} diff --git a/net/src/handler.rs b/net/src/handler.rs index 4b7227679..967edece7 100644 --- a/net/src/handler.rs +++ b/net/src/handler.rs @@ -290,6 +290,19 @@ pub enum AllianceRequest { Deny { inviter: String }, } +/// A family request (report b039), carried out by the client's actor. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum FamilyRequest { + /// The Family window opened (`OPEN_FAMILY`). + Open, + /// Show the Family Tree of the character named so. + Chart { name: String }, + /// Ask the character named so to be the player's junior. + Add { name: String }, + /// Answer `inviter`'s request to be their junior. + Answer { inviter: ClientId, accept: bool }, +} + /// A guild operation, carried out by the client's actor. #[derive(Clone, Debug, PartialEq, Eq)] pub enum GuildRequest { @@ -498,6 +511,7 @@ pub enum WorldAction { Note(NoteRequest), Guild(GuildRequest), Alliance(AllianceRequest), + Family(FamilyRequest), /// The player cast Mystic Door at `at` on their map, leading to `town`, /// for `lasts`. It replaces any door they had open. OpenDoor { @@ -829,6 +843,13 @@ fn world_routes() -> &'static HashMap { InGame(&world::DenyGuildRequestHandler), ), (Op::GuildBbs, InGame(&world::GuildBbsHandler)), + ( + Op::OpenFamilyPedigree, + InGame(&world::OpenFamilyPedigreeHandler), + ), + (Op::OpenFamily, InGame(&world::OpenFamilyHandler)), + (Op::AddFamily, InGame(&world::AddFamilyHandler)), + (Op::AcceptFamily, InGame(&world::AcceptFamilyHandler)), ( Op::AllianceOperation, InGame(&world::AllianceOperationHandler), @@ -960,7 +981,7 @@ mod tests { fn every_registered_opcode_is_unique_per_server() { // The maps would silently drop duplicates; check the table sizes match. assert_eq!(login_routes().len(), 13); - assert_eq!(world_routes().len(), 97); + assert_eq!(world_routes().len(), 102); } #[test] diff --git a/net/src/lib.rs b/net/src/lib.rs index 08c02f571..601806923 100644 --- a/net/src/lib.rs +++ b/net/src/lib.rs @@ -52,9 +52,10 @@ pub mod trade; pub use self::game_data::{closest_spawn_point, get as get_game_data, spawn_position}; pub use self::handler::{ login_route, world_route, AllianceRequest, BbsRequest, BroadcastScope, BuddyRequest, ClientId, - FieldRequest, GuildRequest, HandlerResult, LoginAction, LoginContext, LoginHandler, LoginStage, - MessengerRequest, NoteRequest, PartyRequest, PartySkill, PreGameHandler, SpawnPosition, - WhisperDelivery, WorldAction, WorldContext, WorldHandler, WorldRoute, + FamilyRequest, FieldRequest, GuildRequest, HandlerResult, LoginAction, LoginContext, + LoginHandler, LoginStage, MessengerRequest, NoteRequest, PartyRequest, PartySkill, + PreGameHandler, SpawnPosition, WhisperDelivery, WorldAction, WorldContext, WorldHandler, + WorldRoute, }; pub use self::io::error; pub use self::npc_data::extra_npcs; diff --git a/net/src/packet/build/world/family.rs b/net/src/packet/build/world/family.rs new file mode 100644 index 000000000..7d0de6d4b --- /dev/null +++ b/net/src/packet/build/world/family.rs @@ -0,0 +1,146 @@ +//! The family's packets (recv 94-104 in the client; report b039). + +use crate::{error::NetworkError, packet::op::SendOpcode}; +use game::family::{FamilyChart, FamilyInfo, Refusal, MAX_JUNIORS}; +use packet::{io::write::PktWrite, Packet}; + +fn op(op: SendOpcode) -> Result { + let mut packet = Packet::new_empty(); + packet.write_short(op as i16)?; + Ok(packet) +} + +/// FAMILY_CHART_RESULT: the Family Tree around `chart.viewed`. Out of a +/// family it is empty, and the client draws the player alone. +pub fn build_family_chart(chart: &FamilyChart) -> Result { + let mut packet = op(SendOpcode::FamilyChart)?; + packet.write_int(chart.viewed)?; + packet.write_int(chart.members.len() as i32)?; + for member in &chart.members { + packet.write_int(member.id)?; + packet.write_int(member.senior_id)?; + packet.write_short(member.job)?; + packet.write_byte(member.level)?; + packet.write_byte(u8::from(member.online))?; + packet.write_int(member.reputation)?; + packet.write_int(member.total_reputation)?; + packet.write_int(member.senior_reputation)?; + packet.write_int(member.today_reputation)?; + packet.write_int(member.map_id)?; + // Minutes since they logged in, not kept yet. + packet.write_int(0)?; + packet.write_str_with_length(&member.name)?; + } + packet.write_int(chart.counts.len() as i32)?; + for &(key, count) in &chart.counts { + packet.write_int(key)?; + packet.write_int(count)?; + } + // The entitlements used today, none yet. + packet.write_int(0)?; + packet.write_short(MAX_JUNIORS as i16)?; + Ok(packet) +} + +/// FAMILY_INFO_RESULT: the Family window's numbers, name and message. Until +/// the first, the client sends nothing but `OPEN_FAMILY` and +/// `OPEN_FAMILY_PEDIGREE`. +pub fn build_family_info(info: &FamilyInfo) -> Result { + let mut packet = op(SendOpcode::FamilyInfo)?; + packet.write_int(info.reputation)?; + packet.write_int(info.total_reputation)?; + packet.write_int(info.today_reputation)?; + packet.write_short(info.juniors as i16)?; + packet.write_short(MAX_JUNIORS as i16)?; + // Unknown; the client keeps it unread (b039 1.2). + packet.write_short(0)?; + packet.write_int(info.leader_id)?; + packet.write_str_with_length(&info.name)?; + packet.write_str_with_length(&info.precepts)?; + // The entitlements used today, none yet. + packet.write_int(0)?; + Ok(packet) +} + +/// FAMILY_PRIVILEGE_LIST: the entitlements, none yet. +pub fn build_privilege_list() -> Result { + let mut packet = op(SendOpcode::FamilyPrivilegeList)?; + packet.write_int(0)?; + Ok(packet) +} + +/// FAMILY_RESULT with a refusal's line. +pub fn build_family_refusal(refusal: Refusal) -> Result { + let mut packet = op(SendOpcode::FamilyResult)?; + packet.write_int(refusal.code())?; + packet.write_int(0)?; + Ok(packet) +} + +/// FAMILY_JOIN_REQUEST: `name` (`inviter`) asks the player to be their +/// junior. The client shows a card, or says no by itself. +pub fn build_join_request(inviter: i32, name: &str) -> Result { + let mut packet = op(SendOpcode::FamilyJoinRequest)?; + packet.write_int(inviter)?; + packet.write_str_with_length(name)?; + Ok(packet) +} + +/// FAMILY_JOIN_REQUEST_RESULT: `junior` said yes (a chat line) or no (a +/// notice). +pub fn build_join_result(accepted: bool, junior: &str) -> Result { + let mut packet = op(SendOpcode::FamilyJoinRequestResult)?; + packet.write_byte(u8::from(accepted))?; + packet.write_str_with_length(junior)?; + Ok(packet) +} + +/// FAMILY_JOIN_ACCEPTED: `senior` is now the player's senior (a chat line). +pub fn build_join_accepted(senior: &str) -> Result { + let mut packet = op(SendOpcode::FamilyJoinAccepted)?; + packet.write_str_with_length(senior)?; + Ok(packet) +} + +/// FAMILY_NOTIFY_LOGIN_OR_LOGOUT: the player's senior or junior `name` came +/// online (a card) or left. +pub fn build_relative_online(online: bool, name: &str) -> Result { + let mut packet = op(SendOpcode::FamilyNotifyLoginOrLogout)?; + packet.write_byte(u8::from(online))?; + packet.write_str_with_length(name)?; + Ok(packet) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn info_carries_the_numbers_then_the_names() { + let info = FamilyInfo { + reputation: 5, + total_reputation: 9, + today_reputation: 2, + juniors: 1, + leader_id: 3, + name: "Alice".into(), + precepts: "Hi".into(), + }; + let packet = build_family_info(&info).unwrap(); + let mut expected = vec![0x5F, 0]; + expected.extend([5, 0, 0, 0, 9, 0, 0, 0, 2, 0, 0, 0]); + expected.extend([1, 0, 2, 0, 0, 0, 3, 0, 0, 0]); + expected.extend([5, 0]); + expected.extend(b"Alice"); + expected.extend([2, 0]); + expected.extend(b"Hi"); + expected.extend([0, 0, 0, 0]); + assert_eq!(packet.bytes, expected); + } + + #[test] + fn a_refusal_is_its_code_and_nothing() { + let packet = build_family_refusal(Refusal::LevelGap).unwrap(); + assert_eq!(packet.bytes, [0x60, 0, 0x48, 0, 0, 0, 0, 0, 0, 0]); + } +} diff --git a/net/src/packet/build/world/keymap.rs b/net/src/packet/build/world/keymap.rs index e6859be53..ec84fc4a1 100644 --- a/net/src/packet/build/world/keymap.rs +++ b/net/src/packet/build/world/keymap.rs @@ -132,6 +132,7 @@ mod tests { (27, 4, 16), (34, 4, 18), (25, 4, 20), + (24, 4, 26), (38, 4, 21), (41, 4, 24), (39, 4, 28), diff --git a/net/src/packet/build/world/mod.rs b/net/src/packet/build/world/mod.rs index bbc3d965d..3313d682d 100644 --- a/net/src/packet/build/world/mod.rs +++ b/net/src/packet/build/world/mod.rs @@ -9,6 +9,7 @@ pub mod door; pub mod drop; pub mod effect; pub mod fame; +pub mod family; pub mod field; pub mod guild; pub mod guild_bbs; diff --git a/net/src/packet/handle/world/family.rs b/net/src/packet/handle/world/family.rs new file mode 100644 index 000000000..a724209fd --- /dev/null +++ b/net/src/packet/handle/world/family.rs @@ -0,0 +1,123 @@ +//! The family's requests (report b039, section 2). The client sends all but +//! the first two only once FAMILY_INFO has come. + +use crate::handler::{ + FamilyRequest, HandlerResult, WorldAction, WorldContext, WorldHandler, WorldResult, +}; +use packet::{io::read::PktRead, Packet}; +use std::io::Cursor; + +fn request(request: FamilyRequest) -> WorldResult { + Ok(HandlerResult::empty().with(WorldAction::Family(request))) +} + +/// `OPEN_FAMILY_PEDIGREE`: the Family Tree of the character named, from the +/// Family window (the player's own) or a character's menu. +pub struct OpenFamilyPedigreeHandler; + +impl WorldHandler for OpenFamilyPedigreeHandler { + fn handle(&self, packet: &mut Packet, _ctx: &mut WorldContext) -> WorldResult { + let mut reader = Cursor::new(&packet.bytes); + reader.read_short()?; + let name = reader.read_str_with_length()?; + request(FamilyRequest::Chart { name }) + } +} + +/// `OPEN_FAMILY`: the Family window opened, and wants its contents. +pub struct OpenFamilyHandler; + +impl WorldHandler for OpenFamilyHandler { + fn handle(&self, _packet: &mut Packet, _ctx: &mut WorldContext) -> WorldResult { + request(FamilyRequest::Open) + } +} + +/// `ADD_FAMILY`: Add Junior, with the name typed. +pub struct AddFamilyHandler; + +impl WorldHandler for AddFamilyHandler { + fn handle(&self, packet: &mut Packet, _ctx: &mut WorldContext) -> WorldResult { + let mut reader = Cursor::new(&packet.bytes); + reader.read_short()?; + let name = reader.read_str_with_length()?; + request(FamilyRequest::Add { name }) + } +} + +/// `ACCEPT_FAMILY`: the asker's id and name, and whether the player said +/// yes. The client says no by itself when it isn't showing the card. +pub struct AcceptFamilyHandler; + +impl WorldHandler for AcceptFamilyHandler { + fn handle(&self, packet: &mut Packet, _ctx: &mut WorldContext) -> WorldResult { + let mut reader = Cursor::new(&packet.bytes); + reader.read_short()?; + let inviter = reader.read_int()?; + let _name = reader.read_str_with_length()?; + let accept = reader.read_byte()? != 0; + request(FamilyRequest::Answer { inviter, accept }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::packet::op::RecvOpcode; + use packet::io::write::PktWrite; + + fn handle(handler: &dyn WorldHandler, packet: Packet) -> FamilyRequest { + let mut packet = Packet::new(&packet.bytes); + let mut player = crate::codec::stats::sample_player(); + let mut ctx = WorldContext { + client_id: 1, + channel_id: 0, + player: &mut player, + }; + match handler.handle(&mut packet, &mut ctx).unwrap().actions.pop() { + Some(WorldAction::Family(request)) => request, + other => panic!("unexpected {other:?}"), + } + } + + fn packet(op: RecvOpcode) -> Packet { + let mut packet = Packet::new_empty(); + packet.write_short(op as i16).unwrap(); + packet + } + + #[test] + fn reads_each_request() { + let mut chart = packet(RecvOpcode::OpenFamilyPedigree); + chart.write_str_with_length("Alice").unwrap(); + assert_eq!( + handle(&OpenFamilyPedigreeHandler, chart), + FamilyRequest::Chart { + name: "Alice".into() + } + ); + assert_eq!( + handle(&OpenFamilyHandler, packet(RecvOpcode::OpenFamily)), + FamilyRequest::Open + ); + let mut add = packet(RecvOpcode::AddFamily); + add.write_str_with_length("Bobby").unwrap(); + assert_eq!( + handle(&AddFamilyHandler, add), + FamilyRequest::Add { + name: "Bobby".into() + } + ); + let mut accept = packet(RecvOpcode::AcceptFamily); + accept.write_int(7).unwrap(); + accept.write_str_with_length("Carol").unwrap(); + accept.write_byte(1).unwrap(); + assert_eq!( + handle(&AcceptFamilyHandler, accept), + FamilyRequest::Answer { + inviter: 7, + accept: true + } + ); + } +} diff --git a/net/src/packet/handle/world/mod.rs b/net/src/packet/handle/world/mod.rs index 1acaab660..352048943 100644 --- a/net/src/packet/handle/world/mod.rs +++ b/net/src/packet/handle/world/mod.rs @@ -18,6 +18,7 @@ mod door; mod duey; mod emote; mod fame; +mod family; mod guild; mod guild_bbs; mod hammer; @@ -91,6 +92,9 @@ pub use self::door::UseDoorHandler; pub use self::duey::DueyActionHandler; pub use self::emote::FaceExpressionHandler; pub use self::fame::{GiveFameHandler, MIN_FAME_LEVEL}; +pub use self::family::{ + AcceptFamilyHandler, AddFamilyHandler, OpenFamilyHandler, OpenFamilyPedigreeHandler, +}; pub use self::guild::{DenyGuildRequestHandler, GuildOperationHandler}; pub use self::guild_bbs::GuildBbsHandler; pub use self::hammer::UseHammerHandler; diff --git a/net/src/packet/op/recv.rs b/net/src/packet/op/recv.rs index a8b375bff..e6fb7ee3b 100644 --- a/net/src/packet/op/recv.rs +++ b/net/src/packet/op/recv.rs @@ -57,6 +57,14 @@ pub enum RecvOpcode { AllianceOperation = 0x8F, /// An alliance invitation declined, by the card or the client itself. DenyAllianceRequest = 0x90, + /// A character's Family Tree (report b039). + OpenFamilyPedigree = 0x91, + /// The Family window opened. + OpenFamily = 0x92, + /// Add Junior, by name. + AddFamily = 0x93, + /// An answer to a junior request (the card, or the client itself). + AcceptFamily = 0x96, /// The guild bulletin board (FUN_008166f6 and its siblings). GuildBbs = 0x9B, FaceExpression = 0x33, diff --git a/net/src/packet/op/send.rs b/net/src/packet/op/send.rs index 348bedfac..b2b18ef92 100644 --- a/net/src/packet/op/send.rs +++ b/net/src/packet/op/send.rs @@ -52,8 +52,22 @@ pub enum SendOpcode { ClearAvatarMegaphone = 0x70, MonsterBookSetCard = 0x53, MonsterBookSetCover = 0x54, + /// The Family Tree (report b039). + FamilyChart = 0x5E, FamilyInfo = 0x5F, - FamilyList = 0x64, + /// A refusal or an end, by code. + FamilyResult = 0x60, + /// Someone asks the player to be their junior. + FamilyJoinRequest = 0x61, + /// The junior asked answered. + FamilyJoinRequestResult = 0x62, + /// The player has a new senior. + FamilyJoinAccepted = 0x63, + /// The entitlements and their prices. + FamilyPrivilegeList = 0x64, + FamilyRepGain = 0x65, + /// A senior or junior came online or left. + FamilyNotifyLoginOrLogout = 0x66, MacroSysDataInit = 0x7C, SetField = 0x7D, SetCashShop = 0x7F, diff --git a/runtime/src/actor/client.rs b/runtime/src/actor/client.rs index 26c584c84..ed1d8f701 100644 --- a/runtime/src/actor/client.rs +++ b/runtime/src/actor/client.rs @@ -8,6 +8,7 @@ mod duey; mod events; mod expiry; mod fame; +mod family; mod guild_bbs; mod guilds; mod hazard; @@ -510,6 +511,7 @@ impl ClientActor { WorldAction::Buddy(request) => self.buddy_request(request).await?, WorldAction::Note(request) => self.note_request(request).await?, WorldAction::Guild(request) => self.guild_request(request).await?, + WorldAction::Family(request) => self.family_request(request).await?, WorldAction::Alliance(request) => self.alliance_request(request).await?, WorldAction::GiveFame { target, up } => self.give_fame(target, up).await?, WorldAction::OpenDoor { town, at, lasts } => self.open_door(town, at, lasts).await?, @@ -680,6 +682,7 @@ impl ClientActor { self.show_mount().await?; self.load_buddies().await?; self.load_guild().await?; + self.load_family().await?; self.load_notes().await?; self.announce_parcels().await?; info!( @@ -1220,6 +1223,8 @@ impl ClientActor { ServerMessage::StageCleared { id, stage } => self.stage_cleared(id, stage).await?, ServerMessage::JoinGuild { guild_id } => self.join_guild(guild_id).await?, ServerMessage::GuildChanged => self.guild_changed().await?, + ServerMessage::AddJunior { junior, name } => self.add_junior(junior, name).await?, + ServerMessage::FamilyChanged => self.family_changed().await?, ServerMessage::JoinAlliance { alliance_id } => self.join_alliance(alliance_id).await?, ServerMessage::PartySkill { used, diff --git a/runtime/src/actor/client/family.rs b/runtime/src/actor/client/family.rs new file mode 100644 index 000000000..848004a09 --- /dev/null +++ b/runtime/src/actor/client/family.rs @@ -0,0 +1,122 @@ +//! Families: the Family window, the Family Tree, and taking juniors +//! (report b039). + +use super::ClientActor; +use crate::{db::spawn_db, error::RuntimeError, message::DirectoryMessage}; +use game::family::Refusal; +use net::packet::build::world::family::{ + build_family_info, build_family_refusal, build_join_accepted, build_join_result, + build_privilege_list, +}; +use net::{ClientId, FamilyRequest}; +use tracing::warn; + +impl ClientActor { + /// On entering the game, and when the Family window opens: its numbers + /// and the entitlements. The client asks for nothing else until then. + /// Relatives are read again too: a change made while the player was + /// entering the game reached no one. + pub(super) async fn load_family(&mut self) -> Result<(), RuntimeError> { + self.family_changed().await?; + self.send(&mut build_privilege_list()?).await + } + + async fn send_family_info(&mut self) -> Result<(), RuntimeError> { + let cid = self.client_id; + let info = spawn_db(move || db::family::info(cid)).await?; + self.send(&mut build_family_info(&info)?).await + } + + pub(super) async fn family_request( + &mut self, + request: FamilyRequest, + ) -> Result<(), RuntimeError> { + match request { + FamilyRequest::Open => self.load_family().await, + FamilyRequest::Chart { name } => { + let chart = spawn_db(move || { + db::family::character(&name)? + .map(db::family::chart) + .transpose() + }) + .await?; + let Some(chart) = chart else { + return Ok(()); + }; + self.send_to_directory(DirectoryMessage::FamilyChart { + owner: self.client_id, + chart, + }) + .await + } + FamilyRequest::Add { name } => self.ask_junior(name).await, + FamilyRequest::Answer { inviter, accept } => { + self.send_to_directory(DirectoryMessage::FamilyAnswer { + from: self.client_id, + inviter, + accept, + }) + .await + } + } + } + + /// Ask the character named `name` to be the player's junior. The + /// directory checks the map and levels. + async fn ask_junior(&mut self, name: String) -> Result<(), RuntimeError> { + let cid = self.client_id; + let asked = spawn_db(move || { + let Some(junior) = db::family::character(&name)? else { + return Ok(Err(Refusal::NotFound)); + }; + Ok(db::family::may_add(cid, junior)?.map(|()| junior)) + }) + .await?; + match asked { + Ok(junior) => { + self.send_to_directory(DirectoryMessage::FamilyInvite { from: cid, junior }) + .await + } + Err(refusal) => self.send(&mut build_family_refusal(refusal)?).await, + } + } + + /// `junior` (named `name`) said yes. The rules are checked again, since + /// either may have joined another family meanwhile. + pub(super) async fn add_junior( + &mut self, + junior: ClientId, + name: String, + ) -> Result<(), RuntimeError> { + let cid = self.client_id; + let ids = match spawn_db(move || db::family::add_junior(cid, junior)).await { + Ok(Ok(ids)) => ids, + Ok(Err(refusal)) => return self.send(&mut build_family_refusal(refusal)?).await, + // The junior's character may be gone meanwhile. + Err(error) => { + warn!(cid, junior, %error, "Failed to add a junior"); + return self + .send(&mut build_family_refusal(Refusal::CannotAdd)?) + .await; + } + }; + self.send(&mut build_join_result(true, &name)?).await?; + let senior = self.in_game()?.player.stats.name.clone(); + self.send_to_directory(DirectoryMessage::Deliver { + to: vec![junior], + packet: build_join_accepted(&senior)?, + }) + .await?; + self.send_to_directory(DirectoryMessage::FamilyChanged { ids }) + .await + } + + /// The family changed: who hears of logins, and the Family window. An + /// open Family Tree asks again on the window's numbers. + pub(super) async fn family_changed(&mut self) -> Result<(), RuntimeError> { + let cid = self.client_id; + let relatives = spawn_db(move || db::family::relatives_of(cid)).await?; + self.in_game_mut()?.player.family = relatives; + self.send_family_info().await + } +} diff --git a/runtime/src/actor/directory.rs b/runtime/src/actor/directory.rs index be4e0b8a8..02f2a62a9 100644 --- a/runtime/src/actor/directory.rs +++ b/runtime/src/actor/directory.rs @@ -1,3 +1,5 @@ +mod family; + use crate::actor::avatar_banners::{AvatarBanners, Queued}; use crate::actor::event::EventStart; use crate::actor::guild_invites::GuildInvites; @@ -120,6 +122,11 @@ pub struct WorldDirectory { /// Guild members changing channel: their guild and alliance hear they /// logged out only if they don't arrive in time. guild_away: HashMap, + /// Requests to be someone's junior, by the senior asking. + family_invites: GuildInvites, + /// Family members changing channel, who are said to log out only if + /// they don't arrive in time. + family_away: HashMap, /// The event each channel is playing of each kind, by id. events: HashMap<(u8, EventKind), u32>, next_event: u32, @@ -141,6 +148,8 @@ impl WorldDirectory { messengers: Messengers::default(), doors: HashMap::new(), guild_away: HashMap::new(), + family_invites: GuildInvites::default(), + family_away: HashMap::new(), events: HashMap::new(), next_event: 1, avatar_banners: AvatarBanners::default(), @@ -161,6 +170,7 @@ impl WorldDirectory { let out = self.messengers.purge(now); self.deliver(out); self.purge_guild_away(now); + self.purge_family_away(now); self.run_party_searches(now); } _ = at(self.avatar_banners.until()) => { @@ -294,6 +304,20 @@ impl WorldDirectory { DirectoryMessage::GuildChanged { client_id } => { self.tell(client_id, ServerMessage::GuildChanged) } + DirectoryMessage::FamilyInvite { from, junior } => { + self.family_invite(from, junior, Instant::now()) + } + DirectoryMessage::FamilyAnswer { + from, + inviter, + accept, + } => self.family_answer(from, inviter, accept, Instant::now()), + DirectoryMessage::FamilyChart { owner, chart } => self.send_family_chart(owner, chart), + DirectoryMessage::FamilyChanged { ids } => { + for id in ids { + self.tell(id, ServerMessage::FamilyChanged); + } + } DirectoryMessage::GuildDisbanded { guild_id } => { self.guild_invites.forget_guild(guild_id) } @@ -580,6 +604,10 @@ impl WorldDirectory { } } } + let back = self.family_away.remove(&client_id).is_some(); + if previous.is_none() && !back { + self.tell_relatives(client_id, true); + } } fn unregister(&mut self, client_id: ClientId, connection_id: u64, changing_channel: bool) { @@ -605,6 +633,7 @@ impl WorldDirectory { self.tell_offline(client_id, away); } } + self.family_left(&entry, changing_channel); let out = self .messengers .disconnected(client_id, changing_channel, Instant::now()); @@ -617,6 +646,9 @@ impl WorldDirectory { self.party_searches.stop(client_id); self.guild_invites.forget(client_id); self.alliance_invites.forget(client_id); + self.family_invites.forget(client_id); + // Requests the player made go with them (keyed by the senior). + self.family_invites.forget_guild(client_id); if let Some(party) = self.parties.of(client_id) { self.send_party_update(party); } @@ -1556,6 +1588,7 @@ mod tests { max_hp: 50, guild_id: None, alliance_id: None, + family: Default::default(), } } diff --git a/runtime/src/actor/directory/family.rs b/runtime/src/actor/directory/family.rs new file mode 100644 index 000000000..3b44dd7fa --- /dev/null +++ b/runtime/src/actor/directory/family.rs @@ -0,0 +1,150 @@ +//! The directory's part in families: asking someone to be a junior, and +//! telling relatives of logins (report b039 section 5). + +use super::{Entry, WorldDirectory}; +use crate::actor::messenger::CHANNEL_CHANGE_GRACE; +use crate::message::ServerMessage; +use game::family::{check_levels, FamilyChart, Refusal, Relatives}; +use net::packet::build::world::family::{ + build_family_chart, build_family_refusal, build_join_request, build_join_result, + build_relative_online, +}; +use net::ClientId; +use std::time::Instant; + +/// A family member changing channel, and when they left. +pub(super) struct Away { + name: String, + relatives: Relatives, + left: Instant, +} + +impl WorldDirectory { + /// `from` asks `junior` to be their junior. The database's checks have + /// passed; these need both online. + pub(super) fn family_invite(&mut self, from: ClientId, junior: ClientId, now: Instant) { + if let Some(refusal) = self.junior_refusal(from, junior) { + self.send_packet(from, build_family_refusal(refusal)); + return; + } + // The junior's client would decline a second card for the first, + // so the same senior asking again while it is up is ignored. + if self.family_invites.is_pending(junior, from, now) { + return; + } + let Some(name) = self.clients.get(&from).map(|e| e.presence.name.clone()) else { + return; + }; + // One senior at a time may ask. + if !self.family_invites.invite(from, from, junior, now) { + self.send_packet(from, build_family_refusal(Refusal::Busy)); + return; + } + self.send_packet(junior, build_join_request(from, &name)); + } + + /// Why `junior` can't be `senior`'s now: not online, on another map, + /// or of the wrong level. + fn junior_refusal(&self, senior: ClientId, junior: ClientId) -> Option { + let senior = self.clients.get(&senior)?; + match self.clients.get(&junior) { + None => Some(Refusal::NotFound), + Some(entry) if !entry.shares_map_with(senior) => Some(Refusal::NotSameMap), + Some(entry) => check_levels(senior.presence.level, entry.presence.level).err(), + } + } + + /// `from` answers `inviter`'s request. A yes goes to the senior's actor, + /// which records it; a no is passed on. + pub(super) fn family_answer( + &mut self, + from: ClientId, + inviter: ClientId, + accept: bool, + now: Instant, + ) { + let Some(name) = self + .clients + .get(&from) + .map(|entry| entry.presence.name.clone()) + else { + return; + }; + if accept { + if !self.family_invites.accept(from, inviter, now) { + return; + } + // Either may have moved or levelled since the card came up. + match self.junior_refusal(inviter, from) { + None => self.tell(inviter, ServerMessage::AddJunior { junior: from, name }), + Some(refusal) => self.send_packet(inviter, build_family_refusal(refusal)), + } + } else if self.family_invites.decline(from, inviter) { + self.send_packet(inviter, build_join_result(false, &name)); + } + } + + /// The members online are shown so, at their level and map now. + pub(super) fn send_family_chart(&self, owner: ClientId, mut chart: FamilyChart) { + for member in &mut chart.members { + if let Some(entry) = self.clients.get(&member.id) { + member.online = true; + member.job = entry.presence.job; + member.level = entry.presence.level; + member.map_id = entry.presence.map_id; + } else if self.family_away.contains_key(&member.id) { + // Changing channel, as the guild list has it. + member.online = true; + } + } + self.send_packet(owner, build_family_chart(&chart)); + } + + /// Tells the senior and juniors online that `client_id` logged in or out. + pub(super) fn tell_relatives(&self, client_id: ClientId, online: bool) { + let Some(entry) = self.clients.get(&client_id) else { + return; + }; + self.tell_relatives_of(&entry.presence.name, &entry.presence.family, online); + } + + fn tell_relatives_of(&self, name: &str, relatives: &Relatives, online: bool) { + for id in relatives.ids() { + self.send_packet(id, build_relative_online(online, name)); + } + } + + /// A member left: changing channel, they are held for a while; + /// otherwise their relatives hear at once. + pub(super) fn family_left(&mut self, entry: &Entry, changing_channel: bool) { + let relatives = &entry.presence.family; + if relatives.senior.is_none() && relatives.juniors.is_empty() { + return; + } + if changing_channel { + let away = Away { + name: entry.presence.name.clone(), + relatives: relatives.clone(), + left: Instant::now(), + }; + self.family_away.insert(entry.handle.client_id, away); + } else { + self.tell_relatives_of(&entry.presence.name, relatives, false); + } + } + + /// Members who changed channel and didn't arrive in time left the game. + pub(super) fn purge_family_away(&mut self, now: Instant) { + let gone: Vec<_> = self + .family_away + .iter() + .filter(|(_, away)| now.duration_since(away.left) >= CHANNEL_CHANGE_GRACE) + .map(|(&id, _)| id) + .collect(); + for id in gone { + if let Some(away) = self.family_away.remove(&id) { + self.tell_relatives_of(&away.name, &away.relatives, false); + } + } + } +} diff --git a/runtime/src/actor/guild_invites.rs b/runtime/src/actor/guild_invites.rs index 2db6c621a..e9bf4dfa2 100644 --- a/runtime/src/actor/guild_invites.rs +++ b/runtime/src/actor/guild_invites.rs @@ -63,6 +63,13 @@ impl GuildInvites { } } + /// Whether `invitee` still has `guild_id`'s invitation in front of them. + pub fn is_pending(&self, invitee: ClientId, guild_id: i32, now: Instant) -> bool { + self.invites + .get(&invitee) + .is_some_and(|invite| invite.guild_id == guild_id && now < invite.sent + INVITE_TIMEOUT) + } + /// Whether `inviter`'s invitation to `invitee` is waiting. pub fn is_invited(&self, invitee: ClientId, inviter: ClientId) -> bool { self.invites diff --git a/runtime/src/message.rs b/runtime/src/message.rs index d893abe7e..72b403f7e 100644 --- a/runtime/src/message.rs +++ b/runtime/src/message.rs @@ -13,6 +13,7 @@ //! whose queue is full is kicked instead. That makes wait cycles impossible. use game::alliance::Alliance; +use game::family::Relatives; use game::{AvatarLook, FieldAvatar, Guild, Player}; use net::events::{Entry, EventKind}; use net::packet::build::world::door::DoorView; @@ -91,6 +92,13 @@ pub enum ServerMessage { /// Someone else changed the player's guild (expelled them); read it /// again. GuildChanged, + /// `junior` (named so) said yes to the player's request; add them. + AddJunior { + junior: ClientId, + name: String, + }, + /// The player's family changed; read it again. + FamilyChanged, /// The invitation the player's guild had to `alliance_id` stands; join /// it. JoinAlliance { @@ -414,6 +422,8 @@ pub struct Presence { pub guild_id: Option, /// The guild's alliance, for its members' logins, levels and jobs. pub alliance_id: Option, + /// The senior and juniors, who hear of logins. + pub family: Relatives, } impl Presence { @@ -432,6 +442,7 @@ impl Presence { .as_ref() .map(|guild| guild.alliance_id) .filter(|&id| id != 0), + family: player.family.clone(), } } } @@ -584,6 +595,27 @@ pub enum DirectoryMessage { GuildChanged { client_id: ClientId, }, + /// Ask `junior`, if they are on `from`'s map and of the levels, to be + /// `from`'s junior. + FamilyInvite { + from: ClientId, + junior: ClientId, + }, + /// `from` answered `inviter`'s junior request. + FamilyAnswer { + from: ClientId, + inviter: ClientId, + accept: bool, + }, + /// These characters' families changed; those online read theirs again. + FamilyChanged { + ids: Vec, + }, + /// Send `owner` the Family Tree with who is online, where. + FamilyChart { + owner: ClientId, + chart: game::family::FamilyChart, + }, /// The guild is gone; its invitations no longer stand. GuildDisbanded { guild_id: i32,