From 41e9c760285f80b1105dcc3aa67cab075aaeb97f Mon Sep 17 00:00:00 2001 From: jenstandstad Date: Mon, 6 Jul 2026 13:11:48 +0200 Subject: [PATCH] Adding first working version --- README.md | 15 +- backend/admin/src/CaRenderer.tsx | 5 +- backend/admin/src/PresetNodeTree.tsx | 16 +- backend/admin/src/caEngineSteps/cellGrid.ts | 5 + .../admin/src/caEngineSteps/elementary1d.ts | 30 + backend/admin/src/caEngineSteps/naga.ts | 420 ++ backend/admin/src/caEngineSteps/noop.ts | 6 + .../src/caEngineSteps/outerTotalistic2d.ts | 45 + backend/admin/src/caEngineSteps/wildfire2d.ts | 49 + backend/admin/src/caEngines.ts | 162 +- .../admin/src/caInitialConditionGenerators.ts | 128 + backend/admin/src/main.tsx | 531 ++- backend/admin/src/styles.css | 254 +- backend/admin/src/voxel3d/Voxel3DRenderer.tsx | 15 +- .../admin/src/voxel3d/voxelRendererCore.ts | 80 +- backend/dist/loadEnv.js | 44 + backend/dist/loadEnv.js.map | 1 + backend/dist/server.js | 2 + backend/dist/server.js.map | 2 +- backend/public/admin/assets/index-Bo_SPfbl.js | 4116 ----------------- .../public/admin/assets/index-C7KqEE0-.css | 1 - .../public/admin/assets/index-D7EhCe8j.css | 1 + backend/public/admin/assets/index-nVGDjB10.js | 4116 +++++++++++++++++ backend/public/admin/index.html | 4 +- backend/scripts/load-env.mjs | 49 + backend/scripts/migrate.mjs | 20 + backend/src/loadEnv.ts | 49 + .../009_seed_more_elementary_rules.sql | 109 + .../src/migrations/010_seed_naga_engine.sql | 43 + .../migrations/011_seed_naga_markov_icg.sql | 49 + .../012_add_naga_edge_wrap_setting.sql | 13 + backend/src/server.ts | 3 + backend/test/caEngines.test.ts | 72 +- .../test/caInitialConditionGenerators.test.ts | 48 +- backend/test/caStudioApi.test.ts | 92 + backend/test/caStudioRepository.test.ts | 7 +- 36 files changed, 6211 insertions(+), 4391 deletions(-) create mode 100644 backend/admin/src/caEngineSteps/cellGrid.ts create mode 100644 backend/admin/src/caEngineSteps/elementary1d.ts create mode 100644 backend/admin/src/caEngineSteps/naga.ts create mode 100644 backend/admin/src/caEngineSteps/noop.ts create mode 100644 backend/admin/src/caEngineSteps/outerTotalistic2d.ts create mode 100644 backend/admin/src/caEngineSteps/wildfire2d.ts create mode 100644 backend/dist/loadEnv.js create mode 100644 backend/dist/loadEnv.js.map delete mode 100644 backend/public/admin/assets/index-Bo_SPfbl.js delete mode 100644 backend/public/admin/assets/index-C7KqEE0-.css create mode 100644 backend/public/admin/assets/index-D7EhCe8j.css create mode 100644 backend/public/admin/assets/index-nVGDjB10.js create mode 100644 backend/scripts/load-env.mjs create mode 100644 backend/src/loadEnv.ts create mode 100644 backend/src/migrations/009_seed_more_elementary_rules.sql create mode 100644 backend/src/migrations/010_seed_naga_engine.sql create mode 100644 backend/src/migrations/011_seed_naga_markov_icg.sql create mode 100644 backend/src/migrations/012_add_naga_edge_wrap_setting.sql diff --git a/README.md b/README.md index 0b10ff0..f1515e7 100644 --- a/README.md +++ b/README.md @@ -13,23 +13,28 @@ The original embeddable GlitchComponent has been moved to the sibling folder: Start the database: ```bash -cd backend -docker compose up -d +docker compose up -d postgres +``` + +Create `.env` from the local example: + +```bash +cp .env.example .env ``` Run migrations: ```bash -DATABASE_URL=postgres://ca_studio:ca_studio@localhost:54329/ca_studio_test npm run migrate +npm run migrate ``` Start the API: ```bash -DATABASE_URL=postgres://ca_studio:ca_studio@localhost:54329/ca_studio_test npm run dev +npm run dev ``` -In another terminal, start the admin UI: +In another terminal, start only the admin UI if needed: ```bash npm run admin:dev diff --git a/backend/admin/src/CaRenderer.tsx b/backend/admin/src/CaRenderer.tsx index cca7a37..88577cc 100644 --- a/backend/admin/src/CaRenderer.tsx +++ b/backend/admin/src/CaRenderer.tsx @@ -531,7 +531,10 @@ registerCaRendererRuntime( id: 'voxel-3d', label: 'Voxel 3D', aliases: ['three-voxel'], - supportedClasses: [{ dimensions: 3, states: 2 }], + supportedClasses: [ + { dimensions: 3, states: 2 }, + { dimensions: 3, states: 7 } + ], Component: Voxel3DRendererAdapter }, { replace: true } diff --git a/backend/admin/src/PresetNodeTree.tsx b/backend/admin/src/PresetNodeTree.tsx index 147f337..caec815 100644 --- a/backend/admin/src/PresetNodeTree.tsx +++ b/backend/admin/src/PresetNodeTree.tsx @@ -570,9 +570,19 @@ function NodePropertiesModal({

Preset Editor

Edit preset

- + + _ + + +
diff --git a/backend/admin/src/caEngineSteps/cellGrid.ts b/backend/admin/src/caEngineSteps/cellGrid.ts new file mode 100644 index 0000000..8297d3b --- /dev/null +++ b/backend/admin/src/caEngineSteps/cellGrid.ts @@ -0,0 +1,5 @@ +import type { Cells } from '../types.js' + +export function emptyCellsLike(cells: Cells): Cells { + return cells.map((row) => row.map(() => false)) +} diff --git a/backend/admin/src/caEngineSteps/elementary1d.ts b/backend/admin/src/caEngineSteps/elementary1d.ts new file mode 100644 index 0000000..3227523 --- /dev/null +++ b/backend/admin/src/caEngineSteps/elementary1d.ts @@ -0,0 +1,30 @@ +import { cloneCells, resolveBoundaryCondition, resolveBoundaryIndex } from '../caRuntime.js' +import type { Cells, SceneParams } from '../types.js' + +function parseElementaryRule(ruleId: string | undefined) { + const match = /(?:rule[-_\s]*)?(\d{1,3})/i.exec(ruleId ?? '') + if (!match) return 110 + return Math.max(0, Math.min(255, Number(match[1]))) +} + +export function stepElementary1d(cells: Cells, settings: SceneParams) { + const rule = parseElementaryRule(settings.simulation?.ruleId) + const next = cloneCells(cells) + const width = cells[0]?.length ?? 0 + if (cells.length === 0 || width === 0) return next + const boundary = resolveBoundaryCondition(settings.simulation?.grid) + const sourceRowIndex = Math.max(0, cells.findIndex((row) => row.some(Boolean))) + const sourceRow = cells[sourceRowIndex] ?? [] + + for (let x = 0; x < width; x += 1) { + const leftIndex = resolveBoundaryIndex(x - 1, width, boundary) + const rightIndex = resolveBoundaryIndex(x + 1, width, boundary) + const left = leftIndex === null ? 0 : sourceRow[leftIndex] ? 1 : 0 + const center = sourceRow[x] ? 1 : 0 + const right = rightIndex === null ? 0 : sourceRow[rightIndex] ? 1 : 0 + const pattern = (left << 2) | (center << 1) | right + next[sourceRowIndex][x] = ((rule >> pattern) & 1) === 1 + } + + return next +} diff --git a/backend/admin/src/caEngineSteps/naga.ts b/backend/admin/src/caEngineSteps/naga.ts new file mode 100644 index 0000000..b7f4b79 --- /dev/null +++ b/backend/admin/src/caEngineSteps/naga.ts @@ -0,0 +1,420 @@ +import { resolveBoundaryCondition, resolveBoundaryIndex, seededRandom } from '../caRuntime.js' +import type { BoundaryCondition, Cells, JsonObject, SceneParams } from '../types.js' + +export type NagaArrow = 1 | 2 | 3 | 4 | 5 | 6 +export type NagaSymbol = NagaArrow | null +export type NagaPosition = readonly [number, number, number] +export type NagaUniverse = Map + +export interface FollowResult { + changes: number + emits: number + comment: string +} + +export interface EvolveResult extends FollowResult {} + +interface NagaTopology { + boundary: BoundaryCondition + size: readonly [number, number, number] +} + +export class Moykle { + position: NagaPosition + forward: NagaSymbol + + constructor(position: NagaPosition, arrow: NagaSymbol) { + this.position = position + this.forward = arrow + } +} + +export class Universe { + readonly cells = new Map() + + get(position: NagaPosition) { + return this.cells.get(addr(position))?.forward ?? null + } + + set(moykle: Moykle) { + this.cells.set(addr(moykle.position), moykle) + } +} + +function isArrow(value: unknown): value is NagaArrow { + return value === 1 || value === 2 || value === 3 || value === 4 || value === 5 || value === 6 +} + +function addPosition(left: NagaPosition, right: NagaPosition): NagaPosition { + return [left[0] + right[0], left[1] + right[1], left[2] + right[2]] +} + +function subtractPosition(left: NagaPosition, right: NagaPosition): NagaPosition { + return [left[0] - right[0], left[1] - right[1], left[2] - right[2]] +} + +function resolvePosition(position: NagaPosition, topology: NagaTopology): NagaPosition | null { + const x = resolveBoundaryIndex(position[0], topology.size[0], topology.boundary) + const y = resolveBoundaryIndex(position[1], topology.size[1], topology.boundary) + const z = resolveBoundaryIndex(position[2], topology.size[2], topology.boundary) + if (x === null || y === null || z === null) return null + return [x, y, z] +} + +function translatePosition(position: NagaPosition, offset: NagaPosition, topology: NagaTopology) { + return resolvePosition(addPosition(position, offset), topology) +} + +function reverseTranslatePosition(position: NagaPosition, offset: NagaPosition, topology: NagaTopology) { + return resolvePosition(subtractPosition(position, offset), topology) +} + +export function opposite(symbol: NagaArrow): NagaArrow { + return (((symbol + 2) % 6) + 1) as NagaArrow +} + +export function fVec(symbol: NagaSymbol | 0): NagaPosition | null { + if (symbol === 1) return [1, 0, 0] + if (symbol === 2) return [0, 1, 0] + if (symbol === 3) return [0, 0, 1] + if (symbol === 4) return [-1, 0, 0] + if (symbol === 5) return [0, -1, 0] + if (symbol === 6) return [0, 0, -1] + return null +} + +export function addr(position: NagaPosition) { + return `${position[0]}_${position[1]}_${position[2]}` +} + +export function isHead(universe: NagaUniverse, moykle: Moykle, _time: number, topology: NagaTopology) { + let adjacentsThatPointToMoykle = 0 + if (moykle.forward === null) return false + + for (let i = 1 as NagaArrow; i <= 6; i = (i + 1) as NagaArrow) { + const offset = fVec(i) + if (!offset) continue + + const adjacentPosition = translatePosition(moykle.position, offset, topology) + if (!adjacentPosition) continue + + const adjacent = universe.get(addr(adjacentPosition)) + if (!adjacent || adjacent.forward === null) continue + + if (adjacent.forward !== opposite(i) && i !== moykle.forward) { + adjacentsThatPointToMoykle += 1 + } + } + + return adjacentsThatPointToMoykle === 0 +} + +export function getAllHeads(universe: NagaUniverse, _time: number, topology: NagaTopology) { + const heads: Moykle[] = [] + + for (const moykle of universe.values()) { + let apprentice = 0 + let master = 0 + if (moykle.forward === null) continue + + for (let i = 1 as NagaArrow; i <= 6; i = (i + 1) as NagaArrow) { + const offset = fVec(i) + if (!offset) continue + + const adjacentPosition = translatePosition(moykle.position, offset, topology) + if (!adjacentPosition) continue + + const adjacent = universe.get(addr(adjacentPosition)) + if (!adjacent || adjacent.forward === null) continue + + if (adjacent.forward === opposite(i)) master += 1 + if (i === moykle.forward) apprentice += 1 + } + + if (apprentice <= 1 && master === 0) heads.push(moykle) + } + + return heads +} + +export function moykleNumber(universe: NagaUniverse, moykle: Moykle, _time: number, topology: NagaTopology) { + let pointsToOccupied = 0 + let pointedAtBy = 0 + + for (let i = 1 as NagaArrow; i <= 6; i = (i + 1) as NagaArrow) { + const offset = fVec(i) + if (!offset) continue + + const adjacentPosition = translatePosition(moykle.position, offset, topology) + if (!adjacentPosition) continue + + const adjacent = universe.get(addr(adjacentPosition)) + if (!adjacent || adjacent.forward === null) continue + + if (adjacent.forward === opposite(i)) pointedAtBy += 1 + if (i === moykle.forward) pointsToOccupied += 1 + } + + return [pointsToOccupied, pointedAtBy] as const +} + +export function createNagaRandom(seed: string | number = 100) { + return seededRandom(String(seed)) +} + +export function evolve(universe: NagaUniverse, time: number, topology: NagaTopology, random = createNagaRandom()): EvolveResult { + const headList = getAllHeads(universe, time, topology) + if (headList.length === 0) throw new Error('Cannot evolve, no heads') + + const selected = headList[Math.floor(random() * headList.length)] ?? headList[0] + return follow(universe, selected, time, topology, 0) +} + +export function follow(universe: NagaUniverse, head: Moykle, time: number, topology: NagaTopology, _depth: number): FollowResult { + let changes = 0 + let emits = 0 + let current = head + let tail = head + let tailSearching = true + const tailVisited = new Set() + + while (tailSearching) { + const currentAddress = addr(current.position) + if (tailVisited.has(currentAddress)) { + tailSearching = false + tail = current + break + } + tailVisited.add(currentAddress) + + if (current.forward === null) break + + const offset = fVec(current.forward) + if (!offset) break + + const nextPosition = translatePosition(current.position, offset, topology) + if (!nextPosition) break + + const next = universe.get(addr(nextPosition)) + + if (next && isArrow(next.forward)) { + current = next + tailSearching = true + } else { + tailSearching = false + tail = current + } + } + + if (tail.forward === null) { + return { + changes, + emits, + comment: `T:${time} | Tail has no symbol to move.` + } + } + + const tailOffset = fVec(tail.forward) + if (!tailOffset) { + return { + changes, + emits, + comment: `T:${time} | Tail symbol has no direction.` + } + } + + const placePosition = reverseTranslatePosition(head.position, tailOffset, topology) + if (!placePosition) { + return { + changes, + emits, + comment: `T:${time} | Tail would move outside fixed boundary.` + } + } + const placeAddress = addr(placePosition) + if (!universe.has(placeAddress)) { + universe.set(placeAddress, new Moykle(placePosition, null)) + } + const place = universe.get(placeAddress) + if (!place) throw new Error(`Unable to create naga place cell at ${placeAddress}`) + + if (head.forward === opposite(tail.forward)) { + let comment = `T:${time} | Emission! ${place.position.join(',')} implied contradiction.` + let kinkSearching = true + current = universe.get(addr(head.position)) ?? head + const kinkVisited = new Set() + + while (kinkSearching) { + const currentAddress = addr(current.position) + if (kinkVisited.has(currentAddress)) break + kinkVisited.add(currentAddress) + + if (current.forward === null) break + + const currentOffset = fVec(current.forward) + if (!currentOffset) break + + const nextPosition = translatePosition(current.position, currentOffset, topology) + if (!nextPosition) break + + const next = universe.get(addr(nextPosition)) + + if (next && isArrow(next.forward)) { + current = next + const kinkPlacePosition = reverseTranslatePosition(current.position, tailOffset, topology) + if (!kinkPlacePosition) { + kinkSearching = false + break + } + const kinkPlace = universe.get(addr(kinkPlacePosition)) + kinkSearching = Boolean(kinkPlace && isArrow(kinkPlace.forward)) + } else { + kinkSearching = false + } + } + + const kinkPlacePosition = reverseTranslatePosition(current.position, tailOffset, topology) + if (!kinkPlacePosition) { + return { + changes, + emits, + comment: `T:${time} | Kink would move outside fixed boundary.` + } + } + const kinkPlaceAddress = addr(kinkPlacePosition) + if (!universe.has(kinkPlaceAddress)) { + universe.set(kinkPlaceAddress, new Moykle(kinkPlacePosition, null)) + } + + const kinkPlace = universe.get(kinkPlaceAddress) + if (!kinkPlace) throw new Error(`Unable to create naga kink place cell at ${kinkPlaceAddress}`) + + const temp = kinkPlace.forward + kinkPlace.forward = tail.forward + tail.forward = temp + emits += 1 + changes += 1 + comment += ` Kink at ${kinkPlacePosition.join(',')}, swapped ${kinkPlace.forward} for ${temp}.` + + return { changes, emits, comment } + } + + const comment = `T:${time} | Take tail symbol ${tail.forward} from ${tail.position.join(',')}, put into ${place.position.join(',')} in exchange for ${place.forward}` + const temp = place.forward + place.forward = tail.forward + tail.forward = temp + changes += 1 + + return { changes, emits, comment } +} + +function gridSize(settings: SceneParams, cells: Cells) { + const size = settings.simulation?.grid?.size + const width = Array.isArray(size) && typeof size[0] === 'number' ? size[0] : cells[0]?.length ?? 24 + const height = Array.isArray(size) && typeof size[1] === 'number' ? size[1] : cells.length || 24 + const depth = Array.isArray(size) && typeof size[2] === 'number' ? size[2] : 24 + return [Math.max(1, width), Math.max(1, height), Math.max(1, depth)] as const +} + +function coordinateSymbol(coordinate: number[]) { + const state = coordinate[3] ?? 1 + return isArrow(state) ? state : 1 +} + +function universeFromCoordinates(coordinates: number[][] | undefined) { + const universe: NagaUniverse = new Map() + + for (const coordinate of coordinates ?? []) { + const [x, y, z = 0] = coordinate + if (!Number.isInteger(x) || !Number.isInteger(y) || !Number.isInteger(z)) continue + + const position: NagaPosition = [x, y, z] + universe.set(addr(position), new Moykle(position, coordinateSymbol(coordinate))) + } + + return universe +} + +function seedUniverseFromCells(cells: Cells) { + const universe: NagaUniverse = new Map() + + for (let y = 0; y < cells.length; y += 1) { + for (let x = 0; x < (cells[y]?.length ?? 0); x += 1) { + if (!cells[y][x]) continue + const position: NagaPosition = [x, y, 0] + universe.set(addr(position), new Moykle(position, 1)) + } + } + + return universe +} + +function coordinatesFromUniverse(universe: NagaUniverse, size: readonly [number, number, number]) { + const coordinates: number[][] = [] + + for (const moykle of universe.values()) { + const [x, y, z] = moykle.position + if (moykle.forward === null) continue + if (x < 0 || y < 0 || z < 0 || x >= size[0] || y >= size[1] || z >= size[2]) continue + coordinates.push([x, y, z, moykle.forward]) + } + + return coordinates +} + +function projectCoordinatesToCells(coordinates: number[][], cells: Cells) { + const next = cells.map((row) => row.map(() => false)) + + for (const coordinate of coordinates) { + const [x, y] = coordinate + if (!Number.isInteger(x) || !Number.isInteger(y)) continue + if (y < 0 || x < 0 || y >= next.length || x >= (next[y]?.length ?? 0)) continue + next[y][x] = true + } + + return next +} + +function ensureSimulation(settings: SceneParams) { + settings.simulation ??= {} + settings.simulation.initialCondition ??= {} + return settings.simulation as SceneParams['simulation'] & JsonObject +} + +export function stepNaga3d(cells: Cells, settings: SceneParams) { + const simulation = ensureSimulation(settings) + const size = gridSize(settings, cells) + const coordinates = simulation.initialCondition?.cells + const universe = Array.isArray(coordinates) && coordinates.length > 0 + ? universeFromCoordinates(coordinates) + : seedUniverseFromCells(cells) + const tick = typeof simulation.nagaTick === 'number' ? simulation.nagaTick : 0 + const seed = typeof simulation.seed === 'string' ? simulation.seed : 'naga' + const topology: NagaTopology = { + boundary: resolveBoundaryCondition(simulation.grid), + size + } + + try { + evolve(universe, tick, topology, createNagaRandom(`${seed}:${tick}`)) + } catch (error) { + if (!(error instanceof Error) || error.message !== 'Cannot evolve, no heads') throw error + } + + const nextCoordinates = coordinatesFromUniverse(universe, size) + simulation.initialCondition = { + ...simulation.initialCondition, + type: 'cells', + cells: nextCoordinates + } + simulation.nagaTick = tick + 1 + + return projectCoordinatesToCells(nextCoordinates, cells) +} + +export const Addr = addr +export const Evolve = evolve +export const Follow = follow +export const FVec = fVec +export const Moykle_number = moykleNumber +export const Opposite = opposite diff --git a/backend/admin/src/caEngineSteps/noop.ts b/backend/admin/src/caEngineSteps/noop.ts new file mode 100644 index 0000000..5d2a643 --- /dev/null +++ b/backend/admin/src/caEngineSteps/noop.ts @@ -0,0 +1,6 @@ +import { cloneCells } from '../caRuntime.js' +import type { Cells } from '../types.js' + +export function stepNoop(cells: Cells) { + return cloneCells(cells) +} diff --git a/backend/admin/src/caEngineSteps/outerTotalistic2d.ts b/backend/admin/src/caEngineSteps/outerTotalistic2d.ts new file mode 100644 index 0000000..1e1d95f --- /dev/null +++ b/backend/admin/src/caEngineSteps/outerTotalistic2d.ts @@ -0,0 +1,45 @@ +import { resolveBoundaryCondition, resolveBoundaryIndex } from '../caRuntime.js' +import type { Cells, SceneParams } from '../types.js' +import { emptyCellsLike } from './cellGrid.js' + +function parseOuterTotalisticRule(ruleId: string) { + const match = /^B([0-8]*)\/S([0-8]*)$/i.exec(ruleId) + if (!match) return { birth: new Set([3]), survival: new Set([2, 3]) } + + return { + birth: new Set(match[1].split('').map(Number)), + survival: new Set(match[2].split('').map(Number)) + } +} + +export function stepOuterTotalistic2d(cells: Cells, settings: SceneParams) { + const rule = parseOuterTotalisticRule(settings.simulation?.ruleId ?? 'B3/S23') + const next = emptyCellsLike(cells) + const height = cells.length + const width = cells[0]?.length ?? 0 + const boundary = resolveBoundaryCondition(settings.simulation?.grid) + + for (let y = 0; y < height; y += 1) { + for (let x = 0; x < width; x += 1) { + let neighbours = 0 + + for (let dy = -1; dy <= 1; dy += 1) { + for (let dx = -1; dx <= 1; dx += 1) { + if (dx === 0 && dy === 0) continue + + const rawX = x + dx + const rawY = y + dy + const nx = resolveBoundaryIndex(rawX, width, boundary) + const ny = resolveBoundaryIndex(rawY, height, boundary) + + if (nx === null || ny === null) continue + if (cells[ny][nx]) neighbours += 1 + } + } + + next[y][x] = cells[y][x] ? rule.survival.has(neighbours) : rule.birth.has(neighbours) + } + } + + return next +} diff --git a/backend/admin/src/caEngineSteps/wildfire2d.ts b/backend/admin/src/caEngineSteps/wildfire2d.ts new file mode 100644 index 0000000..46feb01 --- /dev/null +++ b/backend/admin/src/caEngineSteps/wildfire2d.ts @@ -0,0 +1,49 @@ +import { resolveBoundaryCondition, resolveBoundaryIndex, seededRandom } from '../caRuntime.js' +import type { Cells, SceneParams } from '../types.js' +import { emptyCellsLike } from './cellGrid.js' + +function parseProbability(value: unknown, fallback: number) { + return typeof value === 'number' && Number.isFinite(value) ? Math.max(0, Math.min(1, value)) : fallback +} + +function deterministicCellNoise(seed: string, x: number, y: number) { + const random = seededRandom(`${seed}:${x}:${y}`) + return random() +} + +export function stepWildfire2d(cells: Cells, settings: SceneParams) { + const next = emptyCellsLike(cells) + const height = cells.length + const width = cells[0]?.length ?? 0 + const boundary = resolveBoundaryCondition(settings.simulation?.grid) + const spreadProbability = parseProbability(settings.simulation?.spreadProbability, 1) + const seed = typeof settings.simulation?.seed === 'string' ? settings.simulation.seed : 'wildfire' + + for (let y = 0; y < height; y += 1) { + for (let x = 0; x < width; x += 1) { + if (cells[y][x]) { + next[y][x] = false + continue + } + + let burningNeighbours = 0 + const offsets = [ + [0, -1], + [1, 0], + [0, 1], + [-1, 0] + ] as const + + for (const [dx, dy] of offsets) { + const nx = resolveBoundaryIndex(x + dx, width, boundary) + const ny = resolveBoundaryIndex(y + dy, height, boundary) + if (nx === null || ny === null) continue + if (cells[ny][nx]) burningNeighbours += 1 + } + + next[y][x] = burningNeighbours > 0 && deterministicCellNoise(seed, x, y) < spreadProbability + } + } + + return next +} diff --git a/backend/admin/src/caEngines.ts b/backend/admin/src/caEngines.ts index 2df5532..34633bc 100644 --- a/backend/admin/src/caEngines.ts +++ b/backend/admin/src/caEngines.ts @@ -1,10 +1,9 @@ -import { - assertSameCellsShape, - cloneCells, - resolveBoundaryCondition, - resolveBoundaryIndex, - seededRandom -} from './caRuntime.js' +import { assertSameCellsShape } from './caRuntime.js' +import { stepElementary1d } from './caEngineSteps/elementary1d.js' +import { stepNaga3d } from './caEngineSteps/naga.js' +import { stepNoop } from './caEngineSteps/noop.js' +import { stepOuterTotalistic2d } from './caEngineSteps/outerTotalistic2d.js' +import { stepWildfire2d } from './caEngineSteps/wildfire2d.js' import type { CaClass, Cells, JsonObject, SceneParams } from './types.js' export interface CaEngineRuntime { @@ -15,132 +14,13 @@ export interface CaEngineRuntime { step: (cells: Cells, settings: SceneParams) => Cells } +export interface CaStepResult { + cells: Cells + settings: SceneParams +} + const engineRuntimes = new Map() -function emptyCellsLike(cells: Cells): Cells { - return cells.map((row) => row.map(() => false)) -} - -function parseOuterTotalisticRule(ruleId: string) { - const match = /^B([0-8]*)\/S([0-8]*)$/i.exec(ruleId) - if (!match) return { birth: new Set([3]), survival: new Set([2, 3]) } - - return { - birth: new Set(match[1].split('').map(Number)), - survival: new Set(match[2].split('').map(Number)) - } -} - -function parseElementaryRule(ruleId: string | undefined) { - const match = /(?:rule[-_\s]*)?(\d{1,3})/i.exec(ruleId ?? '') - if (!match) return 110 - return Math.max(0, Math.min(255, Number(match[1]))) -} - -function parseProbability(value: unknown, fallback: number) { - return typeof value === 'number' && Number.isFinite(value) ? Math.max(0, Math.min(1, value)) : fallback -} - -function stepOuterTotalistic2d(cells: Cells, settings: SceneParams) { - const rule = parseOuterTotalisticRule(settings.simulation?.ruleId ?? 'B3/S23') - const next = emptyCellsLike(cells) - const height = cells.length - const width = cells[0]?.length ?? 0 - const boundary = resolveBoundaryCondition(settings.simulation?.grid) - - for (let y = 0; y < height; y += 1) { - for (let x = 0; x < width; x += 1) { - let neighbours = 0 - - for (let dy = -1; dy <= 1; dy += 1) { - for (let dx = -1; dx <= 1; dx += 1) { - if (dx === 0 && dy === 0) continue - - const rawX = x + dx - const rawY = y + dy - const nx = resolveBoundaryIndex(rawX, width, boundary) - const ny = resolveBoundaryIndex(rawY, height, boundary) - - if (nx === null || ny === null) continue - if (cells[ny][nx]) neighbours += 1 - } - } - - next[y][x] = cells[y][x] ? rule.survival.has(neighbours) : rule.birth.has(neighbours) - } - } - - return next -} - -function stepElementary1d(cells: Cells, settings: SceneParams) { - const rule = parseElementaryRule(settings.simulation?.ruleId) - const next = cloneCells(cells) - const width = cells[0]?.length ?? 0 - if (cells.length === 0 || width === 0) return next - const boundary = resolveBoundaryCondition(settings.simulation?.grid) - const sourceRowIndex = Math.max(0, cells.findIndex((row) => row.some(Boolean))) - const sourceRow = cells[sourceRowIndex] ?? [] - - for (let x = 0; x < width; x += 1) { - const leftIndex = resolveBoundaryIndex(x - 1, width, boundary) - const rightIndex = resolveBoundaryIndex(x + 1, width, boundary) - const left = leftIndex === null ? 0 : sourceRow[leftIndex] ? 1 : 0 - const center = sourceRow[x] ? 1 : 0 - const right = rightIndex === null ? 0 : sourceRow[rightIndex] ? 1 : 0 - const pattern = (left << 2) | (center << 1) | right - next[sourceRowIndex][x] = ((rule >> pattern) & 1) === 1 - } - - return next -} - -function deterministicCellNoise(seed: string, x: number, y: number) { - const random = seededRandom(`${seed}:${x}:${y}`) - return random() -} - -function stepWildfire2d(cells: Cells, settings: SceneParams) { - const next = emptyCellsLike(cells) - const height = cells.length - const width = cells[0]?.length ?? 0 - const boundary = resolveBoundaryCondition(settings.simulation?.grid) - const spreadProbability = parseProbability(settings.simulation?.spreadProbability, 1) - const seed = typeof settings.simulation?.seed === 'string' ? settings.simulation.seed : 'wildfire' - - for (let y = 0; y < height; y += 1) { - for (let x = 0; x < width; x += 1) { - if (cells[y][x]) { - next[y][x] = false - continue - } - - let burningNeighbours = 0 - const offsets = [ - [0, -1], - [1, 0], - [0, 1], - [-1, 0] - ] as const - - for (const [dx, dy] of offsets) { - const nx = resolveBoundaryIndex(x + dx, width, boundary) - const ny = resolveBoundaryIndex(y + dy, height, boundary) - if (nx === null || ny === null) continue - if (cells[ny][nx]) burningNeighbours += 1 - } - - next[y][x] = burningNeighbours > 0 && deterministicCellNoise(seed, x, y) < spreadProbability - } - } - - return next -} - -function stepNoop(cells: Cells) { - return cloneCells(cells) -} - export function registerCaEngineRuntime(engine: CaEngineRuntime, options: { replace?: boolean } = {}) { if (!engine.id.trim()) throw new Error('CA engine runtime id is required') if (engineRuntimes.has(engine.id) && !options.replace) { @@ -154,11 +34,16 @@ export function listCaEngineRuntimes() { } export function stepCaCells(cells: Cells, settings: SceneParams) { + return stepCaSimulation(cells, settings).cells +} + +export function stepCaSimulation(cells: Cells, settings: SceneParams): CaStepResult { const engineId = settings.simulation?.engineId ?? 'game-of-life-2d' const engine = engineRuntimes.get(engineId) ?? engineRuntimes.get('outer-totalistic-2d') ?? engineRuntimes.get('noop') - const next = engine ? engine.step(cells, settings) : stepNoop(cells) + const nextSettings = structuredClone(settings) as SceneParams + const next = engine ? engine.step(cells, nextSettings) : stepNoop(cells) assertSameCellsShape(cells, next, `CA engine output (${engine?.id ?? 'fallback'})`) - return next + return { cells: next, settings: nextSettings } } registerCaEngineRuntime( @@ -205,6 +90,17 @@ registerCaEngineRuntime( { replace: true } ) +registerCaEngineRuntime( + { + id: 'naga-3d', + label: 'Naga 3D', + supportedClasses: [{ dimensions: 3, states: 7 }], + defaultParams: { ruleId: 'naga-3d', rendererId: 'voxel-3d', seed: 'naga', edgeWrap: true }, + step: stepNaga3d + }, + { replace: true } +) + registerCaEngineRuntime( { id: 'generic-voxel-ca', diff --git a/backend/admin/src/caInitialConditionGenerators.ts b/backend/admin/src/caInitialConditionGenerators.ts index 52bd63d..3d79651 100644 --- a/backend/admin/src/caInitialConditionGenerators.ts +++ b/backend/admin/src/caInitialConditionGenerators.ts @@ -1,4 +1,5 @@ import { numericGridSize, seededRandom } from './caRuntime.js' +import { fVec, type NagaArrow } from './caEngineSteps/naga.js' import type { CaClass, JsonObject, SceneParams } from './types.js' export interface InitialConditionResult { @@ -27,6 +28,14 @@ function clampDensity(value: unknown) { return Math.max(0, Math.min(1, typeof value === 'number' ? value : 0.28)) } +function clampInteger(value: unknown, fallback: number, min: number, max: number) { + return Math.max(min, Math.min(max, typeof value === 'number' && Number.isFinite(value) ? Math.round(value) : fallback)) +} + +function clampProbability(value: unknown, fallback: number) { + return Math.max(0, Math.min(1, typeof value === 'number' && Number.isFinite(value) ? value : fallback)) +} + function randomSoup({ settings, caClass, params }: InitialConditionRuntimeInput): InitialConditionResult { const density = clampDensity(params.density) const seed = typeof params.seed === 'string' ? params.seed : 'studio-seed' @@ -53,6 +62,116 @@ function randomSoup({ settings, caClass, params }: InitialConditionRuntimeInput) } } +function randomNagaArrow(random: () => number): NagaArrow { + return (Math.floor(random() * 6) + 1) as NagaArrow +} + +function nextNagaArrow(previous: NagaArrow, sameTypeProbability: number, random: () => number): NagaArrow { + if (random() < sameTypeProbability) return previous + + const alternatives: NagaArrow[] = [1, 2, 3, 4, 5, 6].filter((arrow) => arrow !== previous) as NagaArrow[] + return alternatives[Math.floor(random() * alternatives.length)] ?? previous +} + +function coordinateKey(x: number, y: number, z: number) { + return `${x}_${y}_${z}` +} + +function inBounds(x: number, y: number, z: number, width: number, height: number, depth: number) { + return x >= 0 && y >= 0 && z >= 0 && x < width && y < height && z < depth +} + +function nagaMarkov({ settings, caClass, params }: InitialConditionRuntimeInput): InitialConditionResult { + const [width, height, depth] = numericGridSize(settings.simulation?.grid, caClass.dimensions) + const count = clampInteger(params.count, 3, 1, Math.max(1, width * height * depth)) + const length = clampInteger(params.length, 5, 1, Math.max(1, width * height * depth)) + const sameTypeProbability = clampProbability(params.sameTypeProbability, 0.8) + const seed = typeof params.seed === 'string' ? params.seed : 'naga-markov' + const random = seededRandom(seed) + const occupied = new Set() + const cells: number[][] = [] + const maxAttempts = count * 300 + let created = 0 + let attempts = 0 + + while (created < count && attempts < maxAttempts) { + attempts += 1 + let x = Math.floor(random() * width) + let y = Math.floor(random() * height) + let z = Math.floor(random() * depth) + let arrow = randomNagaArrow(random) + const proposed: number[][] = [] + const proposedKeys = new Set() + let valid = true + + for (let index = 0; index < length; index += 1) { + const key = coordinateKey(x, y, z) + if (!inBounds(x, y, z, width, height, depth) || occupied.has(key) || proposedKeys.has(key)) { + valid = false + break + } + + proposed.push([x, y, z, arrow]) + proposedKeys.add(key) + + const offset = fVec(arrow) + if (!offset) { + valid = false + break + } + + const nextArrow = nextNagaArrow(arrow, sameTypeProbability, random) + x += offset[0] + y += offset[1] + z += offset[2] + arrow = nextArrow + } + + const terminal = proposed[proposed.length - 1] + if (terminal) { + const [terminalX, terminalY, terminalZ, terminalArrow] = terminal + const terminalOffset = fVec(terminalArrow as NagaArrow) + if (!terminalOffset) { + valid = false + } else { + const targetX = terminalX + terminalOffset[0] + const targetY = terminalY + terminalOffset[1] + const targetZ = terminalZ + terminalOffset[2] + const targetKey = coordinateKey(targetX, targetY, targetZ) + if ( + !inBounds(targetX, targetY, targetZ, width, height, depth) || + occupied.has(targetKey) || + proposedKeys.has(targetKey) + ) { + valid = false + } + } + } + + if (!valid) continue + + for (const cell of proposed) { + const [cellX, cellY, cellZ] = cell + occupied.add(coordinateKey(cellX, cellY, cellZ)) + cells.push(cell) + } + created += 1 + } + + return { + type: 'cells', + cells, + generator: { + kind: 'naga-markov', + count, + length, + sameTypeProbability, + seed, + created + } + } +} + export function registerInitialConditionRuntime( runtime: InitialConditionRuntime, options: { replace?: boolean } = {} @@ -91,3 +210,12 @@ registerInitialConditionRuntime( }, { replace: true } ) + +registerInitialConditionRuntime( + { + id: 'naga-markov', + label: 'Naga Markov chains', + generate: nagaMarkov + }, + { replace: true } +) diff --git a/backend/admin/src/main.tsx b/backend/admin/src/main.tsx index 7c1a16e..0d20797 100644 --- a/backend/admin/src/main.tsx +++ b/backend/admin/src/main.tsx @@ -1,6 +1,6 @@ import React from 'react' import ReactDOM from 'react-dom/client' -import { stepCaCells } from './caEngines.js' +import { stepCaCells, stepCaSimulation } from './caEngines.js' import { generateInitialCondition } from './caInitialConditionGenerators.js' import { getBoundaryCondition, getCaClassFromParams, supportsCaClass as supportsRootClass } from './caRuntime.js' import { CaRenderer, listCompatibleCaRendererRuntimes } from './CaRenderer.js' @@ -50,6 +50,9 @@ type EngineDimension = 1 | 2 | 3 type IcgDimension = 1 | 2 | 3 const GALLERY_TABS: GalleryTab[] = ['decks', 'cas', 'engines', 'icgs'] +const MIN_SPEED_LEVEL = 1 +const MAX_SPEED_LEVEL = 10 +const DEFAULT_SPEED_LEVEL = 5 interface RootConfig { rootName: string @@ -201,6 +204,50 @@ function liveCount(cells: Cells) { return cells.reduce((sum, row) => sum + row.reduce((rowSum, alive) => rowSum + (alive ? 1 : 0), 0), 0) } +function isSparseVoxelClass(caClass: CaClass | undefined) { + return caClass?.dimensions === 3 +} + +function shouldProjectCellsIntoSettings(caClass: CaClass | undefined) { + return !isSparseVoxelClass(caClass) +} + +function gridSizeForDimensions(dimensions: number) { + return [GRID_SIZE, dimensions >= 2 ? GRID_SIZE : 1, dimensions >= 3 ? GRID_SIZE : 1] +} + +function defaultRendererForClass(caClass: CaClass) { + if (caClass.dimensions === 1) return 'elementary-1d' + if (caClass.dimensions === 3) return 'voxel-3d' + return '2d-canvas' +} + +function cameraModeForDimensions(dimensions: number) { + return dimensions >= 3 ? '3d' : '2d' +} + +function normalizeParamsForCaClass(params: JsonObject, caClass: CaClass, rendererId?: string): SceneParams { + const normalized = mergeParamObjects(params, { + caClass, + simulation: { + neighborhoodId: caClass.neighborhoodId, + grid: { + size: gridSizeForDimensions(caClass.dimensions), + boundary: 'wrap', + wrap: true + } + }, + renderer: { id: rendererId || defaultRendererForClass(caClass) }, + camera: { mode: cameraModeForDimensions(caClass.dimensions) } + }) as SceneParams + + if (caClass.dimensions === 3 && normalized.simulation?.initialCondition?.cells?.length === 0) { + normalized.simulation.initialCondition = { type: 'cells', cells: [] } + } + + return normalized +} + function isTypingTarget(target: EventTarget | null) { if (!(target instanceof HTMLElement)) return false const tagName = target.tagName.toLowerCase() @@ -324,6 +371,7 @@ function ruleLabel(ruleId: string) { if (ruleId === 'generations-3d') return '3D Generations' if (ruleId === 'lattice-gas-3d') return '3D Lattice gas' if (ruleId === 'snake-3d') return '3D Snake' + if (ruleId === 'naga-3d') return '3D Naga' return ruleId } @@ -428,11 +476,19 @@ function getParamPath(value: JsonObject, path: string[]) { function engineParamPath(key: string) { if (key === 'rendererId') return ['renderer', 'id'] if (key === 'ruleId') return ['simulation', 'ruleId'] + if (key === 'edgeWrap') return ['simulation', 'grid', 'wrap'] if (key.startsWith('grid.')) return ['simulation', ...key.split('.')] if (key.includes('.')) return key.split('.') return ['simulation', key] } +function engineSettingValue(settings: SceneParams, engine: CaEngine, key: string, schema: JsonObject) { + if (key === 'edgeWrap') return getBoundaryCondition(settings) === 'wrap' + + const path = engineParamPath(key) + return getParamPath(settings, path) ?? schemaDefault(key, schema, engine) +} + function schemaNumber(value: unknown, fallback: number) { return typeof value === 'number' && Number.isFinite(value) ? value : fallback } @@ -471,6 +527,8 @@ function sceneAutoplay(scene: ResolvedScene | null) { } function settingsWithCells(settings: SceneParams, cells: Cells): SceneParams { + if (!shouldProjectCellsIntoSettings(getCaClassFromParams(settings))) return settings + return mergeParamObjects(settings, { simulation: { initialCondition: { @@ -645,16 +703,87 @@ function formatTaskbarClock(date: Date) { return date.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }) } -function Win95WindowControls() { +function WindowControls({ + closeLabel = 'Close', + disabled = false, + onClose +}: { + closeLabel?: string + disabled?: boolean + onClose?: () => void +}) { return ( -
{status}
- +