diff --git a/Dockerfile b/Dockerfile index a578abc0..77075051 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # MoeNotes deploy image: Go semi-SSR gateway + Next.js 16 standalone server + Haneoka WASM deck engine -# (pinned to ournotes-team-builder 4ed6e0061f51 with patches/haneoka-rewards.patch; also backward-compatible +# (pinned to ournotes-team-builder 7c167c95e858 with patches/haneoka-rewards.patch; also backward-compatible # with CI release artifact mode when MOENOTES_BUILD_MODE is unset). # Set MOENOTES_BUILD_MODE=ssr in production to serve live semi-SSR HTML + persistent disk cache + Markdown routing. diff --git a/patches/haneoka-rewards.patch b/patches/haneoka-rewards.patch index 10fc3048..42d7d11c 100644 --- a/patches/haneoka-rewards.patch +++ b/patches/haneoka-rewards.patch @@ -1,5 +1,5 @@ diff --git a/src/api.rs b/src/api.rs -index 55399d3..ff5fc35 100644 +index 2aa61a7..df815d5 100644 --- a/src/api.rs +++ b/src/api.rs @@ -1,3 +1,6 @@ @@ -9,7 +9,23 @@ index 55399d3..ff5fc35 100644 //! Engine context and platform-neutral team evaluation. Chart loading is owned //! by the caller; the same release-bound context can serve native and WASM. use crate::{ -@@ -532,6 +535,45 @@ impl crate::search::Objective for PowerObjective { +@@ -457,7 +460,14 @@ impl Engine { + ); + input.clock = live::FrameClock::for_play(play); + match chart::timing_mode(play) { +- Some(best) if input.slots.iter().any(|slot| !slot.cumulative.is_empty()) => live::score_reachable(&input, &crate::input_timing::InputTiming::new(&self.master, chart, input.judgements.as_deref(), input.clock)?, best)?, ++ Some(best) if input.slots.iter().any(|slot| !slot.cumulative.is_empty()) => { ++ if chart::has_native_inputs(&self.master, chart) { ++ live::score_reachable(&input, &crate::input_timing::InputTiming::new(&self.master, chart, input.judgements.as_deref(), input.clock)?, best)? ++ } else { ++ let frames = chart::timing_frames_at(&self.master, chart, input.judgements.as_deref(), input.clock); ++ live::score_timed(&input, &frames, best)? ++ } ++ } + _ => live::score(&input)?, + } + }; +@@ -577,6 +587,44 @@ impl crate::search::Objective for PowerObjective { }) } } @@ -51,11 +67,10 @@ index 55399d3..ff5fc35 100644 + Ok(crate::search::Exact { key: value, detail: json!({"skipScore": value}) }) + } +} -+ fn constraints( + master: &Master, input: &Value, - members: &[power::Member], -@@ -702,8 +744,18 @@ impl Engine { +@@ -763,8 +811,18 @@ impl Engine { challenge, )?) }; @@ -76,7 +91,7 @@ index 55399d3..ff5fc35 100644 .map(|e| e.effects.as_slice()) .unwrap_or_default(); let member_pct = |member: &power::Member| power::member_event_percent(effects, member, 2); -@@ -732,6 +784,9 @@ impl Engine { +@@ -793,6 +851,9 @@ impl Engine { &cards.snaps, master::number(&request["goal"]["windowSeconds"], 120.0), )) @@ -86,7 +101,7 @@ index 55399d3..ff5fc35 100644 } else { Box::new(PowerObjective) }; -@@ -760,7 +815,7 @@ impl Engine { +@@ -821,7 +882,7 @@ impl Engine { on_candidate: Some(&mut crate::progress::emit), progress: None, })?; @@ -95,7 +110,7 @@ index 55399d3..ff5fc35 100644 Ok( json!({"results":[{"song":song,"hits":hits,"proven":output.proven,"bound":output.bound,"stats":output.stats}],"overall":hits,"unknownCards":cards.unknown,"elapsedMs":now()-started,"stopReason":if output.proven{None}else if generation!=crate::cancellation_generation(){Some("cancelled")}else{Some("budget")}}), ) -@@ -1660,6 +1715,8 @@ impl Engine { +@@ -1721,6 +1782,8 @@ impl Engine { source, )) }); @@ -104,7 +119,7 @@ index 55399d3..ff5fc35 100644 let output = gekiso_objective::search_scored( gekiso_objective::Input { env: gekiso_objective::Environment { -@@ -1691,11 +1748,20 @@ impl Engine { +@@ -1752,11 +1815,20 @@ impl Engine { seeded: request["seeded"] == true, shard, floor, @@ -127,7 +142,7 @@ index 55399d3..ff5fc35 100644 #[cfg(not(target_arch = "wasm32"))] let output = { -@@ -1736,7 +1802,7 @@ impl Engine { +@@ -1797,7 +1869,7 @@ impl Engine { } self.gekiso_cache.replace(Some((cache_key, chart))); Ok( @@ -136,7 +151,7 @@ index 55399d3..ff5fc35 100644 ) } } -@@ -1774,7 +1840,10 @@ impl Engine { +@@ -1835,7 +1907,10 @@ impl Engine { Some("skip") => RouteKind::Skip, _ => RouteKind::Live, }; @@ -148,7 +163,7 @@ index 55399d3..ff5fc35 100644 let songs = if kind == RouteKind::Skip && master::rows(&goal["songs"]).is_empty() { vec![Value::Null] } else { -@@ -1831,7 +1900,7 @@ impl Engine { +@@ -1892,7 +1967,7 @@ impl Engine { .ok_or("missing-chart")?, )?; } @@ -157,7 +172,7 @@ index 55399d3..ff5fc35 100644 Some(master::int(&goal["eventId"])) } else { None -@@ -1846,21 +1915,21 @@ impl Engine { +@@ -1907,21 +1982,21 @@ impl Engine { )?) }; let member_pct = |m: &power::Member| { @@ -182,7 +197,7 @@ index 55399d3..ff5fc35 100644 let key = json!([ song, request["members"], -@@ -1901,7 +1970,7 @@ impl Engine { +@@ -1962,7 +2037,7 @@ impl Engine { floor_exclusive: false, streaming: true, @@ -191,7 +206,7 @@ index 55399d3..ff5fc35 100644 member_power_percent: Some(&member_pct), snap_power_percent: Some(&snap_pct), on_candidate: None, -@@ -1936,7 +2005,8 @@ impl Engine { +@@ -1997,7 +2072,8 @@ impl Engine { 0.0, exchange, score_cap, @@ -201,7 +216,7 @@ index 55399d3..ff5fc35 100644 match weights { Some((cp, other)) => objective.with_weights(cp, other), None => objective, -@@ -1955,6 +2025,7 @@ impl Engine { +@@ -2020,6 +2096,7 @@ impl Engine { { return Err("event-reward-phase".into()); } @@ -209,7 +224,7 @@ index 55399d3..ff5fc35 100644 let mut objective: Box = if let Some(plan) = &plan { use crate::reward_plan::{Context, Metric, Ranked, Source}; Box::new(Ranked::new(Source::Ordinary(Box::new(EventObjective::new( -@@ -1962,6 +2033,12 @@ impl Engine { +@@ -2027,6 +2104,12 @@ impl Engine { plan.anchor() == Metric::Points, &goal["play"], true, 0.0, None, score_cap, ))), Context { plan: plan.clone(), source_anchor: plan.anchor(), route: route.clone(), chart, members: &cards.members, snaps: &cards.snaps, k, floor: f64::NEG_INFINITY, skip_rank: self.master.params.skip_rank, observations: 120 })) @@ -222,7 +237,7 @@ index 55399d3..ff5fc35 100644 } else if cp_measure { Box::new(ChallengePoints::new( LiveObjective { -@@ -1977,7 +2054,7 @@ impl Engine { +@@ -2042,7 +2125,7 @@ impl Engine { }, chart.unwrap().clone(), route.clone(), @@ -231,7 +246,7 @@ index 55399d3..ff5fc35 100644 } else { Box::new(make_event()) }; -@@ -2022,7 +2099,7 @@ impl Engine { +@@ -2087,7 +2170,7 @@ impl Engine { floor_exclusive: false, streaming: true, @@ -240,7 +255,7 @@ index 55399d3..ff5fc35 100644 member_power_percent: Some(&member_pct), snap_power_percent: Some(&snap_pct), on_candidate: if notify { Some(&mut publish) } else { None }, -@@ -2031,6 +2108,31 @@ impl Engine { +@@ -2096,6 +2179,31 @@ impl Engine { }; let mut seeds = seed_teams(request, &song, &cards.members, &cards.snaps); seeds.extend(cap_seeds); @@ -272,7 +287,7 @@ index 55399d3..ff5fc35 100644 let mut output = if phase.is_some_and(|phase| phase != "primary") { let target = request["rewardPhase"]["reward"] .as_f64() -@@ -2085,14 +2187,18 @@ impl Engine { +@@ -2150,14 +2258,18 @@ impl Engine { )] .as_f64() .unwrap_or(f64::NEG_INFINITY) @@ -292,7 +307,7 @@ index 55399d3..ff5fc35 100644 }) { let mut keys: Vec<_> = output.hits.iter().map(|h| h.key).collect(); keys.sort_by(|a, b| b.total_cmp(a)); -@@ -2185,7 +2291,7 @@ impl Engine { +@@ -2250,7 +2362,7 @@ impl Engine { } output.hits = ordered; } @@ -301,7 +316,7 @@ index 55399d3..ff5fc35 100644 let mut keys: Vec<_> = output.hits.iter().map(|h| h.key).collect(); keys.sort_by(|a, b| b.total_cmp(a)); keys.dedup(); -@@ -2226,15 +2332,26 @@ impl Engine { +@@ -2291,15 +2403,26 @@ impl Engine { } output.hits = ordered; } @@ -330,7 +345,7 @@ index 55399d3..ff5fc35 100644 if let Some(phase) = phase { part["rewardPhase"] = json!(phase); } -@@ -2568,7 +2685,7 @@ impl Engine { +@@ -2643,7 +2766,7 @@ impl Engine { if kind == "plan" { return self.plan(input, started, now); } @@ -339,7 +354,7 @@ index 55399d3..ff5fc35 100644 vec![request["goal"]["song"].clone()] } else { master::rows(&request["goal"]["songs"]) -@@ -2581,7 +2698,7 @@ impl Engine { +@@ -2656,7 +2779,7 @@ impl Engine { } else { songs }; @@ -348,7 +363,7 @@ index 55399d3..ff5fc35 100644 return Err(format!("unsupported-goal:{kind}")); } let deadline = request["deadline"].as_f64().unwrap_or_else(|| { -@@ -2631,6 +2748,7 @@ impl Engine { +@@ -2706,6 +2829,7 @@ impl Engine { crate::progress::chart(self.charts.get(&key), kind == "gekiso"); let mut single = request.clone(); single["goal"]["songs"] = json!([song]); @@ -356,7 +371,7 @@ index 55399d3..ff5fc35 100644 let current = now(); let song_deadline = (current + (deadline - current).max(0.0) / (songs.len() - index) as f64) -@@ -2639,13 +2757,15 @@ impl Engine { +@@ -2714,13 +2838,15 @@ impl Engine { single["deadline"] = json!(song_deadline); } let mut response = match kind { @@ -374,7 +389,7 @@ index 55399d3..ff5fc35 100644 } } "gekiso" => self.search_gekiso( -@@ -3963,6 +4083,21 @@ fn merge_reward_candidates( +@@ -4040,6 +4166,21 @@ fn merge_reward_candidates( } fn compare_hits(a: &Value, b: &Value) -> std::cmp::Ordering { @@ -396,7 +411,7 @@ index 55399d3..ff5fc35 100644 let ranking = crate::reward_plan::compare(&b["rewardRanking"], &a["rewardRanking"]); ranking.unwrap_or_else(|| master::number(&b["key"], 0.0) .total_cmp(&master::number(&a["key"], 0.0)) -@@ -3987,6 +4122,8 @@ impl Engine { +@@ -4064,6 +4205,8 @@ impl Engine { return scoped.evaluate_all(&scoped_input); } let request = &input["request"]; @@ -405,7 +420,35 @@ index 55399d3..ff5fc35 100644 let mut out = Vec::new(); for song in master::rows(&request["songs"]) { self.load_source(input, song)?; -@@ -4172,3 +4309,77 @@ pub(crate) fn normalize_hit(hit: &mut Value) { +@@ -4199,12 +4342,21 @@ impl Engine { + if early_grade == Some(grade) { advance_ms } else { 0 } + }).collect(); + if input.slots.iter().all(|slot| slot.cumulative.is_empty()) { return Ok(Value::Null); } +- let timing_inputs = crate::input_timing::InputTiming::new(&self.master, chart, input.judgements.as_deref(), input.clock)?; +- let Some(timing) = live::timing_with_inputs(&input, index, &before_ms, Some(&timing_inputs)) else { return Ok(Value::Null) }; +- let mut output = json!(timing); +- output["worst"] = json!(live::score_reachable_order(&input, &timing_inputs, false, index)?); +- output["optimal"] = json!(live::score_reachable_order(&input, &timing_inputs, true, index)?); +- Ok(output) ++ if chart::has_native_inputs(&self.master, chart) { ++ let timing_inputs = crate::input_timing::InputTiming::new(&self.master, chart, input.judgements.as_deref(), input.clock)?; ++ let Some(timing) = live::timing_with_inputs(&input, index, &before_ms, Some(&timing_inputs)) else { return Ok(Value::Null) }; ++ let mut output = json!(timing); ++ output["worst"] = json!(live::score_reachable_order(&input, &timing_inputs, false, index)?); ++ output["optimal"] = json!(live::score_reachable_order(&input, &timing_inputs, true, index)?); ++ Ok(output) ++ } else { ++ let Some(timing) = live::timing_with_inputs(&input, index, &before_ms, None) else { return Ok(Value::Null) }; ++ let frames = chart::timing_frames_at(&self.master, chart, input.judgements.as_deref(), input.clock); ++ let mut output = json!(timing); ++ output["worst"] = json!(live::score_timed_order(&input, &frames, false, index)?); ++ output["optimal"] = json!(live::score_timed_order(&input, &frames, true, index)?); ++ Ok(output) ++ } + } + } + +@@ -4294,3 +4446,92 @@ pub(crate) fn normalize_hit(hit: &mut Value) { } } } @@ -431,14 +474,29 @@ index 55399d3..ff5fc35 100644 + #[cfg(target_arch="wasm32")] + let _scope=crate::clock::deadline_scope(Some(deadline)); + let kind=match goal["measure"].as_str(){Some("points")=>0,Some("items")=>1,Some("challenge-points")=>2,Some("combined")=>3,_=>return Err("moenotes-reward-measure".into())}; -+ let battle=gk["scene"]!="missionLive"; ++ let battle=gk["scene"]!="missionLive" && goal["room"]["mode"]!="solo"; ++ let mut constraints=constraints(&self.master,&request["constraints"],&cards.members,&cards.snaps); ++ if chart.song.missions.iter().any(|m| *m == 1 || *m == 4) && master::int(&gk["minComboBonus"]) > 1 { ++ let values: Vec<_> = crate::gekiso_objective::opening_combo_bonuses(&self.master, &cards.members, &chart.song.missions) ++ .into_iter().map(|v| v as f64).collect(); ++ let maximum = values.iter().copied().fold(0.0, f64::max); ++ constraints.member_floor = Some(search::MemberFloor { ++ values, ++ minimum: (master::int(&gk["minComboBonus"]) - 1) as f64, ++ maximum, ++ }); ++ } + let mut objective=MoenotesRewards::new(&self.master,&cards.members,&cards.snaps,chart,compiled,route,kind,battle,&goal["room"],deadline,now)?.with_goal(goal)?; ++ if let Some(minimum) = crate::just_window::target(&gk["minJustWindowMs"])? { ++ objective.set_min_just_window(minimum)?; ++ } + let sampled=objective.sampled;let ceiling=objective.ceiling(); + let k=master::int(&request["k"]).max(1) as usize;let generation=crate::cancellation_generation(); + let mut power_request=request.clone();power_request["goal"]=json!({"kind":"power","song":song}); + power_request["deadline"]=json!(deadline.min(now()+50.0)); ++ let has_special_filter = constraints.member_floor.is_some() || !constraints.member_floors.is_empty() || objective.has_just_window(); + let seeded=!seed_teams(request,song,&cards.members,&cards.snaps).is_empty(); -+ let power=if seeded {json!({"overall":[],"results":[{"proven":false,"stats":null}]})} ++ let power=if seeded || has_special_filter {json!({"overall":[],"results":[{"proven":false,"stats":null}]})} + else {crate::progress::with_callback(None,||self.search_power(&power_request,started,now))?}; + let power_proven=power["results"][0]["proven"]==true; + let mut completed=Vec::new();let mut interrupted=false; @@ -464,7 +522,7 @@ index 55399d3..ff5fc35 100644 + seeds.extend(master::rows(&power["overall"]).into_iter().cloned());seeded["seeds"]=json!(seeds); + let mut publish=|hits:&[search::Hit]|{completed=hits.to_vec();crate::progress::emit(hits);}; + let output=search::search_at(search::Input{master:&self.master,player:&player,members:&cards.members,snaps:&cards.snaps,music:Some(&music), -+ objective:&mut objective,constraints:constraints(&request["constraints"],&cards.members,&cards.snaps),k,deadline,now, ++ objective:&mut objective,constraints,k,deadline,now, + seeds:seed_teams(&seeded,song,&cards.members,&cards.snaps),shard:None,owns:None,floor:f64::NEG_INFINITY,floor_exclusive:false, + streaming:true,disable_snap_dominance:true,member_power_percent:None,snap_power_percent:None,on_candidate:Some(&mut publish),progress:None},generation); + interrupted=matches!(&output,Err(error) if error=="search-deadline"); @@ -484,7 +542,7 @@ index 55399d3..ff5fc35 100644 + } +} diff --git a/src/api/arena.rs b/src/api/arena.rs -index b06d931..9caf7af 100644 +index 12e0e4f..31e7e17 100644 --- a/src/api/arena.rs +++ b/src/api/arena.rs @@ -1,3 +1,4 @@ @@ -649,6 +707,83 @@ index 087fc97..b496656 100644 pub(crate) fn column_prices(&self, columns: usize) -> Vec { self.v[1..=columns] .iter() +diff --git a/src/chart.rs b/src/chart.rs +index 1f5e4fa..064c656 100644 +--- a/src/chart.rs ++++ b/src/chart.rs +@@ -249,6 +249,72 @@ pub fn prepare( + luck_percent, + } + } ++pub fn has_native_inputs(master: &Master, chart: &Chart) -> bool { ++ !master.live.judgement_area_offsets.is_empty() ++ && chart.judged_indices.iter().all(|&i| chart.notes[i].input.is_some()) ++} ++ ++/// Reachable delivery frames at `clock`, keeping each input's base judgement, ++/// when precompiled compact charts omit native lane/shape geometry. ++pub fn timing_frames_at( ++ master: &Master, ++ chart: &Chart, ++ grades: Option<&[usize]>, ++ clock: live::FrameClock, ++) -> Vec> { ++ chart ++ .times ++ .iter() ++ .enumerate() ++ .map(|(i, &time)| { ++ let kind = chart.judgement_types[i]; ++ if !matches!(kind, 1 | 2 | 5 | 10 | 11 | 12 | 15) { ++ return vec![clock.frame_time(time)]; ++ } ++ let grade = grades.map_or(5, |rows| rows[i]) as i32; ++ let Some(&before) = master.live.judgement_before_ms.get(&(kind, grade)) else { ++ return Vec::new(); ++ }; ++ let Some(&after) = master.live.judgement_after_ms.get(&(kind, grade)) else { ++ return Vec::new(); ++ }; ++ let stricter: Vec<_> = ((grade + 1)..=5) ++ .filter_map(|g| { ++ Some(( ++ *master.live.judgement_before_ms.get(&(kind, g))?, ++ *master.live.judgement_after_ms.get(&(kind, g))?, ++ )) ++ }) ++ .collect(); ++ let intervals = if stricter.is_empty() { ++ vec![(-before, after)] ++ } else { ++ let strict_before = stricter.iter().map(|r| r.0).max().unwrap(); ++ let strict_after = stricter.iter().map(|r| r.1).max().unwrap(); ++ vec![ ++ (-before, after.min(-strict_before - 1)), ++ ((strict_after + 1).max(-before), after), ++ ] ++ }; ++ let mut frames = Vec::new(); ++ for (low, high) in intervals.into_iter().filter(|(low, high)| low <= high) { ++ let end = clock.frame_time(time.saturating_add(high)); ++ let mut frame = clock.frame_time(time.saturating_add(low)); ++ loop { ++ frames.push(frame); ++ if frame >= end { ++ break; ++ } ++ frame = clock.frame_time(frame.saturating_add(1)); ++ } ++ } ++ frames.sort_unstable(); ++ frames.dedup(); ++ frames ++ }) ++ .collect() ++} ++ + pub fn timing_mode(play: &serde_json::Value) -> Option { + match play["cumulativeTiming"].as_str() { + Some("best") => Some(true), diff --git a/src/clock.rs b/src/clock.rs index 8bfc101..583ee4a 100644 --- a/src/clock.rs @@ -680,7 +815,7 @@ index 8bfc101..583ee4a 100644 + SEARCH_DEADLINE.with(|value|value.get().is_finite() && milliseconds()>=value.get()) +} diff --git a/src/event.rs b/src/event.rs -index d5ff20e..c4e11a5 100644 +index 69b8a39..eda0076 100644 --- a/src/event.rs +++ b/src/event.rs @@ -1,3 +1,6 @@ @@ -723,25 +858,16 @@ index d5ff20e..c4e11a5 100644 + pub challenge: bool, pub rate: f64, pub reward_rate: f64, - pub points: BTreeMap, -@@ -447,7 +434,7 @@ impl Route { - .ok_or_else(|| format!("unknown-event:{event_id}"))?; - let challenge = - kind == RouteKind::Challenge || (kind == RouteKind::Skip && consumption > 10); -- if !challenge && ![0, 1, 2, 3, 4, 5, 10].contains(&consumption) { -+ if !challenge && !(0..=10).contains(&consumption) { - return Err("unsupported-live-boost-cost".into()); - } - let table = if challenge { -@@ -468,6 +455,7 @@ impl Route { + cp_rate: Option, +@@ -469,6 +456,7 @@ impl Route { }; let mut route = Self { kind, + challenge, rate, reward_rate, - points: if challenge { -@@ -511,6 +499,15 @@ impl Route { + cp_rate: (!challenge).then(|| i32(rate)), +@@ -513,6 +501,15 @@ impl Route { .collect(); Ok(route) } @@ -757,16 +883,7 @@ index d5ff20e..c4e11a5 100644 fn rank_row(&self, rank: i32) -> Option<&RankRow> { usize::try_from(rank).ok().and_then(|i| self.ranks.get(i)) } -@@ -527,7 +524,7 @@ impl Route { - }) - } - pub fn cp(&self, rank: i32) -> f64 { -- if self.kind == RouteKind::Live { -+ if !self.challenge { - self.rank_row(rank).map_or(0, |row| row.challenge_points) as f64 * self.rate - } else { - 0.0 -@@ -687,12 +684,28 @@ pub(crate) fn top_character_bonus(members: &[Member], values: &[f64]) -> f64 { +@@ -691,12 +688,28 @@ pub(crate) fn top_character_bonus(members: &[Member], values: &[f64]) -> f64 { } top_five(characters.into_values()) } @@ -795,7 +912,7 @@ index d5ff20e..c4e11a5 100644 pub cp_weight: f64, pub exchange: Option<(f64, f64)>, pub score_cap: f64, -@@ -795,7 +808,13 @@ impl EventObjective { +@@ -799,7 +812,13 @@ impl EventObjective { chart: chart.filter(|_| played).cloned(), route, points_measure, @@ -809,7 +926,7 @@ index d5ff20e..c4e11a5 100644 cp_weight, exchange, score_cap, -@@ -835,6 +854,32 @@ impl EventObjective { +@@ -839,6 +858,32 @@ impl EventObjective { mean_frontiers: vec![Vec::new(); members.len()], } } @@ -842,7 +959,7 @@ index d5ff20e..c4e11a5 100644 /// Ranks by `measure + cp·CP + other·(the other reward)`, the weights already divided by the measure's weight. pub fn with_weights(mut self, cp: f64, other: f64) -> Self { self.targets.clear(); -@@ -891,6 +936,9 @@ impl EventObjective { +@@ -895,6 +940,9 @@ impl EventObjective { } } fn reward_bound(&self, bonus: f64, rank: i32) -> f64 { @@ -852,7 +969,7 @@ index d5ff20e..c4e11a5 100644 let value = |grade| { self.value_upper(bonus, grade) + self.tie * self.other_upper(self.max_other, grade) }; -@@ -947,8 +995,10 @@ impl EventObjective { +@@ -951,8 +999,10 @@ impl EventObjective { self.curves.get(&key).and_then(Option::as_ref) } fn mean_bound(&mut self, bonus: f64, other: f64, score: f64) -> f64 { @@ -864,7 +981,7 @@ index d5ff20e..c4e11a5 100644 } fn cp_curve(&mut self) -> Option<&RewardCurve> { if self.cp_curve.is_none() { -@@ -1104,6 +1154,7 @@ impl EventObjective { +@@ -1108,6 +1158,7 @@ impl EventObjective { from: usize, t: Totals, other: f64, @@ -872,7 +989,7 @@ index d5ff20e..c4e11a5 100644 ) -> Option<(f64, f64)> { if self.core.as_ref()?.unbounded || self.core.as_ref()?.input.judgements.is_some() { return None; -@@ -1115,10 +1166,13 @@ impl EventObjective { +@@ -1119,10 +1170,13 @@ impl EventObjective { self.assignment_profiles[slot] } }); @@ -887,7 +1004,7 @@ index d5ff20e..c4e11a5 100644 let key = other.to_bits(); self.other_mean_bound(other, f64::INFINITY); let secondary = self -@@ -1128,14 +1182,97 @@ impl EventObjective { +@@ -1132,14 +1186,97 @@ impl EventObjective { .map_or(f64::INFINITY, |curve| curve.orders(&coefficients, t.power)); Some((primary, secondary)) } @@ -988,7 +1105,7 @@ index d5ff20e..c4e11a5 100644 let core = self.core.as_ref()?; if core.unbounded || core.input.judgements.is_some() { return None; -@@ -1184,38 +1321,17 @@ impl EventObjective { +@@ -1188,38 +1325,17 @@ impl EventObjective { (((sum * sum).next_up() / (4.0 * converted)).next_up() * (1.0 + RELATIVE_ERROR)) .next_up() }); @@ -1036,7 +1153,7 @@ index d5ff20e..c4e11a5 100644 self.other_mean_bound(other, f64::INFINITY); let secondary = self .other_curves -@@ -1266,6 +1382,7 @@ impl EventObjective { +@@ -1270,6 +1386,7 @@ impl EventObjective { t: Totals, from: usize, other: f64, @@ -1044,7 +1161,7 @@ index d5ff20e..c4e11a5 100644 ) -> Option<(f64, f64)> { if self.core.as_ref()?.unbounded || self.core.as_ref()?.input.judgements.is_some() { return None; -@@ -1277,10 +1394,13 @@ impl EventObjective { +@@ -1281,10 +1398,13 @@ impl EventObjective { self.suffix_profiles[from] } }); @@ -1059,24 +1176,25 @@ index d5ff20e..c4e11a5 100644 self.other_mean_bound(other, f64::INFINITY); let secondary = self .other_curves -@@ -1361,6 +1481,16 @@ impl EventObjective { +@@ -1365,7 +1485,16 @@ impl EventObjective { } } impl Objective for EventObjective { -+ fn generic_assignment_product(&self)->bool {!self.power_ties} -+ fn discrete_upper(&self,upper:f64)->f64 { -+ if self.nonmonotone() || !self.power_ties || !self.points_measure && self.route.rewards.iter().any(|row|(row.probability as f64)%10000.0!=0.0) || self.tie!=0.0 || self.cp_weight!=0.0 || self.exchange.is_some() || !upper.is_finite() || upper.abs()>i32::MAX as f64 {return upper;} -+ let count=if self.core.is_some() && !self.maximum {120.0} else {1.0}; -+ (upper*count).next_up().floor()/count -+ } -+ fn power_ties(&self)->bool {self.power_ties} -+ fn score_ties(&self)->bool {self.power_ties} -+ fn tie_rank(&self,hit:&crate::search::Hit)->[f64;2] {[hit.power,0.0]} -+ fn member_snapshot_prices(&self) -> bool { self.snap_bonus.iter().any(|&bonus|bonus!=0.0) } +- fn generic_assignment_product(&self) -> bool { self.core.is_some() } ++ fn generic_assignment_product(&self) -> bool { self.core.is_some() && !self.power_ties } ++ fn discrete_upper(&self, upper: f64) -> f64 { ++ if self.nonmonotone() || !self.power_ties || !self.points_measure && self.route.rewards.iter().any(|row| (row.probability as f64) % 10000.0 != 0.0) || self.tie != 0.0 || self.cp_weight != 0.0 || self.exchange.is_some() || !upper.is_finite() || upper.abs() > i32::MAX as f64 { return upper; } ++ let count = if self.core.is_some() && !self.maximum { 120.0 } else { 1.0 }; ++ (upper * count).next_up().floor() / count ++ } ++ fn power_ties(&self) -> bool { self.power_ties } ++ fn score_ties(&self) -> bool { self.power_ties } ++ fn tie_rank(&self, hit: &crate::search::Hit) -> [f64; 2] { [hit.power, 0.0] } ++ fn member_snapshot_prices(&self) -> bool { self.snap_bonus.iter().any(|&bonus| bonus != 0.0) } fn seed_objective(&self) -> Option> { (!self.points_measure && self -@@ -1413,17 +1543,19 @@ impl Objective for EventObjective { +@@ -1418,17 +1547,19 @@ impl Objective for EventObjective { .is_none_or(|core| core.shape(member, a) == core.shape(member, b)) } fn preserve_snap_options(&self) -> bool { @@ -1084,8 +1202,8 @@ index d5ff20e..c4e11a5 100644 + self.nonmonotone() || self.unsafe_maximum() } fn conditional_snap_dominance(&self) -> bool { -- !self.nonmonotone() -+ !self.nonmonotone() && !self.unsafe_maximum() +- self.core.is_some() && !self.nonmonotone() ++ self.core.is_some() && !self.nonmonotone() && !self.unsafe_maximum() } fn canonical_leader(&self) -> bool { !self.core.as_ref().is_some_and(|core| core.unbounded) @@ -1099,7 +1217,7 @@ index d5ff20e..c4e11a5 100644 && self .core .as_mut() -@@ -1447,7 +1579,8 @@ impl Objective for EventObjective { +@@ -1452,7 +1583,8 @@ impl Objective for EventObjective { power_delta: f64, ) -> bool { !self.nonmonotone() @@ -1109,7 +1227,7 @@ index d5ff20e..c4e11a5 100644 && self .core .as_mut() -@@ -1493,6 +1626,7 @@ impl Objective for EventObjective { +@@ -1498,6 +1630,7 @@ impl Objective for EventObjective { self.snap_width = width; } fn prepare_assignment_power(&mut self, power: f64) { @@ -1117,7 +1235,7 @@ index d5ff20e..c4e11a5 100644 self.base_power = power; } fn prepare_members(&mut self, characters: &[Vec]) { -@@ -1529,7 +1663,7 @@ impl Objective for EventObjective { +@@ -1534,7 +1667,7 @@ impl Objective for EventObjective { + self.other_member_suffix[from][left] + self.other_snap_top }; @@ -1126,7 +1244,7 @@ index d5ff20e..c4e11a5 100644 .map(|bounds| bounds.0) } fn product_base(&self) -> Option { -@@ -1557,7 +1691,7 @@ impl Objective for EventObjective { +@@ -1562,7 +1695,7 @@ impl Objective for EventObjective { self.value(totals.bonus, rank) + self.tie * self.other(other, rank) } fn joint_targets(&mut self, limit: f64) -> Option> { @@ -1135,7 +1253,7 @@ index d5ff20e..c4e11a5 100644 return None; } let scale = self.core.as_ref()?.input.converted_count as f64 / (1.0 + RELATIVE_ERROR); -@@ -1598,6 +1732,7 @@ impl Objective for EventObjective { +@@ -1603,6 +1736,7 @@ impl Objective for EventObjective { }) } fn prepare_assignment(&mut self, _members: &[usize], options: &[Vec]) { @@ -1143,7 +1261,7 @@ index d5ff20e..c4e11a5 100644 if self.core.is_some() { for (slot, choices) in options.iter().enumerate() { let member = _members[slot]; -@@ -1711,18 +1846,28 @@ impl Objective for EventObjective { +@@ -1716,18 +1850,28 @@ impl Objective for EventObjective { } else { self.assignment_other_bonus(members, assigned, from) }; @@ -1175,7 +1293,7 @@ index d5ff20e..c4e11a5 100644 return Some(bounds.0); } -@@ -1742,6 +1887,7 @@ impl Objective for EventObjective { +@@ -1747,6 +1891,7 @@ impl Objective for EventObjective { } } fn interval(&mut self, team: &Team, t: Totals) -> (f64, f64) { @@ -1183,15 +1301,15 @@ index d5ff20e..c4e11a5 100644 let other = self.other_bonus(team); let Some(core) = &self.core else { let key = -@@ -1765,12 +1911,34 @@ impl Objective for EventObjective { +@@ -1770,12 +1915,34 @@ impl Objective for EventObjective { let mean = self.value(bonus, self.skip_rank); let direct = self.direct(bonus, self.skip_rank); let other_mean = self.other(other, self.skip_rank); + let skip_score = self.skip_score.as_ref().map(|(master, chart)| chart::skip_score(master, chart, power as i32)); return Ok(Exact { key: mean + self.tie * other_mean, -- detail: json!({"mean":mean,"bonus":bonus,"directMean":direct,"otherMean":other_mean,"directMin":direct,"directMax":direct}), -+ detail: json!({"mean":mean,"skipScore":skip_score,"rewardSum":direct,"orders":1,"bonus":bonus,"directMean":direct,"otherMean":other_mean,"directMin":direct,"directMax":direct}), +- detail: json!({"mean":mean,"bonus":bonus,"directMean":direct,"otherMean":other_mean,"directMin":direct,"directMax":direct,"challengePoints":self.route.cp(self.skip_rank)}), ++ detail: json!({"mean":mean,"skipScore":skip_score,"rewardSum":direct,"orders":1,"bonus":bonus,"directMean":direct,"otherMean":other_mean,"directMin":direct,"directMax":direct,"challengePoints":self.route.cp(self.skip_rank)}), }); }; let scores = core.evaluate(team, power as i32)?; @@ -1219,7 +1337,7 @@ index d5ff20e..c4e11a5 100644 let mut per_order = Vec::with_capacity(scores.scores.len()); let mut ranks = BTreeMap::::new(); let (mut total, mut cp, mut reward_sum, mut direct_min, mut direct_max, mut other_sum) = -@@ -1809,7 +1977,7 @@ impl Objective for EventObjective { +@@ -1814,7 +1981,7 @@ impl Objective for EventObjective { let other_mean = (other_sum / count).clamp(other_min, other_max); Ok(Exact { key: (mean + self.tie * other_mean).clamp(key_min, key_max), @@ -1228,7 +1346,7 @@ index d5ff20e..c4e11a5 100644 }) } } -@@ -1937,6 +2105,7 @@ impl ScoreTie { +@@ -1942,6 +2109,7 @@ impl ScoreTie { } } impl Objective for ScoreTie { @@ -1236,7 +1354,7 @@ index d5ff20e..c4e11a5 100644 fn exchange_snap_equivalent(&mut self, member: usize, a: i32, b: i32) -> bool { !self.mean.core.unbounded && self.mean.core.shape(member, a) == self.mean.core.shape(member, b) } -@@ -2032,7 +2201,7 @@ impl Objective for ScoreTie { +@@ -2037,7 +2205,7 @@ impl Objective for ScoreTie { .sum::() + self.member_suffix[from][left] + self.other_snaps; @@ -1245,7 +1363,7 @@ index d5ff20e..c4e11a5 100644 if primary < self.target || self.other_target.is_some_and(|target| other < target) { return Some(f64::NEG_INFINITY); } -@@ -2167,7 +2336,7 @@ impl Objective for ScoreTie { +@@ -2172,7 +2340,7 @@ impl Objective for ScoreTie { .map(|&s| self.event.other_snap(s)) .sum::() + self.other_suffix[from].min(distinct); @@ -1254,7 +1372,7 @@ index d5ff20e..c4e11a5 100644 let upper = if primary < self.target || self.other_target.is_some_and(|target| other < target) { f64::NEG_INFINITY -@@ -2181,7 +2350,7 @@ impl Objective for ScoreTie { +@@ -2186,7 +2354,7 @@ impl Objective for ScoreTie { } if let Some((primary, other, mean)) = self .event @@ -1263,7 +1381,7 @@ index d5ff20e..c4e11a5 100644 { if primary < self.target || self.other_target.is_some_and(|target| other < target) { return Some(f64::NEG_INFINITY); -@@ -2390,6 +2559,10 @@ pub struct ChallengePoints { +@@ -2395,6 +2563,10 @@ pub struct ChallengePoints { pub chart: Chart, pub route: Route, curve: Option, @@ -1274,7 +1392,7 @@ index d5ff20e..c4e11a5 100644 } impl ChallengePoints { pub fn new(mean: crate::objectives::LiveObjective, chart: Chart, route: Route) -> Self { -@@ -2415,7 +2588,32 @@ impl ChallengePoints { +@@ -2420,7 +2592,32 @@ impl ChallengePoints { chart, route, curve, @@ -1307,7 +1425,7 @@ index d5ff20e..c4e11a5 100644 } fn value(&self, score: f64) -> f64 { *self -@@ -2427,8 +2625,11 @@ impl ChallengePoints { +@@ -2432,8 +2629,11 @@ impl ChallengePoints { } } impl Objective for ChallengePoints { @@ -1320,7 +1438,7 @@ index d5ff20e..c4e11a5 100644 } fn needs_power_cap(&self) -> bool { self.mean.needs_power_cap() -@@ -2437,20 +2638,20 @@ impl Objective for ChallengePoints { +@@ -2442,20 +2642,20 @@ impl Objective for ChallengePoints { self.mean.prepare_dominance(power); } fn numeric_snap_dominance(&mut self, m: usize, a: i32, b: i32, power: f64) -> bool { @@ -1345,7 +1463,7 @@ index d5ff20e..c4e11a5 100644 } fn canonical_leader(&self) -> bool { self.mean.canonical_leader() -@@ -2459,20 +2660,22 @@ impl Objective for ChallengePoints { +@@ -2464,20 +2664,22 @@ impl Objective for ChallengePoints { self.mean.skill(m, s) } fn bound(&mut self, t: Totals) -> f64 { @@ -1371,7 +1489,7 @@ index d5ff20e..c4e11a5 100644 ) } fn exact(&mut self, team: &Team, power: f64, slots: &[f64]) -> Result { -@@ -2482,10 +2685,357 @@ impl Objective for ChallengePoints { +@@ -2487,10 +2689,357 @@ impl Objective for ChallengePoints { .into_iter() .map(|s| self.value(crate::master::number(s, 0.0))) .collect(); @@ -1732,8 +1850,20 @@ index d5ff20e..c4e11a5 100644 + "bonus":bonuses[self.selected],"challengePoints":sums[2] as f64/self.observations as f64,"directMin":per_order.iter().copied().reduce(f64::min),"directMax":per_order.iter().copied().reduce(f64::max)})}) + } } +diff --git a/src/event_growth.rs b/src/event_growth.rs +index b182bcf..f95a316 100644 +--- a/src/event_growth.rs ++++ b/src/event_growth.rs +@@ -155,6 +155,7 @@ struct Problem { + } + fn empty_player() -> Player { + Player { ++ memory: serde_json::Value::Null, + character_ranks: BTreeMap::new(), + total_rank: 0, + band_items: BTreeMap::new(), diff --git a/src/gekiso.rs b/src/gekiso.rs -index 3d1ce87..65ebb98 100644 +index afefec4..1616603 100644 --- a/src/gekiso.rs +++ b/src/gekiso.rs @@ -1,3 +1,4 @@ @@ -1741,7 +1871,7 @@ index 3d1ce87..65ebb98 100644 //! Gekiso context simulation, nominal lottery expectation and per-order scores. use crate::{ chart::Source, -@@ -2034,10 +2035,16 @@ struct ReplayTree<'a> { +@@ -2056,10 +2057,16 @@ struct ReplayTree<'a> { observe: Option<&'a (dyn Fn(f64, usize) -> bool + Sync)>, multiplicities: &'a [usize], stopped: AtomicBool, @@ -1758,7 +1888,7 @@ index 3d1ce87..65ebb98 100644 if self.generation != crate::cancellation_generation() { return Err("cancelled".into()); } -@@ -2131,7 +2138,20 @@ pub fn replay_orders( +@@ -2153,7 +2160,20 @@ pub fn replay_orders( seeds: &[i32], power: i32, ) -> Result { @@ -1780,7 +1910,7 @@ index 3d1ce87..65ebb98 100644 } /// Completed leaf orders carry their multiplicity in the native 120-order set. -@@ -2143,7 +2163,7 @@ pub(crate) fn replay_orders_with_cutoff( +@@ -2165,7 +2185,7 @@ pub(crate) fn replay_orders_with_cutoff( power: i32, observe: &(dyn Fn(f64, usize) -> bool + Sync), ) -> Result, String> { @@ -1789,7 +1919,7 @@ index 3d1ce87..65ebb98 100644 } fn replay_orders_observed( -@@ -2153,6 +2173,7 @@ fn replay_orders_observed( +@@ -2175,6 +2195,7 @@ fn replay_orders_observed( seeds: &[i32], power: i32, observe: Option<&(dyn Fn(f64, usize) -> bool + Sync)>, @@ -1797,7 +1927,7 @@ index 3d1ce87..65ebb98 100644 ) -> Result, String> { if deck.len() != 5 { return Err("a team requires five performers".into()); -@@ -2209,7 +2230,7 @@ fn replay_orders_observed( +@@ -2231,7 +2252,7 @@ fn replay_orders_observed( seeds.to_vec() }; let orders: Vec<_> = (0..live::orders().len()).collect(); @@ -1806,7 +1936,7 @@ index 3d1ce87..65ebb98 100644 else { return Ok(None); }; -@@ -2236,9 +2257,29 @@ pub(crate) fn replay_order_subset( +@@ -2258,9 +2279,29 @@ pub(crate) fn replay_order_subset( if chart.luck { return Err("order completion requires a non-LUCK chart".into()); } @@ -1837,7 +1967,7 @@ index 3d1ce87..65ebb98 100644 fn replay_selected_orders( master: &Master, chart: &Chart, -@@ -2247,6 +2288,7 @@ fn replay_selected_orders( +@@ -2269,6 +2310,7 @@ fn replay_selected_orders( power: i32, orders: &[usize], observe: Option<&(dyn Fn(f64, usize) -> bool + Sync)>, @@ -1845,7 +1975,7 @@ index 3d1ce87..65ebb98 100644 ) -> Result>, String> { if orders.is_empty() { return Ok(Some(Vec::new())); -@@ -2292,6 +2334,8 @@ fn replay_selected_orders( +@@ -2320,6 +2362,8 @@ fn replay_selected_orders( observe, multiplicities: &multiplicities, stopped: AtomicBool::new(false), @@ -1855,7 +1985,7 @@ index 3d1ce87..65ebb98 100644 #[cfg(target_arch = "wasm32")] let jobs = vec![(0..event_orders.len()).collect::>()]; diff --git a/src/gekiso_objective.rs b/src/gekiso_objective.rs -index f04c42f..8c2648e 100644 +index b1c2499..465db61 100644 --- a/src/gekiso_objective.rs +++ b/src/gekiso_objective.rs @@ -1,3 +1,5 @@ @@ -1873,7 +2003,7 @@ index f04c42f..8c2648e 100644 master: &'a Master, members: &'a [Member], snaps: &'a [Snap], -@@ -1812,6 +1816,8 @@ impl<'a> Kernel<'a> { +@@ -1798,6 +1802,8 @@ impl<'a> Kernel<'a> { pattern_order: VecDeque::new(), pattern_bytes: 0, numeric_hull: None, @@ -1882,7 +2012,7 @@ index f04c42f..8c2648e 100644 defer_scores: false, seed_preview: false, live_floor: false, -@@ -3278,6 +3284,9 @@ impl<'a> Kernel<'a> { +@@ -3264,6 +3270,9 @@ impl<'a> Kernel<'a> { if let Some(value) = self.cache.get(&key) { return Arc::clone(&value.weights); } @@ -1892,7 +2022,7 @@ index f04c42f..8c2648e 100644 let context = gekiso::context( within.unwrap_or(self.master), self.chart, -@@ -3504,10 +3513,10 @@ impl<'a> Kernel<'a> { +@@ -3490,10 +3499,10 @@ impl<'a> Kernel<'a> { deck: &[Performer], within: Option<&Master>, tag: i32, @@ -1905,7 +2035,7 @@ index f04c42f..8c2648e 100644 } let context = Arc::new( gekiso::context( -@@ -3515,8 +3524,7 @@ impl<'a> Kernel<'a> { +@@ -3501,8 +3510,7 @@ impl<'a> Kernel<'a> { self.chart, &deck, &self.seeds, @@ -1915,7 +2045,7 @@ index f04c42f..8c2648e 100644 ); let weights = Arc::new(self.weights(&context)); let bytes = 128 -@@ -3528,7 +3536,7 @@ impl<'a> Kernel<'a> { +@@ -3514,7 +3522,7 @@ impl<'a> Kernel<'a> { .map(|r| r.len() * 8) .sum::(); if bytes > 16 * 1024 * 1024 { @@ -1924,7 +2054,7 @@ index f04c42f..8c2648e 100644 } // A lottery record shared by several contexts counts once, while any of them holds it. let record = context.shared_record(); -@@ -3573,7 +3581,7 @@ impl<'a> Kernel<'a> { +@@ -3559,7 +3567,7 @@ impl<'a> Kernel<'a> { } } } @@ -1933,7 +2063,7 @@ index f04c42f..8c2648e 100644 } fn weights(&self, ctx: &Context) -> Weights { note_weights(self.master, self.chart, ctx) -@@ -3787,7 +3795,7 @@ impl<'a> Kernel<'a> { +@@ -3773,7 +3781,7 @@ impl<'a> Kernel<'a> { } /// Computes the support bounds a warm pass at `level` is about to read, several at a time where the target has /// threads, and keeps their weights in the cache that `weights_of` reads. @@ -1942,7 +2072,7 @@ index f04c42f..8c2648e 100644 if self.chart.luck { return; } -@@ -3801,6 +3809,7 @@ impl<'a> Kernel<'a> { +@@ -3787,6 +3795,7 @@ impl<'a> Kernel<'a> { let mut seen = HashSet::default(); let mut jobs = Vec::new(); for &(m, s) in pairs { @@ -1950,7 +2080,7 @@ index f04c42f..8c2648e 100644 let Some(p) = self.support(m, s) else { continue; }; -@@ -3828,13 +3837,27 @@ impl<'a> Kernel<'a> { +@@ -3814,13 +3823,27 @@ impl<'a> Kernel<'a> { if jobs.len() < 2 { return; } @@ -1978,7 +2108,7 @@ index f04c42f..8c2648e 100644 for ((_, key), weights) in jobs.into_iter().zip(found) { self.store_weights(key, Arc::new(weights)); } -@@ -4511,12 +4534,14 @@ impl Kernel<'_> { +@@ -4497,12 +4520,14 @@ impl Kernel<'_> { Ok(result) } fn unsettled_scores(&mut self, team: &Team, power: i32) -> Result, String> { @@ -1994,7 +2124,7 @@ index f04c42f..8c2648e 100644 let slots: Vec<_> = team .members .iter() -@@ -4564,6 +4589,13 @@ impl Kernel<'_> { +@@ -4550,6 +4575,13 @@ impl Kernel<'_> { .collect(); // Amortize replay-tree and native worker setup over larger completions. if missing.len() >= 30 { @@ -2008,7 +2138,7 @@ index f04c42f..8c2648e 100644 let values = gekiso::replay_order_subset(self.master, self.chart, &deck, power, &missing)?; for (order, value) in missing.into_iter().zip(values) { -@@ -4571,6 +4603,8 @@ impl Kernel<'_> { +@@ -4557,6 +4589,8 @@ impl Kernel<'_> { } } else { for order in missing { @@ -2017,7 +2147,7 @@ index f04c42f..8c2648e 100644 rows[order] = Some(gekiso::replay_order( self.master, self.chart, -@@ -4660,6 +4694,8 @@ impl Kernel<'_> { +@@ -4646,6 +4680,8 @@ impl Kernel<'_> { .then_some(values) } fn search_exact(&mut self, team: &Team, power: i32, settle: bool) -> Result { @@ -2026,7 +2156,7 @@ index f04c42f..8c2648e 100644 if !self.chart.luck && self.criterion == Criterion::Max && self.seeds.is_empty() && settle { if let Some(bounds) = self.max_order_bounds(team, power) { let deck = self.original_deck(team); -@@ -4669,6 +4705,8 @@ impl Kernel<'_> { +@@ -4655,6 +4691,8 @@ impl Kernel<'_> { let mut best = f64::NEG_INFINITY; let mut count = 0; for index in order { @@ -2035,7 +2165,7 @@ index f04c42f..8c2648e 100644 if bounds[index] <= best { break; } -@@ -4701,7 +4739,7 @@ impl Kernel<'_> { +@@ -4687,7 +4725,7 @@ impl Kernel<'_> { } let upper = self.unsettled_scores(team, power)?; let deck = self.deck_of(team); @@ -2044,7 +2174,7 @@ index f04c42f..8c2648e 100644 let slots: Vec<_> = team .members .iter() -@@ -5092,6 +5130,8 @@ impl Kernel<'_> { +@@ -5078,6 +5116,8 @@ impl Kernel<'_> { }))) } pub fn exact(&mut self, team: &Team, power: i32, settle: bool) -> Result { @@ -2053,7 +2183,7 @@ index f04c42f..8c2648e 100644 if !self.chart.luck && self.seeds.is_empty() { if let Some(scores) = self.partial_orders -@@ -5113,6 +5153,11 @@ impl Kernel<'_> { +@@ -5099,6 +5139,11 @@ impl Kernel<'_> { ) }) .collect(); @@ -2065,7 +2195,7 @@ index f04c42f..8c2648e 100644 let scores = gekiso::replay_orders(self.master, self.chart, &deck, &self.seeds, power)?; return Ok(Exact { key: self.criterion.key(&scores.scores), -@@ -5121,7 +5166,7 @@ impl Kernel<'_> { +@@ -5107,7 +5152,7 @@ impl Kernel<'_> { } let ctx = if self.seeds.is_empty() { let deck = self.deck_of(team); @@ -2074,7 +2204,7 @@ index f04c42f..8c2648e 100644 } else { let deck = team .members -@@ -10649,7 +10694,7 @@ fn warm(adapter: &mut Adapter<'_, '_>, env: &Environment<'_>, deadline: f64) -> +@@ -10651,7 +10696,7 @@ fn warm(adapter: &mut Adapter<'_, '_>, env: &Environment<'_>, deadline: f64) -> }) .collect(); let level = adapter.level; @@ -2083,7 +2213,7 @@ index f04c42f..8c2648e 100644 } for m in 0..env.members.len() { if (env.now)() >= deadline { -@@ -10827,6 +10872,8 @@ fn replay_bound( +@@ -10829,6 +10874,8 @@ fn replay_bound( )) } struct ReplayObjective<'a> { @@ -2092,7 +2222,7 @@ index f04c42f..8c2648e 100644 master: &'a Master, members: &'a [Member], snaps: &'a [Snap], -@@ -10854,6 +10901,9 @@ impl Objective for ReplayObjective<'_> { +@@ -10856,6 +10903,9 @@ impl Objective for ReplayObjective<'_> { fn feasible_assignment(&self, members: &[usize], assigned: &[i32], from: usize, options: &[Vec]) -> bool { self.just_window.as_ref().is_none_or(|window| window.possible_prepared(members, assigned, from, options)) } @@ -2102,7 +2232,7 @@ index f04c42f..8c2648e 100644 fn score_ties(&self) -> bool { self.criterion != Criterion::Mean } -@@ -10958,6 +11008,9 @@ impl Objective for ReplayObjective<'_> { +@@ -10960,6 +11010,9 @@ impl Objective for ReplayObjective<'_> { ) }) .collect(); @@ -2112,7 +2242,7 @@ index f04c42f..8c2648e 100644 let scores = gekiso::replay_orders(self.master, self.chart, &deck, self.seeds, power as i32)?; let mut result = Exact { -@@ -11380,6 +11433,8 @@ fn search_levels( +@@ -11382,6 +11435,8 @@ fn search_levels( }); } let mut objective = ReplayObjective { @@ -2121,7 +2251,7 @@ index f04c42f..8c2648e 100644 master: env.master, members: env.members, snaps: env.snaps, -@@ -11442,6 +11497,8 @@ fn search_levels( +@@ -11444,6 +11499,8 @@ fn search_levels( let generation = env.generation; let mut kernel = Kernel::new(env.master, env.members, env.snaps, chart, seeds, criterion); kernel.just_window = just_window; @@ -2131,7 +2261,7 @@ index f04c42f..8c2648e 100644 kernel.global_fixed_preparation = bounds.preparation.clone(); kernel.global_fixed_luck = bounds.luck.clone(); diff --git a/src/gekiso_objective/shared.rs b/src/gekiso_objective/shared.rs -index 265b957..eacaa32 100644 +index db9fa86..4856470 100644 --- a/src/gekiso_objective/shared.rs +++ b/src/gekiso_objective/shared.rs @@ -1,3 +1,4 @@ @@ -2139,7 +2269,7 @@ index 265b957..eacaa32 100644 //! A single physical formation optimized over a complete set of Gekiso charts. use super::*; -@@ -840,6 +841,10 @@ pub(crate) fn search_shared(input: SharedInput<'_>) -> Result) -> Result) -> Result) -> Result) -> Result) -> Result], best: bool) -> Result { + Ok(summarize(input.power, timed_scores(input, frames, best, None, None)?)) + } ++pub(crate) fn score_timed_order(input: &Input, frames: &[Vec], best: bool, index: usize) -> Result { ++ Ok(timed_scores(input, frames, best, None, Some(index))?[index]) ++} + pub fn score_reachable(input: &Input, timing: &crate::input_timing::InputTiming, best: bool) -> Result { + Ok(summarize(input.power, timed_scores(input, &timing.frames, best, Some(timing), None)?)) + } diff --git a/src/luck.rs b/src/luck.rs -index b0be79a..004e5d4 100644 +index f860a33..0fa2e4f 100644 --- a/src/luck.rs +++ b/src/luck.rs @@ -1,3 +1,4 @@ @@ -2184,7 +2328,7 @@ index b0be79a..004e5d4 100644 //! Ordered vector dynamic programming for the merged website's Gekiso lottery. //! Called only inside the compute backend, never from Flutter's UI isolate. use rustc_hash::{FxHashMap as HashMap, FxHashSet as HashSet}; -@@ -749,6 +750,8 @@ pub fn run(input: &Input) -> Result { +@@ -760,6 +761,8 @@ pub fn run(input: &Input) -> Result { { return Err("invalid gauge width".into()); } @@ -2193,7 +2337,7 @@ index b0be79a..004e5d4 100644 let generation = crate::cancellation_generation(); let mut e = Engine::new(input); let mut out = Output { -@@ -783,6 +786,8 @@ pub fn run(input: &Input) -> Result { +@@ -794,6 +797,8 @@ pub fn run(input: &Input) -> Result { e.reset(input.initial); let mut steady_at = None; for k in first..=last { @@ -2202,7 +2346,7 @@ index b0be79a..004e5d4 100644 if generation != crate::cancellation_generation() { return Err("cancelled".into()); } -@@ -912,6 +917,8 @@ pub fn run(input: &Input) -> Result { +@@ -923,6 +928,8 @@ pub fn run(input: &Input) -> Result { } } } @@ -2212,7 +2356,7 @@ index b0be79a..004e5d4 100644 release(row, &mut e.pool); } diff --git a/src/master.rs b/src/master.rs -index 479b44a..b096696 100644 +index ac38992..62d1d33 100644 --- a/src/master.rs +++ b/src/master.rs @@ -1,3 +1,4 @@ @@ -2220,7 +2364,7 @@ index 479b44a..b096696 100644 //! Release-bound, typed tables compiled from the public team-builder document. use crate::num::f; use serde_json::{Map, Value}; -@@ -375,6 +376,7 @@ pub struct BandItem { +@@ -378,6 +379,7 @@ pub struct BandItem { } #[derive(Clone)] struct MasterData { @@ -2228,7 +2372,7 @@ index 479b44a..b096696 100644 pub server: String, pub release_id: String, pub params: Params, -@@ -414,6 +416,8 @@ struct MasterData { +@@ -417,6 +419,8 @@ struct MasterData { } #[derive(Clone)] pub struct Master { @@ -2237,7 +2381,7 @@ index 479b44a..b096696 100644 pub server: String, pub release_id: String, pub params: Arc, -@@ -455,6 +459,8 @@ pub struct Master { +@@ -458,6 +462,8 @@ pub struct Master { impl From for Master { fn from(data: MasterData) -> Self { Self { @@ -2246,7 +2390,7 @@ index 479b44a..b096696 100644 server: data.server, release_id: data.release_id, params: Arc::new(data.params), -@@ -877,16 +883,14 @@ pub fn compile(data: &Value) -> Result { +@@ -880,16 +886,14 @@ pub fn compile(data: &Value) -> Result { .find(|r| r["id"] == "live_skip_result_score_rank") .map(|r| text(&r["value"])) .unwrap_or_else(|| "C".into()); @@ -2271,7 +2415,7 @@ index 479b44a..b096696 100644 }; let challenge = if !data["crossServer"]["challengeMusics"].is_null() { &data["crossServer"]["challengeMusics"] -@@ -894,6 +898,7 @@ pub fn compile(data: &Value) -> Result { +@@ -897,6 +901,7 @@ pub fn compile(data: &Value) -> Result { &data["challengeMusicTable"]["rows"] }; Ok(Master::from(MasterData { @@ -2279,6 +2423,73 @@ index 479b44a..b096696 100644 server: text(&identity["server"]), release_id: text(&identity["releaseId"]), params: Params { +diff --git a/src/objectives.rs b/src/objectives.rs +index f874e7e..0a717e7 100644 +--- a/src/objectives.rs ++++ b/src/objectives.rs +@@ -136,9 +136,13 @@ impl Objective for AccuracySeed { + }) + } + } ++enum TimingSource { ++ Native(Result), ++ Frames(Vec>), ++} + pub struct LiveCore { + pub input: Input, +- timing: Option<(bool, Result)>, ++ timing: Option<(bool, TimingSource)>, + pub high: Linear, + pub low: Linear, + pub factor_cap: f64, +@@ -258,15 +262,22 @@ impl LiveCore { + let mut input = chart::prepare(master, chart, 0, Vec::new(), judgements, 1.0, 100.0); + input.clock = live::FrameClock::for_play(play); + let timing = chart::timing_mode(play).map(|best| { +- ( +- best, +- crate::input_timing::InputTiming::new( ++ let source = if chart::has_native_inputs(master, chart) { ++ TimingSource::Native(crate::input_timing::InputTiming::new( + master, + chart, + input.judgements.as_deref(), + input.clock, +- ), +- ) ++ )) ++ } else { ++ TimingSource::Frames(chart::timing_frames_at( ++ master, ++ chart, ++ input.judgements.as_deref(), ++ input.clock, ++ )) ++ }; ++ (best, source) + }); + let play_profile = table.for_play(&input); + let healthy_play = play_profile.healthy; +@@ -863,14 +874,15 @@ impl LiveCore { + .iter() + .any(|slot| !slot.cumulative.is_empty()) => + { +- ( +- live::score_reachable( ++ let scores = match timing { ++ TimingSource::Native(timing) => live::score_reachable( + &self.input, + timing.as_ref().map_err(Clone::clone)?, + *best, + )?, +- None, +- ) ++ TimingSource::Frames(frames) => live::score_timed(&self.input, frames, *best)?, ++ }; ++ (scores, None) + } + _ if self.played_envelope => live::score_with_curve(&self.input)?, + _ => (live::score(&self.input)?, None), diff --git a/src/power.rs b/src/power.rs index f7e17a4..79d88dc 100644 --- a/src/power.rs @@ -2391,10 +2602,10 @@ index c6fb67f..45545ff 100644 value["potential"] = json!({"value":detail["potential"],"area":detail["area"]}); } diff --git a/src/rewards.rs b/src/rewards.rs -index 20a5594..c8b11d2 100644 +index 1de2094..ccede8c 100644 --- a/src/rewards.rs +++ b/src/rewards.rs -@@ -11626,3 +11626,205 @@ pub(crate) fn order_score_coefficients_with_base( +@@ -11646,3 +11646,246 @@ pub(crate) fn order_score_coefficients_with_base( .collect(), ) } @@ -2421,6 +2632,8 @@ index 20a5594..c8b11d2 100644 + ceiling_rank: i32, + monotone: bool, + pub sampled: bool, ++ samples: i32, ++ just_window: Option, + deadline: f64, + now: &'a dyn Fn() -> f64, + exact_cache: HashMap<(i32,Vec,Vec),(f64,serde_json::Value)>, @@ -2440,7 +2653,8 @@ index 20a5594..c8b11d2 100644 + let mut top = snap_bonus.clone();top.sort_by(|a,b|b.total_cmp(a)); + let max_bonus = crate::event::top_character_bonus(members,&member_bonus)+top.iter().take(5).map(|v|v.max(0.0)).sum::(); + let mut ranks=chart.ranks.clone();ranks.sort_by_key(|r|r.required); -+ let mut out=Self{master,members,snaps,sampled:gk.luck,chart:gk,route,ranks,kind,battle,players,others, ++ let samples = if gk.luck { 4 } else { 1 }; ++ let mut out=Self{master,members,snaps,sampled:gk.luck,samples,just_window:None,chart:gk,route,ranks,kind,battle,players,others, + member_bonus,snap_bonus,max_bonus,other_member:Vec::new(),other_snap:Vec::new(),levels:Vec::new(),maximum:false,ceiling_rank:2,monotone:true,deadline,now,exact_cache:HashMap::default()}; + let eligible=out.ranks.iter().map(|r|r.rank).collect::>(); + if eligible.is_empty(){return Err("moenotes-reward-ranks".into());} @@ -2461,6 +2675,11 @@ index 20a5594..c8b11d2 100644 + } + pub fn with_goal(mut self, goal:&serde_json::Value)->Result { + self.maximum=goal["criterion"]=="max"; ++ if let Some(seeds) = goal["gekiso"]["seeds"].as_i64().filter(|&n| n > 0) { ++ if self.sampled { ++ self.samples = seeds.min(256) as i32; ++ } ++ } + if self.kind==3 { + self.levels=serde_json::from_value(goal["levels"].clone()).map_err(|_|"moenotes-combined-levels")?; + if self.maximum || self.levels.is_empty() || self.levels.len()>3 || self.levels.iter().flatten().any(|t|t.kind>2 || t.weight<1 || t.weight>1_000_000_000) {return Err("moenotes-combined-levels".into());} @@ -2469,6 +2688,14 @@ index 20a5594..c8b11d2 100644 + }else {self.other_member=vec![0.0;self.members.len()];self.other_snap=vec![0.0;self.snaps.len()];} + Ok(self) + } ++ pub(crate) fn set_min_just_window(&mut self, minimum: f64) -> Result<(), String> { ++ if !self.chart.setup.missions.contains(&3) { return Ok(()); } ++ let seeds: Vec<_> = if self.sampled { (0..self.samples.max(1)).collect() } else { Vec::new() }; ++ let prepared = crate::just_window::Prepared::new(self.master, &self.chart, self.members, self.snaps, minimum, &seeds)?; ++ if prepared.active() { self.just_window = Some(prepared); } ++ Ok(()) ++ } ++ pub(crate) fn has_just_window(&self) -> bool { self.just_window.is_some() } + fn value_at(&self, kind:usize, bonus:f64, rank:i32)->Result { + match kind {0 if self.route.points.contains_key(&rank)=>Ok(self.route.points(bonus,rank)), + 1 if self.route.rewards.iter().any(|r|r.score_rank==rank && r.resource_id==self.route.item_id)=>Ok(self.route.items(bonus,rank)), @@ -2504,6 +2731,31 @@ index 20a5594..c8b11d2 100644 + } +} +impl Objective for MoenotesRewards<'_> { ++ fn filter_member_dominates(&self, better: usize, worse: usize) -> bool { ++ self.just_window.as_ref().is_none_or(|window| window.member_dominates(better, worse)) ++ } ++ fn filter_permutation_invariant(&self) -> bool { ++ self.just_window.as_ref().is_none_or(crate::just_window::Prepared::permutation_invariant) ++ } ++ fn filter_snap_dominates(&self, member: usize, better: i32, worse: i32) -> bool { ++ self.just_window.as_ref().is_none_or(|window| window.snap_dominates(member, better, worse)) ++ } ++ fn feasible_assignment(&self, members: &[usize], assigned: &[i32], from: usize, options: &[Vec]) -> bool { ++ self.just_window.as_ref().is_none_or(|window| window.possible_prepared(members, assigned, from, options)) ++ } ++ fn prepare_assignment(&mut self, members: &[usize], options: &[Vec]) { ++ if let Some(window) = &mut self.just_window { window.prepare_assignment(members, options); } ++ } ++ fn has_team_filter(&self) -> bool { self.just_window.is_some() } ++ fn team_filter_impossible(&self) -> bool { ++ self.just_window.as_ref().is_some_and(crate::just_window::Prepared::impossible) ++ } ++ fn feasible_team(&mut self, team: &Team, power: f64) -> Result { ++ match &mut self.just_window { ++ Some(prepared) => prepared.allows(self.master, &self.chart, team, power as i32), ++ None => Ok(true), ++ } ++ } + fn generic_assignment_product(&self)->bool {false} + fn power_ties(&self)->bool {self.kind<3} + fn physical_teams(&self)->bool {self.kind==3} @@ -2544,7 +2796,7 @@ index 20a5594..c8b11d2 100644 + let mapping:[usize;120]=std::array::from_fn(|i|{let order=live::orders()[i].map(|s|positions[s]);live::orders().iter().position(|o|*o==order).unwrap()}); + let bonus=team.members.iter().map(|&m|self.member_bonus[m]).sum::()+team.snaps.iter().filter(|&&s|s>=0).map(|&s|self.snap_bonus[s as usize]).sum::(); + let other=team.members.iter().map(|&m|self.other_member[m]).sum::()+team.snaps.iter().filter(|&&s|s>=0).map(|&s|self.other_snap[s as usize]).sum::(); -+ let runs=if self.sampled {4}else{1}; ++ let runs=self.samples; + let mut scores=vec![0.0;120];let mut values=vec![[0i128;3];120];let mut ranks=BTreeMap::::new(); + for seed in 0..runs { + if (self.now)()>=self.deadline {return Err("search-deadline".into());} @@ -2601,7 +2853,7 @@ index 20a5594..c8b11d2 100644 + } +} diff --git a/src/search.rs b/src/search.rs -index b2ca65f..85c3bc5 100644 +index dbf4f6c..2c1a457 100644 --- a/src/search.rs +++ b/src/search.rs @@ -1,3 +1,5 @@ @@ -2610,7 +2862,7 @@ index b2ca65f..85c3bc5 100644 //! Exact top-K search over leaders, character-distinct member sets and snap //! assignments. Bounds prune only when the retained legal teams justify it. use crate::{ -@@ -67,6 +69,15 @@ pub trait Objective { +@@ -68,6 +70,15 @@ pub trait Objective { ) -> bool { true } @@ -2626,7 +2878,7 @@ index b2ca65f..85c3bc5 100644 fn score_ties(&self) -> bool { false } -@@ -584,6 +595,7 @@ struct Runner<'a, O: Objective + ?Sized> { +@@ -603,6 +614,7 @@ struct Runner<'a, O: Objective + ?Sized> { hit_keys: Vec, option_scratch: [Vec; 5], retained: HashMap, @@ -2634,7 +2886,7 @@ index b2ca65f..85c3bc5 100644 compact_at: usize, timed_out: bool, unexplored: f64, -@@ -681,7 +693,7 @@ fn suffix(values: &[f64], from: usize) -> f64 { +@@ -700,7 +712,7 @@ fn suffix(values: &[f64], from: usize) -> f64 { values[from..].iter().rev().fold(0.0, |sum, v| sum + v) } impl<'a, O: Objective + ?Sized> Runner<'a, O> { @@ -2643,7 +2895,7 @@ index b2ca65f..85c3bc5 100644 let started = (input.now)(); let generation = crate::cancellation_generation(); let team_filter = input.objective.has_team_filter(); -@@ -746,7 +758,9 @@ impl<'a, O: Objective + ?Sized> Runner<'a, O> { +@@ -765,7 +777,9 @@ impl<'a, O: Objective + ?Sized> Runner<'a, O> { vec![0.0; n * width], ); for i in 0..n { @@ -2653,7 +2905,7 @@ index b2ca65f..85c3bc5 100644 let col = if j < 0 { s } else { j as usize }; if j >= 0 { snap_power[i * width + col] = power::snap_points( -@@ -802,6 +816,7 @@ impl<'a, O: Objective + ?Sized> Runner<'a, O> { +@@ -821,6 +835,7 @@ impl<'a, O: Objective + ?Sized> Runner<'a, O> { }) .collect(); let mut profiles = HashMap::default(); @@ -2661,7 +2913,7 @@ index b2ca65f..85c3bc5 100644 if input.objective.needs_power_cap() { let mut maximum = 0.0_f64; for leader in 0..n { -@@ -918,10 +933,11 @@ impl<'a, O: Objective + ?Sized> Runner<'a, O> { +@@ -937,10 +952,11 @@ impl<'a, O: Objective + ?Sized> Runner<'a, O> { .collect(); let k = input.k; let snap_power = Arc::new(snap_power); @@ -2674,7 +2926,7 @@ index b2ca65f..85c3bc5 100644 input, started, last_notify: started, -@@ -957,6 +973,7 @@ impl<'a, O: Objective + ?Sized> Runner<'a, O> { +@@ -976,6 +992,7 @@ impl<'a, O: Objective + ?Sized> Runner<'a, O> { hit_keys: Vec::new(), option_scratch: std::array::from_fn(|_| Vec::new()), retained: HashMap::default(), @@ -2682,7 +2934,7 @@ index b2ca65f..85c3bc5 100644 compact_at: 4096, timed_out: false, unexplored: f64::NEG_INFINITY, -@@ -968,7 +985,7 @@ impl<'a, O: Objective + ?Sized> Runner<'a, O> { +@@ -987,7 +1004,7 @@ impl<'a, O: Objective + ?Sized> Runner<'a, O> { stamp: 0, target_limit: f64::NAN, member_targets: None, @@ -2691,7 +2943,7 @@ index b2ca65f..85c3bc5 100644 } fn expired(&self) -> bool { self.generation != crate::cancellation_generation() -@@ -1014,6 +1031,7 @@ impl<'a, O: Objective + ?Sized> Runner<'a, O> { +@@ -1033,6 +1050,7 @@ impl<'a, O: Objective + ?Sized> Runner<'a, O> { } } fn cannot_improve(&self, upper: f64) -> bool { @@ -2699,7 +2951,7 @@ index b2ca65f..85c3bc5 100644 (self.input.objective.live_floor() && !self.input.objective.score_ties() && upper <= crate::search_floor()) -@@ -1024,6 +1042,13 @@ impl<'a, O: Objective + ?Sized> Runner<'a, O> { +@@ -1043,6 +1061,13 @@ impl<'a, O: Objective + ?Sized> Runner<'a, O> { || !self.input.objective.score_ties() && upper == self.hits[self.input.k - 1].key)) } @@ -2713,7 +2965,7 @@ index b2ca65f..85c3bc5 100644 fn compare_hit(&mut self, hit: &mut Hit, index: usize) -> Result { if let Some(order) = self.input.objective.compare_rank(hit, &self.hits[index]) { return Ok(order); -@@ -1042,9 +1067,7 @@ impl<'a, O: Objective + ?Sized> Runner<'a, O> { +@@ -1061,9 +1086,7 @@ impl<'a, O: Objective + ?Sized> Runner<'a, O> { self.input .objective .complete_score_tie(&mut self.hits[index])?; @@ -2724,7 +2976,7 @@ index b2ca65f..85c3bc5 100644 } fn insert_position(&mut self, hit: &mut Hit) -> Result { let mut i = self.hits.len(); -@@ -1061,6 +1084,14 @@ impl<'a, O: Objective + ?Sized> Runner<'a, O> { +@@ -1080,6 +1103,14 @@ impl<'a, O: Objective + ?Sized> Runner<'a, O> { keys.sort_by(|a, b| a.encode_utf16().cmp(b.encode_utf16())); keys.join("|") } @@ -2739,7 +2991,7 @@ index b2ca65f..85c3bc5 100644 fn profile(&mut self, leader: usize) -> Arc { self.profiles .entry(leader) -@@ -1072,7 +1103,8 @@ impl<'a, O: Objective + ?Sized> Runner<'a, O> { +@@ -1091,7 +1122,8 @@ impl<'a, O: Objective + ?Sized> Runner<'a, O> { }) .clone() } @@ -2749,7 +3001,7 @@ index b2ca65f..85c3bc5 100644 if !self.input.streaming { self.candidates.push(candidate); return Ok(()); -@@ -1082,7 +1114,8 @@ impl<'a, O: Objective + ?Sized> Runner<'a, O> { +@@ -1101,7 +1133,8 @@ impl<'a, O: Objective + ?Sized> Runner<'a, O> { self.unexplored = self.unexplored.max(candidate.high); return Ok(()); } @@ -2759,7 +3011,7 @@ index b2ca65f..85c3bc5 100644 return Ok(()); } let existing = self -@@ -1134,6 +1167,7 @@ impl<'a, O: Objective + ?Sized> Runner<'a, O> { +@@ -1153,6 +1186,7 @@ impl<'a, O: Objective + ?Sized> Runner<'a, O> { if !was_evaluated { self.stats.exact += 1; } @@ -2767,7 +3019,7 @@ index b2ca65f..85c3bc5 100644 self.frontier.offer(&candidate.set_key, exact.key); let mut hit = Hit { team: candidate.team, -@@ -1160,6 +1194,7 @@ impl<'a, O: Objective + ?Sized> Runner<'a, O> { +@@ -1179,6 +1213,7 @@ impl<'a, O: Objective + ?Sized> Runner<'a, O> { self.retained.remove(&key); } self.input.objective.observe_hits(&self.hits); @@ -2775,7 +3027,7 @@ index b2ca65f..85c3bc5 100644 if let Some(callback) = self.input.on_candidate.as_deref_mut() { callback(&self.hits); } -@@ -1284,7 +1319,8 @@ impl<'a, O: Objective + ?Sized> Runner<'a, O> { +@@ -1303,7 +1338,8 @@ impl<'a, O: Objective + ?Sized> Runner<'a, O> { } let exact = self.input.objective.exact(&seed, power, &powers)?; self.stats.exact += 1; @@ -2785,7 +3037,7 @@ index b2ca65f..85c3bc5 100644 self.frontier.offer(&set_key, exact.key); self.retain_seed( Candidate { -@@ -1341,6 +1377,29 @@ impl<'a, O: Objective + ?Sized> Runner<'a, O> { +@@ -1360,6 +1396,29 @@ impl<'a, O: Objective + ?Sized> Runner<'a, O> { snaps: assigned.iter().map(|&col| snapshots[col]).collect(), }) } @@ -2815,7 +3067,7 @@ index b2ca65f..85c3bc5 100644 fn warm_seed(&mut self, owned_leaders: Option<&[usize]>) -> Result<(), String> { let Some(mut objective) = self.input.objective.seed_objective() else { return Ok(()); -@@ -1683,7 +1742,12 @@ impl<'a, O: Objective + ?Sized> Runner<'a, O> { +@@ -1702,7 +1761,12 @@ impl<'a, O: Objective + ?Sized> Runner<'a, O> { } let exact = self.input.objective.exact(&next, totals.power, &powers)?; self.stats.exact += 1; @@ -2828,7 +3080,7 @@ index b2ca65f..85c3bc5 100644 if candidate > next_value { next_value = candidate; best = Some((next, totals, powers, exact)); -@@ -1698,7 +1762,7 @@ impl<'a, O: Objective + ?Sized> Runner<'a, O> { +@@ -1717,7 +1781,7 @@ impl<'a, O: Objective + ?Sized> Runner<'a, O> { }; team = next; value = next_value; @@ -2837,7 +3089,7 @@ index b2ca65f..85c3bc5 100644 self.frontier.offer(&set_key, exact.key); self.retain_seed( Candidate { -@@ -2167,7 +2231,8 @@ impl<'a, O: Objective + ?Sized> Runner<'a, O> { +@@ -2184,7 +2248,8 @@ impl<'a, O: Objective + ?Sized> Runner<'a, O> { ), }; let plain = self.input.objective.bound(totals); @@ -2847,7 +3099,7 @@ index b2ca65f..85c3bc5 100644 return Ok(()); } let limit = self.necessary_limit(self.limit()); -@@ -2185,6 +2250,7 @@ impl<'a, O: Objective + ?Sized> Runner<'a, O> { +@@ -2202,6 +2267,7 @@ impl<'a, O: Objective + ?Sized> Runner<'a, O> { } let bound = self.member_set_bound(chosen, totals, left, index); if bound < self.limit() @@ -2855,7 +3107,7 @@ index b2ca65f..85c3bc5 100644 || self.cannot_improve(bound) || self .input -@@ -2227,11 +2293,11 @@ impl<'a, O: Objective + ?Sized> Runner<'a, O> { +@@ -2244,11 +2310,11 @@ impl<'a, O: Objective + ?Sized> Runner<'a, O> { self.member_visit(m, index + 1, power, weight, bonus, member_part) } fn assignment_limit(&self, a: &Assignment) -> f64 { @@ -2869,7 +3121,7 @@ index b2ca65f..85c3bc5 100644 && (a.exact_low > upper || !self.input.objective.score_ties() && a.exact_low == upper) } -@@ -2353,6 +2419,20 @@ impl<'a, O: Objective + ?Sized> Runner<'a, O> { +@@ -2370,6 +2436,20 @@ impl<'a, O: Objective + ?Sized> Runner<'a, O> { true } fn remaining_bound(&mut self, a: &Assignment, from: usize, totals: Totals) -> f64 { @@ -2890,7 +3142,7 @@ index b2ca65f..85c3bc5 100644 let mut table = std::mem::take(&mut self.pools[from]); self.tables(&mut table, a, from); let power = self.matching.maximum(&table.power, table.columns, None); -@@ -2376,6 +2456,11 @@ impl<'a, O: Objective + ?Sized> Runner<'a, O> { +@@ -2393,6 +2473,11 @@ impl<'a, O: Objective + ?Sized> Runner<'a, O> { if power <= NONE / 2.0 || skill <= NONE / 2.0 { return f64::NEG_INFINITY; } @@ -2902,7 +3154,7 @@ index b2ca65f..85c3bc5 100644 let order_limit = self.assignment_necessary_limit(a); let objective_bound = self.input.objective.assignment_order_bound( &a.team, -@@ -2391,6 +2476,7 @@ impl<'a, O: Objective + ?Sized> Runner<'a, O> { +@@ -2408,6 +2493,7 @@ impl<'a, O: Objective + ?Sized> Runner<'a, O> { ); if objective_bound.is_some_and(|bound| { bound < self.assignment_limit(a) || self.cannot_improve_set(a, bound) @@ -2910,7 +3162,7 @@ index b2ca65f..85c3bc5 100644 }) { return f64::NEG_INFINITY; } -@@ -2521,8 +2607,10 @@ impl<'a, O: Objective + ?Sized> Runner<'a, O> { +@@ -2538,8 +2624,10 @@ impl<'a, O: Objective + ?Sized> Runner<'a, O> { if high < self.assignment_limit(a) || self.cannot_improve_set(a, high) { return Ok(()); } @@ -2923,7 +3175,7 @@ index b2ca65f..85c3bc5 100644 self.stats.candidates += 1; if self.candidates.len() > self.compact_at { self.compact(); -@@ -2937,7 +3025,9 @@ impl<'a, O: Objective + ?Sized> Runner<'a, O> { +@@ -2954,7 +3042,9 @@ impl<'a, O: Objective + ?Sized> Runner<'a, O> { .max_bonus .is_none_or(|b| totals.bonus <= b) { @@ -2933,7 +3185,7 @@ index b2ca65f..85c3bc5 100644 } } } -@@ -3247,6 +3337,7 @@ impl<'a, O: Objective + ?Sized> Runner<'a, O> { +@@ -3224,6 +3314,7 @@ impl<'a, O: Objective + ?Sized> Runner<'a, O> { slot, ); if optimistic < self.assignment_limit(a) @@ -2941,7 +3193,7 @@ index b2ca65f..85c3bc5 100644 || self.cannot_improve_set(a, optimistic) || (slot > 0 && slot < 5 -@@ -3603,6 +3694,8 @@ impl<'a, O: Objective + ?Sized> Runner<'a, O> { +@@ -3580,6 +3671,8 @@ impl<'a, O: Objective + ?Sized> Runner<'a, O> { } if self.expired() { self.timed_out = true; @@ -2950,7 +3202,7 @@ index b2ca65f..85c3bc5 100644 } self.stats.leaders += 1; if self.any_snap_bonus { -@@ -4055,7 +4148,9 @@ pub(crate) fn search_at( +@@ -4033,7 +4126,9 @@ pub(crate) fn search_at( stats: Stats::default(), }); } diff --git a/src/components/deck/DeckResult.tsx b/src/components/deck/DeckResult.tsx index ef58de5e..0ca32540 100644 --- a/src/components/deck/DeckResult.tsx +++ b/src/components/deck/DeckResult.tsx @@ -117,7 +117,7 @@ export default function DeckResult(props: DeckResultProps) { : optimality?.proven && rateProven ?
{elapsed !== null && {tr("elapsed", { n: (elapsed / 1000).toFixed(1) })}}
:
- + {elapsed !== null && {tr("elapsed", { n: (elapsed / 1000).toFixed(1) })}}
} {!rating && optimality && !optimality.proven && optimality.upperBound !== null && optimality.lowerBound !== null &&

{tr("bounds", { best: formatValue(optimality.lowerBound / unit, metric, locale), limit: formatValue(optimality.upperBound / unit, metric, locale) })} @@ -356,6 +356,7 @@ function SkillOrders(props: DeckResultProps & { team: DeckTeam; aggregation: Dec const tr = (key: string, values?: Record) => t(locale, `deckWorkspace.solver.${key}`, values); const hasTimingExtrema = team.timing !== undefined && ( team.timing.best > team.timing.score + || team.timing.guideAvailable === false || (team.timing.optimal !== undefined && team.timing.worst !== undefined && team.timing.optimal > team.timing.worst) ); const activeTiming = props.final && hasTimingExtrema ? team.timing! : null; @@ -391,8 +392,9 @@ function OrderTimingPanel({ locale, timing, catalog }: { locale: AppLocale; timi const tr = (key: string, values?: Record) => t(locale, `deckWorkspace.solver.${key}`, values); const windows = timing.windows.filter(win => win.steps.length > 0); const hasFullExtrema = timing.worst !== undefined && timing.optimal !== undefined; - const planDelta = hasFullExtrema ? timing.best - timing.worst! : timing.best - timing.score; - const canGuide = timing.best > timing.score && windows.length > 0; + const planDelta = timing.best - timing.score; + const guideBlocked = timing.guideAvailable === false; + const canGuide = !guideBlocked && timing.best > timing.score && windows.length > 0; const memberName = (id: number) => catalog.members.find(card => card.id === id)?.title ?? String(id); return

{hasFullExtrema ? ( @@ -410,7 +412,7 @@ function OrderTimingPanel({ locale, timing, catalog }: { locale: AppLocale; timi
{formatValue(timing.optimal!, "score", locale)}
-
{tr("timingFramePlanScore")}
+
{tr("timingFramePlanScore", { ms: timing.advanceMs })}
{formatValue(timing.best, "score", locale)} {planDelta !== 0 && ( @@ -424,7 +426,9 @@ function OrderTimingPanel({ locale, timing, catalog }: { locale: AppLocale; timi
{tr("timingBest")} {formatValue(timing.best, "score", locale)} - +{formatValue(planDelta, "score", locale)} + {planDelta !== 0 && ( + {planDelta > 0 ? "+" : ""}{formatValue(planDelta, "score", locale)} + )}
@@ -432,6 +436,9 @@ function OrderTimingPanel({ locale, timing, catalog }: { locale: AppLocale; timi
)} + {guideBlocked && ( +

{tr("timingGuideUnavailable")}

+ )} {canGuide && (
diff --git a/src/components/deck/DeckTeamGrowthPanel.tsx b/src/components/deck/DeckTeamGrowthPanel.tsx index 330cafec..b1a912df 100644 --- a/src/components/deck/DeckTeamGrowthPanel.tsx +++ b/src/components/deck/DeckTeamGrowthPanel.tsx @@ -28,6 +28,29 @@ function formatDelta(locale: AppLocale, value: number, fractionDigits = 1): stri }).format(value); } +type GrowthCategoryFilter = "all" | "level" | "awake" | "rank" | "skill"; + +function scenarioCategory(action: DeckGrowthScenario["action"]): Exclude | "full" { + switch (action) { + case "level": + case "levelCap": + return "level"; + case "awake": + case "awakeCap": + return "awake"; + case "rank": + case "rankCap": + return "rank"; + case "liveSkillLevel": + case "liveSkillCap": + case "gekisouSkillLevel": + case "gekisouSkillCap": + return "skill"; + case "full": + return "full"; + } +} + function actionLabel( tr: (key: string, values?: Record) => string, scenario: DeckGrowthScenario, @@ -40,12 +63,20 @@ function actionLabel( return tr("actionLevelCap", values); case "awake": return tr("actionAwake", values); + case "awakeCap": + return tr("actionAwakeCap", values); case "rank": return tr("actionRank", values); + case "rankCap": + return tr("actionRankCap", values); case "liveSkillLevel": return tr("actionLiveSkill", values); + case "liveSkillCap": + return tr("actionLiveSkillCap", values); case "gekisouSkillLevel": return tr("actionGekisouSkill", values); + case "gekisouSkillCap": + return tr("actionGekisouSkillCap", values); case "full": return tr("actionFull"); } @@ -63,7 +94,8 @@ export default function DeckTeamGrowthPanel({ }: DeckTeamGrowthPanelProps) { const tr = (key: string, values?: Record) => t(locale, `deckWorkspace.growth.${key}`, values); - const [scope, setScope] = useState<"step" | "all">("step"); + const [scope, setScope] = useState<"step" | "cap" | "all">("step"); + const [category, setCategory] = useState("all"); const counts = useMemo(() => { if (!report) return null; @@ -81,10 +113,16 @@ export default function DeckTeamGrowthPanel({ const visibleScenarios = useMemo(() => { if (!report) return []; return report.scenarios.filter((scenario) => { - if (scope === "step" && scenario.action === "full") return false; + const resolvedTarget = scenario.target ?? (scenario.action === "full" ? "full" : scenario.action.endsWith("Cap") ? "cap" : "step"); + if (scope === "step" && resolvedTarget !== "step" && scenario.action !== "levelCap") return false; + if (scope === "cap" && resolvedTarget !== "cap") return false; + if (category !== "all") { + const cat = scenarioCategory(scenario.action); + if (cat !== category) return false; + } return scenario.primaryGain > 1e-6 || scenario.scoreGain > 1e-6 || scenario.powerGain > 0; }); - }, [report, scope]); + }, [report, scope, category]); return (
@@ -108,6 +146,7 @@ export default function DeckTeamGrowthPanel({ onChange={setScope} options={[ { value: "step", label: tr("scopeStep") }, + { value: "cap", label: tr("scopeCap") }, { value: "all", label: tr("scopeAll") }, ]} /> @@ -124,6 +163,22 @@ export default function DeckTeamGrowthPanel({ + {report?.status === "ok" && report.scenarios.length > 0 && ( +
+ {(["all", "level", "awake", "rank", "skill"] as const).map((cat) => ( + setCategory(cat)} + /> + ))} +
+ )} + {report && report.status !== "ok" && (

{report.message === "growth-unsupported-runtime" ? tr("unavailable") : (report.message ?? tr("unavailable"))} diff --git a/src/components/deck/DeckWorkspace.tsx b/src/components/deck/DeckWorkspace.tsx index c346ef91..2b04f3e3 100644 --- a/src/components/deck/DeckWorkspace.tsx +++ b/src/components/deck/DeckWorkspace.tsx @@ -21,8 +21,10 @@ import { EMPTY_CARD_FILTERS, memberCardSubject, supportCardSubject, type CardFil import { mergeBoxBackup, parseBox, type BoxCard, type BoxSaveLink, type CardBox, type CardFieldName, type CardKind } from "@/lib/box/model"; import { answerDeckFields } from "@/lib/box/deck-answers"; import { boxAccountJson, gameSaveAccountJson } from "@/lib/deck/account-envelope"; -import { CHALLENGE_POINT_COSTS, DEFAULT_DECK_GOAL, EVENT_GOALS, EVERYDAY_GOALS, MAX_BOOST, computes, computesGoal, defaultDeckGoalInput, goalGap, heldEvent, - playsGekisou, readsAccuracy, effectiveAggregation, usesEventData, usesEventItemRewards, rewardLayoutOf, isChallengeInput, isNetworkInput, challengeRateRequest, recommendationRequest, solverGoalKind, supportsCrossSongMean, goalVenues, isEventPayoffGoal, type ChallengePointCost, type DeckEvent, type DeckGoal, type DeckGoalInput, type DeckVenue, type DeckArenaMusic } from "@/lib/deck/goals"; +import { CHALLENGE_POINT_COSTS, DECK_FRAME_RATES, DEFAULT_DECK_GOAL, EVENT_GOALS, EVERYDAY_GOALS, MAX_BOOST, MAX_GEKISO_INPUT_COUNT, computes, computesGoal, defaultDeckGoalInput, effectiveFrameRate, effectiveMinJustWindowMs, goalGap, heldEvent, + playsGekisou, readsAccuracy, effectiveAggregation, supportsFrameRate, usesEventData, usesEventItemRewards, rewardLayoutOf, isChallengeInput, isNetworkInput, challengeRateRequest, recommendationRequest, solverGoalKind, supportsCrossSongMean, goalVenues, isEventPayoffGoal, type ChallengePointCost, type DeckEvent, type DeckFrameRate, type DeckGoal, type DeckGoalInput, type DeckVenue, type DeckArenaMusic } from "@/lib/deck/goals"; +import { reconcileCardSelections } from "@/lib/deck/card-selection"; +import { checkGekisoConstraintFeasibility } from "@/lib/deck/feasibility"; import { arenaPeriod, arenaSongMatchesBand, resolveArenaRule, type DeckArenaCatalog, type DeckArenaMode, type DeckArenaRuleSource } from "@/lib/deck/arena"; import DeckArenaControls, { type DeckArenaSongEntry } from "./DeckArenaControls"; import DeckManualEventDialog, { DeckEventSourceControls, type DeckEventSourceMode, type NamedEntityOption } from "./DeckManualEventDialog"; @@ -253,6 +255,31 @@ function DeckWorkspace(props: DeckWorkspaceProps) { const [required, setRequired] = useState([]); const [leaderKey, setLeaderKey] = useState(null); const [noSnaps, setNoSnaps] = useState(false); + const [minMissionMembers, setMinMissionMembers] = useState<[number, number, number]>([0, 0, 0]); + const prevActiveBoxCardsRef = useRef([]); + useEffect(() => { + const prevCards = prevActiveBoxCardsRef.current; + const nextCards = activeBox?.cards ?? []; + prevActiveBoxCardsRef.current = nextCards; + if (!prevCards.length) return; + if (!required.length && !excluded.length && !leaderKey) return; + const reconciled = reconcileCardSelections(prevCards, activeBox, { + required: new Set(required), + excluded: new Set(excluded), + leaderKey, + }); + const nextReq = [...reconciled.required]; + const nextExc = [...reconciled.excluded]; + if (nextReq.length !== required.length || nextReq.some((k, i) => k !== required[i])) { + setRequired(nextReq); + } + if (nextExc.length !== excluded.length || nextExc.some((k, i) => k !== excluded[i])) { + setExcluded(nextExc); + } + if (reconciled.leaderKey !== leaderKey) { + setLeaderKey(reconciled.leaderKey); + } + }, [activeBox, required, excluded, leaderKey]); const [userArenaSource, setUserArenaSource] = useState(null); const [arenaLeagueId, setArenaLeagueId] = useState(1); const [arenaCustomBandId, setArenaCustomBandId] = useState(1); @@ -379,6 +406,7 @@ function DeckWorkspace(props: DeckWorkspaceProps) { return [card.identity.value]; }); const resolvedLeaderId = leaderKey ? idsOf([leaderKey], "member", useModernArena)[0] ?? null : null; + const isGekisouInput = playsGekisou(effectiveInput); const constraints = { leader: resolvedLeaderId, includeMembers: idsOf(required, "member", useModernArena), @@ -386,7 +414,38 @@ function DeckWorkspace(props: DeckWorkspaceProps) { excludeMembers: idsOf(excluded, "member"), excludeSnaps: idsOf(excluded, "snap"), noSnaps, + ...(isGekisouInput && minMissionMembers.some(v => v > 0) ? { minMissionMembers } : {}), }; + const feasibilityMemberCatalog = useMemo( + () => new Map(members.map(card => [ + card.id, + { + id: card.id, + characterId: card.characterId, + ...(typeof card.gekisouMissionType === "number" ? { gekisouMissionType: card.gekisouMissionType } : {}), + }, + ])), + [members], + ); + const feasibilityIssue = useMemo(() => { + if (!isGekisouInput) return null; + const minCombo = effectiveInput.minComboBonus; + const minJust = effectiveMinJustWindowMs(effectiveInput); + const manualLuck = effectiveInput.luckSamplingMode === "manual" ? effectiveInput.luckSamples : undefined; + return checkGekisoConstraintFeasibility({ + box: activeBox, + memberCatalog: feasibilityMemberCatalog, + constraints: { + includeMembers: constraints.includeMembers, + excludeMembers: constraints.excludeMembers, + leader: constraints.leader, + minMissionMembers, + }, + ...(minCombo !== undefined ? { minComboBonus: minCombo } : {}), + ...(manualLuck !== undefined ? { luckSamples: manualLuck } : {}), + ...(minJust > 0 ? { minJustWindowMs: minJust } : {}), + }); + }, [isGekisouInput, effectiveInput, activeBox, feasibilityMemberCatalog, constraints.includeMembers, constraints.excludeMembers, constraints.leader, minMissionMembers]); /** The kind the restriction list shows: one at a time, since members and Snaps rate on different rarity scales. */ const shownConstraintKind: CardKind = ownedMembers.length && ownedSnaps.length ? constraintKind : ownedMembers.length ? "member" : "snap"; const constraintEntries = useMemo(() => { @@ -492,7 +551,7 @@ function DeckWorkspace(props: DeckWorkspaceProps) { window.addEventListener("popstate", back); return () => window.removeEventListener("popstate", back); }, [locale]); - useEffect(() => { setExcluded([]); setRequired([]); setLeaderKey(null); setNoSnaps(false); setUserArenaSource(null); setArenaLeagueId(1); setArenaCustomBandId(1); setArenaCustomMusicType(0); setArenaCustomMissions([0, 0, 0]); setArenaPool([]); setArenaPickerOpen(false); + useEffect(() => { setExcluded([]); setRequired([]); setLeaderKey(null); setNoSnaps(false); setMinMissionMembers([0, 0, 0]); setUserArenaSource(null); setArenaLeagueId(1); setArenaCustomBandId(1); setArenaCustomMusicType(0); setArenaCustomMissions([0, 0, 0]); setArenaPool([]); setArenaPickerOpen(false); setEventSourceMode("auto"); setSelectedEventId(null); setCustomEventDraft(null); setCustomEventModalOpen(false); updateGoal({ musicId: null, challengeMusicId: null, arenaMusicId: null }); setRunError(""); setManagement(false); setSetup(false); setDeleting(false); setImporting(false); setConstraintsOpen(false); setEditingCardKey(null); setPlayerSettings(false); setGuideOpen(false); setBackupError(false); setSongPicker(false); setConstraintFilter("all"); @@ -677,11 +736,21 @@ function DeckWorkspace(props: DeckWorkspaceProps) { }; const engine = solver.engine; const saveReady = theoreticalMode || !linked || linkedSave.state.status === "ready"; - const canRun = engine.status === "ready" && supported && !gap && !dataGap && saveReady; + const feasibilityMessage = feasibilityIssue + ? feasibilityIssue.code === "mission_members_unreachable" + ? tr("feasibilityMissionShortage", { required: feasibilityIssue.required, available: feasibilityIssue.available }) + : feasibilityIssue.code === "invalid_mission_member_sum" + ? tr("feasibilityMissionSum") + : feasibilityIssue.code === "invalid_gekiso_count" + ? tr("feasibilityInvalidCount") + : "" + : ""; + const canRun = engine.status === "ready" && supported && !gap && !dataGap && saveReady && !feasibilityMessage; const blocker = engine.status === "loading" ? tr("solver.loading") : engine.status === "unavailable" ? tr(`solver.unavailable.${engine.reason}`) - : engine.status === "failed" ? "" : !supported ? tr(rewardLayoutOf(effectiveInput) ? effectiveInput.aggregation === "expected" ? "rewardMix.unavailable" : "rewardMix.expectedOnly" : goal === "eventItems" ? "solver.eventItemsUnsupported" : "comingSoon") : gap ? tr(`solver.gap.${gap}`) : !saveReady ? tr("solver.saveNotReady") : ""; + : engine.status === "failed" ? "" : feasibilityMessage ? feasibilityMessage : !supported ? tr(rewardLayoutOf(effectiveInput) ? effectiveInput.aggregation === "expected" ? "rewardMix.unavailable" : "rewardMix.expectedOnly" : goal === "eventItems" ? "solver.eventItemsUnsupported" : "comingSoon") : gap ? tr(`solver.gap.${gap}`) : !saveReady ? tr("solver.saveNotReady") : ""; const runBlocker = ownedMembers.length ? "" : tr(linked && !saveReady ? "solver.saveNotReady" : "solver.emptyBox"); - const activeConstraintCount = required.length + excluded.length + (leaderKey ? 1 : 0) + (noSnaps ? 1 : 0); + const missionConstraintCount = isGekisouInput ? minMissionMembers.filter(v => v > 0).length : 0; + const activeConstraintCount = required.length + excluded.length + (leaderKey ? 1 : 0) + (noSnaps ? 1 : 0) + missionConstraintCount; const requiredConstraintCount = new Set([...required, ...(leaderKey ? [leaderKey] : [])]).size; function engineNotice() { if (engine.status === "failed") { @@ -805,10 +874,18 @@ function DeckWorkspace(props: DeckWorkspaceProps) { description={tr("theoretical.hint")} checked={theoreticalMode} onChange={(checked) => { + const currentCards = activeBox?.cards ?? []; + const nextTargetBox = checked ? buildTheoreticalMaxBox(box, server, catalog, saveTables) : box; + const reconciled = reconcileCardSelections(currentCards, nextTargetBox, { + required: new Set(required), + excluded: new Set(excluded), + leaderKey, + }); + prevActiveBoxCardsRef.current = nextTargetBox?.cards ?? []; setTheoreticalMode(checked); - setLeaderKey(null); - setRequired([]); - setExcluded([]); + setLeaderKey(reconciled.leaderKey); + setRequired([...reconciled.required]); + setExcluded([...reconciled.excluded]); }} /> @@ -1012,15 +1089,55 @@ function DeckWorkspace(props: DeckWorkspaceProps) { )} {isNetworkInput(effectiveInput) && (!capabilities || capabilities.gekisouRank ? ( - updateGoal({ gekisouRank })} - options={[1, 2, 3, 4, 5].map((place) => ({ - value: place, - label: tr("gekisouRankOption", { n: place }), - }))} - /> +

+ {goalInput.gekisouRanks === undefined ? ( + updateGoal({ gekisouRank })} + options={[1, 2, 3, 4, 5].map((place) => ({ + value: place, + label: tr("gekisouRankOption", { n: place }), + }))} + /> + ) : ( +
+ {([0, 1, 2] as const).map((idx) => ( + { + const base = goalInput.gekisouRanks ?? [1, 1, 1]; + const next: [number, number, number] = [base[0], base[1], base[2]]; + next[idx] = place; + updateGoal({ gekisouRanks: next }); + }} + options={[1, 2, 3, 4, 5].map((place) => ({ + value: place, + label: tr("gekisouRankOption", { n: place }), + }))} + /> + ))} +
+ )} + {(!capabilities || capabilities.gekisouRangeRanks) && ( + { + if (perRange) { + const fallback = goalInput.gekisouRank ?? 1; + updateGoal({ gekisouRanks: [fallback, fallback, fallback] }); + } else { + const first = goalInput.gekisouRanks?.[0] ?? goalInput.gekisouRank ?? 1; + const { gekisouRanks: _unused, ...rest } = goalInput; + setGoalInput({ ...rest, gekisouRank: first }); + } + }} + /> + )} +
) :

{tr("rankFixed")}

)} {readsAccuracy(effectiveInput) && (
@@ -1032,6 +1149,18 @@ function DeckWorkspace(props: DeckWorkspaceProps) {
+ {supportsFrameRate(effectiveInput) && (!capabilities || (capabilities.frameRates?.length ?? 0) > 0) && ( + updateGoal({ frameRate: Number(raw) as DeckFrameRate })} + options={DECK_FRAME_RATES.map((hz) => ({ + value: String(hz), + label: tr("frameRateOption", { hz }), + }))} + /> + )}
{tr("greatRate", { n: effectiveInput.greatPercent })} @@ -1040,12 +1169,43 @@ function DeckWorkspace(props: DeckWorkspaceProps) { updateGoal({ greatPercent: Array.isArray(next) ? next[0] ?? 0 : next })} />
+ {!playsGekisou(effectiveInput) && effectiveInput.playMode !== "pattern" && (!capabilities || capabilities.accuracy.good) && ( +
+ updateGoal({ goodPercent: val === null ? 0 : Math.min(100, Math.max(0, Math.trunc(val))) })} + /> + updateGoal({ badPercent: val === null ? 0 : Math.min(100, Math.max(0, Math.trunc(val))) })} + /> + updateGoal({ missPercent: val === null ? 0 : Math.min(100, Math.max(0, Math.trunc(val))) })} + /> +
+ )} {playsGekisou(effectiveInput) && (
@@ -1065,15 +1225,117 @@ function DeckWorkspace(props: DeckWorkspaceProps) { {capabilities && (!capabilities.accuracy.great || playsGekisou(effectiveInput) && !capabilities.accuracy.just) ?

{tr("accuracyComing")}

:

{tr(playsGekisou(effectiveInput) ? "accuracyNoteGekisou" : "accuracyNote")}

} - {isEventPayoffGoal(goal) && isNetworkInput(effectiveInput) && ( - updateGoal({ othersAverageScore })} - /> + {playsGekisou(effectiveInput) && ( +
+ {(!capabilities || capabilities.minComboBonus) && ( + updateGoal({ minComboBonus: val === null ? 1 : Math.trunc(val) })} + /> + )} + {(!capabilities || capabilities.minJustWindow) && ( +
+ updateGoal({ justWindowUnit })} + options={[ + { value: "percent" as const, label: tr("justWindowUnitPercent") }, + { value: "ms" as const, label: tr("justWindowUnitMs") }, + ]} + /> + {(goalInput.justWindowUnit ?? "percent") === "percent" ? ( + updateGoal({ justWindowPercent: val === null ? 0 : Math.max(0, val) })} + /> + ) : ( + updateGoal({ justWindowMs: val === null ? 0 : Math.max(0, val) })} + /> + )} +
+ )} + {(!capabilities || capabilities.luckSampling) && ( +
+ updateGoal({ luckSamplingMode })} + options={[ + { value: "auto" as const, label: tr("luckSamplingAuto") }, + { value: "manual" as const, label: tr("luckSamplingManual") }, + ]} + /> + {(goalInput.luckSamplingMode ?? "auto") === "manual" && ( + updateGoal({ luckSamples: val === null ? 0 : Math.trunc(val) })} + /> + )} +
+ )} +
+ )} + {isEventPayoffGoal(goal) && (isNetworkInput(effectiveInput) || solverGoalKind(effectiveInput) === "freeLive") && ( +
+ {(!capabilities || (capabilities.rewardRoomModes?.length ?? 0) > 0) && ( + updateGoal({ rewardRoomMode })} + options={[ + { value: "battle" as const, label: tr("rewardRoomBattle") }, + { value: "solo" as const, label: tr("rewardRoomSolo") }, + ]} + /> + )} + {(goalInput.rewardRoomMode ?? (isNetworkInput(effectiveInput) ? "battle" : "solo")) === "battle" && ( +
+ updateGoal({ rewardPlayers })} + options={[1, 2, 3, 4, 5].map((count) => ({ + value: count, + label: tr("rewardPlayersOption", { n: count }), + }))} + /> + updateGoal({ othersAverageScore })} + /> +
+ )} +
)}
@@ -1210,6 +1472,33 @@ function DeckWorkspace(props: DeckWorkspaceProps) { checked={noSnaps} onChange={setNoSnaps} /> + {isGekisouInput && (!capabilities || capabilities.minMissionMembers) && ( +
+ {tr("minMissionMembersTitle")} +
+ {(["minMissionCombo", "minMissionLuck", "minMissionJust"] as const).map((labelKey, idx) => { + const otherSum = minMissionMembers[0] + minMissionMembers[1] + minMissionMembers[2] - minMissionMembers[idx]!; + return ( + setMinMissionMembers((prev) => { + const next: [number, number, number] = [prev[0], prev[1], prev[2]]; + next[idx] = count; + return next; + })} + options={[0, 1, 2, 3, 4, 5].map((count) => ({ + value: count, + label: String(count), + disabled: count + otherSum > 5, + }))} + /> + ); + })} +
+
+ )}
{ownedMembers.length > 0 && ownedSnaps.length > 0 && ( {t(locale, "filters.reset")} diff --git a/src/i18n/messages/en-US.ts b/src/i18n/messages/en-US.ts index b6b9aabb..934ffa18 100644 --- a/src/i18n/messages/en-US.ts +++ b/src/i18n/messages/en-US.ts @@ -72,7 +72,13 @@ export const enUS = { recompute: "Recalculate", evaluating: "Evaluating single-card upgrades…", scopeStep: "Next step", + scopeCap: "Max item", scopeAll: "Include full max", + categoryAll: "All items", + categoryLevel: "Level", + categoryAwake: "Training", + categoryRank: "Limit break", + categorySkill: "Skill", emptyAllCapped: "Every card in this formation is already at maximum growth.", emptyNoGain: "No positive single-card upgrade gains found (some growth levels may be unrecorded).", summaryCounts: "{upgradable} upgradable · {capped} maxed · {unknown} unknown", @@ -81,9 +87,13 @@ export const enUS = { actionLevel: "Lv {from} → {to}", actionLevelCap: "Lv cap {from} → {to}", actionAwake: "Training {from} → {to}", + actionAwakeCap: "Max training {from} → {to}", actionRank: "Limit break {from} → {to}", + actionRankCap: "Max limit break {from} → {to}", actionLiveSkill: "Live skill {from} → {to}", + actionLiveSkillCap: "Max live skill {from} → {to}", actionGekisouSkill: "Gekisou skill {from} → {to}", + actionGekisouSkillCap: "Max gekisou skill {from} → {to}", actionFull: "Full max growth", gainPrimary: "+{delta}", gainScore: "Score +{delta}", @@ -672,6 +682,33 @@ export const enUS = { "rankFixed": "Every gekisou section counts as 1st place.", "gekisouRankLabel": "Gekisou Range placement", "gekisouRankOption": "#{n}", + "gekisouPerRangeToggle": "Set placement per range", + "gekisouRangeRankLabel": "Range {range} placement", + "frameRateLabel": "Frame rate (Hz)", + "frameRateOption": "{hz} Hz", + "goodRate": "Good rate", + "badRate": "Bad rate", + "missRate": "Miss rate", + "minComboBonusLabel": "Min opening COMBO bonus", + "justWindowLabel": "Min JUST tap window", + "justWindowUnitPercent": "+%", + "justWindowUnitMs": "±ms", + "luckSamplingLabel": "LUCK sampling", + "luckSamplingAuto": "Auto", + "luckSamplingManual": "Manual seeds", + "luckSamplesCountLabel": "Simulation seed count", + "rewardRoomModeLabel": "Reward grade mode", + "rewardRoomBattle": "Battle room", + "rewardRoomSolo": "Solo grade", + "rewardPlayersLabel": "Room players", + "rewardPlayersOption": "{n} players", + "minMissionMembersTitle": "Min Gekisou mission members (sum <= 5)", + "minMissionCombo": "Min COMBO", + "minMissionLuck": "Min LUCK", + "minMissionJust": "Min JUST", + "feasibilityMissionShortage": "Your card pool and fixed constraints cannot field {required} members of this Gekisou type (at most {available} distinct characters available).", + "feasibilityMissionSum": "The minimum COMBO, LUCK, and JUST member counts cannot exceed 5 in total.", + "feasibilityInvalidCount": "A Gekisou numeric condition is outside the valid integer range.", "arena": { "ruleSourceLabel": "Arena regulations", "ruleAuto": "Live schedule", @@ -737,8 +774,9 @@ export const enUS = { "worstTimingScore": "Worst-timing score", "nominalTimingScore": "Nominal-timing score", "bestTimingScore": "Best-timing score", - "timingFramePlanScore": "17 ms plan", + "timingFramePlanScore": "{ms} ms plan", "timingGuide": "Timing guide", + "timingGuideUnavailable": "Chart-time inputs already collide on native lanes, so no safe early-hit guide is available.", "timingNotes": "Notes to hit early", "timingAdvance": "About {ms} ms early", "timingStep": "Step {step}", @@ -769,6 +807,7 @@ export const enUS = { "unproven": "Best so far (not proven)", "stoppedUnproven": "Stopped · best so far (not proven)", "timedOutUnproven": "Time limit reached · best so far (not proven)", + "sampledUnproven": "Sampled evaluation · best so far (not proven)", "remaining": "{n} s left", "bounds": "Best so far {best}; the optimum is at most {limit}", "gapPercent": "gap {n}%", diff --git a/src/i18n/messages/ja-JP.ts b/src/i18n/messages/ja-JP.ts index 31e8ba84..0ba5b0a2 100644 --- a/src/i18n/messages/ja-JP.ts +++ b/src/i18n/messages/ja-JP.ts @@ -61,7 +61,13 @@ export const jaJP = { recompute: "再試算", evaluating: "カードごとの育成上昇量を計算中…", scopeStep: "次の1段階", + scopeCap: "項目最大", scopeAll: "最大育成を含む", + categoryAll: "全項目", + categoryLevel: "Lv", + categoryAwake: "特訓", + categoryRank: "覚醒", + categorySkill: "スキル", emptyAllCapped: "編成内の全カードがすでに最大育成に達しています。", emptyNoGain: "現在の条件で上昇する育成項目がありません(育成段階が未入力の可能性があります)。", summaryCounts: "強化可能 {upgradable} 枚 · 最大育成 {capped} 枚 · 未入力 {unknown} 枚", @@ -70,9 +76,13 @@ export const jaJP = { actionLevel: "Lv {from} → {to}", actionLevelCap: "現上限 Lv {from} → {to}", actionAwake: "特訓 {from} → {to}", + actionAwakeCap: "特訓最大 {from} → {to}", actionRank: "覚醒 {from} → {to}", + actionRankCap: "覚醒最大 {from} → {to}", actionLiveSkill: "ライブスキル {from} → {to}", + actionLiveSkillCap: "ライブスキル最大 {from} → {to}", actionGekisouSkill: "激奏スキル {from} → {to}", + actionGekisouSkillCap: "激奏スキル最大 {from} → {to}", actionFull: "最大育成まで強化", gainPrimary: "+{delta}", gainScore: "スコア +{delta}", @@ -661,6 +671,33 @@ export const jaJP = { "rankFixed": "激走区間はすべて1位として計算します。", "gekisouRankLabel": "激走レンジ順位", "gekisouRankOption": "{n}位", + "gekisouPerRangeToggle": "レンジ別に順位を指定", + "gekisouRangeRankLabel": "第{range}レンジ順位", + "frameRateLabel": "フレームレート (Hz)", + "frameRateOption": "{hz} Hz", + "goodRate": "Good 率", + "badRate": "Bad 率", + "missRate": "Miss 率", + "minComboBonusLabel": "開始時最低 COMBO ボーナス", + "justWindowLabel": "最低 JUST 判定幅", + "justWindowUnitPercent": "+%", + "justWindowUnitMs": "±ms", + "luckSamplingLabel": "LUCK 抽選方式", + "luckSamplingAuto": "自動期待値", + "luckSamplingManual": "シード数指定", + "luckSamplesCountLabel": "シミュレーション回数", + "rewardRoomModeLabel": "報酬ランク判定", + "rewardRoomBattle": "マルチルーム判定", + "rewardRoomSolo": "ソロ判定", + "rewardPlayersLabel": "ルーム人数", + "rewardPlayersOption": "{n}人", + "minMissionMembersTitle": "激奏スキル種別の最低人数 (合計5以下)", + "minMissionCombo": "COMBO 最低", + "minMissionLuck": "LUCK 最低", + "minMissionJust": "JUST 最低", + "feasibilityMissionShortage": "現在のカードプールと固定条件では、この種類の激奏メンバーを {required} 人以上編成できません(重複なし最大 {available} 人)。", + "feasibilityMissionSum": "3種類の激奏スキル最低人数の合計は5人以下にしてください。", + "feasibilityInvalidCount": "激奏の数値条件が有効な整数範囲を超えています。", "arena": { "ruleSourceLabel": "アリーナレギュレーション", "ruleAuto": "開催スケジュール", @@ -726,8 +763,9 @@ export const jaJP = { "worstTimingScore": "最悪タイミングのスコア", "nominalTimingScore": "基準タイミングのスコア", "bestTimingScore": "最良タイミングのスコア", - "timingFramePlanScore": "17 ms 前倒し", + "timingFramePlanScore": "{ms} ms 前倒し", "timingGuide": "タイミング手順", + "timingGuideUnavailable": "譜面通りの入力時点でレーン干渉があるため、安全な前倒し手順を作成できません。", "timingNotes": "早めに叩くノーツ", "timingAdvance": "約 {ms} ms 早め", "timingStep": "{step} 段目", @@ -758,6 +796,7 @@ export const jaJP = { "unproven": "現時点の最良(最適未証明)", "stoppedUnproven": "停止 · 現時点の最良(最適未証明)", "timedOutUnproven": "時間上限 · 現時点の最良(最適未証明)", + "sampledUnproven": "乱数サンプリング評価 · 現時点の最良(最適未証明)", "remaining": "残り {n} 秒", "bounds": "現在の最良 {best}・最適値は {limit} 以下", "gapPercent": "差 {n}%", diff --git a/src/i18n/messages/ko-KR.ts b/src/i18n/messages/ko-KR.ts index 4389d1fe..7e4fab91 100644 --- a/src/i18n/messages/ko-KR.ts +++ b/src/i18n/messages/ko-KR.ts @@ -62,7 +62,13 @@ export const koKR = { recompute: "다시 계산", evaluating: "카드별 육성 상승량을 계산 중…", scopeStep: "다음 1단계", + scopeCap: "항목 최대", scopeAll: "풀육성 포함", + categoryAll: "전체 항목", + categoryLevel: "레벨", + categoryAwake: "특훈", + categoryRank: "각성", + categorySkill: "스킬", emptyAllCapped: "현재 편성의 모든 카드가 이미 최대 육성에 도달했습니다.", emptyNoGain: "현재 조건에서 상승하는 육성 항목이 없습니다 (육성 단계 미입력 가능성).", summaryCounts: "성장 가능 {upgradable}장 · 최대 육성 {capped}장 · 미입력 {unknown}장", @@ -71,9 +77,13 @@ export const koKR = { actionLevel: "레벨 {from} → {to}", actionLevelCap: "현재 상한 {from} → {to}", actionAwake: "특훈 {from} → {to}", + actionAwakeCap: "특훈 최대 {from} → {to}", actionRank: "각성 {from} → {to}", + actionRankCap: "각성 최대 {from} → {to}", actionLiveSkill: "라이브 스킬 {from} → {to}", + actionLiveSkillCap: "라이브 스킬 최대 {from} → {to}", actionGekisouSkill: "격주 스킬 {from} → {to}", + actionGekisouSkillCap: "격주 스킬 최대 {from} → {to}", actionFull: "단일 카드 풀육성", gainPrimary: "+{delta}", gainScore: "점수 +{delta}", @@ -662,6 +672,33 @@ export const koKR = { "rankFixed": "모든 격주 구간을 1위로 계산합니다.", "gekisouRankLabel": "격주 레인지 순위", "gekisouRankOption": "{n}위", + "gekisouPerRangeToggle": "구간별 순위 개별 지정", + "gekisouRangeRankLabel": "{range}구간 순위", + "frameRateLabel": "프레임 레이트 (Hz)", + "frameRateOption": "{hz} Hz", + "goodRate": "Good 비율", + "badRate": "Bad 비율", + "missRate": "Miss 비율", + "minComboBonusLabel": "최소 개막 COMBO 보너스", + "justWindowLabel": "최소 JUST 판정 폭", + "justWindowUnitPercent": "+%", + "justWindowUnitMs": "±ms", + "luckSamplingLabel": "LUCK 추첨 방식", + "luckSamplingAuto": "자동 기대값", + "luckSamplingManual": "시드 수 지정", + "luckSamplesCountLabel": "시뮬레이션 시드 수", + "rewardRoomModeLabel": "보상 랭크 판정", + "rewardRoomBattle": "멀티 룸 판정", + "rewardRoomSolo": "솔로 판정", + "rewardPlayersLabel": "방 인원수", + "rewardPlayersOption": "{n}명", + "minMissionMembersTitle": "격주 스킬 타입 최소 인원 (합계 5 이하)", + "minMissionCombo": "COMBO 최소", + "minMissionLuck": "LUCK 최소", + "minMissionJust": "JUST 최소", + "feasibilityMissionShortage": "현재 카드 풀과 고정 조건으로는 해당 타입의 격주 멤버를 {required}명 이상 편성할 수 없습니다 (중복 제외 최대 {available}명).", + "feasibilityMissionSum": "세 종류 격주 스킬 최소 인원 합계는 5명을 넘을 수 없습니다.", + "feasibilityInvalidCount": "격주 수치 조건이 유효한 정수 범위를 벗어났습니다.", "arena": { "ruleSourceLabel": "아레나 레귤레이션", "ruleAuto": "개최 스케줄", @@ -727,8 +764,9 @@ export const koKR = { "worstTimingScore": "최악 타이밍 점수", "nominalTimingScore": "기준 타이밍 점수", "bestTimingScore": "최선 타이밍 점수", - "timingFramePlanScore": "17 ms 앞당기기", + "timingFramePlanScore": "{ms} ms 앞당기기", "timingGuide": "타이밍 가이드", + "timingGuideUnavailable": "채보 정시 입력에 이미 레인 간섭이 있어 안전한 조기 입력 가이드를 제공할 수 없습니다.", "timingNotes": "미리 쳐야 할 노트", "timingAdvance": "약 {ms} ms 빠르게", "timingStep": "{step}단계", @@ -759,6 +797,7 @@ export const koKR = { "unproven": "현재 최선 (최적 미증명)", "stoppedUnproven": "중지됨 · 현재 최선 (최적 미증명)", "timedOutUnproven": "시간 제한 도달 · 현재 최선 (최적 미증명)", + "sampledUnproven": "무작위 샘플링 평가 · 현재 최선 (최적 미증명)", "remaining": "{n}초 남음", "bounds": "현재 최선 {best} · 최적값은 {limit} 이하", "gapPercent": "차이 {n}%", diff --git a/src/i18n/messages/zh-CN.ts b/src/i18n/messages/zh-CN.ts index d6f6647e..5a1b1fcd 100644 --- a/src/i18n/messages/zh-CN.ts +++ b/src/i18n/messages/zh-CN.ts @@ -64,7 +64,13 @@ export const zhCN = { recompute: "重新计算", evaluating: "正在逐卡测算收益…", scopeStep: "下一步提升", + scopeCap: "单项满级", scopeAll: "含单卡毕业", + categoryAll: "全部属性", + categoryLevel: "等级", + categoryAwake: "特训", + categoryRank: "觉醒", + categorySkill: "技能", emptyAllCapped: "当前编队全员已达满练上限,无需继续养成。", emptyNoGain: "在当前条件下未测算出正收益养成项(可能缺少单卡当前练度记录)。", summaryCounts: "可提升 {upgradable} 张 · 已满练 {capped} 张 · 待补练度 {unknown} 张", @@ -73,9 +79,13 @@ export const zhCN = { actionLevel: "等级 {from} → {to}", actionLevelCap: "当前满级 {from} → {to}", actionAwake: "特训 {from} → {to}", + actionAwakeCap: "特训拉满 {from} → {to}", actionRank: "觉醒 {from} → {to}", + actionRankCap: "觉醒拉满 {from} → {to}", actionLiveSkill: "演出技能 {from} → {to}", + actionLiveSkillCap: "演出技能拉满 {from} → {to}", actionGekisouSkill: "激奏技能 {from} → {to}", + actionGekisouSkillCap: "激奏技能拉满 {from} → {to}", actionFull: "单卡拉满毕业", gainPrimary: "+{delta}", gainScore: "得分 +{delta}", @@ -664,6 +674,33 @@ export const zhCN = { "rankFixed": "每个激走区间按第 1 名计算。", "gekisouRankLabel": "激走区间名次", "gekisouRankOption": "第 {n} 名", + "gekisouPerRangeToggle": "按区间分别设置名次", + "gekisouRangeRankLabel": "第 {range} 区间名次", + "frameRateLabel": "帧率 (Hz)", + "frameRateOption": "{hz} Hz", + "goodRate": "Good 率", + "badRate": "Bad 率", + "missRate": "Miss 率", + "minComboBonusLabel": "最低开段 COMBO 加成", + "justWindowLabel": "最低 JUST 判定窗口", + "justWindowUnitPercent": "+%", + "justWindowUnitMs": "±ms", + "luckSamplingLabel": "LUCK 抽选模式", + "luckSamplingAuto": "自动期望", + "luckSamplingManual": "指定种子数", + "luckSamplesCountLabel": "模拟种子数量", + "rewardRoomModeLabel": "结算评级方式", + "rewardRoomBattle": "协力房间评级", + "rewardRoomSolo": "单人评级", + "rewardPlayersLabel": "房间人数", + "rewardPlayersOption": "{n} 人", + "minMissionMembersTitle": "激奏技能类型最低人数 (合计 ≤ 5)", + "minMissionCombo": "COMBO 最低", + "minMissionLuck": "LUCK 最低", + "minMissionJust": "JUST 最低", + "feasibilityMissionShortage": "当前卡池与固定条件无法凑齐至少 {required} 名该类型激奏成员(最多可用 {available} 名不重复角色)。", + "feasibilityMissionSum": "三种激奏技能最低人数之和不能超过 5 人。", + "feasibilityInvalidCount": "激奏数值条件超出有效整数范围。", "arena": { "ruleSourceLabel": "竞技场规则来源", "ruleAuto": "当期日程", @@ -729,8 +766,9 @@ export const zhCN = { "worstTimingScore": "最差时序分数", "nominalTimingScore": "名义时序分数", "bestTimingScore": "最佳时序分数", - "timingFramePlanScore": "提前 17 ms 方案", + "timingFramePlanScore": "提前 {ms} ms 方案", "timingGuide": "凹分教程", + "timingGuideUnavailable": "原谱面时刻已存在同轨输入冲突,无法提供安全提前击打方案。", "timingNotes": "提前击打的音符", "timingAdvance": "提前约 {ms} ms", "timingStep": "第 {step} 档", @@ -761,6 +799,7 @@ export const zhCN = { "unproven": "当前最优(未证明)", "stoppedUnproven": "已停止 · 当前最优(未证明)", "timedOutUnproven": "已到时间上限 · 当前最优(未证明)", + "sampledUnproven": "随机抽样评估 · 当前最优(未证明)", "remaining": "剩余 {n} 秒", "bounds": "当前最好 {best},最优不超过 {limit}", "gapPercent": "差距 {n}%", diff --git a/src/i18n/messages/zh-TW.ts b/src/i18n/messages/zh-TW.ts index e07b0f04..97fb0898 100644 --- a/src/i18n/messages/zh-TW.ts +++ b/src/i18n/messages/zh-TW.ts @@ -63,7 +63,13 @@ export const zhTW = { recompute: "重新計算", evaluating: "正在逐卡測算收益…", scopeStep: "下一步提升", + scopeCap: "單項滿級", scopeAll: "含單卡畢業", + categoryAll: "全部屬性", + categoryLevel: "等級", + categoryAwake: "特訓", + categoryRank: "覺醒", + categorySkill: "技能", emptyAllCapped: "目前編隊全員已達滿練上限,無需繼續養成。", emptyNoGain: "在目前條件下未測算出正收益養成項(可能缺少單卡目前練度紀錄)。", summaryCounts: "可提升 {upgradable} 張 · 已滿練 {capped} 張 · 待補練度 {unknown} 張", @@ -72,9 +78,13 @@ export const zhTW = { actionLevel: "等級 {from} → {to}", actionLevelCap: "目前滿級 {from} → {to}", actionAwake: "特訓 {from} → {to}", + actionAwakeCap: "特訓拉滿 {from} → {to}", actionRank: "覺醒 {from} → {to}", + actionRankCap: "覺醒拉滿 {from} → {to}", actionLiveSkill: "演出技能 {from} → {to}", + actionLiveSkillCap: "演出技能拉滿 {from} → {to}", actionGekisouSkill: "激奏技能 {from} → {to}", + actionGekisouSkillCap: "激奏技能拉滿 {from} → {to}", actionFull: "單卡拉滿畢業", gainPrimary: "+{delta}", gainScore: "得分 +{delta}", @@ -663,6 +673,33 @@ export const zhTW = { "rankFixed": "每個激走區間按第 1 名計算。", "gekisouRankLabel": "激走區間名次", "gekisouRankOption": "第 {n} 名", + "gekisouPerRangeToggle": "按區間分別設定名次", + "gekisouRangeRankLabel": "第 {range} 區間名次", + "frameRateLabel": "幀率 (Hz)", + "frameRateOption": "{hz} Hz", + "goodRate": "Good 率", + "badRate": "Bad 率", + "missRate": "Miss 率", + "minComboBonusLabel": "最低開段 COMBO 加成", + "justWindowLabel": "最低 JUST 判定視窗", + "justWindowUnitPercent": "+%", + "justWindowUnitMs": "±ms", + "luckSamplingLabel": "LUCK 抽選模式", + "luckSamplingAuto": "自動期望", + "luckSamplingManual": "指定種子數", + "luckSamplesCountLabel": "模擬種子數量", + "rewardRoomModeLabel": "結算評級方式", + "rewardRoomBattle": "協力房間評級", + "rewardRoomSolo": "單人評級", + "rewardPlayersLabel": "房間人數", + "rewardPlayersOption": "{n} 人", + "minMissionMembersTitle": "激奏技能類型最低人數 (合計 ≤ 5)", + "minMissionCombo": "COMBO 最低", + "minMissionLuck": "LUCK 最低", + "minMissionJust": "JUST 最低", + "feasibilityMissionShortage": "目前卡池與固定條件無法湊齊至少 {required} 名該類型激奏成員(最多可用 {available} 名不重複角色)。", + "feasibilityMissionSum": "三種激奏技能最低人數之和不能超過 5 人。", + "feasibilityInvalidCount": "激奏數值條件超出有效整數範圍。", "arena": { "ruleSourceLabel": "競技場規則來源", "ruleAuto": "當期日程", @@ -728,8 +765,9 @@ export const zhTW = { "worstTimingScore": "最差時序分數", "nominalTimingScore": "名義時序分數", "bestTimingScore": "最佳時序分數", - "timingFramePlanScore": "提前 17 ms 方案", + "timingFramePlanScore": "提前 {ms} ms 方案", "timingGuide": "凹分教學", + "timingGuideUnavailable": "原譜面時刻已存在同軌輸入衝突,無法提供安全提前擊打方案。", "timingNotes": "提前擊打的音符", "timingAdvance": "提前約 {ms} ms", "timingStep": "第 {step} 檔", @@ -760,6 +798,7 @@ export const zhTW = { "unproven": "目前最佳(未證明)", "stoppedUnproven": "已停止 · 目前最佳(未證明)", "timedOutUnproven": "已到時間上限 · 目前最佳(未證明)", + "sampledUnproven": "隨機抽樣評估 · 目前最佳(未證明)", "remaining": "剩餘 {n} 秒", "bounds": "目前最好 {best},最優不超過 {limit}", "gapPercent": "差距 {n}%", diff --git a/src/lib/cards/data.ts b/src/lib/cards/data.ts index 4a54bbf0..ba304a5a 100644 --- a/src/lib/cards/data.ts +++ b/src/lib/cards/data.ts @@ -66,6 +66,8 @@ export interface CardViewModel { liveSkillId: number; leaderSkillId: number; gekisouSkillId: number; + /** `MasterGekisouSkill._gekisouMissionType`: 1 = COMBO, 2 = LUCK, 3 = JUST, 4 = ALL. */ + gekisouMissionType?: number; searchText: string; /** The game's exact Addressables key; explicit absence cannot inherit another server's background. */ formationBackgroundKey?: string | null; @@ -89,10 +91,12 @@ export function normalizeCards( bands: RawBand[], texts: RawText[], locale: AppLocale, + gekisouSkills?: readonly { id: number; gekisouMissionType: number }[], ): CardViewModel[] { const textMap = new Map(texts.map((entry) => [entry.id, entry])); const characterMap = new Map(characters.map((entry) => [entry.id, entry])); const bandMap = new Map(bands.map((entry) => [entry.id, entry])); + const gekisouMap = new Map((gekisouSkills ?? []).map((entry) => [entry.id, entry.gekisouMissionType])); const resolveText = (id: string) => localizeMasterText(textMap.get(id), locale) || id; return cards @@ -107,6 +111,7 @@ export function normalizeCards( const bandName = band ? resolveText(band.nameTextID) : ""; const formationBackgroundKey = card.rarity === 2 ? band?.memberRarityRBackgroundAssetPath : `MemberCard/${card.assetID}/member_background[formation]`; + const gekisouMissionType = gekisouMap.get(card.gekisouSkillID); return { id: card.id, @@ -128,6 +133,7 @@ export function normalizeCards( liveSkillId: card.liveSkillID, leaderSkillId: card.leaderSkillID, gekisouSkillId: card.gekisouSkillID, + ...(typeof gekisouMissionType === "number" ? { gekisouMissionType } : {}), searchText: [title, characterName, bandName, card.id].join(" ").toLocaleLowerCase(), formationBackgroundKey: formationBackgroundKey || null, }; diff --git a/src/lib/deck/answer.ts b/src/lib/deck/answer.ts index cfb7cff9..aee251c6 100644 --- a/src/lib/deck/answer.ts +++ b/src/lib/deck/answer.ts @@ -34,6 +34,7 @@ export interface DeckOrderTiming { best: number; worst?: number; optimal?: number; + guideAvailable?: boolean; advanceMs: number; windows: DeckTimingWindow[]; } @@ -111,6 +112,7 @@ function orderTiming(raw: unknown): DeckOrderTiming | undefined { if (!record(raw)) return undefined; const score = num(raw.score), best = num(raw.best), advanceMs = num(raw.advanceMs); const worst = num(raw.worst), optimal = num(raw.optimal); + const guideAvailable = typeof raw.guideAvailable === "boolean" ? raw.guideAvailable : undefined; if (score === null || best === null || advanceMs === null || !Array.isArray(raw.order) || !Array.isArray(raw.windows)) return undefined; return { order: raw.order.map(int), @@ -118,6 +120,7 @@ function orderTiming(raw: unknown): DeckOrderTiming | undefined { best, ...(worst !== null ? { worst } : {}), ...(optimal !== null ? { optimal } : {}), + ...(guideAvailable !== undefined ? { guideAvailable } : {}), advanceMs: Math.trunc(advanceMs), windows: raw.windows.filter(record).flatMap((win) => { const event = num(win.event), member = num(win.member), startMs = num(win.startMs), endMs = num(win.endMs), unit = num(win.unit); diff --git a/src/lib/deck/card-selection.ts b/src/lib/deck/card-selection.ts new file mode 100644 index 00000000..57e44df8 --- /dev/null +++ b/src/lib/deck/card-selection.ts @@ -0,0 +1,80 @@ +import type { BoxCard, CardBox, CardKind } from "@/lib/box/model"; + +/** + * Stable master-identity key (`member:51` or `snap:76`) for cards with a known catalog ID, + * falling back to `key:` only for unverified/unrecognized drafts. + */ +export function stableCardIdentity(card: Pick): string { + if (card.identity.value !== null && card.identity.value !== "") { + return `${card.kind}:${card.identity.value}`; + } + return `key:${card.key}`; +} + +export interface ReconciledCardSelections { + readonly required: Set; + readonly excluded: Set; + readonly leaderKey: string | null; +} + +/** + * Reconcile `required`, `excluded`, and `leaderKey` across Box updates (OCR/video sync, local save import, + * Box switching, and Theoretical Max practice toggles) by stable `(kind, identity.value)` rather than + * ephemeral `card.key` UUIDs (`fb4a5d0`). + */ +export function reconcileCardSelections( + previousCards: readonly BoxCard[], + nextBox: CardBox | null, + previous: ReconciledCardSelections, +): ReconciledCardSelections { + if (!nextBox || nextBox.cards.length === 0) { + return { required: new Set(), excluded: new Set(), leaderKey: null }; + } + + const prevByKey = new Map(previousCards.map((card) => [card.key, card])); + const nextByKey = new Map(nextBox.cards.map((card) => [card.key, card])); + const nextByIdentity = new Map(); + for (const card of nextBox.cards) { + const id = stableCardIdentity(card); + if (!nextByIdentity.has(id)) { + nextByIdentity.set(id, card); + } + } + + const resolveNextKey = (oldKey: string, expectedKind?: CardKind): string | null => { + const prevCard = prevByKey.get(oldKey); + if (prevCard) { + if (expectedKind && prevCard.kind !== expectedKind) return null; + const byIdentity = nextByIdentity.get(stableCardIdentity(prevCard)); + if (byIdentity) return byIdentity.key; + } + const direct = nextByKey.get(oldKey); + if (direct && (!expectedKind || direct.kind === expectedKind)) { + return direct.key; + } + return null; + }; + + const nextRequired = new Set(); + for (const key of previous.required) { + const mapped = resolveNextKey(key); + if (mapped) nextRequired.add(mapped); + } + + const nextExcluded = new Set(); + for (const key of previous.excluded) { + const mapped = resolveNextKey(key); + if (mapped && !nextRequired.has(mapped)) nextExcluded.add(mapped); + } + + const nextLeader = previous.leaderKey ? resolveNextKey(previous.leaderKey, "member") : null; + if (nextLeader && nextExcluded.has(nextLeader)) { + nextExcluded.delete(nextLeader); + } + + return { + required: nextRequired, + excluded: nextExcluded, + leaderKey: nextLeader, + }; +} diff --git a/src/lib/deck/feasibility.ts b/src/lib/deck/feasibility.ts new file mode 100644 index 00000000..d4d0ced0 --- /dev/null +++ b/src/lib/deck/feasibility.ts @@ -0,0 +1,133 @@ +import type { CardBox } from "@/lib/box/model"; + +export const MAX_GEKISO_COUNT = 2_147_483_647; + +export function validGekisoCount(value: number, minimum: number): boolean { + return Number.isSafeInteger(value) && value >= minimum && value <= MAX_GEKISO_COUNT; +} + +export function validMissionMemberMinimums(counts: readonly [number, number, number]): boolean { + return ( + counts.length === 3 + && counts.every((v) => Number.isSafeInteger(v) && v >= 0 && v <= 5) + && counts[0] + counts[1] + counts[2] <= 5 + ); +} + +export interface FeasibilityMemberCard { + readonly id: number; + readonly characterId: number; + readonly gekisouMissionType?: number; +} + +export interface FeasibilityConstraints { + readonly includeMembers: readonly string[]; + readonly excludeMembers: readonly string[]; + readonly leader?: string | null; + readonly minMissionMembers?: readonly [number, number, number]; +} + +export type DeckFeasibilityIssue = + | { readonly code: "invalid_gekiso_count"; readonly field: "minComboBonus" | "luckSamples" | "minJustWindowMs" } + | { readonly code: "invalid_mission_member_sum"; readonly sum: number } + | { readonly code: "mission_members_unreachable"; readonly missionIndex: 0 | 1 | 2; readonly required: number; readonly available: number } + | { readonly code: "character_conflict_or_shortage"; readonly availableCharacters: number }; + +/** + * Pre-submit conservative feasibility check for Gekisou member mission counts and character uniqueness (`5398de2`, `9b68b59`). + * Returns `null` when feasible (or when Box state does not restrict the check). + */ +export function checkGekisoConstraintFeasibility(options: { + readonly box: CardBox | null; + readonly memberCatalog: ReadonlyMap; + readonly constraints: FeasibilityConstraints; + readonly minComboBonus?: number; + readonly luckSamples?: number; + readonly minJustWindowMs?: number; +}): DeckFeasibilityIssue | null { + const { box, memberCatalog, constraints, minComboBonus, luckSamples, minJustWindowMs } = options; + + if (minComboBonus !== undefined && !validGekisoCount(minComboBonus, 1)) { + return { code: "invalid_gekiso_count", field: "minComboBonus" }; + } + if (luckSamples !== undefined && !validGekisoCount(luckSamples, 0)) { + return { code: "invalid_gekiso_count", field: "luckSamples" }; + } + if (minJustWindowMs !== undefined && (!Number.isFinite(minJustWindowMs) || minJustWindowMs < 0 || minJustWindowMs > MAX_GEKISO_COUNT)) { + return { code: "invalid_gekiso_count", field: "minJustWindowMs" }; + } + + const missionMinimums = constraints.minMissionMembers ?? [0, 0, 0]; + if (!validMissionMemberMinimums(missionMinimums)) { + return { code: "invalid_mission_member_sum", sum: missionMinimums[0] + missionMinimums[1] + missionMinimums[2] }; + } + + if (!box) return null; + + const excludedIds = new Set(constraints.excludeMembers); + const ownedMemberIds = new Set(); + for (const card of box.cards) { + if (card.kind !== "member" || !card.identity.value) continue; + if (excludedIds.has(card.identity.value)) continue; + ownedMemberIds.add(card.identity.value); + } + + const fixedIds = new Set(constraints.includeMembers); + if (constraints.leader) fixedIds.add(constraints.leader); + const fixedCharacters = new Set(); + for (const idStr of fixedIds) { + if (!ownedMemberIds.has(idStr)) { + return { code: "character_conflict_or_shortage", availableCharacters: 0 }; + } + const meta = memberCatalog.get(Number(idStr)); + if (meta) { + if (fixedCharacters.has(meta.characterId)) { + return { code: "character_conflict_or_shortage", availableCharacters: fixedCharacters.size }; + } + fixedCharacters.add(meta.characterId); + } + } + if (fixedIds.size > 5) { + return { code: "character_conflict_or_shortage", availableCharacters: fixedCharacters.size }; + } + + const allCharacters = new Set(); + // Mission types in MasterGekisouSkill: 1 = COMBO, 2 = LUCK, 3 = JUST, 4 = ALL (counts toward every mission type). + const charsPerMission: [Set, Set, Set] = [new Set(), new Set(), new Set()]; + let hasMissionMetadata = false; + + for (const idStr of ownedMemberIds) { + const meta = memberCatalog.get(Number(idStr)); + if (!meta) continue; + // If another card of this character is already fixed, only that fixed card can represent this character! + if (fixedCharacters.has(meta.characterId) && !fixedIds.has(idStr)) continue; + allCharacters.add(meta.characterId); + if (typeof meta.gekisouMissionType === "number" && meta.gekisouMissionType > 0) { + hasMissionMetadata = true; + const mt = meta.gekisouMissionType; + if (mt === 1 || mt === 4) charsPerMission[0].add(meta.characterId); + if (mt === 2 || mt === 4) charsPerMission[1].add(meta.characterId); + if (mt === 3 || mt === 4) charsPerMission[2].add(meta.characterId); + } + } + + if (memberCatalog.size > 0 && allCharacters.size < 5) { + return { code: "character_conflict_or_shortage", availableCharacters: allCharacters.size }; + } + + if (hasMissionMetadata) { + for (const idx of [0, 1, 2] as const) { + const req = missionMinimums[idx]; + if (req > 0 && charsPerMission[idx].size < req) { + return { + code: "mission_members_unreachable", + missionIndex: idx, + required: req, + available: charsPerMission[idx].size, + }; + } + } + } + + return null; +} diff --git a/src/lib/deck/goals.ts b/src/lib/deck/goals.ts index afb7e866..b3b52756 100644 --- a/src/lib/deck/goals.ts +++ b/src/lib/deck/goals.ts @@ -17,6 +17,11 @@ export const DEFAULT_DECK_GOAL: DeckGoal = "battle"; export type DeckAggregation = "expected" | "maximum"; /** Cumulative-counter delivery timing within legal base-judgement windows for ordinary maximum-score searches. */ export type DeckCumulativeTiming = "nominal" | "best" | "worst"; +/** Native simulation frame rates (Hz) supported for normal played lives (`7f80fa3`, `6e20f08`). */ +export const DECK_FRAME_RATES = [30, 60, 90, 120] as const; +export type DeckFrameRate = (typeof DECK_FRAME_RATES)[number]; +export const DEFAULT_JUST_WINDOW_BASE_MS = 25; +export const MAX_GEKISO_INPUT_COUNT = 2_147_483_647; export const isEventGoal = (goal: DeckGoal): boolean => (EVENT_GOALS as readonly string[]).includes(goal); export const isEventPayoffGoal = (goal: DeckGoal): boolean => goal === "challengePoints" || goal === "eventPoints" || goal === "eventItems"; @@ -72,6 +77,8 @@ export interface DeckGoalInput { aggregation: DeckAggregation; /** Ordinary maximum-score goal only: optimize or lower-bound cumulative-Snap delivery timing (`best` / `worst`), or keep `nominal`. */ cumulativeTiming?: DeckCumulativeTiming; + /** Ordinary played live frame rate (`30` / `60` / `90` / `120` Hz, default `60`). */ + frameRate?: DeckFrameRate; allSongs?: boolean; /** Multi-song search mode when `allSongs` is enabled on score goals: per-song top teams (`individual`) or one shared formation maximizing average score (`mean`). */ multiSongMode?: "individual" | "mean"; @@ -93,6 +100,19 @@ export interface DeckGoalInput { arenaSongEntries?: readonly { musicId: number; difficulty: MusicDifficulty }[]; /** Gekisou range placement (1..5, default 1). */ gekisouRank?: number; + /** Per-range Gekisou placements (Ranges 1..3, each 1..5). */ + gekisouRanks?: readonly [number, number, number]; + /** Gekisou minimum opening COMBO BONUS (`1..=2147483647`, default `1`). */ + minComboBonus?: number; + /** Gekisou steady JUST tap window target unit (`+%` over base tap window or `±ms` half-window). */ + justWindowUnit?: "percent" | "ms"; + justWindowPercent?: number; + justWindowMs?: number; + /** Base JUST tap half-window in ms from `MasterLiveJudgementTiming` (`25` ms default). */ + baseJustWindowMs?: number; + /** Gekisou LUCK sampling mode (`auto` nominal/4-root vs `manual` seed count `0..n`). */ + luckSamplingMode?: "auto" | "manual"; + luckSamples?: number; playMode: "accuracy" | "pattern"; missEvery: number; boosts: number; @@ -101,11 +121,17 @@ export interface DeckGoalInput { challengePoints: ChallengePointCost; /** Whole percents. */ greatPercent: number; + goodPercent?: number; + badPercent?: number; + missPercent?: number; justPercent: number; /** Power: count the chosen song's attribute and tag bonuses. */ powerSong: boolean; /** Power: count the held event's parameter bonus. */ eventParameter: boolean; + /** Event room grading mode (`battle` vs `solo`) and room player count (`1..5`). */ + rewardRoomMode?: "battle" | "solo"; + rewardPlayers?: number; /** Battle event points: the other players' average score, or null for "the same as mine". */ othersAverageScore: number | null; } @@ -165,6 +191,34 @@ export const effectiveCumulativeTiming = ( ? input.cumulativeTiming : "nominal"; +/** Whether a goal supports selectable native frame rate (`30` / `60` / `90` / `120` Hz). */ +export const supportsFrameRate = (input: Pick): boolean => + readsAccuracy(input) && !playsGekisou(input); + +export const effectiveFrameRate = ( + input: Pick & Partial>, +): DeckFrameRate => + supportsFrameRate(input) && input.frameRate && (DECK_FRAME_RATES as readonly number[]).includes(input.frameRate) + ? input.frameRate + : 60; + +/** Resolved minimum steady JUST tap half-window in ms (`0` when unset). */ +export function effectiveMinJustWindowMs( + input: Partial>, +): number { + const unit = input.justWindowUnit ?? "percent"; + if (unit === "ms") { + const ms = input.justWindowMs ?? 0; + return typeof ms === "number" && Number.isFinite(ms) && ms > 0 ? ms : 0; + } + const pct = input.justWindowPercent ?? 0; + if (typeof pct !== "number" || !Number.isFinite(pct) || pct <= 0) return 0; + const base = typeof input.baseJustWindowMs === "number" && Number.isFinite(input.baseJustWindowMs) && input.baseJustWindowMs > 0 + ? input.baseJustWindowMs + : DEFAULT_JUST_WINDOW_BASE_MS; + return Number((base * (1 + pct / 100)).toFixed(4)); +} + type RewardInput = Pick & Partial>; /** The rewards a goal input can count beside its own. A challenge live spends challenge points and earns none. */ export const mixableRewards = (input: Pick & Partial>): readonly EventReward[] => @@ -189,7 +243,7 @@ export interface DeckSolverCapabilities { goals: readonly string[]; metrics: Readonly>; aggregations?: Partial>>>; - accuracy: { great: boolean; just: boolean }; + accuracy: { great: boolean; good?: boolean; bad?: boolean; miss?: boolean; just: boolean }; patternPlay?: boolean; crossSong?: boolean; crossSongMean?: boolean; @@ -206,12 +260,26 @@ export interface DeckSolverCapabilities { arenaRules?: boolean; /** Gekisou range placements 1..5. */ gekisouRank?: boolean; + /** Per-range Gekisou placements `[r1, r2, r3]` (1..5). */ + gekisouRangeRanks?: boolean; /** Manual / simulated event bonus overrides (`eventEffects`) for future or custom event planning. */ manualEventBonuses?: boolean; /** Fast single-card upgrade impact ranking (`evaluateGrowth`) on solved teams. */ teamGrowth?: boolean; /** Best/worst cumulative-counter delivery timing optimization (`cumulativeTiming: "best" | "worst"`). */ cumulativeTiming?: boolean; + /** Supported frame rates (Hz) for ordinary played lives. */ + frameRates?: readonly number[]; + /** Bounded Gekisou mission member counts (`[COMBO, LUCK, JUST]`, sum <= 5). */ + minMissionMembers?: boolean; + /** Gekisou minimum opening COMBO BONUS (`minComboBonus`). */ + minComboBonus?: boolean; + /** Gekisou steady JUST tap window floor (`minJustWindowMs`). */ + minJustWindow?: boolean; + /** Configurable LUCK simulation seed counts (`luckSamples`). */ + luckSampling?: boolean; + /** Supported event reward room grading modes (`battle` / `solo`). */ + rewardRoomModes?: readonly string[]; } export function parseCapabilities(json: string | null): DeckSolverCapabilities | null { @@ -221,6 +289,7 @@ export function parseCapabilities(json: string | null): DeckSolverCapabilities | if (!value || typeof value !== "object") return null; const raw = value as Record; const strings = (list: unknown) => Array.isArray(list) ? list.filter((item): item is string => typeof item === "string") : []; + const numbers = (list: unknown) => Array.isArray(list) ? list.filter((item): item is number => typeof item === "number" && Number.isFinite(item)) : []; const metricMap = (value: unknown): Record => { const map: Record = {}; if (value && typeof value === "object" && !Array.isArray(value)) for (const [kind, list] of Object.entries(value)) map[kind] = strings(list); @@ -245,17 +314,39 @@ export function parseCapabilities(json: string | null): DeckSolverCapabilities | && (combined.goals === undefined || Array.isArray(combined.goals) && combined.goals.every(kind => typeof kind === "string")) ? { maxLevels: combined.maxLevels as number, maxTerms: combined.maxTerms as number, maxWeight: combined.maxWeight as number, ...(combined.goals === undefined ? {} : { goals: strings(combined.goals) }) } : undefined; + const frameRates = numbers(raw.frameRates); + const rewardRoomModes = strings(raw.rewardRoomModes); return { goals: strings(raw.goals), metrics, aggregations, ...(eventItemRewards ? { eventItemRewards } : {}), ...(combinedMetric ? { combinedMetric } : {}), crossSong: raw.crossSong === true, crossSongMean: raw.crossSongMean === true, songRules: raw.songRules === true, resume: raw.resume === true, luckMissions: raw.luckMissions === true, luckGreatConversions: raw.luckGreatConversions === true, - arenaRules: raw.arenaRules === true, gekisouRank: raw.gekisouRank === true, + arenaRules: raw.arenaRules === true, gekisouRank: raw.gekisouRank === true, gekisouRangeRanks: raw.gekisouRangeRanks === true, manualEventBonuses: raw.manualEventBonuses === true, teamGrowth: raw.teamGrowth === true, cumulativeTiming: raw.cumulativeTiming === true, - patternPlay: raw.patternPlay === true || !!raw.patternPlay && typeof raw.patternPlay === "object", accuracy: { great: accuracy.great === true, just: accuracy.just === true } }; + ...(frameRates.length ? { frameRates } : {}), + minMissionMembers: raw.minMissionMembers === true, + minComboBonus: raw.minComboBonus === true, + minJustWindow: raw.minJustWindow === true, + luckSampling: raw.luckSampling === true, + ...(rewardRoomModes.length ? { rewardRoomModes } : {}), + patternPlay: raw.patternPlay === true || !!raw.patternPlay && typeof raw.patternPlay === "object", + accuracy: { + great: accuracy.great === true, + ...(accuracy.good === true ? { good: true } : {}), + ...(accuracy.bad === true ? { bad: true } : {}), + ...(accuracy.miss === true ? { miss: true } : {}), + just: accuracy.just === true, + } }; } /** Whether the engine computes a goal input's goal kind and metric. */ -export function computes(capabilities: DeckSolverCapabilities, input: Pick & Partial>): boolean { +export function computes( + capabilities: DeckSolverCapabilities, + input: Pick & Partial>, +): boolean { const layout = rewardLayoutOf(input); if (layout) { const combined = capabilities.combinedMetric; @@ -282,11 +373,24 @@ export function computes(capabilities: DeckSolverCapabilities, input: Pick r !== (input.gekisouRank ?? 1)) && !capabilities.gekisouRangeRanks) return false; + if ((input.minComboBonus ?? 1) > 1 && !capabilities.minComboBonus) return false; + if (effectiveMinJustWindowMs(input) > 0 && !capabilities.minJustWindow) return false; + if (input.luckSamplingMode === "manual" && (input.luckSamples ?? 0) > 0 && !capabilities.luckSampling) return false; + } if (effectiveCumulativeTiming(input) !== "nominal" && !capabilities.cumulativeTiming) return false; + const frameRate = effectiveFrameRate(input); + if (frameRate !== 60 && !(capabilities.frameRates ?? []).includes(frameRate)) return false; if (input.playMode === "pattern" && readsAccuracy(input) && !capabilities.patternPlay) return false; if (readsAccuracy(input) && input.playMode !== "pattern") { if ((input.greatPercent ?? 0) !== 0 && !capabilities.accuracy.great) return false; + if (!playsGekisou(input)) { + if ((input.goodPercent ?? 0) !== 0 && !capabilities.accuracy.good) return false; + if ((input.badPercent ?? 0) !== 0 && !capabilities.accuracy.bad) return false; + if ((input.missPercent ?? 0) !== 0 && !capabilities.accuracy.miss) return false; + } if (playsGekisou(input) && (input.justPercent ?? 100) !== 100 && !capabilities.accuracy.just) return false; } if (!capabilities.goals.includes(kind)) return false; @@ -334,6 +438,8 @@ export interface DeckRunConstraints { excludeSnaps: readonly string[]; leader?: string | null; noSnaps?: boolean; + /** Minimum COMBO, LUCK, and JUST Gekisou member counts (`0..=5`, sum `<= 5`). */ + minMissionMembers?: readonly [number, number, number]; } /** JST DateTime ticks (100 ns since 0001-01-01, wall clock in UTC+9) of an epoch time in ms. */ @@ -386,19 +492,42 @@ export function recommendationRequest(input: DeckGoalInput, context: DeckRecomme const gap = goalGap(input, context.event); if (gap) throw new Error(`The goal still needs: ${gap}`); const kind = solverGoalKind(input), event = context.event; + const isGekisou = playsGekisou(input); const great = fraction(input.greatPercent); + const good = isGekisou ? 0 : fraction(Math.min(input.goodPercent ?? 0, Math.max(0, 100 - input.greatPercent))); + const bad = isGekisou ? 0 : fraction(Math.min(input.badPercent ?? 0, Math.max(0, 100 - input.greatPercent - (input.goodPercent ?? 0)))); + const miss = isGekisou ? 0 : fraction(Math.min(input.missPercent ?? 0, Math.max(0, 100 - input.greatPercent - (input.goodPercent ?? 0) - (input.badPercent ?? 0)))); + const hasLowerJudgements = good > 0 || bad > 0 || miss > 0; const timing = effectiveCumulativeTiming(input); - const accuracy = playsGekisou(input) ? `{"greatFraction":${great},"justFraction":${fraction(Math.min(input.justPercent, 100 - input.greatPercent))}}` : `{"greatFraction":${great}}`; + const frameRate = effectiveFrameRate(input); + const extraAccuracyFields = `${good > 0 ? `,"goodFraction":${good}` : ""}${bad > 0 ? `,"badFraction":${bad}` : ""}${miss > 0 ? `,"missFraction":${miss}` : ""}`; + const playTail = `${extraAccuracyFields}${timing !== "nominal" ? `,"cumulativeTiming":${JSON.stringify(timing)}` : ""}${frameRate !== 60 ? `,"frameRate":${frameRate}` : ""}`; + const accuracy = isGekisou + ? `{"greatFraction":${great},"justFraction":${fraction(Math.min(input.justPercent, 100 - input.greatPercent))}}` + : `{"greatFraction":${great}${extraAccuracyFields}}`; const play = input.playMode === "pattern" - ? `"play":{"kind":"pattern","greatFraction":${great},"justFraction":${playsGekisou(input) ? fraction(Math.min(input.justPercent, 100 - input.greatPercent)) : 0},"missEvery":${input.missEvery}${timing !== "nominal" ? `,"cumulativeTiming":${JSON.stringify(timing)}` : ""}}` - : timing !== "nominal" - ? `"play":{"greatFraction":${great},"cumulativeTiming":${JSON.stringify(timing)}}` + ? `"play":{"kind":"pattern","greatFraction":${great},"justFraction":${isGekisou ? fraction(Math.min(input.justPercent, 100 - input.greatPercent)) : 0},"missEvery":${input.missEvery}${playTail}}` + : (timing !== "nominal" || frameRate !== 60 || hasLowerJudgements) + ? `"play":{"greatFraction":${great}${playTail}}` : `"accuracy":${accuracy}`; const song = `"musicId":${input.musicId},"difficulty":${JSON.stringify(input.difficulty)}`; const rank = Math.min(5, Math.max(1, Math.trunc(input.gekisouRank ?? 1))); + const clampRank = (value: number) => Math.min(5, Math.max(1, Math.trunc(value))); + const rawRanks = isGekisou && kind !== "missionLive" && input.gekisouRanks + ? [clampRank(input.gekisouRanks[0]), clampRank(input.gekisouRanks[1]), clampRank(input.gekisouRanks[2])] as const + : null; + const ranksJson = rawRanks && rawRanks.some(r => r !== rank) ? `,"ranks":${JSON.stringify(rawRanks)}` : ""; + const minComboBonus = isGekisou && Number.isSafeInteger(input.minComboBonus) && (input.minComboBonus ?? 1) > 1 + ? Math.min(MAX_GEKISO_INPUT_COUNT, input.minComboBonus!) + : null; + const minJustWindowMs = isGekisou ? effectiveMinJustWindowMs(input) : 0; + const luckSamples = isGekisou && input.luckSamplingMode === "manual" && Number.isSafeInteger(input.luckSamples) && (input.luckSamples ?? 0) > 0 + ? Math.min(MAX_GEKISO_INPUT_COUNT, input.luckSamples!) + : null; + const gekisouExtras = `${ranksJson}${minComboBonus !== null ? `,"minComboBonus":${minComboBonus}` : ""}${minJustWindowMs > 0 ? `,"minJustWindowMs":${minJustWindowMs}` : ""}${luckSamples !== null ? `,"luckSamples":${luckSamples}` : ""}`; let goal: string; switch (kind) { - case "battleLive": case "missionLive": goal = `{"kind":"${kind}",${song}${kind === "battleLive" ? `,"rank":${rank}` : ""},${play}}`; break; + case "battleLive": case "missionLive": goal = `{"kind":"${kind}",${song}${kind === "battleLive" ? `,"rank":${rank}` : ""}${gekisouExtras},${play}}`; break; case "arenaLive": { if (input.arenaRule) { const arenaJson = JSON.stringify({ @@ -411,10 +540,10 @@ export function recommendationRequest(input: DeckGoalInput, context: DeckRecomme ? input.arenaSongEntries.map(entry => ({ musicId: entry.musicId, difficulty: entry.difficulty })) : null; goal = entries - ? `{"kind":"arenaLive","arena":${arenaJson},"songs":${JSON.stringify(entries)},"rank":${rank},${play}}` - : `{"kind":"arenaLive","arena":${arenaJson},${song},"rank":${rank},${play}}`; + ? `{"kind":"arenaLive","arena":${arenaJson},"songs":${JSON.stringify(entries)},"rank":${rank}${gekisouExtras},${play}}` + : `{"kind":"arenaLive","arena":${arenaJson},${song},"rank":${rank}${gekisouExtras},${play}}`; } else { - goal = `{"kind":"arenaLive","arenaMusicId":${input.arenaMusicId},"difficulty":${JSON.stringify(input.difficulty)},"rank":${rank},${play}}`; + goal = `{"kind":"arenaLive","arenaMusicId":${input.arenaMusicId},"difficulty":${JSON.stringify(input.difficulty)},"rank":${rank}${gekisouExtras},${play}}`; } break; } @@ -429,7 +558,7 @@ export function recommendationRequest(input: DeckGoalInput, context: DeckRecomme goal = goal.replace("{", `{${multiSongPrefix}${JSON.stringify(options.songScope.field)}:${JSON.stringify(options.songScope.ids)},`); } if (kind !== "power" && kind !== "arenaLive") { - const rule = activeSongRules(input.songRules, playsGekisou(input)); + const rule = activeSongRules(input.songRules, isGekisou); if (rule) goal = goal.replace("{", `{"songRules":${JSON.stringify(rule)},`); } const parts = [`"format":${JSON.stringify(REQUEST_FORMAT)}`, `"goal":${goal}`]; @@ -443,9 +572,19 @@ export function recommendationRequest(input: DeckGoalInput, context: DeckRecomme const clock = kind === "skip" ? `{"kind":"skip","serverNowJstTicks":${jstTicks(context.now)}}` : `{"kind":"played","liveStartJstTicks":null,"serverNowJstTicks":${jstTicks(context.now)}}`; parts.push(`"metric":${metric}`, `"eventContext":{"resultClock":${clock},"rewardProjection":true}`); - if (isNetworkInput(input)) parts.push(`"room":{"players":5,"othersAverageScore":${input.othersAverageScore === null ? "null" : Math.max(0, Math.trunc(input.othersAverageScore))}}`); + const players = Math.min(5, Math.max(1, Math.trunc(input.rewardPlayers ?? 5))); + const othersJson = input.othersAverageScore === null ? "null" : String(Math.max(0, Math.trunc(input.othersAverageScore))); + if (isNetworkInput(input)) { + if (input.rewardRoomMode === "solo") { + parts.push(`"room":{"mode":"solo","players":1,"othersAverageScore":null}`); + } else { + parts.push(`"room":{"players":${players},"othersAverageScore":${othersJson}}`); + } + } else if (kind === "freeLive" && input.rewardRoomMode === "battle") { + parts.push(`"room":{"mode":"battle","players":${players},"othersAverageScore":${othersJson}}`); + } } else if (kind !== "power") parts.push(`"metric":{"kind":"score"}`); - const { includeMembers, includeSnaps, excludeMembers, excludeSnaps, leader, noSnaps } = context.constraints; + const { includeMembers, includeSnaps, excludeMembers, excludeSnaps, leader, noSnaps, minMissionMembers } = context.constraints; const leaderJson = leader == null || leader === "" ? "null" : ids([leader])[0]!; if (event && usesEventData(input) && (context.manualEvent || context.eventEffects !== undefined)) { parts.push('"manualEvent":true'); @@ -454,8 +593,11 @@ export function recommendationRequest(input: DeckGoalInput, context: DeckRecomme } } const requiredSnapsPart = !noSnaps && includeSnaps && includeSnaps.length > 0 ? `,"includeSnaps":[${ids(includeSnaps).join(",")}]` : ""; + const missionPart = isGekisou && minMissionMembers && minMissionMembers.some(v => v > 0) + ? `,"minMissionMembers":${JSON.stringify([minMissionMembers[0], minMissionMembers[1], minMissionMembers[2]])}` + : ""; parts.push(`"eventIds":[${event && usesEventData(input) ? event.id : ""}]`, - `"constraints":{"leader":${leaderJson},"includeMembers":[${ids(includeMembers).join(",")}]${requiredSnapsPart},"excludeMembers":[${ids(excludeMembers).join(",")}],"excludeSnaps":[${ids(excludeSnaps).join(",")}],"noSnaps":${noSnaps === true ? "true" : "false"}}`, + `"constraints":{"leader":${leaderJson},"includeMembers":[${ids(includeMembers).join(",")}]${requiredSnapsPart},"excludeMembers":[${ids(excludeMembers).join(",")}],"excludeSnaps":[${ids(excludeSnaps).join(",")}],"noSnaps":${noSnaps === true ? "true" : "false"}${missionPart}}`, `"k":${options.teams ?? RECOMMENDATION_COUNT}`, `"limits":{"timeLimitMs":${input.timeLimit === null ? "null" : input.timeLimit * 1000}}`); return `{${parts.join(",")}}`; } diff --git a/src/lib/deck/haneoka/data.ts b/src/lib/deck/haneoka/data.ts index 844c9987..94dbdbb5 100644 --- a/src/lib/deck/haneoka/data.ts +++ b/src/lib/deck/haneoka/data.ts @@ -143,7 +143,10 @@ export function adaptData(data: DeckData, datasetId: string) { skills("gekisoSupport", "MasterGekisouSupportSkill", "MasterGekisouSupportSkillEffect", "gekisouSupportSkillID"), ]), skillReference: Object.fromEntries(Object.entries(reference).map(([key, name]) => [key, rows(name)])), - liveTools: Object.fromEntries(Object.entries(tools).map(([key, name]) => [key, rows(name)])), + liveTools: { + ...Object.fromEntries(Object.entries(tools).map(([key, name]) => [key, rows(name)])), + judgementAreaOffsets: optionalTable(data, "MasterLiveJudgementAreaOffset"), + }, runtimeRules: { tables: Object.fromEntries(Object.entries(runtime).map(([key, name]) => [key, { rows: rows(name) }])) }, bandItems: keyed(rows("MasterBandItem").map(row => ({ ...row, bandItemId: row.id, effects: itemEffects.filter(effect => effect.bandItemId === row.id) }))), challengeMusicTable: { rows: rows("MasterChallengeMusic") }, challengePointTable: { rows: rows("MasterLiveChallengePoint") }, diff --git a/src/lib/deck/haneoka/growth.ts b/src/lib/deck/haneoka/growth.ts index 49c9f216..c001008b 100644 --- a/src/lib/deck/haneoka/growth.ts +++ b/src/lib/deck/haneoka/growth.ts @@ -7,11 +7,17 @@ export type DeckGrowthAction = | "level" | "levelCap" | "awake" + | "awakeCap" | "rank" + | "rankCap" | "liveSkillLevel" + | "liveSkillCap" | "gekisouSkillLevel" + | "gekisouSkillCap" | "full"; +export type DeckGrowthTarget = "step" | "cap" | "full"; + export interface DeckGrowthTeamInput { readonly members: readonly number[]; readonly snaps: readonly (number | null)[]; @@ -32,6 +38,7 @@ export interface DeckGrowthScenario { readonly kind: "member" | "snap"; readonly cardId: number; readonly action: DeckGrowthAction; + readonly target?: DeckGrowthTarget; readonly from: number | null; readonly to: number | null; readonly after: DeckGrowthMetricSnapshot; @@ -340,6 +347,7 @@ export function evaluateTeamGrowth( kind: "member" | "snap", cardId: number, action: DeckGrowthAction, + target: DeckGrowthTarget, from: number | null, to: number | null, nextMembers: typeof baseMembers, @@ -354,6 +362,7 @@ export function evaluateTeamGrowth( kind, cardId, action, + target, from, to, after, @@ -402,33 +411,53 @@ export function evaluateTeamGrowth( if (member.level < currentCap) { const plusOne = structuredClone(baseMembers); plusOne[slot] = { ...plusOne[slot]!, level: member.level + 1 }; - evalCandidate(slot, "member", member.cardId, "level", member.level, member.level + 1, plusOne, baseSnaps); + evalCandidate(slot, "member", member.cardId, "level", "step", member.level, member.level + 1, plusOne, baseSnaps); if (member.level + 1 < currentCap) { const toCap = structuredClone(baseMembers); toCap[slot] = { ...toCap[slot]!, level: currentCap }; - evalCandidate(slot, "member", member.cardId, "levelCap", member.level, currentCap, toCap, baseSnaps); + evalCandidate(slot, "member", member.cardId, "levelCap", "cap", member.level, currentCap, toCap, baseSnaps); } } } if (member.awake !== null && member.awake < maxAwake) { const nextMembers = structuredClone(baseMembers); nextMembers[slot] = { ...nextMembers[slot]!, awake: member.awake + 1 }; - evalCandidate(slot, "member", member.cardId, "awake", member.awake, member.awake + 1, nextMembers, baseSnaps); + evalCandidate(slot, "member", member.cardId, "awake", "step", member.awake, member.awake + 1, nextMembers, baseSnaps); + if (member.awake + 1 < maxAwake) { + const capMembers = structuredClone(baseMembers); + capMembers[slot] = { ...capMembers[slot]!, awake: maxAwake }; + evalCandidate(slot, "member", member.cardId, "awakeCap", "cap", member.awake, maxAwake, capMembers, baseSnaps); + } } if (member.rank !== null && member.rank < maxRank) { const nextMembers = structuredClone(baseMembers); nextMembers[slot] = { ...nextMembers[slot]!, rank: member.rank + 1 }; - evalCandidate(slot, "member", member.cardId, "rank", member.rank, member.rank + 1, nextMembers, baseSnaps); + evalCandidate(slot, "member", member.cardId, "rank", "step", member.rank, member.rank + 1, nextMembers, baseSnaps); + if (member.rank + 1 < maxRank) { + const capMembers = structuredClone(baseMembers); + capMembers[slot] = { ...capMembers[slot]!, rank: maxRank }; + evalCandidate(slot, "member", member.cardId, "rankCap", "cap", member.rank, maxRank, capMembers, baseSnaps); + } } if (!powerOrSkip && !gekisoGoal && member.liveSkillLevel !== null && member.liveSkillLevel < maxLiveSkill) { const nextMembers = structuredClone(baseMembers); nextMembers[slot] = { ...nextMembers[slot]!, liveSkillLevel: member.liveSkillLevel + 1 }; - evalCandidate(slot, "member", member.cardId, "liveSkillLevel", member.liveSkillLevel, member.liveSkillLevel + 1, nextMembers, baseSnaps); + evalCandidate(slot, "member", member.cardId, "liveSkillLevel", "step", member.liveSkillLevel, member.liveSkillLevel + 1, nextMembers, baseSnaps); + if (member.liveSkillLevel + 1 < maxLiveSkill) { + const capMembers = structuredClone(baseMembers); + capMembers[slot] = { ...capMembers[slot]!, liveSkillLevel: maxLiveSkill }; + evalCandidate(slot, "member", member.cardId, "liveSkillCap", "cap", member.liveSkillLevel, maxLiveSkill, capMembers, baseSnaps); + } } if (!powerOrSkip && gekisoGoal && member.gekisoSkillLevel !== null && member.gekisoSkillLevel < maxGekisoSkill) { const nextMembers = structuredClone(baseMembers); nextMembers[slot] = { ...nextMembers[slot]!, gekisoSkillLevel: member.gekisoSkillLevel + 1 }; - evalCandidate(slot, "member", member.cardId, "gekisouSkillLevel", member.gekisoSkillLevel, member.gekisoSkillLevel + 1, nextMembers, baseSnaps); + evalCandidate(slot, "member", member.cardId, "gekisouSkillLevel", "step", member.gekisoSkillLevel, member.gekisoSkillLevel + 1, nextMembers, baseSnaps); + if (member.gekisoSkillLevel + 1 < maxGekisoSkill) { + const capMembers = structuredClone(baseMembers); + capMembers[slot] = { ...capMembers[slot]!, gekisoSkillLevel: maxGekisoSkill }; + evalCandidate(slot, "member", member.cardId, "gekisouSkillCap", "cap", member.gekisoSkillLevel, maxGekisoSkill, capMembers, baseSnaps); + } } const alreadyFull = @@ -447,7 +476,7 @@ export function evaluateTeamGrowth( liveSkillLevel: powerOrSkip || gekisoGoal ? null : maxLiveSkill, gekisoSkillLevel: powerOrSkip || !gekisoGoal ? null : maxGekisoSkill, }; - evalCandidate(slot, "member", member.cardId, "full", null, null, fullMembers, baseSnaps); + evalCandidate(slot, "member", member.cardId, "full", "full", null, null, fullMembers, baseSnaps); } cards.push({ @@ -479,25 +508,30 @@ export function evaluateTeamGrowth( if (snap.level < currentCap) { const plusOne = structuredClone(baseSnaps); plusOne[snapIdx] = { ...plusOne[snapIdx]!, level: snap.level + 1 }; - evalCandidate(slot, "snap", snap.cardId, "level", snap.level, snap.level + 1, baseMembers, plusOne); + evalCandidate(slot, "snap", snap.cardId, "level", "step", snap.level, snap.level + 1, baseMembers, plusOne); if (snap.level + 1 < currentCap) { const toCap = structuredClone(baseSnaps); toCap[snapIdx] = { ...toCap[snapIdx]!, level: currentCap }; - evalCandidate(slot, "snap", snap.cardId, "levelCap", snap.level, currentCap, baseMembers, toCap); + evalCandidate(slot, "snap", snap.cardId, "levelCap", "cap", snap.level, currentCap, baseMembers, toCap); } } } if (snap.rank !== null && snap.rank < maxRank) { const nextSnaps = structuredClone(baseSnaps); nextSnaps[snapIdx] = { ...nextSnaps[snapIdx]!, rank: snap.rank + 1 }; - evalCandidate(slot, "snap", snap.cardId, "rank", snap.rank, snap.rank + 1, baseMembers, nextSnaps); + evalCandidate(slot, "snap", snap.cardId, "rank", "step", snap.rank, snap.rank + 1, baseMembers, nextSnaps); + if (snap.rank + 1 < maxRank) { + const capSnaps = structuredClone(baseSnaps); + capSnaps[snapIdx] = { ...capSnaps[snapIdx]!, rank: maxRank }; + evalCandidate(slot, "snap", snap.cardId, "rankCap", "cap", snap.rank, maxRank, baseMembers, capSnaps); + } } const alreadyFull = snap.rank === maxRank && snap.level === maxLevel; if (!alreadyFull) { const fullSnaps = structuredClone(baseSnaps); fullSnaps[snapIdx] = { ...fullSnaps[snapIdx]!, rank: maxRank, level: maxLevel }; - evalCandidate(slot, "snap", snap.cardId, "full", null, null, baseMembers, fullSnaps); + evalCandidate(slot, "snap", snap.cardId, "full", "full", null, null, baseMembers, fullSnaps); } cards.push({ diff --git a/src/lib/deck/haneoka/request.ts b/src/lib/deck/haneoka/request.ts index b6fb8bc5..c901082a 100644 --- a/src/lib/deck/haneoka/request.ts +++ b/src/lib/deck/haneoka/request.ts @@ -12,29 +12,35 @@ export interface ManualEventEffectInput { bonusType: number; perRank: readonly [number, number, number, number, number]; } +export const MAX_GEKISO_COUNT = 2_147_483_647; +export const FRAME_RATES = [30, 60, 90, 120] as const; +export type DeckFrameRate = (typeof FRAME_RATES)[number]; + export interface RecommendationRequest { format: string; - goal: { kind: string; rank?: number; arenaMusicId?: number; musicIds?: number[]; challengeMusicIds?: number[]; arenaMusicIds?: number[]; multiSong?: "individual" | "mean"; eventParameter?: boolean; musicId?: number; difficulty?: string; challengeMusicId?: number; + goal: { kind: string; rank?: number; ranks?: readonly [number, number, number]; minComboBonus?: number; minJustWindowMs?: number; luckSamples?: number; + arenaMusicId?: number; musicIds?: number[]; challengeMusicIds?: number[]; arenaMusicIds?: number[]; multiSong?: "individual" | "mean"; eventParameter?: boolean; musicId?: number; difficulty?: string; challengeMusicId?: number; arena?: { bandId?: number; musicType?: number; missions?: number[]; mode?: string }; songRules?: { musicType?: number; missions?: number[] }; songs?: { musicId?: number; difficulty?: string }[]; - play?: { kind?: string; greatFraction?: number; justFraction?: number; missEvery?: number; cumulativeTiming?: "best" | "worst" }; - accuracy?: { greatFraction?: number; justFraction?: number } }; + play?: { kind?: string; greatFraction?: number; goodFraction?: number; badFraction?: number; missFraction?: number; justFraction?: number; missEvery?: number; cumulativeTiming?: "best" | "worst"; frameRate?: DeckFrameRate }; + accuracy?: { greatFraction?: number; goodFraction?: number; badFraction?: number; missFraction?: number; justFraction?: number } }; metric?: { kind: string; eventId?: number; consumption?: number; resourceType?: number; resourceId?: number; secondaryPriority?: string; levels?: RewardTermInput[][] }; eventContext?: { rewardProjection?: boolean; resultClock?: { kind?: string; liveStartJstTicks?: number | null; serverNowJstTicks?: number | string } }; - room?: { players: number; othersAverageScore: number | null }; + room?: { mode?: "battle" | "solo"; players: number; othersAverageScore: number | null }; aggregation?: "expected" | "maximum"; eventIds?: number[]; manualEvent?: boolean; eventEffects?: readonly ManualEventEffectInput[]; constraints?: { leader?: number | null; includeMembers?: (number | string)[]; excludeMembers?: (number | string)[]; includeSnaps?: (number | string)[]; excludeSnaps?: (number | string)[]; noSnaps?: boolean; - bindings?: readonly (readonly [number | string, number | string | null])[] }; + bindings?: readonly (readonly [number | string, number | string | null])[]; + minMissionMembers?: readonly [number, number, number] }; k: number; limits?: { timeLimitMs: number | null }; } -export const HANEOKA_COMMIT = "4ed6e0061f519240f19750a3a09024dd27304bd8"; -export const HANEOKA_PATCH_SHA = "1b0634019d439fa4b204452f290810baa4ff25810097b2c3d98d6ac609e0fcf8"; +export const HANEOKA_COMMIT = "7c167c95e8589bc84a9ab726b5b58adbc0ef4b05"; +export const HANEOKA_PATCH_SHA = "f8f5cf1470b0c1cdf9509c947aa92f7f907992ef464ee36ee0c48316196a37ad"; export const CAPABILITIES = { goals: ["power", "freeLive", "challengeLive", "battleLive", "missionLive", "arenaLive", "skip"], metrics: { freeLive: ["score", "eventPoints", "eventItems", "challengePoints"], challengeLive: ["score", "eventPoints", "eventItems"], battleLive: ["score", "eventPoints", "eventItems", "challengePoints"], missionLive: ["score", "eventPoints", "eventItems", "challengePoints"], arenaLive: ["score", "eventPoints", "eventItems", "challengePoints"], skip: ["score", "eventPoints", "eventItems", "challengePoints"] }, @@ -44,9 +50,11 @@ export const CAPABILITIES = { }, combinedMetric: { goals: ["freeLive", "challengeLive", "battleLive", "missionLive", "arenaLive", "skip"], kind: "combined", objective: "lexicographicWeightedExpectedSums", weights: "integer", maxLevels: 3, maxTerms: 9, maxWeight: 1_000_000_000 }, eventItemRewards: { selection: "exactResultGrade", eventGroupField: "eventGroup", rowsPerGrade: 1, probabilityMarker: 10000 }, - crossSong: true, crossSongMean: true, songRules: true, resume: true, arenaRules: true, gekisouRank: true, + crossSong: true, crossSongMean: true, songRules: true, resume: true, arenaRules: true, gekisouRank: true, gekisouRangeRanks: true, manualEventBonuses: true, teamGrowth: true, orderTiming: true, cumulativeTiming: true, - accuracy: { great: true, just: true }, patternPlay: true, luckMissions: true, luckGreatConversions: true, + frameRates: FRAME_RATES, minMissionMembers: true, minComboBonus: true, minJustWindow: true, luckSampling: true, + rewardRoomModes: ["battle", "solo"] as const, + accuracy: { great: true, good: true, bad: true, miss: true, just: true }, patternPlay: true, luckMissions: true, luckGreatConversions: true, }; function validateEventEffects(data: DeckData, raw: readonly ManualEventEffectInput[] | undefined): ManualEventEffectInput[] | null { @@ -90,13 +98,60 @@ function adaptSingleRequest(data: DeckData, request: RecommendationRequest) { || gekiso && request.aggregation === "maximum" && request.metric?.kind === "combined" || request.aggregation === "maximum" && !skip && !["eventPoints", "eventItems", "challengePoints"].includes(request.metric!.kind))) throw new Error("haneoka-unsupported-reward-scene"); - if (rewards && ["battleLive", "arenaLive"].includes(kind) && (!request.room || !Number.isInteger(request.room.players) || request.room.players < 1 || request.room.players > 5 - || request.room.othersAverageScore !== null && (!Number.isInteger(request.room.othersAverageScore) || request.room.othersAverageScore < 0 || request.room.othersAverageScore > 2147483647))) - throw new Error("haneoka-room"); - if (rewards && kind === "missionLive" && request.room !== undefined) throw new Error("haneoka-room"); - if (rewards && kind === "missionLive" && request.goal.rank !== undefined) throw new Error("haneoka-unsupported-rank"); + if (rewards && ["battleLive", "arenaLive"].includes(kind)) { + if (!request.room + || (request.room.mode !== undefined && request.room.mode !== "battle" && request.room.mode !== "solo") + || !Number.isInteger(request.room.players) || request.room.players < 1 || request.room.players > 5 + || request.room.othersAverageScore !== null && (!Number.isInteger(request.room.othersAverageScore) || request.room.othersAverageScore < 0 || request.room.othersAverageScore > MAX_GEKISO_COUNT)) { + throw new Error("haneoka-room"); + } + } + if (rewards && kind === "freeLive" && request.room !== undefined) { + if ((request.room.mode !== undefined && request.room.mode !== "battle" && request.room.mode !== "solo") + || !Number.isInteger(request.room.players) || request.room.players < 1 || request.room.players > 5 + || request.room.othersAverageScore !== null && (!Number.isInteger(request.room.othersAverageScore) || request.room.othersAverageScore < 0 || request.room.othersAverageScore > MAX_GEKISO_COUNT)) { + throw new Error("haneoka-room"); + } + } + if (rewards && !["battleLive", "arenaLive", "freeLive"].includes(kind) && request.room !== undefined) throw new Error("haneoka-room"); + if (rewards && kind === "missionLive" && (request.goal.rank !== undefined || request.goal.ranks !== undefined)) throw new Error("haneoka-unsupported-rank"); if (gekiso && request.goal.rank !== undefined && (!Number.isInteger(request.goal.rank) || request.goal.rank < 1 || request.goal.rank > 5)) throw new Error("haneoka-unsupported-rank"); + const ranks = (() => { + const raw = request.goal.ranks; + if (raw === undefined) return null; + if (!gekiso || !Array.isArray(raw) || raw.length !== 3 || raw.some(v => !Number.isInteger(v) || v < 1 || v > 5)) { + throw new Error("haneoka-unsupported-rank"); + } + if (kind === "missionLive" && raw.some(v => v !== 1)) { + throw new Error("haneoka-unsupported-rank"); + } + return [raw[0]!, raw[1]!, raw[2]!] as [number, number, number]; + })(); const rank = gekiso ? (request.goal.rank ?? 1) : 1; + const minComboBonus = (() => { + const raw = request.goal.minComboBonus; + if (raw === undefined) return null; + if (!gekiso || !Number.isSafeInteger(raw) || raw < 1 || raw > MAX_GEKISO_COUNT) { + throw new Error("haneoka-min-combo-bonus"); + } + return raw > 1 ? raw : null; + })(); + const minJustWindowMs = (() => { + const raw = request.goal.minJustWindowMs; + if (raw === undefined) return null; + if (!gekiso || typeof raw !== "number" || !Number.isFinite(raw) || raw < 0 || raw > MAX_GEKISO_COUNT) { + throw new Error("haneoka-min-just-window"); + } + return raw > 0 ? raw : null; + })(); + const luckSamples = (() => { + const raw = request.goal.luckSamples; + if (raw === undefined) return 0; + if (!gekiso || !Number.isSafeInteger(raw) || raw < 0 || raw > MAX_GEKISO_COUNT) { + throw new Error("haneoka-luck-samples"); + } + return raw; + })(); const parameterEvents = kind === "power" && request.goal.eventParameter ? request.eventIds ?? [] : []; if (parameterEvents.length > 1 || parameterEvents.some(id => !table(data, "MasterEvent").some(event => event.id === id))) throw new Error("haneoka-power-event"); @@ -176,33 +231,94 @@ function adaptSingleRequest(data: DeckData, request: RecommendationRequest) { songs = [song]; } } - const play: { greatFraction?: number; justFraction?: number; missEvery?: number; cumulativeTiming?: "best" | "worst" } = request.goal.play ?? request.goal.accuracy ?? {}; + const play: { + greatFraction?: number; + goodFraction?: number; + badFraction?: number; + missFraction?: number; + justFraction?: number; + missEvery?: number; + cumulativeTiming?: "best" | "worst"; + frameRate?: DeckFrameRate; + } = request.goal.play ?? request.goal.accuracy ?? {}; const great = play.greatFraction ?? 0; + const good = play.goodFraction ?? 0; + const bad = play.badFraction ?? 0; + const miss = play.missFraction ?? 0; const just = play.justFraction ?? 1; - if (![great, just].every(value => typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= 1) - || gekiso && great + just > 1) throw new Error("haneoka-accuracy"); + if (![great, good, bad, miss, just].every(value => typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= 1) + || gekiso && (good > 0 || bad > 0 || miss > 0 || great + just > 1 + 1e-9) + || !gekiso && great + good + bad + miss > 1 + 1e-9) throw new Error("haneoka-accuracy"); const criterion = request.aggregation === "maximum" ? "max" : "mean"; const cumulativeTiming = play.cumulativeTiming; if (cumulativeTiming !== undefined && ( (cumulativeTiming !== "best" && cumulativeTiming !== "worst") || rewards || skip || gekiso || kind === "power" || criterion !== "max" )) throw new Error("haneoka-cumulative-timing"); + const frameRate = play.frameRate; + if (frameRate !== undefined && ( + !(FRAME_RATES as readonly number[]).includes(frameRate) + || skip || gekiso || kind === "power" + )) throw new Error("haneoka-frame-rate"); const scoreGoal = kind === "power" || skip && !rewards ? { kind: skip ? "skip-score" as const : "power" as const, song, challengeEventId, ...(parameterEvents.length ? { parameterEventId: parameterEvents[0], ...(eventEffects ? { eventEffects } : {}) } : skip && challengeEventId !== null && request.eventIds?.includes(challengeEventId) ? { parameterEventId: challengeEventId } : {}) } - : gekiso ? { kind: "gekiso" as const, songs, criterion, rank, seeds: 0, ...(arenaRule ? { arena: arenaRule } : {}), accuracy: { - great, just: great === 1 ? 0 : just / (1 - great), missEvery: play.missEvery ?? 0, - } } + : gekiso ? { + kind: "gekiso" as const, + songs, + criterion, + rank, + ...(ranks ? { ranks } : {}), + ...(minComboBonus ? { minComboBonus } : {}), + ...(minJustWindowMs ? { minJustWindowMs } : {}), + seeds: luckSamples, + ...(arenaRule ? { arena: arenaRule } : {}), + accuracy: { + great, just: great >= 1 ? 0 : Math.min(1, just / (1 - great)), missEvery: play.missEvery ?? 0, + }, + } : { kind: "score" as const, songs, criterion, challengeEventId, play: { - kind: "custom", great, good: 0, bad: 0, miss: 0, missEvery: play.missEvery ?? 0, + kind: "custom", great, good, bad, miss, missEvery: play.missEvery ?? 0, ...(cumulativeTiming ? { cumulativeTiming } : {}), + ...(frameRate !== undefined && frameRate !== 60 ? { frameRate } : {}), } }; const key = (id: unknown, prefix: string) => prefix + integer(typeof id === "string" ? Number(id) : id, "constraint"); const c = request.constraints ?? {}; + const minMissionMembers = (() => { + const raw = c.minMissionMembers; + if (raw === undefined) return null; + if (!Array.isArray(raw) || raw.length !== 3 || raw.some(v => !Number.isInteger(v) || v < 0 || v > 5) || raw[0]! + raw[1]! + raw[2]! > 5) { + throw new Error("haneoka-min-mission-members"); + } + const tuple: [number, number, number] = [raw[0]!, raw[1]!, raw[2]!]; + if (tuple.some(v => v > 0) && !gekiso) throw new Error("haneoka-min-mission-members"); + return tuple.some(v => v > 0) ? tuple : null; + })(); + const normalRewardRoom = rewards && kind === "freeLive" && request.room + ? { + mode: request.room.mode ?? "solo", + players: request.room.players, + ...(request.room.othersAverageScore !== null ? { otherAverageScore: request.room.othersAverageScore } : {}), + } + : undefined; return { - goal: rewards ? { ...eventGoal(data, request, song!, challengeEventId, "play" in scoreGoal ? scoreGoal.play : null, eventEffects), + goal: rewards ? { + ...eventGoal(data, request, song!, challengeEventId, "play" in scoreGoal ? scoreGoal.play : null, eventEffects), + ...(normalRewardRoom ? { rewardRoom: normalRewardRoom } : {}), ...(arenaRule ? { arena: arenaRule } : {}), - ...(scoreGoal.kind === "gekiso" ? { gekiso: { scene: kind, rank, accuracy: scoreGoal.accuracy }, room: request.room } : {}) } : scoreGoal, charts, + ...(scoreGoal.kind === "gekiso" ? { + gekiso: { + scene: kind, + rank, + ...(ranks ? { ranks } : {}), + ...(minComboBonus ? { minComboBonus } : {}), + ...(minJustWindowMs ? { minJustWindowMs } : {}), + ...(luckSamples > 0 ? { seeds: luckSamples } : {}), + accuracy: scoreGoal.accuracy, + }, + room: request.room, + } : {}), + } : scoreGoal, charts, constraints: { requiredMembers: (c.includeMembers ?? []).map((id: unknown) => key(id, "m")), excludedMembers: (c.excludeMembers ?? []).map((id: unknown) => key(id, "m")), @@ -211,6 +327,7 @@ function adaptSingleRequest(data: DeckData, request: RecommendationRequest) { leader: c.leader == null ? null : key(c.leader, "m"), noSnaps: c.noSnaps === true, bindings: (c.bindings ?? []).map(([m, s]) => [key(m, "m"), s == null ? null : key(s, "s")] as [string, string | null]), + ...(minMissionMembers ? { minMissionMembers } : {}), minBonusPercent: null, }, ...(songRules ? { songRules } : {}), diff --git a/src/lib/deck/haneoka/solver.ts b/src/lib/deck/haneoka/solver.ts index a5633e91..76d117c6 100644 --- a/src/lib/deck/haneoka/solver.ts +++ b/src/lib/deck/haneoka/solver.ts @@ -20,17 +20,30 @@ const ORDERS = orders(); function id(key: string): number { return Number(key.slice(1)); } interface EngineTimingStep { step: number; timeMs: number; notes: number; noteIndices?: number[]; gain: number } interface EngineTimingWindow { event: number; startMs: number; endMs: number; unit: number; notes: number[]; steps: EngineTimingStep[] } -interface EngineOrderTiming { score: number; best: number; worst?: number; optimal?: number; advanceMs: number; windows: EngineTimingWindow[]; orderIndex?: number } +interface EngineOrderTiming { score: number; best: number; worst?: number; optimal?: number; guideAvailable?: boolean; advanceMs: number; windows: EngineTimingWindow[]; orderIndex?: number } interface EngineAggregateSong { song: { songId: number; difficulty: number; baseSongId?: number }; power: number; score: { mean: number; min: number; median: number; max: number }; } -interface EngineHit { song?: { songId: number; difficulty: number; baseSongId?: number; arenaMusicId?: number } | null; aggregate?: { kind: "mean"; songs: EngineAggregateSong[] }; lexVector?: string[]; members: string[]; snaps: (string | null)[]; power: number; key: number; skipScore?: number; score?: { orders: number[]; mean?: number } | null; event?: { mean: number; rewardSum: number | string; orders: number; selectedOrder?: number; sampled?: boolean; lower?: number; upper?: number }; levels?: { sum: string; terms: string[]; lower?: number; upper?: number; termBounds?: { lower: number; upper: number }[] }[] } +interface EngineHit { song?: { songId: number; difficulty: number; baseSongId?: number; arenaMusicId?: number } | null; aggregate?: { kind: "mean"; songs: EngineAggregateSong[] }; lexVector?: string[]; leader?: string; members: string[]; snaps: (string | null)[]; power: number; key: number; comboBonus?: number; justWindow?: { ms?: number; baseMs?: number; percent?: number } | null; skipScore?: number; score?: { orders: number[]; mean?: number } | null; gekiso?: { luckSamples?: number; sampled?: boolean }; event?: { mean: number; rewardSum: number | string; orders: number; selectedOrder?: number; sampled?: boolean; luckSamples?: number; lower?: number; upper?: number }; levels?: { sum: string; terms: string[]; lower?: number; upper?: number; termBounds?: { lower: number; upper: number }[] }[] } interface EngineResponse { results: { proven: boolean; bound: number | null; sampled?: boolean; hits?: EngineHit[] }[]; overall: EngineHit[]; elapsedMs: number | null } -function answer(response: EngineResponse, request: RecommendationRequest, coversAllOwnedCards: boolean, final: boolean, coverage?: number, seeds?: Pick[], timings?: ReadonlyMap) { +function normalizeLeaderFirst(hit: Pick): { members: string[]; snaps: (string | null)[] } { + const leaderKey = hit.leader ?? hit.members[0]; + const leaderIdx = leaderKey ? hit.members.indexOf(leaderKey) : 0; + if (leaderIdx <= 0) return { members: hit.members, snaps: hit.snaps }; + const order = [leaderIdx, ...[0, 1, 2, 3, 4].filter(i => i !== leaderIdx)]; + return { + members: order.map(i => hit.members[i]!), + snaps: order.map(i => hit.snaps[i] ?? null), + }; +} +function answer(response: EngineResponse, request: RecommendationRequest, coversAllOwnedCards: boolean, final: boolean, coverage?: number, seeds?: Pick[], timings?: ReadonlyMap, sampledScoreSeeds = 0) { const rows = response.results ?? []; - const sampled = (response.overall ?? []).some(hit => hit.event?.sampled === true) || rows.some(row => row.sampled === true); + const sampled = sampledScoreSeeds > 0 || (response.overall ?? []).some(hit => hit.event?.sampled === true || hit.gekiso?.sampled === true) || rows.some(row => row.sampled === true); + const sampleCount = sampledScoreSeeds > 0 + ? sampledScoreSeeds + : (response.overall ?? [])[0]?.event?.luckSamples ?? (response.overall ?? [])[0]?.gekiso?.luckSamples ?? (request.goal.luckSamples && request.goal.luckSamples > 0 ? request.goal.luckSamples : 4); const proven = final && !sampled && rows.length > 0 && rows.every(row => row.proven === true); const teams = (response.overall ?? []).map((hit, index) => { const members: number[] = hit.members.map(id), snaps: (number | null)[] = hit.snaps.map((key: string | null) => key === null ? null : id(key)); @@ -72,6 +85,7 @@ function answer(response: EngineResponse, request: RecommendationRequest, covers best: rawTiming.best, ...(typeof rawTiming.worst === "number" && Number.isFinite(rawTiming.worst) ? { worst: rawTiming.worst } : {}), ...(typeof rawTiming.optimal === "number" && Number.isFinite(rawTiming.optimal) ? { optimal: rawTiming.optimal } : {}), + ...(typeof rawTiming.guideAvailable === "boolean" ? { guideAvailable: rawTiming.guideAvailable } : {}), advanceMs: rawTiming.advanceMs, windows: rawTiming.windows.map(win => ({ event: win.event, @@ -123,7 +137,7 @@ function answer(response: EngineResponse, request: RecommendationRequest, covers coverage: { total: coverageTotal, covered: coverage ?? rows.filter(row => row.hits?.length).length, proven: rows.filter(row => row.proven).length } } : {}), goal: request.goal, metric: request.metric ?? { kind: "power" }, aggregation: request.aggregation ?? "expected", - ...(sampled ? { rewardSampling: { kind: "fixedRoots", rootSeeds: [0, 1, 2, 3] } } : {}), + ...(sampled ? { rewardSampling: { kind: "fixedRoots", rootSeeds: Array.from({ length: Math.min(256, Math.max(1, sampleCount)) }, (_, i) => i) } } : {}), exitReason: final && sampled ? "sampled" : final && !proven ? "timeLimit" : proven ? "complete" : null }, }); } @@ -214,7 +228,8 @@ export class DeckSolver { if (retainRewards) warm.splice(0, warm.length, ...hits); if (!progress || now - last < intervalMs) return; last = now; - progress(answer({ overall: hits, results: [], elapsedMs: performance.now() - started }, request, box.coversAllOwnedCards, false, covered.size, [...candidates.values()])); + const sampledScoreSeeds = converted.goal.kind === "gekiso" && luck && converted.goal.seeds > 0 ? converted.goal.seeds : 0; + progress(answer({ overall: hits, results: [], elapsedMs: performance.now() - started }, request, box.coversAllOwnedCards, false, covered.size, [...candidates.values()], undefined, sampledScoreSeeds)); }; try { const prepared: ReturnType & { members: typeof box.members; snaps: typeof box.snaps; player: typeof box.player; @@ -262,7 +277,14 @@ export class DeckSolver { op: "evaluateAll", handle: this.handle, charts: [converted.charts[0]!], request: { ...prepared, ...(converted.songRules ? { songRules: converted.songRules } : {}), team: { members: team.members, snaps: team.snaps, leader: team.members[0] }, songs: [song], - gekiso: { accuracy: converted.goal.accuracy, rank: converted.goal.rank, seeds: 0, ...(arena ? { arena } : {}) }, event: null } }).evaluations[0]!; + gekiso: { + accuracy: converted.goal.accuracy, + rank: converted.goal.rank, + ...("ranks" in converted.goal && converted.goal.ranks ? { ranks: converted.goal.ranks } : {}), + seeds: converted.goal.seeds, + ...(arena ? { arena } : {}), + }, + event: null } }).evaluations[0]!; } catch (error) { if (error instanceof Error && error.message === "search-deadline") break; throw error; @@ -273,7 +295,8 @@ export class DeckSolver { for (const hit of warm) if (hit.song) covered.add(JSON.stringify(hit.song)); if (progress && warm.length && performance.now() - last >= intervalMs) { last = performance.now(); - progress(answer({ results: [], overall: mergeWarm([]), elapsedMs: performance.now() - started }, request, box.coversAllOwnedCards, false, covered.size, [...candidates.values()])); + const sampledScoreSeeds = converted.goal.seeds > 0 ? converted.goal.seeds : 0; + progress(answer({ results: [], overall: mergeWarm([]), elapsedMs: performance.now() - started }, request, box.coversAllOwnedCards, false, covered.size, [...candidates.values()], undefined, sampledScoreSeeds)); } } prepared.seeds = [...(prepared.seeds ?? []), ...warm]; @@ -304,6 +327,7 @@ export class DeckSolver { : converted.charts.length === 1 ? converted.charts[0] : undefined; if (!chartEntry) continue; try { + const normalized = normalizeLeaderFirst(hit); const timeline = engine!.invoke<{ timing?: EngineOrderTiming | null }>({ op: "timeline", handle: this.handle, @@ -314,8 +338,8 @@ export class DeckSolver { members: box.members, snaps: box.snaps, unknownPolicy: "min", - memberKeys: hit.members, - snapKeys: hit.snaps, + memberKeys: normalized.members, + snapKeys: normalized.snaps, order: ORDERS[orderIndex]!, play: converted.goal.play, timing: { @@ -338,7 +362,8 @@ export class DeckSolver { } } response.elapsedMs = performance.now() - started; - return answer(response, request, box.coversAllOwnedCards, true, undefined, [...candidates.values()], timings); + const sampledScoreSeeds = converted.goal.kind === "gekiso" && luck && converted.goal.seeds > 0 ? converted.goal.seeds : 0; + return answer(response, request, box.coversAllOwnedCards, true, undefined, [...candidates.values()], timings, sampledScoreSeeds); } catch (error) { if (!(error instanceof Error) || !error.message.startsWith("moenotes-") && error.message !== "haneoka-continuation" && !["combined-reward-levels", "combined-reward-range", "combined-item-lottery", "skip-score-nonmonotone"].includes(error.message)) throw error; return JSON.stringify({ format: "ournotes-deck.account-recommendation/1", final: true, status: "invalid", missing: [], diff --git a/src/lib/masterdata/build-data.ts b/src/lib/masterdata/build-data.ts index d985cb23..6e9a2dae 100644 --- a/src/lib/masterdata/build-data.ts +++ b/src/lib/masterdata/build-data.ts @@ -251,13 +251,16 @@ export interface StoryReaderLookup { export function cardsOn(server: GameServer, locale: AppLocale): Promise { return memo(`cards:${server}:${locale}`, async () => { - const [cards, characters, bands, textTable] = await Promise.all([ + const [cards, characters, bands, textTable, gekisouSkills] = await Promise.all([ table("MasterMemberCard.json", server), table("MasterCharacter.json", server), table("MasterBand.json", server), texts(server), + table<{ id: number; gekisouMissionType: number }>("MasterGekisouSkill.json", server).catch(() => ({ + _allData: [] as Array<{ id: number; gekisouMissionType: number }>, + })), ]); - return normalizeCards(cards._allData, characters._allData, bands._allData, textTable._allData, locale); + return normalizeCards(cards._allData, characters._allData, bands._allData, textTable._allData, locale, gekisouSkills._allData); }); } diff --git a/src/lib/masterdata/deck-master.ts b/src/lib/masterdata/deck-master.ts index 395ceb1d..59ece4f4 100644 --- a/src/lib/masterdata/deck-master.ts +++ b/src/lib/masterdata/deck-master.ts @@ -200,6 +200,10 @@ export const DECK_MASTER_TABLE_SPECS: Readonly> = "_noteSimulateJudgement", "_beforeMs", "_afterMs", ], }, + MasterLiveJudgementAreaOffset: { + columns: ["_id", "_offsetType", "_assistLevel", "_x"], + optionalTable: true, + }, MasterLiveComboScoreBonus: { columns: ["_id", "_comboBonusType", "_requiredComboCount", "_bonusFactor"], }, diff --git a/tests/deck-arena-and-haneoka.test.tsx b/tests/deck-arena-and-haneoka.test.tsx index 49ae924e..da3ad133 100644 --- a/tests/deck-arena-and-haneoka.test.tsx +++ b/tests/deck-arena-and-haneoka.test.tsx @@ -370,7 +370,7 @@ describe("Haneoka patch identity and 1.1.0.100 Arena + cumulative Snap adaptatio const patchBytes = readFileSync(join(process.cwd(), "patches/haneoka-rewards.patch")); const actualSha = createHash("sha256").update(patchBytes).digest("hex"); expect(actualSha).toBe(HANEOKA_PATCH_SHA); - expect(HANEOKA_COMMIT).toBe("4ed6e0061f519240f19750a3a09024dd27304bd8"); + expect(HANEOKA_COMMIT).toBe("7c167c95e8589bc84a9ab726b5b58adbc0ef4b05"); const { data, datasetId } = loadReal110Slice(); const engineData = adaptData(data, datasetId); diff --git a/tests/deck-haneoka-sync-7c167c95.test.tsx b/tests/deck-haneoka-sync-7c167c95.test.tsx new file mode 100644 index 00000000..26964c75 --- /dev/null +++ b/tests/deck-haneoka-sync-7c167c95.test.tsx @@ -0,0 +1,508 @@ +import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, test } from "bun:test"; +import { renderToStaticMarkup } from "react-dom/server"; +import { Md3Provider } from "@/components/md3/Md3Provider"; +import DeckResult from "@/components/deck/DeckResult"; +import DeckTeamGrowthPanel from "@/components/deck/DeckTeamGrowthPanel"; +import type { GameSaveTables } from "@/lib/box/game-save"; +import { createBox, createCard, type CardBox } from "@/lib/box/model"; +import { boxAccountJson } from "@/lib/deck/account-envelope"; +import { parseDeckAnswer } from "@/lib/deck/answer"; +import { reconcileCardSelections } from "@/lib/deck/card-selection"; +import { checkGekisoConstraintFeasibility, MAX_GEKISO_COUNT } from "@/lib/deck/feasibility"; +import { + computes, + defaultDeckGoalInput, + effectiveMinJustWindowMs, + parseCapabilities, + recommendationRequest, + type DeckEvent, +} from "@/lib/deck/goals"; +import type { DeckData } from "@/lib/deck/haneoka/data"; +import { evaluateTeamGrowth, type GrowthInvoker } from "@/lib/deck/haneoka/growth"; +import { adaptRequest, CAPABILITIES } from "@/lib/deck/haneoka/request"; +import { buildTheoreticalMaxBox } from "@/lib/deck/theoretical-box"; + +const SAMPLE_EVENT: DeckEvent = { + id: 50, + name: "Sync Event", + startAt: "2026/09/01 15:00:00", + endAt: "2026/09/09 20:59:59", + itemId: 90, + challengeMusics: [], +}; + +function loadFixtureWithEvent(): { data: DeckData; datasetId: string; saveTables: GameSaveTables } { + const rawBytes = new Uint8Array(readFileSync(join(process.cwd(), "tests/fixtures/haneoka-110-slice.json"))); + const data = JSON.parse(new TextDecoder().decode(rawBytes)) as DeckData; + data.master.MasterEvent = { + columns: ["_id", "_eventType", "_startAt", "_endAt", "_liveEventPointGroup", "_liveEventRewardGroup", "_eventItemId"], + rows: [[50, 1, "2026/09/01 15:00:00", "2026/09/09 20:59:59", 1, 1, 90]], + }; + data.master.MasterLiveEventPoint = { + columns: ["_id", "_group", "_scoreRank", "_value"], + rows: [[1, 1, 1, 100], [2, 1, 2, 120]], + }; + data.master.MasterLiveEventReward = { + columns: ["_id", "_eventGroup", "_scoreRank", "_probability", "_resourceType", "_resourceId", "_resourceCount"], + rows: [[1, 1, 1, 10000, 1, 90, 5], [2, 1, 2, 10000, 1, 90, 6]], + }; + const bytes = new TextEncoder().encode(JSON.stringify(data)); + const datasetId = createHash("sha256").update(bytes).digest("hex"); + const saveTables: GameSaveTables = { + server: "jp", + characters: Array.from({ length: 25 }, (_, i) => String(i + 1)), + characterRanks: [[1, 0], [2, 1]], + members: Object.fromEntries([51, 52, 53, 54, 55].map((id) => [String(id), 3])), + memberLevels: { "3": [[1, 0], [60, 321650]] }, + snaps: { "76": 3 }, + snapLevels: { "3": [[1, 0], [60, 321650]] }, + bandItems: {}, + memories: {}, + musicRanks: {}, + }; + return { data, datasetId, saveTables }; +} + +describe("Haneoka 7c167c95 sync — timing extrema, frame rates, accuracy, Gekiso constraints & Box reconciliation", () => { + test("OrderTimingPanel computes planDelta against nominal score, formats dynamic advanceMs, and handles guideAvailable=false", () => { + const rawAnswer = { + format: "ournotes-deck.account-recommendation/1", + final: true, + status: "ok", + missing: [], + errors: [], + result: { + phase: "done", + elapsedMs: 42, + optimality: { proven: true, lowerBound: 1050000, upperBound: 1050000, fraction: null }, + account: { cards: { coversAllOwnedCards: true } }, + metric: { kind: "score" }, + aggregation: "maximum", + goal: { kind: "freeLive" }, + exitReason: "complete", + teams: [ + { + rank: 1, + rankCertified: true, + leader: { member: 51, snap: 76 }, + others: [ + { member: 52, snap: null }, + { member: 53, snap: null }, + { member: 54, snap: null }, + { member: 55, snap: null }, + ], + power: 250000, + value: { score: 1050000, exact: { numerator: "1050000", denominator: "1" }, interval: null, payoff: null }, + orders: null, + bestOrder: { score: 1050000, order: [51, 52, 53, 54, 55] }, + timing: { + order: [51, 52, 53, 54, 55], + worst: 990000, + score: 1000000, + best: 1035000, + optimal: 1050000, + guideAvailable: true, + advanceMs: 9, + windows: [ + { + event: 0, + member: 51, + startMs: 12000, + endMs: 17000, + unit: 20, + notes: [12500, 13000], + steps: [{ step: 1, timeMs: 13000, notes: 1, noteIndices: [14], gain: 35000 }], + }, + ], + }, + layout: { members: [52, 53, 51, 54, 55], snaps: [null, null, 76, null, null] }, + }, + ], + }, + }; + const parsed = parseDeckAnswer(JSON.stringify(rawAnswer)); + expect(parsed.result?.teams[0]?.timing?.advanceMs).toBe(9); + expect(parsed.result?.teams[0]?.timing?.guideAvailable).toBe(true); + + const html120Hz = renderToStaticMarkup( + + {}} + onRerun={() => {}} + songNames={{}} + onEditCard={() => {}} + onPlayer={() => {}} + onAnswerAll={() => {}} + /> + , + ); + // 120 Hz -> 9 ms early plan, and planDelta = 1,035,000 - 1,000,000 = +35,000 (NOT 1,035,000 - 990,000 = +45,000!) + expect(html120Hz).toContain("提前 9 ms 方案"); + expect(html120Hz).toContain("+35,000"); + expect(html120Hz).not.toContain("+45,000"); + + // When chart-time inputs collide (`guideAvailable: false`), suppress steps and show collision notice + const blockedRaw = structuredClone(rawAnswer); + blockedRaw.result.teams[0]!.timing.guideAvailable = false; + blockedRaw.result.teams[0]!.timing.best = 1000000; + const parsedBlocked = parseDeckAnswer(JSON.stringify(blockedRaw)); + const blockedHtml = renderToStaticMarkup( + + {}} + onRerun={() => {}} + songNames={{}} + onEditCard={() => {}} + onPlayer={() => {}} + onAnswerAll={() => {}} + /> + , + ); + expect(blockedHtml).toContain("原谱面时刻已存在同轨输入冲突"); + expect(blockedHtml).not.toContain("凹分教程"); + + const sampledRaw = structuredClone(rawAnswer); + sampledRaw.result.optimality.proven = false; + sampledRaw.result.exitReason = "sampled"; + const sampledHtml = renderToStaticMarkup( + + {}} + onRerun={() => {}} + songNames={{}} + onEditCard={() => {}} + onPlayer={() => {}} + onAnswerAll={() => {}} + /> + , + ); + expect(sampledHtml).toContain("随机抽样评估 · 当前最优(未证明)"); + }); + + test("reconcileCardSelections preserves leader, required, and excluded cards across Box updates and Theoretical Max toggles", () => { + const { saveTables } = loadFixtureWithEvent(); + const m51 = createCard("member", "box-a-51", "51", 1); + const m52 = createCard("member", "box-a-52", "52", 1); + const m53 = createCard("member", "box-a-53", "53", 1); + const s76 = createCard("snap", "box-a-76", "76", 1); + const boxA: CardBox = { ...createBox("jp", "b1", 1), cards: [m51, m52, m53, s76] }; + + const theoryBox = buildTheoreticalMaxBox( + boxA, + "jp", + { + members: [51, 52, 53, 54, 55].map((id, idx) => ({ id, rarity: 4, characterId: idx + 1 }) as never), + snaps: [{ id: 76, rarity: 4 } as never], + }, + saveTables, + ); + + const toTheory = reconcileCardSelections(boxA.cards, theoryBox, { + leaderKey: "box-a-51", + required: new Set(["box-a-52", "box-a-76"]), + excluded: new Set(["box-a-53"]), + }); + expect(toTheory.leaderKey).toBe("theory-m-51"); + expect([...toTheory.required]).toEqual(["theory-m-52", "theory-s-76"]); + expect([...toTheory.excluded]).toEqual(["theory-m-53"]); + + const backToOwned = reconcileCardSelections(theoryBox.cards, boxA, toTheory); + expect(backToOwned.leaderKey).toBe("box-a-51"); + expect([...backToOwned.required]).toEqual(["box-a-52", "box-a-76"]); + expect([...backToOwned.excluded]).toEqual(["box-a-53"]); + }); + + test("adapts frameRate, Good/Bad/Miss accuracy, per-range Gekiso ranks, minComboBonus, minJustWindowMs, luckSamples, minMissionMembers, and rewardRoom", () => { + const { data } = loadFixtureWithEvent(); + const caps = parseCapabilities(JSON.stringify(CAPABILITIES)); + expect(caps).not.toBeNull(); + expect(caps!.frameRates).toEqual([30, 60, 90, 120]); + expect(caps!.gekisouRangeRanks).toBe(true); + expect(caps!.minMissionMembers).toBe(true); + expect(caps!.minComboBonus).toBe(true); + expect(caps!.minJustWindow).toBe(true); + expect(caps!.luckSampling).toBe(true); + + // 1. Frame rate + Good/Bad/Miss custom accuracy on normal Free Live + const freeInput = { + ...defaultDeckGoalInput("free"), + musicId: 100001, + difficulty: "expert" as const, + frameRate: 120 as const, + greatPercent: 5, + goodPercent: 3, + badPercent: 2, + missPercent: 1, + }; + expect(computes(caps!, freeInput)).toBe(true); + const freeReq = JSON.parse( + recommendationRequest(freeInput, { + event: null, + now: Date.parse("2026-09-05T00:00:00Z"), + constraints: { includeMembers: [], excludeMembers: [], excludeSnaps: [] }, + }), + ); + const adaptedFree = adaptRequest(data, freeReq); + expect(adaptedFree.goal).toMatchObject({ + kind: "score", + play: { kind: "custom", great: 0.05, good: 0.03, bad: 0.02, miss: 0.01, frameRate: 120 }, + }); + + // 2. Gekiso Battle Live with per-range placements, minComboBonus, +% JUST window, manual luckSamples, and minMissionMembers + const battleInput = { + ...defaultDeckGoalInput("battle"), + musicId: 100001, + difficulty: "expert" as const, + gekisouRank: 1, + gekisouRanks: [1, 3, 2] as const, + minComboBonus: 4, + justWindowUnit: "percent" as const, + justWindowPercent: 20, + baseJustWindowMs: 25, + luckSamplingMode: "manual" as const, + luckSamples: 16, + }; + expect(effectiveMinJustWindowMs(battleInput)).toBe(30); + expect(computes(caps!, battleInput)).toBe(true); + const battleReq = JSON.parse( + recommendationRequest(battleInput, { + event: null, + now: Date.parse("2026-09-05T00:00:00Z"), + constraints: { + includeMembers: [], + excludeMembers: [], + excludeSnaps: [], + minMissionMembers: [2, 1, 2], + }, + }), + ); + const adaptedBattle = adaptRequest(data, battleReq); + expect(adaptedBattle.goal).toMatchObject({ + kind: "gekiso", + rank: 1, + ranks: [1, 3, 2], + minComboBonus: 4, + minJustWindowMs: 30, + seeds: 16, + }); + expect(adaptedBattle.constraints.minMissionMembers).toEqual([2, 1, 2]); + + // Reject sum > 5 or out-of-range Gekiso integer inputs + expect(() => + adaptRequest(data, { + ...battleReq, + constraints: { ...battleReq.constraints, minMissionMembers: [2, 2, 2] }, + }), + ).toThrow("haneoka-min-mission-members"); + expect(() => + adaptRequest(data, { + ...battleReq, + goal: { ...battleReq.goal, minComboBonus: MAX_GEKISO_COUNT + 1 }, + }), + ).toThrow("haneoka-min-combo-bonus"); + + // 3. Configurable Event Reward Room grading (`solo` on Battle Live & `battle` with 4 players on Free Live) + const soloBattleRewardReq = JSON.parse( + recommendationRequest( + { + ...defaultDeckGoalInput("eventPoints"), + venue: "battleLive", + musicId: 100001, + rewardRoomMode: "solo", + }, + { + event: SAMPLE_EVENT, + now: Date.parse("2026-09-05T00:00:00Z"), + constraints: { includeMembers: [], excludeMembers: [], excludeSnaps: [] }, + }, + ), + ); + expect(soloBattleRewardReq.room).toEqual({ mode: "solo", players: 1, othersAverageScore: null }); + expect(adaptRequest(data, soloBattleRewardReq).goal).toMatchObject({ + kind: "event", + room: { mode: "solo", players: 1, othersAverageScore: null }, + }); + + const battleFreeRewardReq = JSON.parse( + recommendationRequest( + { + ...defaultDeckGoalInput("eventPoints"), + venue: "freeLive", + musicId: 100001, + rewardRoomMode: "battle", + rewardPlayers: 4, + othersAverageScore: 950000, + }, + { + event: SAMPLE_EVENT, + now: Date.parse("2026-09-05T00:00:00Z"), + constraints: { includeMembers: [], excludeMembers: [], excludeSnaps: [] }, + }, + ), + ); + expect(adaptRequest(data, battleFreeRewardReq).goal).toMatchObject({ + kind: "event", + rewardRoom: { mode: "battle", players: 4, otherAverageScore: 950000 }, + }); + }); + + test("checkGekisoConstraintFeasibility validates distinct-character mission counts and ALL-mission cards", () => { + const cards = [51, 52, 53, 54, 55].map((id) => createCard("member", `k${id}`, String(id), 1)); + const box: CardBox = { ...createBox("jp", "b-feas", 1), cards }; + // 51: COMBO (1), 52: ALL (4), 53: LUCK (2), 54: JUST (3), 55: COMBO (1) + const catalog = new Map([ + [51, { id: 51, characterId: 1, gekisouMissionType: 1 }], + [52, { id: 52, characterId: 2, gekisouMissionType: 4 }], + [53, { id: 53, characterId: 3, gekisouMissionType: 2 }], + [54, { id: 54, characterId: 4, gekisouMissionType: 3 }], + [55, { id: 55, characterId: 5, gekisouMissionType: 1 }], + ]); + + // 3 COMBO (51, 55 + ALL 52) + 2 LUCK (53 + ALL 52) is individually reachable per type! + expect( + checkGekisoConstraintFeasibility({ + box, + memberCatalog: catalog, + constraints: { includeMembers: [], excludeMembers: [], minMissionMembers: [3, 2, 0] }, + }), + ).toBeNull(); + + // Requiring 3 JUST when only 54 (JUST) and 52 (ALL) exist (2 distinct characters) is unreachable! + expect( + checkGekisoConstraintFeasibility({ + box, + memberCatalog: catalog, + constraints: { includeMembers: [], excludeMembers: [], minMissionMembers: [0, 0, 3] }, + }), + ).toEqual({ + code: "mission_members_unreachable", + missionIndex: 2, + required: 3, + available: 2, + }); + }); + + test("evaluateTeamGrowth emits cap scenarios (awakeCap, rankCap, liveSkillCap) and DeckTeamGrowthPanel renders MD3 category chips", () => { + const { data, datasetId, saveTables } = loadFixtureWithEvent(); + const fullBox = buildTheoreticalMaxBox( + null, + "jp", + { + members: [51, 52, 53, 54, 55].map((id, idx) => ({ id, rarity: 4, characterId: idx + 1 }) as never), + snaps: [{ id: 76, rarity: 4 } as never], + }, + saveTables, + ); + const customBox: CardBox = { + ...fullBox, + cards: fullBox.cards.map((card) => + card.identity.value === "51" + ? { + ...card, + fields: { + ...card.fields, + level: { ...card.fields.level, value: 1 }, + awake: { ...card.fields.awake, value: 1 }, + rank: { ...card.fields.rank, value: 1 }, + liveSkillLevel: { ...card.fields.liveSkillLevel, value: 1 }, + }, + } + : card, + ), + }; + + const accountJson = boxAccountJson({ datasetId, server: "jp" }, customBox, saveTables, { fallbackBandItems: true }); + const requestJson = recommendationRequest( + { ...defaultDeckGoalInput("free"), musicId: 100001, difficulty: "expert" }, + { event: null, now: 0, constraints: { includeMembers: [], excludeMembers: [], excludeSnaps: [] } }, + ); + const mockInvoker: GrowthInvoker = { + run(_charts, payload) { + const members = payload.members as { level: number; awake: number; rank: number; liveSkillLevel: number }[]; + const power = members.reduce((sum, m) => sum + m.level * 100 + m.awake * 500 + m.rank * 800, 0); + const meanScore = power * 3 + members.reduce((sum, m) => sum + m.liveSkillLevel * 2000, 0); + return { + overall: [ + { + members: ["m51", "m52", "m53", "m54", "m55"], + snaps: ["s76", null, null, null, null], + power, + key: meanScore, + score: { mean: meanScore, orders: Array.from({ length: 120 }, () => meanScore) }, + }, + ], + }; + }, + }; + + const report = JSON.parse( + evaluateTeamGrowth( + data, + datasetId, + mockInvoker, + accountJson, + requestJson, + JSON.stringify({ + members: [51, 52, 53, 54, 55], + snaps: [76, null, null, null, null], + song: { musicId: 100001, difficulty: "expert" }, + }), + ), + ); + const actions = new Set(report.scenarios.map((s: { action: string }) => s.action)); + expect(actions.has("awakeCap")).toBe(true); + expect(actions.has("rankCap")).toBe(true); + expect(actions.has("liveSkillCap")).toBe(true); + expect(actions.has("full")).toBe(true); + + const html = renderToStaticMarkup( + + {}} + memberById={new Map()} + snapById={new Map()} + /> + , + ); + expect(html).toContain("单项满级"); + expect(html).toContain("全部属性"); + expect(html).toContain("技能"); + expect(html).not.toContain(" { - test("pins Haneoka commit 4ed6e00 and exposes orderTiming + cumulativeTiming capabilities", () => { - expect(HANEOKA_COMMIT).toBe("4ed6e0061f519240f19750a3a09024dd27304bd8"); + test("pins Haneoka commit 7c167c95 and exposes orderTiming + cumulativeTiming capabilities", () => { + expect(HANEOKA_COMMIT).toBe("7c167c95e8589bc84a9ab726b5b58adbc0ef4b05"); expect(CAPABILITIES.orderTiming).toBe(true); expect(CAPABILITIES.cumulativeTiming).toBe(true); }); @@ -193,7 +193,7 @@ describe("Haneoka 4ed6e00 cumulative-Snap OrderTiming, cumulativeTiming goals an expect(zhHtml).toContain("名义时序分数"); expect(zhHtml).toContain("最佳时序分数"); expect(zhHtml).toContain("提前 17 ms 方案"); - expect(zhHtml).toContain("+8,220"); + expect(zhHtml).toContain("+6,420"); expect(zhHtml).toContain("凹分教程"); expect(zhHtml).toContain('
'); expect(zhHtml).toContain("提前约 17 ms");