claude-code-tamagotchi
Version:
A virtual pet that lives in your Claude Code statusline
355 lines (306 loc) • 10.3 kB
text/typescript
import * as fs from 'fs';
import * as path from 'path';
import { config } from '../utils/config';
export interface PetState {
// Core identity
name: string;
type: 'dog' | 'cat' | 'dragon' | 'robot';
birthTime: number;
age: number; // in minutes
// Vital stats (0-100)
happiness: number;
hunger: number;
energy: number;
health: number;
cleanliness: number;
// Timestamps
lastUpdate: number;
lastFed: number;
lastPlayed: number;
lastPetted: number;
lastCleaned: number;
lastSlept: number;
// Current state
currentAnimation: string;
animationFrame: number;
animationStartTime: number;
isAsleep: boolean;
isSick: boolean;
// Activity tracking (NEW)
sessionUpdateCount: number; // Updates in current session
totalUpdateCount: number; // Lifetime update counter
lastUpdateTimestamp: number; // For gap detection
recentUpdateTimestamps: number[]; // Last 30 updates for activity rate
sessionStartTime: number; // When current session began
previousSessionEnd: number; // End of last session
sessionsToday: number; // Number of coding sessions
// Animation state (NEW)
breathingState: boolean; // Simple toggle for breathing
microAnimationFrame: number; // For small animations
animationFrame: number; // Current frame in animation sequence (0-n)
currentAnimationName: string; // Name of current animation being played
// Mood and patterns (NEW)
currentMood: 'normal' | 'debugging' | 'celebrating' | 'tired' | 'focused' | 'sleeping' |
'proud' | 'excited' | 'concerned' | 'confused' | 'suspicious' |
'annoyed' | 'frustrated' | 'angry' | 'furious';
recentKeywords: string[]; // Recent keywords from conversation
// Feedback system fields (NEW)
claudeBehaviorScore: number; // 0-100, how well Claude is behaving
recentViolations: number; // Count of recent issues
feedbackHistory: Array<{
type: string;
severity: string;
remark: string;
timestamp: number;
}>;
lastTranscriptCheck?: string; // Last message UUID checked
currentFeedback?: { // Current feedback being displayed
icon: string;
remark: string;
timestamp: number;
};
// Legacy activity tracking
foodEaten: string[];
totalFeedings: number;
totalPlaySessions: number;
favoriteFood: string;
tricks: string[];
// Special states
pendingAction?: {
type: 'eating' | 'playing' | 'sleeping' | 'bathing';
item?: string;
startTime: number;
duration: number;
updateCount?: number;
};
// System messages for user
systemMessage?: string;
messageTimestamp?: number;
// Thought system fields (NEW)
currentThought?: string; // Current thought being displayed
thoughtTimestamp?: number; // When thought was generated
lastThoughtUpdate: number; // Last update count when thought changed
thoughtHistory: string[]; // Recent thought history
thoughtCategoryFatigue: Record<string, number>; // Fatigue for each category
thoughtEscalation: Record<string, number>; // Escalation tracking for needs
// Evolution
evolutionStage: number;
careLevelPoints: number;
}
export class StateManager {
private stateFile: string;
private lockFile: string;
private state: PetState | null = null;
constructor() {
this.stateFile = config.stateFile;
this.lockFile = `${this.stateFile}.lock`;
}
private createDefaultState(): PetState {
const now = Date.now();
return {
name: config.petName,
type: config.petType,
birthTime: now,
age: 0,
happiness: 80,
hunger: 70,
energy: 90,
health: 100,
cleanliness: 100,
lastUpdate: now,
lastFed: now,
lastPlayed: now,
lastPetted: now,
lastCleaned: now,
lastSlept: now,
currentAnimation: 'idle',
animationFrame: 0,
animationStartTime: now,
isAsleep: false,
isSick: false,
// Activity tracking
sessionUpdateCount: 0,
totalUpdateCount: 0,
lastUpdateTimestamp: now,
recentUpdateTimestamps: [],
sessionStartTime: now,
previousSessionEnd: now,
sessionsToday: 1,
// Animation state
breathingState: false,
microAnimationFrame: 0,
animationFrame: 0,
currentAnimationName: 'idle',
// Mood
currentMood: 'normal',
recentKeywords: [],
// Feedback system
claudeBehaviorScore: 80,
recentViolations: 0,
feedbackHistory: [],
lastTranscriptCheck: undefined,
currentFeedback: undefined,
// Legacy
foodEaten: [],
totalFeedings: 0,
totalPlaySessions: 0,
favoriteFood: 'cookie',
tricks: [],
// Thought system
currentThought: undefined,
thoughtTimestamp: undefined,
lastThoughtUpdate: 0,
thoughtHistory: [],
thoughtCategoryFatigue: {},
thoughtEscalation: {},
evolutionStage: 0,
careLevelPoints: 0,
};
}
private async waitForLock(maxAttempts = 2): Promise<boolean> {
for (let i = 0; i < maxAttempts; i++) {
// Check if lock file exists and is stale (older than 2 seconds)
if (fs.existsSync(this.lockFile)) {
try {
const stats = fs.statSync(this.lockFile);
const age = Date.now() - stats.mtimeMs;
if (age > 2000) {
// Stale lock, remove it
fs.unlinkSync(this.lockFile);
}
} catch {
// Ignore errors checking lock file
}
}
if (!fs.existsSync(this.lockFile)) {
try {
fs.writeFileSync(this.lockFile, process.pid.toString());
return true;
} catch {
// Another process got the lock first
}
}
// Only wait if this isn't the last attempt (max 20ms wait)
if (i < maxAttempts - 1) {
await new Promise(resolve => setTimeout(resolve, 20));
}
}
return false;
}
private releaseLock(): void {
try {
if (fs.existsSync(this.lockFile)) {
fs.unlinkSync(this.lockFile);
}
} catch {
// Ignore errors
}
}
async load(): Promise<PetState> {
if (this.state && Date.now() - this.state.lastUpdate < 100) {
return this.state;
}
const locked = await this.waitForLock();
if (!locked) {
// Fallback to cached state or default
return this.state || this.createDefaultState();
}
try {
if (fs.existsSync(this.stateFile)) {
const data = fs.readFileSync(this.stateFile, 'utf-8');
this.state = JSON.parse(data);
// Validate state
if (!this.state || typeof this.state !== 'object') {
throw new Error('Invalid state file');
}
// Update age
if (this.state) {
const ageMinutes = Math.floor((Date.now() - this.state.birthTime) / 60000);
this.state.age = ageMinutes;
// Migrate old state to include new fields
const now = Date.now();
if (this.state.sessionUpdateCount === undefined) {
this.state.sessionUpdateCount = 0;
this.state.totalUpdateCount = 0;
this.state.lastUpdateTimestamp = this.state.lastUpdate || now;
this.state.recentUpdateTimestamps = [];
this.state.sessionStartTime = now;
this.state.previousSessionEnd = now;
this.state.sessionsToday = 1;
this.state.breathingState = false;
this.state.microAnimationFrame = 0;
this.state.currentMood = 'normal';
this.state.recentKeywords = [];
}
// Migrate animation frame tracking
if (this.state.animationFrame === undefined) {
this.state.animationFrame = 0;
this.state.currentAnimationName = 'idle';
}
// Migrate thought system fields
if (this.state.lastThoughtUpdate === undefined) {
this.state.lastThoughtUpdate = 0;
this.state.thoughtHistory = [];
this.state.thoughtCategoryFatigue = {};
this.state.thoughtEscalation = {};
}
// Migrate feedback system fields
if (this.state.claudeBehaviorScore === undefined) {
this.state.claudeBehaviorScore = 80;
this.state.recentViolations = 0;
this.state.feedbackHistory = [];
this.state.lastTranscriptCheck = undefined;
this.state.currentFeedback = undefined;
}
}
} else {
this.state = this.createDefaultState();
await this.save(this.state);
}
} catch (error) {
// State file corrupted, create new pet
if (config.debugMode) {
console.error('State file corrupted, creating new pet:', error);
}
this.state = this.createDefaultState();
await this.save(this.state);
} finally {
this.releaseLock();
}
return this.state!;
}
async save(state: PetState): Promise<void> {
// Ensure the directory exists FIRST
const dir = path.dirname(this.stateFile);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
const locked = await this.waitForLock();
if (!locked) {
// Still try to save even without lock - better than losing data
if (config.debugMode) {
console.error('Warning: Saving without lock');
}
}
try {
// Update timestamp
state.lastUpdate = Date.now();
// Write to temp file first (atomic write)
const tempFile = `${this.stateFile}.tmp`;
fs.writeFileSync(tempFile, JSON.stringify(state, null, 2));
// Rename atomically
fs.renameSync(tempFile, this.stateFile);
this.state = state;
} catch (error) {
if (config.debugMode) {
console.error('Failed to save state:', error);
}
} finally {
this.releaseLock();
}
}
async reset(): Promise<void> {
const newState = this.createDefaultState();
await this.save(newState);
}
}