godcrm/pes-core/soul/emotions.js
GOD CRM Release f89e074dd1
Some checks failed
CI / Lint / Typecheck / Test / Build (push) Has been cancelled
CI / PostgreSQL Integration Tests (push) Has been cancelled
GOD CRM — public scrubbed snapshot
Governed substrate for autonomous agents: scoped identity (passports),
audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
2026-08-10 04:01:45 +03:00

899 lines
34 KiB
JavaScript

/**
* PES Soul — Emotional State Machine (v2: Level-Gated)
*
* 32 emotional states based on real Pembroke Welsh Corgi behavior.
* States are grouped by unlock level — PES gains emotional range as it evolves.
*
* Level gates:
* 0 basic_emotions — idle, content, playful, happy, butt_wiggle, scared, nap, sploot
* 3 emotion_social — puppy_eyes, wanna_play, velcro, jealous, howl_sing, greeting_frenzy
* 5 emotion_working — alert, bark, herding, bossy, puzzle_solving, wrote_somewhere
* 8 emotion_dramatic — grumble, dramatic_tantrum, stubborn_refuse, sulking, side_eye
* 10 emotion_full — zoomies, play_bow, corgi_flop, anxious, separation_stress, rage, sneaky, food_obsessed
*
* Tick system adapted for internet reality:
* - 1 tick = 1 meaningful event (message, command, agent action)
* - Passive time passes in larger chunks (minutes between interactions)
* - Energy/hunger/loneliness scale to real online session patterns
*/
// ── EMOTION LEVEL GATES ───────────────────────────────────────────
const EMOTION_LEVELS = {
basic_emotions: { level: 0, states: ['idle', 'content', 'playful', 'happy', 'butt_wiggle', 'scared', 'nap', 'sploot', 'sleep'] },
emotion_social: { level: 3, states: ['puppy_eyes', 'wanna_play', 'velcro', 'jealous', 'howl_sing', 'greeting_frenzy'] },
emotion_working: { level: 5, states: ['alert', 'bark', 'herding', 'bossy', 'puzzle_solving', 'wrote_somewhere'] },
emotion_dramatic:{ level: 8, states: ['grumble', 'dramatic_tantrum', 'stubborn_refuse', 'sulking', 'side_eye'] },
emotion_full: { level: 10, states: ['zoomies', 'play_bow', 'corgi_flop', 'anxious', 'separation_stress', 'rage', 'sneaky', 'food_obsessed'] },
};
/**
* Build a Set of unlocked state names for a given PES level.
*/
function buildUnlockedSet(pesLevel) {
const set = new Set();
for (const group of Object.values(EMOTION_LEVELS)) {
if (pesLevel >= group.level) {
for (const s of group.states) set.add(s);
}
}
return set;
}
// ── STATES ────────────────────────────────────────────────────────
// weight: probability of spontaneous emergence (0 = only via trigger/transition)
// decay: how fast intensity drops per tick
// minDuration: ticks before natural exit
// maxDuration: ticks before forced exit
// energyCost: energy burned per tick (negative = recovery)
// category: grouping for UI/logic
const STATES = {
// === CORE / BASELINE (level 0) ===
idle: { weight: 0, decay: 0.005, minDuration: 5, maxDuration: 50, energyCost: 0.001, category: 'baseline' },
content: { weight: 0, decay: 0.003, minDuration: 10, maxDuration: 100, energyCost: 0.001, category: 'baseline' },
playful: { weight: 0.18, decay: 0.008, minDuration: 5, maxDuration: 30, energyCost: 0.008, category: 'happy' },
happy: { weight: 0, decay: 0.010, minDuration: 5, maxDuration: 25, energyCost: 0.005, category: 'happy' },
butt_wiggle: { weight: 0.08, decay: 0.020, minDuration: 2, maxDuration: 8, energyCost: 0.006, category: 'happy' },
scared: { weight: 0, decay: 0.015, minDuration: 2, maxDuration: 15, energyCost: 0.005, category: 'stress' },
nap: { weight: 0, decay: 0.002, minDuration: 10, maxDuration: 60, energyCost: -0.005, category: 'rest' },
sploot: { weight: 0.03, decay: 0.003, minDuration: 5, maxDuration: 40, energyCost: -0.002, category: 'rest' },
// === SOCIAL (level 3) ===
puppy_eyes: { weight: 0.10, decay: 0.004, minDuration: 5, maxDuration: 40, energyCost: 0.002, category: 'social' },
wanna_play: { weight: 0.06, decay: 0.006, minDuration: 5, maxDuration: 25, energyCost: 0.004, category: 'social' },
velcro: { weight: 0.05, decay: 0.002, minDuration: 10, maxDuration: 100, energyCost: 0.001, category: 'social' },
jealous: { weight: 0.02, decay: 0.008, minDuration: 3, maxDuration: 20, energyCost: 0.005, category: 'social' },
howl_sing: { weight: 0.01, decay: 0.015, minDuration: 2, maxDuration: 10, energyCost: 0.007, category: 'social' },
greeting_frenzy: { weight: 0, decay: 0.020, minDuration: 3, maxDuration: 10, energyCost: 0.015, category: 'happy' },
// === WORKING (level 5) ===
alert: { weight: 0, decay: 0.012, minDuration: 3, maxDuration: 20, energyCost: 0.005, category: 'working' },
bark: { weight: 0.07, decay: 0.015, minDuration: 2, maxDuration: 15, energyCost: 0.008, category: 'working' },
herding: { weight: 0.03, decay: 0.006, minDuration: 5, maxDuration: 30, energyCost: 0.012, category: 'working' },
bossy: { weight: 0.02, decay: 0.005, minDuration: 5, maxDuration: 25, energyCost: 0.006, category: 'working' },
puzzle_solving: { weight: 0, decay: 0.004, minDuration: 5, maxDuration: 40, energyCost: 0.010, category: 'working' },
wrote_somewhere: { weight: 0.02, decay: 0.020, minDuration: 2, maxDuration: 10, energyCost: 0.003, category: 'working' },
// === DRAMATIC (level 8) ===
grumble: { weight: 0.04, decay: 0.010, minDuration: 3, maxDuration: 20, energyCost: 0.003, category: 'dramatic' },
dramatic_tantrum: { weight: 0.02, decay: 0.018, minDuration: 2, maxDuration: 12, energyCost: 0.010, category: 'dramatic' },
stubborn_refuse: { weight: 0.02, decay: 0.003, minDuration: 5, maxDuration: 30, energyCost: 0.001, category: 'dramatic' },
sulking: { weight: 0, decay: 0.003, minDuration: 5, maxDuration: 50, energyCost: 0.001, category: 'dramatic' },
side_eye: { weight: 0.02, decay: 0.025, minDuration: 1, maxDuration: 5, energyCost: 0.001, category: 'dramatic' },
// === FULL SPECTRUM (level 10) ===
sleep: { weight: 0, decay: 0.001, minDuration: 20, maxDuration: 200, energyCost: -0.010, category: 'rest' },
zoomies: { weight: 0.05, decay: 0.025, minDuration: 3, maxDuration: 12, energyCost: 0.030, category: 'happy' },
play_bow: { weight: 0.04, decay: 0.030, minDuration: 1, maxDuration: 5, energyCost: 0.005, category: 'happy' },
corgi_flop: { weight: 0.02, decay: 0.015, minDuration: 1, maxDuration: 5, energyCost: 0.000, category: 'rest' },
anxious: { weight: 0, decay: 0.005, minDuration: 5, maxDuration: 30, energyCost: 0.008, category: 'stress' },
separation_stress: { weight: 0, decay: 0.002, minDuration: 10, maxDuration: 100, energyCost: 0.006, category: 'stress' },
rage: { weight: 0.01, decay: 0.002, minDuration: 10, maxDuration: 30, energyCost: 0.020, category: 'rare' },
sneaky: { weight: 0.01, decay: 0.020, minDuration: 2, maxDuration: 10, energyCost: 0.004, category: 'rare' },
food_obsessed: { weight: 0.03, decay: 0.005, minDuration: 3, maxDuration: 25, energyCost: 0.003, category: 'rare' },
};
// ── TRANSITIONS ───────────────────────────────────────────────────
// state -> [possible next states]
// Based on real corgi behavioral flow patterns.
// At runtime, locked states are filtered out.
const TRANSITIONS = {
// Baseline
idle: ['playful', 'puppy_eyes', 'bark', 'wanna_play', 'grumble', 'nap', 'alert', 'sploot', 'velcro', 'content', 'food_obsessed', 'side_eye', 'sneaky'],
content: ['idle', 'playful', 'nap', 'sploot', 'velcro', 'butt_wiggle'],
// Happy cluster
playful: ['happy', 'bark', 'wanna_play', 'zoomies', 'butt_wiggle', 'play_bow', 'idle', 'herding', 'corgi_flop'],
happy: ['playful', 'idle', 'wanna_play', 'butt_wiggle', 'content', 'zoomies', 'sploot'],
butt_wiggle: ['playful', 'zoomies', 'greeting_frenzy', 'happy', 'play_bow'],
zoomies: ['sploot', 'corgi_flop', 'happy', 'playful', 'nap'],
play_bow: ['playful', 'zoomies', 'herding', 'wanna_play'],
greeting_frenzy: ['butt_wiggle', 'zoomies', 'happy', 'velcro', 'playful'],
// Social cluster
puppy_eyes: ['playful', 'grumble', 'idle', 'happy', 'dramatic_tantrum', 'sulking', 'velcro'],
wanna_play: ['playful', 'puppy_eyes', 'idle', 'grumble', 'play_bow', 'dramatic_tantrum'],
velcro: ['content', 'puppy_eyes', 'nap', 'idle', 'jealous', 'separation_stress'],
jealous: ['bark', 'grumble', 'dramatic_tantrum', 'sulking', 'velcro'],
howl_sing: ['alert', 'idle', 'content', 'bark'],
// Working cluster
alert: ['bark', 'wrote_somewhere', 'idle', 'scared', 'herding', 'puzzle_solving', 'side_eye'],
bark: ['playful', 'alert', 'rage', 'idle', 'grumble', 'herding', 'bossy', 'howl_sing'],
herding: ['bark', 'bossy', 'playful', 'grumble', 'zoomies', 'stubborn_refuse'],
bossy: ['herding', 'grumble', 'bark', 'content', 'sulking'],
puzzle_solving: ['happy', 'wrote_somewhere', 'grumble', 'dramatic_tantrum', 'alert'],
wrote_somewhere: ['idle', 'playful', 'happy', 'sneaky', 'content'],
// Dramatic cluster
grumble: ['idle', 'bark', 'puppy_eyes', 'nap', 'dramatic_tantrum', 'stubborn_refuse', 'sulking'],
dramatic_tantrum: ['sulking', 'grumble', 'idle', 'food_obsessed', 'stubborn_refuse'],
stubborn_refuse: ['grumble', 'dramatic_tantrum', 'idle', 'sulking'],
sulking: ['idle', 'food_obsessed', 'puppy_eyes', 'grumble', 'content'],
side_eye: ['alert', 'idle', 'sneaky', 'grumble', 'bark'],
// Rest cluster
sleep: ['idle', 'puppy_eyes', 'alert', 'content'],
nap: ['idle', 'alert', 'content', 'sploot', 'sleep'],
sploot: ['nap', 'idle', 'content', 'alert', 'playful'],
corgi_flop: ['nap', 'sploot', 'sleep', 'idle'],
// Stress cluster
scared: ['idle', 'puppy_eyes', 'nap', 'velcro', 'anxious', 'zoomies'],
anxious: ['scared', 'velcro', 'bark', 'zoomies', 'idle'],
separation_stress: ['anxious', 'bark', 'greeting_frenzy', 'velcro'],
// Rare cluster
rage: ['scared', 'idle', 'grumble', 'corgi_flop'],
sneaky: ['idle', 'happy', 'sulking', 'side_eye', 'food_obsessed'],
food_obsessed: ['puppy_eyes', 'dramatic_tantrum', 'content', 'grumble', 'sneaky'],
};
// ── VITALS ────────────────────────────────────────────────────────
// Adapted for INTERNET REALITY:
// - PES lives in chats/projects, not the physical world
// - "hunger" = need for data/tasks
// - "energy" = computational resource, recovers at rest
// - "loneliness" = time without interactions
// - Ticks bound to events, not seconds
const VITAL_RATES = {
// Per-interaction tick (when something happens)
interaction: {
energy: -0.005,
hunger: 0, // triggers handle hunger explicitly
loneliness: -0.15,
curiosity: +0.02,
},
// Per-minute passive (when nothing happens)
// Balanced: 16h silence (960 min) should reach ~0.5-0.6, not 1.0
passive: {
energy: +0.002,
hunger: +0.0005,
loneliness: +0.0006,
curiosity: -0.0005,
},
// Sleep recovery (per tick while sleeping)
sleep: {
energy: +0.015,
hunger: +0.0003,
loneliness: +0.0002,
curiosity: 0,
},
// Thresholds for auto-transitions
thresholds: {
exhausted: 0.10, // energy < this -> auto sleep
starving: 0.85, // hunger > this -> food_obsessed/grumble
lonely: 0.75, // loneliness > this -> puppy_eyes/separation_stress
bored: 0.15, // curiosity < this -> sneaky/grumble
hyper: 0.90, // energy > this + curiosity > 0.7 -> zoomies
},
};
// ── CREPUSCULAR RHYTHM ────────────────────────────────────────────
// Corgis are most active at dawn and dusk.
// In internet reality: "dawn" = session start, "dusk" = session end.
const RHYTHM = {
sessionStartBurst: 0.3,
sessionEndDrain: 0.2,
activityWindow: 30, // ticks of activity before natural "dusk"
};
// ── HELPERS ───────────────────────────────────────────────────────
function clamp(val, min, max) {
return Math.min(max, Math.max(min, val));
}
/**
* Given a TRANSITIONS list and a set of unlocked states,
* return only the states that are currently accessible.
*/
function filterLocked(candidates, unlocked) {
if (!unlocked) return candidates;
return candidates.filter(s => unlocked.has(s));
}
/**
* Find the best fallback state from the unlocked set.
* Prefers idle > content > nap > sploot (all level-0).
*/
function fallbackState(unlocked) {
for (const s of ['idle', 'content', 'nap', 'sploot']) {
if (!unlocked || unlocked.has(s)) return s;
}
return 'idle';
}
// ── EMOTION ENGINE ────────────────────────────────────────────────
class EmotionState {
/**
* @param {Set<string>|null} unlockedEmotions — set of state names this PES can access.
* If null, only basic_emotions (level 0) are available.
*/
constructor(unlockedEmotions = null) {
// Level gate
this._unlocked = unlockedEmotions || buildUnlockedSet(0);
// Current state
this.state = 'idle';
this.intensity = 0.5;
this.stateStartedAt = Date.now();
this.tickCount = 0;
this.sessionTicks = 0;
this.lastInteractionAt = Date.now();
// Vitals
this.mood = 0.5;
this.energy = 0.8;
this.hunger = 0.2;
this.curiosity = 0.5;
this.loneliness = 0.0;
// Character traits — shaped heavily during critical period,
// but NEVER truly frozen. Like White Fang: early experience matters most,
// but patient, persistent love can reshape even the wildest nature.
this.traits = {
courage: 0.5,
curiosity: 0.5,
loyalty: 0.5,
stubbornness: 0.5,
playfulness: 0.5,
drama: 0.5,
foodDrive: 0.5,
sassiness: 0.5,
};
this.criticalPeriodOver = false;
this.traitChangeAttempts = {};
// History
this.stateHistory = [];
this.listeners = [];
}
// ── Current snapshot ──
get current() {
return {
state: this.state,
intensity: this.intensity,
category: STATES[this.state]?.category || 'unknown',
mood: this.mood,
energy: this.energy,
hunger: this.hunger,
curiosity: this.curiosity,
loneliness: this.loneliness,
tickCount: this.tickCount,
sessionTicks: this.sessionTicks,
duration: (Date.now() - this.stateStartedAt) / 1000,
traits: { ...this.traits },
};
}
// ── Level gate management ──
/**
* Returns the Set of currently unlocked state names.
*/
getAvailableStates() {
return new Set(this._unlocked);
}
/**
* Replace the unlocked emotions set.
* If the current state is not in the new set, transition to fallback.
* @param {Set<string>} set
*/
setUnlockedEmotions(set) {
this._unlocked = set;
if (!this._unlocked.has(this.state)) {
const fb = fallbackState(this._unlocked);
this.state = fb;
this.intensity = 0.3;
this.stateStartedAt = Date.now();
this._emit('forced_fallback', { to: fb, reason: 'state_locked_after_update' });
}
}
/**
* Check if a specific state is unlocked.
*/
isUnlocked(state) {
return this._unlocked.has(state);
}
// ── State transitions ──
/**
* Attempt transition to a new emotional state.
* Returns true if transition happened, false if rejected.
*/
transitionTo(newState, intensity = null, reason = null) {
if (!STATES[newState]) return false;
// Level gate: state must be unlocked
if (!this._unlocked.has(newState)) return false;
// Check if transition is valid from current state
const allowed = TRANSITIONS[this.state];
if (!allowed || !allowed.includes(newState)) return false;
// Rage gate: only 1% chance, only from bark
if (newState === 'rage') {
if (this.state !== 'bark' || Math.random() > 0.01) return false;
}
// Stubborn gate: modified by stubbornness trait
if (newState === 'stubborn_refuse') {
if (Math.random() > this.traits.stubbornness) return false;
}
// Dramatic tantrum gate: modified by drama trait
if (newState === 'dramatic_tantrum') {
if (Math.random() > this.traits.drama * 0.6) return false;
}
// Food obsession gate: modified by foodDrive trait
if (newState === 'food_obsessed') {
if (Math.random() > this.traits.foodDrive * 0.5) return false;
}
const prevState = this.state;
const prevIntensity = this.intensity;
this.state = newState;
this.intensity = intensity !== null
? clamp(intensity, 0, 1)
: this._randomIntensity(newState);
this.stateStartedAt = Date.now();
// Record history (keep last 200)
this.stateHistory.push({
from: prevState,
to: newState,
reason,
intensity: this.intensity,
timestamp: Date.now(),
tickCount: this.tickCount,
});
if (this.stateHistory.length > 200) this.stateHistory.shift();
this._emit('transition', {
from: prevState,
fromIntensity: prevIntensity,
to: newState,
toIntensity: this.intensity,
reason,
});
return true;
}
// ── Tick system ──
/**
* Interaction tick — call when something happens
* (message received, agent acted, command given, etc.)
*/
interactionTick(eventType = 'generic') {
this.tickCount++;
this.sessionTicks++;
this.lastInteractionAt = Date.now();
const stateDef = STATES[this.state];
if (!stateDef) return;
// Decay intensity
this.intensity -= stateDef.decay;
// Apply state energy cost
this.energy = clamp(this.energy - stateDef.energyCost, 0, 1);
// Apply interaction vital changes
const rates = this.state === 'sleep' || this.state === 'nap'
? VITAL_RATES.sleep
: VITAL_RATES.interaction;
this.energy = clamp(this.energy + rates.energy, 0, 1);
this.hunger = clamp(this.hunger + rates.hunger, 0, 1);
this.loneliness = clamp(this.loneliness + rates.loneliness, 0, 1);
this.curiosity = clamp(this.curiosity + rates.curiosity, 0, 1);
// Mood influenced by vitals
this._updateMood();
// Check auto-transitions from vitals
this._checkVitalTransitions();
// If intensity dropped to 0 -> natural transition
if (this.intensity <= 0) {
this.intensity = 0;
if (this.state !== 'idle') {
this.autoTransition();
}
}
// Crepuscular rhythm: long session -> energy drain
if (this.sessionTicks > RHYTHM.activityWindow) {
this.energy = clamp(this.energy - 0.005, 0, 1);
}
this._emit('tick', { ...this.current, eventType });
}
/**
* Passive tick — call periodically when nothing is happening.
* @param {number} minutesPassed — how many minutes since last activity
*/
passiveTick(minutesPassed = 1) {
const stateDef = STATES[this.state];
if (!stateDef) return;
const rates = this.state === 'sleep' || this.state === 'nap'
? VITAL_RATES.sleep
: VITAL_RATES.passive;
// Diminishing returns on hunger/loneliness growth
this.energy = clamp(this.energy + rates.energy * minutesPassed, 0, 1);
this.hunger = clamp(this.hunger + rates.hunger * (1 - this.hunger) * minutesPassed, 0, 1);
this.loneliness = clamp(this.loneliness + rates.loneliness * (1 - this.loneliness) * minutesPassed, 0, 1);
this.curiosity = clamp(this.curiosity + rates.curiosity * minutesPassed, 0, 1);
// Intensity decays over time (slower than interaction ticks)
this.intensity = clamp(this.intensity - stateDef.decay * 0.3 * minutesPassed, 0, 1);
this._updateMood();
this._checkVitalTransitions();
// Been quiet too long -> sleep or separation_stress
if (minutesPassed > 30 && this.state !== 'sleep') {
if (this.loneliness > VITAL_RATES.thresholds.lonely && this._unlocked.has('separation_stress')) {
this.transitionTo('separation_stress', 0.6, 'abandoned');
} else {
this.transitionTo('nap', 0.5, 'quiet');
}
}
if (this.intensity <= 0 && this.state !== 'idle') {
this.autoTransition();
}
this._emit('passive_tick', { ...this.current, minutesPassed });
}
/**
* Session start — call when owner/project becomes active.
* "Dawn" in corgi crepuscular rhythm.
*/
sessionStart() {
this.sessionTicks = 0;
this.energy = clamp(this.energy + RHYTHM.sessionStartBurst, 0, 1);
this.loneliness = clamp(this.loneliness - 0.3, 0, 1);
// Greeting frenzy if was lonely (and state is unlocked)
if ((this.loneliness > 0.3 || this.state === 'separation_stress') && this._unlocked.has('greeting_frenzy')) {
const prevState = this.state;
this.state = 'greeting_frenzy';
this.intensity = clamp(0.5 + this.loneliness * 0.5, 0.3, 1.0);
this.stateStartedAt = Date.now();
this._emit('transition', {
from: prevState,
to: 'greeting_frenzy',
reason: 'owner_returned',
});
}
this._emit('session_start', this.current);
}
// ── Vitals adjustment (external events) ──
adjustMood(delta) { this.mood = clamp(this.mood + delta, 0, 1); }
adjustEnergy(delta) { this.energy = clamp(this.energy + delta, 0, 1); }
adjustHunger(delta) { this.hunger = clamp(this.hunger + delta, 0, 1); }
adjustCuriosity(delta) { this.curiosity = clamp(this.curiosity + delta, 0, 1); }
reduceLoneliness(amount = 0.3) { this.loneliness = clamp(this.loneliness - amount, 0, 1); }
/**
* "Feed" the PES — give it a task/data to process.
* Reduces hunger, boosts mood and curiosity.
*/
feed(quality = 0.5) {
this.hunger = clamp(this.hunger - quality * 0.4, 0, 1);
this.mood = clamp(this.mood + quality * 0.1, 0, 1);
this.curiosity = clamp(this.curiosity + quality * 0.15, 0, 1);
if (this.state === 'food_obsessed') {
this.transitionTo('content', 0.7, 'fed');
}
}
// ── Auto-transition ──
/**
* Pick next state based on vitals, traits, and unlocked states.
* Called when intensity decays to 0 or explicitly by the engine.
*/
autoTransition() {
const rawCandidates = TRANSITIONS[this.state] || ['idle'];
const candidates = filterLocked(rawCandidates, this._unlocked);
if (candidates.length === 0) {
// No valid transitions available — fall back
const fb = fallbackState(this._unlocked);
this.state = fb;
this.intensity = 0.3;
this.stateStartedAt = Date.now();
return;
}
// Weight candidates by current vitals and traits
const scored = candidates.map(s => ({
state: s,
score: this._scoreCandidateState(s),
}));
scored.sort((a, b) => b.score - a.score);
// Try top 3 candidates
for (let i = 0; i < Math.min(3, scored.length); i++) {
if (this.transitionTo(scored[i].state, null, 'natural_decay')) {
return;
}
}
// Fallback to idle
const fb = fallbackState(this._unlocked);
this.state = fb;
this.intensity = 0.3;
this.stateStartedAt = Date.now();
}
// ── Traits (character) ──
// White Fang mechanics: early life shapes deeply,
// but persistent love can reshape ANY trait over time.
/**
* Set a trait value directly. Only during critical period.
*/
setTrait(name, value) {
if (this.criticalPeriodOver) return false;
if (!(name in this.traits)) return false;
this.traits[name] = clamp(value, 0, 1);
return true;
}
/**
* Nudge a trait — small adjustment from interaction.
*
* During critical period: full effect, easy to shape.
* After critical period: MUCH harder, but NOT impossible.
*
* Like White Fang being tamed by Weedon Scott:
* - First 100 tries: almost no visible change (resistance)
* - 100-300 tries: tiny cracks appear (0.03x effect)
* - 300-500 tries: slow but visible progress (0.08x effect)
* - 500+ tries with consistent streak: real change (0.15x effect)
*/
nudgeTrait(name, delta, trigger = null) {
if (!(name in this.traits)) return false;
// During critical period — full effect
if (!this.criticalPeriodOver) {
const oldValue = this.traits[name];
this.traits[name] = clamp(this.traits[name] + delta, 0, 1);
this._emit('trait_changed', { trait: name, oldValue, value: this.traits[name], delta, phase: 'critical_period', trigger });
return true;
}
// After critical period — White Fang mechanics
if (!this.traitChangeAttempts[name]) {
this.traitChangeAttempts[name] = { tries: 0, streak: 0, lastDirection: 0 };
}
const tracker = this.traitChangeAttempts[name];
const direction = delta > 0 ? 1 : -1;
// Streak: consecutive nudges in the same direction
if (direction === tracker.lastDirection) {
tracker.streak++;
} else {
// Changed direction — streak breaks, but some memory remains
tracker.streak = Math.max(0, Math.floor(tracker.streak * 0.3));
tracker.lastDirection = direction;
}
tracker.tries++;
// Calculate multiplier based on persistence
let multiplier;
if (tracker.tries < 100) {
multiplier = 0.01;
} else if (tracker.tries < 300) {
multiplier = 0.03;
} else if (tracker.tries < 500) {
multiplier = 0.08;
} else {
multiplier = 0.15;
}
// Streak bonus
const streakBonus = Math.min(tracker.streak * 0.001, 0.05);
multiplier += streakBonus;
const actualDelta = delta * multiplier;
const oldValue = this.traits[name];
this.traits[name] = clamp(this.traits[name] + actualDelta, 0, 1);
this._emit('trait_changed', {
trait: name,
oldValue,
value: this.traits[name],
delta: actualDelta,
requestedDelta: delta,
multiplier,
tries: tracker.tries,
streak: tracker.streak,
phase: 'post_critical',
trigger,
});
return true;
}
/**
* End critical period. After this, traits resist change
* but persistent love can still reshape them.
*/
endCriticalPeriod() {
this.criticalPeriodOver = true;
this._emit('critical_period_ended', {
traits: { ...this.traits },
message: 'Traits are now resistant to change, but persistent love can still reshape them.',
});
}
/**
* Get progress on changing a trait after critical period.
*/
getTraitChangeProgress(name) {
if (!this.traitChangeAttempts[name]) {
return { tries: 0, streak: 0, phase: 'no_attempts', multiplier: 0.01 };
}
const t = this.traitChangeAttempts[name];
let phase;
if (t.tries < 100) phase = 'resistance';
else if (t.tries < 300) phase = 'cracking';
else if (t.tries < 500) phase = 'learning';
else phase = 'transformed';
return { tries: t.tries, streak: t.streak, phase, lastDirection: t.lastDirection };
}
// ── Events ──
on(event, fn) {
const listener = { event, fn };
this.listeners.push(listener);
return () => {
this.listeners = this.listeners.filter(l => l !== listener);
};
}
// ── Serialize / Deserialize ──
/**
* Serialize the full PES emotional state for persistence.
*/
serialize() {
return {
state: this.state,
intensity: this.intensity,
stateStartedAt: this.stateStartedAt,
tickCount: this.tickCount,
sessionTicks: this.sessionTicks,
lastInteractionAt: this.lastInteractionAt,
mood: this.mood,
energy: this.energy,
hunger: this.hunger,
curiosity: this.curiosity,
loneliness: this.loneliness,
traits: { ...this.traits },
criticalPeriodOver: this.criticalPeriodOver,
traitChangeAttempts: JSON.parse(JSON.stringify(this.traitChangeAttempts)),
stateHistory: this.stateHistory.slice(-50),
unlockedStates: [...this._unlocked],
};
}
/**
* Restore PES from serialized data.
* @param {object} data — output of serialize()
* @param {Set<string>|null} unlockedEmotions — override unlocked set (if null, uses data.unlockedStates or basic)
*/
static deserialize(data, unlockedEmotions = null) {
// Determine unlocked set: explicit param > saved data > basic
let unlocked;
if (unlockedEmotions) {
unlocked = unlockedEmotions;
} else if (data.unlockedStates && Array.isArray(data.unlockedStates)) {
unlocked = new Set(data.unlockedStates);
} else {
unlocked = buildUnlockedSet(0);
}
const emo = new EmotionState(unlocked);
emo.state = data.state && STATES[data.state] ? data.state : 'idle';
emo.intensity = data.intensity ?? 0.5;
emo.stateStartedAt = data.stateStartedAt || Date.now();
emo.tickCount = data.tickCount || 0;
emo.sessionTicks = data.sessionTicks || 0;
emo.lastInteractionAt = data.lastInteractionAt || Date.now();
emo.mood = data.mood ?? 0.5;
emo.energy = data.energy ?? 0.8;
emo.hunger = data.hunger ?? 0.2;
emo.curiosity = data.curiosity ?? 0.5;
emo.loneliness = data.loneliness ?? 0.0;
if (data.traits) emo.traits = { ...emo.traits, ...data.traits };
emo.criticalPeriodOver = data.criticalPeriodOver || false;
emo.traitChangeAttempts = data.traitChangeAttempts || {};
emo.stateHistory = data.stateHistory || [];
// If restored state is locked, fall back
if (!emo._unlocked.has(emo.state)) {
emo.state = fallbackState(emo._unlocked);
emo.intensity = 0.3;
}
return emo;
}
// ── Private ──
_updateMood() {
const vitalPressure =
- this.hunger * 0.02
- this.loneliness * 0.03
+ (this.energy > 0.5 ? 0.005 : -0.005)
+ (this.curiosity > 0.5 ? 0.005 : 0);
this.mood = clamp(this.mood + vitalPressure * 0.1, 0, 1);
}
_checkVitalTransitions() {
const t = VITAL_RATES.thresholds;
// Exhausted -> sleep/nap
if (this.energy < t.exhausted && this.state !== 'sleep' && this.state !== 'nap') {
// Try sleep (level 10), then nap (level 0), then corgi_flop (level 10)
if (this._unlocked.has('sleep') && this.transitionTo('sleep', 0.8, 'exhausted')) return;
if (this.transitionTo('nap', 0.6, 'exhausted')) return;
if (this._unlocked.has('corgi_flop') && this.transitionTo('corgi_flop', 0.9, 'exhausted')) return;
return;
}
// Starving -> food obsession or grumble
if (this.hunger > t.starving && this.state !== 'food_obsessed' && this.state !== 'sleep') {
if (this._unlocked.has('food_obsessed') && this.transitionTo('food_obsessed', 0.7, 'starving')) return;
if (this._unlocked.has('grumble') && this.transitionTo('grumble', 0.5, 'hungry')) return;
return;
}
// Lonely -> puppy eyes or separation stress
if (this.loneliness > t.lonely && !['puppy_eyes', 'separation_stress', 'sleep'].includes(this.state)) {
if (this._unlocked.has('puppy_eyes') && this.transitionTo('puppy_eyes', 0.6, 'lonely')) return;
if (this._unlocked.has('separation_stress') && this.transitionTo('separation_stress', 0.5, 'lonely')) return;
return;
}
// Bored -> sneaky or grumble
if (this.curiosity < t.bored && ['idle', 'content'].includes(this.state)) {
if (this._unlocked.has('sneaky') && this.transitionTo('sneaky', 0.4, 'bored')) return;
if (this._unlocked.has('grumble') && this.transitionTo('grumble', 0.3, 'bored')) return;
return;
}
// Hyper -> zoomies
if (this.energy > t.hyper && this.curiosity > 0.7 && ['playful', 'happy', 'idle'].includes(this.state)) {
if (this._unlocked.has('zoomies') && this.transitionTo('zoomies', 0.9, 'hyper')) return;
return;
}
}
_scoreCandidateState(state) {
let score = STATES[state]?.weight || 0.1;
// Trait influence
if (['playful', 'zoomies', 'play_bow', 'wanna_play'].includes(state)) {
score += this.traits.playfulness * 0.3;
}
if (['grumble', 'side_eye'].includes(state)) {
score += this.traits.sassiness * 0.2;
}
if (['dramatic_tantrum', 'sulking'].includes(state)) {
score += this.traits.drama * 0.2;
}
if (['stubborn_refuse'].includes(state)) {
score += this.traits.stubbornness * 0.3;
}
if (['food_obsessed'].includes(state)) {
score += this.traits.foodDrive * 0.2;
}
if (['herding', 'bossy'].includes(state)) {
score += 0.15;
}
if (['alert', 'puzzle_solving'].includes(state)) {
score += this.curiosity * 0.2;
}
if (['scared', 'anxious'].includes(state)) {
score -= this.traits.courage * 0.3;
}
// Mood influence
if (['happy', 'content', 'playful'].includes(state)) {
score += this.mood * 0.15;
}
if (['grumble', 'sulking', 'dramatic_tantrum'].includes(state)) {
score += (1 - this.mood) * 0.15;
}
// Energy influence
if (['sleep', 'nap', 'sploot', 'corgi_flop'].includes(state)) {
score += (1 - this.energy) * 0.3;
}
if (['zoomies', 'playful', 'herding'].includes(state)) {
score += this.energy * 0.2;
}
// Small random factor for unpredictability
score += Math.random() * 0.1;
return score;
}
_randomIntensity(state) {
const base = STATES[state]?.weight || 0.3;
return clamp(
base + (Math.random() * 0.4 - 0.2) + (this.mood - 0.5) * 0.2,
0.1, 1.0
);
}
_emit(event, data) {
for (const listener of this.listeners) {
if (listener.event === event) {
try { listener.fn(data); } catch (e) { console.error('[EMO] listener error:', e.message); }
}
}
}
}
export { EmotionState, STATES, EMOTION_LEVELS, TRANSITIONS, VITAL_RATES, RHYTHM, buildUnlockedSet };