mirror of
https://github.com/brazilofmux/tinymux
synced 2026-08-13 00:23:11 -04:00
717 lines
28 KiB
Python
717 lines
28 KiB
Python
#!/usr/bin/env python3
|
|
"""WorldBuilder Executor — connects to a MUX and applies compiled commands.
|
|
|
|
Handles:
|
|
- Telnet connection with login
|
|
- Sequential command execution with output capture
|
|
- Dbref capture via lastcreate() + sentinel-wrapped `think` queries,
|
|
which avoids parsing the version-specific free-form @dig/@open/
|
|
@create response text (see live_adapter.py's module docstring for
|
|
the server-format contract this relies on)
|
|
- State file generation (spec ID → dbref mapping)
|
|
- Dry-run mode (log commands without executing)
|
|
"""
|
|
|
|
import socket
|
|
import ssl
|
|
import time
|
|
import argparse
|
|
from pathlib import Path
|
|
from worldbuilder import parse_spec, check_drc, plan_spec_operations, plan_incremental_operations
|
|
from live_adapter import (StateFile, content_hash,
|
|
query_live_snapshot, query_last_created,
|
|
query_think, mux_escape)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Telnet Client (minimal, synchronous)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class MuxConnection:
|
|
"""Simple telnet connection to a MUX server."""
|
|
|
|
IAC = 255
|
|
DONT = 254
|
|
DO = 253
|
|
WONT = 252
|
|
WILL = 251
|
|
SB = 250
|
|
SE = 240
|
|
|
|
def __init__(self, host, port, use_ssl=False, timeout=10, verify_ssl=True):
|
|
self.host = host
|
|
self.port = int(port)
|
|
self.use_ssl = use_ssl
|
|
self.timeout = timeout
|
|
self.verify_ssl = verify_ssl
|
|
self.sock = None
|
|
self.buffer = b''
|
|
|
|
def connect(self):
|
|
raw = socket.create_connection((self.host, self.port), self.timeout)
|
|
if self.use_ssl:
|
|
ctx = ssl.create_default_context()
|
|
if not self.verify_ssl:
|
|
ctx.check_hostname = False
|
|
ctx.verify_mode = ssl.CERT_NONE
|
|
self.sock = ctx.wrap_socket(raw, server_hostname=self.host)
|
|
else:
|
|
self.sock = raw
|
|
self.sock.settimeout(self.timeout)
|
|
# Drain initial welcome text
|
|
self._read_until_quiet(2.0)
|
|
|
|
def login(self, character, password):
|
|
self.send(f'connect {character} {password}')
|
|
time.sleep(1.0)
|
|
return self._read_until_quiet(2.0)
|
|
|
|
def send(self, text):
|
|
data = (text + '\r\n').encode('utf-8')
|
|
self.sock.sendall(data)
|
|
|
|
def read_response(self, wait=0.5):
|
|
"""Read all available data, waiting up to `wait` seconds for quiet."""
|
|
return self._read_until_quiet(wait)
|
|
|
|
def _read_until_quiet(self, quiet_seconds=0.5):
|
|
"""Read until no data arrives for quiet_seconds."""
|
|
result = b''
|
|
self.sock.settimeout(quiet_seconds)
|
|
while True:
|
|
try:
|
|
chunk = self.sock.recv(4096)
|
|
if not chunk:
|
|
break
|
|
result += chunk
|
|
except socket.timeout:
|
|
break
|
|
self.sock.settimeout(self.timeout)
|
|
# Strip telnet IAC sequences
|
|
return self._strip_telnet(result).decode('utf-8', errors='replace')
|
|
|
|
def _strip_telnet(self, data):
|
|
"""Remove telnet IAC sequences from raw data."""
|
|
out = bytearray()
|
|
i = 0
|
|
while i < len(data):
|
|
if data[i] == self.IAC and i + 1 < len(data):
|
|
cmd = data[i + 1]
|
|
if cmd in (self.WILL, self.WONT, self.DO, self.DONT):
|
|
# Respond DONT/WONT to everything
|
|
opt = data[i + 2] if i + 2 < len(data) else 0
|
|
if cmd == self.WILL:
|
|
self.sock.sendall(bytes([self.IAC, self.DONT, opt]))
|
|
elif cmd == self.DO:
|
|
self.sock.sendall(bytes([self.IAC, self.WONT, opt]))
|
|
i += 3
|
|
elif cmd == self.SB:
|
|
# Skip until IAC SE
|
|
j = i + 2
|
|
while j < len(data) - 1:
|
|
if data[j] == self.IAC and data[j + 1] == self.SE:
|
|
j += 2
|
|
break
|
|
j += 1
|
|
i = j
|
|
elif cmd == self.IAC:
|
|
out.append(self.IAC)
|
|
i += 2
|
|
else:
|
|
i += 2 # skip 2-byte command
|
|
else:
|
|
out.append(data[i])
|
|
i += 1
|
|
return bytes(out)
|
|
|
|
def disconnect(self):
|
|
if self.sock:
|
|
try:
|
|
self.send('QUIT')
|
|
except Exception:
|
|
pass
|
|
try:
|
|
self.sock.close()
|
|
except Exception:
|
|
pass
|
|
self.sock = None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Executor
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def execute(spec, conn, state, dry_run=False, log_file=None, operations=None):
|
|
"""Execute semantic operations against a live MUX connection.
|
|
|
|
If operations is None, plans them from the spec and state.
|
|
Returns True on success.
|
|
"""
|
|
def log(msg):
|
|
print(msg)
|
|
if log_file:
|
|
log_file.write(msg + '\n')
|
|
|
|
def escape_attr_value(value):
|
|
return mux_escape(str(value))
|
|
|
|
def do_cmd(cmd):
|
|
"""Send a command and return response. In dry-run, just log."""
|
|
if dry_run:
|
|
log(f' [dry-run] {cmd}')
|
|
return ''
|
|
log(f' > {cmd}')
|
|
conn.send(cmd)
|
|
time.sleep(0.3)
|
|
resp = conn.read_response(0.5)
|
|
if resp.strip():
|
|
for line in resp.strip().split('\n'):
|
|
log(f' < {line.rstrip()}')
|
|
return resp
|
|
|
|
def capture_objid(dbref):
|
|
"""Query objid for a dbref. Returns stripped string or None."""
|
|
if dry_run:
|
|
return None
|
|
resp = do_cmd(f'think [objid({dbref})]')
|
|
return resp.strip() if resp else None
|
|
|
|
state.zone = spec.zone.name
|
|
|
|
# Plan operations if not provided
|
|
if operations is None:
|
|
state_path = state.path
|
|
if state_path.exists() and state.objects:
|
|
operations = plan_incremental_operations(spec, str(state_path))
|
|
else:
|
|
operations = plan_spec_operations(spec)
|
|
|
|
# Separate into phases: rooms first, then exits/things, then destroys
|
|
room_creates = [op for op in operations if op.kind == 'create_room']
|
|
room_updates = [op for op in operations if op.kind == 'update_room']
|
|
exit_creates = [op for op in operations if op.kind == 'create_exit']
|
|
thing_creates = [op for op in operations if op.kind == 'create_thing']
|
|
destroy_exits = [op for op in operations if op.kind == 'destroy_exit']
|
|
destroy_rooms = [op for op in operations if op.kind == 'destroy_room']
|
|
|
|
# Phase 1: Create rooms and capture dbrefs
|
|
if room_creates or room_updates:
|
|
log(f'\n=== Phase 1: Rooms ({len(room_creates)} create, {len(room_updates)} update) ===')
|
|
|
|
for op in room_creates:
|
|
room = op.payload['room']
|
|
log(f'\n--- Room: {room.name} ({op.obj_id}) — creating ---')
|
|
do_cmd(f'@dig/teleport {room.name}')
|
|
|
|
if not dry_run:
|
|
time.sleep(0.2)
|
|
# Prefer lastcreate(me,R) — deterministic, version-stable.
|
|
# Fall back to %L (current location, set by @dig/teleport)
|
|
# if lastcreate comes back empty for any reason.
|
|
dbref = query_last_created(conn, 'R')
|
|
if not dbref:
|
|
dbref = query_think(conn, '%L')
|
|
if dbref and not dbref.startswith('#'):
|
|
dbref = None
|
|
if dbref:
|
|
objid = capture_objid(dbref)
|
|
state.set_room(op.obj_id, dbref, room, objid=objid)
|
|
log(f' [state] {op.obj_id} = {dbref} (objid: {objid})')
|
|
else:
|
|
log(f' [WARNING] Could not capture dbref for {op.obj_id}')
|
|
else:
|
|
state.objects[op.obj_id] = {
|
|
'dbref': f'#DRY_{op.obj_id}',
|
|
'type': 'room',
|
|
'name': room.name,
|
|
'description': room.description,
|
|
'flags': sorted(room.flags),
|
|
'attrs': room.attrs,
|
|
'content_hash': content_hash(room),
|
|
}
|
|
|
|
do_cmd(f'@desc here={mux_escape(room.description)}')
|
|
for flag in room.flags:
|
|
do_cmd(f'@set here={flag}')
|
|
for attr_name, attr_value in room.attrs.items():
|
|
do_cmd(f'&{attr_name} here={escape_attr_value(attr_value)}')
|
|
if room.parent:
|
|
do_cmd(f'@parent here={room.parent}')
|
|
|
|
for op in room_updates:
|
|
room = op.payload['room']
|
|
dbref = op.payload['dbref']
|
|
log(f'\n--- Room: {room.name} ({op.obj_id}) — updating at {dbref} ---')
|
|
do_cmd(f'@teleport me={dbref}')
|
|
time.sleep(0.2)
|
|
|
|
if op.payload.get('name_changed'):
|
|
do_cmd(f'@name here={room.name}')
|
|
if op.payload.get('desc_changed'):
|
|
do_cmd(f'@desc here={mux_escape(room.description)}')
|
|
for flag in sorted(op.payload.get('flags_added', set())):
|
|
do_cmd(f'@set here={flag}')
|
|
for flag in sorted(op.payload.get('flags_removed', set())):
|
|
do_cmd(f'@set here=!{flag}')
|
|
for attr_name in sorted(op.payload.get('attrs_to_set', {})):
|
|
do_cmd(f'&{attr_name} here={escape_attr_value(op.payload["attrs_to_set"][attr_name])}')
|
|
for attr_name in sorted(op.payload.get('attrs_to_unset', set())):
|
|
do_cmd(f'&{attr_name} here=')
|
|
|
|
if not dry_run:
|
|
objid = capture_objid(dbref)
|
|
state.set_room(op.obj_id, dbref, room, objid=objid)
|
|
|
|
# Phase 2: Create exits
|
|
if exit_creates:
|
|
log(f'\n=== Phase 2: Exits ({len(exit_creates)} create) ===')
|
|
|
|
for op in exit_creates:
|
|
ex = op.payload['exit']
|
|
direction = op.payload['direction']
|
|
|
|
if direction == 'forward':
|
|
from_dbref = state.get_dbref(ex.from_room)
|
|
to_dbref = state.get_dbref(ex.to_room)
|
|
if not from_dbref:
|
|
log(f' [ERROR] No dbref for source room: {ex.from_room}')
|
|
continue
|
|
if not to_dbref:
|
|
if ex.to_room.startswith('$'):
|
|
log(f' [SKIP] External reference: {ex.to_room}')
|
|
continue
|
|
log(f' [ERROR] No dbref for destination room: {ex.to_room}')
|
|
continue
|
|
|
|
do_cmd(f'@teleport me={from_dbref}')
|
|
time.sleep(0.2)
|
|
log(f'\n--- Exit: {op.name} ({ex.from_room} -> {ex.to_room}) ---')
|
|
do_cmd(f'@open {ex.name}={to_dbref}')
|
|
|
|
if not dry_run:
|
|
dbref = query_last_created(conn, 'E')
|
|
if dbref:
|
|
objid = capture_objid(dbref)
|
|
state.set_exit(op.obj_id, dbref, op.name, objid=objid)
|
|
else:
|
|
log(f' [WARNING] Could not capture dbref for exit {op.obj_id}')
|
|
else:
|
|
# back exit
|
|
from_dbref = state.get_dbref(ex.to_room)
|
|
to_dbref = state.get_dbref(ex.from_room)
|
|
if not from_dbref or not to_dbref:
|
|
log(f' [ERROR] Missing dbref for back exit: {ex.to_room} -> {ex.from_room}')
|
|
continue
|
|
|
|
do_cmd(f'@teleport me={from_dbref}')
|
|
time.sleep(0.2)
|
|
log(f'\n--- Back exit: {op.name} ({ex.to_room} -> {ex.from_room}) ---')
|
|
do_cmd(f'@open {ex.back_name}={to_dbref}')
|
|
|
|
if not dry_run:
|
|
dbref = query_last_created(conn, 'E')
|
|
if dbref:
|
|
objid = capture_objid(dbref)
|
|
state.set_exit(op.obj_id, dbref, op.name, objid=objid)
|
|
else:
|
|
log(f' [WARNING] Could not capture dbref for back exit {op.obj_id}')
|
|
|
|
# Phase 3: Create things
|
|
if thing_creates:
|
|
log(f'\n=== Phase 3: Things ({len(thing_creates)} create) ===')
|
|
|
|
for op in thing_creates:
|
|
thing = op.payload['thing']
|
|
loc_dbref = state.get_dbref(thing.location)
|
|
if not loc_dbref:
|
|
log(f' [ERROR] No dbref for location: {thing.location}')
|
|
continue
|
|
|
|
do_cmd(f'@teleport me={loc_dbref}')
|
|
time.sleep(0.2)
|
|
log(f'\n--- Thing: {thing.name} ({op.obj_id}) in {thing.location} ---')
|
|
do_cmd(f'@create {thing.name}')
|
|
|
|
# Capture the dbref of the thing we just created. The
|
|
# remaining @desc/@set/& commands still target the thing by
|
|
# name because that matches MUX's normal authoring flow, but
|
|
# the dbref + objid are what the state file needs for
|
|
# subsequent reconciler passes.
|
|
thing_dbref = None
|
|
if not dry_run:
|
|
thing_dbref = query_last_created(conn, 'T')
|
|
|
|
if thing.description:
|
|
do_cmd(f'@desc {thing.name}={mux_escape(thing.description)}')
|
|
for flag in thing.flags:
|
|
do_cmd(f'@set {thing.name}={flag}')
|
|
for attr_name, attr_value in thing.attrs.items():
|
|
do_cmd(f'&{attr_name} {thing.name}={escape_attr_value(attr_value)}')
|
|
if thing.parent:
|
|
do_cmd(f'@parent {thing.name}={thing.parent}')
|
|
|
|
if thing_dbref:
|
|
objid = capture_objid(thing_dbref)
|
|
state.set_thing(op.obj_id, thing_dbref, thing, objid=objid)
|
|
log(f' [state] {op.obj_id} = {thing_dbref} (objid: {objid})')
|
|
elif not dry_run:
|
|
log(f' [WARNING] Could not capture dbref for thing {op.obj_id}')
|
|
|
|
# Phase 4: Destroy (exits before rooms)
|
|
if destroy_exits or destroy_rooms:
|
|
log(f'\n=== Phase 4: Destroy ({len(destroy_exits)} exits, {len(destroy_rooms)} rooms) ===')
|
|
|
|
for op in destroy_exits:
|
|
dbref = op.payload['dbref']
|
|
log(f'\n--- Destroy exit: {op.name} ({op.obj_id}) at {dbref} ---')
|
|
do_cmd(f'@destroy {dbref}')
|
|
state.mark_pending_destroy(op.obj_id)
|
|
|
|
for op in destroy_rooms:
|
|
dbref = op.payload['dbref']
|
|
log(f'\n--- Destroy room: {op.name} ({op.obj_id}) at {dbref} ---')
|
|
do_cmd(f'@destroy/override {dbref}')
|
|
state.mark_pending_destroy(op.obj_id)
|
|
|
|
# Save state
|
|
state.save()
|
|
log(f'\n=== Done. State saved to {state.path} ===')
|
|
return True
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# CLI
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Verify — check live game matches spec
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def verify(spec, conn, state, log_file=None):
|
|
"""Verify that a live game matches the spec. Returns list of discrepancies."""
|
|
issues = []
|
|
|
|
def log(msg):
|
|
print(msg)
|
|
if log_file:
|
|
log_file.write(msg + '\n')
|
|
|
|
def query_one(cmd):
|
|
conn.send(cmd)
|
|
time.sleep(0.3)
|
|
resp = conn.read_response(0.3)
|
|
lines = [l.strip() for l in resp.strip().split('\n') if l.strip()]
|
|
return lines[0] if lines else ''
|
|
|
|
def check_object(spec_id, obj_name, dbref, expected_desc, expected_attrs,
|
|
expected_flags=None, expected_location=None):
|
|
stored_objid = state.objects.get(spec_id, {}).get('objid')
|
|
if stored_objid:
|
|
live_objid = query_one(f'think [objid({dbref})]')
|
|
if live_objid and live_objid != stored_objid:
|
|
issues.append(f"{spec_id!r} ({dbref}): objid mismatch — stored=\"{stored_objid}\" live=\"{live_objid}\" (dbref was recycled!)")
|
|
return False
|
|
if not live_objid or live_objid.startswith('#-1'):
|
|
issues.append(f"{spec_id!r} ({dbref}): object no longer exists")
|
|
return False
|
|
|
|
live_name = query_one(f'think [name({dbref})]')
|
|
if live_name and live_name != obj_name:
|
|
issues.append(f"{spec_id!r} ({dbref}): name mismatch — spec=\"{obj_name}\" live=\"{live_name}\"")
|
|
|
|
live_desc = query_one(f'think [get({dbref}/DESCRIBE)]')
|
|
if not live_desc:
|
|
live_desc = query_one(f'think [get({dbref}/DESC)]')
|
|
if not live_desc and expected_desc.strip():
|
|
issues.append(f"{spec_id!r} ({dbref}): description missing on live server")
|
|
elif live_desc != expected_desc:
|
|
issues.append(f"{spec_id!r} ({dbref}): description mismatch")
|
|
|
|
for attr_name, expected_val in expected_attrs.items():
|
|
live_val = query_one(f'think [get({dbref}/{attr_name})]')
|
|
if live_val != expected_val:
|
|
issues.append(f"{spec_id!r} ({dbref}): attr {attr_name} mismatch — spec=\"{expected_val}\" live=\"{live_val}\"")
|
|
|
|
if expected_flags is not None:
|
|
live_flags = query_one(f'think [flags({dbref})]')
|
|
live_flag_set = set(live_flags.split()) if live_flags else set()
|
|
expected_flag_set = set(expected_flags)
|
|
if live_flag_set != expected_flag_set:
|
|
issues.append(f"{spec_id!r} ({dbref}): flags mismatch — spec=\"{' '.join(sorted(expected_flag_set))}\" live=\"{' '.join(sorted(live_flag_set))}\"")
|
|
|
|
if expected_location is not None:
|
|
live_location = query_one(f'think [loc({dbref})]')
|
|
if live_location != expected_location:
|
|
issues.append(f"{spec_id!r} ({dbref}): location mismatch — spec=\"{expected_location}\" live=\"{live_location}\"")
|
|
|
|
return True
|
|
|
|
log(f"Verifying {len(spec.rooms)} rooms...")
|
|
|
|
for room_id, room in spec.rooms.items():
|
|
dbref = state.get_dbref(room_id)
|
|
if not dbref:
|
|
issues.append(f"Room '{room_id}' ({room.name}): not in state file (never applied?)")
|
|
continue
|
|
|
|
check_object(
|
|
room_id,
|
|
room.name,
|
|
dbref,
|
|
room.description,
|
|
room.attrs,
|
|
expected_flags=room.flags,
|
|
)
|
|
|
|
log(f" [{dbref}] {room.name} — {'OK' if not any(room_id in i for i in issues) else 'MISMATCH'}")
|
|
|
|
# Check exits exist
|
|
log(f"Verifying exits...")
|
|
for ex in spec.exits:
|
|
to_dbref = state.get_dbref(ex.to_room)
|
|
forward_id = f'exit_{ex.from_room}_{ex.to_room}'
|
|
forward_state = state.objects.get(forward_id)
|
|
if not forward_state:
|
|
issues.append(f"Exit '{forward_id}' ({ex.name}): not in state file (never applied?)")
|
|
elif not to_dbref:
|
|
continue
|
|
else:
|
|
exit_dbref = forward_state.get('dbref')
|
|
live_objid = query_one(f'think [objid({exit_dbref})]')
|
|
stored_objid = forward_state.get('objid')
|
|
if stored_objid and live_objid and live_objid != stored_objid:
|
|
issues.append(f"Exit '{forward_id}' ({exit_dbref}): objid mismatch — stored=\"{stored_objid}\" live=\"{live_objid}\" (dbref was recycled!)")
|
|
elif not live_objid or live_objid.startswith('#-1'):
|
|
issues.append(f"Exit '{forward_id}' ({exit_dbref}): object no longer exists")
|
|
else:
|
|
live_name = query_one(f'think [name({exit_dbref})]')
|
|
expected_name = ex.name.split(';')[0]
|
|
if live_name and live_name != expected_name:
|
|
issues.append(f"Exit '{forward_id}' ({exit_dbref}): name mismatch — spec=\"{expected_name}\" live=\"{live_name}\"")
|
|
live_dest = query_one(f'think [home({exit_dbref})]')
|
|
if live_dest != to_dbref:
|
|
issues.append(f"Exit '{forward_id}' ({exit_dbref}): destination mismatch — spec=\"{to_dbref}\" live=\"{live_dest}\"")
|
|
|
|
if ex.back_name:
|
|
back_id = f'exit_{ex.to_room}_{ex.from_room}'
|
|
back_state = state.objects.get(back_id)
|
|
from_dbref = state.get_dbref(ex.from_room)
|
|
if not back_state:
|
|
issues.append(f"Exit '{back_id}' ({ex.back_name}): not in state file (never applied?)")
|
|
elif not from_dbref:
|
|
continue
|
|
else:
|
|
exit_dbref = back_state.get('dbref')
|
|
live_objid = query_one(f'think [objid({exit_dbref})]')
|
|
stored_objid = back_state.get('objid')
|
|
if stored_objid and live_objid and live_objid != stored_objid:
|
|
issues.append(f"Exit '{back_id}' ({exit_dbref}): objid mismatch — stored=\"{stored_objid}\" live=\"{live_objid}\" (dbref was recycled!)")
|
|
elif not live_objid or live_objid.startswith('#-1'):
|
|
issues.append(f"Exit '{back_id}' ({exit_dbref}): object no longer exists")
|
|
else:
|
|
live_name = query_one(f'think [name({exit_dbref})]')
|
|
expected_name = ex.back_name.split(';')[0]
|
|
if live_name and live_name != expected_name:
|
|
issues.append(f"Exit '{back_id}' ({exit_dbref}): name mismatch — spec=\"{expected_name}\" live=\"{live_name}\"")
|
|
live_dest = query_one(f'think [home({exit_dbref})]')
|
|
if live_dest != from_dbref:
|
|
issues.append(f"Exit '{back_id}' ({exit_dbref}): destination mismatch — spec=\"{from_dbref}\" live=\"{live_dest}\"")
|
|
|
|
log(f"Verifying things...")
|
|
for thing_id, thing in spec.things.items():
|
|
dbref = state.get_dbref(thing_id)
|
|
if not dbref:
|
|
issues.append(f"Thing '{thing_id}' ({thing.name}): not in state file (never applied?)")
|
|
continue
|
|
|
|
expected_location = state.get_dbref(thing.location)
|
|
check_object(
|
|
thing_id,
|
|
thing.name,
|
|
dbref,
|
|
thing.description,
|
|
thing.attrs,
|
|
expected_flags=thing.flags,
|
|
expected_location=expected_location,
|
|
)
|
|
|
|
if not issues:
|
|
log("\nVerification passed — all rooms match spec.")
|
|
else:
|
|
log(f"\nVerification found {len(issues)} issue(s):")
|
|
for issue in issues:
|
|
log(f" - {issue}")
|
|
|
|
return issues
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Rollback — destroy objects in reverse order
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def rollback(spec, conn, state, dry_run=False, log_file=None):
|
|
"""Destroy all objects created by a prior apply, in reverse order."""
|
|
issues = []
|
|
|
|
def log(msg):
|
|
print(msg)
|
|
if log_file:
|
|
log_file.write(msg + '\n')
|
|
|
|
def do_cmd(cmd):
|
|
if dry_run:
|
|
log(f" [dry-run] {cmd}")
|
|
return ''
|
|
log(f" > {cmd}")
|
|
conn.send(cmd)
|
|
time.sleep(0.3)
|
|
resp = conn.read_response(0.3)
|
|
if resp.strip():
|
|
for line in resp.strip().split('\n'):
|
|
log(f" < {line.rstrip()}")
|
|
return resp
|
|
|
|
# Collect all dbrefs from state, exits first then rooms
|
|
exits_to_destroy = []
|
|
rooms_to_destroy = []
|
|
skipped = []
|
|
for spec_id, obj in state.objects.items():
|
|
dbref = obj.get('dbref', '')
|
|
if not dbref or dbref.startswith('#DRY'):
|
|
continue
|
|
if obj.get('type') == 'exit':
|
|
exits_to_destroy.append((spec_id, dbref, obj.get('name', ''), obj.get('objid')))
|
|
elif obj.get('type') == 'room':
|
|
rooms_to_destroy.append((spec_id, dbref, obj.get('name', ''), obj.get('objid')))
|
|
|
|
if not exits_to_destroy and not rooms_to_destroy:
|
|
log("Nothing to rollback — state is empty.")
|
|
return
|
|
|
|
log(f"Rolling back: {len(exits_to_destroy)} exits + {len(rooms_to_destroy)} rooms")
|
|
|
|
def verify_objid(dbref, stored_objid, spec_id, name):
|
|
"""Verify objid before destroying. Returns True if safe to destroy."""
|
|
if not stored_objid:
|
|
return True # No objid stored (legacy state), proceed on dbref alone
|
|
if dry_run:
|
|
return True
|
|
live_objid = do_cmd(f'think [objid({dbref})]')
|
|
live_objid = live_objid.strip() if live_objid else ''
|
|
if live_objid and live_objid != stored_objid:
|
|
log(f" [SKIP] {name} ({dbref}): objid mismatch — stored={stored_objid} live={live_objid} (dbref recycled, not ours!)")
|
|
skipped.append(spec_id)
|
|
return False
|
|
return True
|
|
|
|
# Destroy exits first
|
|
for spec_id, dbref, name, objid in exits_to_destroy:
|
|
if not verify_objid(dbref, objid, spec_id, name):
|
|
continue
|
|
log(f"\n--- Destroy exit: {name} ({dbref}) ---")
|
|
do_cmd(f'@destroy {dbref}')
|
|
|
|
# Then rooms
|
|
for spec_id, dbref, name, objid in rooms_to_destroy:
|
|
if not verify_objid(dbref, objid, spec_id, name):
|
|
continue
|
|
log(f"\n--- Destroy room: {name} ({dbref}) ---")
|
|
do_cmd(f'@destroy/override {dbref}')
|
|
|
|
# Destruction is deferred in TinyMUX, so keep entries and mark them
|
|
# pending_destroy until a later reconcile/verify confirms removal.
|
|
if not dry_run:
|
|
for spec_id, _, _, _ in exits_to_destroy:
|
|
if spec_id not in skipped:
|
|
state.mark_pending_destroy(spec_id)
|
|
for spec_id, _, _, _ in rooms_to_destroy:
|
|
if spec_id not in skipped:
|
|
state.mark_pending_destroy(spec_id)
|
|
state.save()
|
|
log(f"\nState updated with pending_destroy markers. Saved to {state.path}")
|
|
else:
|
|
log(f"\n[dry-run] State would be updated with pending_destroy markers.")
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(
|
|
description='WorldBuilder Executor — apply specs to a live MUX')
|
|
parser.add_argument('action', nargs='?', default='apply',
|
|
choices=['apply', 'verify', 'rollback'],
|
|
help='Action (default: apply)')
|
|
parser.add_argument('spec', help='Path to YAML spec file')
|
|
parser.add_argument('--host', required=True, help='MUX hostname')
|
|
parser.add_argument('--port', default='4201', help='MUX port')
|
|
parser.add_argument('--ssl', action='store_true', help='Use SSL/TLS')
|
|
parser.add_argument('--insecure', action='store_true',
|
|
help='Disable TLS certificate and hostname verification')
|
|
parser.add_argument('--character', required=True, help='Builder character name')
|
|
parser.add_argument('--password', required=True, help='Builder password')
|
|
parser.add_argument('--state', help='State file path')
|
|
parser.add_argument('--dry-run', action='store_true', help='Log commands without executing')
|
|
parser.add_argument('--log', help='Log file path')
|
|
|
|
args = parser.parse_args()
|
|
spec_path = Path(args.spec)
|
|
|
|
# Parse and validate
|
|
spec = parse_spec(spec_path)
|
|
drc = check_drc(spec)
|
|
if not drc.ok:
|
|
print("DRC errors — cannot apply:")
|
|
for err in drc.errors:
|
|
print(f" ERROR: {err}")
|
|
return 1
|
|
|
|
# State file
|
|
state_path = args.state or f'.worldbuilder/{spec.zone.name.lower().replace(" ", "_")}.state.yaml'
|
|
state = StateFile(state_path)
|
|
|
|
# Log file
|
|
log_file = open(args.log, 'w') if args.log else None
|
|
|
|
action = args.action
|
|
|
|
try:
|
|
if args.dry_run and action == 'apply':
|
|
print(f"Dry run — no commands will be sent to {args.host}:{args.port}")
|
|
execute(spec, None, state, dry_run=True, log_file=log_file)
|
|
elif args.dry_run and action == 'rollback':
|
|
print(f"Dry run rollback — no commands will be sent")
|
|
rollback(spec, None, state, dry_run=True, log_file=log_file)
|
|
else:
|
|
# Connect
|
|
print(f"Connecting to {args.host}:{args.port}...")
|
|
conn = MuxConnection(args.host, int(args.port), args.ssl,
|
|
verify_ssl=not args.insecure)
|
|
conn.connect()
|
|
print("Connected. Logging in...")
|
|
|
|
resp = conn.login(args.character, args.password)
|
|
if 'Invalid' in resp or 'incorrect' in resp.lower():
|
|
print(f"Login failed: {resp.strip()}")
|
|
conn.disconnect()
|
|
return 1
|
|
print("Logged in.")
|
|
|
|
try:
|
|
if action == 'apply':
|
|
execute(spec, conn, state, log_file=log_file)
|
|
elif action == 'verify':
|
|
issues = verify(spec, conn, state, log_file=log_file)
|
|
return 0 if not issues else 1
|
|
elif action == 'rollback':
|
|
rollback(spec, conn, state, log_file=log_file)
|
|
finally:
|
|
conn.disconnect()
|
|
print("Disconnected.")
|
|
finally:
|
|
if log_file:
|
|
log_file.close()
|
|
|
|
return 0
|
|
|
|
|
|
if __name__ == '__main__':
|
|
exit(main())
|