Still cant get neat arena to work
This commit is contained in:
@@ -6,17 +6,32 @@ interface FitnessGraphProps {
|
||||
|
||||
export default function FitnessGraph({ history }: FitnessGraphProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
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;
|
||||
const padding = 40;
|
||||
|
||||
// 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';
|
||||
@@ -33,10 +48,10 @@ export default function FitnessGraph({ history }: FitnessGraphProps) {
|
||||
ctx.strokeStyle = '#2a2a3e';
|
||||
ctx.lineWidth = 1;
|
||||
for (let i = 0; i <= 5; i++) {
|
||||
const y = padding + (height - 2 * padding) * (i / 5);
|
||||
const y = paddingTop + graphHeight * (i / 5);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(padding, y);
|
||||
ctx.lineTo(width - padding, y);
|
||||
ctx.moveTo(paddingLeft, y);
|
||||
ctx.lineTo(width - paddingRight, y);
|
||||
ctx.stroke();
|
||||
|
||||
// Y-axis labels
|
||||
@@ -44,23 +59,23 @@ export default function FitnessGraph({ history }: FitnessGraphProps) {
|
||||
ctx.fillStyle = '#888';
|
||||
ctx.font = '11px monospace';
|
||||
ctx.textAlign = 'right';
|
||||
ctx.fillText(fitValue.toFixed(1), padding - 5, y + 4);
|
||||
ctx.fillText(fitValue.toFixed(1), paddingLeft - 5, y + 4);
|
||||
}
|
||||
|
||||
// Draw axes
|
||||
ctx.strokeStyle = '#444';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(padding, padding);
|
||||
ctx.lineTo(padding, height - padding);
|
||||
ctx.lineTo(width - padding, height - padding);
|
||||
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) => padding + ((width - 2 * padding) * gen / maxGen);
|
||||
const toX = (gen: number) => paddingLeft + (graphWidth * gen / maxGen);
|
||||
const toY = (fit: number) => {
|
||||
const normalized = (maxFit - fit) / fitRange;
|
||||
return padding + (height - 2 * padding) * normalized;
|
||||
return paddingTop + graphHeight * normalized;
|
||||
};
|
||||
|
||||
// Draw best fitness line
|
||||
@@ -93,20 +108,25 @@ export default function FitnessGraph({ history }: FitnessGraphProps) {
|
||||
ctx.fillText('● Best', width - 120, 25);
|
||||
ctx.fillStyle = '#4488ff';
|
||||
ctx.fillText('● Avg', width - 60, 25);
|
||||
};
|
||||
|
||||
// X-axis label
|
||||
ctx.fillStyle = '#888';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('Generation', width / 2, height - 10);
|
||||
|
||||
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 (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={600}
|
||||
height={200}
|
||||
style={{ width: '100%', height: 'auto', borderRadius: '8px' }}
|
||||
/>
|
||||
<div ref={containerRef} style={{ width: '100%', height: '100%', minHeight: 0 }}>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{ display: 'block' }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -123,9 +123,8 @@ export default function NeatArena() {
|
||||
};
|
||||
}, [mapSeed]);
|
||||
|
||||
// Exhibition match loop (visualizing champion vs baseline)
|
||||
// Exhibition match loop (visualizing best vs second-best AI)
|
||||
useEffect(() => {
|
||||
if (isTraining) return; // Don't run exhibition during training
|
||||
if (!phaserGameRef.current) return;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
@@ -138,18 +137,15 @@ export default function NeatArena() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Agent 0: Imported genome, current gen best, or spinner
|
||||
// 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;
|
||||
|
||||
// Priority: imported > current gen best > all-time best > spinner
|
||||
const currentGenBest = population.genomes.length > 0
|
||||
? population.genomes.reduce((best, g) => g.fitness > best.fitness ? g : best)
|
||||
: null;
|
||||
|
||||
const genomeToUse = importedGenome || currentGenBest || population.bestGenomeEver;
|
||||
|
||||
if (genomeToUse) {
|
||||
const network = createNetwork(genomeToUse);
|
||||
if (genome0) {
|
||||
const network = createNetwork(genome0);
|
||||
const obs = generateObservation(0, sim);
|
||||
const inputs = observationToInputs(obs);
|
||||
const outputs = network.activate(inputs);
|
||||
@@ -164,8 +160,23 @@ export default function NeatArena() {
|
||||
action0 = spinnerBotAction();
|
||||
}
|
||||
|
||||
// Agent 1: Spinner bot
|
||||
const action1 = 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]);
|
||||
|
||||
@@ -178,7 +189,7 @@ export default function NeatArena() {
|
||||
}, 1000 / 30);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [isTraining, showRays, mapSeed, population.bestGenomeEver, importedGenome]);
|
||||
}, [showRays, mapSeed, population.genomes, importedGenome]);
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
setIsTraining(false);
|
||||
@@ -269,10 +280,10 @@ export default function NeatArena() {
|
||||
{isTraining
|
||||
? '🟢 Training in background worker...'
|
||||
: importedGenome
|
||||
? '🎮 Watching imported champion vs Spinner bot'
|
||||
: population.bestGenomeEver
|
||||
? '🎮 Watching champion vs Spinner bot'
|
||||
: '⚪ No champion yet'}
|
||||
? '🎮 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>
|
||||
|
||||
|
||||
100
src/lib/neatArena/aim_mechanics.test.ts
Normal file
100
src/lib/neatArena/aim_mechanics.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
46
src/lib/neatArena/bench_learning.test.ts
Normal file
46
src/lib/neatArena/bench_learning.test.ts
Normal 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
|
||||
61
src/lib/neatArena/benchmark_progress.test.ts
Normal file
61
src/lib/neatArena/benchmark_progress.test.ts
Normal 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)
|
||||
});
|
||||
});
|
||||
37
src/lib/neatArena/check_map_los.ts
Normal file
37
src/lib/neatArena/check_map_los.ts
Normal 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!');
|
||||
28
src/lib/neatArena/check_sight.ts
Normal file
28
src/lib/neatArena/check_sight.ts
Normal 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");
|
||||
66
src/lib/neatArena/curriculum_e2e.test.ts
Normal file
66
src/lib/neatArena/curriculum_e2e.test.ts
Normal 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
|
||||
});
|
||||
41
src/lib/neatArena/debug_curriculum_placeholder.ts
Normal file
41
src/lib/neatArena/debug_curriculum_placeholder.ts
Normal 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.
|
||||
}
|
||||
26
src/lib/neatArena/debug_distance.ts
Normal file
26
src/lib/neatArena/debug_distance.ts
Normal 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.");
|
||||
}
|
||||
46
src/lib/neatArena/debug_fitness_calc.ts
Normal file
46
src/lib/neatArena/debug_fitness_calc.ts
Normal 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}`);
|
||||
}
|
||||
41
src/lib/neatArena/debug_simulation_score.ts
Normal file
41
src/lib/neatArena/debug_simulation_score.ts
Normal 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);
|
||||
110
src/lib/neatArena/duration_impact.test.ts
Normal file
110
src/lib/neatArena/duration_impact.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
157
src/lib/neatArena/e2e_evolution.test.ts
Normal file
157
src/lib/neatArena/e2e_evolution.test.ts
Normal 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)
|
||||
};
|
||||
}
|
||||
301
src/lib/neatArena/evolution.test.ts
Normal file
301
src/lib/neatArena/evolution.test.ts
Normal 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%
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { InnovationTracker, type Genome } from './genome';
|
||||
import type { Species } from './speciation';
|
||||
import type { ReproductionConfig } from './reproduction';
|
||||
import { createMinimalGenome } from './genome';
|
||||
import { createMinimalGenome, cloneGenome } from './genome';
|
||||
import {
|
||||
speciate,
|
||||
adjustCompatibilityThreshold,
|
||||
@@ -30,8 +30,8 @@ export interface EvolutionConfig {
|
||||
}
|
||||
|
||||
export const DEFAULT_EVOLUTION_CONFIG: EvolutionConfig = {
|
||||
populationSize: 40,
|
||||
inputCount: 53, // Ray sensors + extra inputs
|
||||
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,
|
||||
@@ -66,7 +66,7 @@ export function createPopulation(config: EvolutionConfig): Population {
|
||||
genomes,
|
||||
species: [],
|
||||
generation: 0,
|
||||
compatibilityThreshold: 1.5, // Balanced to target 6-10 species
|
||||
compatibilityThreshold: 3.0, // Increased from 1.5 to prevent initial explosion
|
||||
innovationTracker,
|
||||
bestGenomeEver: null,
|
||||
bestFitnessEver: -Infinity,
|
||||
@@ -112,10 +112,43 @@ export function evolveGeneration(population: Population, config: EvolutionConfig
|
||||
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
|
||||
species.length,
|
||||
targetMin,
|
||||
targetMax
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -133,7 +166,33 @@ export function evolveGeneration(population: Population, config: EvolutionConfig
|
||||
* Get statistics for the current population
|
||||
*/
|
||||
export function getPopulationStats(population: Population) {
|
||||
const fitnesses = population.genomes.map(g => g.fitness);
|
||||
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);
|
||||
|
||||
57
src/lib/neatArena/evolution_performance.test.ts
Normal file
57
src/lib/neatArena/evolution_performance.test.ts
Normal 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)
|
||||
});
|
||||
});
|
||||
@@ -12,16 +12,28 @@ import { hasLineOfSight } from './sensors';
|
||||
* - +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;
|
||||
lastHits: number;
|
||||
lastHitsTaken: number;
|
||||
lastHitsDealt: number;
|
||||
shotsFired: number;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Create a new fitness tracker
|
||||
*/
|
||||
@@ -30,7 +42,8 @@ export function createFitnessTracker(agentId: number): FitnessTracker {
|
||||
agentId,
|
||||
fitness: 0,
|
||||
lastKills: 0,
|
||||
lastHits: 0,
|
||||
lastHitsTaken: 0,
|
||||
lastHitsDealt: 0,
|
||||
shotsFired: 0,
|
||||
};
|
||||
}
|
||||
@@ -46,40 +59,57 @@ export function updateFitness(tracker: FitnessTracker, state: SimulationState):
|
||||
|
||||
// Reward for new kills
|
||||
const newKills = agent.kills - tracker.lastKills;
|
||||
newTracker.fitness += newKills * 10;
|
||||
newTracker.fitness += newKills * FITNESS_CONFIG.HIT_REWARD;
|
||||
newTracker.lastKills = agent.kills;
|
||||
|
||||
// Penalty for being hit
|
||||
const newHits = agent.hits - tracker.lastHits;
|
||||
newTracker.fitness -= newHits * 10;
|
||||
newTracker.lastHits = agent.hits;
|
||||
// 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 -= 0.002;
|
||||
newTracker.fitness -= FITNESS_CONFIG.TIME_PENALTY;
|
||||
|
||||
// Check if agent fired this tick (cooldown just set)
|
||||
// Check if agent fired this tick
|
||||
if (agent.fireCooldown === 10) {
|
||||
newTracker.shotsFired++;
|
||||
newTracker.fitness -= 0.2;
|
||||
newTracker.fitness -= FITNESS_CONFIG.SHOT_PENALTY; // Tiny penalty just to prevent spamming empty space
|
||||
}
|
||||
|
||||
// Reward for aiming at visible opponent
|
||||
// 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);
|
||||
|
||||
// Normalize angle difference
|
||||
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);
|
||||
|
||||
// Reward if aiming close (cos > 0.95 ≈ within ~18°)
|
||||
if (cosAngleDiff > 0.95) {
|
||||
newTracker.fitness += 0.01;
|
||||
}
|
||||
// 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;
|
||||
|
||||
@@ -28,10 +28,14 @@ export interface ConnectionGene {
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete genome
|
||||
*/
|
||||
/**
|
||||
* Complete genome
|
||||
*/
|
||||
export interface Genome {
|
||||
id: number;
|
||||
nodes: NodeGene[];
|
||||
connections: ConnectionGene[];
|
||||
fitness: number;
|
||||
@@ -75,6 +79,8 @@ export class InnovationTracker {
|
||||
}
|
||||
}
|
||||
|
||||
let nextGenomeId = 0;
|
||||
|
||||
/**
|
||||
* Create a minimal genome with only input and output nodes, fully connected
|
||||
*/
|
||||
@@ -87,7 +93,8 @@ export function createMinimalGenome(
|
||||
const connections: ConnectionGene[] = [];
|
||||
|
||||
// Create input nodes (IDs 0 to inputCount-1)
|
||||
for (let i = 0; i < inputCount; i++) {
|
||||
// PLUS one extra for Bias
|
||||
for (let i = 0; i < inputCount + 1; i++) {
|
||||
nodes.push({
|
||||
id: i,
|
||||
type: 'input',
|
||||
@@ -95,34 +102,47 @@ export function createMinimalGenome(
|
||||
});
|
||||
}
|
||||
|
||||
// Create output nodes (IDs starting from inputCount)
|
||||
// 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 + i,
|
||||
id: inputCount + 1 + i,
|
||||
type: 'output',
|
||||
activation: 'tanh',
|
||||
});
|
||||
}
|
||||
|
||||
// Create fully connected minimal genome
|
||||
for (let i = 0; i < inputCount; i++) {
|
||||
const inputNode = i; // Assuming inputNode refers to the ID
|
||||
// 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 + o; // Assuming outputNode refers to the ID
|
||||
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: (Math.random() * 4) - 2, // Random weight in [-2, 2] for initial diversity
|
||||
weight,
|
||||
enabled: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: nextGenomeId++,
|
||||
nodes,
|
||||
connections,
|
||||
fitness: 0,
|
||||
@@ -134,6 +154,7 @@ export function createMinimalGenome(
|
||||
*/
|
||||
export function cloneGenome(genome: Genome): Genome {
|
||||
return {
|
||||
id: nextGenomeId++,
|
||||
nodes: genome.nodes.map(n => ({ ...n })),
|
||||
connections: genome.connections.map(c => ({ ...c })),
|
||||
fitness: genome.fitness,
|
||||
|
||||
@@ -39,7 +39,10 @@ export function generateArenaMap(seed: number): ArenaMap {
|
||||
const height = rng.nextFloat(30, 80);
|
||||
|
||||
// Keep walls in left half (with margin)
|
||||
const minX = rng.nextFloat(wallThickness + 20, WORLD_SIZE / 2 - width - 20);
|
||||
// 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 = {
|
||||
@@ -79,7 +82,10 @@ export function generateArenaMap(seed: number): ArenaMap {
|
||||
// Find a valid spawn point on the left
|
||||
do {
|
||||
leftSpawn = {
|
||||
x: rng.nextFloat(wallThickness + 40, WORLD_SIZE / 2 - 40),
|
||||
// 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++;
|
||||
|
||||
@@ -31,15 +31,17 @@ export interface MutationRates {
|
||||
* Default mutation probabilities
|
||||
*/
|
||||
export const DEFAULT_MUTATION_RATES: MutationRates = {
|
||||
mutateWeightsProb: 0.50, // Reduced from 0.8 to allow more structural mutations
|
||||
resetWeightProb: 0.05, // Reduced from 0.1
|
||||
addConnectionProb: 0.20, // Increased from 0.05 for more diversity
|
||||
addNodeProb: 0.15, // Increased from 0.03 for more complexity
|
||||
toggleConnectionProb: 0.10, // Increased from 0.02
|
||||
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
|
||||
perturbationPower: 0.5, // Increased from 0.1 for stronger weight changes
|
||||
resetRange: 2.0, // Weight reset range
|
||||
// Weight mutation parameters
|
||||
perturbationPower: 0.1, // Reduced from 0.5 to prevent re-saturation
|
||||
resetRange: 0.5, // Reduced from 2.0 for safer resets
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -83,7 +85,7 @@ export function mutate(genome: Genome, tracker: InnovationTracker, rates = DEFAU
|
||||
|
||||
// Log structural mutations (only if any happened)
|
||||
if (addedConnections > 0 || addedNodes > 0 || toggledConnections > 0) {
|
||||
console.log(`[Mutation] +${addedConnections} conn, +${addedNodes} nodes, ${toggledConnections} toggled`);
|
||||
// console.log(`[Mutation] +${addedConnections} conn, +${addedNodes} nodes, ${toggledConnections} toggled`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -76,7 +76,10 @@ export function reproduce(
|
||||
// If we don't have enough genomes, fill with random mutations of best
|
||||
while (newGenomes.length < populationSize) {
|
||||
const bestGenome = getBestGenomeFromSpecies(species);
|
||||
const mutated = mutate(bestGenome, innovationTracker, config.mutationRates);
|
||||
// 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);
|
||||
}
|
||||
|
||||
@@ -106,7 +109,9 @@ function reproduceSpecies(
|
||||
// Elitism: keep best genomes unchanged
|
||||
const eliteCount = Math.min(config.elitePerSpecies, sorted.length, offspringCount);
|
||||
for (let i = 0; i < eliteCount; i++) {
|
||||
offspring.push(cloneGenome(sorted[i]));
|
||||
const elite = cloneGenome(sorted[i]);
|
||||
elite.fitness = 0; // Reset fitness for new generation
|
||||
offspring.push(elite);
|
||||
}
|
||||
|
||||
// Generate rest through crossover and mutation
|
||||
@@ -136,9 +141,13 @@ function reproduceSpecies(
|
||||
* Select a parent using fitness-proportionate selection
|
||||
*/
|
||||
function selectParent(sortedGenomes: Genome[]): Genome {
|
||||
// Simple tournament selection (top 50%)
|
||||
const tournamentSize = Math.max(2, Math.floor(sortedGenomes.length * 0.5));
|
||||
const index = Math.floor(Math.random() * tournamentSize);
|
||||
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];
|
||||
}
|
||||
|
||||
|
||||
58
src/lib/neatArena/run_test_manual.ts
Normal file
58
src/lib/neatArena/run_test_manual.ts
Normal 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();
|
||||
@@ -21,9 +21,9 @@ export interface MatchConfig {
|
||||
}
|
||||
|
||||
export const DEFAULT_MATCH_CONFIG: MatchConfig = {
|
||||
matchesPerGenome: 4,
|
||||
matchesPerGenome: 6, // Increased from 4 to reduce variance
|
||||
mapSeed: 12345,
|
||||
maxTicks: 600,
|
||||
maxTicks: 1200, // Increased to 40s (was 10s) to allow complex strategies
|
||||
};
|
||||
|
||||
interface MatchPairing {
|
||||
@@ -37,125 +37,227 @@ interface MatchPairing {
|
||||
* Evaluate entire population using self-play
|
||||
*/
|
||||
export function evaluatePopulation(
|
||||
population: Population,
|
||||
config: MatchConfig = DEFAULT_MATCH_CONFIG
|
||||
population: Population,
|
||||
config: MatchConfig,
|
||||
generation: number = 0 // Added generation for dynamic seeding
|
||||
): Population {
|
||||
// Reset fitness
|
||||
const genomes = population.genomes;
|
||||
const K = config.matchesPerGenome;
|
||||
const K = config.matchesPerGenome; // Total matches (e.g., 6)
|
||||
|
||||
// Initialize fitness trackers
|
||||
const fitnessTrackers = genomes.map((_, i) => ({
|
||||
totalFitness: 0,
|
||||
matchCount: 0,
|
||||
}));
|
||||
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
|
||||
|
||||
// Generate deterministic pairings
|
||||
const pairings = generatePairings(genomes.length, K, population.generation);
|
||||
// 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;
|
||||
|
||||
// Run all matches
|
||||
for (const pairing of pairings) {
|
||||
const result = runMatch(
|
||||
genomes[pairing.genome1Index],
|
||||
genomes[pairing.genome2Index],
|
||||
pairing,
|
||||
config
|
||||
);
|
||||
|
||||
// Accumulate fitness
|
||||
fitnessTrackers[pairing.genome1Index].totalFitness += result.fitness1;
|
||||
fitnessTrackers[pairing.genome1Index].matchCount++;
|
||||
|
||||
fitnessTrackers[pairing.genome2Index].totalFitness += result.fitness2;
|
||||
fitnessTrackers[pairing.genome2Index].matchCount++;
|
||||
|
||||
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);
|
||||
|
||||
console.log('[SelfPlay] Ran', pairings.length, 'matches for', genomes.length, 'genomes');
|
||||
console.log('[SelfPlay] Sample fitness from first genome:', fitnessTrackers[0].totalFitness, '/', fitnessTrackers[0].matchCount);
|
||||
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;
|
||||
|
||||
// Average fitness across matches
|
||||
for (let i = 0; i < genomes.length; i++) {
|
||||
const tracker = fitnessTrackers[i];
|
||||
genomes[i].fitness = tracker.matchCount > 0
|
||||
const avg = tracker.matchCount > 0
|
||||
? tracker.totalFitness / tracker.matchCount
|
||||
: 0;
|
||||
}
|
||||
|
||||
return { ...population, genomes };
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate deterministic match pairings
|
||||
*/
|
||||
function generatePairings(
|
||||
populationSize: number,
|
||||
K: number,
|
||||
seed: number
|
||||
): MatchPairing[] {
|
||||
const pairings: MatchPairing[] = [];
|
||||
const rng = new SeededRandom(seed);
|
||||
|
||||
for (let i = 0; i < populationSize; i++) {
|
||||
for (let k = 0; k < K; k++) {
|
||||
// Pick a random opponent (not self)
|
||||
let opponentIndex;
|
||||
do {
|
||||
opponentIndex = rng.nextInt(0, populationSize);
|
||||
} while (opponentIndex === i);
|
||||
|
||||
// Random spawn pair (0-4)
|
||||
const spawnPairId = rng.nextInt(0, 5);
|
||||
|
||||
// Each match is played twice with swapped sides
|
||||
pairings.push({
|
||||
genome1Index: i,
|
||||
genome2Index: opponentIndex,
|
||||
spawnPairId,
|
||||
swapSides: false,
|
||||
});
|
||||
|
||||
pairings.push({
|
||||
genome1Index: i,
|
||||
genome2Index: opponentIndex,
|
||||
spawnPairId,
|
||||
swapSides: true,
|
||||
});
|
||||
genomes[i].fitness = avg;
|
||||
|
||||
if (avg > maxFitnessInBatch) {
|
||||
maxFitnessInBatch = avg;
|
||||
bestGenomeInBatch = genomes[i];
|
||||
}
|
||||
}
|
||||
|
||||
return pairings;
|
||||
// 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
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a single match between two genomes
|
||||
* 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,
|
||||
pairing: MatchPairing,
|
||||
config: MatchConfig
|
||||
config: MatchConfig,
|
||||
mapSeed: number,
|
||||
spawnPairId: number
|
||||
): { fitness1: number; fitness2: number } {
|
||||
// Swap genomes if needed for side fairness
|
||||
const g1 = pairing.swapSides ? genome2 : genome1;
|
||||
const g2 = pairing.swapSides ? genome1 : genome2;
|
||||
|
||||
// Create networks
|
||||
const network1 = createNetwork(g1);
|
||||
const network2 = createNetwork(g2);
|
||||
|
||||
// Create simulation
|
||||
let sim = createSimulation(config.mapSeed + pairing.spawnPairId, pairing.spawnPairId);
|
||||
|
||||
// Create fitness trackers
|
||||
let tracker1 = createFitnessTracker(0);
|
||||
let tracker2 = createFitnessTracker(1);
|
||||
|
||||
// Run simulation
|
||||
while (!sim.isOver && sim.tick < config.maxTicks) {
|
||||
// Get observations
|
||||
const obs1 = generateObservation(0, sim);
|
||||
const obs2 = generateObservation(1, sim);
|
||||
// Create networks (or mock networks for baselines)
|
||||
const createAgentController = (genome: Genome) => {
|
||||
let tick = 0;
|
||||
|
||||
// Get actions from networks
|
||||
// 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);
|
||||
|
||||
@@ -176,24 +278,16 @@ function runMatch(
|
||||
shoot: outputs2[3],
|
||||
};
|
||||
|
||||
// Step simulation
|
||||
sim = stepSimulation(sim, [action1, action2]);
|
||||
// Step
|
||||
currentSim = stepSimulation(currentSim, [action1, action2]);
|
||||
|
||||
// Update fitness
|
||||
tracker1 = updateFitness(tracker1, sim);
|
||||
tracker2 = updateFitness(tracker2, sim);
|
||||
}
|
||||
|
||||
// Swap fitness back if sides were swapped
|
||||
if (pairing.swapSides) {
|
||||
return {
|
||||
fitness1: tracker2.fitness,
|
||||
fitness2: tracker1.fitness,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
fitness1: tracker1.fitness,
|
||||
fitness2: tracker2.fitness,
|
||||
};
|
||||
// Update local trackers
|
||||
runningTracker1 = updateFitness(runningTracker1, currentSim);
|
||||
runningTracker2 = updateFitness(runningTracker2, currentSim);
|
||||
}
|
||||
|
||||
return {
|
||||
fitness1: runningTracker1.fitness,
|
||||
fitness2: runningTracker2.fitness,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -27,9 +27,15 @@ export function generateObservation(agentId: number, state: SimulationState): Ob
|
||||
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++) {
|
||||
const angle = i * angleStep;
|
||||
const ray = castRay(agent.position, angle, RAY_RANGE, state.map.walls, opponent);
|
||||
// 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);
|
||||
}
|
||||
|
||||
@@ -43,6 +49,25 @@ export function generateObservation(agentId: number, state: SimulationState): Ob
|
||||
|
||||
// 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,
|
||||
@@ -51,20 +76,23 @@ export function generateObservation(agentId: number, state: SimulationState): Ob
|
||||
aimSin,
|
||||
aimCos,
|
||||
cooldown,
|
||||
targetVisible,
|
||||
targetRelativeAngle,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Cast a single ray from origin in a direction, up to maxDist.
|
||||
*
|
||||
* Returns the closest hit: either wall, opponent, or nothing.
|
||||
* Returns the closest hit: either wall, opponent, bullet, or nothing.
|
||||
*/
|
||||
function castRay(
|
||||
origin: Vec2,
|
||||
angle: number,
|
||||
maxDist: number,
|
||||
walls: Wall[],
|
||||
opponent: Agent
|
||||
opponent: Agent,
|
||||
bullets: import('./types').Bullet[]
|
||||
): RayHit {
|
||||
const dir: Vec2 = {
|
||||
x: Math.cos(angle),
|
||||
@@ -77,7 +105,7 @@ function castRay(
|
||||
};
|
||||
|
||||
let closestDist = maxDist;
|
||||
let hitType: 'nothing' | 'wall' | 'opponent' = 'nothing';
|
||||
let hitType: 'nothing' | 'wall' | 'opponent' | 'bullet' = 'nothing';
|
||||
|
||||
// Check wall intersections
|
||||
for (const wall of walls) {
|
||||
@@ -95,6 +123,17 @@ function castRay(
|
||||
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,
|
||||
@@ -194,12 +233,22 @@ export function observationToInputs(obs: Observation): number[] {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -59,6 +59,8 @@ function createAgent(id: number, spawnPoint: Vec2): Agent {
|
||||
hits: 0,
|
||||
kills: 0,
|
||||
spawnPoint,
|
||||
health: SIMULATION_CONFIG.AGENT_HEALTH,
|
||||
maxHealth: SIMULATION_CONFIG.AGENT_HEALTH,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -74,17 +76,24 @@ export function stepSimulation(
|
||||
const newState = { ...state };
|
||||
newState.tick++;
|
||||
|
||||
// Update agents
|
||||
newState.agents = [
|
||||
updateAgent(state.agents[0], actions[0], state),
|
||||
updateAgent(state.agents[1], actions[1], state),
|
||||
];
|
||||
// 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.
|
||||
|
||||
// Update bullets
|
||||
newState.bullets = state.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);
|
||||
|
||||
@@ -104,7 +113,12 @@ export function stepSimulation(
|
||||
/**
|
||||
* Update a single agent
|
||||
*/
|
||||
function updateAgent(agent: Agent, action: AgentAction, state: SimulationState): 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 };
|
||||
@@ -148,7 +162,8 @@ function updateAgent(agent: Agent, action: AgentAction, state: SimulationState):
|
||||
newAgent.position.y = newY;
|
||||
|
||||
// Fire bullet
|
||||
if (action.shoot > 0.5 && newAgent.fireCooldown === 0) {
|
||||
// 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
|
||||
@@ -168,7 +183,7 @@ function updateAgent(agent: Agent, action: AgentAction, state: SimulationState):
|
||||
ttl: SIMULATION_CONFIG.BULLET_TTL,
|
||||
};
|
||||
|
||||
state.bullets.push(bullet);
|
||||
bulletSink.push(bullet);
|
||||
}
|
||||
|
||||
return newAgent;
|
||||
@@ -216,17 +231,23 @@ function checkCollisions(state: SimulationState): void {
|
||||
// Hit!
|
||||
bulletsToRemove.add(bullet.id);
|
||||
|
||||
// Update scores
|
||||
agent.hits++;
|
||||
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.invulnTicks = SIMULATION_CONFIG.RESPAWN_INVULN_TICKS;
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,7 +82,10 @@ export function compatibilityDistance(
|
||||
}
|
||||
|
||||
// Normalize by number of genes in larger genome
|
||||
const N = Math.max(genome1.connections.length, genome2.connections.length, 1);
|
||||
// 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;
|
||||
@@ -157,9 +160,9 @@ export function speciate(
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[Speciation] Threshold: ${compatibilityThreshold.toFixed(2)}, Species formed: ${newSpecies.length}`);
|
||||
// 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));
|
||||
// console.log(`[Speciation] Species sizes:`, newSpecies.map(s => s.members.length));
|
||||
}
|
||||
|
||||
return newSpecies;
|
||||
@@ -174,14 +177,33 @@ export function adjustCompatibilityThreshold(
|
||||
targetMin: number = 6,
|
||||
targetMax: number = 10
|
||||
): number {
|
||||
const adjustmentRate = 0.1;
|
||||
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, make threshold more lenient
|
||||
return currentThreshold + adjustmentRate;
|
||||
} else if (currentSpeciesCount > targetMax) {
|
||||
// Too many species, make threshold stricter
|
||||
// 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;
|
||||
|
||||
89
src/lib/neatArena/speciation_debug.test.ts
Normal file
89
src/lib/neatArena/speciation_debug.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
80
src/lib/neatArena/stagnation_check.test.ts
Normal file
80
src/lib/neatArena/stagnation_check.test.ts
Normal 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
|
||||
});
|
||||
@@ -36,6 +36,7 @@ self.onmessage = async (e: MessageEvent<TrainingWorkerMessage>) => {
|
||||
switch (message.type) {
|
||||
case 'init':
|
||||
if (message.config) {
|
||||
console.log('[Worker] Initializing v11 (Dynamic Maps)...');
|
||||
config = message.config;
|
||||
population = createPopulation(config);
|
||||
sendUpdate();
|
||||
@@ -96,8 +97,8 @@ async function runSingleGeneration(): Promise<ReturnType<typeof getPopulationSta
|
||||
|
||||
console.log('[Worker] Starting generation', population.generation);
|
||||
|
||||
// Evaluate population
|
||||
const evaluatedPop = evaluatePopulation(population, DEFAULT_MATCH_CONFIG);
|
||||
// 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);
|
||||
@@ -105,14 +106,15 @@ async function runSingleGeneration(): Promise<ReturnType<typeof getPopulationSta
|
||||
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');
|
||||
|
||||
// IMPORTANT: Send stats from the EVALUATED population, not the evolved one
|
||||
// (evolved population has fitness reset to 0)
|
||||
return getPopulationStats(evaluatedPop);
|
||||
return stats;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
67
src/lib/neatArena/tuning.test.ts
Normal file
67
src/lib/neatArena/tuning.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -70,6 +70,10 @@ export interface Agent {
|
||||
|
||||
/** Assigned spawn point */
|
||||
spawnPoint: Vec2;
|
||||
|
||||
/** Current Health */
|
||||
health: number;
|
||||
maxHealth: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -146,7 +150,7 @@ export interface RayHit {
|
||||
distance: number;
|
||||
|
||||
/** What the ray hit */
|
||||
hitType: 'nothing' | 'wall' | 'opponent';
|
||||
hitType: 'nothing' | 'wall' | 'opponent' | 'bullet';
|
||||
}
|
||||
|
||||
export interface Observation {
|
||||
@@ -163,6 +167,12 @@ export interface Observation {
|
||||
|
||||
/** 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;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -178,23 +188,27 @@ export const SIMULATION_CONFIG = {
|
||||
DT: 1 / 30,
|
||||
|
||||
/** Episode termination */
|
||||
MAX_TICKS: 600, // 20 seconds
|
||||
MAX_TICKS: 900, // 30 seconds (Increased from 20/10)
|
||||
KILLS_TO_WIN: 5,
|
||||
|
||||
/** Agent physics */
|
||||
AGENT_RADIUS: 8,
|
||||
AGENT_RADIUS: 12, // Increased (was 10) to catch fast bullets
|
||||
AGENT_MAX_SPEED: 120, // units/sec
|
||||
AGENT_TURN_RATE: 270 * (Math.PI / 180), // rad/sec
|
||||
AGENT_TURN_RATE: 400 * (Math.PI / 180), // rad/sec
|
||||
|
||||
/** Respawn */
|
||||
RESPAWN_INVULN_TICKS: 15, // 0.5 seconds
|
||||
|
||||
/** Bullet physics */
|
||||
BULLET_SPEED: 260, // units/sec
|
||||
BULLET_SPEED: 600, // units/sec (Max safe speed without CCD)
|
||||
BULLET_TTL: 60, // 2 seconds
|
||||
FIRE_COOLDOWN: 10, // ~0.33 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,
|
||||
|
||||
Reference in New Issue
Block a user