chore(pages): 🔧 Update build configuration for failed page deployment (request_id: 88f40586)

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Claude Code 2026-03-26 03:52:03 -07:00
parent c699aed0ad
commit 8ed89541e4
7 changed files with 893 additions and 117 deletions

View file

@ -230,7 +230,7 @@ func _update_lake_evaporation(game_map: RefCounted) -> void:
var hop_pos: Vector2i = axial
var hop_strength: float = 1.0
var wind_dir: int = tile.wind_direction
for _hop: int in max_hops:
for _hop_i: int in max_hops:
hop_pos = hop_pos + HexUtilsScript.AXIAL_DIRECTIONS[wind_dir]
var hop_tile: Variant = game_map.tiles.get(hop_pos)
if hop_tile == null:

View file

@ -197,6 +197,11 @@ export class ClimatePhysics {
this.stepCollectMagicForcing(grid)
this.stepOrbitalForcing(grid, turn)
this.stepAerosolForcing(grid)
// Atmosphere: pressure → anomalies → wind → humidity (before temperature)
this.stepBaselinePressure(grid)
this.stepAnomalies(grid, turn)
this.stepWindFromPressure(grid)
this.stepHumidity(grid)
this.stepTemperature(grid)
this.stepLakeThermal(grid)
this.stepMoistureWind(grid)
@ -483,6 +488,8 @@ export class ClimatePhysics {
const nb = tiles[idx(nb_pos.col, nb_pos.row, w)]
if (nb !== null) {
nb.moisture = Math.min(1.0, Math.max(0.0, nb.moisture + share))
}
}
} else {
// Ocean/coast: inject along downwind chain (multi-hop with decay).
// Hop 1 = full strength, hop 2 = hop_decay, hop 3 = hop_decay^2, etc.
@ -491,7 +498,7 @@ export class ClimatePhysics {
let hopRow = row
let hop_strength = 1.0
let wind_dir = tile.wind_direction
for (let _hop = 0; _hop < max_hops; _hop++) {
for (let _hop_i = 0; _hop_i < max_hops; _hop_i++) {
const _hop = neighborInDir(hopCol, hopRow, wind_dir, w, h)
if (_hop === null) break
hopCol = _hop.col
@ -517,8 +524,6 @@ export class ClimatePhysics {
}
}
}
}
}
}
@ -546,6 +551,8 @@ export class ClimatePhysics {
if (nb !== null) {
nb.moisture = Math.min(1.0, Math.max(0.0, nb.moisture + vol_nb))
}
}
} else if (tile.river_source_type === "hot_spring") {
tile.moisture = Math.min(1.0, Math.max(0.0, tile.moisture + hs_self))
for (const nb_pos of neighbors(col, row, w, h)) {
@ -553,13 +560,11 @@ export class ClimatePhysics {
if (nb !== null) {
nb.moisture = Math.min(1.0, Math.max(0.0, nb.moisture + hs_nb))
}
}
} else if (tile.elevation >= elev_thresh) {
tile.moisture = Math.min(1.0, Math.max(0.0, tile.moisture + elev_self))
}
}
}
}
}
}
}
@ -648,6 +653,8 @@ export class ClimatePhysics {
tile.quality_progress = 0
if (tile.quality < 5) {
tile.quality += 1
}
}
} else {
tile.quality_progress -= 1
if (tile.quality_progress <= -down_thresh) {
@ -662,8 +669,6 @@ export class ClimatePhysics {
}
}
}
}
}
}
}
@ -1090,4 +1095,197 @@ export class ClimatePhysics {
}
}
// -- Atmosphere state --
private anomalyAge = new Map<number, number>()
private thermalSustain = new Map<number, number>()
private stepBaselinePressure(grid: GridState): void {
const { tiles, width: w, height: h } = grid
const polarHi = this.p('polar_high_pressure', 1030.0)
const subpolarLo = this.p('subpolar_low_pressure', 995.0)
const subtropHi = this.p('subtropical_high_pressure', 1025.0)
const itczLo = this.p('itcz_low_pressure', 1005.0)
const mtnBoost = this.p('mountain_pressure_boost', 5.0)
const oceanOff = this.p('ocean_pressure_offset', -2.0)
const heatOff = this.p('heated_land_pressure_offset', -3.0)
const heatThresh = this.p('heated_land_temp_threshold', 0.6)
for (const tile of tiles) {
const latFrac = h > 1 ? tile.row / (h - 1) : 0.5
let baseline: number
if (latFrac < 0.15) baseline = polarHi + (subpolarLo - polarHi) * (latFrac / 0.15)
else if (latFrac < 0.30) baseline = subpolarLo
else if (latFrac < 0.45) baseline = subpolarLo + (subtropHi - subpolarLo) * ((latFrac - 0.30) / 0.15)
else if (latFrac < 0.60) baseline = subtropHi
else if (latFrac < 0.85) baseline = subtropHi + (itczLo - subtropHi) * ((latFrac - 0.60) / 0.25)
else baseline = itczLo
const isW = this.atmoIsWater(tile.biome_id)
if (isW) {
baseline += oceanOff
} else {
if (tile.elevation > 0.5) baseline += ((tile.elevation - 0.5) / 0.1) * mtnBoost
if (tile.temperature > heatThresh) baseline += heatOff
}
tile.pressure = baseline + (tile.pressure_anomaly ?? 0)
}
}
private atmoIsWater(biome: string): boolean {
return biome === 'ocean' || biome === 'coast' || biome === 'lake' ||
biome === 'deep_ocean' || biome === 'shallow_ocean' || biome === 'coral_reef' ||
biome === 'estuary' || biome === 'pond' || biome === 'river' || biome === 'mangrove' ||
biome === 'inland_sea'
}
private stepAnomalies(grid: GridState, turn: number): void {
const { tiles } = grid
const thermalLowTemp = this.p('thermal_low_temp_threshold', 0.65)
const coldHighTemp = this.p('cold_high_temp_threshold', 0.2)
const coldHighHum = this.p('cold_high_humidity_threshold', 0.3)
const sustainNeeded = this.p('thermal_low_sustain_turns', 3)
const thermalLowVal = this.p('thermal_low_anomaly', -15.0)
const coldHighVal = this.p('cold_high_anomaly', 15.0)
const decayRate = this.p('anomaly_decay_rate', 2.0)
const reinforceRate = this.p('anomaly_reinforce_rate', 3.0)
const maxAnomaly = this.p('anomaly_max', 50.0)
for (let i = 0; i < tiles.length; i++) {
const tile = tiles[i]
let pa = tile.pressure_anomaly ?? 0
const isHot = tile.temperature > thermalLowTemp
const isCold = tile.temperature < coldHighTemp &&
(tile.humidity ?? 0) < coldHighHum &&
!this.atmoIsWater(tile.biome_id)
if (isHot || isCold) {
this.thermalSustain.set(i, (this.thermalSustain.get(i) ?? 0) + 1)
} else {
this.thermalSustain.delete(i)
}
const sustain = this.thermalSustain.get(i) ?? 0
// Spawn
if (pa === 0 && sustain >= sustainNeeded) {
if (isHot) pa = thermalLowVal
else if (isCold) pa = coldHighVal
}
// Decay / reinforce
if (pa !== 0) {
const condMet = (pa < 0 && tile.temperature > thermalLowTemp) ||
(pa > 0 && tile.temperature < coldHighTemp)
if (condMet && sustain > 0) {
pa = pa < 0 ? Math.max(pa - reinforceRate, -maxAnomaly)
: Math.min(pa + reinforceRate, maxAnomaly)
} else {
pa = pa < 0 ? Math.min(pa + decayRate, 0) : Math.max(pa - decayRate, 0)
}
}
tile.pressure_anomaly = pa
tile.pressure = (tile.pressure ?? 1013) + pa
}
}
private stepWindFromPressure(grid: GridState): void {
const { tiles, width: w, height: h } = grid
const speedScale = this.p('wind_speed_scale', 0.08)
const coriolisScale = this.p('coriolis_scale', 0.3)
for (const tile of tiles) {
const latFrac = h > 1 ? tile.row / (h - 1) : 0.5
const hemiSign = latFrac < 0.5 ? 1 : -1
// Find neighbor with steepest pressure gradient
let bestDir = tile.wind_direction
let bestGrad = 0
const nbs = neighbors(tile.col, tile.row, w, h)
for (let d = 0; d < nbs.length; d++) {
const nb = nbs[d]
const ni = idx(nb.col, nb.row, w)
const grad = (tile.pressure ?? 1013) - (tiles[ni].pressure ?? 1013)
if (grad > bestGrad) {
bestGrad = grad
bestDir = d
}
}
// Coriolis deflection
let deflect = 0
if (coriolisScale > 0.5) {
deflect = hemiSign > 0 ? 1 : -1
} else if (coriolisScale > 0.15 && hemiSign > 0 && bestGrad > 5) {
deflect = 1
}
bestDir = ((bestDir + deflect) % 6 + 6) % 6
tile.wind_direction = bestDir
tile.wind_speed = Math.min(Math.max(bestGrad * speedScale, 0), 1)
}
}
private stepHumidity(grid: GridState): void {
const { tiles, width: w, height: h } = grid
const oceanEvap = this.p('ocean_evap_rate', 0.03)
const soilRate = this.p('soil_evap_rate', 0.01)
const forestEt = this.p('forest_et_rate', 0.01)
const transport = this.p('wind_humidity_transport', 0.1)
const precRh = this.p('precipitation_rh_threshold', 0.95)
const precHumLoss = this.p('precipitation_humidity_loss', 0.1)
const precMoistG = this.p('precipitation_moisture_gain', 0.05)
const humDecay = this.p('humidity_decay_rate', 0.02)
// Snapshot for double-buffering
const oldHum = new Float32Array(tiles.length)
for (let i = 0; i < tiles.length; i++) oldHum[i] = tiles[i].humidity ?? 0
for (let i = 0; i < tiles.length; i++) {
const tile = tiles[i]
let hum = oldHum[i]
// Sources
if (this.atmoIsWater(tile.biome_id)) {
hum += oceanEvap * tile.temperature
} else {
hum += soilRate * tile.moisture
const canopy = tile.canopy_cover ?? 0
if (canopy > 0.3) hum += forestEt * canopy
}
// Wind transport from upwind neighbor
const upwindDir = (tile.wind_direction + 3) % 6
const nbs = neighbors(tile.col, tile.row, w, h)
if (upwindDir < nbs.length) {
const upNb = nbs[upwindDir]
if (upNb) {
const upIdx = idx(upNb.col, upNb.row, w)
hum += transport * oldHum[upIdx] * tile.wind_speed
}
}
// Sinks
hum -= humDecay
// Precipitation
const satCap = 0.2 + tile.temperature * 0.8
const rh = Math.min(hum / Math.max(satCap, 0.001), 1.0)
if (rh >= precRh) {
hum -= precHumLoss
tile.moisture = Math.min(Math.max(tile.moisture + precMoistG, 0), 1)
}
tile.humidity = Math.min(Math.max(hum, 0), 1)
}
}
}

View file

@ -1,8 +1,36 @@
import { useEffect, useState, useRef, type CSSProperties } from 'react'
import {
forwardRef,
memo,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
type CSSProperties,
type ReactNode,
type RefObject,
} from 'react'
import { fetchRecentVariants, variantStreamUrl, rawImageUrl } from '../api'
import type { RecentVariant } from '../types'
import { colors } from './theme'
/* ── constants ─────────────────────────────────────────────────── */
const MIN_CARD_WIDTH = 220
const GRID_GAP = 12
const GRID_PADDING = 16
const FALLBACK_INFO_HEIGHT = 68
const CARD_ENTER_MS = 400
const CARD_SHIFT_MS = 300
const STAGGER_MS = 25
const FADE_OUT_MS = 300
const PAGE_BG = '#0a0a14'
/* ── helpers ───────────────────────────────────────────────────── */
function extractFilename(rawPath: string): string {
return rawPath.replace(/\\/g, '/').split('/').pop() ?? ''
}
@ -18,7 +46,7 @@ function parseScores(notes: string | null): Record<string, number> | null {
}
return Object.keys(result).length > 0 ? result : null
}
} catch { /* parse failure — no scores */ }
} catch { /* no scores */ }
return null
}
@ -29,126 +57,286 @@ function confidence(v: RecentVariant): number {
return vals.reduce((a, b) => a + b, 0) / vals.length
}
const page: CSSProperties = {
minHeight: '100vh',
background: '#0a0a14',
color: colors.text,
padding: 0,
function isLabeled(v: RecentVariant): boolean {
return v.rating !== null || v.is_approved
}
const headerStyle: CSSProperties = {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '12px 24px',
borderBottom: `1px solid ${colors.accent}`,
background: colors.bg,
/* ── responsive grid hook ──────────────────────────────────────── */
interface GridDims {
cols: number
rows: number
cardWidth: number
}
const gridStyle: CSSProperties = {
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(240px, 1fr))',
gap: '12px',
padding: '16px',
function useGridDimensions(
containerRef: RefObject<HTMLDivElement | null>,
onResize: () => void,
): GridDims {
const [dims, setDims] = useState<GridDims>({ cols: 0, rows: 0, cardWidth: 0 })
const onResizeRef = useRef(onResize)
onResizeRef.current = onResize
useEffect(() => {
const el = containerRef.current
if (!el) return
const compute = (): void => {
const rect = el.getBoundingClientRect()
const usableW = rect.width - 2 * GRID_PADDING
const usableH = rect.height - 2 * GRID_PADDING
const cols = Math.max(1, Math.floor((usableW + GRID_GAP) / (MIN_CARD_WIDTH + GRID_GAP)))
const cardWidth = (usableW - (cols - 1) * GRID_GAP) / cols
const cardHeight = cardWidth + INFO_HEIGHT
const rows = Math.max(1, Math.floor((usableH + GRID_GAP) / (cardHeight + GRID_GAP)))
setDims(prev => {
if (prev.cols === cols && prev.rows === rows && Math.abs(prev.cardWidth - cardWidth) < 1) return prev
return { cols, rows, cardWidth }
})
onResizeRef.current()
}
const ro = new ResizeObserver(compute)
ro.observe(el)
compute()
return () => ro.disconnect()
}, [containerRef])
return dims
}
const CARD_ENTER_DURATION = 400
const CARD_SHIFT_DURATION = 300
/* ── score badge ───────────────────────────────────────────────── */
function ScoreBadge({ label, value }: { label: string; value: number }) {
function ScoreBadge({ label, value, muted }: { label: string; value: number; muted: boolean }): ReactNode {
const bg = value >= 0.7 ? 'rgba(16,185,129,0.2)' : value >= 0.5 ? 'rgba(245,158,11,0.2)' : 'rgba(239,68,68,0.2)'
const fg = value >= 0.7 ? '#10b981' : value >= 0.5 ? '#f59e0b' : '#ef4444'
return (
<span style={{ fontSize: 9, padding: '1px 4px', borderRadius: 3, background: bg, color: fg }}>
<span style={{
fontSize: 9,
padding: '1px 4px',
borderRadius: 3,
background: muted ? bg.replace('0.2', '0.08') : bg,
color: muted ? fg + '88' : fg,
}}>
{label}: {(value * 100).toFixed(0)}
</span>
)
}
function Card({ v, isNew, index }: { v: RecentVariant; isNew: boolean; index: number }) {
const conf = confidence(v)
const scores = parseScores(v.notes)
/* ── card ───────────────────────────────────────────────────────── */
interface CardProps {
v: RecentVariant
isNew: boolean
index: number
cardWidth: number
}
const Card = memo(forwardRef<HTMLDivElement, CardProps>(function Card({ v, isNew, index, cardWidth }, ref) {
const labeled = isLabeled(v)
const conf = useMemo(() => confidence(v), [v.notes])
const scores = useMemo(() => parseScores(v.notes), [v.notes])
const passing = conf >= 0.7
const filename = extractFilename(v.raw_path)
const enterDelay = isNew ? 0 : index * 30
const animStyle: CSSProperties = {
transition: `transform ${CARD_SHIFT_DURATION}ms ease ${enterDelay}ms, opacity ${CARD_ENTER_DURATION}ms ease`,
...(isNew ? { animation: `fadeScaleIn ${CARD_ENTER_DURATION}ms ease forwards` } : {}),
}
const enterDelay = isNew ? 0 : index * STAGGER_MS
const animStyle: CSSProperties = isNew
? { animation: `fadeScaleIn ${CARD_ENTER_MS}ms ease forwards` }
: { transition: `transform ${CARD_SHIFT_MS}ms ease ${enterDelay}ms, opacity ${CARD_ENTER_MS}ms ease` }
const cardBg = labeled ? colors.surface : '#0d0d18'
const cardBorder = labeled
? (passing ? '2px solid #10b981' : v.is_approved ? '2px solid #8b5cf6' : '1px solid #1e293b')
: '1px solid #151528'
const cardShadow = labeled ? '0 4px 12px rgba(0,0,0,0.4)' : 'none'
const imgFilter = labeled ? 'none' : 'saturate(0.5) brightness(0.85)'
const imgOpacity = labeled ? 1 : 0.65
const textColor = labeled ? colors.muted : '#4a5060'
const entityColor = labeled ? colors.text : '#6a7080'
return (
<div style={{
background: colors.surface,
borderRadius: 8,
overflow: 'hidden',
border: passing ? '2px solid #10b981' : v.is_approved ? '2px solid #8b5cf6' : '1px solid #1e293b',
position: 'relative',
...animStyle,
}}>
<div
ref={ref}
style={{
background: cardBg,
borderRadius: 8,
overflow: 'hidden',
border: cardBorder,
position: 'relative',
boxShadow: cardShadow,
width: cardWidth,
willChange: 'transform',
...animStyle,
}}
>
<div style={{ position: 'relative' }}>
<img
src={rawImageUrl(filename)}
alt={v.entity_id}
style={{ width: '100%', display: 'block', aspectRatio: '1/1', objectFit: 'cover' }}
style={{
width: '100%',
display: 'block',
aspectRatio: '1/1',
objectFit: 'cover',
filter: imgFilter,
opacity: imgOpacity,
transition: 'filter 0.4s ease, opacity 0.4s ease',
}}
loading="lazy"
/>
{conf > 0 && (
<span style={{
position: 'absolute', top: 8, right: 8,
padding: '2px 8px', borderRadius: 12, fontSize: 11, fontWeight: 700,
background: passing ? '#10b981' : conf >= 0.5 ? '#f59e0b' : '#ef4444',
color: '#000',
}}>{(conf * 100).toFixed(0)}%</span>
background: labeled
? (passing ? '#10b981' : conf >= 0.5 ? '#f59e0b' : '#ef4444')
: (passing ? '#10b98166' : conf >= 0.5 ? '#f59e0b66' : '#ef444466'),
color: labeled ? '#000' : (passing ? '#10b981' : conf >= 0.5 ? '#f59e0b' : '#ef4444'),
}}>
{(conf * 100).toFixed(0)}%
</span>
)}
<span style={{
position: 'absolute', top: 8, left: 8,
padding: '2px 8px', borderRadius: 12, fontSize: 10, fontWeight: 600,
background: 'rgba(0,0,0,0.7)', color: colors.muted,
}}>{v.category}</span>
background: labeled ? 'rgba(0,0,0,0.7)' : 'rgba(0,0,0,0.3)',
color: labeled ? colors.muted : '#4a5060',
}}>
{v.category}
</span>
{v.is_approved && (
<span style={{
position: 'absolute', bottom: 8, right: 8,
padding: '2px 8px', borderRadius: 12, fontSize: 11, fontWeight: 700,
background: '#8b5cf6', color: '#000',
}}>APPROVED</span>
}}>
APPROVED
</span>
)}
</div>
<div style={{ padding: '8px 10px', fontSize: 11, color: colors.muted }}>
<div style={{ fontWeight: 600, color: colors.text, marginBottom: 2 }}>{v.entity_id}</div>
<div style={{ padding: '8px 10px', fontSize: 11, color: textColor }}>
<div style={{ fontWeight: 600, color: entityColor, marginBottom: 2 }}>
{v.entity_id}
</div>
<div>seed: {v.seed}</div>
{scores && (
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap', marginTop: 4 }}>
{Object.entries(scores).map(([k, val]) => (
<ScoreBadge key={k} label={k.replace('_', ' ').replace('production ', 'prod ')} value={val} />
<ScoreBadge
key={k}
label={k.replace('_', ' ').replace('production ', 'prod ')}
value={val}
muted={!labeled}
/>
))}
</div>
)}
</div>
</div>
)
}))
/* ── leaving card (fixed-position ghost) ───────────────────────── */
function LeavingCard({
v,
rect,
cardWidth,
}: {
v: RecentVariant
rect: DOMRect
cardWidth: number
}): ReactNode {
return (
<div
style={{
position: 'fixed',
left: rect.left,
top: rect.top,
width: rect.width,
zIndex: 0,
pointerEvents: 'none',
animation: `fadeOut ${FADE_OUT_MS}ms ease forwards`,
}}
>
<Card v={v} isNew={false} index={0} cardWidth={cardWidth} />
</div>
)
}
export default function SpriteTheaterPage() {
/* ── stable ref callback cache ─────────────────────────────────── */
function useCardRefCallbacks(cardRefs: React.MutableRefObject<Map<number, HTMLDivElement>>) {
const cache = useRef<Map<number, (el: HTMLDivElement | null) => void>>(new Map())
return useCallback((id: number): (el: HTMLDivElement | null) => void => {
let cb = cache.current.get(id)
if (!cb) {
cb = (el: HTMLDivElement | null): void => {
if (el) cardRefs.current.set(id, el)
else {
cardRefs.current.delete(id)
cache.current.delete(id)
}
}
cache.current.set(id, cb)
}
return cb
}, [cardRefs])
}
/* ── page component ────────────────────────────────────────────── */
export default function SpriteTheaterPage(): ReactNode {
const [variants, setVariants] = useState<RecentVariant[]>([])
const [newIds, setNewIds] = useState<Set<number>>(new Set())
const [connected, setConnected] = useState(false)
const eventSourceRef = useRef<EventSource | null>(null)
const [leaving, setLeaving] = useState<Array<{ v: RecentVariant; rect: DOMRect }>>([])
// Clear "new" flags after animation completes
const gridRef = useRef<HTMLDivElement>(null)
const cardRefs = useRef<Map<number, HTMLDivElement>>(new Map())
const prevRectsRef = useRef<Map<number, DOMRect>>(new Map())
const prevVisibleIdsRef = useRef<Set<number>>(new Set())
const isResizeRef = useRef(false)
const variantsRef = useRef(variants)
variantsRef.current = variants
const onResize = useCallback(() => { isResizeRef.current = true }, [])
const { cols, rows, cardWidth } = useGridDimensions(gridRef, onResize)
const visibleCount = cols * rows
const getCardRef = useCardRefCallbacks(cardRefs)
// Clear "new" flags after animation
useEffect(() => {
if (newIds.size === 0) return
const timer = setTimeout(() => setNewIds(new Set()), CARD_ENTER_DURATION + 200)
const timer = setTimeout(() => setNewIds(new Set()), CARD_ENTER_MS + 200)
return () => clearTimeout(timer)
}, [newIds])
// Clean up leaving items after animation
useEffect(() => {
if (leaving.length === 0) return
const timer = setTimeout(() => setLeaving([]), FADE_OUT_MS + 50)
return () => clearTimeout(timer)
}, [leaving])
// Capture current rects before state update
const captureRects = useCallback((): void => {
const rects = new Map<number, DOMRect>()
cardRefs.current.forEach((el, id) => {
rects.set(id, el.getBoundingClientRect())
})
prevRectsRef.current = rects
}, [])
// Initial fetch
useEffect(() => {
fetchRecentVariants(200).then(setVariants).catch(() => { /* initial load failed */ })
}, [])
// SSE connection
useEffect(() => {
const es = new EventSource(variantStreamUrl())
eventSourceRef.current = es
es.onopen = () => setConnected(true)
es.onerror = () => setConnected(false)
@ -156,6 +344,7 @@ export default function SpriteTheaterPage() {
es.onmessage = (event: MessageEvent<string>) => {
try {
const incoming: RecentVariant[] = JSON.parse(event.data) as RecentVariant[]
captureRects()
setVariants(prev => {
const existingIds = new Set(prev.map(v => v.variant_id))
const fresh = incoming.filter(v => !existingIds.has(v.variant_id))
@ -167,22 +356,121 @@ export default function SpriteTheaterPage() {
}
return () => es.close()
}, [])
}, [captureRects])
// Sort by recency: newest first (top-left), oldest last (bottom-right)
const sorted = [...variants].sort((a, b) =>
new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
// Memoize sorted + visible to prevent effect re-fire
const sorted = useMemo(
() => [...variants].sort((a, b) =>
new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
),
[variants],
)
const visible = useMemo(
() => visibleCount > 0 ? sorted.slice(0, visibleCount) : sorted.slice(0, 20),
[sorted, visibleCount],
)
// Stable key for visible set — only changes when the actual IDs change
const visibleKey = useMemo(
() => visible.map(v => v.variant_id).join(','),
[visible],
)
// Detect leaving items when visible set actually changes
useEffect(() => {
const newVisibleIds = new Set(visible.map(v => v.variant_id))
const prevIds = prevVisibleIdsRef.current
if (prevIds.size > 0 && prevRectsRef.current.size > 0) {
const departingItems: Array<{ v: RecentVariant; rect: DOMRect }> = []
for (const id of prevIds) {
if (!newVisibleIds.has(id)) {
const rect = prevRectsRef.current.get(id)
const variant = variantsRef.current.find(s => s.variant_id === id)
if (rect && variant) {
departingItems.push({ v: variant, rect })
}
}
}
if (departingItems.length > 0) {
setLeaving(departingItems)
}
}
prevVisibleIdsRef.current = newVisibleIds
}, [visibleKey, visible])
// FLIP animation after render
useLayoutEffect(() => {
const prev = prevRectsRef.current
if (prev.size === 0) return
if (isResizeRef.current) {
isResizeRef.current = false
prevRectsRef.current = new Map()
return
}
const visibleIds = visible.map(v => v.variant_id)
cardRefs.current.forEach((el, id) => {
const prevRect = prev.get(id)
if (!prevRect) return
const newRect = el.getBoundingClientRect()
const dx = prevRect.left - newRect.left
const dy = prevRect.top - newRect.top
if (Math.abs(dx) < 1 && Math.abs(dy) < 1) return
const idx = visibleIds.indexOf(id)
const delay = idx * STAGGER_MS
el.style.transition = 'none'
el.style.transform = `translate(${dx}px, ${dy}px)`
requestAnimationFrame(() => {
requestAnimationFrame(() => {
el.style.transition = `transform ${CARD_SHIFT_MS}ms cubic-bezier(0.25, 0.46, 0.45, 0.94) ${delay}ms`
el.style.transform = 'translate(0, 0)'
})
})
})
prevRectsRef.current = new Map()
}, [visibleKey, visible])
return (
<div style={page}>
<div style={{
height: '100vh',
background: PAGE_BG,
color: colors.text,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
}}>
<style>{`
@keyframes fadeScaleIn {
0% { opacity: 0; transform: scale(0.8); }
0% { opacity: 0; transform: scale(0.85); }
100% { opacity: 1; transform: scale(1); }
}
@keyframes fadeOut {
0% { opacity: 1; transform: scale(1); }
100% { opacity: 0; transform: scale(0.9) translateY(8px); }
}
`}</style>
<div style={headerStyle}>
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '12px 24px',
borderBottom: `1px solid ${colors.accent}`,
background: colors.bg,
height: HEADER_HEIGHT,
flexShrink: 0,
boxSizing: 'border-box',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<a href="/" style={{ color: colors.text, textDecoration: 'none', fontSize: 18, fontWeight: 700 }}>
Sprite Theater
@ -196,24 +484,62 @@ export default function SpriteTheaterPage() {
{connected ? 'Live' : 'Disconnected'} &mdash; {variants.length} sprites
</span>
</div>
<a href="/" style={{ color: colors.muted, textDecoration: 'none', fontSize: 13 }}>
Dashboard
</a>
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
<span style={{ fontSize: 11, color: colors.muted }}>
{cols}&times;{rows} grid &middot; {visibleCount} visible
</span>
<a href="/" style={{ color: colors.muted, textDecoration: 'none', fontSize: 13 }}>
Dashboard
</a>
</div>
</div>
{variants.length === 0 ? (
<div style={{ textAlign: 'center', padding: 80, color: colors.muted }}>
<div style={{ fontSize: 48, marginBottom: 16 }}>Waiting for sprites...</div>
<div>Generate sprites with <code style={{ color: colors.text }}>./run tools spritegen generate</code></div>
<div style={{ marginTop: 8 }}>New images will appear here in real-time</div>
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div style={{ textAlign: 'center', color: colors.muted }}>
<div style={{ fontSize: 48, marginBottom: 16 }}>Waiting for sprites...</div>
<div>Generate sprites with <code style={{ color: colors.text }}>./run tools spritegen generate</code></div>
<div style={{ marginTop: 8 }}>New images will appear here in real-time</div>
</div>
</div>
) : (
<div style={gridStyle}>
{sorted.map((v, i) => (
<Card key={v.variant_id} v={v} isNew={newIds.has(v.variant_id)} index={i} />
))}
<div
ref={gridRef}
style={{
flex: 1,
overflow: 'hidden',
padding: GRID_PADDING,
boxSizing: 'border-box',
}}
>
<div style={{
display: 'grid',
gridTemplateColumns: cols > 0 ? `repeat(${cols}, 1fr)` : `repeat(auto-fill, minmax(${MIN_CARD_WIDTH}px, 1fr))`,
gap: GRID_GAP,
alignContent: 'start',
}}>
{visible.map((v, i) => (
<Card
key={v.variant_id}
ref={getCardRef(v.variant_id)}
v={v}
isNew={newIds.has(v.variant_id)}
index={i}
cardWidth={cardWidth > 0 ? cardWidth : MIN_CARD_WIDTH}
/>
))}
</div>
</div>
)}
{leaving.map(({ v, rect }) => (
<LeavingCard
key={`leave-${v.variant_id}`}
v={v}
rect={rect}
cardWidth={cardWidth > 0 ? cardWidth : MIN_CARD_WIDTH}
/>
))}
</div>
)
}

View file

@ -0,0 +1,243 @@
"""
Atmosphere system TypeScript assembly pressure, wind, humidity.
Ports atmosphere.gd + atmosphere_anomalies.gd into methods on the
ClimatePhysics class. These run BEFORE temperature in processStep:
1. Baseline pressure from latitude bands + terrain
2. Dynamic pressure anomalies (spawn, drift, decay, merge)
3. Pressure-gradient wind with Coriolis deflection
4. Humidity: evaporation, transport, precipitation
Source GDScript:
engine/src/modules/climate/atmosphere.gd
engine/src/modules/climate/atmosphere_anomalies.gd
"""
def _atmo_build_methods() -> str:
"""Return TypeScript methods to splice into ClimatePhysics class body."""
return "\n".join([
_atmo_state_fields(),
_step_baseline_pressure(),
_step_anomalies(),
_step_wind_from_pressure(),
_step_humidity(),
])
def _atmo_process_calls() -> list[str]:
"""Return the step call lines to insert into processStep."""
return [
" this.stepBaselinePressure(grid)",
" this.stepAnomalies(grid, turn)",
" this.stepWindFromPressure(grid)",
" this.stepHumidity(grid)",
]
def _atmo_state_fields() -> str:
return """
// -- Atmosphere state --
private anomalyAge = new Map<number, number>()
private thermalSustain = new Map<number, number>()
"""
def _step_baseline_pressure() -> str:
return """
private stepBaselinePressure(grid: GridState): void {
const { tiles, width: w, height: h } = grid
const polarHi = this.p('polar_high_pressure', 1030.0)
const subpolarLo = this.p('subpolar_low_pressure', 995.0)
const subtropHi = this.p('subtropical_high_pressure', 1025.0)
const itczLo = this.p('itcz_low_pressure', 1005.0)
const mtnBoost = this.p('mountain_pressure_boost', 5.0)
const oceanOff = this.p('ocean_pressure_offset', -2.0)
const heatOff = this.p('heated_land_pressure_offset', -3.0)
const heatThresh = this.p('heated_land_temp_threshold', 0.6)
for (const tile of tiles) {
const latFrac = h > 1 ? tile.row / (h - 1) : 0.5
let baseline: number
if (latFrac < 0.15) baseline = polarHi + (subpolarLo - polarHi) * (latFrac / 0.15)
else if (latFrac < 0.30) baseline = subpolarLo
else if (latFrac < 0.45) baseline = subpolarLo + (subtropHi - subpolarLo) * ((latFrac - 0.30) / 0.15)
else if (latFrac < 0.60) baseline = subtropHi
else if (latFrac < 0.85) baseline = subtropHi + (itczLo - subtropHi) * ((latFrac - 0.60) / 0.25)
else baseline = itczLo
const isW = this.atmoIsWater(tile.biome_id)
if (isW) {
baseline += oceanOff
} else {
if (tile.elevation > 0.5) baseline += ((tile.elevation - 0.5) / 0.1) * mtnBoost
if (tile.temperature > heatThresh) baseline += heatOff
}
tile.pressure = baseline + (tile.pressure_anomaly ?? 0)
}
}
private atmoIsWater(biome: string): boolean {
return biome === 'ocean' || biome === 'coast' || biome === 'lake' ||
biome === 'deep_ocean' || biome === 'shallow_ocean' || biome === 'coral_reef' ||
biome === 'estuary' || biome === 'pond' || biome === 'river' || biome === 'mangrove' ||
biome === 'inland_sea'
}
"""
def _step_anomalies() -> str:
return """
private stepAnomalies(grid: GridState, turn: number): void {
const { tiles } = grid
const thermalLowTemp = this.p('thermal_low_temp_threshold', 0.65)
const coldHighTemp = this.p('cold_high_temp_threshold', 0.2)
const coldHighHum = this.p('cold_high_humidity_threshold', 0.3)
const sustainNeeded = this.p('thermal_low_sustain_turns', 3)
const thermalLowVal = this.p('thermal_low_anomaly', -15.0)
const coldHighVal = this.p('cold_high_anomaly', 15.0)
const decayRate = this.p('anomaly_decay_rate', 2.0)
const reinforceRate = this.p('anomaly_reinforce_rate', 3.0)
const maxAnomaly = this.p('anomaly_max', 50.0)
for (let i = 0; i < tiles.length; i++) {
const tile = tiles[i]
let pa = tile.pressure_anomaly ?? 0
const isHot = tile.temperature > thermalLowTemp
const isCold = tile.temperature < coldHighTemp &&
(tile.humidity ?? 0) < coldHighHum &&
!this.atmoIsWater(tile.biome_id)
if (isHot || isCold) {
this.thermalSustain.set(i, (this.thermalSustain.get(i) ?? 0) + 1)
} else {
this.thermalSustain.delete(i)
}
const sustain = this.thermalSustain.get(i) ?? 0
// Spawn
if (pa === 0 && sustain >= sustainNeeded) {
if (isHot) pa = thermalLowVal
else if (isCold) pa = coldHighVal
}
// Decay / reinforce
if (pa !== 0) {
const condMet = (pa < 0 && tile.temperature > thermalLowTemp) ||
(pa > 0 && tile.temperature < coldHighTemp)
if (condMet && sustain > 0) {
pa = pa < 0 ? Math.max(pa - reinforceRate, -maxAnomaly)
: Math.min(pa + reinforceRate, maxAnomaly)
} else {
pa = pa < 0 ? Math.min(pa + decayRate, 0) : Math.max(pa - decayRate, 0)
}
}
tile.pressure_anomaly = pa
tile.pressure = (tile.pressure ?? 1013) + pa
}
}
"""
def _step_wind_from_pressure() -> str:
return """
private stepWindFromPressure(grid: GridState): void {
const { tiles, width: w, height: h } = grid
const speedScale = this.p('wind_speed_scale', 0.08)
const coriolisScale = this.p('coriolis_scale', 0.3)
for (const tile of tiles) {
const latFrac = h > 1 ? tile.row / (h - 1) : 0.5
const hemiSign = latFrac < 0.5 ? 1 : -1
// Find neighbor with steepest pressure gradient
let bestDir = tile.wind_direction
let bestGrad = 0
const nbs = neighbors(tile.col, tile.row, w, h)
for (let d = 0; d < nbs.length; d++) {
const nb = nbs[d]
const ni = idx(nb.col, nb.row, w)
const grad = (tile.pressure ?? 1013) - (tiles[ni].pressure ?? 1013)
if (grad > bestGrad) {
bestGrad = grad
bestDir = d
}
}
// Coriolis deflection
let deflect = 0
if (coriolisScale > 0.5) {
deflect = hemiSign > 0 ? 1 : -1
} else if (coriolisScale > 0.15 && hemiSign > 0 && bestGrad > 5) {
deflect = 1
}
bestDir = ((bestDir + deflect) % 6 + 6) % 6
tile.wind_direction = bestDir
tile.wind_speed = Math.min(Math.max(bestGrad * speedScale, 0), 1)
}
}
"""
def _step_humidity() -> str:
return """
private stepHumidity(grid: GridState): void {
const { tiles, width: w, height: h } = grid
const oceanEvap = this.p('ocean_evap_rate', 0.03)
const soilRate = this.p('soil_evap_rate', 0.01)
const forestEt = this.p('forest_et_rate', 0.01)
const transport = this.p('wind_humidity_transport', 0.1)
const precRh = this.p('precipitation_rh_threshold', 0.95)
const precHumLoss = this.p('precipitation_humidity_loss', 0.1)
const precMoistG = this.p('precipitation_moisture_gain', 0.05)
const humDecay = this.p('humidity_decay_rate', 0.02)
// Snapshot for double-buffering
const oldHum = new Float32Array(tiles.length)
for (let i = 0; i < tiles.length; i++) oldHum[i] = tiles[i].humidity ?? 0
for (let i = 0; i < tiles.length; i++) {
const tile = tiles[i]
let hum = oldHum[i]
// Sources
if (this.atmoIsWater(tile.biome_id)) {
hum += oceanEvap * tile.temperature
} else {
hum += soilRate * tile.moisture
const canopy = tile.canopy_cover ?? 0
if (canopy > 0.3) hum += forestEt * canopy
}
// Wind transport from upwind neighbor
const upwindDir = (tile.wind_direction + 3) % 6
const nbs = neighbors(tile.col, tile.row, w, h)
if (upwindDir < nbs.length) {
const upNb = nbs[upwindDir]
if (upNb) {
const upIdx = idx(upNb.col, upNb.row, w)
hum += transport * oldHum[upIdx] * tile.wind_speed
}
}
// Sinks
hum -= humDecay
// Precipitation
const satCap = 0.2 + tile.temperature * 0.8
const rh = Math.min(hum / Math.max(satCap, 0.001), 1.0)
if (rh >= precRh) {
hum -= precHumLoss
tile.moisture = Math.min(Math.max(tile.moisture + precMoistG, 0), 1)
}
tile.humidity = Math.min(Math.max(hum, 0), 1)
}
}
"""

View file

@ -1,11 +1,11 @@
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.11"
# dependencies = ["lilith-gdscript-transpiler>=1.1.0"]
#
# [tool.uv]
# extra-index-url = ["https://forge.nasty.sh/api/packages/lilith/pypi/simple/"]
# dependencies = ["lilith-gdscript-transpiler @ file:///var/home/lilith/Code/@packages/@py/gdscript-transpiler"]
# ///
# TODO: publish 1.1.1 to forge.nasty.sh, then switch back to:
# dependencies = ["lilith-gdscript-transpiler>=1.1.1"]
# [tool.uv] extra-index-url = ["https://forge.nasty.sh/api/packages/lilith/pypi/simple/"]
"""
Magic Civilization engine transpiler thin CLI.
@ -36,6 +36,7 @@ from lilith_gdscript_transpiler import (
)
from mapgen_assembly import _mg_build_full_output
from ecology_assembly import _eco_build_full_output
from atmosphere_assembly import _atmo_build_methods, _atmo_process_calls
REPO = Path(__file__).resolve().parent.parent.parent
@ -49,25 +50,28 @@ SOURCES = {
"ecological_events": REPO / "engine/src/modules/climate/ecological_events.gd",
"anchor_decay": REPO / "engine/src/modules/climate/anchor_decay.gd",
"climate_spec_eval": REPO / "engine/src/modules/climate/climate_spec_eval.gd",
# TODO: Add atmosphere.gd + atmosphere_anomalies.gd once assembly template
# includes stepBaselinePressure → stepAnomalies → stepWindFromPressure → stepHumidity
# before stepTemperature. This will make wind_speed dynamic per turn.
}
OUTPUT = REPO / "packages/engine-ts/src/ClimatePhysics.generated.ts"
REQUIRED_GD_FUNCTIONS: list[tuple[str, str]] = [
("climate_base", "_update_temperatures"),
("climate_base", "_update_lake_thermal_effects"),
("climate_base", "_update_moisture_wind"),
("climate", "_update_moisture_rivers"),
("climate", "_update_lake_evaporation"),
("climate", "_update_deep_earth_water"),
("climate", "_update_precipitation"),
("climate_base", "_check_terrain_evolution"),
("climate", "_compute_global_stats"),
("climate", "_clear_magic_deltas"),
("climate_spec_eval", "ideal_terrain"),
("climate_spec_eval", "ley_channeling_mult"),
("ecological_events", "process_events"),
("anchor_decay", "process_decay"),
("climate_base", "_update_temperatures"),
("climate_base", "_update_lake_thermal_effects"),
("climate_base", "_update_moisture_wind"),
("climate", "_update_moisture_rivers"),
("climate", "_update_lake_evaporation"),
("climate", "_update_deep_earth_water"),
("climate", "_update_precipitation"),
("climate_base", "_check_terrain_evolution"),
("climate", "_compute_global_stats"),
("climate", "_clear_magic_deltas"),
("climate_spec_eval", "ideal_terrain"),
("climate_spec_eval", "ley_channeling_mult"),
("ecological_events", "process_events"),
("anchor_decay", "process_decay"),
]
# ---------------------------------------------------------------------------
@ -348,6 +352,11 @@ export class ClimatePhysics {
this.stepCollectMagicForcing(grid)
this.stepOrbitalForcing(grid, turn)
this.stepAerosolForcing(grid)
// Atmosphere: pressure anomalies wind humidity (before temperature)
this.stepBaselinePressure(grid)
this.stepAnomalies(grid, turn)
this.stepWindFromPressure(grid)
this.stepHumidity(grid)
this.stepTemperature(grid)
this.stepLakeThermal(grid)
this.stepMoistureWind(grid)
@ -933,6 +942,9 @@ def assemble(fns: dict[str, dict[str, str]]) -> str:
parts.append(_emit_ecological_events())
parts.append("\n")
parts.append(_emit_anchor_decay())
parts.append("\n")
# Atmosphere methods (pressure, anomalies, wind, humidity)
parts.append(_atmo_build_methods())
parts.append("\n}\n")
result = "".join(parts)
@ -1032,34 +1044,31 @@ mapgen_config = TranspilerConfig(
# ===========================================================================
ECOLOGY_SOURCES = {
"flora": REPO / "engine/src/modules/ecology/flora.gd",
"fauna": REPO / "engine/src/modules/ecology/fauna.gd",
"ecosystem": REPO / "engine/src/modules/ecology/ecosystem.gd",
"flora": REPO / "engine/src/modules/ecology/flora.gd",
"fauna_simplified": REPO / "engine/src/modules/ecology/fauna_simplified.gd",
"ecosystem_simplified": REPO / "engine/src/modules/ecology/ecosystem_simplified.gd",
}
ECOLOGY_OUTPUT = REPO / "packages/engine-ts/src/EcologyPhysics.generated.ts"
REQUIRED_ECOLOGY_FUNCTIONS: list[tuple[str, str]] = [
("flora", "process_turn"),
("flora", "_tick_canopy"),
("flora", "_tick_undergrowth"),
("flora", "_tick_fungi"),
("flora", "_tick_succession"),
("flora", "_tick_desertification"),
("flora", "_tick_regrowth"),
("ecosystem", "process_turn"),
("ecosystem", "_compute_tile_quality"),
("ecosystem", "_compute_global_health"),
("flora", "tick_canopy"),
("flora", "tick_undergrowth"),
("flora", "tick_fungi"),
("flora", "tick_succession"),
("flora", "tick_desertification"),
("flora", "tick_regrowth"),
# fauna_simplified.gd and ecosystem_simplified.gd may not exist yet —
# ecology_assembly.py handles missing files with fallback hand-written sections.
]
def assemble_ecology(fns: dict[str, dict[str, str]]) -> str:
"""Assemble EcologyPhysics.generated.ts from ecology GDScript.
The ecology system uses Dictionary-keyed tile maps, DataLoader, EventBus,
and SQLite none of which exist in TypeScript. All sections are hand-written
TypeScript that faithfully implements the GDScript ecology pipeline on flat
TileState[] arrays.
Flora tick functions are auto-transpiled from flora.gd.
Fauna and ecosystem sections are auto-transpiled from *_simplified.gd when those
files exist, or fall back to hand-written TypeScript equivalents.
"""
return _eco_build_full_output()