Rename history to state as it's now storing more than just the history
This commit is contained in:
parent
c93c5b9ecd
commit
78834f5319
5 changed files with 149 additions and 40 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -1,4 +1,4 @@
|
|||
__pycache__
|
||||
config.json
|
||||
history.json
|
||||
state.json
|
||||
.venv
|
10
matching.py
10
matching.py
|
@ -3,7 +3,7 @@ import logging
|
|||
import random
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Protocol, runtime_checkable
|
||||
import history
|
||||
import state
|
||||
|
||||
|
||||
# Number of days to step forward from the start of history for each match attempt
|
||||
|
@ -88,7 +88,7 @@ def get_member_group_eligibility_score(member: Member,
|
|||
|
||||
|
||||
def attempt_create_groups(matchees: list[Member],
|
||||
hist: history.History,
|
||||
hist: state.State,
|
||||
oldest_relevant_ts: datetime,
|
||||
per_group: int) -> tuple[bool, list[list[Member]]]:
|
||||
"""History aware group matching"""
|
||||
|
@ -106,7 +106,7 @@ def attempt_create_groups(matchees: list[Member],
|
|||
matchee_matches = hist.matchees.get(
|
||||
str(matchee.id), {}).get("matches", {})
|
||||
relevant_matches = list(int(id) for id, ts in matchee_matches.items()
|
||||
if history.ts_to_datetime(ts) >= oldest_relevant_ts)
|
||||
if state.ts_to_datetime(ts) >= oldest_relevant_ts)
|
||||
|
||||
# Try every single group from the current group onwards
|
||||
# Progressing through the groups like this ensures we slowly fill them up with compatible people
|
||||
|
@ -144,7 +144,7 @@ def datetime_range(start_time: datetime, increment: timedelta, end: datetime):
|
|||
|
||||
|
||||
def members_to_groups(matchees: list[Member],
|
||||
hist: history.History = history.History(),
|
||||
hist: state.State = state.State(),
|
||||
per_group: int = 3,
|
||||
allow_fallback: bool = False) -> list[list[Member]]:
|
||||
"""Generate the groups from the set of matchees"""
|
||||
|
@ -152,7 +152,7 @@ def members_to_groups(matchees: list[Member],
|
|||
rand = random.Random(117) # Some stable randomness
|
||||
|
||||
# Grab the oldest timestamp
|
||||
history_start = hist.oldest() or datetime.now()
|
||||
history_start = hist.oldest_history() or datetime.now()
|
||||
|
||||
# Walk from the start of time until now using the timestep increment
|
||||
for oldest_relevant_datetime in datetime_range(history_start, _ATTEMPT_TIMESTEP_INCREMENT, datetime.now()):
|
||||
|
|
|
@ -5,7 +5,7 @@ import discord
|
|||
import pytest
|
||||
import random
|
||||
import matching
|
||||
import history
|
||||
import state
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
|
||||
|
@ -44,16 +44,16 @@ class Member():
|
|||
return self._id
|
||||
|
||||
|
||||
def inner_validate_members_to_groups(matchees: list[Member], hist: history.History, per_group: int):
|
||||
def inner_validate_members_to_groups(matchees: list[Member], tmp_state: state.State, per_group: int):
|
||||
"""Inner function to validate the main output of the groups function"""
|
||||
groups = matching.members_to_groups(matchees, hist, per_group)
|
||||
groups = matching.members_to_groups(matchees, tmp_state, per_group)
|
||||
|
||||
# We should always have one group
|
||||
assert len(groups)
|
||||
|
||||
# Log the groups to history
|
||||
# This will validate the internals
|
||||
hist.log_groups_to_history(groups)
|
||||
tmp_state.log_groups(groups)
|
||||
|
||||
# Ensure each group contains within the bounds of expected members
|
||||
for group in groups:
|
||||
|
@ -81,8 +81,8 @@ def inner_validate_members_to_groups(matchees: list[Member], hist: history.Histo
|
|||
], ids=['single', "larger_groups", "100_members", "5_group", "pairs", "356_big_groups"])
|
||||
def test_members_to_groups_no_history(matchees, per_group):
|
||||
"""Test simple group matching works"""
|
||||
hist = history.History()
|
||||
inner_validate_members_to_groups(matchees, hist, per_group)
|
||||
tmp_state = state.State()
|
||||
inner_validate_members_to_groups(matchees, tmp_state, per_group)
|
||||
|
||||
|
||||
def items_found_in_lists(list_of_lists, items):
|
||||
|
@ -95,8 +95,8 @@ def items_found_in_lists(list_of_lists, items):
|
|||
|
||||
@pytest.mark.parametrize("history_data, matchees, per_group, checks", [
|
||||
# Slightly more difficult test
|
||||
# Describe a history where we previously matched up some people and ensure they don't get rematched
|
||||
(
|
||||
# Describe a history where we previously matched up some people and ensure they don't get rematched
|
||||
[
|
||||
{
|
||||
"ts": datetime.now() - timedelta(days=1),
|
||||
|
@ -156,13 +156,13 @@ def items_found_in_lists(list_of_lists, items):
|
|||
], ids=['simple_history', 'fallback'])
|
||||
def test_members_to_groups_with_history(history_data, matchees, per_group, checks):
|
||||
"""Test more advanced group matching works"""
|
||||
hist = history.History()
|
||||
tmp_state = state.State()
|
||||
|
||||
# Replay the history
|
||||
for d in history_data:
|
||||
hist.log_groups_to_history(d["groups"], d["ts"])
|
||||
tmp_state.log_groups(d["groups"], d["ts"])
|
||||
|
||||
groups = inner_validate_members_to_groups(matchees, hist, per_group)
|
||||
groups = inner_validate_members_to_groups(matchees, tmp_state, per_group)
|
||||
|
||||
# Run the custom validate functions
|
||||
for check in checks:
|
||||
|
@ -208,8 +208,8 @@ def test_members_to_groups_stress_test():
|
|||
rand.shuffle(history_data)
|
||||
|
||||
# Replay the history
|
||||
hist = history.History()
|
||||
tmp_state = state.State()
|
||||
for d in history_data:
|
||||
hist.log_groups_to_history(d["groups"], d["ts"])
|
||||
tmp_state.log_groups(d["groups"], d["ts"])
|
||||
|
||||
inner_validate_members_to_groups(matchees, hist, per_group)
|
||||
inner_validate_members_to_groups(matchees, tmp_state, per_group)
|
||||
|
|
26
matchy.py
26
matchy.py
|
@ -6,13 +6,13 @@ import discord
|
|||
from discord import app_commands
|
||||
from discord.ext import commands
|
||||
import matching
|
||||
import history
|
||||
import state
|
||||
import config
|
||||
import re
|
||||
|
||||
|
||||
Config = config.load()
|
||||
History = history.load()
|
||||
State = state.load()
|
||||
|
||||
logger = logging.getLogger("matchy")
|
||||
logger.setLevel(logging.INFO)
|
||||
|
@ -68,22 +68,6 @@ async def close(ctx: commands.Context):
|
|||
await bot.close()
|
||||
|
||||
|
||||
# @bot.tree.command(description="Sign up as a matchee in this server")
|
||||
# @commands.guild_only()
|
||||
# async def join(interaction: discord.Interaction):
|
||||
# # TODO: Sign up
|
||||
# await interaction.response.send_message(
|
||||
# f"Awesome, great to have you on board {interaction.user.mention}!", ephemeral=True)
|
||||
|
||||
|
||||
# @bot.tree.command(description="Leave the matchee list in this server")
|
||||
# @commands.guild_only()
|
||||
# async def leave(interaction: discord.Interaction):
|
||||
# # TODO: Remove the user
|
||||
# await interaction.response.send_message(
|
||||
# f"No worries, see you soon {interaction.user.mention}!", ephemeral=True)
|
||||
|
||||
|
||||
@bot.tree.command(description="Match up matchees")
|
||||
@commands.guild_only()
|
||||
@app_commands.describe(members_min="Minimum matchees per match (defaults to 3)",
|
||||
|
@ -114,7 +98,7 @@ async def match(interaction: discord.Interaction, members_min: int = None, match
|
|||
matchees = list(
|
||||
m for m in interaction.channel.members if matchee in m.roles)
|
||||
groups = matching.members_to_groups(
|
||||
matchees, History, members_min, allow_fallback=True)
|
||||
matchees, State, members_min, allow_fallback=True)
|
||||
|
||||
# Post about all the groups with a button to send to the channel
|
||||
groups_list = '\n'.join(matching.group_to_message(g) for g in groups)
|
||||
|
@ -173,7 +157,7 @@ class DynamicGroupButton(discord.ui.DynamicItem[discord.ui.Button],
|
|||
matchees = list(
|
||||
m for m in interaction.channel.members if matchee in m.roles)
|
||||
groups = matching.members_to_groups(
|
||||
matchees, History, self.min, allow_fallback=True)
|
||||
matchees, State, self.min, allow_fallback=True)
|
||||
|
||||
# Send the groups
|
||||
for msg in (matching.group_to_message(g) for g in groups):
|
||||
|
@ -183,7 +167,7 @@ class DynamicGroupButton(discord.ui.DynamicItem[discord.ui.Button],
|
|||
await interaction.channel.send("That's all folks, happy matching and remember - DFTBA!")
|
||||
|
||||
# Save the groups to the history
|
||||
History.save_groups_to_history(groups)
|
||||
State.save_groups(groups)
|
||||
|
||||
logger.info("Done. Matched %s matchees into %s groups.",
|
||||
len(matchees), len(groups))
|
||||
|
|
125
state.py
Normal file
125
state.py
Normal file
|
@ -0,0 +1,125 @@
|
|||
"""Store bot state"""
|
||||
import os
|
||||
from datetime import datetime
|
||||
from schema import Schema, And, Use, Optional
|
||||
from typing import Protocol
|
||||
import files
|
||||
import copy
|
||||
|
||||
_FILE = "state.json"
|
||||
|
||||
# Warning: Changing any of the below needs proper thought to ensure backwards compatibility
|
||||
_DEFAULT_DICT = {
|
||||
"history": {},
|
||||
"matchees": {}
|
||||
}
|
||||
_TIME_FORMAT = "%a %b %d %H:%M:%S %Y"
|
||||
_SCHEMA = Schema(
|
||||
{
|
||||
Optional("history"): {
|
||||
Optional(str): { # a datetime
|
||||
"groups": [
|
||||
{
|
||||
"members": [
|
||||
# The ID of each matchee in the match
|
||||
And(Use(int))
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
Optional("matchees"): {
|
||||
Optional(str): {
|
||||
Optional("matches"): {
|
||||
# Matchee ID and Datetime pair
|
||||
Optional(str): And(Use(str))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class Member(Protocol):
|
||||
@property
|
||||
def id(self) -> int:
|
||||
pass
|
||||
|
||||
|
||||
def ts_to_datetime(ts: str) -> datetime:
|
||||
"""Convert a ts to datetime using the internal format"""
|
||||
return datetime.strptime(ts, _TIME_FORMAT)
|
||||
|
||||
|
||||
def validate(dict: dict):
|
||||
"""Initialise and validate the state"""
|
||||
_SCHEMA.validate(dict)
|
||||
|
||||
|
||||
class State():
|
||||
def __init__(self, data: dict = _DEFAULT_DICT):
|
||||
"""Initialise and validate the state"""
|
||||
validate(data)
|
||||
self.__dict__ = copy.deepcopy(data)
|
||||
|
||||
@property
|
||||
def history(self) -> list[dict]:
|
||||
return self.__dict__["history"]
|
||||
|
||||
@property
|
||||
def matchees(self) -> dict[str, dict]:
|
||||
return self.__dict__["matchees"]
|
||||
|
||||
def save(self) -> None:
|
||||
"""Save out the state"""
|
||||
files.save(_FILE, self.__dict__)
|
||||
|
||||
def oldest_history(self) -> datetime:
|
||||
"""Grab the oldest timestamp in history"""
|
||||
if not self.history:
|
||||
return None
|
||||
times = (ts_to_datetime(dt) for dt in self.history.keys())
|
||||
return sorted(times)[0]
|
||||
|
||||
def log_groups(self, groups: list[list[Member]], ts: datetime = datetime.now()) -> None:
|
||||
"""Log the groups"""
|
||||
tmp_state = State(self.__dict__)
|
||||
ts = datetime.strftime(ts, _TIME_FORMAT)
|
||||
|
||||
# Grab or create the hitory item for this set of groups
|
||||
history_item = {}
|
||||
tmp_state.history[ts] = history_item
|
||||
history_item_groups = []
|
||||
history_item["groups"] = history_item_groups
|
||||
|
||||
for group in groups:
|
||||
|
||||
# Add the group data
|
||||
history_item_groups.append({
|
||||
"members": list(m.id for m in group)
|
||||
})
|
||||
|
||||
# Update the matchee data with the matches
|
||||
for m in group:
|
||||
matchee = tmp_state.matchees.get(str(m.id), {})
|
||||
matchee_matches = matchee.get("matches", {})
|
||||
|
||||
for o in (o for o in group if o.id != m.id):
|
||||
matchee_matches[str(o.id)] = ts
|
||||
|
||||
matchee["matches"] = matchee_matches
|
||||
tmp_state.matchees[str(m.id)] = matchee
|
||||
|
||||
# Validate before storing the result
|
||||
validate(self.__dict__)
|
||||
self.__dict__ = tmp_state.__dict__
|
||||
|
||||
def save_groups(self, groups: list[list[Member]]) -> None:
|
||||
"""Save out the groups to the state file"""
|
||||
self.log_groups(groups)
|
||||
self.save()
|
||||
|
||||
|
||||
def load() -> State:
|
||||
"""Load the state"""
|
||||
return State(files.load(_FILE) if os.path.isfile(_FILE) else _DEFAULT_DICT)
|
Loading…
Add table
Reference in a new issue