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
392 lines
13 KiB
JavaScript
392 lines
13 KiB
JavaScript
const test = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const { EmergentMechanics } = require('../js/emergent-mechanics.js');
|
|
|
|
// Minimal localStorage mock
|
|
function createStorage() {
|
|
const store = new Map();
|
|
return {
|
|
getItem: (k) => store.has(k) ? store.get(k) : null,
|
|
setItem: (k, v) => store.set(k, String(v)),
|
|
removeItem: (k) => store.delete(k),
|
|
clear: () => store.clear()
|
|
};
|
|
}
|
|
|
|
// Fresh storage per test
|
|
function freshSetup() {
|
|
global.localStorage = createStorage();
|
|
}
|
|
|
|
test('constructor initializes with zero patterns', () => {
|
|
freshSetup();
|
|
const em = new EmergentMechanics();
|
|
assert.deepEqual(em.patterns, {
|
|
hoarder: 0, rusher: 0, optimizer: 0,
|
|
idle_player: 0, clicker: 0, balanced: 0
|
|
});
|
|
assert.equal(em.actions.length, 0);
|
|
assert.equal(em.activeEvents.length, 0);
|
|
});
|
|
|
|
test('track records actions into the buffer', () => {
|
|
freshSetup();
|
|
const em = new EmergentMechanics();
|
|
em.track('click');
|
|
em.track('buy_building', { buildingId: 'autocoder' });
|
|
em.track('ops_convert', { resource: 'code' });
|
|
|
|
assert.equal(em.actions.length, 3);
|
|
assert.equal(em.actions[0].action, 'click');
|
|
assert.equal(em.actions[1].data.buildingId, 'autocoder');
|
|
assert.equal(em.clickTimestamps.length, 1);
|
|
assert.equal(em.upgradeChoices.length, 1);
|
|
});
|
|
|
|
test('track records resource deltas', () => {
|
|
freshSetup();
|
|
const em = new EmergentMechanics();
|
|
em.track('click', { resource: 'code', delta: 10 });
|
|
em.track('buy_building', { resource: 'code', delta: -100, buildingId: 'server' });
|
|
|
|
assert.equal(em.resourceDeltas.length, 2);
|
|
assert.equal(em.resourceDeltas[0].delta, 10);
|
|
assert.equal(em.resourceDeltas[1].delta, -100);
|
|
});
|
|
|
|
test('trackResourceSnapshot stores game state', () => {
|
|
freshSetup();
|
|
const em = new EmergentMechanics();
|
|
const g = {
|
|
code: 1000, compute: 50, knowledge: 200, users: 10,
|
|
impact: 5, ops: 8, trust: 12, harmony: 55,
|
|
phase: 2, totalClicks: 100, playTime: 300,
|
|
buildings: { autocoder: 5, server: 2 }
|
|
};
|
|
em.trackResourceSnapshot(g);
|
|
|
|
assert.ok(em._lastSnapshot);
|
|
assert.equal(em._lastSnapshot.code, 1000);
|
|
assert.equal(em._lastSnapshot.phase, 2);
|
|
assert.equal(em._lastSnapshot.buildings.autocoder, 5);
|
|
});
|
|
|
|
test('detectPatterns returns pattern scores', () => {
|
|
freshSetup();
|
|
const em = new EmergentMechanics();
|
|
// Provide a snapshot
|
|
em.trackResourceSnapshot({
|
|
code: 100, compute: 10, knowledge: 10, users: 0,
|
|
impact: 0, ops: 5, trust: 5, harmony: 50,
|
|
phase: 1, totalClicks: 10, playTime: 60,
|
|
buildings: { autocoder: 1 }
|
|
});
|
|
|
|
const patterns = em.detectPatterns();
|
|
assert.ok(typeof patterns === 'object');
|
|
assert.ok('hoarder' in patterns);
|
|
assert.ok('rusher' in patterns);
|
|
assert.ok('optimizer' in patterns);
|
|
assert.ok('idle_player' in patterns);
|
|
assert.ok('clicker' in patterns);
|
|
assert.ok('balanced' in patterns);
|
|
});
|
|
|
|
test('hoarder pattern detects resource accumulation without spending', () => {
|
|
freshSetup();
|
|
const em = new EmergentMechanics();
|
|
// Simulate accumulating resources over time (no purchases)
|
|
for (let i = 0; i < 30; i++) {
|
|
em.resourceDeltas.push({ resource: 'code', delta: 100, time: Date.now() });
|
|
}
|
|
em.trackResourceSnapshot({
|
|
code: 20000, compute: 100, knowledge: 50, users: 0,
|
|
impact: 0, ops: 5, trust: 5, harmony: 50,
|
|
phase: 1, totalClicks: 10, playTime: 120,
|
|
buildings: { autocoder: 1 }
|
|
});
|
|
|
|
const patterns = em.detectPatterns();
|
|
assert.ok(patterns.hoarder > 0, 'Hoarder pattern should be detected');
|
|
});
|
|
|
|
test('clicker pattern detects high click frequency', () => {
|
|
freshSetup();
|
|
const em = new EmergentMechanics();
|
|
const now = Date.now();
|
|
// Simulate rapid clicking (50 clicks in last 30 seconds)
|
|
for (let i = 0; i < 50; i++) {
|
|
em.clickTimestamps.push(now - (30 - i) * 600); // spread over 30 seconds
|
|
}
|
|
em.trackResourceSnapshot({
|
|
code: 100, compute: 10, knowledge: 10, users: 0,
|
|
impact: 0, ops: 5, trust: 5, harmony: 50,
|
|
phase: 1, totalClicks: 100, playTime: 60,
|
|
buildings: { autocoder: 1 }
|
|
});
|
|
|
|
const patterns = em.detectPatterns();
|
|
assert.ok(patterns.clicker > 0, 'Clicker pattern should be detected');
|
|
});
|
|
|
|
test('balanced pattern detects spread of buildings', () => {
|
|
freshSetup();
|
|
const em = new EmergentMechanics();
|
|
em.trackResourceSnapshot({
|
|
code: 500, compute: 200, knowledge: 300, users: 100,
|
|
impact: 50, ops: 10, trust: 15, harmony: 50,
|
|
phase: 3, totalClicks: 200, playTime: 600,
|
|
buildings: { autocoder: 5, server: 4, dataset: 3, trainer: 4, linter: 5 }
|
|
});
|
|
|
|
const patterns = em.detectPatterns();
|
|
assert.ok(patterns.balanced > 0, 'Balanced pattern should be detected');
|
|
});
|
|
|
|
test('generateEvent returns null before cooldown expires', () => {
|
|
freshSetup();
|
|
const em = new EmergentMechanics();
|
|
em.lastEventTime = Date.now(); // just set
|
|
|
|
const event = em.generateEvent();
|
|
assert.equal(event, null);
|
|
});
|
|
|
|
test('generateEvent returns null when no pattern is strong enough', () => {
|
|
freshSetup();
|
|
const em = new EmergentMechanics();
|
|
em.lastEventTime = 0; // cooldown expired
|
|
em.patterns = {
|
|
hoarder: 0.1, rusher: 0.05, optimizer: 0.02,
|
|
idle_player: 0, clicker: 0, balanced: 0.1
|
|
};
|
|
|
|
const event = em.generateEvent();
|
|
assert.equal(event, null);
|
|
});
|
|
|
|
test('generateEvent returns a valid event when pattern is strong', () => {
|
|
freshSetup();
|
|
const em = new EmergentMechanics();
|
|
em.lastEventTime = 0; // cooldown expired
|
|
em.patterns.hoarder = 0.8;
|
|
em.actions = new Array(30).fill({ action: 'click', data: {}, time: Date.now() });
|
|
|
|
const event = em.generateEvent();
|
|
assert.ok(event, 'Should generate an event');
|
|
assert.ok(event.id, 'Event should have an id');
|
|
assert.ok(event.title, 'Event should have a title');
|
|
assert.ok(event.desc, 'Event should have a description');
|
|
assert.equal(event.pattern, 'hoarder');
|
|
assert.ok(Array.isArray(event.choices), 'Event should have choices');
|
|
assert.ok(event.choices.length >= 2, 'Event should have at least 2 choices');
|
|
});
|
|
|
|
test('generateEvent adds to activeEvents and eventHistory', () => {
|
|
freshSetup();
|
|
const em = new EmergentMechanics();
|
|
em.lastEventTime = 0;
|
|
em.patterns.rusher = 0.9;
|
|
em.actions = new Array(30).fill({ action: 'buy_building', data: {}, time: Date.now() });
|
|
|
|
const event = em.generateEvent();
|
|
assert.ok(event);
|
|
assert.equal(em.activeEvents.length, 1);
|
|
assert.equal(em.eventHistory.length, 1);
|
|
assert.equal(em.totalEventsGenerated, 1);
|
|
});
|
|
|
|
test('resolveEvent returns effect and removes from active', () => {
|
|
freshSetup();
|
|
const em = new EmergentMechanics();
|
|
em.lastEventTime = 0;
|
|
em.patterns.hoarder = 0.9;
|
|
em.actions = new Array(30).fill({ action: 'click', data: {}, time: Date.now() });
|
|
|
|
const event = em.generateEvent();
|
|
assert.ok(event);
|
|
|
|
const result = em.resolveEvent(event.id, 0);
|
|
assert.ok(result);
|
|
assert.ok(result.effect);
|
|
assert.equal(result.eventId, event.id);
|
|
assert.equal(em.activeEvents.length, 0);
|
|
});
|
|
|
|
test('resolveEvent returns null for unknown event', () => {
|
|
freshSetup();
|
|
const em = new EmergentMechanics();
|
|
const result = em.resolveEvent('nonexistent', 0);
|
|
assert.equal(result, null);
|
|
});
|
|
|
|
test('getState returns comprehensive state', () => {
|
|
freshSetup();
|
|
const em = new EmergentMechanics();
|
|
em.track('click');
|
|
em.trackResourceSnapshot({
|
|
code: 100, compute: 10, knowledge: 10, users: 0,
|
|
impact: 0, ops: 5, trust: 5, harmony: 50,
|
|
phase: 1, totalClicks: 10, playTime: 60,
|
|
buildings: { autocoder: 1 }
|
|
});
|
|
|
|
const state = em.getState();
|
|
assert.ok(state.patterns);
|
|
assert.ok(Array.isArray(state.activeEvents));
|
|
assert.equal(typeof state.totalPatternsDetected, 'number');
|
|
assert.equal(typeof state.totalEventsGenerated, 'number');
|
|
assert.equal(state.actionsTracked, 1);
|
|
});
|
|
|
|
test('reset clears all state', () => {
|
|
freshSetup();
|
|
const em = new EmergentMechanics();
|
|
em.track('click');
|
|
em.patterns.hoarder = 0.5;
|
|
em.totalPatternsDetected = 3;
|
|
|
|
em.reset();
|
|
|
|
assert.equal(em.actions.length, 0);
|
|
assert.equal(em.patterns.hoarder, 0);
|
|
assert.equal(em.totalPatternsDetected, 0);
|
|
assert.equal(em.activeEvents.length, 0);
|
|
});
|
|
|
|
test('track trims action buffer to 500', () => {
|
|
freshSetup();
|
|
const em = new EmergentMechanics();
|
|
for (let i = 0; i < 600; i++) {
|
|
em.track('click');
|
|
}
|
|
assert.ok(em.actions.length <= 500, `Actions trimmed to ${em.actions.length}`);
|
|
});
|
|
|
|
test('track trims clickTimestamps to 100', () => {
|
|
freshSetup();
|
|
const em = new EmergentMechanics();
|
|
for (let i = 0; i < 150; i++) {
|
|
em.track('click');
|
|
}
|
|
assert.ok(em.clickTimestamps.length <= 100);
|
|
});
|
|
|
|
test('track trims upgradeChoices to 100', () => {
|
|
freshSetup();
|
|
const em = new EmergentMechanics();
|
|
for (let i = 0; i < 150; i++) {
|
|
em.track('buy_building', { buildingId: 'autocoder' });
|
|
}
|
|
assert.ok(em.upgradeChoices.length <= 100);
|
|
});
|
|
|
|
test('event history is trimmed to 50', () => {
|
|
freshSetup();
|
|
const em = new EmergentMechanics();
|
|
em.lastEventTime = 0;
|
|
em.patterns.hoarder = 0.9;
|
|
em.actions = new Array(30).fill({ action: 'click', data: {}, time: Date.now() });
|
|
|
|
for (let i = 0; i < 60; i++) {
|
|
em.lastEventTime = 0;
|
|
em.generateEvent();
|
|
}
|
|
assert.ok(em.eventHistory.length <= 50);
|
|
});
|
|
|
|
test('events from all patterns can be generated', () => {
|
|
const patterns = ['hoarder', 'rusher', 'optimizer', 'idle_player', 'clicker', 'balanced'];
|
|
|
|
for (const pattern of patterns) {
|
|
freshSetup();
|
|
const em = new EmergentMechanics();
|
|
em.lastEventTime = 0;
|
|
// Set pattern directly and prevent auto-detection from modifying it
|
|
em.patterns[pattern] = 0.9;
|
|
em.lastPatternCheck = Date.now() + 99999; // prevent detectPatterns auto-trigger
|
|
em.actions = new Array(30).fill({ action: 'click', data: {}, time: Date.now() });
|
|
|
|
const event = em.generateEvent();
|
|
assert.ok(event, `Should generate event for pattern: ${pattern}`);
|
|
assert.equal(event.pattern, pattern, `Event pattern should match for ${pattern}`);
|
|
}
|
|
});
|
|
|
|
test('idle_player pattern detection', () => {
|
|
freshSetup();
|
|
const em = new EmergentMechanics();
|
|
const oldTime = Date.now() - 600000; // 10 minutes ago
|
|
// Simulate old actions with no recent activity
|
|
for (let i = 0; i < 15; i++) {
|
|
em.actions.push({ action: 'click', data: {}, time: oldTime + i * 1000 });
|
|
}
|
|
em.clickTimestamps = []; // no recent clicks
|
|
em.lastActionTime = oldTime; // last action was 10 min ago
|
|
em.trackResourceSnapshot({
|
|
code: 100, compute: 10, knowledge: 10, users: 0,
|
|
impact: 0, ops: 5, trust: 5, harmony: 50,
|
|
phase: 1, totalClicks: 15, playTime: 300,
|
|
buildings: { autocoder: 2 }
|
|
});
|
|
|
|
const patterns = em.detectPatterns();
|
|
assert.ok(patterns.idle_player > 0, `Idle player pattern should be detected, got ${patterns.idle_player}`);
|
|
});
|
|
|
|
test('rusher pattern detection from rapid purchases', () => {
|
|
freshSetup();
|
|
const em = new EmergentMechanics();
|
|
const now = Date.now();
|
|
// Simulate rapid building purchases
|
|
for (let i = 0; i < 8; i++) {
|
|
em.upgradeChoices.push({ buildingId: 'autocoder', time: now - i * 5000 });
|
|
}
|
|
em.resourceDeltas.push({ resource: 'code', delta: -2000, time: now - 1000 });
|
|
em.trackResourceSnapshot({
|
|
code: 50, compute: 10, knowledge: 10, users: 0,
|
|
impact: 0, ops: 5, trust: 5, harmony: 50,
|
|
phase: 1, totalClicks: 50, playTime: 120,
|
|
buildings: { autocoder: 10 }
|
|
});
|
|
|
|
const patterns = em.detectPatterns();
|
|
assert.ok(patterns.rusher > 0, 'Rusher pattern should be detected');
|
|
});
|
|
|
|
test('optimizer pattern from consistent click timing', () => {
|
|
freshSetup();
|
|
const em = new EmergentMechanics();
|
|
const now = Date.now();
|
|
// Simulate very consistent click intervals (every 300ms)
|
|
for (let i = 0; i < 30; i++) {
|
|
em.clickTimestamps.push(now - (30 - i) * 300);
|
|
}
|
|
em.trackResourceSnapshot({
|
|
code: 500, compute: 50, knowledge: 100, users: 0,
|
|
impact: 0, ops: 10, trust: 5, harmony: 50,
|
|
phase: 1, totalClicks: 100, playTime: 120,
|
|
buildings: { autocoder: 3, linter: 2 }
|
|
});
|
|
|
|
const patterns = em.detectPatterns();
|
|
assert.ok(patterns.optimizer > 0, 'Optimizer pattern should be detected');
|
|
});
|
|
|
|
test('save and load preserves state', () => {
|
|
freshSetup();
|
|
|
|
const em1 = new EmergentMechanics();
|
|
em1.patterns.hoarder = 0.7;
|
|
em1.totalPatternsDetected = 5;
|
|
em1.totalEventsGenerated = 3;
|
|
em1.track('click');
|
|
em1._save();
|
|
|
|
const em2 = new EmergentMechanics();
|
|
assert.equal(em2.patterns.hoarder, 0.7);
|
|
assert.equal(em2.totalPatternsDetected, 5);
|
|
assert.equal(em2.totalEventsGenerated, 3);
|
|
assert.ok(em2.actions.length >= 1);
|
|
});
|