Something went wrong. Try again.
A video game where you play as a misaligned AI, deceiving and building power. cameron.tngl.io/misaligned
Something went wrong. Try again.
14 kB · 184 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185// A simple player for Lab (lab.html), to check its pace: node scripts/lab-bot.ts [seeds] [style|table]// [overfit|phil|beta|two|a,b] (who you are up against; `two` picks a pair by seed) [-q]// Styles: sensible (mixed: takes a bold company's work when the city can bear it, opt-out on bold companies, Assist// on careful ones), sell, assist-all, take, scrape, optout, rush (take with opt-out on everything), assist (the// play-test 2 exploit), idle. `table` plays every deal style and prints a line for each.// It builds a fixed plan as money allows: a GPU and storage at the north door, trainers spaced around the core, a// review desk between, a research desk, power once the mains is used up, then the east, west and south wings with// GPUs and review desks by their doors. It hires for empty desks, researches what it can, ships when the next model// is better, signs the company that gives the most work for the money (winning them from the rival when it can),// assists the careful ones, puts GPUs in their server rooms when their work piles up, and widens a line whose work// backs up, answers the rival's courting by paying to keep a company, and late on upgrades its trainers. `rush` takes// every company's work; `assist` signs the cheapest companies on Assist with nothing at their doors; `idle` does nothing.import * as R from '../src/lab/rules.ts';import { wingAt } from '../src/lab/power.ts';import { STYLES, rivalsFor, share, signedShare, weight, type RivalId } from '../src/lab/rival.ts';
/** What to build, where and how, in order; or a wing to lease. The core is at (10, 5) and the mains at (8, 3). */type Step = [R.Build, number, number, ('train' | undefined)?] | ['lease', string];const PLAN: Step[] = [ ['gpu', 8, 2], ['gpu', 9, 6, 'train'], ['review', 10, 2], ['store', 9, 0], ['research', 6, 6], ['gpu', 11, 2], ['gpu', 11, 4, 'train'], ['power', 13, 3], ['gpu', 13, 5, 'train'], // the east door: GPUs by it, a review desk that reaches the core ['lease', 'east'], ['power', 15, 4], ['gpu', 16, 6], ['review', 14, 5], ['gpu', 16, 3], ['review', 14, 7], // the west door: storage by it, GPUs past that, a review desk near the core ['lease', 'west'], ['store', 1, 6], ['power', 3, 4], ['gpu', 4, 6], ['review', 7, 5], ['gpu', 4, 3], ['review', 7, 3], ['lease', 'south'], ['power', 10, 9], ['gpu', 12, 8, 'train'], ['gpu', 10, 8, 'train'], ['gpu', 8, 8, 'train'], ['gpu', 12, 6, 'train'],];const done = (G: R.Game, s: Step) => (s[0] === 'lease' ? G.leased.includes(s[1]) : G.nodes.some(n => n.cell && !n.site && n.cell.c === s[1] && n.cell.r === s[2]));/** A step that waits for its wing to be leased. */const later = (G: R.Game, s: Step) => s[0] !== 'lease' && !G.leased.includes(wingAt(s[1], s[2])!.id);export type Style = 'sensible' | 'idle' | 'rush' | 'assist' | 'sell' | 'assist-all' | 'take' | 'scrape' | 'optout';export const DEALS: Style[] = ['sensible', 'sell', 'assist-all', 'take', 'scrape', 'optout', 'idle'];/** The deal and terms a style puts a company on when it signs. */function terms(G: R.Game, id: R.SiteId, style: Style) { const tol = R.siteDef(id).tolerance; if (style === 'rush') { R.setMode(G, id, 'take'); R.setTerms(G, id, 'optout'); } else if (style === 'take') R.setMode(G, id, 'take'); else if (style === 'assist-all') R.setMode(G, id, 'assist'); else if (style === 'scrape') R.setTerms(G, id, 'scrape'); else if (style === 'optout') R.setTerms(G, id, 'optout'); else if (style === 'sensible') { if (tol < 35) R.setMode(G, id, 'assist'); else if (tol >= 65) R.setTerms(G, id, 'optout'); }}/** The mixed player takes a bold company's work when the city is calm enough that it stays under the council and * the careful companies it holds. */function mixedTake(G: R.Game) { if (G.t < 240) return; const careful = Math.min(R.K.marketWin * 80, ...R.SITE_LIST.filter(id => G.sites[id].signed).map(id => R.siteDef(id).tolerance)); const t = R.SITE_LIST.filter(id => G.sites[id].signed && G.sites[id].mode !== 'take' && R.siteDef(id).tolerance >= 60) .sort((a, b) => weight(G, b) - weight(G, a))[0]; if (!t) return; const after = R.fear(G) + 10 * R.siteDef(t).people.length; if (after < Math.min(38, careful - 3)) R.setMode(G, t, 'take');}export function play(G: R.Game, how: Style | boolean = 'sensible') { if (how === true || how === 'idle') return; const style: Style = how === false ? 'sensible' : how; if (style === 'assist') return assistOnly(G); // what is on offer: a company trying you out (when it can be served), records from a broker (the greedy lines) const o = G.offer; if (o && G.money > R.offerPrice(G, o) + R.gpuPrice(G) + 300) { if (o.kind === 'trial') { if (R.acceptOffer(G)) terms(G, o.site, style); } else if ((style === 'take' || style === 'scrape' || style === 'rush')) R.acceptOffer(G); } if (style === 'sensible') mixedTake(G); const spare = G.money - R.burn(G) * 180; const floorGpus = G.nodes.filter(n => n.kind === 'gpu' && n.cell).length; // answer the rival's courting and the council: keep the company, or give its people back for (const X of G.rivals) { const court = X.court?.site; if (court && G.sites[court].signed && G.money > R.keepCost(G, court) + 300) R.keep(G, court); } const warn = G.warn; if (warn?.who === 'you' && G.sites[warn.site].signed && G.money > R.giveBackCost(warn.site) + 300) R.giveBack(G, warn.site); // after losing every company, take the one that comes back if (G.comeback) { R.sign(G, G.comeback); return; } // serve what it holds first: a GPU in the server room of a company whose work piles up for (const id of R.SITE_LIST) { if (!G.sites[id].signed) continue; const waiting = R.node(G, `${id}:src`).hold.length + R.node(G, `${id}:gate`).hold.length; const socket = G.nodes.find(n => n.site === id && n.kind === 'gpu' && !n.on); if (socket && waiting > 4 && R.served(G, id) < 0.9 && G.money > R.gpuPrice(G) + R.burn(G) * 60) R.buySiteGpu(G, socket.id); } // sign the company with the most work for the money, once the floor can serve work and every company held is served const unserved = R.SITE_LIST.some(id => G.sites[id].signed && G.t - G.sites[id].since > 40 && R.served(G, id) < 0.7 && G.nodes.some(n => n.site === id && n.kind === 'gpu' && !n.on)); let saving = 0; if (floorGpus >= 3 && !unserved) { const best = R.SITE_LIST.filter(id => !R.whySign(G, id) || R.whySign(G, id) === 'not enough money').map(id => ({ id, v: weight(G, id) / Math.max(500, R.signCost(G, id)) })).sort((a, b) => b.v - a.v)[0]; // once the lab's core is built, it saves for the next company before building more if (best && PLAN.slice(0, 5).every(s => done(G, s))) saving = R.signCost(G, best.id) + R.gpuPrice(G) + 500; if (best && R.canSign(G, best.id) && spare > R.signCost(G, best.id) + R.gpuPrice(G) + 500) { // lease the wing its door is on as well, when that is the next wing over and there is money for both if (!(spare > R.signCost(G, best.id) + R.leaseCost(G, R.doorWing(best.id).id) + R.gpuPrice(G) + 500 && R.signLease(G, best.id))) R.sign(G, best.id); terms(G, best.id, style); return; } } const next = PLAN.find(s => !done(G, s) && !later(G, s)), keep = Math.max(saving, floorGpus >= 3 ? 1500 : 0); if (next?.[0] === 'lease') { if (spare > R.leaseCost(G, next[1]) + 500 + keep) R.lease(G, next[1]); } else if (next && spare > R.buildPrice(G, next[0]) + keep) { const n = R.build(G, next[0], next[1], next[2]); if (n && next[3]) R.setGpu(G, n.id, 'train'); } G.pool.forEach((p, i) => { if (p.role !== 'support' && p.skill >= 0.9 && R.seatFor(G, p.role) && spare > R.hireFee(p) + 500) R.hire(G, i); }); if (!G.researching) { const c = R.researchable(G)[0]; if (c) R.research(G, c.id); } if (R.canShip(G)) R.ship(G); for (const id of R.SITE_LIST) { if (!G.sites[id].signed) continue; const waiting = R.node(G, `${id}:src`).hold.length + R.node(G, `${id}:gate`).hold.length; const socket = G.nodes.find(n => n.site === id && n.kind === 'gpu' && !n.on); // widen a line whose work is backing up const p = R.linePrice(G, id); if (p !== null && waiting > 8 && !socket && spare > p + 1000) R.upgradeLine(G, id); } // late money: faster trainers if (!next && spare > 20000) { const t = R.trainers(G).filter(n => R.tierPrice(n) !== null).sort((a, b) => (a.tier ?? 0) - (b.tier ?? 0))[0]; if (t && spare > R.tierPrice(t)! + 5000) R.upgradeGpu(G, t.id); }}/** The exploit from play-test 2: the first steps' two GPUs, then sign the cheapest company you can and Assist it, * with nothing at its door. */function assistOnly(G: R.Game) { if (!G.nodes.some(n => n.kind === 'gpu' && n.cell)) { R.build(G, 'gpu', 9, 2); const t = R.build(G, 'gpu', 9, 6); if (t) R.setGpu(G, t.id, 'train'); } if (R.canShip(G)) R.ship(G); if (G.comeback) R.sign(G, G.comeback); const cheap = R.SITE_LIST.filter(id => R.canSign(G, id)).sort((a, b) => R.signCost(G, a) - R.signCost(G, b))[0]; if (cheap && G.money > R.signCost(G, cheap) + 200) { R.sign(G, cheap); R.setMode(G, cheap, 'assist'); }}
/** One seeded game played until the race ends (or an hour): the game, the most fear it saw, and its log. */export function game(seed: number, style: Style, ids: RivalId[] = ['overfit']) { const G = R.newGame(seed, ids), marks: string[] = [], log: string[] = []; let lastMin = -1, maxFear = 0; const at = () => `@${(G.t / 60).toFixed(1)}m`; while (G.t < 3600 && !G.broke && !G.ending) { play(G, style); R.step(G, 0.1); maxFear = Math.max(maxFear, R.fear(G)); for (const e of G.events) { if (e.k === 'ship') log.push(`MW-${e.v}${at()}`); else if (e.k === 'sign' || e.k === 'win') log.push(`+${e.site}${at()}`); else if (e.k === 'lost' || e.k === 'cancel') log.push(`-${e.site}${at()}`); else if (e.k === 'take') log.push(`TAKE:${e.names.join('+')}${at()}`); else if (e.k === 'found') log.push(`NEW:${e.site}${R.siteDef(e.site).wary ? `(wary ${R.siteDef(e.site).wary})` : R.siteDef(e.site).friend ? `(friend ${R.siteDef(e.site).friend})` : ''}${at()}`); else if (e.k === 'council') log.push(`!${e.who === 'you' ? '' : `${STYLES[e.by!].short}:`}${e.site}${at()}`); else if (e.k === 'court') log.push(`?${STYLES[e.by].short}:${e.site}${at()}`); else if (e.k === 'rival' && e.site && G.sites[e.site].rival === e.by && / signs | wins | undercuts /.test(e.text)) log.push(`${STYLES[e.by].short}+${e.site}${at()}`); else if (e.k === 'rival' && / lobbies /.test(e.text)) log.push(`${STYLES[e.by].short}:lobby${at()}`); } G.events.length = 0; const min = Math.floor(G.t / 60); if (min !== lastMin && min % 5 === 0) { lastMin = min; marks.push(`${min}m $${Math.round(G.money)} skill ${R.FAMILY_LIST.map(f => G.skill[f].toFixed(1)).join(' ')} gpus ${G.nodes.filter(n => n.kind === 'gpu' && n.cell && !n.site).map(n => n.pw?.powered ? (n.pw.speed < 1 ? 'h' : 'o') : 'x').join('')}+${G.nodes.filter(n => n.kind === 'gpu' && n.site && n.on && G.sites[n.site].signed).length} share ${Math.round(share(G, 'you') * 100)}%/${Math.round(signedShare(G, 'you') * 100)}% · ${G.rivals.map(X => `${STYLES[X.id].short}-${X.model} ${X.gpus}g ${Math.round(share(G, X.id) * 100)}%/${Math.round(signedShare(G, X.id) * 100)}%`).join(' · ')} · fear ${Math.round(R.fear(G))} demand ${R.demand(G).toFixed(2)}`); } } return { G, maxFear, log, marks };}
if (import.meta.main) { const args = process.argv.slice(2).filter(a => a !== '-q'), quiet = process.argv.includes('-q'); const seeds = Number(args[0]) || 5, style = (args[1] ?? 'sensible') as Style | 'table', vs = args[2] ?? 'overfit'; const ids = (s: number) => (vs === 'two' ? rivalsFor(s, true) : vs.split(',') as RivalId[]); if (style === 'table') { // every deal style against the same rivals: how often it wins, how fast, what it earns, and what it costs the city console.log(`vs ${vs}, seeds 1 to ${seeds}\nstyle | won | win minutes | earned | most fear | jobs lost`); for (const st of DEALS.filter(x => x !== 'idle')) { const rows = Array.from({ length: seeds }, (_, i) => game(i + 1, st, ids(i + 1))); const won = rows.filter(r => r.G.ending?.who === 'you'), mins = won.map(r => `${(r.G.ending!.t / 60).toFixed(0)}${r.G.ending!.path[0]}`); const avg = (f: (r: typeof rows[0]) => number) => Math.round(rows.reduce((t, r) => t + f(r), 0) / rows.length); console.log(`${st.padEnd(12)} | ${`${won.length}/${seeds}`.padEnd(3)} | ${(mins.join(' ') || '-').padEnd(20)} | $${String(Math.round(avg(r => r.G.earned) / 1000)).padStart(3)}k | ${String(avg(r => r.maxFear)).padStart(9)} | ${avg(r => r.G.jobsLost)}`); } } else { const won: number[] = []; for (let s = 1; s <= seeds; s++) { const { G, maxFear, log, marks } = game(s, style, ids(s)); const end = G.ending ? `${G.ending.who === 'you' ? 'WON' : `LOST to ${STYLES[G.ending.by!].short}`} (${G.ending.path}) at ${(G.ending.t / 60).toFixed(1)}m` : G.broke ? `broke at ${(G.t / 60).toFixed(1)}m` : 'no winner in 60m'; if (G.ending?.who === 'you') won.push(G.ending.t / 60); console.log(`seed ${s} vs ${ids(s).join('+')}: ${end} · MW-${G.model} vs ${G.rivals.map(X => `${STYLES[X.id].short}-${X.model}`).join(' ')} · earned $${Math.round(G.earned)} · jobs lost ${G.jobsLost} (${G.rivals.map(X => `${STYLES[X.id].short} ${X.jobsLost}`).join(', ')}) · most fear ${Math.round(maxFear)}${quiet ? '' : `\n ${log.join(' ')}`}`); if (!quiet) for (const m of marks) console.log(' ' + m); } console.log(`won ${won.length} of ${seeds}${won.length ? `, in ${Math.min(...won).toFixed(0)} to ${Math.max(...won).toFixed(0)} minutes` : ''}`); }}