Felladrin's picture
Add the simulator files
17fd0c2 verified
import { NeuralNetwork } from "./neuralNetwork.js";
import { randomRange, randomGaussian, clamp } from "./utils.js";
/** Neural network topology shared by all entities */
export const NET_TOPOLOGY = [14, 12, 8];
/** Entity archetype definitions */
export const ENTITY_TYPES = {
gatherer: {
name: "Gatherer",
baseTraits: {
speed: 2.5,
size: 8,
vision: 120,
attack: 0.2,
defense: 0.5,
metabolism: 0.8,
},
color: "#00ff88",
fitnessWeights: { survival: 1.0, energy: 2.0, offspring: 1.5, kills: 0.0 },
description: "Herbivores that seek food resources",
},
predator: {
name: "Predator",
baseTraits: {
speed: 3.5,
size: 10,
vision: 140,
attack: 1.5,
defense: 0.3,
metabolism: 1.2,
},
color: "#ff3366",
fitnessWeights: { survival: 0.5, energy: 1.0, offspring: 1.0, kills: 2.5 },
description: "Carnivores that hunt other entities",
},
builder: {
name: "Builder",
baseTraits: {
speed: 1.8,
size: 9,
vision: 100,
attack: 0.1,
defense: 1.5,
metabolism: 0.6,
},
color: "#3399ff",
fitnessWeights: { survival: 2.0, energy: 1.5, offspring: 2.0, kills: 0.0 },
description: "Defensive entities that build and fortify",
},
explorer: {
name: "Explorer",
baseTraits: {
speed: 4.0,
size: 6,
vision: 180,
attack: 0.3,
defense: 0.3,
metabolism: 1.0,
},
color: "#ffaa00",
fitnessWeights: { survival: 1.5, energy: 1.0, offspring: 1.0, kills: 0.5 },
description: "Fast scouts with wide vision range",
},
hybrid: {
name: "Hybrid",
baseTraits: {
speed: 3.0,
size: 8,
vision: 130,
attack: 0.8,
defense: 0.8,
metabolism: 1.0,
},
color: "#cc66ff",
fitnessWeights: { survival: 1.0, energy: 1.0, offspring: 1.5, kills: 1.0 },
description: "Balanced generalists that adapt to conditions",
},
};
/** Trait constraints to prevent runaway evolution */
const TRAIT_BOUNDS = {
speed: { min: 0.5, max: 8.0 },
size: { min: 2.0, max: 14.0 },
vision: { min: 30, max: 250 },
attack: { min: 0.0, max: 3.0 },
defense: { min: 0.0, max: 3.0 },
metabolism: { min: 0.2, max: 2.5 },
};
/**
* Genome encapsulates an entity's heritable traits and neural weights.
*/
export class Genome {
/**
* @param {string} type - Entity archetype key
* @param {object} [traits] - Physical traits (or auto-generated)
* @param {number[]} [neuralWeights] - Flat weight array (or auto-generated)
*/
constructor(type, traits = null, neuralWeights = null) {
this.type = type;
const base = ENTITY_TYPES[type].baseTraits;
if (traits) {
this.traits = { ...traits };
} else {
this.traits = {};
for (const key of Object.keys(base)) {
const variance = base[key] * 0.15;
this.traits[key] = clamp(
base[key] + randomGaussian(0, variance),
TRAIT_BOUNDS[key].min,
TRAIT_BOUNDS[key].max,
);
}
}
if (neuralWeights) {
this.neuralWeights = neuralWeights.slice();
} else {
const tempNet = new NeuralNetwork(NET_TOPOLOGY);
this.neuralWeights = tempNet.serialize();
}
/** Communication symbol preference (0-7), evolvable */
this.signalPreference = traits
? traits._signal || Math.floor(Math.random() * 8)
: Math.floor(Math.random() * 8);
}
/** Create a brain (NeuralNetwork) from this genome's stored weights */
buildBrain() {
const nn = new NeuralNetwork(NET_TOPOLOGY);
nn.deserialize(this.neuralWeights);
return nn;
}
/** Deep copy */
clone() {
const g = new Genome(
this.type,
{ ...this.traits },
this.neuralWeights.slice(),
);
g.signalPreference = this.signalPreference;
return g;
}
/**
* Single-point crossover of two genomes.
* Child inherits type from the fitter parent.
* @param {Genome} other
* @param {string} [childType] - Override child type
* @returns {Genome}
*/
crossover(other, childType = null) {
const type = childType || this.type;
const childTraits = {};
for (const key of Object.keys(this.traits)) {
if (key === "_signal") continue;
const t = Math.random();
const raw = this.traits[key] * t + other.traits[key] * (1 - t);
childTraits[key] = clamp(
raw,
TRAIT_BOUNDS[key].min,
TRAIT_BOUNDS[key].max,
);
}
const w1 = this.neuralWeights;
const w2 = other.neuralWeights;
const len = Math.min(w1.length, w2.length);
const childWeights = new Array(len);
const crossPoint = Math.floor(Math.random() * len);
for (let i = 0; i < len; i++) {
childWeights[i] = i < crossPoint ? w1[i] : w2[i];
}
const child = new Genome(type, childTraits, childWeights);
child.signalPreference =
Math.random() < 0.5 ? this.signalPreference : other.signalPreference;
return child;
}
/**
* Apply mutations to traits and weights.
* @param {number} traitRate - Probability per trait (e.g. 0.3)
* @param {number} weightRate - Probability per weight (e.g. 0.05)
* @param {number} magnitude - Gaussian std for weight mutation
*/
mutate(traitRate = 0.3, weightRate = 0.05, magnitude = 0.3) {
const base = ENTITY_TYPES[this.type].baseTraits;
for (const key of Object.keys(this.traits)) {
if (key === "_signal") continue;
if (Math.random() < traitRate) {
const scale = base[key] * 0.1;
this.traits[key] = clamp(
this.traits[key] + randomGaussian(0, scale),
TRAIT_BOUNDS[key].min,
TRAIT_BOUNDS[key].max,
);
}
}
for (let i = 0; i < this.neuralWeights.length; i++) {
if (Math.random() < weightRate) {
this.neuralWeights[i] += randomGaussian(0, magnitude);
}
}
if (Math.random() < 0.05) {
this.signalPreference = Math.floor(Math.random() * 8);
}
}
/** Serialize to a plain object (for LocalStorage) */
toJSON() {
return {
type: this.type,
traits: { ...this.traits },
neuralWeights: this.neuralWeights,
signalPreference: this.signalPreference,
};
}
/** Deserialize from a plain object */
static fromJSON(data) {
const g = new Genome(data.type, data.traits, data.neuralWeights);
g.signalPreference = data.signalPreference || 0;
return g;
}
}
/**
* Create a new random genome of a given type.
* @param {string} type - Archetype key
* @returns {Genome}
*/
export function createGenome(type) {
return new Genome(type);
}
/**
* Tournament selection: pick the fittest from a random subset.
* @param {Array<{genome: Genome, fitness: number}>} population
* @param {number} tournamentSize
* @returns {Genome}
*/
export function tournamentSelect(population, tournamentSize = 3) {
let best = null;
let bestFitness = -Infinity;
for (let i = 0; i < tournamentSize; i++) {
const idx = Math.floor(Math.random() * population.length);
const candidate = population[idx];
if (candidate.fitness > bestFitness) {
best = candidate;
bestFitness = candidate.fitness;
}
}
return best.genome;
}
/**
* Calculate fitness score for an entity's lifetime stats.
* @param {string} type - Entity type
* @param {object} stats - { survivalTime, energyGathered, offspring, kills }
* @returns {number}
*/
export function calculateFitness(type, stats) {
const w = ENTITY_TYPES[type].fitnessWeights;
return (
w.survival * stats.survivalTime +
w.energy * stats.energyGathered +
w.offspring * stats.offspring * 10 +
w.kills * stats.kills * 5
);
}
/**
* Measure genetic diversity as average pairwise trait distance.
* @param {Genome[]} genomes
* @returns {number} 0-1 normalized diversity score
*/
export function measureDiversity(genomes) {
if (genomes.length < 2) return 0;
const traitKeys = Object.keys(TRAIT_BOUNDS);
let totalDist = 0;
let pairs = 0;
const sampleSize = Math.min(genomes.length, 30);
for (let i = 0; i < sampleSize; i++) {
for (let j = i + 1; j < sampleSize; j++) {
let dist = 0;
for (const key of traitKeys) {
const range = TRAIT_BOUNDS[key].max - TRAIT_BOUNDS[key].min;
const diff =
Math.abs(genomes[i].traits[key] - genomes[j].traits[key]) / range;
dist += diff * diff;
}
totalDist += Math.sqrt(dist / traitKeys.length);
pairs++;
}
}
return pairs > 0 ? totalDist / pairs : 0;
}
/**
* Produce a child genome from the population using selection + crossover + mutation.
* @param {Array<{genome: Genome, fitness: number}>} population
* @returns {Genome}
*/
export function reproduce(population) {
const parent1 = tournamentSelect(population, 3);
const parent2 = tournamentSelect(population, 3);
const child = parent1.crossover(parent2);
child.mutate(0.25, 0.05, 0.2);
return child;
}