diff --git a/.gitignore b/.gitignore index 9213888..bf26214 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,8 @@ ndk/ .DS_Store custom_songs/ custom_cards/ + +# local-only trees — never commit (35GB between them) +/android/ +/assets.bak/ +/assets.old/ diff --git a/Cargo.lock b/Cargo.lock index c8c7c20..81adc57 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -223,6 +223,22 @@ dependencies = [ "syn", ] +[[package]] +name = "actix-ws" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "decf53c3cdd63dd6f289980b430238f9a2f6d19f8bce8e418272e08d3da43f0f" +dependencies = [ + "actix-codec", + "actix-http", + "actix-web", + "bytestring", + "futures-core", + "futures-sink", + "tokio", + "tokio-util", +] + [[package]] name = "adler2" version = "2.0.1" @@ -1073,6 +1089,7 @@ version = "1.1.0" dependencies = [ "actix-multipart", "actix-web", + "actix-ws", "aes", "argon2", "base64", @@ -1103,6 +1120,7 @@ dependencies = [ "sha1", "sha2", "symphonia", + "tokio", "ureq", "urlencoding", "uuid", @@ -3209,6 +3227,7 @@ dependencies = [ "bytes", "futures-core", "futures-sink", + "futures-util", "pin-project-lite", "tokio", ] diff --git a/Cargo.toml b/Cargo.toml index 687a551..a766c98 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,6 +32,10 @@ include_dir = "0.7.4" jzon = "0.12.5" csv = "1.3" actix-multipart = "0.8" +# Multi-live relay: WebSockets over actix-web 4 without the actor runtime +actix-ws = "0.4" +# tokio is already in the tree under actix-rt; only the mpsc channels are used +tokio = { version = "1", features = ["sync"] } futures-util = "0.3" image = "0.25" zip = { version = "8.6", default-features = false, features = ["deflate"] } diff --git a/src/lib.rs b/src/lib.rs index 9f1c5f0..954d5f8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -37,6 +37,10 @@ pub async fn run_server(in_thread: bool) -> std::io::Result<()> { router::custom_song::migrate::run(); router::custom_song::sweep_audio(); + // The multi-live relay's expiry timers, on the system arbiter rather than on whichever + // HTTP worker happened to serve the first WebSocket upgrade — a worker panic must not + // be able to strand every room's seats for the life of the process. + router::multi_live::start_sweeper(); let rv = HttpServer::new(|| App::new() //.wrap(Cors::permissive()) diff --git a/src/router.rs b/src/router.rs index 0a56f2c..efed3f6 100644 --- a/src/router.rs +++ b/src/router.rs @@ -10,6 +10,7 @@ pub mod home; pub mod lottery; pub mod friend; pub mod live; +pub mod multi_live; pub mod event; pub mod chat; pub mod story; @@ -274,6 +275,7 @@ pub fn configure(cfg: &mut actix_web::web::ServiceConfig) { .configure(login::routes) .configure(lottery::routes) .configure(mission::routes) + .configure(multi_live::routes) .configure(notice::routes) .configure(purchase::routes) .configure(serial_code::routes) diff --git a/src/router/chat.rs b/src/router/chat.rs index 7081786..f8c813b 100644 --- a/src/router/chat.rs +++ b/src/router/chat.rs @@ -7,11 +7,33 @@ pub fn routes(cfg: &mut web::ServiceConfig) { cfg.service( web::scope("/chat") .route("/home", web::post().to(home)) + .route("/talk/get_stamp", web::get().to(get_stamp)) .route("/talk/start", web::post().to(start)) .route("/talk/end", web::post().to(end)) ); } +// The stamps this account owns. ew does not track stamp unlocks, so everyone has the +// masterdata initial set — the same list /chat/home reports, deliberately from one +// source so the two endpoints can never disagree. +fn owned_stamp_ids() -> JsonValue { + databases::INITIAL_CHAT_STAMPS.clone() +} + +// GET /api/chat/talk/get_stamp (Protocol.send_get_stamp, FuncId.GET_STAMP). +// RecvGetStampRData carries a single `master_chat_stamp_ids` array, which its Notify() +// feeds to CallOnUpdateChatStampListNotify — the same sink RecvChatHomeRData uses, so +// the stamp picker ends up with whatever this returns. The client sends no parameters. +// +// This endpoint appears in neither official capture (0 hits across the 288MB JP and +// 1.4GB EN logs), so the shape is taken from the client class and the contents from the +// /chat/home captures that do exist and carry the same field. +async fn get_stamp(Login(_key): Login) -> impl Responder { + Api(Some(object!{ + "master_chat_stamp_ids": owned_stamp_ids() + })) +} + pub fn add_chat(id: i64, num: i64, chats: &mut JsonValue) -> bool { for data in chats.members() { if data["chat_id"] == id && data["room_id"] == num { @@ -48,7 +70,7 @@ async fn home(Login(key): Login) -> impl Responder { Api(Some(object!{ "progress_list": chats, "master_chat_room_ids": rooms, - "master_chat_stamp_ids": [1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,43,44,45,46,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,11001003,22001001,33001001,44001002], + "master_chat_stamp_ids": owned_stamp_ids(), "master_chat_attachment_ids": [] })) } @@ -75,3 +97,41 @@ async fn end(Session { key, body }: Session) -> impl Responder { Api(Some(array![])) } + +#[cfg(test)] +mod tests { + use super::*; + + // Verbatim from an official /api/chat/home capture (JP log, the 65-occurrence + // baseline seen on accounts that had earned no extra stamps yet). Both the JP and EN + // chat_stamp tables reproduce it exactly from _initialStamp, which is what lets + // get_stamp serve masterdata instead of a hardcoded literal. + const OFFICIAL_INITIAL_STAMPS: [i64; 97] = [ + 1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,19,20,21,22,23,24,25,26,27,28,29,30, + 31,32,33,34,35,36,37,38,39,40,41,43,44,45,46,48,49,50,51,52,53,54,55,56,57,58, + 59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84, + 85,86,87,88,89,90,91,92,93,94,95,96,11001003,22001001,33001001,44001002 + ]; + + #[test] + fn the_initial_stamp_set_matches_official() { + let ids: Vec = owned_stamp_ids().members().map(|s| s.as_i64().unwrap()).collect(); + assert_eq!(ids, OFFICIAL_INITIAL_STAMPS.to_vec()); + // Order matters: the official list is masterdata order, not sorted (the 11xxxxxx + // band trails the small ids). + assert_eq!(ids.first(), Some(&1)); + assert_eq!(ids.last(), Some(&44001002)); + // The gaps are real - 18, 42 and 47 are not initial stamps. + for missing in [18, 42, 47] { + assert!(!ids.contains(&missing), "{missing} should not be an initial stamp"); + } + } + + #[test] + fn get_stamp_and_chat_home_cannot_disagree() { + // Both endpoints read the one source; this is what stops /chat/home's list and + // the stamp picker's list from drifting apart. + assert_eq!(owned_stamp_ids(), *databases::INITIAL_CHAT_STAMPS); + assert!(!owned_stamp_ids().is_empty()); + } +} diff --git a/src/router/clear_rate.rs b/src/router/clear_rate.rs index f30b788..8fad950 100644 --- a/src/router/clear_rate.rs +++ b/src/router/clear_rate.rs @@ -68,14 +68,14 @@ fn setup_tables(conn: &rusqlite::Connection) { );").unwrap(); } -fn update_live_score(id: i64, uid: i64, score: i64) { - if uid == 0 || score == 0 { - return; - } - - let info = DATABASE.lock_and_select("SELECT score_data FROM scores WHERE live_id=?1", params!(id)).unwrap_or(String::from("[]")); - let scores = jzon::parse(&info).unwrap(); - +// Merges this play into the song's top-10 board. Pure so the whole read-modify-write can +// sit inside one transaction, and so the keep-best rule is testable on its own. +// +// The board is per-song, not per-account: `scores` is keyed by live_id alone and the user +// lives inside the JSON blob. A user already on the board keeps whichever of their two +// scores is higher — a replay that does not beat the stored one is dropped (`None`), which +// is what makes a repeated or duplicated end idempotent rather than additive. +fn merge_live_score(scores: &JsonValue, uid: i64, score: i64) -> Option { let mut result = array![]; let mut current = 0; let mut added = false; @@ -99,7 +99,8 @@ fn update_live_score(id: i64, uid: i64, score: i64) { } } if scores[i]["user"].as_i64().unwrap() == uid && !added { - return; + // Already on the board with a better score — keep it, drop this play. + return None; } if scores[i]["user"].as_i64().unwrap() == uid { continue; @@ -107,13 +108,41 @@ fn update_live_score(id: i64, uid: i64, score: i64) { result.push(scores[i].clone()).unwrap(); current += 1; } - - if added { - if DATABASE.lock_and_select("SELECT live_id FROM scores WHERE live_id=?1", params!(id)).is_ok() { - DATABASE.lock_and_exec("UPDATE scores SET score_data=?1 WHERE live_id=?2", params!(jzon::stringify(result), id)); - } else { - DATABASE.lock_and_exec("INSERT INTO scores (score_data, live_id) VALUES (?1, ?2)", params!(jzon::stringify(result), id)); - } + + if added { Some(result) } else { None } +} + +fn update_live_score(id: i64, uid: i64, score: i64) { + if uid == 0 || score == 0 { + return; + } + + // One transaction for read + merge + write. Previously this SELECTed to choose between + // UPDATE and INSERT on separate connections, so two ends landing together for a song + // with no row yet both took the INSERT branch and the loser panicked the worker on + // `UNIQUE constraint failed: scores.live_id`. Two clients finishing the same multi + // live make that the normal case, not a rare race. + let write = DATABASE.lock_and_transact(|conn| { + let stored: String = conn + .query_row("SELECT score_data FROM scores WHERE live_id=?1", params!(id), |row| row.get(0)) + .unwrap_or_else(|_| String::from("[]")); + let scores = jzon::parse(&stored).unwrap_or_else(|_| array![]); + + let Some(result) = merge_live_score(&scores, uid, score) else { + return Ok(()); + }; + + // Atomic upsert: no branch left for a concurrent writer to slip between. + conn.execute( + "INSERT INTO scores (live_id, score_data) VALUES (?1, ?2) + ON CONFLICT(live_id) DO UPDATE SET score_data=excluded.score_data", + params!(id, jzon::stringify(result)) + )?; + Ok(()) + }); + + if let Err(e) = write { + println!("Failed to record score for live {id}: {e}"); } } @@ -129,27 +158,44 @@ pub fn invalidate_cache() { crate::lock_onto_mutex!(CACHED_HTML_DATA).take(); } +// The clear-rate counter column this play lands in, or None for a level outside 1-4. +// Names come from this closed set, never from request data, so it is safe to interpolate. +fn clear_rate_column(level: i32, failed: bool) -> Option<&'static str> { + let tier = match level { + 1 => "normal", + 2 => "hard", + 3 => "expert", + 4 => "master", + _ => return None + }; + Some(match (tier, failed) { + ("normal", true) => "normal_failed", ("normal", false) => "normal_pass", + ("hard", true) => "hard_failed", ("hard", false) => "hard_pass", + ("expert", true) => "expert_failed", ("expert", false) => "expert_pass", + (_, true) => "master_failed", (_, false) => "master_pass" + }) +} + pub fn live_completed(id: i64, level: i32, failed: bool, score: i64, uid: i64) { update_live_score(id, uid, score); - match DATABASE.get_live_data(id) { - Ok(info) => { - let value = format!("{}_{}", - if 1 == level { "normal" } else if 2 == level { "hard" } else if 3 == level { "expert" } else { "master" }, - if failed { "failed" } else { "pass" } - ); - let new_info = if 1 == level && failed { info.normal_failed } - else if 1 == level && !failed { info.normal_pass } - else if 2 == level && failed { info.hard_failed } - else if 2 == level && !failed { info.hard_pass } - else if 3 == level && failed { info.expert_failed } - else if 3 == level && !failed { info.expert_pass } - else if 4 == level && failed { info.master_failed } - else if 4 == level && !failed { info.master_pass } else { return; }; - - DATABASE.lock_and_exec(&format!("UPDATE lives SET {}=?1 WHERE live_id=?2", value), params!(new_info + 1, info.live_id)); - }, - Err(_) => { - DATABASE.lock_and_exec("INSERT INTO lives (live_id, normal_failed, normal_pass, hard_failed, hard_pass, expert_failed, expert_pass, master_failed, master_pass) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", params!( + + let Some(column) = clear_rate_column(level, failed) else { + return; + }; + + // `lives` is keyed by live_id alone and had the same select-then-INSERT-or-UPDATE + // split as `scores`, so it could panic the same way on a first-ever concurrent play + // (and lost counts whenever two plays overlapped). One upsert does both branches + // atomically and increments in SQL rather than read-modify-write in Rust. + let write = DATABASE.lock_and_transact(|conn| { + conn.execute( + &format!( + "INSERT INTO lives (live_id, normal_failed, normal_pass, hard_failed, hard_pass, + expert_failed, expert_pass, master_failed, master_pass) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) + ON CONFLICT(live_id) DO UPDATE SET {column} = {column} + 1" + ), + params!( id, if 1 == level && failed { 1 } else { 0 }, if 1 == level && !failed { 1 } else { 0 }, @@ -159,9 +205,14 @@ pub fn live_completed(id: i64, level: i32, failed: bool, score: i64, uid: i64) { if 3 == level && !failed { 1 } else { 0 }, if 4 == level && failed { 1 } else { 0 }, if 4 == level && !failed { 1 } else { 0 } - )); - }, - }; + ) + )?; + Ok(()) + }); + + if let Err(e) = write { + println!("Failed to record clear rate for live {id}: {e}"); + } } fn get_song_title(live_id: i32, english: bool) -> String { @@ -407,3 +458,107 @@ pub async fn clearrate_html(_req: HttpRequest) -> HttpResponse { .content_type(ContentType::html()) .body(html) } + +#[cfg(test)] +mod tests { + use super::*; + + fn board(live_id: i64) -> JsonValue { + let stored = DATABASE + .lock_and_select("SELECT score_data FROM scores WHERE live_id=?1", params!(live_id)) + .unwrap_or_else(|_| String::from("[]")); + jzon::parse(&stored).unwrap() + } + + fn passes(live_id: i64) -> i64 { + DATABASE.get_live_data(live_id).map(|l| l.master_pass).unwrap_or(0) + } + + #[test] + fn merge_keeps_the_users_best_score() { + let existing = array![object!{user: 7, score: 900}]; + + // A better score replaces the stored one rather than adding a second entry. + let better = merge_live_score(&existing, 7, 1000).expect("a better score is recorded"); + assert_eq!(better.len(), 1); + assert_eq!(better[0]["score"].as_i64(), Some(1000)); + + // A worse or equal replay is dropped entirely — this is what makes a repeated + // end idempotent instead of appending the same user twice. + assert!(merge_live_score(&existing, 7, 800).is_none()); + assert!(merge_live_score(&existing, 7, 900).is_none()); + + // A different user is ranked against the board, best first. + let other = merge_live_score(&existing, 8, 950).expect("a new user is recorded"); + assert_eq!(other.len(), 2); + assert_eq!(other[0]["user"].as_i64(), Some(8)); + assert_eq!(other[1]["user"].as_i64(), Some(7)); + } + + #[test] + fn a_duplicate_end_does_not_double_the_board_entry() { + let _lock = crate::runtime::lock_test_data_path(); + let live_id = 990001; + + live_completed(live_id, 4, false, 500000, 4242); + assert_eq!(board(live_id).len(), 1); + assert_eq!(passes(live_id), 1); + + // The second end for the same session: same user, same score. The board must not + // grow, and this must not raise UNIQUE constraint failed: scores.live_id. + live_completed(live_id, 4, false, 500000, 4242); + assert_eq!(board(live_id).len(), 1, "the same user must not appear twice"); + assert_eq!(board(live_id)[0]["score"].as_i64(), Some(500000)); + } + + // The actual regression: two ends for a song with no row yet, landing together. Both + // used to take the INSERT branch and the loser unwrapped a ConstraintViolation into a + // worker panic. Two clients finishing one multi live makes this the normal case. + #[test] + fn concurrent_first_plays_of_one_song_do_not_collide() { + let _lock = crate::runtime::lock_test_data_path(); + let live_id = 990002; + + std::thread::scope(|s| { + for uid in [101i64, 102, 103, 104, 105, 106, 107, 108] { + s.spawn(move || live_completed(live_id, 4, false, 400000 + uid, uid)); + } + }); + + // Every writer landed: no row lost to a race, none lost to a swallowed error. + assert_eq!(board(live_id).len(), 8); + assert_eq!(passes(live_id), 8, "clear-rate counts must not be lost either"); + } + + // The other half of /multi_live/end's score-board branch (the account's own high score + // is pinned in live.rs): a public multi live reaches live_completed with uid 0, so the + // play is counted and the board is left alone. /live/retire has always used the same + // signal for a failed live. + #[test] + fn a_play_with_no_user_counts_the_clear_but_not_the_board() { + let _lock = crate::runtime::lock_test_data_path(); + let live_id = 990003; + + live_completed(live_id, 4, false, 500000, 0); + assert_eq!(board(live_id).len(), 0, "an unranked play must not reach the board"); + assert_eq!(passes(live_id), 1, "but it is still a play of the song"); + + // The same score from a real account does land, which is the private-room path. + live_completed(live_id, 4, false, 500000, 4243); + assert_eq!(board(live_id).len(), 1); + assert_eq!(passes(live_id), 2); + } + + #[test] + fn clear_rate_columns_cover_every_level() { + assert_eq!(clear_rate_column(1, false), Some("normal_pass")); + assert_eq!(clear_rate_column(1, true), Some("normal_failed")); + assert_eq!(clear_rate_column(2, false), Some("hard_pass")); + assert_eq!(clear_rate_column(3, true), Some("expert_failed")); + assert_eq!(clear_rate_column(4, false), Some("master_pass")); + assert_eq!(clear_rate_column(4, true), Some("master_failed")); + // Level 0 (a skip ticket's "any level") writes no counter, as before. + assert_eq!(clear_rate_column(0, false), None); + assert_eq!(clear_rate_column(5, false), None); + } +} diff --git a/src/router/databases/mod.rs b/src/router/databases/mod.rs index df03c02..dafc130 100644 --- a/src/router/databases/mod.rs +++ b/src/router/databases/mod.rs @@ -302,6 +302,57 @@ lazy_static! { info }; + // const.csv keyed by _id. Values are strings in masterdata, exactly as the + // client reads them (ConstMst._value + StringExtensions.ToIntOrDefault). + pub static ref CONST: JsonValue = index_by(&t("const"), "id"); + + pub static ref LIVE_BOOST: JsonValue = index_by(&t("live_boost"), "value"); + + // The stamps every account starts with (chat_stamp._initialStamp), in masterdata + // order. Officially this is the whole of a fresh account's master_chat_stamp_ids — + // captured /api/chat/home responses open with exactly this list before an account's + // earned stamps are appended (see chat::tests::the_initial_stamp_set_matches_official). + pub static ref INITIAL_CHAT_STAMPS: JsonValue = { + let mut ids = array![]; + for data in t("chat_stamp").members() { + if data["initialStamp"].as_i64().unwrap_or(0) == 1 { + ids.push(data["id"].clone()).unwrap(); + } + } + ids + }; + + pub static ref EVENTS: JsonValue = index_by(&t("event"), "id"); + + // release_label.csv keyed by _id — the open/close window masterdata rows are gated + // on. _openedAt / _closedAt are blank for the evergreen label (id 1). + pub static ref RELEASE_LABEL: JsonValue = index_by(&t("release_label"), "id"); + + // event_score.csv keyed by _masterEventId (Shock.EventScoreMst) — the per-event + // event-point yield of one live. Ratios are 1/10000, like every other ratio the + // client divides by COMMON_CONST.RATIO_DIVISOR. + pub static ref EVENT_SCORE: JsonValue = index_by(&t("event_score"), "masterEventId"); + + // music_level rows keyed "{masterMusicId}_{level}" — _fullCombo is the note + // count the multi-live miss/great-perfect ratios are measured against. + pub static ref MUSIC_LEVEL: JsonValue = { + let mut info = object! {}; + for data in t("music_level").members() { + info[format!("{}_{}", data["masterMusicId"], data["level"])] = data.clone(); + } + info + }; + + // multievent_rankbonus keyed "{playerCount}_{liveRank}" — _eventPtBonus is a + // ratio in 1/10000 (the client renders these as `sum / 100` percent). + pub static ref MULTIEVENT_RANK_BONUS: JsonValue = { + let mut info = object! {}; + for data in t("multievent_rankbonus").members() { + info[format!("{}_{}", data["playerCount"], data["liveRank"])] = data.clone(); + } + info + }; + pub static ref RANKS: JsonValue = t("user_rank"); pub static ref USER_RANK_REWARD: JsonValue = { diff --git a/src/router/event.rs b/src/router/event.rs index d941dcf..ae91bbc 100644 --- a/src/router/event.rs +++ b/src/router/event.rs @@ -25,7 +25,39 @@ pub fn routes(cfg: &mut web::ServiceConfig) { ); } -fn get_event_data(key: &str, event_id: u32) -> JsonValue { +// Whether a release_label window is open at `now`. A blank _openedAt has always been +// open and a blank _closedAt never closes, which is how the evergreen label (id 1) is +// expressed. _releaseStatus other than 1 is never released at all. +pub fn release_label_is_open(label_id: i64, now: u64) -> bool { + let label = &databases::RELEASE_LABEL[label_id.to_string()]; + if label.is_empty() || label["releaseStatus"].as_i64().unwrap_or(0) != 1 { + return false; + } + if let Some(opened) = global::parse_datetime(&label["openedAt"].to_string()) { + if now < opened { + return false; + } + } + if let Some(closed) = global::parse_datetime(&label["closedAt"].to_string()) { + if now > closed { + return false; + } + } + true +} + +// Whether an event is currently running, by its own release label window. ew had no +// in-session test before this — nothing else evaluates release_label — so this is the +// one place to extend if the event listing ever needs the same question answered. +pub fn is_in_session(event_id: u32, now: u64) -> bool { + let event = &databases::EVENTS[event_id.to_string()]; + if event.is_empty() { + return false; + } + release_label_is_open(event["masterReleaseLabelId"].as_i64().unwrap_or(0), now) +} + +pub fn get_event_data(key: &str, event_id: u32) -> JsonValue { let mut event = userdata::get_acc_event(key); let is_star_event = STAR_EVENT_IDS.contains(&event_id); //println!("is_star_event: {}, {}", is_star_event, event_id); @@ -50,10 +82,61 @@ fn get_event_data(key: &str, event_id: u32) -> JsonValue { event[event_id.to_string()]["star_event"]["star_event_bonus_daily_count"] = 0.into(); } + normalise_event_shape(&mut event[event_id.to_string()]); + event[event_id.to_string()].clone() } -fn save_event_data(key: &str, event_id: u32, data: JsonValue) { +// The client's /api/event response types are [Serializable] C# classes, and Unity's +// JsonUtility maps them structurally: `score_ranking`, `member_ranking` and `lottery_box` +// are OBJECTS (Shock.ProtocolData.ScoreRanking / MemberRanking / LotteryBox), not arrays. +// new_user_event.json used to seed them as `[]`, which the client cannot bind - and +// MngEventData.SendGetEvent's success callback dereferences `r.data.score_ranking` and +// friends unguarded, so a mis-shaped payload takes out the whole recv and its `onComplete` +// is never called. That callback is the completion of a GATING TaskFlow.Step in +// LiveRestartSelectScene.ReloadScene, so the scene hangs on its loading screen forever. +// +// The template is fixed, but accounts created before that keep their stored blob, so the +// shapes are normalised on the way out as well. Coercion only ever replaces a value the +// client could not have parsed anyway; a well-formed object is left exactly as it is. +// `set_member` already writes member_ranking as an object, which is the shape this agrees +// with. +fn normalise_event_shape(event: &mut JsonValue) { + fn ensure_object(slot: &mut JsonValue, template: JsonValue) { + if !slot.is_object() { + *slot = template; + } else { + for (key, value) in template.entries() { + if slot[key].is_null() { + slot[key] = value.clone(); + } + } + } + } + + ensure_object(&mut event["point_ranking"], object! { rank: 0, point: 0 }); + ensure_object(&mut event["score_ranking"], object! { all_rank: 0, group_rank: 0, score: 0 }); + ensure_object( + &mut event["member_ranking"], + object! { master_character_id: 0, rank: 0, point: 0 }, + ); + ensure_object( + &mut event["lottery_box"], + object! { master_lottery_id: 0, reset_count: 0, draw_count_list: array![] }, + ); + // Read by EventData.SetMultiParameter (help count, penalty window, disconnect flag); + // absent keys bind as 0/false, but sending them keeps the payload self-describing. + for key in ["is_disconnected", "help_count", "penalty_remaining_time"] { + if event[key].is_null() { + event[key] = 0.into(); + } + } + if !event["mission_list"].is_array() { + event["mission_list"] = array![]; + } +} + +pub fn save_event_data(key: &str, event_id: u32, data: JsonValue) { let mut event = userdata::get_acc_event(key); // Check for old version of event data @@ -209,7 +292,7 @@ async fn set_member(Session { key, body }: Session) -> impl Responder { })) } -fn get_rank(event: u32, user_id: u64) -> u32 { +pub fn get_rank(event: u32, user_id: u64) -> u32 { let scores = crate::router::event_ranking::get_raw_info(event); let mut i=1; @@ -254,10 +337,14 @@ fn get_star_rank(points: i64) -> i64 { const LIMIT_COINS: i64 = 2000000000; -fn give_event_points(event_id: u32, amount: i64, user: &mut JsonValue) -> bool { +// The row is keyed by BOTH the event and the point type, exactly as get_points reads it +// back. Matching on the type alone credited whichever event's row happened to come first +// in the list — an account that had ever played a different event got its points there, +// and get_points (which does check the id) then reported 0 for the event just played. +pub fn give_event_points(event_id: u32, amount: i64, user: &mut JsonValue) -> bool { let mut has = false; for data in user["event_point_list"].members_mut() { - if data["type"] == 1 { + if data["type"] == 1 && data["master_event_id"] == event_id { has = true; let new_amount = data["amount"].as_i64().unwrap() + amount; if new_amount > LIMIT_COINS { @@ -278,7 +365,7 @@ fn give_event_points(event_id: u32, amount: i64, user: &mut JsonValue) -> bool { false } -fn get_points(event_id: u32, user: &JsonValue) -> i64 { +pub fn get_points(event_id: u32, user: &JsonValue) -> i64 { for data in user["event_point_list"].members() { if data["type"] == 1 && data["master_event_id"] == event_id { return data["amount"].as_i64().unwrap() @@ -366,3 +453,96 @@ async fn event_end(req: HttpRequest, Session { key, body }: Session) -> impl Res async fn event_skip(req: HttpRequest, Session { key, body }: Session) -> impl Responder { Api(event_live(&req, &key, &body, true)) } + +#[cfg(test)] +mod tests { + use super::*; + + // The client binds these with Unity's JsonUtility against [Serializable] classes: + // ScoreRanking { all_rank, group_rank, score }, MemberRanking { master_character_id, + // rank, point }, PointRanking { rank, point }, LotteryBox { master_lottery_id, + // reset_count, draw_count_list }. An array where an object is expected cannot bind, and + // MngEventData.SendGetEvent's callback dereferences them unguarded. + #[test] + fn the_new_user_template_already_has_the_client_shapes() { + let template: JsonValue = + jzon::parse(&include_file!("src/router/userdata/new_user_event.json")).unwrap(); + for key in ["point_ranking", "score_ranking", "member_ranking", "lottery_box"] { + assert!(template[key].is_object(), "{} must be an object", key); + } + assert!(template["mission_list"].is_array()); + assert_eq!(template["score_ranking"]["all_rank"], 0); + assert_eq!(template["member_ranking"]["master_character_id"], 0); + assert_eq!(template["lottery_box"]["draw_count_list"], array![]); + } + + #[test] + fn stored_blobs_with_the_old_array_shapes_are_coerced() { + // Exactly what accounts created before the template fix have on disk. + let mut stored = object! { + point_ranking: object! { point: 12 }, + score_ranking: array![], + member_ranking: array![], + lottery_box: array![], + mission_list: array![], + }; + normalise_event_shape(&mut stored); + + assert!(stored["score_ranking"].is_object()); + assert_eq!(stored["score_ranking"]["all_rank"], 0); + assert!(stored["member_ranking"].is_object()); + assert!(stored["lottery_box"].is_object()); + assert_eq!(stored["lottery_box"]["draw_count_list"], array![]); + // A key that was already there survives; the missing sibling is filled in. + assert_eq!(stored["point_ranking"]["point"], 12); + assert_eq!(stored["point_ranking"]["rank"], 0); + // The multi trio EventData.SetMultiParameter reads. + assert_eq!(stored["is_disconnected"], 0); + assert_eq!(stored["help_count"], 0); + assert_eq!(stored["penalty_remaining_time"], 0); + } + + #[test] + fn well_formed_data_is_left_alone() { + let mut live = object! { + point_ranking: object! { rank: 3, point: 900 }, + score_ranking: object! { all_rank: 7, group_rank: 2, score: 4242 }, + member_ranking: object! { master_character_id: 5, rank: 1, point: 10 }, + lottery_box: object! { master_lottery_id: 8, reset_count: 1, draw_count_list: array![] }, + mission_list: array![], + is_disconnected: 1, + help_count: 4, + penalty_remaining_time: 600, + }; + let before = live.clone(); + normalise_event_shape(&mut live); + assert_eq!(live, before); + } + + // give_event_points and get_points must agree on what identifies a row. They did not: + // the write matched on the point type alone, so an account that had ever earned points + // in ANY event credited every later event to that first row — and get_points, which + // does compare the event id, then reported 0 for the event actually played. + #[test] + fn event_points_land_in_the_row_for_that_event() { + let mut user = object!{ event_point_list: array![] }; + + give_event_points(108, 35, &mut user); + assert_eq!(get_points(108, &user), 35); + + // A second event opens a row of its own and leaves the first one alone. + give_event_points(111, 70, &mut user); + assert_eq!(get_points(111, &user), 70); + assert_eq!(get_points(108, &user), 35); + assert_eq!(user["event_point_list"].len(), 2); + + // And a second live in the first event still adds to the first row. + give_event_points(108, 35, &mut user); + assert_eq!(get_points(108, &user), 70); + assert_eq!(get_points(111, &user), 70); + assert_eq!(user["event_point_list"].len(), 2); + + // An event never played is worth nothing, not somebody else's total. + assert_eq!(get_points(115, &user), 0); + } +} diff --git a/src/router/global.rs b/src/router/global.rs index 8d47a37..40f606c 100644 --- a/src/router/global.rs +++ b/src/router/global.rs @@ -289,6 +289,48 @@ pub fn format_datetime(time: u64) -> String { format!("{:04}-{:02}-{:02} {:02}:{:02}:{:02}", y, m, d, secs / 3600, (secs % 3600) / 60, secs % 60) } +// Inverse of civil_from_days (Howard Hinnant's days_from_civil). +fn days_from_civil(y: i64, m: i64, d: i64) -> i64 { + let y = if m <= 2 { y - 1 } else { y }; + let era = if y >= 0 { y } else { y - 399 } / 400; + let yoe = y - era * 400; + let mp = if m > 2 { m - 3 } else { m + 9 }; + let doy = (153 * mp + 2) / 5 + d - 1; + let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; + era * 146097 + doe - 719468 +} + +// Parses the datetime shape masterdata uses ("2023/06/19 5:00:00", also tolerating +// '-' separators and a missing time part) into the same naive seconds-since-epoch +// scale format_datetime prints. Naive on purpose: every consumer compares a +// masterdata timestamp against a server timestamp, so both sides share the offset. +pub fn parse_datetime(text: &str) -> Option { + let text = text.trim(); + if text.is_empty() { + return None; + } + let (date, time) = match text.split_once(' ') { + Some((date, time)) => (date, time), + None => (text, "0:0:0") + }; + + let mut date_parts = date.split(['/', '-']); + let y = date_parts.next()?.trim().parse::().ok()?; + let m = date_parts.next()?.trim().parse::().ok()?; + let d = date_parts.next()?.trim().parse::().ok()?; + if !(1..=12).contains(&m) || !(1..=31).contains(&d) { + return None; + } + + let mut time_parts = time.split(':'); + let hh = time_parts.next().unwrap_or("0").trim().parse::().ok()?; + let mm = time_parts.next().unwrap_or("0").trim().parse::().ok()?; + let ss = time_parts.next().unwrap_or("0").trim().parse::().ok()?; + + let secs = days_from_civil(y, m, d) * 86400 + hh * 3600 + mm * 60 + ss; + if secs < 0 { None } else { Some(secs as u64) } +} + fn init_time(current_time: u64, server_data: &mut JsonValue, token: &str, max_time: u64, max: bool) { let mut edited = false; let default_time = 1709272800; diff --git a/src/router/live.rs b/src/router/live.rs index b10cc86..e5bd0e1 100644 --- a/src/router/live.rs +++ b/src/router/live.rs @@ -27,7 +27,9 @@ pub fn routes(cfg: &mut web::ServiceConfig) { async fn retire(Session { key, body }: Session) -> impl Responder { live_retire(&key, &body); if body["live_score"]["play_time"].as_i64().unwrap_or(0) > 5 { - live_completed(body["master_live_id"].as_i64().unwrap(), body["level"].as_i32().unwrap(), true, 0, 0); + // Body-derived, so defaulted rather than unwrapped: a retire is not worth a + // panicked worker either (live_completed ignores an unknown id/level). + live_completed(body["master_live_id"].as_i64().unwrap_or(0), body["level"].as_i32().unwrap_or(0), true, 0, 0); } Api(Some(object!{ "stamina": {}, @@ -222,12 +224,15 @@ fn check_for_stale_data(server_data: &mut JsonValue, live_id: i64) { let mut expired = array![]; let curr_time = global::timestamp(); for (i, live) in server_data["last_live_started"].members().enumerate() { - if live["expire_date_time"].as_u64().unwrap() < curr_time || live["master_live_id"] == live_id { + // A record with no readable expiry is treated as expired rather than panicking: + // start_live always writes one, so this is a corrupt/hand-edited row. + let stale = live["expire_date_time"].as_u64().unwrap_or(0) < curr_time; + if stale || live["master_live_id"] == live_id { expired.push(i).unwrap(); } - if live["expire_date_time"].as_u64().unwrap() < curr_time { + if stale { // User closed game after losing. Count this as a fail. - live_completed(live["master_live_id"].as_i64().unwrap(), live["level"].as_i32().unwrap(), true, 0, 0); + live_completed(live["master_live_id"].as_i64().unwrap_or(0), live["level"].as_i32().unwrap_or(0), true, 0, 0); } } for i in expired.members() { @@ -243,34 +248,94 @@ fn get_end_live_deck_id(login_token: &str, body: &JsonValue) -> Option { let index = server_data["last_live_started"].members().position(|r| r["master_live_id"] == body["master_live_id"])?; let rv = server_data["last_live_started"][index]["deck_slot"].as_i32()?; - check_for_stale_data(&mut server_data, body["master_live_id"].as_i64().unwrap()); + check_for_stale_data(&mut server_data, body["master_live_id"].as_i64().unwrap_or(0)); userdata::save_server_data(login_token, server_data); Some(rv) } -pub fn get_end_live_event_id(login_token: &str, body: &JsonValue) -> Option { +// The whole payload the client POSTed to */start for this live, as recorded by +// start_live. /multi_live/end needs the boost and deck slot out of it, and its +// own request body carries neither. +pub fn get_started_live(login_token: &str, body: &JsonValue) -> Option { let server_data = userdata::get_server_data(login_token); if server_data["last_live_started"].is_null() { return None; } let index = server_data["last_live_started"].members().position(|r| r["master_live_id"] == body["master_live_id"])?; - let rv = server_data["last_live_started"][index]["master_event_id"].as_u32()?; - - Some(rv) + Some(server_data["last_live_started"][index].clone()) } -fn live_retire(login_token: &str, body: &JsonValue) { +// Claims the record start_live left behind: it is returned AND removed in one atomic +// step, so of N ends arriving together for one live exactly one gets Some and every other +// gets None. /multi_live/end awards off that Some and answers a None from current state, +// which is what makes its duplicate protection deliberate rather than a side effect of +// get_end_live_deck_id's shape (that one only consumed when the record carried a numeric +// deck_slot, and only long after the awarding decision had been taken). +// +// The stale sweep check_for_stale_data does is folded in unchanged: records past their +// hour count as a fail and go, so live_end_ex's own later sweep finds nothing left to +// count twice. live_completed is called after the transaction commits - it writes to a +// different database and has no business running inside this one's write lock. +// +// Solo /live/end is deliberately NOT routed through here: it still consumes its record +// inside live_end_ex exactly as it always did. +pub fn take_started_live(login_token: &str, body: &JsonValue) -> Option { + let live_id = body["master_live_id"].clone(); + let curr_time = global::timestamp(); + let mut failed: Vec<(i64, i32)> = Vec::new(); + + let taken = userdata::modify_server_data(login_token, |server_data| { + if server_data["last_live_started"].is_null() { + server_data["last_live_started"] = array![]; + } + let mut taken: Option = None; + let mut kept = array![]; + for live in server_data["last_live_started"].members() { + let stale = live["expire_date_time"].as_u64().unwrap_or(0) < curr_time; + let mine = live["master_live_id"] == live_id; + if stale { + // User closed game after losing. Count this as a fail. + failed.push(( + live["master_live_id"].as_i64().unwrap_or(0), + live["level"].as_i32().unwrap_or(0) + )); + } + if mine && taken.is_none() { + taken = Some(live.clone()); + } else if !stale && !mine { + kept.push(live.clone()).unwrap(); + } + } + server_data["last_live_started"] = kept; + taken + }); + + for (id, level) in failed { + live_completed(id, level, true, 0, 0); + } + taken +} + +pub fn get_end_live_event_id(login_token: &str, body: &JsonValue) -> Option { + get_started_live(login_token, body)?["master_event_id"].as_u32() +} + +pub fn live_retire(login_token: &str, body: &JsonValue) { let mut server_data = userdata::get_server_data(login_token); - check_for_stale_data(&mut server_data, body["master_live_id"].as_i64().unwrap()); + // A body with no master_live_id matches nothing (0 is not a live id); the stale sweep + // still runs, which is all a retire with a malformed body can honestly do. + check_for_stale_data(&mut server_data, body["master_live_id"].as_i64().unwrap_or(0)); userdata::save_server_data(login_token, server_data); } -fn start_live(login_token: &str, body: &JsonValue) { +pub fn start_live(login_token: &str, body: &JsonValue) { let mut server_data = userdata::get_server_data(login_token); if server_data["last_live_started"].is_null() { server_data["last_live_started"] = array![]; } - check_for_stale_data(&mut server_data, body["master_live_id"].as_i64().unwrap()); + // Body-derived: /multi_live/start forwards a body this server never validated, and a + // start with no live id must record a useless record rather than panic a worker. + check_for_stale_data(&mut server_data, body["master_live_id"].as_i64().unwrap_or(0)); let mut to_save = body.clone(); // The user has 1 hour to complete a live to_save["expire_date_time"] = (global::timestamp() + (1 * 60 * 60)).into(); @@ -311,24 +376,32 @@ fn get_clear_count(id: i64, user: &JsonValue) -> i64 { rv } -pub fn update_live_data(user: &mut JsonValue, data: &JsonValue, add: bool) -> JsonValue { +// `record_high_score=false` plays this live for the clear count and the max combo but +// leaves the stored high score alone: it enters the keep-best comparison below as 0, so +// the existing record always wins and is what gets reported back. That is the official +// treatment of a public multi live ("本イベントでは HIGH SCOREは更新されません"), which +// /multi_live/end asks for — see the score-board note in live_end_ex. +pub fn update_live_data(user: &mut JsonValue, data: &JsonValue, add: bool, record_high_score: bool) -> JsonValue { if user["tutorial_step"].as_i32().unwrap() < 130 { return JsonValue::Null; } - + + // Every field here comes off the request body, so a malformed end must not panic a + // worker: a missing id/level/score reads as 0, which is what an empty live record + // would have held anyway. let mut rv = object!{ - "master_live_id": data["master_live_id"].as_i64().unwrap(), - "level": data["level"].as_i64().unwrap(), + "master_live_id": data["master_live_id"].as_i64().unwrap_or(0), + "level": data["level"].as_i64().unwrap_or(0), "clear_count": 1, - "high_score": data["live_score"]["score"].as_i64().unwrap(), - "max_combo": data["live_score"]["max_combo"].as_i64().unwrap(), + "high_score": if record_high_score { data["live_score"]["score"].as_i64().unwrap_or(0) } else { 0 }, + "max_combo": data["live_score"]["max_combo"].as_i64().unwrap_or(0), "auto_enable": 1, //whats this? "updated_time": global::timestamp() }; let mut has = false; for current in user["live_list"].members_mut() { - if current["master_live_id"] == rv["master_live_id"] && (current["level"] == rv["level"] || data["level"].as_i32().unwrap() == 0) { + if current["master_live_id"] == rv["master_live_id"] && (current["level"] == rv["level"] || data["level"].as_i32().unwrap_or(0) == 0) { has = true; if add { rv["clear_count"] = (current["clear_count"].as_i64().unwrap() + 1).into(); @@ -348,7 +421,7 @@ pub fn update_live_data(user: &mut JsonValue, data: &JsonValue, add: bool) -> Js } current["updated_time"] = rv["updated_time"].clone(); rv["level"] = current["level"].clone(); - if data["level"].as_i32().unwrap() != 0 { + if data["level"].as_i32().unwrap_or(0) != 0 { break; } } @@ -586,22 +659,46 @@ fn get_live_character_list(lp_used: i32, deck_id: i32, user: &mut JsonValue, mis } pub fn live_end(req: &HttpRequest, key: &str, body: &JsonValue, skipped: bool) -> JsonValue { + live_end_ex(req, key, body, skipped, true, true) +} + +// consume_lp=false is for lives whose stamina was already taken up front +// (/multi_live/start does that, because its response reports consumed_stamina). +// Everything else still scales off lp_used, so the caller injects use_lp. +// +// update_score_board=false plays the live without recording a score anywhere: neither the +// account's own high score for the song nor the per-song board /live/ranking serves. Only +// /multi_live/end passes false, and only for a PUBLIC (random matchmaking) room — see the +// privacy note there. Everything else about the live is untouched: the clear count, the +// max combo, the clear-rate counters, missions and every reward are computed off this +// play's own score exactly as they always were. +pub fn live_end_ex(req: &HttpRequest, key: &str, body: &JsonValue, skipped: bool, consume_lp: bool, update_score_board: bool) -> JsonValue { let mut user2 = userdata::get_acc_home(&key); let mut user = userdata::get_acc(&key); let mut user_missions = userdata::get_acc_missions(&key); let mut chats = userdata::get_acc_chats(&key); + // Read once, off the request, with a default each: a client (or a replayed/forged + // POST) that omits a field must get a wrong-but-harmless result, not a panicked + // worker. /multi_live/end is the reachable path — it forwards a body this handler + // never validated — but the solo path gets the same treatment, and for a well-formed + // body every one of these is exactly what the old unwrap produced. + let live_id = body["master_live_id"].as_i64().unwrap_or(0); + let level = body["level"].as_i64().unwrap_or(0); + let score = body["live_score"]["score"].as_i64().unwrap_or(0); + let max_combo = body["live_score"]["max_combo"].as_i64().unwrap_or(0); + let jp = items::get_region(req.headers()); let first_clear = !skipped && user["tutorial_step"].as_i32().unwrap() >= 130 - && get_clear_count(body["master_live_id"].as_i64().unwrap(), &user) == 0; + && get_clear_count(live_id, &user) == 0; let live = if skipped { items::use_item(&object!{ value: 21000001, amount: 1, consumeType: 4 - }, body["live_boost"].as_i64().unwrap(), &mut user); + }, body["live_boost"].as_i64().unwrap_or(0), &mut user); update_live_data(&mut user, &object!{ master_live_id: body["master_live_id"].clone(), level: 0, @@ -609,9 +706,9 @@ pub fn live_end(req: &HttpRequest, key: &str, body: &JsonValue, skipped: bool) - score: 1, max_combo: 1 } - }, false) + }, false, update_score_board) } else { - update_live_data(&mut user, &body, true) + update_live_data(&mut user, &body, true, update_score_board) }; //1273009, 1273010, 1273011, 1273012 @@ -626,21 +723,27 @@ pub fn live_end(req: &HttpRequest, key: &str, body: &JsonValue, skipped: bool) - } } + // The account the per-song board is keyed by, or 0 to leave the board alone — + // clear_rate::update_live_score's own "no user, no board entry" guard, which is how + // /live/retire already counts a play it must not rank. The clear-rate counters still + // get the play either way: a public multi live really was played. + let board_uid = if update_score_board { user["user"]["id"].as_i64().unwrap() } else { 0 }; + let missions; if skipped { - live_completed(body["master_live_id"].as_i64().unwrap(), live["level"].as_i32().unwrap(), false, live["high_score"].as_i64().unwrap(), user["user"]["id"].as_i64().unwrap()); - let clear_count = get_clear_count(body["master_live_id"].as_i64().unwrap(), &user); + live_completed(live_id, live["level"].as_i32().unwrap_or(0), false, live["high_score"].as_i64().unwrap_or(0), board_uid); + let clear_count = get_clear_count(live_id, &user); - missions = get_live_mission_completed_ids(&user, body["master_live_id"].as_i64().unwrap(), live["high_score"].as_i64().unwrap(), live["max_combo"].as_i64().unwrap(), clear_count, live["level"].as_i64().unwrap(), false, false).unwrap_or(array![]); + missions = get_live_mission_completed_ids(&user, live_id, live["high_score"].as_i64().unwrap_or(0), live["max_combo"].as_i64().unwrap_or(0), clear_count, live["level"].as_i64().unwrap_or(0), false, false).unwrap_or(array![]); } else { - live_completed(body["master_live_id"].as_i64().unwrap(), body["level"].as_i32().unwrap(), false, body["live_score"]["score"].as_i64().unwrap(), user["user"]["id"].as_i64().unwrap()); - let clear_count = get_clear_count(body["master_live_id"].as_i64().unwrap(), &user); + live_completed(live_id, level as i32, false, score, board_uid); + let clear_count = get_clear_count(live_id, &user); let is_full_combo = (body["live_score"]["good"].as_i32().unwrap_or(1) + body["live_score"]["bad"].as_i32().unwrap_or(1) + body["live_score"]["miss"].as_i32().unwrap_or(1)) == 0; let is_perfect = is_full_combo && body["live_score"]["great"].as_i32().unwrap_or(1) == 0; - missions = get_live_mission_completed_ids(&user, body["master_live_id"].as_i64().unwrap(), body["live_score"]["score"].as_i64().unwrap(), body["live_score"]["max_combo"].as_i64().unwrap(), clear_count, body["level"].as_i64().unwrap(), is_full_combo, is_perfect).unwrap_or(array![]); + missions = get_live_mission_completed_ids(&user, live_id, score, max_combo, clear_count, level, is_full_combo, is_perfect).unwrap_or(array![]); if is_full_combo { if items::advance_mission(1176001, 1, 1, &mut user_missions).is_some() { @@ -665,13 +768,13 @@ pub fn live_end(req: &HttpRequest, key: &str, body: &JsonValue, skipped: bool) - if is_perfect && items::advance_mission(1177001, 1, 1, &mut user_missions).is_some() { cleared_missions.push(1177001).unwrap(); } - if is_perfect && body["level"].as_i32().unwrap() == 4 && items::advance_mission(1177002, 1, 1, &mut user_missions).is_some() { + if is_perfect && level == 4 && items::advance_mission(1177002, 1, 1, &mut user_missions).is_some() { cleared_missions.push(1177002).unwrap(); } } update_live_mission_data(&mut user, &object!{ - master_live_id: body["master_live_id"].as_i64().unwrap(), + master_live_id: live_id, clear_master_live_mission_ids: missions.clone() }); @@ -679,7 +782,9 @@ pub fn live_end(req: &HttpRequest, key: &str, body: &JsonValue, skipped: bool) - let mut reward_list = give_mission_rewards(&mut user, &mut user2, &missions, &mut user_missions, &mut cleared_missions, &mut chats, (lp_used / 10) as i64, jp); - items::lp_modification(&mut user, lp_used as u64, true); + if consume_lp { + items::lp_modification(&mut user, lp_used as u64, true); + } items::give_exp(lp_used, &mut user, &mut user_missions, &mut cleared_missions); @@ -761,7 +866,7 @@ mod tests { master_live_id: live_id, level: level, live_score: { score: score, max_combo: combo } - }, true); + }, true, true); userdata::save_acc(token, user); } @@ -769,6 +874,118 @@ mod tests { get_clear_count(live_id, &userdata::get_acc(token)) } + // The same clear with the score board switched off, which is what live_end_ex does for + // a multi live played in a PUBLIC room. Returns the `live` record the client is + // answered with. + fn record_unscored_clear(token: &str, live_id: i64, level: i64, score: i64, combo: i64) -> JsonValue { + let mut user = userdata::get_acc(token); + let live = update_live_data(&mut user, &object!{ + master_live_id: live_id, + level: level, + live_score: { score: score, max_combo: combo } + }, true, false); + userdata::save_acc(token, user); + live + } + + fn stored_live(token: &str, live_id: i64) -> JsonValue { + userdata::get_acc(token)["live_list"] + .members() + .find(|l| l["master_live_id"] == live_id) + .cloned() + .unwrap_or(JsonValue::Null) + } + + // /multi_live/end's score-board branch, at the write it actually gates. live_end_ex + // itself cannot be driven from a test (items::get_region reaches the clap parser, which + // rejects the harness's own arguments), so the two halves of the gate are pinned where + // they live: the account's own high score here, and the per-song board next door in + // clear_rate. + // + // A PRIVATE room is the unchanged path: it records exactly like a solo live, keep-best. + #[test] + fn a_private_multi_live_records_its_score_keep_best() { + let _lock = crate::runtime::lock_test_data_path(); + let token = "sb_private_room"; + register_account(token); + + record_clear(token, STOCK_LIVE_ID, 4, 500000, 320); + assert_eq!(stored_live(token, STOCK_LIVE_ID)["high_score"], 500000); + + // A better score wins... + record_clear(token, STOCK_LIVE_ID, 4, 600000, 340); + assert_eq!(stored_live(token, STOCK_LIVE_ID)["high_score"], 600000); + + // ...and a worse one never overwrites it. + record_clear(token, STOCK_LIVE_ID, 4, 100000, 20); + assert_eq!(stored_live(token, STOCK_LIVE_ID)["high_score"], 600000); + assert_eq!(pulled_clear_count(token, STOCK_LIVE_ID), 3); + } + + // A PUBLIC room is the official behaviour: the play counts, the score does not. + #[test] + fn a_public_multi_live_leaves_the_high_score_alone() { + let _lock = crate::runtime::lock_test_data_path(); + let token = "sb_public_room"; + register_account(token); + + record_clear(token, STOCK_LIVE_ID, 4, 500000, 320); + + // A score that would beat the stored one is not recorded, and the answer the client + // gets back reports the record that still stands rather than this play's score. + let live = record_unscored_clear(token, STOCK_LIVE_ID, 4, 900000, 400); + assert_eq!(live["high_score"], 500000); + assert_eq!(stored_live(token, STOCK_LIVE_ID)["high_score"], 500000); + + // Everything else about the live still lands: the clear counts and the combo is a + // real one (the client only ever said HIGH SCORE was not updated). + assert_eq!(pulled_clear_count(token, STOCK_LIVE_ID), 2); + assert_eq!(stored_live(token, STOCK_LIVE_ID)["max_combo"], 400); + } + + // The solo path is untouched by the multi branch: live_end — the only entry /live/end + // and /live/skip have — calls live_end_ex with update_score_board hardcoded true, so a + // solo live writes exactly what it always did. + #[test] + fn the_solo_live_path_still_records_its_score() { + let _lock = crate::runtime::lock_test_data_path(); + let token = "sb_solo_path"; + register_account(token); + + record_clear(token, STOCK_LIVE_ID, 4, 450000, 300); + assert_eq!(stored_live(token, STOCK_LIVE_ID)["high_score"], 450000); + + // /live/skip's shape: level 0 matches whatever level is stored, its stand-in score + // of 1 can never beat the record, and the record is what it answers with. + let mut user = userdata::get_acc(token); + let live = update_live_data(&mut user, &object!{ + master_live_id: STOCK_LIVE_ID, + level: 0, + live_score: { score: 1, max_combo: 1 } + }, false, true); + userdata::save_acc(token, user); + assert_eq!(live["high_score"], 450000); + assert_eq!(stored_live(token, STOCK_LIVE_ID)["high_score"], 450000); + } + + #[test] + fn a_public_multi_live_on_a_new_song_records_no_score_at_all() { + let _lock = crate::runtime::lock_test_data_path(); + let token = "sb_public_first_play"; + register_account(token); + + // The first ever play of the song is a public multi: the song gets a live record + // so the clear counts, but there is no high score to show for it. + let live = record_unscored_clear(token, STOCK_LIVE_ID, 4, 900000, 400); + assert_eq!(live["high_score"], 0); + assert_eq!(stored_live(token, STOCK_LIVE_ID)["high_score"], 0); + assert_eq!(pulled_clear_count(token, STOCK_LIVE_ID), 1); + + // A later solo play sets it for real. + record_clear(token, STOCK_LIVE_ID, 4, 300000, 100); + assert_eq!(stored_live(token, STOCK_LIVE_ID)["high_score"], 300000); + } + #[test] fn clear_count_persists_with_custom_songs_disabled() { let _lock = crate::runtime::lock_test_data_path(); diff --git a/src/router/multi_live.rs b/src/router/multi_live.rs new file mode 100644 index 0000000..bad343a --- /dev/null +++ b/src/router/multi_live.rs @@ -0,0 +1,1014 @@ +// Wire format shared with the C# client rewrite; see docs/multi-live-ws-protocol.md. +mod proto; +// Lobby/room state machine, WebSocket-free so it can be tested without a socket. +mod rooms; +// The /multi_live/ws endpoint itself. +mod ws; + +use jzon::{object, JsonValue}; +use actix_web::{web, HttpRequest, Responder}; + +use crate::router::{databases, event, event_ranking, global, items, live, userdata, Session, Api}; + +// The relay's expiry timers (held slots, empty rooms, dead connections). Started once from +// run_server; see ws::start_sweeper for why it is not lazily started by the first upgrade. +pub use ws::start_sweeper; + +pub fn routes(cfg: &mut web::ServiceConfig) { + cfg.service( + web::scope("/multi_live") + .route("/start", web::post().to(start)) + .route("/end", web::post().to(end)) + // The Photon replacement. Note this sits inside the /api scope, so the + // handshake goes through webui_fallback and must carry the usual + // aoharu-asset-version header like every other game request. + .route("/ws", web::get().to(ws::ws)) + ); +} + +// Shock.MULTI_LIVE_END_STATUS, the enum RecvMultiLiveEndRData.is_penalty_miss_ratio +// is cast to (MngLiveData.SendMultiLiveEnd). +// NONE(0) - normal result, client processes every reward list. +// MISS_RATIO_PENALTY_STATUS(1) - MngLiveData bails out right after user/stamina, +// LiveScene.OnMultiLiveEnd leaves the room and +// returns to the multi-event top. Nothing is awarded. +// GREAT_PERFECT_LOW_RATIO_STATUS(2) - rewards still apply, the client only raises +// MultiEventLiveResult.IsOpenCautionDialog. +const STATUS_NONE: u8 = 0; +const STATUS_MISS_RATIO_PENALTY: u8 = 1; +const STATUS_GREAT_PERFECT_LOW_RATIO: u8 = 2; + +// Shock.COMMON_CONST.RATIO_DIVISOR. Every ratio in masterdata (event_score._eventPointRatio +// / ._eventBoostRatio, live_boost._eventPointRatio, multievent_rankbonus._eventPtBonus, +// multievent_card_bonus._pointBonusRatioList) is stored in 1/10000 and divided by this. +const RATIO_ONE: i64 = 10000; + +// Extended-protocol revision this feature requires (X-Protocol-Version, the same ladder +// card.rs=2 / custom_card.rs=3 use). Older client builds carry incompatible multi +// implementations — pre-relay wire framing and pre-rework flows — so every multi_live +// surface (start, end, and the WS upgrade in ws.rs) refuses anything below it. +// 4 = multi-live over the self-hosted WS relay (permanent co-op). +pub const PROTOCOL_VERSION: u32 = 4; + +fn protocol_too_old(req: &HttpRequest) -> bool { + global::client_protocol_version(req) < PROTOCOL_VERSION +} + +fn const_value(id: &str, default: i64) -> i64 { + let raw = &databases::CONST[id]["value"]; + raw.as_str() + .and_then(|v| v.parse::().ok()) + .or_else(|| raw.as_i64()) + .unwrap_or(default) +} + +fn boost_lp(live_boost: i64) -> i64 { + databases::LIVE_BOOST[live_boost.to_string()]["lp"] + .as_i64() + .unwrap_or(10 * live_boost) +} + +fn boost_event_point_ratio(live_boost: i64) -> i64 { + databases::LIVE_BOOST[live_boost.to_string()]["eventPointRatio"] + .as_i64() + .unwrap_or(RATIO_ONE * live_boost.max(1)) +} + +// MusicLevelMst._fullCombo — the note count both client ratio checks divide by. +fn note_count(master_live_id: i64, level: i64) -> Option { + let music_id = databases::LIVE_LIST[master_live_id.to_string()]["masterMusicId"].as_i64()?; + let row = &databases::MUSIC_LEVEL[format!("{}_{}", music_id, level)]; + if row.is_empty() { + return None; + } + let notes = row["fullCombo"].as_i64()?; + if notes <= 0 { None } else { Some(notes) } +} + +// Aoharu.MultiUtil.IsPenalty / IsHalved, evaluated server-side because +// is_penalty_miss_ratio is what the client actually obeys (both MultiUtil helpers +// are unreferenced in the client — they were only ever a local preview). +// +// IsPenalty: (MULTI_PENALTY_MISS_RATIO / 100) * fullCombo <= miss +// IsHalved: perfect + great < (MULTI_EVENT_LIVE_GREAT_PERFECT_NOTES_MIN_RATIO / 100) * fullCombo +// (skipped for LIVE_LEVEL 1, per `if ((int)level == 1) return false;`) +// +// MultiUtil.IsHalved reads Perfect + Good, but only because the client-side +// MultiLiveResultProtocolData(userId, LiveScore, bool) ctor never assigns Great +// (IL2CPP @4769631) — it has no great count to use. The const is named +// ..._GREAT_PERFECT_NOTES_MIN_RATIO and the server does receive the full LiveScore, +// so perfect + great is used here. +// +// MULTI_PENALTY_NO_PLAY_MISS_RATIO (5000) is unreachable as a real miss ratio and is +// the value the ratio takes when nothing was judged at all: a client that never played +// reports 0 misses, which the 70% rule would wave through. +// +// Both checks mirror MultiUtil's "missing MusicLevelMst row => false" shape. +fn multi_live_end_status(body: &JsonValue) -> u8 { + let score = &body["live_score"]; + let notes = match note_count( + body["master_live_id"].as_i64().unwrap_or(0), + body["level"].as_i64().unwrap_or(0) + ) { + Some(n) => n, + None => return STATUS_NONE + }; + + let perfect = score["perfect"].as_i64().unwrap_or(0); + let great = score["great"].as_i64().unwrap_or(0); + let good = score["good"].as_i64().unwrap_or(0); + let bad = score["bad"].as_i64().unwrap_or(0); + let miss = score["miss"].as_i64().unwrap_or(0); + + let penalty_ratio = const_value("MULTI_PENALTY_MISS_RATIO", 70); + let no_play_ratio = const_value("MULTI_PENALTY_NO_PLAY_MISS_RATIO", 5000); + + let judged = perfect + great + good + bad + miss; + let penalised = if judged == 0 { + no_play_ratio >= penalty_ratio + } else { + miss * 100 >= penalty_ratio * notes + }; + if penalised { + return STATUS_MISS_RATIO_PENALTY; + } + + if body["level"].as_i64().unwrap_or(0) != 1 { + let min_ratio = const_value("MULTI_EVENT_LIVE_GREAT_PERFECT_NOTES_MIN_RATIO", 50); + if (perfect + great) * 100 < min_ratio * notes { + return STATUS_GREAT_PERFECT_LOW_RATIO; + } + } + + STATUS_NONE +} + +// The event-point yield of one multi live, from event_score.csv (Shock.EventScoreMst). +// +// Neither ew nor the reconstructed client had an existing consumer to copy: EventData +// exposes _eventLivePointBase / _eventPointRatio / _eventBoostRatio as EventLiveBasePoint +// / EventPointRatio / EventBoostRatio (EventData.cs:134-156) but nothing in the client +// ever reads those three properties — the award has always been computed server-side. +// The interpretation used here is the one the field names and the client's own ratio +// convention dictate, and the one this port is specified against: +// +// points = _eventLivePointBase +// * _eventPointRatio / RATIO_DIVISOR (per-event scaling) +// * _eventBoostRatio / RATIO_DIVISOR (per-event boost worth) +// * live_boost._eventPointRatio / RATIO_DIVISOR (the boost actually spent) +// +// The last factor mirrors LiveBoostMst.GetEventPointRatio (LiveBoostMst.cs:131-137), +// which is `_eventPointRatio / RATIO_DIVISOR` — the boost level itself for the stock +// table (boost 3 -> 30000/10000 -> 3). +// +// All four multi events (108/111/115/119) carry base 10, pointRatio 10000, boostRatio +// 35000, so one boost-1 multi live is worth 35 points. +// +// Division is deferred to the end so the x3.5 boost ratio does not truncate to x3 the +// way the client's integer GetEventPointRatio would. +fn event_live_points(event_id: u32, live_boost: i64) -> i64 { + let row = &databases::EVENT_SCORE[event_id.to_string()]; + if row.is_empty() { + // Should be unreachable: event_score.csv has a row for every event, including + // all four multi events. An event with no row is worth nothing rather than + // silently falling back to an invented constant. + println!("multi_live: no event_score row for event {event_id}, awarding 0 event points"); + return 0; + } + let base = row["eventLivePointBase"].as_i64().unwrap_or(0); + let point_ratio = row["eventPointRatio"].as_i64().unwrap_or(0); + let boost_ratio = row["eventBoostRatio"].as_i64().unwrap_or(0); + + base * point_ratio * boost_ratio * boost_event_point_ratio(live_boost) + / (RATIO_ONE * RATIO_ONE * RATIO_ONE) +} + +// The stamina this live actually cost, as recorded by /multi_live/start. This is what +// live_end_ex scales every reward off, and it is deliberately read without reference to +// the backing event: a closed event suppresses scoring, never rewards. +fn recorded_lp(started: Option<&JsonValue>) -> i64 { + let live_boost = started.and_then(|s| s["live_boost"].as_i64()).unwrap_or(0); + started + .and_then(|s| s["use_lp"].as_i64()) + .unwrap_or_else(|| boost_lp(live_boost)) + .max(0) +} + +// The event a multi live should actually score against, or None when it should score +// against nothing. +// +// Multi is a permanent feature here, entered from a client-side button rather than from +// a live event, so the client faithfully sends the backing event id (108) even though +// that event is closed and stays closed by choice. Awarding against a closed event would +// write event points and ranking rows for a season that is not running, so the whole +// event-point path stays dormant — and lights up on its own, with no further change +// here, if an event is ever actually opened. +// +// Nothing else about the live is affected: rewards, EXP, bond, missions and the response +// shape all come out of live_end_ex exactly as they do for an in-session event. +fn scoring_event(started: Option<&JsonValue>, now: u64) -> Option { + started + .and_then(|s| s["master_event_id"].as_u32()) + .filter(|id| *id != 0) + .filter(|id| event::is_in_session(*id, now)) +} + +// The whole award for one multi live: the event_score yield, the finishing-position +// bonus from multievent_rankbonus, and the GREAT_PERFECT_LOW_RATIO halving. +fn multi_event_points(event_id: u32, live_boost: i64, players: i64, live_rank: i64, status: u8) -> i64 { + let mut points = event_live_points(event_id, live_boost); + points = points * (RATIO_ONE + rank_bonus(players, live_rank)) / RATIO_ONE; + if status == STATUS_GREAT_PERFECT_LOW_RATIO { + // MultiUtil calls this state "halved" — the caution dialog goes with a + // reduced yield, not a forfeited one. + points /= 2; + } + points +} + +// multievent_rankbonus._eventPtBonus for this party size / finishing position. +// The table only covers 2-4 players with liveRank <= playerCount; anything outside +// it (a solo room, a rank the table has no row for) simply earns no bonus. +fn rank_bonus(player_count: i64, live_rank: i64) -> i64 { + databases::MULTIEVENT_RANK_BONUS[format!("{}_{}", player_count, live_rank)]["eventPtBonus"] + .as_i64() + .unwrap_or(0) +} + +// LiveScene.MultiTask4 builds other_live_score_list from MultiPlayManager.AllPlayers, +// so the poster is already one of its entries. Fall back to len + 1 if a caller ever +// sends a list that genuinely excludes itself. +fn player_count(body: &JsonValue, user_id: i64) -> i64 { + let list = &body["other_live_score_list"]; + let contains_self = list + .members() + .any(|s| s["user_id"].as_i64() == Some(user_id)); + let len = list.len() as i64; + if contains_self { len } else { len + 1 } +} + +// Every field of Shock.RecvMultiLiveEndRData, so the penalty path still deserialises +// cleanly. The client reads user/stamina and then returns on status 1, but Notify() +// runs over the whole payload first. +fn barren_response(user: &JsonValue, status: u8) -> JsonValue { + object!{ + "is_penalty_miss_ratio": status, + "gem": user["gem"].clone(), + "clear_master_live_mission_ids": [], + "user": user["user"].clone(), + "stamina": user["stamina"].clone(), + "character_list": [], + "card_list": [], + "card_sub_list": [], + "item_list": [], + "point_list": [], + "group_list": [], + "reward_list": [], + "gift_list": [], + "clear_mission_ids": [], + "event_point_list": user["event_point_list"].clone(), + "event_point_reward_list": [], + "ranking_change": [], + "music_mission_reward_list": [], + "event_ranking_data": { + "event_point_rank": 0, + "next_reward_rank_point": 0, + "event_score_rank": 0, + "next_reward_rank_score": 0 + } + } +} + +// Unlike /live/start, a multi live pays its stamina up front: the response reports +// consumed_stamina and the client never sends a boost with /multi_live/end. The +// amount actually taken is stashed on the recorded start payload so /multi_live/end +// can scale rewards off it without charging for it twice. +async fn start(req: HttpRequest, Session { key, mut body }: Session) -> impl Responder { + // Older clients speak an incompatible multi — refuse before touching any state. + if protocol_too_old(&req) { + return Api(None); + } + // `token` is the room-party correlation key ("{userId}.{guid}") that all up to four + // party members post. Nothing about STARTING a live depends on which room it came + // from — the one thing read out of it is the room's privacy, and that is read here + // rather than at /multi_live/end because here the room is certainly still alive (see + // record_room_privacy). Reconciling the party itself — checking the four results + // against each other — is still deferred. + // master_event_id is recorded verbatim and never validated here: multi is a permanent + // feature entered against a closed event (see scoring_event), so a closed — or absent + // — event must start a live exactly like an open one. Stamina comes off live_boost + // alone, so nothing on this path depends on the event being in session. + let live_boost = body["live_boost"].as_i64().unwrap_or(0); + let lp = boost_lp(live_boost).max(0); + + let mut user = userdata::get_acc(&key); + // Settle regen first so the balance clamped against below is current. + items::lp_modification(&mut user, 0, true); + let available = user["stamina"]["stamina"].as_u64().unwrap_or(0); + // Clamped, not refused, and that IS the intended answer. + // + // The client gates on stamina at the entry to matching, never at the POST: + // MultiSelectionView.RoomCreation / OpenRoomSearchDialog and MultiRestart.RestartLive + // all run StaminaUtils.UseStaminaValue and divert to StaminaChargeScene when it says + // the balance is short, while MultiEventMatchingScene.ChangeScene — which is what + // actually calls SendMultiLiveStart, and does so off the host's _MoveScene RPC — + // computes the cost and drops the "insufficient" flag on the floor. Stamina only ever + // goes up between the gate and the POST (regen; the boost selector lives on the gated + // panels), so an honest client cannot arrive here short, and the official server was + // never asked what to do about it. + // + // So this is the unreachable-in-practice branch, and taking what is there is the + // benign resolution: the live still starts (a party of four must not be broken up by + // one member's balance), consumed_stamina reports what was really taken, and because + // every reward scales off that same recorded use_lp — see recorded_lp and live_end_ex — + // a short live pays out in proportion. Refusing would have to fail a live the other + // three players are already committed to; charging the full cost would mean inventing + // negative stamina. + let consumed = (lp as u64).min(available); + items::lp_modification(&mut user, consumed, true); + userdata::save_acc(&key, user); + + body["use_lp"] = consumed.into(); + record_room_privacy(&mut body); + live::start_live(&key, &body); + + Api(Some(object!{ + "consumed_stamina": consumed + })) +} + +// Whether this live was played in a private (join-by-code) party, which is the one thing +// /multi_live/end reads the room `token` for. +// +// Officially a multi live never updated the score board at all — the client says so on the +// result screen. That stays true for PUBLIC matchmaking; a private party of friends is a +// deliberate divergence and keeps its scores. The room is looked up in the relay's live +// registry, which sits in this same process, so nothing new travels on the wire. +// +// Note the privacy answer is the room's CREATION-time one. The current visible/open flags +// cannot be used: the game hides a public room too, as soon as its members are decided +// (MultiMatchingView.SetRoomOpenVisible(false)), so every room in a live looks unlisted. +fn is_private_party(body: &JsonValue) -> bool { + rooms::token_is_private_room(body["token"].as_str().unwrap_or_default()) +} + +// The key the answer above is stashed under on the start record. +const RECORDED_PRIVATE: &str = "room_private"; + +// Asked once, at /multi_live/start, and written onto the payload start_live records. +// +// Asking at /multi_live/end instead made the answer depend on WHEN each of the up to four +// party members got its POST in: the room dies with its last clean leave, so an ender that +// posted after the party broke up saw no room and fell back to "public" while the others +// had already been told "private" — the same live scored differently per player, and a +// private party silently lost its score board. At start time the room is by definition +// alive (its master minted the token moments earlier and every member is still seated), so +// every member records the same flag off the same room. +// +// A registry miss records nothing at all rather than `false`: the end-side lookup is kept +// as the fallback for records written before this existed, and "absent" is what selects it. +fn record_room_privacy(body: &mut JsonValue) { + if let Some(private) = rooms::token_room_privacy(body["token"].as_str().unwrap_or_default()) { + body[RECORDED_PRIVATE] = private.into(); + } +} + +// What /multi_live/end obeys: the flag recorded at start when there is one, and the live +// registry lookup only for a record that predates it. +fn recorded_privacy(started: Option<&JsonValue>, body: &JsonValue) -> bool { + match started.and_then(|s| s[RECORDED_PRIVATE].as_bool()) { + Some(private) => private, + None => is_private_party(body) + } +} + +async fn end(req: HttpRequest, Session { key, body }: Session) -> impl Responder { + // Older clients speak an incompatible multi — refuse before touching any state + // (in particular before the start record can be consumed). + if protocol_too_old(&req) { + return Api(None); + } + // Loaded once and reused by every path that answers without playing the live; the + // live itself re-reads it, because live_end_ex saves the account. + let account = userdata::get_acc(&key); + // The user's own clock, so a time-travelling account sees the same event window the + // rest of the game shows it. + let uid = account["user"]["id"].as_i64().unwrap_or(0); + + // A body with no live id is answered like a duplicate: it cannot name a start record + // (matching one on a null id would let it claim a record started with the same + // malformed body) and nothing downstream can score it. + if body["master_live_id"].as_i64().is_none() { + println!("multi_live/end: uid {} posted no master_live_id — nothing to end", uid); + return Api(Some(barren_response(&account, STATUS_NONE))); + } + + // The started-live record is what makes an end legitimate, and it is CLAIMED here: + // take_started_live returns it and removes it in one transaction, before anything is + // awarded, so of N ends arriving together exactly one can proceed. The consumption + // used to be a side effect of get_end_live_deck_id deep inside live_end_ex — which + // only fired when the record carried a numeric deck_slot, and fired long after the + // award had been decided, so a re-POST could be paid twice over. + // + // Failing the take means this live was already ended: two clients signed in to the + // SAME account both POST /multi_live/end for the one shared record, or the client + // retried a request whose response it never saw. Granting again would double-count the + // clear, the clear-rate counter, the play-count mission and the flat 17001001 drop. + // So the duplicate is answered from current state: a well-formed result the client can + // display, awarding nothing. + let started = live::take_started_live(&key, &body); + let started = started.as_ref(); + if started.is_none() { + println!("multi_live/end: uid {} has no start record to spend — answering barren", uid); + return Api(Some(barren_response(&account, STATUS_NONE))); + } + + let live_boost = started.and_then(|s| s["live_boost"].as_i64()).unwrap_or(0); + let lp_used = recorded_lp(started); + let event_id = scoring_event(started, global::set_time(global::timestamp(), uid, false)); + + let status = multi_live_end_status(&body); + println!( + "multi_live/end: uid {} score {} miss-ratio status {} ({})", + uid, + body["live_score"]["score"].as_i64().unwrap_or(-1), + status, + match status { + STATUS_MISS_RATIO_PENALTY => "PENALTY — results voided", + 2 => "great/perfect low — points halved", + _ => "ok", + } + ); + + if status == STATUS_MISS_RATIO_PENALTY { + // The client discards the result and leaves the room, so nothing is granted. The + // record is already gone (claimed above), so the live is not left hanging until it + // expires either — and a re-POST of the same penalised result lands on the + // no-record branch rather than back here. + return Api(Some(barren_response(&account, status))); + } + + // /multi_live/end carries neither live_boost nor deck_slot; use_lp is what + // live_end scales exp / gold / bond / mission rewards off. + let mut end_body = body.clone(); + end_body["use_lp"] = lp_used.into(); + if end_body["deck_slot"].is_null() { + if let Some(slot) = started.and_then(|s| s["deck_slot"].as_i32()) { + end_body["deck_slot"] = slot.into(); + } + } + + // A public room scores like the official server did: no high score, no score board. + // Read off the start record, so every member of one party gets the same answer no + // matter how late its POST lands. Only a record from before that was recorded falls + // back to a live lookup, where a room the registry no longer knows about (torn down, + // or a restart since the live started) is treated as public — the behaviour to be + // wrong on. + let private = recorded_privacy(started, &body); + println!( + "multi_live/end: uid {} room is {} — score board {}", + uid, + if private { "PRIVATE" } else { "PUBLIC/unknown" }, + if private { "updated" } else { "skipped (official)" } + ); + + let mut rv = live::live_end_ex(&req, &key, &end_body, false, false, private); + + rv["is_penalty_miss_ratio"] = status.into(); + // Fields RecvMultiLiveEndRData declares that live_end does not emit. + rv["card_list"] = jzon::array![]; + rv["card_sub_list"] = jzon::array![]; + rv["group_list"] = jzon::array![]; + rv["music_mission_reward_list"] = jzon::array![]; + // MngLiveData.SendMultiLiveEnd dereferences event_ranking_data unconditionally, + // so it must be an object even when this live belongs to no event. + rv["event_ranking_data"] = object!{ + "event_point_rank": 0, + "next_reward_rank_point": 0, + "event_score_rank": 0, + "next_reward_rank_score": 0 + }; + + if let Some(event_id) = event_id { + // live_end already saved the account; re-read it before touching event points. + let mut user = userdata::get_acc(&key); + let user_id = user["user"]["id"].as_i64().unwrap_or(0); + + let players = player_count(&body, user_id); + let live_rank = body["multi_live_rank"].as_i64().unwrap_or(0); + + let points = multi_event_points(event_id, live_boost, players, live_rank, status); + + event::give_event_points(event_id, points, &mut user); + userdata::save_acc(&key, user.clone()); + + let total = event::get_points(event_id, &user); + event_ranking::live_completed(event_id, user_id, total, 0); + let rank = event::get_rank(event_id, user_id as u64); + + let mut event = event::get_event_data(&key, event_id); + event["point_ranking"]["point"] = total.into(); + event["point_ranking"]["rank"] = rank.into(); + event::save_event_data(&key, event_id, event); + + rv["event_point_list"] = user["event_point_list"].clone(); + rv["event_ranking_data"] = object!{ + "event_point_rank": rank, + "next_reward_rank_point": 0, + "event_score_rank": rank, + "next_reward_rank_score": 0 + }; + } + + Api(Some(rv)) +} + +#[cfg(test)] +mod tests { + use super::*; + use super::proto::{ClientMsg, Map, Value}; + use jzon::array; + use std::time::Instant; + + const STOCK_LIVE_ID: i64 = 1100101; + + #[test] + fn multi_consts_resolve_from_masterdata() { + assert_eq!(const_value("MULTI_PENALTY_MISS_RATIO", -1), 70); + assert_eq!(const_value("MULTI_PENALTY_NO_PLAY_MISS_RATIO", -1), 5000); + assert_eq!(const_value("MULTI_EVENT_LIVE_GREAT_PERFECT_NOTES_MIN_RATIO", -1), 50); + assert_eq!(const_value("NOT_A_CONST", -1), -1); + } + + #[test] + fn boost_costs_come_from_live_boost() { + assert_eq!(boost_lp(1), 10); + assert_eq!(boost_lp(10), 100); + assert_eq!(boost_event_point_ratio(1), RATIO_ONE); + assert_eq!(boost_event_point_ratio(3), 3 * RATIO_ONE); + } + + // The four multi events from multievent_setting.csv, all sharing one event_score row + // shape: _eventLivePointBase 10, _eventPointRatio 10000, _eventBoostRatio 35000. + const MULTI_EVENTS: [u32; 4] = [108, 111, 115, 119]; + + #[test] + fn event_score_rows_back_every_multi_event() { + for event_id in MULTI_EVENTS { + let row = &databases::EVENT_SCORE[event_id.to_string()]; + assert!(!row.is_empty(), "event_score row missing for event {event_id}"); + assert_eq!(row["eventLivePointBase"].as_i64(), Some(10)); + assert_eq!(row["eventPointRatio"].as_i64(), Some(RATIO_ONE)); + assert_eq!(row["eventBoostRatio"].as_i64(), Some(35000)); + } + } + + #[test] + fn event_live_points_derive_from_event_score() { + // 10 * (10000/10000) * (35000/10000) * boost — and the x3.5 must not truncate + // to x3 on the way through. + assert_eq!(event_live_points(108, 1), 35); + assert_eq!(event_live_points(108, 2), 70); + assert_eq!(event_live_points(108, 10), 350); + for event_id in MULTI_EVENTS { + assert_eq!(event_live_points(event_id, 1), 35); + } + } + + #[test] + fn event_live_points_fall_back_to_zero_without_a_row() { + assert!(databases::EVENT_SCORE["99999"].is_empty()); + assert_eq!(event_live_points(99999, 1), 0); + assert_eq!(multi_event_points(99999, 1, 4, 1, STATUS_NONE), 0); + } + + #[test] + fn multi_event_points_layer_rank_bonus_and_halving() { + // 1st of 4 is +30% (multievent_rankbonus 4/1 = 3000). + assert_eq!(multi_event_points(108, 1, 4, 1, STATUS_NONE), 45); + // Last place earns the flat yield. + assert_eq!(multi_event_points(108, 1, 4, 4, STATUS_NONE), 35); + // The caution state halves whatever the bonus produced. + assert_eq!(multi_event_points(108, 1, 4, 1, STATUS_GREAT_PERFECT_LOW_RATIO), 22); + assert_eq!(multi_event_points(108, 1, 4, 4, STATUS_GREAT_PERFECT_LOW_RATIO), 17); + } + + // release_label 223061504, event 108's window: 2023/06/19 05:00:00 - 2023/06/28 04:59:59. + fn during_multi_event_108() -> u64 { + global::parse_datetime("2023/06/20 12:00:00").unwrap() + } + + #[test] + fn masterdata_datetimes_round_trip() { + let t = global::parse_datetime("2023/06/19 5:00:00").unwrap(); + assert_eq!(global::format_datetime(t), "2023-06-19 05:00:00"); + // A bare date is midnight, and the blank cells the evergreen label uses parse + // to nothing rather than to the epoch. + assert_eq!( + global::parse_datetime("2023/06/19"), + global::parse_datetime("2023/06/19 0:00:00") + ); + assert_eq!(global::parse_datetime(""), None); + assert_eq!(global::parse_datetime("null"), None); + } + + #[test] + fn the_multi_backing_event_is_closed_by_design() { + // The evergreen label has no window at all and is always open. + assert!(event::release_label_is_open(1, during_multi_event_108())); + + // Event 108 is in session only inside its own long-past label window... + assert!(event::is_in_session(108, during_multi_event_108())); + // ...and is closed now, which is the permanent-feature state multi runs in. + // global::timestamp() rather than set_time: set_time reads crate::get_args(), + // whose clap parser chokes on the test-harness filter argument. + let now = global::timestamp(); + assert!(!event::is_in_session(108, now), "event 108 must stay closed"); + for event_id in MULTI_EVENTS { + assert!(!event::is_in_session(event_id, now)); + } + } + + #[test] + fn a_closed_backing_event_scores_against_nothing() { + let started = object!{ master_event_id: 108, live_boost: 1 }; + + // Closed today: the whole event-point path is skipped. + // global::timestamp() rather than set_time: set_time reads crate::get_args(), + // whose clap parser chokes on the test-harness filter argument. + let now = global::timestamp(); + assert_eq!(scoring_event(Some(&started), now), None); + + // But the gate is a window test, not a hardcoded off switch — open the event + // and scoring resumes on its own. + assert_eq!(scoring_event(Some(&started), during_multi_event_108()), Some(108)); + + // A live with no backing event at all, and a missing start record, score nothing. + assert_eq!(scoring_event(Some(&object!{ master_event_id: 0 }), during_multi_event_108()), None); + assert_eq!(scoring_event(None, during_multi_event_108()), None); + } + + // The end-to-end path cannot be exercised here: live_end_ex calls items::get_region, + // which reaches crate::get_args(), whose clap parser rejects the test harness's own + // filter argument. (live.rs's tests avoid live_end for the same reason.) What is + // testable, and what actually matters, is that the reward input is derived with no + // reference to the event window — so a closed event can only ever suppress scoring. + #[test] + fn a_closed_event_still_pays_its_normal_rewards() { + let started = object!{ master_event_id: 108, live_boost: 1, use_lp: 10, deck_slot: 2 }; + let open = during_multi_event_108(); + // global::timestamp() rather than set_time: set_time reads crate::get_args(), + // whose clap parser chokes on the test-harness filter argument. + let closed = global::timestamp(); + + // The event gate is the only thing that moves between the two. + assert_eq!(scoring_event(Some(&started), open), Some(108)); + assert_eq!(scoring_event(Some(&started), closed), None); + + // The reward input is identical either way, and identical to what an eventless + // live would get for the same boost. + assert_eq!(recorded_lp(Some(&started)), 10); + assert_eq!(recorded_lp(Some(&object!{ live_boost: 1, use_lp: 10 })), 10); + // A start record with no recorded charge still falls back to the boost's cost. + assert_eq!(recorded_lp(Some(&object!{ master_event_id: 108, live_boost: 3 })), 30); + assert_eq!(recorded_lp(None), 0); + + // And a suppressed event awards nothing, where an open one would pay 35. + assert_eq!(multi_event_points(108, 1, 4, 4, STATUS_NONE), 35); + } + + // --- duplicate protection (the claim that gates the award) ---------------------- + // + // /multi_live/end awards if and only if take_started_live hands it the record, and the + // record can be handed out exactly once. These drive that claim directly: the handler + // itself cannot be called from a test (live_end_ex reaches items::get_region, which + // reaches crate::get_args(), whose clap parser rejects the harness's own arguments). + + // Two clients signed in to one account share a single started-live record, so the + // second /multi_live/end arrives after the first consumed it. The handler answers that + // from current state and grants nothing; this pins the state machine it keys off. + #[test] + fn a_second_end_for_one_session_finds_no_record_to_spend() { + let _lock = crate::runtime::lock_test_data_path(); + let token = "multi_duplicate_end"; + + let start_body = object!{ + master_live_id: STOCK_LIVE_ID, + level: 4, + deck_slot: 1, + live_boost: 1, + master_event_id: 108, + use_lp: 10 + }; + live::start_live(token, &start_body); + + // First end: the record is there and pays for the live, and claiming it is what + // the handler does BEFORE it awards anything. + let started = live::take_started_live(token, &start_body); + assert!(started.is_some()); + assert_eq!(recorded_lp(started.as_ref()), 10); + + // Second end: nothing left to spend, which is the duplicate signal the handler + // short-circuits on, and the reward scale is zero even if it did not. + let again = live::take_started_live(token, &start_body); + assert!(again.is_none(), "the record must not survive the first end"); + assert_eq!(recorded_lp(again.as_ref()), 0); + assert_eq!(scoring_event(again.as_ref(), during_multi_event_108()), None); + // And it stays gone however many times the client re-POSTs. + assert!(live::take_started_live(token, &start_body).is_none()); + assert!(live::get_started_live(token, &start_body).is_none()); + } + + // The consumption used to hide inside get_end_live_deck_id, behind + // `record["deck_slot"].as_i32()?` — a start whose body carried no numeric deck_slot + // left the record in place, so every re-POST awarded again. Claiming is now about the + // record's existence and nothing else. + #[test] + fn a_start_with_no_deck_slot_is_still_consumed_by_the_first_end() { + let _lock = crate::runtime::lock_test_data_path(); + let token = "multi_no_deck_slot"; + + for start_body in [ + // No deck_slot at all... + object!{ master_live_id: STOCK_LIVE_ID, level: 4, live_boost: 1, use_lp: 10 }, + // ...and one that is present but not a number. + object!{ master_live_id: STOCK_LIVE_ID, level: 4, live_boost: 1, use_lp: 10, deck_slot: "1" } + ] { + live::start_live(token, &start_body); + let first = live::take_started_live(token, &start_body); + assert!(first.is_some(), "the record must be claimable without a deck_slot"); + assert_eq!(recorded_lp(first.as_ref()), 10); + + let second = live::take_started_live(token, &start_body); + assert!(second.is_none(), "a re-POST must find nothing left to award off"); + } + } + + // Two ends landing together — the normal case for one account signed in twice, and + // the one the old shape got wrong: both read the record, both passed the guard, both + // awarded. The claim is a single transaction, so exactly one of any number of racing + // ends can proceed. + #[test] + fn concurrent_ends_claim_the_record_exactly_once() { + let _lock = crate::runtime::lock_test_data_path(); + let token = "multi_concurrent_end"; + + let start_body = object!{ + master_live_id: STOCK_LIVE_ID, + level: 4, + deck_slot: 1, + live_boost: 1, + master_event_id: 108, + use_lp: 10 + }; + // Create the account before the threads start: the claim itself is atomic, the + // lazy account creation behind it is not, and that is not what is under test. + live::start_live(token, &start_body); + + let claims = std::sync::atomic::AtomicUsize::new(0); + std::thread::scope(|s| { + for _ in 0..8 { + s.spawn(|| { + if live::take_started_live(token, &start_body).is_some() { + claims.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + } + }); + } + }); + + assert_eq!( + claims.load(std::sync::atomic::Ordering::SeqCst), + 1, + "exactly one of eight simultaneous ends may award" + ); + assert!(live::get_started_live(token, &start_body).is_none()); + } + + #[test] + fn rank_bonus_matches_multievent_rankbonus() { + assert_eq!(rank_bonus(4, 1), 3000); + assert_eq!(rank_bonus(4, 4), 0); + assert_eq!(rank_bonus(2, 1), 1000); + // Rows the table does not cover earn nothing rather than panicking. + assert_eq!(rank_bonus(1, 1), 0); + assert_eq!(rank_bonus(4, 9), 0); + } + + fn score(perfect: i64, great: i64, good: i64, bad: i64, miss: i64) -> JsonValue { + object!{ + master_live_id: STOCK_LIVE_ID, + level: 4, + live_score: { + perfect: perfect, great: great, good: good, bad: bad, miss: miss + } + } + } + + #[test] + fn end_status_follows_multiutil_ratios() { + let notes = note_count(STOCK_LIVE_ID, 4).expect("music_level row"); + + // A clean full combo is normal. + assert_eq!(multi_live_end_status(&score(notes, 0, 0, 0, 0)), STATUS_NONE); + + // 70% or more of the notes missed trips the penalty. + assert_eq!(multi_live_end_status(&score(0, 0, 0, 0, notes)), STATUS_MISS_RATIO_PENALTY); + + // Nothing judged at all is the "no play" case the 70% rule would wave through. + assert_eq!(multi_live_end_status(&score(0, 0, 0, 0, 0)), STATUS_MISS_RATIO_PENALTY); + + // Under half the notes as perfect/great, but few enough misses to avoid the + // penalty, is the caution ("halved") state. + let goods = notes - (notes / 4) - 1; + assert_eq!( + multi_live_end_status(&score(notes / 4, 0, goods, 0, 1)), + STATUS_GREAT_PERFECT_LOW_RATIO + ); + + // LIVE_LEVEL 1 is exempt from the great/perfect check. + let mut beginner = score(0, 0, 0, 0, 0); + beginner["level"] = 1.into(); + beginner["live_score"]["good"] = note_count(STOCK_LIVE_ID, 1).unwrap().into(); + assert_eq!(multi_live_end_status(&beginner), STATUS_NONE); + + // A live with no MusicLevelMst row never penalises, like MultiUtil. + let mut unknown = score(0, 0, 0, 0, 0); + unknown["master_live_id"] = 999999999i64.into(); + assert_eq!(multi_live_end_status(&unknown), STATUS_NONE); + } + + // --- private vs public rooms (the score-board branch) -------------------------- + // + // The relay runs in this process, so the end handler can ask the room registry what + // kind of room a finishing live belongs to. These drive the REAL (global) registry + // through the same entry points ws.rs uses, then ask is_private_party exactly what the + // handler asks it. Room names and tokens are unique per test, so they do not collide + // with each other in the shared registry. + + fn open_room(name: &str, visible: bool, token: &str) -> rooms::ConnId { + // The receiver is dropped immediately: nothing here reads the relay's outbound + // frames, and a send to a gone writer is already a no-op (Registry::send). + let (tx, _rx) = tokio::sync::mpsc::unbounded_channel(); + let mut reg = rooms::registry(); + let id = reg.connect(1, tx, Instant::now()); + reg.handle(id, ClientMsg::CreateRoom { + name: name.to_string(), + max_players: 4, + visible, + open: true, + props: Map::new(), + lobby_prop_keys: vec![], + player_props: Map::new(), + }, Instant::now()); + // MultiEventMatchingScene mints the token and pushes the room bag just before the + // live starts; it is the same string the client then POSTs to /multi_live/end. + reg.handle(id, ClientMsg::SetRoomProps { + props: Map::from_pairs(vec![("C", Value::Str(token.to_string()))]), + }, Instant::now()); + id + } + + fn set_room_props(id: rooms::ConnId, props: Vec<(&str, Value)>) { + rooms::registry().handle(id, ClientMsg::SetRoomProps { props: Map::from_pairs(props) }, Instant::now()); + } + + // A clean leave by the last active player destroys the room, exactly as a party + // breaking up after the results does. + fn close_room(id: rooms::ConnId) { + rooms::registry().handle(id, ClientMsg::LeaveRoom, Instant::now()); + } + + #[test] + fn a_private_party_is_recognised_by_its_room_token() { + let room = open_room("042719", false, "1.private-party"); + assert!(is_private_party(&object!{ token: "1.private-party" })); + close_room(room); + } + + #[test] + fn a_public_room_stays_public_once_the_game_hides_it() { + // The whole reason privacy is latched at creation: MultiMatchingView closes and + // hides a PUBLIC room the moment its members are decided, so mid-live its flags are + // indistinguishable from a private room's. + let room = open_room("1234567", true, "2.public-room"); + assert!(!is_private_party(&object!{ token: "2.public-room" })); + + set_room_props(room, vec![("#253", Value::Bool(false)), ("#254", Value::Bool(false))]); + assert!( + !is_private_party(&object!{ token: "2.public-room" }), + "a hidden public room must not start updating score boards" + ); + close_room(room); + } + + #[test] + fn a_token_no_room_claims_falls_back_to_public() { + // Torn down before the end arrived, or a restart since the live started. + let room = open_room("3336667", true, "3.gone-by-then"); + close_room(room); + assert!(!is_private_party(&object!{ token: "3.gone-by-then" })); + + // Never existed, and an end with no token at all. + assert!(!is_private_party(&object!{ token: "4.never-existed" })); + assert!(!is_private_party(&object!{})); + } + + // --- privacy is decided ONCE, at start ------------------------------------------ + + #[test] + fn the_privacy_answer_is_recorded_at_start_and_outlives_the_room() { + let room = open_room("551234", false, "5.recorded-private"); + let mut start_body = object!{ token: "5.recorded-private", master_live_id: STOCK_LIVE_ID }; + record_room_privacy(&mut start_body); + assert_eq!(start_body["room_private"].as_bool(), Some(true)); + + // The party breaks up: the room is gone by the time the ends land. + close_room(room); + assert!(!is_private_party(&start_body), "the live lookup can no longer answer"); + + // The recorded answer still does, so the score board is still updated. + assert!(recorded_privacy(Some(&start_body), &start_body)); + } + + #[test] + fn every_ender_of_one_party_reads_the_same_answer() { + // Four members, one room, four /multi_live/start posts — and then the ends arrive + // spread out, the last one after the room has been torn down. Asking the registry + // at END time made the last poster's live PUBLIC while the first three's were + // PRIVATE: one live, scored two different ways. + let room = open_room("552345", false, "6.four-enders"); + let mut records: Vec = (0..4) + .map(|_| object!{ token: "6.four-enders", master_live_id: STOCK_LIVE_ID }) + .collect(); + for record in records.iter_mut() { + record_room_privacy(record); + } + + // Three end while the room is alive, the fourth after it is gone. + let end_body = object!{ token: "6.four-enders" }; + let mut answers: Vec = records[..3] + .iter() + .map(|r| recorded_privacy(Some(r), &end_body)) + .collect(); + close_room(room); + answers.push(recorded_privacy(Some(&records[3]), &end_body)); + + assert_eq!(answers, vec![true; 4], "one live must score one way for everybody"); + } + + #[test] + fn a_registry_miss_at_end_no_longer_flips_a_private_live_to_public() { + let room = open_room("553456", false, "7.miss-at-end"); + let mut start_body = object!{ token: "7.miss-at-end" }; + record_room_privacy(&mut start_body); + close_room(room); + + // The end-time lookup misses and resolves to public... + assert!(!is_private_party(&start_body)); + // ...but it is not what is asked any more. + assert!(recorded_privacy(Some(&start_body), &start_body)); + } + + #[test] + fn a_public_room_records_public_and_stays_public() { + let room = open_room("554567", true, "8.recorded-public"); + let mut start_body = object!{ token: "8.recorded-public" }; + record_room_privacy(&mut start_body); + assert_eq!(start_body["room_private"].as_bool(), Some(false)); + assert!(!recorded_privacy(Some(&start_body), &start_body)); + close_room(room); + assert!(!recorded_privacy(Some(&start_body), &start_body)); + } + + #[test] + fn a_record_with_no_recorded_answer_falls_back_to_the_live_lookup() { + // Started before this was recorded, or started against a token the registry did + // not know (a start POST that arrived after its own room died). Nothing is written + // in that case, deliberately, so the fallback is what selects it. + let room = open_room("555678", false, "9.no-record"); + let mut missed = object!{ token: "9.never-a-room" }; + record_room_privacy(&mut missed); + assert!(missed["room_private"].is_null(), "a miss must record nothing"); + + let legacy = object!{ live_boost: 1 }; + let end_body = object!{ token: "9.no-record" }; + assert!(recorded_privacy(Some(&legacy), &end_body), "falls back to the live room"); + close_room(room); + assert!(!recorded_privacy(Some(&legacy), &end_body), "and to public once it is gone"); + } + + #[test] + fn player_count_does_not_double_count_the_poster() { + let body = object!{ + other_live_score_list: array![ + object!{ user_id: 7 }, + object!{ user_id: 8 }, + object!{ user_id: 9 } + ] + }; + // LiveScene includes the poster in the list. + assert_eq!(player_count(&body, 8), 3); + // A list that omits the poster still yields the real party size. + assert_eq!(player_count(&body, 42), 4); + } +} diff --git a/src/router/multi_live/proto.rs b/src/router/multi_live/proto.rs new file mode 100644 index 0000000..309006b --- /dev/null +++ b/src/router/multi_live/proto.rs @@ -0,0 +1,970 @@ +// Wire format for the multi-live WebSocket relay, per docs/multi-live-ws-protocol.md. +// +// The C# client rewrite encodes/decodes the exact same bytes, so this module is a +// straight transcription of the spec tables rather than idiomatic Rust: every field is +// written in declaration order, all integers are little-endian, strings are UTF-8 behind +// a u16 length, and counts are u8. Nothing here is serde-driven - the type tags mirror +// Photon's Hashtable boxing (an Int32 must arrive as Int32 or GetCurrentRoomEventId's +// exact `is int` check fails), and a derive would hide that. +// +// Both directions are implemented in both halves (encode + decode for client messages +// AND for server messages) even though the relay only ever decodes the former and +// encodes the latter. The unused halves are what the round-trip tests exercise, and they +// double as the reference the C# side is written against. + +use std::fmt; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +// Value tags (see "Value encoding" in the spec). +pub const TAG_NULL: u8 = 0; +pub const TAG_INT: u8 = 1; +pub const TAG_STRING: u8 = 2; +pub const TAG_BOOL: u8 = 3; +pub const TAG_DOUBLE: u8 = 4; + +// Client -> server opcodes. +pub const OP_AUTH: u8 = 1; +pub const OP_JOIN_LOBBY: u8 = 2; +pub const OP_LEAVE_LOBBY: u8 = 3; +pub const OP_CREATE_ROOM: u8 = 4; +pub const OP_JOIN_ROOM: u8 = 5; +pub const OP_JOIN_RANDOM: u8 = 6; +pub const OP_LEAVE_ROOM: u8 = 7; +pub const OP_REJOIN: u8 = 8; +pub const OP_SET_PLAYER_PROPS: u8 = 9; +pub const OP_SET_ROOM_PROPS: u8 = 10; +pub const OP_RPC: u8 = 11; +pub const OP_PING: u8 = 12; + +// Server -> client opcodes. +pub const OP_AUTH_OK: u8 = 20; +pub const OP_JOINED_LOBBY: u8 = 21; +pub const OP_LEFT_LOBBY: u8 = 22; +pub const OP_JOINED_ROOM: u8 = 23; +pub const OP_CREATE_FAILED: u8 = 24; +pub const OP_JOIN_FAILED: u8 = 25; +pub const OP_PLAYER_ENTERED: u8 = 26; +pub const OP_PLAYER_LEFT: u8 = 27; +pub const OP_LEFT_ROOM: u8 = 28; +pub const OP_ROOM_PROPS_CHANGED: u8 = 29; +pub const OP_PLAYER_PROPS_CHANGED: u8 = 30; +pub const OP_MASTER_SWITCHED: u8 = 31; +// Distinct name from the client-side OP_RPC (11); same message, opposite direction. +pub const OP_RPC_BROADCAST: u8 = 32; +pub const OP_PONG: u8 = 33; +pub const OP_KICKED: u8 = 34; + +// Close codes. The spec names 4001 and 4002; 4000 covers the framing errors it +// describes ("Text frames are a protocol error -> close", malformed frame, unknown +// opcode) without naming a code for them. +pub const CLOSE_PROTOCOL: u16 = 4000; +pub const CLOSE_UNAUTHENTICATED: u16 = 4001; +pub const CLOSE_RATE_LIMIT: u16 = 4002; +// Added with the liveness sweep: nothing was heard from this connection for three ping +// intervals, so the relay stops holding its seat. See LIVENESS_TIMEOUT in ws.rs. +pub const CLOSE_IDLE_TIMEOUT: u16 = 4003; + +// Photon ErrorCode mirrors, so the client's existing logging keeps its meaning. +pub const ERR_GAME_ID_ALREADY_EXISTS: i32 = 32766; +pub const ERR_GAME_FULL: i32 = 32765; +pub const ERR_GAME_CLOSED: i32 = 32764; +pub const ERR_NO_RANDOM_MATCH_FOUND: i32 = 32760; +pub const ERR_GAME_DOES_NOT_EXIST: i32 = 32758; + +// Kicked causes. A room only dies once nobody is left to be told, so cause 1 is still +// reserved — encoded, decoded and tested so the C# side can be written against it now. +#[allow(dead_code)] +pub const KICK_ROOM_DESTROYED: i32 = 1; +// Cause 2 is real: the liveness sweep sends it immediately before close 4003, so a client +// that is alive enough to read (a suspended app coming back, a stalled network) can say +// why it was dropped instead of showing a bare socket error. +pub const KICK_IDLE_TIMEOUT: i32 = 2; + +// --------------------------------------------------------------------------- +// Values and maps +// --------------------------------------------------------------------------- + +#[derive(Clone, Debug, PartialEq)] +pub enum Value { + Null, + Int(i32), + Str(String), + Bool(bool), + Double(f64), +} + +impl Value { + pub fn as_int(&self) -> Option { + match self { + Value::Int(v) => Some(*v), + _ => None, + } + } + + pub fn as_str(&self) -> Option<&str> { + match self { + Value::Str(v) => Some(v), + _ => None, + } + } +} + +// An ordered key -> value bag. Ordering is not semantically meaningful on the wire, but +// keeping insertion order makes the changed-subset broadcasts arrive in the order the +// sender wrote them, which is one less thing for the client to reason about (and makes +// the tests deterministic). +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Map(Vec<(String, Value)>); + +impl Map { + pub fn new() -> Self { + Map(Vec::new()) + } + + // Duplicate keys collapse last-writer-wins, exactly like the Hashtable this mirrors. + // Convenience for the tests and for any caller building a bag from scratch; the + // relay itself only ever merges into an existing bag. + #[allow(dead_code)] + pub fn from_pairs(pairs: I) -> Self + where + I: IntoIterator, + K: Into, + { + let mut map = Map::new(); + for (key, value) in pairs { + map.set(key, value); + } + map + } + + pub fn get(&self, key: &str) -> Option<&Value> { + self.0.iter().find(|(k, _)| k == key).map(|(_, v)| v) + } + + pub fn get_int(&self, key: &str) -> Option { + self.get(key).and_then(Value::as_int) + } + + pub fn get_str(&self, key: &str) -> Option<&str> { + self.get(key).and_then(Value::as_str) + } + + // Last-writer-wins in place: an existing key keeps its position. + pub fn set>(&mut self, key: K, value: Value) { + let key = key.into(); + match self.0.iter_mut().find(|(k, _)| *k == key) { + Some(slot) => slot.1 = value, + None => self.0.push((key, value)), + } + } + + // Last-writer-wins merge of `other` INTO self. The relay uses it for property updates + // and for seeding/merging a joiner's bag out of the seating op's `playerProps`. + pub fn merge(&mut self, other: &Map) { + for (key, value) in other.iter() { + self.set(key, value.clone()); + } + } + + pub fn len(&self) -> usize { + self.0.len() + } + + #[allow(dead_code)] + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + + pub fn iter(&self) -> impl Iterator { + self.0.iter().map(|(k, v)| (k.as_str(), v)) + } +} + +// --------------------------------------------------------------------------- +// Messages +// --------------------------------------------------------------------------- + +#[derive(Clone, Debug, PartialEq)] +pub enum ClientMsg { + Auth { user_id: String, token: String }, + JoinLobby { name: String }, + LeaveLobby, + // `props` is the ROOM bag; `player_props` is the joiner's own bag, mirroring Photon's + // OpCreateRoom/OpJoinRoom actorProperties. See docs/multi-live-ws-protocol.md, + // "Player properties at join time". + CreateRoom { + name: String, + max_players: u8, + visible: bool, + open: bool, + props: Map, + lobby_prop_keys: Vec, + player_props: Map, + }, + JoinRoom { name: String, player_props: Map }, + JoinRandom { power_min: i32, power_max: i32, levels: Vec, player_props: Map }, + LeaveRoom, + Rejoin { name: String, player_props: Map }, + SetPlayerProps { props: Map }, + SetRoomProps { props: Map }, + Rpc { name: String, params: Vec }, + Ping, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum ServerMsg { + AuthOk { actorless_time_ms: f64 }, + JoinedLobby, + LeftLobby, + JoinedRoom { + room_name: String, + your_actor: i32, + master_actor: i32, + room_props: Map, + players: Vec<(i32, Map)>, + }, + CreateFailed { code: i32, msg: String }, + JoinFailed { code: i32, msg: String }, + PlayerEntered { actor: i32, props: Map }, + PlayerLeft { actor: i32, inactive: bool }, + LeftRoom, + RoomPropsChanged { props: Map }, + PlayerPropsChanged { actor: i32, props: Map }, + MasterSwitched { new_master_actor: i32 }, + Rpc { sender_actor: i32, name: String, params: Vec }, + Pong { server_time_ms: f64 }, + // Sent by the liveness sweep with KICK_IDLE_TIMEOUT; KICK_ROOM_DESTROYED is reserved. + Kicked { cause: i32 }, +} + +// --------------------------------------------------------------------------- +// Decoding +// --------------------------------------------------------------------------- + +#[derive(Clone, Debug, PartialEq)] +pub enum DecodeError { + // Frame ran out before the field did. + Truncated, + // Zero-length frame: there is not even an opcode. + Empty, + UnknownOp(u8), + UnknownTag(u8), + BadUtf8, + // "One frame = one message" - anything after the message is a framing bug. + TrailingBytes, +} + +impl fmt::Display for DecodeError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + DecodeError::Truncated => write!(f, "truncated frame"), + DecodeError::Empty => write!(f, "empty frame"), + DecodeError::UnknownOp(op) => write!(f, "unknown opcode {}", op), + DecodeError::UnknownTag(tag) => write!(f, "unknown value tag {}", tag), + DecodeError::BadUtf8 => write!(f, "string is not valid utf-8"), + DecodeError::TrailingBytes => write!(f, "trailing bytes after message"), + } + } +} + +struct Reader<'a> { + buf: &'a [u8], + pos: usize, +} + +impl<'a> Reader<'a> { + fn new(buf: &'a [u8]) -> Self { + Reader { buf, pos: 0 } + } + + fn take(&mut self, n: usize) -> Result<&'a [u8], DecodeError> { + let end = self.pos.checked_add(n).ok_or(DecodeError::Truncated)?; + if end > self.buf.len() { + return Err(DecodeError::Truncated); + } + let out = &self.buf[self.pos..end]; + self.pos = end; + Ok(out) + } + + fn u8(&mut self) -> Result { + Ok(self.take(1)?[0]) + } + + fn bool(&mut self) -> Result { + // Photon booleans are one byte; anything non-zero is true. + Ok(self.u8()? != 0) + } + + fn u16(&mut self) -> Result { + let b = self.take(2)?; + Ok(u16::from_le_bytes([b[0], b[1]])) + } + + fn i32(&mut self) -> Result { + let b = self.take(4)?; + Ok(i32::from_le_bytes([b[0], b[1], b[2], b[3]])) + } + + fn f64(&mut self) -> Result { + let b = self.take(8)?; + Ok(f64::from_le_bytes([b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7]])) + } + + fn string(&mut self) -> Result { + let len = self.u16()? as usize; + let bytes = self.take(len)?; + String::from_utf8(bytes.to_vec()).map_err(|_| DecodeError::BadUtf8) + } + + fn value(&mut self) -> Result { + let tag = self.u8()?; + match tag { + TAG_NULL => Ok(Value::Null), + TAG_INT => Ok(Value::Int(self.i32()?)), + TAG_STRING => Ok(Value::Str(self.string()?)), + TAG_BOOL => Ok(Value::Bool(self.bool()?)), + TAG_DOUBLE => Ok(Value::Double(self.f64()?)), + other => Err(DecodeError::UnknownTag(other)), + } + } + + fn map(&mut self) -> Result { + let count = self.u8()?; + let mut map = Map::new(); + for _ in 0..count { + let key = self.string()?; + let value = self.value()?; + map.set(key, value); + } + Ok(map) + } + + fn value_list(&mut self) -> Result, DecodeError> { + let count = self.u8()?; + let mut out = Vec::with_capacity(count as usize); + for _ in 0..count { + out.push(self.value()?); + } + Ok(out) + } + + fn string_list(&mut self) -> Result, DecodeError> { + let count = self.u8()?; + let mut out = Vec::with_capacity(count as usize); + for _ in 0..count { + out.push(self.string()?); + } + Ok(out) + } + + fn finish(&self, value: T) -> Result { + if self.pos != self.buf.len() { + return Err(DecodeError::TrailingBytes); + } + Ok(value) + } +} + +pub fn decode_client(buf: &[u8]) -> Result { + let mut r = Reader::new(buf); + let op = r.u8().map_err(|_| DecodeError::Empty)?; + let msg = match op { + OP_AUTH => ClientMsg::Auth { user_id: r.string()?, token: r.string()? }, + OP_JOIN_LOBBY => ClientMsg::JoinLobby { name: r.string()? }, + OP_LEAVE_LOBBY => ClientMsg::LeaveLobby, + OP_CREATE_ROOM => ClientMsg::CreateRoom { + name: r.string()?, + max_players: r.u8()?, + visible: r.bool()?, + open: r.bool()?, + props: r.map()?, + lobby_prop_keys: r.string_list()?, + player_props: r.map()?, + }, + OP_JOIN_ROOM => ClientMsg::JoinRoom { name: r.string()?, player_props: r.map()? }, + OP_JOIN_RANDOM => ClientMsg::JoinRandom { + power_min: r.i32()?, + power_max: r.i32()?, + levels: r.value_list()?, + player_props: r.map()?, + }, + OP_LEAVE_ROOM => ClientMsg::LeaveRoom, + OP_REJOIN => ClientMsg::Rejoin { name: r.string()?, player_props: r.map()? }, + OP_SET_PLAYER_PROPS => ClientMsg::SetPlayerProps { props: r.map()? }, + OP_SET_ROOM_PROPS => ClientMsg::SetRoomProps { props: r.map()? }, + OP_RPC => ClientMsg::Rpc { name: r.string()?, params: r.value_list()? }, + OP_PING => ClientMsg::Ping, + other => return Err(DecodeError::UnknownOp(other)), + }; + r.finish(msg) +} + +// The client's half of the codec. The relay never calls it; it is the executable +// reference the C# rewrite is written against, and what the round-trip tests drive. +#[allow(dead_code)] +pub fn decode_server(buf: &[u8]) -> Result { + let mut r = Reader::new(buf); + let op = r.u8().map_err(|_| DecodeError::Empty)?; + let msg = match op { + OP_AUTH_OK => ServerMsg::AuthOk { actorless_time_ms: r.f64()? }, + OP_JOINED_LOBBY => ServerMsg::JoinedLobby, + OP_LEFT_LOBBY => ServerMsg::LeftLobby, + OP_JOINED_ROOM => { + let room_name = r.string()?; + let your_actor = r.i32()?; + let master_actor = r.i32()?; + let room_props = r.map()?; + let count = r.u8()?; + let mut players = Vec::with_capacity(count as usize); + for _ in 0..count { + let actor = r.i32()?; + players.push((actor, r.map()?)); + } + ServerMsg::JoinedRoom { room_name, your_actor, master_actor, room_props, players } + } + OP_CREATE_FAILED => ServerMsg::CreateFailed { code: r.i32()?, msg: r.string()? }, + OP_JOIN_FAILED => ServerMsg::JoinFailed { code: r.i32()?, msg: r.string()? }, + OP_PLAYER_ENTERED => ServerMsg::PlayerEntered { actor: r.i32()?, props: r.map()? }, + OP_PLAYER_LEFT => ServerMsg::PlayerLeft { actor: r.i32()?, inactive: r.bool()? }, + OP_LEFT_ROOM => ServerMsg::LeftRoom, + OP_ROOM_PROPS_CHANGED => ServerMsg::RoomPropsChanged { props: r.map()? }, + OP_PLAYER_PROPS_CHANGED => { + ServerMsg::PlayerPropsChanged { actor: r.i32()?, props: r.map()? } + } + OP_MASTER_SWITCHED => ServerMsg::MasterSwitched { new_master_actor: r.i32()? }, + OP_RPC_BROADCAST => ServerMsg::Rpc { + sender_actor: r.i32()?, + name: r.string()?, + params: r.value_list()?, + }, + OP_PONG => ServerMsg::Pong { server_time_ms: r.f64()? }, + OP_KICKED => ServerMsg::Kicked { cause: r.i32()? }, + other => return Err(DecodeError::UnknownOp(other)), + }; + r.finish(msg) +} + +// --------------------------------------------------------------------------- +// Encoding +// --------------------------------------------------------------------------- + +// The relay never legitimately produces a string past 64KiB or a count past 255 (keys +// are one or two characters, rooms hold four players, RPC params are a handful of ints), +// so encoding is infallible and the guards below only exist to keep a bug from emitting +// a frame the peer would mis-frame. They log and clamp rather than panic in a handler. +fn put_str(out: &mut Vec, s: &str) { + let mut bytes = s.as_bytes(); + if bytes.len() > u16::MAX as usize { + println!("multi_live/ws: string of {} bytes truncated to fit u16 length", bytes.len()); + let mut end = u16::MAX as usize; + while end > 0 && !s.is_char_boundary(end) { + end -= 1; + } + bytes = &s.as_bytes()[..end]; + } + out.extend_from_slice(&(bytes.len() as u16).to_le_bytes()); + out.extend_from_slice(bytes); +} + +fn put_count(out: &mut Vec, count: usize, what: &str) -> usize { + let clamped = if count > u8::MAX as usize { + println!("multi_live/ws: {} count {} clamped to 255", what, count); + u8::MAX as usize + } else { + count + }; + out.push(clamped as u8); + clamped +} + +fn put_value(out: &mut Vec, value: &Value) { + match value { + Value::Null => out.push(TAG_NULL), + Value::Int(v) => { + out.push(TAG_INT); + out.extend_from_slice(&v.to_le_bytes()); + } + Value::Str(v) => { + out.push(TAG_STRING); + put_str(out, v); + } + Value::Bool(v) => { + out.push(TAG_BOOL); + out.push(if *v { 1 } else { 0 }); + } + Value::Double(v) => { + out.push(TAG_DOUBLE); + out.extend_from_slice(&v.to_le_bytes()); + } + } +} + +fn put_map(out: &mut Vec, map: &Map) { + let count = put_count(out, map.len(), "map"); + for (key, value) in map.iter().take(count) { + put_str(out, key); + put_value(out, value); + } +} + +fn put_value_list(out: &mut Vec, values: &[Value]) { + let count = put_count(out, values.len(), "value list"); + for value in values.iter().take(count) { + put_value(out, value); + } +} + +#[allow(dead_code)] +fn put_string_list(out: &mut Vec, values: &[String]) { + let count = put_count(out, values.len(), "string list"); + for value in values.iter().take(count) { + put_str(out, value); + } +} + +// See decode_server: the client's half, kept honest by the round-trip tests. +#[allow(dead_code)] +pub fn encode_client(msg: &ClientMsg) -> Vec { + let mut out = Vec::new(); + match msg { + ClientMsg::Auth { user_id, token } => { + out.push(OP_AUTH); + put_str(&mut out, user_id); + put_str(&mut out, token); + } + ClientMsg::JoinLobby { name } => { + out.push(OP_JOIN_LOBBY); + put_str(&mut out, name); + } + ClientMsg::LeaveLobby => out.push(OP_LEAVE_LOBBY), + ClientMsg::CreateRoom { name, max_players, visible, open, props, lobby_prop_keys, player_props } => { + out.push(OP_CREATE_ROOM); + put_str(&mut out, name); + out.push(*max_players); + out.push(if *visible { 1 } else { 0 }); + out.push(if *open { 1 } else { 0 }); + put_map(&mut out, props); + put_string_list(&mut out, lobby_prop_keys); + put_map(&mut out, player_props); + } + ClientMsg::JoinRoom { name, player_props } => { + out.push(OP_JOIN_ROOM); + put_str(&mut out, name); + put_map(&mut out, player_props); + } + ClientMsg::JoinRandom { power_min, power_max, levels, player_props } => { + out.push(OP_JOIN_RANDOM); + out.extend_from_slice(&power_min.to_le_bytes()); + out.extend_from_slice(&power_max.to_le_bytes()); + put_value_list(&mut out, levels); + put_map(&mut out, player_props); + } + ClientMsg::LeaveRoom => out.push(OP_LEAVE_ROOM), + ClientMsg::Rejoin { name, player_props } => { + out.push(OP_REJOIN); + put_str(&mut out, name); + put_map(&mut out, player_props); + } + ClientMsg::SetPlayerProps { props } => { + out.push(OP_SET_PLAYER_PROPS); + put_map(&mut out, props); + } + ClientMsg::SetRoomProps { props } => { + out.push(OP_SET_ROOM_PROPS); + put_map(&mut out, props); + } + ClientMsg::Rpc { name, params } => { + out.push(OP_RPC); + put_str(&mut out, name); + put_value_list(&mut out, params); + } + ClientMsg::Ping => out.push(OP_PING), + } + out +} + +pub fn encode_server(msg: &ServerMsg) -> Vec { + let mut out = Vec::new(); + match msg { + ServerMsg::AuthOk { actorless_time_ms } => { + out.push(OP_AUTH_OK); + out.extend_from_slice(&actorless_time_ms.to_le_bytes()); + } + ServerMsg::JoinedLobby => out.push(OP_JOINED_LOBBY), + ServerMsg::LeftLobby => out.push(OP_LEFT_LOBBY), + ServerMsg::JoinedRoom { room_name, your_actor, master_actor, room_props, players } => { + out.push(OP_JOINED_ROOM); + put_str(&mut out, room_name); + out.extend_from_slice(&your_actor.to_le_bytes()); + out.extend_from_slice(&master_actor.to_le_bytes()); + put_map(&mut out, room_props); + let count = put_count(&mut out, players.len(), "player"); + for (actor, props) in players.iter().take(count) { + out.extend_from_slice(&actor.to_le_bytes()); + put_map(&mut out, props); + } + } + ServerMsg::CreateFailed { code, msg } => { + out.push(OP_CREATE_FAILED); + out.extend_from_slice(&code.to_le_bytes()); + put_str(&mut out, msg); + } + ServerMsg::JoinFailed { code, msg } => { + out.push(OP_JOIN_FAILED); + out.extend_from_slice(&code.to_le_bytes()); + put_str(&mut out, msg); + } + ServerMsg::PlayerEntered { actor, props } => { + out.push(OP_PLAYER_ENTERED); + out.extend_from_slice(&actor.to_le_bytes()); + put_map(&mut out, props); + } + ServerMsg::PlayerLeft { actor, inactive } => { + out.push(OP_PLAYER_LEFT); + out.extend_from_slice(&actor.to_le_bytes()); + out.push(if *inactive { 1 } else { 0 }); + } + ServerMsg::LeftRoom => out.push(OP_LEFT_ROOM), + ServerMsg::RoomPropsChanged { props } => { + out.push(OP_ROOM_PROPS_CHANGED); + put_map(&mut out, props); + } + ServerMsg::PlayerPropsChanged { actor, props } => { + out.push(OP_PLAYER_PROPS_CHANGED); + out.extend_from_slice(&actor.to_le_bytes()); + put_map(&mut out, props); + } + ServerMsg::MasterSwitched { new_master_actor } => { + out.push(OP_MASTER_SWITCHED); + out.extend_from_slice(&new_master_actor.to_le_bytes()); + } + ServerMsg::Rpc { sender_actor, name, params } => { + out.push(OP_RPC_BROADCAST); + out.extend_from_slice(&sender_actor.to_le_bytes()); + put_str(&mut out, name); + put_value_list(&mut out, params); + } + ServerMsg::Pong { server_time_ms } => { + out.push(OP_PONG); + out.extend_from_slice(&server_time_ms.to_le_bytes()); + } + ServerMsg::Kicked { cause } => { + out.push(OP_KICKED); + out.extend_from_slice(&cause.to_le_bytes()); + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn round_client(msg: ClientMsg) { + let bytes = encode_client(&msg); + let back = decode_client(&bytes).expect("client message decodes"); + assert_eq!(back, msg); + assert_eq!(encode_client(&back), bytes); + } + + fn round_server(msg: ServerMsg) { + let bytes = encode_server(&msg); + let back = decode_server(&bytes).expect("server message decodes"); + assert_eq!(back, msg); + assert_eq!(encode_server(&back), bytes); + } + + fn sample_map() -> Map { + Map::from_pairs(vec![ + ("A", Value::Int(-7)), + ("B", Value::Str("ラブライブ".to_string())), + ("C", Value::Bool(true)), + ("D", Value::Double(0.5)), + ("E", Value::Null), + ]) + } + + #[test] + fn every_client_message_round_trips() { + round_client(ClientMsg::Auth { user_id: "12345".into(), token: "a-uuid".into() }); + round_client(ClientMsg::JoinLobby { name: "MultiEventLobby_31".into() }); + round_client(ClientMsg::LeaveLobby); + round_client(ClientMsg::CreateRoom { + name: "123456".into(), + max_players: 4, + visible: false, + open: true, + props: sample_map(), + lobby_prop_keys: vec!["C0".into(), "C1".into()], + player_props: sample_map(), + }); + round_client(ClientMsg::JoinRoom { + name: "042719".into(), + player_props: sample_map(), + }); + round_client(ClientMsg::JoinRandom { + power_min: 0, + power_max: i32::MAX, + levels: vec![Value::Int(1), Value::Int(4)], + player_props: sample_map(), + }); + round_client(ClientMsg::LeaveRoom); + round_client(ClientMsg::Rejoin { + name: "1000000".into(), + player_props: sample_map(), + }); + round_client(ClientMsg::SetPlayerProps { props: sample_map() }); + round_client(ClientMsg::SetRoomProps { props: sample_map() }); + round_client(ClientMsg::Rpc { + name: "SendStamp".into(), + params: vec![Value::Int(3), Value::Str("hi".into())], + }); + round_client(ClientMsg::Ping); + } + + #[test] + fn every_server_message_round_trips() { + round_server(ServerMsg::AuthOk { actorless_time_ms: 1_754_500_000_123.0 }); + round_server(ServerMsg::JoinedLobby); + round_server(ServerMsg::LeftLobby); + round_server(ServerMsg::JoinedRoom { + room_name: "1000000".into(), + your_actor: 2, + master_actor: 1, + room_props: sample_map(), + players: vec![(1, sample_map()), (2, Map::new())], + }); + round_server(ServerMsg::CreateFailed { + code: ERR_GAME_ID_ALREADY_EXISTS, + msg: "GameIdAlreadyExists".into(), + }); + round_server(ServerMsg::JoinFailed { code: ERR_GAME_FULL, msg: "GameFull".into() }); + round_server(ServerMsg::PlayerEntered { actor: 3, props: sample_map() }); + round_server(ServerMsg::PlayerLeft { actor: 3, inactive: true }); + round_server(ServerMsg::PlayerLeft { actor: 3, inactive: false }); + round_server(ServerMsg::LeftRoom); + round_server(ServerMsg::RoomPropsChanged { props: sample_map() }); + round_server(ServerMsg::PlayerPropsChanged { actor: 4, props: sample_map() }); + round_server(ServerMsg::MasterSwitched { new_master_actor: 2 }); + round_server(ServerMsg::Rpc { + sender_actor: 1, + name: "_MoveScene".into(), + params: vec![Value::Null], + }); + round_server(ServerMsg::Pong { server_time_ms: -0.0 }); + round_server(ServerMsg::Kicked { cause: KICK_ROOM_DESTROYED }); + } + + #[test] + fn empty_map_and_empty_strings_round_trip() { + round_client(ClientMsg::Auth { user_id: String::new(), token: String::new() }); + round_client(ClientMsg::JoinLobby { name: String::new() }); + round_client(ClientMsg::SetPlayerProps { props: Map::new() }); + round_client(ClientMsg::Rpc { name: String::new(), params: Vec::new() }); + round_client(ClientMsg::CreateRoom { + name: String::new(), + max_players: 0, + visible: true, + open: false, + props: Map::new(), + lobby_prop_keys: Vec::new(), + player_props: Map::new(), + }); + // A joiner that has committed nothing yet sends an empty bag on every seating op. + round_client(ClientMsg::JoinRoom { name: String::new(), player_props: Map::new() }); + round_client(ClientMsg::Rejoin { name: String::new(), player_props: Map::new() }); + round_client(ClientMsg::JoinRandom { + power_min: 0, + power_max: 0, + levels: Vec::new(), + player_props: Map::new(), + }); + // The trailing playerProps of a JoinRoom is exactly one byte when empty. + assert_eq!( + encode_client(&ClientMsg::JoinRoom { name: String::new(), player_props: Map::new() }), + vec![OP_JOIN_ROOM, 0, 0, 0] + ); + round_server(ServerMsg::JoinedRoom { + room_name: String::new(), + your_actor: 1, + master_actor: 1, + room_props: Map::new(), + players: Vec::new(), + }); + // An empty map is exactly one byte of payload. + assert_eq!(encode_client(&ClientMsg::SetRoomProps { props: Map::new() }), vec![OP_SET_ROOM_PROPS, 0]); + } + + #[test] + fn max_u8_counts_round_trip() { + let map = Map::from_pairs((0..255).map(|i| (format!("k{}", i), Value::Int(i)))); + assert_eq!(map.len(), 255); + round_client(ClientMsg::SetPlayerProps { props: map.clone() }); + + let params: Vec = (0..255).map(Value::Int).collect(); + round_client(ClientMsg::Rpc { name: "SendStamp".into(), params }); + + let keys: Vec = (0..255).map(|i| format!("k{}", i)).collect(); + round_client(ClientMsg::CreateRoom { + name: "n".into(), + max_players: 255, + visible: true, + open: true, + props: map.clone(), + lobby_prop_keys: keys, + player_props: map.clone(), + }); + // A full 255-key bag on each of the other three seating ops. + round_client(ClientMsg::JoinRoom { name: "n".into(), player_props: map.clone() }); + round_client(ClientMsg::Rejoin { name: "n".into(), player_props: map.clone() }); + round_client(ClientMsg::JoinRandom { + power_min: 0, + power_max: 1, + levels: vec![Value::Int(4)], + player_props: map, + }); + + let players: Vec<(i32, Map)> = (0..255).map(|i| (i, Map::new())).collect(); + round_server(ServerMsg::JoinedRoom { + room_name: "n".into(), + your_actor: 1, + master_actor: 1, + room_props: Map::new(), + players, + }); + } + + #[test] + fn integer_and_double_extremes_survive() { + round_client(ClientMsg::JoinRandom { + power_min: i32::MIN, + power_max: i32::MAX, + levels: vec![Value::Int(i32::MIN), Value::Int(i32::MAX), Value::Int(0)], + player_props: Map::new(), + }); + round_server(ServerMsg::PlayerEntered { + actor: i32::MIN, + props: Map::from_pairs(vec![ + ("a", Value::Double(f64::MAX)), + ("b", Value::Double(f64::MIN_POSITIVE)), + ("c", Value::Double(f64::INFINITY)), + ]), + }); + } + + #[test] + fn wire_layout_is_byte_exact() { + // Guard the framing itself: little-endian everywhere, u16 string lengths, + // u8 counts, tag-then-payload values. If this test needs updating, the C# + // client needs updating too. + assert_eq!( + encode_client(&ClientMsg::Auth { user_id: "7".into(), token: "ab".into() }), + vec![OP_AUTH, 1, 0, b'7', 2, 0, b'a', b'b'] + ); + assert_eq!( + encode_client(&ClientMsg::JoinRandom { + power_min: 1, + power_max: 256, + levels: vec![Value::Int(4)], + player_props: Map::new(), + }), + // ... levels ValueList ..., then the trailing empty playerProps map (count 0). + vec![OP_JOIN_RANDOM, 1, 0, 0, 0, 0, 1, 0, 0, 1, TAG_INT, 4, 0, 0, 0, 0] + ); + // The seating ops' trailing bag is an ordinary Map: count:u8 then (key,value)*. + assert_eq!( + encode_client(&ClientMsg::JoinRoom { + name: "42".into(), + player_props: Map::from_pairs(vec![("B", Value::Str("7".into()))]), + }), + vec![OP_JOIN_ROOM, 2, 0, b'4', b'2', 1, 1, 0, b'B', TAG_STRING, 1, 0, b'7'] + ); + assert_eq!( + encode_client(&ClientMsg::Rejoin { + name: "42".into(), + player_props: Map::from_pairs(vec![("F", Value::Int(5))]), + }), + vec![OP_REJOIN, 2, 0, b'4', b'2', 1, 1, 0, b'F', TAG_INT, 5, 0, 0, 0] + ); + assert_eq!( + encode_client(&ClientMsg::SetPlayerProps { + props: Map::from_pairs(vec![("LB", Value::Int(12))]), + }), + vec![OP_SET_PLAYER_PROPS, 1, 2, 0, b'L', b'B', TAG_INT, 12, 0, 0, 0] + ); + assert_eq!( + encode_server(&ServerMsg::PlayerLeft { actor: 2, inactive: true }), + vec![OP_PLAYER_LEFT, 2, 0, 0, 0, 1] + ); + assert_eq!( + encode_server(&ServerMsg::AuthOk { actorless_time_ms: 1.0 }), + vec![OP_AUTH_OK, 0, 0, 0, 0, 0, 0, 0xf0, 0x3f] + ); + } + + #[test] + fn malformed_frames_are_rejected() { + assert_eq!(decode_client(&[]), Err(DecodeError::Empty)); + assert_eq!(decode_client(&[99]), Err(DecodeError::UnknownOp(99))); + assert_eq!(decode_server(&[99]), Err(DecodeError::UnknownOp(99))); + // JoinLobby with a length prefix longer than the payload. + assert_eq!(decode_client(&[OP_JOIN_LOBBY, 4, 0, b'a']), Err(DecodeError::Truncated)); + // Ping carries nothing. + assert_eq!(decode_client(&[OP_PING, 0]), Err(DecodeError::TrailingBytes)); + // Unknown value tag inside a map. + assert_eq!( + decode_client(&[OP_SET_ROOM_PROPS, 1, 1, 0, b'A', 9]), + Err(DecodeError::UnknownTag(9)) + ); + // A map that promises two entries but carries one. + assert_eq!( + decode_client(&[OP_SET_ROOM_PROPS, 2, 1, 0, b'A', TAG_NULL]), + Err(DecodeError::Truncated) + ); + // Invalid UTF-8 in a string. + assert_eq!(decode_client(&[OP_JOIN_ROOM, 1, 0, 0xff]), Err(DecodeError::BadUtf8)); + } + + #[test] + fn duplicate_map_keys_collapse_last_writer_wins() { + let bytes = vec![ + OP_SET_ROOM_PROPS, 2, + 1, 0, b'A', TAG_INT, 1, 0, 0, 0, + 1, 0, b'A', TAG_INT, 2, 0, 0, 0, + ]; + let ClientMsg::SetRoomProps { props } = decode_client(&bytes).unwrap() else { + panic!("expected SetRoomProps"); + }; + assert_eq!(props.len(), 1); + assert_eq!(props.get_int("A"), Some(2)); + } + + #[test] + fn map_set_is_last_writer_wins_in_place() { + let mut map = Map::from_pairs(vec![("A", Value::Int(1)), ("B", Value::Int(2))]); + map.set("A", Value::Int(9)); + assert_eq!(map.len(), 2); + assert_eq!( + map.iter().map(|(k, _)| k).collect::>(), + vec!["A", "B"] + ); + assert_eq!(map.get_int("A"), Some(9)); + } + + #[test] + fn bool_payload_accepts_any_nonzero() { + // Photon writes 0/1; be lenient reading, strict writing. + assert_eq!( + decode_server(&[OP_PLAYER_LEFT, 1, 0, 0, 0, 7]), + Ok(ServerMsg::PlayerLeft { actor: 1, inactive: true }) + ); + assert_eq!( + encode_server(&ServerMsg::PlayerLeft { actor: 1, inactive: true })[5], + 1 + ); + } +} diff --git a/src/router/multi_live/rooms.rs b/src/router/multi_live/rooms.rs new file mode 100644 index 0000000..c732c65 --- /dev/null +++ b/src/router/multi_live/rooms.rs @@ -0,0 +1,2397 @@ +// Room/lobby registry for the multi-live relay, per docs/multi-live-ws-protocol.md. +// +// Concurrency shape (the spec's "Ordering & concurrency" section): +// +// Every mutation goes through the single global REGISTRY mutex, and every outbound +// message an operation produces is pushed into the recipients' per-connection FIFO +// channels before that mutex is released. That is a total order over the whole relay, +// which is a strict superset of the per-room total order the spec asks for, which is in +// turn a strict superset of the per-sender ordering the live-start/live-end barriers +// need. The critical sections never await and never block: the per-connection queues +// are unbounded tokio mpsc senders, so pushing is a wait-free append, and the socket +// writes happen in each connection's own writer task after the lock is gone. +// +// Nothing in this file is async, and nothing here knows about WebSockets. That is what +// makes the room semantics testable without a socket - the tests below drive the same +// entry points ws.rs does and read the frames straight out of the channels. +// +// Time is always passed in as an explicit `now` so the 60s expiry rules can be tested +// without sleeping. + +use std::collections::HashMap; +use std::sync::Mutex; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use lazy_static::lazy_static; +use rand::RngExt; +use tokio::sync::mpsc::UnboundedSender; + +use crate::lock_onto_mutex; +use super::proto::{ + ClientMsg, Map, ServerMsg, Value, + CLOSE_IDLE_TIMEOUT, ERR_GAME_CLOSED, ERR_GAME_DOES_NOT_EXIST, ERR_GAME_FULL, + ERR_GAME_ID_ALREADY_EXISTS, ERR_NO_RANDOM_MATCH_FOUND, KICK_IDLE_TIMEOUT, +}; + +pub type ConnId = u64; + +// Unclean disconnect holds the actor slot and its props this long (spec: 60s, sized for +// MultiRestart's ReconnectAndRejoin plus the MULTI_LIVE_TIME_OUT=15s ForceDisconnectWait). +pub const INACTIVE_TTL: Duration = Duration::from_secs(60); +// A room with zero ACTIVE players is destroyed after this long. +pub const EMPTY_ROOM_TTL: Duration = Duration::from_secs(60); + +// How long a connection may go without saying ANYTHING before the relay stops believing +// in it. A client whose process is killed (or whose phone drops off the network) leaves a +// socket the OS never FINs, so the reader task sits in recv() forever: the actor stays +// ACTIVE, its seat is never converted into a held slot, INACTIVE_TTL never starts and +// EMPTY_ROOM_TTL never starts either — a zombie holds a quarter of a room for the life of +// the process. AUTH_TIMEOUT does not help; it only covers the first frame. +// +// The client sends op-12 Ping every 30s, so 90s is three missed pings — long enough that a +// stalled-but-alive connection is not punished for one lost packet, short enough that a +// dead one frees its seat before anybody waits long for a match. +pub const LIVENESS_TIMEOUT: Duration = Duration::from_secs(90); + +// Room props the matcher reads. C1 is the party power, C0 the live level; both are set +// by the room creator and mirrored to everyone, so the relay needs no masterdata. +const PROP_ROOM_LEVEL: &str = "C0"; +const PROP_ROOM_POWER: &str = "C1"; + +// MultiPlayProtocol.RoomConst.Token — "{userId}.{guid}", minted by the master client in +// MultiEventMatchingScene right before the live starts (CommitCurrentRoomToken + +// PushCurrentRoomData) and mirrored to the whole party. Every member posts it as the +// `token` field of /multi_live/start|end, so it is the only handle the HTTP side has on +// which room a finishing live belongs to. The relay never writes it, only reads it back. +const PROP_ROOM_TOKEN: &str = "C"; + +// Photon's well-known room properties are BYTE keys living in the same bag as the +// game's string keys; the client transport encodes a byte key as "#" + decimal. +// GamePropertyKey.IsOpen is 253 and IsVisible is 254, and the surviving Photon.Realtime +// `Room.IsOpen` / `Room.IsVisible` setters push exactly `{253: bool}` / `{254: bool}` +// through OpSetPropertiesOfRoom - which is how the game closes and hides a room after +// creation (MultiMatchingView.SetRoomOpenVisible / SetRoomOpenHide, +// MultiPlayManager.SendCurrentRoomIsOpen). +// +// The relay has to do BOTH things with them: mirror them in the bag like any other +// property (non-master clients poll their own CurrentRoom.IsOpen mirror to advance) AND +// interpret them, because JoinRandom and JoinRoom filter on the flags. Relaying them +// opaquely would let a closed room keep being matched into. +const PROP_ROOM_IS_OPEN: &str = "#253"; +const PROP_ROOM_IS_VISIBLE: &str = "#254"; + +// Folds any "#253"/"#254" carried by a property update into the room's flags. Anything +// that is not a Bool - a missing key, a stray Int, a Null - leaves the flag alone. +fn apply_flag_props(props: &Map, visible: &mut bool, open: &mut bool) { + if let Some(Value::Bool(value)) = props.get(PROP_ROOM_IS_OPEN) { + *open = *value; + } + if let Some(Value::Bool(value)) = props.get(PROP_ROOM_IS_VISIBLE) { + *visible = *value; + } +} + +// What a connection's writer task pulls out of its queue. Pong is the WebSocket-level +// keepalive reply (not the protocol's op 33) and rides the same queue so it cannot +// overtake a broadcast. +#[derive(Clone, Debug, PartialEq)] +pub enum Outbound { + Msg(ServerMsg), + Pong(Vec), + Close(u16), +} + +pub type Sink = UnboundedSender; + +struct Conn { + user_id: i64, + tx: Sink, + lobby: Option, + room: Option, + // When the last inbound frame of any kind arrived. Bumped by `touch`; read only by + // the liveness sweep. + last_seen: Instant, +} + +struct Player { + actor: i32, + user_id: i64, + // None once the socket dropped without LeaveRoom: the slot and props are held. + conn: Option, + props: Map, + inactive_since: Option, +} + +impl Player { + fn is_active(&self) -> bool { + self.conn.is_some() + } +} + +struct Room { + lobby: String, + max_players: u8, + visible: bool, + open: bool, + // Whether this room was created as a PRIVATE (join-by-code) party, latched once at + // creation and never touched again. /multi_live/end branches the score-board write on + // it, so it must NOT be re-derived from `visible` later: the game hides a room the + // moment its members are decided (MultiMatchingView.SetRoomOpenVisible(false)), which + // makes a public room mid-live indistinguishable from a private one by the live flags. + // + // The signal is the creation-time visibility, because that is exactly what separates + // the client's two create paths: MultiPlayManager.CreatePublicRoom issues + // CreateRoom("", 4, visible: true, open: true) and lets the server name the room, while + // CreatePrivateRoom issues CreateRoom(code, 4, visible: false, open: true) — a private + // party is by definition the one that is never listed for random matching. + private: bool, + props: Map, + // Kept only so a future lobby-listing op can honour what the creator asked to + // publish; the matcher reads C0/C1 straight off the room bag like Photon's SQL did. + #[allow(dead_code)] + lobby_prop_keys: Vec, + players: Vec, + // Actor numbers are 1..n and never reused for the life of the room. + next_actor: i32, + master: i32, + // Creation order, for JoinRandom's "then oldest" tiebreak. + seq: u64, + empty_since: Option, +} + +impl Room { + fn active_count(&self) -> usize { + self.players.iter().filter(|p| p.is_active()).count() + } + + // Held slots count against capacity: an inactive player still owns its seat until the + // 60s rejoin window closes, so a full-with-one-dropout room is still full. + fn occupied(&self) -> usize { + self.players.len() + } + + fn is_full(&self) -> bool { + // Photon treats maxPlayers 0 as "no limit". + self.max_players != 0 && self.occupied() >= self.max_players as usize + } + + fn player_by_conn(&self, conn: ConnId) -> Option<&Player> { + self.players.iter().find(|p| p.conn == Some(conn)) + } + + fn player_by_conn_mut(&mut self, conn: ConnId) -> Option<&mut Player> { + self.players.iter_mut().find(|p| p.conn == Some(conn)) + } + + fn active_conns(&self) -> Vec { + self.players.iter().filter_map(|p| p.conn).collect() + } + + fn snapshot(&self) -> Vec<(i32, Map)> { + // Includes held (inactive) slots: a fresh joiner has to see the seat as taken for + // the later Rejoin's PlayerEntered to make sense, and PUN's Room.Players lists + // inactive actors the same way. + self.players.iter().map(|p| (p.actor, p.props.clone())).collect() + } + + // Photon only moves the master when the current one stops being an active player; + // a rejoining low-numbered actor does not steal it back. + fn reelect_master(&mut self) -> Option { + if self.players.iter().any(|p| p.actor == self.master && p.is_active()) { + return None; + } + let lowest = self + .players + .iter() + .filter(|p| p.is_active()) + .map(|p| p.actor) + .min()?; + if lowest == self.master { + return None; + } + self.master = lowest; + Some(lowest) + } + + fn touch_empty(&mut self, now: Instant) { + if self.active_count() == 0 { + if self.empty_since.is_none() { + self.empty_since = Some(now); + } + } else { + self.empty_since = None; + } + } +} + +pub struct Registry { + next_conn: ConnId, + next_seq: u64, + conns: HashMap, + rooms: HashMap, +} + +lazy_static! { + static ref REGISTRY: Mutex = Mutex::new(Registry::new()); +} + +// The one lock. Held for the whole of an operation, released before any socket write. +pub fn registry() -> std::sync::MutexGuard<'static, Registry> { + lock_onto_mutex!(REGISTRY) +} + +// Server clock for AuthOk/Pong. Milliseconds since the epoch fits an f64 exactly for +// another 200-odd millennia. +pub fn server_time_ms() -> f64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis() as f64) + .unwrap_or(0.0) +} + +type Outbox = Vec<(ConnId, ServerMsg)>; + +impl Registry { + fn new() -> Self { + Registry { + next_conn: 1, + next_seq: 1, + conns: HashMap::new(), + rooms: HashMap::new(), + } + } + + // --- connection lifecycle ------------------------------------------------- + + pub fn connect(&mut self, user_id: i64, tx: Sink, now: Instant) -> ConnId { + let id = self.next_conn; + self.next_conn += 1; + self.conns.insert(id, Conn { user_id, tx, lobby: None, room: None, last_seen: now }); + id + } + + // "Something arrived on this socket." Called for every inbound frame, including the + // ones that never reach `handle` (WebSocket-level ping/pong), which is what makes the + // liveness window a statement about the SOCKET rather than about protocol traffic. + pub fn touch(&mut self, id: ConnId, now: Instant) { + if let Some(conn) = self.conns.get_mut(&id) { + conn.last_seen = now; + } + } + + // Socket dropped without LeaveRoom: the actor goes inactive and keeps its slot and + // props for INACTIVE_TTL so Rejoin can reclaim them. + pub fn disconnect(&mut self, id: ConnId, now: Instant) { + let Some(conn) = self.conns.remove(&id) else { + return; + }; + let Some(room_name) = conn.room else { + println!("multi_live/ws: conn {} disconnected (uid {}, not in a room)", id, conn.user_id); + return; + }; + println!( + "multi_live/ws: conn {} disconnected (uid {}, room '{}' — slot held for rejoin)", + id, conn.user_id, room_name + ); + let mut out: Outbox = Vec::new(); + if let Some(room) = self.rooms.get_mut(&room_name) { + if let Some(player) = room.player_by_conn_mut(id) { + player.conn = None; + player.inactive_since = Some(now); + let actor = player.actor; + for target in room.active_conns() { + out.push((target, ServerMsg::PlayerLeft { actor, inactive: true })); + } + if let Some(master) = room.reelect_master() { + for target in room.active_conns() { + out.push((target, ServerMsg::MasterSwitched { new_master_actor: master })); + } + } + } + room.touch_empty(now); + } + self.flush(out); + } + + // --- dispatch ------------------------------------------------------------- + + // Every op except Auth, which ws.rs handles before the connection is registered. + pub fn handle(&mut self, id: ConnId, msg: ClientMsg, now: Instant) { + // Any op at all is proof of life, op 12 Ping included — that is the whole point of + // the client's 30s ping. + self.touch(id, now); + // Lobby/room lifecycle ops are logged for diagnosis; the chatty per-live traffic + // (SetPlayerProps/Rpc/Ping) is deliberately not. + match &msg { + ClientMsg::JoinLobby { name } => println!("multi_live/ws: conn {} JoinLobby {}", id, name), + ClientMsg::CreateRoom { name, .. } => println!("multi_live/ws: conn {} CreateRoom '{}'", id, name), + ClientMsg::JoinRoom { name, .. } => println!("multi_live/ws: conn {} JoinRoom {}", id, name), + ClientMsg::JoinRandom { power_min, power_max, .. } => { + println!("multi_live/ws: conn {} JoinRandom power {}..{}", id, power_min, power_max) + } + ClientMsg::Rejoin { name, .. } => println!("multi_live/ws: conn {} Rejoin {}", id, name), + ClientMsg::LeaveRoom => println!("multi_live/ws: conn {} LeaveRoom", id), + // RPCs are rare (stamps, scene moves) — log the name, not the params. + ClientMsg::Rpc { name, params } => { + println!("multi_live/ws: conn {} Rpc {} ({} params)", id, name, params.len()) + } + _ => {} + } + let mut out: Outbox = Vec::new(); + match msg { + ClientMsg::Auth { .. } => { + // ws.rs rejects a second Auth as a protocol error before we get here. + } + ClientMsg::JoinLobby { name } => { + if let Some(conn) = self.conns.get_mut(&id) { + conn.lobby = Some(name); + out.push((id, ServerMsg::JoinedLobby)); + } + } + ClientMsg::LeaveLobby => { + if let Some(conn) = self.conns.get_mut(&id) { + conn.lobby = None; + out.push((id, ServerMsg::LeftLobby)); + } + } + ClientMsg::CreateRoom { name, max_players, visible, open, props, lobby_prop_keys, player_props } => { + self.create_room(id, name, max_players, visible, open, props, lobby_prop_keys, player_props, now, &mut out); + } + ClientMsg::JoinRoom { name, player_props } => self.join_room(id, &name, player_props, now, &mut out), + ClientMsg::JoinRandom { power_min, power_max, levels, player_props } => { + self.join_random(id, power_min, power_max, &levels, player_props, now, &mut out); + } + ClientMsg::LeaveRoom => self.leave_room(id, true, now, &mut out), + ClientMsg::Rejoin { name, player_props } => self.rejoin(id, &name, player_props, now, &mut out), + ClientMsg::SetPlayerProps { props } => self.set_player_props(id, props, &mut out), + ClientMsg::SetRoomProps { props } => self.set_room_props(id, props, &mut out), + ClientMsg::Rpc { name, params } => self.rpc(id, name, params, &mut out), + ClientMsg::Ping => { + out.push((id, ServerMsg::Pong { server_time_ms: server_time_ms() })); + } + } + self.flush(out); + } + + // --- room operations ------------------------------------------------------ + + #[allow(clippy::too_many_arguments)] + fn create_room( + &mut self, + id: ConnId, + name: String, + max_players: u8, + visible: bool, + open: bool, + props: Map, + lobby_prop_keys: Vec, + player_props: Map, + now: Instant, + out: &mut Outbox, + ) { + // A client that creates while still seated is a client bug; free the old seat + // rather than leak it. The LeftRoom confirmation is suppressed because the + // caller is about to get JoinedRoom or CreateFailed instead. + self.leave_room(id, false, now, out); + + let Some(conn) = self.conns.get(&id) else { + return; + }; + let lobby = conn.lobby.clone().unwrap_or_default(); + let user_id = conn.user_id; + + let name = if name.is_empty() { + match self.generate_room_name() { + Some(name) => name, + None => { + println!("multi_live/ws: could not find a free room id"); + out.push((id, ServerMsg::CreateFailed { + code: ERR_GAME_ID_ALREADY_EXISTS, + msg: "GameIdAlreadyExists".to_string(), + })); + return; + } + } + } else { + name + }; + + if self.rooms.contains_key(&name) { + out.push((id, ServerMsg::CreateFailed { + code: ERR_GAME_ID_ALREADY_EXISTS, + msg: "GameIdAlreadyExists".to_string(), + })); + return; + } + + let seq = self.next_seq; + self.next_seq += 1; + // The transport seeds #253/#254 into the initial bag alongside the op-4 fields so + // a later joiner's Room mirror starts with the right values. If the two ever + // disagree the bag wins, because the bag is what every client polls. + let (mut visible, mut open) = (visible, open); + apply_flag_props(&props, &mut visible, &mut open); + // Latched here, from the settled creation-time visibility, and never updated. + let private = !visible; + // The creator is master and takes actor 1. + let room = Room { + lobby, + max_players, + visible, + open, + private, + props, + lobby_prop_keys, + // The creator has nobody to notify, but its bag is seeded from the op-4 + // playerProps all the same: the snapshot the NEXT joiner receives in + // JoinedRoom must already carry it, which is what Photon's OpCreateRoom + // actorProperties did. + players: vec![Player { + actor: 1, + user_id, + conn: Some(id), + props: player_props, + inactive_since: None, + }], + next_actor: 2, + master: 1, + seq, + empty_since: None, + }; + let joined = ServerMsg::JoinedRoom { + room_name: name.clone(), + your_actor: 1, + master_actor: 1, + room_props: room.props.clone(), + players: room.snapshot(), + }; + self.rooms.insert(name.clone(), room); + if let Some(conn) = self.conns.get_mut(&id) { + conn.room = Some(name); + } + out.push((id, joined)); + } + + fn generate_room_name(&self) -> Option { + let min = super::const_value("MULTI_ROOM_MIN_ID", 1000000); + let max = super::const_value("MULTI_ROOM_MAX_ID", 99999999).max(min); + let mut rng = rand::rng(); + for _ in 0..64 { + let candidate = rng.random_range(min..max + 1).to_string(); + if !self.rooms.contains_key(&candidate) { + return Some(candidate); + } + } + None + } + + fn join_room(&mut self, id: ConnId, name: &str, player_props: Map, now: Instant, out: &mut Outbox) { + // Join-by-code is not lobby scoped: room names are globally unique, exactly like + // a Photon room name inside one region. + // + // One account may hold several seats. Photon keyed a seat on the CONNECTION, not + // on the account, so two clients signed in to one account were two players — and + // that is what makes testing a party from one account possible here, so it is kept + // deliberately. The consequence is that "full" is only ever about occupancy: a + // second connection from the room's own creator is refused with GameFull when, and + // only when, the room really is at maxPlayers (held slots included, see is_full). + // GameFull is therefore truthful in every case — there is no self-join collision + // dressed up as a full room. + let Some(room) = self.rooms.get(name) else { + out.push((id, ServerMsg::JoinFailed { + code: ERR_GAME_DOES_NOT_EXIST, + msg: "GameDoesNotExist".to_string(), + })); + return; + }; + if !room.open { + out.push((id, ServerMsg::JoinFailed { + code: ERR_GAME_CLOSED, + msg: "GameClosed".to_string(), + })); + return; + } + if room.is_full() { + out.push((id, ServerMsg::JoinFailed { + code: ERR_GAME_FULL, + msg: "GameFull".to_string(), + })); + return; + } + self.seat(id, &name.to_string(), player_props, now, out); + } + + fn join_random( + &mut self, + id: ConnId, + power_min: i32, + power_max: i32, + levels: &[Value], + player_props: Map, + now: Instant, + out: &mut Outbox, + ) { + let Some(conn) = self.conns.get(&id) else { + return; + }; + let lobby = conn.lobby.clone().unwrap_or_default(); + // Empty levels means no level filter - the helper lane is the only caller that + // sends one. + let levels: Vec = levels.iter().filter_map(Value::as_int).collect(); + + let mut best: Option<(&String, &Room)> = None; + for (name, room) in &self.rooms { + if room.lobby != lobby || !room.visible || !room.open || room.is_full() { + continue; + } + // Exclusive bounds on both ends, matching the official + // `C1 > {min} AND C1 < {max}` SQL lobby filter. + let Some(power) = room.props.get_int(PROP_ROOM_POWER) else { + continue; + }; + if power <= power_min || power >= power_max { + continue; + } + if !levels.is_empty() { + match room.props.get_int(PROP_ROOM_LEVEL) { + Some(level) if levels.contains(&level) => {} + _ => continue, + } + } + // FillRoom: most populated first, then oldest. + let better = match best { + None => true, + Some((_, current)) => { + (room.occupied(), std::cmp::Reverse(room.seq)) + > (current.occupied(), std::cmp::Reverse(current.seq)) + } + }; + if better { + best = Some((name, room)); + } + } + + let Some((name, _)) = best else { + out.push((id, ServerMsg::JoinFailed { + code: ERR_NO_RANDOM_MATCH_FOUND, + msg: "NoRandomMatchFound".to_string(), + })); + return; + }; + let name = name.clone(); + self.seat(id, &name, player_props, now, out); + } + + // Adds a fresh actor to an already-validated room. `player_props` is the joiner's own + // bag off the seating op; it is seeded BEFORE the snapshot and the PlayerEntered + // broadcast are built, so neither can ever show an empty newcomer. + fn seat(&mut self, id: ConnId, name: &String, player_props: Map, now: Instant, out: &mut Outbox) { + self.leave_room(id, false, now, out); + + let Some(conn) = self.conns.get(&id) else { + return; + }; + let user_id = conn.user_id; + let Some(room) = self.rooms.get_mut(name) else { + // The implicit leave above can destroy the room being joined if the caller + // was its last active player. + out.push((id, ServerMsg::JoinFailed { + code: ERR_GAME_DOES_NOT_EXIST, + msg: "GameDoesNotExist".to_string(), + })); + return; + }; + + let actor = room.next_actor; + room.next_actor += 1; + room.players.push(Player { + actor, + user_id, + conn: Some(id), + props: player_props, + inactive_since: None, + }); + room.touch_empty(now); + // Cloned out of the seated Player so the snapshot below and this broadcast are + // provably the same bag. + let entered_props = room + .players + .last() + .map(|p| p.props.clone()) + .unwrap_or_default(); + + let others: Vec = room.active_conns().into_iter().filter(|c| *c != id).collect(); + let joined = ServerMsg::JoinedRoom { + room_name: name.clone(), + your_actor: actor, + master_actor: room.master, + room_props: room.props.clone(), + players: room.snapshot(), + }; + let master_change = room.reelect_master(); + let everyone = room.active_conns(); + + if let Some(conn) = self.conns.get_mut(&id) { + conn.room = Some(name.clone()); + } + out.push((id, joined)); + for target in others { + out.push((target, ServerMsg::PlayerEntered { actor, props: entered_props.clone() })); + } + if let Some(master) = master_change { + for target in everyone { + out.push((target, ServerMsg::MasterSwitched { new_master_actor: master })); + } + } + } + + fn rejoin(&mut self, id: ConnId, name: &str, player_props: Map, now: Instant, out: &mut Outbox) { + self.leave_room(id, false, now, out); + + let Some(conn) = self.conns.get(&id) else { + return; + }; + let user_id = conn.user_id; + let Some(room) = self.rooms.get_mut(name) else { + out.push((id, ServerMsg::JoinFailed { + code: ERR_GAME_DOES_NOT_EXIST, + msg: "GameDoesNotExist".to_string(), + })); + return; + }; + // The held slot is matched on the account, not on the socket: the old socket is + // gone by definition. + let Some(player) = room + .players + .iter_mut() + .find(|p| p.user_id == user_id && p.conn.is_none()) + else { + // The spec's code list has no Photon JoinFailedWithRejoinerNotFound (32748); + // the slot being gone is indistinguishable from the room being gone as far as + // the client's retry loop cares, so it reuses GameDoesNotExist. + out.push((id, ServerMsg::JoinFailed { + code: ERR_GAME_DOES_NOT_EXIST, + msg: "RejoinerNotFound".to_string(), + })); + return; + }; + player.conn = Some(id); + player.inactive_since = None; + // Held props are restored by virtue of never having been dropped; the op-8 bag is + // merged OVER them, join-time values winning per key. A rejoiner's F/LB/LC have + // moved on since the drop, while keys it no longer writes keep their held values. + player.props.merge(&player_props); + let actor = player.actor; + let props = player.props.clone(); + room.touch_empty(now); + + let others: Vec = room.active_conns().into_iter().filter(|c| *c != id).collect(); + let joined = ServerMsg::JoinedRoom { + room_name: name.to_string(), + your_actor: actor, + master_actor: room.master, + room_props: room.props.clone(), + players: room.snapshot(), + }; + let master_change = room.reelect_master(); + let everyone = room.active_conns(); + + if let Some(conn) = self.conns.get_mut(&id) { + conn.room = Some(name.to_string()); + } + out.push((id, joined)); + for target in others { + out.push((target, ServerMsg::PlayerEntered { actor, props: props.clone() })); + } + if let Some(master) = master_change { + for target in everyone { + out.push((target, ServerMsg::MasterSwitched { new_master_actor: master })); + } + } + } + + // Clean leave: the slot is released outright, no rejoin window. + fn leave_room(&mut self, id: ConnId, notify_self: bool, now: Instant, out: &mut Outbox) { + let Some(conn) = self.conns.get_mut(&id) else { + return; + }; + let Some(room_name) = conn.room.take() else { + return; + }; + if notify_self { + out.push((id, ServerMsg::LeftRoom)); + } + let Some(room) = self.rooms.get_mut(&room_name) else { + return; + }; + let Some(index) = room.players.iter().position(|p| p.conn == Some(id)) else { + return; + }; + let actor = room.players.remove(index).actor; + room.touch_empty(now); + + for target in room.active_conns() { + out.push((target, ServerMsg::PlayerLeft { actor, inactive: false })); + } + // "Room is destroyed when the last ACTIVE player leaves cleanly" - held slots go + // with it, deliberately. + if room.active_count() == 0 { + self.rooms.remove(&room_name); + return; + } + if let Some(master) = room.reelect_master() { + for target in room.active_conns() { + out.push((target, ServerMsg::MasterSwitched { new_master_actor: master })); + } + } + } + + fn set_player_props(&mut self, id: ConnId, props: Map, out: &mut Outbox) { + let Some(room) = self.room_of(id) else { + return; + }; + let Some(room) = self.rooms.get_mut(&room) else { + return; + }; + let Some(player) = room.player_by_conn_mut(id) else { + return; + }; + // Last-writer-wins per key, applied atomically as one message. + for (key, value) in props.iter() { + player.props.set(key, value.clone()); + } + let actor = player.actor; + // PUN semantics: the sender already applied these locally at send time, so the + // broadcast carries the changed subset to everyone else only. + for target in room.active_conns().into_iter().filter(|c| *c != id) { + out.push((target, ServerMsg::PlayerPropsChanged { actor, props: props.clone() })); + } + } + + fn set_room_props(&mut self, id: ConnId, props: Map, out: &mut Outbox) { + let Some(room) = self.room_of(id) else { + return; + }; + let Some(room) = self.rooms.get_mut(&room) else { + return; + }; + if room.player_by_conn(id).is_none() { + return; + } + for (key, value) in props.iter() { + room.props.set(key, value.clone()); + } + // The room stays open/visible per its own bag: the game flips these mid-session + // (matching complete, live starting) and the matcher must follow. + apply_flag_props(&props, &mut room.visible, &mut room.open); + // Not master-gated: Photon did not enforce it either, the game gates on + // IsMasterClient client-side. + for target in room.active_conns().into_iter().filter(|c| *c != id) { + out.push((target, ServerMsg::RoomPropsChanged { props: props.clone() })); + } + } + + fn rpc(&mut self, id: ConnId, name: String, params: Vec, out: &mut Outbox) { + let Some(room) = self.room_of(id) else { + return; + }; + let Some(room) = self.rooms.get(&room) else { + return; + }; + let Some(player) = room.player_by_conn(id) else { + return; + }; + let sender_actor = player.actor; + // AllViaServer: the sender executes on the echo, not at send time, so it is a + // recipient too. + for target in room.active_conns() { + out.push((target, ServerMsg::Rpc { + sender_actor, + name: name.clone(), + params: params.clone(), + })); + } + } + + fn room_of(&self, id: ConnId) -> Option { + self.conns.get(&id).and_then(|c| c.room.clone()) + } + + // --- timers --------------------------------------------------------------- + + // Closes connections silent past LIVENESS_TIMEOUT, expires held slots past + // INACTIVE_TTL and destroys rooms that have had no active player for EMPTY_ROOM_TTL. + // Called once a second by the sweeper task. + pub fn sweep(&mut self, now: Instant) { + self.reap_idle_connections(now); + let mut out: Outbox = Vec::new(); + for room in self.rooms.values_mut() { + let expired: Vec = room + .players + .iter() + .filter(|p| { + p.inactive_since + .is_some_and(|since| now.duration_since(since) >= INACTIVE_TTL) + }) + .map(|p| p.actor) + .collect(); + if expired.is_empty() { + continue; + } + room.players.retain(|p| !expired.contains(&p.actor)); + // The rejoin window closed: the slot is gone for good, which is a plain + // (non-inactive) leave as far as the remaining mirrors are concerned. + for actor in expired { + for target in room.active_conns() { + out.push((target, ServerMsg::PlayerLeft { actor, inactive: false })); + } + } + if let Some(master) = room.reelect_master() { + for target in room.active_conns() { + out.push((target, ServerMsg::MasterSwitched { new_master_actor: master })); + } + } + room.touch_empty(now); + } + + let dead: Vec = self + .rooms + .iter() + .filter(|(_, room)| { + room.empty_since + .is_some_and(|since| now.duration_since(since) >= EMPTY_ROOM_TTL) + }) + .map(|(name, _)| name.clone()) + .collect(); + for name in dead { + self.rooms.remove(&name); + } + self.flush(out); + } + + // A connection nothing has been heard from for LIVENESS_TIMEOUT is treated as gone: + // it is told why, closed, and then run through the ORDINARY disconnect path, so its + // actor goes inactive, its seat is held for the usual 60s rejoin window and the room + // empties and dies on the usual timers. Nothing here is special-cased — a zombie is + // just a client that dropped without the TCP layer ever admitting it. + fn reap_idle_connections(&mut self, now: Instant) { + let idle: Vec = self + .conns + .iter() + .filter(|(_, conn)| now.duration_since(conn.last_seen) >= LIVENESS_TIMEOUT) + .map(|(id, _)| *id) + .collect(); + for id in idle { + println!( + "multi_live/ws: conn {} silent for {}s — closing (liveness)", + id, + LIVENESS_TIMEOUT.as_secs() + ); + // Queued before disconnect, which drops the connection: the writer task closes + // the socket on Close and its reader's stream then ends, so the reader's own + // disconnect call finds the connection already gone and does nothing. + self.send(id, Outbound::Msg(ServerMsg::Kicked { cause: KICK_IDLE_TIMEOUT })); + self.send(id, Outbound::Close(CLOSE_IDLE_TIMEOUT)); + self.disconnect(id, now); + } + } + + // --- plumbing ------------------------------------------------------------- + + // Still under the registry lock: pushing into an unbounded channel cannot block, so + // the whole broadcast lands in FIFO order before any other operation can interleave. + fn flush(&mut self, out: Outbox) { + for (target, msg) in out { + self.send(target, Outbound::Msg(msg)); + } + } + + pub fn send(&mut self, target: ConnId, msg: Outbound) { + if let Some(conn) = self.conns.get(&target) { + // A send error only means the writer task is already gone; the disconnect + // path will clean the connection up. + let _ = conn.tx.send(msg); + } + } + + // --- introspection (tests, and a future webui panel) ---------------------- + + // Whether the room carrying this live token is a private (join-by-code) party, or None + // when no live room claims the token. + // + // The token is matched against the ROOM bag rather than against the poster's account: + // all up to four party members post the same token, and only the master ever wrote it, + // so the bag is the one place the HTTP and relay halves meet. Room names are globally + // unique but a token is not addressable by name, so this is a scan — the registry holds + // at most a handful of live rooms and this runs once per /multi_live/end. + pub fn token_room_is_private(&self, token: &str) -> Option { + if token.is_empty() { + // An unset room prop reads back as "" on the client, which would otherwise + // match every room that never had a token pushed. + return None; + } + self.rooms + .values() + .find(|room| room.props.get_str(PROP_ROOM_TOKEN) == Some(token)) + .map(|room| room.private) + } + + #[allow(dead_code)] + pub fn room_count(&self) -> usize { + self.rooms.len() + } + + #[allow(dead_code)] + pub fn connection_count(&self) -> usize { + self.conns.len() + } +} + +// What /multi_live/start asks, while the room is certainly still alive: is this a private +// party, or does no live room claim the token at all? The distinction matters to the +// caller — an answer is recorded on the start record, a miss is not (see +// multi_live::record_room_privacy). +pub fn token_room_privacy(token: &str) -> Option { + registry().token_room_is_private(token) +} + +// The same question with the miss folded away, for a start record from before the answer +// was recorded at start time. +// +// A token no live room claims answers `false`. The room is torn down as soon as its last +// active player leaves cleanly, and an end can arrive after that (a client that quit the +// room before its POST landed, or a server restart), so "unknown" has to resolve to +// something — and public is the official behaviour, which is the safe side to be wrong on. +pub fn token_is_private_room(token: &str) -> bool { + token_room_privacy(token).unwrap_or(false) +} + +#[cfg(test)] +mod tests { + use super::*; + use tokio::sync::mpsc::{unbounded_channel, UnboundedReceiver}; + + // Each test drives its own Registry rather than the global one, so they can run in + // parallel without sharing rooms. + struct Harness { + reg: Registry, + now: Instant, + rx: HashMap>, + } + + impl Harness { + fn new() -> Self { + Harness { reg: Registry::new(), now: Instant::now(), rx: HashMap::new() } + } + + fn connect(&mut self, user_id: i64) -> ConnId { + let (tx, rx) = unbounded_channel(); + let id = self.reg.connect(user_id, tx, self.now); + self.rx.insert(id, rx); + id + } + + fn send(&mut self, id: ConnId, msg: ClientMsg) { + self.reg.handle(id, msg, self.now); + } + + fn advance(&mut self, by: Duration) { + self.now += by; + } + + fn sweep(&mut self) { + self.reg.sweep(self.now); + } + + // Everything queued for this connection since the last drain, closes and pongs + // included — the liveness sweep's answer is a Kicked followed by a Close, and + // `drain` throws both away. + fn drain_raw(&mut self, id: ConnId) -> Vec { + let mut out = Vec::new(); + let rx = self.rx.get_mut(&id).expect("known connection"); + while let Ok(msg) = rx.try_recv() { + out.push(msg); + } + out + } + + // Everything queued for this connection since the last drain. + fn drain(&mut self, id: ConnId) -> Vec { + let mut out = Vec::new(); + let rx = self.rx.get_mut(&id).expect("known connection"); + while let Ok(msg) = rx.try_recv() { + match msg { + Outbound::Msg(msg) => out.push(msg), + Outbound::Pong(_) | Outbound::Close(_) => {} + } + } + out + } + + fn clear(&mut self, ids: &[ConnId]) { + for id in ids { + let _ = self.drain(*id); + } + } + + fn create(&mut self, id: ConnId, name: &str, props: Vec<(&str, Value)>) { + self.send(id, ClientMsg::CreateRoom { + name: name.to_string(), + max_players: 4, + visible: true, + open: true, + props: Map::from_pairs(props), + lobby_prop_keys: vec![], + player_props: Map::new(), + }); + } + } + + fn joined_room(msgs: &[ServerMsg]) -> (String, i32, i32, Vec<(i32, Map)>) { + for msg in msgs { + if let ServerMsg::JoinedRoom { room_name, your_actor, master_actor, players, .. } = msg { + return (room_name.clone(), *your_actor, *master_actor, players.clone()); + } + } + panic!("expected JoinedRoom in {:?}", msgs); + } + + #[test] + fn create_seats_the_creator_as_master_actor_one() { + let mut h = Harness::new(); + let a = h.connect(1); + h.send(a, ClientMsg::JoinLobby { name: "MultiEventLobby_31".into() }); + assert_eq!(h.drain(a), vec![ServerMsg::JoinedLobby]); + + h.create(a, "room", vec![]); + let (name, actor, master, players) = joined_room(&h.drain(a)); + assert_eq!(name, "room"); + assert_eq!(actor, 1); + assert_eq!(master, 1); + assert_eq!(players.len(), 1); + assert_eq!(h.reg.room_count(), 1); + } + + #[test] + fn empty_create_name_generates_one_in_the_official_range() { + let mut h = Harness::new(); + let a = h.connect(1); + h.send(a, ClientMsg::CreateRoom { + name: String::new(), + max_players: 4, + visible: true, + open: true, + props: Map::new(), + lobby_prop_keys: vec![], + player_props: Map::new(), + }); + let (name, _, _, _) = joined_room(&h.drain(a)); + let id: i64 = name.parse().expect("generated room name is numeric"); + assert!((1000000..=99999999).contains(&id), "{} out of range", id); + } + + #[test] + fn duplicate_room_name_fails_with_photon_code() { + let mut h = Harness::new(); + let a = h.connect(1); + let b = h.connect(2); + h.create(a, "dup", vec![]); + h.create(b, "dup", vec![]); + assert_eq!( + h.drain(b), + vec![ServerMsg::CreateFailed { + code: ERR_GAME_ID_ALREADY_EXISTS, + msg: "GameIdAlreadyExists".into(), + }] + ); + } + + #[test] + fn join_reports_absent_full_and_closed_rooms() { + let mut h = Harness::new(); + let a = h.connect(1); + let b = h.connect(2); + + h.send(b, ClientMsg::JoinRoom { name: "nope".into(), player_props: Map::new() }); + assert_eq!( + h.drain(b), + vec![ServerMsg::JoinFailed { + code: ERR_GAME_DOES_NOT_EXIST, + msg: "GameDoesNotExist".into(), + }] + ); + + h.send(a, ClientMsg::CreateRoom { + name: "closed".into(), + max_players: 4, + visible: true, + open: false, + props: Map::new(), + lobby_prop_keys: vec![], + player_props: Map::new(), + }); + h.send(b, ClientMsg::JoinRoom { name: "closed".into(), player_props: Map::new() }); + assert_eq!( + h.drain(b), + vec![ServerMsg::JoinFailed { code: ERR_GAME_CLOSED, msg: "GameClosed".into() }] + ); + + let c = h.connect(3); + h.send(c, ClientMsg::CreateRoom { + name: "solo".into(), + max_players: 1, + visible: true, + open: true, + props: Map::new(), + lobby_prop_keys: vec![], + player_props: Map::new(), + }); + h.send(b, ClientMsg::JoinRoom { name: "solo".into(), player_props: Map::new() }); + assert_eq!( + h.drain(b), + vec![ServerMsg::JoinFailed { code: ERR_GAME_FULL, msg: "GameFull".into() }] + ); + } + + #[test] + fn joiners_get_a_snapshot_and_everyone_else_gets_player_entered() { + let mut h = Harness::new(); + let a = h.connect(1); + let b = h.connect(2); + let c = h.connect(3); + h.create(a, "room", vec![("C0", Value::Int(4))]); + h.clear(&[a]); + + h.send(b, ClientMsg::JoinRoom { name: "room".into(), player_props: Map::new() }); + let (_, actor_b, master, players) = joined_room(&h.drain(b)); + assert_eq!(actor_b, 2); + assert_eq!(master, 1); + assert_eq!(players.iter().map(|(a, _)| *a).collect::>(), vec![1, 2]); + assert_eq!( + h.drain(a), + vec![ServerMsg::PlayerEntered { actor: 2, props: Map::new() }] + ); + + h.send(c, ClientMsg::JoinRoom { name: "room".into(), player_props: Map::new() }); + let (_, actor_c, _, _) = joined_room(&h.drain(c)); + assert_eq!(actor_c, 3); + assert_eq!( + h.drain(a), + vec![ServerMsg::PlayerEntered { actor: 3, props: Map::new() }] + ); + assert_eq!( + h.drain(b), + vec![ServerMsg::PlayerEntered { actor: 3, props: Map::new() }] + ); + } + + // One account, several connections, several seats — kept deliberately so a party can + // be tested from a single account (Photon keyed a seat on the connection too). The + // only thing that can refuse the second connection is real occupancy, so GameFull + // never stands in for a self-join collision. + #[test] + fn one_account_may_take_several_seats() { + let mut h = Harness::new(); + let a = h.connect(1); + let a2 = h.connect(1); + h.create(a, "room", vec![]); + h.clear(&[a]); + + h.send(a2, ClientMsg::JoinRoom { name: "room".into(), player_props: Map::new() }); + let (_, actor, master, players) = joined_room(&h.drain(a2)); + assert_eq!(actor, 2, "the creator's own account gets a second actor"); + assert_eq!(master, 1); + assert_eq!(players.len(), 2); + + // ...until the room is genuinely at capacity, which is what GameFull means. + let c = h.connect(1); + h.send(c, ClientMsg::CreateRoom { + name: "solo".into(), + max_players: 1, + visible: true, + open: true, + props: Map::new(), + lobby_prop_keys: vec![], + player_props: Map::new(), + }); + let c2 = h.connect(1); + h.send(c2, ClientMsg::JoinRoom { name: "solo".into(), player_props: Map::new() }); + assert_eq!( + h.drain(c2), + vec![ServerMsg::JoinFailed { code: ERR_GAME_FULL, msg: "GameFull".into() }] + ); + } + + #[test] + fn actor_numbers_are_never_reused() { + let mut h = Harness::new(); + let a = h.connect(1); + let b = h.connect(2); + let c = h.connect(3); + h.create(a, "room", vec![]); + h.send(b, ClientMsg::JoinRoom { name: "room".into(), player_props: Map::new() }); + h.send(b, ClientMsg::LeaveRoom); + h.clear(&[a, b]); + + h.send(c, ClientMsg::JoinRoom { name: "room".into(), player_props: Map::new() }); + let (_, actor_c, _, _) = joined_room(&h.drain(c)); + assert_eq!(actor_c, 3, "actor 2 must not be handed out twice"); + } + + #[test] + fn clean_leave_hands_the_master_to_the_lowest_remaining_actor() { + let mut h = Harness::new(); + let a = h.connect(1); + let b = h.connect(2); + let c = h.connect(3); + h.create(a, "room", vec![]); + h.send(b, ClientMsg::JoinRoom { name: "room".into(), player_props: Map::new() }); + h.send(c, ClientMsg::JoinRoom { name: "room".into(), player_props: Map::new() }); + h.clear(&[a, b, c]); + + h.send(a, ClientMsg::LeaveRoom); + assert_eq!(h.drain(a), vec![ServerMsg::LeftRoom]); + let expected = vec![ + ServerMsg::PlayerLeft { actor: 1, inactive: false }, + ServerMsg::MasterSwitched { new_master_actor: 2 }, + ]; + assert_eq!(h.drain(b), expected); + assert_eq!(h.drain(c), expected); + } + + #[test] + fn last_active_clean_leave_destroys_the_room() { + let mut h = Harness::new(); + let a = h.connect(1); + h.create(a, "room", vec![]); + h.clear(&[a]); + h.send(a, ClientMsg::LeaveRoom); + assert_eq!(h.drain(a), vec![ServerMsg::LeftRoom]); + assert_eq!(h.reg.room_count(), 0); + } + + #[test] + fn unclean_disconnect_holds_the_slot_then_expires_it() { + let mut h = Harness::new(); + let a = h.connect(1); + let b = h.connect(2); + h.create(a, "room", vec![]); + h.send(b, ClientMsg::JoinRoom { name: "room".into(), player_props: Map::new() }); + h.clear(&[a, b]); + + let now = h.now; + h.reg.disconnect(a, now); + assert_eq!( + h.drain(b), + vec![ + ServerMsg::PlayerLeft { actor: 1, inactive: true }, + ServerMsg::MasterSwitched { new_master_actor: 2 }, + ] + ); + assert_eq!(h.reg.room_count(), 1); + + // Still inside the window. + h.advance(Duration::from_secs(59)); + h.sweep(); + assert_eq!(h.drain(b), vec![]); + + // Window closed: the seat is released for good. + h.advance(Duration::from_secs(2)); + h.sweep(); + assert_eq!(h.drain(b), vec![ServerMsg::PlayerLeft { actor: 1, inactive: false }]); + assert_eq!(h.reg.room_count(), 1); + } + + #[test] + fn rejoin_restores_the_held_actor_and_props() { + let mut h = Harness::new(); + let a = h.connect(1); + let b = h.connect(2); + h.create(a, "room", vec![]); + h.send(b, ClientMsg::JoinRoom { name: "room".into(), player_props: Map::new() }); + h.send(a, ClientMsg::SetPlayerProps { + props: Map::from_pairs(vec![("A", Value::Int(42))]), + }); + h.clear(&[a, b]); + + let now = h.now; + h.reg.disconnect(a, now); + h.clear(&[b]); + + // Reconnect: a new socket for the same account reclaims actor 1 with its props. + let a2 = h.connect(1); + h.send(a2, ClientMsg::Rejoin { name: "room".into(), player_props: Map::new() }); + let (_, actor, master, players) = joined_room(&h.drain(a2)); + assert_eq!(actor, 1); + // Mastership stays with actor 2: Photon does not hand it back on rejoin. + assert_eq!(master, 2); + assert_eq!(players.len(), 2); + assert_eq!(players[0].1.get_int("A"), Some(42)); + assert_eq!( + h.drain(b), + vec![ServerMsg::PlayerEntered { + actor: 1, + props: Map::from_pairs(vec![("A", Value::Int(42))]), + }] + ); + } + + // --- join-time player properties (docs "Player properties at join time") ------- + // + // Photon's OpJoinRoom carried actorProperties, so a peer's OnPlayerEnteredRoom fired + // with the joiner's bag already populated and the official client parses it right + // there (MemberContent.InitializePlayer -> uint.Parse on player key "B"). An empty + // bag is an ArgumentNullException on every existing client, not a cosmetic delay. + + fn player_bag() -> Map { + Map::from_pairs(vec![ + ("A", Value::Str("Nozomi".into())), + ("B", Value::Str("1001".into())), + ("E", Value::Int(120)), + ]) + } + + #[test] + fn player_entered_carries_the_joiners_props() { + let mut h = Harness::new(); + let a = h.connect(1); + let b = h.connect(2); + h.create(a, "room", vec![]); + h.clear(&[a]); + + h.send(b, ClientMsg::JoinRoom { name: "room".into(), player_props: player_bag() }); + + // The sitting client must see the real bag on the entry event itself. + assert_eq!( + h.drain(a), + vec![ServerMsg::PlayerEntered { actor: 2, props: player_bag() }] + ); + } + + #[test] + fn the_joined_room_snapshot_agrees_with_player_entered() { + let mut h = Harness::new(); + let a = h.connect(1); + let b = h.connect(2); + let c = h.connect(3); + + // The creator's own bag is seeded too, so it is correct in the snapshot that a + // LATER joiner receives. + h.send(a, ClientMsg::CreateRoom { + name: "room".into(), + max_players: 4, + visible: true, + open: true, + props: Map::new(), + lobby_prop_keys: vec![], + player_props: Map::from_pairs(vec![("B", Value::Str("7".into()))]), + }); + h.send(b, ClientMsg::JoinRoom { name: "room".into(), player_props: player_bag() }); + h.clear(&[a, b]); + + h.send(c, ClientMsg::JoinRoom { + name: "room".into(), + player_props: Map::from_pairs(vec![("B", Value::Str("9".into()))]), + }); + + let (_, actor, _, players) = joined_room(&h.drain(c)); + assert_eq!(actor, 3); + assert_eq!(players.len(), 3); + // Nobody in the snapshot has an empty bag, including the newcomer itself. + assert_eq!(players[0].1.get("B"), Some(&Value::Str("7".into()))); + assert_eq!(players[1].1, player_bag()); + assert_eq!(players[2].1.get("B"), Some(&Value::Str("9".into()))); + + // ...and what the sitting clients were told matches that snapshot exactly. + let entered = ServerMsg::PlayerEntered { + actor: 3, + props: Map::from_pairs(vec![("B", Value::Str("9".into()))]), + }; + assert_eq!(h.drain(a), vec![entered.clone()]); + assert_eq!(h.drain(b), vec![entered]); + } + + #[test] + fn join_random_and_create_seed_the_bag_too() { + let mut h = Harness::new(); + let a = h.connect(1); + let b = h.connect(2); + h.send(a, ClientMsg::JoinLobby { name: "L".into() }); + h.send(b, ClientMsg::JoinLobby { name: "L".into() }); + h.create(a, "room", vec![("C1", Value::Int(5000)), ("C0", Value::Int(4))]); + h.clear(&[a, b]); + + h.send(b, ClientMsg::JoinRandom { + power_min: 0, + power_max: 10000, + levels: vec![Value::Int(4)], + player_props: player_bag(), + }); + + assert_eq!( + h.drain(a), + vec![ServerMsg::PlayerEntered { actor: 2, props: player_bag() }] + ); + } + + #[test] + fn an_empty_join_bag_leaves_the_actor_empty() { + // A client that has committed nothing yet is legal and must not be special-cased. + let mut h = Harness::new(); + let a = h.connect(1); + let b = h.connect(2); + h.create(a, "room", vec![]); + h.clear(&[a]); + + h.send(b, ClientMsg::JoinRoom { name: "room".into(), player_props: Map::new() }); + assert_eq!( + h.drain(a), + vec![ServerMsg::PlayerEntered { actor: 2, props: Map::new() }] + ); + } + + #[test] + fn rejoin_merges_the_fresh_bag_over_the_held_one() { + let mut h = Harness::new(); + let a = h.connect(1); + let b = h.connect(2); + h.create(a, "room", vec![]); + h.send(b, ClientMsg::JoinRoom { name: "room".into(), player_props: Map::new() }); + // Actor 1's state before the drop: a name, a title and a status. + h.send(a, ClientMsg::SetPlayerProps { + props: Map::from_pairs(vec![ + ("A", Value::Str("Nozomi".into())), + ("B", Value::Str("1001".into())), + ("F", Value::Int(1)), + ]), + }); + h.clear(&[a, b]); + + let now = h.now; + h.reg.disconnect(a, now); + h.clear(&[b]); + + // The rejoiner's status has moved on; its name is unchanged and it no longer + // writes the title, which must therefore survive from the held bag. + let a2 = h.connect(1); + h.send(a2, ClientMsg::Rejoin { + name: "room".into(), + player_props: Map::from_pairs(vec![("F", Value::Int(5))]), + }); + + let merged = Map::from_pairs(vec![ + ("A", Value::Str("Nozomi".into())), + ("B", Value::Str("1001".into())), + ("F", Value::Int(5)), + ]); + let (_, actor, _, players) = joined_room(&h.drain(a2)); + assert_eq!(actor, 1); + assert_eq!(players[0].1, merged); + assert_eq!( + h.drain(b), + vec![ServerMsg::PlayerEntered { actor: 1, props: merged }] + ); + } + + #[test] + fn rejoin_after_the_window_or_into_nothing_fails() { + let mut h = Harness::new(); + let a = h.connect(1); + let b = h.connect(2); + h.create(a, "room", vec![]); + h.send(b, ClientMsg::JoinRoom { name: "room".into(), player_props: Map::new() }); + h.clear(&[a, b]); + + let now = h.now; + h.reg.disconnect(a, now); + h.advance(Duration::from_secs(61)); + h.sweep(); + + let a2 = h.connect(1); + h.send(a2, ClientMsg::Rejoin { name: "room".into(), player_props: Map::new() }); + assert_eq!( + h.drain(a2), + vec![ServerMsg::JoinFailed { + code: ERR_GAME_DOES_NOT_EXIST, + msg: "RejoinerNotFound".into(), + }] + ); + + let c = h.connect(9); + h.send(c, ClientMsg::Rejoin { name: "gone".into(), player_props: Map::new() }); + assert_eq!( + h.drain(c), + vec![ServerMsg::JoinFailed { + code: ERR_GAME_DOES_NOT_EXIST, + msg: "GameDoesNotExist".into(), + }] + ); + } + + #[test] + fn rejoin_into_an_emptied_room_takes_the_master() { + let mut h = Harness::new(); + let a = h.connect(1); + let b = h.connect(2); + h.create(a, "room", vec![]); + h.send(b, ClientMsg::JoinRoom { name: "room".into(), player_props: Map::new() }); + h.clear(&[a, b]); + + let now = h.now; + h.reg.disconnect(b, now); + h.reg.disconnect(a, now); + assert_eq!(h.reg.room_count(), 1); + + let a2 = h.connect(1); + h.send(a2, ClientMsg::Rejoin { name: "room".into(), player_props: Map::new() }); + let (_, actor, master, _) = joined_room(&h.drain(a2)); + assert_eq!(actor, 1); + assert_eq!(master, 1); + } + + // --- liveness (the zombie-socket sweep) ---------------------------------------- + // + // A killed client leaves a socket the OS never FINs: the reader task never returns, + // so disconnect is never called and the seat is held ACTIVE forever. These pin the + // sweep that ends that, and pin that it ends it through the ORDINARY disconnect path. + + #[test] + fn a_silent_connection_is_closed_and_its_seat_falls_to_the_rejoin_window() { + let mut h = Harness::new(); + let a = h.connect(1); + let b = h.connect(2); + h.create(a, "room", vec![]); + h.send(b, ClientMsg::JoinRoom { name: "room".into(), player_props: Map::new() }); + h.clear(&[a, b]); + + // 45s of silence from both, then b pings (op 12) and a does not. + h.advance(Duration::from_secs(45)); + h.send(b, ClientMsg::Ping); + h.sweep(); + assert_eq!(h.drain_raw(a), vec![], "45s is well inside the window"); + + // a is now 91s silent, b only 46s. + h.advance(Duration::from_secs(46)); + h.clear(&[b]); + h.sweep(); + + // a is told why and closed... + assert_eq!( + h.drain_raw(a), + vec![ + Outbound::Msg(ServerMsg::Kicked { cause: KICK_IDLE_TIMEOUT }), + Outbound::Close(CLOSE_IDLE_TIMEOUT), + ] + ); + // ...and the room saw exactly what it sees for any unclean drop: a held slot and + // a new master. Nothing about the zombie is special-cased. + assert_eq!( + h.drain(b), + vec![ + ServerMsg::PlayerLeft { actor: 1, inactive: true }, + ServerMsg::MasterSwitched { new_master_actor: 2 }, + ] + ); + assert_eq!(h.reg.room_count(), 1); + assert_eq!(h.reg.connection_count(), 1, "the dead connection is deregistered"); + + // And the seat then expires on the ordinary rejoin timer. + h.advance(INACTIVE_TTL + Duration::from_secs(1)); + h.send(b, ClientMsg::Ping); + h.clear(&[b]); + h.sweep(); + assert_eq!(h.drain(b), vec![ServerMsg::PlayerLeft { actor: 1, inactive: false }]); + } + + #[test] + fn a_pinging_connection_is_never_reaped() { + let mut h = Harness::new(); + let a = h.connect(1); + h.create(a, "room", vec![]); + h.clear(&[a]); + + // The client's 30s ping, for ten minutes of a long live. + for _ in 0..20 { + h.advance(Duration::from_secs(30)); + h.send(a, ClientMsg::Ping); + h.sweep(); + } + assert_eq!(h.reg.connection_count(), 1); + assert_eq!(h.reg.room_count(), 1); + // Nothing but the pongs it asked for. + let out = h.drain_raw(a); + assert_eq!(out.len(), 20); + assert!( + out.iter().all(|m| matches!(m, Outbound::Msg(ServerMsg::Pong { .. }))), + "a live connection must see no Kicked and no Close: {:?}", + out + ); + } + + #[test] + fn a_silent_connection_in_no_room_is_reaped_too() { + // Sockets that authenticated and then went quiet in the lobby leak just as well. + let mut h = Harness::new(); + let a = h.connect(1); + h.send(a, ClientMsg::JoinLobby { name: "L".into() }); + h.clear(&[a]); + + h.advance(LIVENESS_TIMEOUT + Duration::from_secs(1)); + h.sweep(); + assert_eq!(h.reg.connection_count(), 0); + assert_eq!(h.drain_raw(a), vec![ + Outbound::Msg(ServerMsg::Kicked { cause: KICK_IDLE_TIMEOUT }), + Outbound::Close(CLOSE_IDLE_TIMEOUT), + ]); + } + + // Any inbound traffic counts, not just Ping: a client mid-live is writing properties + // several times a second and must never be reaped for not pinging on top of that. + #[test] + fn ordinary_traffic_keeps_a_connection_alive() { + let mut h = Harness::new(); + let a = h.connect(1); + h.create(a, "room", vec![]); + h.clear(&[a]); + + for _ in 0..4 { + h.advance(Duration::from_secs(60)); + h.send(a, ClientMsg::SetPlayerProps { + props: Map::from_pairs(vec![("LB", Value::Int(7))]), + }); + h.sweep(); + } + assert_eq!(h.reg.connection_count(), 1); + assert_eq!(h.drain_raw(a), vec![]); + } + + #[test] + fn a_room_with_no_active_players_dies_after_sixty_seconds() { + let mut h = Harness::new(); + let a = h.connect(1); + h.create(a, "room", vec![]); + let now = h.now; + h.reg.disconnect(a, now); + assert_eq!(h.reg.room_count(), 1); + + h.advance(Duration::from_secs(59)); + h.sweep(); + assert_eq!(h.reg.room_count(), 1); + + h.advance(Duration::from_secs(2)); + h.sweep(); + assert_eq!(h.reg.room_count(), 0); + } + + #[test] + fn player_props_merge_and_broadcast_the_changed_subset_to_others() { + let mut h = Harness::new(); + let a = h.connect(1); + let b = h.connect(2); + h.create(a, "room", vec![]); + h.send(b, ClientMsg::JoinRoom { name: "room".into(), player_props: Map::new() }); + h.clear(&[a, b]); + + h.send(a, ClientMsg::SetPlayerProps { + props: Map::from_pairs(vec![("A", Value::Int(1)), ("B", Value::Str("x".into()))]), + }); + // The sender applied it locally at send time; no echo. + assert_eq!(h.drain(a), vec![]); + assert_eq!( + h.drain(b), + vec![ServerMsg::PlayerPropsChanged { + actor: 1, + props: Map::from_pairs(vec![("A", Value::Int(1)), ("B", Value::Str("x".into()))]), + }] + ); + + // A second write merges: only the touched key travels, the bag keeps both. + h.send(a, ClientMsg::SetPlayerProps { + props: Map::from_pairs(vec![("A", Value::Int(2))]), + }); + assert_eq!( + h.drain(b), + vec![ServerMsg::PlayerPropsChanged { + actor: 1, + props: Map::from_pairs(vec![("A", Value::Int(2))]), + }] + ); + + let c = h.connect(3); + h.send(c, ClientMsg::JoinRoom { name: "room".into(), player_props: Map::new() }); + let (_, _, _, players) = joined_room(&h.drain(c)); + let bag = &players.iter().find(|(actor, _)| *actor == 1).unwrap().1; + assert_eq!(bag.get_int("A"), Some(2)); + assert_eq!(bag.get("B"), Some(&Value::Str("x".into()))); + } + + #[test] + fn room_props_merge_and_are_not_master_gated() { + let mut h = Harness::new(); + let a = h.connect(1); + let b = h.connect(2); + h.create(a, "room", vec![("C0", Value::Int(4)), ("C1", Value::Int(50000))]); + h.send(b, ClientMsg::JoinRoom { name: "room".into(), player_props: Map::new() }); + h.clear(&[a, b]); + + // Actor 2 is not the master and is still allowed to write. + h.send(b, ClientMsg::SetRoomProps { + props: Map::from_pairs(vec![("A", Value::Int(7))]), + }); + assert_eq!(h.drain(b), vec![]); + assert_eq!( + h.drain(a), + vec![ServerMsg::RoomPropsChanged { + props: Map::from_pairs(vec![("A", Value::Int(7))]), + }] + ); + + let c = h.connect(3); + h.send(c, ClientMsg::JoinRoom { name: "room".into(), player_props: Map::new() }); + let msgs = h.drain(c); + let ServerMsg::JoinedRoom { room_props, .. } = &msgs[0] else { + panic!("expected JoinedRoom"); + }; + assert_eq!(room_props.get_int("C0"), Some(4)); + assert_eq!(room_props.get_int("C1"), Some(50000)); + assert_eq!(room_props.get_int("A"), Some(7)); + } + + #[test] + fn rpc_echoes_to_every_active_player_including_the_sender() { + let mut h = Harness::new(); + let a = h.connect(1); + let b = h.connect(2); + h.create(a, "room", vec![]); + h.send(b, ClientMsg::JoinRoom { name: "room".into(), player_props: Map::new() }); + h.clear(&[a, b]); + + h.send(b, ClientMsg::Rpc { + name: "SendStamp".into(), + params: vec![Value::Int(5)], + }); + let expected = vec![ServerMsg::Rpc { + sender_actor: 2, + name: "SendStamp".into(), + params: vec![Value::Int(5)], + }]; + assert_eq!(h.drain(a), expected); + assert_eq!(h.drain(b), expected, "AllViaServer: the sender runs on the echo"); + } + + #[test] + fn one_senders_updates_keep_their_order_when_interleaved() { + // The live-start/live-end barriers poll mirrors of remote properties; two writes + // from one sender arriving out of order deadlocks them. Interleave three senders + // and assert each receiver still sees every sender's writes in send order. + let mut h = Harness::new(); + let a = h.connect(1); + let b = h.connect(2); + let c = h.connect(3); + h.create(a, "room", vec![]); + h.send(b, ClientMsg::JoinRoom { name: "room".into(), player_props: Map::new() }); + h.send(c, ClientMsg::JoinRoom { name: "room".into(), player_props: Map::new() }); + h.clear(&[a, b, c]); + + for step in 0..20 { + for (conn, actor) in [(a, 1), (b, 2), (c, 3)] { + h.send(conn, ClientMsg::SetPlayerProps { + props: Map::from_pairs(vec![("LB", Value::Int(step * 10 + actor))]), + }); + } + // A room prop and an RPC in the middle of the stream must not reorder it. + h.send(a, ClientMsg::SetRoomProps { + props: Map::from_pairs(vec![("A", Value::Int(step))]), + }); + h.send(b, ClientMsg::Rpc { name: "_CountStop".into(), params: vec![] }); + } + + for receiver in [a, b, c] { + let msgs = h.drain(receiver); + for (actor, offset) in [(1, 1), (2, 2), (3, 3)] { + let seen: Vec = msgs + .iter() + .filter_map(|m| match m { + ServerMsg::PlayerPropsChanged { actor: got, props } if *got == actor => { + props.get_int("LB") + } + _ => None, + }) + .collect(); + let expected: Vec = (0..20).map(|step| step * 10 + offset).collect(); + if receiver == [a, b, c][(actor - 1) as usize] { + // Own writes are never echoed back. + assert!(seen.is_empty(), "actor {} saw its own props", actor); + } else { + assert_eq!(seen, expected, "actor {} order seen by {}", actor, receiver); + } + } + let room_props: Vec = msgs + .iter() + .filter_map(|m| match m { + ServerMsg::RoomPropsChanged { props } => props.get_int("A"), + _ => None, + }) + .collect(); + if receiver == a { + assert!(room_props.is_empty()); + } else { + assert_eq!(room_props, (0..20).collect::>()); + } + } + } + + // --- JoinRandom ----------------------------------------------------------- + + fn lobby_room(h: &mut Harness, user: i64, lobby: &str, name: &str, level: i32, power: i32) -> ConnId { + let id = h.connect(user); + h.send(id, ClientMsg::JoinLobby { name: lobby.to_string() }); + h.create(id, name, vec![("C0", Value::Int(level)), ("C1", Value::Int(power))]); + h.clear(&[id]); + id + } + + fn join_random(h: &mut Harness, id: ConnId, min: i32, max: i32, levels: &[i32]) -> Vec { + h.send(id, ClientMsg::JoinRandom { + power_min: min, + power_max: max, + levels: levels.iter().map(|l| Value::Int(*l)).collect(), + player_props: Map::new(), + }); + h.drain(id) + } + + #[test] + fn join_random_power_window_is_exclusive_at_both_ends() { + let mut h = Harness::new(); + lobby_room(&mut h, 1, "L", "exact_low", 4, 100); + lobby_room(&mut h, 2, "L", "exact_high", 4, 200); + + let seeker = h.connect(9); + h.send(seeker, ClientMsg::JoinLobby { name: "L".into() }); + h.clear(&[seeker]); + + // Both rooms sit exactly on a bound, which the official SQL excludes. + let msgs = join_random(&mut h, seeker, 100, 200, &[]); + assert_eq!( + msgs, + vec![ServerMsg::JoinFailed { + code: ERR_NO_RANDOM_MATCH_FOUND, + msg: "NoRandomMatchFound".into(), + }] + ); + + // Widen by one on each side and both become eligible. + let msgs = join_random(&mut h, seeker, 99, 201, &[]); + let (name, _, _, _) = joined_room(&msgs); + assert!(name == "exact_low" || name == "exact_high"); + } + + #[test] + fn join_random_honours_the_level_filter_and_empty_means_any() { + let mut h = Harness::new(); + lobby_room(&mut h, 1, "L", "hard", 4, 500); + + let seeker = h.connect(9); + h.send(seeker, ClientMsg::JoinLobby { name: "L".into() }); + h.clear(&[seeker]); + + let msgs = join_random(&mut h, seeker, 0, 1000, &[1, 2]); + assert!(matches!(msgs[0], ServerMsg::JoinFailed { code: ERR_NO_RANDOM_MATCH_FOUND, .. })); + + let msgs = join_random(&mut h, seeker, 0, 1000, &[3, 4]); + assert_eq!(joined_room(&msgs).0, "hard"); + h.send(seeker, ClientMsg::LeaveRoom); + h.clear(&[seeker]); + + // The helper lane sends no levels at all: everything matches. + let msgs = join_random(&mut h, seeker, 0, 1000, &[]); + assert_eq!(joined_room(&msgs).0, "hard"); + } + + #[test] + fn join_random_skips_invisible_closed_full_and_other_lobbies() { + let mut h = Harness::new(); + let seeker = h.connect(9); + h.send(seeker, ClientMsg::JoinLobby { name: "L".into() }); + h.clear(&[seeker]); + + // Wrong lobby. + lobby_room(&mut h, 1, "OTHER", "elsewhere", 4, 500); + + // Invisible (a private room joined by its 6 character code). + let priv_host = h.connect(2); + h.send(priv_host, ClientMsg::JoinLobby { name: "L".into() }); + h.send(priv_host, ClientMsg::CreateRoom { + name: "042719".into(), + max_players: 4, + visible: false, + open: true, + props: Map::from_pairs(vec![("C0", Value::Int(4)), ("C1", Value::Int(500))]), + lobby_prop_keys: vec![], + player_props: Map::new(), + }); + h.clear(&[priv_host]); + + // Closed. + let closed_host = h.connect(3); + h.send(closed_host, ClientMsg::JoinLobby { name: "L".into() }); + h.send(closed_host, ClientMsg::CreateRoom { + name: "closed".into(), + max_players: 4, + visible: true, + open: false, + props: Map::from_pairs(vec![("C0", Value::Int(4)), ("C1", Value::Int(500))]), + lobby_prop_keys: vec![], + player_props: Map::new(), + }); + h.clear(&[closed_host]); + + // Full. + let full_host = h.connect(4); + h.send(full_host, ClientMsg::JoinLobby { name: "L".into() }); + h.send(full_host, ClientMsg::CreateRoom { + name: "full".into(), + max_players: 1, + visible: true, + open: true, + props: Map::from_pairs(vec![("C0", Value::Int(4)), ("C1", Value::Int(500))]), + lobby_prop_keys: vec![], + player_props: Map::new(), + }); + h.clear(&[full_host]); + + // A room with no C1 at all cannot satisfy the power window. + let bare_host = h.connect(5); + h.send(bare_host, ClientMsg::JoinLobby { name: "L".into() }); + h.create(bare_host, "bare", vec![("C0", Value::Int(4))]); + h.clear(&[bare_host]); + + let msgs = join_random(&mut h, seeker, 0, 1000, &[4]); + assert_eq!( + msgs, + vec![ServerMsg::JoinFailed { + code: ERR_NO_RANDOM_MATCH_FOUND, + msg: "NoRandomMatchFound".into(), + }] + ); + + // A held (inactive) slot still counts against capacity. + let two_host = h.connect(6); + h.send(two_host, ClientMsg::JoinLobby { name: "L".into() }); + h.send(two_host, ClientMsg::CreateRoom { + name: "two".into(), + max_players: 2, + visible: true, + open: true, + props: Map::from_pairs(vec![("C0", Value::Int(4)), ("C1", Value::Int(500))]), + lobby_prop_keys: vec![], + player_props: Map::new(), + }); + let guest = h.connect(7); + h.send(guest, ClientMsg::JoinRoom { name: "two".into(), player_props: Map::new() }); + h.clear(&[two_host, guest]); + let now = h.now; + h.reg.disconnect(guest, now); + h.clear(&[two_host]); + + let msgs = join_random(&mut h, seeker, 0, 1000, &[4]); + assert!(matches!(msgs[0], ServerMsg::JoinFailed { code: ERR_NO_RANDOM_MATCH_FOUND, .. })); + } + + #[test] + fn join_random_fills_the_most_populated_room_then_the_oldest() { + let mut h = Harness::new(); + // Three eligible rooms, created oldest first. + let host1 = lobby_room(&mut h, 1, "L", "oldest", 4, 500); + let _host2 = lobby_room(&mut h, 2, "L", "middle", 4, 500); + let host3 = lobby_room(&mut h, 3, "L", "newest", 4, 500); + + // Put a second body in "newest" so it is the most populated. + let extra = h.connect(4); + h.send(extra, ClientMsg::JoinRoom { name: "newest".into(), player_props: Map::new() }); + h.clear(&[host1, host3, extra]); + + let seeker = h.connect(9); + h.send(seeker, ClientMsg::JoinLobby { name: "L".into() }); + h.clear(&[seeker]); + let msgs = join_random(&mut h, seeker, 0, 1000, &[4]); + assert_eq!(joined_room(&msgs).0, "newest", "most populated wins"); + h.send(seeker, ClientMsg::LeaveRoom); + h.clear(&[seeker]); + + // "newest" is now full-ish but not full; make it full so the tie between the two + // one-player rooms decides on age. + let f1 = h.connect(10); + let f2 = h.connect(11); + h.send(f1, ClientMsg::JoinRoom { name: "newest".into(), player_props: Map::new() }); + h.send(f2, ClientMsg::JoinRoom { name: "newest".into(), player_props: Map::new() }); + h.clear(&[f1, f2]); + + let msgs = join_random(&mut h, seeker, 0, 1000, &[4]); + assert_eq!(joined_room(&msgs).0, "oldest", "equal population falls back to oldest"); + } + + #[test] + fn join_random_is_scoped_to_the_current_lobby() { + let mut h = Harness::new(); + lobby_room(&mut h, 1, "MultiEventLobby_31", "a", 4, 500); + let seeker = h.connect(9); + h.send(seeker, ClientMsg::JoinLobby { name: "MultiEventLobby_32".into() }); + h.clear(&[seeker]); + let msgs = join_random(&mut h, seeker, 0, 1000, &[]); + assert!(matches!(msgs[0], ServerMsg::JoinFailed { code: ERR_NO_RANDOM_MATCH_FOUND, .. })); + + h.send(seeker, ClientMsg::JoinLobby { name: "MultiEventLobby_31".into() }); + h.clear(&[seeker]); + let msgs = join_random(&mut h, seeker, 0, 1000, &[]); + assert_eq!(joined_room(&msgs).0, "a"); + } + + #[test] + fn join_by_code_ignores_visibility_and_the_lobby() { + let mut h = Harness::new(); + let host = h.connect(1); + h.send(host, ClientMsg::JoinLobby { name: "L".into() }); + h.send(host, ClientMsg::CreateRoom { + name: "042719".into(), + max_players: 4, + visible: false, + open: true, + props: Map::new(), + lobby_prop_keys: vec![], + player_props: Map::new(), + }); + h.clear(&[host]); + + let guest = h.connect(2); + h.send(guest, ClientMsg::JoinRoom { name: "042719".into(), player_props: Map::new() }); + assert_eq!(joined_room(&h.drain(guest)).0, "042719"); + } + + // --- #253 / #254 (Photon's IsOpen / IsVisible byte keys) ------------------ + + fn set_flags(h: &mut Harness, id: ConnId, pairs: Vec<(&str, Value)>) { + h.send(id, ClientMsg::SetRoomProps { props: Map::from_pairs(pairs) }); + } + + #[test] + fn closing_a_room_after_creation_stops_matching_and_joining() { + let mut h = Harness::new(); + let host = lobby_room(&mut h, 1, "L", "room", 4, 500); + + let seeker = h.connect(9); + h.send(seeker, ClientMsg::JoinLobby { name: "L".into() }); + h.clear(&[seeker]); + + // Matching completes: MultiMatchingView.SetRoomOpenVisible(false) pushes + // {253: false} and {254: false} through Room.IsOpen / Room.IsVisible. + set_flags(&mut h, host, vec![ + (PROP_ROOM_IS_OPEN, Value::Bool(false)), + (PROP_ROOM_IS_VISIBLE, Value::Bool(false)), + ]); + + let msgs = join_random(&mut h, seeker, 0, 1000, &[4]); + assert_eq!( + msgs, + vec![ServerMsg::JoinFailed { + code: ERR_NO_RANDOM_MATCH_FOUND, + msg: "NoRandomMatchFound".into(), + }] + ); + + h.send(seeker, ClientMsg::JoinRoom { name: "room".into(), player_props: Map::new() }); + assert_eq!( + h.drain(seeker), + vec![ServerMsg::JoinFailed { code: ERR_GAME_CLOSED, msg: "GameClosed".into() }] + ); + } + + #[test] + fn hiding_a_room_stops_matching_but_leaves_the_code_working() { + let mut h = Harness::new(); + let host = lobby_room(&mut h, 1, "L", "042719", 4, 500); + + let seeker = h.connect(9); + h.send(seeker, ClientMsg::JoinLobby { name: "L".into() }); + h.clear(&[seeker]); + + // SetRoomOpenHide: still open, no longer listed. + set_flags(&mut h, host, vec![ + (PROP_ROOM_IS_OPEN, Value::Bool(true)), + (PROP_ROOM_IS_VISIBLE, Value::Bool(false)), + ]); + + let msgs = join_random(&mut h, seeker, 0, 1000, &[4]); + assert!(matches!(msgs[0], ServerMsg::JoinFailed { code: ERR_NO_RANDOM_MATCH_FOUND, .. })); + + // The 6 character code still gets you in. + h.send(seeker, ClientMsg::JoinRoom { name: "042719".into(), player_props: Map::new() }); + assert_eq!(joined_room(&h.drain(seeker)).0, "042719"); + } + + #[test] + fn reopening_a_room_puts_it_back_in_matching() { + let mut h = Harness::new(); + let host = lobby_room(&mut h, 1, "L", "room", 4, 500); + let seeker = h.connect(9); + h.send(seeker, ClientMsg::JoinLobby { name: "L".into() }); + h.clear(&[seeker]); + + set_flags(&mut h, host, vec![ + (PROP_ROOM_IS_OPEN, Value::Bool(false)), + (PROP_ROOM_IS_VISIBLE, Value::Bool(false)), + ]); + let msgs = join_random(&mut h, seeker, 0, 1000, &[4]); + assert!(matches!(msgs[0], ServerMsg::JoinFailed { code: ERR_NO_RANDOM_MATCH_FOUND, .. })); + + // A cancelled match reopens the room. + set_flags(&mut h, host, vec![ + (PROP_ROOM_IS_OPEN, Value::Bool(true)), + (PROP_ROOM_IS_VISIBLE, Value::Bool(true)), + ]); + let msgs = join_random(&mut h, seeker, 0, 1000, &[4]); + assert_eq!(joined_room(&msgs).0, "room"); + } + + #[test] + fn the_flags_are_mirrored_in_the_bag_as_well_as_interpreted() { + // Non-master clients poll their own CurrentRoom.IsOpen mirror, which is fed by + // RoomPropsChanged and by the JoinedRoom snapshot - so the keys have to survive + // in the bag, not just move the server's flags. + let mut h = Harness::new(); + let host = lobby_room(&mut h, 1, "L", "room", 4, 500); + let guest = h.connect(2); + h.send(guest, ClientMsg::JoinRoom { name: "room".into(), player_props: Map::new() }); + h.clear(&[host, guest]); + + set_flags(&mut h, host, vec![(PROP_ROOM_IS_OPEN, Value::Bool(false))]); + assert_eq!( + h.drain(guest), + vec![ServerMsg::RoomPropsChanged { + props: Map::from_pairs(vec![(PROP_ROOM_IS_OPEN, Value::Bool(false))]), + }] + ); + + // Nobody can join a closed room, but a rejoiner still sees the flag in the + // snapshot; check the bag through the room state the sweep-free path exposes. + let late = h.connect(3); + h.send(late, ClientMsg::JoinRoom { name: "room".into(), player_props: Map::new() }); + assert_eq!( + h.drain(late), + vec![ServerMsg::JoinFailed { code: ERR_GAME_CLOSED, msg: "GameClosed".into() }] + ); + + set_flags(&mut h, host, vec![(PROP_ROOM_IS_OPEN, Value::Bool(true))]); + h.clear(&[guest]); + h.send(late, ClientMsg::JoinRoom { name: "room".into(), player_props: Map::new() }); + let msgs = h.drain(late); + let ServerMsg::JoinedRoom { room_props, .. } = &msgs[0] else { + panic!("expected JoinedRoom"); + }; + assert_eq!(room_props.get(PROP_ROOM_IS_OPEN), Some(&Value::Bool(true))); + } + + #[test] + fn create_room_flag_props_override_the_message_fields() { + let mut h = Harness::new(); + let host = h.connect(1); + h.send(host, ClientMsg::JoinLobby { name: "L".into() }); + // Message fields say visible+open; the seeded bag says closed and hidden. + h.send(host, ClientMsg::CreateRoom { + name: "room".into(), + max_players: 4, + visible: true, + open: true, + props: Map::from_pairs(vec![ + ("C0", Value::Int(4)), + ("C1", Value::Int(500)), + (PROP_ROOM_IS_OPEN, Value::Bool(false)), + (PROP_ROOM_IS_VISIBLE, Value::Bool(false)), + ]), + lobby_prop_keys: vec![], + player_props: Map::new(), + }); + h.clear(&[host]); + + let seeker = h.connect(9); + h.send(seeker, ClientMsg::JoinLobby { name: "L".into() }); + h.clear(&[seeker]); + let msgs = join_random(&mut h, seeker, 0, 1000, &[4]); + assert!(matches!(msgs[0], ServerMsg::JoinFailed { code: ERR_NO_RANDOM_MATCH_FOUND, .. })); + h.send(seeker, ClientMsg::JoinRoom { name: "room".into(), player_props: Map::new() }); + assert_eq!( + h.drain(seeker), + vec![ServerMsg::JoinFailed { code: ERR_GAME_CLOSED, msg: "GameClosed".into() }] + ); + } + + #[test] + fn non_bool_or_absent_flag_values_leave_the_room_alone() { + let mut h = Harness::new(); + let host = lobby_room(&mut h, 1, "L", "room", 4, 500); + let seeker = h.connect(9); + h.send(seeker, ClientMsg::JoinLobby { name: "L".into() }); + h.clear(&[seeker]); + + // Photon's boxing contract says these are bools; anything else is a client bug + // and must not silently close the room. + set_flags(&mut h, host, vec![ + (PROP_ROOM_IS_OPEN, Value::Int(0)), + (PROP_ROOM_IS_VISIBLE, Value::Null), + ("A", Value::Str("unrelated".into())), + ]); + let msgs = join_random(&mut h, seeker, 0, 1000, &[4]); + assert_eq!(joined_room(&msgs).0, "room", "a non-Bool must not move the flags"); + h.send(seeker, ClientMsg::LeaveRoom); + h.clear(&[seeker]); + + // An update that mentions neither key leaves both alone too. + set_flags(&mut h, host, vec![("B", Value::Int(1))]); + let msgs = join_random(&mut h, seeker, 0, 1000, &[4]); + assert_eq!(joined_room(&msgs).0, "room"); + } + + #[test] + fn only_the_open_flag_moves_when_only_it_is_sent() { + let mut h = Harness::new(); + let host = lobby_room(&mut h, 1, "L", "room", 4, 500); + let seeker = h.connect(9); + h.send(seeker, ClientMsg::JoinLobby { name: "L".into() }); + h.clear(&[seeker]); + + // MultiPlayManager.SendCurrentRoomIsOpen(false) touches 253 only. + set_flags(&mut h, host, vec![(PROP_ROOM_IS_OPEN, Value::Bool(false))]); + h.send(seeker, ClientMsg::JoinRoom { name: "room".into(), player_props: Map::new() }); + assert_eq!( + h.drain(seeker), + vec![ServerMsg::JoinFailed { code: ERR_GAME_CLOSED, msg: "GameClosed".into() }] + ); + + // Visible is untouched, so reopening alone is enough to be matchable again. + set_flags(&mut h, host, vec![(PROP_ROOM_IS_OPEN, Value::Bool(true))]); + let msgs = join_random(&mut h, seeker, 0, 1000, &[4]); + assert_eq!(joined_room(&msgs).0, "room"); + } + + #[test] + fn props_and_rpcs_outside_a_room_are_ignored() { + let mut h = Harness::new(); + let a = h.connect(1); + h.send(a, ClientMsg::SetPlayerProps { + props: Map::from_pairs(vec![("A", Value::Int(1))]), + }); + h.send(a, ClientMsg::SetRoomProps { + props: Map::from_pairs(vec![("A", Value::Int(1))]), + }); + h.send(a, ClientMsg::Rpc { name: "SendStamp".into(), params: vec![] }); + h.send(a, ClientMsg::LeaveRoom); + assert_eq!(h.drain(a), vec![]); + } + + #[test] + fn ping_answers_with_the_server_clock() { + let mut h = Harness::new(); + let a = h.connect(1); + h.send(a, ClientMsg::Ping); + match h.drain(a).as_slice() { + [ServerMsg::Pong { server_time_ms }] => assert!(*server_time_ms > 0.0), + other => panic!("expected Pong, got {:?}", other), + } + } + + // --- live-token lookup (the /multi_live/end score-board branch) ----------------- + // + // The end handler has nothing but the room token to go on, and has to tell a private + // party from public matchmaking with it. The trap is that the live flags cannot answer + // the question: the game hides a PUBLIC room as well, the moment its members are + // decided, so privacy is latched at creation instead. + + // MultiPlayManager.CreatePrivateRoom: the 6-digit code is the room name, and the room + // is created hidden so random matching can never land in it. + fn create_private(h: &mut Harness, id: ConnId, code: &str) { + h.send(id, ClientMsg::CreateRoom { + name: code.to_string(), + max_players: 4, + visible: false, + open: true, + props: Map::new(), + lobby_prop_keys: vec![], + player_props: Map::new(), + }); + } + + // MultiEventMatchingScene: CommitCurrentRoomToken then PushCurrentRoomData, once, just + // before the live starts. + fn push_token(h: &mut Harness, id: ConnId, token: &str) { + h.send(id, ClientMsg::SetRoomProps { + props: Map::from_pairs(vec![(PROP_ROOM_TOKEN, Value::Str(token.to_string()))]), + }); + } + + #[test] + fn a_private_room_is_found_by_the_live_token_it_carries() { + let mut h = Harness::new(); + let a = h.connect(1); + let b = h.connect(2); + create_private(&mut h, a, "042719"); + h.send(b, ClientMsg::JoinRoom { name: "042719".into(), player_props: Map::new() }); + push_token(&mut h, a, "1.abcd"); + + // Every party member posts this same token, so both ends resolve to the one room. + assert_eq!(h.reg.token_room_is_private("1.abcd"), Some(true)); + } + + #[test] + fn a_public_room_stays_public_after_the_game_hides_it() { + // The regression this whole field exists for: MultiMatchingView.SetRoomOpenVisible + // (false) closes and hides a public room once its members are decided, so by the + // time the live ends the current flags look exactly like a private room's. + let mut h = Harness::new(); + let a = h.connect(1); + h.create(a, "1234567", vec![]); + push_token(&mut h, a, "1.efgh"); + assert_eq!(h.reg.token_room_is_private("1.efgh"), Some(false)); + + set_flags(&mut h, a, vec![ + (PROP_ROOM_IS_OPEN, Value::Bool(false)), + (PROP_ROOM_IS_VISIBLE, Value::Bool(false)), + ]); + assert_eq!( + h.reg.token_room_is_private("1.efgh"), + Some(false), + "a hidden public room must not be mistaken for a private party" + ); + } + + #[test] + fn an_unknown_or_empty_token_matches_no_room() { + let mut h = Harness::new(); + let a = h.connect(1); + create_private(&mut h, a, "042719"); + push_token(&mut h, a, "1.abcd"); + + assert_eq!(h.reg.token_room_is_private("2.nope"), None); + // A room whose master never pushed a token reads back "" on the client; that must + // not match the first room in the registry. + assert_eq!(h.reg.token_room_is_private(""), None); + + // And once the last active player leaves, the room — and the answer — is gone. + h.send(a, ClientMsg::LeaveRoom); + assert_eq!(h.reg.room_count(), 0); + assert_eq!(h.reg.token_room_is_private("1.abcd"), None); + } + + #[test] + fn disconnect_drops_the_connection_record() { + let mut h = Harness::new(); + let a = h.connect(1); + assert_eq!(h.reg.connection_count(), 1); + let now = h.now; + h.reg.disconnect(a, now); + assert_eq!(h.reg.connection_count(), 0); + // Idempotent: the ws task always calls it, even on a clean close. + h.reg.disconnect(a, now); + assert_eq!(h.reg.connection_count(), 0); + } +} diff --git a/src/router/multi_live/ws.rs b/src/router/multi_live/ws.rs new file mode 100644 index 0000000..62d8291 --- /dev/null +++ b/src/router/multi_live/ws.rs @@ -0,0 +1,359 @@ +// WebSocket end of the multi-live relay: GET /api/multi_live/ws. +// +// One task per connection reads frames, decodes them and drives the (synchronous, +// lock-serialised) registry in rooms.rs. A second task per connection drains that +// connection's unbounded queue into the socket. Nothing that touches the registry ever +// awaits while the lock is held, which is what lets the registry hand out a total order +// over every broadcast - see the header comment in rooms.rs. +// +// Everything the client can get wrong ends in a close: 4000 for a framing/protocol +// error, 4001 for an unauthenticated or unauthenticated-first frame, 4002 for blowing +// the inbound rate cap, 4003 for going silent long enough to be presumed dead (the +// liveness sweep - see LIVENESS_TIMEOUT in rooms.rs, and start_sweeper below). + +use std::collections::VecDeque; +use std::time::{Duration, Instant}; + +use actix_web::rt; +use actix_web::{web, HttpRequest, HttpResponse}; +use actix_ws::{AggregatedMessage, AggregatedMessageStream, CloseCode, CloseReason, Session}; +use tokio::sync::mpsc::{unbounded_channel, UnboundedReceiver, UnboundedSender}; + +use crate::router::userdata; +use super::proto::{ + self, ClientMsg, DecodeError, ServerMsg, CLOSE_PROTOCOL, CLOSE_RATE_LIMIT, + CLOSE_UNAUTHENTICATED, +}; +use super::rooms::{self, ConnId, Outbound}; + +// "Server closes idle unauthenticated connections after 10s." +const AUTH_TIMEOUT: Duration = Duration::from_secs(10); +// "a per-connection cap of 30 inbound messages/sec ... over the cap -> close 4002". +// The honest client peaks at 3-5/sec. +const RATE_LIMIT_PER_SEC: usize = 30; +const RATE_WINDOW: Duration = Duration::from_secs(1); +// Property bags and RPC params are tens of bytes; nothing legitimate comes close. +const MAX_FRAME_BYTES: usize = 64 * 1024; +// How often the inactive-actor, empty-room and connection-liveness expiries are checked. +const SWEEP_INTERVAL: Duration = Duration::from_secs(1); + +pub async fn ws(req: HttpRequest, body: web::Payload) -> Result { + // Older client builds carry incompatible multi implementations (pre-relay wire + // framing), so the upgrade itself is gated on the extended-protocol version — a + // non-101 answer makes the client's ConnectAsync fail cleanly into its official + // disconnect flow instead of mis-framing against the relay. + let protocol = crate::router::global::client_protocol_version(&req); + if protocol < super::PROTOCOL_VERSION { + println!( + "multi_live/ws: upgrade refused, X-Protocol-Version {} < {}", + protocol, + super::PROTOCOL_VERSION + ); + return Ok(HttpResponse::UpgradeRequired().finish()); + } + let (response, session, stream) = match actix_ws::handle(&req, body) { + Ok(parts) => { + println!("multi_live/ws: upgrade accepted, awaiting Auth"); + parts + } + Err(err) => { + println!("multi_live/ws: upgrade REJECTED (not a websocket handshake?): {}", err); + return Err(err); + } + }; + let stream = stream + .max_frame_size(MAX_FRAME_BYTES) + .aggregate_continuations() + .max_continuation_size(MAX_FRAME_BYTES); + + let (tx, rx) = unbounded_channel::(); + rt::spawn(writer(session, rx)); + rt::spawn(reader(stream, tx)); + Ok(response) +} + +// The expiry timers: held-slot TTL, empty-room TTL and the connection liveness window. +// +// One task for the whole process, started from run_server so it lives on the system +// arbiter. It used to be lazily started by the first WebSocket upgrade instead, which put +// it on whichever HTTP worker happened to serve that upgrade: a panic anywhere in that +// worker took the sweeper down with it, permanently and silently, and `Once` guaranteed +// nothing would ever start it again. Every room in the process would then hold its seats +// forever. +// +// Called once per run_server, unconditionally and not behind a "started already" flag: +// each run_server builds its own actix System, and a task spawned on the previous one died +// with it (the mobile path stops and restarts the server in-process). A flag would leave +// the restarted server with no sweeper at all; two sweepers, if they ever overlapped, +// would only mean the idempotent sweep runs twice a second. +pub fn start_sweeper() { + rt::spawn(async { + let mut ticker = rt::time::interval(SWEEP_INTERVAL); + loop { + ticker.tick().await; + // The guard is a temporary: it is released before the next await. + rooms::registry().sweep(Instant::now()); + } + }); +} + +// Drains this connection's FIFO queue to the socket. The queue is unbounded so that the +// registry can push a whole broadcast while holding its lock without ever blocking. +async fn writer(mut session: Session, mut rx: UnboundedReceiver) { + while let Some(out) = rx.recv().await { + match out { + Outbound::Msg(msg) => { + if session.binary(proto::encode_server(&msg)).await.is_err() { + return; + } + } + Outbound::Pong(bytes) => { + if session.pong(&bytes).await.is_err() { + return; + } + } + Outbound::Close(code) => { + let _ = session + .close(Some(CloseReason { code: CloseCode::Other(code), description: None })) + .await; + return; + } + } + } + // Queue closed: the connection was deregistered, so the socket goes with it. + let _ = session.close(None).await; +} + +async fn reader(mut stream: AggregatedMessageStream, tx: UnboundedSender) { + let Some(id) = authenticate(&mut stream, &tx).await else { + return; + }; + + let mut rate = RateLimiter::new(); + loop { + let Some(frame) = stream.recv().await else { + break; + }; + let payload = match frame { + Ok(AggregatedMessage::Binary(bytes)) => bytes, + Ok(AggregatedMessage::Ping(bytes)) => { + // WebSocket-level keepalive, unrelated to the protocol's Ping op. It is + // still proof the socket is alive, so it counts for the liveness window + // (the protocol ops are touched inside registry.handle). + rooms::registry().touch(id, Instant::now()); + let _ = tx.send(Outbound::Pong(bytes.to_vec())); + continue; + } + Ok(AggregatedMessage::Pong(_)) => { + rooms::registry().touch(id, Instant::now()); + continue; + } + Ok(AggregatedMessage::Close(_)) => break, + Ok(AggregatedMessage::Text(_)) => { + // "binary frames only ... Text frames are a protocol error -> close". + println!("multi_live/ws: text frame from conn {}", id); + close(&tx, CLOSE_PROTOCOL); + break; + } + Err(err) => { + println!("multi_live/ws: websocket error on conn {}: {}", id, err); + break; + } + }; + + let now = Instant::now(); + if !rate.allow(now) { + println!("multi_live/ws: conn {} exceeded {} msg/sec", id, RATE_LIMIT_PER_SEC); + close(&tx, CLOSE_RATE_LIMIT); + break; + } + + let msg = match proto::decode_client(&payload) { + Ok(msg) => msg, + Err(err) => { + println!("multi_live/ws: bad frame from conn {}: {}", id, err); + close(&tx, close_code_for(&err)); + break; + } + }; + if matches!(msg, ClientMsg::Auth { .. }) { + // Auth is the first message and only the first message. + println!("multi_live/ws: repeat Auth on conn {}", id); + close(&tx, CLOSE_PROTOCOL); + break; + } + rooms::registry().handle(id, msg, now); + } + + rooms::registry().disconnect(id, Instant::now()); +} + +// Reads the mandatory first frame and registers the connection. Returns None when the +// connection was closed instead. +async fn authenticate( + stream: &mut AggregatedMessageStream, + tx: &UnboundedSender, +) -> Option { + let first = match rt::time::timeout(AUTH_TIMEOUT, stream.recv()).await { + Ok(Some(Ok(frame))) => frame, + Ok(Some(Err(_))) | Ok(None) => return None, + Err(_) => { + println!("multi_live/ws: no Auth within {}s", AUTH_TIMEOUT.as_secs()); + close(tx, CLOSE_UNAUTHENTICATED); + return None; + } + }; + + let AggregatedMessage::Binary(payload) = first else { + // "First message on a connection MUST be Auth; anything else -> close 4001." + close(tx, CLOSE_UNAUTHENTICATED); + return None; + }; + let Ok(ClientMsg::Auth { user_id, token }) = proto::decode_client(&payload) else { + println!("multi_live/ws: first message was not a decodable Auth frame"); + close(tx, CLOSE_UNAUTHENTICATED); + return None; + }; + + let Some(uid) = resolve_user(&user_id, &token) else { + println!("multi_live/ws: rejecting uid {}: bad session", user_id); + close(tx, CLOSE_UNAUTHENTICATED); + return None; + }; + + let id = rooms::registry().connect(uid, tx.clone(), Instant::now()); + println!("multi_live/ws: auth ok, uid {} connected as conn {}", uid, id); + let _ = tx.send(Outbound::Msg(ServerMsg::AuthOk { + actorless_time_ms: rooms::server_time_ms(), + })); + Some(id) +} + +// The relay validates the same credential the HTTP layer does. Over HTTP the login token +// arrives inside the `a6573cbe` header (global::get_login) and every handler then keys +// userdata off it; here it arrives as the Auth payload instead, and the tokens table maps +// it back to the account. The claimed userId only has to agree with the token, so a +// client cannot relay as somebody else - which is a little stricter than the HTTP layer, +// where an unknown token simply resolves to an empty account. +fn resolve_user(user_id: &str, token: &str) -> Option { + if token.is_empty() { + return None; + } + match_claim(user_id, userdata::uid_from_login_token(token)) +} + +// The claim check, split out from the lookup so it can be tested without a database. +// `uid` is 0 when the token is unknown. +fn match_claim(user_id: &str, uid: i64) -> Option { + if uid == 0 { + return None; + } + match user_id.parse::() { + // A blank or unparsable userId is tolerated: the token is the authority. + Err(_) => Some(uid), + Ok(claimed) if claimed == 0 || claimed == uid => Some(uid), + Ok(_) => None, + } +} + +fn close_code_for(err: &DecodeError) -> u16 { + // The spec names 4001 and 4002 only; every framing failure shares 4000. + match err { + DecodeError::Empty + | DecodeError::Truncated + | DecodeError::TrailingBytes + | DecodeError::BadUtf8 + | DecodeError::UnknownOp(_) + | DecodeError::UnknownTag(_) => CLOSE_PROTOCOL, + } +} + +fn close(tx: &UnboundedSender, code: u16) { + let _ = tx.send(Outbound::Close(code)); +} + +// Sliding one second window rather than a fixed bucket, so 30 messages either side of a +// second boundary still trips the cap. +struct RateLimiter { + seen: VecDeque, +} + +impl RateLimiter { + fn new() -> Self { + RateLimiter { seen: VecDeque::with_capacity(RATE_LIMIT_PER_SEC + 1) } + } + + fn allow(&mut self, now: Instant) -> bool { + while let Some(front) = self.seen.front() { + if now.duration_since(*front) >= RATE_WINDOW { + self.seen.pop_front(); + } else { + break; + } + } + if self.seen.len() >= RATE_LIMIT_PER_SEC { + return false; + } + self.seen.push_back(now); + true + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rate_limiter_allows_the_cap_and_rejects_the_next() { + let mut rate = RateLimiter::new(); + let start = Instant::now(); + for i in 0..RATE_LIMIT_PER_SEC { + assert!(rate.allow(start + Duration::from_millis(i as u64)), "message {} refused", i); + } + assert!(!rate.allow(start + Duration::from_millis(999))); + // The window slides: once the first message ages out there is room again. + assert!(rate.allow(start + Duration::from_millis(1001))); + } + + #[test] + fn rate_limiter_catches_a_burst_straddling_a_second_boundary() { + let mut rate = RateLimiter::new(); + let start = Instant::now(); + // 29 messages at the end of one second... + for i in 0..RATE_LIMIT_PER_SEC - 1 { + assert!(rate.allow(start + Duration::from_millis(900 + i as u64))); + } + // ...and two more just after the boundary is still 31 inside one second. + assert!(rate.allow(start + Duration::from_millis(1000))); + assert!(!rate.allow(start + Duration::from_millis(1001))); + } + + #[test] + fn every_decode_failure_closes_with_the_protocol_code() { + for err in [ + DecodeError::Empty, + DecodeError::Truncated, + DecodeError::TrailingBytes, + DecodeError::BadUtf8, + DecodeError::UnknownOp(200), + DecodeError::UnknownTag(9), + ] { + assert_eq!(close_code_for(&err), CLOSE_PROTOCOL); + } + } + + #[test] + fn auth_needs_a_token_that_agrees_with_the_claimed_user() { + // An empty token never reaches the database. + assert_eq!(resolve_user("1", ""), None); + // An unknown token resolves to uid 0. + assert_eq!(match_claim("1", 0), None); + assert_eq!(match_claim("42", 42), Some(42)); + // Claiming somebody else's account with your own token is refused. + assert_eq!(match_claim("43", 42), None); + // A blank, zero or unparsable userId defers to the token. + assert_eq!(match_claim("", 42), Some(42)); + assert_eq!(match_claim("0", 42), Some(42)); + assert_eq!(match_claim("not-a-number", 42), Some(42)); + } +} diff --git a/src/router/userdata/mod.rs b/src/router/userdata/mod.rs index fd6737a..40db578 100644 --- a/src/router/userdata/mod.rs +++ b/src/router/userdata/mod.rs @@ -246,6 +246,13 @@ fn get_uid(token: &str) -> i64 { data.parse::().unwrap_or(0) } +// The account a login token belongs to, 0 when the token is unknown. The HTTP layer +// never needs this (it keys everything off the token itself), but the multi-live relay +// authenticates a {userId, token} pair and has to check the two agree. +pub fn uid_from_login_token(token: &str) -> i64 { + get_uid(token) +} + // Needed by gree pub fn get_login_token(uid: i64) -> String { let data = DATABASE.lock_and_select("SELECT token FROM tokens WHERE user_id=?1", params!(uid)); @@ -475,6 +482,47 @@ pub fn save_acc_eventlogin(auth_key: &str, data: JsonValue) { pub fn save_server_data(auth_key: &str, data: JsonValue) { save_data(auth_key, "server_data", data); } + +// Read-modify-write of one account's server_data as ONE atomic step. +// +// get_server_data + save_server_data open a connection each, so two requests for the same +// account both read the pre-state and the later write wins - which for a record that is +// meant to be spent exactly once (the started-live record /multi_live/end awards off) means +// both ends see it and both award. Everything here happens inside a single BEGIN IMMEDIATE +// transaction instead, so concurrent callers serialise and the second one observes what the +// first one wrote. +// +// `f` sees the parsed server_data and returns whatever the caller needs out of it; the +// (possibly mutated) value is written back before the transaction commits. get_key runs +// BEFORE the transaction because it can create the account, which writes on its own +// connection and would otherwise deadlock against our write lock. +// +// A database error yields T::default() rather than a panic: the callers all have a +// "nothing to spend" branch, which is the right answer when the record could not be read. +pub fn modify_server_data(auth_key: &str, f: impl FnOnce(&mut JsonValue) -> T) -> T { + let key = get_key(auth_key); + let rv = DATABASE.lock_and_transact(|conn| { + let raw: String = conn.query_row( + "SELECT server_data FROM server_data WHERE user_id=?1", + params!(key), + |row| row.get(0) + )?; + let mut data = jzon::parse(&raw).unwrap_or(JsonValue::Null); + let rv = f(&mut data); + conn.execute( + "UPDATE server_data SET server_data=?1 WHERE user_id=?2", + params!(jzon::stringify(data), key) + )?; + Ok(rv) + }); + match rv { + Ok(rv) => rv, + Err(err) => { + println!("modify_server_data: {} for user {}", err, key); + T::default() + } + } +} pub fn save_acc_chats(auth_key: &str, data: JsonValue) { save_data(auth_key, "chats", data); } diff --git a/src/router/userdata/new_user_event.json b/src/router/userdata/new_user_event.json index ac54623..a0380d1 100644 --- a/src/router/userdata/new_user_event.json +++ b/src/router/userdata/new_user_event.json @@ -1,13 +1,29 @@ { "point_ranking": { + "rank": 0, "point": 0 }, - "score_ranking": [], - "member_ranking": [], - "lottery_box": [], + "score_ranking": { + "all_rank": 0, + "group_rank": 0, + "score": 0 + }, + "member_ranking": { + "master_character_id": 0, + "rank": 0, + "point": 0 + }, + "lottery_box": { + "master_lottery_id": 0, + "reset_count": 0, + "draw_count_list": [] + }, "mission_list": [], "policy_agreement": 0, "incentive_lottery": 0, + "is_disconnected": 0, + "help_count": 0, + "penalty_remaining_time": 0, "star_event": { "star_level": 0, "last_event_star_level": 0, diff --git a/src/sql.rs b/src/sql.rs index d18aa79..b199c28 100644 --- a/src/sql.rs +++ b/src/sql.rs @@ -59,4 +59,28 @@ impl SQLite { } Ok(rv) } + + // Runs a read-modify-write as one unit. Additive on purpose — the other helpers open + // a fresh connection per statement, so a caller that SELECTs then INSERTs races any + // concurrent caller doing the same and the loser hits a constraint violation (which + // lock_and_exec would unwrap into a worker panic). + // + // BEGIN IMMEDIATE takes the write lock up front rather than at first write, so two + // callers serialise instead of both reading the pre-state; busy_timeout makes the + // loser wait for the winner rather than fail instantly (SQLite::new sets that on its + // own short-lived setup connection, not on the per-call ones). + // + // Errors are returned, never unwrapped: statistics writes must not take down a + // request. + pub fn lock_and_transact( + &self, + f: impl FnOnce(&Connection) -> Result + ) -> Result { + let mut conn = Connection::open(&self.path)?; + conn.busy_timeout(std::time::Duration::from_secs(10))?; + let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?; + let rv = f(&tx)?; + tx.commit()?; + Ok(rv) + } }