diff --git a/server/auto_pair.py b/server/auto_pair.py index 3f30ca891..10739eda6 100644 --- a/server/auto_pair.py +++ b/server/auto_pair.py @@ -1,14 +1,64 @@ +from itertools import product + +from const import BYOS +from misc import time_control_str from newid import new_id from seek import Seek from utils import join_seek from websocket_utils import ws_send_json +def add_to_auto_pairings(app_state, user, data): + """Add auto pairing to app_state and while doing this + tries to find a compatible other auto pairing or seek""" + + auto_variant_tc = None + matching_user = None + matching_seek = None + + rrmin = data["rrmin"] + rrmax = data["rrmax"] + rrmin = rrmin if (rrmin != -1000) else -10000 + rrmax = rrmax if (rrmax != 1000) else 10000 + app_state.auto_pairing_users[user] = (rrmin, rrmax) + + for variant_tc in product(data["variants"], data["tcs"]): + variant_tc = ( + variant_tc[0][0], + variant_tc[0][1], + variant_tc[1][0], + variant_tc[1][1], + variant_tc[1][2], + ) + variant, chess960, base, inc, byoyomi_period = variant_tc + # We don't want to create non byo variant with byo TC combinations + if (byoyomi_period > 0 and variant not in BYOS) or variant.startswith("bughouse"): + continue + + if variant_tc not in app_state.auto_pairings: + app_state.auto_pairings[variant_tc] = set() + app_state.auto_pairings[variant_tc].add(user) + + if (matching_user is None) and (matching_seek is None): + # Try to find the same combo in auto_pairings + matching_user = find_matching_user(app_state, user, variant_tc) + auto_variant_tc = variant_tc + + if (matching_user is None) and (matching_seek is None): + # Maybe there is a matching normal seek + matching_seek = find_matching_seek(app_state, user, variant_tc) + auto_variant_tc = variant_tc + + user.ready_for_auto_pairing = True + + return auto_variant_tc, matching_user, matching_seek + + async def auto_pair(app_state, user, auto_variant_tc, other_user=None, matching_seek=None): """If matching_seek is not None accept it, else create a new one and accpt it by other_user""" - if matching_seek is None: - variant, chess960, base, inc, byoyomi_period = auto_variant_tc + variant, chess960, base, inc, byoyomi_period = auto_variant_tc + if matching_seek is None: seek_id = await new_id(None if app_state.db is None else app_state.db.seek) seek = Seek( seek_id, @@ -40,11 +90,23 @@ async def auto_pair(app_state, user, auto_variant_tc, other_user=None, matching_ for other_user_ws in other_user.lobby_sockets: await ws_send_json(other_user_ws, response) + tc = time_control_str(base, inc, byoyomi_period) + tail960 = "960" if chess960 else "" + msg = "**AUTO PAIR** %s - %s **%s%s** %s" % ( + user.username, + other_user.username, + variant, + tail960, + tc, + ) + await app_state.discord.send_to_discord("accept_seek", msg) + return True def find_matching_user(app_state, user, variant_tc): """Return first compatible user from app_state.auto_pairing_users if there is any, else None""" + variant, chess960, _, _, _ = variant_tc return next( ( @@ -65,6 +127,7 @@ def find_matching_user(app_state, user, variant_tc): def find_matching_seek(app_state, user, variant_tc): """Return first compatible seek from app_state.seeks if there is any, else None""" + variant, chess960, base, inc, byoyomi_period = variant_tc return next( ( diff --git a/server/pychess_global_app_state.py b/server/pychess_global_app_state.py index 14e564507..b79b0c651 100644 --- a/server/pychess_global_app_state.py +++ b/server/pychess_global_app_state.py @@ -174,11 +174,12 @@ async def init_from_db(self): await load_tournament(self, doc["_id"]) self.tournaments_loaded.set() - already_scheduled = await get_scheduled_tournaments(self) - new_tournaments_data = new_scheduled_tournaments(already_scheduled) - await create_scheduled_tournaments(self, new_tournaments_data) + if not isinstance(self.db_client, AsyncMongoMockClient): + already_scheduled = await get_scheduled_tournaments(self) + new_tournaments_data = new_scheduled_tournaments(already_scheduled) + await create_scheduled_tournaments(self, new_tournaments_data) - asyncio.create_task(generate_shield(self), name="generate-shield") + asyncio.create_task(generate_shield(self), name="generate-shield") if "highscore" not in db_collections: await generate_highscore(self) diff --git a/server/user.py b/server/user.py index daaf89dc3..cfc2a7ff9 100644 --- a/server/user.py +++ b/server/user.py @@ -373,40 +373,45 @@ def remove_ws_for_game(self, game_id, ws) -> bool: return False def auto_compatible_with_other_user(self, other_user, variant, chess960): - """Users are compatible when their auto pairing rating ranges are overlapped - and the users are not blocked by each other""" + """Users are compatible when their auto pairing rating ranges are ok + and the users are not blocked by any direction""" - rating = self.get_rating_value(variant, chess960) - rr = self.app_state.auto_pairing_users[self] - a = (rating + rr[0], rating + rr[1]) + self_rating = self.get_rating_value(variant, chess960) + self_rrmin, self_rrmax = self.app_state.auto_pairing_users[self] - rating = other_user.get_rating_value(variant, chess960) - rr = self.app_state.auto_pairing_users[other_user] - b = (rating + rr[0], rating + rr[1]) + other_rating = other_user.get_rating_value(variant, chess960) + other_rrmin, other_rrmax = self.app_state.auto_pairing_users[other_user] - return (other_user.username not in self.blocked) and ( - self.username not in other_user.blocked and max(a[0], b[0]) <= min(a[1], b[1]) + return ( + (other_user.username not in self.blocked) + and (self.username not in other_user.blocked) + and self_rating >= other_rating + other_rrmin + and self_rating <= other_rating + other_rrmax + and other_rating >= self_rating + self_rrmin + and other_rating <= self_rating + self_rrmax ) def auto_compatible_with_seek(self, seek): - """Seek is auto pairing compatible when the rating ranges are overlapped - and the users are not blocked by each other""" + """Seek is auto pairing compatible when the rating ranges are ok + and the users are not blocked by any direction""" - rating = self.get_rating_value(seek.variant, seek.chess960) - rr = self.app_state.auto_pairing_users[self] - a = (rating + rr[0], rating + rr[1]) + self_rating = self.get_rating_value(seek.variant, seek.chess960) + seek_user = self.app_state.users[seek.creator.username] - other_user = seek.creator - rating = other_user.get_rating_value(seek.variant, seek.chess960) - b = (rating + seek.rrmin, rating + seek.rrmax) + auto_rrmin, auto_rrmax = self.app_state.auto_pairing_users[self] - return (other_user.username not in self.blocked) and ( - self.username not in other_user.blocked and max(a[0], b[0]) <= min(a[1], b[1]) + return ( + (seek_user.username not in self.blocked) + and (self.username not in seek_user.blocked) + and self_rating >= seek.rating + seek.rrmin + and self_rating <= seek.rating + seek.rrmax + and seek.rating >= self_rating + auto_rrmin + and seek.rating <= self_rating + auto_rrmax ) def compatible_with_seek(self, seek): """Seek is compatible when my rating is inside the seek rating range - and the users are not blocked by each other""" + and the users are not blocked by any direction""" self_rating = self.get_rating_value(seek.variant, seek.chess960) seek_user = self.app_state.users[seek.creator.username] diff --git a/server/wsl.py b/server/wsl.py index 94aa96258..f3237c676 100644 --- a/server/wsl.py +++ b/server/wsl.py @@ -1,7 +1,6 @@ from __future__ import annotations import asyncio import logging -from itertools import product import aiohttp_session from aiohttp import web @@ -18,11 +17,11 @@ ) from auto_pair import ( auto_pair, - find_matching_seek, + add_to_auto_pairings, find_matching_user, ) from chat import chat_response -from const import ANON_PREFIX, BYOS, STARTED +from const import ANON_PREFIX, STARTED from misc import server_state from newid import new_id from const import TYPE_CHECKING @@ -372,42 +371,7 @@ async def handle_create_auto_pairing(app_state, ws, user, data): if no: return - auto_variant_tc = None - matching_user = None - matching_seek = None - - rrmin = data["rrmin"] - rrmax = data["rrmax"] - rrmin = rrmin if (rrmin != -1000) else -10000 - rrmax = rrmax if (rrmax != 1000) else 10000 - app_state.auto_pairing_users[user] = (rrmin, rrmax) - - for variant_tc in product(data["variants"], data["tcs"]): - variant_tc = ( - variant_tc[0][0], - variant_tc[0][1], - variant_tc[1][0], - variant_tc[1][1], - variant_tc[1][2], - ) - variant, chess960, base, inc, byoyomi_period = variant_tc - # We don't want to create non byo variant with byo TC combinations - if (byoyomi_period > 0 and variant not in BYOS) or variant.startswith("bughouse"): - continue - - if variant_tc not in app_state.auto_pairings: - app_state.auto_pairings[variant_tc] = set() - app_state.auto_pairings[variant_tc].add(user) - - if (matching_user is None) and (matching_seek is None): - # Try to find the same combo in auto_pairings - matching_user = find_matching_user(app_state, user, variant_tc) - auto_variant_tc = variant_tc - - if (matching_user is None) and (matching_seek is None): - # Maybe there is a matching normal seek - matching_seek = find_matching_seek(app_state, user, variant_tc) - auto_variant_tc = variant_tc + auto_variant_tc, matching_user, matching_seek = add_to_auto_pairings(app_state, user, data) auto_paired = False if (matching_user is not None) or (matching_seek is not None): @@ -417,7 +381,6 @@ async def handle_create_auto_pairing(app_state, ws, user, data): ) if not auto_paired: - user.ready_for_auto_pairing = True for user_ws in user.lobby_sockets: await ws_send_json(user_ws, {"type": "auto_pairing_on"}) diff --git a/tests/test_alice.py b/tests/test_alice.py index 35d5baf94..51fdfdf5b 100644 --- a/tests/test_alice.py +++ b/tests/test_alice.py @@ -196,7 +196,9 @@ def test_castling(self): board.legal_moves() board.push(move) - FEN_OOO = "1|q|k|r3r/1pp|bpp|b1/|P1|n|pP1|p1/2PP4/6n1/2|N1|B|N1p/1|p|Q2PPP/R3K|B1R w KQ - 1 14" + FEN_OOO = ( + "1|q|k|r3r/1pp|bpp|b1/|P1|n|pP1|p1/2PP4/6n1/2|N1|B|N1p/1|p|Q2PPP/R3K|B1R w KQ - 1 14" + ) self.assertEqual(board.fen, FEN_OOO) board.pop() @@ -207,7 +209,9 @@ def test_castling(self): board.legal_moves() board.push(move) - FEN_OO = "r|q3|r|k1/1pp|bpp|b1/|P1|n|pP1|p1/2PP4/6n1/2|N1|B|N1p/1|p|Q2PPP/R3K|B1R w KQ - 1 14" + FEN_OO = ( + "r|q3|r|k1/1pp|bpp|b1/|P1|n|pP1|p1/2PP4/6n1/2|N1|B|N1p/1|p|Q2PPP/R3K|B1R w KQ - 1 14" + ) self.assertEqual(board.fen, FEN_OO) board.pop() diff --git a/tests/test_auto_pairing.py b/tests/test_auto_pairing.py new file mode 100644 index 000000000..f7f23e863 --- /dev/null +++ b/tests/test_auto_pairing.py @@ -0,0 +1,227 @@ +# -*- coding: utf-8 -*- + +import unittest +from itertools import product + +from mongomock_motor import AsyncMongoMockClient +from aiohttp.test_utils import AioHTTPTestCase + +from server import make_app +from seek import Seek +from user import User +from pychess_global_app_state_utils import get_app_state +from const import VARIANTS +from glicko2.glicko2 import DEFAULT_PERF +from auto_pair import add_to_auto_pairings + + +PERFS = {variant: DEFAULT_PERF for variant in VARIANTS} +HIGH_PERFS = {variant: {"gl": {"r": 2300}} for variant in VARIANTS} +LOW_PERFS = {variant: {"gl": {"r": 700}} for variant in VARIANTS} + +ALL_TC = [ + (1, 0, 0), + (3, 0, 0), + (3, 2, 0), + (5, 3, 0), + (2, 15, 1), + (5, 15, 1), +] + +ALL_VARIANT = product(map(lambda x: x.removesuffix("960"), VARIANTS), (True, False)) + +DATA = { + "all": {"variants": ALL_VARIANT, "tcs": ALL_TC, "rrmin": -1000, "rrmax": 1000}, + "chess": {"variants": [("chess", False)], "tcs": ALL_TC, "rrmin": -1000, "rrmax": 1000}, + "chess960": {"variants": [("chess", True)], "tcs": ALL_TC, "rrmin": -1000, "rrmax": 1000}, + "chess-500+500": {"variants": [("chess", False)], "tcs": ALL_TC, "rrmin": -500, "rrmax": 500}, + "chess-800+800": {"variants": [("chess", False)], "tcs": ALL_TC, "rrmin": -800, "rrmax": 800}, +} + + +class AutoPairingTestCase(AioHTTPTestCase): + async def startup(self, app): + app_state = get_app_state(self.app) + # players with default ratings + self.aplayer = User(app_state, username="aplayer", perfs=PERFS) + self.bplayer = User(app_state, username="bplayer", perfs=PERFS) + self.cplayer = User(app_state, username="cplayer", perfs=PERFS) + self.dplayer = User(app_state, username="dplayer", perfs=PERFS) + self.eplayer = User(app_state, username="eplayer", perfs=PERFS) + + # low rated player + self.lplayer = User(get_app_state(self.app), username="lplayer", perfs=LOW_PERFS) + + # high rated player + self.hplayer = User(get_app_state(self.app), username="hplayer", perfs=HIGH_PERFS) + + app_state.users["aplayer"] = self.aplayer + app_state.users["bplayer"] = self.bplayer + app_state.users["cplayer"] = self.cplayer + app_state.users["dplayer"] = self.dplayer + app_state.users["eplayer"] = self.eplayer + app_state.users["lplayer"] = self.lplayer + app_state.users["hplayer"] = self.hplayer + + async def get_application(self): + app = make_app(db_client=AsyncMongoMockClient()) + app.on_startup.append(self.startup) + return app + + async def tearDownAsync(self): + await self.client.close() + + def test_add_to_auto_pairings(self): + app_state = get_app_state(self.app) + + variant_tc = ("chess", False, 5, 3, 0) + auto_variant_tc, matching_user, matching_seek = add_to_auto_pairings( + app_state, self.bplayer, DATA["chess"] + ) + + self.assertEqual(auto_variant_tc, variant_tc) + self.assertEqual(app_state.auto_pairing_users[self.bplayer], (-10000, 10000)) + self.assertIn(self.bplayer, app_state.auto_pairings[variant_tc]) + self.assertTrue(self.bplayer.ready_for_auto_pairing) + + variant_tc = ("chess", True, 5, 3, 0) + auto_variant_tc, matching_user, matching_seek = add_to_auto_pairings( + app_state, self.aplayer, DATA["chess960"] + ) + + self.assertEqual(auto_variant_tc, variant_tc) + self.assertIsNone(matching_user) + self.assertIsNone(matching_seek) + self.assertTrue(self.aplayer.ready_for_auto_pairing) + + def remove_from_auto_pairings(self): + app_state = get_app_state(self.app) + + variant_tc = ("chess", False, 5, 3, 0) + add_to_auto_pairings(app_state, self.bplayer, DATA["chess"]) + self.bplayer.remove_from_auto_pairings() + + self.assertNotIn(self.bplayer, app_state.auto_pairing_users) + self.assertNotIn(self.bplayer, app_state.auto_pairings[variant_tc]) + self.assretFalse(self.bplayer.ready_for_auto_pairing) + + def test_auto_compatible_with_seek0(self): + seek = Seek("id", self.aplayer, "chess") # accepts any ratings + + # auto_compatible_with_seek() checks rating ranges and blocked users only! + add_to_auto_pairings(get_app_state(self.app), self.bplayer, DATA["chess960"]) + result = self.bplayer.auto_compatible_with_seek(seek) + self.assertTrue(result) + + # auto_compatible_with_seek() checks rating ranges and blocked users only! + add_to_auto_pairings(get_app_state(self.app), self.cplayer, DATA["chess"]) + result = self.cplayer.auto_compatible_with_seek(seek) + self.assertTrue(result) + + # now auto pairing creator blocks the seek creator + self.dplayer.blocked.add("aplayer") + add_to_auto_pairings(get_app_state(self.app), self.dplayer, DATA["chess960"]) + result = self.dplayer.auto_compatible_with_seek(seek) + self.assertFalse(result) + + # now seek creator blocks auto pairing creator + self.aplayer.blocked.add("eplayer") + add_to_auto_pairings(get_app_state(self.app), self.eplayer, DATA["chess960"]) + result = self.eplayer.auto_compatible_with_seek(seek) + self.assertFalse(result) + + def test_auto_compatible_with_seek1(self): + # low rating player and auto pairing range is not OK + seek = Seek( + "id", self.lplayer, "chess", rrmin=-200, rrmax=0 + ) # low rated player, accepts 500-700 + add_to_auto_pairings( + get_app_state(self.app), self.aplayer, DATA["chess-500+500"] + ) # accepts 1000-2000 + result = self.aplayer.auto_compatible_with_seek(seek) + self.assertFalse(result) + + # low rating player and auto pairing range is OK + seek = Seek( + "id", self.lplayer, "chess", rrmin=-200, rrmax=900 + ) # low rated player, accepts 500-1500 + add_to_auto_pairings( + get_app_state(self.app), self.bplayer, DATA["chess-800+800"] + ) # accepts 700-2300 + result = self.bplayer.auto_compatible_with_seek(seek) + self.assertTrue(result) + + def test_auto_compatible_with_seek2(self): + # high rating player and auto pairing ranges don't overlap + seek = Seek( + "id", self.hplayer, "chess", rrmin=-200, rrmax=200 + ) # high rated player, accepts 2100-2500 + add_to_auto_pairings( + get_app_state(self.app), self.aplayer, DATA["chess-500+500"] + ) # accepts 1000-2000 + result = self.aplayer.auto_compatible_with_seek(seek) + self.assertFalse(result) + + # high rating player and auto pairing ranges are overlapping + seek = Seek( + "id", self.hplayer, "chess", rrmin=-900, rrmax=200 + ) # high rated player, accepts 1400-2500 + add_to_auto_pairings( + get_app_state(self.app), self.bplayer, DATA["chess-800+800"] + ) # accepts 700-2300 + result = self.bplayer.auto_compatible_with_seek(seek) + self.assertTrue(result) + + def test_auto_compatible_with_other_user0(self): + add_to_auto_pairings(get_app_state(self.app), self.aplayer, DATA["chess"]) + add_to_auto_pairings(get_app_state(self.app), self.bplayer, DATA["chess960"]) + + # auto_compatible_with_other_user() checks rating ranges and blocked users only! + result = self.aplayer.auto_compatible_with_other_user(self.bplayer, "chess", False) + self.assertTrue(result) + + # auto_compatible_with_other_user() checks rating ranges and blocked users only! + result = self.aplayer.auto_compatible_with_other_user(self.bplayer, "chess", True) + self.assertTrue(result) + + def test_auto_compatible_with_other_user1(self): + add_to_auto_pairings( + get_app_state(self.app), self.lplayer, DATA["chess"] + ) # accepts any range + add_to_auto_pairings( + get_app_state(self.app), self.aplayer, DATA["chess-500+500"] + ) # accepts 1000-2000 + add_to_auto_pairings( + get_app_state(self.app), self.bplayer, DATA["chess-800+800"] + ) # accepts 700-2300 + + # low rating player and default player + result = self.lplayer.auto_compatible_with_other_user(self.aplayer, "chess", False) + self.assertFalse(result) + + # low rating player and default player + result = self.lplayer.auto_compatible_with_other_user(self.bplayer, "chess", False) + self.assertTrue(result) + + def test_auto_compatible_with_other_user2(self): + add_to_auto_pairings( + get_app_state(self.app), self.hplayer, DATA["chess"] + ) # accepts any range + add_to_auto_pairings( + get_app_state(self.app), self.aplayer, DATA["chess-500+500"] + ) # accepts 1000-2000 + add_to_auto_pairings( + get_app_state(self.app), self.bplayer, DATA["chess-800+800"] + ) # accepts 700-2300 + + # high rating player and default player + result = self.hplayer.auto_compatible_with_other_user(self.aplayer, "chess", False) + self.assertFalse(result) + + # high rating player and default player + result = self.hplayer.auto_compatible_with_other_user(self.bplayer, "chess", False) + self.assertTrue(result) + + +if __name__ == "__main__": + unittest.main(verbosity=2)