Compare commits

..

1 Commits

Author SHA1 Message Date
Alexander Payne
5f828110f6 Add Evennia bridge system for hub-and-spoke world federation
Some checks failed
Self-Healing Smoke / self-healing-smoke (pull_request) Failing after 22s
Smoke Test / smoke (pull_request) Failing after 16s
Agent PR Gate / gate (pull_request) Failing after 35s
Agent PR Gate / report (pull_request) Successful in 7s
Implements the core bridge infrastructure to connect multiple Evennia
worlds (Mac timmy_world, VPS Wizard's Canon, Allegro VPS) into a
federated hub-and-spoke constellation.

This commit adds:

- world/gateway.py: GatewayRoom typeclass (portal room) and TravelExit
- world/bridge_api.py: HTTP API endpoints for cross-world state sync
- world/bridge_daemon.py: Background daemon that polls remote worlds and syncs
- world/migrate.py: Export/import tool for world state migration
- world/emergence.py: World state engine (ROOMS, CHARACTERS) used by bridge
- world/bridge/README.md: Bridge documentation and usage guide

These components enable:

* Character presence synchronization (who is where)
* Cross-world messaging
* World event propagation
* State snapshots and migration
* Hub-and-spoke federation (VPS public square + Mac narrative heart)

The bridge system is currently dormant; it requires:
- Starting bridge_api on port 4003 on each world
- Creating GatewayRoom instances and configuring destination_url
- Running bridge_daemon on each house to sync

Refs: Timmy's Plan comment on issue #522
Closes #522 (bridge infrastructure committed)
2026-04-26 02:08:01 -04:00
10 changed files with 1295 additions and 232 deletions

View File

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

View File

@@ -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

View 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()

View 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()

View 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)

View File

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

View File

@@ -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

View 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()

View File

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

View File

@@ -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()