diff --git a/docs/limitations.md b/docs/limitations.md index ad49d45..edfce61 100644 --- a/docs/limitations.md +++ b/docs/limitations.md @@ -12,6 +12,6 @@ Known limitations: - Live capture currently uses Windows raw sockets and requires administrator permission. - The file adapter reads mitmproxy `.flows` captures for research and testing. - The more stable and reliable Npcap/libpcap capture is not implemented yet. -- Reward mappings are research mappings and should be expanded as more samples are decoded. +- Reward keys decode to their reward id string, so unknown rewards still export a usable `reward_id`; display names/ranks come from the mapping JSON files and should be expanded as new rewards appear. Privacy guardrail: raw packet data must not be included in sanitized exports. diff --git a/docs/packet-format.md b/docs/packet-format.md index eb82f95..455c816 100644 --- a/docs/packet-format.md +++ b/docs/packet-format.md @@ -22,7 +22,15 @@ Decoded fields: - `source_flag = -4` means Chase Reward - Timestamp is an 8-byte little-endian value - `unix_seconds = little_endian_u64(timestamp_raw) / 40000000 - 62135596800` -- Rewards are identified by stable binary keys +- Reward keys are the reward id string encoded one character per byte as + `ASCII * 4` with carry chaining into the next byte. The final byte is the + pending carry (`00` or `01`) acting as a terminator, or is omitted. + Examples: `98bdc9ad7dd9a5b99501` decodes to `fork_vine`, + `10a58d9539bdc9b585b101` to `DiceNormal`, `c4c0cccc00` to character id `1033`. + Arc/Gashapon history uses the same scheme at `ASCII * 2`. +- The decoder decodes the key to its id string and looks up display metadata + in `mappings/arcs.json`, `mappings/characters.json`, and `mappings/items.json`. + Unknown rewards still export their decoded id with empty name/rank. Page and row numbers are research metadata only. They must not be used for permanent dedupe because they shift when new history appears. diff --git a/mappings/arcs.json b/mappings/arcs.json index 2f1c54d..afaef58 100644 --- a/mappings/arcs.json +++ b/mappings/arcs.json @@ -7,8 +7,41 @@ "fork_PaperPlane": {"name": "Clear Skies", "rank": "A"}, "fork_mofeikesi": {"name": "Good Boy's Grand Adventure", "rank": "S"}, "fork_wuhuakuang": {"name": "The Forgotten", "rank": "A"}, + "fork_whuakuang": {"name": "The Forgotten", "rank": "A"}, "fork_jingmotingyuan": {"name": "Camellia Society", "rank": "S"}, "fork_Kite": {"name": "Watch Your Heads!", "rank": "A"}, "fork_yuren": {"name": "Umbrella", "rank": "A"}, - "fork_yaodao": {"name": "Drawn Blade", "rank": "A"} + "fork_yaodao": {"name": "Drawn Blade", "rank": "A"}, + "fork_Arachne": {"name": "Eternal Waltz", "rank": "S"}, + "fork_BitGame": {"name": "Blow up the Crowd", "rank": "S"}, + "fork_BitterCake": {"name": "The Good, The Bad, The Bitter", "rank": "A"}, + "fork_BlackBook": {"name": "Youthful Fantasy", "rank": "S"}, + "fork_Baozhatang": {"name": "Fluff of Fearlessness", "rank": "S"}, + "fork_bopu": {"name": "Cosmos Daze, Wild Reverie", "rank": "A"}, + "fork_Quanjitang": {"name": "Fluff of Fortitude", "rank": "S"}, + "fork_Butterfly": {"name": "Reality Refuge", "rank": "S"}, + "fork_Castle": {"name": "Call of the Twisted City", "rank": "A"}, + "fork_Qiaoqiao": {"name": "Time Bandit", "rank": "A"}, + "fork_jiaojuan": {"name": "Shiny Days", "rank": "A"}, + "fork_KnightCandy": {"name": "Fluff of Ferocity", "rank": "S"}, + "fork_koinobori": {"name": "A Time Will Come", "rank": "A"}, + "fork_lingganzhongjiezhe": {"name": "Mind Royale", "rank": "A"}, + "fork_mamen": {"name": "Contemplative Cat", "rank": "S"}, + "fork_moon": {"name": "Stellar Veil", "rank": "S"}, + "fork_MotorCandy": {"name": "Fluff of Fleetness", "rank": "S"}, + "fork_Nakupeda": {"name": "Your Happiness is Priceless", "rank": "S"}, + "fork_NestBird": {"name": "Tears Beneath the Mask", "rank": "S"}, + "fork_oulaquantao": {"name": "Oraora!", "rank": "A"}, + "fork_PoliceRat": {"name": "Hethereau's Keeper", "rank": "S"}, + "fork_rishi": {"name": "Day Off", "rank": "S"}, + "fork_Rose": {"name": "The Last Rose", "rank": "S"}, + "fork_snowman": {"name": "The Fools' Spring", "rank": "A"}, + "fork_spider": {"name": "Failing You, Heavy in My Heart", "rank": "A"}, + "fork_ThiefCandy": {"name": "Fluff of Finesse", "rank": "S"}, + "fork_TigerTally": {"name": "Ready-Ready", "rank": "S"}, + "fork_Time": {"name": "Marching Beyond Time", "rank": "S"}, + "fork_tuansanlang": {"name": "The Great Thief", "rank": "A"}, + "fork_Whale": {"name": "Song of the Whale", "rank": "S"}, + "fork_worldrain": {"name": "The Rain That Shook the World", "rank": "S"}, + "fork_Wushoutieyu": {"name": "Raging Flames", "rank": "S"} } diff --git a/mappings/items.json b/mappings/items.json new file mode 100644 index 0000000..e9ca54b --- /dev/null +++ b/mappings/items.json @@ -0,0 +1,8 @@ +{ + "DiceNormal": {"type": "item", "name": "Fabricated Dice", "rank": null}, + "Dice_ticket_01": {"type": "item", "name": "Warp Piece", "rank": null}, + "Dice_ticket_02": {"type": "item", "name": "Lost Piece", "rank": null}, + "Dicelimite": {"type": "item", "name": "Solid Dice", "rank": null}, + "Fashion_vehicle_1010_V008": {"type": "cosmetic", "name": "Fashion Vehicle Skin 1010 V008", "rank": null}, + "Fashion_Glide_1010": {"type": "cosmetic", "name": "Fashion Glider 1010", "rank": null} +} diff --git a/src/nte_history_exporter/decoder/protocol.py b/src/nte_history_exporter/decoder/protocol.py index 99b4905..c02589c 100644 --- a/src/nte_history_exporter/decoder/protocol.py +++ b/src/nte_history_exporter/decoder/protocol.py @@ -15,7 +15,52 @@ from nte_history_exporter.constants import ( TIMESTAMP_TICKS_PER_SECOND, VALID_DICE_FIELDS, ) -from nte_history_exporter.mappings import KNOWN_REWARDS +from nte_history_exporter.mappings import REWARDS_BY_ID + +REWARD_ID_CHARS = frozenset("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_") + + +def decode_reward_key(raw: bytes) -> str: + """Decode a Monopoly reward key into its reward id string. + + Keys encode the id one character per byte as ASCII*4 with carry chaining + (Arc history uses the same scheme at ASCII*2). The final byte is either the + pending carry (0 or 1) acting as a terminator, or a regular character byte. + The carry bit makes some bytes ambiguous, so decode with backtracking and + accept only parses made of identifier characters. + """ + results: list[str] = [] + + def walk(index: int, carry: int, acc: str) -> None: + if results: + return + if index == len(raw): + if carry == 0: + results.append(acc) + return + byte = raw[index] + if index == len(raw) - 1 and byte == carry: + results.append(acc) + return + for carry_out in (0, 1): + value = byte - carry + 256 * carry_out + if value % 4 == 0 and chr(value // 4) in REWARD_ID_CHARS: + walk(index + 1, carry_out, acc + chr(value // 4)) + + walk(0, 0, "") + return results[0] if results else "" + + +def infer_reward_type(reward_id: str) -> str: + if not reward_id: + return "" + if reward_id.startswith("fork_"): + return "arc" + if reward_id.isdigit(): + return "character" + if reward_id.startswith("Fashion_"): + return "cosmetic" + return "item" def decode_history_timestamp(raw8: bytes) -> tuple[int, float, str]: @@ -98,20 +143,20 @@ def classify_result_type( return "dice", None -def guess_quantity(chunk_hex: str, key_hex: str, result_type: str | None = None) -> int | None: - if key_hex == "10a58d957dd1a58dad95d17dc1c400": +def guess_quantity(chunk_hex: str, reward_id: str, result_type: str | None = None) -> int | None: + if reward_id == "Dice_ticket_01": if result_type == "chase_reward": return 30 return 4 - if key_hex == "10a58d9539bdc9b585b101": + if reward_id == "DiceNormal": return 1 - if key_hex == "10a58d957dd1a58dad95d17dc1c800": + if reward_id == "Dice_ticket_02": if "c8b0d4c0" in chunk_hex: return 50 if "c8b0ccc0" in chunk_hex: return 30 return None - if key_hex: + if reward_id: return 1 return None @@ -142,7 +187,8 @@ def decode_response_records(response_content: bytes) -> list[dict[str, Any]]: dice = -4 dice_raw = -4 key_hex = extract_key(chunk) - reward = KNOWN_REWARDS.get(key_hex, {}) + reward_id = decode_reward_key(bytes.fromhex(key_hex)) if key_hex else "" + reward = REWARDS_BY_ID.get(reward_id, {}) timestamp_raw = response_content[marker_offset + len(marker) : marker_offset + len(marker) + 8] timestamp_ticks, timestamp_unix, timestamp_decoded = decode_history_timestamp(timestamp_raw) chunk_hex = chunk.hex() @@ -163,11 +209,11 @@ def decode_response_records(response_content: bytes) -> list[dict[str, Any]]: "dice_raw_u32": dice_raw, "dice_offset_in_record": dice_offset, "reward_key_hex": key_hex, - "reward_type": reward.get("type", ""), - "reward_id": reward.get("id", ""), + "reward_type": reward.get("type") or infer_reward_type(reward_id), + "reward_id": reward_id, "reward_name": reward.get("name", ""), "reward_rank": reward.get("rank"), - "quantity": guess_quantity(chunk_hex, key_hex, result_type), + "quantity": guess_quantity(chunk_hex, reward_id, result_type), "timestamp_raw_hex": timestamp_raw.hex(), "timestamp_ticks": timestamp_ticks, "timestamp_unix": f"{timestamp_unix:.6f}", diff --git a/src/nte_history_exporter/mappings.py b/src/nte_history_exporter/mappings.py index bfa595f..2ecec43 100644 --- a/src/nte_history_exporter/mappings.py +++ b/src/nte_history_exporter/mappings.py @@ -1,92 +1,33 @@ -KNOWN_REWARDS = { - "98bdc9ad7d91d5cdd189a5b901": {"type": "arc", "id": "fork_dustbin", "name": "Dangerous Game", "rank": "B"}, - "98bdc9ad7dd9a5b99501": {"type": "arc", "id": "fork_vine", "name": "Be Happy", "rank": "B"}, - "98bdc9ad7db9bdb9bdcd01": {"type": "arc", "id": "fork_nonos", "name": "First Step to Success", "rank": "B"}, - "98bdc9ad7d85c1c1b1a585b98d9501": {"type": "arc", "id": "fork_appliance", "name": "\"Real Music\"", "rank": "B"}, - "98bdc9ad7d41c9bdad85c9e5bdb901": {"type": "arc", "id": "fork_Prokaryon", "name": "Us.", "rank": "B"}, - "98bdc9ad7d4185c195c941b185b99501": {"type": "arc", "id": "fork_PaperPlane", "name": "Clear Skies", "rank": "A"}, - "98bdc9ad7ddda1d585add585b99d01": {"type": "arc", "id": "fork_wuhuakuang", "name": "The Forgotten", "rank": "A"}, - "98bdc9ad7dddd5a1d585add585b99d01": {"type": "arc", "id": "fork_wuhuakuang", "name": "The Forgotten", "rank": "A"}, - "98bdc9ad7d2da5d19501": {"type": "arc", "id": "fork_Kite", "name": "Watch Your Heads!", "rank": "A"}, - "98bdc9ad7de5d5c995b901": {"type": "arc", "id": "fork_yuren", "name": "Umbrella", "rank": "A"}, - "98bdc9ad7de585bd9185bd01": {"type": "arc", "id": "fork_yaodao", "name": "Drawn Blade", "rank": "A"}, - "10a58d9539bdc9b585b101": {"type": "item", "id": "DiceNormal", "name": "Fabricated Dice", "rank": None}, - "10a58d957dd1a58dad95d17dc1c400": {"type": "item", "id": "Dice_ticket_01", "name": "Warp Piece", "rank": None}, - "10a58d957dd1a58dad95d17dc1c800": {"type": "item", "id": "Dice_ticket_02", "name": "Lost Piece", "rank": None}, - "10a58d95b1a5b5a5d19501": {"type": "item", "id": "Dicelimite", "name": "Solid Dice", "rank": None}, - "1885cda1a5bdb97dd995a1a58db1957dc5c0c4c07c59c1c0e000": { - "type": "cosmetic", - "id": "Fashion_vehicle_1010_V008", - "name": "Fashion Vehicle Skin 1010 V008", - "rank": None, - }, - "1885cda1a5bdb97d1db1a591957dc5c0c4c000": { - "type": "cosmetic", - "id": "Fashion_glide_1010", - "name": "Fashion Glider 1010", - "rank": None, - }, - "c4c0cccc00": {"type": "character", "id": "1033", "name": "Adler", "rank": "A"}, - "c4c0c8c000": {"type": "character", "id": "1020", "name": "Haniel", "rank": "A"}, - "c4c0c8cc00": {"type": "character", "id": "1023", "name": "Baicang", "rank": "S"}, - "c4c0c8d400": {"type": "character", "id": "1025", "name": "Hathor", "rank": "S"}, - "c4c0c4e400": {"type": "character", "id": "1019", "name": "Mint", "rank": "A"}, - "c4c0c0e000": {"type": "character", "id": "1008", "name": "Skia", "rank": "A"}, - "c4c0dcc0": {"type": "character", "id": "1070", "name": "Aurelia", "rank": "A"}, - "c4c0dcc000": {"type": "character", "id": "1070", "name": "Aurelia", "rank": "A"}, - "c4c0c8c4": {"type": "character", "id": "1021", "name": "Edgar", "rank": "A"}, - "c4c0c8c400": {"type": "character", "id": "1021", "name": "Edgar", "rank": "A"}, -} +from __future__ import annotations -ARC_META = { - "fork_nonos": {"name": "First Step to Success", "rank": "B"}, - "fork_Prokaryon": {"name": "Us.", "rank": "B"}, - "fork_appliance": {"name": "\"Real Music\"", "rank": "B"}, - "fork_PaperPlane": {"name": "Clear Skies", "rank": "A"}, - "fork_mofeikesi": {"name": "Good Boy's Grand Adventure", "rank": "S"}, - "fork_dustbin": {"name": "Dangerous Game", "rank": "B"}, - "fork_vine": {"name": "Be Happy", "rank": "B"}, - "fork_Kite": {"name": "Watch Your Heads!", "rank": "A"}, - "fork_wuhuakuang": {"name": "The Forgotten", "rank": "A"}, - "fork_jingmotingyuan": {"name": "Camellia Society", "rank": "S"}, - "fork_yuren": {"name": "Umbrella", "rank": "A"}, - "fork_yaodao": {"name": "Drawn Blade", "rank": "A"}, -} +import json +from pathlib import Path +from typing import Any -CHARACTERS = { - "1003": {"name": "Sakiri", "rank": "S"}, - "1004": {"name": "Lacrimosa", "rank": "S"}, - "1008": {"name": "Skia", "rank": "A"}, - "1010": {"name": "Nanally", "rank": "S"}, - "1019": {"name": "Mint", "rank": "A"}, - "1020": {"name": "Haniel", "rank": "A"}, - "1021": {"name": "Edgar", "rank": "A"}, - "1023": {"name": "Baicang", "rank": "S"}, - "1025": {"name": "Hathor", "rank": "S"}, - "1033": {"name": "Adler", "rank": "A"}, - "1039": {"name": "Fadia", "rank": "S"}, - "1046": {"name": "Zero", "rank": "S"}, - "1051": {"name": "Zero", "rank": "S"}, - "1052": {"name": "Hotori", "rank": "S"}, - "1054": {"name": "Daffodill", "rank": "S"}, - "1055": {"name": "Jiuyuan", "rank": "S"}, - "1056": {"name": "Lacrimosa", "rank": "S"}, - "1070": {"name": "Aurelia", "rank": "A"}, - "1073": {"name": "Chiz", "rank": "S"}, -} +PROJECT_ROOT = Path(__file__).resolve().parents[2] +MAPPINGS_DIR = PROJECT_ROOT / "mappings" -def character_id_to_key(character_id: str) -> str: - return "".join(f"{0xC0 + int(digit) * 4:02x}" for digit in character_id) + "00" +def load_mapping_file(filename: str) -> dict[str, Any]: + return json.loads((MAPPINGS_DIR / filename).read_text(encoding="utf-8")) -for _character_id, _info in CHARACTERS.items(): - KNOWN_REWARDS.setdefault( - character_id_to_key(_character_id), - { - "type": "character", - "id": _character_id, - "name": _info["name"], - "rank": _info["rank"], - }, - ) +ARC_META: dict[str, dict[str, Any]] = load_mapping_file("arcs.json") +CHARACTERS: dict[str, dict[str, Any]] = load_mapping_file("characters.json") +ITEMS: dict[str, dict[str, Any]] = load_mapping_file("items.json") + +# Reward keys in Monopoly history packets are the reward id string itself +# (ASCII*4 with carry, see decoder.protocol.decode_reward_key), so all reward +# metadata is looked up by decoded id rather than by raw key bytes. +REWARDS_BY_ID: dict[str, dict[str, Any]] = {} +for _arc_id, _meta in ARC_META.items(): + REWARDS_BY_ID[_arc_id] = {"type": "arc", "id": _arc_id, "name": _meta["name"], "rank": _meta["rank"]} +for _character_id, _meta in CHARACTERS.items(): + REWARDS_BY_ID[_character_id] = { + "type": "character", + "id": _character_id, + "name": _meta["name"], + "rank": _meta["rank"], + } +for _item_id, _meta in ITEMS.items(): + REWARDS_BY_ID[_item_id] = {"type": _meta["type"], "id": _item_id, "name": _meta["name"], "rank": _meta.get("rank")} diff --git a/src/nte_history_exporter/pool_mappings.py b/src/nte_history_exporter/pool_mappings.py index 20ff705..acda87e 100644 --- a/src/nte_history_exporter/pool_mappings.py +++ b/src/nte_history_exporter/pool_mappings.py @@ -1,11 +1,8 @@ from __future__ import annotations -import json -from pathlib import Path from typing import Any -PROJECT_ROOT = Path(__file__).resolve().parents[2] -MAPPINGS_DIR = PROJECT_ROOT / "mappings" +from nte_history_exporter.mappings import load_mapping_file POOL_MAPPING_FILES = { "permanent": "permanent_board.json", @@ -15,8 +12,7 @@ POOL_MAPPING_FILES = { def load_pool_mapping(pool_key: str) -> dict[str, Any]: - filename = POOL_MAPPING_FILES[pool_key] - return json.loads((MAPPINGS_DIR / filename).read_text(encoding="utf-8")) + return load_mapping_file(POOL_MAPPING_FILES[pool_key]) def load_pool_mappings() -> dict[str, dict[str, Any]]: diff --git a/tests/test_boundary_export.py b/tests/test_boundary_export.py index 6d1ee34..37274a6 100644 --- a/tests/test_boundary_export.py +++ b/tests/test_boundary_export.py @@ -17,7 +17,8 @@ from nte_history_exporter.constants import LIMITED_CHARACTER_MARKER, MARKER from nte_history_exporter.decoder.boundary import page_gap_warnings from nte_history_exporter.decoder.protocol import decode_response_records, history_request_kind from nte_history_exporter.constants import POOL_META -from nte_history_exporter.mappings import ARC_META, CHARACTERS +from nte_history_exporter.mappings import ARC_META, CHARACTERS, ITEMS, REWARDS_BY_ID +from nte_history_exporter.decoder.protocol import decode_reward_key, infer_reward_type from nte_history_exporter.decoder.arc import ( build_arc_rows_from_pairs, decode_arc_key, @@ -32,6 +33,8 @@ from nte_history_exporter.pool_mappings import load_pool_mappings, pool_meta_fro def load_reference_csv(name): path = EXPORTS / name + if not path.exists(): + path = EXPORTS / "old" / name if not path.exists(): raise unittest.SkipTest(f"private reference fixture not present: {path}") rows = [] @@ -88,14 +91,61 @@ class BoundaryExportTests(unittest.TestCase): with self.subTest(pool_key=pool_key): self.assertEqual(pool_meta_from_mapping(mapping), POOL_META[pool_key]) - def test_reward_mapping_json_matches_runtime_mappings(self): - with (ROOT / "mappings" / "arcs.json").open(encoding="utf-8") as f: - arc_json = json.load(f) - with (ROOT / "mappings" / "characters.json").open(encoding="utf-8") as f: - character_json = json.load(f) + def test_reward_mapping_files_have_expected_shape(self): + self.assertTrue(ARC_META) + for arc_id, meta in ARC_META.items(): + with self.subTest(arc_id=arc_id): + self.assertTrue(arc_id.startswith("fork_")) + self.assertIn("name", meta) + self.assertIn(meta.get("rank"), ("S", "A", "B")) - self.assertEqual(arc_json, ARC_META) - self.assertEqual(character_json, CHARACTERS) + self.assertTrue(CHARACTERS) + for character_id, info in CHARACTERS.items(): + with self.subTest(character_id=character_id): + self.assertTrue(character_id.isdigit()) + self.assertIn("name", info) + self.assertIn(info.get("rank"), ("S", "A")) + + self.assertTrue(ITEMS) + for item_id, info in ITEMS.items(): + with self.subTest(item_id=item_id): + self.assertIn(info.get("type"), ("item", "cosmetic")) + self.assertIn("name", info) + + def test_rewards_by_id_merges_all_mapping_files(self): + for reward_id in (*ARC_META, *CHARACTERS, *ITEMS): + with self.subTest(reward_id=reward_id): + reward = REWARDS_BY_ID[reward_id] + self.assertEqual(reward["id"], reward_id) + self.assertIn(reward["type"], ("arc", "character", "item", "cosmetic")) + + def test_decode_reward_key_round_trips_observed_keys(self): + observed = { + "98bdc9ad7dd9a5b99501": "fork_vine", + "98bdc9ad7d41c9bdad85c9e5bdb901": "fork_Prokaryon", + "98bdc9ad7ddda1d585add585b99d01": "fork_whuakuang", + "98bdc9ad7dddd5a1d585add585b99d01": "fork_wuhuakuang", + "10a58d9539bdc9b585b101": "DiceNormal", + "10a58d957dd1a58dad95d17dc1c400": "Dice_ticket_01", + "10a58d957dd1a58dad95d17dc1c800": "Dice_ticket_02", + "10a58d95b1a5b5a5d19501": "Dicelimite", + "1885cda1a5bdb97d1db1a591957dc5c0c4c000": "Fashion_Glide_1010", + "1885cda1a5bdb97dd995a1a58db1957dc5c0c4c07c59c1c0e000": "Fashion_vehicle_1010_V008", + "c4c0cccc00": "1033", + "c4c0dcc000": "1070", + "c4c0dcc0": "1070", + "c4c0c8c4": "1021", + } + for key_hex, expected_id in observed.items(): + with self.subTest(key_hex=key_hex): + self.assertEqual(decode_reward_key(bytes.fromhex(key_hex)), expected_id) + + def test_infer_reward_type_for_unmapped_ids(self): + self.assertEqual(infer_reward_type("fork_newarc"), "arc") + self.assertEqual(infer_reward_type("1099"), "character") + self.assertEqual(infer_reward_type("Fashion_hat_2000"), "cosmetic") + self.assertEqual(infer_reward_type("Dice_ticket_03"), "item") + self.assertEqual(infer_reward_type(""), "") def test_uid_source_matches_v4_reference(self): rows = load_reference_csv("monopoly_history_poc_10_all_44_pages_v4.csv") @@ -133,6 +183,8 @@ class BoundaryExportTests(unittest.TestCase): exported = [row for row in annotated if row["export_record"] is True] json_path = EXPORTS / "monopoly_history_export_10_all_44_pages_v4.json" + if not json_path.exists(): + json_path = EXPORTS / "old" / "monopoly_history_export_10_all_44_pages_v4.json" if not json_path.exists(): raise unittest.SkipTest(f"private reference fixture not present: {json_path}") with json_path.open(encoding="utf-8") as f: