Decode TRINIT training actions

This commit is contained in:
gamer147
2026-07-23 22:25:41 -04:00
parent 4c06cd9568
commit 6e95ea29a9
12 changed files with 1097 additions and 65 deletions

View File

@@ -29,6 +29,10 @@ MPINIT is a special footer-mode terrain atlas: each footer copy owns the fifty
authored cells of one 53-cell half-tile grid row. STINIT2's per-stage tile
bounds select rectangles after multiplying both coordinates by two.
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.
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.
@@ -677,6 +681,31 @@ H_SCENE_GALLERY_PAGE_COUNT = 8
H_SCENE_GALLERY_SLOTS_PER_PAGE = 15
H_SCENE_GALLERY_THUMBNAIL_BASE = 0x66421
TRAINING_ACTION_STRING_BASE = 0x453B
TRAINING_ACTION_STRING_STRIDE = 6
TRAINING_ACTION_COUNT = 21
TRAINING_ACTION_ARRAYS = {
"required_story_flag_ids": (0x155BBC, 3),
"forbidden_story_flag_ids": (0x155BFB, 3),
"minimum_unit_level": (0x155C3A, 1),
"maximum_unit_level": (0x155C4F, 1),
"minimum_alignment_encoded": (0x155C64, 1),
"maximum_alignment_encoded": (0x155C79, 1),
"minimum_training_progress": (0x155C8E, 1),
"maximum_training_progress": (0x155CA3, 1),
"minimum_unit_stats": (0x155CB8, 10),
"maximum_unit_stats": (0x155D8A, 10),
"required_item_id": (0x155E5C, 1),
"required_skill_id": (0x155E71, 1),
"spirit_delta": (0x155E86, 1),
"unit_stat_deltas": (0x155E9B, 14),
"alignment_delta_hundredths": (0x155FC1, 1),
"training_progress_delta_hundredths": (0x155FD6, 1),
"awarded_skill_id": (0x155FEB, 1),
"awarded_item_id": (0x156000, 1),
"event_story_flag_ids": (0x156015, 10),
}
def resolve(name: str) -> Path:
for cand in (paths.GAME_DIR / f"{name}.BIN", paths.DATA1 / f"{name}.BIN"):
@@ -3784,6 +3813,416 @@ def extract_h_scene_gallery(scr):
}
def extract_training_actions(scr):
"""Extract TRINIT's 21 training/sexual-magic action definitions."""
string_cells: dict[tuple[int, int], str] = {}
numeric_cells = {
field_name: {}
for field_name in TRAINING_ACTION_ARRAYS
}
classified_offsets = set()
string_write_count = 0
static_write_count = 0
for ins in scr.instructions:
if (
ins.opcode == SET_STRING
and len(ins.args) >= 2
and ins.args[0][0] == T_GLOBAL_STRING
):
destination = ins.args[0][1]
index = destination - TRAINING_ACTION_STRING_BASE
capacity = (
TRAINING_ACTION_COUNT
* TRAINING_ACTION_STRING_STRIDE
)
if not 0 <= index < capacity:
raise ValueError(
f"{scr.path.name}: training string write "
f"0x{destination:x} outside the {capacity}-cell table"
)
action_id, column = divmod(
index, TRAINING_ACTION_STRING_STRIDE
)
value = scr.strings[ins.args[1][1]][0]
_store_unique(
string_cells, (action_id, column), value, action_id
)
classified_offsets.add(ins.offset)
string_write_count += 1
continue
write = _static_global_write(ins)
if write is None:
continue
static_write_count += 1
destination, value = write
if not isinstance(value, int):
raise ValueError(
f"{scr.path.name}: non-static training value "
f"at 0x{ins.offset:x}"
)
for field_name, (base, stride) in TRAINING_ACTION_ARRAYS.items():
index = destination - base
if 0 <= index < TRAINING_ACTION_COUNT * stride:
action_id, column = divmod(index, stride)
_store_unique(
numeric_cells[field_name],
(action_id, column),
value,
action_id,
)
classified_offsets.add(ins.offset)
break
else:
raise ValueError(
f"{scr.path.name}: unclassified training write "
f"0x{destination:x} at 0x{ins.offset:x}"
)
exit_offsets = {
ins.offset
for ins in scr.instructions
if sys4load.display_label(ins.opcode) == "exit"
}
classified_offsets.update(exit_offsets)
unclassified = [
f"0x{ins.offset:x}"
for ins in scr.instructions
if ins.offset not in classified_offsets
]
if unclassified:
raise ValueError(
f"{scr.path.name}: unclassified instructions "
+ ", ".join(unclassified)
)
if len(exit_offsets) != 1:
raise ValueError(
f"{scr.path.name}: expected one exit, found {len(exit_offsets)}"
)
item_records, _ = extract_name(sys4load.load(resolve("ITINIT")))
item_names = {
record["id"]: record["name"] for record in item_records
}
skill_records, _ = extract_name(sys4load.load(resolve("SKINIT")))
skill_names = {
record["id"]: record["name"] for record in skill_records
}
dispatch_records, _ = extract_dispatch(
sys4load.load(resolve("SCINIT"))
)
event_dispatch = {
record["id"]: record for record in dispatch_records
}
def values(field_name: str, action_id: int) -> list[int]:
_, stride = TRAINING_ACTION_ARRAYS[field_name]
cells = numeric_cells[field_name]
return [
cells.get((action_id, column), 0)
for column in range(stride)
]
def scalar(field_name: str, action_id: int) -> int:
return values(field_name, action_id)[0]
records = []
for action_id in range(TRAINING_ACTION_COUNT):
description_lines = [
string_cells.get((action_id, column))
for column in range(3)
]
locked_hint_lines = [
string_cells.get((action_id, column))
for column in range(3, 6)
]
description_lines = [
line for line in description_lines if line is not None
]
locked_hint_lines = [
line for line in locked_hint_lines if line is not None
]
raw_fields = {}
raw_record_fields = {}
raw_string_fields = {}
for column in range(TRAINING_ACTION_STRING_STRIDE):
cell = (action_id, column)
if cell in string_cells:
raw_string_fields[
f"0x{TRAINING_ACTION_STRING_BASE:x}/"
f"{TRAINING_ACTION_STRING_STRIDE}/{column}"
] = string_cells[cell]
for field_name, (base, stride) in TRAINING_ACTION_ARRAYS.items():
for column in range(stride):
cell = (action_id, column)
if cell not in numeric_cells[field_name]:
continue
value = numeric_cells[field_name][cell]
if stride == 1:
raw_fields[f"0x{base:x}"] = value
else:
raw_record_fields[
f"0x{base:x}/{stride}/{column}"
] = value
required_flags = [
value
for value in values(
"required_story_flag_ids", action_id
)
if value
]
forbidden_flags = [
value
for value in values(
"forbidden_story_flag_ids", action_id
)
if value
]
minimum_stats = {
UNIT_STAT_COLUMNS[column]: value
for column, value in enumerate(
values("minimum_unit_stats", action_id)
)
if value
}
maximum_stats = {
UNIT_STAT_COLUMNS[column]: value
for column, value in enumerate(
values("maximum_unit_stats", action_id)
)
if value
}
stat_deltas = {
UNIT_STAT_COLUMNS[column]: value
for column, value in enumerate(
values("unit_stat_deltas", action_id)
)
if value
}
event_ids = values("event_story_flag_ids", action_id)
events = []
for slot, event_id in enumerate(event_ids):
if not event_id:
continue
dispatch = event_dispatch.get(event_id, {})
events.append({
"slot": slot,
"story_flag_id": event_id,
"script_resource_id": dispatch.get(
"script_resource_id", 0
),
"script_name": dispatch.get("script_name", ""),
})
required_item_id = scalar("required_item_id", action_id)
required_skill_id = scalar("required_skill_id", action_id)
awarded_item_id = scalar("awarded_item_id", action_id)
awarded_skill_id = scalar("awarded_skill_id", action_id)
spirit_delta = scalar("spirit_delta", action_id)
minimum_alignment_encoded = scalar(
"minimum_alignment_encoded", action_id
)
maximum_alignment_encoded = scalar(
"maximum_alignment_encoded", action_id
)
alignment_delta = scalar(
"alignment_delta_hundredths", action_id
)
training_delta = scalar(
"training_progress_delta_hundredths", action_id
)
eligibility = {
"required_story_flag_ids": required_flags,
"forbidden_story_flag_ids": forbidden_flags,
"minimum_unit_stats": minimum_stats,
"maximum_unit_stats": maximum_stats,
}
for field_name in (
"minimum_unit_level",
"maximum_unit_level",
"minimum_training_progress",
"maximum_training_progress",
):
value = scalar(field_name, action_id)
if value:
eligibility[field_name] = value
if minimum_alignment_encoded:
eligibility["minimum_alignment"] = (
minimum_alignment_encoded - 100
)
if maximum_alignment_encoded:
eligibility["maximum_alignment"] = (
maximum_alignment_encoded - 100
)
if required_item_id:
eligibility.update({
"required_item_id": required_item_id,
"required_item_name": item_names.get(
required_item_id, ""
),
})
if required_skill_id:
eligibility.update({
"required_skill_id": required_skill_id,
"required_skill_name": skill_names.get(
required_skill_id, ""
),
})
effects = {
"spirit_delta": spirit_delta,
"spirit_cost": -spirit_delta,
"unit_stat_deltas": stat_deltas,
"alignment_delta_hundredths": alignment_delta,
"training_progress_delta_hundredths": training_delta,
}
if awarded_skill_id:
effects.update({
"awarded_skill_id": awarded_skill_id,
"awarded_skill_name": skill_names.get(
awarded_skill_id, ""
),
})
if awarded_item_id:
effects.update({
"awarded_item_id": awarded_item_id,
"awarded_item_name": item_names.get(
awarded_item_id, ""
),
})
records.append({
"id": action_id,
"name": f"training_action_{action_id:02d}",
"description_lines": description_lines,
"description": "".join(description_lines),
"locked_hint_lines": locked_hint_lines,
"locked_hint": "".join(locked_hint_lines),
"eligibility": eligibility,
"effects": effects,
"event_story_flag_ids": event_ids,
"execution_limit": len(events),
"events": events,
"fields": raw_fields,
"record_fields": raw_record_fields,
"string_fields": raw_string_fields,
})
string_key = f"0x{TRAINING_ACTION_STRING_BASE:x}"
array_layouts = {
string_key: {"stride": TRAINING_ACTION_STRING_STRIDE},
**{
f"0x{base:x}": {"stride": stride}
for base, stride in TRAINING_ACTION_ARRAYS.values()
if stride > 1
},
}
semantic_names = {
string_key: "training_action_text",
**{
f"0x{base:x}": f"training_action_{field_name}"
for field_name, (base, _) in TRAINING_ACTION_ARRAYS.items()
},
}
schema_field_semantics = {}
for column in range(TRAINING_ACTION_STRING_STRIDE):
family = (
"description_line" if column < 3 else "locked_hint_line"
)
ordinal = column + 1 if column < 3 else column - 2
schema_field_semantics[
f"{string_key}/{TRAINING_ACTION_STRING_STRIDE}/{column}"
] = f"training_action_{family}_{ordinal}"
for field_name, (base, stride) in TRAINING_ACTION_ARRAYS.items():
key = f"0x{base:x}"
if stride == 1:
schema_field_semantics[key] = semantic_names[key]
continue
for column in range(stride):
schema_field_semantics[
f"{key}/{stride}/{column}"
] = f"training_action_{field_name}.column_{column}"
event_values = [
event["story_flag_id"]
for record in records
for event in record["events"]
]
authored_cell_counts = {
field_name: len(cells)
for field_name, cells in numeric_cells.items()
}
return records, {
"schema": "training-action-definitions",
"reserved_record_count": TRAINING_ACTION_COUNT,
"string_table_base": string_key,
"string_stride": TRAINING_ACTION_STRING_STRIDE,
"numeric_block_start": (
f"0x{TRAINING_ACTION_ARRAYS['required_story_flag_ids'][0]:x}"
),
"numeric_block_end_exclusive": "0x1560e7",
"array_layouts": array_layouts,
"schema_field_semantics": schema_field_semantics,
"semantic_array_names": semantic_names,
"authored_numeric_cell_counts": authored_cell_counts,
"string_write_count": string_write_count,
"static_write_count": static_write_count,
"classified_static_write_count": sum(
len(cells) for cells in numeric_cells.values()
),
"classified_instruction_count": len(classified_offsets),
"required_item_join_count": sum(
bool(record["eligibility"].get("required_item_name"))
for record in records
),
"awarded_item_join_count": sum(
bool(record["effects"].get("awarded_item_name"))
for record in records
),
"awarded_skill_join_count": sum(
bool(record["effects"].get("awarded_skill_name"))
for record in records
),
"event_cell_count": len(event_values),
"distinct_event_story_flag_ids": sorted(set(event_values)),
"resolved_event_dispatch_count": sum(
bool(event["script_name"])
for record in records
for event in record["events"]
),
"runtime_contract": {
"selected_action_id": "0x53edd",
"availability_state_by_action": "0x53ede",
"familiar_alignment": "0x6722",
"familiar_alignment_fraction": "0x6723",
"training_progress": "0x6724",
"training_progress_fraction": "0x6725",
"total_execution_count": "0x6726",
"execution_count_by_action": "0x6727",
"current_spirit": "0x20530",
"maximum_spirit": "0x20534",
},
"consumer_contract": {
"TRAIN.BIN": (
"evaluates every eligibility family, renders the available "
"or locked three-line text, deducts spirit, applies fourteen-"
"stat/alignment/training effects, awards items or skills, "
"increments per-action execution counts, and dispatches the "
"event id selected by the prior execution count"
),
"GAMESTART.BIN": (
"restores all event story flags in slots below each saved "
"per-action execution count so prior training scenes remain "
"completed after load"
),
},
}
def _map_stage_definitions() -> list[dict]:
"""Read the STINIT2 records that own all four terrain-atlas bounds."""
stage_scr = sys4load.load(resolve("STINIT2"))
@@ -4399,6 +4838,11 @@ def write_data_index(data_dir: Path) -> None:
"joins every page to its INIT2 SO027 thumbnail sheet, resolves all 118 populated",
"scene resources, and retains the two implicit empty cells in the final page.",
"",
"TRINIT's dedicated training-action schema exposes 21 six-line text rows and",
"the contiguous eligibility/cost/effect/award/event block consumed by TRAIN.",
"Item and skill ids join to ITINIT/SKINIT; all 75 event slots join through",
"SCINIT, and GAMESTART's restored-story-flag contract remains 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",
@@ -4459,7 +4903,14 @@ def main() -> int:
raise SystemExit(str(error)) from error
scr = sys4load.load(resolve(name))
mode = mode_arg or detect_mode(scr)
if mode_arg is not None:
mode = mode_arg
elif name == "TRINIT":
# TRINIT's six-column sparse string matrix is not the generic
# one-name-per-record layout expected by name-mode auto-detection.
mode = "name"
else:
mode = detect_mode(scr)
extractor = {
"name": extract_name,
"numeric": extract_numeric,
@@ -4491,6 +4942,8 @@ def main() -> int:
extractor = extract_voice_configuration
elif mode == "name" and name == "LAINIT":
extractor = extract_terrain_definitions
elif mode == "name" and name == "TRINIT":
extractor = extract_training_actions
elif mode == "numeric" and name == "SPINIT":
extractor = extract_h_scene_gallery
elif mode == "footer" and name == "MPINIT":

View File

@@ -365,6 +365,67 @@ def profile_h_scene_gallery(data: dict) -> dict:
}
def profile_training_actions(data: dict) -> dict:
"""Summarize TRINIT's training-action gates, effects, and events."""
if data.get("schema") != "training-action-definitions":
return {}
records = data.get("records", [])
return {
"action_count": len(records),
"string_line_count": data.get("string_write_count", 0),
"description_line_count": sum(
len(record.get("description_lines", []))
for record in records
),
"locked_hint_line_count": sum(
len(record.get("locked_hint_lines", []))
for record in records
),
"required_story_flag_cell_count": data.get(
"authored_numeric_cell_counts", {}
).get("required_story_flag_ids", 0),
"required_item_count": sum(
"required_item_id" in record.get("eligibility", {})
for record in records
),
"minimum_alignment_gate_count": sum(
"minimum_alignment" in record.get("eligibility", {})
for record in records
),
"maximum_alignment_gate_count": sum(
"maximum_alignment" in record.get("eligibility", {})
for record in records
),
"minimum_training_gate_count": sum(
"minimum_training_progress"
in record.get("eligibility", {})
for record in records
),
"stat_delta_cell_count": data.get(
"authored_numeric_cell_counts", {}
).get("unit_stat_deltas", 0),
"awarded_item_count": sum(
"awarded_item_id" in record.get("effects", {})
for record in records
),
"awarded_skill_count": sum(
"awarded_skill_id" in record.get("effects", {})
for record in records
),
"event_cell_count": data.get("event_cell_count", 0),
"distinct_event_count": len(
data.get("distinct_event_story_flag_ids", [])
),
"resolved_event_dispatch_count": data.get(
"resolved_event_dispatch_count", 0
),
"execution_limits": dict(sorted(collections.Counter(
str(record.get("execution_limit", 0))
for record in records
).items(), key=lambda item: int(item[0]))),
}
def profile_messages(data: dict) -> dict:
"""Summarize the joined player-facing message evidence."""
records = data["records"]
@@ -492,7 +553,29 @@ def render_markdown(data: dict, rows: list[dict], limit: int) -> str:
f"- records: {data['record_count']}",
f"- populated fields: {len(rows)}",
]
if h_gallery_profile := profile_h_scene_gallery(data):
if training_profile := profile_training_actions(data):
lines.extend([
f"- training actions: {training_profile['action_count']}",
f"- display text lines: "
f"{training_profile['description_line_count']} available + "
f"{training_profile['locked_hint_line_count']} locked",
f"- eligibility cells: "
f"{training_profile['required_story_flag_cell_count']} required "
f"story flags, {training_profile['required_item_count']} items, "
f"{training_profile['minimum_alignment_gate_count']} minimum + "
f"{training_profile['maximum_alignment_gate_count']} maximum "
f"alignment gates, "
f"{training_profile['minimum_training_gate_count']} training gates",
f"- effect cells: {training_profile['stat_delta_cell_count']} stat "
f"deltas, {training_profile['awarded_skill_count']} skill awards, "
f"{training_profile['awarded_item_count']} item awards",
f"- event slots: {training_profile['event_cell_count']} across "
f"{training_profile['distinct_event_count']} distinct story flags "
f"({training_profile['resolved_event_dispatch_count']} dispatches "
f"resolved)",
f"- execution limits: {training_profile['execution_limits']}",
])
elif h_gallery_profile := profile_h_scene_gallery(data):
lines.extend([
f"- geometry: {h_gallery_profile['page_count']} pages × "
f"{h_gallery_profile['slots_per_page']} slots",
@@ -667,6 +750,7 @@ def main() -> int:
"map_atlas_profile": profile_map_atlas(data),
"terrain_definition_profile": profile_terrain_definitions(data),
"h_scene_gallery_profile": profile_h_scene_gallery(data),
"training_action_profile": profile_training_actions(data),
"columns": sorted(rows, key=lambda row: (
int(row["base"], 16), row["stride"] or 0, row["column"] or 0
)),

View File

@@ -1572,6 +1572,90 @@ def test_h_scene_gallery() -> None:
)
def test_training_actions() -> None:
scripts = paths.scripts()
script = sys4load.load(scripts["TRINIT.BIN"])
records, meta = extract_init.extract_training_actions(script)
by_id = {record["id"]: record for record in records}
check(
len(records) == 21
and meta["reserved_record_count"] == 21
and meta["string_stride"] == 6
and meta["numeric_block_start"] == "0x155bbc"
and meta["numeric_block_end_exclusive"] == "0x1560e7",
"TRINIT exposes its 21-row text and contiguous numeric geometry",
)
check(
meta["string_write_count"] == 75
and meta["static_write_count"] == 289
and meta["classified_static_write_count"] == 289
and meta["classified_instruction_count"] == 365,
"TRINIT classifies every text, numeric, and exit instruction",
)
check(
by_id[0]["description_lines"]
== [
"使い魔と性魔術を行い、能力を高める。",
"精気20必要。『捕獲攻撃』獲得。",
]
and by_id[1]["locked_hint_lines"]
== [
"使い魔の成長や特別なアイテムが必要の",
"ようだ……。",
"作る為の方法と材料は……。",
]
and by_id[15]["description_lines"][2]
== "さらに最大精気+2。",
"TRINIT preserves the available and locked three-line text families",
)
check(
by_id[0]["eligibility"]["minimum_unit_level"] == 3
and by_id[4]["eligibility"]["minimum_alignment"] == 15
and by_id[13]["eligibility"]["maximum_alignment"] == -75
and by_id[13]["eligibility"]["minimum_training_progress"] == 45
and by_id[1]["eligibility"]["required_item_name"]
== "マタタビの媚薬",
"TRINIT decodes level, alignment, training, and ITINIT gates",
)
check(
by_id[0]["effects"]["spirit_cost"] == 20
and by_id[0]["effects"]["unit_stat_deltas"]
== {
"physical_attack": 7,
"physical_defense": 4,
"speed": 8,
"luck": 2,
"max_hp": 15,
"max_sp": 12,
"max_fs": 6,
}
and by_id[0]["effects"]["awarded_skill_name"] == "捕獲攻撃"
and by_id[13]["effects"]["alignment_delta_hundredths"] == -2000
and by_id[13]["effects"]["awarded_item_name"] == "死王の喚石",
"TRINIT joins spirit, stat, alignment, skill, and item effects",
)
check(
meta["event_cell_count"] == 75
and len(meta["distinct_event_story_flag_ids"]) == 38
and meta["resolved_event_dispatch_count"] == 75
and by_id[0]["execution_limit"] == 6
and by_id[0]["event_story_flag_ids"]
== [800, 830, 830, 830, 830, 830, 0, 0, 0, 0]
and by_id[0]["events"][0]["script_name"] == "SC0800.BIN"
and by_id[0]["events"][1]["script_name"] == "SC0830.BIN",
"TRINIT event slots join to SCINIT and retain repeat-scene limits",
)
check(
meta["required_item_join_count"] == 8
and meta["awarded_item_join_count"] == 3
and meta["awarded_skill_join_count"] == 8
and meta["authored_numeric_cell_counts"]["unit_stat_deltas"] == 95
and meta["authored_numeric_cell_counts"]["event_story_flag_ids"]
== 75,
"TRINIT accounts for every definition join and populated field family",
)
def test_condition_definitions() -> None:
scripts = paths.scripts()
script = sys4load.load(scripts["ILINIT.BIN"])
@@ -1788,6 +1872,7 @@ if __name__ == "__main__":
test_voice_configuration()
test_terrain_definitions()
test_h_scene_gallery()
test_training_actions()
test_map_terrain_atlas()
test_condition_definitions()
test_field_semantics()

View File

@@ -78,6 +78,14 @@ def test_load_and_lint():
and entries[0x1561f6]["name"]
== "magic_action_information_handler_script_ids",
"MAINIT/MAMES action state is curated")
check(entries[0x453b]["name"] == "training_action_text"
and entries[0x155bbc]["name"]
== "training_action_required_story_flag_ids"
and entries[0x155e9b]["columns"]["13"] == "max_fs"
and entries[0x156015]["columns"]["9"] == "execution_10"
and entries[0x6722]["name"] == "familiar_alignment"
and entries[0x6727]["name"] == "training_action_execution_counts",
"TRINIT/TRAIN action 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,60 @@ def main() -> int:
assert "- geometry: 8 pages × 15 slots" in rendered_h_gallery
assert "- populated scenes: 118/120" in rendered_h_gallery
training_fixture = {
"table": "TRAINING",
"mode": "name",
"schema": "training-action-definitions",
"record_count": 3,
"string_write_count": 7,
"authored_numeric_cell_counts": {
"required_story_flag_ids": 2,
"unit_stat_deltas": 4,
},
"event_cell_count": 6,
"distinct_event_story_flag_ids": [800, 801, 830],
"resolved_event_dispatch_count": 6,
"records": [
{
"description_lines": ["one", "cost"],
"locked_hint_lines": [],
"eligibility": {},
"effects": {"awarded_skill_id": 1},
"execution_limit": 3,
},
{
"description_lines": ["two", "cost"],
"locked_hint_lines": ["locked"],
"eligibility": {
"required_item_id": 35,
"minimum_alignment": 10,
"minimum_training_progress": 5,
},
"effects": {"awarded_item_id": 51},
"execution_limit": 2,
},
{
"description_lines": ["three"],
"locked_hint_lines": ["locked", "more"],
"eligibility": {"maximum_alignment": -20},
"effects": {},
"execution_limit": 1,
},
],
}
training_summary = profile.profile_training_actions(training_fixture)
assert training_summary["action_count"] == 3
assert training_summary["description_line_count"] == 5
assert training_summary["locked_hint_line_count"] == 3
assert training_summary["required_item_count"] == 1
assert training_summary["minimum_alignment_gate_count"] == 1
assert training_summary["maximum_alignment_gate_count"] == 1
assert training_summary["stat_delta_cell_count"] == 4
assert training_summary["execution_limits"] == {"1": 1, "2": 1, "3": 1}
rendered_training = profile.render_markdown(training_fixture, [], 40)
assert "- training actions: 3" in rendered_training
assert "- event slots: 6 across 3 distinct story flags" in rendered_training
messages = profile.profile_messages(fixture)
assert messages["population"] == 1
assert messages["coverage"] == 1 / 3