From 593b23e1e208ab3aa7654a62fc110952af41c97d Mon Sep 17 00:00:00 2001 From: Golumpa Date: Wed, 19 Aug 2026 13:27:47 +0100 Subject: [PATCH] Resolve vehicle livery mappings from matching illustration assets --- docs/mapping-updates.md | 7 ++-- src/nte_history_exporter/mapping_update.py | 34 ++++++++++++++++++- .../DataTable/Gacha/GachaIllustrate.json | 2 +- .../nte_assets/Localization/en/game.json | 4 +++ tests/test_mapping_update.py | 10 ++++-- 5 files changed, 50 insertions(+), 7 deletions(-) diff --git a/docs/mapping-updates.md b/docs/mapping-updates.md index dabe9a3..a81fb80 100644 --- a/docs/mapping-updates.md +++ b/docs/mapping-updates.md @@ -80,6 +80,9 @@ from the committed mappings. It still writes the staged artifacts. - Other pull rewards are selected by `GachaIllustrate` and resolved to the inventory tables for quality and localization keys. Appearance-table IDs provide canonical casing for glider rewards. +- Vehicle liveries are resolved from their illustration name and icon. Rows + whose icon identifies a different reward are treated as unreleased upstream + placeholders and skipped until the assets are corrected. - Mystery Box rewards are selected from every row in `DT_GashaponLotteryGlobal`. No event IDs are hard-coded, so future rotations are included automatically. Rewards resolve through the inventory, capital, @@ -89,8 +92,8 @@ from the committed mappings. It still writes the staged artifacts. currencies, are categorized as items. - Names are resolved strictly through `Localization/en/game.json`. A missing or ambiguous key fails the update instead of falling back to a DT value. -- Character-awakening and character-vehicle illustration entries are not - independent pull rewards and are excluded. +- Character-awakening illustration entries are not independent pull rewards + and are excluded. - Orange, purple, and blue item qualities map to `S`, `A`, and `B`. ## UID compatibility boundary diff --git a/src/nte_history_exporter/mapping_update.py b/src/nte_history_exporter/mapping_update.py index b37b9be..2fa99ee 100644 --- a/src/nte_history_exporter/mapping_update.py +++ b/src/nte_history_exporter/mapping_update.py @@ -324,9 +324,16 @@ def _build_item_mapping( candidate_id.isdigit() or folded.startswith("fork_") or folded.startswith("characterawaken_") - or folded.startswith("fashion_vehicle_") ): continue + if folded.startswith("fashion_vehicle_"): + illustration = tables["illustrations"].get(candidate_id) + if illustration is not None: + vehicle_meta = _normalise_vehicle_livery(candidate_id, illustration, translations) + if vehicle_meta is None: + continue + result[candidate_id] = vehicle_meta + continue sources = (inventory, capital, appearances, vehicle_inventory) match = next((source.get(folded) for source in sources if folded in source), None) if match is None: @@ -343,6 +350,31 @@ def _build_item_mapping( return dict(sorted(result.items(), key=lambda pair: (pair[0].casefold(), pair[0]))) +def _normalise_vehicle_livery( + item_id: str, + row: Any, + translations: dict[str, list[tuple[str, str]]], +) -> dict[str, str] | None: + """Resolve a livery from its illustration, ignoring mismatched placeholder rows.""" + if not isinstance(row, dict): + raise MappingUpdateError(f"NTE_Assets row {item_id} must be an object") + head_icon = row.get("HeadIcon") + asset_path = head_icon.get("AssetPathName") if isinstance(head_icon, dict) else None + if not isinstance(asset_path, str) or not asset_path: + raise MappingUpdateError(f"NTE_Assets vehicle livery row {item_id} has no HeadIcon") + icon_id = asset_path.rsplit("/", 1)[-1].split(".", 1)[0] + if icon_id.casefold() != item_id.casefold(): + return None + item_name = row.get("ItemName_Override") + if not isinstance(item_name, dict): + raise MappingUpdateError(f"NTE_Assets vehicle livery row {item_id} has no ItemName_Override") + return { + "type": "cosmetic", + "name": _translate_name(item_id, item_name, translations).strip(), + "rank": "S", + } + + def _build_achievement_mapping( rows: dict[str, Any], translations: dict[str, list[tuple[str, str]]], diff --git a/tests/fixtures/nte_assets/DataTable/Gacha/GachaIllustrate.json b/tests/fixtures/nte_assets/DataTable/Gacha/GachaIllustrate.json index bd2fdf3..9e28621 100644 --- a/tests/fixtures/nte_assets/DataTable/Gacha/GachaIllustrate.json +++ b/tests/fixtures/nte_assets/DataTable/Gacha/GachaIllustrate.json @@ -1 +1 @@ -[{"Rows":{"1003":{},"1099":{},"fork_alpha":{},"fork_new":{},"DIceNormal":{},"Dice_ticket_03":{},"Fashion_glide_2000":{},"Characterawaken_1003":{}}}] +[{"Rows":{"1003":{},"1099":{},"fork_alpha":{},"fork_new":{},"DIceNormal":{},"Dice_ticket_03":{},"Fashion_glide_2000":{},"Characterawaken_1003":{},"Fashion_vehicle_1010_V008":{"HeadIcon":{"AssetPathName":"/Game/UI/UI_Icon/Item/Fashion_vehicle_1010_V008.Fashion_vehicle_1010_V008"},"ItemName_Override":{"TableId":"/Game/Text/ST_VehicleData.ST_VehicleData","Key":"vehicle_1010_livery"}},"Fashion_vehicle_1072_V006":{"HeadIcon":{"AssetPathName":"/Game/UI/UI_Icon/Item/Fashion_vehicle_1036_V043.Fashion_vehicle_1036_V043"},"ItemName_Override":{"TableId":"/Game/Text/ST_VehicleData.ST_VehicleData","Key":"vehicle_placeholder_livery"}}}}] diff --git a/tests/fixtures/nte_assets/Localization/en/game.json b/tests/fixtures/nte_assets/Localization/en/game.json index 0bbd71e..97e2667 100644 --- a/tests/fixtures/nte_assets/Localization/en/game.json +++ b/tests/fixtures/nte_assets/Localization/en/game.json @@ -18,5 +18,9 @@ }, "ST_Appearance": { "glider_2000": "Inventory Glider" + }, + "ST_VehicleData": { + "vehicle_1010_livery": "Tiger Incoming! - Livery", + "vehicle_placeholder_livery": "Sandy Mirage - Livery" } } diff --git a/tests/test_mapping_update.py b/tests/test_mapping_update.py index a770148..0c48491 100644 --- a/tests/test_mapping_update.py +++ b/tests/test_mapping_update.py @@ -55,7 +55,7 @@ class MappingUpdateTests(unittest.TestCase): self.assertNotIn("fork_retired", result.mappings["arcs.json"]) self.assertNotIn("OldTicket", result.mappings["items.json"]) self.assertNotEqual(result.mappings["characters.json"]["1003"], current["characters.json"]["1003"]) - self.assertEqual(result.report["changes"], {"additions": 5, "updates": 2, "deletions": 3}) + self.assertEqual(result.report["changes"], {"additions": 6, "updates": 2, "deletions": 3}) self.assertIs(result.report["safety"]["authoritative_snapshot"], True) self.assertIs(result.report["safety"]["deletions_allowed"], True) self.assertIs(result.report["safety"]["uid_inputs_touched"], False) @@ -83,7 +83,11 @@ class MappingUpdateTests(unittest.TestCase): self.assertNotIn("DIceNormal", result.mappings["items.json"]) self.assertNotIn("UnusedItem", result.mappings["items.json"]) self.assertFalse(any(key.startswith("Characterawaken_") for key in result.mappings["items.json"])) - self.assertFalse(any(key.startswith("Fashion_vehicle_") for key in result.mappings["items.json"])) + self.assertEqual( + result.mappings["items.json"]["Fashion_vehicle_1010_V008"], + {"type": "cosmetic", "name": "Tiger Incoming! - Livery", "rank": "S"}, + ) + self.assertNotIn("Fashion_vehicle_1072_V006", result.mappings["items.json"]) def test_future_mystery_box_pool_is_discovered_without_hard_coded_event_id(self): assets = load_assets(assets_root=SAMPLE_ASSETS) @@ -113,7 +117,7 @@ class MappingUpdateTests(unittest.TestCase): tables["localization"]["ST_Item"].update( {"wow_ticket": "Wowzers Ticket", "wow_frame": "Wowzers Frame"} ) - tables["localization"]["ST_VehicleData"] = {"vehicle_wow": "Wowmobile"} + tables["localization"]["ST_VehicleData"]["vehicle_wow"] = "Wowmobile" result = build_mapping_update(sample_current(), replace(assets, tables=tables))