diff --git a/scripts/tower_game.py b/scripts/tower_game.py index 7c9c802..7683ae5 100644 --- a/scripts/tower_game.py +++ b/scripts/tower_game.py @@ -16,6 +16,53 @@ 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): @@ -198,6 +245,7 @@ class GameState: }) 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 @@ -306,6 +354,28 @@ class TowerGame: 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) @@ -324,7 +394,9 @@ class TowerGame: # 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) @@ -375,6 +447,33 @@ class TowerGame: "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 { diff --git a/tests/test_tower_game.py b/tests/test_tower_game.py index 498989b..a18c821 100644 --- a/tests/test_tower_game.py +++ b/tests/test_tower_game.py @@ -1,5 +1,6 @@ """Tests for Timmy's Tower Game — emergence narrative engine.""" +import random import pytest from scripts.tower_game import ( @@ -7,6 +8,7 @@ from scripts.tower_game import ( GameState, Phase, Room, + NPC, get_dialogue, get_monologue, format_monologue, @@ -20,7 +22,6 @@ from scripts.tower_game import ( MONOLOGUE_HIGH_TRUST, ) - class TestDialoguePool: """Test dialogue line counts meet acceptance criteria.""" @@ -233,3 +234,73 @@ class TestTowerGame: events = game.run_simulation(50) dialogues = set(e["dialogue"] for e in events) assert len(dialogues) >= 10, f"Expected 10+ unique dialogues, got {len(dialogues)}" + + +class TestNPCRelationships: + """Test NPC-NPC relationship system.""" + + def test_npcs_exist(self): + """Game state contains NPCs.""" + game = TowerGame(seed=42) + assert len(game.state.npcs) >= 2, "Expected at least 2 NPCs" + + def test_each_npc_has_trust_for_all_others(self): + """Each NPC has a trust value (default or explicit) for every other NPC.""" + game = TowerGame(seed=42) + names = [n.name for n in game.state.npcs] + for npc in game.state.npcs: + for other in names: + if other != npc.name: + val = npc.get_trust(other) + assert isinstance(val, float), f"{npc.name} missing trust for {other}" + + def test_friendship_pair_high_trust(self): + """At least one NPC pair has high mutual trust (friendship).""" + game = TowerGame(seed=42) + trust_map = {n.name: n for n in game.state.npcs} + # forge_master and gardener are defined as friendship + fm = trust_map.get("forge_master") + gr = trust_map.get("gardener") + if fm and gr: + assert fm.get_trust("gardener") > 0.5, "forge_master should trust gardener highly" + assert gr.get_trust("forge_master") > 0.5, "gardener should trust forge_master highly" + + def test_tension_pair_low_trust(self): + """At least one NPC pair has low/negative mutual trust (tension).""" + game = TowerGame(seed=42) + trust_map = {n.name: n for n in game.state.npcs} + bk = trust_map.get("bridge_keeper") + ts = trust_map.get("tower_sentinel") + if bk and ts: + assert bk.get_trust("tower_sentinel") < -0.3, "bridge_keeper should distrust tower_sentinel" + assert ts.get_trust("bridge_keeper") < -0.3, "tower_sentinel should distrust bridge_keeper" + + def test_npc_conversation_occurs_when_timmy_absent(self): + """NPCs converse when Timmy is in a room without them.""" + random.seed(123) + game = TowerGame(seed=123) + # Move Timmy to GARDEN (neither forge nor bridge) + game.move(Room.GARDEN) + # Run ticks; expect at least one conversation in 10 + found = False + for _ in range(10): + evt = game.tick() + if evt.get("npc_conversation"): + found = True + break + assert found, "Expected NPC conversation when Timmy is away from NPC rooms" + + def test_npc_conversation_absent_when_timmy_present_with_npcs(self): + """When Timmy is in a room with other NPCs, those NPCs do not converse together.""" + random.seed(456) + game = TowerGame(seed=456) + # Override NPCs: place two NPCs in Timmy's current room (FORGE), no other multi-NPC rooms + npc_a = NPC(name="alice", home_room=Room.FORGE, trust={"bob": 0.5}) + npc_b = NPC(name="bob", home_room=Room.FORGE, trust={"alice": 0.5}) + game.state.npcs = [npc_a, npc_b] + # Verify Timmy is with them in FORGE + assert game.state.current_room == Room.FORGE + # Tick many times; conversation should never appear because the only pair shares room with Timmy + for _ in range(15): + evt = game.tick() + assert evt.get("npc_conversation") is None, "NPCs should not converse when Timmy is in same room"