SLOPSHOPPER

tokin

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

newpanecommandtoasttimer
v0.1.0no licenseupdated 2026-10-09Kiriketsuki/toKin
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · tokin
│ ┃ toKin ✕ › fix the failing auth test and add an audit log call │ ┃ toKin │ ┃ A cold room. A machine. You need a name on ⏺ Read(src/auth.ts) │ ┃ the paperwork. ⎿ Read 6 lines │ ┃ Founder name: type a name ⏎ found ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /tokin │ ⎿ tokin: toKin pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · toKin
toKin A cold room. A machine. You need a name on the paperwork. Founder name: type a name ⏎ found
README

toKin

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.

Play it in Claude Code

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.

Develop

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
FolderHolds
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

How to play

The pane has these sections, top to bottom.

SectionWhat it does
HeaderCash, tokens per second, compute used, free and wasted, research points, Logits
WorkPress to do one contract by hand. It pays at once
MachinesBuy compute. A machine appears when you hold half its cost. Each row shows the time to afford it
AgentsHire an agent on a kin model. Assign it to Earn, Research or Train. Remove it to free its compute
ContractsPick the contract that Earn agents work. An agent needs enough context for the contract
ResearchSpend research points on upgrades. Locked items say what they need
TrainingTrain the next kin model. A run reserves your free compute until it finishes
Logit shopSpend Logits on levels that survive a Distill
DistillReset the run and bank Logits. It needs Distillation and a payout of at least 1
LogThe 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.

Keys

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.

ControlEffect
WorkOne hand-worked contract
Buy buttonsBuy one machine at the shown cost
Create and assign buttonsHire an agent, change its job, or remove it
Distill, then Confirm DistillDistill needs two presses, so a slip does not wipe a run
CancelCloses 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.

Mirror events

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.

EventLog line
A rate limit or an overloaded error from the APIthe punch-card supplier is out of stock. come back later.
A compaction of your conversationthe ledger was boiled down. the sums survived. the margin notes did not.

Playtest handoff (T10)

The automated checks cover the engine, the host wiring, and the balance. The live playtest is still yours:

  1. Start a session with claude --plugin-dir /home/kiriketsuki/dev/Personal/toKin.
  2. Run /tokin, name the founder, and play from a fresh save to the first Distill.
  3. Check the transcript for dim plugin lines that report a skipped hook or a refused render.
  4. Restart the session, run /tokin again, and check that the save and the offline catch-up line appear.
  5. Open a second session, run /tokin there, and check that it shows the read-only line.

Architecture and porting

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.
  • The core reaches the outside world only through 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:

  1. Write a host that implements HostPorts for your platform. core/fakeports.ts is a worked example.
  2. Create new Game(ports, ERA1) and call start().
  3. Render game.view() on every game.subscribe callback.
  4. Turn input into Action values and pass them to game.dispatch. Show the refusal text when ok is false.
  5. Map your own platform events to game.mirror('rateLimit' | 'compaction'), or skip them.
  6. Reuse the save format as is. The save is the GameState JSON, and core/save.ts migrates old versions.
Source 43 files
hooks/register.tsx 165 lines
1// 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}
165
content/era1.ts 168 lines
1// 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}
168
core/game.ts 273 lines
1// 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}
273
core/view.ts 129 lines
1// 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}
129
hooks/adapter.ts 85 lines
1// 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}
85
hooks/controller.ts 36 lines
1// 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}
36
hooks/ui/pane.tsx 70 lines
1// 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}
70
core/types.ts 325 lines
1// 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'
325
content/era1-names.ts 22 lines
1// 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}
22
content/era1-research.ts 122 lines
1// 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]
122
content/era1-story.ts 147 lines
1// 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]
147
core/actions.ts 115 lines
1// 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