From 540aec0e9bf55d0452cffda4b34e1995d3724abf Mon Sep 17 00:00:00 2001 From: Christian Heller Date: Tue, 10 Nov 2020 00:40:35 +0100 Subject: [PATCH] Initial commit. --- config.json | 19 + plomrogue/commands.py | 95 ++++ plomrogue/errors.py | 14 + plomrogue/game.py | 203 +++++++ plomrogue/io.py | 100 ++++ plomrogue/io_tcp.py | 201 +++++++ plomrogue/io_websocket.py | 46 ++ plomrogue/mapping.py | 136 +++++ plomrogue/misc.py | 10 + plomrogue/parser.py | 128 +++++ plomrogue/tasks.py | 71 +++ plomrogue/things.py | 78 +++ requirements.txt | 2 + rogue_chat.py | 34 ++ rogue_chat_curses.py | 613 +++++++++++++++++++++ rogue_chat_nocanvas_monochrome.html | 809 ++++++++++++++++++++++++++++ 16 files changed, 2559 insertions(+) create mode 100644 config.json create mode 100644 plomrogue/commands.py create mode 100644 plomrogue/errors.py create mode 100755 plomrogue/game.py create mode 100644 plomrogue/io.py create mode 100644 plomrogue/io_tcp.py create mode 100644 plomrogue/io_websocket.py create mode 100644 plomrogue/mapping.py create mode 100644 plomrogue/misc.py create mode 100644 plomrogue/parser.py create mode 100644 plomrogue/tasks.py create mode 100644 plomrogue/things.py create mode 100644 requirements.txt create mode 100755 rogue_chat.py create mode 100755 rogue_chat_curses.py create mode 100644 rogue_chat_nocanvas_monochrome.html diff --git a/config.json b/config.json new file mode 100644 index 0000000..c594191 --- /dev/null +++ b/config.json @@ -0,0 +1,19 @@ +{ + "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" +} diff --git a/plomrogue/commands.py b/plomrogue/commands.py new file mode 100644 index 0000000..a80731d --- /dev/null +++ b/plomrogue/commands.py @@ -0,0 +1,95 @@ +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' diff --git a/plomrogue/errors.py b/plomrogue/errors.py new file mode 100644 index 0000000..b98f8f6 --- /dev/null +++ b/plomrogue/errors.py @@ -0,0 +1,14 @@ +class ArgError(Exception): + pass + + +class GameError(Exception): + pass + + +class PlayError(Exception): + pass + + +class BrokenSocketConnection(Exception): + pass diff --git a/plomrogue/game.py b/plomrogue/game.py new file mode 100755 index 0000000..68bcb65 --- /dev/null +++ b/plomrogue/game.py @@ -0,0 +1,203 @@ +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 = {} diff --git a/plomrogue/io.py b/plomrogue/io.py new file mode 100644 index 0000000..8e0f9bf --- /dev/null +++ b/plomrogue/io.py @@ -0,0 +1,100 @@ +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 diff --git a/plomrogue/io_tcp.py b/plomrogue/io_tcp.py new file mode 100644 index 0000000..09b9db1 --- /dev/null +++ b/plomrogue/io_tcp.py @@ -0,0 +1,201 @@ +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: - - + - + + + This also handles a socket.send() return value of 0, which + might be possible or not (?) for blocking sockets: - + + + """ + 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 diff --git a/plomrogue/io_websocket.py b/plomrogue/io_websocket.py new file mode 100644 index 0000000..0a74f4e --- /dev/null +++ b/plomrogue/io_websocket.py @@ -0,0 +1,46 @@ +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() + diff --git a/plomrogue/mapping.py b/plomrogue/mapping.py new file mode 100644 index 0000000..e0a59d8 --- /dev/null +++ b/plomrogue/mapping.py @@ -0,0 +1,136 @@ +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]) diff --git a/plomrogue/misc.py b/plomrogue/misc.py new file mode 100644 index 0000000..a3f7298 --- /dev/null +++ b/plomrogue/misc.py @@ -0,0 +1,10 @@ +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) diff --git a/plomrogue/parser.py b/plomrogue/parser.py new file mode 100644 index 0000000..5782d69 --- /dev/null +++ b/plomrogue/parser.py @@ -0,0 +1,128 @@ +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 diff --git a/plomrogue/tasks.py b/plomrogue/tasks.py new file mode 100644 index 0000000..99bb41f --- /dev/null +++ b/plomrogue/tasks.py @@ -0,0 +1,71 @@ +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] = '.' diff --git a/plomrogue/things.py b/plomrogue/things.py new file mode 100644 index 0000000..0e4af34 --- /dev/null +++ b/plomrogue/things.py @@ -0,0 +1,78 @@ +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' + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..7d7d093 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +SimpleWebSocketServer +ws4py diff --git a/rogue_chat.py b/rogue_chat.py new file mode 100755 index 0000000..026fcdf --- /dev/null +++ b/rogue_chat.py @@ -0,0 +1,34 @@ +#!/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) diff --git a/rogue_chat_curses.py b/rogue_chat_curses.py new file mode 100755 index 0000000..a6d1ca7 --- /dev/null +++ b/rogue_chat_curses.py @@ -0,0 +1,613 @@ +#!/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 + 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') diff --git a/rogue_chat_nocanvas_monochrome.html b/rogue_chat_nocanvas_monochrome.html new file mode 100644 index 0000000..ab3ce34 --- /dev/null +++ b/rogue_chat_nocanvas_monochrome.html @@ -0,0 +1,809 @@ + + + + +
+terminal rows: +terminal columns: +
+

+
+
+keys (see here for non-obvious available values):
+move up (square grid): (hint: ArrowUp)
+move left (square grid): (hint: ArrowLeft)
+move down (square grid): (hint: ArrowDown)
+move right (square grid): (hint: ArrowRight)
+move up-left (hex grid):
+move up-right (hex grid):
+move right (hex grid):
+move down-right (hex grid):
+move down-left (hex grid):
+move left (hex grid):
+flatten surroundings:
+switch to chat mode:
+switch to play mode:
+switch to study mode:
+edit terrain (from play mode):
+annotate terrain (from study mode):
+annotate portal (from study mode):
+
+ + -- 2.30.2