--- /dev/null
+{
+ "switch_to_chat": "t",
+ "switch_to_play": "p",
+ "switch_to_annotate": "m",
+ "switch_to_portal": "P",
+ "switch_to_study": "?",
+ "switch_to_edit": "m",
+ "flatten": "F",
+ "hex_move_upleft": "w",
+ "hex_move_upright": "e",
+ "hex_move_right": "d",
+ "hex_move_downright": "x",
+ "hex_move_downleft": "y",
+ "hex_move_left": "a",
+ "square_move_up": "w",
+ "square_move_left": "a",
+ "square_move_down": "s",
+ "square_move_right": "d"
+}
--- /dev/null
+from plomrogue.misc import quote
+from plomrogue.errors import GameError
+from plomrogue.mapping import YX, MapGeometrySquare, MapGeometryHex
+
+
+
+def cmd_ALL(game, msg, connection_id):
+ if not connection_id in game.sessions:
+ raise GameError('need to be logged in for this')
+ t = game.get_thing(game.sessions[connection_id], False)
+ game.io.send('CHAT ' + quote(t.nickname + ': ' + msg))
+cmd_ALL.argtypes = 'string'
+
+# TOOD split into two commands
+def cmd_LOGIN(game, nick, connection_id):
+ for t in [t for t in game.things if t.type_ == 'player' and t.nickname == nick]:
+ raise GameError('name already in use')
+ if connection_id in game.sessions:
+ t_id = game.sessions[connection_id]
+ t = game.get_thing(t_id, False)
+ old_nick = t.nickname
+ t.nickname = nick
+ game.io.send('CHAT ' + quote(old_nick + ' renamed themselves to ' + nick))
+ else:
+ t = game.thing_types['player'](game)
+ t.position = YX(game.map.size.y // 2, game.map.size.x // 2)
+ game.things += [t] # TODO refactor into Thing.__init__?
+ game.sessions[connection_id] = t.id_
+ game.io.send('LOGIN_OK', connection_id)
+ t.nickname = nick
+ game.io.send('CHAT ' + quote(t.nickname + ' entered the map.'))
+ game.io.send('PLAYER_ID %s' % t.id_, connection_id)
+ game.changed = True
+cmd_LOGIN.argtypes = 'string'
+
+def cmd_GET_GAMESTATE(game, connection_id):
+ game.send_gamestate(connection_id)
+cmd_GET_GAMESTATE.argtypes = ''
+
+def cmd_QUERY(game, target_nick, msg, connection_id):
+ if not connection_id in game.sessions:
+ raise GameError('can only query when logged in')
+ t = game.get_thing(game.sessions[connection_id], False)
+ source_nick = t.nickname
+ for t in [t for t in game.things if t.type_ == 'player' and t.nickname == target_nick]:
+ for c_id in game.sessions:
+ if game.sessions[c_id] == t.id_:
+ game.io.send('CHAT ' + quote(source_nick+ '->' + target_nick + ': ' + msg), c_id)
+ game.io.send('CHAT ' + quote(source_nick+ '->' + target_nick + ': ' + msg), connection_id)
+ return
+ raise GameError('target user offline')
+ raise GameError('can only query with registered nicknames')
+cmd_QUERY.argtypes = 'string string'
+
+def cmd_PING(game, connection_id):
+ game.io.send('PONG', connection_id)
+cmd_PING.argtypes = ''
+
+def cmd_TURN(game, n):
+ game.turn = n
+cmd_TURN.argtypes = 'int:nonneg'
+
+def cmd_ANNOTATE(game, yx, msg, connection_id):
+ if msg == ' ':
+ if yx in game.annotations:
+ del game.annotations[yx]
+ else:
+ game.annotations[yx] = msg
+ game.changed = True
+cmd_ANNOTATE.argtypes = 'yx_tuple:nonneg string'
+
+def cmd_PORTAL(game, yx, msg, connection_id):
+ if msg == ' ':
+ if yx in game.portals:
+ del game.portals[yx]
+ else:
+ game.portals[yx] = msg
+ game.changed = True
+cmd_PORTAL.argtypes = 'yx_tuple:nonneg string'
+
+def cmd_GET_ANNOTATION(game, yx, connection_id):
+ annotation = '(none)';
+ if yx in game.annotations:
+ annotation = game.annotations[yx]
+ game.io.send('ANNOTATION %s %s' % (yx, quote(annotation)))
+cmd_GET_ANNOTATION.argtypes = 'yx_tuple:nonneg'
+
+def cmd_MAP_LINE(game, y, line):
+ game.map.set_line(y, line)
+cmd_MAP_LINE.argtypes = 'int:nonneg string'
+
+def cmd_MAP(game, geometry, size):
+ map_geometry_class = globals()['MapGeometry' + geometry]
+ game.new_world(map_geometry_class(size))
+cmd_MAP.argtypes = 'string:map_geometry yx_tuple:pos'
--- /dev/null
+class ArgError(Exception):
+ pass
+
+
+class GameError(Exception):
+ pass
+
+
+class PlayError(Exception):
+ pass
+
+
+class BrokenSocketConnection(Exception):
+ pass
--- /dev/null
+from plomrogue.tasks import (Task_WAIT, Task_MOVE, Task_WRITE,
+ Task_FLATTEN_SURROUNDINGS)
+from plomrogue.errors import GameError, PlayError
+from plomrogue.io import GameIO
+from plomrogue.misc import quote
+from plomrogue.things import Thing, ThingPlayer
+from plomrogue.mapping import YX, MapGeometrySquare, Map
+
+
+
+class GameBase:
+
+ def __init__(self):
+ self.turn = 0
+ self.things = []
+ self.map_geometry = MapGeometrySquare(YX(24, 40))
+ self.commands = {}
+
+ def get_thing(self, id_, create_unfound):
+ # No default for create_unfound because every call to get_thing
+ # should be accompanied by serious consideration whether to use it.
+ for thing in self.things:
+ if id_ == thing.id_:
+ return thing
+ if create_unfound:
+ t = self.thing_type(self, id_)
+ self.things += [t]
+ return t
+ return None
+
+ def register_command(self, command):
+ prefix = 'cmd_'
+ if not command.__name__.startswith(prefix):
+ raise GameError('illegal command object name: %s' % command.__name__)
+ command_name = command.__name__[len(prefix):]
+ self.commands[command_name] = command
+
+
+
+import os
+class Game(GameBase):
+
+ def __init__(self, save_file, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.changed = True
+ self.io = GameIO(self, save_file)
+ self.tasks = {}
+ self.thing_type = Thing
+ self.thing_types = {'player': ThingPlayer}
+ self.sessions = {}
+ self.map = Map(self.map_geometry.size)
+ self.annotations = {}
+ self.portals = {}
+ if os.path.exists(self.io.save_file):
+ if not os.path.isfile(self.io.save_file):
+ raise GameError('save file path refers to non-file')
+
+ def register_task(self, task):
+ prefix = 'Task_'
+ if not task.__name__.startswith(prefix):
+ raise GameError('illegal task object name: %s' % task.__name__)
+ task_name = task.__name__[len(prefix):]
+ self.tasks[task_name] = task
+
+ def read_savefile(self):
+ if os.path.exists(self.io.save_file):
+ with open(self.io.save_file, 'r') as f:
+ lines = f.readlines()
+ for i in range(len(lines)):
+ line = lines[i]
+ print("FILE INPUT LINE %5s: %s" % (i, line), end='')
+ self.io.handle_input(line, god_mode=True)
+
+ def get_string_options(self, string_option_type):
+ import string
+ if string_option_type == 'direction':
+ return self.map_geometry.get_directions()
+ elif string_option_type == 'char':
+ return [c for c in
+ string.digits + string.ascii_letters + string.punctuation + ' ']
+ elif string_option_type == 'map_geometry':
+ return ['Hex', 'Square']
+ return None
+
+ def get_map_geometry_shape(self):
+ return self.map_geometry.__class__.__name__[len('MapGeometry'):]
+
+ def send_gamestate(self, connection_id=None):
+ """Send out game state data relevant to clients."""
+
+ def send_thing(thing):
+ self.io.send('THING_POS %s %s' % (thing.id_, t.position))
+ if hasattr(thing, 'nickname'):
+ self.io.send('THING_NAME %s %s' % (thing.id_, quote(t.nickname)))
+
+ self.io.send('TURN ' + str(self.turn))
+ for t in self.things:
+ send_thing(t)
+ self.io.send('MAP %s %s %s' % (self.get_map_geometry_shape(),
+ self.map_geometry.size, quote(self.map.terrain)))
+ for yx in self.portals:
+ self.io.send('PORTAL %s %s' % (yx, quote(self.portals[yx])))
+ self.io.send('GAME_STATE_COMPLETE')
+
+ def run_tick(self):
+ to_delete = []
+ for connection_id in self.sessions:
+ connection_id_found = False
+ for server in self.io.servers:
+ if connection_id in server.clients:
+ connection_id_found = True
+ break
+ if not connection_id_found:
+ t = self.get_thing(self.sessions[connection_id], create_unfound=False)
+ if hasattr(t, 'nickname'):
+ self.io.send('CHAT ' + quote(t.nickname + ' left the map.'))
+ self.things.remove(t)
+ to_delete += [connection_id]
+ for connection_id in to_delete:
+ del self.sessions[connection_id]
+ self.changed = True
+ for t in [t for t in self.things]:
+ if t in self.things:
+ try:
+ t.proceed()
+ except GameError as e:
+ for connection_id in [c_id for c_id in self.sessions
+ if self.sessions[c_id] == t.id_]:
+ self.io.send('GAME_ERROR ' + quote(str(e)), connection_id)
+ except PlayError as e:
+ for connection_id in [c_id for c_id in self.sessions
+ if self.sessions[c_id] == t.id_]:
+ self.io.send('PLAY_ERROR ' + quote(str(e)), connection_id)
+ if self.changed:
+ self.turn += 1
+ self.send_gamestate()
+ self.changed = False
+ self.save()
+
+ def get_command(self, command_name):
+
+ def partial_with_attrs(f, *args, **kwargs):
+ from functools import partial
+ p = partial(f, *args, **kwargs)
+ p.__dict__.update(f.__dict__)
+ return p
+
+ def cmd_TASK_colon(task_name, game, *args, connection_id):
+ if connection_id not in game.sessions:
+ raise GameError('Not registered as player.')
+ t = game.get_thing(game.sessions[connection_id], create_unfound=False)
+ t.set_next_task(task_name, args)
+
+ def task_prefixed(command_name, task_prefix, task_command):
+ if command_name.startswith(task_prefix):
+ task_name = command_name[len(task_prefix):]
+ if task_name in self.tasks:
+ f = partial_with_attrs(task_command, task_name, self)
+ task = self.tasks[task_name]
+ f.argtypes = task.argtypes
+ return f
+ return None
+
+ command = task_prefixed(command_name, 'TASK:', cmd_TASK_colon)
+ if command:
+ return command
+ if command_name in self.commands:
+ f = partial_with_attrs(self.commands[command_name], self)
+ return f
+ return None
+
+ def new_thing_id(self):
+ if len(self.things) == 0:
+ return 0
+ # DANGEROUS – if anywhere we append a thing to the list of lower
+ # ID than the highest-value ID, this might lead to re-using an
+ # already active ID. This condition /should/ not be fulfilled
+ # anywhere in the code, but if it does, trouble here is one of
+ # the more obvious indicators that it does – that's why there's
+ # no safeguard here against this.
+ return self.things[-1].id_ + 1
+
+ def save(self):
+
+ def write(f, msg):
+ f.write(msg + '\n')
+
+ with open(self.io.save_file, 'w') as f:
+ # TODO: save tasks
+ write(f, 'TURN %s' % self.turn)
+ map_geometry_shape = self.get_map_geometry_shape()
+ write(f, 'MAP %s %s' % (map_geometry_shape, self.map_geometry.size,))
+ for y, line in self.map.lines():
+ write(f, 'MAP_LINE %5s %s' % (y, quote(line)))
+ for yx in self.annotations:
+ write(f, 'ANNOTATE %s %s' % (yx, quote(self.annotations[yx])))
+ for yx in self.portals:
+ write(f, 'PORTAL %s %s' % (yx, quote(self.portals[yx])))
+
+ def new_world(self, map_geometry):
+ self.map_geometry = map_geometry
+ self.map = Map(self.map_geometry.size)
+ self.annotations = {}
--- /dev/null
+import queue
+import threading
+import inspect
+
+
+
+class GameIO():
+
+ def __init__(self, game, save_file='savefile'):
+ from plomrogue.parser import Parser
+ self.parser = Parser(game)
+ self.game = game
+ self.save_file = save_file
+ self.servers = []
+
+ def loop(self, q):
+ """Handle commands coming through queue q, run game, send results back."""
+ while True:
+ try:
+ command, connection_id = q.get(timeout=0.001)
+ self.handle_input(connection_id, command)
+ except queue.Empty:
+ self.game.run_tick()
+
+ def start_loop(self):
+ """Start game loop, set up self.queue to communicate with it.
+
+ The game loop works sequentially through game commands received
+ via self.queue from connected servers' clients."""
+ self.queue = queue.Queue()
+ c = threading.Thread(target=self.loop, args=(self.queue,))
+ c.start()
+
+ def start_server(self, port, server_class, certfile=None, keyfile=None):
+ """Start server of server_class in talk with game loop.
+
+ The server communicates with the game loop via self.queue.
+ """
+ if 'certfile' in list(inspect.signature(server_class.__init__).parameters):
+ server = server_class(self.queue, port, certfile=certfile, keyfile=keyfile)
+ else:
+ server = server_class(self.queue, port)
+ self.servers += [server]
+ c = threading.Thread(target=server.serve_forever)
+ c.start()
+
+ def handle_input(self, input_, connection_id=None, god_mode=False):
+ """Process input_ to command grammar, call command handler if found.
+
+ Command handlers that have no connectin_i argument in their
+ signature will only be called if god_mode is set.
+
+ """
+ from plomrogue.errors import GameError, ArgError, PlayError
+ from plomrogue.misc import quote
+
+ def answer(connection_id, msg):
+ if connection_id:
+ self.send(msg, connection_id)
+ else:
+ print(msg)
+
+ try:
+ command, args = self.parser.parse(input_)
+ if command is None:
+ answer(connection_id, 'UNHANDLED_INPUT')
+ else:
+ if 'connection_id' in list(inspect.signature(command).parameters):
+ command(*args, connection_id=connection_id)
+ elif god_mode:
+ command(*args)
+ #if store and not hasattr(command, 'dont_save'):
+ # with open(self.game_file_name, 'a') as f:
+ # f.write(input_ + '\n')
+ except ArgError as e:
+ answer(connection_id, 'ARGUMENT_ERROR ' + quote(str(e)))
+ except PlayError as e:
+ answer(connection_id, 'PLAY_ERROR ' + quote(str(e)))
+ except GameError as e:
+ answer(connection_id, 'GAME_ERROR ' + quote(str(e)))
+
+ def send(self, msg, connection_id=None):
+ """Send message msg to servers' client(s).
+
+ If a specific client is identified by connection_id, only
+ sends msg to that one. Else, sends it to all client sessions.
+
+ """
+ if connection_id:
+ for server in self.servers:
+ if connection_id in server.clients:
+ client = server.clients[connection_id]
+ client.put(msg)
+ else:
+ for c_id in self.game.sessions:
+ for server in self.servers:
+ if c_id in server.clients:
+ client = server.clients[c_id]
+ client.put(msg)
+ break
--- /dev/null
+import socketserver
+
+
+# Avoid "Address already in use" errors.
+socketserver.TCPServer.allow_reuse_address = True
+
+
+
+from plomrogue.errors import BrokenSocketConnection
+class PlomSocket:
+
+ def __init__(self, socket):
+ self.socket = socket
+
+ def send(self, message, silent_connection_break=False):
+ """Send via self.socket, encoded/delimited as way recv() expects.
+
+ In detail, all \ and $ in message are escaped with prefixed \,
+ and an unescaped $ is appended as a message delimiter. Then,
+ socket.send() is called as often as necessary to ensure
+ message is sent fully, as socket.send() due to buffering may
+ not send all of it right away.
+
+ Assuming socket is blocking, it's rather improbable that
+ socket.send() will be partial / return a positive value less
+ than the (byte) length of msg – but not entirely out of the
+ question. See: - <http://stackoverflow.com/q/19697218> -
+ <http://stackoverflow.com/q/2618736> -
+ <http://stackoverflow.com/q/8900474>
+
+ This also handles a socket.send() return value of 0, which
+ might be possible or not (?) for blocking sockets: -
+ <http://stackoverflow.com/q/34919846>
+
+ """
+ escaped_message = ''
+ for char in message:
+ if char in ('\\', '$'):
+ escaped_message += '\\'
+ escaped_message += char
+ escaped_message += '$'
+ data = escaped_message.encode()
+ totalsent = 0
+ while totalsent < len(data):
+ socket_broken = False
+ try:
+ sent = self.socket.send(data[totalsent:])
+ socket_broken = sent == 0
+ totalsent = totalsent + sent
+ except OSError as err:
+ if err.errno == 9: # "Bad file descriptor", when connection broken
+ socket_broken = True
+ else:
+ raise err
+ if socket_broken and not silent_connection_break:
+ raise BrokenSocketConnection
+
+ def recv(self):
+ """Get full send()-prepared message from self.socket.
+
+ In detail, socket.recv() is looped over for sequences of bytes
+ that can be decoded as a Unicode string delimited by an
+ unescaped $, with \ and $ escapable by \. If a sequence of
+ characters that ends in an unescaped $ cannot be decoded as
+ Unicode, None is returned as its representation. Stop once
+ socket.recv() returns nothing.
+
+ Under the hood, the TCP stack receives packets that construct
+ the input payload in an internal buffer; socket.recv(BUFSIZE)
+ pops up to BUFSIZE bytes from that buffer, without knowledge
+ either about the input's segmentation into packets, or whether
+ the input is segmented in any other meaningful way; that's why
+ we do our own message segmentation with $ as a delimiter.
+
+ """
+ esc = False
+ data = b''
+ msg = b''
+ while True:
+ try:
+ data = self.socket.recv(1024)
+ except OSError as err:
+ if err.errno == 9: # "Bad file descriptor", when connection broken
+ raise BrokenSocketConnection
+ if 0 == len(data):
+ break
+ for c in data:
+ if esc:
+ msg += bytes([c])
+ esc = False
+ elif chr(c) == '\\':
+ esc = True
+ elif chr(c) == '$':
+ try:
+ yield msg.decode()
+ except UnicodeDecodeError:
+ yield None
+ msg = b''
+ else:
+ msg += bytes([c])
+
+
+
+class PlomSocketSSL(PlomSocket):
+
+ def __init__(self, *args, certfile, keyfile, **kwargs):
+ import ssl
+ super().__init__(*args, **kwargs)
+ self.send('NEED_SSL')
+ self.socket = ssl.wrap_socket(self.socket, server_side=True,
+ certfile=certfile, keyfile=keyfile)
+
+
+
+class IO_Handler(socketserver.BaseRequestHandler):
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+
+ def handle(self):
+ """Move messages between network socket and game IO loop via queues.
+
+ On start (a new connection from client to server), sets up a
+ new queue, sends it via self.server.queue_out to the game IO
+ loop thread, and from then on receives messages to send back
+ from the game IO loop via that new queue.
+
+ At the same time, loops over socket's recv to get messages
+ from the outside into the game IO loop by way of
+ self.server.queue_out into the game IO. Ends connection once a
+ 'QUIT' message is received from socket, and then also calls
+ for a kill of its own queue.
+
+ """
+
+ def send_queue_messages(plom_socket, queue_in, thread_alive):
+ """Send messages via socket from queue_in while thread_alive[0]."""
+ while thread_alive[0]:
+ try:
+ msg = queue_in.get(timeout=1)
+ except queue.Empty:
+ continue
+ plom_socket.send(msg, True)
+
+ import uuid
+ import queue
+ import threading
+ if self.server.socket_class == PlomSocketSSL:
+ plom_socket = self.server.socket_class(self.request,
+ certfile=self.server.certfile,
+ keyfile=self.server.keyfile)
+ else:
+ plom_socket = self.server.socket_class(self.request)
+ print('CONNECTION FROM:', str(self.client_address))
+ connection_id = uuid.uuid4()
+ queue_in = queue.Queue()
+ self.server.clients[connection_id] = queue_in
+ thread_alive = [True]
+ t = threading.Thread(target=send_queue_messages,
+ args=(plom_socket, queue_in, thread_alive))
+ t.start()
+ for message in plom_socket.recv():
+ if message is None:
+ plom_socket.send('BAD MESSAGE', True)
+ elif 'QUIT' == message:
+ plom_socket.send('BYE', True)
+ break
+ else:
+ self.server.queue_out.put((connection_id, message))
+ del self.server.clients[connection_id]
+ thread_alive[0] = False
+ print('CONNECTION CLOSED FROM:', str(self.client_address))
+ plom_socket.socket.close()
+
+
+
+class PlomTCPServer(socketserver.ThreadingTCPServer):
+ """Bind together threaded IO handling server and message queue.
+
+ By default this only serves to localhost connections. For remote
+ connections, consider using PlomTCPServerSSL for more security,
+ which defaults to serving all connections.
+
+ """
+
+ def __init__(self, queue, port, host='127.0.0.1', *args, **kwargs):
+ super().__init__((host, port), IO_Handler, *args, **kwargs)
+ self.socket_class = PlomSocket
+ self.queue_out = queue
+ self.daemon_threads = True # Else, server's threads have daemon=False.
+ self.clients = {}
+
+
+
+class PlomTCPServerSSL(PlomTCPServer):
+
+ def __init__(self, *args, certfile, keyfile, **kwargs):
+ super().__init__(*args, host='0.0.0.0', **kwargs)
+ self.certfile = certfile
+ self.keyfile = keyfile
+ self.socket_class = PlomSocketSSL
--- /dev/null
+from SimpleWebSocketServer import SimpleWebSocketServer, WebSocket
+
+
+
+class PlomWebSocket(WebSocket):
+
+ def handleMessage(self):
+ if self.data == 'QUIT':
+ self.sendMessage('BYE')
+ self.close()
+ else:
+ for connection_id in self.server.clients:
+ if self.server.clients[connection_id] == self:
+ self.server.queue.put((connection_id, self.data))
+ break
+
+ def handleConnected(self):
+ import uuid
+ print('CONNECTION FROM:', self.address)
+ connection_id = uuid.uuid4()
+ self.server.clients[connection_id] = self
+
+ def handleClose(self):
+ print('CONNECTION CLOSED FROM:', self.address)
+ for connection_id in self.server.clients:
+ if self.server.clients[connection_id] == self:
+ del self.server.clients[connection_id]
+
+ def put(self, msg):
+ self.sendMessage(msg)
+
+
+
+class PlomWebSocketServer(SimpleWebSocketServer):
+
+ def __init__(self, queue, port, *args, **kwargs):
+ super().__init__('', port, PlomWebSocket)
+ self.queue = queue
+ self.clients = {}
+
+ def serve_forever(self):
+ self.serveforever()
+
+ def server_close(self):
+ self.close()
+
--- /dev/null
+import collections
+from plomrogue.errors import ArgError
+
+
+
+class YX(collections.namedtuple('YX', ('y', 'x'))):
+
+ def __add__(self, other):
+ return YX(self.y + other.y, self.x + other.x)
+
+ def __sub__(self, other):
+ return YX(self.y - other.y, self.x - other.x)
+
+ def __str__(self):
+ return 'Y:%s,X:%s' % (self.y, self.x)
+
+
+
+class MapGeometry():
+
+ def __init__(self, size):
+ self.size = size
+
+ def get_directions(self):
+ directions = []
+ for name in dir(self):
+ if name[:5] == 'move_':
+ directions += [name[5:]]
+ return directions
+
+ def get_neighbors(self, pos):
+ neighbors = {}
+ for direction in self.get_directions():
+ neighbors[direction] = self.move(pos, direction)
+ return neighbors
+
+ def move(self, start_pos, direction):
+ mover = getattr(self, 'move_' + direction)
+ target = mover(start_pos)
+ if target.y < 0 or target.x < 0 or \
+ target.y >= self.size.y or target.x >= self.size.x:
+ return None
+ return target
+
+
+
+class MapGeometryWithLeftRightMoves(MapGeometry):
+
+ def move_LEFT(self, start_pos):
+ return YX(start_pos.y, start_pos.x - 1)
+
+ def move_RIGHT(self, start_pos):
+ return YX(start_pos.y, start_pos.x + 1)
+
+
+
+class MapGeometrySquare(MapGeometryWithLeftRightMoves):
+
+ def move_UP(self, start_pos):
+ return YX(start_pos.y - 1, start_pos.x)
+
+ def move_DOWN(self, start_pos):
+ return YX(start_pos.y + 1, start_pos.x)
+
+
+
+class MapGeometryHex(MapGeometryWithLeftRightMoves):
+
+ def move_UPLEFT(self, start_pos):
+ start_indented = start_pos.y % 2
+ if start_indented:
+ return YX(start_pos.y - 1, start_pos.x)
+ else:
+ return YX(start_pos.y - 1, start_pos.x - 1)
+
+ def move_UPRIGHT(self, start_pos):
+ start_indented = start_pos.y % 2
+ if start_indented:
+ return YX(start_pos.y - 1, start_pos.x + 1)
+ else:
+ return YX(start_pos.y - 1, start_pos.x)
+
+ def move_DOWNLEFT(self, start_pos):
+ start_indented = start_pos.y % 2
+ if start_indented:
+ return YX(start_pos.y + 1, start_pos.x)
+ else:
+ return YX(start_pos.y + 1, start_pos.x - 1)
+
+ def move_DOWNRIGHT(self, start_pos):
+ start_indented = start_pos.y % 2
+ if start_indented:
+ return YX(start_pos.y + 1, start_pos.x + 1)
+ else:
+ return YX(start_pos.y + 1, start_pos.x)
+
+
+
+class Map():
+
+ def __init__(self, map_size):
+ self.size = map_size
+ self.terrain = '.' * self.size_i
+
+ def __getitem__(self, yx):
+ return self.terrain[self.get_position_index(yx)]
+
+ def __setitem__(self, yx, c):
+ pos_i = self.get_position_index(yx)
+ if type(c) == str:
+ self.terrain = self.terrain[:pos_i] + c + self.terrain[pos_i + 1:]
+ else:
+ self.terrain[pos_i] = c
+
+ @property
+ def size_i(self):
+ return self.size.y * self.size.x
+
+ def set_line(self, y, line):
+ height_map = self.size.y
+ width_map = self.size.x
+ if y >= height_map:
+ raise ArgError('too large row number %s' % y)
+ width_line = len(line)
+ if width_line != width_map:
+ raise ArgError('map line width %s unequal map width %s' % (width_line, width_map))
+ self.terrain = self.terrain[:y * width_map] + line +\
+ self.terrain[(y + 1) * width_map:]
+
+ def get_position_index(self, yx):
+ return yx.y * self.size.x + yx.x
+
+ def lines(self):
+ width = self.size.x
+ for y in range(self.size.y):
+ yield (y, self.terrain[y * width:(y + 1) * width])
--- /dev/null
+def quote(string):
+ """Quote & escape string so client interprets it as single token."""
+ quoted = []
+ quoted += ['"']
+ for c in string:
+ if c in {'"', '\\'}:
+ quoted += ['\\']
+ quoted += [c]
+ quoted += ['"']
+ return ''.join(quoted)
--- /dev/null
+import unittest
+from plomrogue.errors import ArgError
+from plomrogue.mapping import YX
+
+
+class Parser:
+
+ def __init__(self, game=None):
+ self.game = game
+
+ def tokenize(self, msg):
+ """Parse msg string into tokens.
+
+ Separates by ' ' and '\n', but allows whitespace in tokens quoted by
+ '"', and allows escaping within quoted tokens by a prefixed backslash.
+ """
+ tokens = []
+ token = ''
+ quoted = False
+ escaped = False
+ for c in msg:
+ if quoted:
+ if escaped:
+ token += c
+ escaped = False
+ elif c == '\\':
+ escaped = True
+ elif c == '"':
+ quoted = False
+ else:
+ token += c
+ elif c == '"':
+ quoted = True
+ elif c in {' ', '\n'}:
+ if len(token) > 0:
+ tokens += [token]
+ token = ''
+ else:
+ token += c
+ if len(token) > 0:
+ tokens += [token]
+ return tokens
+
+ def parse_yx_tuple(self, yx_string, range_=None):
+ """Parse yx_string as yx_tuple, return result.
+
+ The range_ argument may be 'nonneg' (non-negative, including
+ 0) or 'pos' (positive, excluding 0).
+
+ """
+
+ def get_axis_position_from_argument(axis, token):
+ if len(token) < 3 or token[:2] != axis + ':' or \
+ not (token[2:].isdigit() or token[2] == '-'):
+ raise ArgError('Non-int arg for ' + axis + ' position.')
+ n = int(token[2:])
+ if n < 1 and range_ == 'pos':
+ raise ArgError('Arg for ' + axis + ' position < 1.')
+ elif n < 0 and range_ == 'nonneg':
+ raise ArgError('Arg for ' + axis + ' position < 0.')
+ return n
+
+ tokens = yx_string.split(',')
+ if len(tokens) != 2:
+ raise ArgError('Wrong number of yx-tuple arguments.')
+ y = get_axis_position_from_argument('Y', tokens[0])
+ x = get_axis_position_from_argument('X', tokens[1])
+ return YX(y, x)
+
+ def parse(self, msg):
+ """Parse msg as call to function, return function with args tuple.
+
+ Respects function signature defined in function's .argtypes attribute.
+ """
+ tokens = self.tokenize(msg)
+ if len(tokens) == 0:
+ return None, ()
+ func = self.game.get_command(tokens[0])
+ argtypes = ''
+ if hasattr(func, 'argtypes'):
+ argtypes = func.argtypes
+ if func is None:
+ return None, ()
+ if len(argtypes) == 0:
+ if len(tokens) > 1:
+ raise ArgError('Command expects no argument(s).')
+ return func, ()
+ if len(tokens) == 1:
+ raise ArgError('Command expects argument(s).')
+ args_candidates = tokens[1:]
+ args = self.argsparse(argtypes, args_candidates)
+ return func, args
+
+ def argsparse(self, signature, args_tokens):
+ tmpl_tokens = signature.split()
+ if len(tmpl_tokens) != len(args_tokens):
+ raise ArgError('Number of arguments (' + str(len(args_tokens)) +
+ ') not expected number (' + str(len(tmpl_tokens))
+ + ').')
+ args = []
+ string_string = 'string'
+ for i in range(len(tmpl_tokens)):
+ tmpl = tmpl_tokens[i]
+ arg = args_tokens[i]
+ if tmpl == 'int:nonneg':
+ if not arg.isdigit():
+ raise ArgError('Argument must be non-negative integer.')
+ args += [int(arg)]
+ elif tmpl == 'yx_tuple:nonneg':
+ args += [self.parse_yx_tuple(arg, 'nonneg')]
+ elif tmpl == 'yx_tuple:pos':
+ args += [self.parse_yx_tuple(arg, 'pos')]
+ elif tmpl == string_string:
+ args += [arg]
+ elif tmpl[:len(string_string) + 1] == string_string + ':':
+ if not hasattr(self.game, 'get_string_options'):
+ raise ArgError('No string option directory.')
+ string_option_type = tmpl[len(string_string) + 1:]
+ options = self.game.get_string_options(string_option_type)
+ if options is None:
+ raise ArgError('Unknown string option type.')
+ if arg not in options:
+ msg = 'Argument #%s must be one of: %s' % (i + 1, options)
+ raise ArgError(msg)
+ args += [arg]
+ else:
+ raise ArgError('Unknown argument type: %s' % tmpl)
+ return args
--- /dev/null
+from plomrogue.errors import PlayError
+from plomrogue.mapping import YX
+
+
+
+class Task:
+ argtypes = ''
+ todo = 3
+
+ def __init__(self, thing, args=()):
+ self.thing = thing
+ self.args = args
+
+ def check(self):
+ pass
+
+
+
+class Task_WAIT(Task):
+ todo = 1
+
+ def do(self):
+ return 'success'
+
+
+
+class Task_MOVE(Task):
+ todo = 1
+ argtypes = 'string:direction'
+
+ def get_move_target(self):
+ return self.thing.game.map_geometry.move(self.thing.position,
+ self.args[0])
+
+ def check(self):
+ test_pos = self.get_move_target()
+ if test_pos is None:
+ raise PlayError('would move out of map')
+ elif test_pos in [t.position for t in self.thing.game.things]:
+ raise PlayError('would collide with other things')
+ elif self.thing.game.map[test_pos] != '.':
+ raise PlayError('would move into illegal territory')
+
+ def do(self):
+ self.thing.position = self.get_move_target()
+
+
+
+class Task_WRITE(Task):
+ todo = 1
+ argtypes = 'string:char'
+
+ def check(self):
+ pass
+
+ def do(self):
+ self.thing.game.map[self.thing.position] = self.args[0]
+
+
+
+class Task_FLATTEN_SURROUNDINGS(Task):
+ todo = 10
+
+ def check(self):
+ pass
+
+ def do(self):
+ self.thing.game.map[self.thing.position] = '.'
+ for yx in self.thing.game.map_geometry.get_neighbors(self.thing.position).values():
+ if yx is not None:
+ self.thing.game.map[yx] = '.'
--- /dev/null
+from plomrogue.errors import GameError
+from plomrogue.mapping import YX
+
+
+
+class ThingBase:
+ type_ = '?'
+
+ def __init__(self, game, id_=None, position=(YX(0,0))):
+ self.game = game
+ if id_ is None:
+ self.id_ = self.game.new_thing_id()
+ else:
+ self.id_ = id_
+ self.position = position
+
+
+
+class Thing(ThingBase):
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+
+ def proceed(self):
+ pass
+
+
+
+class ThingAnimate(Thing):
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.next_tasks = []
+ self.set_task('WAIT')
+
+ def set_task(self, task_name, args=()):
+ task_class = self.game.tasks[task_name]
+ self.task = task_class(self, args)
+ self.task.check() # will throw GameError if necessary
+
+ def set_next_task(self, task_name, args=()):
+ task_class = self.game.tasks[task_name]
+ self.next_tasks += [task_class(self, args)]
+
+ def get_next_task(self):
+ if len(self.next_tasks) > 0:
+ task = self.next_tasks.pop(0)
+ task.check()
+ return task
+ else:
+ return None
+
+ def proceed(self):
+ if self.task is None:
+ self.task = self.get_next_task()
+ return
+
+ try:
+ self.task.check()
+ except GameError as e:
+ self.task = None
+ raise GameError
+ return
+ self.task.todo -= 1
+ if self.task.todo <= 0:
+ self._last_task_result = self.task.do()
+ self.game.changed = True
+ self.task = self.get_next_task()
+
+
+
+class ThingPlayer(ThingAnimate):
+ type_ = 'player'
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.nickname = 'undefined'
+
--- /dev/null
+SimpleWebSocketServer
+ws4py
--- /dev/null
+#!/usr/bin/env python3
+from plomrogue.game import Game
+from plomrogue.io_websocket import PlomWebSocketServer
+from plomrogue.io_tcp import PlomTCPServer
+from plomrogue.commands import (cmd_ALL, cmd_LOGIN, cmd_QUERY, cmd_PING, cmd_MAP,
+ cmd_TURN, cmd_MAP_LINE, cmd_GET_ANNOTATION,
+ cmd_ANNOTATE, cmd_PORTAL, cmd_GET_GAMESTATE)
+from plomrogue.tasks import (Task_WAIT, Task_MOVE, Task_WRITE,
+ Task_FLATTEN_SURROUNDINGS)
+import sys
+
+if len(sys.argv) != 2:
+ print('wrong number of arguments, expected one (save file)')
+ exit(1)
+savefile = sys.argv[1]
+game = Game(savefile)
+game.register_command(cmd_PING)
+game.register_command(cmd_LOGIN)
+game.register_command(cmd_QUERY)
+game.register_command(cmd_TURN)
+game.register_command(cmd_MAP)
+game.register_command(cmd_MAP_LINE)
+game.register_command(cmd_GET_ANNOTATION)
+game.register_command(cmd_ANNOTATE)
+game.register_command(cmd_PORTAL)
+game.register_command(cmd_GET_GAMESTATE)
+game.register_task(Task_WAIT)
+game.register_task(Task_MOVE)
+game.register_task(Task_WRITE)
+game.register_task(Task_FLATTEN_SURROUNDINGS)
+game.read_savefile()
+game.io.start_loop()
+game.io.start_server(8000, PlomWebSocketServer)
+game.io.start_server(5000, PlomTCPServer)
--- /dev/null
+#!/usr/bin/env python3
+import curses
+import queue
+import threading
+from plomrogue.game import GameBase
+from plomrogue.parser import Parser
+from plomrogue.mapping import YX, MapGeometrySquare, MapGeometryHex
+from plomrogue.things import ThingBase
+from plomrogue.misc import quote
+from plomrogue.errors import BrokenSocketConnection
+
+from ws4py.client import WebSocketBaseClient
+class WebSocketClient(WebSocketBaseClient):
+
+ def __init__(self, recv_handler, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.recv_handler = recv_handler
+ self.connect()
+
+ def received_message(self, message):
+ if message.is_text:
+ message = str(message)
+ self.recv_handler(message)
+
+ @property
+ def plom_closed(self):
+ return self.client_terminated
+
+from plomrogue.io_tcp import PlomSocket
+class PlomSocketClient(PlomSocket):
+
+ def __init__(self, recv_handler, url):
+ import socket
+ self.recv_handler = recv_handler
+ host, port = url.split(':')
+ super().__init__(socket.create_connection((host, port)))
+
+ def close(self):
+ self.socket.close()
+
+ def run(self):
+ import ssl
+ try:
+ for msg in self.recv():
+ if msg == 'NEED_SSL':
+ self.socket = ssl.wrap_socket(self.socket)
+ continue
+ self.recv_handler(msg)
+ except BrokenSocketConnection:
+ pass # we assume socket will be known as dead by now
+
+def cmd_TURN(game, n):
+ game.turn = n
+ game.things = []
+ game.portals = {}
+ game.turn_complete = False
+cmd_TURN.argtypes = 'int:nonneg'
+
+def cmd_LOGIN_OK(game):
+ game.tui.switch_mode('post_login_wait')
+ game.tui.send('GET_GAMESTATE')
+ game.tui.log_msg('@ welcome')
+cmd_LOGIN_OK.argtypes = ''
+
+def cmd_CHAT(game, msg):
+ game.tui.log_msg('# ' + msg)
+ game.tui.do_refresh = True
+cmd_CHAT.argtypes = 'string'
+
+def cmd_PLAYER_ID(game, player_id):
+ game.player_id = player_id
+cmd_PLAYER_ID.argtypes = 'int:nonneg'
+
+def cmd_THING_POS(game, thing_id, position):
+ t = game.get_thing(thing_id, True)
+ t.position = position
+cmd_THING_POS.argtypes = 'int:nonneg yx_tuple:nonneg'
+
+def cmd_THING_NAME(game, thing_id, name):
+ t = game.get_thing(thing_id, True)
+ t.name = name
+cmd_THING_NAME.argtypes = 'int:nonneg string'
+
+def cmd_MAP(game, geometry, size, content):
+ map_geometry_class = globals()['MapGeometry' + geometry]
+ game.map_geometry = map_geometry_class(size)
+ game.map_content = content
+ if type(game.map_geometry) == MapGeometrySquare:
+ game.tui.movement_keys = {
+ game.tui.keys['square_move_up']: 'UP',
+ game.tui.keys['square_move_left']: 'LEFT',
+ game.tui.keys['square_move_down']: 'DOWN',
+ game.tui.keys['square_move_right']: 'RIGHT',
+ }
+ elif type(game.map_geometry) == MapGeometryHex:
+ game.tui.movement_keys = {
+ game.tui.keys['hex_move_upleft']: 'UPLEFT',
+ game.tui.keys['hex_move_upright']: 'UPRIGHT',
+ game.tui.keys['hex_move_right']: 'RIGHT',
+ game.tui.keys['hex_move_downright']: 'DOWNRIGHT',
+ game.tui.keys['hex_move_downleft']: 'DOWNLEFT',
+ game.tui.keys['hex_move_left']: 'LEFT',
+ }
+cmd_MAP.argtypes = 'string:map_geometry yx_tuple:pos string'
+
+def cmd_GAME_STATE_COMPLETE(game):
+ game.info_db = {}
+ if game.tui.mode.name == 'post_login_wait':
+ game.tui.switch_mode('play')
+ game.tui.help()
+ if game.tui.mode.shows_info:
+ game.tui.query_info()
+ player = game.get_thing(game.player_id, False)
+ if player.position in game.portals:
+ game.tui.teleport_target_host = game.portals[player.position]
+ game.tui.switch_mode('teleport')
+ game.turn_complete = True
+ game.tui.do_refresh = True
+cmd_GAME_STATE_COMPLETE.argtypes = ''
+
+def cmd_PORTAL(game, position, msg):
+ game.portals[position] = msg
+cmd_PORTAL.argtypes = 'yx_tuple:nonneg string'
+
+def cmd_PLAY_ERROR(game, msg):
+ game.tui.flash()
+ game.tui.do_refresh = True
+cmd_PLAY_ERROR.argtypes = 'string'
+
+def cmd_GAME_ERROR(game, msg):
+ game.tui.log_msg('? game error: ' + msg)
+ game.tui.do_refresh = True
+cmd_GAME_ERROR.argtypes = 'string'
+
+def cmd_ARGUMENT_ERROR(game, msg):
+ game.tui.log_msg('? syntax error: ' + msg)
+ game.tui.do_refresh = True
+cmd_ARGUMENT_ERROR.argtypes = 'string'
+
+def cmd_ANNOTATION(game, position, msg):
+ game.info_db[position] = msg
+ if game.tui.mode.shows_info:
+ game.tui.do_refresh = True
+cmd_ANNOTATION.argtypes = 'yx_tuple:nonneg string'
+
+def cmd_PONG(game):
+ pass
+cmd_PONG.argtypes = ''
+
+class Game(GameBase):
+ thing_type = ThingBase
+ turn_complete = False
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.register_command(cmd_LOGIN_OK)
+ self.register_command(cmd_PONG)
+ self.register_command(cmd_CHAT)
+ self.register_command(cmd_PLAYER_ID)
+ self.register_command(cmd_TURN)
+ self.register_command(cmd_THING_POS)
+ self.register_command(cmd_THING_NAME)
+ self.register_command(cmd_MAP)
+ self.register_command(cmd_PORTAL)
+ self.register_command(cmd_ANNOTATION)
+ self.register_command(cmd_GAME_STATE_COMPLETE)
+ self.register_command(cmd_ARGUMENT_ERROR)
+ self.register_command(cmd_GAME_ERROR)
+ self.register_command(cmd_PLAY_ERROR)
+ self.map_content = ''
+ self.player_id = -1
+ self.info_db = {}
+ self.portals = {}
+
+ def get_string_options(self, string_option_type):
+ if string_option_type == 'map_geometry':
+ return ['Hex', 'Square']
+ return None
+
+ def get_command(self, command_name):
+ from functools import partial
+ f = partial(self.commands[command_name], self)
+ f.argtypes = self.commands[command_name].argtypes
+ return f
+
+class TUI:
+
+ class Mode:
+
+ def __init__(self, name, has_input_prompt=False, shows_info=False,
+ is_intro = False):
+ self.name = name
+ self.has_input_prompt = has_input_prompt
+ self.shows_info = shows_info
+ self.is_intro = is_intro
+
+ def __init__(self, host):
+ import os
+ import json
+ self.host = host
+ self.mode_play = self.Mode('play')
+ self.mode_study = self.Mode('study', shows_info=True)
+ self.mode_edit = self.Mode('edit')
+ self.mode_annotate = self.Mode('annotate', has_input_prompt=True, shows_info=True)
+ self.mode_portal = self.Mode('portal', has_input_prompt=True, shows_info=True)
+ self.mode_chat = self.Mode('chat', has_input_prompt=True)
+ self.mode_waiting_for_server = self.Mode('waiting_for_server', is_intro=True)
+ self.mode_login = self.Mode('login', has_input_prompt=True, is_intro=True)
+ self.mode_post_login_wait = self.Mode('post_login_wait', is_intro=True)
+ self.mode_teleport = self.Mode('teleport', has_input_prompt=True)
+ self.game = Game()
+ self.game.tui = self
+ self.parser = Parser(self.game)
+ self.log = []
+ self.do_refresh = True
+ self.queue = queue.Queue()
+ self.login_name = None
+ self.switch_mode('waiting_for_server')
+ self.keys = {
+ 'switch_to_chat': 't',
+ 'switch_to_play': 'p',
+ 'switch_to_annotate': 'm',
+ 'switch_to_portal': 'P',
+ 'switch_to_study': '?',
+ 'switch_to_edit': 'm',
+ 'flatten': 'F',
+ 'hex_move_upleft': 'w',
+ 'hex_move_upright': 'e',
+ 'hex_move_right': 'd',
+ 'hex_move_downright': 'x',
+ 'hex_move_downleft': 'y',
+ 'hex_move_left': 'a',
+ 'square_move_up': 'w',
+ 'square_move_left': 'a',
+ 'square_move_down': 's',
+ 'square_move_right': 'd',
+ }
+ if os.path.isfile('config.json'):
+ with open('config.json', 'r') as f:
+ keys_conf = json.loads(f.read())
+ for k in keys_conf:
+ self.keys[k] = keys_conf[k]
+ curses.wrapper(self.loop)
+
+ def flash(self):
+ curses.flash()
+
+ def send(self, msg):
+ try:
+ if hasattr(self.socket, 'plom_closed') and self.socket.plom_closed:
+ raise BrokenSocketConnection
+ self.socket.send(msg)
+ except (BrokenPipeError, BrokenSocketConnection):
+ self.log_msg('@ server disconnected :(')
+ self.do_refresh = True
+
+ def log_msg(self, msg):
+ self.log += [msg]
+ if len(self.log) > 100:
+ self.log = self.log[-100:]
+
+ def query_info(self):
+ self.send('GET_ANNOTATION ' + str(self.explorer))
+
+ def switch_mode(self, mode_name, keep_position = False):
+ self.mode = getattr(self, 'mode_' + mode_name)
+ if self.mode.shows_info and not keep_position:
+ player = self.game.get_thing(self.game.player_id, False)
+ self.explorer = YX(player.position.y, player.position.x)
+ if self.mode.name == 'waiting_for_server':
+ self.log_msg('@ waiting for server …')
+ elif self.mode.name == 'login':
+ if self.login_name:
+ self.send('LOGIN ' + quote(self.login_name))
+ else:
+ self.log_msg('@ enter username')
+ elif self.mode.name == 'teleport':
+ self.log_msg("@ May teleport to %s" % (self.teleport_target_host)),
+ self.log_msg("@ Enter 'YES!' to enthusiastically affirm.");
+ elif self.mode.name == 'annotate' and self.explorer in self.game.info_db:
+ info = self.game.info_db[self.explorer]
+ if info != '(none)':
+ self.input_ = info
+ elif self.mode.name == 'portal' and self.explorer in self.game.portals:
+ self.input_ = self.game.portals[self.explorer]
+
+ def help(self):
+ self.log_msg("HELP:");
+ self.log_msg("chat mode commands:");
+ self.log_msg(" /nick NAME - re-name yourself to NAME");
+ self.log_msg(" /msg USER TEXT - send TEXT to USER");
+ self.log_msg(" /help - show this help");
+ self.log_msg(" /%s or /play - switch to play mode" % self.keys['switch_to_play']);
+ self.log_msg(" /%s or /study - switch to study mode" % self.keys['switch_to_study']);
+ self.log_msg("commands common to study and play mode:");
+ self.log_msg(" %s - move" % ','.join(self.movement_keys));
+ self.log_msg(" %s - switch to chat mode" % self.keys['switch_to_chat']);
+ self.log_msg("commands specific to play mode:");
+ self.log_msg(" %s - write following ASCII character" % self.keys['switch_to_edit']);
+ self.log_msg(" %s - flatten surroundings" % self.keys['flatten']);
+ self.log_msg(" %s - switch to study mode" % self.keys['switch_to_study']);
+ self.log_msg("commands specific to study mode:");
+ self.log_msg(" %s - annotate terrain" % self.keys['switch_to_annotate']);
+ self.log_msg(" %s - switch to play mode" % self.keys['switch_to_play']);
+
+ def loop(self, stdscr):
+ import time
+ import datetime
+
+ def safe_addstr(y, x, line):
+ if y < self.size.y - 1 or x + len(line) < self.size.x:
+ stdscr.addstr(y, x, line)
+ else: # workaround to <https://stackoverflow.com/q/7063128>
+ cut_i = self.size.x - x - 1
+ cut = line[:cut_i]
+ last_char = line[cut_i]
+ stdscr.addstr(y, self.size.x - 2, last_char)
+ stdscr.insstr(y, self.size.x - 2, ' ')
+ stdscr.addstr(y, x, cut)
+
+ def connect():
+
+ def handle_recv(msg):
+ if msg == 'BYE':
+ self.socket.close()
+ else:
+ self.queue.put(msg)
+
+ socket_client_class = PlomSocketClient
+ if self.host.startswith('ws://') or self.host.startswith('wss://'):
+ socket_client_class = WebSocketClient
+ while True:
+ try:
+ self.socket = socket_client_class(handle_recv, self.host)
+ self.socket_thread = threading.Thread(target=self.socket.run)
+ self.socket_thread.start()
+ self.switch_mode('login')
+ return
+ except ConnectionRefusedError:
+ self.log_msg('@ server connect failure, trying again …')
+ draw_screen()
+ stdscr.refresh()
+ time.sleep(1)
+
+ def reconnect():
+ self.send('QUIT')
+ time.sleep(0.1) # FIXME necessitated by some some strange SSL race
+ # conditions with ws4py, find out what exactly
+ self.switch_mode('waiting_for_server')
+ connect()
+
+ def handle_input(msg):
+ command, args = self.parser.parse(msg)
+ command(*args)
+
+ def msg_into_lines_of_width(msg, width):
+ chunk = ''
+ lines = []
+ x = 0
+ for i in range(len(msg)):
+ if x >= width or msg[i] == "\n":
+ lines += [chunk]
+ chunk = ''
+ x = 0
+ if msg[i] != "\n":
+ chunk += msg[i]
+ x += 1
+ lines += [chunk]
+ return lines
+
+ def reset_screen_size():
+ self.size = YX(*stdscr.getmaxyx())
+ self.size = self.size - YX(self.size.y % 4, 0)
+ self.size = self.size - YX(0, self.size.x % 4)
+ self.window_width = int(self.size.x / 2)
+
+ def recalc_input_lines():
+ if not self.mode.has_input_prompt:
+ self.input_lines = []
+ else:
+ self.input_lines = msg_into_lines_of_width(input_prompt + self.input_,
+ self.window_width)
+
+ def move_explorer(direction):
+ target = self.game.map_geometry.move(self.explorer, direction)
+ if target:
+ self.explorer = target
+ self.query_info()
+ else:
+ self.flash()
+
+ def draw_history():
+ lines = []
+ for line in self.log:
+ lines += msg_into_lines_of_width(line, self.window_width)
+ lines.reverse()
+ height_header = 2
+ max_y = self.size.y - len(self.input_lines)
+ for i in range(len(lines)):
+ if (i >= max_y - height_header):
+ break
+ safe_addstr(max_y - i - 1, self.window_width, lines[i])
+
+ def draw_info():
+ if not self.game.turn_complete:
+ return
+ pos_i = self.explorer.y * self.game.map_geometry.size.x + self.explorer.x
+ info = 'TERRAIN: %s\n' % self.game.map_content[pos_i]
+ for t in self.game.things:
+ if t.position == self.explorer:
+ info += 'PLAYER @: %s\n' % t.name
+ if self.explorer in self.game.portals:
+ info += 'PORTAL: ' + self.game.portals[self.explorer] + '\n'
+ else:
+ info += 'PORTAL: (none)\n'
+ if self.explorer in self.game.info_db:
+ info += 'ANNOTATION: ' + self.game.info_db[self.explorer]
+ else:
+ info += 'ANNOTATION: waiting …'
+ lines = msg_into_lines_of_width(info, self.window_width)
+ height_header = 2
+ for i in range(len(lines)):
+ y = height_header + i
+ if y >= self.size.y - len(self.input_lines):
+ break
+ safe_addstr(y, self.window_width, lines[i])
+
+ def draw_input():
+ y = self.size.y - len(self.input_lines)
+ for i in range(len(self.input_lines)):
+ safe_addstr(y, self.window_width, self.input_lines[i])
+ y += 1
+
+ def draw_turn():
+ if not self.game.turn_complete:
+ return
+ safe_addstr(0, self.window_width, 'TURN: ' + str(self.game.turn))
+
+ def draw_mode():
+ safe_addstr(1, self.window_width, 'MODE: ' + self.mode.name)
+
+ def draw_map():
+ if not self.game.turn_complete:
+ return
+ map_lines_split = []
+ for y in range(self.game.map_geometry.size.y):
+ start = self.game.map_geometry.size.x * y
+ end = start + self.game.map_geometry.size.x
+ map_lines_split += [list(self.game.map_content[start:end])]
+ for t in self.game.things:
+ map_lines_split[t.position.y][t.position.x] = '@'
+ if self.mode.shows_info:
+ map_lines_split[self.explorer.y][self.explorer.x] = '?'
+ map_lines = []
+ if type(self.game.map_geometry) == MapGeometryHex:
+ indent = 0
+ for line in map_lines_split:
+ map_lines += [indent*' ' + ' '.join(line)]
+ indent = 0 if indent else 1
+ else:
+ for line in map_lines_split:
+ map_lines += [' '.join(line)]
+ window_center = YX(int(self.size.y / 2),
+ int(self.window_width / 2))
+ player = self.game.get_thing(self.game.player_id, False)
+ center = player.position
+ if self.mode.shows_info:
+ center = self.explorer
+ center = YX(center.y, center.x * 2)
+ offset = center - window_center
+ if type(self.game.map_geometry) == MapGeometryHex and offset.y % 2:
+ offset += YX(0, 1)
+ term_y = max(0, -offset.y)
+ term_x = max(0, -offset.x)
+ map_y = max(0, offset.y)
+ map_x = max(0, offset.x)
+ while (term_y < self.size.y and map_y < self.game.map_geometry.size.y):
+ to_draw = map_lines[map_y][map_x:self.window_width + offset.x]
+ safe_addstr(term_y, term_x, to_draw)
+ term_y += 1
+ map_y += 1
+
+ def draw_screen():
+ stdscr.clear()
+ recalc_input_lines()
+ if self.mode.has_input_prompt:
+ draw_input()
+ if self.mode.shows_info:
+ draw_info()
+ else:
+ draw_history()
+ draw_mode()
+ if not self.mode.is_intro:
+ draw_turn()
+ draw_map()
+
+ curses.curs_set(False) # hide cursor
+ curses.use_default_colors();
+ stdscr.timeout(10)
+ reset_screen_size()
+ self.explorer = YX(0, 0)
+ self.input_ = ''
+ input_prompt = '> '
+ connect()
+ last_ping = datetime.datetime.now()
+ interval = datetime.timedelta(seconds=30)
+ while True:
+ now = datetime.datetime.now()
+ if now - last_ping > interval:
+ self.send('PING')
+ last_ping = now
+ if self.do_refresh:
+ draw_screen()
+ self.do_refresh = False
+ while True:
+ try:
+ msg = self.queue.get(block=False)
+ handle_input(msg)
+ except queue.Empty:
+ break
+ try:
+ key = stdscr.getkey()
+ self.do_refresh = True
+ except curses.error:
+ continue
+ if key == 'KEY_RESIZE':
+ reset_screen_size()
+ elif self.mode.has_input_prompt and key == 'KEY_BACKSPACE':
+ self.input_ = self.input_[:-1]
+ elif self.mode.has_input_prompt and key != '\n': # Return key
+ self.input_ += key
+ max_length = self.window_width * self.size.y - len(input_prompt) - 1
+ if len(self.input_) > max_length:
+ self.input_ = self.input_[:max_length]
+ elif self.mode == self.mode_login and key == '\n':
+ self.login_name = self.input_
+ self.send('LOGIN ' + quote(self.input_))
+ self.input_ = ""
+ elif self.mode == self.mode_chat and key == '\n':
+ if self.input_[0] == '/':
+ if self.input_ in {'/' + self.keys['switch_to_play'], '/play'}:
+ self.switch_mode('play')
+ elif self.input_ in {'/' + self.keys['switch_to_study'], '/study'}:
+ self.switch_mode('study')
+ elif self.input_ == '/help':
+ self.help()
+ elif self.input_ == '/reconnect':
+ reconnect()
+ elif self.input_.startswith('/nick'):
+ tokens = self.input_.split(maxsplit=1)
+ if len(tokens) == 2:
+ self.send('LOGIN ' + quote(tokens[1]))
+ else:
+ self.log_msg('? need login name')
+ elif self.input_.startswith('/msg'):
+ tokens = self.input_.split(maxsplit=2)
+ if len(tokens) == 3:
+ self.send('QUERY %s %s' % (quote(tokens[1]),
+ quote(tokens[2])))
+ else:
+ self.log_msg('? need message target and message')
+ else:
+ self.log_msg('? unknown command')
+ else:
+ self.send('ALL ' + quote(self.input_))
+ self.input_ = ""
+ elif self.mode == self.mode_annotate and key == '\n':
+ if self.input_ == '':
+ self.input_ = ' '
+ self.send('ANNOTATE %s %s' % (self.explorer, quote(self.input_)))
+ self.input_ = ""
+ self.switch_mode('study', keep_position=True)
+ elif self.mode == self.mode_portal and key == '\n':
+ if self.input_ == '':
+ self.input_ = ' '
+ self.send('PORTAL %s %s' % (self.explorer, quote(self.input_)))
+ self.input_ = ""
+ self.switch_mode('study', keep_position=True)
+ elif self.mode == self.mode_teleport and key == '\n':
+ if self.input_ == 'YES!':
+ self.host = self.teleport_target_host
+ reconnect()
+ else:
+ self.log_msg('@ teleport aborted')
+ self.switch_mode('play')
+ self.input_ = ''
+ elif self.mode == self.mode_study:
+ if key == self.keys['switch_to_chat']:
+ self.switch_mode('chat')
+ elif key == self.keys['switch_to_play']:
+ self.switch_mode('play')
+ elif key == self.keys['switch_to_annotate']:
+ self.switch_mode('annotate', keep_position=True)
+ elif key == self.keys['switch_to_portal']:
+ self.switch_mode('portal', keep_position=True)
+ elif key in self.movement_keys:
+ move_explorer(self.movement_keys[key])
+ elif self.mode == self.mode_play:
+ if key == self.keys['switch_to_chat']:
+ self.switch_mode('chat')
+ elif key == self.keys['switch_to_study']:
+ self.switch_mode('study')
+ if key == self.keys['switch_to_edit']:
+ self.switch_mode('edit')
+ elif key == self.keys['flatten']:
+ self.send('TASK:FLATTEN_SURROUNDINGS')
+ elif key in self.movement_keys:
+ self.send('TASK:MOVE ' + self.movement_keys[key])
+ elif self.mode == self.mode_edit:
+ self.send('TASK:WRITE ' + key)
+ self.switch_mode('play')
+
+TUI('localhost:5000')
--- /dev/null
+<!DOCTYPE html>
+<html><head>
+<style>
+</style>
+</head><body>
+<div>
+terminal rows: <input id="n_rows" type="number" step=4 min=8 value=24 />
+terminal columns: <input id="n_cols" type="number" step=4 min=20 value=80 />
+</div>
+<pre id="terminal" style="display: inline-block;"></pre>
+<textarea id="input" style="opacity: 0; width: 0px;"></textarea>
+<div>
+keys (see <a href="https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key/Key_Values">here</a> for non-obvious available values):<br />
+move up (square grid): <input id="key_square_move_up" type="text" value="w" /> (hint: ArrowUp)<br />
+move left (square grid): <input id="key_square_move_left" type="text" value="a" /> (hint: ArrowLeft)<br />
+move down (square grid): <input id="key_square_move_down" type="text" value="s" /> (hint: ArrowDown)<br />
+move right (square grid): <input id="key_square_move_right" type="text" value="d" /> (hint: ArrowRight)<br />
+move up-left (hex grid): <input id="key_hex_move_upleft" type="text" value="w" /><br />
+move up-right (hex grid): <input id="key_hex_move_upright" type="text" value="e" /><br />
+move right (hex grid): <input id="key_hex_move_right" type="text" value="d" /><br />
+move down-right (hex grid): <input id="key_hex_move_downright" type="text" value="x" /><br />
+move down-left (hex grid): <input id="key_hex_move_downleft" type="text" value="y" /><br />
+move left (hex grid): <input id="key_hex_move_left" type="text" value="a" /><br />
+flatten surroundings: <input id="key_flatten" type="text" value="F" /><br />
+switch to chat mode: <input id="key_switch_to_chat" type="text" value="t" /><br />
+switch to play mode: <input id="key_switch_to_play" type="text" value="p" /><br />
+switch to study mode: <input id="key_switch_to_study" type="text" value="?" /><br />
+edit terrain (from play mode): <input id="key_switch_to_edit" type="text" value="m" /><br />
+annotate terrain (from study mode): <input id="key_switch_to_annotate" type="text" value="m" /><br />
+annotate portal (from study mode): <input id="key_switch_to_portal" type="text" value="P" /><br />
+</div>
+<script>
+"use strict";
+let websocket_location = "ws://localhost:8000";
+
+let rows_selector = document.getElementById("n_rows");
+let cols_selector = document.getElementById("n_cols");
+let key_selectors = document.querySelectorAll('[id^="key_"]');
+
+function restore_selector_value(selector) {
+ let stored_selection = window.localStorage.getItem(selector.id);
+ if (stored_selection) {
+ selector.value = stored_selection;
+ }
+}
+restore_selector_value(rows_selector);
+restore_selector_value(cols_selector);
+for (let key_selector of key_selectors) {
+ restore_selector_value(key_selector);
+}
+
+let terminal = {
+ foreground: 'white',
+ background: 'black',
+ initialize: function() {
+ this.rows = rows_selector.value;
+ this.cols = cols_selector.value;
+ this.pre_el = document.getElementById("terminal");
+ this.pre_el.style.color = this.foreground;
+ this.pre_el.style.backgroundColor = this.background;
+ this.content = [];
+ let line = []
+ for (let y = 0, x = 0; y <= this.rows; x++) {
+ if (x == this.cols) {
+ x = 0;
+ y += 1;
+ this.content.push(line);
+ line = [];
+ if (y == this.rows) {
+ break;
+ }
+ }
+ line.push(' ');
+ }
+ },
+ blink_screen: function() {
+ this.pre_el.style.color = this.background;
+ this.pre_el.style.backgroundColor = this.foreground;
+ setTimeout(() => {
+ this.pre_el.style.color = this.foreground;
+ this.pre_el.style.backgroundColor = this.background;
+ }, 100);
+ },
+ refresh: function() {
+ let pre_string = '';
+ for (let y = 0; y < this.rows; y++) {
+ let line = this.content[y].join('');
+ pre_string += line + '\n';
+ }
+ this.pre_el.textContent = pre_string;
+ },
+ write: function(start_y, start_x, msg) {
+ for (let x = start_x, i = 0; x < this.cols && i < msg.length; x++, i++) {
+ this.content[start_y][x] = msg[i];
+ }
+ },
+ drawBox: function(start_y, start_x, height, width) {
+ let end_y = start_y + height;
+ let end_x = start_x + width;
+ for (let y = start_y, x = start_x; y < this.rows; x++) {
+ if (x == end_x) {
+ x = start_x;
+ y += 1;
+ if (y == end_y) {
+ break;
+ }
+ };
+ this.content[y][x] = ' ';
+ }
+ },
+}
+terminal.initialize();
+
+let parser = {
+ tokenize: function(str) {
+ let token_ends = [];
+ let tokens = [];
+ let token = ''
+ let quoted = false;
+ let escaped = false;
+ for (let i = 0; i < str.length; i++) {
+ let c = str[i];
+ if (quoted) {
+ if (escaped) {
+ token += c;
+ escaped = false;
+ } else if (c == '\\') {
+ escaped = true;
+ } else if (c == '"') {
+ quoted = false
+ } else {
+ token += c;
+ }
+ } else if (c == '"') {
+ quoted = true
+ } else if (c === ' ') {
+ if (token.length > 0) {
+ token_ends.push(i);
+ tokens.push(token);
+ token = '';
+ }
+ } else {
+ token += c;
+ }
+ }
+ if (token.length > 0) {
+ tokens.push(token);
+ }
+ let token_starts = [];
+ for (let i = 0; i < token_ends.length; i++) {
+ token_starts.push(token_ends[i] - tokens[i].length);
+ };
+ return [tokens, token_starts];
+ },
+ parse_yx: function(position_string) {
+ let coordinate_strings = position_string.split(',')
+ let position = [0, 0];
+ position[0] = parseInt(coordinate_strings[0].slice(2));
+ position[1] = parseInt(coordinate_strings[1].slice(2));
+ return position;
+ },
+}
+
+class Thing {
+ constructor(yx) {
+ this.position = yx;
+ }
+}
+
+let server = {
+ init: function(url) {
+ this.url = url;
+ this.websocket = new WebSocket(this.url);
+ this.websocket.onopen = function(event) {
+ window.setInterval(function() { server.send(['PING']) }, 30000);
+ tui.log_msg("@ server connected! :)");
+ tui.switch_mode(mode_login);
+ };
+ this.websocket.onclose = function(event) {
+ tui.log_msg("@ server disconnected :(");
+ tui.log_msg("@ hint: try the '/reconnect' command");
+ };
+ this.websocket.onmessage = this.handle_event;
+ },
+ reconnect: function() {
+ this.reconnect_to(this.url);
+ },
+ reconnect_to: function(url) {
+ this.websocket.close();
+ this.init(url);
+ },
+ send: function(tokens) {
+ this.websocket.send(unparser.untokenize(tokens));
+ },
+ handle_event: function(event) {
+ let tokens = parser.tokenize(event.data)[0];
+ if (tokens[0] === 'TURN') {
+ game.turn_complete = false;
+ game.things = {};
+ game.portals = {};
+ game.turn = parseInt(tokens[1]);
+ } else if (tokens[0] === 'THING_POS') {
+ game.get_thing(tokens[1], true).position = parser.parse_yx(tokens[2]);
+ } else if (tokens[0] === 'THING_NAME') {
+ game.get_thing(tokens[1], true).name_ = tokens[2];
+ } else if (tokens[0] === 'MAP') {
+ game.map_geometry = tokens[1];
+ tui.init_keys();
+ game.map_size = parser.parse_yx(tokens[2]);
+ game.map = tokens[3]
+ } else if (tokens[0] === 'GAME_STATE_COMPLETE') {
+ game.turn_complete = true;
+ explorer.empty_info_db();
+ if (tui.mode == mode_post_login_wait) {
+ tui.switch_mode(mode_play);
+ tui.log_help();
+ } else if (tui.mode == mode_study) {
+ explorer.query_info();
+ }
+ let t = game.get_thing(game.player_id);
+ if (t.position in game.portals) {
+ tui.teleport_target = game.portals[t.position];
+ tui.switch_mode(mode_teleport);
+ return;
+ }
+ tui.full_refresh();
+ } else if (tokens[0] === 'CHAT') {
+ tui.log_msg('# ' + tokens[1], 1);
+ } else if (tokens[0] === 'PLAYER_ID') {
+ game.player_id = parseInt(tokens[1]);
+ } else if (tokens[0] === 'LOGIN_OK') {
+ this.send(['GET_GAMESTATE']);
+ tui.switch_mode(mode_post_login_wait);
+ } else if (tokens[0] === 'PORTAL') {
+ let position = parser.parse_yx(tokens[1]);
+ game.portals[position] = tokens[2];
+ } else if (tokens[0] === 'ANNOTATION') {
+ let position = parser.parse_yx(tokens[1]);
+ explorer.update_info_db(position, tokens[2]);
+ } else if (tokens[0] === 'UNHANDLED_INPUT') {
+ tui.log_msg('? unknown command');
+ } else if (tokens[0] === 'PLAY_ERROR') {
+ terminal.blink_screen();
+ } else if (tokens[0] === 'ARGUMENT_ERROR') {
+ tui.log_msg('? syntax error: ' + tokens[1]);
+ } else if (tokens[0] === 'GAME_ERROR') {
+ tui.log_msg('? game error: ' + tokens[1]);
+ } else if (tokens[0] === 'PONG') {
+ console.log('PONG');
+ } else {
+ tui.log_msg('? unhandled input: ' + event.data);
+ }
+ }
+}
+
+let unparser = {
+ quote: function(str) {
+ let quoted = ['"'];
+ for (let i = 0; i < str.length; i++) {
+ let c = str[i];
+ if (['"', '\\'].includes(c)) {
+ quoted.push('\\');
+ };
+ quoted.push(c);
+ }
+ quoted.push('"');
+ return quoted.join('');
+ },
+ to_yx: function(yx_coordinate) {
+ return "Y:" + yx_coordinate[0] + ",X:" + yx_coordinate[1];
+ },
+ untokenize: function(tokens) {
+ let quoted_tokens = [];
+ for (let token of tokens) {
+ quoted_tokens.push(this.quote(token));
+ }
+ return quoted_tokens.join(" ");
+ }
+}
+
+class Mode {
+ constructor(name, has_input_prompt=false, shows_info=false, is_intro=false) {
+ this.name = name;
+ this.has_input_prompt = has_input_prompt;
+ this.shows_info= shows_info;
+ this.is_intro = is_intro;
+ }
+}
+let mode_waiting_for_server = new Mode('waiting_for_server', false, false, true);
+let mode_login = new Mode('login', true, false, true);
+let mode_post_login_wait = new Mode('waiting for game world', false, false, true);
+let mode_chat = new Mode('chat / write messages to players', true, false);
+let mode_annotate = new Mode('add message to map tile', true, true);
+let mode_play = new Mode('play / move around', false, false);
+let mode_study = new Mode('check map tiles for messages', false, true);
+let mode_edit = new Mode('write ASCII char to map tile', false, false);
+let mode_teleport = new Mode('teleport away?', true);
+let mode_portal = new Mode('add portal to map tile', true, true);
+
+let tui = {
+ mode: mode_waiting_for_server,
+ log: [],
+ input_prompt: '> ',
+ input_lines: [],
+ window_width: terminal.cols / 2,
+ height_turn_line: 1,
+ height_mode_line: 1,
+ height_input: 1,
+ init: function() {
+ this.inputEl = document.getElementById("input");
+ this.inputEl.focus();
+ this.recalc_input_lines();
+ this.height_header = this.height_turn_line + this.height_mode_line;
+ this.log_msg("@ waiting for server connection ...");
+ this.init_keys();
+ },
+ init_keys: function() {
+ this.keys = {};
+ for (let key_selector of key_selectors) {
+ this.keys[key_selector.id.slice(4)] = key_selector.value;
+ }
+ if (game.map_geometry == 'Square') {
+ this.movement_keys = {
+ [this.keys.square_move_up]: 'UP',
+ [this.keys.square_move_left]: 'LEFT',
+ [this.keys.square_move_down]: 'DOWN',
+ [this.keys.square_move_right]: 'RIGHT'
+ };
+ } else if (game.map_geometry == 'Hex') {
+ this.movement_keys = {
+ [this.keys.hex_move_upleft]: 'UPLEFT',
+ [this.keys.hex_move_upright]: 'UPRIGHT',
+ [this.keys.hex_move_right]: 'RIGHT',
+ [this.keys.hex_move_downright]: 'DOWNRIGHT',
+ [this.keys.hex_move_downleft]: 'DOWNLEFT',
+ [this.keys.hex_move_left]: 'LEFT'
+ };
+ };
+ },
+ switch_mode: function(mode, keep_pos=false) {
+ if (mode == mode_study && !keep_pos && game.player_id in game.things) {
+ explorer.position = game.things[game.player_id].position;
+ }
+ this.mode = mode;
+ this.empty_input();
+ if (mode == mode_annotate && explorer.position in explorer.info_db) {
+ let info = explorer.info_db[explorer.position];
+ if (info != "(none)") {
+ this.inputEl.value = info;
+ this.recalc_input_lines();
+ }
+ }
+ if (mode == mode_login) {
+ if (this.login_name) {
+ server.send(['LOGIN', this.login_name]);
+ } else {
+ this.log_msg("? need login name");
+ }
+ } else if (mode == mode_portal && explorer.position in game.portals) {
+ let portal = game.portals[explorer.position]
+ this.inputEl.value = portal;
+ this.recalc_input_lines();
+ } else if (mode == mode_teleport) {
+ tui.log_msg("@ May teleport to: " + tui.teleport_target);
+ tui.log_msg("@ Enter 'YES!' to entusiastically affirm.");
+ }
+ this.full_refresh();
+ },
+ empty_input: function(str) {
+ this.inputEl.value = "";
+ if (this.mode.has_input_prompt) {
+ this.recalc_input_lines();
+ } else {
+ this.height_input = 0;
+ }
+ },
+ recalc_input_lines: function() {
+ this.input_lines = this.msg_into_lines_of_width(this.input_prompt + this.inputEl.value, this.window_width);
+ this.height_input = this.input_lines.length;
+ },
+ msg_into_lines_of_width: function(msg, width) {
+ let chunk = "";
+ let lines = [];
+ for (let i = 0, x = 0; i < msg.length; i++, x++) {
+ if (x >= width || msg[i] == "\n") {
+ lines.push(chunk);
+ chunk = "";
+ x = 0;
+ };
+ if (msg[i] != "\n") {
+ chunk += msg[i];
+ }
+ }
+ lines.push(chunk);
+ return lines;
+ },
+ log_msg: function(msg) {
+ this.log.push(msg);
+ while (this.log.length > 100) {
+ this.log.shift();
+ };
+ this.full_refresh();
+ },
+ log_help: function() {
+ let movement_keys_desc = Object.keys(this.movement_keys).join(',');
+ this.log_msg("HELP:");
+ this.log_msg("chat mode commands:");
+ this.log_msg(" /nick NAME - re-name yourself to NAME");
+ this.log_msg(" /msg USER TEXT - send TEXT to USER");
+ this.log_msg(" /help - show this help");
+ this.log_msg(" /" + this.keys.switch_to_play + " or /play - switch to play mode");
+ this.log_msg(" /" + this.keys.switch_to_study + " or /study - switch to study mode");
+ this.log_msg("commands common to study and play mode:");
+ this.log_msg(" " + movement_keys_desc + " - move");
+ this.log_msg(" " + this.keys.switch_to_chat + " - switch to chat mode");
+ this.log_msg("commands specific to play mode:");
+ this.log_msg(" " + this.keys.switch_to_edit + " - write following ASCII character");
+ this.log_msg(" " + this.keys.flatten + " - flatten surroundings");
+ this.log_msg(" " + this.keys.switch_to_study + " - switch to study mode");
+ this.log_msg("commands specific to study mode:");
+ this.log_msg(" " + this.keys.switch_to_annotate + " - annotate terrain");
+ this.log_msg(" " + this.keys.switch_to_play + " - switch to play mode");
+ },
+ draw_map: function() {
+ let map_lines_split = [];
+ let line = [];
+ for (let i = 0, j = 0; i < game.map.length; i++, j++) {
+ if (j == game.map_size[1]) {
+ map_lines_split.push(line);
+ line = [];
+ j = 0;
+ };
+ line.push(game.map[i]);
+ };
+ map_lines_split.push(line);
+ for (const thing_id in game.things) {
+ let t = game.things[thing_id];
+ map_lines_split[t.position[0]][t.position[1]] = '@';
+ };
+ if (tui.mode.shows_info) {
+ map_lines_split[explorer.position[0]][explorer.position[1]] = '?';
+ }
+ let map_lines = []
+ if (game.map_geometry == 'Square') {
+ for (let line_split of map_lines_split) {
+ map_lines.push(line_split.join(' '));
+ };
+ } else if (game.map_geometry == 'Hex') {
+ let indent = 0
+ for (let line_split of map_lines_split) {
+ map_lines.push(' '.repeat(indent) + line_split.join(' '));
+ if (indent == 0) {
+ indent = 1;
+ } else {
+ indent = 0;
+ };
+ };
+ }
+ let window_center = [terminal.rows / 2, this.window_width / 2];
+ let player = game.things[game.player_id];
+ let center_position = [player.position[0], player.position[1]];
+ if (tui.mode.shows_info) {
+ center_position = [explorer.position[0], explorer.position[1]];
+ }
+ center_position[1] = center_position[1] * 2;
+ let offset = [center_position[0] - window_center[0],
+ center_position[1] - window_center[1]]
+ if (game.map_geometry == 'Hex' && offset[0] % 2) {
+ offset[1] += 1;
+ };
+ let term_y = Math.max(0, -offset[0]);
+ let term_x = Math.max(0, -offset[1]);
+ let map_y = Math.max(0, offset[0]);
+ let map_x = Math.max(0, offset[1]);
+ for (; term_y < terminal.rows && map_y < game.map_size[0]; term_y++, map_y++) {
+ let to_draw = map_lines[map_y].slice(map_x, this.window_width + offset[1]);
+ terminal.write(term_y, term_x, to_draw);
+ }
+ },
+ draw_mode_line: function() {
+ terminal.write(0, this.window_width, 'MODE: ' + this.mode.name);
+ },
+ draw_turn_line: function(n) {
+ terminal.write(1, this.window_width, 'TURN: ' + game.turn);
+ },
+ draw_history: function() {
+ let log_display_lines = [];
+ for (let line of this.log) {
+ log_display_lines = log_display_lines.concat(this.msg_into_lines_of_width(line, this.window_width));
+ };
+ for (let y = terminal.rows - 1 - this.height_input,
+ i = log_display_lines.length - 1;
+ y >= this.height_header && i >= 0;
+ y--, i--) {
+ terminal.write(y, this.window_width, log_display_lines[i]);
+ }
+ },
+ draw_info: function() {
+ let lines = this.msg_into_lines_of_width(explorer.get_info(), this.window_width);
+ for (let y = this.height_header, i = 0; y < terminal.rows && i < lines.length; y++, i++) {
+ terminal.write(y, this.window_width, lines[i]);
+ }
+ },
+ draw_input: function() {
+ if (this.mode.has_input_prompt) {
+ for (let y = terminal.rows - this.height_input, i = 0; i < this.input_lines.length; y++, i++) {
+ terminal.write(y, this.window_width, this.input_lines[i]);
+ }
+ }
+ },
+ full_refresh: function() {
+ terminal.drawBox(0, 0, terminal.rows, terminal.cols);
+ if (this.mode.is_intro) {
+ this.draw_history();
+ this.draw_input();
+ } else {
+ if (game.turn_complete) {
+ this.draw_map();
+ this.draw_turn_line();
+ }
+ this.draw_mode_line();
+ if (this.mode.shows_info) {
+ this.draw_info();
+ } else {
+ this.draw_history();
+ }
+ this.draw_input();
+ }
+ terminal.refresh();
+ }
+}
+
+let game = {
+ init: function() {
+ this.things = {};
+ this.turn = -1;
+ this.map = "";
+ this.map_size = [0,0];
+ this.player_id = -1;
+ this.portals = {};
+ },
+ get_thing: function(id_, create_if_not_found=false) {
+ if (id_ in game.things) {
+ return game.things[id_];
+ } else if (create_if_not_found) {
+ let t = new Thing([0,0]);
+ game.things[id_] = t;
+ return t;
+ };
+ },
+ move: function(start_position, direction) {
+ let target = [start_position[0], start_position[1]];
+ if (direction == 'LEFT') {
+ target[1] -= 1;
+ } else if (direction == 'RIGHT') {
+ target[1] += 1;
+ } else if (game.map_geometry == 'Square') {
+ if (direction == 'UP') {
+ target[0] -= 1;
+ } else if (direction == 'DOWN') {
+ target[0] += 1;
+ };
+ } else if (game.map_geometry == 'Hex') {
+ let start_indented = start_position[0] % 2;
+ if (direction == 'UPLEFT') {
+ target[0] -= 1;
+ if (!start_indented) {
+ target[1] -= 1;
+ }
+ } else if (direction == 'UPRIGHT') {
+ target[0] -= 1;
+ if (start_indented) {
+ target[1] += 1;
+ }
+ } else if (direction == 'DOWNLEFT') {
+ target[0] += 1;
+ if (!start_indented) {
+ target[1] -= 1;
+ }
+ } else if (direction == 'DOWNRIGHT') {
+ target[0] += 1;
+ if (start_indented) {
+ target[1] += 1;
+ }
+ };
+ };
+ if (target[0] < 0 || target[1] < 0 ||
+ target[0] >= this.map_size[0] || target[1] >= this.map_size[1]) {
+ return null;
+ };
+ return target;
+ }
+}
+
+game.init();
+tui.init();
+tui.full_refresh();
+server.init(websocket_location);
+
+let explorer = {
+ position: [0,0],
+ info_db: {},
+ move: function(direction) {
+ let target = game.move(this.position, direction);
+ if (target) {
+ this.position = target
+ this.query_info();
+ tui.full_refresh();
+ } else {
+ terminal.blink_screen();
+ };
+ },
+ update_info_db: function(yx, str) {
+ this.info_db[yx] = str;
+ if (tui.mode == mode_study) {
+ tui.full_refresh();
+ }
+ },
+ empty_info_db: function() {
+ this.info_db = {};
+ if (tui.mode == mode_study) {
+ tui.full_refresh();
+ }
+ },
+ query_info: function() {
+ server.send(["GET_ANNOTATION", unparser.to_yx(explorer.position)]);
+ },
+ get_info: function() {
+ let info = "";
+ let position_i = this.position[0] * game.map_size[1] + this.position[1];
+ info += "TERRAIN: " + game.map[position_i] + "\n";
+ for (let t_id in game.things) {
+ let t = game.things[t_id];
+ if (t.position[0] == this.position[0] && t.position[1] == this.position[1]) {
+ info += "PLAYER @";
+ if (t.name_) {
+ info += ": " + t.name_;
+ }
+ info += "\n";
+ }
+ }
+ if (this.position in game.portals) {
+ info += "PORTAL: " + game.portals[this.position] + "\n";
+ }
+ if (this.position in this.info_db) {
+ info += "ANNOTATIONS: " + this.info_db[this.position];
+ } else {
+ info += 'waiting …';
+ }
+ return info;
+ },
+ annotate: function(msg) {
+ if (msg.length == 0) {
+ msg = " "; // triggers annotation deletion
+ }
+ server.send(["ANNOTATE", unparser.to_yx(explorer.position), msg]);
+ },
+ set_portal: function(msg) {
+ if (msg.length == 0) {
+ msg = " "; // triggers portal deletion
+ }
+ server.send(["PORTAL", unparser.to_yx(explorer.position), msg]);
+ }
+}
+
+tui.inputEl.addEventListener('input', (event) => {
+ if (tui.mode.has_input_prompt) {
+ let max_length = tui.window_width * terminal.rows - tui.input_prompt.length;
+ if (tui.inputEl.value.length > max_length) {
+ tui.inputEl.value = tui.inputEl.value.slice(0, max_length);
+ };
+ tui.recalc_input_lines();
+ tui.full_refresh();
+ } else if (tui.mode == mode_edit && tui.inputEl.value.length > 0) {
+ server.send(["TASK:WRITE", tui.inputEl.value[0]]);
+ tui.switch_mode(mode_play);
+ } else if (tui.mode == mode_teleport) {
+ if (['Y', 'y'].includes(tui.inputEl.value[0])) {
+ server.reconnect_to(tui.teleport_target);
+ } else {
+ tui.log_msg("@ teleportation aborted");
+ tui.switch_mode(mode_play);
+ }
+ }
+}, false);
+tui.inputEl.addEventListener('keydown', (event) => {
+ if (event.key == 'Enter') {
+ event.preventDefault();
+ }
+ if (tui.mode == mode_login && event.key == 'Enter') {
+ tui.login_name = tui.inputEl.value;
+ server.send(['LOGIN', tui.inputEl.value]);
+ tui.empty_input();
+ } else if (tui.mode == mode_portal && event.key == 'Enter') {
+ explorer.set_portal(tui.inputEl.value);
+ tui.switch_mode(mode_study, true);
+ } else if (tui.mode == mode_annotate && event.key == 'Enter') {
+ explorer.annotate(tui.inputEl.value);
+ tui.switch_mode(mode_study, true);
+ } else if (tui.mode == mode_teleport && event.key == 'Enter') {
+ if (tui.inputEl.value == 'YES!') {
+ server.reconnect_to(tui.teleport_target);
+ } else {
+ tui.log_msg('@ teleport aborted');
+ tui.switch_mode(mode_play);
+ };
+ } else if (tui.mode == mode_chat && event.key == 'Enter') {
+ let [tokens, token_starts] = parser.tokenize(tui.inputEl.value);
+ if (tokens.length > 0 && tokens[0].length > 0) {
+ if (tui.inputEl.value[0][0] == '/') {
+ if (tokens[0].slice(1) == 'play' || tokens[0][1] == tui.keys.switch_to_play) {
+ tui.switch_mode(mode_play);
+ } else if (tokens[0].slice(1) == 'study' || tokens[0][1] == tui.keys.switch_to_study) {
+ tui.switch_mode(mode_study);
+ } else if (tokens[0].slice(1) == 'help') {
+ tui.log_help();
+ } else if (tokens[0].slice(1) == 'nick') {
+ if (tokens.length > 1) {
+ server.send(['LOGIN', tokens[1]]);
+ } else {
+ tui.log_msg('? need login name');
+ }
+ } else if (tokens[0].slice(1) == 'msg') {
+ if (tokens.length > 2) {
+ let msg = tui.inputEl.value.slice(token_starts[2]);
+ server.send(['QUERY', tokens[1], msg]);
+ } else {
+ tui.log_msg('? need message target and message');
+ }
+ } else if (tokens[0].slice(1) == 'reconnect') {
+ if (tokens.length > 1) {
+ server.reconnect_to(tokens[1]);
+ } else {
+ server.reconnect();
+ }
+ } else {
+ tui.log_msg('? unknown command');
+ }
+ } else {
+ server.send(['ALL', tui.inputEl.value]);
+ }
+ } else if (tui.inputEl.valuelength > 0) {
+ server.send(['ALL', tui.inputEl.value]);
+ }
+ tui.empty_input();
+ tui.full_refresh();
+ } else if (tui.mode == mode_play) {
+ if (event.key === tui.keys.switch_to_chat) {
+ event.preventDefault();
+ tui.switch_mode(mode_chat);
+ } else if (event.key === tui.keys.switch_to_edit) {
+ event.preventDefault();
+ tui.switch_mode(mode_edit);
+ } else if (event.key === tui.keys.switch_to_study) {
+ tui.switch_mode(mode_study);
+ } else if (event.key === tui.keys.flatten) {
+ server.send(["TASK:FLATTEN_SURROUNDINGS"]);
+ } else if (event.key in tui.movement_keys) {
+ server.send(['TASK:MOVE', tui.movement_keys[event.key]]);
+ };
+ } else if (tui.mode == mode_study) {
+ if (event.key === tui.keys.switch_to_chat) {
+ event.preventDefault();
+ tui.switch_mode(mode_chat);
+ } else if (event.key == tui.keys.switch_to_play) {
+ tui.switch_mode(mode_play);
+ } else if (event.key === tui.keys.switch_to_portal) {
+ event.preventDefault();
+ tui.switch_mode(mode_portal);
+ } else if (event.key in tui.movement_keys) {
+ explorer.move(tui.movement_keys[event.key]);
+ } else if (event.key === tui.keys.switch_to_annotate) {
+ event.preventDefault();
+ tui.switch_mode(mode_annotate);
+ };
+ }
+}, false);
+
+rows_selector.addEventListener('input', function() {
+ if (rows_selector.value % 4 != 0) {
+ return;
+ }
+ window.localStorage.setItem(rows_selector.id, rows_selector.value);
+ terminal.initialize();
+ tui.full_refresh();
+}, false);
+cols_selector.addEventListener('input', function() {
+ if (cols_selector.value % 4 != 0) {
+ return;
+ }
+ window.localStorage.setItem(cols_selector.id, cols_selector.value);
+ terminal.initialize();
+ tui.window_width = terminal.cols / 2,
+ tui.full_refresh();
+}, false);
+for (let key_selector of key_selectors) {
+ key_selector.addEventListener('input', function() {
+ window.localStorage.setItem(key_selector.id, key_selector.value);
+ tui.init_keys();
+ }, false);
+}
+window.setInterval(function() {
+ if (!(['input', 'n_cols', 'n_rows'].includes(document.activeElement.id)
+ || document.activeElement.id.startsWith('key_'))) {
+ tui.inputEl.focus();
+ }
+}, 100);
+</script>
+</body></html>