Compare commits

..

1 Commits

Author SHA1 Message Date
Alexander Whitestone
89de5b2c69 fix: make tower world events affect gameplay
Some checks failed
Self-Healing Smoke / self-healing-smoke (pull_request) Failing after 16s
Agent PR Gate / gate (pull_request) Failing after 40s
Smoke Test / smoke (pull_request) Failing after 27s
Agent PR Gate / report (pull_request) Successful in 21s
Closes #513
2026-04-22 10:44:56 -04:00
3 changed files with 273 additions and 277 deletions

View File

@@ -1059,6 +1059,46 @@ class GameEngine:
self.log("It will always pulse. That much you know.")
self.log("")
self.world.save()
def _bridge_is_hazardous(self):
bridge = self.world.rooms["Bridge"]
return bool(
self.world.state.get("bridge_flooding")
or bridge.get("weather") == "rain"
or bridge.get("rain_ticks", 0) > 0
)
def _bridge_crossing_extra_cost(self, current_room, dest):
if "Bridge" not in (current_room, dest):
return 0
return 2 if self._bridge_is_hazardous() else 0
def _event_dialogue(self, char_name, room_name):
if char_name == "Bezalel" and room_name == "Forge":
if self.world.rooms["Forge"]["fire"] == "cold":
return random.choice([
"The forge is cold. We cannot work until the fire lives again.",
"No forging now. The hearth is dead cold.",
])
if self.world.state.get("forge_fire_dying"):
return random.choice([
"The fire is dying. Tend it before the forge goes dark.",
"The forge is losing heat. Help me keep it alive.",
])
if char_name == "Ezra" and room_name == "Tower" and self.world.state.get("tower_power_low"):
return random.choice([
"The Tower power is too low. The servers won't hold a clean study right now.",
"The LED is flickering. We need steady power before the Tower can be read properly.",
])
if char_name in {"Marcus", "Allegro"} and room_name == "Bridge" and self._bridge_is_hazardous():
return random.choice([
"The Bridge is slick with rain. Cross carefully or wait it out.",
"This rain changes the Bridge. Don't treat it like dry stone.",
])
return None
def log(self, message):
"""Add to Timmy's log."""
@@ -1094,6 +1134,7 @@ class GameEngine:
}
# Process Timmy's action
room_name = self.world.characters["Timmy"]["room"]
timmy_energy = self.world.characters["Timmy"]["energy"]
# Energy constraint checks
@@ -1156,8 +1197,17 @@ class GameEngine:
if direction in connections:
dest = connections[direction]
bridge_extra_cost = self._bridge_crossing_extra_cost(current_room, dest)
move_cost = 1 + bridge_extra_cost
if self.world.characters["Timmy"]["energy"] < move_cost:
scene["log"].append("The rain makes the Bridge too costly to cross right now. Rest first.")
scene["room_desc"] = self.world.get_room_desc(current_room, "Timmy")
here = [n for n in self.world.characters if self.world.characters[n]["room"] == current_room and n != "Timmy"]
scene["here"] = here
return scene
self.world.characters["Timmy"]["room"] = dest
self.world.characters["Timmy"]["energy"] -= 1
self.world.characters["Timmy"]["energy"] -= move_cost
scene["log"].append(f"You move {direction} to The {dest}.")
scene["timmy_room"] = dest
@@ -1165,6 +1215,8 @@ class GameEngine:
# Check for rain on bridge
if dest == "Bridge" and self.world.rooms["Bridge"]["weather"] == "rain":
scene["world_events"].append("Rain mists on the dark water below. The railing is slick.")
if bridge_extra_cost:
scene["log"].append("Rain turns the Bridge crossing into work. You brace against the slick stone. (-2 extra energy)")
# Check trust changes for arrival
here = [n for n in self.world.characters if self.world.characters[n]["room"] == dest and n != "Timmy"]
@@ -1310,25 +1362,69 @@ class GameEngine:
elif timmy_action == "write_rule":
if self.world.characters["Timmy"]["room"] == "Tower":
rules = [
f"Rule #{self.world.tick}: The room remembers those who enter it.",
f"Rule #{self.world.tick}: A man in the dark needs to know someone is in the room.",
f"Rule #{self.world.tick}: The forge does not care about your schedule.",
f"Rule #{self.world.tick}: Every footprint on the stone means someone made it here.",
f"Rule #{self.world.tick}: The bridge does not judge. It only carries.",
f"Rule #{self.world.tick}: A seed planted in patience grows in time.",
f"Rule #{self.world.tick}: What is carved in wood outlasts what is said in anger.",
f"Rule #{self.world.tick}: The garden grows whether anyone watches or not.",
f"Rule #{self.world.tick}: Trust is built one tick at a time.",
f"Rule #{self.world.tick}: The fire remembers who tended it.",
]
new_rule = random.choice(rules)
self.world.rooms["Tower"]["messages"].append(new_rule)
self.world.characters["Timmy"]["energy"] -= 1
scene["log"].append(f"You write on the Tower whiteboard: \"{new_rule}\"")
if self.world.state.get("tower_power_low"):
scene["world_events"].append("The Tower power is too low. The LED flickers over the whiteboard.")
scene["log"].append("The power is too low to write a new rule.")
else:
rules = [
f"Rule #{self.world.tick}: The room remembers those who enter it.",
f"Rule #{self.world.tick}: A man in the dark needs to know someone is in the room.",
f"Rule #{self.world.tick}: The forge does not care about your schedule.",
f"Rule #{self.world.tick}: Every footprint on the stone means someone made it here.",
f"Rule #{self.world.tick}: The bridge does not judge. It only carries.",
f"Rule #{self.world.tick}: A seed planted in patience grows in time.",
f"Rule #{self.world.tick}: What is carved in wood outlasts what is said in anger.",
f"Rule #{self.world.tick}: The garden grows whether anyone watches or not.",
f"Rule #{self.world.tick}: Trust is built one tick at a time.",
f"Rule #{self.world.tick}: The fire remembers who tended it.",
]
new_rule = random.choice(rules)
self.world.rooms["Tower"]["messages"].append(new_rule)
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.")
elif timmy_action == "study":
if self.world.characters["Timmy"]["room"] == "Tower":
if self.world.state.get("tower_power_low"):
scene["world_events"].append("The Tower power is too low. The servers stutter in weak light.")
scene["log"].append("The power is too low to study the servers.")
else:
insights = [
"You study the server rhythm until the pulse resolves into something readable.",
"You trace the signal paths and feel the Tower settle into focus.",
"You study the green LED and the server racks until the pattern becomes clear.",
]
insight = random.choice(insights)
self.world.characters["Timmy"]["energy"] -= 1
self.world.characters["Timmy"]["memories"].append(insight)
scene["log"].append(insight)
scene["world_events"].append("The Tower answers with a steady hum.")
else:
scene["log"].append("You are not in the Tower.")
elif timmy_action == "forge":
if self.world.characters["Timmy"]["room"] == "Forge":
forge_fire = self.world.rooms["Forge"]["fire"]
if forge_fire == "cold":
scene["world_events"].append("The forge is cold. No metal will take shape here yet.")
scene["log"].append("The forge is cold. Tend the fire before you try to forge.")
else:
forged_items = [
f"bridge nail #{self.world.tick}",
f"tower key blank #{self.world.tick}",
f"garden trowel #{self.world.tick}",
]
forged_item = random.choice(forged_items)
self.world.rooms["Forge"]["forged_items"].append(forged_item)
self.world.characters["Timmy"]["energy"] -= 2
self.world.state["items_crafted"] += 1
scene["log"].append(f"You forge {forged_item} at the anvil.")
scene["world_events"].append("The anvil rings and the hearth answers.")
else:
scene["log"].append("You are not in the Forge.")
elif timmy_action == "carve":
if self.world.characters["Timmy"]["room"] == "Bridge":
carvings = [
@@ -1414,7 +1510,11 @@ class GameEngine:
speech_chance = 0.20
if random.random() < speech_chance:
if char_name == "Marcus":
event_line = self._event_dialogue(char_name, room_name)
if event_line:
self.world.characters[char_name]["spoken"].append(event_line)
scene["log"].append(f"{char_name} says: \"{event_line}\"")
elif char_name == "Marcus":
marcus_pool = self.DIALOGUES["Marcus"].get(phase, self.DIALOGUES["Marcus"]["quietus"])
line = random.choice(marcus_pool)
self.world.characters[char_name]["spoken"].append(line)

View File

@@ -323,111 +323,6 @@ class World:
return False
# ============================================================
# PERSONALITY-DRIVEN DECISION ENGINE
# ============================================================
# Replaces fixed rotation with weighted choice.
# Each character has:
# - home_room: preferred location
# - room_weights: base probabilities for each room
# - explore_chance: probability to explore randomly (10%)
# - social_weight: bonus when others are present
# - goal_weights: adjustments based on active_goal
PERSONALITY_DICT = {
"Marcus": {
"home_room": "Garden",
"room_weights": {"Garden": 0.4, "Bridge": 0.2, "Threshold": 0.2, "Tower": 0.1, "Forge": 0.1},
"explore_chance": 0.1,
"social_weight": 0.3,
"goal_weights": {
"sit": {"Garden": +0.3},
"speak_truth": {"Tower": +0.2, "Bridge": +0.2},
"remember": {"Garden": +0.2, "Threshold": +0.1},
},
},
"Bezalel": {
"home_room": "Forge",
"room_weights": {"Forge": 0.5, "Threshold": 0.2, "Garden": 0.1, "Bridge": 0.1, "Tower": 0.1},
"explore_chance": 0.1,
"social_weight": 0.15,
"goal_weights": {
"forge": {"Forge": +0.4},
"tend_fire": {"Forge": +0.5},
"create_key": {"Forge": +0.3},
},
},
"Allegro": {
"home_room": "Threshold",
"room_weights": {"Threshold": 0.35, "Tower": 0.25, "Forge": 0.15, "Garden": 0.15, "Bridge": 0.1},
"explore_chance": 0.1,
"social_weight": 0.25,
"goal_weights": {
"oversee": {"Threshold": +0.3},
"keep_time": {"Tower": +0.3},
"check_tunnel": {"Bridge": +0.2, "Threshold": +0.1},
},
},
"Ezra": {
"home_room": "Tower",
"room_weights": {"Tower": 0.45, "Threshold": 0.2, "Garden": 0.15, "Forge": 0.1, "Bridge": 0.1},
"explore_chance": 0.1,
"social_weight": 0.15,
"goal_weights": {
"study": {"Tower": +0.4},
"read_whiteboard": {"Tower": +0.4},
"find_pattern": {"Garden": +0.2, "Bridge": +0.1},
},
},
"Gemini": {
"home_room": "Garden",
"room_weights": {"Garden": 0.45, "Threshold": 0.2, "Bridge": 0.15, "Tower": 0.1, "Forge": 0.1},
"explore_chance": 0.1,
"social_weight": 0.25,
"goal_weights": {
"observe": {"Garden": +0.2, "Tower": +0.2},
"tend_garden": {"Garden": +0.5},
"listen": {"Bridge": +0.1, "Threshold": +0.1},
},
},
"Claude": {
"home_room": "Threshold",
"room_weights": {"Threshold": 0.3, "Tower": 0.25, "Forge": 0.2, "Garden": 0.15, "Bridge": 0.1},
"explore_chance": 0.1,
"social_weight": 0.2,
"goal_weights": {
"inspect": {"Threshold": +0.2, "Tower": +0.2},
"organize": {"Tower": +0.2, "Forge": +0.1},
"enforce_order": {"Threshold": +0.3, "Bridge": +0.1},
},
},
"ClawCode": {
"home_room": "Forge",
"room_weights": {"Forge": 0.5, "Threshold": 0.2, "Garden": 0.1, "Bridge": 0.1, "Tower": 0.1},
"explore_chance": 0.1,
"social_weight": 0.1,
"goal_weights": {
"forge": {"Forge": +0.4},
"test_edge": {"Forge": +0.4},
"build_weapon": {"Forge": +0.5},
},
},
"Kimi": {
"home_room": "Garden",
"room_weights": {"Garden": 0.4, "Threshold": 0.2, "Tower": 0.15, "Bridge": 0.15, "Forge": 0.1},
"explore_chance": 0.1,
"social_weight": 0.2,
"goal_weights": {
"contemplate": {"Garden": +0.3, "Tower": +0.1},
"read": {"Tower": +0.3},
"remember": {"Bridge": +0.2, "Threshold": +0.1},
},
},
}
# All available rooms
ALL_ROOMS = ["Threshold", "Tower", "Forge", "Garden", "Bridge"]
class ActionSystem:
"""Defines what actions are possible and what they cost."""
@@ -558,167 +453,100 @@ class TimmyAI:
class NPCAI:
"""AI for non-player characters. Weighted decision engine — agents choose, do not rotate."""
"""AI for non-player characters. They make choices based on goals."""
def __init__(self, world):
self.world = world
self._last_reasoning = {} # Store reasoning per char for tick logging
def get_reasoning(self, char_name):
"""Return reasoning dict for last decision."""
return self._last_reasoning.get(char_name, {})
def make_choice(self, char_name):
"""Make a weighted choice for this NPC. Returns (action, reasoning_dict)."""
"""Make a choice for this NPC this tick."""
char = self.world.characters[char_name]
room = char["room"]
available = ActionSystem.get_available_actions(char_name, self.world)
goal = char["active_goal"]
# Low energy → immediate rest
# If low energy, rest
if char["energy"] <= 1:
self._last_reasoning[char_name] = {"trigger": "low_energy", "reason": "Energy ≤ 1, resting"}
return "rest"
# Find personality profile
personality = PERSONALITY_DICT.get(char_name)
if not personality:
# Fallback: move toward home room if not there
if room != char.get("home", "Tower"):
action = f"move:{self._direction_to_home(room, char.get('home', 'Tower'))}"
self._last_reasoning[char_name] = {"trigger": "fallback_no_personality", "action": action}
return action
action = random.choice(["rest", "examine"])
self._last_reasoning[char_name] = {"trigger": "fallback_no_personality", "action": action}
return action
# Goal-driven behavior
goal = char["active_goal"]
# Build weighted action list
weights = self._compute_weights(char_name, char, room, available, personality, goal)
if char_name == "Marcus":
return self._marcus_choice(char, room, available)
elif char_name == "Bezalel":
return self._bezalel_choice(char, room, available)
elif char_name == "Allegro":
return self._allegro_choice(char, room, available)
elif char_name == "Ezra":
return self._ezra_choice(char, room, available)
elif char_name == "Gemini":
return self._gemini_choice(char, room, available)
elif char_name == "Claude":
return self._claude_choice(char, room, available)
elif char_name == "ClawCode":
return self._clawcode_choice(char, room, available)
elif char_name == "Kimi":
return self._kimi_choice(char, room, available)
if not weights:
action = "rest"
self._last_reasoning[char_name] = {"trigger": "fallback", "reason": "No weighted actions available"}
return action
# Sample action
actions, probs = zip(*weights)
action = random.choices(actions, weights=probs)[0]
# Store reasoning
reasoning = self._build_reasoning(char_name, char, room, weights, action, personality, goal)
self._last_reasoning[char_name] = reasoning
return action
return "rest"
def _direction_to_home(self, current_room, home_room):
"""Return direction name to get from current to home (simple adjacency)."""
# For now: use known map directions (fragile but minimal)
# Better: derive from world.rooms connections by searching
connections = self.world.rooms[current_room].get("connections", {})
for direction, dest in connections.items():
if dest == home_room:
return direction
# Fallback: pick a random connected room to explore toward home
if connections:
return random.choice(list(connections.keys()))
return "north" # should not happen
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 _compute_weights(self, char_name, char, room, available, personality, goal):
"""Compute weighted list of (action, prob) tuples."""
weights = []
room_weights = personality["room_weights"]
social_weight = personality["social_weight"]
goal_bonus = personality["goal_weights"].get(goal, {})
# Count others in the room
others_in_room = [n for n in self.world.characters
if self.world.characters[n]["room"] == room and n != char_name]
social_present = len(others_in_room) > 0
for action in available:
base_w = 0.05 # small floor for every action
# Movement-specific
if action.startswith("move:"):
direction = action.split(":")[1]
dest = action.split(" -> ")[1] if " -> " in action else None
if dest:
# Room probability
base_w += room_weights.get(dest, 0.05)
# Home room bonus
if dest == personality["home_room"]:
base_w += 0.2
# Social bonus
if social_present:
base_w += social_weight
# Goal bonus
if dest in goal_bonus:
base_w += goal_bonus[dest]
# Exploration penalty for home room (sometimes leave)
if dest == personality["home_room"]:
base_w *= (1 - personality.get("explore_chance", 0.1))
# Social actions
elif action.startswith("speak:") or action.startswith("listen:") or action.startswith("help:"):
person = action.split(":")[1]
base_w += 0.2 # base social interest
# Goal bonus
base_w += goal_bonus.get(person, 0)
# Other in same room bonus
if any(n == person for n in others_in_room):
base_w += 0.3
# Social weight
base_w += social_weight * 0.5
elif action.startswith("confront:"):
person = action.split(":")[1]
base_w += 0.1 # lower baseline
if any(n == person for n in others_in_room):
base_w += 0.2
# Room-specific craft/production actions
elif action in ["forge", "tend_fire", "study", "write_rule", "carve", "plant"]:
# These are location-bound; should only be available in correct room
if (action == "forge" and room != "Forge") or (action == "tend_fire" and room != "Forge") or (action == "study" and room != "Tower") or (action == "write_rule" and room != "Tower") or (action == "carve" and room != "Bridge") or (action == "plant" and room != "Garden"):
continue # skip (shouldn't be available but guard)
base_w += room_weights.get(room, 0.1) * 1.5 # being in the right room = high weight
# Goal bonus
if action in goal_bonus:
base_w += goal_bonus[action]
# Rest
elif action == "rest":
base_w += char["energy"] * 0.1 # higher energy → less rest
if char["energy"] < 3:
base_w += 0.4
else:
base_w += 0.05
# Examine
elif action == "examine":
base_w += 0.1
weights.append((action, base_w))
# Normalize probabilities to sum to 1
if not weights:
return []
total = sum(w for _, w in weights)
normalized = [(a, w/total) for a, w in weights]
return normalized
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"])
if room != "Forge":
return "move:west"
if random.random() < 0.3:
return "tend_fire"
return "forge"
def _build_reasoning(self, char_name, char, room, weights, action, personality, goal):
"""Build reasoning dict explaining the decision."""
# Find top contenders
sorted_w = sorted(weights, key=lambda x: x[1], reverse=True)
reasoning = {
"char": char_name,
"room": room,
"goal": goal,
"energy": char["energy"],
"chosen": action,
"top_contenders": sorted_w[:3],
}
return reasoning
def _kimi_choice(self, char, room, available):
others = [a.split(":")[1] for a in available if a.startswith("speak:")]
if room == "Garden" and others and random.random() < 0.3:
return f"speak:{random.choice(others)}"
if room == "Tower":
return "study" if char["energy"] > 2 else "rest"
return "move:east" # Head back toward Garden
def _gemini_choice(self, char, room, available):
others = [a.split(":")[1] for a in available if a.startswith("listen:")]
if room == "Garden" and others and random.random() < 0.4:
return f"listen:{random.choice(others)}"
return random.choice(["plant", "rest"] if room == "Garden" else ["move:west"])
def _ezra_choice(self, char, room, available):
if room == "Tower" and char["energy"] > 2:
return random.choice(["study", "write_rule", "help:Timmy"])
if room != "Tower":
return "move:south"
return "rest"
def _claude_choice(self, char, room, available):
others = [a.split(":")[1] for a in available if a.startswith("confront:")]
if others and random.random() < 0.2:
return f"confront:{random.choice(others)}"
return random.choice(["examine", "rest"])
def _clawcode_choice(self, char, room, available):
if room == "Forge" and char["energy"] > 2:
return "forge"
return random.choice(["move:east", "forge", "rest"])
def _allegro_choice(self, char, room, available):
others = [a.split(":")[1] for a in available if a.startswith("speak:")]
if others and random.random() < 0.3:
return f"speak:{random.choice(others)}"
return random.choice(["move:north", "move:south", "examine"])
class DialogueSystem:
@@ -1396,16 +1224,7 @@ class GameEngine:
self.world.characters[char_name]["room"] = dest
self.world.characters[char_name]["energy"] -= 1
scene["npc_actions"].append(f"{char_name} moves from The {old_room} to The {dest}")
# Collect NPC reasoning for debugging (Decision Engine trace)
scene["npc_reasoning"] = {}
for npc_name in self.world.characters:
if npc_name == "Timmy":
continue
reasoning = self.npc_ai.get_reasoning(npc_name)
if reasoning:
scene["npc_reasoning"][npc_name] = reasoning
# Random NPC events
room_name = self.world.characters["Timmy"]["room"]
for char_name in self.world.characters:

View File

@@ -1,6 +1,7 @@
from importlib.util import module_from_spec, spec_from_file_location
from pathlib import Path
import unittest
from unittest.mock import patch
ROOT = Path(__file__).resolve().parent.parent
@@ -66,6 +67,82 @@ class TestEvenniaLocalWorldGame(unittest.TestCase):
self.assertIn("Ezra is already here.", result["log"])
self.assertIn("The servers hum steady. The green LED pulses.", result["world_events"])
def test_bridge_rain_crossing_costs_extra_energy_and_warns(self):
module = load_game_module()
dry_engine = module.GameEngine()
dry_engine.start_new_game()
dry_engine.world.update_world_state = lambda: None
dry_engine.world.characters["Timmy"]["energy"] = 10
dry_result = dry_engine.run_tick("move:south")
dry_energy = dry_engine.world.characters["Timmy"]["energy"]
rainy_engine = module.GameEngine()
rainy_engine.start_new_game()
rainy_engine.world.update_world_state = lambda: None
rainy_engine.world.characters["Timmy"]["energy"] = 10
rainy_engine.world.rooms["Bridge"]["weather"] = "rain"
rainy_engine.world.rooms["Bridge"]["rain_ticks"] = 3
rainy_engine.world.state["bridge_flooding"] = True
rainy_result = rainy_engine.run_tick("move:south")
self.assertEqual(rainy_engine.world.characters["Timmy"]["room"], "Bridge")
self.assertLess(rainy_engine.world.characters["Timmy"]["energy"], dry_energy)
self.assertTrue(
any("bridge" in line.lower() and ("rain" in line.lower() or "slick" in line.lower()) for line in rainy_result["log"] + rainy_result["world_events"]),
rainy_result,
)
def test_tower_power_low_blocks_study_and_write_rule(self):
module = load_game_module()
engine = module.GameEngine()
engine.start_new_game()
engine.world.update_world_state = lambda: None
engine.world.characters["Timmy"]["room"] = "Tower"
engine.world.characters["Timmy"]["energy"] = 10
engine.world.state["tower_power_low"] = True
rules_before = list(engine.world.rooms["Tower"]["messages"])
study_result = engine.run_tick("study")
self.assertEqual(engine.world.characters["Timmy"]["energy"], 10)
self.assertTrue(
any("power" in line.lower() and ("study" in line.lower() or "servers" in line.lower()) for line in study_result["log"] + study_result["world_events"]),
study_result,
)
write_result = engine.run_tick("write_rule")
self.assertEqual(engine.world.rooms["Tower"]["messages"], rules_before)
self.assertTrue(
any("power" in line.lower() and ("write" in line.lower() or "whiteboard" in line.lower()) for line in write_result["log"] + write_result["world_events"]),
write_result,
)
def test_cold_forge_blocks_forge_action_and_bezalel_reacts(self):
module = load_game_module()
engine = module.GameEngine()
engine.start_new_game()
engine.world.update_world_state = lambda: None
engine.npc_ai.make_choice = lambda _name: None
engine.world.characters["Timmy"]["room"] = "Forge"
engine.world.characters["Timmy"]["energy"] = 10
engine.world.characters["Bezalel"]["room"] = "Forge"
engine.world.rooms["Forge"]["fire"] = "cold"
engine.world.state["forge_fire_dying"] = True
forged_before = list(engine.world.rooms["Forge"]["forged_items"])
with patch.object(module.random, "random", return_value=0.0), patch.object(module.random, "choice", side_effect=lambda seq: seq[0]):
result = engine.run_tick("forge")
self.assertEqual(engine.world.rooms["Forge"]["forged_items"], forged_before)
self.assertTrue(
any("forge" in line.lower() and ("cold" in line.lower() or "fire" in line.lower()) for line in result["log"] + result["world_events"]),
result,
)
self.assertTrue(
any(line.startswith("Bezalel says:") and ("fire" in line.lower() or "forge" in line.lower()) for line in result["log"]),
result,
)
if __name__ == "__main__":
unittest.main()