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:
@@ -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();
|
||||
@@ -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
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
@@ -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)
|
||||
@@ -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();
|
||||
@@ -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
@@ -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();
|
||||
Reference in New Issue
Block a user