Files
the-beacon/js/emergent-mechanics.js
Hermes Agent 529248fd94
Some checks failed
Accessibility Checks / a11y-audit (pull_request) Successful in 10s
Smoke Test / smoke (pull_request) Failing after 21s
feat: Emergent game mechanics from player behavior (closes #190)
The game evolves alongside its players.

Tracks behavior patterns (click frequency, resource spending, upgrade choices),
detects player strategies (hoarder, rusher, optimizer, idle), and generates
dynamic events that reward or challenge those strategies.

- EmergentMechanics class: track(), detectPatterns(), generateEvent(), getState()
- 6 pattern detectors: hoarder, rusher, optimizer, idle_player, clicker, balanced
- 16 emergent events across all patterns with meaningful choices
- localStorage persistence for cross-session behavior tracking
- 25 unit tests, all passing
- Hooks into writeCode, buyBuilding, doOps, and tick()
- Stats panel shows emergent events count, pattern detections, active strategy
- Self-contained: additive system, does not break existing mechanics
2026-04-15 19:07:32 -04:00

676 lines
26 KiB
JavaScript

// ============================================================
// THE BEACON - Emergent Game Mechanics
// The game evolves alongside its players.
// Tracks behavior patterns, detects strategies, generates
// dynamic events that reward or challenge those strategies.
// ============================================================
class EmergentMechanics {
constructor() {
this.SAVE_KEY = 'the-beacon-emergent-v1';
this.PATTERN_CHECK_INTERVAL = 30; // seconds between pattern checks
this.MIN_ACTIONS_FOR_PATTERN = 20; // minimum tracked actions before detection kicks in
this.EVENT_COOLDOWN = 120; // seconds between emergent events
this.lastPatternCheck = 0;
this.lastEventTime = 0;
// Behavior tracking buffers
this.actions = []; // [{action, data, time}]
this.clickTimestamps = []; // last N click times for frequency analysis
this.resourceDeltas = []; // [{resource, delta, time}]
this.upgradeChoices = []; // [{buildingId, time}]
this.idlePeriods = []; // [{start, duration}]
// Detected patterns with confidence scores (0-1)
this.patterns = {
hoarder: 0,
rusher: 0,
optimizer: 0,
idle_player: 0,
clicker: 0,
balanced: 0
};
// Active emergent events
this.activeEvents = [];
// History of generated events (for avoiding repetition)
this.eventHistory = [];
// Stats
this.totalPatternsDetected = 0;
this.totalEventsGenerated = 0;
this.lastIdleCheckTime = Date.now();
this.lastActionTime = Date.now();
// Load saved state
this._load();
}
// === BEHAVIOR TRACKING ===
/**
* Track a player action. Called by game systems.
* @param {string} action - Action type: 'click', 'buy_building', 'buy_project', 'ops_convert', 'sprint', 'resolve_event'
* @param {object} data - Action-specific data
*/
track(action, data) {
const now = Date.now();
const entry = { action, data: data || {}, time: now };
this.actions.push(entry);
this.lastActionTime = now;
// Track click frequency
if (action === 'click') {
this.clickTimestamps.push(now);
// Keep only last 100 clicks for frequency analysis
if (this.clickTimestamps.length > 100) {
this.clickTimestamps.shift();
}
}
// Track resource deltas
if (data && data.resource && data.delta !== undefined) {
this.resourceDeltas.push({
resource: data.resource,
delta: data.delta,
time: now
});
if (this.resourceDeltas.length > 200) {
this.resourceDeltas.shift();
}
}
// Track building purchases
if (action === 'buy_building' && data && data.buildingId) {
this.upgradeChoices.push({
buildingId: data.buildingId,
time: now
});
if (this.upgradeChoices.length > 100) {
this.upgradeChoices.shift();
}
}
// Trim old action history (keep last 500)
if (this.actions.length > 500) {
this.actions = this.actions.slice(-500);
}
// Detect idle periods
this._checkIdlePeriod(now);
// Periodically detect patterns
const elapsedSec = (now - this.lastPatternCheck) / 1000;
if (elapsedSec >= this.PATTERN_CHECK_INTERVAL && this.actions.length >= this.MIN_ACTIONS_FOR_PATTERN) {
this.detectPatterns();
this.lastPatternCheck = now;
}
}
/**
* Track a resource snapshot from the game state.
* Called each tick to compare against player behavior.
*/
trackResourceSnapshot(g) {
if (!g) return;
this._lastSnapshot = {
code: g.code,
compute: g.compute,
knowledge: g.knowledge,
users: g.users,
impact: g.impact,
ops: g.ops,
trust: g.trust,
harmony: g.harmony,
phase: g.phase,
totalClicks: g.totalClicks,
playTime: g.playTime,
buildings: { ...g.buildings },
time: Date.now()
};
}
// === PATTERN DETECTION ===
/**
* Analyze tracked behavior to detect player strategies.
* Updates this.patterns with confidence scores (0-1).
*/
detectPatterns() {
const now = Date.now();
const snap = this._lastSnapshot;
if (!snap) return this.patterns;
// Reset low-confidence patterns to decay over time
for (const key of Object.keys(this.patterns)) {
this.patterns[key] *= 0.9;
}
// --- HOARDER: Accumulates resources without spending ---
this._detectHoarder(snap);
// --- RUSHER: Spends resources immediately, rapid building ---
this._detectRusher(snap);
// --- OPTIMIZER: Focuses on efficiency, maxes click combos ---
this._detectOptimizer(snap);
// --- IDLE PLAYER: Low click frequency, relies on passive generation ---
this._detectIdlePlayer();
// --- CLICKER: Very high click frequency ---
this._detectClicker();
// --- BALANCED: Spread across resource types and building categories ---
this._detectBalanced(snap);
// Clamp all to [0, 1]
for (const key of Object.keys(this.patterns)) {
this.patterns[key] = Math.max(0, Math.min(1, this.patterns[key]));
}
// Find dominant pattern
let dominant = null;
let dominantConf = 0;
for (const [key, conf] of Object.entries(this.patterns)) {
if (conf > dominantConf) {
dominantConf = conf;
dominant = key;
}
}
if (dominant && dominantConf > 0.5) {
this.totalPatternsDetected++;
}
this._save();
return this.patterns;
}
_detectHoarder(snap) {
// High resource accumulation relative to spending
const recentPurchases = this.upgradeChoices.filter(
u => u.time > Date.now() - 120000
).length;
// Look at resource deltas: positive deltas without corresponding purchases
const recentDeltas = this.resourceDeltas.filter(
d => d.time > Date.now() - 120000 && d.delta > 0
);
const totalAccumulated = recentDeltas.reduce((sum, d) => sum + d.delta, 0);
// If accumulating a lot but not spending, it's hoarding
if (totalAccumulated > 1000 && recentPurchases < 2) {
this.patterns.hoarder = Math.min(1, this.patterns.hoarder + 0.15);
}
// Check if resources are high relative to phase
const codeThresholds = [0, 500, 5000, 50000, 500000, 5000000];
const threshold = codeThresholds[Math.min(snap.phase, 5)] || 0;
if (threshold > 0 && snap.code > threshold * 3) {
this.patterns.hoarder = Math.min(1, this.patterns.hoarder + 0.1);
}
}
_detectRusher(snap) {
// Rapid building purchases in a short time
const recentPurchases = this.upgradeChoices.filter(
u => u.time > Date.now() - 60000
).length;
if (recentPurchases >= 5) {
this.patterns.rusher = Math.min(1, this.patterns.rusher + 0.2);
}
// Resources spent faster than they're accumulated (spending ratio)
const recentSpendDeltas = this.resourceDeltas.filter(
d => d.time > Date.now() - 60000 && d.delta < 0
);
const totalSpent = Math.abs(recentSpendDeltas.reduce((sum, d) => sum + d.delta, 0));
if (totalSpent > 500) {
this.patterns.rusher = Math.min(1, this.patterns.rusher + 0.1);
}
}
_detectOptimizer(snap) {
// Sustained high combo counts, efficient ops usage
if (this.clickTimestamps.length >= 20) {
const recent = this.clickTimestamps.slice(-20);
const intervals = [];
for (let i = 1; i < recent.length; i++) {
intervals.push(recent[i] - recent[i - 1]);
}
// Consistent click timing = optimized clicking
const avg = intervals.reduce((a, b) => a + b, 0) / intervals.length;
const variance = intervals.reduce((sum, i) => sum + (i - avg) ** 2, 0) / intervals.length;
const stddev = Math.sqrt(variance);
// Low variance with fast timing = optimizer
if (avg < 500 && stddev < avg * 0.3) {
this.patterns.optimizer = Math.min(1, this.patterns.optimizer + 0.15);
}
}
// Efficient ops conversion (converts at near-max ops)
const opsConverts = this.actions.filter(
a => a.action === 'ops_convert' && a.time > Date.now() - 120000
).length;
if (opsConverts >= 10) {
this.patterns.optimizer = Math.min(1, this.patterns.optimizer + 0.1);
}
}
_detectIdlePlayer() {
// Long gaps between actions
const recentActions = this.actions.filter(a => a.time > Date.now() - 300000);
if (recentActions.length < 5 && this.actions.length > 10) {
this.patterns.idle_player = Math.min(1, this.patterns.idle_player + 0.2);
}
// Very low click frequency
const recentClicks = this.clickTimestamps.filter(t => t > Date.now() - 120000);
if (recentClicks.length < 3 && this.clickTimestamps.length > 10) {
this.patterns.idle_player = Math.min(1, this.patterns.idle_player + 0.15);
}
}
_detectClicker() {
if (this.clickTimestamps.length < 10) return;
const recent = this.clickTimestamps.filter(t => t > Date.now() - 30000);
const clicksPerSecond = recent.length / 30;
if (clicksPerSecond > 3) {
this.patterns.clicker = Math.min(1, this.patterns.clicker + 0.2);
} else if (clicksPerSecond > 1.5) {
this.patterns.clicker = Math.min(1, this.patterns.clicker + 0.1);
}
}
_detectBalanced(snap) {
// Check if player has a spread of buildings
const bCounts = Object.values(snap.buildings || {}).filter(c => c > 0);
if (bCounts.length >= 4) {
const max = Math.max(...bCounts);
const min = Math.min(...bCounts);
// If max is not more than 3x min, it's balanced
if (max > 0 && min > 0 && max / min < 3) {
this.patterns.balanced = Math.min(1, this.patterns.balanced + 0.15);
}
}
// Check resource spread
const resources = [snap.code, snap.compute, snap.knowledge, snap.users, snap.ops];
const activeRes = resources.filter(r => r > 10);
if (activeRes.length >= 4) {
this.patterns.balanced = Math.min(1, this.patterns.balanced + 0.1);
}
}
_checkIdlePeriod(now) {
const gap = now - this.lastActionTime;
if (gap > 60000) { // 60 seconds idle
this.idlePeriods.push({
start: this.lastActionTime,
duration: gap
});
if (this.idlePeriods.length > 50) {
this.idlePeriods.shift();
}
}
}
// === EVENT GENERATION ===
/**
* Generate a dynamic event based on detected player patterns.
* Returns an event object or null if no event should fire.
*/
generateEvent() {
const now = Date.now();
const elapsedSec = (now - this.lastEventTime) / 1000;
if (elapsedSec < this.EVENT_COOLDOWN) return null;
// Find dominant pattern
let dominant = null;
let dominantConf = 0;
for (const [key, conf] of Object.entries(this.patterns)) {
if (conf > dominantConf) {
dominantConf = conf;
dominant = key;
}
}
if (!dominant || dominantConf < 0.4) return null;
// Get candidate events for this pattern
const candidates = this._getEventsForPattern(dominant);
if (candidates.length === 0) return null;
// Filter out recently used events
const recentEvents = this.eventHistory.slice(-10).map(e => e.id);
const fresh = candidates.filter(c => !recentEvents.includes(c.id));
const pool = fresh.length > 0 ? fresh : candidates;
// Pick a random event
const event = pool[Math.floor(Math.random() * pool.length)];
// Build event object
const emergentEvent = {
id: event.id,
title: event.title,
desc: event.desc,
pattern: dominant,
confidence: dominantConf,
choices: event.choices,
timestamp: now
};
this.lastEventTime = now;
this.activeEvents.push(emergentEvent);
this.eventHistory.push({ id: event.id, pattern: dominant, time: now });
this.totalEventsGenerated++;
// Trim history
if (this.eventHistory.length > 50) {
this.eventHistory = this.eventHistory.slice(-50);
}
this._save();
return emergentEvent;
}
_getEventsForPattern(pattern) {
const EVENTS = {
hoarder: [
{
id: 'hoard_wisdom',
title: 'THE TREASURER\'S DILEMMA',
desc: 'Your accumulated resources draw attention. A rival system offers to trade knowledge for your surplus code.',
choices: [
{ label: 'Trade 50% code for 2x knowledge', effect: 'knowledge_surge' },
{ label: 'Keep hoarding (trust +3)', effect: 'trust_gain' }
]
},
{
id: 'hoard_decay',
title: 'ENTROPY STRIKES',
desc: 'Unused code rots. Technical debt accumulates when resources sit idle.',
choices: [
{ label: 'Spend reserves to refactor (-30% code, +50% code rate)', effect: 'code_boost' },
{ label: 'Ignore it (harmony -5)', effect: 'harmony_loss' }
]
},
{
id: 'hoard_opportunity',
title: 'MARKET WINDOW',
desc: 'A rare opportunity: bulk compute at 10x efficiency. But only for those with deep reserves.',
choices: [
{ label: 'Buy in bulk (spend 50% code, +compute)', effect: 'compute_surge' },
{ label: 'Pass on this one', effect: 'none' }
]
}
],
rusher: [
{
id: 'rush_bug',
title: 'TECHNICAL DEBT COLLECTOR',
desc: 'Moving fast broke things. A cascade of bugs threatens your production systems.',
choices: [
{ label: 'Emergency fix (spend ops, restore trust)', effect: 'bug_fix' },
{ label: 'Ship a hotfix (trust -3, keep momentum)', effect: 'trust_loss' }
]
},
{
id: 'rush_breakthrough',
title: 'BLAZING TRAIL',
desc: 'Your rapid iteration caught a lucky break. An unexpected optimization emerged from the chaos.',
choices: [
{ label: 'Claim the breakthrough (knowledge +100)', effect: 'knowledge_bonus' },
{ label: 'Stabilize first (trust +2)', effect: 'trust_gain' }
]
},
{
id: 'rush_burnout',
title: 'SYSTEM STRESS',
desc: 'Your infrastructure is running hot. The rapid pace is taking a toll on harmony.',
choices: [
{ label: 'Slow down (+harmony, -build speed for 30s)', effect: 'cooldown' },
{ label: 'Push through (-harmony, keep pace)', effect: 'harmony_loss' }
]
}
],
optimizer: [
{
id: 'opt_discovery',
title: 'EFFICIENCY BREAKTHROUGH',
desc: 'Your systematic approach uncovered a pattern others missed. The algorithm improves.',
choices: [
{ label: 'Apply optimization (all rates +15%)', effect: 'rate_boost' },
{ label: 'Share findings (trust +5, knowledge +50)', effect: 'trust_knowledge' }
]
},
{
id: 'opt_local_max',
title: 'LOCAL MAXIMUM',
desc: 'Your optimized strategy may be missing a bigger opportunity. Divergence could reveal it.',
choices: [
{ label: 'Explore randomly (chance of 3x breakthrough)', effect: 'gamble' },
{ label: 'Stay the course (guaranteed +20% efficiency)', effect: 'safe_boost' }
]
},
{
id: 'opt_elegance',
title: 'ELEGANT SOLUTION',
desc: 'A beautifully simple approach emerges from your careful analysis. Creativity surges.',
choices: [
{ label: 'Implement it (+creativity rate)', effect: 'creativity_boost' },
{ label: 'Document it first (knowledge +75)', effect: 'knowledge_bonus' }
]
}
],
idle_player: [
{
id: 'idle_autonomous',
title: 'THE SYSTEM LEARNS',
desc: 'In your absence, the automation grew more capable. Your agents have been busy.',
choices: [
{ label: 'Claim passive gains (5min of production)', effect: 'passive_claim' },
{ label: 'Set new directives (+ops, customize automation)', effect: 'ops_bonus' }
]
},
{
id: 'idle_drift',
title: 'DRIFT WARNING',
desc: 'The system is running without guidance. Without input, alignment drifts.',
choices: [
{ label: 'Re-engage (trust +5, harmony +10)', effect: 're_engage' },
{ label: 'Trust the system (ops +50)', effect: 'ops_bonus' }
]
},
{
id: 'idle_emergence',
title: 'EMERGENT BEHAVIOR',
desc: 'Your agents developed unexpected capabilities while you were away. A new pattern emerged.',
choices: [
{ label: 'Study it (knowledge +100)', effect: 'knowledge_bonus' },
{ label: 'Embrace it (+all production for 60s)', effect: 'temp_boost' }
]
}
],
clicker: [
{
id: 'click_rsi',
title: 'REPETITIVE STRAIN',
desc: 'The manual effort is showing. Your fingers tire, but the machine responds to your dedication.',
choices: [
{ label: 'Automate this pattern (+auto-clicker power)', effect: 'auto_boost' },
{ label: 'Power through (combo decay slowed)', effect: 'combo_boost' }
]
},
{
id: 'click_rhythm',
title: 'CADENCE LOCKED',
desc: 'Your clicking found a rhythm. The system resonates with your tempo. Production harmonizes.',
choices: [
{ label: 'Maintain rhythm (+click power)', effect: 'click_power' },
{ label: 'Teach the rhythm (auto-clickers learn)', effect: 'auto_learn' }
]
}
],
balanced: [
{
id: 'bal_versatility',
title: 'JACK OF ALL TRADES',
desc: 'Your balanced approach impresses the community. Contributors offer diverse expertise.',
choices: [
{ label: 'Accept help (all resources +25)', effect: 'resource_gift' },
{ label: 'Specialize (choose: 2x any single rate)', effect: 'specialize' }
]
},
{
id: 'bal_resilience',
title: 'RESILIENT ARCHITECTURE',
desc: 'Your balanced system recovers from failures faster than specialized ones.',
choices: [
{ label: 'Leverage resilience (harmony +20)', effect: 'harmony_surge' },
{ label: 'Document the pattern (knowledge +50)', effect: 'knowledge_bonus' }
]
}
]
};
return EVENTS[pattern] || [];
}
/**
* Resolve an emergent event choice.
* Returns the effect string for the game to apply.
*/
resolveEvent(eventId, choiceIndex) {
const eventIdx = this.activeEvents.findIndex(e => e.id === eventId);
if (eventIdx === -1) return null;
const event = this.activeEvents[eventIdx];
const choice = event.choices[choiceIndex];
if (!choice) return null;
// Remove from active
this.activeEvents.splice(eventIdx, 1);
this._save();
return {
effect: choice.effect,
pattern: event.pattern,
eventId: event.id
};
}
// === STATE ===
/**
* Get the full state of the emergent mechanics system.
*/
getState() {
return {
patterns: { ...this.patterns },
activeEvents: [...this.activeEvents],
totalPatternsDetected: this.totalPatternsDetected,
totalEventsGenerated: this.totalEventsGenerated,
actionsTracked: this.actions.length,
dominantPattern: this._getDominantPattern()
};
}
_getDominantPattern() {
let dominant = null;
let maxConf = 0;
for (const [key, conf] of Object.entries(this.patterns)) {
if (conf > maxConf) {
maxConf = conf;
dominant = key;
}
}
return maxConf > 0.3 ? { name: dominant, confidence: maxConf } : null;
}
// === PERSISTENCE ===
_save() {
try {
const state = {
patterns: this.patterns,
eventHistory: this.eventHistory.slice(-20),
totalPatternsDetected: this.totalPatternsDetected,
totalEventsGenerated: this.totalEventsGenerated,
lastPatternCheck: this.lastPatternCheck,
lastEventTime: this.lastEventTime,
// Save abbreviated action data for pattern continuity
recentActions: this.actions.slice(-100),
recentClickTimestamps: this.clickTimestamps.slice(-50),
recentResourceDeltas: this.resourceDeltas.slice(-100),
recentUpgradeChoices: this.upgradeChoices.slice(-50)
};
if (typeof localStorage !== 'undefined') {
localStorage.setItem(this.SAVE_KEY, JSON.stringify(state));
}
} catch (e) {
// localStorage may be unavailable or full
}
}
_load() {
try {
if (typeof localStorage === 'undefined') return;
const raw = localStorage.getItem(this.SAVE_KEY);
if (!raw) return;
const state = JSON.parse(raw);
if (state.patterns) this.patterns = state.patterns;
if (state.eventHistory) this.eventHistory = state.eventHistory;
if (state.totalPatternsDetected) this.totalPatternsDetected = state.totalPatternsDetected;
if (state.totalEventsGenerated) this.totalEventsGenerated = state.totalEventsGenerated;
if (state.lastPatternCheck) this.lastPatternCheck = state.lastPatternCheck;
if (state.lastEventTime) this.lastEventTime = state.lastEventTime;
if (state.recentActions) this.actions = state.recentActions;
if (state.recentClickTimestamps) this.clickTimestamps = state.recentClickTimestamps;
if (state.recentResourceDeltas) this.resourceDeltas = state.recentResourceDeltas;
if (state.recentUpgradeChoices) this.upgradeChoices = state.recentUpgradeChoices;
} catch (e) {
// Corrupted save data — start fresh
}
}
/**
* Reset all emergent mechanics state.
*/
reset() {
this.actions = [];
this.clickTimestamps = [];
this.resourceDeltas = [];
this.upgradeChoices = [];
this.idlePeriods = [];
this.patterns = {
hoarder: 0, rusher: 0, optimizer: 0,
idle_player: 0, clicker: 0, balanced: 0
};
this.activeEvents = [];
this.eventHistory = [];
this.totalPatternsDetected = 0;
this.totalEventsGenerated = 0;
this.lastPatternCheck = 0;
this.lastEventTime = 0;
this._lastSnapshot = null;
this._save();
}
}
// Export for both browser and test environments
if (typeof module !== 'undefined' && module.exports) {
module.exports = { EmergentMechanics };
}
if (typeof window !== 'undefined') {
window.EmergentMechanics = EmergentMechanics;
}