.'];
+ bot.x = Math.max(0, Math.min(W - bot.w, x)); bot.y = Math.max(0, Math.min(H - bot.h, y)); bot.vx = bot.vy = 0;
+ fogLook(bot.x + bot.w / 2, bot.y + bot.h / 2);
+ result = `Bot: ${bot.x}, ${bot.y} grains`; break;
+ }
+ case 'free':
+ if (args.length !== 1 || !['on', 'off'].includes(args[0])) return usage();
+ devFree = args[0] === 'on'; result = 'Free building ' + args[0] + '.'; break;
+ case 'speed':
+ if (args.length !== 1 || !number(args[0])) return usage();
+ devSpeed = Math.max(0.1, Math.min(8, Number(args[0]))); result = 'Speed: ' + devSpeed + '×'; break;
+ case 'pause':
+ if (args.length) return usage();
+ devPaused = !devPaused; devSteps = 0; result = devPaused ? 'Dev paused. Use step or pause to resume.' : 'Dev pause off.'; break;
+ case 'step': {
+ const n = args.length ? Number(args[0]) : 1;
+ if (args.length > 1 || !Number.isInteger(n) || n < 1 || n > 10000 || devSteps + n > 10000) return usage();
+ if (!devPaused) return ['Use pause before step.'];
+ devSteps += n; result = `Queued ${n} ticks.`; break;
+ }
+ case 'weather':
+ if (args.length !== 1 || !['clear', 'rain'].includes(args[0])) return usage();
+ weather.shower = args[0] === 'rain' ? { x0: Math.max(0, bot.x - 160), x1: Math.min(W, bot.x + 160), rate: 2, ramp: 1, until: tick + 3600 } : null;
+ if (args[0] === 'clear') {
+ weather.raining.fill(0);
+ for (const f of flyers) if (f.rain) absorbSteam(f.x);
+ flyers = flyers.filter(f => !f.rain);
+ }
+ weather.next = tick + rainGap(); result = args[0] === 'rain' ? 'A one-minute shower started near the bot.' : 'Current rain cleared. Stored clouds may rain again.'; break;
+ default: return usage();
+ }
+ devLog.push([tick, text]); if (devLog.length > 200) devLog.shift();
+ return ['> ' + text, result];
+}
diff --git a/src/sim/items.js b/src/sim/items.js
index 63dcd2a..be9b2f1 100644
--- a/src/sim/items.js
+++ b/src/sim/items.js
@@ -310,8 +310,8 @@ function giveOrDrop(m, n, x, y) {
if (rest > 0) bagAdd(m, rest, true);
}
function refundAt(cost, mult, x, y) { for (const [m, n] of cost) giveOrDrop(m, n * mult, x, y); }
-function canAfford(cost, mult) { for (const [m, n] of cost) if ((m === CASH ? money : bag[m]) < n * mult) return false; return true; }
-function payCost(cost, mult) { for (const [m, n] of cost) { if (m === CASH) money -= n * mult; else bagTake(m, n * mult); } }
+function canAfford(cost, mult) { if (devFree) return true; for (const [m, n] of cost) if ((m === CASH ? money : bag[m]) < n * mult) return false; return true; }
+function payCost(cost, mult) { if (devFree) return; for (const [m, n] of cost) { if (m === CASH) money -= n * mult; else bagTake(m, n * mult); } }
function fmtMoney(v) { return '$' + Math.round(v).toLocaleString('en-US'); }
function costText(cost, mult) { return cost.map(([m, n]) => { const k = n * (mult || 1); return m === CASH ? fmtMoney(k) : `${k} ${MD[m].name.toLowerCase()}${PH[m] === PH_ITEM && k !== 1 ? 's' : ''}`; }).join(' + '); }
function needText(cost, mult) { const c = cost.find(([m, n]) => (m === CASH ? money : bag[m]) < n * (mult || 1)); return c && c[0] === CASH ? `Costs ${fmtMoney(c[1] * (mult || 1))}; you have ${fmtMoney(money)}.` : 'Not enough ' + costText(c ? [c] : cost, mult) + '.'; }
diff --git a/src/sim/save.js b/src/sim/save.js
index d7ecfd8..0b87435 100644
--- a/src/sim/save.js
+++ b/src/sim/save.js
@@ -9,6 +9,7 @@ function serializeWorld() {
const t16 = new Uint16Array(N);
for (let i = 0; i < N; i++) { const v = (temp[i] + TOFF) * 16; t16[i] = v < 0 ? 0 : v > 65535 ? 65535 : v; }
const meta = {
+ dev: devWorld, devLog: devLog.map(e => e.slice()),
slopePhases: Array.from(slopePhase),
v: SAVE_VERSION, seed: worldSeed, W, H, tick, surfBase, underD, shallowD, lapse, tOff: TOFF,
saplings: saplings.map(s => [s.x, s.y, s.age, s.th, s.seed]),
@@ -32,6 +33,8 @@ function serializeWorld() {
function deserializeWorld(meta, buffers) {
if (meta.v !== SAVE_VERSION) throw new Error('This world was saved by an older version of the game and cannot be loaded.');
alloc(meta.W, meta.H);
+ devWorld = meta.dev === true;
+ devLog = devWorld && Array.isArray(meta.devLog) ? meta.devLog.filter(e => Array.isArray(e) && Number.isSafeInteger(e[0]) && e[0] >= 0 && typeof e[1] === 'string').slice(-200).map(e => [e[0], e[1]]) : [];
worldSeed = meta.seed;
if (meta.surfBase) { surfBase = meta.surfBase; underD = meta.underD; }
shallowD = meta.shallowD || 0; lapse = meta.lapse || 0.36;
diff --git a/src/sim/world.js b/src/sim/world.js
index ea9e6f5..68c1ec4 100644
--- a/src/sim/world.js
+++ b/src/sim/world.js
@@ -39,6 +39,7 @@ function discover(m, x, y, from, how, T) {
}
function alloc(w, h) {
+ resetDevConsole();
W = w; H = h; N = w * h;
fogAlloc();
CW = Math.ceil(w / 32); CH = Math.ceil(h / 32); NCH = CW * CH;
diff --git a/src/styles/devconsole.css b/src/styles/devconsole.css
new file mode 100644
index 0000000..5eb4135
--- /dev/null
+++ b/src/styles/devconsole.css
@@ -0,0 +1,16 @@
+#dev-console {
+ position: fixed; z-index: 15; bottom: 0; left: 0; right: 0;
+ padding: 12px 18px; border-radius: 0; border-top: 2px solid var(--patina);
+ background: rgba(15, 13, 12, 0.97); max-height: 55vh;
+}
+.dev-head { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
+.dev-head strong { color: var(--patina); }
+.dev-head button { margin-left: auto; }
+#dev-output {
+ height: 160px; max-height: 28vh; overflow-y: auto; margin: 8px 0;
+ font: 12px/1.6 var(--font-mono); white-space: pre-wrap; overflow-wrap: anywhere;
+ user-select: text;
+}
+#dev-form { display: flex; align-items: center; gap: 10px; }
+#dev-input { flex: 1; min-width: 0; font-family: var(--font-mono); }
+.dev-badge { display: inline-block; padding: 1px 6px; border: 1px solid var(--patina-dim); border-radius: 4px; color: var(--patina); font-size: 11px; }
diff --git a/src/ui/build-menu.js b/src/ui/build-menu.js
index dc48359..64862e4 100644
--- a/src/ui/build-menu.js
+++ b/src/ui/build-menu.js
@@ -57,7 +57,7 @@ function buildVariantChips(P) {
const dirChip = !P.dirs ? '' : dragging ? `${glyph(slopeDir || P.dirs[GAME.dragDir])} ${esc(slopeDir || P.dirs[GAME.dragDir])} · from your drag` : `${glyph(slopeDir || dir)} ${esc(slopeDir || dir)} · R`;
const fc = GAME.filterCfg;
const sws = ms => ms.slice(0, 6).map(m => ``).join('') + (ms.length > 6 ? `+${ms.length - 6}` : '');
- return pieceUsesWall(P) ? `${P.code === BP_WALL ? `${['square', '◣ lower left', '◢ lower right', '◤ upper left', '◥ upper right'][isSlope(pieceCode(P)) ? pieceCode(P) - BP_SLOPE_BL + 1 : 0]} · T · turn / flip` : ''}`
+ return pieceUsesWall(P) ? `${P.code === BP_WALL ? `${['square', '◣ lower left', '◢ lower right', '◤ upper left', '◥ upper right'][isSlope(pieceCode(P)) ? pieceCode(P) - BP_SLOPE_BL + 1 : 0]} · T · turn / flip` : ''}`
: P.filter ? `${fc.inv ? 'carries' : 'drops'} ${fc.m.length ? sws(fc.m) : 'nothing'} · F${dirChip}`
: dir ? dirChip : '';
}
diff --git a/src/ui/devconsole.js b/src/ui/devconsole.js
new file mode 100644
index 0000000..b9089c8
--- /dev/null
+++ b/src/ui/devconsole.js
@@ -0,0 +1,42 @@
+// Bottom panel; command dispatch and state live in sim/devconsole.js.
+function closeDevConsole() {
+ $('dev-console').hidden = true;
+ $('dev-input').blur();
+}
+function showDevConsole() {
+ $('dev-console').hidden = false;
+ if (!$('dev-output').children.length) devPrint(['Type help to list commands. Backtick or Escape closes this panel.']);
+ $('dev-input').focus();
+}
+function toggleDevConsole() {
+ if (!GAME.running || GAME.loading) return;
+ if (!$('dev-console').hidden) { closeDevConsole(); return; }
+ if (devWorld) { showDevConsole(); return; }
+ dialog('Enable dev console?', 'This save will be permanently marked as a dev-console world. If achievements are added later, they will not unlock in this world.
You can change money, materials, upgrades and game speed. These commands are logged but cannot be undone.
', [
+ { label: 'Cancel' },
+ { label: 'Enable dev console', cls: 'primary', fn: () => {
+ enableDevConsole(); closeModal('m-dialog'); showDevConsole(); saveWorld('main'); return true;
+ } },
+ ]);
+}
+function devPrint(lines) {
+ const log = $('dev-output');
+ for (const line of lines) { const row = document.createElement('div'); row.textContent = line; log.appendChild(row); }
+ while (log.children.length > 400) log.firstChild.remove();
+ log.scrollTop = log.scrollHeight;
+}
+function runDevCommand(text) {
+ devPrint(devCommand(text, GAME.aim));
+ hudDirty = true; lastBagKey = ''; lastBuildKey = '';
+ renderWallet();
+ if (!$('m-notebook').hidden) renderNotebook();
+}
+$('dev-close').onclick = closeDevConsole;
+$('dev-input').onfocus = () => { keys.clear(); GAME.lmb = GAME.rmb = false; GAME.drag = null; GAME.replace = false; };
+$('dev-input').onkeydown = e => {
+ if (e.key === 'Escape' || e.key === '`') { e.preventDefault(); e.stopPropagation(); closeDevConsole(); }
+};
+$('dev-form').onsubmit = e => {
+ e.preventDefault();
+ const input = $('dev-input'); runDevCommand(input.value); input.value = ''; input.focus();
+};
diff --git a/src/ui/input.js b/src/ui/input.js
index 4637e0a..f2ae3cf 100644
--- a/src/ui/input.js
+++ b/src/ui/input.js
@@ -24,6 +24,7 @@ window.addEventListener('keydown', e => {
if (e.repeat) return;
GAME.usingPad = false;
switch (k) {
+ case '`': e.preventDefault(); toggleDevConsole(); break;
case '1': case '2': case '3': case '4': selectTool(+k - 1); break;
case 'c': case 'x': hudDirty = true; break;
case 'v': startPaste(); break;
@@ -42,7 +43,7 @@ window.addEventListener('keydown', e => {
case '[': setBrush(-1); break;
case ']': setBrush(1); break;
case 'Delete': if (ghostBlocks.size || ghostMachines.length || ghostMolds.length) { clearAllGhosts(); toast('Blueprint ghosts cleared.'); } break;
- case 'Escape': if (GAME.selDrag || GAME.paste) stopSelectAndPaste(); else openPause(); break;
+ case 'Escape': if (!$('dev-console').hidden) closeDevConsole(); else if (GAME.selDrag || GAME.paste) stopSelectAndPaste(); else openPause(); break;
case 'Tab': $('bagpanel').hidden = !$('bagpanel').hidden; break;
}
});
diff --git a/src/ui/loop.js b/src/ui/loop.js
index d79634b..ee6cf75 100644
--- a/src/ui/loop.js
+++ b/src/ui/loop.js
@@ -22,11 +22,11 @@ function frame(now) {
const pad = pollPad();
if (GAME.usingPad) { GAME.aim.x = bot.x + bot.w / 2 + GAME.padAim.x; GAME.aim.y = bot.y + bot.h / 2 + GAME.padAim.y; }
else { const w = screenToWorld(GAME.mouse.x, GAME.mouse.y); GAME.aim.x = w.x; GAME.aim.y = w.y; }
- if (!GAME.paused) {
- acc += dtReal * SET.simSpeed;
+ if (!GAME.paused && (!devPaused || devSteps > 0)) {
+ acc += devPaused ? 0 : dtReal * SET.simSpeed * devSpeed;
let steps = 0;
const step = 1 / 60;
- while (acc >= step && steps < SET.maxSteps) {
+ while ((devPaused ? devSteps > 0 : acc >= step) && steps < SET.maxSteps) {
let ix = 0, iy = 0;
if (keys.has('a') || keys.has('ArrowLeft')) ix -= 1; if (keys.has('d') || keys.has('ArrowRight')) ix += 1;
if (keys.has('w') || keys.has('ArrowUp') || keys.has(' ')) iy -= 1; if (keys.has('s') || keys.has('ArrowDown')) iy += 1;
@@ -38,7 +38,8 @@ function frame(now) {
botStep(ix, iy, keys.has('Shift') || GAME.padRun > 24);
toolTick();
const s0 = performance.now(); simTick(); simMs = simMs * 0.9 + (performance.now() - s0) * 0.1;
- acc -= step; steps++;
+ if (devPaused) devSteps--; else acc -= step;
+ steps++;
GAME.playTime += step;
}
if (steps >= SET.maxSteps) acc = 0;
diff --git a/src/ui/notebook.js b/src/ui/notebook.js
index 36c3297..b0b136b 100644
--- a/src/ui/notebook.js
+++ b/src/ui/notebook.js
@@ -57,7 +57,7 @@ function renderNotebook() {
}
function controlsHtml() {
const kb = [
- ['Fly', 'W A S D'], ['Boost while flying', 'Shift'], ['Tool modifier (while not flying)', 'Shift'], ['Use tool', 'Left mouse'], ['Second action', 'Right mouse'],
+ ['Dev console (marks the world)', 'Backtick (`)'], ['Fly', 'W A S D'], ['Boost while flying', 'Shift'], ['Tool modifier (while not flying)', 'Shift'], ['Use tool', 'Left mouse'], ['Second action', 'Right mouse'],
['Tools', '1 – 4'], ['Select one to blow · piece', 'Q / E'], ['Switch build tab', 'Shift + Q / E'], ['Variant: material · direction · flip', 'R'], ['Wall: cycle shape · flip diagonal drag', 'T'], ['Wall or mold material menu (Build)', 'F'],
['Pick up selection or what you click (Pneumatic nozzle)', 'Left drag · Shift: everything'], ['Select (Pneumatic nozzle)', 'Click a bag name · Q/E'], ['Add to selection (Pneumatic nozzle)', 'Ctrl+click a bag name'], ['Show everything found (Pneumatic nozzle)', 'Hold Ctrl in the bag'], ['Clear selection (Pneumatic nozzle)', 'Clear in the bag'], ['Blow everything loose in the bag', 'Shift + right click'], ['Nozzle or brush size', '[ ] or Shift + wheel'],
['Replace what is built (Build)', 'Hold G'], ['Copy a box of what you built', 'Hold C, drag'], ['Cut a box (refunded, paste it back)', 'Hold X, drag'], ['Paste again', 'V'], ['Undo building', 'Ctrl + Z'], ['Blueprint library', 'B'], ['Zoom', 'Mouse wheel'], ['Interact (gates, molds, fans, filters)', 'F'], ['Thermal lens', 'H'], ['Info overlay: filters, belt and screw conveyor directions', 'I'], ['Bot lamp', 'L'],
diff --git a/src/ui/saves.js b/src/ui/saves.js
index 8e99a9e..233ab8c 100644
--- a/src/ui/saves.js
+++ b/src/ui/saves.js
@@ -112,7 +112,7 @@ async function saveWorld(slot, quiet) {
const list = await listWorlds();
let entry = list.find(w => w.id === id);
if (!entry) { entry = { id, name, createdAt: savedAt }; list.push(entry); }
- Object.assign(entry, { savedAt, playTime, seed: s.meta.seed, w: s.meta.W, h: s.meta.H, debt: s.meta.economy.debt, ...(c ? { career: c.id, claimNo } : {}) });
+ Object.assign(entry, { savedAt, playTime, seed: s.meta.seed, w: s.meta.W, h: s.meta.H, debt: s.meta.economy.debt, dev: s.meta.dev, ...(c ? { career: c.id, claimNo } : {}) });
await DB.op('readwrite', st => { st.put({ meta: s.meta, buffers, savedAt, playTime }, `${id}:${slot}`); st.put(list, 'worlds'); if (c) st.put(c, 'career:' + c.id); });
});
if (!quiet) toast(slot === 'auto' ? 'Autosaved.' : 'World saved.');
diff --git a/src/ui/selection.js b/src/ui/selection.js
index 9681668..19d59fa 100644
--- a/src/ui/selection.js
+++ b/src/ui/selection.js
@@ -91,6 +91,7 @@ function pieceExtra(P) { return P.filter ? { m: GAME.filterCfg.m.slice(), inv: G
function filterBlockAt(x, y) { if (x < 0 || y < 0 || x >= W || y >= H) return -1; const b = ((y / BS) | 0) * BW + ((x / BS) | 0); return isFilter(blockP[b]) ? b : -1; }
function chipCost(cost) { const ok = canAfford(cost, 1); return `${esc(costText(cost, 1))}`; }
function pieceCostNow(P, m = GAME.wallMat) {
+ if (devFree) return [[CASH, 0]];
if (P.kind === 'mold') return wallCost(m, MOLDS[P.mold].walls);
if (P.kind === 'block' && isWall(P.code)) return pieceCost(pieceCode(P), m);
return P.cost;
diff --git a/src/ui/session.js b/src/ui/session.js
index c49954d..3edf922 100644
--- a/src/ui/session.js
+++ b/src/ui/session.js
@@ -11,6 +11,7 @@ function showLoading(on, p, label, title) {
if (on) { $('loadbar').style.width = Math.round((p || 0) * 100) + '%'; $('loadtext').textContent = Math.round((p || 0) * 100) + '%'; }
}
function enterGame() {
+ closeDevConsole(); $('dev-output').textContent = ''; $('dev-input').value = '';
$('cards').innerHTML = '';
if (R.floaters) R.floaters.length = 0; R.particles.length = 0;
GAME.status = ''; GAME.statusT = 0;
@@ -80,7 +81,7 @@ async function loadSavedWorld(id) {
}
async function quitToTitle() {
await saveWorld('main', true);
- GAME.running = false; closeAllModals();
+ GAME.running = false; closeDevConsole(); closeAllModals();
$('hud').hidden = true; $('title').hidden = false;
refreshContinue();
if (!TITLE.raf) drawTitle();
@@ -135,7 +136,7 @@ async function renderWorlds(editId) {
for (const w of list) {
const row = el('div', { class: 'world-row', 'data-world': w.id });
const mins = Math.floor((w.playTime || 0) / 60);
- row.innerHTML = `${esc(w.name)}seed ${esc(w.seed)} · ${esc(w.w)}×${esc(w.h)} grains · ${Math.floor(mins / 60)}:${String(mins % 60).padStart(2, '0')} played
${w.savedAt ? esc(new Date(w.savedAt).toLocaleString()) : 'Not saved yet'}
`;
+ row.innerHTML = `${esc(w.name)}${w.dev ? '
Dev console' : ''}
seed ${esc(w.seed)} · ${esc(w.w)}×${esc(w.h)} grains · ${Math.floor(mins / 60)}:${String(mins % 60).padStart(2, '0')} played
${w.savedAt ? esc(new Date(w.savedAt).toLocaleString()) : 'Not saved yet'}
`;
const actions = el('div', { class: 'world-actions' });
const button = (label, fn, cls = '') => { const b = el('button', { class: 'btn ' + cls }, label); b.onclick = async () => { try { await fn(); } catch (e) { toast('Could not reach browser storage.'); } }; actions.appendChild(b); };
if (editId === w.id) {
diff --git a/test/run_all.py b/test/run_all.py
index 20b1e18..6496e41 100644
--- a/test/run_all.py
+++ b/test/run_all.py
@@ -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: []']
diff --git a/test/t_dev_console.js b/test/t_dev_console.js
new file mode 100644
index 0000000..bde7595
--- /dev/null
+++ b/test/t_dev_console.js
@@ -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();
diff --git a/test/t_dev_console.py b/test/t_dev_console.py
new file mode 100644
index 0000000..a921bb1
--- /dev/null
+++ b/test/t_dev_console.py
@@ -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)