// 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]')); // New bulk rocks stay hidden until discovered; isolate each candidate without random selection. run('orders = []; ordersDone = 0; seen.fill(0);'); check('builders do not order unseen granite or basalt rubble', run('makeBuildersOrder()') === null); run('seen[RGRAN] = seen[RBAS] = 1;'); for (const material of ['RGRAN', 'RBAS']) { run(`orders = [{ m: ${material === 'RGRAN' ? 'RBAS' : 'RGRAN'} }];`); check(`builders offer bulk ${material === 'RGRAN' ? 'granite' : 'basalt'} rubble after discovery`, run(`(() => { const o = makeBuildersOrder(); return !!o && o.m === ${material} && o.who === 'Builders' && o.qty >= 300 && o.qty <= 1200 && o.pay > o.qty * PRICE[o.m]; })()`)); } check('rubble prices rise from stone to granite to basalt', run('PRICE[RSTONE] < PRICE[RGRAN] && PRICE[RGRAN] < PRICE[RBAS]')); // 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();