Dev console refuses buildable piece materials; loose piece grains cleared on load (t97)

This commit is contained in:
2026-10-29 18:08:38 -04:00
parent 0483fb8da4
commit 3b427aeecc
9 changed files with 119 additions and 9 deletions
+6
View File
@@ -604,3 +604,9 @@ above, including:
the old feel. Restore the original advancing-front nudge: 30% times ray energy per the old feel. Restore the original advancing-front nudge: 30% times ray energy per
half-grain sample, straight back toward the bot, then down or up if blocked. Scale that half-grain sample, straight back toward the bot, then down or up if blocked. Scale that
chance with sampling distance; do not keep shaking the travelled path. chance with sampling distance; do not keep shaking the travelled path.
- **Build pieces never go in the bag (t97, 2026-10-29).** The owner wants pieces such as saws
built from the menu: giving them as grains let the nozzle leave permanent, uncollectable
cells. Exclude dedicated construction materials from give, discovery and blowing. On load,
clear loose construction grains and their bag/nozzle inventory; preserve owned blocks,
slope seals and machines. Ordinary wall ingredients still become rubble, scrap or bars.
+4 -4
View File
@@ -317,7 +317,7 @@ Molds: cavities for castings; ingot troughs empty themselves below. Removal retu
- functions: moldCellsAt, canPlaceMold, newMoldRecord, addMold, castMix, cavityState, troughState, clearCavity, moldPlugCells, rakeAbove, furnaceJunkHint, unplugMold, ejectIngot, checkMolds, moldAt, tally, collectMold, removeMold - functions: moldCellsAt, canPlaceMold, newMoldRecord, addMold, castMix, cavityState, troughState, clearCavity, moldPlugCells, rakeAbove, furnaceJunkHint, unplugMold, ejectIngot, checkMolds, moldAt, tally, collectMold, removeMold
- variables: DROSS, grains, barWord - variables: DROSS, grains, barWord
### src/sim/items.js (318 lines) ### src/sim/items.js (319 lines)
Bars and other loose objects (ents), and the bag: adding, taking, costs and refunds. Bars and other loose objects (ents), and the bag: adding, taking, costs and refunds.
- functions: entSolidCell, entSupportCount, entFreeAt, entMark, spawnBar, removeEnt, entScrewAt, entScrewFree, entScrewMove, entScrewLane, entScrewEdge, entScrewEnter, entScrewTick, entDiagonalScrewTick, entBeltScrewEnter, entSlopeBeltTick, entTick, entsInCircle, bagAdd, bagTake, giveOrDrop, refundAt, canAfford, payCost, fmtMoney, costText, needText - functions: entSolidCell, entSupportCount, entFreeAt, entMark, spawnBar, removeEnt, entScrewAt, entScrewFree, entScrewMove, entScrewLane, entScrewEdge, entScrewEnter, entScrewTick, entDiagonalScrewTick, entBeltScrewEnter, entSlopeBeltTick, entTick, entsInCircle, bagAdd, bagTake, giveOrDrop, refundAt, canAfford, payCost, fmtMoney, costText, needText
- variables: BAR_W, BAR_H, entCurM, droppedLast - variables: BAR_W, BAR_H, entCurM, droppedLast
@@ -337,7 +337,7 @@ Trees (background layer), acorns, saplings, and waking freezing water.
- functions: cutTree, treeHeld, findTreeBases, treeSupportTick, woodRoom, treeFall, coldTick, plantTick, setTreeCell, saplingTick, igniteTree - functions: cutTree, treeHeld, findTreeBases, treeSupportTick, woodRoom, treeFall, coldTick, plantTick, setTreeCell, saplingTick, igniteTree
- variables: treeBases, saplings, treeDraw - variables: treeBases, saplings, treeDraw
### src/sim/vacuum.js (199 lines) ### src/sim/vacuum.js (200 lines)
The vacuum, blowing grains out, grains in flight (flyers), and the flame tool. Material the vacuum would start on at the cursor: a bar, else the nearest loose grain. The vacuum, blowing grains out, grains in flight (flyers), and the flame tool. Material the vacuum would start on at the cursor: a bar, else the nearest loose grain.
- functions: vacuumTargetAt, toolVacuum, nozzle, toolBlow, flyOpen, depositNear, bagTemp, landFlyer, flyTick, toolIgnite - functions: vacuumTargetAt, toolVacuum, nozzle, toolBlow, flyOpen, depositNear, bagTemp, landFlyer, flyTick, toolIgnite
- variables: blowRR, FLY_G, FLY_Q - variables: blowRR, FLY_G, FLY_Q
@@ -387,12 +387,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. World generation: staged terrain, ore, caves and placement, with synchronous and async entry points.
- functions: generateWorld, generateWorldAsync - functions: generateWorld, generateWorldAsync
### src/sim/devconsole.js (185 lines) ### src/sim/devconsole.js (193 lines)
Dev commands are pure simulation operations; the browser owns the panel and confirmation. Dev commands are pure simulation operations; the browser owns the panel and confirmation.
- functions: resetDevConsole, enableDevConsole, devError, devName, devGiveable, devMaterialList, devDistance, devSuggestions, devHistory, devRemember, devRecall, devComplete, devMaterial, devCommand - functions: resetDevConsole, enableDevConsole, devError, devName, devGiveable, devMaterialList, devDistance, devSuggestions, devHistory, devRemember, devRecall, devComplete, devMaterial, devCommand
- variables: devWorld, devLog, devFree, devSpeed, devPaused, devSteps, DEV_COMMANDS, DEV_MATERIALS - variables: devWorld, devLog, devFree, devSpeed, devPaused, devSteps, DEV_COMMANDS, DEV_MATERIALS
### src/sim/save.js (185 lines) ### src/sim/save.js (216 lines)
Saving and loading a world (serializeWorld / deserializeWorld) and old-save migration. Saving and loading a world (serializeWorld / deserializeWorld) and old-save migration.
- functions: serializeWorld, deserializeWorld, startLoadWake, wakeLoadArea, wakeLoadNearBot, loadWakeTick - functions: serializeWorld, deserializeWorld, startLoadWake, wakeLoadArea, wakeLoadNearBot, loadWakeTick
- variables: SAVE_VERSION, TOFF - variables: SAVE_VERSION, TOFF
+12
View File
@@ -4,6 +4,18 @@ What agents have done, newest first. Add an entry for every piece of work: the d
note or request it was for, what changed, the files touched, the tests run, and anything left note or request it was for, what changed, the files touched, the tests run, and anything left
open or not verified. Keep each entry short; the git history has the details. open or not verified. Keep each entry short; the git history has the details.
## 2026-10-29: t97 keep construction grains out of the bag (Codex)
- PARTMAT now blocks give/discovery, inventory/refunds and single/mixed blowing. Load clears
unowned piece grains and contaminated bag/nozzle entries, preserving construction and seals.
- Files: src/sim/{devconsole,items,vacuum,save}.js, test/t_dev_console.{js,py},
docs/{DESIGN,PROJECT_MAP}.md, tasks/t97_give_no_buildables.md and this log.
- Tests first: 9 new regressions failed; all 37 headless suites passed (14 new checks;
/tmp/t97-headless-final.log). Only browser suite: test/t_dev_console.py, 31 checks passed
(/tmp/t97-browser-final.log); inspected shots/t97-console.png (pixel). Sandboxed real/soft
startup timed out (soft reported WebGPU unavailable); real GPU outside sandbox passed after isolating console output in
the existing colour test. Worldgen/save format unchanged; owner review pending. No commit.
## 2026-10-29: t80 validate imported world arrays (Codex) ## 2026-10-29: t80 validate imported world arrays (Codex)
- Validate positive integer dimensions up to the Huge option (6144 x 2048), cell/block/fog - Validate positive integer dimensions up to the Huge option (6144 x 2048), cell/block/fog
+3 -1
View File
@@ -24,7 +24,7 @@ const DEV_COMMANDS = {
}; };
function devError(text) { return Object.assign([text], { error: true }); } function devError(text) { return Object.assign([text], { error: true }); }
function devName(text) { return text.toLowerCase().replace(/\s+/g, ''); } function devName(text) { return text.toLowerCase().replace(/\s+/g, ''); }
function devGiveable(id) { return id !== EMPTY && PH[id] !== PH_GAS && (PH[id] !== PH_SOLID || !!YLD[id]); } function devGiveable(id) { return id !== EMPTY && !PARTMAT[id] && PH[id] !== PH_GAS && (PH[id] !== PH_SOLID || !!YLD[id]); }
function devMaterialList(text = '') { function devMaterialList(text = '') {
const keys = new Map(Object.entries(DEV_MATERIALS).map(([key, id]) => [id, key])); const keys = new Map(Object.entries(DEV_MATERIALS).map(([key, id]) => [id, key]));
const list = [], name = devName(text); const list = [], name = devName(text);
@@ -91,6 +91,7 @@ function devMaterial(name) {
name = devName(name); name = devName(name);
const internal = Object.entries(DEV_MATERIALS).find(([key]) => devName(key) === name); const internal = Object.entries(DEV_MATERIALS).find(([key]) => devName(key) === name);
if (internal) return internal[1]; if (internal) return internal[1];
for (let id = 1; id < NMAT; id++) if (PARTMAT[id] && devName(MD[id].name) === name) return id;
const ids = devMaterialList(); const ids = devMaterialList();
const exact = ids.filter(([, id]) => devName(MD[id].name) === name); const exact = ids.filter(([, id]) => devName(MD[id].name) === name);
const matches = [...new Set((exact.length ? exact : ids.filter(([key, id]) => devName(key).startsWith(name) || devName(MD[id].name).startsWith(name))).map(([, id]) => id))]; const matches = [...new Set((exact.length ? exact : ids.filter(([key, id]) => devName(key).startsWith(name) || devName(MD[id].name).startsWith(name))).map(([, id]) => id))];
@@ -134,6 +135,7 @@ function devCommand(text, cursor) {
if (!args.length || !Number.isInteger(n) || n <= 0) return usage(); if (!args.length || !Number.isInteger(n) || n <= 0) return usage();
const m = devMaterial(args.join(' ')); const m = devMaterial(args.join(' '));
if (typeof m === 'string') return devError(m); if (typeof m === 'string') return devError(m);
if (PARTMAT[m]) return devError(`${MD[m].name} is built from the build menu, not given.`);
if (!devGiveable(m)) return devError(`${MD[m].name} cannot go in the bag.`); if (!devGiveable(m)) return devError(`${MD[m].name} cannot go in the bag.`);
if (n > 2147483647 - bag[m]) return devError('Too many: a material count cannot exceed 2147483647.'); if (n > 2147483647 - bag[m]) return devError('Too many: a material count cannot exceed 2147483647.');
const into = PH[m] === PH_SOLID && YLD[m] ? YLD[m] : m; const into = PH[m] === PH_SOLID && YLD[m] ? YLD[m] : m;
+2 -1
View File
@@ -279,6 +279,7 @@ function entsInCircle(cx, cy, r) {
// ---------- Bag ---------- // ---------- Bag ----------
function bagAdd(m, n, force) { function bagAdd(m, n, force) {
if (PARTMAT[m]) return 0;
if (PH[m] === PH_SOLID && YLD[m]) m = YLD[m]; // rock and metal go in as what drilling them gives if (PH[m] === PH_SOLID && YLD[m]) m = YLD[m]; // rock and metal go in as what drilling them gives
const room = force ? n : Math.max(0, bagCap() - bagTotal); const room = force ? n : Math.max(0, bagCap() - bagTotal);
const k = Math.min(n, room); const k = Math.min(n, room);
@@ -291,7 +292,7 @@ function bagTake(m, n) { const k = Math.min(n, bag[m]); bag[m] -= k; bagTotal -=
// grains as loose material, bars as bar objects. Counts what dropped. // grains as loose material, bars as bar objects. Counts what dropped.
let droppedLast = 0; let droppedLast = 0;
function giveOrDrop(m, n, x, y) { function giveOrDrop(m, n, x, y) {
if (n <= 0) return; if (n <= 0 || PARTMAT[m]) return;
if (m === CASH) { money += n; return; } if (m === CASH) { money += n; return; }
const k = bagAdd(m, n, false); const k = bagAdd(m, n, false);
let rest = n - k; let rest = n - k;
+9 -2
View File
@@ -139,8 +139,8 @@ function deserializeWorld(meta, buffers) {
weather.shower = mw.shower || null; weather.next = mw.next || weather.next; weather.wind = mw.wind || weather.wind; weather.seen = !!mw.seen; weather.base = mw.base || 0; weather.shower = mw.shower || null; weather.next = mw.next || weather.next; weather.wind = mw.wind || weather.wind; weather.seen = !!mw.seen; weather.base = mw.base || 0;
} }
bot.x = meta.bot.x; bot.y = meta.bot.y; bot.lamp = meta.bot.lamp !== false; bot.vx = bot.vy = 0; bot.x = meta.bot.x; bot.y = meta.bot.y; bot.lamp = meta.bot.lamp !== false; bot.vx = bot.vy = 0;
bag = new Int32Array(NMAT); meta.bag.forEach((v, k) => { if (k < NMAT) bag[k] = v; }); bag = new Int32Array(NMAT); meta.bag.forEach((v, k) => { if (k < NMAT && !PARTMAT[k]) bag[k] = v; });
for (const m of meta.flyers || []) if (m < NMAT) bag[m]++; // grains that were mid-air when saved for (const m of meta.flyers || []) if (m < NMAT && !PARTMAT[m]) bag[m]++; // grains that were mid-air when saved
// solids saved in the bag (older saves) come back as what drilling them gives // solids saved in the bag (older saves) come back as what drilling them gives
for (let k = 0; k < NMAT; k++) if (bag[k] && PH[k] === PH_SOLID && YLD[k]) { bag[YLD[k]] += bag[k]; bag[k] = 0; } for (let k = 0; k < NMAT; k++) if (bag[k] && PH[k] === PH_SOLID && YLD[k]) { bag[YLD[k]] += bag[k]; bag[k] = 0; }
bagTotal = 0; for (let k = 0; k < NMAT; k++) bagTotal += bag[k]; bagTotal = 0; for (let k = 0; k < NMAT; k++) bagTotal += bag[k];
@@ -148,6 +148,13 @@ function deserializeWorld(meta, buffers) {
seen = new Uint8Array(NMAT); meta.seen.forEach((v, k) => { if (k < NMAT) seen[k] = v; }); seen = new Uint8Array(NMAT); meta.seen.forEach((v, k) => { if (k < NMAT) seen[k] = v; });
ledger = meta.ledger || {}; ledger = meta.ledger || {};
Object.assign(stats, meta.stats || {}); Object.assign(stats, meta.stats || {});
// Dedicated piece materials can only survive as owned construction, never nozzle grains.
for (let i = 0; i < N; i++) if (PARTMAT[mat[i]] && !mach[i]) {
const b = (((i / W) | 0) >> 2) * BW + ((i % W) >> 2);
if (!blockOwnsCell(b, i, false) && slopeSealOwner(i) < 0) {
setCell(i, EMPTY, temp[i], 0); markPow(i, false);
}
}
clearStraySeals(); clearStraySeals();
powerDirty = true; rebuildPower(); slopeCleatsTick(false); updateRoof(); powerDirty = true; rebuildPower(); slopeCleatsTick(false); updateRoof();
if (!weather.base) weather.base = worldWater(); // older saves: take the water it has now as its starting amount if (!weather.base) weather.base = worldWater(); // older saves: take the water it has now as its starting amount
+2 -1
View File
@@ -65,12 +65,13 @@ let blowRR = 0;
function toolBlow(cx, cy, r, m, mix) { function toolBlow(cx, cy, r, m, mix) {
let pickM = null; let pickM = null;
if (mix && mix.length) { if (mix && mix.length) {
const avail = mix.filter(k => bag[k] > 0 && PH[k] !== PH_ITEM && (PH[k] !== PH_LIQUID || player.flask)); const avail = mix.filter(k => !PARTMAT[k] && bag[k] > 0 && PH[k] !== PH_ITEM && (PH[k] !== PH_LIQUID || player.flask));
if (!avail.length) return { placed: 0, status: 'None of the selected materials are in the bag.' }; if (!avail.length) return { placed: 0, status: 'None of the selected materials are in the bag.' };
pickM = () => { for (let k = 0; k < avail.length; k++) { const c = avail[(blowRR++) % avail.length]; if (bag[c] > 0) return c; } return 0; }; pickM = () => { for (let k = 0; k < avail.length; k++) { const c = avail[(blowRR++) % avail.length]; if (bag[c] > 0) return c; } return 0; };
m = avail[0]; m = avail[0];
} }
if (!m) return { placed: 0, status: 'Nothing selected: click or Q/E in the bag.' }; if (!m) return { placed: 0, status: 'Nothing selected: click or Q/E in the bag.' };
if (PARTMAT[m]) return { placed: 0, status: MD[m].name + ' is built from the build menu.' };
if (bag[m] <= 0) return { placed: 0, status: 'None of the selected materials are in the bag.' }; if (bag[m] <= 0) return { placed: 0, status: 'None of the selected materials are in the bag.' };
const ph = PH[m]; const ph = PH[m];
if (ph === PH_LIQUID && !player.flask) return { placed: 0, status: 'You need the Flask to carry liquids.' }; if (ph === PH_LIQUID && !player.flask) return { placed: 0, status: 'You need the Flask to carry liquids.' };
+68
View File
@@ -107,4 +107,72 @@ check('completion supports keywords and help commands', run("devComplete('free o
check('completion leaves unmatched input intact', run("devComplete('give zzzzz').text === 'give zzzzz'")); check('completion leaves unmatched input intact', run("devComplete('give zzzzz').text === 'give zzzzz'"));
check('history validates stored data', run("devHistory(null).items.length === 0 && devHistory(['ok', 5, '', ' ']).items.join() === 'ok'")); check('history validates stored data', run("devHistory(null).items.length === 0 && devHistory(['ok', 5, '', ' ']).items.join() === 'ok'"));
check('history caps at 50 and navigates to empty after newest', run("(() => { const h = devHistory([]); for (let i=0; i<55; i++) devRemember(h, 'money ' + i); devRemember(h, ' '); const newest = devRecall(h, -1); for (let i=0;i<60;i++) devRecall(h,-1); const oldest = devRecall(h,-1); for(let i=0;i<60;i++) devRecall(h,1); return h.items.length === 50 && newest === 'money 54' && oldest === 'money 5' && devRecall(h,1) === ''; })()")); check('history caps at 50 and navigates to empty after newest', run("(() => { const h = devHistory([]); for (let i=0; i<55; i++) devRemember(h, 'money ' + i); devRemember(h, ' '); const newest = devRecall(h, -1); for (let i=0;i<60;i++) devRecall(h,-1); const oldest = devRecall(h,-1); for(let i=0;i<60;i++) devRecall(h,1); return h.items.length === 50 && newest === 'money 54' && oldest === 'money 5' && devRecall(h,1) === ''; })()"));
// Construction materials are pieces, never loose inventory or nozzle grains.
flatWorld(run, 240, 160, 110);
run('enableDevConsole();');
check('construction material markers cover every dedicated piece material', run(`
[BELTL, BELTR, SHAFT, GATE, HULL, PIPE, DOCKW, GRINDER, SAWB, PRESSB, RACKB].every(m => PARTMAT[m])
`));
check('give refuses internal and display names of every construction material', run(`(() => {
const before = JSON.stringify([bagTotal, Array.from(bag), devLog]);
for (const [key, m] of Object.entries(DEV_MATERIALS)) if (PARTMAT[m]) {
for (const name of [key, MD[m].name]) {
const out = devCommand('give ' + name + ' 3');
if (!out.error || !out[0].includes('built from the build menu')) return false;
}
}
return before === JSON.stringify([bagTotal, Array.from(bag), devLog]);
})()`));
check('give wall refuses without changing inventory', command('give wall 3').error === true && run('bagTotal === 0'));
check('construction pieces stay out of lists, suggestions and completion', run(`
devMaterialList().every(([, m]) => !PARTMAT[m]) &&
devComplete('give saw').matches.length === 0 &&
!devSuggestions('sawb').includes(MD[SAWB].name)
`));
command('give sand 7'); command('give copper bar 2'); command('give stone 4');
check('grains, bars and wall ingredients still work', run('bag[SAND] === 7 && bag[BARCU] === 2 && bag[RSTONE] === 4'));
check('bag pickup and refund cannot introduce piece grains', run(`(() => {
const before = bagTotal;
const added = bagAdd(SAWB, 2, true);
giveOrDrop(GATE, 3, 40, 40);
return added === 0 && bagTotal === before && !bag[SAWB] && !bag[GATE] && mat[40 * W + 40] === EMPTY;
})()`));
run('bag[SAWB] = 4; bagTotal += 4;');
check('nozzle refuses even a directly contaminated bag', run('toolBlow(50, 40, 1, SAWB).placed === 0 && flyers.length === 0'));
check('mixed nozzle skips piece grains while blowing sand', run(`(() => {
const out = toolBlow(50, 40, 1, SAWB, [SAWB, SAND]);
return out.placed > 0 && flyers.every(f => f.m === SAND) && bag[SAWB] === 4;
})()`));
// Keep legitimate blocks, slopes and their outside seals, parts, machines and natural rock.
flatWorld(run, 240, 160, 110);
run(`
const builtCodes = [BP_WALL, BP_SAW, BP_GRIND, BP_PRESS, BP_RACK, BP_BELT, BP_SHAFT, BP_GATE, BP_PIPE, BP_PUMP, BP_OUTLET, BP_SCREW, BP_GRATE, BP_FILTER, BP_BELT_UP];
for (let k = 0; k < builtCodes.length; k++) placeBlock(builtCodes[k], k === 0 ? RSTONE : 0, 2 + k * 3, 10, true);
placeMachineAt('fan', 0, 4, 17, true);
addMold('ingot', 12, 17, LCLAY, true);
placeDock(180);
const ownedBefore = JSON.stringify([Array.from(mat.slice(40 * W, 80 * W)), Array.from(part.slice(40 * W, 80 * W)), Array.from(blockP)]);
const pieceIds = Object.values(DEV_MATERIALS).filter(m => PARTMAT[m]);
for (let k = 0; k < pieceIds.length; k++) setCell(20 * W + 20 + k, pieceIds[k], 20, 0);
// A nozzle grain in the open side of a real sloped belt is still loose.
const looseCorner = 40 * W + 176;
setCell(looseCorner, SAWB, 20, 0);
bag[SAWB] = 5; bag[HULL] = 2; bag[SAND] = 7; bagTotal = 14;
const straySave = serializeWorld(); straySave.meta.flyers.push(GATE, SAND);
deserializeWorld(straySave.meta, straySave.buffers);
`);
check('loading clears all loose construction grains', run('pieceIds.every((m, k) => mat[20 * W + 20 + k] === EMPTY) && mat[looseCorner] === EMPTY'));
check('loading preserves built cells, slope seals, parts, machines and molds', run(`
ownedBefore === JSON.stringify([Array.from(mat.slice(40 * W, 80 * W)), Array.from(part.slice(40 * W, 80 * W)), Array.from(blockP)]) && machines.some(m => m && m.type === 'fan') && molds.length === 1
`));
check('loading clears construction inventory and saved nozzle grains with correct totals', run('bag[SAWB] === 0 && bag[HULL] === 0 && bag[GATE] === 0 && bag[SAND] === 8 && bagTotal === 8'));
check('taking down a saw and a wall refunds ingredients without piece grains', run(`(() => {
removeBlock(5, 10); removeBlock(2, 10);
return bag[RSTONE] === 16 && Object.values(DEV_MATERIALS).filter(m => PARTMAT[m]).every(m => !bag[m]);
})()`));
check('ordinary natural rock survives cleanup', run('mat[120 * W + 20] === STONE'));
check('free building and unlock still work with construction materials excluded', run(`(() => {
enableDevConsole(); devCommand('unlock'); devCommand('free on');
return seen[SAWB] && placeBlock(BP_SAW, 0, 5, 5, false) === '' && mat[20 * W + 20] === SAWB;
})()`));
done(); done();
+13
View File
@@ -73,6 +73,19 @@ with launch() as (browser, page, url):
check('Ctrl+L clears output and keeps console focus', page.evaluate("$('dev-output').children.length === 0 && document.activeElement === $('dev-input')")) check('Ctrl+L clears output and keeps console focus', page.evaluate("$('dev-output').children.length === 0 && document.activeElement === $('dev-input')"))
page.wait_for_timeout(100) page.wait_for_timeout(100)
check('Tab, arrows and Ctrl+L leave bot and game controls alone', page.evaluate("[bot.x, bot.y, bot.lamp, GAME.tool, $('bagpanel').hidden]") == position and page.evaluate('keys.size === 0 && !GAME.ctrl')) check('Tab, arrows and Ctrl+L leave bot and game controls alone', page.evaluate("[bot.x, bot.y, bot.lamp, GAME.tool, $('bagpanel').hidden]") == position and page.evaluate('keys.size === 0 && !GAME.ctrl'))
command('give saw 3')
check('giving a saw explains building and leaves it out of the bag',
'Saw is built from the build menu, not given.' in page.inner_text('#dev-output')
and page.evaluate('bag[SAWB] === 0'))
page.fill('#dev-input', 'give saw'); page.keyboard.press('Tab')
check('Tab never offers the saw', page.input_value('#dev-input') == 'give saw')
command('materials saw')
check('material discovery omits construction pieces', 'No matching materials.' in page.inner_text('#dev-output'))
command('give sand 7'); command('give copper bar 2'); command('give gate 3')
check('ordinary grains and bars still arrive while gates are refused',
page.evaluate('bag[SAND] === 7 && bag[BARCU] === 2 && bag[GATE] === 0'))
page.screenshot(path=str(SHOTS / 't97-console.png'))
command('clear')
command('materials copperore') command('materials copperore')
command('help give') command('help give')
command('give coperore') command('give coperore')