From db25c14dfb75f3a3514043e63aa1b617286f3552 Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Thu, 20 Aug 2026 20:31:20 -0400 Subject: [PATCH] save sync update --- docs/new-features.md | 1 + src/import/LauncherView.lua | 300 ++++++++++++++++++--------- src/import/RomImporter.lua | 19 +- src/mods/LauncherMods.lua | 27 +++ src/sync/SyncEngine.lua | 55 +++-- src/sync/SyncMods.lua | 104 +++++++++- tests/drivers/launcher_sync_shot.lua | 22 ++ tests/engine/launcher_sync_modal.lua | 115 +++++++++- tests/engine/sync_engine_test.lua | 105 ++++++++++ tests/engine/sync_mods_test.lua | 128 +++++++++++- 10 files changed, 758 insertions(+), 118 deletions(-) diff --git a/docs/new-features.md b/docs/new-features.md index 983034f2..7d5f43f5 100644 --- a/docs/new-features.md +++ b/docs/new-features.md @@ -14,6 +14,7 @@ Features intentionally added beyond the original Pokémon Red, Blue, and Yellow * **Screen position setting** (center, upper, top) shared across all games, for clamp-on controllers that cover the lower screen * **Touch skins** in RetroArch overlay format and Delta `.deltaskin` (including PDF-wrapped bezel art), with per-button press states and Super Game Boy borders * **Pokédex diploma and printer image exports** +* **Shareable mod lists** over save sync, optionally carrying the options set for those mods, which the receiving device is asked about before anything is changed ## Gen 2 Specifics diff --git a/src/import/LauncherView.lua b/src/import/LauncherView.lua index eeee5ed3..a5d6f204 100644 --- a/src/import/LauncherView.lua +++ b/src/import/LauncherView.lua @@ -4362,21 +4362,70 @@ local function syncTitle(imp, m, px, py, pw, pad) return py + Kit.textHeight("button") + math.floor(12 * m.s) end -local function syncStatus(imp, m, x, y, w, eng) +local function syncWidth(m, want) + return math.floor(math.min(want, m.W - 2 * m.pad)) +end + +local function syncFit(m, fixed, rows, gaps, texts) + local fit = { btnH = m.btnH, gap = math.floor(8 * m.s), lines = {} } + local avail = m.H - 2 * m.pad + texts = texts or {} + for i, blk in ipairs(texts) do fit.lines[i] = blk.max end + local function total() + local t = fixed + rows * fit.btnH + gaps * fit.gap + for i, blk in ipairs(texts) do + t = t + Kit.wrapHeight(blk.font, blk.str, blk.w, fit.lines[i]) + end + return t + end + while total() > avail do + local worst, worstH = nil, 0 + for i, blk in ipairs(texts) do + if fit.lines[i] > 1 then + local hgt = Kit.wrapHeight(blk.font, blk.str, blk.w, fit.lines[i]) + if hgt > worstH then worst, worstH = i, hgt end + end + end + if not worst then break end + fit.lines[worst] = fit.lines[worst] - 1 + end + if total() > avail and gaps > 0 then + fit.gap = math.max(math.max(2, math.floor(3 * m.s)), + fit.gap - math.ceil((total() - avail) / gaps)) + end + if total() > avail and rows > 0 then + fit.btnH = math.max(Kit.tapMin(), + fit.btnH - math.ceil((total() - avail) / rows)) + end + fit.over = total() - avail + fit.h = math.min(total(), avail) + return fit +end + +local function syncStatus(imp, m, x, y, w, eng, fit) + local bh = (fit and fit.btnH) or m.btnH + local gap = (fit and fit.gap) or math.floor(8 * m.s) if eng:busy() then - Loader.inline(x, y, w, m.btnH, eng.status) - return m.btnH + math.floor(8 * m.s) + Loader.inline(x, y, w, bh, eng.status) + return bh + gap end Kit.text("small", Kit.ellipsize("small", eng.status or "", w), x, y, eng.phase == "error" and PAL.red or PAL.muted) + return Kit.textHeight("small") + gap + math.floor(2 * m.s) +end + +local function syncReserve(m, eng) + if eng:busy() then return m.btnH + math.floor(8 * m.s) end return Kit.textHeight("small") + math.floor(10 * m.s) end -local function syncRow(imp, m, x, y, w, key, label, opts) +local function syncRow(imp, m, x, y, w, key, label, opts, fit) opts = opts or {} opts.font = "small" - btn(imp, x, y, w, m.btnH, key, label, opts) - return y + m.btnH + math.floor(8 * m.s) + local bh = (fit and fit.btnH) or m.btnH + local gap = (fit and fit.gap) or math.floor(8 * m.s) + btn(imp, x, y, w, bh, key, label, opts) + return y + bh + gap end function LauncherView.syncSideText(meta) @@ -4408,91 +4457,99 @@ end local function buildSyncConflict(imp, m, eng) local row = eng.conflicts[1] local pad = math.floor(18 * m.s) - local w = math.floor(520 * m.s) + local w = syncWidth(m, math.floor(520 * m.s)) local innerW = w - 2 * pad local lead = row.overlap and Strings("These saves were played at the same time.") or Strings("This save also changed on another device.") - local leadH = Kit.wrapHeight("small", lead, innerW, 2) - local sideH = Kit.textHeight("small") + math.floor(2 * m.s) - + Kit.wrapHeight("micro", "x", innerW, 2) - local h = pad + Kit.textHeight("button") + math.floor(12 * m.s) + leadH - + math.floor(10 * m.s) + 2 * (sideH + math.floor(10 * m.s)) - + 4 * (m.btnH + math.floor(8 * m.s)) + pad - local px, py, pw = modalPanel(m, w, h) + local mine = LauncherView.syncSideText(row.localMeta) + local theirs = LauncherView.syncSideText(row.remoteMeta) + local fit = syncFit(m, + 2 * pad + Kit.textHeight("button") + math.floor(22 * m.s) + + 2 * (Kit.textHeight("small") + math.floor(12 * m.s)), + 4, 4, { + { font = "small", str = lead, w = innerW, max = 2 }, + { font = "micro", str = mine, w = innerW, max = 2 }, + { font = "micro", str = theirs, w = innerW, max = 2 }, + }) + local px, py, pw = modalPanel(m, w, fit.h) local cy = syncTitle(imp, m, px, py + pad, pw, pad) - cy = cy + Kit.textWrapped("small", lead, px + pad, cy, pw - 2 * pad, - PAL.detail, 2) + math.floor(10 * m.s) + cy = cy + Kit.textWrapped("small", lead, px + pad, cy, innerW, + PAL.detail, fit.lines[1]) + math.floor(10 * m.s) - local function side(title, meta) + local function side(title, text, lines) Kit.text("small", title, px + pad, cy, PAL.heading) cy = cy + Kit.textHeight("small") + math.floor(2 * m.s) - cy = cy + Kit.textWrapped("micro", LauncherView.syncSideText(meta), - px + pad, cy, pw - 2 * pad, PAL.muted, 2) + math.floor(10 * m.s) + cy = cy + Kit.textWrapped("micro", text, px + pad, cy, innerW, + PAL.muted, lines) + math.floor(10 * m.s) end side(Strings("This device") .. " \194\183 " .. tostring(row.version or "?"), - row.localMeta) - side(Strings("Other device"), row.remoteMeta) + mine, fit.lines[2]) + side(Strings("Other device"), theirs, fit.lines[3]) local key = row.key - cy = syncRow(imp, m, px + pad, cy, pw - 2 * pad, "sync-keep-this", + cy = syncRow(imp, m, px + pad, cy, innerW, "sync-keep-this", Strings("Keep this device"), { kind = "primary", - action = function() imp:_syncResolve(key, "local") end }) - cy = syncRow(imp, m, px + pad, cy, pw - 2 * pad, "sync-keep-other", + action = function() imp:_syncResolve(key, "local") end }, fit) + cy = syncRow(imp, m, px + pad, cy, innerW, "sync-keep-other", Strings("Keep the other device"), { kind = "accent", - action = function() imp:_syncResolve(key, "remote") end }) - cy = syncRow(imp, m, px + pad, cy, pw - 2 * pad, "sync-keep-both", + action = function() imp:_syncResolve(key, "remote") end }, fit) + cy = syncRow(imp, m, px + pad, cy, innerW, "sync-keep-both", Strings("Keep both"), { - action = function() imp:_syncResolve(key, "both") end }) - syncRow(imp, m, px + pad, cy, pw - 2 * pad, "sync-conflict-close", - Strings("Close"), { action = function() imp:_closeSync() end }) + action = function() imp:_syncResolve(key, "both") end }, fit) + syncRow(imp, m, px + pad, cy, innerW, "sync-conflict-close", + Strings("Close"), { action = function() imp:_closeSync() end }, fit) end local function buildSyncLink(imp, m, eng) local mo = imp._syncModal local pad = math.floor(18 * m.s) - local w = math.floor(460 * m.s) - local fieldH = math.max(Kit.tapMin(), math.floor(36 * m.s)) + local w = syncWidth(m, math.floor(460 * m.s)) + local innerW = w - 2 * pad local hint = Strings("Enter the two codes the other device is showing.") - local hintH = Kit.wrapHeight("small", hint, w - 2 * pad, 2) - local h = pad + Kit.textHeight("button") + math.floor(12 * m.s) + hintH - + math.floor(10 * m.s) + 2 * (fieldH + math.floor(8 * m.s)) - + Kit.textHeight("small") + math.floor(10 * m.s) - + 2 * (m.btnH + math.floor(8 * m.s)) + pad - local px, py, pw = modalPanel(m, w, h) + local fieldH = math.max(Kit.tapMin(), math.floor(36 * m.s)) + local fit = syncFit(m, + 2 * pad + Kit.textHeight("button") + math.floor(22 * m.s) + + 2 * (fieldH + math.floor(8 * m.s)) + syncReserve(m, eng), + 2, 2, { { font = "small", str = hint, w = innerW, max = 2 } }) + local px, py, pw = modalPanel(m, w, fit.h) local cy = syncTitle(imp, m, px, py + pad, pw, pad) - cy = cy + Kit.textWrapped("small", hint, px + pad, cy, pw - 2 * pad, - PAL.detail, 2) + math.floor(10 * m.s) - textField(imp, px + pad, cy, pw - 2 * pad, fieldH, "sync-code1", + cy = cy + Kit.textWrapped("small", hint, px + pad, cy, innerW, + PAL.detail, fit.lines[1]) + math.floor(10 * m.s) + textField(imp, px + pad, cy, innerW, fieldH, "sync-code1", mo.code1 or "", Strings("First code"), imp._syncFocus == "code1", function() imp:_syncFocusField("code1") end) - cy = cy + fieldH + math.floor(8 * m.s) - textField(imp, px + pad, cy, pw - 2 * pad, fieldH, "sync-code2", + cy = cy + fieldH + fit.gap + textField(imp, px + pad, cy, innerW, fieldH, "sync-code2", mo.code2 or "", Strings("Second code"), imp._syncFocus == "code2", function() imp:_syncFocusField("code2") end) - cy = cy + fieldH + math.floor(8 * m.s) - cy = cy + syncStatus(imp, m, px + pad, cy, pw - 2 * pad, eng) - cy = syncRow(imp, m, px + pad, cy, pw - 2 * pad, "sync-link-go", + cy = cy + fieldH + fit.gap + cy = cy + syncStatus(imp, m, px + pad, cy, innerW, eng, fit) + cy = syncRow(imp, m, px + pad, cy, innerW, "sync-link-go", Strings("Link this device"), { kind = "primary", enabled = not eng:busy(), - action = function() imp:_syncLink() end }) - syncRow(imp, m, px + pad, cy, pw - 2 * pad, "sync-link-back", - Strings("Back"), { action = function() imp:_syncView("home") end }) + action = function() imp:_syncLink() end }, fit) + syncRow(imp, m, px + pad, cy, innerW, "sync-link-back", + Strings("Back"), { action = function() imp:_syncView("home") end }, fit) end local function buildSyncMods(imp, m, eng) local mo = imp._syncModal local pad = math.floor(18 * m.s) - local w = math.floor(500 * m.s) + local w = syncWidth(m, math.floor(500 * m.s)) + local innerW = w - 2 * pad local fieldH = math.max(Kit.tapMin(), math.floor(36 * m.s)) local plan = eng.modPlan local rows = 4 + (plan and 1 or 0) - local h = pad + Kit.textHeight("button") + math.floor(12 * m.s) - + 3 * (Kit.textHeight("small") + math.floor(8 * m.s)) - + fieldH + math.floor(8 * m.s) - + rows * (m.btnH + math.floor(8 * m.s)) + pad - local px, py, pw = modalPanel(m, w, h) + local codeH = eng.shareCode and (Kit.textHeight("small") + + Kit.textHeight("stat") + Kit.textHeight("micro") + + math.floor(18 * m.s)) or 0 + local planH = plan and (Kit.textHeight("small") + math.floor(8 * m.s)) or 0 + local fit = syncFit(m, + 2 * pad + Kit.textHeight("button") + math.floor(12 * m.s) + codeH + planH + + fieldH + math.floor(8 * m.s) + syncReserve(m, eng), + rows, rows, {}) + local px, py, pw = modalPanel(m, w, fit.h) local cy = syncTitle(imp, m, px, py + pad, pw, pad) - local innerW = pw - 2 * pad if eng.shareCode then Kit.text("small", Strings("Share this code:"), px + pad, cy, PAL.muted) @@ -4504,17 +4561,23 @@ local function buildSyncMods(imp, m, eng) px + pad, cy, PAL.muted) cy = cy + Kit.textHeight("micro") + math.floor(10 * m.s) end + local withOptions = mo.withOptions ~= false + cy = syncRow(imp, m, px + pad, cy, innerW, "sync-share-options", + Strings("Include my mod options") .. " \194\183 " + .. (withOptions and Strings("ON") or Strings("OFF")), + { kind = withOptions and "accent" or nil, enabled = not eng:busy(), + action = function() imp:_syncToggleShareOptions() end }, fit) cy = syncRow(imp, m, px + pad, cy, innerW, "sync-share-mods", Strings("Share mod list"), { kind = "accent", enabled = not eng:busy(), - action = function() imp:_syncShareMods() end }) + action = function() imp:_syncShareMods() end }, fit) textField(imp, px + pad, cy, innerW, fieldH, "sync-share-code", mo.share or "", Strings("Paste a 6-character mod code"), imp._syncFocus == "share", function() imp:_syncFocusField("share") end) - cy = cy + fieldH + math.floor(8 * m.s) + cy = cy + fieldH + fit.gap cy = syncRow(imp, m, px + pad, cy, innerW, "sync-get-mods", Strings("Get mod list"), { kind = "accent", enabled = not eng:busy(), - action = function() imp:_syncGetShare() end }) + action = function() imp:_syncGetShare() end }, fit) if plan then local line = Strings("%d mods, %d indexes to add", @@ -4523,24 +4586,29 @@ local function buildSyncMods(imp, m, eng) line = line .. " \194\183 " .. Strings("%d not in your indexes", #plan.missing) end + if #(plan.options or {}) > 0 then + line = line .. " \194\183 " .. (plan.applyOptions + and Strings("options for %d mods", #plan.options) + or Strings("their options skipped")) + end Kit.text("small", Kit.ellipsize("small", line, innerW), px + pad, cy, PAL.detail) cy = cy + Kit.textHeight("small") + math.floor(8 * m.s) local prog = mo.progress if prog then - Loader.inline(px + pad, cy, innerW, m.btnH, + Loader.inline(px + pad, cy, innerW, fit.btnH, Strings("%d of %d", prog.done or 0, prog.total or 0)) - cy = cy + m.btnH + math.floor(8 * m.s) + cy = cy + fit.btnH + fit.gap else cy = syncRow(imp, m, px + pad, cy, innerW, "sync-apply-mods", Strings("Apply these mods"), { kind = "primary", enabled = not eng:busy(), - action = function() imp:_syncApplyMods() end }) + action = function() imp:_syncApplyMods() end }, fit) end end - cy = cy + syncStatus(imp, m, px + pad, cy, innerW, eng) + cy = cy + syncStatus(imp, m, px + pad, cy, innerW, eng, fit) syncRow(imp, m, px + pad, cy, innerW, "sync-mods-back", Strings("Back"), - { action = function() imp:_syncView("home") end }) + { action = function() imp:_syncView("home") end }, fit) end function LauncherView.syncDeviceRows(eng, limit) @@ -4562,29 +4630,35 @@ end local function buildSyncHome(imp, m, eng) local pad = math.floor(18 * m.s) - local w = math.floor(460 * m.s) + local w = syncWidth(m, math.floor(460 * m.s)) local linked = eng:linked() local codes = eng.codes local body = linked and Strings("This device is linked. Saves sync when the launcher opens, a few seconds after each save, and every few minutes while the app is running.") or Strings(SYNC_HINT) local innerW = w - 2 * pad - local hintH = Kit.wrapHeight("small", body, innerW, 5) local codesH = codes and (Kit.textHeight("small") + math.floor(6 * m.s) + 2 * (Kit.textHeight("title") + math.floor(4 * m.s)) + math.floor(8 * m.s)) or 0 local devices = linked and LauncherView.syncDeviceRows(eng) or {} - local devicesH = #devices > 0 - and (Kit.textHeight("small") + math.floor(6 * m.s)) or 0 - local rows = (linked and 5 or 3) + #devices - local h = pad + Kit.textHeight("button") + math.floor(12 * m.s) + hintH - + math.floor(10 * m.s) + codesH + devicesH + m.btnH + math.floor(10 * m.s) - + rows * (m.btnH + math.floor(8 * m.s)) + pad - local px, py, pw = modalPanel(m, w, h) + local hidden, fit = 0, nil + repeat + local devicesH = #devices > 0 + and (Kit.textHeight("small") + math.floor(6 * m.s)) or 0 + fit = syncFit(m, + 2 * pad + Kit.textHeight("button") + math.floor(22 * m.s) + codesH + + devicesH + syncReserve(m, eng), + (linked and 4 or 3) + #devices, (linked and 4 or 3) + #devices, + { { font = "small", str = body, w = innerW, max = 5 } }) + if fit.over <= 0 or #devices == 0 then break end + table.remove(devices) + hidden = hidden + 1 + until false + local px, py, pw = modalPanel(m, w, fit.h) local cy = syncTitle(imp, m, px, py + pad, pw, pad) - cy = cy + Kit.textWrapped("small", body, px + pad, cy, innerW, PAL.detail, 5) - + math.floor(10 * m.s) + cy = cy + Kit.textWrapped("small", body, px + pad, cy, innerW, PAL.detail, + fit.lines[1]) + math.floor(10 * m.s) if codes then Kit.text("small", Strings("Enter these on your other device:"), px + pad, @@ -4595,23 +4669,24 @@ local function buildSyncHome(imp, m, eng) Kit.text("title", codes.code2, px + pad, cy, PAL.heading) cy = cy + Kit.textHeight("title") + math.floor(8 * m.s) end - cy = cy + syncStatus(imp, m, px + pad, cy, innerW, eng) + cy = cy + syncStatus(imp, m, px + pad, cy, innerW, eng, fit) if #devices > 0 then - Kit.text("small", Strings("Devices on this account:"), px + pad, cy, - PAL.muted) + Kit.text("small", hidden > 0 + and Strings("Devices on this account (%d more)", hidden) + or Strings("Devices on this account:"), px + pad, cy, PAL.muted) cy = cy + Kit.textHeight("small") + math.floor(6 * m.s) for i, device in ipairs(devices) do local id = device.id if device.current then cy = syncRow(imp, m, px + pad, cy, innerW, "sync-device-" .. i, device.label .. " \194\183 " .. Strings("this device"), - { enabled = false }) + { enabled = false }, fit) else cy = syncRow(imp, m, px + pad, cy, innerW, "sync-device-" .. i, Strings("Unlink %s", device.label), { kind = "danger", enabled = not eng:busy(), - action = function() imp:_syncUnlinkDevice(id) end }) + action = function() imp:_syncUnlinkDevice(id) end }, fit) end end end @@ -4619,38 +4694,69 @@ local function buildSyncHome(imp, m, eng) if linked then cy = syncRow(imp, m, px + pad, cy, innerW, "sync-now", Strings("Sync now"), { kind = "primary", enabled = not eng:busy(), - action = function() imp:_syncNow() end }) + action = function() imp:_syncNow() end }, fit) cy = syncRow(imp, m, px + pad, cy, innerW, "sync-mods", Strings("Share or get a mod list"), { kind = "accent", - action = function() imp:_syncView("mods") end }) + action = function() imp:_syncView("mods") end }, fit) cy = syncRow(imp, m, px + pad, cy, innerW, "sync-unlink", Strings("Unlink this device"), { kind = "danger", - action = function() imp:_syncUnlink() end }) + action = function() imp:_syncUnlink() end }, fit) else cy = syncRow(imp, m, px + pad, cy, innerW, "sync-create", Strings("Create sync account"), { kind = "primary", enabled = not eng:busy(), - action = function() imp:_syncCreate() end }) + action = function() imp:_syncCreate() end }, fit) cy = syncRow(imp, m, px + pad, cy, innerW, "sync-link", Strings("Link this device"), { kind = "accent", - action = function() imp:_syncView("link") end }) + action = function() imp:_syncView("link") end }, fit) end syncRow(imp, m, px + pad, cy, innerW, "sync-close", Strings("Close"), - { action = function() imp:_closeSync() end }) + { action = function() imp:_closeSync() end }, fit) +end + +local function buildSyncModOptions(imp, m, eng) + local plan = eng.modPlan + local ids = {} + for _, row in ipairs(plan.options or {}) do ids[#ids + 1] = row.id end + local pad = math.floor(18 * m.s) + local w = syncWidth(m, math.floor(480 * m.s)) + local innerW = w - 2 * pad + local lead = Strings( + "This mod list also carries the options its owner set for %d mods. Import their options, or keep the ones you have?", + #ids) + local names = table.concat(ids, ", ") + local fit = syncFit(m, + 2 * pad + Kit.textHeight("button") + math.floor(32 * m.s), 2, 2, { + { font = "small", str = lead, w = innerW, max = 4 }, + { font = "micro", str = names, w = innerW, max = 3 }, + }) + local px, py, pw = modalPanel(m, w, fit.h) + local cy = syncTitle(imp, m, px, py + pad, pw, pad) + cy = cy + Kit.textWrapped("small", lead, px + pad, cy, innerW, PAL.detail, + fit.lines[1]) + math.floor(8 * m.s) + cy = cy + Kit.textWrapped("micro", names, px + pad, cy, innerW, PAL.muted, + fit.lines[2]) + math.floor(12 * m.s) + cy = syncRow(imp, m, px + pad, cy, innerW, "sync-options-import", + Strings("Import their options"), { kind = "primary", + action = function() imp:_syncAnswerModOptions(true) end }, fit) + syncRow(imp, m, px + pad, cy, innerW, "sync-options-skip", + Strings("Keep my options"), { + action = function() imp:_syncAnswerModOptions(false) end }, fit) end local function buildSyncUnavailable(imp, m, msg) local pad = math.floor(18 * m.s) - local w = math.floor(420 * m.s) - local h = pad + Kit.textHeight("button") + math.floor(12 * m.s) - + Kit.wrapHeight("small", msg, w - 2 * pad, 4) + math.floor(10 * m.s) - + m.btnH + pad - local px, py, pw = modalPanel(m, w, h) + local w = syncWidth(m, math.floor(420 * m.s)) + local innerW = w - 2 * pad + local fit = syncFit(m, + 2 * pad + Kit.textHeight("button") + math.floor(22 * m.s), 1, 0, + { { font = "small", str = msg, w = innerW, max = 4 } }) + local px, py, pw = modalPanel(m, w, fit.h) local cy = syncTitle(imp, m, px, py + pad, pw, pad) - cy = cy + Kit.textWrapped("small", msg, px + pad, cy, pw - 2 * pad, - PAL.detail, 4) + math.floor(10 * m.s) - syncRow(imp, m, px + pad, cy, pw - 2 * pad, "sync-close", - Strings("Close"), { action = function() imp:_closeSync() end }) + cy = cy + Kit.textWrapped("small", msg, px + pad, cy, innerW, + PAL.detail, fit.lines[1]) + math.floor(10 * m.s) + syncRow(imp, m, px + pad, cy, innerW, "sync-close", + Strings("Close"), { action = function() imp:_closeSync() end }, fit) end local function buildSyncModal(imp, m) @@ -4669,6 +4775,12 @@ local function buildSyncModal(imp, m) buildSyncConflict(imp, m, eng) return end + local plan = eng.modPlan + if type(plan) == "table" and #(plan.options or {}) > 0 + and plan.applyOptions == nil then + buildSyncModOptions(imp, m, eng) + return + end local view = imp._syncModal and imp._syncModal.view or "home" if view == "link" then buildSyncLink(imp, m, eng) diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index d5b4aecf..3b2c2ac2 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -3478,7 +3478,7 @@ end function RomImporter:_openSync() self:_syncEngine() self._syncModal = self._syncModal - or { view = "home", code1 = "", code2 = "", share = "" } + or { view = "home", code1 = "", code2 = "", share = "", withOptions = true } self._syncFocus = nil self:_disarmTextInput() end @@ -3563,9 +3563,22 @@ function RomImporter:_syncUnlinkDevice(deviceId) end function RomImporter:_syncShareMods() - local eng = self:_syncEngine() + local eng, mo = self:_syncEngine(), self._syncModal if not eng then return false end - return eng:shareMods() + return eng:shareMods(mo and mo.withOptions ~= false) +end + +function RomImporter:_syncToggleShareOptions() + local mo = self._syncModal + if not mo then return false end + mo.withOptions = not (mo.withOptions ~= false) + return mo.withOptions +end + +function RomImporter:_syncAnswerModOptions(importThem) + local eng = self:_syncEngine() + if not eng or type(eng.answerModOptions) ~= "function" then return false end + return eng:answerModOptions(importThem) end function RomImporter:_syncGetShare() diff --git a/src/mods/LauncherMods.lua b/src/mods/LauncherMods.lua index 970e9517..48f989a9 100644 --- a/src/mods/LauncherMods.lua +++ b/src/mods/LauncherMods.lua @@ -603,6 +603,33 @@ function LauncherMods.setEnabled(id, enabled, version) return true end +function LauncherMods.modOptions() + local ok, options = pcall(SaveData.loadOptions) + if not ok or type(options) ~= "table" then return {} end + return options.modOptions or {} +end + +function LauncherMods.setModOptions(id, values) + if type(id) ~= "string" or id == "" or type(values) ~= "table" then + return false + end + local options = SaveData.loadOptions() + if SaveData.isSafeMode(options) then return false end + options.modOptions = options.modOptions or {} + local bucket = options.modOptions[id] or {} + for key, value in pairs(values) do + local t = type(value) + if type(key) == "string" and key ~= "" + and (t == "string" or t == "number" or t == "boolean") then + bucket[key] = value + end + end + options.modOptions[id] = bucket + SaveData.saveOptions(options) + LauncherMods.syncActiveProfile(options) + return true +end + -- setAllEnabled(ids, enabled [, version]): the launcher's Enable all / Disable -- all buttons (#647). Writes what setEnabled writes, but loads and -- saves once for the whole list: saveOptions rewrites the whole options file per diff --git a/src/sync/SyncEngine.lua b/src/sync/SyncEngine.lua index 985ed415..83be5644 100644 --- a/src/sync/SyncEngine.lua +++ b/src/sync/SyncEngine.lua @@ -596,12 +596,13 @@ function SyncEngine:resolveConflict(key, choice) return true end -function SyncEngine:uploadMods() +function SyncEngine:uploadMods(includeOptions) if not self:linked() then return false, "this device is not linked" end if self:busy() then return false, "sync is busy" end - local manifest = SyncMods.build(self.modDeps) + local manifest = SyncMods.build(self.modDeps, includeOptions) self.phase = "uploading" - self.status = "Uploading the mod list..." + self.status = includeOptions and "Uploading the mod list and options..." + or "Uploading the mod list..." local handle, err = self.client:putMods(manifest) return self:_request(handle, err, function(eng) eng.phase = "idle" @@ -618,19 +619,17 @@ function SyncEngine:fetchModPlan() return self:_request(handle, err, function(eng, res) local data = res.data or {} local manifest = type(data.manifest) == "table" and data.manifest or data - eng.modPlan = SyncMods.plan(manifest, eng.modDeps) - eng.phase = "idle" - eng.status = SyncMods.planEmpty(eng.modPlan) - and "Mods already match" or "Mod changes ready to apply" + eng:_takeModPlan(SyncMods.plan(manifest, eng.modDeps)) end) end -function SyncEngine:shareMods() +function SyncEngine:shareMods(includeOptions) if not self:linked() then return false, "this device is not linked" end if self:busy() then return false, "sync is busy" end - local manifest = SyncMods.build(self.modDeps) + local manifest = SyncMods.build(self.modDeps, includeOptions) self.phase = "uploading" - self.status = "Sharing the mod list..." + self.status = includeOptions and "Sharing the mod list and options..." + or "Sharing the mod list..." local handle, err = self.client:shareMods(manifest) return self:_request(handle, err, function(eng, res) local data = res.data or {} @@ -649,16 +648,44 @@ function SyncEngine:fetchShare(code) return self:_request(handle, err, function(eng, res) local data = res.data or {} local manifest = type(data.manifest) == "table" and data.manifest or data - eng.modPlan = SyncMods.plan(manifest, eng.modDeps) - eng.phase = "idle" - eng.status = SyncMods.planEmpty(eng.modPlan) - and "Mods already match" or "Mod changes ready to apply" + eng:_takeModPlan(SyncMods.plan(manifest, eng.modDeps)) end) end +function SyncEngine:_takeModPlan(plan) + self.modPlan = plan + self.phase = "idle" + if SyncMods.planHasOptions(plan) then + self.status = ("This list carries options for %d mods.") + :format(#plan.options) + elseif SyncMods.planEmpty(plan) then + self.status = "Mods already match" + else + self.status = "Mod changes ready to apply" + end +end + +function SyncEngine:modOptionsAsk() + local plan = self.modPlan + if not SyncMods.planHasOptions(plan) then return nil end + if plan.applyOptions ~= nil then return nil end + return SyncMods.optionModIds(plan) +end + +function SyncEngine:answerModOptions(importThem) + local plan = self.modPlan + if not SyncMods.planHasOptions(plan) then return false end + SyncMods.answerOptions(plan, importThem) + self.status = plan.applyOptions + and "Their mod options will be imported too" + or "Their mod options will be skipped" + return plan.applyOptions +end + function SyncEngine:applyModPlan(progress) if not self.modPlan then return false, "no mod plan" end if self.modApply then return false, "the mods are already being applied" end + self.modPlan.applyOptions = self.modPlan.applyOptions == true local steps = SyncMods.steps(self.modPlan, self.modDeps) if #steps == 0 then self.modPlan = nil diff --git a/src/sync/SyncMods.lua b/src/sync/SyncMods.lua index 6366556a..814447e1 100644 --- a/src/sync/SyncMods.lua +++ b/src/sync/SyncMods.lua @@ -1,6 +1,8 @@ local SyncMods = {} -SyncMods.REV = 1 +SyncMods.REV = 2 +SyncMods.MAX_OPTION_KEYS = 64 +SyncMods.MAX_OPTION_TEXT = 256 local function versions() local ok, GameVersion = pcall(require, "src.core.GameVersion") @@ -35,6 +37,12 @@ local function defaultDeps() setEnabled = function(id, enabled, version) return require("src.mods.LauncherMods").setEnabled(id, enabled, version) end, + modOptions = function() + return require("src.mods.LauncherMods").modOptions() + end, + setOptions = function(id, values) + return require("src.mods.LauncherMods").setModOptions(id, values) + end, } end @@ -46,6 +54,41 @@ local function deps(given) return out end +local function sanitizeOptions(bucket) + if type(bucket) ~= "table" then return nil end + local keys = {} + for k, v in pairs(bucket) do + local t = type(v) + if type(k) == "string" and k ~= "" + and (t == "string" or t == "number" or t == "boolean") then + keys[#keys + 1] = k + end + end + if #keys == 0 then return nil end + table.sort(keys) + local out, n = {}, 0 + for _, k in ipairs(keys) do + if n >= SyncMods.MAX_OPTION_KEYS then break end + local v = bucket[k] + if type(v) == "string" then v = v:sub(1, SyncMods.MAX_OPTION_TEXT) end + local finite = type(v) ~= "number" + or (v == v and v ~= math.huge and v ~= -math.huge) + if finite then + out[k] = v + n = n + 1 + end + end + if n == 0 then return nil end + return out +end + +local function sameOptions(a, b) + for k, v in pairs(a) do + if (b or {})[k] ~= v then return false end + end + return true +end + local function sourceOf(row) local github = row.github or (type(row.manifest) == "table" and row.manifest.github) @@ -55,9 +98,14 @@ local function sourceOf(row) return "local" end -function SyncMods.build(given) +function SyncMods.build(given, includeOptions) local d = deps(given) local manifest = { rev = SyncMods.REV, indexes = {}, mods = {} } + local stored = {} + if includeOptions then + local ok, live = pcall(d.modOptions) + if ok and type(live) == "table" then stored = live end + end for _, row in ipairs(d.indexes() or {}) do local url = row.url or row.feed if type(url) == "string" and url ~= "" then @@ -72,11 +120,14 @@ function SyncMods.build(given) for _, version in ipairs(versions()) do if answers[version] then enabledFor[#enabledFor + 1] = version end end + local options = includeOptions and sanitizeOptions(stored[row.id]) or nil + if options then manifest.hasOptions = true end manifest.mods[#manifest.mods + 1] = { id = row.id, version = row.version, source = sourceOf(row), enabledFor = enabledFor, + options = options, } end end @@ -86,9 +137,16 @@ end function SyncMods.plan(manifest, given) local d = deps(given) - local plan = { indexes = {}, toInstall = {}, toEnable = {}, missing = {} } + local plan = { indexes = {}, toInstall = {}, toEnable = {}, missing = {}, + options = {}, applyOptions = nil } if type(manifest) ~= "table" then return plan end + local liveOptions = {} + do + local ok, live = pcall(d.modOptions) + if ok and type(live) == "table" then liveOptions = live end + end + local haveIndex = {} for _, row in ipairs(d.indexes() or {}) do if type(row.url) == "string" then haveIndex[row.url] = true end @@ -130,6 +188,10 @@ function SyncMods.plan(manifest, given) plan.toEnable[#plan.toEnable + 1] = { id = mod.id, version = version } end end + local wanted = sanitizeOptions(mod.options) + if wanted and not sameOptions(wanted, liveOptions[mod.id]) then + plan.options[#plan.options + 1] = { id = mod.id, values = wanted } + end end end end @@ -138,10 +200,33 @@ end function SyncMods.planEmpty(plan) if type(plan) ~= "table" then return true end + if plan.applyOptions and #(plan.options or {}) > 0 then return false end return #(plan.indexes or {}) == 0 and #(plan.toInstall or {}) == 0 and #(plan.toEnable or {}) == 0 end +function SyncMods.planHasOptions(plan) + return type(plan) == "table" and #(plan.options or {}) > 0 +end + +function SyncMods.optionsAnswered(plan) + return not SyncMods.planHasOptions(plan) or plan.applyOptions ~= nil +end + +function SyncMods.answerOptions(plan, importThem) + if type(plan) ~= "table" then return false end + plan.applyOptions = importThem and true or false + return plan.applyOptions +end + +function SyncMods.optionModIds(plan) + local out = {} + for _, row in ipairs((type(plan) == "table" and plan.options) or {}) do + out[#out + 1] = row.id + end + return out +end + function SyncMods.steps(plan, given) local d = deps(given) local out = {} @@ -175,6 +260,19 @@ function SyncMods.steps(plan, given) return true end } end + if plan.applyOptions then + for _, want in ipairs(plan.options or {}) do + out[#out + 1] = { label = want.id, run = function() + if broken[want.id] then return true end + local ok, err = d.setOptions(want.id, want.values) + if ok == false then + return nil, want.id .. ": " + .. tostring(err or "could not set the mod options") + end + return true + end } + end + end return out end diff --git a/tests/drivers/launcher_sync_shot.lua b/tests/drivers/launcher_sync_shot.lua index b3109f07..b1faa9d9 100644 --- a/tests/drivers/launcher_sync_shot.lua +++ b/tests/drivers/launcher_sync_shot.lua @@ -22,6 +22,10 @@ return function(game) syncNow = function(self) self.status = "Checking for changes..." return true end, unlink = function(self) self.isLinked, self.codes = false, nil return true end, shareMods = function(self) self.shareCode = "K7QW3M" return true end, + answerModOptions = function(self, importThem) + if self.modPlan then self.modPlan.applyOptions = importThem and true or false end + return importThem + end, fetchShare = function(self) return true end, applyModPlan = function(self) self.modPlan = nil return true end, resolveConflict = function(self) self.conflicts = {} self.phase = "idle" return true end, @@ -95,6 +99,24 @@ return function(game) U.log("share code:", tostring(eng.shareCode)) shot("sync_mods.png") + eng.modPlan.options = { + { id = "jp_green", values = { language = "JP" } }, + { id = "randomizer", values = { seed = 1234, wild = true } }, + { id = "widescreen_hud", values = { scale = 2 } }, + } + U.wait(2) + U.log("options question up:", tostring(eng.modPlan.applyOptions == nil)) + shot("sync_mod_options.png") + + love.window.setMode(800, 480, { resizable = true, highdpi = true }) + U.wait(3) + shot("sync_mod_options_short.png") + imp:_syncAnswerModOptions(false) + U.wait(2) + shot("sync_mods_short.png") + love.window.setMode(1024, 768, { resizable = true, highdpi = true }) + U.wait(3) + eng.phase = "conflict" eng.status = "These saves were played at the same time." eng.conflicts = { { diff --git a/tests/engine/launcher_sync_modal.lua b/tests/engine/launcher_sync_modal.lua index 8899d358..10ad290e 100644 --- a/tests/engine/launcher_sync_modal.lua +++ b/tests/engine/launcher_sync_modal.lua @@ -61,11 +61,16 @@ local function fakeEngine(over) self.isLinked, self.codes = false, nil return true end, - shareMods = function(self) - self.calls[#self.calls + 1] = { "shareMods" } + shareMods = function(self, withOptions) + self.calls[#self.calls + 1] = { "shareMods", withOptions } self.shareCode = "K7QW3M" return true end, + answerModOptions = function(self, importThem) + self.calls[#self.calls + 1] = { "answerModOptions", importThem } + if self.modPlan then self.modPlan.applyOptions = importThem and true or false end + return importThem + end, fetchShare = function(self, code) self.calls[#self.calls + 1] = { "fetchShare", code } return true @@ -148,7 +153,16 @@ eq(imp._syncModal, nil, "escape closes the modal") imp:_openSync() imp:_syncView("mods") +eq(imp._syncModal.withOptions, true, + "sharing carries the options that go with the mods by default") imp:_syncShareMods() +eq(eng.calls[#eng.calls][2], true, "so the engine is told to include them") +imp:_syncToggleShareOptions() +eq(imp._syncModal.withOptions, false, "the toggle turns them off") +imp:_syncShareMods() +eq(eng.calls[#eng.calls][2], false, + "and a list can be shared with no options at all") +imp:_syncToggleShareOptions() eq(eng.shareCode, "K7QW3M", "Share mod list asks the engine for a code") imp:_syncFocusField("share") imp:textinput("k7qw3m") @@ -216,6 +230,22 @@ check(labels["Share mod list"], "the mod view shares a list") check(labels["Get mod list"], "and fetches one") check(labels["Apply these mods"], "a fetched plan can be applied") +rEng.modPlan = { indexes = {}, toInstall = {}, toEnable = {}, missing = {}, + options = { { id = "biggermod", values = { speed = 1 } }, + { id = "another-one", values = { theme = "dark" } } } } +labels = controls(rImp) +check(labels["Import their options"], + "a list that carries options asks before importing them") +check(labels["Keep my options"], "and offers to leave this device alone") +check(not labels["Apply these mods"], + "the question is answered before anything is applied") +rImp:_syncAnswerModOptions(false) +eq(rEng.calls[#rEng.calls][2], false, "the answer reaches the engine") +labels = controls(rImp) +check(labels["Apply these mods"], "and the apply road opens again") +check(not labels["Import their options"], "with the question gone") +rEng.modPlan = nil + rImp:_syncView("home") rEng.devices = { { id = "0a1b2c3d", label = "OS X", current = true }, @@ -304,4 +334,85 @@ check(pump and pump:find("self.launcher", 1, true) ~= nil, check(impSrc:find("_syncTypeInto", 1, true) ~= nil, "text input is routed through the code filter") +do + local realCard = Kit.card + local card + Kit.card = function(x, y, w, h, variant) + card = { x = x, y = y, w = w, h = h } + realCard(x, y, w, h, variant) + end + + local sizes = { + { 1080, 2400 }, { 2400, 1080 }, { 1280, 720 }, { 720, 1280 }, + { 640, 960 }, { 480, 800 }, { 960, 540 }, { 800, 480 }, + } + local views = { + { "home", function() end }, + { "devices", function(_, e) + e.codes = { code1 = "1234-5678", code2 = "8765-4321" } + e.devices = { { id = "0a1b2c3d", label = "OS X", current = true }, + { id = "99998888", label = "Android" }, + { id = "77776666", label = "Steam Deck" } } + end }, + { "link", function(i) i:_syncView("link") end }, + { "mods", function(i, e) + i:_syncView("mods") + e.shareCode = "K7QW3M" + e.modPlan = { indexes = { "https://x" }, toInstall = { { id = "a" } }, + toEnable = {}, missing = { { id = "z" } }, options = {} } + end }, + { "mod options", function(i, e) + i:_syncView("mods") + e.modPlan = { indexes = {}, toInstall = {}, toEnable = {}, missing = {}, + options = { { id = "biggermod" }, { id = "another-one" }, + { id = "a-third-one" } } } + end }, + { "busy", function(i, e) + i:_syncView("mods") + e.isBusy = true + e.status = "Uploading the mod list and options..." + end }, + { "conflict", function(_, e) + e.phase = "conflict" + e.conflicts = { { key = "red/abc", version = "red", overlap = true, + localMeta = { savedAt = 1700000000, sessionStart = 1699999000, + summary = { name = "ASH", badges = 3, timeText = "5:42", dexCount = 40 } }, + remoteMeta = { savedAt = 1700000500, sessionStart = 1699999500, + summary = { name = "ASH", badges = 4, timeText = "6:10", dexCount = 44 } } } } + end }, + } + + local worst = { over = 0 } + for _, view in ipairs(views) do + for _, size in ipairs(sizes) do + love.graphics.getDimensions = function() return size[1], size[2] end + love.graphics.getPixelDimensions = love.graphics.getDimensions + local e = fakeEngine({ isLinked = true }) + local i = launcher(e) + i:_openSync() + view[2](i, e) + Kit.audit = {} + card = nil + local ok = pcall(LauncherView.draw, i) + local rows = Kit.audit or {} + Kit.audit = nil + check(ok, ("the %s panel draws at %dx%d"):format(view[1], size[1], size[2])) + for _, r in ipairs(rows) do + if r.class == "control" and card then + local over = math.max((r.x + r.w) - (card.x + card.w), + (r.y + r.h) - (card.y + card.h)) + if over > worst.over then + worst = { over = over, view = view[1], w = size[1], h = size[2], + label = r.label } + end + end + end + end + end + check(worst.over <= 0, ("no sync button leaves its card%s"):format( + worst.over > 0 and (": %s %dx%d overflows by %d at '%s'"):format( + worst.view, worst.w, worst.h, worst.over, worst.label) or "")) + Kit.card = realCard +end + T.finish("launcher_sync_modal") diff --git a/tests/engine/sync_engine_test.lua b/tests/engine/sync_engine_test.lua index ddd3bc78..22a79e9e 100644 --- a/tests/engine/sync_engine_test.lua +++ b/tests/engine/sync_engine_test.lua @@ -553,4 +553,109 @@ do "and the failure reaches the status line") end +do + local shared = {} + local eng, transport = engine({ + ["POST /sync/modshare"] = function(req) + shared[#shared + 1] = Json.decode(req.body) + return { code = 200, body = '{"code":"K7QW3M"}' } + end, + }, {}) + eng.modDeps = { + installed = function() + return { { id = "alpha", version = "1.0.0", + enabledByVersion = { red = true } } } + end, + indexes = function() return {} end, + modOptions = function() return { alpha = { speed = 3 } } end, + setOptions = function() return true end, + } + + eng:shareMods(false) + pump(eng) + T.eq(shared[1].manifest.mods[1].options, nil, + "a shared list can leave the player's options at home") + T.eq(eng.shareCode, "K7QW3M", "and still mints a code") + + eng:shareMods(true) + pump(eng) + T.eq(shared[2].manifest.mods[1].options.speed, 3, + "or carry the options that go with those mods") + T.eq(#transport.sent, 2, "one request each") +end + +do + local eng = engine({ + ["GET /sync/modshare"] = { code = 200, body = + '{"code":"K7QW3M","manifest":{"rev":2,"indexes":[],"hasOptions":true,' .. + '"mods":[{"id":"alpha","version":"1.0.0","enabledFor":["red"],' .. + '"options":{"speed":1}}]}}' }, + }, {}) + local written = {} + eng.modDeps = { + installed = function() + return { { id = "alpha", version = "1.0.0", + enabledByVersion = { red = true } } } + end, + indexes = function() return {} end, + findEntry = function() return nil end, + addIndex = function() return true end, + install = function() return true end, + setEnabled = function() return true end, + modOptions = function() return { alpha = { speed = 3 } } end, + setOptions = function(id, values) written[id] = values return true end, + } + + eng:fetchShare("K7QW3M") + pump(eng) + T.eq(#eng.modPlan.options, 1, "a fetched list reports the options it carries") + T.eq(eng.modPlan.applyOptions, nil, "without deciding for the player") + T.check(eng.status:find("options", 1, true) ~= nil, + "and the status line says there is a question to answer") + local ask = eng:modOptionsAsk() + T.eq(ask and ask[1], "alpha", "the launcher can name the mods it would touch") + + eng:answerModOptions(false) + T.eq(eng:modOptionsAsk(), nil, "answering closes the question") + eng:applyModPlan() + pump(eng) + T.eq(next(written), nil, "declining leaves this device's options alone") + + eng:fetchShare("K7QW3M") + pump(eng) + eng:answerModOptions(true) + eng:applyModPlan() + pump(eng) + T.eq(written.alpha.speed, 1, "accepting writes the sharer's values") +end + +do + local eng = engine({ + ["GET /sync/modshare"] = { code = 200, body = + '{"code":"K7QW3M","manifest":{"rev":2,"indexes":[],"hasOptions":true,' .. + '"mods":[{"id":"alpha","version":"1.0.0","enabledFor":["red"],' .. + '"options":{"speed":1}}]}}' }, + }, {}) + local written = {} + eng.modDeps = { + installed = function() + return { { id = "alpha", version = "1.0.0", + enabledByVersion = { red = true } } } + end, + indexes = function() return {} end, + findEntry = function() return nil end, + addIndex = function() return true end, + install = function() return true end, + setEnabled = function() return true end, + modOptions = function() return { alpha = { speed = 3 } } end, + setOptions = function(id, values) written[id] = values return true end, + } + eng:fetchShare("K7QW3M") + pump(eng) + eng:applyModPlan() + pump(eng) + T.eq(next(written), nil, + "an apply that never asked imports nothing: silence is not consent") +end + T.finish("sync_engine") diff --git a/tests/engine/sync_mods_test.lua b/tests/engine/sync_mods_test.lua index 5574e7d8..0a9110f0 100644 --- a/tests/engine/sync_mods_test.lua +++ b/tests/engine/sync_mods_test.lua @@ -10,9 +10,14 @@ local function row(id, version, enabled, github) enabledByVersion = enabled } end -local function deps(installed, indexes, catalog) - local calls = { installed = {}, enabled = {}, indexes = {} } +local function deps(installed, indexes, catalog, modOptions) + local calls = { installed = {}, enabled = {}, indexes = {}, options = {} } return calls, { + modOptions = function() return modOptions or {} end, + setOptions = function(id, values) + calls.options[id] = values + return true + end, installed = function() return installed end, indexes = function() return indexes or {} end, addIndex = function(url) @@ -145,4 +150,123 @@ do T.eq(#calls.installed, 0, "without the rest of the plan having run yet") end +do + local live = { + alpha = { speed = 3, name = "ASH", on = true, bad = {} }, + zeta = {}, + } + local _, d = deps({ + row("alpha", "2.1.0", { red = true }), + row("zeta", "1.0.0", { red = true }), + }, {}, {}, live) + + local plain = SyncMods.build(d) + T.eq(plain.mods[1].options, nil, + "a mod list shares no options unless the player asks for it") + T.eq(plain.hasOptions, nil, "and is not flagged as carrying any") + + local full = SyncMods.build(d, true) + T.eq(full.hasOptions, true, "opting in flags the list as carrying options") + T.eq(full.mods[1].options.speed, 3, "the player's own values ride along") + T.eq(full.mods[1].options.name, "ASH", "text values too") + T.eq(full.mods[1].options.on, true, "and toggles") + T.eq(full.mods[1].options.bad, nil, + "a nested table is never sent: only scalars cross the wire") + T.eq(full.mods[2].options, nil, "a mod with nothing set sends no bucket") +end + +do + local wide = {} + for i = 1, SyncMods.MAX_OPTION_KEYS + 20 do wide["k" .. i] = i end + wide.huge = string.rep("x", SyncMods.MAX_OPTION_TEXT + 100) + local _, d = deps({ row("alpha", "1.0.0", { red = true }) }, {}, {}, + { alpha = wide }) + local manifest = SyncMods.build(d, true) + local n = 0 + for _ in pairs(manifest.mods[1].options) do n = n + 1 end + T.eq(n, SyncMods.MAX_OPTION_KEYS, "an option bucket is capped") + local kept = manifest.mods[1].options.huge + T.check(kept == nil or #kept == SyncMods.MAX_OPTION_TEXT, + "and a long string is clamped when it makes the cut") +end + +do + local manifest = { rev = 2, indexes = {}, hasOptions = true, mods = { + { id = "alpha", version = "2.1.0", enabledFor = { "red" }, + options = { speed = 1, name = "MISTY" } }, + { id = "beta", version = "1.0.0", enabledFor = { "red" }, + options = { theme = "dark" } }, + { id = "same", version = "1.0.0", enabledFor = { "red" }, + options = { pitch = 5 } }, + { id = "ghost", version = "0.1.0", enabledFor = { "red" }, + options = { anything = 1 } }, + } } + local calls, d = deps( + { row("alpha", "2.1.0", { red = true }), row("same", "1.0.0", { red = true }) }, + {}, { beta = { id = "beta" } }, + { alpha = { speed = 3, name = "ASH" }, same = { pitch = 5 } }) + + local plan = SyncMods.plan(manifest, d) + T.eq(#plan.options, 2, "only mods this device can actually run are listed") + T.eq(plan.options[1].id, "alpha", "the installed one whose values differ") + T.eq(plan.options[2].id, "beta", "and the one this plan installs") + for _, row in ipairs(plan.options) do + T.check(row.id ~= "same", "a mod already set that way is not busywork") + T.check(row.id ~= "ghost", "and a mod that cannot be installed is skipped") + end + T.eq(plan.applyOptions, nil, "nobody's options are imported unasked") + T.eq(SyncMods.planHasOptions(plan), true, "the plan reports it has some") + T.eq(SyncMods.optionsAnswered(plan), false, "and that the question is open") + + local steps = SyncMods.steps(plan, d) + local labels = 0 + for _, step in ipairs(steps) do + if step.label == "alpha" then labels = labels + 1 end + end + T.eq(labels, 0, "an unanswered plan writes no options") + + SyncMods.answerOptions(plan, false) + T.eq(SyncMods.optionsAnswered(plan), true, "declining answers the question") + SyncMods.apply(plan, nil, d) + T.eq(next(calls.options), nil, "and keeps the options this device already had") + + SyncMods.answerOptions(plan, true) + T.eq(plan.applyOptions, true, "accepting arms the option steps") + SyncMods.apply(plan, nil, d) + T.eq(calls.options.alpha.speed, 1, "the sharer's values are written") + T.eq(calls.options.alpha.name, "MISTY", "every key they set") + T.eq(calls.options.beta.theme, "dark", "including a mod installed by the plan") +end + +do + local manifest = { rev = 2, indexes = {}, mods = { + { id = "alpha", version = "1.0.0", enabledFor = { "red" }, + options = { speed = 1 } } } } + local calls, d = deps({ row("alpha", "1.0.0", { red = true }) }, {}, {}, + { alpha = { speed = 3 } }) + local plan = SyncMods.plan(manifest, d) + T.eq(SyncMods.planEmpty(plan), true, + "a list that only differs in options plans no mod work") + SyncMods.answerOptions(plan, true) + T.eq(SyncMods.planEmpty(plan), false, + "until the options are accepted, and then there is work to do") + d.install = function() return nil, "download failed" end + SyncMods.apply(plan, nil, d) + T.eq(calls.options.alpha.speed, 1, "which is just the option write") +end + +do + local manifest = { rev = 2, indexes = {}, mods = { + { id = "beta", version = "1.0.0", enabledFor = { "red" }, + options = { speed = 1 } } } } + local calls, d = deps({}, {}, { beta = { id = "beta" } }) + d.install = function() return nil, "download failed" end + local plan = SyncMods.plan(manifest, d) + SyncMods.answerOptions(plan, true) + local ok = SyncMods.apply(plan, nil, d) + T.eq(ok, false, "a failed install still fails the apply") + T.eq(next(calls.options), nil, + "and the options of a mod that never installed are not written") +end + T.finish("sync_mods")