Decode CCINIT class change rules
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Extract a *INIT data table to JSON. Auto-detects the table's shape.
|
||||
|
||||
*INIT scripts populate global arrays and work buffers with static game data. Four shapes seen:
|
||||
*INIT scripts populate global arrays and work buffers with static game data. Five shapes seen:
|
||||
|
||||
name — records keyed by a name string. Each record: set-string(name), static field writes,
|
||||
set-string(desc). Arrays indexed by record id in lockstep (+1/record).
|
||||
@@ -12,12 +12,14 @@
|
||||
footer into per-record global arrays. The data lives in the footer. (MPINIT maps)
|
||||
mixed — a sparse selector dispatch writes strings, scalars, fixed-buffer cells, and
|
||||
footer arrays for one runtime record. (STINIT stages)
|
||||
rules — conditional blocks select a unit promotion and add effects to shared output
|
||||
buffers. (CCINIT class changes)
|
||||
|
||||
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.
|
||||
|
||||
Usage: py -3.11 -X utf8 tools/extract_init.py <TABLE> [OUTNAME] [--mode name|numeric|footer|mixed]
|
||||
Usage: py -3.11 -X utf8 tools/extract_init.py <TABLE> [OUTNAME] [--mode name|numeric|footer|mixed|rules]
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import json
|
||||
@@ -41,6 +43,22 @@ T_GLOBAL_STRING = 5
|
||||
T_IMM = 0
|
||||
T_LOCAL_INT = 9
|
||||
|
||||
CURRENT_UNIT_ID = 0x66715
|
||||
CURRENT_UNIT_LEVELS = 0x6930
|
||||
UNIT_CLASS_CHANGE_STATE = 0x573BB
|
||||
CLASS_CHANGE_TITLE_OUT = 0x26B4
|
||||
CLASS_CHANGE_LEVEL_OUT = 0xAB8E7
|
||||
CLASS_CHANGE_COST_OUT = 0xAB8E8
|
||||
CLASS_CHANGE_STATS_OUT = 0xAB8E9
|
||||
CLASS_CHANGE_SKILLS_OUT = 0xAB8F7
|
||||
CLASS_CHANGE_FLAGS_OUT = 0xAB8FB
|
||||
|
||||
UNIT_STAT_COLUMNS = (
|
||||
"accuracy", "evasion", "physical_attack", "physical_defense",
|
||||
"magic_attack", "magic_defense", "speed", "luck", "critical_chance",
|
||||
"capture_power", "movement", "max_hp", "max_sp", "max_fs",
|
||||
)
|
||||
|
||||
MESSAGE_TABLES = {
|
||||
"ITINIT": "ITMES",
|
||||
"SKINIT": "SKMES",
|
||||
@@ -130,10 +148,35 @@ def _mixed_guards(scr):
|
||||
return [guard for guard in candidates if guard["selector"] == selector]
|
||||
|
||||
|
||||
def _class_change_guards(scr) -> list[dict]:
|
||||
"""Find CCINIT's source-ordered `current_unit_id == immediate` rule guards."""
|
||||
guards = []
|
||||
for index, ins in enumerate(scr.instructions):
|
||||
if (sys4load.display_label(ins.opcode) == "eq"
|
||||
and len(ins.args) >= 3
|
||||
and ins.args[0][0] == T_LOCAL_INT
|
||||
and ins.args[1] == (T_GLOBAL_INT, CURRENT_UNIT_ID)
|
||||
and ins.args[2][0] == T_IMM):
|
||||
guards.append({
|
||||
"index": index,
|
||||
"offset": ins.offset,
|
||||
"unit_id": ins.args[2][1],
|
||||
})
|
||||
return guards
|
||||
|
||||
|
||||
def detect_mode(scr):
|
||||
ops = [ins.opcode for ins in scr.instructions]
|
||||
has_str = any(ins.opcode == SET_STRING and ins.args and ins.args[0][0] == T_GLOBAL_STRING
|
||||
for ins in scr.instructions)
|
||||
has_class_change_title = any(
|
||||
ins.opcode == SET_STRING
|
||||
and ins.args
|
||||
and ins.args[0] == (T_GLOBAL_STRING, CLASS_CHANGE_TITLE_OUT)
|
||||
for ins in scr.instructions
|
||||
)
|
||||
if has_class_change_title and len(_class_change_guards(scr)) >= 4:
|
||||
return "rules"
|
||||
if has_str and len(_mixed_guards(scr)) >= 4:
|
||||
return "mixed"
|
||||
if has_str:
|
||||
@@ -143,6 +186,157 @@ def detect_mode(scr):
|
||||
return "footer" if n_footer >= max(4, n_int) else "numeric"
|
||||
|
||||
|
||||
@cache
|
||||
def unit_definition_names() -> dict[int, str]:
|
||||
"""Load EBINIT's authoritative unit names by definition id."""
|
||||
records, _ = extract_name(sys4load.load(resolve("EBINIT")))
|
||||
return {record["id"]: record["name"] for record in records}
|
||||
|
||||
|
||||
@cache
|
||||
def skill_definition_names() -> dict[int, str]:
|
||||
"""Load SKINIT's authoritative skill names by skill id."""
|
||||
records, _ = extract_name(sys4load.load(resolve("SKINIT")))
|
||||
return {record["id"]: record["name"] for record in records}
|
||||
|
||||
|
||||
def extract_class_change_rules(scr):
|
||||
"""Extract CCINIT's promotion predicates and accumulator effects.
|
||||
|
||||
CALCCC initializes the output block, invokes CCINIT, and applies the selected
|
||||
title, cost delta, fourteen stat deltas, and up to three skills to the unit.
|
||||
Each CCINIT block is therefore a rule rather than a row in a static table.
|
||||
"""
|
||||
guards = _class_change_guards(scr)
|
||||
if not guards:
|
||||
return [], {}
|
||||
|
||||
unit_names = unit_definition_names()
|
||||
skill_names = skill_definition_names()
|
||||
records = []
|
||||
instructions = scr.instructions
|
||||
for rule_index, guard in enumerate(guards):
|
||||
end = guards[rule_index + 1]["index"] if rule_index + 1 < len(guards) else len(instructions)
|
||||
block = instructions[guard["index"]:end]
|
||||
record = {
|
||||
"id": rule_index + 1,
|
||||
"guard_offset": f"0x{guard['offset']:x}",
|
||||
"unit_id": guard["unit_id"],
|
||||
"unit_name": unit_names.get(guard["unit_id"], ""),
|
||||
"fields": {},
|
||||
"string_fields": {},
|
||||
"array_fields": {},
|
||||
}
|
||||
|
||||
for ins in block:
|
||||
label = sys4load.display_label(ins.opcode)
|
||||
if (label == "lookup-array"
|
||||
and len(ins.args) >= 3
|
||||
and ins.args[1] == (T_GLOBAL_INT, CURRENT_UNIT_LEVELS)
|
||||
and ins.args[2] == (T_GLOBAL_INT, CURRENT_UNIT_ID)):
|
||||
record["level_table"] = f"0x{CURRENT_UNIT_LEVELS:x}"
|
||||
elif (label == "gre"
|
||||
and len(ins.args) >= 3
|
||||
and ins.args[1][0] == 12
|
||||
and ins.args[2][0] == T_IMM
|
||||
and "level_table" in record):
|
||||
record["minimum_level"] = ins.args[2][1]
|
||||
elif (label == "lookup-array-2d"
|
||||
and len(ins.args) >= 5
|
||||
and ins.args[1] == (T_GLOBAL_INT, UNIT_CLASS_CHANGE_STATE)
|
||||
and ins.args[2] == (T_GLOBAL_INT, CURRENT_UNIT_ID)
|
||||
and ins.args[3] == (T_IMM, 10)
|
||||
and ins.args[4][0] == T_IMM):
|
||||
record["class_change_slot_index"] = ins.args[4][1]
|
||||
elif (label == "ne"
|
||||
and len(ins.args) >= 3
|
||||
and ins.args[1] == (T_GLOBAL_INT, CURRENT_UNIT_ID)
|
||||
and ins.args[2][0] == T_GLOBAL_INT):
|
||||
record["excluded_when_unit_equals_global"] = f"0x{ins.args[2][1]:x}"
|
||||
elif (ins.opcode == SET_STRING
|
||||
and len(ins.args) >= 2
|
||||
and ins.args[0] == (T_GLOBAL_STRING, CLASS_CHANGE_TITLE_OUT)):
|
||||
title = scr.strings.get(ins.args[1][1], ("",))[0]
|
||||
record["title"] = title
|
||||
record["name"] = title
|
||||
record["string_fields"][f"0x{CLASS_CHANGE_TITLE_OUT:x}"] = title
|
||||
elif (write := _static_global_write(ins)) is not None:
|
||||
destination, value = write
|
||||
if destination == CLASS_CHANGE_LEVEL_OUT:
|
||||
record["selected_level"] = value
|
||||
record["fields"][f"0x{destination:x}"] = value
|
||||
elif CLASS_CHANGE_SKILLS_OUT <= destination < CLASS_CHANGE_SKILLS_OUT + 4:
|
||||
record["array_fields"][
|
||||
f"0x{CLASS_CHANGE_SKILLS_OUT:x}/{destination - CLASS_CHANGE_SKILLS_OUT}"
|
||||
] = value
|
||||
elif CLASS_CHANGE_FLAGS_OUT <= destination < CLASS_CHANGE_FLAGS_OUT + 10:
|
||||
record["array_fields"][
|
||||
f"0x{CLASS_CHANGE_FLAGS_OUT:x}/{destination - CLASS_CHANGE_FLAGS_OUT}"
|
||||
] = value
|
||||
elif (label == "add"
|
||||
and len(ins.args) >= 3
|
||||
and ins.args[0][0] == T_GLOBAL_INT
|
||||
and ins.args[0] == ins.args[1]
|
||||
and ins.args[2][0] == T_IMM):
|
||||
destination = ins.args[0][1]
|
||||
value = ins.args[2][1]
|
||||
if destination == CLASS_CHANGE_COST_OUT:
|
||||
record["fields"][f"0x{destination:x}"] = value
|
||||
elif CLASS_CHANGE_STATS_OUT <= destination < CLASS_CHANGE_STATS_OUT + 14:
|
||||
record["array_fields"][
|
||||
f"0x{CLASS_CHANGE_STATS_OUT:x}/{destination - CLASS_CHANGE_STATS_OUT}"
|
||||
] = value
|
||||
|
||||
stat_bonuses = {
|
||||
UNIT_STAT_COLUMNS[int(key.split("/")[1])]: value
|
||||
for key, value in record["array_fields"].items()
|
||||
if key.startswith(f"0x{CLASS_CHANGE_STATS_OUT:x}/")
|
||||
}
|
||||
if stat_bonuses:
|
||||
record["stat_bonuses"] = stat_bonuses
|
||||
record["deployment_cost_delta"] = record["fields"].get(
|
||||
f"0x{CLASS_CHANGE_COST_OUT:x}", 0
|
||||
)
|
||||
skill_awards = []
|
||||
for key, skill_id in record["array_fields"].items():
|
||||
if not key.startswith(f"0x{CLASS_CHANGE_SKILLS_OUT:x}/") or skill_id <= 0:
|
||||
continue
|
||||
skill_awards.append({
|
||||
"skill_slot": int(key.split("/")[1]) + 1,
|
||||
"skill_id": skill_id,
|
||||
"skill_name": skill_names.get(skill_id, ""),
|
||||
})
|
||||
if skill_awards:
|
||||
record["skill_awards"] = skill_awards
|
||||
record["state_flag_indices_set"] = [
|
||||
int(key.split("/")[1])
|
||||
for key, value in record["array_fields"].items()
|
||||
if key.startswith(f"0x{CLASS_CHANGE_FLAGS_OUT:x}/") and value
|
||||
]
|
||||
records.append(record)
|
||||
|
||||
array_columns = sorted({
|
||||
key for record in records for key in record["array_fields"]
|
||||
}, key=lambda key: tuple(int(part, 0) for part in key.split("/")))
|
||||
string_columns = sorted({
|
||||
key for record in records for key in record["string_fields"]
|
||||
}, key=lambda key: int(key, 0))
|
||||
return records, {
|
||||
"rule_kind": "unit-class-change",
|
||||
"selector_global": f"0x{CURRENT_UNIT_ID:x}",
|
||||
"unit_level_table": f"0x{CURRENT_UNIT_LEVELS:x}",
|
||||
"persistent_state_table": f"0x{UNIT_CLASS_CHANGE_STATE:x}",
|
||||
"selection_policy": "highest selected_level among eligible unapplied rules",
|
||||
"array_layouts": {
|
||||
f"0x{CLASS_CHANGE_STATS_OUT:x}": {"length": 14},
|
||||
f"0x{CLASS_CHANGE_SKILLS_OUT:x}": {"length": 4},
|
||||
f"0x{CLASS_CHANGE_FLAGS_OUT:x}": {"length": 10},
|
||||
},
|
||||
"string_field_columns": string_columns,
|
||||
"array_field_columns": array_columns,
|
||||
}
|
||||
|
||||
|
||||
def _eval_static_arg(arg, locals_: dict[int, int]):
|
||||
arg_type, value = arg
|
||||
if arg_type == T_IMM:
|
||||
@@ -820,6 +1014,12 @@ def write_data_index(data_dir: Path) -> None:
|
||||
"available descriptions; consumer-proven tagged payload variants receive semantic names while",
|
||||
"engine-dead tagged writes remain in `ignored_payload_fields` and unresolved",
|
||||
"type-specific/mode parameters remain in `unknown_fields`.",
|
||||
"",
|
||||
"Rule-mode tables preserve source-order rule ids and bytecode guard offsets while",
|
||||
"joining their predicates and shared-buffer effects. CCINIT exposes unit/level/applied-slot-index",
|
||||
"eligibility, titles, deployment-cost and named stat deltas, awarded SKINIT skills, and",
|
||||
"the persistent state slot set by each class change. Raw output addresses remain beside",
|
||||
"the joined EBINIT unit and SKINIT skill names.",
|
||||
"Use `tools/init_table_profile.py <TABLE> --build` to generate value/population and",
|
||||
"direct-consumer evidence.",
|
||||
"",
|
||||
@@ -858,6 +1058,7 @@ def main() -> int:
|
||||
"numeric": extract_numeric,
|
||||
"footer": extract_footer,
|
||||
"mixed": extract_mixed,
|
||||
"rules": extract_class_change_rules,
|
||||
}[mode]
|
||||
recs, meta = extractor(scr)
|
||||
if mode == "name" and name in MESSAGE_TABLES:
|
||||
|
||||
@@ -37,8 +37,8 @@ def load_table(name: str) -> dict:
|
||||
if not path.exists():
|
||||
raise SystemExit(f"missing extracted table: {path}")
|
||||
data = json.loads(path.read_text(encoding="utf8"))
|
||||
if data.get("mode") not in {"name", "numeric", "mixed"}:
|
||||
raise SystemExit(f"{name}: field profiling requires name/numeric/mixed mode")
|
||||
if data.get("mode") not in {"name", "numeric", "mixed", "rules"}:
|
||||
raise SystemExit(f"{name}: unsupported field-profiling mode {data.get('mode')!r}")
|
||||
return data
|
||||
|
||||
|
||||
@@ -83,7 +83,11 @@ def profile_columns(data: dict) -> list[dict]:
|
||||
key = f"0x{base:x}"
|
||||
add(key, {
|
||||
"key": key,
|
||||
"kind": "scalar-field" if data.get("mode") == "mixed" else "parallel-array",
|
||||
"kind": (
|
||||
"scalar-field" if data.get("mode") == "mixed"
|
||||
else "rule-output" if data.get("mode") == "rules"
|
||||
else "parallel-array"
|
||||
),
|
||||
"base": key,
|
||||
"stride": None, "column": None,
|
||||
"semantic_name": field_semantics.get(key),
|
||||
@@ -156,6 +160,31 @@ def profile_columns(data: dict) -> list[dict]:
|
||||
return rows
|
||||
|
||||
|
||||
def profile_rules(data: dict) -> dict:
|
||||
"""Summarize predicates and joined effects for conditional rule programs."""
|
||||
if data.get("mode") != "rules":
|
||||
return {}
|
||||
records = data["records"]
|
||||
return {
|
||||
"unit_count": len({record["unit_id"] for record in records}),
|
||||
"titled_rule_count": sum(bool(record.get("title")) for record in records),
|
||||
"level_independent_rule_count": sum(
|
||||
"minimum_level" not in record for record in records
|
||||
),
|
||||
"minimum_levels": dict(sorted(collections.Counter(
|
||||
str(record["minimum_level"])
|
||||
for record in records if "minimum_level" in record
|
||||
).items(), key=lambda item: int(item[0]))),
|
||||
"class_change_slot_indices": dict(sorted(collections.Counter(
|
||||
str(record["class_change_slot_index"])
|
||||
for record in records if "class_change_slot_index" in record
|
||||
).items(), key=lambda item: int(item[0]))),
|
||||
"skill_award_count": sum(
|
||||
len(record.get("skill_awards", [])) for record in records
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def profile_messages(data: dict) -> dict:
|
||||
"""Summarize the joined player-facing message evidence."""
|
||||
records = data["records"]
|
||||
@@ -270,14 +299,26 @@ def render_markdown(data: dict, rows: list[dict], limit: int) -> str:
|
||||
"",
|
||||
f"- records: {data['record_count']}",
|
||||
f"- populated fields: {len(rows)}",
|
||||
f"- player-facing messages: {message_profile['population']}/{data['record_count']} "
|
||||
f"({message_profile['coverage']:.0%})",
|
||||
f"- messages with furigana spans: {message_profile['furigana_records']}",
|
||||
]
|
||||
if rule_profile := profile_rules(data):
|
||||
lines.extend([
|
||||
f"- covered units: {rule_profile['unit_count']}",
|
||||
f"- titled rules: {rule_profile['titled_rule_count']}/{data['record_count']}",
|
||||
f"- level-independent rules: {rule_profile['level_independent_rule_count']}",
|
||||
f"- awarded skills: {rule_profile['skill_award_count']}",
|
||||
])
|
||||
else:
|
||||
lines.extend([
|
||||
f"- player-facing messages: {message_profile['population']}/{data['record_count']} "
|
||||
f"({message_profile['coverage']:.0%})",
|
||||
f"- messages with furigana spans: {message_profile['furigana_records']}",
|
||||
])
|
||||
lines.extend([
|
||||
f"- rows shown: {len(shown)} (ranked by record coverage, then consumer references)",
|
||||
"",
|
||||
"| field | meaning | populated | distinct | range | direct refs | readers | common values | examples |",
|
||||
"|---|---|---:|---:|---|---:|---|---|---|",
|
||||
]
|
||||
])
|
||||
for row in shown:
|
||||
value_range = "—" if row["min"] is None else f"{row['min']}..{row['max']}"
|
||||
readers = ", ".join(entry["script"].removesuffix(".BIN")
|
||||
@@ -325,7 +366,9 @@ def main() -> int:
|
||||
"string_field_count": sum(row["kind"] == "string-field" for row in rows),
|
||||
"array_cell_count": sum(row["kind"] == "array-cell" for row in rows),
|
||||
"footer_array_count": sum(row["kind"] == "footer-array" for row in rows),
|
||||
"rule_output_count": sum(row["kind"] == "rule-output" for row in rows),
|
||||
"message_profile": messages,
|
||||
"rule_profile": profile_rules(data),
|
||||
"columns": sorted(rows, key=lambda row: (
|
||||
int(row["base"], 16), row["stride"] or 0, row["column"] or 0
|
||||
)),
|
||||
|
||||
@@ -263,6 +263,52 @@ def test_real_mixed_table() -> None:
|
||||
) == 485, "STINIT exposes every first-clear-only enemy gate")
|
||||
|
||||
|
||||
def test_real_class_change_rules() -> None:
|
||||
script = sys4load.load(extract_init.resolve("CCINIT"))
|
||||
check(extract_init.detect_mode(script) == "rules",
|
||||
"CCINIT auto-detects as a conditional rule program")
|
||||
records, meta = extract_init.extract_class_change_rules(script)
|
||||
check(len(records) == 71, "CCINIT extracts all 71 class-change rules")
|
||||
check(len({record["unit_id"] for record in records}) == 33,
|
||||
"CCINIT rules cover 33 unit definitions")
|
||||
check(meta["selector_global"] == "0x66715"
|
||||
and meta["persistent_state_table"] == "0x573bb",
|
||||
"CCINIT exposes its unit selector and persistent state table")
|
||||
check(records[0]["unit_name"] == "リリィ:少女時代"
|
||||
and records[0]["selected_level"] == -1
|
||||
and records[0]["excluded_when_unit_equals_global"] == "0x32f0",
|
||||
"CCINIT preserves Lily's level-independent form rule")
|
||||
sylphine = records[2]
|
||||
check(sylphine["unit_id"] == 5
|
||||
and sylphine["minimum_level"] == 50
|
||||
and sylphine["class_change_slot_index"] == 0
|
||||
and sylphine["title"] == "聖王女",
|
||||
"CCINIT decodes unit, level, slot, and awarded title")
|
||||
check(sylphine["deployment_cost_delta"] == 2
|
||||
and sylphine["stat_bonuses"]["physical_attack"] == 3,
|
||||
"CCINIT decodes cost and named stat bonuses")
|
||||
check(sylphine["skill_awards"] == [{
|
||||
"skill_slot": 3, "skill_id": 202, "skill_name": "光燐衝撃",
|
||||
}], "CCINIT joins awarded skill ids to SKINIT names")
|
||||
semantics = extract_init.field_semantics(records, meta["array_layouts"])
|
||||
check(semantics["0x26b4"] == "class_change_title_output"
|
||||
and semantics["0xab8e9/2"] == "class_change_stat_bonuses.physical_attack"
|
||||
and semantics["0xab8f7/2"] == "class_change_skill_awards.skill_slot_3",
|
||||
"CCINIT raw outputs join to canonical global and column names")
|
||||
extract_init.attach_semantic_fields(records, semantics)
|
||||
check(sylphine["semantic_fields"]["class_change_title_output"] == "聖王女"
|
||||
and sylphine["semantic_fields"][
|
||||
"class_change_stat_bonuses.physical_attack"
|
||||
] == 3,
|
||||
"CCINIT rules expose a single semantic field view")
|
||||
check(all(
|
||||
record["minimum_level"] == record["selected_level"]
|
||||
for record in records[2:]
|
||||
), "CCINIT normal promotion thresholds match their selected levels")
|
||||
check(sum(len(record.get("skill_awards", [])) for record in records) == 30,
|
||||
"CCINIT accounts for all 30 awarded skills")
|
||||
|
||||
|
||||
def test_real_message_tables() -> None:
|
||||
scripts = paths.scripts()
|
||||
expected = {
|
||||
@@ -436,6 +482,7 @@ if __name__ == "__main__":
|
||||
test_static_negative_write()
|
||||
test_output_name_validation()
|
||||
test_real_mixed_table()
|
||||
test_real_class_change_rules()
|
||||
test_real_message_tables()
|
||||
test_message_join()
|
||||
test_field_semantics()
|
||||
|
||||
@@ -70,6 +70,31 @@ def main() -> int:
|
||||
assert mixed["0x100/6"]["common"][0]["value"] == "[1, 2, 3]"
|
||||
assert mixed["0x40"]["examples"][0]["name"] == "Win"
|
||||
|
||||
rule_fixture = {
|
||||
"table": "RULES",
|
||||
"mode": "rules",
|
||||
"records": [
|
||||
{
|
||||
"id": 1, "unit_id": 3, "title": "", "fields": {"0x10": -1},
|
||||
"class_change_slot_index": 0, "skill_awards": [{"skill_id": 2}],
|
||||
},
|
||||
{
|
||||
"id": 2, "unit_id": 5, "title": "Promoted",
|
||||
"minimum_level": 50, "class_change_slot_index": 1,
|
||||
"fields": {"0x10": 50},
|
||||
},
|
||||
],
|
||||
}
|
||||
rule_rows = {row["key"]: row for row in profile.profile_columns(rule_fixture)}
|
||||
assert rule_rows["0x10"]["kind"] == "rule-output"
|
||||
rule_summary = profile.profile_rules(rule_fixture)
|
||||
assert rule_summary["unit_count"] == 2
|
||||
assert rule_summary["titled_rule_count"] == 1
|
||||
assert rule_summary["level_independent_rule_count"] == 1
|
||||
assert rule_summary["minimum_levels"] == {"50": 1}
|
||||
assert rule_summary["class_change_slot_indices"] == {"0": 1, "1": 1}
|
||||
assert rule_summary["skill_award_count"] == 1
|
||||
|
||||
messages = profile.profile_messages(fixture)
|
||||
assert messages["population"] == 1
|
||||
assert messages["coverage"] == 1 / 3
|
||||
|
||||
Reference in New Issue
Block a user