// 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 === 15); 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')); check('give turns rock into its rubble and says so', run(`(() => { enableDevConsole(); const before = bag[RBAS]; const out = devCommand('give basalt 50').join(' '); return bag[RBAS] === before + 50 && bag[BASALT] === 0 && /Basalt rubble/.test(out); })()`)); check('give refuses things that cannot be in a bag', run(`(() => { enableDevConsole(); const out = devCommand('give steam 5').join(' '); return /cannot go in the bag/.test(out) && bag[STEAM] === 0; })()`)); check('material matching ignores spaces and case', run("devMaterial('CoPpErOrE') === CUORE && devMaterial('copper bar') === BARCU")); const withoutAlias = run(`(() => { const saved = DEV_MATERIALS.CUORE; delete DEV_MATERIALS.CUORE; try { const before = bag[CUORE]; devCommand('give CoPpEr OrE 7'); return { lookup: devMaterial('CoPpEr OrE') === CUORE && bag[CUORE] === before + 7, prefix: devMaterial('copperor').startsWith('Ambiguous material:') && devMaterial('copperor').includes(', Copper ore.'), list: devMaterialList('CoPpErOrE').some(([, id]) => id === CUORE), suggestions: devSuggestions('coperore').includes(MD[CUORE].name), completion: devComplete('give copperore 25').matches.includes('give Copper ore 25') }; } finally { DEV_MATERIALS.CUORE = saved; } })()`); for (const [feature, works] of Object.entries(withoutAlias)) { check('display-name ' + feature + ' works without an internal alias', works); } check('unknown material suggests close giveable names', /Did you mean:.*Copper ore/i.test(command('give coperore')[0])); check('materials lists giveable names with internal names', command('materials').some(s => /Copper ore.*CUORE/.test(s)) && !command('materials').some(s => /STEAM/.test(s))); check('materials filtering ignores spaces and case', command('materials CoPpErOrE').every(s => /Copper ore/i.test(s)) && command('materials copperore').some(s => /CUORE/.test(s))); check('help for a command has usage and example', command('help give').some(s => s.startsWith('Usage: give')) && command('help give').some(s => s.startsWith('Example: give copper ore'))); check('all commands have detailed help', run("Object.keys(DEV_COMMANDS).every(k => devCommand('help ' + k).some(s => s.startsWith('Example:')))")); check('errors carry explicit status while results do not', command('give no-such-material').error === true && !command('materials').error && command('money nope').error === true); check('clear requests output clearing without changing world', command('clear').clear === true); check('command completion cycles and wraps', run("(() => { const a = devComplete('s'), b = devComplete(a.text, a), c = devComplete(b.text, b); return a.matches.length === 2 && a.text === 'speed' && b.text === 'step' && c.text === a.text; })()")); check('completion keeps spaced materials and amount', run("devComplete('give copperore 25').matches.includes('give Copper ore 25')")); check('completion supports internal names', run("devComplete('give cuor').text === 'give Copper ore'")); check('completion cycles ambiguous materials', run("(() => { const a = devComplete('give copper'), b = devComplete(a.text, a); return a.matches.length > 1 && a.text !== b.text; })()")); check('completion supports keywords and help commands', run("devComplete('free o').matches.length === 2 && devComplete('money s').text === 'money set' && devComplete('debt c').text === 'debt clear' && devComplete('weather r').text === 'weather rain' && devComplete('help gi').text === 'help give'")); 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();