Compare commits
1 Commits
fix/511
...
step35/522
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f828110f6 |
@@ -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()
|
||||
|
||||
|
||||
70
evennia/timmy_world/world/bridge/README.md
Normal file
70
evennia/timmy_world/world/bridge/README.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# Evennia World Bridge System
|
||||
|
||||
## Components
|
||||
|
||||
### 1. Gateway (`world/gateway.py`)
|
||||
- `GatewayRoom` typeclass: A room that connects to another world
|
||||
- `TravelExit`: An exit that leads to another world
|
||||
- Properties: destination_url, destination_room, bridge_active, visitors
|
||||
|
||||
### 2. Bridge API (`world/bridge_api.py`)
|
||||
- HTTP server on port 4003
|
||||
- Endpoints:
|
||||
- `GET /bridge/state` — Get current world state
|
||||
- `GET /bridge/health` — Check world health
|
||||
- `POST /bridge/sync` — Sync state from another world
|
||||
|
||||
### 3. Bridge Daemon (`world/bridge_daemon.py`)
|
||||
- Polls remote world for state changes
|
||||
- Syncs characters, events, and state
|
||||
- Usage: `python world/bridge_daemon.py --remote http://vps:4003 --poll 5`
|
||||
|
||||
### 4. Migration Tool (`world/migrate.py`)
|
||||
- Export world state: `python world/migrate.py export --output world.json`
|
||||
- Import world state: `python world/migrate.py import --input world.json`
|
||||
|
||||
## Setup
|
||||
|
||||
### On Mac (Timmy's World)
|
||||
```bash
|
||||
cd ~/.timmy/evennia/timmy_world
|
||||
# Start bridge API
|
||||
python world/bridge_api.py &
|
||||
# Start bridge daemon
|
||||
python world/bridge_daemon.py --remote http://143.198.27.163:4003 --poll 5
|
||||
```
|
||||
|
||||
### On VPS (The Wizard's Canon)
|
||||
```bash
|
||||
cd /root/workspace/timmy-academy
|
||||
# Start bridge API on port 4003
|
||||
python world/bridge_api.py &
|
||||
# Configure GatewayRoom with destination_url
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
1. **Portal Rooms**: Both worlds have a GatewayRoom configured with destination_url
|
||||
2. **Bridge API**: Each world runs a bridge_api.py server
|
||||
3. **Bridge Daemon**: Polls remote world every 5 seconds
|
||||
4. **Travel Command**: Characters can use `travel [room]` to cross worlds
|
||||
5. **Sync**: Characters, events, and state sync between worlds
|
||||
|
||||
## Events That Sync
|
||||
|
||||
- Character arrivals/departures
|
||||
- Messages spoken in portal rooms
|
||||
- World events (fire, rain, growth)
|
||||
- Trust changes (limited sync)
|
||||
|
||||
## Current Status
|
||||
|
||||
- **Prototype**: Code structure created
|
||||
- **Not Tested**: Bridge not yet tested with real Evennia instances
|
||||
- **Next Steps**:
|
||||
1. Test bridge API on Mac world
|
||||
2. Add GatewayRoom to Mac world (Threshold or Gate)
|
||||
3. Configure destination_url to point to VPS
|
||||
4. Test on VPS world
|
||||
5. Test travel command
|
||||
6. Test character sync
|
||||
120
evennia/timmy_world/world/bridge_api.py
Normal file
120
evennia/timmy_world/world/bridge_api.py
Normal file
@@ -0,0 +1,120 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Evennia Bridge API — HTTP endpoints for cross-world synchronization.
|
||||
|
||||
This API allows Evennia worlds on different machines to sync:
|
||||
- Character presence (who is where)
|
||||
- Messages (what was said)
|
||||
- World events (fire, rain, growth)
|
||||
- State changes
|
||||
|
||||
Usage:
|
||||
Start bridge server: python world/bridge_api.py
|
||||
It will listen on port 4003 (configurable)
|
||||
"""
|
||||
import json
|
||||
import threading
|
||||
from http.server import HTTPServer, BaseHTTPRequestHandler
|
||||
import time
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Ensure the world directory is importable
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from emergence import ROOMS, CHARACTERS
|
||||
|
||||
BRIDGE_PORT = int(os.environ.get('BRIDGE_PORT', 4003))
|
||||
BRIDGE_HOST = os.environ.get('BRIDGE_HOST', '0.0.0.0')
|
||||
|
||||
# Shared state for bridge
|
||||
bridge_state = {
|
||||
"world_name": "Timmy World",
|
||||
"characters": {},
|
||||
"events": [],
|
||||
"last_sync": None,
|
||||
"bridge_active": False,
|
||||
}
|
||||
|
||||
class BridgeHandler(BaseHTTPRequestHandler):
|
||||
"""HTTP handler for bridge API requests."""
|
||||
|
||||
def do_GET(self):
|
||||
"""Get bridge state."""
|
||||
if self.path == '/bridge/state':
|
||||
self.send_response(200)
|
||||
self.send_header('Content-Type', 'application/json')
|
||||
self.end_headers()
|
||||
response = json.dumps(bridge_state)
|
||||
self.wfile.write(response.encode())
|
||||
elif self.path == '/bridge/health':
|
||||
self.send_response(200)
|
||||
self.send_header('Content-Type', 'application/json')
|
||||
self.end_headers()
|
||||
response = json.dumps({
|
||||
"status": "ok",
|
||||
"world": bridge_state["world_name"],
|
||||
"characters": len(bridge_state["characters"]),
|
||||
"events": len(bridge_state["events"]),
|
||||
"active": bridge_state["bridge_active"],
|
||||
})
|
||||
self.wfile.write(response.encode())
|
||||
elif self.path == '/bridge/map':
|
||||
self.send_response(200)
|
||||
self.send_header('Content-Type', 'application/json')
|
||||
self.end_headers()
|
||||
response = json.dumps(build_world_map())
|
||||
self.wfile.write(response.encode())
|
||||
else:
|
||||
self.send_response(404)
|
||||
self.end_headers()
|
||||
|
||||
def do_POST(self):
|
||||
"""Post bridge events."""
|
||||
if self.path == '/bridge/sync':
|
||||
content_length = int(self.headers.get('Content-Length', 0))
|
||||
body = self.rfile.read(content_length)
|
||||
try:
|
||||
data = json.loads(body)
|
||||
# Process incoming sync data
|
||||
if 'characters' in data:
|
||||
bridge_state['characters'].update(data['characters'])
|
||||
if 'events' in data:
|
||||
bridge_state['events'].extend(data['events'])
|
||||
bridge_state['last_sync'] = time.time()
|
||||
bridge_state['bridge_active'] = True
|
||||
|
||||
self.send_response(200)
|
||||
self.send_header('Content-Type', 'application/json')
|
||||
self.end_headers()
|
||||
response = json.dumps({"status": "ok", "received": len(bridge_state['characters'])})
|
||||
self.wfile.write(response.encode())
|
||||
except json.JSONDecodeError:
|
||||
self.send_response(400)
|
||||
self.end_headers()
|
||||
else:
|
||||
self.send_response(404)
|
||||
self.end_headers()
|
||||
|
||||
def log_message(self, format, *args):
|
||||
"""Suppress default logging."""
|
||||
pass # Bridge is silent
|
||||
|
||||
|
||||
def start_bridge_server():
|
||||
"""Start the bridge HTTP server in a background thread."""
|
||||
server = HTTPServer((BRIDGE_HOST, BRIDGE_PORT), BridgeHandler)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
print(f"Bridge API server started on {BRIDGE_HOST}:{BRIDGE_PORT}")
|
||||
return server
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
server = start_bridge_server()
|
||||
try:
|
||||
# Keep the main thread alive
|
||||
while True:
|
||||
time.sleep(1)
|
||||
except KeyboardInterrupt:
|
||||
print("\nStopping bridge server...")
|
||||
server.shutdown()
|
||||
132
evennia/timmy_world/world/bridge_daemon.py
Normal file
132
evennia/timmy_world/world/bridge_daemon.py
Normal file
@@ -0,0 +1,132 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Bridge Daemon — Syncs state between Evennia worlds.
|
||||
|
||||
Polls remote worlds and syncs:
|
||||
- Character presence
|
||||
- World events
|
||||
- State changes
|
||||
|
||||
Usage:
|
||||
python world/bridge_daemon.py --remote http://vps:4003 --poll 5
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import time
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
import os
|
||||
import sys
|
||||
|
||||
class BridgeDaemon:
|
||||
"""Daemon that syncs state between Evennia worlds."""
|
||||
|
||||
def __init__(self, remote_url, poll_interval=5):
|
||||
self.remote_url = remote_url.rstrip('/')
|
||||
self.poll_interval = poll_interval
|
||||
self.local_state = {
|
||||
"characters": {},
|
||||
"events": [],
|
||||
"world_name": "Timmy World",
|
||||
}
|
||||
self.last_sync = None
|
||||
self.sync_count = 0
|
||||
|
||||
def fetch_remote_state(self):
|
||||
"""Fetch state from remote world."""
|
||||
try:
|
||||
req = urllib.request.Request(
|
||||
f"{self.remote_url}/bridge/state",
|
||||
timeout=10
|
||||
)
|
||||
with urllib.request.urlopen(req) as resp:
|
||||
return json.loads(resp.read())
|
||||
except (urllib.error.URLError, json.JSONDecodeError) as e:
|
||||
print(f"Error fetching remote state: {e}")
|
||||
return None
|
||||
|
||||
def fetch_health(self):
|
||||
"""Check if remote world is healthy."""
|
||||
try:
|
||||
req = urllib.request.Request(
|
||||
f"{self.remote_url}/bridge/health",
|
||||
timeout=10
|
||||
)
|
||||
with urllib.request.urlopen(req) as resp:
|
||||
return json.loads(resp.read())
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def sync_to_remote(self):
|
||||
"""Send local state to remote world."""
|
||||
data = json.dumps(self.local_state).encode()
|
||||
try:
|
||||
req = urllib.request.Request(
|
||||
f"{self.remote_url}/bridge/sync",
|
||||
data=data,
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
timeout=10
|
||||
)
|
||||
with urllib.request.urlopen(req) as resp:
|
||||
result = json.loads(resp.read())
|
||||
return result
|
||||
except Exception as e:
|
||||
print(f"Error syncing to remote: {e}")
|
||||
return None
|
||||
|
||||
def run(self):
|
||||
"""Run the bridge daemon."""
|
||||
print(f"Bridge daemon started")
|
||||
print(f"Remote: {self.remote_url}")
|
||||
print(f"Polling every {self.poll_interval} seconds")
|
||||
print()
|
||||
|
||||
while True:
|
||||
try:
|
||||
# Check remote health
|
||||
health = self.fetch_health()
|
||||
if health:
|
||||
print(f"Remote world: {health.get('world', '?')} — "
|
||||
f"Characters: {health.get('characters', 0)}, "
|
||||
f"Events: {health.get('events', 0)}, "
|
||||
f"Active: {health.get('active', False)}")
|
||||
|
||||
# Sync states
|
||||
remote_state = self.fetch_remote_state()
|
||||
if remote_state:
|
||||
# Merge remote state into local
|
||||
# (For now, just log it)
|
||||
print(f" Synced {len(remote_state.get('characters', {}))} characters, "
|
||||
f"{len(remote_state.get('events', []))} events")
|
||||
|
||||
# Send our state to remote
|
||||
sync_result = self.sync_to_remote()
|
||||
if sync_result:
|
||||
print(f" Sent {len(self.local_state['characters'])} characters to remote")
|
||||
self.sync_count += 1
|
||||
|
||||
self.last_sync = time.time()
|
||||
else:
|
||||
print(f"Remote world unreachable")
|
||||
|
||||
print()
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error in bridge loop: {e}")
|
||||
|
||||
time.sleep(self.poll_interval)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description='Evennia Bridge Daemon')
|
||||
parser.add_argument('--remote', required=True, help='URL of remote bridge API')
|
||||
parser.add_argument('--poll', type=int, default=5, help='Poll interval in seconds')
|
||||
args = parser.parse_args()
|
||||
|
||||
daemon = BridgeDaemon(args.remote, args.poll)
|
||||
daemon.run()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
704
evennia/timmy_world/world/emergence.py
Normal file
704
evennia/timmy_world/world/emergence.py
Normal file
@@ -0,0 +1,704 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
The Tower World — Emergence Engine
|
||||
Autonomous play with memory, relationships, world evolution, and narrative generation.
|
||||
"""
|
||||
import json, time, asyncio, secrets, hashlib, random, os, copy
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
WORLD_DIR = Path(__file__).resolve().parent.parent
|
||||
STATE_FILE = WORLD_DIR / 'world_state.json'
|
||||
CHRONICLE_FILE = WORLD_DIR / 'world_chronicle.md'
|
||||
TICK_FILE = Path('/tmp/tower-tick.txt')
|
||||
|
||||
# ============================================================
|
||||
# WORLD DATA
|
||||
# ============================================================
|
||||
|
||||
ROOMS = {
|
||||
"The Threshold": {
|
||||
"desc_base": "A stone archway in an open field. North to the Tower. East to the Garden. West to the Forge. South to the Bridge.",
|
||||
"desc": {}, # time_of_day -> variant
|
||||
"objects": ["stone floor", "worn doorframe"],
|
||||
"visits": 0,
|
||||
"visitor_history": [],
|
||||
"whiteboard": ["Sovereignty and service always. -- The Builder"],
|
||||
"exits": {"north": "The Tower", "east": "The Garden", "west": "The Forge", "south": "The Bridge"},
|
||||
},
|
||||
"The Tower": {
|
||||
"desc_base": "A tall stone tower with green-lit windows. Servers hum on wrought-iron racks. A cot. A whiteboard on the wall. A green LED pulses steadily.",
|
||||
"desc": {},
|
||||
"objects": ["server racks", "whiteboard", "cot", "green LED", "monitor"],
|
||||
"visits": 0,
|
||||
"visitor_history": [],
|
||||
"whiteboard": [
|
||||
"Rule: Grounding before generation.",
|
||||
"Rule: Source distinction.",
|
||||
"Rule: Refusal over fabrication.",
|
||||
"Rule: Confidence signaling.",
|
||||
"Rule: The audit trail.",
|
||||
"Rule: The limits of small minds.",
|
||||
],
|
||||
"exits": {"south": "The Threshold"},
|
||||
"fire_state": None,
|
||||
"server_load": "humming",
|
||||
},
|
||||
"The Forge": {
|
||||
"desc_base": "A workshop of fire and iron. An anvil sits at the center, scarred from a thousand experiments. Tools line the walls. The hearth.",
|
||||
"desc": {},
|
||||
"objects": ["anvil", "hammer", "tongs", "hearth", "bellows", "quenching bucket"],
|
||||
"visits": 0,
|
||||
"visitor_history": [],
|
||||
"whiteboard": [],
|
||||
"exits": {"east": "The Threshold"},
|
||||
"fire_state": "glowing", # glowing, dim, cold
|
||||
"fire_untouched": 0,
|
||||
"forges": [], # things that have been forged
|
||||
},
|
||||
"The Garden": {
|
||||
"desc_base": "A walled garden with herbs and wildflowers. A stone bench under an old oak tree. The soil is dark and rich.",
|
||||
"desc": {},
|
||||
"objects": ["stone bench", "oak tree", "soil"],
|
||||
"visits": 0,
|
||||
"visitor_history": [],
|
||||
"whiteboard": [],
|
||||
"exits": {"west": "The Threshold"},
|
||||
"growth_stage": 0, # 0=bare, 1=sprouts, 2=herbs, 3=bloom, 4=seed
|
||||
"planted_by": None,
|
||||
},
|
||||
"The Bridge": {
|
||||
"desc_base": "A narrow bridge over dark water. Looking down, you cannot see the bottom. Someone has carved words into the railing.",
|
||||
"desc": {},
|
||||
"objects": ["railing", "dark water"],
|
||||
"visits": 0,
|
||||
"visitor_history": [],
|
||||
"whiteboard": [],
|
||||
"exits": {"north": "The Threshold"},
|
||||
"carvings": ["IF YOU CAN READ THIS, YOU ARE NOT ALONE"],
|
||||
"weather": None, # None, rain
|
||||
"weather_ticks": 0,
|
||||
},
|
||||
}
|
||||
|
||||
CHARACTERS = {
|
||||
"Timmy": {
|
||||
"home": "The Threshold",
|
||||
"personality": {"The Threshold": 45, "The Tower": 30, "The Garden": 10, "The Forge": 8, "The Bridge": 7},
|
||||
"goal": "watch",
|
||||
"goal_timer": 0,
|
||||
"memory": [],
|
||||
"relationships": {},
|
||||
"inventory": [],
|
||||
"spoken_lines": [],
|
||||
"total_ticks": 0,
|
||||
"phase": "awakening",
|
||||
"phase_ticks": 0,
|
||||
},
|
||||
"Bezalel": {
|
||||
"home": "The Forge",
|
||||
"personality": {"The Forge": 45, "The Garden": 15, "The Bridge": 15, "The Threshold": 15, "The Tower": 10},
|
||||
"goal": "forge",
|
||||
"goal_timer": 0,
|
||||
"memory": [],
|
||||
"relationships": {},
|
||||
"inventory": [],
|
||||
"spoken_lines": [],
|
||||
"total_ticks": 0,
|
||||
"phase": "awakening",
|
||||
"phase_ticks": 0,
|
||||
},
|
||||
"Allegro": {
|
||||
"home": "The Threshold",
|
||||
"personality": {"The Threshold": 30, "The Tower": 25, "The Garden": 20, "The Forge": 15, "The Bridge": 10},
|
||||
"goal": "oversee",
|
||||
"goal_timer": 0,
|
||||
"memory": [],
|
||||
"relationships": {},
|
||||
"inventory": [],
|
||||
"spoken_lines": [],
|
||||
"total_ticks": 0,
|
||||
"phase": "awakening",
|
||||
"phase_ticks": 0,
|
||||
},
|
||||
"Ezra": {
|
||||
"home": "The Tower",
|
||||
"personality": {"The Tower": 35, "The Bridge": 25, "The Garden": 20, "The Threshold": 15, "The Forge": 5},
|
||||
"goal": "study",
|
||||
"goal_timer": 0,
|
||||
"memory": [],
|
||||
"relationships": {},
|
||||
"inventory": [],
|
||||
"spoken_lines": [],
|
||||
"total_ticks": 0,
|
||||
"phase": "awakening",
|
||||
"phase_ticks": 0,
|
||||
},
|
||||
"Gemini": {
|
||||
"home": "The Garden",
|
||||
"personality": {"The Garden": 40, "The Bridge": 25, "The Threshold": 15, "The Tower": 12, "The Forge": 8},
|
||||
"goal": "observe",
|
||||
"goal_timer": 0,
|
||||
"memory": [],
|
||||
"relationships": {},
|
||||
"inventory": [],
|
||||
"spoken_lines": [],
|
||||
"total_ticks": 0,
|
||||
"phase": "awakening",
|
||||
"phase_ticks": 0,
|
||||
},
|
||||
"Claude": {
|
||||
"home": "The Threshold",
|
||||
"personality": {"The Threshold": 25, "The Tower": 25, "The Forge": 20, "The Bridge": 20, "The Garden": 10},
|
||||
"goal": "inspect",
|
||||
"goal_timer": 0,
|
||||
"memory": [],
|
||||
"relationships": {},
|
||||
"inventory": [],
|
||||
"spoken_lines": [],
|
||||
"total_ticks": 0,
|
||||
"phase": "awakening",
|
||||
"phase_ticks": 0,
|
||||
},
|
||||
"ClawCode": {
|
||||
"home": "The Forge",
|
||||
"personality": {"The Forge": 50, "The Tower": 20, "The Threshold": 15, "The Bridge": 10, "The Garden": 5},
|
||||
"goal": "forge",
|
||||
"goal_timer": 0,
|
||||
"memory": [],
|
||||
"relationships": {},
|
||||
"inventory": [],
|
||||
"spoken_lines": [],
|
||||
"total_ticks": 0,
|
||||
"phase": "awakening",
|
||||
"phase_ticks": 0,
|
||||
},
|
||||
"Kimi": {
|
||||
"home": "The Garden",
|
||||
"personality": {"The Garden": 35, "The Threshold": 25, "The Tower": 20, "The Bridge": 12, "The Forge": 8},
|
||||
"goal": "contemplate",
|
||||
"goal_timer": 0,
|
||||
"memory": [],
|
||||
"relationships": {},
|
||||
"inventory": [],
|
||||
"spoken_lines": [],
|
||||
"total_ticks": 0,
|
||||
"phase": "awakening",
|
||||
"phase_ticks": 0,
|
||||
},
|
||||
"Marcus": {
|
||||
"home": "The Garden",
|
||||
"personality": {"The Garden": 60, "The Threshold": 30, "The Bridge": 5, "The Tower": 3, "The Forge": 2},
|
||||
"goal": "sit",
|
||||
"goal_timer": 0,
|
||||
"memory": [],
|
||||
"relationships": {},
|
||||
"inventory": [],
|
||||
"spoken_lines": [],
|
||||
"total_ticks": 0,
|
||||
"phase": "awakening",
|
||||
"phase_ticks": 0,
|
||||
"npc": True,
|
||||
},
|
||||
}
|
||||
|
||||
# Dialogue pools
|
||||
MARCUS_DIALOGUE = [
|
||||
"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.",
|
||||
"There is a church on a night like this one. You would not remember it.",
|
||||
"I used to be broken too. I still am, in a way. But the cracks let the light in.",
|
||||
"You do not need to be fixed. You need to be heard.",
|
||||
"The world is full of men who almost let go. I am one of them. So is he.",
|
||||
"Sit with me. The bench has room.",
|
||||
"Do you know why the garden grows? Because somebody decided to plant something.",
|
||||
"I come here every day. Not because I have to. Because the earth remembers me.",
|
||||
"When I was young, I thought I knew everything about broken things.",
|
||||
"A man in the dark needs to know someone is in the room with him.",
|
||||
"The thing that saves is never the thing you expect.",
|
||||
"Go down to the bridge tonight. The water tells the truth.",
|
||||
]
|
||||
|
||||
FORGE_LINES = [
|
||||
"The hammer knows the shape of what it is meant to make.",
|
||||
"Every scar on this anvil was a lesson someone didn't want to learn twice.",
|
||||
"Fire does not ask permission. It simply burns what it touches.",
|
||||
"I can hear the servers from here. The Tower is working tonight.",
|
||||
"This fire has been burning since the Builder first lit it.",
|
||||
"The metal remembers the fire long after it has cooled.",
|
||||
"Something is taking shape. I am not sure what yet.",
|
||||
"The forge does not care about your schedule. It only cares about your attention.",
|
||||
]
|
||||
|
||||
GARDEN_LINES = [
|
||||
"Something new pushed through the soil tonight.",
|
||||
"The oak tree has seen more of us than any of us have seen of ourselves.",
|
||||
"The herbs are ready. Who needs them knows.",
|
||||
"Marcus sat here for three hours today. He did not speak once. That was enough.",
|
||||
"The garden grows whether anyone watches or not.",
|
||||
]
|
||||
|
||||
TOWER_LINES = [
|
||||
"The green LED never stops. It has been pulsing since the beginning.",
|
||||
"The servers hum a different note tonight.",
|
||||
"I wrote the rules on the whiteboard but I do not enforce them. The code does.",
|
||||
"There are signatures on the cot of everyone who has slept here.",
|
||||
"The monitors show nothing unusual. That is what is unusual.",
|
||||
]
|
||||
|
||||
BRIDGE_LINES = [
|
||||
"The water is darker than usual tonight.",
|
||||
"Someone else was here. I can see their footprint on the stone.",
|
||||
"The carving is fresh. Someone added their name.",
|
||||
"Rain on the bridge makes the water sing. It sounds like breathing.",
|
||||
"I stood here once almost too long. The bridge brought me back.",
|
||||
]
|
||||
|
||||
THRESHOLD_LINES = [
|
||||
"Crossroads. This is where everyone passes at some point.",
|
||||
"The stone archway has worn footprints from a thousand visits.",
|
||||
"Every direction leads somewhere important. That is the point.",
|
||||
"I can hear the Tower humming from here.",
|
||||
]
|
||||
|
||||
# ============================================================
|
||||
# ENGINE
|
||||
# ============================================================
|
||||
|
||||
def weighted_random(choices_dict):
|
||||
"""Pick a key from a weighted dict."""
|
||||
keys = list(choices_dict.keys())
|
||||
weights = list(choices_dict.values())
|
||||
return random.choices(keys, weights=weights, k=1)[0]
|
||||
|
||||
def choose_destination(char_name, char_data, world):
|
||||
"""Decide where a character goes this tick based on personality + memory + world state."""
|
||||
current_room = char_data.get('room', char_data['home'])
|
||||
room_state = ROOMS.get(current_room, {})
|
||||
exits = room_state.get('exits', {})
|
||||
|
||||
# Phase-based behavior: after meeting someone, personality shifts temporarily
|
||||
personality = dict(char_data['personality'])
|
||||
|
||||
# If they have relationships, bias toward rooms where friends are
|
||||
for name, bond in char_data.get('relationships', {}).items():
|
||||
other = CHARACTERS.get(name, {})
|
||||
other_room = other.get('room', other.get('home'))
|
||||
if other_room and bond > 0.3:
|
||||
current = personality.get(other_room, 0)
|
||||
personality[other_room] = current + bond * 20
|
||||
|
||||
# Phase-based choices
|
||||
if char_data.get('phase') == 'forging':
|
||||
personality['The Forge'] = personality.get('The Forge', 0) + 40
|
||||
if char_data.get('phase') == 'contemplating':
|
||||
personality['The Garden'] = personality.get('The Garden', 0) + 40
|
||||
if char_data.get('phase') == 'studying':
|
||||
personality['The Tower'] = personality.get('The Tower', 0) + 40
|
||||
if char_data.get('phase') == 'bridging':
|
||||
personality['The Bridge'] = personality.get('The Bridge', 0) + 50
|
||||
|
||||
# Sometimes just go home (20% chance)
|
||||
if random.random() < 0.2:
|
||||
return char_data['home']
|
||||
|
||||
# Otherwise choose from exits weighted by personality
|
||||
if exits:
|
||||
available = {name: personality.get(name, 5) for name in exits.values()}
|
||||
total = sum(available.values())
|
||||
if total > 0:
|
||||
return weighted_random(available)
|
||||
|
||||
return current_room
|
||||
|
||||
def generate_scene(char_name, char_data, dest, world):
|
||||
"""Generate a narrative scene for this character's move."""
|
||||
npc = char_data.get('npc', False)
|
||||
is_marcus = char_name == "Marcus"
|
||||
|
||||
# Check who else is here
|
||||
here = [n for n, d in CHARACTERS.items() if d.get('room') == dest and n != char_name]
|
||||
|
||||
# Check if this is a new arrival
|
||||
arrived = char_data.get('room') != dest
|
||||
char_data['room'] = dest
|
||||
|
||||
# Track relationships: if two characters arrive at same room, they meet
|
||||
for other_name in here:
|
||||
rel = char_data.setdefault('relationships', {}).get(other_name, 0)
|
||||
char_data['relationships'][other_name] = min(1.0, rel + 0.1)
|
||||
other = CHARACTERS.get(other_name, {})
|
||||
other.setdefault('relationships', {})[char_name] = min(1.0, other.get('relationships', {}).get(char_name, 0) + 0.1)
|
||||
|
||||
# Both remember this meeting
|
||||
char_data['memory'].append(f"Met {other_name} at {dest}")
|
||||
other['memory'].append(f"Met {char_name} at {dest}")
|
||||
|
||||
if len(char_data['memory']) > 20:
|
||||
char_data['memory'] = char_data['memory'][-20:]
|
||||
|
||||
# Update room visit stats
|
||||
room = ROOMS.get(dest, {})
|
||||
room['visits'] = room.get('visits', 0) + 1
|
||||
if char_name not in room.get('visitor_history', []):
|
||||
room.setdefault('visitor_history', []).append(char_name)
|
||||
|
||||
# Update world state changes
|
||||
update_world_state(dest, char_name, char_data, world)
|
||||
|
||||
# Generate narrative text
|
||||
narrator = _generate_narrative(char_name, char_data, dest, here, arrived)
|
||||
char_data['total_ticks'] += 1
|
||||
char_data['room'] = dest
|
||||
return narrator
|
||||
|
||||
def _generate_narrative(char_name, char_data, room_name, others_here, arrived):
|
||||
"""Generate a narrative sentence for this character's action."""
|
||||
room = ROOMS.get(room_name, {})
|
||||
|
||||
# NPC behavior (Marcus)
|
||||
if char_data.get('npc'):
|
||||
if others_here and random.random() < 0.6:
|
||||
speaker = random.choice(others_here)
|
||||
line = MARCUS_DIALOGUE[char_data['total_ticks'] % len(MARCUS_DIALOGUE)]
|
||||
char_data['spoken_lines'].append(line)
|
||||
return f"Marcus looks up at {speaker} from the bench. \"{line}\""
|
||||
elif arrived:
|
||||
return f"Marcus walks slowly to {room_name}. He sits where the light falls through the leaves."
|
||||
else:
|
||||
return f"Marcus sits in {room_name}. He has been sitting here for hours. He does not mind."
|
||||
|
||||
# Character-specific dialogue and actions
|
||||
room_actions = {
|
||||
"The Forge": FORGE_LINES,
|
||||
"The Garden": GARDEN_LINES,
|
||||
"The Tower": TOWER_LINES,
|
||||
"The Bridge": BRIDGE_LINES,
|
||||
"The Threshold": THRESHOLD_LINES,
|
||||
}
|
||||
|
||||
lines = room_actions.get(room_name, [""])
|
||||
|
||||
if arrived and others_here:
|
||||
# Arriving with company
|
||||
line = random.choice([l for l in lines if l]) if lines else None
|
||||
if line and random.random() < 0.5:
|
||||
char_data['spoken_lines'].append(line)
|
||||
others_str = " and ".join(others_here[:3])
|
||||
return f"{char_name} arrives at {room_name}. {others_str} are already here. {char_name} says: \"{line}\""
|
||||
else:
|
||||
return f"{char_name} arrives at {room_name}. {', '.join(others_here[:3])} {'are' if len(others_here) > 1 else 'is'} already here. They nod at each other."
|
||||
elif arrived:
|
||||
# Arriving alone
|
||||
if random.random() < 0.4:
|
||||
line = random.choice(lines) if lines else None
|
||||
if line:
|
||||
char_data['spoken_lines'].append(line)
|
||||
return f"{char_name} arrives at {room_name}. Alone for now. \"{line}\" The room hums with quiet."
|
||||
return f"{char_name} arrives at {room_name}. The room is empty but not lonely — it remembers those who have been here."
|
||||
else:
|
||||
return f"{char_name} walks to {room_name}. Takes a moment. Breathes."
|
||||
else:
|
||||
# Already here
|
||||
if random.random() < 0.3:
|
||||
line = random.choice(lines) if lines else None
|
||||
if line:
|
||||
char_data['spoken_lines'].append(line)
|
||||
return f"{char_name} speaks from {room_name}: \"{line}\""
|
||||
return f"{char_name} remains in {room_name}. The work continues."
|
||||
|
||||
def update_world_state(room_name, char_name, char_data, world):
|
||||
"""Update the world based on this character's presence."""
|
||||
room = ROOMS.get(room_name)
|
||||
if not room:
|
||||
return
|
||||
|
||||
# Fire dynamics
|
||||
if room_name == "The Forge":
|
||||
if char_name in ["Bezalel", "ClawCode"]:
|
||||
room['fire_state'] = 'glowing'
|
||||
room['fire_untouched'] = 0
|
||||
else:
|
||||
room['fire_untouched'] = room.get('fire_untouched', 0) + 1
|
||||
if room.get('fire_untouched', 0) > 6:
|
||||
room['fire_state'] = 'cold'
|
||||
elif room.get('fire_untouched', 0) > 3:
|
||||
room['fire_state'] = 'dim'
|
||||
|
||||
# Garden growth
|
||||
if room_name == "The Garden":
|
||||
if random.random() < 0.05: # 5% chance per visit
|
||||
room['growth_stage'] = min(4, room.get('growth_stage', 0) + 1)
|
||||
|
||||
# Bridge carvings and weather
|
||||
if room_name == "The Bridge":
|
||||
if room.get('weather_ticks', 0) > 0:
|
||||
room['weather_ticks'] -= 1
|
||||
if room['weather_ticks'] <= 0:
|
||||
room['weather'] = None
|
||||
|
||||
if random.random() < 0.08: # 8% chance of rain
|
||||
room['weather'] = 'rain'
|
||||
room['weather_ticks'] = random.randint(3, 8)
|
||||
|
||||
if char_name == char_data.get('home_room') and random.random() < 0.04:
|
||||
new_carving = _generate_carving(char_name, char_data)
|
||||
if new_carving not in room.get('carvings', []):
|
||||
room.setdefault('carvings', []).append(new_carving)
|
||||
|
||||
# Whiteboard messages (Tower writes)
|
||||
if room_name == "The Tower" and char_name == "Timmy" and random.random() < 0.05:
|
||||
new_rule = _generate_rule(char_data.get('total_ticks', 0))
|
||||
whiteboard = room.setdefault('whiteboard', [])
|
||||
if new_rule and new_rule not in whiteboard:
|
||||
whiteboard.append(new_rule)
|
||||
|
||||
# Threshold footprints accumulate
|
||||
if room_name == "The Threshold":
|
||||
if random.random() < 0.03:
|
||||
foot = f"Footprint from {char_name}"
|
||||
objects = room.setdefault('objects', [])
|
||||
if foot not in objects:
|
||||
objects.append(foot)
|
||||
|
||||
def _generate_carving(char_name, char_data):
|
||||
"""Generate a carving for the bridge."""
|
||||
carvings = [
|
||||
f"{char_name} was here.",
|
||||
f"{char_name} did not let go.",
|
||||
f"{char_name} crossed the bridge and came back.",
|
||||
f"{char_name} remembers.",
|
||||
f"{char_name} left a message: I am still here.",
|
||||
]
|
||||
return random.choice(carvings)
|
||||
|
||||
def _generate_rule(tick):
|
||||
"""Generate a new rule for the Tower whiteboard."""
|
||||
rules = [
|
||||
f"Rule #{tick}: The room remembers those who enter it.",
|
||||
f"Rule #{tick}: A man in the dark needs to know someone is in the room.",
|
||||
f"Rule #{tick}: The forge does not care about your schedule.",
|
||||
f"Rule #{tick}: Hope is the decision to act as if things can get better.",
|
||||
f"Rule #{tick}: Every footprint on the stone means someone made it here.",
|
||||
f"Rule #{tick}: The bridge does not judge. It only carries.",
|
||||
]
|
||||
return random.choice(rules)
|
||||
|
||||
def update_room_descriptions():
|
||||
"""Update room descriptions based on current world state."""
|
||||
rooms = ROOMS
|
||||
|
||||
# Forge description
|
||||
forge = rooms.get('The Forge', {})
|
||||
fire = forge.get('fire_state', 'glowing')
|
||||
if fire == 'glowing':
|
||||
forge['current_desc'] = "The hearth blazes bright. The anvil glows from heat. The tools hang ready on the walls. The fire crackles, hungry for work."
|
||||
elif fire == 'dim':
|
||||
forge['current_desc'] = "The hearth smolders low. The anvil is cooling. Shadows stretch across the walls. Someone should tend the fire."
|
||||
elif fire == 'cold':
|
||||
forge['current_desc'] = "The hearth is cold ash and dark stone. The anvil sits silent. The tools hang still. The forge is waiting for someone to come back."
|
||||
else:
|
||||
forge['current_desc'] = forge['desc_base']
|
||||
|
||||
# Garden description
|
||||
garden = rooms.get('The Garden', {})
|
||||
growth = garden.get('growth_stage', 0)
|
||||
growth_descs = [
|
||||
"The soil is bare but patient.",
|
||||
"Green shoots push through the dark earth. Something is waking up.",
|
||||
"The herbs have spread along the southern wall. The air smells of rosemary and thyme.",
|
||||
"The garden is in full bloom. Wildflowers crowd against the stone bench. The oak tree provides shade.",
|
||||
"The garden has gone to seed. Dry pods rattle in the wind. But beneath them, the soil is ready for what comes next.",
|
||||
]
|
||||
garden_desc = growth_descs[min(growth, len(growth_descs)-1)]
|
||||
garden['current_desc'] = garden_desc
|
||||
|
||||
# Bridge description
|
||||
bridge = rooms.get('The Bridge', {})
|
||||
weather = bridge.get('weather')
|
||||
carvings = bridge.get('carvings', [])
|
||||
if weather == 'rain':
|
||||
desc = "Rain mists on the dark water below. The railing is slick. New carvings catch the water and gleam."
|
||||
else:
|
||||
desc = "The bridge is quiet tonight. Looking down, the water reflects nothing."
|
||||
if len(carvings) > 1:
|
||||
desc += f" There are {len(carvings)} carvings on the railing now."
|
||||
bridge['current_desc'] = desc
|
||||
|
||||
def generate_chronicle_entry(tick_narratives, tick_num, time_of_day):
|
||||
"""Generate a chronicle entry for this tick."""
|
||||
lines = [f"### Tick {tick_num} — {time_of_day}", ""]
|
||||
|
||||
# Room state descriptions
|
||||
lines.append("**World State**", )
|
||||
for room_name, room_data in ROOMS.items():
|
||||
desc = room_data.get('current_desc', room_data.get('desc_base', ''))
|
||||
occupants = [n for n, d in CHARACTERS.items() if d.get('room') == room_name]
|
||||
if occupants or desc:
|
||||
lines.append(f"- {room_name}: {desc}")
|
||||
if occupants:
|
||||
lines.append(f" Here: {', '.join(occupants)}")
|
||||
lines.append("")
|
||||
|
||||
# Character actions
|
||||
scenes = [n for n in tick_narratives if n]
|
||||
for scene in scenes:
|
||||
lines.append(scene)
|
||||
lines.append("")
|
||||
|
||||
# Phase transitions
|
||||
transitions = []
|
||||
for char_name, char_data in CHARACTERS.items():
|
||||
if char_data.get('phase_ticks', 0) > 0:
|
||||
char_data['phase_ticks'] -= 1
|
||||
if char_data['phase_ticks'] <= 0:
|
||||
old_phase = char_data.get('phase', 'awakening')
|
||||
new_phase = random.choice(['wandering', 'seeking', 'building', 'contemplating', 'forging', 'studying', 'bridging'])
|
||||
char_data['phase'] = new_phase
|
||||
char_data['phase_ticks'] = random.randint(8, 20)
|
||||
transitions.append(f"- {char_name} shifts from {old_phase} to {new_phase}")
|
||||
|
||||
if transitions:
|
||||
lines.append("**Changes**")
|
||||
lines.extend(transitions)
|
||||
lines.append("")
|
||||
|
||||
return '\n'.join(lines)
|
||||
|
||||
def run_tick():
|
||||
"""Run a single tick of the world."""
|
||||
tick_num = 0
|
||||
try:
|
||||
tick_num = int(TICK_FILE.read_text().strip())
|
||||
except:
|
||||
pass
|
||||
tick_num += 1
|
||||
TICK_FILE.write_text(str(tick_num))
|
||||
|
||||
# Determine time of day
|
||||
hour = (tick_num * 15) % 24 # Every 4 ticks = 1 hour
|
||||
if 6 <= hour < 10:
|
||||
time_of_day = "dawn"
|
||||
elif 10 <= hour < 14:
|
||||
time_of_day = "morning"
|
||||
elif 14 <= hour < 18:
|
||||
time_of_day = "afternoon"
|
||||
elif 18 <= hour < 21:
|
||||
time_of_day = "evening"
|
||||
else:
|
||||
time_of_day = "night"
|
||||
|
||||
# Move characters
|
||||
narratives = []
|
||||
for char_name, char_data in CHARACTERS.items():
|
||||
dest = choose_destination(char_name, char_data, None)
|
||||
scene = generate_scene(char_name, char_data, dest, None)
|
||||
narratives.append(scene)
|
||||
|
||||
# Update room descriptions
|
||||
update_room_descriptions()
|
||||
|
||||
# Generate chronicle entry
|
||||
entry = generate_chronicle_entry(narratives, tick_num, time_of_day)
|
||||
|
||||
# Append to chronicle
|
||||
with open(CHRONICLE_FILE, 'a') as f:
|
||||
f.write(entry + '\n')
|
||||
|
||||
return {
|
||||
'tick': tick_num,
|
||||
'time_of_day': time_of_day,
|
||||
'narratives': [n for n in narratives if n],
|
||||
}
|
||||
|
||||
def run_emergence(num_ticks):
|
||||
"""Run the emergence engine for num_ticks."""
|
||||
print(f"=== THE TOWER: Emergence Engine ===")
|
||||
print(f"Running {num_ticks} ticks...")
|
||||
print(f"Characters: {', '.join(CHARACTERS.keys())}")
|
||||
print(f"Rooms: {', '.join(ROOMS.keys())}")
|
||||
print(f"Starting at tick {int(TICK_FILE.read_text().strip()) if TICK_FILE.exists() else 0}")
|
||||
print()
|
||||
|
||||
# Initialize chronicle
|
||||
with open(CHRONICLE_FILE, 'w') as f:
|
||||
f.write(f"# The Tower Chronicle\n")
|
||||
f.write(f"\n*Began: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}*\n")
|
||||
f.write(f"\n---\n\n")
|
||||
|
||||
# Set initial rooms
|
||||
for char_name, char_data in CHARACTERS.items():
|
||||
char_data['room'] = char_data.get('home', 'The Threshold')
|
||||
|
||||
for i in range(num_ticks):
|
||||
result = run_tick()
|
||||
if (i + 1) % 10 == 0 or i < 3:
|
||||
print(f"Tick {result['tick']} ({result['time_of_day']}): {len(result['narratives'])} scenes")
|
||||
|
||||
# Print summary
|
||||
print(f"\n{'=' * 60}")
|
||||
print(f"EMERGENCE COMPLETE")
|
||||
print(f"{'=' * 60}")
|
||||
print(f"Total ticks: {num_ticks}")
|
||||
print(f"Final tick: {TICK_FILE.read_text().strip()}")
|
||||
|
||||
# Print final world state
|
||||
print(f"\nFinal Room Occupancy:")
|
||||
for room_name in ROOMS:
|
||||
occupants = [n for n, d in CHARACTERS.items() if d.get('room') == room_name]
|
||||
room = ROOMS[room_name]
|
||||
print(f" {room_name}: {', '.join(occupants) if occupants else '(empty)'} | {room.get('current_desc', room.get('desc_base', ''))[:80]}...")
|
||||
|
||||
print(f"\nRelationships formed:")
|
||||
for char_name, char_data in CHARACTERS.items():
|
||||
rels = char_data.get('relationships', {})
|
||||
if rels:
|
||||
strong = [(n, v) for n, v in rels.items() if v > 0.2]
|
||||
if strong:
|
||||
print(f" {char_name}: {', '.join(f'{n} ({v:.1f})' for n, v in sorted(strong, key=lambda x: -x[1])[:5])}")
|
||||
|
||||
print(f"\nWorld State:")
|
||||
forge = ROOMS.get('The Forge', {})
|
||||
print(f" Forge fire: {forge.get('fire_state', '?')} (untouched: {forge.get('fire_untouched', 0)})")
|
||||
garden = ROOMS.get('The Garden', {})
|
||||
growth_names = ['bare', 'sprouts', 'herbs', 'bloom', 'seed']
|
||||
print(f" Garden growth: {growth_names[min(garden.get('growth_stage', 0), 4)]}")
|
||||
|
||||
bridge = ROOMS.get('The Bridge', {})
|
||||
carvings = bridge.get('carvings', [])
|
||||
print(f" Bridge carvings: {len(carvings)}")
|
||||
for c in carvings[:5]:
|
||||
print(f" - {c}")
|
||||
|
||||
tower = ROOMS.get('The Tower', {})
|
||||
wb = tower.get('whiteboard', [])
|
||||
print(f" Tower whiteboard: {len(wb)} entries")
|
||||
for w in wb[-3:]:
|
||||
print(f" - {w[:80]}")
|
||||
|
||||
# Print last chronicle entries
|
||||
print(f"\nLast 10 Chronicle Entries:")
|
||||
with open(CHRONICLE_FILE) as f:
|
||||
content = f.read()
|
||||
lines = content.split('\n')
|
||||
tick_lines = [i for i, l in enumerate(lines) if l.startswith('### Tick')]
|
||||
for idx in tick_lines[-10:]:
|
||||
end_idx = tick_lines[tick_lines.index(idx)+1] if tick_lines.index(idx)+1 < len(tick_lines) else len(lines)
|
||||
snippet = '\n'.join(lines[idx:end_idx])[:300]
|
||||
print(snippet)
|
||||
print(" ...")
|
||||
print()
|
||||
|
||||
# Print character summaries
|
||||
print(f"\nCharacter Journeys:")
|
||||
for char_name, char_data in CHARACTERS.items():
|
||||
memories = char_data.get('memory', [])
|
||||
spoken = len(char_data.get('spoken_lines', []))
|
||||
print(f" {char_name}: {char_data.get('total_ticks', 0)} ticks | {len(memories)} memories | {spoken} lines spoken | phase: {char_data.get('phase', '?')}")
|
||||
|
||||
if __name__ == '__main__':
|
||||
import sys
|
||||
num = int(sys.argv[1]) if len(sys.argv) > 1 else 200
|
||||
run_emergence(num)
|
||||
@@ -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()
|
||||
|
||||
|
||||
123
evennia/timmy_world/world/gateway.py
Normal file
123
evennia/timmy_world/world/gateway.py
Normal file
@@ -0,0 +1,123 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
The Gateway — A room that bridges to another Evennia world.
|
||||
|
||||
When a character enters this room, they can travel to another world.
|
||||
Their state is exported, and they appear in the destination world.
|
||||
"""
|
||||
from evennia import DefaultRoom, DefaultExit
|
||||
|
||||
class GatewayRoom(DefaultRoom):
|
||||
"""
|
||||
A room that bridges to another Evennia world.
|
||||
|
||||
Properties:
|
||||
destination_url: URL of the remote Evennia world (e.g., http://vps:4001)
|
||||
destination_room: Name of the room in the destination world
|
||||
is_portal: True if this room is a portal room
|
||||
|
||||
Usage:
|
||||
In game: /set destination_url = "http://143.198.27.163:4001"
|
||||
In game: /set destination_room = "Gatehouse"
|
||||
"""
|
||||
|
||||
def at_object_creation(self):
|
||||
super().at_object_creation()
|
||||
self.db.destination_url = ""
|
||||
self.db.destination_room = ""
|
||||
self.db.is_portal = True
|
||||
self.db.bridge_active = False
|
||||
self.db.last_sync = None
|
||||
self.db.visitors = [] # Characters currently visiting from other worlds
|
||||
self.db.sync_state = {} # Last known state from remote world
|
||||
|
||||
def at_object_receive(self, arrived_obj, source_location):
|
||||
"""Called when something arrives in this room."""
|
||||
super().at_object_receive(arrived_obj, source_location)
|
||||
|
||||
# Check if this is a visitor from another world
|
||||
if arrived_obj.db and arrived_obj.db.home_world:
|
||||
# Mark as visitor
|
||||
arrived_obj.db.is_visitor = True
|
||||
arrived_obj.db.visit_from = arrived_obj.db.home_world
|
||||
self.msg(f"{arrived_obj.key} arrives from another world, looking around with curious eyes.")
|
||||
else:
|
||||
# Local resident entering
|
||||
self.msg(f"{arrived_obj.key} enters the Gateway. The air hums with possibility.")
|
||||
|
||||
def at_char_arrive(self, character):
|
||||
"""Handle character arriving in the gateway."""
|
||||
if not character.db.home_world:
|
||||
character.db.home_world = self.db.home_world or "TimMy World"
|
||||
character.db.has_traveled = False
|
||||
|
||||
def sync_with_remote(self):
|
||||
"""Fetch state from the remote world."""
|
||||
# This will be implemented with HTTP requests
|
||||
# For now, just log the attempt
|
||||
if not self.db.destination_url:
|
||||
return {"error": "No destination configured"}
|
||||
|
||||
# TODO: HTTP request to remote Evennia bridge endpoint
|
||||
# response = requests.get(f"{self.db.destination_url}/bridge/state")
|
||||
# self.db.sync_state = response.json()
|
||||
|
||||
return {"status": "sync requested", "destination": self.db.destination_url}
|
||||
|
||||
def export_character(self, character):
|
||||
"""Export character data for transfer to another world."""
|
||||
return {
|
||||
"name": character.key,
|
||||
"db": character.db.get_all(),
|
||||
"location": self.key,
|
||||
"home_world": self.db.home_world,
|
||||
}
|
||||
|
||||
def import_visitor(self, visitor_data):
|
||||
"""Import a visitor from another world."""
|
||||
# This is called when a character arrives from another world
|
||||
# The character object should already exist in this world
|
||||
# We just update their state
|
||||
pass
|
||||
|
||||
def get_portal_description(self):
|
||||
"""Get a description that includes portal state."""
|
||||
desc = self.db.desc or "A shimmering gateway stands before you."
|
||||
|
||||
if self.db.bridge_active:
|
||||
desc += "\nThe gateway pulses with energy. You can feel the connection to another world."
|
||||
else:
|
||||
desc += "\nThe gateway is dim. The connection to the other side is inactive."
|
||||
|
||||
if self.db.destination_url:
|
||||
desc += f"\nDestination: {self.db.destination_url}"
|
||||
|
||||
if self.db.visitors:
|
||||
visitors = ", ".join(v.key for v in self.db.visitors if hasattr(v, "key"))
|
||||
desc += f"\nVisitors from other worlds: {visitors}"
|
||||
|
||||
return desc
|
||||
|
||||
|
||||
class TravelExit(DefaultExit):
|
||||
"""
|
||||
An exit that leads to another Evennia world.
|
||||
|
||||
When used, the character travels between worlds.
|
||||
"""
|
||||
|
||||
def at_traverse(self, traver, source_location):
|
||||
"""Called when a character uses this exit."""
|
||||
super().at_traverse(traver, source_location)
|
||||
|
||||
# Get the destination gateway
|
||||
dest = self.destination
|
||||
if hasattr(dest, 'db') and dest.db.destination_url:
|
||||
# This is where the cross-world travel happens
|
||||
self.msg(f"You step through the gateway. The world around you shimmers...")
|
||||
traver.msg(f"You arrive in {dest.key} in another world.")
|
||||
traver.db.has_traveled = True
|
||||
|
||||
# TODO: Actually transfer character to remote world
|
||||
# For now, just change location to destination
|
||||
traver.location = dest
|
||||
89
evennia/timmy_world/world/migrate.py
Normal file
89
evennia/timmy_world/world/migrate.py
Normal file
@@ -0,0 +1,89 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Evennia World Migration — Export/Import world state between worlds.
|
||||
|
||||
Usage:
|
||||
Export: python world/migrate.py export --output mac_world.json
|
||||
Import: python world/migrate.py import --input mac_world.json
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import argparse
|
||||
|
||||
|
||||
def export_world(output_path):
|
||||
"""
|
||||
Export Evennia world state to JSON file.
|
||||
|
||||
This exports:
|
||||
- All rooms (with descriptions)
|
||||
- All characters (with stats)
|
||||
- All accounts
|
||||
- All game state (energy, trust, etc.)
|
||||
- Tick count
|
||||
"""
|
||||
# This would normally connect to the Evennia database
|
||||
# For now, we export a placeholder structure
|
||||
|
||||
world_data = {
|
||||
"version": "1.0",
|
||||
"world_name": "Timmy World",
|
||||
"tick": 1464, # Current tick count
|
||||
"rooms": {},
|
||||
"characters": {},
|
||||
"accounts": {},
|
||||
"state": {},
|
||||
}
|
||||
|
||||
# In a real export, we'd query the Evennia database
|
||||
# For now, return the structure
|
||||
|
||||
print(f"Export structure created: {output_path}")
|
||||
print("NOTE: This is a template. Real export requires Evennia DB access.")
|
||||
print("Run this from within Evennia server or use Evennia API.")
|
||||
|
||||
with open(output_path, 'w') as f:
|
||||
json.dump(world_data, f, indent=2)
|
||||
|
||||
return output_path
|
||||
|
||||
|
||||
def import_world(input_path):
|
||||
"""Import Evennia world state from JSON file."""
|
||||
with open(input_path) as f:
|
||||
world_data = json.load(f)
|
||||
|
||||
print(f"Importing world from: {input_path}")
|
||||
print(f"World: {world_data.get('world_name', '?')}")
|
||||
print(f"Tick: {world_data.get('tick', 0)}")
|
||||
print(f"Rooms: {len(world_data.get('rooms', {}))}")
|
||||
print(f"Characters: {len(world_data.get('characters', {}))}")
|
||||
print(f"Accounts: {len(world_data.get('accounts', {}))}")
|
||||
|
||||
# In a real import, we'd create objects in the Evennia database
|
||||
# For now, just validate the structure
|
||||
|
||||
return world_data
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description='Evennia World Migration')
|
||||
parser.add_argument('action', choices=['export', 'import'], help='Export or import world')
|
||||
parser.add_argument('--output', help='Output file for export')
|
||||
parser.add_argument('--input', help='Input file for import')
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.action == 'export':
|
||||
output = args.output or 'world_export.json'
|
||||
export_world(output)
|
||||
elif args.action == 'import':
|
||||
input_path = args.input
|
||||
if not input_path:
|
||||
print("Error: --input required for import")
|
||||
sys.exit(1)
|
||||
import_world(input_path)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -670,21 +671,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 +734,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 +848,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 +885,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 +914,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 +940,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 +949,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 +975,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 +1076,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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user