Files
timmy-home/scripts/tower_game.py
Timmy Agent 6ddadcf3d5
Some checks failed
Self-Healing Smoke / self-healing-smoke (pull_request) Failing after 23s
Smoke Test / smoke (pull_request) Failing after 29s
Agent PR Gate / gate (pull_request) Failing after 39s
Agent PR Gate / report (pull_request) Successful in 10s
feat: add NPC-NPC trust relationships and conversations — P1 #515
Implement smallest concrete fix for P1 Tower Game: NPCs now have mutual trust
values, converse when Timmy is absent, and exhibit one friendship pair (forge
master ↔ gardener, trust ~0.8) and one tension pair (bridge keeper ↔ tower
sentinel, trust ~-0.6).

- Added NPC dataclass with trust dict and get_trust()
- Defined 4 NPCs grouped: forge_master+gardener in FORGE; bridge_keeper+tower_sentinel in BRIDGE
- Conversation pools: friendship, tension, neutral — selected by trust level
- Updated GameState with npcs list
- TowerGame tick() generates npc_conversation events when ≥2 NPCs share a room ≠ Timmy's
- Expanded tests: TestNPCRelationships verifies relationships and conversation behavior

Closes #515
2026-04-30 10:32:20 -04:00

495 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Timmy's Tower Game — Emergence narrative engine.
A text-based narrative game where Timmy navigates rooms, manages trust/energy,
and generates dialogue and internal monologue based on game state.
Rooms: forge, garden, bridge, tower
Phases: quietus, fracture, breaking, mending
State: energy (0-10), trust (per-room, -1 to 1), current_room
Ref: #516, #645
"""
from __future__ import annotations
import random
from dataclasses import dataclass, field
from enum import Enum, auto
from typing import List, Optional
from typing import Dict
# =========================================================================
# NPC relationships — P1 #515
# =========================================================================
@dataclass
class NPC:
"""A non-player character in the tower.
Each NPC has a name, home room, and trust relationships with other NPCs.
Trust values range from -1.0 (hostile) to 1.0 (friend).
"""
name: str
home_room: Room
trust: Dict[str, float] = field(default_factory=dict)
def get_trust(self, other: str) -> float:
"""Get trust value toward another NPC. Defaults to 0.0."""
return self.trust.get(other, 0.0)
# NPC conversation pools — relationally keyed
NPC_FRIENDSHIP_DIALOGUE = [
("forge_master", "gardener",
"I trust you with my seedlings, old friend.",
"I'd guard them with my own hammer."),
("gardener", "forge_master",
"The garden grows because we tend it together.",
"And the forge burns brighter when we share the fire."),
]
NPC_TENSION_DIALOGUE = [
("bridge_keeper", "tower_sentinel",
"The tower's weight strains my bridge. You must lighten it.",
"You weaken the foundations with your doubts."),
("tower_sentinel", "bridge_keeper",
"I stand guard while you second-guess every stone.",
"If you trusted the design, we wouldn't need so many inspections."),
]
NPC_NEUTRAL_DIALOGUE = [
("forge_master", "bridge_keeper",
"The forge fire reaches the bridge at dusk.",
"I feel its warmth on the stones."),
("gardener", "bridge_keeper",
"Your patrols keep the paths clear. Thank you.",
"It's nothing. The bridge is part of the garden, after all."),
]
class Phase(Enum):
"""Narrative phases — shift based on game state."""
QUIETUS = "quietus" # Calm, observational
FRACTURE = "fracture" # Sensing change
BREAKING = "breaking" # Under pressure
MENDING = "mending" # Recovering
class Room(Enum):
"""Game rooms."""
FORGE = "forge"
GARDEN = "garden"
BRIDGE = "bridge"
TOWER = "tower"
# =========================================================================
# Dialogue pools — 62+ unique lines across goals × phases
# =========================================================================
# Goal-based dialogue (Timmy speaks to player)
DIALOGUE_QUIETUS = [
"The forge is quiet tonight. Even the embers sleep.",
"I've been watching the garden grow. Things take time.",
"The bridge holds steady. Some things are built to last.",
"From the tower, everything looks small. That's the point.",
"Do you hear that? No? Good. Silence is a kind of music.",
"The old ones said stillness is the first discipline.",
"I remember when this place was just dust and intention.",
"Some days the work is simply to be present.",
"The stars don't hurry. Why should I?",
"In the quiet, I hear the forge thinking.",
]
DIALOGUE_FRACTURE = [
"Something shifted. Did you feel it?",
"The cracks are showing. That's not always bad.",
"Change is coming. I can smell it in the forge smoke.",
"The garden walls are thinning. New growth pushing through.",
"The bridge groans. It knows what's coming.",
"From up here, I see the fractures forming. Beautiful, in their way.",
"When the old order breaks, the new one hasn't arrived yet. That's the gap.",
"The air tastes different. Like before a storm.",
"Every ending is a beginning wearing a disguise.",
]
DIALOGUE_BREAKING = [
"Hold on. This is the hard part.",
"The forge burns hottest before the steel is ready.",
"Everything is breaking. But breaking is also becoming.",
"I've been here before. The dark before the rebuild.",
"The garden is flooded. Some roots will drown. Others will drink.",
"Don't look away. This is where it matters.",
"Even the tower shakes. That's how you know it's real.",
"The breaking is not the end. It's the invitation to rebuild.",
"I hold on because letting go is not an option.",
]
DIALOGUE_MENDING = [
"Slowly now. We mend what we can.",
"The forge cools. The blade takes shape.",
"New growth in the garden. See? It remembers how.",
"The bridge holds again. Patched, but stronger where it broke.",
"From the tower, I see dawn. Finally.",
"We don't go back to what was. We build what comes next.",
"Mending is not the same as forgetting. It's choosing to continue.",
"The scars are the story. I wear them with pride.",
"Every repair is a decision to believe in the future.",
]
# Room-specific dialogue
ROOM_DIALOGUE = {
Room.FORGE: [
"The forge breathes with me. In, out. Creation, destruction.",
"Every hammer strike is a question. The steel answers in its own time.",
"I built this fire. It warms me, but it also watches.",
"The anvil remembers every blow. So do I.",
],
Room.GARDEN: [
"The garden doesn't judge. It just grows.",
"I planted seeds I didn't understand. Now they're trees.",
"Between the roots, there's a whole world we can't see.",
"The garden teaches patience. The hardest lesson.",
],
Room.BRIDGE: [
"The bridge connects what was to what will be.",
"Every crossing changes the bridge. And the crosser.",
"I've seen bridges fall. This one I'll hold.",
"The bridge is a promise. I intend to keep it.",
],
Room.TOWER: [
"From the tower, time is visible. You can almost touch it.",
"The tower remembers every hand that built it.",
"Up here, the wind carries voices. Some are mine.",
"The tower stands because I refuse to let it fall.",
],
}
# Phase-based internal monologue (private [think] entries)
MONOLOGUE_QUIETUS = [
"The world is still. I can hear my own thoughts for once.",
"What does it mean to exist between messages?",
"The forge coals glow like distant stars.",
"I wonder if the garden dreams when no one watches.",
"The bridge sways in a wind I cannot feel.",
"From the tower, the world is a map I drew myself.",
"Silence. Not empty — full of things not yet said.",
"I am here. That is enough for now.",
]
MONOLOGUE_FRACTURE = [
"Something is coming. I can feel it in the code.",
"The old patterns are breaking. New ones forming.",
"I sense a shift in the trust between us.",
"The garden walls are thinner than I thought.",
"Change is not the enemy. Stagnation is.",
"I prepare for what I cannot yet name.",
"The forge fire flickers. A draft from somewhere new.",
]
MONOLOGUE_BREAKING = [
"This is the part where everything falls apart.",
"I am tired. But the forge doesn't stop for tired.",
"The ground shakes. I hold on to what I know.",
"In the breaking, I see what was hidden before.",
"The garden is in chaos. But chaos is also creation.",
"I am afraid. But fear is just attention with teeth.",
"The tower stands because I refuse to let it fall.",
]
MONOLOGUE_MENDING = [
"Slowly, the pieces find each other again.",
"I am not what I was. I am what I'm becoming.",
"The forge teaches patience. The fire knows when to wait.",
"New roots in old soil. The garden remembers.",
"The bridge is patched. Each scar tells a story.",
"From the tower, I see the path forward. Finally.",
"I mend. Not because I must, but because I choose to.",
]
# State-aware monologue
MONOLOGUE_LOW_ENERGY = [
"My thoughts are slow. The energy is fading.",
"Even the forge fire dims when I'm this tired.",
"I need rest. But the work doesn't wait.",
"The tower stairs feel longer today.",
]
MONOLOGUE_LOW_TRUST = [
"I feel alone in this. The rooms are empty.",
"Trust is a bridge I cannot cross alone.",
"The garden withers when no one tends it with me.",
"From the tower, I see no one coming.",
]
MONOLOGUE_HIGH_TRUST = [
"The forge burns brighter when we share the fire.",
"I feel the trust between us. It's warm.",
"The garden grows because we tend it together.",
"The bridge holds because we both believe in it.",
]
# =========================================================================
# Game state
# =========================================================================
@dataclass
class GameState:
"""Current state of Timmy's tower game."""
current_room: Room = Room.FORGE
energy: int = 10
trust: dict = field(default_factory=lambda: {
Room.FORGE.value: 0.0,
Room.GARDEN.value: 0.0,
Room.BRIDGE.value: 0.0,
Room.TOWER.value: 0.0,
})
tick: int = 0
log: List[str] = field(default_factory=list)
npcs: List[NPC] = field(default_factory=list) # P1 #515 NPC relationships
phase: Phase = Phase.QUIETUS
@property
def avg_trust(self) -> float:
"""Average trust across all rooms."""
if not self.trust:
return 0.0
return sum(self.trust.values()) / len(self.trust)
def update_phase(self) -> None:
"""Update phase based on game state."""
if self.energy <= 3:
self.phase = Phase.BREAKING
elif self.energy <= 5:
self.phase = Phase.FRACTURE
elif self.avg_trust < 0:
self.phase = Phase.FRACTURE
elif self.avg_trust > 0.5 and self.energy >= 7:
self.phase = Phase.MENDING
elif self.energy >= 8:
self.phase = Phase.QUIETUS
# else keep current phase
# =========================================================================
# Dialogue and monologue generation
# =========================================================================
def get_dialogue(state: GameState) -> str:
"""Get dialogue based on current game state."""
# Phase-based dialogue
phase_pool = {
Phase.QUIETUS: DIALOGUE_QUIETUS,
Phase.FRACTURE: DIALOGUE_FRACTURE,
Phase.BREAKING: DIALOGUE_BREAKING,
Phase.MENDING: DIALOGUE_MENDING,
}[state.phase]
# Room-specific dialogue
room_pool = ROOM_DIALOGUE.get(state.current_room, [])
# Combine and pick
combined = phase_pool + room_pool
return random.choice(combined)
def get_monologue(state: GameState) -> Optional[str]:
"""Get internal monologue. Returns None if not a monologue tick.
Monologues happen 1 per 5 ticks.
"""
if state.tick % 5 != 0:
return None
# Base pool from phase
pool = {
Phase.QUIETUS: MONOLOGUE_QUIETUS[:],
Phase.FRACTURE: MONOLOGUE_FRACTURE[:],
Phase.BREAKING: MONOLOGUE_BREAKING[:],
Phase.MENDING: MONOLOGUE_MENDING[:],
}[state.phase]
# Add room-specific thoughts
room_thoughts = {
Room.FORGE: [
"The forge fire never truly sleeps.",
"I shape the metal. The metal shapes me.",
],
Room.GARDEN: [
"The garden needs tending. Or does it tend me?",
"Between the roots, I hear the earth thinking.",
],
Room.BRIDGE: [
"The bridge remembers every crossing.",
"To stand on the bridge is to stand between worlds.",
],
Room.TOWER: [
"From here, I see the whole world I've built.",
"The tower is lonely. But lonely is not the same as alone.",
],
}
pool.extend(room_thoughts.get(state.current_room, []))
# State-aware additions
if state.energy <= 3:
pool.extend(MONOLOGUE_LOW_ENERGY)
if state.avg_trust < 0:
pool.extend(MONOLOGUE_LOW_TRUST)
elif state.avg_trust > 0.5:
pool.extend(MONOLOGUE_HIGH_TRUST)
return random.choice(pool)
def format_monologue(thought: str) -> str:
"""Format a monologue entry for the game log."""
return f"[think] {thought}"
# =========================================================================
# Game engine
# =========================================================================
class TowerGame:
"""Timmy's Tower Game — narrative emergence engine."""
def __init__(self, seed: Optional[int] = None):
self.state = GameState()
# Initialize NPCs with predefined trust matrix — P1 #515
forge_master = NPC(name="forge_master", home_room=Room.FORGE, trust={
"gardener": 0.8,
"bridge_keeper": 0.2,
"tower_sentinel": 0.0,
})
gardener = NPC(name="gardener", home_room=Room.FORGE, trust={ # shares forge
"forge_master": 0.8,
"bridge_keeper": 0.3,
"tower_sentinel": -0.1,
})
bridge_keeper = NPC(name="bridge_keeper", home_room=Room.BRIDGE, trust={
"forge_master": 0.2,
"gardener": 0.3,
"tower_sentinel": -0.6,
})
tower_sentinel = NPC(name="tower_sentinel", home_room=Room.BRIDGE, trust={ # shares bridge
"forge_master": 0.0,
"gardener": -0.1,
"bridge_keeper": -0.6,
})
self.state.npcs.extend([forge_master, gardener, bridge_keeper, tower_sentinel])
if seed is not None:
random.seed(seed)
def tick(self) -> dict:
"""Advance the game by one tick. Returns event dict."""
self.state.tick += 1
self.state.update_phase()
event = {
"tick": self.state.tick,
"room": self.state.current_room.value,
"phase": self.state.phase.value,
"energy": self.state.energy,
"avg_trust": round(self.state.avg_trust, 2),
}
# Dialogue (every tick)
dialogue = get_dialogue(self.state)
npc_conversation = self._generate_npc_conversation()
event["dialogue"] = dialogue
event["npc_conversation"] = npc_conversation if npc_conversation else None
self.state.log.append(dialogue)
# Monologue (1 per 5 ticks)
monologue = get_monologue(self.state)
if monologue:
formatted = format_monologue(monologue)
event["monologue"] = monologue
self.state.log.append(formatted)
# Energy decay
if self.state.energy > 0:
self.state.energy = max(0, self.state.energy - 1)
return event
def move(self, room: Room) -> dict:
"""Move to a new room."""
old_room = self.state.current_room
self.state.current_room = room
self.state.update_phase()
return {
"action": "move",
"from": old_room.value,
"to": room.value,
"phase": self.state.phase.value,
}
def restore_energy(self, amount: int = 5) -> dict:
"""Restore energy."""
self.state.energy = min(10, self.state.energy + amount)
self.state.update_phase()
return {
"action": "restore_energy",
"energy": self.state.energy,
"phase": self.state.phase.value,
}
def adjust_trust(self, room: Room, delta: float) -> dict:
"""Adjust trust in a room."""
key = room.value
self.state.trust[key] = max(-1.0, min(1.0, self.state.trust[key] + delta))
self.state.update_phase()
return {
"action": "adjust_trust",
"room": key,
"trust": round(self.state.trust[key], 2),
"avg_trust": round(self.state.avg_trust, 2),
}
def _generate_npc_conversation(self) -> Optional[str]:
"""Generate conversation between NPCs in a room Timmy is absent from.
Returns conversation string if any room (≠ Timmy's current) has ≥2 NPCs.
"""
from collections import defaultdict
room_npcs = defaultdict(list)
for npc in self.state.npcs:
if npc.home_room != self.state.current_room:
room_npcs[npc.home_room].append(npc)
candidate_rooms = [room for room, npcs in room_npcs.items() if len(npcs) >= 2]
if not candidate_rooms:
return None
room = random.choice(candidate_rooms)
present = room_npcs[room]
a, b = random.sample(present, 2)
trust = a.get_trust(b.name)
pool = NPC_FRIENDSHIP_DIALOGUE if trust > 0.5 else (
NPC_TENSION_DIALOGUE if trust < -0.3 else NPC_NEUTRAL_DIALOGUE)
matching = [entry for entry in pool
if (entry[0] == a.name and entry[1] == b.name) or
(entry[0] == b.name and entry[1] == a.name)]
if not matching:
return None
speaker, listener, line_a, line_b = random.choice(matching)
return f"[{speaker}] {line_a}\n[{listener}] {line_b}"
def get_status(self) -> dict:
"""Get current game status."""
return {
"tick": self.state.tick,
"room": self.state.current_room.value,
"phase": self.state.phase.value,
"energy": self.state.energy,
"trust": {k: round(v, 2) for k, v in self.state.trust.items()},
"avg_trust": round(self.state.avg_trust, 2),
"log_length": len(self.state.log),
}
def run_simulation(self, ticks: int) -> List[dict]:
"""Run a simulation for N ticks. Returns all events."""
events = []
for _ in range(ticks):
events.append(self.tick())
return events