Files
ew_author/src/router/multi_live.rs
2026-08-12 17:21:52 -05:00

798 lines
36 KiB
Rust

// Wire format shared with the C# client rewrite; see docs/multi-live-ws-protocol.md.
mod proto;
// Lobby/room state machine, WebSocket-free so it can be tested without a socket.
mod rooms;
// The /multi_live/ws endpoint itself.
mod ws;
use jzon::{object, JsonValue};
use actix_web::{web, HttpRequest, Responder};
use crate::router::{databases, event, event_ranking, global, items, live, userdata, Session, Api};
// The relay's expiry timers (held slots, empty rooms, dead connections). Started once from
// run_server; see ws::start_sweeper for why it is not lazily started by the first upgrade.
pub use ws::start_sweeper;
pub fn routes(cfg: &mut web::ServiceConfig) {
cfg.service(
web::scope("/multi_live")
.route("/start", web::post().to(start))
.route("/end", web::post().to(end))
// The Photon replacement. Note this sits inside the /api scope, so the
// handshake goes through webui_fallback and must carry the usual
// aoharu-asset-version header like every other game request.
.route("/ws", web::get().to(ws::ws))
);
}
// Shock.MULTI_LIVE_END_STATUS, the enum RecvMultiLiveEndRData.is_penalty_miss_ratio
// is cast to (MngLiveData.SendMultiLiveEnd).
// NONE(0) - normal result, client processes every reward list.
// MISS_RATIO_PENALTY_STATUS(1) - MngLiveData bails out right after user/stamina,
// LiveScene.OnMultiLiveEnd leaves the room and
// returns to the multi-event top. Nothing is awarded.
// GREAT_PERFECT_LOW_RATIO_STATUS(2) - rewards still apply, the client only raises
// MultiEventLiveResult.IsOpenCautionDialog.
const STATUS_NONE: u8 = 0;
const STATUS_MISS_RATIO_PENALTY: u8 = 1;
const STATUS_GREAT_PERFECT_LOW_RATIO: u8 = 2;
// Shock.COMMON_CONST.RATIO_DIVISOR. Every ratio in masterdata (event_score._eventPointRatio
// / ._eventBoostRatio, live_boost._eventPointRatio, multievent_rankbonus._eventPtBonus,
// multievent_card_bonus._pointBonusRatioList) is stored in 1/10000 and divided by this.
const RATIO_ONE: i64 = 10000;
// Extended-protocol revision this feature requires (X-Protocol-Version, the same ladder
// card.rs=2 / custom_card.rs=3 use). Older client builds carry incompatible multi
// implementations — pre-relay wire framing and pre-rework flows — so every multi_live
// surface (start, end, and the WS upgrade in ws.rs) refuses anything below it.
// 4 = multi-live over the self-hosted WS relay (permanent co-op).
pub const PROTOCOL_VERSION: u32 = 4;
fn protocol_too_old(req: &HttpRequest) -> bool {
global::client_protocol_version(req) < PROTOCOL_VERSION
}
fn const_value(id: &str, default: i64) -> i64 {
let raw = &databases::CONST[id]["value"];
raw.as_str()
.and_then(|v| v.parse::<i64>().ok())
.or_else(|| raw.as_i64())
.unwrap_or(default)
}
fn boost_lp(live_boost: i64) -> i64 {
databases::LIVE_BOOST[live_boost.to_string()]["lp"]
.as_i64()
.unwrap_or(10 * live_boost)
}
fn boost_event_point_ratio(live_boost: i64) -> i64 {
databases::LIVE_BOOST[live_boost.to_string()]["eventPointRatio"]
.as_i64()
.unwrap_or(RATIO_ONE * live_boost.max(1))
}
// MusicLevelMst._fullCombo — the note count both client ratio checks divide by.
fn note_count(master_live_id: i64, level: i64) -> Option<i64> {
let music_id = databases::LIVE_LIST[master_live_id.to_string()]["masterMusicId"].as_i64()?;
let row = &databases::MUSIC_LEVEL[format!("{}_{}", music_id, level)];
if row.is_empty() {
return None;
}
let notes = row["fullCombo"].as_i64()?;
if notes <= 0 { None } else { Some(notes) }
}
// Aoharu.MultiUtil.IsPenalty / IsHalved, evaluated server-side because
// is_penalty_miss_ratio is what the client actually obeys (both MultiUtil helpers
// are unreferenced in the client — they were only ever a local preview).
//
// IsPenalty: (MULTI_PENALTY_MISS_RATIO / 100) * fullCombo <= miss
// IsHalved: perfect + great < (MULTI_EVENT_LIVE_GREAT_PERFECT_NOTES_MIN_RATIO / 100) * fullCombo
// (skipped for LIVE_LEVEL 1, per `if ((int)level == 1) return false;`)
//
// MultiUtil.IsHalved reads Perfect + Good, but only because the client-side
// MultiLiveResultProtocolData(userId, LiveScore, bool) ctor never assigns Great
// (IL2CPP @4769631) — it has no great count to use. The const is named
// ..._GREAT_PERFECT_NOTES_MIN_RATIO and the server does receive the full LiveScore,
// so perfect + great is used here.
//
// MULTI_PENALTY_NO_PLAY_MISS_RATIO (5000) is unreachable as a real miss ratio and is
// the value the ratio takes when nothing was judged at all: a client that never played
// reports 0 misses, which the 70% rule would wave through.
//
// Both checks mirror MultiUtil's "missing MusicLevelMst row => false" shape.
fn multi_live_end_status(body: &JsonValue) -> u8 {
let score = &body["live_score"];
let notes = match note_count(
body["master_live_id"].as_i64().unwrap_or(0),
body["level"].as_i64().unwrap_or(0)
) {
Some(n) => n,
None => return STATUS_NONE
};
let perfect = score["perfect"].as_i64().unwrap_or(0);
let great = score["great"].as_i64().unwrap_or(0);
let good = score["good"].as_i64().unwrap_or(0);
let bad = score["bad"].as_i64().unwrap_or(0);
let miss = score["miss"].as_i64().unwrap_or(0);
let penalty_ratio = const_value("MULTI_PENALTY_MISS_RATIO", 70);
let no_play_ratio = const_value("MULTI_PENALTY_NO_PLAY_MISS_RATIO", 5000);
let judged = perfect + great + good + bad + miss;
let penalised = if judged == 0 {
no_play_ratio >= penalty_ratio
} else {
miss * 100 >= penalty_ratio * notes
};
if penalised {
return STATUS_MISS_RATIO_PENALTY;
}
if body["level"].as_i64().unwrap_or(0) != 1 {
let min_ratio = const_value("MULTI_EVENT_LIVE_GREAT_PERFECT_NOTES_MIN_RATIO", 50);
if (perfect + great) * 100 < min_ratio * notes {
return STATUS_GREAT_PERFECT_LOW_RATIO;
}
}
STATUS_NONE
}
// The event-point yield of one multi live, from event_score.csv (Shock.EventScoreMst).
//
// Neither ew nor the reconstructed client had an existing consumer to copy: EventData
// exposes _eventLivePointBase / _eventPointRatio / _eventBoostRatio as EventLiveBasePoint
// / EventPointRatio / EventBoostRatio (EventData.cs:134-156) but nothing in the client
// ever reads those three properties — the award has always been computed server-side.
// The interpretation used here is the one the field names and the client's own ratio
// convention dictate, and the one this port is specified against:
//
// points = _eventLivePointBase
// * _eventPointRatio / RATIO_DIVISOR (per-event scaling)
// * _eventBoostRatio / RATIO_DIVISOR (per-event boost worth)
// * live_boost._eventPointRatio / RATIO_DIVISOR (the boost actually spent)
//
// The last factor mirrors LiveBoostMst.GetEventPointRatio (LiveBoostMst.cs:131-137),
// which is `_eventPointRatio / RATIO_DIVISOR` — the boost level itself for the stock
// table (boost 3 -> 30000/10000 -> 3).
//
// All four multi events (108/111/115/119) carry base 10, pointRatio 10000, boostRatio
// 35000, so one boost-1 multi live is worth 35 points.
//
// Division is deferred to the end so the x3.5 boost ratio does not truncate to x3 the
// way the client's integer GetEventPointRatio would.
fn event_live_points(event_id: u32, live_boost: i64) -> i64 {
let row = &databases::EVENT_SCORE[event_id.to_string()];
if row.is_empty() {
// Should be unreachable: event_score.csv has a row for every event, including
// all four multi events. An event with no row is worth nothing rather than
// silently falling back to an invented constant.
println!("multi_live: no event_score row for event {event_id}, awarding 0 event points");
return 0;
}
let base = row["eventLivePointBase"].as_i64().unwrap_or(0);
let point_ratio = row["eventPointRatio"].as_i64().unwrap_or(0);
let boost_ratio = row["eventBoostRatio"].as_i64().unwrap_or(0);
base * point_ratio * boost_ratio * boost_event_point_ratio(live_boost)
/ (RATIO_ONE * RATIO_ONE * RATIO_ONE)
}
// The stamina this live actually cost, as recorded by /multi_live/start. This is what
// live_end_ex scales every reward off, and it is deliberately read without reference to
// the backing event: a closed event suppresses scoring, never rewards.
fn recorded_lp(started: Option<&JsonValue>) -> i64 {
let live_boost = started.and_then(|s| s["live_boost"].as_i64()).unwrap_or(0);
started
.and_then(|s| s["use_lp"].as_i64())
.unwrap_or_else(|| boost_lp(live_boost))
.max(0)
}
// The event a multi live should actually score against, or None when it should score
// against nothing.
//
// Multi is a permanent feature here, entered from a client-side button rather than from
// a live event, so the client faithfully sends the backing event id (108) even though
// that event is closed and stays closed by choice. Awarding against a closed event would
// write event points and ranking rows for a season that is not running, so the whole
// event-point path stays dormant — and lights up on its own, with no further change
// here, if an event is ever actually opened.
//
// Nothing else about the live is affected: rewards, EXP, bond, missions and the response
// shape all come out of live_end_ex exactly as they do for an in-session event.
fn scoring_event(started: Option<&JsonValue>, now: u64) -> Option<u32> {
started
.and_then(|s| s["master_event_id"].as_u32())
.filter(|id| *id != 0)
.filter(|id| event::is_in_session(*id, now))
}
// The whole award for one multi live: the event_score yield, the finishing-position
// bonus from multievent_rankbonus, and the GREAT_PERFECT_LOW_RATIO halving.
fn multi_event_points(event_id: u32, live_boost: i64, players: i64, live_rank: i64, status: u8) -> i64 {
let mut points = event_live_points(event_id, live_boost);
points = points * (RATIO_ONE + rank_bonus(players, live_rank)) / RATIO_ONE;
if status == STATUS_GREAT_PERFECT_LOW_RATIO {
// MultiUtil calls this state "halved" — the caution dialog goes with a
// reduced yield, not a forfeited one.
points /= 2;
}
points
}
// multievent_rankbonus._eventPtBonus for this party size / finishing position.
// The table only covers 2-4 players with liveRank <= playerCount; anything outside
// it (a solo room, a rank the table has no row for) simply earns no bonus.
fn rank_bonus(player_count: i64, live_rank: i64) -> i64 {
databases::MULTIEVENT_RANK_BONUS[format!("{}_{}", player_count, live_rank)]["eventPtBonus"]
.as_i64()
.unwrap_or(0)
}
// LiveScene.MultiTask4 builds other_live_score_list from MultiPlayManager.AllPlayers,
// so the poster is already one of its entries. Fall back to len + 1 if a caller ever
// sends a list that genuinely excludes itself.
fn player_count(body: &JsonValue, user_id: i64) -> i64 {
let list = &body["other_live_score_list"];
let contains_self = list
.members()
.any(|s| s["user_id"].as_i64() == Some(user_id));
let len = list.len() as i64;
if contains_self { len } else { len + 1 }
}
// Every field of Shock.RecvMultiLiveEndRData, so the penalty path still deserialises
// cleanly. The client reads user/stamina and then returns on status 1, but Notify()
// runs over the whole payload first.
fn barren_response(user: &JsonValue, status: u8) -> JsonValue {
object!{
"is_penalty_miss_ratio": status,
"gem": user["gem"].clone(),
"clear_master_live_mission_ids": [],
"user": user["user"].clone(),
"stamina": user["stamina"].clone(),
"character_list": [],
"card_list": [],
"card_sub_list": [],
"item_list": [],
"point_list": [],
"group_list": [],
"reward_list": [],
"gift_list": [],
"clear_mission_ids": [],
"event_point_list": user["event_point_list"].clone(),
"event_point_reward_list": [],
"ranking_change": [],
"music_mission_reward_list": [],
"event_ranking_data": {
"event_point_rank": 0,
"next_reward_rank_point": 0,
"event_score_rank": 0,
"next_reward_rank_score": 0
}
}
}
// Unlike /live/start, a multi live pays its stamina up front: the response reports
// consumed_stamina and the client never sends a boost with /multi_live/end. The
// amount actually taken is stashed on the recorded start payload so /multi_live/end
// can scale rewards off it without charging for it twice.
async fn start(req: HttpRequest, Session { key, mut body }: Session) -> impl Responder {
// Older clients speak an incompatible multi — refuse before touching any state.
if protocol_too_old(&req) {
return Api(None);
}
// `token` is the room-party correlation key ("{userId}.{guid}") that all up to four
// party members post. It is recorded verbatim on the start record and nothing reads
// anything else out of it. Reconciling the party itself — checking the four results
// against each other — is still deferred.
// master_event_id is recorded verbatim and never validated here: multi is a permanent
// feature entered against a closed event (see scoring_event), so a closed — or absent
// — event must start a live exactly like an open one. Stamina comes off live_boost
// alone, so nothing on this path depends on the event being in session.
let live_boost = body["live_boost"].as_i64().unwrap_or(0);
let lp = boost_lp(live_boost).max(0);
let mut user = userdata::get_acc(&key);
// Settle regen first so the balance clamped against below is current.
items::lp_modification(&mut user, 0, true);
let available = user["stamina"]["stamina"].as_u64().unwrap_or(0);
// Clamped, not refused, and that IS the intended answer.
//
// The client gates on stamina at the entry to matching, never at the POST:
// MultiSelectionView.RoomCreation / OpenRoomSearchDialog and MultiRestart.RestartLive
// all run StaminaUtils.UseStaminaValue and divert to StaminaChargeScene when it says
// the balance is short, while MultiEventMatchingScene.ChangeScene — which is what
// actually calls SendMultiLiveStart, and does so off the host's _MoveScene RPC —
// computes the cost and drops the "insufficient" flag on the floor. Stamina only ever
// goes up between the gate and the POST (regen; the boost selector lives on the gated
// panels), so an honest client cannot arrive here short, and the official server was
// never asked what to do about it.
//
// So this is the unreachable-in-practice branch, and taking what is there is the
// benign resolution: the live still starts (a party of four must not be broken up by
// one member's balance), consumed_stamina reports what was really taken, and because
// every reward scales off that same recorded use_lp — see recorded_lp and live_end_ex —
// a short live pays out in proportion. Refusing would have to fail a live the other
// three players are already committed to; charging the full cost would mean inventing
// negative stamina.
let consumed = (lp as u64).min(available);
items::lp_modification(&mut user, consumed, true);
userdata::save_acc(&key, user);
body["use_lp"] = consumed.into();
live::start_live(&key, &body);
Api(Some(object!{
"consumed_stamina": consumed
}))
}
async fn end(req: HttpRequest, Session { key, body }: Session) -> impl Responder {
// Older clients speak an incompatible multi — refuse before touching any state
// (in particular before the start record can be consumed).
if protocol_too_old(&req) {
return Api(None);
}
// Loaded once and reused by every path that answers without playing the live; the
// live itself re-reads it, because live_end_ex saves the account.
let account = userdata::get_acc(&key);
// The user's own clock, so a time-travelling account sees the same event window the
// rest of the game shows it.
let uid = account["user"]["id"].as_i64().unwrap_or(0);
// A body with no live id is answered like a duplicate: it cannot name a start record
// (matching one on a null id would let it claim a record started with the same
// malformed body) and nothing downstream can score it.
if body["master_live_id"].as_i64().is_none() {
println!("multi_live/end: uid {} posted no master_live_id — nothing to end", uid);
return Api(Some(barren_response(&account, STATUS_NONE)));
}
// The started-live record is what makes an end legitimate, and it is CLAIMED here:
// take_started_live returns it and removes it in one transaction, before anything is
// awarded, so of N ends arriving together exactly one can proceed. The consumption
// used to be a side effect of get_end_live_deck_id deep inside live_end_ex — which
// only fired when the record carried a numeric deck_slot, and fired long after the
// award had been decided, so a re-POST could be paid twice over.
//
// Failing the take means this live was already ended: two clients signed in to the
// SAME account both POST /multi_live/end for the one shared record, or the client
// retried a request whose response it never saw. Granting again would double-count the
// clear, the clear-rate counter, the play-count mission and the flat 17001001 drop.
// So the duplicate is answered from current state: a well-formed result the client can
// display, awarding nothing.
let started = live::take_started_live(&key, &body);
let started = started.as_ref();
if started.is_none() {
println!("multi_live/end: uid {} has no start record to spend — answering barren", uid);
return Api(Some(barren_response(&account, STATUS_NONE)));
}
let live_boost = started.and_then(|s| s["live_boost"].as_i64()).unwrap_or(0);
let lp_used = recorded_lp(started);
let event_id = scoring_event(started, global::set_time(global::timestamp(), uid, false));
let status = multi_live_end_status(&body);
println!(
"multi_live/end: uid {} score {} miss-ratio status {} ({})",
uid,
body["live_score"]["score"].as_i64().unwrap_or(-1),
status,
match status {
STATUS_MISS_RATIO_PENALTY => "PENALTY — results voided",
2 => "great/perfect low — points halved",
_ => "ok",
}
);
if status == STATUS_MISS_RATIO_PENALTY {
// The client discards the result and leaves the room, so nothing is granted. The
// record is already gone (claimed above), so the live is not left hanging until it
// expires either — and a re-POST of the same penalised result lands on the
// no-record branch rather than back here.
return Api(Some(barren_response(&account, status)));
}
// /multi_live/end carries neither live_boost nor deck_slot; use_lp is what
// live_end scales exp / gold / bond / mission rewards off.
let mut end_body = body.clone();
end_body["use_lp"] = lp_used.into();
if end_body["deck_slot"].is_null() {
if let Some(slot) = started.and_then(|s| s["deck_slot"].as_i32()) {
end_body["deck_slot"] = slot.into();
}
}
// A multi live scores like the official server did: no high score, no score board —
// the client's own result screen says so. Private (join-by-code) parties are no
// exception (Ethan 2026-08-12; an earlier build recorded private-party scores, and
// the room-privacy plumbing that told the two apart left with that behaviour). The
// clear count and max combo still record either way — the live really was played.
let mut rv = live::live_end_ex(&req, &key, &end_body, false, false, false);
rv["is_penalty_miss_ratio"] = status.into();
// Fields RecvMultiLiveEndRData declares that live_end does not emit.
rv["card_list"] = jzon::array![];
rv["card_sub_list"] = jzon::array![];
rv["group_list"] = jzon::array![];
rv["music_mission_reward_list"] = jzon::array![];
// MngLiveData.SendMultiLiveEnd dereferences event_ranking_data unconditionally,
// so it must be an object even when this live belongs to no event.
rv["event_ranking_data"] = object!{
"event_point_rank": 0,
"next_reward_rank_point": 0,
"event_score_rank": 0,
"next_reward_rank_score": 0
};
if let Some(event_id) = event_id {
// live_end already saved the account; re-read it before touching event points.
let mut user = userdata::get_acc(&key);
let user_id = user["user"]["id"].as_i64().unwrap_or(0);
let players = player_count(&body, user_id);
let live_rank = body["multi_live_rank"].as_i64().unwrap_or(0);
let points = multi_event_points(event_id, live_boost, players, live_rank, status);
event::give_event_points(event_id, points, &mut user);
userdata::save_acc(&key, user.clone());
let total = event::get_points(event_id, &user);
event_ranking::live_completed(event_id, user_id, total, 0);
let rank = event::get_rank(event_id, user_id as u64);
let mut event = event::get_event_data(&key, event_id);
event["point_ranking"]["point"] = total.into();
event["point_ranking"]["rank"] = rank.into();
event::save_event_data(&key, event_id, event);
rv["event_point_list"] = user["event_point_list"].clone();
rv["event_ranking_data"] = object!{
"event_point_rank": rank,
"next_reward_rank_point": 0,
"event_score_rank": rank,
"next_reward_rank_score": 0
};
}
Api(Some(rv))
}
#[cfg(test)]
mod tests {
use super::*;
use super::proto::{ClientMsg, Map, Value};
use jzon::array;
use std::time::Instant;
const STOCK_LIVE_ID: i64 = 1100101;
#[test]
fn multi_consts_resolve_from_masterdata() {
assert_eq!(const_value("MULTI_PENALTY_MISS_RATIO", -1), 70);
assert_eq!(const_value("MULTI_PENALTY_NO_PLAY_MISS_RATIO", -1), 5000);
assert_eq!(const_value("MULTI_EVENT_LIVE_GREAT_PERFECT_NOTES_MIN_RATIO", -1), 50);
assert_eq!(const_value("NOT_A_CONST", -1), -1);
}
#[test]
fn boost_costs_come_from_live_boost() {
assert_eq!(boost_lp(1), 10);
assert_eq!(boost_lp(10), 100);
assert_eq!(boost_event_point_ratio(1), RATIO_ONE);
assert_eq!(boost_event_point_ratio(3), 3 * RATIO_ONE);
}
// The four multi events from multievent_setting.csv, all sharing one event_score row
// shape: _eventLivePointBase 10, _eventPointRatio 10000, _eventBoostRatio 35000.
const MULTI_EVENTS: [u32; 4] = [108, 111, 115, 119];
#[test]
fn event_score_rows_back_every_multi_event() {
for event_id in MULTI_EVENTS {
let row = &databases::EVENT_SCORE[event_id.to_string()];
assert!(!row.is_empty(), "event_score row missing for event {event_id}");
assert_eq!(row["eventLivePointBase"].as_i64(), Some(10));
assert_eq!(row["eventPointRatio"].as_i64(), Some(RATIO_ONE));
assert_eq!(row["eventBoostRatio"].as_i64(), Some(35000));
}
}
#[test]
fn event_live_points_derive_from_event_score() {
// 10 * (10000/10000) * (35000/10000) * boost — and the x3.5 must not truncate
// to x3 on the way through.
assert_eq!(event_live_points(108, 1), 35);
assert_eq!(event_live_points(108, 2), 70);
assert_eq!(event_live_points(108, 10), 350);
for event_id in MULTI_EVENTS {
assert_eq!(event_live_points(event_id, 1), 35);
}
}
#[test]
fn event_live_points_fall_back_to_zero_without_a_row() {
assert!(databases::EVENT_SCORE["99999"].is_empty());
assert_eq!(event_live_points(99999, 1), 0);
assert_eq!(multi_event_points(99999, 1, 4, 1, STATUS_NONE), 0);
}
#[test]
fn multi_event_points_layer_rank_bonus_and_halving() {
// 1st of 4 is +30% (multievent_rankbonus 4/1 = 3000).
assert_eq!(multi_event_points(108, 1, 4, 1, STATUS_NONE), 45);
// Last place earns the flat yield.
assert_eq!(multi_event_points(108, 1, 4, 4, STATUS_NONE), 35);
// The caution state halves whatever the bonus produced.
assert_eq!(multi_event_points(108, 1, 4, 1, STATUS_GREAT_PERFECT_LOW_RATIO), 22);
assert_eq!(multi_event_points(108, 1, 4, 4, STATUS_GREAT_PERFECT_LOW_RATIO), 17);
}
// release_label 223061504, event 108's window: 2023/06/19 05:00:00 - 2023/06/28 04:59:59.
fn during_multi_event_108() -> u64 {
global::parse_datetime("2023/06/20 12:00:00").unwrap()
}
#[test]
fn masterdata_datetimes_round_trip() {
let t = global::parse_datetime("2023/06/19 5:00:00").unwrap();
assert_eq!(global::format_datetime(t), "2023-06-19 05:00:00");
// A bare date is midnight, and the blank cells the evergreen label uses parse
// to nothing rather than to the epoch.
assert_eq!(
global::parse_datetime("2023/06/19"),
global::parse_datetime("2023/06/19 0:00:00")
);
assert_eq!(global::parse_datetime(""), None);
assert_eq!(global::parse_datetime("null"), None);
}
#[test]
fn the_multi_backing_event_is_closed_by_design() {
// The evergreen label has no window at all and is always open.
assert!(event::release_label_is_open(1, during_multi_event_108()));
// Event 108 is in session only inside its own long-past label window...
assert!(event::is_in_session(108, during_multi_event_108()));
// ...and is closed now, which is the permanent-feature state multi runs in.
// global::timestamp() rather than set_time: set_time reads crate::get_args(),
// whose clap parser chokes on the test-harness filter argument.
let now = global::timestamp();
assert!(!event::is_in_session(108, now), "event 108 must stay closed");
for event_id in MULTI_EVENTS {
assert!(!event::is_in_session(event_id, now));
}
}
#[test]
fn a_closed_backing_event_scores_against_nothing() {
let started = object!{ master_event_id: 108, live_boost: 1 };
// Closed today: the whole event-point path is skipped.
// global::timestamp() rather than set_time: set_time reads crate::get_args(),
// whose clap parser chokes on the test-harness filter argument.
let now = global::timestamp();
assert_eq!(scoring_event(Some(&started), now), None);
// But the gate is a window test, not a hardcoded off switch — open the event
// and scoring resumes on its own.
assert_eq!(scoring_event(Some(&started), during_multi_event_108()), Some(108));
// A live with no backing event at all, and a missing start record, score nothing.
assert_eq!(scoring_event(Some(&object!{ master_event_id: 0 }), during_multi_event_108()), None);
assert_eq!(scoring_event(None, during_multi_event_108()), None);
}
// The end-to-end path cannot be exercised here: live_end_ex calls items::get_region,
// which reaches crate::get_args(), whose clap parser rejects the test harness's own
// filter argument. (live.rs's tests avoid live_end for the same reason.) What is
// testable, and what actually matters, is that the reward input is derived with no
// reference to the event window — so a closed event can only ever suppress scoring.
#[test]
fn a_closed_event_still_pays_its_normal_rewards() {
let started = object!{ master_event_id: 108, live_boost: 1, use_lp: 10, deck_slot: 2 };
let open = during_multi_event_108();
// global::timestamp() rather than set_time: set_time reads crate::get_args(),
// whose clap parser chokes on the test-harness filter argument.
let closed = global::timestamp();
// The event gate is the only thing that moves between the two.
assert_eq!(scoring_event(Some(&started), open), Some(108));
assert_eq!(scoring_event(Some(&started), closed), None);
// The reward input is identical either way, and identical to what an eventless
// live would get for the same boost.
assert_eq!(recorded_lp(Some(&started)), 10);
assert_eq!(recorded_lp(Some(&object!{ live_boost: 1, use_lp: 10 })), 10);
// A start record with no recorded charge still falls back to the boost's cost.
assert_eq!(recorded_lp(Some(&object!{ master_event_id: 108, live_boost: 3 })), 30);
assert_eq!(recorded_lp(None), 0);
// And a suppressed event awards nothing, where an open one would pay 35.
assert_eq!(multi_event_points(108, 1, 4, 4, STATUS_NONE), 35);
}
// --- duplicate protection (the claim that gates the award) ----------------------
//
// /multi_live/end awards if and only if take_started_live hands it the record, and the
// record can be handed out exactly once. These drive that claim directly: the handler
// itself cannot be called from a test (live_end_ex reaches items::get_region, which
// reaches crate::get_args(), whose clap parser rejects the harness's own arguments).
// Two clients signed in to one account share a single started-live record, so the
// second /multi_live/end arrives after the first consumed it. The handler answers that
// from current state and grants nothing; this pins the state machine it keys off.
#[test]
fn a_second_end_for_one_session_finds_no_record_to_spend() {
let _lock = crate::runtime::lock_test_data_path();
let token = "multi_duplicate_end";
let start_body = object!{
master_live_id: STOCK_LIVE_ID,
level: 4,
deck_slot: 1,
live_boost: 1,
master_event_id: 108,
use_lp: 10
};
live::start_live(token, &start_body);
// First end: the record is there and pays for the live, and claiming it is what
// the handler does BEFORE it awards anything.
let started = live::take_started_live(token, &start_body);
assert!(started.is_some());
assert_eq!(recorded_lp(started.as_ref()), 10);
// Second end: nothing left to spend, which is the duplicate signal the handler
// short-circuits on, and the reward scale is zero even if it did not.
let again = live::take_started_live(token, &start_body);
assert!(again.is_none(), "the record must not survive the first end");
assert_eq!(recorded_lp(again.as_ref()), 0);
assert_eq!(scoring_event(again.as_ref(), during_multi_event_108()), None);
// And it stays gone however many times the client re-POSTs.
assert!(live::take_started_live(token, &start_body).is_none());
assert!(live::get_started_live(token, &start_body).is_none());
}
// The consumption used to hide inside get_end_live_deck_id, behind
// `record["deck_slot"].as_i32()?` — a start whose body carried no numeric deck_slot
// left the record in place, so every re-POST awarded again. Claiming is now about the
// record's existence and nothing else.
#[test]
fn a_start_with_no_deck_slot_is_still_consumed_by_the_first_end() {
let _lock = crate::runtime::lock_test_data_path();
let token = "multi_no_deck_slot";
for start_body in [
// No deck_slot at all...
object!{ master_live_id: STOCK_LIVE_ID, level: 4, live_boost: 1, use_lp: 10 },
// ...and one that is present but not a number.
object!{ master_live_id: STOCK_LIVE_ID, level: 4, live_boost: 1, use_lp: 10, deck_slot: "1" }
] {
live::start_live(token, &start_body);
let first = live::take_started_live(token, &start_body);
assert!(first.is_some(), "the record must be claimable without a deck_slot");
assert_eq!(recorded_lp(first.as_ref()), 10);
let second = live::take_started_live(token, &start_body);
assert!(second.is_none(), "a re-POST must find nothing left to award off");
}
}
// Two ends landing together — the normal case for one account signed in twice, and
// the one the old shape got wrong: both read the record, both passed the guard, both
// awarded. The claim is a single transaction, so exactly one of any number of racing
// ends can proceed.
#[test]
fn concurrent_ends_claim_the_record_exactly_once() {
let _lock = crate::runtime::lock_test_data_path();
let token = "multi_concurrent_end";
let start_body = object!{
master_live_id: STOCK_LIVE_ID,
level: 4,
deck_slot: 1,
live_boost: 1,
master_event_id: 108,
use_lp: 10
};
// Create the account before the threads start: the claim itself is atomic, the
// lazy account creation behind it is not, and that is not what is under test.
live::start_live(token, &start_body);
let claims = std::sync::atomic::AtomicUsize::new(0);
std::thread::scope(|s| {
for _ in 0..8 {
s.spawn(|| {
if live::take_started_live(token, &start_body).is_some() {
claims.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
}
});
}
});
assert_eq!(
claims.load(std::sync::atomic::Ordering::SeqCst),
1,
"exactly one of eight simultaneous ends may award"
);
assert!(live::get_started_live(token, &start_body).is_none());
}
#[test]
fn rank_bonus_matches_multievent_rankbonus() {
assert_eq!(rank_bonus(4, 1), 3000);
assert_eq!(rank_bonus(4, 4), 0);
assert_eq!(rank_bonus(2, 1), 1000);
// Rows the table does not cover earn nothing rather than panicking.
assert_eq!(rank_bonus(1, 1), 0);
assert_eq!(rank_bonus(4, 9), 0);
}
fn score(perfect: i64, great: i64, good: i64, bad: i64, miss: i64) -> JsonValue {
object!{
master_live_id: STOCK_LIVE_ID,
level: 4,
live_score: {
perfect: perfect, great: great, good: good, bad: bad, miss: miss
}
}
}
#[test]
fn end_status_follows_multiutil_ratios() {
let notes = note_count(STOCK_LIVE_ID, 4).expect("music_level row");
// A clean full combo is normal.
assert_eq!(multi_live_end_status(&score(notes, 0, 0, 0, 0)), STATUS_NONE);
// 70% or more of the notes missed trips the penalty.
assert_eq!(multi_live_end_status(&score(0, 0, 0, 0, notes)), STATUS_MISS_RATIO_PENALTY);
// Nothing judged at all is the "no play" case the 70% rule would wave through.
assert_eq!(multi_live_end_status(&score(0, 0, 0, 0, 0)), STATUS_MISS_RATIO_PENALTY);
// Under half the notes as perfect/great, but few enough misses to avoid the
// penalty, is the caution ("halved") state.
let goods = notes - (notes / 4) - 1;
assert_eq!(
multi_live_end_status(&score(notes / 4, 0, goods, 0, 1)),
STATUS_GREAT_PERFECT_LOW_RATIO
);
// LIVE_LEVEL 1 is exempt from the great/perfect check.
let mut beginner = score(0, 0, 0, 0, 0);
beginner["level"] = 1.into();
beginner["live_score"]["good"] = note_count(STOCK_LIVE_ID, 1).unwrap().into();
assert_eq!(multi_live_end_status(&beginner), STATUS_NONE);
// A live with no MusicLevelMst row never penalises, like MultiUtil.
let mut unknown = score(0, 0, 0, 0, 0);
unknown["master_live_id"] = 999999999i64.into();
assert_eq!(multi_live_end_status(&unknown), STATUS_NONE);
}
#[test]
fn player_count_does_not_double_count_the_poster() {
let body = object!{
other_live_score_list: array![
object!{ user_id: 7 },
object!{ user_id: 8 },
object!{ user_id: 9 }
]
};
// LiveScene includes the poster in the list.
assert_eq!(player_count(&body, 8), 3);
// A list that omits the poster still yields the real party size.
assert_eq!(player_count(&body, 42), 4);
}
}