diff --git a/docs/PROJECT_MAP.md b/docs/PROJECT_MAP.md index b047e65..81623f4 100644 --- a/docs/PROJECT_MAP.md +++ b/docs/PROJECT_MAP.md @@ -13,13 +13,13 @@ tasks/, .kandown/ the owner's to-do list: a kandown board (docs/kandown.md; build.py joins src/ into dist/index.html (one self-contained page) src/manifest.json the build order: simulation files, then browser files src/shell.html page markup (HUD, menus, dialogs); CSS and JS are put in at the markers -src/styles/style.css all styles +src/styles/ base styles and the dev console panel src/sim/ the simulation. No DOM. The headless tests load only these files. src/render/ drawing: the pixel look (CPU) and the smooth look (WebGL2), overlays src/ui/ the browser shell: input, HUD, build menu, notebook, settings, saves, audio, the frame loop test/ headless suites (t_*.js) and their harness; browser tests (play.py, - t_smooth.py); run_all.py + t_smooth.py, t_dev_console.py); run_all.py tools/ serve.py (build, serve, rebuild on change), shot.py (screenshots), where.py, map.py, perf.py, playthrough.py, use_venv.py (switches browser scripts to .venv), scenes/ (screenshot setups), experiments/ @@ -29,6 +29,11 @@ docs/ this map, DESIGN.md, TESTING.md, systems/ (notes per syste dist/, shots/ build output and screenshots (not committed) ``` +The dev console pairs `src/sim/devconsole.js` (headless command dispatch, mark, log and +runtime controls) with `src/ui/devconsole.js` (confirmation and bottom panel). Save metadata +keeps `dev` and the last 200 `devLog` entries; free building, speed and dev pause reset on load. +The frame loop applies speed and queued steps while keeping the per-frame tick limit. + ## How it runs - **One script.** All files share one global scope; there is no import/export. @@ -232,7 +237,7 @@ Starter furnace designs and the milestone list shown in the notebook. - functions: smelterBlueprint - variables: STARTER_DESIGNS, MILESTONES -### src/sim/world.js (106 lines) +### src/sim/world.js (107 lines) World state: the per-cell typed arrays, alloc(), ambient temperature by depth, waking. - functions: emit, bagCap, discover, alloc, depthU, depthY, meanSurface, computeAmbient, fillNoise, wakeXY, wakeHeatXY, wakeIdx, wakeHeatIdx - variables: W, H, N, CW, CH, NCH, mat, temp, life, flags, stamp, part, mach, digp, noiseB, airS, netId, isPow, occ, surfY, dirtY, graniteY, basaltY, magmaY, roofY, AMB, worldSeed, surfBase, underD, shallowD, lapse, BW, BH, NB, blockP, blockV, ents, bgm, flyers, ghostBlocks, ghostMolds, awake, hot, hotLvl, powderArea, roofDirty, loadWakeOrder, loadWakePending, loadWakeIndex, tick, tickStamp, machines, molds, nets, pcells, powerDirty, beltL, beltR, screwLists, brokenMachines, ghostMachines, ghostBlocked, EV, bot, bag, bagTotal, player, seen, ledger, stats @@ -364,7 +369,12 @@ World generation helpers: noise and removal of tiny hard rock specks. World generation: staged terrain, ore, caves and placement, with synchronous and async entry points. - functions: generateWorld, generateWorldAsync -### src/sim/save.js (180 lines) +### src/sim/devconsole.js (113 lines) +Dev commands are pure simulation operations; the browser owns the panel and confirmation. +- functions: resetDevConsole, enableDevConsole, devMaterial, devCommand +- variables: devWorld, devLog, devFree, devSpeed, devPaused, devSteps, DEV_HELP, DEV_MATERIALS + +### src/sim/save.js (183 lines) Saving and loading a world (serializeWorld / deserializeWorld) and old-save migration. - functions: serializeWorld, deserializeWorld, startLoadWake, wakeLoadArea, wakeLoadNearBot, loadWakeTick - variables: SAVE_VERSION, TOFF @@ -438,7 +448,7 @@ Shared player state; the world save remains a complete standalone fallback. - functions: careerState, validCareer, applyCareerState, loadCareer, claimName, openNewClaim, travel, renderClaimsTab - variables: career, claimBusy -### src/ui/session.js (157 lines) +### src/ui/session.js (158 lines) Starting, loading and quitting a game; modal dialogs; the pause menu. - functions: showLoading, enterGame, setupNewWorld, startNewWorld, startFromSave, loadSavedWorld, quitToTitle, refreshContinue, anyModal, openModal, closeModal, closeAllModals, topModal, dialog, openPause, renderWorlds @@ -470,7 +480,7 @@ The build menu along the bottom: categories, piece tiles and their icons. - functions: pieceIcon, catOf, selectBuildCat, cycleBuildCat, buildVariantChips, renderBuildBar, setupBuildBar - variables: BUILD_CATS, BUILD_ORDER, PIECE_ICONS, lastBuildKey, lastBuildMenu -### src/ui/selection.js (121 lines) +### src/ui/selection.js (122 lines) Tool selection: cycling, drill tuning, piece variants and costs. - functions: pieceSize, pieceUsesWall, setDrillWidth, tuneUnderCursor, cycleItem, cycleWall, rotate, toggleBlow, pickFilterUnderCursor, toggleFilterMat, setBrush, pieceV, pieceExtra, filterBlockAt, chipCost, pieceCostNow, pieceCode, turnSlope @@ -487,7 +497,11 @@ Selection panels: current tool card and bag materials. Discovery cards, hover info (I) and the cursor label. - functions: showCard, howText, discoveryCard, reactionsOf, hoverInfo, renderInfo, drawCursorLabel, pipeContentsText -### src/ui/input.js (91 lines) +### src/ui/devconsole.js (42 lines) +Bottom panel; command dispatch and state live in sim/devconsole.js. +- functions: closeDevConsole, showDevConsole, toggleDevConsole, devPrint, runDevCommand + +### src/ui/input.js (92 lines) Keyboard and mouse listeners. - functions: isTyping - variables: canvas @@ -525,7 +539,7 @@ Shared world colours, minimap and full map. - functions: resetMap, mapArea, updateMap, drawMap, mapFit, drawFullMap, openMap, zoomMap, zoomMinimap - variables: MAP_S, MAP -### src/ui/loop.js (134 lines) +### src/ui/loop.js (135 lines) The frame loop and boot. - functions: scanVisible, frame, boot - variables: lastT, acc, lastRender, frameMs, simMs, fpsSm, ambientT @@ -541,6 +555,8 @@ The frame loop and boot. - `test/t_build.js`: Building: undo (Ctrl+Z), cut, replace a machine with Ctrl, pasting ghosts, and the fan heat warning. - `test/t_conveyors.js`: Screw conveyors that never carry liquid (it flows through), screw and belt throughput, filters. - `test/t_corner_seal.js`: Corner-touching square walls seal loose grains without changing free piles or worldgen. +- `test/t_dev_console.js`: Dev command validation, discovery, build costs, runtime controls and save metadata. +- `test/t_dev_console.py`: Dev console confirmation, keyboard isolation, HUD, time controls and saved badge. - `test/t_diag_screws.js`: Diagonal screws: sealed four-lane corner transfers, bars, joins and persistence. - `test/t_drill.js`: The resonance drill: pulses from the bot shake rock until it shatters. Widths trade area for speed, tuning shatters only one material and drains it out of the wall, built blocks are left alone, and the drill says why when nothing breaks. - `test/t_econ.js`: Economy: the dock (instant sale), orders, prices, the market dip, money for machines, alloys. diff --git a/src/manifest.json b/src/manifest.json index b0c1139..22f9c5f 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -1,7 +1,8 @@ { "shell": "shell.html", "css": [ - "styles/style.css" + "styles/style.css", + "styles/devconsole.css" ], "sim": [ "sim/base.js", @@ -38,6 +39,7 @@ "sim/tick.js", "sim/worldgen-helpers.js", "sim/worldgen.js", + "sim/devconsole.js", "sim/save.js" ], "client": [ @@ -67,6 +69,7 @@ "ui/selection-pickers.js", "ui/selection-panels.js", "ui/info.js", + "ui/devconsole.js", "ui/input.js", "ui/actions.js", "ui/gamepad.js", diff --git a/src/shell.html b/src/shell.html index d4dda3f..bfeef6c 100644 --- a/src/shell.html +++ b/src/shell.html @@ -130,6 +130,12 @@ + + diff --git a/src/sim/devconsole.js b/src/sim/devconsole.js new file mode 100644 index 0000000..ff7c609 --- /dev/null +++ b/src/sim/devconsole.js @@ -0,0 +1,113 @@ +// Dev commands are pure simulation operations; the browser owns the panel and confirmation. +let devWorld = false, devLog = [], devFree = false, devSpeed = 1, devPaused = false, devSteps = 0; +function resetDevConsole() { + devWorld = false; devLog = []; devFree = false; devSpeed = 1; devPaused = false; devSteps = 0; +} +function enableDevConsole() { devWorld = true; } +const DEV_HELP = [ + 'help — list commands', + 'money | money set — add or set money; negative amounts allowed', + 'debt | debt clear — set or clear claim debt', + 'give [n] — add grains or items (default 1000); display or internal name, unique prefix', + 'bit [tier] — list or set drill tiers (numbered from 0)', + 'bag [tier] — list or set bag tiers (numbered from 0)', + 'unlock — learn all materials and unlock progress rewards', + 'tp [x y] — teleport in grains; no coordinates uses the cursor', + 'free on|off — build without spending money or materials', + 'speed — speed multiplier (0.25, 0.5, 1, 2, 4; clamped to 0.1–8)', + 'pause — toggle dev pause', + 'step [n] — queue 1–10000 ticks while dev paused (default 1)', + 'weather clear|rain — stop current rain or start a one-minute shower', +]; +// Exact internal names win when a display name names a different material. +const DEV_MATERIALS = { EMPTY, GRASS, DIRT, LDIRT, SAND, GRAVEL, CLAY, STONE, GRANITE, BASALT, WOOD, LEAVES, COALV, COAL, CUV, CUORE, FEV, FEORE, ASH, SLAG, BRICK, GLASS, COPPER, IRON, STEEL, WATER, MAGMA, MCU, MFE, MST, MGL, STEAM, SMOKE, FIRE, BELTL, BELTR, SHAFT, GATE, HULL, SPRING, RSTONE, RGRAN, RBAS, LCLAY, LWOOD, RBRICK, CULLET, SCRCU, SCRFE, SCRST, BARCU, BARFE, BARST, ACORN, CHARCOAL, SNOW, ICE, SNV, SNORE, TIN, MSN, SCRSN, BARSN, ZNV, ZNORE, ZINC, MZN, SCRZN, BARZN, AGV, AGORE, SILVER, MAG, SCRAG, BARAG, AUV, AUORE, GOLD, MAU, SCRAU, BARAU, BRONZE, MBZ, SCRBZ, BARBZ, BRASS, MBS, SCRBS, BARBS, DOCKW, MSLAG, PIPE, BARGL, CUTST, BARSB, GRINDER, SAWB, PLANK, PRESSB, GREENB, BARBR, RACKB }; +function devMaterial(name) { + name = name.toLowerCase(); + const ids = Object.entries(DEV_MATERIALS); + const internal = ids.find(([key]) => key.toLowerCase() === name); + if (internal) return internal[1]; + const exact = ids.filter(([, id]) => MD[id].name.toLowerCase() === name); + const matches = [...new Set((exact.length ? exact : ids.filter(([key, id]) => key.toLowerCase().startsWith(name) || MD[id].name.toLowerCase().startsWith(name))).map(([, id]) => id))]; + return matches.length === 1 ? matches[0] : matches.length ? `Ambiguous material: ${matches.map(id => MD[id].name + ' (' + ids.find(([, m]) => m === id)[0] + ')').join(', ')}.` : 'Unknown material. Use a display name or internal name, such as copper ore or CUORE.'; +} +function devCommand(text, cursor) { + if (typeof text !== 'string' || !text.trim()) return ['Type help to list commands.']; + text = text.trim(); + const [cmd, ...args] = text.toLowerCase().split(/\s+/); + const usage = () => ['Usage: ' + DEV_HELP.find(s => s.startsWith(cmd + ' '))]; + const number = s => s !== undefined && Number.isFinite(Number(s)); + if (cmd === 'help' && !args.length) return DEV_HELP.slice(); + if (!DEV_HELP.some(s => s.startsWith(cmd + ' '))) return ['Unknown command. Type help to list commands.']; + if (!devWorld) return ['Open the dev console and accept the world warning first.']; + let result; + switch (cmd) { + case 'money': { + const set = args[0] === 'set', value = Number(args[set ? 1 : 0]); + if (args.length !== (set ? 2 : 1) || !number(args[set ? 1 : 0]) || !number(String(set ? value : money + value))) return usage(); + money = set ? value : money + value; result = 'Money: ' + fmtMoney(money); break; + } + case 'debt': + if (args.length !== 1 || (args[0] !== 'clear' && (!number(args[0]) || Number(args[0]) < 0))) return usage(); + debt = args[0] === 'clear' ? 0 : Number(args[0]); + result = debt ? 'Claim debt: ' + fmtMoney(debt) : 'Claim paid off.'; break; + case 'give': { + if (!args.length) return usage(); + let n = 1000; + if (number(args[args.length - 1])) n = Number(args.pop()); + if (!args.length || !Number.isInteger(n) || n <= 0) return usage(); + const m = devMaterial(args.join(' ')); + if (typeof m === 'string') return [m]; + if (m === EMPTY) return ['Air cannot go in the bag.']; + if (n > 2147483647 - bag[m]) return ['Too many: a material count cannot exceed 2147483647.']; + bagAdd(m, n, true); result = `Added ${n} ${MD[m].name}.`; break; + } + case 'bit': case 'bag': { + const tiers = cmd === 'bit' ? BIT_TIERS : TANK_TIERS; + if (!args.length) return tiers.map((t, i) => `${i}: ${t.name}${t.cap ? ' (' + t.cap + ' grains)' : ''}`); + if (args.length !== 1 || !number(args[0]) || !Number.isInteger(Number(args[0]))) return usage(); + const tier = Math.max(0, Math.min(tiers.length - 1, Number(args[0]))); + player[cmd === 'bit' ? 'bitTier' : 'tankTier'] = tier; + result = `${cmd}: ${tier} — ${tiers[tier].name}`; break; + } + case 'unlock': + if (args.length) return usage(); + seen.fill(1); player.lens = player.flask = true; + for (const m of MILESTONES) ledger[m.id] = ledger[m.id] || tick || 1; + result = 'All materials learned and progress rewards unlocked.'; break; + case 'tp': { + if (args.length !== 0 && (args.length !== 2 || !args.every(number))) return usage(); + const x = args.length ? Number(args[0]) : cursor && cursor.x, y = args.length ? Number(args[1]) : cursor && cursor.y; + if (!Number.isFinite(x) || !Number.isFinite(y)) return ['No cursor position. Use tp .']; + 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)