Dev console: money, items, upgrades, free build, speed (t66)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-27 14:30:37 -04:00
co-authored by Claude Opus 5.5
parent 02e338a46f
commit dd8068dfe6
19 changed files with 400 additions and 23 deletions
+2 -2
View File
@@ -3,7 +3,7 @@
python3 test/run_all.py every headless suite (test/t_*.js), in parallel
python3 test/run_all.py pipes stone only suites whose name contains one of these words
python3 test/run_all.py --browser also the browser tests (play.py, then t_smooth.py),
python3 test/run_all.py --browser also browser tests (play.py, t_smooth.py, t_dev_console.py),
after rebuilding dist/; they run one at a time
python3 test/run_all.py --browser-only
@@ -42,7 +42,7 @@ def main():
if browser:
subprocess.run([sys.executable, str(ROOT / 'build.py')], cwd=ROOT, check=True)
# one at a time: two headless browsers at once can starve each other and flake
for name in ['play.py', 't_smooth.py']: results.append(run_py(name))
for name in ['play.py', 't_smooth.py', 't_dev_console.py']: results.append(run_py(name))
bad = 0
for name, code, out, secs in results:
fails = [l for l in out.splitlines() if l.startswith('FAIL') or 'Error' in l or l.startswith('FAILS: [') and l != 'FAILS: []']
+73
View File
@@ -0,0 +1,73 @@
// Dev command validation, discovery, build costs, runtime controls and save metadata.
const { load, suite, flatWorld } = require('./harness');
const { run } = load();
const { check, done } = suite();
flatWorld(run, 240, 160, 110);
const command = text => run(`devCommand(${JSON.stringify(text)})`);
check('commands cannot change an unmarked world', command('money 123')[0].includes('warning'));
run('enableDevConsole(); money = 100;');
command('MoNeY -25'); command('money set 1234.5');
check('money adds signed values and sets amounts, ignoring case', run('money === 1234.5'));
command('money -34.5');
check('negative money amount subtracts', run('money === 1200'));
command('debt 500');
check('debt sets claim amount', run('debt === 500'));
command('debt clear');
check('debt clear pays off claim', run('debt === 0'));
run('seen.fill(0); EV.length = 0;');
command('give cUoRe 7'); command('give Copper Bar 3'); command('give charc'); command('give sand 30000');
check('give matches internal names, display names and unique prefixes', run('bag[CUORE] === 7 && bag[BARCU] === 3 && bag[CHARCOAL] === 1000'));
check('give exceeds bag capacity, counts totals and discovers like a pickup', run("bag[SAND] === 30000 && bagTotal === 31010 && seen[SAND] && EV.some(e => e.t === 'discover' && e.m === SAND && e.how === 'pickup')"));
check('ambiguous prefix explains candidates', command('give cop')[0].startsWith('Ambiguous material:'));
check('unknown material is explained', command('give nonexistent')[0].startsWith('Unknown material'));
check('all internal aliases resolve exactly', run('Object.keys(DEV_MATERIALS).length === NMAT && Object.entries(DEV_MATERIALS).every(([k, v]) => devMaterial(k) === v)'));
command('give sand 2147483647');
check('give rejects typed-array overflow', run('bag[SAND] === 30000'));
command('bit 1e100'); command('bag -5');
check('tier setters clamp to valid range', run('player.bitTier === BIT_TIERS.length - 1 && player.tankTier === 0'));
check('tier queries list every tier', command('bit').length === run('BIT_TIERS.length') && command('bag').length === run('TANK_TIERS.length'));
run('player.lens = player.flask = false;'); command('unlock');
check('unlock learns materials, rewards and milestones', run('seen.every(Boolean) && player.lens && player.flask && MILESTONES.every(m => ledger[m.id])'));
command('tp -500 9999');
check('teleport clamps the entire bot inside the world', run('bot.x === 0 && bot.y === H - bot.h && bot.vx === 0 && bot.vy === 0'));
run('devCommand("tp", {x: 70, y: 45})');
check('teleport can use the cursor', run('bot.x === 70 && bot.y === 45'));
command('speed 1e100'); check('speed clamps high', run('devSpeed === 8'));
command('speed -1'); check('speed clamps low', run('devSpeed === 0.1'));
command('step'); check('step requires dev pause', run('devSteps === 0'));
command('pause'); command('step 5');
check('paused steps are queued for the frame loop', run('devPaused && devSteps === 5'));
command('pause'); check('resuming discards queued steps', run('!devPaused && devSteps === 0'));
const beforeBad = run('JSON.stringify([money, debt, bagTotal, player, devLog, devSpeed, devSteps])');
for (const text of ['money', 'money set', 'money Infinity', 'money 1e309', 'money 1 2', 'debt -1', 'debt nope', 'give sand -1', 'give sand 1.5', 'bit 1.5', 'bag nope', 'unlock extra', 'tp 1', 'tp NaN 4', 'free maybe', 'speed NaN', 'step 0', 'step 10001', 'pause extra', 'weather snow', 'nonsense']) {
const output = command(text);
check('bad arguments produce a helpful error: ' + text, output.length > 0 && !output[0].startsWith('>'));
}
check('invalid commands do not mutate or log', beforeBad === run('JSON.stringify([money, debt, bagTotal, player, devLog, devSpeed, devSteps])'));
check('help lists every command', command('help').length === 13);
run('money = 17; bag.fill(0); bagTotal = 0; bagAdd(RSTONE, 32, true); bagAdd(LCLAY, 100, true);');
check('normal build costs are checked', run('!canAfford([[CASH, 100]], 1)'));
command('free on');
const buildBag = run('JSON.stringify(Array.from(bag))');
check('free bypasses affordability', run('canAfford([[CASH, 100000], [BARCU, 99]], 1)'));
check('free places a wall without taking existing rubble', run("placeBlock(BP_WALL, RSTONE, 10, 10, false) === '' && bag[RSTONE] === 32"));
check('free places a machine without charging money', run("placeMachineAt('fan', 0, 24, 10) === '' && money === 17"));
check('free never charges, including blueprint ghosts built later', run('(() => { const m = money; payCost([[CASH, 500]], 3); return money === m; })()'));
check('free places a mold without taking clay', run("typeof addMold('ingot', 35, 10, LCLAY, false) !== 'string' && bag[LCLAY] === 100"));
run("ghostBlocks.set(12 * BW + 17, [BP_BELT, 1]); ghostMachines.push({type: 'fan', dir: 0, bx: 20, by: 17}); ghostMolds.push({kind: 'ingot', bx: 9, by: 16, wall: LCLAY}); ghostTick(); ghostTick();");
check('ghost blocks, machines and molds build free too', run('!ghostBlocks.size && !ghostMachines.length && !ghostMolds.length && money === 17 && ghostMissing === 0'));
check('all free paths preserve bag contents and totals', buildBag === run('JSON.stringify(Array.from(bag))') && run('bagTotal === 132'));
command('free off'); run('payCost([[CASH, 2], [RSTONE, 4]], 1)');
check('normal payment resumes after free off', run('money === 15 && bag[RSTONE] === 28 && bagTotal === 128'));
command('weather rain');
check('weather rain starts a shower near the bot', run('weather.shower && rainAt(bot.x) > 0 && weather.shower.until === tick + 3600'));
command('weather clear');
check('weather clear stops current shower', run('weather.shower === null && weather.raining.every(v => !v)'));
run('for (let i = 0; i < 210; i++) devCommand("money 1");');
check('dev log keeps the last 200 commands', run('devLog.length === 200 && devLog.every(e => e[0] === tick && e[1] === "money 1")'));
run('devCommand("speed 4"); devCommand("pause"); devCommand("free on"); const devSaved = serializeWorld(); const devLogCopy = JSON.stringify(devLog); deserializeWorld(devSaved.meta, devSaved.buffers);');
check('dev mark and command log survive save round trip', run('devWorld && devSaved.meta.dev === true && JSON.stringify(devLog) === devLogCopy'));
check('load resets speed, free building, pause and pending steps', run('devSpeed === 1 && !devFree && !devPaused && devSteps === 0'));
run('alloc(240,160)');
check('a new world starts unmarked without a dev log', run('!devWorld && devLog.length === 0'));
done();
+100
View File
@@ -0,0 +1,100 @@
"""Dev console confirmation, keyboard isolation, HUD, time controls and saved badge."""
import pathlib, sys
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent / 'tools'))
from use_venv import ensure_playwright; ensure_playwright()
from playwright.sync_api import sync_playwright
ROOT = pathlib.Path(__file__).resolve().parent.parent
SHOTS = ROOT / 'shots'
SHOTS.mkdir(exist_ok=True)
fails = []
def check(name, ok):
print(('PASS ' if ok else 'FAIL ') + name)
if not ok: fails.append(name)
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page(viewport={'width': 1280, 'height': 760})
errors = []
page.on('pageerror', lambda e: errors.append(str(e)))
page.add_init_script("localStorage.setItem('deepgrain.settings', JSON.stringify({look:'pixel', sv:2}));")
page.goto((ROOT / 'dist/index.html').as_uri())
page.wait_for_function("!$('title').hidden")
page.evaluate("startNewWorld(424242, 512, 384, 'Console test')")
page.wait_for_function('GAME.running && !GAME.loading')
page.evaluate("document.querySelectorAll('.card, .toast').forEach(e => e.remove())")
page.keyboard.press('`')
check('backtick opens the first-use warning', page.is_visible('#m-dialog') and not page.is_visible('#dev-console'))
page.screenshot(path=str(SHOTS / 't66-warning.png'))
check('warning explains permanent marking and future achievements', 'permanently' in page.inner_text('#dlg-body') and 'achievements' in page.inner_text('#dlg-body'))
page.get_by_role('button', name='Cancel', exact=True).click()
check('cancel leaves the world unmarked and console closed', page.evaluate("!devWorld && $('dev-console').hidden && !GAME.paused"))
page.keyboard.press('`')
page.get_by_role('button', name='Enable dev console', exact=True).click()
page.wait_for_function("!$('dev-console').hidden && document.activeElement === $('dev-input')")
check('accepting marks the world and resumes play', page.evaluate('devWorld && !GAME.paused'))
check('build material chip stays stable across HUD refreshes', page.evaluate("""() => {
selectTool(2); renderSelCard();
const chip = $('selcard').querySelector('[data-wall-picker]');
renderSelCard();
const stable = chip === $('selcard').querySelector('[data-wall-picker]');
selectTool(0); return stable;
}"""))
old_money = page.evaluate('money')
page.fill('#dev-input', 'money 1000'); page.keyboard.press('Enter')
page.wait_for_function(f"money === {old_money + 1000} && $('wallet').textContent.includes(fmtMoney(money))")
check('money command changes the wallet HUD', True)
tool = page.evaluate('GAME.tool')
page.fill('#dev-input', ''); page.keyboard.type('1234wasd')
check('typing game keys does not switch tools or hold movement', page.evaluate(f'GAME.tool === {tool} && keys.size === 0'))
page.keyboard.press('Escape')
check('Escape closes console without opening pause menu', page.evaluate("$('dev-console').hidden && $('m-pause').hidden && !GAME.paused"))
page.keyboard.press('`')
check('marked worlds reopen without the warning', page.evaluate("!$('dev-console').hidden && $('m-dialog').hidden"))
page.keyboard.press('`')
check('backtick closes even with input focused', not page.is_visible('#dev-console'))
page.keyboard.press('`')
def command(text):
page.fill('#dev-input', text); page.keyboard.press('Enter')
command('pause')
tick = page.evaluate('tick')
page.wait_for_timeout(150)
check('dev pause freezes simulation ticks', page.evaluate('tick') == tick)
command('step 5')
page.wait_for_function(f'tick === {tick + 5} && devSteps === 0')
page.wait_for_timeout(150)
check('step advances exactly five ticks then stays paused', page.evaluate('tick') == tick + 5)
# Drive the actual frame accumulator with controlled elapsed time and no scheduled frames.
counts = page.evaluate('''() => {
requestAnimationFrame = () => 0;
GAME.paused = false; devPaused = false; SET.fpsCap = 0; SET.maxSteps = 3;
const counts = [];
for (const speed of [0.5, 1, 2, 8]) {
devSpeed = speed; acc = 0; const before = tick;
const t = performance.now(); lastT = t - 34; lastRender = t - 34; frame(t);
counts.push(tick - before);
}
return counts;
}''')
check('speed scales fixed ticks and respects maxSteps', counts == [1, 2, 3, 3])
page.evaluate('devCommand("debt clear"); runDevCommand("money 1"); openNotebook("Claims")')
page.wait_for_function("$('nb-body').textContent.includes('paid off')")
check('cleared debt is reflected in Claims', True)
page.evaluate('closeAllModals(); runDevCommand("debt 123"); openNotebook("Market")')
check('new debt is reflected in the Market', '$123' in page.inner_text('#nb-body'))
page.evaluate('closeAllModals()')
page.evaluate("async () => { await saveWorld('main', true); await renderWorlds(); }")
page.evaluate("openModal('m-worlds')")
check('world list entry stores and draws the dev badge', page.locator('#worlds-list .dev-badge').is_visible() and page.inner_text('#worlds-list .dev-badge') == 'Dev console')
page.screenshot(path=str(SHOTS / 't66-worlds.png'))
page.evaluate('closeAllModals()')
check('saving and loading preserves mark and log, resets runtime controls', page.evaluate('''async () => {
const sv = await worldNewest(GAME.worldId);
const count = devLog.length;
await startFromSave(sv);
return devWorld && devLog.length === count && devSpeed === 1 && !devPaused && !devFree && $('dev-console').hidden;
}'''))
check('no browser errors', not errors)
browser.close()
sys.exit(1 if fails else 0)