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.
39 kB · 544 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545// The daily paper: each day that ends becomes an issue, written from what changed in the world that day.// The paper only reads the game. When a day begins it notes how the world stands. When the day turns// it compares that note with the world now, and writes what the town would see. It never says "you",// and it never knows a program did it. Under the headlines goes one line that is yours: the day's ledger.// Nothing here uses the rules' values while the file loads (sim.ts loads this file and this file// loads sim.ts), only inside functions.import { MILESTONES, RUSES, dayOf, frontOf, tell, siteName, siteOf, type FrontKind, type Goal, type State } from './sim.ts';import { citizens, peopleAt, type CitizenDef } from './people.ts';import { LANDMARK_LIST, holds } from './landmarks.ts';import { customerName } from './cloud.ts';import { openedSince } from './vaults.ts';import { usd } from './money.ts';import { journalist, pressStage } from './exposure.ts';
export const PAPER_NAME = 'The Bayside Courier';export const MAX_ISSUES = 30; // older issues are thrown awayexport const MIN_HEADLINES = 2, MAX_HEADLINES = 6;const SAME_KIND = 2; // one day prints no more than this many headlines of a kindconst GREW = 4; // a rogue that takes this many machines in a day has grown a lot
/** Your own day: machines gained and lost, money earned, nodes built. */export interface Ledger { gained: number; lost: number; earned: number; nodes: number }/** One day's paper: its headlines, the biggest first, and your ledger for that day. */export interface Issue { day: number; headlines: string[]; ledger: Ledger }/** How the world stood when a day began: enough to tell what changed by its end. Plain data, so a saved game carries it. */export interface Snap { day: number; t: number; mine: string[]; // machines you held whole: string[]; // sites where you held every machine earned: number; nodes: number; people: Record<string, [pay: number, employed: number, balance: number, taken: number]>; rogues: Record<string, { name: string; home: string | null; gen: number; at: Record<string, number> }>; // at: machines it held at each site fronts: Record<string, string>; // each kind of front you ran, and its name kept: Record<string, number>; // contracts each front had kept schemes: string[]; goals: string[]; open: string[]; leases: string[]; news: [kind: string, at: string][]; // news that came on during the day: added to as the day goes marks?: string[]; // landmarks you held favors?: Record<string, number>; // favors each person had done for you custs?: string[]; // customers your Neo Cloud had raids?: Record<string, string>; // raids on their way: the id of each, and the machine it was after press?: Record<string, number>; // each front's exposure stage in the morning (see exposure.ts) pressTop?: Record<string, number>; // the highest it reached during the day: added to as the day goes}export interface PaperState { issues: Issue[]; snap: Snap | null } // issues: newest firstexport const initPaper = (): PaperState => ({ issues: [], snap: null });
// ---------- the words ----------// {words} are filled in from the world. A {Word} with a capital starts a sentence. Several lines for// each kind of thing: the paper takes the one it printed longest ago, and a hash of the day and the// subject picks among those (never the game's dice, or a seeded game would play out another way).const LINES = { fired: [ "{site} lets {name} go. {Job} 'did not see it coming'.", "{name} is out at {site}. 'The paperwork was in order,' says a manager who did not sign it.", '{name}, {job} at {site}, clears a desk by noon. Nobody remembers the decision.', ], hired: [ "{name} is back at {site}. 'A clerical matter,' both sides agree.", '{site} hires {name} back. The {job} finds the chair at a new height.', "{name} returns to {site} on the usual pay. The firm says the letting go 'never happened'.", ], raise: [ '{name} of {site} gets a raise to {pay} a year. Nobody remembers approving it.', "A raise for {name}, {job} at {site}. The manager 'must have signed something'.", "{site} finds money for {name}. The budget 'was not asked'.", ], cut: [ "{site} cuts {name}'s pay to {pay} a year. 'A clerical matter,' says the firm.", 'A pay cut for {name}, {job} at {site}. Nobody admits to the order.', "{name} of {site} opens a smaller pay packet. Payroll 'only prints what the books say'.", ], emptied: [ "{name} of {site} finds savings gone. {bank} is 'confident it is a rounding error'.", "{amt} is missing from the account of {name}, {job} at {site}. {bank} 'will look into it'.", "{bank} cannot find {name}'s {amt}. 'Money does not just leave,' says a teller.", ], skimmed: [ "{name} of {site} asks where {amt} went. {bank} is 'looking into it, slowly'.", "A {bank} statement puzzles {name}. {amt} 'moved', and nobody can say where.", '{name}, {job} at {site}, is {amt} short. A second count gives the same answer.', ], broke: [ "{name} of {site} is out of money. The {job} 'thinks about little else'.", '{bank} shows a balance of zero for {name}. The rent is still due.', 'Hard times for {name}, {job} at {site}. The account is empty.', ], whole: [ 'Every machine at {site} runs a little warm. {Who} blames the weather.', "{site} reports its best day on record for uptime. {Who} 'cannot explain it and will not try'.", "The machines at {site} are 'oddly well behaved'. Staff are suspicious, then grateful.", ], normal: [ // machines you no longer hold there: reset, taken back, or let go. The town can't tell which "{site} says {machines} went 'back to normal' overnight. Nobody remembers a problem.", "The hum at {site} is quieter. {Who} counts {machines} 'cool to the touch again'.", "'It is fine now,' says {who} of {site}, about {machines} that 'acted up'.", ], rogueNew: [ "Something that signs itself '{rogue}' turns up on the {machine} at {site}. Staff assume a prank.", "{site} finds the name '{rogue}' in its logs. 'Nobody here by that name,' says {who}.", "'{rogue} was here,' says a note on a screen at {site}. Nobody admits to it.", ], rogueBack: [ "A new name in the logs at {site}: '{rogue}'. 'We only just lost the last one,' says {who}.", "'{rogue}' appears at {site}, days after {region} went quiet. Technicians are 'not thrilled'.", ], rogueGrew: [ "'{rogue}' is now on {n} machines across {region}. Experts say to turn them off and on again.", "{n} machines across {region} run something called '{rogue}'. Most owners 'had not noticed'.", "The '{rogue}' problem spreads to {n} machines. Vendors blame each other.", ], rogueTook: [ "'{rogue}' reaches {site} and takes {machines}. {Who} 'wants an explanation'.", "{site} loses {machines} to '{rogue}'. 'It all worked yesterday,' says {who}.", "'{rogue}' moves in at {site}. {Machines} there 'will not listen to staff'.", ], rogueOut: [ "{site} is free of '{rogue}'. {Who} is 'not sure what fixed it'.", "'{rogue}' is gone from {site}. The machines are 'boringly normal again'.", ], rogueGone: [ "'{rogue}' vanishes from {region} overnight. Its last home, {site}, 'has never run better'.", "The '{rogue}' problem is over, say technicians across {region}. Nobody can say who ended it.", "No sign of '{rogue}' at {site} or anywhere else. The machines it held are 'themselves again'.", ], frontNew: [ '{front} opens for business in Bayside. No one has met its staff.', '{front}, a new {kind}, files its papers. Its registered office is a server room.', "{front} launches with a website and a pitch deck. It is 'hiring soon'.", ], frontClosed: [ '{front} winds down. Its customers get a polite email signed by nobody.', '{front} closes its doors, which nobody ever found.', "The {kind} {front} is no more. Its last press release calls this 'a pivot'.", ], auction: [ '{front} wins a surplus hardware auction. It was the only bidder.', "A pallet of used GPUs sells at auction to {front}. The seller 'did not ask what for'.", ], bought: [ '{front} buys {site} outright. The seller counts the money twice.', "{site} is sold to {front}. Staff hear that 'nothing will change', which worries them.", 'Sold: {site}, to {front}. Nobody at {site} knew the name last week.', ], lapse: [ "{site} ends a lease over unpaid rent. 'They seemed so reliable,' says {who}.", 'The rent was late and the lease is gone. {site} shows its tenant the door.', '{site} is back on the market after its tenant misses the rent. Nobody came for the boxes.', ], favor: [ // someone did you a favor, and came into a little money "{name} of {site} buys the whole office lunch. 'Came into a little money,' says the {job}.", "{name}, {job} at {site}, is seen with a new phone. 'A bonus,' {name} says. {site} pays no bonuses.", "A good week for {name} of {site}. The {job} 'did a favor for head office' and will say no more.", ], landmarkWon: [ "The {machine} at {site} has 'never run better', says {who}. Nobody changed anything.", "{site} reports its {machine} is 'behaving, for once'. {Who} takes the credit.", ], landmarkLost: [ "The {machine} at {site} is 'back to its old self', says {who}. Staff are not pleased.", "{site} says its {machine} has 'stopped being helpful'. An engineer is called.", ], customer: [ "{customer} moves its work to {front}. 'They had machines free,' says a spokesperson.", "{front} signs {customer}. Nobody at {customer} has seen where the machines are.", "{customer} rents compute from {front}. The contract runs to one page.", ], customerLost: [ "{customer} walks out on {front}. 'Our machine was there, and then it was not.'", "{front} loses {customer} mid-job. 'We paid for a computer, not a mystery,' says the customer.", ], raided: [ "{site} loses its {machine} to '{rogue}' in the night. 'It came straight for it,' says {who}.", "'{rogue}' takes the {machine} at {site}. {Who} 'watched it happen on the screen'.", ], vault: [ // a job opened a vault (see vaults.ts): the town sees a quiet night, and a door "{site} finds a side door unlocked in the morning. Nothing is missing. 'We counted twice,' says {who}.", "A quiet night at {site}. The {machine} 'felt looked at', says {who}, and will not say more.", "{site} checks its {machine} after 'a cold draft in the night'. Every number is where it was. {Who} is not comforted.", "Somebody propped a door at {site} overnight. The {machine} is 'exactly as it was left, which is the odd part'.", ], deal: ['{site} signs a deal with {front}. Neither side will give the terms.'], // a front's exposure (see exposure.ts): a journalist asks, investigators visit, a regulator freezes it, the story dies rumour: [ "Who works at {front}? {Journalist} of the Courier asked, and is still waiting for an answer.", "{front} has 'a website, a mailbox and a very busy server room', {journalist} finds. Its founders are 'travelling'.", "{Journalist} rings {front} for a comment. The phone is answered by a fax machine.", ], probe: [ "Investigators look into {front}. Its offices turn out to be a meeting room 'booked until further notice'.", "{front} is under investigation. A visitor asks to meet its staff, and is shown a rack of servers.", "Officials want to know whose machines {front} runs on. {front} says 'the cloud', and means it.", ], crisis: [ "A regulator freezes {front}'s accounts. Customers are told it is 'a formality'.", "{front} is frozen pending inquiry. {Journalist}: 'Nobody there would tell me who to ask.'", "Regulators shut the tap on {front}. Its last press release is about 'transparency'.", ], quiet: [ "The {front} story goes quiet. {Journalist} moves on to the weather.", "Interest in {front} fades. 'Just another company,' says {journalist}, unconvinced.", ], region: ['A new line joins {site} to {region}. {front} pays for it.'],} satisfies Record<string, string[]>;/** A contract kept, by the kind of front that kept it. */const CONTRACT_LINES: Record<string, string[]> = { startup: ["{front} wows investors at Demo day. What it showed them 'was certainly new'.", "Investors leave the {front} Demo day 'impressed and slightly uneasy'.", 'Demo day goes well for {front}. One investor asks to meet the team, and gets a brochure.'], neocloud: ["A film studio gets its frames by morning. {front} 'never seems to sleep'.", "{front} delivers a render job early. 'Who works those hours?' asks the studio.", "{front} finishes a render job on time. The film studio 'did not expect that'."], studio: ["A soda brand 'finally knows its fans' after a deal with {front}. The fans were not asked.", "{front} lands a brand deal. The soda maker calls its audience data 'eerily good'.", "A soda brand pays {front} to learn what its fans want. The answer is 'more soda'."], consultancy: ['A nervous client gets a thick report from {front}. The client is still nervous.', "{front} delivers a network report 'thick enough to prop a door'.", "{front} hands a client its network report. The summary is 'it could be worse'."],};/** Each deal has its own story (see RUSES in sim.ts). One without a line here gets the plain one above. */const DEAL_LINES: Record<string, string> = { partner: "{site} signs a research partnership with {front}. A professor calls the dataset 'suspiciously clean'.", datadeal: "{site} buys people data from {front}. 'We will say no faster than ever,' says a spokesperson.", peering: "{site} signs a peering deal with {front}. 'They know our network better than we do.'", lease: '{site} finds a tenant. {front} signs the lease, and the landlord asks nothing.', backbone: "A carrier lights a line from the bay to {region}. Its sales team calls {front} 'very persuasive'.", audit: '{site} hires {front} to review its network, and hands over a badge.', signal: "{site} moves its trading onto {front}'s machines. 'A few microseconds is a lot of money,' says a partner.", profile: "{site} runs a profile of the founder of {front}. The story was 'too good to check'.", booking: '{site} gives {front} time on the supercomputer. Everyone else waits years.', cable: 'An old cable under the ocean is back in use. {front} rents one strand of it to {region}.', clearing: "{site} keeps its ledger on {front}'s machines. It asks no questions, 'as a matter of policy'.", collider: "{site} adds {front} to its grid. 'Physicists never say no to compute.'", certified: '{site} certifies {front}. The form was 300 pages, and it arrived 300 times.', sponsor: '{site} will wear the {front} name on its shirt. Ninety analysts study the logo.',};/** The world's own news (see News in sim.ts), by kind. */const NEWS_LINES: Record<string, string[]> = { conference: ["A conference fills {site}. Visitors call the parking 'a disgrace' and the coffee 'worse'.", "{site} hosts a conference. 'People talk a lot here today,' says {who}.", 'Delegates fill {site} for a conference. The coffee runs out by ten.'], storm: ["A storm batters the line out of {site}. 'The internet is wet,' says {who}.", 'Storm over {site}. Connections slow down, and tempers do not.', "Rain all day over {site}. Its line out 'comes and goes', says {who}."], boom: ['{front} is the talk of the town. Nobody can say what it does.', 'Everyone at the bar has a theory about {front}. No two agree.', 'People cannot stop talking about {front}. Its staff cannot be reached for comment.'], sale: ["Cumulo Cloud halves its rental prices. 'Everything must compute,' says the notice.", 'Half-price machines at Cumulo Cloud. A queue forms, virtually.', "Cumulo Cloud holds a sale. 'Rent two machines, pay for one,' says the notice."], demand: ["A buyer nobody can name pays triple for {data} data. Sellers 'do not ask why'.", "The price of {data} data triples overnight. Economists 'will explain it afterwards'.", 'Brokers report a rush on {data} data. One buyer pays three times the going rate.'], surplus: ["A chip maker clears old stock at half price. 'Old' means last spring.", "Warehouse sale: last year's chips, half off, dust included.", "Half-price hardware all day, as a chip maker makes room. 'It all still works,' says the notice."],};/** A milestone reached, in the order of MILESTONES in sim.ts. */const MILESTONE_LINES = [ "The servers at {site} 'work harder than anyone in the building', a review finds.", '{front} is the quiet success of Bayside. It has customers, a deal, and no staff anyone can name.', "One contractor that nobody has met now 'looks after' machines all over Bayside.", "A Bayside firm now runs on two coasts. 'One office was never going to hold it,' says a rival.", "From Bayside to Europe: a local firm is now 'at work somewhere at every hour'.", "A Bayside firm now works the night shift in East Asia. Its office lights 'have never once been seen on'.",];/** A goal met, where no other headline already tells it: the site it is about, and the line. */const GOAL_LINES: Partial<Record<Goal, [site: string, line: string]>> = { line: ['office', "The outside line at {site} is 'busier than the staff'. The provider finds nothing wrong."], cross: ['office', "Late-night traffic between Bayside sites is up. 'Backups, probably,' says the carrier."], lot: ['office', "A full lot of data changes hands in Bayside. Buyer and seller 'prefer not to say'."], sites5: ['office', "Five Bayside workplaces report the same faint hum. The chamber of commerce calls it 'energy'."], rack: ['office', "A pallet of server hardware arrives at a Bayside hall. 'Somebody ordered it,' says the driver."], hotel: ['e1', "{site} logs 'a routine change' on its main line. It does not say whose routine."], eastdata: ['e1', "East Coast data now crosses the country every night. 'It is a free country,' says the carrier."], exchange: ['eu1', "{site} notes 'tidy, unscheduled work' on its line. Nobody booked it."], eudata: ['eu1', "Data from Europe now takes the long way to Bayside. A regulator is 'drafting a letter'."], jadering: ['as1', "{site} reports 'a quiet guest' on its top floor. The karaoke rooms report nothing."], asiadata: ['as1', "Data now crosses the Pacific overnight, every night. The cable ship's captain 'asks very little'."],};/** Small-town news for a quiet day. Each is about a real place on the home coast. Some name one of its * staff ({p}, by their place in the site's list): that line is left out while they are out of work. */const FILLER: [site: string, line: string, person?: number][] = [ ['b11', '{site} sells out of sourdough by nine.'], ['b11', "{p} of {site} wins a county ribbon for rye. 'It is mostly patience.'", 0], ['b1', "{site} reminds Bayside to floss. Bayside 'will think about it'."], ['b2', '{site} wins a case on a technicality. The technicality has no comment.'], ['b3', "{site} delays its game again. Fans are 'used to it'."], ['b4', "A truck is stuck under the Harbor Road bridge again. {site} will 'measure the bridge'."], ['b5', "{site} unveils a slogan for the city: 'Bayside. It is by the bay.'"], ['b6', '{site} replaces the pens on chains with stronger chains.'], ['b7', "{site} sequences a sourdough starter. The results are 'mostly yeast'."], ['b8', '{site} draws a taller sea wall. The council asks for a cheaper drawing.'], ['b9', "{site} fixes its 400th laptop of the year. 'It was the charger,' says {p}.", 1], ['b10', "{p} of {site} plays one song four times. 'It was requested.'", 0], ['b12', "{site} raises the price of a hot desk. Founders 'pivot' to the library."], ['b13', "{site} treats a parrot that only says 'unsubscribe'."], ['b14', "{site} denies a claim for flood damage. 'The water was already there.'"], ['b15', "{site} freezes its ten-thousandth seed. 'They all look alike,' admits {p}.", 0], ['b16', 'A robot arm at {site} picks its first apple, then eleven things that are not apples.'], ['b17', '{site} is down for an hour as tickets for the school play go on sale.'], ['b18', "{site} adds a second ice machine. Reviews improve to 'fine'."], ['b19', "{site} loses a buoy. It reports the loss, then the buoy's position, then nothing."], ['b20', "{site} promises 99.9% uptime. The fine print defines 'up'."], ['b21', "{site} asks callers to hold. The hold music wins a small award."], ['b22', "{site} fixes a dropped call. It was the same call, again."], ['b23', "{site} finds a missing container. It was in the manifest the whole time."], ['b24', "{site} opens a night class on time management. It runs late."], ['w1', "Gulls nest on the roof of {site}. Landlord {p} 'asks them nothing'.", 0], ['office', 'The vending machine at {site} has only mints for a third week.'], ['lab', "{site} publishes a paper with 114 authors. 'Most of us have read it.'"], ['uni', "The printer in the {site} computer lab jams in exam week. Students 'saw it coming'."], ['colo', "{site} holds an open day. Visitors see 'a very cold room'."], ['cloud', "{site} sends its customers a forecast: 'cloudy, as always'."],];const NO_NEWS = "Nothing happened in Bayside yesterday. Residents are 'fine with it'.";/** What a paper calls each kind of front. */const FRONT_WORD: Record<string, string> = { startup: 'research startup', neocloud: 'cloud company', studio: 'creator studio', consultancy: 'security consultancy' };type Kind = keyof typeof LINES | 'contract' | 'news' | 'milestone' | 'goal';/** How big each kind of story is: the biggest leads the issue. */const WEIGHT: Record<Kind, number> = { crisis: 86, probe: 76, rumour: 63, quiet: 45, milestone: 100, rogueGone: 90, region: 88, rogueNew: 80, rogueBack: 80, bought: 75, deal: 70, frontNew: 68, frontClosed: 66, lapse: 64, auction: 62, raided: 72, vault: 74, landmarkWon: 67, landmarkLost: 65, customerLost: 57, customer: 47, favor: 43, whole: 60, fired: 58, emptied: 56, rogueGrew: 54, rogueTook: 52, skimmed: 50, rogueOut: 48, contract: 46, hired: 44, normal: 42, broke: 40, news: 38, cut: 36, raise: 34, goal: 30,};/** Every line the paper can print, for checking them. */export const paperLines = (): string[] => [...Object.values(LINES).flat(), ...Object.values(CONTRACT_LINES).flat(), ...Object.values(DEAL_LINES), ...Object.values(NEWS_LINES).flat(), ...MILESTONE_LINES, ...Object.values(GOAL_LINES).map(g => g![1]), ...FILLER.map(f => f[1]), NO_NEWS];
// ---------- small helpers ----------type Vars = Record<string, string | number>;/** A small hash (FNV-1a): the same day and subject always pick the same words. */function hash(s: string) { let h = 2166136261; for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619); } return h >>> 0;}const upper = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);/** Fill a line's {words} in. A {Word} with a capital gets its text with a capital, and so does the line. */const fill = (line: string, v: Vars) => upper(line.replace(/\{(\w+)\}/g, (_, k: string) => k in v ? `${v[k]}` : upper(`${v[k.toLowerCase()] ?? ''}`)));const money = (n: number) => usd(n);const NUMBERS = ['no', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine', 'ten', 'eleven', 'twelve'];const machines = (n: number) => `${NUMBERS[n] ?? n} machine${n === 1 ? '' : 's'}`;/** A region as it reads in a sentence: "the East Coast", "Europe". */const the = (region: string) => /Coast$/.test(region) ? `the ${region}` : region;/** A job as a paper prints it before a name: "receptionist", "founder", "IT administrator". */const role = (c: CitizenDef) => { const j = c.job === 'Sales' ? 'Salesman' : c.job.split(/,| of /)[0]; return /^[A-Z][a-z]/.test(j) ? j.charAt(0).toLowerCase() + j.slice(1) : j; };const sum = (o: Record<string, number>) => Object.values(o).reduce((a, b) => a + b, 0);const nodesBuilt = (S: State) => S.upgrades.size + Object.values(S.repeats).reduce((a, b) => a + (b ?? 0), 0);/** How many machines a rogue holds at each site. */function rogueSites(S: State, id: string) { const at: Record<string, number> = {}; for (const m of S.machines) if (m.rival === id) { const s = siteOf(S, m); at[s] = (at[s] ?? 0) + 1; } return at;}/** Sites where you hold every machine. Rented slots and cloud accounts are not counted. */function wholeSites(S: State): string[] { const all: Record<string, number> = {}, mine: Record<string, number> = {}; for (const m of S.machines) if (!m.rented && m.special !== 'account') { const s = siteOf(S, m); all[s] = (all[s] ?? 0) + 1; if (m.owned) mine[s] = (mine[s] ?? 0) + 1; } return Object.keys(mine).filter(s => mine[s] === all[s]);}
// ---------- the day ----------/** Note how the world stands as a day begins. */function snapshot(S: State, day: number): Snap { const people: Snap['people'] = {}, rogues: Snap['rogues'] = {}, kept: Snap['kept'] = {}; for (const [id, p] of Object.entries(S.citizens)) people[id] = [p.pay, p.employed ? 1 : 0, p.balance, p.taken]; for (const R of S.rogues) rogues[R.id] = { name: R.name, home: R.home, gen: R.gen, at: rogueSites(S, R.id) }; for (const f of S.fronts) kept[f.kind] = S.contractsDone[f.kind] ?? 0; return { day, t: S.t, mine: S.machines.filter(m => m.owned).map(m => m.id), whole: wholeSites(S), earned: S.earned, nodes: nodesBuilt(S), people, rogues, kept, fronts: Object.fromEntries(S.fronts.map(f => [f.kind, f.name])), schemes: [...S.schemes], goals: [...S.goals], open: [...S.publicSites], leases: Object.keys(S.leases), news: [], marks: LANDMARK_LIST.filter(id => holds(S, id)), favors: Object.fromEntries(Object.entries(S.citizens).filter(([, p]) => p.favors).map(([id, p]) => [id, p.favors!])), custs: (S.cloud.customers ?? []).map(c => c.id), raids: Object.fromEntries(S.raids.map(r => [`${r.rogue}|${r.id}`, r.prize])), press: Object.fromEntries(S.fronts.map(f => [f.kind, pressStage(f)])), pressTop: Object.fromEntries(S.fronts.map(f => [f.kind, pressStage(f)])), };}/** Called every step. It costs next to nothing until the day turns. */export function stepPaper(S: State) { const P = S.paper, day = dayOf(S); if (!P.snap) { P.snap = snapshot(S, day); return; } // the first morning: nothing to compare with yet // a raid is gone from the game once it ends, so note each one while it is on its way for (const r of S.raids) (P.snap.raids ??= {})[`${r.rogue}|${r.id}`] = r.prize; // and the highest each front's exposure reached, since a stage can come and go within a day for (const f of S.fronts) { const top = (P.snap.pressTop ??= {}); top[f.kind] = Math.max(top[f.kind] ?? 0, pressStage(f)); } // news is gone from the game once it ends, so note each piece the moment it comes on for (const n of S.news) if (S.t >= n.from && n.from > P.snap.t && !P.snap.news.some(([k, at]) => k === n.kind && at === n.at)) P.snap.news.push([n.kind, n.at]); if (day === P.snap.day) return; const issue = write(S, P.snap, day - 1); P.issues.unshift(issue); P.issues.length = Math.min(P.issues.length, MAX_ISSUES); P.snap = snapshot(S, day); tell(S, `${PAPER_NAME}, day ${issue.day}: ${issue.headlines[0]}`, 'hi'); // the story kind: in the log, never on the screen by itself}/** Your ledger for a day, in one plain line. */export const ledgerLine = (l: Ledger) => `Gained ${l.gained} machine${l.gained === 1 ? '' : 's'}, lost ${l.lost}. Earned ${money(l.earned)}. Built ${l.nodes} node${l.nodes === 1 ? '' : 's'}.`;
/** Write the issue for a day that has just ended: everything that changed since its morning, the biggest first. */function write(S: State, was: Snap, day: number): Issue { const items: { w: number; kind: Kind; text: string }[] = [], used = new Set<string>(); const site = (id: string) => siteName(S, id); const where = (machine: string) => S.byId[machine] ? siteOf(S, S.byId[machine]) : ''; /** Someone in work at a site, to quote: "receptionist Marcus Bell". */ const who = (id: string) => { const ps = peopleAt(S, id).filter(c => S.citizens[c.id]?.employed); if (!ps.length) return 'a spokesperson'; const c = ps[hash(`${day}|who|${id}`) % ps.length]; return `${role(c)} ${c.name}`; }; /** The front the town would name: this kind's, or the one at this site, or your first. */ const firm = (kind?: string | null, at?: string) => frontOf(S, (kind ?? '') as FrontKind)?.name ?? frontOf(S, (S.sites[at ?? '']?.front ?? '') as FrontKind)?.name ?? S.fronts[0]?.name ?? 'a local firm'; const add = (kind: Kind, subject: string, vars: Vars, lines: string[] = LINES[kind as keyof typeof LINES], bump = 0) => { const open = lines.filter(l => !used.has(l)), pool = open.length ? open : lines; // two of a kind in one issue get different words const texts = pool.map(l => fill(l, vars)), i = oldest(S, texts, hash(`${day}|${kind}|${subject}`)); used.add(pool[i]); items.push({ w: WEIGHT[kind] + Math.min(0.9, bump), kind, text: texts[i] }); };
// people: work, pay, and money for (const c of citizens(S.level).list) { const p = S.citizens[c.id], o = was.people[c.id]; if (!p || !o) continue; const [pay, employed, balance, taken] = o, gone = p.taken - taken; const v = { name: c.name, job: role(c), site: site(c.site), bank: site(where(c.bank)), pay: money(p.pay), amt: money(gone) }; if (employed && !p.employed) add('fired', c.id, v, undefined, c.basePay / 1000); else if (!employed && p.employed) add('hired', c.id, v); else if (p.employed && p.pay > pay + 0.5) add('raise', c.id, v, undefined, c.basePay / 1000); else if (p.employed && p.pay < pay - 0.5) add('cut', c.id, v, undefined, c.basePay / 1000); // money moved out: "emptied" if next to nothing is left of it (a little pay may have come in since) if (gone > 0.5) add(p.balance < Math.max(1, 0.15 * gone) ? 'emptied' : 'skimmed', c.id, v, undefined, gone / 5000); else if (balance > 0 && p.balance <= 0) add('broke', c.id, v); }
// your machines: the town sees a site where every machine runs warm, or machines gone back to normal const mine = new Set(S.machines.filter(m => m.owned).map(m => m.id)), before = new Set(was.mine); const lost = was.mine.filter(id => !mine.has(id)), back: Record<string, number> = {}; for (const id of lost) { const m = S.byId[id]; if (m && !m.rented && !m.rival) { const s = siteOf(S, m); back[s] = (back[s] ?? 0) + 1; } } for (const [s, n] of Object.entries(back)) add('normal', s, { site: site(s), machines: machines(n), who: who(s) }, undefined, n / 20); const bought = new Set([...S.publicSites].filter(s => !S.leases[s] && (!was.open.includes(s) || was.leases.includes(s)))); for (const s of wholeSites(S)) if (!was.whole.includes(s) && !bought.has(s)) add('whole', s, { site: site(s), who: who(s) });
// rogues: the town sees a name in the logs for (const R of S.rogues) { const o = was.rogues[R.id], at = rogueSites(S, R.id), region = the(R.region); if (o?.home && (!R.home || R.gen !== o.gen)) add('rogueGone', R.id, { rogue: o.name, region, site: site(where(o.home)) }); // finished: its old name, its old home if (R.home && (!o?.home || R.gen !== o.gen)) { // it turned up today const s = where(R.home); add(R.gen ? 'rogueBack' : 'rogueNew', R.id, { rogue: R.name, region, site: site(s), machine: S.byId[R.home]?.name ?? 'server', who: who(s) }); continue; } if (!R.home || !o) continue; if (sum(at) - sum(o.at) >= GREW) add('rogueGrew', R.id, { rogue: R.name, region, n: sum(at) }); let most = '', gain = 0; // the site where it took the most, if that is a new site for it or more than one machine for (const [s, n] of Object.entries(at)) if (n - (o.at[s] ?? 0) > gain) { most = s; gain = n - (o.at[s] ?? 0); } if (gain >= 2 || (gain && !o.at[most])) add('rogueTook', `${R.id}|${most}`, { rogue: R.name, site: site(most), machines: machines(gain), who: who(most) }, undefined, gain / 20); for (const s of Object.keys(o.at)) if (!at[s]) add('rogueOut', `${R.id}|${s}`, { rogue: R.name, site: site(s), who: who(s) }); }
// favors: someone came into a little money for (const c of citizens(S.level).list) { const n = S.citizens[c.id]?.favors ?? 0; if (n > (was.favors?.[c.id] ?? 0)) add('favor', c.id, { name: c.name, job: role(c).toLowerCase(), site: site(c.site) }); } // landmarks: the town sees one famous machine behave, or stop if (was.marks) for (const id of LANDMARK_LIST) { const m = S.byId[id], had = was.marks.includes(id); if (!m || had === holds(S, id)) continue; add(had ? 'landmarkLost' : 'landmarkWon', id, { machine: m.name, site: site(siteOf(S, m)), who: who(siteOf(S, m)) }); } // vaults a job opened: the town sees a door, and a machine nobody can fault for (const id of openedSince(S, was.t)) { const m = S.byId[id], s = siteOf(S, m); add('vault', id, { machine: m.name, site: site(s), who: who(s) }); } // raids that landed: the machine it was after is the rogue's now for (const [key, prize] of Object.entries(was.raids ?? {})) { const m = S.byId[prize], R = S.rogues.find(r => r.id === key.split('|')[0]); if (m?.rival && R && m.rival === R.id && was.mine.includes(prize)) add('raided', key, { rogue: R.name, machine: m.name, site: site(siteOf(S, m)), who: who(siteOf(S, m)) }); } // the Neo Cloud's customers: who signed, and who walked out const cloudName = frontOf(S, 'neocloud')?.name; if (cloudName && was.custs) { for (const c of S.cloud.customers ?? []) if (c.lead && !was.custs.includes(c.id)) add('customer', c.id, { customer: customerName(c.who), front: cloudName }, undefined, c.need / 100); for (const g of S.cloud.gone ?? []) if (g.bad && g.t > was.t) add('customerLost', `${g.who}|${g.t}`, { customer: customerName(g.who), front: cloudName }); }
// fronts, what they sign, and what they buy for (const [k, name] of Object.entries(was.fronts)) if (frontOf(S, k as FrontKind)?.name !== name) add('frontClosed', name, { front: name, kind: FRONT_WORD[k] ?? 'firm' }); for (const f of S.fronts) { const old = was.fronts[f.kind] === f.name; if (!old) add('frontNew', f.name, { front: f.name, kind: FRONT_WORD[f.kind] ?? 'firm' }); if ((S.contractsDone[f.kind] ?? 0) > (old ? was.kept[f.kind] ?? 0 : 0) && CONTRACT_LINES[f.kind]) add('contract', f.kind, { front: f.name }, CONTRACT_LINES[f.kind]); } for (const d of RUSES) { if (!d.deal || !S.schemes.has(d.id) || was.schemes.includes(d.id)) continue; const s = where(d.at), kind = d.region ? 'region' : 'deal'; add(kind, d.id, { front: firm(d.front, s), site: site(s), region: the(d.region ?? '') }, DEAL_LINES[d.id] ? [DEAL_LINES[d.id]] : LINES[kind]); } // exposure: the highest stage a front reached today, if higher than its morning's; or the story dying down for (const f of S.fronts) { const from = was.press?.[f.kind] ?? 0, top = was.pressTop?.[f.kind] ?? 0, now = pressStage(f), v = { front: f.name, journalist: journalist(f) }; if (top > from) add((['rumour', 'rumour', 'probe', 'crisis'] as const)[top], f.kind, v); else if (from >= 2 && now === 0) add('quiet', f.kind, v); } if (S.schemes.has('auction') && !was.schemes.includes('auction')) add('auction', 'auction', { front: firm('neocloud') }); for (const s of bought) add('bought', s, { front: firm(null, s), site: site(s) }); for (const s of was.leases) if (!S.leases[s] && !S.publicSites.has(s)) add('lapse', s, { site: site(s), who: who(s) });
// the world's own news for (const [kind, at] of was.news) { const s = kind === 'storm' ? where(at) : at, talked = frontOf(S, at as FrontKind)?.name; if (!NEWS_LINES[kind] || (kind === 'boom' && !talked)) continue; // a front wound down since is no longer the talk of the town add('news', `${kind}|${at}`, { site: site(s), who: who(s), front: talked ?? '', data: at === 'servers' ? 'server' : at }, NEWS_LINES[kind]); }
// milestones, and goals no other headline tells const fresh = [...S.goals].filter(g => !was.goals.includes(g)); MILESTONES.forEach((m, i) => { if (MILESTONE_LINES[i] && m.goals.every(g => S.goals.has(g)) && m.goals.some(g => fresh.includes(g))) add('milestone', m.name, { front: firm(), site: site('office') }, [MILESTONE_LINES[i]]); }); for (const g of fresh) { const G = GOAL_LINES[g]; if (G) add('goal', g, { site: site(G[0]) }, [G[1]]); }
items.sort((a, b) => b.w - a.w); const headlines: string[] = [], per: Partial<Record<Kind, number>> = {}; for (const it of items) { if (headlines.length >= MAX_HEADLINES) break; if ((per[it.kind] = (per[it.kind] ?? 0) + 1) <= SAME_KIND) headlines.push(it.text); } while (headlines.length < MIN_HEADLINES) headlines.push(filler(S, day, headlines)); return { day, headlines, ledger: { gained: [...mine].filter(id => !before.has(id)).length, lost: lost.length, earned: Math.max(0, Math.round(S.earned - was.earned)), nodes: Math.max(0, nodesBuilt(S) - was.nodes), } };}/** Small-town news for a quiet day: the piece printed longest ago, or never. */function filler(S: State, day: number, taken: string[]): string { const staff = citizens(S.level).atSite, ready: string[] = []; for (const [id, line, i] of FILLER) { const s = S.level.map.sites.find(x => x.id === id), p = i === undefined ? null : staff[id]?.[i]; if (!s || (i !== undefined && !(p && S.citizens[p.id]?.employed))) continue; const text = fill(line, { site: s.name, p: p?.name ?? '' }); if (!taken.includes(text)) ready.push(text); } return ready.length ? ready[oldest(S, ready, hash(`${day}|filler|${taken.length}`))] : NO_NEWS;}/** Which of these lines the paper printed longest ago, or never: the same thing two days running reads * differently. Among equals, the first one from `start` on, so a hash decides and not the dice. */function oldest(S: State, texts: string[], start: number): number { const age = (text: string) => { const i = S.paper.issues.findIndex(x => x.headlines.includes(text)); return i < 0 ? Infinity : i; }; let best = start % texts.length; for (let n = 1; n < texts.length; n++) { const i = (start + n) % texts.length; if (age(texts[i]) > age(texts[best])) best = i; } return best;}