Decode CDINIT card generation lists

This commit is contained in:
gamer147
2026-07-23 22:41:32 -04:00
parent dd3d6fc4dd
commit 21b7751085
9 changed files with 813 additions and 22 deletions

View File

@@ -33,6 +33,11 @@ TRINIT is a special name-mode registry: 21 training/sexual-magic actions each
own six display-text slots and a contiguous block of eligibility, cost, effect,
award, and ten-slot event arrays consumed by TRAIN and restored by GAMESTART.
CDINIT is a special numeric-mode registry: nine selector-dispatched card
generation lists populate a parallel card-id array and three-column weight
schedule. FIELD filters the joined CDINIT2 definitions by story flags and uses
the current stage turn to grow each candidate's weighted-selection share.
Records are {id, name?, desc?, fields:{"0x<col_base>": value}} or, for footer tables,
{id, global_addr, footer_off, values:[...]}. Column addresses are raw engine globals;
confirmed names come from the generated engine global registry while raw keys remain provenance.
@@ -706,6 +711,16 @@ TRAINING_ACTION_ARRAYS = {
"event_story_flag_ids": (0x156015, 10),
}
CARD_GENERATION_SELECTOR = 0x152485
CARD_GENERATION_WEIGHT_BASE = 0x152486
CARD_GENERATION_WEIGHT_STRIDE = 3
CARD_GENERATION_CARD_ID_BASE = 0x1525B2
CARD_GENERATION_SCAN_CAPACITY = 100
CARD_GENERATION_CLEAR_COUNT = 50
CARD_REQUIRED_STORY_FLAG_BASE = 0x151A5D
CARD_FORBIDDEN_STORY_FLAG_BASE = 0x151B89
CARD_STORY_FLAG_STRIDE = 3
def resolve(name: str) -> Path:
for cand in (paths.GAME_DIR / f"{name}.BIN", paths.DATA1 / f"{name}.BIN"):
@@ -4223,6 +4238,417 @@ def extract_training_actions(scr):
}
def extract_card_generation_lists(scr):
"""Extract CDINIT's selector-dispatched weighted card candidate lists."""
instructions = scr.instructions
classified_offsets = set()
cursor = 0
expected_prelude = (
(
"mul",
[
(T_LOCAL_INT, 0),
(T_IMM, CARD_GENERATION_CLEAR_COUNT),
(T_IMM, CARD_GENERATION_WEIGHT_STRIDE),
],
),
(
"copy-to-global",
[
(T_GLOBAL_INT, CARD_GENERATION_WEIGHT_BASE),
(T_LOCAL_INT, 0),
],
),
(
"copy-to-global",
[
(T_GLOBAL_INT, CARD_GENERATION_CARD_ID_BASE),
(T_IMM, CARD_GENERATION_CLEAR_COUNT),
],
),
)
for expected_label, expected_args in expected_prelude:
if cursor >= len(instructions):
raise ValueError(f"{scr.path.name}: truncated CDINIT prelude")
ins = instructions[cursor]
label = sys4load.display_label(ins.opcode)
if label != expected_label or ins.args != expected_args:
raise ValueError(
f"{scr.path.name}: unexpected prelude instruction "
f"at 0x{ins.offset:x}: {label} {ins.args}"
)
classified_offsets.add(ins.offset)
cursor += 1
list_cells: dict[int, dict[int, dict[str, int]]] = {}
branch_offsets: dict[int, int] = {}
while cursor < len(instructions):
ins = instructions[cursor]
if sys4load.display_label(ins.opcode) != "eq":
break
if (
len(ins.args) != 3
or ins.args[0] != (T_LOCAL_INT, 0)
or ins.args[1] != (T_GLOBAL_INT, CARD_GENERATION_SELECTOR)
or ins.args[2][0] != T_IMM
):
raise ValueError(
f"{scr.path.name}: malformed selector test at 0x{ins.offset:x}"
)
selector = ins.args[2][1]
if selector in list_cells:
raise ValueError(
f"{scr.path.name}: duplicate selector {selector}"
)
list_cells[selector] = {}
branch_offsets[selector] = ins.offset
classified_offsets.add(ins.offset)
cursor += 1
if cursor >= len(instructions):
raise ValueError(
f"{scr.path.name}: selector {selector} lacks a branch"
)
branch = instructions[cursor]
if (
sys4load.display_label(branch.opcode) != "jcc"
or branch.args[:2]
!= [(T_LOCAL_INT, 0), (T_IMM, 0xFFFFFFFF)]
):
raise ValueError(
f"{scr.path.name}: malformed selector branch "
f"at 0x{branch.offset:x}"
)
classified_offsets.add(branch.offset)
cursor += 1
while cursor < len(instructions):
write_ins = instructions[cursor]
if sys4load.display_label(write_ins.opcode) != "mov":
break
write = _static_global_write(write_ins)
if write is None or not isinstance(write[1], int):
raise ValueError(
f"{scr.path.name}: non-static list write "
f"at 0x{write_ins.offset:x}"
)
destination, value = write
if (
CARD_GENERATION_CARD_ID_BASE
< destination
< CARD_GENERATION_CARD_ID_BASE
+ CARD_GENERATION_SCAN_CAPACITY
):
slot = destination - CARD_GENERATION_CARD_ID_BASE
field_name = "card_id"
elif (
CARD_GENERATION_WEIGHT_BASE
<= destination
< CARD_GENERATION_WEIGHT_BASE
+ CARD_GENERATION_SCAN_CAPACITY
* CARD_GENERATION_WEIGHT_STRIDE
):
index = destination - CARD_GENERATION_WEIGHT_BASE
slot, column = divmod(
index, CARD_GENERATION_WEIGHT_STRIDE
)
field_name = (
"base_weight",
"growth_interval_turns",
"growth_weight",
)[column]
else:
raise ValueError(
f"{scr.path.name}: unclassified list write "
f"0x{destination:x} at 0x{write_ins.offset:x}"
)
if slot == 0:
raise ValueError(
f"{scr.path.name}: selector {selector} writes reserved "
f"slot zero at 0x{write_ins.offset:x}"
)
entry = list_cells[selector].setdefault(slot, {})
if field_name in entry:
raise ValueError(
f"{scr.path.name}: selector {selector} slot {slot} "
f"overwrites {field_name}"
)
entry[field_name] = value
classified_offsets.add(write_ins.offset)
cursor += 1
if cursor >= len(instructions):
raise ValueError(
f"{scr.path.name}: selector {selector} lacks terminal jump"
)
terminal = instructions[cursor]
if sys4load.display_label(terminal.opcode) != "jmp":
raise ValueError(
f"{scr.path.name}: selector {selector} lacks terminal jump "
f"at 0x{terminal.offset:x}"
)
classified_offsets.add(terminal.offset)
cursor += 1
if not list_cells:
raise ValueError(f"{scr.path.name}: no card-generation selectors")
fallback_comment = ""
while cursor < len(instructions):
ins = instructions[cursor]
label = sys4load.display_label(ins.opcode)
if label == "comment":
fallback_comment = scr.strings[ins.args[0][1]][0]
elif label not in ("instruction-marker-noop", "exit"):
raise ValueError(
f"{scr.path.name}: unclassified trailing instruction "
f"{label} at 0x{ins.offset:x}"
)
classified_offsets.add(ins.offset)
cursor += 1
unclassified = [
f"0x{ins.offset:x}"
for ins in instructions
if ins.offset not in classified_offsets
]
if unclassified:
raise ValueError(
f"{scr.path.name}: unclassified instructions "
+ ", ".join(unclassified)
)
required_fields = {
"card_id",
"base_weight",
"growth_interval_turns",
"growth_weight",
}
for selector, cells in list_cells.items():
expected_slots = list(range(1, len(cells) + 1))
if sorted(cells) != expected_slots:
raise ValueError(
f"{scr.path.name}: selector {selector} has non-contiguous "
f"slots {sorted(cells)}"
)
for slot, entry in cells.items():
if set(entry) != required_fields:
raise ValueError(
f"{scr.path.name}: selector {selector} slot {slot} "
f"has fields {sorted(entry)}, expected "
f"{sorted(required_fields)}"
)
card_scr = sys4load.load(resolve("CDINIT2"))
card_records, _ = extract_name(card_scr)
card_definitions = {
record["id"]: {
"name": record.get("name") or "",
"description": record.get("desc") or "",
"required_story_flag_ids": [],
"forbidden_story_flag_ids": [],
"ignored_required_story_flag_ids": [],
}
for record in card_records
}
flag_cells = {
"required_story_flag_ids": {},
"forbidden_story_flag_ids": {},
}
for ins in card_scr.instructions:
write = _static_global_write(ins)
if write is None or not isinstance(write[1], int):
continue
destination, value = write
for field_name, base in (
("required_story_flag_ids", CARD_REQUIRED_STORY_FLAG_BASE),
("forbidden_story_flag_ids", CARD_FORBIDDEN_STORY_FLAG_BASE),
):
index = destination - base
if not (
0
<= index
< CARD_GENERATION_SCAN_CAPACITY * CARD_STORY_FLAG_STRIDE
):
continue
card_id, column = divmod(index, CARD_STORY_FLAG_STRIDE)
flag_cells[field_name][(card_id, column)] = value
break
for card_id, definition in card_definitions.items():
for field_name in flag_cells:
definition[field_name] = [
flag_cells[field_name].get((card_id, column), 0)
for column in range(2)
if flag_cells[field_name].get((card_id, column), 0)
]
ignored_required = flag_cells[
"required_story_flag_ids"
].get((card_id, 2), 0)
if ignored_required:
definition["ignored_required_story_flag_ids"] = [
ignored_required
]
stages, _ = extract_mixed(sys4load.load(resolve("STINIT")))
attach_stage_object_placements(stages)
references_by_selector: dict[int, dict[int, list[int]]] = (
collections.defaultdict(lambda: collections.defaultdict(list))
)
for stage in stages:
for obj in stage.get("object_placements", []):
selector = obj.get("card_generation_list_id")
if selector is None:
continue
references_by_selector[selector][stage["id"]].append(
obj["slot"]
)
records = []
all_card_ids = set()
resolved_card_reference_count = 0
for selector in sorted(list_cells):
entries = []
for slot, raw_entry in sorted(list_cells[selector].items()):
card_id = raw_entry["card_id"]
all_card_ids.add(card_id)
definition = card_definitions.get(card_id, {})
if definition.get("name"):
resolved_card_reference_count += 1
entries.append({
"slot": slot,
"card_id": card_id,
"card_name": definition.get("name", ""),
"card_description": definition.get("description", ""),
"base_weight": raw_entry["base_weight"],
"growth_interval_turns": raw_entry[
"growth_interval_turns"
],
"growth_weight": raw_entry["growth_weight"],
"required_story_flag_ids": definition.get(
"required_story_flag_ids", []
),
"forbidden_story_flag_ids": definition.get(
"forbidden_story_flag_ids", []
),
"ignored_required_story_flag_ids": definition.get(
"ignored_required_story_flag_ids", []
),
"source_addresses": {
"card_id": (
f"0x{CARD_GENERATION_CARD_ID_BASE + slot:x}"
),
"base_weight": (
f"0x{CARD_GENERATION_WEIGHT_BASE + slot * 3:x}"
),
"growth_interval_turns": (
f"0x{CARD_GENERATION_WEIGHT_BASE + slot * 3 + 1:x}"
),
"growth_weight": (
f"0x{CARD_GENERATION_WEIGHT_BASE + slot * 3 + 2:x}"
),
},
})
stage_references = [
{
"stage_id": stage_id,
"object_slots": sorted(object_slots),
}
for stage_id, object_slots
in sorted(references_by_selector.get(selector, {}).items())
]
records.append({
"id": selector,
"name": f"card_generation_list_{selector}",
"branch_offset": f"0x{branch_offsets[selector]:x}",
"entry_count": len(entries),
"stage_object_references": stage_references,
"entries": entries,
"fields": {},
})
used_selectors = sorted(
selector
for selector in list_cells
if selector in references_by_selector
)
entry_count = sum(len(cells) for cells in list_cells.values())
return records, {
"schema": "card-generation-lists",
"selector_global": f"0x{CARD_GENERATION_SELECTOR:x}",
"card_id_array_base": f"0x{CARD_GENERATION_CARD_ID_BASE:x}",
"weight_schedule_table_base": (
f"0x{CARD_GENERATION_WEIGHT_BASE:x}"
),
"weight_schedule_stride": CARD_GENERATION_WEIGHT_STRIDE,
"runtime_scan_capacity": CARD_GENERATION_SCAN_CAPACITY,
"cleared_entry_prefix": CARD_GENERATION_CLEAR_COUNT,
"selector_ids": sorted(list_cells),
"used_selector_ids": used_selectors,
"unreferenced_selector_ids": sorted(
set(list_cells) - set(used_selectors)
),
"entry_count": entry_count,
"distinct_card_ids": sorted(all_card_ids),
"resolved_card_reference_count": resolved_card_reference_count,
"ignored_required_story_flag_definition_count": sum(
bool(definition["ignored_required_story_flag_ids"])
for definition in card_definitions.values()
),
"ignored_required_story_flag_entry_count": sum(
bool(entry["ignored_required_story_flag_ids"])
for record in records
for entry in record["entries"]
),
"stage_definition_reference_count": sum(
len(stage_map)
for stage_map in references_by_selector.values()
),
"stage_object_reference_count": sum(
len(object_slots)
for stage_map in references_by_selector.values()
for object_slots in stage_map.values()
),
"fallback_comment": fallback_comment,
"classified_instruction_count": len(classified_offsets),
"semantic_array_names": {
f"0x{CARD_GENERATION_SELECTOR:x}": (
"current_card_generation_list_id"
),
f"0x{CARD_GENERATION_CARD_ID_BASE:x}": (
"card_generation_card_ids"
),
f"0x{CARD_GENERATION_WEIGHT_BASE:x}": (
"card_generation_weight_schedules"
),
},
"weight_formula": (
"base_weight + floor(current_stage_turn / "
"growth_interval_turns) * growth_weight; when "
"growth_interval_turns is zero, use base_weight"
),
"consumer_contract": {
"FIELD.BIN": (
"load the STINIT type-28 object's card-generation list, "
"scan 100 candidate slots, discard empty card ids and "
"CDINIT2 definitions whose required/forbidden story flags "
"fail, compute the current-turn-adjusted weight, and select "
"one surviving card by cumulative weighted random choice"
),
"CDINIT2.BIN": (
"provides card names, result text, effects, graphics, and "
"the required/forbidden story-flag rows used by FIELD; "
"FIELD tests only columns zero and one, leaving the eighteen "
"authored required-flag values in column two engine-dead"
),
"STINIT.BIN": (
"type-28 stage objects supply the selector id consumed by "
"CDINIT"
),
},
}
def _map_stage_definitions() -> list[dict]:
"""Read the STINIT2 records that own all four terrain-atlas bounds."""
stage_scr = sys4load.load(resolve("STINIT2"))
@@ -4843,6 +5269,11 @@ def write_data_index(data_dir: Path) -> None:
"Item and skill ids join to ITINIT/SKINIT; all 75 event slots join through",
"SCINIT, and GAMESTART's restored-story-flag contract remains explicit.",
"",
"CDINIT's dedicated card-generation schema exposes nine sparse selector lists,",
"joins their 383 weighted candidate slots to CDINIT2 card names and story-flag",
"gates, and links the seven used selectors back to STINIT type-28 stage objects.",
"FIELD's turn-scaled weight formula and 100-slot selection scan remain explicit.",
"",
"MPINIT's dedicated terrain-atlas schema exposes 1,472 authored rows of a sparse",
"53-column half-tile grid. It joins STINIT2's doubled tile-bound rectangles to 66",
"stage definitions, preserves implicit-zero rows and raw footer provenance, and",
@@ -4909,6 +5340,10 @@ def main() -> int:
# TRINIT's six-column sparse string matrix is not the generic
# one-name-per-record layout expected by name-mode auto-detection.
mode = "name"
elif name == "CDINIT":
# CDINIT's selector branches look like one fragmented numeric table
# to the generic parallel-array detector.
mode = "numeric"
else:
mode = detect_mode(scr)
extractor = {
@@ -4944,6 +5379,8 @@ def main() -> int:
extractor = extract_terrain_definitions
elif mode == "name" and name == "TRINIT":
extractor = extract_training_actions
elif mode == "numeric" and name == "CDINIT":
extractor = extract_card_generation_lists
elif mode == "numeric" and name == "SPINIT":
extractor = extract_h_scene_gallery
elif mode == "footer" and name == "MPINIT":

View File

@@ -426,6 +426,49 @@ def profile_training_actions(data: dict) -> dict:
}
def profile_card_generation_lists(data: dict) -> dict:
"""Summarize CDINIT's weighted, selector-dispatched card lists."""
if data.get("schema") != "card-generation-lists":
return {}
entries = [
entry
for record in data.get("records", [])
for entry in record.get("entries", [])
]
return {
"list_count": len(data.get("records", [])),
"entry_count": data.get("entry_count", 0),
"distinct_card_count": len(data.get("distinct_card_ids", [])),
"resolved_card_reference_count": data.get(
"resolved_card_reference_count", 0
),
"used_selector_count": len(data.get("used_selector_ids", [])),
"unreferenced_selector_ids": data.get(
"unreferenced_selector_ids", []
),
"stage_definition_reference_count": data.get(
"stage_definition_reference_count", 0
),
"stage_object_reference_count": data.get(
"stage_object_reference_count", 0
),
"story_flag_gated_entry_count": sum(
bool(entry.get("required_story_flag_ids"))
or bool(entry.get("forbidden_story_flag_ids"))
for entry in entries
),
"ignored_required_story_flag_entry_count": data.get(
"ignored_required_story_flag_entry_count", 0
),
"runtime_scan_capacity": data.get("runtime_scan_capacity", 0),
"cleared_entry_prefix": data.get("cleared_entry_prefix", 0),
"list_entry_counts": {
str(record["id"]): record.get("entry_count", 0)
for record in data.get("records", [])
},
}
def profile_messages(data: dict) -> dict:
"""Summarize the joined player-facing message evidence."""
records = data["records"]
@@ -553,7 +596,29 @@ def render_markdown(data: dict, rows: list[dict], limit: int) -> str:
f"- records: {data['record_count']}",
f"- populated fields: {len(rows)}",
]
if training_profile := profile_training_actions(data):
if card_profile := profile_card_generation_lists(data):
lines.extend([
f"- card-generation lists: {card_profile['list_count']}",
f"- weighted entries: {card_profile['entry_count']} across "
f"{card_profile['distinct_card_count']} distinct cards "
f"({card_profile['resolved_card_reference_count']} CDINIT2 "
f"references resolved)",
f"- selector usage: {card_profile['used_selector_count']} used; "
f"unreferenced {card_profile['unreferenced_selector_ids']}",
f"- STINIT joins: "
f"{card_profile['stage_object_reference_count']} type-28 objects "
f"across {card_profile['stage_definition_reference_count']} "
f"stage definitions",
f"- story-flag-gated entries: "
f"{card_profile['story_flag_gated_entry_count']} effective; "
f"{card_profile['ignored_required_story_flag_entry_count']} "
f"carry an engine-dead third required flag",
f"- runtime scan/clear prefix: "
f"{card_profile['runtime_scan_capacity']}/"
f"{card_profile['cleared_entry_prefix']} slots",
f"- entries by selector: {card_profile['list_entry_counts']}",
])
elif training_profile := profile_training_actions(data):
lines.extend([
f"- training actions: {training_profile['action_count']}",
f"- display text lines: "
@@ -751,6 +816,7 @@ def main() -> int:
"terrain_definition_profile": profile_terrain_definitions(data),
"h_scene_gallery_profile": profile_h_scene_gallery(data),
"training_action_profile": profile_training_actions(data),
"card_generation_profile": profile_card_generation_lists(data),
"columns": sorted(rows, key=lambda row: (
int(row["base"], 16), row["stride"] or 0, row["column"] or 0
)),

View File

@@ -1656,6 +1656,87 @@ def test_training_actions() -> None:
)
def test_card_generation_lists() -> None:
scripts = paths.scripts()
script = sys4load.load(scripts["CDINIT.BIN"])
check(
extract_init.detect_mode(script) == "numeric",
"CDINIT remains compatible with numeric-mode auto-detection",
)
records, meta = extract_init.extract_card_generation_lists(script)
by_id = {record["id"]: record for record in records}
check(
list(by_id) == [1, 11, 31, 41, 55, 61, 71, 94, 160]
and [record["entry_count"] for record in records]
== [11, 36, 38, 51, 26, 52, 64, 30, 75],
"CDINIT exposes all nine selector branches and their candidate counts",
)
check(
meta["entry_count"] == 383
and len(meta["distinct_card_ids"]) == 81
and meta["resolved_card_reference_count"] == 383
and meta["classified_instruction_count"] == 1565,
"CDINIT classifies every instruction and resolves every card reference",
)
first = by_id[1]["entries"][0]
check(
first["slot"] == 1
and first["card_id"] == 1
and first["card_name"] == "癒しのカード・小"
and first["base_weight"] == 25
and first["growth_interval_turns"] == 5
and first["growth_weight"] == 1
and first["source_addresses"]
== {
"card_id": "0x1525b3",
"base_weight": "0x152489",
"growth_interval_turns": "0x15248a",
"growth_weight": "0x15248b",
},
"CDINIT retains the parallel card-id and weight-table provenance",
)
gated = next(
entry
for entry in by_id[11]["entries"]
if entry["card_id"] == 14
)
check(
gated["card_name"] == "使い魔のカード"
and gated["required_story_flag_ids"] == [901]
and gated["forbidden_story_flag_ids"] == [861],
"CDINIT entries join CDINIT2 names and FIELD story-flag gates",
)
ignored_gate = next(
entry
for entry in by_id[41]["entries"]
if entry["card_id"] == 18
)
check(
meta["ignored_required_story_flag_definition_count"] == 18
and meta["ignored_required_story_flag_entry_count"] == 54
and ignored_gate["required_story_flag_ids"] == [901, 863]
and ignored_gate["ignored_required_story_flag_ids"] == [51],
"CDINIT distinguishes FIELD's two live required flags from column three",
)
check(
meta["used_selector_ids"] == [1, 11, 31, 41, 61, 71, 160]
and meta["unreferenced_selector_ids"] == [55, 94]
and meta["stage_definition_reference_count"] == 50
and meta["stage_object_reference_count"] == 246
and by_id[1]["stage_object_references"]
== [{"stage_id": 1, "object_slots": [8]}],
"CDINIT joins every used list back to STINIT type-28 stage objects",
)
check(
meta["runtime_scan_capacity"] == 100
and meta["cleared_entry_prefix"] == 50
and by_id[160]["entry_count"] == 75
and meta["fallback_comment"]
== "カード発生リストの設定が不足しています",
"CDINIT preserves its 100-slot scan, 50-slot clear, and fallback warning",
)
def test_condition_definitions() -> None:
scripts = paths.scripts()
script = sys4load.load(scripts["ILINIT.BIN"])
@@ -1873,6 +1954,7 @@ if __name__ == "__main__":
test_terrain_definitions()
test_h_scene_gallery()
test_training_actions()
test_card_generation_lists()
test_map_terrain_atlas()
test_condition_definitions()
test_field_semantics()

View File

@@ -86,6 +86,14 @@ def test_load_and_lint():
and entries[0x6722]["name"] == "familiar_alignment"
and entries[0x6727]["name"] == "training_action_execution_counts",
"TRINIT/TRAIN action state is curated")
check(entries[0x152485]["name"] == "current_card_generation_list_id"
and entries[0x152486]["columns"]["2"] == "growth_weight"
and entries[0x1525b2]["name"] == "card_generation_card_ids"
and entries[0x1519f8]["name"] == "current_card_id"
and entries[0x151a5d]["columns"]["2"]
== "engine_dead_required_flag_3"
and entries[0x204f4]["name"] == "current_stage_turn",
"CDINIT/FIELD card-generation state is curated")
check(entries[0x15a095]["name"] == "information_tab_index"
and entries[0x15a096]["name"] == "information_message_handled"
and entries[0x15a097]["name"]

View File

@@ -267,6 +267,59 @@ def main() -> int:
assert "- geometry: 8 pages × 15 slots" in rendered_h_gallery
assert "- populated scenes: 118/120" in rendered_h_gallery
card_fixture = {
"table": "CARDS",
"mode": "numeric",
"schema": "card-generation-lists",
"record_count": 2,
"entry_count": 3,
"distinct_card_ids": [1, 14],
"resolved_card_reference_count": 3,
"used_selector_ids": [1],
"unreferenced_selector_ids": [55],
"stage_definition_reference_count": 1,
"stage_object_reference_count": 2,
"runtime_scan_capacity": 100,
"cleared_entry_prefix": 50,
"records": [
{
"id": 1,
"entry_count": 2,
"entries": [
{
"card_id": 1,
"required_story_flag_ids": [],
"forbidden_story_flag_ids": [],
},
{
"card_id": 14,
"required_story_flag_ids": [901],
"forbidden_story_flag_ids": [861],
},
],
},
{
"id": 55,
"entry_count": 1,
"entries": [
{
"card_id": 1,
"required_story_flag_ids": [],
"forbidden_story_flag_ids": [],
},
],
},
],
}
card_summary = profile.profile_card_generation_lists(card_fixture)
assert card_summary["list_count"] == 2
assert card_summary["entry_count"] == 3
assert card_summary["story_flag_gated_entry_count"] == 1
assert card_summary["list_entry_counts"] == {"1": 2, "55": 1}
rendered_cards = profile.render_markdown(card_fixture, [], 40)
assert "- card-generation lists: 2" in rendered_cards
assert "- runtime scan/clear prefix: 100/50 slots" in rendered_cards
training_fixture = {
"table": "TRAINING",
"mode": "name",