commit e6cb475b6d0d816993fe5b22c2839522cda8a7bb Author: pwgen2155 Date: Wed Feb 28 16:09:51 2024 +1100 feat: working diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..823a699 --- /dev/null +++ b/.flake8 @@ -0,0 +1,2 @@ +[flake8] +extend-ignore = * diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aff21da --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +# Django # +*.log +*.pot +*.pyc +__pycache__ +*~ +*/__pycache__ +*/*/__pycache__ +*/*/*/__pycache__ +db.sqlite3 +media + +# Backup files # +*.bak +*.sqlite3 + +site/static/ diff --git a/COPYING b/COPYING new file mode 100644 index 0000000..f433b1a --- /dev/null +++ b/COPYING @@ -0,0 +1,177 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0e3aad6 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,7 @@ +FROM python:3.11 + +RUN pip install --no-cache-dir django uwsgi Pillow + +VOLUME /data + +CMD [ "python", "/data/dawdle.py", "/data/data/dawdle.conf"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..81724d5 --- /dev/null +++ b/README.md @@ -0,0 +1,49 @@ +# DawdleRPG + +DawdleRPG is an IdleRPG clone written in Python. + +## Basic Setup + +- Edit `dawdle.conf` to configure your bot. +- Run `dawdle.py ` +- The data directory defaults to the parent directory of the + configuration file, and dawdlerpg expects files to be in that + directory. + +## Setup with Website + +The included `install.sh` script will set up the dawdlerpg bot and +website on a freshly installed Debian system. It uses nginx, uwsgi, +and django for the site. At some point, you should be prompted to +edit the dawdle.conf file, and you'll need to edit some configuration +parameters explained by the comments in the file. + +```sh +./install.sh +``` + +If you don't have a clean install, you should instead look at the +`install.sh` script and use the pieces that work for your setup. + +## Migrating from IdleRPG + +DawdleRPG is capable of being a drop-in replacement. + +- Run `dawdle.py ` + +If you have any command line overrides to the configuration, you will +need to replace them with the `-o key=value` option. + +## Differences from IdleRPG + +- Names, items, and durations are in different colors. +- Output throttling allows configurable rate over a period. +- Long messages are word wrapped. +- Logging can be set to different levels. +- Better IRC protocol support. +- More game numbers are configurable. +- Quest pathfinding is much more efficient. +- Fights caused by map collisions have chance of finding item. +- All worn items have a chance to get buffs/debuffs instead of a subset. +- High level character can still get special items. +- Special items are always buffs. diff --git a/af/dawdlerpg.nginx b/af/dawdlerpg.nginx new file mode 100644 index 0000000..bab7faa --- /dev/null +++ b/af/dawdlerpg.nginx @@ -0,0 +1,33 @@ +# DawdleRPG nginx configuration +server { + listen 80 default_server; + listen [::]:80 default_server; + + # SSL configuration + # + # listen 443 ssl default_server; + # listen [::]:443 ssl default_server; + # gzip off + # + + root DAWDLERPG_DIR/site/static; + charset utf-8; + server_name _; + + location /media { + alias DAWDLERPG_DIR/site/media; + } + location /static { + alias DAWDLERPG_DIR/site/static; + } + location /favicon.ico { + alias DAWDLERPG_DIR/site/static/favicon.ico; + } + location /robots.txt { + alias DAWDLERPG_DIR/site/static/robots.txt; + } + location / { + uwsgi_pass unix:///tmp/dawdlerpg-uwsgi.sock; + include uwsgi_params; + } +} diff --git a/af/events.txt b/af/events.txt new file mode 100644 index 0000000..85c129e --- /dev/null +++ b/af/events.txt @@ -0,0 +1,31 @@ +# Calamities - these add to a player's time to level +C was struck in the head by a falling anime box set +C was mauled by a werewolf +C kept asking for invites to the secreet club +C posted too many times in help +C DIED... a bit + +# Godsends - these subtract from a player's time to level +G was sprinkled with fairy dust, or something... +G found a pot of gold with an unreleased Anime BD +G found the glorious wonders of linux +G met a friendly AB user +G found a magical spring, that wasn't poisioned +G drank a potent elixir +G rewrote this Godsends in Go instead of Perl + +# Quest mode 1 - these are simply timed quests +Q1 solve the theft of the AB domain +Q1 deliver a priceless scroll to the library of Chinese Anime +Q1 rescue an imprisoned precure from the palace of Grefog +Q1 impersonate cultists to infiltrate a dark temple +Q1 unearth the secret of the Twin Peeks (.)(.) +Q1 throwing a party for Animefield +Q1 decipher the fabled tablet of AnimeBytes + +# Quest mode 2 - these are two-stage navigation quests +Q2 464 23 113 187 search for a hostage nymph and heal her broken spring +Q2 321 488 137 56 complete the Pisan pilgrimage from the shine of Katu to Mount Irta +Q2 304 269 70 417 secretly follow a Esteri assassin and report on her activities +Q2 447 432 359 318 travel to the Orod volcano to retrieve a lava-proof circlet and bring it to a country village +Q2 326 31 246 133 must spring a Jonast noble from a prison and escort him back to his ancestral lands. diff --git a/dawdle.py b/dawdle.py new file mode 100755 index 0000000..18c9c38 --- /dev/null +++ b/dawdle.py @@ -0,0 +1,220 @@ +#!/usr/bin/python3 + +# Copyright 2021 Daniel Lowe +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import asyncio +import atexit +import logging +import os +import os.path +import re +import resource +import signal +import sys +import termios +import time + +from dawdle import bot +from dawdle import conf +from dawdle import irc +from dawdle.log import log +import dawdle.log as dawdlelog + + +def first_setup(db: bot.GameDB) -> None: + """Perform initialization of game.""" + pname = input(f"Initializing dbfile {bot.datapath(conf.get('dbfile'))}. Give an account name that you would like to have admin access [{conf.get('owner')}]: ") + if pname == "": + pname = conf.get("owner") + pclass = input("Enter a character class for this account: ") + pclass = pclass[:conf.get("max_class_len")] + try: + old = termios.tcgetattr(sys.stdin.fileno()) + new = old.copy() + new[3] = new[3] & ~termios.ECHO + termios.tcsetattr(sys.stdin.fileno(), termios.TCSADRAIN, new) + ppass = input("Password for this account: ") + finally: + termios.tcsetattr(sys.stdin.fileno(), termios.TCSADRAIN, old) + + if db.exists(): + db.clear() + else: + db.create() + p = db.new_player(pname, pclass, ppass) + p.isadmin = True + db.write_players() + + print(f"\n\nOK, wrote you into {bot.datapath(conf.get('dbfile'))}\n") + + +def check_pidfile(pidfile: str) -> None: + """Exit if pid in pidfile is still active.""" + try: + with open(pidfile) as inf: + pid = int(inf.readline().rstrip()) + try: + os.kill(pid, 0) + except OSError: + pass + else: + sys.stderr.write(f"The pidfile at {pidfile} indicates that dawdle is still running at pid {pid}. Remove the file or kill the process.\n") + sys.exit(1) + except FileNotFoundError: + pass + + +def daemonize() -> None: + """Daemonize the process.""" + # python-daemon on pip would do this better. + + # set core limit to 0 + core_limit = (0, 0) + resource.setrlimit(resource.RLIMIT_CORE, core_limit) + os.umask(0) + + signal.signal(signal.SIGCHLD, signal.SIG_IGN) + pid = os.fork() + if pid > 0: + os._exit(0) + os.setsid() + pid = os.fork() + if pid > 0: + os._exit(0) + os.chdir("/") + signal.signal(signal.SIGTSTP, signal.SIG_IGN) + signal.signal(signal.SIGTTIN, signal.SIG_IGN) + signal.signal(signal.SIGTTOU, signal.SIG_IGN) + signal.signal(signal.SIGTERM, signal.SIG_DFL) + os.dup2(os.open(os.devnull, os.O_RDWR), sys.stdin.fileno()) + os.dup2(os.open(os.devnull, os.O_RDWR), sys.stdout.fileno()) + os.dup2(os.open(os.devnull, os.O_RDWR), sys.stderr.fileno()) + + +async def mainloop(client: irc.IRCClient) -> None: + """Connect to servers repeatedly.""" + while not client.quitting: + addr, port = conf.get("servers")[0].split(':') + await client.connect(addr, port) + if client.quitting or not conf.get("reconnect"): + break + await asyncio.sleep(conf.get("reconnect_wait")) + + +def start_bot() -> None: + """Main entry point for bot.""" + conf.init() + + # Legacy IdleRPG logging config + if conf.get("debug"): + dawdlelog.add_handler("DEBUG", "/dev/stderr", "%(asctime)s %(message)s") + + if conf.has("logfile"): + dawdlelog.add_handler(conf.get("loglevel"), + bot.datapath(conf.get("logfile")), + "%(asctime)s %(message)s") + + # DawdleRPG logging config + for logger in conf.get("loggers"): + if len(logger) != 3: + sys.stderr.write(f"Invalid log configuration {logger}.") + sys.exit(2) + dawdlelog.add_handler(logger[0], bot.datapath(logger[1]), logger[2]) + + log.info("Bot %s starting.", bot.VERSION) + + store: bot.GameStorage + if conf.get("store_format") == "idlerpg": + store = bot.IdleRPGGameStorage(bot.datapath(conf.get("dbfile"))) + elif conf.get("store_format") == "sqlite3": + store = bot.Sqlite3GameStorage(bot.datapath(conf.get("dbfile"))) + else: + sys.stderr.write(f"Invalid configuration store_format={conf.get('store_format')}. Configuration must be idlerpg or sqlite3.") + sys.exit(2) + + if conf.get("setup"): + if store.exists(): + store.clear() + first_setup(bot.GameDB(store)) + sys.exit(0) + + db = bot.GameDB(store) + if not db.exists(): + sys.stderr.write("Game db doesn't exist. Run with --setup.") + sys.exit(6) + + db.backup_store() + db.load_state() + + if conf.get("migrate"): + new_store = bot.Sqlite3GameStorage(conf.get("migrate")) + if new_store.exists(): + new_store.clear() + else: + new_store.create() + print(f"Writing {db.count_players()} players.") + new_store.write(db._players.values()) + print(f"Writing quest.") + new_store.update_quest(db._quest) + # Update history from modsfile. + print(f"Writing history.") + names = set(db._players.keys()) + history = [] + with open(bot.datapath(conf.get("modsfile")), "rb") as inf: + for linebytes in inf.readlines(): + try: + line = str(linebytes, encoding='utf8') + except UnicodeDecodeError: + line = str(linebytes, encoding='latin-1') + + match = re.match(r'\[(\d\d)/(\d\d)/(\d\d) (.*?)\] (.*)', line) + if not match: + print(f"Line didn't parse: {line}") + continue + mon, day, year, timeofday, text = match.groups() + for word in re.findall(r"\w+", text): + if word in names: + history.append((word, f"20{year}-{mon}-{day} {timeofday}", text)) + if len(history) > 10000: + new_store.bulk_history_insert(history) + history = [] + new_store.bulk_history_insert(history) + print("Done.") + sys.exit(0) + + if db.count_players() == 0: + sys.stderr.write(f"Zero players in {conf.get('dbfile')}. Do you need to run with --setup?\n") + sys.exit(6) + + if conf.has("pidfile"): + check_pidfile(bot.datapath(conf.get("pidfile"))) + + if conf.get("daemonize"): + daemonize() + + if conf.has("pidfile"): + with open(bot.datapath(conf.get("pidfile")), "w") as ouf: + ouf.write(f"{os.getpid()}\n") + atexit.register(os.remove, bot.datapath(conf.get("pidfile"))) + + mybot = bot.DawdleBot(db) + client = irc.IRCClient(mybot) + + log.info("Starting main async loop.") + asyncio.run(mainloop(client)) + + +if __name__ == "__main__": + start_bot() diff --git a/dawdle/__init__.py b/dawdle/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/dawdle/abstract.py b/dawdle/abstract.py new file mode 100644 index 0000000..168d15a --- /dev/null +++ b/dawdle/abstract.py @@ -0,0 +1,144 @@ +from abc import abstractmethod, ABC +from typing import Iterable, Optional, Set + + +class AbstractClient(ABC): + + class User(ABC): + nick: str + userhost: str + modes: Set[str] + joined: float + pass + + + @abstractmethod + def chanmsg(self, text: str) -> None: + pass + + @abstractmethod + def notice(self, target: str, text: str) -> None: + pass + + @abstractmethod + def match_user(self, nick: str, userhost: str) -> bool: + pass + + @abstractmethod + def bot_has_ops(self) -> bool: + pass + + @abstractmethod + def grant_voice(self, *targets: str) -> None: + pass + + @abstractmethod + def revoke_voice(self, *targets: str) -> None: + pass + + @abstractmethod + def set_channel_voices(self, voiced_nicks: Iterable[str]) -> None: + pass + + @abstractmethod + def writeq_len(self) -> int: + pass + + @abstractmethod + def writeq_bytes(self) -> int: + pass + + @abstractmethod + def clear_writeq(self) -> None: + pass + + + @abstractmethod + def servername(self) -> str: + pass + + + @abstractmethod + def bytes_sent(self) -> int: + pass + + + @abstractmethod + def bytes_received(self) -> int: + pass + + + @abstractmethod + def user_exists(self, nick: str) -> bool: + pass + + @abstractmethod + def nick_userhost(self, nick: str) -> Optional[str]: + pass + + @abstractmethod + def is_bot_nick(self, nick: str) -> bool: + pass + + @abstractmethod + def quit(self, text: str) -> None: + pass + + +class AbstractBot(ABC): + + @abstractmethod + def connected(self, client: AbstractClient) -> None: + pass + + @abstractmethod + def disconnected(self) -> None: + pass + + @abstractmethod + def ready(self) -> None: + pass + + @abstractmethod + def acquired_ops(self) -> None: + pass + + @abstractmethod + def nick_parted(self, user: AbstractClient.User) -> None: + pass + + @abstractmethod + def nick_kicked(self, user: AbstractClient.User) -> None: + pass + + @abstractmethod + def netsplit(self, user: AbstractClient.User) -> None: + pass + + @abstractmethod + def nick_dropped(self, user: AbstractClient.User) -> None: + pass + + @abstractmethod + def nick_quit(self, user: AbstractClient.User) -> None: + pass + + @abstractmethod + def nick_changed(self, user: AbstractClient.User, new_nick: str) -> None: + pass + + @abstractmethod + def private_message(self, user: AbstractClient.User, text: str) -> None: + pass + + @abstractmethod + def channel_message(self, user: AbstractClient.User, text: str) -> None: + pass + + @abstractmethod + def channel_notice(self, user: AbstractClient.User, text: str) -> None: + pass + + +AbstractClient.register(tuple) +AbstractBot.register(tuple) diff --git a/dawdle/bot.py b/dawdle/bot.py new file mode 100755 index 0000000..2e39c54 --- /dev/null +++ b/dawdle/bot.py @@ -0,0 +1,2547 @@ +# Copyright 2021 Daniel Lowe +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import asyncio +import collections +import crypt +import datetime +import itertools +import math +import os +import os.path +import re +import shutil +import sqlite3 +import sys +import time + +from hmac import compare_digest as compare_hash + +from dawdle import abstract +from dawdle import chunk +from dawdle import conf +from dawdle import rand +from dawdle.log import log +from typing import cast, Any, Dict, List, Literal, Iterable, Optional, Set, Tuple + + +VERSION = "1.0.0" + +start_time = int(time.time()) + +def plural(num: int, singlestr: str='', pluralstr: str='s') -> str: + """Return singlestr when num is 1, otherwise pluralstr.""" + if num == 1: + return singlestr + return pluralstr + + +def duration(secs: int) -> str: + """Return description of duration marked in seconds.""" + d, secs = int(secs / 86400), secs % 86400 + h, secs = int(secs / 3600), secs % 3600 + m, secs = int(secs / 60), secs % 60 + return C("duration", f"{d} day{plural(d)}, {h:02d}:{m:02d}:{int(secs):02d}") + + +def datapath(path: str) -> str: + """Return path relative to datadir unless path is absolute.""" + if os.path.isabs(path): + return path + return os.path.join(conf.get("datadir"), path) + + +def CC(color: str) -> str: + """Return color code if colors are enabled.""" + if not conf.get("color"): + return "" + colors = {"white": 0, "black": 1, "navy": 2, "green": 3, "red": 4, "maroon": 5, "purple": 6, "olive": 7, "yellow": 8, "lgreen": 9, "teal": 10, "cyan": 11, "blue": 12, "magenta": 13, "gray": 14, "lgray": 15, "default": 99} + if color not in colors: + return f"[{color}?]" + return f"\x03{colors[color]:02d},99" + + +def C(field: str='', text: str='') -> str: + """Return colorized version of text according to config field. + + If text is specified, returns the colorized version with a formatting reset. + If text is not specified, returns just the color code. + If field is not specified, returns just a formatting reset. + """ + if not conf.get("color"): + return text + if field == "": + return "\x0f" + conf_field = f"{field}color" + if not conf.has(conf_field): + return f"[{conf_field}?]" + text + if text == "": + return CC(conf.get(conf_field)) + return CC(conf.get(conf_field)) + text + "\x0f" + + +class Item(object): + """Represents an item held by a player.""" + + SLOTS = ['ring', 'amulet', 'charm', 'weapon', 'helm', 'tunic', 'gloves', 'leggings', 'shield', 'boots'] + DESC = { + 'ring': 'ring', + 'amulet': 'amulet', + 'charm': 'charm', + 'weapon': 'weapon', + 'helm': 'helm', + 'tunic': 'tunic', + 'shield': 'shield', + 'gloves': 'pair of gloves', + 'leggings': 'set of leggings', + 'boots': 'pair of boots' + } + + + def __init__(self, level: int, name: str): + self.level = level + self.name = name + + def __eq__(self, o: object) -> bool: + if not isinstance(o, Item): + return NotImplemented + return self.level == o.level and self.name == o.name + + +class Ally(object): + name: str + baseclass: str + fullclass: str + alignment: str + level: int + nextlvl: int + + @staticmethod + def time_to_next_level(level: int) -> int: + """Returns seconds required to advance levels.""" + base, step, maxexplvl = conf.get("allylvlbase"), conf.get("allylvlstep"), conf.get("allymaxexplvl") + # time is exponential until maxexplvl, then logarithmic + if level > maxexplvl: + return int(base * step ** maxexplvl + (86400 * math.log(level - maxexplvl + 1, step))) + return int(base * step ** level) + + + def __init__(self, name: str, baseclass: str, fullclass: str, alignment: str, level: int, nextlvl: int) -> None: + self.name = name + self.baseclass = baseclass + self.fullclass = fullclass + self.alignment = alignment + self.level = level + self.nextlvl = nextlvl + + +class Player(object): + """Represents a player of the dawdlerpg game.""" + + name: str + cclass: str + pw: str + email: str + isadmin: bool + level: int + nextlvl: int + online: bool + nick: str + userhost: str + idled: int + posx: int + posy: int + penmessage: int + pennick: int + penpart: int + penkick: int + penquit: int + pendropped: int + penquest: int + penlogout: int + created: datetime.datetime + lastlogin: datetime.datetime + alignment: str + items: Dict[str, Item] + allies: Dict[str, Ally] + + @staticmethod + def time_to_next_level(level: int) -> int: + """Returns seconds required to advance levels.""" + base, step, maxexplvl = conf.get("rpbase"), conf.get("rpstep"), conf.get("rpmaxexplvl") + # time is exponential until maxexplvl, then logarithmic + if level > maxexplvl: + return int(base * step ** maxexplvl + (86400 * math.log(level - maxexplvl + 1, step))) + return int(base * step ** level) + + + @classmethod + def from_dict(cls: object, d: Dict[str, Any]) -> "Player": + """Returns a player with its values set to the dict's.""" + p = Player() + for k,v in d.items(): + setattr(p, k, v) + p.items = dict() + p.allies = dict() + return p + + @staticmethod + def new_player(pname: str, pclass: str, ppass: str) -> "Player": + """Initialize a new player.""" + now = datetime.datetime.now().replace(microsecond=0) + p = Player() + # name of account + p.name = pname + # name of character class - affects nothing + p.cclass = pclass + # hashed password + p.set_password(ppass) + # email address + p.email = "" + # admin bit + p.isadmin = False + # level + p.level = 0 + # time in seconds to next level + p.nextlvl = Player.time_to_next_level(0) + # whether or not the account is online + p.online = False + # IRC nick if online + p.nick = "" + # Userhost if online - used to automatically log players back in + p.userhost = "" + # total seconds idled + p.idled = 0 + # X position on map + p.posx = 0 + # Y position on map + p.posy = 0 + # Total penalties from messaging + p.penmessage = 0 + # Total penalties from changing nicks + p.pennick = 0 + # Total penalties from leaving the channel + p.penpart = 0 + # Total penalties from being kicked + p.penkick = 0 + # Total penalties from quitting + p.penquit = 0 + # Total penalties from dropping connection + p.pendropped = 0 + # Total penalties from losing quests + p.penquest = 0 + # Total penalties from using the logout command + p.penlogout = 0 + # Time created + p.created = now + # Last time logged in + p.lastlogin = now + # Character alignment - should only be n, g, or e + p.alignment = "n" + # Items held by player + p.items = dict() + # Allies associated with player + p.allies = dict() + + return p + + + def set_password(self, ppass: str) -> None: + """Sets the password field with a hashed value.""" + self.pw = crypt.crypt(ppass, crypt.mksalt()) + + + def item_level(self, slot: str) -> int: + if slot not in self.items: + return 0 + return self.items[slot].level + + + def item_name(self, slot: str) -> str: + if slot not in self.items: + return "" + return self.items[slot].name + + + def acquire_item(self, slot: str, level: int, name: str='') -> None: + """Acquire an item.""" + self.items[slot] = Item(level, name) + + + def swap_items(self, o: "Player", slot: str) -> None: + """Swap items of SLOT with the other player O.""" + myitem, otheritem = self.items.get(slot), o.items.get(slot) + if myitem is None and otheritem is not None: + del o.items[slot] + elif myitem is not None: + o.items[slot] = myitem + if otheritem is None and myitem is not None: + del self.items[slot] + elif otheritem is not None: + self.items[slot] = otheritem + + + def itemsum(self) -> int: + """Add up the power of all the player's items""" + return sum([item.level for item in self.items.values()]) + + + def battleitemsum(self) -> int: + """ + Add up item power for battle. + + Good players get a boost, and evil players get a penalty. + """ + itemsum = self.itemsum() + itemsum += sum([ally.level for ally in self.allies.values()]) + + if self.alignment == 'e': + return int(itemsum * conf.get("evil_battle_pct")/100) + if self.alignment == 'g': + return int(itemsum * conf.get("good_battle_pct")/100) + return itemsum + + +class Quest(object): + """Class for tracking quests.""" + questor_names: List[str] + questors: List[Player] + mode: int + text: str + qtime: Optional[int] + stage: Optional[int] + dests: List[Tuple[int, int]] + + def __init__(self) -> None: + # TODO: This is an ugly hack to support deserialization. + self.questor_names = [] + self.questors = [] + self.mode = 0 + self.text = "" + self.qtime = None + self.stage = None + self.dests = [] + + +class GameStorage(object): + """Interface for a GameDB backend.""" + + def create(self) -> None: + """Create a new store.""" + raise NotImplementedError + + def exists(self) -> bool: + """Return True if a store exists.""" + raise NotImplementedError + + def backup(self) -> None: + """Backup the store to a backup directory.""" + raise NotImplementedError + + def clear(self) -> None: + """Reinitialize the store.""" + raise NotImplementedError + + def readall(self) -> Iterable[Player]: + """Returns all players read from store.""" + raise NotImplementedError + + def write(self, players: Iterable[Player]) -> None: + """Write the given players to the store.""" + raise NotImplementedError + + def close(self) -> None: + """Close the store.""" + raise NotImplementedError + + def new(self, player: Player) -> None: + """Create a new player record.""" + raise NotImplementedError + + def rename_player(self, old: str, new: str) -> None: + """Rename a player in the store.""" + raise NotImplementedError + + def delete_player(self, pname: str) -> None: + """Removes a player from the store.""" + raise NotImplementedError + + def add_history(self, players: List[str], text: str) -> None: + """Adds history text for the players.""" + raise NotImplementedError + + def read_quest(self) -> Optional[Quest]: + """Returns stored quest object or None.""" + raise NotImplementedError + + def update_quest(self, quest: Optional[Quest]) -> None: + """Updates quest information in store.""" + raise NotImplementedError + + +class IdleRPGGameStorage(GameStorage): + """Implements a GameStorage compatible with the IdleRPG db. + + Since the IdleRPG db is a tsv file, we can't reasonably update it + piecemeal, so the playerbase is cached during reads, and written + in its entirety on writes. The player objects are shared between + the cache and the GameDB. + + """ + + IRPG_FIELDS = ["username", "pass", "is admin", "level", "class", "next ttl", "nick", "userhost", "online", "idled", "x pos", "y pos", "pen_mesg", "pen_nick", "pen_part", "pen_kick", "pen_quit", "pen_quest", "pen_logout", "created", "last login", "amulet", "charm", "helm", "boots", "gloves", "ring", "leggings", "shield", "tunic", "weapon", "alignment"] + IRPG_FIELD_COUNT = len(IRPG_FIELDS) + + # Instead of names, idlerpg decided to tack on codes to the number. + ITEMCODES = { + "Mattt's Omniscience Grand Crown":"a", + "Juliet's Glorious Ring of Sparkliness":"h", + "Res0's Protectorate Plate Mail":"b", + "Dwyn's Storm Magic Amulet":"c", + "Jotun's Fury Colossal Sword":"d", + "Drdink's Cane of Blind Rage":"e", + "Mrquick's Magical Boots of Swiftness":"f", + "Jeff's Cluehammer of Doom":"g" + } + + _dbpath: str + _cache: List[Player] + + def _code_to_item(self, s: str) -> Tuple[int, str]: + """Converts an IdleRPG item code to a tuple of (level, name).""" + match = re.match(r"(\d+)(.?)", s) + if not match: + log.error(f"invalid item code: {s}") + return (int(s), "") + lvl = int(match[1]) + if match[2]: + return (lvl, [k for k,v in IdleRPGGameStorage.ITEMCODES.items() if v == match[2]][0]) + return (lvl, "") + + + def _item_to_code(self, level: int, name: str) -> str: + """Converts an item level and name to an IdleRPG item code.""" + return f"{level}{IdleRPGGameStorage.ITEMCODES.get(name, '')}" + + + def __init__(self, dbpath: str): + self._dbpath = dbpath + self._cache = [] + + + def create(self) -> None: + """Creates a new IdleRPG db.""" + self.write([]) + + + def exists(self) -> bool: + """Returns true if the db file exists.""" + return os.path.exists(self._dbpath) + + + def close(self) -> None: + """Does nothing - no need to close the idlerpg file.""" + pass + + + def backup(self) -> None: + """Backs up database to a directory.""" + os.makedirs(datapath(conf.get("backupdir")), exist_ok=True) + backup_path = os.path.join(datapath(conf.get("backupdir")), + f"{time.strftime('%Y-%m-%dT%H:%M:%S')}-{os.path.basename(conf.get('dbfile'))}") + shutil.copyfile(self._dbpath, backup_path) + + + def _player_to_record(self, p: Player) -> str: + """Converts the player to an IdleRPG db record.""" + return "\t".join([ + p.name, + p.pw, + "1" if p.isadmin else "0", + str(p.level), + p.cclass, + str(p.nextlvl), + p.nick, + p.userhost, + "1" if p.online else "0", + str(p.idled), + str(p.posx), + str(p.posy), + str(p.penmessage), + str(p.pennick), + str(p.penpart), + str(p.penkick), + str(p.penquit + p.pendropped), + str(p.penquest), + str(p.penlogout), + str(int(p.created.timestamp())), + str(int(p.lastlogin.timestamp())), + self._item_to_code(p.item_level("amulet"), p.item_name("amulet")), + self._item_to_code(p.item_level("charm"), p.item_name("charm")), + self._item_to_code(p.item_level("helm"), p.item_name("helm")), + self._item_to_code(p.item_level("boots"), p.item_name("boots")), + self._item_to_code(p.item_level("gloves"), p.item_name("gloves")), + self._item_to_code(p.item_level("ring"), p.item_name("ring")), + self._item_to_code(p.item_level("leggings"), p.item_name("leggings")), + self._item_to_code(p.item_level("shield"), p.item_name("shield")), + self._item_to_code(p.item_level("tunic"), p.item_name("tunic")), + self._item_to_code(p.item_level("weapon"), p.item_name("weapon")), + str(p.alignment) + ]) + "\n" + + + def readall(self) -> Iterable[Player]: + """Reads all the players into a dict.""" + self._cache = [] + with open(self._dbpath) as inf: + for line in inf.readlines(): + if re.match(r'\s*(?:#|$)', line): + continue + parts = line.rstrip().split("\t") + if len(parts) != IdleRPGGameStorage.IRPG_FIELD_COUNT: + log.critical("line corrupt in player db - %d fields: %s", len(parts), repr(line)) + sys.exit(-1) + + # This makes a mapping from irpg field to player field. + d = dict(zip(["name", "pw", "isadmin", "level", "cclass", "nextlvl", "nick", "userhost", "online", "idled", "posx", "posy", "penmessage", "pennick", "penpart", "penkick", "penquit", "penquest", "penlogout", "created", "lastlogin", "amulet", "charm", "helm", "boots", "gloves", "ring", "leggings", "shield", "tunic", "weapon", "alignment"], parts)) + # convert items + items = dict() + for i in Item.SLOTS: + level, name = self._code_to_item(d[i]) + if level > 0: + items[i] = Item(level, name) + del d[i] + + # convert int fields + for f in ["level", "nextlvl", "idled", "posx", "posy", "penmessage", "pennick", "penpart", "penkick", "penquit", "penquest", "penlogout", "created", "lastlogin"]: + d[f] = round(float(d[f])) # type:ignore + # convert boolean fields + for f in ["isadmin", "online"]: + d[f] = (d[f] == '1') # type:ignore + + d['email'] = "" # needed to pass testing + + d['pendropped'] = 0 # type: ignore + + d['created'] = datetime.datetime.fromtimestamp(int(d['created'])) # type:ignore + d['lastlogin'] = datetime.datetime.fromtimestamp(int(d['lastlogin'])) # type:ignore + + p = Player.from_dict(d) + p.items = items + self._cache.append(p) + return self._cache + + + def write(self, players: Iterable[Player]) -> None: + """Writes players to an IdleRPG db.""" + with open(self._dbpath, "w") as ouf: + ouf.write("# " + "\t".join(IdleRPGGameStorage.IRPG_FIELDS) + "\n") + for p in self._cache: + ouf.write(self._player_to_record(p)) + + + def new(self, p: Player) -> None: + """Creates a new player in the db.""" + self._cache.append(p) + with open(self._dbpath, "a") as ouf: + ouf.write(self._player_to_record(p)) + + + def rename_player(self, old_name: str, new_name: str) -> None: + """Renames a player in the db.""" + # Presumably the player in the cache has changed its name, so + # just write out the file. + self.write([]) + + + def delete_player(self, pname:str) -> None: + """Removes a player from the db.""" + self._cache = [p for p in self._cache if p.name != pname] + self.write([]) + + + def add_history(self, pnames: List[str], text: str) -> None: + """Adds history text for the player. + + IdleRPG dumps all history into a single modsfile, which is + then grepped for by the website. + + """ + with open(datapath(conf.get("modsfile")), "a") as ouf: + ouf.write(f"[{time.strftime('%m/%d/%y %H:%M:%S')}] {text}\n") + + + def read_quest(self) -> Optional[Quest]: + """Returns stored Quest object or None.""" + if not conf.get("writequestfile"): + return None + if os.stat(datapath(conf.get("questfilename"))).st_size == 0: + return None + + with open(datapath(conf.get("questfilename"))) as inf: + q = Quest() + for line in inf.readlines(): + key, val = line.split(' ', 2) + if key == "T": + q.text = val + elif key == "Y": + q.mode = int(val) + elif key == "S": + if q.mode == 1: + q.qtime = int(val) + else: + q.stage = int(val) + elif key == "P": + for pair in chunk.chunk(val.split(' '), 2): + q.dests.append((int(pair[0]), int(pair[1]))) + elif re.match("P\d", key): + q.questor_names.append(val) + return q + + + def update_quest(self, quest: Optional[Quest]) -> None: + """Updates quest information in store.""" + if not conf.get("writequestfile"): + return + with open(datapath(conf.get("questfilename")), 'w') as ouf: + if not quest: + # leave behind an empty quest file + return + + ouf.write(f"T {quest.text}\n" + f"Y {quest.mode}\n") + + if quest.mode == 1: + ouf.write(f"S {quest.qtime}\n") + elif quest.mode == 2: + ouf.write(f"S {quest.stage:d}\n" + f"P {' '.join([' '.join([str(c) for c in p]) for p in quest.dests])}\n") + + ouf.write(f"P1 {quest.questors[0].name}\n" + f"P2 {quest.questors[1].name}\n" + f"P3 {quest.questors[2].name}\n" + f"P4 {quest.questors[3].name}\n") + + +class Sqlite3GameStorage(GameStorage): + """Player store using sqlite3.""" + + FIELDS = ["name", "cclass", "pw", "email", "isadmin", "level", "nextlvl", "nick", "userhost", "online", "idled", "posx", "posy", "penmessage", "pennick", "penpart", "penkick", "penquit", "pendropped", "penquest", "penlogout", "created", "lastlogin", "alignment"] + + + _dbpath: str + _db: Optional[sqlite3.Connection] + + @staticmethod + def dict_factory(cursor: sqlite3.Cursor, row: sqlite3.Row) -> Dict[str, str]: + """Converts a sqlite3 row into a dict.""" + d = {} + for idx, col in enumerate(cursor.description): + d[col[0]] = row[idx] + return d + + + def _connect(self) -> sqlite3.Connection: + """Connects to the sqlite3 db if not already connected.""" + if self._db is None: + self._db = sqlite3.connect(self._dbpath) + self._db.row_factory = Sqlite3GameStorage.dict_factory + + return self._db + + + def __init__(self, dbpath: str): + self._dbpath = dbpath + self._db = None + + + def create(self) -> None: + """Initializes a new db.""" + with self._connect() as con: + con.execute(f"create table dawdle_player ({','.join(Sqlite3GameStorage.FIELDS)})") + con.execute('create table dawdle_item (id, owner_id, slot, level, name, CONSTRAINT "unique_item_owner_slot" UNIQUE ("owner_id", "slot"))') + con.execute('create table dawdle_ally ("id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, "slot" varchar(10) NOT NULL, "name" varchar(50) NOT NULL, "baseclass" varchar(30) NOT NULL, "fullclass" varchar(30) NOT NULL, "alignment" varchar(1) NOT NULL, "level" integer NOT NULL, "nextlvl" integer NOT NULL, "owner_id" varchar(50) NOT NULL REFERENCES "dawdle_player" ("name") DEFERRABLE INITIALLY DEFERRED)') + con.execute("create table dawdle_history (id, owner_id, time, text)") + con.execute("create table dawdle_quest (id, mode, p1, p2, p3, p4, text, qtime, stage, dest1x, dest1y, dest2x, dest2y)") + con.execute("insert into dawdle_quest (mode) values (0)") + + + def clear(self) -> None: + """Destroys all data in the db without deleting it.""" + with self._connect() as con: + con.execute("delete from dawdle_player") + con.execute("delete from dawdle_item") + con.execute("delete from dawdle_ally") + con.execute("delete from dawdle_history") + con.execute("delete from dawdle_quest") + + + def exists(self) -> bool: + """Returns True if the db exists.""" + return os.path.exists(self._dbpath) + + + def backup(self) -> None: + """Backs up database to a directory.""" + os.makedirs(datapath(conf.get("backupdir")), exist_ok=True) + with self._connect() as con: + backup_path = os.path.join(datapath(conf.get("backupdir")), + f"{time.strftime('%Y-%m-%dT%H:%M:%S')}-{os.path.basename(conf.get('dbfile'))}") + with sqlite3.connect(backup_path) as backup_db: + con.backup(backup_db) + + + def readall(self) -> Iterable[Player]: + """Reads all the players from the db.""" + players = {} + with self._connect() as con: + cur = con.execute("select * from dawdle_player") + for d in cur.fetchall(): + d["created"] = datetime.datetime.fromisoformat(d["created"]) + d["lastlogin"] = datetime.datetime.fromisoformat(d["lastlogin"]) + players[d["name"]] = Player.from_dict(d) + cur = con.execute("select * from dawdle_item") + for d in cur.fetchall(): + players[d["owner_id"]].items[d["slot"]] = Item(d["level"], d["name"]) + cur = con.execute("select * from dawdle_ally") + for d in cur.fetchall(): + players[d["owner_id"]].allies[d["slot"]] = Ally(d["name"], d["baseclass"], d["fullclass"], d["alignment"], int(d["level"]), int(d["nextlvl"])) + return players.values() + + + def write(self, players: Iterable[Player]) -> None: + """Writes player information into the db.""" + with self._connect() as con: + sql_fields = ",".join(Sqlite3GameStorage.FIELDS) + p_fields = ",".join([":"+k for k in Sqlite3GameStorage.FIELDS]) + con.executemany(f"replace into dawdle_player ({sql_fields}) values ({p_fields})", + [vars(p) for p in players]) + item_updates = [] + for p in players: + for slot, item in p.items.items(): + item_updates.append((p.name, slot, item.level, item.name)) + con.executemany("replace into dawdle_item (owner_id, slot, level, name) values (:owner, :slot, :level, :name)", + item_updates) + for p in players: + ally_updates = [] + for slot, ally in p.allies.items(): + ally_updates.append((p.name, slot, ally.name, ally.baseclass, ally.fullclass, ally.alignment, ally.level, ally.nextlvl)) + # Unlike items, allies can be removed. Not much to do + # here but remove all allies and insert them back. + con.execute("delete from dawdle_ally where owner_id=:owner_id", (p.name,)) + con.executemany("insert into dawdle_ally (owner_id, slot, name, baseclass, fullclass, alignment, level, nextlvl) values (:owner, :slot, :name, :baseclass, :fullclass, :alignment, :level, :nextlvl)", + ally_updates) + + + def close(self) -> None: + """Finish using db.""" + assert self._db is not None + self._db.close() + + + def new(self, p: Player) -> None: + """Create new character in db.""" + with self._connect() as con: + d = vars(p) + con.execute(f"insert into dawdle_player ({','.join(Sqlite3GameStorage.FIELDS)}) values ({('?, ' * len(Sqlite3GameStorage.FIELDS))[:-2]})", + [d[k] for k in Sqlite3GameStorage.FIELDS]) + con.commit() + + + def rename_player(self, old_name: str, new_name: str) -> None: + """Rename player in db.""" + with self._connect() as con: + con.execute("update dawdle_player set name = ? where name = ?", (new_name, old_name)) + con.commit() + + + def delete_player(self, pname: str) -> None: + """Remove player from db.""" + with self._connect() as con: + con.execute("delete from dawdle_history where owner_id = ?", (pname,)) + con.execute("delete from dawdle_item where owner_id = ?", (pname,)) + con.execute("delete from dawdle_player where name = ?", (pname,)) + con.commit() + + + def bulk_history_insert(self, history: List[Tuple[str, str, str]]) -> None: + """Inserts (owner, time, text) tuples into history db.""" + with self._connect() as con: + con.executemany("insert into dawdle_history (owner_id, time, text) values (?, datetime(?), ?)", history) + + + def add_history(self, pnames: List[str], text: str, time: str='now') -> None: + """Adds history text for the player.""" + with self._connect() as con: + con.executemany("insert into dawdle_history (owner_id, time, text) values (?, datetime(?), ?)", + [(pname, time, text) for pname in pnames]) + + + def read_quest(self) -> Optional[Quest]: + with self._connect() as con: + cur = con.execute("select * from dawdle_quest") + res = cur.fetchone() + if not res: + log.debug("No quest object found. Inserting blank quest object.") + # We should always have a quest object + con.execute("insert into dawdle_quest (mode, p1, p2, p3, p4, text, qtime, stage, dest1x, dest1y, dest2x, dest2y) values (0, '', '', '', '', '', 0, 0, 0, 0, 0, 0)") + return None + if res['mode'] == 0: + log.debug("No quest loaded (mode == 0)") + return None + q = Quest() + q.questor_names = [res['p1'], res['p2'], res['p3'], res['p4']] + q.text = res['text'] + q.mode = int(res['mode']) + if q.mode == 1: + q.qtime = int(res['qtime']) + if q.mode == 2: + q.stage = int(res['stage']) + q.dests = [(int(res['dest1x']), int(res['dest1y'])), (int(res['dest2x']), int(res['dest2y']))] + + return q + + + def update_quest(self, quest: Optional[Quest]) -> None: + """Updates quest information in store.""" + with self._connect() as con: + if not quest: + con.execute("update dawdle_quest set mode = 0") + con.commit() + return + if quest.mode == 1: + con.execute("update dawdle_quest set mode=?, text=?, p1=?, p2=?, p3=?, p4=?, qtime=?", + (quest.mode, + quest.text, + quest.questors[0].name, + quest.questors[1].name, + quest.questors[2].name, + quest.questors[3].name, + quest.qtime)) + else: + con.execute("update dawdle_quest set mode=?, text=?, p1=?, p2=?, p3=?, p4=?, stage=?, dest1x=?, dest1y=?, dest2x=?, dest2y=?", + (quest.mode, + quest.text, + quest.questors[0].name, + quest.questors[1].name, + quest.questors[2].name, + quest.questors[3].name, + quest.stage, + quest.dests[0][0], + quest.dests[0][1], + quest.dests[1][0], + quest.dests[1][1])) + con.commit() + + +class GameDB(object): + """Class to manage the in-memory game state.""" + + _store: GameStorage + _players: Dict[str, Player] + _quest: Optional[Quest] + + def __init__(self, store: GameStorage): + self._store = store + self._players = {} + self._quest = None + + def __getitem__(self, pname: str) -> Player: + """Return a player by name.""" + return self._players[pname] + + + def __contains__(self, pname: str) -> bool: + """Returns True if the player is in the db.""" + return pname in self._players + + + def create(self) -> None: + """Creates a new database from scratch.""" + self._store.create() + + + def clear(self) -> None: + """Reinitializes the new database.""" + self._store.clear() + + + def close(self) -> None: + """Close the underlying db. Used for testing.""" + self._store.close() + + + def exists(self) -> bool: + """Returns True if the underlying store exists.""" + return self._store.exists() + + + def backup_store(self) -> None: + """Backup store into another file.""" + self._store.backup() + + + def load_state(self) -> None: + """Load all players from database into memory""" + for p in self._store.readall(): + self._players[p.name] = p + self._quest = self._store.read_quest() + if self._quest: + log.info("Loaded quest mode %s", self._quest.mode) + self._quest.questors = [] + for pname in self._quest.questor_names: + if pname in self._players: + self._quest.questors.append(self._players[pname]) + else: + log.info("No quest loaded.") + + + def write_players(self, players: Optional[List[Player]]=None) -> None: + """Write player objects into database. Defaults to all.""" + if players is None: + self._store.write(self._players.values()) + else: + self._store.write(players) + + + def new_player(self, pname: str, pclass: str, ppass: str) -> Player: + """Create a new player with the name, class, and password.""" + if pname in self._players: + raise KeyError + + p = Player.new_player(pname, pclass, ppass) + self._players[pname] = p + self._store.new(p) + + return p + + + def rename_player(self, old_name: str, new_name: str) -> None: + """Rename a player in the db.""" + self._players[new_name] = self._players[old_name] + self._players[new_name].name = new_name + self._players.pop(old_name, None) + self._store.rename_player(old_name, new_name) + + + def delete_player(self, pname: str) -> None: + """Remove a player from the db.""" + self._players.pop(pname) + self._store.delete_player(pname) + + + def from_user(self, user: abstract.AbstractClient.User) -> Optional[Player]: + """Find the given online player with the irc user.""" + # The "userhost" includes the nick so it's still matching the + # nick. + for p in self._players.values(): + if p.online and p.userhost == user.userhost: + return p + return None + + + def add_history(self, players: List[Player], text: str) -> None: + """Add text to the players' history.""" + self._store.add_history([p.name for p in players], text) + + + def update_quest(self, quest: Optional[Quest]) -> None: + self._quest = quest + self._store.update_quest(quest) + + + def check_login(self, pname: str, ppass: str) -> bool: + """Return True if name and password are a valid login.""" + result = (pname in self._players) + result = result and compare_hash(self._players[pname].pw, crypt.crypt(ppass, self._players[pname].pw)) + return result + + + def count_players(self) -> int: + """Return number of all players registered.""" + return len(self._players) + + + def online_players(self) -> List[Player]: + """Return all active, online players.""" + return [p for p in self._players.values() if p.online] + + + def max_player_power(self) -> int: + """Return the itemsum of the most powerful player.""" + return max([p.itemsum() for p in self._players.values()]) + + + def top_players(self) -> List[Player]: + """Return the top three players.""" + return sorted(self._players.values(), key=lambda p: (-p.level, p.nextlvl))[:3] + + + def inactive_since(self, expire: int) -> List[Player]: + """Return all players that have been inactive since a point in time.""" + return [p for p in self._players.values() if not p.online and p.lastlogin.timestamp() < expire] + + +# SpecialItem is a configuration tuple for specifying special items. +SpecialItem = collections.namedtuple('SpecialItem', ['minlvl', 'itemlvl', 'lvlspread', 'kind', 'name', 'flavor']) + + +class DawdleBot(abstract.AbstractBot): + """Class implementing the game.""" + + # Commands in ALLOWALL can be used by anyone. + # Commands in ALLOWPLAYERS can only be used by logged-in players + # All other commands are admin-only + ALLOWALL = ["help", "info", "login", "register", "quest", "version"] + ALLOWPLAYERS = ["align", "logout", "mname", "newpass", "removeme", "status", "whoami"] + CMDHELP = { + "help": "help [] - Display help on commands.", + "login": "login - Login to your account.", + "register": "register - Create a new character.", + "quest": "quest - Display the current quest, if any.", + "version": "Display the version of the bot.", + "align": "align good|neutral|evil - Change your character's alignment.", + "logout": "logout - Log out of your account. You will be penalized!", + "newpass": "newpass - Change your account's password.", + "email": "email [] - set the email address for your account.", + "removeme": "removeme - Delete your character.", + "status": "status - Show bot status.", + "whoami": "whoami - Shows who you are logged in as.", + "announce": "announce - Sends a message to the channel.", + "raw": "send commands RAW DOG.", + "backup": "backup - Backup the player db.", + "chclass": "chclass - Change the character class of the account.", + "chpass": "chpass - Change the password of the account.", + "chuser": "chuser - Change the name of the account.", + "clearq": "clearq - Clear the sending queue of the bot.", + "del": "del - Delete the account.", + "deladmin": "deladmin - Remove admin privileges from account.", + "delold": "delold <# of days> - Remove all accounts older than a number of days.", + "die": "die - Shut down the bot.", + "jump": "jump - Switch to a different IRC server.", + "mkadmin": "mkadmin - Grant admin privileges to the account.", + "pause": "pause - Toggle pause mode.", + "rehash": "rehash - Not sure.", + "reloaddb": "reloaddb - Reload the player database.", + "restart": "restart - Restarts the bot.", + "silent": "silent - Sets silentmode to the given mode.", + "hog": "hog - Triggers the Hand of God.", + "push": "push - Adds seconds to the next level of account.", + "trigger": "trigger calamity|godsend|hog|teambattle|evilness|goodness|battle|mount - Triggers the event.", + "mname": "mname - Sets the name of your mount." + } + + + _irc: Optional[abstract.AbstractClient] + _db: GameDB + _state: Literal["disconnected", "connected", "ready"] + _qtimer: int + _silence: Set[str] + _pause: bool + _last_reg_time: float + _events: Dict[str, List[str]] + _events_loaded: int + _new_accounts: int + _gametick_task: Optional[asyncio.Task] # type: ignore + + + def __init__(self, db: GameDB) -> None: + self._irc = None # irc connection + self._db = db # the player database + self._state = 'disconnected' # connected, disconnected, or ready + self._qtimer = 0 # time until next quest + self._silence = set() # can have 'chanmsg' or 'notice' to silence them + self._pause = False # prevents game events from happening when True + self._last_reg_time = 0 + self._events = {} # pre-parsed contents of events file + self._events_loaded = 0 # time the events file was loaded, to detect file changes + self._new_accounts = 0 # number of new accounts created since startup + self._gametick_task = None + + + def connected(self, irc: abstract.AbstractClient) -> None: + """Called when connected to IRC.""" + self._irc = irc + self._state = 'connected' + + + def chanmsg(self, text: str) -> None: + """Send a message to the bot channel.""" + assert self._irc is not None + if 'chanmsgs' in self._silence: + return + self._irc.chanmsg(text) + + + def logchanmsg(self, players: List[Player], text: str) -> None: + """Send a message to the bot channel and attach it to each player's history.""" + assert self._irc is not None + if 'chanmsgs' in self._silence: + return + self._irc.chanmsg(text) + # strip color codes for saving to file. + text = re.sub(r"\x0f|\x03\d\d?(?:,\d\d?)?", "", text) + self._db.add_history(players, text) + + + def notice(self, nick: str, text: str) -> None: + """Send a notice to a given nick.""" + assert self._irc is not None + if 'notices' in self._silence: + return + self._irc.notice(nick, text) + + + def _autologin(self) -> None: + """Check online players for autologin.""" + autologin = [] + for p in self._db.online_players(): + if self._irc.match_user(p.nick, p.userhost): + autologin.append(p.name) + else: + p.lastlogin = datetime.datetime.now() + self._db.write_players() + if autologin: + self.chanmsg(f"{len(autologin)} user{plural(len(autologin))} automatically logged in; accounts: {', '.join(autologin)}") + if self._irc.bot_has_ops(): + self.acquired_ops() + else: + self.chanmsg("0 users qualified for auto login.") + + + def ready(self) -> None: + """Called when bot has finished joining channel.""" + assert self._irc is not None + self._state = 'ready' + self._autologin() + self.refresh_events() + self._gametick_task = asyncio.create_task(self.gametick_loop()) + self._qtimer = int(time.time()) + rand.randint('qtimer_init', + conf.get("quest_interval_min"), + conf.get("quest_interval_max")) + + + def acquired_ops(self) -> None: + """Called when the bot has acquired ops status on the channel.""" + assert self._irc is not None + if not conf.get("voiceonlogin") or self._state != 'ready': + return + + self._irc.set_channel_voices([p.nick for p in self._db.online_players()]) + + + def disconnected(self) -> None: + """Called when the bot has been disconnected.""" + self._irc = None + self._state = 'disconnected' + if self._gametick_task: + self._gametick_task.cancel() + self._gametick_task = None + + + def private_message(self, user: abstract.AbstractClient.User, text: str) -> None: + """Called when private message received.""" + assert self._irc is not None + if text == '': + return + if self._state != "ready": + self.notice(user.nick, "The bot isn't ready yet.") + return + + parts = text.split(' ', 1) + cmd = parts[0].lower() + if len(parts) == 2: + args = parts[1] + else: + args = '' + player = self._db.from_user(user) + if cmd in DawdleBot.ALLOWPLAYERS: + if not player: + self.notice(user.nick, "You are not logged in.") + return + elif cmd not in DawdleBot.ALLOWALL: + if player is None or not player.isadmin: + self.notice(user.nick, f"You cannot do '{cmd}'.") + return + if hasattr(self, f'cmd_{cmd}'): + getattr(self, f'cmd_{cmd}')(player, user.nick, args) + else: + self.notice(user.nick, f"'{cmd} isn't actually a command.") + + + def channel_message(self, user: abstract.AbstractClient.User, text: str) -> None: + """Called when channel message received.""" + player = self._db.from_user(user) + if player: + self.penalize(player, "message", text) + + + def channel_notice(self, user: abstract.AbstractClient.User, text: str) -> None: + """Called when channel notice received.""" + player = self._db.from_user(user) + if player: + self.penalize(player, "message", text) + + + def nick_changed(self, user: abstract.AbstractClient.User, new_nick: str) -> None: + """Called when someone on channel changed nick.""" + player = self._db.from_user(user) + if player: + player.nick = new_nick + self.penalize(player, "nick") + + + def nick_parted(self, user: abstract.AbstractClient.User) -> None: + """Called when someone left the channel.""" + player = self._db.from_user(user) + if player: + self.penalize(player, "part") + player.online = False + player.lastlogin = datetime.datetime.now() + self._db.write_players([player]) + + + def netsplit(self, user: abstract.AbstractClient.User) -> None: + """Called when someone was netsplit.""" + player = self._db.from_user(user) + if player: + player.lastlogin = datetime.datetime.now() + + + def nick_dropped(self, user: abstract.AbstractClient.User) -> None: + """Called when someone was disconnected.""" + player = self._db.from_user(user) + if player: + player.lastlogin = datetime.datetime.now() + + + def nick_quit(self, user: abstract.AbstractClient.User) -> None: + """Called when someone quit IRC intentionally.""" + player = self._db.from_user(user) + if player: + self.penalize(player, "quit") + player.online = False + player.lastlogin = datetime.datetime.now() + self._db.write_players([player]) + + + def nick_kicked(self, user: abstract.AbstractClient.User) -> None: + """Called when someone was kicked.""" + player = self._db.from_user(user) + if player: + self.penalize(player, "kick") + player.online = False + player.lastlogin = datetime.datetime.now() + self._db.write_players([player]) + + + def cmd_align(self, player: Player, nick: str, args: str) -> None: + """change alignment of character.""" + if args not in ["good", "neutral", "evil"]: + self.notice(nick, "Try: ALIGN good|neutral|evil") + return + player.alignment = args[0] + self.notice(nick, f"You have converted to {args}.") + mount = player.allies.get("mount") + if mount: + if mount.name: + self.notice(nick, f"{C('name', mount.name)}, your {mount.fullclass}, is disgusted with your change of heart and leaves.") + else: + self.notice(nick, f"Your {mount.fullclass} is disgusted with your change of heart and leaves.") + del player.allies["mount"] + self._db.write_players([player]) + + + def cmd_help(self, player: Player, nick: str, args: str) -> None: + """get help.""" + if args: + if args in DawdleBot.CMDHELP: + self.notice(nick, DawdleBot.CMDHELP[args]) + else: + self.notice(nick, f"{args} is not a command you can get help on.") + return + if not player: + self.notice(nick, f"Available commands: {','.join(DawdleBot.ALLOWALL)}") + self.notice(nick, f"For more information, see {conf.get('helpurl')}.") + elif not player.isadmin: + self.notice(nick, f"Available commands: {','.join(DawdleBot.ALLOWALL + DawdleBot.ALLOWPLAYERS)}") + self.notice(nick, f"For more information, see {conf.get('helpurl')}.") + else: + self.notice(nick, f"Available commands: {','.join(sorted(DawdleBot.CMDHELP.keys()))}") + self.notice(nick, f"Player help is at {conf.get('helpurl')} ; admin help is at {conf.get('admincommurl')}") + + + def cmd_version(self, player: Player, nick: str, args: str) -> None: + """display version information.""" + self.notice(nick, f"DawdleRPG v{VERSION} by Daniel Lowe") + + + def cmd_info(self, player: Player, nick: str, args: str) -> None: + """display bot information and admin list.""" + assert self._irc is not None + admins = [C('name', p.name) for p in self._db.online_players() if p.isadmin] + if admins: + admin_notice = f"Admin{plural(len(admins))} online: " + ", ".join(admins) + else: + admin_notice = "No admins online." + if not player or not player.isadmin: + if conf.get("allowuserinfo"): + self.notice(nick, f"DawdleRPG v{VERSION} by Daniel Lowe, " + f"On via server: {self._irc.servername()}. " + f"{admin_notice}") + else: + self.notice(nick, "You cannot do 'info'.") + return + + online_count = len(self._db.online_players()) + q_msgs = self._irc.writeq_len() + q_bytes = self._irc.writeq_bytes() + if self._silence: + silent_mode = ','.join(self._silence) + else: + silent_mode = 'off' + self.notice(nick, + f"{self._irc.bytes_sent() / 1024:.2f}kiB sent, " + f"{self._irc.bytes_received() / 1024:.2f}kiB received " + f"in {duration(int(time.time() - start_time))}. " + f"{online_count} player{plural(online_count)} online of " + f"{self._db.count_players()} total users. " + f"{self._new_accounts} account{plural(self._new_accounts)} created since startup. " + f"PAUSE_MODE is {'on' if self._pause else 'off'}, " + f"SILENT_MODE is {silent_mode}. " + f"Outgoing queue is {q_bytes} byte{plural(q_bytes)} " + f"in {q_msgs} item{plural(q_msgs)}. " + f"On via: {self._irc.servername()}. {admin_notice}") + + + def cmd_whoami(self, player: Player, nick: str, args: str) -> None: + """display game character information.""" + self.notice(nick, f"You are {C('name', player.name)}, the level {player.level} {player.cclass}. Next level in {duration(player.nextlvl)}.") + + def cmd_announce(self, player: Player, nick: str, args: str) -> None: + """Send a message to the channel via the bot.""" + self.chanmsg(args) + + def cmd_raw(self, player: Player, nick: str, args: str) -> None: + """Send a message to the server as the bot.""" + self._irc.sendnow(args) + + def cmd_status(self, player: Player, nick: str, args: str) -> None: + """get status on player.""" + if not conf.get("statuscmd"): + self.notice(nick, "You cannot do 'status'.") + return + if args == '': + t = player + elif args not in self._db: + self.notice(nick, f"No such player '{args}'.") + return + else: + t = self._db[args] + self.notice(nick, + f"{C('name', t.name)}: Level {t.level} {t.cclass}; " + f"Status: {'Online' if t.online else 'Offline'}; " + f"TTL: {duration(t.nextlvl)}; " + f"Idled: {duration(t.idled)}; " + f"Item sum: {t.itemsum()}") + + + def cmd_login(self, player: Player, nick: str, args: str) -> None: + """start playing as existing character.""" + assert self._irc is not None + if not self._irc.user_exists(nick): + self.notice(nick, f"Sorry, you aren't on {conf.get('botchan')}.") + return + if player and self._irc.match_user(player.nick, player.userhost): + self.notice(nick, f"You are already online as {C('name', player.name)}") + return + + parts = args.split(' ', 1) + if len(parts) != 2: + self.notice(nick, "Try: LOGIN ") + return + pname, ppass = parts + if pname not in self._db: + self.notice(nick, "Sorry, no such account name. Note that account names are case sensitive.") + return + if not self._db.check_login(pname, ppass): + self.notice(nick, "Wrong password.") + return + # Success! + if conf.get("voiceonlogin") and self._irc.bot_has_ops(): + self._irc.grant_voice(nick) + p = self._db[pname] + userhost = self._irc.nick_userhost(nick) + assert userhost is not None + p.userhost = userhost + p.lastlogin = datetime.datetime.now() + if p.online and p.nick == nick: + # If the player was already online and they have the same + # nick, they need no reintroduction to the channel. + self.notice(nick, f"Welcome back, {C('name', p.name)}. Next level in {duration(p.nextlvl)}.") + else: + p.online = True + p.nick = nick + self.notice(nick, f"Logon successful. Next level in {duration(p.nextlvl)}.") + self.chanmsg(f"{C('name', p.name)}, the level {p.level} {p.cclass}, is now online from nickname {nick}. Next level in {duration(p.nextlvl)}.") + self._db.write_players([p]) + + + def cmd_register(self, player: Player, nick: str, args: str) -> None: + """start game as new player.""" + assert self._irc is not None + if player: + self.notice(nick, f"Sorry, you are already online as {C('name', player.name)}") + return + if not self._irc.user_exists(nick): + self.notice(nick, f"Sorry, you aren't on {conf.get('botchan')}") + return + if self._pause: + self.notice(nick, + "Sorry, new accounts may not be registered while the " + "bot is in pause mode; please wait a few minutes and " + "try again.") + return + now = time.time() + if now - self._last_reg_time < 1: + self.notice(nick, "Sorry, there have been too many registrations. Try again in a minute.") + return + self._last_reg_time = now + + parts = args.split(' ', 2) + if len(parts) != 3: + self.notice(nick, "Try: REGISTER ") + self.notice(nick, "i.e. REGISTER Artemis MyPassword Goddess of the Hunt") + return + pname, ppass, pclass = parts + if pname in self._db: + self.notice(nick, "Sorry, that character name is already in use.") + elif self._irc.is_bot_nick(pname): + self.notice(nick, "That character name cannot be registered.") + elif len(parts[1]) < 1 or len(pname) > conf.get("max_name_len"): + self.notice(nick, f"Sorry, character names must be between 1 and {conf.get('max_name_len')} characters long.") + elif len(parts[1]) < 1 or len(pclass) > conf.get("max_class_len"): + self.notice(nick, f"Sorry, character classes must be between 1 and {conf.get('max_class_len')} characters long.") + elif pname[0] == "#": + self.notice(nick, "Sorry, character names may not start with #.") + elif not pname.isprintable(): + self.notice(nick, "Sorry, character names may not include control codes.") + elif not pclass.isprintable(): + self.notice(nick, "Sorry, character classes may not include control codes.") + else: + player = self._db.new_player(pname, pclass, ppass) + player.online = True + player.nick = nick + userhost = self._irc.nick_userhost(nick) + assert userhost is not None + player.userhost = userhost + player.posx = rand.randint("new_player_posy", 0, conf.get("mapx")) + player.posy = rand.randint("new_player_posy", 0, conf.get("mapy")) + + if conf.get("voiceonlogin") and self._irc.bot_has_ops(): + self._irc.grant_voice(nick) + self.chanmsg(f"Welcome {nick}'s new player {C('name', pname)}, the {pclass}! Next level in {duration(player.nextlvl)}.") + self.notice(nick, f"Success! Account {C('name', pname)} created. You have {duration(player.nextlvl)} seconds of idleness until you reach level 1.") + self.notice(nick, "NOTE: The point of the game is to see who can idle the longest. As such, parting, quitting, and changing nicks all penalize you.") + self._new_accounts += 1 + + + def cmd_removeme(self, player: Player, nick: str, args: str) -> None: + """Delete own character.""" + assert self._irc is not None + if args == "": + self.notice(nick, "Try: REMOVEME ") + elif not self._db.check_login(player.name, args): + self.notice(nick, "Wrong password.") + else: + self.notice(nick, f"Account {C('name', player.name)} removed.") + self.chanmsg(f"{nick} removed their account. {C('name', player.name)}, the level {player.level} {player.cclass} is no more.") + self._db.delete_player(player.name) + if conf.get("voiceonlogin") and self._irc.bot_has_ops(): + self._irc.revoke_voice(nick) + + + def cmd_newpass(self, player: Player, nick: str, args: str) -> None: + """change own password.""" + parts = args.split(' ', 1) + if len(parts) != 2: + self.notice(nick, "Try: NEWPASS ") + elif not self._db.check_login(player.name, parts[0]): + self.notice(nick, "Wrong password.") + else: + player.set_password(parts[1]) + self._db.write_players([player]) + self.notice(nick, "Your password was changed.") + + + def cmd_email(self, player: Player, nick: str, args: str) -> None: + """View or change email address""" + addr = args.split(" ")[0] + if addr == "": + if player.email == "": + self.notice(nick, "Your account does not have an email set. " + "You can set it with EMAIL .") + else: + self.notice(nick, f"Your account email is {player.email}.") + return + if '@' not in addr: + self.notice(nick, "That doesn't look like an email address.") + return + + player.email = addr + self._db.write_players([player]) + self.notice(nick, f"Your email is now set to {player.email}.") + + + def cmd_logout(self, player: Player, nick: str, args: str) -> None: + """stop playing as character.""" + assert self._irc is not None + self.notice(nick, "You have been logged out.") + player.online = False + player.lastlogin = datetime.datetime.now() + self._db.write_players([player]) + if conf.get("voiceonlogin") and self._irc.bot_has_ops(): + self._irc.revoke_voice(nick) + self.penalize(player, "logout") + + + def cmd_backup(self, player: Player, nick: str, args: str) -> None: + """copy database file to a backup directory.""" + self._db.backup_store() + self.notice(nick, "Player database backed up.") + + + def cmd_chclass(self, player: Player, nick: str, args: str) -> None: + """change another player's character class.""" + parts = args.split(' ', 1) + if len(parts) != 2: + self.notice(nick, "Try: CHCLASS ") + elif parts[0] not in self._db: + self.notice(nick, f"{parts[0]} is not a valid account.") + elif len(parts[1]) < 1 or len(parts[1]) > conf.get("max_class_len"): + self.notice(nick, f"Character classes must be between 1 and {conf.get('max_class_len')} characters long.") + elif not parts[1].isprintable(): + self.notice(nick, "Character classes may not include control codes.") + else: + self._db[parts[0]].cclass = parts[1] + self.notice(nick, f"{parts[0]}'s character class is now '{parts[1]}'.") + + + def cmd_chpass(self, player: Player, nick: str, args: str) -> None: + """change another player's password.""" + parts = args.split(' ', 1) + if len(parts) != 2: + self.notice(nick, "Try: CHPASS ") + elif parts[0] not in self._db: + self.notice(nick, f"{parts[0]} is not a valid account.") + else: + self._db[parts[0]].set_password(parts[1]) + self.notice(nick, f"{parts[0]}'s password changed.") + + + def cmd_chuser(self, player: Player, nick: str, args: str) -> None: + """Change someone's username.""" + parts = args.split(' ', 1) + if len(parts) != 2: + self.notice(nick, "Try: CHPASS ") + elif parts[0] not in self._db: + self.notice(nick, f"{parts[0]} is not a valid account.") + elif parts[1] in self._db: + self.notice(nick, f"{parts[1]} is already taken.") + elif len(parts[1]) < 1 or len(parts[1]) > conf.get("max_name_len"): + self.notice(nick, f"Character names must be between 1 and {conf.get('max_name_len')} characters long.") + elif parts[1][0] == "#": + self.notice(nick, "Character names may not start with a #.") + elif not parts[1].isprintable(): + self.notice(nick, "Character names may not include control codes.") + else: + self._db.rename_player(parts[0], parts[1]) + self.notice(nick, f"{parts[0]} is now known as {parts[1]}.") + + + def cmd_config(self, player: Player, nick: str, args: str) -> None: + """View/set a configuration setting.""" + if args == "": + self.notice(nick, "Try: CONFIG or CONFIG ") + return + + parts = args.split(' ', 2) + if len(parts) == 1: + if conf.has(parts[0]): + self.notice(nick, f"{parts[0]} {conf.get(parts[0])}") + else: + self.notice(nick, f"Matching config keys: {', '.join([k for k in conf._conf if parts[0] in k])}") + return + if not conf.has(parts[0]): + self.notice(nick, f"{parts[0]} is not a config key.") + return + val = conf.parse_val(parts[1]) + conf._conf[parts[0]] = val + self.notice(nick, f"{parts[0]} set to {val}.") + + + def cmd_clearq(self, player: Player, nick: str, args: str) -> None: + """Clear outgoing message queue.""" + assert self._irc is not None + self._irc.clear_writeq() + self.notice(nick, "Output queue cleared.") + + + def cmd_del(self, player: Player, nick: str, args: str) -> None: + """Delete another player's account.""" + if args not in self._db: + self.notice(nick, f"{args} is not a valid account.") + else: + self._db.delete_player(args) + self.notice(nick, f"{args} has been deleted.") + + + def cmd_deladmin(self, player: Player, nick: str, args: str) -> None: + """Remove admin authority.""" + if args not in self._db: + self.notice(nick, f"{args} is not a valid account.") + elif not self._db[args].isadmin: + self.notice(nick, f"{args} is already not an admin.") + elif args == conf.get("owner"): + self.notice(nick, "You can't do that.") + else: + self._db[args].isadmin = False + self._db.write_players([player]) + self.notice(nick, f"{args} is no longer an admin.") + + + def cmd_delold(self, player: Player, nick: str, args: str) -> None: + """Remove players not accessed in a number of days.""" + if not re.match(r"\d+", args): + self.notice(nick, "Try DELOLD <# of days>") + return + days = int(args) + if days < 7: + self.notice(nick, "That seems a bit low.") + return + expire_time = int(time.time()) - days * 86400 + old = [p.name for p in self._db.inactive_since(expire_time)] + for pname in old: + self._db.delete_player(pname) + self.chanmsg(f"{len(old)} account{plural(len(old))} not accessed " + f"in the last {days} days removed by {C('name', player.name)}.") + + + def cmd_die(self, player: Player, nick: str, args: str) -> None: + """Shut down the bot.""" + assert self._irc is not None + self.notice(nick, "Shutting down.") + log.info("%s (as %s) initiated shutdown.", player.name, nick) + self._irc.quit("Shutting down for maintenance.") + sys.exit(0) + + + def cmd_jump(self, player: Player, nick: str, args: str) -> None: + """Switch to new IRC server.""" + # Not implemented. + pass + + + def cmd_mkadmin(self, player: Player, nick: str, args: str) -> None: + """Grant admin authority to player.""" + if args not in self._db: + self.notice(nick, f"{args} is not a valid account.") + elif self._db[args].isadmin: + self.notice(nick, f"{args} is already an admin.") + else: + self._db[args].isadmin = True + self._db.write_players([self._db[args]]) + self.notice(nick, f"{args} is now an admin.") + + + def cmd_pause(self, player: Player, nick: str, args: str) -> None: + """Toggle pause mode.""" + self._pause = not self._pause + if self._pause: + self.notice(nick, "Pause mode enabled.") + else: + self.notice(nick, "Pause mode disabled.") + + + def cmd_rehash(self, player: Player, nick: str, args: str) -> None: + """Re-read configuration file.""" + conf.read_config(conf.get("confpath")) + self.notice(nick, "Configuration reloaded.") + + + def cmd_reloaddb(self, player: Player, nick: str, args: str) -> None: + """Reload the player database.""" + if not self._pause: + self.notice(nick, "ERROR: can only use RELOADDB while in PAUSE mode.") + return + self._db.load_state() + + + def cmd_restart(self, player: Player, nick: str, args: str) -> None: + """Restart from scratch.""" + # Not implemented. + pass + + + def cmd_silent(self, player: Player, nick: str, args: str) -> None: + """Set silent mode.""" + old_silence = self._silence + self._silence = set() + if args == "0": + self.notice(nick, "Silent mode set to 0. Channels and notices are enabled.") + elif args == "1": + self.notice(nick, "Silent mode set to 1. Channel output is silenced.") + self._silence = set(["chanmsg"]) + elif args == "2": + self.notice(nick, "Silent mode set to 2. Private notices are silenced.") + self._silence = set(["notices"]) + elif args == "3": + self.notice(nick, "Silent mode set to 3. Channel and private notice output are silenced.") + self._silence = set(["chanmsg", "notices"]) + else: + self.notice(nick, "Try: SILENT 0|1|2|3") + self._silence = old_silence + + + def cmd_hog(self, player: Player, nick: str, args: str) -> None: + """Trigger Hand of God.""" + self.chanmsg(f"{C('name', player.name)} has summoned the Hand of God.") + self.hand_of_god(self._db.online_players()) + + + def cmd_push(self, player: Player, nick: str, args: str) -> None: + """Push someone toward or away from their next level.""" + parts = args.split(' ') + if len(parts) != 2 or not re.match(r'[+-]?\d+', parts[1]): + self.notice(nick, "Try: PUSH ") + return + if parts[0] not in self._db: + self.notice(nick, f"No such username {parts[0]}.") + return + target = self._db[parts[0]] + amount = int(parts[1]) + if amount == 0: + self.notice(nick, "That would not be interesting.") + return + + if amount > target.nextlvl: + self.notice(nick, + f"Time to level for {C('name', target.name)} ({target.nextlvl}s) " + f"is lower than {amount}; setting TTL to 0.") + amount = target.nextlvl + target.nextlvl -= amount + direction = 'towards' if amount > 0 else 'away from' + self.notice(nick, f"{C('name', target.name)} now reaches level {target.level + 1} in {duration(target.nextlvl)}.") + self.logchanmsg([target], + f"{C('name', player.name)} has pushed {C('name', target.name)} {abs(amount)} seconds {direction} " + f"level {target.level + 1}. {C('name', target.name)} reaches next level " + f"in {duration(target.nextlvl)}.") + + + def cmd_trigger(self, player: Player, nick: str, args: str) -> None: + """Trigger in-game events""" + if args == 'calamity': + self.chanmsg(f"{C('name', player.name)} brings down ruin upon the land.") + self.calamity() + elif args == 'godsend': + self.chanmsg(f"{C('name', player.name)} rains blessings upon the people.") + self.godsend() + elif args == 'hog': + self.chanmsg(f"{C('name', player.name)} has summoned the Hand of God.") + self.hand_of_god(self._db.online_players()) + elif args == 'teambattle': + self.chanmsg(f"{C('name', player.name)} has decreed violence.") + self.team_battle(self._db.online_players()) + elif args == 'evilness': + self.chanmsg(f"{C('name', player.name)} has swept the lands with evil.") + self.evilness(self._db.online_players()) + elif args == 'goodness': + self.chanmsg(f"{C('name', player.name)} has drawn down light from the heavens.") + self.goodness(self._db.online_players()) + elif args == 'battle': + self.chanmsg(f"{C('name', player.name)} has called forth a gladitorial arena.") + self.challenge_opp(rand.choice('triggered_battle', self._db.online_players())) + elif args == 'mount': + non_mount_players = [p for p in self._db.online_players() if p.level >= 40 and "mount" not in p.allies] + if not non_mount_players: + self.notice(nick, "No qualifying players available.") + return + target = rand.choice("find_mount_player", non_mount_players) + self.chanmsg(f"{C('name', player.name)} has called forth a mount for {target.name}.") + self.find_mount(target) + elif args == 'quest': + if self._db._quest: + self.notice(nick, "There's already a quest on.") + return + now = int(time.time()) + latest_login_time = now - conf.get("quest_min_login") + qp = [p for p in self._db.online_players() if p.level > conf.get("quest_min_level") and p.lastlogin.timestamp() < latest_login_time] + if len(qp) == 0: + self.notice(nick, "There are no eligible players.") + return + if len(qp) < 4: + self.notice(nick, "There aren't enough eligible players.") + return + self.chanmsg(f"{C('name', player.name)} has called heroes to a quest.") + self.notice(nick, "Starting quest.") + self.quest_start(now) + + + def cmd_quest(self, player: Player, nick: str, args: str) -> None: + """Get information on current quest.""" + assert self._irc is not None + if self._db._quest is None: + self.notice(nick, "There is no active quest.") + elif self._db._quest.mode == 1: + assert self._db._quest.qtime is not None + qp = self._db._quest.questors + self.notice(nick, + f"{C('name', qp[0].name)}, {C('name', qp[1].name)}, {C('name', qp[2].name)}, and {C('name', qp[3].name)} " + f"are on a quest to {self._db._quest.text}. Quest to complete in " + f"{duration(int(self._db._quest.qtime - time.time()))}.") + elif self._db._quest.mode == 2: + assert self._db._quest.dests is not None + qp = self._db._quest.questors + mapnotice = '' + if conf.has("mapurl"): + mapnotice = f" See {conf.get('mapurl')} to monitor their journey's progress." + self.notice(nick, + f"{C('name', qp[0].name)}, {C('name', qp[1].name)}, {C('name', qp[2].name)}, and {C('name', qp[3].name)} " + f"are on a quest to {self._db._quest.text}. Participants must first reach " + f"({self._db._quest.dests[0][0]}, {self._db._quest.dests[0][1]}), then " + f"({self._db._quest.dests[1][0]}, {self._db._quest.dests[1][1]}).{mapnotice}") + + + def cmd_mname(self, player: Player, nick: str, args: str) -> None: + """Get information on current quest.""" + assert self._irc is not None + if "mount" not in player.allies: + self.notice(nick, "You don't have a mount.") + elif len(args) == 0: + self.notice(nick, "Usage: mname ") + elif len(args) > 30: + self.notice(nick, "You must select a name less than 30 characters.") + else: + player.allies["mount"].name = args + self.notice(nick, f"Your {player.allies['mount'].fullclass} is now named \"{args}\".") + self._db.write_players([player]) + + + def cmd_recalcallies(self, player: Player, nick: str, args: str) -> None: + """Recompute TTL for all allies.""" + assert self._irc is not None + for p in self._db._players.values(): + for a in p.allies.values(): + a.nextlvl = Ally.time_to_next_level(a.level) + self._db.write_players() + self.notice(nick, "TTL for all allies recomputed.") + + + def penalize(self, player: Player, kind: str, text: Optional[str]=None) -> None: + """Exact penalities on a transgressing player.""" + penalty = conf.get("pen"+kind) + if penalty == 0: + return + + if self._db._quest and player in self._db._quest.questors: + log.info("Quest failed due to %s penalty by %s", kind, player.name) + op = self._db.online_players() + self.logchanmsg(op, + f"{C('name')}{player.name}'s{C()} insolence has brought the wrath of " + f"the gods down upon them. Your great wickedness " + f"burdens you like lead, drawing you downwards with " + f"great force towards hell. Thereby have you plunged " + f"{conf.get('penquest')} steps closer to that gaping maw.") + for p in op: + gain = int(conf.get("penquest") * (conf.get("rppenstep") ** p.level)) + p.penquest += gain + p.nextlvl += gain + + self._db.update_quest(None) + self._qtimer = time.time() + conf.get("quest_interval_min") + + if text: + penalty *= len(text) + penalty *= int(conf.get("rppenstep") ** player.level) + if conf.has("limitpen") and penalty > conf.get("limitpen"): + penalty = conf.get("limitpen") + setattr(player, "pen"+kind, getattr(player, "pen"+kind) + penalty) + player.nextlvl += penalty + if kind not in ['dropped', 'quit']: + pendesc = {"quit": "quitting", + "dropped": "dropped connection", + "nick": "changing nicks", + "message": "messaging", + "part": "parting", + "kick": "being kicked", + "logout": "LOGOUT command"}[kind] + self.notice(player.nick, f"Penalty of {duration(penalty)} added to your timer for {pendesc}.") + + + def refresh_events(self) -> None: + """Read events file if it has changed.""" + if self._events_loaded == os.path.getmtime(datapath(conf.get("eventsfile"))): + return + + self._events = {} + with open(datapath(conf.get("eventsfile"))) as inf: + for line in inf.readlines(): + line = line.rstrip() + if line != "": + self._events.setdefault(line[0], []).append(line[1:].lstrip()) + + + def expire_splits(self) -> None: + """Kick players offline if they were disconnected for too long.""" + assert self._irc is not None + expiration = time.time() - conf.get("splitwait") + dropped_players = [] + for p in self._db.online_players(): + if self._irc.user_exists(p.nick): + continue + if p.lastlogin.timestamp() > expiration: + continue + log.info("Expiring %s who was logged in as %s but was lost in a netsplit.", p.nick, p.name) + self.penalize(p, "dropped") + p.online = False + dropped_players.append(p) + + self._db.write_players(dropped_players) + + async def gametick_loop(self) -> None: + """Main gameplay loop to manage timing.""" + try: + last_time = time.time() - 1 + while self._state == 'ready': + await asyncio.sleep(conf.get("self_clock")) + now = time.time() + self.gametick(int(now), int(now - last_time)) + last_time = now + except Exception as err: + log.exception(err) + sys.exit(2) + + + def gametick(self, now: int, passed: int) -> None: + """Main gameplay routine.""" + if conf.get("detectsplits"): + self.expire_splits() + self.refresh_events() + + op = self._db.online_players() + online_count = 0 + evil_count = 0 + good_count = 0 + for player in op: + online_count += 1 + if player.alignment == 'e': + evil_count += 1 + elif player.alignment == 'g': + good_count += 1 + + day_ticks = 86400/conf.get("self_clock") + if rand.randint('hog_trigger', 0, 20 * day_ticks) < online_count: + self.hand_of_god(op) + if rand.randint('team_battle_trigger', 0, 24 * day_ticks) < online_count: + self.team_battle(op) + if rand.randint('calamity_trigger', 0, 8 * day_ticks) < online_count: + self.calamity() + if rand.randint('godsend_trigger', 0, 4 * day_ticks) < online_count: + self.godsend() + if rand.randint('evilness_trigger', 0, 8 * day_ticks) < evil_count: + self.evilness(op) + if rand.randint('goodness_trigger', 0, 12 * day_ticks) < good_count: + self.goodness(op) + + self.move_players() + self.quest_check(now) + + if now % 120 == 0 and self._db._quest: + self._db.update_quest(self._db._quest) + if now % 36000 == 0: + top = self._db.top_players() + if top: + self.chanmsg("Idle RPG Top Players:") + for i, p in zip(itertools.count(), top): + self.chanmsg(f"{C('name', p.name)}, the level {p.level} {p.cclass}, is #{i+1}! " + f"Next level in {duration(p.nextlvl)}.") + self._db.backup_store() + # high level players fight each other randomly + hlp = [p for p in op if p.level >= 45] + if now % 3600 == 0 and len(hlp) > len(op) * 0.15: + self.challenge_opp(rand.choice('pvp_combat', hlp)) + + # periodic warning about pause mode + if now % 600 == 0 and self._pause: + self.chanmsg("WARNING: Cannot write database in PAUSE mode!") + + for player in op: + player.nextlvl -= passed + player.idled += passed + if player.nextlvl < 1: + player.level += 1 + player.nextlvl = Player.time_to_next_level(player.level) + + self.chanmsg(f"{C('name', player.name)}, the {player.cclass}, has attained level {player.level}! Next level in {duration(player.nextlvl)}.") + if player.level >= 60 and 'mount' not in player.allies and rand.randomly('ally_find', 10): + self.find_mount(player) + else: + self.find_item(player) + # Players below level 25 have fewer battles. + if player.level >= 25 or rand.randomly('lowlevel_battle', 4): + self.challenge_opp(player) + for slot, ally in player.allies.items(): + ally.nextlvl -= passed + if ally.nextlvl < 1: + ally.level += 1 + ally.nextlvl = Ally.time_to_next_level(ally.level) + if ally.name: + self.notice(player.nick, f"Your {slot} {C('name', ally.name)}, the {ally.fullclass}, has attained level {ally.level}! Next level in {duration(ally.nextlvl)}.") + else: + self.notice(player.nick, f"Your {slot}, the {ally.fullclass}, has attained level {ally.level}! Next level in {duration(ally.nextlvl)}.") + if rand.randomly('ally_evolve', 20): + ally.fullclass = self.random_mount_class(ally.baseclass, ally.level, ally.alignment) + if ally.name: + self.notice(player.nick, f"{C('name', ally.name)} has evolved into a {ally.fullclass}!") + else: + self.notice(player.nick, f"Your {slot} has evolved into a {ally.fullclass}!") + + self._db.write_players(op) + + + def hand_of_god(self, op: List[Player]) -> None: + """Hand of God that pushes a random player forword or back.""" + player = rand.choice('hog_player', op) + amount = int(player.nextlvl * (5 + rand.randint('hog_amount', 0, 71))/100) + if rand.randomly('hog_effect', 5): + self.logchanmsg([player], f"Thereupon He stretched out His little finger among them and consumed {C('name', player.name)} with fire, slowing the heathen {duration(amount)} from level {player.level + 1}.") + player.nextlvl += amount + else: + self.logchanmsg([player], f"Verily I say unto thee, the Heavens have burst forth, and the blessed hand of God carried {C('name', player.name)} {duration(amount)} toward level {player.level + 1}.") + player.nextlvl -= amount + self.chanmsg(f"{C('name', player.name)} reaches next level in {duration(player.nextlvl)}.") + self._db.write_players([player]) + + + def random_mount_class(self, base_class: str, level: int, alignment: str) -> str: + if level < 40: + prefixes = ["droopy", "tired-looking", "lame", "languid", "saggy", "slouching", "standard"] + suffixes = [] + elif level < 50: + prefixes = ["river", "hill", "city"] + suffixes = ["moseying", "the road", "the market", "the trough"] + elif level < 60: + prefixes = ["alert", "strong", "keen", "savage", "tireless", "stout", "burly"] + suffixes = ["fortune", "swiftness", "strength", "speed", "haste"] + elif level < 70: + prefixes = ["weird", "snowy", "crimson", "rugged", "lucky", "energetic"] + suffixes = ["fortune", "swiftness", "strength", "speed", "haste"] + if alignment == 'g': + suffixes.extend(["sustenance", "generosity", "guarding"]) + elif alignment == 'e': + suffixes.extend(["poison", "venom", "avarice", "animosity", "bile", "spite"]) + elif level < 80: + prefixes = ["volcanic", "brutal", "smoldering", "glimmering", "ember", "mighty"] + suffixes = ["fire", "water", "earth", "air", "freedom", "enchantment"] + if alignment == 'g': + suffixes.extend(["healing", "kindness", "honor", "remedy", "light"]) + elif alignment == 'e': + prefixes.extend(["dark", "hateful"]) + suffixes.extend(["blight", "cruelty", "defiance", "butchery"]) + elif level < 90: + prefixes = ["glowing", "thunder", "glorious", "rainbow", "chromatic"] + suffixes = ["storms", "lightning", "power", "meteors"] + if alignment == 'g': + suffixes.extend(["spirit", "joy", "bliss", "zeal", "restoration"]) + elif alignment == 'e': + prefixes.extend(["loathsome"]) + suffixes.extend(["destruction", "loathing", "carnage", "butchery"]) + else: + prefixes = ["prismatic", "sparkly", "mystic", "lunar", "sacred", "divine"] + suffixes = ["the beyond", "infinity", "ages", "the cosmos", "destiny"] + if alignment == 'g': + suffixes.extend(["hope", "life", "truth"]) + elif alignment == 'e': + suffixes.extend(["despair", "death", "malice", "pestilence"]) + else: + suffixes.extend(["the great balance"]) + + if prefixes and not suffixes: + prefix = rand.choice("class_prefix", prefixes) + full_class = f"{prefix} {base_class}" + elif suffixes and not prefixes: + suffix = rand.choice("class_suffix", suffixes) + full_class = f"{base_class} of {suffix}" + else: + prefix = rand.choice("class_prefix", prefixes) + suffix = rand.choice("class_suffix", suffixes) + full_class = f"{prefix} {base_class} of {suffix}" + return full_class + + + def find_mount(self, player: Player) -> None: + """Generate a random mount and give it to the player as an Ally.""" + # First generate level just like item level. + level = rand.gauss('find_mount_level', + player.level, + player.level / 5) + nextlvl = Ally.time_to_next_level(level) + + base_classes = ["bear", "eagle", "horse", "llama", "donkey", "ox", "snake", "elephant", "fox", "wolf", "squirrel", "camel"] + if player.alignment == 'g': + base_classes.extend(["pegasus", "unicorn", "hippogriff"]) + elif player.alignment == 'e': + base_classes.extend(["manticore", "chimera", "warg", "shark", "spider"]) + base_class = rand.choice("base_class", base_classes) + full_class = self.random_mount_class(base_class, level, player.alignment) + player.allies["mount"] = Ally("", base_class, full_class, player.alignment, level, nextlvl) + + if player.alignment == 'g': + self.chanmsg(f"{player.name} befriends a level {level} {full_class}, and they quickly become inseparable companions!") + elif player.alignment == 'n': + self.chanmsg(f"{player.name} offers food to a level {level} {full_class} and they become partners!") + elif player.alignment == 'e': + self.chanmsg(f"{player.name} comes upon a level {level} {full_class}, who is swiftly brought to heel!") + + def find_item(self, player: Player) -> None: + """Find a random item and add to player if higher level.""" + # TODO: Convert to configuration + # Note that order is important here - each item is less likely to be picked than the previous. + special_items = [SpecialItem(25, 50, 25, 'helm', "Mattt's Omniscience Grand Crown", + "Your enemies fall before you as you anticipate their every move."), + SpecialItem(25, 50, 25, 'ring', "Juliet's Glorious Ring of Sparkliness", + "Your enemies are blinded by both its glory and their greed as you " + "bring desolation upon them."), + SpecialItem(30, 75, 25, 'tunic', "Res0's Protectorate Plate Mail", + "Your enemies cower in fear as their attacks have no effect on you."), + SpecialItem(35, 100, 25, 'amulet', "Dwyn's Storm Magic Amulet", + "Your enemies are swept away by an elemental fury before the war " + "has even begun."), + SpecialItem(40, 150, 25, 'weapon', "Jotun's Fury Colossal Sword", + "Your enemies' hatred is brought to a quick end as you arc your " + "wrist, dealing the crushing blow."), + SpecialItem(45, 175, 26, 'weapon', "Drdink's Cane of Blind Rage", + "Your enemies are tossed aside as you blindly swing your arm " + "around hitting stuff."), + SpecialItem(48, 250, 51, 'boots', "Mrquick's Magical Boots of Swiftness", + "Your enemies are left choking on your dust as you run from them " + "very, very quickly."), + SpecialItem(25, 300, 51, 'weapon', "Jeff's Cluehammer of Doom", + "Your enemies are left with a sudden and intense clarity of " + "mind... even as you relieve them of it.")] + + for si in special_items: + if player.level >= si.minlvl and rand.randomly('specitem_find', 40): + fudge = rand.randint('specitem_level', 0, si.lvlspread) + ilvl = si.itemlvl + fudge + # Ensure that the player always gets a boost from + # special items. This differs from the original, + # where a more powerful carried or generated item + # would override. This would mean that special items + # would gradually be impossible to get. + if ilvl <= player.item_level(si.kind): + ilvl = player.item_level(si.kind) + fudge + self.notice(player.nick, + f"The light of the gods shines down upon you! You have " + f"found the {C('item')}level {ilvl} {si.name}{C()}! {si.flavor}") + player.acquire_item(si.kind, ilvl, si.name) + self._db.write_players([player]) + return + + slot = rand.choice('find_item_slot', Item.SLOTS) + level = rand.gauss('find_item_level', + player.level, + player.level / 5) + + old_level = player.item_level(slot) + if level > old_level: + self.notice(player.nick, + f"You found a {C('item')}level {level} {Item.DESC[slot]}{C()}! " + f"Your current {C('item')}{Item.DESC[slot]}{C()} is only " + f"level {old_level}, so it seems Luck is with you!") + player.acquire_item(slot, level) + self._db.write_players([player]) + else: + self.notice(player.nick, + f"You found a {C('item')}level {level} {Item.DESC[slot]}{C()}. " + f"Your current {C('item', Item.DESC[slot])} is level {old_level}, " + f"so it seems Luck is against you. You toss the {C('item', Item.DESC[slot])}.") + + + def pvp_battle(self, player: Player, opp: Optional[Player], flavor_start: str, flavor_win: str, flavor_loss: str) -> None: + """Enact a powerful player-vs-player battle.""" + if opp is None: + oppname = conf.get("botnick") + oppsum = self._db.max_player_power()+1 + else: + oppname = opp.name + oppsum = opp.battleitemsum() + + playersum = player.battleitemsum() + playerroll = rand.randint('pvp_player_roll', 0, playersum) + opproll = rand.randint('pvp_opp_roll', 0, oppsum) + if playerroll >= opproll: + gain = 20 if opp is None else max(7, int(opp.level / 4)) + amount = int((gain / 100)*player.nextlvl) + self.logchanmsg([player], f"{C('name', player.name)} [{playerroll}/{playersum}] has {flavor_start} " + f"{C('name', oppname)} [{opproll}/{oppsum}] {flavor_win}! " + f"{duration(amount)} is removed from {C('name')}{player.name}'s{C()} clock.") + player.nextlvl -= amount + if player.nextlvl > 0: + self.chanmsg(f"{C('name', player.name)} reaches next level in {duration(player.nextlvl)}.") + if opp is not None: + if rand.randomly('pvp_critical', {'g': 50, 'n': 35, 'e': 20}[player.alignment]): + penalty = int(((5 + rand.randint('pvp_cs_penalty_pct', 0, 20))/100 * opp.nextlvl)) + self.logchanmsg([opp], f"{C('name', player.name)} has dealt {C('name', opp.name)} a {CC('red')}Critical Strike{C()}! " + f"{duration(penalty)} is added to {C('name', opp.name)}'s clock.") + opp.nextlvl += penalty + self.chanmsg(f"{C('name', opp.name)} reaches next level in {duration(opp.nextlvl)}.") + elif player.level > 19 and rand.randomly('pvp_swap_item', 25): + slot = rand.choice('pvp_swap_itemtype', Item.SLOTS) + playeritem = player.item_level(slot) + oppitem = opp.item_level(slot) + if oppitem > playeritem: + self.logchanmsg([player, opp], f"In the fierce battle, {C('name', opp.name)} dropped their {C('item')}level " + f"{oppitem} {Item.DESC[slot]}{C()}! {C('name', player.name)} picks it up, tossing " + f"their old {C('item')}level {playeritem} {Item.DESC[slot]}{C()} to {C('name', opp.name)}.") + player.swap_items(opp, slot) + else: + # Losing + loss = 10 if opp is None else max(7, int(opp.level / 7)) + amount = int((loss / 100)*player.nextlvl) + self.logchanmsg([player], f"{C('name', player.name)} [{playerroll}/{playersum}] has {flavor_start} " + f"{oppname} [{opproll}/{oppsum}] {flavor_loss}! {duration(amount)} is " + f"added to {C('name')}{player.name}'s{C()} clock.") + player.nextlvl += amount + self.chanmsg(f"{C('name', player.name)} reaches next level in {duration(player.nextlvl)}.") + + if rand.randomly('pvp_find_item', {'g': 50, 'n': 67, 'e': 100}[player.alignment]): + self.logchanmsg([player], f"While recovering from battle, {C('name', player.name)} notices a glint " + f"in the mud. Upon investigation, they find an old lost item!") + self.find_item(player) + + + def challenge_opp(self, player: Player) -> None: + """Pit player against another random player.""" + op = cast(List[Optional[Player]], self._db.online_players()) + op.remove(player) # Let's not fight ourselves + op.append(None) # This is the bot opponent + self.pvp_battle(player, rand.choice('challenge_opp_choice', op), 'challenged', 'and won', 'and lost') + + + def team_battle(self, op: List[Player]) -> None: + """Have a 3-vs-3 battle between teams.""" + if len(op) < 6: + return + op = rand.sample('team_battle_members', op, 6) + team_a = sum([p.battleitemsum() for p in op[0:3]]) + team_b = sum([p.battleitemsum() for p in op[3:6]]) + gain = int(min([p.nextlvl for p in op[0:6]]) * 0.2) + roll_a = rand.randint('team_a_roll', 0, team_a) + roll_b = rand.randint('team_b_roll', 0, team_b) + if roll_a >= roll_b: + self.logchanmsg(op[0:3], f"{C('name', op[0].name)}, {C('name', op[1].name)}, and {C('name', op[2].name)} [{roll_a}/{team_a}] " + f"have team battled {C('name', op[3].name)}, {C('name', op[4].name)}, and {C('name', op[5].name)} " + f"[{roll_b}/{team_b}] and won! {duration(gain)} is removed from their clocks.") + for p in op[0:3]: + p.nextlvl -= gain + else: + self.logchanmsg(op[0:3], f"{C('name', op[0].name)}, {C('name', op[1].name)}, and {C('name', op[2].name)} [{roll_a}/{team_a}] " + f"have team battled {C('name', op[3].name)}, {C('name', op[4].name)}, and {C('name', op[5].name)} " + f"[{roll_b}/{team_b}] and lost! {duration(gain)} is added to their clocks.") + for p in op[0:3]: + p.nextlvl += gain + + + def calamity(self) -> None: + """Bring bad things to a random player.""" + player = rand.choice('calamity_target', self._db.online_players()) + if not player: + return + + if player.items and rand.randomly('calamity_item_damage', 10): + # Item damaging calamity + slot = rand.choice('calamity_slot', sorted(player.items.keys())) + if slot == "ring": + msg = f"{C('name', player.name)} accidentally smashed their {C('item', 'ring')} with a hammer!" + elif slot == "amulet": + msg = f"{C('name', player.name)} fell, chipping the stone in their {C('item', 'amulet')}!" + elif slot == "charm": + msg = f"{C('name', player.name)} slipped and dropped their {C('item', 'charm')} in a dirty bog!" + elif slot == "weapon": + msg = f"{C('name', player.name)} left their {C('item', 'weapon')} out in the rain to rust!" + elif slot == "helm": + msg = f"{C('name')}{player.name}'s{C()} {C('item', 'helm')} was touched by a rust monster!" + elif slot == "tunic": + msg = f"{C('name', player.name)} spilled a level 7 shrinking potion on their {C('item', 'tunic')}!" + elif slot == "gloves": + msg = f"{C('name', player.name)} dipped their gloved fingers in a pool of acid!" + elif slot == "leggings": + msg = f"{C('name', player.name)} burned a hole through their {C('item', 'leggings')} while ironing them!" + elif slot == "shield": + msg = f"{C('name')}{player.name}'s{C()} {C('item', 'shield')} was damaged by a dragon's fiery breath!" + elif slot == "boots": + msg = f"{C('name', player.name)} stepped in some hot lava!" + self.logchanmsg([player], msg + f" {C('name')}{player.name}'s{C()} {C('item', Item.DESC[slot])} loses 10% of its effectiveness.") + player.items[slot].level = int(player.items[slot].level * 0.9) + return + + # Level setback calamity + amount = int(rand.randint('calamity_setback_pct', 5, 13) / 100 * player.nextlvl) + player.nextlvl += amount + action = rand.choice('calamity_action', self._events["C"]) + self.logchanmsg([player], f"{C('name', player.name)} {action}! This terrible calamity has slowed them " + f"{duration(amount)} from level {player.level + 1}.") + if player.nextlvl > 0: + self.chanmsg(f"{C('name', player.name)} reaches next level in {duration(player.nextlvl)}.") + + + def godsend(self) -> None: + """Bring good things to a random player.""" + player = rand.choice('godsend_target', self._db.online_players()) + if not player: + return + + if player.items and rand.randomly('godsend_item_improve', 10): + # Item improving godsend + slot = rand.choice('godsend_slot', sorted(player.items.keys())) + if slot == "ring": + msg = f"{C('name', player.name)} dipped their {C('item', 'ring')} into a sacred fountain!" + elif slot == "amulet": + msg = f"{C('name')}{player.name}'s{C()} {C('item', 'amulet')} was blessed by a passing cleric!" + elif slot == "charm": + msg = f"{C('name')}{player.name}'s{C()} {C('item', 'charm')} ate a bolt of lightning!" + elif slot == "weapon": + msg = f"{C('name', player.name)} sharpened the edge of their {C('item', 'weapon')}!" + elif slot == "helm": + msg = f"{C('name', player.name)} polished their {C('item', 'helm')} to a mirror shine." + elif slot == "tunic": + msg = f"A magician cast a spell of Rigidity on {C('name')}{player.name}'s{C()} {C('item', 'tunic')}!" + elif slot == "gloves": + msg = f"{C('name', player.name)} lined their {C('item', 'gloves')} with a magical cloth!" + elif slot == "leggings": + msg = f"The local wizard imbued {C('name')}{player.name}'s{C()} {C('item', 'pants')} with a Spirit of Fortitude!" + elif slot == "shield": + msg = f"{C('name', player.name)} reinforced their {C('item', 'shield')} with a dragon's scale!" + elif slot == "boots": + msg = f"A sorceror enchanted {C('name')}{player.name}'s{C()} {C('item', 'boots')} with Swiftness!" + + self.logchanmsg([player], msg + f" {C('name')}{player.name}'s{C()} {C('item', Item.DESC[slot])} gains 10% effectiveness.") + player.items[slot].level = int(player.items[slot].level * 1.1) + return + + # Level godsend + amount = int(rand.randint('godsend_amount_pct', 5, 13) / 100 * player.nextlvl) + player.nextlvl -= amount + action = rand.choice('godsend_action', self._events["G"]) + self.logchanmsg([player], f"{C('name', player.name)} {action}! This wondrous godsend has accelerated them " + f"{duration(amount)} towards level {player.level + 1}.") + if player.nextlvl > 0: + self.chanmsg(f"{C('name', player.name)} reaches next level in {duration(player.nextlvl)}.") + + + def evilness(self, op: List[Player]) -> None: + """Bring evil or an item to a random evil player.""" + evil_p = [p for p in op if p.alignment == 'e'] + if not evil_p: + return + player = rand.choice('evilness_player', evil_p) + if rand.randomly('evilness_theft', 2): + target = rand.choice('evilness_target', [p for p in op if p.alignment == 'g']) + if not target: + return + slot = rand.choice('evilness_slot', Item.SLOTS) + if player.item_level(slot) < target.item_level(slot): + player.swap_items(target, slot) + self.logchanmsg([player, target], + f"{C('name', player.name)} stole {target.name}'s {C('item')}level {player.item_level(slot)} " + f"{Item.DESC[slot]}{C()} while they were sleeping! {C('name', player.name)} " + f"leaves their old {C('item')}level {target.item_level(slot)} {Item.DESC[slot]}{C()} " + f"behind, which {C('name', target.name)} then takes.") + else: + self.notice(player.nick, + f"You made to steal {C('name', target.name)}'s {C('item', Item.DESC[slot])}, " + f"but realized it was lower level than your own. You creep " + f"back into the shadows.") + else: + amount = int(player.nextlvl * rand.randint('evilness_penalty_pct', 1,6) / 100) + player.nextlvl += amount + self.logchanmsg([player], f"{C('name', player.name)} is forsaken by their evil god. {duration(amount)} is " + f"added to their clock.") + if player.nextlvl > 0: + self.chanmsg(f"{C('name', player.name)} reaches next level in {duration(player.nextlvl)}.") + + def goodness(self, op: List[Player]) -> None: + """Bring two good players closer to their next level.""" + good_p = [p for p in op if p.alignment == 'g'] + if len(good_p) < 2: + return + players = rand.sample('goodness_players', good_p, 2) + gain = rand.randint('goodness_gain_pct', 5, 13) + self.logchanmsg([players[0], players[1]], + f"{C('name', players[0].name)} and {C('name', players[1].name)} have not let the iniquities " + f"of evil people poison them. Together have they prayed to their god, " + f"and light now shines down upon them. {gain}% of their time is removed " + f"from their clocks.") + for player in players: + player.nextlvl = int(player.nextlvl * (1 - gain / 100)) + if player.nextlvl > 0: + self.chanmsg(f"{C('name', player.name)} reaches next level in {duration(player.nextlvl)}.") + + + def move_players(self) -> None: + """Move players around the map.""" + op = self._db.online_players() + if not op: + return + rand.shuffle('move_players_order', op) + mapx = conf.get("mapx") + mapy = conf.get("mapy") + combatants: Dict[Tuple[int,int], Player] = dict() + if self._db._quest and self._db._quest.mode == 2: + assert self._db._quest.stage is not None + assert self._db._quest.dests is not None + destx = self._db._quest.dests[self._db._quest.stage-1][0] + desty = self._db._quest.dests[self._db._quest.stage-1][1] + for p in self._db._quest.questors: + if not rand.randomly("quest_movement", 10): + # Move at 10% speed when questing. + op = [x for x in op if x != p] + continue + # mode 2 questors always move towards the next goal + xmove = 0 + ymove = 0 + distx = destx - p.posx + if distx != 0: + if abs(distx) > mapx/2: + distx = -distx + xmove = int(distx / abs(distx)) # normalize to -1/0/1 + + disty = desty - p.posy + if disty != 0: + if abs(disty) > mapy/2: + disty = -disty + ymove = int(disty / abs(disty)) # normalize to -1/0/1 + + p.posx = (p.posx + xmove) % mapx + p.posy = (p.posy + ymove) % mapy + # take questors out of rotation for movement and pvp + op = [x for x in op if x != p] + + for p in op: + # everyone else wanders aimlessly + p.posx = (p.posx + rand.randint('move_player_x',-1,1)) % mapx + p.posy = (p.posy + rand.randint('move_player_y',-1,1)) % mapy + + if (p.posx, p.posy) in combatants: + combatant = combatants[(p.posx, p.posy)] + if combatant.isadmin and rand.randomly('move_player_bow', 100): + self.chanmsg(f"{C('name', p.name)} encounters {C('name', combatant.name)} and bows humbly.") + elif rand.randomly('move_player_combat', len(op)): + self.pvp_battle(p, combatant, + 'come upon', + 'and taken them in combat', + 'and been defeated in combat') + del combatants[(p.posx, p.posy)] + else: + combatants[(p.posx, p.posy)] = p + + + def quest_start(self, now: int) -> None: + """Start a random quest with four random players.""" + latest_login_time = now - conf.get("quest_min_login") + qp = [p for p in self._db.online_players() if p.level > conf.get("quest_min_level") and p.lastlogin.timestamp() < latest_login_time] + if len(qp) < 4: + return + qp = rand.sample('quest_members', qp, 4) + questconf = rand.choice('quest_selection', self._events["Q"]) + match = (re.match(r'(1) (.*)', questconf) or + re.match(r'(2) (\d+) (\d+) (\d+) (\d+) (.*)', questconf)) + if not match: + return + new_quest = Quest() + new_quest.questors = qp + if match[1] == '1': + quest_time = rand.randint('quest_time', 6, 12)*3600 + new_quest.mode = 1 + new_quest.text = match[2] + new_quest.qtime = int(time.time()) + quest_time + self.chanmsg(f"{C('name', qp[0].name)}, {C('name', qp[1].name)}, {C('name', qp[2].name)}, and {C('name', qp[3].name)} have " + f"been chosen by the gods to {new_quest.text}. Quest to end in " + f"{duration(quest_time)}.") + log.info("Starting mode 1 quest with duration %s", duration(quest_time)) + elif match[1] == '2': + new_quest.mode = 2 + new_quest.stage = 1 + new_quest.dests = [(int(match[2]), int(match[3])), (int(match[4]), int(match[5]))] + new_quest.text = match[6] + mapnotice = '' + if conf.has("mapurl"): + mapnotice = f" See {conf.get('mapurl')} to monitor their journey's progress." + self.chanmsg(f"{C('name', qp[0].name)}, {C('name', qp[1].name)}, {C('name', qp[2].name)}, and {C('name', qp[3].name)} have " + f"been chosen by the gods to {new_quest.text}. Participants must first " + f"reach ({new_quest.dests[0][0]},{new_quest.dests[0][1]}), " + f"then ({new_quest.dests[1][0]},{new_quest.dests[1][1]}).{mapnotice}") + log.info("Starting mode 2 quest") + self._db.update_quest(new_quest) + + + def quest_check(self, now: int) -> None: + """Complete quest if criteria are met.""" + if self._db._quest is None: + if now >= self._qtimer: + self.quest_start(now) + elif self._db._quest.mode == 1: + assert self._db._quest.qtime is not None + if now >= self._db._quest.qtime: + qp = self._db._quest.questors + self.logchanmsg(qp, + f"{C('name', qp[0].name)}, {C('name', qp[1].name)}, {C('name', qp[2].name)}, and {C('name', qp[3].name)} " + f"have blessed the realm by completing their quest! 25% of " + f"their burden is eliminated.") + for q in qp: + q.nextlvl = int(q.nextlvl * 0.75) + self._qtimer = now + conf.get("quest_interval_min") + self._db.update_quest(None) + log.info("Quest completed (mode 1)") + + elif self._db._quest.mode == 2: + assert self._db._quest.stage is not None + assert self._db._quest.dests is not None + destx = self._db._quest.dests[self._db._quest.stage-1][0] + desty = self._db._quest.dests[self._db._quest.stage-1][1] + done = True + for q in self._db._quest.questors: + if q.posx != destx or q.posy != desty: + done = False + break + if done: + self._db._quest.stage += 1 + qp = self._db._quest.questors + dests_left = len(self._db._quest.dests) - self._db._quest.stage + 1 + if dests_left > 0: + self.chanmsg(f"{C('name', qp[0].name)}, {C('name', qp[1].name)}, {C('name', qp[2].name)}, and {C('name', qp[3].name)} " + f"have reached a landmark on their journey! {dests_left} " + f"landmark{plural(dests_left)} " + f"remain{plural(dests_left, 's', '')}.") + else: + self.logchanmsg(qp, f"{C('name', qp[0].name)}, {C('name', qp[1].name)}, {C('name', qp[2].name)}, and {C('name', qp[3].name)} " + f"have completed their journey! 25% of " + f"their burden is eliminated.") + for q in qp: + q.nextlvl = int(q.nextlvl * 0.75) + self._db._quest = None + self._qtimer = now + conf.get("quest_interval_min") + log.info("Quest completed (mode 2)") + self._db.update_quest(self._db._quest) diff --git a/dawdle/chunk.py b/dawdle/chunk.py new file mode 100644 index 0000000..5996da0 --- /dev/null +++ b/dawdle/chunk.py @@ -0,0 +1,34 @@ +import itertools +from typing import Iterable, Iterator, Tuple, TypeVar + + +T = TypeVar("T") + + +def chunk(iterable: Iterable[T], n: int) -> Iterator[Tuple[T, ...]]: + """Collect data into chunks of size not more than n. + + The last chunk may be a different length than n. + chunk('ABCDEFG', 3) --> ABC DEF G + """ + iterator = iter(iterable) + while True: + group = tuple(itertools.islice(iterator, 0, n)) + if not group: + break + yield group + + +def padded_chunk(iterable: Iterable[T], n: int, pad: T) -> Iterator[Tuple[T, ...]]: + """Collect data into chunks of exactly n elements. + + The chunks will all be the same size, padded with the pad value. + padded_chunk('ABCDEFG', 3, 'x') --> ABC DEF Gxx + """ + iterator = iter(iterable) + while True: + group = list(itertools.islice(iterator, 0, n)) + if not group: + break + group.extend(itertools.repeat(pad, n - len(group))) + yield tuple(group) diff --git a/dawdle/conf.py b/dawdle/conf.py new file mode 100644 index 0000000..18e1856 --- /dev/null +++ b/dawdle/conf.py @@ -0,0 +1,153 @@ +import argparse +import logging +import os.path +import re +import sys + +from dawdle.log import log +from typing import cast, Any, Dict, List, Union + +DURATION_RE = re.compile(r"(\d+)([dhms])") +NUMERIC_RE = re.compile(r"[+-]?\d+(?:(\.)\d*)?") + +_conf = dict() + + +def parse_val(s: str) -> Union[bool, float, str]: + """Parse values used in the configuration file.""" + if s in ["on", "yes", "true"]: + return True + if s in ["off", "no", "false"]: + return False + istime = DURATION_RE.match(s) + if istime: + return int(istime[1]) * {"d":86400, "h": 3600, "m": 60, "s": 1}[istime[2]] + + isnum = NUMERIC_RE.match(s) + if isnum: + if isnum[1]: + return float(s) + return int(s) + return s + + +def read_config(path: str) -> Dict[str, Any]: + """Return dict with contents of configuration file.""" + newconf = { + "setup": False, + "servers": [], + "okurls": [], + "loggers": [], + "localaddr": None, + # Legacy idlerpg option + "debug": False, + # Non-idlerpg config needs defaults + "confpath": os.path.realpath(path), + "datadir": os.path.realpath(os.path.dirname(path)), + "rpmaxexplvl": 60, + "allylvlbase": 200, + "allylvlstep": 1.16, + "allymaxexplvl": 60, + "backupdir": ".dbbackup", + "store_format": "idlerpg", + "daemonize": True, + "loglevel": "DEBUG", + "throttle": True, + "throttle_rate": 4, + "throttle_period": 1, + "penquest": 15, + "pennick": 30, + "penmessage": 1, + "penpart": 200, + "penkick": 250, + "penquit": 20, + "pendropped": 20, + "penlogout": 20, + "good_battle_pct": 110, + "evil_battle_pct": 90, + "max_name_len": 16, + "max_class_len": 30, + "message_wrap_len": 400, + "quest_interval_min": 12*3600, + "quest_interval_max": 24*3600, + "quest_min_level": 24, + "quest_min_login": 36000, + "color": False, + "namecolor": "cyan", + "durationcolor": "green", + "itemcolor": "olive", + } + + ignore_line_re = re.compile(r"^\s*(?:#|$)") + config_line_re = re.compile(r"^\s*(\S+)\s*(.*)$") + try: + with open(path) as inf: + for line in inf: + if ignore_line_re.match(line): + continue + match = config_line_re.match(line) + if not match: + log.warning("Invalid config line: "+line) + continue + key, val = match[1].lower(), match[2].rstrip() + if key == "die": + log.critical(f"Please edit {path} to setup your bot's options.") + sys.exit(1) + elif key == "server": + cast(List[str], newconf["servers"]).append(val) + elif key == "okurl": + cast(List[str], newconf["okurls"]).append(val) + elif key == "log": + cast(List[List[str]], newconf["loggers"]).append(val.split(" ", 2)) + else: + newconf[key] = parse_val(val) + except OSError as err: + log.critical(f"Unable to read {path}: {err}") + sys.exit(1) + return newconf + + +def init() -> None: + global _conf + parser = argparse.ArgumentParser(description="IdleRPG clone") + parser.add_argument("-o", "--override", action='append', default=[], help="Override config option in k=v format.") + parser.add_argument("--setup", default=False, action="store_true", help="Begin initial setup.") + parser.add_argument("--migrate", help="Migrate game to slqite3 db at path.") + parser.add_argument("config_file", help="Path to configuration file. You must specify this.") + + args = parser.parse_args() + _conf.update(read_config(args.config_file)) + + _conf['setup'] = args.setup + _conf['migrate'] = args.migrate + + # override configurations from command line + server_overrides = [] + okurl_overrides = [] + for pair in args.override: + if "=" not in pair: + sys.stderr.write("Overrides must be in k=v format.\n") + sys.exit(1) + k,v = pair.split('=', 1) + if k == "server": + server_overrides.append(v) + elif k == "okurl": + okurl_overrides.append(v) + else: + _conf[k] = parse_val(v) + if server_overrides: + _conf["servers"] = server_overrides + if okurl_overrides: + _conf["okurls"] = okurl_overrides + + # Debug flag turns off daemonization, sets loglevel to debug, and logs to stderr + if _conf["debug"]: + _conf["daemonize"] = False + _conf["loglevel"] = logging.DEBUG + + +def get(key: str) -> Any: + return _conf[key] + +def has(key: str) -> Any: + return key in _conf diff --git a/dawdle/irc.py b/dawdle/irc.py new file mode 100644 index 0000000..6fba827 --- /dev/null +++ b/dawdle/irc.py @@ -0,0 +1,589 @@ +import asyncio +import collections +import logging +import os +import re +import textwrap +import time + +from dawdle import abstract +from dawdle import chunk +from dawdle import conf +from dawdle.log import log +from typing import Dict, Iterable, List, Optional, Set, TypeVar + + +T = TypeVar("T") + +class IRCClient(abstract.AbstractClient): + """IRCClient acts as a layer between the IRC protocol and the bot protocol. + + This class has the following responsibilities: + - Connection and disconnection + - Nick recovery + - Output throttling + - NickServ authentication + - IRC message decoding and parsing + - Tracking users in the channel + - Calling methods on the bot interface + """ + MESSAGE_RE = re.compile(r'^(?:@(\S*) )?(?::([^ !]*)(?:!([^ @]*)(?:@([^ ]*))?)?\s+)?(\S+)\s*((?:[^:]\S*(?:\s+|$))*)(?::(.*))?') + + Message = collections.namedtuple('Message', ['tags', 'src', 'user', 'host', 'cmd', 'args', 'trailing', 'line', 'time']) + + + @staticmethod + def parse_message(line: str) -> Optional[Message]: + """Parse IRC line into a Message.""" + # Parse IRC message with a regular expression + match = IRCClient.MESSAGE_RE.match(line) + if not match: + return None + rawtags, src, user, host, cmd, argstr, trailing = match.groups() + # IRCv3 supports tags + tags: Dict[str, Optional[str]] = {} + if rawtags is not None and rawtags != "": + for pairstr in rawtags.split(';'): + pair = pairstr.split('=') + if len(pair) == 2: + tags[pair[0]] = re.sub(r"\\(.)", + lambda m: {":": ";", "s": " ", "r": "\r", "n": "\n"}.get(m[1], m[1]), + pair[1]) + else: + tags[pair[0]] = None + # Arguments before the trailing argument (after the colon) are space-delimited + args = [] if argstr == "" else argstr.rstrip().split(' ') + # There's nothing special about the trailing argument except it can have spaces. + if trailing is not None: + args.append(trailing) + # Numeric responses specify a useless target afterwards + if re.match(r'\d+', cmd): + args = args[1:] + # Support time tag, which allows servers and bouncers to send history + if 'time' in tags and tags["time"] is not None: + msgtime = time.mktime(time.strptime(tags['time'], "%Y-%m-%dT%H:%M:%S")) + else: + msgtime = time.time() + return IRCClient.Message(tags, src, user, host, cmd, args, trailing, line, msgtime) + + + class User(abstract.AbstractClient.User): + """An IRC user in the channel.""" + + def __init__(self, nick: str, userhost: str, modes: List[str], joined: float): + self.nick = nick + self.userhost = userhost + self.modes = set(modes) + self.joined = joined + + + _bot: abstract.AbstractBot + _writer: Optional[asyncio.StreamWriter] + _nick: str + _bytes_sent: int + _bytes_received: int + _caps: Set[str] + _server: Optional[str] + _messages_sent: int + _writeq: List[bytes] + _flushq_task: Optional[asyncio.Task] # type: ignore + _prefixmodes: Dict[str, str] + _maxmodes: int + _modetypes: Dict[str, int] + _users: Dict[str, abstract.AbstractClient.User] + quitting: bool + + def __init__(self, bot: abstract.AbstractBot): + self._bot = bot + self._writer = None + self._nick = conf.get("botnick") + self._bytes_sent = 0 + self._bytes_received = 0 + self._caps = set() + self._server = None + self._messages_sent = 0 + self._writeq = [] + self._flushq_task = None + self._prefixmodes = {} + self._maxmodes = 3 + self._modetypes = {} + self._users = {} + self.quitting = False + + + async def connect(self, addr: str, port: str) -> None: + """Connect to IRC network and handle messages.""" + reader, self._writer = await asyncio.open_connection(addr, port, ssl=True, local_addr=conf.get("localaddr")) + self._server = addr + self._connected = True + self._messages_sent = 0 + self._writeq = [] + self._flushq_task = None + self._prefixmodes = {} + self._maxmodes = 3 + self._modetypes = {} + self._users = {} + self._caps = set() # all enabled capabilities + self.sendnow("CAP REQ :multi-prefix userhost-in-names") + self.sendnow("CAP END") + if 'BOTPASS' in os.environ: + self.sendnow(f"PASS {os.environ['BOTPASS']}") + self.sendnow(f"NICK {conf.get('botnick')}") + self.sendnow(f"USER {conf.get('botuser')} 0 * :{conf.get('botrlnm')}") + self._bot.connected(self) + while True: + linebytes = await reader.readline() + if not linebytes: + if self._flushq_task: + self._flushq_task.cancel() + self._bot.disconnected() + self._writer.close() + await self._writer.wait_closed() + self._writer = None + break + self._bytes_received += len(linebytes) + # Assume utf-8 encoding, fall back to latin-1, which has no invalid encodings from bytes. + try: + line = str(linebytes, encoding='utf8') + except UnicodeDecodeError: + line = str(linebytes, encoding='latin-1') + line = line.rstrip('\r\n') + loglevel = 5 if re.match(r"^PING ", line) else logging.DEBUG + log.log(loglevel, "<- %s", line) + msg = IRCClient.parse_message(line) + if msg: + self.dispatch(msg) + + + def send(self, s: str, loglevel: int=logging.DEBUG) -> None: + """Send throttled messages.""" + assert self._writer is not None + b = bytes(s+"\r\n", encoding='utf8') + + if not conf.get("throttle"): + log.log(loglevel, "-> %s", s) + self._writer.write(b) + self._bytes_sent += len(b) + return + + if self._messages_sent < conf.get("throttle_rate"): + log.log(loglevel, "(%d)-> %s", self._messages_sent, s) + self._writer.write(b) + self._messages_sent += 1 + self._bytes_sent += len(b) + else: + self._writeq.append(b) + + # The flushq task will reset messages_sent after the throttle period. + if not self._flushq_task: + self._flushq_task = asyncio.create_task(self.flushq_task()) + + + def sendnow(self, s: str, loglevel:int=logging.DEBUG) -> None: + """Send messages ignoring throttle.""" + assert self._writer is not None + log.log(loglevel, "=> %s", s) + b = bytes(s+"\r\n", encoding='utf8') + self._writer.write(b) + self._messages_sent += 1 + self._bytes_sent += len(b) + if conf.get("throttle") and not self._flushq_task: + self._flushq_task = asyncio.create_task(self.flushq_task()) + + + async def flushq_task(self) -> None: + """Flush send queue and release throttle.""" + assert self._writer is not None + await asyncio.sleep(conf.get("throttle_period")) + self._messages_sent = max(0, self._messages_sent - conf.get("throttle_rate")) + while self._writeq: + while self._writeq and self._messages_sent < conf.get("throttle_rate"): + log.debug("(%d)~> %s", self._messages_sent, str(self._writeq[0], encoding='utf8').rstrip()) + self._writer.write(self._writeq[0]) + self._messages_sent += 1 + self._bytes_sent += len(self._writeq[0]) + self._writeq = self._writeq[1:] + if self._writeq: + await asyncio.sleep(conf.get("throttle_period")) + self._messages_sent = max(0, self._messages_sent - conf.get("throttle_rate")) + + self._flushq_task = None + + + def servername(self) -> str: + if self._server: + return self._server + return "" + + + def bytes_sent(self) -> int: + return self._bytes_sent + + + def bytes_received(self) -> int: + return self._bytes_received + + + def writeq_len(self) -> int: + """Returns number of messages in the write queue.""" + return sum([len(b) for b in self._writeq]) + + + def writeq_bytes(self) -> int: + """Returns number of bytes in the write queue.""" + return sum([len(b) for b in self._writeq]) + + + def clear_writeq(self) -> None: + self._writeq.clear() + + + def dispatch(self, msg: Message) -> None: + """Dispatch the IRC command to a handler method.""" + if hasattr(self, "handle_"+msg.cmd.lower()): + getattr(self, "handle_"+msg.cmd.lower())(msg) + + + def handle_ping(self, msg: Message) -> None: + """PING - sends PONG back to server for keepalive.""" + self.sendnow(f"PONG :{msg.trailing}", loglevel=5) + + + def handle_005(self, msg: Message) -> None: + """RPL_ISUPPORT - server features and information""" + self._server = msg.src + params = dict([arg.split('=') if '=' in arg else (arg, arg) for arg in msg.args]) + if 'MODES' in params: + self._maxmodes = int(params['MODES']) + if 'PREFIX' in params: + m = re.match(r'\(([^)]*)\)(.*)', params['PREFIX']) + if m: + self._prefixmodes.update(zip(m[2], m[1])) + for mode in m[1]: + self._modetypes[mode] = 2 + if 'CHANMODES' in params: + m = re.match(r'([^,]*),([^,]*),([^,]*),(.*)', params['CHANMODES']) + if m: + for mode in m[1]: + self._modetypes[mode] = 1 # adds to a list and always has a parameter + for mode in m[2]: + self._modetypes[mode] = 2 # changes a setting and always has param + for mode in m[3]: + self._modetypes[mode] = 3 # only has a parameter when set + for mode in m[4]: + self._modetypes[mode] = 4 # never has a parameter + + + def handle_376(self, msg: Message) -> None: + """RPL_ENDOFMOTD - server is ready""" + self.mode(conf.get("botnick"), conf.get("botmodes")) + if conf.has("botident"): + self.send(conf.get("botident")) + if conf.has("botlogin"): + self.sendnow(conf.get("botlogin")) + self.join(conf.get("botchan")) + + + def handle_422(self, msg: Message) -> None: + """ERR_NOTMOTD - server is ready, but without a MOTD""" + self.mode(conf.get("botnick"), conf.get("botmodes")) + if conf.has("botident"): + self.sendnow(f"{conf.get('botident')}") + if conf.has("botlogin"): + self.sendnow(conf.get("botlogin")) + self.join(conf.get("botchan")) + + + def handle_352(self, msg: Message) -> None: + """RPL_WHOREPLY - Response to WHO command""" + self.add_user(msg.args[4], + f"{msg.src}!{msg.args[1]}@{msg.args[2]}", + [self._prefixmodes[p] for p in msg.args[5][1:]], # Format is [GH]\S* + msg.time) + + + def handle_315(self, msg: Message) -> None: + """RPL_ENDOFWHO - End of WHO command response""" + self._bot.ready() + + + def handle_353(self, msg: Message) -> None: + """RPL_NAMREPLY - names in the channel""" + if 'userhost-in-names' not in self._caps: + return + prefixes=''.join(self._prefixmodes.keys()) + userhost_re = re.compile(f"([{prefixes}]*)" + r"((\S+)!\S+@\S+)") + for u in msg.trailing.split(' '): + m = userhost_re.match(u) + if m: + self.add_user(m[3], m[2], [self._prefixmodes[p] for p in m[1]], msg.time) + + + def handle_366(self, msg: Message) -> None: + """RPL_ENDOFNAMES - the actual end of channel joining""" + # We know who is in the channel now + if conf.has("botopcmd"): + self.sendnow(re.sub(r'%botnick%', self._nick, conf.get("botopcmd"))) + if 'userhost-in-names' in self._caps: + self._bot.ready() + else: + self.send(f"WHO {conf.get('botchan')}") + + + def handle_433(self, msg: Message) -> None: + """ERR_NICKNAME_IN_USE - try another nick""" + self._nick = self._nick + "0" + self.nick(self._nick) + if conf.has("botghostcmd"): + self.sendnow(conf.get("botghostcmd")) + + + def handle_444(self, msg: Message) -> None: + """ERR_NOLOGIN - """ + if conf.has("botident"): + self.sendnow(conf.get("botident")) + if conf.has("botlogin"): + self.sendnow(conf.get("botlogin")) + + + def handle_cap(self, msg: Message) -> None: + """CAP - notification of capability""" + # We only care about enabled capabilities. + if msg.args[1] == "ACK": + self._caps.update(msg.args[2].split(' ')) + + + def handle_join(self, msg: Message) -> None: + """JOIN - bot or user joined the channel.""" + self.add_user(msg.src, f"{msg.src}!{msg.user}@{msg.host}", [], msg.time) + + + def handle_part(self, msg: Message) -> None: + """PART - bot or user left the channel.""" + user = self.remove_user(msg.src) + self._bot.nick_parted(user) + + + def handle_kick(self, msg: Message) -> None: + """KICK - user was kicked from the channel.""" + user = self.remove_user(msg.args[1]) + self._bot.nick_kicked(user) + + + def handle_mode(self, msg: Message) -> None: + """MODE - bot or channel changed its mode.""" + # ignore mode changes to everything except the bot channel + if msg.args[0] != conf.get("botchan"): + return + changes = [] + params = [] + for arg in msg.args[1:]: + m = re.match(r'([-+])(.*)', arg) + if m: + changes.extend([(m[1], term) for term in m[2]]) + else: + params.append(arg) + for change in changes: + # all this modetype machinery is required to accurately parse modelines + modetype = self._modetypes[change[1]] + if modetype == 1 or modetype == 2 or (modetype == 3 and change[0] == '+'): + param = params.pop() + if modetype != 2: + continue + if change[0] == '+': + self._users[param].modes.add(change[1]) + if param == self._nick and change[1] == 'o': + # Acquiring op is special to the bot + self._bot.acquired_ops() + else: + self._users[param].modes.discard(change[1]) + + + def handle_nick(self, msg: Message) -> None: + """NICK - bot or user had its nick changed.""" + + # Do this first so that the user still matches the player. + self._bot.nick_changed(self._users[msg.src], msg.args[0]) + + self._users[msg.args[0]] = self._users[msg.src] + self._users[msg.args[0]].nick = msg.args[0] + del self._users[msg.src] + + if msg.src == self._nick: + # Update my nick + self._nick = msg.args[0] + return + + if msg.src == conf.get("botnick"): + # Grab my nick that someone left + self.nick(conf.get("botnick")) + + + def handle_quit(self, msg: Message) -> None: + """QUIT - bot or user was disconnected.""" + if msg.src == conf.get("botnick"): + # Grab my nick that someone left + self.nick(conf.get("botnick")) + user = self.remove_user(msg.src) + if conf.get("detectsplits") and re.match(r'\S+\.\S+ \S+\.\S+', msg.trailing): + # Don't penalize on netsplit + self._bot.netsplit(user) + elif re.match(r"Read error|Ping timeout", msg.trailing): + self._bot.nick_dropped(user) + else: + self._bot.nick_quit(user) + + + def handle_notice(self, msg: Message) -> None: + """NOTICE - Message sent, used to prevent loops in bots.""" + if msg.args[0] != self._nick and msg.src in self._users and self.user_is_ok(msg): + # we ignore private notices + self._bot.channel_notice(self._users[msg.src], msg.trailing) + + + def handle_privmsg(self, msg: Message) -> None: + """PRIVMSG - Message sent.""" + if msg.src not in self._users: + # Server messages + return + if msg.args[0] == self._nick: + self._bot.private_message(self._users[msg.src], msg.trailing) + elif self.user_is_ok(msg): + self._bot.channel_message(self._users[msg.src], msg.trailing) + + + def add_user(self, nick: str, userhost: str, modes: List[str], joined: float) -> None: + """Adds channel user with the given properties.""" + self._users[nick] = IRCClient.User(nick, userhost, modes, joined) + + + def remove_user(self, nick: str) -> "abstract.AbstractClient.User": + """Remove user with the given nick. Returns that user.""" + user = self._users[nick] + del self._users[nick] + if len(self._users) == 1 and not self.bot_has_ops(): + # Try to acquire ops by leaving and joining + self.sendnow(f"PART {conf.get('botchan')} :Acquiring ops") + self.sendnow(f"JOIN {conf.get('botchan')}") + return user + + + def user_exists(self, nick: str) -> bool: + return nick in self._users + + + def user_is_ok(self, msg: Message) -> bool: + """Check to see if msg should cause user to be kickbanned.""" + if not conf.get("doban"): + # Bot doesn't do bans + return True + if not self.bot_has_ops(): + # Bot can't do bans + return True + if msg.src == self._nick: + # Bot is always ok + return True + if msg.src not in self._users: + # Not in channel - maybe channel could use mode +n + return False + if msg.time > self._users[msg.src].joined + conf.get("bannable_time"): + # Been in channel for a while, prob ok? + return True + + for host in re.findall(r"https?://([^/]+)/", msg.trailing): + if host not in conf.get("okurls"): + # User not okay + self.kickban(msg.src) + return False + return True + + + def match_user(self, nick: str, userhost: str) -> bool: + """Return True if the nick and userhost match an existing user.""" + return nick in self._users and userhost == self._users[nick].userhost + + + def is_bot_nick(self, nick: str) -> bool: + return nick == self._nick or nick == conf.get("botnick") + + + def bot_has_ops(self) -> bool: + """Return True if the bot has ops in the channel.""" + return self._nick in self._users and 'o' in self._users[self._nick].modes + + + def nick_userhost(self, nick: str) -> Optional[str]: + if nick not in self._users: + return None + return self._users[nick].userhost + + + def kickban(self, nick: str) -> None: + """Kick a nick from the channel and ban them.""" + self.sendnow(f"MODE {conf.get('botchan')} +b {nick}") + self.sendnow(f"KICK {conf.get('botchan')} {nick} :No advertising") + + + def nick(self, nick: str) -> None: + """Send nick change request.""" + self.sendnow(f"NICK {nick}") + + + def join(self, channel: str) -> None: + """Send channel join request.""" + time.sleep(5) + self.send(f"JOIN {channel}") + + + def grant_voice(self, *targets: str) -> None: + for subset in chunk.chunk(targets, self._maxmodes): + self.send(f"MODE {conf.get('botchan')} +{'v' * len(subset)} {' '.join(subset)}") + + + def revoke_voice(self, *targets: str) -> None: + for subset in chunk.chunk(targets, self._maxmodes): + self.send(f"MODE {conf.get('botchan')} -{'v' * len(subset)} {' '.join(subset)}") + + + def set_channel_voices(self, voiced_nicks: Iterable[str]) -> None: + add_voice = [] + remove_voice = [] + for u in self._users.keys(): + if 'v' in self._users[u].modes: + if u not in voiced_nicks: + remove_voice.append(u) + else: + if u in voiced_nicks: + add_voice.append(u) + if add_voice: + self.grant_voice(*add_voice) + if remove_voice: + self.revoke_voice(*remove_voice) + + + + def mode(self, target: str, *modeinfo: str) -> None: + """Send mode change request.""" + for modes in chunk.chunk(modeinfo, self._maxmodes): + self.send(f"MODE {target} {' '.join(modes)}") + + + def notice(self, target: str, text: str) -> None: + """Send notice text to target.""" + for line in textwrap.wrap(text, width=conf.get("message_wrap_len")): + self.send(f"NOTICE {target} :{line}") + + + def chanmsg(self, text: str) -> None: + """Send message text to bot channel.""" + for line in textwrap.wrap(text, width=conf.get("message_wrap_len")): + self.send(f"PRIVMSG {conf.get('botchan')} :{line}") + + + def quit(self, text: str) -> None: + """Send quit request to server.""" + self.quitting = True + if text: + self.sendnow(f"QUIT :{text}") + else: + self.sendnow("QUIT") diff --git a/dawdle/log.py b/dawdle/log.py new file mode 100644 index 0000000..577f295 --- /dev/null +++ b/dawdle/log.py @@ -0,0 +1,23 @@ +import logging +import sys + +from typing import Union + +logging.addLevelName(5, "SPAMMY") + + +log = logging.getLogger() +log.setLevel(0) + + +def add_handler(loglevel:Union[str, int] , logfile: str, template: str) -> None: + h: Union[logging.StreamHandler, logging.FileHandler] + if logfile == "/dev/stdout": + h = logging.StreamHandler(sys.stdout) + elif logfile == "/dev/stderr": + h = logging.StreamHandler(sys.stderr) + else: + h = logging.FileHandler(logfile) + h.setLevel(loglevel) + h.setFormatter(logging.Formatter(template)) + log.addHandler(h) diff --git a/dawdle/rand.py b/dawdle/rand.py new file mode 100644 index 0000000..1f71720 --- /dev/null +++ b/dawdle/rand.py @@ -0,0 +1,53 @@ +import random +from typing import cast, Any, Dict, List, MutableSequence, Sequence, TypeVar + +T = TypeVar("T") + + +overrides: Dict[str,Any] = {} + + +def randomly(key: str, odds: int) -> bool: + """Overrideable random func which returns true at 1:ODDS odds.""" + if key in overrides: + return cast(bool, overrides[key]) + return random.randint(0, odds-1) < 1 + + +def randint(key: str, bottom: int, top: int) -> int: + """Overrideable random func which returns an integer bottom <= i <= top.""" + if key in overrides: + return cast(int, overrides[key]) + return random.randint(int(bottom), int(top)) + + +def gauss(key: str, mu: float, sigma: float) -> int: + """Overrideable func which returns an random int with gaussian distribution.""" + if key in overrides: + return cast(int, overrides[key]) + return int(random.gauss(mu, sigma)) + +def sample(key: str, seq: Sequence[T], count: int) -> List[T]: + """Overrideable random func which returns random COUNT elements of SEQ.""" + if key in overrides: + return cast(List[T], overrides[key]) + return random.sample(seq, count) + + +def choice(key: str, seq: Sequence[T]) -> T: + """Overrideable random func which returns one random element of SEQ.""" + if key in overrides: + return cast(T, overrides[key]) + # Don't use random.choice here - it uses random access, which + # is unsupported by the dict_keys view. + return random.sample(seq, 1)[0] + + +def shuffle(key: str, seq: MutableSequence[Any]) -> None: + """Overrideable random func which does an in-place shuffle of SEQ.""" + if key in overrides: + seq.clear() + seq.extend(overrides[key]) + return None + random.shuffle(seq) + return None diff --git a/dawdle/test_bot.py b/dawdle/test_bot.py new file mode 100755 index 0000000..2db111c --- /dev/null +++ b/dawdle/test_bot.py @@ -0,0 +1,917 @@ +#!/usr/bin/python3 + +import datetime +import os.path +import tempfile +import time +import unittest + +from dawdle import bot +from dawdle import conf +from dawdle import irc +from dawdle import rand + + +class TestGameDBSqlite3(unittest.TestCase): + def test_db(self): + conf._conf['rpbase'] = 600 + conf._conf['rpstep'] = 1.16 + conf._conf['rpmaxexplvl'] = 60 + with tempfile.TemporaryDirectory() as tmpdir: + db = bot.GameDB(bot.Sqlite3GameStorage(os.path.join(tmpdir, 'dawdle_test.db'))) + self.assertFalse(db.exists()) + db.create() + p = db.new_player('foo', 'bar', 'baz') + p.online = True + p.lastlogin = datetime.datetime.now() + datetime.timedelta(days=5) + db.write_players() + self.assertTrue(db.exists()) + db.load_state() + self.assertEqual(db['foo'].name, 'foo') + self.assertEqual(db['foo'].online, True) + self.assertEqual(db['foo'].created, p.created) + self.assertEqual(db['foo'].lastlogin, p.lastlogin) + db.close() + + def test_passwords(self): + with tempfile.TemporaryDirectory() as tmpdir: + db = bot.GameDB(bot.Sqlite3GameStorage(os.path.join(tmpdir, 'dawdle_test.db'))) + self.assertFalse(db.exists()) + db.create() + p = db.new_player('foo', 'bar', 'baz') + self.assertTrue(db.check_login('foo', 'baz')) + self.assertFalse(db.check_login('foo', 'azb')) + p.set_password('azb') + self.assertTrue(db.check_login('foo', 'azb')) + db.close() + + +class TestGameDBIdleRPG(unittest.TestCase): + def setUp(self): + self.maxDiff = None + + + def test_db(self): + conf._conf['botnick'] = '' + conf._conf['writequestfile'] = True + conf._conf['questfilename'] = "/tmp/testquestfile.txt" + with tempfile.TemporaryDirectory() as tmpdir: + db = bot.GameDB(bot.IdleRPGGameStorage(os.path.join(tmpdir, 'dawdle_test.db'))) + db.create() + db.update_quest(None) + op = db.new_player('foo', 'bar', 'baz') + op.items['amulet'] = bot.Item(55, '') + op.items['helm'] = bot.Item(42, "Jeff's Cluehammer of Doom") + db.write_players() + db.load_state() + p = db['foo'] + self.assertEqual(vars(op), vars(p)) + db.close() + + +class FakeIRCClient(object): + def __init__(self): + self._nick = 'dawdlerpg' + self._users = {} + self.server = "irc.example.com" + self.chanmsgs = [] + self.notices = {} + + + def user_exists(self, nick): + return nick in self._users + + + def nick_userhost(self, nick): + return self._users[nick] + + + def match_user(self, nick, userhost): + return nick in self._users and userhost == self._users[nick].userhost + + + def bot_has_ops(self): + return False + + + def resetmsgs(self): + self.chanmsgs = [] + self.notices = {} + + + def chanmsg(self, text): + self.chanmsgs.append(text) + + + def notice(self, nick, text): + self.notices.setdefault(nick, []).append(text) + + + def servername(self): + return "irc.example.com" + + +class FakeGameStorage(bot.GameStorage): + + def __init__(self): + self._mem = {} + + def backup(self): + pass + + def create(self): + pass + + def readall(self): + pass + + def write(self, p): + pass + + def close(self): + pass + + def new(self, p): + self._mem[p.name] = p + + def rename(self, old, new): + self._mem[new] = self._mem[old] + self._mem.pop(old) + + def delete_player(self, pname): + self._mem.pop(pname) + + + def add_history(self, players, text): + pass + + + def update_quest(self, quest): + pass + + +class TestBot(unittest.TestCase): + + def test_nick_change(self): + conf._conf['botnick'] = 'dawdlerpg' + conf._conf['botchan'] = '#dawdlerpg' + conf._conf['message_wrap_len'] = 400 + conf._conf['throttle'] = False + conf._conf['pennick'] = 10 + conf._conf['penquit'] = 20 + conf._conf['rppenstep'] = 1.14 + + testbot = bot.DawdleBot(bot.GameDB(FakeGameStorage())) + testirc = FakeIRCClient() + testbot.connected(testirc) + testirc._users['foo'] = irc.IRCClient.User("foo", "foo!foo@example.com", [], 1) + a = testbot._db.new_player('a', 'b', 'c') + a.online = True + a.nick = 'foo' + a.userhost = 'foo!foo@example.com' + self.assertEqual('foo', a.nick) + testbot.nick_changed(testirc._users['foo'], 'bar') + self.assertEqual('bar', a.nick) + testbot.nick_quit(testirc._users['foo']) + + + def test_dropped_conn(self): + conf._conf['splitwait'] = 10 + conf._conf['pendropped'] = 20 + conf._conf['rppenstep'] = 1.14 + testbot = bot.DawdleBot(bot.GameDB(FakeGameStorage())) + testirc = FakeIRCClient() + testbot.connected(testirc) + testirc._users['foo'] = irc.IRCClient.User("foo", "foo!foo@example.com", [], 1) + a = testbot._db.new_player('a', 'b', 'c') + a.online = True + a.nick = 'foo' + a.userhost = 'foo!foo@example.com' + self.assertTrue(testbot._db._players['a'].nick, 'foo') + testbot.nick_dropped(testirc._users['foo']) + del testirc._users['foo'] + testbot.expire_splits() + self.assertTrue(a.online) + a.lastlogin -= datetime.timedelta(seconds=11) + testbot.expire_splits() + self.assertFalse(a.online) + + def test_find_mount(self): + conf._conf['allylvlbase'] = 200 + conf._conf['allylvlstep'] = 1.16 + conf._conf['allymaxexplvl'] = 60 + testbot = bot.DawdleBot(bot.GameDB(FakeGameStorage())) + testirc = FakeIRCClient() + testbot.connected(testirc) + testirc._users['foo'] = irc.IRCClient.User("foo", "foo!foo@example.com", [], 1) + a = testbot._db.new_player('a', 'b', 'c') + a.level = 40 + a.online = True + a.nick = 'foo' + a.userhost = 'foo!foo@example.com' + testbot.find_mount(a) + self.assertIn("mount", a.allies) + + +class TestGameDB(unittest.TestCase): + + def test_top_players(self): + db = bot.GameDB(FakeGameStorage()) + a = db.new_player('a', 'waffle', 'c') + a.level, a.nextlvl = 30, 100 + b = db.new_player('b', 'doughnut', 'c') + b.level, b.nextlvl = 20, 1000 + c = db.new_player('c', 'bagel', 'c') + c.level, c.nextlvl = 10, 10 + self.assertEqual(['a', 'b', 'c'], [p.name for p in db.top_players()]) + + +class TestPvPBattle(unittest.TestCase): + + + def setUp(self): + conf._conf['rpbase'] = 600 + conf._conf['rpstep'] = 1.16 + conf._conf['rpmaxexplvl'] = 60 + conf._conf['modsfile'] = '/tmp/modsfile.txt' + conf._conf['color'] = False + self.bot = bot.DawdleBot(bot.GameDB(FakeGameStorage())) + self.irc = FakeIRCClient() + self.bot.connected(self.irc) + + + def test_player_battle_win(self): + a = self.bot._db.new_player('a', 'b', 'c') + a.items['amulet'] = bot.Item(20, '') + b = self.bot._db.new_player('b', 'c', 'd') + b.items['amulet'] = bot.Item(40, '') + rand.overrides = { + 'pvp_player_roll': 20, + 'pvp_opp_roll': 10, + 'pvp_critical': False, + 'pvp_swap_item': False, + 'pvp_find_item': False + } + self.bot.pvp_battle(a, b, "fought", "and has won", "and has lost") + self.assertListEqual(self.irc.chanmsgs, [ + "a [20/20] has fought b [10/40] and has won! 0 days, 00:00:42 is removed from a's clock.", + "a reaches next level in 0 days, 00:09:18." + ]) + self.assertEqual(a.nextlvl, 558) + + + def test_player_battle_bot(self): + conf._conf['botnick'] = 'dawdlerpg' + a = self.bot._db.new_player('a', 'b', 'c') + a.items['amulet'] = bot.Item(20, '') + rand.overrides = { + 'pvp_player_roll': 20, + 'pvp_opp_roll': 10, + 'pvp_critical': False, + 'pvp_swap_item': False, + 'pvp_find_item': False + } + self.bot.pvp_battle(a, None, "fought", "and has won", "and has lost") + self.assertListEqual(self.irc.chanmsgs, [ + "a [20/20] has fought dawdlerpg [10/21] and has won! 0 days, 00:02:00 is removed from a's clock.", + "a reaches next level in 0 days, 00:08:00." + ]) + self.assertEqual(a.nextlvl, 480) + + + def test_player_battle_lose(self): + a = self.bot._db.new_player('a', 'b', 'c') + a.items['amulet'] = bot.Item(20, '') + b = self.bot._db.new_player('b', 'c', 'd') + b.items['amulet'] = bot.Item(40, '') + rand.overrides = { + 'pvp_player_roll': 10, + 'pvp_opp_roll': 20, + 'pvp_critical': False, + 'pvp_swap_item': False, + 'pvp_find_item': False + } + self.bot.pvp_battle(a, b, "fought", "and has won", "and has lost") + self.assertListEqual(self.irc.chanmsgs, [ + "a [10/20] has fought b [20/40] and has lost! 0 days, 00:00:42 is added to a's clock.", + "a reaches next level in 0 days, 00:10:42." + ]) + self.assertEqual(a.nextlvl, 642) + + + def test_player_battle_critical(self): + a = self.bot._db.new_player('a', 'b', 'c') + a.items['amulet'] = bot.Item(20, '') + b = self.bot._db.new_player('b', 'c', 'd') + b.items['amulet'] = bot.Item(40, '') + rand.overrides = { + 'pvp_player_roll': 20, + 'pvp_opp_roll': 10, + 'pvp_critical': True, + 'pvp_cs_penalty_pct': 10, + 'pvp_swap_item': False, + 'pvp_find_item': False + } + self.bot.pvp_battle(a, b, "fought", "and has won", "and has lost") + self.assertListEqual(self.irc.chanmsgs, [ + "a [20/20] has fought b [10/40] and has won! 0 days, 00:00:42 is removed from a's clock.", + "a reaches next level in 0 days, 00:09:18.", + "a has dealt b a Critical Strike! 0 days, 00:01:30 is added to b's clock.", + "b reaches next level in 0 days, 00:11:30." + ]) + self.assertEqual(a.nextlvl, 558) + self.assertEqual(b.nextlvl, 690) + + + def test_player_battle_swapitem(self): + a = self.bot._db.new_player('a', 'b', 'c') + a.level = 20 + a.items['amulet'] = bot.Item(20, '') + b = self.bot._db.new_player('b', 'c', 'd') + b.items['amulet'] = bot.Item(40, '') + rand.overrides = { + 'pvp_player_roll': 20, + 'pvp_opp_roll': 10, + 'pvp_critical': False, + 'pvp_swap_item': True, + 'pvp_swap_itemtype': 'amulet', + 'pvp_find_item': False + } + self.bot.pvp_battle(a, b, "fought", "and has won", "and has lost") + self.assertListEqual(self.irc.chanmsgs, [ + "a [20/20] has fought b [10/40] and has won! 0 days, 00:00:42 is removed from a's clock.", + "a reaches next level in 0 days, 00:09:18.", + "In the fierce battle, b dropped their level 40 amulet! a picks it up, tossing their old level 20 amulet to b." + ]) + self.assertEqual(a.nextlvl, 558) + self.assertEqual(a.items['amulet'].level, 40) + self.assertEqual(b.items['amulet'].level, 20) + + + def test_player_battle_finditem(self): + a = self.bot._db.new_player('a', 'b', 'c') + a.nick = 'a' + a.items['amulet'] = bot.Item(20, '') + b = self.bot._db.new_player('b', 'c', 'd') + b.items['amulet'] = bot.Item(40, '') + rand.overrides = { + 'pvp_player_roll': 20, + 'pvp_opp_roll': 10, + 'pvp_critical': False, + 'pvp_swap_item': False, + 'pvp_find_item': True, + 'specitem_find': False, + 'find_item_slot': 'charm', + 'find_item_level': 5 + } + self.bot.pvp_battle(a, b, "fought", "and has won", "and has lost") + self.assertListEqual(self.irc.chanmsgs, [ + "a [20/20] has fought b [10/40] and has won! 0 days, 00:00:42 is removed from a's clock.", + "a reaches next level in 0 days, 00:09:18.", + "While recovering from battle, a notices a glint in the mud. Upon investigation, they find an old lost item!" + ]) + self.assertListEqual(self.irc.notices['a'], [ + "You found a level 5 charm! Your current charm is only level 0, so it seems Luck is with you!" + ]) + + self.assertEqual(a.nextlvl, 558) + + +class TestTeamBattle(unittest.TestCase): + + + def setUp(self): + conf._conf['rpbase'] = 600 + conf._conf['rpstep'] = 1.16 + conf._conf['rpmaxexplvl'] = 60 + conf._conf['modsfile'] = '/tmp/modsfile.txt' + conf._conf['color'] = False + self.bot = bot.DawdleBot(bot.GameDB(FakeGameStorage())) + self.irc = FakeIRCClient() + self.bot.connected(self.irc) + + + def test_setup_insufficient_players(self): + op = [self.bot._db.new_player(pname, 'a', 'b') for pname in "abcde"] + self.bot.team_battle(op) + self.assertEqual(self.irc.chanmsgs, []) + + + def test_win(self): + op = [self.bot._db.new_player(pname, 'a', 'b') for pname in "abcdef"] + op[0].items['amulet'] = bot.Item(20, "") + op[1].items['amulet'] = bot.Item(20, "") + op[2].items['amulet'] = bot.Item(20, "") + op[3].items['amulet'] = bot.Item(40, "") + op[4].items['amulet'] = bot.Item(40, "") + op[5].items['amulet'] = bot.Item(40, "") + op[0].nextlvl = 1200 + op[1].nextlvl = 3600 + op[2].nextlvl = 3600 + op[3].nextlvl = 3600 + op[4].nextlvl = 3600 + op[5].nextlvl = 3600 + rand.overrides = { + 'team_battle_members': op, + 'team_a_roll': 60, + 'team_b_roll': 30 + } + self.bot.team_battle(op) + self.assertEqual(self.irc.chanmsgs[0], "a, b, and c [60/60] have team battled d, e, and f [30/120] and won! 0 days, 00:04:00 is removed from their clocks.") + + def test_loss(self): + op = [self.bot._db.new_player(pname, 'a', 'b') for pname in "abcdef"] + op[0].items['amulet'] = bot.Item(20, "") + op[1].items['amulet'] = bot.Item(20, "") + op[2].items['amulet'] = bot.Item(20, "") + op[3].items['amulet'] = bot.Item(40, "") + op[4].items['amulet'] = bot.Item(40, "") + op[5].items['amulet'] = bot.Item(40, "") + op[0].nextlvl = 1200 + op[1].nextlvl = 3600 + op[2].nextlvl = 3600 + op[3].nextlvl = 3600 + op[4].nextlvl = 3600 + op[5].nextlvl = 3600 + rand.overrides = { + 'team_battle_members': op, + 'team_a_roll': 30, + 'team_b_roll': 60 + } + self.bot.team_battle(op) + self.assertEqual(self.irc.chanmsgs[0], "a, b, and c [30/60] have team battled d, e, and f [60/120] and lost! 0 days, 00:04:00 is added to their clocks.") + + +class TestEvilness(unittest.TestCase): + + + def setUp(self): + conf._conf['rpbase'] = 600 + conf._conf['rpstep'] = 1.16 + conf._conf['rpmaxexplvl'] = 60 + conf._conf['modsfile'] = '/tmp/modsfile.txt' + conf._conf['color'] = False + self.bot = bot.DawdleBot(bot.GameDB(FakeGameStorage())) + self.irc = FakeIRCClient() + self.bot.connected(self.irc) + + + def test_theft(self): + op = [self.bot._db.new_player('a', 'b', 'c'), self.bot._db.new_player('b', 'c', 'd')] + op[0].alignment = 'e' + op[1].alignment = 'g' + op[1].items['amulet'] = bot.Item(20, "") + rand.overrides = { + 'evilness_theft': True, + 'evilness_slot': 'amulet' + } + self.bot.evilness(op) + self.assertEqual(self.irc.chanmsgs[0], "a stole b's level 20 amulet while they were sleeping! a leaves their old level 0 amulet behind, which b then takes.") + + + def test_penalty(self): + op = [self.bot._db.new_player('a', 'b', 'c')] + op[0].alignment = 'e' + rand.overrides = { + 'evilness_theft': False, + 'evilness_penalty_pct': 5 + } + self.bot.evilness(op) + self.assertEqual(self.irc.chanmsgs[0], "a is forsaken by their evil god. 0 days, 00:00:30 is added to their clock.") + + +class TestGoodness(unittest.TestCase): + + + def setUp(self): + conf._conf['rpbase'] = 600 + conf._conf['rpstep'] = 1.16 + conf._conf['rpmaxexplvl'] = 60 + conf._conf['modsfile'] = '/tmp/modsfile.txt' + conf._conf['color'] = False + self.bot = bot.DawdleBot(bot.GameDB(FakeGameStorage())) + self.irc = FakeIRCClient() + self.bot.connected(self.irc) + + + def test_goodness(self): + op = [self.bot._db.new_player('a', 'b', 'c'), self.bot._db.new_player('b', 'c', 'd')] + op[0].alignment = 'g' + op[1].alignment = 'g' + rand.overrides = { + 'goodness_players': op, + 'goodness_gain_pct': 10, + } + self.bot.goodness(op) + self.assertListEqual(self.irc.chanmsgs, [ + "a and b have not let the iniquities of evil people poison them. Together have they prayed to their god, and light now shines down upon them. 10% of their time is removed from their clocks.", + "a reaches next level in 0 days, 00:09:00.", + "b reaches next level in 0 days, 00:09:00." + ]) + self.assertEqual(op[0].nextlvl, 540) + self.assertEqual(op[1].nextlvl, 540) + + +class TestHandOfGod(unittest.TestCase): + + + def setUp(self): + conf._conf['rpbase'] = 600 + conf._conf['rpstep'] = 1.16 + conf._conf['rpmaxexplvl'] = 60 + conf._conf['color'] = False + self.bot = bot.DawdleBot(bot.GameDB(FakeGameStorage())) + self.irc = FakeIRCClient() + self.bot.connected(self.irc) + + + def test_forward(self): + op = [self.bot._db.new_player('a', 'b', 'c')] + rand.overrides = { + 'hog_effect': False, + 'hog_amount': 10 + } + self.bot.hand_of_god(op) + self.assertEqual(self.irc.chanmsgs[0], "Verily I say unto thee, the Heavens have burst forth, and the blessed hand of God carried a 0 days, 00:01:30 toward level 1.") + self.assertEqual(self.irc.chanmsgs[1], "a reaches next level in 0 days, 00:08:30.") + + + def test_back(self): + op = [self.bot._db.new_player('a', 'b', 'c')] + rand.overrides = { + 'hog_effect': True, + 'hog_amount': 10 + } + self.bot.hand_of_god(op) + self.assertEqual(self.irc.chanmsgs[0], "Thereupon He stretched out His little finger among them and consumed a with fire, slowing the heathen 0 days, 00:01:30 from level 1.") + self.assertEqual(self.irc.chanmsgs[1], "a reaches next level in 0 days, 00:11:30.") + + +class TestQuest(unittest.TestCase): + + + def setUp(self): + conf._conf['rpbase'] = 600 + conf._conf['rpstep'] = 1.16 + conf._conf['rpmaxexplvl'] = 60 + conf._conf['datadir'] = os.path.join(os.path.dirname(os.path.dirname(__file__)), "setup") + conf._conf['eventsfile'] = "events.txt" + conf._conf['writequestfile'] = True + conf._conf['questfilename'] = "/tmp/testquestfile.txt" + conf._conf['quest_interval_min'] = 6*3600 + conf._conf['quest_interval_max'] = 12*3600 + conf._conf['quest_min_level'] = 24 + conf._conf['quest_min_login'] = 36000 + conf._conf['penquest'] = 15 + conf._conf['penlogout'] = 20 + conf._conf['color'] = False + self.bot = bot.DawdleBot(bot.GameDB(FakeGameStorage())) + self.irc = FakeIRCClient() + self.bot.connected(self.irc) + self.bot._state = "ready" + self.bot.refresh_events() + + + def test_questing_mode_1(self): + users = [irc.IRCClient.User(uname, f"{uname}!{uname}@irc.example.com", [], 0) for uname in "abcd"] + op = [self.bot._db.new_player(pname, 'a', 'b') for pname in "abcd"] + now = time.time() + for u,p in zip(users,op): + p.online = True + p.level = 25 + p.lastlogin = datetime.datetime.fromtimestamp(now - 36001) + p.nick = u.nick + p.userhost = u.userhost + rand.overrides = { + "quest_members": op, + "quest_selection": "1 locate the centuries-lost tomes of the grim prophet Haplashak Mhadhu", + "quest_time": 12 + } + self.bot.quest_start(now) + self.bot.private_message(users[0], 'quest') + # time passes + self.bot._db._quest.qtime = now-1 + self.bot.quest_check(now) + + self.assertListEqual(self.irc.chanmsgs, [ + "a, b, c, and d have been chosen by the gods to locate the centuries-lost tomes of the grim prophet Haplashak Mhadhu. Quest to end in 0 days, 12:00:00.", + "a, b, c, and d have blessed the realm by completing their quest! 25% of their burden is eliminated." + ]) + self.assertListEqual(self.irc.notices['a'], [ + "a, b, c, and d are on a quest to locate the centuries-lost tomes of the grim prophet Haplashak Mhadhu. Quest to complete in 0 days, 11:59:59." + ]) + self.assertEqual(op[0].nextlvl, 450) + self.assertEqual(op[1].nextlvl, 450) + self.assertEqual(op[2].nextlvl, 450) + self.assertEqual(op[3].nextlvl, 450) + + + def test_questing_mode_2(self): + conf._conf['mapurl'] = "https://example.com/" + users = [irc.IRCClient.User(uname, f"{uname}!{uname}@irc.example.com", [], 0) for uname in "abcd"] + op = [self.bot._db.new_player(pname, 'a', 'b') for pname in "abcd"] + now = time.time() + for u,p in zip(users,op): + p.online = True + p.level = 25 + p.lastlogin = datetime.datetime.fromtimestamp(now - 36001) + p.nick = u.nick + p.userhost = u.userhost + rand.overrides = { + "quest_members": op, + "quest_selection": "2 400 475 480 380 explore and chart the dark lands of T'rnalvph", + } + self.bot._db._online = op + self.bot.quest_start(now) + self.bot.private_message(users[0], 'quest') + for p in op: + p.posx, p.posy = 400, 475 + self.bot.quest_check(1) + for p in op: + p.posx, p.posy = 480, 380 + self.bot.quest_check(2) + + self.assertEqual(self.irc.chanmsgs, [ + "a, b, c, and d have been chosen by the gods to explore and chart the dark lands of T'rnalvph. Participants must first reach (400,475), then (480,380). See https://example.com/ to monitor their journey's progress.", + "a, b, c, and d have reached a landmark on their journey! 1 landmark remains.", + "a, b, c, and d have completed their journey! 25% of their burden is eliminated." + ]) + self.assertListEqual(self.irc.notices['a'], [ + "a, b, c, and d are on a quest to explore and chart the dark lands of T'rnalvph. Participants must first reach (400, 475), then (480, 380). See https://example.com/ to monitor their journey's progress." + ]) + self.assertEqual(op[0].nextlvl, 450) + self.assertEqual(op[1].nextlvl, 450) + self.assertEqual(op[2].nextlvl, 450) + self.assertEqual(op[3].nextlvl, 450) + + + def test_questing_failure(self): + conf._conf['rppenstep'] = 1.14 + op = [self.bot._db.new_player(pname, 'a', 'b') for pname in "abcd"] + now = time.time() + for p in op: + p.online = True + p.nick = p.name + p.level = 25 + p.lastlogin = datetime.datetime.fromtimestamp(now - 36001) + rand.overrides = { + "quest_members": op, + "quest_selection": "1 locate the centuries-lost tomes of the grim prophet Haplashak Mhadhu", + "quest_time": 12 + } + self.bot.quest_start(now) + self.bot.penalize(op[0], 'logout') + + self.assertListEqual(self.irc.chanmsgs, [ + "a, b, c, and d have been chosen by the gods to locate the centuries-lost tomes of the grim prophet Haplashak Mhadhu. Quest to end in 0 days, 12:00:00.", + "a's insolence has brought the wrath of the gods down upon them. Your great wickedness burdens you like lead, drawing you downwards with great force towards hell. Thereby have you plunged 15 steps closer to that gaping maw." + ]) + self.assertListEqual(self.irc.notices['a'], + ["Penalty of 0 days, 00:08:40 added to your timer for LOGOUT command."]) + self.assertEqual(op[0].nextlvl, 1516) + self.assertEqual(op[1].nextlvl, 996) + self.assertEqual(op[2].nextlvl, 996) + self.assertEqual(op[3].nextlvl, 996) + + def test_quest_autologin_failure(self): + users = [irc.IRCClient.User(uname, f"{uname}!{uname}@irc.example.com", [], 0) for uname in "abcd"] + op = [self.bot._db.new_player(pname, 'a', 'b') for pname in "abcd"] + now = time.time() + for u,p in zip(users,op): + p.online = True + p.level = 25 + p.lastlogin = datetime.datetime.fromtimestamp(now - 36001) + p.nick = u.nick + p.userhost = u.userhost + self.irc._users = dict((user.nick, user) for user in users) + rand.overrides = { + "quest_members": op, + "quest_selection": "2 10 20 30 40 locate the centuries-lost tomes of the grim prophet Haplashak Mhadhu", + } + self.bot.quest_start(now) + self.bot.disconnected() + self.bot.connected(self.irc) + del self.irc._users["d"] + self.bot._autologin() + self.bot.move_players() + self.bot.quest_check(now+50000) + + + def test_cmd_trigger_quest(self): + op = [self.bot._db.new_player(pname, "a", "b") for pname in "abcd"] + now = time.time() + for p in op: + p.online = True + p.nick = p.name + p.level = 25 + p.lastlogin = datetime.datetime.fromtimestamp(now - 36001) + rand.overrides = { + "quest_members": op, + "quest_selection": "1 locate the centuries-lost tomes of the grim prophet Haplashak Mhadhu", + "quest_time": 12 + } + op[0].admin = True + self.bot.cmd_trigger(op[0], op[0].nick, "quest") + + +class TestAdminCommands(unittest.TestCase): + + def setUp(self): + conf._conf['rpbase'] = 600 + conf._conf['rpstep'] = 1.16 + conf._conf['rpmaxexplvl'] = 60 + conf._conf['color'] = False + self.bot = bot.DawdleBot(bot.GameDB(FakeGameStorage())) + self.irc = FakeIRCClient() + self.bot.connected(self.irc) + + def test_delold(self): + op = [self.bot._db.new_player(pname, 'a', 'b') for pname in "abcd"] + expired = time.time() - 9 * 86400 + for p in op[:2]: + p.lastlogin = datetime.datetime.fromtimestamp(expired) + op[3].online = True + op[3].isadmin = True + self.bot.cmd_delold(op[3], op[3].nick, "7") + self.assertListEqual(self.irc.chanmsgs, [ + "2 accounts not accessed in the last 7 days removed by d." + ]) + self.assertNotIn(op[0].name, self.bot._db) + self.assertNotIn(op[1].name, self.bot._db) + self.assertIn(op[2].name, self.bot._db) + self.assertIn(op[3].name, self.bot._db) + + +class TestPlayerCommands(unittest.TestCase): + + def setUp(self): + conf._conf['rpbase'] = 600 + conf._conf['rpstep'] = 1.16 + conf._conf['rpmaxexplvl'] = 60 + conf._conf['color'] = False + conf._conf['allowuserinfo'] = True + conf._conf['helpurl'] = "http://example.com/" + conf._conf['botchan'] = "#dawdlerpg" + conf._conf["voiceonlogin"] = False + self.bot = bot.DawdleBot(bot.GameDB(FakeGameStorage())) + self.irc = FakeIRCClient() + self.bot.connected(self.irc) + + def test_unrestricted_commands_without_player(self): + self.irc._server = 'irc.example.com' + for cmd in bot.DawdleBot.ALLOWALL: + # We don't care what it does, as long as it doesn't crash. + getattr(self.bot, f"cmd_{cmd}")(None, "foo", "") + + def test_cmd_info(self): + self.irc._server = 'irc.example.com' + self.bot.cmd_info(None, "foo", "") + self.assertIn("DawdleRPG v", self.irc.notices["foo"][0]) + player = self.bot._db.new_player("bar", 'a', 'b') + self.bot.cmd_info(player, "bar", "") + self.assertIn("DawdleRPG v", self.irc.notices["bar"][0]) + + def test_cmd_login(self): + + self.bot.cmd_login(None, "foo", "bar baz") + self.irc._users['foo'] = irc.IRCClient.User("foo", "foo@example.com", [], 1) + self.assertEqual("Sorry, you aren't on #dawdlerpg.", self.irc.notices["foo"][0]) + self.irc.resetmsgs() + player = self.bot._db.new_player("bar", 'a', 'b') + player.set_password("baz") + self.bot.cmd_login(None, "foo", "bar baz") + self.assertIn("foo", self.irc.chanmsgs[0]) + + def test_cmd_email_no_arg_no_email(self): + player = self.bot._db.new_player("foo", 'a', 'b') + self.bot.cmd_email(player, "foo", "") + self.assertEqual("Your account does not have an email set. You can set it with EMAIL .", self.irc.notices["foo"][0]) + + def test_cmd_email_no_arg_with_email(self): + player = self.bot._db.new_player("foo", 'a', 'b') + player.email = "foo@example.com" + self.bot.cmd_email(player, "foo", "") + self.assertEqual("Your account email is foo@example.com.", self.irc.notices["foo"][0]) + + def test_cmd_email_with_arg_bad_email(self): + player = self.bot._db.new_player("foo", 'a', 'b') + player.email = "foo@example.com" + self.bot.cmd_email(player, "foo", "bar") + self.assertEqual(player.email, "foo@example.com") + self.assertEqual(self.bot._db._store._mem["foo"].email, "foo@example.com") + self.assertEqual("That doesn't look like an email address.", self.irc.notices["foo"][0]) + + def test_cmd_email_with_arg_good_email(self): + player = self.bot._db.new_player("foo", 'a', 'b') + player.email = "foo@example.com" + self.bot.cmd_email(player, "foo", "bar@example.com") + self.assertEqual(player.email, "bar@example.com") + self.assertEqual(self.bot._db._store._mem["foo"].email, "bar@example.com") + self.assertEqual("Your email is now set to bar@example.com.", self.irc.notices["foo"][0]) + + +class TestFindItem(unittest.TestCase): + + def setUp(self): + conf._conf['rpbase'] = 600 + conf._conf['rpstep'] = 1.16 + conf._conf['rpmaxexplvl'] = 60 + conf._conf['modsfile'] = '/tmp/modsfile.txt' + conf._conf['color'] = False + self.bot = bot.DawdleBot(bot.GameDB(FakeGameStorage())) + self.irc = FakeIRCClient() + self.bot.connected(self.irc) + + def test_special_item(self): + a = self.bot._db.new_player('a', 'b', 'c') + a.nick = 'a' + a.items['helm'] = bot.Item(20, '') + a.level = 25 + rand.overrides = { + 'specitem_find': True, + 'specitem_level': 5, + } + self.bot.find_item(a) + self.assertIn("Your enemies fall before you", self.irc.notices["a"][0]) + self.assertEqual(a.items['helm'].level, 55) + self.assertEqual(a.items['helm'].name, "Mattt's Omniscience Grand Crown") + + def test_special_has_bigger_item(self): + a = self.bot._db.new_player('a', 'b', 'c') + a.nick = 'a' + a.items['helm'] = bot.Item(60, '') + a.level = 25 + rand.overrides = { + 'specitem_find': True, + 'specitem_level': 5, + } + self.bot.find_item(a) + self.assertIn("Your enemies fall before you", self.irc.notices["a"][0]) + # Note that the level is always higher than the current item. + self.assertEqual(a.items['helm'].level, 65) + self.assertEqual(a.items['helm'].name, "Mattt's Omniscience Grand Crown") + + def test_higher_level_item(self): + a = self.bot._db.new_player('a', 'b', 'c') + a.nick = 'a' + a.items['amulet'] = bot.Item(60, '') + rand.overrides = { + 'specitem_find': False, + 'specitem_level': 5, + 'find_item_slot': 'amulet', + 'find_item_level': 70, + } + self.bot.find_item(a) + self.assertIn("Luck is with you!", self.irc.notices["a"][0]) + self.assertEqual(a.items['amulet'].level, 70) + self.assertEqual(a.items['amulet'].name, '') + + def test_lower_level_item(self): + a = self.bot._db.new_player('a', 'b', 'c') + a.nick = 'a' + a.items['amulet'] = bot.Item(60, '') + rand.overrides = { + 'specitem_find': False, + 'specitem_level': 5, + 'find_item_slot': 'amulet', + 'find_item_level': 50, + } + self.bot.find_item(a) + self.assertIn("Luck is against you.", self.irc.notices["a"][0]) + self.assertEqual(a.items['amulet'].level, 60) + self.assertEqual(a.items['amulet'].name, '') + + +class TestGameTick(unittest.TestCase): + + def setUp(self): + conf._conf['rpbase'] = 600 + conf._conf['rpstep'] = 1.16 + conf._conf['rpmaxexplvl'] = 60 + conf._conf['detectsplits'] = True + conf._conf['splitwait'] = 300 + conf._conf['datadir'] = os.path.join(os.path.dirname(os.path.dirname(__file__)), "setup") + conf._conf['eventsfile'] = "events.txt" + conf._conf['writequestfile'] = True + conf._conf['questfilename'] = "/tmp/testquestfile.txt" + conf._conf['quest_min_level'] = 24 + conf._conf['quest_min_login'] = 0 + conf._conf['self_clock'] = 1 + conf._conf['mapx'] = 500 + conf._conf['mapy'] = 500 + conf._conf['color'] = False + self.bot = bot.DawdleBot(bot.GameDB(FakeGameStorage())) + self.irc = FakeIRCClient() + self.bot.connected(self.irc) + + def test_gametick(self): + op = [self.bot._db.new_player(pname, 'a', 'b') for pname in "abcd"] + level = 25 + for p in op: + p.online = True + p.level = level + level += 3 + self.bot.gametick(0, 0) + +if __name__ == "__main__": + unittest.main() diff --git a/dawdle/test_irc.py b/dawdle/test_irc.py new file mode 100644 index 0000000..69e1284 --- /dev/null +++ b/dawdle/test_irc.py @@ -0,0 +1,138 @@ +import unittest + +from dawdle import abstract +from dawdle import conf +from dawdle import irc + +class FakeBot(abstract.AbstractBot): + + + def connected(self, client: abstract.AbstractClient) -> None: + pass + + def disconnected(self) -> None: + pass + + def ready(self) -> None: + pass + + def acquired_ops(self) -> None: + pass + + def nick_parted(self, user: abstract.AbstractClient.User) -> None: + pass + + def nick_kicked(self, user: abstract.AbstractClient.User) -> None: + pass + + def netsplit(self, user: abstract.AbstractClient.User) -> None: + pass + + def nick_dropped(self, user: abstract.AbstractClient.User) -> None: + pass + + def nick_quit(self, user: abstract.AbstractClient.User) -> None: + pass + + def nick_changed(self, user: abstract.AbstractClient.User, new_nick: str) -> None: + pass + + def private_message(self, user: abstract.AbstractClient.User, text: str) -> None: + pass + + def channel_message(self, user: abstract.AbstractClient.User, text: str) -> None: + pass + + def channel_notice(self, user: abstract.AbstractClient.User, text: str) -> None: + pass + + +class TestIRCMessage(unittest.TestCase): + def test_basic(self) -> None: + line = "@time=2021-07-31T13:55:00;bar=baz :nick!example@example.com PART #example :later!" + msg = irc.IRCClient.parse_message(line) + self.assertIsNotNone(msg) + if msg is None: + return + self.assertEqual(msg.tags, {"time": "2021-07-31T13:55:00", "bar": "baz"}) + self.assertEqual(msg.src, "nick") + self.assertEqual(msg.user, "example") + self.assertEqual(msg.host, "example.com") + self.assertEqual(msg.cmd, "PART") + self.assertEqual(msg.args, ["#example", "later!"]) + self.assertEqual(msg.trailing, "later!") + self.assertEqual(msg.line, line) + self.assertEqual(msg.time, 1627754100) + + def test_one_trailing_arg(self) -> None: + line = ":foo!bar@example.com NICK :baz" + msg = irc.IRCClient.parse_message(line) + self.assertIsNotNone(msg) + if msg is None: + return + self.assertEqual(msg.tags, {}) + self.assertEqual(msg.src, "foo") + self.assertEqual(msg.user, "bar") + self.assertEqual(msg.host, "example.com") + self.assertEqual(msg.cmd, "NICK") + self.assertEqual(msg.args, ["baz"]) + self.assertEqual(msg.trailing, "baz") + self.assertEqual(msg.line, line) + + + def test_complextags(self) -> None: + line = "@keyone=one\\sbig\\:value;keytwo=t\\wo\\rbig\\n\\\\values :nick!example@example.com PART #example :later!" + msg = irc.IRCClient.parse_message(line) + self.assertIsNotNone(msg) + if msg is None: + return + self.assertEqual(msg.tags, { + "keyone": "one big;value", + "keytwo": "two\rbig\n\\values", + }) + + + def test_notags(self) -> None: + line = ":nick!example@example.com PART #example :later!" + msg = irc.IRCClient.parse_message(line) + self.assertIsNotNone(msg) + if msg is None: + return + self.assertEqual(msg.tags, {}) + self.assertEqual(msg.src, "nick") + + def test_badtags(self) -> None: + line = "@asdf :nick!example@example.com PART #example :later!" + msg = irc.IRCClient.parse_message(line) + self.assertIsNotNone(msg) + if msg is None: + return + self.assertEqual(msg.tags, {'asdf': None}) + self.assertEqual(msg.src, "nick") + + line = "@ :nick!example@example.com PART #example :later!" + msg = irc.IRCClient.parse_message(line) + self.assertIsNotNone(msg) + if msg is None: + return + self.assertEqual(msg.tags, {}) + self.assertEqual(msg.src, "nick") + + +class TestIRCClient(unittest.TestCase): + + def test_handle_cap(self) -> None: + conf._conf['botnick'] = 'foo' + client = irc.IRCClient(FakeBot()) + client.handle_cap(irc.IRCClient.Message(tags={}, src='tungsten.libera.chat', user=None, host=None, cmd='CAP', args=['*', 'ACK', 'multi-prefix'], trailing='multi-prefix', line=':tungsten.libera.chat CAP * ACK :multi-prefix', time=1629501206)) + self.assertIn("multi-prefix", client._caps) + + + def test_nick_change(self) -> None: + conf._conf['botnick'] = 'dawdlerpg' + testbot = FakeBot() + client = irc.IRCClient(testbot) + client.handle_join(irc.IRCClient.Message(tags={}, src='foo', user=None, host=None, cmd='NICK', args=['#dawdlerpg'], trailing='', line='', time=0)) + client.handle_nick(irc.IRCClient.Message(tags={}, src='foo', user=None, host=None, cmd='NICK', args=['bar'], trailing='bar', line='', time=0)) + self.assertNotIn('foo', client._users) + self.assertIn('bar', client._users) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..7ddc0de --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,59 @@ +version: "3" + +services: + dawdle: + build: . + volumes: + - ./:/data + command: + - 'python' + - '/data/dawdle.py' + - '/data/af/dawdle.conf' + restart: unless-stopped + + web: + build: . + volumes: + - ./site:/site + working_dir: /site + restart: unless-stopped + command: python manage.py runserver 0.0.0.0:8000 + networks: + - web + + nginx: + image: docker.io/library/nginx:stable-alpine + volumes: + - ./site/static:/usr/share/nginx/html:ro + - ./nginx.conf.template:/etc/nginx/templates/default.conf.template + restart: unless-stopped + networks: + - web + labels: + - traefik.enable=true + - traefik.http.middlewares.https-compress-ircl3.compress=true + + - traefik.http.middlewares.header_ircl3.headers.sslRedirect=true + - traefik.http.middlewares.header_ircl3.headers.sslHost=irc.l3.lv + - traefik.http.middlewares.header_ircl3.headers.stsSeconds=3600 + - traefik.http.middlewares.header_ircl3.headers.stsPreload=true + - traefik.http.middlewares.header_ircl3.headers.forceSTSHeader=true + - traefik.http.middlewares.header_ircl3.headers.frameDeny=true + - traefik.http.middlewares.header_ircl3.headers.contentTypeNosniff=true + - traefik.http.middlewares.header_ircl3.headers.customRequestHeaders.Cache-Control='public, max-age=3600' + + - traefik.http.routers.ircl3.rule=Host(`irc.l3.lv`) + - traefik.http.routers.ircl3.tls=true + - traefik.http.routers.ircl3.tls.certresolver=le + - traefik.http.routers.ircl3.entrypoints=secure + - traefik.http.routers.ircl3.middlewares=header_ircl3,https-compress-ircl3 + - traefik.http.services.ircl3.loadbalancer.server.port=80 + + - traefik.http.routers.ircl3_http.rule=Host(`irc.l3.lv`) + - traefik.http.routers.ircl3_http.service=ircl3 + - traefik.http.routers.ircl3_http.entrypoints=web + - traefik.http.routers.ircl3_http.middlewares=header_ircl3,https-compress-ircl3 + +networks: + web: + external: true diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..0575718 --- /dev/null +++ b/install.sh @@ -0,0 +1,64 @@ +#!/bin/bash + +if [[ "$UID" != "0" ]]; then + echo This script must be run as root. + exit 1 +fi + +if [[ "x$1" == "x" ]]; then + echo "Usage: install.sh " >/dev/stderr + exit 1 +fi + +HOST="$1" + +DIR="$(readlink -f $(dirname $0))" +echo "Using $DIR as dawdlerpg directory." + +echo Installing services. +apt install -y nginx python3-pip +pip install django uwsgi Pillow + +echo Configuring services. +sed "s#DAWDLERPG_DIR#${DIR}#g" setup/uwsgi.service >/etc/systemd/system/uwsgi.service +sed "s#DAWDLERPG_DIR#${DIR}#g" setup/uwsgi.ini >/etc/uwsgi.ini +sed "s#DAWDLERPG_DIR#${DIR}#g" setup/dawdlerpg.service >/etc/systemd/system/dawdlerpg.service +sed "s#DAWDLERPG_DIR#${DIR}#g" setup/dawdlerpg.nginx >/etc/nginx/sites-available/dawdlerpg +rm -f /etc/nginx/sites-enabled/default +ln -sf /etc/nginx/sites-available/dawdlerpg /etc/nginx/sites-enabled/ + +systemctl enable uwsgi +systemctl enable dawdlerpg +systemctl enable nginx + +echo Setting up dawdlerpg. +cp setup/dawdle.conf "$DIR/data" +cp setup/events.txt "$DIR/data" +pushd "$DIR/site/" +SECRET_KEY="$(openssl rand -base64 45)" +sed -e "/^SECRET_KEY/ c \\SECRET_KEY = '${SECRET_KEY}'" \ + -e "/^ALLOWED_HOSTS/ c \\ALLOWED_HOSTS = ['${HOST}']" \ + -e "/^DEBUG/ c \\DEBUG = False" \ + setup/project-settings.py \ + >project/settings.py +./manage.py migrate --database=default +./manage.py migrate --database=game +./manage.py collectstatic --no-input +cd "$DIR" +echo -n "You should now edit the dawdle.conf file for your particular game. Press RETURN to begin editing." +read +"$EDITOR" "$DIR/data/dawdle.conf" +if [[ $! != 0 ]]; then + exit $! +fi +"$DIR/dawdle.py" --setup "$DIR/data/dawdle.conf" +popd + +chown -R www-data:www-data "$DIR" + +echo Starting systems. +systemctl start uwsgi +systemctl restart nginx +systemctl start dawdlerpg + +echo Done. diff --git a/map/get.sh b/map/get.sh new file mode 100755 index 0000000..9a7d778 --- /dev/null +++ b/map/get.sh @@ -0,0 +1,3 @@ +#!/bin/sh + +curl https://irc.l3.lv/map -o "/opt/dawdlerpg/map/map-$(date +%s).png" diff --git a/map/make-mp4 b/map/make-mp4 new file mode 100755 index 0000000..c8701f8 --- /dev/null +++ b/map/make-mp4 @@ -0,0 +1,11 @@ +#!/bin/bash + +rm /opt/dawdlerpg/site/static/map.mp4; + +ffmpeg -framerate 3 -pattern_type glob -i '/opt/dawdlerpg/map/*.png' \ + -c:v libx264 -pix_fmt yuv420p \ + /opt/dawdlerpg/site/static/map.mp4 + +now=$(date -u) + +sed -i "s/{% with timelapse='.*' %}/{% with timelapse='${now}' %}/" /opt/dawdlerpg/site/dawdle/templates/dawdle/timelapse.html diff --git a/nginx.conf.template b/nginx.conf.template new file mode 100644 index 0000000..1dc0c40 --- /dev/null +++ b/nginx.conf.template @@ -0,0 +1,15 @@ +server { + + location /static { + #autoindex on; + alias /usr/share/nginx/html/; + } + + location / { + proxy_pass http://web:8000; + proxy_set_header Host $host; + } + + access_log syslog; + error_log stderr warn; +} diff --git a/setup/dawdle.conf b/setup/dawdle.conf new file mode 100644 index 0000000..9979c35 --- /dev/null +++ b/setup/dawdle.conf @@ -0,0 +1,258 @@ +# Configuration file for the DawdleRPG bot. +# +# This is backwards-compatible with the IdleRPG bot's configuration. +# Some of the old directives are ignored. + + +# Basic configuration - you should set all these +################################################ + +# Remove this line so the bot knows you edited the file. +#die + +# Superuser that cannot be DELADMINed. +owner Animefield + +# Server name:port. +server irc.animefriends.moe:7000 + +# Bot's nickname +botnick IdleFriend + +# Bot's username +botuser Idle + +# Bot's real name +botrlnm DawdleRPG Bot + +# Bot joins this channel +botchan #idlerpg + +# Bot changes its modes to this on connection +botmodes +Bix + +# Bot identifies itself with this command - %botpass% string is +# replaced by the BOTPASS environment variable. +# Commented out because it requires registration by the server. +botident PRIVMSG NickServ :identify %botpass% + +# Bot gains ops with this command after joining channel. The channel +# name and bot nick are replaced with their configuration. +# Commented out because it requires registration by the server. +# botopcmd PRIVMSG ChanServ :op %botchan% %botnick% + +# Bot sends this command to attempt to retrieve its nick. +# Commented out because it requires registration by the server. +botghostcmd PRIVMSG NickServ :ghost %botnick% %botpass% + +# URL that shows up in the help command output. +helpurl http://example.com/ + +# URL where admins can find help. +admincommurl http://example.com/admincomms.txt + +# URL where users can reach the online quest map, if available. +mapurl http://example.com/quest.php + +# Daemonize the bot. This will make the bot detach from the terminal +# and act as an independent server. If you are running this from a +# command line or an init script, this should be on. If you are +# running it from systemd or some other service manager, this should +# probably be off. +daemonize off + +# Gameplay configuration - you might want to tweak these. +######################################################### + +# Base amount of time to level. +rpbase 600 + +# Time to next level is rpbase * (rpstep ** current level) +rpstep 1.16 + +# Penalty time = penalty * (rppenstep ** current level) +rppenstep 1.14 + +# Maximum level for ttl to be exponentially computed. +rpmaxexplvl 60 + +# Ally base time to level +allylvlbase 200 + +# Time to next ally level is allylvlbase * (allylvlstep ** level) +allylvlstep 1.16 + +# Maximum level for ally ttl to be exponentially computed +allymaxexplvl 60 + +# Allow non-admin users to access info command. +allowuserinfo on + +# Time penalty limit. +limitpen 24192200 + +# Penalty for losing the quest. This is applied to all questors! +penquest 15 + +# Penalty for changing nick. +pennick 30 + +# Penalty for sending a message - this is per character! +penmessage 1 + +# Penalty for leaving the channel. +penpart 200 + +# Penalty for being kicked from the channel. +penkick 250 + +# Penalty for quitting. +penquit 20 + +# Penalty for dropping connection. +pendropped 20 + +# Penalty for using the LOGOUT command. +penlogout 20 + +# Width of map. +mapx 500 + +# Height of map. +mapy 500 + +# Percent change in battle power for good. +good_battle_pct 110 + +# Percent change in battle power for evil. +evil_battle_pct 90 + +# Kick/ban users who mention a URL within seconds of joining channel. +doban on + +# Time after joining that users can mention a URL without being banned. +bannable_time 90s + +# Minimum time between quests. +quest_interval_min 12h +quest_interval_max 24h + +# Minimum level that a player must be to quest. +quest_min_level 24 + +# Minimum login time in seconds that a player must have to quest. +quest_min_login 36000 + +# These are URL hosts which are okay to mention early in channel. Multiples are fine. +# okurl example.com +# okurl example.org + +# Write quest file to be picked up from the website. +writequestfile on + +# Voice users on login. +voiceonlogin on + +# Allow users to view information on another user? +statuscmd on + +# Disallow registration of filenames existing in a different case? +casematters on + +# Detect netsplits. Servers are supposed to disallow netsplit-like +# messages, so this shouldn't be a cheat vector. +detectsplits on + +# Time to wait after netsplit for disconnection. +splitwait 10m + +# Enable mIRC color codes +color on + +# Color of player names +namecolor cyan + +# Color of durations (mostly time to level) +durationcolor green + +# Color of items +itemcolor yellow + +# Technical details +################### + +# Directory to look for game files. This is set to this configuration +# file's directory by default. Uncomment if you need to specify it. +# datadir + +# Filename for player database. This should be set to the +# game.sqlite3 in the site folder if using the django website. +dbfile dawdle.db +# To integrate with website, use this. Create this file with: +# manager.py migrate --database=game +# before running with --setup +# dbfile ../site/game.sqlite3 + +# Game store format - "idlerpg" is fully compatible with the original +# perl idlerpg bot and website so dawdlerpg can be used as a drop-in +# replacement. "sqlite3" allows some of the newer features and works +# with the django website. +# store_format idlerpg +store_format sqlite3 + +# Filename for events file. +eventsfile events.txt + +# Filename for quest file. Used by website. +questfilename questinfo.txt + +# Game events are saved to this filename. Used by website. +modsfile modifiers.txt + +# Logging output - format is log