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 * **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 * **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** * **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 ## Gen 2 Specifics
+204 -92
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) return py + Kit.textHeight("button") + math.floor(12 * m.s)
end 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 if eng:busy() then
Loader.inline(x, y, w, m.btnH, eng.status) Loader.inline(x, y, w, bh, eng.status)
return m.btnH + math.floor(8 * m.s) return bh + gap
end end
Kit.text("small", Kit.ellipsize("small", eng.status or "", w), x, y, Kit.text("small", Kit.ellipsize("small", eng.status or "", w), x, y,
eng.phase == "error" and PAL.red or PAL.muted) 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) return Kit.textHeight("small") + math.floor(10 * m.s)
end 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 = opts or {}
opts.font = "small" opts.font = "small"
btn(imp, x, y, w, m.btnH, key, label, opts) local bh = (fit and fit.btnH) or m.btnH
return y + m.btnH + math.floor(8 * m.s) 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 end
function LauncherView.syncSideText(meta) function LauncherView.syncSideText(meta)
@@ -4408,91 +4457,99 @@ end
local function buildSyncConflict(imp, m, eng) local function buildSyncConflict(imp, m, eng)
local row = eng.conflicts[1] local row = eng.conflicts[1]
local pad = math.floor(18 * m.s) 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 innerW = w - 2 * pad
local lead = row.overlap local lead = row.overlap
and Strings("These saves were played at the same time.") and Strings("These saves were played at the same time.")
or Strings("This save also changed on another device.") or Strings("This save also changed on another device.")
local leadH = Kit.wrapHeight("small", lead, innerW, 2) local mine = LauncherView.syncSideText(row.localMeta)
local sideH = Kit.textHeight("small") + math.floor(2 * m.s) local theirs = LauncherView.syncSideText(row.remoteMeta)
+ Kit.wrapHeight("micro", "x", innerW, 2) local fit = syncFit(m,
local h = pad + Kit.textHeight("button") + math.floor(12 * m.s) + leadH 2 * pad + Kit.textHeight("button") + math.floor(22 * m.s)
+ math.floor(10 * m.s) + 2 * (sideH + math.floor(10 * m.s)) + 2 * (Kit.textHeight("small") + math.floor(12 * m.s)),
+ 4 * (m.btnH + math.floor(8 * m.s)) + pad 4, 4, {
local px, py, pw = modalPanel(m, w, h) { 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) local cy = syncTitle(imp, m, px, py + pad, pw, pad)
cy = cy + Kit.textWrapped("small", lead, px + pad, cy, pw - 2 * pad, cy = cy + Kit.textWrapped("small", lead, px + pad, cy, innerW,
PAL.detail, 2) + math.floor(10 * m.s) 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) Kit.text("small", title, px + pad, cy, PAL.heading)
cy = cy + Kit.textHeight("small") + math.floor(2 * m.s) cy = cy + Kit.textHeight("small") + math.floor(2 * m.s)
cy = cy + Kit.textWrapped("micro", LauncherView.syncSideText(meta), cy = cy + Kit.textWrapped("micro", text, px + pad, cy, innerW,
px + pad, cy, pw - 2 * pad, PAL.muted, 2) + math.floor(10 * m.s) PAL.muted, lines) + math.floor(10 * m.s)
end end
side(Strings("This device") .. " \194\183 " .. tostring(row.version or "?"), side(Strings("This device") .. " \194\183 " .. tostring(row.version or "?"),
row.localMeta) mine, fit.lines[2])
side(Strings("Other device"), row.remoteMeta) side(Strings("Other device"), theirs, fit.lines[3])
local key = row.key 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", Strings("Keep this device"), { kind = "primary",
action = function() imp:_syncResolve(key, "local") end }) action = function() imp:_syncResolve(key, "local") end }, fit)
cy = syncRow(imp, m, px + pad, cy, pw - 2 * pad, "sync-keep-other", cy = syncRow(imp, m, px + pad, cy, innerW, "sync-keep-other",
Strings("Keep the other device"), { kind = "accent", Strings("Keep the other device"), { kind = "accent",
action = function() imp:_syncResolve(key, "remote") end }) action = function() imp:_syncResolve(key, "remote") end }, fit)
cy = syncRow(imp, m, px + pad, cy, pw - 2 * pad, "sync-keep-both", cy = syncRow(imp, m, px + pad, cy, innerW, "sync-keep-both",
Strings("Keep both"), { Strings("Keep both"), {
action = function() imp:_syncResolve(key, "both") end }) action = function() imp:_syncResolve(key, "both") end }, fit)
syncRow(imp, m, px + pad, cy, pw - 2 * pad, "sync-conflict-close", syncRow(imp, m, px + pad, cy, innerW, "sync-conflict-close",
Strings("Close"), { action = function() imp:_closeSync() end }) Strings("Close"), { action = function() imp:_closeSync() end }, fit)
end end
local function buildSyncLink(imp, m, eng) local function buildSyncLink(imp, m, eng)
local mo = imp._syncModal local mo = imp._syncModal
local pad = math.floor(18 * m.s) local pad = math.floor(18 * m.s)
local w = math.floor(460 * m.s) local w = syncWidth(m, math.floor(460 * m.s))
local fieldH = math.max(Kit.tapMin(), math.floor(36 * m.s)) local innerW = w - 2 * pad
local hint = Strings("Enter the two codes the other device is showing.") local hint = Strings("Enter the two codes the other device is showing.")
local hintH = Kit.wrapHeight("small", hint, w - 2 * pad, 2) local fieldH = math.max(Kit.tapMin(), math.floor(36 * m.s))
local h = pad + Kit.textHeight("button") + math.floor(12 * m.s) + hintH local fit = syncFit(m,
+ math.floor(10 * m.s) + 2 * (fieldH + math.floor(8 * m.s)) 2 * pad + Kit.textHeight("button") + math.floor(22 * m.s)
+ Kit.textHeight("small") + math.floor(10 * m.s) + 2 * (fieldH + math.floor(8 * m.s)) + syncReserve(m, eng),
+ 2 * (m.btnH + math.floor(8 * m.s)) + pad 2, 2, { { font = "small", str = hint, w = innerW, max = 2 } })
local px, py, pw = modalPanel(m, w, h) local px, py, pw = modalPanel(m, w, fit.h)
local cy = syncTitle(imp, m, px, py + pad, pw, pad) local cy = syncTitle(imp, m, px, py + pad, pw, pad)
cy = cy + Kit.textWrapped("small", hint, px + pad, cy, pw - 2 * pad, cy = cy + Kit.textWrapped("small", hint, px + pad, cy, innerW,
PAL.detail, 2) + math.floor(10 * m.s) PAL.detail, fit.lines[1]) + math.floor(10 * m.s)
textField(imp, px + pad, cy, pw - 2 * pad, fieldH, "sync-code1", textField(imp, px + pad, cy, innerW, fieldH, "sync-code1",
mo.code1 or "", Strings("First code"), imp._syncFocus == "code1", mo.code1 or "", Strings("First code"), imp._syncFocus == "code1",
function() imp:_syncFocusField("code1") end) function() imp:_syncFocusField("code1") end)
cy = cy + fieldH + math.floor(8 * m.s) cy = cy + fieldH + fit.gap
textField(imp, px + pad, cy, pw - 2 * pad, fieldH, "sync-code2", textField(imp, px + pad, cy, innerW, fieldH, "sync-code2",
mo.code2 or "", Strings("Second code"), imp._syncFocus == "code2", mo.code2 or "", Strings("Second code"), imp._syncFocus == "code2",
function() imp:_syncFocusField("code2") end) function() imp:_syncFocusField("code2") end)
cy = cy + fieldH + math.floor(8 * m.s) cy = cy + fieldH + fit.gap
cy = cy + syncStatus(imp, m, px + pad, cy, pw - 2 * pad, eng) cy = cy + syncStatus(imp, m, px + pad, cy, innerW, eng, fit)
cy = syncRow(imp, m, px + pad, cy, pw - 2 * pad, "sync-link-go", cy = syncRow(imp, m, px + pad, cy, innerW, "sync-link-go",
Strings("Link this device"), { kind = "primary", enabled = not eng:busy(), Strings("Link this device"), { kind = "primary", enabled = not eng:busy(),
action = function() imp:_syncLink() end }) action = function() imp:_syncLink() end }, fit)
syncRow(imp, m, px + pad, cy, pw - 2 * pad, "sync-link-back", syncRow(imp, m, px + pad, cy, innerW, "sync-link-back",
Strings("Back"), { action = function() imp:_syncView("home") end }) Strings("Back"), { action = function() imp:_syncView("home") end }, fit)
end end
local function buildSyncMods(imp, m, eng) local function buildSyncMods(imp, m, eng)
local mo = imp._syncModal local mo = imp._syncModal
local pad = math.floor(18 * m.s) 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 fieldH = math.max(Kit.tapMin(), math.floor(36 * m.s))
local plan = eng.modPlan local plan = eng.modPlan
local rows = 4 + (plan and 1 or 0) local rows = 4 + (plan and 1 or 0)
local h = pad + Kit.textHeight("button") + math.floor(12 * m.s) local codeH = eng.shareCode and (Kit.textHeight("small")
+ 3 * (Kit.textHeight("small") + math.floor(8 * m.s)) + Kit.textHeight("stat") + Kit.textHeight("micro")
+ fieldH + math.floor(8 * m.s) + math.floor(18 * m.s)) or 0
+ rows * (m.btnH + math.floor(8 * m.s)) + pad local planH = plan and (Kit.textHeight("small") + math.floor(8 * m.s)) or 0
local px, py, pw = modalPanel(m, w, h) 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 cy = syncTitle(imp, m, px, py + pad, pw, pad)
local innerW = pw - 2 * pad
if eng.shareCode then if eng.shareCode then
Kit.text("small", Strings("Share this code:"), px + pad, cy, PAL.muted) 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) px + pad, cy, PAL.muted)
cy = cy + Kit.textHeight("micro") + math.floor(10 * m.s) cy = cy + Kit.textHeight("micro") + math.floor(10 * m.s)
end 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", cy = syncRow(imp, m, px + pad, cy, innerW, "sync-share-mods",
Strings("Share mod list"), { kind = "accent", enabled = not eng:busy(), 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", textField(imp, px + pad, cy, innerW, fieldH, "sync-share-code",
mo.share or "", Strings("Paste a 6-character mod code"), mo.share or "", Strings("Paste a 6-character mod code"),
imp._syncFocus == "share", function() imp:_syncFocusField("share") end) 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", cy = syncRow(imp, m, px + pad, cy, innerW, "sync-get-mods",
Strings("Get mod list"), { kind = "accent", enabled = not eng:busy(), Strings("Get mod list"), { kind = "accent", enabled = not eng:busy(),
action = function() imp:_syncGetShare() end }) action = function() imp:_syncGetShare() end }, fit)
if plan then if plan then
local line = Strings("%d mods, %d indexes to add", 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", line = line .. " \194\183 " .. Strings("%d not in your indexes",
#plan.missing) #plan.missing)
end 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, Kit.text("small", Kit.ellipsize("small", line, innerW), px + pad, cy,
PAL.detail) PAL.detail)
cy = cy + Kit.textHeight("small") + math.floor(8 * m.s) cy = cy + Kit.textHeight("small") + math.floor(8 * m.s)
local prog = mo.progress local prog = mo.progress
if prog then 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)) 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 else
cy = syncRow(imp, m, px + pad, cy, innerW, "sync-apply-mods", cy = syncRow(imp, m, px + pad, cy, innerW, "sync-apply-mods",
Strings("Apply these mods"), { kind = "primary", Strings("Apply these mods"), { kind = "primary",
enabled = not eng:busy(), enabled = not eng:busy(),
action = function() imp:_syncApplyMods() end }) action = function() imp:_syncApplyMods() end }, fit)
end end
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"), 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 end
function LauncherView.syncDeviceRows(eng, limit) function LauncherView.syncDeviceRows(eng, limit)
@@ -4562,29 +4630,35 @@ end
local function buildSyncHome(imp, m, eng) local function buildSyncHome(imp, m, eng)
local pad = math.floor(18 * m.s) 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 linked = eng:linked()
local codes = eng.codes local codes = eng.codes
local body = linked 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.") 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) or Strings(SYNC_HINT)
local innerW = w - 2 * pad local innerW = w - 2 * pad
local hintH = Kit.wrapHeight("small", body, innerW, 5)
local codesH = codes local codesH = codes
and (Kit.textHeight("small") + math.floor(6 * m.s) and (Kit.textHeight("small") + math.floor(6 * m.s)
+ 2 * (Kit.textHeight("title") + math.floor(4 * m.s)) + 2 * (Kit.textHeight("title") + math.floor(4 * m.s))
+ math.floor(8 * m.s)) or 0 + math.floor(8 * m.s)) or 0
local devices = linked and LauncherView.syncDeviceRows(eng) or {} local devices = linked and LauncherView.syncDeviceRows(eng) or {}
local hidden, fit = 0, nil
repeat
local devicesH = #devices > 0 local devicesH = #devices > 0
and (Kit.textHeight("small") + math.floor(6 * m.s)) or 0 and (Kit.textHeight("small") + math.floor(6 * m.s)) or 0
local rows = (linked and 5 or 3) + #devices fit = syncFit(m,
local h = pad + Kit.textHeight("button") + math.floor(12 * m.s) + hintH 2 * pad + Kit.textHeight("button") + math.floor(22 * m.s) + codesH
+ math.floor(10 * m.s) + codesH + devicesH + m.btnH + math.floor(10 * m.s) + devicesH + syncReserve(m, eng),
+ rows * (m.btnH + math.floor(8 * m.s)) + pad (linked and 4 or 3) + #devices, (linked and 4 or 3) + #devices,
local px, py, pw = modalPanel(m, w, h) { { 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) local cy = syncTitle(imp, m, px, py + pad, pw, pad)
cy = cy + Kit.textWrapped("small", body, px + pad, cy, innerW, PAL.detail, 5) cy = cy + Kit.textWrapped("small", body, px + pad, cy, innerW, PAL.detail,
+ math.floor(10 * m.s) fit.lines[1]) + math.floor(10 * m.s)
if codes then if codes then
Kit.text("small", Strings("Enter these on your other device:"), px + pad, 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) Kit.text("title", codes.code2, px + pad, cy, PAL.heading)
cy = cy + Kit.textHeight("title") + math.floor(8 * m.s) cy = cy + Kit.textHeight("title") + math.floor(8 * m.s)
end 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 if #devices > 0 then
Kit.text("small", Strings("Devices on this account:"), px + pad, cy, Kit.text("small", hidden > 0
PAL.muted) 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) cy = cy + Kit.textHeight("small") + math.floor(6 * m.s)
for i, device in ipairs(devices) do for i, device in ipairs(devices) do
local id = device.id local id = device.id
if device.current then if device.current then
cy = syncRow(imp, m, px + pad, cy, innerW, "sync-device-" .. i, cy = syncRow(imp, m, px + pad, cy, innerW, "sync-device-" .. i,
device.label .. " \194\183 " .. Strings("this device"), device.label .. " \194\183 " .. Strings("this device"),
{ enabled = false }) { enabled = false }, fit)
else else
cy = syncRow(imp, m, px + pad, cy, innerW, "sync-device-" .. i, cy = syncRow(imp, m, px + pad, cy, innerW, "sync-device-" .. i,
Strings("Unlink %s", device.label), { kind = "danger", Strings("Unlink %s", device.label), { kind = "danger",
enabled = not eng:busy(), enabled = not eng:busy(),
action = function() imp:_syncUnlinkDevice(id) end }) action = function() imp:_syncUnlinkDevice(id) end }, fit)
end end
end end
end end
@@ -4619,38 +4694,69 @@ local function buildSyncHome(imp, m, eng)
if linked then if linked then
cy = syncRow(imp, m, px + pad, cy, innerW, "sync-now", Strings("Sync now"), cy = syncRow(imp, m, px + pad, cy, innerW, "sync-now", Strings("Sync now"),
{ kind = "primary", enabled = not eng:busy(), { 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", cy = syncRow(imp, m, px + pad, cy, innerW, "sync-mods",
Strings("Share or get a mod list"), { kind = "accent", 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", cy = syncRow(imp, m, px + pad, cy, innerW, "sync-unlink",
Strings("Unlink this device"), { kind = "danger", Strings("Unlink this device"), { kind = "danger",
action = function() imp:_syncUnlink() end }) action = function() imp:_syncUnlink() end }, fit)
else else
cy = syncRow(imp, m, px + pad, cy, innerW, "sync-create", cy = syncRow(imp, m, px + pad, cy, innerW, "sync-create",
Strings("Create sync account"), { kind = "primary", Strings("Create sync account"), { kind = "primary",
enabled = not eng:busy(), 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", cy = syncRow(imp, m, px + pad, cy, innerW, "sync-link",
Strings("Link this device"), { kind = "accent", Strings("Link this device"), { kind = "accent",
action = function() imp:_syncView("link") end }) action = function() imp:_syncView("link") end }, fit)
end end
syncRow(imp, m, px + pad, cy, innerW, "sync-close", Strings("Close"), 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 end
local function buildSyncUnavailable(imp, m, msg) local function buildSyncUnavailable(imp, m, msg)
local pad = math.floor(18 * m.s) local pad = math.floor(18 * m.s)
local w = math.floor(420 * m.s) local w = syncWidth(m, math.floor(420 * m.s))
local h = pad + Kit.textHeight("button") + math.floor(12 * m.s) local innerW = w - 2 * pad
+ Kit.wrapHeight("small", msg, w - 2 * pad, 4) + math.floor(10 * m.s) local fit = syncFit(m,
+ m.btnH + pad 2 * pad + Kit.textHeight("button") + math.floor(22 * m.s), 1, 0,
local px, py, pw = modalPanel(m, w, h) { { 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) local cy = syncTitle(imp, m, px, py + pad, pw, pad)
cy = cy + Kit.textWrapped("small", msg, px + pad, cy, pw - 2 * pad, cy = cy + Kit.textWrapped("small", msg, px + pad, cy, innerW,
PAL.detail, 4) + math.floor(10 * m.s) PAL.detail, fit.lines[1]) + math.floor(10 * m.s)
syncRow(imp, m, px + pad, cy, pw - 2 * pad, "sync-close", syncRow(imp, m, px + pad, cy, innerW, "sync-close",
Strings("Close"), { action = function() imp:_closeSync() end }) Strings("Close"), { action = function() imp:_closeSync() end }, fit)
end end
local function buildSyncModal(imp, m) local function buildSyncModal(imp, m)
@@ -4669,6 +4775,12 @@ local function buildSyncModal(imp, m)
buildSyncConflict(imp, m, eng) buildSyncConflict(imp, m, eng)
return return
end 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" local view = imp._syncModal and imp._syncModal.view or "home"
if view == "link" then if view == "link" then
buildSyncLink(imp, m, eng) buildSyncLink(imp, m, eng)
+16 -3
View File
@@ -3478,7 +3478,7 @@ end
function RomImporter:_openSync() function RomImporter:_openSync()
self:_syncEngine() self:_syncEngine()
self._syncModal = self._syncModal self._syncModal = self._syncModal
or { view = "home", code1 = "", code2 = "", share = "" } or { view = "home", code1 = "", code2 = "", share = "", withOptions = true }
self._syncFocus = nil self._syncFocus = nil
self:_disarmTextInput() self:_disarmTextInput()
end end
@@ -3563,9 +3563,22 @@ function RomImporter:_syncUnlinkDevice(deviceId)
end end
function RomImporter:_syncShareMods() function RomImporter:_syncShareMods()
local eng = self:_syncEngine() local eng, mo = self:_syncEngine(), self._syncModal
if not eng then return false end 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 end
function RomImporter:_syncGetShare() function RomImporter:_syncGetShare()
+27
View File
@@ -603,6 +603,33 @@ function LauncherMods.setEnabled(id, enabled, version)
return true return true
end 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 -- setAllEnabled(ids, enabled [, version]): the launcher's Enable all / Disable
-- all buttons (#647). Writes what setEnabled writes, but loads and -- all buttons (#647). Writes what setEnabled writes, but loads and
-- saves once for the whole list: saveOptions rewrites the whole options file per -- 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 return true
end end
function SyncEngine:uploadMods() function SyncEngine:uploadMods(includeOptions)
if not self:linked() then return false, "this device is not linked" end if not self:linked() then return false, "this device is not linked" end
if self:busy() then return false, "sync is busy" 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.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) local handle, err = self.client:putMods(manifest)
return self:_request(handle, err, function(eng) return self:_request(handle, err, function(eng)
eng.phase = "idle" eng.phase = "idle"
@@ -618,19 +619,17 @@ function SyncEngine:fetchModPlan()
return self:_request(handle, err, function(eng, res) return self:_request(handle, err, function(eng, res)
local data = res.data or {} local data = res.data or {}
local manifest = type(data.manifest) == "table" and data.manifest or data local manifest = type(data.manifest) == "table" and data.manifest or data
eng.modPlan = SyncMods.plan(manifest, eng.modDeps) eng:_takeModPlan(SyncMods.plan(manifest, eng.modDeps))
eng.phase = "idle"
eng.status = SyncMods.planEmpty(eng.modPlan)
and "Mods already match" or "Mod changes ready to apply"
end) end)
end end
function SyncEngine:shareMods() function SyncEngine:shareMods(includeOptions)
if not self:linked() then return false, "this device is not linked" end if not self:linked() then return false, "this device is not linked" end
if self:busy() then return false, "sync is busy" 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.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) local handle, err = self.client:shareMods(manifest)
return self:_request(handle, err, function(eng, res) return self:_request(handle, err, function(eng, res)
local data = res.data or {} local data = res.data or {}
@@ -649,16 +648,44 @@ function SyncEngine:fetchShare(code)
return self:_request(handle, err, function(eng, res) return self:_request(handle, err, function(eng, res)
local data = res.data or {} local data = res.data or {}
local manifest = type(data.manifest) == "table" and data.manifest or data local manifest = type(data.manifest) == "table" and data.manifest or data
eng.modPlan = SyncMods.plan(manifest, eng.modDeps) eng:_takeModPlan(SyncMods.plan(manifest, eng.modDeps))
eng.phase = "idle"
eng.status = SyncMods.planEmpty(eng.modPlan)
and "Mods already match" or "Mod changes ready to apply"
end) end)
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) function SyncEngine:applyModPlan(progress)
if not self.modPlan then return false, "no mod plan" end if not self.modPlan then return false, "no mod plan" end
if self.modApply then return false, "the mods are already being applied" 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) local steps = SyncMods.steps(self.modPlan, self.modDeps)
if #steps == 0 then if #steps == 0 then
self.modPlan = nil self.modPlan = nil
+101 -3
View File
@@ -1,6 +1,8 @@
local SyncMods = {} local SyncMods = {}
SyncMods.REV = 1 SyncMods.REV = 2
SyncMods.MAX_OPTION_KEYS = 64
SyncMods.MAX_OPTION_TEXT = 256
local function versions() local function versions()
local ok, GameVersion = pcall(require, "src.core.GameVersion") local ok, GameVersion = pcall(require, "src.core.GameVersion")
@@ -35,6 +37,12 @@ local function defaultDeps()
setEnabled = function(id, enabled, version) setEnabled = function(id, enabled, version)
return require("src.mods.LauncherMods").setEnabled(id, enabled, version) return require("src.mods.LauncherMods").setEnabled(id, enabled, version)
end, end,
modOptions = function()
return require("src.mods.LauncherMods").modOptions()
end,
setOptions = function(id, values)
return require("src.mods.LauncherMods").setModOptions(id, values)
end,
} }
end end
@@ -46,6 +54,41 @@ local function deps(given)
return out return out
end 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 function sourceOf(row)
local github = row.github local github = row.github
or (type(row.manifest) == "table" and row.manifest.github) or (type(row.manifest) == "table" and row.manifest.github)
@@ -55,9 +98,14 @@ local function sourceOf(row)
return "local" return "local"
end end
function SyncMods.build(given) function SyncMods.build(given, includeOptions)
local d = deps(given) local d = deps(given)
local manifest = { rev = SyncMods.REV, indexes = {}, mods = {} } 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 for _, row in ipairs(d.indexes() or {}) do
local url = row.url or row.feed local url = row.url or row.feed
if type(url) == "string" and url ~= "" then if type(url) == "string" and url ~= "" then
@@ -72,11 +120,14 @@ function SyncMods.build(given)
for _, version in ipairs(versions()) do for _, version in ipairs(versions()) do
if answers[version] then enabledFor[#enabledFor + 1] = version end if answers[version] then enabledFor[#enabledFor + 1] = version end
end end
local options = includeOptions and sanitizeOptions(stored[row.id]) or nil
if options then manifest.hasOptions = true end
manifest.mods[#manifest.mods + 1] = { manifest.mods[#manifest.mods + 1] = {
id = row.id, id = row.id,
version = row.version, version = row.version,
source = sourceOf(row), source = sourceOf(row),
enabledFor = enabledFor, enabledFor = enabledFor,
options = options,
} }
end end
end end
@@ -86,9 +137,16 @@ end
function SyncMods.plan(manifest, given) function SyncMods.plan(manifest, given)
local d = deps(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 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 = {} local haveIndex = {}
for _, row in ipairs(d.indexes() or {}) do for _, row in ipairs(d.indexes() or {}) do
if type(row.url) == "string" then haveIndex[row.url] = true end 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 } plan.toEnable[#plan.toEnable + 1] = { id = mod.id, version = version }
end end
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 end
end end
@@ -138,10 +200,33 @@ end
function SyncMods.planEmpty(plan) function SyncMods.planEmpty(plan)
if type(plan) ~= "table" then return true end 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 return #(plan.indexes or {}) == 0 and #(plan.toInstall or {}) == 0
and #(plan.toEnable or {}) == 0 and #(plan.toEnable or {}) == 0
end 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) function SyncMods.steps(plan, given)
local d = deps(given) local d = deps(given)
local out = {} local out = {}
@@ -175,6 +260,19 @@ function SyncMods.steps(plan, given)
return true return true
end } end }
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 return out
end end
+22
View File
@@ -22,6 +22,10 @@ return function(game)
syncNow = function(self) self.status = "Checking for changes..." return true end, syncNow = function(self) self.status = "Checking for changes..." return true end,
unlink = function(self) self.isLinked, self.codes = false, nil return true end, unlink = function(self) self.isLinked, self.codes = false, nil return true end,
shareMods = function(self) self.shareCode = "K7QW3M" 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, fetchShare = function(self) return true end,
applyModPlan = function(self) self.modPlan = nil return true end, applyModPlan = function(self) self.modPlan = nil return true end,
resolveConflict = function(self) self.conflicts = {} self.phase = "idle" 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)) U.log("share code:", tostring(eng.shareCode))
shot("sync_mods.png") 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.phase = "conflict"
eng.status = "These saves were played at the same time." eng.status = "These saves were played at the same time."
eng.conflicts = { { eng.conflicts = { {
+113 -2
View File
@@ -61,11 +61,16 @@ local function fakeEngine(over)
self.isLinked, self.codes = false, nil self.isLinked, self.codes = false, nil
return true return true
end, end,
shareMods = function(self) shareMods = function(self, withOptions)
self.calls[#self.calls + 1] = { "shareMods" } self.calls[#self.calls + 1] = { "shareMods", withOptions }
self.shareCode = "K7QW3M" self.shareCode = "K7QW3M"
return true return true
end, 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) fetchShare = function(self, code)
self.calls[#self.calls + 1] = { "fetchShare", code } self.calls[#self.calls + 1] = { "fetchShare", code }
return true return true
@@ -148,7 +153,16 @@ eq(imp._syncModal, nil, "escape closes the modal")
imp:_openSync() imp:_openSync()
imp:_syncView("mods") imp:_syncView("mods")
eq(imp._syncModal.withOptions, true,
"sharing carries the options that go with the mods by default")
imp:_syncShareMods() 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") eq(eng.shareCode, "K7QW3M", "Share mod list asks the engine for a code")
imp:_syncFocusField("share") imp:_syncFocusField("share")
imp:textinput("k7qw3m") 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["Get mod list"], "and fetches one")
check(labels["Apply these mods"], "a fetched plan can be applied") 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") rImp:_syncView("home")
rEng.devices = { rEng.devices = {
{ id = "0a1b2c3d", label = "OS X", current = true }, { 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, check(impSrc:find("_syncTypeInto", 1, true) ~= nil,
"text input is routed through the code filter") "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") T.finish("launcher_sync_modal")
+105
View File
@@ -553,4 +553,109 @@ do
"and the failure reaches the status line") "and the failure reaches the status line")
end 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") T.finish("sync_engine")
+126 -2
View File
@@ -10,9 +10,14 @@ local function row(id, version, enabled, github)
enabledByVersion = enabled } enabledByVersion = enabled }
end end
local function deps(installed, indexes, catalog) local function deps(installed, indexes, catalog, modOptions)
local calls = { installed = {}, enabled = {}, indexes = {} } local calls = { installed = {}, enabled = {}, indexes = {}, options = {} }
return calls, { return calls, {
modOptions = function() return modOptions or {} end,
setOptions = function(id, values)
calls.options[id] = values
return true
end,
installed = function() return installed end, installed = function() return installed end,
indexes = function() return indexes or {} end, indexes = function() return indexes or {} end,
addIndex = function(url) addIndex = function(url)
@@ -145,4 +150,123 @@ do
T.eq(#calls.installed, 0, "without the rest of the plan having run yet") T.eq(#calls.installed, 0, "without the rest of the plan having run yet")
end 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") T.finish("sync_mods")