canvas-website/src/open-mapping/discovery/spores.ts

833 lines
22 KiB
TypeScript

/**
* Spore and Mycelium Growth System
*
* Integrates the mycelium network with the discovery game system.
* Players can plant spores at discovered locations, growing networks
* that produce fruiting bodies when they connect.
*/
import type {
Spore,
SporeType,
PlantedSpore,
FruitingBody,
FruitingBodyType,
DiscoveryReward,
GameEvent,
GameEventListener,
} from './types';
import type { GeohashCommitment } from '../privacy/types';
import type { MyceliumNode, Hypha, Signal, NodeType, HyphaType } from '../mycelium/types';
import { MyceliumNetwork, createMyceliumNetwork } from '../mycelium';
// =============================================================================
// Spore Configuration
// =============================================================================
/**
* Configuration for the spore system
*/
export interface SporeSystemConfig {
/** Base growth rate (units per tick) */
baseGrowthRate: number;
/** Nutrient decay rate per tick */
nutrientDecayRate: number;
/** Distance threshold for spore connection */
connectionDistance: number;
/** Minimum network nodes to spawn fruiting body */
minNodesForFruit: number;
/** Fruiting body spawn chance when conditions met */
fruitSpawnChance: number;
/** Maximum active spores per player */
maxSporesPerPlayer: number;
/** Tick interval in milliseconds */
tickInterval: number;
}
/**
* Default configuration
*/
export const DEFAULT_SPORE_CONFIG: SporeSystemConfig = {
baseGrowthRate: 1,
nutrientDecayRate: 0.1,
connectionDistance: 100, // meters
minNodesForFruit: 3,
fruitSpawnChance: 0.3,
maxSporesPerPlayer: 10,
tickInterval: 60000, // 1 minute
};
// =============================================================================
// Spore Templates
// =============================================================================
/**
* Pre-defined spore templates
*/
export const SPORE_TEMPLATES: Record<SporeType, Omit<Spore, 'id'>> = {
explorer: {
type: 'explorer',
growthRate: 1.5,
maxReach: 150,
nutrientCapacity: 100,
properties: {
revealRadius: 50,
speedBoost: 1.2,
},
visual: {
color: '#4ade80',
pattern: 'radial',
},
},
connector: {
type: 'connector',
growthRate: 0.8,
maxReach: 300,
nutrientCapacity: 150,
properties: {
connectionStrength: 2,
signalBoost: 1.5,
},
visual: {
color: '#818cf8',
pattern: 'branching',
},
},
amplifier: {
type: 'amplifier',
growthRate: 0.5,
maxReach: 50,
nutrientCapacity: 200,
properties: {
signalAmplification: 3,
rangeBoost: 2,
},
visual: {
color: '#fbbf24',
pattern: 'spiral',
},
},
guardian: {
type: 'guardian',
growthRate: 0.3,
maxReach: 75,
nutrientCapacity: 300,
properties: {
protectionRadius: 100,
decayResistance: 5,
},
visual: {
color: '#f472b6',
pattern: 'clustered',
},
},
harvester: {
type: 'harvester',
growthRate: 0.6,
maxReach: 100,
nutrientCapacity: 120,
properties: {
yieldMultiplier: 2,
harvestSpeed: 1.5,
},
visual: {
color: '#a78bfa',
pattern: 'branching',
},
},
temporal: {
type: 'temporal',
growthRate: 1.0,
maxReach: 80,
nutrientCapacity: 80,
properties: {
timeShift: 30, // minutes
phaseChance: 0.1,
},
visual: {
color: '#67e8f9',
pattern: 'spiral',
},
},
social: {
type: 'social',
growthRate: 0.7,
maxReach: 200,
nutrientCapacity: 100,
properties: {
groupBonus: 1.5,
connectionRange: 50,
},
visual: {
color: '#fb923c',
pattern: 'radial',
},
},
};
// =============================================================================
// Spore Manager
// =============================================================================
/**
* Manages spore planting and mycelium growth
*/
export class SporeManager {
private config: SporeSystemConfig;
private network: MyceliumNetwork;
private plantedSpores: Map<string, PlantedSpore> = new Map();
private fruitingBodies: Map<string, FruitingBody> = new Map();
private playerSporeCount: Map<string, number> = new Map();
private listeners: Set<GameEventListener> = new Set();
private tickTimer: ReturnType<typeof setInterval> | null = null;
constructor(config: Partial<SporeSystemConfig> = {}) {
this.config = { ...DEFAULT_SPORE_CONFIG, ...config };
this.network = createMyceliumNetwork();
}
// ===========================================================================
// Spore Planting
// ===========================================================================
/**
* Create a spore from template
*/
createSpore(type: SporeType): Spore {
const template = SPORE_TEMPLATES[type];
return {
...template,
id: `spore-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
};
}
/**
* Plant a spore at a location
*/
async plantSpore(params: {
spore: Spore;
locationCommitment: GeohashCommitment;
planterPubKey: string;
}): Promise<{ success: boolean; planted?: PlantedSpore; error?: string }> {
// Check player spore limit
const currentCount = this.playerSporeCount.get(params.planterPubKey) ?? 0;
if (currentCount >= this.config.maxSporesPerPlayer) {
return {
success: false,
error: `Maximum ${this.config.maxSporesPerPlayer} active spores allowed`,
};
}
// Create mycelium node at location
const node = this.network.addNode({
type: this.sporeTypeToNodeType(params.spore.type),
position: this.geohashToPosition(params.locationCommitment.geohash),
strength: params.spore.nutrientCapacity / 100,
data: {
sporeId: params.spore.id,
planterPubKey: params.planterPubKey,
sporeType: params.spore.type,
},
});
// Create planted spore record
const planted: PlantedSpore = {
id: `planted-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
spore: params.spore,
locationCommitment: params.locationCommitment,
planterPubKey: params.planterPubKey,
plantedAt: new Date(),
nutrients: params.spore.nutrientCapacity,
nodeId: node.id,
hyphaIds: [],
};
this.plantedSpores.set(planted.id, planted);
this.playerSporeCount.set(params.planterPubKey, currentCount + 1);
this.emit({ type: 'spore:planted', spore: planted });
// Check for nearby spores to connect
this.attemptConnections(planted);
return { success: true, planted };
}
/**
* Attempt to connect a newly planted spore with nearby ones
*/
private attemptConnections(planted: PlantedSpore): void {
const plantedPosition = this.geohashToPosition(planted.locationCommitment.geohash);
for (const [id, other] of this.plantedSpores.entries()) {
if (id === planted.id) continue;
if (other.nutrients <= 0) continue;
const otherPosition = this.geohashToPosition(other.locationCommitment.geohash);
const distance = this.calculateDistance(plantedPosition, otherPosition);
// Check if within connection range
const maxRange = Math.min(planted.spore.maxReach, other.spore.maxReach);
if (distance <= maxRange) {
// Create hypha connection
const hypha = this.network.addHypha({
type: this.getHyphaType(planted.spore.type, other.spore.type),
fromId: planted.nodeId,
toId: other.nodeId,
strength: 0.5,
data: {
plantedSporeIds: [planted.id, other.id],
},
});
planted.hyphaIds.push(hypha.id);
other.hyphaIds.push(hypha.id);
// Check for fruiting body conditions
this.checkFruitingConditions(planted);
}
}
}
/**
* Map spore type to mycelium node type
*/
private sporeTypeToNodeType(sporeType: SporeType): NodeType {
const mapping: Record<SporeType, NodeType> = {
explorer: 'discovery',
connector: 'waypoint',
amplifier: 'poi',
guardian: 'cluster',
harvester: 'resource',
temporal: 'event',
social: 'person',
};
return mapping[sporeType];
}
/**
* Get hypha type based on connected spore types
*/
private getHyphaType(type1: SporeType, type2: SporeType): HyphaType {
if (type1 === 'social' || type2 === 'social') return 'social';
if (type1 === 'temporal' || type2 === 'temporal') return 'temporal';
if (type1 === 'connector' || type2 === 'connector') return 'route';
return 'proximity';
}
// ===========================================================================
// Fruiting Bodies
// ===========================================================================
/**
* Check if conditions are met for a fruiting body
*/
private checkFruitingConditions(spore: PlantedSpore): void {
// Find all connected spores
const connected = this.findConnectedSpores(spore.id);
if (connected.length >= this.config.minNodesForFruit) {
// Random chance to spawn
if (Math.random() < this.config.fruitSpawnChance) {
this.spawnFruitingBody(connected);
}
}
}
/**
* Find all spores connected to a given spore
*/
private findConnectedSpores(sporeId: string): PlantedSpore[] {
const connected: PlantedSpore[] = [];
const visited = new Set<string>();
const queue = [sporeId];
while (queue.length > 0) {
const currentId = queue.shift()!;
if (visited.has(currentId)) continue;
visited.add(currentId);
const spore = this.plantedSpores.get(currentId);
if (!spore || spore.nutrients <= 0) continue;
connected.push(spore);
// Find connections via hyphae
for (const hyphaId of spore.hyphaIds) {
const hypha = this.network.getHypha(hyphaId);
if (hypha) {
// Find the other node
const otherNodeId =
hypha.fromId === spore.nodeId ? hypha.toId : hypha.fromId;
// Find spore by node ID
for (const [id, s] of this.plantedSpores.entries()) {
if (s.nodeId === otherNodeId && !visited.has(id)) {
queue.push(id);
}
}
}
}
}
return connected;
}
/**
* Spawn a fruiting body from connected spores
*/
private spawnFruitingBody(spores: PlantedSpore[]): FruitingBody {
// Determine fruiting body type based on spore composition
const type = this.determineFruitType(spores);
// Calculate center position
const centerGeohash = this.calculateCenterGeohash(spores);
// Collect contributors
const contributors = [...new Set(spores.map((s) => s.planterPubKey))];
// Generate rewards based on type and contributor count
const rewards = this.generateFruitRewards(type, spores.length, contributors.length);
// Calculate decay time based on fruit type
const lifespanMinutes = this.getFruitLifespan(type);
const now = new Date();
const fruit: FruitingBody = {
id: `fruit-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
type,
locationCommitment: {
geohash: centerGeohash,
hash: '', // Would be calculated
timestamp: now,
precision: 7,
},
sourceSporeIds: spores.map((s) => s.id),
harvestableRewards: rewards,
emergedAt: now,
decaysAt: new Date(now.getTime() + lifespanMinutes * 60 * 1000),
maturity: 0,
contributors,
};
this.fruitingBodies.set(fruit.id, fruit);
this.emit({ type: 'fruit:emerged', fruit });
// Notify network
this.network.emit({
id: `signal-fruit-${fruit.id}`,
type: 'discovery',
sourceId: spores[0].nodeId,
strength: 1,
timestamp: now,
data: { fruitId: fruit.id, type },
});
return fruit;
}
/**
* Determine fruiting body type from spore composition
*/
private determineFruitType(spores: PlantedSpore[]): FruitingBodyType {
const typeCounts: Record<SporeType, number> = {
explorer: 0,
connector: 0,
amplifier: 0,
guardian: 0,
harvester: 0,
temporal: 0,
social: 0,
};
for (const spore of spores) {
typeCounts[spore.spore.type]++;
}
// Legendary fruit: all different types
const uniqueTypes = Object.values(typeCounts).filter((c) => c > 0).length;
if (uniqueTypes >= 5) return 'giant';
// Temporal fruit: mostly temporal spores
if (typeCounts.temporal >= spores.length * 0.5) return 'temporal';
// Social/symbiotic: requires multiple contributors
const contributors = new Set(spores.map((s) => s.planterPubKey)).size;
if (contributors >= 3) return 'symbiotic';
// Bioluminescent: amplifier dominant
if (typeCounts.amplifier >= spores.length * 0.4) return 'bioluminescent';
// Cluster: guardian dominant
if (typeCounts.guardian >= spores.length * 0.4) return 'cluster';
return 'common';
}
/**
* Generate rewards for a fruiting body
*/
private generateFruitRewards(
type: FruitingBodyType,
sporeCount: number,
contributorCount: number
): DiscoveryReward[] {
const rewards: DiscoveryReward[] = [];
// Base rewards by type
const rewardConfig: Record<
FruitingBodyType,
{ type: DiscoveryReward['type']; rarity: DiscoveryReward['rarity']; quantity: number }
> = {
common: { type: 'spore', rarity: 'common', quantity: 2 },
cluster: { type: 'spore', rarity: 'uncommon', quantity: 3 },
giant: { type: 'collectible', rarity: 'epic', quantity: 1 },
bioluminescent: { type: 'hint', rarity: 'rare', quantity: 1 },
symbiotic: { type: 'points', rarity: 'rare', quantity: 100 * contributorCount },
temporal: { type: 'experience', rarity: 'uncommon', quantity: 50 },
};
const config = rewardConfig[type];
rewards.push({
type: config.type,
rewardId: `fruit-reward-${type}`,
quantity: config.quantity + Math.floor(sporeCount / 2),
rarity: config.rarity,
});
// Bonus for multiple contributors
if (contributorCount > 1) {
rewards.push({
type: 'points',
rewardId: 'collaboration-bonus',
quantity: 25 * contributorCount,
rarity: 'common',
});
}
return rewards;
}
/**
* Get lifespan for fruit type in minutes
*/
private getFruitLifespan(type: FruitingBodyType): number {
const lifespans: Record<FruitingBodyType, number> = {
common: 60, // 1 hour
cluster: 120, // 2 hours
giant: 360, // 6 hours
bioluminescent: 30, // 30 minutes (rare, must be quick)
symbiotic: 240, // 4 hours (need coordination)
temporal: 15, // 15 minutes (very brief)
};
return lifespans[type];
}
/**
* Harvest a fruiting body
*/
harvestFruit(
fruitId: string,
playerPubKey: string
): { success: boolean; rewards?: DiscoveryReward[]; error?: string } {
const fruit = this.fruitingBodies.get(fruitId);
if (!fruit) {
return { success: false, error: 'Fruiting body not found' };
}
const now = new Date();
if (now > fruit.decaysAt) {
this.fruitingBodies.delete(fruitId);
return { success: false, error: 'Fruiting body has decayed' };
}
if (fruit.maturity < 100) {
return { success: false, error: 'Fruiting body not mature yet' };
}
// Symbiotic fruits require a contributor to harvest
if (fruit.type === 'symbiotic' && !fruit.contributors.includes(playerPubKey)) {
return { success: false, error: 'Only contributors can harvest symbiotic fruits' };
}
// Collect rewards
const rewards = [...fruit.harvestableRewards];
// Remove fruit
this.fruitingBodies.delete(fruitId);
this.emit({ type: 'fruit:harvested', fruitId, playerId: playerPubKey });
return { success: true, rewards };
}
// ===========================================================================
// Growth Simulation
// ===========================================================================
/**
* Start the growth simulation
*/
startSimulation(): void {
if (this.tickTimer) return;
this.tickTimer = setInterval(() => {
this.tick();
}, this.config.tickInterval);
}
/**
* Stop the growth simulation
*/
stopSimulation(): void {
if (this.tickTimer) {
clearInterval(this.tickTimer);
this.tickTimer = null;
}
}
/**
* Process one simulation tick
*/
tick(): void {
const now = new Date();
// Update spore nutrients
for (const [id, spore] of this.plantedSpores.entries()) {
// Decay nutrients
spore.nutrients -= this.config.nutrientDecayRate;
// Check for death
if (spore.nutrients <= 0) {
this.removeSpore(id);
continue;
}
// Grow hyphae
this.growHyphae(spore);
}
// Mature fruiting bodies
for (const [id, fruit] of this.fruitingBodies.entries()) {
// Check decay
if (now > fruit.decaysAt) {
this.fruitingBodies.delete(id);
continue;
}
// Increase maturity
const ageMs = now.getTime() - fruit.emergedAt.getTime();
const lifespanMs = fruit.decaysAt.getTime() - fruit.emergedAt.getTime();
fruit.maturity = Math.min(100, (ageMs / lifespanMs) * 100 * 2); // Mature at 50% lifespan
}
// Update network
this.network.propagateSignals(0.9);
}
/**
* Grow hyphae from a spore
*/
private growHyphae(spore: PlantedSpore): void {
const growthRate = spore.spore.growthRate * this.config.baseGrowthRate;
// Try to extend existing hyphae or create new connections
for (const hyphaId of spore.hyphaIds) {
const hypha = this.network.getHypha(hyphaId);
if (hypha) {
// Strengthen existing connection
hypha.strength = Math.min(1, hypha.strength + growthRate * 0.01);
}
}
}
/**
* Remove a dead spore
*/
private removeSpore(sporeId: string): void {
const spore = this.plantedSpores.get(sporeId);
if (!spore) return;
// Remove from network
this.network.removeNode(spore.nodeId);
// Update player count
const count = this.playerSporeCount.get(spore.planterPubKey) ?? 1;
this.playerSporeCount.set(spore.planterPubKey, Math.max(0, count - 1));
this.plantedSpores.delete(sporeId);
}
// ===========================================================================
// Utility Functions
// ===========================================================================
/**
* Convert geohash to approximate position
*/
private geohashToPosition(geohash: string): { x: number; y: number } {
// Simplified conversion - in production, use proper decoding
let x = 0,
y = 0;
for (let i = 0; i < geohash.length; i++) {
const code = geohash.charCodeAt(i);
x += code * Math.pow(32, geohash.length - i - 1);
y += (code * 7) % 100;
}
return { x: x % 10000, y: y * 100 };
}
/**
* Calculate distance between positions
*/
private calculateDistance(
a: { x: number; y: number },
b: { x: number; y: number }
): number {
const dx = b.x - a.x;
const dy = b.y - a.y;
return Math.sqrt(dx * dx + dy * dy);
}
/**
* Calculate center geohash of multiple spores
*/
private calculateCenterGeohash(spores: PlantedSpore[]): string {
// Simplified - just use first spore's geohash
// In production, calculate actual center
return spores[0].locationCommitment.geohash;
}
// ===========================================================================
// Queries
// ===========================================================================
/**
* Get planted spore by ID
*/
getPlantedSpore(id: string): PlantedSpore | undefined {
return this.plantedSpores.get(id);
}
/**
* Get all planted spores
*/
getAllPlantedSpores(): PlantedSpore[] {
return Array.from(this.plantedSpores.values());
}
/**
* Get player's planted spores
*/
getPlayerSpores(playerPubKey: string): PlantedSpore[] {
return Array.from(this.plantedSpores.values()).filter(
(s) => s.planterPubKey === playerPubKey
);
}
/**
* Get fruiting body by ID
*/
getFruitingBody(id: string): FruitingBody | undefined {
return this.fruitingBodies.get(id);
}
/**
* Get all fruiting bodies
*/
getAllFruitingBodies(): FruitingBody[] {
return Array.from(this.fruitingBodies.values());
}
/**
* Get mature fruiting bodies
*/
getMatureFruits(): FruitingBody[] {
return Array.from(this.fruitingBodies.values()).filter((f) => f.maturity >= 100);
}
/**
* Get the underlying mycelium network
*/
getNetwork(): MyceliumNetwork {
return this.network;
}
// ===========================================================================
// Events
// ===========================================================================
/**
* Subscribe to events
*/
on(listener: GameEventListener): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
private emit(event: GameEvent): void {
for (const listener of this.listeners) {
try {
listener(event);
} catch (e) {
console.error('Error in game event listener:', e);
}
}
}
// ===========================================================================
// Serialization
// ===========================================================================
/**
* Export state
*/
export(): string {
return JSON.stringify({
plantedSpores: Array.from(this.plantedSpores.entries()),
fruitingBodies: Array.from(this.fruitingBodies.entries()),
playerSporeCount: Array.from(this.playerSporeCount.entries()),
});
}
/**
* Import state
*/
import(json: string): void {
const data = JSON.parse(json);
this.plantedSpores = new Map(data.plantedSpores);
this.fruitingBodies = new Map(data.fruitingBodies);
this.playerSporeCount = new Map(data.playerSporeCount);
}
}
// =============================================================================
// Factory Functions
// =============================================================================
/**
* Create a spore manager
*/
export function createSporeManager(config?: Partial<SporeSystemConfig>): SporeManager {
return new SporeManager(config);
}
/**
* Create a spore from type
*/
export function createSporeFromType(type: SporeType): Spore {
const template = SPORE_TEMPLATES[type];
return {
...template,
id: `spore-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
};
}