deps-add(packages-directly): ➕ Install and pin critical security packages
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
4ed8b8862c
commit
9034d117f7
14 changed files with 3620 additions and 0 deletions
15
packages/engine-ts/package.json
Normal file
15
packages/engine-ts/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
1115
packages/engine-ts/src/ClimatePhysics.generated.ts
Normal file
1115
packages/engine-ts/src/ClimatePhysics.generated.ts
Normal file
File diff suppressed because it is too large
Load diff
154
packages/engine-ts/src/HexGrid.ts
Normal file
154
packages/engine-ts/src/HexGrid.ts
Normal 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.35–0.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.55–0.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)
|
||||
}
|
||||
|
||||
1543
packages/engine-ts/src/MapGenerator.generated.ts
Normal file
1543
packages/engine-ts/src/MapGenerator.generated.ts
Normal file
File diff suppressed because it is too large
Load diff
6
packages/engine-ts/src/configs/grid.ts
Normal file
6
packages/engine-ts/src/configs/grid.ts
Normal 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
|
||||
3
packages/engine-ts/src/configs/index.ts
Normal file
3
packages/engine-ts/src/configs/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export * from './grid'
|
||||
export * from './rendering'
|
||||
export * from './simulation'
|
||||
48
packages/engine-ts/src/configs/rendering.ts
Normal file
48
packages/engine-ts/src/configs/rendering.ts
Normal 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
|
||||
17
packages/engine-ts/src/configs/simulation.ts
Normal file
17
packages/engine-ts/src/configs/simulation.ts
Normal 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
|
||||
8
packages/engine-ts/src/index.ts
Normal file
8
packages/engine-ts/src/index.ts
Normal 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'
|
||||
297
packages/engine-ts/src/runner.ts
Normal file
297
packages/engine-ts/src/runner.ts
Normal 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,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
141
packages/engine-ts/src/scenarios.ts
Normal file
141
packages/engine-ts/src/scenarios.ts
Normal 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,
|
||||
]
|
||||
141
packages/engine-ts/src/types.ts
Normal file
141
packages/engine-ts/src/types.ts
Normal 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
|
||||
}
|
||||
118
packages/engine-ts/src/worker-protocol.ts
Normal file
118
packages/engine-ts/src/worker-protocol.ts
Normal 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
|
||||
}
|
||||
14
packages/engine-ts/tsconfig.json
Normal file
14
packages/engine-ts/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue