Several worlds: a Worlds list with load, rename and delete; old save migrates to World 1

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-24 00:20:18 -04:00
co-authored by Claude Opus 5.5
parent c434faefc4
commit aadcbc6efb
11 changed files with 274 additions and 35 deletions
+81 -1
View File
@@ -21,7 +21,77 @@ with sync_playwright() as p:
pg.on('pageerror', lambda e: logs.append(f'PAGEERROR: {e}'))
pg.on('console', lambda m: logs.append(f'{m.type}: {m.text}') if m.type == 'error' else None)
pg.goto(url); pg.wait_for_timeout(300)
pg.click('#btn-new'); pg.fill('#nw-seed', '777'); pg.click('#btn-generate')
check('storage transactions roll back when a write throws', pg.evaluate("""async () => {
try { await DB.op('readwrite', st => { st.put('partial', 'abort-test'); throw new Error('test abort'); }); }
catch (e) { return await DB.get('abort-test') === undefined; }
return false;
}"""))
# Migrate a real legacy save before starting the play-through.
pg.evaluate("generateWorld(12345, 512, 384, null)")
pg.evaluate("async () => { const s = serializeWorld(); await DB.put('main', { ...s, savedAt: Date.now(), playTime: 3661 }); }")
check('a failed migration leaves the legacy save intact', pg.evaluate("""async () => {
const get = DB.get;
DB.get = async function (k) { const v = await get.call(this, k); return k.endsWith(':main') && v ? { ...v, savedAt: -1 } : v; };
try { await migrateWorlds(); return false; }
catch (e) { return !!await get.call(DB, 'main') && await get.call(DB, 'worlds') === undefined; }
finally { DB.get = get; }
}"""))
pg.reload(); pg.wait_for_function("!$('title').hidden && !$('btn-continue').hidden")
check('legacy save becomes World 1 and retires its old key', pg.evaluate("async () => { const w = await listWorlds(); return w.length === 1 && w[0].name === 'World 1' && await DB.get('main') === undefined; }"))
pg.click('#btn-continue'); pg.wait_for_function('GAME.running')
check('Continue loads the migrated seed and playtime', pg.evaluate('worldSeed === 12345 && GAME.playTime >= 3661 && !!GAME.worldId'))
pg.evaluate('quitToTitle()')
for seed in [222, 333]:
if not pg.is_visible('#nw-seed'): pg.click('#btn-new')
pg.fill('#nw-seed', str(seed)); pg.click('#btn-generate')
pg.wait_for_function('GAME.running', timeout=20000)
pg.evaluate('quitToTitle()')
pg.wait_for_function("!$('title').hidden")
pg.click('#btn-worlds'); pg.locator('#m-worlds').wait_for(state='visible')
check('three independent worlds appear with their seeds', pg.locator('.world-row').count() == 3 and all(str(seed) in pg.inner_text('#worlds-list') for seed in [12345, 222, 333]))
# Both looks, at both requested sizes; title UI shares the same styling.
for look_shot in ['pixel', 'smooth']:
pg.evaluate(f"SET.look = '{look_shot}'")
for width, height, suffix in [(1280, 800, ''), (900, 700, '-900')]:
pg.set_viewport_size({'width': width, 'height': height})
pg.screenshot(path=str(root / 'shots' / f'worlds-{look_shot}{suffix}.png'))
pg.set_viewport_size({'width': 1280, 'height': 800}); pg.evaluate("SET.look = 'pixel'")
ids = pg.evaluate("async () => Object.fromEntries((await listWorlds()).map(w => [w.seed, w.id]))")
for seed in [222, 333]:
row = pg.locator(f'[data-world="{ids[str(seed)]}"]')
row.get_by_role('button', name='Load', exact=True).click(); pg.wait_for_function('GAME.running')
check(f'Load switches to seed {seed}', pg.evaluate('worldSeed') == seed and pg.evaluate('GAME.worldId') == ids[str(seed)])
pg.evaluate('quitToTitle()'); pg.click('#btn-worlds')
row = pg.locator(f'[data-world="{ids["222"]}"]')
row.get_by_role('button', name='Rename', exact=True).click()
row.locator('input').fill('<b>x</b>'); row.get_by_role('button', name='Save', exact=True).click()
pg.wait_for_function("document.querySelector('.world-row strong') && [...document.querySelectorAll('.world-row strong')].some(e => e.textContent === '<b>x</b>')")
check('HTML in world names is text', row.locator('strong').inner_text() == '<b>x</b>' and row.locator('strong b').count() == 0)
pg.reload(); pg.wait_for_function("!$('title').hidden"); pg.click('#btn-worlds')
check('rename persists after reload', row.locator('strong').inner_text() == '<b>x</b>')
check('blank renames keep the old name', pg.evaluate("async id => { await renameWorld(id, ' '); return (await listWorlds()).find(w => w.id === id).name === '<b>x</b>'; }", ids['222']))
row.get_by_role('button', name='Rename', exact=True).click()
row.locator('input').evaluate("e => e.value = 'x'.repeat(60)")
row.get_by_role('button', name='Save', exact=True).click()
pg.wait_for_function("[...document.querySelectorAll('.world-row strong')].some(e => e.textContent === 'x'.repeat(40))")
check('rename boundary cuts names to 40 characters', row.locator('strong').inner_text() == 'x' * 40)
row.get_by_role('button', name='Delete', exact=True).click()
pg.locator('#dlg-foot').get_by_role('button', name='Cancel', exact=True).click()
check('cancelling deletion keeps the world', row.count() == 1)
row.get_by_role('button', name='Delete', exact=True).click()
pg.locator('#dlg-foot').get_by_role('button', name='Delete', exact=True).click()
pg.wait_for_function("document.querySelectorAll('.world-row').length === 2")
check('confirmed deletion removes both saves and the entry', pg.evaluate("async id => !(await listWorlds()).some(w => w.id === id) && !await DB.get(id + ':main') && !await DB.get(id + ':auto')", ids['222']))
check('each world chooses its newer autosave without changing another', pg.evaluate("""async id => {
const main = await DB.get(id + ':main');
await DB.put(id + ':auto', { ...main, savedAt: main.savedAt + 1, playTime: 7777 });
const sv = await worldNewest(id); return sv.playTime === 7777 && sv.worldId === id;
}""", ids['333']))
pg.keyboard.press('Escape'); pg.click('#btn-continue'); pg.wait_for_function('GAME.running')
check('Continue chooses the most recently saved world', pg.evaluate('worldSeed') == 333)
pg.evaluate('quitToTitle()')
if not pg.is_visible('#nw-seed'): pg.click('#btn-new')
pg.fill('#nw-seed', '777'); pg.click('#btn-generate')
pg.wait_for_function('GAME.running === true', timeout=20000)
pg.wait_for_timeout(500)
ev = pg.evaluate
@@ -542,10 +612,13 @@ with sync_playwright() as p:
pg3 = ctx.new_page()
pg3.on('pageerror', lambda e: logs.append(f'PAGEERROR(import): {e}'))
pg3.goto(url); pg3.wait_for_timeout(300)
worlds_before_import = ev('listWorlds().then(w => w.length)')
pg3.set_input_files('#importfile', dl.path())
pg3.wait_for_function('GAME.running === true', timeout=60000)
got = pg3.evaluate('JSON.stringify([W, H, Math.round(money), Math.round(debt), bagTotal])')
check('importing it on the title screen brings the same world back', got == want, f'{want} -> {got}')
pg3.wait_for_function("async () => (await listWorlds()).length === " + str(worlds_before_import + 1))
check('import adds an independent saved world', pg3.evaluate('GAME.worldId') != ev('GAME.worldId') and pg3.evaluate('listWorlds().then(w => w.length)') == worlds_before_import + 1)
pg3.close()
pg.keyboard.press('Escape'); settle(150)
@@ -562,6 +635,13 @@ with sync_playwright() as p:
pg2.close()
print('\n'.join(logs) or 'no console errors')
check('every status line is short (≤ 90 characters)', len(ev('_maxStatus')) <= 90, ev('_maxStatus'))
ev('quitToTitle()'); ev("openSettings('Game')")
before_wipe = ev('listWorlds().then(w => w.length)')
pg.click('#set-wipe'); pg.locator('#dlg-foot').get_by_role('button', name='Cancel', exact=True).click()
check('cancelling Delete all worlds keeps every world', ev('listWorlds().then(w => w.length)') == before_wipe)
pg.click('#set-wipe'); pg.locator('#dlg-foot').get_by_role('button', name='Delete all worlds', exact=True).click()
pg.wait_for_function("async () => await DB.get('worlds') === undefined && $('btn-continue').hidden && $('btn-worlds').hidden")
check('Delete all worlds clears the saves and index', ev("DB.op('readonly', st => st.getAllKeys()).then(keys => keys.length === 0)"))
print('FAILS:', fails)
b.close()
sys.exit(1 if fails else 0)