mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-22 13:36:14 +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
|
* **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
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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,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 = { {
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
Reference in New Issue
Block a user