From 13573356474eb633882e8094b6a4fc7e194b37f8 Mon Sep 17 00:00:00 2001 From: nichinichisou Date: Sun, 18 Oct 2026 20:28:38 +0800 Subject: [PATCH] fix(sim): leave non-playable characters out of the character list MasterCharacter rows read the optional `_isNonPlayable` column (absent means false). A player save lists only playable characters, so account resolution no longer requires an unlisted non-playable character under a partial character list, and complete coverage leaves it out of the total character rank. A listed non-playable character is read like any other. The owned-snapshot input applies the same rule to its rank checks. Co-Authored-By: Claude Opus 5.5 --- crates/ournotes-search/src/owned_snapshot.rs | 5 +- crates/ournotes-sim/src/account.rs | 14 ++++-- crates/ournotes-sim/src/master.rs | 3 +- crates/ournotes-sim/tests/account.rs | 48 ++++++++++++++++++++ docs/account-input.md | 3 ++ 5 files changed, 67 insertions(+), 6 deletions(-) diff --git a/crates/ournotes-search/src/owned_snapshot.rs b/crates/ournotes-search/src/owned_snapshot.rs index f7540846..01e763df 100644 --- a/crates/ournotes-search/src/owned_snapshot.rs +++ b/crates/ournotes-search/src/owned_snapshot.rs @@ -548,7 +548,8 @@ impl OwnedSnapshot { if sum.is_some_and(|sum| total < sum) { out.error("player.characterTotalRank", "rank_conflict", "total is below the known local-rank sum"); } - let absent = master.characters.iter().filter(|row| !ranks.contains_key(&row.id)).count(); + let absent = + master.characters.iter().filter(|row| !row.is_non_playable && !ranks.contains_key(&row.id)).count(); let minimum_rank = master.character_ranks.iter().map(|row| row.rank).filter(|rank| *rank > 0).min(); if let (Some(sum), Some(minimum_rank)) = (sum, minimum_rank) { let minimum = i64::try_from(absent) @@ -579,7 +580,7 @@ impl OwnedSnapshot { } } if self.player.character_ranks.coverage == Coverage::Complete { - if master.characters.iter().any(|row| !ranks.contains_key(&row.id)) { + if master.characters.iter().any(|row| !row.is_non_playable && !ranks.contains_key(&row.id)) { out.error("player.characterRanks", "coverage_conflict", "complete coverage is missing characters"); } if let (Some(sum), Some(total)) = (sum, total) diff --git a/crates/ournotes-sim/src/account.rs b/crates/ournotes-sim/src/account.rs index d7aff96a..691509b0 100644 --- a/crates/ournotes-sim/src/account.rs +++ b/crates/ournotes-sim/src/account.rs @@ -845,8 +845,10 @@ impl AccountInput { out.label_since(mark, &format!("snap _masterId {id}")); } - // Characters: the rank each reaches with its experience; under complete coverage an unlisted character is at - // experience 0, so every character counts in the total rank. + // Characters: the rank each reaches with its experience; under complete coverage an unlisted playable character + // is at experience 0, so every playable character counts in the total rank. A save lists only playable + // characters, so non-playable ones are neither required nor counted when unlisted. A listed non-playable + // character is read like any other. let mut character_ranks = BTreeMap::new(); let character_items = out.identities( "_player._characters", @@ -870,7 +872,13 @@ impl AccountInput { } out.label_since(mark, &format!("character _masterId {id}")); } - let unlisted: Vec = master.characters.iter().map(|c| c.id).filter(|id| !listed.contains(id)).collect(); + let unlisted: Vec = master + .characters + .iter() + .filter(|c| !c.is_non_playable) + .map(|c| c.id) + .filter(|id| !listed.contains(id)) + .collect(); if !unlisted.is_empty() { match coverage.characters { Coverage::Complete => match master.character_rank_by_exp(0) { diff --git a/crates/ournotes-sim/src/master.rs b/crates/ournotes-sim/src/master.rs index a1585850..79af5919 100644 --- a/crates/ournotes-sim/src/master.rs +++ b/crates/ournotes-sim/src/master.rs @@ -147,10 +147,11 @@ row!( ); row!( - /// `MasterCharacter`. + /// `MasterCharacter`. `_isNonPlayable` marks a character a player save never lists. CharacterRow { id: i64 = "_id", band_id: i64 = "_bandID", + is_non_playable: bool = "_isNonPlayable", } ); diff --git a/crates/ournotes-sim/tests/account.rs b/crates/ournotes-sim/tests/account.rs index 369054b0..f5af205b 100644 --- a/crates/ournotes-sim/tests/account.rs +++ b/crates/ournotes-sim/tests/account.rs @@ -571,6 +571,54 @@ fn characters_reach_ranks_by_experience_and_all_count_in_the_total() { assert_eq!(missing(&resolve(&doc, Goal::Power)), ["_player._characters[1]._exp"]); } +/// The synthetic master with character 4 marked non-playable. +fn master_with_non_playable_character() -> Master { + let mut t = tables(); + t.insert( + "MasterCharacter", + json!([{"_id": 1, "_bandID": 1}, {"_id": 2, "_bandID": 1}, {"_id": 3, "_bandID": 2}, + {"_id": 4, "_bandID": 2, "_isNonPlayable": true}]), + ); + master_of(&t) +} + +fn resolve_on(master: Master, doc: &Value) -> Resolution { + AccountInput::from_json(&doc.to_string()).unwrap().resolve( + &data_of(master, "jp"), + Goal::Power, + &Exclusions::default(), + ) +} + +#[test] +fn a_partial_character_list_requires_only_unlisted_playable_characters() { + let mut doc = base(); + doc["coverage"]["_player._characters"] = json!("partial"); + // Characters 1 and 2 are listed, 3 is playable and unlisted, 4 is non-playable and unlisted. + let resolution = resolve_on(master_with_non_playable_character(), &doc); + assert_eq!(missing(&resolution), ["_player._characters"]); + assert!(resolution.missing[0].message.contains("characters 3 are"), "{}", resolution.missing[0].message); + doc["account"]["_player"]["_characters"].as_array_mut().unwrap().push(json!({"_masterId": 3, "_exp": 0})); + ok(resolve_on(master_with_non_playable_character(), &doc)); +} + +#[test] +fn complete_character_coverage_leaves_non_playable_characters_out_of_the_total() { + // Complete coverage: characters 1 and 2 reach ranks 3 and 5, playable character 3 is unlisted and counts at + // experience 0 as rank 1; non-playable character 4 is unlisted and does not count. + let resolved = ok(resolve_on(master_with_non_playable_character(), &base())); + let player = &resolved.roster().player; + assert_eq!(player.character_ranks, BTreeMap::from([(1, 3), (2, 5), (3, 1)])); + assert_eq!(player.character_total_rank(), 9); + // A listed non-playable character is read normally and its rank is part of the total. + let mut doc = base(); + doc["account"]["_player"]["_characters"].as_array_mut().unwrap().push(json!({"_masterId": 4, "_exp": 5})); + let resolved = ok(resolve_on(master_with_non_playable_character(), &doc)); + let player = &resolved.roster().player; + assert_eq!(player.character_ranks, BTreeMap::from([(1, 3), (2, 5), (3, 1), (4, 3)])); + assert_eq!(player.character_total_rank(), 12); +} + #[test] fn the_declared_vip_rank_is_required_and_must_be_in_the_deck_data() { let mut doc = base(); diff --git a/docs/account-input.md b/docs/account-input.md index 6ae4c0fd..78c94ee4 100644 --- a/docs/account-input.md +++ b/docs/account-input.md @@ -32,6 +32,9 @@ integer tokens. Missing/null scalar facts remain unknown. Duplicate keys in read Experience is converted through each card kind's level table. Character rank uses `MasterCharacterRank._exp`; account resolution rejects data missing those thresholds. Every represented character contributes to total rank. +A save lists only playable characters: a `MasterCharacter` row with `_isNonPlayable` true (the optional column, absent +meaning false) is not required, and when unlisted it is left out of the total rank. A listed non-playable character is +read like any other. A memory music group's bonus uses the songs actually listed in that group, not an inferred complete master group. Power/skip do not read skill levels. Ordinary live reads live skill levels; Gekisou also reads performance skill levels. Excluding a card from candidates does not remove its ownership for memory bonuses.