Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions crates/ournotes-search/src/owned_snapshot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
14 changes: 11 additions & 3 deletions crates/ournotes-sim/src/account.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -870,7 +872,13 @@ impl AccountInput {
}
out.label_since(mark, &format!("character _masterId {id}"));
}
let unlisted: Vec<i64> = master.characters.iter().map(|c| c.id).filter(|id| !listed.contains(id)).collect();
let unlisted: Vec<i64> = 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) {
Expand Down
3 changes: 2 additions & 1 deletion crates/ournotes-sim/src/master.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
}
);

Expand Down
48 changes: 48 additions & 0 deletions crates/ournotes-sim/tests/account.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
3 changes: 3 additions & 0 deletions docs/account-input.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading