From b1afa90754477caa58b27bc4380696f32e556554 Mon Sep 17 00:00:00 2001 From: Daniel Hoer Date: Wed, 9 Sep 2026 22:48:36 +0200 Subject: [PATCH 1/7] feat(editor): add locks and simplify story filters --- apps/save-editor/CHANGELOG.md | 10 + apps/save-editor/README.md | 9 +- apps/save-editor/assets/lock_catalog.json | 1 + .../editor/domain/editor_notifier.dart | 53 +- .../features/editor/domain/lock_catalog.dart | 108 +++ .../features/editor/domain/locks_models.dart | 60 ++ .../lib/features/editor/ui/area_labels.dart | 63 ++ .../editor/ui/location_picker_dialog.dart | 59 +- .../lib/features/editor/ui/locks_panel.dart | 823 ++++++++++++++++++ .../features/editor/ui/story_state_panel.dart | 139 ++- .../lib/features/editor/ui/world_tab.dart | 29 +- apps/save-editor/lib/l10n/app_de.arb | 24 +- apps/save-editor/lib/l10n/app_en.arb | 29 +- apps/save-editor/lib/l10n/app_es.arb | 24 +- apps/save-editor/lib/l10n/app_fr.arb | 24 +- apps/save-editor/lib/l10n/app_it.arb | 24 +- apps/save-editor/lib/l10n/app_ja.arb | 24 +- .../lib/l10n/app_localizations.dart | 112 ++- .../lib/l10n/app_localizations_de.dart | 70 +- .../lib/l10n/app_localizations_en.dart | 69 +- .../lib/l10n/app_localizations_es.dart | 71 +- .../lib/l10n/app_localizations_fr.dart | 71 +- .../lib/l10n/app_localizations_it.dart | 71 +- .../lib/l10n/app_localizations_ja.dart | 68 +- .../lib/l10n/app_localizations_pl.dart | 69 +- .../lib/l10n/app_localizations_pt.dart | 142 ++- .../lib/l10n/app_localizations_ru.dart | 70 +- .../lib/l10n/app_localizations_zh.dart | 136 ++- apps/save-editor/lib/l10n/app_pl.arb | 24 +- apps/save-editor/lib/l10n/app_pt.arb | 24 +- apps/save-editor/lib/l10n/app_pt_BR.arb | 24 +- apps/save-editor/lib/l10n/app_ru.arb | 24 +- apps/save-editor/lib/l10n/app_zh.arb | 24 +- apps/save-editor/lib/l10n/app_zh_Hans.arb | 24 +- apps/save-editor/pubspec.yaml | 1 + .../test/editor_notifier_test.dart | 18 + .../editor/domain/lock_catalog_test.dart | 77 ++ .../features/editor/ui/locks_panel_test.dart | 595 +++++++++++++ .../editor/ui/story_state_panel_test.dart | 188 +++- apps/save-editor/tools/build_lock_catalog.py | 288 ++++++ build.py | 88 +- crates/gore-save/src/lib.rs | 362 ++++++++ crates/gore-save/src/locks.rs | 496 +++++++++++ crates/gore-save/tests/locks.rs | 219 +++++ scripts/test_build_run.py | 55 ++ 45 files changed, 4794 insertions(+), 189 deletions(-) create mode 100644 apps/save-editor/assets/lock_catalog.json create mode 100644 apps/save-editor/lib/features/editor/domain/lock_catalog.dart create mode 100644 apps/save-editor/lib/features/editor/domain/locks_models.dart create mode 100644 apps/save-editor/lib/features/editor/ui/area_labels.dart create mode 100644 apps/save-editor/lib/features/editor/ui/locks_panel.dart create mode 100644 apps/save-editor/test/features/editor/domain/lock_catalog_test.dart create mode 100644 apps/save-editor/test/features/editor/ui/locks_panel_test.dart create mode 100644 apps/save-editor/tools/build_lock_catalog.py create mode 100644 crates/gore-save/src/locks.rs create mode 100644 crates/gore-save/tests/locks.rs create mode 100644 scripts/test_build_run.py diff --git a/apps/save-editor/CHANGELOG.md b/apps/save-editor/CHANGELOG.md index 8116eb1c0..724ec9580 100644 --- a/apps/save-editor/CHANGELOG.md +++ b/apps/save-editor/CHANGELOG.md @@ -8,6 +8,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Added + +- Browse and edit chest and door locks under World → Locks, with search and + filters. Relocking a door also closes it. + +### Changed + +- Story states are easier to browse, with clearer filters and help. Unused + entries are hidden by default and can be shown when needed. + ## [1.4.1] - 2026-09-06 ### Added diff --git a/apps/save-editor/README.md b/apps/save-editor/README.md index d65a065af..a4ea35461 100644 --- a/apps/save-editor/README.md +++ b/apps/save-editor/README.md @@ -53,8 +53,8 @@ game install. For modding, use the [`gore` CLI](../../docs/guide/README.md) or ## Compatibility Tested with Steam game version CL168781. Other game versions are not guaranteed: -the bundled item, item-stat and named-location catalogs come from specific game -cooks and can become stale when a patch moves, renames or changes game data. +the bundled item, item-stat, lock and named-location catalogs come from specific +game cooks and can become stale when a patch moves, renames or changes game data. Keep backups and use a Save Editor build qualified for the updated game version. @@ -62,7 +62,10 @@ The bundled item stats (`assets/item_stats.json`) carry what the shipped script cache says about every item — its type tag, damage, requirements, value and description key — plus the game's own inventory filter tables. `assets/glossary_images.json` maps each glossary entry to its portrait file. -Regenerate both after a game update with the scripts in +`assets/lock_catalog.json` lists every lockable chest and door in the game with +its difficulty, keys and region — a save records only the locks the player has +already opened, so the Welt tab's lock list needs the other half from here. +Regenerate all three after a game update with the scripts in `apps/save-editor/tools/`; each file header documents the commands. The glossary portraits themselves are not bundled: they are loose PNGs in the diff --git a/apps/save-editor/assets/lock_catalog.json b/apps/save-editor/assets/lock_catalog.json new file mode 100644 index 000000000..ea0817611 --- /dev/null +++ b/apps/save-editor/assets/lock_catalog.json @@ -0,0 +1 @@ +{"version":1,"locks":[{"n":"AbandonedMine_Fence_Door","k":"door","a":"AM","keys":["ItKe_AbandonedMine_01"]},{"n":"AbandonedMine_UluTalisman_Door","k":"door","a":"AM","keys":["ItAt_Amulet_Ulu_Talisman"]},{"n":"AMR_Storage_Room_Door","k":"door","a":"MR","d":5,"l":"AMR_Storage_Room_Lock","keys":["ItKe_Focus_04"]},{"n":"BC_LootCave_Door","k":"door","a":"BC","d":3,"l":"BC_LootCave_Door_Lock","keys":["ItKe_Quentin_01"]},{"n":"BC_LootCave_Door_02","k":"door","a":"BC","d":6,"l":"BC_LootCave_Door_Lock_02","keys":["ItKe_Quentin_01"]},{"n":"BT_Tower_Door","k":"door","a":"OA","d":7,"l":"BT_Tower_Door_Lock","keys":["ItKe_Tower_01"]},{"n":"CV_Stash_Door","k":"door","a":"CV","d":5,"l":"CV_Stash_Door_Lock","keys":["ItKe_Cavalorn_01"]},{"n":"EZ_LootCage_Door","k":"door","a":"EZ","d":1,"l":"EZ_LootCage_Door_Lock","keys":["ItKe_LootCage_01"]},{"n":"FM_AbandonedGallery_Door","k":"door","a":"FM","d":5,"l":"FM_AbandonedGallery_Door_Lock","keys":["ItKe_AbandonedGallery_01"]},{"n":"FM_Winch_Gate","k":"door","a":"FM","keys":["ItKe_Freemine"]},{"n":"HC_InnosTemple_Door_01","k":"door","a":"HC","l":"HC_InnosTemple_Door_01_Lock"},{"n":"IO_AMR_CHEST_04","k":"chest","a":"MR","d":4,"l":"AMR_Chest_04_Lock","r":true},{"n":"IO_AM_CHEST_04","k":"chest","a":"AM","d":4,"l":"AM_Chest_04_Lock","r":true},{"n":"IO_AM_CHEST_05","k":"chest","a":"AM","r":true},{"n":"IO_BC_CHEST_01","k":"chest","a":"BC","d":1,"l":"BC_Chest_01_Lock","r":true},{"n":"IO_BC_CHEST_02","k":"chest","a":"BC","d":2,"l":"BC_Chest_02_Lock","r":true},{"n":"IO_BC_CHEST_03","k":"chest","a":"BC","d":2,"l":"BC_Chest_03_Lock","r":true},{"n":"IO_BC_CHEST_04","k":"chest","a":"BC","d":2,"l":"BC_Chest_04_Lock","r":true},{"n":"IO_BC_CHEST_05","k":"chest","a":"BC","d":2,"l":"BC_Chest_05_Lock","r":true},{"n":"IO_BC_CHEST_07","k":"chest","a":"BC","d":1,"l":"BC_Chest_07_Lock","r":true},{"n":"IO_BC_CHEST_08","k":"chest","a":"BC","d":3,"l":"BC_Chest_08_Lock","r":true},{"n":"IO_BC_CHEST_09","k":"chest","a":"BC","d":2,"l":"BC_Chest_09_Lock","r":true},{"n":"IO_BC_CHEST_10","k":"chest","a":"BC","d":1,"l":"BC_Chest_10_Lock","r":true},{"n":"IO_BC_CHEST_11","k":"chest","a":"BC","d":1,"l":"BC_Chest_11_Lock","r":true},{"n":"IO_BC_CHEST_12","k":"chest","a":"BC","d":2,"l":"BC_Chest_12_Lock","r":true},{"n":"IO_BC_CHEST_13","k":"chest","a":"BC","d":2,"l":"BC_Chest_13_Lock","r":true},{"n":"IO_BC_CHEST_14","k":"chest","a":"BC","d":2,"l":"BC_Chest_14_Lock","r":true},{"n":"IO_BC_CHEST_15","k":"chest","a":"BC","d":3,"l":"BC_Chest_15_Lock","r":true},{"n":"IO_BC_CHEST_16","k":"chest","a":"BC","d":6,"l":"BC_Chest_16_Lock","r":true},{"n":"IO_BC_CHEST_17","k":"chest","a":"BC","d":5,"l":"BC_Chest_17_Lock","r":true},{"n":"IO_BC_CHEST_18","k":"chest","a":"BC","d":5,"l":"BC_Chest_18_Lock","r":true},{"n":"IO_BC_CHEST_19","k":"chest","a":"BC","d":7,"l":"BC_Chest_19_Lock","r":true},{"n":"IO_BC_CHEST_20","k":"chest","a":"BC","d":4,"l":"BC_Chest_20_Lock","r":true},{"n":"IO_BC_CHEST_22","k":"chest","a":"BC","d":4,"l":"BC_Chest_22_Lock","r":true},{"n":"IO_BC_CHEST_23","k":"chest","a":"BC","d":2,"l":"BC_Chest_23_Lock","r":true},{"n":"IO_BT_CHEST_01","k":"chest","a":"OA","d":4,"l":"BT_Chest_01_Lock","r":true},{"n":"IO_BT_CHEST_02","k":"chest","a":"CV","d":7,"l":"BT_Chest_02_Lock","r":true},{"n":"IO_CR_CHEST_01","k":"chest","a":"CR","d":6,"l":"CR_Chest_01_Lock","r":true},{"n":"IO_CR_CHEST_02","k":"chest","a":"CR","d":6,"l":"CR_Chest_02_Lock","r":true},{"n":"IO_CV_CHEST_01","k":"chest","a":"CV","d":4,"l":"CV_Chest_01_Lock","r":true},{"n":"IO_CV_CHEST_02","k":"chest","a":"CV","d":4,"l":"CV_Chest_02_Lock","r":true},{"n":"IO_CV_CHEST_07","k":"chest","a":"CV","d":3,"l":"CV_Chest_07_Lock","r":true},{"n":"IO_CV_CHEST_09","k":"chest","a":"CV","d":5,"l":"CV_Chest_09_Lock","r":true},{"n":"IO_CV_CHEST_10","k":"chest","a":"CV","d":4,"l":"CV_Chest_10_Lock","r":true},{"n":"IO_EF_CHEST_03","k":"chest","a":"EF","d":6,"l":"EF_Chest_03_Lock","r":true},{"n":"IO_EF_CHEST_05","k":"chest","a":"EF","d":7,"l":"EF_Chest_05_Lock","r":true},{"n":"IO_FME_CHEST_CONTROLACCESS","k":"chest","a":"FM","d":2,"l":"FME_Chest_ControlAccess_Lock","r":true},{"n":"IO_FME_CHEST_DIGGINGGALLERY_02","k":"chest","a":"FM","d":4,"l":"FM_Chest_Digginggallery_02_Lock"},{"n":"IO_FME_CHEST_EMPTYHUT_02","k":"chest","a":"FM","d":2,"l":"FME_Chest_EmptyHut_02_Lock","r":true},{"n":"IO_FME_CHEST_EMPTYHUT_04","k":"chest","a":"FM","d":2,"l":"FME_Chest_EmptyHut_04_Lock","r":true},{"n":"IO_FME_CHEST_EMPTYHUT_05","k":"chest","a":"FM","d":1,"l":"FME_Chest_EmptyHut_05_Lock","r":true},{"n":"IO_FME_CHEST_GATE","k":"chest","a":"FM","d":5,"l":"FME_Chest_Gate_Lock","r":true},{"n":"IO_FME_CHEST_MERCENARY13_765_01","k":"chest","a":"FM","d":1,"l":"FME_Chest_Mercenary13_765_01_Lock","r":true},{"n":"IO_FME_CHEST_MERCENARY13_765_02","k":"chest","a":"FM","d":1,"l":"FME_Chest_Mercenary13_765_02_Lock","r":true},{"n":"IO_FME_CHEST_SWINEY","k":"chest","a":"FM","d":3,"l":"FME_Chest_Swiney_Lock","r":true},{"n":"IO_FM_CHEST_FURNACE_01","k":"chest","a":"FM","d":4,"l":"FM_Chest_Furnace_01_Lock","r":true},{"n":"IO_FM_CHEST_FURNACE_02","k":"chest","a":"FM","d":4,"l":"FM_Chest_Furnace_02_Lock","r":true},{"n":"IO_FM_CHEST_GALLERY","k":"chest","a":"FM","d":7,"l":"FM_Chest_Gallery_01_Lock","r":true},{"n":"IO_IWML_CHEST_01","k":"chest","a":"IWM","d":3,"l":"IWML_Chest_01_Lock","r":true},{"n":"IO_IWML_CHEST_02","k":"chest","a":"IWM","d":4,"l":"IWML_Chest_02_Lock","r":true},{"n":"IO_IWML_CHEST_03","k":"chest","a":"IWM","d":4,"l":"IWML_Chest_03_Lock","r":true},{"n":"IO_IWML_CHEST_04","k":"chest","a":"IWM","d":3,"l":"IWML_Chest_04_Lock","r":true},{"n":"IO_IWML_CHEST_05","k":"chest","a":"IWM","d":4,"l":"IWML_Chest_05_Lock","r":true},{"n":"IO_MF_CHEST_01","k":"chest","a":"MF","d":6,"l":"MF_Chest_01_Lock","r":true},{"n":"IO_MF_CHEST_04","k":"chest","a":"MF","d":7,"l":"MF_Chest_04_Lock","r":true},{"n":"IO_MF_CHEST_07","k":"chest","a":"MF","d":5,"l":"MF_Chest_07_Lock","r":true},{"n":"IO_MF_CHEST_08","k":"chest","a":"MF","d":4,"l":"MF_Chest_08_Lock","r":true},{"n":"IO_MF_CHEST_11","k":"chest","a":"MF","keys":["ItKe_Berg_01"]},{"n":"IO_MF_CHEST_12","k":"chest","a":"MF","d":6,"l":"MF_Chest_12_Lock","r":true},{"n":"IO_NC_CHEST_BLADE","k":"chest","a":"NC","d":3,"l":"NC_Chest_Blade_Lock","r":true},{"n":"IO_NC_CHEST_BRUCE","k":"chest","a":"NC","d":3,"l":"NC_Chest_Bruce_Lock","r":true},{"n":"IO_NC_CHEST_BUSTER","k":"chest","a":"NC","d":3,"l":"NC_Chest_Buster_Lock","r":true},{"n":"IO_NC_CHEST_BUTCH","k":"chest","a":"NC","d":3,"l":"NC_Chest_Butch_Lock","r":true},{"n":"IO_NC_CHEST_CELLAR","k":"chest","a":"NC","d":3,"l":"NC_Chest_Cellar_Lock","r":true},{"n":"IO_NC_CHEST_CIPHER","k":"chest","a":"NC","d":4,"l":"NC_Chest_Cipher_Lock","r":true},{"n":"IO_NC_CHEST_CORD","k":"chest","a":"NC","d":5,"l":"NC_Chest_Cord_Lock","r":true},{"n":"IO_NC_CHEST_CRONOS","k":"chest","a":"NC","d":5,"l":"NC_Chest_Cronos_Lock","r":true},{"n":"IO_NC_CHEST_DISTILLERY","k":"chest","a":"NC","d":4,"l":"NC_Chest_Distillery_Lock","r":true},{"n":"IO_NC_CHEST_GATEINTERIOR_01","k":"chest","a":"NC","d":4,"l":"NC_Chest_GateInterior_01_Lock","r":true},{"n":"IO_NC_CHEST_GATEINTERIOR_02","k":"chest","a":"NC","d":2,"l":"NC_Chest_GateInterior_02_Lock","r":true},{"n":"IO_NC_CHEST_GORN","k":"chest","a":"NC","d":3,"l":"NC_Chest_Gorn_Lock","r":true},{"n":"IO_NC_CHEST_HOMER","k":"chest","a":"NC","d":3,"l":"NC_Chest_Homer_Lock","r":true},{"n":"IO_NC_CHEST_JEREMIAH","k":"chest","a":"NC","d":2,"l":"NC_Chest_Jeremiah_Lock","r":true},{"n":"IO_NC_CHEST_LARES","k":"chest","a":"NC","d":5,"l":"NC_Chest_Lares_Lock","r":true},{"n":"IO_NC_CHEST_LEE","k":"chest","a":"NC","d":5,"l":"NC_Chest_Lee_Lock","r":true},{"n":"IO_NC_CHEST_LEFTY_01","k":"chest","a":"NC","d":2,"l":"NC_Chest_Lefty_01_Lock","r":true},{"n":"IO_NC_CHEST_LEFTY_02","k":"chest","a":"NC","d":2,"l":"NC_Chest_Lefty_02_Lock","r":true},{"n":"IO_NC_CHEST_MERCENARY02_703","k":"chest","a":"NC","d":3,"l":"NC_Chest_Mercenary02_703_Lock","r":true},{"n":"IO_NC_CHEST_MERCENARY03_705","k":"chest","a":"NC","d":1,"l":"NC_Chest_Mercenary03_705_Lock","r":true},{"n":"IO_NC_CHEST_MERCENARY04_706","k":"chest","a":"NC","d":3,"l":"NC_Chest_Mercenary04_706_Lock","r":true},{"n":"IO_NC_CHEST_MERCENARY06_708","k":"chest","a":"NC","d":3,"l":"NC_Chest_Mercenary06_708_Lock","r":true},{"n":"IO_NC_CHEST_MERCENARY07_710","k":"chest","a":"NC","d":4,"l":"NC_Chest_Mercenary07_710_Lock","r":true},{"n":"IO_NC_CHEST_MERCENARY17_735","k":"chest","a":"NC","d":3,"l":"NC_Chest_Mercenary17_735_Lock","r":true},{"n":"IO_NC_CHEST_MERDARION","k":"chest","a":"NC","d":5,"l":"NC_Chest_Merdarion_Lock","r":true},{"n":"IO_NC_CHEST_MYXIR","k":"chest","a":"NC","d":5,"l":"NC_Chest_Myxir_Lock","r":true},{"n":"IO_NC_CHEST_NEFARIUS","k":"chest","a":"NC","d":5,"l":"NC_Chest_Nefarius_Lock","r":true},{"n":"IO_NC_CHEST_ORIK","k":"chest","a":"NC","d":3,"l":"NC_Chest_Orik_Lock","r":true},{"n":"IO_NC_CHEST_RICELORD","k":"chest","a":"NC","d":4,"l":"NC_Chest_Ricelord_Lock","r":true},{"n":"IO_NC_CHEST_RIORDIAN","k":"chest","a":"NC","d":5,"l":"NC_Chest_Riordian_Lock","r":true},{"n":"IO_NC_CHEST_ROGUE01_800","k":"chest","a":"NC","d":2,"l":"NC_Chest_Rogue01_800_Lock","r":true},{"n":"IO_NC_CHEST_ROGUE03_806","k":"chest","a":"NC","d":4,"l":"NC_Chest_Rogue03_806_Lock","r":true},{"n":"IO_NC_CHEST_ROGUE05_810","k":"chest","a":"NC","d":3,"l":"NC_Chest_Rogue05_810_Lock","r":true},{"n":"IO_NC_CHEST_ROGUE06_811","k":"chest","a":"NC","d":3,"l":"NC_Chest_Rogue06_811_Lock","r":true},{"n":"IO_NC_CHEST_ROGUE07_815","k":"chest","a":"NC","d":2,"l":"NC_Chest_Rogue07_815_Lock","r":true},{"n":"IO_NC_CHEST_ROGUE08_816","k":"chest","a":"NC","d":3,"l":"NC_Chest_Rogue08_816_Lock","r":true},{"n":"IO_NC_CHEST_ROGUE09_817","k":"chest","a":"NC","d":3,"l":"NC_Chest_Rogue09_817_Lock","r":true},{"n":"IO_NC_CHEST_ROGUE10_820","k":"chest","a":"NC","d":3,"l":"NC_Chest_Rogue10_820_Lock","r":true},{"n":"IO_NC_CHEST_ROGUE11_821","k":"chest","a":"NC","d":1,"l":"NC_Chest_Rogue11_821_Lock","r":true},{"n":"IO_NC_CHEST_ROGUE13_823","k":"chest","a":"NC","d":2,"l":"NC_Chest_Rogue13_823_Lock","r":true},{"n":"IO_NC_CHEST_ROGUE22_837","k":"chest","a":"NC","d":3,"l":"NC_Chest_Rogue22_837_Lock","r":true},{"n":"IO_NC_CHEST_ROGUE23_838","k":"chest","a":"NC","d":4,"l":"NC_Chest_Rogue23_838_Lock","r":true},{"n":"IO_NC_CHEST_SATURAS","k":"chest","a":"NC","d":5,"l":"NC_Chest_Saturas_Lock","r":true},{"n":"IO_NC_CHEST_SHARKY","k":"chest","a":"NC","d":4,"l":"NC_Chest_Sharky_Lock","r":true},{"n":"IO_NC_CHEST_SILAS","k":"chest","a":"NC","d":3,"l":"NC_Chest_Silas_Lock","r":true},{"n":"IO_NC_CHEST_SUNKEN_01","k":"chest","a":"NC","d":4,"l":"NC_Chest_Sunken_01_Lock","r":true},{"n":"IO_NC_CHEST_SUNKEN_02","k":"chest","a":"NC","d":4,"l":"NC_Chest_Sunken_02_Lock","r":true},{"n":"IO_NC_CHEST_TORLOF","k":"chest","a":"NC","d":3,"l":"NC_Chest_Torlof_Lock","r":true},{"n":"IO_NC_CHEST_WEDGE","k":"chest","a":"NC","d":5,"l":"NC_Chest_Wedge_Lock","r":true},{"n":"IO_NC_CHEST_WOLF","k":"chest","a":"NC","d":3,"l":"NC_Chest_Wolf_Lock","r":true},{"n":"IO_OA_CHEST_01","k":"chest","a":"OA","d":3,"l":"OA_Chest_01_Lock","r":true},{"n":"IO_OA_CHEST_02","k":"chest","a":"OA","d":6,"l":"OA_Chest_02_Lock","r":true},{"n":"IO_OA_CHEST_03","k":"chest","a":"OA","d":4,"l":"OA_Chest_03_Lock","r":true},{"n":"IO_OA_CHEST_04","k":"chest","a":"OA","d":5,"l":"OA_Chest_04_Lock","r":true},{"n":"IO_OCS_CHEST_04","k":"chest","a":"OC","d":7,"l":"OCS_Chest_04_Lock","r":true},{"n":"IO_OC_CHEST_ARMORY_01","k":"chest","a":"OC","d":7,"l":"OC_Chest_Armory_01_Lock","keys":["ItKe_Gomez_01"],"r":true},{"n":"IO_OC_CHEST_ARMORY_02","k":"chest","a":"OC","d":7,"l":"OC_Chest_Armory_02_Lock","keys":["ItKe_Gomez_01"],"r":true},{"n":"IO_OC_CHEST_ARMORY_03","k":"chest","a":"OC","d":7,"l":"OC_Chest_Armory_03_Lock","keys":["ItKe_Gomez_01"],"r":true},{"n":"IO_OC_CHEST_ARMORY_04","k":"chest","a":"OC","d":7,"l":"OC_Chest_Armory_04_Lock","keys":["ItKe_Gomez_01"],"r":true},{"n":"IO_OC_CHEST_ARTO","k":"chest","a":"OC","d":7,"l":"OC_Chest_Arto_Lock","r":true},{"n":"IO_OC_CHEST_BAALPARVEZ","k":"chest","a":"OC","d":3,"l":"OC_Chest_BaalParvez_Lock","r":true},{"n":"IO_OC_CHEST_BALAM","k":"chest","a":"OC","d":4,"l":"OC_Chest_Balam_Lock","r":true},{"n":"IO_OC_CHEST_BARTHOLO","k":"chest","a":"OC","d":4,"l":"OC_Chest_Bartholo_Lock","r":true},{"n":"IO_OC_CHEST_BETTINGOWNER","k":"chest","a":"OC","d":4,"l":"OC_Chest_Bettingowner_Lock","r":true},{"n":"IO_OC_CHEST_BLOODWYN","k":"chest","a":"OC","d":4,"l":"OC_Chest_Bloodwyn_Lock","r":true},{"n":"IO_OC_CHEST_BULLIT","k":"chest","a":"OC","d":2,"l":"OC_Chest_Bullit_Lock","r":true},{"n":"IO_OC_CHEST_CORRISTO","k":"chest","a":"OC","d":4,"l":"OC_Chest_Corristo_Lock","r":true},{"n":"IO_OC_CHEST_CUTTER","k":"chest","a":"OC","d":3,"l":"OC_Chest_Cutter_Lock","r":true},{"n":"IO_OC_CHEST_DAMAROK","k":"chest","a":"OC","d":5,"l":"OC_Chest_Damarok_Lock","r":true},{"n":"IO_OC_CHEST_DEXTER","k":"chest","a":"OC","d":1,"l":"OC_Chest_Dexter_Lock","r":true},{"n":"IO_OC_CHEST_DIEGO","k":"chest","a":"OC","d":5,"l":"OC_Chest_Diego_Lock","r":true},{"n":"IO_OC_CHEST_DIGGER501","k":"chest","a":"OC","d":3,"l":"OC_Chest_Digger501_Lock","r":true},{"n":"IO_OC_CHEST_DIGGER502","k":"chest","a":"OC","d":1,"l":"OC_Chest_Digger502_Lock","r":true},{"n":"IO_OC_CHEST_DIGGER503","k":"chest","a":"OC","d":3,"l":"OC_Chest_Digger503_Lock","r":true},{"n":"IO_OC_CHEST_DIGGER504","k":"chest","a":"OC","d":2,"l":"OC_Chest_Digger504_Lock","r":true},{"n":"IO_OC_CHEST_DIGGER505","k":"chest","a":"OC","d":3,"l":"OC_Chest_Digger505_Lock","r":true},{"n":"IO_OC_CHEST_DIGGER506","k":"chest","a":"OC","d":2,"l":"OC_Chest_Digger506_Lock","r":true},{"n":"IO_OC_CHEST_DIGGER507","k":"chest","a":"OC","d":4,"l":"OC_Chest_Digger507_Lock","r":true},{"n":"IO_OC_CHEST_DIGGER508","k":"chest","a":"OC","d":3,"l":"OC_Chest_Digger508_Lock","r":true},{"n":"IO_OC_CHEST_DIGGER509","k":"chest","a":"OC","d":3,"l":"OC_Chest_Digger509_Lock","r":true},{"n":"IO_OC_CHEST_DIGGER510","k":"chest","a":"OC","d":3,"l":"OC_Chest_Digger510_Lock","r":true},{"n":"IO_OC_CHEST_DIGGER512","k":"chest","a":"OC","d":3,"l":"OC_Chest_Digger512_Lock","r":true},{"n":"IO_OC_CHEST_DIGGER513","k":"chest","a":"OC","d":4,"l":"OC_Chest_Digger513_Lock","r":true},{"n":"IO_OC_CHEST_DIGGER514","k":"chest","a":"OC","d":1,"l":"OC_Chest_Digger514_Lock","r":true},{"n":"IO_OC_CHEST_DIGGER515","k":"chest","a":"OC","d":4,"l":"OC_Chest_Digger515_Lock","r":true},{"n":"IO_OC_CHEST_DIGGER516","k":"chest","a":"OC","d":2,"l":"OC_Chest_Digger516_Lock","r":true},{"n":"IO_OC_CHEST_DIGGER517","k":"chest","a":"OC","d":3,"l":"OC_Chest_Digger517_Lock","r":true},{"n":"IO_OC_CHEST_DIGGER519","k":"chest","a":"OC","d":3,"l":"OC_Chest_Digger519_Lock","r":true},{"n":"IO_OC_CHEST_DIGGER521","k":"chest","a":"OC","d":3,"l":"OC_Chest_Digger521_Lock","r":true},{"n":"IO_OC_CHEST_DIGGER522","k":"chest","a":"OC","d":3,"l":"OC_Chest_Digger522_Lock","r":true},{"n":"IO_OC_CHEST_DIGGER523","k":"chest","a":"OC","d":2,"l":"OC_Chest_Digger523_Lock","r":true},{"n":"IO_OC_CHEST_DIGGER526","k":"chest","a":"OC","d":2,"l":"OC_Chest_Digger526_Lock","r":true},{"n":"IO_OC_CHEST_DIGGER527","k":"chest","a":"OC","d":2,"l":"OC_Chest_Digger527_Lock","r":true},{"n":"IO_OC_CHEST_DIGGER528","k":"chest","a":"OC","d":2,"l":"OC_Chest_Digger528_Lock","r":true},{"n":"IO_OC_CHEST_DIGGER529","k":"chest","a":"OC","d":1,"l":"OC_Chest_Digger529_Lock","r":true},{"n":"IO_OC_CHEST_DIGGER531","k":"chest","a":"OC","d":3,"l":"OC_Chest_Digger531_Lock","r":true},{"n":"IO_OC_CHEST_DIGGER532","k":"chest","a":"OC","d":3,"l":"OC_Chest_Digger532_Lock","r":true},{"n":"IO_OC_CHEST_DIGGER533","k":"chest","a":"OC","d":2,"l":"OC_Chest_Digger533_Lock","r":true},{"n":"IO_OC_CHEST_DIGGER535","k":"chest","a":"OC","d":2,"l":"OC_Chest_Digger535_Lock","r":true},{"n":"IO_OC_CHEST_DIGGER553","k":"chest","a":"OC","d":2,"l":"OC_Chest_Digger553_Lock","r":true},{"n":"IO_OC_CHEST_DIGGER554","k":"chest","a":"OC","d":2,"l":"OC_Chest_Digger554_Lock","r":true},{"n":"IO_OC_CHEST_DIGGER555","k":"chest","a":"OC","d":3,"l":"OC_Chest_Digger555_Lock","r":true},{"n":"IO_OC_CHEST_DIGGER556","k":"chest","a":"OC","d":2,"l":"OC_Chest_Digger556_Lock","r":true},{"n":"IO_OC_CHEST_DIGGER557","k":"chest","a":"OC","d":1,"l":"OC_Chest_Digger557_Lock","r":true},{"n":"IO_OC_CHEST_DIGGER560","k":"chest","a":"OC","d":2,"l":"OC_Chest_Digger560_Lock","r":true},{"n":"IO_OC_CHEST_DIGGER561","k":"chest","a":"OC","d":3,"l":"OC_Chest_Digger561_Lock","r":true},{"n":"IO_OC_CHEST_DIGGER565","k":"chest","a":"OC","d":2,"l":"OC_Chest_Digger565_Lock","r":true},{"n":"IO_OC_CHEST_DIGGER575","k":"chest","a":"OC","d":2,"l":"OC_Chest_Digger575_Lock","r":true},{"n":"IO_OC_CHEST_DIGGER576","k":"chest","a":"OC","d":3,"l":"OC_Chest_Digger576_Lock","r":true},{"n":"IO_OC_CHEST_DIGGER577","k":"chest","a":"OC","d":3,"l":"OC_Chest_Digger577_Lock","r":true},{"n":"IO_OC_CHEST_DIGGER578","k":"chest","a":"OC","d":1,"l":"OC_Chest_Digger578_Lock","r":true},{"n":"IO_OC_CHEST_DRAGO","k":"chest","a":"OC","d":4,"l":"OC_Chest_Drago_Lock","r":true},{"n":"IO_OC_CHEST_DUSTY","k":"chest","a":"OC","d":2,"l":"OC_Chest_Dusty_Lock","r":true},{"n":"IO_OC_CHEST_FINGERS","k":"chest","a":"OC","d":5,"l":"OC_Chest_Fingers_Lock","r":true},{"n":"IO_OC_CHEST_FISK_01","k":"chest","a":"OC","d":5,"l":"OC_Chest_Fisk_01_Lock","r":true},{"n":"IO_OC_CHEST_FISK_02","k":"chest","a":"OC","d":4,"l":"OC_Chest_Fisk_02_Lock","r":true},{"n":"IO_OC_CHEST_FISK_03","k":"chest","a":"OC","d":5,"l":"OC_Chest_Fisk_03_Lock","r":true},{"n":"IO_OC_CHEST_FISK_04","k":"chest","a":"OC","d":4,"l":"OC_Chest_Fisk_04_Lock","r":true},{"n":"IO_OC_CHEST_FISK_05","k":"chest","a":"OC","d":6,"l":"OC_Chest_Fisk_05_Lock","r":true},{"n":"IO_OC_CHEST_FISK_06","k":"chest","a":"OC","d":4,"l":"OC_Chest_Fisk_06_Lock","r":true},{"n":"IO_OC_CHEST_FISK_07","k":"chest","a":"OC","d":4,"l":"OC_Chest_Fisk_07_Lock","r":true},{"n":"IO_OC_CHEST_FISK_08","k":"chest","a":"OC","d":4,"l":"OC_Chest_Fisk_08_Lock","r":true},{"n":"IO_OC_CHEST_FLETCHER","k":"chest","a":"OC","d":2,"l":"OC_Chest_Fletcher_Lock","r":true},{"n":"IO_OC_CHEST_FORGE_01","k":"chest","a":"OC","d":4,"l":"OC_Chest_Forge_01_Lock","r":true},{"n":"IO_OC_CHEST_FORGE_02","k":"chest","a":"OC","d":7,"l":"OC_Chest_Forge_02_Lock","r":true},{"n":"IO_OC_CHEST_FORGE_04","k":"chest","a":"OC","d":6,"l":"OC_Chest_Forge_04_Lock","r":true},{"n":"IO_OC_CHEST_GOMEZ_01","k":"chest","a":"OC","d":7,"l":"OC_Chest_Gomez_01_Lock","keys":["ItKe_Gomez_01"],"r":true},{"n":"IO_OC_CHEST_GOMEZ_02","k":"chest","a":"OC","keys":["ItKe_Gomez_01"]},{"n":"IO_OC_CHEST_GORHANIS","k":"chest","a":"OC","d":4,"l":"OC_Chest_GorHanis_Lock","r":true},{"n":"IO_OC_CHEST_GRAHAM","k":"chest","a":"OC","d":6,"l":"OC_Chest_Graham_Lock","r":true},{"n":"IO_OC_CHEST_GRAVO","k":"chest","a":"OC","d":3,"l":"OC_Chest_Gravo_Lock","r":true},{"n":"IO_OC_CHEST_GRIM","k":"chest","a":"OC","d":3,"l":"OC_Chest_Grim_Lock","r":true},{"n":"IO_OC_CHEST_GUARD206","k":"chest","a":"OC","d":2,"l":"OC_Chest_Guard206_Lock","r":true},{"n":"IO_OC_CHEST_GUARD209","k":"chest","a":"OC","d":2,"l":"OC_Chest_Guard209_Lock","r":true},{"n":"IO_OC_CHEST_GUARD220","k":"chest","a":"OC","d":2,"l":"OC_Chest_Guard220_Lock","r":true},{"n":"IO_OC_CHEST_GUARD221","k":"chest","a":"OC","d":3,"l":"OC_Chest_Guard221_Lock","r":true},{"n":"IO_OC_CHEST_GUARD226","k":"chest","a":"OC","d":2,"l":"OC_Chest_Guard226_Lock","r":true},{"n":"IO_OC_CHEST_GUARD228","k":"chest","a":"OC","d":3,"l":"OC_Chest_Guard228_Lock","r":true},{"n":"IO_OC_CHEST_GUARD229","k":"chest","a":"OC","d":2,"l":"OC_Chest_Guard229_Lock","r":true},{"n":"IO_OC_CHEST_GUARD230","k":"chest","a":"OC","d":4,"l":"OC_Chest_Guard230_Lock","r":true},{"n":"IO_OC_CHEST_GUARD232","k":"chest","a":"OC","d":2,"l":"OC_Chest_Guard232_Lock","r":true},{"n":"IO_OC_CHEST_GUARD239","k":"chest","a":"OC","d":3,"l":"OC_Chest_Guard239_Lock","r":true},{"n":"IO_OC_CHEST_GUARD240","k":"chest","a":"OC","d":3,"l":"OC_Chest_Guard240_Lock","r":true},{"n":"IO_OC_CHEST_GUARD241","k":"chest","a":"OC","d":4,"l":"OC_Chest_Guard241_Lock","r":true},{"n":"IO_OC_CHEST_GUARD242","k":"chest","a":"OC","d":3,"l":"OC_Chest_Guard242_Lock","r":true},{"n":"IO_OC_CHEST_GUARD252","k":"chest","a":"OC","d":4,"l":"OC_Chest_Guard252_Lock","r":true},{"n":"IO_OC_CHEST_GUARD253","k":"chest","a":"OC","d":2,"l":"OC_Chest_Guard253_Lock","r":true},{"n":"IO_OC_CHEST_GUY","k":"chest","a":"OC","d":5,"l":"OC_Chest_Guy_Lock","r":true},{"n":"IO_OC_CHEST_HUNO","k":"chest","a":"OC","d":4,"l":"OC_Chest_Huno_Lock","r":true},{"n":"IO_OC_CHEST_JACKAL","k":"chest","a":"OC","d":3,"l":"OC_Chest_Jackal_Lock","r":true},{"n":"IO_OC_CHEST_JESSE","k":"chest","a":"OC","d":2,"l":"OC_Chest_Jesse_Lock","r":true},{"n":"IO_OC_CHEST_KHARIM","k":"chest","a":"OC","d":2,"l":"OC_Chest_Kharim_Lock","r":true},{"n":"IO_OC_CHEST_KIRGO","k":"chest","a":"OC","d":4,"l":"OC_Chest_Kirgo_Lock","r":true},{"n":"IO_OC_CHEST_KITCHEN","k":"chest","a":"OC","d":4,"l":"OC_Chest_Kitchen_Lock","r":true},{"n":"IO_OC_CHEST_KYLE","k":"chest","a":"OC","d":3,"l":"OC_Chest_Kyle_Lock","r":true},{"n":"IO_OC_CHEST_MILTEN","k":"chest","a":"OC","d":5,"l":"OC_Chest_Milten_Lock","r":true},{"n":"IO_OC_CHEST_MORDRAG","k":"chest","a":"OC","d":4,"l":"OC_Chest_Mordrag_Lock","r":true},{"n":"IO_OC_CHEST_MUD","k":"chest","a":"OC","d":1,"l":"OC_Chest_Mud_Lock","r":true},{"n":"IO_OC_CHEST_OMID","k":"chest","a":"OC","d":2,"l":"OC_Chest_Omid_Lock","r":true},{"n":"IO_OC_CHEST_RAVEN","k":"chest","a":"OC","d":6,"l":"OC_Chest_Raven_Lock","r":true},{"n":"IO_OC_CHEST_RODRIGUEZ","k":"chest","a":"OC","d":4,"l":"OC_Chest_Rodriguez_Lock","r":true},{"n":"IO_OC_CHEST_RUINS_01","k":"chest","a":"OC","d":4,"l":"OC_Chest_Ruins_01_Lock","r":true},{"n":"IO_OC_CHEST_RUINS_02","k":"chest","a":"OC","d":3,"l":"OC_Chest_Ruins_02_Lock","r":true},{"n":"IO_OC_CHEST_SCAR","k":"chest","a":"OC","d":7,"l":"OC_Chest_Scar_Lock","r":true},{"n":"IO_OC_CHEST_SCATTY","k":"chest","a":"OC","d":3,"l":"OC_Chest_Scatty_Lock","r":true},{"n":"IO_OC_CHEST_SCORPIO","k":"chest","a":"OC","d":3,"l":"OC_Chest_Scorpio_Lock","r":true},{"n":"IO_OC_CHEST_SHADOW304","k":"chest","a":"OC","d":4,"l":"OC_Chest_Shadow304_Lock","r":true},{"n":"IO_OC_CHEST_SHADOW306","k":"chest","a":"OC","d":3,"l":"OC_Chest_Shadow306_Lock","r":true},{"n":"IO_OC_CHEST_SHADOW310","k":"chest","a":"OC","d":1,"l":"OC_Chest_Shadow310_Lock","r":true},{"n":"IO_OC_CHEST_SHADOW313","k":"chest","a":"OC","d":4,"l":"OC_Chest_Shadow313_Lock","r":true},{"n":"IO_OC_CHEST_SHADOW314","k":"chest","a":"OC","d":3,"l":"OC_Chest_Shadow314_Lock","r":true},{"n":"IO_OC_CHEST_SHADOW316","k":"chest","a":"OC","d":4,"l":"OC_Chest_Shadow316_Lock","r":true},{"n":"IO_OC_CHEST_SHADOW318","k":"chest","a":"OC","d":4,"l":"OC_Chest_Shadow318_Lock","r":true},{"n":"IO_OC_CHEST_SHADOW319","k":"chest","a":"OC","d":4,"l":"OC_Chest_Shadow319_Lock","r":true},{"n":"IO_OC_CHEST_SHADOW322","k":"chest","a":"OC","d":3,"l":"OC_Chest_Shadow322_Lock","r":true},{"n":"IO_OC_CHEST_SHADOW328","k":"chest","a":"OC","d":3,"l":"OC_Chest_Shadow328_Lock","r":true},{"n":"IO_OC_CHEST_SHADOW330","k":"chest","a":"OC","d":3,"l":"OC_Chest_Shadow330_Lock","r":true},{"n":"IO_OC_CHEST_SKIP","k":"chest","a":"OC","d":4,"l":"OC_Chest_Skip_Lock","r":true},{"n":"IO_OC_CHEST_SLY","k":"chest","a":"OC","d":5,"l":"OC_Chest_Sly_Lock","r":true},{"n":"IO_OC_CHEST_SNAF_01","k":"chest","a":"OC","d":1,"l":"OC_Chest_Snaf_01_Lock","r":true},{"n":"IO_OC_CHEST_SNAF_02","k":"chest","a":"OC","d":4,"l":"OC_Chest_Snaf_02_Lock","r":true},{"n":"IO_OC_CHEST_STONE","k":"chest","a":"OC","d":3,"l":"OC_Chest_Stone_Lock","r":true},{"n":"IO_OC_CHEST_THORUS","k":"chest","a":"OC","d":5,"l":"OC_Chest_Thorus_Lock","r":true},{"n":"IO_OC_CHEST_THRONE_01","k":"chest","a":"OC","d":1,"l":"OC_Chest_Throne_01_Lock","keys":["ItKe_Gomez_01"],"r":true},{"n":"IO_OC_CHEST_TORREZ","k":"chest","a":"OC","d":5,"l":"OC_Chest_Torrez_Lock","r":true},{"n":"IO_OC_CHEST_TOWER_01","k":"chest","a":"OC","d":6,"l":"OC_Chest_Tower_01_Lock","r":true},{"n":"IO_OC_CHEST_TOWER_02","k":"chest","a":"OC","d":5,"l":"OC_Chest_Tower_02_Lock","r":true},{"n":"IO_OC_CHEST_TOWER_03","k":"chest","a":"OC","d":3,"l":"OC_Chest_Tower_03_Lock","r":true},{"n":"IO_OC_CHEST_TOWER_04","k":"chest","a":"OC","d":6,"l":"OC_Chest_Tower_04_Lock","r":true},{"n":"IO_OC_CHEST_TOWER_05","k":"chest","a":"OC","d":3,"l":"OC_Chest_Tower_05_Lock","r":true},{"n":"IO_OC_CHEST_TOWER_06","k":"chest","a":"OC","d":5,"l":"OC_Chest_Tower_06_Lock","r":true},{"n":"IO_OC_CHEST_TOWER_07","k":"chest","a":"OC","d":4,"l":"OC_Chest_Tower_07_Lock","r":true},{"n":"IO_OC_CHEST_TOWER_08","k":"chest","a":"OC","d":6,"l":"OC_Chest_Tower_08_Lock","r":true},{"n":"IO_OC_CHEST_TOWER_09","k":"chest","a":"OC","d":5,"l":"OC_Chest_Tower_09_Lock","r":true},{"n":"IO_OC_CHEST_WHISTLER","k":"chest","a":"OC","d":1,"l":"OC_Chest_Whistler_Lock","r":true},{"n":"IO_OG_CHEST_01","k":"chest","a":"OG","d":5,"l":"OG_Chest_01_Lock","r":true},{"n":"IO_OM_CHEST_AARON","k":"chest","a":"OM","d":7,"l":"OM_Chest_Aaron_Lock","keys":["ItKe_Om_02"],"r":true},{"n":"IO_OM_CHEST_ALBERTO_01","k":"chest","a":"OM","d":4,"l":"OM_Chest_Alberto_01_Lock","r":true},{"n":"IO_OM_CHEST_ALBERTO_03","k":"chest","a":"OM","d":4,"l":"OM_Chest_Alberto_03_Lock","r":true},{"n":"IO_OM_CHEST_ALBERTO_04","k":"chest","a":"OM","d":1,"l":"OM_Chest_Alberto_04_Lock","r":true},{"n":"IO_OM_CHEST_ALBERTO_05","k":"chest","a":"OM","d":1,"l":"OM_Chest_Alberto_05_Lock","r":true},{"n":"IO_OM_CHEST_SANTINO_01","k":"chest","a":"OM","d":4,"l":"OM_Chest_Santino_01_Lock","r":true},{"n":"IO_OM_CHEST_SANTINO_02","k":"chest","a":"OM","d":2,"l":"OM_Chest_Santino_02_Lock","r":true},{"n":"IO_OM_CHEST_SANTINO_03","k":"chest","a":"OM","d":2,"l":"OM_Chest_Santino_03_Lock","r":true},{"n":"IO_OM_CHEST_ULBERT_01","k":"chest","a":"OM","d":7,"l":"OM_Chest_Ulbert_01_Lock","keys":["ItKe_Om_03"],"r":true},{"n":"IO_OM_CHEST_ULBERT_02","k":"chest","a":"OM","d":6,"l":"OM_Chest_Ulbert_02_Lock","keys":["ItKe_Om_03"],"r":true},{"n":"IO_Otown_CHEST_01","k":"chest","a":"OTOWN","d":5,"l":"Otown_Chest_01_Lock","r":true},{"n":"IO_Otown_CHEST_02","k":"chest","a":"OTOWN","d":6,"l":"Otown_Chest_02_Lock","r":true},{"n":"IO_Otown_CHEST_03","k":"chest","a":"FM","d":6,"l":"Otown_Chest_03_Lock","r":true},{"n":"IO_Otown_CHEST_04","k":"chest","a":"OTOWN","d":6,"l":"Otown_Chest_04_Lock","r":true},{"n":"IO_OTT_Chest_05","k":"chest","a":"OT","d":4,"l":"OTT_Chest_05_Lock","r":true},{"n":"IO_OTT_Chest_06","k":"chest","a":"OT","d":5,"l":"OTT_Chest_06_Lock","r":true},{"n":"IO_OTT_Chest_09","k":"chest","a":"OT","d":5,"l":"OTT_Chest_09_Lock","r":true},{"n":"IO_SC_CHEST_BAALCADAR","k":"chest","a":"SC","d":5,"l":"SC_Chest_BaalCadar_Lock","r":true},{"n":"IO_SC_CHEST_BAALNAMIB","k":"chest","a":"SC","d":4,"l":"SC_Chest_BaalNamib_Lock","r":true},{"n":"IO_SC_CHEST_BAALORUN","k":"chest","a":"SC","d":4,"l":"SC_Chest_BaalOrun_Lock","r":true},{"n":"IO_SC_CHEST_BAALTONDRAL","k":"chest","a":"SC","d":4,"l":"SC_Chest_BaalTondral_Lock","r":true},{"n":"IO_SC_CHEST_BLACKSMITH","k":"chest","a":"SC","d":6,"l":"SC_Chest_Blacksmith_Lock","r":true},{"n":"IO_SC_CHEST_CHANI","k":"chest","a":"SC","d":4,"l":"SC_Chest_Chani_Lock","r":true},{"n":"IO_SC_CHEST_CORANGAR","k":"chest","a":"SC","d":4,"l":"SC_Chest_CorAngar_Lock","r":true},{"n":"IO_SC_CHEST_CORKALOM_01","k":"chest","a":"SC","d":1,"l":"SC_Chest_CorKalom_01_Lock","r":true},{"n":"IO_SC_CHEST_CORKALOM_03","k":"chest","a":"SC","d":7,"l":"SC_Chest_CorKalom_03_Lock","r":true},{"n":"IO_SC_CHEST_FORTUNO","k":"chest","a":"SC","d":6,"l":"SC_Chest_Fortuno_Lock","r":true},{"n":"IO_SC_CHEST_NATALIA","k":"chest","a":"SC","d":5,"l":"SC_Chest_Natalia_Lock","r":true},{"n":"IO_SC_CHEST_NYRAS","k":"chest","a":"SC","d":4,"l":"SC_Chest_Nyras_Lock","r":true},{"n":"IO_SC_CHEST_VIRAN","k":"chest","a":"SC","d":4,"l":"SC_Chest_Viran_Lock","r":true},{"n":"IO_SC_CHEST_YBERION","k":"chest","a":"SC","d":6,"l":"SC_Chest_Yberion_Lock","r":true},{"n":"IO_SNT_CHEST_09","k":"chest","a":"SNT","d":5,"l":"SNT_Chest_09_Lock","r":true},{"n":"IO_SW_CHEST_01","k":"chest","a":"SW","d":7,"l":"SW_Chest_01_Lock","r":true},{"n":"IO_SW_CHEST_02","k":"chest","a":"SW","d":6,"l":"SW_Chest_02_Lock","r":true},{"n":"IO_SW_CHEST_03","k":"chest","a":"SW","d":5,"l":"SW_Chest_03_Lock","r":true},{"n":"IO_SW_CHEST_07","k":"chest","a":"SW","d":1,"l":"SW_Chest_07_Lock","r":true},{"n":"IO_XT_CHEST_01","k":"chest","a":"XT","d":6,"l":"XT_Chest_01_Lock","r":true},{"n":"IO_XT_CHEST_02","k":"chest","a":"XT","d":4,"l":"XT_Chest_02_Lock","r":true},{"n":"IO_XT_CHEST_03","k":"chest","a":"XT","d":6,"l":"XT_Chest_03_Lock","r":true},{"n":"IO_XT_CHEST_04","k":"chest","a":"XT","d":6,"l":"XT_Chest_04_Lock","r":true},{"n":"IO_XT_CHEST_05","k":"chest","a":"XT","d":7,"l":"XT_Chest_05_Lock","r":true},{"n":"IO_XT_CHEST_06","k":"chest","a":"XT","d":7,"l":"XT_Chest_06_Lock","r":true},{"n":"MF_CellarCages_Door","k":"door","a":"MF","d":5,"l":"MF_CellarCages_Lock","keys":["ItKe_CellarCages_01"]},{"n":"MF_CellarCages_Door_02","k":"door","a":"MF","d":4,"l":"MF_CellarCages_Lock_02","keys":["ItKe_CellarCages_01"]},{"n":"NC_Cave_Lee_Door","k":"door","a":"NC","d":4,"l":"NC_LeeCave_Door_Lock","keys":["ItKe_LeeDoor"]},{"n":"NC_Cave_Tavern_Door","k":"door","a":"NC","d":2,"l":"NC_Cave_Tavern_Door_Lock","keys":["ItKe_TavernDoor"]},{"n":"NC_Cave_Tavern_Door_02","k":"door","a":"NC","d":5,"l":"NC_Cave_Tavern_Door_Lock_02","keys":["ItKe_TavernDoor"]},{"n":"NC_RiceLord_Door_01","k":"door","a":"NC","d":5,"l":"NC_RiceLord_Room_Lock_01","keys":["ItKe_Ricelord_01"]},{"n":"NC_RiceLord_Door_02","k":"door","a":"NC","d":3,"l":"NC_RiceLord_Room_Lock_02","keys":["ItKe_Ricelord_01"]},{"n":"OC_Armory_Door","k":"door","a":"OC","d":6,"l":"OC_Armory_Door_Lock","keys":["ItKe_Smith_01","ItKe_GomezCastleMaster"]},{"n":"OC_Cell12_Bandit_Door","k":"door","a":"OC","d":2,"l":"OC_Cell12_Bandit_Door_Lock","keys":["ItKe_BanditCell","ItKe_GomezCastleMaster"]},{"n":"OC_Cellar_Door","k":"door","a":"OC","d":6,"l":"OC_Cellar_Door_Lock","keys":["ItKe_Storage_01","ItKe_MerchantCellarDoor","ItKe_GomezCastleMaster","ItKe_Dungeonkey"]},{"n":"OC_Cellar_Door_02","k":"door","a":"OC","d":5,"l":"OC_Cellar_Lock_02","keys":["ItKe_MerchantCellarDoor","ItKe_Storage_01","ItKe_GomezCastleMaster","ItKe_Dungeonkey"]},{"n":"OC_Fisk_Hut_Door","k":"door","a":"OC","d":4,"l":"OC_Fisk_Hut_Door_Lock"},{"n":"OC_Fisk_Storage_Door","k":"door","a":"OC","d":7,"l":"OC_Fisk_Storage_Door_Lock","keys":["ItKe_FiskStorage"]},{"n":"OC_Forge_Loot_Room_Door","k":"door","a":"OC","l":"OC_Forge_Loot_Room_Lock"},{"n":"OC_Gomez_Room_Door","k":"door","a":"OC","d":4,"l":"OC_Gomez_Room_Lock","keys":["ItKe_GomezCastleMaster","ItKe_Gomez_01"]},{"n":"OC_Guards_Cell_01_Door","k":"door","a":"OC","l":"OC_Guards_Cell_01_Lock"},{"n":"OC_Guards_Cell_02_Door","k":"door","a":"OC","l":"OC_Guards_Cell_02_Lock"},{"n":"OC_Mages_Room_01_Door","k":"door","a":"OC","d":3,"l":"OC_Mages_Room_01_Lock","keys":["ItKe_FireMagesRoom"]},{"n":"OC_Mages_Room_02_Door","k":"door","a":"OC","d":6,"l":"OC_Mages_Room_02_Lock","keys":["ItKe_FireMagesRoom"]},{"n":"OC_Mages_Room_03_Door","k":"door","a":"OC","d":3,"l":"OC_Mages_Room_03_Lock","keys":["ItKe_FireMagesRoom"]},{"n":"OC_Merchant_Secret_Passage_Door","k":"door","a":"OC","keys":["ItKe_MerchantCellarDoor"]},{"n":"OC_Orebaron_Room_01_Door","k":"door","a":"OC","l":"OC_Orebaron_Room_01_Lock"},{"n":"OC_Orebaron_Room_02_Door","k":"door","a":"OC","l":"OC_Orebaron_Room_02_Lock"},{"n":"OC_Prison_Dungeons_Cell_04_Door","k":"door","a":"OC","keys":["Permalocked"]},{"n":"OC_Prison_Dungeons_Cell_05_Door","k":"door","a":"OC","keys":["Permalocked"]},{"n":"OC_Prison_Dungeons_Cell_06_Door","k":"door","a":"OC","d":2,"l":"OC_Prison_Dungeons_06_Lock","keys":["ItKe_Dungeonkey","ItKe_GomezCastleMaster"]},{"n":"OC_Prison_Dungeons_Cell_09_Door","k":"door","a":"OC","d":7,"l":"OC_Prison_Dungeons_09_Lock","keys":["ItKe_Dungeonkey","ItKe_GomezCastleMaster"]},{"n":"OC_Prison_Dungeons_Cell_11_Door","k":"door","a":"OC","keys":["Permalocked"]},{"n":"OC_Prison_Dungeons_Cell_23_Door","k":"door","a":"OC","d":2,"l":"OC_Prison_Dungeons_23_Lock","keys":["ItKe_Dungeonkey","ItKe_GomezCastleMaster"]},{"n":"OC_Prison_Dungeons_Moat_Door","k":"door","a":"OC","d":7,"l":"OC_Prison_Dungeons_Moat_Lock","keys":["ItKe_GomezCastleMaster"]},{"n":"OC_Raven_Room_01_Door","k":"door","a":"OC","l":"OC_Raven_Room_01_Lock"},{"n":"OC_Raven_Room_02_Door","k":"door","a":"OC","d":6,"l":"OC_Raven_Room_02_Lock","keys":["ItKe_RavenTower","ItKe_GomezCastleMaster"]},{"n":"OC_Santino_Door","k":"door","a":"OC","d":5,"l":"OC_Santino_Lock","keys":["ItKe_MerchantCellarDoor"]},{"n":"OG_MainTomb_Gate","k":"door","a":"OG","keys":["ItMs_Focus_01"]},{"n":"OrcGraveyard_Puzzle_Door","k":"door","a":"OG","keys":["ItKe_OrcGraveyard"]},{"n":"SNT_LootCage_Door","k":"door","a":"ST","d":1,"l":"SNT_LootCage_Door_Lock","keys":["ItKe_Sunkentower"]},{"n":"SunkenTower_Door_02_Altar_A","k":"door","a":"SNT","keys":["ItMs_SunkenTowerStone_01"]},{"n":"SunkenTower_Door_02_Altar_B","k":"door","a":"SNT","keys":["ItMs_SunkenTowerStone_02"]},{"n":"SW_Captain_Ship_Door","k":"door","a":"SW","d":6,"l":"SW_Captain_Ship_Door_Lock","keys":["ItKe_CaptainShip"]},{"n":"XT_ExperimentalChamber_Door","k":"door","a":"XT","d":1,"l":"XT_ExperimentalChamber_Door_Lock"}]} diff --git a/apps/save-editor/lib/features/editor/domain/editor_notifier.dart b/apps/save-editor/lib/features/editor/domain/editor_notifier.dart index 77bec45fc..4428611ec 100644 --- a/apps/save-editor/lib/features/editor/domain/editor_notifier.dart +++ b/apps/save-editor/lib/features/editor/domain/editor_notifier.dart @@ -10,6 +10,7 @@ import 'package:goresave/features/editor/domain/editor_settings_store.dart'; import 'package:goresave/features/editor/domain/game_time.dart'; import 'package:goresave/features/editor/domain/glossary_models.dart'; import 'package:goresave/features/editor/domain/hero_attributes.dart'; +import 'package:goresave/features/editor/domain/locks_models.dart'; import 'package:goresave/features/editor/domain/npc_actors_page.dart'; import 'package:goresave/features/editor/domain/npc_attributes.dart'; import 'package:goresave/features/editor/domain/npc_position.dart'; @@ -1197,9 +1198,7 @@ class EditorNotifier extends StateNotifier { /// survive. void refreshSelectedActorStatus({required String id, required bool isDead}) { final selected = state.selectedActor; - if (selected.isPlayer || - selected.id != id || - selected.isDead == isDead) { + if (selected.isPlayer || selected.id != id || selected.isDead == isDead) { return; } state = state.copyWith( @@ -4289,6 +4288,40 @@ class EditorNotifier extends StateNotifier { } } + /// Which locks this save records as already opened. + /// + /// The catalog of locks that EXIST is bundled with the app, not read from the + /// save — a fresh game carries an empty set — so the panel loads the two and + /// joins them. + Future loadLocks() async { + final path = state.selectedPath; + if (path == null) { + return LocksResult(error: _l10n.editorNoSaveSelected); + } + try { + final response = await _execute( + 'private.locks.list', + payload: {'path': path}, + ); + if (response['ok'] != true) { + return LocksResult( + error: _l10n.editorLockListFailed(_errorDetails(response)), + ); + } + return LocksResult.fromJson( + (response['data'] as Map).cast(), + ); + } catch (error) { + return LocksResult(error: _l10n.editorLockListFailed('$error')); + } + } + + /// Pending-edit key of the queued lock changes. One entry holds every toggle: + /// `private.locks.setUnlocked` is value-addressed (the core finds the lock by + /// name on a fresh parse per edit), so a whole panel of them batches into a + /// single `write_save` and needs no place in [splicingPaths]. + static const pendingLocksKey = 'world.locks'; + /// Pending-edit key prefix for a queued faction forgive (``). static const _factionForgivePrefix = 'factions.forgive:'; @@ -4484,6 +4517,10 @@ String? _structuredEditTarget(Map edit) { foldEditTargetPart(fields['character']), foldEditTargetPart(fields['entry']), ]); + // Two intents for the same lock in one write contradict each other; the + // core refuses the pair, so catch it here rather than at save time. + case 'private.locks.setUnlocked': + return key([foldEditTargetPart(fields['lock'])]); case 'private.glossary.setSegment': return key([ _foldAssetName(fields['documentClass']), @@ -4833,6 +4870,14 @@ bool structuredEditRewrites( 'CharacterKnowledgeByUniqueName', character, ); + case 'private.locks.setUnlocked': + return _pathHasName(typedPath, 'm_UnlockedLocks') || + (fields['unlocked'] == false && + const [ + 'm_DoorsOpen', + 'm_DoorsClosed', + 'm_SavedDoorsMessagesStruct', + ].any((name) => _pathHasName(typedPath, name))); // Claims a whole slot — but only in the inventory it targets; another // actor's slots are a different subtree. case 'private.inventory.addItem': @@ -4946,6 +4991,8 @@ bool _mayInvalidateOrdinals(Map edit) { 'private.inventory.repairSlots', 'private.knowledge.addCharacter', 'private.knowledge.setEntry', + // Set-adds or set-removes a name in m_UnlockedLocks. + 'private.locks.setUnlocked', 'private.npc.revive', 'private.npc.setRelationship', 'private.glossary.setSegment', diff --git a/apps/save-editor/lib/features/editor/domain/lock_catalog.dart b/apps/save-editor/lib/features/editor/domain/lock_catalog.dart new file mode 100644 index 000000000..eeb0b24b0 --- /dev/null +++ b/apps/save-editor/lib/features/editor/domain/lock_catalog.dart @@ -0,0 +1,108 @@ +import 'dart:convert'; + +import 'package:flutter/services.dart' show rootBundle; + +/// Whether a lock sits on a chest or on a door. The save does not distinguish +/// the two — both are names in the same set — so the split comes from the +/// catalog, where it is read off the game's own class defaults. +enum LockKind { chest, door } + +/// How many of the game's four difficulty pips are filled for a raw 1..7 tier. +/// +/// The lockpicking widget (`/Game/UI/LockPick/W_LockPickUI`) holds exactly four +/// `Difficulty_1..4` images and fills each one when the tier reaches its own +/// threshold. The thresholds are 1, 2, 4, and 6 — so the seven internal tiers +/// collapse onto four bars as 1 / 2,3 / 4,5 / 6,7, and the player never sees +/// the raw number anywhere in the game. +const lockDifficultyBarThresholds = [1, 2, 4, 6]; + +/// Total pips the game draws; the unfilled ones use its "empty" texture. +const lockDifficultyBarCount = 4; + +int lockDifficultyBars(int difficulty) => + lockDifficultyBarThresholds.where((t) => difficulty >= t).length; + +/// One lockable chest or door in the game. +/// +/// [name] is the key the save is addressed by: the lock-bearing class's +/// `m_UniqueName`, which is exactly what turns up in +/// `LockPersistentData.m_UnlockedLocks` once the player has opened it. The +/// class's own `m_Lock` id ([lockId]) is a different string and never appears +/// in a save; it is carried only so a row can be traced back to the script. +class LockEntry { + LockEntry({ + required this.name, + required this.kind, + required this.area, + required this.difficulty, + required this.lockId, + required this.keys, + required this.randomized, + }) : search = [name, ...keys].join(' ').toLowerCase(); + + final String name; + final LockKind kind; + + /// Area code, matching a `LocationArea.id`, or `''` when unknown. + final String area; + + /// Lockpicking difficulty, 1–7. Null for a lock that only ever opens with a + /// key, and for the chests whose lock the randomizer assigns at runtime. + final int? difficulty; + + /// The script-side lock id (`OC_Chest_Dexter_Lock`). Never a save key. + final String? lockId; + + /// Item ids of the keys that open this lock, if any. + final List keys; + + /// Whether the randomized-lock subsystem registers this chest. Those carry no + /// fixed difficulty of their own, but they do get a lock in a running game. + final bool randomized; + + /// Lowercased [name] plus key ids, computed ONCE at parse time. The list is + /// filtered client-side, so each keystroke must be a substring scan over + /// cached strings rather than hundreds of fresh `toLowerCase()` calls. + final String search; +} + +/// The bundled catalog of every lock in the game, generated from the shipped +/// AngelScript cache by `apps/save-editor/tools/build_lock_catalog.py`. +/// Regenerate after a game patch: the lock set is cook-specific. +class LockCatalog { + LockCatalog({required this.locks}) + : _byName = {for (final lock in locks) lock.name.toLowerCase(): lock}; + + final List locks; + final Map _byName; + + /// Case-insensitive, matching UE `FName` semantics: a save may spell a lock + /// differently from the catalog and still mean the same lock. + LockEntry? byName(String name) => _byName[name.toLowerCase()]; + + static LockCatalog fromJsonString(String json) { + final root = jsonDecode(json) as Map; + final locks = (root['locks'] as List? ?? const []) + .whereType>() + .map( + (lock) => LockEntry( + name: lock['n'] as String? ?? '', + kind: lock['k'] == 'door' ? LockKind.door : LockKind.chest, + area: lock['a'] as String? ?? '', + difficulty: (lock['d'] as num?)?.toInt(), + lockId: lock['l'] as String?, + keys: (lock['keys'] as List? ?? const []) + .whereType() + .toList(growable: false), + randomized: lock['r'] == true, + ), + ) + .where((lock) => lock.name.isNotEmpty) + .toList() + ..sort((a, b) => a.name.compareTo(b.name)); + return LockCatalog(locks: locks); + } + + static Future loadBundled() async => + fromJsonString(await rootBundle.loadString('assets/lock_catalog.json')); +} diff --git a/apps/save-editor/lib/features/editor/domain/locks_models.dart b/apps/save-editor/lib/features/editor/domain/locks_models.dart new file mode 100644 index 000000000..2f98f521c --- /dev/null +++ b/apps/save-editor/lib/features/editor/domain/locks_models.dart @@ -0,0 +1,60 @@ +/// Result of `private.locks.list`. +/// +/// A save records only which locks the player has ALREADY opened, so this is +/// the smaller half of the picture: the panel joins it against the bundled +/// lock catalog, which is what knows the other 200-odd locks that are still +/// shut. Carries an optional [error] (set by the notifier instead of throwing) +/// so the panel renders failures inline. +class LocksResult { + LocksResult({this.unlocked = const [], this.writable = const [], this.error}) + : _folded = {for (final name in unlocked) name.toLowerCase()}; + + factory LocksResult.fromJson(Map json) { + return LocksResult( + unlocked: (json['unlocked'] as List? ?? const []) + .whereType() + .toList(growable: false), + writable: (json['writable'] as List? ?? const []) + .whereType() + .toList(growable: false), + ); + } + + /// Every lock the player has opened, spelled as the save spells it. The + /// spelling matters for a name the catalog does not know: that row is + /// rendered and edited under the save's own name, not a folded copy. + final List unlocked; + + /// Lower-cased [unlocked], for membership tests. The save's set is an FName + /// set, so a catalog entry differing only in case is the same lock. + final Set _folded; + + /// Ops the core will accept for this save. Empty when the save carries no + /// editable lock set, which is what keeps the panel read-only instead of + /// offering a toggle the write path would refuse. + final List writable; + + final String? error; + + bool get canSetUnlocked => writable.contains('private.locks.setUnlocked'); + + bool isUnlocked(String name) => _folded.contains(name.toLowerCase()); +} + +/// Pending lock change → `private.locks.setUnlocked`. Declarative: the state +/// one lock shall be in. Keyed by the lock name in the pending map, so +/// toggling a row back to its saved value drops the edit instead of stacking a +/// second one. +class LockSetUnlockedEdit { + const LockSetUnlockedEdit({required this.lock, required this.unlocked}); + + final String lock; + final bool unlocked; + + Map toEditJson() { + return { + 'path': 'private.locks.setUnlocked', + 'value': {'lock': lock, 'unlocked': unlocked}, + }; + } +} diff --git a/apps/save-editor/lib/features/editor/ui/area_labels.dart b/apps/save-editor/lib/features/editor/ui/area_labels.dart new file mode 100644 index 000000000..e6f4be14b --- /dev/null +++ b/apps/save-editor/lib/features/editor/ui/area_labels.dart @@ -0,0 +1,63 @@ +import 'package:flutter/foundation.dart' show visibleForTesting; +import 'package:goresave/features/editor/domain/location_catalog.dart'; +import 'package:goresave/l10n/app_localizations.dart'; +import 'package:goresave/loc/game_lang.dart'; + +/// Our own name for an area the game itself does not name, or null when the +/// area is not one of them. +/// +/// The catalog gives every area an English `label` and gives 18 of the 26 a +/// `locId` into the game's own strings. The other eight have no clean label +/// anywhere in the game's 43,851 ids — only quest titles, item names and +/// dialogue lines mention them — so their names are the editor's own and live +/// in the ARB like any other piece of UI text. +/// +/// An explicit `switch` rather than "whatever the ARB happens to contain": an +/// area added to the catalog without a translation lands on `_` and is named by +/// `location_picker_dialog_test`, instead of silently rendering English inside +/// an otherwise German sidebar — which is the bug this table exists to close. +@visibleForTesting +String? appAreaLabel(String areaId, AppLocalizations l10n) => switch (areaId) { + 'CV' => l10n.locationAreaCavalornValley, + 'EF' => l10n.locationAreaEastForest, + 'FT' => l10n.locationAreaFogTower, + 'HC' => l10n.locationAreaTundra, + 'IWM' => l10n.locationAreaIllegalWeedMixers, + 'OA' => l10n.locationAreaOrcArena, + 'OG' => l10n.locationAreaOrcGraveyard, + 'SW' => l10n.locationAreaShipwreck, + _ => null, +}; + +/// Localized area name, in this order: the game's own notification string when +/// the catalog carries a loc id, then our [appAreaLabel] for the areas the game +/// does not name, and only then the generated English [LocationArea.label] — a +/// safety net for an area added to the catalog before anyone translated it, +/// never the normal outcome. +/// +/// Shared by the location picker and the locks panel: both group by the same +/// area codes, and a second copy of the table above is exactly the drift this +/// function exists to prevent. +/// +/// NOTE on German: for this `area_*` family the real string sits in the +/// `german` set and `german_new` is NULL — inverted versus the rest of the +/// game's text, where `german_new` wins. No special casing is needed because +/// [resolveGameText] walks `lang.locSets` in order and SKIPS empty/missing +/// sets, so `german_new` being absent falls through to `german` on its own. +String localizedAreaLabel( + String areaId, + LocationCatalog catalog, + Map> locCatalog, + GameLang lang, + AppLocalizations l10n, +) { + if (areaId.isEmpty) return l10n.locationAreaOther; + final area = catalog.areaById(areaId); + if (area == null) return areaId; + final locId = area.locId; + if (locId != null && locId.isNotEmpty) { + final localized = resolveGameText(locCatalog, locId, lang); + if (localized != null && localized.trim().isNotEmpty) return localized; + } + return appAreaLabel(areaId, l10n) ?? area.label; +} diff --git a/apps/save-editor/lib/features/editor/ui/location_picker_dialog.dart b/apps/save-editor/lib/features/editor/ui/location_picker_dialog.dart index dc0fc2434..d905a629f 100644 --- a/apps/save-editor/lib/features/editor/ui/location_picker_dialog.dart +++ b/apps/save-editor/lib/features/editor/ui/location_picker_dialog.dart @@ -3,11 +3,18 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:goresave/features/editor/domain/location_catalog.dart'; +import 'package:goresave/features/editor/ui/area_labels.dart'; import 'package:goresave/features/editor/ui/sidebar_tile.dart'; import 'package:goresave/l10n/app_localizations.dart'; import 'package:goresave/loc/game_lang.dart'; import 'package:goresave/loc/loc_catalog_provider.dart'; +// The area-name table moved to area_labels.dart so the locks panel can group by +// the same codes. Re-exported: `appAreaLabel` is part of this file's tested +// surface. +export 'package:goresave/features/editor/ui/area_labels.dart' + show appAreaLabel; + /// The result of [showLocationPickerDialog]: the chosen [spot] plus whether the /// user asked for its orientation to be applied as well. /// @@ -42,32 +49,6 @@ Future showLocationPickerDialog( ); } -/// Our own name for an area the game itself does not name, or null when the -/// area is not one of them. -/// -/// The catalog gives every area an English `label` and gives 18 of the 26 a -/// `locId` into the game's own strings. The other eight have no clean label -/// anywhere in the game's 43,851 ids — only quest titles, item names and -/// dialogue lines mention them — so their names are the editor's own and live -/// in the ARB like any other piece of UI text. -/// -/// An explicit `switch` rather than "whatever the ARB happens to contain": an -/// area added to the catalog without a translation lands on `_` and is named by -/// `location_picker_dialog_test`, instead of silently rendering English inside -/// an otherwise German sidebar — which is the bug this table exists to close. -@visibleForTesting -String? appAreaLabel(String areaId, AppLocalizations l10n) => switch (areaId) { - 'CV' => l10n.locationAreaCavalornValley, - 'EF' => l10n.locationAreaEastForest, - 'FT' => l10n.locationAreaFogTower, - 'HC' => l10n.locationAreaTundra, - 'IWM' => l10n.locationAreaIllegalWeedMixers, - 'OA' => l10n.locationAreaOrcArena, - 'OG' => l10n.locationAreaOrcGraveyard, - 'SW' => l10n.locationAreaShipwreck, - _ => null, -}; - class _LocationPickerDialog extends ConsumerStatefulWidget { const _LocationPickerDialog({this.catalogOverride}); @@ -154,34 +135,16 @@ class _LocationPickerDialogState extends ConsumerState<_LocationPickerDialog> { return groups; } - /// Localized area name, in this order: the game's own notification string - /// when the catalog carries a loc id, then our [appAreaLabel] for the areas - /// the game does not name, and only then the generated English - /// [LocationArea.label] — a safety net for an area added to the catalog - /// before anyone translated it, never the normal outcome. - /// - /// NOTE on German: for this `area_*` family the real string sits in the - /// `german` set and `german_new` is NULL — inverted versus the rest of the - /// game's text, where `german_new` wins. No special casing is needed because - /// [resolveGameText] walks `lang.locSets` in order and SKIPS empty/missing - /// sets, so `german_new` being absent falls through to `german` on its own. + /// Localized area name. Delegates to the shared [localizedAreaLabel] so this + /// dialog and the locks panel, which group by the same area codes, can never + /// name the same area differently. String _areaLabel( String areaId, LocationCatalog catalog, Map> locCatalog, GameLang lang, AppLocalizations l10n, - ) { - if (areaId.isEmpty) return l10n.locationAreaOther; - final area = catalog.areaById(areaId); - if (area == null) return areaId; - final locId = area.locId; - if (locId != null && locId.isNotEmpty) { - final localized = resolveGameText(locCatalog, locId, lang); - if (localized != null && localized.trim().isNotEmpty) return localized; - } - return appAreaLabel(areaId, l10n) ?? area.label; - } + ) => localizedAreaLabel(areaId, catalog, locCatalog, lang, l10n); void _onSearchChanged(String _) { _debounce?.cancel(); diff --git a/apps/save-editor/lib/features/editor/ui/locks_panel.dart b/apps/save-editor/lib/features/editor/ui/locks_panel.dart new file mode 100644 index 000000000..25b014fb9 --- /dev/null +++ b/apps/save-editor/lib/features/editor/ui/locks_panel.dart @@ -0,0 +1,823 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:goresave/features/app/domain/ui_settings.dart'; +import 'package:goresave/features/editor/domain/item_categories.dart'; +import 'package:goresave/features/editor/domain/location_catalog.dart'; +import 'package:goresave/features/editor/domain/lock_catalog.dart'; +import 'package:goresave/features/editor/ui/area_labels.dart'; +import 'package:goresave/features/editor/ui/game_icon.dart'; +import 'package:goresave/l10n/app_localizations.dart'; +import 'package:goresave/loc/game_lang.dart'; +import 'package:goresave/loc/loc_catalog_provider.dart'; +import 'package:goresave/providers/data_providers.dart'; + +import '../domain/editor_models.dart'; +import '../domain/editor_notifier.dart'; +import '../domain/locks_models.dart'; +import '../domain/pending_edits.dart'; + +/// Localized item name for a key, falling back to the id-derived name the +/// inventory uses when no localization catalog has been extracted. +/// +/// Shared by the rows and the search index: a key the reader sees named one way +/// and searches by another is the defect this single source removes. +String localizedKeyName( + Map> locCatalog, + GameLang lang, + String id, +) => + localizedGameName(locCatalog, lang, id) ?? + itemDisplayNameFromId(id, fallback: id); + +/// Which kinds of lock the list shows. +enum _KindFilter { all, chests, doors } + +/// Which lock states the list shows. +enum _StateFilter { all, unlocked, locked } + +/// One row of the list: a catalog entry joined with what the save says about it. +/// +/// [entry] is null for a name the save carries that the bundled catalog does +/// not know. Those are kept and shown rather than dropped: the catalog is +/// cook-specific, so a save written by another build can name a lock this one +/// has no row for, and hiding it would hide a real, editable piece of state. +class _LockRow { + _LockRow({required this.name, required this.entry, required this.unlocked}); + + final String name; + final LockEntry? entry; + final bool unlocked; + + bool get isDoor => entry?.kind == LockKind.door; + String get area => entry?.area ?? ''; + String get search => entry?.search ?? name.toLowerCase(); +} + +/// World-tab section listing every chest and door lock in the game, with the +/// save's own "already opened" state editable per row. +/// +/// Two sources meet here. Which locks EXIST comes from the bundled catalog, +/// harvested from the game's script cache; which of them the player has opened +/// comes from `LockPersistentData.m_UnlockedLocks` in the save, which is the +/// only half a save carries at all — a fresh game has an empty set. +class LocksDetail extends ConsumerStatefulWidget { + const LocksDetail({ + super.key, + required this.notifier, + required this.editable, + required this.reloadKey, + required this.theme, + this.catalogOverride, + this.locationsOverride, + }); + + final EditorNotifier notifier; + final bool editable; + final SaveInspection reloadKey; + final ThemeData theme; + + /// Replaces the bundled asset in widget tests; production leaves it null and + /// gets [LockCatalog.loadBundled]. + final LockCatalog? catalogOverride; + + /// Same, for the location catalog the region rail takes its area names from. + final LocationCatalog? locationsOverride; + + @override + ConsumerState createState() => _LocksDetailState(); +} + +class _LocksDetailState extends ConsumerState { + LocksResult _saved = LocksResult(); + LockCatalog? _catalog; + LocationCatalog? _locations; + bool _loading = false; + int _reloadEpoch = 0; + + /// Queued toggles, keyed by the lock's own name. Toggling a row back to the + /// value the save already holds REMOVES its entry instead of stacking a + /// second one, which is what makes a second tap read as "undo". + final Map _pending = {}; + + String _query = ''; + String _area = ''; + Map _searchIndex = const {}; + Object? _searchIndexKey; + _KindFilter _kind = _KindFilter.all; + _StateFilter _state = _StateFilter.all; + + @override + void initState() { + super.initState(); + _load(); + } + + @override + void didUpdateWidget(covariant LocksDetail oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.reloadKey != oldWidget.reloadKey || + !identical(widget.notifier, oldWidget.notifier)) { + // A completed save re-seeds the inspection; drafts that survived it are + // re-read from the registry in _load. + _pending.clear(); + _load(); + } + } + + Future _load() async { + final epoch = ++_reloadEpoch; + setState(() => _loading = true); + final saved = await widget.notifier.loadLocks(); + if (!mounted || epoch != _reloadEpoch) return; + final catalog = widget.catalogOverride ?? await LockCatalog.loadBundled(); + if (!mounted || epoch != _reloadEpoch) return; + // Only for the area names in the rail; a failure here must not cost the + // list, so an unreadable catalog degrades to bare area codes. + LocationCatalog? locations = widget.locationsOverride; + if (locations == null) { + try { + locations = await LocationCatalog.loadBundled(); + } catch (_) { + locations = null; + } + } + if (!mounted || epoch != _reloadEpoch) return; + setState(() { + _loading = false; + _saved = saved; + _catalog = catalog; + _locations = locations; + _seedFromPending(); + }); + } + + /// Re-read queued toggles from the global registry, so a section switch (or a + /// partial save) does not lose drafts the Save button would still write. + void _seedFromPending() { + _pending.clear(); + final queued = widget.notifier.pendingEditFor( + EditorNotifier.pendingLocksKey, + ); + for (final edit in queued?.edits ?? const >[]) { + final value = edit['value']; + if (value is! Map) continue; + final lock = value['lock']; + final unlocked = value['unlocked']; + if (lock is String && unlocked is bool) { + _pending[lock] = LockSetUnlockedEdit(lock: lock, unlocked: unlocked); + } + } + } + + /// Push the whole draft map as ONE pending entry. Every toggle is + /// value-addressed, so they batch into a single `write_save`. + void _pushPending() { + if (_pending.isEmpty) { + widget.notifier.clearPendingEdit(EditorNotifier.pendingLocksKey); + return; + } + widget.notifier.setPendingEdit( + EditorNotifier.pendingLocksKey, + PendingSaveEdit( + edits: _pending.values.map((edit) => edit.toEditJson()).toList(), + ), + ); + } + + void _toggle(_LockRow row, bool unlocked) { + setState(() { + if (unlocked == row.unlocked) { + // Back to what the save holds: the edit is not needed at all. + _pending.remove(row.name); + } else { + _pending[row.name] = LockSetUnlockedEdit( + lock: row.name, + unlocked: unlocked, + ); + } + _pushPending(); + }); + } + + void _discardPending() { + setState(() { + _pending.clear(); + _pushPending(); + }); + } + + /// Every lock, catalog first and then the save-only names the catalog does + /// not know, each carrying the state the save records for it. + List<_LockRow> get _allRows { + final catalog = _catalog; + if (catalog == null) return const []; + final rows = [ + for (final entry in catalog.locks) + _LockRow( + name: entry.name, + entry: entry, + unlocked: _saved.isUnlocked(entry.name), + ), + ]; + final known = {for (final entry in catalog.locks) entry.name.toLowerCase()}; + for (final name in _saved.unlocked) { + if (known.contains(name.toLowerCase())) continue; + rows.add(_LockRow(name: name, entry: null, unlocked: true)); + } + return rows; + } + + /// [withArea] false answers "would this row match if no region were + /// selected?", which is what the rail's per-region counts need. + bool _matchesFilters(_LockRow row, {bool withArea = true}) { + if (withArea && _area.isNotEmpty && row.area != _area) return false; + switch (_kind) { + case _KindFilter.chests: + if (row.isDoor) return false; + case _KindFilter.doors: + if (!row.isDoor) return false; + case _KindFilter.all: + break; + } + final effective = _pending[row.name]?.unlocked ?? row.unlocked; + switch (_state) { + case _StateFilter.unlocked: + if (!effective) return false; + case _StateFilter.locked: + if (effective) return false; + case _StateFilter.all: + break; + } + if (_query.isEmpty) return true; + return (_searchIndex[row.name] ?? row.search).contains(_query); + } + + /// Searchable text per lock, including each key's LOCALIZED name. + /// + /// The catalog can only pre-compute the technical ids, but the row shows the + /// game's own item name — so without this, typing what you see finds + /// nothing while the field promises you can search by key. Rebuilt only when + /// the localization behind those names changes, not per keystroke. + void _refreshSearchIndex( + Map> locCatalog, + GameLang lang, + ) { + // The catalog is part of the key: the first build runs before it has + // loaded, and keying on the localization alone cached that empty index + // forever — the ids kept matching (they are in the catalog's own search + // string) while every localized name silently did not. + final key = (locCatalog, lang, _catalog); + if (_searchIndexKey == key) return; + _searchIndexKey = key; + _searchIndex = { + for (final entry in _catalog?.locks ?? const []) + if (entry.keys.isNotEmpty) + entry.name: [ + entry.search, + for (final id in entry.keys) localizedKeyName(locCatalog, lang, id), + ].join(' ').toLowerCase(), + }; + } + + @override + Widget build(BuildContext context) { + // Rebuild when a pending edit is dropped elsewhere (the global Save, or a + // reset from another panel), so the row switches stop reflecting a draft + // that no longer exists. + ref.watch(editorProvider.select((state) => state.pendingEdits)); + _seedFromPending(); + final l10n = AppLocalizations.of(context); + final scheme = widget.theme.colorScheme; + final locCatalog = ref.watch(locCatalogProvider).value ?? const {}; + final lang = ref.watch(currentGameLangProvider); + final showObjectIds = ref.watch(showObjectIdsProvider); + + _refreshSearchIndex(locCatalog, lang); + final rows = _allRows; + final visible = rows.where(_matchesFilters).toList(); + // Region counts follow the kind/state/search filters but NOT the region + // itself, so the rail keeps showing where the remaining matches are. + final byArea = {}; + var acrossRegions = 0; + for (final row in rows) { + if (!_matchesFilters(row, withArea: false)) continue; + acrossRegions++; + byArea[row.area] = (byArea[row.area] ?? 0) + 1; + } + + final canEdit = widget.editable && _saved.canSetUnlocked && !_loading; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // No card title: the World sidebar tile already names the section. + if (_saved.error != null) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Text( + _saved.error!, + style: TextStyle(color: scheme.error), + ), + ), + if (_saved.error == null && !_loading && !canEdit) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Text( + l10n.locksReadOnly, + style: widget.theme.textTheme.bodySmall?.copyWith( + color: scheme.onSurfaceVariant, + ), + ), + ), + Expanded( + child: _loading && rows.isEmpty + ? const Center(child: CircularProgressIndicator()) + : Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SizedBox( + width: 220, + child: _RegionRail( + counts: byArea, + total: acrossRegions, + selected: _area, + locations: _locations, + locCatalog: locCatalog, + lang: lang, + l10n: l10n, + theme: widget.theme, + onSelected: (area) => setState(() => _area = area), + ), + ), + const SizedBox(width: 12), + const VerticalDivider(width: 1), + const SizedBox(width: 12), + Expanded( + child: _LockList( + rows: visible, + total: rows.length, + pending: _pending, + canEdit: canEdit, + query: _query, + kind: _kind, + state: _state, + locCatalog: locCatalog, + lang: lang, + showObjectIds: showObjectIds, + l10n: l10n, + theme: widget.theme, + onQuery: (value) => setState( + () => _query = value.trim().toLowerCase(), + ), + onKind: (value) => setState(() => _kind = value), + onState: (value) => setState(() => _state = value), + onToggle: _toggle, + onDiscard: _discardPending, + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +/// Left rail: one row per region, with the number of locks it holds under the +/// current kind/state/search filters. +class _RegionRail extends StatelessWidget { + const _RegionRail({ + required this.counts, + required this.total, + required this.selected, + required this.locations, + required this.locCatalog, + required this.lang, + required this.l10n, + required this.theme, + required this.onSelected, + }); + + final Map counts; + final int total; + final String selected; + final LocationCatalog? locations; + final Map> locCatalog; + final GameLang lang; + final AppLocalizations l10n; + final ThemeData theme; + final ValueChanged onSelected; + + String _label(String area) { + final catalog = locations; + if (catalog == null) return area.isEmpty ? l10n.locationAreaOther : area; + return localizedAreaLabel(area, catalog, locCatalog, lang, l10n); + } + + @override + Widget build(BuildContext context) { + // Busiest region first: the Old Camp alone holds nearly half the locks, and + // an alphabetical rail would bury it below a dozen one-lock corners. + final areas = counts.keys.toList() + ..sort((a, b) { + final byCount = counts[b]!.compareTo(counts[a]!); + return byCount != 0 ? byCount : _label(a).compareTo(_label(b)); + }); + + return ListView( + padding: EdgeInsets.zero, + children: [ + _RegionTile( + label: l10n.locksAllRegions, + count: total, + selected: selected.isEmpty, + onTap: () => onSelected(''), + theme: theme, + ), + for (final area in areas) + _RegionTile( + label: _label(area), + count: counts[area]!, + selected: selected == area, + onTap: () => onSelected(area), + theme: theme, + ), + ], + ); + } +} + +class _RegionTile extends StatelessWidget { + const _RegionTile({ + required this.label, + required this.count, + required this.selected, + required this.onTap, + required this.theme, + }); + + final String label; + final int count; + final bool selected; + final VoidCallback onTap; + final ThemeData theme; + + @override + Widget build(BuildContext context) { + return ListTile( + key: ValueKey('locks-region-$label'), + dense: true, + selected: selected, + selectedTileColor: theme.colorScheme.primaryContainer, + title: _EllipsisTooltip(text: label), + trailing: Text( + '$count', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + onTap: onTap, + ); + } +} + +/// Right pane: search, the two filter chip rows, and the rows themselves. +class _LockList extends StatelessWidget { + const _LockList({ + required this.rows, + required this.total, + required this.pending, + required this.canEdit, + required this.query, + required this.kind, + required this.state, + required this.locCatalog, + required this.lang, + required this.showObjectIds, + required this.l10n, + required this.theme, + required this.onQuery, + required this.onKind, + required this.onState, + required this.onToggle, + required this.onDiscard, + }); + + final List<_LockRow> rows; + final int total; + final Map pending; + final bool canEdit; + final String query; + final _KindFilter kind; + final _StateFilter state; + final Map> locCatalog; + final GameLang lang; + final bool showObjectIds; + final AppLocalizations l10n; + final ThemeData theme; + final ValueChanged onQuery; + final ValueChanged<_KindFilter> onKind; + final ValueChanged<_StateFilter> onState; + final void Function(_LockRow row, bool unlocked) onToggle; + final VoidCallback onDiscard; + + String _kindLabel(_KindFilter value) => switch (value) { + _KindFilter.all => l10n.categoryAll, + _KindFilter.chests => l10n.locksFilterChests, + _KindFilter.doors => l10n.locksFilterDoors, + }; + + String _stateLabel(_StateFilter value) => switch (value) { + _StateFilter.all => l10n.categoryAll, + _StateFilter.unlocked => l10n.locksFilterUnlocked, + _StateFilter.locked => l10n.locksFilterLocked, + }; + + String _keyName(String id) { + final name = localizedKeyName(locCatalog, lang, id); + return showObjectIds && name != id ? '$name ($id)' : name; + } + + /// The line under a lock's name: what it takes to open it. + String? _subtitleText(_LockRow row) { + final entry = row.entry; + if (entry == null) return l10n.locksUnknownEntry; + // `Permalocked` is a sentinel key that matches no item in the game — the + // lock is meant never to open, so say that instead of naming a key the + // player can never hold. + if (entry.keys.length == 1 && entry.keys.first == 'Permalocked') { + return l10n.locksPermalocked; + } + final parts = []; + // A lock with no pickable difficulty at all only ever opens with its key. + if (entry.difficulty == null && entry.keys.isNotEmpty) { + parts.add(l10n.locksKeyOnly); + } + if (entry.keys.isNotEmpty) { + parts.add(l10n.locksKeyLabel(entry.keys.map(_keyName).join(', '))); + } + return parts.isEmpty ? null : parts.join(' · '); + } + + /// The line under a lock's name: the game's own difficulty pips, then what it + /// takes to open it. + Widget? _subtitle(_LockRow row) { + final text = _subtitleText(row); + final entry = row.entry; + final hasMeter = + entry != null && (entry.difficulty != null || entry.lockId != null); + if (!hasMeter) return text == null ? null : Text(text); + return Row( + children: [ + _DifficultyBars( + difficulty: entry.difficulty ?? 0, + theme: theme, + l10n: l10n, + ), + if (text != null) ...[ + const SizedBox(width: 8), + Flexible(child: Text(text, overflow: TextOverflow.ellipsis)), + ], + ], + ); + } + + @override + Widget build(BuildContext context) { + final scheme = theme.colorScheme; + final anyDoorVisible = rows.any((row) => row.isDoor); + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Expanded( + child: TextField( + key: const Key('locks-search'), + decoration: InputDecoration( + isDense: true, + prefixIcon: const Icon(Icons.search), + hintText: l10n.locksSearchHint, + ), + onChanged: onQuery, + ), + ), + if (pending.isNotEmpty) ...[ + const SizedBox(width: 8), + IconButton( + key: const Key('locks-discard-pending'), + icon: const Icon(Icons.undo_outlined), + tooltip: l10n.locksResetPending, + onPressed: onDiscard, + ), + ], + ], + ), + const SizedBox(height: 8), + // One row for both groups; the Wrap breaks to a second line only when + // the pane is too narrow. The wider gap between the groups is what + // keeps their two "All" chips from reading as one broken group. + Wrap( + spacing: 7, + runSpacing: 6, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + for (final value in _KindFilter.values) + ChoiceChip( + key: ValueKey('locks-kind-${value.name}'), + label: Text(_kindLabel(value)), + selected: kind == value, + onSelected: (_) => onKind(value), + ), + const SizedBox(width: 28), + for (final value in _StateFilter.values) + ChoiceChip( + key: ValueKey('locks-state-${value.name}'), + label: Text(_stateLabel(value)), + selected: state == value, + onSelected: (_) => onState(value), + ), + const SizedBox(width: 4), + Text( + l10n.locksShownOfTotal(rows.length, total), + style: theme.textTheme.bodySmall?.copyWith( + color: scheme.onSurfaceVariant, + ), + ), + ], + ), + if (anyDoorVisible) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text( + l10n.locksDoorLeafHint, + style: theme.textTheme.bodySmall?.copyWith( + color: scheme.onSurfaceVariant, + ), + ), + ), + const SizedBox(height: 12), + Expanded( + child: rows.isEmpty + ? Center( + child: Text( + l10n.noEntriesMatch, + style: TextStyle(color: scheme.onSurfaceVariant), + ), + ) + : ListView.separated( + key: const Key('locks-list'), + itemCount: rows.length, + separatorBuilder: (_, _) => const Divider(height: 1), + itemBuilder: (context, index) { + final row = rows[index]; + final draft = pending[row.name]; + final effective = draft?.unlocked ?? row.unlocked; + final subtitle = _subtitle(row); + return ListTile( + key: ValueKey('lock-${row.name}'), + dense: true, + // The game's own marks: the interaction glyph it draws on + // a container and on a door. + leading: GameIcon( + name: row.isDoor + ? 'T_Interaction_Door' + : 'T_Interaction_Loot', + fallbackIcon: row.isDoor + ? Icons.meeting_room_outlined + : Icons.inventory_2_outlined, + color: effective ? scheme.primary : scheme.outline, + ), + title: Text(row.name), + subtitle: draft != null + ? Text(l10n.glossaryPending) + : subtitle, + // The switch alone does not say which way is which, so + // the state is spelled out beside it — the word is what + // the row means, the switch is only how it is changed. + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + effective + ? l10n.locksFilterUnlocked + : l10n.locksFilterLocked, + style: theme.textTheme.bodySmall?.copyWith( + color: effective + ? scheme.primary + : scheme.onSurfaceVariant, + ), + ), + const SizedBox(width: 8), + Switch.adaptive( + value: effective, + onChanged: canEdit + ? (value) => onToggle(row, value) + : null, + ), + ], + ), + onTap: canEdit ? () => onToggle(row, !effective) : null, + ); + }, + ), + ), + ], + ); + } +} + +/// The game's own difficulty display: four pips, filled up to the lock's tier. +/// +/// The lockpicking widget fills pip N when the tier reaches its threshold, and +/// the thresholds are 1, 2, 4, 6 — so a player never sees the raw 1..7 number, +/// only how many of four bars are lit. Showing the raw tier here would invent a +/// scale the game does not have, so it is offered as a tooltip instead. +class _DifficultyBars extends StatelessWidget { + const _DifficultyBars({ + required this.difficulty, + required this.theme, + required this.l10n, + }); + + final int difficulty; + final ThemeData theme; + final AppLocalizations l10n; + + @override + Widget build(BuildContext context) { + final filled = lockDifficultyBars(difficulty); + final scheme = theme.colorScheme; + final message = l10n.locksDifficultyLevel(filled, difficulty); + return Tooltip( + message: message, + child: Semantics( + label: message, + excludeSemantics: true, + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + for (var bar = 1; bar <= lockDifficultyBarCount; bar++) + Padding( + padding: const EdgeInsets.only(right: 2), + child: Container( + width: 4, + // Rising pips, like the game's own signal-strength shape. + height: 5.0 + bar * 2, + decoration: BoxDecoration( + color: bar <= filled + ? scheme.primary + : scheme.outlineVariant, + borderRadius: BorderRadius.circular(1), + ), + ), + ), + ], + ), + ), + ); + } +} + +/// One line of text that ellipsises, and offers the full string as a tooltip +/// ONLY when it actually had to be cut. +/// +/// A tooltip on every label would fire on the many that fit, which is noise; +/// measuring first means the hover only ever appears where it tells the reader +/// something they cannot already see. The region rail is where this bites — +/// "Illegale Sumpfkrautmischer" does not fit a 220px rail in any language. +class _EllipsisTooltip extends StatelessWidget { + const _EllipsisTooltip({required this.text}); + + final String text; + + @override + Widget build(BuildContext context) { + final label = Text(text, maxLines: 1, overflow: TextOverflow.ellipsis); + return LayoutBuilder( + builder: (context, constraints) { + if (!constraints.hasBoundedWidth) return label; + // The enclosing DefaultTextStyle is the one the row actually paints + // with (ListTile installs its own), so measuring against it matches + // what the reader sees. + final effective = DefaultTextStyle.of(context).style; + final painter = TextPainter( + text: TextSpan(text: text, style: effective), + maxLines: 1, + textDirection: Directionality.of(context), + textScaler: MediaQuery.textScalerOf(context), + )..layout(maxWidth: constraints.maxWidth); + final truncated = painter.didExceedMaxLines; + painter.dispose(); + return truncated ? Tooltip(message: text, child: label) : label; + }, + ); + } +} diff --git a/apps/save-editor/lib/features/editor/ui/story_state_panel.dart b/apps/save-editor/lib/features/editor/ui/story_state_panel.dart index 545e9a23e..8be051503 100644 --- a/apps/save-editor/lib/features/editor/ui/story_state_panel.dart +++ b/apps/save-editor/lib/features/editor/ui/story_state_panel.dart @@ -15,7 +15,9 @@ import '../domain/story_state_models.dart'; import '../domain/story_state_presentation.dart'; import '../domain/story_state_semantics.dart'; -enum _StoryFilter { all, integer, timeMarker, chapter, unknown } +/// `Chapter` is serialized as a plain `int` and is a single entry, so it is +/// filtered as an integer; only its badge and dedicated editor set it apart. +enum _StoryFilter { all, integer, timeMarker, unknown } enum _StoryPresence { stored, unset, all } @@ -65,6 +67,7 @@ class _StoryStateDetailState extends ConsumerState { _StoryPresence _presence = _StoryPresence.stored; String _query = ''; bool _showInfo = false; + bool _showDormant = false; bool _loading = false; int _loadEpoch = 0; @@ -89,6 +92,7 @@ class _StoryStateDetailState extends ConsumerState { _filter = _StoryFilter.all; _presence = _StoryPresence.stored; _showInfo = false; + _showDormant = false; _page = const StoryStatePage(); } _load(); @@ -186,24 +190,72 @@ class _StoryStateDetailState extends ConsumerState { } } + /// Whether this save carries an ID the bundled catalog does not declare. + /// + /// Vanilla saves never do, so the unknown filter would otherwise be a chip + /// that is permanently zero; a modded or newer-version save brings it back. + bool get _hasUnknown => + _page.unknownStoredTotal > 0 || + _page.values.any( + (value) => value.semanticType == StorySemanticType.unknown, + ); + + _StoryFilter get _activeFilter => + _filter == _StoryFilter.unknown && !_hasUnknown + ? _StoryFilter.all + : _filter; + + /// A field the shipped scripts neither read nor write. 149 of the 419 + /// declared integers are leftovers of the Gothic 1 symbol-table port, so the + /// catalog hides them until they are asked for. + bool _isDormant(StoryStateValue value) => + storyIntegerSemantics(value.id)?.kind == + StoryIntegerKind.dormantOrLegacyInteger; + + /// Whether the unused-field toggle governs this row at all. A dormant field + /// this save actually stores, or one with a queued edit, stays reachable. + bool _dormantGoverned(StoryStateValue value, Set pendingIds) => + _isDormant(value) && + !value.stored && + !pendingIds.contains(normalizeStoryStateId(value.id)); + + bool _suppressedAsDormant(StoryStateValue value, Set pendingIds) => + !_showDormant && _dormantGoverned(value, pendingIds); + + /// The rows the presence chips describe, which is also what the type chips + /// count. A running search deliberately does not change these counts. + List _presenceValues(Set pendingIds) => _page.values + .where( + (value) => + _matchesPresence(value, _presence) && + !_suppressedAsDormant(value, pendingIds), + ) + .toList(growable: false); + List _visibleValues( Map> locCatalog, GameLang lang, + Set pendingIds, ) { + final filter = _activeFilter; return _page.values .where((value) { final presenceMatches = _matchesPresence(value, _presence); - final typeMatches = switch (_filter) { + final typeMatches = switch (filter) { _StoryFilter.all => true, + // The chapter rides along with the integers it is stored as. _StoryFilter.integer => - value.semanticType == StorySemanticType.integer, + value.semanticType == StorySemanticType.integer || + value.semanticType == StorySemanticType.chapter, _StoryFilter.timeMarker => value.semanticType == StorySemanticType.timeMarker, - _StoryFilter.chapter => - value.semanticType == StorySemanticType.chapter, _StoryFilter.unknown => value.semanticType == StorySemanticType.unknown, }; + // A search reaches the hidden fields; an empty one does not. + if (_query.isEmpty && _suppressedAsDormant(value, pendingIds)) { + return false; + } if (!presenceMatches || !typeMatches || _query.isEmpty) { return presenceMatches && typeMatches; } @@ -233,11 +285,12 @@ class _StoryStateDetailState extends ConsumerState { final locCatalog = ref.watch(locCatalogProvider).value ?? const {}; final lang = ref.watch(currentGameLangProvider); final showObjectIds = ref.watch(showObjectIdsProvider); - final values = _visibleValues(locCatalog, lang); final pendingById = { for (final edit in widget.notifier.allStoryStateEdits()) edit.normalizedId: edit, }; + final pendingIds = pendingById.keys.toSet(); + final values = _visibleValues(locCatalog, lang, pendingIds); // A same-save refresh intentionally keeps the old rows visible while the // new inspection loads. Do not let those rows create a draft with the old // compare-and-swap snapshot; editing resumes only after the fresh page @@ -246,9 +299,14 @@ class _StoryStateDetailState extends ConsumerState { final showCodecReadOnly = !widget.editable && _page.error == null; final showStructureReadOnly = widget.editable && !_loading && !_page.writable && _page.error == null; - final presenceValues = _page.values - .where((value) => _matchesPresence(value, _presence)) - .toList(growable: false); + final presenceValues = _presenceValues(pendingIds); + final dormantCount = _page.values + .where( + (value) => + _matchesPresence(value, _presence) && + _dormantGoverned(value, pendingIds), + ) + .length; final scheme = widget.theme.colorScheme; return Card( child: Padding( @@ -280,17 +338,10 @@ class _StoryStateDetailState extends ConsumerState { ), if (effectiveEditable) ...[ const SizedBox(height: 4), - Tooltip( - message: l10n.storyStateEditingGuidance, - child: Text( - l10n.storyStateEditingGuidance, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: widget.theme.textTheme.bodySmall - ?.copyWith( - color: scheme.onSurfaceVariant, - ), - ), + Text( + l10n.storyStateEditingGuidance, + style: widget.theme.textTheme.bodySmall + ?.copyWith(color: scheme.onSurfaceVariant), ), ], ], @@ -377,6 +428,7 @@ class _StoryStateDetailState extends ConsumerState { Wrap( spacing: 7, runSpacing: 6, + crossAxisAlignment: WrapCrossAlignment.center, children: [ for (final presence in _StoryPresence.values) ChoiceChip( @@ -384,20 +436,31 @@ class _StoryStateDetailState extends ConsumerState { selected: _presence == presence, onSelected: (_) => setState(() => _presence = presence), ), - ], - ), - const SizedBox(height: 6), - Wrap( - spacing: 7, - runSpacing: 6, - crossAxisAlignment: WrapCrossAlignment.center, - children: [ + const SizedBox(width: 28), for (final filter in _StoryFilter.values) - ChoiceChip( - label: Text(_filterLabel(l10n, filter, presenceValues)), - selected: _filter == filter, - onSelected: (_) => setState(() => _filter = filter), + if (filter != _StoryFilter.all && + (filter != _StoryFilter.unknown || _hasUnknown)) + ChoiceChip( + label: Text(_filterLabel(l10n, filter, presenceValues)), + selected: _activeFilter == filter, + onSelected: (_) => setState(() => _filter = filter), + ), + if (dormantCount > 0) + FilterChip( + key: const Key('story-state-dormant'), + avatar: const Icon(Icons.visibility_off_outlined, size: 18), + label: Text(l10n.storyStateShowDormant(dormantCount)), + selected: _showDormant, + onSelected: (selected) => + setState(() => _showDormant = selected), + ), + ChoiceChip( + label: Text( + _filterLabel(l10n, _StoryFilter.all, presenceValues), ), + selected: _activeFilter == _StoryFilter.all, + onSelected: (_) => setState(() => _filter = _StoryFilter.all), + ), const SizedBox(width: 4), Text( l10n.storyStateValuesCount(values.length, _page.total), @@ -1085,7 +1148,11 @@ String _filterLabel( _StoryFilter.integer => ( l10n.storyStateInteger, values - .where((value) => value.semanticType == StorySemanticType.integer) + .where( + (value) => + value.semanticType == StorySemanticType.integer || + value.semanticType == StorySemanticType.chapter, + ) .length, ), _StoryFilter.timeMarker => ( @@ -1094,12 +1161,6 @@ String _filterLabel( .where((value) => value.semanticType == StorySemanticType.timeMarker) .length, ), - _StoryFilter.chapter => ( - l10n.storyStateChapter, - values - .where((value) => value.semanticType == StorySemanticType.chapter) - .length, - ), _StoryFilter.unknown => ( l10n.storyStateUnknown, values diff --git a/apps/save-editor/lib/features/editor/ui/world_tab.dart b/apps/save-editor/lib/features/editor/ui/world_tab.dart index ac0863e48..c33e5fccd 100644 --- a/apps/save-editor/lib/features/editor/ui/world_tab.dart +++ b/apps/save-editor/lib/features/editor/ui/world_tab.dart @@ -5,6 +5,7 @@ import 'package:goresave/l10n/app_localizations.dart'; import '../domain/editor_models.dart'; import '../domain/editor_notifier.dart'; import 'glossary_panel.dart'; +import 'locks_panel.dart'; import 'progression_panel.dart' show QuestsDetail, FactionsDetail; import 'story_state_panel.dart'; @@ -12,7 +13,7 @@ import 'story_state_panel.dart'; /// deliberately absent: they moved to detail-only panels (KnowledgeDetail / /// EventsDetail) keyed by a shared character selection and are mounted from /// the Characters tab, not from this sidebar. -enum _WorldSection { quests, glossary, factions, storyState } +enum _WorldSection { quests, glossary, factions, locks, storyState } /// World tab: structured quests, glossary, source-aware story state, and /// faction crime records. @@ -47,6 +48,9 @@ class _WorldTabState extends State { // Story state parses a large private map and joins optional glossary // context. Like the glossary, do that work only after its first selection. bool _storyStateMounted = false; + // The locks section reads the save's unlocked set and loads two bundled + // catalogs to name and group the rest. Defer that until it is opened. + bool _locksMounted = false; @override Widget build(BuildContext context) { @@ -117,6 +121,18 @@ class _WorldTabState extends State { onTap: () => setState(() => _selected = _WorldSection.factions), ), + _SidebarTile( + icon: Icons.lock_open_outlined, + // The game's own padlock, the same glyph it draws on a + // locked chest or door. + gameIcon: 'T_Icon_Lock', + label: l10n.locksSidebar, + selected: _selected == _WorldSection.locks, + onTap: () => setState(() { + _locksMounted = true; + _selected = _WorldSection.locks; + }), + ), _SidebarTile( icon: Icons.account_tree_outlined, gameIcon: 'T_Icon_Commpleted', @@ -175,6 +191,17 @@ class _WorldTabState extends State { theme: theme, ), ), + if (_locksMounted) + Offstage( + offstage: _selected != _WorldSection.locks, + child: LocksDetail( + key: const ValueKey('locks'), + notifier: widget.notifier, + editable: widget.editable, + reloadKey: reloadKey, + theme: theme, + ), + ), if (_storyStateMounted) Offstage( offstage: _selected != _WorldSection.storyState, diff --git a/apps/save-editor/lib/l10n/app_de.arb b/apps/save-editor/lib/l10n/app_de.arb index 2b9e56515..9a1fe24ae 100644 --- a/apps/save-editor/lib/l10n/app_de.arb +++ b/apps/save-editor/lib/l10n/app_de.arb @@ -5,7 +5,7 @@ "showObjectIdsSubtitle": "Technische IDs von Gegenständen, Dialogwissen, Quests und verwaisten Akteuren im Editor anzeigen. NPC-IDs werden immer angezeigt.", "@@locale": "de", "storyStateSidebar": "Story-Zustände", - "storyStateDescription": "Autoritativer Katalog der in den ausgelieferten Spielscripts deklarierten, persistenten Story-Zustände. Gespeicherte Einträge zeigen ihren Rohwert; im Save fehlende Katalogfelder sind als nicht gesetzt markiert. Im Source deklarierte Zeitpunkte werden als Spielzeit formatiert. Andere Ganzzahlen können Ja/Nein-Werte, Zähler oder mehrstufige Zustände sein.", + "storyStateDescription": "Hier merkt sich das Spiel Fortschritte bei Quests, Dialogen und Ereignissen. „Gespeichert“ zeigt die Werte deines Spielstands, „Nicht gesetzt“ die übrigen bekannten Einträge. Zahlen können je nach Eintrag Ja/Nein, einen Zähler oder einen Fortschrittsschritt bedeuten. Zeitpunkte zeigen einen Tag und eine Uhrzeit im Spiel.", "storyStateReadOnly": "Schreibgeschützt, bis die Skriptbedeutung der Werte und sichere Map-Schreibvorgänge belegt sind. Zugeordneter Glossartext ist Kontext, keine direkte Übersetzung der technischen ID.", "storyStateStructureReadOnly": "Die StoryPropertyValues-Struktur dieses Spielstands konnte nicht eindeutig und sicher aufgelöst werden. Story-Werte bleiben für diesen Spielstand schreibgeschützt.", "storyStateSearch": "Story-Zustände durchsuchen", @@ -14,6 +14,7 @@ "storyStateTimeMarker": "Zeitpunkt", "storyStateChapter": "Kapitel", "storyStateUnknown": "Unbekannter Source-Typ", + "storyStateShowDormant": "Ungenutzte anzeigen ({count})", "storyStateUnknownDetail": "Diese gespeicherte ID fehlt im aktuellen Script-Katalog (z. B. durch eine Mod oder neuere Spielversion). Der Save-Rohwert ist zwar int32, seine Bedeutung wird aber nicht geraten.", "storyStateStored": "Gespeichert", "storyStateUnset": "Nicht gesetzt", @@ -24,7 +25,7 @@ "storyStateDurationDays": "{days, plural, =1{1 Tag} other{{days} Tage}} {time}", "storyStateRelatedGlossary": "Zugehöriger Glossareintrag", "storyStateTechnicalPath": "Technischer Pfad", - "storyStateEditingGuidance": "Jeder Eintrag bleibt über den gesamten vorzeichenbehafteten int32-Bereich editierbar. Script-belegte Schalter und Wertvorschläge sind Hilfen; die Rohwerteingabe bleibt immer verfügbar. Story-Änderungen können Dialog-, Quest- oder Weltübergänge überspringen – beim Speichern wird automatisch ein Backup angelegt.", + "storyStateEditingGuidance": "Wähle einen Eintrag, um seinen Wert zu ändern. Die Änderung wird erst mit „Speichern“ übernommen. Verändere nur Werte, deren Wirkung du kennst: Quests oder Dialoge können sonst nicht mehr wie erwartet ablaufen. Beim Speichern wird automatisch ein Backup angelegt.", "storyStatePending": "Ausstehend", "storyStatePendingValue": "Wird als {value} gespeichert", "@storyStatePendingValue": {"placeholders": {"value": {"type": "String"}}}, @@ -814,5 +815,22 @@ "statisticsUnknown": "Nicht verfügbar", "statisticsMore": "Weitere Statistiken", "statisticsSummary": "Level {level}, {guild}, Kapitel {chapter}. {completed} Quests abgeschlossen, {failed} fehlgeschlagen. Spielzeit: {playTime}.", - "@statisticsSummary": {"placeholders": {"level": {"type": "String"}, "guild": {"type": "String"}, "chapter": {"type": "String"}, "completed": {"type": "int"}, "failed": {"type": "int"}, "playTime": {"type": "String"}}} + "@statisticsSummary": {"placeholders": {"level": {"type": "String"}, "guild": {"type": "String"}, "chapter": {"type": "String"}, "completed": {"type": "int"}, "failed": {"type": "int"}, "playTime": {"type": "String"}}}, + "locksSidebar": "Schlösser", + "editorLockListFailed": "Die Schlossliste konnte nicht geladen werden: {details}", + "locksSearchHint": "Schloss oder Schlüssel suchen", + "locksAllRegions": "Alle Regionen", + "locksShownOfTotal": "{shown} von {total}", + "locksFilterChests": "Truhen", + "locksFilterDoors": "Türen", + "locksFilterUnlocked": "Aufgeschlossen", + "locksFilterLocked": "Verschlossen", + "locksDifficultyLevel": "Schwierigkeit {bars} von 4 (intern Stufe {level} von 7)", + "locksKeyOnly": "Nur mit Schlüssel", + "locksKeyLabel": "Schlüssel: {keys}", + "locksPermalocked": "Dauerhaft verriegelt", + "locksReadOnly": "Dieser Spielstand hat keine bearbeitbare Schlossliste.", + "locksUnknownEntry": "Nicht in dieser Spielversion", + "locksDoorLeafHint": "Eine Tür wieder abzuschließen schließt auch den Türflügel.", + "locksResetPending": "Ausstehende Schlossänderungen verwerfen" } diff --git a/apps/save-editor/lib/l10n/app_en.arb b/apps/save-editor/lib/l10n/app_en.arb index b876d3c74..0c5fa7534 100644 --- a/apps/save-editor/lib/l10n/app_en.arb +++ b/apps/save-editor/lib/l10n/app_en.arb @@ -5,7 +5,7 @@ "showObjectIdsSubtitle": "Show technical item, dialogue knowledge, quest, and orphan actor IDs in the editor. NPC IDs are always shown.", "@@locale": "en", "storyStateSidebar": "Story state", - "storyStateDescription": "Authoritative catalog of persisted story state declared by the shipped game scripts. Stored entries show their raw value; catalog fields missing from this save are marked as not set. Source-declared time markers are formatted as game time, while other integers may be booleans, counters, or multi-state values.", + "storyStateDescription": "The game tracks progress in quests, dialogues and events here. “Stored” shows the values in your save; “Not set” shows the other known entries. Depending on the entry, a number can mean yes/no, a count or a stage of progress. Time markers show a day and time in the game.", "storyStateReadOnly": "Read-only until the script meaning of values and safe map writes are established. Related glossary text is context, not a direct translation of the technical ID.", "storyStateStructureReadOnly": "The StoryPropertyValues structure in this save could not be resolved uniquely and safely. Story values remain read-only for this save.", "storyStateSearch": "Search story state", @@ -15,6 +15,8 @@ "storyStateTimeMarker": "Time marker", "storyStateChapter": "Chapter", "storyStateUnknown": "Unknown source type", + "storyStateShowDormant": "Show unused ({count})", + "@storyStateShowDormant": {"placeholders": {"count": {"type": "int"}}}, "storyStateUnknownDetail": "This stored ID is absent from the current script catalog (for example, from a mod or newer game version). Its save wire value is int32, but its meaning is not inferred.", "storyStateStored": "Stored", "storyStateUnset": "Not set", @@ -28,7 +30,7 @@ "@storyStateDurationDays": {"placeholders": {"days": {"type": "int"}, "time": {"type": "String"}}}, "storyStateRelatedGlossary": "Related glossary entry", "storyStateTechnicalPath": "Technical path", - "storyStateEditingGuidance": "Every entry remains editable across the full signed int32 range. Script-backed switches and value suggestions are guidance; raw input is always available. Story changes can skip dialogue, quest, or world transitions, so save them deliberately — a backup is created automatically.", + "storyStateEditingGuidance": "Select an entry to change its value. Changes take effect when you save. Only change values whose effects you understand: quests or dialogues may otherwise stop working as expected. A backup is created automatically when you save.", "storyStatePending": "Pending", "storyStatePendingValue": "Will be stored as {value}", "@storyStatePendingValue": {"placeholders": {"value": {"type": "String"}}}, @@ -1360,5 +1362,26 @@ "statisticsUnknown": "Not available", "statisticsMore": "More statistics", "statisticsSummary": "Level {level}, {guild}, chapter {chapter}. {completed} quests completed, {failed} failed. Play time: {playTime}.", - "@statisticsSummary": {"placeholders": {"level": {"type": "String"}, "guild": {"type": "String"}, "chapter": {"type": "String"}, "completed": {"type": "int"}, "failed": {"type": "int"}, "playTime": {"type": "String"}}} + "@statisticsSummary": {"placeholders": {"level": {"type": "String"}, "guild": {"type": "String"}, "chapter": {"type": "String"}, "completed": {"type": "int"}, "failed": {"type": "int"}, "playTime": {"type": "String"}}}, + "locksSidebar": "Locks", + "editorLockListFailed": "Lock list failed: {details}", + "@editorLockListFailed": {"placeholders": {"details": {"type": "String"}}}, + "locksSearchHint": "Search lock or key", + "locksAllRegions": "All regions", + "locksShownOfTotal": "{shown} of {total}", + "@locksShownOfTotal": {"placeholders": {"shown": {"type": "int"}, "total": {"type": "int"}}}, + "locksFilterChests": "Chests", + "locksFilterDoors": "Doors", + "locksFilterUnlocked": "Unlocked", + "locksFilterLocked": "Locked", + "locksDifficultyLevel": "Difficulty {bars} of 4 (internal tier {level} of 7)", + "@locksDifficultyLevel": {"placeholders": {"bars": {"type": "int"}, "level": {"type": "int"}}}, + "locksKeyOnly": "Key only", + "locksKeyLabel": "Key: {keys}", + "@locksKeyLabel": {"placeholders": {"keys": {"type": "String"}}}, + "locksPermalocked": "Sealed permanently", + "locksReadOnly": "This save has no editable lock set.", + "locksUnknownEntry": "Not in this game version", + "locksDoorLeafHint": "Locking a door again also shuts the door itself.", + "locksResetPending": "Discard queued lock changes" } diff --git a/apps/save-editor/lib/l10n/app_es.arb b/apps/save-editor/lib/l10n/app_es.arb index 0b9b558a5..3aeb75e39 100644 --- a/apps/save-editor/lib/l10n/app_es.arb +++ b/apps/save-editor/lib/l10n/app_es.arb @@ -3,7 +3,7 @@ "debugSectionSubtitle": "Diagnóstico y datos sin procesar para informes de errores", "@@locale": "es", "storyStateSidebar": "Estado de la historia", - "storyStateDescription": "Catálogo autoritativo de estados persistentes declarados por los scripts distribuidos con el juego. Las entradas guardadas muestran su valor bruto; los campos del catálogo ausentes de esta partida se marcan como no establecidos. Las marcas de tiempo declaradas en el código se muestran como tiempo de juego; los demás enteros pueden ser booleanos, contadores o estados de varios niveles.", + "storyStateDescription": "Aquí el juego registra el progreso de misiones, diálogos y eventos. «Guardado» muestra los valores de tu partida; «No establecido», las demás entradas conocidas. Según la entrada, un número puede indicar sí/no, una cantidad o una etapa de progreso. Los marcadores de tiempo muestran un día y una hora del juego.", "storyStateReadOnly": "Solo lectura hasta conocer el significado de los valores en los scripts y disponer de escrituras seguras del mapa. El texto de glosario relacionado aporta contexto; no es una traducción directa del ID técnico.", "storyStateStructureReadOnly": "No se pudo identificar de forma inequívoca y segura la estructura StoryPropertyValues de esta partida guardada. Los valores de historia seguirán siendo de solo lectura para esta partida.", "storyStateSearch": "Buscar estados de historia", @@ -12,6 +12,7 @@ "storyStateTimeMarker": "Marca de tiempo", "storyStateChapter": "Capítulo", "storyStateUnknown": "Tipo de origen desconocido", + "storyStateShowDormant": "Mostrar sin usar ({count})", "storyStateUnknownDetail": "Este ID guardado no figura en el catálogo de scripts actual (por ejemplo, por un mod o una versión más reciente del juego). El valor almacenado es int32, pero no se infiere su significado.", "storyStateStored": "Guardado", "storyStateUnset": "No establecido", @@ -22,7 +23,7 @@ "storyStateDurationDays": "{days, plural, =1{1 día} other{{days} días}} {time}", "storyStateRelatedGlossary": "Entrada de glosario relacionada", "storyStateTechnicalPath": "Ruta técnica", - "storyStateEditingGuidance": "Todas las entradas se pueden editar en todo el intervalo de int32 con signo. Los indicadores y las sugerencias de valores respaldados por los scripts son solo orientativos; la entrada sin procesar siempre está disponible. Los cambios en el estado de la historia pueden omitir transiciones de diálogos, misiones o del mundo, así que guárdalos con cuidado; se crea una copia de seguridad automáticamente.", + "storyStateEditingGuidance": "Selecciona una entrada para cambiar su valor. Los cambios se aplican al guardar. Cambia solo valores cuyo efecto conozcas: de lo contrario, las misiones o los diálogos podrían dejar de funcionar como esperas. Al guardar se crea una copia de seguridad automáticamente.", "storyStatePending": "Pendiente", "storyStatePendingValue": "Se guardará como {value}", "@storyStatePendingValue": {"placeholders": {"value": {"type": "String"}}}, @@ -814,5 +815,22 @@ "statisticsUnknown": "No disponible", "statisticsMore": "Más estadísticas", "statisticsSummary": "Nivel {level}, {guild}, capítulo {chapter}. {completed} misiones completadas, {failed} fallidas. Tiempo de juego: {playTime}.", - "@statisticsSummary": {"placeholders": {"level": {"type": "String"}, "guild": {"type": "String"}, "chapter": {"type": "String"}, "completed": {"type": "int"}, "failed": {"type": "int"}, "playTime": {"type": "String"}}} + "@statisticsSummary": {"placeholders": {"level": {"type": "String"}, "guild": {"type": "String"}, "chapter": {"type": "String"}, "completed": {"type": "int"}, "failed": {"type": "int"}, "playTime": {"type": "String"}}}, + "locksSidebar": "Cerraduras", + "editorLockListFailed": "No se pudo cargar la lista de cerraduras: {details}", + "locksSearchHint": "Buscar cerradura o llave", + "locksAllRegions": "Todas las regiones", + "locksShownOfTotal": "{shown} de {total}", + "locksFilterChests": "Cofres", + "locksFilterDoors": "Puertas", + "locksFilterUnlocked": "Abierta", + "locksFilterLocked": "Cerrada", + "locksDifficultyLevel": "Dificultad {bars} de 4 (nivel interno {level} de 7)", + "locksKeyOnly": "Solo con llave", + "locksKeyLabel": "Llave: {keys}", + "locksPermalocked": "Sellada permanentemente", + "locksReadOnly": "Esta partida no tiene una lista de cerraduras editable.", + "locksUnknownEntry": "No existe en esta versión del juego", + "locksDoorLeafHint": "Volver a cerrar una puerta también cierra la puerta en sí.", + "locksResetPending": "Descartar los cambios de cerraduras pendientes" } diff --git a/apps/save-editor/lib/l10n/app_fr.arb b/apps/save-editor/lib/l10n/app_fr.arb index 00ea3850e..d00e4ef34 100644 --- a/apps/save-editor/lib/l10n/app_fr.arb +++ b/apps/save-editor/lib/l10n/app_fr.arb @@ -3,7 +3,7 @@ "debugSectionSubtitle": "Diagnostic et données brutes pour les rapports de bogue", "@@locale": "fr", "storyStateSidebar": "État de l’histoire", - "storyStateDescription": "Catalogue de référence des états persistants déclarés par les scripts livrés avec le jeu. Les entrées enregistrées affichent leur valeur brute ; les champs du catalogue absents de cette sauvegarde sont marqués comme non définis. Les repères temporels déclarés dans le code sont affichés en temps de jeu ; les autres entiers peuvent être des booléens, compteurs ou états à plusieurs niveaux.", + "storyStateDescription": "Le jeu mémorise ici la progression des quêtes, des dialogues et des événements. « Enregistré » affiche les valeurs de ta sauvegarde ; « Non défini », les autres entrées connues. Selon l’entrée, un nombre peut indiquer oui/non, un compteur ou une étape de progression. Les repères temporels indiquent un jour et une heure dans le jeu.", "storyStateReadOnly": "Lecture seule tant que la signification des valeurs dans les scripts et l’écriture sûre de la map ne sont pas établies. Le texte de glossaire associé donne du contexte ; ce n’est pas une traduction directe de l’ID technique.", "storyStateStructureReadOnly": "La structure StoryPropertyValues de cette sauvegarde n’a pas pu être identifiée de manière univoque et sûre. Les valeurs d’histoire restent en lecture seule pour cette sauvegarde.", "storyStateSearch": "Rechercher dans l’état de l’histoire", @@ -12,6 +12,7 @@ "storyStateTimeMarker": "Repère temporel", "storyStateChapter": "Chapitre", "storyStateUnknown": "Type source inconnu", + "storyStateShowDormant": "Afficher les inutilisés ({count})", "storyStateUnknownDetail": "Cet ID enregistré est absent du catalogue de scripts actuel (par exemple à cause d’un mod ou d’une version plus récente du jeu). Sa valeur sérialisée est un int32, mais sa signification n’est pas déduite.", "storyStateStored": "Enregistré", "storyStateUnset": "Non défini", @@ -22,7 +23,7 @@ "storyStateDurationDays": "{days, plural, =1{1 jour} other{{days} jours}} {time}", "storyStateRelatedGlossary": "Entrée de glossaire associée", "storyStateTechnicalPath": "Chemin technique", - "storyStateEditingGuidance": "Chaque entrée reste modifiable sur toute la plage int32 signée. Les indicateurs et suggestions de valeurs issus des scripts sont fournis à titre indicatif ; la saisie brute reste toujours disponible. Les changements d’état de l’histoire peuvent court-circuiter des transitions de dialogue, de quête ou du monde : enregistrez-les avec précaution. Une sauvegarde de sécurité est créée automatiquement.", + "storyStateEditingGuidance": "Sélectionne une entrée pour modifier sa valeur. Les changements sont appliqués à l’enregistrement. Ne modifie que les valeurs dont tu connais les effets : les quêtes ou les dialogues pourraient sinon ne plus fonctionner comme prévu. Une copie de sauvegarde est créée automatiquement à l’enregistrement.", "storyStatePending": "En attente", "storyStatePendingValue": "Sera enregistré sous la valeur {value}", "@storyStatePendingValue": {"placeholders": {"value": {"type": "String"}}}, @@ -825,5 +826,22 @@ "statisticsUnknown": "Indisponible", "statisticsMore": "Plus de statistiques", "statisticsSummary": "Niveau {level}, {guild}, chapitre {chapter}. {completed} quêtes terminées, {failed} échouées. Temps de jeu : {playTime}.", - "@statisticsSummary": {"placeholders": {"level": {"type": "String"}, "guild": {"type": "String"}, "chapter": {"type": "String"}, "completed": {"type": "int"}, "failed": {"type": "int"}, "playTime": {"type": "String"}}} + "@statisticsSummary": {"placeholders": {"level": {"type": "String"}, "guild": {"type": "String"}, "chapter": {"type": "String"}, "completed": {"type": "int"}, "failed": {"type": "int"}, "playTime": {"type": "String"}}}, + "locksSidebar": "Serrures", + "editorLockListFailed": "Échec du chargement de la liste des serrures : {details}", + "locksSearchHint": "Rechercher une serrure ou une clé", + "locksAllRegions": "Toutes les régions", + "locksShownOfTotal": "{shown} sur {total}", + "locksFilterChests": "Coffres", + "locksFilterDoors": "Portes", + "locksFilterUnlocked": "Déverrouillée", + "locksFilterLocked": "Verrouillée", + "locksDifficultyLevel": "Difficulté {bars} sur 4 (palier interne {level} sur 7)", + "locksKeyOnly": "Clé uniquement", + "locksKeyLabel": "Clé : {keys}", + "locksPermalocked": "Scellée définitivement", + "locksReadOnly": "Cette sauvegarde ne contient pas de liste de serrures modifiable.", + "locksUnknownEntry": "Absente de cette version du jeu", + "locksDoorLeafHint": "Reverrouiller une porte referme aussi le battant.", + "locksResetPending": "Abandonner les modifications de serrures en attente" } diff --git a/apps/save-editor/lib/l10n/app_it.arb b/apps/save-editor/lib/l10n/app_it.arb index 090aef683..bf9313259 100644 --- a/apps/save-editor/lib/l10n/app_it.arb +++ b/apps/save-editor/lib/l10n/app_it.arb @@ -3,7 +3,7 @@ "debugSectionSubtitle": "Diagnostica e dati grezzi per le segnalazioni di bug", "@@locale": "it", "storyStateSidebar": "Stato della storia", - "storyStateDescription": "Catalogo autorevole degli stati persistenti dichiarati dagli script distribuiti con il gioco. Le voci salvate mostrano il valore grezzo; i campi del catalogo assenti dal salvataggio sono indicati come non impostati. I marcatori temporali dichiarati nel codice sono formattati come tempo di gioco; gli altri interi possono essere booleani, contatori o stati a più livelli.", + "storyStateDescription": "Qui il gioco registra i progressi di missioni, dialoghi ed eventi. «Salvato» mostra i valori del salvataggio; «Non impostato», le altre voci note. A seconda della voce, un numero può indicare sì/no, un conteggio o una fase di avanzamento. I riferimenti temporali mostrano un giorno e un’ora nel gioco.", "storyStateReadOnly": "Sola lettura finché non sono noti il significato dei valori negli script e una scrittura sicura della mappa. Il testo del glossario collegato offre contesto, non è una traduzione diretta dell’ID tecnico.", "storyStateStructureReadOnly": "Non è stato possibile individuare in modo univoco e sicuro la struttura StoryPropertyValues di questo salvataggio. I valori della storia rimangono di sola lettura per questo salvataggio.", "storyStateSearch": "Cerca nello stato della storia", @@ -12,6 +12,7 @@ "storyStateTimeMarker": "Marcatore temporale", "storyStateChapter": "Capitolo", "storyStateUnknown": "Tipo sorgente sconosciuto", + "storyStateShowDormant": "Mostra inutilizzati ({count})", "storyStateUnknownDetail": "Questo ID salvato non è presente nel catalogo degli script attuale (ad esempio per una mod o una versione più recente del gioco). Il valore serializzato è int32, ma il significato non viene dedotto.", "storyStateStored": "Salvato", "storyStateUnset": "Non impostato", @@ -22,7 +23,7 @@ "storyStateDurationDays": "{days, plural, =1{1 giorno} other{{days} giorni}} {time}", "storyStateRelatedGlossary": "Voce del glossario collegata", "storyStateTechnicalPath": "Percorso tecnico", - "storyStateEditingGuidance": "Ogni voce resta modificabile nell’intero intervallo int32 con segno. Gli indicatori e i suggerimenti di valore ricavati dagli script sono solo indicativi; l’inserimento del valore grezzo è sempre disponibile. Le modifiche allo stato della storia possono saltare transizioni di dialoghi, missioni o del mondo, quindi salvale con cautela; viene creata automaticamente una copia di sicurezza.", + "storyStateEditingGuidance": "Seleziona una voce per modificarne il valore. Le modifiche vengono applicate quando salvi. Modifica solo valori di cui conosci gli effetti: altrimenti missioni o dialoghi potrebbero non funzionare più come previsto. Quando salvi, viene creata automaticamente una copia di sicurezza.", "storyStatePending": "In sospeso", "storyStatePendingValue": "Verrà salvato come {value}", "@storyStatePendingValue": {"placeholders": {"value": {"type": "String"}}}, @@ -814,5 +815,22 @@ "statisticsUnknown": "Non disponibile", "statisticsMore": "Altre statistiche", "statisticsSummary": "Livello {level}, {guild}, capitolo {chapter}. {completed} missioni completate, {failed} fallite. Tempo di gioco: {playTime}.", - "@statisticsSummary": {"placeholders": {"level": {"type": "String"}, "guild": {"type": "String"}, "chapter": {"type": "String"}, "completed": {"type": "int"}, "failed": {"type": "int"}, "playTime": {"type": "String"}}} + "@statisticsSummary": {"placeholders": {"level": {"type": "String"}, "guild": {"type": "String"}, "chapter": {"type": "String"}, "completed": {"type": "int"}, "failed": {"type": "int"}, "playTime": {"type": "String"}}}, + "locksSidebar": "Serrature", + "editorLockListFailed": "Impossibile caricare l'elenco delle serrature: {details}", + "locksSearchHint": "Cerca serratura o chiave", + "locksAllRegions": "Tutte le regioni", + "locksShownOfTotal": "{shown} di {total}", + "locksFilterChests": "Casse", + "locksFilterDoors": "Porte", + "locksFilterUnlocked": "Aperta", + "locksFilterLocked": "Chiusa", + "locksDifficultyLevel": "Difficoltà {bars} su 4 (livello interno {level} su 7)", + "locksKeyOnly": "Solo con chiave", + "locksKeyLabel": "Chiave: {keys}", + "locksPermalocked": "Sigillata per sempre", + "locksReadOnly": "Questo salvataggio non ha un elenco di serrature modificabile.", + "locksUnknownEntry": "Non presente in questa versione del gioco", + "locksDoorLeafHint": "Richiudere una porta chiude anche il battente.", + "locksResetPending": "Scarta le modifiche alle serrature in sospeso" } diff --git a/apps/save-editor/lib/l10n/app_ja.arb b/apps/save-editor/lib/l10n/app_ja.arb index 22335900e..cb224d27f 100644 --- a/apps/save-editor/lib/l10n/app_ja.arb +++ b/apps/save-editor/lib/l10n/app_ja.arb @@ -3,7 +3,7 @@ "debugSectionSubtitle": "バグ報告用の診断と生データ", "@@locale": "ja", "storyStateSidebar": "ストーリー状態", - "storyStateDescription": "製品版ゲームスクリプトで宣言された永続ストーリー状態の正式なカタログです。保存済み項目には生の値を表示し、このセーブにないカタログ項目は未設定として示します。ソースで宣言された時刻はゲーム内時刻として表示します。その他の整数は真偽値、カウンター、多段階状態などの場合があります。", + "storyStateDescription": "ここにはクエスト、会話、イベントの進行状況が記録されています。「保存済み」はセーブデータ内の値、「未設定」はそれ以外の既知の項目を表示します。数値は項目によって、はい/いいえ、回数、進行段階などを表します。時刻の項目はゲーム内の日付と時刻を表します。", "storyStateReadOnly": "スクリプト上の意味と安全なマップ書き込みが確認できるまでは読み取り専用です。関連する用語集テキストは文脈であり、技術 ID の直接翻訳ではありません。", "storyStateStructureReadOnly": "このセーブデータの StoryPropertyValues 構造を一意かつ安全に特定できませんでした。このセーブデータではストーリー値は読み取り専用のままです。", "storyStateSearch": "ストーリー状態を検索", @@ -12,6 +12,7 @@ "storyStateTimeMarker": "時刻マーカー", "storyStateChapter": "チャプター", "storyStateUnknown": "不明なソース型", + "storyStateShowDormant": "未使用を表示({count})", "storyStateUnknownDetail": "この保存済み ID は現在のスクリプトカタログに存在しません(Mod または新しいゲーム版など)。保存形式の値は int32 ですが、その意味は推測しません。", "storyStateStored": "保存済み", "storyStateUnset": "未設定", @@ -22,7 +23,7 @@ "storyStateDurationDays": "{days}日 {time}", "storyStateRelatedGlossary": "関連する用語集項目", "storyStateTechnicalPath": "技術パス", - "storyStateEditingGuidance": "すべての項目は、符号付き int32 の全範囲で編集できます。スクリプトに基づくフラグや値の候補は参考情報であり、生の値はいつでも入力できます。ストーリー状態を変更すると、会話、クエスト、ワールドの遷移が飛ばされる場合があります。内容を確認して保存してください。バックアップは自動的に作成されます。", + "storyStateEditingGuidance": "項目を選ぶと値を変更できます。変更は保存すると反映されます。効果を理解している値だけを変更してください。クエストや会話が意図どおりに進まなくなる場合があります。保存時には自動的にバックアップが作成されます。", "storyStatePending": "保留中", "storyStatePendingValue": "{value} として保存されます", "@storyStatePendingValue": {"placeholders": {"value": {"type": "String"}}}, @@ -821,5 +822,22 @@ "statisticsUnknown": "利用不可", "statisticsMore": "その他の統計", "statisticsSummary": "レベル{level}、{guild}、チャプター{chapter}。完了クエスト{completed}件、失敗{failed}件。プレイ時間:{playTime}。", - "@statisticsSummary": {"placeholders": {"level": {"type": "String"}, "guild": {"type": "String"}, "chapter": {"type": "String"}, "completed": {"type": "int"}, "failed": {"type": "int"}, "playTime": {"type": "String"}}} + "@statisticsSummary": {"placeholders": {"level": {"type": "String"}, "guild": {"type": "String"}, "chapter": {"type": "String"}, "completed": {"type": "int"}, "failed": {"type": "int"}, "playTime": {"type": "String"}}}, + "locksSidebar": "錠", + "editorLockListFailed": "錠の一覧を読み込めませんでした: {details}", + "locksSearchHint": "錠または鍵を検索", + "locksAllRegions": "すべての地域", + "locksShownOfTotal": "{total} 件中 {shown} 件", + "locksFilterChests": "宝箱", + "locksFilterDoors": "扉", + "locksFilterUnlocked": "解錠済み", + "locksFilterLocked": "施錠中", + "locksDifficultyLevel": "難易度 {bars}/4(内部段階 {level}/7)", + "locksKeyOnly": "鍵のみ", + "locksKeyLabel": "鍵: {keys}", + "locksPermalocked": "恒久的に封鎖", + "locksReadOnly": "このセーブデータには編集できる錠の一覧がありません。", + "locksUnknownEntry": "このバージョンには存在しません", + "locksDoorLeafHint": "扉を再び施錠すると、扉自体も閉まります。", + "locksResetPending": "保留中の錠の変更を破棄" } diff --git a/apps/save-editor/lib/l10n/app_localizations.dart b/apps/save-editor/lib/l10n/app_localizations.dart index 475557c93..1001a9ceb 100644 --- a/apps/save-editor/lib/l10n/app_localizations.dart +++ b/apps/save-editor/lib/l10n/app_localizations.dart @@ -149,7 +149,7 @@ abstract class AppLocalizations { /// No description provided for @storyStateDescription. /// /// In en, this message translates to: - /// **'Authoritative catalog of persisted story state declared by the shipped game scripts. Stored entries show their raw value; catalog fields missing from this save are marked as not set. Source-declared time markers are formatted as game time, while other integers may be booleans, counters, or multi-state values.'** + /// **'The game tracks progress in quests, dialogues and events here. “Stored” shows the values in your save; “Not set” shows the other known entries. Depending on the entry, a number can mean yes/no, a count or a stage of progress. Time markers show a day and time in the game.'** String get storyStateDescription; /// No description provided for @storyStateReadOnly. @@ -200,6 +200,12 @@ abstract class AppLocalizations { /// **'Unknown source type'** String get storyStateUnknown; + /// No description provided for @storyStateShowDormant. + /// + /// In en, this message translates to: + /// **'Show unused ({count})'** + String storyStateShowDormant(int count); + /// No description provided for @storyStateUnknownDetail. /// /// In en, this message translates to: @@ -263,7 +269,7 @@ abstract class AppLocalizations { /// No description provided for @storyStateEditingGuidance. /// /// In en, this message translates to: - /// **'Every entry remains editable across the full signed int32 range. Script-backed switches and value suggestions are guidance; raw input is always available. Story changes can skip dialogue, quest, or world transitions, so save them deliberately — a backup is created automatically.'** + /// **'Select an entry to change its value. Changes take effect when you save. Only change values whose effects you understand: quests or dialogues may otherwise stop working as expected. A backup is created automatically when you save.'** String get storyStateEditingGuidance; /// No description provided for @storyStatePending. @@ -4866,6 +4872,108 @@ abstract class AppLocalizations { int failed, String playTime, ); + + /// No description provided for @locksSidebar. + /// + /// In en, this message translates to: + /// **'Locks'** + String get locksSidebar; + + /// No description provided for @editorLockListFailed. + /// + /// In en, this message translates to: + /// **'Lock list failed: {details}'** + String editorLockListFailed(String details); + + /// No description provided for @locksSearchHint. + /// + /// In en, this message translates to: + /// **'Search lock or key'** + String get locksSearchHint; + + /// No description provided for @locksAllRegions. + /// + /// In en, this message translates to: + /// **'All regions'** + String get locksAllRegions; + + /// No description provided for @locksShownOfTotal. + /// + /// In en, this message translates to: + /// **'{shown} of {total}'** + String locksShownOfTotal(int shown, int total); + + /// No description provided for @locksFilterChests. + /// + /// In en, this message translates to: + /// **'Chests'** + String get locksFilterChests; + + /// No description provided for @locksFilterDoors. + /// + /// In en, this message translates to: + /// **'Doors'** + String get locksFilterDoors; + + /// No description provided for @locksFilterUnlocked. + /// + /// In en, this message translates to: + /// **'Unlocked'** + String get locksFilterUnlocked; + + /// No description provided for @locksFilterLocked. + /// + /// In en, this message translates to: + /// **'Locked'** + String get locksFilterLocked; + + /// No description provided for @locksDifficultyLevel. + /// + /// In en, this message translates to: + /// **'Difficulty {bars} of 4 (internal tier {level} of 7)'** + String locksDifficultyLevel(int bars, int level); + + /// No description provided for @locksKeyOnly. + /// + /// In en, this message translates to: + /// **'Key only'** + String get locksKeyOnly; + + /// No description provided for @locksKeyLabel. + /// + /// In en, this message translates to: + /// **'Key: {keys}'** + String locksKeyLabel(String keys); + + /// No description provided for @locksPermalocked. + /// + /// In en, this message translates to: + /// **'Sealed permanently'** + String get locksPermalocked; + + /// No description provided for @locksReadOnly. + /// + /// In en, this message translates to: + /// **'This save has no editable lock set.'** + String get locksReadOnly; + + /// No description provided for @locksUnknownEntry. + /// + /// In en, this message translates to: + /// **'Not in this game version'** + String get locksUnknownEntry; + + /// No description provided for @locksDoorLeafHint. + /// + /// In en, this message translates to: + /// **'Locking a door again also shuts the door itself.'** + String get locksDoorLeafHint; + + /// No description provided for @locksResetPending. + /// + /// In en, this message translates to: + /// **'Discard queued lock changes'** + String get locksResetPending; } class _AppLocalizationsDelegate diff --git a/apps/save-editor/lib/l10n/app_localizations_de.dart b/apps/save-editor/lib/l10n/app_localizations_de.dart index e1c610c04..f9b7a19d3 100644 --- a/apps/save-editor/lib/l10n/app_localizations_de.dart +++ b/apps/save-editor/lib/l10n/app_localizations_de.dart @@ -26,7 +26,7 @@ class AppLocalizationsDe extends AppLocalizations { @override String get storyStateDescription => - 'Autoritativer Katalog der in den ausgelieferten Spielscripts deklarierten, persistenten Story-Zustände. Gespeicherte Einträge zeigen ihren Rohwert; im Save fehlende Katalogfelder sind als nicht gesetzt markiert. Im Source deklarierte Zeitpunkte werden als Spielzeit formatiert. Andere Ganzzahlen können Ja/Nein-Werte, Zähler oder mehrstufige Zustände sein.'; + 'Hier merkt sich das Spiel Fortschritte bei Quests, Dialogen und Ereignissen. „Gespeichert“ zeigt die Werte deines Spielstands, „Nicht gesetzt“ die übrigen bekannten Einträge. Zahlen können je nach Eintrag Ja/Nein, einen Zähler oder einen Fortschrittsschritt bedeuten. Zeitpunkte zeigen einen Tag und eine Uhrzeit im Spiel.'; @override String get storyStateReadOnly => @@ -56,6 +56,11 @@ class AppLocalizationsDe extends AppLocalizations { @override String get storyStateUnknown => 'Unbekannter Source-Typ'; + @override + String storyStateShowDormant(int count) { + return 'Ungenutzte anzeigen ($count)'; + } + @override String get storyStateUnknownDetail => 'Diese gespeicherte ID fehlt im aktuellen Script-Katalog (z. B. durch eine Mod oder neuere Spielversion). Der Save-Rohwert ist zwar int32, seine Bedeutung wird aber nicht geraten.'; @@ -102,7 +107,7 @@ class AppLocalizationsDe extends AppLocalizations { @override String get storyStateEditingGuidance => - 'Jeder Eintrag bleibt über den gesamten vorzeichenbehafteten int32-Bereich editierbar. Script-belegte Schalter und Wertvorschläge sind Hilfen; die Rohwerteingabe bleibt immer verfügbar. Story-Änderungen können Dialog-, Quest- oder Weltübergänge überspringen – beim Speichern wird automatisch ein Backup angelegt.'; + 'Wähle einen Eintrag, um seinen Wert zu ändern. Die Änderung wird erst mit „Speichern“ übernommen. Verändere nur Werte, deren Wirkung du kennst: Quests oder Dialoge können sonst nicht mehr wie erwartet ablaufen. Beim Speichern wird automatisch ein Backup angelegt.'; @override String get storyStatePending => 'Ausstehend'; @@ -3251,4 +3256,65 @@ class AppLocalizationsDe extends AppLocalizations { ) { return 'Level $level, $guild, Kapitel $chapter. $completed Quests abgeschlossen, $failed fehlgeschlagen. Spielzeit: $playTime.'; } + + @override + String get locksSidebar => 'Schlösser'; + + @override + String editorLockListFailed(String details) { + return 'Die Schlossliste konnte nicht geladen werden: $details'; + } + + @override + String get locksSearchHint => 'Schloss oder Schlüssel suchen'; + + @override + String get locksAllRegions => 'Alle Regionen'; + + @override + String locksShownOfTotal(int shown, int total) { + return '$shown von $total'; + } + + @override + String get locksFilterChests => 'Truhen'; + + @override + String get locksFilterDoors => 'Türen'; + + @override + String get locksFilterUnlocked => 'Aufgeschlossen'; + + @override + String get locksFilterLocked => 'Verschlossen'; + + @override + String locksDifficultyLevel(int bars, int level) { + return 'Schwierigkeit $bars von 4 (intern Stufe $level von 7)'; + } + + @override + String get locksKeyOnly => 'Nur mit Schlüssel'; + + @override + String locksKeyLabel(String keys) { + return 'Schlüssel: $keys'; + } + + @override + String get locksPermalocked => 'Dauerhaft verriegelt'; + + @override + String get locksReadOnly => + 'Dieser Spielstand hat keine bearbeitbare Schlossliste.'; + + @override + String get locksUnknownEntry => 'Nicht in dieser Spielversion'; + + @override + String get locksDoorLeafHint => + 'Eine Tür wieder abzuschließen schließt auch den Türflügel.'; + + @override + String get locksResetPending => 'Ausstehende Schlossänderungen verwerfen'; } diff --git a/apps/save-editor/lib/l10n/app_localizations_en.dart b/apps/save-editor/lib/l10n/app_localizations_en.dart index d5f82cf9a..30926f934 100644 --- a/apps/save-editor/lib/l10n/app_localizations_en.dart +++ b/apps/save-editor/lib/l10n/app_localizations_en.dart @@ -26,7 +26,7 @@ class AppLocalizationsEn extends AppLocalizations { @override String get storyStateDescription => - 'Authoritative catalog of persisted story state declared by the shipped game scripts. Stored entries show their raw value; catalog fields missing from this save are marked as not set. Source-declared time markers are formatted as game time, while other integers may be booleans, counters, or multi-state values.'; + 'The game tracks progress in quests, dialogues and events here. “Stored” shows the values in your save; “Not set” shows the other known entries. Depending on the entry, a number can mean yes/no, a count or a stage of progress. Time markers show a day and time in the game.'; @override String get storyStateReadOnly => @@ -56,6 +56,11 @@ class AppLocalizationsEn extends AppLocalizations { @override String get storyStateUnknown => 'Unknown source type'; + @override + String storyStateShowDormant(int count) { + return 'Show unused ($count)'; + } + @override String get storyStateUnknownDetail => 'This stored ID is absent from the current script catalog (for example, from a mod or newer game version). Its save wire value is int32, but its meaning is not inferred.'; @@ -102,7 +107,7 @@ class AppLocalizationsEn extends AppLocalizations { @override String get storyStateEditingGuidance => - 'Every entry remains editable across the full signed int32 range. Script-backed switches and value suggestions are guidance; raw input is always available. Story changes can skip dialogue, quest, or world transitions, so save them deliberately — a backup is created automatically.'; + 'Select an entry to change its value. Changes take effect when you save. Only change values whose effects you understand: quests or dialogues may otherwise stop working as expected. A backup is created automatically when you save.'; @override String get storyStatePending => 'Pending'; @@ -3229,4 +3234,64 @@ class AppLocalizationsEn extends AppLocalizations { ) { return 'Level $level, $guild, chapter $chapter. $completed quests completed, $failed failed. Play time: $playTime.'; } + + @override + String get locksSidebar => 'Locks'; + + @override + String editorLockListFailed(String details) { + return 'Lock list failed: $details'; + } + + @override + String get locksSearchHint => 'Search lock or key'; + + @override + String get locksAllRegions => 'All regions'; + + @override + String locksShownOfTotal(int shown, int total) { + return '$shown of $total'; + } + + @override + String get locksFilterChests => 'Chests'; + + @override + String get locksFilterDoors => 'Doors'; + + @override + String get locksFilterUnlocked => 'Unlocked'; + + @override + String get locksFilterLocked => 'Locked'; + + @override + String locksDifficultyLevel(int bars, int level) { + return 'Difficulty $bars of 4 (internal tier $level of 7)'; + } + + @override + String get locksKeyOnly => 'Key only'; + + @override + String locksKeyLabel(String keys) { + return 'Key: $keys'; + } + + @override + String get locksPermalocked => 'Sealed permanently'; + + @override + String get locksReadOnly => 'This save has no editable lock set.'; + + @override + String get locksUnknownEntry => 'Not in this game version'; + + @override + String get locksDoorLeafHint => + 'Locking a door again also shuts the door itself.'; + + @override + String get locksResetPending => 'Discard queued lock changes'; } diff --git a/apps/save-editor/lib/l10n/app_localizations_es.dart b/apps/save-editor/lib/l10n/app_localizations_es.dart index 1859db8b0..aee1d8733 100644 --- a/apps/save-editor/lib/l10n/app_localizations_es.dart +++ b/apps/save-editor/lib/l10n/app_localizations_es.dart @@ -27,7 +27,7 @@ class AppLocalizationsEs extends AppLocalizations { @override String get storyStateDescription => - 'Catálogo autoritativo de estados persistentes declarados por los scripts distribuidos con el juego. Las entradas guardadas muestran su valor bruto; los campos del catálogo ausentes de esta partida se marcan como no establecidos. Las marcas de tiempo declaradas en el código se muestran como tiempo de juego; los demás enteros pueden ser booleanos, contadores o estados de varios niveles.'; + 'Aquí el juego registra el progreso de misiones, diálogos y eventos. «Guardado» muestra los valores de tu partida; «No establecido», las demás entradas conocidas. Según la entrada, un número puede indicar sí/no, una cantidad o una etapa de progreso. Los marcadores de tiempo muestran un día y una hora del juego.'; @override String get storyStateReadOnly => @@ -57,6 +57,11 @@ class AppLocalizationsEs extends AppLocalizations { @override String get storyStateUnknown => 'Tipo de origen desconocido'; + @override + String storyStateShowDormant(int count) { + return 'Mostrar sin usar ($count)'; + } + @override String get storyStateUnknownDetail => 'Este ID guardado no figura en el catálogo de scripts actual (por ejemplo, por un mod o una versión más reciente del juego). El valor almacenado es int32, pero no se infiere su significado.'; @@ -103,7 +108,7 @@ class AppLocalizationsEs extends AppLocalizations { @override String get storyStateEditingGuidance => - 'Todas las entradas se pueden editar en todo el intervalo de int32 con signo. Los indicadores y las sugerencias de valores respaldados por los scripts son solo orientativos; la entrada sin procesar siempre está disponible. Los cambios en el estado de la historia pueden omitir transiciones de diálogos, misiones o del mundo, así que guárdalos con cuidado; se crea una copia de seguridad automáticamente.'; + 'Selecciona una entrada para cambiar su valor. Los cambios se aplican al guardar. Cambia solo valores cuyo efecto conozcas: de lo contrario, las misiones o los diálogos podrían dejar de funcionar como esperas. Al guardar se crea una copia de seguridad automáticamente.'; @override String get storyStatePending => 'Pendiente'; @@ -3254,4 +3259,66 @@ class AppLocalizationsEs extends AppLocalizations { ) { return 'Nivel $level, $guild, capítulo $chapter. $completed misiones completadas, $failed fallidas. Tiempo de juego: $playTime.'; } + + @override + String get locksSidebar => 'Cerraduras'; + + @override + String editorLockListFailed(String details) { + return 'No se pudo cargar la lista de cerraduras: $details'; + } + + @override + String get locksSearchHint => 'Buscar cerradura o llave'; + + @override + String get locksAllRegions => 'Todas las regiones'; + + @override + String locksShownOfTotal(int shown, int total) { + return '$shown de $total'; + } + + @override + String get locksFilterChests => 'Cofres'; + + @override + String get locksFilterDoors => 'Puertas'; + + @override + String get locksFilterUnlocked => 'Abierta'; + + @override + String get locksFilterLocked => 'Cerrada'; + + @override + String locksDifficultyLevel(int bars, int level) { + return 'Dificultad $bars de 4 (nivel interno $level de 7)'; + } + + @override + String get locksKeyOnly => 'Solo con llave'; + + @override + String locksKeyLabel(String keys) { + return 'Llave: $keys'; + } + + @override + String get locksPermalocked => 'Sellada permanentemente'; + + @override + String get locksReadOnly => + 'Esta partida no tiene una lista de cerraduras editable.'; + + @override + String get locksUnknownEntry => 'No existe en esta versión del juego'; + + @override + String get locksDoorLeafHint => + 'Volver a cerrar una puerta también cierra la puerta en sí.'; + + @override + String get locksResetPending => + 'Descartar los cambios de cerraduras pendientes'; } diff --git a/apps/save-editor/lib/l10n/app_localizations_fr.dart b/apps/save-editor/lib/l10n/app_localizations_fr.dart index 5d13e7978..f0901e08c 100644 --- a/apps/save-editor/lib/l10n/app_localizations_fr.dart +++ b/apps/save-editor/lib/l10n/app_localizations_fr.dart @@ -28,7 +28,7 @@ class AppLocalizationsFr extends AppLocalizations { @override String get storyStateDescription => - 'Catalogue de référence des états persistants déclarés par les scripts livrés avec le jeu. Les entrées enregistrées affichent leur valeur brute ; les champs du catalogue absents de cette sauvegarde sont marqués comme non définis. Les repères temporels déclarés dans le code sont affichés en temps de jeu ; les autres entiers peuvent être des booléens, compteurs ou états à plusieurs niveaux.'; + 'Le jeu mémorise ici la progression des quêtes, des dialogues et des événements. « Enregistré » affiche les valeurs de ta sauvegarde ; « Non défini », les autres entrées connues. Selon l’entrée, un nombre peut indiquer oui/non, un compteur ou une étape de progression. Les repères temporels indiquent un jour et une heure dans le jeu.'; @override String get storyStateReadOnly => @@ -58,6 +58,11 @@ class AppLocalizationsFr extends AppLocalizations { @override String get storyStateUnknown => 'Type source inconnu'; + @override + String storyStateShowDormant(int count) { + return 'Afficher les inutilisés ($count)'; + } + @override String get storyStateUnknownDetail => 'Cet ID enregistré est absent du catalogue de scripts actuel (par exemple à cause d’un mod ou d’une version plus récente du jeu). Sa valeur sérialisée est un int32, mais sa signification n’est pas déduite.'; @@ -104,7 +109,7 @@ class AppLocalizationsFr extends AppLocalizations { @override String get storyStateEditingGuidance => - 'Chaque entrée reste modifiable sur toute la plage int32 signée. Les indicateurs et suggestions de valeurs issus des scripts sont fournis à titre indicatif ; la saisie brute reste toujours disponible. Les changements d’état de l’histoire peuvent court-circuiter des transitions de dialogue, de quête ou du monde : enregistrez-les avec précaution. Une sauvegarde de sécurité est créée automatiquement.'; + 'Sélectionne une entrée pour modifier sa valeur. Les changements sont appliqués à l’enregistrement. Ne modifie que les valeurs dont tu connais les effets : les quêtes ou les dialogues pourraient sinon ne plus fonctionner comme prévu. Une copie de sauvegarde est créée automatiquement à l’enregistrement.'; @override String get storyStatePending => 'En attente'; @@ -3269,4 +3274,66 @@ class AppLocalizationsFr extends AppLocalizations { ) { return 'Niveau $level, $guild, chapitre $chapter. $completed quêtes terminées, $failed échouées. Temps de jeu : $playTime.'; } + + @override + String get locksSidebar => 'Serrures'; + + @override + String editorLockListFailed(String details) { + return 'Échec du chargement de la liste des serrures : $details'; + } + + @override + String get locksSearchHint => 'Rechercher une serrure ou une clé'; + + @override + String get locksAllRegions => 'Toutes les régions'; + + @override + String locksShownOfTotal(int shown, int total) { + return '$shown sur $total'; + } + + @override + String get locksFilterChests => 'Coffres'; + + @override + String get locksFilterDoors => 'Portes'; + + @override + String get locksFilterUnlocked => 'Déverrouillée'; + + @override + String get locksFilterLocked => 'Verrouillée'; + + @override + String locksDifficultyLevel(int bars, int level) { + return 'Difficulté $bars sur 4 (palier interne $level sur 7)'; + } + + @override + String get locksKeyOnly => 'Clé uniquement'; + + @override + String locksKeyLabel(String keys) { + return 'Clé : $keys'; + } + + @override + String get locksPermalocked => 'Scellée définitivement'; + + @override + String get locksReadOnly => + 'Cette sauvegarde ne contient pas de liste de serrures modifiable.'; + + @override + String get locksUnknownEntry => 'Absente de cette version du jeu'; + + @override + String get locksDoorLeafHint => + 'Reverrouiller une porte referme aussi le battant.'; + + @override + String get locksResetPending => + 'Abandonner les modifications de serrures en attente'; } diff --git a/apps/save-editor/lib/l10n/app_localizations_it.dart b/apps/save-editor/lib/l10n/app_localizations_it.dart index 2d8c5ce86..8b418543a 100644 --- a/apps/save-editor/lib/l10n/app_localizations_it.dart +++ b/apps/save-editor/lib/l10n/app_localizations_it.dart @@ -27,7 +27,7 @@ class AppLocalizationsIt extends AppLocalizations { @override String get storyStateDescription => - 'Catalogo autorevole degli stati persistenti dichiarati dagli script distribuiti con il gioco. Le voci salvate mostrano il valore grezzo; i campi del catalogo assenti dal salvataggio sono indicati come non impostati. I marcatori temporali dichiarati nel codice sono formattati come tempo di gioco; gli altri interi possono essere booleani, contatori o stati a più livelli.'; + 'Qui il gioco registra i progressi di missioni, dialoghi ed eventi. «Salvato» mostra i valori del salvataggio; «Non impostato», le altre voci note. A seconda della voce, un numero può indicare sì/no, un conteggio o una fase di avanzamento. I riferimenti temporali mostrano un giorno e un’ora nel gioco.'; @override String get storyStateReadOnly => @@ -57,6 +57,11 @@ class AppLocalizationsIt extends AppLocalizations { @override String get storyStateUnknown => 'Tipo sorgente sconosciuto'; + @override + String storyStateShowDormant(int count) { + return 'Mostra inutilizzati ($count)'; + } + @override String get storyStateUnknownDetail => 'Questo ID salvato non è presente nel catalogo degli script attuale (ad esempio per una mod o una versione più recente del gioco). Il valore serializzato è int32, ma il significato non viene dedotto.'; @@ -103,7 +108,7 @@ class AppLocalizationsIt extends AppLocalizations { @override String get storyStateEditingGuidance => - 'Ogni voce resta modificabile nell’intero intervallo int32 con segno. Gli indicatori e i suggerimenti di valore ricavati dagli script sono solo indicativi; l’inserimento del valore grezzo è sempre disponibile. Le modifiche allo stato della storia possono saltare transizioni di dialoghi, missioni o del mondo, quindi salvale con cautela; viene creata automaticamente una copia di sicurezza.'; + 'Seleziona una voce per modificarne il valore. Le modifiche vengono applicate quando salvi. Modifica solo valori di cui conosci gli effetti: altrimenti missioni o dialoghi potrebbero non funzionare più come previsto. Quando salvi, viene creata automaticamente una copia di sicurezza.'; @override String get storyStatePending => 'In sospeso'; @@ -3256,4 +3261,66 @@ class AppLocalizationsIt extends AppLocalizations { ) { return 'Livello $level, $guild, capitolo $chapter. $completed missioni completate, $failed fallite. Tempo di gioco: $playTime.'; } + + @override + String get locksSidebar => 'Serrature'; + + @override + String editorLockListFailed(String details) { + return 'Impossibile caricare l\'elenco delle serrature: $details'; + } + + @override + String get locksSearchHint => 'Cerca serratura o chiave'; + + @override + String get locksAllRegions => 'Tutte le regioni'; + + @override + String locksShownOfTotal(int shown, int total) { + return '$shown di $total'; + } + + @override + String get locksFilterChests => 'Casse'; + + @override + String get locksFilterDoors => 'Porte'; + + @override + String get locksFilterUnlocked => 'Aperta'; + + @override + String get locksFilterLocked => 'Chiusa'; + + @override + String locksDifficultyLevel(int bars, int level) { + return 'Difficoltà $bars su 4 (livello interno $level su 7)'; + } + + @override + String get locksKeyOnly => 'Solo con chiave'; + + @override + String locksKeyLabel(String keys) { + return 'Chiave: $keys'; + } + + @override + String get locksPermalocked => 'Sigillata per sempre'; + + @override + String get locksReadOnly => + 'Questo salvataggio non ha un elenco di serrature modificabile.'; + + @override + String get locksUnknownEntry => 'Non presente in questa versione del gioco'; + + @override + String get locksDoorLeafHint => + 'Richiudere una porta chiude anche il battente.'; + + @override + String get locksResetPending => + 'Scarta le modifiche alle serrature in sospeso'; } diff --git a/apps/save-editor/lib/l10n/app_localizations_ja.dart b/apps/save-editor/lib/l10n/app_localizations_ja.dart index 9c79fd0d6..5da25a329 100644 --- a/apps/save-editor/lib/l10n/app_localizations_ja.dart +++ b/apps/save-editor/lib/l10n/app_localizations_ja.dart @@ -26,7 +26,7 @@ class AppLocalizationsJa extends AppLocalizations { @override String get storyStateDescription => - '製品版ゲームスクリプトで宣言された永続ストーリー状態の正式なカタログです。保存済み項目には生の値を表示し、このセーブにないカタログ項目は未設定として示します。ソースで宣言された時刻はゲーム内時刻として表示します。その他の整数は真偽値、カウンター、多段階状態などの場合があります。'; + 'ここにはクエスト、会話、イベントの進行状況が記録されています。「保存済み」はセーブデータ内の値、「未設定」はそれ以外の既知の項目を表示します。数値は項目によって、はい/いいえ、回数、進行段階などを表します。時刻の項目はゲーム内の日付と時刻を表します。'; @override String get storyStateReadOnly => @@ -56,6 +56,11 @@ class AppLocalizationsJa extends AppLocalizations { @override String get storyStateUnknown => '不明なソース型'; + @override + String storyStateShowDormant(int count) { + return '未使用を表示($count)'; + } + @override String get storyStateUnknownDetail => 'この保存済み ID は現在のスクリプトカタログに存在しません(Mod または新しいゲーム版など)。保存形式の値は int32 ですが、その意味は推測しません。'; @@ -96,7 +101,7 @@ class AppLocalizationsJa extends AppLocalizations { @override String get storyStateEditingGuidance => - 'すべての項目は、符号付き int32 の全範囲で編集できます。スクリプトに基づくフラグや値の候補は参考情報であり、生の値はいつでも入力できます。ストーリー状態を変更すると、会話、クエスト、ワールドの遷移が飛ばされる場合があります。内容を確認して保存してください。バックアップは自動的に作成されます。'; + '項目を選ぶと値を変更できます。変更は保存すると反映されます。効果を理解している値だけを変更してください。クエストや会話が意図どおりに進まなくなる場合があります。保存時には自動的にバックアップが作成されます。'; @override String get storyStatePending => '保留中'; @@ -3145,4 +3150,63 @@ class AppLocalizationsJa extends AppLocalizations { ) { return 'レベル$level、$guild、チャプター$chapter。完了クエスト$completed件、失敗$failed件。プレイ時間:$playTime。'; } + + @override + String get locksSidebar => '錠'; + + @override + String editorLockListFailed(String details) { + return '錠の一覧を読み込めませんでした: $details'; + } + + @override + String get locksSearchHint => '錠または鍵を検索'; + + @override + String get locksAllRegions => 'すべての地域'; + + @override + String locksShownOfTotal(int shown, int total) { + return '$total 件中 $shown 件'; + } + + @override + String get locksFilterChests => '宝箱'; + + @override + String get locksFilterDoors => '扉'; + + @override + String get locksFilterUnlocked => '解錠済み'; + + @override + String get locksFilterLocked => '施錠中'; + + @override + String locksDifficultyLevel(int bars, int level) { + return '難易度 $bars/4(内部段階 $level/7)'; + } + + @override + String get locksKeyOnly => '鍵のみ'; + + @override + String locksKeyLabel(String keys) { + return '鍵: $keys'; + } + + @override + String get locksPermalocked => '恒久的に封鎖'; + + @override + String get locksReadOnly => 'このセーブデータには編集できる錠の一覧がありません。'; + + @override + String get locksUnknownEntry => 'このバージョンには存在しません'; + + @override + String get locksDoorLeafHint => '扉を再び施錠すると、扉自体も閉まります。'; + + @override + String get locksResetPending => '保留中の錠の変更を破棄'; } diff --git a/apps/save-editor/lib/l10n/app_localizations_pl.dart b/apps/save-editor/lib/l10n/app_localizations_pl.dart index 4aef9b01d..fb5677512 100644 --- a/apps/save-editor/lib/l10n/app_localizations_pl.dart +++ b/apps/save-editor/lib/l10n/app_localizations_pl.dart @@ -27,7 +27,7 @@ class AppLocalizationsPl extends AppLocalizations { @override String get storyStateDescription => - 'Autorytatywny katalog trwałych stanów fabuły zadeklarowanych w skryptach dostarczonych z grą. Zapisane wpisy pokazują surową wartość; pola katalogu nieobecne w tym zapisie są oznaczone jako nieustawione. Znaczniki czasu zadeklarowane w kodzie są przedstawiane jako czas gry, a pozostałe liczby całkowite mogą być wartościami logicznymi, licznikami albo stanami wielopoziomowymi.'; + 'Gra zapisuje tu postępy zadań, dialogów i wydarzeń. „Zapisane” pokazuje wartości z twojego zapisu gry, a „Nieustawione” — pozostałe znane wpisy. Zależnie od wpisu liczba może oznaczać tak/nie, licznik lub etap postępu. Znaczniki czasu pokazują dzień i godzinę w grze.'; @override String get storyStateReadOnly => @@ -57,6 +57,11 @@ class AppLocalizationsPl extends AppLocalizations { @override String get storyStateUnknown => 'Nieznany typ źródłowy'; + @override + String storyStateShowDormant(int count) { + return 'Pokaż nieużywane ($count)'; + } + @override String get storyStateUnknownDetail => 'Tego zapisanego ID nie ma w bieżącym katalogu skryptów (np. pochodzi z moda lub nowszej wersji gry). Wartość w zapisie ma format int32, ale jej znaczenie nie jest zgadywane.'; @@ -105,7 +110,7 @@ class AppLocalizationsPl extends AppLocalizations { @override String get storyStateEditingGuidance => - 'Każdy wpis można edytować w pełnym zakresie wartości int32 ze znakiem. Flagi i sugerowane wartości oparte na skryptach są jedynie wskazówkami; zawsze można wprowadzić surową wartość. Zmiany stanu fabuły mogą pominąć przejścia dialogów, zadań lub świata, dlatego zapisuj je rozważnie. Kopia zapasowa zostanie utworzona automatycznie.'; + 'Wybierz wpis, aby zmienić jego wartość. Zmiany zostaną zastosowane po zapisaniu. Zmieniaj tylko wartości, których działanie rozumiesz: inaczej zadania lub dialogi mogą przestać działać zgodnie z oczekiwaniami. Podczas zapisywania automatycznie powstaje kopia zapasowa.'; @override String get storyStatePending => 'Oczekuje'; @@ -3264,4 +3269,64 @@ class AppLocalizationsPl extends AppLocalizations { ) { return 'Poziom $level, $guild, rozdział $chapter. Ukończone zadania: $completed, nieudane: $failed. Czas gry: $playTime.'; } + + @override + String get locksSidebar => 'Zamki'; + + @override + String editorLockListFailed(String details) { + return 'Nie udało się wczytać listy zamków: $details'; + } + + @override + String get locksSearchHint => 'Szukaj zamka lub klucza'; + + @override + String get locksAllRegions => 'Wszystkie regiony'; + + @override + String locksShownOfTotal(int shown, int total) { + return '$shown z $total'; + } + + @override + String get locksFilterChests => 'Skrzynie'; + + @override + String get locksFilterDoors => 'Drzwi'; + + @override + String get locksFilterUnlocked => 'Otwarty'; + + @override + String get locksFilterLocked => 'Zamknięty'; + + @override + String locksDifficultyLevel(int bars, int level) { + return 'Trudność $bars z 4 (wewnętrzny stopień $level z 7)'; + } + + @override + String get locksKeyOnly => 'Tylko kluczem'; + + @override + String locksKeyLabel(String keys) { + return 'Klucz: $keys'; + } + + @override + String get locksPermalocked => 'Zapieczętowane na stałe'; + + @override + String get locksReadOnly => 'Ten zapis nie ma edytowalnej listy zamków.'; + + @override + String get locksUnknownEntry => 'Brak w tej wersji gry'; + + @override + String get locksDoorLeafHint => + 'Ponowne zamknięcie drzwi zamyka także same drzwi.'; + + @override + String get locksResetPending => 'Odrzuć oczekujące zmiany zamków'; } diff --git a/apps/save-editor/lib/l10n/app_localizations_pt.dart b/apps/save-editor/lib/l10n/app_localizations_pt.dart index 3802d6bd5..8eaae9e0f 100644 --- a/apps/save-editor/lib/l10n/app_localizations_pt.dart +++ b/apps/save-editor/lib/l10n/app_localizations_pt.dart @@ -27,7 +27,7 @@ class AppLocalizationsPt extends AppLocalizations { @override String get storyStateDescription => - 'Catálogo de referência dos estados persistentes declarados pelos scripts fornecidos com o jogo. As entradas guardadas mostram o valor bruto; os campos do catálogo ausentes deste jogo guardado são marcados como não definidos. Os marcadores temporais declarados no código são apresentados como tempo de jogo; os restantes inteiros podem ser booleanos, contadores ou estados de vários níveis.'; + 'O jogo regista aqui o progresso de missões, diálogos e eventos. «Guardado» mostra os valores da tua gravação; «Não definido», as restantes entradas conhecidas. Consoante a entrada, um número pode indicar sim/não, uma contagem ou uma etapa de progresso. Os marcadores de tempo mostram um dia e uma hora no jogo.'; @override String get storyStateReadOnly => @@ -57,6 +57,11 @@ class AppLocalizationsPt extends AppLocalizations { @override String get storyStateUnknown => 'Tipo de origem desconhecido'; + @override + String storyStateShowDormant(int count) { + return 'Mostrar não utilizados ($count)'; + } + @override String get storyStateUnknownDetail => 'Este ID guardado não consta do catálogo de scripts atual (por exemplo, por um mod ou uma versão mais recente do jogo). O valor serializado é int32, mas o seu significado não é inferido.'; @@ -103,7 +108,7 @@ class AppLocalizationsPt extends AppLocalizations { @override String get storyStateEditingGuidance => - 'Todas as entradas podem ser editadas em todo o intervalo int32 com sinal. Os indicadores e as sugestões de valores baseados nos scripts servem apenas de orientação; a introdução do valor bruto está sempre disponível. As alterações ao estado da história podem ignorar transições de diálogos, missões ou do mundo, por isso guarda-as com cuidado. É criada automaticamente uma cópia de segurança.'; + 'Seleciona uma entrada para alterar o seu valor. As alterações são aplicadas quando guardas. Altera apenas valores cujos efeitos conheças: caso contrário, as missões ou os diálogos podem deixar de funcionar como esperado. Ao guardar, é criada automaticamente uma cópia de segurança.'; @override String get storyStatePending => 'Pendente'; @@ -3251,6 +3256,68 @@ class AppLocalizationsPt extends AppLocalizations { ) { return 'Nível $level, $guild, capítulo $chapter. $completed missões concluídas, $failed falhadas. Tempo de jogo: $playTime.'; } + + @override + String get locksSidebar => 'Fechaduras'; + + @override + String editorLockListFailed(String details) { + return 'Não foi possível carregar a lista de fechaduras: $details'; + } + + @override + String get locksSearchHint => 'Procurar fechadura ou chave'; + + @override + String get locksAllRegions => 'Todas as regiões'; + + @override + String locksShownOfTotal(int shown, int total) { + return '$shown de $total'; + } + + @override + String get locksFilterChests => 'Baús'; + + @override + String get locksFilterDoors => 'Portas'; + + @override + String get locksFilterUnlocked => 'Destrancada'; + + @override + String get locksFilterLocked => 'Trancada'; + + @override + String locksDifficultyLevel(int bars, int level) { + return 'Dificuldade $bars de 4 (nível interno $level de 7)'; + } + + @override + String get locksKeyOnly => 'Só com chave'; + + @override + String locksKeyLabel(String keys) { + return 'Chave: $keys'; + } + + @override + String get locksPermalocked => 'Selada permanentemente'; + + @override + String get locksReadOnly => + 'Este save não tem uma lista de fechaduras editável.'; + + @override + String get locksUnknownEntry => 'Não existe nesta versão do jogo'; + + @override + String get locksDoorLeafHint => + 'Voltar a trancar uma porta também fecha a própria porta.'; + + @override + String get locksResetPending => + 'Descartar as alterações de fechaduras pendentes'; } /// The translations for Portuguese, as used in Brazil (`pt_BR`). @@ -3276,7 +3343,7 @@ class AppLocalizationsPtBr extends AppLocalizationsPt { @override String get storyStateDescription => - 'Catálogo oficial dos estados persistentes declarados pelos scripts fornecidos com o jogo. As entradas salvas mostram o valor bruto; os campos do catálogo ausentes deste save são marcados como não definidos. Os marcadores de tempo declarados no código são exibidos como tempo de jogo; os demais inteiros podem ser booleanos, contadores ou estados com vários níveis.'; + 'O jogo registra aqui o progresso de missões, diálogos e eventos. “Salvo” mostra os valores do seu jogo salvo; “Não definido”, as demais entradas conhecidas. Dependendo da entrada, um número pode indicar sim/não, uma contagem ou uma etapa de progresso. Os marcadores de tempo mostram um dia e uma hora no jogo.'; @override String get storyStateReadOnly => @@ -3306,6 +3373,11 @@ class AppLocalizationsPtBr extends AppLocalizationsPt { @override String get storyStateUnknown => 'Tipo de origem desconhecido'; + @override + String storyStateShowDormant(int count) { + return 'Mostrar não utilizados ($count)'; + } + @override String get storyStateUnknownDetail => 'Este ID salvo não consta no catálogo de scripts atual (por exemplo, por um mod ou uma versão mais recente do jogo). O valor serializado é int32, mas seu significado não é inferido.'; @@ -3352,7 +3424,7 @@ class AppLocalizationsPtBr extends AppLocalizationsPt { @override String get storyStateEditingGuidance => - 'Todos os itens podem ser editados em todo o intervalo int32 com sinal. Os indicadores e as sugestões de valores baseados nos scripts servem apenas como orientação; a entrada do valor bruto está sempre disponível. As alterações no estado da história podem pular transições de diálogos, missões ou do mundo, portanto salve-as com cuidado. Um backup é criado automaticamente.'; + 'Selecione uma entrada para alterar seu valor. As alterações são aplicadas quando você salva. Altere apenas valores cujos efeitos conheça: caso contrário, missões ou diálogos podem deixar de funcionar como esperado. Ao salvar, uma cópia de segurança é criada automaticamente.'; @override String get storyStatePending => 'Pendente'; @@ -6498,4 +6570,66 @@ class AppLocalizationsPtBr extends AppLocalizationsPt { ) { return 'Nível $level, $guild, capítulo $chapter. $completed missões concluídas, $failed falharam. Tempo de jogo: $playTime.'; } + + @override + String get locksSidebar => 'Fechaduras'; + + @override + String editorLockListFailed(String details) { + return 'Não foi possível carregar a lista de fechaduras: $details'; + } + + @override + String get locksSearchHint => 'Procurar fechadura ou chave'; + + @override + String get locksAllRegions => 'Todas as regiões'; + + @override + String locksShownOfTotal(int shown, int total) { + return '$shown de $total'; + } + + @override + String get locksFilterChests => 'Baús'; + + @override + String get locksFilterDoors => 'Portas'; + + @override + String get locksFilterUnlocked => 'Destrancada'; + + @override + String get locksFilterLocked => 'Trancada'; + + @override + String locksDifficultyLevel(int bars, int level) { + return 'Dificuldade $bars de 4 (nível interno $level de 7)'; + } + + @override + String get locksKeyOnly => 'Só com chave'; + + @override + String locksKeyLabel(String keys) { + return 'Chave: $keys'; + } + + @override + String get locksPermalocked => 'Selada permanentemente'; + + @override + String get locksReadOnly => + 'Este save não tem uma lista de fechaduras editável.'; + + @override + String get locksUnknownEntry => 'Não existe nesta versão do jogo'; + + @override + String get locksDoorLeafHint => + 'Voltar a trancar uma porta também fecha a própria porta.'; + + @override + String get locksResetPending => + 'Descartar as alterações de fechaduras pendentes'; } diff --git a/apps/save-editor/lib/l10n/app_localizations_ru.dart b/apps/save-editor/lib/l10n/app_localizations_ru.dart index fad8371d5..8098c0999 100644 --- a/apps/save-editor/lib/l10n/app_localizations_ru.dart +++ b/apps/save-editor/lib/l10n/app_localizations_ru.dart @@ -27,7 +27,7 @@ class AppLocalizationsRu extends AppLocalizations { @override String get storyStateDescription => - 'Авторитетный каталог постоянных состояний сюжета, объявленных в поставляемых с игрой скриптах. Сохранённые записи показывают исходное значение; отсутствующие в сохранении поля каталога отмечены как незаданные. Метки времени, объявленные в коде, показываются как игровое время; остальные целые числа могут быть логическими значениями, счётчиками или многоуровневыми состояниями.'; + 'Здесь игра хранит прогресс заданий, диалогов и событий. «Сохранено» показывает значения из вашего сохранения, а «Не задано» — остальные известные записи. В зависимости от записи число может означать да/нет, счётчик или этап прохождения. Метки времени показывают день и время в игре.'; @override String get storyStateReadOnly => @@ -57,6 +57,11 @@ class AppLocalizationsRu extends AppLocalizations { @override String get storyStateUnknown => 'Неизвестный исходный тип'; + @override + String storyStateShowDormant(int count) { + return 'Показать неиспользуемые ($count)'; + } + @override String get storyStateUnknownDetail => 'Этого сохранённого ID нет в текущем каталоге скриптов (например, он добавлен модом или новой версией игры). В сохранении значение имеет формат int32, но его смысл не угадывается.'; @@ -105,7 +110,7 @@ class AppLocalizationsRu extends AppLocalizations { @override String get storyStateEditingGuidance => - 'Каждую запись можно редактировать во всём диапазоне знакового int32. Флаги и предлагаемые значения, основанные на скриптах, служат лишь подсказками; исходное значение всегда можно ввести вручную. Изменение состояния сюжета может пропустить переходы диалогов, заданий или мира, поэтому сохраняйте такие правки осознанно. Резервная копия создаётся автоматически.'; + 'Выберите запись, чтобы изменить её значение. Изменения применяются при сохранении. Меняйте только значения, действие которых вам понятно: иначе задания или диалоги могут перестать работать как ожидается. При сохранении автоматически создаётся резервная копия.'; @override String get storyStatePending => 'Ожидает'; @@ -3262,4 +3267,65 @@ class AppLocalizationsRu extends AppLocalizations { ) { return 'Уровень $level, $guild, глава $chapter. Выполнено заданий: $completed, провалено: $failed. Время игры: $playTime.'; } + + @override + String get locksSidebar => 'Замки'; + + @override + String editorLockListFailed(String details) { + return 'Не удалось загрузить список замков: $details'; + } + + @override + String get locksSearchHint => 'Поиск замка или ключа'; + + @override + String get locksAllRegions => 'Все регионы'; + + @override + String locksShownOfTotal(int shown, int total) { + return '$shown из $total'; + } + + @override + String get locksFilterChests => 'Сундуки'; + + @override + String get locksFilterDoors => 'Двери'; + + @override + String get locksFilterUnlocked => 'Открыт'; + + @override + String get locksFilterLocked => 'Заперт'; + + @override + String locksDifficultyLevel(int bars, int level) { + return 'Сложность $bars из 4 (внутренний уровень $level из 7)'; + } + + @override + String get locksKeyOnly => 'Только ключом'; + + @override + String locksKeyLabel(String keys) { + return 'Ключ: $keys'; + } + + @override + String get locksPermalocked => 'Запечатано навсегда'; + + @override + String get locksReadOnly => + 'В этом сохранении нет редактируемого списка замков.'; + + @override + String get locksUnknownEntry => 'Отсутствует в этой версии игры'; + + @override + String get locksDoorLeafHint => + 'Повторное запирание двери также закрывает саму дверь.'; + + @override + String get locksResetPending => 'Отменить несохранённые изменения замков'; } diff --git a/apps/save-editor/lib/l10n/app_localizations_zh.dart b/apps/save-editor/lib/l10n/app_localizations_zh.dart index 902676643..586942f93 100644 --- a/apps/save-editor/lib/l10n/app_localizations_zh.dart +++ b/apps/save-editor/lib/l10n/app_localizations_zh.dart @@ -25,7 +25,7 @@ class AppLocalizationsZh extends AppLocalizations { @override String get storyStateDescription => - '游戏随附脚本中声明的持久剧情状态权威目录。已保存条目显示原始值;此存档中缺少的目录字段会标记为未设置。源码声明的时间标记会格式化为游戏时间,其他整数可能表示布尔值、计数器或多阶段状态。'; + '游戏在这里记录任务、对话和事件的进度。“已保存”显示存档中的数值,“未设置”显示其他已知条目。根据条目的不同,数字可能表示是或否、次数或进度阶段。时间标记表示游戏内的日期和时间。'; @override String get storyStateReadOnly => @@ -55,6 +55,11 @@ class AppLocalizationsZh extends AppLocalizations { @override String get storyStateUnknown => '未知源码类型'; + @override + String storyStateShowDormant(int count) { + return '显示未使用($count)'; + } + @override String get storyStateUnknownDetail => '当前脚本目录中没有此已保存 ID(例如来自模组或更新的游戏版本)。其存档线值为 int32,但不会推断其含义。'; @@ -94,7 +99,7 @@ class AppLocalizationsZh extends AppLocalizations { @override String get storyStateEditingGuidance => - '所有条目在整个有符号 int32 范围内都可编辑。根据脚本确定的标志和建议值仅供参考;始终可以直接输入原始值。更改剧情状态可能会跳过对话、任务或世界的正常转换,因此请谨慎保存。系统会自动创建备份。'; + '选择条目即可修改数值,修改会在保存后生效。请只修改你了解其作用的数值,否则任务或对话可能无法按预期进行。保存时会自动创建备份。'; @override String get storyStatePending => '待处理'; @@ -3107,6 +3112,65 @@ class AppLocalizationsZh extends AppLocalizations { ) { return '等级$level,$guild,第$chapter章。完成任务$completed个,失败$failed个。游玩时间:$playTime。'; } + + @override + String get locksSidebar => '锁'; + + @override + String editorLockListFailed(String details) { + return '无法加载锁列表:$details'; + } + + @override + String get locksSearchHint => '搜索锁或钥匙'; + + @override + String get locksAllRegions => '所有区域'; + + @override + String locksShownOfTotal(int shown, int total) { + return '$total 个中的 $shown 个'; + } + + @override + String get locksFilterChests => '箱子'; + + @override + String get locksFilterDoors => '门'; + + @override + String get locksFilterUnlocked => '已开启'; + + @override + String get locksFilterLocked => '已上锁'; + + @override + String locksDifficultyLevel(int bars, int level) { + return '难度 $bars/4(内部等级 $level/7)'; + } + + @override + String get locksKeyOnly => '仅限钥匙'; + + @override + String locksKeyLabel(String keys) { + return '钥匙:$keys'; + } + + @override + String get locksPermalocked => '永久封闭'; + + @override + String get locksReadOnly => '此存档没有可编辑的锁列表。'; + + @override + String get locksUnknownEntry => '此游戏版本中不存在'; + + @override + String get locksDoorLeafHint => '重新锁上门时,门本身也会关闭。'; + + @override + String get locksResetPending => '放弃待保存的锁改动'; } /// The translations for Chinese, using the Han script (`zh_Hans`). @@ -3130,7 +3194,7 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { @override String get storyStateDescription => - '游戏随附脚本中声明的持久剧情状态权威目录。已保存条目显示原始值;此存档中缺少的目录字段会标记为未设置。源码声明的时间标记会格式化为游戏时间,其他整数可能表示布尔值、计数器或多阶段状态。'; + '游戏在这里记录任务、对话和事件的进度。“已保存”显示存档中的数值,“未设置”显示其他已知条目。根据条目的不同,数字可能表示是或否、次数或进度阶段。时间标记表示游戏内的日期和时间。'; @override String get storyStateReadOnly => @@ -3160,6 +3224,11 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { @override String get storyStateUnknown => '未知源码类型'; + @override + String storyStateShowDormant(int count) { + return '显示未使用($count)'; + } + @override String get storyStateUnknownDetail => '当前脚本目录中没有此已保存 ID(例如来自模组或更新的游戏版本)。其存档线值为 int32,但不会推断其含义。'; @@ -3199,7 +3268,7 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { @override String get storyStateEditingGuidance => - '所有条目在整个有符号 int32 范围内都可编辑。根据脚本确定的标志和建议值仅供参考;始终可以直接输入原始值。更改剧情状态可能会跳过对话、任务或世界的正常转换,因此请谨慎保存。系统会自动创建备份。'; + '选择条目即可修改数值,修改会在保存后生效。请只修改你了解其作用的数值,否则任务或对话可能无法按预期进行。保存时会自动创建备份。'; @override String get storyStatePending => '待处理'; @@ -6212,4 +6281,63 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { ) { return '等级$level,$guild,第$chapter章。完成任务$completed个,失败$failed个。游玩时间:$playTime。'; } + + @override + String get locksSidebar => '锁'; + + @override + String editorLockListFailed(String details) { + return '无法加载锁列表:$details'; + } + + @override + String get locksSearchHint => '搜索锁或钥匙'; + + @override + String get locksAllRegions => '所有区域'; + + @override + String locksShownOfTotal(int shown, int total) { + return '$total 个中的 $shown 个'; + } + + @override + String get locksFilterChests => '箱子'; + + @override + String get locksFilterDoors => '门'; + + @override + String get locksFilterUnlocked => '已开启'; + + @override + String get locksFilterLocked => '已上锁'; + + @override + String locksDifficultyLevel(int bars, int level) { + return '难度 $bars/4(内部等级 $level/7)'; + } + + @override + String get locksKeyOnly => '仅限钥匙'; + + @override + String locksKeyLabel(String keys) { + return '钥匙:$keys'; + } + + @override + String get locksPermalocked => '永久封闭'; + + @override + String get locksReadOnly => '此存档没有可编辑的锁列表。'; + + @override + String get locksUnknownEntry => '此游戏版本中不存在'; + + @override + String get locksDoorLeafHint => '重新锁上门时,门本身也会关闭。'; + + @override + String get locksResetPending => '放弃待保存的锁改动'; } diff --git a/apps/save-editor/lib/l10n/app_pl.arb b/apps/save-editor/lib/l10n/app_pl.arb index 017428965..0ed5e7fad 100644 --- a/apps/save-editor/lib/l10n/app_pl.arb +++ b/apps/save-editor/lib/l10n/app_pl.arb @@ -3,7 +3,7 @@ "debugSectionSubtitle": "Diagnostyka i surowe dane do zgłoszeń błędów", "@@locale": "pl", "storyStateSidebar": "Stan fabuły", - "storyStateDescription": "Autorytatywny katalog trwałych stanów fabuły zadeklarowanych w skryptach dostarczonych z grą. Zapisane wpisy pokazują surową wartość; pola katalogu nieobecne w tym zapisie są oznaczone jako nieustawione. Znaczniki czasu zadeklarowane w kodzie są przedstawiane jako czas gry, a pozostałe liczby całkowite mogą być wartościami logicznymi, licznikami albo stanami wielopoziomowymi.", + "storyStateDescription": "Gra zapisuje tu postępy zadań, dialogów i wydarzeń. „Zapisane” pokazuje wartości z twojego zapisu gry, a „Nieustawione” — pozostałe znane wpisy. Zależnie od wpisu liczba może oznaczać tak/nie, licznik lub etap postępu. Znaczniki czasu pokazują dzień i godzinę w grze.", "storyStateReadOnly": "Tylko do odczytu, dopóki nie zostanie potwierdzone znaczenie wartości w skryptach i bezpieczny zapis mapy. Powiązany tekst glosariusza jest kontekstem, a nie bezpośrednim tłumaczeniem technicznego ID.", "storyStateStructureReadOnly": "Nie udało się jednoznacznie i bezpiecznie ustalić struktury StoryPropertyValues w tym zapisie. Wartości fabuły pozostają w tym zapisie tylko do odczytu.", "storyStateSearch": "Szukaj w stanie fabuły", @@ -12,6 +12,7 @@ "storyStateTimeMarker": "Znacznik czasu", "storyStateChapter": "Rozdział", "storyStateUnknown": "Nieznany typ źródłowy", + "storyStateShowDormant": "Pokaż nieużywane ({count})", "storyStateUnknownDetail": "Tego zapisanego ID nie ma w bieżącym katalogu skryptów (np. pochodzi z moda lub nowszej wersji gry). Wartość w zapisie ma format int32, ale jej znaczenie nie jest zgadywane.", "storyStateStored": "Zapisane", "storyStateUnset": "Nieustawione", @@ -22,7 +23,7 @@ "storyStateDurationDays": "{days, plural, =1{1 dzień} few{{days} dni} many{{days} dni} other{{days} dnia}} {time}", "storyStateRelatedGlossary": "Powiązany wpis glosariusza", "storyStateTechnicalPath": "Ścieżka techniczna", - "storyStateEditingGuidance": "Każdy wpis można edytować w pełnym zakresie wartości int32 ze znakiem. Flagi i sugerowane wartości oparte na skryptach są jedynie wskazówkami; zawsze można wprowadzić surową wartość. Zmiany stanu fabuły mogą pominąć przejścia dialogów, zadań lub świata, dlatego zapisuj je rozważnie. Kopia zapasowa zostanie utworzona automatycznie.", + "storyStateEditingGuidance": "Wybierz wpis, aby zmienić jego wartość. Zmiany zostaną zastosowane po zapisaniu. Zmieniaj tylko wartości, których działanie rozumiesz: inaczej zadania lub dialogi mogą przestać działać zgodnie z oczekiwaniami. Podczas zapisywania automatycznie powstaje kopia zapasowa.", "storyStatePending": "Oczekuje", "storyStatePendingValue": "Zostanie zapisane jako {value}", "@storyStatePendingValue": {"placeholders": {"value": {"type": "String"}}}, @@ -814,5 +815,22 @@ "statisticsUnknown": "Niedostępne", "statisticsMore": "Więcej statystyk", "statisticsSummary": "Poziom {level}, {guild}, rozdział {chapter}. Ukończone zadania: {completed}, nieudane: {failed}. Czas gry: {playTime}.", - "@statisticsSummary": {"placeholders": {"level": {"type": "String"}, "guild": {"type": "String"}, "chapter": {"type": "String"}, "completed": {"type": "int"}, "failed": {"type": "int"}, "playTime": {"type": "String"}}} + "@statisticsSummary": {"placeholders": {"level": {"type": "String"}, "guild": {"type": "String"}, "chapter": {"type": "String"}, "completed": {"type": "int"}, "failed": {"type": "int"}, "playTime": {"type": "String"}}}, + "locksSidebar": "Zamki", + "editorLockListFailed": "Nie udało się wczytać listy zamków: {details}", + "locksSearchHint": "Szukaj zamka lub klucza", + "locksAllRegions": "Wszystkie regiony", + "locksShownOfTotal": "{shown} z {total}", + "locksFilterChests": "Skrzynie", + "locksFilterDoors": "Drzwi", + "locksFilterUnlocked": "Otwarty", + "locksFilterLocked": "Zamknięty", + "locksDifficultyLevel": "Trudność {bars} z 4 (wewnętrzny stopień {level} z 7)", + "locksKeyOnly": "Tylko kluczem", + "locksKeyLabel": "Klucz: {keys}", + "locksPermalocked": "Zapieczętowane na stałe", + "locksReadOnly": "Ten zapis nie ma edytowalnej listy zamków.", + "locksUnknownEntry": "Brak w tej wersji gry", + "locksDoorLeafHint": "Ponowne zamknięcie drzwi zamyka także same drzwi.", + "locksResetPending": "Odrzuć oczekujące zmiany zamków" } diff --git a/apps/save-editor/lib/l10n/app_pt.arb b/apps/save-editor/lib/l10n/app_pt.arb index 18c59cafb..f918dee51 100644 --- a/apps/save-editor/lib/l10n/app_pt.arb +++ b/apps/save-editor/lib/l10n/app_pt.arb @@ -3,7 +3,7 @@ "debugSectionSubtitle": "Diagnóstico e dados brutos para relatórios de bugs", "@@locale": "pt", "storyStateSidebar": "Estado da história", - "storyStateDescription": "Catálogo de referência dos estados persistentes declarados pelos scripts fornecidos com o jogo. As entradas guardadas mostram o valor bruto; os campos do catálogo ausentes deste jogo guardado são marcados como não definidos. Os marcadores temporais declarados no código são apresentados como tempo de jogo; os restantes inteiros podem ser booleanos, contadores ou estados de vários níveis.", + "storyStateDescription": "O jogo regista aqui o progresso de missões, diálogos e eventos. «Guardado» mostra os valores da tua gravação; «Não definido», as restantes entradas conhecidas. Consoante a entrada, um número pode indicar sim/não, uma contagem ou uma etapa de progresso. Os marcadores de tempo mostram um dia e uma hora no jogo.", "storyStateReadOnly": "Só de leitura até serem conhecidos o significado dos valores nos scripts e uma escrita segura do mapa. O texto de glossário relacionado fornece contexto; não é uma tradução direta do ID técnico.", "storyStateStructureReadOnly": "Não foi possível identificar de forma inequívoca e segura a estrutura StoryPropertyValues deste jogo guardado. Os valores da história permanecem só de leitura para este jogo guardado.", "storyStateSearch": "Pesquisar estado da história", @@ -12,6 +12,7 @@ "storyStateTimeMarker": "Marcador temporal", "storyStateChapter": "Capítulo", "storyStateUnknown": "Tipo de origem desconhecido", + "storyStateShowDormant": "Mostrar não utilizados ({count})", "storyStateUnknownDetail": "Este ID guardado não consta do catálogo de scripts atual (por exemplo, por um mod ou uma versão mais recente do jogo). O valor serializado é int32, mas o seu significado não é inferido.", "storyStateStored": "Guardado", "storyStateUnset": "Não definido", @@ -22,7 +23,7 @@ "storyStateDurationDays": "{days, plural, =1{1 dia} other{{days} dias}} {time}", "storyStateRelatedGlossary": "Entrada de glossário relacionada", "storyStateTechnicalPath": "Caminho técnico", - "storyStateEditingGuidance": "Todas as entradas podem ser editadas em todo o intervalo int32 com sinal. Os indicadores e as sugestões de valores baseados nos scripts servem apenas de orientação; a introdução do valor bruto está sempre disponível. As alterações ao estado da história podem ignorar transições de diálogos, missões ou do mundo, por isso guarda-as com cuidado. É criada automaticamente uma cópia de segurança.", + "storyStateEditingGuidance": "Seleciona uma entrada para alterar o seu valor. As alterações são aplicadas quando guardas. Altera apenas valores cujos efeitos conheças: caso contrário, as missões ou os diálogos podem deixar de funcionar como esperado. Ao guardar, é criada automaticamente uma cópia de segurança.", "storyStatePending": "Pendente", "storyStatePendingValue": "Será guardado como {value}", "@storyStatePendingValue": {"placeholders": {"value": {"type": "String"}}}, @@ -814,5 +815,22 @@ "statisticsUnknown": "Indisponível", "statisticsMore": "Mais estatísticas", "statisticsSummary": "Nível {level}, {guild}, capítulo {chapter}. {completed} missões concluídas, {failed} falhadas. Tempo de jogo: {playTime}.", - "@statisticsSummary": {"placeholders": {"level": {"type": "String"}, "guild": {"type": "String"}, "chapter": {"type": "String"}, "completed": {"type": "int"}, "failed": {"type": "int"}, "playTime": {"type": "String"}}} + "@statisticsSummary": {"placeholders": {"level": {"type": "String"}, "guild": {"type": "String"}, "chapter": {"type": "String"}, "completed": {"type": "int"}, "failed": {"type": "int"}, "playTime": {"type": "String"}}}, + "locksSidebar": "Fechaduras", + "editorLockListFailed": "Não foi possível carregar a lista de fechaduras: {details}", + "locksSearchHint": "Procurar fechadura ou chave", + "locksAllRegions": "Todas as regiões", + "locksShownOfTotal": "{shown} de {total}", + "locksFilterChests": "Baús", + "locksFilterDoors": "Portas", + "locksFilterUnlocked": "Destrancada", + "locksFilterLocked": "Trancada", + "locksDifficultyLevel": "Dificuldade {bars} de 4 (nível interno {level} de 7)", + "locksKeyOnly": "Só com chave", + "locksKeyLabel": "Chave: {keys}", + "locksPermalocked": "Selada permanentemente", + "locksReadOnly": "Este save não tem uma lista de fechaduras editável.", + "locksUnknownEntry": "Não existe nesta versão do jogo", + "locksDoorLeafHint": "Voltar a trancar uma porta também fecha a própria porta.", + "locksResetPending": "Descartar as alterações de fechaduras pendentes" } diff --git a/apps/save-editor/lib/l10n/app_pt_BR.arb b/apps/save-editor/lib/l10n/app_pt_BR.arb index 67ed1e585..4587785fd 100644 --- a/apps/save-editor/lib/l10n/app_pt_BR.arb +++ b/apps/save-editor/lib/l10n/app_pt_BR.arb @@ -3,7 +3,7 @@ "debugSectionSubtitle": "Diagnóstico e dados brutos para relatórios de bugs", "@@locale": "pt_BR", "storyStateSidebar": "Estado da história", - "storyStateDescription": "Catálogo oficial dos estados persistentes declarados pelos scripts fornecidos com o jogo. As entradas salvas mostram o valor bruto; os campos do catálogo ausentes deste save são marcados como não definidos. Os marcadores de tempo declarados no código são exibidos como tempo de jogo; os demais inteiros podem ser booleanos, contadores ou estados com vários níveis.", + "storyStateDescription": "O jogo registra aqui o progresso de missões, diálogos e eventos. “Salvo” mostra os valores do seu jogo salvo; “Não definido”, as demais entradas conhecidas. Dependendo da entrada, um número pode indicar sim/não, uma contagem ou uma etapa de progresso. Os marcadores de tempo mostram um dia e uma hora no jogo.", "storyStateReadOnly": "Somente leitura até que o significado dos valores nos scripts e uma gravação segura do mapa sejam conhecidos. O texto relacionado do glossário fornece contexto; não é uma tradução direta do ID técnico.", "storyStateStructureReadOnly": "Não foi possível identificar de forma inequívoca e segura a estrutura StoryPropertyValues deste save. Os valores da história permanecerão somente para leitura neste save.", "storyStateSearch": "Pesquisar estado da história", @@ -12,6 +12,7 @@ "storyStateTimeMarker": "Marcador de tempo", "storyStateChapter": "Capítulo", "storyStateUnknown": "Tipo de origem desconhecido", + "storyStateShowDormant": "Mostrar não utilizados ({count})", "storyStateUnknownDetail": "Este ID salvo não consta no catálogo de scripts atual (por exemplo, por um mod ou uma versão mais recente do jogo). O valor serializado é int32, mas seu significado não é inferido.", "storyStateStored": "Salvo", "storyStateUnset": "Não definido", @@ -22,7 +23,7 @@ "storyStateDurationDays": "{days, plural, =1{1 dia} other{{days} dias}} {time}", "storyStateRelatedGlossary": "Entrada relacionada do glossário", "storyStateTechnicalPath": "Caminho técnico", - "storyStateEditingGuidance": "Todos os itens podem ser editados em todo o intervalo int32 com sinal. Os indicadores e as sugestões de valores baseados nos scripts servem apenas como orientação; a entrada do valor bruto está sempre disponível. As alterações no estado da história podem pular transições de diálogos, missões ou do mundo, portanto salve-as com cuidado. Um backup é criado automaticamente.", + "storyStateEditingGuidance": "Selecione uma entrada para alterar seu valor. As alterações são aplicadas quando você salva. Altere apenas valores cujos efeitos conheça: caso contrário, missões ou diálogos podem deixar de funcionar como esperado. Ao salvar, uma cópia de segurança é criada automaticamente.", "storyStatePending": "Pendente", "storyStatePendingValue": "Será salvo como {value}", "@storyStatePendingValue": {"placeholders": {"value": {"type": "String"}}}, @@ -814,5 +815,22 @@ "statisticsUnknown": "Indisponível", "statisticsMore": "Mais estatísticas", "statisticsSummary": "Nível {level}, {guild}, capítulo {chapter}. {completed} missões concluídas, {failed} falharam. Tempo de jogo: {playTime}.", - "@statisticsSummary": {"placeholders": {"level": {"type": "String"}, "guild": {"type": "String"}, "chapter": {"type": "String"}, "completed": {"type": "int"}, "failed": {"type": "int"}, "playTime": {"type": "String"}}} + "@statisticsSummary": {"placeholders": {"level": {"type": "String"}, "guild": {"type": "String"}, "chapter": {"type": "String"}, "completed": {"type": "int"}, "failed": {"type": "int"}, "playTime": {"type": "String"}}}, + "locksSidebar": "Fechaduras", + "editorLockListFailed": "Não foi possível carregar a lista de fechaduras: {details}", + "locksSearchHint": "Procurar fechadura ou chave", + "locksAllRegions": "Todas as regiões", + "locksShownOfTotal": "{shown} de {total}", + "locksFilterChests": "Baús", + "locksFilterDoors": "Portas", + "locksFilterUnlocked": "Destrancada", + "locksFilterLocked": "Trancada", + "locksDifficultyLevel": "Dificuldade {bars} de 4 (nível interno {level} de 7)", + "locksKeyOnly": "Só com chave", + "locksKeyLabel": "Chave: {keys}", + "locksPermalocked": "Selada permanentemente", + "locksReadOnly": "Este save não tem uma lista de fechaduras editável.", + "locksUnknownEntry": "Não existe nesta versão do jogo", + "locksDoorLeafHint": "Voltar a trancar uma porta também fecha a própria porta.", + "locksResetPending": "Descartar as alterações de fechaduras pendentes" } diff --git a/apps/save-editor/lib/l10n/app_ru.arb b/apps/save-editor/lib/l10n/app_ru.arb index 471db902e..2095b5434 100644 --- a/apps/save-editor/lib/l10n/app_ru.arb +++ b/apps/save-editor/lib/l10n/app_ru.arb @@ -3,7 +3,7 @@ "debugSectionSubtitle": "Диагностика и необработанные данные для отчётов об ошибках", "@@locale": "ru", "storyStateSidebar": "Состояние сюжета", - "storyStateDescription": "Авторитетный каталог постоянных состояний сюжета, объявленных в поставляемых с игрой скриптах. Сохранённые записи показывают исходное значение; отсутствующие в сохранении поля каталога отмечены как незаданные. Метки времени, объявленные в коде, показываются как игровое время; остальные целые числа могут быть логическими значениями, счётчиками или многоуровневыми состояниями.", + "storyStateDescription": "Здесь игра хранит прогресс заданий, диалогов и событий. «Сохранено» показывает значения из вашего сохранения, а «Не задано» — остальные известные записи. В зависимости от записи число может означать да/нет, счётчик или этап прохождения. Метки времени показывают день и время в игре.", "storyStateReadOnly": "Только для чтения, пока не подтверждены смысл значений в скриптах и безопасная запись карты. Связанный текст глоссария даёт контекст, а не прямой перевод технического ID.", "storyStateStructureReadOnly": "Не удалось однозначно и безопасно определить структуру StoryPropertyValues в этом сохранении. Значения сюжета останутся доступными только для чтения в этом сохранении.", "storyStateSearch": "Поиск по состоянию сюжета", @@ -12,6 +12,7 @@ "storyStateTimeMarker": "Метка времени", "storyStateChapter": "Глава", "storyStateUnknown": "Неизвестный исходный тип", + "storyStateShowDormant": "Показать неиспользуемые ({count})", "storyStateUnknownDetail": "Этого сохранённого ID нет в текущем каталоге скриптов (например, он добавлен модом или новой версией игры). В сохранении значение имеет формат int32, но его смысл не угадывается.", "storyStateStored": "Сохранено", "storyStateUnset": "Не задано", @@ -22,7 +23,7 @@ "storyStateDurationDays": "{days, plural, =1{1 день} few{{days} дня} many{{days} дней} other{{days} дня}} {time}", "storyStateRelatedGlossary": "Связанная запись глоссария", "storyStateTechnicalPath": "Технический путь", - "storyStateEditingGuidance": "Каждую запись можно редактировать во всём диапазоне знакового int32. Флаги и предлагаемые значения, основанные на скриптах, служат лишь подсказками; исходное значение всегда можно ввести вручную. Изменение состояния сюжета может пропустить переходы диалогов, заданий или мира, поэтому сохраняйте такие правки осознанно. Резервная копия создаётся автоматически.", + "storyStateEditingGuidance": "Выберите запись, чтобы изменить её значение. Изменения применяются при сохранении. Меняйте только значения, действие которых вам понятно: иначе задания или диалоги могут перестать работать как ожидается. При сохранении автоматически создаётся резервная копия.", "storyStatePending": "Ожидает", "storyStatePendingValue": "Будет сохранено как {value}", "@storyStatePendingValue": {"placeholders": {"value": {"type": "String"}}}, @@ -814,5 +815,22 @@ "statisticsUnknown": "Недоступно", "statisticsMore": "Дополнительная статистика", "statisticsSummary": "Уровень {level}, {guild}, глава {chapter}. Выполнено заданий: {completed}, провалено: {failed}. Время игры: {playTime}.", - "@statisticsSummary": {"placeholders": {"level": {"type": "String"}, "guild": {"type": "String"}, "chapter": {"type": "String"}, "completed": {"type": "int"}, "failed": {"type": "int"}, "playTime": {"type": "String"}}} + "@statisticsSummary": {"placeholders": {"level": {"type": "String"}, "guild": {"type": "String"}, "chapter": {"type": "String"}, "completed": {"type": "int"}, "failed": {"type": "int"}, "playTime": {"type": "String"}}}, + "locksSidebar": "Замки", + "editorLockListFailed": "Не удалось загрузить список замков: {details}", + "locksSearchHint": "Поиск замка или ключа", + "locksAllRegions": "Все регионы", + "locksShownOfTotal": "{shown} из {total}", + "locksFilterChests": "Сундуки", + "locksFilterDoors": "Двери", + "locksFilterUnlocked": "Открыт", + "locksFilterLocked": "Заперт", + "locksDifficultyLevel": "Сложность {bars} из 4 (внутренний уровень {level} из 7)", + "locksKeyOnly": "Только ключом", + "locksKeyLabel": "Ключ: {keys}", + "locksPermalocked": "Запечатано навсегда", + "locksReadOnly": "В этом сохранении нет редактируемого списка замков.", + "locksUnknownEntry": "Отсутствует в этой версии игры", + "locksDoorLeafHint": "Повторное запирание двери также закрывает саму дверь.", + "locksResetPending": "Отменить несохранённые изменения замков" } diff --git a/apps/save-editor/lib/l10n/app_zh.arb b/apps/save-editor/lib/l10n/app_zh.arb index 7a335fde6..f83f70dbb 100644 --- a/apps/save-editor/lib/l10n/app_zh.arb +++ b/apps/save-editor/lib/l10n/app_zh.arb @@ -3,7 +3,7 @@ "debugSectionSubtitle": "用于错误报告的诊断和原始数据", "@@locale": "zh", "storyStateSidebar": "剧情状态", - "storyStateDescription": "游戏随附脚本中声明的持久剧情状态权威目录。已保存条目显示原始值;此存档中缺少的目录字段会标记为未设置。源码声明的时间标记会格式化为游戏时间,其他整数可能表示布尔值、计数器或多阶段状态。", + "storyStateDescription": "游戏在这里记录任务、对话和事件的进度。“已保存”显示存档中的数值,“未设置”显示其他已知条目。根据条目的不同,数字可能表示是或否、次数或进度阶段。时间标记表示游戏内的日期和时间。", "storyStateReadOnly": "在确认脚本含义和安全的映射写入方式前保持只读。关联的词条文本仅提供上下文,并非技术 ID 的直接翻译。", "storyStateStructureReadOnly": "无法唯一且安全地确定此存档中的 StoryPropertyValues 结构。此存档的剧情值将保持只读。", "storyStateSearch": "搜索剧情状态", @@ -12,6 +12,7 @@ "storyStateTimeMarker": "时间标记", "storyStateChapter": "章节", "storyStateUnknown": "未知源码类型", + "storyStateShowDormant": "显示未使用({count})", "storyStateUnknownDetail": "当前脚本目录中没有此已保存 ID(例如来自模组或更新的游戏版本)。其存档线值为 int32,但不会推断其含义。", "storyStateStored": "已保存", "storyStateUnset": "未设置", @@ -22,7 +23,7 @@ "storyStateDurationDays": "{days} 天 {time}", "storyStateRelatedGlossary": "关联词条", "storyStateTechnicalPath": "技术路径", - "storyStateEditingGuidance": "所有条目在整个有符号 int32 范围内都可编辑。根据脚本确定的标志和建议值仅供参考;始终可以直接输入原始值。更改剧情状态可能会跳过对话、任务或世界的正常转换,因此请谨慎保存。系统会自动创建备份。", + "storyStateEditingGuidance": "选择条目即可修改数值,修改会在保存后生效。请只修改你了解其作用的数值,否则任务或对话可能无法按预期进行。保存时会自动创建备份。", "storyStatePending": "待处理", "storyStatePendingValue": "将保存为 {value}", "@storyStatePendingValue": {"placeholders": {"value": {"type": "String"}}}, @@ -821,5 +822,22 @@ "statisticsUnknown": "不可用", "statisticsMore": "更多统计", "statisticsSummary": "等级{level},{guild},第{chapter}章。完成任务{completed}个,失败{failed}个。游玩时间:{playTime}。", - "@statisticsSummary": {"placeholders": {"level": {"type": "String"}, "guild": {"type": "String"}, "chapter": {"type": "String"}, "completed": {"type": "int"}, "failed": {"type": "int"}, "playTime": {"type": "String"}}} + "@statisticsSummary": {"placeholders": {"level": {"type": "String"}, "guild": {"type": "String"}, "chapter": {"type": "String"}, "completed": {"type": "int"}, "failed": {"type": "int"}, "playTime": {"type": "String"}}}, + "locksSidebar": "锁", + "editorLockListFailed": "无法加载锁列表:{details}", + "locksSearchHint": "搜索锁或钥匙", + "locksAllRegions": "所有区域", + "locksShownOfTotal": "{total} 个中的 {shown} 个", + "locksFilterChests": "箱子", + "locksFilterDoors": "门", + "locksFilterUnlocked": "已开启", + "locksFilterLocked": "已上锁", + "locksDifficultyLevel": "难度 {bars}/4(内部等级 {level}/7)", + "locksKeyOnly": "仅限钥匙", + "locksKeyLabel": "钥匙:{keys}", + "locksPermalocked": "永久封闭", + "locksReadOnly": "此存档没有可编辑的锁列表。", + "locksUnknownEntry": "此游戏版本中不存在", + "locksDoorLeafHint": "重新锁上门时,门本身也会关闭。", + "locksResetPending": "放弃待保存的锁改动" } diff --git a/apps/save-editor/lib/l10n/app_zh_Hans.arb b/apps/save-editor/lib/l10n/app_zh_Hans.arb index 26c3f739a..7d0acb1be 100644 --- a/apps/save-editor/lib/l10n/app_zh_Hans.arb +++ b/apps/save-editor/lib/l10n/app_zh_Hans.arb @@ -3,7 +3,7 @@ "debugSectionSubtitle": "用于错误报告的诊断和原始数据", "@@locale": "zh_Hans", "storyStateSidebar": "剧情状态", - "storyStateDescription": "游戏随附脚本中声明的持久剧情状态权威目录。已保存条目显示原始值;此存档中缺少的目录字段会标记为未设置。源码声明的时间标记会格式化为游戏时间,其他整数可能表示布尔值、计数器或多阶段状态。", + "storyStateDescription": "游戏在这里记录任务、对话和事件的进度。“已保存”显示存档中的数值,“未设置”显示其他已知条目。根据条目的不同,数字可能表示是或否、次数或进度阶段。时间标记表示游戏内的日期和时间。", "storyStateReadOnly": "在确认脚本含义和安全的映射写入方式前保持只读。关联的词条文本仅提供上下文,并非技术 ID 的直接翻译。", "storyStateStructureReadOnly": "无法唯一且安全地确定此存档中的 StoryPropertyValues 结构。此存档的剧情值将保持只读。", "storyStateSearch": "搜索剧情状态", @@ -12,6 +12,7 @@ "storyStateTimeMarker": "时间标记", "storyStateChapter": "章节", "storyStateUnknown": "未知源码类型", + "storyStateShowDormant": "显示未使用({count})", "storyStateUnknownDetail": "当前脚本目录中没有此已保存 ID(例如来自模组或更新的游戏版本)。其存档线值为 int32,但不会推断其含义。", "storyStateStored": "已保存", "storyStateUnset": "未设置", @@ -22,7 +23,7 @@ "storyStateDurationDays": "{days} 天 {time}", "storyStateRelatedGlossary": "关联词条", "storyStateTechnicalPath": "技术路径", - "storyStateEditingGuidance": "所有条目在整个有符号 int32 范围内都可编辑。根据脚本确定的标志和建议值仅供参考;始终可以直接输入原始值。更改剧情状态可能会跳过对话、任务或世界的正常转换,因此请谨慎保存。系统会自动创建备份。", + "storyStateEditingGuidance": "选择条目即可修改数值,修改会在保存后生效。请只修改你了解其作用的数值,否则任务或对话可能无法按预期进行。保存时会自动创建备份。", "storyStatePending": "待处理", "storyStatePendingValue": "将保存为 {value}", "@storyStatePendingValue": {"placeholders": {"value": {"type": "String"}}}, @@ -821,5 +822,22 @@ "statisticsUnknown": "不可用", "statisticsMore": "更多统计", "statisticsSummary": "等级{level},{guild},第{chapter}章。完成任务{completed}个,失败{failed}个。游玩时间:{playTime}。", - "@statisticsSummary": {"placeholders": {"level": {"type": "String"}, "guild": {"type": "String"}, "chapter": {"type": "String"}, "completed": {"type": "int"}, "failed": {"type": "int"}, "playTime": {"type": "String"}}} + "@statisticsSummary": {"placeholders": {"level": {"type": "String"}, "guild": {"type": "String"}, "chapter": {"type": "String"}, "completed": {"type": "int"}, "failed": {"type": "int"}, "playTime": {"type": "String"}}}, + "locksSidebar": "锁", + "editorLockListFailed": "无法加载锁列表:{details}", + "locksSearchHint": "搜索锁或钥匙", + "locksAllRegions": "所有区域", + "locksShownOfTotal": "{total} 个中的 {shown} 个", + "locksFilterChests": "箱子", + "locksFilterDoors": "门", + "locksFilterUnlocked": "已开启", + "locksFilterLocked": "已上锁", + "locksDifficultyLevel": "难度 {bars}/4(内部等级 {level}/7)", + "locksKeyOnly": "仅限钥匙", + "locksKeyLabel": "钥匙:{keys}", + "locksPermalocked": "永久封闭", + "locksReadOnly": "此存档没有可编辑的锁列表。", + "locksUnknownEntry": "此游戏版本中不存在", + "locksDoorLeafHint": "重新锁上门时,门本身也会关闭。", + "locksResetPending": "放弃待保存的锁改动" } diff --git a/apps/save-editor/pubspec.yaml b/apps/save-editor/pubspec.yaml index 265b8b79a..ea90d9a7c 100644 --- a/apps/save-editor/pubspec.yaml +++ b/apps/save-editor/pubspec.yaml @@ -67,6 +67,7 @@ flutter: - assets/npc_catalog.json - assets/knowledge_catalog.json - assets/location_catalog.json + - assets/lock_catalog.json - assets/glossary_images.json - assets/glossary_npc_catalog.json - assets/glossary_segment_text_catalog.json diff --git a/apps/save-editor/test/editor_notifier_test.dart b/apps/save-editor/test/editor_notifier_test.dart index f9b547502..4d2bd9c3e 100644 --- a/apps/save-editor/test/editor_notifier_test.dart +++ b/apps/save-editor/test/editor_notifier_test.dart @@ -3044,6 +3044,24 @@ void main() { ); }); + test('lock edits separate raw lock and door writes', () { + for (final unlocked in [true, false]) { + final lock = { + 'path': 'private.locks.setUnlocked', + 'value': {'lock': 'CV_Stash_Door', 'unlocked': unlocked}, + }; + expect(structuredEditRewrites(lock, const ['m_UnlockedLocks']), isTrue); + for (final property in [ + 'm_DoorsOpen', + 'm_DoorsClosed', + 'm_SavedDoorsMessagesStruct', + ]) { + expect(structuredEditRewrites(lock, [property]), !unlocked); + } + expect(structuredEditRewrites(lock, const ['m_Traders']), isFalse); + } + }); + test('addressing a whole map collides with every entry in it', () { final setEntry = { 'path': 'private.knowledge.setEntry', diff --git a/apps/save-editor/test/features/editor/domain/lock_catalog_test.dart b/apps/save-editor/test/features/editor/domain/lock_catalog_test.dart new file mode 100644 index 000000000..6a9868a5b --- /dev/null +++ b/apps/save-editor/test/features/editor/domain/lock_catalog_test.dart @@ -0,0 +1,77 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:goresave/features/editor/domain/lock_catalog.dart'; + +void main() { + group('difficulty bars', () { + // The game's lockpicking widget holds four Difficulty_1..4 images and + // fills each when the lock's raw tier reaches its own threshold. The + // thresholds are 1, 2, 4, 6, decompiled from the widget's SetLockDifficulty + // bytecode — so the seven internal tiers collapse onto four bars and the + // player never sees the raw number. + test('the seven tiers collapse onto the game\'s four bars', () { + expect(lockDifficultyBars(1), 1); + expect(lockDifficultyBars(2), 2); + expect(lockDifficultyBars(3), 2); + expect(lockDifficultyBars(4), 3); + expect(lockDifficultyBars(5), 3); + expect(lockDifficultyBars(6), 4); + expect(lockDifficultyBars(7), 4); + }); + + test('never exceeds the four pips the widget draws', () { + for (var tier = 1; tier <= 12; tier++) { + expect(lockDifficultyBars(tier), lessThanOrEqualTo(lockDifficultyBarCount)); + } + }); + }); + + group('catalog parsing', () { + test('reads a lock with every field', () { + final catalog = LockCatalog.fromJsonString(''' +{"version":1,"locks":[ + {"n":"IO_OC_CHEST_GOMEZ_01","k":"chest","a":"OC","d":7, + "l":"OC_Chest_Gomez_01_Lock","keys":["ItKe_Gomez_01"],"r":true} +]} +'''); + final lock = catalog.locks.single; + expect(lock.name, 'IO_OC_CHEST_GOMEZ_01'); + expect(lock.kind, LockKind.chest); + expect(lock.area, 'OC'); + expect(lock.difficulty, 7); + expect(lock.lockId, 'OC_Chest_Gomez_01_Lock'); + expect(lock.keys, ['ItKe_Gomez_01']); + expect(lock.randomized, isTrue); + }); + + test('a door without a difficulty keeps its keys', () { + final catalog = LockCatalog.fromJsonString(''' +{"version":1,"locks":[ + {"n":"AbandonedMine_Fence_Door","k":"door","a":"AM","keys":["ItKe_AbandonedMine_01"]} +]} +'''); + final lock = catalog.locks.single; + expect(lock.kind, LockKind.door); + expect(lock.difficulty, isNull, reason: 'key-only locks are not pickable'); + expect(lock.keys, ['ItKe_AbandonedMine_01']); + }); + + test('lookup folds case, like the FName set in a save', () { + final catalog = LockCatalog.fromJsonString( + '{"version":1,"locks":[{"n":"IO_OC_CHEST_DEXTER","k":"chest","a":"OC"}]}', + ); + expect(catalog.byName('io_oc_chest_dexter'), isNotNull); + expect(catalog.byName('nope'), isNull); + }); + + test('the search string covers the name and its keys', () { + final catalog = LockCatalog.fromJsonString(''' +{"version":1,"locks":[ + {"n":"IO_OM_CHEST_AARON","k":"chest","a":"OM","keys":["ItKe_Om_02"]} +]} +'''); + final search = catalog.locks.single.search; + expect(search, contains('aaron')); + expect(search, contains('itke_om_02')); + }); + }); +} diff --git a/apps/save-editor/test/features/editor/ui/locks_panel_test.dart b/apps/save-editor/test/features/editor/ui/locks_panel_test.dart new file mode 100644 index 000000000..704b13806 --- /dev/null +++ b/apps/save-editor/test/features/editor/ui/locks_panel_test.dart @@ -0,0 +1,595 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:goresave/features/app/domain/ui_settings.dart'; +import 'package:goresave/features/editor/domain/core_service.dart'; +import 'package:goresave/features/editor/domain/editor_notifier.dart'; +import 'package:goresave/features/editor/domain/location_catalog.dart'; +import 'package:goresave/features/editor/domain/lock_catalog.dart'; +import 'package:goresave/features/editor/ui/locks_panel.dart'; +import 'package:goresave/l10n/app_localizations.dart'; +import 'package:goresave/loc/loc_catalog_provider.dart'; +import 'package:goresave/providers/data_providers.dart'; + +import '../../../support/l10n_test_app.dart'; + +/// Minimal fake core: enough for an [EditorNotifier] to settle on a save, plus +/// a canned `private.locks.list` answer. +class _LocksCore implements GoresaveCoreService { + _LocksCore({this.unlocked = const [], this.writable = true}); + + final List unlocked; + final bool writable; + + @override + String get description => 'locks-fake'; + + @override + bool get isAvailable => true; + + @override + Future> execute( + String command, { + Map payload = const {}, + }) async { + switch (command) { + case 'scan_save_dir': + return { + 'ok': true, + 'data': { + 'saveRoot': payload['path'], + 'saves': [ + { + 'path': r'C:\tmp\saves\G1R-001.sav', + 'slot': 'G1R-001', + 'format': 'GSAV', + 'fileSize': 100, + 'sha1': 'a', + 'status': 'ok', + 'playerSaveName': 'Save A', + }, + ], + 'profiles': [], + }, + }; + case 'inspect_save': + return { + 'ok': true, + 'data': { + 'format': 'GSAV', + 'path': payload['path'], + 'slot': 'G1R-001', + 'size': 100, + 'sha1': 'a', + 'private': { + 'status': 'decoded', + 'progression': {'status': 'ok'}, + 'typedParse': {'status': 'ok'}, + }, + }, + }; + case 'list_backups': + return { + 'ok': true, + 'data': { + 'path': payload['path'], + 'backups': [], + 'companionBackups': [], + }, + }; + case 'check_codec': + return { + 'ok': true, + 'data': { + 'backend': 'kraken', + 'available': true, + 'canDecompress': true, + 'canCompress': true, + 'status': 'ready', + }, + }; + case 'private.locks.list': + return { + 'ok': true, + 'data': { + 'unlocked': unlocked, + 'writable': writable ? ['private.locks.setUnlocked'] : [], + }, + }; + default: + return { + 'ok': true, + 'data': { + 'total': 0, + 'offset': 0, + 'limit': 50, + 'characters': [], + 'entries': [], + }, + }; + } + } +} + +/// A stand-in catalog: two Old Camp locks and one New Camp door, enough to +/// exercise both filters and the region rail without the bundled asset. +LockCatalog _catalog() => LockCatalog.fromJsonString(''' +{"version":1,"locks":[ + {"n":"IO_OC_CHEST_DEXTER","k":"chest","a":"OC","d":3,"l":"OC_Chest_Dexter_Lock"}, + {"n":"IO_OC_CHEST_GOMEZ_01","k":"chest","a":"OC","d":7,"keys":["ItKe_Gomez_01"]}, + {"n":"NC_Cave_Tavern_Door","k":"door","a":"NC","d":4}, + {"n":"OC_Guards_Cell_01_Door","k":"door","a":"OC","l":"OC_Guards_Cell_01_Lock"} +]} +'''); + +/// Just the two areas the stub catalog uses, so the rail can be asserted +/// without reaching for the 884 KB bundled asset through `rootBundle`. +LocationCatalog _locations() => LocationCatalog.fromJsonString(''' +{"version":1, + "areas":[{"id":"OC","label":"Old Camp","locId":null}, + {"id":"NC","label":"New Camp","locId":null}], + "spots":[]} +'''); + +Future _notifier(WidgetTester tester, _LocksCore core) async { + final notifier = EditorNotifier(core, saveDir: r'C:\tmp\saves'); + addTearDown(() { + // Panels using the provider override let Riverpod dispose the notifier. + if (notifier.mounted) notifier.dispose(); + }); + // inspect() schedules real timers the widget-test clock will not advance. + await tester.runAsync(() async { + await notifier.inspect(r'C:\tmp\saves\G1R-001.sav'); + }); + return notifier; +} + +Widget _wrap(Widget child, {String locale = 'en'}) => ProviderScope( + child: MaterialApp( + locale: Locale(locale), + localizationsDelegates: testLocalizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold(body: SizedBox(width: 1100, height: 760, child: child)), + ), +); + +/// The panel loads through the notifier's core queue — a real Future the +/// widget-test clock does not advance on its own. +Future _settle(WidgetTester tester) async { + for (var i = 0; i < 12; i++) { + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 10)), + ); + await tester.pump(); + } +} + +/// The switch inside one lock row. The row is a ListTile whose trailing pairs +/// the state word with the switch, so the switch is addressed through the row. +Switch _switchOf(WidgetTester tester, String lock) => tester.widget( + find.descendant( + of: find.byKey(ValueKey('lock-$lock')), + matching: find.byType(Switch), + ), +); + +Future _panel( + WidgetTester tester, + EditorNotifier notifier, { + bool editable = true, +}) async { + final panel = LocksDetail( + notifier: notifier, + editable: editable, + reloadKey: notifier.state.inspection!, + theme: ThemeData.light(), + catalogOverride: _catalog(), + locationsOverride: _locations(), + ); + await tester.pumpWidget( + ProviderScope( + overrides: [editorProvider.overrideWith((ref) => notifier)], + child: _wrap(panel), + ), + ); + await _settle(tester); + return panel; +} + +void main() { + testWidgets('every lock is listed, locked and unlocked alike', ( + tester, + ) async { + final core = _LocksCore(unlocked: ['IO_OC_CHEST_DEXTER']); + await _panel(tester, await _notifier(tester, core)); + + // The save knows one of the three; the other two come from the catalog and + // would be invisible if the panel only read the save. + expect(find.text('IO_OC_CHEST_DEXTER'), findsOneWidget); + expect(find.text('IO_OC_CHEST_GOMEZ_01'), findsOneWidget); + expect(find.text('NC_Cave_Tavern_Door'), findsOneWidget); + expect(find.text('4 of 4'), findsOneWidget); + + expect(_switchOf(tester, 'IO_OC_CHEST_DEXTER').value, isTrue); + expect(_switchOf(tester, 'NC_Cave_Tavern_Door').value, isFalse); + + // The switch alone does not say which way is which: each row spells its + // state out beside it. + expect( + find.descendant( + of: find.byKey(const ValueKey('lock-IO_OC_CHEST_DEXTER')), + matching: find.text('Unlocked'), + ), + findsOneWidget, + ); + expect( + find.descendant( + of: find.byKey(const ValueKey('lock-NC_Cave_Tavern_Door')), + matching: find.text('Locked'), + ), + findsOneWidget, + ); + }); + + testWidgets('unlocking a lock queues one setUnlocked edit', (tester) async { + final core = _LocksCore(); + final notifier = await _notifier(tester, core); + await _panel(tester, notifier); + + await tester.tap(find.byKey(const ValueKey('lock-IO_OC_CHEST_GOMEZ_01'))); + await _settle(tester); + + final pending = notifier.pendingEditFor('world.locks'); + expect(pending, isNotNull); + expect(pending!.edits.single, { + 'path': 'private.locks.setUnlocked', + 'value': {'lock': 'IO_OC_CHEST_GOMEZ_01', 'unlocked': true}, + }); + }); + + testWidgets('locking an opened lock again queues the reverse edit', ( + tester, + ) async { + final core = _LocksCore(unlocked: ['NC_Cave_Tavern_Door']); + final notifier = await _notifier(tester, core); + await _panel(tester, notifier); + + await tester.tap(find.byKey(const ValueKey('lock-NC_Cave_Tavern_Door'))); + await _settle(tester); + + expect(notifier.pendingEditFor('world.locks')!.edits.single, { + 'path': 'private.locks.setUnlocked', + 'value': {'lock': 'NC_Cave_Tavern_Door', 'unlocked': false}, + }); + }); + + testWidgets('toggling back to the saved value drops the edit', ( + tester, + ) async { + final core = _LocksCore(); + final notifier = await _notifier(tester, core); + await _panel(tester, notifier); + + final row = find.byKey(const ValueKey('lock-IO_OC_CHEST_DEXTER')); + await tester.tap(row); + await _settle(tester); + expect(notifier.pendingEditFor('world.locks'), isNotNull); + + await tester.tap(row); + await _settle(tester); + expect( + notifier.pendingEditFor('world.locks'), + isNull, + reason: 'a second tap is an undo, not a second stacked edit', + ); + }); + + testWidgets('clearing drafts elsewhere resets the displayed lock state', ( + tester, + ) async { + final notifier = await _notifier(tester, _LocksCore()); + await _panel(tester, notifier); + await tester.tap(find.byKey(const ValueKey('lock-IO_OC_CHEST_DEXTER'))); + await _settle(tester); + expect(_switchOf(tester, 'IO_OC_CHEST_DEXTER').value, isTrue); + + notifier.clearPendingEdit(EditorNotifier.pendingLocksKey); + await _settle(tester); + expect(_switchOf(tester, 'IO_OC_CHEST_DEXTER').value, isFalse); + expect(find.byKey(const Key('locks-discard-pending')), findsNothing); + + await tester.tap(find.byKey(const ValueKey('lock-NC_Cave_Tavern_Door'))); + await _settle(tester); + expect( + notifier + .pendingEditFor(EditorNotifier.pendingLocksKey)! + .edits + .single['value'], + {'lock': 'NC_Cave_Tavern_Door', 'unlocked': true}, + ); + }); + + testWidgets('several toggles ride in one pending entry', (tester) async { + final core = _LocksCore(); + final notifier = await _notifier(tester, core); + await _panel(tester, notifier); + + await tester.tap(find.byKey(const ValueKey('lock-IO_OC_CHEST_DEXTER'))); + await _settle(tester); + await tester.tap(find.byKey(const ValueKey('lock-NC_Cave_Tavern_Door'))); + await _settle(tester); + + // setUnlocked is value-addressed, so the whole panel batches into one + // write instead of one write per row. + expect(notifier.pendingEditFor('world.locks')!.edits, hasLength(2)); + }); + + testWidgets('the region rail filters the list and counts each region', ( + tester, + ) async { + final core = _LocksCore(); + await _panel(tester, await _notifier(tester, core)); + + // Area codes resolve to the game's own region names. + expect(find.text('Old Camp'), findsOneWidget); + expect(find.text('New Camp'), findsOneWidget); + + await tester.tap(find.byKey(const ValueKey('locks-region-Old Camp'))); + await tester.pumpAndSettle(); + + expect(find.text('IO_OC_CHEST_DEXTER'), findsOneWidget); + expect(find.text('NC_Cave_Tavern_Door'), findsNothing); + expect(find.text('3 of 4'), findsOneWidget); + }); + + testWidgets('the kind filter separates chests from doors', (tester) async { + final core = _LocksCore(); + await _panel(tester, await _notifier(tester, core)); + + await tester.tap(find.byKey(const ValueKey('locks-kind-doors'))); + await tester.pumpAndSettle(); + + expect(find.text('NC_Cave_Tavern_Door'), findsOneWidget); + expect(find.text('IO_OC_CHEST_DEXTER'), findsNothing); + expect(find.text('2 of 4'), findsOneWidget); + }); + + testWidgets('the state filter separates opened from still-locked', ( + tester, + ) async { + final core = _LocksCore(unlocked: ['IO_OC_CHEST_DEXTER']); + await _panel(tester, await _notifier(tester, core)); + + await tester.tap(find.byKey(const ValueKey('locks-state-locked'))); + await tester.pumpAndSettle(); + + expect(find.text('IO_OC_CHEST_DEXTER'), findsNothing); + expect(find.text('IO_OC_CHEST_GOMEZ_01'), findsOneWidget); + expect(find.text('3 of 4'), findsOneWidget); + }); + + testWidgets('search matches the lock name and its key', (tester) async { + final core = _LocksCore(); + await _panel(tester, await _notifier(tester, core)); + + await tester.enterText(find.byKey(const Key('locks-search')), 'Gomez'); + await tester.pumpAndSettle(); + + expect(find.text('IO_OC_CHEST_GOMEZ_01'), findsOneWidget); + expect(find.text('IO_OC_CHEST_DEXTER'), findsNothing); + }); + + testWidgets('a key is searchable by its raw id AND its localized name', ( + tester, + ) async { + // The row shows the game's own item name while the catalog only knows the + // technical id. Both spellings have to reach the row, or the field promises + // a key search that fails on exactly what the reader can see. + // The localization is pinned here so the assertion does not depend on + // whether the machine running the test has extracted the game's strings. + final core = _LocksCore(); + final notifier = await _notifier(tester, core); + await tester.pumpWidget( + ProviderScope( + overrides: [ + locCatalogProvider.overrideWith( + (ref) async => { + 'itke_gomez_01': {'english': 'Ore Baron Key'}, + }, + ), + ], + child: MaterialApp( + locale: const Locale('en'), + localizationsDelegates: testLocalizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: SizedBox( + width: 1100, + height: 760, + child: LocksDetail( + notifier: notifier, + editable: true, + reloadKey: notifier.state.inspection!, + theme: ThemeData.light(), + catalogOverride: _catalog(), + locationsOverride: _locations(), + ), + ), + ), + ), + ), + ); + await _settle(tester); + + expect(find.textContaining('Ore Baron Key'), findsOneWidget); + + await tester.enterText( + find.byKey(const Key('locks-search')), + 'ItKe_Gomez_01', + ); + await tester.pumpAndSettle(); + expect(find.text('IO_OC_CHEST_GOMEZ_01'), findsOneWidget); + expect(find.text('1 of 4'), findsOneWidget); + + await tester.enterText(find.byKey(const Key('locks-search')), 'ore baron'); + await tester.pumpAndSettle(); + expect(find.text('IO_OC_CHEST_GOMEZ_01'), findsOneWidget); + expect(find.text('1 of 4'), findsOneWidget); + }); + + testWidgets('technical ids add the key id beside its name', (tester) async { + final core = _LocksCore(); + final notifier = await _notifier(tester, core); + await _panel(tester, notifier); + + // Off by default: the row names the key, nothing else. + expect(find.textContaining('ItKe_Gomez_01'), findsNothing); + + final container = ProviderScope.containerOf( + tester.element(find.byType(LocksDetail)), + ); + container.read(showObjectIdsProvider.notifier).set(true); + await _settle(tester); + + expect(find.textContaining('(ItKe_Gomez_01)'), findsOneWidget); + }); + + testWidgets('a truncated region name offers its full text on hover', ( + tester, + ) async { + final core = _LocksCore(); + final notifier = await _notifier(tester, core); + await tester.pumpWidget( + _wrap( + LocksDetail( + notifier: notifier, + editable: true, + reloadKey: notifier.state.inspection!, + theme: ThemeData.light(), + catalogOverride: _catalog(), + locationsOverride: LocationCatalog.fromJsonString(''' +{"version":1, + "areas":[{"id":"OC","label":"A name far too long for a two-hundred pixel rail to show","locId":null}, + {"id":"NC","label":"New Camp","locId":null}], + "spots":[]} +'''), + ), + ), + ); + await _settle(tester); + + // The cut label gets the tooltip; one that fits must not, or every row + // would sprout a hover that repeats what is already on screen. + expect( + find.byTooltip( + 'A name far too long for a two-hundred pixel rail to show', + ), + findsOneWidget, + ); + expect(find.byTooltip('New Camp'), findsNothing); + }); + + testWidgets('a lock with no authored tier still shows an empty meter', ( + tester, + ) async { + // Seven doors carry a lock config but no m_LockDifficulty. The int is then + // 0 and the game fills no pips — an empty meter is what it draws, so the + // row must not silently drop the fact that there is a lock at all. + final core = _LocksCore(); + await _panel(tester, await _notifier(tester, core)); + + final meter = find.descendant( + of: find.byKey(const ValueKey('lock-OC_Guards_Cell_01_Door')), + matching: find.byTooltip('Difficulty 0 of 4 (internal tier 0 of 7)'), + ); + expect(meter, findsOneWidget); + }); + + testWidgets('the difficulty tooltip names both scales', (tester) async { + // Tier 3 fills two of four pips; a tooltip claiming "3 of 7" beside two + // filled bars is the confusion this wording exists to remove. + final core = _LocksCore(); + await _panel(tester, await _notifier(tester, core)); + + expect( + find.descendant( + of: find.byKey(const ValueKey('lock-IO_OC_CHEST_DEXTER')), + matching: find.byTooltip('Difficulty 2 of 4 (internal tier 3 of 7)'), + ), + findsOneWidget, + ); + }); + + testWidgets('a name the catalog does not know is still shown', ( + tester, + ) async { + // The catalog is cook-specific: a save from another build can carry a lock + // this one has no row for, and dropping it would hide editable state. + final core = _LocksCore(unlocked: ['IO_AM_CHEST_05']); + await _panel(tester, await _notifier(tester, core)); + + expect(find.text('IO_AM_CHEST_05'), findsOneWidget); + expect(find.text('Not in this game version'), findsOneWidget); + }); + + testWidgets('a save with no editable lock set stays read-only', ( + tester, + ) async { + final core = _LocksCore(writable: false); + final notifier = await _notifier(tester, core); + await _panel(tester, notifier); + + expect(find.text('This save has no editable lock set.'), findsOneWidget); + expect(_switchOf(tester, 'IO_OC_CHEST_DEXTER').onChanged, isNull); + + await tester.tap(find.byKey(const ValueKey('lock-IO_OC_CHEST_DEXTER'))); + await _settle(tester); + expect(notifier.pendingEditFor('world.locks'), isNull); + }); + + testWidgets('queued toggles can be discarded', (tester) async { + final core = _LocksCore(); + final notifier = await _notifier(tester, core); + await _panel(tester, notifier); + + await tester.tap(find.byKey(const ValueKey('lock-IO_OC_CHEST_DEXTER'))); + await _settle(tester); + expect(notifier.pendingEditFor('world.locks'), isNotNull); + + await tester.tap(find.byKey(const Key('locks-discard-pending'))); + await _settle(tester); + expect(notifier.pendingEditFor('world.locks'), isNull); + }); + + testWidgets('the section is translated', (tester) async { + final core = _LocksCore(); + final notifier = await _notifier(tester, core); + await tester.pumpWidget( + _wrap( + LocksDetail( + notifier: notifier, + editable: true, + reloadKey: notifier.state.inspection!, + theme: ThemeData.light(), + catalogOverride: _catalog(), + locationsOverride: _locations(), + ), + locale: 'de', + ), + ); + await _settle(tester); + + expect(find.widgetWithText(ChoiceChip, 'Truhen'), findsOneWidget); + expect(find.widgetWithText(ChoiceChip, 'Türen'), findsOneWidget); + expect(find.widgetWithText(ChoiceChip, 'Verschlossen'), findsOneWidget); + expect(find.text('Alle Regionen'), findsOneWidget); + // Each row names its own state, so the switch is never read alone. + expect( + find.descendant( + of: find.byKey(const ValueKey('lock-NC_Cave_Tavern_Door')), + matching: find.text('Verschlossen'), + ), + findsOneWidget, + ); + }); +} diff --git a/apps/save-editor/test/features/editor/ui/story_state_panel_test.dart b/apps/save-editor/test/features/editor/ui/story_state_panel_test.dart index 67817f269..91ca4c9d0 100644 --- a/apps/save-editor/test/features/editor/ui/story_state_panel_test.dart +++ b/apps/save-editor/test/features/editor/ui/story_state_panel_test.dart @@ -78,7 +78,7 @@ void main() { await tester.pumpAndSettle(); expect(find.byKey(const Key('story-state-info-box')), findsOneWidget); expect( - find.textContaining('Authoritative catalog of persisted story state'), + find.textContaining('The game tracks progress in quests'), findsOneWidget, ); expect(tester.widget(infoButton).isSelected, isTrue); @@ -796,6 +796,192 @@ void main() { findsNothing, ); }); + + testWidgets('drops the unknown-type filter when the save has no unknown ID', ( + tester, + ) async { + final notifier = EditorNotifier(_BenignCore(), saveDir: r'C:\tmp\saves'); + addTearDown(notifier.dispose); + const page = StoryStatePage( + values: [ + StoryStateValue( + id: 'AfterCinematic_Nyras', + value: 1, + stored: true, + catalogKnown: true, + path: [], + semanticType: StorySemanticType.integer, + declaredType: 'int32', + ), + ], + total: 1, + storedTotal: 1, + catalogTotal: 1, + writable: true, + ); + + await _pumpEditableStoryPanel(tester, notifier: notifier, page: page); + + expect(find.textContaining('Unknown source type'), findsNothing); + expect(find.text('Integer (1)'), findsOneWidget); + }); + + testWidgets('counts the chapter inside the integer filter', (tester) async { + final notifier = EditorNotifier(_BenignCore(), saveDir: r'C:\tmp\saves'); + addTearDown(notifier.dispose); + const page = StoryStatePage( + values: [ + StoryStateValue( + id: 'Chapter', + value: 2, + stored: true, + catalogKnown: true, + path: [], + semanticType: StorySemanticType.chapter, + declaredType: 'int', + ), + StoryStateValue( + id: 'AfterCinematic_Nyras', + value: 1, + stored: true, + catalogKnown: true, + path: [], + semanticType: StorySemanticType.integer, + declaredType: 'int32', + ), + ], + total: 2, + storedTotal: 2, + catalogTotal: 2, + writable: true, + ); + + await _pumpEditableStoryPanel(tester, notifier: notifier, page: page); + + expect(find.text('Chapter (1)'), findsNothing); + expect(find.text('Integer (2)'), findsOneWidget); + + await tester.tap(find.text('Integer (2)')); + await tester.pumpAndSettle(); + expect(find.text('2 of 2 story values'), findsOneWidget); + // The chapter keeps its own badge and dedicated editor. + expect(find.text('Chapter'), findsNWidgets(2)); + }); + + testWidgets('hides dormant catalog fields until they are asked for', ( + tester, + ) async { + final notifier = EditorNotifier(_BenignCore(), saveDir: r'C:\tmp\saves'); + addTearDown(notifier.dispose); + const page = StoryStatePage( + values: [ + StoryStateValue( + id: 'KalomDead', + value: 1, + stored: true, + catalogKnown: true, + path: [], + semanticType: StorySemanticType.integer, + declaredType: 'int32', + ), + StoryStateValue( + id: 'FindXardas', + value: null, + stored: false, + catalogKnown: true, + path: [], + semanticType: StorySemanticType.integer, + declaredType: 'int32', + ), + StoryStateValue( + id: 'AfterCinematic_Nyras', + value: null, + stored: false, + catalogKnown: true, + path: [], + semanticType: StorySemanticType.integer, + declaredType: 'int32', + ), + ], + total: 3, + storedTotal: 1, + catalogTotal: 3, + unsetTotal: 2, + writable: true, + ); + + await _pumpEditableStoryPanel(tester, notifier: notifier, page: page); + // A dormant field that this save actually stores is never hidden. + expect(find.text('Kalom Dead'), findsOneWidget); + expect(find.textContaining('Show unused'), findsNothing); + + await tester.tap(find.text('All (3)')); + await tester.pumpAndSettle(); + expect(find.text('Kalom Dead'), findsOneWidget); + expect(find.text('After Cinematic Nyras'), findsOneWidget); + expect(find.text('Find Xardas'), findsNothing); + expect(find.text('2 of 3 story values'), findsOneWidget); + + await tester.tap(find.text('Show unused (1)')); + await tester.pumpAndSettle(); + expect(find.text('Find Xardas'), findsOneWidget); + expect(find.text('3 of 3 story values'), findsOneWidget); + + await tester.tap(find.text('Show unused (1)')); + await tester.pumpAndSettle(); + expect(find.text('Find Xardas'), findsNothing); + + await tester.enterText( + find.byKey(const Key('story-state-search')), + 'xardas', + ); + await tester.pumpAndSettle(); + expect(find.text('Find Xardas'), findsOneWidget); + + await tester.enterText(find.byKey(const Key('story-state-search')), ''); + await tester.pumpAndSettle(); + expect(find.text('Find Xardas'), findsNothing); + }); + + testWidgets('keeps a dormant field visible while an edit is pending', ( + tester, + ) async { + final notifier = EditorNotifier(_BenignCore(), saveDir: r'C:\tmp\saves'); + addTearDown(notifier.dispose); + const page = StoryStatePage( + values: [ + StoryStateValue( + id: 'FindXardas', + value: null, + stored: false, + catalogKnown: true, + path: [], + semanticType: StorySemanticType.integer, + declaredType: 'int32', + ), + ], + total: 1, + storedTotal: 0, + catalogTotal: 1, + unsetTotal: 1, + writable: true, + ); + + await _pumpEditableStoryPanel(tester, notifier: notifier, page: page); + notifier.setStoryStateEdit( + const StoryStateEdit( + id: 'FindXardas', + present: true, + rawValue: 1, + expectedStored: false, + expectedRawValue: null, + ), + ); + await tester.tap(find.text('All (1)')); + await tester.pumpAndSettle(); + expect(find.text('Find Xardas'), findsOneWidget); + expect(find.text('Will be stored as 1'), findsOneWidget); + }); } Future _pumpEditableStoryPanel( diff --git a/apps/save-editor/tools/build_lock_catalog.py b/apps/save-editor/tools/build_lock_catalog.py new file mode 100644 index 000000000..538c53381 --- /dev/null +++ b/apps/save-editor/tools/build_lock_catalog.py @@ -0,0 +1,288 @@ +#!/usr/bin/env python3 +"""Build `assets/lock_catalog.json` from a decompiled AngelScript source tree. + +A save records only the locks the player has already opened, as a set of names +in `LockPersistentData.m_UnlockedLocks`. To show every lock in the game — and +to let one be locked again — the editor needs the full list from somewhere +else, and the shipped script cache is the game's own authority on it. + +Every lockable chest and door is an `InteractiveObjects/*` class whose defaults +carry the lock: + + class UIO_OC_CHEST_DEXTER : UIoChestDefault + { + default m_UniqueName = n"IO_OC_CHEST_DEXTER"; + default m_Lock = n"OC_Chest_Dexter_Lock"; + default m_LockDifficulty = 3; + } + +`m_UniqueName` — not `m_Lock` — is what the save writes, so that is the key +this catalog is addressed by. A lock counts as one when the class carries any +of `m_Lock`, `m_LockDifficulty` or `m_Keys`, or when `RandomLockPools` +registers the chest for the randomized-lock subsystem; the last source is the +only reason `IO_AM_CHEST_05` appears in real saves at all. + +Regenerate after a game update: + + gore as emit-all "$GAME/G1R/Script/PrecompiledScript_Shipping.Cache" out_as + python apps/save-editor/tools/build_lock_catalog.py out_as \ + --locations apps/save-editor/assets/location_catalog.json \ + --out apps/save-editor/assets/lock_catalog.json + +`gore as emit-all` must be a build that emits class `default` statements; +without them this script has nothing to read and refuses to write a file. +""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +from pathlib import Path + +CLASS_RE = re.compile(r"^class\s+(\w+)\s*(?::\s*(\w+))?\s*$") +UNIQUE_NAME_RE = re.compile(r'^\s*default\s+m_UniqueName\s*=\s*n"([^"]+)";') +LOCK_RE = re.compile(r'^\s*default\s+m_Lock\s*=\s*n"([^"]+)";') +DIFFICULTY_RE = re.compile(r"^\s*default\s+m_LockDifficulty\s*=\s*(\d+);") +KEY_RE = re.compile(r'^\s*default\s+m_Keys\.Add\(n"([^"]+)"\);') +INTERACTION_RE = re.compile( + r"^\s*default\s+m_DefaultInteraction\s*=\s*GameplayTag::(\w+);" +) +REGISTER_CHEST_RE = re.compile( + r"^\s*default\s+RegisterChest\(TSubclassOf\(" + r"(\w+)::StaticClass\(\)\)\);" +) + +# The scan is limited to this subtree: every lockable chest and door lives in +# it, and reading the whole emitted tree would mean parsing ~7300 files to find +# them. +SOURCE_DIR = "InteractiveObjects" + +# Dev fixtures that ship in the retail cache but stand behind no placed actor. +# `TestChests` is a whole module of them; `UExampleDoor` is the template the +# door classes are cloned from, and its "key" is the literal `LockExampleA`. +SKIPPED_MODULES = {"TestChests"} +SKIPPED_CLASSES = {"UExampleDoor"} + +# A chest is one by base class, a door by the interaction the game offers on +# it. Both are stated in the defaults, so neither has to be guessed from a name. +CHEST_BASES = {"UIoChestDefault", "UIoLootDefault"} +DOOR_INTERACTION_PREFIX = "Action_Door" + +# A handful of doors spell their area out instead of using its code, and sit at +# a placement the location catalog does not carry. These are the game's own +# long forms for areas that do have a code. +AREA_ALIASES = { + "ABANDONEDMINE": "AM", + "ORCGRAVEYARD": "OG", + "SUNKENTOWER": "SNT", +} + + +class LockClass: + """One `InteractiveObjects` class, reduced to what a lock needs.""" + + def __init__(self, name: str, base: str | None, module: str) -> None: + self.name = name + self.base = base + self.module = module + self.unique_name: str | None = None + self.lock: str | None = None + self.difficulty: int | None = None + self.keys: list[str] = [] + self.interaction: str | None = None + + @property + def carries_lock(self) -> bool: + return bool(self.lock or self.difficulty or self.keys) + + @property + def kind(self) -> str | None: + if self.base in CHEST_BASES: + return "chest" + if self.interaction and self.interaction.startswith(DOOR_INTERACTION_PREFIX): + return "door" + return None + + +def parse_module(path: Path, module: str) -> list[LockClass]: + """Every class in one emitted `.as` file, with its lock defaults.""" + classes: list[LockClass] = [] + current: LockClass | None = None + for line in path.read_text(encoding="utf-8", errors="replace").splitlines(): + match = CLASS_RE.match(line) + if match: + current = LockClass(match.group(1), match.group(2), module) + classes.append(current) + continue + if current is None: + continue + if match := UNIQUE_NAME_RE.match(line): + current.unique_name = match.group(1) + elif match := LOCK_RE.match(line): + current.lock = match.group(1) + elif match := DIFFICULTY_RE.match(line): + current.difficulty = int(match.group(1)) + elif match := KEY_RE.match(line): + current.keys.append(match.group(1)) + elif match := INTERACTION_RE.match(line): + current.interaction = match.group(1) + return classes + + +def parse_random_pool(path: Path) -> set[str]: + """Class names the randomized-lock subsystem registers as lockable.""" + if not path.is_file(): + return set() + registered: set[str] = set() + for line in path.read_text(encoding="utf-8", errors="replace").splitlines(): + if match := REGISTER_CHEST_RE.match(line): + registered.add(match.group(1)) + return registered + + +def load_areas(catalog_path: Path | None) -> tuple[dict[str, str], list[str]]: + """`{upper-cased spot name: area code}` plus the known area codes. + + The location catalog is built from the game's own `InteractionSpots.json`, + so a lock's own placement is usually already in it; the area codes are the + fallback for the rest. + """ + if catalog_path is None: + return {}, [] + catalog = json.loads(catalog_path.read_text(encoding="utf-8")) + spots = { + spot["n"].upper(): spot.get("a") or "" + for spot in catalog.get("spots", []) + if spot.get("n") + } + # Longest first, so `OTOWN` wins over `OT` and `SNT` over `SC`. + codes = sorted( + (area["id"] for area in catalog.get("areas", [])), + key=len, + reverse=True, + ) + return spots, codes + + +def resolve_area(name: str, spots: dict[str, str], codes: list[str]) -> str: + """The area a lock sits in: its own placement first, then its name prefix.""" + placed = spots.get(name.upper()) + if placed: + return placed + # `IO_OC_CHEST_DEXTER` and `OC_Santino_Door` both carry the code as the + # first name part after an optional `IO_`. + stem = name[3:] if name.upper().startswith("IO_") else name + upper = stem.upper() + for code in codes: + if upper.startswith(f"{code}_"): + return code + for long_form, code in AREA_ALIASES.items(): + if upper.startswith(f"{long_form}_"): + return code + return "" + + +def build( + source_root: Path, location_catalog: Path | None +) -> tuple[dict[str, object], dict[str, int]]: + source_dir = source_root / SOURCE_DIR + if not source_dir.is_dir(): + raise SystemExit( + f"no {SOURCE_DIR}/ under {source_root} — pass the root of an " + f"`gore as emit-all` tree" + ) + + classes: list[LockClass] = [] + for path in sorted(source_dir.glob("*.as")): + module = path.stem + if module in SKIPPED_MODULES: + continue + classes.extend(parse_module(path, module)) + + registered = parse_random_pool(source_dir / "RandomLockPools.as") + spots, codes = load_areas(location_catalog) + + locks: list[dict[str, object]] = [] + for entry in classes: + if entry.name in SKIPPED_CLASSES or not entry.unique_name: + continue + randomized = entry.name in registered + if not entry.carries_lock and not randomized: + continue + kind = entry.kind + if kind is None: + continue + row: dict[str, object] = { + "n": entry.unique_name, + "k": kind, + "a": resolve_area(entry.unique_name, spots, codes), + } + if entry.difficulty is not None: + row["d"] = entry.difficulty + if entry.lock: + row["l"] = entry.lock + if entry.keys: + row["keys"] = entry.keys + if randomized: + row["r"] = True + locks.append(row) + + locks.sort(key=lambda row: str(row["n"]).upper()) + seen: set[str] = set() + for row in locks: + name = str(row["n"]) + if name in seen: + raise SystemExit(f"duplicate lock name in the source tree: {name}") + seen.add(name) + + report = { + "classes": len(classes), + "locks": len(locks), + "chests": sum(1 for row in locks if row["k"] == "chest"), + "doors": sum(1 for row in locks if row["k"] == "door"), + "with_difficulty": sum(1 for row in locks if "d" in row), + "with_key": sum(1 for row in locks if "keys" in row), + "randomized": sum(1 for row in locks if "r" in row), + "without_area": sum(1 for row in locks if not row["a"]), + } + return {"version": 1, "locks": locks}, report + + +def main(argv: list[str]) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "source", + type=Path, + help="root of an `gore as emit-all` tree (the directory holding InteractiveObjects/)", + ) + parser.add_argument( + "--locations", + type=Path, + default=None, + help="location_catalog.json, to resolve each lock's area", + ) + parser.add_argument("--out", type=Path, required=True) + args = parser.parse_args(argv) + + catalog, report = build(args.source, args.locations) + if not catalog["locks"]: + raise SystemExit( + "no locks found — was the tree emitted by a build that writes " + "class `default` statements?" + ) + + args.out.parent.mkdir(parents=True, exist_ok=True) + args.out.write_text( + json.dumps(catalog, ensure_ascii=False, separators=(",", ":")) + "\n", + encoding="utf-8", + ) + for key, value in report.items(): + print(f"{key}: {value}") + print(f"wrote {args.out}") + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/build.py b/build.py index d323927ea..d7f579107 100644 --- a/build.py +++ b/build.py @@ -2127,24 +2127,7 @@ def build_project(project: str, release: bool, dry: bool) -> None: _stage_standalone_compiler_bundle(project, host_dir, dry=dry) return # flutter app: build native cdylib first, then the app, then bundle the dll. - # The cargo package id (hyphenated) and the produced dll basename - # (underscored) differ, so they are tracked as separate fields. - crate = cfg["core_crate"] - cargo_cmd = [CARGO, "build", "-p", crate] - if release: - cargo_cmd.append("--release") - run( - f"cargo build {crate} ({mode})", - cargo_cmd, - dry=dry, - extra_env=_standalone_compiler_build_env(project, dry=dry), - ) - if not dry: - _verify_host_embedded_standalone_compiler_catalog( - project, - target_dir(release) / f"{cfg['core_dll']}.dll", - dry=False, - ) + build_core_dll(project, release=release, dry=dry) flutter_cmd = [FLUTTER, "build", "windows", f"--{mode}"] # Every Flutter app's About dialog reads GIT_SHA (String.fromEnvironment, defaultValue 'dev'). @@ -2159,6 +2142,50 @@ def build_project(project: str, release: bool, dry: bool) -> None: # line endings alone, so a build does not leave a worktree that reads as dirty # (see discard_line_ending_only_churn). discard_line_ending_only_churn(project) + stage_core_dll(project, release=release) + + +def runnable_exe(project: str, release: bool) -> Path: + cfg = PROJECTS[project] + if cfg["kind"] == "flutter": + return flutter_build_dir(project, release) / cfg["exe"] + if cfg["kind"] == "rust-bin": + return target_dir(release) / f"{cfg['bin']}.exe" + raise SystemExit(f"{project} is not runnable") + + +def build_core_dll(project: str, release: bool, dry: bool = False) -> Path: + """Compile a Flutter app's native cdylib and return the built artifact. + + The cargo package id (hyphenated) and the produced dll basename + (underscored) differ, so they are tracked as separate config fields. + """ + cfg = PROJECTS[project] + mode = "release" if release else "debug" + crate = cfg["core_crate"] + cargo_cmd = [CARGO, "build", "-p", crate] + if release: + cargo_cmd.append("--release") + run( + f"cargo build {crate} ({mode})", + cargo_cmd, + dry=dry, + extra_env=_standalone_compiler_build_env(project, dry=dry), + ) + src = target_dir(release) / f"{cfg['core_dll']}.dll" + if not dry: + _verify_host_embedded_standalone_compiler_catalog(project, src, dry=False) + return src + + +def stage_core_dll(project: str, release: bool) -> Path: + """Copy the native cdylib next to the app's exe, where the app looks first. + + Kept separate from [build_core_dll] so `run` can refresh the core of an + already-built app: the exe existing does NOT mean its core is there, and an + app launched without one loads no core at all and silently does nothing. + """ + cfg = PROJECTS[project] rel = flutter_build_dir(project, release) if not rel.exists(): raise SystemExit(f"missing flutter output: {rel}") @@ -2169,24 +2196,21 @@ def build_project(project: str, release: bool, dry: bool) -> None: shutil.copy2(src, rel / dll) _verify_host_embedded_standalone_compiler_catalog(project, rel / dll, dry=False) print(f"copied {dll} -> {rel / dll}") - - -def runnable_exe(project: str, release: bool) -> Path: - cfg = PROJECTS[project] - if cfg["kind"] == "flutter": - return flutter_build_dir(project, release) / cfg["exe"] - if cfg["kind"] == "rust-bin": - return target_dir(release) / f"{cfg['bin']}.exe" - raise SystemExit(f"{project} is not runnable") + return rel / dll def run_project(project: str, release: bool) -> None: - """Launch the built program, building it first if it is missing.""" + """Build the program, then launch it. + + `run` REBUILDS rather than launching whatever happens to sit in build/. + Both toolchains are incremental, so an unchanged tree costs seconds — and + the alternative is worse than slow: a Flutter app whose exe already exists + launches, looks perfectly healthy, and shows code from whenever it was last + built. Checking only that the exe exists has already put a stale UI and a + missing native core in front of a reviewer. + """ exe = runnable_exe(project, release) - if not exe.exists(): - mode = "release" if release else "debug" - print(f"{exe.name} ({mode}) not built yet; building first...") - build_project(project, release=release, dry=False) + build_project(project, release=release, dry=False) if not exe.exists(): raise SystemExit(f"build did not produce {exe}") # Run from the exe's own directory so a Flutter app finds its bundled DLLs. diff --git a/crates/gore-save/src/lib.rs b/crates/gore-save/src/lib.rs index 64a9510b0..6355f366a 100644 --- a/crates/gore-save/src/lib.rs +++ b/crates/gore-save/src/lib.rs @@ -1,6 +1,7 @@ pub mod codec_backend; mod codec_calibration; pub mod factions; +pub mod locks; pub mod npc; pub mod placement; pub mod properties; @@ -564,6 +565,12 @@ fn execute_json_inner(input: &str) -> Result { let codec_backend = Some(&kraken_backend as &dyn codec_backend::CodecBackend); list_guild_crimes_command(&path, codec_backend) } + "private.locks.list" => { + let path = required_path(&payload)?; + let kraken_backend = codec_backend::KrakenBackend::default(); + let codec_backend = Some(&kraken_backend as &dyn codec_backend::CodecBackend); + locks_list_command(&path, codec_backend) + } "private.traders.list" => { let path = required_path(&payload)?; let kraken_backend = codec_backend::KrakenBackend::default(); @@ -6898,6 +6905,7 @@ const CACHEABLE_READ_COMMANDS: &[&str] = &[ "private.npc.position", "private.npc.inventory", "private.factions.list", + "private.locks.list", ]; /// Identity of one cached response: the exact request, plus a content @@ -8334,6 +8342,35 @@ fn npc_inventory_command( Ok(summary) } +/// `private.locks.list`: the names of every lock the player has already opened. +/// +/// Payload: `{ path }`. Returns `{ unlocked: [...], writable: [...] }`. +/// +/// Only the save side is answered here. Which locks EXIST in the game is not in +/// a save at all — a fresh start carries the set with zero elements — so the +/// full list comes from the bundled lock catalog and is joined against this +/// answer by the app. +fn locks_list_command( + path: &Path, + backend: Option<&dyn codec_backend::CodecBackend>, +) -> Result { + let backend = backend.ok_or_else(|| { + CoreError::Codec("reading locks requires a working codec backend".to_string()) + })?; + let root = decode_private_root_cached(path, backend)?; + let summary = locks::list_locks(&root); + // One op covers both directions: the edit carries the desired state. + let writable: Vec<&str> = if summary.writable { + vec!["private.locks.setUnlocked"] + } else { + Vec::new() + }; + Ok(json!({ + "unlocked": summary.unlocked, + "writable": writable, + })) +} + /// `private.traders.list`: every merchant's shop record in array order. /// /// Payload: `{ path }`. Returns `{ traders: [...], writable: [...] }`. @@ -11096,6 +11133,13 @@ fn apply_private_edits( "private.knowledge.setEntry" => { parse_private_knowledge_set_entry_edit(edit).map(PrivateEdit::KnowledgeSetEntry) } + // Value-addressed like the generic set ops it is built on: the lock + // is found by name on a fresh parse per edit, never by an index a + // sibling edit could have shifted, so a whole panel of toggles + // batches into one write. + "private.locks.setUnlocked" => { + parse_private_lock_set_unlocked_edit(edit).map(PrivateEdit::LockSetUnlocked) + } // Value-addressed skill edit (resolves its target by skill base, // never a stale index, and re-parses per edit), so a batch of // them applies safely even when some are structural @@ -11370,6 +11414,12 @@ fn structured_edit_target(edit: &PrivateEdit) -> Option<(&'static str, String)> "knowledge entry of that character", key([entry.character.as_str(), entry.entry.as_str()]), )), + // Declarative about ONE lock. Two intents for the same lock in one write + // are a contradiction the caller has to resolve, not something to apply + // in submission order; different locks keep batching. + PrivateEdit::LockSetUnlocked(lock) => { + Some(("lock state of that chest or door", key([lock.lock.as_str(), ""]))) + } PrivateEdit::GlossarySetSegment(glossary) => Some(( "glossary segment", key([ @@ -11442,6 +11492,16 @@ fn structured_edit_rewrites(edit: &PrivateEdit, path: &[properties::PathSeg]) -> PrivateEdit::KnowledgeSetEntry(edit) => { path_enters_map_entry(path, "CharacterKnowledgeByUniqueName", &edit.character) } + // Splices the one set every lock lives in, so a raw container edit on + // that same set in the same write is refused rather than resolved + // against a cardinality the lock edit already moved. + PrivateEdit::LockSetUnlocked(edit) => { + path_has_name(path, locks::UNLOCKED_LOCKS_PROPERTY) + || (!edit.unlocked + && ["m_DoorsOpen", "m_DoorsClosed", "m_SavedDoorsMessagesStruct"] + .iter() + .any(|name| path_has_name(path, name))) + } // Claims a whole slot: the add fills a blank one and resets its payload, the // removal blanks one. Only in the inventory it targets — another actor's // slots are a different subtree. @@ -11492,6 +11552,8 @@ fn may_invalidate_caller_ordinals(edit: &PrivateEdit) -> bool { PrivateEdit::KnowledgeAddCharacter(_) => true, // May insert a character and set-add/set-remove an entry. PrivateEdit::KnowledgeSetEntry(_) => true, + // Set-adds or set-removes a name in m_UnlockedLocks. + PrivateEdit::LockSetUnlocked(_) => true, // Removes memory events across owners and drops the corpse map entry. PrivateEdit::NpcRevive(_) => true, // Inserts a missing relationship entry. @@ -11680,6 +11742,7 @@ enum PrivateEdit { GlossarySetSegment(PrivateGlossarySetSegmentEdit), KnowledgeAddCharacter(String), KnowledgeSetEntry(PrivateKnowledgeSetEntryEdit), + LockSetUnlocked(PrivateLockSetUnlockedEdit), FactionsForgive(PrivateFactionsForgiveEdit), SkillSet(skills::SkillSetEdit), TraderSetStock(traders::SetStockEdit), @@ -11976,6 +12039,54 @@ fn parse_private_knowledge_set_entry_edit( }) } +/// `private.locks.setUnlocked` edit: `value = { lock: String, unlocked: bool }`. +/// +/// One intent per lock, stated as the desired state rather than as an add or a +/// remove, so re-applying a batch after a partial write is a no-op instead of a +/// duplicate-insert error. +#[derive(Debug, Clone, PartialEq, Eq)] +struct PrivateLockSetUnlockedEdit { + lock: String, + unlocked: bool, +} + +fn parse_private_lock_set_unlocked_edit( + edit: &Edit, +) -> Result { + let value = edit.value.as_object().ok_or_else(|| { + CoreError::InvalidRequest("private.locks.setUnlocked value must be an object".to_string()) + })?; + let lock = value + .get("lock") + .and_then(Value::as_str) + .map(str::trim) + .filter(|text| !text.is_empty()) + .ok_or_else(|| { + CoreError::InvalidRequest( + "private.locks.setUnlocked requires a non-empty string value.lock".to_string(), + ) + })?; + // The name is spliced into the payload as an FString; the game's own longest + // lock name is well under this, so anything past it is a malformed request. + if lock.len() > 256 { + return Err(CoreError::InvalidRequest( + "private.locks.setUnlocked value.lock is too long".to_string(), + )); + } + let unlocked = value + .get("unlocked") + .and_then(Value::as_bool) + .ok_or_else(|| { + CoreError::InvalidRequest( + "private.locks.setUnlocked requires boolean value.unlocked".to_string(), + ) + })?; + Ok(PrivateLockSetUnlockedEdit { + lock: lock.to_string(), + unlocked, + }) +} + /// `private.factions.forgive` edit: `value = { guild: String }`. Forgives every /// unforgiven player crime implicating the (camp-level) guild — a fixed-size /// batch of `bIsForgiven`/`bIsSuppressed` bool-byte flips. @@ -13336,6 +13447,9 @@ fn apply_private_edit_to_payload_uncached( PrivateEdit::KnowledgeSetEntry(edit) => { apply_private_knowledge_set_entry_to_payload(payload, edit) } + PrivateEdit::LockSetUnlocked(edit) => { + apply_private_lock_set_unlocked_to_payload(payload, edit) + } PrivateEdit::FactionsForgive(edit) => factions::apply_forgive(payload, &edit.guild), } } @@ -13891,6 +14005,122 @@ fn apply_private_knowledge_set_entry_to_payload( Ok(()) } +/// Apply one lock intent atomically: add the name to `m_UnlockedLocks` to leave +/// the lock open, remove it to lock the chest or door again. +/// +/// Desired-state, like the knowledge writer: an intent that already holds is a +/// no-op, so re-submitting a batch after a partial write neither fails on a +/// duplicate insert nor on a missing removal. +fn apply_private_lock_set_unlocked_to_payload( + payload: &mut Vec, + edit: &PrivateLockSetUnlockedEdit, +) -> Result<(), CoreError> { + let mut patched = payload.clone(); + let (contains, set_path) = locks::lock_snapshot(&patched, &edit.lock)?; + let Some(set_path) = set_path else { + return Err(CoreError::UnsupportedEdit(format!( + "this save has no {} set to edit", + locks::UNLOCKED_LOCKS_PROPERTY + ))); + }; + if contains && edit.unlocked { + return Ok(()); + } + + if contains != edit.unlocked { + apply_private_typed_container_edit_to_payload( + &mut patched, + &PrivateTypedContainerEdit { + path: set_path, + edit: if edit.unlocked { + properties::ContainerEdit::SetAdd(edit.lock.clone()) + } else { + properties::ContainerEdit::SetRemove(edit.lock.clone()) + }, + }, + )?; + } + + let (final_contains, _) = locks::lock_snapshot(&patched, &edit.lock)?; + if final_contains != edit.unlocked { + return Err(CoreError::Validation(format!( + "lock postcondition failed for {:?}", + edit.lock + ))); + } + + // Restoring a door's lock while its leaf stays open leaves a locked door + // standing open, which is not a state the player could have produced. The + // leaf lives in a different bucket, so shut it here. + if !edit.unlocked { + close_door_leaf_in_payload(&mut patched, &edit.lock)?; + } + + properties::parse_private_root(&patched).map_err(|err| { + CoreError::Parse(format!("lock edit produced an inconsistent payload: {err}")) + })?; + *payload = patched; + Ok(()) +} + +/// Shut one door's leaf, doing exactly what the game's own +/// `Server_SetDoorOpenState(false, name)` does: strip EVERY copy of the name +/// from `m_DoorsOpen` (the game appends without a uniqueness check, so a door +/// used often has dozens) and append it once to `m_DoorsClosed`. +/// +/// A name that is not in `m_DoorsOpen` is not a door, or is a door already +/// shut — either way there is nothing to do, so every chest lands here as a +/// no-op. +fn close_door_leaf_in_payload(payload: &mut Vec, name: &str) -> Result<(), CoreError> { + let root = properties::parse_private_root(payload)?; + let Some(plan) = locks::plan_close_door_leaf(&root, name)? else { + return Ok(()); + }; + drop(root); + + // Fixed-size writes first, so they resolve against the layout they were + // planned on; the length-changing splices come after. + for magnitude_path in &plan.open_message_magnitudes { + let root = properties::parse_private_root(payload)?; + let segments = properties::parse_path(magnitude_path)?; + let target = properties::resolve(&root.properties, &segments)?.clone(); + drop(root); + properties::patch_scalar(payload, &target, properties::ScalarValue::Double(0.0))?; + } + + apply_container_edit_by_path( + payload, + &plan.open_path, + properties::ContainerEdit::ArrayRemoveMany(plan.open_indices.clone()), + )?; + + if plan.closed_needs_entry { + if let Some(closed_path) = &plan.closed_path { + apply_container_edit_by_path( + payload, + closed_path, + properties::ContainerEdit::ArrayInsertBytes(properties::encode_fstring_value(name)), + )?; + } + } + Ok(()) +} + +/// Resolve `path` on a fresh parse and apply one container edit to the payload. +fn apply_container_edit_by_path( + payload: &mut Vec, + path: &[String], + edit: properties::ContainerEdit, +) -> Result<(), CoreError> { + let root = properties::parse_private_root(payload)?; + let segments = properties::parse_path(path)?; + let resolved = properties::resolve_chain(&root.properties, &segments)?; + let target = resolved.target.clone(); + let enclosing = resolved.enclosing_size_fields.clone(); + drop(root); + properties::patch_container(payload, &target, &enclosing, &edit) +} + /// Enum label identifying the player's main item container inside /// `m_Inventory.m_Keys`. The container index varies between saves, so it must /// always be looked up by value, never hardcoded. @@ -16716,6 +16946,138 @@ mod tests { /// loser sees a re-parse where it asserted a hit. static REAL_SAVE_CACHE_TESTS: Mutex<()> = Mutex::new(()); + /// A private root with the two door arrays, built through the real parser + /// so the descriptors are the ones a save carries. + fn door_state_payload(open: &[&str], closed: &[&str]) -> Vec { + fn fstring(value: &str) -> Vec { + let mut out = ((value.len() + 1) as i32).to_le_bytes().to_vec(); + out.extend_from_slice(value.as_bytes()); + out.push(0); + out + } + fn name_array(property: &str, names: &[&str]) -> Vec { + let mut body = (names.len() as u32).to_le_bytes().to_vec(); + for name in names { + body.extend_from_slice(&fstring(name)); + } + let mut out = fstring(property); + out.extend_from_slice(&fstring("ArrayProperty")); + out.extend_from_slice(&1u32.to_le_bytes()); + out.extend_from_slice(&fstring("NameProperty")); + out.extend_from_slice(&0u32.to_le_bytes()); + out.extend_from_slice(&(body.len() as u32).to_le_bytes()); + out.push(0); + out.extend_from_slice(&body); + out + } + let mut props = name_array("m_DoorsOpen", open); + props.extend_from_slice(&name_array("m_DoorsClosed", closed)); + props.extend_from_slice(&private_name_set_property("m_UnlockedLocks", &[])); + let mut out = fstring("/Script/G1R.GameStateDataBaseSaveData"); + out.push(0); + out.extend_from_slice(&props); + out.extend_from_slice(&fstring("None")); + out.extend_from_slice(&0u32.to_le_bytes()); + out + } + + fn door_arrays(payload: &[u8]) -> (Vec, Vec) { + let root = properties::parse_private_root(payload).unwrap(); + let read = |name: &str| -> Vec { + let Some((_, property)) = properties::find_property_by_name(&root, name) else { + return Vec::new(); + }; + match &property.value { + properties::PropertyValue::Array { elements } => elements + .iter() + .filter_map(|e| match e { + properties::PropertyValue::Name(v) + | properties::PropertyValue::Str(v) => Some(v.clone()), + _ => None, + }) + .collect(), + _ => Vec::new(), + } + }; + (read("m_DoorsOpen"), read("m_DoorsClosed")) + } + + #[test] + fn relocking_an_already_locked_door_still_closes_its_leaf() { + let mut payload = door_state_payload(&["CV_Stash_Door", "OC_Cellar_Door"], &[]); + apply_private_lock_set_unlocked_to_payload( + &mut payload, + &PrivateLockSetUnlockedEdit { + lock: "CV_Stash_Door".to_string(), + unlocked: false, + }, + ) + .unwrap(); + let (open, closed) = door_arrays(&payload); + assert_eq!(open, ["OC_Cellar_Door"]); + assert_eq!(closed, ["CV_Stash_Door"]); + assert!(!locks::lock_snapshot(&payload, "CV_Stash_Door").unwrap().0); + } + + #[test] + fn shared_door_relock_is_refused_without_changing_the_payload() { + let mut payload = door_state_payload(&["GenericDoorSavedState", "CV_Stash_Door"], &[]); + let before = payload.clone(); + let result = apply_private_lock_set_unlocked_to_payload( + &mut payload, + &PrivateLockSetUnlockedEdit { + lock: "GenericDoorSavedState".to_string(), + unlocked: false, + }, + ); + assert!( + result + .unwrap_err() + .to_string() + .contains("whole class of doors") + ); + assert_eq!(payload, before); + } + + #[test] + fn closing_a_door_strips_every_copy_and_records_it_closed() { + // The game appends to m_DoorsOpen without a uniqueness check, so a door + // used often has many copies — leaving one behind leaves it open, + // because IsDoorOpen scans that array first and returns on a hit. + let mut payload = door_state_payload( + &[ + "EZ_LootCage_Door", + "OC_Cellar_Door", + "EZ_LootCage_Door", + "EZ_LootCage_Door", + ], + &[], + ); + close_door_leaf_in_payload(&mut payload, "EZ_LootCage_Door").unwrap(); + + let (open, closed) = door_arrays(&payload); + assert_eq!(open, ["OC_Cellar_Door"], "every copy must go, and only those"); + assert_eq!(closed, ["EZ_LootCage_Door"]); + } + + #[test] + fn closing_a_door_twice_does_not_stack_a_second_closed_entry() { + let mut payload = door_state_payload(&["CV_Stash_Door"], &[]); + close_door_leaf_in_payload(&mut payload, "CV_Stash_Door").unwrap(); + close_door_leaf_in_payload(&mut payload, "CV_Stash_Door").unwrap(); + let (open, closed) = door_arrays(&payload); + assert!(open.is_empty()); + assert_eq!(closed, ["CV_Stash_Door"]); + } + + #[test] + fn closing_a_chest_leaves_the_door_arrays_alone() { + let before = door_state_payload(&["OC_Cellar_Door"], &[]); + let mut payload = before.clone(); + close_door_leaf_in_payload(&mut payload, "IO_OC_CHEST_DEXTER").unwrap(); + assert_eq!(payload, before, "a chest has no leaf; nothing may move"); + } + #[test] fn item_icons_prepare_returns_manifest_and_actual_source_path() { let resolved_hint = RefCell::new(None); diff --git a/crates/gore-save/src/locks.rs b/crates/gore-save/src/locks.rs new file mode 100644 index 000000000..cf42eb932 --- /dev/null +++ b/crates/gore-save/src/locks.rs @@ -0,0 +1,496 @@ +//! Chest and door locks. +//! +//! A save carries one bucket for them, `m_GenericData["LockPersistentData"]`, +//! and that bucket has exactly one field: +//! +//! ```text +//! LockPersistentData.m_UnlockedLocks : SetProperty +//! ``` +//! +//! The set holds the name of every lock the player has already opened — +//! nothing else. A fresh save carries the set with zero elements, so the +//! property is always there to write into; what grows is its contents. +//! +//! Chest locks and door locks live in the SAME set and the game does not +//! separate them here: `IO_OC_CHEST_DEXTER` sits next to `OC_Santino_Door`. +//! The name is the lock-bearing class's `m_UniqueName`, which is what the +//! bundled lock catalog is keyed by; the class's own `m_Lock` id is a +//! different string and never appears in a save. +//! +//! Removing a name locks that chest or door again. For a door that is only +//! half the story: `GameStateDataBase.m_DoorsOpen` carries the leaf state +//! separately, so a door whose lock is restored while its name stays in +//! `m_DoorsOpen` would be a locked door standing open. [`plan_close_door_leaf`] +//! is what shuts it, mirroring the game's own `Server_SetDoorOpenState(false)`. + +use serde::Serialize; + +use crate::CoreError; +use crate::properties::{self, PropertyValue, RootObject}; + +/// `m_GenericData` key of the bucket holding the unlocked-lock set. +pub const LOCK_BUCKET_KEY: &str = "LockPersistentData"; + +/// The one field in that bucket. +pub const UNLOCKED_LOCKS_PROPERTY: &str = "m_UnlockedLocks"; + +/// `GameStateDataBase.m_DoorsOpen` — the leaf state, not the lock. The game +/// appends (not add-unique) on every open, so one door occurs many times. +const DOORS_OPEN_PROPERTY: &str = "m_DoorsOpen"; + +/// Its counterpart. `AG1RGameState::IsDoorOpen` scans `m_DoorsOpen` FIRST and +/// returns true on a hit, so adding a name here while it is still in +/// `m_DoorsOpen` changes nothing — a door is shut only by doing both. +const DOORS_CLOSED_PROPERTY: &str = "m_DoorsClosed"; + +/// Parallel arrays: a door with a saved verb message replays it on load. +const DOOR_MESSAGE_NAMES_PROPERTY: &str = "m_SavedDoorsMessagesName"; +const DOOR_MESSAGE_STRUCTS_PROPERTY: &str = "m_SavedDoorsMessagesStruct"; + +/// The event whose magnitude carries the door's section: 1 opens, 0 shuts. +const DOOR_SECTION_EVENT: &str = "m_CurrentSection"; + +/// Door entries keyed by a CLASS rather than by one door's unique name. +/// +/// A door with no `m_UniqueName` falls back to its class, so these buckets each +/// stand for many doors at once (39, 42 and 4 copies in one real save). No lock +/// is named after one — the catalog is keyed by `m_UniqueName` — so this can +/// only be reached by a future entry that should not have been there; refusing +/// beats shutting every generic door in the game. +const SHARED_DOOR_BUCKETS: [&str; 3] = [ + "GenericDoor", + "GenericDoorSavedState", + "GenericDoorNotPlayer", +]; + +/// Everything a payload must change so one door's leaf reads as shut. +/// +/// Mirrors exactly what the game's own `Server_SetDoorOpenState(false, name)` +/// writes, so the result is a state the engine itself produces rather than a +/// combination it never makes. +#[derive(Debug, Clone)] +pub struct DoorLeafPlan { + /// Addressable path of `m_DoorsOpen`. + pub open_path: Vec, + /// EVERY index holding this door's name. The game appends without a + /// uniqueness check, so a much-used door has dozens of copies and removing + /// one of them leaves the door open. + pub open_indices: Vec, + /// Addressable path of `m_DoorsClosed`, when the save has that array. + pub closed_path: Option>, + /// Whether the name still has to be appended there. + pub closed_needs_entry: bool, + /// Paths of every saved-message magnitude that still says "open". + pub open_message_magnitudes: Vec>, +} + +/// Case-insensitive name/string equality, matching UE `FName` semantics. +fn element_is(element: &PropertyValue, name: &str) -> bool { + match element { + PropertyValue::Name(value) | PropertyValue::Str(value) => value.eq_ignore_ascii_case(name), + _ => false, + } +} + +fn array_elements<'a>( + root: &'a RootObject, + property: &str, +) -> Option<(Vec, &'a [PropertyValue])> { + let (path, found) = properties::find_property_by_name(root, property)?; + match &found.value { + PropertyValue::Array { elements } => Some((path, elements.as_slice())), + _ => None, + } +} + +/// What it takes to shut `name`'s leaf, or `None` when there is nothing to do — +/// a chest (chests never appear in `m_DoorsOpen`) or a door already shut. +/// +/// Reads only; the caller applies the plan. +pub fn plan_close_door_leaf( + root: &RootObject, + name: &str, +) -> Result, CoreError> { + if SHARED_DOOR_BUCKETS + .iter() + .any(|bucket| bucket.eq_ignore_ascii_case(name)) + { + return Err(CoreError::UnsupportedEdit(format!( + "{name:?} names a whole class of doors, not one door; refusing to \ + shut every door that shares it" + ))); + } + let Some((open_path, open_elements)) = array_elements(root, DOORS_OPEN_PROPERTY) else { + return Ok(None); + }; + let open_indices: Vec = open_elements + .iter() + .enumerate() + .filter(|(_, element)| element_is(element, name)) + .map(|(index, _)| index) + .collect(); + if open_indices.is_empty() { + return Ok(None); + } + + let (closed_path, closed_needs_entry) = match array_elements(root, DOORS_CLOSED_PROPERTY) { + Some((path, elements)) => { + let present = elements.iter().any(|element| element_is(element, name)); + (Some(path), !present) + } + None => (None, false), + }; + + // A door that carries a saved verb message would have it replayed on load; + // if that message still says section 1, the leaf swings back open however + // the arrays read. Zero it for this door only. + let mut open_message_magnitudes = Vec::new(); + if let (Some((_, names)), Some((structs_path, structs))) = ( + array_elements(root, DOOR_MESSAGE_NAMES_PROPERTY), + array_elements(root, DOOR_MESSAGE_STRUCTS_PROPERTY), + ) { + for (index, entry) in names.iter().enumerate() { + if !element_is(entry, name) { + continue; + } + // The two arrays are parallel; a save whose lengths disagree is not + // one this can reason about, so it is left alone. + let Some(message) = structs.get(index).and_then(struct_properties) else { + continue; + }; + let is_section_event = message + .iter() + .any(|p| p.name.as_str() == "m_Event" && element_is(&p.value, DOOR_SECTION_EVENT)); + if !is_section_event { + continue; + } + let still_open = message.iter().any(|p| { + p.name.as_str() == "m_Magnitude" + && matches!(p.value, PropertyValue::Double(value) if value != 0.0) + }); + if !still_open { + continue; + } + let mut path = structs_path.clone(); + path.push(format!("[{index}]")); + path.push("m_Magnitude".to_string()); + open_message_magnitudes.push(path); + } + } + + Ok(Some(DoorLeafPlan { + open_path, + open_indices, + closed_path, + closed_needs_entry, + open_message_magnitudes, + })) +} + +fn struct_properties(element: &PropertyValue) -> Option<&[properties::Property]> { + match element { + PropertyValue::Struct(properties::StructValue::Properties(props)) => Some(props.as_slice()), + PropertyValue::Struct(properties::StructValue::Instanced(Some(inner))) => { + Some(inner.properties.as_slice()) + } + _ => None, + } +} + +/// What the editor needs to know about a save's locks. +#[derive(Debug, Clone, Serialize)] +pub struct LocksSummary { + /// Every name in `m_UnlockedLocks`, in the order the save stores them. + pub unlocked: Vec, + /// Whether the set was found and carries an element type this crate can + /// splice. False means the panel must stay read-only rather than offer an + /// edit the write path would refuse. + pub writable: bool, +} + +/// The `m_UnlockedLocks` property, with the addressable path that reaches it. +/// +/// Looked up by name rather than by walking to the bucket first: the property +/// name is unique in the tree, and a by-name lookup does not assume where in +/// `m_GenericData` the bucket happens to sit. +fn unlocked_locks<'a>(root: &'a RootObject) -> Option<(Vec, &'a properties::Property)> { + properties::find_property_by_name(root, UNLOCKED_LOCKS_PROPERTY) +} + +/// Whether the set can be spliced: [`properties::ContainerEdit::SetAdd`] and +/// `SetRemove` accept `Name` and `Str` element types and nothing else, so a +/// set of anything else must be reported as read-only instead of failing at +/// save time. +fn set_is_writable(property: &properties::Property) -> bool { + if property.type_name.as_str() != "SetProperty" { + return false; + } + let Some(inner) = property.descriptor.inner.as_deref() else { + return false; + }; + matches!(inner.type_name.as_str(), "NameProperty" | "StrProperty") +} + +/// Read the unlocked-lock names out of a parsed private root. +/// +/// A save without the bucket is not an error: it answers with an empty list +/// and `writable: false`, which is what an older or truncated save looks like. +pub fn list_locks(root: &RootObject) -> LocksSummary { + let Some((_, property)) = unlocked_locks(root) else { + return LocksSummary { + unlocked: Vec::new(), + writable: false, + }; + }; + let PropertyValue::Set { elements, .. } = &property.value else { + return LocksSummary { + unlocked: Vec::new(), + writable: false, + }; + }; + let unlocked = elements + .iter() + .filter_map(|element| match element { + PropertyValue::Name(value) | PropertyValue::Str(value) => Some(value.clone()), + _ => None, + }) + .collect(); + LocksSummary { + unlocked, + writable: set_is_writable(property), + } +} + +/// Whether `name` is in the set right now, plus the path that addresses the +/// set. `None` for the path means the save has no such set at all. +/// +/// Name comparison is case-insensitive, matching UE `FName` semantics and the +/// `SetAdd`/`SetRemove` appliers, so a catalog entry that differs from a +/// save's spelling only in case is still recognized as present. +pub fn lock_snapshot( + payload: &[u8], + name: &str, +) -> Result<(bool, Option>), CoreError> { + let root = properties::parse_private_root(payload)?; + let Some((path, property)) = unlocked_locks(&root) else { + return Ok((false, None)); + }; + let PropertyValue::Set { elements, .. } = &property.value else { + return Err(CoreError::Parse(format!( + "{UNLOCKED_LOCKS_PROPERTY} is not a SetProperty" + ))); + }; + if !set_is_writable(property) { + return Err(CoreError::UnsupportedEdit(format!( + "{UNLOCKED_LOCKS_PROPERTY} holds elements this build cannot splice" + ))); + } + let contains = elements.iter().any(|element| match element { + PropertyValue::Name(value) | PropertyValue::Str(value) => value.eq_ignore_ascii_case(name), + _ => false, + }); + Ok((contains, Some(properties::parse_path(&path)?))) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn fstring(value: &str) -> Vec { + let mut out = Vec::new(); + out.extend_from_slice(&((value.len() + 1) as i32).to_le_bytes()); + out.extend_from_slice(value.as_bytes()); + out.push(0); + out + } + + /// A private root carrying one `m_UnlockedLocks` set, built the way the + /// property tests build theirs: raw bytes through the real parser, so the + /// descriptor and element types are the ones a save would have. + fn payload_with_locks(inner_type: &str, names: &[&str]) -> Vec { + let mut set_body = 0u32.to_le_bytes().to_vec(); // num_to_remove + set_body.extend_from_slice(&(names.len() as u32).to_le_bytes()); + for name in names { + set_body.extend_from_slice(&fstring(name)); + } + + let mut props = fstring(UNLOCKED_LOCKS_PROPERTY); + props.extend_from_slice(&fstring("SetProperty")); + props.extend_from_slice(&1u32.to_le_bytes()); // inner descriptor present + props.extend_from_slice(&fstring(inner_type)); + props.extend_from_slice(&0u32.to_le_bytes()); // array_index + props.extend_from_slice(&(set_body.len() as u32).to_le_bytes()); + props.push(0); // tag flags + props.extend_from_slice(&set_body); + + let mut out = fstring("/Script/G1R.GothicLockSaveGameData"); + out.push(0); // object flag + out.extend_from_slice(&props); + out.extend_from_slice(&fstring("None")); + out.extend_from_slice(&0u32.to_le_bytes()); // footer + out + } + + /// A `SetProperty`/`ArrayProperty` of names, tag and all. + fn name_container(kind: &str, property: &str, names: &[&str]) -> Vec { + let mut body = Vec::new(); + if kind == "SetProperty" { + body.extend_from_slice(&0u32.to_le_bytes()); // num_to_remove + } + body.extend_from_slice(&(names.len() as u32).to_le_bytes()); + for name in names { + body.extend_from_slice(&fstring(name)); + } + let mut out = fstring(property); + out.extend_from_slice(&fstring(kind)); + out.extend_from_slice(&1u32.to_le_bytes()); // inner descriptor present + out.extend_from_slice(&fstring("NameProperty")); + out.extend_from_slice(&0u32.to_le_bytes()); // array_index + out.extend_from_slice(&(body.len() as u32).to_le_bytes()); + out.push(0); // tag flags + out.extend_from_slice(&body); + out + } + + fn root_of(props: Vec) -> Vec { + let mut out = fstring("/Script/G1R.GameStateDataBaseSaveData"); + out.push(0); + out.extend_from_slice(&props); + out.extend_from_slice(&fstring("None")); + out.extend_from_slice(&0u32.to_le_bytes()); + out + } + + /// A door state carrying the shape a real save has: the game appends to + /// `m_DoorsOpen` without a uniqueness check, so one door occurs many times. + fn door_payload(open: &[&str], closed: &[&str]) -> Vec { + let mut props = name_container("ArrayProperty", DOORS_OPEN_PROPERTY, open); + props.extend_from_slice(&name_container( + "ArrayProperty", + DOORS_CLOSED_PROPERTY, + closed, + )); + root_of(props) + } + + #[test] + fn closing_a_door_strips_every_copy_of_its_name() { + let payload = door_payload( + &["OC_Santino_Door", "CV_Stash_Door", "OC_Santino_Door"], + &[], + ); + let root = properties::parse_private_root(&payload).unwrap(); + let plan = plan_close_door_leaf(&root, "OC_Santino_Door") + .unwrap() + .expect("the door is open"); + assert_eq!( + plan.open_indices, + [0, 2], + "one removal would leave the door open" + ); + assert!(plan.closed_needs_entry); + assert!(plan.closed_path.is_some()); + } + + #[test] + fn a_door_already_in_the_closed_list_needs_no_second_entry() { + let payload = door_payload(&["CV_Stash_Door"], &["CV_Stash_Door"]); + let root = properties::parse_private_root(&payload).unwrap(); + let plan = plan_close_door_leaf(&root, "CV_Stash_Door") + .unwrap() + .expect("still listed as open"); + // m_DoorsOpen is scanned first and wins, so the stale open entry still + // has to go — but the closed entry must not be duplicated. + assert_eq!(plan.open_indices, [0]); + assert!(!plan.closed_needs_entry); + } + + #[test] + fn a_chest_has_no_leaf_to_close() { + let payload = door_payload(&["OC_Santino_Door"], &[]); + let root = properties::parse_private_root(&payload).unwrap(); + assert!( + plan_close_door_leaf(&root, "IO_OC_CHEST_DEXTER") + .unwrap() + .is_none(), + "chests never appear in m_DoorsOpen" + ); + } + + #[test] + fn a_shared_class_bucket_is_refused_rather_than_shutting_them_all() { + // These names stand for every door that has no unique name of its own; + // one save holds 42 copies of GenericDoorSavedState alone. + let payload = door_payload(&["GenericDoorSavedState", "CV_Stash_Door"], &[]); + let root = properties::parse_private_root(&payload).unwrap(); + let error = plan_close_door_leaf(&root, "GenericDoorSavedState") + .expect_err("a class bucket must be refused"); + assert!( + error.to_string().contains("whole class of doors"), + "unexpected error: {error}" + ); + } + + #[test] + fn a_door_that_is_already_shut_is_left_alone() { + let payload = door_payload(&["CV_Stash_Door"], &[]); + let root = properties::parse_private_root(&payload).unwrap(); + assert!( + plan_close_door_leaf(&root, "OC_Cellar_Door") + .unwrap() + .is_none() + ); + } + + #[test] + fn a_save_without_the_door_arrays_is_not_an_error() { + let payload = payload_with_locks("NameProperty", &["OC_Santino_Door"]); + let root = properties::parse_private_root(&payload).unwrap(); + assert!( + plan_close_door_leaf(&root, "OC_Santino_Door") + .unwrap() + .is_none() + ); + } + + #[test] + fn lists_every_name_in_the_set() { + let payload = + payload_with_locks("NameProperty", &["IO_OC_CHEST_DEXTER", "OC_Santino_Door"]); + let root = properties::parse_private_root(&payload).unwrap(); + let summary = list_locks(&root); + assert_eq!(summary.unlocked, ["IO_OC_CHEST_DEXTER", "OC_Santino_Door"]); + assert!(summary.writable); + } + + #[test] + fn an_empty_set_is_still_writable() { + let payload = payload_with_locks("NameProperty", &[]); + let root = properties::parse_private_root(&payload).unwrap(); + let summary = list_locks(&root); + assert!(summary.unlocked.is_empty()); + assert!( + summary.writable, + "a fresh save carries the set with 0 elements" + ); + } + + #[test] + fn a_set_of_something_unsplicable_is_reported_read_only() { + // An int set parses fine but SetAdd/SetRemove refuse it, so the panel + // must be told the save is read-only here instead of offering a toggle. + let payload = payload_with_locks("IntProperty", &[]); + let root = properties::parse_private_root(&payload).unwrap(); + assert!(!list_locks(&root).writable); + } + + #[test] + fn membership_is_case_insensitive_like_fname() { + let payload = payload_with_locks("NameProperty", &["IO_OC_CHEST_DEXTER"]); + let (contains, path) = lock_snapshot(&payload, "io_oc_chest_dexter").unwrap(); + assert!(contains); + assert!(path.is_some()); + let (absent, _) = lock_snapshot(&payload, "IO_OC_CHEST_STONE").unwrap(); + assert!(!absent); + } +} diff --git a/crates/gore-save/tests/locks.rs b/crates/gore-save/tests/locks.rs new file mode 100644 index 000000000..f089aba62 --- /dev/null +++ b/crates/gore-save/tests/locks.rs @@ -0,0 +1,219 @@ +//! Lock read + edit roundtrip over the JSON command surface. +//! +//! Runs against the embedded game-start save, so it needs no `GORE_SAVE` and no +//! game install: the shipped bytes already carry `m_UnlockedLocks` as an empty +//! `Set`, which is exactly the state a new game starts in. +//! +//! cargo test -p gore-save --test locks -- --nocapture + +use serde_json::{Value, json}; + +fn exec(req: Value) -> Value { + let resp: Value = serde_json::from_str(&gore_save::execute_json(&req.to_string())).unwrap(); + assert_eq!(resp["ok"], json!(true), "request failed: {resp}"); + resp["data"].clone() +} + +fn exec_err(req: Value) -> String { + let resp: Value = serde_json::from_str(&gore_save::execute_json(&req.to_string())).unwrap(); + assert_eq!( + resp["ok"], + json!(false), + "request unexpectedly succeeded: {resp}" + ); + resp["error"].to_string() +} + +fn start_save(name: &str) -> String { + let mut p = std::env::temp_dir(); + p.push(format!("gore_locks_{name}.sav")); + std::fs::write( + &p, + gore_save::startsaves::start_save_bytes(gore_save::startsaves::ResourcesLevel::Gothic), + ) + .expect("write temp save"); + p.to_string_lossy().to_string() +} + +fn out_path(name: &str) -> String { + let mut p = std::env::temp_dir(); + p.push(format!("gore_locks_{name}_out.sav")); + p.to_string_lossy().to_string() +} + +fn list(path: &str) -> Value { + exec(json!({ "command": "private.locks.list", "payload": { "path": path } })) +} + +fn unlocked(path: &str) -> Vec { + list(path)["unlocked"] + .as_array() + .expect("unlocked array") + .iter() + .map(|v| v.as_str().unwrap().to_string()) + .collect() +} + +fn write(path: &str, out: &str, edits: Vec) -> Value { + exec(json!({ + "command": "write_save", + "payload": { "path": path, "outputPath": out, "edits": edits }, + })) +} + +fn set_unlocked(lock: &str, unlocked: bool) -> Value { + json!({ + "path": "private.locks.setUnlocked", + "value": { "lock": lock, "unlocked": unlocked }, + }) +} + +#[test] +fn a_fresh_save_has_no_unlocked_locks_but_is_writable() { + let path = start_save("fresh"); + let data = list(&path); + assert_eq!(data["unlocked"], json!([])); + assert_eq!(data["writable"], json!(["private.locks.setUnlocked"])); +} + +#[test] +fn unlocking_a_lock_adds_its_name_to_the_set() { + let path = start_save("unlock"); + let out = out_path("unlock"); + write(&path, &out, vec![set_unlocked("IO_OC_CHEST_DEXTER", true)]); + assert_eq!(unlocked(&out), ["IO_OC_CHEST_DEXTER"]); +} + +#[test] +fn locking_it_again_removes_the_name() { + let path = start_save("relock"); + let opened = out_path("relock_opened"); + let closed = out_path("relock_closed"); + write( + &path, + &opened, + vec![set_unlocked("OC_Guards_Cell_01_Door", true)], + ); + assert_eq!(unlocked(&opened), ["OC_Guards_Cell_01_Door"]); + + write( + &opened, + &closed, + vec![set_unlocked("OC_Guards_Cell_01_Door", false)], + ); + assert!(unlocked(&closed).is_empty()); +} + +#[test] +fn chest_and_door_locks_batch_into_one_write() { + let path = start_save("batch"); + let out = out_path("batch"); + write( + &path, + &out, + vec![ + set_unlocked("IO_OC_CHEST_DEXTER", true), + set_unlocked("OC_Santino_Door", true), + set_unlocked("IO_NC_CHEST_RICELORD", true), + ], + ); + let mut names = unlocked(&out); + names.sort(); + assert_eq!( + names, + [ + "IO_NC_CHEST_RICELORD", + "IO_OC_CHEST_DEXTER", + "OC_Santino_Door" + ] + ); +} + +#[test] +fn an_intent_that_already_holds_is_a_no_op() { + let path = start_save("idempotent"); + let once = out_path("idempotent_once"); + let twice = out_path("idempotent_twice"); + write(&path, &once, vec![set_unlocked("IO_OC_CHEST_STONE", true)]); + // Re-applying the same desired state must not fail on a duplicate insert, + // which is what makes a retry after a partial write safe. + write(&once, &twice, vec![set_unlocked("IO_OC_CHEST_STONE", true)]); + assert_eq!(unlocked(&twice), ["IO_OC_CHEST_STONE"]); + + // Locking something that was never unlocked is equally a no-op. + let never = out_path("idempotent_never"); + write(&twice, &never, vec![set_unlocked("IO_SW_CHEST_01", false)]); + assert_eq!(unlocked(&never), ["IO_OC_CHEST_STONE"]); +} + +#[test] +fn two_intents_for_the_same_lock_are_refused() { + let path = start_save("conflict"); + let out = out_path("conflict"); + let error = exec_err(json!({ + "command": "write_save", + "payload": { + "path": path, + "outputPath": out, + "edits": [ + set_unlocked("IO_OC_CHEST_DEXTER", true), + set_unlocked("IO_OC_CHEST_DEXTER", false), + ], + }, + })); + assert!( + error.contains("lock state of that chest or door"), + "unexpected error: {error}" + ); + assert!( + !std::path::Path::new(&out).exists(), + "a refused write must leave no output file" + ); +} + +#[test] +fn relocking_and_raw_door_array_edits_are_refused_in_both_orders() { + let path = start_save("door_conflict"); + let dir = tempfile::tempdir().unwrap(); + let out = dir.path().join("refused.sav"); + let lock = set_unlocked("CV_Stash_Door", false); + let raw = json!({ + "path": "private.typed.arrayRemove", + "value": {"path": ["m_DoorsOpen"], "index": 0}, + }); + for edits in [vec![lock.clone(), raw.clone()], vec![raw, lock]] { + let error = exec_err(json!({ + "command": "write_save", + "payload": {"path": path, "outputPath": out, "edits": edits}, + })); + assert!( + error.contains("rewrites as a whole"), + "unexpected error: {error}" + ); + assert!(!out.exists()); + } +} + +#[test] +fn a_missing_or_malformed_value_is_refused() { + let path = start_save("malformed"); + let out = out_path("malformed"); + for value in [ + json!({ "unlocked": true }), + json!({ "lock": " ", "unlocked": true }), + json!({ "lock": "IO_OC_CHEST_DEXTER" }), + ] { + let error = exec_err(json!({ + "command": "write_save", + "payload": { + "path": path, + "outputPath": out, + "edits": [{ "path": "private.locks.setUnlocked", "value": value }], + }, + })); + assert!( + error.contains("private.locks.setUnlocked"), + "unexpected error: {error}" + ); + } +} diff --git a/scripts/test_build_run.py b/scripts/test_build_run.py new file mode 100644 index 000000000..6986aeb8e --- /dev/null +++ b/scripts/test_build_run.py @@ -0,0 +1,55 @@ +"""Launching an existing app must refresh both its UI and native core.""" + +from pathlib import Path +import sys +import tempfile +import unittest +from unittest import mock + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) +import build as gore_build + + +class RunBuildTest(unittest.TestCase): + def test_existing_exe_launches_with_current_ui_and_core(self): + for existing_core in (False, True): + with self.subTest(existing_core=existing_core), tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + bundle = root / 'apps/save-editor/build/windows/x64/runner/Debug' + bundle.mkdir(parents=True) + (bundle / 'goresave.exe').write_bytes(b'existing runner') + kernel = bundle / 'data/flutter_assets/kernel_blob.bin' + kernel.parent.mkdir(parents=True) + kernel.write_bytes(b'stale UI') + if existing_core: + (bundle / 'gore_save.dll').write_bytes(b'stale core') + + def compile_step(label, args, **kwargs): + if args[0] == gore_build.CARGO: + dll = root / 'target/debug/gore_save.dll' + dll.parent.mkdir(parents=True) + dll.write_bytes(b'current core') + elif args[0] == gore_build.FLUTTER: + kernel.write_bytes(b'current UI') + else: + self.fail(f'Unexpected build step: {label}') + + def launch(args, **kwargs): + self.assertEqual(Path(args[0]), bundle / 'goresave.exe') + self.assertEqual(kwargs['cwd'], bundle) + self.assertEqual(kernel.read_bytes(), b'current UI') + self.assertEqual((bundle / 'gore_save.dll').read_bytes(), b'current core') + + with ( + mock.patch.object(gore_build, 'ROOT', root), + mock.patch.object(gore_build, 'run', side_effect=compile_step), + mock.patch.object(gore_build, 'discard_line_ending_only_churn'), + mock.patch.object(gore_build, 'resolve_git_sha', return_value='test-sha'), + mock.patch.object(gore_build.subprocess, 'Popen', side_effect=launch) as process, + ): + gore_build.run_project('gore-save-editor', release=False) + process.assert_called_once() + + +if __name__ == '__main__': + unittest.main() From 1a57a5f7156179249e52d0a68491ec4772571ca0 Mon Sep 17 00:00:00 2001 From: Daniel Hoer Date: Wed, 9 Sep 2026 23:00:13 +0200 Subject: [PATCH 2/7] fix(editor): correct launch and lock difficulty --- .../lib/features/editor/ui/locks_panel.dart | 12 ++------ .../features/editor/ui/locks_panel_test.dart | 19 +++++++++---- build.py | 4 +-- scripts/test_build_run.py | 28 +++++++++++++++++++ 4 files changed, 45 insertions(+), 18 deletions(-) diff --git a/apps/save-editor/lib/features/editor/ui/locks_panel.dart b/apps/save-editor/lib/features/editor/ui/locks_panel.dart index 25b014fb9..83a09e264 100644 --- a/apps/save-editor/lib/features/editor/ui/locks_panel.dart +++ b/apps/save-editor/lib/features/editor/ui/locks_panel.dart @@ -568,17 +568,11 @@ class _LockList extends StatelessWidget { /// takes to open it. Widget? _subtitle(_LockRow row) { final text = _subtitleText(row); - final entry = row.entry; - final hasMeter = - entry != null && (entry.difficulty != null || entry.lockId != null); - if (!hasMeter) return text == null ? null : Text(text); + final difficulty = row.entry?.difficulty; + if (difficulty == null) return text == null ? null : Text(text); return Row( children: [ - _DifficultyBars( - difficulty: entry.difficulty ?? 0, - theme: theme, - l10n: l10n, - ), + _DifficultyBars(difficulty: difficulty, theme: theme, l10n: l10n), if (text != null) ...[ const SizedBox(width: 8), Flexible(child: Text(text, overflow: TextOverflow.ellipsis)), diff --git a/apps/save-editor/test/features/editor/ui/locks_panel_test.dart b/apps/save-editor/test/features/editor/ui/locks_panel_test.dart index 704b13806..6c343f7a1 100644 --- a/apps/save-editor/test/features/editor/ui/locks_panel_test.dart +++ b/apps/save-editor/test/features/editor/ui/locks_panel_test.dart @@ -489,20 +489,27 @@ void main() { expect(find.byTooltip('New Camp'), findsNothing); }); - testWidgets('a lock with no authored tier still shows an empty meter', ( + testWidgets('a lock with no authored tier omits the difficulty meter', ( tester, ) async { - // Seven doors carry a lock config but no m_LockDifficulty. The int is then - // 0 and the game fills no pips — an empty meter is what it draws, so the - // row must not silently drop the fact that there is a lock at all. + // A lock id does not supply a pickable difficulty. Keep the lock row, + // but do not turn a missing catalog tier into a displayed zero. final core = _LocksCore(); await _panel(tester, await _notifier(tester, core)); final meter = find.descendant( of: find.byKey(const ValueKey('lock-OC_Guards_Cell_01_Door')), - matching: find.byTooltip('Difficulty 0 of 4 (internal tier 0 of 7)'), + matching: find.byWidgetPredicate( + (widget) => + widget is Tooltip && + (widget.message?.startsWith('Difficulty') ?? false), + ), + ); + expect( + find.byKey(const ValueKey('lock-OC_Guards_Cell_01_Door')), + findsOneWidget, ); - expect(meter, findsOneWidget); + expect(meter, findsNothing); }); testWidgets('the difficulty tooltip names both scales', (tester) async { diff --git a/build.py b/build.py index d7f579107..e57e56b43 100644 --- a/build.py +++ b/build.py @@ -2652,9 +2652,7 @@ def main() -> int: raise SystemExit("build --run takes exactly one project (not 'all')") t = targets[0] release = args.release or not args.debug - runnable_exe(t, release) # rejects non-runnable before the rebuild - build_project(t, release=release, dry=False) - run_project(t, release=release) # exe just built, so this only launches + run_project(t, release=release) return 0 targets = expand_targets( diff --git a/scripts/test_build_run.py b/scripts/test_build_run.py index 6986aeb8e..e55fdf4cf 100644 --- a/scripts/test_build_run.py +++ b/scripts/test_build_run.py @@ -11,6 +11,34 @@ class RunBuildTest(unittest.TestCase): + def test_build_run_builds_once_before_launching(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + bundle = root / 'apps/save-editor/build/windows/x64/runner/Debug' + bundle.mkdir(parents=True) + exe = bundle / 'goresave.exe' + exe.write_bytes(b'stale runner') + events = [] + + def build(project, release, dry): + self.assertEqual((project, release, dry), ('gore-save-editor', False, False)) + events.append('build') + exe.write_bytes(b'current runner') + + def launch(args, **kwargs): + self.assertEqual(Path(args[0]), exe) + self.assertEqual(exe.read_bytes(), b'current runner') + events.append('launch') + + with ( + mock.patch.object(gore_build, 'ROOT', root), + mock.patch.object(gore_build, 'build_project', side_effect=build), + mock.patch.object(gore_build.subprocess, 'Popen', side_effect=launch), + mock.patch.object(sys, 'argv', ['build.py', 'gore-save-editor', 'build', '--debug', '--run']), + ): + self.assertEqual(gore_build.main(), 0) + self.assertEqual(events, ['build', 'launch']) + def test_existing_exe_launches_with_current_ui_and_core(self): for existing_core in (False, True): with self.subTest(existing_core=existing_core), tempfile.TemporaryDirectory() as tmp: From 1442bdb1e99411c3e3ff009493eb9e316bf946a0 Mon Sep 17 00:00:00 2001 From: Daniel Hoer Date: Wed, 9 Sep 2026 23:14:00 +0200 Subject: [PATCH 3/7] fix(editor): guard door message relock conflicts --- .../editor/domain/editor_notifier.dart | 26 ++++++++ .../test/editor_notifier_test.dart | 65 +++++++++++++++++++ crates/gore-save/src/lib.rs | 11 +++- crates/gore-save/tests/locks.rs | 35 ++++++---- 4 files changed, 120 insertions(+), 17 deletions(-) diff --git a/apps/save-editor/lib/features/editor/domain/editor_notifier.dart b/apps/save-editor/lib/features/editor/domain/editor_notifier.dart index 4428611ec..d638af866 100644 --- a/apps/save-editor/lib/features/editor/domain/editor_notifier.dart +++ b/apps/save-editor/lib/features/editor/domain/editor_notifier.dart @@ -1781,6 +1781,31 @@ class EditorNotifier extends StateNotifier { state = state.copyWith(error: _l10n.editorTraderArrayConflict); return false; } + // Relocking pairs message names with structs by index. Splitting a raw + // change into an earlier write could remap the door to another message, + // so reject the combination before any sub-write reaches the save. + final hasRelock = allEdits.any((keyed) { + final edit = keyed.edit; + final value = edit['value']; + return edit['path'] == 'private.locks.setUnlocked' && + value is Map && + value['unlocked'] == false; + }); + if (hasRelock) { + for (final keyed in allEdits) { + final path = _rawTypedEditPath(keyed.edit); + if (path != null && + const [ + 'm_SavedDoorsMessagesName', + 'm_SavedDoorsMessagesStruct', + ].any((name) => _pathHasName(path, name))) { + state = state.copyWith( + error: _l10n.editorConflictingPropertyEdits(path.join(' › ')), + ); + return false; + } + } + } final fixedBatch = allEdits .where( (k) => @@ -4876,6 +4901,7 @@ bool structuredEditRewrites( const [ 'm_DoorsOpen', 'm_DoorsClosed', + 'm_SavedDoorsMessagesName', 'm_SavedDoorsMessagesStruct', ].any((name) => _pathHasName(typedPath, name))); // Claims a whole slot — but only in the inventory it targets; another diff --git a/apps/save-editor/test/editor_notifier_test.dart b/apps/save-editor/test/editor_notifier_test.dart index 4d2bd9c3e..364d271c0 100644 --- a/apps/save-editor/test/editor_notifier_test.dart +++ b/apps/save-editor/test/editor_notifier_test.dart @@ -1380,6 +1380,70 @@ void main() { }, ); + test( + 'saveAllPending refuses raw door messages only when relocking', + () async { + for (final unlocked in [true, false]) { + for (final property in [ + 'm_SavedDoorsMessagesName', + 'm_SavedDoorsMessagesStruct', + ]) { + for (final operation in [ + 'arrayRemove', + 'arrayDuplicate', + 'setValue', + ]) { + for (final rawFirst in [true, false]) { + final core = _RecordingCoreService(); + final notifier = EditorNotifier(core, saveDir: r'C:\tmp\saves'); + await notifier.inspect(r'C:\tmp\saves\G1R-001.sav'); + notifier.setPendingEdit( + rawFirst ? 'a:raw' : 'z:raw', + PendingSaveEdit( + edits: [ + { + 'path': 'private.typed.$operation', + 'value': { + 'path': [property], + 'index': 0, + 'value': 'OtherDoor', + }, + }, + ], + ), + ); + notifier.setPendingEdit( + 'lock', + PendingSaveEdit( + edits: [ + { + 'path': 'private.locks.setUnlocked', + 'value': {'lock': 'CV_Stash_Door', 'unlocked': unlocked}, + }, + ], + ), + ); + + expect(await notifier.saveAllPending(), unlocked); + final writes = core.requests.where( + (r) => r.command == 'write_save', + ); + if (unlocked) { + expect(writes, isNotEmpty); + expect(notifier.state.error, isNull); + } else { + expect(writes, isEmpty); + expect(notifier.state.error, contains(property)); + expect(notifier.state.pendingEdits, hasLength(2)); + } + notifier.dispose(); + } + } + } + } + }, + ); + test( 'saveAllPending refuses a typed edit to a glossary quest CurrentState path', () async { @@ -3054,6 +3118,7 @@ void main() { for (final property in [ 'm_DoorsOpen', 'm_DoorsClosed', + 'm_SavedDoorsMessagesName', 'm_SavedDoorsMessagesStruct', ]) { expect(structuredEditRewrites(lock, [property]), !unlocked); diff --git a/crates/gore-save/src/lib.rs b/crates/gore-save/src/lib.rs index 6355f366a..0ea1f94f7 100644 --- a/crates/gore-save/src/lib.rs +++ b/crates/gore-save/src/lib.rs @@ -11498,9 +11498,14 @@ fn structured_edit_rewrites(edit: &PrivateEdit, path: &[properties::PathSeg]) -> PrivateEdit::LockSetUnlocked(edit) => { path_has_name(path, locks::UNLOCKED_LOCKS_PROPERTY) || (!edit.unlocked - && ["m_DoorsOpen", "m_DoorsClosed", "m_SavedDoorsMessagesStruct"] - .iter() - .any(|name| path_has_name(path, name))) + && [ + "m_DoorsOpen", + "m_DoorsClosed", + "m_SavedDoorsMessagesName", + "m_SavedDoorsMessagesStruct", + ] + .iter() + .any(|name| path_has_name(path, name))) } // Claims a whole slot: the add fills a blank one and resets its payload, the // removal blanks one. Only in the inventory it targets — another actor's diff --git a/crates/gore-save/tests/locks.rs b/crates/gore-save/tests/locks.rs index f089aba62..ae8827683 100644 --- a/crates/gore-save/tests/locks.rs +++ b/crates/gore-save/tests/locks.rs @@ -177,20 +177,27 @@ fn relocking_and_raw_door_array_edits_are_refused_in_both_orders() { let dir = tempfile::tempdir().unwrap(); let out = dir.path().join("refused.sav"); let lock = set_unlocked("CV_Stash_Door", false); - let raw = json!({ - "path": "private.typed.arrayRemove", - "value": {"path": ["m_DoorsOpen"], "index": 0}, - }); - for edits in [vec![lock.clone(), raw.clone()], vec![raw, lock]] { - let error = exec_err(json!({ - "command": "write_save", - "payload": {"path": path, "outputPath": out, "edits": edits}, - })); - assert!( - error.contains("rewrites as a whole"), - "unexpected error: {error}" - ); - assert!(!out.exists()); + for property in [ + "m_DoorsOpen", + "m_DoorsClosed", + "m_SavedDoorsMessagesName", + "m_SavedDoorsMessagesStruct", + ] { + let raw = json!({ + "path": "private.typed.arrayRemove", + "value": {"path": [property], "index": 0}, + }); + for edits in [vec![lock.clone(), raw.clone()], vec![raw, lock.clone()]] { + let error = exec_err(json!({ + "command": "write_save", + "payload": {"path": path, "outputPath": out, "edits": edits}, + })); + assert!( + error.contains("rewrites as a whole"), + "unexpected error for {property}: {error}" + ); + assert!(!out.exists()); + } } } From 42661dd4d7c03bce2f3b5a6cc73350c19505d8c0 Mon Sep 17 00:00:00 2001 From: Daniel Hoer Date: Wed, 9 Sep 2026 23:24:32 +0200 Subject: [PATCH 4/7] fix(editor): preserve stored lock spelling --- crates/gore-save/src/lib.rs | 32 +++++++++------ crates/gore-save/src/locks.rs | 74 ++++++++++++++++++++++++++++------- 2 files changed, 81 insertions(+), 25 deletions(-) diff --git a/crates/gore-save/src/lib.rs b/crates/gore-save/src/lib.rs index 0ea1f94f7..901b1e1b9 100644 --- a/crates/gore-save/src/lib.rs +++ b/crates/gore-save/src/lib.rs @@ -14021,32 +14021,37 @@ fn apply_private_lock_set_unlocked_to_payload( edit: &PrivateLockSetUnlockedEdit, ) -> Result<(), CoreError> { let mut patched = payload.clone(); - let (contains, set_path) = locks::lock_snapshot(&patched, &edit.lock)?; + let (stored_names, set_path) = locks::lock_snapshot(&patched, &edit.lock)?; let Some(set_path) = set_path else { return Err(CoreError::UnsupportedEdit(format!( "this save has no {} set to edit", locks::UNLOCKED_LOCKS_PROPERTY ))); }; - if contains && edit.unlocked { + if !stored_names.is_empty() && edit.unlocked { return Ok(()); } - if contains != edit.unlocked { + let changes: Vec<_> = if edit.unlocked { + vec![properties::ContainerEdit::SetAdd(edit.lock.clone())] + } else { + stored_names + .into_iter() + .map(properties::ContainerEdit::SetRemove) + .collect() + }; + for change in changes { apply_private_typed_container_edit_to_payload( &mut patched, &PrivateTypedContainerEdit { - path: set_path, - edit: if edit.unlocked { - properties::ContainerEdit::SetAdd(edit.lock.clone()) - } else { - properties::ContainerEdit::SetRemove(edit.lock.clone()) - }, + path: set_path.clone(), + edit: change, }, )?; } - let (final_contains, _) = locks::lock_snapshot(&patched, &edit.lock)?; + let (final_names, _) = locks::lock_snapshot(&patched, &edit.lock)?; + let final_contains = !final_names.is_empty(); if final_contains != edit.unlocked { return Err(CoreError::Validation(format!( "lock postcondition failed for {:?}", @@ -17021,7 +17026,12 @@ mod tests { let (open, closed) = door_arrays(&payload); assert_eq!(open, ["OC_Cellar_Door"]); assert_eq!(closed, ["CV_Stash_Door"]); - assert!(!locks::lock_snapshot(&payload, "CV_Stash_Door").unwrap().0); + assert!( + locks::lock_snapshot(&payload, "CV_Stash_Door") + .unwrap() + .0 + .is_empty() + ); } #[test] diff --git a/crates/gore-save/src/locks.rs b/crates/gore-save/src/locks.rs index cf42eb932..7970e5711 100644 --- a/crates/gore-save/src/locks.rs +++ b/crates/gore-save/src/locks.rs @@ -261,19 +261,19 @@ pub fn list_locks(root: &RootObject) -> LocksSummary { } } -/// Whether `name` is in the set right now, plus the path that addresses the +/// Stored spellings of `name` in the set, plus the path that addresses the /// set. `None` for the path means the save has no such set at all. /// -/// Name comparison is case-insensitive, matching UE `FName` semantics and the -/// `SetAdd`/`SetRemove` appliers, so a catalog entry that differs from a -/// save's spelling only in case is still recognized as present. +/// Lock IDs compare case-insensitively, matching UE `FName` semantics and the +/// editor. Preserve each stored spelling for removal: string sets compare +/// case-sensitively and can hold multiple case variants of the same lock ID. pub fn lock_snapshot( payload: &[u8], name: &str, -) -> Result<(bool, Option>), CoreError> { +) -> Result<(Vec, Option>), CoreError> { let root = properties::parse_private_root(payload)?; let Some((path, property)) = unlocked_locks(&root) else { - return Ok((false, None)); + return Ok((Vec::new(), None)); }; let PropertyValue::Set { elements, .. } = &property.value else { return Err(CoreError::Parse(format!( @@ -285,11 +285,18 @@ pub fn lock_snapshot( "{UNLOCKED_LOCKS_PROPERTY} holds elements this build cannot splice" ))); } - let contains = elements.iter().any(|element| match element { - PropertyValue::Name(value) | PropertyValue::Str(value) => value.eq_ignore_ascii_case(name), - _ => false, - }); - Ok((contains, Some(properties::parse_path(&path)?))) + let stored_names = elements + .iter() + .filter_map(|element| match element { + PropertyValue::Name(value) | PropertyValue::Str(value) + if value.eq_ignore_ascii_case(name) => + { + Some(value.clone()) + } + _ => None, + }) + .collect(); + Ok((stored_names, Some(properties::parse_path(&path)?))) } #[cfg(test)] @@ -487,10 +494,49 @@ mod tests { #[test] fn membership_is_case_insensitive_like_fname() { let payload = payload_with_locks("NameProperty", &["IO_OC_CHEST_DEXTER"]); - let (contains, path) = lock_snapshot(&payload, "io_oc_chest_dexter").unwrap(); - assert!(contains); + let (stored_names, path) = lock_snapshot(&payload, "io_oc_chest_dexter").unwrap(); + assert_eq!(stored_names, ["IO_OC_CHEST_DEXTER"]); assert!(path.is_some()); let (absent, _) = lock_snapshot(&payload, "IO_OC_CHEST_STONE").unwrap(); - assert!(!absent); + assert!(absent.is_empty()); + } + + #[test] + fn relocking_removes_stored_case_variants_for_name_and_string_sets() { + for inner_type in ["NameProperty", "StrProperty"] { + let names: &[&str] = if inner_type == "StrProperty" { + &["io_oc_chest_dexter", "IO_OC_CHEST_DEXTER", "OtherLock"] + } else { + &["io_oc_chest_dexter", "OtherLock"] + }; + let mut payload = payload_with_locks(inner_type, names); + let before = payload.clone(); + let apply = |payload: &mut Vec, unlocked| { + crate::apply_private_lock_set_unlocked_to_payload( + payload, + &crate::PrivateLockSetUnlockedEdit { + lock: "IO_OC_CHEST_DEXTER".to_string(), + unlocked, + }, + ) + .unwrap(); + }; + apply(&mut payload, true); + assert_eq!(payload, before, "already unlocked: {inner_type}"); + apply(&mut payload, false); + let root = properties::parse_private_root(&payload).unwrap(); + assert_eq!(list_locks(&root).unlocked, ["OtherLock"]); + let locked = payload.clone(); + apply(&mut payload, false); + assert_eq!(payload, locked, "already locked: {inner_type}"); + apply(&mut payload, true); + let root = properties::parse_private_root(&payload).unwrap(); + assert_eq!( + list_locks(&root).unlocked, + ["OtherLock", "IO_OC_CHEST_DEXTER"] + ); + apply(&mut payload, false); + assert_eq!(payload, locked, "roundtrip: {inner_type}"); + } } } From 245cace4ecdcddee67955b68baf7e273f13afb73 Mon Sep 17 00:00:00 2001 From: Daniel Hoer Date: Wed, 9 Sep 2026 23:34:43 +0200 Subject: [PATCH 5/7] fix(editor): reject raw lock write conflicts --- .../editor/domain/editor_notifier.dart | 24 ++++++-------- .../test/editor_notifier_test.dart | 31 +++++++++++++------ 2 files changed, 30 insertions(+), 25 deletions(-) diff --git a/apps/save-editor/lib/features/editor/domain/editor_notifier.dart b/apps/save-editor/lib/features/editor/domain/editor_notifier.dart index d638af866..c09d9a8dc 100644 --- a/apps/save-editor/lib/features/editor/domain/editor_notifier.dart +++ b/apps/save-editor/lib/features/editor/domain/editor_notifier.dart @@ -1781,24 +1781,18 @@ class EditorNotifier extends StateNotifier { state = state.copyWith(error: _l10n.editorTraderArrayConflict); return false; } - // Relocking pairs message names with structs by index. Splitting a raw - // change into an earlier write could remap the door to another message, - // so reject the combination before any sub-write reaches the save. - final hasRelock = allEdits.any((keyed) { - final edit = keyed.edit; - final value = edit['value']; - return edit['path'] == 'private.locks.setUnlocked' && - value is Map && - value['unlocked'] == false; - }); - if (hasRelock) { + // Lock changes splice the lock set and door arrays; relocking also pairs + // message names with structs by index. Splitting a conflicting raw edit + // into another write can shift its target or remap a door message, so + // reject every affected container before any sub-write reaches the save. + final lockEdits = allEdits + .where((keyed) => keyed.edit['path'] == 'private.locks.setUnlocked') + .toList(); + if (lockEdits.isNotEmpty) { for (final keyed in allEdits) { final path = _rawTypedEditPath(keyed.edit); if (path != null && - const [ - 'm_SavedDoorsMessagesName', - 'm_SavedDoorsMessagesStruct', - ].any((name) => _pathHasName(path, name))) { + lockEdits.any((lock) => structuredEditRewrites(lock.edit, path))) { state = state.copyWith( error: _l10n.editorConflictingPropertyEdits(path.join(' › ')), ); diff --git a/apps/save-editor/test/editor_notifier_test.dart b/apps/save-editor/test/editor_notifier_test.dart index 364d271c0..5f36bb5b8 100644 --- a/apps/save-editor/test/editor_notifier_test.dart +++ b/apps/save-editor/test/editor_notifier_test.dart @@ -1381,18 +1381,20 @@ void main() { ); test( - 'saveAllPending refuses raw door messages only when relocking', + 'saveAllPending refuses raw edits a lock change can invalidate', () async { for (final unlocked in [true, false]) { for (final property in [ + 'm_UnlockedLocks', + 'm_DoorsOpen', + 'm_DoorsClosed', 'm_SavedDoorsMessagesName', 'm_SavedDoorsMessagesStruct', ]) { - for (final operation in [ - 'arrayRemove', - 'arrayDuplicate', - 'setValue', - ]) { + final operations = property == 'm_UnlockedLocks' + ? ['setRemove', 'setAdd', 'setValue'] + : ['arrayRemove', 'arrayDuplicate', 'setValue']; + for (final operation in operations) { for (final rawFirst in [true, false]) { final core = _RecordingCoreService(); final notifier = EditorNotifier(core, saveDir: r'C:\tmp\saves'); @@ -1404,9 +1406,17 @@ void main() { { 'path': 'private.typed.$operation', 'value': { - 'path': [property], + 'path': [ + property, + if (operation == 'setValue') '[0]', + if (operation == 'setValue' && + property == 'm_SavedDoorsMessagesStruct') + 'm_Magnitude', + ], 'index': 0, - 'value': 'OtherDoor', + 'value': property == 'm_SavedDoorsMessagesStruct' + ? 1.0 + : 'OtherDoor', }, }, ], @@ -1424,11 +1434,12 @@ void main() { ), ); - expect(await notifier.saveAllPending(), unlocked); + final canSave = unlocked && property != 'm_UnlockedLocks'; + expect(await notifier.saveAllPending(), canSave); final writes = core.requests.where( (r) => r.command == 'write_save', ); - if (unlocked) { + if (canSave) { expect(writes, isNotEmpty); expect(notifier.state.error, isNull); } else { From 420481721d826e19becd73bf68cdc658fe92b620 Mon Sep 17 00:00:00 2001 From: Daniel Hoer Date: Wed, 9 Sep 2026 23:45:55 +0200 Subject: [PATCH 6/7] fix(editor): distinguish unknown lock filters --- .../lib/features/editor/ui/locks_panel.dart | 36 ++++++++------- .../features/editor/ui/locks_panel_test.dart | 46 ++++++++++++++++++- 2 files changed, 65 insertions(+), 17 deletions(-) diff --git a/apps/save-editor/lib/features/editor/ui/locks_panel.dart b/apps/save-editor/lib/features/editor/ui/locks_panel.dart index 83a09e264..09f1aaba4 100644 --- a/apps/save-editor/lib/features/editor/ui/locks_panel.dart +++ b/apps/save-editor/lib/features/editor/ui/locks_panel.dart @@ -48,7 +48,7 @@ class _LockRow { final LockEntry? entry; final bool unlocked; - bool get isDoor => entry?.kind == LockKind.door; + LockKind? get kind => entry?.kind; String get area => entry?.area ?? ''; String get search => entry?.search ?? name.toLowerCase(); } @@ -100,7 +100,7 @@ class _LocksDetailState extends ConsumerState { final Map _pending = {}; String _query = ''; - String _area = ''; + String? _area; Map _searchIndex = const {}; Object? _searchIndexKey; _KindFilter _kind = _KindFilter.all; @@ -230,12 +230,12 @@ class _LocksDetailState extends ConsumerState { /// [withArea] false answers "would this row match if no region were /// selected?", which is what the rail's per-region counts need. bool _matchesFilters(_LockRow row, {bool withArea = true}) { - if (withArea && _area.isNotEmpty && row.area != _area) return false; + if (withArea && _area != null && row.area != _area) return false; switch (_kind) { case _KindFilter.chests: - if (row.isDoor) return false; + if (row.kind != LockKind.chest) return false; case _KindFilter.doors: - if (!row.isDoor) return false; + if (row.kind != LockKind.door) return false; case _KindFilter.all: break; } @@ -405,13 +405,13 @@ class _RegionRail extends StatelessWidget { final Map counts; final int total; - final String selected; + final String? selected; final LocationCatalog? locations; final Map> locCatalog; final GameLang lang; final AppLocalizations l10n; final ThemeData theme; - final ValueChanged onSelected; + final ValueChanged onSelected; String _label(String area) { final catalog = locations; @@ -435,8 +435,8 @@ class _RegionRail extends StatelessWidget { _RegionTile( label: l10n.locksAllRegions, count: total, - selected: selected.isEmpty, - onTap: () => onSelected(''), + selected: selected == null, + onTap: () => onSelected(null), theme: theme, ), for (final area in areas) @@ -584,7 +584,7 @@ class _LockList extends StatelessWidget { @override Widget build(BuildContext context) { final scheme = theme.colorScheme; - final anyDoorVisible = rows.any((row) => row.isDoor); + final anyDoorVisible = rows.any((row) => row.kind == LockKind.door); return Column( crossAxisAlignment: CrossAxisAlignment.stretch, @@ -680,12 +680,16 @@ class _LockList extends StatelessWidget { // The game's own marks: the interaction glyph it draws on // a container and on a door. leading: GameIcon( - name: row.isDoor - ? 'T_Interaction_Door' - : 'T_Interaction_Loot', - fallbackIcon: row.isDoor - ? Icons.meeting_room_outlined - : Icons.inventory_2_outlined, + name: switch (row.kind) { + LockKind.door => 'T_Interaction_Door', + LockKind.chest => 'T_Interaction_Loot', + null => null, + }, + fallbackIcon: switch (row.kind) { + LockKind.door => Icons.meeting_room_outlined, + LockKind.chest => Icons.inventory_2_outlined, + null => Icons.lock_outline, + }, color: effective ? scheme.primary : scheme.outline, ), title: Text(row.name), diff --git a/apps/save-editor/test/features/editor/ui/locks_panel_test.dart b/apps/save-editor/test/features/editor/ui/locks_panel_test.dart index 6c343f7a1..7b3c7227f 100644 --- a/apps/save-editor/test/features/editor/ui/locks_panel_test.dart +++ b/apps/save-editor/test/features/editor/ui/locks_panel_test.dart @@ -527,7 +527,7 @@ void main() { ); }); - testWidgets('a name the catalog does not know is still shown', ( + testWidgets('an unknown lock stays unclassified and visible under All', ( tester, ) async { // The catalog is cook-specific: a save from another build can carry a lock @@ -537,6 +537,50 @@ void main() { expect(find.text('IO_AM_CHEST_05'), findsOneWidget); expect(find.text('Not in this game version'), findsOneWidget); + expect( + find.descendant( + of: find.byKey(const ValueKey('lock-IO_AM_CHEST_05')), + matching: find.byIcon(Icons.lock_outline), + ), + findsOneWidget, + ); + for (final kind in ['chests', 'doors']) { + await tester.tap(find.byKey(ValueKey('locks-kind-$kind'))); + await tester.pumpAndSettle(); + expect(find.text('IO_AM_CHEST_05'), findsNothing); + expect(find.text('2 of 5'), findsOneWidget); + } + await tester.tap(find.byKey(const ValueKey('locks-kind-all'))); + await tester.pumpAndSettle(); + expect(find.text('IO_AM_CHEST_05'), findsOneWidget); + expect(find.text('5 of 5'), findsOneWidget); + }); + + testWidgets('Other isolates unknown regions independently from All regions', ( + tester, + ) async { + final core = _LocksCore(unlocked: ['IO_AM_CHEST_05']); + await _panel(tester, await _notifier(tester, core)); + final all = find.byKey(const ValueKey('locks-region-All regions')); + final other = find.byKey(const ValueKey('locks-region-Other')); + expect(tester.widget(all).selected, isTrue); + expect(tester.widget(other).selected, isFalse); + + await tester.tap(other); + await tester.pumpAndSettle(); + expect(tester.widget(all).selected, isFalse); + expect(tester.widget(other).selected, isTrue); + expect(find.text('IO_AM_CHEST_05'), findsOneWidget); + expect(find.text('IO_OC_CHEST_DEXTER'), findsNothing); + expect(find.text('1 of 5'), findsOneWidget); + + await tester.tap(all); + await tester.pumpAndSettle(); + expect(tester.widget(all).selected, isTrue); + expect(tester.widget(other).selected, isFalse); + expect(find.text('IO_AM_CHEST_05'), findsOneWidget); + expect(find.text('IO_OC_CHEST_DEXTER'), findsOneWidget); + expect(find.text('5 of 5'), findsOneWidget); }); testWidgets('a save with no editable lock set stays read-only', ( From 0474e136cd58a07e3316fe5ae4ffb0172c65d895 Mon Sep 17 00:00:00 2001 From: Daniel Hoer Date: Thu, 10 Sep 2026 00:07:13 +0200 Subject: [PATCH 7/7] fix(editor): preserve lock identity and door state --- .../lib/features/editor/ui/locks_panel.dart | 6 ++- .../features/editor/ui/locks_panel_test.dart | 52 +++++++++++++++++++ crates/gore-save/src/lib.rs | 50 ++++++++++++++++++ crates/gore-save/src/locks.rs | 21 +++++--- 4 files changed, 121 insertions(+), 8 deletions(-) diff --git a/apps/save-editor/lib/features/editor/ui/locks_panel.dart b/apps/save-editor/lib/features/editor/ui/locks_panel.dart index 09f1aaba4..aa8deb124 100644 --- a/apps/save-editor/lib/features/editor/ui/locks_panel.dart +++ b/apps/save-editor/lib/features/editor/ui/locks_panel.dart @@ -221,7 +221,7 @@ class _LocksDetailState extends ConsumerState { ]; final known = {for (final entry in catalog.locks) entry.name.toLowerCase()}; for (final name in _saved.unlocked) { - if (known.contains(name.toLowerCase())) continue; + if (!known.add(name.toLowerCase())) continue; rows.add(_LockRow(name: name, entry: null, unlocked: true)); } return rows; @@ -297,7 +297,9 @@ class _LocksDetailState extends ConsumerState { final visible = rows.where(_matchesFilters).toList(); // Region counts follow the kind/state/search filters but NOT the region // itself, so the rail keeps showing where the remaining matches are. - final byArea = {}; + // Keep an active region visible even when another filter removes its last + // match, so the restriction stays visible and can be cleared via All. + final byArea = {?_area: 0}; var acrossRegions = 0; for (final row in rows) { if (!_matchesFilters(row, withArea: false)) continue; diff --git a/apps/save-editor/test/features/editor/ui/locks_panel_test.dart b/apps/save-editor/test/features/editor/ui/locks_panel_test.dart index 7b3c7227f..3b19f61f0 100644 --- a/apps/save-editor/test/features/editor/ui/locks_panel_test.dart +++ b/apps/save-editor/test/features/editor/ui/locks_panel_test.dart @@ -342,6 +342,33 @@ void main() { expect(find.text('3 of 4'), findsOneWidget); }); + testWidgets('an active region with zero matches stays visible and selected', ( + tester, + ) async { + await _panel(tester, await _notifier(tester, _LocksCore())); + final newCamp = find.byKey(const ValueKey('locks-region-New Camp')); + final all = find.byKey(const ValueKey('locks-region-All regions')); + await tester.tap(newCamp); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('locks-kind-chests'))); + await tester.pumpAndSettle(); + + expect(tester.widget(newCamp).selected, isTrue); + expect( + find.descendant(of: newCamp, matching: find.text('0')), + findsOneWidget, + ); + expect(tester.widget(all).selected, isFalse); + expect(find.descendant(of: all, matching: find.text('2')), findsOneWidget); + expect(find.text('IO_OC_CHEST_DEXTER'), findsNothing); + + await tester.tap(all); + await tester.pumpAndSettle(); + expect(tester.widget(all).selected, isTrue); + expect(find.text('IO_OC_CHEST_DEXTER'), findsOneWidget); + expect(find.text('2 of 4'), findsOneWidget); + }); + testWidgets('the kind filter separates chests from doors', (tester) async { final core = _LocksCore(); await _panel(tester, await _notifier(tester, core)); @@ -556,6 +583,31 @@ void main() { expect(find.text('5 of 5'), findsOneWidget); }); + testWidgets('case variants of a save-only lock share one row and draft', ( + tester, + ) async { + final core = _LocksCore(unlocked: ['ModDoor', 'moddoor']); + final notifier = await _notifier(tester, core); + await _panel(tester, notifier); + final row = find.byKey(const ValueKey('lock-ModDoor')); + expect(row, findsOneWidget); + expect(find.byKey(const ValueKey('lock-moddoor')), findsNothing); + expect(find.text('5 of 5'), findsOneWidget); + + await tester.tap(row); + await _settle(tester); + expect(notifier.pendingEditFor('world.locks')!.edits.single, { + 'path': 'private.locks.setUnlocked', + 'value': {'lock': 'ModDoor', 'unlocked': false}, + }); + final toggle = find.descendant(of: row, matching: find.byType(Switch)); + expect(tester.widget(toggle).value, isFalse); + await tester.tap(row); + await _settle(tester); + expect(notifier.pendingEditFor('world.locks'), isNull); + expect(tester.widget(toggle).value, isTrue); + }); + testWidgets('Other isolates unknown regions independently from All regions', ( tester, ) async { diff --git a/crates/gore-save/src/lib.rs b/crates/gore-save/src/lib.rs index 901b1e1b9..661fc394d 100644 --- a/crates/gore-save/src/lib.rs +++ b/crates/gore-save/src/lib.rs @@ -17034,6 +17034,56 @@ mod tests { ); } + #[test] + fn relocking_checks_parallel_door_messages_before_mutating() { + for (name_count, struct_count) in [(1, 2), (2, 1), (2, 2)] { + let mut props = private_name_set_property("m_UnlockedLocks", &["TargetDoor"]); + props.extend(inv_name_array_property("m_DoorsOpen", &["TargetDoor"])); + props.extend(inv_name_array_property("m_DoorsClosed", &[])); + props.extend(inv_name_array_property( + "m_SavedDoorsMessagesName", + &["TargetDoor", "OtherDoor"][..name_count], + )); + let mut message = inv_name_property("m_Event", "m_CurrentSection"); + message.extend(private_double_property("m_Magnitude", 1.0)); + props.extend(inv_struct_array_property( + "m_SavedDoorsMessagesStruct", + "SavedDoorMessage", + &vec![message; struct_count], + )); + let mut payload = fstring("/Script/G1R.GameStateDataBaseSaveData"); + payload.push(0); + payload.extend(props); + payload.extend(fstring("None")); + payload.extend(0u32.to_le_bytes()); + let before = payload.clone(); + let result = apply_private_lock_set_unlocked_to_payload( + &mut payload, + &PrivateLockSetUnlockedEdit { + lock: "TargetDoor".to_string(), + unlocked: false, + }, + ); + if name_count != struct_count { + assert!(result.unwrap_err().to_string().contains("different lengths")); + assert_eq!(payload, before, "a refused relock must be atomic"); + } else { + result.unwrap(); + let root = properties::parse_private_root(&payload).unwrap(); + for (index, expected) in [(0, 0.0), (1, 1.0)] { + let path = properties::parse_path(&[ + "m_SavedDoorsMessagesStruct".to_string(), + format!("[{index}]"), + "m_Magnitude".to_string(), + ]) + .unwrap(); + let actual = &properties::resolve(&root.properties, &path).unwrap().value; + assert_eq!(actual, &properties::PropertyValue::Double(expected)); + } + } + } + } + #[test] fn shared_door_relock_is_refused_without_changing_the_payload() { let mut payload = door_state_payload(&["GenericDoorSavedState", "CV_Stash_Door"], &[]); diff --git a/crates/gore-save/src/locks.rs b/crates/gore-save/src/locks.rs index 7970e5711..75d76de78 100644 --- a/crates/gore-save/src/locks.rs +++ b/crates/gore-save/src/locks.rs @@ -145,16 +145,25 @@ pub fn plan_close_door_leaf( // if that message still says section 1, the leaf swings back open however // the arrays read. Zero it for this door only. let mut open_message_magnitudes = Vec::new(); - if let (Some((_, names)), Some((structs_path, structs))) = ( - array_elements(root, DOOR_MESSAGE_NAMES_PROPERTY), - array_elements(root, DOOR_MESSAGE_STRUCTS_PROPERTY), - ) { + let message_names = array_elements(root, DOOR_MESSAGE_NAMES_PROPERTY); + let message_structs = array_elements(root, DOOR_MESSAGE_STRUCTS_PROPERTY); + if message_names + .as_ref() + .map_or(0, |(_, entries)| entries.len()) + != message_structs + .as_ref() + .map_or(0, |(_, entries)| entries.len()) + { + return Err(CoreError::UnsupportedEdit(format!( + "cannot relock {name:?}: saved door-message name and struct arrays have different lengths" + ))); + } + if let (Some((_, names)), Some((structs_path, structs))) = (message_names, message_structs) { for (index, entry) in names.iter().enumerate() { if !element_is(entry, name) { continue; } - // The two arrays are parallel; a save whose lengths disagree is not - // one this can reason about, so it is left alone. + // Unknown message payloads are left intact. let Some(message) = structs.get(index).and_then(struct_properties) else { continue; };