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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions HeavenClient/Net/Handlers/GuildHandlers.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -564,9 +564,12 @@ namespace ms
int32_t level = recv.read_int();
std::string name = recv.read_string();

// 0x1245; type 0 also tells a family junior of a lower level that
// their reps are halved (0x1246), which needs families.
// 0x1245; type 0 also tells a senior the junior has passed that
// the junior's Rep is halved (0x1246, 0xa1396f).
chat_line("<" + std::string(type == 2 ? "Guild" : "Family") + "> " + name + " has reached Lv. " + std::to_string(level) + ".");

if (type == 0 && level > Stage::get().get_player().get_stats().get_stat(MapleStat::Id::LEVEL))
chat_line("- The Reps you have received from " + name + " will be reduced in half.");
}

void NotifyJobChangeHandler::handle(InPacket& recv) const
Expand Down
32 changes: 29 additions & 3 deletions agent_docs/families.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ 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; only separating takes it away.
to the senior. `rep_day` is the day the two daily counters belong to;
on another day they read as 0 and the next gain starts them again.
- `precepts` matters only on the leader's row. A junior who leaves keeps an
empty one until they gain juniors of their own.

Expand All @@ -27,6 +28,8 @@ who has ever been in a family:
- The precepts are at most 200 characters (`MAX_PRECEPTS`).
- Separating costs Rep and mesos (`separation_rep`, `separation_fee`; see
below).
- Juniors earn their seniors Rep (`REP_PER_KILL`, `REP_PER_BOSS`,
`REP_PER_LEVEL`, `rep_gain`; see below).
- `Refusal` maps each refusal to the `FAMILY_RESULT` code that picks the
client's line.

Expand Down Expand Up @@ -117,6 +120,26 @@ packets into `FamilyRequest`s, which the client actor
which the client shows with the name it remembered. Everyone in the old
family gets INFO again.

### Earning Rep

v83's own amounts aren't known, so these are Cosmic's (report b049): 4 Rep
for each kill a junior gets EXP from (a share counts; a kill worth no EXP
doesn't), 20 for a boss, and 200 for each level they gain. The player's actor (`family_earn`) asks the directory
(`DirectoryMessage::FamilyEarn`) for the senior's level if they are online,
and `ServerMessage::EarnRep` then runs `db::family::earn`. The senior and
the senior's own senior each get the gain, halved when the senior's level
is below the junior's. It is added to their current, total and today's
counters and to the Rep the junior has recorded to the senior. Each gets
`FAMILY_REP_GAIN` (the gain and the junior's name) and then INFO again.

### Level and job notices

When a member goes up a level, the senior gets `NOTIFY_LEVELUP` for each
level: type 1, or type 0 when the new level is above the senior's, which
the client adds the halving line to. When a member changes job, the senior
and the juniors get `NOTIFY_JOB_CHANGE` type 1 with the job and "> " and the
name (`tell_family_progress`, next to the guild's).

### Login notices

Relatives (the senior and the juniors) get `FAMILY_NOTIFY_LOGIN_OR_LOGOUT`
Expand All @@ -127,8 +150,7 @@ when a member enters or leaves the game. A channel change is held for

These are not implemented yet:

- entitlements (`USE_FAMILY`) and the summon;
- earning rep.
- entitlements (`USE_FAMILY`) and the summon.

## Client

Expand Down Expand Up @@ -174,3 +196,7 @@ b045 (how they open).
Yes/No before answering.
- **Login notices:** a relative logging in shows a card, and the open tree
updates who is online.
- **Level notices:** `NOTIFY_LEVELUP` type 0 is a family member who has
passed the player's level. After the usual line the client adds "- The
Reps you have received from %s will be reduced in half."
(`GuildHandlers.cpp`).
1 change: 1 addition & 0 deletions db/migrations/2026-11-09-000000_family_rep_day/down.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE family_members DROP COLUMN rep_day;
3 changes: 3 additions & 0 deletions db/migrations/2026-11-09-000000_family_rep_day/up.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
-- The day a row's Rep counters for today (earned, and given to the senior)
-- were last written. They count from 0 again on the next day.
ALTER TABLE family_members ADD COLUMN rep_day DATE NOT NULL DEFAULT CURRENT_DATE;
76 changes: 71 additions & 5 deletions db/src/family.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,24 @@
//! which goes by their name, and keeps its message.

use crate::schema::{characters, family_members};
use diesel::dsl::sql;
use diesel::expression::SqlLiteral;
use diesel::prelude::*;
use diesel::sql_types::Integer;
use game::family::{
separation_fee, separation_rep, ChartMember, FamilyChart, FamilyInfo, Refusal, Relatives,
MAX_GENERATIONS, MAX_JUNIORS, MAX_PRECEPTS,
rep_gain, separation_fee, separation_rep, ChartMember, FamilyChart, FamilyInfo, Refusal,
Relatives, MAX_GENERATIONS, MAX_JUNIORS, MAX_PRECEPTS,
};

diesel::define_sql_function!(fn lower(x: diesel::sql_types::Text) -> diesel::sql_types::Text);

/// A counter for today: as written if `rep_day` is today, else 0.
fn today(column: &str) -> SqlLiteral<Integer> {
sql(&format!(
"CASE WHEN family_members.rep_day = CURRENT_DATE THEN family_members.{column} ELSE 0 END"
))
}

/// The id of the character named `name`, ignoring case.
pub fn character(name: &str) -> crate::Result<Option<i32>> {
Ok(characters::table
Expand Down Expand Up @@ -102,7 +112,7 @@ pub fn info(character_id: i32) -> crate::Result<FamilyInfo> {
.select((
family_members::reputation,
family_members::total_reputation,
family_members::today_reputation,
today("today_reputation"),
))
.first::<(i32, i32, i32)>(&mut conn)
.optional()?;
Expand Down Expand Up @@ -171,8 +181,8 @@ pub fn chart(viewed: i32) -> crate::Result<FamilyChart> {
characters::level,
family_members::reputation,
family_members::total_reputation,
family_members::senior_reputation,
family_members::today_reputation,
today("senior_reputation"),
today("today_reputation"),
characters::map_id,
characters::name,
))
Expand Down Expand Up @@ -284,6 +294,62 @@ pub fn set_precepts(leader: i32, precepts: &str) -> crate::Result<Option<Vec<i32
})?)
}

/// `junior` (level `junior_level`) earned their senior and the senior's
/// senior `gain` Rep, halved if the senior (level `senior_level`, else as
/// saved) is below them. Each gets it to spend, in their total and in
/// today's; the junior records the senior's share. Returns who got how
/// much.
pub fn earn(
junior: i32,
gain: i32,
junior_level: u8,
senior_level: Option<u8>,
) -> crate::Result<Vec<(i32, i32)>> {
let mut conn = crate::connection()?;
Ok(conn.transaction(|conn| -> QueryResult<_> {
let Some(senior) = senior_of(conn, junior)? else {
return Ok(Vec::new());
};
let senior_level = match senior_level {
Some(level) => level,
None => characters::table
.find(senior)
.select(characters::level)
.first::<i16>(conn)?
.clamp(0, 255) as u8,
};
let gain = rep_gain(gain, senior_level, junior_level);
if gain <= 0 {
return Ok(Vec::new());
}
let earners: Vec<i32> = std::iter::once(senior)
.chain(senior_of(conn, senior)?)
.collect();
// Rows in id order, as `separate` locks them.
let mut rows: Vec<i32> = earners.iter().copied().chain([junior]).collect();
rows.sort_unstable();
for id in rows {
let (earned, given) = if id == junior { (0, gain) } else { (gain, 0) };
diesel::sql_query(
"UPDATE family_members SET \
reputation = reputation + $1, \
total_reputation = total_reputation + $1, \
today_reputation = CASE WHEN rep_day = CURRENT_DATE \
THEN today_reputation ELSE 0 END + $1, \
senior_reputation = CASE WHEN rep_day = CURRENT_DATE \
THEN senior_reputation ELSE 0 END + $2, \
rep_day = CURRENT_DATE \
WHERE character_id = $3",
)
.bind::<Integer, _>(earned)
.bind::<Integer, _>(given)
.bind::<Integer, _>(id)
.execute(conn)?;
}
Ok(earners.into_iter().map(|id| (id, gain)).collect())
})?)
}

/// How parting a senior from a junior went.
#[derive(Debug, PartialEq, Eq)]
pub enum Separation {
Expand Down
1 change: 1 addition & 0 deletions db/src/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -536,6 +536,7 @@ diesel::table! {
senior_reputation -> Int4,
#[max_length = 200]
precepts -> Varchar,
rep_day -> Date,
}
}
diesel::joinable!(family_members -> characters (character_id));
Expand Down
26 changes: 26 additions & 0 deletions game/src/family.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,24 @@ pub fn separation_rep(junior: u8) -> i32 {
(level / 20 + 10) * level
}

/// Rep a junior earns their senior and their senior's senior for each mob
/// they share in killing, each boss, and each level they gain. v83's own
/// rule isn't known; these are Cosmic's settings (report b049).
pub const REP_PER_KILL: i32 = 4;
pub const REP_PER_BOSS: i32 = 20;
pub const REP_PER_LEVEL: i32 = 200;

/// What `gain` Rep from a junior of level `junior` comes to for a senior of
/// level `senior`: half, once the junior has passed them (the client's
/// warnings 0x121b, 0x121c and 0x1246).
pub fn rep_gain(gain: i32, senior: u8, junior: u8) -> i32 {
if senior < junior {
gain / 2
} else {
gain
}
}

/// A character's senior and juniors, by character id; both empty out of a
/// family.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
Expand Down Expand Up @@ -172,4 +190,12 @@ mod tests {
assert_eq!(separation_rep(19), 190);
assert_eq!(separation_rep(20), 220);
}

#[test]
fn a_junior_who_has_passed_their_senior_earns_them_half() {
assert_eq!(rep_gain(REP_PER_KILL, 30, 20), 4);
assert_eq!(rep_gain(REP_PER_KILL, 30, 30), 4);
assert_eq!(rep_gain(REP_PER_KILL, 30, 31), 2);
assert_eq!(rep_gain(5, 30, 31), 2);
}
}
11 changes: 11 additions & 0 deletions integration-harness/src/database.rs
Original file line number Diff line number Diff line change
Expand Up @@ -414,6 +414,17 @@ pub async fn set_meso(character_id: i32, meso: i32) -> Result<(), HarnessError>
.await
}

/// Give the logged-out character `character_id` `exp` EXP into their level.
pub async fn set_exp(character_id: i32, exp: i32) -> Result<(), HarnessError> {
change_one_row("set exp", move |conn| {
diesel::sql_query("UPDATE characters SET exp = $2 WHERE id = $1")
.bind::<Integer, _>(character_id)
.bind::<Integer, _>(exp)
.execute(conn)
})
.await
}

/// Give `character_id`, who is in a family, `reputation` Rep to spend.
pub async fn set_family_reputation(character_id: i32, reputation: i32) -> Result<(), HarnessError> {
change_one_row("set family reputation", move |conn| {
Expand Down
79 changes: 77 additions & 2 deletions integration-harness/tests/families.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
//! the asker a fee by the levels between them, and the seniors Rep by the
//! junior's level.

use integration_harness::database::{set_family_reputation, set_job, set_meso};
use integration_harness::database::{set_exp, set_family_reputation, set_job, set_meso};
use integration_harness::hunting::attack;
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};
Expand All @@ -21,6 +22,8 @@ use tokio::time::Duration;

const LITH_HARBOR: i32 = 104_000_000;
const HENESYS: i32 = 100_000_000;
const FOREST: i32 = 50_000;
const SNAIL: i32 = 100100;

const NOT_FOUND: i32 = 0x41;
const SAME_FAMILY: i32 = 0x42;
Expand Down Expand Up @@ -56,7 +59,18 @@ fn info(juniors: u16, leader: i32, name: &str) -> (i16, Vec<u8>) {
}

fn info_with(rep: i32, juniors: u16, leader: i32, name: &str, precepts: &str) -> (i16, Vec<u8>) {
let mut body = [rep, 0, 0].map(i32::to_le_bytes).concat();
info_full([rep, 0, 0], juniors, leader, name, precepts)
}

/// FAMILY_INFO_RESULT with `reps`: to spend, ever earned, and today's.
fn info_full(
reps: [i32; 3],
juniors: u16,
leader: i32,
name: &str,
precepts: &str,
) -> (i16, Vec<u8>) {
let mut body = reps.map(i32::to_le_bytes).concat();
body.extend(juniors.to_le_bytes());
body.extend(2u16.to_le_bytes());
body.extend(0u16.to_le_bytes());
Expand Down Expand Up @@ -353,3 +367,64 @@ async fn the_leader_sets_the_precepts_and_parting_costs_rep_and_mesos() {
[info_with(5000 - 440, 0, 0, "", "")]
);
}

#[tokio::test]
async fn a_juniors_kills_and_levels_earn_both_seniors_rep() {
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, 40).await;
new_character(&b_config, 30).await;
let c_id = new_character(&c_config, 20).await;
// One EXP short of level 21.
set_exp(c_id, 13_476).await.expect("exp");
let (an, bn, cn) = (
a_config.character_name.clone(),
b_config.character_name.clone(),
c_config.character_name.clone(),
);
let (mut a, _) = enter(&a_config, LITH_HARBOR).await;
let (mut b, _) = enter(&b_config, LITH_HARBOR).await;
let (mut c, _) = enter(&c_config, LITH_HARBOR).await;
take_junior(&mut a, &an, &mut b, &bn).await;
take_junior(&mut b, &bn, &mut c, &cn).await;

// C kills a snail and goes up a level: 200 for the level and 4 for the
// kill, to B and to A. Only B, the direct senior, reads of the level.
let mut ct = Trace::default();
assert_eq!(warp(&mut c.connection, FOREST, "", &mut ct).await, FOREST);
until(&mut c.connection, &mut ct, |t| {
!t.field.alive(SNAIL).is_empty()
})
.await;
let snail = ct.field.alive(SNAIL)[0];
send(&mut c.connection, attack(snail, 8)).await;
until(&mut c.connection, &mut ct, |t| {
t.field.killed.contains(&snail)
})
.await;
let gain = |rep: i32| {
let mut body = rep.to_le_bytes().to_vec();
body.extend(str_bytes(&cn));
(SendOpcode::FamilyRepGain as i16, body)
};
let level_up = (SendOpcode::NotifyLevelUp as i16, 1, 21, cn.clone());
for (session, notices) in [(&mut b, vec![level_up]), (&mut a, vec![])] {
let mut t = Trace::default();
until(&mut session.connection, &mut t, |t| {
t.family.last() == Some(&info_full([204; 3], 1, a_id, &an, ""))
})
.await;
read_for(&mut session.connection, &mut t, 300).await;
let gains: Vec<_> = t
.family
.iter()
.filter(|(op, _)| *op == SendOpcode::FamilyRepGain as i16)
.cloned()
.collect();
assert_eq!(gains, [gain(200), gain(4)]);
assert_eq!(t.notices, notices);
}
}
Loading
Loading