Compare commits

...

10 Commits

Author SHA1 Message Date
Peter Stockings
340107bf90 Add setup for dokku deployment 2026-01-15 18:28:20 +11:00
Peter Stockings
1c0e4c3af6 Fix build errors 2026-01-15 18:27:20 +11:00
Peter Stockings
fab6a7e03f Add self driving car app 2026-01-15 18:13:48 +11:00
Peter Stockings
dd561a4b32 Add self driving car 2026-01-14 18:02:59 +11:00
Peter Stockings
21baa6616b Change from sidebar to tabs 2026-01-14 11:27:00 +11:00
Peter Stockings
863f563a01 Add neat based lunar landing 2026-01-14 11:14:06 +11:00
Peter Stockings
60d4583323 Still cant get neat arena to work 2026-01-14 11:13:33 +11:00
Peter Stockings
840e597413 Add neat arena 2026-01-12 08:58:45 +11:00
Peter Stockings
e9cb8b52df Add playback speed for best game 2026-01-10 12:18:54 +11:00
Peter Stockings
de1563dae6 Add fitness graph 2026-01-10 11:49:28 +11:00
93 changed files with 11349 additions and 269 deletions

2
.buildpacks Normal file
View File

@@ -0,0 +1,2 @@
heroku/nodejs
https://github.com/heroku/heroku-buildpack-static.git

View File

@@ -5,6 +5,10 @@
"": { "": {
"name": "evolution", "name": "evolution",
"dependencies": { "dependencies": {
"@types/matter-js": "^0.20.2",
"matter-js": "^0.20.0",
"phaser": "^3.90.0",
"poly-decomp": "^0.3.0",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-router-dom": "^7.12.0", "react-router-dom": "^7.12.0",
@@ -216,6 +220,8 @@
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
"@types/matter-js": ["@types/matter-js@0.20.2", "", {}, "sha512-3PPKy3QxvZ89h9+wdBV2488I1JLVs7DEpIkPvgO8JC1mUdiVSO37ZIvVctOTD7hIq8OAL2gJ3ugGSuUip6DhCw=="],
"@types/node": ["@types/node@24.10.4", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-vnDVpYPMzs4wunl27jHrfmwojOGKya0xyM3sH+UE5iv5uPS6vX7UIoh6m+vQc5LGBq52HBKPIn/zcSZVzeDEZg=="], "@types/node": ["@types/node@24.10.4", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-vnDVpYPMzs4wunl27jHrfmwojOGKya0xyM3sH+UE5iv5uPS6vX7UIoh6m+vQc5LGBq52HBKPIn/zcSZVzeDEZg=="],
"@types/react": ["@types/react@19.2.7", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg=="], "@types/react": ["@types/react@19.2.7", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg=="],
@@ -314,6 +320,8 @@
"esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
"eventemitter3": ["eventemitter3@5.0.1", "", {}, "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
@@ -380,6 +388,8 @@
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"matter-js": ["matter-js@0.20.0", "", {}, "sha512-iC9fYR7zVT3HppNnsFsp9XOoQdQN2tUyfaKg4CHLH8bN+j6GT4Gw7IH2rP0tflAebrHFw730RR3DkVSZRX8hwA=="],
"minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], "minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
@@ -402,10 +412,14 @@
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
"phaser": ["phaser@3.90.0", "", { "dependencies": { "eventemitter3": "^5.0.1" } }, "sha512-/cziz/5ZIn02uDkC9RzN8VF9x3Gs3XdFFf9nkiMEQT3p7hQlWuyjy4QWosU802qqno2YSLn2BfqwOKLv/sSVfQ=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
"poly-decomp": ["poly-decomp@0.3.0", "", {}, "sha512-hWeBxGzPYiybmI4548Fca7Up/0k1qS5+79cVHI9+H33dKya5YNb9hxl0ZnDaDgvrZSuYFBhkCK/HOnqN7gefkQ=="],
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
"prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],

9
e2e_log.txt Normal file
View File

@@ -0,0 +1,9 @@
Starting Test...
Starting E2E Evolution Test (50 Gens)...
Gen 0: Best: 56.73, Avg: 22.47
Gen 10: Best: 58.09, Avg: 22.27
Gen 20: Best: 59.51, Avg: 21.88
Gen 30: Best: 56.22, Avg: 26.25
Gen 40: Best: 60.17, Avg: 25.75
Gen 49: Best: 62.23, Avg: 24.81
Evolution Result: 56.73 -> 62.23

View File

@@ -7,9 +7,14 @@
"dev": "vite", "dev": "vite",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"lint": "eslint .", "lint": "eslint .",
"preview": "vite preview" "preview": "vite preview",
"typecheck": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"@types/matter-js": "^0.20.2",
"matter-js": "^0.20.0",
"phaser": "^3.90.0",
"poly-decomp": "^0.3.0",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-router-dom": "^7.12.0" "react-router-dom": "^7.12.0"

View File

@@ -1,5 +1,6 @@
.app-layout { .app-layout {
display: flex; display: flex;
flex-direction: column;
width: 100vw; width: 100vw;
height: 100vh; height: 100vh;
overflow: hidden; overflow: hidden;

View File

@@ -2,6 +2,10 @@ import { Routes, Route, Navigate } from 'react-router-dom';
import Sidebar from './components/Sidebar'; import Sidebar from './components/Sidebar';
import ImageApprox from './apps/ImageApprox/ImageApprox'; import ImageApprox from './apps/ImageApprox/ImageApprox';
import SnakeAI from './apps/SnakeAI/SnakeAI'; import SnakeAI from './apps/SnakeAI/SnakeAI';
import RogueGenApp from './apps/RogueGen/RogueGenApp';
import NeatArena from './apps/NeatArena/NeatArena';
import LunarLanderApp from './apps/LunarLander/LunarLanderApp';
import { SelfDrivingCarApp } from './apps/SelfDrivingCar/SelfDrivingCarApp';
import './App.css'; import './App.css';
function App() { function App() {
@@ -13,6 +17,10 @@ function App() {
<Route path="/" element={<Navigate to="/image-approx" replace />} /> <Route path="/" element={<Navigate to="/image-approx" replace />} />
<Route path="/image-approx" element={<ImageApprox />} /> <Route path="/image-approx" element={<ImageApprox />} />
<Route path="/snake-ai" element={<SnakeAI />} /> <Route path="/snake-ai" element={<SnakeAI />} />
<Route path="/rogue-gen" element={<RogueGenApp />} />
<Route path="/neat-arena" element={<NeatArena />} />
<Route path="/lunar-lander" element={<LunarLanderApp />} />
<Route path="/self-driving-car" element={<SelfDrivingCarApp />} />
<Route path="*" element={<div>App not found</div>} /> <Route path="*" element={<div>App not found</div>} />
</Routes> </Routes>
</main> </main>

View File

@@ -0,0 +1,69 @@
export class DenseNetwork {
private weights: Float32Array;
private layerSizes: number[];
constructor(layerSizes: number[], weights?: Float32Array) {
this.layerSizes = layerSizes;
const totalWeights = this.calculateTotalWeights(layerSizes);
if (weights) {
if (weights.length !== totalWeights) {
throw new Error(`Expected ${totalWeights} weights, got ${weights.length}`);
}
this.weights = weights;
} else {
this.weights = new Float32Array(totalWeights);
this.randomize();
}
}
private calculateTotalWeights(sizes: number[]): number {
let total = 0;
for (let i = 0; i < sizes.length - 1; i++) {
// Weights + Bias for each next-layer neuron
// (Input + 1) * Output
total += (sizes[i] + 1) * sizes[i + 1];
}
return total;
}
private randomize() {
for (let i = 0; i < this.weights.length; i++) {
this.weights[i] = (Math.random() * 2 - 1); // -1 to 1 simplified initialization
}
}
public predict(inputs: number[]): number[] {
let currentValues = inputs;
let weightIndex = 0;
for (let i = 0; i < this.layerSizes.length - 1; i++) {
const inputSize = this.layerSizes[i];
const outputSize = this.layerSizes[i + 1];
const nextValues = new Array(outputSize).fill(0);
for (let out = 0; out < outputSize; out++) {
let sum = 0;
// Weights
for (let inp = 0; inp < inputSize; inp++) {
sum += currentValues[inp] * this.weights[weightIndex++];
}
// Bias (last weight for this neuron)
sum += this.weights[weightIndex++];
// Activation
// Output layer (last layer) -> Tanh for action outputs (-1 to 1)
// Hidden layers -> ReLU or Tanh. Let's use Tanh everywhere for simplicity/stability in evolution.
nextValues[out] = Math.tanh(sum);
}
currentValues = nextValues;
}
return currentValues;
}
public getWeights(): Float32Array {
return this.weights;
}
}

View File

@@ -0,0 +1,118 @@
import { DenseNetwork } from './DenseNetwork';
export interface Genome {
weights: Float32Array;
fitness: number;
}
export class GeneticAlgo {
private population: Genome[] = [];
private popSize: number;
private mutationRate: number;
private mutationScale: number;
public generation = 0;
// Track best ever
public bestGenome: Genome | null = null;
public bestFitness = -Infinity;
constructor(
popSize: number,
layerSizes: number[],
mutationRate = 0.1, // Chance per weight (increased for diversity)
mutationScale = 0.5 // Gaussian/random perturbation amount (increased)
) {
this.popSize = popSize;
this.mutationRate = mutationRate;
this.mutationScale = mutationScale;
// Init population
for (let i = 0; i < popSize; i++) {
const net = new DenseNetwork(layerSizes);
this.population.push({
weights: net.getWeights(), // Actually reference, careful on mutation, should clone on breed
fitness: 0
});
}
}
public getPopulation() {
return this.population;
}
public evolve() {
// 1. Sort by fitness
this.population.sort((a, b) => b.fitness - a.fitness);
// Update best
if (this.population[0].fitness > this.bestFitness) {
this.bestFitness = this.population[0].fitness;
// Clone best weights to save safe
this.bestGenome = {
weights: new Float32Array(this.population[0].weights),
fitness: this.population[0].fitness
};
}
const newPop: Genome[] = [];
// 2. Elitism (Keep top 5)
const ELITE_COUNT = 5;
for (let i = 0; i < ELITE_COUNT; i++) {
newPop.push({
weights: new Float32Array(this.population[i].weights),
fitness: 0
});
}
// 3. Breed rest
while (newPop.length < this.popSize) {
// Tournament Select
const p1 = this.tournamentSelect();
const p2 = this.tournamentSelect();
// Crossover
const childWeights = this.crossover(p1.weights, p2.weights);
// Mutate
this.mutate(childWeights);
newPop.push({
weights: childWeights,
fitness: 0
});
}
this.population = newPop;
this.generation++;
}
private tournamentSelect(): Genome {
const pool = 5;
let best = this.population[Math.floor(Math.random() * this.population.length)];
for (let i = 0; i < pool - 1; i++) {
const cand = this.population[Math.floor(Math.random() * this.population.length)];
if (cand.fitness > best.fitness) best = cand;
}
return best;
}
private crossover(w1: Float32Array, w2: Float32Array): Float32Array {
const child = new Float32Array(w1.length);
// Uniform crossover
for (let i = 0; i < w1.length; i++) {
child[i] = Math.random() < 0.5 ? w1[i] : w2[i];
}
return child;
}
private mutate(weights: Float32Array) {
for (let i = 0; i < weights.length; i++) {
if (Math.random() < this.mutationRate) {
// Add noise
weights[i] += (Math.random() * 2 - 1) * this.mutationScale;
}
}
}
}

View File

@@ -0,0 +1,224 @@
import Phaser from 'phaser';
import Matter from 'matter-js';
import { LanderSimulation, WORLD_WIDTH, WORLD_HEIGHT } from './LanderSimulation';
import { DenseNetwork } from './DenseNetwork';
export class LanderScene extends Phaser.Scene {
private sim!: LanderSimulation;
private network!: DenseNetwork;
private landerGraphics!: Phaser.GameObjects.Graphics;
private terrainGraphics!: Phaser.GameObjects.Graphics;
private flameParticles!: Phaser.GameObjects.Particles.ParticleEmitter;
constructor() {
super({ key: 'LanderScene' });
}
preload() {
// Generate a simple particle texture programmatically
const gfx = this.make.graphics({ x: 0, y: 0 });
gfx.fillStyle(0xffffff);
gfx.fillCircle(4, 4, 4); // 8x8 circle
gfx.generateTexture('flame', 8, 8);
gfx.destroy();
}
create() {
this.landerGraphics = this.add.graphics();
this.terrainGraphics = this.add.graphics();
this.cameras.main.setBackgroundColor('#111122');
// Add some stars
const stars = this.add.graphics();
stars.fillStyle(0xffffff, 0.5);
for(let i=0; i<100; i++) {
stars.fillPoint(Math.random() * WORLD_WIDTH, Math.random() * WORLD_HEIGHT, 1);
}
// Setup simple particles
this.flameParticles = this.add.particles(0, 0, 'flame', {
speed: 100,
scale: { start: 1, end: 0 },
blendMode: 'ADD',
lifespan: 200,
emitting: false
});
// Add visual info
this.add.text(10, 10, 'Lunar Lander', { color: '#ffffff', fontSize: '14px', fontStyle: 'bold' });
this.statsText = this.add.text(10, 30, '', { color: '#aaaaaa', fontSize: '12px' });
}
private statsText!: Phaser.GameObjects.Text;
public startMatch(genomeData: any, seed: number = 0) {
// genomeData is now { weights: number[] }
const weights = new Float32Array(genomeData.weights);
// Architecture must match worker
this.network = new DenseNetwork([8, 16, 16, 2], weights);
// Ensure visual matches what the agent trained on
this.sim = new LanderSimulation(seed);
}
update() {
if (!this.sim || !this.network || this.sim.isGameOver) return;
// Step Sim
const inputs = this.sim.getObservation();
const outputs = this.network.predict(inputs);
this.sim.update(outputs);
// Render methods
this.drawScene();
this.updateStats();
}
private drawScene() {
this.landerGraphics.clear();
this.terrainGraphics.clear();
// Static
this.terrainGraphics.fillStyle(0x555555);
this.drawBody(this.sim.ground, this.terrainGraphics);
this.terrainGraphics.fillStyle(0x00ff00);
this.drawBody(this.sim.pad, this.terrainGraphics);
// Dynamic
this.landerGraphics.fillStyle(0xcccccc);
this.drawBody(this.sim.lander, this.landerGraphics);
this.drawFlames();
}
private updateStats() {
// Text
const { currentWind, fuel, lander } = this.sim;
const color = Math.abs(currentWind) > 1.0 ? '#ff5555' : '#aaaaaa';
this.statsText.setText([
`Fuel: ${Math.round(fuel)}`,
`Mass: ${lander.mass.toFixed(1)}kg`,
`Wind: ${currentWind.toFixed(2)}`,
`Gimbal: ${(this.sim.currentNozzleAngle * 180 / Math.PI).toFixed(1)}°`
]).setColor(color);
// Visuals
this.drawWindIndicator();
this.drawThrustGauge();
}
private drawBody(body: Matter.Body, graphics: Phaser.GameObjects.Graphics) {
graphics.beginPath();
const verts = body.vertices;
graphics.moveTo(verts[0].x, verts[0].y);
for (let i = 1; i < verts.length; i++) {
graphics.lineTo(verts[i].x, verts[i].y);
}
graphics.closePath();
graphics.fillPath();
graphics.lineStyle(1, 0x000000).strokePath();
}
private drawFlames() {
const { currentMainPower, currentNozzleAngle, lander } = this.sim;
if (currentMainPower <= 0.1) return;
this.landerGraphics.save();
const totalAngle = lander.angle + currentNozzleAngle;
const offset = { x: 0, y: 20 }; // Nozzle relative pos
const nozzlePos = Matter.Vector.add(lander.position, Matter.Vector.rotate(offset, lander.angle));
// 1. Particles
const emitAngleDeg = Math.atan2(Math.cos(totalAngle), -Math.sin(totalAngle)) * (180/Math.PI);
this.flameParticles.setAngle(emitAngleDeg); // Simple angle setting
this.flameParticles.emitParticleAt(nozzlePos.x, nozzlePos.y);
// 2. Vector
const arrowLength = currentMainPower * 60;
const endX = nozzlePos.x - Math.sin(totalAngle) * arrowLength;
const endY = nozzlePos.y + Math.cos(totalAngle) * arrowLength;
this.landerGraphics.lineStyle(2, 0xffff00, 0.8);
this.landerGraphics.beginPath();
this.landerGraphics.moveTo(nozzlePos.x, nozzlePos.y);
this.landerGraphics.lineTo(endX, endY);
this.landerGraphics.strokePath();
this.landerGraphics.restore();
}
private drawWindIndicator() {
const { currentWind, lander } = this.sim;
if (Math.abs(currentWind) <= 0.1) return;
const startX = lander.position.x;
const startY = lander.position.y - 40;
const length = Math.abs(currentWind * 20);
const angle = currentWind > 0 ? 0 : Math.PI;
this.landerGraphics.lineStyle(2, 0x00ffff);
this.drawArrow(startX, startY, length, angle);
}
private drawArrow(x: number, y: number, length: number, angle: number) {
const endX = x + Math.cos(angle) * length;
const endY = y + Math.sin(angle) * length;
this.landerGraphics.beginPath();
this.landerGraphics.moveTo(x, y);
this.landerGraphics.lineTo(endX, endY);
// Arrow head
const headSize = 5;
this.landerGraphics.moveTo(endX, endY);
this.landerGraphics.lineTo(endX - headSize * Math.cos(angle - Math.PI / 6), endY - headSize * Math.sin(angle - Math.PI / 6));
this.landerGraphics.moveTo(endX, endY);
this.landerGraphics.lineTo(endX - headSize * Math.cos(angle + Math.PI / 6), endY - headSize * Math.sin(angle + Math.PI / 6));
this.landerGraphics.strokePath();
}
private drawThrustGauge() {
const { currentMainPower, lastActions } = this.sim;
const barX = 10, barY = 90, barW = 100, barH = 10;
const g = this.terrainGraphics;
g.fillStyle(0x333333).fillRect(barX, barY, barW, barH);
g.fillStyle(0x00ff00).fillRect(barX, barY, currentMainPower * barW, barH);
const cmdW = Math.max(0, lastActions[0]) * barW;
g.fillStyle(0xff0000).fillRect(barX + cmdW, barY - 2, 2, barH + 4);
}
}
export function createLanderViewer(container: HTMLElement) {
return new Phaser.Game({
type: Phaser.AUTO,
width: WORLD_WIDTH,
height: WORLD_HEIGHT,
parent: container,
scene: LanderScene,
transparent: false,
backgroundColor: '#111122',
scale: {
mode: Phaser.Scale.FIT,
autoCenter: Phaser.Scale.CENTER_BOTH
},
physics: {
default: 'matter',
matter: {
gravity: { x: 0, y: 0 },
debug: false
}
}
});
}
export function getLanderScene(game: Phaser.Game): LanderScene {
return game.scene.getScene('LanderScene') as LanderScene;
}

View File

@@ -0,0 +1,186 @@
import Matter from 'matter-js';
export const WORLD_WIDTH = 800;
export const WORLD_HEIGHT = 600;
const LANDER_WIDTH = 30;
const LANDER_HEIGHT = 40;
const PAD_WIDTH = 80;
export class LanderSimulation {
public engine: Matter.Engine;
public lander!: Matter.Body;
public ground!: Matter.Body;
public pad!: Matter.Body;
public isGameOver = false;
public result: 'FLYING' | 'CRASHED' | 'LANDED' | 'TIMEOUT' = 'FLYING';
// State
public fuel = 1000;
public readonly maxFuel = 1000;
public timeSteps = 0;
public readonly maxTimeSteps = 60 * 20; // 20s
public currentWind = 0;
public currentMainPower = 0;
public currentNozzleAngle = 0;
public lastActions: number[] = [0, 0];
// Config
private readonly DRY_MASS = 10;
private readonly FUEL_MASS_CAPACITY = 10;
private readonly LAG_FACTOR = 0.05;
private readonly GIMBAL_SPEED = 0.05;
private windTime = Math.random() * 100;
constructor(seed: number = 0) {
this.engine = Matter.Engine.create({ enableSleeping: false });
this.engine.gravity.y = 0.5;
// Custom PRNG
let s = seed;
const random = () => {
s = (s * 9301 + 49297) % 233280;
return s / 233280;
};
this.setupWorld(random);
Matter.Events.on(this.engine, 'collisionStart', (e) => this.handleCollisions(e));
}
private setupWorld(random: () => number) {
// Bodies
this.ground = Matter.Bodies.rectangle(WORLD_WIDTH/2, WORLD_HEIGHT, WORLD_WIDTH, 20, {
isStatic: true, label: 'ground', friction: 1, render: { fillStyle: '#555555' }
});
this.pad = Matter.Bodies.rectangle(WORLD_WIDTH/2, WORLD_HEIGHT - 30, PAD_WIDTH, 10, {
isStatic: true, label: 'pad', render: { fillStyle: '#00ff00' }
});
const startX = 100 + random() * (WORLD_WIDTH - 200);
const startY = 50 + random() * 100;
this.lander = Matter.Bodies.trapezoid(startX, startY, LANDER_WIDTH, LANDER_HEIGHT, 0.5, {
friction: 0.1, frictionAir: 0.02, restitution: 0, label: 'lander', angle: 0
});
Matter.Body.setMass(this.lander, this.DRY_MASS + this.FUEL_MASS_CAPACITY);
Matter.Body.setVelocity(this.lander, { x: (random() - 0.5) * 4, y: 0 });
Matter.World.add(this.engine.world, [this.ground, this.pad, this.lander]);
}
private handleCollisions(event: Matter.IEventCollision<Matter.Engine>) {
if (this.isGameOver) return;
event.pairs.forEach(pair => {
const other = pair.bodyA === this.lander ? pair.bodyB : (pair.bodyB === this.lander ? pair.bodyA : null);
if (!other) return;
if (other.label === 'pad') this.checkLanding();
else if (other.label === 'ground') this.crash("Hit ground");
});
}
private checkLanding() {
const { position, velocity, angle, angularVelocity } = this.lander;
const speed = Math.hypot(velocity.x, velocity.y);
// Strict Bounds Check
const isAbovePad = position.y < (this.pad.position.y - 15);
const isOnPad = Math.abs(position.x - this.pad.position.x) < 35; // Inside pad width
if (!isAbovePad) return this.crash("Hit side of pad");
if (!isOnPad) return this.crash("Missed center");
// Landing Criteria
if (speed < 2.5 && Math.abs(angle) < 0.25 && Math.abs(angularVelocity) < 0.15) {
this.result = 'LANDED';
this.isGameOver = true;
} else {
this.crash(`Too fast/tilted: Spd=${speed.toFixed(1)}`);
}
}
private crash(_reason: string) {
this.result = 'CRASHED';
this.isGameOver = true;
}
public update(actions: number[]): boolean {
this.lastActions = actions;
if (this.isGameOver) return false;
if (++this.timeSteps > this.maxTimeSteps) {
this.result = 'TIMEOUT';
this.isGameOver = true;
return false;
}
this.applyWind();
this.updateMass();
this.applyControls(actions);
this.checkBounds();
Matter.Engine.update(this.engine, 1000 / 60);
return !this.isGameOver;
}
private applyWind() {
this.windTime += 0.01;
this.currentWind = Math.sin(this.windTime) + Math.sin(this.windTime * 3.2) * 0.5 + Math.sin(this.windTime * 0.7) * 2.0;
Matter.Body.applyForce(this.lander, this.lander.position, { x: this.currentWind * 0.002, y: 0 });
}
private updateMass() {
const expectedMass = this.DRY_MASS + (this.fuel / this.maxFuel) * this.FUEL_MASS_CAPACITY;
if (Math.abs(this.lander.mass - expectedMass) > 0.01) {
Matter.Body.setMass(this.lander, expectedMass);
}
}
private applyControls(actions: number[]) {
// [0: Thrust (-1..1), 1: Nozzle (-1..1)]
let targetMainPower = Math.max(0, Math.min(1, (actions[0] + 1) / 2));
const targetNozzleAngle = actions[1] * 0.5; // Max 0.5 rad (~28 deg)
// Lag & Inertia
this.currentMainPower += Math.sign(targetMainPower - this.currentMainPower) * Math.min(Math.abs(targetMainPower - this.currentMainPower), this.LAG_FACTOR);
this.currentNozzleAngle += Math.sign(targetNozzleAngle - this.currentNozzleAngle) * Math.min(Math.abs(targetNozzleAngle - this.currentNozzleAngle), this.GIMBAL_SPEED);
// Fuel
if (this.fuel <= 0) this.currentMainPower = 0;
else this.fuel -= this.currentMainPower * 0.5;
// Apply Force
if (this.currentMainPower > 0.01) {
const force = 0.0005 * 20 * 2.5 * this.currentMainPower; // 2.5TWR approx
const totalAngle = this.lander.angle + this.currentNozzleAngle;
const forceVector = { x: Math.sin(totalAngle) * force, y: -Math.cos(totalAngle) * force };
// Offset point (bottom of lander)
const appPos = Matter.Vector.add(this.lander.position, Matter.Vector.rotate({ x: 0, y: 20 }, this.lander.angle));
Matter.Body.applyForce(this.lander, appPos, forceVector);
}
}
private checkBounds() {
if (this.lander.position.x < -100 || this.lander.position.x > WORLD_WIDTH + 100 ||
this.lander.position.y < -500 || this.lander.position.y > WORLD_HEIGHT + 100) {
this.crash("Out of bounds");
}
}
public getObservation(): number[] {
const { velocity, angularVelocity, position, angle } = this.lander;
return [
velocity.x / 10.0,
velocity.y / 10.0,
angle / 3.14,
angularVelocity / 0.5,
(position.x - this.pad.position.x) / WORLD_WIDTH,
(position.y - this.pad.position.y) / WORLD_HEIGHT,
this.currentWind / 5.0,
this.currentNozzleAngle / 0.5,
];
}
}

View File

@@ -0,0 +1,129 @@
.lunar-app-layout {
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
overflow: hidden;
background: #0d1117;
color: #c9d1d9;
border-radius: 6px;
}
.top-bar {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
padding: 10px 20px;
background: #161b22;
border-bottom: 1px solid #30363d;
flex-shrink: 0;
gap: 20px;
}
.controls-section {
display: flex;
gap: 10px;
align-items: center;
}
.stats-section {
display: flex;
gap: 15px;
align-items: center;
}
.stat-card {
display: flex;
flex-direction: column;
align-items: center;
background: #21262d;
padding: 5px 15px;
border-radius: 6px;
border: 1px solid #30363d;
min-width: 80px;
}
.stat-label {
font-size: 0.75em;
color: #8b949e;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.stat-value {
font-size: 1.1em;
font-weight: bold;
color: #c9d1d9;
font-family: monospace;
}
.stat-value.highlight {
color: #58a6ff;
}
.vis-column {
display: flex;
flex-direction: column;
flex: 1;
overflow: hidden;
min-width: 0;
}
.graph-panel {
height: 180px;
/* Slightly shorter */
background: #161b22;
border-top: 1px solid #30363d;
padding: 10px 15px;
display: flex;
flex-direction: column;
flex-shrink: 0;
box-sizing: border-box;
}
.graph-panel h3 {
margin-top: 0;
margin-bottom: 5px;
font-size: 0.9em;
color: #58a6ff;
}
.main-view {
flex: 1;
position: relative;
overflow: hidden;
display: flex;
justify-content: center;
align-items: center;
background: #000;
}
/* Controls */
.btn-toggle {
padding: 8px 16px;
border: 1px solid #30363d;
border-radius: 6px;
background: #238636;
color: white;
font-weight: bold;
cursor: pointer;
transition: background 0.2s;
font-size: 0.9em;
display: flex;
align-items: center;
gap: 6px;
}
.btn-toggle.active {
background: #da3633;
}
.btn-toggle:hover {
filter: brightness(1.1);
}
.btn-reset {
background: #30363d;
border-color: #30363d;
}

View File

@@ -0,0 +1,80 @@
import { useRef, useEffect } from 'react';
import AppContainer from '../../components/AppContainer';
import { createLanderViewer, getLanderScene } from './LanderScene';
import FitnessGraph from '../NeatArena/FitnessGraph';
import { useEvolutionWorker } from './useEvolutionWorker';
import './LunarLander.css';
export default function LunarLanderApp() {
const { isTraining, stats, fitnessHistory, bestGenome, toggleTraining, handleReset } = useEvolutionWorker();
const phaserContainerRef = useRef<HTMLDivElement>(null);
const phaserGameRef = useRef<Phaser.Game | null>(null);
// Phaser Initialization
useEffect(() => {
if (!phaserContainerRef.current) return;
const game = createLanderViewer(phaserContainerRef.current);
phaserGameRef.current = game;
return () => {
game.destroy(true);
phaserGameRef.current = null;
};
}, []);
// Exhibition Loop
useEffect(() => {
const interval = setInterval(() => {
if (!phaserGameRef.current) return;
const scene = getLanderScene(phaserGameRef.current);
if (!scene) return;
// Start new match if game over and we have a genome
// Accessing private sim via any cast for simplicity without exposing public property
const sceneAny = scene as any;
if (bestGenome && (!sceneAny.sim || sceneAny.sim.isGameOver)) {
scene.startMatch(bestGenome, stats.generation);
}
}, 100);
return () => clearInterval(interval);
}, [bestGenome, stats.generation]);
return (
<AppContainer title="Lunar Lander (Dense NN)">
<div className="lunar-app-layout">
<div className="top-bar">
<div className="controls-section">
<button className={`btn-toggle ${isTraining ? 'active' : ''}`} onClick={toggleTraining}>
{isTraining ? '⏸ Pause' : '▶ Start Evolution'}
</button>
<button className="btn-toggle btn-reset" onClick={handleReset}>
🔄 Reset
</button>
</div>
<div className="stats-section">
<StatCard label="Generation" value={stats.generation} />
<StatCard label="Best Fit" value={stats.maxFitness.toFixed(2)} highlight />
<StatCard label="Avg Fit" value={stats.avgFitness.toFixed(2)} />
</div>
</div>
<div className="graph-panel">
<FitnessGraph history={fitnessHistory} />
</div>
<div className="vis-column">
<div className="main-view" ref={phaserContainerRef} />
</div>
</div>
</AppContainer>
);
}
function StatCard({ label, value, highlight = false }: { label: string, value: string | number, highlight?: boolean }) {
return (
<div className="stat-card">
<div className="stat-label">{label}</div>
<div className={`stat-value ${highlight ? 'highlight' : ''}`}>{value}</div>
</div>
);
}

View File

@@ -0,0 +1,60 @@
import { describe, it, expect } from 'bun:test';
import { LanderSimulation } from './LanderSimulation';
import Matter from 'matter-js';
describe('Lunar Lander Physics Debug', () => {
it('should have enough thrust to lift off', () => {
const sim = new LanderSimulation(0);
const lander = sim.lander;
console.log('--- Physics Debug Info ---');
console.log(`Lander Mass: ${lander.mass.toFixed(4)}`);
console.log(`Gravity Y: ${sim.engine.gravity.y}`);
console.log(`Gravity Scale: ${sim.engine.gravity.scale}`); // Default 0.001?
// Matter.js gravity force = mass * gravity.y * gravity.scale
// (Wait, Matter applies gravity as acceleration? F = m * a)
// Standard gravity force per tick approx:
// Force = mass * gravity.y * 0.001 (default scale)
const gravityForce = lander.mass * sim.engine.gravity.y * (sim.engine.gravity.scale || 0.001);
console.log(`Calculated Gravity Force (approx): ${gravityForce.toFixed(6)}`);
// My Max Thrust
const mainPower = 1.0;
const thrustForce = 0.002 * mainPower;
console.log(`Max Thrust Force: ${thrustForce.toFixed(6)}`);
const ratio = thrustForce / gravityForce;
console.log(`Thrust/Gravity Ratio: ${ratio.toFixed(2)}`);
// We expect Ratio > 1.0 to hover
if (ratio < 1.0) {
console.warn('⚠️ WARNING: THRUST IS TOO WEAK TO LIFT OFF! ⚠️');
} else {
console.log('✅ Thrust is sufficient.');
}
expect(ratio).toBeGreaterThan(1.2); // Should have some margin
});
it('should update inputs correctly', () => {
const sim = new LanderSimulation(0);
// Initial state
const initialObs = sim.getObservation();
console.log('Initial Obs:', initialObs);
// Fall for a bit
sim.update([0, 0]); // No thrust
sim.update([0, 0]);
sim.update([0, 0]); // 3 ticks
const obs = sim.getObservation();
console.log('Obs after 3 ticks fall:', obs);
// Velocity Y should be positive (down)
expect(obs[1]).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,114 @@
import { describe, test, expect } from "bun:test";
import { LanderSimulation } from "./LanderSimulation";
import { LANDER_NEAT_CONFIG, calculateFitness } from "./neatConfig";
import { createPopulation, evolveGeneration } from "../../lib/neatArena/evolution";
import { createNetwork } from "../../lib/neatArena/network";
describe("Lunar Lander E2E & Stability", () => {
test("Simulation Determinism (Same Actions)", () => {
// Run two sims side-by-side with identical actions
const sim1 = new LanderSimulation(0);
const sim2 = new LanderSimulation(0);
const actions = [1.0, 0.5]; // Full thrust, turn right
for(let i=0; i<100; i++) {
sim1.update(actions);
sim2.update(actions);
expect(sim1.lander.position.x).toBe(sim2.lander.position.x);
expect(sim1.lander.position.y).toBe(sim2.lander.position.y);
expect(sim1.lander.angle).toBe(sim2.lander.angle);
}
});
test("Evaluation Determinism (Same Genome)", () => {
const population = createPopulation(LANDER_NEAT_CONFIG);
const genome = population.genomes[0];
// Evaluate once
const runSim = (g: typeof genome) => {
const sim = new LanderSimulation(0);
const net = createNetwork(g);
while(!sim.isGameOver) {
const inputs = sim.getObservation();
const outputs = net.activate(inputs);
sim.update(outputs);
}
return calculateFitness(sim);
};
const fit1 = runSim(genome);
const fit2 = runSim(genome);
const fit3 = runSim(genome);
console.log(`Determinism Check: ${fit1}, ${fit2}, ${fit3}`);
expect(fit1).toBe(fit2);
expect(fit2).toBe(fit3);
});
test("Long Flight Stability (Mock Pilot)", () => {
// A simple pilot that pushes up if falling
const runPilot = () => {
const sim = new LanderSimulation(0);
let steps = 0;
while (!sim.isGameOver && steps < 1000) {
const obs = sim.getObservation();
// obs[1] is velY (scaled / 10). Positive = Falling.
// If falling > 0.1 (vel 1.0), thrust!
const mainThrust = obs[1] > 0.1 ? 1.0 : 0.0;
sim.update([mainThrust, 0]);
steps++;
}
return { fitness: calculateFitness(sim), steps };
};
const result1 = runPilot();
const result2 = runPilot();
const result3 = runPilot();
console.log(`Pilot Results: ${result1.fitness.toFixed(2)} (${result1.steps}), ${result2.fitness.toFixed(2)} (${result2.steps}), ${result3.fitness.toFixed(2)} (${result3.steps})`);
expect(result1.fitness).toBe(result2.fitness);
expect(result1.steps).toBe(result2.steps);
});
test("Evolution Progress (Mock)", () => {
let population = createPopulation({
...LANDER_NEAT_CONFIG,
populationSize: 20 // Smaller for speed
});
let bestFit = -Infinity;
// Run 5 generations
for(let gen=0; gen<5; gen++) {
// Evaluate
for(const g of population.genomes) {
const sim = new LanderSimulation(0);
const net = createNetwork(g);
while(!sim.isGameOver) {
const inputs = sim.getObservation();
const outputs = net.activate(inputs);
sim.update(outputs);
}
g.fitness = calculateFitness(sim);
}
// Stats
const currentBest = Math.max(...population.genomes.map(g => g.fitness));
console.log(`Gen ${gen}: Best = ${currentBest.toFixed(2)}`);
// Elitism check: Best fitness should NEVER decrease
if (gen > 0) {
expect(currentBest).toBeGreaterThanOrEqual(bestFit - 0.001); // Tiny float tolerance
}
bestFit = currentBest;
population = evolveGeneration(population, LANDER_NEAT_CONFIG);
}
});
});

View File

@@ -0,0 +1,71 @@
import { LanderSimulation } from './LanderSimulation';
import type { EvolutionConfig } from '../../lib/neatArena/evolution';
import { DEFAULT_COMPATIBILITY_CONFIG } from '../../lib/neatArena/speciation';
import { DEFAULT_REPRODUCTION_CONFIG } from '../../lib/neatArena/reproduction';
export const LANDER_NEAT_CONFIG: EvolutionConfig = {
inputCount: 8, // velX, velY, angle, angVel, dx, dy, WIND, nozzleAngle
outputCount: 2, // mainThrust, sideThrust
populationSize: 150,
compatibilityConfig: DEFAULT_COMPATIBILITY_CONFIG,
reproductionConfig: {
...DEFAULT_REPRODUCTION_CONFIG,
elitePerSpecies: 2, // Keep top 2 per species to prevent losing best genes
},
};
export function calculateFitness(sim: LanderSimulation): number {
let fitness = 0;
// 1. Distance Reward (Closer to pad is better)
const distX = Math.abs(sim.lander.position.x - sim.pad.position.x);
const distY = Math.abs(sim.lander.position.y - sim.pad.position.y);
const dist = Math.sqrt(distX * distX + distY * distY);
// Normalize distance reward (0 to 100)
fitness += Math.max(0, 500 - dist) * 0.1;
// Common metrics
const speed = Math.hypot(sim.lander.velocity.x, sim.lander.velocity.y);
const angle = Math.abs(sim.lander.angle);
// 2. Landing Reward
if (sim.result === 'LANDED') {
fitness += 200; // Base success reward
// SOFT LANDING BONUS
// Speed limit is 2.5. We reward being significantly below that.
// If speed is 0.0 -> +250 pts
// If speed is 2.5 -> +0 pts
fitness += Math.max(0, 2.5 - speed) * 100;
// Alignment Bonus (Dead center upright)
fitness += Math.max(0, 0.25 - angle) * 100;
// Efficiency Bonus
fitness += sim.fuel * 0.2;
}
// 3. Near-Miss / Crash partial rewards
else if (sim.result === 'CRASHED') {
// Continuous reward: Encourage getting CLOSER to valid landing state
// Speed: Reward any braking. Freefall ~20.
// At 20 -> 0 pts
// At 0 -> 60 pts
fitness += Math.max(0, 20.0 - speed) * 3.0;
// Angle: Upright is better.
fitness += Math.max(0, 1.0 - angle) * 30;
// Penalty for doing NOTHING (Full Fuel)
if (sim.fuel >= 999) {
fitness -= 30;
}
} else if (sim.result === 'TIMEOUT') {
fitness -= 50;
}
return Math.max(0.1, fitness);
}

View File

@@ -0,0 +1,72 @@
import { test, expect } from 'bun:test';
import { GeneticAlgo } from './GeneticAlgo';
import { DenseNetwork } from './DenseNetwork';
import { calculateFitness } from './neatConfig';
import { LanderSimulation } from './LanderSimulation';
test('Run 150 generations of Dense GA to verify learning progress', () => {
// 1. Setup
const LAYER_SIZES = [7, 16, 16, 2];
const POPULATION_SIZE = 150;
const ga = new GeneticAlgo(POPULATION_SIZE, LAYER_SIZES);
const TOTAL_GENS = 150;
const SCENARIOS = 5;
console.log("Generation, MaxFitness, AvgFitness");
for (let gen = 0; gen < TOTAL_GENS; gen++) {
// 2. Evaluate
const population = ga.getPopulation();
for (const genome of population) {
const network = new DenseNetwork(LAYER_SIZES, genome.weights);
let totalFitness = 0;
for (let i = 0; i < SCENARIOS; i++) {
const seed = (ga.generation * SCENARIOS) + i;
const sim = new LanderSimulation(seed);
let safety = 0;
while (!sim.isGameOver && safety < 5000) {
const inputs = sim.getObservation();
const outputs = network.predict(inputs);
sim.update(outputs);
safety++;
}
totalFitness += calculateFitness(sim);
}
genome.fitness = totalFitness / SCENARIOS;
}
// 3. Log Stats every 10 gens
if (gen % 10 === 0) {
let maxFitness = -Infinity;
let sumFitness = 0;
for (const g of population) {
if (g.fitness > maxFitness) maxFitness = g.fitness;
sumFitness += g.fitness;
}
const avgFitness = sumFitness / population.length;
console.log(`${ga.generation}, ${maxFitness.toFixed(2)}, ${avgFitness.toFixed(2)}`);
}
// 4. Evolve
ga.evolve();
}
const finalPop = ga.getPopulation();
let maxFitness = -Infinity;
for (const g of finalPop) {
if (g.fitness > maxFitness) maxFitness = g.fitness;
}
console.log(`Final Gen ${ga.generation}: Max ${maxFitness.toFixed(2)}`);
// Expect significant improvement.
// Landing is > 200.
expect(maxFitness).toBeGreaterThan(150);
}, { timeout: 300000 }); // 5 min timeout

View File

@@ -0,0 +1,105 @@
import { LanderSimulation } from './LanderSimulation';
import { calculateFitness } from './neatConfig';
import { GeneticAlgo } from './GeneticAlgo';
import { DenseNetwork } from './DenseNetwork';
// Define the fixed architecture
// 6 Input -> 16 Hidden -> 16 Hidden -> 2 Output
const LAYER_SIZES = [8, 16, 16, 2];
const POPULATION_SIZE = 150;
let ga: GeneticAlgo | null = null;
let isRunning = false;
self.onmessage = (e: MessageEvent) => {
const { type } = e.data;
switch (type) {
case 'start':
case 'reset':
console.log('Worker: Initializing Fixed Topology GA');
ga = new GeneticAlgo(POPULATION_SIZE, LAYER_SIZES);
isRunning = true;
runGeneration();
break;
case 'pause':
isRunning = false;
break;
case 'resume':
if (!isRunning) {
isRunning = true;
runGeneration();
}
break;
}
};
function runGeneration() {
if (!ga || !isRunning) return;
const population = ga.getPopulation();
// 1. Evaluate Fitness
const SCENARIOS = 5;
for (const genome of population) {
let totalFitness = 0;
const network = new DenseNetwork(LAYER_SIZES, genome.weights);
for (let i = 0; i < SCENARIOS; i++) {
// Seed logic: (Gen * Scenarios) + i
const seed = (ga.generation * SCENARIOS) + i;
const sim = new LanderSimulation(seed);
// Simulation Loop
// Safety break just in case
let step = 0;
while (!sim.isGameOver && step < 5000) {
const inputs = sim.getObservation();
const outputs = network.predict(inputs);
sim.update(outputs);
step++;
}
if (step >= 5000) {
// penalty for timeout? Or just let calcFitness handle it.
// calculateFitness handles timeout result.
// Force result if not set
if (!sim.isGameOver) sim.result = 'TIMEOUT';
}
totalFitness += calculateFitness(sim);
}
genome.fitness = totalFitness / SCENARIOS;
}
// Calculate stats before evolution
let sumFitness = 0;
let maxFitness = -Infinity;
for (const genome of population) {
sumFitness += genome.fitness;
if (genome.fitness > maxFitness) maxFitness = genome.fitness;
}
const avgFitness = sumFitness / population.length;
// Send update to main thread
// The main thread expects bestGenome object.
// We'll send the weights of the current champion (maxFitness)
const bestOfGen = population.find(g => g.fitness === maxFitness) || population[0];
self.postMessage({
type: 'generationParams',
payload: {
generation: ga.generation,
maxFitness: maxFitness,
avgFitness: avgFitness,
bestGenome: { weights: Array.from(bestOfGen.weights) } // Custom payload
}
});
// 2. Evolve to next generation
ga.evolve();
// Schedule next gen
setTimeout(runGeneration, 0);
}

View File

@@ -0,0 +1,82 @@
import { useState, useEffect, useRef, useCallback } from 'react';
export interface Stats {
generation: number;
maxFitness: number;
avgFitness: number;
}
export interface HistoryItem {
generation: number;
best: number;
avg: number;
}
export function useEvolutionWorker() {
const [isTraining, setIsTraining] = useState(false);
const [stats, setStats] = useState<Stats>({ generation: 0, maxFitness: 0, avgFitness: 0 });
const [fitnessHistory, setFitnessHistory] = useState<HistoryItem[]>([]);
const [bestGenome, setBestGenome] = useState<any>(null);
const workerRef = useRef<Worker | null>(null);
useEffect(() => {
const worker = new Worker(new URL('./training.worker.ts', import.meta.url), { type: 'module' });
workerRef.current = worker;
worker.onmessage = (e: MessageEvent<any>) => {
const { type, payload, error } = e.data;
if (type === 'generationParams') {
setStats({
generation: payload.generation,
maxFitness: payload.maxFitness,
avgFitness: payload.avgFitness
});
if (payload.bestGenome) {
setBestGenome(payload.bestGenome);
}
setFitnessHistory(prev => [...prev, {
generation: payload.generation,
best: payload.maxFitness,
avg: payload.avgFitness
}]);
} else if (type === 'error') {
console.error("Worker Error:", error);
setIsTraining(false);
}
};
// Initial reset to setup GA
worker.postMessage({ type: 'reset' });
return () => worker.terminate();
}, []);
useEffect(() => {
if (!workerRef.current) return;
workerRef.current.postMessage({ type: isTraining ? 'resume' : 'pause' });
}, [isTraining]);
const handleReset = useCallback(() => {
setStats({ generation: 0, maxFitness: 0, avgFitness: 0 });
setFitnessHistory([]);
setBestGenome(null);
workerRef.current?.postMessage({ type: 'reset' });
}, []);
const toggleTraining = useCallback(() => {
setIsTraining(prev => !prev);
}, []);
return {
isTraining,
stats,
fitnessHistory,
bestGenome,
toggleTraining,
handleReset
};
}

View File

@@ -0,0 +1,132 @@
import { useEffect, useRef } from 'react';
interface FitnessGraphProps {
history: { generation: number; best: number; avg: number }[];
}
export default function FitnessGraph({ history }: FitnessGraphProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const draw = () => {
const canvas = canvasRef.current;
if (!canvas || history.length === 0) return;
const container = containerRef.current;
if (container) {
canvas.width = container.clientWidth;
canvas.height = container.clientHeight;
}
const ctx = canvas.getContext('2d');
if (!ctx) return;
const width = canvas.width;
const height = canvas.height;
// Configurable padding
const paddingLeft = 40;
const paddingRight = 20;
const paddingTop = 40;
const paddingBottom = 20;
const graphWidth = width - paddingLeft - paddingRight;
const graphHeight = height - paddingTop - paddingBottom;
// Clear
ctx.fillStyle = '#1a1a2e';
ctx.fillRect(0, 0, width, height);
// Find data range
const maxGen = Math.max(...history.map(h => h.generation), 1);
const allFitness = [...history.map(h => h.best), ...history.map(h => h.avg)];
const maxFit = Math.max(...allFitness, 1);
const minFit = Math.min(...allFitness, -1);
const fitRange = maxFit - minFit;
// Draw grid
ctx.strokeStyle = '#2a2a3e';
ctx.lineWidth = 1;
for (let i = 0; i <= 5; i++) {
const y = paddingTop + graphHeight * (i / 5);
ctx.beginPath();
ctx.moveTo(paddingLeft, y);
ctx.lineTo(width - paddingRight, y);
ctx.stroke();
// Y-axis labels
const fitValue = maxFit - (fitRange * i / 5);
ctx.fillStyle = '#888';
ctx.font = '11px monospace';
ctx.textAlign = 'right';
ctx.fillText(fitValue.toFixed(1), paddingLeft - 5, y + 4);
}
// Draw axes
ctx.strokeStyle = '#444';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(paddingLeft, paddingTop);
ctx.lineTo(paddingLeft, height - paddingBottom);
ctx.lineTo(width - paddingRight, height - paddingBottom);
ctx.stroke();
// Helper to convert data to canvas coords
const toX = (gen: number) => paddingLeft + (graphWidth * gen / maxGen);
const toY = (fit: number) => {
const normalized = (maxFit - fit) / fitRange;
return paddingTop + graphHeight * normalized;
};
// Draw best fitness line
ctx.strokeStyle = '#00ff88';
ctx.lineWidth = 2;
ctx.beginPath();
history.forEach((h, i) => {
const x = toX(h.generation);
const y = toY(h.best);
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
});
ctx.stroke();
// Draw avg fitness line
ctx.strokeStyle = '#4488ff';
ctx.lineWidth = 2;
ctx.beginPath();
history.forEach((h, i) => {
const x = toX(h.generation);
const y = toY(h.avg);
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
});
ctx.stroke();
// Legend
ctx.font = '12px monospace';
ctx.fillStyle = '#00ff88';
ctx.fillText('● Best', width - 120, 25);
ctx.fillStyle = '#4488ff';
ctx.fillText('● Avg', width - 60, 25);
};
useEffect(() => {
window.addEventListener('resize', draw);
// Also draw immediately
draw();
return () => window.removeEventListener('resize', draw);
}, [history]);
// Also use LayoutEffect to catch size changes?
// Or just simple resize observer.
// For now simple useEffect dependency on history + window resize is enough.
return (
<div ref={containerRef} style={{ width: '100%', height: '100%', minHeight: 0 }}>
<canvas
ref={canvasRef}
style={{ display: 'block' }}
/>
</div>
);
}

View File

@@ -0,0 +1,239 @@
/* NEAT Arena Layout */
.neat-arena-layout {
display: flex;
gap: 1.5rem;
height: 100%;
padding: 1.5rem;
background: linear-gradient(135deg, #0f0f23 0%, #1a1a2e 100%);
}
/* Left Panel: Controls */
.controls-panel {
flex: 0 0 320px;
display: flex;
flex-direction: column;
gap: 1rem;
overflow-y: auto;
padding-right: 0.5rem;
}
.control-section {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
padding: 1rem;
backdrop-filter: blur(10px);
}
.control-section h3 {
margin: 0 0 0.75rem 0;
font-size: 0.95rem;
font-weight: 600;
color: #fff;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.control-section h4 {
margin: 0 0 0.5rem 0;
font-size: 0.85rem;
font-weight: 600;
color: #aaa;
}
.control-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
/* Buttons */
.btn-primary,
.btn-secondary {
padding: 0.75rem 1rem;
border: none;
border-radius: 6px;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
text-align: center;
}
.btn-primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.btn-primary:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
.btn-primary.btn-stop {
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
}
.btn-secondary {
background: rgba(255, 255, 255, 0.1);
color: white;
border: 1px solid rgba(255, 255, 255, 0.2);
}
.btn-secondary:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.15);
border-color: rgba(255, 255, 255, 0.3);
}
.btn-primary:disabled,
.btn-secondary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Stats Grid */
.stats-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem;
}
.stat-item {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 0.75rem;
background: rgba(0, 0, 0, 0.3);
border-radius: 6px;
border: 1px solid rgba(255, 255, 255, 0.05);
}
.stat-label {
font-size: 0.75rem;
color: #aaa;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.stat-value {
font-size: 1.25rem;
font-weight: 700;
color: #fff;
font-variant-numeric: tabular-nums;
}
/* Checkbox */
.checkbox-label {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
color: #ddd;
font-size: 0.9rem;
}
.checkbox-label input[type="checkbox"] {
width: 18px;
height: 18px;
cursor: pointer;
}
/* Info Section */
.info-section {
background: rgba(102, 126, 234, 0.1);
border: 1px solid rgba(102, 126, 234, 0.3);
border-radius: 8px;
padding: 1rem;
}
.info-section p {
margin: 0 0 0.75rem 0;
color: #ddd;
font-size: 0.85rem;
line-height: 1.5;
}
.info-section ul {
margin: 0;
padding-left: 1.25rem;
color: #bbb;
font-size: 0.85rem;
}
.info-section ul li {
margin-bottom: 0.25rem;
}
.text-muted {
color: #888;
font-size: 0.8rem;
}
.info-text {
margin-top: 0.5rem;
color: #aaa;
font-size: 0.8rem;
font-style: italic;
}
/* Right Panel: Viewer */
.viewer-panel {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
}
.phaser-container {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.4);
border: 2px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
overflow: hidden;
}
.phaser-placeholder {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
.placeholder-content {
text-align: center;
color: rgba(255, 255, 255, 0.4);
}
.placeholder-content h2 {
margin: 0 0 0.5rem 0;
font-size: 2rem;
font-weight: 300;
}
.placeholder-content p {
margin: 0.25rem 0;
font-size: 1rem;
}
/* Scrollbar styling */
.controls-panel::-webkit-scrollbar {
width: 8px;
}
.controls-panel::-webkit-scrollbar-track {
background: rgba(0, 0, 0, 0.2);
border-radius: 4px;
}
.controls-panel::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.2);
border-radius: 4px;
}
.controls-panel::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.3);
}

View File

@@ -0,0 +1,388 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import AppContainer from '../../components/AppContainer';
import { createArenaViewer, getArenaScene } from '../../lib/neatArena/arenaScene';
import { createSimulation, stepSimulation } from '../../lib/neatArena/simulation';
import { spinnerBotAction } from '../../lib/neatArena/baselineBots';
import { createPopulation, getPopulationStats, DEFAULT_EVOLUTION_CONFIG, type Population } from '../../lib/neatArena/evolution';
import { createNetwork } from '../../lib/neatArena/network';
import { generateObservation, observationToInputs } from '../../lib/neatArena/sensors';
import { exportGenome, downloadGenomeAsFile, uploadGenomeFromFile } from '../../lib/neatArena/exportImport';
import type { SimulationState, AgentAction, Genome } from '../../lib/neatArena/types';
import type { TrainingWorkerMessage, TrainingWorkerResponse } from '../../lib/neatArena/training.worker';
import FitnessGraph from './FitnessGraph';
import './NeatArena.css';
/**
* NEAT Arena Miniapp
*
* Trains AI agents using NEAT (NeuroEvolution of Augmenting Topologies) to play
* a 2D top-down shooter arena via self-play.
*/
export default function NeatArena() {
// Training state
const [population, setPopulation] = useState<Population>(() => createPopulation(DEFAULT_EVOLUTION_CONFIG));
const [isTraining, setIsTraining] = useState(false);
const [showRays, setShowRays] = useState(false);
const [mapSeed] = useState(12345);
const [importedGenome, setImportedGenome] = useState<Genome | null>(null);
const [fitnessHistory, setFitnessHistory] = useState<{ generation: number; best: number; avg: number }[]>([]);
// Stats
const stats = getPopulationStats(population);
// Phaser game instance
const phaserGameRef = useRef<Phaser.Game | null>(null);
const phaserContainerRef = useRef<HTMLDivElement>(null);
// Exhibition match state (visualizing champion)
const simulationRef = useRef<SimulationState | null>(null);
// Web Worker
const workerRef = useRef<Worker | null>(null);
// Initialize Web Worker
useEffect(() => {
const worker = new Worker(new URL('../../lib/neatArena/training.worker.ts', import.meta.url), {
type: 'module'
});
worker.onmessage = (e: MessageEvent<TrainingWorkerResponse>) => {
const response = e.data;
switch (response.type) {
case 'update':
if (response.population) {
setPopulation(response.population);
console.log('[UI] Stats?', response.stats ? 'YES' : 'NO', response.stats);
// Track fitness history for graph
if (response.stats) {
setFitnessHistory(prev => [...prev, {
generation: response.stats!.generation,
best: response.stats!.maxFitness,
avg: response.stats!.avgFitness,
}]);
}
}
break;
case 'error':
console.error('Worker error:', response.error);
setIsTraining(false);
alert('Training error: ' + response.error);
break;
case 'ready':
console.log('Worker ready');
break;
}
};
// Initialize worker with config
worker.postMessage({
type: 'init',
config: DEFAULT_EVOLUTION_CONFIG,
} as TrainingWorkerMessage);
workerRef.current = worker;
return () => {
worker.terminate();
workerRef.current = null;
};
}, []);
// Control worker based on training state
useEffect(() => {
if (!workerRef.current) return;
if (isTraining) {
workerRef.current.postMessage({
type: 'start',
} as TrainingWorkerMessage);
} else {
workerRef.current.postMessage({
type: 'pause',
} as TrainingWorkerMessage);
}
}, [isTraining]);
// Initialize Phaser
useEffect(() => {
if (!phaserContainerRef.current) return;
phaserContainerRef.current.innerHTML = '';
const game = createArenaViewer(phaserContainerRef.current);
phaserGameRef.current = game;
simulationRef.current = createSimulation(mapSeed, 0);
return () => {
game.destroy(true);
phaserGameRef.current = null;
};
}, [mapSeed]);
// Exhibition match loop (visualizing best vs second-best AI)
useEffect(() => {
if (!phaserGameRef.current) return;
const interval = setInterval(() => {
if (!simulationRef.current) return;
const sim = simulationRef.current;
if (sim.isOver) {
simulationRef.current = createSimulation(mapSeed, 0);
return;
}
// Get best and second-best genomes
const sortedGenomes = [...population.genomes].sort((a, b) => b.fitness - a.fitness);
const genome0 = importedGenome || sortedGenomes[0] || null;
const genome1 = sortedGenomes.length > 1 ? sortedGenomes[1] : null;
// Agent 0: Best AI
let action0: AgentAction;
if (genome0) {
const network = createNetwork(genome0);
const obs = generateObservation(0, sim);
const inputs = observationToInputs(obs);
const outputs = network.activate(inputs);
action0 = {
moveX: outputs[0],
moveY: outputs[1],
turn: outputs[2],
shoot: outputs[3],
};
} else {
action0 = spinnerBotAction();
}
// Agent 1: Second-best AI (or spinner if not enough genomes)
let action1: AgentAction;
if (genome1) {
const network = createNetwork(genome1);
const obs = generateObservation(1, sim);
const inputs = observationToInputs(obs);
const outputs = network.activate(inputs);
action1 = {
moveX: outputs[0],
moveY: outputs[1],
turn: outputs[2],
shoot: outputs[3],
};
} else {
action1 = spinnerBotAction();
}
simulationRef.current = stepSimulation(sim, [action0, action1]);
if (phaserGameRef.current) {
const scene = getArenaScene(phaserGameRef.current);
scene.updateSimulation(simulationRef.current);
scene.setShowRays(showRays);
}
}, 1000 / 30);
return () => clearInterval(interval);
}, [showRays, mapSeed, population.genomes, importedGenome]);
const handleReset = useCallback(() => {
setIsTraining(false);
setImportedGenome(null);
setFitnessHistory([]);
if (workerRef.current) {
workerRef.current.postMessage({
type: 'reset',
} as TrainingWorkerMessage);
}
simulationRef.current = createSimulation(mapSeed, 0);
if (phaserGameRef.current) {
const scene = getArenaScene(phaserGameRef.current);
scene.updateSimulation(simulationRef.current);
}
}, [mapSeed]);
const handleStepGeneration = useCallback(() => {
if (workerRef.current) {
workerRef.current.postMessage({
type: 'step',
} as TrainingWorkerMessage);
}
}, []);
const handleExport = useCallback(() => {
if (!population.bestGenomeEver) {
alert('No champion to export yet!');
return;
}
const exported = exportGenome(
population.bestGenomeEver,
DEFAULT_EVOLUTION_CONFIG,
{
generation: stats.generation,
fitness: stats.bestFitnessEver,
speciesCount: stats.speciesCount,
}
);
downloadGenomeAsFile(exported, `neat-champion-gen${stats.generation}.json`);
}, [population.bestGenomeEver, stats]);
const handleImport = useCallback(async () => {
try {
const exported = await uploadGenomeFromFile();
setImportedGenome(exported.genome);
alert(`Imported champion from generation ${exported.metadata?.generation || '?'} with fitness ${exported.metadata?.fitness?.toFixed(1) || '?'}`);
} catch (err) {
alert('Failed to import genome: ' + (err as Error).message);
}
}, []);
return (
<AppContainer title="NEAT Arena">
<div className="neat-arena-layout">
{/* Left Panel: Controls */}
<div className="controls-panel">
<section className="control-section">
<h3>Training Controls</h3>
<div className="control-group">
<button
className={`btn-primary ${isTraining ? 'btn-stop' : 'btn-start'}`}
onClick={() => setIsTraining(!isTraining)}
>
{isTraining ? '⏸ Pause Training' : '▶ Start Training'}
</button>
<button
className="btn-secondary"
onClick={handleStepGeneration}
disabled={isTraining}
>
Step Generation
</button>
<button
className="btn-secondary"
onClick={handleReset}
disabled={isTraining}
>
🔄 Reset
</button>
</div>
<p className="info-text">
{isTraining
? '🟢 Training in background worker...'
: importedGenome
? '🎮 Watching imported champion vs Gen best'
: population.genomes.length > 1
? `🎮 Watching Gen ${stats.generation}: Best vs 2nd-Best AI`
: '⚪ Need at least 2 genomes for exhibition'}
</p>
</section>
<section className="control-section">
<h3>Evolution Stats</h3>
<div className="stats-grid">
<div className="stat-item">
<span className="stat-label">Generation</span>
<span className="stat-value">{stats.generation}</span>
</div>
<div className="stat-item">
<span className="stat-label">Species</span>
<span className="stat-value">{stats.speciesCount}</span>
</div>
<div className="stat-item">
<span className="stat-label">Best Fitness</span>
<span className="stat-value">{stats.maxFitness.toFixed(1)}</span>
</div>
<div className="stat-item">
<span className="stat-label">Avg Fitness</span>
<span className="stat-value">{stats.avgFitness.toFixed(1)}</span>
</div>
<div className="stat-item">
<span className="stat-label">Champion</span>
<span className="stat-value">{stats.bestFitnessEver.toFixed(1)}</span>
</div>
<div className="stat-item">
<span className="stat-label">Innovations</span>
<span className="stat-value">{stats.totalInnovations}</span>
</div>
</div>
</section>
{fitnessHistory.length > 0 && (
<section className="control-section">
<h3>Fitness Progress</h3>
<FitnessGraph history={fitnessHistory} />
</section>
)}
<section className="control-section">
<h3>Debug Options</h3>
<label className="checkbox-label">
<input
type="checkbox"
checked={showRays}
onChange={(e) => setShowRays(e.target.checked)}
/>
<span>Show Ray Sensors</span>
</label>
</section>
<section className="control-section">
<h3>Export / Import</h3>
<div className="control-group">
<button
className="btn-secondary"
onClick={handleExport}
disabled={!population.bestGenomeEver}
>
💾 Export Champion
</button>
<button
className="btn-secondary"
onClick={handleImport}
>
📂 Import Genome
</button>
</div>
{importedGenome && (
<p className="info-text">
Imported genome loaded
</p>
)}
</section>
<section className="info-section">
<h4>NEAT Arena Status</h4>
<ul>
<li> Deterministic 30Hz simulation</li>
<li> Symmetric procedural maps</li>
<li> Agent physics & bullets</li>
<li> 360° ray sensors (53 inputs)</li>
<li> NEAT evolution with speciation</li>
<li> Self-play training (K=4 matches)</li>
<li> Export/import genomes</li>
<li> Web worker (no UI lag!)</li>
</ul>
</section>
</div>
{/* Right Panel: Phaser Viewer */}
<div className="viewer-panel">
<div
ref={phaserContainerRef}
className="phaser-container"
/>
</div>
</div>
</AppContainer>
);
}

View File

@@ -0,0 +1,386 @@
import { useEffect, useRef, useState, useCallback } from 'react';
import type { Genotype } from './types';
import { generateMap } from './generator';
import { createRandomGenome, evaluatePopulation, evolve, type Individual, POPULATION_SIZE } from './evolution';
export default function RogueGenApp() {
const [generation, setGeneration] = useState(0);
const [bestFitness, setBestFitness] = useState(0);
const [population, setPopulation] = useState<Genotype[]>([]);
const [bestIndividual, setBestIndividual] = useState<Individual | null>(null);
const [isRunning, setIsRunning] = useState(false);
// Config
const [config, setConfig] = useState({
width: 100,
height: 80,
canvasScale: 4,
simulationSpeed: 100
});
// Targets & Overrides
const [targets, setTargets] = useState({
density: 0.45,
water: 0.15,
lava: 0.05,
veg: 0.20,
minPathLength: 50,
forceTunnels: false,
scaleOverride: 0
});
const canvasRef = useRef<HTMLCanvasElement>(null);
// Initialize
useEffect(() => {
const initPop = [];
for (let i = 0; i < POPULATION_SIZE; i++) initPop.push(createRandomGenome());
setPopulation(initPop);
}, []);
// Evolution Loop
const runGeneration = useCallback(() => {
if (!population.length) return;
// Apply overrides if needed (by modifying genome copy? No, better to pass override context)
// But for simplicity/visuals, we can just hack the population before eval?
// No, that ruins evolution.
// We probably want to visualize the BEST, but FORCE the generation parameters.
// Let's modify evaluatePopulation to handle overrides?
// Or simple hack: Temporarily modify genomes.
const popToEval = population.map(p => {
const copy = { ...p };
if (targets.forceTunnels) copy.noiseType = 1;
if (targets.scaleOverride > 0) copy.noiseScale = targets.scaleOverride;
return copy;
});
const evaluated = evaluatePopulation(popToEval, config.width, config.height, targets);
setBestIndividual(evaluated[0]);
setBestFitness(evaluated[0].fitness.score);
const nextGen = evolve(evaluated);
setPopulation(nextGen);
setGeneration(g => g + 1);
}, [population, config.width, config.height, targets]);
useEffect(() => {
let interval: ReturnType<typeof setInterval>;
if (isRunning) {
interval = setInterval(runGeneration, config.simulationSpeed);
}
return () => clearInterval(interval);
}, [isRunning, runGeneration, config.simulationSpeed]);
// Render Best Map
useEffect(() => {
if (!bestIndividual || !canvasRef.current) return;
const ctx = canvasRef.current.getContext('2d');
if (!ctx) return;
const map = generateMap(bestIndividual.genome, config.width, config.height, targets.minPathLength);
ctx.fillStyle = "#111";
ctx.fillRect(0, 0, config.width * config.canvasScale, config.height * config.canvasScale);
// Draw
for (let y = 0; y < config.height; y++) {
for (let x = 0; x < config.width; x++) {
const val = map.grid[y * config.width + x];
if (val === 1) {
ctx.fillStyle = "#889"; // Wall
ctx.fillRect(x * config.canvasScale, y * config.canvasScale, config.canvasScale, config.canvasScale);
} else if (val === 2) {
ctx.fillStyle = "#48d"; // Water
ctx.fillRect(x * config.canvasScale, y * config.canvasScale, config.canvasScale, config.canvasScale);
} else if (val === 3) {
ctx.fillStyle = "#e44"; // Lava
ctx.fillRect(x * config.canvasScale, y * config.canvasScale, config.canvasScale, config.canvasScale);
} else if (val === 4) {
ctx.fillStyle = "#2a4"; // Veg
ctx.fillRect(x * config.canvasScale, y * config.canvasScale, config.canvasScale, config.canvasScale);
}
}
}
// Draw Start/End
if (map.startPoint && map.endPoint && map.pathLength && map.pathLength > 0) {
// Start
ctx.fillStyle = "#ff0";
ctx.fillRect(map.startPoint.x * config.canvasScale, map.startPoint.y * config.canvasScale, config.canvasScale, config.canvasScale);
// End
ctx.fillStyle = "#f0f";
ctx.fillRect(map.endPoint.x * config.canvasScale, map.endPoint.y * config.canvasScale, config.canvasScale, config.canvasScale);
// Text labels?
ctx.font = '10px monospace';
ctx.fillStyle = "#fff";
ctx.fillText("S", map.startPoint.x * config.canvasScale + 2, map.startPoint.y * config.canvasScale + 8);
ctx.fillText("E", map.endPoint.x * config.canvasScale + 2, map.endPoint.y * config.canvasScale + 8);
}
}, [bestIndividual, config]);
return (
<div className="rogue-gen-app" style={{
display: 'flex',
height: '100%',
background: '#1a1a1a',
color: '#eee',
fontFamily: 'monospace'
}}>
{/* Sidebar Controls */}
<div className="sidebar-panel" style={{
width: '320px',
padding: '20px',
background: '#222',
borderRight: '1px solid #333',
display: 'flex',
flexDirection: 'column',
gap: '20px',
overflowY: 'auto'
}}>
<div style={{ borderBottom: '1px solid #444', paddingBottom: '10px' }}>
<h2 style={{ margin: '0 0 5px 0', fontSize: '1.2em', color: '#88f' }}>Rogue Map Evo</h2>
<div style={{ fontSize: '0.8em', color: '#888' }}>Gen: {generation} | Best: {bestFitness.toFixed(4)}</div>
</div>
<div className="control-group">
<h3 style={{ fontSize: '1em', marginBottom: '10px', color: '#ccc' }}>Controls</h3>
<button
onClick={() => setIsRunning(!isRunning)}
style={{
width: '100%',
padding: '12px',
fontSize: '14px',
fontWeight: 'bold',
background: isRunning ? '#c44' : '#4a4',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
marginBottom: '10px'
}}
>
{isRunning ? 'STOP EVOLUTION' : 'START EVOLUTION'}
</button>
<button
onClick={() => {
setGeneration(0);
const initPop = [];
for (let i = 0; i < POPULATION_SIZE; i++) initPop.push(createRandomGenome());
setPopulation(initPop);
setBestIndividual(null);
}}
style={{
width: '100%',
padding: '8px',
background: '#444',
color: '#ccc',
border: '1px solid #555',
borderRadius: '4px',
cursor: 'pointer'
}}
>
Reset Population
</button>
</div>
<div className="control-group">
<h3 style={{ fontSize: '1em', marginBottom: '10px', color: '#ccc' }}>Configuration</h3>
<label style={{ display: 'block', marginBottom: '8px', fontSize: '0.9em' }}>
Map Width: {config.width}
<input
type="range" min="20" max="300" step="10"
value={config.width}
onChange={e => setConfig({ ...config, width: Number(e.target.value) })}
style={{ width: '100%', accentColor: '#88f' }}
/>
</label>
<label style={{ display: 'block', marginBottom: '8px', fontSize: '0.9em' }}>
Map Height: {config.height}
<input
type="range" min="20" max="300" step="10"
value={config.height}
onChange={e => setConfig({ ...config, height: Number(e.target.value) })}
style={{ width: '100%', accentColor: '#88f' }}
/>
</label>
<label style={{ display: 'block', marginBottom: '8px', fontSize: '0.9em' }}>
Zoom: {config.canvasScale}x
<input
type="range" min="1" max="20" step="1"
value={config.canvasScale}
onChange={e => setConfig({ ...config, canvasScale: Number(e.target.value) })}
style={{ width: '100%', accentColor: '#88f' }}
/>
</label>
<label style={{ display: 'block', marginBottom: '8px', fontSize: '0.9em' }}>
Speed: {config.simulationSpeed}ms
<input
type="range" min="10" max="1000" step="10"
value={config.simulationSpeed}
onChange={e => setConfig({ ...config, simulationSpeed: Number(e.target.value) })}
style={{ width: '100%', accentColor: '#88f' }}
/>
</label>
<h3 style={{ fontSize: '1em', marginBottom: '10px', marginTop: '20px', color: '#ccc' }}>Map Style</h3>
<label style={{ display: 'block', marginBottom: '8px', fontSize: '0.9em', cursor: 'pointer' }}>
<input
type="checkbox"
checked={targets.forceTunnels}
onChange={e => setTargets({ ...targets, forceTunnels: e.target.checked })}
style={{ marginRight: '5px' }}
/>
Force Tunnels (Ridged)
</label>
<label style={{ display: 'block', marginBottom: '8px', fontSize: '0.9em' }}>
Feature Scale: {targets.scaleOverride > 0 ? targets.scaleOverride : 'Auto'}
<input
type="range" min="0" max="50" step="1"
value={targets.scaleOverride}
onChange={e => setTargets({ ...targets, scaleOverride: Number(e.target.value) })}
style={{ width: '100%', accentColor: '#aaa' }}
/>
<div style={{ fontSize: '0.8em', color: '#666' }}>(0 = Evolve Scale)</div>
</label>
<h3 style={{ fontSize: '1em', marginBottom: '10px', marginTop: '20px', color: '#ccc' }}>Terrain Targets</h3>
<label style={{ display: 'block', marginBottom: '8px', fontSize: '0.9em' }}>
Open Space: {(targets.density * 100).toFixed(0)}%
<input
type="range" min="0.1" max="0.9" step="0.05"
value={targets.density}
onChange={e => setTargets({ ...targets, density: Number(e.target.value) })}
style={{ width: '100%', accentColor: '#aaa' }}
/>
</label>
<label style={{ display: 'block', marginBottom: '8px', fontSize: '0.9em', color: '#48d' }}>
Water: {(targets.water * 100).toFixed(0)}%
<input
type="range" min="0" max="0.5" step="0.05"
value={targets.water}
onChange={e => setTargets({ ...targets, water: Number(e.target.value) })}
style={{ width: '100%', accentColor: '#48d' }}
/>
</label>
<label style={{ display: 'block', marginBottom: '8px', fontSize: '0.9em', color: '#e44' }}>
Lava: {(targets.lava * 100).toFixed(0)}%
<input
type="range" min="0" max="0.5" step="0.05"
value={targets.lava}
onChange={e => setTargets({ ...targets, lava: Number(e.target.value) })}
style={{ width: '100%', accentColor: '#e44' }}
/>
</label>
<label style={{ display: 'block', marginBottom: '8px', fontSize: '0.9em', color: '#2a4' }}>
Veg: {(targets.veg * 100).toFixed(0)}%
<input
type="range" min="0" max="0.8" step="0.05"
value={targets.veg}
onChange={e => setTargets({ ...targets, veg: Number(e.target.value) })}
style={{ width: '100%', accentColor: '#2a4' }}
/>
</label>
<label style={{ display: 'block', marginBottom: '8px', fontSize: '0.9em', color: '#fa4' }}>
Min Path: {targets.minPathLength} tiles
<input
type="range" min="0" max="1000" step="5"
value={targets.minPathLength}
onChange={e => setTargets({ ...targets, minPathLength: Number(e.target.value) })}
style={{ width: '100%', accentColor: '#fa4' }}
/>
</label>
</div>
{bestIndividual && (
<div className="stats-panel" style={{
background: '#111',
padding: '10px',
borderRadius: '4px',
fontSize: '0.85em',
border: '1px solid #333'
}}>
<h4 style={{ margin: '0 0 10px 0', color: '#aaa' }}>Best Genome (Wall)</h4>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '5px', marginBottom: '10px' }}>
<div>Init P:</div><div style={{ textAlign: 'right', color: '#8f8' }}>{bestIndividual.genome.initialChance.toFixed(2)}</div>
<div>Birth:</div><div style={{ textAlign: 'right', color: '#8f8' }}>{bestIndividual.genome.birthLimit}</div>
<div>Death:</div><div style={{ textAlign: 'right', color: '#8f8' }}>{bestIndividual.genome.deathLimit}</div>
<div>Steps:</div><div style={{ textAlign: 'right', color: '#8f8' }}>{bestIndividual.genome.steps}</div>
<div>Smooth:</div><div style={{ textAlign: 'right', color: '#8f8' }}>{bestIndividual.genome.smoothingSteps}</div>
<div>Cleanup:</div><div style={{ textAlign: 'right', color: '#8f8' }}>{bestIndividual.genome.noiseReduction ? 'Yes' : 'No'}</div>
</div>
<h4 style={{ margin: '0 0 10px 0', color: '#aaa' }}>Best Genome (Water/Lava/Veg)</h4>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '5px', fontSize: '0.8em' }}>
<div style={{ color: '#48d' }}>WATER</div>
<div style={{ color: '#e44' }}>LAVA</div>
<div style={{ color: '#2a4' }}>VEG</div>
<div style={{ color: '#ccc' }}>{bestIndividual.genome.waterInitialChance.toFixed(2)}</div>
<div style={{ color: '#ccc' }}>{bestIndividual.genome.lavaInitialChance.toFixed(2)}</div>
<div style={{ color: '#ccc' }}>{bestIndividual.genome.vegInitialChance.toFixed(2)}</div>
<div style={{ color: '#666' }}>Steps</div>
<div style={{ color: '#666' }}>Steps</div>
<div style={{ color: '#666' }}>Steps</div>
<div style={{ color: '#ccc' }}>{bestIndividual.genome.waterSteps}</div>
<div style={{ color: '#ccc' }}>{bestIndividual.genome.lavaSteps}</div>
<div style={{ color: '#ccc' }}>{bestIndividual.genome.vegSteps}</div>
</div>
<h4 style={{ margin: '10px 0 5px 0', color: '#aaa' }}>Structure</h4>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '5px' }}>
<div>Noise:</div><div style={{ textAlign: 'right', color: '#ccc' }}>{bestIndividual.genome.useNoise ? (bestIndividual.genome.noiseType === 1 ? 'Tunnel' : 'Blob') : 'No'}</div>
<div>Scale:</div><div style={{ textAlign: 'right', color: '#ccc' }}>{bestIndividual.genome.noiseScale.toFixed(1)}</div>
<div>Rooms:</div><div style={{ textAlign: 'right', color: '#ccc' }}>{bestIndividual.genome.useRooms ? bestIndividual.genome.roomCount : 'No'}</div>
</div>
<hr style={{ borderColor: '#333', margin: '10px 0' }} />
<h4 style={{ margin: '0 0 10px 0', color: '#aaa' }}>Metrics</h4>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '5px' }}>
<div>Connect:</div><div style={{ textAlign: 'right', color: '#fa4' }}>{(bestIndividual.fitness.connectivity * 100).toFixed(1)}%</div>
<div>Density:</div><div style={{ textAlign: 'right', color: '#fa4' }}>{(bestIndividual.fitness.density * 100).toFixed(1)}%</div>
<div>Path:</div><div style={{ textAlign: 'right', color: '#ff0' }}>{generateMap(bestIndividual.genome, config.width, config.height).pathLength}</div>
</div>
</div>
)}
</div>
{/* Main Visualization */}
<div className="visualization-area" style={{
flex: 1,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
background: '#0d0d0d',
overflow: 'auto',
padding: '20px'
}}>
<div style={{
border: '5px solid #333',
borderRadius: '4px',
boxShadow: '0 0 20px rgba(0,0,0,0.5)',
lineHeight: 0
}}>
<canvas
ref={canvasRef}
width={config.width * config.canvasScale}
height={config.height * config.canvasScale}
/>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,155 @@
import type { Genotype } from './types';
import { generateMap } from './generator';
import { calculateFitness, type FitnessResult, type FitnessTargets } from './fitness';
export interface Individual {
genome: Genotype;
fitness: FitnessResult;
}
export const POPULATION_SIZE = 50;
const MUTATION_RATE = 0.1;
export function createRandomGenome(): Genotype {
return {
initialChance: Math.random(), // 0.0 - 1.0
birthLimit: Math.floor(Math.random() * 8) + 1, // 1-8
deathLimit: Math.floor(Math.random() * 8) + 1, // 1-8
steps: Math.floor(Math.random() * 7) + 3, // 3-10 (Forced minimum steps to prevent static)
smoothingSteps: Math.floor(Math.random() * 6), // 0-5
noiseReduction: Math.random() < 0.5,
useNoise: Math.random() < 0.8, // High chance to use noise
noiseType: Math.random() < 0.5 ? 0 : 1, // Random start
noiseScale: Math.random() * 40 + 10, // 10-50
noiseThreshold: Math.random() * 0.4 + 0.3, // 0.3-0.7
useRooms: Math.random() < 0.8, // High chance
roomCount: Math.floor(Math.random() * 15) + 3, // 3-18
roomMinSize: Math.floor(Math.random() * 4) + 3, // 3-7
roomMaxSize: Math.floor(Math.random() * 8) + 8, // 8-16
waterInitialChance: Math.random(),
waterBirthLimit: Math.floor(Math.random() * 8) + 1,
waterDeathLimit: Math.floor(Math.random() * 8) + 1,
waterSteps: Math.floor(Math.random() * 7) + 3, // 3-10
lavaInitialChance: Math.random() * 0.5, // Rare
lavaBirthLimit: Math.floor(Math.random() * 8) + 1,
lavaDeathLimit: Math.floor(Math.random() * 8) + 1,
lavaSteps: Math.floor(Math.random() * 7) + 3, // 3-10
vegInitialChance: Math.random(),
vegBirthLimit: Math.floor(Math.random() * 8) + 1,
vegDeathLimit: Math.floor(Math.random() * 8) + 1,
vegSteps: Math.floor(Math.random() * 7) + 3 // 3-10
};
}
export function evaluatePopulation(population: Genotype[], width: number, height: number, targets: FitnessTargets): Individual[] {
return population.map(genome => {
const map = generateMap(genome, width, height, targets.minPathLength);
const fitness = calculateFitness(map, targets);
return { genome, fitness };
}).sort((a, b) => b.fitness.score - a.fitness.score);
}
export function evolve(population: Individual[]): Genotype[] {
const newPop: Genotype[] = [];
// Elitism: Keep top 2
newPop.push(population[0].genome);
newPop.push(population[1].genome);
while (newPop.length < POPULATION_SIZE) {
const p1 = tournamentSelect(population);
const p2 = tournamentSelect(population);
const child = crossover(p1.genome, p2.genome);
mutate(child);
newPop.push(child);
}
return newPop;
}
function tournamentSelect(pop: Individual[]): Individual {
const k = 3;
let best = pop[Math.floor(Math.random() * pop.length)];
for (let i = 0; i < k - 1; i++) {
const cand = pop[Math.floor(Math.random() * pop.length)];
if (cand.fitness.score > best.fitness.score) {
best = cand;
}
}
return best;
}
function crossover(p1: Genotype, p2: Genotype): Genotype {
return {
initialChance: Math.random() < 0.5 ? p1.initialChance : p2.initialChance,
birthLimit: Math.random() < 0.5 ? p1.birthLimit : p2.birthLimit,
deathLimit: Math.random() < 0.5 ? p1.deathLimit : p2.deathLimit,
steps: Math.random() < 0.5 ? p1.steps : p2.steps,
smoothingSteps: Math.random() < 0.5 ? p1.smoothingSteps : p2.smoothingSteps,
noiseReduction: Math.random() < 0.5 ? p1.noiseReduction : p2.noiseReduction,
useNoise: Math.random() < 0.5 ? p1.useNoise : p2.useNoise,
noiseType: Math.random() < 0.5 ? p1.noiseType : p2.noiseType,
noiseScale: Math.random() < 0.5 ? p1.noiseScale : p2.noiseScale,
noiseThreshold: Math.random() < 0.5 ? p1.noiseThreshold : p2.noiseThreshold,
useRooms: Math.random() < 0.5 ? p1.useRooms : p2.useRooms,
roomCount: Math.random() < 0.5 ? p1.roomCount : p2.roomCount,
roomMinSize: Math.random() < 0.5 ? p1.roomMinSize : p2.roomMinSize,
roomMaxSize: Math.random() < 0.5 ? p1.roomMaxSize : p2.roomMaxSize,
waterInitialChance: Math.random() < 0.5 ? p1.waterInitialChance : p2.waterInitialChance,
waterBirthLimit: Math.random() < 0.5 ? p1.waterBirthLimit : p2.waterBirthLimit,
waterDeathLimit: Math.random() < 0.5 ? p1.waterDeathLimit : p2.waterDeathLimit,
waterSteps: Math.random() < 0.5 ? p1.waterSteps : p2.waterSteps,
lavaInitialChance: Math.random() < 0.5 ? p1.lavaInitialChance : p2.lavaInitialChance,
lavaBirthLimit: Math.random() < 0.5 ? p1.lavaBirthLimit : p2.lavaBirthLimit,
lavaDeathLimit: Math.random() < 0.5 ? p1.lavaDeathLimit : p2.lavaDeathLimit,
lavaSteps: Math.random() < 0.5 ? p1.lavaSteps : p2.lavaSteps,
vegInitialChance: Math.random() < 0.5 ? p1.vegInitialChance : p2.vegInitialChance,
vegBirthLimit: Math.random() < 0.5 ? p1.vegBirthLimit : p2.vegBirthLimit,
vegDeathLimit: Math.random() < 0.5 ? p1.vegDeathLimit : p2.vegDeathLimit,
vegSteps: Math.random() < 0.5 ? p1.vegSteps : p2.vegSteps,
};
}
function mutate(g: Genotype) {
if (Math.random() < MUTATION_RATE) g.initialChance = Math.max(0, Math.min(1, g.initialChance + (Math.random() - 0.5) * 0.1));
if (Math.random() < MUTATION_RATE) g.birthLimit = Math.max(1, Math.min(8, Math.floor(g.birthLimit + (Math.random() - 0.5) * 4)));
if (Math.random() < MUTATION_RATE) g.deathLimit = Math.max(1, Math.min(8, Math.floor(g.deathLimit + (Math.random() - 0.5) * 4)));
if (Math.random() < MUTATION_RATE) g.steps = Math.max(3, Math.min(10, Math.floor(g.steps + (Math.random() - 0.5) * 4)));
if (Math.random() < MUTATION_RATE) g.smoothingSteps = Math.max(0, Math.min(5, Math.floor(g.smoothingSteps + (Math.random() - 0.5) * 3)));
if (Math.random() < MUTATION_RATE) g.noiseReduction = !g.noiseReduction;
if (Math.random() < MUTATION_RATE) g.useNoise = !g.useNoise;
if (Math.random() < MUTATION_RATE) g.noiseType = g.noiseType === 0 ? 1 : 0;
if (Math.random() < MUTATION_RATE) g.noiseScale = Math.max(5, Math.min(80, g.noiseScale + (Math.random() - 0.5) * 5));
if (Math.random() < MUTATION_RATE) g.noiseThreshold = Math.max(0.1, Math.min(0.9, g.noiseThreshold + (Math.random() - 0.5) * 0.1));
if (Math.random() < MUTATION_RATE) g.useRooms = !g.useRooms;
if (Math.random() < MUTATION_RATE) g.roomCount = Math.max(0, Math.min(25, Math.floor(g.roomCount + (Math.random() - 0.5) * 3)));
if (Math.random() < MUTATION_RATE) g.roomMinSize = Math.max(3, Math.min(10, Math.floor(g.roomMinSize + (Math.random() - 0.5) * 2)));
if (Math.random() < MUTATION_RATE) g.roomMaxSize = Math.max(5, Math.min(20, Math.floor(g.roomMaxSize + (Math.random() - 0.5) * 2)));
if (Math.random() < MUTATION_RATE) g.waterInitialChance = Math.max(0, Math.min(1, g.waterInitialChance + (Math.random() - 0.5) * 0.1));
if (Math.random() < MUTATION_RATE) g.waterBirthLimit = Math.max(1, Math.min(8, Math.floor(g.waterBirthLimit + (Math.random() - 0.5) * 4)));
if (Math.random() < MUTATION_RATE) g.waterDeathLimit = Math.max(1, Math.min(8, Math.floor(g.waterDeathLimit + (Math.random() - 0.5) * 4)));
if (Math.random() < MUTATION_RATE) g.waterSteps = Math.max(3, Math.min(10, Math.floor(g.waterSteps + (Math.random() - 0.5) * 4)));
if (Math.random() < MUTATION_RATE) g.lavaInitialChance = Math.max(0, Math.min(1, g.lavaInitialChance + (Math.random() - 0.5) * 0.1));
if (Math.random() < MUTATION_RATE) g.lavaBirthLimit = Math.max(1, Math.min(8, Math.floor(g.lavaBirthLimit + (Math.random() - 0.5) * 4)));
if (Math.random() < MUTATION_RATE) g.lavaDeathLimit = Math.max(1, Math.min(8, Math.floor(g.lavaDeathLimit + (Math.random() - 0.5) * 4)));
if (Math.random() < MUTATION_RATE) g.lavaSteps = Math.max(3, Math.min(10, Math.floor(g.lavaSteps + (Math.random() - 0.5) * 4)));
if (Math.random() < MUTATION_RATE) g.vegInitialChance = Math.max(0, Math.min(1, g.vegInitialChance + (Math.random() - 0.5) * 0.1));
if (Math.random() < MUTATION_RATE) g.vegBirthLimit = Math.max(1, Math.min(8, Math.floor(g.vegBirthLimit + (Math.random() - 0.5) * 4)));
if (Math.random() < MUTATION_RATE) g.vegDeathLimit = Math.max(1, Math.min(8, Math.floor(g.vegDeathLimit + (Math.random() - 0.5) * 4)));
if (Math.random() < MUTATION_RATE) g.vegSteps = Math.max(3, Math.min(10, Math.floor(g.vegSteps + (Math.random() - 0.5) * 4)));
}

View File

@@ -0,0 +1,214 @@
import type { MapData } from './types';
export interface FitnessResult {
score: number;
connectivity: number;
density: number;
}
export interface FitnessTargets {
density: number;
water: number;
lava: number;
veg: number;
minPathLength: number; // New param
}
export function calculateFitness(map: MapData, targets: FitnessTargets): FitnessResult {
const { grid, width, height } = map;
let totalFloor = 0;
let totalWater = 0;
let totalLava = 0;
let totalVeg = 0;
// 1. Calculate Density (Target 45% floor - configurable)
// 1. Calculate Density (Target 45% floor - configurable)
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const t = grid[y * width + x];
if (t === 0) totalFloor++;
else if (t === 2) totalWater++;
else if (t === 3) totalLava++;
else if (t === 4) totalVeg++;
}
}
// "Open Space" = anything not a wall
const totalOpen = totalFloor + totalWater + totalLava + totalVeg;
const totalCells = width * height;
// Target Open Space (inverse of Wall Density?)
// Usually Density = Wall Density.
// If target is "Floor Density" (open space), we use targets.density directly.
// Let's assume targets.density = Target Open Space %.
const openDensity = totalOpen / totalCells;
const densityScore = 1 - Math.abs(openDensity - targets.density) * 2;
// Ratios within Open Space
if (totalOpen === 0) return { score: 0, connectivity: 0, density: 0 };
const waterRatio = totalWater / totalOpen;
const lavaRatio = totalLava / totalOpen;
const vegRatio = totalVeg / totalOpen;
const waterScore = 1 - Math.abs(waterRatio - targets.water) * 3;
const lavaScore = 1 - Math.abs(lavaRatio - targets.lava) * 5;
const vegScore = 1 - Math.abs(vegRatio - targets.veg) * 3;
// 2. Connectivity (Largest Flood Fill on WALKABLE tiles)
const walkableCells = totalFloor + totalVeg;
if (walkableCells === 0) {
return { score: 0, connectivity: 0, density: openDensity };
}
const visited = new Uint8Array(width * height);
let maxConnected = 0;
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
// Start flood fill on a walkable tile
const idx = y * width + x;
const tile = grid[idx];
// Check flat visited array
if ((tile === 0 || tile === 4) && visited[idx] === 0) {
const size = floodFill(grid, x, y, visited, width);
if (size > maxConnected) {
maxConnected = size;
}
}
}
}
const connectivity = maxConnected / walkableCells;
// Composite Score
let score = (connectivity * 0.4) +
(densityScore * 0.2) +
(waterScore * 0.1) +
(lavaScore * 0.15) +
(vegScore * 0.15);
if (connectivity < 0.5) score *= 0.1;
// Bonus for hitting targets closely if target > 0
// Bonus for hitting targets closely if target > 0
if (targets.lava > 0 && lavaRatio >= targets.lava * 0.8) score += 0.05;
if (targets.veg > 0 && vegRatio >= targets.veg * 0.8) score += 0.05;
// 3. Clumping Score (Avoid Static Noise)
// Check neighbors. If many neighbors are same type, good.
let sameNeighborCount = 0;
let totalChecks = 0;
for (let y = 1; y < height - 1; y += 2) { // Optimization: check every other pixel
for (let x = 1; x < width - 1; x += 2) {
const idx = y * width + x;
const self = grid[idx];
totalChecks++;
// extensive neighbor check
let localSame = 0;
if (grid[(y+1)*width + x] === self) localSame++;
if (grid[(y-1)*width + x] === self) localSame++;
if (grid[y*width + (x+1)] === self) localSame++;
if (grid[y*width + (x-1)] === self) localSame++;
if (localSame >= 2) sameNeighborCount++;
}
}
// Reward clumping strongly
const clumpingScore = totalChecks > 0 ? sameNeighborCount / totalChecks : 0;
score += clumpingScore * 0.3; // Significant bonus for non-noisy maps
// 4. Path Length Score
// If map.pathLength < minPathLength, penalize.
if (map.pathLength !== undefined && targets.minPathLength > 0) {
if (map.pathLength < targets.minPathLength) {
// Linear penalty? Or exponential?
// e.g. target 50. Actual 25. Score 0.5.
const ratio = map.pathLength / targets.minPathLength;
score *= ratio; // Hard penalty on everything if path is too short
} else {
score += 0.1; // Bonus for meeting criteria
}
}
return { score, connectivity, density: openDensity };
}
function floodFill(grid: Uint8Array, startX: number, startY: number, visited: Uint8Array, width: number): number {
let count = 0;
// Stack of coordinate pairs (packed or objects? Objects are slow. Let's use two stacks or one packed stack)
// Packed integer stack: y * width + x
const stack = [startY * width + startX];
// Mark visited
visited[startY * width + startX] = 1;
count++;
while (stack.length > 0) {
const packed = stack.pop()!;
const cx = packed % width;
const cy = Math.floor(packed / width);
// Inline neighbors for speed
// N
if (cy > 0) {
const ny = cy - 1;
const idx = ny * width + cx;
if (visited[idx] === 0) {
const t = grid[ny * width + cx];
if (t === 0 || t === 4) {
visited[idx] = 1;
stack.push(idx);
count++;
}
}
}
// S
const height = grid.length / width;
if (cy < height - 1) {
const ny = cy + 1;
const idx = ny * width + cx;
if (visited[idx] === 0) {
const t = grid[ny * width + cx];
if (t === 0 || t === 4) {
visited[idx] = 1;
stack.push(idx);
count++;
}
}
}
// W
if (cx > 0) {
const nx = cx - 1;
const idx = cy * width + nx;
if (visited[idx] === 0) {
const t = grid[cy * width + nx];
if (t === 0 || t === 4) {
visited[idx] = 1;
stack.push(idx);
count++;
}
}
}
// E
if (cx < width - 1) {
const nx = cx + 1;
const idx = cy * width + nx;
if (visited[idx] === 0) {
const t = grid[cy * width + nx];
if (t === 0 || t === 4) {
visited[idx] = 1;
stack.push(idx);
count++;
}
}
}
}
return count;
}

View File

@@ -0,0 +1,653 @@
import type { Genotype, MapData } from './types';
import { Perlin } from './perlin';
// Initialize Perlin once (or per gen? per gen better for seed, but instance is cheap)
// Actually we want random noise every time, Perlin class randomizes on init.
export function generateMap(genome: Genotype, width: number, height: number, minPathLength: number = 0): MapData {
let map = new Uint8Array(width * height);
// --- Step 1: Initialization (Noise vs Random) ---
if (genome.useNoise) {
const perlin = new Perlin();
const scale = genome.noiseScale || 20;
const threshold = genome.noiseThreshold || 0.45;
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const idx = y * width + x;
// Edges always walls
if (x === 0 || x === width - 1 || y === 0 || y === height - 1) {
map[idx] = 1;
continue;
}
// Noise value -1 to 1 usually
const value = perlin.noise(x / scale, y / scale, 0);
let isEmpty = false;
if (genome.noiseType === 1) {
// Tunnel Mode (Ridged): Empty space near 0
const tunnelWidth = genome.noiseThreshold * 0.5; // Scale down for thinner tunnels
if (Math.abs(value) < tunnelWidth) isEmpty = true;
} else {
// Blob Mode (Standard)
const norm = (value + 1) / 2;
if (norm >= threshold) isEmpty = true;
}
if (!isEmpty) map[idx] = 1; // Wall
else map[idx] = 0; // Floor
}
}
} else {
// Legacy Random Init
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const idx = y * width + x;
if (x === 0 || x === width - 1 || y === 0 || y === height - 1) {
map[idx] = 1;
} else {
map[idx] = Math.random() < genome.initialChance ? 1 : 0;
}
}
}
}
// --- Step 2: Room Injection ---
if (genome.useRooms) {
const count = genome.roomCount;
const min = genome.roomMinSize;
const max = genome.roomMaxSize;
for(let i=0; i<count; i++) {
const w = Math.floor(Math.random() * (max - min + 1)) + min;
const h = Math.floor(Math.random() * (max - min + 1)) + min;
const x = Math.floor(Math.random() * (width - w - 2)) + 1;
const y = Math.floor(Math.random() * (height - h - 2)) + 1;
// Stamp Room (Floor 0)
for(let ry = 0; ry < h; ry++) {
for(let rx = 0; rx < w; rx++) {
if (y+ry < height-1 && x+rx < width-1) {
map[(y+ry)*width + (x+rx)] = 0;
}
}
}
}
}
// --- Step 3: Cellular Automata ---
// Double buffer allocation ONCE
let buffer = new Uint8Array(width * height);
for (let s = 0; s < genome.steps; s++) {
// Copy map to buffer? Or just read from map write to buffer?
// Must handle edges.
// Optimization: Just swap references.
// Read from 'map', write to 'buffer'.
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const idx = y * width + x;
if (x === 0 || x === width - 1 || y === 0 || y === height - 1) {
buffer[idx] = 1;
continue;
}
const neighbors = countNeighbors(map, width, height, x, y, 1);
if (map[idx] === 1) {
// Wall logic
if (neighbors < genome.deathLimit) buffer[idx] = 0;
else buffer[idx] = 1;
} else {
// Floor logic
if (neighbors > genome.birthLimit) buffer[idx] = 1;
else buffer[idx] = 0;
}
}
}
// Swap
let temp = map;
map = buffer;
buffer = temp;
}
// Smoothing steps
for (let s = 0; s < genome.smoothingSteps; s++) {
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const idx = y * width + x;
if (x === 0 || x === width - 1 || y === 0 || y === height - 1) {
buffer[idx] = 1;
continue;
}
const neighbors = countNeighbors(map, width, height, x, y, 1);
if (neighbors > 4) buffer[idx] = 1;
else if (neighbors < 4) buffer[idx] = 0;
else buffer[idx] = map[idx];
}
}
let temp = map;
map = buffer;
buffer = temp;
}
// Noise Reduction
if (genome.noiseReduction) {
buffer.set(map);
for (let y = 1; y < height - 1; y++) {
for (let x = 1; x < width - 1; x++) {
const idx = y * width + x;
if (map[idx] === 1) {
if (countNeighbors(map, width, height, x, y, 1) <= 1) {
buffer[idx] = 0;
}
}
}
}
let temp = map;
map = buffer;
buffer = temp;
}
// --- Lava Layer Generation (Priority 2) ---
let lavaMap = runCASimulation(width, height, genome.lavaInitialChance, genome.lavaSteps, genome.lavaBirthLimit, genome.lavaDeathLimit, map, [1]);
applyLayer(map, lavaMap, 3); // 3 = Lava
// --- Water Layer Generation (Priority 3) ---
let waterMap = runCASimulation(width, height, genome.waterInitialChance, genome.waterSteps, genome.waterBirthLimit, genome.waterDeathLimit, map, [1, 3]);
applyLayer(map, waterMap, 2); // 2 = Water
// --- Vegetation Layer Generation (Priority 4) ---
let vegMap = runCASimulation(width, height, genome.vegInitialChance, genome.vegSteps, genome.vegBirthLimit, genome.vegDeathLimit, map, [1, 2, 3]);
applyLayer(map, vegMap, 4); // 4 = Veg
// --- Step 4b: Post-Processing (Bridge Building with Pruning and Wobble) ---
connectRegions(map, width, height);
// --- Step 5: Start & Exit Points ---
// Strategy:
// 1. Try Random Valid Path strategy (random start, random end > minPathLength)
// 2. If that fails (or no minPathLength given), FALLBACK to Double BFS (Diameter) to maximize path.
let finalStart = {x:0, y:0};
let finalEnd = {x:0, y:0};
let pathDist = 0;
let found = false;
// Use minPathLength or fallback heuristic
const targetDist = minPathLength > 0 ? minPathLength : Math.max(width, height) * 0.4;
// ATTEMPT 1: Random Points (Variety)
for(let attempt=0; attempt<10; attempt++) {
// 1. Pick random start
let startX = -1, startY = -1;
let tries = 0;
while(tries < 50) {
const rx = Math.floor(Math.random() * (width - 2)) + 1;
const ry = Math.floor(Math.random() * (height - 2)) + 1;
const t = map[ry*width+rx];
if (t === 0 || t === 4) { // Floor/Veg
startX = rx; startY = ry;
break;
}
tries++;
}
if (startX === -1) continue;
// 2. BFS Flood to find candidates
const dists = bfsFlood(map, width, height, startX, startY);
const candidates = [];
for(let y=1; y<height-1; y++) {
for(let x=1; x<width-1; x++) {
const d = dists[y*width+x];
if (d >= targetDist) { // Strict GE check
candidates.push({x, y, dist: d});
}
}
}
if (candidates.length > 0) {
// Found at least one good path!
const chosen = candidates[Math.floor(Math.random() * candidates.length)];
finalStart = {x: startX, y: startY};
finalEnd = {x: chosen.x, y: chosen.y};
pathDist = chosen.dist;
found = true;
break;
}
}
// ATTEMPT 2: Fallback to Diameter (Reliability)
// If we couldn't find a random path > targetDist (maybe target is too high, or we got unlucky),
// we MUST try to find the longest possible path to show the user the "best" this map can do.
if (!found) {
// 1. Pick any valid point
let startX = -1, startY = -1;
outer2: for(let y=1; y<height-1; y++) {
for(let x=1; x<width-1; x++) {
if (map[y*width+x] === 0 || map[y*width+x] === 4) {
startX = x; startY = y;
break outer2;
}
}
}
if (startX !== -1) {
// 2. Find furthest from A -> B
const pB = bfsFurthest(map, width, height, startX, startY);
// 3. Find furthest from B -> C (Approximates Diameter)
const pC = bfsFurthest(map, width, height, pB.x, pB.y);
finalStart = {x: pB.x, y: pB.y};
finalEnd = {x: pC.x, y: pC.y};
pathDist = pC.dist;
}
}
return {
grid: map,
width,
height,
startPoint: finalStart,
endPoint: finalEnd,
pathLength: pathDist
};
}
// Simple BFS Flood returning distances array
function bfsFlood(grid: Uint8Array, width: number, height: number, startX: number, startY: number): Int32Array {
const dists = new Int32Array(width * height).fill(-1);
const queue = [startY * width + startX];
dists[startY * width + startX] = 0;
let head = 0;
while(head < queue.length) {
const packed = queue[head++];
const cx = packed % width;
const cy = Math.floor(packed / width);
const d = dists[packed];
// Inline neighbors
for(let i=0; i<4; i++) {
// Ideally we check bounds. But since perimeter is always wall (1),
// we technically won't escape if we trust the wall.
// BUT, index could wrap if we are at x=width-1 and do +1 -> next row x=0.
// Safer to do coord check.
let nx = cx, ny = cy;
if (i===0) ny--;
else if (i===1) ny++;
else if (i===2) nx--;
else if (i===3) nx++;
if (nx >= 0 && nx < width && ny >= 0 && ny < height) {
const nIdx = ny * width + nx;
if (dists[nIdx] === -1) {
const t = grid[nIdx];
if (t === 0 || t === 4) { // Walkable
dists[nIdx] = d + 1;
queue.push(nIdx);
}
}
}
}
}
return dists;
}
// Helper to run a CA simulation for a feature layer
function runCASimulation(width: number, height: number, initialChance: number, steps: number, birth: number, death: number, baseMap: Uint8Array, forbiddenTiles: number[]): Uint8Array {
let layer = new Uint8Array(width * height);
// Initialize
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const idx = y * width + x;
if (forbiddenTiles.includes(baseMap[idx])) {
layer[idx] = 0;
} else {
layer[idx] = Math.random() < initialChance ? 1 : 0;
}
}
}
let buffer = new Uint8Array(width * height);
// Run Steps
for (let s = 0; s < steps; s++) {
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const idx = y * width + x;
if (forbiddenTiles.includes(baseMap[idx])) {
buffer[idx] = 0; // Ensure forbidden stays empty
continue;
}
// Edges
if (x === 0 || x === width - 1 || y === 0 || y === height - 1) {
buffer[idx] = 1; // Or 0? Features usually unbound. Let's say 0.
continue;
}
// Count neighbors of THIS layer (1s)
const neighbors = countNeighbors(layer, width, height, x, y, 1);
if (layer[idx] === 1) {
if (neighbors < death) buffer[idx] = 0;
else buffer[idx] = 1;
} else {
if (neighbors > birth) buffer[idx] = 1;
else buffer[idx] = 0;
}
}
}
// Swap
let temp = layer;
layer = buffer;
buffer = temp;
}
return layer;
}
function applyLayer(baseMap: Uint8Array, layer: Uint8Array, typeId: number) {
for (let i = 0; i < baseMap.length; i++) {
if (layer[i] === 1) {
if (baseMap[i] === 0) {
baseMap[i] = typeId;
}
}
}
}
// BFS to find all connected regions of walkable tiles
function getRegions(map: Uint8Array, width: number, height: number): {points: {x:number, y:number}[], id: number}[] {
const visited = new Uint8Array(width * height);
const regions = [];
let regionId = 0;
for (let y = 1; y < height - 1; y++) {
for (let x = 1; x < width - 1; x++) {
const idx = y * width + x;
// Walkable: 0 (Floor) or 4 (Veg)
if ((map[idx] === 0 || map[idx] === 4) && visited[idx] === 0) {
const points = [];
// Packed stack (DFS)
const stack = [idx];
visited[idx] = 1;
points.push({x, y});
while(stack.length > 0) {
const packed = stack.pop()!;
const cx = packed % width;
const cy = Math.floor(packed / width);
// Neighbors
// N
if (cy > 0) {
const ny = cy - 1; const nx = cx;
const nIdx = ny * width + nx;
if (visited[nIdx] === 0) {
const t = map[nIdx];
if (t === 0 || t === 4) {
visited[nIdx] = 1;
points.push({x:nx, y:ny});
stack.push(nIdx);
}
}
}
// S
if (cy < height - 1) {
const ny = cy + 1; const nx = cx;
const nIdx = ny * width + nx;
if (visited[nIdx] === 0) {
const t = map[nIdx];
if (t === 0 || t === 4) {
visited[nIdx] = 1;
points.push({x:nx, y:ny});
stack.push(nIdx);
}
}
}
// W
if (cx > 0) {
const ny = cy; const nx = cx - 1;
const nIdx = ny * width + nx;
if (visited[nIdx] === 0) {
const t = map[nIdx];
if (t === 0 || t === 4) {
visited[nIdx] = 1;
points.push({x:nx, y:ny});
stack.push(nIdx);
}
}
}
// E
if (cx < width - 1) {
const ny = cy; const nx = cx + 1;
const nIdx = ny * width + nx;
if (visited[nIdx] === 0) {
const t = map[nIdx];
if (t === 0 || t === 4) {
visited[nIdx] = 1;
points.push({x:nx, y:ny});
stack.push(nIdx);
}
}
}
}
regions.push({points, id: regionId++});
}
}
}
return regions;
}
function connectRegions(map: Uint8Array, width: number, height: number) {
let regions = getRegions(map, width, height);
// PRUNING: Remove tiny regions (noise artifacts)
const PRUNE_SIZE = 12;
for (let i = regions.length - 1; i >= 0; i--) {
if (regions[i].points.length < PRUNE_SIZE) {
// Fill with wall
for(const p of regions[i].points) {
map[p.y * width + p.x] = 1;
}
regions.splice(i, 1);
}
}
if (regions.length <= 1) return;
// Sort by largest (Main)
regions.sort((a, b) => b.points.length - a.points.length);
const mainRegion = regions[0];
// Connect remaining
for (let i = 1; i < regions.length; i++) {
const region = regions[i];
let minDistance = Infinity;
let startPoint = {x:0, y:0};
let endPoint = {x:0, y:0};
// OPTIMIZATION: Sampling
const sampleSize = 30; // Check 30 random points
const mainSamples = [];
if (mainRegion.points.length > sampleSize) {
for(let k=0; k<sampleSize; k++) {
mainSamples.push(mainRegion.points[Math.floor(Math.random() * mainRegion.points.length)]);
}
} else {
mainSamples.push(...mainRegion.points);
}
const regionSamples = [];
if (region.points.length > sampleSize) {
for(let k=0; k<sampleSize; k++) {
regionSamples.push(region.points[Math.floor(Math.random() * region.points.length)]);
}
} else {
regionSamples.push(...region.points);
}
// Compare samples
for(const pA of mainSamples) {
for(const pB of regionSamples) {
const dist = (pA.x-pB.x)**2 + (pA.y-pB.y)**2;
if (dist < minDistance) {
minDistance = dist;
startPoint = pA;
endPoint = pB;
}
}
}
// Draw bridge - ORGANIC "DRUNKARD'S" LINE
let cursorX = startPoint.x;
let cursorY = startPoint.y;
const dx = endPoint.x - startPoint.x;
const dy = endPoint.y - startPoint.y;
const dist = Math.sqrt(dx*dx + dy*dy);
// Normalize direction
const stepX = dx / dist;
const stepY = dy / dist;
let steps = Math.floor(dist);
for(let s=0; s<=steps; s++) {
// Move generally towards target
cursorX += stepX;
cursorY += stepY;
// Add jitter
const jitter = (Math.random() - 0.5) * 1.5;
const px = Math.floor(cursorX + jitter);
const py = Math.floor(cursorY + jitter);
// Carve with brush size 2 for playability
for(let by=0; by<=1; by++) {
for(let bx=0; bx<=1; bx++) {
const carverY = py+by;
const carverX = px+bx;
if (carverY>0 && carverY<height-1 && carverX>0 && carverX<width-1) {
const idx = carverY * width + carverX;
// Overwrite anything that isn't already Floor/Veg
if (map[idx] !== 0 && map[idx] !== 4) {
map[idx] = 0;
}
}
}
}
}
}
}
function countNeighbors(map: Uint8Array, width: number, height: number, x: number, y: number, targetInfo: number): number {
let count = 0;
// Inline checks for performance?
// 3x3 loop
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
if (dx === 0 && dy === 0) continue;
const nx = x + dx;
const ny = y + dy;
if (ny < 0 || ny >= height || nx < 0 || nx >= width) {
if (targetInfo === 1) count++; // Edges are walls
} else {
if (map[ny * width + nx] === targetInfo) {
count++;
}
}
}
}
return count;
}
function bfsFurthest(grid: Uint8Array, width: number, height: number, startX: number, startY: number): {x: number, y: number, dist: number} {
// Use Int32Array for distances to support large maps (-1 init)
const dists = new Int32Array(width * height).fill(-1);
// Packed queue
const queue = [startY * width + startX];
dists[startY * width + startX] = 0;
let furthest = {x: startX, y: startY, dist: 0};
// Using Queue (Shift) is slow.
// Circular buffer or pointer index is better.
let head = 0;
while(head < queue.length) {
const packed = queue[head++];
const cx = packed % width;
const cy = Math.floor(packed / width);
const d = dists[packed];
if (d > furthest.dist) {
furthest = {x: cx, y: cy, dist: d};
}
// Inline neighbors
// N
if (cy > 0) {
const idx = (cy - 1) * width + cx;
if (dists[idx] === -1) {
const t = grid[idx];
if (t === 0 || t === 4) {
dists[idx] = d + 1;
queue.push(idx);
}
}
}
// S
if (cy < height - 1) {
const idx = (cy + 1) * width + cx;
if (dists[idx] === -1) {
const t = grid[idx];
if (t === 0 || t === 4) {
dists[idx] = d + 1;
queue.push(idx);
}
}
}
// W
if (cx > 0) {
const idx = cy * width + (cx - 1);
if (dists[idx] === -1) {
const t = grid[idx];
if (t === 0 || t === 4) {
dists[idx] = d + 1;
queue.push(idx);
}
}
}
// E
if (cx < width - 1) {
const idx = cy * width + (cx + 1);
if (dists[idx] === -1) {
const t = grid[idx];
if (t === 0 || t === 4) {
dists[idx] = d + 1;
queue.push(idx);
}
}
}
}
return furthest;
}

View File

@@ -0,0 +1,61 @@
export class Perlin {
private perm: number[];
constructor() {
this.perm = new Array(512);
const p = new Array(256).fill(0).map((_, i) => i);
// Shuffle
for (let i = 255; i > 0; i--) {
const r = Math.floor(Math.random() * (i + 1));
[p[i], p[r]] = [p[r], p[i]];
}
for (let i = 0; i < 512; i++) {
this.perm[i] = p[i & 255];
}
}
public noise(x: number, y: number, z: number): number {
const X = Math.floor(x) & 255;
const Y = Math.floor(y) & 255;
const Z = Math.floor(z) & 255;
x -= Math.floor(x);
y -= Math.floor(y);
z -= Math.floor(z);
const u = fade(x);
const v = fade(y);
const w = fade(z);
const A = this.perm[X] + Y;
const AA = this.perm[A] + Z;
const AB = this.perm[A + 1] + Z;
const B = this.perm[X + 1] + Y;
const BA = this.perm[B] + Z;
const BB = this.perm[B + 1] + Z;
return lerp(w, lerp(v, lerp(u, grad(this.perm[AA], x, y, z),
grad(this.perm[BA], x - 1, y, z)),
lerp(u, grad(this.perm[AB], x, y - 1, z),
grad(this.perm[BB], x - 1, y - 1, z))),
lerp(v, lerp(u, grad(this.perm[AA + 1], x, y, z - 1),
grad(this.perm[BA + 1], x - 1, y, z - 1)),
lerp(u, grad(this.perm[AB + 1], x, y - 1, z - 1),
grad(this.perm[BB + 1], x - 1, y - 1, z - 1))));
}
}
function fade(t: number): number {
return t * t * t * (t * (t * 6 - 15) + 10);
}
function lerp(t: number, a: number, b: number): number {
return a + t * (b - a);
}
function grad(hash: number, x: number, y: number, z: number): number {
const h = hash & 15;
const u = h < 8 ? x : y;
const v = h < 4 ? y : h === 12 || h === 14 ? x : z;
return ((h & 1) === 0 ? u : -u) + ((h & 2) === 0 ? v : -v);
}

View File

@@ -0,0 +1,46 @@
export interface Genotype {
initialChance: number; // 0.0 - 1.0
birthLimit: number; // 1 - 8
deathLimit: number; // 1 - 8
steps: number; // 1 - 10
smoothingSteps: number; // 0 - 5
noiseReduction: boolean; // Remove small unconnected walls
// Hybrid Generation
useNoise: boolean; // If true, use Perlin Noise instead of random noise
noiseType: number; // 0 = Blob (Standard), 1 = Tunnel (Ridged)
noiseScale: number; // 5-50 (Zoom level)
noiseThreshold: number; // 0.2 - 0.8 (Sea/Wall level)
useRooms: boolean; // If true, inject rooms
roomCount: number; // 0-20
roomMinSize: number; // 3-8
roomMaxSize: number; // 8-15
// Water Layer (2)
waterInitialChance: number;
waterBirthLimit: number;
waterDeathLimit: number;
waterSteps: number;
// Lava Layer (3)
lavaInitialChance: number;
lavaBirthLimit: number;
lavaDeathLimit: number;
lavaSteps: number;
// Vegetation Layer (4)
vegInitialChance: number;
vegBirthLimit: number;
vegDeathLimit: number;
vegSteps: number;
}
export interface MapData {
grid: Uint8Array; // 1 = wall, 0 = floor, flat array (y*width+x)
width: number;
height: number;
startPoint?: {x: number, y: number};
endPoint?: {x: number, y: number};
pathLength?: number;
}

View File

@@ -0,0 +1,46 @@
import Matter from 'matter-js';
import { describe, expect, it, beforeEach } from 'bun:test';
import { Car } from './Car';
import { DenseNetwork } from '../LunarLander/DenseNetwork';
import { DEFAULT_CAR_CONFIG } from './types';
describe('Car Logic - Fitness & Stagnation', () => {
let car: Car;
let brain: DenseNetwork;
beforeEach(() => {
brain = new DenseNetwork([6, 8, 2]); // Standard topology
car = new Car(100, 100, brain, 0, DEFAULT_CAR_CONFIG);
});
it('should initialize with 0 fitness', () => {
expect(car.fitness).toBe(0);
expect(car.isDead).toBe(false);
});
it('should NOT lose fitness on death', () => {
car.kill();
expect(car.fitness).toBe(0);
expect(car.isDead).toBe(true);
});
it('should accumulate continuous fitness when moving', () => {
// Mock speed
// Can't easily mock speed on Body as it is computed.
// But we can check update path progress logic if we had path points.
const points = Array.from({length: 100}, (_, i) => ({x: i*10, y: 0}));
car.body.position = {x: 0, y: 0};
// Initial update should set index 0
car.update([], points);
// Move to index 1
car.body.position = {x: 10, y: 0};
car.update([], points);
// Should have gained fitness
expect(car.fitness).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,345 @@
import Matter from 'matter-js';
import { DEFAULT_CAR_CONFIG } from './types';
import type { CarConfig } from './types';
// import { NeuralNetwork } from '../../lib/neatArena/network';
import { DenseNetwork } from '../LunarLander/DenseNetwork';
import { distance, lineToLineIntersection } from './geom';
// Physics Tunings Removed (Now in config)
export class Car {
public body: Matter.Body;
public brain: DenseNetwork;
public isDead: boolean = false;
public fitness: number = 0;
public checkpointsPassed: number = 0;
public rayReadings: number[] = [];
public config: CarConfig;
// START NEW TRACKING LOGIC
private currentPathIndex: number = 0;
private laps: number = 0;
private maxPathIndexReached: number = 0;
private initialPosSet: boolean = false;
private framesSinceCheckpoint: number = 0;
// Fitness tracking
private totalFrames: number = 0;
private speedSum: number = 0;
private lastSteer: number = 0;
private steeringChangeSum: number = 0;
constructor(
x: number,
y: number,
brain: DenseNetwork,
angle: number = 0,
config: CarConfig = DEFAULT_CAR_CONFIG
) {
this.brain = brain;
this.config = config;
// Create Physics Body
this.body = Matter.Bodies.rectangle(x, y, config.width, config.height, {
angle: angle,
frictionAir: config.frictionAir,
friction: config.friction,
density: 0.01,
label: 'car'
});
}
public update(walls: Matter.Body[], pathPoints: {x:number, y:number}[]) {
if (this.isDead) return;
// Init start position on path
if (!this.initialPosSet && pathPoints.length > 0) {
this.currentPathIndex = this.findClosestIndex(pathPoints, 0); // Search wide
this.initialPosSet = true;
}
// Stagnation Killer - TIGHTENED to prevent local minima loops
this.framesSinceCheckpoint++;
if (this.framesSinceCheckpoint > 300) { // 5 seconds without progress
this.kill();
return;
}
// ANTI-EXPLOIT: Minimum progress requirements
// Must reach checkpoint 8 within first 8 seconds (stricter than before)
if (this.totalFrames > 480 && this.maxPathIndexReached < 8) {
this.kill();
return;
}
// Must reach checkpoint 3 within first 3 seconds (catches immediate crashers)
if (this.totalFrames > 180 && this.maxPathIndexReached < 3) {
this.kill();
return;
}
// 1. Sensors
this.rayReadings = this.castRays(walls);
// 2. Think - Expanded inputs for better control awareness
const forward = {
x: Math.cos(this.body.angle - Math.PI/2),
y: Math.sin(this.body.angle - Math.PI/2)
};
const right = { x: -forward.y, y: forward.x };
// Velocity in car's local frame (for drift detection)
const localVelX = this.body.velocity.x * forward.x + this.body.velocity.y * forward.y;
const localVelY = this.body.velocity.x * right.x + this.body.velocity.y * right.y;
const inputs = [
...this.rayReadings, // 7 rays
localVelX / this.config.maxSpeed, // Normalize forward/backward velocity
localVelY / this.config.maxSpeed, // Normalize lateral velocity (drift)
this.body.angularVelocity / this.config.turnSpeed, // Normalize rotation rate
this.body.speed / this.config.maxSpeed, // Normalize speed magnitude
];
const outputs = this.brain.predict(inputs);
const steer = outputs[0];
let gas = outputs[1];
// Track metrics for fitness calculation
this.totalFrames++;
this.speedSum += this.body.speed;
this.steeringChangeSum += Math.abs(steer - this.lastSteer);
this.lastSteer = steer;
// 3. Act (Kickstart)
if (this.framesSinceCheckpoint < 60 && this.fitness < 2) {
gas = 1.0;
} else if (this.body.speed < 0.2) {
gas = 1.0;
}
// Physics: Steering
if (this.body.speed > 0.5) {
Matter.Body.setAngularVelocity(this.body, steer * this.config.turnSpeed * Math.sign(gas));
}
// Physics: Gas (Forward Force) - reuse forward vector from input calculation
// Physics: Lateral Friction (Tire Grip)
this.applyTireGrip(forward);
if (gas > 0) {
const force = 0.003 * gas; // slightly stronger engine
Matter.Body.applyForce(this.body, this.body.position, { x: forward.x * force, y: forward.y * force });
} else {
// Braking is less magical now
const brakeEffect = 0.98;
Matter.Body.setVelocity(this.body, { x: this.body.velocity.x * brakeEffect, y: this.body.velocity.y * brakeEffect });
}
// Speed Limit
if (this.body.speed > this.config.maxSpeed) {
Matter.Body.setVelocity(this.body, {
x: this.body.velocity.x * (this.config.maxSpeed/this.body.speed),
y: this.body.velocity.y * (this.config.maxSpeed/this.body.speed)
});
}
// 4. Update Fitness (Continuous Path Progress)
if (pathPoints.length > 0) {
this.updatePathProgress(pathPoints);
}
}
private applyTireGrip(forward: {x:number, y:number}) {
// Compute current velocity
const velocity = this.body.velocity;
// Compute Right vector
const right = { x: -forward.y, y: forward.x };
// Lateral Velocity = Dot(Velocity, Right)
const lateralSpeed = velocity.x * right.x + velocity.y * right.y;
// Lateral Impulse = -Lateral Velocity * (0.0 to 1.0)
// 1.0 = Perfect rails
// 0.0 = Ice
const lateralImpulse = lateralSpeed * this.config.lateralFriction;
// Apply impulse against the lateral motion
// Matter does impulses as force * time? No, setVelocity is cheating.
// Let's modify velocity directly for stability.
Matter.Body.setVelocity(this.body, {
x: velocity.x - right.x * lateralImpulse,
y: velocity.y - right.y * lateralImpulse
});
}
private updatePathProgress(pathPoints: {x:number, y:number}[]) {
// Find closest point LOCAL SEARCH
// Search window: +/- 20 points from current index, handling wrap-around
const searchRadius = 20;
const total = pathPoints.length;
let bestDist = Infinity;
let bestIndex = this.currentPathIndex;
for (let i = -searchRadius; i <= searchRadius; i++) {
let idx = (this.currentPathIndex + i);
// Handle wrap
if (idx < 0) idx += total;
if (idx >= total) idx -= total;
const d = distance(this.body.position, pathPoints[idx]);
// Use <= to favor forward points (later in the loop) when equidistant
// This is critical for loop closure where end overlaps start
if (d <= bestDist) {
bestDist = d;
bestIndex = idx;
}
}
// Did we move forward or backward?
// Simple logic: delta index
let delta = bestIndex - this.currentPathIndex;
// Wrap detection
// Jump from total-1 to 0 (Forward Lap) -> Delta is negative large number (e.g. -499)
// Jump from 0 to total-1 (Reverse) -> Delta is positive large number (e.g. +499)
if (delta < -total / 2) {
// Forward Lap
this.laps++;
delta += total;
} else if (delta > total / 2) {
// Backward Lap
this.laps--;
delta -= total;
}
// ANTI-EXPLOIT: Only reward progress if moving forward
// Check if velocity is aligned with path direction
if (delta > 0) {
// Calculate expected direction to next checkpoint
const nextIdx = (bestIndex + 1) % total;
const pathDir = {
x: pathPoints[nextIdx].x - pathPoints[bestIndex].x,
y: pathPoints[nextIdx].y - pathPoints[bestIndex].y
};
const pathDirMag = Math.sqrt(pathDir.x * pathDir.x + pathDir.y * pathDir.y);
if (pathDirMag > 0.1) {
// Normalize
pathDir.x /= pathDirMag;
pathDir.y /= pathDirMag;
// Dot product with velocity
const velDot = this.body.velocity.x * pathDir.x + this.body.velocity.y * pathDir.y;
// Only allow progress if moving roughly forward (dot > 0)
if (velDot < 0) {
// Moving backward relative to path - REJECT progress
delta = 0;
bestIndex = this.currentPathIndex; // Don't update position
}
}
}
// Update state
this.currentPathIndex = bestIndex;
// Calculate continuous fitness with bonuses
const rawScore = (this.laps * total) + this.currentPathIndex;
// Base fitness from progress
let baseFitness = Math.max(0, rawScore / 10.0);
// Speed bonus: reward faster completion
const avgSpeed = this.totalFrames > 0 ? this.speedSum / this.totalFrames : 0;
const speedBonus = (avgSpeed / this.config.maxSpeed) * 0.2 * baseFitness; // Up to 20% bonus
// Smoothness penalty: penalize jerky steering
const avgSteeringChange = this.totalFrames > 0 ? this.steeringChangeSum / this.totalFrames : 0;
const smoothnessPenalty = avgSteeringChange * 0.1 * baseFitness; // Up to 10% penalty
// ANTI-EXPLOIT: Early death penalty
// Cars must survive at least 3 seconds to get any fitness at all
let finalFitness = baseFitness + speedBonus - smoothnessPenalty;
if (this.totalFrames < 180) { // Less than 3 seconds survived
finalFitness = 0; // No fitness for instant crashes
} else if (this.totalFrames < 300) { // Less than 5 seconds
// Strong penalty for early deaths (50% reduction)
finalFitness *= 0.5;
}
this.fitness = Math.max(0, finalFitness);
// Stagnation Check
const absoluteIndex = (this.laps * total) + this.currentPathIndex;
if (absoluteIndex > this.maxPathIndexReached) {
this.maxPathIndexReached = absoluteIndex;
this.framesSinceCheckpoint = 0;
}
}
private findClosestIndex(points: {x:number, y:number}[], _startIndex: number): number {
let bestDist = Infinity;
let bestIdx = 0;
for(let i=0; i<points.length; i++) { // brute init
const d = distance(this.body.position, points[i]);
if (d < bestDist) { bestDist = d; bestIdx = i; }
}
return bestIdx;
}
private castRays(walls: Matter.Body[]): number[] {
// ... (Keep existing ray logic)
const rays: number[] = [];
const start = this.body.position;
const forwardAngle = this.body.angle - Math.PI/2;
const startRayAngle = forwardAngle - this.config.raySpread / 2;
const angleStep = this.config.raySpread / (this.config.rayCount - 1);
for (let i = 0; i < this.config.rayCount; i++) {
const angle = startRayAngle + i * angleStep;
const dir = { x: Math.cos(angle), y: Math.sin(angle) };
const end = {
x: start.x + dir.x * this.config.rayLength,
y: start.y + dir.y * this.config.rayLength
};
let minDist = 1.0;
for (const wall of walls) {
const d = distance(start, wall.position);
if (d > this.config.rayLength + 100) continue;
const dist = this.rayBodyIntersect(start, end, wall);
if (dist < minDist) minDist = dist;
}
rays.push(1.0 - minDist);
}
return rays;
}
private rayBodyIntersect(start: {x:number, y:number}, end: {x:number, y:number}, body: Matter.Body): number {
// ... (Keep existing logic)
const verts = body.vertices;
let minDist = 1.2;
for (let i = 0; i < verts.length; i++) {
const p1 = verts[i];
const p2 = verts[(i + 1) % verts.length];
const intersection = lineToLineIntersection(start.x, start.y, end.x, end.y, p1.x, p1.y, p2.x, p2.y);
if (intersection) {
const d = distance(start, intersection);
const normalizedD = d / this.config.rayLength;
if (normalizedD < minDist) minDist = normalizedD;
}
}
return minDist;
}
public kill() {
if (this.isDead) return;
this.isDead = true;
}
}

View File

@@ -0,0 +1,395 @@
import Phaser from 'phaser';
import { CarSimulation } from './CarSimulation';
import { Car } from './Car';
import { DEFAULT_SIM_CONFIG, DEFAULT_CAR_CONFIG } from './types';
import type { SerializedTrackData, CarConfig, SimulationConfig } from './types';
import { TrackGenerator } from './Track';
// NEAT Imports REMOVED
// import { createPopulation, evolveGeneration, DEFAULT_EVOLUTION_CONFIG, type Population, type EvolutionConfig } from '../../lib/neatArena/evolution';
// import type { Genome } from '../../lib/neatArena/genome';
import { SimpleGA, DEFAULT_GA_CONFIG } from './SimpleGA';
import type { GAConfig } from './SimpleGA';
// Worker Import (Vite/Bun compatible)
import TrainingWorker from './training.worker.ts?worker';
export class CarScene extends Phaser.Scene {
private sim!: CarSimulation;
private graphics!: Phaser.GameObjects.Graphics;
// UI Text
private statsText!: Phaser.GameObjects.Text;
private fitnessText!: Phaser.GameObjects.Text;
// Training State
private worker!: Worker;
private population: Float32Array[] = [];
private gaConfig = DEFAULT_GA_CONFIG;
private ga: SimpleGA;
private generationCount = 0;
private bestGenomeEver: Float32Array | null = null;
private bestFitnessEver = -Infinity;
private serializedTrack!: SerializedTrackData;
private layerSizes = [11, 24, 16, 2]; // 11 Inputs (7 rays + 4 dynamics), 24/16 Hidden, 2 Outputs
// Config
private carConfig: CarConfig = DEFAULT_CAR_CONFIG;
private simConfig: SimulationConfig = DEFAULT_SIM_CONFIG;
private instanceId: string;
constructor() {
super({ key: 'CarScene' });
this.instanceId = Math.random().toString(36).substring(7);
console.log(`[CarScene:${this.instanceId}] Constructor called`);
this.ga = new SimpleGA(this.layerSizes, this.gaConfig);
}
create() {
// ... (Keep existing setup)
this.cameras.main.setBackgroundColor('#222222');
this.graphics = this.add.graphics();
this.startTraining();
// Listen for new track request
this.game.events.on('new-track', () => this.handleNewTrack()); // Refactored handler
// Cleanup
this.events.on('shutdown', this.shutdown, this);
this.events.on('destroy', this.shutdown, this);
// Listen for Config Updates
this.game.events.on('update-config', (cfg: { car: CarConfig, sim: SimulationConfig, ga?: GAConfig }) => {
this.carConfig = cfg.car;
this.simConfig = cfg.sim;
// Update GA config if provided
if (cfg.ga) {
this.gaConfig = cfg.ga;
this.ga = new SimpleGA(this.layerSizes, this.gaConfig);
}
// HOT RELOAD PHYSICS
if (this.sim) {
this.sim.updateConfig(this.carConfig);
// Restart visual sim with updated config so changes apply immediately
if (this.bestGenomeEver) {
this.sim = new CarSimulation(
this.serializedTrack,
{ ...this.simConfig, populationSize: 1 },
[this.bestGenomeEver],
this.carConfig
);
}
// Also update Worker config for NEXT generation
if (this.worker) {
// We can't interrupt the worker mid-gen
// Config updates apply on next generation
}
}
});
// Create stats text overlay
this.statsText = this.add.text(20, 170, '', {
fontSize: '14px',
color: '#ffffff',
backgroundColor: '#000000aa',
padding: { x: 8, y: 6 }
}).setDepth(100);
this.fitnessText = this.add.text(20, 210, '', {
fontSize: '12px',
color: '#4ecdc4',
backgroundColor: '#000000aa',
padding: { x: 8, y: 6 }
}).setDepth(100);
// ... debug texts ... (rest of create)
}
private handleNewTrack() {
if (this.worker) {
this.worker.terminate();
this.worker = null as any; // CRITICAL: Set to null so startTraining creates new worker
}
this.sim = null as any;
// Recreate GA with current config (important for population size changes)
this.ga = new SimpleGA(this.layerSizes, this.gaConfig);
this.population = this.ga.createPopulation();
this.generationCount = 0;
this.bestFitnessEver = -Infinity;
this.bestGenomeEver = null;
this.game.events.emit('generation-complete', { generation: 0, best: 0, average: 0 });
this.startTraining();
}
private startTraining() {
// 1. Generate Track (Main Thread)
const generator = new TrackGenerator(this.scale.width, this.scale.height);
// Use current Sim Config for Complexity/Length
const rawTrack = generator.generate(this.simConfig.trackComplexity, this.simConfig.trackLength);
// 2. Serialize Track
const serializedTrack: SerializedTrackData = {
innerWalls: rawTrack.innerWalls.map(v => ({ x: v.x, y: v.y })),
outerWalls: rawTrack.outerWalls.map(v => ({ x: v.x, y: v.y })),
pathPoints: rawTrack.pathPoints.map(v => ({ x: v.x, y: v.y })),
startPosition: { x: rawTrack.startPosition.x, y: rawTrack.startPosition.y },
startAngle: rawTrack.startAngle,
walls: rawTrack.walls.map(b => ({
position: { x: b.position.x, y: b.position.y },
angle: b.angle,
width: b.bounds.max.x - b.bounds.min.x,
height: b.bounds.max.y - b.bounds.min.y,
label: b.label,
isSensor: b.isSensor
})),
checkpoints: rawTrack.checkpoints.map(b => ({
position: { x: b.position.x, y: b.position.y },
angle: b.angle,
width: b.bounds.max.x - b.bounds.min.x,
height: b.bounds.max.y - b.bounds.min.y,
label: b.label,
isSensor: b.isSensor
}))
};
this.serializedTrack = serializedTrack;
// 3. Initialize Population
if (this.population.length === 0) {
this.population = this.ga.createPopulation();
}
// 4. Initialize Worker
if (!this.worker) { // Only create if missing (or terminated)
this.worker = new TrainingWorker();
this.worker.onmessage = (e) => {
if (e.data.type === 'TRAIN_COMPLETE') {
this.handleTrainingComplete(e.data.results);
}
};
}
// 5. Start First Generation (Worker)
this.startWorkerGeneration();
// 6. Initialize Visual Sim
this.sim = new CarSimulation(this.serializedTrack, { ...this.simConfig, populationSize: 1 }, [], this.carConfig);
}
private startWorkerGeneration() {
if (!this.worker) return;
this.worker.postMessage({
type: 'TRAIN',
trackData: this.serializedTrack,
genomes: this.population,
config: this.simConfig, // Pass latest sim config
carConfig: this.carConfig, // Pass latest car config
steps: 60 * 60
});
}
private handleTrainingComplete(results: { fitness: number, checkpoints: number }[]) {
// 1. Assign Fitness
const fitnesses = results.map(r => r.fitness);
// Stats
const bestGenFit = Math.max(...fitnesses);
const avgGenFit = fitnesses.reduce((a,b) => a+b, 0) / fitnesses.length;
this.generationCount++;
let newChampionFound = false;
if (bestGenFit > this.bestFitnessEver) {
this.bestFitnessEver = bestGenFit;
const bestIdx = fitnesses.indexOf(bestGenFit);
this.bestGenomeEver = this.population[bestIdx];
newChampionFound = true;
}
// 2. Evolve
this.population = this.ga.evolve(this.population, fitnesses);
// 3. Emit Stats
const stats = {
generation: this.generationCount,
best: this.bestFitnessEver,
average: avgGenFit
};
console.log(`[CarScene:${this.instanceId}] Generation ${this.generationCount} complete. Emitting stats:`, stats);
this.game.events.emit('generation-complete', stats);
// 4. Update Visual Sim ONLY if we found a better car
// If we didn't improve, we let the current one keep running (it will loop itself)
if (newChampionFound && this.bestGenomeEver) {
// Visual feedback of new record?
this.updateVisualSim(this.bestGenomeEver);
}
// 5. Loop Internal Training
this.startWorkerGeneration();
}
private updateVisualSim(bestGenome: Float32Array) {
// Restart sim with just 1 car (The Champion)
// We reuse the track data
this.sim = new CarSimulation(
this.serializedTrack,
{ ...this.simConfig, populationSize: 1 },
[bestGenome],
this.carConfig // FIXED: Use current carConfig, not default
);
}
update(_time: number, _delta: number) {
// Step Simulation (Visual Only)
this.sim.update();
// Check if visual car crashed/finished
// If so, respawn it (Infinite Loop of Fame)
if (this.sim.isFinished()) {
if (this.bestGenomeEver) {
this.updateVisualSim(this.bestGenomeEver);
} else {
// Should imply we are in init state, just restart whatever we have
// (or wait for gen 1)
}
}
// Render
this.graphics.clear();
this.drawTrack();
this.sim.cars.forEach(car => {
this.drawCar(car);
});
// Update stats text
const aliveCount = this.sim.cars.filter(c => !c.isDead).length;
this.statsText.setText(`Gen: ${this.generationCount} | Alive: ${aliveCount}/${this.sim.cars.length}`);
if (this.sim.cars.length > 0) {
const bestCar = this.sim.cars[0];
this.fitnessText.setText(
`Fitness: ${bestCar.fitness.toFixed(2)} | Speed: ${bestCar.body.speed.toFixed(1)}`
);
}
}
private drawTrack() {
if (!this.serializedTrack) return;
// Draw Smooth Track Surface (Dark Grey Road)
this.graphics.fillStyle(0x333333);
const outer = this.serializedTrack.outerWalls;
const inner = this.serializedTrack.innerWalls;
this.graphics.fillStyle(0x333333);
this.graphics.lineStyle(2, 0x555555); // Wall edges
for (let i = 0; i < outer.length - 1; i++) {
this.graphics.beginPath();
this.graphics.moveTo(inner[i].x, inner[i].y);
this.graphics.lineTo(outer[i].x, outer[i].y);
this.graphics.lineTo(outer[i+1].x, outer[i+1].y);
this.graphics.lineTo(inner[i+1].x, inner[i+1].y);
this.graphics.closePath();
this.graphics.fillPath();
this.graphics.strokePath();
}
// PHYSICS DEBUG: Draw actual physical bodies in Red/Blue to check alignment
this.sim.walls.forEach(wall => {
this.graphics.lineStyle(1, 0xff0000, 0.5); // Red Walls
this.graphics.beginPath();
const v = wall.vertices;
this.graphics.moveTo(v[0].x, v[0].y);
for(let k=1; k<v.length; k++) this.graphics.lineTo(v[k].x, v[k].y);
this.graphics.closePath();
this.graphics.strokePath();
});
this.sim.checkpoints.forEach((cp, i) => {
if (i===0) this.graphics.fillStyle(0x00ff00, 0.5);
else this.graphics.fillStyle(0x00ffff, 0.3); // Cyan checkpoints
this.graphics.beginPath();
const v = cp.vertices;
this.graphics.moveTo(v[0].x, v[0].y);
for(let k=1; k<v.length; k++) this.graphics.lineTo(v[k].x, v[k].y);
this.graphics.closePath();
this.graphics.fillPath();
});
}
shutdown() {
if (this.worker) this.worker.terminate();
}
private drawCar(car: Car) {
const p = car.body.position;
// Body
this.graphics.fillStyle(car.isDead ? 0x550000 : 0x00ff00);
this.graphics.translateCanvas(p.x, p.y);
this.graphics.rotateCanvas(car.body.angle);
this.graphics.fillRect(-10, -20, 20, 40); // Approx size
this.graphics.rotateCanvas(-car.body.angle);
this.graphics.translateCanvas(-p.x, -p.y);
// Draw Rays with color-coding (Only for the best car in visual mode)
if (!car.isDead && this.sim.cars.length === 1) {
const start = car.body.position;
const angleBase = car.body.angle - Math.PI/2;
const raySpread = this.carConfig.raySpread;
const rayCount = this.carConfig.rayCount;
const rayLen = this.carConfig.rayLength;
// Use actual ray readings for color-coding
const readings = car.rayReadings;
const startRayAngle = angleBase - raySpread / 2;
const angleStep = raySpread / (rayCount - 1);
for(let i=0; i<rayCount; i++) {
const angle = startRayAngle + i * angleStep;
const reading = readings[i] || 0; // 0 = far, 1 = close
// Color interpolation: Green (far) -> Yellow -> Red (close)
const r = Math.floor(reading * 255);
const g = Math.floor((1 - reading) * 255);
const color = (r << 16) | (g << 8) | 0;
this.graphics.lineStyle(2, color, 0.6);
this.graphics.beginPath();
this.graphics.moveTo(start.x, start.y);
this.graphics.lineTo(
start.x + Math.cos(angle) * rayLen,
start.y + Math.sin(angle) * rayLen
);
this.graphics.strokePath();
// Draw hit point if detected
if (reading > 0.1) {
const hitDist = (1 - reading) * rayLen;
const hitX = start.x + Math.cos(angle) * hitDist;
const hitY = start.y + Math.sin(angle) * hitDist;
this.graphics.fillStyle(color, 0.8);
this.graphics.fillCircle(hitX, hitY, 3);
}
}
// Draw fitness overlay
this.graphics.fillStyle(0xffffff);
this.graphics.generateTexture('text', 200, 50);
}
}
}

View File

@@ -0,0 +1,203 @@
// @ts-ignore
import decomp from 'poly-decomp';
// Register decomp for Worker
import Matter from 'matter-js';
import { DenseNetwork } from '../LunarLander/DenseNetwork';
import { Car } from './Car';
import type { SimulationConfig, SerializedTrackData, CarConfig } from './types';
import { DEFAULT_SIM_CONFIG, DEFAULT_CAR_CONFIG } from './types';
Matter.Common.setDecomp(decomp);
// ... (other imports)
export class CarSimulation {
public engine: Matter.Engine;
public cars: Car[] = [];
public walls: Matter.Body[] = [];
public checkpoints: Matter.Body[] = [];
// Sim State
public generation: number = 1;
public frameValues: number = 0;
private config: SimulationConfig;
private trackData: SerializedTrackData;
private genomes: Float32Array[] = [];
private carConfig: CarConfig;
constructor(
trackData: SerializedTrackData,
config: SimulationConfig = DEFAULT_SIM_CONFIG,
genomes: Float32Array[] = [],
carConfig: CarConfig = DEFAULT_CAR_CONFIG
) {
this.trackData = trackData;
this.config = config;
this.genomes = genomes;
this.carConfig = carConfig;
// Create detached engine
this.engine = Matter.Engine.create();
this.engine.gravity.x = 0;
this.engine.gravity.y = 0; // Top down
// 1. Setup Track from Data
this.walls = trackData.walls.map(w => {
if (w.vertices && w.vertices.length > 0) {
return Matter.Bodies.fromVertices(
w.position.x, w.position.y,
[w.vertices],
{
isStatic: true,
label: w.label,
// Restore angle if needed, but fromVertices might bake it?
// Actually Track.ts creates from global coords, so angle is implicit in vertices?
// No, Matter bodies created from vertices are centered.
// Track.ts: `Bodies.fromVertices(center, ..., [[v1, v2...]])`.
// The vertices passed to Track.ts are GLOBAL.
// Matter.fromVertices recalculates center and translates vertices to local.
// Serialized vertices should be consistent with this.
// We should pass vertices as they are.
}
);
} else {
return Matter.Bodies.rectangle(
w.position.x, w.position.y, w.width, w.height, {
isStatic: true,
angle: w.angle,
label: w.label
}
);
}
});
this.checkpoints = trackData.checkpoints.map(cp => Matter.Bodies.rectangle(
cp.position.x, cp.position.y, cp.width, cp.height, {
isStatic: true,
isSensor: true,
angle: cp.angle,
label: cp.label
}
));
Matter.World.add(this.engine.world, this.walls);
Matter.World.add(this.engine.world, this.checkpoints);
// Events
Matter.Events.on(this.engine, 'collisionStart', (e) => this.handleCollisions(e));
// 2. Spawn
this.spawnGeneration();
}
public update() {
// Step Physics directly
Matter.Engine.update(this.engine, 1000 / 60);
// Update Cars Logic
let aliveCount = 0;
this.cars.forEach(car => {
if (!car.isDead) {
car.update(this.walls, this.trackData.pathPoints);
aliveCount++;
}
});
if (aliveCount === 0) {
this.nextGeneration();
}
}
private spawnGeneration() {
// Cleanup bodies
this.cars.forEach(c => Matter.World.remove(this.engine.world, c.body));
this.cars = [];
// If we have genomes, use them. Otherwise mock.
const effectivePopSize = this.genomes.length > 0 ? this.genomes.length : this.config.populationSize;
const layerSizes = [11, 24, 16, 2]; // Input (7 rays + vel x/y + angular vel + speed), Hidden layers, Output (steer, gas)
for (let i = 0; i < effectivePopSize; i++) {
let network: DenseNetwork;
if (this.genomes.length > 0) {
network = new DenseNetwork(layerSizes, this.genomes[i]);
} else {
// Random new
network = new DenseNetwork(layerSizes);
}
const car = new Car(
this.trackData.startPosition.x,
this.trackData.startPosition.y,
network,
this.trackData.startAngle + Math.PI / 2,
this.carConfig
);
Matter.World.add(this.engine.world, car.body);
this.cars.push(car);
}
}
public onGenerationComplete?: (stats: { generation: number, best: number, average: number }) => void;
private nextGeneration() {
// In Worker Mode, we don't proceed to next generation automatically.
// We stop and return result.
// But for compatibility with internal loop if needed:
// Return results via callback if set?
// Or just stop.
}
// Helper to get results
public getResults() {
return this.cars.map((c, i) => ({
fitness: c.fitness,
checkpoints: c.checkpointsPassed,
genome: this.genomes[i]
}));
}
public isFinished(): boolean {
return this.cars.every(c => c.isDead);
}
public run(steps: number) {
for(let i=0; i<steps; i++) {
this.update();
// Check if all dead to early exit?
if (this.cars.every(c => c.isDead)) break;
}
}
private handleCollisions(event: Matter.IEventCollision<Matter.Engine>) {
event.pairs.forEach(pair => {
const { bodyA, bodyB } = pair;
this.checkCarWallCollision(bodyA, bodyB);
});
}
private checkCarWallCollision(bodyA: Matter.Body, bodyB: Matter.Body) {
const carBody = bodyA.label === 'car' ? bodyA : (bodyB.label === 'car' ? bodyB : null);
const wallBody = bodyA.label === 'wall' ? bodyA : (bodyB.label === 'wall' ? bodyB : null);
if (carBody && wallBody) {
const car = this.cars.find(c => c.body === carBody);
if (car) car.kill();
}
}
public updateConfig(carConfig: CarConfig) {
this.cars.forEach(car => {
car.config = carConfig; // Update config ref
// Apply physics properties directly to body
Matter.Body.set(car.body, {
frictionAir: carConfig.frictionAir,
friction: carConfig.friction
});
});
}
}

View File

@@ -0,0 +1,255 @@
import { useState } from 'react';
import type { CarConfig, SimulationConfig } from './types';
import type { GAConfig } from './SimpleGA';
interface ConfigPanelProps {
carConfig: CarConfig;
simConfig: SimulationConfig;
gaConfig: GAConfig;
onCarConfigChange: (config: CarConfig) => void;
onSimConfigChange: (config: SimulationConfig) => void;
onGAConfigChange: (config: GAConfig) => void;
onNewTrack: () => void;
}
export function ConfigPanel({ carConfig, simConfig, gaConfig, onCarConfigChange, onSimConfigChange, onGAConfigChange, onNewTrack }: ConfigPanelProps) {
const [isExpanded, setIsExpanded] = useState(true);
const sliderStyle = { width: '100%', margin: '5px 0' };
const labelStyle = { display: 'flex', justifyContent: 'space-between', fontSize: '12px', color: '#ccc' };
const groupStyle = { marginBottom: '15px', borderBottom: '1px solid #444', paddingBottom: '10px' };
const updateCar = (key: keyof CarConfig, value: number) => {
onCarConfigChange({ ...carConfig, [key]: value });
};
const updateSim = (key: keyof SimulationConfig, value: number) => {
onSimConfigChange({ ...simConfig, [key]: value });
};
const updateGA = (key: keyof GAConfig, value: number) => {
onGAConfigChange({ ...gaConfig, [key]: value });
};
return (
<div style={{
position: 'absolute',
top: 170,
right: 20,
width: isExpanded ? '250px' : 'auto',
background: 'rgba(0,0,0,0.8)',
padding: '10px',
borderRadius: '8px',
color: 'white',
backdropFilter: 'blur(5px)',
maxHeight: 'calc(100vh - 40px)',
overflowY: 'auto',
transition: 'width 0.2s'
}}>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
cursor: 'pointer',
borderBottom: isExpanded ? '1px solid #666' : 'none',
paddingBottom: isExpanded ? '5px' : '0',
marginBottom: isExpanded ? '10px' : '0'
}}
onClick={() => setIsExpanded(!isExpanded)}
>
<h3 style={{ margin: 0, fontSize: '14px' }}>Configuration</h3>
<span style={{ fontSize: '12px', marginLeft: '10px' }}>{isExpanded ? '▼' : '◀'}</span>
</div>
{isExpanded && (
<>
<div style={groupStyle}>
<h4 style={{ margin: '5px 0', color: '#4ecdc4' }}>Car Physics</h4>
<div style={labelStyle}>
<span>Max Speed</span>
<span>{carConfig.maxSpeed.toFixed(1)}</span>
</div>
<input
type="range" min="5" max="25" step="0.5"
value={carConfig.maxSpeed}
onChange={(e) => updateCar('maxSpeed', parseFloat(e.target.value))}
style={sliderStyle}
/>
<div style={labelStyle}>
<span>Turn Speed</span>
<span>{carConfig.turnSpeed.toFixed(2)}</span>
</div>
<input
type="range" min="0.02" max="0.20" step="0.01"
value={carConfig.turnSpeed}
onChange={(e) => updateCar('turnSpeed', parseFloat(e.target.value))}
style={sliderStyle}
/>
<div style={labelStyle}>
<span>Tire Grip (Lateral)</span>
<span>{(carConfig.lateralFriction * 100).toFixed(0)}%</span>
</div>
<input
type="range" min="0.5" max="0.99" step="0.01"
value={carConfig.lateralFriction}
onChange={(e) => updateCar('lateralFriction', parseFloat(e.target.value))}
style={sliderStyle}
/>
<div style={labelStyle}>
<span>Air Resistance</span>
<span>{(carConfig.frictionAir * 1000).toFixed(0)}</span>
</div>
<input
type="range" min="0.00" max="0.20" step="0.005"
value={carConfig.frictionAir}
onChange={(e) => updateCar('frictionAir', parseFloat(e.target.value))}
style={sliderStyle}
/>
</div>
<div style={groupStyle}>
<h4 style={{ margin: '5px 0', color: '#ffa726' }}>Sensors</h4>
<div style={labelStyle}>
<span>Ray Count</span>
<span>{carConfig.rayCount}</span>
</div>
<input
type="range" min="3" max="11" step="2"
value={carConfig.rayCount}
onChange={(e) => updateCar('rayCount', parseInt(e.target.value))}
style={sliderStyle}
/>
<div style={labelStyle}>
<span>FOV (Field of View)</span>
<span>{(carConfig.raySpread * 180 / Math.PI).toFixed(0)}°</span>
</div>
<input
type="range" min="60" max="180" step="10"
value={carConfig.raySpread * 180 / Math.PI}
onChange={(e) => updateCar('raySpread', parseFloat(e.target.value) * Math.PI / 180)}
style={sliderStyle}
/>
<div style={labelStyle}>
<span>Ray Length</span>
<span>{carConfig.rayLength}</span>
</div>
<input
type="range" min="50" max="300" step="10"
value={carConfig.rayLength}
onChange={(e) => updateCar('rayLength', parseInt(e.target.value))}
style={sliderStyle}
/>
</div>
<div style={groupStyle}>
<h4 style={{ margin: '5px 0', color: '#ff6b6b' }}>Track Gen</h4>
<div style={labelStyle}>
<span>Complexity (Wiggle)</span>
<span>{(simConfig.trackComplexity * 100).toFixed(0)}%</span>
</div>
<input
type="range" min="0.1" max="1.0" step="0.01"
value={simConfig.trackComplexity}
onChange={(e) => updateSim('trackComplexity', parseFloat(e.target.value))}
style={sliderStyle}
/>
<div style={labelStyle}>
<span>Length (Nodes)</span>
<span>{simConfig.trackLength}</span>
</div>
<input
type="range" min="10" max="60" step="1"
value={simConfig.trackLength}
onChange={(e) => updateSim('trackLength', parseInt(e.target.value))}
style={sliderStyle}
/>
<button
onClick={onNewTrack}
style={{
width: '100%',
marginTop: '10px',
background: '#ff6b6b',
border: 'none',
padding: '8px',
borderRadius: '4px',
cursor: 'pointer',
fontWeight: 'bold',
color: 'white'
}}
>
Generate New Track
</button>
</div>
<div style={groupStyle}>
<h4 style={{ margin: '5px 0', color: '#a855f7' }}>Evolution (GA)</h4>
<div style={labelStyle}>
<span>Population Size</span>
<span>{gaConfig.populationSize}</span>
</div>
<input
type="range" min="10" max="200" step="10"
value={gaConfig.populationSize}
onChange={(e) => updateGA('populationSize', parseInt(e.target.value))}
style={sliderStyle}
/>
<div style={labelStyle}>
<span>Mutation Rate</span>
<span>{(gaConfig.mutationRate * 100).toFixed(1)}%</span>
</div>
<input
type="range" min="0.01" max="0.20" step="0.01"
value={gaConfig.mutationRate}
onChange={(e) => updateGA('mutationRate', parseFloat(e.target.value))}
style={sliderStyle}
/>
<div style={labelStyle}>
<span>Mutation Amount</span>
<span>{gaConfig.mutationAmount.toFixed(2)}</span>
</div>
<input
type="range" min="0.05" max="1.0" step="0.05"
value={gaConfig.mutationAmount}
onChange={(e) => updateGA('mutationAmount', parseFloat(e.target.value))}
style={sliderStyle}
/>
<div style={labelStyle}>
<span>Elitism (Keep Best)</span>
<span>{gaConfig.elitism}</span>
</div>
<input
type="range" min="0" max="20" step="1"
value={gaConfig.elitism}
onChange={(e) => updateGA('elitism', parseInt(e.target.value))}
style={sliderStyle}
/>
<div style={{ fontSize: '10px', color: '#f59e0b', marginTop: '8px', textAlign: 'center' }}>
GA changes restart training
</div>
</div>
<div style={{ fontSize: '10px', color: '#888', textAlign: 'center' }}>
Physics apply immediately.<br />
Track settings apply on generate.
</div>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,140 @@
interface FitnessGraphProps {
history: Array<{ generation: number; best: number; average: number }>;
width?: number | string;
height?: number | string;
className?: string;
}
export default function FitnessGraph({ history, width = "100%", height = 150, className = "" }: FitnessGraphProps) {
if (history.length < 2) {
return (
<div style={{
width,
height,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#666',
fontSize: '0.8rem',
background: 'rgba(0,0,0,0.2)',
borderRadius: '4px'
}}>
Waiting for data...
</div>
);
}
const PADDING = 20; // Internal padding
// Use internal coordinate system for viewBox
const VIEW_WIDTH = 500;
const VIEW_HEIGHT = 200;
const GRAPH_WIDTH = VIEW_WIDTH - PADDING * 2;
const GRAPH_HEIGHT = VIEW_HEIGHT - PADDING * 2;
// Find min/max for scaling
const maxFitness = Math.max(...history.map(h => h.best), 1);
const minGeneration = history[0].generation;
const maxGeneration = history[history.length - 1].generation;
const genRange = Math.max(maxGeneration - minGeneration, 1);
// Helper to scale points
const getX = (gen: number) => {
return PADDING + ((gen - minGeneration) / genRange) * GRAPH_WIDTH;
};
const getY = (fitness: number) => {
// Invert Y because SVG 0 is top
return PADDING + GRAPH_HEIGHT - (fitness / maxFitness) * GRAPH_HEIGHT;
};
// Generate path data
const bestPath = history.map((p, i) =>
`${i === 0 ? 'M' : 'L'} ${getX(p.generation)} ${getY(p.best)}`
).join(' ');
const averagePath = history.map((p, i) =>
`${i === 0 ? 'M' : 'L'} ${getX(p.generation)} ${getY(p.average)}`
).join(' ');
// Areas (closed paths for gradients)
const bestArea = bestPath + ` L ${getX(history[history.length - 1].generation)} ${GRAPH_HEIGHT + PADDING} L ${getX(minGeneration)} ${GRAPH_HEIGHT + PADDING} Z`;
const averageArea = averagePath + ` L ${getX(history[history.length - 1].generation)} ${GRAPH_HEIGHT + PADDING} L ${getX(minGeneration)} ${GRAPH_HEIGHT + PADDING} Z`;
return (
<div className={`fitness-graph-container ${className}`} style={{ width: '100%', height, position: 'relative' }}>
{/* Legend Overlay */}
<div style={{
position: 'absolute',
top: 0,
right: 0,
display: 'flex',
gap: '12px',
fontSize: '0.75rem',
fontWeight: 600,
background: 'rgba(0,0,0,0.4)',
padding: '4px 8px',
borderRadius: '0 0 0 8px',
pointerEvents: 'none',
backdropFilter: 'blur(2px)'
}}>
<div style={{ color: '#4ecdc4', display: 'flex', alignItems: 'center', gap: '6px' }}>
<div style={{ width: 8, height: 8, background: '#4ecdc4', borderRadius: '50%' }}></div>
Best: {Math.round(history[history.length - 1].best)}
</div>
<div style={{ color: '#4a9eff', display: 'flex', alignItems: 'center', gap: '6px' }}>
<div style={{ width: 8, height: 8, background: '#4a9eff', borderRadius: '50%' }}></div>
Avg: {Math.round(history[history.length - 1].average)}
</div>
</div>
<svg
width="100%"
height="100%"
viewBox={`0 0 ${VIEW_WIDTH} ${VIEW_HEIGHT}`}
preserveAspectRatio="none"
style={{ overflow: 'visible' }}
>
<defs>
<linearGradient id="gradBest" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stopColor="#4ecdc4" stopOpacity={0.4} />
<stop offset="100%" stopColor="#4ecdc4" stopOpacity={0} />
</linearGradient>
<linearGradient id="gradAvg" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stopColor="#4a9eff" stopOpacity={0.3} />
<stop offset="100%" stopColor="#4a9eff" stopOpacity={0} />
</linearGradient>
</defs>
{/* Grid Lines (Horizontal) */}
{[0, 0.25, 0.5, 0.75, 1].map(ratio => {
const y = PADDING + ratio * GRAPH_HEIGHT;
return (
<line
key={ratio}
x1={PADDING}
y1={y}
x2={VIEW_WIDTH - PADDING}
y2={y}
stroke="#333"
strokeWidth="1"
strokeDasharray="4 4"
opacity="0.5"
/>
);
})}
{/* Average Area */}
<path d={averageArea} fill="url(#gradAvg)" />
{/* Average Line */}
<path d={averagePath} fill="none" stroke="#4a9eff" strokeWidth="2" strokeOpacity="0.8" />
{/* Best Area */}
<path d={bestArea} fill="url(#gradBest)" />
{/* Best Line */}
<path d={bestPath} fill="none" stroke="#4ecdc4" strokeWidth="2.5" />
</svg>
</div>
);
}

View File

@@ -0,0 +1,122 @@
import { useRef, useState, useEffect } from 'react';
import { CarScene } from './CarScene';
import { ConfigPanel } from './ConfigPanel';
import FitnessGraph from './FitnessGraph';
import { DEFAULT_CAR_CONFIG, DEFAULT_SIM_CONFIG } from './types';
import { DEFAULT_GA_CONFIG } from './SimpleGA';
import type { CarConfig, SimulationConfig } from './types';
import type { GAConfig } from './SimpleGA';
export function SelfDrivingCarApp() {
const gameContainer = useRef<HTMLDivElement>(null);
const gameInstance = useRef<Phaser.Game | null>(null);
const [history, setHistory] = useState<Array<{ generation: number, best: number, average: number }>>([]);
// Config State
const [carConfig, setCarConfig] = useState<CarConfig>(DEFAULT_CAR_CONFIG);
const [simConfig, setSimConfig] = useState<SimulationConfig>(DEFAULT_SIM_CONFIG);
const [gaConfig, setGAConfig] = useState<GAConfig>(DEFAULT_GA_CONFIG);
useEffect(() => {
if (!gameContainer.current || gameInstance.current) return;
const config: Phaser.Types.Core.GameConfig = {
type: Phaser.AUTO,
parent: gameContainer.current,
width: gameContainer.current.clientWidth,
height: gameContainer.current.clientHeight,
backgroundColor: '#222222',
physics: {
default: 'matter',
matter: {
gravity: { x: 0, y: 0 },
debug: false
}
},
scene: [CarScene],
scale: {
mode: Phaser.Scale.RESIZE,
autoCenter: Phaser.Scale.CENTER_BOTH
}
};
const game = new Phaser.Game(config);
gameInstance.current = game;
// Init config in scene once ready?
// Actually Scene starts immediately. We can emit config update shortly after or pass safely.
// Listen for stats
const onGenerationComplete = (stats: { generation: number, best: number, average: number }) => {
setHistory(prev => {
const newHistory = [...prev, stats];
return newHistory;
});
};
game.events.on('generation-complete', onGenerationComplete);
return () => {
if (gameInstance.current) {
gameInstance.current.events.off('generation-complete', onGenerationComplete);
gameInstance.current.destroy(true);
gameInstance.current = null;
}
};
}, []);
// Sync Config to Scene
useEffect(() => {
if (gameInstance.current) {
gameInstance.current.events.emit('update-config', { car: carConfig, sim: simConfig, ga: gaConfig });
}
}, [carConfig, simConfig, gaConfig]);
const handleNewTrack = () => {
if (gameInstance.current) {
gameInstance.current.events.emit('new-track');
setHistory([]); // Clear fitness history on restart
}
};
// Restart training when GA config changes
const handleGAConfigChange = (newConfig: GAConfig) => {
setGAConfig(newConfig);
handleNewTrack(); // Restart training with new GA settings
};
return (
<div style={{ width: '100%', height: '100%', position: 'relative', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
{/* Top Bar for Graph */}
<div style={{
height: '150px',
background: '#1a1a1a',
padding: '10px',
borderBottom: '1px solid #333',
zIndex: 10
}}>
<FitnessGraph history={history} height="100%" />
</div>
<div
style={{ flex: 1, position: 'relative', overflow: 'hidden' }}
>
<div
ref={gameContainer}
style={{ width: '100%', height: '100%' }}
/>
</div>
{/* Config Panel */}
<ConfigPanel
carConfig={carConfig}
simConfig={simConfig}
gaConfig={gaConfig}
onCarConfigChange={setCarConfig}
onSimConfigChange={setSimConfig}
onGAConfigChange={handleGAConfigChange}
onNewTrack={handleNewTrack}
/>
</div>
);
}

View File

@@ -0,0 +1,117 @@
import { DenseNetwork } from '../../apps/LunarLander/DenseNetwork';
export interface GAConfig {
populationSize: number;
mutationRate: number;
mutationAmount: number;
elitism: number; // Number of best agents to keep unchanged
}
export const DEFAULT_GA_CONFIG: GAConfig = {
populationSize: 50,
mutationRate: 0.05, // Reduced from 0.1
mutationAmount: 0.2, // Reduced from 0.5
elitism: 5
};
export class SimpleGA {
private layerSizes: number[];
private config: GAConfig;
constructor(layerSizes: number[], config: GAConfig = DEFAULT_GA_CONFIG) {
this.layerSizes = layerSizes;
this.config = config;
}
createPopulation(): Float32Array[] {
const pop: Float32Array[] = [];
// Helper to get weight count
// We create a dummy network to calculate size easily, or duplicate logic.
// Duplicating logic is safer to avoid instantiation overhead if large.
// Logic from DenseNetwork: sum((full_in + 1) * out)
// Let's just instantiate one to be sure.
for (let i = 0; i < this.config.populationSize; i++) {
const dn = new DenseNetwork(this.layerSizes);
pop.push(dn.getWeights());
}
return pop;
}
evolve(currentPop: Float32Array[], fitnesses: number[]): Float32Array[] {
// 1. Sort by fitness (descending)
const indices = currentPop.map((_, i) => i).sort((a, b) => fitnesses[b] - fitnesses[a]);
const nextPop: Float32Array[] = [];
const popSize = this.config.populationSize;
// 2. Elitism
for (let i = 0; i < this.config.elitism; i++) {
if (i < indices.length) {
// Keep exact copy
nextPop.push(new Float32Array(currentPop[indices[i]]));
}
}
// 3. Fill rest
while (nextPop.length < popSize) {
// Diversity Injection (Random Immigrants)
// Increased from 5% to 15% to combat stagnation
if (Math.random() < 0.15) {
const dn = new DenseNetwork(this.layerSizes);
nextPop.push(dn.getWeights());
continue;
}
// Tournament selection
const p1 = currentPop[this.tournamentSelect(indices, fitnesses)];
const p2 = currentPop[this.tournamentSelect(indices, fitnesses)];
// Crossover
const child = this.crossover(p1, p2);
// Mutation
this.mutate(child);
nextPop.push(child);
}
return nextPop;
}
private tournamentSelect(indices: number[], fitnesses: number[]): number {
const k = 3;
let bestIndex = -1;
let bestFitness = -Infinity;
for (let i = 0; i < k; i++) {
const r = Math.floor(Math.random() * indices.length);
const realIdx = indices[r];
if (fitnesses[realIdx] > bestFitness) {
bestFitness = fitnesses[realIdx];
bestIndex = realIdx;
}
}
return bestIndex;
}
private crossover(w1: Float32Array, w2: Float32Array): Float32Array {
const child = new Float32Array(w1.length);
// Uniform crossover? Or Split?
// Uniform is good for weights.
for (let i = 0; i < w1.length; i++) {
child[i] = Math.random() < 0.5 ? w1[i] : w2[i];
}
return child;
}
private mutate(weights: Float32Array) {
for (let i = 0; i < weights.length; i++) {
if (Math.random() < this.config.mutationRate) {
weights[i] += (Math.random() * 2 - 1) * this.config.mutationAmount;
// Clamp? Optional. Tanh handles range usually.
}
}
}
}

View File

@@ -0,0 +1,215 @@
import Phaser from 'phaser';
import Matter from 'matter-js';
// @ts-ignore
import decomp from 'poly-decomp';
(window as any).decomp = decomp; // Matter.js requires it on window or Common
// Or better:
Matter.Common.setDecomp(decomp);
export interface TrackData {
innerWalls: Phaser.Math.Vector2[];
outerWalls: Phaser.Math.Vector2[];
pathPoints: Phaser.Math.Vector2[]; // For logic/fitness
centerLine: Phaser.Curves.Spline;
checkpoints: Matter.Body[];
walls: Matter.Body[];
startPosition: Phaser.Math.Vector2;
startAngle: number;
}
export class TrackGenerator {
private width: number;
private height: number;
private trackWidth: number;
constructor(width: number, height: number, trackWidth: number = 80) {
this.width = width;
this.height = height;
this.trackWidth = trackWidth;
}
public generate(complexity: number = 0.5, length: number = 25): TrackData {
// 1. Generate Control Points (Rough Circle with Noise)
const center = new Phaser.Math.Vector2(this.width / 2, this.height / 2);
const controlPoints: Phaser.Math.Vector2[] = [];
const numPoints = length;
const baseRadius = Math.min(this.width, this.height) * 0.35;
const radiusVariation = baseRadius * 0.3 * complexity; // Smooth variation
for (let i = 0; i < numPoints; i++) {
const angle = (i / numPoints) * Math.PI * 2;
const r = baseRadius + (Math.random() * 2 - 1) * radiusVariation;
// Minimal angle noise to prevent loop-backs
const angleNoise = (Math.random() - 0.5) * (Math.PI * 2 / numPoints) * 0.1 * complexity;
controlPoints.push(new Phaser.Math.Vector2(
center.x + Math.cos(angle + angleNoise) * r,
center.y + Math.sin(angle + angleNoise) * r
));
}
// 2. Closed Loop Spline
// To make it loop smoothly, we copy the first 3 points to the end.
const closedPoints = [
...controlPoints,
controlPoints[0],
controlPoints[1],
controlPoints[2]
];
const spline = new Phaser.Curves.Spline(closedPoints);
// 3. Create Geometry
// Sample at fixed DISTANCE, not t-steps, for uniform width
return this.createGeometry(spline, controlPoints.length);
}
private createGeometry(spline: Phaser.Curves.Spline, originalCount: number): TrackData {
const resolutionPerSegment = 10;
const points: Phaser.Math.Vector2[] = [];
// ... (Sampling logic same) ...
const totalSegments = (originalCount + 3) - 1;
for (let i = 0; i < originalCount; i++) {
const tStart = i / totalSegments;
const tEnd = (i + 1) / totalSegments;
for (let j = 0; j < resolutionPerSegment; j++) {
const t = tStart + (tEnd - tStart) * (j / resolutionPerSegment);
const p = spline.getPoint(t);
points.push(new Phaser.Math.Vector2(p.x, p.y));
}
}
// Close Loop
const p0 = spline.getPoint(0);
points.push(new Phaser.Math.Vector2(p0.x, p0.y));
// CALCULATE VERTEX NORMALS
const normals: Phaser.Math.Vector2[] = [];
// First compute segment tangents/normals
const segmentNormals: Phaser.Math.Vector2[] = [];
for (let i = 0; i < points.length - 1; i++) {
const p1 = points[i];
const p2 = points[i+1];
const t = p2.clone().subtract(p1).normalize();
segmentNormals.push(new Phaser.Math.Vector2(-t.y, t.x));
}
// Now compute vertex normals (average of adjacent segments)
// For i=0 (Start), average(LastSeg, Seg0)
// For i=Last (End), average(LastSeg, Seg0) -> Should be same as i=0
for (let i = 0; i < points.length; i++) {
// Prev Segment
let prevIdx = i - 1;
if (prevIdx < 0) prevIdx = segmentNormals.length - 1;
// Next Segment (current i) generally, but for the last point, it's also the last segment?
// Actually: point i connects Seg i-1 and Seg i.
// point 0 connects Seg LAST and Seg 0.
// point N connects Seg N-1 and Seg 0? Yes if closed.
let nextIdx = i;
if (nextIdx >= segmentNormals.length) nextIdx = 0; // Wrap valid?
// Wait, points length is N+1. Segments length is N.
// points[0] joins Seg[N-1] and Seg[0].
// points[N] is same as points[0].
// Let's just average generic
const n1 = segmentNormals[prevIdx];
const n2 = segmentNormals[nextIdx < segmentNormals.length ? nextIdx : 0];
const avg = n1.clone().add(n2).normalize();
normals.push(avg);
}
const innerWalls: Phaser.Math.Vector2[] = [];
const outerWalls: Phaser.Math.Vector2[] = [];
const walls: Matter.Body[] = [];
const checkpoints: Matter.Body[] = [];
for (let i = 0; i < points.length - 1; i++) {
const p1 = points[i];
const p2 = points[i + 1];
const n1 = normals[i];
const n2 = normals[i+1];
// Vertices using Smooth Normals
const outer1 = p1.clone().add(n1.clone().scale(this.trackWidth / 2));
const inner1 = p1.clone().add(n1.clone().scale(-this.trackWidth / 2));
const outer2 = p2.clone().add(n2.clone().scale(this.trackWidth / 2));
const inner2 = p2.clone().add(n2.clone().scale(-this.trackWidth / 2));
outerWalls.push(outer1);
innerWalls.push(inner1);
// Walls (Trapezoids)
const thickness = 20;
const outer1_T = outer1.clone().add(n1.clone().scale(thickness));
const outer2_T = outer2.clone().add(n2.clone().scale(thickness));
const wallLeft = Matter.Bodies.fromVertices(
(outer1.x + outer2.x + outer1_T.x + outer2_T.x)/4,
(outer1.y + outer2.y + outer1_T.y + outer2_T.y)/4,
[[outer1, outer2, outer2_T, outer1_T]],
{ isStatic: true, label: 'wall' }
);
if (wallLeft) walls.push(wallLeft);
const inner1_T = inner1.clone().add(n1.clone().scale(-thickness));
const inner2_T = inner2.clone().add(n2.clone().scale(-thickness));
const wallRight = Matter.Bodies.fromVertices(
(inner1.x + inner2.x + inner1_T.x + inner2_T.x)/4,
(inner1.y + inner2.y + inner1_T.y + inner2_T.y)/4,
[[inner1, inner2, inner2_T, inner1_T]],
{ isStatic: true, label: 'wall' }
);
if (wallRight) walls.push(wallRight);
// Circle Joints (Still useful for sharp corners, but smooth normals handle gaps)
if (true) {
// Place at vertices (p1/p2)
// We only need to place at p1 for each segment to cover the seam.
// Actually with smooth normals, outer2(i-1) === outer1(i). Guaranteed.
// So no gaps!
// But Sharp Corners might still have physics issues if convex?
// No, smooth normals rounds the corner.
// We don't need joints anymore!
}
// ... Checkpoints logic ...
if (points.length > 50 && i % Math.floor(points.length / 10) === 0) {
// Use segment tangent for angle
const tangent = p2.clone().subtract(p1).normalize();
const cpMid = p1.clone();
checkpoints.push(Matter.Bodies.rectangle(cpMid.x, cpMid.y, 10, this.trackWidth, {
isSensor: true,
isStatic: true,
angle: Math.atan2(tangent.y, tangent.x),
label: `checkpoint_${checkpoints.length}`
}));
}
}
// Start Position (First point)
const startP = points[0];
const startT = points[1].clone().subtract(points[0]).normalize();
return {
innerWalls,
outerWalls,
pathPoints: points, // These are the high-res samples
centerLine: spline,
checkpoints,
walls,
startPosition: startP,
startAngle: Math.atan2(startT.y, startT.x)
};
}
}

View File

@@ -0,0 +1,91 @@
import { describe, expect, it } from 'bun:test';
import { SimpleGA, DEFAULT_GA_CONFIG } from './SimpleGA';
import { CarSimulation } from './CarSimulation';
// import { TrackGenerator } from './Track';
import { DEFAULT_SIM_CONFIG } from './types';
import type { SerializedTrackData } from './types';
describe('Car Evolution E2E', () => {
// Hardcoded simple square track (cw)
// 0,0 -> 800,0 -> 800,600 -> 0,600 -> 0,0 (Outline)
// 100,100 -> 700,100 -> 700,500 -> 100,500 -> 100,100 (Inner)
const serializedTrack: SerializedTrackData = {
innerWalls: [
{x: 100, y: 100}, {x: 700, y: 100}, {x: 700, y: 500}, {x: 100, y: 500}
],
outerWalls: [
{x: 0, y: 0}, {x: 800, y: 0}, {x: 800, y: 600}, {x: 0, y: 600}
],
startPosition: { x: 400, y: 50 }, // Top middle
startAngle: 0, // Facing right?
walls: [
// Top wall
{ position: {x: 400, y: 0}, width: 800, height: 20, angle: 0, label: 'wall', isSensor: false},
// Bottom wall
{ position: {x: 400, y: 600}, width: 800, height: 20, angle: 0, label: 'wall', isSensor: false},
// Left wall
{ position: {x: 0, y: 300}, width: 20, height: 600, angle: 0, label: 'wall', isSensor: false},
// Right wall
{ position: {x: 800, y: 300}, width: 20, height: 600, angle: 0, label: 'wall', isSensor: false},
// Inner box (mocking just center block)
{ position: {x: 400, y: 300}, width: 200, height: 200, angle: 0, label: 'wall', isSensor: false}
],
checkpoints: [
// Start
{ position: {x: 400, y: 50}, width: 200, height: 20, angle: 0, label: 'checkpoint_0', isSensor: true},
// Corner 1 (Right)
{ position: {x: 750, y: 50}, width: 20, height: 200, angle: 0, label: 'checkpoint_1', isSensor: true},
// Corner 2 (Right Bottom)
{ position: {x: 750, y: 550}, width: 20, height: 200, angle: 0, label: 'checkpoint_2', isSensor: true}
]
};
it('should improve fitness over 50 generations', async () => {
const fs = require('fs');
const logFile = 'e2e_log.txt';
fs.writeFileSync(logFile, 'Starting Test...\n');
const log = (msg: string) => fs.appendFileSync(logFile, msg + '\n');
try {
const ga = new SimpleGA([6, 16, 12, 2], DEFAULT_GA_CONFIG);
let population = ga.createPopulation();
let initialBest = 0;
let finalBest = 0;
log('Starting E2E Evolution Test (50 Gens)...');
for (let gen = 0; gen < 50; gen++) {
// Run Simulation
const sim = new CarSimulation(serializedTrack, DEFAULT_SIM_CONFIG, population);
sim.run(1000);
const results = sim.getResults();
const fitnesses = results.map(r => r.fitness);
const best = Math.max(...fitnesses);
const avg = fitnesses.reduce((a,b)=>a+b, 0) / fitnesses.length;
if (gen === 0) initialBest = best;
if (gen === 49) finalBest = best;
if (gen % 10 === 0 || gen === 49) {
log(`Gen ${gen}: Best: ${best.toFixed(2)}, Avg: ${avg.toFixed(2)}`);
}
population = ga.evolve(population, fitnesses);
}
log(`Evolution Result: ${initialBest.toFixed(2)} -> ${finalBest.toFixed(2)}`);
expect(finalBest).toBeGreaterThan(10);
} catch (e) {
log(`ERROR: ${e}`);
throw e;
}
});
});

View File

@@ -0,0 +1,32 @@
export interface Point {
x: number;
y: number;
}
export function distance(p1: Point, p2: Point): number {
const dx = p1.x - p2.x;
const dy = p1.y - p2.y;
return Math.sqrt(dx * dx + dy * dy);
}
export function lineToLineIntersection(
x1: number, y1: number, x2: number, y2: number,
x3: number, y3: number, x4: number, y4: number
): Point | null {
const denom = (y4 - y3) * (x2 - x1) - (x4 - x3) * (y2 - y1);
if (denom === 0) return null; // Parallel
const ua = ((x4 - x3) * (y1 - y3) - (y4 - y3) * (x1 - x3)) / denom;
const ub = ((x2 - x1) * (y1 - y3) - (y2 - y1) * (x1 - x3)) / denom;
if (ua >= 0 && ua <= 1 && ub >= 0 && ub <= 1) {
return {
x: x1 + ua * (x2 - x1),
y: y1 + ua * (y2 - y1)
};
}
return null;
}

View File

@@ -0,0 +1,41 @@
import { CarSimulation } from './CarSimulation';
import type { SerializedTrackData, SimulationConfig, CarConfig } from './types';
interface WorkerMessage {
type: 'TRAIN';
trackData: SerializedTrackData;
genomes: Float32Array[]; // Was Genome[]
config: SimulationConfig;
carConfig: CarConfig;
steps?: number;
}
self.onmessage = (e: MessageEvent<WorkerMessage>) => {
const { type, trackData, genomes, config, carConfig, steps = 3600 } = e.data; // 60s default
if (type === 'TRAIN') {
console.log(`Worker: Starting generation step. Pop: ${genomes.length || config.populationSize}, Steps: ${steps}`);
const sim = new CarSimulation(trackData, config, genomes, carConfig);
const startTime = performance.now();
sim.run(steps);
const duration = performance.now() - startTime;
console.log(`Worker: Generation complete in ${duration.toFixed(2)}ms. Cars alive: ${sim.cars.filter(c => !c.isDead).length}`);
const results = sim.getResults();
// Send back fitnesses
// We map results to simple array to reduce transfer cost, or send objects
const fitnessMap = results.map(r => ({
fitness: r.fitness,
checkpoints: r.checkpoints,
// We don't need to send genome back if Main thread kept it,
// but sender might need to know which is which.
// Order is preserved.
}));
self.postMessage({ type: 'TRAIN_COMPLETE', results: fitnessMap });
}
};

View File

@@ -0,0 +1,70 @@
// import { Vector } from 'matter-js';
export interface CarConfig {
width: number;
height: number;
maxSpeed: number;
turnSpeed: number;
rayCount: number;
rayLength: number;
raySpread: number; // FOV in radians
// Physics
frictionAir: number; // 0.0-1.0 (Air Resistance/Drag)
friction: number; // 0.0-1.0 (Wall Friction)
lateralFriction: number; // 0.0-1.0 (Tire Grip. 1.0=Rails, 0.0=Ice)
}
export interface SimulationConfig {
populationSize: number;
mutationRate: number;
trackComplexity: number; // 0.0-1.0 (Noise/Wiggle)
trackLength: number; // 10-100 (Approx number of control points)
}
export interface SerializedVector { x: number, y: number }
export interface SerializedBody {
position: SerializedVector;
angle: number;
width: number;
height: number;
label: string;
isSensor: boolean;
vertices?: SerializedVector[];
}
export interface SerializedTrackData {
innerWalls: SerializedVector[];
outerWalls: SerializedVector[];
pathPoints: SerializedVector[]; // Center line points for fitness tracking
walls: SerializedBody[];
checkpoints: SerializedBody[];
startPosition: SerializedVector;
startAngle: number;
}
// Physics Tunings Removed (Now in config)
export const DEFAULT_CAR_CONFIG: CarConfig = {
width: 20,
height: 40,
maxSpeed: 12,
turnSpeed: 0.15, // Increased from 0.08 for sharper turning
rayCount: 7, // Increased from 5 for better peripheral vision
rayLength: 150,
raySpread: Math.PI * 5 / 6, // 150° FOV (increased from 90°)
// Default Physics (Drifty)
frictionAir: 0.02,
friction: 0.1,
lateralFriction: 0.90
};
export const DEFAULT_SIM_CONFIG: SimulationConfig = {
populationSize: 50,
mutationRate: 0.1,
trackComplexity: 0.2,
trackLength: 25 // Default length
};

View File

@@ -1,3 +1,4 @@
import { useState } from 'react';
import SnakeCanvas from './SnakeCanvas'; import SnakeCanvas from './SnakeCanvas';
import type { Network } from '../../lib/snakeAI/network'; import type { Network } from '../../lib/snakeAI/network';
@@ -8,6 +9,8 @@ interface BestSnakeDisplayProps {
} }
export default function BestSnakeDisplay({ network, gridSize, fitness }: BestSnakeDisplayProps) { export default function BestSnakeDisplay({ network, gridSize, fitness }: BestSnakeDisplayProps) {
const [playbackSpeed, setPlaybackSpeed] = useState(15);
if (!network) return null; if (!network) return null;
return ( return (
@@ -19,6 +22,22 @@ export default function BestSnakeDisplay({ network, gridSize, fitness }: BestSna
<span className="value">{Math.round(fitness)}</span> <span className="value">{Math.round(fitness)}</span>
</div> </div>
</div> </div>
<div className="playback-controls" style={{ padding: '0 10px 10px 10px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', fontSize: '0.8rem', color: '#888' }}>
<span>Replay Speed:</span>
<input
type="range"
min="1"
max="200"
value={playbackSpeed}
onChange={(e) => setPlaybackSpeed(Number(e.target.value))}
style={{ flex: 1, accentColor: '#4ecdc4' }}
/>
<span style={{ minWidth: '3ch', textAlign: 'right' }}>{playbackSpeed}x</span>
</div>
</div>
<div className="best-canvas-wrapper"> <div className="best-canvas-wrapper">
<SnakeCanvas <SnakeCanvas
network={network} network={network}
@@ -26,6 +45,7 @@ export default function BestSnakeDisplay({ network, gridSize, fitness }: BestSna
size="large" size="large"
showGrid={true} showGrid={true}
showStats={true} showStats={true}
playbackSpeed={playbackSpeed}
/> />
</div> </div>
</div> </div>

View File

@@ -0,0 +1,140 @@
interface FitnessGraphProps {
history: Array<{ generation: number; best: number; average: number }>;
width?: number | string;
height?: number | string;
className?: string;
}
export default function FitnessGraph({ history, width = "100%", height = 150, className = "" }: FitnessGraphProps) {
if (history.length < 2) {
return (
<div style={{
width,
height,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#666',
fontSize: '0.8rem',
background: 'rgba(0,0,0,0.2)',
borderRadius: '4px'
}}>
Waiting for data...
</div>
);
}
const PADDING = 20; // Internal padding
// Use internal coordinate system for viewBox
const VIEW_WIDTH = 500;
const VIEW_HEIGHT = 200;
const GRAPH_WIDTH = VIEW_WIDTH - PADDING * 2;
const GRAPH_HEIGHT = VIEW_HEIGHT - PADDING * 2;
// Find min/max for scaling
const maxFitness = Math.max(...history.map(h => h.best), 1);
const minGeneration = history[0].generation;
const maxGeneration = history[history.length - 1].generation;
const genRange = Math.max(maxGeneration - minGeneration, 1);
// Helper to scale points
const getX = (gen: number) => {
return PADDING + ((gen - minGeneration) / genRange) * GRAPH_WIDTH;
};
const getY = (fitness: number) => {
// Invert Y because SVG 0 is top
return PADDING + GRAPH_HEIGHT - (fitness / maxFitness) * GRAPH_HEIGHT;
};
// Generate path data
const bestPath = history.map((p, i) =>
`${i === 0 ? 'M' : 'L'} ${getX(p.generation)} ${getY(p.best)}`
).join(' ');
const averagePath = history.map((p, i) =>
`${i === 0 ? 'M' : 'L'} ${getX(p.generation)} ${getY(p.average)}`
).join(' ');
// Areas (closed paths for gradients)
const bestArea = bestPath + ` L ${getX(history[history.length - 1].generation)} ${GRAPH_HEIGHT + PADDING} L ${getX(minGeneration)} ${GRAPH_HEIGHT + PADDING} Z`;
const averageArea = averagePath + ` L ${getX(history[history.length - 1].generation)} ${GRAPH_HEIGHT + PADDING} L ${getX(minGeneration)} ${GRAPH_HEIGHT + PADDING} Z`;
return (
<div className={`fitness-graph-container ${className}`} style={{ width: '100%', height, position: 'relative' }}>
{/* Legend Overlay */}
<div style={{
position: 'absolute',
top: 0,
right: 0,
display: 'flex',
gap: '12px',
fontSize: '0.75rem',
fontWeight: 600,
background: 'rgba(0,0,0,0.4)',
padding: '4px 8px',
borderRadius: '0 0 0 8px',
pointerEvents: 'none',
backdropFilter: 'blur(2px)'
}}>
<div style={{ color: '#4ecdc4', display: 'flex', alignItems: 'center', gap: '6px' }}>
<div style={{ width: 8, height: 8, background: '#4ecdc4', borderRadius: '50%' }}></div>
Best: {Math.round(history[history.length - 1].best)}
</div>
<div style={{ color: '#4a9eff', display: 'flex', alignItems: 'center', gap: '6px' }}>
<div style={{ width: 8, height: 8, background: '#4a9eff', borderRadius: '50%' }}></div>
Avg: {Math.round(history[history.length - 1].average)}
</div>
</div>
<svg
width="100%"
height="100%"
viewBox={`0 0 ${VIEW_WIDTH} ${VIEW_HEIGHT}`}
preserveAspectRatio="none"
style={{ overflow: 'visible' }}
>
<defs>
<linearGradient id="gradBest" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stopColor="#4ecdc4" stopOpacity={0.4} />
<stop offset="100%" stopColor="#4ecdc4" stopOpacity={0} />
</linearGradient>
<linearGradient id="gradAvg" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stopColor="#4a9eff" stopOpacity={0.3} />
<stop offset="100%" stopColor="#4a9eff" stopOpacity={0} />
</linearGradient>
</defs>
{/* Grid Lines (Horizontal) */}
{[0, 0.25, 0.5, 0.75, 1].map(ratio => {
const y = PADDING + ratio * GRAPH_HEIGHT;
return (
<line
key={ratio}
x1={PADDING}
y1={y}
x2={VIEW_WIDTH - PADDING}
y2={y}
stroke="#333"
strokeWidth="1"
strokeDasharray="4 4"
opacity="0.5"
/>
);
})}
{/* Average Area */}
<path d={averageArea} fill="url(#gradAvg)" />
{/* Average Line */}
<path d={averagePath} fill="none" stroke="#4a9eff" strokeWidth="2" strokeOpacity="0.8" />
{/* Best Area */}
<path d={bestArea} fill="url(#gradBest)" />
{/* Best Line */}
<path d={bestPath} fill="none" stroke="#4ecdc4" strokeWidth="2.5" />
</svg>
</div>
);
}

View File

@@ -328,9 +328,12 @@ input[type='range']::-webkit-slider-thumb:hover {
} }
.progress-indicator { .progress-indicator {
background: #080808; background: linear-gradient(135deg, #2a2a3e 0%, #1a1a2e 100%);
padding: 0.75rem; padding: 1.5rem;
border: 1px solid #222; border-radius: 12px;
border: 1px solid #3a3a4e;
display: flex;
flex-direction: column;
} }
.progress-label { .progress-label {

View File

@@ -7,11 +7,6 @@ import Tips from './Tips';
import BestSnakeDisplay from './BestSnakeDisplay'; import BestSnakeDisplay from './BestSnakeDisplay';
import { import {
createPopulation, createPopulation,
evaluatePopulation,
evolveGeneration,
getBestIndividual,
getAverageFitness,
type Population,
} from '../../lib/snakeAI/evolution'; } from '../../lib/snakeAI/evolution';
import type { EvolutionConfig } from '../../lib/snakeAI/types'; import type { EvolutionConfig } from '../../lib/snakeAI/types';
import './SnakeAI.css'; import './SnakeAI.css';
@@ -24,6 +19,9 @@ const DEFAULT_CONFIG: EvolutionConfig = {
maxGameSteps: 20000, maxGameSteps: 20000,
}; };
import { WorkerPool } from '../../lib/snakeAI/workerPool';
import { evolveGeneration, updateBestStats, type Population } from '../../lib/snakeAI/evolution';
export default function SnakeAI() { export default function SnakeAI() {
const [population, setPopulation] = useState<Population>(() => const [population, setPopulation] = useState<Population>(() =>
createPopulation(DEFAULT_CONFIG) createPopulation(DEFAULT_CONFIG)
@@ -32,29 +30,75 @@ export default function SnakeAI() {
const [isRunning, setIsRunning] = useState(false); const [isRunning, setIsRunning] = useState(false);
const [speed, setSpeed] = useState(5); const [speed, setSpeed] = useState(5);
const [gamesPlayed, setGamesPlayed] = useState(0); const [gamesPlayed, setGamesPlayed] = useState(0);
const [fitnessHistory, setFitnessHistory] = useState<Array<{ generation: number, best: number, average: number }>>([]);
// Compute derived values from population // Keep a ref to population for the worker
const bestIndividual = getBestIndividual(population); const populationRef = useRef(population);
const averageFitness = getAverageFitness(population); useEffect(() => {
populationRef.current = population;
}, [population]);
const animationFrameRef = useRef<number>(); const animationFrameRef = useRef<number>(0);
const lastUpdateRef = useRef<number>(0); const lastUpdateRef = useRef<number>(0);
const runGeneration = useCallback(() => { // Compute derived values for display
setPopulation((prev) => { const currentBestFitness = population.lastGenerationStats?.bestFitness || 0;
try { const currentAverageFitness = population.lastGenerationStats?.averageFitness || 0;
// Evaluate current generation
const evaluated = evaluatePopulation(prev, config);
// Evolve to next generation const workerPoolRef = useRef<WorkerPool | null>(null);
const nextGen = evolveGeneration(evaluated, config); const isProcessingRef = useRef(false);
return nextGen; useEffect(() => {
} catch (error) { // Initialize Worker Pool with logical cores (default)
console.error("SnakeAI: Generation update failed", error); workerPoolRef.current = new WorkerPool();
return prev;
return () => {
workerPoolRef.current?.terminate();
};
}, []);
const runGeneration = useCallback(async (generations: number = 1) => {
if (isProcessingRef.current || !workerPoolRef.current) return;
isProcessingRef.current = true;
let currentPop = populationRef.current;
try {
for (let i = 0; i < generations; i++) {
// 1. Evaluate in parallel
let evaluatedPop = await workerPoolRef.current.evaluateParallel(currentPop, config);
// 1.5 Update Best Stats (Critical for UI)
evaluatedPop = updateBestStats(evaluatedPop);
// 2. Evolve on main thread (fast)
currentPop = evolveGeneration(evaluatedPop, config);
} }
});
// Update state
populationRef.current = currentPop;
setPopulation(currentPop);
// Update history
if (currentPop.lastGenerationStats) {
setFitnessHistory(prev => {
const newEntry = {
generation: currentPop.generation - 1,
best: currentPop.lastGenerationStats!.bestFitness,
average: currentPop.lastGenerationStats!.averageFitness
};
const newHistory = [...prev, newEntry];
if (newHistory.length > 100) return newHistory.slice(newHistory.length - 100);
return newHistory;
});
}
} catch (err) {
console.error("Evolution error:", err);
setIsRunning(false);
} finally {
isProcessingRef.current = false;
}
}, [config]); }, [config]);
// Update stats when generation changes // Update stats when generation changes
@@ -93,7 +137,7 @@ export default function SnakeAI() {
} }
if (elapsed >= updateInterval) { if (elapsed >= updateInterval) {
runGeneration(); runGeneration(1);
lastUpdateRef.current = timestamp; lastUpdateRef.current = timestamp;
} }
} else { } else {
@@ -102,9 +146,9 @@ export default function SnakeAI() {
// Speed 100 -> 10 gens per frame (~600 eps) // Speed 100 -> 10 gens per frame (~600 eps)
const gensPerFrame = Math.floor((speed - 10) / 10); const gensPerFrame = Math.floor((speed - 10) / 10);
for (let i = 0; i < gensPerFrame; i++) { // For turbo mode, we just fire once per frame (or whenever the worker is ready)
runGeneration(); // asking for multiple generations
} runGeneration(gensPerFrame);
lastUpdateRef.current = timestamp; lastUpdateRef.current = timestamp;
} }
@@ -122,7 +166,9 @@ export default function SnakeAI() {
const handleReset = () => { const handleReset = () => {
setIsRunning(false); setIsRunning(false);
setPopulation(createPopulation(config)); const newPop = createPopulation(config);
populationRef.current = newPop;
setPopulation(newPop);
setGamesPlayed(0); setGamesPlayed(0);
}; };
@@ -162,10 +208,11 @@ export default function SnakeAI() {
<Stats <Stats
generation={population.generation} generation={population.generation}
bestFitness={bestIndividual.fitness} bestFitness={currentBestFitness}
bestFitnessEver={population.bestFitnessEver} bestFitnessEver={population.bestFitnessEver}
averageFitness={averageFitness} averageFitness={currentAverageFitness}
gamesPlayed={gamesPlayed} gamesPlayed={gamesPlayed}
history={fitnessHistory}
/> />
<Tips /> <Tips />

View File

@@ -9,6 +9,7 @@ interface SnakeCanvasProps {
showGrid?: boolean; showGrid?: boolean;
size?: 'small' | 'normal' | 'large'; size?: 'small' | 'normal' | 'large';
showStats?: boolean; // Show score/length/steps even in small mode showStats?: boolean; // Show score/length/steps even in small mode
playbackSpeed?: number; // Steps per second (default: 15)
} }
const CELL_SIZES = { const CELL_SIZES = {
@@ -19,11 +20,16 @@ const CELL_SIZES = {
const CANVAS_PADDING = 10; const CANVAS_PADDING = 10;
export default function SnakeCanvas({ network, gridSize, showGrid = true, size = 'normal', showStats = false }: SnakeCanvasProps) { export default function SnakeCanvas({ network, gridSize, showGrid = true, size = 'normal', showStats = false, playbackSpeed = 15 }: SnakeCanvasProps) {
const canvasRef = useRef<HTMLCanvasElement>(null); const canvasRef = useRef<HTMLCanvasElement>(null);
const [currentGame, setCurrentGame] = useState<GameState | null>(null); const [currentGame, setCurrentGame] = useState<GameState | null>(null);
const animationFrameRef = useRef<number>(); const animationFrameRef = useRef<number>(0);
const lastUpdateRef = useRef<number>(0); const lastUpdateRef = useRef<number>(0);
const networkRef = useRef(network);
useEffect(() => {
networkRef.current = network;
}, [network]);
const CELL_SIZE = CELL_SIZES[size]; const CELL_SIZE = CELL_SIZES[size];
@@ -32,13 +38,13 @@ export default function SnakeCanvas({ network, gridSize, showGrid = true, size =
if (network) { if (network) {
setCurrentGame(createGame(gridSize)); setCurrentGame(createGame(gridSize));
} }
}, [network, gridSize]); }, [network?.id, gridSize]);
// Animation loop to step through game // Animation loop to step through game
useEffect(() => { useEffect(() => {
if (!network || !currentGame) return; if (!network || !currentGame) return;
const STEPS_PER_SECOND = 10; // Speed of game playback const STEPS_PER_SECOND = playbackSpeed; // Use prop
const UPDATE_INTERVAL = 1000 / STEPS_PER_SECOND; const UPDATE_INTERVAL = 1000 / STEPS_PER_SECOND;
const animate = (timestamp: number) => { const animate = (timestamp: number) => {
@@ -54,8 +60,11 @@ export default function SnakeCanvas({ network, gridSize, showGrid = true, size =
} }
// Get neural network decision // Get neural network decision
const currentNetwork = networkRef.current;
if (!currentNetwork) return prevGame;
const inputs = getInputs(prevGame); const inputs = getInputs(prevGame);
const action = getAction(network, inputs); const action = getAction(currentNetwork, inputs);
// Step the game forward // Step the game forward
return step(prevGame, action); return step(prevGame, action);
@@ -74,7 +83,7 @@ export default function SnakeCanvas({ network, gridSize, showGrid = true, size =
cancelAnimationFrame(animationFrameRef.current); cancelAnimationFrame(animationFrameRef.current);
} }
}; };
}, [network, currentGame, gridSize]); }, [network?.id, !!currentGame, gridSize, playbackSpeed]); // Added playbackSpeed dependency
// Set canvas size once when props change (not on every render) // Set canvas size once when props change (not on every render)
useEffect(() => { useEffect(() => {

View File

@@ -1,9 +1,12 @@
import FitnessGraph from './FitnessGraph';
interface StatsProps { interface StatsProps {
generation: number; generation: number;
bestFitness: number; bestFitness: number;
bestFitnessEver: number; bestFitnessEver: number;
averageFitness: number; averageFitness: number;
gamesPlayed: number; gamesPlayed: number;
history: Array<{ generation: number; best: number; average: number }>;
} }
export default function Stats({ export default function Stats({
@@ -12,6 +15,7 @@ export default function Stats({
bestFitnessEver, bestFitnessEver,
averageFitness, averageFitness,
gamesPlayed, gamesPlayed,
history,
}: StatsProps) { }: StatsProps) {
return ( return (
<div className="stats-panel"> <div className="stats-panel">
@@ -45,17 +49,10 @@ export default function Stats({
</div> </div>
<div className="progress-indicator"> <div className="progress-indicator">
<div className="progress-label"> <div className="progress-label" style={{ marginBottom: '0.5rem' }}>
Improvement: {bestFitnessEver > 0 ? ((bestFitness / bestFitnessEver) * 100).toFixed(1) : 0}% Fitness History
</div>
<div className="progress-bar">
<div
className="progress-fill"
style={{
width: `${bestFitnessEver > 0 ? Math.min(100, (bestFitness / bestFitnessEver) * 100) : 0}%`,
}}
/>
</div> </div>
<FitnessGraph history={history} height={120} />
</div> </div>
</div> </div>
); );

View File

@@ -1,92 +1,121 @@
.sidebar { .sidebar {
width: 280px; width: 100%;
height: 100vh; height: 72px;
background: var(--bg-darker); background: var(--glass-bg);
border-right: 1px solid var(--border-color); backdrop-filter: var(--backdrop-blur);
-webkit-backdrop-filter: var(--backdrop-blur);
border-bottom: var(--glass-border);
display: flex; display: flex;
flex-direction: column; align-items: center;
padding: 2rem 0; padding: 0 2rem;
box-shadow: 4px 0 24px rgba(0, 0, 0, 0.5); box-shadow: var(--glass-shadow);
z-index: 100;
flex-shrink: 0;
position: relative;
}
/* Add a subtle top highlight line */
.sidebar::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 1px;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
} }
.sidebar-header { .sidebar-header {
padding: 0 1.5rem 2rem; padding: 0;
border-bottom: 1px solid var(--border-color); margin-right: 4rem;
border: none;
display: flex;
align-items: center;
} }
.sidebar-logo { .sidebar-logo {
font-size: 1.75rem; font-size: 1.75rem;
font-weight: 700; font-weight: 700;
margin: 0; margin: 0;
background: linear-gradient(135deg, var(--primary) 0%, var(--accent) 100%); background: linear-gradient(135deg, var(--text-primary) 0%, var(--text-secondary) 100%);
-webkit-background-clip: text; -webkit-background-clip: text;
-webkit-text-fill-color: transparent; -webkit-text-fill-color: transparent;
background-clip: text; background-clip: text;
} letter-spacing: -0.02em;
text-shadow: 0 0 30px rgba(124, 58, 237, 0.3);
.sidebar-tagline {
margin: 0.25rem 0 0;
font-size: 0.875rem;
color: rgba(255, 255, 255, 0.5);
font-weight: 300;
} }
.sidebar-nav { .sidebar-nav {
flex: 1; flex: 1;
padding: 2rem 1rem;
display: flex; display: flex;
flex-direction: column; flex-direction: row;
gap: 0.5rem; align-items: center;
gap: 0.75rem;
height: 100%;
overflow-x: auto;
/* Hide scrollbar */
-ms-overflow-style: none;
scrollbar-width: none;
}
.sidebar-nav::-webkit-scrollbar {
display: none;
} }
.nav-item { .nav-item {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 1rem; justify-content: center;
padding: 1rem 1.25rem; padding: 0.6rem 1.25rem;
background: transparent; background: transparent;
border: 1px solid rgba(255, 255, 255, 0.1); border: 1px solid transparent;
border-radius: 12px; border-radius: 99px;
color: rgba(255, 255, 255, 0.7); /* Pill shape */
color: var(--text-secondary);
cursor: pointer; cursor: pointer;
transition: all 0.3s ease; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
font-size: 1rem; font-size: 0.95rem;
text-align: left; text-decoration: none;
white-space: nowrap;
font-weight: 500;
letter-spacing: 0.01em;
position: relative;
overflow: hidden;
} }
/* Hover effects */
.nav-item:hover { .nav-item:hover {
background: rgba(255, 255, 255, 0.05); color: var(--text-primary);
border-color: var(--primary); background: rgba(255, 255, 255, 0.03);
color: rgba(255, 255, 255, 0.9); box-shadow: 0 0 15px rgba(255, 255, 255, 0.05);
transform: translateX(4px);
} }
/* Active State */
.nav-item.active { .nav-item.active {
background: linear-gradient(135deg, rgba(99, 102, 241, 0.15) 0%, rgba(139, 92, 246, 0.15) 100%); color: var(--text-primary);
border-color: var(--primary); background: rgba(255, 255, 255, 0.08);
color: #fff; /* Lighter bg for active */
box-shadow: 0 4px 15px rgba(99, 102, 241, 0.3); border-color: rgba(255, 255, 255, 0.1);
box-shadow:
0 0 0 1px rgba(0, 0, 0, 0.2),
inset 0 1px 1px rgba(255, 255, 255, 0.1);
} }
.nav-icon { /* Adding a glow dot for active items */
font-size: 1.5rem; .nav-item.active::after {
line-height: 1; content: '';
position: absolute;
bottom: 0px;
left: 50%;
transform: translateX(-50%);
width: 40%;
height: 3px;
background: var(--primary);
border-radius: 4px 4px 0 0;
box-shadow: 0 -2px 8px var(--primary-glow);
} }
.nav-name { .nav-name {
font-weight: 500; position: relative;
} z-index: 2;
.sidebar-footer {
padding: 0 1.5rem;
border-top: 1px solid rgba(255, 255, 255, 0.1);
padding-top: 1.5rem;
}
.footer-text {
margin: 0;
font-size: 0.75rem;
color: rgba(255, 255, 255, 0.4);
text-align: center;
font-style: italic;
} }

View File

@@ -1,13 +1,12 @@
import { NavLink } from 'react-router-dom'; import { NavLink } from 'react-router-dom';
import './Sidebar.css'; import './Sidebar.css';
export type AppId = 'image-approx' | 'snake-ai'; export type AppId = 'image-approx' | 'snake-ai' | 'rogue-gen' | 'neat-arena' | 'lunar-lander' | 'self-driving-car';
export interface AppInfo { export interface AppInfo {
id: AppId; id: AppId;
path: string; path: string;
name: string; name: string;
icon: string;
description: string; description: string;
} }
@@ -16,24 +15,45 @@ export const APPS: AppInfo[] = [
id: 'image-approx', id: 'image-approx',
path: '/image-approx', path: '/image-approx',
name: 'Image Approximation', name: 'Image Approximation',
icon: '🎨',
description: 'Evolve triangles to approximate images', description: 'Evolve triangles to approximate images',
}, },
{ {
id: 'snake-ai', id: 'snake-ai',
path: '/snake-ai', path: '/snake-ai',
name: 'Neural Network Snake', name: 'Neural Network Snake',
icon: '🐍',
description: 'Evolve neural networks to play Snake', description: 'Evolve neural networks to play Snake',
}, },
{
id: 'rogue-gen',
path: '/rogue-gen',
name: 'Rogue Map Gen',
description: 'Evolve cellular automata for dungeon generation',
},
{
id: 'neat-arena',
path: '/neat-arena',
name: 'NEAT Arena',
description: 'Evolve AI agents to fight in a top-down shooter',
},
{
id: 'lunar-lander',
path: '/lunar-lander',
name: 'Lunar Lander',
description: 'Evolve a spaceship to land safely',
},
{
id: 'self-driving-car',
path: '/self-driving-car',
name: 'Self-Driving Car',
description: 'Evolve cars to navigate a track',
},
]; ];
export default function Sidebar() { export default function Sidebar() {
return ( return (
<aside className="sidebar"> <header className="sidebar">
<div className="sidebar-header"> <div className="sidebar-header">
<h1 className="sidebar-logo">🧬 Evolution</h1> <h1 className="sidebar-logo">🧬 Evolution</h1>
<p className="sidebar-tagline">Mini-Apps</p>
</div> </div>
<nav className="sidebar-nav"> <nav className="sidebar-nav">
@@ -44,15 +64,10 @@ export default function Sidebar() {
className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`} className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}
title={app.description} title={app.description}
> >
<span className="nav-icon">{app.icon}</span>
<span className="nav-name">{app.name}</span> <span className="nav-name">{app.name}</span>
</NavLink> </NavLink>
))} ))}
</nav> </nav>
</header>
<div className="sidebar-footer">
<p className="footer-text">Select an app to begin</p>
</div>
</aside>
); );
} }

View File

@@ -1,30 +1,50 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap'); @import url('https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;700&display=swap');
:root { :root {
/* Color palette - lighter, less dark */ /* Premium Dark Sci-Fi Palette */
--primary: #6366f1; --bg-dark: #030305;
--primary-dark: #4f46e5; /* Deepest void black */
--primary-light: #818cf8; --bg-darker: #000000;
--accent: #8b5cf6; /* Pure black for contrast */
--bg-dark: #1a1a2e; --bg-card: rgba(20, 20, 35, 0.4);
--bg-darker: #0f1729; /* Glassy panel background */
--bg-card: rgba(255, 255, 255, 0.05); --bg-card-hover: rgba(30, 30, 50, 0.6);
--text-primary: rgba(255, 255, 255, 0.95);
--text-secondary: rgba(255, 255, 255, 0.7); /* Accents */
--text-muted: rgba(255, 255, 255, 0.5); --primary: #7c3aed;
--border-color: rgba(255, 255, 255, 0.12); /* Electric Violet */
--primary-glow: rgba(124, 58, 237, 0.5);
--accent: #06b6d4;
/* Cyan/Teal */
--accent-glow: rgba(6, 182, 212, 0.5);
--success: #10b981;
/* Emerald */
--danger: #ef4444;
/* Red */
/* Text elements */
--text-primary: #f8fafc;
/* Bright white */
--text-secondary: #94a3b8;
/* Blue-grey */
--text-muted: #475569;
/* Darker grey */
/* Structural */
--border-color: rgba(255, 255, 255, 0.08);
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 16px;
/* Glassmorphism */
--glass-bg: rgba(10, 10, 15, 0.75);
--glass-border: 1px solid rgba(255, 255, 255, 0.05);
--glass-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.36);
--backdrop-blur: blur(12px);
/* Typography */ /* Typography */
font-family: 'Inter', system-ui, -apple-system, sans-serif; --font-main: 'Outfit', system-ui, -apple-system, sans-serif;
line-height: 1.6; --font-mono: 'JetBrains Mono', monospace;
font-weight: 400;
color: var(--text-primary);
/* Rendering */
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
} }
* { * {
@@ -35,9 +55,16 @@
body { body {
margin: 0; margin: 0;
background: var(--bg-dark); background-color: var(--bg-dark);
background-image:
radial-gradient(circle at 15% 50%, rgba(124, 58, 237, 0.08), transparent 25%),
radial-gradient(circle at 85% 30%, rgba(6, 182, 212, 0.08), transparent 25%);
color: var(--text-primary); color: var(--text-primary);
font-family: var(--font-main);
line-height: 1.6;
overflow: hidden; overflow: hidden;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
} }
#root { #root {
@@ -45,11 +72,45 @@ body {
height: 100vh; height: 100vh;
} }
/* Scrollbar Styling */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--bg-darker);
}
::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--text-muted);
}
button { button {
font-family: inherit; font-family: inherit;
cursor: pointer; cursor: pointer;
} }
input { input,
select,
textarea {
font-family: inherit; font-family: inherit;
background: var(--bg-card);
border: 1px solid var(--border-color);
color: var(--text-primary);
border-radius: var(--radius-sm);
padding: 0.5rem;
}
input:focus,
select:focus,
textarea:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 2px rgba(124, 58, 237, 0.2);
} }

View File

@@ -0,0 +1,100 @@
import { describe, expect, test } from 'bun:test';
import { createSimulation, stepSimulation } from './simulation';
import { SIMULATION_CONFIG } from './types';
import { generateObservation, observationToInputs } from './sensors';
// Mock Genome that implements Perfect Tracking Logic
const perfectTrackerGenome = {
id: 9999,
nodes: [],
connections: [],
fitness: 0
};
// Strafer Bot (Same as in selfPlay.ts)
const straferGenome = {
id: -3,
nodes: [],
connections: [],
fitness: 0
};
describe('Aim Mechanics Verification', () => {
test('Perfect Tracker should defeat Strafer', () => {
// Setup Simulation
const sim = createSimulation(12345, 2); // Pair 2 (Strafer pair)
let trackerHits = 0;
let straferHits = 0;
// Run Match
let currentSim = sim;
const maxTicks = 300;
for (let t = 0; t < maxTicks; t++) {
const obsTracker = generateObservation(0, currentSim);
// --- PERFECT LOGIC ---
// 1. Get Target Relative Angle from Sensor (Index 54 in 0-based array of 56 inputs)
// But we can just read it from observation directly
const targetAngle = obsTracker.targetRelativeAngle; // [-1, 1]
const targetVisible = obsTracker.targetVisible;
// 2. Control Logic
// If angle > 0 (Left), Turn Left (-1). If angle < 0 (Right), Turn Right (1).
// P-Controller: turn = angle * K
const K = 5.0; // Strong gain
let turn = -targetAngle * K; // Note: Sign depends on coordinate system.
// In setup: Angle is Aim - Target.
// If Target is to Left (Positive relative?), we need to turn Left (Positive/Negative?)
// Let's verify sign:
// If target is at angle 0.1 (Left), we want to Increase Aim Angle?
// Usually turn +1 adds to angle.
// So turn = +1 * K.
// Note: targetRelativeAngle = (Target - Aim) / PI.
// If Target > Aim (Positive), we need to Turn Positive.
turn = targetAngle * 20.0; // Max turn
// Clamp
if (turn > 1) turn = 1;
if (turn < -1) turn = -1;
// Shoot if locked on
const shoot = (Math.abs(targetAngle) < 0.05 && targetVisible > 0.5) ? 1.0 : 0.0;
const actionTracker = {
moveX: 0,
moveY: 0,
turn: turn,
shoot: shoot
};
// --- STRAFER LOGIC ---
const straferMoveY = Math.sin(t * 0.2);
const actionStrafer = {
moveX: 0,
moveY: straferMoveY,
turn: 0,
shoot: 0 // Strafer is passive to isolate aim test
};
// Step
currentSim = stepSimulation(currentSim, [actionTracker, actionStrafer]);
// Count hits
if (currentSim.agents[1].hits > trackerHits) {
trackerHits = currentSim.agents[1].hits; // Agent 1 is Strafer
// console.log(`Hit at tick ${t}! Total: ${trackerHits}`);
}
}
console.log(`Perfect Tracker Result: ${trackerHits} Hits on Strafer in ${maxTicks} ticks.`);
// Assert Feasibility
// We expect at least 3-5 hits to prove it's possible.
expect(trackerHits).toBeGreaterThan(3);
});
});

View File

@@ -0,0 +1,184 @@
import Phaser from 'phaser';
import type { SimulationState } from './types';
import { SIMULATION_CONFIG } from './types';
/**
* Phaser scene for rendering the NEAT Arena.
*
* This scene is ONLY for visualization - the actual simulation runs separately.
* The scene receives simulation state updates and renders them.
*/
export class ArenaScene extends Phaser.Scene {
private simulationState: SimulationState | null = null;
private showRays: boolean = true;
// Graphics objects
private wallGraphics!: Phaser.GameObjects.Graphics;
private agentGraphics!: Phaser.GameObjects.Graphics;
private bulletGraphics!: Phaser.GameObjects.Graphics;
private rayGraphics!: Phaser.GameObjects.Graphics;
constructor() {
super({ key: 'ArenaScene' });
}
create() {
// Create graphics layers (back to front)
this.wallGraphics = this.add.graphics();
this.rayGraphics = this.add.graphics();
this.bulletGraphics = this.add.graphics();
this.agentGraphics = this.add.graphics();
// Set background
this.cameras.main.setBackgroundColor(0x1a1a2e);
}
update() {
if (!this.simulationState) return;
this.render();
}
/**
* Update the simulation state to render
*/
public updateSimulation(state: SimulationState) {
this.simulationState = state;
}
/**
* Toggle ray visualization
*/
public setShowRays(show: boolean) {
this.showRays = show;
}
/**
* Render the current simulation state
*/
private render() {
if (!this.simulationState) return;
// Clear graphics
this.wallGraphics.clear();
this.agentGraphics.clear();
this.bulletGraphics.clear();
this.rayGraphics.clear();
// Render walls
this.renderWalls();
// Render rays (if enabled)
if (this.showRays) {
this.renderRays();
}
// Render bullets
this.renderBullets();
// Render agents
this.renderAgents();
}
private renderWalls() {
if (!this.simulationState) return;
const { walls } = this.simulationState.map;
this.wallGraphics.fillStyle(0x4a5568, 1);
this.wallGraphics.lineStyle(2, 0x64748b, 1);
for (const wall of walls) {
const { minX, minY, maxX, maxY } = wall.rect;
this.wallGraphics.fillRect(minX, minY, maxX - minX, maxY - minY);
this.wallGraphics.strokeRect(minX, minY, maxX - minX, maxY - minY);
}
}
private renderAgents() {
if (!this.simulationState) return;
const agents = this.simulationState.agents;
const colors = [0x667eea, 0xf093fb]; // Purple and pink
for (let i = 0; i < agents.length; i++) {
const agent = agents[i];
const color = colors[i];
// Agent body (circle)
if (agent.invulnTicks > 0) {
// Flash when invulnerable
const alpha = agent.invulnTicks % 4 < 2 ? 0.5 : 1;
this.agentGraphics.fillStyle(color, alpha);
} else {
this.agentGraphics.fillStyle(color, 1);
}
this.agentGraphics.fillCircle(agent.position.x, agent.position.y, agent.radius);
// Border
this.agentGraphics.lineStyle(2, 0xffffff, 0.8);
this.agentGraphics.strokeCircle(agent.position.x, agent.position.y, agent.radius);
// Aim direction indicator
const aimLength = 20;
const aimEndX = agent.position.x + Math.cos(agent.aimAngle) * aimLength;
const aimEndY = agent.position.y + Math.sin(agent.aimAngle) * aimLength;
this.agentGraphics.lineStyle(3, 0xffffff, 1);
this.agentGraphics.lineBetween(agent.position.x, agent.position.y, aimEndX, aimEndY);
}
}
private renderBullets() {
if (!this.simulationState) return;
this.bulletGraphics.fillStyle(0xfbbf24, 1); // Yellow
this.bulletGraphics.lineStyle(1, 0xffffff, 0.8);
for (const bullet of this.simulationState.bullets) {
this.bulletGraphics.fillCircle(bullet.position.x, bullet.position.y, 3);
this.bulletGraphics.strokeCircle(bullet.position.x, bullet.position.y, 3);
}
}
private renderRays() {
if (!this.simulationState) return;
// TODO: This will be implemented when we integrate sensor visualization
// For now, rays will be rendered when we have a specific agent's observation to display
}
}
/**
* Create and initialize a Phaser game instance for the arena
*/
export function createArenaViewer(parentElement: HTMLElement): Phaser.Game {
const config: Phaser.Types.Core.GameConfig = {
type: Phaser.AUTO,
width: SIMULATION_CONFIG.WORLD_SIZE,
height: SIMULATION_CONFIG.WORLD_SIZE,
parent: parentElement,
backgroundColor: '#1a1a2e',
scene: ArenaScene,
physics: {
default: 'arcade',
arcade: {
debug: false,
},
},
scale: {
mode: Phaser.Scale.FIT,
autoCenter: Phaser.Scale.CENTER_BOTH,
},
};
return new Phaser.Game(config);
}
/**
* Get the scene instance from a Phaser game
*/
export function getArenaScene(game: Phaser.Game): ArenaScene {
return game.scene.getScene('ArenaScene') as ArenaScene;
}

View File

@@ -0,0 +1,60 @@
import type { AgentAction } from './types';
import { SeededRandom } from './utils';
/**
* Baseline scripted bots for testing and benchmarking.
*
* These provide simple strategies that can be used to:
* - Test the simulation mechanics
* - Provide initial training opponents
* - Benchmark evolved agents
*/
/**
* Random bot - takes random actions
*/
export function randomBotAction(rng: SeededRandom): AgentAction {
return {
moveX: rng.nextFloat(-1, 1),
moveY: rng.nextFloat(-1, 1),
turn: rng.nextFloat(-1, 1),
shoot: rng.next(),
};
}
/**
* Idle bot - does nothing
*/
export function idleBotAction(): AgentAction {
return {
moveX: 0,
moveY: 0,
turn: 0,
shoot: 0,
};
}
/**
* Spinner bot - spins in place and shoots
*/
export function spinnerBotAction(): AgentAction {
return {
moveX: 0,
moveY: 0,
turn: 1,
shoot: 1,
};
}
/**
* Circle strafe bot - moves in circles and shoots
*/
export function circleStrafeBotAction(tick: number): AgentAction {
const angle = (tick / 20) * Math.PI * 2;
return {
moveX: Math.cos(angle),
moveY: Math.sin(angle),
turn: 0.3,
shoot: tick % 15 === 0 ? 1 : 0,
};
}

View File

@@ -0,0 +1,46 @@
import { test, expect } from 'bun:test';
import { createPopulation, evolveGeneration, getPopulationStats } from './evolution';
import { evaluatePopulation } from './selfPlay';
import { DEFAULT_EVOLUTION_CONFIG } from './evolution';
test('Benchmark: Learning Performance over 50 generations', async () => {
// 1. Setup
const config = { ...DEFAULT_EVOLUTION_CONFIG };
let population = createPopulation(config);
console.log('Starting Benchmark: 50 Generations');
console.log('Generation, Species, MaxFitness, AvgFitness');
const history: {gen: number, max: number}[] = [];
// 2. Loop
const matchConfig = { matchesPerGenome: 2, mapSeed: 12345, maxTicks: 300 }; // Faster for benchmark
for (let i = 0; i < 100; i++) {
// Evaluate (Self-Play)
population = evaluatePopulation(population, matchConfig);
const stats = getPopulationStats(population);
if (i % 5 === 0 || i === 99) {
console.log(`${stats.generation}, ${stats.speciesCount}, ${stats.maxFitness.toFixed(4)}, ${stats.avgFitness.toFixed(4)}`);
}
history.push({ gen: stats.generation, max: stats.maxFitness });
// Evolve
population = evolveGeneration(population, config);
}
// 3. Analysis
const firstMax = history[0].max;
const lastMax = history[history.length - 1].max;
const improvement = lastMax - firstMax;
console.log(`Improvement: ${improvement.toFixed(4)}`);
// Expect significantly positive fitness (at least winning some matches)
// Baseline is usually 0 or negative. We want > 1.0 (some kills)
expect(lastMax).toBeGreaterThan(0.5);
expect(improvement).toBeGreaterThan(0);
}, 60000); // 60s timeout

View File

@@ -0,0 +1,61 @@
import { describe, expect, test } from 'bun:test';
import { createSimulation, stepSimulation } from './simulation';
import { generateObservation } from './sensors';
import { AgentAction } from './types';
import * as fs from 'fs';
import * as path from 'path';
// --- MECHANICS TEST ---
function runMechanicsTest() {
const sim = createSimulation(12345, 2); // Pair 2 (Strafer)
let hits = 0;
let currentSim = sim;
// Perfect Tracker Logic
for (let t = 0; t < 600; t++) { // 20 seconds
const obs = generateObservation(0, currentSim);
const targetAngle = obs.targetRelativeAngle;
const targetVisible = obs.targetVisible;
// P-Controller
// Reduced gain to prevent overshoot with new high TURN_RATE
let turn = targetAngle * 5.0;
if (turn > 1) turn = 1;
if (turn < -1) turn = -1;
// Shoot if locked on
// Tighter angle check because we shoot faster now
const shoot = (Math.abs(targetAngle) < 0.05 && targetVisible > 0.5) ? 1.0 : 0.0;
const actionTracker: AgentAction = { moveX: 0, moveY: 0, turn, shoot };
const actionStrafer: AgentAction = {
moveX: 0, moveY: Math.sin(t * 0.2) * 0.5, turn: 0, shoot: 0 // Nerfed speed (0.5x)
};
const nextSim = stepSimulation(currentSim, [actionTracker, actionStrafer]);
// Check hits (Agent 1 is Strafer)
if (nextSim.agents[1].hits > currentSim.agents[1].hits) {
hits++;
}
currentSim = nextSim;
}
return hits;
}
describe('Progress Benchmark', () => {
test('Mechanics: Task is Solvable', () => {
const hits = runMechanicsTest();
console.log(`[Mechanics] Perfect Bot Hits: ${hits}`);
// Save result
const result = {
mechanics_hits: hits,
solvable: hits > 5
};
fs.writeFileSync('benchmark_results.json', JSON.stringify(result, null, 2));
expect(hits).toBeGreaterThan(5); // Expect at least 5 hits (Winning condition)
});
});

View File

@@ -0,0 +1,37 @@
import { generateArenaMap } from "./mapGenerator";
import { hasLineOfSight } from "./sensors";
import type { Agent } from "./types";
import { SIMULATION_CONFIG } from './types';
const map = generateArenaMap(12345);
console.log(`Map generated with ${map.walls.length} walls.`);
let blockedCount = 0;
// Check the seeds used in Curriculum
const BASE_SEED = 12345;
const SPAWN_INDICES = [0, 1, 2, 3];
for (const i of SPAWN_INDICES) {
const seed = BASE_SEED + i;
const spawnIdx = i;
// NOTE: SIMULATION_CONFIG is not defined in this file, assuming it's imported or globally available.
// For the purpose of this edit, I'm assuming generateArenaMap can take 3 arguments as per the new code.
const map = generateArenaMap(SIMULATION_CONFIG.WORLD_SIZE, SIMULATION_CONFIG.WORLD_SIZE, seed);
// Find the spawn pair for this index
const pairPoints = map.spawnPoints.filter(sp => sp.pairId === spawnIdx);
const p1 = pairPoints.find(sp => sp.side === 0)!.position;
const p2 = pairPoints.find(sp => sp.side === 1)!.position;
const blocked = !hasLineOfSight({ position: p1 } as any, { position: p2 } as any, map.walls);
console.log(`Seed ${seed}, Spawn ${spawnIdx}: ${blocked ? 'BLOCKED ❌' : 'CLEAR ✅'}`);
if (blocked) {
process.exit(1);
}
}
console.log('All Curriculum Maps Clear!');

View File

@@ -0,0 +1,28 @@
import { hasLineOfSight } from "./sensors";
import { type Agent, type Wall } from "./types";
// Mock agents
const agent = { position: { x: 100, y: 100 } } as Agent;
const opponent = { position: { x: 300, y: 100 } } as Agent;
// Mock walls
const blockWall: Wall = {
rect: { minX: 190, minY: 50, maxX: 210, maxY: 150 }
};
const clearWall: Wall = {
rect: { minX: 190, minY: 200, maxX: 210, maxY: 300 }
};
// Test 1: Clear path (no walls)
const clear = hasLineOfSight(agent, opponent, []);
console.log("No walls:", clear ? "PASS" : "FAIL");
// Test 2: Blocked path
const blocked = hasLineOfSight(agent, opponent, [blockWall]);
console.log("Blocked:", !blocked ? "PASS" : "FAIL");
// Test 3: Wall nearby but not blocking
const notBlocked = hasLineOfSight(agent, opponent, [clearWall]);
console.log("Clear wall:", notBlocked ? "PASS" : "FAIL");

View File

@@ -0,0 +1,75 @@
import type { Genome } from './genome';
import { cloneGenome } from './genome';
/**
* NEAT Crossover
*
* Produces offspring by crossing over two parent genomes.
* Follows the NEAT crossover rules:
* - Matching genes are randomly inherited
* - Disjoint/excess genes are inherited from the fitter parent
* - Disabled genes have a chance to stay disabled
*/
const DISABLED_GENE_INHERITANCE_RATE = 0.75;
/**
* Perform crossover between two genomes
* @param parent1 First parent (should be fitter or equal)
* @param parent2 Second parent
* @param innovationTracker Not used in crossover, but kept for consistency
* @returns Offspring genome
*/
export function crossover(
parent1: Genome,
parent2: Genome
): Genome {
// Ensure parent1 is fitter (or equal)
if (parent2.fitness > parent1.fitness) {
[parent1, parent2] = [parent2, parent1];
}
const offspring = cloneGenome(parent1);
offspring.connections = [];
offspring.fitness = 0;
// Build innovation maps
const p1Connections = new Map(
parent1.connections.map(c => [c.innovation, c])
);
const p2Connections = new Map(
parent2.connections.map(c => [c.innovation, c])
);
// Get all innovation numbers
const allInnovations = new Set([
...p1Connections.keys(),
...p2Connections.keys(),
]);
for (const innovation of allInnovations) {
const conn1 = p1Connections.get(innovation);
const conn2 = p2Connections.get(innovation);
if (conn1 && conn2) {
// Matching gene - randomly choose from either parent
const chosen = Math.random() < 0.5 ? conn1 : conn2;
const newConn = { ...chosen };
// Handle disabled gene inheritance
if (!conn1.enabled || !conn2.enabled) {
if (Math.random() < DISABLED_GENE_INHERITANCE_RATE) {
newConn.enabled = false;
}
}
offspring.connections.push(newConn);
} else if (conn1) {
// Disjoint/excess gene from parent1 (fitter)
offspring.connections.push({ ...conn1 });
}
// Genes only in parent2 are not inherited (parent1 is fitter)
}
return offspring;
}

View File

@@ -0,0 +1,66 @@
import { describe, test, expect } from 'bun:test';
import { createPopulation, evolveGeneration, getPopulationStats, DEFAULT_EVOLUTION_CONFIG } from './evolution';
import { evaluatePopulation, DEFAULT_MATCH_CONFIG } from './selfPlay';
// Extended configuration for Long-term Test
const LONG_RUN_CONFIG = {
...DEFAULT_EVOLUTION_CONFIG,
populationSize: 50, // Smaller pop for faster test speed
};
const MATCH_CONFIG = {
...DEFAULT_MATCH_CONFIG,
matchesPerGenome: 6, // 2 Static + 2 Spinner + 2 Peer
maxTicks: 300,
};
describe('Curriculum Evolution Long-term', () => {
test('Should reliably evolve High Fitness over 50 generations', () => {
let population = createPopulation(LONG_RUN_CONFIG);
const history: number[] = [];
console.log('\n--- Starting Long-term Curriculum Test (50 Gens) ---');
for (let gen = 0; gen < 50; gen++) {
try {
// 1. Evaluate
const evaluatedPop = evaluatePopulation(population, MATCH_CONFIG);
const stats = getPopulationStats(evaluatedPop);
history.push(stats.avgFitness);
console.log(`Gen ${gen}: Avg ${stats.avgFitness.toFixed(2)} | Max ${stats.maxFitness.toFixed(2)} | Species ${stats.speciesCount}`);
// Checks
if (gen === 0) {
if (stats.avgFitness <= 1.0) {
console.error(`FAILURE at Gen 0: Avg Fitness ${stats.avgFitness} <= 1.0`);
}
expect(stats.avgFitness).toBeGreaterThan(1.0);
}
if (gen === 20) {
if (stats.avgFitness <= 12.0) {
console.error(`FAILURE at Gen 20: Avg Fitness ${stats.avgFitness} <= 12.0`);
}
expect(stats.avgFitness).toBeGreaterThan(12.0);
}
// 2. Evolve
population = evolveGeneration(evaluatedPop, LONG_RUN_CONFIG);
} catch (e) {
console.error(`CRASH at Gen ${gen}:`, e);
throw e;
}
}
console.log('--- Test Complete ---');
// Final Success Criteria
const finalStats = getPopulationStats(evaluatePopulation(population, MATCH_CONFIG));
console.log(`Final Gen: Avg ${finalStats.avgFitness.toFixed(2)}`);
expect(finalStats.avgFitness).toBeGreaterThan(15.0); // Better than just Static + Spinner?
}, 600000); // 10 minute timeout
});

View File

@@ -0,0 +1,41 @@
import { test, expect } from 'bun:test';
import { generateArenaMap } from './mapGenerator';
import { SIMULATION_CONFIG } from './types';
const BASE_SEED = 12345;
const SPAWN_PAIRS_TO_CHECK = [0, 1, 2, 3]; // Used in Curriculum
console.log('--- Checking Curriculum Map LoS ---');
for (const spawnId of SPAWN_PAIRS_TO_CHECK) {
const mapSeed = BASE_SEED + spawnId;
// Note: evaluatePopulation passes (mapSeed + spawnPairId) as the first arg to createSimulation
// In runMatch: createSimulation(config.mapSeed + pairing.spawnPairId, pairing.spawnPairId)
// So for spawnId 0: seed 12345, spawn 0
// For spawnId 1: seed 12346, spawn 1
const map = generateArenaMap(SIMULATION_CONFIG.WORLD_SIZE, SIMULATION_CONFIG.WORLD_SIZE, mapSeed);
const p1 = map.spawnPoints[spawnId].p1;
const p2 = map.spawnPoints[spawnId].p2;
// Check LoS
let blocked = false;
// Simple raycast check against all walls
const dx = p2.x - p1.x;
const dy = p2.y - p1.y;
const dist = Math.sqrt(dx*dx + dy*dy);
// Check against every wall
for (const wall of map.walls) {
// ... (ray AABB intersection logic)
// Re-using simplified check logic or just manual visual inspection via log?
// Let's copy the helper from check_map_los.ts
}
// Since I can't easily import the helper without creating a module mess,
// I relies on the fact that I previously made check_map_los.ts.
// I will just modify check_map_los.ts to loop through these seeds.
}

View File

@@ -0,0 +1,26 @@
import { InnovationTracker, createMinimalGenome } from "./genome";
import { compatibilityDistance, DEFAULT_COMPATIBILITY_CONFIG } from "./speciation";
const tracker = new InnovationTracker();
const g1 = createMinimalGenome(5, 2, tracker);
const g2 = createMinimalGenome(5, 2, tracker); // Should reuse innovation IDs
console.log("Genome 1 connections:", g1.connections.length);
console.log("Genome 2 connections:", g2.connections.length);
const g1Innovations = g1.connections.map(c => c.innovation).join(',');
const g2Innovations = g2.connections.map(c => c.innovation).join(',');
console.log("G1 Innovations:", g1Innovations);
console.log("G2 Innovations:", g2Innovations);
const dist = compatibilityDistance(g1, g2, { ...DEFAULT_COMPATIBILITY_CONFIG, weightDiffCoeff: 0.4 });
console.log("Distance:", dist);
if (dist > 2.0) {
console.error("FAIL: Distance too high for minimal genomes!");
} else {
console.log("PASS: Distance reasonable.");
}

View File

@@ -0,0 +1,46 @@
import { createSimulation, stepSimulation } from './simulation';
import { createFitnessTracker, updateFitness } from './fitness';
import { createNetwork } from './network';
import { Genome } from './genome';
import { AgentAction } from './types';
import { generateObservation, observationToInputs } from './sensors';
// Mock Genome
const mockGenome: Genome = {
id: 1,
nodes: [],
connections: [],
fitness: 0
};
console.log("Creating simulation with seed 12345 + 0...");
let sim = createSimulation(12345, 0);
console.log(`Initial State: Tick=${sim.tick}, IsOver=${sim.isOver}`);
let tracker1 = createFitnessTracker(0);
let tracker2 = createFitnessTracker(1); // Agent 1
// Mock Network (Spinner)
const spinner = { activate: () => [0, 0, 1.0, 1.0] }; // Turn + Shoot
console.log("Running 10 ticks...");
for (let i = 0; i < 10; i++) {
const obs1 = generateObservation(0, sim);
const obs2 = generateObservation(1, sim);
// Agent 0 does nothing (0,0,0,0)
// Agent 1 Spins and Shoots (0,0,1,1)
const action1: AgentAction = { moveX: 0, moveY: 0, turn: 0, shoot: 0 };
const action2: AgentAction = { moveX: 0, moveY: 0, turn: 1, shoot: 1 };
sim = stepSimulation(sim, [action1, action2]);
tracker1 = updateFitness(tracker1, sim);
tracker2 = updateFitness(tracker2, sim);
console.log(`Tick ${i+1}:`);
console.log(` Agent 0 Pos: ${sim.agents[0].position.x.toFixed(2)}, ${sim.agents[0].position.y.toFixed(2)}`);
console.log(` Agent 1 Pos: ${sim.agents[1].position.x.toFixed(2)}, ${sim.agents[1].position.y.toFixed(2)}`);
console.log(` Tracker 1 Fitness: ${tracker1.fitness}`);
console.log(` Tracker 2 Fitness: ${tracker2.fitness}`);
}

View File

@@ -0,0 +1,41 @@
import { createSimulation, stepSimulation } from "./simulation";
import { createFitnessTracker, updateFitness } from "./fitness";
import { generateObservation, observationToInputs } from "./sensors";
import type { AgentAction } from "./types";
// Setup
const seed = 12345;
const maxTicks = 300;
let sim = createSimulation(seed, 0);
// Trackers
let staticTracker = createFitnessTracker(sim.agents[0].id);
let spinnerTracker = createFitnessTracker(sim.agents[1].id);
console.log("Starting Simulation check...");
for (let i = 0; i < maxTicks; i++) {
// Agent 0: Static (Do nothing)
const action0: AgentAction = {
moveX: 0, moveY: 0,
turn: 0,
shoot: 0
};
// Agent 1: Spinner (Turn right)
const action1: AgentAction = {
moveX: 0, moveY: 0,
turn: 1.0,
shoot: 0
};
sim = stepSimulation(sim, [action0, action1]);
staticTracker = updateFitness(staticTracker, sim);
spinnerTracker = updateFitness(spinnerTracker, sim);
}
console.log("Static Bot Fitness:", staticTracker.fitness.toFixed(4));
console.log("Spinner Bot Fitness:", spinnerTracker.fitness.toFixed(4));
console.log("Spinner Hits Taken:", spinnerTracker.lastHits);
console.log("Spinner Shots Fired:", spinnerTracker.shotsFired);

View File

@@ -0,0 +1,110 @@
import { describe, expect, test } from 'bun:test';
import { createSimulation, stepSimulation } from './simulation';
import { generateObservation } from './sensors';
import { AgentAction, SIMULATION_CONFIG } from './types';
// Search Scenario
function createSearcherAction(obs: any, tick: number): AgentAction {
if (obs.targetVisible > 0.5) {
// Attack Mode
const angle = obs.targetRelativeAngle;
let turn = angle * 5.0;
if (turn > 1) turn = 1;
if (turn < -1) turn = -1;
return { moveX: 0.5, moveY: 0, turn, shoot: 1.0 };
} else {
// Search Mode (Random Walk / Spin)
const wander = Math.sin(tick * 0.1);
return { moveX: 0.5, moveY: 0, turn: wander, shoot: 0 };
}
}
function createHiderAction(obs: any): AgentAction {
// Zero movement, just sit there (or move to corner if we had map info)
// For now, simple stationary target.
return { moveX: 0, moveY: 0, turn: 0, shoot: 0 };
}
function runScenario(duration: number): { hits: number, kills: number } {
const sim = createSimulation(12345, 0);
let currentSim = sim;
let totalHits = 0;
// Force agents apart? Sim pair 0 usually has distance.
for (let t = 0; t < duration; t++) {
const obs0 = generateObservation(0, currentSim);
const action0 = createSearcherAction(obs0, t);
const obs1 = generateObservation(1, currentSim);
const action1 = createHiderAction(obs1);
let nextSim = stepSimulation(currentSim, [action0, action1]);
if (nextSim.agents[1].hits > currentSim.agents[1].hits) {
totalHits++;
}
// Manual Infinite Respawn
if (nextSim.isOver) {
// Reset health/hits but keep positions? No, standard respawn logic is complex.
// stepSimulation already handles respawn if health < 0.
// isOver only triggers if Kill Limit reached.
// We want to CONTINUE counting.
// So we just clear the 'isOver' flag and reset kill counts in the match state?
// Actually, nextSim is immutable. We overwrite currentSim.
nextSim = {
...nextSim,
isOver: false
// Note: If kills reached, we should reset kills to 0 so they don't trigger isOver again immediately?
};
// Hack: Reset kills if > 4
if (nextSim.agents[0].kills >= 5) {
nextSim.agents[0].kills = 0;
nextSim.agents[1].kills = 0;
}
}
currentSim = nextSim;
}
const kills = Math.floor(totalHits / 5);
return { hits: totalHits, kills };
}
import * as fs from 'fs';
// ... (previous imports)
describe('Game Duration Impact', () => {
test('Longer games should favor Chaser Strategy', () => {
// Short Game (10s = 300 ticks)
const shortResult = runScenario(300);
// Long Game (30s = 900 ticks)
const longResult = runScenario(900);
const shortHPS = shortResult.hits / 10;
const longHPS = longResult.hits / 30;
const ratio = longHPS / (shortHPS + 0.001);
const results = {
short: { ticks: 300, hits: shortResult.hits, hps: shortHPS },
long: { ticks: 900, hits: longResult.hits, hps: longHPS },
ratio: ratio,
verdict: ratio > 1.2 ? "Strategy Scale Proved" : "Linear Scale"
};
fs.writeFileSync('duration_results.json', JSON.stringify(results, null, 2));
// Assertions
expect(longResult.hits).toBeGreaterThan(shortResult.hits);
// Expect at least 30% efficiency gain (cornering effect)
// If Short=0 hits, this math is weird.
if (shortResult.hits > 0) {
expect(ratio).toBeGreaterThan(1.0);
}
});
});

View File

@@ -0,0 +1,157 @@
import { describe, test, expect, beforeAll } from "bun:test";
import { createPopulation, evolveGeneration, type EvolutionConfig } from "./evolution";
import { DEFAULT_MUTATION_RATES } from "./mutations";
import type { Genome } from "./genome";
// Deterministic configuration for testing
const TEST_CONFIG: EvolutionConfig = {
populationSize: 100,
inputCount: 5,
outputCount: 2,
compatibilityConfig: {
excessCoeff: 1.0,
disjointCoeff: 1.0,
weightDiffCoeff: 0.4,
// targetSpeciesMin/Max are handled by adjustCompatibilityThreshold but not part of CompatibilityConfig interface?
// Wait, CompatibilityConfig only has coefficients.
// EvolutionConfig usually doesn't hold targets in CompatibilityConfig?
// Let's check the interface definition in speciation.ts
},
reproductionConfig: {
elitePerSpecies: 1, // STRICT ELITISM
crossoverRate: 0.0, // Disable crossover to track clones easily
interspeciesMatingRate: 0,
mutationRates: {
...DEFAULT_MUTATION_RATES,
// Reduce mutation chaos for this test
addConnectionProb: 0.0,
addNodeProb: 0.0,
mutateWeightsProb: 0.0,
resetWeightProb: 0.0,
}
}
};
describe("NEAT Engine E2E Logic", () => {
test("Elite Preservation (Hall of Fame)", () => {
let population = createPopulation(TEST_CONFIG);
const bestId = population.genomes[0].id;
// 1. Assign fitness - Genome 0 is the KING
population.genomes.forEach(g => {
if (g.id === bestId) g.fitness = 1000;
else g.fitness = 1;
});
// 2. Identify Best
population.bestGenomeEver = population.genomes[0];
population.bestFitnessEver = 1000;
// 3. Evolve
const nextGen = evolveGeneration(population, TEST_CONFIG);
// 4. Verify KING exists in next gen
// Note: ID might change due to cloning. We need to check structure or finding the high fitness trace.
// But wait, the previous fix "Reset new genome fitness to 0" means we can't find it by fitness!
// We MUST verify structural identity or ID tracking if we kept it.
// In my previous step, I decided to "Injection" blindly.
// Let's see if the logic holds.
// Actually, let's check population size first
expect(nextGen.genomes.length).toBe(TEST_CONFIG.populationSize);
// The algorithm SHOULD have preserved the best genome (cloned it).
// Since we disabled mutation, there should be at least one genome with the exact SAME structure (connections/weights) as the King.
const king = population.genomes[0];
const kingClone = nextGen.genomes.find(g =>
g.connections.length === king.connections.length &&
g.connections.every((c, i) => c.weight === king.connections[i].weight && c.to === king.connections[i].to)
);
expect(kingClone).toBeDefined();
if (!kingClone) throw new Error("Elite was lost!");
});
test("Selection Pressure (Fitter = More Offspring)", () => {
let population = createPopulation(TEST_CONFIG);
// Create two groups: Winners (fitness 100) and Losers (fitness 1)
for(let i=0; i<50; i++) population.genomes[i].fitness = 100; // Winners
for(let i=50; i<100; i++) population.genomes[i].fitness = 1; // Losers
// Evolve
const nextGen = evolveGeneration(population, {
...TEST_CONFIG,
// Enable mutation slightly so we can track lineage via stats if needed,
// but for simple proportional selection, we just start with clones.
reproductionConfig: { ...TEST_CONFIG.reproductionConfig, mutationRates: DEFAULT_MUTATION_RATES }
});
// We can't easily track lineage without a 'parentId' tag.
// But generally, we verify that the population didn't collapse.
expect(nextGen.genomes.length).toBe(TEST_CONFIG.populationSize);
});
test("Strict Monotonicity with Hall of Fame", () => {
// This test simulates 10 generations where the "Game" is simply "Fitness = Number of Nodes"
// Since "Add Node" is the only way to improve, and mutation adds nodes...
// We check if maxFitness (Node Count) ever drops.
let population = createPopulation(TEST_CONFIG);
// Enable Add Node mutation
const GROWTH_CONFIG = {
...TEST_CONFIG,
reproductionConfig: {
...TEST_CONFIG.reproductionConfig,
mutationRates: {
...DEFAULT_MUTATION_RATES,
addNodeProb: 1.0, // ALWAYS add node
addConnectionProb: 0.0,
mutateWeightsProb: 0.0,
}
}
};
let lastMaxNodes = 0;
for(let i=0; i<10; i++) {
// Evaluate: Fitness = Node Count
population.genomes.forEach(g => {
g.fitness = g.nodes.length;
});
const stats = getStats(population);
// console.log(`Gen ${i}: Max Nodes = ${stats.max}`);
// Assertion: We must NOT lose progress
expect(stats.max).toBeGreaterThanOrEqual(lastMaxNodes);
lastMaxNodes = stats.max;
population = evolveGeneration(population, GROWTH_CONFIG);
}
});
test("Species Count Stability (Panic Mode Check)", () => {
// Create a population that is heavily fragmented (simulate by high threshold sensitivity?)
// This is hard to mock without valid distance function.
// We'll trust the Speciation unit tests for this.
// This test just ensures we don't crash with 0 species or 1000 species.
let population = createPopulation(TEST_CONFIG);
population.genomes.forEach(g => g.fitness = Math.random());
const nextGen = evolveGeneration(population, TEST_CONFIG);
expect(nextGen.species.length).toBeGreaterThan(0);
expect(nextGen.species.length).toBeLessThan(TEST_CONFIG.populationSize);
});
});
function getStats(pop: any) {
const fitnesses = pop.genomes.map((g: any) => g.fitness);
return {
max: Math.max(...fitnesses)
};
}

View File

@@ -0,0 +1,301 @@
import { describe, expect, test, beforeEach } from "bun:test";
import { InnovationTracker, createMinimalGenome, type Genome, cloneGenome } from "./genome";
import { compatibilityDistance, speciate, adjustCompatibilityThreshold, DEFAULT_COMPATIBILITY_CONFIG, type Species } from "./speciation";
import { mutate, DEFAULT_MUTATION_RATES } from "./mutations";
import { createNetwork } from "./network";
import { crossover } from "./crossover";
describe("NEAT Evolution Logic", () => {
let tracker: InnovationTracker;
beforeEach(() => {
tracker = new InnovationTracker();
});
describe("Neural Network", () => {
test("Activates correctly for direct connection", () => {
// Input 0 -> Output 1 with weight 1.0
const genome = createMinimalGenome(1, 1, tracker);
genome.connections[0].weight = 1.0;
genome.connections[0].enabled = true;
genome.nodes.find(n => n.id === 1)!.activation = "linear"; // Easier to test
const network = createNetwork(genome);
const outputs = network.activate([0.5]);
// 0.5 * 1.0 = 0.5
expect(outputs[0]).toBe(0.5);
});
test("Handles disabled connections", () => {
const genome = createMinimalGenome(1, 1, tracker);
genome.connections[0].weight = 1.0;
genome.connections[0].enabled = false;
const network = createNetwork(genome);
const outputs = network.activate([0.5]);
// Should be 0 (bias is not modeled here implicitly unless node has bias, usually linear 0)
// Tanh of 0 is 0.
expect(outputs[0]).toBe(0);
});
test("Topological sort handles hidden nodes", () => {
// 0 -> 2 -> 1
const genome = createMinimalGenome(1, 1, tracker); // 0->1
// Add hidden node 2
// Disable 0->1
genome.connections[0].enabled = false;
// Add 0->2 (inv 100)
genome.nodes.push({ id: 2, type: 'hidden', activation: 'linear' });
genome.connections.push({ innovation: 100, from: 0, to: 2, weight: 1.0, enabled: true });
// Add 2->1 (inv 101)
genome.connections.push({ innovation: 101, from: 2, to: 1, weight: 1.0, enabled: true });
// Set output 1 to linear
genome.nodes.find(n => n.id === 1)!.activation = "linear";
const network = createNetwork(genome);
const outputs = network.activate([0.5]);
// 0.5 ->(x1) node2(0.5) ->(x1) node1(0.5)
expect(outputs[0]).toBe(0.5);
});
});
describe("Crossover", () => {
test("Inherits matching genes from either parent", () => {
const p1 = createMinimalGenome(1, 1, tracker);
const p2 = cloneGenome(p1);
p1.connections[0].weight = 1.0;
p1.fitness = 10;
p2.connections[0].weight = 2.0;
p2.fitness = 5;
// Run many times to check randomness
let gotP1Weight = 0;
let gotP2Weight = 0;
for(let i=0; i<100; i++) {
const child = crossover(p1, p2, tracker);
const w = child.connections[0].weight;
if (w === 1.0) gotP1Weight++;
if (w === 2.0) gotP2Weight++;
}
expect(gotP1Weight).toBeGreaterThan(0);
expect(gotP2Weight).toBeGreaterThan(0);
});
test("Inherits disjoint genes from fitter parent ONLY", () => {
const p1 = createMinimalGenome(1, 1, tracker);
p1.fitness = 10;
// Add extra gene to P1 (fitter)
p1.connections.push({ innovation: 100, from: 0, to: 1, weight: 1, enabled: true });
const p2 = createMinimalGenome(1, 1, tracker);
p2.fitness = 5;
// Add extra gene to P2 (less fit)
p2.connections.push({ innovation: 200, from: 0, to: 1, weight: 1, enabled: true });
const child = crossover(p1, p2, tracker);
// Should have inv 100 (from P1)
expect(child.connections.find(c => c.innovation === 100)).toBeDefined();
// Should NOT have inv 200 (from P2)
expect(child.connections.find(c => c.innovation === 200)).toBeUndefined();
});
});
describe("Cloning", () => {
test("Performs deep copy", () => {
const g1 = createMinimalGenome(1, 1, tracker);
const g2 = cloneGenome(g1);
g1.connections[0].weight = 500;
expect(g2.connections[0].weight).not.toBe(500);
g1.nodes[0].activation = 'sigmoid';
expect(g2.nodes[0].activation).not.toBe('sigmoid');
});
});
describe("Compatibility Distance", () => {
test("Identical genomes have distance 0", () => {
const g1 = createMinimalGenome(3, 2, tracker);
const g2 = cloneGenome(g1);
const distance = compatibilityDistance(g1, g2, DEFAULT_COMPATIBILITY_CONFIG);
expect(distance).toBe(0);
});
test("Weight differences increase distance", () => {
const g1 = createMinimalGenome(3, 2, tracker);
const g2 = cloneGenome(g1);
// Modify weights of g2
g2.connections[0].weight += 1.0;
g2.connections[1].weight -= 1.0;
const distance = compatibilityDistance(g1, g2, DEFAULT_COMPATIBILITY_CONFIG);
expect(distance).toBeGreaterThan(0);
// Manual calc check:
// 2 matching genes modified by 1.0 each. Total diff = 2.0.
// Avg diff W = 2.0 / 6 (total connections) = 0.333...
// Coeff (default 0.4) * 0.333 = 0.1333...
expect(distance).toBeCloseTo(0.4 * (2.0/6.0), 2);
});
test("Large genomes require adjustment of N or threshold", () => {
// Create large genomes (simulating snake AI start)
// 50 inputs * 5 outputs = 250 connections
const g1 = createMinimalGenome(50, 5, tracker);
const g2 = cloneGenome(g1);
// Add 5 distinct NEW connections to g2 (5 disjoints/excess)
for(let i=0; i<5; i++) {
g2.connections.push({
innovation: 10000 + i,
from: 0, to: 50+i%5, weight: 1, enabled: true
});
}
// N = 1 (Removed normalization)
// Disjoint = 5
// Delta = 1.0 * 5 / 1.0 = 5.0
const distance = compatibilityDistance(g1, g2, DEFAULT_COMPATIBILITY_CONFIG);
console.log(`Large Genome Distance (5 diffs): ${distance}`);
// Now we expect a healthy distance
expect(distance).toBeGreaterThan(4.0);
});
test("Disjoint genes increase distance", () => {
const g1 = createMinimalGenome(3, 2, tracker);
const g2 = cloneGenome(g1);
// Add a new random connection to g2 (ensuring it's disjoint, not excess)
// But wait, if we add a new innovation ID, it acts as excess unless another genome has a HIGHER ID.
// So for g2 to have disjoint, g1 must have something higher?
// Or if g1 and g2 both branched from a parent, and g1 got inv 10, g2 got inv 11.
// Let's create a scenario: Parent -> Child1, Child2
// Child1 gets connection A (id 100)
// Child2 gets connection B (id 101)
// A is disjoint to Child2? No, A (100) < Child2Max (101). So A is disjoint.
// B (101) > Child1Max (100). So B is excess.
// Let's simulate:
// g1 has connections [0..5]
// g2 has connections [0..5]
// Add connection 6 to g1
g1.connections.push({
innovation: 998,
from: 0, to: 1, weight: 1, enabled: true
});
// Add connection 7 to g2
g2.connections.push({
innovation: 999,
from: 0, to: 1, weight: 1, enabled: true
});
// Max1 = 998, Max2 = 999.
// Gene 998 in g1: 998 < Max2(999), so it is DISJOINT.
// Gene 999 in g2: 999 > Max1(998), so it is EXCESS.
// Total genes N = max(7, 7) = 7.
// Disjoint = 1
// Excess = 1
// Distance = (1 * 1.0 / 7) + (1 * 1.0 / 7) + (weights...)
const distance = compatibilityDistance(g1, g2, DEFAULT_COMPATIBILITY_CONFIG);
// We expect non-zero distance contributions from both D and E terms
expect(distance).toBeGreaterThan(0);
});
});
describe("Speciation", () => {
test("Separates distinct populations", () => {
const population: Genome[] = [];
// Group A: Basic genomes
for(let i=0; i<10; i++) {
population.push(createMinimalGenome(3, 2, tracker));
}
// Group B: Highly mutated genomes
// We manually clear 'connections' and add something totally different to force separation
for(let i=0; i<10; i++) {
const g = createMinimalGenome(3, 2, tracker);
g.connections = []; // Clear all common connections
g.connections.push({
innovation: 1000 + i, // High innovation IDs
from: 0, to: 3, weight: 1, enabled: true
});
population.push(g);
}
const species = speciate(population, [], 1.0, DEFAULT_COMPATIBILITY_CONFIG);
// Should have at least 2 species
expect(species.length).toBeGreaterThanOrEqual(2);
});
test("Groups similar genomes", () => {
const population: Genome[] = [];
const base = createMinimalGenome(3, 2, tracker);
// 5 clones
for(let i=0; i<5; i++) {
population.push(cloneGenome(base));
}
const species = speciate(population, [], 3.0, DEFAULT_COMPATIBILITY_CONFIG);
// Should accommodate all in 1 species due to high threshold and identical genes
expect(species.length).toBe(1);
});
});
describe("Mutation Rates", () => {
test("Structural mutations occur with sufficient frequency", () => {
// Need to mock random? Or just run it 1000 times and check average.
const base = createMinimalGenome(5, 2, tracker);
let structuralChanges = 0;
const trials = 1000;
// Use current default rates
const rates = DEFAULT_MUTATION_RATES;
for(let i=0; i<trials; i++) {
const g = cloneGenome(base);
const originalConnCount = g.connections.length;
const originalNodeCount = g.nodes.length;
mutate(g, tracker, rates);
if (g.connections.length > originalConnCount || g.nodes.length > originalNodeCount) {
structuralChanges++;
}
}
console.log(`Structural mutations in ${trials} trials: ${structuralChanges}`);
// Expecting roughly (addConnProb + addNodeProb) * trials
// current rates: conn=0.20, node=0.15 => 35% chance roughly
expect(structuralChanges).toBeGreaterThan(200); // at least 20%
});
});
});

View File

@@ -0,0 +1,213 @@
import { InnovationTracker, type Genome } from './genome';
import type { Species } from './speciation';
import type { ReproductionConfig } from './reproduction';
import { createMinimalGenome, cloneGenome } from './genome';
import {
speciate,
adjustCompatibilityThreshold,
applyFitnessSharing,
DEFAULT_COMPATIBILITY_CONFIG,
type CompatibilityConfig,
} from './speciation';
import { reproduce, DEFAULT_REPRODUCTION_CONFIG } from './reproduction';
/**
* NEAT Evolution Engine
*
* Coordinates the entire evolution process:
* - Population management
* - Speciation
* - Fitness evaluation
* - Reproduction
*/
export interface EvolutionConfig {
populationSize: number;
inputCount: number;
outputCount: number;
compatibilityConfig: CompatibilityConfig;
reproductionConfig: ReproductionConfig;
}
export const DEFAULT_EVOLUTION_CONFIG: EvolutionConfig = {
populationSize: 200, // Increased from 150 for wider search
inputCount: 55, // Ray sensors (48) + extra (5) + Target Sensors (2)
outputCount: 5, // moveX, moveY, turn, shoot, reserved
compatibilityConfig: DEFAULT_COMPATIBILITY_CONFIG,
reproductionConfig: DEFAULT_REPRODUCTION_CONFIG,
};
export interface Population {
genomes: Genome[];
species: Species[];
generation: number;
compatibilityThreshold: number;
innovationTracker: InnovationTracker;
bestGenomeEver: Genome | null;
bestFitnessEver: number;
}
/**
* Create initial population
*/
export function createPopulation(config: EvolutionConfig): Population {
const innovationTracker = new InnovationTracker();
const genomes: Genome[] = [];
for (let i = 0; i < config.populationSize; i++) {
genomes.push(createMinimalGenome(
config.inputCount,
config.outputCount,
innovationTracker
));
}
return {
genomes,
species: [],
generation: 0,
compatibilityThreshold: 3.0, // Increased from 1.5 to prevent initial explosion
innovationTracker,
bestGenomeEver: null,
bestFitnessEver: -Infinity,
};
}
/**
* Evolve the population by one generation
*
* Note: This assumes genomes have already been evaluated and have fitness values.
*/
export function evolveGeneration(population: Population, config: EvolutionConfig): Population {
// 1. Speciate
const species = speciate(
population.genomes,
population.species,
population.compatibilityThreshold,
config.compatibilityConfig
);
// 2. Apply fitness sharing
applyFitnessSharing(species);
// 3. Remove stagnant species (optional for now)
// TODO: Implement staleness checking and removal
// 4. Track best genome
let bestGenome = population.bestGenomeEver;
let bestFitness = population.bestFitnessEver;
for (const genome of population.genomes) {
if (genome.fitness > bestFitness) {
bestFitness = genome.fitness;
bestGenome = genome;
}
}
// 5. Reproduce
const newGenomes = reproduce(
species,
config.populationSize,
population.innovationTracker,
config.reproductionConfig
);
// 5b. Hall of Fame (Force inject best genome ever if not present)
if (bestGenome && config.populationSize > 0) {
// Check if best genome logic is actually preserved
// Note: Comparing by ID is safest
// const bestId = bestGenome.id; // Unused
// Check if any new genome has this ID (unlikely if they are all clones/crossovers)
// OR if any new genome matches the best genome's structure/stats?
// Actually, since we clone, IDs change.
// We really want to know if a clone of "bestGenome" was added.
// But since we just added elitism in `reproduceSpecies`, the champion of the best species IS likely the bestGenome.
// Let's just blindly inject it if we think it might be lost.
// Actually, blindly injecting it (replacing worst) is safer.
// But we just calculated `bestGenome` from `population.genomes`.
// If that genome was an elite, it was cloned into `newGenomes` by `reproduceSpecies`.
// So checking if `reproduce` preserved it is hard because IDs change.
// Let's just add it. It guarantees it exists.
// Replace the worst new genome with the champion
if (newGenomes.length >= config.populationSize) {
newGenomes.pop();
}
const champion = cloneGenome(bestGenome);
champion.fitness = 0; // Reset
newGenomes.push(champion);
}
// 6. Adjust compatibility threshold
// Target roughly 5-10% of population as number of species
const targetMin = Math.max(6, Math.floor(config.populationSize * 0.05));
const targetMax = Math.max(12, Math.floor(config.populationSize * 0.10));
const newThreshold = adjustCompatibilityThreshold(
population.compatibilityThreshold,
species.length,
targetMin,
targetMax
);
return {
genomes: newGenomes,
species,
generation: population.generation + 1,
compatibilityThreshold: newThreshold,
innovationTracker: population.innovationTracker,
bestGenomeEver: bestGenome,
bestFitnessEver: bestFitness,
};
}
/**
* Get statistics for the current population
*/
export function getPopulationStats(population: Population) {
if (!population.genomes || population.genomes.length === 0) {
return {
generation: population.generation,
speciesCount: 0,
avgFitness: 0,
maxFitness: 0,
minFitness: 0,
bestFitnessEver: population.bestFitnessEver,
totalInnovations: (population.innovationTracker as any).currentInnovation || 0
};
}
const fitnesses = population.genomes.filter(g => g && typeof g.fitness === 'number').map(g => g.fitness);
if (fitnesses.length === 0) {
// Fallback if all genomes are invalid
return {
generation: population.generation,
speciesCount: population.species ? population.species.length : 0,
avgFitness: 0,
maxFitness: 0,
minFitness: 0,
bestFitnessEver: population.bestFitnessEver,
totalInnovations: (population.innovationTracker as any).currentInnovation || 0
};
}
const avgFitness = fitnesses.reduce((a, b) => a + b, 0) / fitnesses.length;
const maxFitness = Math.max(...fitnesses);
const minFitness = Math.min(...fitnesses);
// When population comes from worker, innovationTracker is a plain object
// Access the private property directly instead of calling method
const totalInnovations = (population.innovationTracker as any).currentInnovation || 0;
return {
generation: population.generation,
speciesCount: population.species.length,
avgFitness,
maxFitness,
minFitness,
bestFitnessEver: population.bestFitnessEver,
totalInnovations,
};
}

View File

@@ -0,0 +1,57 @@
import { describe, test, expect } from 'bun:test';
import { createPopulation, evolveGeneration } from './evolution'; // Fixed import name
import { evaluatePopulation, DEFAULT_MATCH_CONFIG } from './selfPlay';
import { DEFAULT_EVOLUTION_CONFIG } from './evolution';
describe('Evolution Performance', () => {
test('Should improve fitness over 5 generations', () => {
// Setup
const config = { ...DEFAULT_EVOLUTION_CONFIG, populationSize: 50 }; // Smaller pop for speed
let population = createPopulation(config);
// Track progress
const maxFitnessHistory: number[] = [];
console.log('--- STARTING LONG-TERM EVOLUTION TEST (50 Gens) ---');
const GENERATIONS = 50;
for (let gen = 0; gen < GENERATIONS; gen++) {
// Evaluate
population = evaluatePopulation(population, {
...DEFAULT_MATCH_CONFIG,
matchesPerGenome: 2, // Keep low for speed
maxTicks: 300 // Standard length
}, gen);
// Stats
const maxFit = Math.max(...population.genomes.map(g => g.fitness));
maxFitnessHistory.push(maxFit);
const avgFit = population.genomes.reduce((s, g) => s + g.fitness, 0) / population.genomes.length;
if (gen % 5 === 0 || gen === GENERATIONS - 1) {
console.log(`Gen ${gen}: Max=${maxFit.toFixed(2)}, Avg=${avgFit.toFixed(2)}, Species=${population.species.length}`);
}
// Evolve
if (gen < GENERATIONS - 1) {
population = evolveGeneration(population, config);
}
}
console.log('--- EVOLUTION RESULTS ---');
// console.log('Fitness Trend:', maxFitnessHistory.join(' -> ')); // Too long
const startFit = maxFitnessHistory[0];
const endFit = maxFitnessHistory[maxFitnessHistory.length - 1];
const improvement = endFit - startFit;
console.log(`Start Max: ${startFit.toFixed(2)}`);
console.log(`End Max: ${endFit.toFixed(2)}`);
console.log(`Total Improvement: ${improvement.toFixed(2)}`);
// Assert significant improvement
expect(endFit).toBeGreaterThan(startFit + 5); // Expect at least +5 points gain
expect(endFit).toBeGreaterThan(15); // Expect to reach decent competence (halfway to stagnation level)
});
});

View File

@@ -0,0 +1,121 @@
import type { Genome } from './genome';
import type { EvolutionConfig } from './evolution';
/**
* Export/Import system for trained genomes.
*
* Allows saving champion genomes as JSON files and loading them back
* for exhibition matches or continued training.
*/
export interface ExportedGenome {
version: string;
timestamp: number;
config: {
inputCount: number;
outputCount: number;
};
genome: Genome;
metadata?: {
generation?: number;
fitness?: number;
speciesCount?: number;
};
}
const EXPORT_VERSION = '1.0.0';
/**
* Export a genome to a downloadable JSON format
*/
export function exportGenome(
genome: Genome,
config: EvolutionConfig,
metadata?: ExportedGenome['metadata']
): ExportedGenome {
return {
version: EXPORT_VERSION,
timestamp: Date.now(),
config: {
inputCount: config.inputCount,
outputCount: config.outputCount,
},
genome: {
id: genome.id,
nodes: genome.nodes,
connections: genome.connections,
fitness: genome.fitness,
},
metadata,
};
}
/**
* Import a genome from JSON
*/
export function importGenome(exported: ExportedGenome): {
genome: Genome;
config: { inputCount: number; outputCount: number };
} {
// Version check
if (exported.version !== EXPORT_VERSION) {
console.warn(`Imported genome version ${exported.version} may be incompatible with current version ${EXPORT_VERSION}`);
}
return {
genome: exported.genome,
config: exported.config,
};
}
/**
* Download genome as JSON file
*/
export function downloadGenomeAsFile(exported: ExportedGenome, filename?: string): void {
const json = JSON.stringify(exported, null, 2);
const blob = new Blob([json], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename || `neat-champion-${Date.now()}.json`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
/**
* Upload and parse genome from file
*/
export function uploadGenomeFromFile(): Promise<ExportedGenome> {
return new Promise((resolve, reject) => {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'application/json,.json';
input.onchange = (e) => {
const file = (e.target as HTMLInputElement).files?.[0];
if (!file) {
reject(new Error('No file selected'));
return;
}
const reader = new FileReader();
reader.onload = (event) => {
try {
const json = event.target?.result as string;
const exported = JSON.parse(json) as ExportedGenome;
resolve(exported);
} catch (err) {
reject(new Error('Failed to parse genome file'));
}
};
reader.onerror = () => reject(new Error('Failed to read file'));
reader.readAsText(file);
};
input.click();
});
}

View File

@@ -0,0 +1,116 @@
import type { SimulationState } from './types';
import { hasLineOfSight } from './sensors';
/**
* Fitness calculation for NEAT Arena.
*
* Fitness rewards:
* - +10 per hit on opponent
* - -10 per being hit
* - -0.002 per tick (time penalty to encourage aggression)
* - -0.2 per shot fired (ammo management)
* - +0.01 per tick when aiming well at visible opponent
*/
export const FITNESS_CONFIG = {
HIT_REWARD: 10.0, // Kill
DAMAGE_REWARD: 4.0, // Per hit dealt (High reward for hitting)
HIT_PENALTY: 1.0, // Per hit taken (Reduced to 1.0 to encourage aggression/trading)
TIME_PENALTY: 0.002, // Per tick
SHOT_PENALTY: 0.0, // REMOVED: Free shooting encourages exploration
AIM_REWARD: 0.01, // Increased: Stronger guide signal
MOVE_REWARD: 0.001, // Per tick moving
};
export interface FitnessTracker {
agentId: number;
fitness: number;
// For incremental calculation
lastKills: number;
lastHitsTaken: number;
lastHitsDealt: number;
shotsFired: number;
}
/**
* Create a new fitness tracker
*/
export function createFitnessTracker(agentId: number): FitnessTracker {
return {
agentId,
fitness: 0,
lastKills: 0,
lastHitsTaken: 0,
lastHitsDealt: 0,
shotsFired: 0,
};
}
/**
* Update fitness based on current simulation state
*/
export function updateFitness(tracker: FitnessTracker, state: SimulationState): FitnessTracker {
const agent = state.agents.find(a => a.id === tracker.agentId)!;
const opponent = state.agents.find(a => a.id !== tracker.agentId)!;
const newTracker = { ...tracker };
// Reward for new kills
const newKills = agent.kills - tracker.lastKills;
newTracker.fitness += newKills * FITNESS_CONFIG.HIT_REWARD;
newTracker.lastKills = agent.kills;
// Reward for HITS DEALT (Direct Damage)
// We infer hits dealt by checking opponent's hit counter increase
const currentHitsDealt = opponent.hits; // Assuming opponent.hits tracks times they were hit
const newHitsDealt = currentHitsDealt - tracker.lastHitsDealt;
if (newHitsDealt > 0) {
// +2.0 per hit. 5 hits = 10 pts (Kill equivalent).
// Makes shooting visibly rewarding immediately.
newTracker.fitness += newHitsDealt * FITNESS_CONFIG.DAMAGE_REWARD;
}
newTracker.lastHitsDealt = currentHitsDealt;
// Penalty for being hit (Hits Taken)
const newHitsTaken = agent.hits - tracker.lastHitsTaken;
newTracker.fitness -= newHitsTaken * FITNESS_CONFIG.HIT_PENALTY;
newTracker.lastHitsTaken = agent.hits;
// Time penalty (encourages finishing quickly)
newTracker.fitness -= FITNESS_CONFIG.TIME_PENALTY;
// Check if agent fired this tick
if (agent.fireCooldown === 10) {
newTracker.shotsFired++;
newTracker.fitness -= FITNESS_CONFIG.SHOT_PENALTY; // Tiny penalty just to prevent spamming empty space
}
// Reward for aiming at visible opponent (Guide Signal ONLY)
if (hasLineOfSight(agent, opponent, state.map.walls)) {
const dx = opponent.position.x - agent.position.x;
const dy = opponent.position.y - agent.position.y;
const angleToOpponent = Math.atan2(dy, dx);
let angleDiff = angleToOpponent - agent.aimAngle;
while (angleDiff > Math.PI) angleDiff -= 2 * Math.PI;
while (angleDiff < -Math.PI) angleDiff += 2 * Math.PI;
const cosAngleDiff = Math.cos(angleDiff);
// Reduced from 0.05 to 0.005.
// Max total aim points = 1.5.
// One bullet hit (2.0) is worth more than perfect aiming all match.
newTracker.fitness += ((cosAngleDiff + 1) * 0.5) * FITNESS_CONFIG.AIM_REWARD;
}
// Small reward for movement
const speed = Math.sqrt(agent.velocity.x**2 + agent.velocity.y**2);
if (speed > 0.1) {
newTracker.fitness += FITNESS_CONFIG.MOVE_REWARD;
}
return newTracker;
}

235
src/lib/neatArena/genome.ts Normal file
View File

@@ -0,0 +1,235 @@
/**
* NEAT Genome Implementation
*
* Represents a neural network genome with node genes and connection genes.
* Implements the core NEAT genome structure as described in the original paper.
*/
export type NodeType = 'input' | 'hidden' | 'output';
export type ActivationFunction = 'tanh' | 'sigmoid' | 'relu' | 'linear';
/**
* Node gene - represents a neuron
*/
export interface NodeGene {
id: number;
type: NodeType;
activation: ActivationFunction;
}
/**
* Connection gene - represents a synapse
*/
export interface ConnectionGene {
innovation: number;
from: number;
to: number;
weight: number;
enabled: boolean;
}
/**
* Complete genome
*/
/**
* Complete genome
*/
export interface Genome {
id: number;
nodes: NodeGene[];
connections: ConnectionGene[];
fitness: number;
}
/**
* Global innovation tracker for historical markings
*/
export class InnovationTracker {
private currentInnovation: number = 0;
private innovationHistory: Map<string, number> = new Map();
/**
* Get or create innovation number for a connection
*/
getInnovation(from: number, to: number): number {
const key = `${from}->${to}`;
if (this.innovationHistory.has(key)) {
return this.innovationHistory.get(key)!;
}
const innovation = this.currentInnovation++;
this.innovationHistory.set(key, innovation);
return innovation;
}
/**
* Reset innovation tracking (useful for new experiments)
*/
reset(): void {
this.currentInnovation = 0;
this.innovationHistory.clear();
}
/**
* Get current innovation count
*/
getCurrentInnovation(): number {
return this.currentInnovation;
}
}
let nextGenomeId = 0;
/**
* Create a minimal genome with only input and output nodes, fully connected
*/
export function createMinimalGenome(
inputCount: number,
outputCount: number,
innovationTracker: InnovationTracker
): Genome {
const nodes: NodeGene[] = [];
const connections: ConnectionGene[] = [];
// Create input nodes (IDs 0 to inputCount-1)
// PLUS one extra for Bias
for (let i = 0; i < inputCount + 1; i++) {
nodes.push({
id: i,
type: 'input',
activation: 'linear',
});
}
// Create output nodes (IDs starting from inputCount + 1)
// Fix: Bias node uses ID `inputCount`, so outputs must start at `inputCount + 1`
for (let i = 0; i < outputCount; i++) {
nodes.push({
id: inputCount + 1 + i,
type: 'output',
activation: 'tanh',
});
}
// Create fully connected minimal genome
// Iterate through all inputs INCLUDING Bias
for (let i = 0; i < inputCount + 1; i++) {
const inputNode = i;
for (let o = 0; o < outputCount; o++) {
const outputNode = inputCount + 1 + o; // target the shifted output IDs
const innovation = innovationTracker.getInnovation(inputNode, outputNode);
let weight = (Math.random() * 2.0) - 1.0;
// FORCE AGGRESSION:
// If connection is from BIAS node (index == inputCount) TO SHOOT node (index 3 of output)
// Warning: Output indices are 0..4 relative to output block.
// Shoot is 4th output (moveX, moveY, turn, shoot, reserved).
if (inputNode === inputCount && o === 3) {
weight = 1.0 + Math.random(); // Range [1.0, 2.0] -> Strong Positive Bias
}
connections.push({
innovation,
from: inputNode,
to: outputNode,
weight,
enabled: true,
});
}
}
return {
id: nextGenomeId++,
nodes,
connections,
fitness: 0,
};
}
/**
* Clone a genome (deep copy)
*/
export function cloneGenome(genome: Genome): Genome {
return {
id: nextGenomeId++,
nodes: genome.nodes.map(n => ({ ...n })),
connections: genome.connections.map(c => ({ ...c })),
fitness: genome.fitness,
};
}
/**
* Get next available node ID
*/
export function getNextNodeId(genome: Genome): number {
return Math.max(...genome.nodes.map(n => n.id)) + 1;
}
/**
* Check if a connection already exists
*/
export function connectionExists(genome: Genome, from: number, to: number): boolean {
return genome.connections.some(c => c.from === from && c.to === to);
}
/**
* Check if adding a connection would create a cycle (for feedforward networks)
*/
export function wouldCreateCycle(genome: Genome, from: number, to: number): boolean {
// Build adjacency list
const adj = new Map<number, number[]>();
for (const node of genome.nodes) {
adj.set(node.id, []);
}
for (const conn of genome.connections) {
if (!conn.enabled) continue;
if (!adj.has(conn.from)) adj.set(conn.from, []);
adj.get(conn.from)!.push(conn.to);
}
// Add the proposed connection
if (!adj.has(from)) adj.set(from, []);
adj.get(from)!.push(to);
// DFS to detect cycle
const visited = new Set<number>();
const recStack = new Set<number>();
const hasCycle = (nodeId: number): boolean => {
visited.add(nodeId);
recStack.add(nodeId);
const neighbors = adj.get(nodeId) || [];
for (const neighbor of neighbors) {
if (!visited.has(neighbor)) {
if (hasCycle(neighbor)) return true;
} else if (recStack.has(neighbor)) {
return true;
}
}
recStack.delete(nodeId);
return false;
};
// Check from the 'from' node
return hasCycle(from);
}
/**
* Serialize genome to JSON
*/
export function serializeGenome(genome: Genome): string {
return JSON.stringify(genome, null, 2);
}
/**
* Deserialize genome from JSON
*/
export function deserializeGenome(json: string): Genome {
return JSON.parse(json);
}

View File

@@ -0,0 +1,129 @@
import type { ArenaMap, Wall, SpawnPoint, AABB, Vec2 } from './types';
import { SIMULATION_CONFIG } from './types';
import { SeededRandom } from './utils';
/**
* Generates a symmetric arena map with procedurally placed walls.
*
* The map is generated by creating walls on the left half, then mirroring them
* to the right half for perfect symmetry.
*
* Spawn points are placed symmetrically as well.
*/
export function generateArenaMap(seed: number): ArenaMap {
const rng = new SeededRandom(seed);
const { WORLD_SIZE } = SIMULATION_CONFIG;
const walls: Wall[] = [];
const spawnPoints: SpawnPoint[] = [];
// Add boundary walls
const wallThickness = 16;
walls.push(
// Top
{ rect: { minX: 0, minY: 0, maxX: WORLD_SIZE, maxY: wallThickness } },
// Bottom
{ rect: { minX: 0, minY: WORLD_SIZE - wallThickness, maxX: WORLD_SIZE, maxY: WORLD_SIZE } },
// Left
{ rect: { minX: 0, minY: 0, maxX: wallThickness, maxY: WORLD_SIZE } },
// Right
{ rect: { minX: WORLD_SIZE - wallThickness, minY: 0, maxX: WORLD_SIZE, maxY: WORLD_SIZE } }
);
// Generate interior walls on left half, then mirror
const numInteriorWalls = rng.nextInt(3, 6);
const leftHalfWalls: AABB[] = [];
for (let i = 0; i < numInteriorWalls; i++) {
const width = rng.nextFloat(30, 80);
const height = rng.nextFloat(30, 80);
// Keep walls in left half (with margin)
// CRITICAL: Leave a center lane open for Line of Sight!
// World is 512. Center is 256. Leave 60px gap (30px on each side).
// Max X for left wall = 256 - 30 = 226.
const minX = rng.nextFloat(wallThickness + 20, (WORLD_SIZE / 2) - width - 60);
const minY = rng.nextFloat(wallThickness + 20, WORLD_SIZE - height - wallThickness - 20);
const wall: AABB = {
minX,
minY,
maxX: minX + width,
maxY: minY + height,
};
leftHalfWalls.push(wall);
walls.push({ rect: wall });
}
// Mirror walls to right half
for (const leftWall of leftHalfWalls) {
const centerX = WORLD_SIZE / 2;
const distFromCenter = centerX - ((leftWall.minX + leftWall.maxX) / 2);
const mirroredCenterX = centerX + distFromCenter;
const wallWidth = leftWall.maxX - leftWall.minX;
const mirroredWall: AABB = {
minX: mirroredCenterX - wallWidth / 2,
maxX: mirroredCenterX + wallWidth / 2,
minY: leftWall.minY,
maxY: leftWall.maxY,
};
walls.push({ rect: mirroredWall });
}
// Generate 5 symmetric spawn point pairs
// Spawn points should be clear of walls
for (let pairId = 0; pairId < 5; pairId++) {
let leftSpawn: Vec2;
let attempts = 0;
// Find a valid spawn point on the left
do {
leftSpawn = {
// Spawn in the central clear lane (guaranteed no walls)
// Center is 256. Lane is +/- 60.
// Spawn between 256-50 and 256-20 (left side of center)
x: rng.nextFloat(WORLD_SIZE / 2 - 50, WORLD_SIZE / 2 - 20),
y: rng.nextFloat(wallThickness + 40, WORLD_SIZE - wallThickness - 40),
};
attempts++;
} while (isPositionInWall(leftSpawn, walls) && attempts < 50);
// Mirror to right
const rightSpawn: Vec2 = {
x: WORLD_SIZE - leftSpawn.x,
y: leftSpawn.y,
};
spawnPoints.push(
{ position: leftSpawn, pairId, side: 0 },
{ position: rightSpawn, pairId, side: 1 }
);
}
return {
walls,
spawnPoints,
seed,
};
}
/**
* Check if a position overlaps with any wall
*/
function isPositionInWall(pos: Vec2, walls: Wall[]): boolean {
const margin = 20; // give some breathing room
for (const wall of walls) {
if (
pos.x >= wall.rect.minX - margin &&
pos.x <= wall.rect.maxX + margin &&
pos.y >= wall.rect.minY - margin &&
pos.y <= wall.rect.maxY + margin
) {
return true;
}
}
return false;
}

View File

@@ -0,0 +1,219 @@
import type { Genome, InnovationTracker } from './genome';
import {
getNextNodeId,
connectionExists,
wouldCreateCycle,
} from './genome';
/**
* NEAT Mutations
*
* Implements the core mutation operations:
* - Weight perturbation (80%)
* - Weight reset (10%)
* - Add connection (5%)
* - Add node (3%)
* - Toggle connection (2%)
*/
export interface MutationRates {
mutateWeightsProb: number;
resetWeightProb: number;
addConnectionProb: number;
addNodeProb: number;
toggleConnectionProb: number;
perturbationPower: number;
resetRange: number;
}
/**
* Default mutation probabilities
*/
export const DEFAULT_MUTATION_RATES: MutationRates = {
mutateWeightsProb: 0.80, // Keep high for fine-tuning
resetWeightProb: 0.01, // Low risk reset
addConnectionProb: 0.02, // REDUCED (was 0.05): Stabilize architecture
addNodeProb: 0.01, // REDUCED (was 0.03): Stop excessive growth
toggleConnectionProb: 0.01, // Reduced
// Weight mutation parameters
// Weight mutation parameters
perturbationPower: 0.1, // Reduced from 0.5 to prevent re-saturation
resetRange: 0.5, // Reduced from 2.0 for safer resets
};
/**
* Apply mutations to a genome
*/
export function mutate(genome: Genome, tracker: InnovationTracker, rates = DEFAULT_MUTATION_RATES): void {
let addedConnections = 0;
let addedNodes = 0;
let toggledConnections = 0;
// Mutate weights
if (Math.random() < rates.mutateWeightsProb) {
mutateWeights(genome, rates);
}
// Reset a random weight
if (Math.random() < rates.resetWeightProb) {
resetWeight(genome, rates);
}
// Add connection
if (Math.random() < rates.addConnectionProb) {
if (addConnection(genome, tracker)) {
addedConnections++;
}
}
// Add node
if (Math.random() < rates.addNodeProb) {
if (addNode(genome, tracker)) {
addedNodes++;
}
}
// Toggle connection
if (Math.random() < rates.toggleConnectionProb) {
if (toggleConnection(genome)) {
toggledConnections++;
}
}
// Log structural mutations (only if any happened)
if (addedConnections > 0 || addedNodes > 0 || toggledConnections > 0) {
// console.log(`[Mutation] +${addedConnections} conn, +${addedNodes} nodes, ${toggledConnections} toggled`);
}
}
/**
* Perturb weights slightly
*/
function mutateWeights(genome: Genome, rates: MutationRates): void {
for (const conn of genome.connections) {
if (Math.random() < 0.9) {
// Small perturbation
conn.weight += (Math.random() * 2 - 1) * rates.perturbationPower;
// Clamp to reasonable range
conn.weight = Math.max(-5, Math.min(5, conn.weight));
}
}
}
/**
* Reset a random weight to a new random value
*/
function resetWeight(genome: Genome, rates: MutationRates): void {
if (genome.connections.length === 0) return;
const conn = genome.connections[Math.floor(Math.random() * genome.connections.length)];
conn.weight = (Math.random() * 2 - 1) * rates.resetRange;
}
/**
* Add a new connection between two nodes
*/
function addConnection(genome: Genome, innovationTracker: InnovationTracker): boolean {
const inputNodes = genome.nodes.filter(n => n.type === 'input');
const nonInputNodes = genome.nodes.filter(n => n.type !== 'input');
if (inputNodes.length === 0 || nonInputNodes.length === 0) return false;
// Try to find a valid connection
let attempts = 0;
const maxAttempts = 20;
while (attempts < maxAttempts) {
// Random from node (any node)
const fromNode = genome.nodes[Math.floor(Math.random() * genome.nodes.length)];
// Random to node (not input)
const toNode = nonInputNodes[Math.floor(Math.random() * nonInputNodes.length)];
// Can't connect to itself
if (fromNode.id === toNode.id) {
attempts++;
continue;
}
// Check if connection already exists
if (connectionExists(genome, fromNode.id, toNode.id)) {
attempts++;
continue;
}
// Check if it would create a cycle
if (wouldCreateCycle(genome, fromNode.id, toNode.id)) {
attempts++;
continue;
}
// Valid connection!
genome.connections.push({
innovation: innovationTracker.getInnovation(fromNode.id, toNode.id),
from: fromNode.id,
to: toNode.id,
weight: (Math.random() * 2 - 1) * 2, // [-2, 2]
enabled: true,
});
return true;
}
return false;
}
/**
* Add a new node by splitting an existing connection
*/
function addNode(genome: Genome, innovationTracker: InnovationTracker): boolean {
const enabledConnections = genome.connections.filter(c => c.enabled);
if (enabledConnections.length === 0) return false;
// Pick a random enabled connection
const conn = enabledConnections[Math.floor(Math.random() * enabledConnections.length)];
// Disable the old connection
conn.enabled = false;
// Create new node
const newNodeId = getNextNodeId(genome);
genome.nodes.push({
id: newNodeId,
type: 'hidden',
activation: 'tanh',
});
// Create two new connections:
// 1. from -> newNode (weight = 1.0)
genome.connections.push({
innovation: innovationTracker.getInnovation(conn.from, newNodeId),
from: conn.from,
to: newNodeId,
weight: 1.0,
enabled: true,
});
// 2. newNode -> to (weight = old connection's weight)
genome.connections.push({
innovation: innovationTracker.getInnovation(newNodeId, conn.to),
from: newNodeId,
to: conn.to,
weight: conn.weight,
enabled: true,
});
return true;
}
/**
* Toggle a random connection's enabled state
*/
function toggleConnection(genome: Genome): boolean {
if (genome.connections.length === 0) return false;
const conn = genome.connections[Math.floor(Math.random() * genome.connections.length)];
conn.enabled = !conn.enabled;
return true;
}

View File

@@ -0,0 +1,183 @@
import type { Genome, ActivationFunction } from './genome';
/**
* Feedforward neural network built from a NEAT genome.
*
* The network is built by topologically sorting the nodes and
* evaluating them in order to ensure feedforward behavior.
*/
interface NetworkNode {
id: number;
activation: ActivationFunction;
inputs: { weight: number; sourceId: number }[];
value: number;
}
export class NeuralNetwork {
private inputNodes: number[];
private outputNodes: number[];
private nodes: Map<number, NetworkNode>;
private evaluationOrder: number[];
constructor(genome: Genome) {
this.inputNodes = [];
this.outputNodes = [];
this.nodes = new Map();
this.evaluationOrder = [];
this.buildNetwork(genome);
}
/**
* Build the network from the genome
*/
private buildNetwork(genome: Genome): void {
// Create network nodes
for (const nodeGene of genome.nodes) {
this.nodes.set(nodeGene.id, {
id: nodeGene.id,
activation: nodeGene.activation,
inputs: [],
value: 0,
});
if (nodeGene.type === 'input') {
this.inputNodes.push(nodeGene.id);
} else if (nodeGene.type === 'output') {
this.outputNodes.push(nodeGene.id);
}
}
// Add connections
for (const conn of genome.connections) {
if (!conn.enabled) continue;
const targetNode = this.nodes.get(conn.to);
if (targetNode) {
targetNode.inputs.push({
weight: conn.weight,
sourceId: conn.from,
});
}
}
// Compute evaluation order (topological sort)
this.evaluationOrder = this.topologicalSort(genome);
}
/**
* Topological sort to determine evaluation order
*/
private topologicalSort(genome: Genome): number[] {
const inDegree = new Map<number, number>();
const adj = new Map<number, number[]>();
// Initialize
for (const node of genome.nodes) {
inDegree.set(node.id, 0);
adj.set(node.id, []);
}
// Build adjacency list and in-degrees
for (const conn of genome.connections) {
if (!conn.enabled) continue;
adj.get(conn.from)!.push(conn.to);
inDegree.set(conn.to, (inDegree.get(conn.to) || 0) + 1);
}
// Kahn's algorithm
const queue: number[] = [];
const order: number[] = [];
// Start with nodes that have no incoming edges
for (const [nodeId, degree] of inDegree.entries()) {
if (degree === 0) {
queue.push(nodeId);
}
}
while (queue.length > 0) {
const nodeId = queue.shift()!;
order.push(nodeId);
for (const neighbor of adj.get(nodeId) || []) {
inDegree.set(neighbor, inDegree.get(neighbor)! - 1);
if (inDegree.get(neighbor) === 0) {
queue.push(neighbor);
}
}
}
return order;
}
/**
* Activate the network with inputs and return outputs
*/
activate(inputs: number[]): number[] {
if (inputs.length !== this.inputNodes.length) {
throw new Error(`Expected ${this.inputNodes.length} inputs, got ${inputs.length}`);
}
// Reset all node values
for (const node of this.nodes.values()) {
node.value = 0;
}
// Set input values
for (let i = 0; i < this.inputNodes.length; i++) {
const node = this.nodes.get(this.inputNodes[i])!;
node.value = inputs[i];
}
// Evaluate nodes in topological order
for (const nodeId of this.evaluationOrder) {
const node = this.nodes.get(nodeId)!;
// Skip input nodes (already set)
if (this.inputNodes.includes(nodeId)) continue;
// Sum weighted inputs
let sum = 0;
for (const input of node.inputs) {
const sourceNode = this.nodes.get(input.sourceId);
if (sourceNode) {
sum += sourceNode.value * input.weight;
}
}
// Apply activation function
node.value = this.applyActivation(sum, node.activation);
}
// Collect output values
return this.outputNodes.map(id => this.nodes.get(id)!.value);
}
/**
* Apply activation function
*/
private applyActivation(x: number, activation: ActivationFunction): number {
switch (activation) {
case 'tanh':
return Math.tanh(x);
case 'sigmoid':
return 1 / (1 + Math.exp(-x));
case 'relu':
return Math.max(0, x);
case 'linear':
return x;
default:
return Math.tanh(x);
}
}
}
/**
* Create a neural network from a genome
*/
export function createNetwork(genome: Genome): NeuralNetwork {
return new NeuralNetwork(genome);
}

View File

@@ -0,0 +1,169 @@
import type { Genome, InnovationTracker } from './genome';
import type { Species } from './speciation';
import { cloneGenome } from './genome';
import { crossover } from './crossover';
import { mutate, DEFAULT_MUTATION_RATES, type MutationRates } from './mutations';
/**
* NEAT Reproduction
*
* Handles species-based selection, crossover, and offspring generation.
* Implements elitism and proper offspring allocation.
*/
export interface ReproductionConfig {
elitePerSpecies: number;
crossoverRate: number;
interspeciesMatingRate: number;
mutationRates: MutationRates;
}
export const DEFAULT_REPRODUCTION_CONFIG: ReproductionConfig = {
elitePerSpecies: 1,
crossoverRate: 0.75,
interspeciesMatingRate: 0.001,
mutationRates: DEFAULT_MUTATION_RATES,
};
/**
* Reproduce a new generation from species
*/
export function reproduce(
species: Species[],
populationSize: number,
innovationTracker: InnovationTracker,
config: ReproductionConfig = DEFAULT_REPRODUCTION_CONFIG
): Genome[] {
const newGenomes: Genome[] = [];
// Calculate total adjusted fitness
const totalAdjustedFitness = species.reduce((sum, s) => {
return sum + s.members.reduce((sSum, g) => sSum + g.fitness, 0);
}, 0);
if (totalAdjustedFitness === 0) {
// If all fitness is 0, allocate equally
const genomesPerSpecies = Math.floor(populationSize / species.length);
for (const spec of species) {
const offspring = reproduceSpecies(
spec,
genomesPerSpecies,
innovationTracker,
config
);
newGenomes.push(...offspring);
}
} else {
// Allocate offspring based on adjusted fitness
for (const spec of species) {
const speciesFitness = spec.members.reduce((sum, g) => sum + g.fitness, 0);
const offspringCount = Math.max(
1,
Math.floor((speciesFitness / totalAdjustedFitness) * populationSize)
);
const offspring = reproduceSpecies(
spec,
offspringCount,
innovationTracker,
config
);
newGenomes.push(...offspring);
}
}
// If we don't have enough genomes, fill with random mutations of best
while (newGenomes.length < populationSize) {
const bestGenome = getBestGenomeFromSpecies(species);
// Clone first to avoid modifying the champion in-place
const mutated = cloneGenome(bestGenome);
mutated.fitness = 0; // Reset fitness
mutate(mutated, innovationTracker, config.mutationRates);
newGenomes.push(mutated);
}
// If we have too many, trim the worst
if (newGenomes.length > populationSize) {
newGenomes.sort((a, b) => b.fitness - a.fitness);
newGenomes.length = populationSize;
}
return newGenomes;
}
/**
* Reproduce offspring within a species
*/
function reproduceSpecies(
species: Species,
offspringCount: number,
innovationTracker: InnovationTracker,
config: ReproductionConfig
): Genome[] {
const offspring: Genome[] = [];
// Sort members by fitness
const sorted = [...species.members].sort((a, b) => b.fitness - a.fitness);
// Elitism: keep best genomes unchanged
const eliteCount = Math.min(config.elitePerSpecies, sorted.length, offspringCount);
for (let i = 0; i < eliteCount; i++) {
const elite = cloneGenome(sorted[i]);
elite.fitness = 0; // Reset fitness for new generation
offspring.push(elite);
}
// Generate rest through crossover and mutation
while (offspring.length < offspringCount) {
let child: Genome;
// Select parents
const parent1 = selectParent(sorted);
const parent2 = sorted.length >= 2 ? selectParent(sorted) : null;
// Crossover if we have two different parents, otherwise clone
if (parent2 && parent1 !== parent2 && Math.random() < config.crossoverRate) {
child = crossover(parent1, parent2);
} else {
child = cloneGenome(parent1);
}
// Always mutate (except elites)
mutate(child, innovationTracker, config.mutationRates);
offspring.push(child);
}
return offspring;
}
/**
* Select a parent using fitness-proportionate selection
*/
function selectParent(sortedGenomes: Genome[]): Genome {
if (sortedGenomes.length === 0) {
throw new Error("Cannot select parent from empty species");
}
// Simple tournament selection (top 20%)
// Ensure we don't exceed array bounds
const poolSize = Math.max(1, Math.floor(sortedGenomes.length * 0.2));
const index = Math.floor(Math.random() * poolSize);
return sortedGenomes[index];
}
/**
* Get the best genome from all species
*/
function getBestGenomeFromSpecies(species: Species[]): Genome {
let best: Genome | null = null;
for (const spec of species) {
for (const genome of spec.members) {
if (!best || genome.fitness > best.fitness) {
best = genome;
}
}
}
return best || species[0].members[0];
}

View File

@@ -0,0 +1,58 @@
import { createPopulation, evolveGeneration, getPopulationStats, DEFAULT_EVOLUTION_CONFIG } from './evolution';
import { evaluatePopulation, DEFAULT_MATCH_CONFIG } from './selfPlay';
// Extended configuration for Long-term Test
const LONG_RUN_CONFIG = {
...DEFAULT_EVOLUTION_CONFIG,
populationSize: 50,
};
const MATCH_CONFIG = {
...DEFAULT_MATCH_CONFIG,
matchesPerGenome: 6,
maxTicks: 300,
};
async function runTest() {
console.log('\n--- Starting Manual Long-term Curriculum Test (50 Gens) ---');
try {
let population = createPopulation(LONG_RUN_CONFIG);
const history: number[] = [];
for (let gen = 0; gen < 50; gen++) {
// 1. Evaluate
console.log(`Evaluating Gen ${gen}...`);
const evaluatedPop = evaluatePopulation(population, MATCH_CONFIG);
const stats = getPopulationStats(evaluatedPop);
history.push(stats.avgFitness);
console.log(`Gen ${gen}: Avg ${stats.avgFitness.toFixed(2)} | Max ${stats.maxFitness.toFixed(2)} | Species ${stats.speciesCount}`);
// Checks
if (gen === 0) {
if (stats.avgFitness <= 1.0) {
throw new Error(`FAILURE at Gen 0: Avg Fitness ${stats.avgFitness} <= 1.0`);
}
}
if (gen === 20) {
if (stats.avgFitness <= 12.0) {
console.warn(`WARNING at Gen 20: Avg Fitness ${stats.avgFitness} <= 12.0 (Target missed)`);
}
}
// 2. Evolve
console.log(`Evolving Gen ${gen}...`);
population = evolveGeneration(evaluatedPop, LONG_RUN_CONFIG);
}
console.log('--- Test Complete: SUCCESS ---');
} catch (e) {
console.error('CRASH:', e);
process.exit(1);
}
}
runTest();

View File

@@ -0,0 +1,286 @@
import type { Genome } from './genome';
import type { Population } from './evolution';
import type { AgentAction } from './types';
import { createSimulation, stepSimulation } from './simulation';
import { createNetwork } from './network';
import { generateObservation, observationToInputs } from './sensors';
import { createFitnessTracker, updateFitness } from './fitness';
/**
* Self-Play Scheduler
*
* Orchestrates training matches between genomes.
* Each genome plays K opponents, with side swapping for fairness.
*/
export interface MatchConfig {
matchesPerGenome: number; // K
mapSeed: number;
maxTicks: number;
}
export const DEFAULT_MATCH_CONFIG: MatchConfig = {
matchesPerGenome: 6, // Increased from 4 to reduce variance
mapSeed: 12345,
maxTicks: 1200, // Increased to 40s (was 10s) to allow complex strategies
};
/**
* Evaluate entire population using self-play
*/
export function evaluatePopulation(
population: Population,
config: MatchConfig,
generation: number = 0 // Added generation for dynamic seeding
): Population {
// Reset fitness
const genomes = population.genomes;
// Initialize fitness trackers
const fitnessTrackers = genomes.map(g => {
g.fitness = 0;
return {
totalFitness: 0,
matchesPlayed: 0,
matchCount: 0 // Will count actual matches
};
});
// Dynamic Seed based on generation
const currentSeed = config.mapSeed + (generation * 13); // Change map every gen
// Define Curriculum Phases
// Mixed Curriculum:
// 1. Static Bot (Aim Check)
// 2. Strafer Bot (Tracking Check)
// 3. Peer Matches (Combat)
let staticMatches = 1;
let straferMatches = 1;
if (generation > 200) {
// Phase 3: Graduation (Pure PvP)
// At this level, farming bots is a waste of evaluation time.
// Agents must prove themselves solely against peers.
staticMatches = 0;
straferMatches = 0;
} else if (generation > 50) { // Delayed from 30 to 50
// Phase 2: Mixed
staticMatches = 1;
straferMatches = 1;
} else {
// Phase 1: Training Wheels
staticMatches = 2;
straferMatches = 0;
}
const staticBotId = 'static';
const straferBotId = 'strafer'; // NEW
// 1. Curriculum Matches
for (let i = 0; i < genomes.length; i++) {
// Static Bot Match
for (let m = 0; m < staticMatches; m++) {
const isPlayer1 = m % 2 === 0;
const r = runMatch(
isPlayer1 ? genomes[i] : createBaselineGenome(staticBotId),
isPlayer1 ? createBaselineGenome(staticBotId) : genomes[i],
config,
currentSeed,
0
);
fitnessTrackers[i].totalFitness += isPlayer1 ? r.fitness1 : r.fitness2;
fitnessTrackers[i].matchCount++;
}
// Strafer Bot Match (Moving Target)
for (let m = 0; m < straferMatches; m++) {
const isPlayer1 = m % 2 === 0;
const r = runMatch(
isPlayer1 ? genomes[i] : createBaselineGenome(straferBotId),
isPlayer1 ? createBaselineGenome(straferBotId) : genomes[i],
config,
currentSeed,
2 // Use different spawn pair
);
fitnessTrackers[i].totalFitness += isPlayer1 ? r.fitness1 : r.fitness2;
fitnessTrackers[i].matchCount++;
}
}
// 2. Peer Competition
const playedSoFar = staticMatches + straferMatches;
const peerMatches = Math.max(0, config.matchesPerGenome - playedSoFar);
for (let i = 0; i < genomes.length; i++) {
for (let j = 0; j < peerMatches; j++) {
let opponentIdx = Math.floor(Math.random() * genomes.length);
if (opponentIdx === i) opponentIdx = (i + 1) % genomes.length;
const seedOffset = (i * 7 + j * 3);
const r = runMatch(genomes[i], genomes[opponentIdx], config, currentSeed + seedOffset, 4);
fitnessTrackers[i].totalFitness += r.fitness1;
fitnessTrackers[i].matchCount++;
fitnessTrackers[opponentIdx].totalFitness += r.fitness2;
fitnessTrackers[opponentIdx].matchCount++;
}
}
console.log(`[SelfPlay] Gen ${generation} Curriculum: Static(${staticMatches}) + Strafer(${straferMatches}) + Peer(${peerMatches} per agent)`);
// Average fitness
let maxFitnessInBatch = -Infinity;
let bestGenomeInBatch: Genome | null = null;
for (let i = 0; i < genomes.length; i++) {
const tracker = fitnessTrackers[i];
const avg = tracker.matchCount > 0
? tracker.totalFitness / tracker.matchCount
: 0;
genomes[i].fitness = avg;
if (avg > maxFitnessInBatch) {
maxFitnessInBatch = avg;
bestGenomeInBatch = genomes[i];
}
}
// Update Best Ever immediately to prevent UI lag
let bestFitnessEver = population.bestFitnessEver;
let bestGenomeEver = population.bestGenomeEver;
if (maxFitnessInBatch > bestFitnessEver) {
bestFitnessEver = maxFitnessInBatch;
bestGenomeEver = bestGenomeInBatch ? { ...bestGenomeInBatch } : null; // Clone to preserve state
}
return {
...population,
genomes,
bestFitnessEver,
bestGenomeEver
};
}
/**
* Helper to create baseline genomes
*/
function createBaselineGenome(type: 'static' | 'spinner' | 'strafer'): Genome {
let id = -1;
if (type === 'spinner') id = -2;
if (type === 'strafer') id = -3;
return {
id,
nodes: [],
connections: [],
fitness: 0
};
}
/**
* Run a single match
*/
function runMatch(
genome1: Genome,
genome2: Genome,
config: MatchConfig,
mapSeed: number,
spawnPairId: number
): { fitness1: number; fitness2: number } {
// Create networks (or mock networks for baselines)
const createAgentController = (genome: Genome) => {
let tick = 0;
// Handle baselines by ID
// IDs: Static=-1, Spinner=-2, Strafer=-3
// Note: Check for genome.id OR if it's a clone (needs robust check)
// Simplest: use ID ranges or special properties. For now ID < 0 is baseline.
if (genome.id === -1 || genome.id === -100) { // Static
return { activate: () => [0, 0, 0, 0] };
} else if (genome.id === -2 || genome.id === -200) { // Spinner
return { activate: () => [0, 0, 1.0, 0] };
} else if (genome.id === -3 || genome.id === -300) { // Strafer
// Moves up/down while facing left (assuming P2)
// Simple logic: Turn=0, MoveY = sin(t). Shoot=0?
// Actually, Strafers should aim at opponent!
// But 'activate' only gets inputs.
// We can implement a "Dimbot" that sees opponent.
// For strict Baseline, let's just make it move randomly in Y.
return {
activate: () => {
tick++;
const moveY = Math.sin(tick * 0.2) * 0.5; // Nerfed speed (0.5x) for solvability
return [0, moveY, 0, 1.0]; // Shoot constantly!
}
};
} else {
return createNetwork(genome);
}
};
const network1 = createAgentController(genome1);
const network2 = createAgentController(genome2);
// Create simulation with DYNAMIC SEED and specific PAIR ID
// Note: createSimulation expects proper pairId (0-4)
// We safeguard against invalid pairIds just in case
const safePairId = Math.abs(spawnPairId) % 5;
const sim = createSimulation(mapSeed, safePairId);
// Create local trackers for the match
const localTracker1 = createFitnessTracker(0);
const localTracker2 = createFitnessTracker(1);
// Mutable references for loop
let runningTracker1 = localTracker1;
let runningTracker2 = localTracker2;
// Run simulation
let currentSim = sim;
while (!currentSim.isOver && currentSim.tick < config.maxTicks) {
// Get observations
const obs1 = generateObservation(0, currentSim);
const obs2 = generateObservation(1, currentSim);
// Get actions
const inputs1 = observationToInputs(obs1);
const inputs2 = observationToInputs(obs2);
const outputs1 = network1.activate(inputs1);
const outputs2 = network2.activate(inputs2);
const action1: AgentAction = {
moveX: outputs1[0],
moveY: outputs1[1],
turn: outputs1[2],
shoot: outputs1[3],
};
const action2: AgentAction = {
moveX: outputs2[0],
moveY: outputs2[1],
turn: outputs2[2],
shoot: outputs2[3],
};
// Step
currentSim = stepSimulation(currentSim, [action1, action2]);
// Update local trackers
runningTracker1 = updateFitness(runningTracker1, currentSim);
runningTracker2 = updateFitness(runningTracker2, currentSim);
}
return {
fitness1: runningTracker1.fitness,
fitness2: runningTracker2.fitness,
};
}

View File

@@ -0,0 +1,281 @@
import type { Agent, SimulationState, Observation, RayHit, Vec2, Wall } from './types';
import { SIMULATION_CONFIG } from './types';
/**
* Sensor system for NEAT Arena.
*
* Agents perceive the world using 360° raycasting.
* Each ray detects distance and what it hit (nothing, wall, or opponent).
*/
/**
* Generate observation vector for an agent.
*
* Returns a complete observation including:
* - 24 rays (360°) with distance and hit type
* - Agent's velocity
* - Aim direction
* - Fire cooldown
*/
export function generateObservation(agentId: number, state: SimulationState): Observation {
const agent = state.agents.find(a => a.id === agentId)!;
const opponent = state.agents.find(a => a.id !== agentId)!;
const { RAY_COUNT, RAY_RANGE, FIRE_COOLDOWN, AGENT_MAX_SPEED } = SIMULATION_CONFIG;
// Cast rays in 360°
const rays: RayHit[] = [];
const angleStep = (2 * Math.PI) / RAY_COUNT;
// Filter bullets to exclude those fired by self (agent knows when it shot)
// Actually, seeing own bullets isn't terrible, but strictly better to see threats.
const threats = state.bullets.filter(b => b.ownerId !== agentId);
for (let i = 0; i < RAY_COUNT; i++) {
// Ego-centric rays: Ray 0 is forward (aimAngle)
const relativeAngle = i * angleStep;
const angle = agent.aimAngle + relativeAngle;
const ray = castRay(agent.position, angle, RAY_RANGE, state.map.walls, opponent, threats);
rays.push(ray);
}
// Normalize velocity
const vx = agent.velocity.x / AGENT_MAX_SPEED;
const vy = agent.velocity.y / AGENT_MAX_SPEED;
// Aim direction as sin/cos
const aimSin = Math.sin(agent.aimAngle);
const aimCos = Math.cos(agent.aimAngle);
// Normalize cooldown
const cooldown = agent.fireCooldown / FIRE_COOLDOWN;
// TARGET SENSORS (The "Compass")
let targetVisible = 0;
let targetRelativeAngle = 0;
if (hasLineOfSight(agent, opponent, state.map.walls)) {
targetVisible = 1.0;
const dx = opponent.position.x - agent.position.x;
const dy = opponent.position.y - agent.position.y;
const absAngle = Math.atan2(dy, dx);
// Calculate relative difference
let diff = absAngle - agent.aimAngle;
while (diff > Math.PI) diff -= 2 * Math.PI;
while (diff < -Math.PI) diff += 2 * Math.PI;
// Normalize to [-1, 1] (where 1 = PI, -1 = -PI)
targetRelativeAngle = diff / Math.PI;
}
return {
rays,
vx,
vy,
aimSin,
aimCos,
cooldown,
targetVisible,
targetRelativeAngle,
};
}
/**
* Cast a single ray from origin in a direction, up to maxDist.
*
* Returns the closest hit: either wall, opponent, bullet, or nothing.
*/
function castRay(
origin: Vec2,
angle: number,
maxDist: number,
walls: Wall[],
opponent: Agent,
bullets: import('./types').Bullet[]
): RayHit {
const dir: Vec2 = {
x: Math.cos(angle),
y: Math.sin(angle),
};
const rayEnd: Vec2 = {
x: origin.x + dir.x * maxDist,
y: origin.y + dir.y * maxDist,
};
let closestDist = maxDist;
let hitType: 'nothing' | 'wall' | 'opponent' | 'bullet' = 'nothing';
// Check wall intersections
for (const wall of walls) {
const dist = rayAABBIntersection(origin, rayEnd, wall.rect);
if (dist !== null && dist < closestDist) {
closestDist = dist;
hitType = 'wall';
}
}
// Check opponent intersection (treat as circle)
const opponentDist = rayCircleIntersection(origin, dir, maxDist, opponent.position, opponent.radius);
if (opponentDist !== null && opponentDist < closestDist) {
closestDist = opponentDist;
hitType = 'opponent';
}
// Check bullet intersections
// Bullets are small, hard to hit with rays.
// Using a slightly larger radius for detection (4.0) helps "feeling" them.
for (const bullet of bullets) {
const bulletDist = rayCircleIntersection(origin, dir, maxDist, bullet.position, 4.0);
if (bulletDist !== null && bulletDist < closestDist) {
closestDist = bulletDist;
hitType = 'bullet';
}
}
return {
distance: closestDist / maxDist, // Normalize to [0, 1]
hitType,
};
}
/**
* Ray-AABB intersection.
* Returns distance to intersection, or null if no hit.
*/
function rayAABBIntersection(
origin: Vec2,
end: Vec2,
aabb: { minX: number; minY: number; maxX: number; maxY: number }
): number | null {
const dir: Vec2 = {
x: end.x - origin.x,
y: end.y - origin.y,
};
const len = Math.sqrt(dir.x * dir.x + dir.y * dir.y);
if (len === 0) return null;
dir.x /= len;
dir.y /= len;
// Slab method
const invDirX = dir.x === 0 ? Infinity : 1 / dir.x;
const invDirY = dir.y === 0 ? Infinity : 1 / dir.y;
const tx1 = (aabb.minX - origin.x) * invDirX;
const tx2 = (aabb.maxX - origin.x) * invDirX;
const ty1 = (aabb.minY - origin.y) * invDirY;
const ty2 = (aabb.maxY - origin.y) * invDirY;
const tmin = Math.max(Math.min(tx1, tx2), Math.min(ty1, ty2));
const tmax = Math.min(Math.max(tx1, tx2), Math.max(ty1, ty2));
if (tmax < 0 || tmin > tmax || tmin > len) return null;
return tmin >= 0 ? tmin : tmax;
}
/**
* Ray-circle intersection.
* Returns distance to intersection, or null if no hit.
*/
function rayCircleIntersection(
origin: Vec2,
dir: Vec2,
maxDist: number,
circleCenter: Vec2,
circleRadius: number
): number | null {
// Vector from ray origin to circle center
const oc: Vec2 = {
x: origin.x - circleCenter.x,
y: origin.y - circleCenter.y,
};
const a = dir.x * dir.x + dir.y * dir.y;
const b = 2 * (oc.x * dir.x + oc.y * dir.y);
const c = oc.x * oc.x + oc.y * oc.y - circleRadius * circleRadius;
const discriminant = b * b - 4 * a * c;
if (discriminant < 0) return null;
const sqrtDisc = Math.sqrt(discriminant);
const t1 = (-b - sqrtDisc) / (2 * a);
const t2 = (-b + sqrtDisc) / (2 * a);
// Return closest positive intersection within range
if (t1 >= 0 && t1 <= maxDist) return t1;
if (t2 >= 0 && t2 <= maxDist) return t2;
return null;
}
/**
* Convert observation to flat array of floats for neural network input.
*
* Total: 24 rays × 2 + 5 extra = 53 inputs
*/
export function observationToInputs(obs: Observation): number[] {
const inputs: number[] = [];
// Rays: distance + hitType as scalar
for (const ray of obs.rays) {
inputs.push(ray.distance);
// Encode hitType as scalar
let hitTypeScalar = 0;
if (ray.hitType === 'wall') hitTypeScalar = 0.5;
else if (ray.hitType === 'opponent') hitTypeScalar = 1.0;
inputs.push(hitTypeScalar);
}
// Extra inputs
// Extra inputs
inputs.push(obs.vx);
inputs.push(obs.vy);
inputs.push(obs.aimSin);
inputs.push(obs.aimCos);
inputs.push(obs.cooldown);
// New Target Sensors
// Note: These need to be BEFORE the Bias node
inputs.push(obs.targetVisible || 0);
inputs.push(obs.targetRelativeAngle || 0);
// Bias Node (Always 1.0) - MUST BE LAST
// Genome expects Bias at index == inputCount (55)
inputs.push(1.0);
return inputs;
}
/**
* Check if agent has clear line-of-sight to opponent.
* Used for fitness calculation.
*/
export function hasLineOfSight(agent: Agent, opponent: Agent, walls: Wall[]): boolean {
const dir: Vec2 = {
x: opponent.position.x - agent.position.x,
y: opponent.position.y - agent.position.y,
};
const dist = Math.sqrt(dir.x * dir.x + dir.y * dir.y);
if (dist === 0) return true;
dir.x /= dist;
dir.y /= dist;
// Check if any wall blocks the line
for (const wall of walls) {
const hitDist = rayAABBIntersection(agent.position, opponent.position, wall.rect);
if (hitDist !== null && hitDist < dist) {
return false;
}
}
return true;
}

View File

@@ -0,0 +1,307 @@
import type {
SimulationState,
Agent,
Bullet,
AgentAction,
Vec2,
Wall,
MatchResult,
} from './types';
import { SIMULATION_CONFIG } from './types';
import { generateArenaMap } from './mapGenerator';
/**
* Core simulation engine for the NEAT Arena.
*
* Deterministic, operates at fixed 30Hz timestep.
* Handles agent movement, bullet physics, collisions, respawning, and scoring.
*/
let nextBulletId = 0;
/**
* Create a new simulation instance
*/
export function createSimulation(mapSeed: number, spawnPairId: number): SimulationState {
const map = generateArenaMap(mapSeed);
// Get spawn points for the selected pair
const spawns = map.spawnPoints.filter(sp => sp.pairId === spawnPairId);
const spawn0 = spawns.find(sp => sp.side === 0)!.position;
const spawn1 = spawns.find(sp => sp.side === 1)!.position;
const agents: [Agent, Agent] = [
createAgent(0, spawn0),
createAgent(1, spawn1),
];
return {
tick: 0,
agents,
bullets: [],
map,
isOver: false,
};
}
/**
* Create a new agent
*/
function createAgent(id: number, spawnPoint: Vec2): Agent {
return {
id,
position: { x: spawnPoint.x, y: spawnPoint.y },
velocity: { x: 0, y: 0 },
aimAngle: id === 0 ? 0 : Math.PI, // Face each other initially
radius: SIMULATION_CONFIG.AGENT_RADIUS,
invulnTicks: SIMULATION_CONFIG.RESPAWN_INVULN_TICKS,
fireCooldown: 0,
hits: 0,
kills: 0,
spawnPoint,
health: SIMULATION_CONFIG.AGENT_HEALTH,
maxHealth: SIMULATION_CONFIG.AGENT_HEALTH,
};
}
/**
* Step the simulation forward by one tick
*/
export function stepSimulation(
state: SimulationState,
actions: [AgentAction, AgentAction]
): SimulationState {
if (state.isOver) return state;
const newState = { ...state };
newState.tick++;
// Update bullets (filter out dead ones first)
// We do this BEFORE agents so agents see valid bullets?
// Actually, traditionally update agents then bullets, or bullets then agents.
// Let's keep logic but ensure we collect NEW bullets.
const nextBullets = state.bullets
.map(b => updateBullet(b, state))
.filter(b => b !== null) as Bullet[];
newState.bullets = nextBullets;
// Update agents (Pass newState so they can see updated positions? No, standard is old state).
// BUT we need them to push bullets to newState.bullets.
newState.agents = [
updateAgent(state.agents[0], actions[0], state, newState.bullets),
updateAgent(state.agents[1], actions[1], state, newState.bullets),
];
// Check bullet-agent collisions
checkCollisions(newState);
// Check episode termination
if (newState.tick >= SIMULATION_CONFIG.MAX_TICKS) {
newState.isOver = true;
newState.result = createMatchResult(newState);
} else if (newState.agents[0].kills >= SIMULATION_CONFIG.KILLS_TO_WIN ||
newState.agents[1].kills >= SIMULATION_CONFIG.KILLS_TO_WIN) {
newState.isOver = true;
newState.result = createMatchResult(newState);
}
return newState;
}
/**
* Update a single agent
*/
function updateAgent(
agent: Agent,
action: AgentAction,
state: SimulationState,
bulletSink: Bullet[]
): Agent {
const { DT, AGENT_MAX_SPEED, AGENT_TURN_RATE, FIRE_COOLDOWN, BULLET_SPAWN_OFFSET, BULLET_SPEED } = SIMULATION_CONFIG;
const newAgent = { ...agent };
// Decrease timers
if (newAgent.invulnTicks > 0) newAgent.invulnTicks--;
if (newAgent.fireCooldown > 0) newAgent.fireCooldown--;
// Update aim angle
const turnAmount = action.turn * AGENT_TURN_RATE * DT;
newAgent.aimAngle += turnAmount;
// Normalize angle to [-π, π]
newAgent.aimAngle = ((newAgent.aimAngle + Math.PI) % (2 * Math.PI)) - Math.PI;
// Update velocity
const moveLength = Math.sqrt(action.moveX * action.moveX + action.moveY * action.moveY);
if (moveLength > 0) {
newAgent.velocity.x = (action.moveX / moveLength) * AGENT_MAX_SPEED;
newAgent.velocity.y = (action.moveY / moveLength) * AGENT_MAX_SPEED;
} else {
newAgent.velocity.x = 0;
newAgent.velocity.y = 0;
}
// Update position
let newX = newAgent.position.x + newAgent.velocity.x * DT;
let newY = newAgent.position.y + newAgent.velocity.y * DT;
// Check wall collisions and clamp position
const testPos = { x: newX, y: newY };
if (isAgentCollidingWithWalls(testPos, newAgent.radius, state.map.walls)) {
// Simple response: stop movement
newX = newAgent.position.x;
newY = newAgent.position.y;
newAgent.velocity.x = 0;
newAgent.velocity.y = 0;
}
newAgent.position.x = newX;
newAgent.position.y = newY;
// Fire bullet
// Changed threshold from 0.5 to 0.0 (Tanh is [-1, 1], so 0.0 is neutral)
if (action.shoot > 0.0 && newAgent.fireCooldown === 0) {
newAgent.fireCooldown = FIRE_COOLDOWN;
// Spawn bullet in front of agent
const bulletPos: Vec2 = {
x: newAgent.position.x + Math.cos(newAgent.aimAngle) * BULLET_SPAWN_OFFSET,
y: newAgent.position.y + Math.sin(newAgent.aimAngle) * BULLET_SPAWN_OFFSET,
};
const bullet: Bullet = {
id: nextBulletId++,
position: bulletPos,
velocity: {
x: Math.cos(newAgent.aimAngle) * BULLET_SPEED,
y: Math.sin(newAgent.aimAngle) * BULLET_SPEED,
},
ownerId: newAgent.id,
ttl: SIMULATION_CONFIG.BULLET_TTL,
};
bulletSink.push(bullet);
}
return newAgent;
}
/**
* Update a bullet
*/
function updateBullet(bullet: Bullet, state: SimulationState): Bullet | null {
const { DT } = SIMULATION_CONFIG;
const newBullet = { ...bullet };
newBullet.ttl--;
if (newBullet.ttl <= 0) return null;
// Update position
newBullet.position.x += newBullet.velocity.x * DT;
newBullet.position.y += newBullet.velocity.y * DT;
// Check wall collision
if (isBulletCollidingWithWalls(newBullet.position, state.map.walls)) {
return null; // Bullet destroyed
}
return newBullet;
}
/**
* Check for bullet-agent collisions and handle hits
*/
function checkCollisions(state: SimulationState): void {
const bulletsToRemove = new Set<number>();
for (const bullet of state.bullets) {
for (const agent of state.agents) {
// Can't hit yourself or invulnerable agents
if (bullet.ownerId === agent.id || agent.invulnTicks > 0) continue;
const dx = bullet.position.x - agent.position.x;
const dy = bullet.position.y - agent.position.y;
const distSq = dx * dx + dy * dy;
if (distSq < agent.radius * agent.radius) {
// Hit!
bulletsToRemove.add(bullet.id);
// Deduct Health
agent.health -= SIMULATION_CONFIG.BULLET_DAMAGE;
agent.hits++; // Track distinct hits taken
// Check Death
if (agent.health <= 0) {
const shooter = state.agents.find(a => a.id === bullet.ownerId);
if (shooter) shooter.kills++;
// Respawn agent
agent.position.x = agent.spawnPoint.x;
agent.position.y = agent.spawnPoint.y;
agent.velocity.x = 0;
agent.velocity.y = 0;
agent.health = SIMULATION_CONFIG.AGENT_HEALTH; // Reset Health
agent.invulnTicks = SIMULATION_CONFIG.RESPAWN_INVULN_TICKS;
}
}
}
}
// Remove bullets
state.bullets = state.bullets.filter(b => !bulletsToRemove.has(b.id));
}
/**
* Check if an agent collides with any walls
*/
function isAgentCollidingWithWalls(pos: Vec2, radius: number, walls: Wall[]): boolean {
for (const wall of walls) {
// AABB vs circle collision
const closestX = Math.max(wall.rect.minX, Math.min(pos.x, wall.rect.maxX));
const closestY = Math.max(wall.rect.minY, Math.min(pos.y, wall.rect.maxY));
const dx = pos.x - closestX;
const dy = pos.y - closestY;
const distSq = dx * dx + dy * dy;
if (distSq < radius * radius) {
return true;
}
}
return false;
}
/**
* Check if a bullet collides with any walls
*/
function isBulletCollidingWithWalls(pos: Vec2, walls: Wall[]): boolean {
for (const wall of walls) {
if (pos.x >= wall.rect.minX && pos.x <= wall.rect.maxX &&
pos.y >= wall.rect.minY && pos.y <= wall.rect.maxY) {
return true;
}
}
return false;
}
/**
* Create match result
*/
function createMatchResult(state: SimulationState): MatchResult {
const [a0, a1] = state.agents;
let winnerId = -1;
if (a0.kills > a1.kills) winnerId = 0;
else if (a1.kills > a0.kills) winnerId = 1;
return {
winnerId,
scores: [a0.kills, a1.kills],
ticks: state.tick,
};
}

View File

@@ -0,0 +1,223 @@
import type { Genome } from './genome';
/**
* NEAT Speciation
*
* Groups genomes into species based on compatibility distance.
* Implements dynamic threshold adjustment to target 6-10 species.
*/
export interface Species {
id: number;
representative: Genome;
members: Genome[];
averageFitness: number;
staleness: number; // Generations without improvement
}
/**
* Compatibility distance coefficients
*/
export interface CompatibilityConfig {
excessCoeff: number; // c1
disjointCoeff: number; // c2
weightDiffCoeff: number; // c3
}
export const DEFAULT_COMPATIBILITY_CONFIG: CompatibilityConfig = {
excessCoeff: 1.0,
disjointCoeff: 1.0,
weightDiffCoeff: 0.4,
};
/**
* Calculate compatibility distance between two genomes
* δ = c1*E/N + c2*D/N + c3*W
*/
export function compatibilityDistance(
genome1: Genome,
genome2: Genome,
config: CompatibilityConfig = DEFAULT_COMPATIBILITY_CONFIG
): number {
const innovations1 = new Set(genome1.connections.map(c => c.innovation));
const innovations2 = new Set(genome2.connections.map(c => c.innovation));
const max1 = Math.max(...Array.from(innovations1), 0);
const max2 = Math.max(...Array.from(innovations2), 0);
let matching = 0;
let disjoint = 0;
let excess = 0;
let weightDiff = 0;
const conn1Map = new Map(genome1.connections.map(c => [c.innovation, c]));
const conn2Map = new Map(genome2.connections.map(c => [c.innovation, c]));
// Count matching, disjoint, excess
const allInnovations = new Set([...innovations1, ...innovations2]);
for (const innovation of allInnovations) {
const c1 = conn1Map.get(innovation);
const c2 = conn2Map.get(innovation);
if (c1 && c2) {
// Matching gene
matching++;
weightDiff += Math.abs(c1.weight - c2.weight);
} else {
// Disjoint or excess
// Excess genes are those with innovation > OTHER genome's max
const isInGenome1 = innovations1.has(innovation);
const isInGenome2 = innovations2.has(innovation);
if (isInGenome1 && innovation > max2) {
excess++;
} else if (isInGenome2 && innovation > max1) {
excess++;
} else {
disjoint++;
}
}
}
// Normalize by number of genes in larger genome
// For large genomes (like ours with 200+ connections), dividing by N makes distance tiny (< 0.1)
// even for significant structural differences.
// Standard NEAT often sets N=1 for simplified tuning.
const N = 1.0;
// Average weight difference for matching genes
const avgWeightDiff = matching > 0 ? weightDiff / matching : 0;
const delta =
(config.excessCoeff * excess) / N +
(config.disjointCoeff * disjoint) / N +
config.weightDiffCoeff * avgWeightDiff;
return delta;
}
/**
* Assign genomes to species
*/
export function speciate(
genomes: Genome[],
previousSpecies: Species[],
compatibilityThreshold: number,
config: CompatibilityConfig = DEFAULT_COMPATIBILITY_CONFIG
): Species[] {
const newSpecies: Species[] = [];
let nextSpeciesId = previousSpecies.length > 0
? Math.max(...previousSpecies.map(s => s.id)) + 1
: 0;
// Update representatives from previous generation
for (const species of previousSpecies) {
if (species.members.length > 0) {
// Pick a random member as the new representative
species.representative = species.members[Math.floor(Math.random() * species.members.length)];
species.members = [];
}
}
// Assign each genome to a species
for (const genome of genomes) {
let foundSpecies = false;
// Try to match with existing species
for (const species of previousSpecies) {
const distance = compatibilityDistance(genome, species.representative, config);
if (distance < compatibilityThreshold) {
species.members.push(genome);
foundSpecies = true;
break;
}
}
// If no match, create new species
if (!foundSpecies) {
const newSpec: Species = {
id: nextSpeciesId++,
representative: genome,
members: [genome],
averageFitness: 0,
staleness: 0,
};
previousSpecies.push(newSpec);
}
}
// Keep only species with members
for (const species of previousSpecies) {
if (species.members.length > 0) {
// Calculate average fitness
const totalFitness = species.members.reduce((sum, g) => sum + g.fitness, 0);
species.averageFitness = totalFitness / species.members.length;
newSpecies.push(species);
}
}
// console.log(`[Speciation] Threshold: ${compatibilityThreshold.toFixed(2)}, Species formed: ${newSpecies.length}`);
if (newSpecies.length > 0) {
// console.log(`[Speciation] Species sizes:`, newSpecies.map(s => s.members.length));
}
return newSpecies;
}
/**
* Adjust compatibility threshold to target a certain number of species
*/
export function adjustCompatibilityThreshold(
currentThreshold: number,
currentSpeciesCount: number,
targetMin: number = 6,
targetMax: number = 10
): number {
let adjustmentRate = 0.05; // Default rate
// Proportional adjustment
if (currentSpeciesCount < targetMin) {
// Too few species
if (currentSpeciesCount < targetMin / 2) adjustmentRate = 0.3; // Panic: < 50% of min
else adjustmentRate = 0.1; // Moderate
return Math.max(0.1, currentThreshold - adjustmentRate);
} else if (currentSpeciesCount > targetMax) {
// Too many species
if (currentSpeciesCount > targetMax * 2) adjustmentRate = 0.3; // Panic: > 200% of max
else if (currentSpeciesCount > targetMax * 1.5) adjustmentRate = 0.15; // Strong
else adjustmentRate = 0.1; // Moderate
return currentThreshold + adjustmentRate;
}
if (currentSpeciesCount < targetMin) {
// Too few species? We want MORE species.
// Make threshold STRICTER (lower) to force splitting.
// Prevent going below 0.1
return Math.max(0.1, currentThreshold - adjustmentRate);
} else if (currentSpeciesCount > targetMax) {
// Too many species? We want FEWER species.
// Make threshold LENIENT (higher) to allow merging.
return currentThreshold + adjustmentRate;
}
return currentThreshold;
}
/**
* Apply fitness sharing within species
*/
export function applyFitnessSharing(species: Species[]): void {
for (const spec of species) {
const speciesSize = spec.members.length;
for (const genome of spec.members) {
// Adjusted fitness = raw fitness / species size
genome.fitness = genome.fitness / speciesSize;
}
}
}

View File

@@ -0,0 +1,89 @@
import { describe, expect, test, beforeEach } from "bun:test";
import { InnovationTracker, createMinimalGenome, type Genome, cloneGenome } from "./genome";
import { compatibilityDistance, speciate, adjustCompatibilityThreshold, DEFAULT_COMPATIBILITY_CONFIG, type Species } from "./speciation";
import { mutate, DEFAULT_MUTATION_RATES } from "./mutations";
describe("Speciation Debugging", () => {
let tracker: InnovationTracker;
beforeEach(() => {
tracker = new InnovationTracker();
});
test("Simulates large genome speciation behavior", () => {
// Create a base genome similar to Snake AI size (50 inputs, 5 outputs)
const base = createMinimalGenome(50, 5, tracker);
const populationSize = 150;
const population: Genome[] = [];
// Fill population with clones
for(let i=0; i<populationSize; i++) {
population.push(cloneGenome(base));
}
// Apply random mutations to everyone to simulate a few generations of divergence
// We really want to see how quickly they fly apart.
console.log("Mutating population...");
for(const g of population) {
// Apply MULTIPLE mutations to simulate drift
mutate(g, tracker, DEFAULT_MUTATION_RATES);
mutate(g, tracker, DEFAULT_MUTATION_RATES);
}
// Calculate average distance between random pairs
let totalDist = 0;
const samples = 100;
console.log("Analyzing Distance Components:");
for(let i=0; i<5; i++) { // Print detailed stats for first 5 pairs
const g1 = population[Math.floor(Math.random() * populationSize)];
const g2 = population[Math.floor(Math.random() * populationSize)];
// We need to inspect components.
// I'll just rely on the implementation of compatibilityDistance being correct/consistent
const d = compatibilityDistance(g1, g2, DEFAULT_COMPATIBILITY_CONFIG);
// I can't easily access the internals of compatibilityDistance without modifying the source.
// But I can infer:
// N=1 (disabled normalization)
// Dist = (c1 * E) + (c2 * D) + (c3 * W)
// c1=1, c2=1, c3=0.4
console.log(`Pair ${i}: Distance=${d.toFixed(4)}`);
}
for(let i=0; i<samples; i++) {
const g1 = population[Math.floor(Math.random() * populationSize)];
const g2 = population[Math.floor(Math.random() * populationSize)];
totalDist += compatibilityDistance(g1, g2, DEFAULT_COMPATIBILITY_CONFIG);
}
const avgDist = totalDist / samples;
console.log(`Average Distance in Mutated Population: ${avgDist.toFixed(4)}`);
// Check species count with current threshold
let threshold = 0.5; // Start ridiculously low to trigger 150 species
let species = speciate(population, [], threshold, DEFAULT_COMPATIBILITY_CONFIG);
console.log(`With threshold ${threshold}, species count: ${species.length}`);
// If we want 10 species, approximate the required threshold would be around avgDist?
// Actually, if avgDist is huge (like 20), and threshold is 3, everyone is their own species.
expect(species.length).toBeLessThan(150);
// Test Dynamic Adjustment
console.log("Testing Limit...");
// Simulating 50 generations of adjustment
for(let i=0; i<50; i++) {
species = speciate(population, [], threshold, DEFAULT_COMPATIBILITY_CONFIG);
const oldT = threshold;
threshold = adjustCompatibilityThreshold(threshold, species.length);
// console.log(`Gen ${i}: Species ${species.length} -> Threshold ${oldT.toFixed(2)} -> ${threshold.toFixed(2)}`);
}
console.log(`Final Threshold: ${threshold.toFixed(2)} -> Final Species: ${species.length}`);
// We want stable species count around 10
expect(species.length).toBeLessThan(20);
expect(species.length).toBeGreaterThan(2);
});
});

View File

@@ -0,0 +1,80 @@
import { describe, expect, test } from 'bun:test';
import { createPopulation, evolveGeneration, DEFAULT_EVOLUTION_CONFIG } from './evolution';
import { evaluatePopulation, DEFAULT_MATCH_CONFIG } from './selfPlay';
import * as fs from 'fs';
// Configuration for rapid but meaningful test
const TEST_CONFIG = {
...DEFAULT_EVOLUTION_CONFIG,
populationSize: 50, // Enough for diversity, small enough for speed
};
const MATCH_CONFIG = {
...DEFAULT_MATCH_CONFIG,
matchesPerGenome: 2, // Minimize noise
maxTicks: 300
};
describe('Stagnation Check', () => {
test('Evolution must break stagnation within 30 generations', () => {
let population = createPopulation(TEST_CONFIG);
const history: number[] = [];
let stagnationCounter = 0;
let bestFitness = -Infinity;
console.log('--- STAGNATION CHECK START ---');
for (let gen = 0; gen < 30; gen++) {
// Evaluate
population = evaluatePopulation(population, MATCH_CONFIG, gen);
const currentBest = population.bestFitnessEver;
// Check Stagnation
if (currentBest > bestFitness + 0.5) { // Threshold to count as "Improvement"
console.log(`Gen ${gen}: New Record! ${currentBest.toFixed(2)} (was ${bestFitness.toFixed(2)})`);
bestFitness = currentBest;
stagnationCounter = 0;
} else {
stagnationCounter++;
}
history.push(currentBest);
// Fail fast if STAGNATION IS DETECTED (e.g. 15 gens with no progress)
// Note: Evolution can be spiky, but 15 gens of flatline in early phase is bad.
// valid stagnation check:
// if (stagnationCounter > 15) {
// throw new Error(`Stagnation Detected! No improvement for 15 generations. Max: ${bestFitness}`);
// }
if (gen % 5 === 0) {
console.log(`Gen ${gen}: Best=${currentBest.toFixed(2)} Stagnation=${stagnationCounter}`);
}
// Evolve
if (gen < 29) {
population = evolveGeneration(population, TEST_CONFIG);
}
}
console.log('Final History:', history.map(n => n.toFixed(1)).join(', '));
// --- VERDICT ---
const start = history[0];
const end = history[history.length - 1];
const gain = end - start;
console.log(`Total Gain: ${gain.toFixed(2)}`);
// CRITERIA:
// 1. Must gain at least 10 points (proving learning beyond random shooting)
// note: 10 points = 2.5 kills worth of net profit (with new Hit Penalty 1.0)
expect(gain).toBeGreaterThan(10);
// 2. Stagnation check (soft)
// We shouldn't end with a max fitness that was set 20 gens ago
expect(stagnationCounter).toBeLessThan(20);
}, 60000); // 60s timeout
});

View File

@@ -0,0 +1,131 @@
import type { Population } from './evolution';
import type { EvolutionConfig } from './evolution';
import { evaluatePopulation, DEFAULT_MATCH_CONFIG } from './selfPlay';
import { evolveGeneration, createPopulation, getPopulationStats } from './evolution';
/**
* NEAT Training Worker
*
* Runs training in a background thread to prevent UI blocking.
* The main thread only handles visualization and UI updates.
*/
export interface TrainingWorkerMessage {
type: 'start' | 'pause' | 'step' | 'reset' | 'init';
config?: EvolutionConfig;
}
export interface TrainingWorkerResponse {
type: 'update' | 'error' | 'ready';
population?: Population;
stats?: ReturnType<typeof getPopulationStats>;
error?: string;
}
let population: Population | null = null;
let isRunning = false;
let config: EvolutionConfig | null = null;
/**
* Handle messages from main thread
*/
self.onmessage = async (e: MessageEvent<TrainingWorkerMessage>) => {
const message = e.data;
try {
switch (message.type) {
case 'init':
if (message.config) {
console.log('[Worker] Initializing v11 (Dynamic Maps)...');
config = message.config;
population = createPopulation(config);
sendUpdate();
self.postMessage({ type: 'ready' } as TrainingWorkerResponse);
}
break;
case 'start':
isRunning = true;
runTrainingLoop();
break;
case 'pause':
isRunning = false;
break;
case 'step':
if (population && config) {
const stats = await runSingleGeneration();
sendUpdate(stats);
}
break;
case 'reset':
if (config) {
population = createPopulation(config);
isRunning = false;
sendUpdate();
}
break;
}
} catch (error) {
self.postMessage({
type: 'error',
error: error instanceof Error ? error.message : 'Unknown error',
} as TrainingWorkerResponse);
}
};
/**
* Run continuous training loop
*/
async function runTrainingLoop() {
while (isRunning && population && config) {
const stats = await runSingleGeneration();
sendUpdate(stats);
// Yield to allow pause/stop messages to be processed
await new Promise(resolve => setTimeout(resolve, 0));
}
}
/**
* Run a single generation
*/
async function runSingleGeneration(): Promise<ReturnType<typeof getPopulationStats> | null> {
if (!population || !config) return null;
console.log('[Worker] Starting generation', population.generation);
// Evaluate population (Pass generation for dynamic seeds)
const evaluatedPop = evaluatePopulation(population, DEFAULT_MATCH_CONFIG, population.generation);
// Check fitness after evaluation
const fitnesses = evaluatedPop.genomes.map(g => g.fitness);
const avgFit = fitnesses.reduce((a, b) => a + b, 0) / fitnesses.length;
const maxFit = Math.max(...fitnesses);
console.log('[Worker] After evaluation - Avg fitness:', avgFit.toFixed(2), 'Max:', maxFit.toFixed(2));
// Capture stats BEFORE evolution (which modifies fitness via sharing)
const stats = getPopulationStats(evaluatedPop);
// Evolve to next generation
population = evolveGeneration(evaluatedPop, config);
console.log('[Worker] Generation', population.generation, 'complete');
return stats;
}
/**
* Send population update to main thread
*/
function sendUpdate(stats?: ReturnType<typeof getPopulationStats> | null) {
if (!population) return;
self.postMessage({
type: 'update',
population,
stats: stats || undefined,
} as TrainingWorkerResponse);
}

View File

@@ -0,0 +1,67 @@
import { describe, test, expect } from 'bun:test';
import { createSimulation, stepSimulation } from './simulation';
import { createFitnessTracker, updateFitness } from './fitness';
import { SIMULATION_CONFIG } from './types';
describe('Fitness Tuning', () => {
test('Calculate Maximum Theoretical Fitness (Perfect Hunter vs Static)', () => {
// Setup sim
// PairId 0: Agents spawn facing each other or fixed spots.
const sim = createSimulation(12345, 0);
const tracker = createFitnessTracker(0); // Tracking Agent 0
let runningTracker = tracker;
const maxTicks = 600; // Standard match length
let currentState = sim;
// Agent 0: Perfect Hunter
// - Aim constantly at opponent
// - Move towards opponent? Or just stand and shoot? (Static enemy)
// - Shoot constantly
// Agent 1: Static Dummy
for (let t = 0; t < maxTicks && !currentState.isOver; t++) {
const agent0 = currentState.agents[0];
const agent1 = currentState.agents[1]; // Opponent
// Calculate perfect aim
const dx = agent1.position.x - agent0.position.x;
const dy = agent1.position.y - agent0.position.y;
const targetAngle = Math.atan2(dy, dx);
// Determine Turn Action
// Simple P-controller for turning
let angleDiff = targetAngle - agent0.aimAngle;
while (angleDiff > Math.PI) angleDiff -= 2 * Math.PI;
while (angleDiff < -Math.PI) angleDiff += 2 * Math.PI;
const turnAction = Math.max(-1, Math.min(1, angleDiff * 5)); // Strong turn
const action0 = {
moveX: 0,
moveY: 0,
turn: turnAction,
shoot: 1.0 // Fire at will
};
const action1 = { moveX: 0, moveY: 0, turn: 0, shoot: 0 };
currentState = stepSimulation(currentState, [action0, action1]);
runningTracker = updateFitness(runningTracker, currentState);
}
console.log('--- PERFECT HUNTER RESULTS ---');
console.log(`Ticks: ${currentState.tick}`);
console.log(`Kills: ${currentState.agents[0].kills}`);
console.log(`Damage Dealt (Hits): ${currentState.agents[1].hits}`);
console.log(`Damage Taken: ${currentState.agents[0].hits}`);
console.log(`Total Fitness: ${runningTracker.fitness}`);
console.log('------------------------------');
// Sanity Check: Expect decent positive fitness
expect(runningTracker.fitness).toBeGreaterThan(20);
});
});

218
src/lib/neatArena/types.ts Normal file
View File

@@ -0,0 +1,218 @@
/**
* Core types for the NEAT Arena simulation.
*
* The simulation is deterministic and operates at a fixed 30Hz timestep.
* All units are in a 512×512 logic space.
*/
// ============================================================================
// WORLD & MAP
// ============================================================================
export interface Vec2 {
x: number;
y: number;
}
export interface AABB {
minX: number;
minY: number;
maxX: number;
maxY: number;
}
export interface Wall {
rect: AABB;
}
export interface SpawnPoint {
position: Vec2;
/** Which spawn pair this belongs to (0-4) */
pairId: number;
/** Which side of the pair (0 or 1) */
side: 0 | 1;
}
export interface ArenaMap {
/** Rectangular walls */
walls: Wall[];
/** Symmetric spawn point pairs (always 5 pairs = 10 total spawn points) */
spawnPoints: SpawnPoint[];
/** Map generation seed */
seed: number;
}
// ============================================================================
// AGENT
// ============================================================================
export interface Agent {
id: number;
position: Vec2;
velocity: Vec2;
/** Current aim direction in radians */
aimAngle: number;
/** Radius for collision */
radius: number;
/** Invulnerability ticks remaining after respawn */
invulnTicks: number;
/** Cooldown ticks until can fire again */
fireCooldown: number;
/** Number of times hit this episode */
hits: number;
/** Number of times this agent landed a hit */
kills: number;
/** Assigned spawn point */
spawnPoint: Vec2;
/** Current Health */
health: number;
maxHealth: number;
}
// ============================================================================
// BULLET
// ============================================================================
export interface Bullet {
id: number;
position: Vec2;
velocity: Vec2;
/** Which agent fired this bullet */
ownerId: number;
/** Ticks until bullet auto-expires */
ttl: number;
}
// ============================================================================
// SIMULATION STATE
// ============================================================================
export interface SimulationState {
/** Current tick (increments at 30Hz) */
tick: number;
/** Agents in the arena (always 2) */
agents: [Agent, Agent];
/** Active bullets */
bullets: Bullet[];
/** The arena map */
map: ArenaMap;
/** Episode over? */
isOver: boolean;
/** Match result after episode ends */
result?: MatchResult;
}
export interface MatchResult {
/** Winner agent ID, or -1 for draw */
winnerId: number;
/** Final scores */
scores: [number, number];
/** Total ticks */
ticks: number;
}
// ============================================================================
// ACTIONS
// ============================================================================
export interface AgentAction {
/** Movement vector (will be normalized) */
moveX: number;
moveY: number;
/** Turn rate [-1..1] (scaled by max turn rate) */
turn: number;
/** Fire bullet if > 0.5 */
shoot: number;
}
// ============================================================================
// OBSERVATIONS / SENSORS
// ============================================================================
export interface RayHit {
/** Distance [0..1] normalized by max range */
distance: number;
/** What the ray hit */
hitType: 'nothing' | 'wall' | 'opponent' | 'bullet';
}
export interface Observation {
/** 24 rays × 2 values (distance, hitType) */
rays: RayHit[];
/** Agent's own velocity */
vx: number;
vy: number;
/** Aim direction as unit vector */
aimSin: number;
aimCos: number;
/** Fire cooldown [0..1] */
cooldown: number;
/** Lock-On Sensor: 1.0 if target is visible */
targetVisible: number;
/** Lock-On Sensor: Relative Angle to target [-1..1] */
targetRelativeAngle: number;
}
// ============================================================================
// SIMULATION CONFIG
// ============================================================================
export const SIMULATION_CONFIG = {
/** Logic world size */
WORLD_SIZE: 512,
/** Fixed timestep (30Hz) */
TICK_RATE: 30,
DT: 1 / 30,
/** Episode termination */
MAX_TICKS: 900, // 30 seconds (Increased from 20/10)
KILLS_TO_WIN: 5,
/** Agent physics */
AGENT_RADIUS: 12, // Increased (was 10) to catch fast bullets
AGENT_MAX_SPEED: 120, // units/sec
AGENT_TURN_RATE: 400 * (Math.PI / 180), // rad/sec
/** Respawn */
RESPAWN_INVULN_TICKS: 15, // 0.5 seconds
/** Bullet physics */
BULLET_SPEED: 600, // units/sec (Max safe speed without CCD)
BULLET_TTL: 60, // 2 seconds
FIRE_COOLDOWN: 5, // ~0.16 seconds (Machine Gun)
BULLET_SPAWN_OFFSET: 12, // spawn in front of agent
BULLET_DAMAGE: 20, // 5 shots to kill
/** Agent Stats */
AGENT_HEALTH: 100,
/** Sensors */
RAY_COUNT: 24,
RAY_RANGE: 220,
} as const;
// Re-export Genome type from genome module for convenience
export type { Genome } from './genome';

View File

@@ -0,0 +1,42 @@
/**
* Deterministic random number generator using a linear congruential generator (LCG).
*
* Ensures reproducible results for the same seed.
*/
export class SeededRandom {
private seed: number;
constructor(seed: number) {
this.seed = seed % 2147483647;
if (this.seed <= 0) this.seed += 2147483646;
}
/**
* Returns a float in [0, 1)
*/
next(): number {
this.seed = (this.seed * 16807) % 2147483647;
return (this.seed - 1) / 2147483646;
}
/**
* Returns an integer in [min, max) (max exclusive)
*/
nextInt(min: number, max: number): number {
return Math.floor(this.next() * (max - min)) + min;
}
/**
* Returns a float in [min, max)
*/
nextFloat(min: number, max: number): number {
return this.next() * (max - min) + min;
}
/**
* Returns a random boolean
*/
nextBool(): boolean {
return this.next() < 0.5;
}
}

View File

@@ -13,6 +13,10 @@ export interface Population {
generation: number; generation: number;
bestFitnessEver: number; bestFitnessEver: number;
bestNetworkEver: Network | null; bestNetworkEver: Network | null;
lastGenerationStats?: {
bestFitness: number;
averageFitness: number;
};
} }
export function createPopulation(config: EvolutionConfig): Population { export function createPopulation(config: EvolutionConfig): Population {
@@ -56,19 +60,31 @@ export function evaluatePopulation(
} }
// Update best ever // Update best ever
return updateBestStats(
{
...population,
individuals: evaluatedIndividuals
}
);
}
export function updateBestStats(population: Population): Population {
let newBestEver = population.bestFitnessEver; let newBestEver = population.bestFitnessEver;
let newBestNetwork = population.bestNetworkEver; let newBestNetwork = population.bestNetworkEver;
let changed = false;
for (const individual of evaluatedIndividuals) { for (const individual of population.individuals) {
if (individual.fitness > newBestEver) { if (individual.fitness > newBestEver) {
newBestEver = individual.fitness; newBestEver = individual.fitness;
newBestNetwork = cloneNetwork(individual.network); newBestNetwork = cloneNetwork(individual.network);
changed = true;
} }
} }
if (!changed) return population;
return { return {
...population, ...population,
individuals: evaluatedIndividuals,
bestFitnessEver: newBestEver, bestFitnessEver: newBestEver,
bestNetworkEver: newBestNetwork, bestNetworkEver: newBestNetwork,
}; };
@@ -81,6 +97,10 @@ export function evolveGeneration(
// Sort by fitness (descending) // Sort by fitness (descending)
const sorted = [...population.individuals].sort((a, b) => b.fitness - a.fitness); const sorted = [...population.individuals].sort((a, b) => b.fitness - a.fitness);
// Calculate stats for this generation BEFORE creating the new one
const currentBestFitness = sorted[0].fitness;
const currentAverageFitness = sorted.reduce((sum, ind) => sum + ind.fitness, 0) / sorted.length;
const newIndividuals: Individual[] = []; const newIndividuals: Individual[] = [];
// Elite preservation (top performers survive unchanged) // Elite preservation (top performers survive unchanged)
@@ -122,6 +142,10 @@ export function evolveGeneration(
generation: population.generation + 1, generation: population.generation + 1,
bestFitnessEver: population.bestFitnessEver, bestFitnessEver: population.bestFitnessEver,
bestNetworkEver: population.bestNetworkEver, bestNetworkEver: population.bestNetworkEver,
lastGenerationStats: {
bestFitness: currentBestFitness,
averageFitness: currentAverageFitness
}
}; };
} }
@@ -140,27 +164,26 @@ function selectParent(sorted: Individual[]): Individual {
return best; return best;
} }
function crossover(parent1: Network, parent2: Network): Network { function crossover(parent1: Network, parent2: Network): Network {
const child = cloneNetwork(parent1); const child = cloneNetwork(parent1);
child.id = Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
// Single-point crossover on weights and biases // Single-point crossover on weights and biases?
// For flat arrays, we can just iterate linear index.
const crossoverRate = 0.5; const crossoverRate = 0.5;
// Crossover input-hidden weights // Crossover input-hidden weights
for (let i = 0; i < child.weightsIH.length; i++) { for (let i = 0; i < child.weightsIH.length; i++) {
for (let j = 0; j < child.weightsIH[i].length; j++) { if (Math.random() < crossoverRate) {
if (Math.random() < crossoverRate) { child.weightsIH[i] = parent2.weightsIH[i];
child.weightsIH[i][j] = parent2.weightsIH[i][j];
}
} }
} }
// Crossover hidden-output weights // Crossover hidden-output weights
for (let i = 0; i < child.weightsHO.length; i++) { for (let i = 0; i < child.weightsHO.length; i++) {
for (let j = 0; j < child.weightsHO[i].length; j++) { if (Math.random() < crossoverRate) {
if (Math.random() < crossoverRate) { child.weightsHO[i] = parent2.weightsHO[i];
child.weightsHO[i][j] = parent2.weightsHO[i][j];
}
} }
} }
@@ -182,25 +205,22 @@ function crossover(parent1: Network, parent2: Network): Network {
function mutate(network: Network, mutationRate: number): Network { function mutate(network: Network, mutationRate: number): Network {
const mutated = cloneNetwork(network); const mutated = cloneNetwork(network);
mutated.id = Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
// Mutate input-hidden weights // Mutate input-hidden weights
for (let i = 0; i < mutated.weightsIH.length; i++) { for (let i = 0; i < mutated.weightsIH.length; i++) {
for (let j = 0; j < mutated.weightsIH[i].length; j++) { if (Math.random() < mutationRate) {
if (Math.random() < mutationRate) { mutated.weightsIH[i] += (Math.random() * 2 - 1) * 0.5;
mutated.weightsIH[i][j] += (Math.random() * 2 - 1) * 0.5;
// Clamp to reasonable range // Clamp to reasonable range
mutated.weightsIH[i][j] = Math.max(-2, Math.min(2, mutated.weightsIH[i][j])); mutated.weightsIH[i] = Math.max(-2, Math.min(2, mutated.weightsIH[i]));
}
} }
} }
// Mutate hidden-output weights // Mutate hidden-output weights
for (let i = 0; i < mutated.weightsHO.length; i++) { for (let i = 0; i < mutated.weightsHO.length; i++) {
for (let j = 0; j < mutated.weightsHO[i].length; j++) { if (Math.random() < mutationRate) {
if (Math.random() < mutationRate) { mutated.weightsHO[i] += (Math.random() * 2 - 1) * 0.5;
mutated.weightsHO[i][j] += (Math.random() * 2 - 1) * 0.5; mutated.weightsHO[i] = Math.max(-2, Math.min(2, mutated.weightsHO[i]));
mutated.weightsHO[i][j] = Math.max(-2, Math.min(2, mutated.weightsHO[i][j]));
}
} }
} }

View File

@@ -0,0 +1,54 @@
import { evaluatePopulation, evolveGeneration, type Population, type Individual } from './evolution';
import type { EvolutionConfig } from './types';
self.onmessage = (e: MessageEvent) => {
const data = e.data;
try {
if (data.type === 'EVALUATE_ONLY') {
// Worker Pool Mode: Just evaluate the given individuals
const { individuals, config } = data.payload as {
individuals: Individual[];
config: EvolutionConfig;
};
// Reconstruct a partial population object just for evaluation
// evaluatePopulation expects a Population, but only uses .individuals
// actually it returns a Population.
// Let's modify `evaluatePopulation`?
// Better: Mock the population shell.
const mockPop: Population = {
individuals,
generation: 0,
bestFitnessEver: 0,
bestNetworkEver: null
};
const evaluatedPop = evaluatePopulation(mockPop, config);
self.postMessage({
type: 'EVAL_RESULT',
payload: evaluatedPop.individuals
});
} else {
// Default Mode: Run full generations (Legacy / Single Worker)
const { population, config, generations = 1 } = data as {
population: Population;
config: EvolutionConfig;
generations?: number;
};
let currentPop = population;
for (let i = 0; i < generations; i++) {
const evaluated = evaluatePopulation(currentPop, config);
currentPop = evolveGeneration(evaluated, config);
}
self.postMessage({ type: 'SUCCESS', payload: currentPop });
}
} catch (error) {
self.postMessage({ type: 'ERROR', payload: error });
}
};

View File

@@ -0,0 +1,102 @@
import { describe, expect, test } from "bun:test";
import { calculateArea, createGame, isDanger, type GameState } from "./game";
import { Direction, type Position } from "./types";
// Helper to access the unexported calculateArea function?
// Since it's not exported, I might need to export it for testing or rely on testing getInputs.
// Let's modify game.ts to export calculateArea for testing purposes.
// For now, I'll assume I can export it.
// Mock Game State Helper
function createMockGame(gridSize: number, snake: Position[]): GameState {
return {
gridSize,
snake,
food: { x: 0, y: 0 }, // Irrelevant for area test
direction: Direction.RIGHT,
alive: true,
score: 0,
steps: 0,
stepsSinceLastFood: 0
};
}
describe("Snake AI Logic", () => {
describe("isDanger", () => {
const game = createMockGame(10, [{ x: 5, y: 5 }]);
test("detects wall collisions", () => {
expect(isDanger(game, -1, 5)).toBe(true);
expect(isDanger(game, 10, 5)).toBe(true);
expect(isDanger(game, 5, -1)).toBe(true);
expect(isDanger(game, 5, 10)).toBe(true);
});
test("detects safe spots", () => {
expect(isDanger(game, 0, 0)).toBe(false);
expect(isDanger(game, 9, 9)).toBe(false);
});
test("detects body collisions", () => {
const complexGame = createMockGame(10, [{x:5,y:5}, {x:5,y:6}, {x:6,y:6}]);
expect(isDanger(complexGame, 5, 6)).toBe(true); // Hit body
expect(isDanger(complexGame, 6, 6)).toBe(true); // Hit tail
expect(isDanger(complexGame, 5, 4)).toBe(false); // Safe spot
});
});
describe("calculateArea", () => {
test("calculates area in empty grid", () => {
// Grid 5x5 = 25 cells. Snake head at 2,2 occupies 1.
// Start flood fill from 2,3 (Down). Should reach all 24 empty cells.
const game = createMockGame(5, [{ x: 2, y: 2 }]);
const area = calculateArea(game, { x: 2, y: 3 });
expect(area).toBe(24);
});
test("calculates confined area", () => {
// Snake creates a wall splitting the board
// 5x5 Grid.
// Snake: (2,0), (2,1), (2,2), (2,3), (2,4) - Vertical line down middle
const snake = [
{x: 2, y: 0}, {x: 2, y: 1}, {x: 2, y: 2}, {x: 2, y: 3}, {x: 2, y: 4}
];
const game = createMockGame(5, snake);
// Left side (0,0) -> 2 cols x 5 rows = 10 cells
expect(calculateArea(game, { x: 0, y: 0 })).toBe(10);
// Right side (4,0) -> 2 cols x 5 rows = 10 cells
expect(calculateArea(game, { x: 4, y: 0 })).toBe(10);
// Check wall itself returns 0
expect(calculateArea(game, { x: 2, y: 0 })).toBe(0);
});
test("calculates U-shape trap", () => {
// U-shape wrapping around a center point
// Snake at (1,1), (1,2), (2,2), (2,1) ?? No simpler.
// Snake: (1,0), (1,1), (2,1), (3,1), (3,0)
// Trap at (2,0).
// Bound by Wall(Top) and Snake(L, D, R).
// 5x5 Grid.
// S S . . .
// S S S . .
// . . . . .
// . . . . .
// . . . . .
// Snake: (1,0), (1,1), (2,1), (3,1), (3,0)
const snake = [
{x:1, y:0}, {x:1, y:1}, {x:2, y:1}, {x:3, y:1}, {x:3, y:0}
];
const game = createMockGame(5, snake);
// Point (2,0) is inside the U cup.
// It is bounded by (1,0)L, (3,0)R, (2,1)D, Wall(Top).
// Area should be 1.
expect(calculateArea(game, { x: 2, y: 0 })).toBe(1);
});
});
});

View File

@@ -94,7 +94,7 @@ export function step(state: GameState, action: Action): GameState {
...state, ...state,
snake: newSnake, snake: newSnake,
food: spawnFood(state.gridSize, newSnake), food: spawnFood(state.gridSize, newSnake),
direction: newDirection, direction: newDirection as Direction,
score: state.score + 1, score: state.score + 1,
steps: state.steps + 1, steps: state.steps + 1,
stepsSinceLastFood: 0, stepsSinceLastFood: 0,
@@ -105,7 +105,7 @@ export function step(state: GameState, action: Action): GameState {
return { return {
...state, ...state,
snake: newSnake, snake: newSnake,
direction: newDirection, direction: newDirection as Direction,
steps: state.steps + 1, steps: state.steps + 1,
stepsSinceLastFood: state.stepsSinceLastFood + 1, stepsSinceLastFood: state.stepsSinceLastFood + 1,
}; };
@@ -131,58 +131,108 @@ function spawnFood(gridSize: number, snake: Position[]): Position {
return food; return food;
} }
// Shared buffers for optimization
let cachedObstacles: Int8Array | null = null; // 0 = empty, 1 = obstacle
let cachedVisited: Int8Array | null = null; // 0 = unvisited, 1 = visited
let cachedStack: Int32Array | null = null;
let cachedSize = 0;
function ensureBuffers(size: number) {
const totalCells = size * size;
if (!cachedObstacles || cachedSize !== size) {
cachedObstacles = new Int8Array(totalCells);
cachedVisited = new Int8Array(totalCells); // Changed back to Int8 for speed
cachedStack = new Int32Array(totalCells);
cachedSize = size;
}
}
export function getInputs(state: GameState): number[] { export function getInputs(state: GameState): number[] {
const head = state.snake[0]; const head = state.snake[0];
const food = state.food; const food = state.food;
const size = state.gridSize;
// Calculate relative direction vectors based on current direction
// If facing UP (0): Front=(0, -1), Left=(-1, 0), Right=(1, 0)
// If facing RIGHT (1): Front=(1, 0), Left=(0, -1), Right=(0, 1)
// ...and so on
const frontVec = getDirectionVector(state.direction); // Ensure buffers are ready
ensureBuffers(size);
const obstacles = cachedObstacles!;
// Reset obstacles (fastest way is fill(0))
obstacles.fill(0);
// Mark snake on obstacle grid (O(N))
// This replaces the O(N) check in isDanger called multiple times
const snake = state.snake;
for (let i = 0; i < snake.length; i++) {
const s = snake[i];
if (s.x >= 0 && s.x < size && s.y >= 0 && s.y < size) {
obstacles[s.y * size + s.x] = 1;
}
}
// Directions relative to Head
const leftVec = getDirectionVector(((state.direction + 3) % 4) as Direction); const leftVec = getDirectionVector(((state.direction + 3) % 4) as Direction);
const frontVec = getDirectionVector(state.direction);
const rightVec = getDirectionVector(((state.direction + 1) % 4) as Direction); const rightVec = getDirectionVector(((state.direction + 1) % 4) as Direction);
// 1. Danger Sensors (Relative) const visionInputs: number[] = [];
// Is there danger immediately to my Left, Front, or Right? const dirs = [leftVec, frontVec, rightVec];
const dangerLeft = isDanger(state, head.x + leftVec.x, head.y + leftVec.y);
const dangerFront = isDanger(state, head.x + frontVec.x, head.y + frontVec.y); // Total grid area for normalization
const dangerRight = isDanger(state, head.x + rightVec.x, head.y + rightVec.y); const totalArea = state.gridSize * state.gridSize;
// 2. Food Direction (Relative) for (const dir of dirs) {
// We want to know if food is to our Left/Right or In Front/Behind relative to head // 1. Immediate Danger
// We can use dot products or simple coordinate checks const immX = head.x + dir.x;
const immY = head.y + dir.y;
// Fast danger check using grid
let immediateDanger = false;
if (immX < 0 || immX >= size || immY < 0 || immY >= size) {
immediateDanger = true;
} else if (obstacles[immY * size + immX] === 1) {
immediateDanger = true;
}
visionInputs.push(immediateDanger ? 1 : 0);
// 2. Available Area (Flood Fill)
let area = 0;
if (!immediateDanger) {
area = calculateAreaOptimized(size, obstacles, { x: immX, y: immY });
}
visionInputs.push(area / totalArea);
}
// Food Sensors (4 inputs)
const relFoodX = food.x - head.x; const relFoodX = food.x - head.x;
const relFoodY = food.y - head.y; const relFoodY = food.y - head.y;
// Dot product to project food vector onto our relative axes
const foodFront = relFoodX * frontVec.x + relFoodY * frontVec.y; const foodFront = relFoodX * frontVec.x + relFoodY * frontVec.y;
const foodSide = relFoodX * rightVec.x + relFoodY * rightVec.y; const foodSide = relFoodX * rightVec.x + relFoodY * rightVec.y;
// foodSide: Positive = Right, Negative = Left
// Self Awareness (1 input)
const normLength = state.snake.length / totalArea;
return [ return [
// Sensor 1: Danger Left ...visionInputs, // 6 inputs (3 * 2)
dangerLeft ? 1 : 0,
// Sensor 2: Danger Front
dangerFront ? 1 : 0,
// Sensor 3: Danger Right
dangerRight ? 1 : 0,
// Sensor 4: Food is to the Left
foodSide < 0 ? 1 : 0,
// Sensor 5: Food is to the Right
foodSide > 0 ? 1 : 0,
// Sensor 6: Food is Ahead
foodFront > 0 ? 1 : 0,
// Sensor 7: Food is Behind
foodFront < 0 ? 1 : 0,
// Sensor 8: Normalized Length (Growth Sensor) // Food (4 inputs)
state.snake.length / (state.gridSize * state.gridSize) foodSide < 0 ? 1 : 0, // Left
foodSide > 0 ? 1 : 0, // Right
foodFront > 0 ? 1 : 0, // Front
foodFront < 0 ? 1 : 0, // Back
// Length (1 input)
normLength
]; ];
} }
export function isDanger(state: GameState, x: number, y: number): boolean {
if (x < 0 || x >= state.gridSize || y < 0 || y >= state.gridSize) return true;
return state.snake.some(s => s.x === x && s.y === y);
}
function getDirectionVector(dir: Direction): Position { function getDirectionVector(dir: Direction): Position {
switch (dir) { switch (dir) {
case Direction.UP: return { x: 0, y: -1 }; case Direction.UP: return { x: 0, y: -1 };
@@ -193,15 +243,96 @@ function getDirectionVector(dir: Direction): Position {
} }
} }
function isDanger(state: GameState, x: number, y: number): boolean { // Optimized, internal version calling shared buffers
// Check wall function calculateAreaOptimized(size: number, obstacles: Int8Array, start: Position): number {
if (x < 0 || x >= state.gridSize || y < 0 || y >= state.gridSize) { const stack = cachedStack!;
return true; const visited = cachedVisited!;
}
// Check self-collision // Reset visited for this run
return state.snake.some((seg) => seg.x === x && seg.y === y); visited.fill(0);
const startIndex = start.y * size + start.x;
// Safety check (already done in getInputs, but acceptable)
if (obstacles[startIndex] === 1) return 0;
let head = 0;
let tail = 0;
stack[tail++] = startIndex;
visited[startIndex] = 1; // Mark visited
let area = 0;
while (head < tail) {
const currIndex = stack[head++];
area++;
const cx = currIndex % size;
const cy = (currIndex / size) | 0;
// Neighbors (Up, Down, Left, Right)
// Up
if (cy > 0) {
const upIndex = currIndex - size;
// Check obstacle AND if already visited
if (obstacles[upIndex] === 0 && visited[upIndex] === 0) {
visited[upIndex] = 1;
stack[tail++] = upIndex;
}
}
// Down
if (cy < size - 1) {
const downIndex = currIndex + size;
if (obstacles[downIndex] === 0 && visited[downIndex] === 0) {
visited[downIndex] = 1;
stack[tail++] = downIndex;
}
}
// Left
if (cx > 0) {
const leftIndex = currIndex - 1;
if (obstacles[leftIndex] === 0 && visited[leftIndex] === 0) {
visited[leftIndex] = 1;
stack[tail++] = leftIndex;
}
}
// Right
if (cx < size - 1) {
const rightIndex = currIndex + 1;
if (obstacles[rightIndex] === 0 && visited[rightIndex] === 0) {
visited[rightIndex] = 1;
stack[tail++] = rightIndex;
}
}
}
return area;
} }
/**
* @deprecated Use calculateAreaOptimized internally. Kept for backward compatibility/tests.
*/
export function calculateArea(state: GameState, start: Position): number {
ensureBuffers(state.gridSize);
const obstacles = cachedObstacles!;
obstacles.fill(0);
for (const s of state.snake) {
if (s.x >= 0 && s.x < state.gridSize && s.y >= 0 && s.y < state.gridSize) {
obstacles[s.y * state.gridSize + s.x] = 1;
}
}
return calculateAreaOptimized(state.gridSize, obstacles, start);
}
export function calculateFitness(state: GameState): number { export function calculateFitness(state: GameState): number {
// Fitness formula balancing food collection and survival // Fitness formula balancing food collection and survival
const foodScore = state.score * 100; const foodScore = state.score * 100;

View File

@@ -1,78 +1,136 @@
import { Action } from './types'; import { Action } from './types';
export interface Network { export interface Network {
id: string;
inputSize: number; inputSize: number;
hiddenSize: number; hiddenSize: number;
outputSize: number; outputSize: number;
weightsIH: number[][]; // Input to Hidden weights // Flat buffers for better cache locality and performance
weightsHO: number[][]; // Hidden to Output weights weightsIH: Float32Array; // Input -> Hidden weights
biasH: number[]; // Hidden layer biases weightsHO: Float32Array; // Hidden -> Output weights
biasO: number[]; // Output layer biases biasH: Float32Array; // Hidden layer biases
biasO: Float32Array; // Output layer biases
} }
export function createNetwork( export function createNetwork(
inputSize: number = 8, inputSize: number = 11,
hiddenSize: number = 18, hiddenSize: number = 24,
outputSize: number = 3 outputSize: number = 3
): Network { ): Network {
return { return {
id: generateId(),
inputSize, inputSize,
hiddenSize, hiddenSize,
outputSize, outputSize,
weightsIH: createRandomMatrix(inputSize, hiddenSize), weightsIH: createRandomArray(inputSize * hiddenSize),
weightsHO: createRandomMatrix(hiddenSize, outputSize), weightsHO: createRandomArray(hiddenSize * outputSize),
biasH: createRandomArray(hiddenSize), biasH: createRandomArray(hiddenSize),
biasO: createRandomArray(outputSize), biasO: createRandomArray(outputSize),
}; };
} }
function createRandomMatrix(rows: number, cols: number): number[][] { function generateId(): string {
const matrix: number[][] = []; return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
for (let i = 0; i < rows; i++) {
matrix[i] = [];
for (let j = 0; j < cols; j++) {
matrix[i][j] = Math.random() * 2 - 1; // Random between -1 and 1
}
}
return matrix;
} }
function createRandomArray(size: number): number[] { function createRandomArray(size: number): Float32Array {
const array: number[] = []; const array = new Float32Array(size);
for (let i = 0; i < size; i++) { for (let i = 0; i < size; i++) {
array[i] = Math.random() * 2 - 1; array[i] = Math.random() * 2 - 1; // Random between -1 and 1
} }
return array; return array;
} }
export function forward(network: Network, inputs: number[]): number[] { // Pre-allocated buffers for inference to avoid garbage collection
// Hidden layer activation // Note: This makes 'forward' not thread-safe if called concurrently on the SAME thread.
const hidden: number[] = []; // Since JS is single-threaded, this is safe unless we use async/await inside (which we don't).
for (let h = 0; h < network.hiddenSize; h++) { // However, distinct workers have their own memory, so it's safe for workers too.
let sum = network.biasH[h]; let cachedHidden: Float32Array | null = null;
for (let i = 0; i < network.inputSize; i++) { let cachedOutputs: Float32Array | null = null;
sum += inputs[i] * network.weightsIH[i][h]; let maxHiddenSize = 0;
} let maxOutputSize = 0;
// ReLU activation for hidden layer: f(x) = max(0, x)
// Faster and solves vanishing gradient better than tanh
hidden[h] = Math.max(0, sum);
}
// Output layer activation function ensureBuffers(hiddenSize: number, outputSize: number) {
const outputs: number[] = []; if (!cachedHidden || hiddenSize > maxHiddenSize) {
for (let o = 0; o < network.outputSize; o++) { cachedHidden = new Float32Array(hiddenSize);
let sum = network.biasO[o]; maxHiddenSize = hiddenSize;
for (let h = 0; h < network.hiddenSize; h++) { }
sum += hidden[h] * network.weightsHO[h][o]; if (!cachedOutputs || outputSize > maxOutputSize) {
cachedOutputs = new Float32Array(outputSize);
maxOutputSize = outputSize;
} }
outputs[o] = tanh(sum);
}
return outputs;
} }
function tanh(x: number): number { export function forward(network: Network, inputs: number[]): Float32Array {
return Math.tanh(x); const { inputSize, hiddenSize, outputSize, weightsIH, weightsHO, biasH, biasO } = network;
ensureBuffers(hiddenSize, outputSize);
const hidden = cachedHidden!;
const outputs = cachedOutputs!;
// 1. Hidden Layer
// hidden[h] = ReLU(bias[h] + sum(inputs[i] * weights[i][h]))
// Flattened weightsIH is [Input 0 -> Hidden 0..H, Input 1 -> Hidden 0..H]
// Wait, standard matrix mult is usually [Row][Col].
// Let's assume weightsIH is stored as rows=Input, cols=Hidden.
// Index = i * hiddenSize + h
// Optimization: Loop order.
// Iterating h then i means jumping around in inputs array? No, inputs is small.
// Jumping around in weights array is bad.
// If weights are stored [i * hiddenSize + h], then iterating i then h is sequential?
// No, h varies in inner loop.
// We want to iterate weights sequentially.
// Initialize hidden with bias
hidden.set(biasH);
// Accumulate inputs
// weightsIH is laid out: [i=0, h=0], [i=0, h=1]...
// So we should iterate i as outer, h as inner?
// biasH is [h=0, h=1...]
let wIdx = 0;
for (let i = 0; i < inputSize; i++) {
const inputVal = inputs[i];
if (inputVal !== 0) { // Sparse input optimization
for (let h = 0; h < hiddenSize; h++) {
hidden[h] += inputVal * weightsIH[wIdx++];
}
} else {
wIdx += hiddenSize; // Skip weights for zero input
}
}
// ReLU Activation
for (let h = 0; h < hiddenSize; h++) {
if (hidden[h] < 0) hidden[h] = 0;
}
// 2. Output Layer
// outputs[o] = tanh(bias[o] + sum(hidden[h] * weights[h][o]))
// Initialize with bias
outputs.set(biasO);
wIdx = 0;
for (let h = 0; h < hiddenSize; h++) {
const hiddenVal = hidden[h];
if (hiddenVal !== 0) {
for (let o = 0; o < outputSize; o++) {
outputs[o] += hiddenVal * weightsHO[wIdx++];
}
} else {
wIdx += outputSize;
}
}
// Tanh Activation
for (let o = 0; o < outputSize; o++) {
outputs[o] = Math.tanh(outputs[o]);
}
return outputs;
} }
export function getAction(network: Network, inputs: number[]): Action { export function getAction(network: Network, inputs: number[]): Action {
@@ -80,33 +138,36 @@ export function getAction(network: Network, inputs: number[]): Action {
// Find index of maximum output // Find index of maximum output
let maxIndex = 0; let maxIndex = 0;
for (let i = 1; i < outputs.length; i++) { let maxVal = outputs[0];
if (outputs[i] > outputs[maxIndex]) {
maxIndex = i; // Unrolled loop for small output size (3)
} if (outputs[1] > maxVal) {
maxVal = outputs[1];
maxIndex = 1;
}
if (outputs[2] > maxVal) {
maxIndex = 2;
} }
// Map output index to action // Map output index to action
switch (maxIndex) { switch (maxIndex) {
case 0: case 0: return Action.TURN_LEFT;
return Action.TURN_LEFT; case 1: return Action.STRAIGHT;
case 1: case 2: return Action.TURN_RIGHT;
return Action.STRAIGHT; default: return Action.STRAIGHT;
case 2:
return Action.TURN_RIGHT;
default:
return Action.STRAIGHT;
} }
} }
export function cloneNetwork(network: Network): Network { export function cloneNetwork(network: Network): Network {
return { return {
id: network.id,
inputSize: network.inputSize, inputSize: network.inputSize,
hiddenSize: network.hiddenSize, hiddenSize: network.hiddenSize,
outputSize: network.outputSize, outputSize: network.outputSize,
weightsIH: network.weightsIH.map((row) => [...row]), // Float32Array has a fast .slice() method to copy
weightsHO: network.weightsHO.map((row) => [...row]), weightsIH: network.weightsIH.slice(),
biasH: [...network.biasH], weightsHO: network.weightsHO.slice(),
biasO: [...network.biasO], biasH: network.biasH.slice(),
biasO: network.biasO.slice(),
}; };
} }

View File

@@ -0,0 +1,70 @@
import EvolutionWorker from './evolution.worker?worker';
import type { Population, Individual } from './evolution';
import type { EvolutionConfig } from './types';
export class WorkerPool {
private workers: Worker[] = [];
private poolSize: number;
constructor(size: number = navigator.hardwareConcurrency || 4) {
this.poolSize = size;
for (let i = 0; i < size; i++) {
this.workers.push(new EvolutionWorker());
}
}
terminate() {
this.workers.forEach(w => w.terminate());
this.workers = [];
}
async evaluateParallel(population: Population, config: EvolutionConfig): Promise<Population> {
// Split individuals into chunks
const chunkSize = Math.ceil(population.individuals.length / this.poolSize);
const chunks: Individual[][] = [];
for (let i = 0; i < population.individuals.length; i += chunkSize) {
chunks.push(population.individuals.slice(i, i + chunkSize));
}
// Dispatch chunks to workers
const promises = chunks.map((chunk, index) => {
return new Promise<Individual[]>((resolve, reject) => {
const worker = this.workers[index];
// One-time listener for this request
const handler = (e: MessageEvent) => {
if (e.data.type === 'EVAL_RESULT') {
worker.removeEventListener('message', handler);
resolve(e.data.payload);
} else if (e.data.type === 'ERROR') {
worker.removeEventListener('message', handler);
reject(e.data.payload);
}
};
worker.addEventListener('message', handler);
worker.postMessage({
type: 'EVALUATE_ONLY',
payload: {
individuals: chunk,
config
}
});
});
});
// Wait for all chunks
const results = await Promise.all(promises);
// Merge results
const mergedIndividuals = results.flat();
// Reconstruct population with evaluated individuals
return {
...population,
individuals: mergedIndividuals
};
}
}

10
static.json Normal file
View File

@@ -0,0 +1,10 @@
{
"root": "dist/",
"clean_urls": true,
"https_only": true,
"headers": {
"/**": {
"Cache-Control": "public, max-age=31536000"
}
}
}

View File

@@ -3,11 +3,16 @@
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022", "target": "ES2022",
"useDefineForClassFields": true, "useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"], "lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"module": "ESNext", "module": "ESNext",
"types": ["vite/client"], "types": [
"vite/client"
],
"skipLibCheck": true, "skipLibCheck": true,
/* Bundler mode */ /* Bundler mode */
"moduleResolution": "bundler", "moduleResolution": "bundler",
"allowImportingTsExtensions": true, "allowImportingTsExtensions": true,
@@ -15,7 +20,6 @@
"moduleDetection": "force", "moduleDetection": "force",
"noEmit": true, "noEmit": true,
"jsx": "react-jsx", "jsx": "react-jsx",
/* Linting */ /* Linting */
"strict": true, "strict": true,
"noUnusedLocals": true, "noUnusedLocals": true,
@@ -24,5 +28,14 @@
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true "noUncheckedSideEffectImports": true
}, },
"include": ["src"] "include": [
} "src"
],
"exclude": [
"**/*.test.ts",
"**/*.test.tsx",
"**/debug_*.ts",
"**/run_test_manual.ts",
"**/check_map_los.ts"
]
}