This repository has been archived on 2026-03-24. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
Timmy-time-dashboard/src/timmy/familiar.py
2026-03-18 21:50:36 -04:00

264 lines
8.6 KiB
Python

"""Pip the Familiar — a creature with its own small mind.
Pip is a glowing sprite who lives in the Workshop independently of Timmy.
He has a behavioral state machine that makes the room feel alive:
SLEEPING → WAKING → WANDERING → INVESTIGATING → BORED → SLEEPING
Special states triggered by Timmy's cognitive signals:
ALERT — confidence drops below 0.3
PLAYFUL — Timmy is amused / energized
HIDING — unknown visitor + Timmy uncertain
The backend tracks Pip's *logical* state; the browser handles movement
interpolation and particle rendering.
"""
import logging
import random
import time
from dataclasses import asdict, dataclass, field
from enum import StrEnum
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# States
# ---------------------------------------------------------------------------
class PipState(StrEnum):
"""Pip's behavioral states."""
SLEEPING = "sleeping"
WAKING = "waking"
WANDERING = "wandering"
INVESTIGATING = "investigating"
BORED = "bored"
# Special states
ALERT = "alert"
PLAYFUL = "playful"
HIDING = "hiding"
# States from which Pip can be interrupted by special triggers
_INTERRUPTIBLE = frozenset(
{
PipState.SLEEPING,
PipState.WANDERING,
PipState.BORED,
PipState.WAKING,
}
)
# How long each state lasts before auto-transitioning (seconds)
_STATE_DURATIONS: dict[PipState, tuple[float, float]] = {
PipState.SLEEPING: (120.0, 300.0), # 2-5 min
PipState.WAKING: (1.5, 2.5),
PipState.WANDERING: (15.0, 45.0),
PipState.INVESTIGATING: (8.0, 12.0),
PipState.BORED: (20.0, 40.0),
PipState.ALERT: (10.0, 20.0),
PipState.PLAYFUL: (8.0, 15.0),
PipState.HIDING: (15.0, 30.0),
}
# Default position near the fireplace
_FIREPLACE_POS = (2.1, 0.5, -1.3)
# ---------------------------------------------------------------------------
# Schema
# ---------------------------------------------------------------------------
@dataclass
class PipSnapshot:
"""Serialisable snapshot of Pip's current state."""
name: str = "Pip"
state: str = "sleeping"
position: tuple[float, float, float] = _FIREPLACE_POS
mood_mirror: str = "calm"
since: float = field(default_factory=time.monotonic)
def to_dict(self) -> dict:
"""Public dict for API / WebSocket / state file consumers."""
d = asdict(self)
d["position"] = list(d["position"])
# Convert monotonic timestamp to duration
d["state_duration_s"] = round(time.monotonic() - d.pop("since"), 1)
return d
# ---------------------------------------------------------------------------
# Familiar
# ---------------------------------------------------------------------------
class Familiar:
"""Pip's behavioral AI — a tiny state machine driven by events and time.
Usage::
pip_familiar.on_event("visitor_entered")
pip_familiar.on_mood_change("energized")
state = pip_familiar.tick() # call periodically
"""
def __init__(self) -> None:
self._state = PipState.SLEEPING
self._entered_at = time.monotonic()
self._duration = random.uniform(*_STATE_DURATIONS[PipState.SLEEPING])
self._mood_mirror = "calm"
self._pending_mood: str | None = None
self._mood_change_at: float = 0.0
self._position = _FIREPLACE_POS
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
@property
def state(self) -> PipState:
return self._state
@property
def mood_mirror(self) -> str:
return self._mood_mirror
def snapshot(self) -> PipSnapshot:
"""Current state as a serialisable snapshot."""
return PipSnapshot(
state=self._state.value,
position=self._position,
mood_mirror=self._mood_mirror,
since=self._entered_at,
)
def tick(self, now: float | None = None) -> PipState:
"""Advance the state machine. Call periodically (e.g. every second).
Returns the (possibly new) state.
"""
now = now if now is not None else time.monotonic()
# Apply delayed mood mirror (3-second lag)
if self._pending_mood and now >= self._mood_change_at:
self._mood_mirror = self._pending_mood
self._pending_mood = None
# Check if current state has expired
elapsed = now - self._entered_at
if elapsed < self._duration:
return self._state
# Auto-transition
next_state = self._next_state()
self._transition(next_state, now)
return self._state
def on_event(self, event: str, now: float | None = None) -> PipState:
"""React to a Workshop event.
Supported events:
visitor_entered, visitor_spoke, loud_event, scroll_knocked
"""
now = now if now is not None else time.monotonic()
if event == "visitor_entered" and self._state in _INTERRUPTIBLE:
if self._state == PipState.SLEEPING:
self._transition(PipState.WAKING, now)
else:
self._transition(PipState.INVESTIGATING, now)
elif event == "visitor_spoke":
if self._state in (PipState.WANDERING, PipState.WAKING):
self._transition(PipState.INVESTIGATING, now)
elif event == "loud_event":
if self._state == PipState.SLEEPING:
self._transition(PipState.WAKING, now)
return self._state
def on_mood_change(
self,
timmy_mood: str,
confidence: float = 0.5,
now: float | None = None,
) -> PipState:
"""Mirror Timmy's mood with a 3-second delay.
Special states triggered by mood + confidence:
- confidence < 0.3 → ALERT (bristles, particles go red-gold)
- mood == "energized" → PLAYFUL (figure-8s around crystal ball)
- mood == "hesitant" + confidence < 0.4 → HIDING
"""
now = now if now is not None else time.monotonic()
# Schedule mood mirror with 3s delay
self._pending_mood = timmy_mood
self._mood_change_at = now + 3.0
# Special state triggers (immediate)
if confidence < 0.3 and self._state in _INTERRUPTIBLE:
self._transition(PipState.ALERT, now)
elif timmy_mood == "energized" and self._state in _INTERRUPTIBLE:
self._transition(PipState.PLAYFUL, now)
elif timmy_mood == "hesitant" and confidence < 0.4 and self._state in _INTERRUPTIBLE:
self._transition(PipState.HIDING, now)
return self._state
# ------------------------------------------------------------------
# Internals
# ------------------------------------------------------------------
def _transition(self, new_state: PipState, now: float) -> None:
"""Move to a new state."""
old = self._state
self._state = new_state
self._entered_at = now
self._duration = random.uniform(*_STATE_DURATIONS[new_state])
self._position = self._position_for(new_state)
logger.debug("Pip: %s%s", old.value, new_state.value)
def _next_state(self) -> PipState:
"""Determine the natural next state after the current one expires."""
transitions: dict[PipState, PipState] = {
PipState.SLEEPING: PipState.WAKING,
PipState.WAKING: PipState.WANDERING,
PipState.WANDERING: PipState.BORED,
PipState.INVESTIGATING: PipState.BORED,
PipState.BORED: PipState.SLEEPING,
# Special states return to wandering
PipState.ALERT: PipState.WANDERING,
PipState.PLAYFUL: PipState.WANDERING,
PipState.HIDING: PipState.WAKING,
}
return transitions.get(self._state, PipState.SLEEPING)
def _position_for(self, state: PipState) -> tuple[float, float, float]:
"""Approximate position hint for a given state.
The browser interpolates smoothly; these are target anchors.
"""
if state in (PipState.SLEEPING, PipState.BORED):
return _FIREPLACE_POS
if state == PipState.HIDING:
return (0.5, 0.3, -2.0) # Behind the desk
if state == PipState.PLAYFUL:
return (1.0, 1.2, 0.0) # Near the crystal ball
# Wandering / investigating / waking — random room position
return (
random.uniform(-1.0, 3.0),
random.uniform(0.5, 1.5),
random.uniform(-2.0, 1.0),
)
# Module-level singleton
pip_familiar = Familiar()