// ============================================================ // PES v2 Memory Store — Level-Unlock Architecture // ============================================================ // Clean rebuild. No migrations. Level-gated features. // ============================================================ import Database from 'better-sqlite3'; import { readFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; const __dirname = dirname(fileURLToPath(import.meta.url)); const TRAITS = [ 'courage', 'curiosity_trait', 'loyalty', 'stubbornness', 'playfulness', 'drama', 'food_obsession', 'sass', 'aggression' ]; const PHASE_THRESHOLDS = { puppy: 0, young: 200, adult: 500, experienced: 1000, cyber: 2000 }; // ── Level-Unlock Tree ──────────────────────────────────── // Every feature PES can do, gated by level. const UNLOCK_TREE = [ // Level 0 — birth { feature: 'basic_emotions', level: 0, category: 'emotion' }, { feature: 'babble_tier0', level: 0, category: 'sound' }, { feature: 'sticker_react', level: 0, category: 'core' }, // Level 2 { feature: 'combo_phrases', level: 2, category: 'sound' }, { feature: 'emoji_learn', level: 2, category: 'core' }, // Level 3 { feature: 'notes', level: 3, category: 'ability' }, { feature: 'emotion_social', level: 3, category: 'emotion' }, // Level 4 { feature: 'reminders', level: 4, category: 'ability' }, { feature: 'babble_tier1', level: 4, category: 'sound' }, // Level 5 { feature: 'emotion_working', level: 5, category: 'emotion' }, { feature: 'search_memory', level: 5, category: 'ability' }, // Level 6 { feature: 'file_storage', level: 6, category: 'ability' }, { feature: 'babble_tier2', level: 6, category: 'sound' }, // Level 8 { feature: 'contacts', level: 8, category: 'ability' }, { feature: 'emotion_dramatic', level: 8, category: 'emotion' }, { feature: 'crm_read', level: 8, category: 'crm' }, // Level 10 { feature: 'emotion_full', level: 10, category: 'emotion' }, { feature: 'babble_tier3', level: 10, category: 'sound' }, { feature: 'crm_write', level: 10, category: 'crm' }, // Level 12 { feature: 'orchestrator', level: 12, category: 'ability' }, { feature: 'analytics', level: 12, category: 'crm' }, // Level 15 { feature: 'sound_invention', level: 15, category: 'sound' }, { feature: 'crm_tasks', level: 15, category: 'crm' }, // Level 20 { feature: 'advanced_babble', level: 20, category: 'sound' }, { feature: 'automation', level: 20, category: 'ability' }, // Level 25 { feature: 'social_pets', level: 10, category: 'social' }, { feature: 'templates', level: 25, category: 'ability' }, // Level 30+ { feature: 'spells', level: 30, category: 'ability' }, { feature: 'crypto_language', level: 40, category: 'sound' }, ]; function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); } function now() { return new Date().toISOString(); } export class MemoryStore { constructor(dbPath) { this.dbPath = dbPath; this.db = new Database(dbPath); this.db.pragma('journal_mode = WAL'); this.db.pragma('foreign_keys = ON'); this.db.pragma('synchronous = NORMAL'); this._checkpointInterval = setInterval(() => { try { this.db.pragma('wal_checkpoint(TRUNCATE)'); } catch (_) {} }, 5 * 60 * 1000); } // ── Init ───────────────────────────────────────────────── init() { const sql = readFileSync(join(__dirname, 'schema.sql'), 'utf-8'); this.db.exec(sql); this._seedUnlocks(); this._seedAchievements(); return this; } _seedUnlocks() { const insert = this.db.prepare( 'INSERT OR IGNORE INTO unlocks (feature, level_req, category) VALUES (?, ?, ?)' ); const tx = this.db.transaction(() => { for (const u of UNLOCK_TREE) { insert.run(u.feature, u.level, u.category); } }); tx(); } // ── Config ─────────────────────────────────────────────── getConfig(key) { const row = this.db.prepare('SELECT value FROM config WHERE key = ?').get(key); return row ? row.value : null; } setConfig(key, value) { this.db.prepare('INSERT OR REPLACE INTO config (key, value) VALUES (?, ?)').run(key, String(value)); } // ── Stats ──────────────────────────────────────────────── getStats() { let row = this.db.prepare('SELECT * FROM stats WHERE id = 1').get(); if (!row) { this.db.prepare('INSERT OR IGNORE INTO stats (id) VALUES (1)').run(); row = this.db.prepare('SELECT * FROM stats WHERE id = 1').get(); if (!row) return null; } row.traits = {}; for (const t of TRAITS) row.traits[t] = row[t]; try { row.trait_phases = JSON.parse(row.trait_phases); } catch { row.trait_phases = {}; } return row; } updateStats(data) { const sets = []; const vals = []; for (const [k, v] of Object.entries(data)) { if (k === 'id') continue; sets.push(`${k} = ?`); vals.push(typeof v === 'object' ? JSON.stringify(v) : v); } if (sets.length === 0) return; vals.push(1); this.db.prepare(`UPDATE stats SET ${sets.join(', ')} WHERE id = ?`).run(...vals); } // ── XP ─────────────────────────────────────────────────── addXP(amount, reason, detail = null) { this.db.prepare('INSERT INTO xp_log (amount, reason, detail) VALUES (?, ?, ?)').run(amount, reason, detail); const stats = this.getStats(); const newXP = stats.xp + amount; const newLevel = this._xpToLevel(newXP); const newPhase = this._levelToPhase(newLevel); const leveledUp = Math.floor(newLevel) > Math.floor(stats.level || 0); this.updateStats({ xp: newXP, level: newLevel, phase: newPhase }); // Check unlocks const unlocked = this._checkUnlocks(newLevel); // Return compat object (telegram.js expects these fields) return { xp: newXP, level: newLevel, phase: newPhase, levelUp: leveledUp, leveledUp, added: amount, dailyXP: amount, dailyCap: 999, unlocked, }; } _xpToLevel(xp) { if (xp <= 0) return 0; let remaining = xp; let level = 0; while (level < 100) { const cost = 10 * Math.pow(1.08, level); if (remaining < cost) break; remaining -= cost; level++; } if (level < 100) { const nextCost = 10 * Math.pow(1.08, level); level += Math.round((remaining / nextCost) * 10) / 10; } return Math.max(0, Math.min(100, Math.round(level * 10) / 10)); } _levelToPhase(level) { if (level >= 80) return 'cyber'; if (level >= 60) return 'experienced'; if (level >= 40) return 'adult'; if (level >= 20) return 'young'; return 'puppy'; } // ── Unlocks ────────────────────────────────────────────── _checkUnlocks(level) { const newUnlocks = this.db.prepare( 'SELECT * FROM unlocks WHERE level_req <= ? AND unlocked = 0' ).all(Math.floor(level)); if (newUnlocks.length === 0) return []; const update = this.db.prepare( 'UPDATE unlocks SET unlocked = 1, unlocked_at = ? WHERE id = ?' ); const ts = now(); const unlocked = []; for (const u of newUnlocks) { update.run(ts, u.id); unlocked.push(u.feature); } return unlocked; } isUnlocked(feature) { const row = this.db.prepare('SELECT unlocked FROM unlocks WHERE feature = ?').get(feature); return row ? row.unlocked === 1 : false; } getUnlocks() { return this.db.prepare('SELECT * FROM unlocks ORDER BY level_req').all(); } getUnlockedFeatures() { return this.db.prepare('SELECT feature FROM unlocks WHERE unlocked = 1').all().map(r => r.feature); } getNextUnlock(currentLevel) { return this.db.prepare( 'SELECT * FROM unlocks WHERE unlocked = 0 AND level_req <= ? ORDER BY level_req LIMIT 1' ).get(Math.floor(currentLevel)); } // ── Interactions ───────────────────────────────────────── logInteraction({ actor, action_type, action_detail, emotion_before, emotion_after, xp_gained, context }) { this.db.prepare(` INSERT INTO interactions (actor, action_type, action_detail, emotion_before, emotion_after, xp_gained, context) VALUES (?, ?, ?, ?, ?, ?, ?) `).run(actor, action_type, action_detail || null, emotion_before || null, emotion_after || null, xp_gained || 0, context ? JSON.stringify(context) : null); this.updateStats({ interactions_total: (this.getStats().interactions_total || 0) + 1, last_interaction: now() }); } getRecentInteractions(limit = 20) { return this.db.prepare('SELECT * FROM interactions ORDER BY id DESC LIMIT ?').all(limit); } // ── Relationships ──────────────────────────────────────── addOrUpdateRelationship(entity, data) { const existing = this.getRelationship(entity); if (existing) { const sets = []; const vals = []; if (data.trust !== undefined) { sets.push('trust = ?'); vals.push(clamp(data.trust, 0, 1)); } if (data.affection !== undefined) { sets.push('affection = ?'); vals.push(clamp(data.affection, 0, 1)); } if (data.relationship_phase !== undefined) { sets.push('relationship_phase = ?'); vals.push(data.relationship_phase); } sets.push('interactions_count = interactions_count + 1'); sets.push('last_seen = ?'); vals.push(now()); vals.push(entity); this.db.prepare(`UPDATE relationships SET ${sets.join(', ')} WHERE entity = ?`).run(...vals); } else { this.db.prepare(` INSERT INTO relationships (entity, trust, affection, interactions_count, relationship_phase) VALUES (?, ?, ?, 1, ?) `).run( entity, clamp(data.trust ?? 0.1, 0, 1), clamp(data.affection ?? 0.1, 0, 1), data.relationship_phase || 'stranger' ); } return this.getRelationship(entity); } getRelationship(entity) { return this.db.prepare('SELECT * FROM relationships WHERE entity = ?').get(entity); } upsertRelationship(entity, data) { const existing = this.getRelationship(entity); if (existing) { const trust = clamp((data.trust ?? 0) + existing.trust, 0, 1); const affection = clamp((data.affection ?? 0) + existing.affection, 0, 1); const count = existing.interactions_count + 1; let phase = existing.relationship_phase; if (trust > 0.8 && affection > 0.8) phase = 'bonded'; else if (trust > 0.6 && affection > 0.6) phase = 'favorite'; else if (trust > 0.4) phase = 'friend'; else if (count > 5) phase = 'acquaintance'; this.db.prepare(` UPDATE relationships SET trust = ?, affection = ?, interactions_count = ?, relationship_phase = ?, last_seen = ? WHERE entity = ? `).run(trust, affection, count, phase, now(), entity); } else { this.db.prepare(` INSERT INTO relationships (entity, trust, affection, interactions_count) VALUES (?, ?, ?, 1) `).run(entity, clamp(data.trust ?? 0.1, 0, 1), clamp(data.affection ?? 0.1, 0, 1)); } } // ── Commands ───────────────────────────────────────────── getCommand(input) { return this.db.prepare('SELECT * FROM commands WHERE input = ?').get(input); } learnCommand(input, category = 'custom') { const existing = this.getCommand(input); if (existing) { this.db.prepare('UPDATE commands SET attempts = attempts + 1 WHERE id = ?').run(existing.id); return existing; } this.db.prepare('INSERT INTO commands (input, category) VALUES (?, ?)').run(input, category); return this.getCommand(input); } markCommandLearned(id) { this.db.prepare('UPDATE commands SET understood = 1, learned_at = ? WHERE id = ?').run(now(), id); } // ── Reactions ──────────────────────────────────────────── logReaction({ pes_action, reactor, reaction_type, reaction_value, weight, platform }) { this.db.prepare(` INSERT INTO reaction_memory (pes_action, reactor, reaction_type, reaction_value, weight, platform) VALUES (?, ?, ?, ?, ?, ?) `).run(pes_action, reactor, reaction_type, reaction_value || null, weight || 0, platform || null); } // ── Stickers ───────────────────────────────────────────── learnSticker({ file_id, file_unique_id, set_name, emoji, emotion_key }) { const existing = this.db.prepare('SELECT * FROM learned_stickers WHERE file_unique_id = ?').get(file_unique_id); if (existing) { this.db.prepare('UPDATE learned_stickers SET times_seen = times_seen + 1 WHERE id = ?').run(existing.id); return existing; } this.db.prepare(` INSERT INTO learned_stickers (file_id, file_unique_id, set_name, emoji, emotion_key) VALUES (?, ?, ?, ?, ?) `).run(file_id, file_unique_id, set_name || null, emoji || null, emotion_key || null); return this.db.prepare('SELECT * FROM learned_stickers WHERE file_unique_id = ?').get(file_unique_id); } getStickerByEmotion(emotion) { return this.db.prepare( 'SELECT * FROM learned_stickers WHERE emotion_key = ? ORDER BY preference_score DESC, times_seen DESC LIMIT 5' ).all(emotion); } updateStickerPreference(file_unique_id, delta) { this.db.prepare('UPDATE learned_stickers SET preference_score = preference_score + ? WHERE file_unique_id = ?').run(delta, file_unique_id); } // ── Sticker discovery ──────────────────────────────────── discoverStickerSet(set_name, source = 'owner') { this.db.prepare('INSERT OR IGNORE INTO sticker_discovery (set_name, source) VALUES (?, ?)').run(set_name, source); } getDiscoveredSets() { return this.db.prepare('SELECT * FROM sticker_discovery ORDER BY match_score DESC').all(); } // ── Emojis ─────────────────────────────────────────────── learnEmoji(emoji, owner_sent = false) { const existing = this.db.prepare('SELECT * FROM learned_emojis WHERE emoji = ?').get(emoji); if (existing) { this.db.prepare('UPDATE learned_emojis SET times_seen = times_seen + 1, owner_sent = MAX(owner_sent, ?) WHERE id = ?') .run(owner_sent ? 1 : 0, existing.id); return; } this.db.prepare('INSERT INTO learned_emojis (emoji, owner_sent) VALUES (?, ?)').run(emoji, owner_sent ? 1 : 0); } getTopOwnerEmojis(limit = 5) { return this.db.prepare( 'SELECT emoji FROM learned_emojis WHERE owner_sent = 1 ORDER BY times_seen DESC LIMIT ?' ).all(limit).map(r => r.emoji); } // ── Notes (Level 3) ───────────────────────────────────── addNote(ownerId, text, tags = [], category = null) { this.db.prepare(` INSERT INTO owner_notes (owner_id, text, tags, category) VALUES (?, ?, ?, ?) `).run(ownerId, text, JSON.stringify(tags), category); } searchNotes(queryOrOwnerId, optsOrQuery) { // Compat: old API is searchNotes(query, {owner_id, limit}), new is searchNotes(ownerId, query) let ownerId, query, limit; if (typeof optsOrQuery === 'object' && optsOrQuery !== null) { // Old style: searchNotes(query, {owner_id, limit}) query = queryOrOwnerId; ownerId = optsOrQuery.owner_id; limit = optsOrQuery.limit || 10; } else { // New style: searchNotes(ownerId, query) ownerId = queryOrOwnerId; query = optsOrQuery; limit = 10; } return this.db.prepare(` SELECT * FROM owner_notes WHERE owner_id = ? AND is_deleted = 0 AND text LIKE ? ORDER BY created_at DESC LIMIT ? `).all(ownerId, `%${query}%`, limit); } getNotes(ownerId, limit = 10) { return this.db.prepare(` SELECT * FROM owner_notes WHERE owner_id = ? AND is_deleted = 0 ORDER BY created_at DESC LIMIT ? `).all(ownerId, limit); } // ── Reminders (Level 4) ────────────────────────────────── addReminder(text, remindAt) { this.db.prepare('INSERT INTO reminders (text, remind_at) VALUES (?, ?)').run(text, remindAt); } getPendingReminders() { return this.db.prepare(` SELECT * FROM reminders WHERE delivered = 0 AND remind_at <= ? `).all(now()); } markReminderDelivered(id) { this.db.prepare('UPDATE reminders SET delivered = 1, delivered_at = ? WHERE id = ?').run(now(), id); } // ── Files (Level 6) ────────────────────────────────────── saveFile({ ownerId, file_id, file_unique_id, file_type, file_name, description, tags }) { this.db.prepare(` INSERT OR IGNORE INTO owner_files (owner_id, telegram_file_id, telegram_file_unique_id, file_type, file_name, description, tags) VALUES (?, ?, ?, ?, ?, ?, ?) `).run(ownerId, file_id, file_unique_id, file_type || 'document', file_name || null, description || null, JSON.stringify(tags || [])); } searchFiles(ownerId, query) { return this.db.prepare(` SELECT * FROM owner_files WHERE owner_id = ? AND is_deleted = 0 AND (file_name LIKE ? OR description LIKE ? OR tags LIKE ?) ORDER BY created_at DESC LIMIT 10 `).all(ownerId, `%${query}%`, `%${query}%`, `%${query}%`); } // ── Contacts (Level 8) ────────────────────────────────── addContact(ownerId, { name, phone, email, birthday, relationship, notes }) { this.db.prepare(` INSERT INTO owner_contacts (owner_id, name, phone, email, birthday, relationship, notes) VALUES (?, ?, ?, ?, ?, ?, ?) `).run(ownerId, name, phone || null, email || null, birthday || null, relationship || null, notes || null); } searchContacts(ownerId, query) { return this.db.prepare(` SELECT * FROM owner_contacts WHERE owner_id = ? AND is_deleted = 0 AND (name LIKE ? OR phone LIKE ? OR notes LIKE ?) ORDER BY name LIMIT 10 `).all(ownerId, `%${query}%`, `%${query}%`, `%${query}%`); } // ── Sessions ───────────────────────────────────────────── startSession() { this.db.prepare('INSERT INTO sessions DEFAULT VALUES').run(); const stats = this.getStats(); this.updateStats({ session_count: (stats.session_count || 0) + 1 }); return this.db.prepare('SELECT * FROM sessions ORDER BY id DESC LIMIT 1').get(); } endSession(id, interactionsCount, xpEarned) { this.db.prepare('UPDATE sessions SET ended_at = ?, interactions_count = ?, xp_earned = ? WHERE id = ?') .run(now(), interactionsCount, xpEarned, id); } // ── Babble state ───────────────────────────────────────── saveBabbleState(data) { this.db.prepare(` UPDATE babble_state SET invented_sounds = ?, owner_emojis = ?, sound_memory = ?, updated_at = ? WHERE id = 1 `).run( JSON.stringify(data.invented_sounds || []), JSON.stringify(data.owner_emojis || []), JSON.stringify(data.sound_memory || []), now() ); } getBabbleState() { const row = this.db.prepare('SELECT * FROM babble_state WHERE id = 1').get(); if (!row) return { invented_sounds: [], owner_emojis: [], sound_memory: [] }; return { invented_sounds: JSON.parse(row.invented_sounds || '[]'), owner_emojis: JSON.parse(row.owner_emojis || '[]'), sound_memory: JSON.parse(row.sound_memory || '[]'), }; } // ── Letters ────────────────────────────────────────────── createLetter(type, recipient, content) { this.db.prepare('INSERT INTO letters (letter_type, recipient, content) VALUES (?, ?, ?)') .run(type, recipient, typeof content === 'string' ? content : JSON.stringify(content)); } // ── Snapshot (for transfer/export) ─────────────────────── createSnapshot() { return { stats: this.getStats(), config: this.db.prepare('SELECT * FROM config').all(), unlocks: this.getUnlocks(), interactions_count: this.db.prepare('SELECT COUNT(*) as c FROM interactions').get().c, relationships: this.db.prepare('SELECT * FROM relationships').all(), commands: this.db.prepare('SELECT * FROM commands WHERE understood = 1').all(), babble: this.getBabbleState(), }; } // ── Compatibility layer (telegram.js calls these) ──────── // Minimal impls — telegram.js v1 API surface. All actively used. getOriginByFileId() { return null; } markEmojiUsed() {} getLearnedEmojis(opts = {}) { try { const limit = opts?.limit || 20; if (opts?.owner_only) { return this.db.prepare('SELECT * FROM learned_emojis WHERE owner_sent = 1 ORDER BY times_seen DESC LIMIT ?').all(limit); } return this.db.prepare('SELECT * FROM learned_emojis ORDER BY times_seen DESC LIMIT ?').all(limit); } catch { return []; } } getLearnedStickers(opts = {}) { try { const limit = opts?.limit || 20; return this.db.prepare('SELECT * FROM learned_stickers ORDER BY times_seen DESC LIMIT ?').all(limit); } catch { return []; } } getMainCollectionPack() { return null; } setMainCollectionPack() {} incrementPackStickerCount() {} saveCreatedPack() {} getCreatedPacks() { return []; } saveStickerOrigin() {} getCustomEmojiStickers() { return []; } getStickersByRelevance({ emotion_key, limit } = {}) { if (!emotion_key) return []; return this.getStickerByEmotion(emotion_key).slice(0, limit || 5); } markStickerUsed(fileUniqueId) { try { this.db.prepare('UPDATE learned_stickers SET times_used = times_used + 1 WHERE file_unique_id = ?').run(fileUniqueId); } catch {} } recordPattern() {} getTopPatterns() { return []; } getExplorablePacks() { return []; } getUntriedStickersFromPack() { return []; } saveDiscovery() {} markStickerTried() {} incrementDiscoveryTried() {} recalculatePreference() {} getStickerCount() { try { return this.db.prepare('SELECT COUNT(*) as c FROM learned_stickers').get()?.c || 0; } catch { return 0; } } getNoteCount() { try { return this.db.prepare('SELECT COUNT(*) as c FROM owner_notes WHERE is_deleted = 0').get()?.c || 0; } catch { return 0; } } getAgeDays() { const stats = this.getStats(); if (!stats?.created_at) return 0; return Math.floor((Date.now() - new Date(stats.created_at).getTime()) / 86400000); } saveNote({ owner_id, text, tags, category, pinned, source_message_id, remind_at }) { this.db.prepare(` INSERT INTO owner_notes (owner_id, text, tags, category, pinned, remind_at) VALUES (?, ?, ?, ?, ?, ?) `).run(owner_id, text, JSON.stringify(tags || []), category || null, pinned ? 1 : 0, remind_at || null); return true; } createReminder(text, remindAt) { this.addReminder(text, remindAt); } getDueReminders() { return this.db.prepare(` SELECT * FROM reminders WHERE delivered = 0 AND remind_at <= ? `).all(now()); } getDueNoteReminders() { return this.db.prepare(` SELECT * FROM owner_notes WHERE remind_at IS NOT NULL AND remind_sent = 0 AND is_deleted = 0 AND remind_at <= ? `).all(now()); } markNoteReminderSent(id) { this.db.prepare('UPDATE owner_notes SET remind_sent = 1 WHERE id = ?').run(id); } getUpcomingNoteReminders(minutes = 30) { const future = new Date(Date.now() + minutes * 60 * 1000).toISOString(); return this.db.prepare(` SELECT * FROM owner_notes WHERE remind_at IS NOT NULL AND remind_sent = 0 AND is_deleted = 0 AND remind_at <= ? AND remind_at > ? `).all(future, now()); } deleteNote(id) { this.db.prepare('UPDATE owner_notes SET is_deleted = 1 WHERE id = ?').run(id); } // ── Pet Friendships (Level 10) ────────────────────────── sendFriendRequest(friendChatId, petName, breed = null) { this.db.prepare(` INSERT OR IGNORE INTO pet_friendships (friend_chat_id, friend_pet_name, friend_breed, status) VALUES (?, ?, ?, 'pending') `).run(String(friendChatId), petName, breed); } acceptFriendRequest(friendChatId) { this.db.prepare(` UPDATE pet_friendships SET status = 'accepted', last_interaction = ? WHERE friend_chat_id = ? AND status = 'pending' `).run(now(), String(friendChatId)); } rejectFriendRequest(friendChatId) { this.db.prepare(` UPDATE pet_friendships SET status = 'rejected' WHERE friend_chat_id = ? AND status = 'pending' `).run(String(friendChatId)); } getFriendship(friendChatId) { return this.db.prepare('SELECT * FROM pet_friendships WHERE friend_chat_id = ?').get(String(friendChatId)); } getFriends() { return this.db.prepare("SELECT * FROM pet_friendships WHERE status = 'accepted' ORDER BY trust_level DESC").all(); } getPendingFriendRequests() { return this.db.prepare("SELECT * FROM pet_friendships WHERE status = 'pending' ORDER BY created_at DESC").all(); } removeFriend(friendChatId) { this.db.prepare('DELETE FROM pet_friendships WHERE friend_chat_id = ?').run(String(friendChatId)); } sendGift(friendChatId, giftType, giftName, giftEmoji, moodEffect = 0.05) { this.db.prepare(` INSERT INTO pet_gifts (direction, friend_chat_id, gift_type, gift_name, gift_emoji, mood_effect) VALUES ('sent', ?, ?, ?, ?, ?) `).run(String(friendChatId), giftType, giftName, giftEmoji, moodEffect); this.db.prepare(` UPDATE pet_friendships SET gifts_sent = gifts_sent + 1, trust_level = MIN(1.0, trust_level + 0.02), last_interaction = ? WHERE friend_chat_id = ? `).run(now(), String(friendChatId)); } receiveGift(friendChatId, giftType, giftName, giftEmoji, moodEffect = 0.05) { this.db.prepare(` INSERT INTO pet_gifts (direction, friend_chat_id, gift_type, gift_name, gift_emoji, mood_effect) VALUES ('received', ?, ?, ?, ?, ?) `).run(String(friendChatId), giftType, giftName, giftEmoji, moodEffect); this.db.prepare(` UPDATE pet_friendships SET gifts_received = gifts_received + 1, trust_level = MIN(1.0, trust_level + 0.03), last_interaction = ? WHERE friend_chat_id = ? `).run(now(), String(friendChatId)); } getGiftHistory(friendChatId = null, limit = 20) { if (friendChatId) { return this.db.prepare('SELECT * FROM pet_gifts WHERE friend_chat_id = ? ORDER BY created_at DESC LIMIT ?') .all(String(friendChatId), limit); } return this.db.prepare('SELECT * FROM pet_gifts ORDER BY created_at DESC LIMIT ?').all(limit); } getFriendCount() { try { return this.db.prepare("SELECT COUNT(*) as c FROM pet_friendships WHERE status = 'accepted'").get()?.c || 0; } catch { return 0; } } // ── Daily Streaks ────────────────────────────────────── updateStreak() { const today = new Date().toISOString().slice(0, 10); // YYYY-MM-DD const row = this.db.prepare('SELECT * FROM daily_streaks WHERE id = 1').get(); if (!row) { this.db.prepare('INSERT OR IGNORE INTO daily_streaks (id) VALUES (1)').run(); this.db.prepare('UPDATE daily_streaks SET current_streak = 1, longest_streak = 1, last_active_date = ?, total_active_days = 1 WHERE id = 1').run(today); return { current: 1, longest: 1, isNew: true, total: 1 }; } if (row.last_active_date === today) { return { current: row.current_streak, longest: row.longest_streak, isNew: false, total: row.total_active_days }; } const yesterday = new Date(Date.now() - 86400000).toISOString().slice(0, 10); let newStreak; if (row.last_active_date === yesterday) { newStreak = row.current_streak + 1; } else { newStreak = 1; // streak broken } const newLongest = Math.max(row.longest_streak, newStreak); const newTotal = row.total_active_days + 1; this.db.prepare('UPDATE daily_streaks SET current_streak = ?, longest_streak = ?, last_active_date = ?, total_active_days = ? WHERE id = 1') .run(newStreak, newLongest, today, newTotal); return { current: newStreak, longest: newLongest, isNew: true, total: newTotal }; } getStreak() { const row = this.db.prepare('SELECT * FROM daily_streaks WHERE id = 1').get(); if (!row) return { current: 0, longest: 0, total: 0 }; return { current: row.current_streak, longest: row.longest_streak, total: row.total_active_days, lastDate: row.last_active_date }; } // ── Achievements ────────────────────────────────────── _seedAchievements() { const ACHIEVEMENTS = [ { key: 'first_message', name: 'Первое слово', desc: 'Первое сообщение', emoji: '💬', cat: 'social', threshold: 1 }, { key: 'msg_100', name: 'Болтун', desc: '100 сообщений', emoji: '🗣️', cat: 'social', threshold: 100 }, { key: 'msg_1000', name: 'Тысячник', desc: '1000 сообщений', emoji: '📢', cat: 'social', threshold: 1000 }, { key: 'feed_50', name: 'Кормилец', desc: 'Покормил 50 раз', emoji: '🍖', cat: 'care', threshold: 50 }, { key: 'praise_30', name: 'Добрый хозяин', desc: '30 похвал', emoji: '❤️', cat: 'care', threshold: 30 }, { key: 'level_5', name: 'Юниор', desc: 'Достигнуть уровня 5', emoji: '⭐', cat: 'growth', threshold: 5 }, { key: 'level_10', name: 'Мастер', desc: 'Достигнуть уровня 10', emoji: '🌟', cat: 'growth', threshold: 10 }, { key: 'streak_7', name: 'Неделя вместе', desc: 'Streak 7 дней подряд', emoji: '🔥', cat: 'streak', threshold: 7 }, { key: 'streak_30', name: 'Месяц верности', desc: 'Streak 30 дней подряд', emoji: '💎', cat: 'streak', threshold: 30 }, { key: 'notes_10', name: 'Записная книжка', desc: 'Сохранить 10 заметок', emoji: '📝', cat: 'ability', threshold: 10 }, ]; const insert = this.db.prepare( 'INSERT OR IGNORE INTO achievements (key, name, description, emoji, category, threshold) VALUES (?, ?, ?, ?, ?, ?)' ); const tx = this.db.transaction(() => { for (const a of ACHIEVEMENTS) { insert.run(a.key, a.name, a.desc, a.emoji, a.cat, a.threshold); } }); tx(); } checkAchievements(context = {}) { const stats = this.getStats(); const streak = this.getStreak(); const noteCount = this.getNoteCount(); const newlyUnlocked = []; const checks = { first_message: stats.interactions_total >= 1, msg_100: stats.interactions_total >= 100, msg_1000: stats.interactions_total >= 1000, feed_50: (context.feedCount || this._countActions('feed')) >= 50, praise_30: stats.times_praised >= 30, level_5: Math.floor(stats.level) >= 5, level_10: Math.floor(stats.level) >= 10, streak_7: streak.current >= 7, streak_30: streak.current >= 30, notes_10: noteCount >= 10, }; const progressValues = { first_message: stats.interactions_total, msg_100: stats.interactions_total, msg_1000: stats.interactions_total, feed_50: context.feedCount || this._countActions('feed'), praise_30: stats.times_praised, level_5: Math.floor(stats.level), level_10: Math.floor(stats.level), streak_7: streak.current, streak_30: streak.current, notes_10: noteCount, }; for (const [key, met] of Object.entries(checks)) { const progress = progressValues[key] || 0; // Update progress this.db.prepare('UPDATE achievements SET progress = ? WHERE key = ? AND unlocked = 0').run(progress, key); if (met) { const row = this.db.prepare('SELECT * FROM achievements WHERE key = ? AND unlocked = 0').get(key); if (row) { this.db.prepare('UPDATE achievements SET unlocked = 1, unlocked_at = ?, progress = ? WHERE key = ?') .run(now(), progress, key); newlyUnlocked.push({ key, name: row.name, emoji: row.emoji, description: row.description }); } } } return newlyUnlocked; } _countActions(actionType) { try { const row = this.db.prepare("SELECT COUNT(*) as c FROM interactions WHERE action_detail = ?").get(actionType); return row?.c || 0; } catch { return 0; } } getAchievements() { return this.db.prepare('SELECT * FROM achievements ORDER BY unlocked DESC, category, threshold').all(); } getUnlockedAchievements() { return this.db.prepare('SELECT * FROM achievements WHERE unlocked = 1 ORDER BY unlocked_at DESC').all(); } getAchievementProgress() { const all = this.getAchievements(); const unlocked = all.filter(a => a.unlocked); return { total: all.length, unlocked: unlocked.length, achievements: all }; } // ── Cleanup ────────────────────────────────────────────── close() { if (this._checkpointInterval) clearInterval(this._checkpointInterval); try { this.db.close(); } catch (_) {} } } export default MemoryStore; export { UNLOCK_TREE, TRAITS, PHASE_THRESHOLDS };