Merge pull request 'feat: tower game NPC-NPC relationships — closes #515' (#994) from step35/515-p1-tower-game-npc-npc-relati into main
This commit was merged in pull request #994.
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user