From a483e29fdbe631c9f24a83261a243567807caa35 Mon Sep 17 00:00:00 2001 From: Robert Burnham Date: Tue, 15 Sep 2026 04:02:54 -0500 Subject: [PATCH] Migrate keybinds that keyload a bind file we no longer ship (#9249) Resolves a keyload of one of the deleted luaui/configs/hotkeys bind files to what that file used to bind, so migrating a player's own uikeys.txt no longer drops it. The four preset paths already resolve by name to the profile that replaced them, but the fragments those presets pulled in - the chat and UI keys, the grid menu, the number row - name no profile, and a uikeys.txt built by copying a preset carries exactly those three keyload lines. Their contents go in a new file under common/configs rather than restoring the four .txt files. Restoring them is the smaller change and needs no code, but it leaves files sitting at a path nothing selects any more, and the next cleanup deletes them and quietly reopens this. The new spec migrates a uikeys.txt holding those three keyloads plus one bind of the player's own: 1 binding on master, 192 with this. AI disclosure: written with assistance from Claude Code. --- common/configs/keybind_retired_includes.json | 786 ++++++++++++++++++ .../keybind_retired_includes.schema.json | 29 + common/configs/keybinds.README.md | 10 +- luaui/Include/keybind_profiles.lua | 46 +- spec/common/json_schema.lua | 10 + spec/common/keybind_catalog_spec.lua | 19 + spec/luaui/Include/keybind_profiles_spec.lua | 132 +++ 7 files changed, 1019 insertions(+), 13 deletions(-) create mode 100644 common/configs/keybind_retired_includes.json create mode 100644 common/configs/keybind_retired_includes.schema.json create mode 100644 spec/luaui/Include/keybind_profiles_spec.lua diff --git a/common/configs/keybind_retired_includes.json b/common/configs/keybind_retired_includes.json new file mode 100644 index 00000000000..561390a3706 --- /dev/null +++ b/common/configs/keybind_retired_includes.json @@ -0,0 +1,786 @@ +{ + "luaui/configs/hotkeys/chat_and_ui_keys.txt": [ + { + "keyset": "esc", + "action": "select AllMap++_ClearSelection_SelectNum_0+" + }, + { + "keyset": "esc", + "action": "quitmessage" + }, + { + "keyset": "Shift+esc", + "action": "quitmenu" + }, + { + "keyset": "Ctrl+Shift+esc", + "action": "quitforce" + }, + { + "keyset": "Alt+Shift+esc", + "action": "reloadforce" + }, + { + "keyset": "Any+escape", + "action": "edit_escape" + }, + { + "keyset": "Any+pause", + "action": "pause" + }, + { + "keyset": "esc", + "action": "teamstatus_close" + }, + { + "keyset": "esc", + "action": "customgameinfo_close" + }, + { + "keyset": "esc", + "action": "buildmenu_pregame_deselect" + }, + { + "keyset": "Alt+backspace", + "action": "fullscreen" + }, + { + "keyset": "Any+sc_z", + "action": "selectbox_same" + }, + { + "keyset": "Any+space", + "action": "selectbox_idle" + }, + { + "keyset": "Any+shift", + "action": "selectbox_append" + }, + { + "keyset": "Any+shift", + "action": "selectbox_any" + }, + { + "keyset": "Any+ctrl", + "action": "selectbox_deselect" + }, + { + "keyset": "Any+alt", + "action": "selectbox_mobile" + }, + { + "keyset": "Any+space", + "action": "selectloop" + }, + { + "keyset": "Any+ctrl", + "action": "selectloop_invert" + }, + { + "keyset": "Any+shift", + "action": "selectloop_add" + }, + { + "keyset": "Any+space", + "action": "buildsplit" + }, + { + "keyset": "Any+space", + "action": "commandinsert prepend_between" + }, + { + "keyset": "alt+sc_.", + "action": "attack_range_inc" + }, + { + "keyset": "alt+sc_comma", + "action": "attack_range_dec" + }, + { + "keyset": "Any+enter", + "action": "chat" + }, + { + "keyset": "Alt+ctrl+sc_a", + "action": "chatswitchally" + }, + { + "keyset": "Alt+ctrl+sc_s", + "action": "chatswitchspec" + }, + { + "keyset": "Any+tab", + "action": "edit_complete" + }, + { + "keyset": "Any+backspace", + "action": "edit_backspace" + }, + { + "keyset": "Any+delete", + "action": "edit_delete" + }, + { + "keyset": "Any+home", + "action": "edit_home" + }, + { + "keyset": "Alt+left", + "action": "edit_home" + }, + { + "keyset": "Any+end", + "action": "edit_end" + }, + { + "keyset": "Alt+right", + "action": "edit_end" + }, + { + "keyset": "Any+up", + "action": "edit_prev_line" + }, + { + "keyset": "Any+down", + "action": "edit_next_line" + }, + { + "keyset": "Any+left", + "action": "edit_prev_char" + }, + { + "keyset": "Any+right", + "action": "edit_next_char" + }, + { + "keyset": "Ctrl+left", + "action": "edit_prev_word" + }, + { + "keyset": "Ctrl+right", + "action": "edit_next_word" + }, + { + "keyset": "Any+enter", + "action": "edit_return" + }, + { + "keyset": "Ctrl+v", + "action": "pastetext" + }, + { + "keyset": "Any+up", + "action": "moveforward" + }, + { + "keyset": "Any+down", + "action": "moveback" + }, + { + "keyset": "Any+right", + "action": "moveright" + }, + { + "keyset": "Any+left", + "action": "moveleft" + }, + { + "keyset": "Any+pageup", + "action": "moveup" + }, + { + "keyset": "Any+pagedown", + "action": "movedown" + }, + { + "keyset": "Any+alt", + "action": "movereset" + }, + { + "keyset": "Any+alt", + "action": "moverotate" + }, + { + "keyset": "Any+ctrl", + "action": "movetilt" + }, + { + "keyset": "ctrl+sc_o", + "action": "fov_dec 5" + }, + { + "keyset": "ctrl+sc_p", + "action": "fov_inc 5" + }, + { + "keyset": "sc_numpad1", + "action": "fov_dec 5" + }, + { + "keyset": "sc_numpad7", + "action": "fov_inc 5" + }, + { + "keyset": "Meta+ctrl+tab", + "action": "pip1_copy" + }, + { + "keyset": "Meta+tab", + "action": "pip1_switch" + }, + { + "keyset": "Alt+sc_t", + "action": "pip1_track" + }, + { + "keyset": "Any+alt", + "action": "toggle_allied_upgrade" + } + ], + "luaui/configs/hotkeys/dev_keys.txt": [ + { + "keyset": "Alt+b", + "action": "debug" + }, + { + "keyset": "Alt+v", + "action": "debugcolvol" + }, + { + "keyset": "Alt+p", + "action": "debugpath" + } + ], + "luaui/configs/hotkeys/gridmenu_keys.txt": [ + { + "keyset": "sc_z", + "action": "gridmenu_category 1" + }, + { + "keyset": "sc_x", + "action": "gridmenu_category 2" + }, + { + "keyset": "sc_c", + "action": "gridmenu_category 3" + }, + { + "keyset": "sc_v", + "action": "gridmenu_category 4" + }, + { + "keyset": "Shift+sc_z", + "action": "gridmenu_category 1" + }, + { + "keyset": "Shift+sc_x", + "action": "gridmenu_category 2" + }, + { + "keyset": "Shift+sc_c", + "action": "gridmenu_category 3" + }, + { + "keyset": "Shift+sc_v", + "action": "gridmenu_category 4" + }, + { + "keyset": "Any+sc_z", + "action": "gridmenu_key 1 1" + }, + { + "keyset": "Any+sc_x", + "action": "gridmenu_key 1 2" + }, + { + "keyset": "Any+sc_c", + "action": "gridmenu_key 1 3" + }, + { + "keyset": "Any+sc_v", + "action": "gridmenu_key 1 4" + }, + { + "keyset": "Any+sc_a", + "action": "gridmenu_key 2 1" + }, + { + "keyset": "Any+sc_s", + "action": "gridmenu_key 2 2" + }, + { + "keyset": "Any+sc_d", + "action": "gridmenu_key 2 3" + }, + { + "keyset": "Any+sc_f", + "action": "gridmenu_key 2 4" + }, + { + "keyset": "Any+sc_q", + "action": "gridmenu_key 3 1" + }, + { + "keyset": "Any+sc_w", + "action": "gridmenu_key 3 2" + }, + { + "keyset": "Any+sc_e", + "action": "gridmenu_key 3 3" + }, + { + "keyset": "Any+sc_r", + "action": "gridmenu_key 3 4" + }, + { + "keyset": "sc_b", + "action": "gridmenu_next_page" + }, + { + "keyset": "sc_.", + "action": "gridmenu_cycle_builder" + } + ], + "luaui/configs/hotkeys/num_keys.txt": [ + { + "keyset": "1", + "action": "specteam 0" + }, + { + "keyset": "2", + "action": "specteam 1" + }, + { + "keyset": "3", + "action": "specteam 2" + }, + { + "keyset": "4", + "action": "specteam 3" + }, + { + "keyset": "5", + "action": "specteam 4" + }, + { + "keyset": "6", + "action": "specteam 5" + }, + { + "keyset": "7", + "action": "specteam 6" + }, + { + "keyset": "8", + "action": "specteam 7" + }, + { + "keyset": "9", + "action": "specteam 8" + }, + { + "keyset": "Alt+0", + "action": "add_to_autogroup 0" + }, + { + "keyset": "Alt+1", + "action": "add_to_autogroup 1" + }, + { + "keyset": "Alt+2", + "action": "add_to_autogroup 2" + }, + { + "keyset": "Alt+3", + "action": "add_to_autogroup 3" + }, + { + "keyset": "Alt+4", + "action": "add_to_autogroup 4" + }, + { + "keyset": "Alt+5", + "action": "add_to_autogroup 5" + }, + { + "keyset": "Alt+6", + "action": "add_to_autogroup 6" + }, + { + "keyset": "Alt+7", + "action": "add_to_autogroup 7" + }, + { + "keyset": "Alt+8", + "action": "add_to_autogroup 8" + }, + { + "keyset": "Alt+9", + "action": "add_to_autogroup 9" + }, + { + "keyset": "Shift+Alt+0", + "action": "load_autogroup_preset 0" + }, + { + "keyset": "Shift+Alt+1", + "action": "load_autogroup_preset 1" + }, + { + "keyset": "Shift+Alt+2", + "action": "load_autogroup_preset 2" + }, + { + "keyset": "Shift+Alt+3", + "action": "load_autogroup_preset 3" + }, + { + "keyset": "Shift+Alt+4", + "action": "load_autogroup_preset 4" + }, + { + "keyset": "Shift+Alt+5", + "action": "load_autogroup_preset 5" + }, + { + "keyset": "Shift+Alt+6", + "action": "load_autogroup_preset 6" + }, + { + "keyset": "Shift+Alt+7", + "action": "load_autogroup_preset 7" + }, + { + "keyset": "Shift+Alt+8", + "action": "load_autogroup_preset 8" + }, + { + "keyset": "Shift+Alt+9", + "action": "load_autogroup_preset 9" + }, + { + "keyset": "0,0", + "action": "group focus 0" + }, + { + "keyset": "1,1", + "action": "group focus 1" + }, + { + "keyset": "2,2", + "action": "group focus 2" + }, + { + "keyset": "3,3", + "action": "group focus 3" + }, + { + "keyset": "4,4", + "action": "group focus 4" + }, + { + "keyset": "5,5", + "action": "group focus 5" + }, + { + "keyset": "6,6", + "action": "group focus 6" + }, + { + "keyset": "7,7", + "action": "group focus 7" + }, + { + "keyset": "8,8", + "action": "group focus 8" + }, + { + "keyset": "9,9", + "action": "group focus 9" + }, + { + "keyset": "0", + "action": "group select 0" + }, + { + "keyset": "1", + "action": "group select 1" + }, + { + "keyset": "2", + "action": "group select 2" + }, + { + "keyset": "3", + "action": "group select 3" + }, + { + "keyset": "4", + "action": "group select 4" + }, + { + "keyset": "5", + "action": "group select 5" + }, + { + "keyset": "6", + "action": "group select 6" + }, + { + "keyset": "7", + "action": "group select 7" + }, + { + "keyset": "8", + "action": "group select 8" + }, + { + "keyset": "9", + "action": "group select 9" + }, + { + "keyset": "Ctrl+0", + "action": "group set 0" + }, + { + "keyset": "Ctrl+1", + "action": "group set 1" + }, + { + "keyset": "Ctrl+2", + "action": "group set 2" + }, + { + "keyset": "Ctrl+3", + "action": "group set 3" + }, + { + "keyset": "Ctrl+4", + "action": "group set 4" + }, + { + "keyset": "Ctrl+5", + "action": "group set 5" + }, + { + "keyset": "Ctrl+6", + "action": "group set 6" + }, + { + "keyset": "Ctrl+7", + "action": "group set 7" + }, + { + "keyset": "Ctrl+8", + "action": "group set 8" + }, + { + "keyset": "Ctrl+9", + "action": "group set 9" + }, + { + "keyset": "Shift+0", + "action": "group selectadd 0" + }, + { + "keyset": "Shift+1", + "action": "group selectadd 1" + }, + { + "keyset": "Shift+2", + "action": "group selectadd 2" + }, + { + "keyset": "Shift+3", + "action": "group selectadd 3" + }, + { + "keyset": "Shift+4", + "action": "group selectadd 4" + }, + { + "keyset": "Shift+5", + "action": "group selectadd 5" + }, + { + "keyset": "Shift+6", + "action": "group selectadd 6" + }, + { + "keyset": "Shift+7", + "action": "group selectadd 7" + }, + { + "keyset": "Shift+8", + "action": "group selectadd 8" + }, + { + "keyset": "Shift+9", + "action": "group selectadd 9" + }, + { + "keyset": "Ctrl+Shift+0", + "action": "group add 0" + }, + { + "keyset": "Ctrl+Shift+1", + "action": "group add 1" + }, + { + "keyset": "Ctrl+Shift+2", + "action": "group add 2" + }, + { + "keyset": "Ctrl+Shift+3", + "action": "group add 3" + }, + { + "keyset": "Ctrl+Shift+4", + "action": "group add 4" + }, + { + "keyset": "Ctrl+Shift+5", + "action": "group add 5" + }, + { + "keyset": "Ctrl+Shift+6", + "action": "group add 6" + }, + { + "keyset": "Ctrl+Shift+7", + "action": "group add 7" + }, + { + "keyset": "Ctrl+Shift+8", + "action": "group add 8" + }, + { + "keyset": "Ctrl+Shift+9", + "action": "group add 9" + }, + { + "keyset": "Ctrl+Alt+0", + "action": "group selecttoggle 0" + }, + { + "keyset": "Ctrl+Alt+1", + "action": "group selecttoggle 1" + }, + { + "keyset": "Ctrl+Alt+2", + "action": "group selecttoggle 2" + }, + { + "keyset": "Ctrl+Alt+3", + "action": "group selecttoggle 3" + }, + { + "keyset": "Ctrl+Alt+4", + "action": "group selecttoggle 4" + }, + { + "keyset": "Ctrl+Alt+5", + "action": "group selecttoggle 5" + }, + { + "keyset": "Ctrl+Alt+6", + "action": "group selecttoggle 6" + }, + { + "keyset": "Ctrl+Alt+7", + "action": "group selecttoggle 7" + }, + { + "keyset": "Ctrl+Alt+8", + "action": "group selecttoggle 8" + }, + { + "keyset": "Ctrl+Alt+9", + "action": "group selecttoggle 9" + }, + { + "keyset": "meta+alt+0", + "action": "factory_preset save 0" + }, + { + "keyset": "meta+alt+1", + "action": "factory_preset save 1" + }, + { + "keyset": "meta+alt+2", + "action": "factory_preset save 2" + }, + { + "keyset": "meta+alt+3", + "action": "factory_preset save 3" + }, + { + "keyset": "meta+alt+4", + "action": "factory_preset save 4" + }, + { + "keyset": "meta+alt+5", + "action": "factory_preset save 5" + }, + { + "keyset": "meta+alt+6", + "action": "factory_preset save 6" + }, + { + "keyset": "meta+alt+7", + "action": "factory_preset save 7" + }, + { + "keyset": "meta+alt+8", + "action": "factory_preset save 8" + }, + { + "keyset": "meta+alt+9", + "action": "factory_preset save 9" + }, + { + "keyset": "meta+0", + "action": "factory_preset load 0" + }, + { + "keyset": "meta+1", + "action": "factory_preset load 1" + }, + { + "keyset": "meta+2", + "action": "factory_preset load 2" + }, + { + "keyset": "meta+3", + "action": "factory_preset load 3" + }, + { + "keyset": "meta+4", + "action": "factory_preset load 4" + }, + { + "keyset": "meta+5", + "action": "factory_preset load 5" + }, + { + "keyset": "meta+6", + "action": "factory_preset load 6" + }, + { + "keyset": "meta+7", + "action": "factory_preset load 7" + }, + { + "keyset": "meta+8", + "action": "factory_preset load 8" + }, + { + "keyset": "meta+9", + "action": "factory_preset load 9" + }, + { + "keyset": "any+sc_space", + "action": "factory_preset_show" + } + ] +} diff --git a/common/configs/keybind_retired_includes.schema.json b/common/configs/keybind_retired_includes.schema.json new file mode 100644 index 00000000000..e7740c68859 --- /dev/null +++ b/common/configs/keybind_retired_includes.schema.json @@ -0,0 +1,29 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://raw.githubusercontent.com/beyond-all-reason/Beyond-All-Reason/master/common/configs/keybind_retired_includes.schema.json", + "title": "Retired keybind include files", + "description": "What each deleted luaui/configs/hotkeys fragment bound, keyed by the path a player's own bind file still names in a keyload. Read only while migrating such a file.", + "type": "object", + "additionalProperties": { + "type": "array", + "minItems": 1, + "description": "The file's bind lines, in the order it listed them.", + "items": { + "type": "object", + "required": ["keyset", "action"], + "additionalProperties": false, + "properties": { + "keyset": { + "type": "string", + "minLength": 1, + "description": "Keyset exactly as /bind expects it, including any modifier prefix and comma-separated chain." + }, + "action": { + "type": "string", + "minLength": 1, + "description": "Bind command plus its space-separated arguments." + } + } + } + } +} diff --git a/common/configs/keybinds.README.md b/common/configs/keybinds.README.md index 6ef9ec393ae..7353a4c097c 100644 --- a/common/configs/keybinds.README.md +++ b/common/configs/keybinds.README.md @@ -10,8 +10,9 @@ data or the rules. They hold *data and rules only* - no rendering, no engine cal |---|---|---| | `keybind_catalog.json` | Ordered categories of keybindable commands, with i18n label keys and bind-action ids. | `keybind_catalog.schema.json` | | `keybind_defaults.json` | The keybind profiles the game ships, each a complete keymap. | `keybind_defaults.schema.json` | +| `keybind_retired_includes.json` | What the deleted `luaui/configs/hotkeys` fragments bound, for migrating a player's own bind file that still keyloads one. | `keybind_retired_includes.schema.json` | -Both are validated in CI by `spec/common/keybind_catalog_spec.lua`: each file against its schema, +All three are validated in CI by `spec/common/keybind_catalog_spec.lua`: each file against its schema, profile names unique across the shipped set, every purely modifier-only action marked read-only, and every action command written in lower case. @@ -30,6 +31,13 @@ there is no base layer to reason about. The shipped profiles carry their binding inline rather than pointing at bind files, so a consumer reads one shape whether the profile came from this file or from the player's own. +A player's own file can still `keyload` the bind files those profiles replaced, which is what +`keybind_retired_includes.json` is for. A preset path resolves by name to the profile that +covers it, but the fragments the presets pulled in - the chat and UI keys, the grid menu, the +number row - name no profile, so without their contents a migration would drop every binding +they held. It is a frozen record of files that no longer exist, not something to keep in step +with the profiles. + Every shipped profile is selectable and read-only; editing one forks a copy under a name the player chooses. diff --git a/luaui/Include/keybind_profiles.lua b/luaui/Include/keybind_profiles.lua index b6df6830f22..4bb9471fc68 100644 --- a/luaui/Include/keybind_profiles.lua +++ b/luaui/Include/keybind_profiles.lua @@ -14,6 +14,7 @@ local keybindConfig = VFS.Include("luaui/Include/keybind_config.lua") local PROFILES_PATH = "LuaUI/Config/keybind_profiles.json" local DEFAULTS_PATH = "common/configs/keybind_defaults.json" +local RETIRED_INCLUDES_PATH = "common/configs/keybind_retired_includes.json" local ACTIVE_FILE = "uikeys.txt" local BACKUP_FILE = "uikeys.txt.bak" local STORE_VERSION = 2 @@ -200,6 +201,25 @@ local function toBindFile(profile) return table.concat(out, "\n") .. "\n" end +-- Only for upgrades: what a bind file we stopped shipping used to bind, for a keyload that +-- still names it. Read on the first one that needs it rather than at include time, since +-- nothing but a migration gets here. +---@type table +local retiredIncludes +local function retiredBinds(path) + local preset = presetFiles[path] + local profile = preset and M.isBuiltin(preset) + if profile then + return profile.binds + end + + if not retiredIncludes then + retiredIncludes = keybindConfig.load(RETIRED_INCLUDES_PATH) or {} + end + + return retiredIncludes[path] +end + -- The engine has no Lua getter for the fakemeta key, so migration is the only -- chance to carry a non-default one over from the file the player already had. -- Reads the bind lines back out of a keybind file. Needed for the player's own @@ -283,23 +303,25 @@ local function readBindFile(text, depth) local included = line:match("^%s*keyload%s+(%S+)") if included and depth < 8 then local text = VFS.LoadFile(included) - local retired = presetFiles[included] and M.isBuiltin(presetFiles[included]) if text then for _, b in ipairs(readBindFile(text, depth + 1) or {}) do binds[#binds + 1] = b end - elseif retired then - -- The shipped presets stopped being files, so a keyload naming one has - -- nothing to read. Their bindings are the profile of that name now. - for _, b in ipairs(retired.binds or {}) do - binds[#binds + 1] = { keyset = b.keyset, action = b.action } - end else - Spring.Echo( - "[keybind_profiles] Error: keyload could not read " - .. included - .. "; any bindings it held are missing from the migrated profile" - ) + -- These stopped being files, so a keyload naming one has nothing to read: + -- what they bound lives in the data that replaced them. + local retired = retiredBinds(included) + if retired then + for _, b in ipairs(retired) do + binds[#binds + 1] = { keyset = b.keyset, action = b.action } + end + else + Spring.Echo( + "[keybind_profiles] Error: keyload could not read " + .. included + .. "; any bindings it held are missing from the migrated profile" + ) + end end end end diff --git a/spec/common/json_schema.lua b/spec/common/json_schema.lua index a7be63b23bf..591fee6727b 100644 --- a/spec/common/json_schema.lua +++ b/spec/common/json_schema.lua @@ -75,6 +75,7 @@ local function resolve(schema, root) return node end +---@type fun(schema: table, value: any, root: table, path: string, errors: string[]): string[] local validate -- oneOf holds only when exactly one branch matches. @@ -164,6 +165,15 @@ validate = function(schema, value, root, path, errors) end end + if type(schema.additionalProperties) == "table" then + local declared = schema.properties or {} + for name, entry in pairs(value) do + if type(name) == "string" and declared[name] == nil then + validate(schema.additionalProperties, entry, root, path .. "." .. name, errors) + end + end + end + if schema.minItems and isArray(value) and #value < schema.minItems then errors[#errors + 1] = path .. ": has " .. #value .. " items, needs at least " .. schema.minItems end diff --git a/spec/common/keybind_catalog_spec.lua b/spec/common/keybind_catalog_spec.lua index 5ae2f4f5b60..61d1d0f8eb0 100644 --- a/spec/common/keybind_catalog_spec.lua +++ b/spec/common/keybind_catalog_spec.lua @@ -137,5 +137,24 @@ describe("keybind catalog", function() check(bind.action, "profile bind") end end + + local retired = loadJson("common/configs/keybind_retired_includes.json") + for _, binds in pairs(retired) do + for _, bind in ipairs(binds) do + check(bind.action, "retired include bind") + end + end + end) +end) + +-- Read only while migrating a player's own bind file that still keyloads one of the +-- fragments the shipped presets used to pull in. It records what those files bound, so it +-- tracks what was deleted rather than what the game ships now. +describe("retired keybind includes", function() + it("matches its schema", function() + conformsTo( + "common/configs/keybind_retired_includes.schema.json", + "common/configs/keybind_retired_includes.json" + ) end) end) diff --git a/spec/luaui/Include/keybind_profiles_spec.lua b/spec/luaui/Include/keybind_profiles_spec.lua new file mode 100644 index 00000000000..5ce5f6ae4d6 --- /dev/null +++ b/spec/luaui/Include/keybind_profiles_spec.lua @@ -0,0 +1,132 @@ +-- Guards the one-time migration of a player's own uikeys.txt, which is the only thing that +-- still resolves a keyload. The bind files it can name are gone from the tree, so what they +-- bound has to come from common/configs; a keyload that resolves to nothing costs the player +-- every binding it held, silently. +-- +-- Nothing here reaches disk: the store and the keymap the migration writes are both discarded. + +local Json = VFS.Include("common/luaUtilities/json.lua") + +local function readFile(path) + local file = assert(io.open(path, "r"), "cannot open " .. path) + local contents = file:read("*a") + file:close() + + return contents +end + +local function loadJson(path) + return Json.decode(readFile(path)) +end + +-- The profile the migration makes of a bind file the player wrote themselves. +local function migrate(uikeys) + local realOpen, realLoadFile = io.open, VFS.LoadFile + local realGetConfig, realSetConfig = Spring.GetConfigString, Spring.SetConfigString + local realGetKeyCode = Spring.GetKeyCode + + VFS.LoadFile = function(path) + if path == "uikeys.txt" then + return uikeys + end + + local file = realOpen(path, "rb") + if not file then + return nil + end + + local contents = file:read("*a") + file:close() + + return contents + end + Spring.GetConfigString = function(_, default) + return default + end + Spring.SetConfigString = function() end + Spring.GetKeyCode = function() + return 1 + end + io.open = function(path, mode) + if mode == "w" then + return { write = function() end, close = function() end } + end + + return realOpen(path, mode) + end + + local ok, result = pcall(function() + local profiles = VFS.Include("luaui/Include/keybind_profiles.lua") + profiles.load() + + return profiles.get(profiles.list()[1]) + end) + + io.open, VFS.LoadFile = realOpen, realLoadFile + Spring.GetConfigString, Spring.SetConfigString = realGetConfig, realSetConfig + Spring.GetKeyCode = realGetKeyCode + + assert(ok, tostring(result)) + + return result +end + +local function shippedProfile(defaults, name) + for _, profile in ipairs(defaults.profiles) do + if profile.name == name then + return profile + end + end + + error("no shipped profile named " .. name) +end + +local function boundActions(profile) + local actions = {} + for _, bind in ipairs(profile.binds) do + actions[bind.action:match("^%S+")] = true + end + + return actions +end + +describe("migrating a keyload of a bind file the game no longer ships", function() + local retired = loadJson("common/configs/keybind_retired_includes.json") + local defaults = loadJson("common/configs/keybind_defaults.json") + + it("keeps what an include the presets pulled in bound", function() + local profile = migrate(table.concat({ + "keyload luaui/configs/hotkeys/chat_and_ui_keys.txt", + "keyload luaui/configs/hotkeys/gridmenu_keys.txt", + "bind Any+f12 screenshot", + }, "\n")) + + local expected = #retired["luaui/configs/hotkeys/chat_and_ui_keys.txt"] + + #retired["luaui/configs/hotkeys/gridmenu_keys.txt"] + + 1 + assert.are.equal(expected, #profile.binds) + + local actions = boundActions(profile) + assert.is_true(actions.quitmenu) + assert.is_true(actions.gridmenu_key) + assert.is_true(actions.screenshot) + end) + + it("keeps what a preset bound, now that it is a profile", function() + local profile = migrate(table.concat({ + "keyload luaui/configs/hotkeys/grid_keys.txt", + "bind Any+f12 screenshot", + }, "\n")) + + assert.are.equal(#shippedProfile(defaults, "Grid").binds + 1, #profile.binds) + end) + + it("keeps the player's own bindings when a keyload resolves to nothing", function() + local profile = migrate(table.concat({ + "keyload luaui/configs/hotkeys/never_shipped_keys.txt", + "bind Any+f12 screenshot", + }, "\n")) + + assert.are.equal(1, #profile.binds) + end) +end)