deps-add(packages-directly): Install and pin critical security packages

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Claude Code 2026-03-25 22:48:52 -07:00
parent 4ed8b8862c
commit 9034d117f7
14 changed files with 3620 additions and 0 deletions

View file

@ -0,0 +1,15 @@
{
"name": "@magic-civ/engine-ts",
"version": "0.1.0",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"typescript": "^5.7.0"
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,154 @@
import { GRID_WIDTH, GRID_HEIGHT } from './configs'
export { GRID_WIDTH, GRID_HEIGHT }
// Odd-q offset neighbor deltas: [even_col_offsets, odd_col_offsets]
// Each entry is [dcol, drow] for directions E, NE, NW, W, SW, SE
const ODD_Q_NEIGHBORS: readonly [readonly [number, number][], readonly [number, number][]] = [
// even col
[[1, 0], [1, -1], [0, -1], [-1, 0], [-1, 1], [0, 1]],
// odd col
[[1, 0], [1, 1], [0, 1], [-1, 0], [-1, -1], [0, -1]],
] as const
// Axial direction vectors: E(0), NE(1), NW(2), W(3), SW(4), SE(5)
// These match the GDScript HexUtils.AXIAL_DIRECTIONS order
export const AXIAL_DIRECTIONS: readonly [number, number][] = [
[1, 0], [1, -1], [0, -1], [-1, 0], [-1, 1], [0, 1],
] as const
// ---------------------------------------------------------------------------
// Core grid helpers
// ---------------------------------------------------------------------------
export function idx(col: number, row: number, w: number): number {
return row * w + col
}
export function neighbors(
col: number,
row: number,
w: number,
h: number,
): Array<{ col: number; row: number }> {
const parity = col & 1
const deltas = ODD_Q_NEIGHBORS[parity]
const result: Array<{ col: number; row: number }> = []
for (const [dc, dr] of deltas) {
const nc = col + dc
const nr = row + dr
if (nc >= 0 && nc < w && nr >= 0 && nr < h) {
result.push({ col: nc, row: nr })
}
}
return result
}
export function axialToOffset(q: number, r: number): { col: number; row: number } {
const col = q
const row = r + (q - (q & 1)) / 2
return { col, row }
}
/** The tile that wind arrives FROM (opposite direction, clamped to grid). */
export function upwindPos(
col: number,
row: number,
windDir: number,
w: number,
h: number,
): { col: number; row: number } | null {
const oppositeDir = (windDir + 3) % 6
const parity = col & 1
const [dc, dr] = ODD_Q_NEIGHBORS[parity][oppositeDir]
const nc = col + dc
const nr = row + dr
if (nc < 0 || nc >= w || nr < 0 || nr >= h) return null
return { col: nc, row: nr }
}
/** Step one hex in direction dir (0-5), returning null if out of bounds. */
export function neighborInDir(
col: number,
row: number,
dir: number,
w: number,
h: number,
): { col: number; row: number } | null {
const parity = col & 1
const [dc, dr] = ODD_Q_NEIGHBORS[parity][dir % 6]
const nc = col + dc
const nr = row + dr
if (nc < 0 || nc >= w || nr < 0 || nr >= h) return null
return { col: nc, row: nr }
}
/** Solar insolation at a row: 1.0 at equator (center), 0.0 at poles. */
export function solarByRow(row: number, height: number): number {
const centerRow = height / 2.0
return 1.0 - Math.abs((row - centerRow) / centerRow)
}
// ---------------------------------------------------------------------------
// Terrain classification
// ---------------------------------------------------------------------------
export function classifyTerrain(
temp: number,
moisture: number,
elevation: number,
): string {
// Only the highest peaks are locked terrain — elevation interacts with
// climate for hills (cold hills→tundra, wet hills→forest).
if (elevation > 0.82) return 'mountains'
if (elevation > 0.70) {
if (temp < 0.25) return 'tundra'
if (moisture > 0.65 && temp > 0.55) return 'forest'
return 'hills'
}
// Polar / cold zones
if (temp < 0.10) return 'snow'
if (temp < 0.20) return moisture >= 0.30 ? 'boreal_forest' : 'tundra'
if (temp < 0.35) {
if (moisture >= 0.50) return 'boreal_forest'
if (moisture >= 0.30) return 'grassland'
return 'tundra'
}
// Temperate zone (0.350.55) — this is where most diversity should live
if (temp < 0.55) {
if (moisture < 0.25) return 'desert'
if (moisture < 0.40) return 'plains'
if (moisture < 0.55) return 'grassland'
if (moisture < 0.70) return 'forest'
return 'swamp'
}
// Warm zone (0.550.80)
if (temp < 0.80) {
if (moisture < 0.25) return 'desert'
if (moisture < 0.40) return 'plains'
if (moisture < 0.55) return 'grassland'
if (moisture < 0.68) return 'forest'
return 'jungle'
}
// Hot zone (>0.80) — true tropics, equatorial only
if (moisture < 0.30) return 'desert'
if (moisture < 0.45) return 'plains'
if (moisture < 0.60) return 'grassland'
if (moisture < 0.68) return 'forest'
return 'jungle'
}
// ---------------------------------------------------------------------------
// Seeded noise
// ---------------------------------------------------------------------------
export function hashNoise(x: number, y: number, seed: number): number {
// Returns a deterministic pseudo-random float in [0, 1)
const v = Math.sin(x * 127.1 + y * 311.7 + seed * 74.3) * 43758.5453
return v - Math.floor(v)
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,6 @@
/** Hex grid dimensions — 40 columns × 24 rows, odd-q offset layout. */
export const GRID_WIDTH = 40
export const GRID_HEIGHT = 24
/** Rows at the very top and bottom forced to ocean (no land at poles). */
export const POLAR_ROWS = 2

View file

@ -0,0 +1,3 @@
export * from './grid'
export * from './rendering'
export * from './simulation'

View file

@ -0,0 +1,48 @@
import type { LeySchool } from '../types'
import { GRID_WIDTH, GRID_HEIGHT } from './grid'
/** Hex tile size in the WebGL renderer (pixels). */
export const HEX_W = 24
export const HEX_H = 20
/** Canvas dimensions — derived from grid + hex size so the canvas exactly matches the camera frustum. */
export const CANVAS_W = GRID_WIDTH * HEX_W * 0.75 + HEX_W * 0.25 // 726
export const CANVAS_H = GRID_HEIGHT * HEX_H + HEX_H * 0.5 // 490
/** Ley line glow colors per school (Three.js hex). */
export const LEY_COLORS: Record<LeySchool, number> = {
death: 0x8B00FF,
life: 0xFFD700,
nature: 0x00FF88,
aether: 0x00CFFF,
chaos: 0xFF4400,
}
/** School colors for wonder markers (RGB 0-1 for shaders). */
export const SCHOOL_COLORS: Record<string, [number, number, number]> = {
death: [0.54, 0.00, 1.00],
life: [1.00, 0.85, 0.00],
nature: [0.00, 1.00, 0.53],
aether: [0.00, 0.81, 1.00],
chaos: [1.00, 0.27, 0.00],
generic: [0.85, 0.85, 0.85],
all: [1.00, 1.00, 1.00],
none: [0.60, 0.60, 0.60],
}
/**
* Diamond-glow offsets for ley line rendering.
* [dx, dy, opacity_weight] center + 4 cardinal + 4 diagonal.
* Additive blending accumulates to full brightness at center, fades at edges.
*/
export const GLOW_OFFSETS: ReadonlyArray<[number, number, number]> = [
[0, 0, 1.00],
[-1, 0, 0.45], [1, 0, 0.45], [0, -1, 0.45], [0, 1, 0.45],
[-1, -1, 0.20], [1, -1, 0.20], [-1, 1, 0.20], [1, 1, 0.20],
]
/** Maximum ley edges rendered (sorted by strength, weakest culled). */
export const MAX_VISIBLE_LEY_EDGES = 8
/** Wind particle count for the wind layer overlay. */
export const WIND_PARTICLE_COUNT = 300

View file

@ -0,0 +1,17 @@
/** Default number of scenario turns to simulate (after worldAge geological history). */
export const DEFAULT_SCENARIO_TURNS = 2000
/** Number of additional turns when the user clicks "Extend". */
export const EXTEND_TURNS = 500
/** Milliseconds per animation frame in the playback loop (~12.5 fps). */
export const FRAME_MS = 80
/** Default layer mask: terrain only (bit 0). */
export const DEFAULT_LAYER_MASK = (1 << 0)
/** Fixed world seed used by the simulation. Same seed = same world. */
export const WORLD_SEED = 42
/** Default playback buffer duration in seconds (used when ?buffer= is absent). */
export const DEFAULT_BUFFER_SECONDS = 10

View file

@ -0,0 +1,8 @@
export * from './types'
export * from './HexGrid'
export * from './ClimatePhysics.generated'
export * from './MapGenerator.generated'
export * from './runner'
export * from './scenarios'
export * from './worker-protocol'
export * from './configs'

View file

@ -0,0 +1,297 @@
import type {
GridState,
GridSnapshot,
TurnStats,
EcologicalEvent,
ScenarioConfig,
TerrainData,
LeySchool,
SimulationResult,
} from './types'
import { GRID_WIDTH, GRID_HEIGHT } from './HexGrid'
import { ClimatePhysics } from './ClimatePhysics.generated'
import { generate as generateMap } from './MapGenerator.generated'
import { WORLD_SEED, DEFAULT_SCENARIO_TURNS } from './configs'
// ---------------------------------------------------------------------------
// Terrain order — fixed for consistent encoding across frames
// ---------------------------------------------------------------------------
export const TERRAIN_ORDER: readonly string[] = [
'ocean', 'coast', 'lake', 'inland_sea', 'ice', 'snow', 'tundra', 'desert',
'plains', 'grassland', 'forest', 'boreal_forest', 'jungle', 'enchanted_forest',
'hills', 'mountains', 'swamp', 'corrupted_land', 'volcano',
// Natural wonders (geological/biological formations)
'mana_node', 'ley_nexus', 'lodestone_spire', 'crystal_cavern',
'worldroot', 'primordial_spring', 'abyssal_vortex',
] as const
const TERRAIN_INDEX = new Map<string, number>(
TERRAIN_ORDER.map((id, i) => [id, i]),
)
function encodeTerrainId(terrainId: string): number {
const i = TERRAIN_INDEX.get(terrainId) ?? 0
return i / (TERRAIN_ORDER.length - 1)
}
// ---------------------------------------------------------------------------
// Terrain cache
// ---------------------------------------------------------------------------
export function buildTerrainCache(): Map<string, TerrainData> {
const terrainMods = import.meta.glob(
'@data/terrain/*.json',
{ eager: true, import: 'default' },
) as Record<string, { terrains: TerrainData[] }>
const cache = new Map<string, TerrainData>()
for (const mod of Object.values(terrainMods)) {
if (!mod?.terrains) continue
for (const terrain of mod.terrains) {
cache.set(terrain.id, terrain)
}
}
return cache
}
// ---------------------------------------------------------------------------
// Params loading
// ---------------------------------------------------------------------------
type ClimateParamsRaw = Record<string, number | Record<string, unknown>>
function flattenParams(raw: ClimateParamsRaw): Record<string, number> {
const out: Record<string, number> = {}
for (const [k, v] of Object.entries(raw)) {
if (typeof v === 'number') out[k] = v
}
return out
}
export function loadClimateParams(): Record<string, number> {
const mods = import.meta.glob(
'@data/climate_params.json',
{ eager: true, import: 'default' },
) as Record<string, ClimateParamsRaw>
const raw = Object.values(mods)[0]
if (!raw) throw new Error('climate_params.json not found via @data/ alias')
return flattenParams(raw)
}
// ---------------------------------------------------------------------------
// Snapshot encoding
// ---------------------------------------------------------------------------
export function encodeSnapshot(
grid: GridState,
turn: number,
events: EcologicalEvent[] = [],
): GridSnapshot {
const n = grid.tiles.length
const texA = new Float32Array(n * 4)
const texB = new Float32Array(n * 4)
for (let i = 0; i < n; i++) {
const tile = grid.tiles[i]
const base = i * 4
texA[base + 0] = tile.temperature
texA[base + 1] = tile.moisture
texA[base + 2] = tile.corruption_pressure
texA[base + 3] = tile.reef_health
texB[base + 0] = tile.wind_direction / 5
texB[base + 1] = tile.wind_speed
texB[base + 2] = encodeTerrainId(tile.terrain_id)
let riverMask = 0
for (const e of tile.river_edges) riverMask |= (1 << e)
texB[base + 3] = riverMask / 63
}
const stats = computeTurnStats(grid)
return {
texA, texB,
width: grid.width,
height: grid.height,
turn,
global_avg_temp: grid.global_avg_temp,
ocean_dead_fraction: grid.ocean_dead_fraction,
ley_edges: [],
wonder_positions: [],
stats,
events,
}
}
const EMPTY_SCHOOL_RECORD: Record<LeySchool, number> = { death: 0, life: 0, nature: 0, aether: 0, chaos: 0 }
export function computeTurnStats(grid: GridState): TurnStats {
const { tiles } = grid
let tempSum = 0
let moistSum = 0
let landCount = 0
let corruptedCount = 0
const terrain_counts: Record<string, number> = {}
for (const tile of tiles) {
terrain_counts[tile.terrain_id] = (terrain_counts[tile.terrain_id] ?? 0) + 1
const isWater = tile.terrain_id === 'ocean' || tile.terrain_id === 'coast' ||
tile.terrain_id === 'lake' || tile.terrain_id === 'inland_sea'
if (!isWater) {
landCount++
tempSum += tile.temperature
moistSum += tile.moisture
if (tile.terrain_id === 'corrupted_land') corruptedCount++
}
}
return {
avg_temp: landCount > 0 ? tempSum / landCount : 0.5,
avg_moisture: landCount > 0 ? moistSum / landCount : 0.5,
corrupted_pct: landCount > 0 ? corruptedCount / landCount : 0,
total_ley_strength: 0,
dominant_ley_school: '',
ley_school_strengths: { ...EMPTY_SCHOOL_RECORD },
ley_land_coverage: { ...EMPTY_SCHOOL_RECORD },
ocean_dead_pct: grid.ocean_dead_fraction,
terrain_counts,
}
}
// ---------------------------------------------------------------------------
// Terrain cache from pre-loaded data (worker-compatible, no import.meta.glob)
// ---------------------------------------------------------------------------
export function buildTerrainCacheFromData(data: Record<string, TerrainData>): Map<string, TerrainData> {
return new Map(Object.entries(data))
}
// ---------------------------------------------------------------------------
// Grid state cloning (for checkpoints)
// ---------------------------------------------------------------------------
export function cloneGridState(grid: GridState): GridState {
return {
width: grid.width,
height: grid.height,
global_avg_temp: grid.global_avg_temp,
ocean_dead_fraction: grid.ocean_dead_fraction,
tiles: grid.tiles.map((t) => ({
...t,
river_edges: [...t.river_edges],
})),
}
}
// ---------------------------------------------------------------------------
// Volcanic winter per-turn forcing
// ---------------------------------------------------------------------------
export function applyVolcanicWinterForcing(grid: GridState): void {
const { tiles } = grid
const RADIUS = 3
const volcanos = tiles.filter((t) => t.terrain_id === 'volcano')
for (const volcano of volcanos) {
const q1 = volcano.col
const s1 = volcano.row - (volcano.col - (volcano.col & 1)) / 2
for (const tile of tiles) {
const q2 = tile.col
const s2 = tile.row - (tile.col - (tile.col & 1)) / 2
const dq = q2 - q1
const ds = s2 - s1
const dist = (Math.abs(dq) + Math.abs(ds) + Math.abs(dq + ds)) / 2
if (dist <= RADIUS) {
tile.magic_heat_delta -= 0.008
}
}
}
}
// ---------------------------------------------------------------------------
// Core simulation loop (synchronous)
// ---------------------------------------------------------------------------
export function runScenarioSync(
config: ScenarioConfig,
terrainCache: Map<string, TerrainData>,
params: Record<string, number>,
scenarioTurns = DEFAULT_SCENARIO_TURNS,
seed = WORLD_SEED,
): SimulationResult {
const worldSeed = seed
const worldAge = config.worldAge
const totalTurns = worldAge + scenarioTurns
// Generate map from seed using the transpiled GDScript pipeline
const grid = generateMap(worldSeed, GRID_WIDTH, GRID_HEIGHT, terrainCache, params, 'continents')
const physics = new ClimatePhysics(params, terrainCache)
const snapshots: GridSnapshot[] = []
const isVolcanicWinter = config.id === 'volcanic_winter'
// Phase 1: Geological history (worldAge turns) — pure physics + ecology, no scenario forcing.
for (let turn = 0; turn < worldAge; turn++) {
physics.processStep(grid, turn, worldSeed)
}
// Phase 2: Apply scenario initMap overrides on the geologically mature world
config.initMap(grid)
// Phase 3: Scenario simulation — volcanic forcing, full recording
for (let turn = worldAge; turn < totalTurns; turn++) {
const scenarioTurn = turn - worldAge
if (isVolcanicWinter) applyVolcanicWinterForcing(grid)
const events = physics.processStep(grid, turn, worldSeed)
snapshots.push(encodeSnapshot(grid, scenarioTurn, events))
}
return {
snapshots,
continuation: {
grid, physics, config,
nextAbsoluteTurn: totalTurns,
worldSeed, isVolcanicWinter,
},
}
}
// ---------------------------------------------------------------------------
// Extend an existing simulation by additional turns
// ---------------------------------------------------------------------------
export function extendSimulation(
prev: SimulationResult,
additionalTurns: number,
): SimulationResult {
const { continuation } = prev
const { grid, config, nextAbsoluteTurn, worldSeed, isVolcanicWinter } = continuation
const physics = continuation.physics as ClimatePhysics
const worldAge = config.worldAge
const snapshots = [...prev.snapshots]
const endTurn = nextAbsoluteTurn + additionalTurns
for (let turn = nextAbsoluteTurn; turn < endTurn; turn++) {
const scenarioTurn = turn - worldAge
if (isVolcanicWinter) applyVolcanicWinterForcing(grid)
const events = physics.processStep(grid, turn, worldSeed)
snapshots.push(encodeSnapshot(grid, scenarioTurn, events))
}
return {
snapshots,
continuation: {
grid, physics, config,
nextAbsoluteTurn: endTurn,
worldSeed, isVolcanicWinter,
},
}
}

View file

@ -0,0 +1,141 @@
import type { ScenarioConfig, GridState } from './types'
import { DEFAULT_WORLD_AGE } from './types'
import { idx } from './HexGrid'
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function noInit(_grid: GridState): void { /* no overrides */ }
// ---------------------------------------------------------------------------
// 1a. Base — No Magic
// ---------------------------------------------------------------------------
const baseNoMagic: ScenarioConfig = {
id: 'base_no_magic',
worldAge: 0,
name: 'Baseline',
description:
'Reference climate scenario. Temperature, moisture, pressure, and terrain evolve under solar forcing alone. Should reach equilibrium — persistent drift indicates a physics bug.',
initMap: noInit,
}
// ---------------------------------------------------------------------------
// 2. Ice Age Spiral
// ---------------------------------------------------------------------------
const iceAge: ScenarioConfig = {
worldAge: DEFAULT_WORLD_AGE,
id: 'ice_age',
name: 'Ice Age Spiral',
description:
'Polar temperatures drop sharply, cascading inward as the cold front tightens over 150 turns.',
initMap: (grid) => {
for (const tile of grid.tiles) {
if (tile.row < 5 || tile.row > 19) {
tile.temperature = 0.05
}
}
},
}
// ---------------------------------------------------------------------------
// 3. Desertification
// ---------------------------------------------------------------------------
const desertification: ScenarioConfig = {
worldAge: DEFAULT_WORLD_AGE,
id: 'desertification',
name: 'Desertification',
description:
'The subtropical band dries out. Moisture collapses, plains turn to desert, and the effect slowly reverses as equilibrium restores.',
initMap: (grid) => {
for (const tile of grid.tiles) {
if (tile.row >= 9 && tile.row <= 14) {
tile.moisture = Math.max(0, tile.moisture - 0.2)
}
}
},
}
// ---------------------------------------------------------------------------
// 4. Monsoon Cycle
// ---------------------------------------------------------------------------
const monsoonCycle: ScenarioConfig = {
worldAge: DEFAULT_WORLD_AGE,
id: 'monsoon',
name: 'Monsoon Cycle',
description:
'High moisture in the equatorial band recreates the wet/dry rhythm of tropical monsoon seasons as the climate system oscillates toward equilibrium.',
initMap: (grid) => {
for (const tile of grid.tiles) {
if (tile.row >= 10 && tile.row <= 14) {
tile.moisture = 0.7
}
}
},
}
// ---------------------------------------------------------------------------
// 5. Flooding
// ---------------------------------------------------------------------------
const flooding: ScenarioConfig = {
worldAge: DEFAULT_WORLD_AGE,
id: 'flooding',
name: 'Flooding',
description:
'Excessive moisture drives relentless precipitation across the globe. Rivers overflow, coasts surge, and the land drowns in an oscillating cycle of inundation.',
initMap: (grid) => {
for (const tile of grid.tiles) {
tile.moisture = Math.min(1.0, tile.moisture + 0.3)
tile.flow_accumulation = Math.min(10.0, tile.flow_accumulation * 1.5)
}
},
}
// ---------------------------------------------------------------------------
// 6. Volcanic Winter
// ---------------------------------------------------------------------------
const volcanicWinter: ScenarioConfig = {
worldAge: DEFAULT_WORLD_AGE,
id: 'volcanic_winter',
name: 'Volcanic Winter',
description:
'Six volcanoes erupt simultaneously, blasting ash clouds that cool the entire globe. Pure physical forcing from volcanic particulates.',
initMap: (grid) => {
const { width: w } = grid
const volcanicSites = [
{ col: 5, row: 4 }, { col: 15, row: 6 }, { col: 25, row: 3 },
{ col: 35, row: 5 }, { col: 10, row: 18 }, { col: 30, row: 20 },
]
for (const { col, row } of volcanicSites) {
if (col >= 0 && col < w && row >= 0 && row < grid.height) {
const tile = grid.tiles[idx(col, row, w)]
if (tile) tile.terrain_id = 'volcano'
}
}
// Seed ash cooling delta on volcano-adjacent tiles
for (const tile of grid.tiles) {
if (tile.terrain_id === 'volcano') {
tile.magic_heat_delta = -0.005
}
}
},
}
// ---------------------------------------------------------------------------
// Export
// ---------------------------------------------------------------------------
export const SCENARIOS: ScenarioConfig[] = [
baseNoMagic,
iceAge,
desertification,
monsoonCycle,
flooding,
volcanicWinter,
]

View file

@ -0,0 +1,141 @@
export type School = 'death' | 'life' | 'nature' | 'aether' | 'chaos' | 'generic' | 'all' | 'none'
export type LeySchool = 'death' | 'life' | 'nature' | 'aether' | 'chaos'
export interface TileState {
col: number
row: number
temperature: number // [0, 1]
moisture: number // [0, 1]
elevation: number // [0, 1]
terrain_id: string
wind_direction: number // [0, 5] axial direction index
wind_speed: number // [0, 1]
quality: number // 1 | 2 | 3
quality_progress: number // counter toward next quality change
river_edges: number[] // edge indices [0-5] where rivers flow
flow_accumulation: number
corruption_pressure: number // [0, 1]
original_terrain_id: string
ley_line_count: number
ley_school: LeySchool | ''
reef_health: number // [0, 1], relevant for coast tiles
magic_heat_delta: number
magic_moisture_delta: number
is_natural_wonder: boolean
wonder_anchor_strength: number // from terrain data ley_anchor_strength
wonder_anchor_school: School // LEGACY — single school (kept for anchor decay compat)
wonder_anchor_schools: LeySchool[] // multi-school affinities for ley network
wonder_tier: number // 1-5, aligned with eras (separate from terrain quality)
// Optional fields written by subsystems (not present on all tiles)
river_source_type?: string // 'snowmelt' | 'spring' | 'hot_spring' | 'glacial' | undefined
fish_stock?: number // marine ecosystem fish population [0, 1]
corruption_resistance_pct?: number // city building protection [0, 1]
}
export interface GridState {
tiles: TileState[]
width: number
height: number
global_avg_temp: number
ocean_dead_fraction: number
}
export interface TurnStats {
avg_temp: number
avg_moisture: number
corrupted_pct: number
total_ley_strength: number
dominant_ley_school: LeySchool | ''
ley_school_strengths: Record<LeySchool, number>
ley_land_coverage: Record<LeySchool, number> // fraction of land tiles affiliated with each school
ocean_dead_pct: number
terrain_counts: Record<string, number>
}
// Packed per-tile data for rendering: 2 RGBA float textures
// texA: [temperature, moisture, corruption_pressure, reef_health]
// texB: [wind_direction/5, wind_speed, terrain_encoded, river_bitmask/63]
export interface GridSnapshot {
texA: Float32Array // width * height * 4
texB: Float32Array // width * height * 4
width: number
height: number
turn: number
global_avg_temp: number
ocean_dead_fraction: number
ley_edges: LeyEdge[]
wonder_positions: WonderMarker[]
stats: TurnStats
events: EcologicalEvent[] // events that occurred this turn
}
export interface LeyEdge {
fromCol: number
fromRow: number
toCol: number
toRow: number
school: LeySchool
strength: number // [0, 5]
}
export interface WonderMarker {
col: number
row: number
schools: LeySchool[]
tier: number // 1-5
strength: number // = tier
}
export interface EcologicalEvent {
turn: number
type: string
col: number
row: number
description: string
}
/** Valid world age values — increments of 500 geological turns from 0. */
export const WORLD_AGE_OPTIONS = [0, 500, 1000, 1500, 2000, 2500, 3000] as const
export type WorldAge = (typeof WORLD_AGE_OPTIONS)[number]
export const DEFAULT_WORLD_AGE: WorldAge = 2000
export interface ScenarioConfig {
id: string
name: string
description: string
initMap: (grid: GridState) => void
worldAge: WorldAge // turns of geological history before scenario forcing begins
}
/** Opaque handle for continuing a simulation beyond its initial run. */
export interface ContinuationState {
grid: GridState
config: ScenarioConfig
nextAbsoluteTurn: number
worldSeed: number
isVolcanicWinter: boolean
// ClimatePhysics is stored as `unknown` here to avoid
// importing the class type into the shared types file. The runner casts it.
physics: unknown
}
/** Result of running or extending a simulation. */
export interface SimulationResult {
snapshots: GridSnapshot[]
continuation: ContinuationState
}
// Terrain data shape from games/age-of-four/data/terrain/*.json
export interface TerrainData {
id: string
name: string
albedo: number
evapotranspiration: number
color: [number, number, number]
flags: string[]
climate_zone: string
terrain_power?: { spell_school?: string; corruption_spread_modifier?: number }
mana_major?: { school: string; amount: number }
ley_anchor_strength?: number
ley_anchor_school?: string
}

View file

@ -0,0 +1,118 @@
import type { TurnStats, EcologicalEvent, GridSnapshot, TerrainData, LeyEdge, WonderMarker } from './types'
// ---------------------------------------------------------------------------
// Commands: main thread → worker
// ---------------------------------------------------------------------------
export type WorkerCommand =
| InitCommand
| RunCommand
| ExtendCommand
| FrameCommand
| CancelCommand
export interface InitCommand {
type: 'init'
terrainData: Record<string, TerrainData>
params: Record<string, number>
}
export interface RunCommand {
type: 'run'
scenarioId: string
turns: number
/** How many frames to pre-encode after simulation completes. */
prebufferFrames: number
/** World seed (default 42). */
seed?: number
}
export interface ExtendCommand {
type: 'extend'
scenarioId: string
turns: number
}
export interface FrameCommand {
type: 'frame'
scenarioId: string
turn: number
lookahead: number
}
export interface CancelCommand {
type: 'cancel'
scenarioId: string
}
// ---------------------------------------------------------------------------
// Responses: worker → main thread
// ---------------------------------------------------------------------------
export type WorkerResponse =
| ReadyResponse
| ProgressResponse
| StatsResponse
| FramesResponse
| DoneResponse
| PrebufferDoneResponse
| ErrorResponse
export interface ReadyResponse {
type: 'ready'
}
export interface ProgressResponse {
type: 'progress'
scenarioId: string
turn: number
total: number
phase: 'geology' | 'scenario' | 'buffering'
}
/** Lightweight per-turn data sent once after simulation completes. */
export interface StatsResponse {
type: 'stats'
scenarioId: string
allStats: TurnStats[]
allEvents: EcologicalEvent[][]
}
/** Rendered frames with Float32Array textures — sent on demand via 'frame' command. */
export interface FramesResponse {
type: 'frames'
scenarioId: string
startTurn: number
snapshots: FramePayload[]
}
/** Minimal snapshot for rendering — no stats/events (main thread has those already). */
export interface FramePayload {
texA: Float32Array
texB: Float32Array
width: number
height: number
turn: number
global_avg_temp: number
ocean_dead_fraction: number
ley_edges: LeyEdge[]
wonder_positions: WonderMarker[]
}
export interface DoneResponse {
type: 'done'
scenarioId: string
totalTurns: number
}
/** Posted after the initial playback buffer frames have all been sent. */
export interface PrebufferDoneResponse {
type: 'prebuffer_done'
scenarioId: string
}
export interface ErrorResponse {
type: 'error'
scenarioId: string
message: string
}

View file

@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": ["ES2020"],
"module": "ESNext",
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"strict": true
},
"include": ["src"]
}