feat: working

This commit is contained in:
pwgen2155 2024-02-28 16:09:51 +11:00
commit e6cb475b6d
75 changed files with 7648 additions and 0 deletions

2
.flake8 Normal file
View file

@ -0,0 +1,2 @@
[flake8]
extend-ignore = *

17
.gitignore vendored Normal file
View file

@ -0,0 +1,17 @@
# Django #
*.log
*.pot
*.pyc
__pycache__
*~
*/__pycache__
*/*/__pycache__
*/*/*/__pycache__
db.sqlite3
media
# Backup files #
*.bak
*.sqlite3
site/static/

177
COPYING Normal file
View file

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

7
Dockerfile Normal file
View file

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

49
README.md Normal file
View file

@ -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 <path to dawdle.conf>`
- 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 <hostname>
```
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 <path to old irpg.conf>`
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.

33
af/dawdlerpg.nginx Normal file
View file

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

31
af/events.txt Normal file
View file

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

220
dawdle.py Executable file
View file

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

0
dawdle/__init__.py Normal file
View file

144
dawdle/abstract.py Normal file
View file

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

2547
dawdle/bot.py Executable file

File diff suppressed because it is too large Load diff

34
dawdle/chunk.py Normal file
View file

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

153
dawdle/conf.py Normal file
View file

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

589
dawdle/irc.py Normal file
View file

@ -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 "<disconnected>"
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")

23
dawdle/log.py Normal file
View file

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

53
dawdle/rand.py Normal file
View file

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

917
dawdle/test_bot.py Executable file
View file

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

138
dawdle/test_irc.py Normal file
View file

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

59
docker-compose.yml Normal file
View file

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

64
install.sh Executable file
View file

@ -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 <hostname>" >/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.

3
map/get.sh Executable file
View file

@ -0,0 +1,3 @@
#!/bin/sh
curl https://irc.l3.lv/map -o "/opt/dawdlerpg/map/map-$(date +%s).png"

11
map/make-mp4 Executable file
View file

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

15
nginx.conf.template Normal file
View file

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

258
setup/dawdle.conf Normal file
View file

@ -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 <data directory>
# 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 <log level> <path> <template>.
# Levels are CRITICAL, WARNING, INFO, DEBUG, and SPAMMY. The path is
# relative to the data directory. The template uses the python logger
# format documented in
# https://docs.python.org/3/library/logging.html#logrecord-attributes
#
# Examples:
# For debugging: tslog DEBUG /dev/stderr %(asctime)s %(message)s
# For systemd journal: log INFO /dev/stdout %(message)s
# For saving to a log file: tslog WARNING dawdle.log %(asctime)s %(message)s
log INFO dawdle.log %(message)s
# PID file for ensuring that dawdlerpg isn't running twice.
pidfile dawdle.pid
# Backup directory.
backupdir backups
# Maximum player name length.
max_player_len 16
# Maximum player class length.
max_class_len 30
# Does the bot reconnect when disconnected?
reconnect on
# How long to wait after disconnection to reconnect.
reconnect_wait 30s
# Throttle output?
throttle on
# Maximum messages to send during throttle period. Any more messages
# will be queued until next period.
throttle_rate 5
throttle_period 10s
# Address to use for outgoing connection. This is used to select a
# specific interface. Uses default interface when unset.
# localaddr <local address>
# IRC max message width to use for word wrap.
message_wrap_len 400
# Interval in seconds to check for game events.
self_clock 1s

BIN
setup/dawdle.db Normal file

Binary file not shown.

1
setup/dawdle.pid Normal file
View file

@ -0,0 +1 @@
1

33
setup/dawdlerpg.nginx Normal file
View file

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

14
setup/dawdlerpg.service Normal file
View file

@ -0,0 +1,14 @@
[Unit]
Description=DawdleRPG IRC Bot
After=network.target auditd.service
[Service]
User=www-data
Group=www-data
ExecStart=DAWDLERPG_DIR/dawdle.py -o daemonize=off DAWDLERPG_DIR/data/dawdle.conf
Restart=on-failure
RestartPreventExitStatus=255
Type=simple
[Install]
WantedBy=multi-user.target

27
setup/events.txt Normal file
View file

@ -0,0 +1,27 @@
# Calamities - these add to a player's time to level
C was struck by lightning
C was mauled by a bear
C was cursed by a swamp witch
C fell into a bog
C DIED... a bit
# Godsends - these subtract from a player's time to level
G was sprinkled with fairy dust
G found a pot of gold
G met a friendly bard
G found a magical spring
G drank a potent elixir
# Quest mode 1 - these are simply timed quests
Q1 solve the theft of the five rings of Macala
Q1 deliver a priceless scroll to the Azar library
Q1 rescue an imprisoned genie from the palace of the Thutharks
Q1 impersonate cultists to infiltrate a dark temple
Q1 unearth the secret of the Twin Pines
# Quest mode 2 - these are two-stage navigation quests
Q2 464 23 113 187 search for a hostage nymph and heal her broken tree
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.

130
setup/project-settings.py Normal file
View file

@ -0,0 +1,130 @@
"""
Django settings for dawdle project.
Generated by 'django-admin startproject' using Django 2.2.12.
For more information on this file, see
https://docs.djangoproject.com/en/2.2/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/2.2/ref/settings/
"""
import os
# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/2.2/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = ''
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True
ALLOWED_HOSTS = []
# Application definition
INSTALLED_APPS = [
'dawdle.apps.DawdleConfig',
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
]
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
ROOT_URLCONF = 'project.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
],
},
},
]
WSGI_APPLICATION = 'project.wsgi.application'
# Database
# https://docs.djangoproject.com/en/2.2/ref/settings/#databases
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': os.path.join(BASE_DIR, 'db.sqlite3'),
},
'game': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': os.path.join(BASE_DIR, 'game.sqlite3'),
}
}
# Routers
# https://docs.djangoproject.com/en/3.2/topics/db/multi-db/
DATABASE_ROUTERS = ['dawdle.router.DawdleRouter']
# Password validation
# https://docs.djangoproject.com/en/2.2/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = [
{
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
},
]
# Internationalization
# https://docs.djangoproject.com/en/2.2/topics/i18n/
LANGUAGE_CODE = 'en-us'
TIME_ZONE = 'UTC'
USE_I18N = True
USE_L10N = True
USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/2.2/howto/static-files/
STATIC_URL = '/static/'
STATIC_ROOT = os.path.join(BASE_DIR, "static")

18
setup/uwsgi.ini Normal file
View file

@ -0,0 +1,18 @@
[uwsgi]
# base directory (full path)
chdir = DAWDLERPG_DIR/site/
# Django's wsgi file
module = project.wsgi:application
# Set uid/gid to www-data so nginx can access
uid = www-data
gid = www-data
# master
master = true
# maximum number of worker processes
processes = 3
# the socket (use the full path to be safe)
socket = /tmp/dawdlerpg-uwsgi.sock
# ... with appropriate permissions - may be needed
chmod-socket = 664
# clear environment on exit
vacuum = true

13
setup/uwsgi.service Normal file
View file

@ -0,0 +1,13 @@
[Unit]
Description=uWSGI Service
After=network.target auditd.service
[Service]
EnvironmentFile=-/etc/default/uwsgi
ExecStart=/usr/local/bin/uwsgi --ini /etc/uwsgi.ini
Restart=on-failure
RestartPreventExitStatus=255
type=notify
[Install]
WantedBy=multi-user.target

0
site/dawdle/__init__.py Normal file
View file

8
site/dawdle/admin.py Normal file
View file

@ -0,0 +1,8 @@
from django.contrib import admin
from .models import Player,Item,History,Quest
admin.site.register(Player)
admin.site.register(Item)
admin.site.register(History)
admin.site.register(Quest)

5
site/dawdle/apps.py Normal file
View file

@ -0,0 +1,5 @@
from django.apps import AppConfig
class DawdleConfig(AppConfig):
name = 'dawdle'
default_auto_field = 'django.db.models.AutoField'

View file

@ -0,0 +1,52 @@
# Generated by Django 3.2.6 on 2021-08-28 11:50
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Player',
fields=[
('name', models.CharField(max_length=50, primary_key=True, serialize=False)),
('cclass', models.CharField(max_length=30)),
('pw', models.CharField(max_length=64)),
('alignment', models.CharField(choices=[('g', 'Good'), ('n', 'Neutral'), ('e', 'Evil')], default='n', max_length=1)),
('isadmin', models.BooleanField(default=False)),
('online', models.BooleanField(default=False)),
('nick', models.CharField(max_length=32)),
('level', models.IntegerField()),
('nextlvl', models.IntegerField()),
('userhost', models.CharField(max_length=255)),
('posx', models.IntegerField()),
('posy', models.IntegerField()),
('idled', models.IntegerField()),
('penmessage', models.IntegerField()),
('pennick', models.IntegerField()),
('penpart', models.IntegerField()),
('penkick', models.IntegerField()),
('penquit', models.IntegerField()),
('pendropped', models.IntegerField()),
('penquest', models.IntegerField()),
('penlogout', models.IntegerField()),
('created', models.DateTimeField(editable=False)),
('lastlogin', models.DateTimeField(editable=False)),
],
),
migrations.CreateModel(
name='Item',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=50)),
('level', models.IntegerField()),
('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='dawdle.player')),
],
),
]

View file

@ -0,0 +1,68 @@
# Generated by Django 3.2.6 on 2021-08-28 11:58
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dawdle', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='player',
name='idled',
field=models.IntegerField(default=0),
),
migrations.AlterField(
model_name='player',
name='level',
field=models.IntegerField(default=0),
),
migrations.AlterField(
model_name='player',
name='nextlvl',
field=models.IntegerField(default=600),
),
migrations.AlterField(
model_name='player',
name='pendropped',
field=models.IntegerField(default=0),
),
migrations.AlterField(
model_name='player',
name='penkick',
field=models.IntegerField(default=0),
),
migrations.AlterField(
model_name='player',
name='penlogout',
field=models.IntegerField(default=0),
),
migrations.AlterField(
model_name='player',
name='penmessage',
field=models.IntegerField(default=0),
),
migrations.AlterField(
model_name='player',
name='pennick',
field=models.IntegerField(default=0),
),
migrations.AlterField(
model_name='player',
name='penpart',
field=models.IntegerField(default=0),
),
migrations.AlterField(
model_name='player',
name='penquest',
field=models.IntegerField(default=0),
),
migrations.AlterField(
model_name='player',
name='penquit',
field=models.IntegerField(default=0),
),
]

View file

@ -0,0 +1,23 @@
# Generated by Django 3.2.6 on 2021-08-28 12:02
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dawdle', '0002_auto_20210828_1158'),
]
operations = [
migrations.AlterField(
model_name='player',
name='created',
field=models.DateTimeField(auto_now_add=True),
),
migrations.AlterField(
model_name='player',
name='lastlogin',
field=models.DateTimeField(auto_now_add=True),
),
]

View file

@ -0,0 +1,19 @@
# Generated by Django 3.2.6 on 2021-09-01 22:59
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dawdle', '0003_auto_20210828_1202'),
]
operations = [
migrations.AddField(
model_name='item',
name='slot',
field=models.CharField(default='helm', max_length=10),
preserve_default=False,
),
]

View file

@ -0,0 +1,23 @@
# Generated by Django 3.2.6 on 2021-09-09 10:55
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('dawdle', '0004_item_slot'),
]
operations = [
migrations.CreateModel(
name='History',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('time', models.DateTimeField(auto_now_add=True)),
('text', models.CharField(max_length=400)),
('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='dawdle.player')),
],
),
]

View file

@ -0,0 +1,31 @@
# Generated by Django 3.2.6 on 2021-09-09 11:01
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dawdle', '0005_history'),
]
operations = [
migrations.CreateModel(
name='Quest',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('mode', models.IntegerField(default=0)),
('p1', models.CharField(max_length=50)),
('p2', models.CharField(max_length=50)),
('p3', models.CharField(max_length=50)),
('p4', models.CharField(max_length=50)),
('text', models.CharField(max_length=400)),
('qtime', models.IntegerField(default=0)),
('stage', models.IntegerField(default=0)),
('dest1x', models.IntegerField(default=0)),
('dest1y', models.IntegerField(default=0)),
('dest2x', models.IntegerField(default=0)),
('dest2y', models.IntegerField(default=0)),
],
),
]

View file

@ -0,0 +1,17 @@
# Generated by Django 3.2.6 on 2021-09-10 10:55
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dawdle', '0006_quest'),
]
operations = [
migrations.AddConstraint(
model_name='item',
constraint=models.UniqueConstraint(fields=('owner', 'slot'), name='unique_item_owner_slot'),
),
]

View file

@ -0,0 +1,28 @@
# Generated by Django 3.2.6 on 2021-10-27 11:07
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('dawdle', '0007_item_unique_item_owner_slot'),
]
operations = [
migrations.CreateModel(
name='Ally',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('slot', models.CharField(max_length=10)),
('name', models.CharField(max_length=50)),
('baseclass', models.CharField(max_length=30)),
('fullclass', models.CharField(max_length=30)),
('alignment', models.CharField(choices=[('g', 'Good'), ('n', 'Neutral'), ('e', 'Evil')], default='n', max_length=1)),
('level', models.IntegerField(default=0)),
('nextlvl', models.IntegerField(default=600)),
('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='dawdle.player')),
],
),
]

View file

@ -0,0 +1,19 @@
# Generated by Django 4.0.7 on 2023-04-10 17:14
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dawdle', '0008_ally'),
]
operations = [
migrations.AddField(
model_name='player',
name='email',
field=models.CharField(default='', max_length=64),
preserve_default=False,
),
]

View file

@ -0,0 +1,19 @@
# Generated by Django 4.0.7 on 2023-04-10 17:14
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dawdle', '0009_player_email'),
]
operations = [
migrations.AddField(
model_name='player',
name='penrealign',
field=models.IntegerField(),
preserve_default=False,
),
]

View file

83
site/dawdle/models.py Normal file
View file

@ -0,0 +1,83 @@
from django.db import models
class AlignmentChoices(models.TextChoices):
GOOD = "g"
NEUTRAL = "n"
EVIL = "e"
class Player(models.Model):
name = models.CharField(primary_key=True, max_length=50)
cclass = models.CharField(max_length=30)
pw = models.CharField(max_length=64)
email = models.CharField(max_length=64)
alignment = models.CharField(
max_length=1,
choices=AlignmentChoices.choices,
default=AlignmentChoices.NEUTRAL
)
isadmin = models.BooleanField(default=False)
online = models.BooleanField(default=False)
nick = models.CharField(max_length=32)
level = models.IntegerField(default=0)
nextlvl = models.IntegerField(default=600)
userhost = models.CharField(max_length=255)
posx = models.IntegerField()
posy = models.IntegerField()
idled = models.IntegerField(default=0)
penmessage = models.IntegerField(default=0)
pennick = models.IntegerField(default=0)
penpart = models.IntegerField(default=0)
penkick = models.IntegerField(default=0)
penquit = models.IntegerField(default=0)
pendropped = models.IntegerField(default=0)
penquest = models.IntegerField(default=0)
penlogout = models.IntegerField(default=0)
created = models.DateTimeField(auto_now_add=True)
lastlogin = models.DateTimeField(auto_now_add=True)
class Ally(models.Model):
owner = models.ForeignKey(Player, on_delete=models.CASCADE)
slot = models.CharField(max_length=10) # mount, sidekick, henchman
name = models.CharField(max_length=50) # player selectable name - may be empty
baseclass = models.CharField(max_length=30) # lizard
fullclass = models.CharField(max_length=30) # sparkly lizard of the beyond
alignment = models.CharField(
max_length=1,
choices=AlignmentChoices.choices,
default=AlignmentChoices.NEUTRAL
)
level = models.IntegerField(default=0)
nextlvl = models.IntegerField(default=600)
class Item(models.Model):
class Meta:
# We use this constraint for the sqlite3 REPLACE command
constraints = [models.UniqueConstraint(name="unique_item_owner_slot", fields=["owner", "slot"])]
owner = models.ForeignKey(Player, on_delete=models.CASCADE)
slot = models.CharField(max_length=10)
name = models.CharField(max_length=50)
level = models.IntegerField()
class History(models.Model):
owner = models.ForeignKey(Player, on_delete=models.CASCADE)
time = models.DateTimeField(auto_now_add=True)
text = models.CharField(max_length=400)
class Quest(models.Model):
mode = models.IntegerField(default=0)
p1 = models.CharField(max_length=50)
p2 = models.CharField(max_length=50)
p3 = models.CharField(max_length=50)
p4 = models.CharField(max_length=50)
text = models.CharField(max_length=400)
qtime = models.IntegerField(default=0)
stage = models.IntegerField(default=0)
dest1x = models.IntegerField(default=0)
dest1y = models.IntegerField(default=0)
dest2x = models.IntegerField(default=0)
dest2y = models.IntegerField(default=0)

26
site/dawdle/router.py Normal file
View file

@ -0,0 +1,26 @@
class DawdleRouter(object):
def db_for_read(self, model, **hints):
"Point all operations on dawdle models to 'dawdledb'"
if model._meta.app_label == 'dawdle':
return 'game'
return 'default'
def db_for_write(self, model, **hints):
"Point all operations on dawdle models to 'dawdledb'"
if model._meta.app_label == 'dawdle':
return 'game'
return 'default'
def allow_relation(self, obj1, obj2, **hints):
"Allow any relation if a both models in dawdle app"
if obj1._meta.app_label == 'dawdle' and obj2._meta.app_label == 'dawdle':
return True
# Allow if neither is dawdle app
elif 'dawdle' not in [obj1._meta.app_label, obj2._meta.app_label]:
return True
return False
def allow_migrate(self, db, app_label, model_name=None, **hints):
if app_label == 'dawdle':
return db == 'game'
return db == 'default'

View file

@ -0,0 +1,135 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
version="1.0"
width="128"
height="128"
id="svg2"
sodipodi:docname="Blue_sphere.svg"
inkscape:version="0.92.5 (2060ec1f9f, 2020-04-08)">
<metadata
id="metadata1057">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1920"
inkscape:window-height="1025"
id="namedview1055"
showgrid="false"
inkscape:zoom="1.84375"
inkscape:cx="-32.813559"
inkscape:cy="64"
inkscape:window-x="0"
inkscape:window-y="27"
inkscape:window-maximized="1"
inkscape:current-layer="svg2" />
<defs
id="defs5">
<linearGradient
id="linearGradient2816">
<stop
style="stop-color:#003de4;stop-opacity:1"
offset="0"
id="stop2818" />
<stop
style="stop-color:white;stop-opacity:1"
offset="1"
id="stop2820" />
</linearGradient>
<linearGradient
id="linearGradient2794">
<stop
style="stop-color:#545454;stop-opacity:1"
offset="0"
id="stop2796" />
<stop
style="stop-color:#545454;stop-opacity:1"
offset="0.9464286"
id="stop2804" />
<stop
style="stop-color:#c5c5c5;stop-opacity:0"
offset="1"
id="stop2798" />
</linearGradient>
<linearGradient
id="linearGradient2760">
<stop
style="stop-color:#80bbff;stop-opacity:1"
offset="0"
id="stop2762" />
<stop
style="stop-color:#001ee9;stop-opacity:1"
offset="0.61160713"
id="stop2768" />
<stop
style="stop-color:#a6d1ff;stop-opacity:1"
offset="1"
id="stop2764" />
</linearGradient>
<linearGradient
x1="6.9292336"
y1="121.07077"
x2="6.9292336"
y2="8.9609833"
id="linearGradient2766"
xlink:href="#linearGradient2760"
gradientUnits="userSpaceOnUse" />
<radialGradient
cx="62.984127"
cy="65.015877"
r="54.179893"
fx="62.984127"
fy="65.015877"
id="radialGradient2812"
xlink:href="#linearGradient2794"
gradientUnits="userSpaceOnUse" />
<linearGradient
x1="31.5"
y1="63"
x2="31.5"
y2="12"
id="linearGradient2822"
xlink:href="#linearGradient2816"
gradientUnits="userSpaceOnUse" />
</defs>
<path
d="M 117.16402 65.015877 A 54.179893 54.179893 0 1 1 8.8042336,65.015877 A 54.179893 54.179893 0 1 1 117.16402 65.015877 z"
transform="translate(5.724864,4.031744)"
style="opacity:1;fill:url(#radialGradient2812);fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:3.75;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
id="path2790" />
<path
d="M 117.16402 65.015877 A 54.179893 54.179893 0 1 1 8.8042336,65.015877 A 54.179893 54.179893 0 1 1 117.16402 65.015877 z"
transform="translate(1.69312,0)"
style="opacity:1;fill:url(#linearGradient2766);fill-opacity:1;fill-rule:nonzero;stroke:#002274;stroke-width:3.75;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
id="path1873" />
<path
d="M 95.5 37.5 A 32 25.5 0 1 1 31.5,37.5 A 32 25.5 0 1 1 95.5 37.5 z"
transform="translate(1,2)"
style="opacity:1;fill:url(#linearGradient2822);fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:3.75;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
id="path2814" />
</svg>

After

Width:  |  Height:  |  Size: 4.5 KiB

View file

@ -0,0 +1,135 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
version="1.0"
width="128"
height="128"
id="svg2"
sodipodi:docname="Gray_sphere.svg"
inkscape:version="0.92.5 (2060ec1f9f, 2020-04-08)">
<metadata
id="metadata952">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1920"
inkscape:window-height="1025"
id="namedview950"
showgrid="false"
inkscape:zoom="6.6875"
inkscape:cx="64"
inkscape:cy="64"
inkscape:window-x="0"
inkscape:window-y="27"
inkscape:window-maximized="1"
inkscape:current-layer="svg2" />
<defs
id="defs5">
<linearGradient
id="linearGradient2816">
<stop
style="stop-color:#534f4f;stop-opacity:1"
offset="0"
id="stop2818" />
<stop
style="stop-color:white;stop-opacity:1"
offset="1"
id="stop2820" />
</linearGradient>
<linearGradient
id="linearGradient2794">
<stop
style="stop-color:#545454;stop-opacity:1"
offset="0"
id="stop2796" />
<stop
style="stop-color:#545454;stop-opacity:1"
offset="0.9464286"
id="stop2804" />
<stop
style="stop-color:#c5c5c5;stop-opacity:0"
offset="1"
id="stop2798" />
</linearGradient>
<linearGradient
id="linearGradient2760">
<stop
style="stop-color:#808080;stop-opacity:1"
offset="0"
id="stop2762" />
<stop
style="stop-color:#525050;stop-opacity:1"
offset="0.61160713"
id="stop2768" />
<stop
style="stop-color:#b2b2b2;stop-opacity:1"
offset="1"
id="stop2764" />
</linearGradient>
<linearGradient
x1="6.9292336"
y1="121.07077"
x2="6.9292336"
y2="8.9609833"
id="linearGradient2766"
xlink:href="#linearGradient2760"
gradientUnits="userSpaceOnUse" />
<radialGradient
cx="62.984127"
cy="65.015877"
r="54.179893"
fx="62.984127"
fy="65.015877"
id="radialGradient2812"
xlink:href="#linearGradient2794"
gradientUnits="userSpaceOnUse" />
<linearGradient
x1="31.5"
y1="63"
x2="31.5"
y2="12"
id="linearGradient2822"
xlink:href="#linearGradient2816"
gradientUnits="userSpaceOnUse" />
</defs>
<path
d="M 117.16402 65.015877 A 54.179893 54.179893 0 1 1 8.8042336,65.015877 A 54.179893 54.179893 0 1 1 117.16402 65.015877 z"
transform="translate(5.724864,4.031744)"
style="opacity:1;fill:url(#radialGradient2812);fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:3.75;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
id="path2790" />
<path
d="M 117.16402 65.015877 A 54.179893 54.179893 0 1 1 8.8042336,65.015877 A 54.179893 54.179893 0 1 1 117.16402 65.015877 z"
style="opacity:1;fill:url(#linearGradient2766);fill-opacity:1;fill-rule:nonzero;stroke:#3a3a3a;stroke-width:3.75;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
id="path1873"
transform="translate(1.69312,0)" />
<path
d="M 95.5 37.5 A 32 25.5 0 1 1 31.5,37.5 A 32 25.5 0 1 1 95.5 37.5 z"
transform="translate(1,2)"
style="opacity:1;fill:url(#linearGradient2822);fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:3.75;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
id="path2814" />
</svg>

After

Width:  |  Height:  |  Size: 4.5 KiB

View file

@ -0,0 +1,95 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
version="1.0"
width="128"
height="128"
id="svg2">
<defs
id="defs5">
<linearGradient
id="linearGradient2816">
<stop
style="stop-color:#00b000;stop-opacity:1"
offset="0"
id="stop2818" />
<stop
style="stop-color:#ffffff;stop-opacity:1"
offset="1"
id="stop2820" />
</linearGradient>
<linearGradient
id="linearGradient2794">
<stop
style="stop-color:#545454;stop-opacity:1"
offset="0"
id="stop2796" />
<stop
style="stop-color:#545454;stop-opacity:1"
offset="0.9464286"
id="stop2804" />
<stop
style="stop-color:#c5c5c5;stop-opacity:0"
offset="1"
id="stop2798" />
</linearGradient>
<linearGradient
id="linearGradient2760">
<stop
style="stop-color:#00ff00;stop-opacity:1"
offset="0"
id="stop2762" />
<stop
style="stop-color:#00a200;stop-opacity:1"
offset="0.61160713"
id="stop2768" />
<stop
style="stop-color:#66ff66;stop-opacity:1"
offset="1"
id="stop2764" />
</linearGradient>
<linearGradient
x1="6.9292336"
y1="121.07077"
x2="6.9292336"
y2="8.9609833"
id="linearGradient2766"
xlink:href="#linearGradient2760"
gradientUnits="userSpaceOnUse" />
<radialGradient
cx="62.984127"
cy="65.015877"
r="54.179893"
fx="62.984127"
fy="65.015877"
id="radialGradient2812"
xlink:href="#linearGradient2794"
gradientUnits="userSpaceOnUse" />
<linearGradient
x1="31.5"
y1="63"
x2="31.5"
y2="12"
id="linearGradient2822"
xlink:href="#linearGradient2816"
gradientUnits="userSpaceOnUse" />
</defs>
<path
d="M 117.16402,65.015877 C 117.16402,94.938606 92.906856,119.19577 62.984127,119.19577 C 33.061398,119.19577 8.8042336,94.938606 8.8042336,65.015877 C 8.8042336,35.093148 33.061398,10.835984 62.984127,10.835984 C 92.906856,10.835984 117.16402,35.093148 117.16402,65.015877 z "
transform="translate(5.724864,4.031744)"
style="opacity:1;fill:url(#radialGradient2812);fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:3.75;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
id="path2790" />
<path
d="M 117.16402,65.015877 C 117.16402,94.938606 92.906856,119.19577 62.984127,119.19577 C 33.061398,119.19577 8.8042336,94.938606 8.8042336,65.015877 C 8.8042336,35.093148 33.061398,10.835984 62.984127,10.835984 C 92.906856,10.835984 117.16402,35.093148 117.16402,65.015877 z "
transform="translate(1.69312,0)"
style="opacity:1;fill:url(#linearGradient2766);fill-opacity:1;fill-rule:nonzero;stroke:#007400;stroke-width:3.75;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
id="path1873" />
<path
d="M 95.5,37.5 C 95.5,51.583261 81.173112,63 63.5,63 C 45.826888,63 31.5,51.583261 31.5,37.5 C 31.5,23.416739 45.826888,12 63.5,12 C 81.173112,12 95.5,23.416739 95.5,37.5 z "
transform="translate(1,2)"
style="opacity:1;fill:url(#linearGradient2822);fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:3.75;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
id="path2814" />
</svg>

After

Width:  |  Height:  |  Size: 3.7 KiB

View file

@ -0,0 +1,95 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
version="1.0"
width="128"
height="128"
id="svg2">
<defs
id="defs5">
<linearGradient
id="linearGradient2816">
<stop
style="stop-color:#a20000;stop-opacity:1"
offset="0"
id="stop2818" />
<stop
style="stop-color:white;stop-opacity:1"
offset="1"
id="stop2820" />
</linearGradient>
<linearGradient
id="linearGradient2794">
<stop
style="stop-color:#545454;stop-opacity:1"
offset="0"
id="stop2796" />
<stop
style="stop-color:#545454;stop-opacity:1"
offset="0.9464286"
id="stop2804" />
<stop
style="stop-color:#c5c5c5;stop-opacity:0"
offset="1"
id="stop2798" />
</linearGradient>
<linearGradient
id="linearGradient2760">
<stop
style="stop-color:red;stop-opacity:1"
offset="0"
id="stop2762" />
<stop
style="stop-color:#a20000;stop-opacity:1"
offset="0.61160713"
id="stop2768" />
<stop
style="stop-color:#f66;stop-opacity:1"
offset="1"
id="stop2764" />
</linearGradient>
<linearGradient
x1="6.9292336"
y1="121.07077"
x2="6.9292336"
y2="8.9609833"
id="linearGradient2766"
xlink:href="#linearGradient2760"
gradientUnits="userSpaceOnUse" />
<radialGradient
cx="62.984127"
cy="65.015877"
r="54.179893"
fx="62.984127"
fy="65.015877"
id="radialGradient2812"
xlink:href="#linearGradient2794"
gradientUnits="userSpaceOnUse" />
<linearGradient
x1="31.5"
y1="63"
x2="31.5"
y2="12"
id="linearGradient2822"
xlink:href="#linearGradient2816"
gradientUnits="userSpaceOnUse" />
</defs>
<path
d="M 117.16402 65.015877 A 54.179893 54.179893 0 1 1 8.8042336,65.015877 A 54.179893 54.179893 0 1 1 117.16402 65.015877 z"
transform="translate(5.724864,4.031744)"
style="opacity:1;fill:url(#radialGradient2812);fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:3.75;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
id="path2790" />
<path
d="M 117.16402 65.015877 A 54.179893 54.179893 0 1 1 8.8042336,65.015877 A 54.179893 54.179893 0 1 1 117.16402 65.015877 z"
transform="translate(1.69312,0)"
style="opacity:1;fill:url(#linearGradient2766);fill-opacity:1;fill-rule:nonzero;stroke:#740000;stroke-width:3.75;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
id="path1873" />
<path
d="M 95.5 37.5 A 32 25.5 0 1 1 31.5,37.5 A 32 25.5 0 1 1 95.5 37.5 z"
transform="translate(1,2)"
style="opacity:1;fill:url(#linearGradient2822);fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:3.75;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
id="path2814" />
</svg>

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 494 KiB

View file

@ -0,0 +1,19 @@
{% extends "dawdle/base.html" %}
{% block content %}
<div class="contentbox">
<h1>About DawdleRPG</h1>
<p>DawdleRPG was made by Daniel Lowe in loving imitation of the venerable
<a href="https://idlerpg.net/">IdleRPG</a> of IRC fame,
by <a href="mailto:jotun@idlerpg.net">jotun@idlerpg.net</a>.</p>
<p>Source code for both the bot and the website can be found at:
<a href="https://github.com/dlowe-net/dawdlerpg/">https://github.com/dlowe-net/dawdlerpg/</a>.
The code is licensed under the Apache License 2.0.</p>
<p> Modified for the AnimeFriends community </p>
</div>
{% endblock %}

View file

@ -0,0 +1,162 @@
<html><head><title>{% block title %}Friends IdleRPG{% endblock %}</title>
<style>
body {
font-family: sans-serif;
margin: 0;
padding: 0;
background: #eee;
}
#header-container {
color: #e1cdb5;
background: #49306b;
margin: 0;
padding: 0 0.5em;
}
#header {
text-align: left;
#height: 67px;
padding: 0;
margin: 0 auto;
width: 70%;
position: relative;
}
#header h1 {
display: inline-block;
margin: 0;
line-height: 67px;
}
#header ul {
display: inline-block;
position: absolute;
right: 0;
margin: 0;
padding: 0;
list-style-type: none;
height: 100%:
}
#header li {
display: inline-block;
margin: 0;
line-height: 67px;
text-align: center;
padding-left: 1em;
padding-right: 1em;
}
#header li:hover {
background-color: #AF97AC;
}
#header a {
color: #e1cdb5;
text-decoration: none;
}
#content {
margin: 1em auto;
width: 75%;
}
.contentbox {
background: white;
margin: 2rem;
padding: 0.5rem;
}
.contentbox h1 {
margin-top: 0;
padding: 0;
}
.contentbox h2 {
margin-top: 0;
padding: 0;
}
.good-align {
color: #00f;
}
.evil-align {
color: #500;
}
@media only screen and (max-width: 1200px) {
body {
font-size: 200%;
margin: 0;
padding: 0;
}
#header-container {
padding-top: 0;
padding-bottom: 0;
}
#header h1 {
display: block;
position: relative;
text-align: center;
}
#header ul {
display: block;
position: relative;
margin: 0 auto;
text-align: center;
}
#content {
width: 100%;
margin: 0;
}
.contentbox {
margin: 0 0;
}
}
#playerlist {
display: inline-block;
border-collapse: collapse;
}
#playerlist tr:nth-child(even) {
background: #E1CDB5;
}
#playerlist thead tr {
background: #90708C;
}
#playerlist thead tr td {
color: white;
font-weight: bold;
padding: 0.5rem 1rem;
}
#playerlist td {
padding: 0.1rem 1rem;
}
#pmap-container {
display: inline-block;
padding: 1rem;
vertical-align: top;
}
.online a {
text-decoration: none;
color: black;
}
.online a:hover {
color: black;
text-decoration: underline;
}
.offline {
color: gray;
}
.offline a {
text-decoration: none;
color: gray;
}
.offline a:hover {
color: gray;
text-decoration: underline;
}
{% block extrastyles %}{% endblock %}
</style>
</head><body>
<div id="header-container">
<div id="header">
<h1><a href="/">AnimeFriends IdleRPG</a></h1>
<ul>
<a href="{% url 'dawdle:about' %}"><li>About</li></a>
<a href="{% url 'dawdle:player-list' %}"><li>Players</li></a>
<a href="/quest"><li>Quest</li></a>
<a href="/timelapse"><li>TimeLapse</li></a>
</ul>
</div>
</div>
<div id="content">{% block content %}{% endblock %}</div>
</body></html>

View file

@ -0,0 +1,112 @@
{% extends "dawdle/base.html" %}
{% block content %}
<div class="contentbox">
<h1>Welcome to IdleRPG (DawdleRPG)</h1>
<p>IdleRPG is an IRC game where your alter-ego goes adventuring
across a rich land full of magic and adventure, while players lurk
silently in a channel. You register your character, and sit back
and enjoy the ride while shit talking your friends.</p>
<p>Originally based on the DawdleRPG code, (one of the only bots with
ssl support) but has been modified slightly for a more "enjoyable" time
See all <strong>(New)</strong> items below</p>
<h2>Getting Started</h2>
<p>To start, login to your favorite IRC networ and join the
#idleRPG channel. Then, send a private message to the bot:</p>
<p><code>/msg IdleFriend register &lt;name&gt; &lt;password&gt;
&lt;character class&gt;</code></p>
<p>If all goes well, your character will start at level 0 and begin
wandering the lands. You may explore the other commands by
messaging <code>help</code> to the bot.</p>
<p>If you lose connection to IRC, you should login quickly with the
command: <code>/msg IdleFriend login &lt;name&gt; &lt;password&gt;</code></p>
<h2>Advancement & Penalties</h2>
<p>Your character advances through the passage of time. You will be
kept updated regularly on how long your character has until the
next level. Events may happen to your character which will push
you forward or back along your advancement path.</p>
<p>As much as doing nothing on the channel is rewarded, doing
something on the channel is punished. Changing your nick,
parting the channel, getting kicked from the channel,
quitting IRC, and using the LOGOUT command are all penalized to
different degrees. If you are disconnected or netsplit, your
character will <em>not</em> be penalized as long as you login
again within 15 minutes.</p>
<p>Incurring a penalty may cause you to fail a quest, penalizing
everyone online! See below for details.</p>
<p><strong>(NEW)</strong> No penalty is incurred for Chatting in the channel!</p>
<h2>Combat and Equipment</h2>
<p>Characters will frequently engage in combat. Combat is resolved
by taking the sum of item levels of each combatant, then randomly
picking a number between 0 and that sum. The side with the higher
sum wins the combat.</p>
<p>In one-on-one fights, the winner may take an item from the loser,
giving the loser a replacement item in a moment of pity. The
winner might also deal a Critical Strike, which slows the loser's
progess to the next level. Regardless of outcome, the challenger
may randomly find an item, and pick it up.</p>
<p>The level of equipment found is usually limited by the level of
the character, but there are certain rare artifacts of great
power. The level of these artifacts can be far beyond what a
character may usually possess.</p>
<h2>Alignment</h2>
<p>Your character starts out as neutral. By using
the <code>ALIGN</code> command, you may change it to good or
evil (or back to neutral, if you tire of the struggle).</p>
<p>Good characters get a bonus to their combat power, and
occasionally help each other to the next level. They are more
likely to find an item after battling. However, they are much less
likely to deal a Critical Strike.</p>
<p>Evil characters have a penalty to their combat power, but are
much more likely to deal a Critical Strike. They are less likely to
find an item after the battle. They will steal an item from a
good character if they can, but are frequently forsaken by their
evil god.</p>
<p><strong>(New)</strong> Once you align, removing your alignment
or forsaking your god and changing will incur a penalty that fits your
crimes. (Same penalty as leaving the channel)</p>
<h2>Mounts</h2>
<p>When your character is powerful enough, they might attract a
mount. This mount will level with your character, but at a faster
rate. The mount will match your alignment, however &mdash;
changing your alignment will cause them to leave in disgust. Your
mount's level adds to your prowess in battle.</p>
<h2>Quests</h2>
<p>The gods will occasionally pick out four characters to go on a
Quest. The nature of this quest may vary - sometimes a quest will
take a certain length of time. Sometimes the characters must
journey together to a far-off land. On completion of the quest,
each questor will advance 25% of their time to next level.</p>
<p>If any questor fails the quest, however, if they are ever
penalized, the quest has failed, and the gods will
punish <em>all</em> characters with a stiff penalty.</p>
</div>
{% endblock %}

View file

@ -0,0 +1,82 @@
{% extends "dawdle/base.html" %}
{% load duration %}
{% load alignment %}
{% block content %}
<div class="contentbox">
<h1>{{player.name}}, the level {{player.level}} {{player.cclass}}</h1>
<table><tbody>
<tr>
<td>Level:</td><td>{{player.level}}</td>
<td>Current Position:</td><td>{{player.posx}}, {{player.posy}}</td>
</tr>
<tr>
<td>Account created:</td><td>{{player.created}}</td>
<td>Last Login:</td><td>{{player.lastlogin}}</td>
</tr>
<tr>
<td>Total time idled:</td><td>{{player.idled|duration}}</td>
<td>Time to next level:</td><td>{{player.nextlvl|duration}}</td>
</tr>
<tr>
<td>Alignment:</td><td><span class="{{player.alignment|alignment}}-align">{{player.alignment|alignment}}</span></td>
</tr>
</tbody></table>
<img src="{% url 'dawdle:player-map' player.name %}" alt="Player Map" usemap="#playermap">
<map name="playermap">
<area shape="circle"
coords="{{player.posx}},{{player.posy}},3"
alt="{{player.name}}"
href="{% url 'dawdle:player-detail' player.name %}">
</map>
</div><div class="contentbox">
<h2>Penalties</h2>
<table><tbody>
<tr>
<td>Being kicked:</td><td>{{player.penkick|duration}}</td>
<td>Leaving channel:</td><td>{{player.penpart|duration}}</td>
</tr>
<tr>
<td>Quitting:</td><td>{{player.penquit|duration}}</td>
<td>Dropping link:</td><td>{{player.pendropped|duration}}</td>
</tr>
<tr>
<td>Changing nicks:</td><td>{{player.pennick|duration}}</td>
<td>Messaging:</td><td>{{player.penmessage|duration}}</td>
</tr>
<tr>
<td>Logout command:</td><td>{{player.penlogout|duration}}</td>
<td>Quest failures:</td><td>{{player.penquest|duration}}</td>
</tr>
</tbody></table>
<p>Total penalties: {{total_penalties|duration}}</p>
</div><div class="contentbox">
<h2>Items</h2>
<ul>
{% for item in player.item_set.all|dictsort:"slot" %}
<li>{{item.slot}}: Level {{ item.level }} {{item.name}}</li>
{% endfor %}
</ul>
<p>Total item level: {{ total_items }}</p>
</div><div class="contentbox">
{% if player.ally_set %}
<h2>Allies</h2>
<ul>
{% for ally in player.ally_set.all|dictsort:"slot" %}
<li>{{ally.slot}}: level {{ ally.level }} {{ally.fullclass}}, Next level in {{ally.nextlvl|duration}}.</li>
{% endfor %}
</ul>
</div><div class="contentbox">
{% endif %}
<h2>History</h2>
<ul>
{% for event in player.history_set.all|dictsortreversed:"time" %}
<li>{{event.time}}: {{event.text}}</li>
{% endfor %}
</ul>
</div>
{% endblock %}

View file

@ -0,0 +1,49 @@
{% extends "dawdle/base.html" %}
{% load static %}
{% load duration %}
{% load alignment %}
{% block content %}
<div class="contentbox">
<h1>Players</h1>
<table id="playerlist">
<thead>
<tr><td>Rank</td><td style="width:30rem">Name</td><td>Time to next level</td></tr>
</thead><tbody>
{% for p in object_list %}
<tr class="{%if p.online %}online{% else %}offline{% endif %}">
<td style="text-align:right">#{{forloop.counter}}</td>
<td><img style="height: 0.7em; width: 0.7em"
src="{% if p.online %}
{% static 'dawdle/Blue_sphere.svg' %}
{% else %}
{% static 'dawdle/Gray_sphere.svg' %}
{% endif %}"
alt="{% if p.online %}
On
{% else %}
Off
{% endif %}">
<a href="{% url 'dawdle:player-detail' p.name %}">{{p.name}},
the <span class="{{p.alignment|alignment}}-align">{{p.alignment|alignment}}</span>
level {{p.level}} {{p.cclass}}</a>
</td>
<td style="text-align: right"2>{{p.nextlvl|duration}}</td>
</tr>
{% endfor %}
</tbody></table>
<div id="pmap-container">
<img id="pmap" src="{% url 'dawdle:map' %}" alt="Player Map" usemap="#playermap">
</div>
<map name="playermap">
{% for p in object_list %}
<area shape="circle"
coords="{{p.posx}},{{p.posy}},3"
alt="{{p.name}}"
href="{% url 'dawdle:player-detail' p.name %}">
{% endfor %}
</map>
</div>
{% endblock %}

View file

@ -0,0 +1,53 @@
{% extends "dawdle/base.html" %}
{% load static %}
{% load duration %}
{% load alignment %}
{% load split %}
{% load lvlsum %}
{% block content %}
<div class="contentbox">
<h1>Players</h1>
<table id="playerlist">
<thead>
<tr><td>Rank</td><td style="width:30rem">Name</td><td>Time to next level</td><td>IRC User</td><td>Level Total</td></tr>
</thead><tbody>
{% for p in object_list %}
<tr class="{%if p.online %}online{% else %}offline{% endif %}">
<td style="text-align:right">#{{forloop.counter}}</td>
<td><img style="height: 0.7em; width: 0.7em"
src="{% if p.online %}
{% static 'dawdle/Blue_sphere.svg' %}
{% else %}
{% static 'dawdle/Gray_sphere.svg' %}
{% endif %}"
alt="{% if p.online %}
On
{% else %}
Off
{% endif %}">
<a href="{% url 'dawdle:player-detail' p.name %}">{{p.name}},
the <span class="{{p.alignment|alignment}}-align">{{p.alignment|alignment}}</span>
level {{p.level}} {{p.cclass}}</a>
</td>
<td style="text-align: right"2>{{p.nextlvl|duration}}</td>
<td><i>{{ p.userhost|split:"!"|first}}</i></td>
<td style="text-align: right"2>{{p.item_set | lvlsum}}</td>
{#{% for i in p.item_set.aggregate(Sum('level'))['level__sum'] %}#}
</tr>
{% endfor %}
</tbody></table>
<div id="pmap-container">
<img id="pmap" src="{% url 'dawdle:map' %}" alt="Player Map" usemap="#playermap">
</div>
<map name="playermap">
{% for p in object_list %}
<area shape="circle"
coords="{{p.posx}},{{p.posy}},3"
alt="{{p.name}}"
href="{% url 'dawdle:player-detail' p.name %}">
{% endfor %}
</map>
</div>
{% endblock %}

View file

@ -0,0 +1,63 @@
{% extends "dawdle/base.html" %}
{% load static %}
{% load duration %}
{% load alignment %}
{% block content %}
<div class="contentbox">
{% if quest and quest.mode != 0%}
<h1>The sacred quest to {{quest.text}}</h1>
{% if quest.mode == 1 %}
<p>Time left in quest: {{qtime_remaining|duration}}</p>
{% elif quest.stage == 1 %}
<p>Questor's next destination: ({{quest.dest1x}}, {{quest.dest1y}})</p>
{% else %}
<p>Questor's next destination: ({{quest.dest2x}}, {{quest.dest2y}})</p>
{% endif %}
<table id="playerlist">
<thead>
<tr><td style="width:30rem">Questor</td><td>Time to next level</td></tr>
</thead><tbody>
{% for p in questors %}
<tr class="{%if p.online %}online{% else %}offline{% endif %}">
<td><img style="height: 0.7em; width: 0.7em"
src="{% if p.online %}
{% static 'dawdle/Blue_sphere.svg' %}
{% else %}
{% static 'dawdle/Gray_sphere.svg' %}
{% endif %}"
alt="{% if p.online %}
On
{% else %}
Off
{% endif %}">
<a href="{% url 'dawdle:player-detail' p.name %}">{{p.name}},
the <span class="{{p.alignment|alignment}}-align">{{p.alignment|alignment}}</span>
level {{p.level}} {{p.cclass}}</a>
</td>
<td style="text-align: right"2>{{p.nextlvl|duration}}</td>
</tr>
{% endfor %}
</tbody></table>
<div id="pmap-container">
<img id="pmap" src="{% url 'dawdle:map' %}" alt="Player Map" usemap="#playermap">
</div>
<map name="playermap">
{% for p in questors %}
<area shape="circle"
coords="{{p.posx}},{{p.posy}},3"
alt="{{p.name}}"
href="{% url 'dawdle:player-detail' p.name %}">
{% endfor %}
</map>
{% else %}
<h1>No current quest</h1>
<p>There is no decreed quest at this time.</p>
{% endif %}
</div>
{% endblock %}

View file

@ -0,0 +1,16 @@
{% extends "dawdle/base.html" %}
{% block content %}
<div class="contentbox">
<h1>Timelapse of movements</h1>
<p>A Timelapse of everyone since:
Mon, Feb 26 00:05:24 UTC 2024 </p>
{% with timelapse='Wed Feb 28 04:01:29 UTC 2024' %}
<p> Last Generated: {{ timelapse }} </p>
{% endwith %}
<video controls width="500"> <source src="/static/map.mp4" type="video/mp4" />
</div>
{% endblock %}

View file

View file

@ -0,0 +1,8 @@
from django import template
register = template.Library()
def alignment(align):
return {"g": "good", "n":"neutral", "e":"evil"}[align]
register.filter(alignment)

View file

@ -0,0 +1,17 @@
from django import template
register = template.Library()
def plural(num, singlestr='', pluralstr='s'):
"""Return singlestr when num is 1, otherwise pluralstr."""
if num == 1:
return singlestr
return pluralstr
def duration(secs):
d, secs = int(secs / 86400), secs % 86400
h, secs = int(secs / 3600), secs % 3600
m, secs = int(secs / 60), secs % 60
return f"{d} day{plural(d)}, {h:02d}:{m:02d}:{int(secs):02d}"
register.filter(duration)

View file

@ -0,0 +1,9 @@
from django import template
from django.db.models import Sum
register = template.Library()
def lvlsum(values):
return values.aggregate(Sum(('level')))['level__sum']
register.filter(lvlsum)

View file

@ -0,0 +1,10 @@
from django import template
register = template.Library()
def split(value, key):
value.split("key")
return value.split(key)
register.filter(split)

3
site/dawdle/tests.py Normal file
View file

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

17
site/dawdle/urls.py Normal file
View file

@ -0,0 +1,17 @@
from django.contrib import admin
from django.urls import path
from dawdle import views
app_name = 'dawdle'
urlpatterns = [
path('', views.FrontView.as_view(), name='front'),
path('about', views.AboutView.as_view(), name='about'),
path('map', views.MapView.as_view(), name='map'),
path('map/<path:player>', views.MapView.as_view(), name='player-map'),
path('map/quest', views.MapView.as_view(), {'quest': 'quest'}, name='quest-map'),
path('players', views.PlayerListView.as_view(), name='player-list'),
path('player/<path:pk>', views.PlayerDetailView.as_view(), name='player-detail'),
path('quest', views.QuestView.as_view(), name='quest'),
path('timelapse', views.Timelapse.as_view(), name='timelapse'),
]

152
site/dawdle/views.py Normal file
View file

@ -0,0 +1,152 @@
import csv
import io
import json
import os
import time
from xml.dom import minidom
from django.db.models import Sum
from django.http import HttpResponse
from django.shortcuts import render
from django.views import generic, View
from django.views.decorators.vary import vary_on_headers
from PIL import Image, ImageDraw
from .models import Player, Item, Quest
from project.settings import BASE_DIR
class FrontView(generic.TemplateView):
template_name = "dawdle/front.html"
class AboutView(generic.TemplateView):
template_name = "dawdle/about.html"
class MapView(View):
def _colorplayer(self, player, questors):
if player.name in questors:
return (0x00, 0xff, 0xe9), (0x80, 0xbb, 0xff)
if player.online:
return (0x00, 0x1e, 0xe9), (0x80, 0xbb, 0xff)
return (0xaa, 0xaa, 0xaa), (0xee, 0xee, 0xee)
def get(self, request, *args, **kwargs):
base_map = Image.open(os.path.join(BASE_DIR, "dawdle/static/dawdle/map.png"))
full_map = base_map.copy()
draw = ImageDraw.Draw(full_map)
questors = []
q = Quest.objects.get()
if q and q.mode != 0:
questors = [q.p1, q.p2, q.p3, q.p4]
if 'player' in kwargs:
pquery = Player.objects.filter(name=kwargs['player'])
dotsize = 5
elif 'quest' in kwargs and questors:
pquery = Player.objects.filter(name__in=questors)
else:
pquery = Player.objects
dotsize = 3
if q and q.mode == 2:
if q.stage == 1:
draw.ellipse([q.dest1x-dotsize, q.dest1y-dotsize, q.dest1x+dotsize, q.dest1y+dotsize], fill=(0xff, 0xff, 0x00))
else:
draw.ellipse([q.dest2x-dotsize, q.dest2y-dotsize, q.dest2x+dotsize, q.dest2y+dotsize], fill=(0xff, 0xff, 0x00))
for p in pquery.all():
fillcolor, strokecolor = self._colorplayer(p, questors)
draw.ellipse([p.posx-dotsize, p.posy-dotsize, p.posx+dotsize, p.posy+dotsize],
outline=strokecolor,
fill=fillcolor)
map_bytes = io.BytesIO()
full_map.save(map_bytes, format="png")
return HttpResponse(map_bytes.getvalue(), content_type='image/png')
class PlayerListView(generic.ListView):
model = Player
queryset = Player.objects.order_by('-level', 'nextlvl')
#def get_context_data(self, **kwargs):
# #context = super().get_context_data(**kwargs)
# context['players'] = p.order_by('-level', 'nextlvl')
# context['total_items'] = p.item_set.aggregate(Sum('level'))['level__sum']
# return context
class PlayerDetailView(generic.DetailView):
model = Player
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
p = self.object
context['total_penalties'] = sum([p.penkick, p.penpart, p.penquit, p.pendropped, p.pennick, p.penmessage, p.penlogout, p.penquest])
context['total_items'] = p.item_set.aggregate(Sum('level'))['level__sum']
return context
class PlayerDumpView(generic.ListView):
@vary_on_headers('Accept')
def get(self, request, *args, **kwargs):
response = HttpResponse()
plist = []
for p in Player.objects.all():
plist.append({
"name": p.name,
"cclass": p.cclass,
"idled": p.idled,
"level": p.level,
"nick": p.nick,
"userhost": p.userhost,
"email": p.email,
})
if request.accepts('text/plain') or request.accepts('text/csv'):
response.content_type = 'text/csv'
writer = csv.DictWriter(response, ('name', 'cclass', 'idled', 'level', 'nick', 'userhost', 'email'))
writer.writeheader()
for p in plist:
writer.writerow(p)
elif request.accepts('application/json'):
response.content_type = 'application/json'
json.dump(plist, response, separators=(',',':'))
elif request.accepts('application/xml'):
response.content_type = 'application/xml'
root = minidom.Document()
players_el = root.createElement('players')
root.appendChild(players_el)
for p in plist:
el = root.createElement('player')
for k,v in p.items():
el.setAttribute(k, str(v))
players_el.appendChild(el)
root.writexml(response)
return response
class QuestView(generic.TemplateView):
template_name = "dawdle/quest.html"
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
try:
quest = Quest.objects.get()
questors = Player.objects.filter(name__in=[quest.p1,quest.p2,quest.p3,quest.p4]).all()
context['quest'] = quest
context['questors'] = questors
context['qtime_remaining'] = quest.qtime - time.time()
except Quest.DoesNotExist:
context['quest'] = None
return context
class Timelapse(generic.TemplateView):
template_name = "dawdle/timelapse.html"

21
site/manage.py Executable file
View file

@ -0,0 +1,21 @@
#!/usr/bin/env python3
"""Django's command-line utility for administrative tasks."""
import os
import sys
def main():
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'project.settings')
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
raise ImportError(
"Couldn't import Django. Are you sure it's installed and "
"available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?"
) from exc
execute_from_command_line(sys.argv)
if __name__ == '__main__':
main()

0
site/project/__init__.py Normal file
View file

129
site/project/settings.py Normal file
View file

@ -0,0 +1,129 @@
"""
Django settings for dawdle project.
Generated by 'django-admin startproject' using Django 2.2.12.
For more information on this file, see
https://docs.djangoproject.com/en/2.2/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/2.2/ref/settings/
"""
import os
# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/2.2/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = 'GKF+J8ifZXIcW0sMP4+EOxD4DHTAZSN0FXbw/l9qP8Zchy1Xd1Z5wPbIEhOj'
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = False
ALLOWED_HOSTS = ['irc.l3.lv']
# Application definition
INSTALLED_APPS = [
'dawdle.apps.DawdleConfig',
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
]
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
ROOT_URLCONF = 'project.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
],
},
},
]
WSGI_APPLICATION = 'project.wsgi.application'
# Database
# https://docs.djangoproject.com/en/2.2/ref/settings/#databases
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': os.path.join(BASE_DIR, 'db.sqlite3'),
},
'game': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': os.path.join(BASE_DIR, 'game.sqlite3'),
}
}
# Routers
# https://docs.djangoproject.com/en/3.2/topics/db/multi-db/
DATABASE_ROUTERS = ['dawdle.router.DawdleRouter']
# Password validation
# https://docs.djangoproject.com/en/2.2/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = [
{
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
},
]
# Internationalization
# https://docs.djangoproject.com/en/2.2/topics/i18n/
LANGUAGE_CODE = 'en-us'
TIME_ZONE = 'UTC'
USE_I18N = True
USE_L10N = True
USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/2.2/howto/static-files/
STATIC_URL = '/static/'
STATIC_ROOT = os.path.join(BASE_DIR, "static")

22
site/project/urls.py Normal file
View file

@ -0,0 +1,22 @@
"""dawdle URL Configuration
The `urlpatterns` list routes URLs to views. For more information please see:
https://docs.djangoproject.com/en/2.2/topics/http/urls/
Examples:
Function views
1. Add an import: from my_app import views
2. Add a URL to urlpatterns: path('', views.home, name='home')
Class-based views
1. Add an import: from other_app.views import Home
2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
Including another URLconf
1. Import the include() function: from django.urls import include, path
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
"""
from django.contrib import admin
from django.urls import include, path
urlpatterns = [
path('', include('dawdle.urls')),
path('admin/', admin.site.urls),
]

16
site/project/wsgi.py Normal file
View file

@ -0,0 +1,16 @@
"""
WSGI config for dawdle project.
It exposes the WSGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/2.2/howto/deployment/wsgi/
"""
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'project.settings')
application = get_wsgi_application()

27
update.sh Normal file
View file

@ -0,0 +1,27 @@
#!/bin/bash
set -x
set -e
DIR="$(readlink -f $(dirname $0))"
echo "Using $DIR as dawdlerpg directory"
echo "Updating source tree"
cd "$DIR"
git fetch -a
git stash
git merge origin/main
git stash pop
echo "Migrating db"
cd "$DIR/site"
./manage.py --database=default
./manage.py --database=game
./manage collectstatic --no-input
cd "$DIR"
chown -R www-data:www-data "$DIR"
echo "Restart bot and website"
systemctl restart dawdlerpg
systemctl restart uwsgi