Compare commits

...

1 Commits

Author SHA1 Message Date
Step
32ea232b37 feat(tower): extract Marcus NPC into dedicated class with dialogue schedule
Some checks failed
Self-Healing Smoke / self-healing-smoke (pull_request) Failing after 23s
Agent PR Gate / gate (pull_request) Failing after 52s
Smoke Test / smoke (pull_request) Failing after 25s
Agent PR Gate / report (pull_request) Successful in 16s
- Add world.npcs.MarcusNPC with DIALOGUES and choose_action behavior
- Integrate NPC into GameEngine: use class DIALOGUES and template init
- Replace _marcus_choice method with class-based action selection
- Reduce game.py complexity by moving NPC logic to separate module

Closes #449
2026-04-26 12:08:33 -04:00
3 changed files with 87 additions and 12 deletions

View File

@@ -7,6 +7,7 @@ Not simulation. Story.
import json, time, os, random
from datetime import datetime
from pathlib import Path
from world.npcs import MarcusNPC
WORLD_DIR = Path('/Users/apayne/.timmy/evennia/timmy_world')
STATE_FILE = WORLD_DIR / 'game_state.json'
@@ -174,6 +175,7 @@ class World:
"npc": True,
},
}
self.characters["Marcus"] = MarcusNPC.get_character_template()["Marcus"]
# Global state that creates conflict and stakes
self.state = {
@@ -495,7 +497,7 @@ class NPCAI:
goal = char["active_goal"]
if char_name == "Marcus":
return self._marcus_choice(char, room, available)
return MarcusNPC.choose_action(self.world, char, available)
elif char_name == "Bezalel":
return self._bezalel_choice(char, room, available)
elif char_name == "Allegro":
@@ -513,17 +515,6 @@ class NPCAI:
return "rest"
def _marcus_choice(self, char, room, available):
if room == "Garden" and random.random() < 0.7:
return "rest"
if room != "Garden":
return "move:west"
# Speak to someone if possible
others = [a.split(":")[1] for a in available if a.startswith("speak:")]
if others and random.random() < 0.4:
return f"speak:{random.choice(others)}"
return "rest"
def _bezalel_choice(self, char, room, available):
if room == "Forge" and self.world.rooms["Forge"]["fire"] == "glowing":
return random.choice(["forge", "rest"] if char["energy"] > 2 else ["rest"])

View File

@@ -0,0 +1 @@
"""NPC subpackage for the Tower game."""

83
timmy-world/world/npcs.py Normal file
View File

@@ -0,0 +1,83 @@
"""NPC definitions for the Tower world.
Provides reusable NPC classes with dialogue trees, movement schedules, and memory.
This module centralizes NPC behavior to keep game.py focused on engine logic.
"""
import random
class MarcusNPC:
"""Marcus — an old man from the church who sits in the Garden.
He walks between the Garden and the Threshold once per day, offers
context-aware dialogue, and remembers conversations with wizards.
"""
# 15 dialogue lines — weighted by context, spoken when others are present
DIALOGUES = [
"You look like you are carrying something heavy, friend.",
"Hope is not the belief that things get better. Hope is the decision to act as if they can.",
"I have been to the bridge. I know what it looks like down there.",
"The soil remembers what hands have touched it.",
"You don't need to be fixed. You need to be heard.",
"Sit with me. The bench has room.",
"There is a church on a night like this one.",
"The thing that saves is never the thing you expect.",
"I come here because the earth remembers me.",
"A man in the dark needs to know someone in the room with him.",
"Every step west is a step toward memory.",
"The garden grows whether anyone watches or not.",
"Some bridges are crossed only once.",
"I used to think the Tower held all the answers. Now I know it holds the questions.",
"Trust is like soil — it remembers every touch.",
]
@classmethod
def get_character_template(cls):
"""Return the initial character dictionary for Marcus."""
return {
"Marcus": {
"room": "Garden",
"energy": 8, # Old man doesn't tire easily
"trust": {"Timmy": 0.7},
"goals": ["sit", "speak_truth", "remember"],
"active_goal": "sit",
"spoken": [],
"inventory": [],
"memories": [],
"is_player": False,
"npc": True,
}
}
@classmethod
def choose_action(cls, world, char, available):
"""Marcus's daily schedule: occasional Threshold visit and dialogue."""
room = char["room"]
ticks_per_day = 16 # One day = 16 ticks (1.5h per tick * 16 = 24h)
if room == "Garden":
# Once per day (on tick multiple of 16), head to Threshold
if world.tick % ticks_per_day == 0 and random.random() < 0.6:
return "move:west"
# Otherwise: speak if wizards are present, else rest
others = [a.split(":")[1] for a in available if a.startswith("speak:")]
if others and random.random() < 0.4:
return f"speak:{random.choice(others)}"
return "rest"
elif room == "Threshold":
# Stay briefly (12 ticks), then return to Garden
mod = world.tick % ticks_per_day
if mod in (1, 2): # short visit window
others = [a.split(":")[1] for a in available if a.startswith("speak:")]
if others and random.random() < 0.4:
return f"speak:{random.choice(others)}"
return "rest"
else:
return "move:east"
else:
# Should not happen; safest to rest
return "rest"