Governed substrate for autonomous agents: scoped identity (passports), audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
672 lines
23 KiB
JavaScript
672 lines
23 KiB
JavaScript
/**
|
||
* PES v2 — Main Controller (Level-Unlock Architecture)
|
||
*
|
||
* The BODY of PES. Wires all subsystems together:
|
||
* store (MemoryStore) + emotions (EmotionState) + babble (BabbleEngine)
|
||
* + tick loop (heartbeat) + level-unlock controller
|
||
*
|
||
* ES module. No triggers.js, symbols.js, or expression-engine.js.
|
||
* All expression logic handled by babble-engine; telegram adapter handles stickers.
|
||
*/
|
||
|
||
import { EventEmitter } from 'node:events';
|
||
import { existsSync, mkdirSync } from 'node:fs';
|
||
import { dirname } from 'node:path';
|
||
|
||
import { EmotionState, STATES, buildUnlockedSet } from '../soul/emotions.js';
|
||
import { BabbleEngine } from '../soul/babble-engine.js';
|
||
import MemoryStore from '../memory/store.js';
|
||
|
||
// ── XP Awards ────────────────────────────────────────────────
|
||
const XP = {
|
||
message: 1,
|
||
praise: 3,
|
||
scold: -1,
|
||
sticker: 2,
|
||
reaction: 1,
|
||
command_learned: 8,
|
||
dialogue_bonus: 2,
|
||
};
|
||
|
||
// ── Tick Intervals (ms) ──────────────────────────────────────
|
||
const TICK_INTERVALS = {
|
||
active: 30_000, // 30s — owner present
|
||
idle: 5 * 60_000, // 5min — nearby but nothing
|
||
sleeping: 15 * 60_000, // 15min — dreams
|
||
away: 30 * 60_000, // 30min — owner left
|
||
};
|
||
|
||
// ── Silence thresholds for auto-mode (ms) ────────────────────
|
||
const SILENCE = {
|
||
toIdle: 2 * 60_000, // 2min silence → idle
|
||
toSleeping: 15 * 60_000, // 15min silence → sleeping
|
||
toAway: 60 * 60_000, // 1h silence → away
|
||
};
|
||
|
||
// ── Trait seeding profiles ───────────────────────────────────
|
||
const TRAIT_PROFILES = [
|
||
{ max: 0.2, traits: { courage: 0.85, aggression: 0.7, playfulness: 0.4, sass: 0.3, curiosity_trait: 0.5 } },
|
||
{ max: 0.4, traits: { curiosity_trait: 0.85, playfulness: 0.8, courage: 0.5, drama: 0.3, loyalty: 0.5 } },
|
||
{ max: 0.6, traits: { courage: 0.5, curiosity_trait: 0.5, loyalty: 0.5, playfulness: 0.5, drama: 0.4 } },
|
||
{ max: 0.8, traits: { loyalty: 0.85, stubbornness: 0.75, courage: 0.6, playfulness: 0.4, sass: 0.3 } },
|
||
{ max: 1.01, traits: { drama: 0.85, sass: 0.8, playfulness: 0.6, curiosity_trait: 0.6, stubbornness: 0.4 } },
|
||
];
|
||
|
||
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
|
||
|
||
|
||
class Pes extends EventEmitter {
|
||
/**
|
||
* @param {object} config
|
||
* @param {string} config.name — e.g. 'Тор'
|
||
* @param {string} config.ownerId — telegram user id
|
||
* @param {string} config.dbPath — path to SQLite file
|
||
* @param {string} config.breed — e.g. 'corgi'
|
||
* @param {number} config.seed — 0-1, personality seed
|
||
* @param {boolean} [config.autoTick=true]
|
||
*/
|
||
constructor(config) {
|
||
super();
|
||
this.name = config.name;
|
||
this.ownerId = config.ownerId;
|
||
this.dbPath = config.dbPath;
|
||
this.breed = config.breed || 'corgi';
|
||
this.seed = clamp(config.seed ?? 0.5, 0, 1);
|
||
this.autoTick = config.autoTick !== false;
|
||
|
||
// Subsystems — initialized in birth() or wake()
|
||
this.store = null;
|
||
this.emotions = null;
|
||
this.babble = null;
|
||
|
||
// Tick state
|
||
this._tickMode = 'idle';
|
||
this._tickTimer = null;
|
||
this._lastEventAt = Date.now();
|
||
this._alive = false;
|
||
this.silentMode = false; // toggled by /silent command
|
||
}
|
||
|
||
// ── Compatibility getters (telegram.js expects these) ────
|
||
|
||
get level() {
|
||
return this.store?.getStats()?.level || 0;
|
||
}
|
||
|
||
get mood() {
|
||
return this.emotions?.mood ?? 0.5;
|
||
}
|
||
|
||
get traits() {
|
||
if (!this.store) return {};
|
||
const stats = this.store.getStats();
|
||
return stats?.traits || {};
|
||
}
|
||
|
||
/** Compat: telegram.js checks pes.engine?.silentMode */
|
||
get engine() {
|
||
return { silentMode: this.silentMode };
|
||
}
|
||
|
||
/** Compat: telegram.js checks pes.identity?.seed */
|
||
get identity() {
|
||
return { seed: this.seed };
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════
|
||
// LIFECYCLE
|
||
// ════════════════════════════════════════════════════════════
|
||
|
||
/**
|
||
* First-time init: create DB, seed traits, start ticking.
|
||
*/
|
||
birth() {
|
||
const dir = dirname(this.dbPath);
|
||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
||
|
||
this.store = new MemoryStore(this.dbPath);
|
||
this.store.init();
|
||
|
||
// Seed initial stats if fresh
|
||
const stats = this.store.getStats();
|
||
if (!stats || stats.xp === 0) {
|
||
this._seedTraits();
|
||
this.store.setConfig('name', this.name);
|
||
this.store.setConfig('breed', this.breed);
|
||
this.store.setConfig('owner_id', this.ownerId);
|
||
this.store.setConfig('seed', String(this.seed));
|
||
this.store.setConfig('born_at', new Date().toISOString());
|
||
}
|
||
|
||
// Init emotion engine with level-0 unlocks
|
||
const level = stats?.level || 0;
|
||
this.emotions = new EmotionState(buildUnlockedSet(level));
|
||
this._seedEmotionTraits();
|
||
|
||
// Init babble engine
|
||
const unlockedSounds = this._getUnlockedSounds();
|
||
this.babble = new BabbleEngine(this.seed, unlockedSounds);
|
||
|
||
this._alive = true;
|
||
if (this.autoTick) this._startTick('active');
|
||
|
||
this.emit('born', { name: this.name, breed: this.breed });
|
||
return this;
|
||
}
|
||
|
||
/**
|
||
* Resume from saved state: load emotions, apply passive time decay.
|
||
*/
|
||
wake() {
|
||
const dir = dirname(this.dbPath);
|
||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
||
|
||
this.store = new MemoryStore(this.dbPath);
|
||
this.store.init();
|
||
|
||
const stats = this.store.getStats();
|
||
const level = stats?.level || 0;
|
||
|
||
// Restore emotions
|
||
const savedEmotion = this.store.getConfig('emotion_state');
|
||
if (savedEmotion) {
|
||
try {
|
||
const data = JSON.parse(savedEmotion);
|
||
this.emotions = EmotionState.deserialize(data, buildUnlockedSet(level));
|
||
} catch {
|
||
this.emotions = new EmotionState(buildUnlockedSet(level));
|
||
}
|
||
} else {
|
||
this.emotions = new EmotionState(buildUnlockedSet(level));
|
||
}
|
||
|
||
// Restore babble
|
||
const unlockedSounds = this._getUnlockedSounds();
|
||
const savedBabble = this.store.getConfig('babble_state');
|
||
if (savedBabble) {
|
||
try {
|
||
const data = JSON.parse(savedBabble);
|
||
this.babble = BabbleEngine.deserialize(data, this.seed);
|
||
this.babble.setUnlockedSounds(unlockedSounds);
|
||
} catch {
|
||
this.babble = new BabbleEngine(this.seed, unlockedSounds);
|
||
}
|
||
} else {
|
||
this.babble = new BabbleEngine(this.seed, unlockedSounds);
|
||
}
|
||
|
||
// Apply passive time decay since last checkpoint
|
||
const minutesSleeping = this._calcSleepMinutes();
|
||
if (minutesSleeping > 0) {
|
||
this.emotions.passiveTick(minutesSleeping);
|
||
}
|
||
|
||
this._alive = true;
|
||
if (this.autoTick) this._startTick('idle');
|
||
|
||
this.emit('wake', { name: this.name, minutesSleeping });
|
||
return this;
|
||
}
|
||
|
||
/**
|
||
* Save state, stop ticking.
|
||
*/
|
||
sleep() {
|
||
this._checkpoint();
|
||
this._stopTick();
|
||
this._alive = false;
|
||
this.emit('sleep', {});
|
||
}
|
||
|
||
/**
|
||
* Cleanup intervals, close DB.
|
||
*/
|
||
destroy() {
|
||
this._stopTick();
|
||
this._alive = false;
|
||
if (this.store) {
|
||
try { this._checkpoint(); } catch (e) { console.error('[PES] checkpoint on destroy failed:', e.message); }
|
||
this.store.close();
|
||
this.store = null;
|
||
}
|
||
this.removeAllListeners();
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════
|
||
// CORE API
|
||
// ════════════════════════════════════════════════════════════
|
||
|
||
/**
|
||
* Main event entry point — trigger event → emotion change → XP → emit expression.
|
||
* @param {{ type: string, from: string, data: any }} evt
|
||
*/
|
||
event(evt) {
|
||
if (!this._alive) return null;
|
||
// Compat: crm-bridge sends {type, source, detail}, telegram sends {type, from, content}
|
||
const type = evt.type;
|
||
const from = evt.from || evt.source || this.ownerId;
|
||
const data = evt.data || evt.detail || evt.content;
|
||
|
||
this._lastEventAt = Date.now();
|
||
this._ensureTickMode('active');
|
||
|
||
// Emotion interaction tick
|
||
this.emotions.interactionTick(type);
|
||
this.emotions.reduceLoneliness(0.1);
|
||
|
||
// XP
|
||
const xpAmount = XP[type] ?? XP.message;
|
||
const xpResult = this.store.addXP(xpAmount, type, typeof data === 'string' ? data : null);
|
||
|
||
// Log interaction
|
||
const emoBefore = this.emotions.state;
|
||
this.store.logInteraction({
|
||
actor: from || this.ownerId,
|
||
action_type: type,
|
||
action_detail: typeof data === 'string' ? data.slice(0, 200) : null,
|
||
emotion_before: emoBefore,
|
||
emotion_after: this.emotions.state,
|
||
xp_gained: xpAmount,
|
||
});
|
||
|
||
// Update relationship
|
||
if (from) {
|
||
this.store.upsertRelationship(from, {
|
||
trust: type === 'praise' ? 0.02 : 0.005,
|
||
affection: type === 'praise' ? 0.03 : 0.01,
|
||
});
|
||
}
|
||
|
||
// Check level-up and unlocks
|
||
if (xpResult.levelUp) {
|
||
this._onLevelUp(xpResult.level, xpResult.phase);
|
||
}
|
||
|
||
// Daily streak
|
||
let streakResult = null;
|
||
try { streakResult = this.store.updateStreak(); } catch (e) { console.error('[PES] streak error:', e.message); }
|
||
|
||
// Streak bonus XP (new day = +5 XP bonus per streak day, max +25)
|
||
if (streakResult?.isNew && streakResult.current > 1) {
|
||
const bonus = Math.min(streakResult.current * 5, 25);
|
||
this.store.addXP(bonus, 'streak_bonus', `streak:${streakResult.current}`);
|
||
}
|
||
|
||
// Check achievements
|
||
let newAchievements = [];
|
||
try { newAchievements = this.store.checkAchievements(); } catch (e) { console.error('[PES] achievements error:', e.message); }
|
||
|
||
// Emit achievement events
|
||
for (const ach of newAchievements) {
|
||
this.emit('achievement', ach);
|
||
}
|
||
|
||
// Generate expression
|
||
const expression = this._express();
|
||
this.emit('expression', expression);
|
||
|
||
return { xp: xpResult, expression, streak: streakResult, achievements: newAchievements };
|
||
}
|
||
|
||
/**
|
||
* Positive feedback → mood up, trust up, XP+3.
|
||
*/
|
||
praise() {
|
||
this.emotions.adjustMood(0.15);
|
||
this.emotions.nudgeTrait('loyalty', 0.02, 'praise');
|
||
this.emotions.nudgeTrait('playfulness', 0.01, 'praise');
|
||
|
||
this.emotions.transitionTo('happy', null, 'praise')
|
||
|| this.emotions.transitionTo('butt_wiggle', null, 'praise');
|
||
|
||
return this.event({ type: 'praise', from: this.ownerId, data: 'praise' });
|
||
}
|
||
|
||
/**
|
||
* Negative feedback → mood down, XP-1.
|
||
*/
|
||
scold() {
|
||
this.emotions.adjustMood(-0.1);
|
||
this.emotions.nudgeTrait('courage', -0.01, 'scold');
|
||
|
||
this.emotions.transitionTo('scared', null, 'scold')
|
||
|| this.emotions.transitionTo('sulking', null, 'scold');
|
||
|
||
return this.event({ type: 'scold', from: this.ownerId, data: 'scold' });
|
||
}
|
||
|
||
/**
|
||
* Feed with data/task → hunger down.
|
||
* @param {number|object} quality — 0 to 1, or {type, content} for compat
|
||
*/
|
||
feed(quality = 0.5) {
|
||
// Compat: telegram.js passes {type: 'text', content: '...'}
|
||
const q = typeof quality === 'object' ? 0.5 : quality;
|
||
this.emotions.feed(q);
|
||
return this.event({ type: 'message', from: this.ownerId, data: 'feed' });
|
||
}
|
||
|
||
/**
|
||
* Collect feedback/reaction from owner.
|
||
* @param {{ reaction_type: string, reaction_value: string, platform: string }} opts
|
||
*/
|
||
react({ reaction_type, reaction_value, platform }) {
|
||
this.store.logReaction({
|
||
pes_action: this.emotions.state,
|
||
reactor: this.ownerId,
|
||
reaction_type,
|
||
reaction_value,
|
||
weight: reaction_value === 'positive' ? 1 : reaction_value === 'negative' ? -1 : 0,
|
||
platform,
|
||
});
|
||
return this.event({ type: 'reaction', from: this.ownerId, data: reaction_type });
|
||
}
|
||
|
||
/**
|
||
* Teach or use a command.
|
||
* @param {string} input — command text
|
||
* @param {boolean|object} success — true/false or {target: '...'} for compat
|
||
*/
|
||
command(input, success = false) {
|
||
// Compat: telegram.js passes {target: '...'} as second arg
|
||
const isSuccess = typeof success === 'boolean' ? success : false;
|
||
const cmd = this.store.learnCommand(input);
|
||
if (isSuccess && cmd && !cmd.understood) {
|
||
this.store.markCommandLearned(cmd.id);
|
||
return this.event({ type: 'command_learned', from: this.ownerId, data: input });
|
||
}
|
||
return this.event({ type: 'message', from: this.ownerId, data: `cmd:${input}` });
|
||
}
|
||
|
||
/**
|
||
* Full status snapshot.
|
||
* @returns {{ name, level, phase, emotion, vitals, traits, unlocks, ... }}
|
||
*/
|
||
status() {
|
||
const stats = this.store?.getStats() || {};
|
||
const emo = this.emotions || {};
|
||
const unlocks = this.store?.getUnlockedFeatures() || [];
|
||
const next = this.store?.getNextUnlock(stats.level) || null;
|
||
const ageDays = this.store?.getAgeDays() || 0;
|
||
|
||
// Flat structure for telegram.js compat
|
||
return {
|
||
name: this.name,
|
||
breed: this.breed,
|
||
level: stats.level || 0,
|
||
xp: stats.xp || 0,
|
||
phase: stats.phase || 'puppy',
|
||
// Flat vitals (telegram.js expects s.mood, s.energy, etc.)
|
||
mood: emo.mood ?? 0.5,
|
||
energy: emo.energy ?? 0.8,
|
||
hunger: emo.hunger ?? 0.2,
|
||
curiosity: emo.curiosity ?? 0.5,
|
||
loneliness: emo.loneliness ?? 0,
|
||
// Emotion
|
||
emotion: emo.state || 'idle',
|
||
intensity: emo.intensity || 0.5,
|
||
// Nested vitals (for code that uses s.vitals.mood)
|
||
vitals: {
|
||
mood: emo.mood ?? 0.5,
|
||
energy: emo.energy ?? 0.8,
|
||
hunger: emo.hunger ?? 0.2,
|
||
curiosity: emo.curiosity ?? 0.5,
|
||
loneliness: emo.loneliness ?? 0,
|
||
},
|
||
traits: emo.traits || {},
|
||
age: ageDays,
|
||
unlocks,
|
||
nextUnlock: next ? { feature: next.feature, level: next.level_req } : null,
|
||
tickMode: this._tickMode,
|
||
alive: this._alive,
|
||
streak: this.store?.getStreak() || { current: 0, longest: 0, total: 0 },
|
||
achievements: this.store?.getAchievementProgress() || { total: 0, unlocked: 0, achievements: [] },
|
||
};
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════
|
||
// TICK SYSTEM (heartbeat)
|
||
// ════════════════════════════════════════════════════════════
|
||
|
||
_startTick(mode) {
|
||
this._stopTick();
|
||
this._tickMode = mode;
|
||
const interval = TICK_INTERVALS[mode] || TICK_INTERVALS.idle;
|
||
this._tickTimer = setInterval(() => this._tick(), interval);
|
||
}
|
||
|
||
_stopTick() {
|
||
if (this._tickTimer) {
|
||
clearInterval(this._tickTimer);
|
||
this._tickTimer = null;
|
||
}
|
||
}
|
||
|
||
_tick() {
|
||
if (!this._alive || !this.emotions) return;
|
||
|
||
const silenceMs = Date.now() - this._lastEventAt;
|
||
|
||
// Auto-mode transition based on silence duration
|
||
const newMode = this._silenceToMode(silenceMs);
|
||
if (newMode !== this._tickMode) {
|
||
this._startTick(newMode);
|
||
}
|
||
|
||
// Passive tick — cap at 5 minutes per tick to avoid vitals spike
|
||
const minutesPassed = Math.floor(silenceMs / 60_000);
|
||
if (minutesPassed > 0) {
|
||
this.emotions.passiveTick(Math.min(minutesPassed, 5));
|
||
}
|
||
|
||
// Periodic checkpoint
|
||
if (this.emotions.tickCount % 10 === 0) {
|
||
this._checkpoint();
|
||
}
|
||
}
|
||
|
||
_silenceToMode(ms) {
|
||
if (ms < SILENCE.toIdle) return 'active';
|
||
if (ms < SILENCE.toSleeping) return 'idle';
|
||
if (ms < SILENCE.toAway) return 'sleeping';
|
||
return 'away';
|
||
}
|
||
|
||
_ensureTickMode(mode) {
|
||
if (this._tickMode !== mode) {
|
||
this._startTick(mode);
|
||
}
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════
|
||
// LEVEL-UNLOCK CONTROLLER
|
||
// ════════════════════════════════════════════════════════════
|
||
|
||
/**
|
||
* Called on every level-up. Checks for new unlocks, updates subsystems.
|
||
*/
|
||
_onLevelUp(level, phase) {
|
||
const floorLevel = Math.floor(level);
|
||
|
||
this.emit('level_up', { level: floorLevel, phase });
|
||
|
||
// Find features that just became available at this exact level
|
||
const allUnlocks = this.store.getUnlocks();
|
||
const justUnlocked = allUnlocks.filter(u =>
|
||
u.unlocked === 1 && u.level_req === floorLevel
|
||
);
|
||
|
||
for (const u of justUnlocked) {
|
||
this.emit('unlock', {
|
||
feature: u.feature,
|
||
level: u.level_req,
|
||
category: u.category,
|
||
});
|
||
}
|
||
|
||
// Update emotion FSM with newly unlocked emotions
|
||
const newEmotionSet = buildUnlockedSet(floorLevel);
|
||
this.emotions.setUnlockedEmotions(newEmotionSet);
|
||
|
||
// Update babble engine with newly unlocked sound tiers
|
||
const sounds = this._getUnlockedSounds();
|
||
this.babble.setUnlockedSounds(sounds);
|
||
|
||
// End critical period at level 5 (traits become resistant to change)
|
||
if (floorLevel >= 5 && !this.emotions.criticalPeriodOver) {
|
||
this.emotions.endCriticalPeriod();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Get list of unlocked sound-tier feature names for babble engine.
|
||
*/
|
||
_getUnlockedSounds() {
|
||
if (!this.store) return [];
|
||
const features = this.store.getUnlockedFeatures();
|
||
const soundFeatures = [
|
||
'babble_tier0', 'combo_phrases', 'babble_tier1', 'babble_tier2',
|
||
'babble_tier3', 'sound_invention', 'advanced_babble', 'crypto_language',
|
||
];
|
||
return features.filter(f => soundFeatures.includes(f));
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════
|
||
// EXPRESSION GENERATOR
|
||
// ════════════════════════════════════════════════════════════
|
||
|
||
/**
|
||
* Generate an expression object from current emotional state.
|
||
* Emitted as 'expression' event for platform adapters (telegram).
|
||
*/
|
||
_express() {
|
||
const stats = this.store.getStats();
|
||
const level = Math.floor(stats?.level || 0);
|
||
const emoState = this.emotions.state;
|
||
const intensity = this.emotions.intensity;
|
||
|
||
const babble = this.babble.generate(level, emoState, intensity);
|
||
const stickerHint = this._stickerHint(emoState, intensity);
|
||
|
||
return {
|
||
emotion: emoState,
|
||
intensity,
|
||
babble,
|
||
sticker_hint: stickerHint,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Map emotion + intensity to a sticker hint category.
|
||
* The telegram adapter uses this to pick from learned sticker sets.
|
||
*/
|
||
_stickerHint(emotion, intensity) {
|
||
const cat = STATES[emotion]?.category || 'baseline';
|
||
if (cat === 'happy' && intensity > 0.6) return 'excited';
|
||
if (cat === 'happy') return 'happy';
|
||
if (cat === 'social') return 'social';
|
||
if (cat === 'working') return 'focused';
|
||
if (cat === 'dramatic') return 'dramatic';
|
||
if (cat === 'stress') return 'scared';
|
||
if (cat === 'rest') return 'sleepy';
|
||
if (cat === 'rare' && emotion === 'food_obsessed') return 'hungry';
|
||
if (cat === 'rare') return 'sneaky';
|
||
return 'neutral';
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════
|
||
// TRAIT SEEDING
|
||
// ════════════════════════════════════════════════════════════
|
||
|
||
/**
|
||
* Seed initial trait values from config.seed deterministically.
|
||
*/
|
||
_seedTraits() {
|
||
const profile = TRAIT_PROFILES.find(p => this.seed < p.max) || TRAIT_PROFILES[2];
|
||
const stats = this.store.getStats();
|
||
if (!stats) return;
|
||
|
||
const updates = {};
|
||
for (const [key, val] of Object.entries(profile.traits)) {
|
||
// Deterministic jitter from seed
|
||
const jitter = (this.seed * 7.31 % 0.2) - 0.1;
|
||
updates[key] = clamp(val + jitter, 0, 1);
|
||
}
|
||
this.store.updateStats(updates);
|
||
}
|
||
|
||
/**
|
||
* Copy stored traits into emotion engine (used on birth/wake).
|
||
*/
|
||
_seedEmotionTraits() {
|
||
if (!this.emotions || !this.store) return;
|
||
const stats = this.store.getStats();
|
||
if (!stats?.traits) return;
|
||
|
||
const mapping = {
|
||
courage: 'courage',
|
||
curiosity_trait: 'curiosity',
|
||
loyalty: 'loyalty',
|
||
stubbornness: 'stubbornness',
|
||
playfulness: 'playfulness',
|
||
drama: 'drama',
|
||
food_obsession: 'foodDrive',
|
||
sass: 'sassiness',
|
||
};
|
||
|
||
for (const [storeKey, emotionKey] of Object.entries(mapping)) {
|
||
if (stats.traits[storeKey] !== undefined) {
|
||
this.emotions.setTrait(emotionKey, stats.traits[storeKey]);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ════════════════════════════════════════════════════════════
|
||
// STATE PERSISTENCE
|
||
// ════════════════════════════════════════════════════════════
|
||
|
||
/**
|
||
* Save current state to DB (emotions, babble, vitals).
|
||
*/
|
||
_checkpoint() {
|
||
if (!this.store || !this.emotions) return;
|
||
|
||
try {
|
||
// Emotion state
|
||
this.store.setConfig('emotion_state', JSON.stringify(this.emotions.serialize()));
|
||
|
||
// Babble state
|
||
if (this.babble) {
|
||
this.store.setConfig('babble_state', JSON.stringify(this.babble.serialize()));
|
||
}
|
||
|
||
// Timestamp for sleep-duration calc
|
||
this.store.setConfig('last_checkpoint', new Date().toISOString());
|
||
|
||
// Vitals to stats table
|
||
const emo = this.emotions.current;
|
||
this.store.updateStats({
|
||
mood: emo.mood,
|
||
energy: emo.energy,
|
||
hunger: emo.hunger,
|
||
loneliness: emo.loneliness,
|
||
});
|
||
} catch (e) { console.error('[PES] _checkpoint failed:', e.message); }
|
||
}
|
||
|
||
/**
|
||
* Calculate how many minutes PES was sleeping since last checkpoint.
|
||
*/
|
||
_calcSleepMinutes() {
|
||
if (!this.store) return 0;
|
||
const lastStr = this.store.getConfig('last_checkpoint');
|
||
if (!lastStr) return 0;
|
||
const last = new Date(lastStr).getTime();
|
||
if (isNaN(last)) return 0;
|
||
return Math.max(0, Math.floor((Date.now() - last) / 60_000));
|
||
}
|
||
}
|
||
|
||
|
||
export default Pes;
|
||
export { Pes, XP, TICK_INTERVALS, TRAIT_PROFILES };
|