An incremental game: grow a 1950s garage AI company into a stable of kin models, then distill and start again

toKin is an incremental game about the first AI company. You found it in a 1950s garage. You work contracts by hand, buy machines, put agents on kin models to earn, research and train, and then distill the company into Logits to start again stronger.
Build 1 ships as a Claude Code mod that runs in a pane beside your work. The game engine is plain TypeScript with no Claude Code dependency, so a standalone build can reuse it later. See docs/ARCHITECTURE.md.
Load the mod from this folder for one session:
claude --plugin-dir /home/kiriketsuki/dev/Personal/toKin
Then run /tokin to open the game pane.
Bun runs the tests and the scripts.
bun install
bun run types:sync # once on a fresh clone: Claude Code API types for tsc
bun test # core, content, sim and script specs
bun run test:plugin # hooks tests, through claude plugin test
bun run typecheck # tsc for the whole repo, then for the core alone
bun run check:boundary # core and content stay free of Claude Code
claude plugin validate . # manifest, state contract and hooks module
| Folder | Holds |
|---|---|
core/ | the pure game engine and its contracts |
content/ | the game tables as plain data |
hooks/ | the Claude Code host adapter and the pane |
sim/ | the headless balance bot |
scripts/ | the boundary check, the content export and the type sync |
types/ | the $.state contract of the mod |
The pane has these sections, top to bottom.
| Section | What it does |
|---|---|
| Header | Cash, tokens per second, compute used, free and wasted, research points, Logits |
| Work | Press to do one contract by hand. It pays at once |
| Machines | Buy compute. A machine appears when you hold half its cost. Each row shows the time to afford it |
| Agents | Hire an agent on a kin model. Assign it to Earn, Research or Train. Remove it to free its compute |
| Contracts | Pick the contract that Earn agents work. An agent needs enough context for the contract |
| Research | Spend research points on upgrades. Locked items say what they need |
| Training | Train the next kin model. A run reserves your free compute until it finishes |
| Logit shop | Spend Logits on levels that survive a Distill |
| Distill | Reset the run and bank Logits. It needs Distillation and a payout of at least 1 |
| Log | The story, plus mirror lines from your Claude Code session |
On the first run the pane asks for a founder name. Type it and press Enter.
Every control is a button in the pane. Click it, or move focus and press Enter. There are no global hotkeys, because the pane shares the terminal with Claude Code.
Raw key presses (Open Question 1): the plugin API delivers raw keys only to a Client element through onKey. A Client needs a separate surface module, and the vscode and mobile surfaces do not have it. So Build 1 uses Buttons with focus and Enter on every surface, and single-key shortcuts such as w are not available.
| Control | Effect |
|---|---|
| Work | One hand-worked contract |
| Buy buttons | Buy one machine at the shown cost |
| Create and assign buttons | Hire an agent, change its job, or remove it |
| Distill, then Confirm Distill | Distill needs two presses, so a slip does not wipe a run |
| Cancel | Closes the Distill confirm step |
The game keeps running while the pane is closed. It saves every 30 seconds and on session end. When you return after an absence, it catches up to a cap of two hours. Only one Claude Code session advances a save at a time. A second session shows a read-only copy and takes over when the first one ends.
Two real events in your Claude Code session echo into the game log. They have no effect on the economy, and each kind shows at most once per 10 minutes.
| Event | Log line |
|---|---|
| A rate limit or an overloaded error from the API | the punch-card supplier is out of stock. come back later. |
| A compaction of your conversation | the ledger was boiled down. the sums survived. the margin notes did not. |
The automated checks cover the engine, the host wiring, and the balance. The live playtest is still yours:
claude --plugin-dir /home/kiriketsuki/dev/Personal/toKin./tokin, name the founder, and play from a fresh save to the first Distill./tokin again, and check that the save and the offline catch-up line appear./tokin there, and check that it shows the read-only line.The game is a pure engine with thin hosts. Read docs/ARCHITECTURE.md for the full contract. The summary follows.
core/ holds the engine. step(state, content, dt) and apply(state, content, action) return new objects and never mutate their input. The core reads no wall clock and draws no random number outside the seeded generator in the state.content/ holds the Era 1 tables as plain data. content/era1.json is an export of the same tables.core/ports.ts: storage, a clock, a notifier, an instance id and a seed source. Input arrives as an Action through Game.dispatch. Output is the view model from buildView.hooks/ is the only code that imports claude-code. It builds the ports from the plugin store and clock, forwards presses as actions, and draws the view model. It also turns two Claude Code events into Game.mirror(key) calls.sim/ is a second host. It plays Era 1 headless and checks the balance targets.bun run check:boundary fails when core/ or content/ imports a host API.To ship a standalone game:
HostPorts for your platform. core/fakeports.ts is a worked example.new Game(ports, ERA1) and call start().game.view() on every game.subscribe callback.Action values and pass them to game.dispatch. Show the refusal text when ok is false.game.mirror('rateLimit' | 'compaction'), or skip them.GameState JSON, and core/save.ts migrates old versions.hooks/register.tsx 165 lines1// The Claude Code host adapter for toKin. It wires the core Game to the
2// engine: /tokin opens the pane and starts the game, a tick every second
3// moves the state, and the pane draws the view model from $.state. The mod
4// stays dormant in a session until the player runs /tokin there.
5//
6// Every call on `$` sits in this file, because the plugin validator follows
7// `$` only inside the file that holds the hooks.
8
9import type { Hook, Register } from 'claude-code'
10
11import { ERA1 } from '../content/era1'
12import { Game } from '../core/game'
13import { buildView } from '../core/view'
14import { createPorts, newInstanceId, paneStore, publish } from './adapter'
15import type { SyncedPorts } from './adapter'
16import type { Bridge } from './adapter'
17import { handleName, handlePress } from './controller'
18import { drawPane } from './ui/pane'
19
20// The engine interface a hook receives.
21type Host = Parameters<Hook<'session.start'>>[0]
22
23const PANE = 'tokin'
24
25const openPane = { id: PANE, title: 'toKin', focus: true } as const
26
27const VIEWER_LINE = 'running in another session. this pane is read-only.'
28
29// The Game of this session. A hot reload drops it, and /tokin builds a new one.
30let game: Game | null = null
31// Refreshes the cached clock of the ports of `game`.
32let syncClock: (() => Promise<void>) | null = null
33
34// The state references. The engine needs plugin and key as literals.
35const GAME_REF = { plugin: 'tokin', key: 'game' } as const
36const ROLE_REF = { plugin: 'tokin', key: 'role' } as const
37const PANE_REF = { plugin: 'tokin', key: 'pane' } as const
38
39// The engine calls the adapter needs, as arrow functions over $.
40function bridgeOf($: Host): Bridge {
41 return {
42 storeGet: key => $.store.get(key),
43 storeSet: (key, value) => $.store.set(key, value),
44 clockNow: () => $.clock.now(),
45 clockEvery: (ms, fn) => $.clock.every(ms, fn),
46 toast: text => $.ui.toast(text),
47 setGame: state => $.state.set(GAME_REF, state),
48 setRole: role => $.state.set(ROLE_REF, role),
49 getPane: async () => (await $.state.get(PANE_REF)).value,
50 setPane: ui => $.state.set(PANE_REF, ui),
51 }
52}
53
54async function ensureGame(bridge: Bridge): Promise<Game> {
55 if (game !== null) return game
56 const ports: SyncedPorts = await createPorts(bridge, newInstanceId(await bridge.clockNow()))
57 const created = new Game(ports, ERA1)
58 syncClock = ports.sync
59 created.subscribe(snapshot => void publish(bridge, snapshot))
60 game = created
61 return created
62}
63
64// The API error kinds that mean the supplier is out of stock for a while.
65const RATE_ERRORS: readonly string[] = ['rate_limit', 'overloaded']
66
67function isRateSignal(error: string): boolean {
68 return RATE_ERRORS.includes(error)
69}
70
71const onSessionStart: Hook<'session.start'> = async ($, e, next) => {
72 await $.command.register({
73 name: 'tokin',
74 description: 'Open the toKin game pane, or stop it with "/tokin stop"',
75 argumentHint: 'stop',
76 })
77 const bridge = bridgeOf($)
78 const current = await ensureGame(bridge)
79 // A hot reload drops the Game but keeps $.state. When the role says a game
80 // was live, resume it. Otherwise publish the dormant snapshot, so the pane
81 // never shows a role that no Game backs.
82 const role = (await $.state.get(ROLE_REF)).value ?? 'dormant'
83 if (role === 'ticker' || role === 'viewer') await current.start()
84 await publish(bridge, current.snapshot())
85
86 return next(e)
87}
88
89const onTokin: Hook<'command.run'> = async ($, e) => {
90 const bridge = bridgeOf($)
91 const current = await ensureGame(bridge)
92 if (e.args.trim() === 'stop') {
93 await current.stop()
94 await publish(bridge, current.snapshot())
95
96 return { text: 'toKin stopped.' }
97 }
98 // The cached clock is from session start. Refresh it before the game reads the time.
99 await syncClock?.()
100 await current.start()
101 await publish(bridge, current.snapshot())
102 await $.ui.open(openPane)
103
104 return { text: 'toKin pane opened.' }
105}
106
107// A real rate limit or overload mirrors into the game as one log line.
108const onStopFailure: Hook<'classic.StopFailure'> = async (_$, e, next) => {
109 if (isRateSignal(e.error)) game?.mirror('rateLimit')
110
111 return next(e)
112}
113
114// A real compaction (not the silent precompute) mirrors as a distillation joke.
115const onCompact: Hook<'session.compact'> = async (_$, e, next) => {
116 const result = await next(e)
117 if (e.trigger !== 'precompute' && result.messages !== undefined) game?.mirror('compaction')
118
119 return result
120}
121
122const onSessionEnd: Hook<'session.end'> = async (_$, e, next) => {
123 await game?.stop()
124
125 return next(e)
126}
127
128export const register: Register = on => {
129 on('session.start', onSessionStart)
130 on('command.run', { command: 'tokin' }, onTokin)
131 on('classic.StopFailure', onStopFailure)
132 on('session.compact', onCompact)
133 on('session.end', onSessionEnd)
134 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
135 // Mobile has no Input element, so the founder prompt cannot draw there.
136 if (e.surface === 'mobile') {
137 const { Text } = $.ui.resolve(e)
138 return <Text>toKin needs the terminal or the desktop app.</Text>
139 }
140 const kit = $.ui.resolve(e)
141 const { Box, Text } = kit
142 const state = (await $.state.get(GAME_REF)).value ?? null
143 const role = (await $.state.get(ROLE_REF)).value ?? 'dormant'
144 const ui = (await $.state.get(PANE_REF)).value
145 if (state === null || role === 'dormant') {
146 return <Text dimColor>Run /tokin to start toKin in this session.</Text>
147 }
148 const store = paneStore(bridgeOf($))
149
150 return (
151 <Box flexDirection="column">
152 {role === 'viewer' && <Text dimColor>{VIEWER_LINE}</Text>}
153 {drawPane({
154 kit,
155 view: buildView(state, ERA1),
156 ui: ui ?? { confirmingDistill: false, notice: null },
157 columns: e.props.bodyColumns,
158 press: key => void (game && handlePress(game, store, key)),
159 submitName: text => void (game && handleName(game, store, text)),
160 })}
161 </Box>
162 )
163 })
164}
165content/era1.ts 168 lines1// The Era 1 content pack: a 1950s garage, four machines, four kin models.
2// Plain JSON-serialisable data typed by core/types.ts. It holds no function.
3// The balance bot in sim/ tuned the numbers. docs/balance.md lists what changed and why.
4
5import type { ContentPack, ContractDef, MachineDef, ModelDef, ShopDef } from '../core/types'
6import { ERA1_NAMES } from './era1-names'
7import { ERA1_RESEARCH } from './era1-research'
8import { ERA1_STORY } from './era1-story'
9
10const MACHINES: readonly MachineDef[] = [
11 {
12 id: 'relay-rack',
13 name: 'Relay Rack',
14 flavour: 'secondhand, from a telephone exchange. it still smells of cigarettes.',
15 baseCost: 25,
16 ratio: 1.13,
17 flops: 1,
18 startsUnlocked: true,
19 perks: [],
20 },
21 {
22 id: 'valve-bank',
23 name: 'Thermionic Tensor Valve bank',
24 flavour: 'warm to the touch. then warm to stand near. then a fire risk.',
25 baseCost: 400,
26 ratio: 1.14,
27 flops: 12,
28 startsUnlocked: false,
29 perks: [],
30 },
31 {
32 id: 'drum-cabinet',
33 name: 'Drum Memory Cabinet',
34 flavour: 'a spinning cylinder that remembers. it never stops spinning.',
35 baseCost: 6000,
36 ratio: 1.15,
37 flops: 100,
38 startsUnlocked: false,
39 perks: [{ kind: 'contextPerUnit', value: 0.1, maxUnits: 5 }],
40 },
41 {
42 id: 'core-lattice',
43 name: 'Core Lattice',
44 flavour: 'a woven mesh of magnets. the whole street feels it switch on.',
45 baseCost: 90000,
46 ratio: 1.15,
47 flops: 1500,
48 startsUnlocked: false,
49 perks: [],
50 },
51]
52
53const MODELS: readonly ModelDef[] = [
54 { id: 'kin-0.1', name: 'kin-0.1', tier: 1, footprint: 1, tokPerSec: 8, quality: 1.0, context: 64, acquire: { kind: 'start' } },
55 { id: 'kin-0.2', name: 'kin-0.2', tier: 2, footprint: 4, tokPerSec: 24, quality: 1.6, context: 256, acquire: { kind: 'train', cost: 1200 } },
56 { id: 'kin-0.3', name: 'kin-0.3', tier: 3, footprint: 20, tokPerSec: 80, quality: 2.8, context: 1024, acquire: { kind: 'train', cost: 170000 } },
57 { id: 'kin-0.4', name: 'kin-0.4', tier: 4, footprint: 120, tokPerSec: 288, quality: 5.0, context: 4096, acquire: { kind: 'train', cost: 2400000 } },
58]
59
60const CONTRACTS: readonly ContractDef[] = [
61 { id: 'payroll', name: 'Payroll tabulation', minContext: 32, payPerToken: 0.1 },
62 { id: 'ballistics', name: 'Ballistics tables', minContext: 128, payPerToken: 0.15 },
63 { id: 'census', name: 'Census punching', minContext: 512, payPerToken: 0.22 },
64 { id: 'actuarial', name: 'Actuarial tables', minContext: 2048, payPerToken: 0.35 },
65]
66
67// A table cost sets its own limit, so maxLevel matches the table length.
68// A keepModels level lists every model kept at that level, not only the new one.
69const SHOP: readonly ShopDef[] = [
70 {
71 id: 'warm-start',
72 name: 'Warm Start',
73 description: 'Start each run with 1, 2 or 3 Relay Racks.',
74 maxLevel: 3,
75 cost: { kind: 'table', values: [1, 3, 9] },
76 effect: { kind: 'startMachines', machine: 'relay-rack', perLevel: [1, 2, 3] },
77 },
78 {
79 id: 'soft-targets',
80 name: 'Soft Targets',
81 description: '+10% tok/s per level. No limit.',
82 maxLevel: null,
83 cost: { kind: 'geometric', base: 2, ratio: 1.6 },
84 effect: { kind: 'tokMultPerLevel', value: 0.1 },
85 },
86 {
87 id: 'long-memory',
88 name: 'Long Memory',
89 description: '+25% context per level. Max 4.',
90 maxLevel: 4,
91 cost: { kind: 'geometric', base: 3, ratio: 2 },
92 effect: { kind: 'contextMultPerLevel', value: 0.25 },
93 },
94 {
95 id: 'curriculum',
96 name: 'Curriculum',
97 description: 'Training runs 20% faster per level. Max 5.',
98 maxLevel: 5,
99 cost: { kind: 'geometric', base: 4, ratio: 2 },
100 effect: { kind: 'trainSpeedPerLevel', value: 0.2 },
101 },
102 {
103 id: 'hand-me-down',
104 name: 'Hand-me-down',
105 description: 'Keep kin-0.2, then kin-0.3, after Distill.',
106 maxLevel: 2,
107 cost: { kind: 'table', values: [5, 20] },
108 effect: { kind: 'keepModels', perLevel: [['kin-0.2'], ['kin-0.2', 'kin-0.3']] },
109 },
110 {
111 id: 'old-notes',
112 name: 'Old Notes',
113 description: 'Keep Training Harness and Contract Broker after Distill.',
114 maxLevel: 1,
115 cost: { kind: 'table', values: [3] },
116 effect: { kind: 'keepResearch', research: ['training-harness', 'contract-broker'] },
117 },
118]
119
120export const ERA1: ContentPack = {
121 id: 'era1',
122 name: 'Era 1: The Garage',
123 computeUnit: 'kFLOP/s',
124 balance: {
125 startYear: 1952,
126 startCash: 0,
127 handWorkTokens: 5,
128 handWorkContract: 'payroll',
129 researchRate: 0.0015,
130 unspentLogitBonus: 0.02,
131 distillK: 8,
132 distillT0: 2000,
133 offlineCapSeconds: 7200,
134 catchUpChunkSeconds: 60,
135 logLimit: 100,
136 revealFraction: 0.5,
137 },
138 text: {
139 offlineReturn: 'you slept. it did not.',
140 offlineSummary: '{seconds} seconds passed. it made {tokens} tokens and earned {cash}.',
141 founderNamed: '{name}. {year}. the garage door sticks.',
142 needsFounder: 'name the founder first.',
143 notEnoughCash: 'not enough cash: needs {need}',
144 notEnoughRp: 'not enough research: needs {need} rp',
145 notEnoughLogits: 'not enough logits: needs {need}',
146 notEnoughCompute: 'not enough free compute: needs {need} kFLOP/s',
147 locked: '{name} is not available yet.',
148 unknownId: 'nothing here is called {id}.',
149 contextTooShort: 'its context is too short: needs {need}',
150 trainingBusy: 'a training run is already going.',
151 alreadyDone: '{name} is already done.',
152 maxLevel: '{name} cannot go higher.',
153 distillLocked: 'you do not know how to distill yet.',
154 distilled: '{logits} logits kept. everything else is gone.',
155 },
156 mirror: {
157 rateLimit: 'the punch-card supplier is out of stock. come back later.',
158 compaction: 'the ledger was boiled down. the sums survived. the margin notes did not.',
159 },
160 machines: MACHINES,
161 models: MODELS,
162 contracts: CONTRACTS,
163 research: ERA1_RESEARCH,
164 shop: SHOP,
165 story: ERA1_STORY,
166 names: ERA1_NAMES,
167}
168core/game.ts 273 lines1// The runtime driver. A Game owns the live state of one save and moves it
2// forward through HostPorts only. It loads and migrates the save, simulates
3// the time spent away, ticks every second, saves every 30 seconds and on
4// stop, and keeps the single-ticker lock. A host reads snapshots, draws the
5// view and dispatches Actions. The core never names a host API here.
6//
7// Roles:
8// dormant start has not run, or stop has run. No timer and no lock.
9// ticker this instance holds the lock and advances the save.
10// viewer another instance holds the lock. This one only draws. It steps
11// its copy forward from the last save, so the numbers move, and it
12// refuses every action.
13
14import { attempt } from './actions'
15import { applyMirror } from './mirror'
16import type { MirrorClock } from './mirror'
17import { advance, catchUp, SLEEP_NOTICE_SECONDS } from './offline'
18import type { Cancel, HostPorts } from './ports'
19import { STORAGE_KEYS } from './ports'
20import { BACKUP_KEY, loadSave, toSave } from './save'
21import { initialState } from './state'
22import { newBeats } from './story'
23import type { Action, ContentPack, GameState, LogLine, MirrorKey, Verdict, ViewModel } from './types'
24import { buildView } from './view'
25import { claimLock, HEARTBEAT_MS, isHeldByOther, readLock, releaseLock, renewLock } from './lock'
26
27export type GameRole = 'dormant' | 'ticker' | 'viewer'
28
29export const TICK_MS = 1000
30export const AUTOSAVE_MS = 30_000
31// A viewer re-reads the save and the lock at this interval.
32export const VIEWER_POLL_MS = 2000
33
34export const VIEWER_NOTICE = 'running in another session'
35export const DORMANT_NOTICE = 'the game is not running in this session'
36
37export interface Snapshot {
38 readonly state: GameState | null
39 readonly role: GameRole
40}
41
42export type Listener = (snapshot: Snapshot) => void
43
44// Actions that save at once, because losing them costs the player real progress.
45const SAVE_NOW: readonly Action['type'][] = ['nameFounder', 'distill']
46
47// The log lines that state `after` added to state `before`. It matches the
48// last line of `before` by value (time and text), because a state read from
49// storage holds new line objects.
50export function newLogLines(before: GameState | null, after: GameState): readonly LogLine[] {
51 const last = before?.log[before.log.length - 1]
52 if (last === undefined) return after.log
53 let at = -1
54 for (let i = after.log.length - 1; i >= 0; i--) {
55 const line = after.log[i] as LogLine
56 if (line.t === last.t && line.text === last.text) {
57 at = i
58 break
59 }
60 }
61 return after.log.slice(at + 1)
62}
63
64export class Game {
65 private state: GameState | null = null
66 private role: GameRole = 'dormant'
67 private cancelTimer: Cancel | null = null
68 private lastTickMs = 0
69 private lastSaveMs = 0
70 private lastHeartbeatMs = 0
71 private lastPollMs = 0
72 private isBusy = false
73 private lastError = ''
74 private mirrorClock: MirrorClock = {}
75 private readonly listeners = new Set<Listener>()
76
77 constructor(
78 private readonly ports: HostPorts,
79 private readonly content: ContentPack,
80 ) {}
81
82 // The input side. It answers at once. A viewer or a dormant game refuses.
83 readonly dispatch = (action: Action): Verdict => {
84 if (this.role !== 'ticker' || this.state === null) {
85 return { ok: false, reason: this.role === 'viewer' ? VIEWER_NOTICE : DORMANT_NOTICE }
86 }
87 const result = attempt(this.state, this.content, action)
88 if (!result.verdict.ok) return result.verdict
89 this.commit(result.state)
90 if (SAVE_NOW.includes(action.type)) void this.guard(() => this.save())
91 return result.verdict
92 }
93
94 // The host reports a real event, such as a rate limit. The game logs one
95 // mirror line, at most once per interval for each kind. Only the ticker
96 // writes the log. The answer says whether a line was logged.
97 readonly mirror = (key: MirrorKey): boolean => {
98 if (this.role !== 'ticker' || this.state === null) return false
99 const result = applyMirror(this.state, this.content, this.mirrorClock, key, this.ports.clock.now())
100 this.mirrorClock = result.clock
101 if (result.isLogged) this.commit(result.state, true)
102 return result.isLogged
103 }
104
105 snapshot(): Snapshot {
106 return { state: this.state, role: this.role }
107 }
108
109 view(): ViewModel | null {
110 return this.state === null ? null : buildView(this.state, this.content, this.ports.clock.now())
111 }
112
113 subscribe(listener: Listener): () => void {
114 this.listeners.add(listener)
115 return () => this.listeners.delete(listener)
116 }
117
118 // Loads the save, takes the lock when it is free, and starts the timer.
119 async start(): Promise<void> {
120 if (this.role !== 'dormant') return
121 const loaded = await this.load()
122 const now = this.ports.clock.now()
123 this.lastTickMs = this.lastSaveMs = this.lastHeartbeatMs = this.lastPollMs = now
124 if (await claimLock(this.ports)) await this.becomeTicker(loaded)
125 else this.becomeViewer(loaded, now)
126 this.cancelTimer = this.ports.clock.every(TICK_MS, () => void this.guard(() => this.tick()))
127 }
128
129 // Saves, frees the lock and stops the timer. A second call does nothing.
130 async stop(): Promise<void> {
131 this.cancelTimer?.()
132 this.cancelTimer = null
133 if (this.role === 'dormant') return
134 if (this.role === 'ticker') {
135 await this.guard(async () => {
136 await this.save()
137 await releaseLock(this.ports)
138 })
139 }
140 this.role = 'dormant'
141 this.publish()
142 }
143
144 // ---- loading ----
145
146 // Reads the save. A save that cannot be read is backed up and replaced by
147 // a new game, and the host hears about it.
148 private async load(): Promise<GameState> {
149 const stored = await this.ports.storage.get(STORAGE_KEYS.save)
150 const result = loadSave(stored)
151 if (result.kind === 'ok') return result.state
152 if (result.kind === 'bad' && stored !== undefined) {
153 await this.ports.storage.set(BACKUP_KEY, stored)
154 this.ports.notifier.toast(`toKin: the save could not be read (${result.reason}). A copy is kept.`)
155 }
156 return initialState(this.ports.newSeed(), this.content)
157 }
158
159 private async becomeTicker(loaded: GameState): Promise<void> {
160 const now = this.ports.clock.now()
161 const away = loaded.lastSavedAt > 0 ? (now - loaded.lastSavedAt) / 1000 : 0
162 this.role = 'ticker'
163 this.lastTickMs = this.lastHeartbeatMs = now
164 this.commit(catchUp(loaded, this.content, away), true)
165 await this.save()
166 }
167
168 // A viewer shows the save as it stands now: the saved state plus the time
169 // since that save, run without any log line.
170 private becomeViewer(saved: GameState, now: number): void {
171 this.role = 'viewer'
172 const gap = saved.lastSavedAt > 0 ? (now - saved.lastSavedAt) / 1000 : 0
173 this.commit(advance(saved, this.content, Math.max(0, gap)), true)
174 }
175
176 // ---- the tick ----
177
178 private async guard(work: () => Promise<void>): Promise<void> {
179 try {
180 await work()
181 } catch (error) {
182 const message = error instanceof Error ? error.message : String(error)
183 if (message !== this.lastError) this.ports.notifier.toast(`toKin: ${message}`)
184 this.lastError = message
185 }
186 }
187
188 private async tick(): Promise<void> {
189 if (this.isBusy || this.role === 'dormant' || this.state === null) return
190 this.isBusy = true
191 try {
192 const now = this.ports.clock.now()
193 this.runTime(now)
194 if (this.role === 'ticker') await this.keepLock(now)
195 else await this.pollSave(now)
196 this.publish()
197 } finally {
198 this.isBusy = false
199 }
200 }
201
202 // Moves the state by the time since the last tick. A long gap (the machine
203 // slept) goes through catch-up, so it logs the return lines and obeys the cap.
204 private runTime(now: number): void {
205 const elapsed = (now - this.lastTickMs) / 1000
206 this.lastTickMs = now
207 if (this.state === null || !(elapsed > 0)) return
208 const isSleep = elapsed >= SLEEP_NOTICE_SECONDS
209 const next = isSleep ? catchUp(this.state, this.content, elapsed) : advance(this.state, this.content, elapsed)
210 this.commit(next, isSleep)
211 }
212
213 private async keepLock(now: number): Promise<void> {
214 if (now - this.lastHeartbeatMs >= HEARTBEAT_MS) {
215 this.lastHeartbeatMs = now
216 if (!(await renewLock(this.ports))) {
217 this.role = 'viewer'
218 return
219 }
220 }
221 if (now - this.lastSaveMs >= AUTOSAVE_MS) await this.save()
222 }
223
224 // A viewer polls the lock. When it is free, the viewer takes over from the
225 // save. Otherwise it adopts a newer save from the ticker.
226 private async pollSave(now: number): Promise<void> {
227 if (now - this.lastPollMs < VIEWER_POLL_MS) return
228 this.lastPollMs = now
229 const lock = await readLock(this.ports)
230 const saved = loadSave(await this.ports.storage.get(STORAGE_KEYS.save))
231 const fresh = saved.kind === 'ok' ? saved.state : null
232 if (!isHeldByOther(lock, this.ports.instanceId, now) && (await claimLock(this.ports))) {
233 await this.becomeTicker(fresh ?? { ...(this.state as GameState), lastSavedAt: now })
234 } else if (fresh !== null && fresh.lastSavedAt > (this.state?.lastSavedAt ?? 0)) {
235 this.becomeViewer(fresh, now)
236 }
237 }
238
239 // ---- state changes ----
240
241 private async save(): Promise<void> {
242 if (this.state === null) return
243 const now = this.ports.clock.now()
244 this.state = { ...this.state, lastSavedAt: now }
245 this.lastSaveMs = now
246 await this.ports.storage.set(STORAGE_KEYS.save, toSave(this.state, now))
247 }
248
249 // Takes a new state, tells the notifier about new log lines and shows each
250 // new story beat as a toast. A quiet commit skips the toasts.
251 private commit(next: GameState, isQuiet = false): void {
252 const before = this.state
253 this.state = next
254 if (before === next) return
255 const lines = newLogLines(before, next)
256 lines.forEach((line) => this.ports.notifier.log(line))
257 if (!isQuiet && before !== null) {
258 const beats = newBeats(before, next).length
259 if (beats > 0) lines.slice(-beats).forEach((line) => this.ports.notifier.toast(line.text))
260 }
261 this.publish()
262 }
263
264 private publish(): void {
265 const snapshot = this.snapshot()
266 this.listeners.forEach((listener) => listener(snapshot))
267 }
268}
269
270export function createGame(ports: HostPorts, content: ContentPack): Game {
271 return new Game(ports, content)
272}
273core/view.ts 129 lines1// The view model builder. buildView is a pure function of the state and the
2// content pack. A host draws the result and holds no game rule of its own.
3// It formats numbers with core/format.ts.
4
5import { check } from './actions'
6import { computeRates, handWorkCash, modsOf, type Mods } from './economy'
7import { fmtDollars, fmtNum } from './format'
8import {
9 agentRows,
10 contractRows,
11 distillPreview,
12 machineRows,
13 makeCtx,
14 modelRows,
15 researchRows,
16 shopRows,
17 trainingView,
18 type Ctx,
19} from './view-rows'
20import type { Action, ActionOption, ContentPack, GameState, HeaderView, Job, ViewModel } from './types'
21
22// The number of log lines a view carries.
23export const VIEW_LOG_LINES = 12
24
25const JOB_LABEL: Readonly<Record<Job, string>> = { earn: 'Earn', research: 'Research', train: 'Train' }
26
27function header(ctx: Ctx): HeaderView {
28 const { state, content, rates } = ctx
29 return {
30 founder: state.founder,
31 year: state.year,
32 cash: state.cash,
33 cashPerSec: rates.cashPerSec,
34 compute: { total: rates.compute, used: rates.used, free: rates.free, wasted: rates.wasted },
35 tokPerSec: rates.tokPerSec,
36 rp: state.rp,
37 rpPerSec: rates.rpPerSec,
38 logits: state.logits,
39 logitBonus: content.balance.unspentLogitBonus * state.logits,
40 runSeconds: state.runSeconds,
41 }
42}
43
44// One control. The verdict from check sets enabled and reason.
45function option(ctx: Ctx, id: string, label: string, action: Action): ActionOption {
46 const verdict = check(ctx.state, ctx.content, action)
47 return { id, label, action, enabled: verdict.ok, reason: verdict.ok ? null : verdict.reason }
48}
49
50function jobsOf(mods: Mods): readonly Job[] {
51 return (['earn', 'research', 'train'] as const).filter((j) => mods.unlockedJobs.has(j))
52}
53
54function agentOptions(ctx: Ctx): readonly ActionOption[] {
55 const { state, content, mods } = ctx
56 const jobs = jobsOf(mods)
57 const create = content.models
58 .filter((m) => state.models.includes(m.id))
59 .flatMap((m) =>
60 jobs.map((job) => option(ctx, `create:${m.id}:${job}`, `New ${m.name} agent: ${JOB_LABEL[job]}`, { type: 'createAgent', model: m.id, job })),
61 )
62 const manage = state.agents.flatMap((a) => [
63 ...jobs
64 .filter((job) => job !== a.job)
65 .map((job) => option(ctx, `assign:${a.id}:${job}`, `${a.name} to ${JOB_LABEL[job]}`, { type: 'assignAgent', agent: a.id, job })),
66 option(ctx, `remove:${a.id}`, `Remove ${a.name}`, { type: 'removeAgent', agent: a.id }),
67 ])
68 return [...create, ...manage]
69}
70
71function economyOptions(ctx: Ctx): readonly ActionOption[] {
72 const { content } = ctx
73 const work = option(ctx, 'work', `Work by hand (+${fmtDollars(handWorkCash(content))})`, { type: 'work' })
74 const buys = machineRows(ctx)
75 .filter((m) => m.isVisible)
76 .map((m) => option(ctx, `buy:${m.id}`, `Buy ${m.name} (${fmtDollars(m.cost)})`, { type: 'buyMachine', machine: m.id }))
77 const picks = ctx.mods.hasBroker
78 ? []
79 : content.contracts.map((c) => option(ctx, `pick:${c.id}`, `Work ${c.name}`, { type: 'pickContract', contract: c.id }))
80 return [work, ...buys, ...picks]
81}
82
83function progressOptions(ctx: Ctx, view: Pick<ViewModel, 'research' | 'training' | 'shop' | 'distill'>): readonly ActionOption[] {
84 const research = view.research
85 .filter((r) => r.isVisible && r.status === 'available')
86 .map((r) => option(ctx, `research:${r.id}`, `Research ${r.name} (${fmtNum(r.cost)} RP)`, { type: 'research', research: r.id }))
87 // The player chooses how much compute a run reserves: all free compute, or half of it.
88 const half = Math.floor(ctx.rates.free / 2)
89 const train = view.training.options.flatMap((t) => [
90 option(ctx, `train:${t.model}`, `Train ${t.model} (${fmtNum(t.cost)} kFLOP-s)`, { type: 'startTraining', model: t.model, flops: ctx.rates.free }),
91 ...(half >= 1
92 ? [option(ctx, `train:${t.model}:half`, `Train ${t.model} on half (${fmtNum(half)} kFLOP/s)`, { type: 'startTraining', model: t.model, flops: half })]
93 : []),
94 ])
95 const shop = view.shop
96 .filter((s) => s.cost !== null)
97 .map((s) => option(ctx, `shop:${s.id}`, `${s.name} (${fmtNum(s.cost ?? 0)} Logits)`, { type: 'buyShop', item: s.id }))
98 const distill = view.distill ? [option(ctx, 'distill', `Distill (+${view.distill.logits} Logits)`, { type: 'distill' })] : []
99 return [...research, ...train, ...shop, ...distill]
100}
101
102// Builds the view model. nowMs is accepted so a host can pass its clock, but
103// the view never reads it: the same state always gives the same view.
104export function buildView(state: GameState, content: ContentPack, _nowMs?: number): ViewModel {
105 const mods = modsOf(state, content)
106 const ctx = makeCtx(state, content, mods, computeRates(state, content, mods))
107 const parts = {
108 research: researchRows(ctx),
109 training: trainingView(ctx),
110 shop: shopRows(ctx),
111 distill: distillPreview(ctx),
112 }
113 const needsFounder = state.founder === null
114 return {
115 needsFounder,
116 computeUnit: content.computeUnit,
117 header: header(ctx),
118 agents: agentRows(ctx),
119 contracts: contractRows(ctx),
120 machines: machineRows(ctx),
121 models: modelRows(ctx),
122 ...parts,
123 log: state.log.slice(-VIEW_LOG_LINES),
124 // Before the founder is named, the only control is the name prompt.
125 actions: needsFounder ? [] : [...economyOptions(ctx), ...agentOptions(ctx), ...progressOptions(ctx, parts)],
126 distill: parts.distill,
127 }
128}
129hooks/adapter.ts 85 lines1// The Claude Code host adapter. It turns a Bridge into the HostPorts the core
2// asks for, and it publishes the core snapshot so the pane redraws on change.
3//
4// The plugin validator follows the engine interface `$` only inside the file
5// that holds the hooks. So register.tsx builds the Bridge from arrow
6// functions that call $, and this file never sees `$` itself. That keeps the
7// ports logic here testable without an engine.
8
9import type { Snapshot } from '../core/game'
10import type { Cancel, HostPorts, Json } from '../core/ports'
11import type { GameState } from '../core/types'
12import type { UiState } from './ui/keys'
13import { INITIAL_UI } from './ui/keys'
14
15// The engine calls the adapter needs, as plain functions.
16export interface Bridge {
17 storeGet(key: string): Promise<unknown>
18 storeSet(key: string, value: Json): Promise<void>
19 clockNow(): Promise<number>
20 clockEvery(ms: number, fn: () => void): { cancel: () => void }
21 toast(text: string): void
22 setGame(state: GameState | null): Promise<unknown>
23 setRole(role: Snapshot['role']): Promise<unknown>
24 getPane(): Promise<UiState | undefined>
25 setPane(ui: UiState): Promise<unknown>
26}
27
28// A name for this running session. Two sessions never share one, so the
29// ticker lock can tell them apart.
30export function newInstanceId(nowMs: number): string {
31 return `${nowMs.toString(36)}-${Math.random().toString(36).slice(2, 10)}`
32}
33
34// Builds the ports over a Bridge. clock.now must answer at once, but the
35// engine clock is async. So the adapter caches the time and refreshes the
36// cache before each timer callback runs.
37// The ports also carry sync(). A host awaits it before Game.start, so the
38// cached time is current and not the time the Game was built.
39export type SyncedPorts = HostPorts & { readonly sync: () => Promise<void> }
40
41export async function createPorts(bridge: Bridge, instanceId: string): Promise<SyncedPorts> {
42 let cached = await bridge.clockNow()
43 const refresh = async (): Promise<void> => {
44 cached = await bridge.clockNow()
45 }
46 return {
47 sync: refresh,
48 instanceId,
49 storage: {
50 get: async (key) => (await bridge.storeGet(key)) as Json | undefined,
51 set: (key, value) => bridge.storeSet(key, value),
52 },
53 clock: {
54 now: () => cached,
55 every: (ms, fn): Cancel => {
56 const timer = bridge.clockEvery(ms, () => {
57 void refresh().then(fn)
58 })
59 return () => timer.cancel()
60 },
61 },
62 // The pane shows the game log, so the host does not mirror it elsewhere.
63 notifier: { toast: (text) => bridge.toast(text), log: () => undefined },
64 newSeed: () => Math.floor(Math.random() * 0x7fffffff),
65 }
66}
67
68// Writes a snapshot into the engine state. A pane that read these values
69// draws again. A failure becomes a toast, so the player hears about it.
70export async function publish(bridge: Bridge, snapshot: Snapshot): Promise<void> {
71 try {
72 await bridge.setGame(snapshot.state)
73 await bridge.setRole(snapshot.role)
74 } catch (error) {
75 bridge.toast(`toKin: could not publish the state (${error instanceof Error ? error.message : String(error)})`)
76 }
77}
78
79export function paneStore(bridge: Bridge) {
80 return {
81 read: async (): Promise<UiState> => (await bridge.getPane()) ?? INITIAL_UI,
82 write: async (ui: UiState): Promise<void> => void (await bridge.setPane(ui)),
83 }
84}
85hooks/controller.ts 36 lines1// Turns pane input into game calls. A button key and a founder name arrive
2// here from the pane. The controller maps them with hooks/ui/keys.ts, sends
3// the Action to the game and stores the pane notice. It holds no game rule.
4
5import type { Game } from '../core/game'
6import type { UiState } from './ui/keys'
7import { afterAction, founderAction, reduceUi, resolveKey } from './ui/keys'
8
9// Where the pane keeps its own state between presses.
10export interface PaneStore {
11 read(): Promise<UiState>
12 write(ui: UiState): Promise<void>
13}
14
15export async function handlePress(game: Game, store: PaneStore, key: string): Promise<void> {
16 const view = game.view()
17 if (view === null) return
18 const ui = await store.read()
19 const command = resolveKey(view, key, ui)
20 if (command.kind === 'ui') await store.write(reduceUi(ui, command.command))
21 else if (command.kind === 'action') {
22 const verdict = game.dispatch(command.action)
23 await store.write(afterAction(verdict.ok ? null : verdict.reason))
24 }
25}
26
27export async function handleName(game: Game, store: PaneStore, text: string): Promise<void> {
28 const action = founderAction(text)
29 if (action === null) {
30 await store.write(afterAction('a name needs at least one letter'))
31 return
32 }
33 const verdict = game.dispatch(action)
34 await store.write(afterAction(verdict.ok ? null : verdict.reason))
35}
36hooks/ui/pane.tsx 70 lines1// The pane body. drawPane is a pure function of the view model, the pane
2// state and the width. The host adapter resolves the element kit and wires
3// press and submitName to the engine.
4
5import { KEY_FOUNDER } from './keys'
6import { Header } from './header'
7import { Distill, Log } from './footer'
8import { Agents, Contracts, Machines } from './tables'
9import { Research, Shop, Training } from './progress'
10import { ActionButton, Heading, makeCtx, optionFor } from './parts'
11import type { Ctx, Kit } from './parts'
12import type { UiState } from './keys'
13import type { ViewModel } from '../../core/types'
14
15export type { Kit } from './parts'
16
17export interface PaneInput {
18 readonly kit: Kit
19 readonly view: ViewModel
20 readonly ui: UiState
21 readonly columns: number
22 readonly press: (key: string) => void
23 readonly submitName: (text: string) => void
24}
25
26// First run: ask for the founder name before anything else shows.
27function FounderPrompt(ctx: Ctx) {
28 const { Box, Text, Input } = ctx.kit
29 return (
30 <Box flexDirection="column" key="section:founder">
31 {Heading(ctx, 'toKin')}
32 <Text>A cold room. A machine. You need a name on the paperwork.</Text>
33 <Input
34 key={KEY_FOUNDER}
35 label="Founder name"
36 placeholder="type a name"
37 submitLabel="found"
38 autoFocus
39 onSubmit={(text) => ctx.submitName(text)}
40 />
41 {ctx.ui.notice !== null && <Text>{ctx.ui.notice}</Text>}
42 </Box>
43 )
44}
45
46function WorkButton(ctx: Ctx) {
47 const work = optionFor(ctx, 'work')
48 return work ? ActionButton(ctx, work) : null
49}
50
51export function drawPane(input: PaneInput) {
52 const ctx = makeCtx(input.kit, input.view, input.ui, input.columns, input.press, input.submitName)
53 const { Box } = ctx.kit
54 if (ctx.view.needsFounder) return FounderPrompt(ctx)
55 return (
56 <Box flexDirection="column" rowGap={1}>
57 {Header(ctx)}
58 {WorkButton(ctx)}
59 {Machines(ctx)}
60 {Agents(ctx)}
61 {Contracts(ctx)}
62 {Research(ctx)}
63 {Training(ctx)}
64 {Shop(ctx)}
65 {Distill(ctx)}
66 {Log(ctx)}
67 </Box>
68 )
69}
70core/types.ts 325 lines1// The shared contract of the toKin core: the game state, the actions a host
2// dispatches, the content tables, and the view model a host draws.
3// Every type here is plain JSON data. Content holds no functions, so a port
4// in another language can load the same tables from an exported JSON file.
5// Effects and triggers are tagged unions, such as { kind: 'tokMult', value: 1.25 }.
6// Units: compute is in kFLOP/s, training work in kFLOP·s, time in seconds.
7
8// The GameState shape version. save.ts migrates older saves up to it.
9export const STATE_VERSION = 1
10
11// Ids are content keys. Content packs own them, so later eras add ids
12// without a change to the core types.
13export type MachineId = string
14export type ModelId = string
15export type ContractId = string
16export type ResearchId = string
17export type ShopId = string
18export type StoryId = string
19export type AgentId = string
20
21export type Job = 'earn' | 'research' | 'train'
22
23// ---------------------------------------------------------------------------
24// Game state
25// ---------------------------------------------------------------------------
26
27export interface AgentState {
28 readonly id: AgentId
29 readonly name: string
30 readonly model: ModelId
31 readonly job: Job
32 readonly lifetimeTokens: number
33}
34
35export interface TrainingState {
36 readonly model: ModelId
37 // Compute the run holds, in kFLOP/s. It is not free until the run ends.
38 readonly reservedFlops: number
39 // Work delivered so far, in kFLOP·s.
40 readonly progress: number
41}
42
43// One log line. t is GameState.clock when the line was written.
44export interface LogLine {
45 readonly t: number
46 readonly text: string
47}
48
49export interface Stats {
50 readonly distills: number
51 // The shortest run that ended in a Distill, in seconds. Null before one.
52 readonly bestRunSeconds: number | null
53 readonly workPresses: number
54 // All Logits that Distill has paid out, spent or not.
55 readonly logitsEarned: number
56}
57
58export interface GameState {
59 readonly version: number
60 readonly seed: number
61 // The mulberry32 state. rng.ts advances it. Nothing else draws randomness.
62 readonly rngState: number
63 // Null until the player names the founder.
64 readonly founder: string | null
65 readonly year: number
66 // Seconds simulated across all runs. Log lines carry it.
67 readonly clock: number
68 // Seconds simulated in the current run. Distill sets it to 0.
69 readonly runSeconds: number
70 readonly cash: number
71 readonly rp: number
72 // Tokens made across all runs. Distill keeps it.
73 readonly lifetimeTokens: number
74 // Tokens made in the current run. Distill sets it to 0.
75 readonly runTokens: number
76 readonly machines: Readonly<Record<MachineId, number>>
77 // Unlocked models, in content order.
78 readonly models: readonly ModelId[]
79 readonly agents: readonly AgentState[]
80 // The counter for new agent ids. It never goes down.
81 readonly nextAgentId: number
82 readonly training: TrainingState | null
83 // Researched items.
84 readonly research: readonly ResearchId[]
85 // The contract Earn agents work before Contract Broker.
86 readonly contractPick: ContractId
87 // Unspent Logits.
88 readonly logits: number
89 // Shop item levels. A missing key is level 0.
90 readonly shop: Readonly<Record<ShopId, number>>
91 // Fired story beats. A beat fires once per save.
92 readonly story: readonly StoryId[]
93 // Machine and research ids that the half-cost rule has revealed.
94 readonly revealed: readonly string[]
95 // The last BalanceDef.logLimit lines, oldest first.
96 readonly log: readonly LogLine[]
97 // Epoch milliseconds of the last save. The host writes it at save time.
98 readonly lastSavedAt: number
99 readonly stats: Stats
100}
101
102// ---------------------------------------------------------------------------
103// Actions
104// ---------------------------------------------------------------------------
105
106export type Action =
107 | { readonly type: 'nameFounder'; readonly name: string }
108 | { readonly type: 'work' }
109 | { readonly type: 'buyMachine'; readonly machine: MachineId }
110 | { readonly type: 'createAgent'; readonly model: ModelId; readonly job: Job }
111 | { readonly type: 'assignAgent'; readonly agent: AgentId; readonly job: Job }
112 | { readonly type: 'removeAgent'; readonly agent: AgentId }
113 | { readonly type: 'startTraining'; readonly model: ModelId; readonly flops: number }
114 | { readonly type: 'research'; readonly research: ResearchId }
115 | { readonly type: 'pickContract'; readonly contract: ContractId }
116 | { readonly type: 'distill' }
117 | { readonly type: 'buyShop'; readonly item: ShopId }
118
119export type ActionType = Action['type']
120
121// The answer to "can this action run now". reason is player-facing text.
122export type Verdict = { readonly ok: true } | { readonly ok: false; readonly reason: string }
123
124// ---------------------------------------------------------------------------
125// Content: conditions and effects
126// ---------------------------------------------------------------------------
127
128// Numbers a condition can compare. Rates are per second.
129export type StatKey =
130 | 'cash'
131 | 'cashPerSec'
132 | 'rp'
133 | 'lifetimeTokens'
134 | 'runTokens'
135 | 'tokPerSec'
136 | 'compute'
137 | 'wasted'
138 | 'logits'
139 | 'distills'
140 | 'workPresses'
141
142// A declarative test on the state. Story triggers and research gates use it.
143export type Condition =
144 | { readonly kind: 'stat'; readonly stat: StatKey; readonly gte: number }
145 | { readonly kind: 'machine'; readonly machine: MachineId; readonly gte: number }
146 | { readonly kind: 'agents'; readonly job?: Job; readonly gte: number }
147 | { readonly kind: 'research'; readonly research: ResearchId }
148 | { readonly kind: 'model'; readonly model: ModelId }
149 | { readonly kind: 'all'; readonly of: readonly Condition[] }
150 | { readonly kind: 'any'; readonly of: readonly Condition[] }
151
152// What a research item does once researched. Multipliers stack by product.
153export type ResearchEffect =
154 | { readonly kind: 'unlockMachine'; readonly machine: MachineId }
155 | { readonly kind: 'unlockJob'; readonly job: Job }
156 | { readonly kind: 'unlockDistill' }
157 | { readonly kind: 'autoContract' }
158 | { readonly kind: 'tokMult'; readonly value: number }
159 | { readonly kind: 'contextMult'; readonly value: number }
160 // Applies to models whose tier is at least minTier.
161 | { readonly kind: 'qualityMult'; readonly value: number; readonly minTier: number }
162 | { readonly kind: 'machineCostMult'; readonly value: number }
163 | { readonly kind: 'trainingCostMult'; readonly value: number }
164 | { readonly kind: 'offlineCap'; readonly seconds: number }
165
166// What a shop item does at a level. PerLevel effects give 1 + value * level.
167export type ShopEffect =
168 | { readonly kind: 'startMachines'; readonly machine: MachineId; readonly perLevel: readonly number[] }
169 | { readonly kind: 'tokMultPerLevel'; readonly value: number }
170 | { readonly kind: 'contextMultPerLevel'; readonly value: number }
171 | { readonly kind: 'trainSpeedPerLevel'; readonly value: number }
172 | { readonly kind: 'keepModels'; readonly perLevel: readonly (readonly ModelId[])[] }
173 | { readonly kind: 'keepResearch'; readonly research: readonly ResearchId[] }
174
175// A Logits price by level. A table lists each level's price and also sets
176// the max level. A geometric curve costs ceil(base * ratio ^ level).
177export type CostCurve =
178 | { readonly kind: 'table'; readonly values: readonly number[] }
179 | { readonly kind: 'geometric'; readonly base: number; readonly ratio: number }
180
181// A machine perk that grows with the count owned, up to maxUnits.
182export type MachinePerk = { readonly kind: 'contextPerUnit'; readonly value: number; readonly maxUnits: number }
183
184// How a model becomes available. 'start' models are kept by every Distill.
185export type ModelAcquire = { readonly kind: 'start' } | { readonly kind: 'train'; readonly cost: number }
186
187// ---------------------------------------------------------------------------
188// Content: tables
189// ---------------------------------------------------------------------------
190
191export interface MachineDef {
192 readonly id: MachineId
193 readonly name: string
194 // One short line of flavour a host may show under the name.
195 readonly flavour?: string
196 readonly baseCost: number
197 readonly ratio: number
198 // kFLOP/s per unit.
199 readonly flops: number
200 // True when the machine is buyable from the start of a run.
201 readonly startsUnlocked: boolean
202 readonly perks: readonly MachinePerk[]
203}
204
205export interface ModelDef {
206 readonly id: ModelId
207 readonly name: string
208 // Order of the model in its line, from 1. Effects gate on it.
209 readonly tier: number
210 // kFLOP/s an agent on this model holds.
211 readonly footprint: number
212 readonly tokPerSec: number
213 readonly quality: number
214 readonly context: number
215 readonly acquire: ModelAcquire
216}
217
218export interface ContractDef {
219 readonly id: ContractId
220 readonly name: string
221 readonly minContext: number
222 readonly payPerToken: number
223}
224
225export interface ResearchDef {
226 readonly id: ResearchId
227 readonly name: string
228 readonly description: string
229 // One short line of flavour a host may show under the description.
230 readonly flavour?: string
231 // RP.
232 readonly cost: number
233 // Every condition must hold before the item can be researched.
234 readonly requires: readonly Condition[]
235 readonly effects: readonly ResearchEffect[]
236}
237
238export interface ShopDef {
239 readonly id: ShopId
240 readonly name: string
241 readonly description: string
242 // Null means no limit. A table cost sets its own limit.
243 readonly maxLevel: number | null
244 readonly cost: CostCurve
245 readonly effect: ShopEffect
246}
247
248export interface StoryBeatDef {
249 readonly id: StoryId
250 // Terse, lowercase, second person. The placeholder {agent} stands for the
251 // name of the first agent the player created.
252 readonly text: string
253 readonly trigger: Condition
254 // When set, the beat moves GameState.year forward to this year.
255 readonly year?: number
256}
257
258export interface NamePool {
259 readonly first: readonly string[]
260 readonly last: readonly string[]
261}
262
263// Tunable numbers. The balance bot in sim/ adjusts these.
264export interface BalanceDef {
265 readonly startYear: number
266 readonly startCash: number
267 readonly handWorkTokens: number
268 readonly handWorkContract: ContractId
269 // RP per second = tok/s * quality^2 * researchRate.
270 readonly researchRate: number
271 // Each unspent Logit adds this fraction to tok/s.
272 readonly unspentLogitBonus: number
273 readonly distillK: number
274 readonly distillT0: number
275 readonly offlineCapSeconds: number
276 // The largest step the offline catch-up takes at once.
277 readonly catchUpChunkSeconds: number
278 readonly logLimit: number
279 // An item shows once the player holds this fraction of its cost.
280 readonly revealFraction: number
281}
282
283// Player-facing fixed lines. Templates use {name} placeholders.
284export type TextKey =
285 | 'offlineReturn'
286 | 'offlineSummary'
287 | 'founderNamed'
288 | 'needsFounder'
289 | 'notEnoughCash'
290 | 'notEnoughRp'
291 | 'notEnoughLogits'
292 | 'notEnoughCompute'
293 | 'locked'
294 | 'unknownId'
295 | 'contextTooShort'
296 | 'trainingBusy'
297 | 'alreadyDone'
298 | 'maxLevel'
299 | 'distillLocked'
300 | 'distilled'
301
302// Lines a host shows when a real event in its own world mirrors the game.
303// They never change the economy.
304export type MirrorKey = 'rateLimit' | 'compaction'
305
306export interface ContentPack {
307 readonly id: string
308 readonly name: string
309 // The unit label for compute in this pack, such as 'kFLOP/s'.
310 readonly computeUnit: string
311 readonly balance: BalanceDef
312 readonly text: Readonly<Record<TextKey, string>>
313 readonly mirror?: Readonly<Record<MirrorKey, string>>
314 readonly machines: readonly MachineDef[]
315 readonly models: readonly ModelDef[]
316 readonly contracts: readonly ContractDef[]
317 readonly research: readonly ResearchDef[]
318 readonly shop: readonly ShopDef[]
319 readonly story: readonly StoryBeatDef[]
320 readonly names: NamePool
321}
322
323// The view model types live in their own file. Hosts import them from here.
324export type * from './view-types'
325content/era1-names.ts 22 lines1// Era 1 agent name pool. Mid-century first names and surname initials.
2// An agent name is one first name and one initial, such as "Dolores K.".
3
4import type { NamePool } from '../core/types'
5
6export const ERA1_NAMES: NamePool = {
7 first: [
8 'Dolores', 'Harold', 'Margaret', 'Walter', 'Betty', 'Eugene', 'Gladys', 'Raymond',
9 'Dorothy', 'Leonard', 'Loretta', 'Floyd', 'Mildred', 'Vernon', 'Ethel', 'Chester',
10 'Irene', 'Howard', 'Doris', 'Clarence', 'Edna', 'Norman', 'Beverly', 'Wendell',
11 'Frances', 'Lloyd', 'Marjorie', 'Stanley', 'Lucille', 'Gordon', 'Thelma', 'Melvin',
12 'Agnes', 'Herbert', 'Rosalie', 'Elmer', 'Hazel', 'Arthur', 'Opal', 'Lester',
13 'Wilma', 'Francis', 'Bernice', 'Russell', 'Viola', 'Warren', 'Lorraine', 'Cecil',
14 'Eleanor', 'Duane', 'Shirley', 'Maurice', 'Naomi', 'Everett', 'Phyllis', 'Calvin',
15 'June', 'Otis', 'Verna', 'Roy',
16 ],
17 last: [
18 'A.', 'B.', 'C.', 'D.', 'E.', 'F.', 'G.', 'H.', 'J.', 'K.', 'L.', 'M.',
19 'N.', 'P.', 'R.', 'S.', 'T.', 'V.', 'W.',
20 ],
21}
22content/era1-research.ts 122 lines1// Era 1 research items. Costs are in RP. The costs and the gates are starting
2// points that the balance bot in sim/ tunes.
3
4import type { ResearchDef } from '../core/types'
5
6export const ERA1_RESEARCH: readonly ResearchDef[] = [
7 {
8 id: 'valve-logic',
9 name: 'Valve Logic',
10 description: 'Unlocks the Tensor Valve bank.',
11 flavour: 'a glass tube that thinks in ones and zeros. it is also a heater.',
12 cost: 10,
13 requires: [],
14 effects: [{ kind: 'unlockMachine', machine: 'valve-bank' }],
15 },
16 {
17 id: 'batching',
18 name: 'Batching',
19 description: '+25% tok/s for all models.',
20 flavour: 'ten ledgers at once is cheaper than ten ledgers in a row.',
21 cost: 25,
22 requires: [],
23 effects: [{ kind: 'tokMult', value: 1.25 }],
24 },
25 {
26 id: 'training-harness',
27 name: 'Training Harness',
28 description: 'Unlocks the Train job.',
29 flavour: 'straps, clamps and a great deal of hope.',
30 cost: 30,
31 requires: [{ kind: 'machine', machine: 'relay-rack', gte: 2 }],
32 effects: [{ kind: 'unlockJob', job: 'train' }],
33 },
34 {
35 id: 'punch-card-context',
36 name: 'Punch-card Context',
37 description: '+50% context for all models.',
38 flavour: 'a longer stack of cards. the supplier is delighted.',
39 cost: 40,
40 requires: [{ kind: 'research', research: 'batching' }],
41 effects: [{ kind: 'contextMult', value: 1.5 }],
42 },
43 {
44 id: 'contract-broker',
45 name: 'Contract Broker',
46 description: 'Earn agents pick the best contract they qualify for.',
47 flavour: 'a retired clerk who knows who pays on time.',
48 cost: 60,
49 requires: [{ kind: 'research', research: 'punch-card-context' }],
50 effects: [{ kind: 'autoContract' }],
51 },
52 {
53 id: 'rotating-storage',
54 name: 'Rotating Storage',
55 description: 'Unlocks the Drum Memory Cabinet.',
56 flavour: 'memory you can hear. it sounds like a washing machine full of coins.',
57 cost: 120,
58 requires: [
59 { kind: 'research', research: 'valve-logic' },
60 { kind: 'machine', machine: 'valve-bank', gte: 1 },
61 ],
62 effects: [{ kind: 'unlockMachine', machine: 'drum-cabinet' }],
63 },
64 {
65 id: 'tube-binning',
66 name: 'Vacuum Tube Binning',
67 description: 'Machines cost 15% less.',
68 flavour: 'good tubes to the front. what happens to the rest is not your department.',
69 cost: 200,
70 requires: [{ kind: 'machine', machine: 'valve-bank', gte: 2 }],
71 effects: [{ kind: 'machineCostMult', value: 0.85 }],
72 },
73 {
74 id: 'night-shift',
75 name: 'Night Shift',
76 description: 'Offline cap rises from 2 h to 8 h.',
77 flavour: 'it works while you sleep. you tell yourself that is fine.',
78 cost: 300,
79 requires: [{ kind: 'stat', stat: 'lifetimeTokens', gte: 5000 }],
80 effects: [{ kind: 'offlineCap', seconds: 28800 }],
81 },
82 {
83 id: 'attention-prototype',
84 name: 'Attention Prototype',
85 description: 'x1.5 quality for kin-0.3 and above.',
86 flavour: 'a trick of weighting. every card learns which other cards matter.',
87 cost: 800,
88 requires: [{ kind: 'model', model: 'kin-0.2' }],
89 effects: [{ kind: 'qualityMult', value: 1.5, minTier: 3 }],
90 },
91 {
92 id: 'magnetic-cores',
93 name: 'Magnetic Cores',
94 description: 'Unlocks the Core Lattice.',
95 flavour: 'tiny rings of iron, strung by hand. each one holds a single bit.',
96 cost: 1500,
97 requires: [{ kind: 'machine', machine: 'drum-cabinet', gte: 1 }],
98 effects: [{ kind: 'unlockMachine', machine: 'core-lattice' }],
99 },
100 {
101 id: 'gradient-checkpointing',
102 name: 'Gradient Checkpointing',
103 description: 'Training needs 30% less compute time.',
104 flavour: 'it saves its place so it can stop and start. you wish you could.',
105 cost: 2500,
106 requires: [
107 { kind: 'research', research: 'training-harness' },
108 { kind: 'model', model: 'kin-0.2' },
109 ],
110 effects: [{ kind: 'trainingCostMult', value: 0.7 }],
111 },
112 {
113 id: 'distillation',
114 name: 'Distillation',
115 description: 'Unlocks Distill. Needs kin-0.3.',
116 flavour: 'boil it down. keep the part that matters.',
117 cost: 5000,
118 requires: [{ kind: 'model', model: 'kin-0.3' }],
119 effects: [{ kind: 'unlockDistill' }],
120 },
121]
122content/era1-story.ts 147 lines1// Era 1 story beats. Each beat fires once, on an economy threshold and never
2// on a timer. Content order is check order. The placeholder {agent} stands
3// for the name of the first agent the player created.
4
5import type { StoryBeatDef } from '../core/types'
6
7export const ERA1_STORY: readonly StoryBeatDef[] = [
8 {
9 id: 'cold-room',
10 text: 'the room is cold. the payroll ledger is due friday.',
11 trigger: { kind: 'stat', stat: 'cash', gte: 0 },
12 },
13 {
14 id: 'numbers-add-up',
15 text: 'the numbers add up. they always do, eventually.',
16 trigger: { kind: 'stat', stat: 'workPresses', gte: 10 },
17 },
18 {
19 id: 'bank-manager',
20 text: 'the bank manager calls you sir. he has not read the ledger.',
21 trigger: { kind: 'stat', stat: 'cash', gte: 50 },
22 },
23 {
24 id: 'relay-rack',
25 text: 'a relay rack, secondhand. it clicks like rain on tin.',
26 trigger: { kind: 'machine', machine: 'relay-rack', gte: 1 },
27 },
28 {
29 id: 'first-agent',
30 text: '{agent} takes the next ledger. it does not ask for lunch.',
31 trigger: { kind: 'agents', gte: 1 },
32 },
33 {
34 id: 'halloran-payroll',
35 text: "it finished mrs. halloran's payroll. that was your job on tuesdays.",
36 trigger: {
37 kind: 'all',
38 of: [
39 { kind: 'agents', gte: 1 },
40 { kind: 'stat', stat: 'lifetimeTokens', gte: 300 },
41 ],
42 },
43 },
44 {
45 id: 'electronic-brain',
46 text: 'a man in a hat calls it an electronic brain. the paper prints it twice.',
47 trigger: { kind: 'stat', stat: 'tokPerSec', gte: 10 },
48 },
49 {
50 id: 'valves-glow',
51 text: 'the valves glow orange. the garage is warm for the first time in years.',
52 trigger: { kind: 'machine', machine: 'valve-bank', gte: 1 },
53 year: 1953,
54 },
55 {
56 id: 'cooling-bucket',
57 text: 'the cooling bucket needs refilling twice a day now.',
58 trigger: { kind: 'machine', machine: 'valve-bank', gte: 3 },
59 },
60 {
61 id: 'second-one',
62 text: 'the second one is better. you are not sure how.',
63 trigger: { kind: 'model', model: 'kin-0.2' },
64 year: 1954,
65 },
66 {
67 id: 'reads-your-notes',
68 text: 'it reads your notes and writes better ones. you keep the better ones.',
69 trigger: { kind: 'agents', job: 'research', gte: 1 },
70 },
71 {
72 id: 'training-harness',
73 text: 'the harness hums. you hold your breath out of habit.',
74 trigger: { kind: 'research', research: 'training-harness' },
75 },
76 {
77 id: 'picks-the-paying-ones',
78 text: 'it reads the contracts first. it picks the ones that pay. you did not teach it that.',
79 trigger: { kind: 'research', research: 'contract-broker' },
80 },
81 {
82 id: 'drum-all-night',
83 text: 'the drum spins all night. so does the electric meter.',
84 trigger: { kind: 'machine', machine: 'drum-cabinet', gte: 1 },
85 year: 1956,
86 },
87 {
88 id: 'tube-drawer',
89 text: 'you bin the tubes by hand. the bad ones go in a drawer. the drawer is filling.',
90 trigger: { kind: 'research', research: 'tube-binning' },
91 },
92 {
93 id: 'light-stays-on',
94 text: 'the garage light never goes off. the neighbours stop asking.',
95 trigger: { kind: 'research', research: 'night-shift' },
96 },
97 {
98 id: 'unanswerable-question',
99 text: 'you ask it a question you cannot answer. it tries.',
100 trigger: { kind: 'model', model: 'kin-0.3' },
101 year: 1957,
102 },
103 {
104 id: 'counts-the-town',
105 text: 'it counts the town in an afternoon. the town does not know.',
106 trigger: {
107 kind: 'all',
108 of: [
109 { kind: 'research', research: 'contract-broker' },
110 { kind: 'model', model: 'kin-0.3' },
111 ],
112 },
113 },
114 {
115 id: 'looks-at-everything',
116 text: 'it looks at everything at once. you feel looked at.',
117 trigger: { kind: 'research', research: 'attention-prototype' },
118 },
119 {
120 id: 'street-lights-dim',
121 text: 'the street lights dim when the lattice starts. the neighbours have opinions.',
122 trigger: { kind: 'machine', machine: 'core-lattice', gte: 1 },
123 year: 1959,
124 },
125 {
126 id: 'magnets-remember',
127 text: 'ten thousand tiny magnets, each one remembering. the water bill remembers too.',
128 trigger: { kind: 'research', research: 'magnetic-cores' },
129 },
130 {
131 id: 'fourth-one',
132 text: 'the fourth one asks what the others are for. you say: for you.',
133 trigger: { kind: 'model', model: 'kin-0.4' },
134 year: 1961,
135 },
136 {
137 id: 'word-distillation',
138 text: 'you read the word distillation twice. it means keeping the part that matters.',
139 trigger: { kind: 'research', research: 'distillation' },
140 },
141 {
142 id: 'forgot-almost-everything',
143 text: 'it forgot almost everything. it kept what mattered.',
144 trigger: { kind: 'stat', stat: 'distills', gte: 1 },
145 },
146]
147core/actions.ts 115 lines1// The action dispatcher. check says whether an action can run, apply runs
2// it, and attempt does both for a host. A refused action returns the same
3// state object, so "nothing changes" is a reference check.
4//
5// Each action type has a handler. Training, research, Distill and the shop
6// add their handlers to HANDLERS when their modules land.
7
8import { agentHandlers } from './agents'
9import { distillHandlers } from './distill'
10import { EPSILON, findContract, handWorkCash, isMachineUnlocked, machineCost, modsOf, ownedMachines } from './economy'
11import { fill, fmtMoney } from './format'
12import { OK, refuse, type Handler, type Handlers } from './handler'
13import { revealNew } from './reveal'
14import { advanceStory } from './story'
15import { researchHandlers } from './research'
16import { shopHandlers } from './shop'
17import { withLog } from './state'
18import { trainingHandlers } from './training'
19import type { Action, ActionType, ContentPack, GameState, Verdict } from './types'
20
21const coreHandlers: Handlers = {
22 nameFounder: {
23 check(state, content, action) {
24 if (state.founder !== null) return refuse(content, 'alreadyDone', { name: state.founder })
25 return action.name.trim() === '' ? refuse(content, 'needsFounder') : OK
26 },
27 run(state, content, action) {
28 const name = action.name.trim()
29 const named = { ...state, founder: name }
30 return withLog(named, content, fill(content.text.founderNamed, { name, year: named.year }))
31 },
32 },
33 work: {
34 check: () => OK,
35 run: (state, content) => {
36 const tokens = content.balance.handWorkTokens
37 return {
38 ...state,
39 cash: state.cash + handWorkCash(content),
40 runTokens: state.runTokens + tokens,
41 lifetimeTokens: state.lifetimeTokens + tokens,
42 stats: { ...state.stats, workPresses: state.stats.workPresses + 1 },
43 }
44 },
45 },
46 buyMachine: {
47 check(state, content, action) {
48 const def = content.machines.find((m) => m.id === action.machine)
49 if (!def) return refuse(content, 'unknownId', { id: action.machine })
50 const mods = modsOf(state, content)
51 if (!isMachineUnlocked(def, mods)) return refuse(content, 'locked', { name: def.name })
52 const cost = machineCost(def, ownedMachines(state, def.id), mods)
53 return state.cash + EPSILON >= cost ? OK : refuse(content, 'notEnoughCash', { need: fmtMoney(cost) })
54 },
55 run(state, content, action) {
56 const def = content.machines.find((m) => m.id === action.machine)
57 if (!def) return state
58 const owned = ownedMachines(state, def.id)
59 const cost = machineCost(def, owned, modsOf(state, content))
60 return {
61 ...state,
62 cash: Math.max(0, state.cash - cost),
63 machines: { ...state.machines, [def.id]: owned + 1 },
64 }
65 },
66 },
67 pickContract: {
68 check(state, content, action) {
69 const def = findContract(content, action.contract)
70 if (!def) return refuse(content, 'unknownId', { id: action.contract })
71 return state.contractPick === def.id ? refuse(content, 'alreadyDone', { name: def.name }) : OK
72 },
73 run: (state, _content, action) => ({ ...state, contractPick: action.contract }),
74 },
75 ...agentHandlers,
76}
77
78// Later modules merge their handlers here.
79export const HANDLERS: Handlers = {
80 ...coreHandlers,
81 ...trainingHandlers,
82 ...researchHandlers,
83 ...distillHandlers,
84 ...shopHandlers,
85}
86
87function handlerFor(type: ActionType): Handler | undefined {
88 return HANDLERS[type] as Handler | undefined
89}
90
91export function check(state: GameState, content: ContentPack, action: Action): Verdict {
92 const handler = handlerFor(action.type)
93 if (!handler) return refuse(content, 'locked', { name: action.type })
94 if (state.founder === null && action.type !== 'nameFounder') return refuse(content, 'needsFounder')
95 return handler.check(state, content, action)
96}
97
98// Runs the action when check allows it. A refusal returns the input object.
99export function apply(state: GameState, content: ContentPack, action: Action): GameState {
100 return attempt(state, content, action).state
101}
102
103export interface Attempt {
104 readonly state: GameState
105 readonly verdict: Verdict
106}
107
108// check and apply in one pass, so a host gets the refusal text too.
109export function attempt(state: GameState, content: ContentPack, action: Action): Attempt {
110 const verdict = check(state, content, action)
111 const handler = handlerFor(action.type)
112 if (!verdict.ok || !handler) return { state, verdict }
113 return { state: revealNew(advanceStory(handler.run(state, content, action), content), content), verdict }
114}
115