Multi live

This commit is contained in:
Ethan O'Brien
2026-08-11 14:41:28 -05:00
parent 75613dafb0
commit 27d7b8de82
18 changed files with 5650 additions and 83 deletions

5
.gitignore vendored
View File

@@ -12,3 +12,8 @@ ndk/
.DS_Store .DS_Store
custom_songs/ custom_songs/
custom_cards/ custom_cards/
# local-only trees — never commit (35GB between them)
/android/
/assets.bak/
/assets.old/

19
Cargo.lock generated
View File

@@ -223,6 +223,22 @@ dependencies = [
"syn", "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]] [[package]]
name = "adler2" name = "adler2"
version = "2.0.1" version = "2.0.1"
@@ -1073,6 +1089,7 @@ version = "1.1.0"
dependencies = [ dependencies = [
"actix-multipart", "actix-multipart",
"actix-web", "actix-web",
"actix-ws",
"aes", "aes",
"argon2", "argon2",
"base64", "base64",
@@ -1103,6 +1120,7 @@ dependencies = [
"sha1", "sha1",
"sha2", "sha2",
"symphonia", "symphonia",
"tokio",
"ureq", "ureq",
"urlencoding", "urlencoding",
"uuid", "uuid",
@@ -3209,6 +3227,7 @@ dependencies = [
"bytes", "bytes",
"futures-core", "futures-core",
"futures-sink", "futures-sink",
"futures-util",
"pin-project-lite", "pin-project-lite",
"tokio", "tokio",
] ]

View File

@@ -32,6 +32,10 @@ include_dir = "0.7.4"
jzon = "0.12.5" jzon = "0.12.5"
csv = "1.3" csv = "1.3"
actix-multipart = "0.8" 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" futures-util = "0.3"
image = "0.25" image = "0.25"
zip = { version = "8.6", default-features = false, features = ["deflate"] } zip = { version = "8.6", default-features = false, features = ["deflate"] }

View File

@@ -37,6 +37,10 @@ pub async fn run_server(in_thread: bool) -> std::io::Result<()> {
router::custom_song::migrate::run(); router::custom_song::migrate::run();
router::custom_song::sweep_audio(); 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() let rv = HttpServer::new(|| App::new()
//.wrap(Cors::permissive()) //.wrap(Cors::permissive())

View File

@@ -10,6 +10,7 @@ pub mod home;
pub mod lottery; pub mod lottery;
pub mod friend; pub mod friend;
pub mod live; pub mod live;
pub mod multi_live;
pub mod event; pub mod event;
pub mod chat; pub mod chat;
pub mod story; pub mod story;
@@ -274,6 +275,7 @@ pub fn configure(cfg: &mut actix_web::web::ServiceConfig) {
.configure(login::routes) .configure(login::routes)
.configure(lottery::routes) .configure(lottery::routes)
.configure(mission::routes) .configure(mission::routes)
.configure(multi_live::routes)
.configure(notice::routes) .configure(notice::routes)
.configure(purchase::routes) .configure(purchase::routes)
.configure(serial_code::routes) .configure(serial_code::routes)

View File

@@ -7,11 +7,33 @@ pub fn routes(cfg: &mut web::ServiceConfig) {
cfg.service( cfg.service(
web::scope("/chat") web::scope("/chat")
.route("/home", web::post().to(home)) .route("/home", web::post().to(home))
.route("/talk/get_stamp", web::get().to(get_stamp))
.route("/talk/start", web::post().to(start)) .route("/talk/start", web::post().to(start))
.route("/talk/end", web::post().to(end)) .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 { pub fn add_chat(id: i64, num: i64, chats: &mut JsonValue) -> bool {
for data in chats.members() { for data in chats.members() {
if data["chat_id"] == id && data["room_id"] == num { if data["chat_id"] == id && data["room_id"] == num {
@@ -48,7 +70,7 @@ async fn home(Login(key): Login) -> impl Responder {
Api(Some(object!{ Api(Some(object!{
"progress_list": chats, "progress_list": chats,
"master_chat_room_ids": rooms, "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": [] "master_chat_attachment_ids": []
})) }))
} }
@@ -75,3 +97,41 @@ async fn end(Session { key, body }: Session) -> impl Responder {
Api(Some(array![])) 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<i64> = 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());
}
}

View File

@@ -68,14 +68,14 @@ fn setup_tables(conn: &rusqlite::Connection) {
);").unwrap(); );").unwrap();
} }
fn update_live_score(id: i64, uid: i64, score: i64) { // Merges this play into the song's top-10 board. Pure so the whole read-modify-write can
if uid == 0 || score == 0 { // sit inside one transaction, and so the keep-best rule is testable on its own.
return; //
} // 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
let info = DATABASE.lock_and_select("SELECT score_data FROM scores WHERE live_id=?1", params!(id)).unwrap_or(String::from("[]")); // scores is higher — a replay that does not beat the stored one is dropped (`None`), which
let scores = jzon::parse(&info).unwrap(); // is what makes a repeated or duplicated end idempotent rather than additive.
fn merge_live_score(scores: &JsonValue, uid: i64, score: i64) -> Option<JsonValue> {
let mut result = array![]; let mut result = array![];
let mut current = 0; let mut current = 0;
let mut added = false; 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 { 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 { if scores[i]["user"].as_i64().unwrap() == uid {
continue; continue;
@@ -108,12 +109,40 @@ fn update_live_score(id: i64, uid: i64, score: i64) {
current += 1; current += 1;
} }
if added { if added { Some(result) } else { None }
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 { fn update_live_score(id: i64, uid: i64, score: i64) {
DATABASE.lock_and_exec("INSERT INTO scores (score_data, live_id) VALUES (?1, ?2)", params!(jzon::stringify(result), id)); 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(); 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) { pub fn live_completed(id: i64, level: i32, failed: bool, score: i64, uid: i64) {
update_live_score(id, uid, score); 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)); let Some(column) = clear_rate_column(level, failed) else {
}, return;
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!(
// `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, id,
if 1 == level && failed { 1 } else { 0 }, if 1 == level && failed { 1 } else { 0 },
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 3 == level && !failed { 1 } else { 0 },
if 4 == level && failed { 1 } else { 0 }, if 4 == 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 { 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()) .content_type(ContentType::html())
.body(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);
}
}

View File

@@ -302,6 +302,57 @@ lazy_static! {
info 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 RANKS: JsonValue = t("user_rank");
pub static ref USER_RANK_REWARD: JsonValue = { pub static ref USER_RANK_REWARD: JsonValue = {

View File

@@ -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 mut event = userdata::get_acc_event(key);
let is_star_event = STAR_EVENT_IDS.contains(&event_id); let is_star_event = STAR_EVENT_IDS.contains(&event_id);
//println!("is_star_event: {}, {}", is_star_event, 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(); 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() 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); let mut event = userdata::get_acc_event(key);
// Check for old version of event data // 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 scores = crate::router::event_ranking::get_raw_info(event);
let mut i=1; let mut i=1;
@@ -254,10 +337,14 @@ fn get_star_rank(points: i64) -> i64 {
const LIMIT_COINS: i64 = 2000000000; 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; let mut has = false;
for data in user["event_point_list"].members_mut() { 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; has = true;
let new_amount = data["amount"].as_i64().unwrap() + amount; let new_amount = data["amount"].as_i64().unwrap() + amount;
if new_amount > LIMIT_COINS { if new_amount > LIMIT_COINS {
@@ -278,7 +365,7 @@ fn give_event_points(event_id: u32, amount: i64, user: &mut JsonValue) -> bool {
false 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() { for data in user["event_point_list"].members() {
if data["type"] == 1 && data["master_event_id"] == event_id { if data["type"] == 1 && data["master_event_id"] == event_id {
return data["amount"].as_i64().unwrap() 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 { async fn event_skip(req: HttpRequest, Session { key, body }: Session) -> impl Responder {
Api(event_live(&req, &key, &body, true)) 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);
}
}

View File

@@ -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) 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<u64> {
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::<i64>().ok()?;
let m = date_parts.next()?.trim().parse::<i64>().ok()?;
let d = date_parts.next()?.trim().parse::<i64>().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::<i64>().ok()?;
let mm = time_parts.next().unwrap_or("0").trim().parse::<i64>().ok()?;
let ss = time_parts.next().unwrap_or("0").trim().parse::<i64>().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) { fn init_time(current_time: u64, server_data: &mut JsonValue, token: &str, max_time: u64, max: bool) {
let mut edited = false; let mut edited = false;
let default_time = 1709272800; let default_time = 1709272800;

View File

@@ -27,7 +27,9 @@ pub fn routes(cfg: &mut web::ServiceConfig) {
async fn retire(Session { key, body }: Session) -> impl Responder { async fn retire(Session { key, body }: Session) -> impl Responder {
live_retire(&key, &body); live_retire(&key, &body);
if body["live_score"]["play_time"].as_i64().unwrap_or(0) > 5 { 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!{ Api(Some(object!{
"stamina": {}, "stamina": {},
@@ -222,12 +224,15 @@ fn check_for_stale_data(server_data: &mut JsonValue, live_id: i64) {
let mut expired = array![]; let mut expired = array![];
let curr_time = global::timestamp(); let curr_time = global::timestamp();
for (i, live) in server_data["last_live_started"].members().enumerate() { 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(); 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. // 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() { for i in expired.members() {
@@ -243,34 +248,94 @@ fn get_end_live_deck_id(login_token: &str, body: &JsonValue) -> Option<i32> {
let index = server_data["last_live_started"].members().position(|r| r["master_live_id"] == body["master_live_id"])?; 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()?; 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); userdata::save_server_data(login_token, server_data);
Some(rv) Some(rv)
} }
pub fn get_end_live_event_id(login_token: &str, body: &JsonValue) -> Option<u32> { // 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<JsonValue> {
let server_data = userdata::get_server_data(login_token); let server_data = userdata::get_server_data(login_token);
if server_data["last_live_started"].is_null() { if server_data["last_live_started"].is_null() {
return None; return None;
} }
let index = server_data["last_live_started"].members().position(|r| r["master_live_id"] == body["master_live_id"])?; 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(server_data["last_live_started"][index].clone())
Some(rv)
} }
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<JsonValue> {
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<JsonValue> = 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<u32> {
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); 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); 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); let mut server_data = userdata::get_server_data(login_token);
if server_data["last_live_started"].is_null() { if server_data["last_live_started"].is_null() {
server_data["last_live_started"] = array![]; 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(); let mut to_save = body.clone();
// The user has 1 hour to complete a live // The user has 1 hour to complete a live
to_save["expire_date_time"] = (global::timestamp() + (1 * 60 * 60)).into(); 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 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 { if user["tutorial_step"].as_i32().unwrap() < 130 {
return JsonValue::Null; 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!{ let mut rv = object!{
"master_live_id": data["master_live_id"].as_i64().unwrap(), "master_live_id": data["master_live_id"].as_i64().unwrap_or(0),
"level": data["level"].as_i64().unwrap(), "level": data["level"].as_i64().unwrap_or(0),
"clear_count": 1, "clear_count": 1,
"high_score": data["live_score"]["score"].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(), "max_combo": data["live_score"]["max_combo"].as_i64().unwrap_or(0),
"auto_enable": 1, //whats this? "auto_enable": 1, //whats this?
"updated_time": global::timestamp() "updated_time": global::timestamp()
}; };
let mut has = false; let mut has = false;
for current in user["live_list"].members_mut() { 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; has = true;
if add { if add {
rv["clear_count"] = (current["clear_count"].as_i64().unwrap() + 1).into(); 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(); current["updated_time"] = rv["updated_time"].clone();
rv["level"] = current["level"].clone(); rv["level"] = current["level"].clone();
if data["level"].as_i32().unwrap() != 0 { if data["level"].as_i32().unwrap_or(0) != 0 {
break; 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 { 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 user2 = userdata::get_acc_home(&key);
let mut user = userdata::get_acc(&key); let mut user = userdata::get_acc(&key);
let mut user_missions = userdata::get_acc_missions(&key); let mut user_missions = userdata::get_acc_missions(&key);
let mut chats = userdata::get_acc_chats(&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 jp = items::get_region(req.headers());
let first_clear = !skipped let first_clear = !skipped
&& user["tutorial_step"].as_i32().unwrap() >= 130 && 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 { let live = if skipped {
items::use_item(&object!{ items::use_item(&object!{
value: 21000001, value: 21000001,
amount: 1, amount: 1,
consumeType: 4 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!{ update_live_data(&mut user, &object!{
master_live_id: body["master_live_id"].clone(), master_live_id: body["master_live_id"].clone(),
level: 0, level: 0,
@@ -609,9 +706,9 @@ pub fn live_end(req: &HttpRequest, key: &str, body: &JsonValue, skipped: bool) -
score: 1, score: 1,
max_combo: 1 max_combo: 1
} }
}, false) }, false, update_score_board)
} else { } else {
update_live_data(&mut user, &body, true) update_live_data(&mut user, &body, true, update_score_board)
}; };
//1273009, 1273010, 1273011, 1273012 //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; let missions;
if skipped { 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()); 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(body["master_live_id"].as_i64().unwrap(), &user); 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 { } 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()); live_completed(live_id, level as i32, false, score, board_uid);
let clear_count = get_clear_count(body["master_live_id"].as_i64().unwrap(), &user); 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_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; 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 is_full_combo {
if items::advance_mission(1176001, 1, 1, &mut user_missions).is_some() { 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() { if is_perfect && items::advance_mission(1177001, 1, 1, &mut user_missions).is_some() {
cleared_missions.push(1177001).unwrap(); 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(); cleared_missions.push(1177002).unwrap();
} }
} }
update_live_mission_data(&mut user, &object!{ 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() 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); 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);
if consume_lp {
items::lp_modification(&mut user, lp_used as u64, true); items::lp_modification(&mut user, lp_used as u64, true);
}
items::give_exp(lp_used, &mut user, &mut user_missions, &mut cleared_missions); items::give_exp(lp_used, &mut user, &mut user_missions, &mut cleared_missions);
@@ -761,7 +866,7 @@ mod tests {
master_live_id: live_id, master_live_id: live_id,
level: level, level: level,
live_score: { score: score, max_combo: combo } live_score: { score: score, max_combo: combo }
}, true); }, true, true);
userdata::save_acc(token, user); userdata::save_acc(token, user);
} }
@@ -769,6 +874,118 @@ mod tests {
get_clear_count(live_id, &userdata::get_acc(token)) 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] #[test]
fn clear_count_persists_with_custom_songs_disabled() { fn clear_count_persists_with_custom_songs_disabled() {
let _lock = crate::runtime::lock_test_data_path(); let _lock = crate::runtime::lock_test_data_path();

1014
src/router/multi_live.rs Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -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<i32> {
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<I, K>(pairs: I) -> Self
where
I: IntoIterator<Item = (K, Value)>,
K: Into<String>,
{
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<i32> {
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<K: Into<String>>(&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<Item = (&str, &Value)> {
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<String>,
player_props: Map,
},
JoinRoom { name: String, player_props: Map },
JoinRandom { power_min: i32, power_max: i32, levels: Vec<Value>, player_props: Map },
LeaveRoom,
Rejoin { name: String, player_props: Map },
SetPlayerProps { props: Map },
SetRoomProps { props: Map },
Rpc { name: String, params: Vec<Value> },
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<Value> },
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<u8, DecodeError> {
Ok(self.take(1)?[0])
}
fn bool(&mut self) -> Result<bool, DecodeError> {
// Photon booleans are one byte; anything non-zero is true.
Ok(self.u8()? != 0)
}
fn u16(&mut self) -> Result<u16, DecodeError> {
let b = self.take(2)?;
Ok(u16::from_le_bytes([b[0], b[1]]))
}
fn i32(&mut self) -> Result<i32, DecodeError> {
let b = self.take(4)?;
Ok(i32::from_le_bytes([b[0], b[1], b[2], b[3]]))
}
fn f64(&mut self) -> Result<f64, DecodeError> {
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<String, DecodeError> {
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<Value, DecodeError> {
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<Map, DecodeError> {
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<Vec<Value>, 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<Vec<String>, 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<T>(&self, value: T) -> Result<T, DecodeError> {
if self.pos != self.buf.len() {
return Err(DecodeError::TrailingBytes);
}
Ok(value)
}
}
pub fn decode_client(buf: &[u8]) -> Result<ClientMsg, DecodeError> {
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<ServerMsg, DecodeError> {
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<u8>, 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<u8>, 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<u8>, 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<u8>, 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<u8>, 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<u8>, 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<u8> {
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<u8> {
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<Value> = (0..255).map(Value::Int).collect();
round_client(ClientMsg::Rpc { name: "SendStamp".into(), params });
let keys: Vec<String> = (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<_>>(),
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
);
}
}

File diff suppressed because it is too large Load Diff

359
src/router/multi_live/ws.rs Normal file
View File

@@ -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<HttpResponse, actix_web::Error> {
// 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::<Outbound>();
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<Outbound>) {
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<Outbound>) {
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<Outbound>,
) -> Option<ConnId> {
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<i64> {
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<i64> {
if uid == 0 {
return None;
}
match user_id.parse::<i64>() {
// 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<Outbound>, 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<Instant>,
}
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));
}
}

View File

@@ -246,6 +246,13 @@ fn get_uid(token: &str) -> i64 {
data.parse::<i64>().unwrap_or(0) data.parse::<i64>().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 // Needed by gree
pub fn get_login_token(uid: i64) -> String { pub fn get_login_token(uid: i64) -> String {
let data = DATABASE.lock_and_select("SELECT token FROM tokens WHERE user_id=?1", params!(uid)); 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) { pub fn save_server_data(auth_key: &str, data: JsonValue) {
save_data(auth_key, "server_data", data); 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<T: Default>(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) { pub fn save_acc_chats(auth_key: &str, data: JsonValue) {
save_data(auth_key, "chats", data); save_data(auth_key, "chats", data);
} }

View File

@@ -1,13 +1,29 @@
{ {
"point_ranking": { "point_ranking": {
"rank": 0,
"point": 0 "point": 0
}, },
"score_ranking": [], "score_ranking": {
"member_ranking": [], "all_rank": 0,
"lottery_box": [], "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": [], "mission_list": [],
"policy_agreement": 0, "policy_agreement": 0,
"incentive_lottery": 0, "incentive_lottery": 0,
"is_disconnected": 0,
"help_count": 0,
"penalty_remaining_time": 0,
"star_event": { "star_event": {
"star_level": 0, "star_level": 0,
"last_event_star_level": 0, "last_event_star_level": 0,

View File

@@ -59,4 +59,28 @@ impl SQLite {
} }
Ok(rv) 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<T>(
&self,
f: impl FnOnce(&Connection) -> Result<T, rusqlite::Error>
) -> Result<T, rusqlite::Error> {
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)
}
} }