save sync update

This commit is contained in:
bryanthaboi
2026-08-20 20:31:20 -04:00
committed by bryanthaboi
parent 90163a3ff2
commit db25c14dfb
10 changed files with 758 additions and 118 deletions
+1
View File
@@ -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
+206 -94
View File
@@ -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)
+16 -3
View File
@@ -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()
+27
View File
@@ -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
+41 -14
View File
@@ -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
+101 -3
View File
@@ -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
+22
View File
@@ -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 = { {
+113 -2
View File
@@ -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")
+105
View File
@@ -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")
+126 -2
View File
@@ -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")