wordbot: Add round extension and some other stuff

If nobody scored in this round of wordbot, then silently extend the
round (if configured to do so; default yes).

Also add some type annotations and an index to ensure_db
This commit is contained in:
2026-01-26 18:03:33 -08:00
parent c247dbae8c
commit a299623dd9

View File

@@ -20,10 +20,10 @@ def denotify_nick(nick: str) -> str:
class Db: class Db:
def __init__(self, path: Path): def __init__(self, path: Path) -> None:
self.path = path self.path = path
def ensure_db(self): def ensure_db(self) -> None:
self.path.parent.mkdir(parents=True, exist_ok=True) self.path.parent.mkdir(parents=True, exist_ok=True)
with sqlite3.connect(self.path) as conn: with sqlite3.connect(self.path) as conn:
conn.executescript( conn.executescript(
@@ -51,6 +51,7 @@ class Db:
FOREIGN KEY (word) REFERENCES word(id), FOREIGN KEY (word) REFERENCES word(id),
UNIQUE(game, word) UNIQUE(game, word)
); );
CREATE INDEX IF NOT EXISTS idx_score_game_word ON score(game, word);
""" """
) )
@@ -82,7 +83,7 @@ class Db:
def start_round( def start_round(
self, channel: str, duration: int, words: Set[str], allow_early_end=False self, channel: str, duration: int, words: Set[str], allow_early_end=False
): ) -> None:
self.ensure_db() self.ensure_db()
if self.is_game_active(channel) and not allow_early_end: if self.is_game_active(channel) and not allow_early_end:
# Don't start a new game if you don't have to # Don't start a new game if you don't have to
@@ -102,7 +103,13 @@ class Db:
"INSERT INTO word (game, word) VALUES (?, ?)", game_words_iter "INSERT INTO word (game, word) VALUES (?, ?)", game_words_iter
) )
def add_score(self, channel: str, user: str, word: str, line: str): def extend_round(self, game_id: int, duration: int) -> None:
self.ensure_db()
end = time.time() + duration
with sqlite3.connect(self.path) as conn:
conn.execute("UPDATE game SET end = end + ? WHERE id = ?", (end, game_id))
def add_score(self, channel: str, user: str, word: str, line: str) -> None:
self.ensure_db() self.ensure_db()
game_id = self.current_game(channel) game_id = self.current_game(channel)
if not game_id: if not game_id:
@@ -124,7 +131,7 @@ class Db:
{"game_id": game_id, "word": word, "user": user, "line": line}, {"game_id": game_id, "word": word, "user": user, "line": line},
) )
def scores(self, channel: str): def scores(self, channel: str) -> dict[str, int]:
# This differs from .leaderboard() by using a specific game ID, rather # This differs from .leaderboard() by using a specific game ID, rather
# than all games for the channel. # than all games for the channel.
game_id = self.current_game(channel) game_id = self.current_game(channel)
@@ -143,7 +150,7 @@ class Db:
rows = cur.fetchall() rows = cur.fetchall()
return {row[0]: row[1] for row in rows} return {row[0]: row[1] for row in rows}
def leaderboard(self, channel: str): def leaderboard(self, channel: str) -> dict[str, int]:
# This differs from .scores() by using the game.channel = ?, rather than # This differs from .scores() by using the game.channel = ?, rather than
# a specific game id. # a specific game id.
with sqlite3.connect(self.path) as conn: with sqlite3.connect(self.path) as conn:
@@ -179,7 +186,7 @@ class Db:
class Wordbot(Plugin): class Wordbot(Plugin):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs) -> None:
super(Wordbot, self).__init__(*args, **kwargs) super(Wordbot, self).__init__(*args, **kwargs)
self.db_path = Path( self.db_path = Path(
self.plugin_config.get("db_path", "data/wordbot/wordbot.db") self.plugin_config.get("db_path", "data/wordbot/wordbot.db")
@@ -188,40 +195,41 @@ class Wordbot(Plugin):
self.db = Db(self.db_path) self.db = Db(self.db_path)
self.duration = int(self.plugin_config.get("hours_per_round", 5)) * 3600 self.duration = int(self.plugin_config.get("hours_per_round", 5)) * 3600
self.words_per_round = int(self.plugin_config.get("words_per_round", 300)) self.words_per_round = int(self.plugin_config.get("words_per_round", 300))
self.__watch_games_task = None self.extend_game = self.plugin_config.get("extend_game", True)
self.__db_lock = asyncio.Lock() self._watch_games_task = None
self._db_lock = asyncio.Lock()
def get_words(self) -> Set[str]: def get_words(self) -> Set[str]:
with open(self.words_path) as fp: with open(self.words_path) as fp:
return {word.strip().lower() for word in fp} return {word.strip().lower() for word in fp}
async def on_load(self): async def on_load(self) -> None:
# Make sure games are running on all channels # Make sure games are running on all channels
# This happens before on_connect # This happens before on_connect
for channel in self.channels: for channel in self.channels:
if not self.db.is_game_active(channel): if not self.db.is_game_active(channel):
self.start_round(channel) self.start_round(channel)
async def on_connect(self, conn: IrcProtocol): async def on_connect(self, conn: IrcProtocol) -> None:
# Start watcher up to end games # Start watcher up to end games
self.__watch_games_task = asyncio.create_task(self.__watch_games(conn)) self._watch_games_task = asyncio.create_task(self._watch_games(conn))
async def __watch_games(self, conn: IrcProtocol): async def _watch_games(self, conn: IrcProtocol) -> None:
while True: while True:
await asyncio.sleep(1.0) await asyncio.sleep(1.0)
for channel in self.bot.joined_channels: for channel in self.bot.joined_channels:
if not self.db.is_game_active(channel): if not self.db.is_game_active(channel):
async with self.__db_lock: async with self._db_lock:
# End round # End round
self.end_round(conn, channel) self.end_round(conn, channel)
# Create new round # Create new round
self.start_round(channel) self.start_round(channel)
async def on_unload(self, conn: IrcProtocol): async def on_unload(self, conn: IrcProtocol) -> None:
if self.__watch_games_task: if self._watch_games_task:
self.__watch_games_task.cancel() self._watch_games_task.cancel()
async def on_message(self, conn: IrcProtocol, channel: str, who: Prefix, line: str): async def on_message(self, conn: IrcProtocol, channel: str, who: Prefix, line: str) -> None:
if who.nick == self.server_config.nick: if who.nick == self.server_config.nick:
return return
line = line.strip() line = line.strip()
@@ -230,7 +238,7 @@ class Wordbot(Plugin):
elif line[0] == "!": elif line[0] == "!":
await self.handle_command(conn, channel, who, line) await self.handle_command(conn, channel, who, line)
else: else:
async with self.__db_lock: async with self._db_lock:
if not self.db.is_game_active(channel): if not self.db.is_game_active(channel):
# Don't try to score words for inactive games # Don't try to score words for inactive games
return return
@@ -269,11 +277,11 @@ class Wordbot(Plugin):
async def handle_command( async def handle_command(
self, conn: IrcProtocol, channel: str, who: Prefix, line: str self, conn: IrcProtocol, channel: str, who: Prefix, line: str
): ) -> None:
parts = line.strip().split() parts = line.strip().split()
match parts: match parts:
# case ["!wordbot", "end_now"]: # case ["!wordbot", "end_now"]:
# async with self.__db_lock: # async with self._db_lock:
# self.end_round(conn, channel) # self.end_round(conn, channel)
# self.start_round(channel, allow_early_end=True) # self.start_round(channel, allow_early_end=True)
case ["!wordbot", "leaderboard", *args]: case ["!wordbot", "leaderboard", *args]:
@@ -306,7 +314,7 @@ class Wordbot(Plugin):
case _: case _:
pass pass
def start_round(self, channel: str, allow_early_end: bool = False): def start_round(self, channel: str, allow_early_end: bool = False) -> None:
log.debug("Starting new wordbot round for %s", channel) log.debug("Starting new wordbot round for %s", channel)
# Choose words for new round # Choose words for new round
with open(self.words_path) as fp: with open(self.words_path) as fp:
@@ -315,11 +323,18 @@ class Wordbot(Plugin):
words = words[: self.words_per_round] words = words[: self.words_per_round]
self.db.start_round(channel, self.duration, words, allow_early_end) self.db.start_round(channel, self.duration, words, allow_early_end)
def end_round(self, conn: IrcProtocol, channel: str): def end_round(self, conn: IrcProtocol, channel: str) -> None:
log.debug("Ending wordbot round for %s", channel) log.debug("Ending wordbot round for %s", channel)
# Sort the scores
scores = sorted(self.db.scores(channel).items(), key=lambda value: -value[1]) scores = sorted(self.db.scores(channel).items(), key=lambda value: -value[1])
# Add their ordering game_id = self.db.current_game(channel)
if not scores and self.extend_game:
log.debug(
"No scores were made in this round - extending the game by %s hours",
self.hours_per_round
)
self.db.extend_round(game_id, self.duration)
return
# Add score ordering
rankings = { rankings = {
score: rank score: rank
for rank, score in enumerate( for rank, score in enumerate(
@@ -328,7 +343,6 @@ class Wordbot(Plugin):
) )
) )
} }
game_id = self.db.current_game(channel)
self.send_to(conn, channel, f"Game #{game_id} over. Here were the scores:") self.send_to(conn, channel, f"Game #{game_id} over. Here were the scores:")
for user, score in scores: for user, score in scores:
self.send_to(conn, channel, f"{rankings[score] + 1}. {user}. {score}") self.send_to(conn, channel, f"{rankings[score] + 1}. {user}. {score}")