diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 052ee80..9c99ceb 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -604,3 +604,9 @@ above, including: 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 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. diff --git a/docs/PROJECT_MAP.md b/docs/PROJECT_MAP.md index 02a967f..c4a64f6 100644 --- a/docs/PROJECT_MAP.md +++ b/docs/PROJECT_MAP.md @@ -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 - 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. - 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 @@ -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 - 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. - functions: vacuumTargetAt, toolVacuum, nozzle, toolBlow, flyOpen, depositNear, bagTemp, landFlyer, flyTick, toolIgnite - 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. - 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. - 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 -### src/sim/save.js (185 lines) +### src/sim/save.js (216 lines) Saving and loading a world (serializeWorld / deserializeWorld) and old-save migration. - functions: serializeWorld, deserializeWorld, startLoadWake, wakeLoadArea, wakeLoadNearBot, loadWakeTick - variables: SAVE_VERSION, TOFF diff --git a/docs/agent-log.md b/docs/agent-log.md index 7fb4711..9bbfef8 100644 --- a/docs/agent-log.md +++ b/docs/agent-log.md @@ -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 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) - Validate positive integer dimensions up to the Huge option (6144 x 2048), cell/block/fog diff --git a/src/sim/devconsole.js b/src/sim/devconsole.js index ed9fa13..3dc93a9 100644 --- a/src/sim/devconsole.js +++ b/src/sim/devconsole.js @@ -24,7 +24,7 @@ const DEV_COMMANDS = { }; function devError(text) { return Object.assign([text], { error: true }); } 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 = '') { const keys = new Map(Object.entries(DEV_MATERIALS).map(([key, id]) => [id, key])); const list = [], name = devName(text); @@ -91,6 +91,7 @@ function devMaterial(name) { name = devName(name); const internal = Object.entries(DEV_MATERIALS).find(([key]) => devName(key) === name); 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 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))]; @@ -134,6 +135,7 @@ function devCommand(text, cursor) { if (!args.length || !Number.isInteger(n) || n <= 0) return usage(); const m = devMaterial(args.join(' ')); 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 (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; diff --git a/src/sim/items.js b/src/sim/items.js index 2e27dc3..f2d0ae4 100644 --- a/src/sim/items.js +++ b/src/sim/items.js @@ -279,6 +279,7 @@ function entsInCircle(cx, cy, r) { // ---------- Bag ---------- 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 const room = force ? n : Math.max(0, bagCap() - bagTotal); 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. let droppedLast = 0; function giveOrDrop(m, n, x, y) { - if (n <= 0) return; + if (n <= 0 || PARTMAT[m]) return; if (m === CASH) { money += n; return; } const k = bagAdd(m, n, false); let rest = n - k; diff --git a/src/sim/save.js b/src/sim/save.js index bf71dff..bcd1435 100644 --- a/src/sim/save.js +++ b/src/sim/save.js @@ -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; } 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; }); - for (const m of meta.flyers || []) if (m < NMAT) bag[m]++; // grains that were mid-air when saved + 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 && !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 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]; @@ -148,6 +148,13 @@ function deserializeWorld(meta, buffers) { seen = new Uint8Array(NMAT); meta.seen.forEach((v, k) => { if (k < NMAT) seen[k] = v; }); ledger = meta.ledger || {}; 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(); powerDirty = true; rebuildPower(); slopeCleatsTick(false); updateRoof(); if (!weather.base) weather.base = worldWater(); // older saves: take the water it has now as its starting amount diff --git a/src/sim/vacuum.js b/src/sim/vacuum.js index 891558d..66fe526 100644 --- a/src/sim/vacuum.js +++ b/src/sim/vacuum.js @@ -65,12 +65,13 @@ let blowRR = 0; function toolBlow(cx, cy, r, m, mix) { let pickM = null; 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.' }; 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]; } 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.' }; const ph = PH[m]; if (ph === PH_LIQUID && !player.flask) return { placed: 0, status: 'You need the Flask to carry liquids.' }; diff --git a/test/t_dev_console.js b/test/t_dev_console.js index 77f75a2..a59ce7e 100644 --- a/test/t_dev_console.js +++ b/test/t_dev_console.js @@ -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('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) === ''; })()")); +// 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(); diff --git a/test/t_dev_console.py b/test/t_dev_console.py index 253ec33..39beb63 100644 --- a/test/t_dev_console.py +++ b/test/t_dev_console.py @@ -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')")) 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')) + 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('help give') command('give coperore')