From 9274049e4ccbfd669ab85c0706ce608788875ac7 Mon Sep 17 00:00:00 2001 From: David Goldstein Date: Thu, 22 Oct 2026 01:11:17 -0400 Subject: [PATCH 1/2] Family server: family_members table, the Family window's INFO and privilege list on entering the game and OPEN_FAMILY, ADD_FAMILY/ACCEPT_FAMILY with b039's refusals (not found, other map, levels, has a senior, same family, busy, generations), the Family Tree (FAMILY_CHART_RESULT) with who is online, and relatives' login notices held through a channel change; fixes the net route-count and default-keymap unit tests --- AGENTS.md | 1 + agent_docs/families.md | 95 +++++++ .../2026-11-08-000000_families/down.sql | 1 + .../2026-11-08-000000_families/up.sql | 17 ++ db/src/family.rs | 264 ++++++++++++++++++ db/src/lib.rs | 1 + db/src/player.rs | 1 + db/src/schema.rs | 14 + game/src/character.rs | 4 + game/src/family.rs | 147 ++++++++++ game/src/lib.rs | 1 + integration-harness/src/trace.rs | 7 + integration-harness/tests/families.rs | 211 ++++++++++++++ net/src/handler.rs | 23 +- net/src/lib.rs | 7 +- net/src/packet/build/world/family.rs | 146 ++++++++++ net/src/packet/build/world/keymap.rs | 1 + net/src/packet/build/world/mod.rs | 1 + net/src/packet/handle/world/family.rs | 123 ++++++++ net/src/packet/handle/world/mod.rs | 4 + net/src/packet/op/recv.rs | 8 + net/src/packet/op/send.rs | 16 +- runtime/src/actor/client.rs | 5 + runtime/src/actor/client/family.rs | 113 ++++++++ runtime/src/actor/directory.rs | 31 ++ runtime/src/actor/directory/family.rs | 132 +++++++++ runtime/src/message.rs | 32 +++ 27 files changed, 1401 insertions(+), 5 deletions(-) create mode 100644 agent_docs/families.md create mode 100644 db/migrations/2026-11-08-000000_families/down.sql create mode 100644 db/migrations/2026-11-08-000000_families/up.sql create mode 100644 db/src/family.rs create mode 100644 game/src/family.rs create mode 100644 integration-harness/tests/families.rs create mode 100644 net/src/packet/build/world/family.rs create mode 100644 net/src/packet/handle/world/family.rs create mode 100644 runtime/src/actor/client/family.rs create mode 100644 runtime/src/actor/directory/family.rs 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..bed17d25a --- /dev/null +++ b/agent_docs/families.md @@ -0,0 +1,95 @@ +# 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 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". + 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 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. + - The directory marks who is online 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..f66919206 --- /dev/null +++ b/db/src/family.rs @@ -0,0 +1,264 @@ +//! 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(); + + 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..ca0cdb7f1 --- /dev/null +++ b/integration-harness/tests/families.rs @@ -0,0 +1,211 @@ +//! 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 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); + 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..df2a88711 --- /dev/null +++ b/runtime/src/actor/client/family.rs @@ -0,0 +1,113 @@ +//! 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}; + +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. + pub(super) async fn load_family(&mut self) -> Result<(), RuntimeError> { + self.send_family_info().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 added = spawn_db(move || db::family::add_junior(cid, junior)).await?; + let ids = match added { + Ok(ids) => ids, + Err(refusal) => return self.send(&mut build_family_refusal(refusal)?).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..82e11d05e 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,7 @@ 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); if let Some(party) = self.parties.of(client_id) { self.send_party_update(party); } @@ -1556,6 +1586,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..01e12008c --- /dev/null +++ b/runtime/src/actor/directory/family.rs @@ -0,0 +1,132 @@ +//! 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) { + let Some(senior) = self.clients.get(&from) else { + return; + }; + let refusal = 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(), + }; + if let Some(refusal) = refusal { + self.send_packet(from, build_family_refusal(refusal)); + return; + } + let name = senior.presence.name.clone(); + // One senior at a time may ask; the same one may ask again. + 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)); + } + + /// `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) { + self.tell(inviter, ServerMessage::AddJunior { junior: from, name }); + } + } 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; + } + } + 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/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, From 5a9dcd7fe969eb7275fa0a3af961ee2f14cc68c7 Mon Sep 17 00:00:00 2001 From: David Goldstein Date: Thu, 22 Oct 2026 01:20:45 -0400 Subject: [PATCH 2/2] Family review fixes (b043): a second ask while the card is up is ignored, a senior's requests end with them, a yes is checked again for the map and levels, relatives are read again on entering the game, a failed add refuses instead of disconnecting, and the tree hangs a far senior from the leader and counts a channel change as online --- agent_docs/families.md | 18 ++++++++--- db/src/family.rs | 8 +++++ integration-harness/tests/families.rs | 12 +++++--- runtime/src/actor/client/family.rs | 19 +++++++++--- runtime/src/actor/directory.rs | 2 ++ runtime/src/actor/directory/family.rs | 44 +++++++++++++++++++-------- runtime/src/actor/guild_invites.rs | 7 +++++ 7 files changed, 82 insertions(+), 28 deletions(-) diff --git a/agent_docs/families.md b/agent_docs/families.md index bed17d25a..5b8ec75ba 100644 --- a/agent_docs/families.md +++ b/agent_docs/families.md @@ -27,7 +27,9 @@ who has ever been in a family: client's line. `Player.family` (`Relatives`) holds the senior and juniors. It is loaded -with the character and copied into the directory's `Presence`. +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 @@ -47,11 +49,16 @@ packets into `FamilyRequest`s, which the client actor 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". + 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 goes to the senior's actor (`ServerMessage::AddJunior`), which + - 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; @@ -64,8 +71,9 @@ packets into `FamilyRequest`s, which the client actor - 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. - - The directory marks who is online and fills in their current level, - job and map. + - 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; diff --git a/db/src/family.rs b/db/src/family.rs index f66919206..7bacf6771 100644 --- a/db/src/family.rs +++ b/db/src/family.rs @@ -192,6 +192,14 @@ pub fn chart(viewed: i32) -> crate::Result { 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)]; diff --git a/integration-harness/tests/families.rs b/integration-harness/tests/families.rs index ca0cdb7f1..26e539b92 100644 --- a/integration-harness/tests/families.rs +++ b/integration-harness/tests/families.rs @@ -1,10 +1,10 @@ //! 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 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. +//! 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; @@ -151,6 +151,8 @@ async fn a_senior_takes_a_junior_on_their_map_who_says_yes() { 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; diff --git a/runtime/src/actor/client/family.rs b/runtime/src/actor/client/family.rs index df2a88711..848004a09 100644 --- a/runtime/src/actor/client/family.rs +++ b/runtime/src/actor/client/family.rs @@ -9,12 +9,15 @@ use net::packet::build::world::family::{ 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.send_family_info().await?; + self.family_changed().await?; self.send(&mut build_privilege_list()?).await } @@ -86,10 +89,16 @@ impl ClientActor { name: String, ) -> Result<(), RuntimeError> { let cid = self.client_id; - let added = spawn_db(move || db::family::add_junior(cid, junior)).await?; - let ids = match added { - Ok(ids) => ids, - Err(refusal) => return self.send(&mut build_family_refusal(refusal)?).await, + 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(); diff --git a/runtime/src/actor/directory.rs b/runtime/src/actor/directory.rs index 82e11d05e..02f2a62a9 100644 --- a/runtime/src/actor/directory.rs +++ b/runtime/src/actor/directory.rs @@ -647,6 +647,8 @@ impl WorldDirectory { 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); } diff --git a/runtime/src/actor/directory/family.rs b/runtime/src/actor/directory/family.rs index 01e12008c..3b44dd7fa 100644 --- a/runtime/src/actor/directory/family.rs +++ b/runtime/src/actor/directory/family.rs @@ -23,20 +23,19 @@ 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) { - let Some(senior) = self.clients.get(&from) else { - return; - }; - let refusal = 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(), - }; - if let Some(refusal) = refusal { + if let Some(refusal) = self.junior_refusal(from, junior) { self.send_packet(from, build_family_refusal(refusal)); return; } - let name = senior.presence.name.clone(); - // One senior at a time may ask; the same one may ask again. + // 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; @@ -44,6 +43,17 @@ impl WorldDirectory { 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( @@ -61,8 +71,13 @@ impl WorldDirectory { return; }; if accept { - if self.family_invites.accept(from, inviter, now) { - self.tell(inviter, ServerMessage::AddJunior { junior: from, name }); + 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)); @@ -77,6 +92,9 @@ impl WorldDirectory { 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)); 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