From 80aeed406aa039c3c39639fbd05344b5d1fd0fcb Mon Sep 17 00:00:00 2001 From: nichinichisou Date: Sun, 18 Oct 2026 21:01:08 +0800 Subject: [PATCH] fix(deck): export the non-playable flag of each character The deck input's MasterCharacter table now carries `_isNonPlayable`, written as `false` for a row that does not have the column. A deck model reads it to leave characters a player save never lists out of the required character list and the total character rank. Co-Authored-By: Claude Opus 5.5 --- docs/music-data.md | 4 +++- src/nnnotes/musicdata/deckdata.py | 16 ++++++++++++---- tests/musicdata/test_deckdata.py | 18 ++++++++++++++++++ 3 files changed, 33 insertions(+), 5 deletions(-) diff --git a/docs/music-data.md b/docs/music-data.md index 88d3a67..dae9ac1 100644 --- a/docs/music-data.md +++ b/docs/music-data.md @@ -352,6 +352,8 @@ column should be parsed as 32-bit floats. Tables with a column list export those columns, and every row must have them. Tables marked "all" export every column their rows have, in the order the rows first have them; when such a table has no rows, `columns` is empty (the master data carries no field list for an empty table). A reader treats a table missing from `master` as empty. +`MasterCharacter._isNonPlayable` is the game's flag for a non-playable character; a row without it is written as +`false`. | Table | Columns | |---|---| @@ -363,7 +365,7 @@ column their rows have, in the order the rows first have them; when such a table | `MasterSupportCard` | `_id` `_characterIDs` `_rarity` `_cardType` `_performancePowerMax` `_technicPowerMax` `_visualPowerMax` `_supportCardLevelGroup` `_supportCardRankGroup` `_supportSkillId01` `_supportSkillId02` `_gekisouSupportSkillId01` `_gekisouSupportSkillId02` | | `MasterSupportCardLevel` | `_id` `_group` `_level` `_exp` `_performanceRate` `_technicRate` `_visualRate` | | `MasterSupportCardRank` | `_id` `_group` `_rank` `_limitLevel` `_cardTypeLinkBonusRate` `_supportSkill01Level` `_supportSkill02Level` `_gekisouSupportSkill01Level` `_gekisouSupportSkill02Level` | -| `MasterCharacter` | `_id` `_bandID` | +| `MasterCharacter` | `_id` `_bandID` `_isNonPlayable` | | `MasterBand` | `_id` | | `MasterCharacterRank` | `_id` `_rank` `_exp` `_bonus` | | `MasterCharacterTotalRank` | `_id` `_totalRank` `_bonus` | diff --git a/src/nnnotes/musicdata/deckdata.py b/src/nnnotes/musicdata/deckdata.py index 9a4a3ed..c714947 100644 --- a/src/nnnotes/musicdata/deckdata.py +++ b/src/nnnotes/musicdata/deckdata.py @@ -50,6 +50,9 @@ ) _WHOLE = None # every column of the table, in the order the rows first have them +# {table: {column: value}}: columns exported with this value when a row does not have them +DEFAULTS: dict[str, dict[str, Any]] = {"MasterCharacter": {"_isNonPlayable": False}} + # (table, columns), in file order. A table with a column list exports those columns; a whole table all of its own. TABLES: tuple[tuple[str, tuple[str, ...] | None], ...] = tuple( (t, None if c is None else tuple(c.split())) @@ -80,7 +83,7 @@ "_id _group _rank _limitLevel _cardTypeLinkBonusRate _supportSkill01Level " "_supportSkill02Level _gekisouSupportSkill01Level _gekisouSupportSkill02Level", ), - ("MasterCharacter", "_id _bandID"), + ("MasterCharacter", "_id _bandID _isNonPlayable"), ("MasterBand", "_id"), ("MasterCharacterRank", "_id _rank _exp _bonus"), ("MasterCharacterTotalRank", "_id _totalRank _bonus"), @@ -337,7 +340,8 @@ def _value(v, where: str) -> Any: def master_subset(tables: dict[str, list[dict]]) -> dict: """{table: {"columns", "rows"}} for TABLES, rows in the served order. A table with a column list must have each - of them in every row; a whole table exports the columns its rows have (none when it has no rows).""" + of them in every row, except the columns of DEFAULTS (written as their default when a row lacks them); a whole + table exports the columns its rows have (none when it has no rows).""" out = {} for name, cols in TABLES: rows = tables[name] @@ -346,13 +350,17 @@ def master_subset(tables: dict[str, list[dict]]) -> dict: for r in rows: seen.update(dict.fromkeys(r)) cols = tuple(seen) + defaults = DEFAULTS.get(name, {}) for i, r in enumerate(rows): - missing = [c for c in cols if c not in r] + missing = [c for c in cols if c not in r and c not in defaults] if missing: raise DeckDataError(f"{name}: row {i} (_id {r.get('_id')}) has no column {missing[0]}") out[name] = { "columns": list(cols), - "rows": [[_value(r[c], f"{name} row {i} {c}") for c in cols] for i, r in enumerate(rows)], + "rows": [ + [_value(r[c] if c in r else defaults[c], f"{name} row {i} {c}") for c in cols] + for i, r in enumerate(rows) + ], } return out diff --git a/tests/musicdata/test_deckdata.py b/tests/musicdata/test_deckdata.py index 558aba3..5a9fa6a 100644 --- a/tests/musicdata/test_deckdata.py +++ b/tests/musicdata/test_deckdata.py @@ -320,6 +320,24 @@ def rows(name): ) +def test_character_non_playable_flag(tmp_path): + def rows(name): + if name == "MasterCharacter": + return [ + {"_id": 1, "_bandID": 1, "_isNonPlayable": True}, + {"_id": 2, "_bandID": 2, "_isNonPlayable": False}, + {"_id": 3, "_bandID": 1}, # a master without the column + ] + return rows_of(name) + + export(tmp_path, rows=rows) + raw = (tmp_path / "deck.json").read_bytes() + expected = ( + '"MasterCharacter":{"columns":["_id","_bandID","_isNonPlayable"],"rows":[[1,1,true],[2,2,false],[3,1,false]]}' + ) + assert expected.encode() in raw + + def test_character_rank_carries_its_experience_threshold(tmp_path): def rows(name): if name == "MasterCharacterRank":