Skip to content
This repository was archived by the owner on Mar 13, 2026. It is now read-only.
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions docs/firestore_schema.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# EcosysX Firestore Schema

## collection: `simulations`
- **docId:** `world-01`
- **fields:**
- `tickCount`: Number
- `isRunning`: Boolean
- `worldSize`: Map `{ width: Number, height: Number }`
- `config`: Map (holds parameters like mutation rates, energy costs, etc.)

## collection: `agents`
- **docId:** `[agentId]`
- **fields:**
- `simulationId`: String
- `speciesType`: String ("HERBIVORE" or "CARNIVORE")
- `isAlive`: Boolean
- `age`: Number
- `energy`: Number
- `location`: GeoPoint `{ x: Number, y: Number }`
- `state`: String (e.g., "FORAGING", "FLEEING", "SEEKING_MATE")
- `genome`: Map `{ genes: Array<Number> }`
- `phenotype`: Map `{ size: Number, color: String, speed: Number, sensoryRange: Number, isPredator: Boolean }`

## collection: `environment`
- **docId:** `world-01-terrain`
- **fields:**
- `grid`: Array<Number> (A flattened 2D array representing terrain cost per cell)
- `dimensions`: Map `{ width: Number, height: Number }`
9 changes: 9 additions & 0 deletions firestore.rules
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /{document=**} {
allow read: if true;
allow write: if request.auth != null;
}
}
}
130 changes: 130 additions & 0 deletions functions/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
const functions = require('firebase-functions');
const admin = require('firebase-admin');
admin.initializeApp();
const db = admin.firestore();

const agentStates = {
FORAGING: (agent, world) => {
const nearestPredator = findNearest(agent, world.agents.filter(a => a.phenotype.isPredator), agent.phenotype.sensoryRange);
if (nearestPredator) {
agent.state = 'FLEEING';
return;
}
const target = findNearest(agent, world.resources, agent.phenotype.sensoryRange);
moveTowards(agent, target, agent.phenotype.speed, world.worldSize);
},
FLEEING: (agent, world) => {
const nearestPredator = findNearest(agent, world.agents.filter(a => a.phenotype.isPredator), agent.phenotype.sensoryRange);
if (nearestPredator) {
moveAwayFrom(agent, nearestPredator, agent.phenotype.speed, world.worldSize);
} else {
agent.state = 'FORAGING';
}
},
HUNTING: (agent, world) => {
const target = findNearest(agent, world.agents.filter(a => !a.phenotype.isPredator), agent.phenotype.sensoryRange);
moveTowards(agent, target, agent.phenotype.speed, world.worldSize);
}
};

function findNearest(agent, targets, range) {
let minDist = Infinity;
let nearest = null;
for (const t of targets) {
const dist = Math.hypot(agent.location.x - t.location.x, agent.location.y - t.location.y);
if (dist < minDist && dist <= range) {
minDist = dist;
nearest = t;
}
}
return nearest;
}

function moveTowards(agent, target, speed, worldSize) {
if (!target) return;
const dx = target.location.x - agent.location.x;
const dy = target.location.y - agent.location.y;
const len = Math.hypot(dx, dy) || 1;
agent.location.x = Math.min(worldSize.width, Math.max(0, agent.location.x + (dx / len) * speed));
agent.location.y = Math.min(worldSize.height, Math.max(0, agent.location.y + (dy / len) * speed));
}

function moveAwayFrom(agent, threat, speed, worldSize) {
if (!threat) return;
const dx = agent.location.x - threat.location.x;
const dy = agent.location.y - threat.location.y;
const len = Math.hypot(dx, dy) || 1;
agent.location.x = Math.min(worldSize.width, Math.max(0, agent.location.x + (dx / len) * speed));
agent.location.y = Math.min(worldSize.height, Math.max(0, agent.location.y + (dy / len) * speed));
}

exports.simulationTick = functions.runWith({ timeoutSeconds: 300, memory: '1GB' }).pubsub.schedule('every 1 minutes').onRun(async () => {
const simRef = db.collection('simulations').doc('world-01');
const simDoc = await simRef.get();
if (!simDoc.exists || !simDoc.data().isRunning) return null;

const config = simDoc.data().config;
const agentDocs = await db.collection('agents').get();
const resourceDocs = await db.collection('resources').get();

const world = {
agents: agentDocs.docs.map(doc => ({ id: doc.id, ...doc.data() })),
resources: resourceDocs.docs.map(doc => ({ id: doc.id, ...doc.data() })),
worldSize: config.worldSize
};

const batch = db.batch();

world.agents.forEach(agent => {
if (!agent.isAlive) return;

agent.energy -= (config.metabolicCostBase + (agent.phenotype.speed * config.metabolicCostSpeedFactor));
agent.age += 1;

if (agent.energy <= 0 || agent.age > agent.phenotype.maxLifespan) {
agent.isAlive = false;
batch.delete(db.collection('agents').doc(agent.id));
return;
}

const stateLogic = agent.speciesType === 'CARNIVORE' ? agentStates.HUNTING : agentStates[agent.state];
if (stateLogic) stateLogic(agent, world);

if (agent.speciesType === 'HERBIVORE') {
const eatenIndex = world.resources.findIndex(r => Math.hypot(agent.location.x - r.location.x, agent.location.y - r.location.y) < agent.phenotype.size);
if (eatenIndex > -1) {
agent.energy += world.resources[eatenIndex].energyValue;
batch.delete(db.collection('resources').doc(world.resources[eatenIndex].id));
world.resources.splice(eatenIndex, 1);
}
} else {
const preyIndex = world.agents.findIndex(p => p.isAlive && p.speciesType === 'HERBIVORE' && Math.hypot(agent.location.x - p.location.x, agent.location.y - p.location.y) < agent.phenotype.size);
if (preyIndex > -1) {
agent.energy += world.agents[preyIndex].energy;
world.agents[preyIndex].isAlive = false;
batch.delete(db.collection('agents').doc(world.agents[preyIndex].id));
}
}

if (agent.energy > config.reproEnergyCost) {
agent.energy -= config.reproEnergyCost;
// placeholder for reproduction logic
}

batch.update(db.collection('agents').doc(agent.id), { location: agent.location, energy: agent.energy, age: agent.age, state: agent.state });
});

if (Math.random() < config.foodReplenishRate && world.resources.length < config.maxResources) {
const newResId = db.collection('resources').doc().id;
batch.set(db.collection('resources').doc(newResId), {
location: { x: Math.random() * world.worldSize.width, y: Math.random() * world.worldSize.height },
energyValue: config.foodEnergyValue
});
}

batch.update(simRef, { tickCount: admin.firestore.FieldValue.increment(1), populationSize: world.agents.filter(a => a.isAlive).length });

await batch.commit();
console.log('Tick complete.');
return null;
});
40 changes: 40 additions & 0 deletions functions/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'

admin.initializeApp()
const db = admin.firestore()

export const calculatePlayerRewards = functions.firestore
.document('simulations/{simId}')
.onUpdate(async (change, context) => {
const beforeData = change.before.data() || {}
const afterData = change.after.data() || {}

const noveltyEventsBefore = beforeData.majorNoveltyCount || 0
const noveltyEventsAfter = afterData.majorNoveltyCount || 0

if (noveltyEventsAfter > noveltyEventsBefore) {
const logs = await db
.collection('intervention_logs')
.where('simulationId', '==', context.params.simId)
.where('timestamp', '<', afterData.lastNoveltyTimestamp)
.orderBy('timestamp', 'desc')
.limit(1)
.get()

if (!logs.empty) {
const interventionData = logs.docs[0].data()
const playerId = interventionData.playerId

const points = 1000
const playerRef = db.collection('players').doc(playerId)

await db.runTransaction(async tx => {
const playerDoc = await tx.get(playerRef)
const newScore = (playerDoc.data()?.score || 0) + points
tx.update(playerRef, { score: newScore })
})
}
}
return null
})
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,9 @@
"tailwindcss-animate": "^1.0.7",
"three": "0.168.0",
"xlsx": "^0.18.5",
"zod": "^3.24.2"
"zod": "^3.24.2",
"firebase-admin": "^12.0.0",
"firebase-functions": "^4.4.1"
},
"devDependencies": {
"@types/js-yaml": "^4.0.9",
Expand Down
12 changes: 12 additions & 0 deletions public/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>EcosysX Real-Time Viewer</title>
<style> body { margin: 0; overflow: hidden; background: #333; } canvas { display: block; border: 1px solid white; } </style>
</head>
<body>
<canvas id="world-canvas"></canvas>
<script type="module" src="main.js"></script>
</body>
</html>
44 changes: 44 additions & 0 deletions public/main.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { initializeApp } from "https://www.gstatic.com/firebasejs/9.15.0/firebase-app.js";
import { getFirestore, collection, onSnapshot } from "https://www.gstatic.com/firebasejs/9.15.0/firebase-firestore.js";

const firebaseConfig = { /* YOUR FIREBASE CONFIG HERE */ };
const app = initializeApp(firebaseConfig);
const db = getFirestore(app);

const canvas = document.getElementById('world-canvas');
const ctx = canvas.getContext('2d');
canvas.width = 800;
canvas.height = 600;

let worldState = { agents: [], resources: [] };

onSnapshot(collection(db, 'agents'), (snapshot) => {
worldState.agents = snapshot.docs.map(doc => doc.data());
requestAnimationFrame(drawWorld);
});

onSnapshot(collection(db, 'resources'), (snapshot) => {
worldState.resources = snapshot.docs.map(doc => doc.data());
});

function drawWorld() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = 'lime';
worldState.resources.forEach(res => {
ctx.beginPath();
ctx.arc(res.location.x, res.location.y, 3, 0, 2 * Math.PI);
ctx.fill();
});

worldState.agents.forEach(agent => {
if (agent.isAlive) {
ctx.beginPath();
ctx.arc(agent.location.x, agent.location.y, agent.phenotype.size, 0, 2 * Math.PI);
ctx.fillStyle = agent.phenotype.color;
ctx.fill();
ctx.strokeStyle = `rgba(255, 255, 255, ${agent.energy / 500})`;
ctx.lineWidth = 2;
ctx.stroke();
}
});
}
27 changes: 27 additions & 0 deletions public/modules/Agent.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { Genome } from './Genome.js';

export class Agent {
constructor(id, config) {
this.id = id;
this.config = config;
Object.assign(this, config);
const genomeConfig = { length: config.genomeLength, h1: config.genome?.haplotype1, h2: config.genome?.haplotype2 };
this.genome = new Genome(genomeConfig);
this.phenotype = {};
this.decodePhenotype();
}

decodePhenotype() {
this.phenotype.speed = 1 + (this.genome.getEffectiveGene(0) * 4);
this.phenotype.sensoryRange = 30 + (this.genome.getEffectiveGene(1) * 120);
this.phenotype.size = this.speciesType === 'CARNIVORE' ? 7 : 5;
const r = this.speciesType === 'CARNIVORE' ? 200 : 50;
const g = Math.floor(this.genome.getEffectiveGene(3) * 150);
const b = 50;
this.phenotype.color = `rgb(${r},${g},${b})`;
}

update(worldState) {
// Behavior handled in cloud function state machine
}
}
29 changes: 29 additions & 0 deletions public/modules/Genome.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
export class Genome {
constructor(config) {
this.haplotype1 = config.h1 || Array.from({ length: config.length }, () => Math.random());
this.haplotype2 = config.h2 || Array.from({ length: config.length }, () => Math.random());
this.length = config.length;
}

getEffectiveGene(index) {
return (this.haplotype1[index] + this.haplotype2[index]) / 2;
}

static crossover(p1, p2) {
const crossoverPoint = Math.floor(Math.random() * (p1.length - 1)) + 1;
const off1_h1 = [...p1.haplotype1.slice(0, crossoverPoint), ...p2.haplotype1.slice(crossoverPoint)];
const off1_h2 = [...p1.haplotype2.slice(0, crossoverPoint), ...p2.haplotype2.slice(crossoverPoint)];
return new Genome({ length: p1.length, h1: off1_h1, h2: off1_h2 });
}

getMutatedCopy(mutationRate) {
const mutate = (haplotype) => haplotype.map(gene => {
if (Math.random() < mutationRate) {
const mutation = (Math.random() - 0.5) * 0.1;
return Math.max(0, Math.min(1, gene + mutation));
}
return gene;
});
return new Genome({ length: this.length, h1: mutate(this.haplotype1), h2: mutate(this.haplotype2) });
}
}
38 changes: 38 additions & 0 deletions src/components/intervention/control-panel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
'use client'

import React from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'

interface ControlPanelProps {
onTriggerCatastrophe?: () => void
onAlterEnvironment?: () => void
onSeedLife?: () => void
}

const ControlPanel: React.FC<ControlPanelProps> = ({
onTriggerCatastrophe,
onAlterEnvironment,
onSeedLife,
}) => {
return (
<Card className="w-full max-w-md shadow-xl">
<CardHeader>
<CardTitle className="text-xl text-center">Interventions</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<Button className="w-full" variant="destructive" onClick={onTriggerCatastrophe}>
Trigger Catastrophe
</Button>
<Button className="w-full" variant="outline" onClick={onAlterEnvironment}>
Alter Environment
</Button>
<Button className="w-full" variant="secondary" onClick={onSeedLife}>
Seed Life
</Button>
</CardContent>
</Card>
)
}

export default ControlPanel
17 changes: 17 additions & 0 deletions src/types/ecosys.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
export interface Player {
id: string
score: number
}

export interface Simulation {
majorNoveltyCount?: number
lastNoveltyTimestamp?: number
}

export interface InterventionLog {
playerId: string
simulationId: string
timestamp: number
actionType: string
parameters?: Record<string, unknown>
}