Deepgrain Foundry: move from a claude.ai artifact to a local repository

The game as of artifact version 24, ready for local development with
coding agents.

- Source split from three large files into 52 small ones under src/sim,
  src/render and src/ui, joined in the order in src/manifest.json by
  build.py. The joined code is unchanged apart from comments; every
  headless suite gives the same output, and screenshots in both looks
  are pixel-identical to before.
- Light tidying with no change in behaviour: one movement scan loop,
  shared hazard-stripe and belt shader helpers, unused code removed, a
  shared check helper for the test suites.
- World files can be exported as an ordinary download outside claude.ai.
- Tests: broken old scripts removed, experiments moved to tools/,
  test/run_all.py added. Tools: shot.py (repeatable screenshots),
  where.py, map.py.
- Docs: AGENTS.md, CLAUDE.md, NOTES.md (the owner's to-do inbox),
  docs/PROJECT_MAP.md, DESIGN.md, TESTING.md, systems notes, the
  owner's requests and an agent log.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K9kLmYADKSEjuwUN3rFxDA
This commit is contained in:
2026-10-23 16:05:31 -04:00
co-authored by Claude Opus 5.5
commit b270c4e818
97 changed files with 11710 additions and 0 deletions
+78
View File
@@ -0,0 +1,78 @@
// Fires a starter furnace design repeatedly the way a player would, without cleaning it out
// between firings, to see whether it clogs. Usage: node test/furnace_play.js [design] [ore] [coal] [firings] [roll] [mode]
// mode: 'mix' (even ore/coal mix blown in), 'layers' (ore first, coal on top), 'vent' (blown in from above through the vent)
const { load, flatWorld, ticks } = require('./harness');
const { run } = load();
const id = process.argv[2] || 'smelter1', ore = +(process.argv[3] || 50), coal = +(process.argv[4] || 50), firings = +(process.argv[5] || 3);
const roll = +(process.argv[6] || 0), mode = process.argv[7] || 'mix';
flatWorld(run, 240, 160, 100);
if (process.env.PATCH) run(process.env.PATCH); // try a rule change without editing the game
for (let k = 0; k < roll; k++) run('rnd()');
run(`bag[RSTONE] = 900; bag[LCLAY] = 200; bagTotal = 1100;`);
const d = run(`JSON.stringify(STARTER_DESIGNS.find(d => d.id === '${id}').bp())`);
const bp = JSON.parse(d);
const ox = 24, oy = 25 - bp.bh;
run(`bot.x = ${ox * 4 + bp.bw * 2 - 2}; bot.y = ${oy * 4 - 14}; pasteGhost(${d}, ${ox}, ${oy});`);
for (let k = 0; k < 40 && run('ghostBlocks.size + ghostMolds.length') > 0; k++) ticks(run, 30);
const ch = run(`(STARTER_DESIGNS.find(d => d.id === '${id}').chamber || 3)`);
const cx0 = (ox + 1) * 4, cx1 = (ox + bp.bw - 1) * 4 - 1, floorY = (oy + 1 + ch) * 4 - 1;
const count = (m, inMold) => run(`(()=>{let n=0;const cav=new Set(molds.flatMap(m=>m.cavs.flat()));for(let i=0;i<N;i++)if(mat[i]===${m}&&(${inMold}?cav.has(i):!cav.has(i)))n++;return n})()`);
let totalBars = 0, bits = 0;
function dump() {
const chm = { [run('EMPTY')]: '.', [run('COAL')]: 'c', [run('CUORE')]: 'o', [run('MCU')]: 'M', [run('COPPER')]: '=', [run('SLAG')]: 's', [run('ASH')]: 'a', [run('FIRE')]: '^', [run('SMOKE')]: '~', [run('STONE')]: '#', [run('SCRCU')]: '%' };
for (let y = oy * 4 - 2; y < (oy + bp.bh) * 4 + 2; y++) { let l = ''; for (let x = ox * 4 - 2; x < (ox + bp.bw) * 4 + 2; x++) { const i = y * run('W') + x; const m = run(`mat[${i}]`); l += run(`part[${i}]`) === 5 && m === 0 ? '+' : (chm[m] || '?'); } console.log(l); }
}
const results = [];
for (let f = 0; f < firings; f++) {
run(`bag[CUORE] += ${ore}; bag[COAL] += ${coal}; bagTotal += ${ore + coal};`);
const blow = seq => {
run(`bot.x = ${(cx0 + cx1) / 2 - 2}; bot.y = ${oy * 4};`);
for (const s of seq) for (let k = 0; k < 400 && run(s === 'mix' ? 'bag[CUORE] + bag[COAL]' : `bag[${s}]`) > 0; k++) {
const tx = cx0 + 1 + (k * 5) % (cx1 - cx0 - 2);
run(s === 'mix' ? `toolBlow(${tx}, ${floorY - 1}, 0, CUORE, [CUORE, COAL]); simTick();` : `toolBlow(${tx}, ${floorY - 1}, 0, ${s}, null); simTick();`);
}
};
// like a player: hold the igniter on a few spots of coal that touch air, or on top of the pile if none shows
const light = () => {
let spots = run(`(()=>{const out=[];for(let y=${oy * 4 + 4};y<=${floorY};y++)for(let x=${cx0};x<=${cx1};x++){const i=y*W+x;if(mat[i]===COAL&&mat[i-W]===EMPTY)out.push([x,y]);}return out})()`);
if (!spots.length) spots = run(`(()=>{const out=[];for(let x=${cx0};x<=${cx1};x++)for(let y=${oy * 4 + 4};y<=${floorY};y++){const i=y*W+x;if(PH[mat[i]]===PH_POWDER){out.push([x,y]);break;}}return out})()`);
const pick = [spots[0], spots[Math.floor(spots.length / 2)], spots[spots.length - 1]].filter(Boolean);
run(`for (let k=0;k<50;k++){ ${pick.map(([x, y]) => `toolIgnite(${x}, ${y});`).join('')} simTick(); }`);
};
if (mode === 'vent') {
run(`bot.x = ${(cx0 + cx1) / 2 - 2}; bot.y = ${oy * 4 - 16};`);
for (let k = 0; k < 600 && run('bag[CUORE] + bag[COAL]') > 0; k++) run(`toolBlow(${(cx0 + cx1) >> 1}, ${floorY - 2}, 0, CUORE, [CUORE, COAL]); simTick();`);
ticks(run, 60); light();
} else if (mode === 'litfirst') {
// light the coal, then blow the ore in on top of the fire
blow(['COAL']); ticks(run, 30); light(); ticks(run, 180); blow(['CUORE']);
} else {
blow(mode === 'layers' ? ['CUORE', 'COAL'] : mode === 'coalfirst' ? ['COAL', 'CUORE'] : ['mix']);
ticks(run, mode === 'mixwait' ? 3600 : 60);
light();
}
run(`bot.x = ${ox * 4 - 30}; bot.y = ${oy * 4 - 20};`);
if (process.env.WATCH) {
// a picture every 10 s: material, and temperature in hundreds of degrees
const chw = { [run('EMPTY')]: '.', [run('COAL')]: 'c', [run('CUORE')]: 'o', [run('MCU')]: 'M', [run('COPPER')]: '=', [run('SLAG')]: 's', [run('ASH')]: 'a', [run('FIRE')]: '^', [run('SMOKE')]: '~', [run('STONE')]: '#', [run('SCRCU')]: '%' };
for (let s = 0; s < 9; s++) {
ticks(run, 600);
const rows = JSON.parse(run(`JSON.stringify((()=>{const o=[];for(let y=${oy * 4 + 2};y<=${floorY + 9};y++){const r=[];for(let x=${cx0 - 1};x<=${cx1 + 1};x++){const i=y*W+x;r.push([mat[i],part[i],Math.round(temp[i]),flags[i]&F_BURN?1:0]);}o.push(r);}return o})())`));
console.log(`-- ${(s + 1) * 10}s`);
for (const r of rows) console.log(r.map(([m, p, t, b]) => (p === 5 && m === 0 ? '+' : b ? '*' : (chw[m] || '?'))).join('') + ' ' + r.map(([m, p, t]) => t < 100 ? ' ' : String(Math.min(9, Math.floor(t / 150)))).join(''));
}
} else ticks(run, 5400);
const st = run(`JSON.stringify(molds.map(m => m.state + (m.flaw ? ':' + m.flaw : '')))`);
if (process.env.DUMP === 'each') { console.log('-- before knock-out, firing', f + 1, st); dump(); }
const msgs = JSON.parse(run(`JSON.stringify(molds.map(m => collectMold(m)))`));
const bars = run('ents.length'); totalBars += bars;
if (run('player.bitTier') > bits) bits = run('player.bitTier');
run('for (const e of ents.slice()) removeEnt(e);');
const left = { ore: count(run('CUORE'), false), coal: count(run('COAL'), false), slag: count(run('SLAG'), false), ash: count(run('ASH'), false), copperOut: count(run('COPPER'), false) + count(run('SCRCU'), false) };
results.push({ f: f + 1, st, msg: msgs[0], bars, ...left });
console.log(`firing ${f + 1}: ${st} -> ${msgs[0]} | bars ${bars} | left in furnace: ore ${left.ore}, coal ${left.coal}, slag ${left.slag}, ash ${left.ash}, copper outside mold ${left.copperOut}`);
if (id === 'bitfurnace') run('player.bitTier = 0;');
}
console.log(`TOTAL bars ${totalBars} bit ${bits}`);
if (process.env.DUMP) dump();
+83
View File
@@ -0,0 +1,83 @@
// Loads the simulation (the src/sim/ files listed in src/manifest.json, joined in order into one
// script, the same way build.py joins them) so scenarios can drive it headlessly in Node.
// Errors point at the real file and line (sim/pipes.js:42), not at the joined script.
const fs = require('fs');
const vm = require('vm');
const path = require('path');
const SRC = path.join(__dirname, '../src');
let starts = []; // [first line in the joined script, file]
function where(line) {
let f = starts[0];
for (const s of starts) if (s[0] <= line) f = s; else break;
return f ? `${f[1]}:${line - f[0] + 1}` : `sim:${line}`;
}
function fixStack(e) {
if (e && typeof e.stack === 'string') e.stack = e.stack.replace(/sim-bundle\.js:(\d+)/g, (_, n) => 'src/' + where(+n));
return e;
}
function load() {
const man = JSON.parse(fs.readFileSync(path.join(SRC, 'manifest.json'), 'utf8'));
const parts = []; let line = 1; starts = [];
for (const f of man.sim) {
const text = fs.readFileSync(path.join(SRC, f), 'utf8').replace(/\n+$/, '');
starts.push([line, f]); parts.push(text); line += text.split('\n').length;
}
try { vm.runInThisContext(parts.join('\n'), { filename: 'sim-bundle.js' }); } catch (e) { throw fixStack(e); }
const run = (src) => { try { return vm.runInThisContext(src); } catch (e) { throw fixStack(e); } };
return { run };
}
// A test suite's checks: check(name, ok, detail) prints PASS / FAIL; done() prints the total and
// sets a failing exit code, so test/run_all.py can tell.
function suite() {
let fails = 0;
const check = (name, cond, extra) => { console.log((cond ? 'PASS ' : 'FAIL ') + name + (extra !== undefined ? ` [${extra}]` : '')); if (!cond) fails++; };
const done = () => { console.log(fails ? `${fails} FAILED` : 'ALL PASS'); if (fails) process.exitCode = 1; };
return { check, done, get fails() { return fails; } };
}
// Build a flat test world: sky above row `ground`, stone below.
function flatWorld(run, w, h, ground, opts = {}) {
run(`
alloc(${w}, ${h});
worldSeed = 7;
for (let x = 0; x < W; x++) { surfY[x] = ${ground}; dirtY[x] = ${ground}+10; graniteY[x] = H; basaltY[x] = H; magmaY[x] = H; }
computeAmbient(); fillNoise();
for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) {
const i = y*W+x; mat[i] = y >= ${ground} ? STONE : EMPTY; flags[i] = y >= ${ground} ? F_NAT : 0; temp[i] = AMB[y];
}
bot.x = (W>>1) - 8; bot.y = ${ground} - 30;
bag = new Int32Array(NMAT); bagTotal = 0;
player.bitTier = 0; player.tankTier = 3; player.lens = true; player.flask = true;
updateRoof(); resetWeather(); weather.next = 1e15; // no showers unless a test asks for one
`);
}
function put(run, x, y, m, t) { run(`setCell(${y}*W+${x}, ${m}, ${t === undefined ? `AMB[${y}]` : t}, 0)`); }
function rect(run, x0, y0, x1, y1, m) { run(`for (let y=${y0};y<=${y1};y++) for (let x=${x0};x<=${x1};x++) setCell(y*W+x, ${m}, AMB[y], 0)`); }
function count(run, m, x0 = 0, y0 = 0, x1 = null, y1 = null) {
return run(`(()=>{let n=0; const X1=${x1 === null ? 'W-1' : x1}, Y1=${y1 === null ? 'H-1' : y1}; for (let y=${y0};y<=Y1;y++) for (let x=${x0};x<=X1;x++) if (mat[y*W+x]===${m}) n++; return n;})()`);
}
function maxTemp(run, x0, y0, x1, y1) {
return run(`(()=>{let t=-1e9; for (let y=${y0};y<=${y1};y++) for (let x=${x0};x<=${x1};x++) t=Math.max(t,temp[y*W+x]); return Math.round(t);})()`);
}
function ticks(run, n) { run(`for (let k=0;k<${n};k++) simTick();`); }
module.exports = { load, suite, flatWorld, put, rect, count, maxTemp, ticks };
// ASCII cross-section: one char per cell
const GLYPH = { 4: ':', 34: '<', 35: '>', 36: '-', 37: '|', 38: 'H', 0: ' ', 7: '#', 6: 'c', 20: 'B', 13: '.', 15: 'o', 17: 'f', 18: ',', 19: 's', 22: 'C', 23: 'I', 24: 'S', 27: '~', 28: '=', 29: '%', 25: 'w', 31: '"', 32: ';', 33: '^', 26: 'M', 21: 'g', 30: '*' };
function dump(run, x0, y0, x1, y1, withTemp) {
const rows = run(`(()=>{const r=[];for(let y=${y0};y<=${y1};y++){const row=[];for(let x=${x0};x<=${x1};x++){const i=y*W+x;row.push([mat[i],Math.round(temp[i]),flags[i]&F_BURN?1:0]);}r.push(row);}return r;})()`);
for (const row of rows) {
let a = '', b = '';
for (const [m, t, burn] of row) {
let g = GLYPH[m] !== undefined ? GLYPH[m] : '?';
if (burn) g = g === '.' ? '*' : g;
a += g;
b += t >= 1500 ? '9' : t >= 1300 ? '8' : t >= 1085 ? '7' : t >= 900 ? '6' : t >= 700 ? '5' : t >= 500 ? '4' : t >= 300 ? '3' : t >= 150 ? '2' : t >= 60 ? '1' : '0';
}
console.log(a + (withTemp ? ' ' + b : ''));
}
}
module.exports.dump = dump;
+481
View File
@@ -0,0 +1,481 @@
"""Play-test in a real browser: mouse, keyboard and a mocked gamepad drive the game; save and continue.
Screenshots go to shots/play/ (or the folder given as the first argument). Pixel look unless the
second argument is 'smooth'."""
import json, sys, pathlib
from playwright.sync_api import sync_playwright
root = pathlib.Path(__file__).resolve().parent.parent
out = pathlib.Path(sys.argv[1] if len(sys.argv) > 1 else root / 'shots' / 'play'); out.mkdir(parents=True, exist_ok=True)
url = (root / 'dist' / 'index.html').as_uri()
fails = []
def check(name, cond, extra=''):
print(('PASS ' if cond else 'FAIL ') + name + (f' [{extra}]' if extra != '' else ''))
if not cond: fails.append(name)
with sync_playwright() as p:
b = p.chromium.launch()
ctx = b.new_context(viewport={'width': 1280, 'height': 800})
pg = ctx.new_page()
logs = []
pg.on('pageerror', lambda e: logs.append(f'PAGEERROR: {e}'))
pg.on('console', lambda m: logs.append(f'{m.type}: {m.text}') if m.type == 'error' else None)
pg.goto(url); pg.wait_for_timeout(300)
pg.click('#btn-new'); pg.fill('#nw-seed', '777'); pg.click('#btn-generate')
pg.wait_for_function('GAME.running === true', timeout=20000)
pg.wait_for_timeout(500)
ev = pg.evaluate
look = sys.argv[2] if len(sys.argv) > 2 else 'pixel'
check('a new game starts fully zoomed in', ev('SET.zoom') == 12, ev('SET.zoom'))
ev("SET.zoom = 4; resizeRender(); R.snapNext = true;") # the rest of this play-through was laid out at zoom 4
ev(f"SET.look = '{look}'"); pg.wait_for_timeout(200)
check(f'the {look} look is drawing', ev('R.smoothOn') == (look == 'smooth'), ev('[SET.look, R.smoothOn, SM.ok]'))
def cell_to_screen(x, y):
return ev(f'(() => {{ const dpr = R.canvas.width / innerWidth; const s = worldToScreenPx({x}+0.5, {y}+0.5); return [s.x/dpr, s.y/dpr]; }})()')
def blk(bx, by): return cell_to_screen(bx * 4 + 2, by * 4 + 2)
def hold(x, y, ms, button='left'):
pg.mouse.move(x, y); pg.mouse.down(button=button); pg.wait_for_timeout(ms); pg.mouse.up(button=button)
def select(key, cond, step='e', n=16):
pg.keyboard.press(key); pg.wait_for_timeout(60)
for _ in range(n):
if ev(cond): return True
pg.keyboard.press(step); pg.wait_for_timeout(30)
return ev(cond)
def settle(ms=300): pg.wait_for_timeout(ms)
def drag(fa, fc, steps=6, shift=False, hold_key=None):
# move to the start, let the camera settle, then drag using fresh screen positions
pg.mouse.move(*fa()); pg.wait_for_timeout(900)
a = fa(); pg.mouse.move(*a); pg.wait_for_timeout(50); c = fc()
if shift: pg.keyboard.down('Shift')
if hold_key: pg.keyboard.down(hold_key); pg.wait_for_timeout(40)
pg.mouse.down(); pg.mouse.move(*c, steps=steps); pg.mouse.up()
if hold_key: pg.keyboard.up(hold_key)
if shift: pg.keyboard.up('Shift')
pg.wait_for_timeout(100)
def refill(js): ev(js + '; bagTotal = 0; for (let k = 0; k < NMAT; k++) bagTotal += bag[k]; hudDirty = true;')
ev('player.tankTier = 3')
# no trees right around the work area: a test fire that spreads into one would block the belt row
ev('(() => { const x0 = Math.floor(bot.x) - 70, x1 = Math.floor(bot.x) + 30; for (let y = 0; y < surfY[Math.floor(bot.x)] + 4; y++) for (let x = x0; x < x1; x++) bgm[y * W + x] = 0; findTreeBases(); })()')
bx, by = ev('[Math.floor(bot.x+2), Math.floor(bot.y+2)]')
gy = ev(f'surfY[{bx}]')
# 1. drill: rock breaks loose, nothing enters the bag
pg.mouse.move(*cell_to_screen(bx + 6, gy + 3)); settle(900)
sx, sy = cell_to_screen(bx + 6, gy + 3)
hold(sx, sy, 3500)
settle(700)
check('drill digs with the mouse', ev('stats.dug') > 20, ev('stats.dug'))
check('drill does not fill the bag', ev('bagTotal') == 0, ev('bagTotal'))
# the resonance drill's controls: Q/E pulse width (medium to start), R tunes to what is under the cursor
check('the drill starts on a medium pulse', ev('GAME.drillW') == 1, ev('GAME.drillW'))
pg.keyboard.press('e'); settle(60)
w_wide = ev('GAME.drillW')
pg.keyboard.press('q'); pg.keyboard.press('q'); pg.keyboard.press('q'); settle(60)
check('E widens the pulse and Q narrows it, stopping at the ends', w_wide == 2 and ev('GAME.drillW') == 0, f"{w_wide} then {ev('GAME.drillW')}")
check('the drill card names the pulse width', 'narrow pulse' in pg.inner_text('#selcard'), pg.inner_text('#selcard')[:80])
pg.keyboard.press('e'); settle(60)
pg.mouse.move(*cell_to_screen(bx + 6, gy + 8)); settle(200)
pg.keyboard.press('r'); settle(60)
tuned = ev('GAME.drillTune')
check('R tunes the drill to the rock under the cursor', tuned != 0, ev('GAME.drillTune && MD[GAME.drillTune].name'))
pg.keyboard.press('r'); settle(60)
check('and R again untunes it', ev('GAME.drillTune') == 0)
check('dig milestone', ev('!!ledger.dig'))
check('discovery cards appear for new materials', pg.locator('#cards .card-pop').count() > 0, pg.locator('#cards .card-pop').count())
check('no fixed tutorial panel', pg.locator('#objective').count() == 0)
pg.screenshot(path=str(out / '01-dug.png'))
check('trees live in the background layer', ev('bgm.some(v => v === WOOD)') and ev('(() => { for (let i = 0; i < N; i++) if (mat[i] === LEAVES) return false; return true; })()'))
# 2. vacuum, left button: only the material you start on
pg.keyboard.press('2'); settle(100)
tgt = ev(f'''(() => {{ for (let r = 0; r < 30; r++) for (let dy = -r; dy <= r; dy++) for (let dx = -r; dx <= r; dx++) {{
const x = {bx + 6} + dx, y = {gy + 3} + dy, m = mat[y*W+x]; if (PH[m] === PH_POWDER && inReach(x, y)) return [x, y, m]; }} return null; }})()''')
check('loose material lies around the hole', tgt is not None)
pg.mouse.move(*cell_to_screen(tgt[0], tgt[1])); settle(900)
vx, vy = cell_to_screen(tgt[0], tgt[1])
hold(vx, vy, 1500)
settle(200)
got = ev(f'bag[{tgt[2]}]')
others = ev(f'bagTotal - bag[{tgt[2]}]')
check('vacuum (left) picks the clicked material', got > 0, f"{ev(f'MD[{tgt[2]}].name')} x{got}")
check('vacuum (left) leaves other materials', others == 0, others)
# R ticks what is under the cursor; ticked materials come along with any left-click suck
tgt2 = ev(f'''(() => {{ for (let r = 0; r < 40; r++) for (let dy = -r; dy <= r; dy++) for (let dx = -r; dx <= r; dx++) {{
const x = {bx + 6} + dx, y = {gy + 3} + dy, m = mat[y*W+x]; if (PH[m] === PH_POWDER && inReach(x, y)) return [x, y, m]; }} return null; }})()''')
if tgt2:
cx, cy = cell_to_screen(tgt2[0], tgt2[1])
pg.mouse.move(cx, cy); settle(80); pg.keyboard.press('r'); settle(80)
check('R ticks the material under the cursor', ev(f'GAME.collect.has({tgt2[2]})'), ev('[...GAME.collect]'))
ev('renderBag()'); settle(50)
ticks = pg.locator('#baglist .tick.on')
if ticks.count(): ticks.first.click(); settle(80)
check('bag tick unticks it', not ev(f'GAME.collect.has({tgt2[2]})'), ev('[...GAME.collect]'))
pg.screenshot(path=str(out / '02-vacuum.png'))
# 2b. the vacuum filter is fixed when you click: ore with stone around it, ore ticked
def mixed_pile(x0, y0):
ev(f"{{ const r = mulberry32(3); for (let y = {y0}; y < {y0} + 6; y++) for (let x = {x0}; x < {x0} + 14; x++) setCell(y*W+x, r() < 0.5 ? CUORE : RSTONE, AMB[y], 0); }}")
def suck_at(x, y, ms=900, shift=False, fly=None):
pg.mouse.move(*cell_to_screen(x, y)); settle(900)
if fly: pg.keyboard.down(fly)
if shift: pg.keyboard.down('Shift')
settle(40)
pg.mouse.move(*cell_to_screen(x, y)); pg.wait_for_timeout(40)
if ev(f'grainAt({x}, {y})') != ev(f'grainAt(Math.floor(GAME.aim.x), Math.floor(GAME.aim.y))'): pg.mouse.move(*cell_to_screen(x, y)); pg.wait_for_timeout(20)
pg.mouse.down(); pg.wait_for_timeout(ms); pg.mouse.up()
if shift: pg.keyboard.up('Shift')
if fly: pg.keyboard.up(fly)
settle(100)
ev('bot.vx = bot.vy = 0;')
px0, py0 = bx - 7, gy - 12
def find_ore():
return ev(f'(() => {{ for (let y = {py0} - 8; y < {gy} + 4; y++) for (let x = {px0} - 6; x < {px0} + 20; x++) if (mat[y*W+x] === CUORE && mat[(y-1)*W+x] === EMPTY && inReach(x, y)) return [x, y]; return null; }})()')
mixed_pile(px0, py0); settle(400)
ore = find_ore()
refill('bag.fill(0)')
ev('GAME.collect = new Set([CUORE]); lastBagKey = "";')
suck_at(*ore)
check('with ore ticked, clicking ore never pulls in stone', ev('bag[CUORE]') > 0 and ev('bag[RSTONE]') == 0, f"ore {ev('bag[CUORE]')}, stone {ev('bag[RSTONE]')}")
mixed_pile(px0, py0); settle(400); refill('bag.fill(0)')
st = ev(f'(() => {{ for (let y = {py0} - 8; y < {gy} + 4; y++) for (let x = {px0} - 6; x < {px0} + 20; x++) if (mat[y*W+x] === RSTONE && mat[(y-1)*W+x] === EMPTY && inReach(x, y)) return [x, y]; return null; }})()')
suck_at(*st)
check('with ore ticked, starting on stone rubble still takes only the ore', ev('bag[CUORE]') > 0 and ev('bag[RSTONE]') == 0, f"ore {ev('bag[CUORE]')}, stone {ev('bag[RSTONE]')}")
ev('GAME.collect = new Set(); lastBagKey = "";')
mixed_pile(px0, py0); settle(400); refill('bag.fill(0)')
ore = find_ore()
suck_at(*ore, ms=500, shift=True, fly='d')
check('Shift held while flying boosts instead of taking everything', ev('bag[RSTONE]') == 0 or ev('bag[CUORE]') == 0, f"ore {ev('bag[CUORE]')}, stone {ev('bag[RSTONE]')}")
ev(f'bot.x = {bx} - 2; bot.y = {by} - 2; bot.vx = bot.vy = 0;'); settle(300)
mixed_pile(px0, py0); settle(400); refill('bag.fill(0)')
ore = find_ore()
suck_at(*ore, ms=500, shift=True)
check('Shift held standing still takes everything', ev('bag[RSTONE]') > 0 and ev('bag[CUORE]') > 0, f"ore {ev('bag[CUORE]')}, stone {ev('bag[RSTONE]')}")
# zoom jumps straight to the bot
pg.mouse.move(640, 400); settle(600)
pg.mouse.wheel(0, -100); pg.wait_for_timeout(60)
c1 = ev('[R.camX, R.camY]'); settle(900); c2 = ev('[R.camX, R.camY]')
check('zooming lands on the bot without a pan afterwards', abs(c1[0] - c2[0]) < 1.5 and abs(c1[1] - c2[1]) < 1.5, f'{c1} -> {c2}')
pg.mouse.wheel(0, 100); settle(300)
refill('bag.fill(0)')
# 3. right button blows the chosen material out of the bot; the choice is sticky
refill('bag[COAL] = 120; bag[RSTONE] = 600; bag[CUORE] = 60; bag[LCLAY] = 200; bag[LWOOD] = 120')
ok = select('2', 'GAME.dropMat === COAL && !GAME.blowMix')
check('Q/E chooses coal to blow', ok, ev('GAME.dropMat'))
dx_, dy_ = cell_to_screen(bx - 14, gy - 6)
c0 = ev('bag[COAL]')
pg.mouse.move(dx_, dy_); pg.mouse.down(button='right'); settle(150)
check('blown grains fly out of the bot', ev('flyers.length') > 0, ev('flyers.length'))
pg.screenshot(path=str(out / '03a-blowing.png'))
settle(350); pg.mouse.up(button='right'); settle(800)
check('right click blows coal out of the bag onto the ground', ev('bag[COAL]') < c0 and ev('flyers.length') == 0, f"{c0}->{ev('bag[COAL]')}")
refill('bag[COAL] = 3')
hold(dx_, dy_, 400, 'right'); settle(400)
check('chosen material stays chosen after it runs out', ev('bag[COAL] === 0 && GAME.dropMat === COAL'), ev('[bag[COAL], GAME.dropMat]'))
# tick coal and ore: Shift no longer changes what is blown, the selection does
refill('bag[COAL] = 60; bag[CUORE] = 60')
ev('GAME.collect = new Set([COAL, CUORE]); GAME.dropMat = COAL; GAME.blowMix = false; lastBagKey = ""; hudDirty = true;')
mx_, my_ = cell_to_screen(bx - 26, gy - 6)
pg.mouse.move(mx_, my_); pg.keyboard.down('Shift'); pg.mouse.down(button='right'); settle(400); pg.mouse.up(button='right'); pg.keyboard.up('Shift'); settle(600)
check('Shift + right click still blows only the chosen coal', ev('bag[CUORE]') == 60 and ev('bag[COAL]') < 60, ev('[bag[COAL], bag[CUORE]]'))
refill('bag[COAL] = 60; bag[CUORE] = 60'); settle(200)
pg.click('#baglist .mixrow .pick'); settle(200)
check('the bag offers the ticked mix as a choice', ev('GAME.blowMix') and 'Ticked mix' in pg.inner_text('#selcard'), pg.inner_text('#selcard')[:120])
pg.mouse.move(mx_, my_); pg.mouse.down(button='right'); settle(500); pg.mouse.up(button='right'); settle(600)
dc, do_ = 60 - ev('bag[COAL]'), 60 - ev('bag[CUORE]')
check('choosing Ticked mix, right click blows an even ore/coal mix', dc > 5 and abs(dc - do_) <= 2, f'coal {dc}, ore {do_}')
ev('GAME.blowMix = false;')
ev('GAME.collect = new Set(); lastBagKey = "";')
refill('bag[COAL] = 120')
# 4. igniter lights it
pg.keyboard.press('4')
spot = ev(f'(() => {{ for (let r = 0; r < 20; r++) for (const s of [1, -1]) {{ const x = {bx-14} + r * s; for (let y = 1; y < H; y++) {{ const m = mat[y*W+x]; if (m === EMPTY) continue; if (m === COAL && mat[(y-1)*W+x] === EMPTY && inReach(x, y)) return [x, y]; break; }} }} return [{bx-14}, 0]; }})()')
pg.mouse.move(*cell_to_screen(*spot)); settle(900)
ix, iy = cell_to_screen(*spot)
hold(ix, iy, 2000)
settle(1500)
check('igniter lights coal', ev('stats.ignitions') > 0, ev('stats.ignitions'))
check('thermal lens unlocked', ev('player.lens'))
pg.keyboard.press('h'); settle(200)
check('lens toggles', ev('R.lens'))
check('the lens is named on screen and framed', 'Thermal lens' in ev("$('ovbadges').textContent") and 'lens' in ev("$('ovframe').className"), pg.inner_text('#ovbadges'))
pg.screenshot(path=str(out / '03-fire-lens.png'))
pg.keyboard.press('h'); settle(150)
check('and the label and frame go when it is off', pg.inner_text('#ovbadges').strip() == '' and ev("$('ovframe').className") == '', ev("$('ovframe').className"))
# 5. build: a line of stone wall blocks by dragging, a rectangle with Shift
ok = select('3', "piece().id === 'wall'")
ev('GAME.wallMat = RSTONE; hudDirty = true')
check('build tool starts on wall blocks', ok)
bbx, bby = ev('[Math.floor((bot.x + 2) / 4), Math.floor((bot.y + 2) / 4)]')
rb = ev('bag[RSTONE]'); nb0 = ev('blockP.reduce((a, v) => a + (v === BP_WALL), 0)')
drag(lambda: blk(bbx + 3, bby - 4), lambda: blk(bbx + 8, bby - 4))
nb1 = ev('blockP.reduce((a, v) => a + (v === BP_WALL), 0)')
check('drag places a straight line of 6 wall blocks', nb1 - nb0 == 6, f'{nb1 - nb0} blocks, {ev("GAME.status")}')
check('each wall block costs 16 rubble', rb - ev('bag[RSTONE]') == 96, rb - ev('bag[RSTONE]'))
drag(lambda: blk(bbx - 10, bby - 8), lambda: blk(bbx - 8, bby - 6), 4, True)
nb2 = ev('blockP.reduce((a, v) => a + (v === BP_WALL), 0)')
check('Shift-drag fills a 3×3 rectangle', nb2 - nb1 == 9, nb2 - nb1)
r0 = ev('bag[RSTONE]')
pg.mouse.move(*blk(bbx + 3, bby - 4)); pg.mouse.down(button='right'); settle(120); pg.mouse.up(button='right'); settle(80)
check('right click takes a block back with a refund', ev('bag[RSTONE]') - r0 == 16 and ev('blockP.reduce((a, v) => a + (v === BP_WALL), 0)') == nb2 - 1, ev('bag[RSTONE]') - r0)
pg.keyboard.press('r'); settle(60)
check('R cycles the wall material', ev('GAME.wallMat') != ev('RSTONE'), ev('MD[GAME.wallMat].name'))
ev('GAME.wallMat = RSTONE')
pg.mouse.move(*blk(bbx + 5, bby - 12)); settle(150)
pg.screenshot(path=str(out / '04-blocks.png'))
# the bot flies through built blocks but not natural rock
wx_, wy_ = ev(f'[{(bbx + 5) * 4 + 1}, {(bby - 4) * 4 + 1}]')
check('bot can sit inside a built wall', not ev(f'botOverlapSolid({wx_ - 2}, {wy_ - 2})'))
check('bot cannot sit inside natural rock', ev(f'botOverlapSolid({bx}, {gy + 12})'))
# 6. ingot mold: click to place, pour, knock out bars, vacuum them
ok = select('3', "piece().id === 'ingotmold'")
check('E cycles to the ingot mold', ok)
spot = ev(f'''(() => {{ for (let dx = -2; dx <= 10; dx++) for (let dy = 6; dy < 14; dy++) {{ const ox = {bbx} + dx, oy = {bby} - dy; if (!canPlaceMold('ingot', ox - 1, oy) && inReach(ox * 4 + 8, oy * 4 + 2)) return [ox, oy]; }} return null; }})()''')
check('found a spot for the mold', spot is not None)
mx, my = blk(spot[0], spot[1])
pg.mouse.move(mx, my); settle(100); pg.mouse.down(); pg.mouse.up(); settle(100)
check('mold placed by click', ev('molds.length') == 1, ev('GAME.status'))
ev('for (const cav of molds[0].cavs) for (const i of cav) setCell(i, MCU, 1160, 0)')
pg.wait_for_function("molds[0].state === 'ready'", timeout=30000)
check('bars set in the mold', ev("molds[0].state === 'ready' && molds[0].ready.length === 3"), ev('[molds[0].state, molds[0].ready.length]'))
pg.screenshot(path=str(out / '05-mold-ready.png'))
mo = ev('[molds[0].x + 8, molds[0].y + 2]')
pg.mouse.move(*cell_to_screen(*mo)); settle(60); pg.keyboard.press('f'); settle(300)
check('F knocks out three bars', ev('ents.length') == 3, ev('GAME.status'))
settle(1500)
pg.screenshot(path=str(out / '06-bars.png'))
pg.keyboard.press('2'); settle(60)
e0 = ev('[ents[0].ix + 2, ents[0].iy + 1]')
pg.mouse.move(*cell_to_screen(*e0)); settle(900)
hold(*cell_to_screen(*e0), 900)
check('vacuum picks up bars', ev('bag[BARCU]') >= 1, ev('bag[BARCU]'))
# 7. belt line by dragging, copied with a blueprint and pasted
refill('bag[BARCU] = 60'); ev('money = 1000')
ok = select('3', "piece().id === 'belt'")
check('select belt', ok)
# keep the belt in open air, on a row clear of the dock and other machines
shift_ = ev(f'''(() => {{ for (const dx of [0, -2, -4, -6, -8, 2]) {{ let ok = true; for (let bx = {bbx} + dx - 6; bx <= {bbx} + dx + 2 && ok; bx++) {{ const c = {bby} * 4 * W + bx * 4; if (blockP[{bby} * BW + bx] || mach[c] || mach[c + 3] || mach[c + 3 * W] || !inReach(bx * 4 + 2, {bby} * 4 + 2)) ok = false; for (let y = {bby}*4 - 4; y < {bby}*4 + 8 && ok; y++) for (let x = bx*4; x < bx*4 + 4; x++) if (mach[y*W+x]) ok = false; }} if (ok) return dx; }} return 0; }})()''')
bbx += shift_
ev(f"for (let y = {bby*4 - 4}; y < {bby*4 + 8}; y++) for (let x = {(bbx-8)*4}; x < {(bbx+4)*4}; x++) if (!blockP[((y/4)|0)*BW + ((x/4)|0)]) setCell(y*W+x, EMPTY, 20, 0); for (const e of ents.slice()) if (e.ix > {(bbx-9)*4} && e.ix < {(bbx+4)*4} && e.iy > {bby*4 - 8} && e.iy < {bby*4 + 10}) removeEnt(e);")
drag(lambda: blk(bbx - 6, bby), lambda: blk(bbx + 2, bby))
nbelt = ev('blockP.reduce((a, v) => a + (v === BP_BELT), 0)')
if nbelt != 9:
print('DEBUG belt row', ev(f'''JSON.stringify((() => {{ const o = []; for (let bx = {bbx} - 6; bx <= {bbx} + 2; bx++) {{ const b = {bby} * BW + bx; o.push([bx, blockP[b], blockInReach(bx, {bby}), (() => {{ const r = []; for (let y = {bby}*4; y < {bby}*4+4; y++) for (let x = bx*4; x < bx*4+4; x++) {{ const m = mat[y*W+x]; if (m) r.push(MD[m].name); }} return [...new Set(r)].join('/'); }})()]); }} return o; }})())'''), ev('[bot.x, bot.y]'))
check('belt blocks drawn by drag', nbelt == 9, f'{nbelt} {ev("GAME.status")}')
check('belts dragged to the right run right', ev(f'(() => {{ for (let bx = {bbx} - 6; bx <= {bbx} + 2; bx++) if (blockV[{bby} * BW + bx] !== 1) return false; return true; }})()'))
# copy: hold C and drag a box over them
pg.mouse.move(*blk(bbx - 6, bby)); settle(300); pg.keyboard.down('c'); settle(200)
check('holding C shows copy on the card', 'Copy' in ev("$('selcard').textContent") and ev("selModeHeld()") == 'copy', ev("$('selcard').textContent")[:80])
pg.keyboard.up('c'); settle(100)
drag(lambda: cell_to_screen((bbx - 6) * 4 + 1, bby * 4 + 1), lambda: cell_to_screen((bbx + 2) * 4 + 2, bby * 4 + 2), 5, hold_key='c')
check('blueprint copied', ev('!!GAME.clip && GAME.paste && GAME.clip.blocks.length === 9'), ev('GAME.status'))
pg.mouse.move(*blk(bbx - 2, bby - 9)); settle(150)
pg.screenshot(path=str(out / '07-paste-preview.png'))
pg.mouse.down(); pg.mouse.up()
settle(1500)
nbelt2 = ev('blockP.reduce((a, v) => a + (v === BP_BELT), 0)')
check('ghosts get built from the bag', nbelt2 == 18, f'belts={nbelt2} ghosts={ev("ghostBlocks.size")}')
m_before = ev('money')
pg.keyboard.press('Control+z'); settle(300)
check('Ctrl+Z undoes the paste: what was built from it comes back down, refunded', ev('blockP.reduce((a, v) => a + (v === BP_BELT), 0)') == 9 and ev('ghostBlocks.size') == 0 and ev('money') > m_before, ev('[blockP.reduce((a, v) => a + (v === BP_BELT), 0), ghostBlocks.size, GAME.status]'))
pg.keyboard.press('Escape'); settle(150)
check('Esc stops pasting (and does not open the menu)', not ev('GAME.paste') and ev("$('m-pause').hidden"))
# a line dragged right to left runs left
ev(f"for (let y = {(bby+1)*4}; y < {(bby+2)*4}; y++) for (let x = {(bbx-8)*4}; x < {(bbx+4)*4}; x++) if (!blockP[((y/4)|0)*BW + ((x/4)|0)]) setCell(y*W+x, EMPTY, 20, 0);")
ok = select('3', "piece().id === 'belt'")
drag(lambda: blk(bbx - 2, bby + 1), lambda: blk(bbx - 6, bby + 1))
check('belts dragged to the left run left, and the card follows', ev(f'(() => {{ for (let bx = {bbx} - 6; bx <= {bbx} - 2; bx++) if (blockP[({bby} + 1) * BW + bx] !== BP_BELT || blockV[({bby} + 1) * BW + bx] !== 0) return false; return true; }})()') and ev('GAME.pieceDir.belt') == 0, ev(f'[blockP[({bby} + 1) * BW + {bbx} - 4], blockV[({bby} + 1) * BW + {bbx} - 4], GAME.status]'))
# cut: hold X and drag; it all comes down into the bag, ready to paste; Ctrl+Z puts it back
m_before = ev('money')
drag(lambda: cell_to_screen((bbx - 6) * 4 + 1, (bby + 1) * 4 + 1), lambda: cell_to_screen((bbx - 2) * 4 + 2, (bby + 1) * 4 + 2), 5, hold_key='x')
check('X-drag cuts the row: taken down, refunded, ready to paste', ev(f'blockP[({bby} + 1) * BW + {bbx} - 4]') == 0 and ev('money') > m_before and ev('GAME.paste && GAME.clip.blocks.length === 5'), ev('[money, GAME.status]'))
pg.keyboard.press('Escape'); settle(100)
pg.keyboard.press('Control+z'); settle(300)
check('Ctrl+Z puts the cut row back', ev(f'blockP[({bby} + 1) * BW + {bbx} - 4]') == ev('BP_BELT') and ev('money') == m_before, ev('[money, GAME.status]'))
# Ctrl with a machine: it replaces the belt it is placed on
ok = select('3', "piece().id === 'fan'")
pg.mouse.move(*blk(bbx - 4, bby + 1)); settle(400)
pg.mouse.down(); pg.mouse.up(); settle(100)
check('a fan on a belt says to hold Ctrl', 'Ctrl' in ev('GAME.status'), ev('GAME.status'))
pg.keyboard.down('Control'); settle(100); pg.mouse.move(*blk(bbx - 4, bby + 1)); settle(200)
check('holding Ctrl shows replace mode', 'Replace' in ev("$('selcard').textContent"))
pg.screenshot(path=str(out / '07g-replace-fan.png'))
pg.mouse.down(); pg.mouse.up(); settle(100); pg.keyboard.up('Control'); settle(100)
check('Ctrl-click puts the fan in place of the belt', ev(f"machines.some(m => m && m.type === 'fan' && m.x === ({bbx} - 4) * 4 && m.y === ({bby} + 1) * 4)") and ev(f'blockP[({bby} + 1) * BW + {bbx} - 4]') == 0, ev('GAME.status'))
# pipes are laid by dragging, like belts
ok = select('3', "piece().id === 'pipe'")
check('pipes, pumps, outlets and the refiner are in the build menu', ok and all(x in ev("PIECES.filter(p => !p.hidden).map(p => p.id)") for x in ['pipe', 'pump', 'outlet', 'refiner']))
ev(f"for (let y = {(bby+2)*4}; y < {(bby+3)*4}; y++) for (let x = {(bbx-8)*4}; x < {(bbx+4)*4}; x++) if (!blockP[((y/4)|0)*BW + ((x/4)|0)]) setCell(y*W+x, EMPTY, 20, 0);")
drag(lambda: blk(bbx - 6, bby + 2), lambda: blk(bbx - 3, bby + 2))
check('dragging lays a line of pipe that joins into one line', ev(f'(() => {{ let n = 0; for (let bx = {bbx} - 6; bx <= {bbx} - 3; bx++) if (blockP[({bby} + 2) * BW + bx] === BP_PIPE) n++; return n; }})()') == 4 and ev(f'pipeNetOf(({bby} + 2) * BW + {bbx} - 6).blocks.length') == 4, ev('GAME.status'))
ok = select('3', "piece().id === 'fan'")
pg.keyboard.press('Control+z'); settle(200)
pg.keyboard.press('Control+z'); settle(200)
check('and Ctrl+Z swaps the belt back', ev(f'blockP[({bby} + 1) * BW + {bbx} - 4]') == ev('BP_BELT') and not ev(f"machines.some(m => m && m.type === 'fan' && m.x === ({bbx} - 4) * 4)"), ev('GAME.status'))
# 7b. filter: choose several materials with F, place a line, replace belts with Ctrl, edit the whole line
ev('seen[CUORE] = 1; seen[FEORE] = 1; seen[SAND] = 1; money = 1000; GAME.filterCfg = { m: [], inv: 0 };')
ok = select('3', "piece().id === 'filter'")
check('select filter', ok)
pg.keyboard.press('f'); settle(200)
check('F opens the filter picker', ev("!$('m-dialog').hidden") and pg.locator('#dlg-body .fp').count() >= 3, pg.locator('#dlg-body .fp').count())
pg.locator('#dlg-body .fp', has_text='Copper ore').click(); pg.locator('#dlg-body .fp', has_text='Iron ore').click(); pg.locator('#dlg-body .fd', has_text='Right').click(); settle(100)
pg.screenshot(path=str(out / '07b-filter-picker.png'))
pg.locator('#dlg-foot button', has_text='Set filter').click(); settle(150)
check('picking sets several materials and the direction', ev('JSON.stringify(GAME.filterCfg.m) === JSON.stringify([CUORE, FEORE].sort((a, b) => a - b)) && GAME.filterCfg.inv === 0 && GAME.pieceDir.filter === 1') and ev("$('m-dialog').hidden"), ev('JSON.stringify([GAME.filterCfg, GAME.pieceDir.filter])'))
fy = bby - 5
ev(f"for (let y = {fy*4 - 12}; y < {fy*4 + 12}; y++) for (let x = {(bbx-8)*4}; x < {(bbx+2)*4}; x++) if (!blockP[((y/4)|0)*BW + ((x/4)|0)]) setCell(y*W+x, EMPTY, 20, 0);")
drag(lambda: blk(bbx - 6, fy), lambda: blk(bbx - 3, fy))
nf = ev('blockP.reduce((a, v) => a + (v === BP_FILTER), 0)')
check('filters placed by drag, dropping both ores and carrying right', nf == 4 and ev(f'blockV[{fy}*BW+{bbx-5}]') == 128 and ev(f'filterInfo({fy}*BW+{bbx-5}).m.length') == 2, f"{nf} {ev(f'JSON.stringify(filterInfo({fy}*BW+{bbx-5}))')}")
# two belts at the end of the line: plain dragging leaves them, Ctrl-dragging swaps them for filters
ev(f"placeBlock(BP_BELT, 1, {bbx-2}, {fy}, true); placeBlock(BP_BELT, 1, {bbx-1}, {fy}, true);")
drag(lambda: blk(bbx - 2, fy), lambda: blk(bbx - 1, fy))
check('dragging over built belts leaves them and says how to replace', ev(f'blockP[{fy}*BW+{bbx-2}] === BP_BELT') and 'Ctrl' in ev('GAME.status'), ev('GAME.status'))
pg.keyboard.down('Control'); settle(50)
pg.mouse.move(*blk(bbx - 2, fy)); settle(300)
pg.screenshot(path=str(out / '07c-replace-preview.png'))
drag(lambda: blk(bbx - 2, fy), lambda: blk(bbx - 1, fy))
pg.keyboard.up('Control'); settle(50)
check('Ctrl-dragging replaces them with filters', ev(f'blockP[{fy}*BW+{bbx-2}] === BP_FILTER && blockP[{fy}*BW+{bbx-1}] === BP_FILTER') and ev(f'filterRun({fy}*BW+{bbx-5}).length') == 6, ev('GAME.status'))
pg.mouse.move(*blk(bbx - 5, fy)); settle(200)
check('hovering a filter says what it drops and carries', 'copper ore' in ev('JSON.stringify(hoverInfo(Math.floor(GAME.aim.x), Math.floor(GAME.aim.y)))').lower())
ev(f"for (let y = {fy*4 - 10}; y < {fy*4 - 1}; y++) for (let x = {(bbx-6)*4}; x < {(bbx-5)*4 + 2}; x++) setCell(y*W+x, [SAND, CUORE, FEORE][(x+y)%3], 20, 0);")
settle(500)
pg.screenshot(path=str(out / '07d-filter-working.png'))
pg.keyboard.press('1'); settle(60)
pg.mouse.move(*blk(bbx - 4, fy)); settle(150)
pg.keyboard.press('f'); settle(200)
check('F on a placed filter opens the picker for its whole line', ev("!$('m-dialog').hidden") and '6 in this row' in pg.locator('#dlg-title').inner_text() and ev('GAME.editRun && GAME.editRun.length') == 6, pg.locator('#dlg-title').inner_text())
pg.locator('#dlg-body .fm', has_text='Carry these').click(); pg.locator('#dlg-body .fp', has_text='Copper ore').click(); pg.locator('#dlg-body .fp', has_text='Iron ore').click(); pg.locator('#dlg-body .fp', has_text='Sand').click(); settle(100)
pg.screenshot(path=str(out / '07e-filter-edit-line.png'))
pg.locator('#dlg-foot button', has_text='Set filter').click(); settle(150)
check('and changes the whole line to carry sand and drop the rest', ev(f'filterRun({fy}*BW+{bbx-6}).length') == 6 and ev(f'filterInfo({fy}*BW+{bbx-1}).inv') == 1 and ev(f'JSON.stringify(filterInfo({fy}*BW+{bbx-6}).m) === JSON.stringify([SAND])') and not ev('GAME.editRun'))
pg.mouse.move(*blk(bbx - 4, fy - 12)); settle(150)
pg.keyboard.press('i'); settle(300)
check('I turns on the info overlay', ev('GAME.infoOverlay === true'))
check('the info overlay is named on screen and framed', 'Info overlay' in ev("$('ovbadges').textContent") and 'info' in ev("$('ovframe').className"), pg.inner_text('#ovbadges'))
pg.screenshot(path=str(out / '07f-filter-tags.png'))
pg.keyboard.press('i'); settle(100)
check('and off again', ev('!GAME.infoOverlay'))
# 8. zoom range
for _ in range(12): pg.mouse.wheel(0, -100); pg.wait_for_timeout(20)
check('zoom reaches 12', ev('SET.zoom') == 12, ev('SET.zoom'))
pg.screenshot(path=str(out / '08-zoom12.png'))
for _ in range(8): pg.mouse.wheel(0, 100); pg.wait_for_timeout(20)
# 9. notebook + save + quit + continue
pg.keyboard.press('n'); settle(200)
check('notebook opens', ev("!$('m-notebook').hidden"))
ev("nbTab = 'Pieces'; renderNotebook()"); settle(100)
pg.screenshot(path=str(out / '09-notebook-pieces.png'))
ev("nbTab = 'Ledger'; renderNotebook()"); settle(100)
pg.screenshot(path=str(out / '09b-notebook-ledger.png'))
ev("nbTab = 'Blueprints'; renderNotebook()"); settle(100)
pg.screenshot(path=str(out / '09c-notebook-blueprints.png'))
pg.locator('[data-starter]').first.click(); settle(150)
check('starter design ready to place', ev("!!GAME.clip && GAME.paste && GAME.clip.molds.length === 1"))
pg.mouse.move(*blk(bbx + 2, bby - 16)); settle(200)
pg.screenshot(path=str(out / '09d-starter-ghost.png'))
pg.keyboard.press('Escape'); settle(100) # stop pasting
pg.keyboard.press('n'); settle(150)
pg.keyboard.press('Escape'); settle(100)
pg.keyboard.press('Escape'); settle(100)
check('pause opens', ev("!$('m-pause').hidden"))
pg.click('#pm-save'); settle(800)
bag_before = ev('bagTotal'); blocks_before = ev('blockP.reduce((a,v)=>a+(v>0),0)')
pg.click('#pm-title'); settle(1200)
check('back at title with continue', ev("!$('title').hidden && !$('btn-continue').hidden"))
pg.click('#btn-continue'); pg.wait_for_function('GAME.running === true', timeout=10000); settle(500)
check('continue restores bag and blocks', ev('bagTotal') == bag_before and ev('blockP.reduce((a,v)=>a+(v>0),0)') == blocks_before, f"{bag_before}->{ev('bagTotal')}")
# 9b. selling: goods sell the moment they drop into the dock; orders pay a lump sum and send a truck
ev("SET.cardSeconds = 9; orders = []; nextOrderT = 1e12; R.floaters = [];")
bot_home = ev('[bot.x, bot.y]')
ev("{ const dk = dock(); bot.x = dk.x + 12; bot.y = dk.y - 26; R.snapNext = true; }"); settle(400)
m0 = ev('money')
ev("{ const dk = dock(); for (let y = dk.y + 2; y < dk.y + 8; y++) for (let x = dk.x + 2; x < dk.x + 22; x++) setCell(y*W+x, (x+y)&1 ? CUORE : COAL, AMB[y], 0); }")
pg.wait_for_function(f'money > {m0} + 50', timeout=10000); settle(150)
check('goods are paid for as they drop through the grate', ev('money') > m0 + 50, f"{m0} -> {ev('money')}")
try: pg.wait_for_function('(R.floaters || []).length > 0', timeout=4000)
except Exception: pass
check('a "+$" floats up from the dock', ev('(R.floaters || []).length') > 0, ev('JSON.stringify([floatAcc, EV.length, tick, GAME.paused])'))
pg.screenshot(path=str(out / '09c-dock-sale.png'))
settle(300)
check('the wallet shows the new money', pg.locator('#w-money').inner_text().replace(',', '') == '$' + str(round(ev('money'))), pg.locator('#w-money').inner_text())
ev("seen[BARCU] = 1; orders.push({ id: 77, m: BARCU, qty: 2, got: 0, mult: 2, pay: 160, due: tick + 36000, mins: 10 }); orders.push({ id: 78, m: CUORE, qty: 300, got: 120, mult: 1.8, pay: 540, due: tick + 9000, mins: 5 }); hudDirty = true;")
settle(200)
check('the wallet lists open orders', pg.locator('#w-orders .ord').count() == 2, pg.locator('#w-orders').inner_text())
pg.screenshot(path=str(out / '09d-wallet-orders.png'), clip={'x': 0, 'y': 0, 'width': 1280, 'height': 260})
m1 = ev('money')
ev("{ const dk = dock(); spawnBar(BARCU, dk.x + 6, dk.y + 2, 0, 0); spawnBar(BARCU, dk.x + 12, dk.y + 2, 0, 0); }")
pg.wait_for_function('ordersDone > 0', timeout=10000); settle(200)
check('filling an order pays its lump sum', abs(ev('money') - m1 - 160) < 1, f"{m1} -> {ev('money')}")
check('an order-filled card appears, with no truck in it', pg.locator('.card-pop', has_text='Order filled').count() >= 1 and pg.locator('.card-pop', has_text='truck').count() == 0)
ev(f"bot.x = {bot_home[0]}; bot.y = {bot_home[1]}; R.snapNext = true;"); settle(400)
pg.click('#wallet'); settle(250)
check('the wallet opens the Market page', ev("!$('m-notebook').hidden && nbTab === 'Market'"))
d0 = ev('debt'); pg.locator('[data-pay="all"]').click(); settle(200)
check('paying toward the claim lowers the debt', ev('debt') < d0 and ev('money') == 0, f"debt {d0} -> {ev('debt')}")
pg.screenshot(path=str(out / '09f-market.png'))
pg.keyboard.press('Escape'); settle(150)
# 10. mocked gamepad
ev('''() => {
const mk = () => ({ connected: true, axes: [0,0,0,0], buttons: Array.from({length:17}, () => ({pressed:false, value:0})) });
window.__pad = mk();
navigator.getGamepads = () => [window.__pad];
}''')
pg.keyboard.press('1'); settle(100)
d0 = ev('stats.dug')
ev("__pad.axes[3] = 0.9; __pad.buttons[7].pressed = true; __pad.buttons[7].value = 1;")
settle(1500)
ev("__pad.buttons[7].pressed = false; __pad.buttons[7].value = 0;")
settle(200)
check('gamepad RT digs where the right stick aims', ev('stats.dug') > d0, f"{d0}->{ev('stats.dug')}")
ev("__pad.buttons[5].pressed = true;"); settle(100); ev("__pad.buttons[5].pressed = false;"); settle(100)
check('gamepad RB switches to the vacuum', ev('GAME.tool') == 1)
ev("__pad.buttons[9].pressed = true;"); settle(100); ev("__pad.buttons[9].pressed = false;"); settle(100)
check('gamepad Start opens menu', ev("!$('m-pause').hidden"))
ev("__pad.buttons[13].pressed = true;"); settle(80); ev("__pad.buttons[13].pressed = false;"); settle(80)
check('gamepad d-pad moves menu focus', ev("document.activeElement && document.activeElement.tagName") == 'BUTTON')
ev("__pad.buttons[1].pressed = true;"); settle(80); ev("__pad.buttons[1].pressed = false;"); settle(80)
check('gamepad B closes menu', ev("$('m-pause').hidden"))
pg.screenshot(path=str(out / '10-after.png'))
# 10b. world files: export from the pause menu as an ordinary download, import on the title screen
pg.keyboard.press('Escape'); settle(150)
with pg.expect_download() as dl_info:
pg.click('#pm-export')
dl = dl_info.value
wf = json.loads(pathlib.Path(dl.path()).read_text())
check('Export world file downloads a world file', wf.get('format') == 'deepgrain-world' and wf['meta']['W'] == ev('W'), dl.suggested_filename)
want = ev('JSON.stringify([W, H, Math.round(money), Math.round(debt), bagTotal])')
pg3 = ctx.new_page()
pg3.on('pageerror', lambda e: logs.append(f'PAGEERROR(import): {e}'))
pg3.goto(url); pg3.wait_for_timeout(300)
pg3.set_input_files('#importfile', dl.path())
pg3.wait_for_function('GAME.running === true', timeout=60000)
got = pg3.evaluate('JSON.stringify([W, H, Math.round(money), Math.round(debt), bagTotal])')
check('importing it on the title screen brings the same world back', got == want, f'{want} -> {got}')
pg3.close()
pg.keyboard.press('Escape'); settle(150)
# 11. the largest world from the title screen
pg2 = ctx.new_page()
pg2.on('pageerror', lambda e: logs.append(f'PAGEERROR(large): {e}'))
pg2.goto(url); pg2.wait_for_timeout(300)
check('the size menu offers small, medium and large, and no deep world', pg2.evaluate("[...document.querySelectorAll('#nw-size option')].map(o => o.value).join(',')") == '2048x1024,3072x1280,4096x1536', pg2.evaluate("[...document.querySelectorAll('#nw-size option')].map(o => o.value).join(',')"))
pg2.click('#btn-new'); pg2.fill('#nw-seed', '4242'); pg2.select_option('#nw-size', '4096x1536'); pg2.click('#btn-generate')
pg2.wait_for_function('GAME.running === true', timeout=90000)
check('large world generates', pg2.evaluate('W === 4096 && H === 1536'), pg2.evaluate('[W, H]'))
pg2.evaluate("bot.x = 2000; bot.y = magmaY[2000] - 60; snapCamera();"); pg2.wait_for_timeout(800)
pg2.screenshot(path=str(out / '11-deep-magma.png'))
pg2.close()
print('\n'.join(logs) or 'no console errors')
print('FAILS:', fails)
b.close()
sys.exit(1 if fails else 0)
+60
View File
@@ -0,0 +1,60 @@
#!/usr/bin/env python3
"""Run the test suites and report.
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),
after rebuilding dist/; they run one at a time
python3 test/run_all.py --browser-only
Exit status 0 only if everything passed. Failing checks are printed with their suite.
The headless suites are deterministic: the same code gives the same output, line for line, so
saving the output before and after a refactor and diffing it proves nothing changed.
"""
import concurrent.futures as cf, pathlib, subprocess, sys, time
ROOT = pathlib.Path(__file__).resolve().parent.parent
TEST = ROOT / 'test'
def run_node(path):
t0 = time.time()
p = subprocess.run(['node', str(path)], cwd=ROOT, capture_output=True, text=True, timeout=1800)
return path.stem, p.returncode, p.stdout + p.stderr, time.time() - t0
def run_py(name):
t0 = time.time()
p = subprocess.run([sys.executable, str(TEST / name)], cwd=ROOT, capture_output=True, text=True, timeout=1800)
return name, p.returncode, p.stdout + p.stderr, time.time() - t0
def main():
args = [a for a in sys.argv[1:] if not a.startswith('--')]
browser = '--browser' in sys.argv or '--browser-only' in sys.argv
node = '--browser-only' not in sys.argv
results = []
if node:
suites = sorted(TEST.glob('t_*.js'))
if args: suites = [s for s in suites if any(a in s.stem for a in args)]
with cf.ThreadPoolExecutor(max_workers=8) as ex:
for r in ex.map(run_node, suites): results.append(r)
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))
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: []']
passes = sum(1 for l in out.splitlines() if l.startswith('PASS'))
ok = code == 0 and not [l for l in fails if l.startswith('FAIL ') or 'Error' in l]
bad += not ok
print(f"{'ok ' if ok else 'FAIL'} {name:22s} {passes:3d} checks passed {secs:5.1f}s")
if not ok:
for l in (fails or out.splitlines()[-15:]): print(' ' + l)
print('ALL PASSED' if not bad else f'{bad} SUITE(S) FAILED')
sys.exit(1 if bad else 0)
if __name__ == '__main__':
main()
+95
View File
@@ -0,0 +1,95 @@
// Basics: drilling to rubble, the vacuum and blowing, walls, the ingot mold and bars, belts, blueprints, save/load.
const { load, suite, flatWorld, rect, ticks, count } = require('./harness');
const { run } = load();
const { check, done } = suite();
flatWorld(run, 240, 160, 100);
run('player.tankTier = 3; player.flask = false;');
// 1. drill breaks stone loose; nothing goes into the bag
run('bot.x = 116; bot.y = 92; for (let k=0;k<80;k++) toolDig(120, 104, 3)');
check('drill leaves bag empty', run('bagTotal') === 0, run('bagTotal'));
const rub = count(run, run('RSTONE'));
check('drill turns stone into loose rubble', rub > 20, rub);
ticks(run, 60);
// 2. vacuum with a filter: only rubble
rect(run, 100, 96, 104, 99, run('COAL'));
ticks(run, 30);
const got = run('toolVacuum(120, 104, 6, m => m === RSTONE).got');
check('vacuum picks only the filtered material', run('bag[RSTONE]') > 0 && run('bag[COAL]') === 0, `${run('bag[RSTONE]')} rubble, ${run('bag[COAL]')} coal`);
run('toolVacuum(102, 98, 4, m => true)');
check('vacuum everything mode picks coal', run('bag[COAL]') > 0, run('bag[COAL]'));
// 3. drop: grains fall
const c0 = run('bag[COAL]');
run('bot.x = 100; bot.y = 60;');
run('for (let k=0;k<5;k++) { toolBlow(80, 70, 1, COAL); simTick(); }');
check('blown coal flies out of the bot', run('flyers.length') > 0, run('flyers.length'));
ticks(run, 120);
check('blown coal leaves the bag and lands on the ground', run('bag[COAL]') < c0 && run('flyers.length') === 0 && count(run, run('COAL'), 60, 80, 100, 99) > 0, `${c0}->${run('bag[COAL]')} landed ${count(run, run('COAL'), 60, 80, 100, 99)}`);
// bot flies through built walls but not through natural rock
run('bag[RSTONE] += 64; bagTotal += 64; placeBlocks(BP_WALL, RSTONE, blockRect(30, 14, 30, 17))');
check('bot can overlap a built wall', !run('botOverlapSolid(121, 58)'));
check('bot cannot overlap natural rock', run('botOverlapSolid(120, 98)'));
// trees: water passes in front of a background trunk; cutting the base drops the tree
run('for (let y = 80; y < 100; y++) for (let x = 140; x < 142; x++) bgm[y*W+x] = WOOD; for (let y = 72; y < 80; y++) for (let x = 136; x < 146; x++) bgm[y*W+x] = LEAVES;');
rect(run, 130, 96, 138, 99, run('WATER'));
ticks(run, 120);
check('water flows past a tree trunk', count(run, run('WATER'), 141, 90, 160, 99) > 0, count(run, run('WATER'), 141, 90, 160, 99));
run('bot.x = 134; bot.y = 88; for (let k=0;k<120;k++) toolDig(140, 99, 1)');
ticks(run, 5);
check('cutting the trunk base brings the tree down as wood chunks', run('bgm[85*W+140]') === 0 && count(run, run('LWOOD')) > 20, count(run, run('LWOOD')));
// 4. wall blocks cost rubble and snap to the grid
run('bag[RSTONE] = 200; bagTotal = 0; for (let k=0;k<NMAT;k++) bagTotal += bag[k];');
const r1 = run("JSON.stringify(placeBlocks(BP_WALL, RSTONE, blockLine(25, 20, 29, 20)))");
check('stone wall line placed', JSON.parse(r1).n === 5 && run('bag[RSTONE]') === 200 - 80, r1 + ' bag=' + run('bag[RSTONE]'));
check('wall cells are solid stone at grid positions', run('mat[80*W+100]') === run('STONE') && run('mat[83*W+119]') === run('STONE'));
const rm = run("removePieceAt(101, 81)");
check('taking a wall block back refunds its rubble', run('bag[RSTONE]') === 200 - 64, rm + ' ' + run('bag[RSTONE]'));
// 5. ingot mold: pour molten copper, knock out bars
run('bag[RSTONE] = 200; bagTotal = 0; for (let k=0;k<NMAT;k++) bagTotal += bag[k];');
const mo = run("placeMoldAt('ingot', 32, 22, RSTONE)");
check('ingot mold placed', mo === '' && run('molds.length') === 1, mo);
run('for (const cav of molds[0].cavs) for (const i of cav) setCell(i, MCU, 1160, 0)');
ticks(run, 900);
check('ingot mold sets', run('molds[0].state') === 'ready' && run('molds[0].ready.length') === 3, run('molds[0].state') + ' ' + run('molds[0].ready.length'));
const msg = run('collectMold(molds[0])');
check('knock-out spawns 3 bars', run('ents.length') === 3, msg);
ticks(run, 200);
check('bars fall and come to rest', run('ents.every(e => Math.abs(e.vy) < 0.01)'), run('JSON.stringify(ents.map(e=>[e.ix,e.iy]))'));
check('bars count as built-up copper', run('stats.bars') === 3);
// 6. vacuum bars
const bx = run('ents[0].ix + 2'), by = run('ents[0].iy + 1');
run(`toolVacuum(${bx}, ${by}, 4, m => m === BARCU)`);
check('vacuum picks up bars', run('bag[BARCU]') >= 1 && run('ents.length') < 3, run('bag[BARCU]'));
// 7. belt carries a bar: belt from block (40,24) to (46,24), powered by direct supply
run('bot.x = 165; bot.y = 70; bag[BARCU] = 20; bagTotal = 0; for (let k=0;k<NMAT;k++) bagTotal += bag[k];');
const rb = run("JSON.stringify(placeBlocks(BP_BELT, 1, blockLine(40, 24, 46, 24)))");
check('belt blocks placed for $12 each', JSON.parse(rb).n === 7 && run('money') === 250 - 84, rb + ' money ' + run('money'));
run('rebuildPower(); nets[netId[96*W+161]].prods.push({out: 50});');
run('spawnBar(BARCU, 162, 90, 0, 0)');
ticks(run, 20);
const x0 = run('ents[ents.length-1].ix');
ticks(run, 60);
const x1 = run('ents[ents.length-1].ix');
check('powered belt moves the bar right', x1 > x0 + 10, `${x0} -> ${x1}`);
// 8. bar melts when heated
run('const e = ents[ents.length-1]; for (let dy=-2; dy<4; dy++) for (let dx=-2; dx<6; dx++) temp[(e.iy+dy)*W + e.ix+dx] = 1300; e.T = 1100;');
ticks(run, 3);
check('hot bar melts into molten copper', count(run, run('MCU')) > 0, count(run, run('MCU')));
// 9. machines snap to blocks
run('bag[LWOOD] = 100; bagTotal = 0; for (let k=0;k<NMAT;k++) bagTotal += bag[k];');
check('water wheel placed on block grid', run("placeMachineAt('wheel', 0, 10, 10)") === '' && run('machines[1].x') === 40);
// 10. blueprint copy + ghost fill
run('bot.x = 100; bot.y = 60;');
run('bag[RSTONE] = 400; bagTotal = 0; for (let k=0;k<NMAT;k++) bagTotal += bag[k];');
run("placeBlocks(BP_WALL, RSTONE, blockRect(20, 18, 22, 19))");
const bp = run("JSON.stringify(copyRegion(80, 72, 91, 79))");
check('blueprint captures 6 wall blocks', JSON.parse(bp).blocks.length === 6, bp.slice(0, 80));
run(`pasteGhost(${bp}, 34, 14)`);
ticks(run, 20);
check('ghosts are built from the bag', run('ghostBlocks.size') === 0 && run('blockP[14*BW+34]') === run('BP_WALL'), run('ghostBlocks.size'));
// 11. save / load round trip
const before = run('JSON.stringify({bag: bagTotal, ents: ents.length, molds: molds.length, mach: machines.filter(m => m && m.type !== "dock").length, blocks: blockP.reduce((a,b)=>a+(b>0),0)})');
run('globalThis.__sv = serializeWorld(); deserializeWorld(__sv.meta, __sv.buffers);');
const after = run('JSON.stringify({bag: bagTotal, ents: ents.length, molds: molds.length, mach: machines.filter(m => m && m.type !== "dock").length, blocks: blockP.reduce((a,b)=>a+(b>0),0)})');
check('save/load keeps bag, bars, molds, machines and blocks', before === after, before + ' vs ' + after);
done();
+80
View File
@@ -0,0 +1,80 @@
// Building: undo (Ctrl+Z), cut, replace a machine with Ctrl, pasting ghosts, and the fan heat warning.
const { load, suite, flatWorld, count } = require('./harness');
const { run } = load();
const { check, done } = suite();
const fresh = () => { flatWorld(run, 240, 160, 100); run('money = 1000; bag[RSTONE] = 400; bagTotal = 400; bot.x = 100; bot.y = 60; undoStack = []; clearAllGhosts();'); };
const belts = () => run('blockP.reduce((a, v) => a + (v === BP_BELT), 0)');
const fans = () => run("machines.filter(m => m && m.type === 'fan').length");
// placing a line of belts, then undoing it: they come down and the money comes back
fresh();
const m0 = run('money');
run("undoRecord('placing belts', () => placeBlocks(BP_BELT, 1, blockLine(22, 20, 27, 20), undefined, false))");
check('a line of 6 belts is placed', belts() === 6 && run('money') === m0 - 72, `${belts()} belts, $${run('money')}`);
check('Ctrl+Z takes them back and refunds them', run('undoLast()') === 'Undid placing belts.' && belts() === 0 && run('money') === m0, `${belts()} belts, $${run('money')}`);
check('with nothing left, it says so', run('undoLast()') === 'Nothing to undo.');
// taking a piece back, then undoing: it is built again
fresh();
run('placeBlocks(BP_BELT, 1, blockLine(22, 20, 25, 20), undefined, false); placeBlock(BP_WALL, RSTONE, 26, 20, false);');
const st0 = run('bag[RSTONE]');
run("undoRecord('taking back', () => { removePieceAt(23 * 4 + 1, 20 * 4 + 1); removePieceAt(26 * 4 + 1, 20 * 4 + 1); })");
check('taking back a belt and a wall block', belts() === 3 && run('blockP[20 * BW + 26]') === 0 && run('bag[RSTONE]') === st0 + 16, `${belts()} belts, ${run('bag[RSTONE]')} rubble`);
run('undoLast()');
check('undo builds them again from the bag', belts() === 4 && run('blockP[20 * BW + 26]') === run('BP_WALL') && run('bag[RSTONE]') === st0, `${belts()} belts, ${run('bag[RSTONE]')} rubble`);
// replacing belts with filters, then undoing: the belts come back
fresh();
run('placeBlocks(BP_BELT, 1, blockLine(22, 20, 25, 20), undefined, false);');
run("undoRecord('replacing', () => placeBlocks(BP_FILTER, 128, blockLine(23, 20, 24, 20), { m: [SAND], inv: 0 }, true))");
check('Ctrl-dragging filters over belts replaces them', run('blockP[20 * BW + 23]') === run('BP_FILTER') && belts() === 2);
run('undoLast()');
check('undo puts the belts back', belts() === 4 && run('blockP.reduce((a, v) => a + (v === BP_FILTER), 0)') === 0, belts());
// Ctrl over built blocks with a machine: they are taken down and the fan goes in their place
fresh();
run('placeBlocks(BP_BELT, 1, blockLine(22, 20, 25, 20), undefined, false);');
check('a fan will not go on a belt without Ctrl', /already/.test(run("canPlaceMachine('fan', 23, 20) || ''")));
run("undoRecord('replacing', () => { clearFootprint(23, 20, 1, 1); placeMachineAt('fan', 0, 23, 20); })");
check('with Ctrl the belt under it is taken down and the fan placed', fans() === 1 && belts() === 3);
run('undoLast()');
check('undo takes the fan back and rebuilds the belt', fans() === 0 && belts() === 4);
run('for (let y = 76; y < 80; y++) for (let x = 112; x < 116; x++) setCell(y * W + x, STONE, AMB[y], F_NAT);');
check('natural rock still stops a replace', /rock/.test(run('clearFootprint(28, 19, 1, 1)')));
// cut: everything in the box comes down into the bag and onto the clipboard; undo puts it back
fresh();
run("placeBlocks(BP_BELT, 1, blockLine(22, 20, 25, 20), undefined, false); placeMachineAt('fan', 0, 22, 18);");
const mc = run('money');
const bp = JSON.parse(run("JSON.stringify(undoRecord('cutting', () => cutRegion(22 * 4, 18 * 4, 26 * 4 - 1, 21 * 4 - 1)))"));
check('cut picks up the belts and the fan', bp && bp.blocks.length === 4 && bp.machines.length === 1, bp && `${bp.blocks.length} blocks, ${bp.machines.length} machines`);
check('and takes them down, refunded', belts() === 0 && fans() === 0 && run('money') === mc + 48 + 60, `${belts()} belts, ${fans()} fans, $${run('money')}`);
run('undoLast()');
check('undo after a cut puts it all back', belts() === 4 && fans() === 1 && run('money') === mc, `${belts()} belts, ${fans()} fans, $${run('money')}`);
// paste: ghosts appear; undo clears them, and takes back any the bot already built
fresh();
run("placeBlocks(BP_BELT, 1, blockLine(22, 20, 25, 20), undefined, false); window_bp = copyRegion(22 * 4, 20 * 4, 26 * 4 - 1, 21 * 4 - 1);");
run("undoRecord('pasting', () => pasteGhost(window_bp, 22, 17))");
check('pasting places ghosts', run('ghostBlocks.size') === 4);
run('for (let k = 0; k < 400; k++) simTick();');
const built = belts();
check('the bot builds some or all of them', built > 4, built);
check('building from ghosts is never an undo step of its own', run('undoStack.length') === 1, run('undoStack.length'));
run('undoLast()');
check('undo clears the ghosts and takes back what was built from them', belts() === 4 && run('ghostBlocks.size') === 0, `${belts()} belts, ${run('ghostBlocks.size')} ghosts`);
// a fan getting near its melting point warns you
fresh();
run("placeMachineAt('fan', 0, 22, 20); EV.length = 0;");
run('for (let y = 80; y < 84; y++) for (let x = 88; x < 92; x++) temp[y * W + x] = 900; for (let k = 0; k < 40; k++) { for (let y = 80; y < 84; y++) for (let x = 88; x < 92; x++) temp[y * W + x] = 900; simTick(); }');
const warn = run("EV.filter(e => e.t === 'toast' && /fan/i.test(e.text)).map(e => e.text).join(' | ')");
check('a fan at 900°C warns that it is about to melt', /1085/.test(warn) && fans() === 1, warn);
// old saves: tiny specks of too-hard rock become the rock around them, once
fresh();
run('for (let y = 120; y < 122; y++) for (let x = 60; x < 62; x++) setCell(y * W + x, FEV, AMB[y], F_NAT); for (let y = 120; y < 130; y++) for (let x = 140; x < 150; x++) setCell(y * W + x, FEV, AMB[y], F_NAT);');
const n = run('clearHardSpecks(true)');
check('a 4-grain speck of iron ore in stone becomes stone; a 100-grain vein stays', n === 1 && count(run, run('FEV')) === 100 && run('mat[120 * W + 60]') === run('STONE'), `${n} specks, ${count(run, run('FEV'))} vein`);
done();
+160
View File
@@ -0,0 +1,160 @@
// Screw tubes that never carry liquid (it flows through), tube and belt throughput, filters.
const { load, suite, flatWorld, rect, ticks, count } = require('./harness');
const { run } = load();
const { check, done } = suite();
function fresh() { flatWorld(run, 320, 200, 120); run('bot.x = 20; bot.y = 40; money = 1e6; globalThis.__got = 0;'); }
const tubeLiquid = () => run('(() => { let n = 0; for (let i = 0; i < N; i++) if (part[i] >= P_TU && part[i] <= P_TR && PH[mat[i]] === PH_LIQUID) n++; return n; })()');
// grains that land in the sink region are counted and taken away, so outlets never back up
function rate(x0, x1, y0, y1, warm, meas) {
const sink = `for (let y=${y0};y<=${y1};y++) for (let x=${x0};x<=${x1};x++) { const i=y*W+x; if (mat[i]===SAND) { mat[i]=EMPTY; __got++; } }`;
for (let k = 0; k < warm; k++) run(`simTick(); ${sink}`);
run('__got = 0');
for (let k = 0; k < meas; k++) run(`simTick(); ${sink}`);
return run('__got') / (meas / 60);
}
// --- tubes are sealed: no water in, no water carried
fresh();
run('for (let y = 120; y < 170; y++) for (let x = 100; x < 124; x++) setCell(y*W+x, EMPTY, AMB[y], 0)');
rect(run, 100, 150, 123, 169, run('SAND'));
rect(run, 100, 130, 123, 149, run('WATER'));
run('for (let by = 22; by <= 41; by++) placeBlock(BP_TUBE, 0, 27, by, true); for (let bx = 27; bx <= 34; bx++) placeBlock(BP_TUBE, 1, bx, 21, true);');
const water0 = count(run, run('WATER'));
ticks(run, 900);
const wOut = count(run, run('WATER'), 124, 0, 319, 119), sOut = count(run, run('SAND'), 124, 0, 319, 119);
check('a lift standing in a flooded pit brings up the sand but not the water', sOut > 150 && wOut === 0, `sand out ${sOut}, water out ${wOut}, water standing in the tubes ${tubeLiquid()}`);
const pitClouds = run('weather.moist.reduce((a, v) => a + v, 0)');
check('the pit keeps its water (bar what steams off into the clouds)', water0 - count(run, run('WATER')) - pitClouds <= 6, `${water0} -> ${count(run, run('WATER'))}, ${pitClouds.toFixed(0)} in the clouds`);
fresh();
run('for (let bx = 20; bx < 26; bx++) placeBlock(BP_TUBE, 1, bx, 25, true);');
rect(run, 84, 60, 95, 79, run('WATER'));
ticks(run, 300);
check('water poured on a tube runs straight through it', count(run, run('WATER'), 0, 0, 319, 99) === 0 && count(run, run('WATER'), 0, 104, 319, 119) > 200 && tubeLiquid() < 10, `${count(run, run('WATER'), 0, 104, 319, 119)} below, ${tubeLiquid()} still inside`);
fresh();
// a vertical tube pointing up, its top open to a pool: the water drains down through it, against the screw
run('for (let by = 20; by <= 26; by++) placeBlock(BP_TUBE, 0, 25, by, true); for (let y = 60; y < 80; y++) { setCell(y*W+99, STONE, AMB[y], 0); setCell(y*W+104, STONE, AMB[y], 0); }');
rect(run, 100, 60, 103, 79, run('WATER'));
ticks(run, 400);
check('water runs down through an upward screw, which never pushes it back', count(run, run('WATER'), 0, 0, 319, 79) === 0, count(run, run('WATER'), 0, 0, 319, 79));
// --- throughput
fresh();
run('for (let bx = 20; bx < 30; bx++) placeBlock(BP_TUBE, 1, bx, 20, true);');
run('for (let y = 0; y < 80; y++) { setCell(y*W+79, STONE, AMB[y], 0); setCell(y*W+96, STONE, AMB[y], 0); }');
rect(run, 80, 0, 95, 79, run('SAND'));
ticks(run, 60);
const lanes = run('(() => { const s = new Set(); for (let y = 80; y < 84; y++) for (let x = 100; x < 120; x++) if (mat[y*W+x] === SAND) s.add(y); return s.size; })()');
check('a sideways tube fed from a hopper runs full, all four grains wide', lanes === 4, lanes);
fresh();
run('for (let bx = 20; bx < 30; bx++) placeBlock(BP_TUBE, 1, bx, 20, true);');
run('for (let y = 0; y < 80; y++) { setCell(y*W+79, STONE, AMB[y], 0); setCell(y*W+96, STONE, AMB[y], 0); }');
rect(run, 80, 0, 95, 79, run('SAND'));
const tr = rate(120, 319, 84, 119, 90, 300);
// tubes run at belt pace (every other tick), so a belt under a tube's mouth keeps up with it
check('a sideways tube moves around a hundred grains a second', tr > 80 && tr < 150, `${tr.toFixed(0)}/s`);
fresh();
run('for (let y = 120; y < 196; y++) for (let x = 90; x < 130; x++) setCell(y*W+x, EMPTY, AMB[y], 0)');
rect(run, 90, 130, 129, 195, run('SAND'));
run('for (let by = 20; by <= 37; by++) placeBlock(BP_TUBE, 0, 26, by, true); for (let bx = 26; bx <= 40; bx++) placeBlock(BP_TUBE, 1, bx, 19, true);');
const lr = rate(164, 319, 80, 119, 400, 300);
const liftLanes = run('(() => { const s = new Set(); for (let y = 76; y < 80; y++) for (let x = 120; x < 160; x++) if (mat[y*W+x] === SAND) s.add(y); return s.size; })()');
check('a lift turns its corner without squeezing down to one lane', liftLanes === 4 && lr > 30, `${lr.toFixed(0)}/s, ${liftLanes} lanes at the top`);
fresh();
run('for (let bx = 20; bx < 32; bx++) placeBlock(BP_BELT, 1, bx, 25, true);');
rect(run, 80, 20, 95, 99, run('SAND'));
const br = rate(128, 319, 104, 119, 90, 300);
ticks(run, 0);
check('a belt carries a pile off at several dozen grains a second', br > 50, `${br.toFixed(0)}/s`);
fresh();
run('for (let bx = 20; bx < 32; bx++) placeBlock(BP_BELT, 1, bx, 25, true);');
rect(run, 96, 80, 111, 99, run('WATER'));
ticks(run, 300);
check('water on a belt drains through it', count(run, run('WATER'), 0, 0, 319, 99) === 0 && count(run, run('WATER'), 0, 104, 319, 119) > 280, count(run, run('WATER'), 0, 0, 319, 99));
fresh();
// a belt under standing water: walls hold a pool over its middle, and sand still rides through it
run('for (let bx = 20; bx < 32; bx++) placeBlock(BP_BELT, 1, bx, 25, true); for (let y = 90; y < 98; y++) { setCell(y*W+100, STONE, AMB[y], 0); setCell(y*W+108, STONE, AMB[y], 0); }');
rect(run, 101, 90, 107, 99, run('WATER'));
rect(run, 80, 92, 87, 99, run('SAND')); rect(run, 79, 88, 79, 99, run('STONE'));
run('globalThis.__past = 0;');
for (let k = 0; k < 600; k++) { run('for (let y = 92; y < 100; y++) for (let x = 101; x < 108; x++) if (mat[y*W+x] === EMPTY) setCell(y*W+x, WATER, AMB[y], 0); simTick(); for (let y = 104; y < 120; y++) for (let x = 128; x < 319; x++) if (mat[y*W+x] === SAND) { mat[y*W+x] = EMPTY; __past++; }'); }
const wetPast = run('__past') + count(run, run('SAND'), 108, 0, 319, 119);
check('sand rides through water standing on a belt and on past it', wetPast === 64, `${wetPast} of 64`);
fresh();
run('for (let bx = 20; bx < 32; bx++) placeBlock(BP_BELT, 1, bx, 25, true);');
rect(run, 80, 60, 95, 99, run('SAND'));
ticks(run, 40);
const depth = run('(() => { let d = 0; for (let y = 99; y > 90; y--) if (mat[y*W+110] === SAND) d++; else break; return d; })()');
check('the load rides a belt up to a block deep', depth >= 3, depth);
// --- filters
fresh();
// a filter for copper ore under a mixed pile; everything else rides right and off the end
// fed from a walled hopper over its left end, the way a tube or belt would drop a mixed load on it
run('for (let bx = 20; bx < 30; bx++) placeBlock(BP_FILTER, 128, bx, 25, true, { m: [CUORE] });');
run('for (let y = 30; y < 120; y++) setCell(y*W+79, STONE, AMB[y], 0); for (let y = 30; y < 96; y++) setCell(y*W+88, STONE, AMB[y], 0);');
run('for (let y = 40; y < 96; y++) for (let x = 80; x < 88; x++) setCell(y*W+x, (x + y) % 3 ? SAND : CUORE, AMB[y], 0)');
const ore0 = count(run, run('CUORE')), sand0 = count(run, run('SAND'));
// what rides off the far end is taken away and counted, so its pile never spreads back under the filter
run('globalThis.__past = [0, 0];');
for (let k = 0; k < 1200; k++) run('simTick(); for (let y = 104; y < 120; y++) for (let x = 120; x < 319; x++) { const i = y*W+x; if (mat[i] === SAND) { __past[0]++; mat[i] = EMPTY; } else if (mat[i] === CUORE) { __past[1]++; mat[i] = EMPTY; } }');
const oreUnder = count(run, run('CUORE'), 80, 104, 119, 119), sandUnder = count(run, run('SAND'), 80, 104, 119, 119);
const sandPast = run('__past[0]');
check('a filter lets its material fall through, even from under the rest of the load', oreUnder > ore0 * 0.9, `${oreUnder} of ${ore0} ore below`);
check('everything else rides along on top and off the end', sandUnder === 0 && sandPast > sand0 * 0.9, `${sandUnder} sand fell through, ${sandPast} of ${sand0} carried off`);
fresh();
run('for (let bx = 20; bx < 26; bx++) placeBlock(BP_FILTER, 0, bx, 25, true, { m: [CUORE] });');
rect(run, 84, 70, 95, 89, run('WATER'));
ticks(run, 400);
check('liquids drain through a filter', count(run, run('WATER'), 0, 0, 319, 99) === 0 && count(run, run('WATER'), 0, 104, 319, 119) > 220, count(run, run('WATER'), 0, 104, 319, 119));
fresh();
run('for (let bx = 20; bx < 26; bx++) placeBlock(BP_FILTER, 128, bx, 25, true, { m: [SAND] }); for (let bx = 30; bx < 34; bx++) placeBlock(BP_FILTER, 0, bx, 25, true, { m: [BARCU] });');
run('spawnBar(BARCU, 82, 96, 0, 0); spawnBar(BARCU, 124, 96, 0, 0);');
ticks(run, 200);
const bars = JSON.parse(run('JSON.stringify(ents.map(e => [e.ix, e.iy]))'));
check('a bar rides along a filter set for something else', bars.some(([x, y]) => x > 100 || y > 104), JSON.stringify(bars));
check('and falls through a filter set for its own kind', bars.some(([x, y]) => x >= 116 && x < 136 && y >= 104), JSON.stringify(bars));
// carry mode with several materials: sand and coal ride along, everything else drops
fresh();
run('for (let bx = 20; bx < 30; bx++) placeBlock(BP_FILTER, 128, bx, 25, true, { m: [SAND, COAL], inv: 1 });');
run('for (let y = 30; y < 120; y++) setCell(y*W+79, STONE, AMB[y], 0); for (let y = 30; y < 96; y++) setCell(y*W+88, STONE, AMB[y], 0);');
run('for (let y = 40; y < 96; y++) for (let x = 80; x < 88; x++) setCell(y*W+x, [SAND, CUORE, COAL, FEORE][(x + y * 3) & 3], AMB[y], 0)');
const k0 = { sand: count(run, run('SAND')), coal: count(run, run('COAL')), cu: count(run, run('CUORE')), fe: count(run, run('FEORE')) };
run('globalThis.__past = {};');
for (let k = 0; k < 1200; k++) run('simTick(); for (let y = 104; y < 120; y++) for (let x = 120; x < 319; x++) { const i = y*W+x, m = mat[i]; if (m !== EMPTY && m !== STONE) { __past[m] = (__past[m] || 0) + 1; mat[i] = EMPTY; } }');
const under = { sand: count(run, run('SAND'), 80, 104, 119, 119), coal: count(run, run('COAL'), 80, 104, 119, 119), cu: count(run, run('CUORE'), 80, 104, 119, 119), fe: count(run, run('FEORE'), 80, 104, 119, 119) };
check('carry mode: the chosen materials ride along and off the end', under.sand === 0 && under.coal === 0 && run('(__past[SAND] || 0) + (__past[COAL] || 0)') > 0.9 * (k0.sand + k0.coal), JSON.stringify(under));
check('and everything else drops through, both kinds of ore', under.cu > 0.9 * k0.cu && under.fe > 0.9 * k0.fe, `${under.cu}/${k0.cu} copper, ${under.fe}/${k0.fe} iron`);
// changing what a filter passes pushes out what no longer fits; blueprints and saves keep the setting
fresh();
run('placeBlock(BP_FILTER, 0, 25, 25, true, { m: [SAND] }); for (let y = 100; y < 104; y++) for (let x = 100; x < 104; x++) setCell(y*W+x, SAND, AMB[y], 0);');
run('setFilter(25 * BW + 25, { m: [CUORE] })');
check('switching a filter to copper ore pushes the sand inside it out on top', count(run, run('SAND'), 100, 100, 103, 103) === 0 && count(run, run('SAND'), 96, 80, 108, 99) === 16);
run('placeBlock(BP_FILTER, 128, 30, 25, true, { m: [FEORE, CUORE], inv: 1 });');
const bp = JSON.parse(run('JSON.stringify(flipBlueprint(copyRegion(120, 100, 123, 103)).blocks)'));
check('a flipped blueprint keeps the filter settings and turns its direction', bp[0][3] === 0 && JSON.stringify(bp[0][4]) === JSON.stringify({ m: [run('CUORE'), run('FEORE')].sort((a, b) => a - b), inv: 1 }), JSON.stringify(bp));
run('clearAllGhosts(); pasteGhost(flipBlueprint(copyRegion(120, 100, 123, 103)), 34, 25); bot.x = 130; bot.y = 80; for (let k = 0; k < 30; k++) ghostTick();');
check('pasting it builds a filter with the same materials and mode', run('blockP[25*BW+34]') === run('BP_FILTER') && run('JSON.stringify(filterInfo(25*BW+34))') === JSON.stringify({ m: [run('CUORE'), run('FEORE')].sort((a, b) => a - b), inv: 1 }), run('JSON.stringify(filterInfo(25*BW+34))'));
run('globalThis.__s = serializeWorld(); deserializeWorld(__s.meta, __s.buffers);');
check('save and load keep filters, their materials, mode and direction', run('blockP[25*BW+30]') === run('BP_FILTER') && run('blockV[25*BW+30]') === 128 && run('filterInfo(25*BW+30).inv') === 1 && run('filterInfo(25*BW+30).m.length') === 2 && run('part[100*W+120]') === run('P_FILTER'));
// a save from before: one material kept in the block variant
run('__s.meta.filters = []; __s.buffers.blockV[25*BW+30] = 128 | SAND; deserializeWorld(__s.meta, __s.buffers);');
check('older filters keep the one material they had', run('JSON.stringify(filterInfo(25*BW+30))') === JSON.stringify({ m: [run('SAND')], inv: 0 }) && run('blockV[25*BW+30]') === 128);
// the run of matching filters in a row
fresh();
run('for (let bx = 20; bx < 26; bx++) placeBlock(BP_FILTER, 128, bx, 25, true, { m: [SAND] }); placeBlock(BP_FILTER, 128, 26, 25, true, { m: [COAL] }); placeBlock(BP_FILTER, 0, 27, 25, true, { m: [COAL] });');
check('a filter line is the touching filters that do the same thing', run('filterRun(25*BW+22).length') === 6 && run('filterRun(25*BW+26).length') === 1 && run('filterRun(25*BW+27).length') === 1);
// replacing what is built: hold the modifier and it swaps, refunding the old piece
fresh();
run('money = 200; for (let bx = 20; bx < 24; bx++) placeBlock(BP_BELT, 1, bx, 25, true); bot.x = 88; bot.y = 80;');
let r = JSON.parse(run('JSON.stringify(placeBlocks(BP_FILTER, 128, blockLine(20, 25, 23, 25), { m: [SAND] }, false))'));
check('without the modifier, built blocks are left alone', r.n === 0 && /already built/.test(r.status) && run('money') === 200, JSON.stringify(r));
r = JSON.parse(run('JSON.stringify(placeBlocks(BP_FILTER, 128, blockLine(20, 25, 23, 25), { m: [SAND] }, true))'));
check('with it, belts are swapped for filters, paying the difference', r.n === 4 && run('blockP.reduce((a, v) => a + (v === BP_FILTER), 0)') === 4 && run('blockP.reduce((a, v) => a + (v === BP_BELT), 0)') === 0 && run('money') === 200 + 4 * 12 - 4 * 40, `${JSON.stringify(r)} money ${run('money')}`);
r = JSON.parse(run('JSON.stringify(placeBlocks(BP_FILTER, 0, blockLine(20, 25, 23, 25), { m: [COAL], inv: 1 }, true))'));
check('replacing a filter with a filter just re-sets it, at no cost', r.n === 4 && run('money') === 200 + 48 - 160 && run('filterInfo(25*BW+21).inv') === 1 && run('blockV[25*BW+21]') === 0, `${JSON.stringify(r)} money ${run('money')}`);
run('bag[RSTONE] = 64; bagTotal = 64;');
r = JSON.parse(run('JSON.stringify(placeBlocks(BP_WALL, RSTONE, blockLine(20, 25, 21, 25), undefined, true))'));
check('and a wall can replace them too', r.n === 2 && run('blockP[25*BW+20]') === run('BP_WALL') && run('!filterCfg.has(25*BW+20)'), JSON.stringify(r));
check('filters are a buildable piece with a price', run('PIECE_BY_ID.filter && !PIECE_BY_ID.filter.hidden && PIECE_BY_ID.filter.cost[0][0] === CASH'));
check('no trucks', run('typeof truck') === 'undefined');
done();
+137
View File
@@ -0,0 +1,137 @@
// 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.
const { load, suite, flatWorld, rect, ticks, count } = require('./harness');
const { run } = load();
const { check, done } = suite();
// a stone wall to the right of the bot, from x 124 on, rows 60..99
function wall(m = 'STONE') { flatWorld(run, 240, 160, 100); run(`for (let y = 60; y < 100; y++) for (let x = 124; x < 240; x++) setCell(y * W + x, ${m}, AMB[y], F_NAT);`); run('bot.x = 110; bot.y = 76; stats.dug = 0; drillPulses = []; player.bitTier = 0;'); }
const hold = (ax, ay, w, tune, secs) => run(`for (let k = 0; k < ${Math.round(secs * 60)}; k++) { toolPulse(${ax}, ${ay}, ${w}, ${tune || 0}); simTick(); }`);
// how deep the hole goes along the aim line, and how many grains it opened in all
const depth = () => run('(() => { let d = 0; for (let x = 124; x < 240; x++) { if (PH[mat[78 * W + x]] === PH_SOLID) break; d = x - 123; } return d; })()');
const opened = () => run('(() => { let n = 0; for (let y = 60; y < 100; y++) for (let x = 124; x < 240; x++) if (PH[mat[y * W + x]] !== PH_SOLID) n++; return n; })()');
wall(); run('bot.x = 116;'); hold(150, 78, 1, 0, 2);
const med = { d: depth(), n: opened() };
check('held on a stone wall, medium pulses dig a hole and drop rubble', med.d >= 6 && run('stats.dug') > 30 && count(run, run('RSTONE')) > 30, `${med.d} deep, ${med.n} opened, ${count(run, run('RSTONE'))} rubble`);
wall(); hold(150, 78, 1, 0, 0.3);
check('rock that is shaking but has not broken yet shows it', run('(() => { let n = 0; for (let i = 0; i < N; i++) if (digp[i] > 0 && PH[mat[i]] === PH_SOLID) n++; return n; })()') > 5);
// the widths, with a copper bit so the reach is not what stops them
const wall2 = () => { wall(); run('player.bitTier = 1;'); };
wall2(); hold(150, 78, 1, 0, 2);
const med2 = { d: depth(), n: opened() };
wall2(); hold(150, 78, 0, 0, 2);
const nar = { d: depth(), n: opened() };
wall2(); hold(150, 78, 2, 0, 2);
const wid = { d: depth(), n: opened() };
check('a narrow pulse bores deeper than a wide one', nar.d > wid.d, `narrow ${nar.d}, medium ${med2.d}, wide ${wid.d} deep`);
const rowsOf = () => run('(() => { let a = 99, b = 0; for (let y = 60; y < 100; y++) for (let x = 124; x < 132; x++) if (PH[mat[y * W + x]] !== PH_SOLID) { a = Math.min(a, y); b = Math.max(b, y); } return b - a + 1; })()');
wall2(); hold(150, 78, 2, 0, 2); const wideRows = rowsOf();
wall2(); hold(150, 78, 0, 0, 2); const narRows = rowsOf();
check('the wide cone is taller at the face than the narrow one', wideRows > narRows, `wide ${wideRows} rows, narrow ${narRows}`);
// the reach depends on the width: a narrow beam reaches furthest, a wide fan only close by
run('player.bitTier = 0;');
const reaches = [0, 1, 2].map(w => run(`drillReach(${w})`));
check('a narrow pulse reaches further than a medium one, and a medium one further than a wide one', reaches[0] > reaches[1] && reaches[1] > reaches[2], reaches.join(' / '));
// following the face like a player would, narrow bores the fastest tunnel and wide opens the most rock
const follow = (w) => {
wall2(); run('for (let y = 50; y < 100; y++) for (let x = 124; x < 240; x++) setCell(y * W + x, STONE, AMB[y], F_NAT); bot.x = 116;');
for (let t = 0; t < 12; t++) run(`for (let k = 0; k < 15; k++) { toolPulse(Math.round(bot.x + 42), 78, ${w}, 0); simTick(); } for (let k = 0; k < 40; k++) { if (botOverlapSolid(bot.x + 4, bot.y)) break; bot.x += 1; }`);
return { d: depth(), n: opened() };
};
const fN = follow(0), fM = follow(1), fW = follow(2);
check('following the face, the narrow tunnel goes furthest', fN.d > fM.d * 1.3 && fM.d > fW.d, `narrow ${fN.d}, medium ${fM.d}, wide ${fW.d} deep`);
check('and the wide fan opens up the most rock', fW.n > fN.n && fW.n > fM.n, `narrow ${fN.n}, medium ${fM.n}, wide ${fW.n} grains`);
// the fan leaves the whole face of the bot, so rock right beside it is not missed
for (const w of [0, 1, 2]) {
wall(); run('for (let y = 60; y < 100; y++) for (let x = 124; x < 240; x++) setCell(y * W + x, EMPTY, AMB[y], 0); for (let y = 77; y < 81; y++) for (let x = 121; x < 124; x++) setCell(y * W + x, STONE, AMB[y], F_NAT); bot.x = 116; bot.y = 76;');
const lump = () => count(run, run('STONE'), 121, 60, 123, 81);
hold(150, 74, w, 0, 1.5);
check(`a ${['narrow', 'medium', 'wide'][w]} pulse aimed a little above a lump touching the bot still breaks it`, lump() <= 2, `${lump()} of 12 left`);
}
// tuning: copper veins in stone; tuned to copper ore only the veins break, and drain out
wall();
run('for (let s = 0; s <= 1; s += 0.01) { const x = Math.round(124 + 20 * s), y = Math.round(80 - 8 * s); for (let dy = -1; dy <= 1; dy++) for (let dx = 0; dx <= 1; dx++) setCell((y + dy) * W + x + dx, CUV, AMB[y], F_NAT); }');
run('player.bitTier = 1; bot.x = 114;');
const stone0 = count(run, run('STONE')), vein0 = count(run, run('CUV'));
hold(136, 77, 1, run('CUORE'), 3);
const stone1 = count(run, run('STONE')), vein1 = count(run, run('CUV'));
check('tuned to copper ore, the stone around the vein is untouched', stone1 === stone0, `stone ${stone0} -> ${stone1}`);
check('and the vein shatters', vein1 < vein0 * 0.6, `vein ${vein0} -> ${vein1}`);
const out = run('(() => { let n = 0; for (let y = 0; y < 100; y++) for (let x = 0; x < 124; x++) if (mat[y * W + x] === CUORE) n++; return n; })()');
check('shattered ore pours out of the wall toward the bot', out > 10, `${out} ore grains outside the wall`);
// built blocks soak pulses up
wall();
run('bag[RSTONE] = 200; bagTotal = 200; for (let by = 17; by <= 21; by++) placeBlock(BP_WALL, RSTONE, 30, by, true);'); // a built column at x 120..123
const built0 = run('(() => { let n = 0; for (let y = 68; y < 88; y++) for (let x = 120; x < 124; x++) if (mat[y * W + x] === STONE) n++; return n; })()');
hold(140, 78, 1, 0, 2);
const built1 = run('(() => { let n = 0; for (let y = 68; y < 88; y++) for (let x = 120; x < 124; x++) if (mat[y * W + x] === STONE) n++; return n; })()');
check('built blocks in the way are never harmed', built0 > 0 && built1 === built0, `${built0} -> ${built1}`);
check('and they shield what is behind them', opened() < med.n / 2, `${opened()} opened behind the wall`);
// it says why nothing breaks
wall('GRANITE');
const r = JSON.parse(run('JSON.stringify(toolPulse(140, 78, 1, 0))'));
check('granite with a stone bit says which bit it needs', /too hard/.test(r.status) && /iron bit/i.test(r.status), r.status);
hold(140, 78, 1, 0, 1);
check('and the granite stays whole', count(run, run('GRANITE')) === 116 * 40, count(run, run('GRANITE')));
wall();
run('drillPulses = []; tick += 20;');
const far = JSON.parse(run('JSON.stringify(toolPulse(200, 78, 1, 0))'));
check('aiming past the reach says how far the bit reaches', /Out of reach/.test(far.status), far.status);
run('drillPulses = []; tick += 20;');
const farW = JSON.parse(run('JSON.stringify(toolPulse(125, 78, 2, 0))')), nearM = JSON.parse(run('tick += 20; JSON.stringify(toolPulse(125, 78, 1, 0))'));
check('a spot a medium pulse reaches is out of reach for a wide one, and it says so', /Out of reach/.test(farW.status) && /wide/.test(farW.status) && !nearM.status, `${farW.status} | ${nearM.status}`);
// stray bits: a lump of rock left hanging when the rock round it breaks falls apart
const hanging = (m) => {
flatWorld(run, 240, 160, 100);
run('for (let y = 60; y < 100; y++) for (let x = 140; x < 240; x++) setCell(y * W + x, STONE, AMB[y], F_NAT);');
// a 3 x 3 lump joined to the wall by a line of stone grains
run(`player.bitTier = 0; for (let y = 70; y < 73; y++) for (let x = 130; x < 133; x++) setCell(y * W + x, ${m}, AMB[y], F_NAT); for (let x = 133; x < 140; x++) setCell(71 * W + x, STONE, AMB[71], F_NAT);`);
run('shatterCell(71 * W + 136, 136, 71, STONE); crumbleAround(136, 71);');
};
hanging('STONE');
check('a bit of stone cut off from the wall falls apart', count(run, run('STONE'), 128, 60, 135, 80) === 0 && count(run, run('RSTONE')) === 13, `${count(run, run('STONE'), 128, 60, 135, 80)} left, ${count(run, run('RSTONE'))} rubble`);
check('and the wall it came off stays whole', count(run, run('STONE'), 137, 60, 239, 99) === 100 * 40 + 3, count(run, run('STONE'), 137, 60, 239, 99));
hanging('FEV');
check('a cut-off lump with ore too hard for the bit in it stays whole', count(run, run('FEV')) === 9 && count(run, run('FEORE')) === 0, `${count(run, run('FEV'))} vein, ${count(run, run('FEORE'))} ore`);
// too-hard rock is never knocked loose, however small
const speckWall = (n) => {
flatWorld(run, 240, 160, 100);
run('for (let y = 60; y < 100; y++) for (let x = 124; x < 240; x++) setCell(y * W + x, STONE, AMB[y], F_NAT); player.bitTier = 0; drillPulses = [];');
// a lump of iron ore at the face, n grains across, three rows tall, sticking out into the open
run(`for (let y = 77; y < 80; y++) for (let x = 122; x < ${122 + n}; x++) setCell(y * W + x, FEV, AMB[y], F_NAT); bot.x = 112; bot.y = 76;`);
};
speckWall(2); hold(150, 78, 1, 0, 3);
check('a speck of iron ore is never broken or knocked loose by a stone bit', count(run, run('FEV')) === 6 && count(run, run('FEORE')) === 0, `${count(run, run('FEV'))} left, ${count(run, run('FEORE'))} ore`);
// world generation leaves no tiny specks of rock harder than what is round them
run('generateWorld(424242, 1024, 640)');
// (a speck only counts if it is harder than the rock round it; another sweep finds none)
const tiny = run('clearHardSpecks(false)');
check('a new world has no specks of iron, zinc or granite small enough to leave in the way', tiny === 0, `${tiny} specks`);
const fe = count(run, run('FEV'));
check('and still has plenty of iron ore', fe > 4000, fe);
// the bot slips round a lip of rock a grain or two deep instead of stopping dead
const corridor = (lip) => {
flatWorld(run, 240, 160, 100);
run(`for (let y = 50; y < 100; y++) for (let x = 100; x < 240; x++) if (y < 70 || y >= 78) setCell(y * W + x, STONE, AMB[y], F_NAT);
for (let y = 70; y < 70 + ${lip}; y++) for (let x = 140; x < 143; x++) setCell(y * W + x, STONE, AMB[y], F_NAT);
bot.x = 110; bot.y = 70; bot.vx = bot.vy = 0;`);
for (let k = 0; k < 150; k++) run('botStep(1, 0, false)');
return run('bot.x');
};
const past2 = corridor(2), past8 = corridor(8);
check('flying along a tunnel, the bot slips past a two-grain lip', past2 > 150, past2.toFixed(1));
check('but a wall across the tunnel still stops it', past8 < 140, past8.toFixed(1));
// trees in the way come apart into wood chunks
flatWorld(run, 240, 160, 100);
run('for (let y = 80; y < 100; y++) for (let x = 120; x < 122; x++) bgm[y * W + x] = WOOD; findTreeBases(); bot.x = 108; bot.y = 88; player.bitTier = 0;');
hold(121, 92, 1, 0, 3);
check('a tree trunk in the path is cut into wood chunks', count(run, run('LWOOD')) > 0, count(run, run('LWOOD')));
done();
+115
View File
@@ -0,0 +1,115 @@
// Economy: the dock (instant sale), orders, prices, the market dip, money for machines, alloys.
const { load, suite, flatWorld, rect, ticks, count } = require('./harness');
const { run } = load();
const { check, done } = suite();
// a generated world comes with a dock near spawn
run('generateWorld(777, 2048, 1024, null)');
const d = run('JSON.stringify(dock() && { x: dock().x, y: dock().y })');
check('new worlds have a trade dock near spawn', d !== 'null' && Math.abs(JSON.parse(d).x - 1024) < 80, d);
check('starting money and claim debt', run('money') === 250 && run('debt') === 20000);
// goods dropped into the dock sell the moment they reach its grate
run('mkRecent.fill(0); orders = []; nextOrderT = 1e12; EV.length = 0;');
run('{ const dk = dock(); for (let y = dk.y + 2; y < dk.y + 8; y++) for (let x = dk.x + 2; x < dk.x + 22; x++) setCell(y*W+x, (x+y) % 2 ? CUORE : COAL, AMB[y], 0); }');
run('{ const dk = dock(); spawnBar(BARCU, dk.x + 6, dk.y + 2, 0, 0); spawnBar(BARCU, dk.x + 12, dk.y + 2, 0, 0); }');
ticks(run, 90);
const interior = () => run('(() => { const dk = dock(); let n = 0; for (let y = dk.y; y < dk.y + 14; y++) for (let x = dk.x + 2; x < dk.x + 22; x++) if (mat[y*W+x] !== EMPTY) n++; return n; })()');
check('ore, coal and bars are paid for as soon as they drop in', run('money') > 250 + 150 && interior() === 0 && run('ents.length') === 0, `money ${run('money').toFixed(1)}`);
check('a "+$" floater rises from the dock', run('EV.some(e => e.t === "sold" && e.v > 1)'));
// no cap: a whole column of ore poured in over time all sells
run('money = 0; EV.length = 0;');
for (let k = 0; k < 20; k++) {
run('{ const dk = dock(); for (let y = dk.y - 30; y < dk.y - 10; y++) for (let x = dk.x + 2; x < dk.x + 22; x++) if (mat[y*W+x] === EMPTY) setCell(y*W+x, CUORE, AMB[y], 0); }');
ticks(run, 30);
}
ticks(run, 240);
const sold = run('money');
check('8,000 grains poured in over ten seconds all sell, nothing piles up', sold > 2500 && interior() === 0, `$${sold.toFixed(0)}, price now ${(run('priceFactor(CUORE)') * 100).toFixed(0)}%`);
// hot goods wait until they cool
run('{ const dk = dock(); for (let x = dk.x + 2; x < dk.x + 22; x++) setCell((dk.y + 13)*W + x, CUORE, 600, 0); }');
ticks(run, 3);
check('hot goods are not sold until they cool', interior() >= 15, interior());
run('{ const dk = dock(); for (let y = dk.y; y < dk.y + 14; y++) for (let x = dk.x + 2; x < dk.x + 22; x++) setCell(y*W+x, EMPTY, AMB[y], 0); }');
// orders: goods fill a matching order first and the order pays a lump sum
run('mkRecent.fill(0); money = 0; earned = 0; orders = []; ordersDone = 0; EV.length = 0;');
run('orders.push({ id: 1, m: BARCU, qty: 3, got: 0, mult: 2, pay: 240, due: tick + 36000, mins: 10 })');
run('{ const dk = dock(); spawnBar(BARCU, dk.x + 6, dk.y + 2, 0, 0); spawnBar(BARCU, dk.x + 10, dk.y + 2, 0, 0); }');
ticks(run, 90);
check('two bars count toward the order and are not paid yet', run('orders[0].got') === 2 && run('money') === 0, `got ${run('orders[0].got')}, money ${run('money')}`);
run('{ const dk = dock(); spawnBar(BARCU, dk.x + 6, dk.y + 2, 0, 0); spawnBar(BARCU, dk.x + 14, dk.y + 2, 0, 0); }');
ticks(run, 90);
check('the third bar fills it: the lump sum is paid, and the fourth sells at market', run('orders.length') === 0 && run('ordersDone') === 1 && Math.abs(run('money') - 280) < 1, `money ${run('money').toFixed(1)}`);
check('the order-filled event carries its pay, and no truck comes', run('EV.some(e => e.t === "order_done" && e.pay === 240)') && run('typeof truck') === 'undefined');
// an expired order costs nothing: what was delivered is paid at market
run('money = 0; mkRecent.fill(0); orders.push({ id: 2, m: CUORE, qty: 500, got: 0, mult: 2, pay: 1000, due: tick + 400, mins: 1 })');
run('{ const dk = dock(); for (let y = dk.y + 2; y < dk.y + 4; y++) for (let x = dk.x + 2; x < dk.x + 22; x++) setCell(y*W+x, CUORE, AMB[y], 0); }');
ticks(run, 90);
check('ore counts toward its order', run('orders[0].got') === 40 && run('money') === 0, run('orders[0].got'));
ticks(run, 400);
check('when the order runs out, the delivered ore is paid at market', run('orders.length') === 0 && Math.abs(run('money') - 40) < 1 && run('EV.some(e => e.t === "order_expired" && e.got === 40)'), `money ${run('money').toFixed(2)}`);
// buyers post orders for goods you have seen, and they pay well over market
run('orders = []; ordersDone = 0; seen.fill(0); seen[CUORE] = 1; seen[BARCU] = 1; seen[COAL] = 1; nextOrderT = 0;');
ticks(run, 120);
const o1 = JSON.parse(run('JSON.stringify(orders[0] || null)'));
check('a buyer posts an order for something you have found', o1 && [run('CUORE'), run('BARCU'), run('COAL')].includes(o1.m) && o1.pay >= o1.qty * run(`PRICE[${o1.m}]`) * 1.5, JSON.stringify(o1));
let sizes = [];
for (const done of [0, 20]) { run(`ordersDone = ${done}; orders = [];`); let t = 0; for (let k = 0; k < 40; k++) t += run('(() => { const o = makeOrder(); return o ? o.qty * PRICE[o.m] : 0; })()'); sizes.push(t / 40); }
check('orders grow as your standing grows', sizes[1] > sizes[0] * 3 && run('STANDING[standing()]') === 'Export houses', sizes.map(v => v.toFixed(0)).join(' -> '));
run('ordersDone = 0; orders = []; nextOrderT = 0;');
run('for (let k = 0; k < 300; k++) { tick += 1800; ordersTick(); }');
check('there are never more than three open orders', run('orders.length') <= 3 && run('orders.length') >= 1, run('orders.length'));
// the market dips when one thing is sold in bulk, then recovers
run('mkRecent.fill(0)');
const p0 = run('priceNow(BARCU)');
run('for (let k = 0; k < 100; k++) sellUnit(BARCU)');
const p1 = run('priceNow(BARCU)');
run('for (let k = 0; k < 4 * 480; k++) marketTick()'); // four half-lives: 32 minutes
const p2 = run('priceNow(BARCU)');
check('selling 100 copper bars lowers their price, which recovers within half an hour', p1 < p0 * 0.5 && p2 > p0 * 0.9, `${p0} -> ${p1.toFixed(1)} -> ${p2.toFixed(1)}`);
check('dirt earns nothing, and rubble only a pittance (builders pay a few times that)', run('PRICE[LDIRT]') === 0 && run('PRICE[RSTONE]') > 0 && run('PRICE[RSTONE]') < 0.05);
check('refined goods pay more than the raw ore in them', run('PRICE[BARCU] > 13 * PRICE[CUORE] + 13 * PRICE[COAL]') && run('PRICE[BARBZ] > 2 * PRICE[BARCU]'));
// machines cost money, walls do not
flatWorld(run, 240, 160, 100);
run('money = 30; bag[RSTONE] = 100; bagTotal = 100; bot.x = 110; bot.y = 70;');
const r1 = run('JSON.stringify(placeBlocks(BP_BELT, 1, blockLine(28, 20, 31, 20)))');
check('belts cost money: $30 buys two', JSON.parse(r1).n === 2 && run('money') === 6, r1 + ' money ' + run('money'));
check('wall blocks still cost material, not money', run('placeBlock(BP_WALL, RSTONE, 26, 22, false)') === '' && run('money') === 6);
run('removePieceAt(113, 81)');
check('taking a belt back refunds its price', run('money') === 18, run('money'));
// paying the claim
run('money = 25000; debt = 20000;');
run('payClaim(5000)');
check('paying part of the claim', run('debt') === 15000 && run('money') === 20000);
run('payClaim(1e9); checkLedger();');
check('paying it off marks the milestone', run('debt') === 0 && run('!!ledger.claim') && run('money') === 5000);
// alloys: tin into molten copper becomes bronze; zinc makes brass
flatWorld(run, 240, 160, 100);
rect(run, 100, 80, 101, 99, run('BRICK')); rect(run, 140, 80, 141, 99, run('BRICK'));
run('for (let y = 92; y <= 99; y++) for (let x = 102; x <= 139; x++) setCell(y*W+x, MCU, 1200, 0)');
run('for (let y = 86; y <= 87; y++) for (let x = 110; x <= 131; x++) setCell(y*W+x, MSN, 1100, 0)');
run('for (let y=80;y<100;y++) for (let x=100;x<142;x++) wakeHeatXY(x,y)');
for (let k = 0; k < 20; k++) { run('for (let y=80;y<100;y++) for (let x=100;x<142;x++) { const i=y*W+x; if (CASTMETAL[mat[i]] && temp[i] < 1150) temp[i] = 1150; }'); ticks(run, 15); }
const bz = count(run, run('MBZ')), sn = count(run, run('MSN')), cu = count(run, run('MCU'));
check('tin melted into copper mixes into bronze, roughly 1 part tin to 7 copper', bz > 200 && sn <= 12, `bronze ${bz}, tin left ${sn}, copper left ${cu}`);
run('for (let y = 92; y <= 99; y++) for (let x = 102; x <= 139; x++) setCell(y*W+x, MCU, 1200, 0)');
run('for (let y = 86; y <= 87; y++) for (let x = 110; x <= 131; x++) setCell(y*W+x, MZN, 1100, 0)');
for (let k = 0; k < 20; k++) { run('for (let y=80;y<100;y++) for (let x=100;x<142;x++) { const i=y*W+x; if (CASTMETAL[mat[i]] && temp[i] < 1150) temp[i] = 1150; }'); ticks(run, 15); }
check('zinc melted into copper becomes brass', count(run, run('MBS')) > 60, count(run, run('MBS')));
// a bronze ingot casts into bronze bars
flatWorld(run, 240, 160, 100);
run('bag[RSTONE] = 100; bagTotal = 100; placeMoldAt("ingot", 30, 22, RSTONE)');
run('for (const i of molds[0].cavs[0]) setCell(i, MBZ, 1000, 0)');
ticks(run, 900);
run('collectMold(molds[0])');
check('bronze casts into bronze bars', run('ents.length') === 3 && run('ents.every(e => e.m === BARBZ)'), run('JSON.stringify(ents.map(e => MD[e.m].name))'));
// soft metals are no good for a drill bit
run('placeMoldAt("bit", 20, 22, RSTONE)');
run('for (const i of molds[1].cavs[0]) setCell(i, MAU, 1100, 0)');
ticks(run, 1200);
check('a gold casting is too soft for a drill bit', /too soft/.test(run('collectMold(molds[1])')) && run('player.bitTier') === 0);
// save and load keeps the economy
run('generateWorld(778, 768, 512, null); money = 1234; debt = 5000; earned = 9999; mkRecent[BARCU] = 777; ordersDone = 6;');
run('orders = [{ id: 9, m: BARCU, qty: 10, got: 4, mult: 2.1, pay: 840, due: tick + 9000, mins: 3 }];');
run('globalThis.__s = serializeWorld(); orders = []; ordersDone = 0; deserializeWorld(__s.meta, __s.buffers);');
check('save and load keep money, debt, earnings, market, orders and the dock', run('money') === 1234 && run('debt') === 5000 && run('earned') === 9999 && run('mkRecent[BARCU]') === 777 && run('!!dock()') && run('ordersDone') === 6 && run('orders.length === 1 && orders[0].got === 4 && orders[0].pay === 840'));
done();
+24
View File
@@ -0,0 +1,24 @@
// The starter furnaces keep working when fired again and again without cleaning, however
// the charge goes in: mixed, ore first with coal on top, or blown in through the vent.
// Each case plays furnace_play.js, which builds the design and fires it like a player.
const { execFileSync } = require('child_process');
const { suite } = require('./harness');
const { check, done } = suite();
function play(design, ore, coal, firings, roll, mode) {
const out = execFileSync('node', [__dirname + '/furnace_play.js', design, ore, coal, firings, roll, mode].map(String), { encoding: 'utf8', timeout: 600000 });
const per = [...out.matchAll(/firing \d+: (.*)\| bars (\d+) \| left in furnace: ore (\d+), coal (\d+), slag (\d+), ash (\d+)/g)]
.map(r => ({ bit: /New drill bit/.test(r[1]), bars: +r[2], ore: +r[3], coal: +r[4], slag: +r[5], ash: +r[6] }));
return per;
}
for (const mode of ['mix', 'layers', 'vent']) {
const runs = [0, 1].map(k => play('smelter1', 50, 50, 6, k, mode));
const bars = runs.map(r => r.reduce((s, f) => s + f.bars, 0));
const last = runs.map(r => r[r.length - 1]);
check(`copper smelter, ${mode}: about 3 bars every firing for 6 firings`, bars.every(b => b >= 16), bars.join(', ') + ' of 18');
check(` and the furnace stays clean`, last.every(f => f.ore < 30 && f.slag + f.ash < 10), last.map(f => `ore ${f.ore} slag ${f.slag} ash ${f.ash}`).join('; '));
}
for (const mode of ['mix', 'layers', 'vent']) {
const got = [0, 1, 2].map(k => play('bitfurnace', 70, 70, 2, k, mode).filter(f => f.bit).length);
check(`bit furnace, ${mode}: makes a bit every firing`, got.reduce((s, n) => s + n, 0) >= 5, got.join(', ') + ' of 2 each');
}
done();
+114
View File
@@ -0,0 +1,114 @@
// Molds and furnaces stay usable: knock-outs leave the mold clean, plugs over the mold are
// cleared, spoiled castings can be knocked out, grains never drop through a grate by teleporting,
// and slag lets the fire breathe.
const { load, suite, flatWorld, rect, ticks, count } = require('./harness');
const { run } = load();
const { check, done } = suite();
function fresh() { flatWorld(run, 240, 160, 100); run('bot.x = 110; bot.y = 70; bag.fill(0); bagTotal = 0; for (const e of ents.slice()) removeEnt(e);'); }
const cavCount = (k, m) => run(`molds[${k}].cavs[0].filter(i => mat[i] === ${m}).length`);
// an ingot with 27 grains of copper: 3 bars, and the 3 left over go to the bag, not back in the trough
fresh();
run('bag[RSTONE] = 100; bagTotal = 100; placeMoldAt("ingot", 30, 22, RSTONE); bag[RSTONE] = 0; bagTotal = 0;');
run('{ const c = molds[0].cavs[0].slice().sort((a, b) => b - a); for (let k = 0; k < 27; k++) { setCell(c[k], COPPER, 30, F_CAST); } } checkMolds();');
let msg = run('collectMold(molds[0])');
check('knocking out an ingot gives the bars', run('ents.length') === 3 && run('ents.every(e => e.m === BARCU)'), msg);
check('and leaves the trough empty, the scrap in your bag', run('molds[0].cavs[0].every(i => mat[i] === EMPTY)') && run('bag[SCRCU]') === 3, `scrap in bag ${run('bag[SCRCU]')}`);
// a plate of copper set over the trough, and drips frozen in the grate above: knocked loose too
fresh();
run('bag[RSTONE] = 100; bagTotal = 100; placeMoldAt("ingot", 30, 22, RSTONE); bag[RSTONE] = 0; bagTotal = 0;');
run('for (let bx = 30; bx < 34; bx++) placeBlock(BP_GRATE, 0, bx, 21, true);');
run('{ const mo = molds[0]; for (let x = mo.x + 1; x < mo.x + 15; x++) setCell(mo.y * W + x, COPPER, 30, F_CAST); for (let x = mo.x + 2; x < mo.x + 10; x++) setCell((mo.y - 1) * W + x, COPPER, 30, F_CAST); } checkMolds();');
check('a mold with only a plate over it reads as empty', run('molds[0].state') === 'empty');
msg = run('collectMold(molds[0])');
check('pressing F knocks the plate and the frozen drips loose', run(`(() => { const mo = molds[0]; for (let y = mo.y - 4; y <= mo.y; y++) for (let x = mo.x; x < mo.x + 16; x++) if (mat[y*W+x] === COPPER) return false; return true; })()`) && run('bag[SCRCU]') === 22, `${msg} | scrap ${run('bag[SCRCU]')}`);
check('the grate itself is still there', run('part[(molds[0].y - 1) * W + molds[0].x + 3]') === run('P_GRATE'));
// slag in an ingot is skimmed off; the bars still come out
fresh();
run('bag[RSTONE] = 100; bagTotal = 100; placeMoldAt("ingot", 30, 22, RSTONE); bag[RSTONE] = 0; bagTotal = 0;');
run('{ const c = molds[0].cavs[0].slice().sort((a, b) => b - a); for (let k = 0; k < 16; k++) setCell(c[k], COPPER, 30, F_CAST); for (let k = 16; k < 20; k++) setCell(c[k], SLAG, 30, 0); } checkMolds();');
check('an ingot with slag on top is still ready', run('molds[0].state') === 'ready', run('molds[0].state'));
msg = run('collectMold(molds[0])');
check('the slag is chipped off and thrown away, and two bars come out', run('ents.length') === 2 && run('bag[SLAG]') === 0 && /chipped off/.test(msg) && run('molds[0].cavs[0].every(i => mat[i] === EMPTY)'), msg);
// a bit casting with slag in it is spoiled, but F knocks it out instead of asking you to drill
fresh();
run('bag[RSTONE] = 200; bagTotal = 200; placeMoldAt("bit", 30, 20, RSTONE); bag[RSTONE] = 0; bagTotal = 0;');
run('{ const c = molds[0].cavs[0]; c.forEach((i, k) => setCell(i, k === 3 ? SLAG : COPPER, 30, F_CAST)); } checkMolds();');
check('a bit with slag in it is spoiled', run('molds[0].state') === 'flawed', run('molds[0].flaw'));
msg = run('collectMold(molds[0])');
check('F knocks the spoiled bit out as scrap and the mold is ready again', run('molds[0].cavs[0].every(i => mat[i] === EMPTY)') && run('bag[SCRCU]') === 29 && run('bag[SLAG]') === 0 && run('player.bitTier') === 0, msg);
// a blown grain with nowhere to land never ends up below a grate
fresh();
run('for (let bx = 25; bx < 30; bx++) placeBlock(BP_GRATE, 0, bx, 22, true);'); // grate at y 88..91
rect(run, 70, 66, 149, 87, run('SAND')); // the chamber above the grate is packed full up to y 66
const placed = run('depositNear(CUORE, 20, 108, 86)');
check('a grain that cannot land nearby goes up, never through the grate', placed && count(run, run('CUORE'), 0, 66, 239, 159) === 0 && count(run, run('CUORE'), 0, 0, 239, 65) === 1, `below the pile ${count(run, run('CUORE'), 0, 66, 239, 159)}, above it ${count(run, run('CUORE'), 0, 0, 239, 65)}`);
// slag lets the fire breathe: coal buried under slag still finds air
fresh();
rect(run, 100, 95, 110, 99, run('COAL')); rect(run, 100, 92, 110, 94, run('SLAG'));
check('coal under a layer of slag still gets air', run('hasO2(94 * 0 + 95 * W + 105, 105, 95)'));
rect(run, 100, 92, 110, 94, run('RSTONE'));
check('but not under rubble', !run('hasO2(95 * W + 105, 105, 95)'));
// molten slag trickles down through a charge but is too thick for a grate; molten copper runs through
fresh();
run('for (let bx = 25; bx < 30; bx++) placeBlock(BP_GRATE, 0, bx, 23, true);'); // grate at y 92..95
rect(run, 98, 70, 99, 99, run('STONE')); rect(run, 120, 70, 121, 99, run('STONE'));
run('for (let x = 104; x < 112; x++) { setCell(80 * W + x, MSLAG, 1300, 0); life[80 * W + x] = 255; setCell(81 * W + x, MCU, 1300, 0); life[81 * W + x] = 255; } for (let y = 84; y < 92; y++) for (let x = 100; x < 116; x++) setCell(y * W + x, CUORE, 1300, 0);');
run('for (let y = 76; y < 100; y++) for (let x = 100; x < 116; x++) temp[y * W + x] = 1300;');
ticks(run, 240);
const below = m => run(`(()=>{let n=0;for(let y=96;y<100;y++)for(let x=96;x<120;x++)if(mat[y*W+x]===${m})n++;return n})()`);
const sunk = run('(()=>{let n=0;for(let y=84;y<96;y++)for(let x=96;x<120;x++)if(mat[y*W+x]===MSLAG||mat[y*W+x]===SLAG)n++;return n})()');
check('molten slag sinks down through the ore and stops on the grate', sunk >= 6 && below(run('MSLAG')) + below(run('SLAG')) === 0, `slag in the charge ${sunk}, under the grate ${below(run('MSLAG')) + below(run('SLAG'))}`);
check('molten copper runs through the grate', below(run('MCU')) + below(run('COPPER')) >= 6, below(run('MCU')) + below(run('COPPER')));
// ash fuses into molten slag in a hot fire, but not in a wood fire's heat
fresh();
run('for (let x = 100; x < 104; x++) setCell(98 * W + x, ASH, 1320, 0); for (let x = 110; x < 114; x++) setCell(98 * W + x, ASH, 900, 0);');
run('for (let k = 0; k < 120; k++) { for (let y = 90; y < 100; y++) for (let x = 96; x < 108; x++) if (mat[y * W + x] === ASH) temp[y * W + x] = Math.max(temp[y * W + x], 1320); simTick(); }');
check('ash fuses into slag above 1250°C', count(run, run('ASH'), 96, 90, 107, 99) === 0 && count(run, run('MSLAG')) + count(run, run('SLAG')) === 4, `ash ${count(run, run('ASH'), 96, 90, 107, 99)}, slag ${count(run, run('MSLAG')) + count(run, run('SLAG'))}`);
check('ash in a cooler fire stays ash', count(run, run('ASH'), 108, 90, 116, 99) === 4);
// a mixed charge stays mixed until it burns; ore then works its way down through the burning bed
fresh();
rect(run, 100, 90, 101, 99, run('STONE')); rect(run, 114, 90, 115, 99, run('STONE'));
run('{ const r = mulberry32(3); for (let y = 90; y <= 99; y++) for (let x = 102; x <= 113; x++) setCell(y * W + x, r() < 0.5 ? CUORE : COAL, AMB[y], 0); }');
const oreY = () => run('(()=>{let s=0,n=0;for(let i=0;i<N;i++) if(mat[i]===CUORE){s+=(i/W)|0;n++;} return s/n})()');
const y0 = oreY(); ticks(run, 3600); const y1 = oreY();
check('ore does not sift down through unlit coal', Math.abs(y1 - y0) < 0.3, `${y0.toFixed(2)} -> ${y1.toFixed(2)}`);
run('for (let y = 90; y <= 99; y++) for (let x = 102; x <= 113; x++) { const i = y * W + x; if (mat[i] === COAL) { flags[i] |= F_BURN; life[i] = 250; temp[i] = 900; } wakeXY(x, y); }');
run('for (let k = 0; k < 900; k++) { if (k % 8 === 0) wakeXY(107, 95); simTick(); }'); const y2 = oreY();
check('but sinks into a burning bed', y2 > y1 + 0.3, `${y1.toFixed(2)} -> ${y2.toFixed(2)}`);
// knocking out the mold rakes slag and ash off the grate above it, and leaves the charge
fresh();
run('bag[RSTONE] = 100; bagTotal = 100; placeMoldAt("ingot", 30, 22, RSTONE); bag[RSTONE] = 0; bagTotal = 0;');
run('for (let bx = 30; bx < 34; bx++) placeBlock(BP_GRATE, 0, bx, 21, true); for (let bx = 29; bx < 35; bx++) placeBlock(BP_WALL, RSTONE, bx, 17, true);');
run('{ const mo = molds[0]; for (let x = mo.x; x < mo.x + 16; x++) { setCell((mo.y - 5) * W + x, SLAG, 30, 0); setCell((mo.y - 6) * W + x, x & 1 ? ASH : SLAG, 30, 0); setCell((mo.y - 7) * W + x, x & 1 ? CUORE : COAL, 30, 0); setCell(70 * W + x, SLAG, 30, 0); } const c = molds[0].cavs[0]; for (let k = 0; k < 16; k++) setCell(c[k], COPPER, 30, F_CAST); } checkMolds();');
msg = run('collectMold(molds[0])');
check('F rakes the slag and ash off the grate', /raked/.test(msg) && count(run, run('SLAG'), 120, 72, 136, 88) + count(run, run('ASH'), 120, 72, 136, 88) === 0 && run('bag[SLAG] + bag[ASH]') === 0, msg);
check('but leaves the unburnt charge, and what is outside the furnace roof', count(run, run('CUORE'), 120, 72, 136, 88) + count(run, run('COAL'), 120, 72, 136, 88) === 16 && count(run, run('SLAG'), 120, 70, 136, 70) === 16);
// slag floating on top of a part-filled bit is not a flaw: fire again, or knock it out
fresh();
run('bag[RSTONE] = 200; bagTotal = 200; placeMoldAt("bit", 30, 20, RSTONE); bag[RSTONE] = 0; bagTotal = 0;');
run('{ const c = molds[0].cavs[0].slice().sort((a, b) => b - a); c.forEach((i, k) => setCell(i, k < 24 ? COPPER : SLAG, 30, k < 24 ? F_CAST : 0)); } checkMolds();');
check('a bit with slag floating on top reads as not full, not spoiled', run('molds[0].state') === 'short' && /floating/.test(run('molds[0].flaw')), run('molds[0].flaw'));
msg = run('collectMold(molds[0])');
check('the first F press on an unfinished bit only asks', run('molds[0].cavs[0].filter(i => mat[i] === COPPER).length') === 24 && /again/.test(msg), msg);
msg = run('collectMold(molds[0])');
check('a second press knocks it out as scrap', run('molds[0].cavs[0].every(i => mat[i] === EMPTY)') && run('bag[SCRCU]') === 24 && run('player.bitTier') === 0, msg);
// the igniter's flame reaches coal just under a thin layer of ore, when air gets to it from below
fresh();
run('for (let bx = 25; bx < 30; bx++) placeBlock(BP_GRATE, 0, bx, 23, true);');
rect(run, 100, 90, 119, 91, run('COAL')); rect(run, 100, 88, 119, 89, run('CUORE'));
run('for (let k = 0; k < 60; k++) { toolIgnite(110, 88); simTick(); }');
ticks(run, 120);
check('the igniter lights coal under two rows of ore over a grate', run('(()=>{for(let i=0;i<N;i++) if(flags[i]&F_BURN) return true; return false})()'));
check('and says when there is nothing to burn', run('toolIgnite(60, 60).fuel') === false);
// iron with some steel in it casts as iron; copper mixed with iron spoils the casting
fresh();
run('bag[RBRICK] = 200; bagTotal = 200; placeMoldAt("bit", 30, 20, RBRICK); bag[RBRICK] = 0; bagTotal = 0; player.bitTier = 1;');
run('{ const c = molds[0].cavs[0]; c.forEach((i, k) => setCell(i, k % 5 === 0 ? STEEL : IRON, 30, F_CAST)); } checkMolds();');
check('a bit of iron with a little steel in it is a good iron bit', run('molds[0].state') === 'ready' && run('molds[0].metal') === run('IRON'), run('molds[0].state + " " + molds[0].flaw'));
msg = run('collectMold(molds[0])');
check('and knocks out as an iron bit', run('player.bitTier') === 2, msg);
run('{ const c = molds[0].cavs[0]; c.forEach((i, k) => setCell(i, k % 5 === 0 ? COPPER : IRON, 30, F_CAST)); } checkMolds();');
check('copper mixed with iron still spoils it', run('molds[0].state') === 'flawed', run('molds[0].flaw'));
done();
+101
View File
@@ -0,0 +1,101 @@
// Pipes, pumps and outlets move liquids; the Refiner grinds waste rock for trace ore.
const { load, suite, flatWorld, count } = require('./harness');
const { run } = load();
const { check, done } = suite();
const box = (x0, y0, x1, y1, m, fl = 'F_NAT') => run(`for (let y = ${y0}; y <= ${y1}; y++) for (let x = ${x0}; x <= ${x1}; x++) setCell(y * W + x, ${m}, AMB[y], ${fl})`);
const lineWater = () => run('pipeNets.reduce((a, n) => a + (n.m === WATER ? n.n : 0), 0)');
const totalWater = () => count(run, run('WATER')) + lineWater();
// two stone basins; a pump in the bottom corner of the left one pushes water up a pipe, over the
// wall and out of an outlet into the right one
function rig() {
flatWorld(run, 240, 160, 100);
run('money = 5000; bot.x = 110; bot.y = 50;');
box(36, 80, 39, 99, 'STONE'); box(72, 80, 75, 99, 'STONE'); // left basin walls
box(108, 80, 111, 99, 'STONE'); box(144, 80, 147, 99, 'STONE'); // right basin walls
box(40, 84, 71, 99, 'WATER', 0);
run(`placeBlock(BP_PUMP, 1, 17, 24, false); // pushes right, draws in from the left
for (let by = 19; by <= 23; by++) placeBlock(BP_PIPE, 0, 17, by, false);
for (let bx = 18; bx <= 28; bx++) placeBlock(BP_PIPE, 0, bx, 19, false);
placeBlock(BP_OUTLET, 0, 29, 19, false); // pours down`);
}
rig();
const w0 = totalWater(), ww0 = run('worldWater()');
run('for (let k = 0; k < 900; k++) simTick();');
const right = count(run, run('WATER'), 112, 60, 143, 99);
check('a pump, a pipe over the wall and an outlet move water from one basin to the other', right > 250, `${right} grains in the right basin, ${lineWater()} in the pipes`);
check('no water is lost or made on the way', totalWater() === w0, `${w0} -> ${totalWater()}`);
check('the weather counts water in pipes, so it sends no rain to make up for it', run('worldWater()') === ww0, `${ww0} -> ${run('worldWater()')}`);
// the line is one network: pump, 16 pipes, outlet
check('touching pipes, pump and outlet make one line', run('(rebuildPipes(), pipeNets.length)') === 1 && run('pipeNets[0].blocks.length') === 18, `${run('pipeNets.length')} lines, ${run('pipeNets[0].blocks.length')} blocks`);
// an outlet closed with F holds the water in the line; the pump stops when the line is full
rig();
run('interactAt(29 * 4 + 1, 19 * 4 + 1); for (let k = 0; k < 900; k++) simTick();');
check('with the outlet closed the line fills up and nothing comes out', count(run, run('WATER'), 112, 60, 143, 99) === 0 && lineWater() === 18 * 16, `${lineWater()} in the line`);
run('interactAt(29 * 4 + 1, 19 * 4 + 1); for (let k = 0; k < 120; k++) simTick();');
check('opened again, it pours', count(run, run('WATER'), 112, 60, 143, 99) > 50);
// taking a pipe back spills its share where it was
rig();
run('interactAt(29 * 4 + 1, 19 * 4 + 1); for (let k = 0; k < 900; k++) simTick();');
const before = totalWater();
run('removeBlock(23, 19); rebuildPipes();');
check('taking a full pipe back spills its 16 grains of water there', totalWater() === before && count(run, run('WATER'), 88, 70, 99, 99) > 0, `${before} -> ${totalWater()}`);
check('and the line is cut in two', run('pipeNets.length') === 2, run('pipeNets.length'));
// one liquid per line, and nothing hotter than 500°C
rig();
run('interactAt(29 * 4 + 1, 19 * 4 + 1); for (let k = 0; k < 60; k++) simTick();'); // outlet closed: some water stays in the line
box(40, 84, 71, 99, 'EMPTY', 0); box(40, 90, 71, 99, 'MSN', 0); run('for (let y = 90; y < 100; y++) for (let x = 40; x < 72; x++) temp[y * W + x] = 300;');
run('for (let k = 0; k < 120; k++) simTick();');
check('a line holding water will not take molten tin', count(run, run('MSN')) + count(run, run('TIN')) === 32 * 10 && run('pipeNets[0].m') === run('WATER'), `${count(run, run('MSN')) + count(run, run('TIN'))} tin left, line holds ${run('MD[pipeNets[0].m].name')}`);
rig();
box(40, 84, 71, 99, 'EMPTY', 0); box(40, 90, 71, 99, 'MCU', 0); run('for (let y = 90; y < 100; y++) for (let x = 40; x < 72; x++) temp[y * W + x] = 1200;');
run('for (let k = 0; k < 60; k++) { for (let y = 90; y < 100; y++) for (let x = 40; x < 72; x++) if (mat[y * W + x] === MCU) temp[y * W + x] = 1200; simTick(); }');
check('a pump will not take molten copper at 1200°C', run('pipeNets[0].n') === 0, run('pipeNets[0].n'));
// what is in the pipes is saved
rig();
run('interactAt(29 * 4 + 1, 19 * 4 + 1); for (let k = 0; k < 400; k++) simTick();');
const held = lineWater();
run('(() => { const s = serializeWorld(); deserializeWorld(JSON.parse(JSON.stringify(s.meta)), s.buffers); })()');
check('the water in a line survives saving and loading', held > 100 && lineWater() === held, `${held} -> ${lineWater()}`);
// pipes are thin: water and sand pass through a pipe run instead of being held back by it
flatWorld(run, 240, 160, 100);
run('money = 5000; bot.x = 60; bot.y = 50;');
box(36, 76, 39, 99, 'STONE'); box(88, 76, 91, 99, 'STONE');
run('for (let by = 19; by <= 24; by++) placeBlock(BP_PIPE, 0, 15, by, false);'); // a pipe wall at x 60..63
box(40, 80, 59, 99, 'WATER', 0);
run('for (let k = 0; k < 400; k++) simTick();');
check('water flows through a run of pipe', count(run, run('WATER'), 64, 70, 87, 99) > 60, `${count(run, run('WATER'), 64, 70, 87, 99)} grains got past`);
box(60, 64, 63, 70, 'SAND', 0); run('for (let k = 0; k < 300; k++) simTick();');
check('and sand dropped on a pipe falls down through it', count(run, run('SAND'), 40, 80, 87, 99) > 20, `${count(run, run('SAND'), 40, 80, 87, 99)} of 28 at the bottom`);
// ---- the Refiner
function refinerRig(feed, n) {
flatWorld(run, 240, 160, 100);
run(`money = 5000; bot.x = 110; bot.y = 40; stats.refined = 0; stats.refinedOre = 0; placeMachineAt('refiner', 0, 26, 15);`); // x 104..115, y 60..71; chute out at y 72
run(`{ let k = 0; for (let y = 60; y < 70 && k < ${n}; y++) for (let x = 106; x < 114 && k < ${n}; x++, k++) setCell(y * W + x, ${feed}, AMB[y], 0); }`);
}
refinerRig('RSTONE', 80);
const ore = () => count(run, run('CUORE')) + count(run, run('COAL')) + count(run, run('FEORE')) + count(run, run('SNORE'));
// five hopper-loads, each ground before the next goes in
for (let k = 0; k < 5; k++) { run('for (let k = 0; k < 300; k++) simTick();'); if (k < 4) run('for (let y = 60; y < 70; y++) for (let x = 106; x < 114; x++) setCell(y * W + x, RSTONE, AMB[y], 0);'); }
const fed = 400, sand = count(run, run('SAND')), o = ore();
check('the Refiner grinds all the stone rubble it is fed', count(run, run('RSTONE')) === 0 && run('stats.refined') === fed, `${count(run, run('RSTONE'))} left, ${run('stats.refined')} of ${fed} ground`);
check('about half as much sand comes out', sand > fed * 0.35 && sand < fed * 0.6, `${sand} sand from ${fed} rubble`);
check('with the odd grain of common ore', o > 0 && o < fed * 0.15, `${o} ore grains`);
// gravel, sand and dirt are refined too; ore drops straight through
refinerRig('LDIRT', 60); run('for (let k = 0; k < 600; k++) simTick();');
check('dirt goes through the refiner too', count(run, run('LDIRT')) === 0 && run('stats.refined') === 60);
refinerRig('CUORE', 30); run('for (let k = 0; k < 400; k++) simTick();');
check('ore it cannot grind drops straight through, all of it', count(run, run('CUORE')) === 30 && count(run, run('CUORE'), 104, 60, 115, 71) === 0, count(run, run('CUORE'), 104, 60, 115, 71));
// a blocked chute stops it
refinerRig('RSTONE', 40);
box(100, 72, 120, 73, 'STONE');
run('for (let k = 0; k < 200; k++) simTick();');
check('with the space under its chute blocked it stops, and says so', count(run, run('RSTONE')) === 40 && run("machines.find(m => m && m.type === 'refiner').blocked") === true);
done();
+47
View File
@@ -0,0 +1,47 @@
"""The smooth look: the WebGL pass compiles, draws, falls back cleanly, and the thermal lens still works."""
import sys, pathlib
from playwright.sync_api import sync_playwright
root = pathlib.Path(__file__).resolve().parent.parent
out = pathlib.Path(sys.argv[1] if len(sys.argv) > 1 else root / 'shots' / 'smooth'); out.mkdir(parents=True, exist_ok=True)
fails = []
def check(name, cond, extra=''):
print(('PASS ' if cond else 'FAIL ') + name + (f' [{extra}]' if extra != '' else ''))
if not cond: fails.append(name)
with sync_playwright() as p:
b = p.chromium.launch(args=['--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'])
pg = b.new_page(viewport={'width': 1024, 'height': 640})
logs = []
pg.on('pageerror', lambda e: logs.append(f'PAGEERROR: {e}'))
pg.on('console', lambda m: logs.append(f'{m.type}: {m.text}') if m.type in ('error', 'warning') else None)
pg.add_init_script('window.__allowSoftwareGL = true; localStorage.clear();')
pg.goto((root / 'dist' / 'index.html').as_uri()); pg.wait_for_timeout(300)
pg.click('#btn-new'); pg.fill('#nw-seed', '777'); pg.click('#btn-generate')
pg.wait_for_function('GAME.running === true', timeout=20000); pg.wait_for_timeout(1500)
ev = pg.evaluate
check('the smooth look is the default and is drawing', ev("SET.look === 'smooth' && SM.ok && R.smoothOn"), ev('[SET.look, SM.ok, SM.why, R.smoothOn]'))
# the canvas shows the GPU image: sample a sky pixel and a ground pixel, they must differ and not be black
px = ev("""(() => { const c = R.ctx.getImageData(R.canvas.width >> 1, 4, 1, 1).data, g = worldToScreenPx(bot.x + 2, surfY[Math.floor(bot.x)] + 6), d = R.ctx.getImageData(Math.round(g.x), Math.round(g.y), 1, 1).data; return [Array.from(c), Array.from(d)]; })()""")
check('it draws sky and ground', sum(px[0][:3]) > 200 and px[0][:3] != px[1][:3], px)
pg.screenshot(path=str(out / 'smooth.png'))
ev("player.lens = true"); pg.keyboard.press('h'); pg.wait_for_timeout(1500)
check('the thermal lens draws in pixels', ev('R.lens && !R.smoothOn'))
pg.keyboard.press('h'); pg.wait_for_timeout(1500)
check('and the smooth look comes back after it', ev('!R.lens && R.smoothOn'), ev('[R.lens, R.smoothOn, SET.look, SM.ok, SM.why, SM.ms, SM.scale]'))
ev("SET.look = 'pixel'"); pg.wait_for_timeout(1500)
check('the pixel look can be chosen', ev('!R.smoothOn'))
pg.screenshot(path=str(out / 'pixel.png'))
ev("SET.look = 'smooth'"); pg.wait_for_timeout(1500)
ev("SM.scale = 0.5"); pg.wait_for_timeout(1500)
st = ev('[R.smoothOn, SM.scale, SM.canvas.width, R.canvas.width, SET.look, SM.ok, SM.why]'); check('half-resolution smooth still draws', (st[0] and st[2] == round(st[3] * st[1])) or st[1] > 0.5, st)
# a browser without hardware drawing gets the pixel look and a note
pg2 = b.new_page(viewport={'width': 800, 'height': 500})
pg2.add_init_script('localStorage.clear();')
pg2.goto((root / 'dist' / 'index.html').as_uri()); pg2.wait_for_timeout(300)
pg2.click('#btn-new'); pg2.fill('#nw-seed', '5'); pg2.click('#btn-generate')
pg2.wait_for_function('GAME.running === true', timeout=20000); pg2.wait_for_timeout(1200)
check('software rendering falls back to pixels with a note', pg2.evaluate("SET.look === 'pixel' && !R.smoothOn && SM.why === 'software'") and pg2.locator('.toast', has_text='graphics card').count() >= 1, pg2.evaluate('[SET.look, SM.why]'))
errs = [l for l in logs if 'PAGEERROR' in l or l.startswith('error')]
check('no errors', not errs, errs[:3])
b.close()
print('FAILS:', fails)
sys.exit(1 if fails else 0)
+77
View File
@@ -0,0 +1,77 @@
// Uses for waste rock and sand: glass bricks from a fan furnace, the stone cutter, the silo and
// builders' orders.
const { load, suite, flatWorld, count } = require('./harness');
const { run } = load();
const { check, done } = suite();
const bars = (m) => run(`ents.filter(e => e.m === ${m}).length`);
// ---- glass: a brick furnace over an ingot mold, a fan blowing in through a side grate,
// charged with sand and coal
flatWorld(run, 240, 160, 100);
run('money = 9999; bot.x = 60; bot.y = 40;');
const bp = JSON.parse(run("JSON.stringify(smelterBlueprint('ingot', 1, 3, RBRICK))"));
const ox = 24, oy = 25 - bp.bh;
run(`(() => { const bp = ${JSON.stringify(bp)};
for (const [dx, dy, code, v] of bp.blocks) placeBlock(code, v, ${ox} + dx, ${oy} + dy, true);
for (const [k, dx, dy, wl] of bp.molds) addMold(k, ${ox} + dx, ${oy} + dy, wl, true);
// a grate low in the left wall, and a fan a block out from it blowing in
removeBlock(${ox}, ${oy} + 3); placeBlock(BP_GRATE, 0, ${ox}, ${oy} + 3, true);
addMachine('fan', ${ox} - 2, ${oy} + 3, 0);
})()`);
const cx0 = (ox + 1) * 4, cx1 = (ox + bp.bw - 1) * 4 - 1, cy0 = (oy + 1) * 4, cy1 = (oy + 4) * 4 - 1;
run(`{ const r = mulberry32(9); for (let y = ${cy0 + 5}; y <= ${cy1}; y++) for (let x = ${cx0}; x <= ${cx1}; x++) if (mat[y * W + x] === EMPTY) setCell(y * W + x, r() < 0.5 ? SAND : COAL, AMB[y], 0); }`);
const sand0 = count(run, run('SAND'));
run(`for (let k = 0; k < 60; k++) { toolIgnite(${cx0 + 3}, ${cy0 + 5}); toolIgnite(${cx1 - 3}, ${cy0 + 5}); simTick(); }`);
let peak = 0;
for (let t = 0; t < 6; t++) { run('for (let k = 0; k < 600; k++) simTick();'); peak = Math.max(peak, run(`(() => { let p = 0; for (let y = ${cy0}; y <= ${cy1}; y++) for (let x = ${cx0}; x <= ${cx1}; x++) p = Math.max(p, temp[y * W + x]); return Math.round(p); })()`)); }
const st = run('molds[0].state'), glassIn = run('molds[0].cavs.flat().filter(i => mat[i] === GLASS).length');
check('a fan furnace melts the sand and the molten glass runs into the ingot mold', glassIn >= 8, `peak ${peak}°C, ${sand0} sand -> ${count(run, run('SAND'))}, ${glassIn} glass in the mold, state ${st}`);
const msg = run('collectMold(molds[0])');
check('knocking it out gives glass bricks', bars(run('BARGL')) >= 2, `${bars(run('BARGL'))} bricks: ${msg}`);
check('glass bricks sell for much more than the sand in them', run('PRICE[BARGL]') >= 8 * run('PRICE[SAND]') * 10, run('PRICE[BARGL]'));
check('glass in a bit mold is too brittle to make a bit', run("METAL_TIER[GLASS]") === 0);
// ---- the stone cutter: 8 rubble to a stone brick; other grains drop through
flatWorld(run, 240, 160, 100);
run("money = 9999; bot.x = 60; bot.y = 40; stats.bricks = 0; placeMachineAt('cutter', 0, 26, 18); for (let bx = 24; bx <= 34; bx++) placeBlock(BP_BELT, 1, bx, 21, true);"); // x 104..111, y 72..79; out at y 80, onto a belt
run('for (let y = 72; y < 77; y++) for (let x = 106; x < 110; x++) setCell(y * W + x, RSTONE, AMB[y], 0);'); // 20 grains
for (let k = 0; k < 4; k++) { run('for (let k = 0; k < 150; k++) simTick();'); run('for (let y = 72; y < 77; y++) for (let x = 106; x < 110; x++) if (mat[y * W + x] === EMPTY) setCell(y * W + x, RSTONE, AMB[y], 0);'); }
run('for (let k = 0; k < 300; k++) simTick();');
const fed = run('stats.bricks') * 8 + run("machines.find(m => m && m.type === 'cutter').cut");
check('the stone cutter turns every 8 rubble into a stone brick', bars(run('BARSB')) === run('stats.bricks') && run('stats.bricks') >= 8 && count(run, run('RSTONE'), 104, 72, 111, 79) === 0, `${bars(run('BARSB'))} bricks from ${fed} rubble`);
check('and they ride a belt away from under it', run('ents.filter(e => e.m === BARSB && e.ix > 120).length') >= 3, run('JSON.stringify(ents.filter(e => e.m === BARSB).map(e => e.ix))'));
check('stone bricks build cut stone walls, 2 a block', run('WALL_OF[BARSB]') === run('CUTST') && JSON.stringify(run('wallCost(BARSB, 16)')) === JSON.stringify([[run('BARSB'), 2]]));
check('cut stone stands more heat than rough stone', run('MELT[CUTST]') > run('MELT[STONE]'));
run('bag[BARSB] = 4; bagTotal = 4; placeBlock(BP_WALL, BARSB, 10, 20, false);');
const wallOk = count(run, run('CUTST')) === 16 && run('bag[BARSB]') === 2;
run('removeBlock(10, 20)');
check('a cut stone wall is laid from 2 bricks and gives them back', wallOk && run('bag[BARSB] + ents.filter(e => e.m === BARSB).length') >= 4 - 0, `${run('bag[BARSB]')} in the bag`);
// ---- the silo: one material in through the hatch, out of the gate when it is open
flatWorld(run, 240, 160, 100);
run("money = 9999; bot.x = 60; bot.y = 30; placeMachineAt('silo', 0, 26, 1);"); // x 104..115, y 4..27; gate at y 27
const silo = 'machines.find(m => m && m.type === "silo")';
run('for (let k = 0; k < 30; k++) { for (let x = 107; x < 113; x++) setCell(2 * W + x, RSTONE, AMB[2], 0); simTick(); }');
check('grains dropped in its hatch go into the silo', run(`${silo}.sn`) >= 150 && run(`${silo}.sm`) === run('RSTONE'), run(`${silo}.sn`));
run('for (let x = 107; x < 113; x++) setCell(2 * W + x, SAND, AMB[2], 0); for (let k = 0; k < 20; k++) simTick();');
check('a different material does not go in on top', run(`${silo}.sm`) === run('RSTONE') && count(run, run('SAND'), 104, 0, 115, 6) > 0);
check('it will not be taken down while it holds something', /Empty the silo/.test(run('removePieceAt(110, 12)')) && run(`!!${silo}`));
const held = run(`${silo}.sn`);
run('interactAt(110, 12); for (let k = 0; k < 60; k++) simTick();');
const out1 = count(run, run('RSTONE'), 100, 27, 120, 99);
check('F opens the gate and it pours out underneath', out1 > 30 && run(`${silo}.sn`) === held - out1, `${out1} out, ${run(`${silo}.sn`)} left of ${held}`);
run('interactAt(110, 12); for (let k = 0; k < 60; k++) simTick();');
check('F shuts it again', count(run, run('RSTONE'), 100, 27, 120, 99) === out1);
run('(() => { const s = serializeWorld(); deserializeWorld(JSON.parse(JSON.stringify(s.meta)), s.buffers); })()');
check('what is in a silo is saved', run(`${silo}.sn`) === held - out1 && run(`${silo}.sm`) === run('RSTONE'));
// ---- builders' orders: bulk rubble, gravel and stone bricks at a few times a tiny price
flatWorld(run, 240, 160, 100);
run('seen[RSTONE] = 1; seen[GRAVEL] = 1; seen[BARSB] = 1; orders = []; ordersDone = 0;');
const os = JSON.parse(run('JSON.stringify(Array.from({ length: 40 }, () => makeBuildersOrder()))'));
const okOrders = os.every(o => o && o.who === 'Builders' && [run('RSTONE'), run('GRAVEL'), run('SAND'), run('BARSB')].includes(o.m) && o.pay > 0 && o.pay < 400);
check("builders post orders for rubble, gravel and stone bricks that pay a little", okOrders, os.slice(0, 4).map(o => `${o.qty} of ${o.m} for $${o.pay}`).join(', '));
const regular = JSON.parse(run('JSON.stringify(Array.from({ length: 60 }, () => { const o = makeOrder(); return o && !o.who ? o.m : 0; }))'));
check('ordinary buyers never ask for thousands of grains of rubble', !regular.includes(run('RSTONE')) && !regular.includes(run('GRAVEL')));
done();
+104
View File
@@ -0,0 +1,104 @@
// Weather: no more springs; steam rises into clouds and rains back; showers; snow when it is freezing.
const { load, suite, flatWorld, rect, ticks, count } = require('./harness');
const { run } = load();
const { check, done } = suite();
const moist = () => run('weather.moist.reduce((a, v) => a + v, 0)');
const rainInAir = (m) => run(`flyers.filter(f => f.rain && f.m === ${m}).length`);
run('generateWorld(777, 1024, 640, null)');
check('new worlds have no springs', count(run, run('SPRING')) === 0);
check('and the same seed still makes the same land', run('surfY[512]') > 0 && run('!!dock()'));
// an older save with a spring in it: the spring becomes plain rock
run('globalThis.__s = serializeWorld(); __s.buffers.mat[300 * W + 500] = SPRING; deserializeWorld(__s.meta, __s.buffers);');
check('springs in older worlds turn into plain rock', run('mat[300 * W + 500]') === run('STONE') && count(run, run('SPRING')) === 0);
// steam in the open sky rises into the clouds instead of condensing
flatWorld(run, 320, 200, 120);
rect(run, 150, 100, 169, 104, run('STEAM'));
run('for (let y = 100; y < 105; y++) for (let x = 150; x < 170; x++) temp[y*W+x] = 60;'); // already cooler than 97°C
const steam0 = count(run, run('STEAM'));
ticks(run, 240);
check('cool steam under open sky rises to cloud height and joins the clouds', count(run, run('STEAM')) === 0 && count(run, run('WATER')) + rainInAir(run('WATER')) === 0 && moist() > steam0 * 0.9, `water ${count(run, run('WATER'))}, clouds ${moist().toFixed(0)} of ${steam0}`);
check('a small cloud just hangs there', run('weather.raining.every(v => !v)'));
// under a roof it condenses as before
flatWorld(run, 320, 200, 120);
rect(run, 140, 80, 179, 81, run('STONE')); rect(run, 140, 80, 141, 119, run('STONE')); rect(run, 178, 80, 179, 119, run('STONE'));
rect(run, 150, 100, 169, 109, run('STEAM'));
run('updateRoof(); for (let y = 100; y < 110; y++) for (let x = 150; x < 170; x++) temp[y*W+x] = 60;');
ticks(run, 600);
check('under a roof steam still turns back into water', count(run, run('WATER')) > 120 && moist() === 0, `water ${count(run, run('WATER'))}, clouds ${moist()}`);
// clouds rain back out over the band where they gathered
flatWorld(run, 512, 200, 120);
run('weather.moist[3] = 400; weather.wind = 0;');
ticks(run, 2);
check('a big cloud starts to rain', run('weather.raining[3]') === 1);
ticks(run, 900);
const landed = count(run, run('WATER'));
const near = count(run, run('WATER'), 3 * 64 - 64, 0, 4 * 64 + 64, 199);
check('a cloud rains its water back down', landed + rainInAir(run('WATER')) + moist() > 390 && landed > 250, `landed ${landed}, in the air ${rainInAir(run('WATER'))}, left in the cloud ${moist().toFixed(0)}`);
check('close to where it gathered', near > landed * 0.85, `${near} of ${landed} within a band`);
// boiling water outdoors comes back as rain
flatWorld(run, 320, 200, 120);
rect(run, 140, 116, 179, 119, run('WATER'));
run('for (let y = 116; y < 120; y++) for (let x = 140; x < 180; x++) { temp[y*W+x] = 400; wakeHeatXY(x, y); }');
const w0 = count(run, run('WATER'));
for (let k = 0; k < 40; k++) { run('for (let y = 116; y < 120; y++) for (let x = 140; x < 180; x++) if (mat[y*W+x] === WATER && temp[y*W+x] < 150) { temp[y*W+x] = 400; wakeHeatXY(x, y); }'); ticks(run, 30); }
check('water boiled off outdoors goes up into the clouds', moist() > 30 || rainInAir(run('WATER')) > 0, `clouds ${moist().toFixed(0)}, ${count(run, run('WATER'))} of ${w0} still water`);
run('weather.moist.fill(0); weather.moist[2] = 200;'); // more steam piles in: enough for a downpour
ticks(run, 2400);
check('and once the cloud is big enough it falls back down as rain', run('weather.raining.some(v => v)') || count(run, run('WATER')) > 100,
`water ${count(run, run('WATER'))}, clouds ${moist().toFixed(0)}`);
check('nothing is lost on the way', count(run, run('WATER')) + count(run, run('STEAM')) + moist() + rainInAir(run('WATER')) > w0 * 0.85, `water ${count(run, run('WATER'))}, steam ${count(run, run('STEAM'))}, clouds ${moist().toFixed(0)} of ${w0}`);
// water is never made: with all of its water still there, no shower blows in
flatWorld(run, 640, 200, 120);
rect(run, 100, 110, 400, 119, run('WATER'));
run('weather.base = worldWater(); weather.next = tick; EV.length = 0;');
ticks(run, 2);
check('a world with all its water gets no showers from outside', !run('weather.shower') && run('weather.next - tick') > 3600 * 4, run('weather.next - tick'));
// some water left the world (sold at the dock, say): a shower makes up just that much
const wBefore = run('worldWater()');
run('for (let y = 110; y < 120; y++) for (let x = 100; x < 200; x++) setCell(y * W + x, EMPTY, AMB[y], 0);');
run('weather.next = tick; EV.length = 0;');
ticks(run, 2);
const sh = JSON.parse(run('JSON.stringify(weather.shower)'));
check('a world that has lost water gets a shower, and says so', sh && run('EV.some(e => e.t === "weather")'), JSON.stringify(sh));
run('weather.shower.rate = 2; weather.wind = 0;');
ticks(run, 3600);
const wAfter = run('worldWater()');
check('the shower brings back what was lost, and no more', wAfter >= wBefore * 0.97 && wAfter <= wBefore * 1.03, `${wBefore} -> ${wAfter}`);
check('mostly under the shower', count(run, run('WATER'), Math.floor(sh.x0) - 60, 0, Math.ceil(sh.x1) + 60, 199) > (count(run, run('WATER')) - 2000) * 0.9);
ticks(run, 400);
check('the shower passes and the next check is a few minutes off', !run('weather.shower') && run('weather.next - tick') > 3600 * 4, run('weather.next - tick'));
// clouds that drift to the edge of the world stay there
flatWorld(run, 640, 200, 120);
run('weather.moist[0] = 100; weather.moist[9] = 100; weather.wind = 1;');
ticks(run, 600);
check('clouds blown to the edge of the world are not lost', Math.round(moist()) === 200, moist());
check('the sky reads as raining while it rains, dry after', run('rainAt(5)') === 0);
// where the ground is frozen it snows
flatWorld(run, 320, 200, 120);
run('for (let y = 60; y <= 120; y++) AMB[y] = -6; for (let i = 0; i < N; i++) if (mat[i] === EMPTY) temp[i] = AMB[(i / W) | 0]; weather.moist[2] = 200; weather.wind = 0;');
ticks(run, 900);
check('on freezing ground it snows instead', count(run, run('SNOW')) > 100 && count(run, run('WATER')) < 5, `snow ${count(run, run('SNOW'))}, water ${count(run, run('WATER'))}`);
// saving: weather is kept; rain in the air never ends up in your bag
flatWorld(run, 320, 200, 120);
run('weather.moist[1] = 123; weather.next = tick + 5000; bag.fill(0); bagTotal = 0;');
ticks(run, 2);
run('spawnRain(10, 300, 50);');
run('globalThis.__s = serializeWorld(); deserializeWorld(__s.meta, __s.buffers);');
check('save and load keep the clouds and the forecast', Math.round(run('weather.moist[1]')) === 123 - 0 || run('weather.moist[1]') > 100, run('weather.moist[1]'));
check('rain in the air never ends up in your bag', run('bagTotal') === 0, run('bagTotal'));
check('rain in the air at the save goes back into the clouds', run('weather.moist.reduce((a, v) => a + v, 0)') >= 123 + 40, run('weather.moist.reduce((a, v) => a + v, 0)'));
// snow and water
flatWorld(run, 240, 160, 100);
rect(run, 100, 80, 101, 99, run('STONE')); rect(run, 140, 80, 141, 99, run('STONE')); rect(run, 102, 90, 139, 99, run('WATER'));
check('snow blown out of the bag comes out frozen', run('bagTemp(SNOW)') < 0, run('bagTemp(SNOW)'));
run('bag[SNOW] = 200; bagTotal = 200; bot.x = 116; bot.y = 70; for (let k = 0; k < 200 && bag[SNOW] > 0; k++) { toolBlow(110 + (k * 7) % 24, 86, 1, SNOW, null); simTick(); }');
ticks(run, 900);
check('snow that lands on a warm pond melts into it', count(run, run('SNOW')) < 10 && count(run, run('WATER')) > 38 * 10 + 100, `snow ${count(run, run('SNOW'))}, water ${count(run, run('WATER'))}`);
run('for (let y = 60; y < 120; y++) AMB[y] = -6; for (let y = 90; y < 100; y++) for (let x = 102; x < 140; x++) if (mat[y*W+x] === WATER) temp[y*W+x] = 0.2;');
run('for (let x = 102; x < 140; x++) for (let y = 84; y < 88; y++) setCell(y * W + x, SNOW, -6, 0);');
ticks(run, 2400);
check('on freezing ground, a pond with snow on it ices over', count(run, run('ICE')) > 30, `ice ${count(run, run('ICE'))}, water ${count(run, run('WATER'))}`);
done();
+219
View File
@@ -0,0 +1,219 @@
// Trees from acorns, charcoal, brick, density, water/lava/snow/ice, full-bag
// refunds, drill reach, ballistic blowing, snowy mountains.
const { load, suite, flatWorld, rect, ticks, count } = require('./harness');
const { run } = load();
const { check, done } = suite();
const fresh = () => { flatWorld(run, 240, 160, 100); run('player.tankTier = 3; saplings = []; stats.grown = 0;'); };
const box = (x0, y0, x1, y1, m) => rect(run, x0, y0, x1, y1, m);
// 1. acorns sprout on soil under open sky and grow into a tree
fresh();
box(100, 100, 140, 103, run('DIRT'));
run('setCell(99*W+120, ACORN, AMB[99], 0); updateRoof();');
ticks(run, 1200);
check('acorn sprouts into a sapling', run('saplings.length') === 1 && run('mat[99*W+120]') !== run('ACORN'), run('saplings.length'));
ticks(run, 5600);
const wood = run('bgm.reduce((a, v) => a + (v === WOOD), 0)'), leaves = run('bgm.reduce((a, v) => a + (v === LEAVES), 0)');
check('sapling grows into a full tree', run('stats.grown') === 1 && wood >= 14 && leaves > 40, `wood ${wood}, leaves ${leaves}`);
// acorn on bare stone does not sprout
run('setCell(99*W+60, ACORN, AMB[99], 0); updateRoof();');
box(55, 100, 65, 100, run('STONE'));
ticks(run, 1500);
check('acorn on stone stays an acorn', run('mat[99*W+60]') === run('ACORN') && run('saplings.length') === 0);
// cutting the grown tree drops acorns
run('bot.x = 114; bot.y = 86; for (let k=0;k<160;k++) toolDig(121, 99, 1)');
ticks(run, 20);
check('felling a tree drops acorns and wood', count(run, run('ACORN')) >= 2 && count(run, run('LWOOD')) > 10, `${count(run, run('ACORN'))} acorns, ${count(run, run('LWOOD'))} wood`);
// 2. charcoal: wood chunks heated in a sealed stone box (a kiln) char instead of burning
fresh();
box(110, 80, 130, 99, run('STONE')); box(112, 82, 128, 97, run('LWOOD'));
run('for (let y=80;y<=99;y++) for (let x=110;x<=130;x++) { const i=y*W+x; temp[i] = 520; wakeHeatXY(x,y); }');
for (let k = 0; k < 30; k++) { run('for (let y=80;y<=99;y++) for (let x=110;x<=130;x++) { const i=y*W+x; if (mat[i]===STONE) temp[i] = 560; }'); ticks(run, 20); }
const ch = count(run, run('CHARCOAL'), 110, 80, 130, 99), wl = count(run, run('LWOOD'), 110, 80, 130, 99);
check('sealed kiln turns wood into charcoal', ch > 100, `charcoal ${ch}, wood left ${wl}`);
// smothered: a burning wood pile buried under dirt chars inside
fresh();
box(110, 92, 130, 99, run('LWOOD'));
run('for (let k=0;k<60;k++){ for (let x=112;x<130;x+=4) toolIgnite(x, 92); simTick(); }');
ticks(run, 60);
box(106, 86, 134, 91, run('LDIRT'));
ticks(run, 1800);
check('a buried burning pile leaves charcoal', count(run, run('CHARCOAL')) > 20, count(run, run('CHARCOAL')));
// charcoal burns hot
fresh();
box(110, 94, 130, 99, run('CHARCOAL'));
run('for (let k=0;k<60;k++){ toolIgnite(120, 94); simTick(); }');
ticks(run, 600);
const tc = run('(()=>{let t=0;for(let y=88;y<100;y++)for(let x=105;x<135;x++)t=Math.max(t,temp[y*W+x]);return Math.round(t)})()');
check('charcoal burns over 1100°C', tc > 1100, tc);
// 3. brick: clay lumps mixed into a walled coal fire turn into brick rubble
fresh();
box(104, 76, 107, 99, run('STONE')); box(133, 76, 136, 99, run('STONE')); box(104, 76, 136, 79, run('STONE'));
run('for (let y=76;y<80;y++) for (let x=118;x<122;x++) setCell(y*W+x, EMPTY, AMB[y], 0)');
run('{ const r = mulberry32(5); for (let y=90;y<=99;y++) for (let x=108;x<=132;x++) setCell(y*W+x, r() < 0.4 ? LCLAY : COAL, AMB[y], 0); }');
run('bot.x = 115; bot.y = 82; for (let k=0;k<60;k++){ for (const x of [110,120,130]) { for (let y=90;y<99;y++) if (mat[y*W+x]===COAL && mat[(y-1)*W+x]===EMPTY) { toolIgnite(x,y); break; } } simTick(); }');
ticks(run, 3600);
check('clay lumps in a walled coal fire become brick rubble', count(run, run('RBRICK')) > 15, `${count(run, run('RBRICK'))} brick, ${count(run, run('LCLAY'))} clay left`);
// 4. density: a pile sorts heavy under light; wood floats, ore sinks
fresh();
box(110, 84, 130, 99, run('COAL')); box(110, 80, 130, 83, run('CUORE'));
const oreY0 = run('(()=>{let s=0,n=0;for(let i=0;i<N;i++) if(mat[i]===CUORE){s+=(i/W)|0;n++;} return s/n})()');
ticks(run, 3600);
const oreY1 = run('(()=>{let s=0,n=0;for(let i=0;i<N;i++) if(mat[i]===CUORE){s+=(i/W)|0;n++;} return s/n})()');
check('ore settles down through a coal pile', oreY1 > oreY0 + 3, `${oreY0.toFixed(1)} -> ${oreY1.toFixed(1)}`);
fresh();
box(100, 80, 101, 99, run('STONE')); box(140, 80, 141, 99, run('STONE'));
box(102, 86, 139, 99, run('WATER')); box(110, 70, 115, 73, run('LWOOD')); box(125, 70, 130, 73, run('RSTONE'));
ticks(run, 900);
const wy = run('(()=>{let s=0,n=0;for(let i=0;i<N;i++) if(mat[i]===LWOOD){s+=(i/W)|0;n++;} return s/n})()');
const ry = run('(()=>{let s=0,n=0;for(let i=0;i<N;i++) if(mat[i]===RSTONE){s+=(i/W)|0;n++;} return s/n})()');
check('wood chunks float, rubble sinks', wy < 89 && ry > 95, `wood y ${wy.toFixed(1)}, rubble y ${ry.toFixed(1)}`);
// 5. lava meets water: steam and a basalt crust
fresh();
box(100, 80, 101, 99, run('STONE')); box(140, 80, 141, 99, run('STONE'));
run('for (let y=90;y<=99;y++) for (let x=102;x<=139;x++) setCell(y*W+x, MAGMA, 1250, 0)');
box(102, 84, 139, 87, run('WATER'));
ticks(run, 240);
check('water on lava flashes to steam and crusts it to basalt', count(run, run('BASALT')) > 20 && (count(run, run('STEAM')) + count(run, run('WATER'))) < 150, `basalt ${count(run, run('BASALT'))}, steam ${count(run, run('STEAM'))}, water ${count(run, run('WATER'))}`);
// 6. snow melts by a fire; water freezes in cold air high up
fresh();
box(110, 90, 116, 99, run('COAL')); box(117, 88, 126, 99, run('SNOW'));
run('for (let y=88;y<=99;y++) for (let x=117;x<=126;x++) temp[y*W+x] = -5;');
run('bot.x = 108; bot.y = 80; for (let k=0;k<60;k++){ toolIgnite(112, 90); simTick(); }');
ticks(run, 1800);
check('fire melts snow into water', count(run, run('SNOW')) < 100 && count(run, run('WATER')) + count(run, run('STEAM')) > 0, `snow ${count(run, run('SNOW'))}, water ${count(run, run('WATER'))}`);
check('the air is below freezing high up', run('AMB[5]') < -1, run('AMB[5].toFixed(1)'));
fresh();
box(100, 8, 140, 9, run('STONE')); box(100, 4, 101, 7, run('STONE')); box(139, 4, 140, 7, run('STONE'));
run('for (let y=5;y<=7;y++) for (let x=102;x<=138;x++) setCell(y*W+x, WATER, AMB[y], 0)');
ticks(run, 2400);
check('water up in the cold air freezes into ice', count(run, run('ICE')) > 20, `ice ${count(run, run('ICE'))}, water ${count(run, run('WATER'))}`);
const sv = run('(()=>{ const s = serializeWorld(); deserializeWorld(s.meta, s.buffers); let t = 99; for (let i=0;i<N;i++) if (mat[i]===ICE) t = Math.min(t, temp[i]); return t; })()');
check('sub-zero temperatures survive save and load', sv < 0, sv);
// 7. full bag: what does not fit drops where the block stood
fresh();
run('player.tankTier = 0; bag[RSTONE] = 16; bag[LDIRT] = 1500 - 16; bagTotal = 1500; bot.x = 110; bot.y = 60;');
run('placeBlocks(BP_WALL, RSTONE, [[30, 18]])');
run('bag[LDIRT] = 1500; bagTotal = 1500;');
const msg = run('removePieceAt(121, 73)');
check('removing a block with a full bag drops its rubble in place', count(run, run('RSTONE'), 120, 72, 123, 75) === 16 && /bag is full/.test(msg), msg);
run('bag[BARCU] = 20; bag[LDIRT] = 1480; bagTotal = 1500; placeBlocks(BP_BELT, 1, [[32, 18]]); bag[LDIRT] += 1; bagTotal = 1500;');
const m0 = run('money'); run('removePieceAt(129, 73)'); ticks(run, 60);
check('a belt removed with a full bag still refunds its price as money', run('money') === m0 + 12, `${m0} -> ${run('money')}`);
// 8. drill reach grows with the bit
fresh();
run('bot.x = 100; bot.y = 70;');
const far = run('JSON.stringify(toolDig(102, 102, 2))');
check('stone bit cannot reach 30 grains away', JSON.parse(far).dug === 0 && /Out of reach/.test(far), far);
run('player.bitTier = 3;'); run('for (let k=0;k<30;k++) toolDig(102, 102, 2)');
check('steel bit reaches that far', count(run, run('RSTONE')) > 5, count(run, run('RSTONE')));
// 9. blown grains fly past the cursor until gravity brings them down
fresh();
run('bot.x = 100; bot.y = 60; bag[SAND] = 200; bagTotal = 200;');
for (let k = 0; k < 20; k++) run('toolBlow(120, 55, 0, SAND); simTick();');
ticks(run, 200);
const sx = run('(()=>{let s=0,n=0;for(let i=0;i<N;i++) if(mat[i]===SAND){s+=i%W;n++;} return n ? s/n : 0})()');
check('blown sand carries on past the cursor and lands beyond it', sx > 121 && run('flyers.length') === 0, `landed around x ${sx.toFixed(1)} (cursor at 120)`);
// 10. a generated world has a snowy mountain
run('generateWorld(4242, 2048, 1024, null)');
const snow = run('(()=>{let n=0;for(let i=0;i<N;i++) if(mat[i]===SNOW) n++; return n})()');
const peak = run('Math.min(...surfY)');
check('worldgen raises a snow-capped mountain', snow > 80 && run(`AMB[${peak}]`) < 0, `snow ${snow}, peak y ${peak}, ${run(`AMB[${peak}].toFixed(1)`)}°C`);
// the layout: plenty of sky, the first ores as shallow as ever, the deep layers stretched out below
check('a third of a new world is sky', Math.abs(run('surfBase / H') - 0.3) < 0.01, run('surfBase'));
const shallowOre = run('(()=>{let cu=0,fe=0;for(let x=0;x<W;x++)for(let d=0;d<150;d++){const m=mat[(surfY[x]+d)*W+x];if(m===CUV)cu++;else if(m===FEV)fe++;}return [cu,fe]})()');
check('copper and iron ore are within reach of the surface', shallowOre[0] > 2000 && shallowOre[1] > 200, shallowOre);
check('the deep layers fill the extra depth', run('depthY(0.905) - surfBase') > 600 && run('Math.abs(depthY(0.28) - surfBase - 131) <= 1'), run('[depthY(0.28), depthY(0.5), depthY(0.905)].map(y => y - surfBase)'));
check('it is still warm a hundred cells above the ground', run('AMB[surfBase - 100]') > 0, run('AMB[surfBase - 100].toFixed(1)'));
run('generateWorld(99, 4096, 1536, null)');
const marks = run('(()=>{let lakes=0,peaks=0,inLake=false;for(let x=1;x<W-1;x++){const l=mat[(surfY[x]-1)*W+x]===WATER;if(l&&!inLake)lakes++;inLake=l;if(surfY[x]<surfBase-100&&surfY[x]<=surfY[x-1]&&surfY[x]<surfY[x+1])peaks++;}return [lakes,peaks]})()');
check('a large world has several lakes and mountains across it', marks[0] >= 2 && marks[1] >= 2, marks);
// --- round 5: trees under water, cut saplings, blown grains stay out of the bag
fresh();
box(100, 100, 140, 103, run('DIRT'));
run('for (let y = 82; y < 100; y++) for (let x = 120; x < 122; x++) bgm[y*W+x] = WOOD; for (let y = 74; y < 82; y++) for (let x = 115; x < 127; x++) bgm[y*W+x] = LEAVES;');
box(100, 84, 101, 99, run('STONE')); box(140, 84, 141, 99, run('STONE')); box(102, 84, 139, 99, run('WATER'));
run('bot.x = 114; bot.y = 80; for (let k=0;k<200;k++) toolDig(120, 98, 1)');
ticks(run, 200);
check('a tree under water can be cut down', run('bgm[90*W+120]') === 0 && count(run, run('LWOOD')) > 10, `${count(run, run('LWOOD'))} wood chunks`);
fresh();
box(100, 100, 140, 103, run('DIRT'));
run('setCell(99*W+120, ACORN, AMB[99], 0); updateRoof();');
ticks(run, 1200 + 3000);
run('bot.x = 114; bot.y = 86; for (let k=0;k<160;k++) toolDig(120, 98, 1)');
ticks(run, 30);
run('for (let i = 0; i < N; i++) if (mat[i] === ACORN) setCell(i, EMPTY, 20, 0)'); // keep its acorns from sprouting a new one
ticks(run, 2400);
const left = run('bgm.reduce((a, v) => a + (v === WOOD), 0)');
check('a half-grown tree that is cut down stays down', run('saplings.length') === 0 && left < 4, `trunk cells left ${left}, saplings ${run('saplings.length')}`);
fresh();
run('bot.x = 110; bot.y = 85; bag[SAND] = 300; bagTotal = 300;');
for (let k = 0; k < 400 && run('bag[SAND]') > 0; k++) run('toolBlow(125, 99, 5, SAND); simTick();');
ticks(run, 400);
check('blown grains never come back into the bag', run('bag[SAND]') === 0 && count(run, run('SAND')) === 300, `bag ${run('bag[SAND]')}, world ${count(run, run('SAND'))}`);
// --- round 6: lifts, falling trees, machines without power, the bot and walls
fresh();
// a shaft 16 wide, a column of up-tubes from its floor, turned sideways at the top to unload onto the ground
run('for (let y = 100; y < 132; y++) for (let x = 120; x < 128; x++) setCell(y*W+x, EMPTY, AMB[y], 0)');
run('bot.x = 110; bot.y = 90;');
// unload well above the ground: a pile that reaches the outlet would block it
run('for (let by = 19; by <= 32; by++) placeBlock(BP_TUBE, 0, 30, by, true); for (let bx = 30; bx <= 35; bx++) placeBlock(BP_TUBE, 1, bx, 18, true);');
box(120, 116, 127, 131, run('SAND'));
const sand0 = count(run, run('SAND'));
ticks(run, 1800);
const lifted = count(run, run('SAND'), 136, 0, 239, 99);
check('a column of upward screw tubes lifts sand out of a shaft, unpowered', lifted > sand0 * 0.8, `${lifted} of ${sand0} grains brought up and out`);
fresh();
run('for (let bx = 25; bx <= 30; bx++) placeBlock(BP_BELT, 1, bx, 22, true); rebuildPower();');
box(101, 84, 104, 87, run('SAND'));
ticks(run, 600);
check('belts run without any power', count(run, run('SAND'), 110, 0, 239, 99) > 8, count(run, run('SAND'), 110, 0, 239, 99));
fresh();
box(100, 100, 140, 103, run('DIRT'));
run('for (let y = 86; y < 100; y++) for (let x = 120; x < 122; x++) bgm[y*W+x] = WOOD; for (let y = 76; y < 86; y++) for (let x = 115; x < 127; x++) bgm[y*W+x] = LEAVES; findTreeBases();');
run('for (let y = 100; y < 104; y++) for (let x = 116; x < 126; x++) setCell(y*W+x, EMPTY, AMB[y], 0)');
ticks(run, 60);
check('a tree with its ground dug out from under it falls', run('bgm[90*W+120]') === 0 && count(run, run('LWOOD')) > 10, count(run, run('LWOOD')));
fresh();
// a stone box of sand; the bot flies in through the wall: no sand may end up outside
box(110, 80, 113, 99, run('STONE')); box(130, 80, 133, 99, run('STONE')); box(110, 96, 133, 99, run('STONE'));
run('for (let y=80;y<100;y++) for (let x=110;x<134;x++) flags[y*W+x] = 0;');
box(114, 84, 129, 95, run('SAND'));
const inside0 = count(run, run('SAND'), 114, 0, 129, 99);
run('bot.x = 100; bot.y = 88;');
for (let k = 0; k < 120; k++) run('botStep(1, 0, false); simTick();');
for (let k = 0; k < 160; k++) run('botStep(-1, 0, false); simTick();');
ticks(run, 120);
check('the bot flying through a wall into sand never pushes sand out', count(run, run('SAND'), 114, 0, 129, 99) === inside0, `${inside0} -> ${count(run, run('SAND'), 114, 0, 129, 99)} inside`);
// a settled sand pile with the bot sitting in it: not one grain moves
fresh();
box(110, 92, 133, 99, run('STONE'));
box(114, 84, 129, 91, run('SAND'));
ticks(run, 60);
const pile = () => run('(() => { let s = ""; for (let y = 70; y < 92; y++) for (let x = 110; x < 134; x++) s += mat[y * W + x] === SAND ? 1 : 0; return s; })()');
const pile0 = pile();
run('bot.x = 119; bot.y = 84; bot.vx = bot.vy = 0;');
for (let k = 0; k < 90; k++) run('botStep(0, 0, false); simTick();');
check('the bot resting inside a sand pile leaves every grain where it was', pile() === pile0);
// sand poured onto the bot falls through it and piles up just as it would with the bot elsewhere
const pour = (bx) => {
fresh(); box(110, 96, 133, 99, run('STONE'));
run(`bot.x = ${bx}; bot.y = 86; bot.vx = bot.vy = 0;`);
for (let k = 0; k < 140; k++) run('if (k_ % 2 === 0) setCell(60 * W + 120, SAND, AMB[60], 0); botStep(0, 0, false); simTick();'.replace('k_', String(k)));
ticks(run, 60);
return [count(run, run('SAND'), 110, 60, 133, 95), count(run, run('SAND'), 110, 60, 133, 84)];
};
const [withBot, aboveBot] = pour(118), [without] = pour(200);
check('sand poured onto the bot falls through it to the ground', withBot === without && aboveBot === 0, `${withBot} grains landed (${without} with the bot away), ${aboveBot} held up above it`);
done();