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);
|
||
|
|
});
|