Merge pull request 'feat: tower game NPC-NPC relationships — closes #515' (#994) from step35/515-p1-tower-game-npc-npc-relati into main
Some checks failed
Self-Healing Smoke / self-healing-smoke (push) Has been cancelled
Smoke Test / smoke (push) Has been cancelled

This commit was merged in pull request #994.
This commit is contained in:
2026-05-05 12:53:35 +00:00
2 changed files with 171 additions and 1 deletions

View File

@@ -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 {

View File

@@ -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"