mirror of
https://git.ethanthesleepy.one/ethanaobrien/ew
synced 2026-08-26 15:02:18 +08:00
798 lines
36 KiB
Rust
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);
|
|
}
|
|
}
|