diff --git a/docs/agent-log.md b/docs/agent-log.md index bbd2fbd..bf2ff66 100644 --- a/docs/agent-log.md +++ b/docs/agent-log.md @@ -4,6 +4,17 @@ 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: t78 damaged blueprint code validation (Codex) + +- bpValid checks block codes, non-fixed machine types and mold kinds against their + tables before import; damaged codes show the existing message and paste nothing. + Blueprint format and simulation/worldgen behaviour are unchanged. +- Files: src/ui/saves.js, new test/t_blueprint_valid.js, tasks/t78_damaged_blueprint_code.md + and this log. Regression failed first; all 32 focused checks, build.py and all 36 + headless suites passed (python3 test/run_all.py; /tmp/t78-headless.log). +- Browser/GPU tests and screenshots skipped at the owner's request; owner game + playtesting remains open. No commit. + ## 2026-10-29: t82 dev console material table lookup (Codex) diff --git a/src/ui/saves.js b/src/ui/saves.js index 233ab8c..6e90901 100644 --- a/src/ui/saves.js +++ b/src/ui/saves.js @@ -211,7 +211,16 @@ async function importWorldFile(file) { function libLoad() { try { return JSON.parse(localStorage.getItem('deepgrain.blueprints') || '[]'); } catch (e) { return []; } } function libSave(list) { try { localStorage.setItem('deepgrain.blueprints', JSON.stringify(list)); return true; } catch (e) { toast('Blueprints cannot be stored in this view. Copy the blueprint code instead.'); return false; } } function bpEncode(bp) { return 'DGBP2:' + btoa(unescape(encodeURIComponent(JSON.stringify(bp)))); } -function bpValid(bp) { return !!(bp && bp.v === 2 && Array.isArray(bp.blocks) && bp.bw > 0 && bp.bh > 0); } +function bpValid(bp) { + return !!(bp && bp.v === 2 && Array.isArray(bp.blocks) && bp.bw > 0 && bp.bh > 0 + && bp.blocks.every(b => Array.isArray(b) && Number.isInteger(b[2]) && Object.hasOwn(PIECE_BY_CODE, b[2])) + && Array.isArray(bp.machines || []) && (bp.machines || []).every(m => { + if (!Array.isArray(m) || !Object.hasOwn(PIECE_BY_ID, m[0])) return false; + const p = PIECE_BY_ID[m[0]]; + return p.kind === 'machine' && !p.fixed; + }) + && Array.isArray(bp.molds || []) && (bp.molds || []).every(m => Array.isArray(m) && Object.hasOwn(MOLDS, m[0]))); +} function bpDecode(code) { const s = code.trim(); if (s.startsWith('DGBP1:')) throw new Error('That blueprint is from an older version of the game, before the block grid. It cannot be used.'); diff --git a/test/t_blueprint_valid.js b/test/t_blueprint_valid.js new file mode 100644 index 0000000..aa03de9 --- /dev/null +++ b/test/t_blueprint_valid.js @@ -0,0 +1,61 @@ +// Shared blueprint codes must be safe before entering the clipboard or ghost queues. +const fs = require('fs'); +const path = require('path'); +const { load, suite, flatWorld } = require('./harness'); +const { run } = load(), { check, done } = suite(); +for (const file of ['saves.js', 'notebook-blueprints.js']) { + run(fs.readFileSync(path.join(__dirname, '../src/ui', file), 'utf8')); +} +flatWorld(run, 128, 128, 112); +run(` + globalThis.goodBlueprint = { + v: 2, bw: 8, bh: 8, + blocks: [[0, 0, BP_WALL, RSTONE]], + machines: [['fan', 1, 0, 0]], + molds: [['ingot', 0, 2, RSTONE]] + }; + globalThis.GAME = { clip: null, paste: false }; + globalThis.hudDirty = false; + globalThis.elements = { 'bp-import': {}, 'bp-paste': { value: '' } }; + globalThis.$ = id => elements[id] || null; + globalThis.localStorage = { getItem: () => null }; + globalThis.esc = s => s; + globalThis.toast = message => { globalThis.lastToast = message; }; + globalThis.closeAllModals = () => {}; + globalThis.dialog = (title, html, buttons) => { globalThis.importBlueprint = buttons[1].fn; }; + renderBlueprintTab({ innerHTML: '', querySelectorAll: () => [] }); + elements['bp-import'].onclick(); +`); +check('a good blueprint code round-trips unchanged', run('JSON.stringify(bpDecode(bpEncode(goodBlueprint))) === JSON.stringify(goodBlueprint)')); +check('a good decoded blueprint has a build cost', run('bpCost(bpDecode(bpEncode(goodBlueprint))).length > 0')); +check('block-only codes still allow omitted machine and mold lists', run(` + (() => { const bp = bpDecode(bpEncode({ v: 2, bw: 1, bh: 1, blocks: [[0, 0, BP_WALL, RSTONE]] })); + return bp.machines.length === 0 && bp.molds.length === 0; })() +`)); + +for (const [name, damage] of [ + ['unknown piece 250', 'bp.blocks[0][2] = 250'], + ['array property used as a piece code', "bp.blocks[0][2] = 'length'"], + ['unknown machine', "bp.machines[0][0] = 'unknown-machine'"], + ['block used as a machine', "bp.machines[0][0] = 'wall'"], + ['fixed trade dock', "bp.machines[0][0] = 'dock'"], + ['unknown mold', "bp.molds[0][0] = 'unknown-mold'"], + ['inherited mold name', "bp.molds[0][0] = 'toString'"], +]) { + run(`{ + clearAllGhosts(); GAME.clip = null; GAME.paste = false; + const bp = JSON.parse(JSON.stringify(goodBlueprint)); ${damage}; + globalThis.damagedBlueprint = bp; + elements['bp-paste'].value = bpEncode(bp); + importBlueprint(); + if (GAME.paste && GAME.clip) pasteGhost(GAME.clip, 2, 2); + }`); + check(`${name} fails validation`, run('!bpValid(damagedBlueprint)')); + check(`${name} shows the damaged message`, run("lastToast === 'That code is damaged.'")); + check(`${name} leaves the clipboard and paste mode empty`, run('GAME.clip === null && !GAME.paste')); + check(`${name} pastes nothing`, run('ghostBlocks.size === 0 && ghostMachines.length === 0 && ghostMolds.length === 0')); +} +run(`clearAllGhosts(); elements['bp-paste'].value = bpEncode(goodBlueprint); importBlueprint(); + if (GAME.paste && GAME.clip) pasteGhost(GAME.clip, 2, 2);`); +check('a good code still imports and pastes all three piece kinds', run('GAME.paste && ghostBlocks.size === 1 && ghostMachines.length === 1 && ghostMolds.length === 1')); +done();