mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-21 05:00:43 +02:00
save sync update
This commit is contained in:
@@ -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
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
@@ -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,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 = { {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user