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
6 changed files with 144 additions and 244 deletions

View File

@@ -285,24 +285,25 @@ class World:
self.state.pop("phase_transition_event", None)
# Natural energy decay: the world is exhausting
self.state.pop("energy_collapse_event", None)
for char_name, char in self.characters.items():
char["energy"] = max(0, char["energy"] - 0.3)
# Check for energy collapse
if char["energy"] <= 0:
current = char.get("room", "Threshold")
next_rooms = [room for room in self.rooms.keys() if room != current]
new_room = random.choice(next_rooms) if next_rooms else current
char["energy"] = 2 # Wake up with some energy
char["room"] = new_room
# Timmy collapse gets special narrative treatment
if char.get("is_player", False):
char["memories"].append("Collapsed from exhaustion.")
self.state["energy_collapse_event"] = {
"character": char_name,
"from_room": current,
"to_room": new_room,
}
char["energy"] = 2 # Wake up with some energy
# Random room change (scattered)
rooms = list(self.rooms.keys())
current = char.get("room", "Threshold")
new_room = current
attempts = 0
while new_room == current and attempts < 10:
import random as _r
new_room = _r.choice(rooms)
attempts += 1
if new_room != current:
char["room"] = new_room
# Forge fire naturally dims if not tended
# Phase-aware: Breaking phase has higher fire-death chance
@@ -1093,21 +1094,7 @@ class GameEngine:
}
# Process Timmy's action
room_name = self.world.characters["Timmy"]["room"]
timmy_energy = self.world.characters["Timmy"]["energy"]
collapse_event = self.world.state.pop("energy_collapse_event", None)
if collapse_event and collapse_event.get("character") == "Timmy":
room_name = self.world.characters["Timmy"]["room"]
scene["timmy_room"] = room_name
scene["timmy_energy"] = self.world.characters["Timmy"]["energy"]
scene["room_desc"] = self.world.get_room_desc(room_name, "Timmy")
here = [n for n in self.world.characters if self.world.characters[n]["room"] == room_name and n != "Timmy"]
scene["here"] = here
scene["log"].append("You collapse from exhaustion. The world spins. You wake somewhere else.")
if collapse_event.get("from_room") != collapse_event.get("to_room"):
scene["log"].append(f"You are in The {collapse_event['to_room']}, disoriented.")
return scene
# Energy constraint checks
action_costs = {
@@ -1170,7 +1157,7 @@ class GameEngine:
if direction in connections:
dest = connections[direction]
self.world.characters["Timmy"]["room"] = dest
self.world.characters["Timmy"]["energy"] -= action_cost
self.world.characters["Timmy"]["energy"] -= 1
scene["log"].append(f"You move {direction} to The {dest}.")
scene["timmy_room"] = dest
@@ -1278,7 +1265,7 @@ class GameEngine:
self.world.characters["Timmy"]["trust"]["Kimi"] = min(1.0,
self.world.characters["Timmy"]["trust"].get("Kimi", 0) + 0.1)
self.world.characters["Timmy"]["energy"] -= action_cost
self.world.characters["Timmy"]["energy"] -= 0
else:
scene["log"].append(f"{target} is not in this room.")
@@ -1315,7 +1302,7 @@ class GameEngine:
if self.world.characters["Timmy"]["room"] == "Forge":
self.world.rooms["Forge"]["fire"] = "glowing"
self.world.rooms["Forge"]["fire_tended"] += 1
self.world.characters["Timmy"]["energy"] -= action_cost
self.world.characters["Timmy"]["energy"] -= 2
scene["log"].append("You tend the forge fire. The flames leap up, bright and hungry.")
self.world.state["forge_fire_dying"] = False
else:
@@ -1337,7 +1324,7 @@ class GameEngine:
]
new_rule = random.choice(rules)
self.world.rooms["Tower"]["messages"].append(new_rule)
self.world.characters["Timmy"]["energy"] -= action_cost
self.world.characters["Timmy"]["energy"] -= 1
scene["log"].append(f"You write on the Tower whiteboard: \"{new_rule}\"")
else:
scene["log"].append("You are not in the Tower.")
@@ -1356,7 +1343,7 @@ class GameEngine:
new_carving = random.choice(carvings)
if new_carving not in self.world.rooms["Bridge"]["carvings"]:
self.world.rooms["Bridge"]["carvings"].append(new_carving)
self.world.characters["Timmy"]["energy"] -= action_cost
self.world.characters["Timmy"]["energy"] -= 1
scene["log"].append(f"You carve into the railing: \"{new_carving}\"")
else:
scene["log"].append("You are not on the Bridge.")
@@ -1365,7 +1352,7 @@ class GameEngine:
if self.world.characters["Timmy"]["room"] == "Garden":
self.world.rooms["Garden"]["growth"] = min(5,
self.world.rooms["Garden"]["growth"] + 1)
self.world.characters["Timmy"]["energy"] -= action_cost
self.world.characters["Timmy"]["energy"] -= 1
scene["log"].append("You plant something in the dark soil. The earth takes it without question.")
else:
scene["log"].append("You are not in the Garden.")
@@ -1384,7 +1371,7 @@ class GameEngine:
self.world.characters["Timmy"]["trust"].get(target_name, 0) + 0.2)
self.world.characters[target_name]["trust"]["Timmy"] = min(1.0,
self.world.characters[target_name]["trust"].get("Timmy", 0) + 0.2)
self.world.characters["Timmy"]["energy"] -= action_cost
self.world.characters["Timmy"]["energy"] -= 1
scene["log"].append(f"You help {target_name}. They look grateful.")
else:
@@ -1440,9 +1427,6 @@ class GameEngine:
self.world.characters[char_name]["spoken"].append(line)
scene["log"].append(f"{char_name} says: \"{line}\"")
scene["timmy_room"] = self.world.characters["Timmy"]["room"]
scene["timmy_energy"] = self.world.characters["Timmy"]["energy"]
# Save the world
self.world.save()

View File

@@ -203,25 +203,26 @@ class World:
def update_world_state(self):
"""World changes independent of character actions."""
# Natural energy decay: the world is exhausting
self.state.pop("energy_collapse_event", None)
for char_name, char in self.characters.items():
char["energy"] = max(0, char["energy"] - 0.3)
# Check for energy collapse
if char["energy"] <= 0:
current = char.get("room", "Threshold")
next_rooms = [room for room in self.rooms.keys() if room != current]
new_room = random.choice(next_rooms) if next_rooms else current
char["energy"] = 2 # Wake up with some energy
char["room"] = new_room
# Timmy collapse gets special narrative treatment
if char.get("is_player", False):
char["memories"].append("Collapsed from exhaustion.")
self.state["energy_collapse_event"] = {
"character": char_name,
"from_room": current,
"to_room": new_room,
}
char["energy"] = 2 # Wake up with some energy
# Random room change (scattered)
rooms = list(self.rooms.keys())
current = char.get("room", "Threshold")
new_room = current
attempts = 0
while new_room == current and attempts < 10:
new_room = rooms[0] # Will change to random
attempts += 1
if new_room != current:
char["room"] = new_room
# Forge fire naturally dims if not tended
self.state["forge_fire_dying"] = random.random() < 0.1
# Random weather events
@@ -926,21 +927,7 @@ class GameEngine:
}
# Process Timmy's action
room_name = self.world.characters["Timmy"]["room"]
timmy_energy = self.world.characters["Timmy"]["energy"]
collapse_event = self.world.state.pop("energy_collapse_event", None)
if collapse_event and collapse_event.get("character") == "Timmy":
room_name = self.world.characters["Timmy"]["room"]
scene["timmy_room"] = room_name
scene["timmy_energy"] = self.world.characters["Timmy"]["energy"]
scene["room_desc"] = self.world.get_room_desc(room_name, "Timmy")
here = [n for n in self.world.characters if self.world.characters[n]["room"] == room_name and n != "Timmy"]
scene["here"] = here
scene["log"].append("You collapse from exhaustion. The world spins. You wake somewhere else.")
if collapse_event.get("from_room") != collapse_event.get("to_room"):
scene["log"].append(f"You are in The {collapse_event['to_room']}, disoriented.")
return scene
# Energy constraint checks
action_costs = {
@@ -1003,7 +990,7 @@ class GameEngine:
if direction in connections:
dest = connections[direction]
self.world.characters["Timmy"]["room"] = dest
self.world.characters["Timmy"]["energy"] -= action_cost
self.world.characters["Timmy"]["energy"] -= 1
scene["log"].append(f"You move {direction} to The {dest}.")
scene["timmy_room"] = dest
@@ -1105,7 +1092,7 @@ class GameEngine:
self.world.characters["Timmy"]["trust"]["Kimi"] = min(1.0,
self.world.characters["Timmy"]["trust"].get("Kimi", 0) + 0.1)
self.world.characters["Timmy"]["energy"] -= action_cost
self.world.characters["Timmy"]["energy"] -= 0
else:
scene["log"].append(f"{target} is not in this room.")
@@ -1142,7 +1129,7 @@ class GameEngine:
if self.world.characters["Timmy"]["room"] == "Forge":
self.world.rooms["Forge"]["fire"] = "glowing"
self.world.rooms["Forge"]["fire_tended"] += 1
self.world.characters["Timmy"]["energy"] -= action_cost
self.world.characters["Timmy"]["energy"] -= 2
scene["log"].append("You tend the forge fire. The flames leap up, bright and hungry.")
self.world.state["forge_fire_dying"] = False
else:
@@ -1164,7 +1151,7 @@ class GameEngine:
]
new_rule = random.choice(rules)
self.world.rooms["Tower"]["messages"].append(new_rule)
self.world.characters["Timmy"]["energy"] -= action_cost
self.world.characters["Timmy"]["energy"] -= 1
scene["log"].append(f"You write on the Tower whiteboard: \"{new_rule}\"")
else:
scene["log"].append("You are not in the Tower.")
@@ -1183,7 +1170,7 @@ class GameEngine:
new_carving = random.choice(carvings)
if new_carving not in self.world.rooms["Bridge"]["carvings"]:
self.world.rooms["Bridge"]["carvings"].append(new_carving)
self.world.characters["Timmy"]["energy"] -= action_cost
self.world.characters["Timmy"]["energy"] -= 1
scene["log"].append(f"You carve into the railing: \"{new_carving}\"")
else:
scene["log"].append("You are not on the Bridge.")
@@ -1192,7 +1179,7 @@ class GameEngine:
if self.world.characters["Timmy"]["room"] == "Garden":
self.world.rooms["Garden"]["growth"] = min(5,
self.world.rooms["Garden"]["growth"] + 1)
self.world.characters["Timmy"]["energy"] -= action_cost
self.world.characters["Timmy"]["energy"] -= 1
scene["log"].append("You plant something in the dark soil. The earth takes it without question.")
else:
scene["log"].append("You are not in the Garden.")
@@ -1211,7 +1198,7 @@ class GameEngine:
self.world.characters["Timmy"]["trust"].get(target_name, 0) + 0.2)
self.world.characters[target_name]["trust"]["Timmy"] = min(1.0,
self.world.characters[target_name]["trust"].get("Timmy", 0) + 0.2)
self.world.characters["Timmy"]["energy"] -= action_cost
self.world.characters["Timmy"]["energy"] -= 1
scene["log"].append(f"You help {target_name}. They look grateful.")
else:
@@ -1255,9 +1242,6 @@ class GameEngine:
self.world.characters[char_name]["spoken"].append(line)
scene["log"].append(f'{char_name} says: "{line}"')
scene["timmy_room"] = self.world.characters["Timmy"]["room"]
scene["timmy_energy"] = self.world.characters["Timmy"]["energy"]
# Save the world
self.world.save()

View File

@@ -1,127 +0,0 @@
from importlib.util import module_from_spec, spec_from_file_location
from pathlib import Path
import random as std_random
import pytest
ROOT = Path(__file__).resolve().parent.parent
GAME_PATH = ROOT / "evennia" / "timmy_world" / "game.py"
def load_game_module(tmp_path: Path):
spec = spec_from_file_location("evennia_local_world_game_energy", GAME_PATH)
module = module_from_spec(spec)
assert spec.loader is not None
spec.loader.exec_module(module)
module.random.seed(0)
module.WORLD_DIR = tmp_path
module.STATE_FILE = tmp_path / "game_state.json"
module.TIMMY_LOG = tmp_path / "timmy_log.md"
module.WORLD_DIR.mkdir(parents=True, exist_ok=True)
return module
def make_engine(tmp_path: Path):
module = load_game_module(tmp_path)
engine = module.GameEngine()
engine.start_new_game()
return module, engine
def choose_action(module, engine):
energy = engine.world.characters["Timmy"]["energy"]
room = engine.world.characters["Timmy"]["room"]
actions = module.PlayerInterface(engine).get_available_actions()
if energy <= 1 and "rest" in actions:
return "rest"
if room == "Garden" and "speak:Marcus" in actions and energy <= 4.3:
return "speak:Marcus"
if room == "Forge" and "tend_fire" in actions:
return "tend_fire"
if room == "Tower" and "write_rule" in actions:
return "write_rule"
if room == "Bridge" and "carve" in actions:
return "carve"
if room == "Garden" and "plant" in actions:
return "plant"
moves = [action.split(" ->")[0] for action in actions if action.startswith("move:")]
if moves:
return moves[0]
if "look" in actions:
return "look"
return actions[0]
class TestTowerGameEnergyConstraints:
def test_low_energy_blocks_costly_action_without_crashing(self, tmp_path):
module, engine = make_engine(tmp_path)
engine.world.characters["Timmy"]["energy"] = 1
result = engine.run_tick("move:north")
assert any("too exhausted" in line for line in result["log"])
assert engine.world.characters["Timmy"]["room"] == "Threshold"
assert engine.world.characters["Timmy"]["energy"] == pytest.approx(0.7)
def test_tired_move_uses_extra_effort_and_drains_energy(self, tmp_path):
module, engine = make_engine(tmp_path)
engine.world.characters["Timmy"]["energy"] = 3.3
result = engine.run_tick("move:north")
assert any("more effort than usual" in line for line in result["log"])
assert engine.world.characters["Timmy"]["room"] == "Tower"
assert engine.world.characters["Timmy"]["energy"] == pytest.approx(0.0)
assert result["timmy_energy"] == pytest.approx(0.0)
def test_rest_is_meaningful_choice_with_room_specific_recovery(self, tmp_path):
module, garden_engine = make_engine(tmp_path / "garden")
garden_engine.world.characters["Timmy"]["room"] = "Garden"
garden_engine.world.characters["Timmy"]["energy"] = 1.0
garden_result = garden_engine.run_tick("rest")
module, bridge_engine = make_engine(tmp_path / "bridge")
bridge_engine.world.characters["Timmy"]["room"] = "Bridge"
bridge_engine.world.characters["Timmy"]["energy"] = 1.0
bridge_result = bridge_engine.run_tick("rest")
assert any("stone bench" in line for line in garden_result["log"])
assert any("no place to rest" in line.lower() for line in bridge_result["log"])
assert garden_engine.world.characters["Timmy"]["energy"] > bridge_engine.world.characters["Timmy"]["energy"]
def test_marcus_can_provide_energy_relief_when_timmy_is_tired(self, tmp_path):
module, engine = make_engine(tmp_path)
engine.world.characters["Timmy"]["room"] = "Garden"
engine.world.characters["Timmy"]["energy"] = 4.3
result = engine.run_tick("speak:Marcus")
assert any("Marcus offers you food" in line for line in result["log"])
assert engine.world.characters["Timmy"]["energy"] == pytest.approx(5.0)
assert result["timmy_energy"] == pytest.approx(5.0)
def test_energy_collapse_moves_timmy_and_records_memory(self, tmp_path, monkeypatch):
module, engine = make_engine(tmp_path)
engine.world.characters["Timmy"]["energy"] = 0
engine.world.characters["Timmy"]["room"] = "Threshold"
monkeypatch.setattr(std_random, "choice", lambda seq: "Tower")
result = engine.run_tick("look")
assert any("collapse from exhaustion" in line.lower() for line in result["log"])
assert engine.world.characters["Timmy"]["room"] == "Tower"
assert engine.world.characters["Timmy"]["energy"] == 2
assert "Collapsed from exhaustion." in engine.world.characters["Timmy"]["memories"]
def test_intentional_play_reaches_low_energy_within_100_ticks(self, tmp_path):
module, engine = make_engine(tmp_path)
min_energy = engine.world.characters["Timmy"]["energy"]
for _ in range(100):
action = choose_action(module, engine)
engine.run_tick(action)
min_energy = min(min_energy, engine.world.characters["Timmy"]["energy"])
assert min_energy <= 3

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 = {
@@ -203,25 +205,26 @@ class World:
def update_world_state(self):
"""World changes independent of character actions."""
# Natural energy decay: the world is exhausting
self.state.pop("energy_collapse_event", None)
for char_name, char in self.characters.items():
char["energy"] = max(0, char["energy"] - 0.3)
# Check for energy collapse
if char["energy"] <= 0:
current = char.get("room", "Threshold")
next_rooms = [room for room in self.rooms.keys() if room != current]
new_room = random.choice(next_rooms) if next_rooms else current
char["energy"] = 2 # Wake up with some energy
char["room"] = new_room
# Timmy collapse gets special narrative treatment
if char.get("is_player", False):
char["memories"].append("Collapsed from exhaustion.")
self.state["energy_collapse_event"] = {
"character": char_name,
"from_room": current,
"to_room": new_room,
}
char["energy"] = 2 # Wake up with some energy
# Random room change (scattered)
rooms = list(self.rooms.keys())
current = char.get("room", "Threshold")
new_room = current
attempts = 0
while new_room == current and attempts < 10:
new_room = rooms[0] # Will change to random
attempts += 1
if new_room != current:
char["room"] = new_room
# Forge fire naturally dims if not tended
self.state["forge_fire_dying"] = random.random() < 0.1
# Random weather events
@@ -494,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":
@@ -512,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"])
@@ -670,21 +662,7 @@ class GameEngine:
}
# Process Timmy's action
room_name = self.world.characters["Timmy"]["room"]
timmy_energy = self.world.characters["Timmy"]["energy"]
collapse_event = self.world.state.pop("energy_collapse_event", None)
if collapse_event and collapse_event.get("character") == "Timmy":
room_name = self.world.characters["Timmy"]["room"]
scene["timmy_room"] = room_name
scene["timmy_energy"] = self.world.characters["Timmy"]["energy"]
scene["room_desc"] = self.world.get_room_desc(room_name, "Timmy")
here = [n for n in self.world.characters if self.world.characters[n]["room"] == room_name and n != "Timmy"]
scene["here"] = here
scene["log"].append("You collapse from exhaustion. The world spins. You wake somewhere else.")
if collapse_event.get("from_room") != collapse_event.get("to_room"):
scene["log"].append(f"You are in The {collapse_event['to_room']}, disoriented.")
return scene
# Energy constraint checks
action_costs = {
@@ -747,7 +725,7 @@ class GameEngine:
if direction in connections:
dest = connections[direction]
self.world.characters["Timmy"]["room"] = dest
self.world.characters["Timmy"]["energy"] -= action_cost
self.world.characters["Timmy"]["energy"] -= 1
scene["log"].append(f"You move {direction} to The {dest}.")
scene["timmy_room"] = dest
@@ -861,7 +839,7 @@ class GameEngine:
self.world.characters["Timmy"]["trust"]["Kimi"] = min(1.0,
self.world.characters["Timmy"]["trust"].get("Kimi", 0) + 0.1)
self.world.characters["Timmy"]["energy"] -= action_cost
self.world.characters["Timmy"]["energy"] -= 0
else:
scene["log"].append(f"{target} is not in this room.")
@@ -898,7 +876,7 @@ class GameEngine:
if self.world.characters["Timmy"]["room"] == "Forge":
self.world.rooms["Forge"]["fire"] = "glowing"
self.world.rooms["Forge"]["fire_tended"] += 1
self.world.characters["Timmy"]["energy"] -= action_cost
self.world.characters["Timmy"]["energy"] -= 2
scene["log"].append("You tend the forge fire. The flames leap up, bright and hungry.")
self.world.state["forge_fire_dying"] = False
else:
@@ -927,7 +905,7 @@ class GameEngine:
]
new_rule = random.choice(rules)
self.world.rooms["Tower"]["messages"].append(new_rule)
self.world.characters["Timmy"]["energy"] -= action_cost
self.world.characters["Timmy"]["energy"] -= 1
scene["log"].append(f"You write on the Tower whiteboard: \"{new_rule}\"")
else:
scene["log"].append("You are not in the Tower.")
@@ -953,7 +931,7 @@ class GameEngine:
new_carving = random.choice(carvings)
if new_carving not in self.world.rooms["Bridge"]["carvings"]:
self.world.rooms["Bridge"]["carvings"].append(new_carving)
self.world.characters["Timmy"]["energy"] -= action_cost
self.world.characters["Timmy"]["energy"] -= 1
scene["log"].append(f"You carve into the railing: \"{new_carving}\"")
else:
scene["log"].append("You are not on the Bridge.")
@@ -962,7 +940,7 @@ class GameEngine:
if self.world.characters["Timmy"]["room"] == "Garden":
self.world.rooms["Garden"]["growth"] = min(5,
self.world.rooms["Garden"]["growth"] + 1)
self.world.characters["Timmy"]["energy"] -= action_cost
self.world.characters["Timmy"]["energy"] -= 1
scene["log"].append("You plant something in the dark soil. The earth takes it without question.")
else:
scene["log"].append("You are not in the Garden.")
@@ -988,7 +966,7 @@ class GameEngine:
self.world.characters["Timmy"]["trust"].get(target_name, 0) + 0.2)
self.world.characters[target_name]["trust"]["Timmy"] = min(1.0,
self.world.characters[target_name]["trust"].get("Timmy", 0) + 0.2)
self.world.characters["Timmy"]["energy"] -= action_cost
self.world.characters["Timmy"]["energy"] -= 1
scene["log"].append(f"You help {target_name}. They look grateful.")
elif timmy_action.startswith("confront:"):
@@ -1089,9 +1067,6 @@ class GameEngine:
self.world.characters[char_name]["spoken"].append(line)
scene["log"].append(f"{char_name} says: \"{line}\"")
scene["timmy_room"] = self.world.characters["Timmy"]["room"]
scene["timmy_energy"] = self.world.characters["Timmy"]["energy"]
# Save the world
self.world.save()

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"