SLOPSHOPPER

omnirealm

OmniRealm, a god-simulator sandbox in a side panel: terraform a procedural world, seed races and beasts, watch kingdoms rise and war, then intervene with…

newpanecommandtoast
v0.3.0MITupdated 2026-10-09Roberdinho/claude-mods/plugins/omnirealm
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · omnirealm
│ ┃ OmniRealm ✕ › fix the failing auth test and add an audit log call │ ┃ ▣ client module ./world.tsx │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ 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 │ │ › /omnirealm │ ⎿ omnirealm: OmniRealm opened. Click the map to give it the keys; │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · OmniRealm
▣ client module ./world.tsx
README

omnirealm

OmniRealm, a god-simulator sandbox in a side panel. A procedural world of oceans, coasts, forests, mountains, tundra, desert, swamps and enchanted groves; humans, elves, orcs and dwarves who found kingdoms, farm, build, advance through the ages, make alliances, go to war and rebel; and you, with the power to reshape the land, seed life and intervene.

/omnirealm

On the desktop the map is pixel art with buttons and dropdowns; in the terminal it is drawn in coloured text with a clickable text toolbar. Click the map to give it the keyboard; Esc hands it back.

Powers

Pick a category, then a tool. A line under the toolbar says what the tool does.

CategoryTools
TerrainOcean, Shallows, Sand, Grass, Forest, Hills, Mountain, Desert, Tundra, Swamp, Enchanted, Corrupted, Heat, Cool
NatureRain, Lightning, Fire, Tornado, Earthquake, Volcano
LifeHumans, Elves, Orcs, Dwarves, Sheep, Chickens, Wolves, Bears, Dragon, Demon, UFO
DestroyDynamite, Bomb, Napalm, Atomic, Antimatter
MagicPlague, Zombies, Madness, Healing, Blessing, Curse
InspectInspect a creature, Remove life

Click or drag on the map to use a tool; brushes go from 1 to 6. Dragons, demons and UFOs land where you click. When nothing can be placed (people on water, a full world) the panel says why.

Inspect a creature to see its age, health, damage, armour, speed and traits. Click a trait to remove it, add a random one, follow the creature, or smite it.

The world

  • Kingdoms grow from villages, gather wood, stone and food, raise houses and children, and research their way from the Stone Age to the Arcane Age. A ruler is crowned when the throne is empty.
  • Diplomacy follows race and distance: neighbours fall out and declare war, allies join in, weary realms make peace, and a sprawling or leaderless kingdom may split in rebellion.
  • Traits are inherited and random: genius, immune, bloodlust, giant, fast, strong, lucky, wise, fire proof, regenerates, weak, slow, frail.
  • Realms lists every kingdom with its people, houses, age, ruler, wars and allies, and the chronicle of the world.

Controls

ControlDesktopTerminal
Pause / play❚❚ PauseSpace
Speed 1×, 2×, 4×⏩ Speed1, 2, 3
Brush size− Smaller, + Bigger[ and ]
PanRight-drag, arrows or WASDRight-drag, arrows or WASD
Zoom🔭 Zoom out / 🔎 Zoom inz
Kingdom borders🗺️ Bordersb
Realms and chronicle👑 Realmsr
Category, toolDropdownsTab, q and e
Save, load💾 Save, 📂 LoadShift+S, Shift+L
New world🌍 New worldthe New world button

Save keeps one world, across sessions.

How it draws

The game runs inside a Client region on the panel's own frame clock. On the desktop it posts each frame to the hooks module, which paints it as SVG (textured terrain, sprites tinted by kingdom, fire, storms and explosions), with the game's input region laid exactly over the picture. The buttons live in a Client of their own, so they keep working while the map redraws.

Source 5 files
hooks/register.tsx 344 lines
1import type { JsonValue, Register } from 'claude-code'
2
3import type { OmniCmd, OmniFrame } from '../types'
4import { worldSvg } from './art'
5
6const PANE = 'omnirealm'
7const TITLE = 'OmniRealm'
8const SAVE_KEY = 'world'
9
10const frameRef = { plugin: 'omnirealm', key: 'frame' } as const
11const cmdsRef = { plugin: 'omnirealm', key: 'cmds' } as const
12
13/** Lines a row of desktop controls takes with the gap after it: a button is taller than a line of text. */
14const BUTTON_ROW_LH = 2
15/** The line under the toolbar saying what the tool does. */
16const HINT_ROWS = 1
17/** Lines kept below the map for the log or the inspected unit's card. */
18const INFO_ROWS = 4
19/** The column's gaps between its parts, half a line each, and a line to spare. */
20const GAPS_LH = 3
21
22/** Cells a label takes: wide for emoji and other pictographs, nothing for a variation selector. */
23function cells(label: string): number {
24  let n = 0
25  for (const ch of label) {
26    const c = ch.codePointAt(0) ?? 0
27    if (c === 0xfe0f || c === 0x200d) continue
28    n += c >= 0x1f000 || (c >= 0x2600 && c <= 0x27bf) || (c >= 0x2b00 && c <= 0x2bff) ? 2 : 1
29  }
30  return n
31}
32
33/** How many rows a wrapping row of buttons with these labels takes at this width. */
34function buttonRows(labels: string[], cols: number): number {
35  let rows = 1, x = 0
36  for (const label of labels) {
37    const w = cells(label) + 4
38    if (x > 0 && x + w > cols) { rows++; x = 0 }
39    x += w + 1
40  }
41  return rows
42}
43
44// Kept in step with the game's TOOLS in world.tsx.
45const CATS: [string, string][] = [
46  ['terrain', '⛰️ Terrain'], ['nature', '🌦️ Nature'], ['life', '🐑 Life'],
47  ['destroy', '💣 Destroy'], ['magic', '✨ Magic'], ['inspect', '🔍 Inspect'],
48]
49const TOOLS: [string, string, string][] = [
50  ['terrain', 'deep', '🌊 Ocean'], ['terrain', 'shallow', '💧 Shallows'], ['terrain', 'sand', '🏖️ Sand'],
51  ['terrain', 'grass', '🌱 Grass'], ['terrain', 'forest', '🌲 Forest'], ['terrain', 'hills', '🟫 Hills'],
52  ['terrain', 'mountain', '🏔️ Mountain'], ['terrain', 'desert', '🏜️ Desert'], ['terrain', 'tundra', '❄️ Tundra'],
53  ['terrain', 'swamp', '🐸 Swamp'], ['terrain', 'enchanted', '🔮 Enchanted'], ['terrain', 'corrupt', '☠️ Corrupted'],
54  ['terrain', 'heat', '🌡️ Heat'], ['terrain', 'cool', '🧊 Cool'],
55  ['nature', 'rain', '🌧️ Rain'], ['nature', 'lightning', '⚡ Lightning'], ['nature', 'fire', '🔥 Fire'],
56  ['nature', 'tornado', '🌪️ Tornado'], ['nature', 'quake', '〰️ Earthquake'], ['nature', 'volcano', '🌋 Volcano'],
57  ['life', 'human', '🧑 Humans'], ['life', 'elf', '🧝 Elves'], ['life', 'orc', '👹 Orcs'], ['life', 'dwarf', '⛏️ Dwarves'],
58  ['life', 'sheep', '🐑 Sheep'], ['life', 'chicken', '🐔 Chickens'], ['life', 'wolf', '🐺 Wolves'], ['life', 'bear', '🐻 Bears'],
59  ['life', 'dragon', '🐉 Dragon'], ['life', 'demon', '😈 Demon'], ['life', 'ufo', '🛸 UFO'],
60  ['destroy', 'dynamite', '🧨 Dynamite'], ['destroy', 'bomb', '💣 Bomb'], ['destroy', 'napalm', '🔥 Napalm'],
61  ['destroy', 'atomic', '☢️ Atomic'], ['destroy', 'antimatter', '🕳️ Antimatter'],
62  ['magic', 'plague', '🦠 Plague'], ['magic', 'zombie', '🧟 Zombies'], ['magic', 'madness', '🌀 Madness'],
63  ['magic', 'heal', '💚 Healing'], ['magic', 'bless', '🙏 Blessing'], ['magic', 'curse', '💀 Curse'],
64  ['inspect', 'inspect', '🔍 Inspect'], ['inspect', 'erase', '🧹 Remove life'],
65]
66
67/** What each tool does, shown under the toolbar while it is picked. */
68const HINTS: Record<string, string> = {
69  deep: 'Paint deep ocean. Floods houses; creatures that can\'t fly drown.',
70  shallow: 'Paint shallow water. Floods houses.',
71  sand: 'Paint sand beaches. Buildable, poor farmland.',
72  grass: 'Paint grassland: the best farmland, where villages grow fastest.',
73  forest: 'Plant forest: wood for building. Burns easily.',
74  hills: 'Raise hills: stone for research. Dwarves love them.',
75  mountain: 'Raise mountains: a wall only dwarves, bears and demons cross.',
76  desert: 'Paint desert. Little food grows here.',
77  tundra: 'Paint snowy tundra.',
78  swamp: 'Paint swamp. Walkable, not buildable.',
79  enchanted: 'Plant enchanted forest: creatures standing on it heal.',
80  corrupt: 'Corrupt the land: it spreads, and demons crawl out of it.',
81  heat: 'Heat up: melts ice and snow, dries land to desert, rock to lava.',
82  cool: 'Cool down: freezes water, cools lava to rock, puts out fires.',
83  rain: 'Rain: puts out fires, greens scorched land and desert, grows forests.',
84  lightning: 'Lightning: strikes one spot, hurting creatures and setting fires.',
85  fire: 'Start a fire: spreads through forest, grass and houses.',
86  tornado: 'Tornado: wanders for a while, flinging creatures and wrecking houses.',
87  quake: 'Earthquake: raises hills, opens rifts, shakes houses down.',
88  volcano: 'Volcano: raises a mountain that pours lava for a while.',
89  human: 'Place humans: they found or join a kingdom, farm and build.',
90  elf: 'Place elves: long-lived, quick, gather wood without felling trees.',
91  orc: 'Place orcs: tough, regenerate, and make enemies easily.',
92  dwarf: 'Place dwarves: sturdy miners who can cross mountains.',
93  sheep: 'Place sheep: they graze and breed on grass; prey for wolves.',
94  chicken: 'Place chickens: they breed on grass; prey for wolves.',
95  wolf: 'Place wolves: they hunt sheep, chickens and sometimes people.',
96  bear: 'Place bears: strong hunters that attack anyone.',
97  dragon: 'Place a dragon: flies anywhere and breathes fire on villages.',
98  demon: 'Place a demon: sets fires and hunts people.',
99  ufo: 'Place a UFO: it abducts creatures, then leaves for the stars.',
100  dynamite: 'Dynamite: a small blast. Drag to blast a trail.',
101  bomb: 'Bomb: a big blast that scorches the land.',
102  napalm: 'Napalm: sets a wide area on fire.',
103  atomic: 'Atomic bomb: destroys everything far around, leaving a crater.',
104  antimatter: 'Antimatter: erases the land itself, leaving open ocean.',
105  plague: 'Plague: infects creatures; it spreads, and can kill. Immune ones resist.',
106  zombie: 'Zombies: turns people into zombies, or raises one; their bite spreads it.',
107  madness: 'Madness: people attack anyone near them for a while.',
108  heal: 'Healing: restores health and cures plague and madness.',
109  bless: 'Blessing: gives each creature under the brush a good trait.',
110  curse: 'Curse: gives each creature under the brush a bad trait.',
111  inspect: 'Inspect: click a creature to see its stats and traits, and edit them.',
112  erase: 'Remove life: removes every creature under the brush.',
113}
114
115/** The hint line: what the tool does, and how big it lands. */
116function hintFor(tool: string, brush: number): string {
117  const single = ['lightning', 'tornado', 'quake', 'volcano', 'dragon', 'demon', 'ufo', 'bomb', 'napalm', 'atomic', 'antimatter', 'inspect']
118  const how = single.includes(tool) ? 'Click the map.' : `Click or drag on the map · brush ${brush}.`
119  return `${HINTS[tool] ?? ''} ${how}  Keys after a click: arrows pan, space pauses.`
120}
121
122/** What the Client was last handed per surface, so a load answer keeps it. */
123const lastProps: Record<string, { [k: string]: JsonValue }> = {}
124/** The commands as last drawn; a press appends to these. */
125let cmds: OmniCmd[] = []
126let lastSeq = 0
127
128type Message = { type: 'frame'; frame?: OmniFrame; save?: JsonValue; load?: boolean }
129
130const isMessage = (data: unknown): data is Message =>
131  typeof data === 'object' && data !== null && (data as Message).type === 'frame'
132
133export const register: Register = on => {
134  on('session.start', async ($, e, next) => {
135    await $.command.register({
136      name: 'omnirealm',
137      description: 'Open OmniRealm, the god-simulator sandbox, in a side panel',
138    })
139
140    return next(e)
141  })
142
143  on('command.run', { command: 'omnirealm' }, async $ => {
144    await $.ui.open({ id: PANE, title: TITLE })
145
146    return { text: 'OmniRealm opened. Click the map to give it the keys; Esc hands them back.' }
147  })
148
149  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
150    if (e.surface === 'terminal') {
151      const { Box, Client } = $.ui.resolve(e)
152      lastProps.terminal = {}
153
154      return (
155        <Box flexDirection="column" width="100%">
156          <Client key="world" module="./world.tsx" width="100%" height={Math.max(16, e.props.scroll.bodyRows)} />
157        </Box>
158      )
159    }
160    if (e.surface !== 'desktop') {
161      const { Text } = $.ui.resolve(e)
162      return <Text dimColor>OmniRealm needs the terminal or the desktop app to draw its world.</Text>
163    }
164
165    // Desktop: the toolbar on top, where nothing below it can push it away;
166    // then the map, a pixel-art picture with the game's input region laid
167    // exactly over it, sized to leave room for everything else; then info.
168    // Every button and dropdown is drawn by a Client of its own (see
169    // controls.tsx): this pane redraws with each frame of the map, and a
170    // Button it drew would lose its press to the next drawing.
171    const { Box, Text, Svg, Client } = $.ui.resolve(e)
172    const { value: frame } = await $.state.get(frameRef)
173    cmds = (await $.state.get(cmdsRef)).value ?? []
174    const hud = frame?.hud
175    const info = frame?.info
176    const cat = hud?.cat ?? 'life'
177    const isRealms = hud?.view === 'realms' && frame !== undefined
178
179    // The header names the tool, brush, speed and state these set.
180    const speedNext = hud?.speed === 1 ? 2 : hud?.speed === 2 ? 4 : 1
181    const toolbarButtons = [
182      { key: 'pause', label: hud?.paused ? '▶ Play' : '❚❚ Pause', act: 'pause', active: hud?.paused === true },
183      { key: 'speed', label: `⏩ Speed ${hud?.speed ?? 1}×`, act: `speed:${speedNext}` },
184      { key: 'brush-down', label: '− Smaller', act: 'brush:-1' },
185      { key: 'brush-up', label: '+ Bigger', act: 'brush:1' },
186      { key: 'zoom', label: hud?.zoom === 1 ? '🔎 Zoom in' : '🔭 Zoom out', act: 'zoom' },
187      { key: 'borders', label: '🗺️ Borders', act: 'borders', active: hud?.borders === true },
188      { key: 'realms', label: '👑 Realms', act: 'realms', active: isRealms },
189      { key: 'save', label: '💾 Save', act: 'save' },
190      { key: 'load', label: '📂 Load', act: 'load' },
191      { key: 'new', label: '🌍 New world', act: 'new' },
192    ]
193    const toolOptions = TOOLS.filter(([c]) => c === cat).map(([, id, label]) => ({ value: id, label }))
194    const toolbarProps = {
195      selects: [
196        { key: 'cat', value: cat, act: 'cat', options: CATS.map(([value, label]) => ({ value, label })) },
197        { key: 'tool', value: hud?.tool ?? '', act: 'tool', options: toolOptions },
198      ],
199      buttons: toolbarButtons,
200    }
201    const catLabel = CATS.find(([id]) => id === cat)?.[1] ?? ''
202    const toolLabel = toolOptions.find(o => o.value === hud?.tool)?.label ?? ''
203
204    // Room the map may take: the pane less the header, the toolbar's rows as
205    // they wrap at this width, the info below and the gaps.
206    const cols = Math.max(20, e.props.bodyColumns)
207    const toolbarRows = buttonRows([`${catLabel} ▾`, `${toolLabel} ▾`, ...toolbarButtons.map(s => s.label)], cols)
208    const realmRows = isRealms ? Math.min(frame.realms.length, 8) + 2 + frame.log.length : 0
209    const chrome = 1 + toolbarRows * BUTTON_ROW_LH + HINT_ROWS + INFO_ROWS + realmRows + GAPS_LH
210    const availRows = Math.max(8, e.props.scroll.bodyRows - chrome)
211    const props = { desktop: true, availRows, cmds }
212    lastProps.desktop = props
213
214
215    const header = hud === undefined
216      ? <Text dimColor>Shaping the world…</Text>
217      : (
218        <Text wrap="truncate-end">
219          <Text bold color="#ffd23f">OmniRealm</Text>
220          <Text dimColor>{`  Year ${hud.year} · ${hud.pop} people · ${hud.kingdoms} kingdoms${hud.wars > 0 ? ` · ${hud.wars} at war` : ''} · ${hud.beasts} beasts  `}</Text>
221          <Text bold color="#74c0fc">{`${hud.toolLabel}${hud.cat !== 'inspect' && hud.tool !== 'lightning' ? ` · brush ${hud.brush}` : ''}`}</Text>
222          <Text color={hud.paused ? '#ffa94d' : '#8ce99a'}>{hud.paused ? '  ❚❚ paused' : `  ▶ ${hud.speed}×`}</Text>
223        </Text>
224      )
225
226    const toolbar = (
227      <Box flexDirection="column">
228        <Client key="toolbar" module="./controls.tsx" props={toolbarProps} />
229        <Box key="hint"><Text dimColor wrap="truncate-end">{hud === undefined ? ' ' : hintFor(hud.tool, hud.brush)}</Text></Box>
230      </Box>
231    )
232
233    const realms = isRealms
234      ? (
235        <Box flexDirection="column">
236          <Text bold>{`Realms of the world (${frame.realms.length})`}</Text>
237          {frame.realms.length === 0 && <Text dimColor>No kingdoms yet: drop humans, elves, orcs or dwarves on land.</Text>}
238          {frame.realms.slice(0, 8).map(r => (
239            <Text key={`realm-${r.name}`} wrap="truncate-end">
240              <Text color={r.color}>■ </Text>
241              <Text bold color={r.color}>{r.name}</Text>
242              <Text dimColor>{` ${r.text}`}</Text>
243              {r.wars ? <Text color="#ff8787">{` · ⚔ ${r.wars}`}</Text> : null}
244              {r.allies ? <Text color="#8ce99a">{` · ♥ ${r.allies}`}</Text> : null}
245            </Text>
246          ))}
247          <Text bold>Chronicle</Text>
248          {frame.log.map((line, n) => <Text key={`chron-${n}`} dimColor wrap="truncate-end">{line}</Text>)}
249        </Box>
250      )
251      : null
252
253    // The map's box is the pane's width by `availRows` rows: the picture is
254    // stretched over it (a flex row stretches its height; it fills its box),
255    // and the game's region, as wide as the pane, draws itself that many rows
256    // tall over the same box, so a click's cell maps straight to a tile. The
257    // desktop sizes a Client by what it draws, never by a percentage height.
258    const map = (
259      <Box key="map" position="relative" width="100%">
260        <Box flexDirection="row" width="100%" height={availRows}>
261          {frame !== undefined
262            ? <Svg source={worldSvg(frame)} alt={`OmniRealm world map, year ${frame.hud.year}`} />
263            : <Text dimColor> </Text>}
264        </Box>
265        <Box position="absolute" top={0} left={0} width="100%" flexDirection="column">
266          <Client key="world" module="./world.tsx" props={props} />
267        </Box>
268      </Box>
269    )
270
271    const card = info?.kind === 'unit'
272      ? {
273        label: info.traits.length > 0 ? 'Traits (click to remove):' : 'No traits',
274        buttons: [
275          ...info.traits.map(t => ({ key: `trait-${t.name}`, label: `${t.good ? '✦' : '✧'} ${t.name.replace('_', ' ')} ✕`, act: `untrait:${t.name}` })),
276          ...(info.canTrait ? [{ key: 'trait-add', label: '+ Random trait', act: 'trait' }] : []),
277          { key: 'follow', label: info.follow ? '📍 Stop following' : '📍 Follow', act: 'follow', active: info.follow },
278          { key: 'smite', label: '⚡ Smite', act: 'smite' },
279          { key: 'close', label: '✕ Close', act: 'close' },
280        ],
281      }
282      : undefined
283    const details = info?.kind === 'unit' && card !== undefined
284      ? (
285        <Box flexDirection="column" rowGap={1}>
286          <Text wrap="truncate-end">
287            <Text bold color={info.color}>{info.name}</Text>
288            <Text dimColor>{`  ${info.line}`}</Text>
289          </Text>
290          <Client key="card" module="./controls.tsx" props={card} />
291        </Box>
292      )
293      : isRealms
294        ? null
295        : (
296          <Box flexDirection="column">
297            {(frame?.log ?? []).map((line, n) => <Text key={`log-${n}`} dimColor={n > 0} wrap="truncate-end">{line}</Text>)}
298          </Box>
299        )
300
301    return (
302      <Box flexDirection="column" width="100%" rowGap={1}>
303        {header}
304        {toolbar}
305        {realms}
306        {map}
307        {details}
308      </Box>
309    )
310  })
311
312  // The game posts each frame and its saves; the controls their commands,
313  // handed on to the game through its props.
314  on('ui.message', async ($, e, next) => {
315    const data = e.data as { type?: string; act?: string } | null
316    if (data?.type === 'cmd' && typeof data.act === 'string') {
317      lastSeq = Math.max(lastSeq + 1, Date.now())
318      cmds = [...cmds, { seq: lastSeq, act: data.act }].slice(-16)
319      await $.state.set(cmdsRef, cmds)
320
321      return {}
322    }
323    if (e.element !== 'world' || !isMessage(e.data)) return next(e)
324    const msg = e.data
325    if (msg.save !== undefined) {
326      await $.store.set(SAVE_KEY, msg.save)
327      $.ui.toast('OmniRealm: world saved')
328    }
329    if (msg.frame !== undefined) await $.state.set(frameRef, msg.frame)
330    if (msg.load === true) {
331      const world = await $.store.get(SAVE_KEY)
332      if (world === undefined) {
333        $.ui.toast('OmniRealm: no saved world yet')
334
335        return {}
336      }
337
338      return { props: { ...(lastProps[e.surface] ?? {}), load: world as JsonValue, nonce: Date.now() } }
339    }
340
341    return {}
342  })
343}
344
hooks/art.ts 304 lines
1import type { OmniFrame } from '../types'
2
3/*
4 * The desktop's picture of the world: pixel-art SVG built from a frame.
5 * Terrain is one path per kind filled with a textured pattern, so the size
6 * follows the shape of the land, not its area; sprites are symbols drawn
7 * from 8x8 pixel maps, tinted by kingdom through `currentColor`.
8 */
9
10const T = 16
11
12// Kept in step with the game's own tables in world.tsx.
13const KIND_IDS = ['human', 'elf', 'orc', 'dwarf', 'sheep', 'chicken', 'wolf', 'bear', 'dragon', 'demon', 'ufo', 'zombie']
14const FX_IDS = ['rain', 'flash', 'boom', 'tornado', 'volcano', 'heal', 'plague', 'magic', 'nuke']
15const DEEP = 0, SHALLOW = 1, SAND = 2, GRASS = 3, FOREST = 4, HILLS = 5, MOUNTAIN = 6, SNOW = 7,
16  DESERT = 8, SWAMP = 9, ENCHANTED = 10, LAVA = 12
17
18const PALETTE: Record<string, string> = {
19  k: '#1b1b1f', w: '#ffffff', h: '#ead9b0', s: '#f2c08d', b: '#7a4a21', B: '#4e2e12', g: '#3f8f3a',
20  G: '#24602a', l: '#6fc35a', r: '#d63a2a', R: '#8f1f16', o: '#f08a24', y: '#ffd23f', e: '#a3a3a3',
21  E: '#5d5d63', p: '#8b4fd6', P: '#ff5fa8', u: '#3b6fc8', t: '#6ff0d0', n: '#f4f8fb', z: '#8fbf4f',
22  m: '#c9ccd1', M: '#8a8f99', c: 'currentColor',
23}
24
25/** Horizontal runs of one colour become one rect each. */
26function pixels(rows: string[], scale: number, palette: Record<string, string> = PALETTE): string {
27  let out = ''
28  rows.forEach((row, y) => {
29    let x = 0
30    while (x < row.length) {
31      const ch = row[x] as string
32      let end = x + 1
33      while (end < row.length && row[end] === ch) end++
34      const fill = palette[ch]
35      if (fill !== undefined) out += `<rect x="${x * scale}" y="${y * scale}" width="${(end - x) * scale}" height="${scale}" fill="${fill}"/>`
36      x = end
37    }
38  })
39  return out
40}
41
42const SPRITES: Record<string, string[]> = {
43  tree: ['...GG...', '..GgGG..', '.GggglG.', '.GgglgG.', '..GggG..', '...bB...', '...bB...', '........'],
44  pine: ['...G....', '..GgG...', '...G....', '..GgG...', '.GgggG..', '..GGG...', '...b....', '........'],
45  mountain: ['........', '...nn...', '..nnEE..', '..eeEE..', '.eeeEEE.', '.eeeeEEE', 'eeeeeEEE', '........'],
46  hill: ['........', '........', '........', '...ll...', '..lggg..', '.lggggG.', 'lgggggGG', '........'],
47  cactus: ['........', '...g....', '.g.g....', '.ggg.g..', '...ggg..', '...g....', '...g....', '........'],
48  reed: ['........', '.b...b..', '.g.b.g..', '.g.g.g..', '.ggg.g..', '..g.gg..', '........', '........'],
49  crystal: ['........', '...t....', '..tpt...', '..ppp.t.', '..pPp.p.', '...p....', '........', '........'],
50  house: ['...cc...', '..cccc..', '.cccccc.', 'cccccccc', '.hhhhhh.', '.hhBBhh.', '.hhBBhh.', '........'],
51  castle: ['c.c..c.c', 'cccccccc', '.mmmmmm.', '.mMmmMm.', '.mmBBmm.', '.mmBBmm.', '.MmBBmM.', '........'],
52  fire: ['...y....', '..yo.y..', '..oyo...', '.oryo.o.', '.orryoo.', '.rroorr.', '..rrRr..', '........'],
53  human: ['...bb...', '..bssb..', '...ss...', '..cccc..', '.sccccs.', '..cccc..', '..u..u..', '..k..k..'],
54  elf: ['..yyyy..', '.s.ss.s.', '...ss...', '..cccc..', '.sccccs.', '..cccc..', '..g..g..', '..k..k..'],
55  orc: ['........', '..zzzz..', '..zwwz..', '.cccccc.', 'zccccccz', '.cccccc.', '..E..E..', '..k..k..'],
56  dwarf: ['........', '..mmmm..', '..ssss..', '..oooo..', '.cooooc.', '.cccccc.', '..b..b..', '..k..k..'],
57  sheep: ['........', '........', '.wwwww..', 'wwwwwwkk', 'wwwwwwkk', '.wwwww..', '.k.k.k..', '........'],
58  chicken: ['........', '....r...', '...ww...', '..wwwo..', 'wwwww...', '.www....', '..y.....', '........'],
59  wolf: ['........', '......e.', '.....eee', 'eeeeeeek', 'eeeeeee.', '.e.e.e..', '.e.e.e..', '........'],
60  bear: ['.B...B..', '.bbbbb..', '.bkbkbbb', '.bbbbbbb', 'bbbbbbbb', 'bbbbbbbb', '.b.b.b.b', '........'],
61  dragon: ['r.....r.', 'rr...rr.', '.rrrrr..', '..rrrrry', '.rrRrrr.', 'rr.rr.rr', 'r..r...r', '........'],
62  demon: ['r......r', '.r....r.', '..PPPP..', '..PyyP..', '.PPPPPP.', 'P.PPPP.P', '..P..P..', '..k..k..'],
63  ufo: ['........', '...tt...', '..tttt..', '.mmmmmm.', 'mmymymmm', '.mmmmmm.', '..y..y..', '........'],
64  zombie: ['...zz...', '..zkzk..', '...zz...', '.zuuuu..', 'z.uuuuz.', '..uuuu..', '..E..E..', '..k..k..'],
65  crown: ['........', 'y..y..y.', 'yyyyyyy.', '........', '........', '........', '........', '........'],
66}
67
68type Pattern = { base: string; rows?: string[]; pal?: Record<string, string> }
69
70const PATTERNS: Pattern[] = [
71  { base: '#1f4f8f', rows: ['........', '.ww.....', 'w..w....', '........', '........', '.....ww.', '....w..w', '........'], pal: { w: '#2f66aa' } },
72  { base: '#3d86c6', rows: ['........', '..ww....', '.w..w...', '........', '........', '......ww', '.....w..', '........'], pal: { w: '#62a8e0' } },
73  { base: '#e2cf8a', rows: ['........', '.d......', '.....d..', '........', '...d....', '........', '......d.', '..d.....'], pal: { d: '#c9b06a' } },
74  { base: '#5fa548', rows: ['........', '.l......', '.dl..d..', '.....dl.', '........', '..d.....', '..dl..l.', '......d.'], pal: { d: '#4c8f3a', l: '#7dc362' } },
75  { base: '#3e7d35', rows: ['........', '..d.....', '......d.', '........', '.d......', '....d...', '........', '.......d'], pal: { d: '#356d2d' } },
76  { base: '#8f9a55', rows: ['........', '..dd....', '........', '.....dd.', '........', '.dd.....', '........', '....dd..'], pal: { d: '#7a8446' } },
77  { base: '#7b746c', rows: ['........', '.d......', '.....d..', '........', '..d.....', '......d.', '........', '...d....'], pal: { d: '#6a645c' } },
78  { base: '#eef3f6', rows: ['........', '..d.....', '......d.', '........', '....d...', '.d......', '........', '......d.'], pal: { d: '#d5e0e8' } },
79  { base: '#e8b866', rows: ['........', '........', '..dddd..', '.d....d.', '........', '........', 'dd....dd', '..dddd..'], pal: { d: '#d6a352' } },
80  { base: '#4f6440', rows: ['........', '.ww.....', 'wwww....', '.ww.....', '.....d..', '....ww..', '...wwww.', '....ww..'], pal: { w: '#3e5a5c', d: '#62784f' } },
81  { base: '#5d4594', rows: ['........', '.s......', '........', '.....s..', '........', '..s.....', '......s.', '........'], pal: { s: '#d6b8ff' } },
82  { base: '#3f1d36', rows: ['..v.....', '..v.....', '...v....', '...vv...', '.....v..', '.....v..', '....v...', '........'], pal: { v: '#8a2f63' } },
83  { base: '#cf3f16', rows: ['........', '.yy.....', '.yoy....', '..y...d.', '.....ddd', '......d.', '..o.....', '........'], pal: { y: '#ffc23a', o: '#ff8a1c', d: '#9e2a0b' } },
84  { base: '#b9e2f2', rows: ['w.......', '.w......', '..w.....', '..w.....', '...ww...', '.....w..', '......w.', '.......w'], pal: { w: '#e8f7fd' } },
85  { base: '#3d3532', rows: ['........', '.a......', '.....a..', '...a....', '........', '......a.', '..a.....', '........'], pal: { a: '#5b4f49' } },
86]
87
88// Water and lava shimmer: a second pattern offset by a pixel, swapped each frame.
89const ALT: Record<number, number> = { [DEEP]: 15, [SHALLOW]: 16, [LAVA]: 17 }
90
91function patternDefs(): string {
92  const one = (id: string, p: Pattern, shift: number) => {
93    const rows = (p.rows ?? []).map(r => r.slice(shift) + r.slice(0, shift))
94    return `<pattern id="${id}" width="${T}" height="${T}" patternUnits="userSpaceOnUse"><rect width="${T}" height="${T}" fill="${p.base}"/>${pixels(rows, 2, p.pal)}</pattern>`
95  }
96  // Land whose every tile carries a sprite has it baked into its texture,
97  // so a view full of forest costs one path, not a sprite per tile.
98  const withSprites = (id: string, p: Pattern, sprites: string[]) =>
99    `<pattern id="${id}" width="${T * sprites.length}" height="${T}" patternUnits="userSpaceOnUse"><rect width="${T * sprites.length}" height="${T}" fill="${p.base}"/>`
100    + sprites.map((sp, n) => `<g transform="translate(${n * T} 0)">${pixels(p.rows ?? [], 2, p.pal)}${pixels(SPRITES[sp] ?? [], 2)}</g>`).join('') + '</pattern>'
101  const baked: Record<number, string[]> = { [FOREST]: ['tree', 'pine', 'tree'], [MOUNTAIN]: ['mountain'], [HILLS]: ['hill', 'hill', '.'] }
102  let out = PATTERNS.map((p, i) => baked[i] !== undefined ? withSprites(`t${i}`, p, baked[i] ?? []) : one(`t${i}`, p, 0)).join('')
103  for (const [t, alt] of Object.entries(ALT)) out += one(`t${alt}`, PATTERNS[Number(t)] as Pattern, 2)
104  return out
105}
106
107const DEFS = `<defs>${patternDefs()}${Object.entries(SPRITES).map(([id, rows]) => `<symbol id="${id}" viewBox="0 0 8 8">${pixels(rows, 1)}</symbol>`).join('')}`
108  + `<radialGradient id="boom"><stop offset="0" stop-color="#fff6c2"/><stop offset=".45" stop-color="#ffb12a"/><stop offset="1" stop-color="#d9480f" stop-opacity="0"/></radialGradient>`
109  + `<radialGradient id="glow"><stop offset="0" stop-color="#ff6a2a" stop-opacity=".7"/><stop offset="1" stop-color="#ff6a2a" stop-opacity="0"/></radialGradient></defs>`
110
111/** A cheap per-tile hash, for decorations that stay put. */
112const hash = (x: number, y: number) => ((x * 73856093) ^ (y * 19349663)) >>> 0
113
114const use = (id: string, x: number, y: number, size = T, extra = '') =>
115  `<use href="#${id}" x="${x}" y="${y}" width="${size}" height="${size}"${extra}/>`
116
117/** What an Svg's source may hold, less a margin. */
118const SVG_BUDGET = 124_000
119
120/**
121 * The picture of a frame, as much of it as fits an Svg's source: past the
122 * budget it drops the sparse decorations and the shore foam, then the
123 * borders' wash, rather than leave the pane blank.
124 */
125export function worldSvg(f: OmniFrame): string {
126  let svg = ''
127  for (const detail of [2, 1, 0]) {
128    svg = drawWorld(f, detail)
129    if (svg.length <= SVG_BUDGET) return svg
130  }
131  return svg
132}
133
134function drawWorld(f: OmniFrame, detail: number): string {
135  const { vw, vh, camX: worldX, camY: worldY } = f
136  const W = vw * T, H = vh * T
137  const out: string[] = []
138  // The pane stretches the picture over a box of its own: as wide as the
139  // pane, as tall as the rows it gives the map. It fills that box exactly,
140  // so the game's input region laid over the same box lines up tile for tile.
141  out.push(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="4000" height="${Math.round((4000 * vh) / vw)}" preserveAspectRatio="none" shape-rendering="crispEdges">`)
142  out.push(DEFS)
143
144  // Terrain: one path per kind, a run of equal tiles per subpath.
145  const paths = new Map<number, string>()
146  for (let y = 0; y < vh; y++) {
147    let x = 0
148    while (x < vw) {
149      const t = f.terr.charCodeAt(y * vw + x) - 65
150      let end = x + 1
151      while (end < vw && f.terr.charCodeAt(y * vw + end) - 65 === t) end++
152      paths.set(t, (paths.get(t) ?? '') + `M${x * T} ${y * T}h${(end - x) * T}v${T}h-${(end - x) * T}z`)
153      x = end
154    }
155  }
156  const odd = f.tick % 2 === 1
157  for (const [t, d] of paths) out.push(`<path fill="url(#t${odd && ALT[t] !== undefined ? ALT[t] : t})" d="${d}"/>`)
158
159  // Shore foam where land meets water, on the land's edge.
160  let foam = ''
161  for (let y = 0; y < vh; y++) for (let x = 0; x < vw; x++) {
162    const t = f.terr.charCodeAt(y * vw + x) - 65
163    if (t === DEEP || t === SHALLOW) continue
164    const water = (dx: number, dy: number) => {
165      const nx = x + dx, ny = y + dy
166      if (nx < 0 || ny < 0 || nx >= vw || ny >= vh) return false
167      const n = f.terr.charCodeAt(ny * vw + nx) - 65
168      return n === DEEP || n === SHALLOW
169    }
170    if (water(0, -1)) foam += `M${x * T} ${y * T}h${T}`
171    if (water(0, 1)) foam += `M${x * T} ${y * T + T}h${T}`
172    if (water(-1, 0)) foam += `M${x * T} ${y * T}v${T}`
173    if (water(1, 0)) foam += `M${x * T + T} ${y * T}v${T}`
174  }
175  if (foam && detail >= 2) out.push(`<path d="${foam}" stroke="#e9f6ff" stroke-opacity=".55" stroke-width="2" fill="none"/>`)
176
177  // Kingdom borders: a wash of each realm's colour over its land.
178  if (f.owner.length === vw * vh && detail >= 1) {
179    const wash = new Map<number, string>()
180    for (let y = 0; y < vh; y++) {
181      let x = 0
182      while (x < vw) {
183        const o = f.owner.charCodeAt(y * vw + x) - 48
184        let end = x + 1
185        while (end < vw && f.owner.charCodeAt(y * vw + end) - 48 === o) end++
186        if (o > 0) wash.set(o, (wash.get(o) ?? '') + `M${x * T} ${y * T}h${(end - x) * T}v${T}h-${(end - x) * T}z`)
187        x = end
188      }
189    }
190    for (const [o, d] of wash) out.push(`<path fill="${f.colors[o - 1] ?? '#fff'}" fill-opacity=".28" d="${d}"/>`)
191  }
192
193  // Decorations that belong to the land.
194  const houses = new Set<number>()
195  for (let n = 0; n < f.houses.length; n += 3) houses.add(f.houses[n] ?? -1)
196  for (let y = 0; y < vh && detail >= 2; y++) for (let x = 0; x < vw; x++) {
197    const i = y * vw + x
198    if (houses.has(i)) continue
199    const t = f.terr.charCodeAt(i) - 65
200    const hsh = hash(x + worldX, y + worldY)
201    const px = x * T, py = y * T
202    if (t === DESERT && hsh % 9 === 0) out.push(use('cactus', px, py))
203    else if (t === SWAMP && hsh % 4 === 0) out.push(use('reed', px, py))
204    else if (t === ENCHANTED && hsh % 5 === 0) out.push(use('crystal', px, py))
205    else if (t === SNOW && hsh % 11 === 0) out.push(use('pine', px, py))
206    else if (t === GRASS && hsh % 23 === 0) out.push(use('tree', px, py, 12, ' opacity=".9"'))
207  }
208
209  // Houses and capitals in their kingdom's colour.
210  for (let n = 0; n < f.houses.length; n += 3) {
211    const i = f.houses[n] ?? 0, kd = f.houses[n + 1] ?? 0, cap = f.houses[n + 2] === 1
212    out.push(use(cap ? 'castle' : 'house', (i % vw) * T, Math.floor(i / vw) * T, T, ` color="${f.colors[kd] ?? '#ccc'}"`))
213  }
214
215  // Fire, flickering between two sizes.
216  for (const i of f.fire) {
217    const x = (i % vw) * T, y = Math.floor(i / vw) * T
218    const big = (i + f.tick) % 2 === 0
219    out.push(`<rect x="${x}" y="${y}" width="${T}" height="${T}" fill="url(#glow)"/>`)
220    out.push(use('fire', x + (big ? 0 : 2), y + (big ? 0 : 3), big ? T : 12))
221  }
222
223  // Creatures: a step of bob, the crowd spread a little, rulers crowned.
224  for (let n = 0; n < f.units.length; n += 6) {
225    const x = f.units[n] ?? 0, y = f.units[n + 1] ?? 0, kind = KIND_IDS[f.units[n + 2] ?? 0] ?? 'human'
226    const kd = f.units[n + 3] ?? -1, flags = f.units[n + 4] ?? 0, id = f.units[n + 5] ?? 0
227    const big = kind === 'dragon' || kind === 'ufo' || kind === 'demon'
228    const size = big ? 24 : 13
229    const ox = x * T + (T - size) / 2 + (big ? 0 : ((id % 3) - 1) * 2)
230    const oy = y * T + (T - size) / 2 - ((f.tick + id) % 2) - (big ? 4 : 0)
231    const color = kd >= 0 ? f.colors[kd] ?? '#ddd' : '#c8b9a6'
232    if (flags & 8) out.push(`<rect x="${x * T - 1}" y="${y * T - 1}" width="${T + 2}" height="${T + 2}" fill="none" stroke="#fff" stroke-width="2"/>`)
233    if (big) out.push(`<ellipse cx="${x * T + 8}" cy="${y * T + 15}" rx="9" ry="3" fill="#000" fill-opacity=".3"/>`)
234    const tint = flags & 2 ? ' opacity=".75"' : ''
235    out.push(use(kind, ox, oy, size, ` color="${flags & 4 ? '#ff4dff' : color}"${tint}`))
236    if (flags & 2) out.push(`<circle cx="${x * T + 12}" cy="${y * T + 3}" r="2.5" fill="#a9e34b"/>`)
237    if (flags & 1) out.push(use('crown', ox + 2, oy - 6, 9))
238  }
239
240  // Powers in flight.
241  for (let n = 0; n < f.fx.length; n += 5) {
242    const kind = FX_IDS[f.fx[n] ?? 0], x = f.fx[n + 1] ?? 0, y = f.fx[n + 2] ?? 0, r = f.fx[n + 3] ?? 0, ttl = f.fx[n + 4] ?? 0
243    const cx = x * T + 8, cy = y * T + 8, R = (r + 0.5) * T
244    switch (kind) {
245      case 'rain': {
246        out.push(`<circle cx="${cx}" cy="${cy}" r="${R}" fill="#5c7a99" fill-opacity=".22"/>`)
247        let d = ''
248        for (let k = 0; k < r * 6 + 6; k++) {
249          const a = hash(k, f.tick) % 1000 / 1000 * Math.PI * 2, dd = (hash(f.tick, k) % 1000) / 1000 * R
250          const sx = cx + Math.cos(a) * dd, sy = cy + Math.sin(a) * dd
251          d += `M${sx.toFixed(0)} ${sy.toFixed(0)}l-3 8`
252        }
253        out.push(`<path d="${d}" stroke="#cfe8ff" stroke-width="1.5" stroke-opacity=".85"/>`)
254        break
255      }
256      case 'flash':
257        out.push(`<circle cx="${cx}" cy="${cy}" r="${T * 1.5}" fill="#fffbe0" fill-opacity=".55"/>`)
258        out.push(`<path d="M${cx + 2} ${cy - 120}l-10 40h10l-12 44h9l-8 36 26-48h-10l12-40h-10l10-32z" fill="#fff59a" stroke="#fff" stroke-width="1" shape-rendering="auto"/>`)
259        break
260      case 'boom':
261        out.push(`<circle cx="${cx}" cy="${cy}" r="${R * (1.4 - ttl * 0.15)}" fill="url(#boom)" shape-rendering="auto"/>`)
262        break
263      case 'nuke': {
264        const grow = 1.25 - ttl / 12
265        out.push(`<circle cx="${cx}" cy="${cy}" r="${R * grow}" fill="url(#boom)" shape-rendering="auto"/>`)
266        if (ttl > 6) out.push(`<rect width="${W}" height="${H}" fill="#fff" fill-opacity="${((ttl - 6) / 5).toFixed(2)}"/>`)
267        else out.push(`<g shape-rendering="auto" fill="#9c8f86" fill-opacity=".85"><rect x="${cx - 8}" y="${cy - 70}" width="16" height="70"/><ellipse cx="${cx}" cy="${cy - 78}" rx="46" ry="26"/><ellipse cx="${cx}" cy="${cy - 92}" rx="30" ry="16" fill="#b8aaa0"/></g>`)
268        break
269      }
270      case 'tornado': {
271        const s = f.tick % 2 === 0 ? 1 : -1
272        out.push(`<g shape-rendering="auto" fill="none" stroke="#d9dde2" stroke-width="3" stroke-opacity=".9">`
273          + `<ellipse cx="${cx}" cy="${cy - 26}" rx="22" ry="6"/><ellipse cx="${cx + 3 * s}" cy="${cy - 16}" rx="16" ry="5"/>`
274          + `<ellipse cx="${cx - 2 * s}" cy="${cy - 7}" rx="10" ry="4"/><ellipse cx="${cx + s}" cy="${cy + 1}" rx="5" ry="3"/></g>`)
275        break
276      }
277      case 'volcano':
278        out.push(`<circle cx="${cx}" cy="${cy}" r="${T * 3}" fill="url(#glow)" shape-rendering="auto"/>`)
279        out.push(`<g fill="#ffb12a">${[0, 1, 2, 3, 4].map(k => { const a = hash(k, f.tick) % 628 / 100; const d = 8 + (hash(f.tick, k) % 30); return `<rect x="${(cx + Math.cos(a) * d).toFixed(0)}" y="${(cy - 10 - Math.abs(Math.sin(a)) * d).toFixed(0)}" width="3" height="3"/>` }).join('')}</g>`)
280        break
281      case 'heal':
282        out.push(`<circle cx="${cx}" cy="${cy}" r="${R}" fill="#8ce99a" fill-opacity=".18"/>`)
283        out.push(`<g fill="#b2f2bb">${[0, 1, 2, 3, 4, 5].map(k => { const a = k + f.tick * 0.3; const d = R * 0.6; const px = cx + Math.cos(a) * d, py = cy + Math.sin(a) * d - ttl; return `<rect x="${(px - 1).toFixed(0)}" y="${(py - 4).toFixed(0)}" width="3" height="9"/><rect x="${(px - 4).toFixed(0)}" y="${(py - 1).toFixed(0)}" width="9" height="3"/>` }).join('')}</g>`)
284        break
285      case 'plague':
286        out.push(`<circle cx="${cx}" cy="${cy}" r="${R}" fill="#94d82d" fill-opacity=".28" shape-rendering="auto"/>`)
287        break
288      case 'magic':
289        out.push(`<circle cx="${cx}" cy="${cy}" r="${R}" fill="#be4bdb" fill-opacity=".2" shape-rendering="auto"/>`)
290        out.push(`<g fill="#f3d9fa">${[0, 1, 2, 3, 4, 5, 6].map(k => { const a = hash(k, ttl) % 628 / 100; const d = (hash(ttl, k) % 100) / 100 * R; return `<rect x="${(cx + Math.cos(a) * d).toFixed(0)}" y="${(cy + Math.sin(a) * d).toFixed(0)}" width="3" height="3"/>` }).join('')}</g>`)
291        break
292    }
293  }
294
295  // The brush under the pointer.
296  if (f.hover.length === 3) {
297    const [x = 0, y = 0, r = 0] = f.hover
298    out.push(`<circle cx="${x * T + 8}" cy="${y * T + 8}" r="${(r + 0.5) * T}" fill="#fff" fill-opacity=".12" stroke="#fff" stroke-opacity=".8" stroke-width="1.5" stroke-dasharray="4 3" shape-rendering="auto"/>`)
299  }
300
301  out.push('</svg>')
302  return out.join('')
303}
304
hooks/world.tsx 1726 lines
1import type { ClientKeyEvent, ClientModule, ClientPointerEvent, ClientSurface, JsonValue } from 'claude-code'
2import type { OmniCmd, OmniFrame, OmniInfo, OmniRealm } from '../types'
3
4/*
5 * OmniRealm: a god-simulator sandbox drawn in a Client region. The whole
6 * world lives here, in the instance's local state, and runs on the region's
7 * frame clock; the hooks module only opens the pane and keeps saves.
8 */
9
10// ---------------------------------------------------------------- world ---
11
12const W = 112
13const H = 72
14const TICK_MS = 120
15const TICKS_PER_YEAR = 48
16const MAX_UNITS = 520
17/** Room above the natural cap kept for what the god places by hand. */
18const GOD_RESERVE = 60
19const HOUSE_HP = 60
20/** What a drawing may serialize to, under the 100,000 a Client's tree is held to. */
21const TREE_BUDGET = 90_000
22
23const DEEP = 0, SHALLOW = 1, SAND = 2, GRASS = 3, FOREST = 4, HILLS = 5, MOUNTAIN = 6, SNOW = 7,
24  DESERT = 8, SWAMP = 9, ENCHANTED = 10, CORRUPTED = 11, LAVA = 12, ICE = 13, SCORCHED = 14
25
26const TERRAIN_NAME = ['Deep ocean', 'Shallows', 'Sand', 'Grassland', 'Forest', 'Hills', 'Mountains', 'Tundra',
27  'Desert', 'Swamp', 'Enchanted forest', 'Corrupted land', 'Lava', 'Ice', 'Scorched earth']
28const TERRAIN_BG = ['#1b3a6b', '#2f6fb3', '#d9c47c', '#5c9e45', '#2e6a2e', '#8c8a55', '#6b6763', '#e4ecf0',
29  '#e0b266', '#4b5a3a', '#5b3f8c', '#3d1f33', '#cf431b', '#a9d9ef', '#3b3431']
30const TERRAIN_GLYPH = [' ', ' ', ' ', ' ', '♣', '^', '▲', ' ', ' ', '~', '*', '%', '~', ' ', '.']
31const TERRAIN_FG = ['', '', '', '', '#4f9c3c', '#b4ae7b', '#a7a29d', '', '', '#708251', '#cda7ff', '#8f2f63', '#ffb320', '', '#6c6058']
32
33const isWater = (t: number) => t === DEEP || t === SHALLOW
34const FLAMMABLE: Record<number, number> = { [FOREST]: 0.22, [GRASS]: 0.07, [ENCHANTED]: 0.05, [SWAMP]: 0.015, [SNOW]: 0.01 }
35const BUILDABLE = new Set([GRASS, SAND, DESERT, SNOW, HILLS, FOREST, ENCHANTED, SCORCHED])
36
37// ---------------------------------------------------------------- kinds ---
38
39type Race = 'human' | 'elf' | 'orc' | 'dwarf'
40type Kind = Race | 'sheep' | 'chicken' | 'wolf' | 'bear' | 'dragon' | 'demon' | 'ufo' | 'zombie'
41
42type KindInfo = { ch: string; color: string; hp: number; dmg: number; arm: number; spd: number; life: number; fly?: boolean; label: string }
43
44const KINDS: Record<Kind, KindInfo> = {
45  human: { ch: 'h', color: '#f4c99b', hp: 30, dmg: 4, arm: 1, spd: 2, life: 70, label: 'Human' },
46  elf: { ch: 'e', color: '#9ff0b4', hp: 25, dmg: 4, arm: 0, spd: 3, life: 220, label: 'Elf' },
47  orc: { ch: 'o', color: '#8fd14f', hp: 40, dmg: 6, arm: 1, spd: 2, life: 55, label: 'Orc' },
48  dwarf: { ch: 'd', color: '#e0a060', hp: 40, dmg: 5, arm: 3, spd: 1, life: 160, label: 'Dwarf' },
49  sheep: { ch: 's', color: '#ffffff', hp: 10, dmg: 0, arm: 0, spd: 1, life: 14, label: 'Sheep' },
50  chicken: { ch: 'c', color: '#ffe680', hp: 5, dmg: 0, arm: 0, spd: 2, life: 8, label: 'Chicken' },
51  wolf: { ch: 'w', color: '#c8c8c8', hp: 22, dmg: 5, arm: 0, spd: 3, life: 15, label: 'Wolf' },
52  bear: { ch: 'B', color: '#a0703a', hp: 50, dmg: 9, arm: 2, spd: 2, life: 28, label: 'Bear' },
53  dragon: { ch: 'D', color: '#ff3b1f', hp: 320, dmg: 26, arm: 6, spd: 2, life: 9999, fly: true, label: 'Dragon' },
54  demon: { ch: 'X', color: '#ff2a7a', hp: 130, dmg: 15, arm: 3, spd: 2, life: 9999, label: 'Demon' },
55  ufo: { ch: 'U', color: '#7dffd8', hp: 160, dmg: 0, arm: 5, spd: 3, life: 9999, fly: true, label: 'UFO' },
56  zombie: { ch: 'z', color: '#7fbf3f', hp: 26, dmg: 5, arm: 0, spd: 1, life: 9999, label: 'Zombie' },
57}
58
59const RACES: Race[] = ['human', 'elf', 'orc', 'dwarf']
60const isRace = (k: Kind): k is Race => k === 'human' || k === 'elf' || k === 'orc' || k === 'dwarf'
61const isPrey = (k: Kind) => k === 'sheep' || k === 'chicken'
62const isMonster = (k: Kind) => k === 'wolf' || k === 'bear' || k === 'dragon' || k === 'demon' || k === 'ufo' || k === 'zombie'
63
64const TRAITS = ['genius', 'immune', 'bloodlust', 'giant', 'fast', 'strong', 'lucky', 'wise', 'fire_proof', 'regenerates',
65  'weak', 'slow', 'frail'] as const
66type Trait = (typeof TRAITS)[number]
67const GOOD_TRAITS: Trait[] = ['genius', 'immune', 'giant', 'fast', 'strong', 'lucky', 'wise', 'fire_proof', 'regenerates']
68const BAD_TRAITS: Trait[] = ['weak', 'slow', 'frail', 'bloodlust']
69
70const SYLLABLES: Record<Race, string[]> = {
71  human: ['al', 'ber', 'cor', 'dan', 'ed', 'fran', 'gar', 'hen', 'is', 'jo', 'ka', 'lo', 'mar', 'ric', 'sten', 'wil', 'ma', 'ra'],
72  elf: ['ae', 'lin', 'thal', 'ri', 'el', 'sil', 'van', 'nor', 'ith', 'ya', 'lor', 'iel', 'ga', 'la', 'dri'],
73  orc: ['gr', 'ug', 'mok', 'thar', 'zug', 'gor', 'krag', 'ush', 'bol', 'rok', 'naz', 'gul'],
74  dwarf: ['dur', 'bal', 'thor', 'grim', 'nar', 'dain', 'kil', 'bor', 'gim', 'rum', 'fun', 'od'],
75}
76const REALM_SUFFIX: Record<Race, string[]> = {
77  human: ['ia', 'land', 'shire', 'mark', 'ford'],
78  elf: ['ndor', 'lith', 'wen', 'rion', 'thas'],
79  orc: ['gash', 'mor', 'uk', 'nakh', 'zor'],
80  dwarf: ['heim', 'grund', 'dal', 'karak', 'hold'],
81}
82const KINGDOM_COLORS = ['#ff5a5f', '#4ea8ff', '#ffd23f', '#c77dff', '#ff9f1c', '#2ec4b6', '#f15bb5', '#a3e635', '#00e5ff', '#ffffff', '#ff7b54', '#9b8cff']
83const AGES = ['Stone Age', 'Bronze Age', 'Iron Age', 'Steel Age', 'Gunpowder Age', 'Arcane Age']
84
85type Unit = {
86  id: number; k: Kind; x: number; y: number
87  hp: number; max: number; dmg: number; arm: number; spd: number; age: number; life: number
88  traits: Trait[]; kd: number; name: string
89  sick: number; mad: number; tgt: number; tx: number; ty: number; mode: number; cd: number
90}
91
92type Kingdom = {
93  id: number; race: Race; name: string; color: string; cx: number; cy: number
94  wood: number; stone: number; food: number; tech: number; research: number
95  king: number; rel: Record<string, number>; war: number[]; weary: Record<string, number>
96  alive: boolean; founded: number; pop: number; houses: number
97}
98
99type Fx = { kind: 'rain' | 'flash' | 'boom' | 'tornado' | 'volcano' | 'heal' | 'plague' | 'magic' | 'nuke'; x: number; y: number; r: number; ttl: number }
100
101type Cat = 'terrain' | 'nature' | 'life' | 'destroy' | 'magic' | 'inspect'
102type View = 'map' | 'realms'
103
104type Region = { row: number; x0: number; x1: number; act: () => void }
105
106type Game = {
107  terrain: Uint8Array; fire: Uint8Array; house: Int16Array; hhp: Uint8Array; territory: Int16Array
108  units: Unit[]; byId: Map<number, Unit>; kingdoms: Kingdom[]; fx: Fx[]; log: string[]
109  nextId: number; tick: number; speed: number; paused: boolean
110  camX: number; camY: number; zoom: 1 | 2; cat: Cat; tool: string; brush: number
111  view: View; borders: boolean; selected: number; follow: boolean
112  hover: { x: number; y: number } | null; painting: boolean; lastPaint: number
113  panning: { x: number; y: number; camX: number; camY: number } | null
114  regions: Region[]; mapTop: number; mapRows: number; drawZoom: 1 | 2; detail: number; frames: number; loadedNonce: number; flash: string; flashTtl: number
115  head: Int32Array; nextIdx: Int32Array
116  /** Desktop: the map is the hooks module's picture, this region only its input. */
117  desktop: boolean; dvh: number; cmdSeq: number; availRows: number; cols: number; rows: number
118  outbox: { save?: boolean; load?: boolean }; lastFrame: string
119}
120
121// ------------------------------------------------------------- helpers ---
122
123const rnd = Math.random
124const ri = (n: number) => Math.floor(rnd() * n)
125const pick = <T,>(xs: readonly T[]): T => xs[ri(xs.length)] as T
126const inBounds = (x: number, y: number) => x >= 0 && y >= 0 && x < W && y < H
127const dist = (ax: number, ay: number, bx: number, by: number) => Math.max(Math.abs(ax - bx), Math.abs(ay - by))
128const cap = (s: string) => s.charAt(0).toUpperCase() + s.slice(1)
129const DIRS = [[1, 0], [-1, 0], [0, 1], [0, -1], [1, 1], [1, -1], [-1, 1], [-1, -1]] as const
130
131function mix(a: string, b: string, t: number): string {
132  const pa = parseInt(a.slice(1), 16), pb = parseInt(b.slice(1), 16)
133  const ch = (s: number) => Math.round(((pa >> s) & 255) * (1 - t) + ((pb >> s) & 255) * t)
134  return '#' + ((1 << 24) | (ch(16) << 16) | (ch(8) << 8) | ch(0)).toString(16).slice(1)
135}
136
137function nameFor(race: Race): string {
138  const s = SYLLABLES[race]
139  return cap(pick(s) + pick(s) + (rnd() < 0.3 ? pick(s) : ''))
140}
141
142function log(g: Game, text: string) {
143  g.log.push(`Y${year(g)} ${text}`)
144  if (g.log.length > 80) g.log.shift()
145}
146
147const year = (g: Game) => Math.floor(g.tick / TICKS_PER_YEAR)
148
149// ----------------------------------------------------------- generation ---
150
151function noise(scale: number): (x: number, y: number) => number {
152  const gw = Math.ceil(W / scale) + 2, gh = Math.ceil(H / scale) + 2
153  const lattice = Array.from({ length: gw * gh }, () => rnd())
154  const sm = (t: number) => t * t * (3 - 2 * t)
155  return (x, y) => {
156    const fx = x / scale, fy = y / scale
157    const x0 = Math.floor(fx), y0 = Math.floor(fy)
158    const tx = sm(fx - x0), ty = sm(fy - y0)
159    const v = (i: number, j: number) => lattice[j * gw + i] ?? 0
160    const a = v(x0, y0) * (1 - tx) + v(x0 + 1, y0) * tx
161    const b = v(x0, y0 + 1) * (1 - tx) + v(x0 + 1, y0 + 1) * tx
162    return a * (1 - ty) + b * ty
163  }
164}
165
166function fbm(scales: [number, number][]): (x: number, y: number) => number {
167  const layers = scales.map(([s, w]) => [noise(s), w] as const)
168  return (x, y) => layers.reduce((sum, [n, w]) => sum + n(x, y) * w, 0)
169}
170
171function generate(terrain: Uint8Array) {
172  const elev = fbm([[22, 0.5], [10, 0.3], [4, 0.2]])
173  const moist = fbm([[18, 0.6], [6, 0.4]])
174  const magic = noise(9)
175  for (let y = 0; y < H; y++) {
176    for (let x = 0; x < W; x++) {
177      const nx = x / W - 0.5, ny = y / H - 0.5
178      const edge = Math.sqrt(nx * nx * 1.1 + ny * ny * 1.4) * 0.55
179      const e = elev(x, y) - edge + 0.18
180      const temp = 1 - Math.abs(ny) * 2.1 + (moist(x + 50, y) - 0.5) * 0.3 - Math.max(0, e - 0.6) * 1.2
181      const m = moist(x, y)
182      let t: number
183      if (e < 0.3) t = DEEP
184      else if (e < 0.37) t = temp < 0.12 ? ICE : SHALLOW
185      else if (e < 0.4) t = temp < 0.2 ? SNOW : SAND
186      else if (e < 0.63) {
187        if (temp < 0.22) t = SNOW
188        else if (temp > 0.72 && m < 0.42) t = DESERT
189        else if (m > 0.66) t = temp > 0.55 ? SWAMP : FOREST
190        else if (m > 0.5) t = FOREST
191        else t = GRASS
192        if ((t === FOREST || t === GRASS) && magic(x, y) > 0.8) t = ENCHANTED
193      } else if (e < 0.72) t = HILLS
194      else if (e < 0.82) t = MOUNTAIN
195      else t = SNOW
196      terrain[y * W + x] = t
197    }
198  }
199}
200
201function newGame(): Game {
202  const g: Game = {
203    terrain: new Uint8Array(W * H), fire: new Uint8Array(W * H), house: new Int16Array(W * H),
204    hhp: new Uint8Array(W * H), territory: new Int16Array(W * H),
205    units: [], byId: new Map(), kingdoms: [], fx: [], log: [],
206    nextId: 1, tick: 0, speed: 1, paused: false,
207    camX: 0, camY: 0, zoom: 2, cat: 'life', tool: 'human', brush: 2,
208    view: 'map', borders: false, selected: -1, follow: false,
209    hover: null, painting: false, lastPaint: -1, panning: null,
210    regions: [], mapTop: 1, mapRows: 0, drawZoom: 2, detail: 3, frames: 0,
211    desktop: false, dvh: 0, cmdSeq: 0, availRows: 30, cols: 0, rows: 0, outbox: {}, lastFrame: '', loadedNonce: 0, flash: '', flashTtl: 0,
212    head: new Int32Array(W * H), nextIdx: new Int32Array(MAX_UNITS + 64),
213  }
214  generate(g.terrain)
215  seedLife(g)
216  // Open on the peoples, not an empty corner of ocean.
217  const first = g.units.find(u => isRace(u.k))
218  if (first !== undefined) { g.camX = first.x - 16; g.camY = first.y - 10 }
219  log(g, 'The world is born.')
220  return g
221}
222
223function randomLand(g: Game, ok: (t: number) => boolean = t => BUILDABLE.has(t) && t !== FOREST): [number, number] | null {
224  for (let i = 0; i < 400; i++) {
225    const x = 6 + ri(W - 12), y = 6 + ri(H - 12)
226    if (ok(g.terrain[y * W + x] ?? DEEP)) return [x, y]
227  }
228  return null
229}
230
231function seedLife(g: Game) {
232  for (const race of RACES) {
233    const at = randomLand(g, t => t === GRASS || (race === 'dwarf' && t === HILLS) || (race === 'elf' && t === ENCHANTED))
234      ?? randomLand(g)
235    if (at === null) continue
236    for (let i = 0; i < 6; i++) spawnNear(g, race, at[0], at[1], 2)
237  }
238  for (let i = 0; i < 18; i++) {
239    const at = randomLand(g, t => t === GRASS)
240    if (at !== null) spawnNear(g, rnd() < 0.7 ? 'sheep' : 'chicken', at[0], at[1], 1)
241  }
242  for (let i = 0; i < 4; i++) {
243    const at = randomLand(g, t => t === FOREST || t === SNOW)
244    if (at !== null) spawnNear(g, rnd() < 0.7 ? 'wolf' : 'bear', at[0], at[1], 1)
245  }
246}
247
248// -------------------------------------------------------------- units ---
249
250function applyTraits(u: Unit) {
251  const base = KINDS[u.k]
252  const has = (t: Trait) => u.traits.includes(t)
253  let max = base.hp, dmg = base.dmg, arm = base.arm, spd = base.spd
254  if (has('giant')) { max *= 1.6; dmg += 2 }
255  if (has('strong')) dmg += 3
256  if (has('weak')) dmg = Math.max(1, dmg - 2)
257  if (has('frail')) max *= 0.7
258  if (has('fast')) spd += 1
259  if (has('slow')) spd -= 1
260  const ratio = u.max > 0 ? u.hp / u.max : 1
261  u.max = Math.round(max)
262  u.hp = Math.max(1, Math.round(u.max * ratio))
263  u.dmg = base.dmg === 0 ? 0 : dmg
264  u.arm = arm
265  u.spd = Math.max(1, Math.min(3, spd))
266}
267
268function walkable(g: Game, u: Unit, x: number, y: number): boolean {
269  if (!inBounds(x, y)) return false
270  if (KINDS[u.k].fly) return true
271  const t = g.terrain[y * W + x] ?? DEEP
272  if (isWater(t)) return false
273  if (t === LAVA) return u.k === 'demon'
274  if (t === MOUNTAIN) return u.k === 'dwarf' || u.k === 'demon' || u.k === 'bear'
275  return true
276}
277
278function spawn(g: Game, k: Kind, x: number, y: number, parent?: Unit, limit = MAX_UNITS): Unit | null {
279  if (g.units.length >= limit || !inBounds(x, y)) return null
280  const info = KINDS[k]
281  const u: Unit = {
282    id: g.nextId++, k, x, y, hp: info.hp, max: info.hp, dmg: info.dmg, arm: info.arm, spd: info.spd,
283    age: parent ? 0 : isRace(k) ? 16 + ri(14) : ri(3), life: Math.round(info.life * (0.8 + rnd() * 0.4)),
284    traits: [], kd: parent?.kd ?? -1, name: isRace(k) ? nameFor(k) : info.label,
285    sick: 0, mad: 0, tgt: -1, tx: -1, ty: -1, mode: 0, cd: ri(3),
286  }
287  if (isRace(k)) {
288    if (k === 'orc') u.traits.push('regenerates')
289    for (const t of parent?.traits ?? []) if (rnd() < 0.5 && !u.traits.includes(t)) u.traits.push(t)
290    if (rnd() < 0.35) {
291      const t = rnd() < 0.75 ? pick(GOOD_TRAITS) : pick(BAD_TRAITS)
292      if (!u.traits.includes(t)) u.traits.push(t)
293    }
294  }
295  if (k === 'demon') u.traits.push('fire_proof')
296  if (u.traits.includes('wise')) u.life = Math.round(u.life * 1.5)
297  applyTraits(u)
298  u.hp = u.max
299  if (!walkable(g, u, x, y)) return null
300  g.units.push(u)
301  g.byId.set(u.id, u)
302  return u
303}
304
305function spawnNear(g: Game, k: Kind, x: number, y: number, r: number, limit = MAX_UNITS): Unit | null {
306  for (let i = 0; i < 12; i++) {
307    const u = spawn(g, k, x + ri(r * 2 + 1) - r, y + ri(r * 2 + 1) - r, undefined, limit)
308    if (u !== null) return u
309  }
310  return null
311}
312
313function rebuildIndex(g: Game) {
314  g.head.fill(-1)
315  if (g.nextIdx.length < g.units.length) g.nextIdx = new Int32Array(g.units.length + 64)
316  g.byId.clear()
317  g.units.forEach((u, i) => {
318    const c = u.y * W + u.x
319    g.nextIdx[i] = g.head[c] ?? -1
320    g.head[c] = i
321    g.byId.set(u.id, u)
322  })
323}
324
325function nearest(g: Game, u: Unit, r: number, ok: (v: Unit) => boolean): Unit | null {
326  let best: Unit | null = null, bd = 1e9
327  for (let y = Math.max(0, u.y - r); y <= Math.min(H - 1, u.y + r); y++) {
328    for (let x = Math.max(0, u.x - r); x <= Math.min(W - 1, u.x + r); x++) {
329      for (let i = g.head[y * W + x] ?? -1; i >= 0; i = g.nextIdx[i] ?? -1) {
330        const v = g.units[i]
331        if (v === undefined || v === u || v.hp <= 0 || !ok(v)) continue
332        const d = dist(u.x, u.y, v.x, v.y)
333        if (d < bd) { bd = d; best = v }
334      }
335    }
336  }
337  return best
338}
339
340function unitsIn(g: Game, x: number, y: number, r: number): Unit[] {
341  return g.units.filter(u => u.hp > 0 && (u.x - x) ** 2 + (u.y - y) ** 2 <= r * r + r)
342}
343
344function stepToward(g: Game, u: Unit, tx: number, ty: number, away = false) {
345  let dx = Math.sign(tx - u.x), dy = Math.sign(ty - u.y)
346  if (away) { dx = -dx; dy = -dy }
347  const tries: (readonly [number, number])[] = [[dx, dy], [dx, 0], [0, dy]]
348  for (let i = 0; i < 3; i++) tries.push(pick(DIRS))
349  for (const [ax, ay] of tries) {
350    if ((ax !== 0 || ay !== 0) && walkable(g, u, u.x + ax, u.y + ay)) { u.x += ax; u.y += ay; return }
351  }
352}
353
354function wander(g: Game, u: Unit) {
355  if (rnd() < 0.6) {
356    const [dx, dy] = pick(DIRS)
357    if (walkable(g, u, u.x + dx, u.y + dy)) { u.x += dx; u.y += dy }
358  }
359}
360
361function hurt(g: Game, v: Unit, amount: number, by?: Unit) {
362  if (v.traits.includes('lucky') && rnd() < 0.25) return
363  v.hp -= Math.max(1, amount - v.arm)
364  if (v.hp <= 0 && by?.k === 'zombie' && isRace(v.k) && !v.traits.includes('immune')) {
365    // Bitten to death: rises again as one of them.
366    zombify(v)
367  }
368}
369
370function zombify(v: Unit) {
371  v.k = 'zombie'; v.kd = -1; v.traits = v.traits.filter(t => t !== 'regenerates'); v.mad = 0; v.sick = 0
372  v.max = 0; applyTraits(v); v.hp = v.max
373}
374
375function strikeOrApproach(g: Game, u: Unit, v: Unit) {
376  if (dist(u.x, u.y, v.x, v.y) <= 1) {
377    const k = u.kd >= 0 ? g.kingdoms[u.kd] : undefined
378    hurt(g, v, u.dmg + (k?.tech ?? 0), u)
379  } else stepToward(g, u, v.x, v.y)
380}
381
382const atWar = (g: Game, a: number, b: number) => a >= 0 && b >= 0 && a !== b && (g.kingdoms[a]?.war.includes(b) ?? false)
383
384function hostile(g: Game, u: Unit, v: Unit): boolean {
385  if (u.mad > 0) return true
386  if (isMonster(v.k) && v.k !== 'ufo') return true
387  if (isRace(v.k) && v.mad > 0) return true
388  return isRace(v.k) && atWar(g, u.kd, v.kd)
389}
390
391function canAct(g: Game, u: Unit): boolean {
392  if (u.cd > 0) { u.cd--; return false }
393  u.cd = 3 - u.spd
394  return true
395}
396
397function act(g: Game, u: Unit) {
398  const i = u.y * W + u.x
399  const t = g.terrain[i] ?? DEEP
400  const fly = KINDS[u.k].fly === true
401  const fireProof = u.traits.includes('fire_proof')
402  if (!fly && (isWater(t) || (t === LAVA && u.k !== 'demon'))) {
403    u.hp -= t === LAVA ? 12 : 3
404    for (const [dx, dy] of DIRS) if (walkable(g, u, u.x + dx, u.y + dy)) { u.x += dx; u.y += dy; break }
405  }
406  if ((g.fire[i] ?? 0) > 0 && !fireProof) u.hp -= 3
407  if (u.traits.includes('regenerates') && u.hp < u.max) u.hp = Math.min(u.max, u.hp + 0.12)
408  if (t === ENCHANTED && u.hp < u.max) u.hp = Math.min(u.max, u.hp + 0.2)
409  if (u.mad > 0) u.mad--
410  if (u.sick > 0) {
411    u.sick++
412    u.hp -= 0.18
413    if (rnd() < 0.08) {
414      const v = nearest(g, u, 1, w => w.sick === 0 && (isRace(w.k) || isPrey(w.k)) && !w.traits.includes('immune'))
415      if (v !== null) v.sick = 1
416    }
417    if (u.sick > 160 && rnd() < 0.02) u.sick = 0
418  }
419  if (!canAct(g, u)) return
420
421  switch (u.k) {
422    case 'human': case 'elf': case 'orc': case 'dwarf': return civAct(g, u)
423    case 'sheep': case 'chicken': {
424      const threat = nearest(g, u, 3, v => v.k === 'wolf' || v.k === 'bear' || v.k === 'zombie')
425      if (threat !== null) return stepToward(g, u, threat.x, threat.y, true)
426      return wander(g, u)
427    }
428    case 'wolf': case 'bear': {
429      if (u.tgt < 0 || (g.tick + u.id) % 5 === 0) {
430        const prey = nearest(g, u, 7, v => isPrey(v.k) || (isRace(v.k) && (u.k === 'bear' || rnd() < 0.3)))
431        u.tgt = prey?.id ?? -1
432      }
433      const prey = g.byId.get(u.tgt)
434      if (prey !== undefined && prey.hp > 0) return strikeOrApproach(g, u, prey)
435      u.tgt = -1
436      return wander(g, u)
437    }
438    case 'zombie': {
439      if (u.tgt < 0 || (g.tick + u.id) % 4 === 0) u.tgt = nearest(g, u, 8, v => v.k !== 'zombie' && !isMonster(v.k))?.id ?? -1
440      const v = g.byId.get(u.tgt)
441      if (v !== undefined && v.hp > 0) return strikeOrApproach(g, u, v)
442      return wander(g, u)
443    }
444    case 'dragon': {
445      if ((g.tick + u.id) % 26 === 0) {
446        const v = nearest(g, u, 6, w => isRace(w.k) || isPrey(w.k))
447        if (v !== null) breathe(g, v.x, v.y)
448      }
449      const v = nearest(g, u, 2, w => isRace(w.k))
450      if (v !== null && rnd() < 0.5) return strikeOrApproach(g, u, v)
451      if (u.tx < 0 || dist(u.x, u.y, u.tx, u.ty) < 2) { u.tx = ri(W); u.ty = ri(H) }
452      stepToward(g, u, u.tx, u.ty)
453      return
454    }
455    case 'demon': {
456      if (rnd() < 0.05) ignite(g, u.x + ri(3) - 1, u.y + ri(3) - 1)
457      if (u.tgt < 0 || (g.tick + u.id) % 6 === 0) u.tgt = nearest(g, u, 7, v => isRace(v.k))?.id ?? -1
458      const v = g.byId.get(u.tgt)
459      if (v !== undefined && v.hp > 0) return strikeOrApproach(g, u, v)
460      return wander(g, u)
461    }
462    case 'ufo': {
463      u.age += 1 / TICKS_PER_YEAR
464      if (u.age > 14) { u.hp = 0; log(g, 'The UFO leaves for the stars.'); return }
465      if ((g.tick + u.id) % 30 === 0) {
466        const v = nearest(g, u, 2, w => w.k !== 'ufo' && w.k !== 'dragon')
467        if (v !== null) {
468          v.hp = -999
469          g.fx.push({ kind: 'magic', x: v.x, y: v.y, r: 0, ttl: 4 })
470          if (isRace(v.k)) log(g, `${v.name} the ${KINDS[v.k].label} was abducted by a UFO!`)
471        }
472      }
473      if (u.tx < 0 || dist(u.x, u.y, u.tx, u.ty) < 2) { u.tx = ri(W); u.ty = ri(H) }
474      stepToward(g, u, u.tx, u.ty)
475      return
476    }
477  }
478}
479
480function civAct(g: Game, u: Unit) {
481  if (u.mad > 0) {
482    const v = nearest(g, u, 6, () => true)
483    return v !== null ? strikeOrApproach(g, u, v) : wander(g, u)
484  }
485  if (u.kd < 0 || !(g.kingdoms[u.kd]?.alive ?? false)) {
486    u.kd = -1
487    joinOrFound(g, u)
488    if (u.kd < 0) return wander(g, u)
489  }
490  const k = g.kingdoms[u.kd] as Kingdom
491  const warrior = u.traits.includes('bloodlust') || u.id % (u.k === 'orc' ? 2 : 3) === 0
492  if (u.tgt < 0 || (g.tick + u.id) % 4 === 0) {
493    u.tgt = nearest(g, u, warrior ? 9 : 4, v => hostile(g, u, v))?.id ?? -1
494  }
495  const enemy = g.byId.get(u.tgt)
496  if (enemy !== undefined && enemy.hp > 0) {
497    if (warrior || dist(u.x, u.y, enemy.x, enemy.y) <= 1 || enemy.k === 'sheep') return strikeOrApproach(g, u, enemy)
498    return stepToward(g, u, enemy.x, enemy.y, true)
499  }
500  u.tgt = -1
501  if (warrior && k.war.length > 0) {
502    if (u.mode !== 1 || (g.tick + u.id) % 8 === 0) {
503      const h = nearestHouse(g, u.x, u.y, 14, kd => atWar(g, u.kd, kd))
504      if (h !== null) { u.mode = 1; u.tx = h[0]; u.ty = h[1] } else if (u.mode === 1) { u.mode = 0; u.tx = -1 }
505    }
506    if (u.mode === 1) {
507      const hi = u.ty * W + u.tx
508      if ((g.house[hi] ?? 0) === 0) { u.mode = 0; u.tx = -1; return }
509      if (dist(u.x, u.y, u.tx, u.ty) <= 1) {
510        damageHouse(g, hi, u.dmg + k.tech)
511        if (rnd() < 0.04) ignite(g, u.tx, u.ty)
512      } else stepToward(g, u, u.tx, u.ty)
513      return
514    }
515  }
516  u.mode = 0
517  if (u.tx < 0 || dist(u.x, u.y, u.tx, u.ty) === 0) {
518    if (u.tx >= 0) work(g, u, k)
519    const home = randomHouse(g, k)
520    const tx = home[0] + ri(13) - 6, ty = home[1] + ri(13) - 6
521    if (walkable(g, u, tx, ty)) { u.tx = tx; u.ty = ty } else u.tx = -1
522    return
523  }
524  stepToward(g, u, u.tx, u.ty)
525  if (rnd() < 0.05) u.tx = -1
526}
527
528function work(g: Game, u: Unit, k: Kingdom) {
529  const i = u.y * W + u.x
530  const t = g.terrain[i] ?? GRASS
531  if (t === FOREST || t === ENCHANTED) {
532    k.wood += u.k === 'elf' ? 2 : 1
533    if (t === FOREST && u.k !== 'elf' && rnd() < 0.08) g.terrain[i] = GRASS
534  } else if (t === HILLS || t === MOUNTAIN) {
535    k.stone += u.k === 'dwarf' ? 3 : 1
536  } else if (t === GRASS || t === SWAMP) {
537    k.food += 2
538  } else if (t === SAND || t === DESERT || t === SNOW || t === SCORCHED) {
539    if (rnd() < 0.5) k.food += 1
540  }
541  if (u.traits.includes('genius')) k.research += 1
542}
543
544function joinOrFound(g: Game, u: Unit) {
545  const race = u.k as Race
546  let best: Kingdom | null = null, bd = 24
547  for (const k of g.kingdoms) {
548    if (!k.alive || k.race !== race) continue
549    const d = dist(u.x, u.y, k.cx, k.cy)
550    if (d < bd) { bd = d; best = k }
551  }
552  if (best !== null) { u.kd = best.id; return }
553  const i = u.y * W + u.x
554  const t = g.terrain[i] ?? DEEP
555  if (!BUILDABLE.has(t) || (g.house[i] ?? 0) !== 0) return
556  const k = foundKingdom(g, race, u.x, u.y)
557  u.kd = k.id
558  k.king = u.id
559  placeHouse(g, k, u.x, u.y)
560  log(g, `${u.name} founds the ${KINDS[race].label.toLowerCase()} kingdom of ${k.name}.`)
561}
562
563function foundKingdom(g: Game, race: Race, x: number, y: number): Kingdom {
564  const used = new Set(g.kingdoms.filter(k => k.alive).map(k => k.color))
565  const color = KINGDOM_COLORS.find(c => !used.has(c)) ?? pick(KINGDOM_COLORS)
566  const k: Kingdom = {
567    id: g.kingdoms.length, race, name: nameFor(race).slice(0, 6) + pick(REALM_SUFFIX[race]), color, cx: x, cy: y,
568    wood: 6, stone: 0, food: 8, tech: 0, research: 0, king: -1, rel: {}, war: [], weary: {},
569    alive: true, founded: g.tick, pop: 1, houses: 0,
570  }
571  for (const o of g.kingdoms) {
572    if (!o.alive) continue
573    const r = affinity(race, o.race) + ri(21) - 10
574    k.rel[o.id] = r
575    o.rel[k.id] = r
576  }
577  g.kingdoms.push(k)
578  return k
579}
580
581function affinity(a: Race, b: Race): number {
582  if (a === b) return 30
583  const pair = [a, b].sort().join('-')
584  const table: Record<string, number> = {
585    'dwarf-human': 15, 'elf-human': 10, 'dwarf-elf': -15, 'elf-orc': -55, 'human-orc': -35, 'dwarf-orc': -40,
586  }
587  return table[pair] ?? 0
588}
589
590function placeHouse(g: Game, k: Kingdom, x: number, y: number) {
591  const i = y * W + x
592  if (g.terrain[i] === FOREST) g.terrain[i] = GRASS
593  g.house[i] = k.id + 1
594  g.hhp[i] = HOUSE_HP
595  g.fire[i] = 0
596}
597
598function damageHouse(g: Game, i: number, amount: number) {
599  const hp = (g.hhp[i] ?? 0) - amount
600  if (hp <= 0) { g.house[i] = 0; g.hhp[i] = 0 } else g.hhp[i] = hp
601}
602
603function nearestHouse(g: Game, x: number, y: number, r: number, ok: (kd: number) => boolean): [number, number] | null {
604  let best: [number, number] | null = null, bd = 1e9
605  for (let yy = Math.max(0, y - r); yy <= Math.min(H - 1, y + r); yy++) {
606    for (let xx = Math.max(0, x - r); xx <= Math.min(W - 1, x + r); xx++) {
607      const h = g.house[yy * W + xx] ?? 0
608      if (h === 0 || !ok(h - 1)) continue
609      const d = dist(x, y, xx, yy)
610      if (d < bd) { bd = d; best = [xx, yy] }
611    }
612  }
613  return best
614}
615
616function randomHouse(g: Game, k: Kingdom): [number, number] {
617  for (let n = 0; n < 6; n++) {
618    const x = k.cx + ri(17) - 8, y = k.cy + ri(17) - 8
619    if (inBounds(x, y) && g.house[y * W + x] === k.id + 1) return [x, y]
620  }
621  return [k.cx, k.cy]
622}
623
624// ---------------------------------------------------------- kingdoms ---
625
626function kingdomsQuarter(g: Game) {
627  const houses: [number, number][][] = g.kingdoms.map(() => [])
628  g.territory.fill(0)
629  for (let i = 0; i < W * H; i++) {
630    const h = g.house[i] ?? 0
631    if (h === 0) continue
632    houses[h - 1]?.push([i % W, Math.floor(i / W)])
633  }
634  for (const k of g.kingdoms) k.pop = 0
635  for (const u of g.units) if (u.kd >= 0 && isRace(u.k)) { const k = g.kingdoms[u.kd]; if (k) k.pop++ }
636
637  for (const k of g.kingdoms) {
638    if (!k.alive) continue
639    const own = houses[k.id] ?? []
640    k.houses = own.length
641    for (const [x, y] of own) {
642      for (let dy = -2; dy <= 2; dy++) for (let dx = -2; dx <= 2; dx++) {
643        if (inBounds(x + dx, y + dy)) g.territory[(y + dy) * W + x + dx] = k.id + 1
644      }
645    }
646    if (k.pop === 0 && k.houses === 0) {
647      k.alive = false
648      log(g, `The kingdom of ${k.name} has fallen.`)
649      for (const o of g.kingdoms) o.war = o.war.filter(id => id !== k.id)
650      continue
651    }
652    if (k.pop === 0) {
653      // Empty houses crumble.
654      for (const [x, y] of own) if (rnd() < 0.2) damageHouse(g, y * W + x, 30)
655      continue
656    }
657    if (g.house[k.cy * W + k.cx] !== k.id + 1 && own.length > 0) {
658      const [x, y] = pick(own)
659      k.cx = x; k.cy = y
660    }
661    // Build.
662    if (k.wood >= 4 && k.houses * 3 < k.pop + 3) {
663      for (let n = 0; n < 12; n++) {
664        const [bx, by] = own.length > 0 ? pick(own) : [k.cx, k.cy]
665        const x = bx + ri(7) - 3, y = by + ri(7) - 3
666        const i = y * W + x
667        const t = g.terrain[i] ?? DEEP
668        if (!inBounds(x, y) || (g.house[i] ?? 0) !== 0 || (g.fire[i] ?? 0) > 0) continue
669        if (!(BUILDABLE.has(t) || (k.race === 'dwarf' && t === MOUNTAIN))) continue
670        if (own.length === 0) { k.cx = x; k.cy = y }
671        placeHouse(g, k, x, y)
672        k.wood -= 4
673        k.houses++
674        break
675      }
676    }
677    // Births.
678    const members = g.units.filter(u => u.kd === k.id && isRace(u.k))
679    if (k.food >= 6 && k.pop < Math.max(2, k.houses * 4) && own.length > 0) {
680      const parent = pick(members)
681      const [x, y] = pick(own)
682      const child = spawnNear(g, k.race, x, y, 1)
683      if (child !== null) {
684        child.kd = k.id
685        child.age = 0
686        for (const t of parent?.traits ?? []) if (rnd() < 0.5 && !child.traits.includes(t)) child.traits.push(t)
687        applyTraits(child)
688        k.food -= 6
689      }
690    }
691    // Research.
692    if (k.stone >= 10) { k.stone -= 10; k.research += 4 }
693    k.research += 1
694    if (k.research >= 60 * (k.tech + 1) && k.tech < AGES.length - 1) {
695      k.research = 0
696      k.tech++
697      log(g, `${k.name} enters the ${AGES[k.tech]}.`)
698    }
699    // A crown for the strongest when the throne is empty.
700    const king = g.byId.get(k.king)
701    if (king === undefined || king.hp <= 0 || king.kd !== k.id) {
702      const heir = members.filter(u => u.age >= 16).sort((a, b) => b.max + b.dmg * 5 - (a.max + a.dmg * 5))[0]
703      if (heir !== undefined) {
704        k.king = heir.id
705        log(g, `${heir.name} is crowned ruler of ${k.name}.`)
706      } else k.king = -1
707    }
708  }
709}
710
711function kingdomsYear(g: Game) {
712  const alive = g.kingdoms.filter(k => k.alive)
713  for (const a of alive) {
714    for (const b of alive) {
715      if (b.id <= a.id) continue
716      let r = a.rel[b.id] ?? affinity(a.race, b.race)
717      r += (affinity(a.race, b.race) - r) * 0.05 + ri(13) - 6
718      if (dist(a.cx, a.cy, b.cx, b.cy) < 28) r -= 4
719      if (atWar(g, a.id, b.id)) {
720        const w = (a.weary[b.id] ?? 0) + 1
721        a.weary[b.id] = w
722        if (w >= 4 && rnd() < 0.3) {
723          makePeace(g, a, b)
724          r = Math.max(r, -10) + 25
725        }
726      } else if (r < -40 && rnd() < 0.35 && year(g) - Math.floor(Math.max(a.founded, b.founded) / TICKS_PER_YEAR) > 2) {
727        declareWar(g, a, b)
728      }
729      r = Math.max(-100, Math.min(100, r))
730      a.rel[b.id] = r
731      b.rel[a.id] = r
732    }
733  }
734  // Rebellion: a sprawling realm, or one without a ruler, may split.
735  for (const k of alive) {
736    const kingless = !g.byId.has(k.king)
737    if (!(k.houses >= 16 && rnd() < 0.12) && !(kingless && k.houses >= 8 && rnd() < 0.25)) continue
738    let far: [number, number] | null = null, fd = 10
739    for (let i = 0; i < W * H; i++) {
740      if (g.house[i] !== k.id + 1) continue
741      const x = i % W, y = Math.floor(i / W), d = dist(x, y, k.cx, k.cy)
742      if (d > fd) { fd = d; far = [x, y] }
743    }
744    if (far === null) continue
745    const rebel = foundKingdom(g, k.race, far[0], far[1])
746    for (let i = 0; i < W * H; i++) {
747      if (g.house[i] === k.id + 1 && dist(i % W, Math.floor(i / W), far[0], far[1]) <= 7) g.house[i] = rebel.id + 1
748    }
749    for (const u of g.units) if (u.kd === k.id && dist(u.x, u.y, far[0], far[1]) <= 8 && u.id !== k.king) u.kd = rebel.id
750    rebel.wood = Math.floor(k.wood / 3)
751    rebel.tech = k.tech
752    rebel.rel[k.id] = -100
753    k.rel[rebel.id] = -100
754    log(g, `Rebellion! ${rebel.name} breaks away from ${k.name}.`)
755    declareWar(g, rebel, k, true)
756  }
757  // Ageing.
758  for (const u of g.units) {
759    if (u.k === 'ufo') continue
760    u.age++
761    if (u.age > u.life) {
762      u.hp = 0
763      if (isRace(u.k) && g.kingdoms[u.kd]?.king === u.id) log(g, `${u.name} of ${g.kingdoms[u.kd]?.name} dies of old age.`)
764    }
765  }
766  // Wildlife.
767  const count = (k: Kind) => g.units.filter(u => u.k === k).length
768  const sheep = count('sheep') + count('chicken')
769  for (const u of [...g.units]) {
770    if (isPrey(u.k) && sheep < 90 && rnd() < 0.35 && g.terrain[u.y * W + u.x] === GRASS) spawnNear(g, u.k, u.x, u.y, 1)
771    else if (u.k === 'wolf' && count('wolf') < 24 && sheep > 10 && rnd() < 0.15) spawnNear(g, 'wolf', u.x, u.y, 1)
772    else if (u.k === 'bear' && count('bear') < 10 && rnd() < 0.05) spawnNear(g, 'bear', u.x, u.y, 1)
773  }
774}
775
776function declareWar(g: Game, a: Kingdom, b: Kingdom, quiet = false) {
777  if (a.war.includes(b.id)) return
778  a.war.push(b.id); b.war.push(a.id)
779  a.weary[b.id] = 0; b.weary[a.id] = 0
780  if (!quiet) log(g, `War! ${a.name} declares war on ${b.name}.`)
781  for (const c of g.kingdoms) {
782    if (!c.alive || c.id === a.id || c.id === b.id || c.war.includes(a.id)) continue
783    if ((c.rel[b.id] ?? 0) > 55) {
784      c.war.push(a.id); a.war.push(c.id)
785      log(g, `${c.name} joins the war at the side of its ally ${b.name}.`)
786    }
787  }
788}
789
790function makePeace(g: Game, a: Kingdom, b: Kingdom) {
791  a.war = a.war.filter(id => id !== b.id)
792  b.war = b.war.filter(id => id !== a.id)
793  log(g, `${a.name} and ${b.name} make peace.`)
794}
795
796// -------------------------------------------------------------- tiles ---
797
798function ignite(g: Game, x: number, y: number) {
799  if (!inBounds(x, y)) return
800  const i = y * W + x
801  const t = g.terrain[i] ?? DEEP
802  if (isWater(t) || t === ICE) return
803  g.fire[i] = 10 + ri(10)
804}
805
806function breathe(g: Game, x: number, y: number) {
807  for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) if (rnd() < 0.8) ignite(g, x + dx, y + dy)
808  for (const v of unitsIn(g, x, y, 1)) if (v.k !== 'dragon' && !v.traits.includes('fire_proof')) hurt(g, v, 18)
809  g.fx.push({ kind: 'boom', x, y, r: 1, ttl: 3 })
810}
811
812function tiles(g: Game) {
813  // Fire burns down, spreads, and leaves scorched earth.
814  for (let i = 0; i < W * H; i++) {
815    const f = g.fire[i] ?? 0
816    if (f === 0) continue
817    const x = i % W, y = Math.floor(i / W)
818    const t = g.terrain[i] ?? DEEP
819    if (isWater(t) || t === ICE) { g.fire[i] = 0; continue }
820    if ((g.house[i] ?? 0) !== 0) damageHouse(g, i, 6)
821    if (rnd() < 0.5) {
822      const [dx, dy] = pick(DIRS)
823      const j = (y + dy) * W + x + dx
824      if (inBounds(x + dx, y + dy) && (g.fire[j] ?? 0) === 0) {
825        const nt = g.terrain[j] ?? DEEP
826        const chance = (g.house[j] ?? 0) !== 0 ? 0.3 : FLAMMABLE[nt] ?? 0
827        if (rnd() < chance * 3) ignite(g, x + dx, y + dy)
828      }
829    }
830    g.fire[i] = f - 1
831    if (f === 1 && (t === FOREST || t === GRASS || t === ENCHANTED)) g.terrain[i] = SCORCHED
832  }
833  // A few random tiles change on their own: regrowth, lava, corruption.
834  for (let n = 0; n < 500; n++) {
835    const x = ri(W), y = ri(H), i = y * W + x
836    const t = g.terrain[i] ?? DEEP
837    const [dx, dy] = pick(DIRS)
838    const j = (y + dy) * W + x + dx
839    const nt = inBounds(x + dx, y + dy) ? g.terrain[j] ?? DEEP : DEEP
840    if (t === SCORCHED && (g.fire[i] ?? 0) === 0 && rnd() < 0.03) g.terrain[i] = GRASS
841    else if (t === GRASS && nt === FOREST && rnd() < 0.04 && (g.house[i] ?? 0) === 0) g.terrain[i] = FOREST
842    else if (t === LAVA) {
843      if (isWater(nt) || nt === ICE || nt === SNOW) { g.terrain[i] = MOUNTAIN; if (nt !== SNOW) g.terrain[j] = SHALLOW }
844      else if (rnd() < 0.25) ignite(g, x + dx, y + dy)
845      if (rnd() < 0.02) g.terrain[i] = MOUNTAIN
846    } else if (t === CORRUPTED) {
847      if (!isWater(nt) && nt !== CORRUPTED && nt !== MOUNTAIN && rnd() < 0.05) g.terrain[j] = CORRUPTED
848      if (rnd() < 0.002 && g.units.filter(u => u.k === 'demon').length < 6) {
849        const d = spawn(g, 'demon', x, y)
850        if (d !== null) log(g, 'A demon claws its way out of the corrupted land.')
851      }
852    } else if (t === ICE && nt === LAVA) g.terrain[i] = SHALLOW
853  }
854}
855
856function fxStep(g: Game) {
857  for (const f of g.fx) {
858    f.ttl--
859    if (f.kind === 'rain') {
860      for (let n = 0; n < 6; n++) {
861        const x = f.x + ri(f.r * 2 + 1) - f.r, y = f.y + ri(f.r * 2 + 1) - f.r
862        if (!inBounds(x, y)) continue
863        const i = y * W + x
864        g.fire[i] = 0
865        const t = g.terrain[i]
866        if ((t === SCORCHED || t === DESERT) && rnd() < 0.3) g.terrain[i] = GRASS
867        else if (t === GRASS && rnd() < 0.08) g.terrain[i] = FOREST
868        else if (t === LAVA && rnd() < 0.3) g.terrain[i] = MOUNTAIN
869      }
870    } else if (f.kind === 'tornado') {
871      const [dx, dy] = pick(DIRS)
872      f.x = Math.max(0, Math.min(W - 1, f.x + dx)); f.y = Math.max(0, Math.min(H - 1, f.y + dy))
873      for (let yy = f.y - 1; yy <= f.y + 1; yy++) for (let xx = f.x - 1; xx <= f.x + 1; xx++) {
874        if (!inBounds(xx, yy)) continue
875        const i = yy * W + xx
876        if ((g.house[i] ?? 0) !== 0) damageHouse(g, i, 25)
877        if (g.terrain[i] === FOREST && rnd() < 0.4) g.terrain[i] = GRASS
878        g.fire[i] = 0
879      }
880      for (const u of unitsIn(g, f.x, f.y, 1)) {
881        if (u.k === 'dragon' || u.k === 'ufo') continue
882        hurt(g, u, 6)
883        const nx = u.x + ri(7) - 3, ny = u.y + ri(7) - 3
884        if (inBounds(nx, ny)) { u.x = nx; u.y = ny }
885      }
886    } else if (f.kind === 'volcano') {
887      const r = 1 + ri(3)
888      const x = f.x + ri(r * 2 + 1) - r, y = f.y + ri(r * 2 + 1) - r
889      if (inBounds(x, y) && rnd() < 0.6) {
890        const i = y * W + x
891        g.terrain[i] = LAVA; g.house[i] = 0
892        for (const [dx, dy] of DIRS) if (rnd() < 0.3) ignite(g, x + dx, y + dy)
893      }
894    }
895  }
896  g.fx = g.fx.filter(f => f.ttl > 0)
897}
898
899function step(g: Game) {
900  g.tick++
901  rebuildIndex(g)
902  for (const u of g.units) if (u.hp > 0) act(g, u)
903  tiles(g)
904  fxStep(g)
905  if (g.tick % 12 === 0) kingdomsQuarter(g)
906  if (g.tick % TICKS_PER_YEAR === 0) kingdomsYear(g)
907  const dead = g.units.filter(u => u.hp <= 0)
908  if (dead.length > 0) {
909    for (const u of dead) {
910      const k = g.kingdoms[u.kd]
911      if (k?.king === u.id && u.age <= u.life) log(g, `${u.name}, ruler of ${k.name}, has been slain!`)
912      if (u.id === g.selected) { g.selected = -1; g.follow = false }
913    }
914    g.units = g.units.filter(u => u.hp > 0)
915  }
916}
917
918// -------------------------------------------------------------- tools ---
919
920type Tool = { id: string; label: string; cat: Cat; drag: boolean; apply: (g: Game, x: number, y: number, r: number) => void }
921
922function disc(x: number, y: number, r: number, fn: (x: number, y: number, i: number) => void) {
923  for (let dy = -r; dy <= r; dy++) for (let dx = -r; dx <= r; dx++) {
924    if (dx * dx + dy * dy > r * r + r || !inBounds(x + dx, y + dy)) continue
925    fn(x + dx, y + dy, (y + dy) * W + x + dx)
926  }
927}
928
929function paint(t: number) {
930  return (g: Game, x: number, y: number, r: number) => disc(x, y, r, (_x, _y, i) => {
931    g.terrain[i] = t
932    g.fire[i] = 0
933    if (isWater(t) || t === LAVA || t === MOUNTAIN) g.house[i] = 0
934  })
935}
936
937function heat(g: Game, x: number, y: number, r: number) {
938  disc(x, y, r, (xx, yy, i) => {
939    if (rnd() < 0.5) return
940    const t = g.terrain[i]
941    if (t === ICE) g.terrain[i] = SHALLOW
942    else if (t === SNOW) g.terrain[i] = GRASS
943    else if (t === SHALLOW && rnd() < 0.3) g.terrain[i] = SAND
944    else if (t === GRASS || t === FOREST || t === SWAMP) { g.terrain[i] = DESERT; if (rnd() < 0.3) ignite(g, xx, yy) }
945    else if ((t === SAND || t === DESERT || t === MOUNTAIN || t === HILLS || t === SCORCHED) && rnd() < 0.4) { g.terrain[i] = LAVA; g.house[i] = 0 }
946  })
947}
948
949function cool(g: Game, x: number, y: number, r: number) {
950  disc(x, y, r, (_x, _y, i) => {
951    if (rnd() < 0.5) return
952    g.fire[i] = 0
953    const t = g.terrain[i]
954    if (t === LAVA) g.terrain[i] = MOUNTAIN
955    else if (t === SHALLOW || (t === DEEP && rnd() < 0.3)) g.terrain[i] = ICE
956    else if (t === GRASS || t === FOREST || t === SWAMP || t === SCORCHED) g.terrain[i] = SNOW
957    else if (t === DESERT) g.terrain[i] = SAND
958  })
959}
960
961function explode(g: Game, x: number, y: number, r: number, power: number, kind: Fx['kind'] = 'boom') {
962  disc(x, y, r, (xx, yy, i) => {
963    const t = g.terrain[i] ?? DEEP
964    g.house[i] = 0
965    if (!isWater(t)) {
966      const core = (xx - x) ** 2 + (yy - y) ** 2 <= (r * 0.5) ** 2
967      g.terrain[i] = core && r >= 2 ? (t === MOUNTAIN || t === HILLS ? HILLS : r >= 6 ? SHALLOW : SCORCHED) : SCORCHED
968      if (rnd() < 0.5) ignite(g, xx, yy)
969    }
970  })
971  for (const u of unitsIn(g, x, y, r)) hurt(g, u, power)
972  g.fx.push({ kind, x, y, r, ttl: kind === 'nuke' ? 10 : 4 })
973}
974
975function spawnTool(k: Kind) {
976  return (g: Game, x: number, y: number, r: number) => {
977    const n = KINDS[k].hp >= 100 ? 1 : Math.max(1, r * 2)
978    let placed = 0
979    for (let i = 0; i < n; i++) {
980      // A dragon, demon or UFO lands right where it is dropped; a herd spreads over the brush.
981      const u = spawnNear(g, k, x, y, KINDS[k].hp >= 100 ? 0 : r, MAX_UNITS + GOD_RESERVE)
982      if (u === null) continue
983      placed++
984      if (KINDS[k].hp >= 100) log(g, `A ${KINDS[k].label.toLowerCase()} appears!`)
985    }
986    // Say why nothing appeared, rather than leave the click unanswered.
987    if (placed === 0) {
988      g.flash = g.units.length >= MAX_UNITS + GOD_RESERVE
989        ? `The world is full (${g.units.length} creatures): remove some life first.`
990        : `${KINDS[k].label} can't live there: try land${k === 'dwarf' ? ' or mountains' : ''}.`
991      g.flashTtl = 30
992    }
993  }
994}
995
996const TOOLS: Tool[] = [
997  { id: 'deep', label: 'Ocean', cat: 'terrain', drag: true, apply: paint(DEEP) },
998  { id: 'shallow', label: 'Shallows', cat: 'terrain', drag: true, apply: paint(SHALLOW) },
999  { id: 'sand', label: 'Sand', cat: 'terrain', drag: true, apply: paint(SAND) },
1000  { id: 'grass', label: 'Grass', cat: 'terrain', drag: true, apply: paint(GRASS) },
1001  { id: 'forest', label: 'Forest', cat: 'terrain', drag: true, apply: paint(FOREST) },
1002  { id: 'hills', label: 'Hills', cat: 'terrain', drag: true, apply: paint(HILLS) },
1003  { id: 'mountain', label: 'Mountain', cat: 'terrain', drag: true, apply: paint(MOUNTAIN) },
1004  { id: 'desert', label: 'Desert', cat: 'terrain', drag: true, apply: paint(DESERT) },
1005  { id: 'tundra', label: 'Tundra', cat: 'terrain', drag: true, apply: paint(SNOW) },
1006  { id: 'swamp', label: 'Swamp', cat: 'terrain', drag: true, apply: paint(SWAMP) },
1007  { id: 'enchanted', label: 'Enchanted', cat: 'terrain', drag: true, apply: paint(ENCHANTED) },
1008  { id: 'corrupt', label: 'Corrupted', cat: 'terrain', drag: true, apply: paint(CORRUPTED) },
1009  { id: 'heat', label: 'Heat', cat: 'terrain', drag: true, apply: heat },
1010  { id: 'cool', label: 'Cool', cat: 'terrain', drag: true, apply: cool },
1011
1012  { id: 'rain', label: 'Rain', cat: 'nature', drag: true, apply: (g, x, y, r) => g.fx.push({ kind: 'rain', x, y, r: r + 2, ttl: 24 }) },
1013  {
1014    id: 'lightning', label: 'Lightning', cat: 'nature', drag: false, apply: (g, x, y) => {
1015      for (const u of unitsIn(g, x, y, 1)) hurt(g, u, 45)
1016      ignite(g, x, y)
1017      if ((g.house[y * W + x] ?? 0) !== 0) damageHouse(g, y * W + x, 40)
1018      g.fx.push({ kind: 'flash', x, y, r: 0, ttl: 3 })
1019    },
1020  },
1021  { id: 'fire', label: 'Fire', cat: 'nature', drag: true, apply: (g, x, y, r) => disc(x, y, Math.max(0, r - 1), (xx, yy) => ignite(g, xx, yy)) },
1022  { id: 'tornado', label: 'Tornado', cat: 'nature', drag: false, apply: (g, x, y) => { g.fx.push({ kind: 'tornado', x, y, r: 1, ttl: 70 }); log(g, 'A tornado touches down.') } },
1023  {
1024    id: 'quake', label: 'Earthquake', cat: 'nature', drag: false, apply: (g, x, y) => {
1025      disc(x, y, 7, (_x, _y, i) => {
1026        if (rnd() < 0.5 && (g.house[i] ?? 0) !== 0) damageHouse(g, i, 40)
1027        if (rnd() > 0.25) return
1028        const t = g.terrain[i] ?? DEEP
1029        g.terrain[i] = rnd() < 0.15 && !isWater(t) ? SHALLOW : t === HILLS ? MOUNTAIN : isWater(t) ? t : HILLS
1030        if (isWater(g.terrain[i] ?? DEEP)) g.house[i] = 0
1031      })
1032      for (const u of unitsIn(g, x, y, 7)) hurt(g, u, 8)
1033      log(g, 'The earth shakes!')
1034    },
1035  },
1036  {
1037    id: 'volcano', label: 'Volcano', cat: 'nature', drag: false, apply: (g, x, y) => {
1038      disc(x, y, 2, (_x, _y, i) => { g.terrain[i] = MOUNTAIN; g.house[i] = 0 })
1039      g.terrain[y * W + x] = LAVA
1040      g.fx.push({ kind: 'volcano', x, y, r: 3, ttl: 60 })
1041      log(g, 'A volcano erupts!')
1042    },
1043  },
1044
1045  { id: 'human', label: 'Humans', cat: 'life', drag: true, apply: spawnTool('human') },
1046  { id: 'elf', label: 'Elves', cat: 'life', drag: true, apply: spawnTool('elf') },
1047  { id: 'orc', label: 'Orcs', cat: 'life', drag: true, apply: spawnTool('orc') },
1048  { id: 'dwarf', label: 'Dwarves', cat: 'life', drag: true, apply: spawnTool('dwarf') },
1049  { id: 'sheep', label: 'Sheep', cat: 'life', drag: true, apply: spawnTool('sheep') },
1050  { id: 'chicken', label: 'Chickens', cat: 'life', drag: true, apply: spawnTool('chicken') },
1051  { id: 'wolf', label: 'Wolves', cat: 'life', drag: true, apply: spawnTool('wolf') },
1052  { id: 'bear', label: 'Bears', cat: 'life', drag: true, apply: spawnTool('bear') },
1053  { id: 'dragon', label: 'Dragon', cat: 'life', drag: false, apply: spawnTool('dragon') },
1054  { id: 'demon', label: 'Demon', cat: 'life', drag: false, apply: spawnTool('demon') },
1055  { id: 'ufo', label: 'UFO', cat: 'life', drag: false, apply: spawnTool('ufo') },
1056
1057  { id: 'dynamite', label: 'Dynamite', cat: 'destroy', drag: true, apply: (g, x, y) => explode(g, x, y, 1, 30) },
1058  { id: 'bomb', label: 'Bomb', cat: 'destroy', drag: false, apply: (g, x, y) => explode(g, x, y, 3, 70) },
1059  { id: 'napalm', label: 'Napalm', cat: 'destroy', drag: false, apply: (g, x, y) => { disc(x, y, 5, (xx, yy) => { if (rnd() < 0.7) ignite(g, xx, yy) }); g.fx.push({ kind: 'boom', x, y, r: 5, ttl: 3 }) } },
1060  { id: 'atomic', label: 'Atomic', cat: 'destroy', drag: false, apply: (g, x, y) => { explode(g, x, y, 9, 999, 'nuke'); disc(x, y, 13, (xx, yy) => { if (rnd() < 0.15) ignite(g, xx, yy) }); log(g, 'An atomic bomb detonates!') } },
1061  {
1062    id: 'antimatter', label: 'Antimatter', cat: 'destroy', drag: false, apply: (g, x, y) => {
1063      disc(x, y, 6, (_x, _y, i) => { g.terrain[i] = DEEP; g.house[i] = 0; g.fire[i] = 0 })
1064      for (const u of unitsIn(g, x, y, 6)) u.hp = -999
1065      g.fx.push({ kind: 'magic', x, y, r: 6, ttl: 5 })
1066      log(g, 'Antimatter erases a piece of the world.')
1067    },
1068  },
1069
1070  { id: 'plague', label: 'Plague', cat: 'magic', drag: true, apply: (g, x, y, r) => { for (const u of unitsIn(g, x, y, r)) if (!u.traits.includes('immune') && !isMonster(u.k)) u.sick = 1; g.fx.push({ kind: 'plague', x, y, r, ttl: 6 }) } },
1071  {
1072    id: 'zombie', label: 'Zombies', cat: 'magic', drag: true, apply: (g, x, y, r) => {
1073      let turned = 0
1074      for (const u of unitsIn(g, x, y, r)) if (isRace(u.k) && !u.traits.includes('immune')) { zombify(u); turned++ }
1075      g.fx.push({ kind: 'plague', x, y, r, ttl: 6 })
1076      if (turned > 0 || spawnNear(g, 'zombie', x, y, r, MAX_UNITS + GOD_RESERVE) !== null) return
1077      g.flash = "Zombies can't rise there: try land, or people who aren't immune."
1078      g.flashTtl = 30
1079    },
1080  },
1081  { id: 'madness', label: 'Madness', cat: 'magic', drag: true, apply: (g, x, y, r) => { for (const u of unitsIn(g, x, y, r)) if (isRace(u.k)) u.mad = 120; g.fx.push({ kind: 'magic', x, y, r, ttl: 5 }) } },
1082  { id: 'heal', label: 'Healing', cat: 'magic', drag: true, apply: (g, x, y, r) => { for (const u of unitsIn(g, x, y, r)) { u.hp = u.max; u.sick = 0; u.mad = 0 } g.fx.push({ kind: 'heal', x, y, r, ttl: 6 }) } },
1083  { id: 'bless', label: 'Blessing', cat: 'magic', drag: false, apply: (g, x, y, r) => { for (const u of unitsIn(g, x, y, r)) addTrait(u, pick(GOOD_TRAITS)); g.fx.push({ kind: 'heal', x, y, r, ttl: 6 }) } },
1084  { id: 'curse', label: 'Curse', cat: 'magic', drag: false, apply: (g, x, y, r) => { for (const u of unitsIn(g, x, y, r)) addTrait(u, pick(BAD_TRAITS)); g.fx.push({ kind: 'magic', x, y, r, ttl: 6 }) } },
1085
1086  { id: 'inspect', label: 'Inspect', cat: 'inspect', drag: false, apply: (g, x, y) => inspect(g, x, y) },
1087  { id: 'erase', label: 'Remove life', cat: 'inspect', drag: true, apply: (g, x, y, r) => { for (const u of unitsIn(g, x, y, Math.max(0, r - 1))) u.hp = -999 } },
1088]
1089
1090const CATS: [Cat, string][] = [['terrain', 'Terrain'], ['nature', 'Nature'], ['life', 'Life'], ['destroy', 'Destroy'], ['magic', 'Magic'], ['inspect', 'Inspect']]
1091
1092function addTrait(u: Unit, t: Trait) {
1093  if (!isRace(u.k) || u.traits.includes(t)) return
1094  u.traits.push(t)
1095  if (t === 'wise') u.life = Math.round(u.life * 1.5)
1096  applyTraits(u)
1097}
1098
1099function removeTrait(u: Unit, t: Trait) {
1100  u.traits = u.traits.filter(x => x !== t)
1101  if (t === 'wise') u.life = Math.round(u.life / 1.5)
1102  applyTraits(u)
1103}
1104
1105function inspect(g: Game, x: number, y: number) {
1106  let best: Unit | null = null, bd = 3
1107  for (const u of g.units) {
1108    const d = dist(u.x, u.y, x, y)
1109    if (u.hp > 0 && d < bd) { bd = d; best = u }
1110  }
1111  g.selected = best?.id ?? -1
1112  if (best === null) {
1113    const h = g.house[y * W + x] ?? 0
1114    g.flash = h !== 0 ? `A house of ${g.kingdoms[h - 1]?.name ?? '?'} on ${TERRAIN_NAME[g.terrain[y * W + x] ?? 0]}` : TERRAIN_NAME[g.terrain[y * W + x] ?? 0] ?? ''
1115    g.flashTtl = 30
1116  }
1117}
1118
1119// --------------------------------------------------------------- saves ---
1120
1121type Save = { v: 1; tick: number; terrain: string; houses: number[]; units: (string | number)[][]; kingdoms: Kingdom[]; log: string[]; nextId: number }
1122
1123function toSave(g: Game): Save {
1124  const houses: number[] = []
1125  for (let i = 0; i < W * H; i++) if ((g.house[i] ?? 0) !== 0) houses.push(i, g.house[i] ?? 0, g.hhp[i] ?? 0)
1126  return {
1127    v: 1, tick: g.tick, nextId: g.nextId, log: g.log.slice(-30),
1128    terrain: String.fromCharCode(...Array.from(g.terrain, t => 65 + t)),
1129    houses,
1130    units: g.units.map(u => [u.id, u.k, u.x, u.y, Math.round(u.hp), u.age, u.life, u.kd, u.name,
1131      u.traits.reduce((m, t) => m | (1 << TRAITS.indexOf(t)), 0), u.sick, u.mad]),
1132    kingdoms: g.kingdoms,
1133  }
1134}
1135
1136function fromSave(g: Game, frozen: Save) {
1137  // Props arrive frozen; the world needs its own copy to live in.
1138  const s = JSON.parse(JSON.stringify(frozen)) as Save
1139  if (s.v !== 1 || s.terrain.length !== W * H) return
1140  for (let i = 0; i < W * H; i++) g.terrain[i] = s.terrain.charCodeAt(i) - 65
1141  g.house.fill(0); g.hhp.fill(0); g.fire.fill(0)
1142  for (let n = 0; n + 2 < s.houses.length; n += 3) { g.house[s.houses[n] ?? 0] = s.houses[n + 1] ?? 0; g.hhp[s.houses[n] ?? 0] = s.houses[n + 2] ?? 0 }
1143  g.kingdoms = s.kingdoms
1144  g.units = []
1145  for (const r of s.units) {
1146    const [id, k, x, y, hp, age, life, kd, name, bits, sick, mad] = r as [number, Kind, number, number, number, number, number, number, string, number, number, number]
1147    if (!(k in KINDS)) continue
1148    const u: Unit = { id, k, x, y, hp, max: 0, dmg: 0, arm: 0, spd: 1, age, life, kd, name, traits: TRAITS.filter((_t, b) => (bits & (1 << b)) !== 0), sick, mad, tgt: -1, tx: -1, ty: -1, mode: 0, cd: 0 }
1149    applyTraits(u)
1150    u.hp = Math.min(hp, u.max)
1151    g.units.push(u)
1152  }
1153  g.tick = s.tick; g.nextId = s.nextId; g.log = s.log; g.fx = []; g.selected = -1; g.follow = false
1154  rebuildIndex(g)
1155  kingdomsQuarter(g)
1156  log(g, 'The world is restored from a save.')
1157}
1158
1159// -------------------------------------------------------------- input ---
1160
1161const toolOf = (g: Game) => TOOLS.find(t => t.id === g.tool) ?? (TOOLS[0] as Tool)
1162
1163function tileAt(g: Game, px: number, py: number): [number, number] | null {
1164  if (g.desktop) {
1165    if (g.cols <= 0 || g.rows <= 0 || px < 0 || py < 0 || px >= g.cols || py >= g.rows) return null
1166    const x = g.camX + Math.floor(((px + 0.5) * desktopVw(g)) / g.cols), y = g.camY + Math.floor(((py + 0.5) * g.dvh) / g.rows)
1167    return inBounds(x, y) ? [x, y] : null
1168  }
1169  const row = py - g.mapTop
1170  if (row < 0 || row >= g.mapRows || g.view !== 'map') return null
1171  const x = g.camX + Math.floor(px / g.drawZoom), y = g.camY + row
1172  return inBounds(x, y) ? [x, y] : null
1173}
1174
1175function onPointer(g: Game, e: ClientPointerEvent): boolean {
1176  const tile = tileAt(g, e.x, e.y)
1177  if (e.type === 'leave') { g.hover = null; g.painting = false; g.panning = null; return true }
1178  if (e.type === 'up') { g.painting = false; g.panning = null; return false }
1179  if (e.type === 'down' && (e.button === 'right' || e.button === 'middle')) {
1180    g.panning = { x: e.x, y: e.y, camX: g.camX, camY: g.camY }
1181    return false
1182  }
1183  if (e.type === 'move' && g.panning !== null) {
1184    g.camX = g.panning.camX - Math.round((e.x - g.panning.x) / g.zoom)
1185    g.camY = g.panning.camY - (e.y - g.panning.y)
1186    g.follow = false
1187    return true
1188  }
1189  if (e.type === 'down') {
1190    if (tile === null) {
1191      if (g.desktop) return false
1192      const hit = g.regions.find(r => r.row === e.y && e.x >= r.x0 && e.x < r.x1)
1193      hit?.act()
1194      return hit !== undefined
1195    }
1196    g.painting = true
1197    g.lastPaint = -1
1198  }
1199  if (e.type === 'move') {
1200    const was = g.hover
hooks/controls.tsx 34 lines
1import type { ClientModule, JsonValue } from 'claude-code'
2
3/*
4 * The desktop's buttons and dropdowns. They live in a Client of their own so
5 * their handlers stay put: the pane around them redraws with every frame of
6 * the map, and a Button drawn by the pane would lose its press to the next
7 * drawing. This region redraws only when its own props change. A press posts
8 * the command; the hooks module hands it on to the game.
9 */
10
11type Choice = { key: string; value: string; act: string; options: { value: string; label: string }[] }
12type Press = { key: string; label: string; act: string; active?: boolean }
13export type ControlsProps = { label?: string; selects?: Choice[]; buttons: Press[] }
14
15const Controls: ClientModule<JsonValue> = (props, s) => {
16  const { Box, Text, Button, Select } = s.elements
17  const p = (props ?? { buttons: [] }) as unknown as ControlsProps
18  const send = (act: string) => s.post({ type: 'cmd', act })
19
20  return (
21    <Box flexDirection="row" flexWrap="wrap" columnGap={1} rowGap={1}>
22      {p.label !== undefined ? <Text dimColor>{p.label}</Text> : null}
23      {(p.selects ?? []).map(c => (
24        <Select key={c.key} value={c.value} options={c.options} onSelect={v => send(`${c.act}:${v}`)} />
25      ))}
26      {p.buttons.map(b => (
27        <Button key={b.key} variant={b.active === true ? 'primary' : 'secondary'} onPress={() => send(b.act)}>{b.label}</Button>
28      ))}
29    </Box>
30  )
31}
32
33export default Controls
34
types/index.d.ts 72 lines
1/**
2 * One frame of the world as the desktop draws it: the game runs in the
3 * Client, which posts what is in view; the hooks module paints it as SVG.
4 * Coordinates are tiles relative to the view's top left.
5 */
6export type OmniFrame = {
7  vw: number
8  vh: number
9  /** The view's top left in world tiles, so decorations stay put as it pans. */
10  camX: number
11  camY: number
12  tick: number
13  /** `vw * vh` characters, one per tile: `'A'` plus the terrain code. */
14  terr: string
15  /** With borders on, `vw * vh` characters: `'0'` none, else `'0'` plus kingdom id plus one. */
16  owner: string
17  /** Burning tiles, as `y * vw + x`. */
18  fire: number[]
19  /** Triplets: tile index, kingdom id, 1 for the capital. */
20  houses: number[]
21  /** Sextuplets: x, y, kind index, kingdom id or -1, flags (1 ruler, 2 sick, 4 mad, 8 selected), id. */
22  units: number[]
23  /** Quintuplets: effect index, x, y, radius, frames left. */
24  fx: number[]
25  /** Empty, or x, y, radius of the brush under the pointer. */
26  hover: number[]
27  /** Kingdom colours by id. */
28  colors: string[]
29  hud: OmniHud
30  info: OmniInfo
31  realms: OmniRealm[]
32  log: string[]
33}
34
35export type OmniHud = {
36  year: number
37  pop: number
38  kingdoms: number
39  wars: number
40  beasts: number
41  cat: string
42  tool: string
43  toolLabel: string
44  paused: boolean
45  speed: number
46  brush: number
47  zoom: number
48  borders: boolean
49  view: 'map' | 'realms'
50  /** The last command the game applied: what the panel's buttons sent, acknowledged. */
51  ack: number
52  /** Rows of text the map's box takes on the desktop, which the input region spans. */
53  rows: number
54}
55
56export type OmniTrait = { name: string; good: boolean }
57
58export type OmniInfo =
59  | { kind: 'log' }
60  | { kind: 'unit'; name: string; color: string; line: string; traits: OmniTrait[]; canTrait: boolean; follow: boolean }
61
62export type OmniRealm = { name: string; color: string; text: string; wars: string; allies: string }
63
64/** A press on a desktop Button, handed to the Client through its props. */
65export type OmniCmd = { seq: number; act: string }
66
67declare module 'claude-code' {
68  interface PluginState {
69    omnirealm: { frame: OmniFrame; cmds: OmniCmd[] }
70  }
71}
72