diff --git a/.github/ISSUE_TEMPLATE/bug-report.md b/.github/ISSUE_TEMPLATE/bug-report.md new file mode 100644 index 0000000..87a6b09 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug-report.md @@ -0,0 +1,32 @@ +--- +name: Bug Report +about: Create a report to help us improve +title: "[BUG] " +labels: bug +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Platform:** + - OS: [e.g. iOS] + - Browser [e.g. chrome, safari] + - Version [e.g. 22] + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/suggestion.md b/.github/ISSUE_TEMPLATE/suggestion.md new file mode 100644 index 0000000..e67787c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/suggestion.md @@ -0,0 +1,20 @@ +--- +name: Suggestion +about: Suggest an idea for this project +title: "[SUGGESTION] " +labels: enhancement +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/spirit/tests/test_account_settings.py b/spirit/tests/test_account_settings.py deleted file mode 100644 index ceeff44..0000000 --- a/spirit/tests/test_account_settings.py +++ /dev/null @@ -1,120 +0,0 @@ -import os -import tempfile -import unittest -from unittest.mock import patch - -from spirit.packets.handlers.data_sync import DataSyncHandler -from spirit.network.message_names import OutboundMsg -from spirit.game.attributes import AttrID - - -class MockClientHandler: - def __init__(self, account_id="settings_user", username="settings_user"): - self.addr = ("127.0.0.1", 4444) - self.sent_packets = [] - self.player = type("P", (), {"account_id": account_id, "username": username})() - - async def send_packet(self, data, request_id=0, flags=0): - self.sent_packets.append(data) - - -class TestAccountSettings(unittest.IsolatedAsyncioTestCase): - @classmethod - def setUpClass(cls): - cls.db_fd, cls.db_path = tempfile.mkstemp() - cls.db_patcher = patch('spirit.database.connection.DB_PATH', cls.db_path) - cls.db_patcher.start() - from spirit.database.setup_db import setup_database - setup_database() - - @classmethod - def tearDownClass(cls): - cls.db_patcher.stop() - try: - os.close(cls.db_fd) - os.unlink(cls.db_path) - except OSError: - pass - - def _make_account(self, name): - from spirit.database.accounts import create_account - return create_account(name, "pw")["account_id"] - - def test_fresh_account_has_empty_settings_attr(self): - from spirit.game.account_attributes import build_account_attributes - aid = self._make_account("fresh_settings") - attrs = {a["name"]: a["value"] for a in build_account_attributes(aid)} - self.assertIn(AttrID.ACCOUNT_SETTINGS.value, attrs) - self.assertEqual(attrs[AttrID.ACCOUNT_SETTINGS.value], {}) - - def test_merge_persists_and_is_partial(self): - from spirit.database.player_data import merge_account_settings, get_account_settings - aid = self._make_account("merge_settings") - merge_account_settings(aid, {109: 15, 116: 1}) - merge_account_settings(aid, {110: -1}) # partial write must not wipe others - settings = get_account_settings(aid) - self.assertEqual(settings["109"], 15) - self.assertEqual(settings["116"], 1) - self.assertEqual(settings["110"], -1) - - def test_login_anchor_suppresses_replay_but_keeps_ingame_animation(self): - from spirit.database import versus_data - from spirit.game.season_manager import VersusSeasonManager - from spirit.game.account_attributes import ( - build_account_attributes, anchor_versus_animation, - VERSUS_LAST_SEEN_POINTS_SETTING as S109) - from spirit.game.attributes import AttrID - - aid = self._make_account("anchor_case") - season = VersusSeasonManager().get_active_season() - sid = season.season_id if season else "" - for _ in range(2): - versus_data.award_match_points(aid, True, season=season) # prior-session points - points = versus_data.get_progress(aid, sid)[0] - self.assertGreater(points, 0) - - def s109(attrs): - d = {a["name"]: a["value"] for a in attrs} - return d[AttrID.ACCOUNT_SETTINGS.value].get(str(S109)), d[AttrID.SEASON_POINTS.value] - - # Login anchors 109 to current points -> nothing to animate on relog - anchor_versus_animation(aid) - anchored, pts_attr = s109(build_account_attributes(aid)) - self.assertEqual(anchored, points) - self.assertEqual(pts_attr, points) - - # In-session win: 109 stays stale (no re-anchor) so the gain still animates - versus_data.award_match_points(aid, True, season=season) - new_points = versus_data.get_progress(aid, sid)[0] - stale, pts_attr2 = s109(build_account_attributes(aid)) - self.assertEqual(stale, points) # last-seen still old - self.assertEqual(pts_attr2, new_points) # but season points advanced -> animates - - # Relog re-anchors -> no replay of the now-seen points - anchor_versus_animation(aid) - reanchored, _ = s109(build_account_attributes(aid)) - self.assertEqual(reanchored, new_points) - - async def test_handler_persists_and_echoes_account_updated(self): - aid = self._make_account("handler_settings") - client = MockClientHandler(account_id=aid, username="handler_settings") - handler = DataSyncHandler(client) - - # setting 109 = LastKnownSeasonPointTotal (versus ladder animation anchor) - await handler.handle_set_account_settings({"settings": {"109": 42}}, 0, 0) - - # Echoes AccountUpdated so the client's SaveAccountSettings coroutine unblocks - self.assertEqual(len(client.sent_packets), 1) - packet = client.sent_packets[0] - self.assertEqual(packet["messageName"], OutboundMsg.ACCOUNT_UPDATED.value) - attrs = {a["name"]: a["value"] for a in packet["account"]["attributes"]} - self.assertEqual(attrs[AttrID.ACCOUNT_SETTINGS.value]["109"], 42) - - # Value survives a subsequent login attribute build (persisted) - from spirit.game.account_attributes import build_account_attributes - relogin = {a["name"]: a["value"] for a in build_account_attributes(aid)} - self.assertEqual(relogin[AttrID.ACCOUNT_SETTINGS.value]["109"], 42) - - -if __name__ == "__main__": - unittest.main() diff --git a/spirit/tests/test_admin_api.py b/spirit/tests/test_admin_api.py deleted file mode 100644 index 2ab6106..0000000 --- a/spirit/tests/test_admin_api.py +++ /dev/null @@ -1,363 +0,0 @@ -import json -import unittest -import os -import tempfile -from unittest.mock import patch - -from spirit.database import db_manager - - -ADMIN_COOKIE = "" - - -def call(method, path, body=None, cookie=None): - from spirit.server.admin_api import route_admin - headers = {"Cookie": ADMIN_COOKIE if cookie is None else cookie} - result = route_admin(method, path, - json.dumps(body) if body is not None else None, - headers=headers) - status, payload, ctype = result[:3] - if ctype.startswith('application/json'): - return status, json.loads(payload) - return status, payload - - -def login(username, password): - """Logs into the admin API; returns (status, data, cookie).""" - from spirit.server.admin_api import route_admin - result = route_admin('POST', '/admin/api/login', - json.dumps({"username": username, "password": password}), - headers={}) - status, payload = result[0], json.loads(result[1]) - cookie = "" - if len(result) == 4: - for name, value in result[3]: - if name == 'Set-Cookie': - cookie = value.split(';')[0] - return status, payload, cookie - - -class TestAdminAPI(unittest.TestCase): - @classmethod - def setUpClass(cls): - cls.db_fd, cls.db_path = tempfile.mkstemp() - cls.db_patcher = patch('spirit.database.connection.DB_PATH', cls.db_path) - cls.db_patcher.start() - - from spirit.database.setup_db import setup_database - setup_database() - - global ADMIN_COOKIE - status, data, ADMIN_COOKIE = login("brandon", "password") - assert status == 200 and data["ok"], "seeded admin login failed" - - @classmethod - def tearDownClass(cls): - cls.db_patcher.stop() - try: - os.close(cls.db_fd) - os.unlink(cls.db_path) - except OSError: - pass - - def test_dashboard_served(self): - status, payload = call('GET', '/admin') - self.assertEqual(status, 200) - self.assertIn(b'SpiritPTCGO', payload) - - def test_overview(self): - status, data = call('GET', '/admin/api/overview') - self.assertEqual(status, 200) - self.assertTrue(data["ok"]) - self.assertIn("SWSH12", data["eligible_sets"]) - self.assertNotIn("BW1", data["eligible_sets"]) - - def test_code_lifecycle(self): - # Generate - status, data = call('POST', '/admin/api/codes', { - "reward": {"coins": 500, "products": {}}, "max_uses": 5 - }) - self.assertEqual(status, 200) - code = data["codes"][0]["code_string"] - self.assertTrue(code.startswith("SPIRIT-")) - - # List - status, data = call('GET', '/admin/api/codes') - self.assertIn(code, [c["code_string"] for c in data["codes"]]) - - # Update / disable - status, data = call('POST', '/admin/api/codes/update', {"code": code, "enabled": False}) - self.assertEqual(status, 200) - self.assertFalse(data["code"]["enabled"]) - - # Duplicate rejected - status, _ = call('POST', '/admin/api/codes', {"code": code, "reward": {}}) - self.assertEqual(status, 409) - - # Delete - status, data = call('POST', '/admin/api/codes/delete', {"code": code}) - self.assertEqual(status, 200) - - def test_shop_item_lifecycle(self): - status, data = call('POST', '/admin/api/shop', { - "product_guid": "d017c195-83c5-c74e-0638-25128b3116c4", - "display_name": "Fusion Strike Pack", - "price": 150, "featured": True - }) - self.assertEqual(status, 200) - item_id = data["item"]["id"] - self.assertTrue(data["item"]["enabled"]) - self.assertTrue(data["item"]["featured"]) - - status, data = call('POST', '/admin/api/shop/toggle', {"id": item_id}) - self.assertFalse(data["item"]["enabled"]) - - status, data = call('GET', '/admin/api/shop') - self.assertIn(item_id, [i["id"] for i in data["items"]]) - - status, _ = call('POST', '/admin/api/shop/delete', {"id": item_id}) - self.assertEqual(status, 200) - - def test_dynamic_page_lifecycle(self): - content = {"template": "LandingPageRight", - "labels": {"GameText": {"bundle": {"en_US": "Hello"}}}} - status, data = call('POST', '/admin/api/pages', { - "content_json": content, "sort_order": 2 - }) - self.assertEqual(status, 200) - page_id = data["page"]["id"] - self.assertEqual(data["page"]["content_json"]["template"], "LandingPageRight") - - status, data = call('GET', '/admin/api/pages') - self.assertIn(page_id, [p["id"] for p in data["pages"]]) - - status, _ = call('POST', '/admin/api/pages/delete', {"id": page_id}) - self.assertEqual(status, 200) - - def test_products_listing(self): - status, data = call('GET', '/admin/api/products') - self.assertEqual(status, 200) - keys = {p["key"] for p in data["products"]} - self.assertIn("SWSH12", keys) # auto-generated booster present - - def test_grant_all_cards(self): - from spirit.database.accounts import create_account - from spirit.database.player_data import get_collection_by_account_id - from spirit.game.scripts.cards import loader as card_loader - - account = create_account("grant_all_tester", "pw123") - account_id = account["account_id"] - - status, data = call('POST', '/admin/api/accounts/grant-all-cards', - {"account_id": account_id, "count": 4}) - self.assertEqual(status, 200) - expected = sum(1 for c in card_loader.load_all() if c.key != "Free_Energy") - self.assertEqual(data["granted"], expected) - - collection = {c["archetype_id"]: c for c in get_collection_by_account_id(account_id)} - for card in card_loader.load_all(): - if card.key == "Free_Energy": - continue - self.assertGreaterEqual(collection[card.guid]["tradable_count"], 4) - - def test_grant_all_cards_bad_account(self): - status, _ = call('POST', '/admin/api/accounts/grant-all-cards', - {"account_id": "does-not-exist"}) - self.assertEqual(status, 404) - - def test_unknown_route(self): - status, data = call('GET', '/admin/api/nonsense') - self.assertEqual(status, 404) - - # ---------------- auth ---------------- - - def test_api_requires_auth(self): - status, data = call('GET', '/admin/api/overview', cookie="") - self.assertEqual(status, 401) - status, data = call('POST', '/admin/api/codes', {"reward": {}}, cookie="spirit_admin=bogus") - self.assertEqual(status, 401) - - def test_dashboard_html_is_public(self): - status, payload = call('GET', '/admin', cookie="") - self.assertEqual(status, 200) - - def test_login_rejects_bad_credentials_and_non_admin(self): - status, data, _ = login("brandon", "wrongpassword") - self.assertEqual(status, 401) - # a non-admin account can't log in once an admin exists - from spirit.database.accounts import create_account - create_account("plain_user", "pw123") - status, data, _ = login("plain_user", "pw123") - self.assertEqual(status, 401) - - def test_session_endpoint(self): - status, data = call('GET', '/admin/api/session') - self.assertTrue(data["authenticated"]) - self.assertEqual(data["username"], "brandon") - status, data = call('GET', '/admin/api/session', cookie="") - self.assertFalse(data["authenticated"]) - - def test_set_admin(self): - from spirit.database.accounts import create_account, get_account_by_username - acc = create_account("promo_user", "pw123") or get_account_by_username("promo_user") - status, data = call('POST', '/admin/api/accounts/set-admin', - {"account_id": acc["account_id"], "is_admin": True}) - self.assertEqual(status, 200) - status, data, cookie = login("promo_user", "pw123") - self.assertEqual(status, 200) - self.assertTrue(cookie) - call('POST', '/admin/api/accounts/set-admin', - {"account_id": acc["account_id"], "is_admin": False}) - - # ---------------- tournaments ---------------- - - def _tournament_definition(self): - return { - "name": "AdminTest Cup", "title": "AdminTest Cup", "description": "test", - "previewTime": 1, "startTime": 2, "entryClosingTime": 4102444800000, - "resolutionTime": 4102444800001, "disappearTime": 4102444800002, - "maxRuns": 3, "prizeBy": "wins", - "run": { - "entryFee": [{"currency": "Tokens", "amount": 10}], - "allowDeckSwitching": True, - "maxWins": 3, "maxLosses": 2, "maxGames": 0, - "prizeTable": [ - {"start": 0, "end": 2, "rewards": [ - {"rewardType": "Tokens", "rewardAmount": 50}]}, - {"start": 3, "end": 3, "rewards": [ - {"rewardType": "Tokens", "rewardAmount": 500}]}, - ], - }, - "leaderboard": {"runs": 0, "winValue": 3, "lossValue": 1, "prizeTable": []}, - } - - def test_tournament_lifecycle(self): - status, data = call('POST', '/admin/api/tournaments', - {"definition": self._tournament_definition()}) - self.assertEqual(status, 200) - tid = data["tournament"]["tournament_id"] - - from spirit.game.tournament_manager import TournamentManager - self.assertIsNotNone(TournamentManager().get(tid)) - - status, data = call('GET', '/admin/api/tournaments') - self.assertIn(tid, [t["tournament_id"] for t in data["tournaments"]]) - - status, data = call('POST', '/admin/api/tournaments/toggle', {"tournament_id": tid}) - self.assertFalse(data["tournament"]["enabled"]) - - status, data = call('GET', '/admin/api/tournaments/standings/' + tid) - self.assertEqual(status, 200) - self.assertEqual(data["standings"], []) - - status, _ = call('POST', '/admin/api/tournaments/delete', {"tournament_id": tid}) - self.assertEqual(status, 200) - self.assertIsNone(TournamentManager().get(tid)) - - def test_tournament_validation(self): - bad = self._tournament_definition() - bad["run"]["maxWins"] = 0 - bad["run"]["maxLosses"] = 0 - status, _ = call('POST', '/admin/api/tournaments', {"definition": bad}) - self.assertEqual(status, 400) - - bad = self._tournament_definition() - bad["run"]["prizeTable"][0]["rewards"] = [{"rewardType": "Archetype"}] - status, _ = call('POST', '/admin/api/tournaments', {"definition": bad}) - self.assertEqual(status, 400) - - status, _ = call('POST', '/admin/api/tournaments', {"definition": {"name": ""}}) - self.assertEqual(status, 400) - - def test_card_search_and_image(self): - status, data = call('POST', '/admin/api/cards/search', {"query": "lugia"}) - self.assertEqual(status, 200) - self.assertTrue(data["cards"]) - self.assertIn("image", data["cards"][0]) - with_image = next((c for c in data["cards"] if c["image"]), None) - self.assertIsNotNone(with_image) - status, payload = call('GET', with_image["image"]) - self.assertEqual(status, 200) - self.assertTrue(payload.startswith(b'\x89PNG')) - - def test_card_image_rejects_traversal(self): - status, _ = call('GET', '/admin/api/card-image/../secret/1') - self.assertEqual(status, 400) - status, _ = call('GET', '/admin/api/card-image/NOSET/999') - self.assertEqual(status, 404) - - def test_cards_lookup(self): - status, data = call('POST', '/admin/api/cards/search', {"query": "lugia"}) - guid = data["cards"][0]["guid"] - status, data = call('POST', '/admin/api/cards/lookup', {"guids": [guid.upper()]}) - self.assertEqual(status, 200) - self.assertIn(guid.lower(), data["cards"]) - self.assertTrue(data["cards"][guid.lower()]["name"]) - - def test_versus_seasons_roundtrip(self): - from spirit.game import season_manager - fd, path = tempfile.mkstemp(suffix='.json') - os.close(fd) - try: - with open(path, 'w') as f: - f.write('[]') - with patch('spirit.game.season_manager.SEASONS_PATH', path): - payload = {"seasons": [{ - "seasonID": "TestSeason", "startTime": 0, "endTime": 4102444800000, - "description": {"id": "Test Season"}, - "tiers": [{"rewards": {"10": [ - {"name": "5 Tokens", "rewardType": "Tokens", - "rewardAmount": 5, "rewardCurrency": "prizeTrainerCoin"}, - {"name": "Reward Card", "rewardType": "Archetype", - "rewardAmount": 2, "rewardProductID": "abc-123", - "rewardDescription": {"id": "Reward Card"}} - ]}}], - "resetRewardID": "" - }]} - status, data = call('POST', '/admin/api/versus-seasons', payload) - self.assertEqual(status, 200) - - status, data = call('GET', '/admin/api/versus-seasons') - self.assertEqual(status, 200) - self.assertEqual(data["seasons"][0]["seasonID"], "TestSeason") - # normalized wire order: archetypes first, tokens last, indexed - rewards = data["seasons"][0]["tiers"][0]["rewards"]["10"] - self.assertEqual(rewards[0]["rewardType"], "Archetype") - self.assertEqual(rewards[0]["rewardProductID"], "abc-123") - self.assertEqual(rewards[0]["index"], 0) - self.assertEqual(rewards[1]["rewardType"], "Tokens") - self.assertIsNone(rewards[1]["rewardProductID"]) - self.assertEqual(rewards[1]["index"], 1) - - # hot reload picked up the new config - mgr = season_manager.VersusSeasonManager() - self.assertEqual(mgr.seasons[0].season_id, "TestSeason") - finally: - os.unlink(path) - season_manager.VersusSeasonManager().load_seasons() - - def test_versus_seasons_validation(self): - status, _ = call('POST', '/admin/api/versus-seasons', {"seasons": []}) - self.assertEqual(status, 400) - status, _ = call('POST', '/admin/api/versus-seasons', - {"seasons": [{"seasonID": ""}]}) - self.assertEqual(status, 400) - status, _ = call('POST', '/admin/api/versus-seasons', {"seasons": [{ - "seasonID": "S", "tiers": [{"rewards": {"10": [ - {"name": "Card", "rewardType": "Archetype", "rewardAmount": 1} - ]}}] - }]}) - self.assertEqual(status, 400) # Archetype reward missing rewardProductID - - # client crash guard: multi-reward milestone with no card/product - status, _ = call('POST', '/admin/api/versus-seasons', {"seasons": [{ - "seasonID": "S", "tiers": [{"rewards": {"10": [ - {"name": "5 Tokens", "rewardType": "Tokens", "rewardAmount": 5}, - {"name": "10 Tokens", "rewardType": "Tokens", "rewardAmount": 10} - ]}}] - }]}) - self.assertEqual(status, 400) - - -if __name__ == '__main__': - unittest.main() diff --git a/spirit/tests/test_auth.py b/spirit/tests/test_auth.py deleted file mode 100644 index 90ed3d6..0000000 --- a/spirit/tests/test_auth.py +++ /dev/null @@ -1,109 +0,0 @@ -import unittest -from unittest.mock import MagicMock, AsyncMock, patch -from spirit.packets.handlers.auth import AuthHandler -from spirit.network.message_names import OutboundMsg - - -class MockServer: - def __init__(self): - self.clients = [] - - -class MockClientHandler: - def __init__(self, addr=("127.0.0.1", 54321), server=None): - self.addr = addr - self.server = server or MockServer() - self.sent_packets = [] - self.session_id = "test-session-id" - self.player = None - - async def send_packet(self, data, request_id=0, flags=0): - self.sent_packets.append(data) - - -class TestAuthConcurrentLogin(unittest.IsolatedAsyncioTestCase): - @patch("spirit.packets.handlers.auth.process_daily_login") - @patch("spirit.packets.handlers.auth.Player") - @patch("spirit.packets.handlers.auth.SocialHandler") - async def test_auth_success_when_no_concurrent_sessions(self, mock_social_handler_class, mock_player_class, mock_process_daily_login): - mock_process_daily_login.return_value = { - "firstDailyLogin": True, - "activations": 1, - "rewardDay": 1, - "nextRewardTimestampMs": 4102444800000, - "granted": [] - } - # Mocking SocialHandler instance and its broadcast_presence method - mock_social_instance = MagicMock() - mock_social_instance.broadcast_presence = AsyncMock() - mock_social_handler_class.return_value = mock_social_instance - - # Mock player - mock_player_instance = MagicMock() - mock_player_class.return_value = mock_player_instance - - server = MockServer() - client = MockClientHandler(addr=("127.0.0.1", 1111), server=server) - server.clients.append(client) - - handler = AuthHandler(client) - - account_data = { - "account_id": "user123", - "username": "testuser", - "screen_name": "TestUser" - } - - await handler._send_auth_success(request_id=123, account_data=account_data) - - # Assert player is set - self.assertIsNotNone(client.player) - mock_player_class.assert_called_once_with(account_data) - - # Assert success packet was sent (at least the AUTH_SUCCESS packet) - sent_messages = [p["messageName"] for p in client.sent_packets] - self.assertIn(OutboundMsg.AUTH_SUCCESS.value, sent_messages) - - @patch("spirit.packets.handlers.auth.Player") - @patch("spirit.packets.handlers.auth.SocialHandler") - async def test_auth_failed_when_concurrent_session_exists(self, mock_social_handler_class, mock_player_class): - # Setup Server and two clients - server = MockServer() - - # Existing client (already logged in) - existing_client = MockClientHandler(addr=("127.0.0.1", 2222), server=server) - existing_player = MagicMock() - existing_player.account_id = "user123" - existing_client.player = existing_player - server.clients.append(existing_client) - - # New client trying to log in - new_client = MockClientHandler(addr=("127.0.0.1", 3333), server=server) - server.clients.append(new_client) - - handler = AuthHandler(new_client) - - account_data = { - "account_id": "user123", - "username": "testuser", - "screen_name": "TestUser" - } - - await handler._send_auth_success(request_id=456, account_data=account_data) - - # Assert new player is NOT set (login blocked) - self.assertIsNone(new_client.player) - - # Assert AUTH_FAILED packet is sent with the expected message - self.assertEqual(len(new_client.sent_packets), 1) - response = new_client.sent_packets[0] - self.assertEqual(response["messageName"], OutboundMsg.AUTH_FAILED.value) - self.assertEqual(response["reason"]["token"], "The account you are trying to login is already online.") - - # Ensure player and social methods were NOT called for the new client - mock_player_class.assert_not_called() - mock_social_handler_class.assert_not_called() - - -if __name__ == "__main__": - unittest.main() diff --git a/spirit/tests/test_avatar_defaults.py b/spirit/tests/test_avatar_defaults.py deleted file mode 100644 index 74f38e4..0000000 --- a/spirit/tests/test_avatar_defaults.py +++ /dev/null @@ -1,106 +0,0 @@ -import json -import unittest - -from spirit.packets.handlers.data_sync import DataSyncHandler -from spirit.game.attributes import AttrID - -# The exact set of "empty" sentinel names the PTCGO client accepts. -# Recovered by decoding the obfuscated string blob in pie-src.dll -# (AvatarItemRendererBase.empty). Only when an avatar item's NAME is one of -# these does AvatarItemRenderer.UpdateAvatarItem SKIP the texture download and -# mark the slot ready. Any other name triggers a download; if the texture is -# absent from the avatar bundle the slot never becomes ready, stalling -# FlatAvatarRenderer on the VersusScreen and producing the -# "Releasing render texture that is set as Camera.targetTexture!" crash log. -CLIENT_EMPTY_SENTINELS = { - "EmptyAvatarItem", - "MNoJacket", - "MNoGlasses", - "FNoGlasses", - "FBarefoot", -} - - -class MockClientHandler: - def __init__(self): - self.addr = ("127.0.0.1", 54321) - self.sent_packets = [] - - async def send_packet(self, data, request_id=0, flags=0): - self.sent_packets.append(data) - - -def _attr_map(archetype): - """Flatten a protobuf archetype's attributes into {attr_id: python_value}.""" - out = {} - for attr in archetype.attributes: - v = attr.value - # objectType: 0=INT, 1=FLOAT?, ... we just read whichever field is set. - if v.stringValue: - out[attr.name] = v.stringValue - elif v.boolValue: - out[attr.name] = v.boolValue - else: - out[attr.name] = v.intValue - return out - - -class TestAvatarDefaults(unittest.IsolatedAsyncioTestCase): - async def test_default_avatar_items_use_empty_sentinel(self): - """ - The 16 default (AVATAR_IS_DEFAULT) avatar archetypes are what the client's - DefaultItemForGroup resolves an uncustomized avatar to. For every group - that has no real texture in the avatar bundle, the default MUST carry an - "empty" sentinel NAME, otherwise the client tries to download a - non-existent texture, the avatar renderer hangs, and the VersusScreen - teardown logs the render-texture crash on both matched clients. - """ - client = MockClientHandler() - handler = DataSyncHandler(client) - - await handler.handle_get_protobuf_all_avatar_archetypes_list( - msg={}, rid=7, flags=0 - ) - - self.assertEqual(len(client.sent_packets), 1) - proto = client.sent_packets[0] - - name_id = int(AttrID.NAME) - group_id = int(AttrID.AVATAR_GROUP) - is_default_id = int(AttrID.AVATAR_IS_DEFAULT) - - defaults_by_group = {} - for arch in proto.archetypes: - attrs = _attr_map(arch) - if attrs.get(is_default_id) is True: - grp = attrs.get(group_id) - raw_name = attrs.get(name_id) - # NAME is JSON-wrapped: {"id": ""} - base_name = json.loads(raw_name)["id"] - defaults_by_group[grp] = base_name - - # One default per customization group (0..15). - self.assertEqual(set(defaults_by_group.keys()), set(range(16))) - - for grp, base_name in defaults_by_group.items(): - if grp == 15: - # Skin is a colour slot: the client parses the trailing 6 hex - # chars of the name for the default skin tone. - tail = base_name[-6:] - int(tail, 16) # raises if not valid hex -> fails the test - else: - self.assertIn( - base_name, - CLIENT_EMPTY_SENTINELS, - f"group {grp} default '{base_name}' is not an empty sentinel; " - f"the client will try to download a missing texture and hang", - ) - # Regression guard against the old hanging value. - self.assertFalse( - base_name.startswith("dummy_avatar_item_"), - f"group {grp} still uses the hanging placeholder name", - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/spirit/tests/test_bench_slots.py b/spirit/tests/test_bench_slots.py deleted file mode 100644 index e8c66a4..0000000 --- a/spirit/tests/test_bench_slots.py +++ /dev/null @@ -1,182 +0,0 @@ -"""Bench slot bookkeeping: the client stamps every card with the -positionInParent of its last EntityMoved and never renumbers the bench, -so vacated slots (KO promotion, retreat) must be refilled explicitly.""" - -import unittest -import uuid - -from spirit.game.attributes import AttrID, PokemonTypes -from spirit.game.data_utils import EnergyCardDef, PokemonCardDef -from spirit.game.models.board import create_card_entity -from spirit.game.models.card import Card -from spirit.game.session.effects import EffectContext - -from spirit.game.session.game_session import GameSession - -P1 = "player-1" -P2 = "player-2" - - -def make_card(card_def) -> Card: - d = card_def.to_archetype_dict() - return Card(d["guid"], d["key"], d["attributes"]) - - -POKEMON = make_card(PokemonCardDef( - guid="00000000-0000-0000-0000-0000000000c1", - key="BW1", name="com.test.pokemon.Lillipup.Name", - collector_number=3, set_code="BW1", rarity=1, - hp=60, elements=[PokemonTypes.COLORLESS], retreat_cost=1, -)) - -ENERGY = make_card(EnergyCardDef( - guid="00000000-0000-0000-0000-0000000000e2", - key="BW1", name="com.test.energy.Colorless.Name", - collector_number=101, set_code="BW1", rarity=0, - energy_type=PokemonTypes.COLORLESS, -)) - - -class MockClientHandler: - def __init__(self, account_id, username): - self.player = type("P", (), { - "account_id": account_id, "username": username, - "screen_name": username, "avatar_decks": [], - })() - self.addr = ("127.0.0.1", 12345) - self.sent_packets = [] - - async def send_packet(self, data, request_id=0, flags=0): - self.sent_packets.append(data) - - -class BenchSlotTestBase(unittest.IsolatedAsyncioTestCase): - def setUp(self): - self.client1 = MockClientHandler(P1, "Ash") - self.client2 = MockClientHandler(P2, "Gary") - pairing = { - "is_solo": False, - "format": "Standard", - "players": { - P1: {"client": self.client1, "deck": {"deckID": "d1", "deckName": "A", "cards": []}, "ready": True}, - P2: {"client": self.client2, "deck": {"deckID": "d2", "deckName": "B", "cards": []}, "ready": True}, - }, - } - self.session = GameSession(str(uuid.uuid4()), pairing) - self.board = self.session.board_state - self.session.turn_state.begin_turn(P1) - - def add_to(self, area_name, card=POKEMON, player_id=P1, slot=None): - entity = create_card_entity(card, owning_player_id=player_id) - self.board.add_card_to_area(entity, self.board.find_player_area(player_id, area_name)) - if slot is not None: - entity.board_slot = slot - return entity - - def moved_msgs(self, client): - return [ - p["msg"] for p in client.sent_packets - if isinstance(p, dict) and isinstance(p.get("msg"), dict) - and p["msg"].get("name") == "EntityMoved" - ] - - -class TestFreeBenchSlot(BenchSlotTestBase): - def test_fills_the_lowest_vacated_slot(self): - # Lugia (slot 0) was promoted away; Lumineon keeps its slot-1 stamp. - self.add_to("bench", slot=1) - self.assertEqual(self.board.free_bench_slot(P1), 0) - - def test_appends_after_the_highest_occupied_slot(self): - self.add_to("bench", slot=0) - self.add_to("bench", slot=1) - self.assertEqual(self.board.free_bench_slot(P1), 2) - - def test_unstamped_children_fall_back_to_list_length(self): - self.add_to("bench") - self.assertEqual(self.board.free_bench_slot(P1), 1) - - -class TestBenchEntryFillsGaps(BenchSlotTestBase): - async def test_play_basic_takes_the_vacated_slot(self): - self.add_to("bench", slot=1) - card = self.add_to("hand") - await self.session._execute_play_basic(P1, card) - - move = self.moved_msgs(self.client1)[-1] - self.assertEqual(move["value"]["positionInParent"], 0) - self.assertEqual(card.board_slot, 0) - - async def test_effect_bench_fills_gap_then_appends(self): - # The Summoning Star scenario: bench = Lumineon stamped slot 1. - lumineon = self.add_to("bench", slot=1) - archeops1 = self.add_to("discard") - archeops2 = self.add_to("discard") - ctx = EffectContext(self.session, P1, lumineon, None) - - self.assertTrue(await ctx.bench_pokemon(archeops1)) - self.assertTrue(await ctx.bench_pokemon(archeops2)) - - self.assertEqual(archeops1.board_slot, 0) - self.assertEqual(archeops2.board_slot, 2) - positions = [ - msg["value"]["positionInParent"] - for _, msg, _ in ctx._messages if msg.get("name") == "EntityMoved" - ] - self.assertEqual(positions, [0, 2]) - - -class TestSwapsUseStampedSlot(BenchSlotTestBase): - async def test_retreat_old_active_takes_the_stamped_slot(self): - active = self.add_to("activePokemonArea") - energy = self.add_to("discard", ENERGY) - self.board.attach_card(energy.entity_id, active.entity_id) - # Lone benched Pokemon rendered at slot 2 (earlier neighbors left). - benched = self.add_to("bench", slot=2) - - from spirit.game.session.legal_actions import ACTION_RETREAT, compute_legal_actions - entries = compute_legal_actions( - self.board, self.session.turn_state, P1, self.session.game_id - ) - entry = next( - e for e in entries - if e["selectableAction"]["description"] == ACTION_RETREAT - ) - await self.session._execute_retreat( - P1, active, entry, [energy.entity_id, benched.entity_id] - ) - - self.assertEqual(active.board_slot, 2) - moves = self.moved_msgs(self.client1) - to_bench = next( - m for m in moves if m["value"]["entityID"] == active.entity_id - ) - self.assertEqual(to_bench["value"]["positionInParent"], 2) - - async def test_switch_active_old_active_takes_the_stamped_slot(self): - active = self.add_to("activePokemonArea") - benched = self.add_to("bench", slot=3) - ctx = EffectContext(self.session, P1, active, None) - - self.assertTrue(await ctx.switch_active(P1, benched)) - - self.assertEqual(active.board_slot, 3) - self.assertEqual(benched.board_slot, 0) - - -class TestSerializedGapAttributes(BenchSlotTestBase): - def test_sgs_lists_empty_slots_and_sorts_children(self): - later = self.add_to("bench", slot=3) - earlier = self.add_to("bench", slot=1) - - self.board.serialize(P1) - - bench = self.board.find_player_area(P1, "bench") - self.assertEqual(bench.children, [earlier, later]) - self.assertEqual( - bench.get_attribute(AttrID.AREA_EMPTY_SLOTS), [0, 2, 4] - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/spirit/tests/test_chat_rooms.py b/spirit/tests/test_chat_rooms.py deleted file mode 100644 index c71c405..0000000 --- a/spirit/tests/test_chat_rooms.py +++ /dev/null @@ -1,114 +0,0 @@ -import unittest - -from spirit.network.message_names import OutboundMsg -from spirit.packets.handlers.social import SocialHandler, LOBBY_ROOMS - - -class MockPlayer: - def __init__(self, account_id, screen_name): - self.account_id = account_id - self.screen_name = screen_name - - -class MockServer: - def __init__(self): - self.clients = [] - - -class MockClientHandler: - def __init__(self, server, account_id, screen_name): - self.server = server - self.player = MockPlayer(account_id, screen_name) - self.addr = ("127.0.0.1", 12345) - self.sent_packets = [] - server.clients.append(self) - - async def send_packet(self, data, request_id=0, flags=0): - self.sent_packets.append(data) - - def msgs(self, name): - return [p for p in self.sent_packets if p.get("messageName") == name] - - -class TestLobbyChatRooms(unittest.IsolatedAsyncioTestCase): - """Lobby room wire protocol (decoded from s.f/s.w/T.B/T.C). - - A joined Lobby room must exist so the client's exit-game-chat command - (T.W) has a JoinedRooms[0] to switch back to; without one it re-selects - the game room, deletes its RoomMessages entry, and every later - ChatGenerator.generate() KeyNotFoundExceptions on the dangling - CurrentSelectedRoom. - """ - - def setUp(self): - self.server = MockServer() - self.alice = MockClientHandler(self.server, "acc-alice", "Alice") - self.bob = MockClientHandler(self.server, "acc-bob", "Bob") - - async def test_public_rooms_lists_lobby_type_zero(self): - handler = SocialHandler(self.alice) - await handler.handle_public_rooms({}, request_id=1, flags=0) - response = self.alice.msgs(OutboundMsg.AVAILABLE_ROOMS.value)[0] - self.assertTrue(response["rooms"]) - # s.w only auto-joins rooms with roomType == RoomType.Lobby (0). - self.assertTrue(all(r["roomType"] == 0 for r in response["rooms"])) - - async def test_join_room_replies_chat_connected_and_notifies(self): - room_id = LOBBY_ROOMS[0]["roomID"] - await SocialHandler(self.alice).handle_join_room( - {"roomID": room_id}, request_id=1, flags=0) - await SocialHandler(self.bob).handle_join_room( - {"roomID": room_id}, request_id=2, flags=0) - - connected = self.bob.msgs(OutboundMsg.CHAT_CONNECTED.value)[0] - self.assertEqual(connected["room"]["roomID"], room_id) - self.assertEqual(connected["room"]["roomType"], 0) - member_ids = [m["accountID"] for m in connected["members"]] - self.assertIn("acc-alice", member_ids) - self.assertEqual(connected["messageHistory"], []) - - joined = self.alice.msgs(OutboundMsg.NOTIFY_JOIN.value)[0] - self.assertEqual(joined["roomID"], room_id) - self.assertEqual(joined["userInfo"]["accountID"], "acc-bob") - - async def test_unknown_room_join_is_ignored(self): - await SocialHandler(self.alice).handle_join_room( - {"roomID": "not-a-room"}, request_id=1, flags=0) - self.assertEqual(self.alice.sent_packets, []) - - async def test_room_chat_broadcasts_to_all_members_including_sender(self): - room_id = LOBBY_ROOMS[0]["roomID"] - for client in (self.alice, self.bob): - await SocialHandler(client).handle_join_room( - {"roomID": room_id}, request_id=1, flags=0) - await SocialHandler(self.alice).handle_room_chat( - {"roomID": room_id, "message": "hello lobby"}, request_id=3, flags=0) - - # The sender has no local echo: their own line renders from NotifyChat. - for client in (self.alice, self.bob): - notify = client.msgs(OutboundMsg.NOTIFY_CHAT.value)[0] - self.assertEqual(notify["roomID"], room_id) - self.assertEqual(notify["message"], "hello lobby") - self.assertEqual(notify["userInfo"]["displayName"], "Alice") - - async def test_leave_room_replies_disconnected_and_notifies(self): - room_id = LOBBY_ROOMS[0]["roomID"] - for client in (self.alice, self.bob): - await SocialHandler(client).handle_join_room( - {"roomID": room_id}, request_id=1, flags=0) - await SocialHandler(self.bob).handle_leave_room( - {"roomID": room_id}, request_id=4, flags=0) - - disconnected = self.bob.msgs(OutboundMsg.CHAT_DISCONNECTED.value)[0] - self.assertEqual(disconnected["roomID"], room_id) - left = self.alice.msgs(OutboundMsg.NOTIFY_LEAVE.value)[0] - self.assertEqual(left["accountID"], "acc-bob") - - # A member who left no longer receives room chat. - await SocialHandler(self.alice).handle_room_chat( - {"roomID": room_id, "message": "still here?"}, request_id=5, flags=0) - self.assertEqual(len(self.bob.msgs(OutboundMsg.NOTIFY_CHAT.value)), 0) - - -if __name__ == "__main__": - unittest.main() diff --git a/spirit/tests/test_daily_rewards.py b/spirit/tests/test_daily_rewards.py deleted file mode 100644 index 63c845a..0000000 --- a/spirit/tests/test_daily_rewards.py +++ /dev/null @@ -1,195 +0,0 @@ -import datetime -import os -import tempfile -import unittest -from unittest.mock import patch - -from spirit.game.daily_rewards import ( - DailyRewardManager, parse_tracks, VALID_REWARD_TYPES -) - -TEST_TRACKS = { - "newbie": [ - [{"name": "Tokens", "rewardType": "Tokens", "rewardAmount": 100}], - [{"name": "Card", "rewardType": "Archetype", "rewardAmount": 2, - "rewardProductID": "aaaa-cccc"}], - [{"name": "Ticket", "rewardType": "TournamentTicket", "rewardAmount": 1}], - ], - "standard": [ - [{"name": "Tokens", "rewardType": "Tokens", "rewardAmount": 7}], - [{"name": "Tokens", "rewardType": "Tokens", "rewardAmount": 7}], - [{"name": "Tokens", "rewardType": "Tokens", "rewardAmount": 7}], - [{"name": "Tokens", "rewardType": "Tokens", "rewardAmount": 7}], - [{"name": "Tokens", "rewardType": "Tokens", "rewardAmount": 7}], - ], -} - - -class TestDailyRewardTracks(unittest.TestCase): - def setUp(self): - self.manager = DailyRewardManager() - self.manager.tracks = parse_tracks(TEST_TRACKS) - - def tearDown(self): - self.manager.load_tracks() - - def test_wire_shape(self): - newbie = self.manager.weeks_rewards(1) - self.assertEqual(len(newbie), 3) - standard = self.manager.weeks_rewards(4) - self.assertEqual(len(standard), 5) - for day in newbie + standard: - self.assertTrue(day) - for i, reward in enumerate(day): - self.assertIn(reward["rewardType"], VALID_REWARD_TYPES) - self.assertEqual(reward["index"], i) - if reward["rewardType"] != "Archetype": - self.assertIsNone(reward["rewardProductID"]) - - def test_reward_day_wraps_per_track(self): - self.assertEqual(self.manager.reward_day(1, 1), 1) - self.assertEqual(self.manager.reward_day(3, 3), 3) - self.assertEqual(self.manager.reward_day(4, 3), 1) # newbie track wraps at 3 - self.assertEqual(self.manager.reward_day(4, 4), 4) # standard track - self.assertEqual(self.manager.reward_day(6, 6), 1) # standard wraps at 5 - - def test_invalid_reward_type_rejected(self): - bad = {**TEST_TRACKS, "newbie": [ - [{"name": "x", "rewardType": "Currency", "rewardAmount": 5}]] * 3} - with self.assertRaises(ValueError): - parse_tracks(bad) - - def test_wrong_day_count_rejected(self): - bad = {**TEST_TRACKS, "standard": TEST_TRACKS["standard"][:4]} - with self.assertRaises(ValueError): - parse_tracks(bad) - - def test_archetype_requires_product_id(self): - bad = {**TEST_TRACKS, "newbie": [ - [{"name": "x", "rewardType": "Archetype", "rewardAmount": 1}]] * 3} - with self.assertRaises(ValueError): - parse_tracks(bad) - - def test_multi_reward_day_must_be_all_archetype(self): - bad = {**TEST_TRACKS, "newbie": [ - [{"name": "a", "rewardType": "Archetype", "rewardAmount": 1, - "rewardProductID": "aaaa-cccc"}, - {"name": "t", "rewardType": "Tokens", "rewardAmount": 5}]] * 3} - with self.assertRaises(ValueError): - parse_tracks(bad) - - def test_bad_config_file_falls_back_to_defaults(self): - fd, path = tempfile.mkstemp(suffix=".json") - try: - with os.fdopen(fd, "w") as f: - f.write("{not json") - self.manager.load_tracks(path) - self.assertEqual(len(self.manager.tracks["newbie"]), 3) - self.assertEqual(len(self.manager.tracks["standard"]), 5) - finally: - os.unlink(path) - - -class TestDailyLoginProgress(unittest.TestCase): - @classmethod - def setUpClass(cls): - cls.db_fd, cls.db_path = tempfile.mkstemp() - cls.db_patcher = patch('spirit.database.connection.DB_PATH', cls.db_path) - cls.db_patcher.start() - from spirit.database.setup_db import setup_database - setup_database() - cls.manager = DailyRewardManager() - cls.manager.tracks = parse_tracks(TEST_TRACKS) - - @classmethod - def tearDownClass(cls): - cls.manager.load_tracks() - cls.db_patcher.stop() - try: - os.close(cls.db_fd) - os.unlink(cls.db_path) - except OSError: - pass - - def _new_account(self, name): - from spirit.database.accounts import create_account - return create_account(name, "pw123")["account_id"] - - def _rewind_last_claim(self, account_id, days): - from spirit.database import db_session - from spirit.database.models.economy import DailyLoginProgress - with db_session() as session: - p = session.query(DailyLoginProgress).filter_by(account_id=account_id).first() - p.last_claim_date = p.last_claim_date - datetime.timedelta(days=days) - - def test_claims_once_per_day(self): - from spirit.database.daily_rewards import process_daily_login - from spirit.database.player_data import get_wallet_by_account_id - - account_id = self._new_account("daily_tester") - coins_before = get_wallet_by_account_id(account_id)["coins"] - - r = process_daily_login(account_id) - self.assertTrue(r["firstDailyLogin"]) - self.assertEqual(r["activations"], 1) - self.assertEqual(r["rewardDay"], 1) - self.assertEqual(get_wallet_by_account_id(account_id)["coins"], coins_before + 100) - - r2 = process_daily_login(account_id) - self.assertFalse(r2["firstDailyLogin"]) - self.assertEqual(r2["activations"], 1) - self.assertEqual(get_wallet_by_account_id(account_id)["coins"], coins_before + 100) - - def test_streak_grants_and_newbie_transition(self): - from spirit.database.daily_rewards import process_daily_login - from spirit.database.player_data import ( - get_wallet_by_account_id, get_collection_by_account_id) - - account_id = self._new_account("daily_streaker") - wallet = get_wallet_by_account_id(account_id) - coins_before, tickets_before = wallet["coins"], wallet["tickets"] - - process_daily_login(account_id) # day 1: 100 tokens - self._rewind_last_claim(account_id, 1) - process_daily_login(account_id) # day 2: 2x archetype - collection = {c["archetype_id"]: c for c in get_collection_by_account_id(account_id)} - self.assertEqual(collection["aaaa-cccc"]["nontradable_count"], 2) - - self._rewind_last_claim(account_id, 1) - r = process_daily_login(account_id) # day 3: ticket - self.assertEqual(r["activations"], 3) - self.assertEqual( - get_wallet_by_account_id(account_id)["tickets"], tickets_before + 1) - - self._rewind_last_claim(account_id, 1) - r = process_daily_login(account_id) # day 4: standard track - self.assertEqual(r["activations"], 4) - self.assertEqual(r["rewardDay"], 4) - self.assertFalse(self.manager.is_newbie(r["activations"])) - self.assertEqual(len(self.manager.weeks_rewards(r["activations"])), 5) - self.assertEqual( - get_wallet_by_account_id(account_id)["coins"], coins_before + 100 + 7) - - def test_streak_resets_after_gap(self): - from spirit.database.daily_rewards import process_daily_login - - account_id = self._new_account("daily_lapser") - process_daily_login(account_id) - self._rewind_last_claim(account_id, 1) - r = process_daily_login(account_id) - self.assertEqual(r["rewardDay"], 2) - - self._rewind_last_claim(account_id, 3) - r = process_daily_login(account_id) - self.assertEqual(r["rewardDay"], 1) # streak reset - self.assertEqual(r["activations"], 3) # lifetime count kept - - def test_next_reward_timestamp_is_future_ms(self): - from spirit.database.daily_rewards import process_daily_login - import time - - account_id = self._new_account("daily_timer") - r = process_daily_login(account_id) - now_ms = time.time() * 1000 - self.assertGreater(r["nextRewardTimestampMs"], now_ms) - self.assertLessEqual(r["nextRewardTimestampMs"], now_ms + 24 * 3600 * 1000) diff --git a/spirit/tests/test_deck.py b/spirit/tests/test_deck.py deleted file mode 100644 index 03b4fb9..0000000 --- a/spirit/tests/test_deck.py +++ /dev/null @@ -1,324 +0,0 @@ -import unittest -import os -import tempfile -import uuid -import json -from unittest.mock import MagicMock, AsyncMock, patch - -# Ensure the database paths are mocked to a temporary file BEFORE importing anything that might initialize connection pools -from spirit.database import db_manager - -class TestDeckOperations(unittest.IsolatedAsyncioTestCase): - @classmethod - def setUpClass(cls): - cls.db_fd, cls.db_path = tempfile.mkstemp() - cls.db_patcher = patch('spirit.database.connection.DB_PATH', cls.db_path) - cls.db_patcher.start() - - # Initialize the test database tables - from spirit.database.setup_db import setup_database - setup_database() - - @classmethod - def tearDownClass(cls): - cls.db_patcher.stop() - try: - os.close(cls.db_fd) - os.unlink(cls.db_path) - except OSError: - pass - - def setUp(self): - # We also need to mock or clear any global caches if necessary - pass - - def test_database_deck_lifecycle(self): - """Verifies player_data save_deck, get_decks_by_account_id, and delete_deck flow.""" - from spirit.database.player_data import save_deck, get_decks_by_account_id, delete_deck - - account_id = "test-acc-123" - deck_id = str(uuid.uuid4()) - deck_name = "Lightning Strike" - deck_data = { - "deckID": deck_id, - "deckName": deck_name, - "cards": [] - } - - # 1. Save the deck - success = save_deck(account_id, deck_id, deck_name, deck_data, is_avatar=False) - self.assertTrue(success) - - # 2. Get the decks and verify it exists - decks = get_decks_by_account_id(account_id, is_avatar=False) - self.assertEqual(len(decks), 1) - self.assertEqual(decks[0]["id"], deck_id) - self.assertEqual(decks[0]["name"], deck_name) - self.assertEqual(decks[0]["deck_data"]["deckID"], deck_id) - - # 3. Delete the deck - del_success = delete_deck(account_id, deck_id) - self.assertTrue(del_success) - - # 4. Get the decks and verify it is gone - decks_after = get_decks_by_account_id(account_id, is_avatar=False) - self.assertEqual(len(decks_after), 0) - - def test_player_model_deck_deletion(self): - """Verifies Player model's local state synchronization after deck deletion.""" - from spirit.game.models.player import Player - - account_data = { - "account_id": "player-acc-456", - "username": "tester", - "screen_name": "Tester" - } - - # We construct player after DB has been set up - player = Player(account_data) - - deck_id = str(uuid.uuid4()) - deck_dict = { - "deckID": deck_id, - "deckName": "Water Splash" - } - - # Save deck through player - player.save_deck_data(deck_dict, is_avatar=False) - self.assertEqual(len(player.decks), 1) - self.assertEqual(player.decks[0]["id"], deck_id) - - # Delete deck through player - player.delete_deck_data(deck_id, is_avatar=False) - self.assertEqual(len(player.decks), 0) - - async def test_handler_cake_delete_deck(self): - """Verifies handle_cake_delete_deck correctly deletes a deck and sends DeckDeleted response.""" - from spirit.packets.handlers.data_sync import DataSyncHandler - from spirit.game.models.player import Player - from spirit.network.message_names import OutboundMsg, InboundMsg - from spirit.network.protocol import WargFlags - - # Setup mock client - mock_client = MagicMock() - mock_client.send_packet = AsyncMock() - mock_client.addr = ("127.0.0.1", 12345) - - # Setup player on client - account_data = { - "account_id": "player-acc-789", - "username": "handler_tester", - "screen_name": "HandlerTester" - } - player = Player(account_data) - mock_client.player = player - - # Save a deck to ensure we can delete it - deck_id = str(uuid.uuid4()) - deck_dict = { - "deckID": deck_id, - "deckName": "Fire Blast" - } - player.save_deck_data(deck_dict, is_avatar=False) - self.assertEqual(len(player.decks), 1) - - # Instantiate handler - handler = DataSyncHandler(mock_client) - - # Invoke handle_cake_delete_deck - message = { - "deckID": deck_id - } - await handler.handle_cake_delete_deck(message, request_id=101, flags=WargFlags.CLEAR) - - # Verify database / local state deletion - self.assertEqual(len(player.decks), 0) - - # Verify response packet was sent - mock_client.send_packet.assert_called_once() - sent_args = mock_client.send_packet.call_args[0] - sent_res = sent_args[0] - sent_rid = sent_args[1] - sent_flags = mock_client.send_packet.call_args[1].get("flags") - - self.assertEqual(sent_res["messageName"], OutboundMsg.DECK_DELETED.value) - self.assertEqual(sent_res["deckID"], deck_id) - self.assertEqual(sent_rid, 101) - self.assertEqual(sent_flags, WargFlags.CLEAR) - - async def test_handler_cake_save_deck(self): - """Verifies handle_cake_save_deck correctly saves a deck, populates validationResults, and sends DeckSaved response.""" - from spirit.packets.handlers.data_sync import DataSyncHandler - from spirit.game.models.player import Player - from spirit.network.message_names import OutboundMsg, InboundMsg - from spirit.network.protocol import WargFlags - - # Setup mock client - mock_client = MagicMock() - mock_client.send_packet = AsyncMock() - mock_client.addr = ("127.0.0.1", 12345) - - # Setup player on client - account_data = { - "account_id": "player-acc-save-999", - "username": "save_tester", - "screen_name": "SaveTester" - } - player = Player(account_data) - mock_client.player = player - - # Instantiate handler - handler = DataSyncHandler(mock_client) - - # Incoming save request with attributes - deck_id = str(uuid.uuid4()) - message = { - "deck": { - "deckID": deck_id, - "deckName": "Leaf Storm", - "attributes": [ - {"name": 200670, "value": "some-coin-guid"} - ], - "piles": { - "deck": [] - } - } - } - - # Invoke handle_cake_save_deck - await handler.handle_cake_save_deck(message, request_id=102, flags=WargFlags.CLEAR) - - # Verify DB / local state updated - self.assertEqual(len(player.decks), 1) - self.assertEqual(player.decks[0]["id"], deck_id) - self.assertEqual(player.decks[0]["name"], "Leaf Storm") - - # Verify response packet was sent - mock_client.send_packet.assert_called_once() - sent_args = mock_client.send_packet.call_args[0] - sent_res = sent_args[0] - sent_rid = sent_args[1] - sent_flags = mock_client.send_packet.call_args[1].get("flags") - - self.assertEqual(sent_res["messageName"], OutboundMsg.DECK_SAVED.value) - self.assertEqual(sent_res["deckID"], deck_id) - self.assertEqual(sent_res["deck"]["deckName"], "Leaf Storm") - - # Core checks for validationResults - self.assertIn("validationResults", sent_res) - self.assertEqual(len(sent_res["validationResults"]), 3) - self.assertEqual(sent_res["validationResults"][0]["formatName"], "Modified") - self.assertTrue(sent_res["validationResults"][0]["valid"]) - - self.assertEqual(sent_rid, 102) - self.assertEqual(sent_flags, WargFlags.CLEAR) - - async def test_handler_validate_all_decks(self): - """Verifies handle_validate_all_decks returns correctly structured validation results for player's decks.""" - from spirit.packets.handlers.data_sync import DataSyncHandler - from spirit.game.models.player import Player - from spirit.network.message_names import OutboundMsg, InboundMsg - from spirit.network.protocol import WargFlags - - mock_client = MagicMock() - mock_client.send_packet = AsyncMock() - mock_client.addr = ("127.0.0.1", 12345) - - player = Player({ - "account_id": "val-all-acc-111", - "username": "val_all_tester", - "screen_name": "ValAllTester" - }) - mock_client.player = player - - # Pre-populate a deck - deck_id = str(uuid.uuid4()) - player.save_deck_data({"deckID": deck_id, "deckName": "Val Deck"}, is_avatar=False) - - handler = DataSyncHandler(mock_client) - - # Invoke ValidateAllDecks - formats = ["6402e830-7fed-4cd1-b172-2a320047c2bb", "98c83df9-ec82-4193-84a8-104115ce4e25"] - message = {"formats": formats} - await handler.handle_validate_all_decks(message, request_id=103, flags=WargFlags.CLEAR) - - mock_client.send_packet.assert_called_once() - sent_res = mock_client.send_packet.call_args[0][0] - self.assertEqual(sent_res["messageName"], OutboundMsg.DECKS_VALIDATED.value) - self.assertIn("results", sent_res) - self.assertEqual(len(sent_res["results"]), 2) # 1 deck * 2 formats - self.assertEqual(sent_res["results"][0]["deckID"], deck_id) - self.assertEqual(sent_res["results"][0]["formatName"], "Modified") - self.assertTrue(sent_res["results"][0]["valid"]) - - async def test_handler_validate_decks(self): - """Verifies handle_validate_decks returns correctly structured validation results for specified decks.""" - from spirit.packets.handlers.data_sync import DataSyncHandler - from spirit.network.message_names import OutboundMsg, InboundMsg - from spirit.network.protocol import WargFlags - - mock_client = MagicMock() - mock_client.send_packet = AsyncMock() - mock_client.addr = ("127.0.0.1", 12345) - - handler = DataSyncHandler(mock_client) - - # Invoke ValidateDecks - deck_id = str(uuid.uuid4()) - message = { - "decks": [{"deckID": deck_id}], - "formats": ["6a1dec5a-34db-4cee-a503-4ee759304135"] - } - await handler.handle_validate_decks(message, request_id=104, flags=WargFlags.CLEAR) - - mock_client.send_packet.assert_called_once() - sent_res = mock_client.send_packet.call_args[0][0] - self.assertEqual(sent_res["messageName"], OutboundMsg.DECKS_VALIDATED.value) - self.assertEqual(len(sent_res["results"]), 1) - self.assertEqual(sent_res["results"][0]["deckID"], deck_id) - self.assertEqual(sent_res["results"][0]["formatName"], "Unlimited") - self.assertTrue(sent_res["results"][0]["valid"]) - - def test_dynamic_validation_attribute_injection(self): - """Verifies that player deck retrieval dynamically injects the VALID_FORMATS attribute (10860) with standard format names.""" - from spirit.game.models.player import Player - from spirit.game.attributes import AttrID - - player = Player({ - "account_id": "val-inject-acc-321", - "username": "inject_tester", - "screen_name": "InjectTester" - }) - - deck_id = str(uuid.uuid4()) - deck_dict = { - "deckID": deck_id, - "deckName": "Lightning Speed", - "attributes": [ - {"name": AttrID.SELECTED_COIN.value, "value": "some-coin-id"} - ], - "piles": {"deck": []} - } - - # Save to player - player.save_deck_data(deck_dict, is_avatar=False) - - # Retrieve player's deck data - decks_data = player.get_decks_data() - self.assertEqual(len(decks_data["decks"]), 1) - - retrieved_deck = decks_data["decks"][0] - attributes = retrieved_deck.get("attributes", []) - - # Verify the VALID_FORMATS (10860) attribute was injected and contains standard format names - format_attr = next((attr for attr in attributes if attr.get("name") == AttrID.VALID_FORMATS.value), None) - self.assertIsNotNone(format_attr) - self.assertEqual(format_attr["value"], ["Modified", "Expanded", "Unlimited", "Legacy"]) - - # Ensure other attributes are preserved - coin_attr = next((attr for attr in attributes if attr.get("name") == AttrID.SELECTED_COIN.value), None) - self.assertIsNotNone(coin_attr) - self.assertEqual(coin_attr["value"], "some-coin-id") - -if __name__ == '__main__': - unittest.main() diff --git a/spirit/tests/test_dynamic_pages.py b/spirit/tests/test_dynamic_pages.py deleted file mode 100644 index 610b815..0000000 --- a/spirit/tests/test_dynamic_pages.py +++ /dev/null @@ -1,100 +0,0 @@ -import unittest -import os -import tempfile -from unittest.mock import MagicMock, AsyncMock, patch - -from spirit.database import db_manager - - -class TestDynamicPages(unittest.IsolatedAsyncioTestCase): - @classmethod - def setUpClass(cls): - cls.db_fd, cls.db_path = tempfile.mkstemp() - cls.db_patcher = patch('spirit.database.connection.DB_PATH', cls.db_path) - cls.db_patcher.start() - - from spirit.database.setup_db import setup_database - setup_database() - - @classmethod - def tearDownClass(cls): - cls.db_patcher.stop() - try: - os.close(cls.db_fd) - os.unlink(cls.db_path) - except OSError: - pass - - def setUp(self): - from spirit.database import db_session, DynamicPage - with db_session() as session: - session.query(DynamicPage).delete() - - def _make_handler(self): - from spirit.packets.handlers.data_sync import DataSyncHandler - mock_client = MagicMock() - mock_client.send_packet = AsyncMock() - mock_client.addr = ("127.0.0.1", 4444) - mock_client.player = None - return DataSyncHandler(mock_client), mock_client - - async def test_dynamic_pages_served_from_db_sorted(self): - from spirit.database.economy_data import upsert_dynamic_page - from spirit.network.message_names import OutboundMsg - - upsert_dynamic_page({"template": "LandingPageLeft"}, sort_order=2) - upsert_dynamic_page({"template": "LandingPageRight"}, sort_order=1) - upsert_dynamic_page({"template": "Hidden"}, sort_order=0, enabled=False) - upsert_dynamic_page({"note": "down for maintenance"}, - page_type="maintenance", sort_order=0) - - handler, client = self._make_handler() - await handler.handle_get_dynamic_pages({}, 100, 0) - - client.send_packet.assert_called_once() - res = client.send_packet.call_args.args[0] - self.assertEqual(res["messageName"], OutboundMsg.DYNAMIC_LANDING_PAGES.value) - - templates = [p["template"] for p in res["pageData"]] - self.assertEqual(templates, ["LandingPageRight", "LandingPageLeft"]) - self.assertNotIn("Hidden", templates) - - self.assertEqual(len(res["maintenanceData"]), 1) - self.assertEqual(res["maintenanceData"][0]["note"], "down for maintenance") - - async def test_dynamic_pages_empty_db(self): - handler, client = self._make_handler() - await handler.handle_get_dynamic_pages({}, 101, 0) - - res = client.send_packet.call_args.args[0] - self.assertEqual(res["pageData"], []) - self.assertEqual(res["maintenanceData"], []) - - async def test_admin_edit_flows_to_client(self): - """A page saved through the Admin API is served to the game client.""" - import json as jsonlib - from spirit.server.admin_api import route_admin - - login = route_admin('POST', '/admin/api/login', - jsonlib.dumps({"username": "brandon", "password": "password"}), - headers={}) - cookie = login[3][0][1].split(';')[0] - - content = {"template": "LandingPageRight", - "labels": {"GameText": {"bundle": {"en_US": "Season 1 rewards!"}}}} - result = route_admin('POST', '/admin/api/pages', jsonlib.dumps({ - "content_json": content, "sort_order": 1 - }), headers={"Cookie": cookie}) - self.assertEqual(result[0], 200) - - handler, client = self._make_handler() - await handler.handle_get_dynamic_pages({}, 102, 0) - res = client.send_packet.call_args.args[0] - self.assertEqual(len(res["pageData"]), 1) - self.assertEqual( - res["pageData"][0]["labels"]["GameText"]["bundle"]["en_US"], - "Season 1 rewards!") - - -if __name__ == '__main__': - unittest.main() diff --git a/spirit/tests/test_dynamic_versions.py b/spirit/tests/test_dynamic_versions.py deleted file mode 100644 index 1b00277..0000000 --- a/spirit/tests/test_dynamic_versions.py +++ /dev/null @@ -1,48 +0,0 @@ -import unittest - -from spirit.packets.handlers.data_sync import DataSyncHandler -from spirit.network.message_names import OutboundMsg - - -class MockClientHandler: - def __init__(self): - self.addr = ("127.0.0.1", 54321) - self.sent_packets = [] - - async def send_packet(self, data, request_id=0, flags=0): - self.sent_packets.append(data) - - -class TestDynamicVersions(unittest.IsolatedAsyncioTestCase): - async def test_version_data_contains_content_type_keys(self): - """ - The client's VersionLabel.Start() (Type.Server) indexes - versionData["ArchetypeContentType"] and - versionData["LocalizationContentType"] with no ContainsKey guard. - These keys MUST be present or the client throws a KeyNotFoundException - the moment the in-game Settings panel is opened (in any scene). - """ - client = MockClientHandler() - handler = DataSyncHandler(client) - - await handler.handle_get_dynamic_versions( - message={}, request_id=42, flags=0 - ) - - self.assertEqual(len(client.sent_packets), 1) - res = client.sent_packets[0] - - self.assertEqual(res["messageName"], OutboundMsg.DYNAMIC_VERSIONS.value) - version_data = res["versionData"] - - # Regression guard: the two keys the client indexes unconditionally. - self.assertIn("ArchetypeContentType", version_data) - self.assertIn("LocalizationContentType", version_data) - - # All version values must be strings (client concatenates them as text). - for key, value in version_data.items(): - self.assertIsInstance(value, str, f"{key} must be a string") - - -if __name__ == "__main__": - unittest.main() diff --git a/spirit/tests/test_effect_coverage.py b/spirit/tests/test_effect_coverage.py deleted file mode 100644 index 5d2e1cf..0000000 --- a/spirit/tests/test_effect_coverage.py +++ /dev/null @@ -1,140 +0,0 @@ -import os -import shutil -import tempfile -import unittest - -from spirit.tools.effect_coverage import scan_cards, group_work_items, triage - - -VANILLA_ATTACK = """from spirit.game.data_utils import PokemonCardDef, Attack -from spirit.game.attributes import PokemonTypes, PokemonStage, Rarities - -card = PokemonCardDef( - guid="11111111-1111-1111-1111-111111111111", - key="SETA", - name="com.direwolfdigital.cake.data.archetypes.pokemon.Ratata.Name", - collector_number=1, - set_code="SETA", - rarity=Rarities.Common, - hp=50, - elements=[PokemonTypes.COLORLESS], - stage=PokemonStage.BASIC, - retreat_cost=1, - abilities=[ - Attack(title="Tackle", cost={PokemonTypes.COLORLESS: 1}, damage=10), - ], -) -""" - -STUB_ATTACK = """from spirit.game.data_utils import PokemonCardDef, Attack, unimplemented -from spirit.game.attributes import PokemonTypes, PokemonStage, Rarities - -card = PokemonCardDef( - guid="22222222-2222-2222-2222-222222222222", - key="{key}", - name="com.direwolfdigital.cake.data.archetypes.pokemon.Drawmon.Name", - collector_number={num}, - set_code="{key}", - rarity=Rarities.Common, - hp=60, - elements=[PokemonTypes.COLORLESS], - stage=PokemonStage.BASIC, - retreat_cost=1, - abilities=[ - Attack( - title="Card Fetch", - game_text="Draw 2 cards.", - cost={{PokemonTypes.COLORLESS: 1}}, - effect=unimplemented, - ), - ], -) -""" - -STUB_TRAINER = """from spirit.game.data_utils import ItemCardDef, unimplemented -from spirit.game.attributes import Rarities - -card = ItemCardDef( - guid="33333333-3333-3333-3333-333333333333", - key="SETA", - name="com.direwolfdigital.cake.data.archetypes.trainer.Potion.Name", - collector_number=99, - set_code="SETA", - rarity=Rarities.Common, - effect=unimplemented -) -""" - - -class TestEffectCoverage(unittest.TestCase): - def setUp(self): - self.tmp_dir = tempfile.mkdtemp() - self.set_a = os.path.join(self.tmp_dir, "SETA") - self.set_b = os.path.join(self.tmp_dir, "SETB") - os.makedirs(self.set_a) - os.makedirs(self.set_b) - - with open(os.path.join(self.set_a, "Ratata_1.py"), "w") as f: - f.write(VANILLA_ATTACK) - with open(os.path.join(self.set_a, "Drawmon_2.py"), "w") as f: - f.write(STUB_ATTACK.format(key="SETA", num=2)) - with open(os.path.join(self.set_b, "Drawmon_2.py"), "w") as f: - f.write(STUB_ATTACK.format(key="SETB", num=2)) - with open(os.path.join(self.set_a, "Potion_99.py"), "w") as f: - f.write(STUB_TRAINER) - - # Should be skipped by the scanner (non-script / dunder file). - with open(os.path.join(self.set_a, "__init__.py"), "w") as f: - f.write("") - - def tearDown(self): - shutil.rmtree(self.tmp_dir, ignore_errors=True) - - def test_scan_counts_scripts_and_flags_stubs(self): - infos = scan_cards(cards_root=self.tmp_dir) - self.assertEqual(len(infos), 4) - - by_name = {(i.set_code, i.card_name): i for i in infos} - self.assertFalse(by_name[("SETA", "Ratata")].is_stub) - self.assertTrue(by_name[("SETA", "Drawmon")].is_stub) - self.assertTrue(by_name[("SETA", "Potion")].is_stub) - self.assertEqual(by_name[("SETA", "Potion")].signature, ()) - self.assertEqual(by_name[("SETA", "Potion")].card_class, "ItemCardDef") - - def test_reprints_dedupe_into_one_work_item(self): - infos = scan_cards(cards_root=self.tmp_dir) - work_items = group_work_items(infos) - - drawmon_items = [w for w in work_items if w.card_name == "Drawmon"] - self.assertEqual(len(drawmon_items), 1) - self.assertEqual(drawmon_items[0].script_count, 2) - covered_sets = {s.set_code for s in drawmon_items[0].scripts} - self.assertEqual(covered_sets, {"SETA", "SETB"}) - - # Vanilla card produced no work item at all. - self.assertFalse([w for w in work_items if w.card_name == "Ratata"]) - - def test_triage_keyword_matching(self): - self.assertEqual(triage(("draw 2 cards.",)), "search-draw") - self.assertEqual(triage(("flip a coin. if heads, do 20 more damage.",)), "coin-flip") - self.assertEqual(triage(("heal 30 damage from this pokemon.",)), "heal") - self.assertEqual(triage(()), "other") - - def test_set_filter(self): - infos = scan_cards(cards_root=self.tmp_dir, only_set="SETB") - self.assertEqual(len(infos), 1) - self.assertEqual(infos[0].set_code, "SETB") - - def test_work_item_json_shape(self): - infos = scan_cards(cards_root=self.tmp_dir) - work_items = group_work_items(infos) - potion_item = next(w for w in work_items if w.card_name == "Potion") - d = potion_item.to_dict() - self.assertEqual(d["card_class"], "ItemCardDef") - self.assertEqual(d["category"], "other") - self.assertEqual(d["script_count"], 1) - self.assertEqual(d["covered"], [{"set": "SETA", "number": 99}]) - - -if __name__ == "__main__": - unittest.main() diff --git a/spirit/tests/test_effects.py b/spirit/tests/test_effects.py deleted file mode 100644 index 78fdfb6..0000000 --- a/spirit/tests/test_effects.py +++ /dev/null @@ -1,839 +0,0 @@ -"""Unit tests for the card-effect engine (spirit/game/session/effects.py).""" - -import unittest - -from spirit.game.attributes import AttrID, PokemonTypes -from spirit.game.data_utils import ( - Ability, - Attack, - EnergyCardDef, - PokemonCardDef, - Triggers, - prize_value, - unimplemented, -) -from spirit.game.models.board import BoardState, create_card_entity -from spirit.game.models.card import Card -from spirit.game.session.effects import ( - EffectContext, - resolve_attack, - resolve_on_play_ability, -) -from spirit.game.session.game_session import NestedSequence -from spirit.game.session.legal_actions import TurnState -from spirit.network.message_names import OutboundMsg - -P1 = "player-1" -P2 = "player-2" -GAME_ID = "test-game" - - -def make_card(card_def) -> Card: - d = card_def.to_archetype_dict() - return Card(d["guid"], d["key"], d["attributes"]) - - -ATTACKER_DEF = PokemonCardDef( - guid="00000000-0000-0000-0000-0000000000a1", - key="BW1", name="com.test.pokemon.Snivy.Name", - collector_number=1, set_code="BW1", rarity=1, - hp=60, elements=[PokemonTypes.GRASS], - abilities=[ - Attack("Vine Whip", cost={PokemonTypes.GRASS: 1}, damage=30), - Attack("Leaf Storm", "storm.text", cost={PokemonTypes.GRASS: 2}, - damage=20, effect=unimplemented), - ], -) -ATTACKER = make_card(ATTACKER_DEF) -VANILLA_ATTACK = ATTACKER_DEF.abilities[0] -UNIMPLEMENTED_ATTACK = ATTACKER_DEF.abilities[1] - -WEAK_DEFENDER = make_card(PokemonCardDef( - guid="00000000-0000-0000-0000-0000000000d1", - key="BW1", name="com.test.pokemon.Oshawott.Name", - collector_number=2, set_code="BW1", rarity=1, - hp=180, elements=[PokemonTypes.WATER], - weakness_type=PokemonTypes.GRASS, -)) - -RESISTANT_DEFENDER = make_card(PokemonCardDef( - guid="00000000-0000-0000-0000-0000000000d2", - key="BW1", name="com.test.pokemon.Pidove.Name", - collector_number=3, set_code="BW1", rarity=1, - hp=60, elements=[PokemonTypes.COLORLESS], - resistance_type=PokemonTypes.GRASS, -)) - -PLAIN_DEFENDER = make_card(PokemonCardDef( - guid="00000000-0000-0000-0000-0000000000d3", - key="BW1", name="com.test.pokemon.Patrat.Name", - collector_number=4, set_code="BW1", rarity=1, - hp=30, elements=[PokemonTypes.COLORLESS], -)) - -GRASS_ENERGY = make_card(EnergyCardDef( - guid="00000000-0000-0000-0000-0000000000e1", - key="BW1", name="com.test.energy.Grass.Name", - collector_number=100, set_code="BW1", rarity=0, - energy_type=PokemonTypes.GRASS, -)) - - -class FakePlayer: - def __init__(self, name): - self.screen_name = name - - -class FakeSession: - """Duck-typed GameSession capturing sequence brackets per viewer.""" - - def __init__(self, board): - self.board_state = board - self.game_id = GAME_ID - self.players = {P1: FakePlayer("P1"), P2: FakePlayer("P2")} - self.turn_state = TurnState() - self.sleep_checkup_coins = {} - self.poison_counters = {} - self.paralyzed_since = {} - self.sent = [] # (player_ids, sequence_name, inner_messages) - self.prompts = [] # (player_id, prompt, buttons) - self.choice_replies = [] # queued button indices, popped per prompt - self.chooser_calls = [] # (player_id, [card ids], count, minimum, prompt) - self.panel_calls = [] # (player_id, source_entity_id, prompt, buttons) - self.chooser_replies = [] # queued entity-id lists, popped per chooser - self.entity_picker_calls = [] # (player_id, [card ids]) routed in-place - self.damage_counter_calls = [] # (player_id, [card ids], count, prompt) - self.damage_counter_replies = [] # queued {entity_id: counters}, popped per call - self.prize_reveal_calls = [] # (player_id, prize ids, selectable ids) - self.prize_reveal_replies = [] # queued picked prize id (or None), popped - self.attack_sources = None # last _broadcast_attack_sources payload - self.prize_awards = [] # (player_id, count) awarded by resolve_knockouts - self.game_over = None # (winner_id, reason) when a win condition fired - self.game_stats = {P1: {}, P2: {}} - self.mvp_damage = {P1: {}, P2: {}} - - def credit_card_damage(self, player_id, entity, amount): - guid = getattr(entity, "archetype_id", None) - if not guid or amount <= 0: - return - entry = self.mvp_damage.setdefault(player_id, {}).setdefault(guid, [0, ""]) - entry[0] += amount - - def stat_add(self, player_id, key, amount=1): - stats = self.game_stats.setdefault(player_id, {}) - stats[key] = stats.get(key, 0) + amount - - def stat_max(self, player_id, key, value): - stats = self.game_stats.setdefault(player_id, {}) - if value > stats.get(key, 0): - stats[key] = value - - def _opponent_id(self, player_id): - return P2 if player_id == P1 else P1 - - def clear_condition_state(self, entity_id): - self.sleep_checkup_coins.pop(entity_id, None) - self.poison_counters.pop(entity_id, None) - self.paralyzed_since.pop(entity_id, None) - - def clear_pokemon_effects(self, pokemon): - had_conditions = bool(pokemon.get_attribute(AttrID.SPECIAL_CONDITIONS)) - pokemon.set_attribute(AttrID.SPECIAL_CONDITIONS, []) - self.clear_condition_state(pokemon.entity_id) - for key in [k for k in self.turn_state.attack_locks if k[0] == pokemon.entity_id]: - self.turn_state.attack_locks.pop(key, None) - return had_conditions - - def reset_pokemon_damage(self, pokemon): - printed_max = pokemon.attribute_originals.get( - AttrID.HP.value, pokemon.get_attribute(AttrID.HP, 0) - ) - pokemon.set_attribute(AttrID.HP, printed_max) - - def reset_ability_usage(self, pokemon): - for key in [k for k in self.turn_state.used_abilities - if k[0] == pokemon.entity_id]: - self.turn_state.used_abilities.discard(key) - - @staticmethod - def _build_msg(name, value): - return {"name": name, "value": value} - - def _entity_introduced_msg(self, card): - return self._build_msg(OutboundMsg.ENTITY_INTRODUCED.value, { - "gameID": self.game_id, - "entityID": card.entity_id, - "entityName": card.get_entity_name(), - "attributeMap": card.serialize_attributes(), - }) - - def _entity_moved_msg(self, entity_id, destination_id, position): - return self._build_msg(OutboundMsg.ENTITY_MOVED.value, { - "gameID": self.game_id, - "entityID": entity_id, - "destinationID": destination_id, - "positionInParent": position, - "animDuration": 300, - }) - - async def send_game_sequence(self, players, name, inner_messages): - viewer_ids = [pid for pid, p in self.players.items() if p in players] - self.sent.append((viewer_ids, getattr(name, "value", name), list(inner_messages))) - - async def prompt_player_choice(self, player_id, prompt, buttons, sort_type=""): - self.prompts.append((player_id, prompt, buttons)) - return self.choice_replies.pop(0) if self.choice_replies else 0 - - async def prompt_choice_panel(self, player_id, source, options, - prompt=None, descriptions=None): - self.prompts.append((player_id, prompt, options)) - self.panel_calls.append((player_id, source.entity_id, prompt, options)) - return self.choice_replies.pop(0) if self.choice_replies else 0 - - async def prompt_card_chooser(self, player_id, source_entity_id, cards, - count, minimum=None, prompt="", ordered=False, - display_cards=None, slot_prompt=""): - ids = [c.entity_id for c in cards] - self.chooser_calls.append((player_id, ids, count, minimum, prompt)) - if self.chooser_replies: - return [i for i in self.chooser_replies.pop(0) if i in ids][:count] - return ids[:count] - - async def prompt_entity_picker(self, player_id, source_entity_id, cards, - count, minimum=None, prompt=""): - self.entity_picker_calls.append((player_id, [c.entity_id for c in cards])) - return await self.prompt_card_chooser( - player_id, source_entity_id, cards, count, minimum, prompt - ) - - async def prompt_card_chooser_groups(self, player_id, source_entity_id, - groups, prompt="", display_cards=None, - total=None, any_of=False): - picked = [] - for group in groups: - picked.append(await self.prompt_card_chooser( - player_id, source_entity_id, group["cards"], group["count"], - group.get("minimum", 0), prompt, - )) - return picked - - async def prompt_damage_counter_placement(self, player_id, source_entity_id, - candidates, count, - amount_per_click=10, prompt=""): - ids = [c.entity_id for c in candidates] - self.damage_counter_calls.append((player_id, ids, count, prompt)) - if self.damage_counter_replies: - return self.damage_counter_replies.pop(0) - return {ids[0]: count} if ids else {} - - async def _broadcast_attack_sources(self, entity_ids): - self.attack_sources = entity_ids - - def _reveal_card_msg(self, entity_id, return_to_origin): - return self._build_msg(OutboundMsg.REVEAL_CARD_TO_ALL_EFFECT.value, { - "gameID": self.game_id, - "entityID": entity_id, - "Return": return_to_origin, - "alwaysReveal": False, - }) - - def _entity_id_data_effect_msg(self, key, entity_id): - return self._build_msg(OutboundMsg.ENTITY_ID_DATA_EFFECT.value, { - "gameID": self.game_id, "key": key, "value": entity_id, - }) - - def _attributes_reset_msg(self, entity_id): - return self._build_msg(OutboundMsg.ATTRIBUTES_RESET.value, { - "gameID": self.game_id, "entityID": entity_id, - }) - - async def prompt_prize_reveal_pick(self, player_id, source_id, - prize_ids, selectable_ids): - self.prize_reveal_calls.append((player_id, list(prize_ids), list(selectable_ids))) - if self.prize_reveal_replies: - reply = self.prize_reveal_replies.pop(0) - return reply if reply in selectable_ids else None - return selectable_ids[0] if selectable_ids else None - - def _condition_attr_msg(self, pokemon): - conditions = pokemon.get_attribute(AttrID.SPECIAL_CONDITIONS) or [] - return self._build_msg(OutboundMsg.ATTRIBUTE_MODIFIED.value, { - "gameID": self.game_id, - "entityID": pokemon.entity_id, - "attribute": { - "name": AttrID.SPECIAL_CONDITIONS.value, - "value": conditions, - "originalValue": conditions, - "modValue": None, - }, - }) - - async def send_game_sequence_flush(self, ctx): - for pid in self.players: - for name, msgs in ctx.bracket_runs_for(pid): - await self.send_game_sequence([self.players[pid]], name, msgs) - - async def resolve_knockouts(self, ctx, _ko_depth=0): - """Mirror of GameSession.resolve_knockouts' discard/prize accounting, - without the interactive prize pick and promotion prompts.""" - for pokemon in ctx.knockouts: - owner_id = pokemon.owning_player_id - taker_id = self._opponent_id(owner_id) - discard = self.board_state.find_player_area(owner_id, "discard") - stack = [pokemon] - queue = list(pokemon.children) - while queue: - entity = queue.pop(0) - stack.append(entity) - queue.extend(entity.children) - moves = [] - for entity in stack: - position = len(discard.children) - if self.board_state.move_card(entity.entity_id, discard.entity_id): - moves.append(self._entity_moved_msg( - entity.entity_id, discard.entity_id, position - )) - if moves: - await self.send_game_sequence( - list(self.players.values()), "Knockout", moves - ) - self.prize_awards.append((taker_id, prize_value(pokemon.archetype_id))) - ctx.knockouts.clear() - - async def end_game(self, winner_id, reason): - self.game_over = (winner_id, reason) - - async def _broadcast_entity_attribute(self, entity, attr, value): - entity.set_attribute(attr, value) - - async def _promote_new_active(self, player_id): - from spirit.game.models.board import PokemonEntity - bench = self.board_state.find_player_area(player_id, "bench") - active = self.board_state.find_player_area(player_id, "activePokemonArea") - cands = [c for c in (bench.children if bench else []) - if isinstance(c, PokemonEntity)] - if not cands: - return False - self.board_state.move_card(cands[0].entity_id, active.entity_id) - return True - - -class EffectsTestBase(unittest.IsolatedAsyncioTestCase): - def setUp(self): - self.board = BoardState(GAME_ID, [P1, P2]) - self.session = FakeSession(self.board) - - def add_to(self, area_name, card, player_id): - entity = create_card_entity(card, owning_player_id=player_id) - area = self.board.find_player_area(player_id, area_name) - self.board.add_card_to_area(entity, area) - return entity - - def brackets(self, name): - return [s for s in self.session.sent if s[1] == name] - - @staticmethod - def flat_msgs(msgs): - """Bracket messages with nested child sequences folded in.""" - out = [] - for m in msgs: - out.extend(m.messages if isinstance(m, NestedSequence) else [m]) - return out - - def attack_bracket_for(self, viewer_id): - for viewer_ids, name, msgs in self.session.sent: - if name == "Attack" and viewer_ids == [viewer_id]: - return msgs - return None - - -class TestVanillaAttack(EffectsTestBase): - async def test_base_damage_with_weakness(self): - attacker = self.add_to("activePokemonArea", ATTACKER, P1) - defender = self.add_to("activePokemonArea", WEAK_DEFENDER, P2) - - await resolve_attack(self.session, P1, attacker, VANILLA_ATTACK, - VANILLA_ATTACK.ability_id) - - # 30 base doubled by Grass weakness = 60; 180 -> 120. - self.assertEqual(defender.get_attribute(AttrID.HP), 120) - - for pid in (P1, P2): - msgs = self.attack_bracket_for(pid) - self.assertIsNotNone(msgs) - names = [m["name"] for m in msgs] - self.assertEqual(names[0], "AbilityPlayedEffect") - self.assertEqual(names[-1], "AbilityFinishedEffect") - self.assertIn("CakeAttackEffect", names) - # Damaging attacks get the client's lunge group, not the orb. - self.assertNotIn("NonDamagingTargetsEffect", names) - self.assertIn("AttributeModified", names) - # The damage popup must precede the HP change: the client's KO - # check compares damageAmount against the pre-hit HP value. - self.assertLess( - names.index("CakeAttackEffect"), names.index("AttributeModified") - ) - cake = next(m for m in msgs if m["name"] == "CakeAttackEffect") - self.assertEqual(cake["value"]["damageAmount"], 60) - self.assertTrue(cake["value"]["weaknessTriggered"]) - self.assertEqual(cake["value"]["entityID"], defender.entity_id) - self.assertEqual(cake["value"]["damageSource"], attacker.entity_id) - attr = next(m for m in msgs if m["name"] == "AttributeModified") - self.assertEqual(attr["value"]["attribute"]["value"], 120) - self.assertEqual(attr["value"]["attribute"]["originalValue"], 180) - - # The attacker's pulled-back panel is tucked home first. - dismiss = self.brackets("DismissAbilitySelect") - self.assertEqual(len(dismiss), 1) - self.assertEqual(dismiss[0][0], [P1]) - self.assertEqual(self.brackets("Knockout"), []) - - async def test_resistance_reduces_to_floor_zero(self): - attacker = self.add_to("activePokemonArea", ATTACKER, P1) - defender = self.add_to("activePokemonArea", RESISTANT_DEFENDER, P2) - - await resolve_attack(self.session, P1, attacker, VANILLA_ATTACK, - VANILLA_ATTACK.ability_id) - - self.assertEqual(defender.get_attribute(AttrID.HP), 60) - cake = next(m for m in self.attack_bracket_for(P1) - if m["name"] == "CakeAttackEffect") - self.assertTrue(cake["value"]["resistanceTrigger"]) - self.assertEqual(cake["value"]["damageAmount"], 0) - - async def test_damaged_entity_serializes_max_as_original_value(self): - attacker = self.add_to("activePokemonArea", ATTACKER, P1) - defender = self.add_to("activePokemonArea", WEAK_DEFENDER, P2) - await resolve_attack(self.session, P1, attacker, VANILLA_ATTACK, - VANILLA_ATTACK.ability_id) - - hp_attr = next(a for a in defender.serialize_attributes() - if a["name"] == AttrID.HP.value) - self.assertEqual(hp_attr["value"], 120) - self.assertEqual(hp_attr["originalValue"], 180) - - -class TestUnimplementedAttack(EffectsTestBase): - async def test_resolves_base_damage_and_logs(self): - attacker = self.add_to("activePokemonArea", ATTACKER, P1) - defender = self.add_to("activePokemonArea", WEAK_DEFENDER, P2) - - with self.assertLogs(level="WARNING") as logs: - await resolve_attack(self.session, P1, attacker, UNIMPLEMENTED_ATTACK, - UNIMPLEMENTED_ATTACK.ability_id) - self.assertTrue(any("unimplemented" in line for line in logs.output)) - # 20 base doubled by weakness = 40; 180 -> 140. - self.assertEqual(defender.get_attribute(AttrID.HP), 140) - - -class TestKnockout(EffectsTestBase): - async def test_knockout_discards_stack_with_attachments(self): - attacker = self.add_to("activePokemonArea", ATTACKER, P1) - defender = self.add_to("activePokemonArea", PLAIN_DEFENDER, P2) # 30 HP - energy = self.add_to("discard", GRASS_ENERGY, P2) - self.board.attach_card(energy.entity_id, defender.entity_id) - - await resolve_attack(self.session, P1, attacker, VANILLA_ATTACK, - VANILLA_ATTACK.ability_id) - - self.assertEqual(defender.get_attribute(AttrID.HP), 0) - discard = self.board.find_player_area(P2, "discard") - self.assertIn(defender, discard.children) - self.assertIn(energy, discard.children) - self.assertIsNone(self.board.active_pokemon(P2)) - - knockouts = self.brackets("Knockout") - self.assertEqual(len(knockouts), 1) - moved_ids = [m["value"]["entityID"] for m in knockouts[0][2]] - self.assertEqual(moved_ids, [defender.entity_id, energy.entity_id]) - - -class TestCustomEffect(EffectsTestBase): - async def test_effect_can_deal_damage_and_draw(self): - async def effect(ctx): - await ctx.deal_damage(10) - await ctx.draw_cards(1) - - attack = Attack("Custom", cost={PokemonTypes.GRASS: 1}, - damage=10, effect=effect) - attack.ability_id = "00000000-0000-0000-0000-00000000ab01" - attacker = self.add_to("activePokemonArea", ATTACKER, P1) - defender = self.add_to("activePokemonArea", WEAK_DEFENDER, P2) - deck_card = self.add_to("deck", GRASS_ENERGY, P1) - - await resolve_attack(self.session, P1, attacker, attack, attack.ability_id) - - # 10 doubled by weakness = 20; 180 -> 160. - self.assertEqual(defender.get_attribute(AttrID.HP), 160) - hand = self.board.find_player_area(P1, "hand") - self.assertIn(deck_card, hand.children) - - # Draws flush as their own Draw bracket after the attack; faces - # reveal only to the drawing player, and the moves ride a nested - # GroupedMove (a flat move would take the flip fan's default curve). - p1_names = [m["name"] for m in self.attack_bracket_for(P1)] - self.assertNotIn("EntityIntroduced", p1_names) - draw_brackets = self.brackets("Draw") - self.assertEqual(len(draw_brackets), 2) - for viewer_ids, _, msgs in draw_brackets: - nested = [m for m in msgs if isinstance(m, NestedSequence)] - self.assertEqual([n.name for n in nested], ["GroupedMove"]) - names = [m["name"] for m in self.flat_msgs(msgs)] - if viewer_ids == [P1]: - self.assertIn("EntityIntroduced", names) - else: - self.assertNotIn("EntityIntroduced", names) - self.assertIn("EntityMoved", [m["name"] for m in nested[0].messages]) - - async def test_non_damaging_attack_carries_orb_at_the_deck(self): - async def read_wind(ctx): - await ctx.draw_cards(1) - - attack = Attack("Wind", cost={PokemonTypes.GRASS: 1}, effect=read_wind) - attack.ability_id = "00000000-0000-0000-0000-00000000ab03" - attacker = self.add_to("activePokemonArea", ATTACKER, P1) - self.add_to("activePokemonArea", PLAIN_DEFENDER, P2) - self.add_to("deck", GRASS_ENERGY, P1) - - await resolve_attack(self.session, P1, attacker, attack, attack.ability_id) - - # No CakeAttackEffect means M.N only clears the pulled-out attacker - # via the r.u orb -- built from NonDamagingTargetsEffect's targets. - deck = self.board.find_player_area(P1, "deck") - for pid in (P1, P2): - msgs = self.attack_bracket_for(pid) - orb = next(m for m in msgs if m["name"] == "NonDamagingTargetsEffect") - self.assertEqual(orb["value"]["targets"], [deck.entity_id]) - - async def test_bench_damage_skips_modifiers(self): - async def snipe(ctx): - await ctx.deal_damage(30, target=ctx.opponent_bench()[0]) - - attack = Attack("Snipe", cost={PokemonTypes.GRASS: 1}, effect=snipe) - attack.ability_id = "00000000-0000-0000-0000-00000000ab02" - attacker = self.add_to("activePokemonArea", ATTACKER, P1) - self.add_to("activePokemonArea", PLAIN_DEFENDER, P2) - benched = self.add_to("bench", WEAK_DEFENDER, P2) - - await resolve_attack(self.session, P1, attacker, attack, attack.ability_id) - - # Weakness does not apply off-Active by default: 180 - 30. - self.assertEqual(benched.get_attribute(AttrID.HP), 150) - - -class TestOnPlayAbility(EffectsTestBase): - def make_on_play_pokemon(self): - async def dark_asset(ctx): - if await ctx.ask_yes_no("Draw cards until you have 6 cards in your hand?"): - await ctx.draw_until(6) - - card_def = PokemonCardDef( - guid="00000000-0000-0000-0000-0000000000c1", - key="BW1", name="com.test.pokemon.CrobatV.Name", - collector_number=5, set_code="BW1", rarity=3, - hp=180, elements=[PokemonTypes.DARKNESS], - abilities=[ - Ability("Dark Asset", "darkasset.text", - trigger=Triggers.ON_PLAY, effect=dark_asset), - ], - ) - return make_card(card_def), card_def.abilities[0] - - async def test_draws_until_six_with_orb_choreography(self): - card, ability = self.make_on_play_pokemon() - pokemon = self.add_to("bench", card, P1) - for _ in range(2): - self.add_to("hand", GRASS_ENERGY, P1) - for _ in range(6): - self.add_to("deck", GRASS_ENERGY, P1) - - await resolve_on_play_ability(self.session, P1, pokemon, ability) - - hand = self.board.find_player_area(P1, "hand") - self.assertEqual(len(hand.children), 6) - - # The "you may" prompt went to the ability's owner as a Yes/No dialog. - self.assertEqual(len(self.session.prompts), 1) - prompt_player, _, buttons = self.session.prompts[0] - self.assertEqual(prompt_player, P1) - self.assertEqual(buttons, ["Yes", "No"]) - - # Attack bracket per viewer: markers + the orb shot at the deck; the - # playmat attack-source attr points at the ability's Pokemon. - self.assertEqual(self.session.attack_sources, [pokemon.entity_id]) - attack_brackets = self.brackets("Attack") - self.assertEqual(len(attack_brackets), 2) - deck = self.board.find_player_area(P1, "deck") - for _, _, msgs in attack_brackets: - names = [m["name"] for m in msgs] - self.assertEqual( - names, - ["AbilityPlayedEffect", "NonDamagingTargetsEffect", - "AbilityFinishedEffect"], - ) - head = msgs[0]["value"] - self.assertEqual(head["abilityType"], "PokeAbility") - self.assertEqual(head["abilityID"], ability.ability_id) - self.assertEqual(msgs[1]["value"]["targets"], [deck.entity_id]) - - # A Draw bracket per viewer carries the draws (moves nested in a - # GroupedMove child); drawn faces reveal only to the drawing player. - draw_brackets = self.brackets("Draw") - self.assertEqual(len(draw_brackets), 2) - for viewer_ids, _, msgs in draw_brackets: - nested = [m for m in msgs if isinstance(m, NestedSequence)] - self.assertEqual([n.name for n in nested], ["GroupedMove"]) - names = [m["name"] for m in self.flat_msgs(msgs)] - if viewer_ids == [P1]: - self.assertEqual(names.count("EntityIntroduced"), 4) - else: - self.assertNotIn("EntityIntroduced", names) - self.assertEqual(names.count("EntityMoved"), 4) - - async def test_declining_the_prompt_draws_nothing(self): - card, ability = self.make_on_play_pokemon() - pokemon = self.add_to("bench", card, P1) - deck_card = self.add_to("deck", GRASS_ENERGY, P1) - self.session.choice_replies.append(1) # "No" - - await resolve_on_play_ability(self.session, P1, pokemon, ability) - - deck = self.board.find_player_area(P1, "deck") - self.assertIn(deck_card, deck.children) - # The ability still announces itself (popin/gamelog), but no orb plays. - self.assertEqual(len(self.brackets("PokeAbility")), 2) - self.assertEqual(self.brackets("Attack"), []) - - async def test_hand_already_full_draws_nothing(self): - card, ability = self.make_on_play_pokemon() - pokemon = self.add_to("bench", card, P1) - for _ in range(6): - self.add_to("hand", GRASS_ENERGY, P1) - deck_card = self.add_to("deck", GRASS_ENERGY, P1) - - await resolve_on_play_ability(self.session, P1, pokemon, ability) - - deck = self.board.find_player_area(P1, "deck") - self.assertIn(deck_card, deck.children) - - async def test_unimplemented_on_play_ability_is_skipped(self): - ability = Ability("Mystery", "mystery.text", - trigger=Triggers.ON_PLAY, effect=unimplemented) - ability.ability_id = "00000000-0000-0000-0000-00000000ab04" - pokemon = self.add_to("bench", PLAIN_DEFENDER, P1) - - with self.assertLogs(level="WARNING") as logs: - await resolve_on_play_ability(self.session, P1, pokemon, ability) - self.assertTrue(any("unimplemented" in line for line in logs.output)) - self.assertEqual(self.brackets("PokeAbility"), []) - - -class TestHeal(EffectsTestBase): - async def test_heal_caps_at_max_hp(self): - async def mend(ctx): - await ctx.heal(40) - - attack = Attack("Mend", cost={PokemonTypes.GRASS: 1}, effect=mend) - attack.ability_id = "00000000-0000-0000-0000-00000000ab03" - attacker = self.add_to("activePokemonArea", ATTACKER, P1) - self.add_to("activePokemonArea", PLAIN_DEFENDER, P2) - attacker.set_attribute(AttrID.HP, 40) # 20 damage on a 60 HP Pokemon - - await resolve_attack(self.session, P1, attacker, attack, attack.ability_id) - - self.assertEqual(attacker.get_attribute(AttrID.HP), 60) - - -class TestChooserRouting(EffectsTestBase): - """choose_cards routes in-play targets (incl. attachments and the Stadium) - to the in-place picker: the reveal browser can never display them (their - renders belong to higher-layer playmat requesters -> empty carousel).""" - - def ctx_for(self, player_id=P1): - source = self.add_to("hand", ATTACKER, player_id) - return EffectContext(self.session, player_id, source, None) - - def attach(self, card, pokemon, player_id): - entity = create_card_entity(card, owning_player_id=player_id) - self.board.add_card_to_area(entity, self.board.find_player_area(player_id, "hand")) - self.board.attach_card(entity.entity_id, pokemon.entity_id) - return entity - - async def test_attached_energy_uses_in_place_picker(self): - ctx = self.ctx_for() - active = self.add_to("activePokemonArea", WEAK_DEFENDER, P2) - benched = self.add_to("bench", PLAIN_DEFENDER, P2) - energies = [self.attach(GRASS_ENERGY, active, P2), - self.attach(GRASS_ENERGY, benched, P2)] - - picks = await ctx.choose_cards(energies, 1, minimum=1, prompt="pick") - - self.assertEqual(len(self.session.entity_picker_calls), 1) - self.assertEqual(picks[0].entity_id, energies[0].entity_id) - - async def test_tools_and_stadium_mix_uses_in_place_picker(self): - ctx = self.ctx_for() - active = self.add_to("activePokemonArea", WEAK_DEFENDER, P2) - tool = self.attach(GRASS_ENERGY, active, P2) - stadium = create_card_entity(ATTACKER, owning_player_id=P2) - self.board.add_card_to_area(stadium, self.board.find_global_area("activeStadium")) - - await ctx.choose_cards([tool, stadium], 1, minimum=1, prompt="pick") - - self.assertEqual(len(self.session.entity_picker_calls), 1) - - async def test_discard_pile_still_uses_browser(self): - ctx = self.ctx_for() - discarded = self.add_to("discard", PLAIN_DEFENDER, P1) - - await ctx.choose_cards([discarded], 1, prompt="pick") - - self.assertEqual(self.session.entity_picker_calls, []) - self.assertEqual(len(self.session.chooser_calls), 1) - - async def test_attachment_on_discarded_stack_uses_browser(self): - ctx = self.ctx_for() - discarded = self.add_to("discard", PLAIN_DEFENDER, P1) - energy = self.attach(GRASS_ENERGY, discarded, P1) - - await ctx.choose_cards([energy], 1, prompt="pick") - - self.assertEqual(self.session.entity_picker_calls, []) - - async def test_opponent_hand_uses_browser(self): - ctx = self.ctx_for() - opp_card = self.add_to("hand", PLAIN_DEFENDER, P2) - - await ctx.choose_cards([opp_card], 1, prompt="pick") - - self.assertEqual(self.session.entity_picker_calls, []) - - -class TestHisuianHeavyBall(EffectsTestBase): - """look_at_prizes_take_basic drives the prize-fan reveal/pick/swap/shuffle.""" - - def _inner_names(self): - names = [] - for _viewers, _name, msgs in self.session.sent: - names.extend(m["name"] for m in self.flat_msgs(msgs)) - return names - - def _reset_targets(self): - out = [] - for viewer_ids, _name, msgs in self.session.sent: - for m in self.flat_msgs(msgs): - if m["name"] == OutboundMsg.ATTRIBUTES_RESET.value: - out.extend((v, m["value"]["entityID"]) for v in viewer_ids) - return out - - async def test_reveal_basic_takes_it_and_swaps_in(self): - source = self.add_to("hand", ATTACKER, P1) - ctx = EffectContext(self.session, P1, source, None) - basic = self.add_to("prizePile", WEAK_DEFENDER, P1) - energy = self.add_to("prizePile", GRASS_ENERGY, P1) - - took = await ctx.look_at_prizes_take_basic() - - self.assertTrue(took) - # The offer reveals every Prize but only the Basic is selectable. - _pid, prize_ids, selectable = self.session.prize_reveal_calls[0] - self.assertEqual(set(prize_ids), {basic.entity_id, energy.entity_id}) - self.assertEqual(selectable, [basic.entity_id]) - # Basic -> hand; Heavy Ball takes the vacated face-down Prize slot. - hand = self.board.find_player_area(P1, "hand") - prize = self.board.find_player_area(P1, "prizePile") - self.assertIn(basic, hand.children) - self.assertIn(source, prize.children) - self.assertNotIn(basic, prize.children) - # The Basic is taken via WithOpenPrizeCards -- that bracket closes the - # prize present (a plain move leaves the fan on screen). - wopc = [s for s in self.session.sent if s[1] == "WithOpenPrizeCards"] - self.assertTrue(wopc) - moved = {m["value"]["entityID"] for _v, _n, msgs in wopc - for m in msgs if m["name"] == OutboundMsg.ENTITY_MOVED.value} - self.assertIn(basic.entity_id, moved) - # The revealed Prizes are re-hidden and then shuffled. - inner = self._inner_names() - self.assertIn(OutboundMsg.ATTRIBUTES_RESET.value, inner) - self.assertIn(OutboundMsg.SHUFFLED.value, inner) - # The swapped-in Heavy Ball is re-hidden for the OPPONENT too (it sat - # revealed on the trainer slot). - self.assertIn((P2, source.entity_id), self._reset_targets()) - - async def test_decline_keeps_card_for_discard_but_shuffles(self): - source = self.add_to("hand", ATTACKER, P1) - ctx = EffectContext(self.session, P1, source, None) - basic = self.add_to("prizePile", WEAK_DEFENDER, P1) - self.add_to("prizePile", GRASS_ENERGY, P1) - self.session.prize_reveal_replies = [None] - - took = await ctx.look_at_prizes_take_basic() - - self.assertFalse(took) - prize = self.board.find_player_area(P1, "prizePile") - hand = self.board.find_player_area(P1, "hand") - self.assertIn(source, hand.children) # left for the normal discard - self.assertNotIn(source, prize.children) - self.assertIn(basic, prize.children) - inner = self._inner_names() - self.assertIn(OutboundMsg.ATTRIBUTES_RESET.value, inner) - self.assertIn(OutboundMsg.SHUFFLED.value, inner) - - async def test_no_basic_present_declines(self): - source = self.add_to("hand", ATTACKER, P1) - ctx = EffectContext(self.session, P1, source, None) - self.add_to("prizePile", GRASS_ENERGY, P1) - self.add_to("prizePile", GRASS_ENERGY, P1) - - took = await ctx.look_at_prizes_take_basic() - - self.assertFalse(took) - _pid, _prize_ids, selectable = self.session.prize_reveal_calls[0] - self.assertEqual(selectable, []) - - async def test_no_prizes_is_a_noop(self): - source = self.add_to("hand", ATTACKER, P1) - ctx = EffectContext(self.session, P1, source, None) - - took = await ctx.look_at_prizes_take_basic() - - self.assertFalse(took) - self.assertEqual(self.session.prize_reveal_calls, []) - - -class TestLeavePlayResetsAbilityUsage(EffectsTestBase): - """A Pokemon that used a once-per-turn ability, then left play (Scoop Up - Net -> hand), is a fresh card: its usage resets so a replay can use it.""" - - async def test_put_in_hand_clears_once_per_turn_usage(self): - pokemon = self.add_to("activePokemonArea", ATTACKER, P1) - action_id = "11111111-2222-3333-4444-555555555555" - self.session.turn_state.used_abilities.add((pokemon.entity_id, action_id)) - - ctx = EffectContext(self.session, P1, pokemon, None) - await ctx.put_in_hand([pokemon], reveal=False) - - self.assertNotIn( - (pokemon.entity_id, action_id), self.session.turn_state.used_abilities - ) - - async def test_other_pokemon_usage_survives(self): - scooped = self.add_to("activePokemonArea", ATTACKER, P1) - other = self.add_to("bench", ATTACKER, P1) - action_id = "11111111-2222-3333-4444-555555555555" - self.session.turn_state.used_abilities.add((scooped.entity_id, action_id)) - self.session.turn_state.used_abilities.add((other.entity_id, action_id)) - - ctx = EffectContext(self.session, P1, scooped, None) - await ctx.put_in_hand([scooped], reveal=False) - - self.assertNotIn( - (scooped.entity_id, action_id), self.session.turn_state.used_abilities - ) - self.assertIn( - (other.entity_id, action_id), self.session.turn_state.used_abilities - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/spirit/tests/test_energy_pips.py b/spirit/tests/test_energy_pips.py deleted file mode 100644 index 6b802f6..0000000 --- a/spirit/tests/test_energy_pips.py +++ /dev/null @@ -1,115 +0,0 @@ -"""Tests for special-energy attachment pip texture generation (auto_bundle).""" - -import os -import shutil -import tempfile -import unittest - -from spirit.game.attributes import PokemonTypes, Rarities -from spirit.game.data_utils import EnergyCardDef, ItemCardDef, PokemonToolCardDef -from spirit.server.auto_bundle import ( - _detect_emblem_circle, - _is_pokemon_tool, - _is_special_energy, - generate_energy_pip_png, - generate_tool_pip_png, -) - -try: - from PIL import Image, ImageDraw - HAS_PIL = True -except ImportError: - HAS_PIL = False - - -SPECIAL_ENERGY = EnergyCardDef( - guid="00000000-0000-0000-0000-0000000000p1", - key="BW1", name="com.test.energy.SpecialPip.Name", - collector_number=169, set_code="BW1", rarity=0, - energy_type=PokemonTypes.COLORLESS, is_special=True, -) - -BASIC_ENERGY = EnergyCardDef( - guid="00000000-0000-0000-0000-0000000000p2", - key="BW1", name="com.test.energy.BasicPip.Name", - collector_number=106, set_code="BW1", rarity=0, - energy_type=PokemonTypes.FIRE, -) - -TOOL_CARD = PokemonToolCardDef( - guid="00000000-0000-0000-0000-0000000000p3", - key="BW1", name="com.test.trainer.ToolPip.Name", - collector_number=156, set_code="BW1", rarity=Rarities.Uncommon, -) - -ITEM_CARD = ItemCardDef( - guid="00000000-0000-0000-0000-0000000000p4", - key="BW1", name="com.test.trainer.ItemPip.Name", - collector_number=157, set_code="BW1", rarity=Rarities.Uncommon, -) - - -class TestSpecialEnergyDetection(unittest.TestCase): - def test_special_energy_flag(self): - self.assertTrue(_is_special_energy(SPECIAL_ENERGY)) - self.assertFalse(_is_special_energy(BASIC_ENERGY)) - - def test_pokemon_tool_flag(self): - self.assertTrue(_is_pokemon_tool(TOOL_CARD)) - self.assertFalse(_is_pokemon_tool(ITEM_CARD)) - self.assertFalse(_is_pokemon_tool(BASIC_ENERGY)) - - -@unittest.skipUnless(HAS_PIL, "Pillow not installed") -class TestPipGeneration(unittest.TestCase): - def setUp(self): - self.temp_dir = tempfile.mkdtemp() - self.src = os.path.join(self.temp_dir, "card.png") - Image.new("RGBA", (734, 1024), (200, 40, 40, 255)).save(self.src) - - def tearDown(self): - shutil.rmtree(self.temp_dir, ignore_errors=True) - - def test_pip_is_a_square_crop_named_energypip(self): - out = generate_energy_pip_png(self.src, "SWSH12", "169", - out_dir=self.temp_dir) - self.assertEqual(os.path.basename(out), "SWSH12_169_energypip.png") - with Image.open(out) as pip: - self.assertEqual(pip.width, pip.height) - self.assertGreater(pip.width, 0) - - def test_tool_pip_is_a_square_crop_named_toolpip(self): - out = generate_tool_pip_png(self.src, "SWSH1", "156", - out_dir=self.temp_dir) - self.assertEqual(os.path.basename(out), "SWSH1_156_toolpip.png") - with Image.open(out) as pip: - self.assertEqual(pip.width, pip.height) - self.assertGreater(pip.width, 0) - - def test_detection_centers_on_a_drawn_emblem(self): - img = Image.new("RGBA", (734, 1024), (240, 240, 240, 255)) - # Off-center circle emblem inside the art window. - ImageDraw.Draw(img).ellipse((150, 250, 500, 600), fill=(30, 60, 200, 255)) - res = _detect_emblem_circle(img) - self.assertIsNotNone(res) - cx, cy, r = res - self.assertAlmostEqual(cx, 325, delta=25) - self.assertAlmostEqual(cy, 425, delta=25) - self.assertAlmostEqual(r, 175, delta=30) - - def test_flat_image_falls_back_without_detection(self): - img = Image.open(self.src) - self.assertIsNone(_detect_emblem_circle(img)) - - def test_pip_is_cached_until_the_source_changes(self): - out = generate_energy_pip_png(self.src, "SWSH12", "169", - out_dir=self.temp_dir) - first_mtime = os.path.getmtime(out) - again = generate_energy_pip_png(self.src, "SWSH12", "169", - out_dir=self.temp_dir) - self.assertEqual(out, again) - self.assertEqual(os.path.getmtime(again), first_mtime) - - -if __name__ == "__main__": - unittest.main() diff --git a/spirit/tests/test_gameplay.py b/spirit/tests/test_gameplay.py deleted file mode 100644 index df26eb5..0000000 --- a/spirit/tests/test_gameplay.py +++ /dev/null @@ -1,1352 +0,0 @@ -import unittest -import asyncio -import uuid -from unittest.mock import MagicMock, AsyncMock - -from spirit.network.message_names import InboundMsg, OutboundMsg -from spirit.network.protocol import WargFlags -from spirit.game.session.game_session import GameSession -from spirit.game.session.manager import GameSessionManager -from spirit.packets.handlers.gameplay import GameplayHandler -from spirit.packets.handlers.data_sync import DataSyncHandler - - -class MockPlayer: - def __init__(self, account_id, username): - self.account_id = account_id - self.username = username - self.screen_name = username - self.avatar_decks = [] - - def get_avatar_decks_data(self): - return {"decks": self.avatar_decks} - - def save_deck_data(self, deck_dict: dict, is_avatar: bool = False): - if is_avatar: - self.avatar_decks.append(deck_dict) - - -class MockClientHandler: - def __init__(self, account_id, username): - self.player = MockPlayer(account_id, username) - self.addr = ("127.0.0.1", 12345) - self.sent_packets = [] - - async def send_packet(self, data, request_id=0, flags=0): - # Handle dict or protobuf - if hasattr(data, "SerializeToString"): - # Mock protobuf payload - self.sent_packets.append({ - "protobuf": data.__class__.__name__, - "request_id": request_id, - "flags": flags - }) - else: - self.sent_packets.append(data) - - -class TestGameplayFlow(unittest.IsolatedAsyncioTestCase): - async def asyncSetUp(self): - self.session_manager = GameSessionManager() - # Reset session manager state - self.session_manager.active_sessions = {} - self.session_manager.pending_pairings = {} - - async def test_player_ready_handshake(self): - # 1. Setup mock clients and deck - client1 = MockClientHandler("player-1", "Ash") - client2 = MockClientHandler("player-2", "Gary") - - deck1 = {"deckID": "deck-1", "deckName": "Charizard", "cards": [{"guid": "00000000-0000-0000-0000-000000000002", "count": 2}]} - deck2 = {"deckID": "deck-2", "deckName": "Blastoise", "cards": []} - - pairing = { - "is_solo": False, - "format": "Standard", - "players": { - "player-1": {"client": client1, "deck": deck1, "ready": True}, - "player-2": {"client": client2, "deck": deck2, "ready": True} - } - } - - # 2. Spawn GameSession - game_id = str(uuid.uuid4()) - session = GameSession(game_id, pairing) - self.session_manager.active_sessions[game_id] = session - - # Verify initial ready state is empty - self.assertEqual(len(session.ready_players), 0) - - # 3. Simulate client 1 sending PlayerReady - handler1 = GameplayHandler(client1) - await handler1.handle_player_ready({}, request_id=1, flags=WargFlags.CLEAR) - - # Player 1 should be registered as ready - self.assertIn("player-1", session.ready_players) - self.assertEqual(len(session.ready_players), 1) - - # No SerializedGameState should be broadcast yet since player 2 is not ready - self.assertEqual(len(client1.sent_packets), 0) - - # 4. Simulate client 2 sending PlayerReady - handler2 = GameplayHandler(client2) - await handler2.handle_player_ready({}, request_id=2, flags=WargFlags.CLEAR) - - # Yield back to the event loop to allow the background gameplay task to start - await asyncio.sleep(0.01) - - # Both ready, should trigger SerializedGameState broadcast - self.assertIn("player-2", session.ready_players) - self.assertEqual(len(session.ready_players), 2) - - # Verify both players received the SerializedGameState packet. - # The caller receives 2 packets (SerializedGameState + the wrapped - # CoinFlipChoiceRequired prompt); the non-caller receives 4 (the - # SerializedGameState + the complete OpponentPickingHeadsOrTails - # bracket: StartSequence + CustomChoiceOfferMessage + StopSequence). - self.assertIn(len(client1.sent_packets), [2, 4]) - self.assertIn(len(client2.sent_packets), [2, 4]) - self.assertEqual(len(client1.sent_packets) + len(client2.sent_packets), 6) - - # Every match packet must ride a SequenceMessage: bare messages are - # executed twice by the client (inline + queued pump). - for client in (client1, client2): - for packet in client.sent_packets: - self.assertEqual(packet["messageName"], "SequenceMessage") - - # The caller's prompt is a standalone empty-GUID SequenceMessage; the - # non-caller's bracket is complete (Start ... Stop) so the sequence - # pump renders the waiting screen immediately. - caller_client, waiting_client = ( - (client1, client2) if len(client1.sent_packets) == 2 else (client2, client1) - ) - prompt = caller_client.sent_packets[1] - self.assertEqual(prompt["sequenceID"], "00000000-0000-0000-0000-000000000000") - self.assertEqual(prompt["msg"]["name"], "CoinFlipChoiceRequired") - self.assertEqual(len(prompt["msg"]["value"]["buttons"]), 2) - - bracket = waiting_client.sent_packets[1:] - self.assertEqual(bracket[0]["msg"]["name"], "StartSequence") - self.assertEqual(bracket[0]["msg"]["value"]["name"], "OpponentPickingHeadsOrTails") - self.assertEqual(bracket[1]["msg"]["name"], "CustomChoiceOfferMessage") - self.assertIsNone(bracket[1]["msg"]["value"]["sourceEntity"]) - self.assertEqual(bracket[2]["msg"]["name"], "StopSequence") - # All three share the same (non-empty) sequence ID. - self.assertEqual(len({p["sequenceID"] for p in bracket}), 1) - self.assertNotEqual(bracket[0]["sequenceID"], "00000000-0000-0000-0000-000000000000") - - # The initial state must be wrapped in a standalone SequenceMessage with the - # empty sequence ID; a bare SerializedGameState is applied twice by the - # client and the second apply kills the sequence pump. - gs_packet = client1.sent_packets[0] - self.assertEqual(gs_packet["messageName"], "SequenceMessage") - self.assertEqual(gs_packet["sequenceID"], "00000000-0000-0000-0000-000000000000") - self.assertEqual(gs_packet["gameID"], game_id) - self.assertEqual(gs_packet["msg"]["name"], "SerializedGameState") - self.assertEqual(gs_packet["msg"]["value"]["gameID"], game_id) - - # Validate tree structure of Playmat root entity - entities = gs_packet["msg"]["value"]["entities"] - self.assertEqual(entities["entityName"], "com.direwolfdigital.cake.rules.entities.CakePlayMat") - - # Validate children areas: PlayerEntities - children_names = [child["entityName"] for child in entities["children"]] - self.assertIn("com.direwolfdigital.cake.rules.entities.CakePlayerEntity", children_names) - - # Confirm PlayAreas exist - self.assertTrue(any(child["entityName"] == "com.direwolfdigital.game.core.PlayArea" for child in entities["children"])) - - async def test_full_pregame_coin_flip_flow(self): - """Drives the entire pregame flow: coin call -> flip -> go-first -> - ActivePlayerSet, and verifies the wire choreography the client's - sequence pump and Selection system require.""" - from unittest.mock import patch - from spirit.game.models.card import Card - from spirit.game.attributes import AttrID, CardType, PokemonStage - from spirit.game.scripts.cards import loader as card_loader - - # Register a Basic Pokemon so both decks have real, drawable cards. - mon = Card( - guid="e2e-basic-mon", - key="X", - display_name="Testmon", - attributes={ - str(AttrID.CARD_TYPE.value): {"value": CardType.POKEMON.value}, - str(AttrID.STAGE.value): {"value": PokemonStage.BASIC.value}, - }, - ) - card_loader.cards_by_guid[mon.guid] = mon - deck = {"cards": [{"guid": mon.guid, "count": 20}]} - - client1 = MockClientHandler("player-1", "Ash") - client2 = MockClientHandler("player-2", "Gary") - pairing = { - "is_solo": False, - "format": "Standard", - "players": { - "player-1": {"client": client1, "deck": dict(deck, deckID="d1", deckName="A"), "ready": True}, - "player-2": {"client": client2, "deck": dict(deck, deckID="d2", deckName="B"), "ready": True}, - }, - } - - game_id = str(uuid.uuid4()) - session = GameSession(game_id, pairing) - self.session_manager.active_sessions[game_id] = session - - clients = {"player-1": client1, "player-2": client2} - - # Neutralize the 3s animation delay without breaking the test's own - # waits (game_session shares the global asyncio module). - real_sleep = asyncio.sleep - with patch( - "spirit.game.session.game_session.asyncio.sleep", - new=lambda *_args, **_kwargs: real_sleep(0), - ): - await session.mark_player_ready("player-1") - await session.mark_player_ready("player-2") - await real_sleep(0.05) - - caller_id = session.coin_flip_caller_id - self.assertIsNotNone(caller_id) - - # Caller picks Heads (button 0). The live client reports the picked - # button index in the "selection" field of GameCustomChoice. - await session.receive_player_action(caller_id, {"selection": 0, "counter": 1}) - await real_sleep(0.05) - - winner_id = session.coin_flip_winner_id - self.assertIsNotNone(winner_id) - - # Winner chooses to go first (button 0 = Yes) - await session.receive_player_action(winner_id, {"selection": 0, "counter": 2}) - await real_sleep(0.05) - - # Both decks have Basics in the opening hand, so no mulligans; the - # session moves straight to concurrent Active placement offers. - self.assertEqual(session.game_phase, "PLACEMENT_PHASE") - - def latest_offer(client): - for p in reversed(client.sent_packets): - if ( - p.get("messageName") == "SequenceMessage" - and p["msg"]["name"] == "SelectionWithTargetsRequired" - ): - return p["msg"]["value"] - return None - - def pause_prompts(client): - return [ - p["msg"]["value"]["prompt"]["id"] for p in client.sent_packets - if p.get("messageName") == "SequenceMessage" - and p["msg"]["name"] == "PauseOnPromptEffect" - ] - - # Each player places the first offered Basic as their Active. - # The offer is a single-level SelectionWithTargetsRequired whose - # targetMap keys are the draggable basics and whose targetType - # (node Kind) drives the client's active-spot drop machinery. - for pid, client in clients.items(): - offer = latest_offer(client) - self.assertIsNotNone(offer, f"{pid} got no active placement offer") - self.assertEqual(offer["targetType"], "KnockoutPokemonTargetInformation") - # forced=false keeps the Next button label "Done"; the button is - # actually hidden by the PauseOnPromptEffect override, which must - # precede the offer. - self.assertFalse(offer["forced"]) - self.assertIn( - "Choose a Basic Pokémon to be your Active Pokémon", - pause_prompts(client), - ) - card_id = next(iter(offer["targetMap"].keys())) - await session.receive_player_action(pid, { - "gameID": game_id, - "selection": {"entityID": card_id, "targetResponses": []}, - "counter": offer["counter"], - }) - await real_sleep(0.05) - - # The first player to finish waits on the opponent (center prompt); - # the barrier then clears the overrides on both clients before bench. - all_pause_texts = [t for c in clients.values() for t in pause_prompts(c)] - self.assertIn( - "Please wait while your opponent chooses an Active Pokémon.", - all_pause_texts, - ) - for pid, client in clients.items(): - closes = [ - p for p in client.sent_packets - if p.get("messageName") == "SequenceMessage" - and p["msg"]["name"] == "ClosePauseOnPromptEffect" - ] - self.assertTrue(closes, f"{pid} never got ClosePauseOnPromptEffect") - - # Both decline the (optional) bench offer: selection null = Done. - for pid, client in clients.items(): - offer = latest_offer(client) - self.assertIsNotNone(offer, f"{pid} got no bench offer") - self.assertEqual(offer["targetType"], "InitialBenchedTargetInformation") - self.assertFalse(offer["forced"]) - await session.receive_player_action( - pid, {"gameID": game_id, "selection": None, "counter": offer["counter"]} - ) - await real_sleep(0.05) - - # Setup finished and the session moved straight into the turn loop - # (turn 1's action offer is pending on the active player). - self.assertEqual(session.game_phase, "TURN_LOOP") - - # The winner chose "Yes" (go first), so every active-player - # announcement (pregame + turn 1's begin-turn bracket) names the - # winner. - active_player_ids = [ - p["msg"]["value"]["accountID"] for p in client1.sent_packets - if p.get("messageName") == "SequenceMessage" - and p["msg"]["name"] == "ActivePlayerSet" - ] - self.assertTrue(active_player_ids) - self.assertTrue(all(pid == winner_id for pid in active_player_ids)) - - def sequence_names(client): - return [ - p["msg"]["value"]["name"] for p in client.sent_packets - if p.get("messageName") == "SequenceMessage" and p["msg"]["name"] == "StartSequence" - ] - - def message_names(client): - return [ - p["msg"]["name"] for p in client.sent_packets - if p.get("messageName") == "SequenceMessage" - ] - - loser_id = next(pid for pid in ("player-1", "player-2") if pid != winner_id) - winner_client, loser_client = clients[winner_id], clients[loser_id] - - # ForceSelectionFinished must never be sent: on the client without an - # active selection it bumps the skip counter that swallows the next - # selection offer (m.d / E.a). - for client in (client1, client2): - self.assertNotIn("ForceSelectionFinished", message_names(client)) - # Both clients see the flip and the active-player announcement. - self.assertIn("InitialCoinFlip", sequence_names(client)) - self.assertIn("ActivePlayerSet", sequence_names(client)) - # Every bracket sent to a client must be complete (Start/Stop pairs). - names = message_names(client) - self.assertEqual(names.count("StartSequence"), names.count("StopSequence")) - - # Selection prompts go only to the choosing player; waiting sequences - # go only to the other player. - caller_client = clients[caller_id] - opponent_client = clients[next(pid for pid in ("player-1", "player-2") if pid != caller_id)] - self.assertIn("CoinFlipChoiceRequired", message_names(caller_client)) - self.assertNotIn("CoinFlipChoiceRequired", message_names(opponent_client)) - self.assertIn("OpponentPickingHeadsOrTails", sequence_names(opponent_client)) - self.assertNotIn("OpponentPickingHeadsOrTails", sequence_names(caller_client)) - - self.assertIn("GoFirstChoiceRequired", message_names(winner_client)) - self.assertNotIn("GoFirstChoiceRequired", message_names(loser_client)) - self.assertIn("OpponentChoosingToGoFirst", sequence_names(loser_client)) - self.assertNotIn("OpponentChoosingToGoFirst", sequence_names(winner_client)) - - # Setup phase: each deck's shuffle animation fires (as a standalone - # empty-GUID Shuffled message) BEFORE the DealInitialHands bracket. - for client in (client1, client2): - packets = client.sent_packets - shuffle_idx = [ - i for i, p in enumerate(packets) - if p.get("messageName") == "SequenceMessage" - and p["msg"]["name"] == "Shuffled" - ] - deal_start_idx = next( - i for i, p in enumerate(packets) - if p.get("messageName") == "SequenceMessage" - and p["msg"]["name"] == "StartSequence" - and p["msg"]["value"]["name"] == "DealInitialHands" - ) - # Both players' decks shuffle, and every shuffle precedes the deal. - self.assertEqual(len(shuffle_idx), 2) - self.assertTrue(all(i < deal_start_idx for i in shuffle_idx)) - for i in shuffle_idx: - self.assertEqual(packets[i]["sequenceID"], "00000000-0000-0000-0000-000000000000") - - def bracket_inner(client, seq_name): - """Messages between the first Start/Stop pair of the named bracket.""" - msgs, in_bracket = [], False - for p in client.sent_packets: - if p.get("messageName") != "SequenceMessage": - continue - m = p["msg"] - if m["name"] == "StartSequence" and m["value"]["name"] == seq_name: - in_bracket = True - continue - if m["name"] == "StopSequence" and m["value"]["name"] == seq_name: - break - if in_bracket: - msgs.append(m) - return msgs - - # Setup phase: EntityIntroduced reveals for the viewer's own 7 cards - # arrive as standalone empty-GUID messages BEFORE the bracket (the - # DealInitialHands executor runs GroupedMove children before flat - # commands, so in-bracket intros would only apply after the deal - # animation), and the 14 moves ride two GroupedMove children -- one - # per player -- which the client executes in PARALLEL so both hands - # fan out simultaneously. - for pid, client in clients.items(): - self.assertIn("DealInitialHands", sequence_names(client)) - seq_packets = [ - p for p in client.sent_packets - if p.get("messageName") == "SequenceMessage" - ] - deal_start = next( - i for i, p in enumerate(seq_packets) - if p["msg"]["name"] == "StartSequence" - and p["msg"]["value"]["name"] == "DealInitialHands" - ) - intros = [ - p for p in seq_packets[:deal_start] - if p["msg"]["name"] == "EntityIntroduced" - ] - self.assertEqual(len(intros), 7) - # Only the recipient's own cards are revealed, as standalone - # (empty-GUID) messages so they apply before the deal animates. - hand = session.board_state.find_player_area(pid, "hand") - active = session.board_state.find_player_area(pid, "activePokemonArea") - own_ids = {c.entity_id for c in hand.children} | {c.entity_id for c in active.children} - for intro in intros: - self.assertEqual(intro["sequenceID"], "00000000-0000-0000-0000-000000000000") - self.assertIn(intro["msg"]["value"]["entityID"], own_ids) - self.assertTrue(intro["msg"]["value"]["attributeMap"]) - - deal_inner = bracket_inner(client, "DealInitialHands") - group_starts = [ - m for m in deal_inner - if m["name"] == "StartSequence" and m["value"]["name"] == "GroupedMove" - ] - deal_moves = [m for m in deal_inner if m["name"] == "EntityMoved"] - self.assertEqual(len(group_starts), 2) - self.assertEqual(len(deal_moves), 14) - # 2 GroupedMove children (Start + 7 moves + Stop each). - self.assertEqual(len(deal_inner), 18) - self.assertNotIn("EntityIntroduced", [m["name"] for m in deal_inner]) - - # Placement: each placed Active is echoed in an InitialPick bracket to - # both clients, and each client is shown the OPPONENT's placed card via - # exactly one EntityIntroduced inside IntroduceInitialPokemon. - for pid, client in clients.items(): - self.assertIn("InitialPick", sequence_names(client)) - introduced = bracket_inner(client, "IntroduceInitialPokemon") - self.assertEqual([m["name"] for m in introduced], ["EntityIntroduced"]) - opponent_id = next(p for p in clients if p != pid) - opp_active = session.board_state.find_player_area(opponent_id, "activePokemonArea") - self.assertEqual(introduced[0]["value"]["entityID"], opp_active.children[0].entity_id) - # The reveal carries the full render attributes inline. - self.assertTrue(introduced[0]["value"]["attributeMap"]) - - # Prizes: 6 per player dealt in one DealInitialPrizeCards bracket. - # This uses two GroupedMove children (one per player) to deal prizes in parallel. - for client in (client1, client2): - prize_inner = bracket_inner(client, "DealInitialPrizeCards") - group_starts = [ - m for m in prize_inner - if m["name"] == "StartSequence" and m["value"]["name"] == "GroupedMove" - ] - prize_moves = [m for m in prize_inner if m["name"] == "EntityMoved"] - self.assertEqual(len(group_starts), 2) - self.assertEqual(len(prize_moves), 12) - # 2 GroupedMove children (Start + 6 moves + Stop each) - self.assertEqual(len(prize_inner), 16) - - # Server-side board reflects the full setup: 6 in hand (7 - active), - # 1 active, 6 prizes, 7 left in deck (20 - 7 - 6) -- plus the turn-1 - # draw for the going-first player (the winner chose to go first). - for pid in ("player-1", "player-2"): - turn_draw = 1 if pid == winner_id else 0 - self.assertEqual(len(session.board_state.find_player_area(pid, "hand").children), 6 + turn_draw) - self.assertEqual(len(session.board_state.find_player_area(pid, "activePokemonArea").children), 1) - self.assertEqual(len(session.board_state.find_player_area(pid, "bench").children), 0) - self.assertEqual(len(session.board_state.find_player_area(pid, "prizePile").children), 6) - self.assertEqual(len(session.board_state.find_player_area(pid, "deck").children), 7 - turn_draw) - - # ---- Turn 1: only the going-first player gets the action offer ---- - def latest_action_offer(client): - for p in reversed(client.sent_packets): - if ( - p.get("messageName") == "SequenceMessage" - and p["msg"]["name"] == "SelectionWithTargetsAndActionsRequired" - ): - return p["msg"]["value"] - return None - - offer = latest_action_offer(winner_client) - self.assertIsNotNone(offer, "active player got no main action offer") - self.assertIsNone(latest_action_offer(loser_client)) - # forced=false lets the root advance with nothing selected (End Turn). - self.assertFalse(offer["forced"]) - self.assertEqual(offer["targetType"], "Ability") - # All 7 basics in hand are playable (bench empty); the test Pokemon - # has no attacks/energy so nothing else is offered. - self.assertEqual(len(offer["targetMap"]), 7) - entry = offer["targetMap"][0] - self.assertEqual(entry["selectableAction"]["description"], "DefaultPokemonPlayAbility") - self.assertEqual(entry["selectableAction"]["selectionType"], "Ability") - bench_id = session.board_state.find_player_area(winner_id, "bench").entity_id - self.assertEqual(entry["targetInfoLst"][0]["validTargets"], [bench_id]) - # No prompt on the main offer -- it would render as a stuck banner. - self.assertIsNone(offer["prompt"]) - - # Bench one basic. The reply mirrors Outgoing.SelectionWithTargetsAndActions: - # selection = [[entityID, actionID], [targetResponses...]]. - await session.receive_player_action(winner_id, { - "gameID": game_id, - "selection": [ - [entry["entityID"], entry["selectableAction"]["actionID"]], - [], - ], - "counter": offer["counter"], - }) - await real_sleep(0.05) - - bench = session.board_state.find_player_area(winner_id, "bench") - self.assertEqual([c.entity_id for c in bench.children], [entry["entityID"]]) - # The play is echoed to both viewers in a PlayCard bracket; the - # opponent gets the intro in a preceding SerialSequence bracket (the - # play executor reads the card TYPE before its own bracket executes). - for pid, client in clients.items(): - play_inner = [m["name"] for m in bracket_inner(client, "PlayCard")] - self.assertEqual(play_inner, ["EntityMoved"]) - intro_inner = [m["name"] for m in bracket_inner(client, "SerialSequence")] - if pid == winner_id: - self.assertEqual(intro_inner, []) - else: - self.assertEqual(intro_inner, ["EntityIntroduced"]) - - # A recomputed offer follows: 6 playable cards left, plus a retreat on - # the Active now that the bench is occupied (no RETREAT_COST attr = - # free retreat). Ending the turn (selection null) passes play. - offer2 = latest_action_offer(winner_client) - self.assertNotEqual(offer2["counter"], offer["counter"]) - self.assertEqual(len(offer2["targetMap"]), 7) - retreat_entries = [ - e for e in offer2["targetMap"] - if e["selectableAction"]["description"] == "BaseRetreat" - ] - self.assertEqual(len(retreat_entries), 1) - self.assertEqual( - retreat_entries[0]["targetInfoLst"][0]["name"], - "RetreatNewActiveTargetInformation", - ) - await session.receive_player_action(winner_id, { - "gameID": game_id, "selection": None, "counter": offer2["counter"], - }) - await real_sleep(0.05) - - # Turn 2: the opponent draws their turn card and gets their offer. - self.assertEqual(session.turn_state.turn_number, 2) - self.assertEqual(session.turn_state.active_player_id, loser_id) - self.assertEqual( - len(session.board_state.find_player_area(loser_id, "hand").children), 7 - ) - self.assertIsNotNone(latest_action_offer(loser_client)) - - async def test_draw_and_mulligan_helpers(self): - """BoardState.draw_cards moves cards deck->hand and reports Basic Pokemon presence.""" - from spirit.game.models.board import BoardState - from spirit.game.models.card import Card - from spirit.game.attributes import AttrID, CardType, PokemonStage - from spirit.game.scripts.cards import loader as card_loader - - basic = Card( - guid="basic-mon-guid", - key="X", - display_name="Basicmon", - attributes={ - str(AttrID.CARD_TYPE.value): {"value": CardType.POKEMON.value}, - str(AttrID.STAGE.value): {"value": PokemonStage.BASIC.value}, - }, - ) - card_loader.cards_by_guid[basic.guid] = basic - - board = BoardState("game-draw", ["p1"]) - board.populate_deck("p1", {"cards": [{"guid": basic.guid, "count": 10}]}) - - deck = board.find_player_area("p1", "deck") - self.assertEqual(len(deck.children), 10) - - drawn = board.draw_cards("p1", 7) - self.assertEqual(len(drawn), 7) - self.assertEqual(len(board.find_player_area("p1", "hand").children), 7) - self.assertEqual(len(deck.children), 3) - # Each descriptor targets the hand area with an ascending position. - hand_id = board.find_player_area("p1", "hand").entity_id - self.assertTrue(all(d["destination_id"] == hand_id for d in drawn)) - self.assertEqual([d["position"] for d in drawn], list(range(7))) - - # Hand has Basic Pokemon -> no mulligan required. - self.assertTrue(board.has_basic_pokemon_in_hand("p1")) - - # Shuffling with distinct seeds produces distinct orderings (same deck). - import random as _random - - board_a = BoardState("ga", ["p1"]) - board_a.populate_deck("p1", {"cards": [{"guid": basic.guid, "count": 20}]}) - deck_a = board_a.find_player_area("p1", "deck") - original = [c.entity_id for c in deck_a.children] - board_a.shuffle_deck("p1", rng=_random.Random(1)) - shuffled_1 = [c.entity_id for c in deck_a.children] - board_a.shuffle_deck("p1", rng=_random.Random(2)) - shuffled_2 = [c.entity_id for c in deck_a.children] - # A seeded shuffle changes order and is reproducible. - self.assertNotEqual(original, shuffled_1) - self.assertNotEqual(shuffled_1, shuffled_2) - - # Returning the hand refills the deck. - board.return_hand_to_deck("p1") - self.assertEqual(len(board.find_player_area("p1", "hand").children), 0) - self.assertEqual(len(deck.children), 10) - - async def test_mulligan_loop_reveal_and_extra_draw(self): - """A Basic-less hand mulligans (Mulligan bracket + redraw), the busted - hand is revealed to both clients via MulliganRevealCardsEffect, and the - opponent gets a Yes/No CustomChoiceRequired extra-draw offer (never - MulliganChoiceRequired, which has no client UI).""" - from unittest.mock import patch - from spirit.game.models.board import PokemonEntity - from spirit.game.models.card import Card - from spirit.game.attributes import AttrID, CardType, PokemonStage - from spirit.game.scripts.cards import loader as card_loader - - basic = Card( - guid="mull-basic-guid", - key="X", - display_name="Mullmon", - attributes={ - str(AttrID.CARD_TYPE.value): {"value": CardType.POKEMON.value}, - str(AttrID.STAGE.value): {"value": PokemonStage.BASIC.value}, - }, - ) - trainer = Card( - guid="mull-trainer-guid", - key="X", - display_name="Mullhelper", - attributes={ - str(AttrID.CARD_TYPE.value): {"value": CardType.TRAINER.value}, - }, - ) - card_loader.cards_by_guid[basic.guid] = basic - card_loader.cards_by_guid[trainer.guid] = trainer - - client1 = MockClientHandler("player-1", "Ash") - client2 = MockClientHandler("player-2", "Gary") - pairing = { - "is_solo": False, - "format": "Standard", - "players": { - "player-1": { - "client": client1, - "deck": { - "deckID": "d1", "deckName": "A", - "cards": [ - {"guid": basic.guid, "count": 13}, - {"guid": trainer.guid, "count": 7}, - ], - }, - "ready": True, - }, - "player-2": { - "client": client2, - "deck": { - "deckID": "d2", "deckName": "B", - "cards": [{"guid": basic.guid, "count": 20}], - }, - "ready": True, - }, - }, - } - - game_id = str(uuid.uuid4()) - session = GameSession(game_id, pairing) - board = session.board_state - - # Deterministic opening: player-1's hand is 7 trainers (mulligan), - # player-2's is 7 basics. The mulligan reshuffle puts basics on top. - p1_hand = board.find_player_area("player-1", "hand") - p1_deck = board.find_player_area("player-1", "deck") - for card in [c for c in list(p1_deck.children) if not isinstance(c, PokemonEntity)]: - board.move_card(card.entity_id, p1_hand.entity_id) - board.draw_cards("player-2", 7) - self.assertEqual(len(p1_hand.children), 7) - self.assertFalse(board.has_basic_pokemon_in_hand("player-1")) - - def fake_shuffle(player_id, rng=None): - deck = board.find_player_area(player_id, "deck") - basics = [c for c in deck.children if isinstance(c, PokemonEntity)] - others = [c for c in deck.children if not isinstance(c, PokemonEntity)] - deck.children[:] = others + basics # basics drawn first (top = end) - return True - - real_sleep = asyncio.sleep - with patch.object(board, "shuffle_deck", side_effect=fake_shuffle), patch( - "spirit.game.session.game_session.asyncio.sleep", - new=lambda *_a, **_k: real_sleep(0), - ): - task = asyncio.create_task(session.run_mulligan_phase()) - # Wait for the extra-draw prompt to reach player-2. - for _ in range(100): - await real_sleep(0.01) - prompt = next( - (p["msg"]["value"] for p in reversed(client2.sent_packets) - if p.get("messageName") == "SequenceMessage" - and p["msg"]["name"] == "CustomChoiceRequired"), - None, - ) - if prompt: - break - self.assertIsNotNone(prompt, "player-2 never got the extra-draw offer") - self.assertEqual(prompt.get("kind"), "") - self.assertEqual(prompt.get("offerLength"), 0) - await session.receive_player_action( - "player-2", {"gameID": game_id, "selection": 0, "counter": prompt["counter"]} - ) - await asyncio.wait_for(task, timeout=5) - - def message_names(client): - return [ - p["msg"]["name"] for p in client.sent_packets - if p.get("messageName") == "SequenceMessage" - ] - - def sequence_names(client): - return [ - p["msg"]["value"]["name"] for p in client.sent_packets - if p.get("messageName") == "SequenceMessage" and p["msg"]["name"] == "StartSequence" - ] - - for client in (client1, client2): - # Mulligan bracket (hand back + Shuffled) then a Draw bracket redraw. - self.assertIn("Mulligan", sequence_names(client)) - self.assertIn("Draw", sequence_names(client)) - self.assertIn("Shuffled", message_names(client)) - # The hand-return moves ride a nested GroupedMove bracket inside - # the Mulligan bracket (the Mulligan executor runs inner commands - # sequentially; GroupedMove animates them together). All 7 moves - # must carry the CHILD's sequenceID, opened inside the parent. - envelopes = [ - p for p in client.sent_packets - if p.get("messageName") == "SequenceMessage" - ] - group_start = next( - i for i, p in enumerate(envelopes) - if p["msg"]["name"] == "StartSequence" - and p["msg"]["value"]["name"] == "GroupedMove" - ) - child_id = envelopes[group_start]["msg"]["value"]["sequenceID"] - mull_start = next( - i for i, p in enumerate(envelopes) - if p["msg"]["name"] == "StartSequence" - and p["msg"]["value"]["name"] == "Mulligan" - ) - self.assertLess(mull_start, group_start) - group_moves = envelopes[group_start + 1:group_start + 8] - for move in group_moves: - self.assertEqual(move["msg"]["name"], "EntityMoved") - self.assertEqual(move["sequenceID"], child_id) - self.assertEqual(envelopes[group_start + 8]["msg"]["name"], "StopSequence") - self.assertEqual( - envelopes[group_start + 8]["msg"]["value"]["sequenceID"], child_id - ) - # The busted hand is revealed to BOTH clients (the carousel - # special-cases the owning player), with attributes inline. - self.assertIn("RevealMulligans", sequence_names(client)) - reveal = next( - p["msg"]["value"] for p in client.sent_packets - if p.get("messageName") == "SequenceMessage" - and p["msg"]["name"] == "MulliganRevealCardsEffect" - ) - self.assertEqual(reveal["player"], "player-1") - self.assertEqual(len(reveal["entityIDPiles"]), 1) - self.assertEqual(len(reveal["entityIDPiles"][0]), 7) - # Every revealed card must inline attr 10000 (archetype GUID): - # MulliganSetContents resolves the archetype through it and - # crashes with ArgumentNullException when it is absent. - for attr_list in reveal["entityIDPiles"][0].values(): - self.assertIn( - {"name": AttrID.ARCHETYPE_ID.value, "value": trainer.guid, - "originalValue": trainer.guid, "modValue": trainer.guid}, - attr_list, - ) - # MulliganChoiceRequired has no client UI; it must never be sent. - self.assertNotIn("MulliganChoiceRequired", message_names(client)) - - # Only the non-mulliganing player is offered the extra draw. - self.assertNotIn("CustomChoiceRequired", message_names(client1)) - self.assertIn("CustomChoiceRequired", message_names(client2)) - - # player-1 redrew into a Basic hand; player-2 drew the extra card. - self.assertTrue(board.has_basic_pokemon_in_hand("player-1")) - self.assertEqual(len(board.find_player_area("player-1", "hand").children), 7) - self.assertEqual(len(board.find_player_area("player-2", "hand").children), 8) - - async def test_serialized_state_hides_opponent_cards(self): - """Per-viewer serialization: cards you don't own carry attributes=null - (un-introduced -> face-down back on the client); your own cards keep - their full render attributes. Structural entities are never hidden.""" - from spirit.game.models.board import BoardState - from spirit.game.models.card import Card - from spirit.game.attributes import AttrID, CardType, PokemonStage - from spirit.game.scripts.cards import loader as card_loader - - basic = Card( - guid="hide-basic-guid", - key="X", - display_name="Hidemon", - attributes={ - str(AttrID.CARD_TYPE.value): {"value": CardType.POKEMON.value}, - str(AttrID.STAGE.value): {"value": PokemonStage.BASIC.value}, - }, - ) - card_loader.cards_by_guid[basic.guid] = basic - - board = BoardState("g-hide", ["p1", "p2"]) - deck_data = {"cards": [{"guid": basic.guid, "count": 3}]} - board.populate_deck("p1", deck_data) - board.populate_deck("p2", deck_data) - - def collect_cards(node, out): - if node["entityName"].endswith(".Pokemon"): - out.append(node) - for child in node["children"]: - collect_cards(child, out) - - # All cards start in the decks: hidden from EVERYONE, including their - # owner (deck order and prize faces are hidden knowledge; the owner - # only learns a card via EntityIntroduced on draw). - p1_view_cards = [] - collect_cards(board.serialize("p1")["entities"], p1_view_cards) - self.assertEqual(len(p1_view_cards), 6) - for card in p1_view_cards: - self.assertIsNone(card["attributes"]) - - # Own cards in visible zones (hand) serialize introduced; hidden - # zones (deck, prizePile) stay face-down even for the owner. - board.draw_cards("p1", 1) - board.deal_from_deck("p1", "prizePile", 1) - by_zone = { - zone: board.find_player_area("p1", zone).children[0].serialize("p1") - for zone in ("hand", "prizePile", "deck") - } - self.assertIsNotNone(by_zone["hand"]["attributes"]) - self.assertIsNone(by_zone["prizePile"]["attributes"]) - self.assertIsNone(by_zone["deck"]["attributes"]) - # The opponent sees all of them face-down. - for zone in ("hand", "prizePile", "deck"): - card = board.find_player_area("p1", zone).children[0] - self.assertIsNone(card.serialize("p2")["attributes"]) - - # Omniscient view (no viewer) keeps everything introduced. - full_view_cards = [] - collect_cards(board.serialize()["entities"], full_view_cards) - self.assertTrue(all(c["attributes"] is not None for c in full_view_cards)) - - # Structural entities are never hidden in any view. - p2_view = board.serialize("p2")["entities"] - self.assertIsNotNone(p2_view["attributes"]) - for child in p2_view["children"]: - self.assertIsNotNone(child["attributes"]) - - # The bench area must carry the slot count (201920): the client's - # BenchLayout divides by it, and a missing value NaNs every benched - # card's transform (invisible card + collider crash). - bench = board.find_player_area("p1", "bench") - self.assertEqual(bench.get_attribute(AttrID.AREA_SLOTS), 5) - serialized_bench = bench.serialize("p2") - slot_attrs = [a["value"] for a in serialized_bench["attributes"] if a["name"] == AttrID.AREA_SLOTS.value] - self.assertEqual(slot_attrs, [5]) - - async def test_card_entity_carries_match_render_attributes(self): - """In-match card faces render from the entity's own attributes (pie k.P - builds the set/type facets via entity.GetAttribute, no archetype cache). - Each card entity must carry the set code (200580) and collector number - (200780) or the client can't form a texture-bundle key. Attribute 10020 - must NOT be the set code (the type facet reads it as an image-name - override that would replace the collector-number key).""" - from spirit.game.models.board import BoardState - from spirit.game.models.card import PokemonCard - from spirit.game.attributes import AttrID, CardType, PokemonStage - from spirit.game.scripts.cards import loader as card_loader - - mon = PokemonCard( - guid="render-mon-guid", - key="BW1", - display_name="Rendermon", - attributes={ - str(AttrID.CARD_TYPE.value): {"value": CardType.POKEMON.value}, - str(AttrID.STAGE.value): {"value": PokemonStage.BASIC.value}, - str(AttrID.COLLECTOR_NUMBER.value): {"value": 79}, - }, - ) - card_loader.cards_by_guid[mon.guid] = mon - - board = BoardState("g-render", ["p1"]) - board.populate_deck("p1", {"cards": [{"guid": mon.guid, "count": 1}]}) - deck = board.find_player_area("p1", "deck") - card_entity = deck.children[0] - - attrs = card_entity.attributes - # Load-bearing: set code (bundle name) + collector number. - self.assertEqual(attrs.get(AttrID.SET_CACHE_KEY.value), "BW1") - self.assertEqual(attrs.get(AttrID.COLLECTOR_NUMBER.value), 79) - self.assertEqual(attrs.get(AttrID.CARD_TYPE.value), CardType.POKEMON.value) - # 10020 must be dropped so it doesn't hijack the texture key. - self.assertNotIn(AttrID.EXPANSION.value, attrs) - - # Serialized form exposes them in the client's attribute-list shape. - serialized = card_entity.serialize() - names = {a["name"]: a["value"] for a in serialized["attributes"]} - self.assertEqual(names.get(AttrID.SET_CACHE_KEY.value), "BW1") - self.assertEqual(names.get(AttrID.COLLECTOR_NUMBER.value), 79) - self.assertNotIn(AttrID.EXPANSION.value, names) - - async def test_serialized_state_dispatched_once_on_repeat_ready(self): - """A repeat PlayerReady must not re-dispatch SerializedGameState (would crash the - client loader with '...can't be loaded while a game is in progress!').""" - client1 = MockClientHandler("player-1", "Ash") - client2 = MockClientHandler("player-2", "Gary") - - pairing = { - "is_solo": False, - "format": "Standard", - "players": { - "player-1": {"client": client1, "deck": {"deckID": "d1", "deckName": "A", "cards": []}, "ready": True}, - "player-2": {"client": client2, "deck": {"deckID": "d2", "deckName": "B", "cards": []}, "ready": True}, - }, - } - - game_id = str(uuid.uuid4()) - session = GameSession(game_id, pairing) - self.session_manager.active_sessions[game_id] = session - - handler1 = GameplayHandler(client1) - handler2 = GameplayHandler(client2) - - # Both players ready -> dispatch happens exactly once. - await handler1.handle_player_ready({}, request_id=1, flags=WargFlags.CLEAR) - await handler2.handle_player_ready({}, request_id=2, flags=WargFlags.CLEAR) - await asyncio.sleep(0.01) - - def sgs_count(client): - return sum( - 1 for p in client.sent_packets - if p.get("messageName") == "SequenceMessage" - and isinstance(p.get("msg"), dict) - and p["msg"].get("name") == "SerializedGameState" - ) - - self.assertEqual(sgs_count(client1), 1) - self.assertEqual(sgs_count(client2), 1) - - # A duplicate PlayerReady (e.g. scene transition / reconnect) must NOT re-dispatch. - await handler2.handle_player_ready({}, request_id=3, flags=WargFlags.CLEAR) - await handler1.handle_player_ready({}, request_id=4, flags=WargFlags.CLEAR) - await asyncio.sleep(0.01) - - self.assertEqual(sgs_count(client1), 1, "SerializedGameState re-dispatched on repeat PlayerReady") - self.assertEqual(sgs_count(client2), 1, "SerializedGameState re-dispatched on repeat PlayerReady") - - async def test_deckbox_extras_empty_forces_twotone(self): - """The deckbox game-extras must be empty so the client renders the - dependency-free TwoTone box. A GUID/image name routes through the (currently - broken) cosmetic-bundle texture path and shows a white box. See Q.S:60,70.""" - from spirit.game.session.game_session import GameOptions - - options = GameOptions() - options.add_player( - player_id="player-1", - name="Ash", - avatar_items=[], - sleeve_id="sleeve-guid", - coin_id="coin-guid", - deckbox_id="e129b0d3-b934-4fbd-b021-545106c75694", - ) - opts = options.to_dict() - - # Both deckbox keys must be present AND empty (empty image -> TwoTone; - # empty box -> client skips the GUID/archetype -> white-box branch). - self.assertEqual(opts["gameExtrasDeckBox_player-1"], "") - self.assertEqual(opts["gameExtrasDeckImage_player-1"], "") - - # Other cosmetics are unaffected. - self.assertEqual(opts["gameExtrasCoin_player-1"], "coin-guid") - self.assertEqual(opts["gameExtrasSleeve_player-1"], "sleeve-guid") - - async def test_get_avatar_deck_fallback(self): - client = MockClientHandler("player-1", "Ash") - handler = DataSyncHandler(client) - - # Verify that get_avatar_decks_data returns empty originally - self.assertEqual(len(client.player.avatar_decks), 0) - - # Call handle_get_avatar_deck_list - await handler.handle_get_avatar_deck_list({}, request_id=1, flags=WargFlags.CLEAR) - - # Client should receive default avatar deck containing mock items - self.assertEqual(len(client.sent_packets), 1) - res = client.sent_packets[0] - - self.assertEqual(res["messageName"], OutboundMsg.AVATAR_DECK_LIST.value) - self.assertEqual(len(res["decks"]), 1) - - deck = res["decks"][0] - self.assertEqual(deck["deckName"], "Default Avatar") - self.assertEqual(len(deck["piles"]["AvatarItems"]), 16) - - async def test_player_entity_gx_vstar_attribute_attachment(self): - """Verifies that decks containing GX or VSTAR cards correctly flag physical playmat tokens on PlayerEntity.""" - from spirit.game.models.board import BoardState - from spirit.game.attributes import PlayerAttrID - from spirit.game.models.card import Card - from spirit.game.scripts.cards import loader as card_loader - - # 1. Register a mock GX card into global loader cache - mock_gx_card = Card( - guid="mock-gx-card-guid-12345", - key="CUSTOM", - display_name="Mewtwo-GX", - searchable_by=["Mewtwo-GX", "GX"] - ) - card_loader.cards_by_guid[mock_gx_card.guid] = mock_gx_card - - # 2. Setup board state and populate with GX + VSTAR deck - board = BoardState("game-123", ["player-ash"]) - - # This deck has 1 Alolan Vulpix VSTAR and 1 Mock Mewtwo-GX - deck_data = { - "deckID": "deck-vstar", - "deckName": "Power Deck", - "cards": [ - {"guid": "040f2a64-dffa-52b3-8248-30a1faacf403", "count": 1}, # Alolan Vulpix VSTAR - {"guid": "mock-gx-card-guid-12345", "count": 1} # Mock Mewtwo-GX - ] - } - - # Populate deck and trigger dynamic scanning - board.populate_deck("player-ash", deck_data) - - # 3. Locate PlayerEntity and verify attributes are attached correctly - player_entity = board.find_player_entity("player-ash") - - self.assertIsNotNone(player_entity) - self.assertTrue(player_entity.get_attribute(PlayerAttrID.HAS_GX_TOKEN)) - self.assertTrue(player_entity.get_attribute(PlayerAttrID.HAS_VSTAR_TOKEN)) - - # 4. The attributes alone never show the markers: the client's - # MatchFound handler SetActive()s the playmat token objects only when - # GameOptions["Tokens"] contains "GXToken"/"VSTARToken" (F.w decoded). - client1 = MockClientHandler("player-1", "Ash") - client2 = MockClientHandler("player-2", "Gary") - pairing = { - "is_solo": False, - "format": "Standard", - "players": { - "player-1": {"client": client1, "deck": deck_data, "ready": True}, - "player-2": {"client": client2, "deck": {"deckID": "d2", "deckName": "Plain", "cards": []}, "ready": True}, - }, - } - session = GameSession(str(uuid.uuid4()), pairing) - await session.start() - - match_found = next( - p for p in client1.sent_packets - if p["messageName"] == OutboundMsg.MATCH_FOUND.value - ) - tokens = match_found["gameOptions"].get("Tokens", "") - self.assertIn("GXToken", tokens) - self.assertIn("VSTARToken", tokens) - # showPlayerUpsetNUX float.Parses eloRating_ unguarded per player. - for pid in ("player-1", "player-2"): - float(match_found["gameOptions"][f"eloRating_{pid}"]) - # SerializedGameState mirrors the same options for reconnects. - self.assertIn("VSTARToken", session.board_state.game_options.get("Tokens", "")) - - # A match with no GX/VSTAR cards must not emit the key at all. - plain_pairing = { - "is_solo": False, - "format": "Standard", - "players": { - "player-1": {"client": MockClientHandler("player-1", "Ash"), "deck": {"deckID": "d3", "deckName": "Plain", "cards": []}, "ready": True}, - "player-2": {"client": MockClientHandler("player-2", "Gary"), "deck": {"deckID": "d4", "deckName": "Plain", "cards": []}, "ready": True}, - }, - } - plain_session = GameSession(str(uuid.uuid4()), plain_pairing) - await plain_session.start() - self.assertNotIn("Tokens", plain_session.board_state.game_options) - - - async def test_game_chat(self): - # 1. Setup mock clients and deck - client1 = MockClientHandler("player-1", "Ash") - client2 = MockClientHandler("player-2", "Gary") - - deck1 = {"deckID": "deck-1", "deckName": "Charizard", "cards": []} - deck2 = {"deckID": "deck-2", "deckName": "Blastoise", "cards": []} - - pairing = { - "is_solo": False, - "format": "Standard", - "players": { - "player-1": {"client": client1, "deck": deck1, "ready": True}, - "player-2": {"client": client2, "deck": deck2, "ready": True} - } - } - - # 2. Spawn GameSession - game_id = str(uuid.uuid4()) - session = GameSession(game_id, pairing) - self.session_manager.active_sessions[game_id] = session - - # 3. Handle GameChat from player 1 - handler1 = GameplayHandler(client1) - chat_message = { - "gameID": game_id, - "message": "Hello Gary!" - } - await handler1.handle_game_chat(chat_message, request_id=1, flags=0) - - # Give event loop time to broadcast - await asyncio.sleep(0.01) - - # Verify both players received NotifyGameChat - self.assertEqual(len(client1.sent_packets), 1) - self.assertEqual(len(client2.sent_packets), 1) - - res1 = client1.sent_packets[0] - self.assertEqual(res1["messageName"], OutboundMsg.NOTIFY_GAME_CHAT.value) - self.assertEqual(res1["gameID"], game_id) - self.assertEqual(res1["message"], "Hello Gary!") - self.assertEqual(res1["userInfo"]["accountID"], "player-1") - self.assertEqual(res1["userInfo"]["displayName"], "Ash") - - -class TestEndGamePayload(unittest.IsolatedAsyncioTestCase): - """GameCompletedMessage wire contract (EOG summary dialog requirements).""" - - def _make_session(self): - self.client1 = MockClientHandler("eog-p1", "Ash") - self.client2 = MockClientHandler("eog-p2", "Gary") - pairing = { - "is_solo": False, - "format": "Standard", - "players": { - "eog-p1": {"client": self.client1, "deck": {"deckID": "d1", "deckName": "A", "cards": []}, "ready": True}, - "eog-p2": {"client": self.client2, "deck": {"deckID": "d2", "deckName": "B", "cards": []}, "ready": True}, - }, - } - return GameSession(str(uuid.uuid4()), pairing) - - async def test_knockout_resets_hp_to_printed_max(self): - from spirit.game.attributes import AttrID - from spirit.game.data_utils import PokemonCardDef - from spirit.game.models.board import create_card_entity - from spirit.game.models.card import Card - from spirit.game.session.effects import EffectContext - - session = self._make_session() - card_def = PokemonCardDef( - guid="00000000-0000-0000-0000-0000000ba001", - key="BW1", name="com.test.pokemon.KOTarget.Name", - collector_number=990, set_code="BW1", rarity=1, - hp=90, elements=[], - ) - d = card_def.to_archetype_dict() - pokemon = create_card_entity( - Card(d["guid"], d["key"], d["attributes"]), owning_player_id="eog-p2" - ) - # Benched (no promotion prompt) and damaged to 0. - bench = session.board_state.find_player_area("eog-p2", "bench") - session.board_state.add_card_to_area(pokemon, bench) - pokemon.set_attribute(AttrID.HP, 0) - - ctx = EffectContext(session, "eog-p1", pokemon, None) - ctx.knockouts.append(pokemon) - await session.resolve_knockouts(ctx) - - discard = session.board_state.find_player_area("eog-p2", "discard") - self.assertIn(pokemon, discard.children) - # No damage counters in the discard: HP back at the printed max, - # both server-side and in the Knockout bracket's AttributeModified. - self.assertEqual(pokemon.get_attribute(AttrID.HP), 90) - attr_msgs = [ - pkt["msg"]["value"] for pkt in self.client2.sent_packets - if (pkt.get("msg") or {}).get("name") == "AttributeModified" - and pkt["msg"]["value"]["entityID"] == pokemon.entity_id - ] - self.assertTrue(attr_msgs) - self.assertEqual(attr_msgs[-1]["attribute"]["value"], 90) - self.assertEqual(attr_msgs[-1]["attribute"]["originalValue"], 90) - - async def test_wake_up_bracket_carries_target_data_effect(self): - from unittest.mock import patch - from spirit.game.attributes import AttrID - from spirit.game.data_utils import PokemonCardDef - from spirit.game.models.board import create_card_entity - from spirit.game.models.card import Card - - session = self._make_session() - card_def = PokemonCardDef( - guid="00000000-0000-0000-0000-0000000ba002", - key="BW1", name="com.test.pokemon.Sleeper.Name", - collector_number=991, set_code="BW1", rarity=1, - hp=150, elements=[], - ) - d = card_def.to_archetype_dict() - pokemon = create_card_entity( - Card(d["guid"], d["key"], d["attributes"]), owning_player_id="eog-p1" - ) - active = session.board_state.find_player_area("eog-p1", "activePokemonArea") - session.board_state.add_card_to_area(pokemon, active) - pokemon.set_attribute(AttrID.SPECIAL_CONDITIONS, ["Asleep"]) - session.sleep_checkup_coins[pokemon.entity_id] = 2 - - async def instant(_): - return None - - with patch("spirit.game.session.game_session.random.choice", return_value=0), \ - patch("spirit.game.session.game_session.asyncio.sleep", new=instant): - await session._run_pokemon_checkup() - - self.assertEqual(pokemon.get_attribute(AttrID.SPECIAL_CONDITIONS), []) - msgs = [pkt["msg"] for pkt in self.client1.sent_packets if pkt.get("msg")] - starts = [m for m in msgs if m["name"] == "StartSequence" - and m["value"]["name"] == "RemoveSpecialCondition"] - self.assertTrue(starts) - # The executor's ctor (M.t) indexes the bracket's data effects with - # "Target" -- it must ride the bracket before the condition attr. - idx = msgs.index(starts[0]) - data_effect = msgs[idx + 1] - self.assertEqual(data_effect["name"], "EntityIDDataEffect") - self.assertEqual(data_effect["value"]["key"], "Target") - self.assertEqual(data_effect["value"]["value"], pokemon.entity_id) - self.assertEqual(msgs[idx + 2]["name"], "AttributeModified") - self.assertEqual(msgs[idx + 2]["value"]["attribute"]["value"], []) - - @staticmethod - def _completed_msg(client): - for pkt in client.sent_packets: - msg = pkt.get("msg") or {} - if msg.get("name") == OutboundMsg.GAME_COMPLETED_MESSAGE.value: - return msg["value"] - return None - - async def test_game_completed_message_per_viewer(self): - from spirit.game.session.game_session import GameOver - from spirit.database.player_data import COINS_PER_WIN, COINS_PER_LOSS - - session = self._make_session() - session.stat_add("eog-p1", "damagedealt", 120) - session.stat_max("eog-p1", "biggestattack", 220) - session.stat_add("eog-p1", "headsflipped", 3) - - class FakeAttacker: - archetype_id = "00000000-0000-0000-0000-00000000ba99" - - @staticmethod - def get_attribute(_attr): - return {"id": "com.test.pokemon.Mvp.Name"} - - session.credit_card_damage("eog-p1", FakeAttacker, 90) - session.credit_card_damage("eog-p1", FakeAttacker, 130) - - with self.assertRaises(GameOver): - await session.end_game("eog-p1", "Took all Prize cards") - - winner = self._completed_msg(self.client1) - loser = self._completed_msg(self.client2) - self.assertIsNotNone(winner) - self.assertIsNotNone(loser) - - # GameResult is REQUIRED: the summary dialog indexes it unguarded. - self.assertEqual(winner["additionalParameters"]["GameResult"], {"id": "Win"}) - self.assertEqual(loser["additionalParameters"]["GameResult"], {"id": "Loss"}) - - # Per-viewer stats swap sides between the two messages. - self.assertEqual( - winner["additionalParameters"]["me_playmat.endgame.stat.biggestattack"], - {"id": "220"}, - ) - self.assertEqual( - loser["additionalParameters"]["opp_playmat.endgame.stat.biggestattack"], - {"id": "220"}, - ) - - # Coins at the configured win/loss rates, mirrored in the rewardList. - self.assertEqual(winner["coins"], COINS_PER_WIN) - self.assertEqual(loser["coins"], COINS_PER_LOSS) - reward = winner["rewardList"][0] - self.assertEqual(reward["rewardType"], "Tokens") - self.assertEqual(reward["rewardAmount"], COINS_PER_WIN) - # rewardDescription must be non-null: RewardsList derefs its ID. - self.assertTrue(reward["rewardDescription"]["id"]) - - self.assertIn("GameDuration", winner["additionalParameters"]) - self.assertEqual(winner["winner"], "eog-p1") - self.assertEqual(winner["loser"], "eog-p2") - - # Summary-page header tiles are per-viewer plain totals. - self.assertEqual(winner["additionalParameters"]["Damagedealt"], {"id": "120"}) - self.assertEqual(winner["additionalParameters"]["Headsflipped"], {"id": "3"}) - self.assertEqual(loser["additionalParameters"]["Damagedealt"], {"id": "0"}) - - # MVP card = the viewer-side card with the most credited damage. - self.assertEqual( - winner["additionalParameters"]["me_$playmat.endgame.stat.mvp.archetypeid$"], - {"id": "00000000-0000-0000-0000-00000000ba99"}, - ) - self.assertEqual( - winner["additionalParameters"]["me_playmat.endgame.stat.mvp"], - {"id": "com.test.pokemon.Mvp.Name"}, - ) - self.assertEqual( - loser["additionalParameters"]["opp_$playmat.endgame.stat.mvp.archetypeid$"], - {"id": "00000000-0000-0000-0000-00000000ba99"}, - ) - # A player who dealt no damage gets no MVP keys (client guards them). - self.assertNotIn( - "me_$playmat.endgame.stat.mvp.archetypeid$", - loser["additionalParameters"], - ) - - async def test_concede_completes_the_game_for_both_players(self): - import asyncio - from spirit.game.session.constants import GamePhase - from spirit.game.session.game_session import GameOver - - session = self._make_session() - # Simulate the gameplay task blocked on the opponent's reply. - waiter = session.players["eog-p1"] - waiter.pending_choice_future = asyncio.get_running_loop().create_future() - - await session.concede("eog-p2") - - winner = self._completed_msg(self.client1) - loser = self._completed_msg(self.client2) - self.assertIsNotNone(winner) - self.assertIsNotNone(loser) - self.assertEqual(winner["winner"], "eog-p1") - self.assertEqual(winner["additionalParameters"]["GameResult"], {"id": "Win"}) - self.assertEqual(loser["additionalParameters"]["GameResult"], {"id": "Loss"}) - self.assertIn("conceded", winner["endOfGameText"]["id"]) - - # The pending wait unwinds the gameplay loop with GameOver. - self.assertEqual(session.game_phase, GamePhase.GAME_OVER) - with self.assertRaises(GameOver): - waiter.pending_choice_future.result() - # Any later prompt raises instead of blocking forever. - with self.assertRaises(GameOver): - await session.prompt_selection_message(waiter, "Noop", {}) - # Conceding an already-decided game is a no-op. - before = len(self.client1.sent_packets) - await session.concede("eog-p1") - self.assertEqual(len(self.client1.sent_packets), before) - - -if __name__ == "__main__": - unittest.main() diff --git a/spirit/tests/test_http_assets.py b/spirit/tests/test_http_assets.py deleted file mode 100644 index 23df1a9..0000000 --- a/spirit/tests/test_http_assets.py +++ /dev/null @@ -1,124 +0,0 @@ -import unittest -import os -import shutil -import tempfile -import urllib.request -import urllib.error -import socket -import hashlib -from spirit.server.http_server import AssetHTTPServer, ASSET_PATHS - -class TestHTTPAssetRouting(unittest.TestCase): - @classmethod - def setUpClass(cls): - # 1. Create a temporary directory for assets - cls.temp_dir = tempfile.mkdtemp() - - # Create a mock physical card bundle folder - cls.bundle_folder = os.path.join(cls.temp_dir, "en_US_SWSH12") - os.makedirs(cls.bundle_folder, exist_ok=True) - - cls.data_file = os.path.join(cls.bundle_folder, "__data") - with open(cls.data_file, "wb") as f: - f.write(b"MOCK-SWSH12-ASSET-DATA with CAB-698f1293cb9396443b3f13ebe0cec855 inside") - - # Create another custom set bundle folder - cls.custom_folder = os.path.join(cls.temp_dir, "en_US_CUSTOM") - os.makedirs(cls.custom_folder, exist_ok=True) - cls.custom_data_file = os.path.join(cls.custom_folder, "__data") - with open(cls.custom_data_file, "wb") as f: - f.write(b"MOCK-CUSTOM-ASSET-DATA with CAB-698f1293cb9396443b3f13ebe0cec855 inside") - - # 2. Add temporary path to global ASSET_PATHS - ASSET_PATHS.insert(0, cls.temp_dir) - - # 3. Start HTTPServer on a free local port - # Find a free port dynamically - s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - s.bind(('127.0.0.1', 0)) - cls.port = s.getsockname()[1] - s.close() - - cls.server = AssetHTTPServer(host='127.0.0.1', port=cls.port) - cls.server.start() - - @classmethod - def tearDownClass(cls): - # Stop the server - cls.server.stop() - - # Remove from ASSET_PATHS - if cls.temp_dir in ASSET_PATHS: - ASSET_PATHS.remove(cls.temp_dir) - - # Clean up files - shutil.rmtree(cls.temp_dir, ignore_errors=True) - - def test_routing_physical_bundle_direct(self): - # Direct physical request (should NOT have CAB replaced since it's not a virtual split) - url = f"http://127.0.0.1:{self.port}/en_US/en_US_SWSH12_1.unity3d" - try: - with urllib.request.urlopen(url, timeout=5) as response: - self.assertEqual(response.status, 200) - data = response.read() - self.assertEqual(data, b"MOCK-SWSH12-ASSET-DATA with CAB-698f1293cb9396443b3f13ebe0cec855 inside") - except urllib.error.URLError as e: - self.fail(f"Failed to fetch asset: {e}") - - def test_routing_virtual_type_split_water(self): - # Virtual type-split request for water (should have CAB replaced dynamically) - url = f"http://127.0.0.1:{self.port}/en_US/en_US_SWSH12_water_1.unity3d" - try: - with urllib.request.urlopen(url, timeout=5) as response: - self.assertEqual(response.status, 200) - data = response.read() - - # Compute expected virtual CAB name - h = hashlib.md5(b"SWSH12_water").hexdigest() - expected_cab = f"CAB-{h}".encode('utf-8') - - self.assertIn(expected_cab, data) - self.assertNotIn(b"CAB-698f1293cb9396443b3f13ebe0cec855", data) - except urllib.error.URLError as e: - self.fail(f"Failed to fetch asset: {e}") - - def test_routing_virtual_type_split_colorless(self): - # Virtual type-split request for colorless (should have CAB replaced dynamically) - url = f"http://127.0.0.1:{self.port}/en_US/en_US_CUSTOM_colorless_1.unity3d" - try: - with urllib.request.urlopen(url, timeout=5) as response: - self.assertEqual(response.status, 200) - data = response.read() - - # Compute expected virtual CAB name - h = hashlib.md5(b"CUSTOM_colorless").hexdigest() - expected_cab = f"CAB-{h}".encode('utf-8') - - self.assertIn(expected_cab, data) - self.assertNotIn(b"CAB-698f1293cb9396443b3f13ebe0cec855", data) - except urllib.error.URLError as e: - self.fail(f"Failed to fetch asset: {e}") - - def test_routing_non_existent_404(self): - # Check that unrelated non-existent assets still 404 - url = f"http://127.0.0.1:{self.port}/en_US/en_US_NONEXISTENT_1.unity3d" - with self.assertRaises(urllib.error.HTTPError) as cm: - urllib.request.urlopen(url, timeout=5) - self.assertEqual(cm.exception.code, 404) - - def test_messaging_manifest_has_messaging_array(self): - # null 'messaging' NREs ServerManifestDataRetriever on the Tournament scene - import json - url = f"http://127.0.0.1:{self.port}/patch/messaging/manifest.json?x=1" - with urllib.request.urlopen(url, timeout=5) as response: - self.assertEqual(response.status, 200) - body = json.loads(response.read()) - self.assertIsInstance(body.get("messaging"), list) - # the generic patcher manifest must still answer its own route - url = f"http://127.0.0.1:{self.port}/patch/manifest.json" - with urllib.request.urlopen(url, timeout=5) as response: - body = json.loads(response.read()) - self.assertIn("LatestWindowsClientVersion", body) - -if __name__ == '__main__': - unittest.main() diff --git a/spirit/tests/test_import_set.py b/spirit/tests/test_import_set.py deleted file mode 100644 index 7f15295..0000000 --- a/spirit/tests/test_import_set.py +++ /dev/null @@ -1,133 +0,0 @@ -"""Unit tests for the set importer's script generation (spirit/tools/import_set.py).""" - -import unittest - -from spirit.game.attributes import AttrID, PokemonTypes -from spirit.game.data_utils import unimplemented -from spirit.tools.import_set import fix_text, parse_damage, render_script - -SAMPLE_POKEMON = { - "id": "swsh12-4", - "name": "Ariados", - "supertype": "Pokémon", - "subtypes": ["Stage 1"], - "hp": "90", - "types": ["Grass"], - "evolvesFrom": "Spinarak", - "abilities": [ - {"name": "Hidden Threads", - "text": "Your opponent's Pokémon VSTAR's attacks cost more.", - "type": "Ability"}, - ], - "attacks": [ - {"name": "Pierce", "cost": ["Grass", "Colorless"], - "convertedEnergyCost": 2, "damage": "50", "text": ""}, - {"name": "Poison Sting", "cost": ["Grass"], - "convertedEnergyCost": 1, "damage": "10+", - "text": "Flip a coin. If heads, this attack does 20 more damage."}, - ], - "weaknesses": [{"type": "Fire", "value": "×2"}], - "resistances": [{"type": "Fighting", "value": "-30"}], - "retreatCost": ["Colorless"], - "convertedRetreatCost": 1, - "number": "4", - "rarity": "Rare Holo", - "nationalPokedexNumbers": [168], - "images": {"large": "https://example.test/4.png"}, -} - - -def exec_script(source: str): - namespace = {} - exec(compile(source, "", "exec"), namespace) - return namespace["card"] - - -class TestHelpers(unittest.TestCase): - def test_fix_text_repairs_mojibake(self): - self.assertEqual(fix_text("Pokémon"), "Pokémon") - self.assertEqual(fix_text("plain text"), "plain text") - - def test_parse_damage(self): - self.assertEqual(parse_damage("50"), (50, "")) - self.assertEqual(parse_damage("10+"), (10, "+")) - self.assertEqual(parse_damage("20×"), (20, "x")) - self.assertEqual(parse_damage("220-"), (220, "-")) - self.assertEqual(parse_damage(""), (0, "")) - - -class TestPokemonGeneration(unittest.TestCase): - def setUp(self): - self.card = exec_script(render_script(SAMPLE_POKEMON)) - - def test_subtypes_imported(self): - self.assertEqual(self.card.subtypes, ["Stage 1"]) - - def test_vanilla_attack_auto_allocated(self): - pierce = self.card.abilities[1] - self.assertEqual(pierce.title, "Pierce") - self.assertIsNone(pierce.effect) - self.assertEqual(pierce.damage, 50) - self.assertEqual(pierce.cost, - {PokemonTypes.GRASS: 1, PokemonTypes.COLORLESS: 1}) - - def test_texted_attack_marked_unimplemented(self): - sting = self.card.abilities[2] - self.assertIs(sting.effect, unimplemented) - self.assertEqual(sting.damage, 10) - self.assertEqual(sting.damage_operator, "+") - self.assertIn("Flip a coin", sting.game_text) - - def test_ability_marked_unimplemented_with_repaired_text(self): - ability = self.card.abilities[0] - self.assertIs(ability.effect, unimplemented) - self.assertIn("Pokémon VSTAR", ability.game_text) - - def test_weakness_and_resistance_imported(self): - attrs = self.card.to_archetype_dict()["attributes"] - weakness = attrs[str(AttrID.WEAKNESS_TYPES.value)]["value"] - self.assertEqual(weakness, f"[{PokemonTypes.FIRE.value}]") - resistance = attrs[str(AttrID.RESISTANCE_TYPES.value)]["value"] - self.assertEqual(resistance, PokemonTypes.FIGHTING.value) - - # Verify the operators and amounts are correctly serialized for the client - self.assertEqual(attrs[str(AttrID.WEAKNESS_OPERATOR.value)]["value"], "x") - self.assertEqual(attrs[str(AttrID.WEAKNESS_AMOUNT.value)]["value"], 2) - self.assertEqual(attrs[str(AttrID.RESISTANCE_OPERATOR.value)]["value"], "-") - self.assertEqual(attrs[str(AttrID.RESISTANCE_AMOUNT.value)]["value"], 30) - - def test_ability_ids_assigned_deterministically(self): - for ability in self.card.abilities: - self.assertIsNotNone(ability.ability_id) - - -class TestNonPokemonGeneration(unittest.TestCase): - def test_trainer_renders_without_abilities(self): - trainer = { - "id": "swsh12-153", "name": "Capturing Aroma", - "supertype": "Trainer", "subtypes": ["Item"], - "rules": ["Flip a coin."], "number": "153", "rarity": "Uncommon", - } - card_src = render_script(trainer) - self.assertIn("effect=unimplemented", card_src) - self.assertIn("from spirit.game.data_utils import ItemCardDef, unimplemented", card_src) - - card = exec_script(card_src) - self.assertEqual(card.collector_number, 153) - self.assertIs(card.effect, unimplemented) - self.assertEqual(card.subtypes, ["Item"]) - - def test_energy_renders_with_subtypes(self): - energy = { - "id": "swsh12-156", "name": "V Guard Energy", - "supertype": "Energy", "subtypes": ["Special"], - "number": "156", "rarity": "Uncommon", - } - card_src = render_script(energy) - self.assertIn("is_special=True", card_src) - card = exec_script(card_src) - self.assertEqual(card.subtypes, ["Special"]) - - -if __name__ == "__main__": - unittest.main() diff --git a/spirit/tests/test_legal_actions.py b/spirit/tests/test_legal_actions.py deleted file mode 100644 index 96aa699..0000000 --- a/spirit/tests/test_legal_actions.py +++ /dev/null @@ -1,486 +0,0 @@ -"""Unit tests for the main-turn legal-action engine (legal_actions.py).""" - -import unittest - -from spirit.game.attributes import AttrID, PokemonTypes -from spirit.game.data_utils import ( - Attack, - EnergyCardDef, - ItemCardDef, - PokemonCardDef, - SupporterCardDef, - ability_id_for, -) -from spirit.game.models.board import BoardState, create_card_entity -from spirit.game.models.card import Card -from spirit.game.session.constants import BENCH_CAPACITY -from spirit.game.session.constants import SelectionKind -from spirit.game.session.legal_actions import ( - ACTION_EVOLVE, - ACTION_PLAY_ENERGY, - ACTION_PLAY_POKEMON, - ACTION_RETREAT, - ACTION_USE_ATTACK, - ACTION_USE_TRAINER, - TurnState, - attack_cost_satisfied, - compute_legal_actions, - energy_provided_count, -) - -P1 = "player-1" -P2 = "player-2" -GAME_ID = "test-game" - - -def make_card(card_def) -> Card: - d = card_def.to_archetype_dict() - return Card(d["guid"], d["key"], d["attributes"]) - - -BASIC_POKEMON = make_card(PokemonCardDef( - guid="00000000-0000-0000-0000-0000000000b1", - key="BW1", name="com.test.pokemon.Patrat.Name", - collector_number=1, set_code="BW1", rarity=1, - hp=60, elements=[PokemonTypes.COLORLESS], - abilities=[ - Attack("Tackle", "tackle.text", {PokemonTypes.COLORLESS: 1}, damage=10), - Attack("Big Bite", "bite.text", - {PokemonTypes.FIRE: 1, PokemonTypes.COLORLESS: 1}, damage=30), - ], -)) - -STAGE1_POKEMON = make_card(PokemonCardDef( - guid="00000000-0000-0000-0000-0000000000s1", - key="BW1", name="com.test.pokemon.Watchog.Name", - collector_number=2, set_code="BW1", rarity=2, - hp=90, elements=[PokemonTypes.COLORLESS], stage=__import__( - "spirit.game.attributes", fromlist=["PokemonStage"]).PokemonStage.STAGE1, - evolves_from="com.test.pokemon.Patrat.Name", -)) - -FIRE_ENERGY = make_card(EnergyCardDef( - guid="00000000-0000-0000-0000-0000000000e1", - key="BW1", name="com.test.energy.Fire.Name", - collector_number=106, set_code="BW1", rarity=0, - energy_type=PokemonTypes.FIRE, -)) - -ITEM_CARD = make_card(ItemCardDef( - guid="00000000-0000-0000-0000-0000000000i1", - key="BW1", name="com.test.trainer.Potion.Name", - collector_number=100, set_code="BW1", rarity=0, -)) - -SUPPORTER_CARD = make_card(SupporterCardDef( - guid="00000000-0000-0000-0000-0000000000su", - key="BW1", name="com.test.trainer.Cheren.Name", - collector_number=101, set_code="BW1", rarity=0, -)) - - -class LegalActionsTestBase(unittest.TestCase): - def setUp(self): - self.board = BoardState(GAME_ID, [P1, P2]) - self.state = TurnState() - - def add_to(self, area_name: str, card: Card, player_id: str = P1): - entity = create_card_entity(card, owning_player_id=player_id) - area = self.board.find_player_area(player_id, area_name) - self.board.add_card_to_area(entity, area) - return entity - - def actions(self, player_id: str = P1): - return compute_legal_actions(self.board, self.state, player_id, GAME_ID) - - def actions_for(self, entity, player_id: str = P1): - return [ - e for e in self.actions(player_id) - if e["entityID"] == entity.entity_id - ] - - -class TestPokemonPlays(LegalActionsTestBase): - def test_basic_playable_while_bench_has_space(self): - basic = self.add_to("hand", BASIC_POKEMON) - self.state.begin_turn(P1) - - entries = self.actions_for(basic) - self.assertEqual(len(entries), 1) - action = entries[0]["selectableAction"] - self.assertEqual(action["description"], ACTION_PLAY_POKEMON) - self.assertEqual(action["selectionType"], "Ability") - bench = self.board.find_player_area(P1, "bench") - info = entries[0]["targetInfoLst"][0] - self.assertEqual(info["validTargets"], [bench.entity_id]) - - def test_basic_not_playable_with_full_bench(self): - basic = self.add_to("hand", BASIC_POKEMON) - for _ in range(BENCH_CAPACITY): - self.add_to("bench", BASIC_POKEMON) - self.state.begin_turn(P1) - - self.assertEqual(self.actions_for(basic), []) - - def test_evolution_requires_settled_target_and_turn_three(self): - stage1 = self.add_to("hand", STAGE1_POKEMON) - target = self.add_to("activePokemonArea", BASIC_POKEMON) - - # Turn 1: never evolvable. - self.state.begin_turn(P1) - self.assertEqual(self.actions_for(stage1), []) - - # Turn 3 (both players have had a turn), target from setup (turn 0). - self.state.begin_turn(P2) - self.state.begin_turn(P1) - entries = self.actions_for(stage1) - self.assertEqual(len(entries), 1) - self.assertEqual(entries[0]["selectableAction"]["description"], ACTION_EVOLVE) - info = entries[0]["targetInfoLst"][0] - self.assertEqual(info["name"], "EntityListTargetInformation") - self.assertEqual(info["validTargets"], [target.entity_id]) - - def test_pokemon_played_this_turn_cannot_evolve(self): - stage1 = self.add_to("hand", STAGE1_POKEMON) - target = self.add_to("bench", BASIC_POKEMON) - self.state.begin_turn(P1) - self.state.begin_turn(P2) - self.state.begin_turn(P1) - self.state.mark_entered_play(target.entity_id) # benched this turn - - self.assertEqual(self.actions_for(stage1), []) - - -class TestEnergyPlays(LegalActionsTestBase): - def test_energy_attach_offered_once_per_turn(self): - energy = self.add_to("hand", FIRE_ENERGY) - active = self.add_to("activePokemonArea", BASIC_POKEMON) - benched = self.add_to("bench", BASIC_POKEMON) - self.state.begin_turn(P1) - - entries = self.actions_for(energy) - self.assertEqual(len(entries), 1) - self.assertEqual(entries[0]["selectableAction"]["description"], ACTION_PLAY_ENERGY) - self.assertCountEqual( - entries[0]["targetInfoLst"][0]["validTargets"], - [active.entity_id, benched.entity_id], - ) - - self.state.energy_attached = True - self.assertEqual(self.actions_for(energy), []) - - def test_energy_needs_a_pokemon_in_play(self): - energy = self.add_to("hand", FIRE_ENERGY) - self.state.begin_turn(P1) - self.assertEqual(self.actions_for(energy), []) - - -class TestTrainerPlays(LegalActionsTestBase): - def test_item_always_playable_supporter_once_and_not_turn_one(self): - item = self.add_to("hand", ITEM_CARD) - supporter = self.add_to("hand", SUPPORTER_CARD) - - self.state.begin_turn(P1) # turn 1 - self.assertEqual(len(self.actions_for(item)), 1) - self.assertEqual(self.actions_for(supporter), []) - - self.state.begin_turn(P2) # turn 2 - entries = self.actions_for(supporter, P1) - # Not P1's turn state anymore, but legality is computed per player id; - # supporter is allowed from turn 2 onward when the flag is clear. - self.assertEqual(len(entries), 1) - self.assertEqual(entries[0]["selectableAction"]["description"], ACTION_USE_TRAINER) - - self.state.supporter_played = True - self.assertEqual(self.actions_for(supporter, P1), []) - self.assertEqual(len(self.actions_for(item, P1)), 1) - - -class TestAttacks(LegalActionsTestBase): - def attach_energy(self, pokemon, energy_card, player_id: str = P1): - entity = create_card_entity(energy_card, owning_player_id=player_id) - area = self.board.find_player_area(player_id, "discard") - self.board.add_card_to_area(entity, area) # register in cache - self.board.attach_card(entity.entity_id, pokemon.entity_id) - return entity - - def advance_to_turn(self, n: int, player_id: str = P1): - for _ in range(n - 1): - self.state.begin_turn(P2) - self.state.begin_turn(player_id) - - def test_attack_offered_when_cost_met(self): - active = self.add_to("activePokemonArea", BASIC_POKEMON) - self.advance_to_turn(3) - - # No energy: no attacks. - self.assertEqual(self.actions_for(active), []) - - # One fire energy pays Tackle (1 colorless) but not Big Bite (F+C). - self.attach_energy(active, FIRE_ENERGY) - entries = self.actions_for(active) - self.assertEqual(len(entries), 1) - self.assertEqual(entries[0]["selectableAction"]["description"], ACTION_USE_ATTACK) - # Attacks open the K.O ability panel, not the j.V play-from-hand flow. - self.assertEqual(entries[0]["selectableAction"]["selectionType"], "AbilitySelection") - self.assertEqual( - entries[0]["selectableAction"]["actionID"], - ability_id_for(BASIC_POKEMON.guid, 0), - ) - - # Second fire energy also pays Big Bite. - self.attach_energy(active, FIRE_ENERGY) - entries = self.actions_for(active) - self.assertEqual(len(entries), 2) - - def test_benched_pokemon_do_not_offer_attacks(self): - benched = self.add_to("bench", BASIC_POKEMON) - self.attach_energy(benched, FIRE_ENERGY) - self.advance_to_turn(3) - self.assertEqual(self.actions_for(benched), []) - - def test_no_attacks_on_the_games_first_turn(self): - active = self.add_to("activePokemonArea", BASIC_POKEMON) - self.attach_energy(active, FIRE_ENERGY) - - # Turn 1: the player going first cannot attack. - self.state.begin_turn(P1) - self.assertEqual(self.actions_for(active), []) - - # Turn 2: the second player can. - self.state.begin_turn(P2) - p2_active = self.add_to("activePokemonArea", BASIC_POKEMON, P2) - self.attach_energy(p2_active, FIRE_ENERGY, P2) - entries = [ - e for e in self.actions(P2) - if e["entityID"] == p2_active.entity_id - ] - self.assertEqual(len(entries), 1) - - -FREE_RETREATER = make_card(PokemonCardDef( - guid="00000000-0000-0000-0000-0000000000fr", - key="BW1", name="com.test.pokemon.Emolga.Name", - collector_number=5, set_code="BW1", rarity=1, - hp=70, elements=[PokemonTypes.LIGHTNING], - retreat_cost=0, -)) - - -class TestRetreat(LegalActionsTestBase): - def attach_energy(self, pokemon, energy_card=FIRE_ENERGY): - entity = create_card_entity(energy_card, owning_player_id=P1) - self.board.add_card_to_area(entity, self.board.find_player_area(P1, "discard")) - self.board.attach_card(entity.entity_id, pokemon.entity_id) - return entity - - def retreat_entries(self, entity): - return [ - e for e in self.actions_for(entity) - if e["selectableAction"]["description"] == ACTION_RETREAT - ] - - def test_retreat_offered_with_bench_and_paid_cost(self): - active = self.add_to("activePokemonArea", BASIC_POKEMON) - benched = self.add_to("bench", BASIC_POKEMON) - energy = self.attach_energy(active) - self.state.begin_turn(P1) - - entries = self.retreat_entries(active) - self.assertEqual(len(entries), 1) - action = entries[0]["selectableAction"] - # Retreat rides the K.O pulled-back panel next to the attacks. - self.assertEqual(action["selectionType"], "AbilitySelection") - infos = entries[0]["targetInfoLst"] - self.assertEqual(len(infos), 2) - # New active first, cost last: the Done button only renders on the - # final node, and the pip tray label reads the cost node's prompt. - new_active_info, cost_info = infos - self.assertEqual(new_active_info["name"], SelectionKind.RETREAT_NEW_ACTIVE.value) - self.assertEqual(new_active_info["validTargets"], [benched.entity_id]) - self.assertTrue(new_active_info["targetPrompt"]["id"]) - self.assertEqual(cost_info["name"], SelectionKind.RETREAT_COST_ENTITY_LIST.value) - self.assertEqual(cost_info["validTargets"], [energy.entity_id]) - self.assertEqual(cost_info["valueToSelect"], 1) - self.assertEqual(cost_info["numberToSelect"], 1) - self.assertTrue(cost_info["targetPrompt"]["id"]) - - def test_retreat_needs_a_benched_pokemon(self): - active = self.add_to("activePokemonArea", BASIC_POKEMON) - self.attach_energy(active) - self.state.begin_turn(P1) - self.assertEqual(self.retreat_entries(active), []) - - def test_retreat_needs_the_cost_paid(self): - active = self.add_to("activePokemonArea", BASIC_POKEMON) - self.add_to("bench", BASIC_POKEMON) - self.state.begin_turn(P1) - self.assertEqual(self.retreat_entries(active), []) - - def test_retreat_only_once_per_turn(self): - active = self.add_to("activePokemonArea", BASIC_POKEMON) - self.add_to("bench", BASIC_POKEMON) - self.attach_energy(active) - self.state.begin_turn(P1) - self.assertEqual(len(self.retreat_entries(active)), 1) - - self.state.retreated = True - self.assertEqual(self.retreat_entries(active), []) - - self.state.begin_turn(P2) - self.state.begin_turn(P1) # flag resets each turn - self.assertEqual(len(self.retreat_entries(active)), 1) - - def test_free_retreat_skips_the_cost_node(self): - active = self.add_to("activePokemonArea", FREE_RETREATER) - benched = self.add_to("bench", BASIC_POKEMON) - self.state.begin_turn(P1) - - entries = self.retreat_entries(active) - self.assertEqual(len(entries), 1) - infos = entries[0]["targetInfoLst"] - self.assertEqual(len(infos), 1) - self.assertEqual(infos[0]["name"], SelectionKind.RETREAT_NEW_ACTIVE.value) - self.assertEqual(infos[0]["validTargets"], [benched.entity_id]) - - def test_double_energy_pays_two_retreat_cost(self): - two_cost = make_card(PokemonCardDef( - guid="00000000-0000-0000-0000-0000000000r2", - key="BW1", name="com.test.pokemon.Stoutland.Name", - collector_number=6, set_code="BW1", rarity=2, - hp=130, elements=[PokemonTypes.COLORLESS], retreat_cost=2, - )) - active = self.add_to("activePokemonArea", two_cost) - self.add_to("bench", BASIC_POKEMON) - double = self.attach_energy(active) - # One card providing two energy (client tallies EnergyProvidedCount). - double.set_attribute(AttrID.ENERGY_INFO, { - "options": [[PokemonTypes.COLORLESS.value, PokemonTypes.COLORLESS.value]] - }) - self.state.begin_turn(P1) - - self.assertEqual(energy_provided_count(double), 2) - entries = self.retreat_entries(active) - self.assertEqual(len(entries), 1) - self.assertEqual(entries[0]["targetInfoLst"][1]["valueToSelect"], 2) - - -class TestAttackCostSatisfied(unittest.TestCase): - def energy_entities(self, *types): - entities = [] - for t in types: - card = make_card(EnergyCardDef( - guid=f"00000000-0000-0000-0000-00000000{t.value:04d}", - key="BW1", name=f"com.test.energy.{t.name}.Name", - collector_number=200 + t.value, set_code="BW1", rarity=0, - energy_type=t, - )) - entities.append(create_card_entity(card, owning_player_id=P1)) - return entities - - def test_typed_and_colorless_costs(self): - energies = self.energy_entities(PokemonTypes.FIRE, PokemonTypes.WATER) - - self.assertTrue(attack_cost_satisfied({"Fire": 1, "Colorless": 1}, energies)) - self.assertTrue(attack_cost_satisfied({"Colorless": 2}, energies)) - self.assertFalse(attack_cost_satisfied({"Fire": 2}, energies)) - self.assertFalse(attack_cost_satisfied({"Fire": 1, "Colorless": 2}, energies)) - self.assertTrue(attack_cost_satisfied({}, [])) - # Legacy numeric-string keys still parse. - self.assertTrue(attack_cost_satisfied( - {str(PokemonTypes.FIRE.value): 1}, energies)) - - def test_typed_requirements_consume_matching_energy_first(self): - energies = self.energy_entities(PokemonTypes.FIRE) - # The single fire energy must not double-count for fire AND colorless. - self.assertFalse(attack_cost_satisfied({"Fire": 1, "Colorless": 1}, energies)) - - def test_double_provide_energy_pays_two_colorless(self): - double = create_card_entity(make_card(EnergyCardDef( - guid="00000000-0000-0000-0000-0000000000dt", - key="BW1", name="com.test.energy.DoubleTurbo.Name", - collector_number=151, set_code="BW1", rarity=0, - energy_type=PokemonTypes.COLORLESS, is_special=True, - provides=[[PokemonTypes.COLORLESS, PokemonTypes.COLORLESS]], - )), owning_player_id=P1) - - self.assertTrue(attack_cost_satisfied({"Colorless": 2}, [double])) - self.assertFalse(attack_cost_satisfied({"Colorless": 3}, [double])) - # One basic + Double Turbo covers a 3-Colorless cost (Lugia V). - basic = self.energy_entities(PokemonTypes.GRASS) - self.assertTrue(attack_cost_satisfied({"Colorless": 3}, basic + [double])) - # Colorless-only options never pay a typed requirement. - self.assertFalse(attack_cost_satisfied({"Fire": 1}, [double])) - - def test_typed_consumption_leaves_remaining_capacity_for_colorless(self): - double_water = create_card_entity(make_card(EnergyCardDef( - guid="00000000-0000-0000-0000-0000000000dw", - key="BW1", name="com.test.energy.DoubleWater.Name", - collector_number=152, set_code="BW1", rarity=0, - energy_type=PokemonTypes.WATER, is_special=True, - provides=[[PokemonTypes.WATER, PokemonTypes.WATER]], - )), owning_player_id=P1) - - self.assertTrue(attack_cost_satisfied( - {"Water": 1, "Colorless": 1}, [double_water])) - self.assertFalse(attack_cost_satisfied( - {"Water": 1, "Colorless": 2}, [double_water])) - - -class TestActionIDsAreGuids(LegalActionsTestBase): - def test_every_offered_action_id_parses_as_guid(self): - """AbilityID (TypedID) runs `new Guid(id)` client-side: one bad - actionID kills deserialization of the whole offer.""" - import uuid - self.add_to("hand", BASIC_POKEMON) - self.add_to("hand", FIRE_ENERGY) - self.add_to("hand", ITEM_CARD) - active = self.add_to("activePokemonArea", BASIC_POKEMON) - energy = create_card_entity(FIRE_ENERGY, owning_player_id=P1) - self.board.add_card_to_area(energy, self.board.find_player_area(P1, "discard")) - self.board.attach_card(energy.entity_id, active.entity_id) - self.state.begin_turn(P1) - - entries = self.actions() - self.assertTrue(entries) - for entry in entries: - uuid.UUID(entry["selectableAction"]["actionID"]) - - -class TestAbilityIDAssignment(unittest.TestCase): - def test_pie_abilities_carry_deterministic_guid_ability_ids(self): - import json - import uuid - abilities = BASIC_POKEMON.get_attribute_value(AttrID.PIE_ABILITIES) - parsed = json.loads(abilities) - self.assertEqual(parsed[0]["abilityID"], ability_id_for(BASIC_POKEMON.guid, 0)) - self.assertEqual(parsed[1]["abilityID"], ability_id_for(BASIC_POKEMON.guid, 1)) - # The client's AbilityID ctor runs `new Guid(id)` -- every ability ID - # on the wire must parse as a GUID. - for ability in parsed: - uuid.UUID(ability["abilityID"]) - - def test_cost_keys_are_client_type_names(self): - """Dictionary keys coerce by NAME on the client; - numeric-string keys crash archetype sync at login.""" - import json - parsed = json.loads(BASIC_POKEMON.get_attribute_value(AttrID.PIE_ABILITIES)) - self.assertEqual(parsed[0]["cost"], {"Colorless": 1}) - self.assertEqual(parsed[1]["cost"], {"Fire": 1, "Colorless": 1}) - - def test_ability_type_is_a_type_hint_class_name(self): - """abilityType is the PieAbilityDescription subclass name: JsonFx - resolves it through TypeHintedClasses (an int crashes login sync).""" - import json - hint_classes = { - "Attack", "PokeAbility", "PokePower", "PokeBody", "AncientTrait", - "EnergyAbility", "PlayAbility", "RetreatAbility", "StadiumAbility", - "TechnicalMachine", "TrainerAbility", - } - parsed = json.loads(BASIC_POKEMON.get_attribute_value(AttrID.PIE_ABILITIES)) - for ability in parsed: - self.assertIn(ability["abilityType"], hint_classes) - - -if __name__ == "__main__": - unittest.main() diff --git a/spirit/tests/test_lost_zone_regi_decks.py b/spirit/tests/test_lost_zone_regi_decks.py deleted file mode 100644 index 095c60d..0000000 --- a/spirit/tests/test_lost_zone_regi_decks.py +++ /dev/null @@ -1,686 +0,0 @@ -"""Tests for the Lost Zone Box and Regigigas deck card implementations.""" - -import unittest -import uuid - -from spirit.game.attributes import AttrID, PokemonStage, PokemonTypes -from spirit.game.data_utils import EnergyCardDef, PokemonCardDef -from spirit.game.models.board import create_card_entity -from spirit.game.session.effects import EffectContext, resolve_attack -from spirit.game.session.game_session import GameSession -from spirit.game.session.legal_actions import ( - ACTION_USE_ABILITY, ACTION_USE_ATTACK, compute_legal_actions, - effective_attack_cost, energy_provided_count, -) -from spirit.game.session.passives import effective_retreat_cost - -from spirit.tests.test_effects import ( - EffectsTestBase, make_card, P1, P2, GAME_ID, -) - -# Card definitions under test (importing runs their scripts / registers them). -from spirit.game.scripts.cards.SWSH11.Cramorant_50 import card as CRAMORANT -from spirit.game.scripts.cards.SWSH4.Zeraora_61 import card as ZERAORA -from spirit.game.scripts.cards.SWSH10.Regigigas_130 import card as REGIGIGAS -from spirit.game.scripts.cards.SWSH10.Registeel_108 import card as REGISTEEL -from spirit.game.scripts.cards.SWSH7.Regidrago_124 import card as REGIDRAGO_EVS -from spirit.game.scripts.cards.SWSH10.Regidrago_118 import card as REGIDRAGO_ASR -from spirit.game.scripts.cards.SWSH10.Regice_37 import card as REGICE -from spirit.game.scripts.cards.SWSH10.Regirock_75 import card as REGIROCK -from spirit.game.scripts.cards.SWSH11.Sableye_70 import card as SABLEYE -from spirit.game.scripts.cards.SWSH11.AerodactylVSTAR_93 import card as AERO_VSTAR -from spirit.game.scripts.cards.SWSH45.CrobatV_44 import card as CROBAT_V -from spirit.game.scripts.cards.SWSH1.AirBalloon_156 import card as AIR_BALLOON -from spirit.game.scripts.cards.SWSH2.TwinEnergy_174 import card as TWIN_ENERGY -from spirit.game.scripts.cards.SWSH11.DrapionV_118 import card as DRAPION_V -from spirit.game.scripts.cards.SWSH10.RadiantGreninja_46 import card as RADIANT_GRENINJA - -from spirit.game.card_effects.trainers import ( - colresss_experiment, scoop_up_net, switch_cart, energy_recycler, - trekking_shoes, mirage_gate, mirage_gate_condition, -) -from spirit.game.card_effects.energies import gift_energy_on_ko -from spirit.game.card_effects.pokemon import dark_asset - - -PSYCHIC_ENERGY_DEF = EnergyCardDef( - guid="00000000-0000-0000-0000-0000000000e5", - key="BW1", name="com.test.energy.Psychic", collector_number=200, - set_code="BW1", rarity=0, energy_type=PokemonTypes.PSYCHIC, -) -WATER_ENERGY_DEF = EnergyCardDef( - guid="00000000-0000-0000-0000-0000000000e6", - key="BW1", name="com.test.energy.Water", collector_number=201, - set_code="BW1", rarity=0, energy_type=PokemonTypes.WATER, -) -WEAK_TO_WATER_DEF = PokemonCardDef( - guid="00000000-0000-0000-0000-0000000000f9", - key="BW1", name="com.test.pokemon.WeakToWater.Name", - collector_number=249, set_code="BW1", rarity=1, - hp=200, elements=[PokemonTypes.COLORLESS], - weakness_type=PokemonTypes.WATER, -) - - -_BASIC_COUNTER = [0] - - -def _basic(hp=120, elements=None, subtypes=None, retreat=1): - _BASIC_COUNTER[0] += 1 - n = _BASIC_COUNTER[0] - return PokemonCardDef( - guid=f"00000000-0000-0000-0000-{n:012d}", - key="BW1", name=f"com.test.pokemon.Mon{n}.Name", - collector_number=n, set_code="BW1", rarity=1, - hp=hp, elements=elements or [PokemonTypes.COLORLESS], - stage=PokemonStage.BASIC, retreat_cost=retreat, subtypes=subtypes or ["Basic"], - ) - - -VMAX_DEF = _basic(hp=310, subtypes=["VMAX"]) -FUSION_DEF = _basic(subtypes=["Basic", "Fusion Strike"]) -PLAIN_BIG = _basic(hp=330) - - -class LostZoneRegiBase(EffectsTestBase): - def attach(self, card, pokemon, player_id): - entity = create_card_entity(card, owning_player_id=player_id) - self.board.add_card_to_area( - entity, self.board.find_player_area(player_id, "hand") - ) - self.board.attach_card(entity.entity_id, pokemon.entity_id) - return entity - - def fill_lost_zone(self, count, player_id=P1): - for _ in range(count): - self.add_to("lostZone", make_card(PLAIN_BIG), player_id) - - def make_source(self, card_def=None, player_id=P1): - """A detached acting card (as a trainer sits on activeTrainer, not in - the player's hand) so it never inflates hand/zone counts.""" - return create_card_entity( - make_card(card_def or REGIROCK), owning_player_id=player_id - ) - - -class TestLostZonePassives(LostZoneRegiBase): - async def test_lost_provisions_zeroes_cost_at_four(self): - active = self.add_to("activePokemonArea", make_card(CRAMORANT), P1) - cost = {"Water": 2, "Colorless": 1} - self.fill_lost_zone(3) - self.assertEqual( - effective_attack_cost(self.board, active, dict(cost)), cost - ) - self.add_to("lostZone", make_card(PLAIN_BIG), P1) # -> 4 - self.assertEqual(effective_attack_cost(self.board, active, dict(cost)), {}) - - async def test_air_balloon_reduces_retreat_cost(self): - active = self.add_to("activePokemonArea", make_card(REGIROCK), P1) # retreat 3 - self.assertEqual(effective_retreat_cost(self.board, active), 3) - self.attach(make_card(AIR_BALLOON), active, P1) - self.assertEqual(effective_retreat_cost(self.board, active), 1) - - -class TestAttackConditionGating(LostZoneRegiBase): - def _lost_mine_offered(self, player_id=P1): - self.session.turn_state.turn_number = 3 - entries = compute_legal_actions( - self.board, self.session.turn_state, player_id, GAME_ID - ) - lost_mine = SABLEYE.abilities[1] - return any( - e["selectableAction"]["description"] == ACTION_USE_ATTACK - and e["selectableAction"]["actionID"] == lost_mine.ability_id - for e in entries - ) - - async def test_lost_mine_gated_on_ten_lost_zone(self): - active = self.add_to("activePokemonArea", make_card(SABLEYE), P1) - self.attach(make_card(PSYCHIC_ENERGY_DEF), active, P1) - self.add_to("bench", make_card(PLAIN_BIG), P1) # a benched Pokemon exists - self.assertFalse(self._lost_mine_offered()) - self.fill_lost_zone(10) - self.assertTrue(self._lost_mine_offered()) - - -class TestDragonsHoardGating(LostZoneRegiBase): - def _hoard_offered(self, player_id=P1): - entries = compute_legal_actions( - self.board, self.session.turn_state, player_id, GAME_ID - ) - hoard = REGIDRAGO_ASR.abilities[0] - return any( - e["selectableAction"]["description"] == ACTION_USE_ABILITY - and e["selectableAction"]["actionID"] == hoard.ability_id - for e in entries - ) - - async def test_hoard_gated_on_hand_below_four(self): - self.add_to("activePokemonArea", make_card(REGIDRAGO_ASR), P1) - # Empty hand -> would draw to 4, offered. - self.assertTrue(self._hoard_offered()) - # Four cards in hand -> drawing to 4 does nothing, not offered. - for _ in range(4): - self.add_to("hand", make_card(PLAIN_BIG), P1) - self.assertFalse(self._hoard_offered()) - - async def test_hoard_not_offered_off_active(self): - self.add_to("activePokemonArea", make_card(PLAIN_BIG), P1) - self.add_to("bench", make_card(REGIDRAGO_ASR), P1) - self.assertFalse(self._hoard_offered()) - - -class TestConditionalDamage(LostZoneRegiBase): - async def test_fighting_lightning_bonus_vs_v(self): - attacker = self.add_to("activePokemonArea", make_card(ZERAORA), P1) - defender = self.add_to("activePokemonArea", make_card(CROBAT_V), P2) # 180 HP V - attack = ZERAORA.abilities[0] - await resolve_attack(self.session, P1, attacker, attack, attack.ability_id) - self.assertEqual(defender.get_attribute(AttrID.HP), 180 - 110) - - async def test_fighting_lightning_no_bonus_vs_basic(self): - attacker = self.add_to("activePokemonArea", make_card(ZERAORA), P1) - defender = self.add_to("activePokemonArea", make_card(PLAIN_BIG), P2) # 330 HP - attack = ZERAORA.abilities[0] - await resolve_attack(self.session, P1, attacker, attack, attack.ability_id) - self.assertEqual(defender.get_attribute(AttrID.HP), 330 - 30) - - async def test_gigaton_break_bonus_vs_vmax(self): - attacker = self.add_to("activePokemonArea", make_card(REGIGIGAS), P1) - defender = self.add_to("activePokemonArea", make_card(VMAX_DEF), P2) # 310 HP - attack = REGIGIGAS.abilities[1] - await resolve_attack(self.session, P1, attacker, attack, attack.ability_id) - self.assertEqual(defender.get_attribute(AttrID.HP), 310 - 300) - - async def test_heavy_slam_scales_with_retreat_cost(self): - attacker = self.add_to("activePokemonArea", make_card(REGISTEEL), P1) - defender = self.add_to("activePokemonArea", make_card(REGIGIGAS), P2) # retreat 4 - attack = REGISTEEL.abilities[1] - await resolve_attack(self.session, P1, attacker, attack, attack.ability_id) - # 220 - 50*4 = 20 - self.assertEqual(defender.get_attribute(AttrID.HP), 150 - 20) - - async def test_dragon_energy_scales_with_damage_counters(self): - attacker = self.add_to("activePokemonArea", make_card(REGIDRAGO_EVS), P1) - attacker.set_attribute(AttrID.HP, 130 - 30) # 3 damage counters - defender = self.add_to("activePokemonArea", make_card(PLAIN_BIG), P2) # 330 - attack = REGIDRAGO_EVS.abilities[1] - await resolve_attack(self.session, P1, attacker, attack, attack.ability_id) - # 240 - 20*3 = 180 - self.assertEqual(defender.get_attribute(AttrID.HP), 330 - 180) - - -class TestBlizzardBind(LostZoneRegiBase): - async def test_locks_opponent_v_attacks_next_turn(self): - self.session.turn_state.turn_number = 5 - attacker = self.add_to("activePokemonArea", make_card(REGICE), P1) - defender = self.add_to("activePokemonArea", make_card(CROBAT_V), P2) - attack = REGICE.abilities[1] - await resolve_attack(self.session, P1, attacker, attack, attack.ability_id) - venomous = CROBAT_V.abilities[1].ability_id - # Locked through the opponent's next turn (6), free again by turn 7. - self.assertTrue( - self.session.turn_state.attack_locked(defender.entity_id, venomous) - ) - self.session.turn_state.turn_number = 7 - self.assertFalse( - self.session.turn_state.attack_locked(defender.entity_id, venomous) - ) - - -class TestLostDive(LostZoneRegiBase): - async def test_puts_top_three_in_lost_zone(self): - attacker = self.add_to("activePokemonArea", make_card(AERO_VSTAR), P1) - self.add_to("activePokemonArea", make_card(PLAIN_BIG), P2) - for _ in range(5): - self.add_to("deck", make_card(PLAIN_BIG), P1) - attack = AERO_VSTAR.abilities[0] - await resolve_attack(self.session, P1, attacker, attack, attack.ability_id) - self.assertEqual(len(self.board.find_player_area(P1, "lostZone").children), 3) - self.assertEqual(len(self.board.find_player_area(P1, "deck").children), 2) - - -class TestAncientStar(LostZoneRegiBase): - async def test_locks_opponent_v_abilities(self): - from spirit.game.session.passives import ability_locked - attacker = self.add_to("activePokemonArea", make_card(AERO_VSTAR), P1) - opp_v = self.add_to("bench", make_card(CROBAT_V), P2) - self.assertFalse(ability_locked(self.board, opp_v)) - ctx = EffectContext(self.session, P1, attacker, AERO_VSTAR.abilities[1]) - await __import__( - "spirit.game.card_effects.pokemon", fromlist=["ancient_star"] - ).ancient_star(ctx) - self.assertTrue(ability_locked(self.board, opp_v)) - - -class TestScoopUpNet(LostZoneRegiBase): - async def test_returns_benched_pokemon_and_discards_attachments(self): - self.add_to("activePokemonArea", make_card(REGIROCK), P1) - benched = self.add_to("bench", make_card(SABLEYE), P1) - self.attach(make_card(WATER_ENERGY_DEF), benched, P1) - source = self.make_source() - ctx = EffectContext(self.session, P1, source, None) - self.session.chooser_replies.append([benched.entity_id]) - await scoop_up_net(ctx) - await self.session.send_game_sequence_flush(ctx) - hand = self.board.find_player_area(P1, "hand") - self.assertIn(benched.entity_id, [c.entity_id for c in hand.children]) - self.assertEqual(len(self.board.find_player_area(P1, "bench").children), 0) - self.assertEqual(len(self.board.find_player_area(P1, "discard").children), 1) - - async def test_damage_counters_cleared_when_returned_to_hand(self): - self.add_to("activePokemonArea", make_card(REGIROCK), P1) - benched = self.add_to("bench", make_card(SABLEYE), P1) - printed_hp = benched.attribute_originals[AttrID.HP.value] - benched.set_attribute(AttrID.HP, printed_hp - 30) # 3 damage counters - source = self.make_source() - ctx = EffectContext(self.session, P1, source, None) - self.session.chooser_replies.append([benched.entity_id]) - await scoop_up_net(ctx) - await self.session.send_game_sequence_flush(ctx) - # Back in hand as a fresh card: no damage survives. - self.assertEqual(benched.get_attribute(AttrID.HP), printed_hp) - - -class TestSwitchCart(LostZoneRegiBase): - async def test_switch_and_heal(self): - active = self.add_to("activePokemonArea", make_card(SABLEYE), P1) # basic - active.set_attribute(AttrID.HP, 80 - 50) # damaged - benched = self.add_to("bench", make_card(REGIROCK), P1) - source = self.make_source() - ctx = EffectContext(self.session, P1, source, None) - self.session.chooser_replies.append([benched.entity_id]) - await switch_cart(ctx) - self.assertIs(self.board.active_pokemon(P1), benched) - # Old active moved to bench, healed 30 (30 -> 60). - self.assertEqual(active.get_attribute(AttrID.HP), 60) - - -class TestEnergyRecycler(LostZoneRegiBase): - async def test_shuffles_basic_energy_into_deck(self): - for _ in range(3): - self.add_to("discard", make_card(WATER_ENERGY_DEF), P1) - source = self.make_source() - ctx = EffectContext(self.session, P1, source, None) - # default chooser reply picks all available up to count - await energy_recycler(ctx) - self.assertEqual(len(self.board.find_player_area(P1, "deck").children), 3) - self.assertEqual(len(self.board.find_player_area(P1, "discard").children), 0) - - -class TestColressExperiment(LostZoneRegiBase): - async def test_take_three_lost_two(self): - cards = [self.add_to("deck", make_card(PLAIN_BIG), P1) for _ in range(5)] - source = self.make_source() - ctx = EffectContext(self.session, P1, source, None) - top3 = [c.entity_id for c in cards[-3:]] - self.session.chooser_replies.append(top3) - await colresss_experiment(ctx) - self.assertEqual(len(self.board.find_player_area(P1, "hand").children), 3) - self.assertEqual(len(self.board.find_player_area(P1, "lostZone").children), 2) - - -class TestTrekkingShoes(LostZoneRegiBase): - def _intro_brackets(self, entity_id): - """(viewer_ids, name) of brackets carrying an EntityIntroduced for entity_id.""" - out = [] - for viewer_ids, name, msgs in self.session.sent: - for m in msgs: - value = m.get("value", {}) if isinstance(m, dict) else {} - if m["name"] == "EntityIntroduced" and value.get("entityID") == entity_id: - out.append((viewer_ids, name)) - return out - - def _assert_no_reveal_brackets(self, entity_id): - """No RevealCardToAllEffect for entity_id anywhere (the panel version - never parks the card in multiPresentArea).""" - for _, _, msgs in self.session.sent: - for m in msgs: - value = m.get("value", {}) if isinstance(m, dict) else {} - self.assertFalse( - m["name"] == "RevealCardToAllEffect" and value.get("entityID") == entity_id - ) - - async def test_take_into_hand(self): - top = self.add_to("deck", make_card(PLAIN_BIG), P1) - source = self.make_source() - ctx = EffectContext(self.session, P1, source, None) - self.session.choice_replies.append(0) # Put into hand - await trekking_shoes(ctx) - hand = self.board.find_player_area(P1, "hand") - self.assertIn(top.entity_id, [c.entity_id for c in hand.children]) - self.assertEqual(len(self.board.find_player_area(P1, "discard").children), 0) - # Owner-only intro, no reveal park anywhere. - self.assertEqual(self._intro_brackets(top.entity_id), [([P1], "SerialSequence")]) - self._assert_no_reveal_brackets(top.entity_id) - # Floating ability panel on the revealed card (not the plain centered dialog). - self.assertEqual(len(self.session.panel_calls), 1) - player_id, source_id, prompt, buttons = self.session.panel_calls[0] - self.assertEqual(player_id, P1) - self.assertEqual(source_id, top.entity_id) - self.assertEqual(len(buttons), 2) - - async def test_decline_discards_and_draws(self): - top = self.add_to("deck", make_card(PLAIN_BIG), P1) - under = self.add_to("deck", make_card(PLAIN_BIG), P1) # becomes new top after discard - # deck order: [top, under]; top-of-deck is last child = 'under' - source = self.make_source() - ctx = EffectContext(self.session, P1, source, None) - self.session.choice_replies.append(1) # Discard and draw a card - await trekking_shoes(ctx) - discard = self.board.find_player_area(P1, "discard") - hand = self.board.find_player_area(P1, "hand") - self.assertEqual(len(discard.children), 1) # the peeked top card - self.assertEqual(len(hand.children), 1) # drew a card - self.assertEqual(self._intro_brackets(under.entity_id), [([P1], "SerialSequence")]) - self._assert_no_reveal_brackets(under.entity_id) - self.assertEqual(len(self.session.panel_calls), 1) - player_id, source_id, prompt, buttons = self.session.panel_calls[0] - self.assertEqual(player_id, P1) - self.assertEqual(source_id, under.entity_id) - self.assertEqual(len(buttons), 2) - - async def test_empty_deck_no_op(self): - source = self.make_source() - ctx = EffectContext(self.session, P1, source, None) - await trekking_shoes(ctx) - self.assertEqual(len(self.session.panel_calls), 0) - self.assertEqual(len(self.session.prompts), 0) - self.assertEqual(len(self.session.sent), 0) - - -class TestRegiGate(LostZoneRegiBase): - async def test_searches_basic_onto_bench(self): - attacker = self.add_to("activePokemonArea", make_card(REGIROCK), P1) - self.add_to("activePokemonArea", make_card(PLAIN_BIG), P2) - basic = self.add_to("deck", make_card(PLAIN_BIG), P1) - attack = REGIROCK.abilities[0] - self.session.chooser_replies.append([basic.entity_id]) - await resolve_attack(self.session, P1, attacker, attack, attack.ability_id) - self.assertIn(basic.entity_id, - [c.entity_id for c in self.board.find_player_area(P1, "bench").children]) - - -class TestWildStyle(LostZoneRegiBase): - async def test_cost_reduction_per_opposing_special_pokemon(self): - active = self.add_to("activePokemonArea", make_card(DRAPION_V), P1) - cost = {"Colorless": 4} - self.assertEqual(effective_attack_cost(self.board, active, dict(cost)), cost) - self.add_to("bench", make_card(FUSION_DEF), P2) - self.add_to("activePokemonArea", make_card(FUSION_DEF), P2) - self.assertEqual( - effective_attack_cost(self.board, active, dict(cost)), {"Colorless": 2} - ) - - -class TestEnergies(LostZoneRegiBase): - async def test_twin_energy_provides_two(self): - twin = create_card_entity(make_card(TWIN_ENERGY), owning_player_id=P1) - self.assertEqual(energy_provided_count(twin), 2) - - async def test_gift_energy_draws_to_seven(self): - source = self.add_to("activePokemonArea", make_card(REGIROCK), P1) - for _ in range(10): - self.add_to("deck", make_card(PLAIN_BIG), P1) - self.add_to("hand", make_card(PLAIN_BIG), P1) # 1 card in hand - ctx = EffectContext(self.session, P1, source, None) - await gift_energy_on_ko(ctx) - self.assertEqual(len(self.board.find_player_area(P1, "hand").children), 7) - - -class TestDarkAsset(LostZoneRegiBase): - async def test_draws_to_six_when_accepted(self): - source = self.add_to("bench", make_card(CROBAT_V), P1) - for _ in range(10): - self.add_to("deck", make_card(PLAIN_BIG), P1) - self.add_to("hand", make_card(PLAIN_BIG), P1) # 1 in hand - ctx = EffectContext(self.session, P1, source, CROBAT_V.abilities[0]) - self.session.choice_replies.append(0) # "Yes" - await dark_asset(ctx) - self.assertEqual(len(self.board.find_player_area(P1, "hand").children), 6) - - -class TestMoonlightShuriken(LostZoneRegiBase): - async def test_one_chooser_resolves_both_targets(self): - attacker = self.add_to("activePokemonArea", make_card(RADIANT_GRENINJA), P1) - self.attach(make_card(WATER_ENERGY_DEF), attacker, P1) - self.attach(make_card(WATER_ENERGY_DEF), attacker, P1) - opp_active = self.add_to("activePokemonArea", make_card(WEAK_TO_WATER_DEF), P2) - opp_bench = self.add_to("bench", make_card(PLAIN_BIG), P2) - attack = RADIANT_GRENINJA.abilities[1] - self.session.chooser_replies.append([opp_active.entity_id, opp_bench.entity_id]) - - await resolve_attack(self.session, P1, attacker, attack, attack.ability_id) - - self.assertEqual(len(self.session.chooser_calls), 1) - self.assertEqual(self.session.chooser_calls[0][2], 2) - # Active takes Weakness (x2, Greninja is Water); Benched does not. - self.assertEqual(opp_active.get_attribute(AttrID.HP), 200 - 180) - self.assertEqual(opp_bench.get_attribute(AttrID.HP), 330 - 90) - - -class TestMirageGateCondition(LostZoneRegiBase): - async def test_requires_seven_lost_zone(self): - self.fill_lost_zone(6) - self.assertFalse(mirage_gate_condition(self.board, P1)) - self.add_to("lostZone", make_card(PLAIN_BIG), P1) - self.assertTrue(mirage_gate_condition(self.board, P1)) - - -class TestMirageGate(LostZoneRegiBase): - async def test_one_group_per_type_then_per_energy_attach_picks(self): - source = self.make_source(player_id=P1) - # Multiple basics of the same type: only ONE representative per type - # should reach the browser. - water1 = self.add_to("deck", make_card(WATER_ENERGY_DEF), P1) - water2 = self.add_to("deck", make_card(WATER_ENERGY_DEF), P1) - psychic1 = self.add_to("deck", make_card(PSYCHIC_ENERGY_DEF), P1) - psychic2 = self.add_to("deck", make_card(PSYCHIC_ENERGY_DEF), P1) - pokemon = self.add_to("activePokemonArea", make_card(PLAIN_BIG), P1) - ctx = EffectContext(self.session, P1, source, None) - self.session.chooser_replies.append([water1.entity_id, psychic1.entity_id]) # energy pick - self.session.chooser_replies.append([pokemon.entity_id]) # attach #1 - self.session.chooser_replies.append([pokemon.entity_id]) # attach #2 - - await mirage_gate(ctx) - - energies = self.board.attached_energies(pokemon) - self.assertEqual(len(energies), 2) - self.assertIn(water1, energies) - self.assertIn(psychic1, energies) - self.assertEqual(len(self.session.chooser_calls), 3) - energy_call_ids = self.session.chooser_calls[0][1] - self.assertEqual(set(energy_call_ids), {water1.entity_id, psychic1.entity_id}) - self.assertNotIn(water2.entity_id, energy_call_ids) - self.assertNotIn(psychic2.entity_id, energy_call_ids) - - async def test_whiffs_with_no_basic_energy_in_deck(self): - source = self.make_source(player_id=P1) - self.add_to("deck", make_card(PLAIN_BIG), P1) - ctx = EffectContext(self.session, P1, source, None) - await mirage_gate(ctx) - self.assertEqual(len(self.session.chooser_calls), 1) # the whiffed browse - - -class TestMirageGateSingleGroupOffer(unittest.IsolatedAsyncioTestCase): - """Wire shape of the plain single-group reveal browser mirage_gate now - uses: one representative card per basic-Energy type, not an - AnyComposite browser (that node kind misrenders on the client).""" - - class _MockClient: - def __init__(self, account_id, username): - self.player = type("P", (), { - "account_id": account_id, "username": username, - "screen_name": username, "avatar_decks": [], - })() - self.addr = ("127.0.0.1", 12345) - self.sent_packets = [] - - async def send_packet(self, data, request_id=0, flags=0): - self.sent_packets.append(data) - - def setUp(self): - self.client1 = self._MockClient(P1, "Ash") - self.client2 = self._MockClient(P2, "Gary") - pairing = { - "is_solo": False, - "format": "Standard", - "players": { - P1: {"client": self.client1, "deck": {"deckID": "d1", "deckName": "A", "cards": []}, "ready": True}, - P2: {"client": self.client2, "deck": {"deckID": "d2", "deckName": "B", "cards": []}, "ready": True}, - }, - } - self.session = GameSession(str(uuid.uuid4()), pairing) - self.board = self.session.board_state - self.captured_offer = None - - async def fake_prompt(player, msg_name, offer, expected_counter=None): - self.captured_offer = offer - return {"selection": {"targetResponses": [ - {"entityList": [self.water.entity_id, self.psychic.entity_id]} - ]}} - self.session.prompt_selection_message = fake_prompt - - deck = self.board.find_player_area(P1, "deck") - self.water = create_card_entity(make_card(WATER_ENERGY_DEF), owning_player_id=P1) - self.board.add_card_to_area(self.water, deck) - self.water2 = create_card_entity(make_card(WATER_ENERGY_DEF), owning_player_id=P1) - self.board.add_card_to_area(self.water2, deck) - self.psychic = create_card_entity(make_card(PSYCHIC_ENERGY_DEF), owning_player_id=P1) - self.board.add_card_to_area(self.psychic, deck) - self.source = create_card_entity(make_card(REGIROCK), owning_player_id=P1) - - async def test_offer_shape(self): - ctx = EffectContext(self.session, P1, self.source, None) - reps = [self.water, self.psychic] - picked = await ctx.choose_cards( - reps, 2, minimum=0, - prompt="Choose up to 2 basic Energy cards of different types.", - display_cards=reps, - ) - - offer = self.captured_offer - node = next(iter(offer["targetMap"].values()))[0] - self.assertEqual(node["name"], "CompositeRevealEntityListTargetInformation") - self.assertEqual(offer["targetType"], "CompositeRevealEntityListTargetInformation") - self.assertEqual(node["numberToSelect"], 2) - self.assertEqual(node["minimumToSelect"], 0) - self.assertFalse(node["forced"]) - self.assertEqual(set(node["revealEntities"].keys()), - {self.water.entity_id, self.psychic.entity_id}) - self.assertNotIn(self.water2.entity_id, node["revealEntities"]) - self.assertEqual(set(node["validTargets"]), - {self.water.entity_id, self.psychic.entity_id}) - self.assertTrue(node["targetPrompt"]) - self.assertEqual(picked, [self.water, self.psychic]) - - -class TestDamageCounterPlacementOffer(unittest.IsolatedAsyncioTestCase): - """Wire shape of the native click-to-place damage-counter picker (Sableye's - Lost Mine): MultiSelectEntityListTargetInformation node (command Q.N), and - a MultiSelectEntityListTargetResponse-shaped reply.""" - - class _MockClient: - def __init__(self, account_id, username): - self.player = type("P", (), { - "account_id": account_id, "username": username, - "screen_name": username, "avatar_decks": [], - })() - self.addr = ("127.0.0.1", 12345) - self.sent_packets = [] - - async def send_packet(self, data, request_id=0, flags=0): - self.sent_packets.append(data) - - def setUp(self): - self.client1 = self._MockClient(P1, "Ash") - self.client2 = self._MockClient(P2, "Gary") - pairing = { - "is_solo": False, - "format": "Standard", - "players": { - P1: {"client": self.client1, "deck": {"deckID": "d1", "deckName": "A", "cards": []}, "ready": True}, - P2: {"client": self.client2, "deck": {"deckID": "d2", "deckName": "B", "cards": []}, "ready": True}, - }, - } - self.session = GameSession(str(uuid.uuid4()), pairing) - self.board = self.session.board_state - self.captured_offer = None - - self.source = create_card_entity(make_card(SABLEYE), owning_player_id=P1) - self.active = create_card_entity(make_card(REGIROCK), owning_player_id=P2) - self.board.add_card_to_area( - self.active, self.board.find_player_area(P2, "activePokemonArea") - ) - self.benched = create_card_entity(make_card(REGICE), owning_player_id=P2) - self.board.add_card_to_area( - self.benched, self.board.find_player_area(P2, "bench") - ) - - async def fake_prompt(player, msg_name, offer, expected_counter=None): - self.captured_offer = offer - return {"selection": {"targetResponses": [ - {"entities": [ - {"target": self.active.entity_id, "selections": 9}, - {"target": self.benched.entity_id, "selections": 3}, - ]} - ]}} - self.session.prompt_selection_message = fake_prompt - - async def test_offer_shape_and_reply_parsing(self): - ctx = EffectContext(self.session, P1, self.source, None) - await ctx.place_damage_counters(12, ctx.opponent_pokemon_in_play()) - - offer = self.captured_offer - node = next(iter(offer["targetMap"].values()))[0] - self.assertEqual(node["name"], "MultiSelectEntityListTargetInformation") - self.assertEqual(offer["targetType"], "MultiSelectEntityListTargetInformation") - self.assertEqual(node["numberToSelect"], 12) - self.assertEqual(node["minimumToSelect"], 12) - self.assertTrue(node["forced"]) - self.assertEqual(node["amountPerClick"], 10) - self.assertIsNotNone(node.get("hintTargetMap")) - self.assertTrue(node["targetPrompt"]) - self.assertEqual( - set(node["validTargets"]), {self.active.entity_id, self.benched.entity_id} - ) - - self.assertEqual(self.active.get_attribute(AttrID.HP), 130 - 90) - self.assertEqual(self.benched.get_attribute(AttrID.HP), 130 - 30) - - -class TestPublicPileReveal(LostZoneRegiBase): - """A card moved into a public pile from a zone the owner can't see (deck / - prizes) must be introduced to BOTH viewers -- otherwise the owner's pile - renders a faceless card back and the Lost Zone viewer NREs on the missing - archetype attribute (a.k.InitializeStackedCards).""" - - def _intros_to(self, entity_id, viewer): - return [m for vids, _name, msgs in self.session.sent if vids == [viewer] - for m in msgs - if m.get("name") == "EntityIntroduced" - and m["value"]["entityID"] == entity_id] - - async def test_deck_card_to_lost_zone_introduces_to_both(self): - source = self.add_to("activePokemonArea", make_card(REGIROCK), P1) - deck_card = self.add_to("deck", make_card(PLAIN_BIG), P1) - ctx = EffectContext(self.session, P1, source, None) - await ctx.move_to_lost_zone([deck_card]) - await self.session.send_game_sequence_flush(ctx) - - self.assertEqual(len(self._intros_to(deck_card.entity_id, P1)), 1) - self.assertEqual(len(self._intros_to(deck_card.entity_id, P2)), 1) - lost = self.board.find_player_area(P1, "lostZone") - self.assertIn(deck_card, lost.children) - - async def test_hand_card_to_discard_intros_opponent_only(self): - # A hand card is already visible to its owner -- no redundant owner - # intro (the opponent still needs one, hand is hidden from them). - source = self.add_to("activePokemonArea", make_card(REGIROCK), P1) - hand_card = self.add_to("hand", make_card(PLAIN_BIG), P1) - ctx = EffectContext(self.session, P1, source, None) - await ctx.discard_cards([hand_card]) - await self.session.send_game_sequence_flush(ctx) - - self.assertEqual(len(self._intros_to(hand_card.entity_id, P1)), 0) - self.assertEqual(len(self._intros_to(hand_card.entity_id, P2)), 1) diff --git a/spirit/tests/test_lugia_deck.py b/spirit/tests/test_lugia_deck.py deleted file mode 100644 index 533ff79..0000000 --- a/spirit/tests/test_lugia_deck.py +++ /dev/null @@ -1,677 +0,0 @@ -"""End-to-end unit tests for the Lugia VSTAR deck cards and the engine -features they exercise (passives, choosers, switches, conditions, VSTAR).""" - -import unittest - -from spirit.tests.test_effects import EffectsTestBase, P1, P2 - -from spirit.game.attributes import AttrID, PokemonTypes -from spirit.game.data_utils import CARD_DEFS_BY_GUID, EnergyCardDef -from spirit.game.models.board import BoardState -from spirit.game.scripts.cards import loader -from spirit.game.session.effects import ( - EffectContext, - resolve_attack, - resolve_energy_attach_cost, - resolve_energy_on_attach, - resolve_trainer_effect, -) -from spirit.game.session.legal_actions import ( - ACTION_ATTACH_TOOL, - ACTION_PLAY_ENERGY, - ACTION_RETREAT, - ACTION_USE_ABILITY, - ACTION_USE_ATTACK, - ACTION_USE_TRAINER, - TurnState, - compute_legal_actions, -) -from spirit.game.session.passives import ( - compute_damage, - effective_attack_cost, - effective_max_hp, -) - -GAME_ID = "test-game" - -# Archetype GUIDs of the deck's printings (spirit/game/scripts/cards). -DUNSPARCE = "294dcbed-7729-5d2d-bee6-0f911544a3cf" -LUGIA_V = "fb5563a2-d94c-5699-b89a-834d1c68c53c" -LUGIA_VSTAR = "03ef5e0c-e166-56af-9dd9-3fc40b576f39" -ARCHEOPS = "b1ff4ef4-88a9-5f17-b940-0194d40dff6f" -ORANGURU = "caae62aa-a272-51ae-8178-079147fd65aa" -SNORLAX = "4915ec3e-4ad8-59dd-b135-e34a0b43e434" -STOUTLAND_V = "c25c58bd-765c-55fc-b661-aec1b4aef657" -YVELTAL = "69e7c69a-a11f-5f8f-a4c5-483669997e7f" -RADIANT_CHARIZARD = "dbdfb6ff-6850-5078-972c-47c3ce9ed97c" -RAIKOU = "a4d02546-82ac-5b28-8171-04f4e4f14409" -PUMPKABOO = "04560a42-d19b-5255-9214-ab55f98a18ee" -LUMINEON_V = "b4ce3a0e-da4d-5478-899a-ec0a364bc59a" -MANAPHY = "fe53515c-30ea-5867-bd89-f4ee7b6fc52c" - -PROF_RESEARCH = "39450361-45e4-5450-b8eb-e0d5b5b4789b" -MARNIE = "ed3f56a6-383b-516b-a693-6f903d6b679f" -BOSSS_ORDERS = "6bf73995-dba1-5433-bc30-fbbd15d6d282" -SERENA = "9da91305-afcf-57f7-971a-c0ab57422f13" -IRIDA = "99351945-9d97-5a0e-a68f-3cead4b2ba95" -QUICK_BALL = "cdaf82a1-0150-5052-b1ee-eb3fbe5437b6" -EVOLUTION_INCENSE = "74403d98-c7f0-5c0c-8cf8-82bb6f1a51b6" -LOST_VACUUM = "60a35514-a1e0-5c3a-9ff0-1fe1dd718e18" -CHOICE_BELT = "a4309622-34a1-5398-9237-091ad3bc0acd" -ESCAPE_ROPE = "49a44b53-3548-5b2f-ab20-68bf4bab240a" -ULTRA_BALL = "fb774725-4a88-5dd0-888d-b53a1488c226" - -POWERFUL_COLORLESS = "f38454bf-14b8-5b80-906d-feb90569337c" -AURORA = "cd7f0518-76e6-5249-bd0a-b7c75e76e096" -CAPTURE = "7622bd93-cd34-54c4-8104-a93f372d145e" -DOUBLE_TURBO = "f2c20770-b08b-5f5b-bb9a-7ec86ad399a6" -HEAT_FIRE = "2d92cb2b-50fc-5129-875d-e9ab2a698d74" -SPEED_LIGHTNING = "9ddcc31e-666e-5832-9071-54d5cb2e6bdf" -V_GUARD = "77d43395-0432-5739-84d4-4581bd0a5a83" - -# Plain typed energy for stocking attack costs in tests. -FIRE_TEST_ENERGY = EnergyCardDef( - guid="00000000-0000-0000-0000-00000000fe01", - key="BW1", name="com.test.energy.Fire.Name", - collector_number=901, set_code="BW1", rarity=0, - energy_type=PokemonTypes.FIRE, -) - - -def setUpModule(): - if not loader.cards_by_guid: - loader.load_all() - - -def deck_card(guid): - return loader.cards_by_guid[guid] - - -def ability_named(guid, title): - definition = CARD_DEFS_BY_GUID[guid] - return next(a for a in definition.abilities if a.title == title) - - -class LugiaDeckTestBase(EffectsTestBase): - def add_deck_card(self, area_name, guid, player_id): - return self.add_to(area_name, deck_card(guid), player_id) - - def attach(self, guid, pokemon, player_id): - card = self.add_to("discard", deck_card(guid), player_id) - self.board.attach_card(card.entity_id, pokemon.entity_id) - return card - - def attack_ctx(self, attacker, guid, title): - return EffectContext(self.session, P1, attacker, ability_named(guid, title)) - - -# ---------------------------------------------------------------------- -# Damage-pipeline passives -# ---------------------------------------------------------------------- - -class TestDamagePassives(LugiaDeckTestBase): - def test_choice_belt_adds_30_versus_active_v(self): - lugia = self.add_deck_card("activePokemonArea", LUGIA_V, P1) - stoutland = self.add_deck_card("activePokemonArea", STOUTLAND_V, P2) - self.attach(CHOICE_BELT, lugia, P1) - calc = compute_damage(self.board, lugia, stoutland, 130) - self.assertEqual(calc.amount, 160) - - def test_choice_belt_ignores_non_v_targets(self): - lugia = self.add_deck_card("activePokemonArea", LUGIA_V, P1) - snorlax = self.add_deck_card("activePokemonArea", SNORLAX, P2) - self.attach(CHOICE_BELT, lugia, P1) - calc = compute_damage(self.board, lugia, snorlax, 130) - self.assertEqual(calc.amount, 130) - - def test_powerful_colorless_adds_20_to_active_only(self): - lugia = self.add_deck_card("activePokemonArea", LUGIA_V, P1) - active = self.add_deck_card("activePokemonArea", SNORLAX, P2) - benched = self.add_deck_card("bench", ORANGURU, P2) - self.attach(POWERFUL_COLORLESS, lugia, P1) - self.assertEqual(compute_damage(self.board, lugia, active, 130).amount, 150) - self.assertEqual( - compute_damage(self.board, lugia, benched, 30, apply_modifiers=False).amount, - 30, - ) - - def test_double_turbo_reduces_damage_by_20(self): - lugia = self.add_deck_card("activePokemonArea", LUGIA_V, P1) - snorlax = self.add_deck_card("activePokemonArea", SNORLAX, P2) - self.attach(DOUBLE_TURBO, lugia, P1) - self.assertEqual(compute_damage(self.board, lugia, snorlax, 130).amount, 110) - - def test_v_guard_reduces_damage_from_v_attackers(self): - stoutland = self.add_deck_card("activePokemonArea", STOUTLAND_V, P1) - lugia = self.add_deck_card("activePokemonArea", LUGIA_V, P2) - self.attach(V_GUARD, lugia, P2) - self.assertEqual(compute_damage(self.board, stoutland, lugia, 40).amount, 10) - - def test_v_guard_ignores_non_v_attackers(self): - snorlax = self.add_deck_card("activePokemonArea", SNORLAX, P1) - lugia = self.add_deck_card("activePokemonArea", LUGIA_V, P2) - self.attach(V_GUARD, lugia, P2) - self.assertEqual(compute_damage(self.board, snorlax, lugia, 180).amount, 180) - - def test_v_guard_does_not_stack(self): - stoutland = self.add_deck_card("activePokemonArea", STOUTLAND_V, P1) - lugia = self.add_deck_card("activePokemonArea", LUGIA_V, P2) - self.attach(V_GUARD, lugia, P2) - self.attach(V_GUARD, lugia, P2) - # "This effect can't be applied more than once at a time." - self.assertEqual(compute_damage(self.board, stoutland, lugia, 70).amount, 40) - - def test_mysterious_nest_removes_colorless_weakness(self): - raikou = self.add_deck_card("activePokemonArea", RAIKOU, P1) - lugia = self.add_deck_card("activePokemonArea", LUGIA_V, P2) - self.assertEqual(compute_damage(self.board, raikou, lugia, 120).amount, 240) - self.add_deck_card("bench", DUNSPARCE, P1) - self.assertEqual(compute_damage(self.board, raikou, lugia, 120).amount, 120) - - def test_excited_heart_discounts_colorless_cost(self): - charizard = self.add_deck_card("activePokemonArea", RADIANT_CHARIZARD, P1) - printed = {"Fire": 1, "Colorless": 4} - self.assertEqual( - effective_attack_cost(self.board, charizard, printed), printed - ) - # Two prizes taken by the opponent: 6 dealt, 4 left in the pile. - self.board.prizes_dealt[P2] = 6 - for _ in range(4): - self.add_deck_card("prizePile", DUNSPARCE, P2) - self.assertEqual( - effective_attack_cost(self.board, charizard, printed), - {"Fire": 1, "Colorless": 2}, - ) - - def test_heat_fire_grants_20_max_hp(self): - charizard = self.add_deck_card("activePokemonArea", RADIANT_CHARIZARD, P1) - self.assertEqual(effective_max_hp(self.board, charizard), 160) - self.attach(HEAT_FIRE, charizard, P1) - self.assertEqual(effective_max_hp(self.board, charizard), 180) - - def test_heat_fire_grants_no_hp_to_non_fire_pokemon(self): - lugia = self.add_deck_card("activePokemonArea", LUGIA_V, P1) - base = effective_max_hp(self.board, lugia) - self.attach(HEAT_FIRE, lugia, P1) - self.assertEqual(effective_max_hp(self.board, lugia), base) - - -# ---------------------------------------------------------------------- -# Pokemon effects -# ---------------------------------------------------------------------- - -class TestPokemonEffects(LugiaDeckTestBase): - async def test_read_the_wind_discards_then_draws_3(self): - lugia = self.add_deck_card("activePokemonArea", LUGIA_V, P1) - self.add_deck_card("activePokemonArea", SNORLAX, P2) - fodder = self.add_deck_card("hand", QUICK_BALL, P1) - for _ in range(3): - self.add_deck_card("deck", DUNSPARCE, P1) - await resolve_attack(self.session, P1, lugia, - ability_named(LUGIA_V, "Read the Wind"), "a-1") - discard = self.board.find_player_area(P1, "discard") - self.assertIn(fodder, discard.children) - self.assertEqual(len(self.board.find_player_area(P1, "hand").children), 3) - - async def test_tempest_dive_discards_stadium_on_yes(self): - lugia = self.add_deck_card("activePokemonArea", LUGIA_VSTAR, P1) - self.add_deck_card("activePokemonArea", SNORLAX, P2) - stadium_area = self.board.find_global_area("activeStadium") - stadium = self.add_deck_card("discard", QUICK_BALL, P1) - self.board.move_card(stadium.entity_id, stadium_area.entity_id) - self.session.choice_replies = [0] # Yes - ctx = self.attack_ctx(lugia, LUGIA_VSTAR, "Tempest Dive") - await ability_named(LUGIA_VSTAR, "Tempest Dive").effect(ctx) - self.assertEqual(stadium_area.children, []) - - async def test_summoning_star_benches_only_rule_box_free_colorless(self): - lugia = self.add_deck_card("activePokemonArea", LUGIA_VSTAR, P1) - dunsparce = self.add_deck_card("discard", DUNSPARCE, P1) - lugia_v = self.add_deck_card("discard", LUGIA_V, P1) - ctx = EffectContext(self.session, P1, lugia, - ability_named(LUGIA_VSTAR, "Summoning Star")) - await ability_named(LUGIA_VSTAR, "Summoning Star").effect(ctx) - offered = self.session.chooser_calls[0][1] - self.assertEqual(offered, [dunsparce.entity_id]) - # The discard pile is public: the pick may not choose zero. - self.assertEqual(self.session.chooser_calls[0][3], 1) - bench = self.board.find_player_area(P1, "bench") - self.assertIn(dunsparce, bench.children) - self.assertNotIn(lugia_v, bench.children) - - async def test_primate_wisdom_swaps_hand_card_with_deck_top(self): - oranguru = self.add_deck_card("bench", ORANGURU, P1) - hand_card = self.add_deck_card("hand", QUICK_BALL, P1) - bottom = self.add_deck_card("deck", DUNSPARCE, P1) - top = self.add_deck_card("deck", MANAPHY, P1) - ctx = EffectContext(self.session, P1, oranguru, - ability_named(ORANGURU, "Primate Wisdom")) - await ability_named(ORANGURU, "Primate Wisdom").effect(ctx) - deck = self.board.find_player_area(P1, "deck") - hand = self.board.find_player_area(P1, "hand") - self.assertIn(top, hand.children) - self.assertEqual(deck.children[-1], hand_card) # new deck top - self.assertIn(bottom, deck.children) - - async def test_thumping_snore_puts_self_asleep_with_two_coin_checkup(self): - snorlax = self.add_deck_card("activePokemonArea", SNORLAX, P1) - self.add_deck_card("activePokemonArea", LUGIA_V, P2) - ctx = self.attack_ctx(snorlax, SNORLAX, "Thumping Snore") - await ability_named(SNORLAX, "Thumping Snore").effect(ctx) - self.assertEqual( - snorlax.get_attribute(AttrID.SPECIAL_CONDITIONS), ["Asleep"] - ) - self.assertEqual(self.session.sleep_checkup_coins[snorlax.entity_id], 2) - # The AddSpecialCondition bracket must lead with the "Target" data - # effect (the executor's ctor indexes it, same as Remove/Evolve). - runs = ctx.bracket_runs_for(P1) - add_run = next(msgs for name, msgs in runs if name == "AddSpecialCondition") - self.assertEqual(add_run[0]["name"], "EntityIDDataEffect") - self.assertEqual(add_run[0]["value"]["key"], "Target") - self.assertEqual(add_run[0]["value"]["value"], snorlax.entity_id) - - async def test_unfazed_fat_blocks_amazing_destruction(self): - yveltal = self.add_deck_card("activePokemonArea", YVELTAL, P1) - snorlax = self.add_deck_card("activePokemonArea", SNORLAX, P2) - ctx = self.attack_ctx(yveltal, YVELTAL, "Amazing Destruction") - await ability_named(YVELTAL, "Amazing Destruction").effect(ctx) - self.assertEqual(ctx.knockouts, []) - self.assertGreater(snorlax.get_attribute(AttrID.HP), 0) - - async def test_amazing_destruction_kos_unprotected_active(self): - yveltal = self.add_deck_card("activePokemonArea", YVELTAL, P1) - lugia = self.add_deck_card("activePokemonArea", LUGIA_V, P2) - ctx = self.attack_ctx(yveltal, YVELTAL, "Amazing Destruction") - await ability_named(YVELTAL, "Amazing Destruction").effect(ctx) - self.assertIn(lugia, ctx.knockouts) - - async def test_amazing_shot_bench_damage_blocked_by_wave_veil(self): - raikou = self.add_deck_card("activePokemonArea", RAIKOU, P1) - active = self.add_deck_card("activePokemonArea", SNORLAX, P2) - manaphy = self.add_deck_card("bench", MANAPHY, P2) - ctx = self.attack_ctx(raikou, RAIKOU, "Amazing Shot") - await ability_named(RAIKOU, "Amazing Shot").effect(ctx) - self.assertEqual(active.get_attribute(AttrID.HP), 150 - 120) - self.assertEqual(manaphy.get_attribute(AttrID.HP), 70) # protected - - async def test_double_dip_fangs_takes_extra_prize_on_basic_ko(self): - stoutland = self.add_deck_card("activePokemonArea", STOUTLAND_V, P1) - dunsparce = self.add_deck_card("activePokemonArea", DUNSPARCE, P2) - dunsparce.set_attribute(AttrID.HP, 30) - ctx = self.attack_ctx(stoutland, STOUTLAND_V, "Double Dip Fangs") - await ability_named(STOUTLAND_V, "Double Dip Fangs").effect(ctx) - self.assertIn(dunsparce, ctx.knockouts) - self.assertEqual(ctx.extra_prizes, 1) - - async def test_wild_tackle_self_damage(self): - stoutland = self.add_deck_card("activePokemonArea", STOUTLAND_V, P1) - self.add_deck_card("activePokemonArea", LUGIA_V, P2) - ctx = self.attack_ctx(stoutland, STOUTLAND_V, "Wild Tackle") - await ability_named(STOUTLAND_V, "Wild Tackle").effect(ctx) - self.assertEqual(stoutland.get_attribute(AttrID.HP), 210 - 30) - - async def test_aqua_return_shuffles_stack_into_deck(self): - lumineon = self.add_deck_card("activePokemonArea", LUMINEON_V, P1) - energy = self.attach(POWERFUL_COLORLESS, lumineon, P1) - self.add_deck_card("activePokemonArea", SNORLAX, P2) - await resolve_attack(self.session, P1, lumineon, - ability_named(LUMINEON_V, "Aqua Return"), "a-2") - deck = self.board.find_player_area(P1, "deck") - self.assertIn(lumineon, deck.children) - self.assertIn(energy, deck.children) - self.assertIsNone(self.board.active_pokemon(P1)) - - async def test_luminous_sign_searches_supporter(self): - lumineon = self.add_deck_card("bench", LUMINEON_V, P1) - marnie = self.add_deck_card("deck", MARNIE, P1) - self.add_deck_card("deck", DUNSPARCE, P1) - ctx = EffectContext(self.session, P1, lumineon, - ability_named(LUMINEON_V, "Luminous Sign")) - await ability_named(LUMINEON_V, "Luminous Sign").effect(ctx) - self.assertEqual(self.session.chooser_calls[0][1], [marnie.entity_id]) - self.assertIn(marnie, self.board.find_player_area(P1, "hand").children) - - async def test_primal_turbo_attaches_special_energy_from_deck(self): - archeops = self.add_deck_card("bench", ARCHEOPS, P1) - lugia = self.add_deck_card("activePokemonArea", LUGIA_VSTAR, P1) - aurora = self.add_deck_card("deck", AURORA, P1) - powerful = self.add_deck_card("deck", POWERFUL_COLORLESS, P1) - self.add_deck_card("deck", DUNSPARCE, P1) - # Chooser 1: the two Special Energies; chooser 2: the target Pokemon. - self.session.chooser_replies = [ - [aurora.entity_id, powerful.entity_id], - [lugia.entity_id], - ] - ctx = EffectContext(self.session, P1, archeops, - ability_named(ARCHEOPS, "Primal Turbo")) - await ability_named(ARCHEOPS, "Primal Turbo").effect(ctx) - self.assertIn(aurora, lugia.children) - self.assertIn(powerful, lugia.children) - - -# ---------------------------------------------------------------------- -# Trainer effects -# ---------------------------------------------------------------------- - -class TestTrainerEffects(LugiaDeckTestBase): - async def play_trainer(self, guid, player_id=P1): - """Runs a trainer's registered effect the way the session does.""" - card = self.add_to("discard", deck_card(guid), player_id) - trainer_area = self.board.find_global_area("activeTrainer") - self.board.move_card(card.entity_id, trainer_area.entity_id) - card.owning_player_id = player_id - return await resolve_trainer_effect(self.session, player_id, card) - - async def test_professors_research_discards_hand_draws_7(self): - held = [self.add_deck_card("hand", DUNSPARCE, P1) for _ in range(3)] - for _ in range(8): - self.add_deck_card("deck", QUICK_BALL, P1) - await self.play_trainer(PROF_RESEARCH) - discard = self.board.find_player_area(P1, "discard") - for card in held: - self.assertIn(card, discard.children) - self.assertEqual(len(self.board.find_player_area(P1, "hand").children), 7) - - async def test_marnie_bottoms_hands_and_draws_5_and_4(self): - mine = [self.add_deck_card("hand", DUNSPARCE, P1) for _ in range(2)] - theirs = [self.add_deck_card("hand", MANAPHY, P2) for _ in range(3)] - for _ in range(8): - self.add_deck_card("deck", QUICK_BALL, P1) - self.add_deck_card("deck", QUICK_BALL, P2) - await self.play_trainer(MARNIE) - self.assertEqual(len(self.board.find_player_area(P1, "hand").children), 5) - self.assertEqual(len(self.board.find_player_area(P2, "hand").children), 4) - p1_deck = self.board.find_player_area(P1, "deck") - p2_deck = self.board.find_player_area(P2, "deck") - # Old hands sit at the BOTTOM of the decks (positions 0..n-1). - self.assertEqual(set(p1_deck.children[:2]), set(mine)) - self.assertEqual(set(p2_deck.children[:3]), set(theirs)) - - async def test_marnie_choreography_is_sequential_per_player(self): - for _ in range(2): - self.add_deck_card("hand", DUNSPARCE, P1) - self.add_deck_card("hand", MANAPHY, P2) - for _ in range(8): - self.add_deck_card("deck", QUICK_BALL, P1) - self.add_deck_card("deck", QUICK_BALL, P2) - hand = self.board.find_player_area(P1, "hand") - deck = self.board.find_player_area(P1, "deck") - ctx = await self.play_trainer(MARNIE) - # Player's shuffle-to-bottom and draw fully precede the opponent's. - runs = ctx.bracket_runs_for(P1) - self.assertEqual( - [name for name, _ in runs], - ["HandShuffledAndMovedToDeck", "Draw", - "HandShuffledAndMovedToDeck", "Draw"], - ) - msgs = runs[0][1] - self.assertEqual(msgs[0]["name"], "Shuffled") - self.assertEqual(msgs[0]["value"]["entityID"], hand.entity_id) - self.assertEqual(msgs[-1]["name"], "PlaceOnBottom") - self.assertEqual(msgs[-1]["value"]["entityID"], hand.entity_id) - self.assertEqual(msgs[-1]["value"]["target"], deck.entity_id) - - async def test_bosss_orders_gusts_chosen_bench_pokemon(self): - self.add_deck_card("activePokemonArea", LUGIA_V, P1) - old_active = self.add_deck_card("activePokemonArea", SNORLAX, P2) - benched = self.add_deck_card("bench", MANAPHY, P2) - await self.play_trainer(BOSSS_ORDERS) - self.assertIs(self.board.active_pokemon(P2), benched) - bench = self.board.find_player_area(P2, "bench") - self.assertIn(old_active, bench.children) - - async def test_escape_rope_switches_both_sides_opponent_first(self): - my_active = self.add_deck_card("activePokemonArea", LUGIA_V, P1) - my_bench = self.add_deck_card("bench", ORANGURU, P1) - their_active = self.add_deck_card("activePokemonArea", SNORLAX, P2) - their_bench = self.add_deck_card("bench", MANAPHY, P2) - await self.play_trainer(ESCAPE_ROPE) - self.assertIs(self.board.active_pokemon(P1), my_bench) - self.assertIs(self.board.active_pokemon(P2), their_bench) - # The opponent's chooser was prompted before the player's. - self.assertEqual(self.session.chooser_calls[0][0], P2) - self.assertEqual(self.session.chooser_calls[1][0], P1) - self.assertIn(my_active, self.board.find_player_area(P1, "bench").children) - self.assertIn(their_active, self.board.find_player_area(P2, "bench").children) - - async def test_quick_ball_discards_then_fetches_basic(self): - fodder = self.add_deck_card("hand", EVOLUTION_INCENSE, P1) - basic = self.add_deck_card("deck", DUNSPARCE, P1) - self.add_deck_card("deck", ARCHEOPS, P1) # not Basic: filtered out - await self.play_trainer(QUICK_BALL) - self.assertIn(fodder, self.board.find_player_area(P1, "discard").children) - search_offer = self.session.chooser_calls[1][1] - self.assertEqual(search_offer, [basic.entity_id]) - self.assertIn(basic, self.board.find_player_area(P1, "hand").children) - - async def test_ultra_ball_discards_two_then_fetches_any_pokemon(self): - for _ in range(2): - self.add_deck_card("hand", EVOLUTION_INCENSE, P1) - archeops = self.add_deck_card("deck", ARCHEOPS, P1) - self.add_deck_card("deck", QUICK_BALL, P1) - await self.play_trainer(ULTRA_BALL) - self.assertEqual( - len(self.board.find_player_area(P1, "discard").children), 2 - ) - self.assertIn(archeops, self.board.find_player_area(P1, "hand").children) - - async def test_evolution_incense_fetches_evolution_pokemon(self): - archeops = self.add_deck_card("deck", ARCHEOPS, P1) - self.add_deck_card("deck", DUNSPARCE, P1) - await self.play_trainer(EVOLUTION_INCENSE) - self.assertEqual(self.session.chooser_calls[0][1], [archeops.entity_id]) - self.assertIn(archeops, self.board.find_player_area(P1, "hand").children) - - async def test_irida_fetches_water_pokemon_and_item(self): - manaphy = self.add_deck_card("deck", MANAPHY, P1) - quick_ball = self.add_deck_card("deck", QUICK_BALL, P1) - self.add_deck_card("deck", DUNSPARCE, P1) # neither Water nor Item - await self.play_trainer(IRIDA) - # One AND-composite browser with a Water group and an Item group, - # not one combined pick of any 2 matches. - water_offer, item_offer = self.session.chooser_calls[:2] - self.assertEqual(water_offer[1], [manaphy.entity_id]) - self.assertEqual(item_offer[1], [quick_ball.entity_id]) - hand = self.board.find_player_area(P1, "hand") - self.assertIn(manaphy, hand.children) - self.assertIn(quick_ball, hand.children) - - async def test_serena_gust_mode_targets_only_pokemon_v(self): - self.add_deck_card("activePokemonArea", LUGIA_V, P1) - self.add_deck_card("activePokemonArea", SNORLAX, P2) - manaphy = self.add_deck_card("bench", MANAPHY, P2) - lugia_v = self.add_deck_card("bench", LUGIA_V, P2) - # Empty hand: the gust mode is auto-selected. - await self.play_trainer(SERENA) - offered = self.session.chooser_calls[0][1] - self.assertEqual(offered, [lugia_v.entity_id]) - self.assertIs(self.board.active_pokemon(P2), lugia_v) - self.assertIsNot(self.board.active_pokemon(P2), manaphy) - - async def test_serena_discard_mode_draws_to_five(self): - self.add_deck_card("activePokemonArea", SNORLAX, P2) # no V bench - for _ in range(3): - self.add_deck_card("hand", DUNSPARCE, P1) - for _ in range(8): - self.add_deck_card("deck", QUICK_BALL, P1) - self.session.chooser_replies = [ - [self.board.find_player_area(P1, "hand").children[0].entity_id] - ] - await self.play_trainer(SERENA) - self.assertEqual(len(self.board.find_player_area(P1, "hand").children), 5) - - async def test_lost_vacuum_sends_cost_and_target_to_lost_zone(self): - fodder = self.add_deck_card("hand", DUNSPARCE, P1) - lugia = self.add_deck_card("activePokemonArea", LUGIA_V, P1) - belt = self.attach(CHOICE_BELT, lugia, P1) - await self.play_trainer(LOST_VACUUM) - lost = self.board.find_player_area(P1, "lostZone") - self.assertIn(fodder, lost.children) - self.assertIn(belt, lost.children) - self.assertNotIn(belt, lugia.children) - - -# ---------------------------------------------------------------------- -# Special-energy hooks -# ---------------------------------------------------------------------- - -class TestEnergyHooks(LugiaDeckTestBase): - async def test_aurora_attach_cost_discards_a_card(self): - lugia = self.add_deck_card("activePokemonArea", LUGIA_V, P1) - aurora = self.add_deck_card("hand", AURORA, P1) - fodder = self.add_deck_card("hand", DUNSPARCE, P1) - ctx = await resolve_energy_attach_cost(self.session, P1, aurora, lugia) - self.assertIsNotNone(ctx) - self.assertIn(fodder, self.board.find_player_area(P1, "discard").children) - # The energy being attached is never offered as its own cost. - self.assertNotIn(aurora.entity_id, self.session.chooser_calls[0][1]) - - async def test_capture_energy_benches_a_basic_from_deck(self): - lugia = self.add_deck_card("activePokemonArea", LUGIA_V, P1) - capture = self.attach(CAPTURE, lugia, P1) - basic = self.add_deck_card("deck", DUNSPARCE, P1) - self.add_deck_card("deck", ARCHEOPS, P1) - ctx = await resolve_energy_on_attach(self.session, P1, capture, lugia) - self.assertIsNotNone(ctx) - self.assertIn(basic, self.board.find_player_area(P1, "bench").children) - self.assertIn(basic.entity_id, self.session.turn_state.entered_play_turn) - - async def test_speed_lightning_draws_two(self): - raikou = self.add_deck_card("activePokemonArea", RAIKOU, P1) - speed = self.attach(SPEED_LIGHTNING, raikou, P1) - for _ in range(2): - self.add_deck_card("deck", DUNSPARCE, P1) - ctx = await resolve_energy_on_attach(self.session, P1, speed, raikou) - self.assertIsNotNone(ctx) - self.assertEqual(len(self.board.find_player_area(P1, "hand").children), 2) - - async def test_speed_lightning_no_draw_on_non_lightning_target(self): - lugia = self.add_deck_card("activePokemonArea", LUGIA_V, P1) - speed = self.attach(SPEED_LIGHTNING, lugia, P1) - for _ in range(2): - self.add_deck_card("deck", DUNSPARCE, P1) - await resolve_energy_on_attach(self.session, P1, speed, lugia) - self.assertEqual(len(self.board.find_player_area(P1, "hand").children), 0) - - -# ---------------------------------------------------------------------- -# Legal-action gating -# ---------------------------------------------------------------------- - -class TestLegalActionGating(unittest.TestCase): - def setUp(self): - if not loader.cards_by_guid: - loader.load_all() - self.board = BoardState(GAME_ID, [P1, P2]) - self.state = TurnState() - self.state.begin_turn(P1) - self.state.begin_turn(P1) # turn 2: attacks/supporters allowed - - def add(self, area_name, guid, player_id=P1): - from spirit.game.models.board import create_card_entity - entity = create_card_entity(deck_card(guid), owning_player_id=player_id) - area = self.board.find_player_area(player_id, area_name) - self.board.add_card_to_area(entity, area) - return entity - - def actions(self, description): - return [ - e for e in compute_legal_actions(self.board, self.state, P1, GAME_ID) - if e["selectableAction"]["description"] == description - ] - - def test_tool_offered_only_for_pokemon_without_a_tool(self): - lugia = self.add("activePokemonArea", LUGIA_V) - oranguru = self.add("bench", ORANGURU) - self.add("hand", CHOICE_BELT) - belt = self.add("discard", CHOICE_BELT) - self.board.attach_card(belt.entity_id, lugia.entity_id) - entries = self.actions(ACTION_ATTACH_TOOL) - self.assertEqual(len(entries), 1) - self.assertEqual( - entries[0]["targetInfoLst"][0]["validTargets"], [oranguru.entity_id] - ) - - def test_special_energies_attach_to_any_pokemon(self): - # SWSH special energies carry no attach restriction; their benefits - # are type-gated in the passive/on_attach instead. - lugia = self.add("activePokemonArea", LUGIA_V) - charizard = self.add("bench", RADIANT_CHARIZARD) - self.add("hand", HEAT_FIRE) - entries = self.actions(ACTION_PLAY_ENERGY) - self.assertEqual(len(entries), 1) - self.assertEqual( - set(entries[0]["targetInfoLst"][0]["validTargets"]), - {lugia.entity_id, charizard.entity_id}, - ) - - def test_aurora_needs_another_card_in_hand(self): - self.add("activePokemonArea", LUGIA_V) - self.add("hand", AURORA) - self.assertEqual(self.actions(ACTION_PLAY_ENERGY), []) - self.add("hand", DUNSPARCE) - self.assertEqual(len(self.actions(ACTION_PLAY_ENERGY)), 1) - - def test_quick_ball_needs_another_card_to_discard(self): - self.add("activePokemonArea", LUGIA_V) - self.add("hand", QUICK_BALL) - self.assertEqual(self.actions(ACTION_USE_TRAINER), []) - self.add("hand", MANAPHY) - self.assertEqual(len(self.actions(ACTION_USE_TRAINER)), 1) - - def test_bosss_orders_needs_an_opposing_bench(self): - self.add("activePokemonArea", LUGIA_V) - self.add("activePokemonArea", SNORLAX, P2) - self.add("hand", BOSSS_ORDERS) - self.assertEqual(self.actions(ACTION_USE_TRAINER), []) - self.add("bench", MANAPHY, P2) - self.assertEqual(len(self.actions(ACTION_USE_TRAINER)), 1) - - def test_activatable_ability_once_per_turn(self): - archeops = self.add("bench", ARCHEOPS) - self.add("activePokemonArea", LUGIA_V) - entries = self.actions(ACTION_USE_ABILITY) - self.assertEqual(len(entries), 1) - ability_id = entries[0]["selectableAction"]["actionID"] - self.state.used_abilities.add((archeops.entity_id, ability_id)) - self.assertEqual(self.actions(ACTION_USE_ABILITY), []) - - def test_vstar_power_once_per_game(self): - self.add("activePokemonArea", LUGIA_VSTAR) - # No valid discard target: Summoning Star may not be used at all. - self.assertEqual(self.actions(ACTION_USE_ABILITY), []) - self.add("discard", DUNSPARCE) - self.assertEqual(len(self.actions(ACTION_USE_ABILITY)), 1) - self.state.vstar_used.add(P1) - self.assertEqual(self.actions(ACTION_USE_ABILITY), []) - - def test_combustion_blast_locked_after_use(self): - charizard = self.add("activePokemonArea", RADIANT_CHARIZARD) - for _ in range(5): - energy = self.add("discard", HEAT_FIRE) - self.board.attach_card(energy.entity_id, charizard.entity_id) - entries = self.actions(ACTION_USE_ATTACK) - self.assertEqual(len(entries), 1) - self.state.lock_attack( - charizard.entity_id, entries[0]["selectableAction"]["actionID"] - ) - self.assertEqual(self.actions(ACTION_USE_ATTACK), []) - # The lock expires after the user's next turn. - self.state.turn_number += 3 - self.assertEqual(len(self.actions(ACTION_USE_ATTACK)), 1) - - def test_asleep_active_cannot_attack_or_retreat(self): - snorlax = self.add("activePokemonArea", SNORLAX) - self.add("bench", MANAPHY) - for _ in range(4): - energy = self.add("discard", POWERFUL_COLORLESS) - self.board.attach_card(energy.entity_id, snorlax.entity_id) - self.assertEqual(len(self.actions(ACTION_USE_ATTACK)), 1) - self.assertEqual(len(self.actions(ACTION_RETREAT)), 1) - snorlax.set_attribute(AttrID.SPECIAL_CONDITIONS, ["Asleep"]) - self.assertEqual(self.actions(ACTION_USE_ATTACK), []) - self.assertEqual(self.actions(ACTION_RETREAT), []) - - -if __name__ == "__main__": - unittest.main() diff --git a/spirit/tests/test_matchmaking.py b/spirit/tests/test_matchmaking.py deleted file mode 100644 index 272368f..0000000 --- a/spirit/tests/test_matchmaking.py +++ /dev/null @@ -1,368 +0,0 @@ -import unittest -import asyncio -from spirit.game.session.manager import GameSessionManager -from spirit.network.message_names import OutboundMsg - -class MockPlayer: - def __init__(self, account_id: str, username: str): - self.account_id = account_id - self.username = username - -class MockServer: - def __init__(self): - self.clients = [] - - -class MockClientHandler: - def __init__(self, account_id: str, username: str, server=None): - self.player = MockPlayer(account_id, username) - self.sent_packets = [] - self.addr = ("127.0.0.1", 12345) - self.server = server or MockServer() - self.server.clients.append(self) - - async def send_packet(self, response_body, request_id, flags=0): - self.sent_packets.append(response_body) - - -class TestMatchmakingFlows(unittest.IsolatedAsyncioTestCase): - def setUp(self): - # Reset the GameSessionManager singleton state before each test - self.manager = GameSessionManager() - self.manager.queues = {} - self.manager.pending_pairings = {} - self.manager.active_sessions = {} - self.manager.auto_confirm_ready = False - - def test_singleton_behavior(self): - manager2 = GameSessionManager() - self.assertIs(self.manager, manager2) - - async def test_multiplayer_queue_matchmaking(self): - client1 = MockClientHandler("player-1", "Ash") - client2 = MockClientHandler("player-2", "Gary") - - deck1 = {"deckID": "deck-1", "deckName": "Charizard Fire"} - deck2 = {"deckID": "deck-2", "deckName": "Blastoise Water"} - - # 1. Player 1 enters standard queue - await self.manager.add_to_queue(client1, "Standard", deck1, {}) - - # Verify Player 1 received MatchQueueEntered - self.assertEqual(len(client1.sent_packets), 1) - self.assertEqual(client1.sent_packets[0]["messageName"], OutboundMsg.MATCH_QUEUE_ENTERED.value) - self.assertIn("Standard", self.manager.queues) - self.assertEqual(len(self.manager.queues["Standard"]), 1) - - # 2. Player 2 enters standard queue (triggers pair matching) - await self.manager.add_to_queue(client2, "Standard", deck2, {}) - - # Wait for the ConfirmReadyForMatch 1.5s delay timer to fire - await asyncio.sleep(1.6) - - # Queue should now be empty - self.assertEqual(len(self.manager.queues["Standard"]), 0) - # Should have created 1 pending pairing - self.assertEqual(len(self.manager.pending_pairings), 1) - - game_id = list(self.manager.pending_pairings.keys())[0] - pairing = self.manager.pending_pairings[game_id] - - self.assertFalse(pairing["is_solo"]) - self.assertEqual(pairing["queue_name"], "Standard") - - # Verify MatchQueueEntered and ConfirmReadyForMatch sent - self.assertEqual(len(client1.sent_packets), 2) - self.assertEqual(len(client2.sent_packets), 2) - - p1_confirm = client1.sent_packets[1] - p2_confirm = client2.sent_packets[1] - - self.assertEqual(client2.sent_packets[0]["messageName"], OutboundMsg.MATCH_QUEUE_ENTERED.value) - self.assertEqual(p1_confirm["messageName"], OutboundMsg.CONFIRM_READY_FOR_MATCH.value) - self.assertEqual(p1_confirm["gameID"], game_id) - self.assertEqual(p2_confirm["messageName"], OutboundMsg.CONFIRM_READY_FOR_MATCH.value) - self.assertEqual(p2_confirm["gameID"], game_id) - - # 3. Ready check confirmation - await self.manager.confirm_ready(client1, game_id) - # Game should not start yet as Player 2 is not ready - self.assertEqual(len(self.manager.active_sessions), 0) - self.assertTrue(self.manager.pending_pairings[game_id]["players"]["player-1"]["ready"]) - self.assertFalse(self.manager.pending_pairings[game_id]["players"]["player-2"]["ready"]) - - await self.manager.confirm_ready(client2, game_id) - # Both ready! Should clear pending and move to active sessions - self.assertEqual(len(self.manager.pending_pairings), 0) - self.assertEqual(len(self.manager.active_sessions), 1) - - # Yield to event loop to allow asyncio.create_task(session.start()) to run - await asyncio.sleep(0.05) - - # Verify MatchFound sent to both - self.assertEqual(len(client1.sent_packets), 3) - self.assertEqual(len(client2.sent_packets), 3) - - self.assertEqual(client1.sent_packets[2]["messageName"], OutboundMsg.MATCH_FOUND.value) - self.assertEqual(client2.sent_packets[2]["messageName"], OutboundMsg.MATCH_FOUND.value) - - async def test_single_player_ai_matchmaking(self): - client = MockClientHandler("player-1", "Ash") - deck = {"deckID": "deck-1", "deckName": "Charizard Fire"} - - # 1. Start solo match against AI - await self.manager.start_solo_match(client, deck, "easy_ai_bot", {}) - - # Should be a pending pairing - self.assertEqual(len(self.manager.pending_pairings), 1) - game_id = list(self.manager.pending_pairings.keys())[0] - pairing = self.manager.pending_pairings[game_id] - - self.assertTrue(pairing["is_solo"]) - self.assertEqual(pairing["solitaire_id"], "easy_ai_bot") - - # Player 1 is not ready, AI is instantly ready - self.assertFalse(pairing["players"]["player-1"]["ready"]) - self.assertTrue(pairing["players"]["mock_ai_bot"]["ready"]) - - # Verify ConfirmReadyForMatch sent - self.assertEqual(len(client.sent_packets), 1) - self.assertEqual(client.sent_packets[0]["messageName"], OutboundMsg.CONFIRM_READY_FOR_MATCH.value) - - # 2. Confirm ready from player 1 - await self.manager.confirm_ready(client, game_id) - - # Since AI was already ready, this should start the session immediately - self.assertEqual(len(self.manager.pending_pairings), 0) - self.assertEqual(len(self.manager.active_sessions), 1) - - # Yield to event loop to allow asyncio.create_task(session.start()) to run - await asyncio.sleep(0.05) - - # Verify MatchFound is sent - self.assertEqual(len(client.sent_packets), 2) - self.assertEqual(client.sent_packets[1]["messageName"], OutboundMsg.MATCH_FOUND.value) - - async def test_cancel_match_request(self): - client = MockClientHandler("player-1", "Ash") - deck = {"deckID": "deck-1"} - - # 1. Enter queue - await self.manager.add_to_queue(client, "Standard", deck, {}) - self.assertEqual(len(self.manager.queues["Standard"]), 1) - - # 2. Cancel match request - await self.manager.remove_from_queue(client, send_left_packet=True) - self.assertEqual(len(self.manager.queues["Standard"]), 0) - - # Verify MatchQueueLeft is sent - self.assertEqual(len(client.sent_packets), 2) - self.assertEqual(client.sent_packets[1]["messageName"], OutboundMsg.MATCH_QUEUE_LEFT.value) - - async def test_matchmaking_deck_cosmetic_resolution(self): - from unittest.mock import MagicMock - from spirit.game.models.player import Player - from spirit.packets.handlers.matchmaking import MatchmakingHandler - from spirit.game.attributes import AttrID - - # Create mock client and player - client = MockClientHandler("player-cosmetic-1", "Satoshi") - player = Player({ - "account_id": "player-cosmetic-1", - "username": "satoshi", - "screen_name": "Satoshi" - }) - client.player = player - - # Save a customized deck into player's local list - custom_deck_id = "custom-deck-123" - custom_deck = { - "deckID": custom_deck_id, - "deckName": "Cosmetic Test Deck", - "attributes": [ - {"name": AttrID.SELECTED_COIN.value, "value": "custom-coin-guid"}, - {"name": AttrID.SELECTED_SLEEVE.value, "value": ["custom-sleeve-guid"]}, - {"name": AttrID.SELECTED_DECK_BOX.value, "value": "custom-deckbox-guid"} - ], - "piles": {"deck": []} - } - player.save_deck_data(custom_deck) - - # Instantiate matchmaking handler - handler = MatchmakingHandler(client) - - # 1. Verify resolution of full deck data with string and list cosmetic values - resolved_deck = handler._resolve_full_deck({"deckID": custom_deck_id}) - self.assertEqual(resolved_deck["deckName"], "Cosmetic Test Deck") - - # Test cosmetic values on NetworkPlayer instantiated with resolved deck - from spirit.game.session.network_player import NetworkPlayer - np = NetworkPlayer( - client_handler=client, - deck_data=resolved_deck - ) - self.assertEqual(np.coin_id, "custom-coin-guid") # String value resolution - self.assertEqual(np.sleeve_id, "custom-sleeve-guid") # List value resolution - self.assertEqual(np.deckbox_id, "custom-deckbox-guid") - - # 2. Verify fallback default cosmetic values when they are missing or empty - empty_deck = { - "deckID": "empty-deck", - "deckName": "Empty Cosmetic Deck", - "attributes": [], - "piles": {"deck": []} - } - np_empty = NetworkPlayer( - client_handler=client, - deck_data=empty_deck - ) - # Verify fallback deckbox GUID is the safe Basic Deck Box GUID - self.assertEqual(np_empty.deckbox_id, "e129b0d3-b934-4fbd-b021-545106c75694") - self.assertEqual(np_empty.coin_id, "B9A4EA96-949E-11E1-890F-EFB676C7909C") - self.assertEqual(np_empty.sleeve_id, "e079c0d3-b934-4fbd-b021-545106c75693") - - async def test_active_session_cleanup_on_matchmaking(self): - client = MockClientHandler("player-cleanup-1", "Satoshi") - deck = {"deckID": "deck-1"} - - # 1. Start a solo match to populate an active session - await self.manager.start_solo_match(client, deck, "easy_ai_bot", {}) - # Confirm ready to move to active sessions - game_id = list(self.manager.pending_pairings.keys())[0] - await self.manager.confirm_ready(client, game_id) - - # Verify session is active - self.assertEqual(len(self.manager.active_sessions), 1) - - # 2. Add to queue should automatically trigger removal and cleanup of the old session - await self.manager.add_to_queue(client, "Standard", deck, {}) - self.assertEqual(len(self.manager.active_sessions), 0) - - -class TestFriendChallenges(unittest.IsolatedAsyncioTestCase): - def setUp(self): - self.manager = GameSessionManager() - self.manager.queues = {} - self.manager.pending_pairings = {} - self.manager.active_sessions = {} - self.manager.pending_challenges = {} - self.manager.auto_confirm_ready = False - - self.server = MockServer() - self.challenger = MockClientHandler("player-1", "Ash", server=self.server) - self.opponent = MockClientHandler("player-2", "Gary", server=self.server) - - def _last(self, client): - return client.sent_packets[-1] - - async def test_challenge_accept_starts_game(self): - deck1 = {"deckID": "deck-1"} - deck2 = {"deckID": "deck-2"} - options = {"QueueType": "Standard", "TimersEnabled": "True"} - - await self.manager.create_challenge(self.challenger, "player-2", deck1, options) - - sent = self._last(self.challenger) - self.assertEqual(sent["messageName"], OutboundMsg.MATCH_REQUEST_SENT.value) - self.assertEqual(sent["opponentID"], "player-2") - path = sent["path"] - self.assertIn(path, self.manager.pending_challenges) - - requested = self._last(self.opponent) - self.assertEqual(requested["messageName"], OutboundMsg.MATCH_REQUESTED.value) - self.assertEqual(requested["path"], path) - self.assertEqual(requested["opponentID"], "player-1") - self.assertEqual(requested["matchOptions"], options) - - await self.manager.accept_challenge(self.opponent, path, deck2) - - accepted = self._last(self.opponent) - self.assertEqual(accepted["messageName"], OutboundMsg.MATCH_REQUEST_SENT.value) - self.assertEqual(accepted["opponentID"], "player-1") - - self.assertEqual(len(self.manager.pending_challenges), 0) - self.assertEqual(len(self.manager.pending_pairings), 1) - game_id, pairing = next(iter(self.manager.pending_pairings.items())) - self.assertEqual(pairing["queue_name"], "Friend") - self.assertEqual(pairing["players"]["player-1"]["deck"], deck1) - self.assertEqual(pairing["players"]["player-2"]["deck"], deck2) - - # Ready check fires after the 1.5s settle delay - await asyncio.sleep(1.6) - self.assertEqual(self._last(self.challenger)["messageName"], OutboundMsg.CONFIRM_READY_FOR_MATCH.value) - self.assertEqual(self._last(self.opponent)["messageName"], OutboundMsg.CONFIRM_READY_FOR_MATCH.value) - - await self.manager.confirm_ready(self.challenger, game_id) - await self.manager.confirm_ready(self.opponent, game_id) - await asyncio.sleep(0.05) - - self.assertEqual(len(self.manager.active_sessions), 1) - self.assertEqual(self._last(self.challenger)["messageName"], OutboundMsg.MATCH_FOUND.value) - self.assertEqual(self._last(self.opponent)["messageName"], OutboundMsg.MATCH_FOUND.value) - - async def test_challenge_offline_opponent_fails(self): - await self.manager.create_challenge(self.challenger, "player-offline", {}, {}) - - failed = self._last(self.challenger) - self.assertEqual(failed["messageName"], OutboundMsg.MATCH_REQUEST_WITH_SPECIFIC_CLIENT_FAILED.value) - self.assertIn("id", failed["failureType"]) - self.assertEqual(len(self.manager.pending_challenges), 0) - - async def test_challenge_reject_notifies_challenger(self): - await self.manager.create_challenge(self.challenger, "player-2", {}, {}) - path = self._last(self.challenger)["path"] - - await self.manager.reject_challenge(self.opponent, path) - - rejected = self._last(self.challenger) - self.assertEqual(rejected["messageName"], OutboundMsg.MATCH_REQUEST_REJECTED.value) - self.assertEqual(rejected["path"], path) - self.assertEqual(rejected["failureType"], "Rejected") - self.assertEqual(len(self.manager.pending_challenges), 0) - - async def test_challenge_cancel_notifies_both(self): - await self.manager.create_challenge(self.challenger, "player-2", {}, {}) - path = self._last(self.challenger)["path"] - - await self.manager.cancel_challenge(self.challenger, path) - - self.assertEqual(self._last(self.challenger)["messageName"], OutboundMsg.MATCH_REQUEST_CANCELLED.value) - cancelled = self._last(self.opponent) - self.assertEqual(cancelled["messageName"], OutboundMsg.MATCH_REQUEST_CANCELLED.value) - self.assertEqual(cancelled["path"], path) - self.assertEqual(len(self.manager.pending_challenges), 0) - - async def test_accept_stale_path_fails(self): - await self.manager.accept_challenge(self.opponent, "challenge_gone", {}) - failed = self._last(self.opponent) - self.assertEqual(failed["messageName"], OutboundMsg.MATCH_REQUEST_WITH_SPECIFIC_CLIENT_FAILED.value) - - async def test_disconnect_cancels_pending_challenge(self): - await self.manager.create_challenge(self.challenger, "player-2", {}, {}) - path = self._last(self.challenger)["path"] - - await self.manager.remove_challenges_for_client(self.challenger, notify_self=False) - - cancelled = self._last(self.opponent) - self.assertEqual(cancelled["messageName"], OutboundMsg.MATCH_REQUEST_CANCELLED.value) - self.assertEqual(cancelled["path"], path) - self.assertEqual(len(self.manager.pending_challenges), 0) - - async def test_new_challenge_replaces_previous(self): - third = MockClientHandler("player-3", "Misty", server=self.server) - await self.manager.create_challenge(self.challenger, "player-2", {}, {}) - first_path = self._last(self.challenger)["path"] - - await self.manager.create_challenge(self.challenger, "player-3", {}, {}) - - self.assertEqual(len(self.manager.pending_challenges), 1) - self.assertNotIn(first_path, self.manager.pending_challenges) - # The first opponent's dialog is unwound with a cancellation - cancelled = self.opponent.sent_packets[-1] - self.assertEqual(cancelled["messageName"], OutboundMsg.MATCH_REQUEST_CANCELLED.value) - self.assertEqual(cancelled["path"], first_path) - self.assertEqual(self._last(third)["messageName"], OutboundMsg.MATCH_REQUESTED.value) - - -if __name__ == "__main__": - unittest.main() diff --git a/spirit/tests/test_mew_deck_pokemon.py b/spirit/tests/test_mew_deck_pokemon.py deleted file mode 100644 index e04ef61..0000000 --- a/spirit/tests/test_mew_deck_pokemon.py +++ /dev/null @@ -1,300 +0,0 @@ -"""End-to-end unit tests for the Mew VMAX deck's BATCH B Pokemon cards: -Oricorio, Genesect V, Mew V, Mew VMAX -- and the engine features they lean on -(non-stacking damage-taken passives, ability conditions gating a no-op draw, -ctx.use_attack's lock propagation, ignore_target_effects).""" - -import unittest -from unittest.mock import AsyncMock - -from spirit.tests.test_phase1_engine import EngineTestBase, P1, P2, make_card - -from spirit.game.attributes import AttrID, PokemonTypes -from spirit.game.data_utils import ( - CARD_DEFS_BY_GUID, - EnergyCardDef, - PokemonCardDef, - PokemonToolCardDef, -) -from spirit.game.models.board import create_card_entity -from spirit.game.scripts.cards import loader -from spirit.game.session.effects import EffectContext, resolve_attack -from spirit.game.session.passives import Passive, compute_damage - -# Archetype GUIDs of the deck's printings (spirit/game/scripts/cards/SWSH8). -ORICORIO = "91504f79-cabd-5abc-aa32-b397543a6b24" -GENESECT_V_185 = "8a340773-6bfd-5786-acc2-28b31243f9ce" -MEW_V_113 = "bf0556da-f2cd-5b45-8bd9-6b7bff65f745" -MEW_VMAX_114 = "2b2efb79-6c02-5350-89ca-6777e295e7a9" -MEW_VMAX_268 = "72b7e13f-0f90-50ba-992d-ac6ca731a25b" - - -def setUpModule(): - if not loader.cards_by_guid: - loader.load_all() - - -def deck_card(guid): - return loader.cards_by_guid[guid] - - -def ability_named(guid, title): - definition = CARD_DEFS_BY_GUID[guid] - return next(a for a in definition.abilities if a.title == title) - - -# ---------------------------------------------------------------------- -# Local test fixtures (guids under a private 30000000 prefix, unused -# elsewhere in the suite) -# ---------------------------------------------------------------------- - -TEST_ENERGY_DEF = EnergyCardDef( - guid="30000000-0000-0000-0000-0000000000e1", - key="TST", name="com.test.energy.Plain.Name", - collector_number=901, set_code="TST", rarity=0, - energy_type=PokemonTypes.COLORLESS, -) -TEST_ENERGY = make_card(TEST_ENERGY_DEF) - -PLAIN_MON_DEF = PokemonCardDef( - guid="30000000-0000-0000-0000-0000000000a1", - key="TST", name="com.test.pokemon.PlainMon.Name", - collector_number=902, set_code="TST", rarity=1, - hp=100, elements=[PokemonTypes.COLORLESS], -) -PLAIN_MON = make_card(PLAIN_MON_DEF) - - -class _AlwaysPreventsPassive(Passive): - """Test-local stand-in for a target-side damage shield.""" - def prevents_damage(self, calc, carrier) -> bool: - return True - - -SHIELD_TOOL_DEF = PokemonToolCardDef( - passive=_AlwaysPreventsPassive(), - guid="30000000-0000-0000-0000-0000000000e2", - key="TST", name="com.test.trainer.ShieldTool.Name", - collector_number=903, set_code="TST", rarity=0, -) -SHIELD_TOOL = make_card(SHIELD_TOOL_DEF) - - -class MewDeckTestBase(EngineTestBase): - def add_deck_card(self, area_name, guid, player_id): - return self.add_to(area_name, deck_card(guid), player_id) - - -# ---------------------------------------------------------------------- -# Oricorio: Lesson in Zeal (passive) + Glistening Droplets (attack) -# ---------------------------------------------------------------------- - -class TestOricorio(MewDeckTestBase): - def test_lesson_in_zeal_reduces_damage_after_weakness_resistance(self): - self.add_deck_card("bench", ORICORIO, P1) - mew = self.add_deck_card("activePokemonArea", MEW_V_113, P1) - attacker = self.add_deck_card("activePokemonArea", GENESECT_V_185, P2) - self.assertEqual(compute_damage(self.board, attacker, mew, 100).amount, 80) - - def test_lesson_in_zeal_does_not_stack(self): - self.add_deck_card("bench", ORICORIO, P1) - self.add_deck_card("bench", ORICORIO, P1) - mew = self.add_deck_card("activePokemonArea", MEW_V_113, P1) - attacker = self.add_deck_card("activePokemonArea", GENESECT_V_185, P2) - # "You can't apply more than 1 Lesson in Zeal Ability at a time." - self.assertEqual(compute_damage(self.board, attacker, mew, 100).amount, 80) - - def test_lesson_in_zeal_ignores_non_fusion_strike_target(self): - self.add_deck_card("bench", ORICORIO, P1) - plain = self.add_to("activePokemonArea", PLAIN_MON, P1) - attacker = self.add_deck_card("activePokemonArea", GENESECT_V_185, P2) - self.assertEqual(compute_damage(self.board, attacker, plain, 100).amount, 100) - - def test_lesson_in_zeal_only_protects_its_owners_side(self): - self.add_deck_card("bench", ORICORIO, P1) # Oricorio owned by P1 - opp_mew = self.add_deck_card("activePokemonArea", MEW_V_113, P2) - attacker = self.add_deck_card("activePokemonArea", GENESECT_V_185, P1) - self.assertEqual(compute_damage(self.board, attacker, opp_mew, 100).amount, 100) - - async def test_glistening_droplets_places_5_counters_via_native_picker(self): - oricorio = self.add_deck_card("activePokemonArea", ORICORIO, P1) - target = self.add_deck_card("activePokemonArea", MEW_V_113, P2) - ctx = EffectContext(self.session, P1, oricorio, - ability_named(ORICORIO, "Glistening Droplets")) - self.session.damage_counter_replies.append({target.entity_id: 5}) - await ability_named(ORICORIO, "Glistening Droplets").effect(ctx) - self.assertEqual(target.get_attribute(AttrID.HP), 180 - 50) - - -# ---------------------------------------------------------------------- -# Genesect V: Fusion Strike System (ability) + Techno Blast (attack) -# ---------------------------------------------------------------------- - -class TestGenesectV(MewDeckTestBase): - def test_fusion_strike_system_condition_gates_on_hand_size(self): - genesect = self.add_deck_card("activePokemonArea", GENESECT_V_185, P1) - ability = ability_named(GENESECT_V_185, "Fusion Strike System") - # 1 Fusion Strike Pokemon in play, 0 cards in hand: offerable. - self.assertTrue(ability.condition(self.board, P1, genesect)) - self.add_deck_card("hand", MEW_V_113, P1) - # Hand size (1) no longer less than the Fusion Strike count (1). - self.assertFalse(ability.condition(self.board, P1, genesect)) - - async def test_fusion_strike_system_draws_until_hand_matches_count(self): - genesect = self.add_deck_card("activePokemonArea", GENESECT_V_185, P1) - self.add_deck_card("bench", MEW_V_113, P1) # 2 Fusion Strike Pokemon - for _ in range(5): - self.add_deck_card("deck", MEW_VMAX_114, P1) - ctx = EffectContext(self.session, P1, genesect, - ability_named(GENESECT_V_185, "Fusion Strike System")) - await ability_named(GENESECT_V_185, "Fusion Strike System").effect(ctx) - self.assertEqual(len(self.board.find_player_area(P1, "hand").children), 2) - - async def test_techno_blast_vanilla_damage_and_locks_next_turn(self): - genesect = self.add_deck_card("activePokemonArea", GENESECT_V_185, P1) - defender = self.add_deck_card("activePokemonArea", MEW_VMAX_114, P2) # 310 HP - attack = ability_named(GENESECT_V_185, "Techno Blast") - await resolve_attack(self.session, P1, genesect, attack, attack.ability_id) - self.assertEqual(defender.get_attribute(AttrID.HP), 310 - 210) - self.assertTrue(self.session.turn_state.attack_locked( - genesect.entity_id, attack.ability_id - )) - - -# ---------------------------------------------------------------------- -# Mew V: Energy Mix + Psychic Leap -# ---------------------------------------------------------------------- - -class TestMewV(MewDeckTestBase): - async def test_energy_mix_attaches_to_chosen_fusion_strike_target(self): - mew = self.add_deck_card("activePokemonArea", MEW_V_113, P1) - bench_fusion = self.add_deck_card("bench", MEW_VMAX_114, P1) - energy = self.add_to("deck", TEST_ENERGY, P1) - ctx = EffectContext(self.session, P1, mew, - ability_named(MEW_V_113, "Energy Mix")) - self.session.chooser_replies.append([energy.entity_id]) # search - self.session.chooser_replies.append([bench_fusion.entity_id]) # target - await ability_named(MEW_V_113, "Energy Mix").effect(ctx) - self.assertIn(energy, bench_fusion.children) - - async def test_energy_mix_no_fusion_strike_target_leaves_energy_in_deck(self): - # Effect source isn't placed on the board itself: models the search - # legally whiffing when no Fusion Strike Pokemon is in play. - energy = self.add_to("deck", TEST_ENERGY, P1) - mew = create_card_entity(deck_card(MEW_V_113), owning_player_id=P1) - ctx = EffectContext(self.session, P1, mew, - ability_named(MEW_V_113, "Energy Mix")) - self.session.chooser_replies.append([energy.entity_id]) - await ability_named(MEW_V_113, "Energy Mix").effect(ctx) - deck = self.board.find_player_area(P1, "deck") - self.assertIn(energy, deck.children) - self.assertTrue(any(m["name"] == "Shuffled" for _, m, _ in ctx._messages)) - - async def test_psychic_leap_shuffles_and_promotes_on_yes(self): - mew = self.add_deck_card("activePokemonArea", MEW_V_113, P1) - self.add_deck_card("bench", MEW_VMAX_114, P1) - self.add_deck_card("activePokemonArea", MEW_VMAX_114, P2) - self.session.choice_replies.append(0) # Yes - attack = ability_named(MEW_V_113, "Psychic Leap") - await resolve_attack(self.session, P1, mew, attack, attack.ability_id) - deck = self.board.find_player_area(P1, "deck") - self.assertIn(mew, deck.children) - self.assertEqual(self.session.promotions, [P1]) - self.assertIsNone(self.session.game_over) - - async def test_psychic_leap_no_shuffle_on_decline(self): - mew = self.add_deck_card("activePokemonArea", MEW_V_113, P1) - self.add_deck_card("activePokemonArea", MEW_VMAX_114, P2) - self.session.choice_replies.append(1) # No - attack = ability_named(MEW_V_113, "Psychic Leap") - await resolve_attack(self.session, P1, mew, attack, attack.ability_id) - self.assertIs(self.board.active_pokemon(P1), mew) - self.assertEqual(self.session.promotions, []) - - async def test_psychic_leap_ends_game_when_promotion_fails(self): - mew = self.add_deck_card("activePokemonArea", MEW_V_113, P1) - self.add_deck_card("activePokemonArea", MEW_VMAX_114, P2) - self.session.choice_replies.append(0) # Yes - self.session._promote_new_active = AsyncMock(return_value=False) - attack = ability_named(MEW_V_113, "Psychic Leap") - await resolve_attack(self.session, P1, mew, attack, attack.ability_id) - self.assertEqual(self.session.game_over, (P2, "P1 has no Pokémon left")) - - -# ---------------------------------------------------------------------- -# Mew VMAX: Cross Fusion Strike + Max Miracle -# ---------------------------------------------------------------------- - -class TestMewVMAX(MewDeckTestBase): - async def test_cross_fusion_strike_no_candidates_is_noop(self): - mewmax = self.add_deck_card("activePokemonArea", MEW_VMAX_114, P1) - defender = self.add_deck_card("activePokemonArea", MEW_V_113, P2) - ctx = EffectContext(self.session, P1, mewmax, - ability_named(MEW_VMAX_114, "Cross Fusion Strike")) - await ability_named(MEW_VMAX_114, "Cross Fusion Strike").effect(ctx) - self.assertEqual(ctx.knockouts, []) - self.assertEqual(defender.get_attribute(AttrID.HP), 180) - self.assertEqual(self.session.prompts, []) - - async def test_cross_fusion_strike_copies_non_locking_attack(self): - mewmax = self.add_deck_card("activePokemonArea", MEW_VMAX_114, P1) - self.add_deck_card("bench", ORICORIO, P1) - defender = self.add_deck_card("activePokemonArea", MEW_V_113, P2) - ctx = EffectContext(self.session, P1, mewmax, - ability_named(MEW_VMAX_114, "Cross Fusion Strike")) - self.session.choice_replies.append(0) # the only candidate: Glistening Droplets - self.session.chooser_replies.append([defender.entity_id]) # counter target - self.session.choice_replies.append(4) # amount "5" - await ability_named(MEW_VMAX_114, "Cross Fusion Strike").effect(ctx) - self.assertEqual(defender.get_attribute(AttrID.HP), 180 - 50) - miracle_id = ability_named(MEW_VMAX_114, "Max Miracle").ability_id - cross_id = ability_named(MEW_VMAX_114, "Cross Fusion Strike").ability_id - # (attack_locked is vacuously true at turn_number 0; check the lock - # table directly instead of the helper.) - self.assertNotIn((mewmax.entity_id, miracle_id), self.session.turn_state.attack_locks) - self.assertNotIn((mewmax.entity_id, cross_id), self.session.turn_state.attack_locks) - - async def test_cross_fusion_strike_copies_locking_attack_locks_both_own_attacks(self): - mewmax = self.add_deck_card("activePokemonArea", MEW_VMAX_114, P1) - self.add_deck_card("bench", GENESECT_V_185, P1) - defender = self.add_deck_card("activePokemonArea", MEW_VMAX_268, P2) # 310 HP - ctx = EffectContext(self.session, P1, mewmax, - ability_named(MEW_VMAX_114, "Cross Fusion Strike")) - self.session.choice_replies.append(0) # the only candidate: Techno Blast - await ability_named(MEW_VMAX_114, "Cross Fusion Strike").effect(ctx) - self.assertEqual(defender.get_attribute(AttrID.HP), 310 - 210) - cross_id = ability_named(MEW_VMAX_114, "Cross Fusion Strike").ability_id - miracle_id = ability_named(MEW_VMAX_114, "Max Miracle").ability_id - # Copying a locked attack means the user can't attack at all next - # turn, not just can't reuse the copied attack. - self.assertTrue(self.session.turn_state.attack_locked(mewmax.entity_id, cross_id)) - self.assertTrue(self.session.turn_state.attack_locked(mewmax.entity_id, miracle_id)) - - async def test_cross_fusion_strike_copy_of_copy_fizzles(self): - mewmax = self.add_deck_card("activePokemonArea", MEW_VMAX_114, P1) - self.add_deck_card("bench", MEW_VMAX_268, P1) # benched Mew VMAX alt-art - defender = self.add_deck_card("activePokemonArea", MEW_V_113, P2) - attack = ability_named(MEW_VMAX_114, "Cross Fusion Strike") - # Copy attacks stay selectable per the ruling; picking the benched Mew - # VMAX's Cross Fusion Strike re-enters the copy chain and fizzles - # (attack does nothing, resolution completes, turn ends normally). - self.session.choice_replies.append(0) - await resolve_attack(self.session, P1, mewmax, attack, attack.ability_id) - _, _, buttons = self.session.prompts[-1] - self.assertEqual(len(buttons), 2) - self.assertIn("Cross Fusion Strike", buttons[0]) - self.assertEqual(len(self.session.prompts), 1) # no recursive re-prompt - self.assertEqual(defender.get_attribute(AttrID.HP), 180) - self.assertEqual(self.session.turn_state.attack_locks, {}) - - async def test_max_miracle_ignores_target_side_prevention(self): - mewmax = self.add_deck_card("activePokemonArea", MEW_VMAX_114, P1) - defender = self.add_deck_card("activePokemonArea", MEW_V_113, P2) - shield = self.add_to("discard", SHIELD_TOOL, P2) - self.board.attach_card(shield.entity_id, defender.entity_id) - attack = ability_named(MEW_VMAX_114, "Max Miracle") - await resolve_attack(self.session, P1, mewmax, attack, attack.ability_id) - self.assertEqual(defender.get_attribute(AttrID.HP), 180 - 130) - - -if __name__ == "__main__": - unittest.main() diff --git a/spirit/tests/test_mew_deck_trainers.py b/spirit/tests/test_mew_deck_trainers.py deleted file mode 100644 index 3196ed0..0000000 --- a/spirit/tests/test_mew_deck_trainers.py +++ /dev/null @@ -1,570 +0,0 @@ -"""End-to-end unit tests for BATCH A of the Mew VMAX deck spec: the -Trainer/Stadium/Pokemon Tool cards in spirit/game/card_effects/trainers.py -and their script wiring (spirit/game/scripts/cards/**).""" - -import importlib.util -import os -import unittest -from unittest.mock import AsyncMock, patch - -from spirit.tests.test_phase1_engine import EngineTestBase, P1, P2, make_card - -from spirit.game.attributes import AttrID, PokemonTypes -from spirit.game.card_effects import trainers -from spirit.game.card_effects.energies import DoubleTurboPassive -from spirit.game.card_effects.pokemon import ChoiceBeltPassive -from spirit.game.data_utils import ( - Ability, - Activations, - Attack, - EnergyCardDef, - ItemCardDef, - PokemonCardDef, - PokemonStage, - PokemonToolCardDef, - StadiumCardDef, - SupporterCardDef, -) -from spirit.game.models.board import BoardState, create_card_entity -from spirit.game.session.effects import EffectContext, resolve_trainer_effect -from spirit.game.session.passives import ability_locked, compute_damage - -SCRIPTS_DIR = os.path.join( - os.path.dirname(__file__), "..", "game", "scripts", "cards" -) - - -def load_script(*parts): - """Imports a card script module directly, bypassing the full loader.""" - path = os.path.join(SCRIPTS_DIR, *parts) - spec = importlib.util.spec_from_file_location( - "test_script_" + "_".join(parts), path - ) - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module.card - - -# ---------------------------------------------------------------------- -# Local test fixtures (guids under a private 40000000 prefix, unused -# elsewhere in the suite) -# ---------------------------------------------------------------------- - -BASIC_DEF = PokemonCardDef( - guid="40000000-0000-0000-0000-0000000000a1", - key="TST", name="com.test.pokemon.Basic.Name", - collector_number=901, set_code="TST", rarity=1, - hp=60, elements=[PokemonTypes.COLORLESS], stage=PokemonStage.BASIC, -) -STAGE1_DEF = PokemonCardDef( - guid="40000000-0000-0000-0000-0000000000a2", - key="TST", name="com.test.pokemon.Stage1.Name", - collector_number=902, set_code="TST", rarity=1, - hp=90, elements=[PokemonTypes.COLORLESS], stage=PokemonStage.STAGE1, -) -WATER_BASIC_DEF = PokemonCardDef( - guid="40000000-0000-0000-0000-0000000000a3", - key="TST", name="com.test.pokemon.WaterBasic.Name", - collector_number=903, set_code="TST", rarity=1, - hp=70, elements=[PokemonTypes.WATER], stage=PokemonStage.BASIC, -) -V_POKEMON_DEF = PokemonCardDef( - guid="40000000-0000-0000-0000-0000000000a4", - key="TST", name="com.test.pokemon.VMon.Name", - collector_number=904, set_code="TST", rarity=1, - hp=200, elements=[PokemonTypes.COLORLESS], subtypes=["V"], -) -PLAIN_MON_DEF = PokemonCardDef( - guid="40000000-0000-0000-0000-0000000000a5", - key="TST", name="com.test.pokemon.Plain.Name", - collector_number=905, set_code="TST", rarity=1, - hp=100, elements=[PokemonTypes.COLORLESS], -) -FUSION_ATTACKER_DEF = PokemonCardDef( - guid="40000000-0000-0000-0000-0000000000a6", - key="TST", name="com.test.pokemon.FusionAttacker.Name", - collector_number=906, set_code="TST", rarity=1, - hp=120, elements=[PokemonTypes.COLORLESS], subtypes=["Fusion Strike"], - abilities=[Attack("Poke Punch", cost={PokemonTypes.COLORLESS: 1}, damage=50)], -) -FUSION_ATTACK = FUSION_ATTACKER_DEF.abilities[0] - -SUPPORTER_FILLER_DEF = SupporterCardDef( - guid="40000000-0000-0000-0000-0000000000c1", - key="TST", name="com.test.trainer.SupporterFiller.Name", - collector_number=920, set_code="TST", rarity=1, -) -ITEM_FILLER_DEF = ItemCardDef( - guid="40000000-0000-0000-0000-0000000000c2", - key="TST", name="com.test.trainer.ItemFiller.Name", - collector_number=921, set_code="TST", rarity=1, -) -SPECIAL_ENERGY_DEF = EnergyCardDef( - guid="40000000-0000-0000-0000-0000000000e1", - key="TST", name="com.test.energy.Special.Name", - collector_number=930, set_code="TST", rarity=0, - energy_type=PokemonTypes.WATER, is_special=True, -) -PLAIN_ENERGY_DEF = EnergyCardDef( - guid="40000000-0000-0000-0000-0000000000e2", - key="TST", name="com.test.energy.Plain.Name", - collector_number=931, set_code="TST", rarity=0, - energy_type=PokemonTypes.COLORLESS, -) - - -def deck_of(count, definition=BASIC_DEF): - return [make_card(definition) for _ in range(count)] - - -class TrainerDeckTestBase(EngineTestBase): - async def play_trainer(self, card_def, player_id=P1): - """Runs a trainer's registered effect through the trainer-slot flow, - mirroring GameSession._execute_play_trainer's board state.""" - card = self.add_to("discard", make_card(card_def), player_id) - trainer_area = self.board.find_global_area("activeTrainer") - self.board.move_card(card.entity_id, trainer_area.entity_id) - card.owning_player_id = player_id - return await resolve_trainer_effect(self.session, player_id, card) - - -# ---------------------------------------------------------------------- -# 1. Switch -# ---------------------------------------------------------------------- - -class TestSwitch(TrainerDeckTestBase): - def test_condition_requires_own_bench(self): - self.assertFalse(trainers.player_has_bench(self.board, P1)) - self.add_to("bench", make_card(BASIC_DEF), P1) - self.assertTrue(trainers.player_has_bench(self.board, P1)) - - async def test_switches_active_with_chosen_benched_pokemon(self): - self.add_to("activePokemonArea", make_card(PLAIN_MON_DEF), P1) - benched = self.add_to("bench", make_card(BASIC_DEF), P1) - await trainers.switch(EffectContext(self.session, P1, benched, None)) - self.assertIs(self.board.active_pokemon(P1), benched) - - -# ---------------------------------------------------------------------- -# 2. Pal Pad -# ---------------------------------------------------------------------- - -class TestPalPad(TrainerDeckTestBase): - def test_condition_requires_supporter_in_discard(self): - self.assertFalse(trainers.has_supporter_in_discard(self.board, P1)) - self.add_to("discard", make_card(SUPPORTER_FILLER_DEF), P1) - self.assertTrue(trainers.has_supporter_in_discard(self.board, P1)) - - async def test_shuffles_up_to_2_supporters_into_deck(self): - supporters = [self.add_to("discard", make_card(SUPPORTER_FILLER_DEF), P1) - for _ in range(3)] - item = self.add_to("discard", make_card(ITEM_FILLER_DEF), P1) - self.session.chooser_replies = [ - [supporters[0].entity_id, supporters[1].entity_id] - ] - await trainers.pal_pad(EffectContext(self.session, P1, supporters[0], None)) - deck = self.board.find_player_area(P1, "deck") - self.assertIn(supporters[0], deck.children) - self.assertIn(supporters[1], deck.children) - discard = self.board.find_player_area(P1, "discard") - self.assertIn(supporters[2], discard.children) - self.assertIn(item, discard.children) # not a Supporter: untouched - # Only Supporters were offered to the chooser. - offered = self.session.chooser_calls[0][1] - self.assertNotIn(item.entity_id, offered) - - -# ---------------------------------------------------------------------- -# 3. Judge -# ---------------------------------------------------------------------- - -class TestJudge(TrainerDeckTestBase): - async def test_each_player_shuffles_hand_and_draws_4(self): - for _ in range(2): - self.add_to("hand", make_card(BASIC_DEF), P1) - self.add_to("hand", make_card(BASIC_DEF), P2) - for _ in range(8): - self.add_to("deck", make_card(BASIC_DEF), P1) - self.add_to("deck", make_card(BASIC_DEF), P2) - source = self.add_to("hand", make_card(ITEM_FILLER_DEF), P1) - await trainers.judge(EffectContext(self.session, P1, source, None)) - self.assertEqual(len(self.board.find_player_area(P1, "hand").children), 4) - self.assertEqual(len(self.board.find_player_area(P2, "hand").children), 4) - - -# ---------------------------------------------------------------------- -# 4. Roxanne -# ---------------------------------------------------------------------- - -class TestRoxanne(TrainerDeckTestBase): - def test_condition_gates_on_opponent_prizes_remaining(self): - for _ in range(4): - self.add_to("prizePile", make_card(BASIC_DEF), P2) - self.assertFalse(trainers.opponent_prizes_low(self.board, P1)) - prize_pile = self.board.find_player_area(P2, "prizePile") - discard = self.board.find_player_area(P2, "discard") - # Taking 1 prize leaves 3 remaining: the condition flips on. - self.board.move_card(prize_pile.children[0].entity_id, discard.entity_id) - self.assertTrue(trainers.opponent_prizes_low(self.board, P1)) - - async def test_self_draws_6_opponent_draws_2(self): - self.add_to("hand", make_card(BASIC_DEF), P1) - self.add_to("hand", make_card(BASIC_DEF), P2) - for _ in range(10): - self.add_to("deck", make_card(BASIC_DEF), P1) - self.add_to("deck", make_card(BASIC_DEF), P2) - await trainers.roxanne(EffectContext(self.session, P1, None, None)) - self.assertEqual(len(self.board.find_player_area(P1, "hand").children), 6) - self.assertEqual(len(self.board.find_player_area(P2, "hand").children), 2) - - -# ---------------------------------------------------------------------- -# 5. Cyllene -# ---------------------------------------------------------------------- - -class TestCyllene(TrainerDeckTestBase): - def test_condition_requires_a_discarded_card(self): - self.assertFalse(trainers.has_discard_card(self.board, P1)) - self.add_to("discard", make_card(BASIC_DEF), P1) - self.assertTrue(trainers.has_discard_card(self.board, P1)) - - async def test_two_heads_puts_two_cards_on_top_in_order(self): - card_a = self.add_to("discard", make_card(BASIC_DEF), P1) - card_b = self.add_to("discard", make_card(STAGE1_DEF), P1) - source = self.add_to("hand", make_card(ITEM_FILLER_DEF), P1) - self.session.chooser_replies = [[card_a.entity_id, card_b.entity_id]] - with patch("spirit.game.session.effects.random.choice", side_effect=[0, 0]), \ - patch("spirit.game.session.effects.asyncio.sleep", new=AsyncMock()): - await trainers.cyllene(EffectContext(self.session, P1, source, None)) - deck = self.board.find_player_area(P1, "deck") - # card_b was picked last -> ends up on top of the deck. - self.assertEqual(deck.children[-1], card_b) - self.assertIn(card_a, deck.children) - - async def test_all_tails_moves_nothing(self): - self.add_to("discard", make_card(BASIC_DEF), P1) - source = self.add_to("hand", make_card(ITEM_FILLER_DEF), P1) - with patch("spirit.game.session.effects.random.choice", side_effect=[1, 1]), \ - patch("spirit.game.session.effects.asyncio.sleep", new=AsyncMock()): - await trainers.cyllene(EffectContext(self.session, P1, source, None)) - self.assertEqual(self.session.chooser_calls, []) - - -# ---------------------------------------------------------------------- -# 6. Battle VIP Pass -# ---------------------------------------------------------------------- - -class TestBattleVIPPass(TrainerDeckTestBase): - def test_condition_first_turn_and_bench_space_only(self): - self.session.turn_state.turn_number = 2 - self.assertTrue(trainers.battle_vip_pass_playable(self.board, P1)) - self.session.turn_state.turn_number = 3 - self.assertFalse(trainers.battle_vip_pass_playable(self.board, P1)) - - def test_condition_false_when_bench_full(self): - self.session.turn_state.turn_number = 1 - for _ in range(5): - self.add_to("bench", make_card(BASIC_DEF), P1) - self.assertFalse(trainers.battle_vip_pass_playable(self.board, P1)) - - async def test_benches_up_to_2_basics_and_shuffles(self): - basics = [self.add_to("deck", make_card(BASIC_DEF), P1) for _ in range(2)] - self.add_to("deck", make_card(STAGE1_DEF), P1) # not Basic: filtered - source = self.add_to("hand", make_card(ITEM_FILLER_DEF), P1) - self.session.chooser_replies = [[c.entity_id for c in basics]] - await trainers.battle_vip_pass(EffectContext(self.session, P1, source, None)) - bench = self.board.find_player_area(P1, "bench") - for card in basics: - self.assertIn(card, bench.children) - - async def test_caps_search_count_by_remaining_bench_space(self): - for _ in range(4): - self.add_to("bench", make_card(BASIC_DEF), P1) # 1 slot left - basic = self.add_to("deck", make_card(BASIC_DEF), P1) - source = self.add_to("hand", make_card(ITEM_FILLER_DEF), P1) - await trainers.battle_vip_pass(EffectContext(self.session, P1, source, None)) - self.assertEqual(self.session.chooser_calls[0][2], 1) # count capped to 1 - self.assertIn(basic, self.board.find_player_area(P1, "bench").children) - - -# ---------------------------------------------------------------------- -# 7. Power Tablet -# ---------------------------------------------------------------------- - -class TestPowerTablet(TrainerDeckTestBase): - async def test_adds_30_damage_modifier_for_fusion_strike_this_turn(self): - attacker = self.add_to("activePokemonArea", make_card(FUSION_ATTACKER_DEF), P1) - target = self.add_to("activePokemonArea", make_card(PLAIN_MON_DEF), P2) - source = self.add_to("hand", make_card(ITEM_FILLER_DEF), P1) - await trainers.power_tablet(EffectContext(self.session, P1, source, None)) - calc = compute_damage(self.board, attacker, target, 50) - self.assertEqual(calc.amount, 80) - - async def test_no_condition_gate(self): - card = load_script("SWSH8", "PowerTablet_281.py") - self.assertIsNone(card.condition) - - -# ---------------------------------------------------------------------- -# 8. Cram-o-matic -# ---------------------------------------------------------------------- - -class TestCramomatic(TrainerDeckTestBase): - def test_condition_requires_another_item_besides_itself(self): - self.add_to("hand", make_card(ITEM_FILLER_DEF), P1) # itself, conceptually - self.assertFalse(trainers.has_other_item_in_hand(self.board, P1)) - self.add_to("hand", make_card(ITEM_FILLER_DEF), P1) - self.assertTrue(trainers.has_other_item_in_hand(self.board, P1)) - - async def test_heads_searches_deck_without_revealing(self): - fodder = self.add_to("hand", make_card(ITEM_FILLER_DEF), P1) - kept = self.add_to("hand", make_card(SUPPORTER_FILLER_DEF), P1) # not discardable - found = self.add_to("deck", make_card(BASIC_DEF), P1) - self.session.chooser_replies = [[fodder.entity_id], [found.entity_id]] - with patch("spirit.game.session.effects.random.choice", side_effect=[0]): - ctx = EffectContext(self.session, P1, fodder, None) - await trainers.cramomatic(ctx) - self.assertIn(fodder, self.board.find_player_area(P1, "discard").children) - self.assertIn(kept, self.board.find_player_area(P1, "hand").children) - self.assertIn(found, self.board.find_player_area(P1, "hand").children) - # No "reveal it" clause: the opponent never learns the found card's - # identity (no EntityIntroduced for it), unlike the discarded fodder - # (discarding is always public). - p2_intros = {m["value"]["entityID"] for m in ctx.messages_for(P2) - if m["name"] == "EntityIntroduced"} - self.assertNotIn(found.entity_id, p2_intros) - self.assertIn(fodder.entity_id, p2_intros) - - async def test_tails_does_not_search(self): - fodder = self.add_to("hand", make_card(ITEM_FILLER_DEF), P1) - self.add_to("deck", make_card(BASIC_DEF), P1) - self.session.chooser_replies = [[fodder.entity_id]] - with patch("spirit.game.session.effects.random.choice", side_effect=[1]): - ctx = EffectContext(self.session, P1, fodder, None) - await trainers.cramomatic(ctx) - self.assertEqual(len(self.session.chooser_calls), 1) # only the discard pick - - -# ---------------------------------------------------------------------- -# 9. Rotom Phone -# ---------------------------------------------------------------------- - -class TestRotomPhone(TrainerDeckTestBase): - def test_condition_requires_nonempty_deck(self): - self.assertFalse(trainers.deck_nonempty(self.board, P1)) - self.add_to("deck", make_card(BASIC_DEF), P1) - self.assertTrue(trainers.deck_nonempty(self.board, P1)) - - async def test_looks_at_top_5_and_puts_chosen_on_top(self): - cards = [self.add_to("deck", make_card(BASIC_DEF), P1) for _ in range(7)] - top5 = list(reversed(cards[-5:])) - chosen = top5[2] - source = self.add_to("hand", make_card(ITEM_FILLER_DEF), P1) - self.session.chooser_replies = [[chosen.entity_id]] - await trainers.rotom_phone(EffectContext(self.session, P1, source, None)) - deck = self.board.find_player_area(P1, "deck") - self.assertEqual(deck.children[-1], chosen) - self.assertEqual(set(deck.children), set(cards)) # nothing lost - - -# ---------------------------------------------------------------------- -# 10. Fan of Waves -# ---------------------------------------------------------------------- - -class TestFanOfWaves(TrainerDeckTestBase): - def test_condition_requires_opponent_special_energy(self): - pokemon = self.add_to("activePokemonArea", make_card(PLAIN_MON_DEF), P2) - self.assertFalse(trainers.opponent_has_special_energy(self.board, P1)) - energy = self.add_to("discard", make_card(SPECIAL_ENERGY_DEF), P2) - self.board.attach_card(energy.entity_id, pokemon.entity_id) - self.assertTrue(trainers.opponent_has_special_energy(self.board, P1)) - - async def test_puts_special_energy_on_bottom_of_opponents_deck(self): - pokemon = self.add_to("activePokemonArea", make_card(PLAIN_MON_DEF), P2) - energy = self.add_to("discard", make_card(SPECIAL_ENERGY_DEF), P2) - self.board.attach_card(energy.entity_id, pokemon.entity_id) - plain_energy = self.add_to("discard", make_card(PLAIN_ENERGY_DEF), P2) - self.board.attach_card(plain_energy.entity_id, pokemon.entity_id) - self.add_to("deck", make_card(BASIC_DEF), P2) - source = self.add_to("hand", make_card(ITEM_FILLER_DEF), P1) - await trainers.fan_of_waves(EffectContext(self.session, P1, source, None)) - deck = self.board.find_player_area(P2, "deck") - self.assertEqual(deck.children[0], energy) # bottom of deck = index 0 - # The plain (non-Special) Energy was never a valid target. - self.assertNotIn(plain_energy, deck.children) - offered = self.session.chooser_calls[0][1] - self.assertNotIn(plain_energy.entity_id, offered) - - -# ---------------------------------------------------------------------- -# 11. Big Parasol -# ---------------------------------------------------------------------- - -BIG_PARASOL_TEST_DEF = PokemonToolCardDef( - passive=trainers.BigParasolPassive(), - guid="40000000-0000-0000-0000-0000000000e3", - key="TST", name="com.test.trainer.BigParasolTest.Name", - collector_number=910, set_code="TST", rarity=0, -) - - -class TestBigParasol(TrainerDeckTestBase): - def _attach(self, area_name): - holder = self.add_to(area_name, make_card(BASIC_DEF), P2) - tool = self.add_to("discard", make_card(BIG_PARASOL_TEST_DEF), P2) - self.board.attach_card(tool.entity_id, holder.entity_id) - return holder - - def test_shields_whole_side_while_holder_is_active(self): - holder = self._attach("activePokemonArea") - bench_mate = self.add_to("bench", make_card(BASIC_DEF), P2) - attacker = self.add_to("activePokemonArea", make_card(PLAIN_MON_DEF), P1) - atk = Attack("Zap", cost={}, damage=0) - ctx = EffectContext(self.session, P1, attacker, atk) - self.assertTrue(ctx.effects_blocked(holder)) - self.assertTrue(ctx.effects_blocked(bench_mate)) - - def test_no_shield_when_holder_is_benched(self): - holder = self._attach("bench") - attacker = self.add_to("activePokemonArea", make_card(PLAIN_MON_DEF), P1) - atk = Attack("Zap", cost={}, damage=0) - ctx = EffectContext(self.session, P1, attacker, atk) - self.assertFalse(ctx.effects_blocked(holder)) - - def test_does_not_block_damage(self): - holder = self._attach("activePokemonArea") - attacker = self.add_to("bench", make_card(PLAIN_MON_DEF), P1) - self.assertFalse(compute_damage(self.board, attacker, holder, 30).prevented) - - -# ---------------------------------------------------------------------- -# 12. Path to the Peak -# ---------------------------------------------------------------------- - -PATH_TO_THE_PEAK_TEST_DEF = StadiumCardDef( - passive=trainers.PathToThePeakPassive(), - guid="40000000-0000-0000-0000-0000000000f1", - key="TST", name="com.test.trainer.PathToThePeakTest.Name", - collector_number=911, set_code="TST", rarity=0, -) - - -class TestPathToThePeak(TrainerDeckTestBase): - def test_locks_rule_box_abilities_both_sides(self): - v_pokemon = self.add_to("bench", make_card(V_POKEMON_DEF), P1) - self.assertFalse(ability_locked(self.board, v_pokemon)) - stadium_area = self.board.find_global_area("activeStadium") - entity = create_card_entity(make_card(PATH_TO_THE_PEAK_TEST_DEF), owning_player_id=P2) - self.board.add_card_to_area(entity, stadium_area) - self.assertTrue(ability_locked(self.board, v_pokemon)) - - def test_leaves_non_rule_box_pokemon_alone(self): - plain = self.add_to("bench", make_card(PLAIN_MON_DEF), P1) - stadium_area = self.board.find_global_area("activeStadium") - entity = create_card_entity(make_card(PATH_TO_THE_PEAK_TEST_DEF), owning_player_id=P2) - self.board.add_card_to_area(entity, stadium_area) - self.assertFalse(ability_locked(self.board, plain)) - - -# ---------------------------------------------------------------------- -# 13. Lost City -# ---------------------------------------------------------------------- - -class TestLostCity(unittest.TestCase): - def test_reroutes_every_knockout_to_the_lost_zone(self): - passive = trainers.LostCityPassive() - self.assertEqual(passive.knockout_destination(object(), object()), "lostZone") - - -# ---------------------------------------------------------------------- -# 14. Forest Seal Stone -# ---------------------------------------------------------------------- - -class TestForestSealStone(TrainerDeckTestBase): - def test_script_wires_a_vstar_granted_ability(self): - card = load_script("SWSH12", "ForestSealStone_156.py") - self.assertEqual(len(card.granted_abilities), 1) - ability = card.granted_abilities[0] - self.assertEqual(ability.title, "Star Alchemy") - self.assertTrue(ability.vstar) - self.assertEqual(ability.activation, Activations.ONCE_PER_TURN) - self.assertIs(ability.effect, trainers.star_alchemy) - - def test_condition_requires_a_pokemon_v_holder(self): - card = load_script("SWSH12", "ForestSealStone_156.py") - condition = card.granted_abilities[0].condition - v_pokemon = self.add_to("bench", make_card(V_POKEMON_DEF), P1) - plain = self.add_to("bench", make_card(PLAIN_MON_DEF), P1) - self.assertTrue(condition(self.board, P1, v_pokemon)) - self.assertFalse(condition(self.board, P1, plain)) - - async def test_star_alchemy_searches_without_revealing(self): - found = self.add_to("deck", make_card(BASIC_DEF), P1) - holder = self.add_to("bench", make_card(V_POKEMON_DEF), P1) - self.session.chooser_replies = [[found.entity_id]] - ctx = EffectContext(self.session, P1, holder, None) - await trainers.star_alchemy(ctx) - self.assertIn(found, self.board.find_player_area(P1, "hand").children) - p2_intros = {m["value"]["entityID"] for m in ctx.messages_for(P2) - if m["name"] == "EntityIntroduced"} - self.assertNotIn(found.entity_id, p2_intros) - - -# ---------------------------------------------------------------------- -# 15. Stub reprints wired to already-shared effects -# ---------------------------------------------------------------------- - -class TestReprintWiring(unittest.TestCase): - def test_bosss_orders_reprints(self): - for set_code, filename in [ - ("SWSH2", "BosssOrders_154.py"), ("SWSH2", "BosssOrders_200.py"), - ("SWSH45", "BosssOrders_58.py"), ("SWSH9", "BosssOrders_132.py"), - ]: - card = load_script(set_code, filename) - self.assertIs(card.effect, trainers.bosss_orders) - self.assertIs(card.condition, trainers.opponent_has_bench) - - def test_ultra_ball_reprint(self): - card = load_script("SWSH9", "UltraBall_150.py") - self.assertIs(card.effect, trainers.ultra_ball) - self.assertTrue(card.condition(self.board_for_hand_size(3), P1)) - self.assertFalse(card.condition(self.board_for_hand_size(2), P1)) - - def test_quick_ball_reprints(self): - for set_code, filename in [ - ("SWSH1", "QuickBall_179.py"), ("SWSH8", "QuickBall_237.py"), - ]: - card = load_script(set_code, filename) - self.assertIs(card.effect, trainers.quick_ball) - self.assertTrue(card.condition(self.board_for_hand_size(2), P1)) - self.assertFalse(card.condition(self.board_for_hand_size(1), P1)) - - def test_lost_vacuum_reprint(self): - card = load_script("SWSH11", "LostVacuum_217.py") - self.assertIs(card.effect, trainers.lost_vacuum) - self.assertIs(card.condition, trainers.lost_vacuum_playable) - - def test_choice_belt_reprint(self): - card = load_script("SWSH10", "ChoiceBelt_211.py") - self.assertIsInstance(card.passive, ChoiceBeltPassive) - - def test_double_turbo_energy_reprint(self): - card = load_script("SWSH10", "DoubleTurboEnergy_216.py") - self.assertIsInstance(card.passive, DoubleTurboPassive) - self.assertEqual( - card.extra_attributes[str(AttrID.ENERGY_INFO.value)]["value"], - '{"options": [[1, 1]]}', - ) - - @staticmethod - def board_for_hand_size(count): - board = BoardState("wiring-test", [P1, P2]) - hand = board.find_player_area(P1, "hand") - for _ in range(count): - entity = create_card_entity(make_card(BASIC_DEF), owning_player_id=P1) - board.add_card_to_area(entity, hand) - return board - - -if __name__ == "__main__": - unittest.main() diff --git a/spirit/tests/test_phase1_engine.py b/spirit/tests/test_phase1_engine.py deleted file mode 100644 index 13f985e..0000000 --- a/spirit/tests/test_phase1_engine.py +++ /dev/null @@ -1,890 +0,0 @@ -"""Unit tests for the Phase 1 engine prerequisites (spirit/game/session/ -passives.py, effects.py, legal_actions.py, game_session.py) needed by the -Mew VMAX deck: turn-scoped damage modifiers, ability locks, knockout -destinations, coin flips, deck peek/bottom, effect-immune damage, tool- -granted abilities, and damage-counter placement.""" - -import unittest -from unittest.mock import AsyncMock, patch - -from spirit.game.attributes import AttrID, PokemonTypes -from spirit.game.data_utils import ( - Ability, - Activations, - Attack, - EnergyCardDef, - PokemonCardDef, - PokemonToolCardDef, - StadiumCardDef, - Triggers, - has_rule_box, - unimplemented, -) -from spirit.game.models.board import BoardState, create_card_entity -from spirit.game.models.card import Card -from spirit.game.session.effects import EffectContext, resolve_attack, resolve_on_play_ability -from spirit.game.session.game_session import GameSession -from spirit.game.card_effects.pokemon import fusion_strike_bench_attacks -from spirit.game.session.legal_actions import ( - ACTION_USE_ABILITY, - ACTION_USE_ATTACK, - KIND_CAKE_ATTACK_CHOICE, - TurnState, - _attack_entries, - compute_legal_actions, - copy_attack_choice_node, -) -from spirit.game.session.passives import ( - Passive, - TurnDamageModifier, - ability_locked, - active_passives, - compute_damage, -) -from spirit.network.message_names import OutboundMsg - -P1 = "player-1" -P2 = "player-2" -GAME_ID = "test-game" - - -def make_card(card_def) -> Card: - d = card_def.to_archetype_dict() - return Card(d["guid"], d["key"], d["attributes"]) - - -# ---------------------------------------------------------------------- -# Shared card fixtures -# ---------------------------------------------------------------------- - -FUSION_ATTACKER_DEF = PokemonCardDef( - guid="10000000-0000-0000-0000-0000000000a1", - key="TST", name="com.test.pokemon.FusionAttacker.Name", - collector_number=1, set_code="TST", rarity=1, - hp=120, elements=[PokemonTypes.COLORLESS], - subtypes=["Fusion Strike"], - abilities=[Attack("Poke Punch", cost={PokemonTypes.COLORLESS: 1}, damage=10)], -) -FUSION_ATTACKER = make_card(FUSION_ATTACKER_DEF) -FUSION_VANILLA_ATTACK = FUSION_ATTACKER_DEF.abilities[0] - -PLAIN_ATTACKER_DEF = PokemonCardDef( - guid="10000000-0000-0000-0000-0000000000a2", - key="TST", name="com.test.pokemon.PlainAttacker.Name", - collector_number=2, set_code="TST", rarity=1, - hp=90, elements=[PokemonTypes.COLORLESS], - abilities=[Attack("Tackle", cost={PokemonTypes.COLORLESS: 1}, damage=10)], -) -PLAIN_ATTACKER = make_card(PLAIN_ATTACKER_DEF) -PLAIN_VANILLA_ATTACK = PLAIN_ATTACKER_DEF.abilities[0] - -PLAIN_DEFENDER_DEF = PokemonCardDef( - guid="10000000-0000-0000-0000-0000000000d1", - key="TST", name="com.test.pokemon.PlainDefender.Name", - collector_number=3, set_code="TST", rarity=1, - hp=100, elements=[PokemonTypes.COLORLESS], -) -PLAIN_DEFENDER = make_card(PLAIN_DEFENDER_DEF) - -BENCH_DEFENDER_DEF = PokemonCardDef( - guid="10000000-0000-0000-0000-0000000000d2", - key="TST", name="com.test.pokemon.BenchDefender.Name", - collector_number=4, set_code="TST", rarity=1, - hp=100, elements=[PokemonTypes.COLORLESS], -) -BENCH_DEFENDER = make_card(BENCH_DEFENDER_DEF) - - -# ---------------------------------------------------------------------- -# Shared duck-typed GameSession stub (mirrors test_effects.FakeSession, plus -# delegates resolve_knockouts/refresh_granted_abilities to the REAL -# GameSession implementation so E6/E10 exercise production code). -# ---------------------------------------------------------------------- - -class FakePlayer: - def __init__(self, name): - self.screen_name = name - - -class FakeSession: - def __init__(self, board): - self.board_state = board - self.game_id = GAME_ID - self.players = {P1: FakePlayer("P1"), P2: FakePlayer("P2")} - self.turn_state = TurnState() - self._turn_visualizations = {} - self.sleep_checkup_coins = {} - self.poison_counters = {} - self.paralyzed_since = {} - self.sent = [] - self.broadcasts = [] - self.prompts = [] - self.choice_replies = [] - self.chooser_calls = [] - self.chooser_replies = [] - self.damage_counter_calls = [] - self.damage_counter_replies = [] - self.attack_sources = None - self.prize_awards = [] - self.promotions = [] - self.game_over = None - self.game_stats = {P1: {}, P2: {}} - self.mvp_damage = {P1: {}, P2: {}} - - def credit_card_damage(self, player_id, entity, amount): - guid = getattr(entity, "archetype_id", None) - if not guid or amount <= 0: - return - entry = self.mvp_damage.setdefault(player_id, {}).setdefault(guid, [0, ""]) - entry[0] += amount - - def stat_add(self, player_id, key, amount=1): - stats = self.game_stats.setdefault(player_id, {}) - stats[key] = stats.get(key, 0) + amount - - def stat_max(self, player_id, key, value): - stats = self.game_stats.setdefault(player_id, {}) - if value > stats.get(key, 0): - stats[key] = value - - def _opponent_id(self, player_id): - return P2 if player_id == P1 else P1 - - @staticmethod - def _build_msg(name, value): - return {"name": name, "value": value} - - def _sequence_envelope(self, sequence_id, msg): - return {"sequenceID": sequence_id, "gameID": self.game_id, "msg": msg} - - async def broadcast_packet(self, name, value, flags=0): - self.broadcasts.append((name, value)) - - def _entity_introduced_msg(self, card): - return self._build_msg(OutboundMsg.ENTITY_INTRODUCED.value, { - "gameID": self.game_id, - "entityID": card.entity_id, - "entityName": card.get_entity_name(), - "attributeMap": card.serialize_attributes(), - }) - - def _entity_moved_msg(self, entity_id, destination_id, position): - return self._build_msg(OutboundMsg.ENTITY_MOVED.value, { - "gameID": self.game_id, - "entityID": entity_id, - "destinationID": destination_id, - "positionInParent": position, - "animDuration": 300, - }) - - def _reveal_card_msg(self, entity_id, return_to_origin): - return self._build_msg(OutboundMsg.REVEAL_CARD_TO_ALL_EFFECT.value, { - "gameID": self.game_id, - "entityID": entity_id, - "Return": return_to_origin, - "alwaysReveal": False, - }) - - def _entity_id_data_effect_msg(self, key, entity_id): - return self._build_msg(OutboundMsg.ENTITY_ID_DATA_EFFECT.value, { - "gameID": self.game_id, "key": key, "value": entity_id, - }) - - def _condition_attr_msg(self, pokemon): - conditions = pokemon.get_attribute(AttrID.SPECIAL_CONDITIONS) or [] - return self._build_msg(OutboundMsg.ATTRIBUTE_MODIFIED.value, { - "gameID": self.game_id, - "entityID": pokemon.entity_id, - "attribute": { - "name": AttrID.SPECIAL_CONDITIONS.value, - "value": conditions, - "originalValue": conditions, - "modValue": None, - }, - }) - - async def send_game_sequence(self, players, name, inner_messages): - viewer_ids = [pid for pid, p in self.players.items() if p in players] - self.sent.append((viewer_ids, getattr(name, "value", name), list(inner_messages))) - - async def prompt_player_choice(self, player_id, prompt, buttons, sort_type=""): - self.prompts.append((player_id, prompt, buttons)) - return self.choice_replies.pop(0) if self.choice_replies else 0 - - async def prompt_choice_panel(self, player_id, source, options, - prompt=None, descriptions=None): - self.prompts.append((player_id, prompt, options)) - return self.choice_replies.pop(0) if self.choice_replies else 0 - - async def prompt_attack_selection(self, player_id, source, candidates, prompt=""): - labels = [attack.title for _pokemon, attack in candidates] - self.prompts.append((player_id, prompt, labels)) - if not candidates: - return None - return self.choice_replies.pop(0) if self.choice_replies else 0 - - async def prompt_card_chooser(self, player_id, source_entity_id, cards, - count, minimum=None, prompt="", ordered=False, - display_cards=None, slot_prompt=""): - ids = [c.entity_id for c in cards] - self.chooser_calls.append((player_id, ids, count, minimum, prompt)) - if self.chooser_replies: - return [i for i in self.chooser_replies.pop(0) if i in ids][:count] - return ids[:count] - - async def prompt_entity_picker(self, player_id, source_entity_id, cards, - count, minimum=None, prompt=""): - return await self.prompt_card_chooser( - player_id, source_entity_id, cards, count, minimum, prompt - ) - - async def prompt_card_chooser_groups(self, player_id, source_entity_id, - groups, prompt="", display_cards=None, - total=None, any_of=False): - picked = [] - for group in groups: - picked.append(await self.prompt_card_chooser( - player_id, source_entity_id, group["cards"], group["count"], - group.get("minimum", 0), prompt, - )) - return picked - - async def prompt_damage_counter_placement(self, player_id, source_entity_id, - candidates, count, - amount_per_click=10, prompt=""): - ids = [c.entity_id for c in candidates] - self.damage_counter_calls.append((player_id, ids, count, prompt)) - if self.damage_counter_replies: - return self.damage_counter_replies.pop(0) - return {ids[0]: count} if ids else {} - - async def _broadcast_attack_sources(self, entity_ids): - self.attack_sources = entity_ids - - async def _take_prizes(self, player_id, count): - self.prize_awards.append((player_id, count)) - - async def _promote_new_active(self, player_id): - self.promotions.append(player_id) - return True - - async def end_game(self, winner_id, reason): - self.game_over = (winner_id, reason) - - # Delegate to the REAL GameSession implementations so these tests - # exercise production code, not a hand-rolled mirror. - async def resolve_knockouts(self, ctx, _ko_depth=0): - await GameSession.resolve_knockouts(self, ctx, _ko_depth=_ko_depth) - - def clear_condition_state(self, entity_id): - GameSession.clear_condition_state(self, entity_id) - - def clear_pokemon_effects(self, pokemon): - return GameSession.clear_pokemon_effects(self, pokemon) - - def reset_pokemon_damage(self, pokemon): - return GameSession.reset_pokemon_damage(self, pokemon) - - def reset_ability_usage(self, pokemon): - return GameSession.reset_ability_usage(self, pokemon) - - def _pie_ability_entries(self, pokemon): - return GameSession._pie_ability_entries(self, pokemon) - - async def refresh_granted_abilities(self, pokemon): - await GameSession.refresh_granted_abilities(self, pokemon) - - async def _broadcast_entity_attribute(self, entity, attr, value): - await GameSession._broadcast_entity_attribute(self, entity, attr, value) - - def _clear_entity_visualizations_msg(self, entity): - return GameSession._clear_entity_visualizations_msg(self, entity) - - async def add_turn_stat_visualization(self, pokemon, arrow, display_type, - source_name, card_text=None): - await GameSession.add_turn_stat_visualization( - self, pokemon, arrow, display_type, source_name, card_text - ) - - -class EngineTestBase(unittest.IsolatedAsyncioTestCase): - def setUp(self): - self.board = BoardState(GAME_ID, [P1, P2]) - self.session = FakeSession(self.board) - self.board.turn_state = self.session.turn_state - - def add_to(self, area_name, card, player_id): - entity = create_card_entity(card, owning_player_id=player_id) - area = self.board.find_player_area(player_id, area_name) - self.board.add_card_to_area(entity, area) - return entity - - def brackets(self, name): - return [s for s in self.session.sent if s[1] == name] - - -# ---------------------------------------------------------------------- -# E1: TurnState reference on BoardState -# ---------------------------------------------------------------------- - -class TestTurnStateReference(unittest.TestCase): - def test_default_is_none_and_neutral_in_compute_damage(self): - board = BoardState(GAME_ID, [P1, P2]) - self.assertIsNone(board.turn_state) - attacker = create_card_entity(PLAIN_ATTACKER, owning_player_id=P1) - target = create_card_entity(PLAIN_DEFENDER, owning_player_id=P2) - board.find_player_area(P1, "activePokemonArea").add_child(attacker) - board.find_player_area(P2, "activePokemonArea").add_child(target) - # No turn_state at all: modifiers loop must not crash. - calc = compute_damage(board, attacker, target, 10) - self.assertEqual(calc.amount, 10) - - -# ---------------------------------------------------------------------- -# E3: Turn-scoped damage modifiers (Power Tablet) -# ---------------------------------------------------------------------- - -class TestTurnDamageModifier(EngineTestBase): - def test_modifier_applies_only_to_matching_subtype_owner_and_active(self): - attacker = self.add_to("activePokemonArea", FUSION_ATTACKER, P1) - active_target = self.add_to("activePokemonArea", PLAIN_DEFENDER, P2) - bench_target = self.add_to("bench", BENCH_DEFENDER, P2) - self.board.turn_state.damage_modifiers.append( - TurnDamageModifier(amount=30, player_id=P1, requires_subtype="Fusion Strike") - ) - - # Matching owner + subtype + opponent's Active: +30. - self.assertEqual( - compute_damage(self.board, attacker, active_target, 10).amount, 40 - ) - # Not versus the Active (opposing_active_only default True): no bonus. - self.assertEqual( - compute_damage(self.board, attacker, bench_target, 10, - apply_modifiers=False).amount, - 10, - ) - - def test_modifier_ignores_non_matching_subtype(self): - attacker = self.add_to("activePokemonArea", PLAIN_ATTACKER, P1) - target = self.add_to("activePokemonArea", PLAIN_DEFENDER, P2) - self.board.turn_state.damage_modifiers.append( - TurnDamageModifier(amount=30, player_id=P1, requires_subtype="Fusion Strike") - ) - self.assertEqual(compute_damage(self.board, attacker, target, 10).amount, 10) - - def test_modifier_ignores_other_players_attackers(self): - attacker = self.add_to("activePokemonArea", FUSION_ATTACKER, P2) - target = self.add_to("activePokemonArea", PLAIN_DEFENDER, P1) - self.board.turn_state.damage_modifiers.append( - TurnDamageModifier(amount=30, player_id=P1, requires_subtype="Fusion Strike") - ) - self.assertEqual(compute_damage(self.board, attacker, target, 10).amount, 10) - - def test_cleared_by_begin_turn(self): - self.board.turn_state.damage_modifiers.append( - TurnDamageModifier(amount=30, player_id=P1) - ) - self.board.turn_state.begin_turn(P1) - self.assertEqual(self.board.turn_state.damage_modifiers, []) - - async def test_ctx_add_turn_damage_modifier_helper(self): - attacker = self.add_to("activePokemonArea", FUSION_ATTACKER, P1) - ctx = EffectContext(self.session, P1, attacker, FUSION_VANILLA_ATTACK) - mod = TurnDamageModifier(amount=15, player_id=P1) - ctx.add_turn_damage_modifier(mod) - self.assertIn(mod, self.session.turn_state.damage_modifiers) - - -# ---------------------------------------------------------------------- -# E4 + E5: Stadium passives & ability lock (Path to the Peak) -# ---------------------------------------------------------------------- - -class _PathToThePeakPassive(Passive): - """Test-local stand-in: turns off Abilities of any Rule Box Pokemon.""" - def blocks_abilities(self, pokemon, carrier) -> bool: - return has_rule_box(pokemon.archetype_id) - - -class _InertToolPassive(Passive): - """Marker passive with no active hooks, just to prove survival.""" - - -class _InertAbilityPassive(Passive): - """Marker passive (e.g. a Poke-Body) riding the V's own ability slot.""" - - -async def _dummy_ability_effect(ctx): - pass - - -V_ABILITY = Ability( - "Star Alchemy", "text", - activation=Activations.ONCE_PER_TURN, effect=_dummy_ability_effect, - passive=_InertAbilityPassive(), -) - -V_POKEMON_DEF = PokemonCardDef( - guid="10000000-0000-0000-0000-0000000000a3", - key="TST", name="com.test.pokemon.RuleBoxV.Name", - collector_number=5, set_code="TST", rarity=1, - hp=200, elements=[PokemonTypes.COLORLESS], subtypes=["V"], - abilities=[V_ABILITY], -) -V_POKEMON = make_card(V_POKEMON_DEF) - -INERT_TOOL_DEF = PokemonToolCardDef( - passive=_InertToolPassive(), - guid="10000000-0000-0000-0000-0000000000e3", - key="TST", name="com.test.trainer.InertTool.Name", - collector_number=6, set_code="TST", rarity=0, -) -INERT_TOOL = make_card(INERT_TOOL_DEF) - -PATH_TO_THE_PEAK_DEF = StadiumCardDef( - passive=_PathToThePeakPassive(), - guid="10000000-0000-0000-0000-0000000000f1", - key="TST", name="com.test.trainer.PathToThePeak.Name", - collector_number=7, set_code="TST", rarity=0, -) -PATH_TO_THE_PEAK = make_card(PATH_TO_THE_PEAK_DEF) - - -class TestStadiumPassiveAndAbilityLock(EngineTestBase): - def _setup_v_with_tool(self): - v_pokemon = self.add_to("bench", V_POKEMON, P1) - tool = self.add_to("discard", INERT_TOOL, P1) - self.board.attach_card(tool.entity_id, v_pokemon.entity_id) - return v_pokemon, tool - - def test_ability_and_tool_passives_active_without_stadium(self): - v_pokemon, tool = self._setup_v_with_tool() - pairs = active_passives(self.board) - carriers = [c for _, c in pairs] - self.assertIn(v_pokemon, carriers) - self.assertIn(tool, carriers) - self.assertFalse(ability_locked(self.board, v_pokemon)) - - def test_stadium_locks_ability_but_tool_survives(self): - v_pokemon, tool = self._setup_v_with_tool() - stadium_area = self.board.find_global_area("activeStadium") - stadium = create_card_entity(PATH_TO_THE_PEAK, owning_player_id=P2) - self.board.add_card_to_area(stadium, stadium_area) - - self.assertTrue(ability_locked(self.board, v_pokemon)) - pairs = active_passives(self.board) - carriers = [c for _, c in pairs] - self.assertNotIn(v_pokemon, carriers) # ability passive excluded - self.assertIn(tool, carriers) # tool passive survives - self.assertIn(stadium, carriers) # stadium's own passive counts - - def test_stadium_lock_gates_legal_action_offer(self): - v_pokemon, _ = self._setup_v_with_tool() - state = TurnState() - state.turn_number = 3 - entries = compute_legal_actions(self.board, state, P1, GAME_ID) - self.assertTrue(any( - e["selectableAction"]["actionID"] == V_ABILITY.ability_id - for e in entries - )) - - stadium_area = self.board.find_global_area("activeStadium") - stadium = create_card_entity(PATH_TO_THE_PEAK, owning_player_id=P2) - self.board.add_card_to_area(stadium, stadium_area) - entries = compute_legal_actions(self.board, state, P1, GAME_ID) - self.assertFalse(any( - e["selectableAction"]["actionID"] == V_ABILITY.ability_id - for e in entries - )) - - def test_lock_off_after_stadium_discarded(self): - v_pokemon, _ = self._setup_v_with_tool() - stadium_area = self.board.find_global_area("activeStadium") - stadium = create_card_entity(PATH_TO_THE_PEAK, owning_player_id=P2) - self.board.add_card_to_area(stadium, stadium_area) - self.assertTrue(ability_locked(self.board, v_pokemon)) - - discard = self.board.find_player_area(P2, "discard") - self.board.move_card(stadium.entity_id, discard.entity_id) - self.assertFalse(ability_locked(self.board, v_pokemon)) - - async def test_resolve_on_play_ability_returns_early_when_locked(self): - stadium_area = self.board.find_global_area("activeStadium") - stadium = create_card_entity(PATH_TO_THE_PEAK, owning_player_id=P2) - self.board.add_card_to_area(stadium, stadium_area) - v_pokemon = self.add_to("bench", V_POKEMON, P1) - - called = False - - async def marker_effect(ctx): - nonlocal called - called = True - - locked_ability = Ability( - "Locked On-Play", "text", trigger=Triggers.ON_PLAY, effect=marker_effect, - ) - await resolve_on_play_ability(self.session, P1, v_pokemon, locked_ability) - self.assertFalse(called) - - -# ---------------------------------------------------------------------- -# E6: Knockout destination (Lost City) -# ---------------------------------------------------------------------- - -class _LostCityPassive(Passive): - def knockout_destination(self, pokemon, carrier): - return "lostZone" - - -LOST_CITY_DEF = StadiumCardDef( - passive=_LostCityPassive(), - guid="10000000-0000-0000-0000-0000000000f2", - key="TST", name="com.test.trainer.LostCity.Name", - collector_number=8, set_code="TST", rarity=0, -) -LOST_CITY = make_card(LOST_CITY_DEF) - -TEST_ENERGY_DEF = EnergyCardDef( - guid="10000000-0000-0000-0000-0000000000e2", - key="TST", name="com.test.energy.Plain.Name", - collector_number=900, set_code="TST", rarity=0, - energy_type=PokemonTypes.COLORLESS, -) -TEST_ENERGY = make_card(TEST_ENERGY_DEF) - - -class TestKnockoutDestination(EngineTestBase): - async def test_pokemon_stack_to_lost_zone_energy_to_discard(self): - stadium_area = self.board.find_global_area("activeStadium") - stadium = create_card_entity(LOST_CITY, owning_player_id=P1) - self.board.add_card_to_area(stadium, stadium_area) - - # Benched (not Active) so promotion/prize logic no-ops cleanly. - pokemon = self.add_to("bench", PLAIN_DEFENDER, P2) - pre_evo = self.add_to("discard", PLAIN_DEFENDER, P2) - self.board.attach_card(pre_evo.entity_id, pokemon.entity_id) - energy = self.add_to("discard", TEST_ENERGY, P2) - self.board.attach_card(energy.entity_id, pokemon.entity_id) - - ctx = EffectContext(self.session, P1, pokemon, None) - ctx.knockouts.append(pokemon) - await self.session.resolve_knockouts(ctx) - - lost_zone = self.board.find_player_area(P2, "lostZone") - discard = self.board.find_player_area(P2, "discard") - self.assertIn(pokemon, lost_zone.children) - self.assertIn(pre_evo, lost_zone.children) - self.assertIn(energy, discard.children) - self.assertEqual(self.session.prize_awards, [(P1, 1)]) - self.assertEqual(self.session.promotions, []) - - -# ---------------------------------------------------------------------- -# E7: ctx.use_attack (Cross Fusion) -# ---------------------------------------------------------------------- - -BORROWED_ATTACK = Attack( - "Borrowed Blast", cost={PokemonTypes.COLORLESS: 2}, damage=50, - locks_next_turn=True, -) -BORROWED_ATTACK.ability_id = "10000000-0000-0000-0000-0000000000ab" - - -async def _cross_fusion_effect(ctx): - await ctx.use_attack(BORROWED_ATTACK) - - -CROSS_FUSION_ATTACK = Attack("Cross Fusion", cost={PokemonTypes.COLORLESS: 1}, - effect=_cross_fusion_effect) -CROSS_FUSION_ATTACK.ability_id = "10000000-0000-0000-0000-0000000000ac" - - -class TestUseAttack(EngineTestBase): - async def test_resolves_borrowed_damage_and_locks_attacker(self): - attacker = self.add_to("activePokemonArea", PLAIN_ATTACKER, P1) - defender = self.add_to("activePokemonArea", PLAIN_DEFENDER, P2) - - await resolve_attack(self.session, P1, attacker, CROSS_FUSION_ATTACK, - CROSS_FUSION_ATTACK.ability_id) - - self.assertEqual(defender.get_attribute(AttrID.HP), 50) # 100 - 50 - self.assertTrue( - self.session.turn_state.attack_locked( - attacker.entity_id, BORROWED_ATTACK.ability_id - ) - ) - # The outer bracket's title/actionID stay the USER attack's (captured - # before the effect ran and swapped ctx.ability). - attack_brackets = [s for s in self.session.sent if s[1] == "Attack"] - head = attack_brackets[0][2][0] - self.assertEqual(head["value"]["abilityID"], CROSS_FUSION_ATTACK.ability_id) - self.assertEqual(head["value"]["abilityTitle"], {"id": "Cross Fusion"}) - - -# ---------------------------------------------------------------------- -# E8: Deck peek / bottom-of-deck -# ---------------------------------------------------------------------- - -class TestDeckPeekAndBottom(EngineTestBase): - def test_deck_top_returns_topmost_first(self): - card1 = self.add_to("deck", PLAIN_DEFENDER, P1) - card2 = self.add_to("deck", BENCH_DEFENDER, P1) - card3 = self.add_to("deck", PLAIN_ATTACKER, P1) - ctx = EffectContext(self.session, P1, card1, None) - - self.assertEqual(ctx.deck_top(1), [card3]) - self.assertEqual(ctx.deck_top(2), [card3, card2]) - self.assertEqual(ctx.deck_top(3), [card3, card2, card1]) - - async def test_put_on_bottom_of_deck(self): - self.add_to("deck", PLAIN_DEFENDER, P1) - card = self.add_to("hand", BENCH_DEFENDER, P1) - ctx = EffectContext(self.session, P1, card, None) - - result = await ctx.put_on_bottom_of_deck(card) - - self.assertTrue(result) - deck = self.board.find_player_area(P1, "deck") - self.assertEqual(deck.children[0], card) - moves = [m for _, m, bracket in ctx._messages if bracket == "GroupedMove"] - self.assertEqual(moves[0]["value"]["positionInParent"], 0) - self.assertEqual(moves[0]["value"]["entityID"], card.entity_id) - - -# ---------------------------------------------------------------------- -# E9: Effect-immune damage (Max Miracle) -# ---------------------------------------------------------------------- - -class _AlwaysPreventsPassive(Passive): - def prevents_damage(self, calc, carrier) -> bool: - return True - - -class _AttackerBoostPassive(Passive): - def modify_damage_dealt(self, calc, carrier): - calc.amount += 20 - - -ATTACKER_TOOL_DEF = PokemonToolCardDef( - passive=_AttackerBoostPassive(), - guid="10000000-0000-0000-0000-0000000000e4", - key="TST", name="com.test.trainer.AttackerBoost.Name", - collector_number=9, set_code="TST", rarity=0, -) -ATTACKER_TOOL = make_card(ATTACKER_TOOL_DEF) - -TARGET_SHIELD_TOOL_DEF = PokemonToolCardDef( - passive=_AlwaysPreventsPassive(), - guid="10000000-0000-0000-0000-0000000000e5", - key="TST", name="com.test.trainer.TargetShield.Name", - collector_number=10, set_code="TST", rarity=0, -) -TARGET_SHIELD_TOOL = make_card(TARGET_SHIELD_TOOL_DEF) - - -class TestIgnoreTargetEffects(EngineTestBase): - def test_ignore_target_effects_skips_target_side_prevention_only(self): - attacker = self.add_to("activePokemonArea", PLAIN_ATTACKER, P1) - defender = self.add_to("activePokemonArea", PLAIN_DEFENDER, P2) - atk_tool = self.add_to("discard", ATTACKER_TOOL, P1) - self.board.attach_card(atk_tool.entity_id, attacker.entity_id) - shield_tool = self.add_to("discard", TARGET_SHIELD_TOOL, P2) - self.board.attach_card(shield_tool.entity_id, defender.entity_id) - - # Without the flag: the target's shield prevents the hit entirely. - blocked = compute_damage(self.board, attacker, defender, 10) - self.assertTrue(blocked.prevented) - self.assertEqual(blocked.amount, 0) - - # With it: attacker-side boost still applies, target shield skipped. - calc = compute_damage(self.board, attacker, defender, 10, - ignore_target_effects=True) - self.assertFalse(calc.prevented) - self.assertEqual(calc.amount, 30) - - -# ---------------------------------------------------------------------- -# E10: Tool-granted abilities (Forest Seal Stone) -# ---------------------------------------------------------------------- - -HOLDER_POKEMON_DEF = PokemonCardDef( - guid="10000000-0000-0000-0000-0000000000a4", - key="TST", name="com.test.pokemon.ToolHolder.Name", - collector_number=11, set_code="TST", rarity=1, - hp=150, elements=[PokemonTypes.COLORLESS], -) -HOLDER_POKEMON = make_card(HOLDER_POKEMON_DEF) - - -async def _granted_effect(ctx): - pass - - -FOREST_SEAL_STONE_DEF = PokemonToolCardDef( - granted_abilities=[ - Ability("Star Alchemy", "text", activation=Activations.ONCE_PER_TURN, - effect=_granted_effect), - ], - guid="10000000-0000-0000-0000-0000000000e6", - key="TST", name="com.test.trainer.ForestSealStone.Name", - collector_number=12, set_code="TST", rarity=0, -) -FOREST_SEAL_STONE = make_card(FOREST_SEAL_STONE_DEF) - - -class TestGrantedAbilities(EngineTestBase): - async def test_attach_grants_and_discard_revokes(self): - pokemon = self.add_to("bench", HOLDER_POKEMON, P1) - tool = self.add_to("discard", FOREST_SEAL_STONE, P1) - self.board.attach_card(tool.entity_id, pokemon.entity_id) - - await self.session.refresh_granted_abilities(pokemon) - entries = pokemon.get_attribute(AttrID.PIE_ABILITIES) - self.assertEqual(len(entries), 1) - self.assertEqual( - entries[0]["abilityID"], - FOREST_SEAL_STONE_DEF.granted_abilities[0].ability_id, - ) - - ctx = EffectContext(self.session, P1, pokemon, None) - await ctx.discard_cards([tool]) - - entries_after = pokemon.get_attribute(AttrID.PIE_ABILITIES) - self.assertEqual(entries_after, []) - discard = self.board.find_player_area(P1, "discard") - self.assertIn(tool, discard.children) - - -# ---------------------------------------------------------------------- -# E2: ctx.flip_coins -# ---------------------------------------------------------------------- - -class TestFlipCoins(EngineTestBase): - async def test_ability_context_queues_inline_and_tallies_stats(self): - attacker = self.add_to("activePokemonArea", PLAIN_ATTACKER, P1) - ctx = EffectContext(self.session, P1, attacker, PLAIN_VANILLA_ATTACK) - - with patch("spirit.game.session.effects.random.choice", side_effect=[0, 1, 0]): - results = await ctx.flip_coins(3, title="Coin Check") - - self.assertEqual(results, [True, False, True]) - self.assertEqual(self.session.game_stats[P1]["headsflipped"], 2) - self.assertEqual(self.session.game_stats[P1]["tailsflipped"], 1) - # Ability context: queued inline (bracket=None), never sent directly. - self.assertEqual(len(ctx._messages), 1) - vid, msg, bracket = ctx._messages[0] - self.assertIsNone(bracket) - self.assertEqual(msg["value"]["resultLst"], [0, 1, 0]) - self.assertEqual(msg["value"]["title"], {"id": "Coin Check"}) - self.assertEqual(self.session.sent, []) - - async def test_trainer_context_sends_immediately_with_source_name_fallback(self): - card = self.add_to("hand", PLAIN_DEFENDER, P1) - ctx = EffectContext(self.session, P1, card, None) - - with patch("spirit.game.session.effects.random.choice", side_effect=[1, 1]), \ - patch("spirit.game.session.effects.asyncio.sleep", new=AsyncMock()) as mock_sleep: - results = await ctx.flip_coins(2) - - self.assertEqual(results, [False, False]) - mock_sleep.assert_awaited_once() - flip_brackets = self.brackets("PokeAbility") - self.assertEqual(len(flip_brackets), 1) - value = flip_brackets[0][2][0]["value"] - self.assertEqual(value["resultLst"], [1, 1]) - self.assertEqual(value["title"], {"id": "com.test.pokemon.PlainDefender.Name"}) - - -# ---------------------------------------------------------------------- -# E11: Damage-counter placement (Glistening Droplets) -# ---------------------------------------------------------------------- - -class TestPlaceDamageCounters(EngineTestBase): - async def test_single_offer_distributes_counters_per_scripted_placement(self): - source = self.add_to("activePokemonArea", PLAIN_ATTACKER, P1) - active = self.add_to("activePokemonArea", PLAIN_DEFENDER, P2) - benched = self.add_to("bench", BENCH_DEFENDER, P2) - ctx = EffectContext(self.session, P1, source, None) - - self.session.damage_counter_replies.append( - {active.entity_id: 2, benched.entity_id: 1} - ) - - await ctx.place_damage_counters(3, candidates=[active, benched]) - - self.assertEqual(len(self.session.damage_counter_calls), 1) - call_player, call_ids, call_count, _prompt = self.session.damage_counter_calls[0] - self.assertEqual(call_player, P1) - self.assertEqual(set(call_ids), {active.entity_id, benched.entity_id}) - self.assertEqual(call_count, 3) - self.assertEqual(active.get_attribute(AttrID.HP), 80) # 100 - 20 - self.assertEqual(benched.get_attribute(AttrID.HP), 90) # 100 - 10 - - -# ---------------------------------------------------------------------- -# E12: Copy-attack choice node (Cross Fusion Strike's forced CakeAttack pick) -# ---------------------------------------------------------------------- - -COPY_MASTER_DEF = PokemonCardDef( - guid="10000000-0000-0000-0000-0000000000c1", - key="TST", name="com.test.pokemon.CopyMaster.Name", - collector_number=5, set_code="TST", rarity=1, - hp=310, elements=[PokemonTypes.PSYCHIC], - subtypes=["Fusion Strike"], - abilities=[Attack("Cross Copy")], -) -COPY_MASTER = make_card(COPY_MASTER_DEF) -COPY_ATTACK = COPY_MASTER_DEF.abilities[0] - - -class TestCopyAttackChoiceNode(EngineTestBase): - def test_choice_node_wire_shape(self): - active = self.add_to("activePokemonArea", COPY_MASTER, P1) - self.add_to("bench", FUSION_ATTACKER, P1) - self.add_to("bench", PLAIN_ATTACKER, P1) # not Fusion Strike: excluded - - candidates = fusion_strike_bench_attacks(self.board, P1) - node = copy_attack_choice_node( - active.entity_id, candidates, "Choose an attack." - ) - - self.assertEqual(node["name"], KIND_CAKE_ATTACK_CHOICE) - self.assertTrue(node["selected"]) - self.assertEqual(node["targetPrompt"], {"id": "Choose an attack."}) - choices = node["choices"] - self.assertEqual(len(choices), 1) - self.assertEqual(choices[0]["title"], {"id": "Poke Punch"}) - self.assertTrue(choices[0]["abilityID"]) - # bonusInfo renders each choice with the OWNER's type, not the copier's. - self.assertEqual( - choices[0]["bonusInfo"]["originalOwnerTypes"], - [PokemonTypes.COLORLESS.value], - ) - - def test_candidates_exclude_non_fusion_and_dedupe(self): - self.add_to("activePokemonArea", COPY_MASTER, P1) - fusion = self.add_to("bench", FUSION_ATTACKER, P1) - self.add_to("bench", FUSION_ATTACKER, P1) # duplicate: deduped - self.add_to("bench", PLAIN_ATTACKER, P1) - - candidates = fusion_strike_bench_attacks(self.board, P1) - - self.assertEqual(candidates, [(fusion, FUSION_VANILLA_ATTACK)]) - - def test_attack_entries_stay_plain(self): - # The copy pick rides a FORCED follow-up offer, never the main-turn - # entry's targetInfoLst (nodes under an ActionsNode are cancellable). - self.add_to("activePokemonArea", COPY_MASTER, P1) - self.add_to("bench", FUSION_ATTACKER, P1) - self.session.turn_state.turn_number = 3 # attack_locked(0, ...) is True - entries = _attack_entries( - self.board, self.session.turn_state, P1, GAME_ID - ) - entry = next( - e for e in entries - if e["selectableAction"]["actionID"] == COPY_ATTACK.ability_id - ) - self.assertEqual(entry["targetInfoLst"], []) - - -if __name__ == "__main__": - unittest.main() diff --git a/spirit/tests/test_prize_pick.py b/spirit/tests/test_prize_pick.py deleted file mode 100644 index 38f2235..0000000 --- a/spirit/tests/test_prize_pick.py +++ /dev/null @@ -1,139 +0,0 @@ -"""Prize-take choreography: the r.B fan-in flag and taken-slot gap tracking.""" - -import unittest -import uuid - -from spirit.game.attributes import AttrID, PokemonTypes -from spirit.game.data_utils import PokemonCardDef -from spirit.game.models.board import create_card_entity -from spirit.game.models.card import Card -from spirit.game.session.game_session import GameSession - -P1 = "player-1" -P2 = "player-2" - - -def make_card(card_def) -> Card: - d = card_def.to_archetype_dict() - return Card(d["guid"], d["key"], d["attributes"]) - - -POKEMON = make_card(PokemonCardDef( - guid="00000000-0000-0000-0000-0000000000f1", - key="BW1", name="com.test.pokemon.Purrloin.Name", - collector_number=5, set_code="BW1", rarity=1, - hp=60, elements=[PokemonTypes.COLORLESS], -)) - - -class MockClientHandler: - def __init__(self, account_id, username): - self.player = type("P", (), { - "account_id": account_id, "username": username, - "screen_name": username, "avatar_decks": [], - })() - self.addr = ("127.0.0.1", 12345) - self.sent_packets = [] - - async def send_packet(self, data, request_id=0, flags=0): - self.sent_packets.append(data) - - -class PrizePickTestBase(unittest.IsolatedAsyncioTestCase): - def setUp(self): - self.client1 = MockClientHandler(P1, "Ash") - self.client2 = MockClientHandler(P2, "Gary") - pairing = { - "is_solo": False, - "format": "Standard", - "players": { - P1: {"client": self.client1, "deck": {"deckID": "d1", "deckName": "A", "cards": []}, "ready": True}, - P2: {"client": self.client2, "deck": {"deckID": "d2", "deckName": "B", "cards": []}, "ready": True}, - }, - } - self.session = GameSession(str(uuid.uuid4()), pairing) - self.board = self.session.board_state - self.prize_area = self.board.find_player_area(P1, "prizePile") - self.prizes = [] - for slot in range(6): - entity = create_card_entity(POKEMON, owning_player_id=P1) - self.board.add_card_to_area(entity, self.prize_area) - entity.board_slot = slot - self.prizes.append(entity) - self.board.prizes_dealt[P1] = 6 - self.captured_offers = [] - self.pick_queue = [] - - async def fake_prompt(player, msg_name, offer, expected_counter=None): - self.captured_offers.append(offer) - picked = self.pick_queue.pop(0) - return {"selection": {"targetResponses": [{"entityList": picked}]}} - - self.session.prompt_selection_message = fake_prompt - - def bracket_msgs(self, client, name): - """Inner messages of the last `name` sequence bracket sent to client.""" - runs, current = [], None - for p in client.sent_packets: - msg = p.get("msg") if isinstance(p, dict) else None - if not isinstance(msg, dict): - continue - if msg["name"] == "StartSequence" and msg["value"]["name"] == name: - current = [] - elif msg["name"] == "StopSequence" and msg["value"]["name"] == name: - runs.append(current or []) - current = None - elif current is not None: - current.append(msg) - return runs[-1] if runs else None - - -class TestPrizePickOffer(PrizePickTestBase): - async def test_offer_enables_the_fan_in_animation(self): - self.pick_queue.append([self.prizes[0].entity_id]) - await self.session._take_prizes(P1, 1) - - offer = self.captured_offers[0] - node = next(iter(offer["targetMap"].values()))[0] - # r.B plays the pile->slots fly-in ONLY when this JSON flag is true; - # omitted/false = the fan teleports into frame. - self.assertTrue(node["presentPrizesAllowed"]) - self.assertEqual(node["name"], "PrizeCardTargetInformation") - self.assertFalse(node["horizontalLayout"]) - - -class TestPrizeGaps(PrizePickTestBase): - async def test_taken_slots_are_marked_as_gaps(self): - self.pick_queue.append([self.prizes[2].entity_id, self.prizes[4].entity_id]) - await self.session._take_prizes(P1, 2) - - self.assertEqual( - self.prize_area.get_attribute(AttrID.AREA_EMPTY_SLOTS), [2, 4] - ) - for client in (self.client1, self.client2): - msgs = self.bracket_msgs(client, "WithOpenPrizeCards") - self.assertIsNotNone(msgs) - attr = next(m for m in msgs if m["name"] == "AttributeModified") - self.assertEqual(attr["value"]["entityID"], self.prize_area.entity_id) - self.assertEqual(attr["value"]["attribute"]["value"], [2, 4]) - # Prize faces reveal to the taker only. - taker = self.bracket_msgs(self.client1, "WithOpenPrizeCards") - opponent = self.bracket_msgs(self.client2, "WithOpenPrizeCards") - self.assertEqual( - sum(1 for m in taker if m["name"] == "EntityIntroduced"), 2) - self.assertEqual( - sum(1 for m in opponent if m["name"] == "EntityIntroduced"), 0) - - async def test_gaps_accumulate_across_takes(self): - self.pick_queue.append([self.prizes[5].entity_id]) - await self.session._take_prizes(P1, 1) - self.pick_queue.append([self.prizes[1].entity_id]) - await self.session._take_prizes(P1, 1) - - self.assertEqual( - self.prize_area.get_attribute(AttrID.AREA_EMPTY_SLOTS), [1, 5] - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/spirit/tests/test_product.py b/spirit/tests/test_product.py deleted file mode 100644 index 9fb6555..0000000 --- a/spirit/tests/test_product.py +++ /dev/null @@ -1,229 +0,0 @@ -import unittest -import json -import spirit.server.state as state -from spirit.game.models.product import Product, BoosterPack -from spirit.game.attributes import AttrID, CardType, Rarities -from spirit.game.data_utils import BoosterPackDef -from spirit.game.scripts.cards import loader as card_loader - -class TestCardLoaderCache(unittest.TestCase): - def test_load_all_is_cached(self): - first = card_loader.load_all() - self.assertTrue(first) - # Repeated calls must NOT re-exec scripts (blocks the event loop ~1s - # per call and rebuilds effect registries mid-game) - self.assertIs(card_loader.load_all(), first) - card_one = card_loader.cards[0] - self.assertIs(card_loader.load_all()[0], card_one) - - -class TestProductDynamicHostExpansion(unittest.TestCase): - def setUp(self): - # Reset server host to default before each test - state.SERVER_HOST = "127.0.0.1:8000" - - def test_relative_image_url_preservation(self): - # 1. Setup a product with a relative image URL - attributes = { - str(AttrID.IMAGE_URL.value): {"type": "string", "value": "bw1_booster.png"}, - str(AttrID.PRODUCT_TYPE.value): {"type": "int", "value": 1} - } - product = Product(guid="test-guid", key="test-key", attributes=attributes) - - # 2. Verify Shop JSON serialization preserves the relative URL to prevent cache overwrites - serialized_json = product.to_archetype_json() - img_attr_json = next(attr for attr in serialized_json["attributes"] if attr["name"] == AttrID.IMAGE_URL.value) - self.assertEqual(img_attr_json["value"], "bw1_booster.png") - - # 3. Verify Protobuf serialization dictionary preserves relative URL - serialized_dict = product.to_archetype_dict() - img_attr_dict = serialized_dict["attributes"][str(AttrID.IMAGE_URL.value)] - self.assertEqual(img_attr_dict["value"], "bw1_booster.png") - - def test_localhost_image_url_preservation(self): - # 1. Setup a product with a hardcoded localhost URL - attributes = { - str(AttrID.IMAGE_URL.value): {"type": "string", "value": "http://127.0.0.1:8000/products/custom_sleeve.png"}, - str(AttrID.PRODUCT_TYPE.value): {"type": "int", "value": 1} - } - product = Product(guid="test-guid", key="test-key", attributes=attributes) - - # 2. Verify Shop JSON serialization preserves original URL without dynamic expansion - serialized_json = product.to_archetype_json() - img_attr_json = next(attr for attr in serialized_json["attributes"] if attr["name"] == AttrID.IMAGE_URL.value) - self.assertEqual(img_attr_json["value"], "http://127.0.0.1:8000/products/custom_sleeve.png") - - # 3. Verify Protobuf serialization dictionary preserves exact values - serialized_dict = product.to_archetype_dict() - img_attr_dict = serialized_dict["attributes"][str(AttrID.IMAGE_URL.value)] - self.assertEqual(img_attr_dict["value"], "http://127.0.0.1:8000/products/custom_sleeve.png") - - def test_product_definition_image_name_extraction(self): - # Verify that ProductDef.to_archetype_dict extracts image name correctly to prevent shop/collection image bugs - prod_def = BoosterPackDef( - guid="test-pack-guid", - key="BW1", - name="test-pack", - image_url="http://127.0.0.1:8000/products/bw1_booster.png" - ) - archetype = prod_def.to_archetype_dict() - - # Verify AttrID.IMAGE_NAME (10520) is populated and has the correct relative asset name - img_name_attr = archetype["attributes"][str(AttrID.IMAGE_NAME.value)] - self.assertEqual(img_name_attr["value"], "bw1_booster") - - # Verify relative image_url extraction works too - prod_def_rel = BoosterPackDef( - guid="test-pack-guid-rel", - key="BW1", - name="test-pack-rel", - image_url="my_cool_sleeve.png" - ) - archetype_rel = prod_def_rel.to_archetype_dict() - img_name_attr_rel = archetype_rel["attributes"][str(AttrID.IMAGE_NAME.value)] - self.assertEqual(img_name_attr_rel["value"], "my_cool_sleeve") - - def test_booster_pack_dynamic_pool(self): - # Load all cards to populate card_loader.cards - card_loader.load_all() - - # Create a SWSH8 booster pack - booster = BoosterPack(guid="test-swsh8-pack", key="SWSH8") - - # Open it - opened_guids = booster.open("-1") - - # Verify it returns 10 cards - self.assertEqual(len(opened_guids), 10) - - # Verify 4 Commons (indices 0 to 3) - for i in range(4): - c = card_loader.cards_by_guid.get(opened_guids[i]) - self.assertIsNotNone(c) - self.assertEqual(c.get_attribute_value(AttrID.RARITY), Rarities.Common.value) - self.assertNotEqual(c.get_attribute_value(AttrID.CARD_TYPE), CardType.ENERGY.value) - - # Verify 3 Uncommons (indices 4 to 6) - for i in range(4, 7): - c = card_loader.cards_by_guid.get(opened_guids[i]) - self.assertIsNotNone(c) - self.assertEqual(c.get_attribute_value(AttrID.RARITY), Rarities.Uncommon.value) - self.assertNotEqual(c.get_attribute_value(AttrID.CARD_TYPE), CardType.ENERGY.value) - - # Verify 1 Energy (index 7) - c_energy = card_loader.cards_by_guid.get(opened_guids[7]) - self.assertIsNotNone(c_energy) - self.assertEqual(c_energy.get_attribute_value(AttrID.CARD_TYPE), CardType.ENERGY.value) - self.assertFalse(c_energy.get_attribute_value(AttrID.IS_SPECIAL_ENERGY)) - - # Verify 1 Reverse Holo (index 8) - c_rev = card_loader.cards_by_guid.get(opened_guids[8]) - self.assertIsNotNone(c_rev) - self.assertNotEqual(c_rev.get_attribute_value(AttrID.CARD_TYPE), CardType.ENERGY.value) - - # Verify 1 Rare / Hit (index 9) - c_rare = card_loader.cards_by_guid.get(opened_guids[9]) - self.assertIsNotNone(c_rare) - self.assertGreaterEqual(c_rare.get_attribute_value(AttrID.RARITY), Rarities.Rare.value) - self.assertNotEqual(c_rare.get_attribute_value(AttrID.CARD_TYPE), CardType.ENERGY.value) - - # Create a Booster with a key that has no cards - empty_booster = BoosterPack(guid="test-empty-pack", key="NO_CARDS_KEY") - empty_guids = empty_booster.open("-1") - - # It should fallback to the default BW1 pool - self.assertEqual(len(empty_guids), 10) - for guid in empty_guids: - self.assertIn(guid, empty_booster.card_pool) - - -class TestAutoBoosterImages(unittest.TestCase): - def test_every_eligible_pack_image_maps_to_bundle_asset(self): - """Each eligible booster's IMAGE_NAME must equal a real PNG basename, or the client - requests a missing asset from the 'packs' bundle and shows the loading hourglass.""" - import os - from spirit.game.scripts.products import loader, PACK_ART_DIR - from spirit.game.set_utils import eligible_booster_sets - from spirit.game.attributes import ProductType - - loader.load_all() - art = {os.path.splitext(f)[0].lower() - for f in os.listdir(PACK_ART_DIR) if f.lower().endswith(".png")} - eligible = {c.upper() for c in eligible_booster_sets()} - - for p in loader.products: - if p.product_type != ProductType.PACKS.value or p.key.upper() not in eligible: - continue - image_name = p.get_attribute_value(AttrID.IMAGE_NAME) - self.assertIn(image_name, art, - f"Pack {p.key} IMAGE_NAME '{image_name}' has no matching art file") - - def test_resolve_pack_image_prefers_exact_then_prefix_then_generic(self): - from spirit.game.scripts.products import resolve_pack_image, GENERIC_PACK_IMAGE - # SWSH12 has {set}_booster_{name}.png -> prefix match - self.assertTrue(resolve_pack_image("SWSH12").startswith("swsh12_booster")) - # A set with no art at all falls back to the generic placeholder - self.assertEqual(resolve_pack_image("ZZZ_NO_SUCH_SET"), GENERIC_PACK_IMAGE) - - -class TestPackRarityData(unittest.TestCase): - def test_every_pack_carries_nonnull_rarity_data(self): - """Attr 202250 (a.g[]) MUST be a non-empty array on every booster pack; the info-popup - does new List(ValueFor) and NREs on null (crashing on the 'i' button).""" - from spirit.game.scripts.products import loader - from spirit.game.attributes import ProductType - - loader.load_all() - packs = [p for p in loader.products if p.product_type == ProductType.PACKS.value] - self.assertTrue(packs, "no booster packs loaded") - for p in packs: - data = p.get_attribute_value(AttrID.PACK_RARITY_DATA) - self.assertIsInstance(data, list, f"{p.key} rarity data must be a list, got {data!r}") - self.assertTrue(data, f"{p.key} rarity data is empty") - for slot in data: - self.assertIn("rarityIcon", slot) - self.assertIn("count", slot) - self.assertIsInstance(slot["rarityName"]["id"], str) - - def test_rarity_data_survives_sku_alias(self): - """The shop lists packs under a SKU alias; it must inherit 202250 so the shop 'i' popup works.""" - from spirit.game.models.product import make_shop_sku - pack = BoosterPackDef(guid="11111111-2222-3333-4444-555555555555", - key="SWSH12", name="Test Pack", image_url="swsh12_booster") - arch = pack.to_archetype_dict() - prod = BoosterPack(arch["guid"], arch["key"], arch["attributes"]) - sku = make_shop_sku(prod) - self.assertTrue(sku.get_attribute_value(AttrID.PACK_RARITY_DATA)) - - -class TestPackPreviewCards(unittest.TestCase): - def test_packs_get_valid_preview_cards(self): - """Attr 201505 routes the "i" popup marquee through the cache-safe GetArchetype filter. - Every preview GUID must be a real loaded card (the unfiltered set-featured path crashes).""" - import spirit.packets.handlers.data_sync # noqa: F401 - loads cards at import - from spirit.game.scripts.cards import loader as card_loader - from spirit.game.scripts.products import loader - from spirit.game.attributes import ProductType - - loader.load_all() - card_guids = {c.guid for c in card_loader.cards} - packs = [p for p in loader.products if p.product_type == ProductType.PACKS.value] - for p in packs: - attr = p.attributes.get(str(AttrID.PACK_PREVIEW_CARDS.value)) - self.assertIsNotNone(attr, f"{p.key} has no preview cards (201505)") - preview = json.loads(attr["value"]) - self.assertTrue(1 <= len(preview) <= 3, f"{p.key} preview size {len(preview)}") - for guid in preview: - self.assertIn(guid, card_guids, f"{p.key} preview {guid} is not a loaded card") - - def test_reload_sets_clears_featured_archetypes(self): - """The set-featured GUIDs are original-PTCGO cards we never load; the pack popup feeds them - UNFILTERED into CachedViewModel (KeyNotFoundException), so they must be cleared on load.""" - from spirit.packets.handlers import data_sync - data_sync.reload_sets() - self.assertTrue(data_sync.SETS_DB, "no sets loaded") - self.assertTrue(all(not s.get("featuredArchetypes") for s in data_sync.SETS_DB)) - - -if __name__ == '__main__': - unittest.main() diff --git a/spirit/tests/test_redemption.py b/spirit/tests/test_redemption.py deleted file mode 100644 index 95a04b4..0000000 --- a/spirit/tests/test_redemption.py +++ /dev/null @@ -1,151 +0,0 @@ -import unittest -import os -import tempfile -from unittest.mock import MagicMock, AsyncMock, patch - -from spirit.database import db_manager - - -class TestCodeRedemption(unittest.IsolatedAsyncioTestCase): - @classmethod - def setUpClass(cls): - cls.db_fd, cls.db_path = tempfile.mkstemp() - cls.db_patcher = patch('spirit.database.connection.DB_PATH', cls.db_path) - cls.db_patcher.start() - - from spirit.database.setup_db import setup_database - setup_database() - - @classmethod - def tearDownClass(cls): - cls.db_patcher.stop() - try: - os.close(cls.db_fd) - os.unlink(cls.db_path) - except OSError: - pass - - def _make_handler(self, account_id): - from spirit.packets.handlers.redemption import RedemptionHandler - from spirit.game.models.player import Player - - mock_client = MagicMock() - mock_client.send_packet = AsyncMock() - mock_client.addr = ("127.0.0.1", 5555) - mock_client.player = Player({ - "account_id": account_id, - "username": "redeemer", - "screen_name": "Redeemer" - }) - return RedemptionHandler(mock_client), mock_client - - def _sent_by_name(self, mock_client, name): - return [c.args[0] for c in mock_client.send_packet.call_args_list - if isinstance(c.args[0], dict) and c.args[0].get("messageName") == name] - - async def test_validate_code_valid_and_invalid(self): - from spirit.database import economy_data - from spirit.network.message_names import OutboundMsg - - code = economy_data.create_code(reward={"coins": 100}, max_uses=1)["code_string"] - handler, client = self._make_handler("acc-validate-1") - - await handler.handle_validate_code({"code": code}, 10, 0) - valid = self._sent_by_name(client, OutboundMsg.CODE_IS_VALID.value) - self.assertEqual(len(valid), 1) - self.assertEqual(valid[0]["code"], code) - - await handler.handle_validate_code({"code": "SPIRIT-FAKE-FAKE-FAKE"}, 11, 0) - invalid = self._sent_by_name(client, OutboundMsg.INVALID_CODE.value) - self.assertEqual(len(invalid), 1) - self.assertIn("id", invalid[0]["reason"]) - - async def test_redeem_code_grants_rewards(self): - from spirit.database import economy_data - from spirit.database.player_data import get_wallet_by_account_id, get_collection_by_account_id - from spirit.network.message_names import OutboundMsg - - account_id = "acc-redeem-1" - # Seed wallet - get_wallet_by_account_id(account_id) - before = get_wallet_by_account_id(account_id) - - pack_guid = "d017c195-83c5-c74e-0638-25128b3116c4" - code = economy_data.create_code( - reward={"coins": 250, "gems": 5, "products": {pack_guid: 3}}, max_uses=1 - )["code_string"] - - handler, client = self._make_handler(account_id) - await handler.handle_redeem_codes({"codes": [code]}, 20, 0) - - success = self._sent_by_name(client, OutboundMsg.CODE_SUCCESSFULLY_REDEEMED.value) - self.assertEqual(len(success), 1) - self.assertEqual(success[0]["code"], code) - - # Wallet credited - after = get_wallet_by_account_id(account_id) - self.assertEqual(after["coins"], before["coins"] + 250) - self.assertEqual(after["gems"], before["gems"] + 5) - - # Collection credited - col = {c["archetype_id"]: c for c in get_collection_by_account_id(account_id)} - self.assertEqual(col[pack_guid]["nontradable_count"], 3) - - # Collection + wallet syncs were pushed - self.assertTrue(self._sent_by_name(client, OutboundMsg.COLLECTION_COUNT_FOUND.value)) - self.assertTrue(self._sent_by_name(client, OutboundMsg.CURRENT_WALLET.value)) - - async def test_redeem_twice_fails(self): - from spirit.database import economy_data - from spirit.network.message_names import OutboundMsg - - code = economy_data.create_code(reward={"coins": 10}, max_uses=0)["code_string"] - handler, client = self._make_handler("acc-redeem-twice") - - await handler.handle_redeem_codes({"codes": [code]}, 30, 0) - await handler.handle_redeem_codes({"codes": [code]}, 31, 0) - - self.assertEqual(len(self._sent_by_name(client, OutboundMsg.CODE_SUCCESSFULLY_REDEEMED.value)), 1) - failures = self._sent_by_name(client, OutboundMsg.CODE_REDEMPTION_FAILURE.value) - self.assertEqual(len(failures), 1) - self.assertEqual(failures[0]["reason"]["id"], "shop.redeemcodes.error.codealreadyused") - - async def test_max_uses_exhaustion(self): - from spirit.database import economy_data - from spirit.network.message_names import OutboundMsg - - code = economy_data.create_code(reward={"coins": 10}, max_uses=1)["code_string"] - - handler1, client1 = self._make_handler("acc-uses-1") - await handler1.handle_redeem_codes({"codes": [code]}, 40, 0) - self.assertEqual(len(self._sent_by_name(client1, OutboundMsg.CODE_SUCCESSFULLY_REDEEMED.value)), 1) - - handler2, client2 = self._make_handler("acc-uses-2") - await handler2.handle_redeem_codes({"codes": [code]}, 41, 0) - failures = self._sent_by_name(client2, OutboundMsg.CODE_REDEMPTION_FAILURE.value) - self.assertEqual(len(failures), 1) - self.assertEqual(failures[0]["reason"]["id"], "shop.redeemcodes.error.codeexpired") - - async def test_disabled_code_rejected(self): - from spirit.database import economy_data - from spirit.network.message_names import OutboundMsg - - code = economy_data.create_code(reward={"coins": 10}, enabled=False)["code_string"] - handler, client = self._make_handler("acc-disabled") - - await handler.handle_validate_code({"code": code}, 50, 0) - invalid = self._sent_by_name(client, OutboundMsg.INVALID_CODE.value) - self.assertEqual(len(invalid), 1) - - def test_handler_registered_in_router(self): - from spirit.packets.handlers.redemption import RedemptionHandler - from spirit.network.message_names import InboundMsg - - handler = RedemptionHandler(MagicMock()) - handlers = handler.get_handlers() - self.assertIn(InboundMsg.VALIDATE_CODE.value, handlers) - self.assertIn(InboundMsg.REDEEM_CODES.value, handlers) - - -if __name__ == '__main__': - unittest.main() diff --git a/spirit/tests/test_retreat.py b/spirit/tests/test_retreat.py deleted file mode 100644 index b51a49c..0000000 --- a/spirit/tests/test_retreat.py +++ /dev/null @@ -1,177 +0,0 @@ -"""Tests for the retreat executor (GameSession._execute_retreat).""" - -import unittest -import uuid - -from spirit.game.attributes import AttrID, GameSequence, PokemonTypes -from spirit.game.data_utils import EnergyCardDef, PokemonCardDef -from spirit.game.models.board import create_card_entity -from spirit.game.models.card import Card -from spirit.game.session.game_session import GameSession -from spirit.game.session.legal_actions import ACTION_RETREAT, compute_legal_actions - -P1 = "player-1" -P2 = "player-2" - - -def make_card(card_def) -> Card: - d = card_def.to_archetype_dict() - return Card(d["guid"], d["key"], d["attributes"]) - - -ACTIVE_POKEMON = make_card(PokemonCardDef( - guid="00000000-0000-0000-0000-0000000000a1", - key="BW1", name="com.test.pokemon.Snivy.Name", - collector_number=1, set_code="BW1", rarity=1, - hp=60, elements=[PokemonTypes.GRASS], retreat_cost=1, -)) - -BENCH_POKEMON = make_card(PokemonCardDef( - guid="00000000-0000-0000-0000-0000000000b1", - key="BW1", name="com.test.pokemon.Patrat.Name", - collector_number=2, set_code="BW1", rarity=1, - hp=50, elements=[PokemonTypes.COLORLESS], -)) - -GRASS_ENERGY = make_card(EnergyCardDef( - guid="00000000-0000-0000-0000-0000000000e1", - key="BW1", name="com.test.energy.Grass.Name", - collector_number=100, set_code="BW1", rarity=0, - energy_type=PokemonTypes.GRASS, -)) - - -class MockClientHandler: - def __init__(self, account_id, username): - self.player = type("P", (), { - "account_id": account_id, "username": username, - "screen_name": username, "avatar_decks": [], - })() - self.addr = ("127.0.0.1", 12345) - self.sent_packets = [] - - async def send_packet(self, data, request_id=0, flags=0): - self.sent_packets.append(data) - - -class TestExecuteRetreat(unittest.IsolatedAsyncioTestCase): - def setUp(self): - self.client1 = MockClientHandler(P1, "Ash") - self.client2 = MockClientHandler(P2, "Gary") - pairing = { - "is_solo": False, - "format": "Standard", - "players": { - P1: {"client": self.client1, "deck": {"deckID": "d1", "deckName": "A", "cards": []}, "ready": True}, - P2: {"client": self.client2, "deck": {"deckID": "d2", "deckName": "B", "cards": []}, "ready": True}, - }, - } - self.session = GameSession(str(uuid.uuid4()), pairing) - self.board = self.session.board_state - - self.active = self.add_to("activePokemonArea", ACTIVE_POKEMON) - self.benched = self.add_to("bench", BENCH_POKEMON) - self.energy = self.add_to("discard", GRASS_ENERGY) - self.board.attach_card(self.energy.entity_id, self.active.entity_id) - self.session.turn_state.begin_turn(P1) - - def add_to(self, area_name, card, player_id=P1): - entity = create_card_entity(card, owning_player_id=player_id) - self.board.add_card_to_area(entity, self.board.find_player_area(player_id, area_name)) - return entity - - def retreat_entry(self): - entries = compute_legal_actions( - self.board, self.session.turn_state, P1, self.session.game_id - ) - return next( - e for e in entries - if e["selectableAction"]["description"] == ACTION_RETREAT - ) - - def sequence_brackets(self, client, name): - """Inner messages of each sequence bracket with the given name.""" - brackets = [] - current = None - for packet in client.sent_packets: - msg = packet.get("msg") if isinstance(packet, dict) else None - if not isinstance(msg, dict): - continue - if msg.get("name") == "StartSequence" and msg["value"].get("name") == name: - current = [] - elif msg.get("name") == "StopSequence" and current is not None: - brackets.append(current) - current = None - elif current is not None: - current.append(msg) - return brackets - - async def test_retreat_swaps_active_discards_energy_and_broadcasts(self): - entry = self.retreat_entry() - await self.session._execute_retreat( - P1, self.active, entry, [self.energy.entity_id, self.benched.entity_id] - ) - - active_area = self.board.find_player_area(P1, "activePokemonArea") - bench_area = self.board.find_player_area(P1, "bench") - discard_area = self.board.find_player_area(P1, "discard") - self.assertEqual(active_area.children, [self.benched]) - self.assertEqual(bench_area.children, [self.active]) - self.assertIn(self.energy, discard_area.children) - self.assertTrue(self.session.turn_state.retreated) - - for client in (self.client1, self.client2): - brackets = self.sequence_brackets(client, GameSequence.RETREAT.value) - self.assertEqual(len(brackets), 1) - names = [m["name"] for m in brackets[0]] - # N.P's ctor requires the Retreating/NewActive data effects. - self.assertEqual(names[:2], ["EntityIDDataEffect", "EntityIDDataEffect"]) - self.assertEqual(brackets[0][0]["value"]["key"], "Retreating") - self.assertEqual(brackets[0][0]["value"]["value"], self.active.entity_id) - self.assertEqual(brackets[0][1]["value"]["key"], "NewActive") - self.assertEqual(brackets[0][1]["value"]["value"], self.benched.entity_id) - self.assertEqual(names[2:], ["EntityMoved", "EntityMoved", "EntityMoved"]) - - # The retreat is spent for the rest of the turn. - entries = compute_legal_actions( - self.board, self.session.turn_state, P1, self.session.game_id - ) - self.assertFalse( - [e for e in entries if e["selectableAction"]["description"] == ACTION_RETREAT] - ) - - async def test_underpaid_retreat_is_rejected(self): - entry = self.retreat_entry() - # Reply omits the energy payment entirely. - await self.session._execute_retreat(P1, self.active, entry, [self.benched.entity_id]) - - active_area = self.board.find_player_area(P1, "activePokemonArea") - self.assertEqual(active_area.children, [self.active]) - self.assertFalse(self.session.turn_state.retreated) - self.assertFalse(self.sequence_brackets(self.client1, GameSequence.RETREAT.value)) - - async def test_invalid_new_active_is_rejected(self): - entry = self.retreat_entry() - await self.session._execute_retreat( - P1, self.active, entry, [self.energy.entity_id, self.active.entity_id] - ) - - active_area = self.board.find_player_area(P1, "activePokemonArea") - self.assertEqual(active_area.children, [self.active]) - self.assertFalse(self.session.turn_state.retreated) - - async def test_old_active_takes_the_vacated_bench_slot(self): - other_benched = self.add_to("bench", BENCH_POKEMON) - entry = self.retreat_entry() - await self.session._execute_retreat( - P1, self.active, entry, [self.energy.entity_id, other_benched.entity_id] - ) - - bench_area = self.board.find_player_area(P1, "bench") - active_area = self.board.find_player_area(P1, "activePokemonArea") - self.assertEqual(active_area.children, [other_benched]) - self.assertEqual(bench_area.children, [self.benched, self.active]) - - -if __name__ == "__main__": - unittest.main() diff --git a/spirit/tests/test_shop_system.py b/spirit/tests/test_shop_system.py deleted file mode 100644 index 72d48d6..0000000 --- a/spirit/tests/test_shop_system.py +++ /dev/null @@ -1,204 +0,0 @@ -import unittest -import os -import tempfile -from unittest.mock import MagicMock, AsyncMock, patch - -from spirit.database import db_manager - -SWSH8_BOOSTER = "d017c195-83c5-c74e-0638-25128b3116c4" - - -def _pack_sku(shop, pack_guid): - """The shop-facing SKU GUID that maps to a given collection pack GUID.""" - for sku, pack in shop.sku_to_pack.items(): - if pack == pack_guid: - return sku - return None - - -class TestDynamicShop(unittest.IsolatedAsyncioTestCase): - @classmethod - def setUpClass(cls): - cls.db_fd, cls.db_path = tempfile.mkstemp() - cls.db_patcher = patch('spirit.database.connection.DB_PATH', cls.db_path) - cls.db_patcher.start() - - from spirit.database.setup_db import setup_database - setup_database() - - @classmethod - def tearDownClass(cls): - cls.db_patcher.stop() - try: - os.close(cls.db_fd) - os.unlink(cls.db_path) - except OSError: - pass - - def setUp(self): - # Clear any admin shop rows between tests, then rebuild the shop - from spirit.database import db_session, ShopItem - with db_session() as session: - session.query(ShopItem).delete() - from spirit.shop import shop_manager - shop_manager.reload_from_db() - - def test_shop_offers_boosters_only_for_eligible_sets(self): - from spirit.shop import shop_manager - from spirit.game.set_utils import eligible_booster_sets - from spirit.game.attributes import ProductType - - shop = shop_manager.get_shop() - eligible = {c.upper() for c in eligible_booster_sets()} - - pack_keys = {p.key.upper() for p in shop.available_products.values() - if p.product_type == ProductType.PACKS.value} - self.assertEqual(pack_keys, eligible) - self.assertNotIn("BW1", pack_keys) - self.assertIn("SWSH12", pack_keys) - - # Every offered pack has a price - for p in shop.available_products.values(): - if p.product_type == ProductType.PACKS.value: - self.assertTrue(p.prices, f"pack {p.key} has no price") - - def test_admin_shop_item_overrides_price(self): - from spirit.database.economy_data import upsert_shop_item - from spirit.shop import shop_manager - from spirit.game.attributes import AttrID - - upsert_shop_item(product_guid=SWSH8_BOOSTER, price=555, - currency=AttrID.TRAINER_TOKENS.value, featured=True) - shop_manager.reload_from_db() - - shop = shop_manager.get_shop() - sku = _pack_sku(shop, SWSH8_BOOSTER) - self.assertIsNotNone(sku) - product = shop.available_products[sku] - self.assertEqual(product.prices[0]["value"], 555) - self.assertIn(sku, [fp.product_id for fp in shop.featured_products]) - - def test_disabled_admin_item_removes_product(self): - from spirit.database.economy_data import upsert_shop_item - from spirit.shop import shop_manager - - upsert_shop_item(product_guid=SWSH8_BOOSTER, enabled=False) - shop_manager.reload_from_db() - - shop = shop_manager.get_shop() - # Neither the raw pack GUID nor a SKU for it is listed when disabled - self.assertNotIn(SWSH8_BOOSTER, shop.available_products) - self.assertIsNone(_pack_sku(shop, SWSH8_BOOSTER)) - - def test_owned_pack_guid_is_never_listed_in_shop(self): - # The collection pack GUID must stay out of the shop (only its SKU is listed), so the - # client never marks it dynamic and never evicts it from the collection on shop exit. - from spirit.shop import shop_manager - shop = shop_manager.get_shop() - self.assertNotIn(SWSH8_BOOSTER, shop.available_products) - sku = _pack_sku(shop, SWSH8_BOOSTER) - self.assertIsNotNone(sku) - self.assertNotEqual(sku, SWSH8_BOOSTER) - self.assertIn(sku, shop.available_products) - - def _make_shop_handler(self, account_id): - from spirit.packets.handlers.shop import ShopHandler - from spirit.game.models.player import Player - - mock_client = MagicMock() - mock_client.send_packet = AsyncMock() - mock_client.addr = ("127.0.0.1", 7777) - mock_client.player = Player({ - "account_id": account_id, - "username": "buyer", - "screen_name": "Buyer" - }) - return ShopHandler(mock_client), mock_client - - async def test_purchase_catalog_product_deducts_and_persists(self): - from spirit.database.player_data import get_wallet_by_account_id, get_collection_by_account_id - from spirit.network.message_names import OutboundMsg - from spirit.game.attributes import AttrID - - account_id = "acc-shop-buy-1" - get_wallet_by_account_id(account_id) # seed wallet (50000 coins) - - from spirit.shop import shop_manager - sku = _pack_sku(shop_manager.get_shop(), SWSH8_BOOSTER) - - handler, client = self._make_shop_handler(account_id) - before_coins = handler.client.player.wallet.balances[AttrID.TRAINER_TOKENS] - - # Client buys the shop SKU; server grants the real collection pack GUID - await handler.handle_purchase_catalog_products( - {"productIDs": [sku], "purchaseType": 0}, 60, 0) - - sent = [c.args[0] for c in client.send_packet.call_args_list] - purchased = [m for m in sent if m.get("messageName") == OutboundMsg.PRODUCTS_PURCHASED.value] - self.assertEqual(len(purchased), 1) - self.assertEqual(purchased[0]["items"][0]["archetypeID"], SWSH8_BOOSTER) - - # Wallet deducted by the default booster price - after_coins = handler.client.player.wallet.balances[AttrID.TRAINER_TOKENS] - self.assertEqual(after_coins, before_coins - 200) - - # Persisted in collection - col = {c["archetype_id"]: c for c in get_collection_by_account_id(account_id)} - self.assertIn(SWSH8_BOOSTER, col) - self.assertEqual(col[SWSH8_BOOSTER]["tradable_count"], 1) - - async def test_purchase_insufficient_funds_rejected(self): - from spirit.database.player_data import get_wallet_by_account_id, update_wallet - from spirit.network.message_names import OutboundMsg - - account_id = "acc-shop-poor" - get_wallet_by_account_id(account_id) - update_wallet(account_id, 5, 0, 0) # only 5 coins - - from spirit.shop import shop_manager - sku = _pack_sku(shop_manager.get_shop(), SWSH8_BOOSTER) - - handler, client = self._make_shop_handler(account_id) - await handler.handle_purchase_catalog_products( - {"productIDs": [sku], "purchaseType": 0}, 61, 0) - - sent = [c.args[0] for c in client.send_packet.call_args_list] - purchased = [m for m in sent if m.get("messageName") == OutboundMsg.PRODUCTS_PURCHASED.value] - self.assertEqual(len(purchased), 0) - - async def test_open_booster_grants_ten_cards(self): - from spirit.database.player_data import add_to_collection, get_collection_by_account_id - from spirit.network.message_names import OutboundMsg - - account_id = "acc-shop-open-1" - add_to_collection(account_id, SWSH8_BOOSTER, count=1, is_tradable=False) - - handler, client = self._make_shop_handler(account_id) - await handler.handle_open_products_by_archetype_id( - {"products": [SWSH8_BOOSTER]}, 62, 0) - - sent = [c.args[0] for c in client.send_packet.call_args_list] - opened = [m for m in sent if m.get("messageName") == OutboundMsg.PRODUCTS_OPENED.value] - self.assertEqual(len(opened), 1) - self.assertEqual(len(opened[0]["items"]), 10) - - # Every generated card belongs to SWSH8's card scripts - from spirit.game.scripts.cards import loader as card_loader - for item in opened[0]["items"]: - card = card_loader.cards_by_guid.get(item["archetypeID"].lower()) - self.assertIsNotNone(card) - self.assertIn(card.key.lower(), ("swsh8", "free_energy")) - - def test_get_available_products_lists_eligible_packs(self): - from spirit.shop import shop_manager - shop = shop_manager.get_shop() - sku = _pack_sku(shop, SWSH8_BOOSTER) - guids = shop.get_available_guids() - self.assertIn(sku, guids) - self.assertNotIn(SWSH8_BOOSTER, guids) # only the SKU is listed - price_map = shop.get_price_map() - self.assertEqual(price_map[sku]["value"][0]["value"], 200) - - -if __name__ == '__main__': - unittest.main() diff --git a/spirit/tests/test_staple_trainers.py b/spirit/tests/test_staple_trainers.py deleted file mode 100644 index 0437e30..0000000 --- a/spirit/tests/test_staple_trainers.py +++ /dev/null @@ -1,539 +0,0 @@ -"""End-to-end unit tests for the staple-trainer sweep (Batch C): shared -implementations added to spirit/game/card_effects/trainers.py and their -script wiring across SWSH1/2/3/4/9/10/35/45/PGO.""" - -import importlib.util -import os -import unittest - -from spirit.tests.test_phase1_engine import EngineTestBase, P1, P2, make_card - -from spirit.game.attributes import AttrID, PokemonTypes -from spirit.game.card_effects import trainers -from spirit.game.data_utils import ( - EnergyCardDef, - ItemCardDef, - PokemonCardDef, - PokemonStage, - SupporterCardDef, -) -from spirit.game.models.board import create_card_entity -from spirit.game.session.effects import EffectContext, resolve_trainer_effect - -SCRIPTS_DIR = os.path.join( - os.path.dirname(__file__), "..", "game", "scripts", "cards" -) - - -def load_script(*parts): - """Imports a card script module directly, bypassing the full loader.""" - path = os.path.join(SCRIPTS_DIR, *parts) - spec = importlib.util.spec_from_file_location( - "test_staple_script_" + "_".join(parts), path - ) - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module.card - - -# ---------------------------------------------------------------------- -# Local test fixtures (guids under a private 50000000 prefix, unused -# elsewhere in the suite) -# ---------------------------------------------------------------------- - -BASIC_DEF = PokemonCardDef( - guid="50000000-0000-0000-0000-0000000000a1", - key="TST", name="com.test.pokemon.Basic.Name", - collector_number=901, set_code="TST", rarity=1, - hp=60, elements=[PokemonTypes.COLORLESS], stage=PokemonStage.BASIC, -) -DARKNESS_BASIC_DEF = PokemonCardDef( - guid="50000000-0000-0000-0000-0000000000a2", - key="TST", name="com.test.pokemon.DarknessBasic.Name", - collector_number=902, set_code="TST", rarity=1, - hp=70, elements=[PokemonTypes.DARKNESS], stage=PokemonStage.BASIC, -) -VMAX_DEF = PokemonCardDef( - guid="50000000-0000-0000-0000-0000000000a3", - key="TST", name="com.test.pokemon.VMax.Name", - collector_number=903, set_code="TST", rarity=1, - hp=320, elements=[PokemonTypes.COLORLESS], subtypes=["VMAX"], -) -PLAIN_MON_DEF = PokemonCardDef( - guid="50000000-0000-0000-0000-0000000000a4", - key="TST", name="com.test.pokemon.Plain.Name", - collector_number=904, set_code="TST", rarity=1, - hp=100, elements=[PokemonTypes.COLORLESS], -) - -SUPPORTER_FILLER_DEF = SupporterCardDef( - guid="50000000-0000-0000-0000-0000000000c1", - key="TST", name="com.test.trainer.SupporterFiller.Name", - collector_number=920, set_code="TST", rarity=1, -) -ITEM_FILLER_DEF = ItemCardDef( - guid="50000000-0000-0000-0000-0000000000c2", - key="TST", name="com.test.trainer.ItemFiller.Name", - collector_number=921, set_code="TST", rarity=1, -) - -BASIC_ENERGY_DEF = EnergyCardDef( - guid="50000000-0000-0000-0000-0000000000e1", - key="TST", name="com.test.energy.Basic.Name", - collector_number=930, set_code="TST", rarity=0, - energy_type=PokemonTypes.COLORLESS, -) -METAL_ENERGY_DEF = EnergyCardDef( - guid="50000000-0000-0000-0000-0000000000e2", - key="TST", name="com.test.energy.Metal.Name", - collector_number=931, set_code="TST", rarity=0, - energy_type=PokemonTypes.METAL, -) -GRASS_ENERGY_DEF = EnergyCardDef( - guid="50000000-0000-0000-0000-0000000000e3", - key="TST", name="com.test.energy.Grass.Name", - collector_number=932, set_code="TST", rarity=0, - energy_type=PokemonTypes.GRASS, -) -SPECIAL_ENERGY_DEF = EnergyCardDef( - guid="50000000-0000-0000-0000-0000000000e4", - key="TST", name="com.test.energy.Special.Name", - collector_number=933, set_code="TST", rarity=0, - energy_type=PokemonTypes.WATER, is_special=True, -) - - -class TrainerDeckTestBase(EngineTestBase): - async def play_trainer(self, card_def, player_id=P1): - """Runs a trainer's registered effect through the trainer-slot flow, - mirroring GameSession._execute_play_trainer's board state.""" - card = self.add_to("discard", make_card(card_def), player_id) - trainer_area = self.board.find_global_area("activeTrainer") - self.board.move_card(card.entity_id, trainer_area.entity_id) - card.owning_player_id = player_id - return await resolve_trainer_effect(self.session, player_id, card) - - def played_source(self, card_def, player_id=P1): - """A trainer source sitting in activeTrainer (already off the hand), - matching the real placement-before-effect flow -- for tests where the - card's own hand-count would otherwise pollute a draw/discard assert.""" - entity = create_card_entity(make_card(card_def), owning_player_id=player_id) - trainer_area = self.board.find_global_area("activeTrainer") - self.board.add_card_to_area(entity, trainer_area) - return entity - - -# ---------------------------------------------------------------------- -# 1. Piers -# ---------------------------------------------------------------------- - -class TestPiers(TrainerDeckTestBase): - async def test_finds_an_energy_and_a_darkness_pokemon(self): - energy = self.add_to("deck", make_card(BASIC_ENERGY_DEF), P1) - darkness = self.add_to("deck", make_card(DARKNESS_BASIC_DEF), P1) - self.add_to("deck", make_card(PLAIN_MON_DEF), P1) # not selectable - source = self.add_to("hand", make_card(ITEM_FILLER_DEF), P1) - self.session.chooser_replies = [[energy.entity_id], [darkness.entity_id]] - await trainers.piers(EffectContext(self.session, P1, source, None)) - hand = self.board.find_player_area(P1, "hand") - self.assertIn(energy, hand.children) - self.assertIn(darkness, hand.children) - - -# ---------------------------------------------------------------------- -# 2. Professor's Research (+ Professor Magnolia reprint) -# ---------------------------------------------------------------------- - -class TestProfessorsResearch(TrainerDeckTestBase): - async def test_discards_hand_and_draws_7(self): - for _ in range(3): - self.add_to("hand", make_card(BASIC_DEF), P1) - for _ in range(7): - self.add_to("deck", make_card(BASIC_DEF), P1) - source = self.add_to("hand", make_card(SUPPORTER_FILLER_DEF), P1) - await trainers.professors_research(EffectContext(self.session, P1, source, None)) - self.assertEqual(len(self.board.find_player_area(P1, "hand").children), 7) - self.assertEqual(len(self.board.find_player_area(P1, "discard").children), 4) - - def test_reprints_wired(self): - for set_code, filename in [ - ("PGO", "ProfessorsResearch_78.py"), ("PGO", "ProfessorsResearch_84.py"), - ("SWSH45", "ProfessorsResearch_60.py"), ("SWSH9", "ProfessorsResearch_147.py"), - ("SWSH1", "ProfessorsResearchProfessorMagnolia_178.py"), - ("SWSH1", "ProfessorsResearchProfessorMagnolia_201.py"), - ("SWSH1", "ProfessorsResearchProfessorMagnolia_209.py"), - ("SWSH35", "ProfessorsResearchProfessorMagnolia_62.py"), - ]: - card = load_script(set_code, filename) - self.assertIs(card.effect, trainers.professors_research) - - -# ---------------------------------------------------------------------- -# 3. Bede -# ---------------------------------------------------------------------- - -class TestBede(TrainerDeckTestBase): - def test_condition_requires_bench_and_basic_energy(self): - self.assertFalse(trainers.bede_playable(self.board, P1)) - self.add_to("bench", make_card(BASIC_DEF), P1) - self.assertFalse(trainers.bede_playable(self.board, P1)) - self.add_to("hand", make_card(BASIC_ENERGY_DEF), P1) - self.assertTrue(trainers.bede_playable(self.board, P1)) - - def test_special_energy_does_not_satisfy_condition(self): - self.add_to("bench", make_card(BASIC_DEF), P1) - self.add_to("hand", make_card(SPECIAL_ENERGY_DEF), P1) - self.assertFalse(trainers.bede_playable(self.board, P1)) - - async def test_attaches_chosen_basic_energy_to_chosen_bench_pokemon(self): - bench = self.add_to("bench", make_card(BASIC_DEF), P1) - energy = self.add_to("hand", make_card(BASIC_ENERGY_DEF), P1) - source = self.add_to("hand", make_card(SUPPORTER_FILLER_DEF), P1) - self.session.chooser_replies = [[energy.entity_id], [bench.entity_id]] - await trainers.bede(EffectContext(self.session, P1, source, None)) - self.assertIn(energy, bench.children) - - -# ---------------------------------------------------------------------- -# 4. Team Yell Grunt -# ---------------------------------------------------------------------- - -class TestTeamYellGrunt(TrainerDeckTestBase): - def test_condition_requires_opponent_energy_attached(self): - pokemon = self.add_to("activePokemonArea", make_card(PLAIN_MON_DEF), P2) - self.assertFalse(trainers.opponent_has_energy_attached(self.board, P1)) - energy = self.add_to("discard", make_card(BASIC_ENERGY_DEF), P2) - self.board.attach_card(energy.entity_id, pokemon.entity_id) - self.assertTrue(trainers.opponent_has_energy_attached(self.board, P1)) - - async def test_returns_opponent_energy_to_their_hand(self): - pokemon = self.add_to("activePokemonArea", make_card(PLAIN_MON_DEF), P2) - energy = self.add_to("discard", make_card(BASIC_ENERGY_DEF), P2) - self.board.attach_card(energy.entity_id, pokemon.entity_id) - source = self.add_to("hand", make_card(SUPPORTER_FILLER_DEF), P1) - self.session.chooser_replies = [[energy.entity_id]] - await trainers.team_yell_grunt(EffectContext(self.session, P1, source, None)) - self.assertIn(energy, self.board.find_player_area(P2, "hand").children) - - -# ---------------------------------------------------------------------- -# 5. Milo -# ---------------------------------------------------------------------- - -class TestMilo(TrainerDeckTestBase): - async def test_draws_2_per_card_discarded(self): - for _ in range(2): - self.add_to("hand", make_card(BASIC_DEF), P1) - for _ in range(4): - self.add_to("deck", make_card(BASIC_DEF), P1) - source = self.played_source(SUPPORTER_FILLER_DEF) - hand_cards = self.board.find_player_area(P1, "hand").children - discard_ids = [c.entity_id for c in hand_cards][:2] - self.session.chooser_replies = [discard_ids] - await trainers.milo(EffectContext(self.session, P1, source, None)) - self.assertEqual(len(self.board.find_player_area(P1, "hand").children), 4) - - async def test_no_discard_means_no_draw(self): - source = self.played_source(SUPPORTER_FILLER_DEF) - self.session.chooser_replies = [[]] - await trainers.milo(EffectContext(self.session, P1, source, None)) - self.assertEqual(len(self.board.find_player_area(P1, "hand").children), 0) - - -# ---------------------------------------------------------------------- -# 6. Sonia -# ---------------------------------------------------------------------- - -class TestSonia(TrainerDeckTestBase): - async def test_can_search_up_to_2_basic_pokemon(self): - basics = [self.add_to("deck", make_card(BASIC_DEF), P1) for _ in range(2)] - self.add_to("deck", make_card(BASIC_ENERGY_DEF), P1) - source = self.add_to("hand", make_card(SUPPORTER_FILLER_DEF), P1) - self.session.choice_replies = [0] # choose "Basic Pokemon" - self.session.chooser_replies = [[c.entity_id for c in basics]] - await trainers.sonia(EffectContext(self.session, P1, source, None)) - hand = self.board.find_player_area(P1, "hand") - for card in basics: - self.assertIn(card, hand.children) - - async def test_can_search_up_to_2_basic_energy_instead(self): - energies = [self.add_to("deck", make_card(BASIC_ENERGY_DEF), P1) for _ in range(2)] - self.add_to("deck", make_card(BASIC_DEF), P1) - source = self.add_to("hand", make_card(SUPPORTER_FILLER_DEF), P1) - self.session.choice_replies = [1] # choose "basic Energy cards" - self.session.chooser_replies = [[c.entity_id for c in energies]] - await trainers.sonia(EffectContext(self.session, P1, source, None)) - hand = self.board.find_player_area(P1, "hand") - for card in energies: - self.assertIn(card, hand.children) - - -# ---------------------------------------------------------------------- -# 7. Kabu -# ---------------------------------------------------------------------- - -class TestKabu(TrainerDeckTestBase): - async def test_draws_4_with_a_benched_pokemon(self): - self.add_to("activePokemonArea", make_card(PLAIN_MON_DEF), P1) - self.add_to("bench", make_card(BASIC_DEF), P1) - for _ in range(2): - self.add_to("hand", make_card(BASIC_DEF), P1) - for _ in range(10): - self.add_to("deck", make_card(BASIC_DEF), P1) - source = self.add_to("hand", make_card(SUPPORTER_FILLER_DEF), P1) - await trainers.kabu(EffectContext(self.session, P1, source, None)) - self.assertEqual(len(self.board.find_player_area(P1, "hand").children), 4) - - async def test_draws_8_when_active_is_only_pokemon(self): - self.add_to("activePokemonArea", make_card(PLAIN_MON_DEF), P1) - for _ in range(2): - self.add_to("hand", make_card(BASIC_DEF), P1) - for _ in range(10): - self.add_to("deck", make_card(BASIC_DEF), P1) - source = self.add_to("hand", make_card(SUPPORTER_FILLER_DEF), P1) - await trainers.kabu(EffectContext(self.session, P1, source, None)) - self.assertEqual(len(self.board.find_player_area(P1, "hand").children), 8) - - -# ---------------------------------------------------------------------- -# 8. Rose -# ---------------------------------------------------------------------- - -class TestRose(TrainerDeckTestBase): - def test_condition_requires_a_vmax_in_play(self): - self.assertFalse(trainers.has_vmax_in_play(self.board, P1)) - self.add_to("activePokemonArea", make_card(VMAX_DEF), P1) - self.assertTrue(trainers.has_vmax_in_play(self.board, P1)) - - async def test_attaches_energy_and_discards_hand_when_any_attached(self): - vmax = self.add_to("activePokemonArea", make_card(VMAX_DEF), P1) - energies = [self.add_to("discard", make_card(BASIC_ENERGY_DEF), P1) for _ in range(2)] - hand_card = self.add_to("hand", make_card(BASIC_DEF), P1) - source = self.add_to("hand", make_card(SUPPORTER_FILLER_DEF), P1) - self.session.chooser_replies = [ - [vmax.entity_id], [c.entity_id for c in energies] - ] - await trainers.rose(EffectContext(self.session, P1, source, None)) - for energy in energies: - self.assertIn(energy, vmax.children) - self.assertIn(hand_card, self.board.find_player_area(P1, "discard").children) - - async def test_no_energy_attached_keeps_hand(self): - vmax = self.add_to("activePokemonArea", make_card(VMAX_DEF), P1) - hand_card = self.add_to("hand", make_card(BASIC_DEF), P1) - source = self.add_to("hand", make_card(SUPPORTER_FILLER_DEF), P1) - self.session.chooser_replies = [[vmax.entity_id], []] - await trainers.rose(EffectContext(self.session, P1, source, None)) - self.assertIn(hand_card, self.board.find_player_area(P1, "hand").children) - - -# ---------------------------------------------------------------------- -# 9. Hop -# ---------------------------------------------------------------------- - -class TestHop(TrainerDeckTestBase): - async def test_draws_3(self): - for _ in range(3): - self.add_to("deck", make_card(BASIC_DEF), P1) - source = self.played_source(SUPPORTER_FILLER_DEF) - await trainers.hop(EffectContext(self.session, P1, source, None)) - self.assertEqual(len(self.board.find_player_area(P1, "hand").children), 3) - - -# ---------------------------------------------------------------------- -# 10. Marnie reprint wiring (shared impl already tested elsewhere) -# ---------------------------------------------------------------------- - -class TestMarnieReprints(unittest.TestCase): - def test_reprints_wired_to_shared_marnie(self): - for set_code, filename in [ - ("SWSH1", "Marnie_169.py"), ("SWSH1", "Marnie_200.py"), - ("SWSH1", "Marnie_208.py"), ("SWSH35", "Marnie_56.py"), - ]: - card = load_script(set_code, filename) - self.assertIs(card.effect, trainers.marnie) - - -# ---------------------------------------------------------------------- -# 11. Adaman -# ---------------------------------------------------------------------- - -class TestAdaman(TrainerDeckTestBase): - def test_condition_requires_2_metal_energy_in_hand(self): - self.assertFalse(trainers.has_two_metal_energy_in_hand(self.board, P1)) - self.add_to("hand", make_card(METAL_ENERGY_DEF), P1) - self.assertFalse(trainers.has_two_metal_energy_in_hand(self.board, P1)) - self.add_to("hand", make_card(METAL_ENERGY_DEF), P1) - self.assertTrue(trainers.has_two_metal_energy_in_hand(self.board, P1)) - - async def test_discards_2_metal_energy_then_searches_without_reveal(self): - metals = [self.add_to("hand", make_card(METAL_ENERGY_DEF), P1) for _ in range(2)] - found = self.add_to("deck", make_card(BASIC_DEF), P1) - source = self.add_to("hand", make_card(SUPPORTER_FILLER_DEF), P1) - self.session.chooser_replies = [ - [c.entity_id for c in metals], [found.entity_id] - ] - ctx = EffectContext(self.session, P1, source, None) - await trainers.adaman(ctx) - for metal in metals: - self.assertIn(metal, self.board.find_player_area(P1, "discard").children) - self.assertIn(found, self.board.find_player_area(P1, "hand").children) - p2_intros = {m["value"]["entityID"] for m in ctx.messages_for(P2) - if m["name"] == "EntityIntroduced"} - self.assertNotIn(found.entity_id, p2_intros) - - async def test_insufficient_metal_energy_cancels_search(self): - metal = self.add_to("hand", make_card(METAL_ENERGY_DEF), P1) - self.add_to("deck", make_card(BASIC_DEF), P1) - source = self.add_to("hand", make_card(SUPPORTER_FILLER_DEF), P1) - self.session.chooser_replies = [[metal.entity_id]] - await trainers.adaman(EffectContext(self.session, P1, source, None)) - self.assertEqual(len(self.session.chooser_calls), 1) # only the discard pick - - -# ---------------------------------------------------------------------- -# 12. Gardenia's Vigor -# ---------------------------------------------------------------------- - -class TestGardeniasVigor(TrainerDeckTestBase): - async def test_draws_2_then_attaches_up_to_2_grass_energy(self): - for _ in range(2): - self.add_to("deck", make_card(BASIC_DEF), P1) - bench = self.add_to("bench", make_card(BASIC_DEF), P1) - grass = [self.add_to("hand", make_card(GRASS_ENERGY_DEF), P1) for _ in range(2)] - source = self.played_source(SUPPORTER_FILLER_DEF) - self.session.chooser_replies = [ - [bench.entity_id], [c.entity_id for c in grass] - ] - await trainers.gardenias_vigor(EffectContext(self.session, P1, source, None)) - self.assertEqual(len(self.board.find_player_area(P1, "hand").children), 2) - for energy in grass: - self.assertIn(energy, bench.children) - - async def test_no_draws_available_skips_attach_entirely(self): - bench = self.add_to("bench", make_card(BASIC_DEF), P1) - grass = self.add_to("hand", make_card(GRASS_ENERGY_DEF), P1) - source = self.played_source(SUPPORTER_FILLER_DEF) - await trainers.gardenias_vigor(EffectContext(self.session, P1, source, None)) - self.assertNotIn(grass, bench.children) - self.assertEqual(self.session.chooser_calls, []) - - -# ---------------------------------------------------------------------- -# 13. Kamado -# ---------------------------------------------------------------------- - -class TestKamado(TrainerDeckTestBase): - def test_condition_requires_another_card_in_hand(self): - source = self.add_to("hand", make_card(SUPPORTER_FILLER_DEF), P1) - board = self.board_for_hand_size(1) - self.assertFalse(trainers.hand_size_at_least(2)(board, P1)) - self.add_to("hand", make_card(BASIC_DEF), P1) - self.assertTrue(trainers.hand_size_at_least(2)(self.board, P1)) - - async def test_keeps_chosen_card_discards_rest_draws_4(self): - keep = self.add_to("hand", make_card(BASIC_DEF), P1) - discard_these = [self.add_to("hand", make_card(BASIC_DEF), P1) for _ in range(2)] - for _ in range(4): - self.add_to("deck", make_card(BASIC_DEF), P1) - source = self.add_to("hand", make_card(SUPPORTER_FILLER_DEF), P1) - self.session.chooser_replies = [[keep.entity_id]] - await trainers.kamado(EffectContext(self.session, P1, source, None)) - hand = self.board.find_player_area(P1, "hand") - self.assertIn(keep, hand.children) - for card in discard_these: - self.assertIn(card, self.board.find_player_area(P1, "discard").children) - self.assertEqual(len(hand.children), 5) # kept + 4 drawn - - @staticmethod - def board_for_hand_size(count): - from spirit.game.models.board import BoardState, create_card_entity - board = BoardState("wiring-test", [P1, P2]) - hand = board.find_player_area(P1, "hand") - for _ in range(count): - entity = create_card_entity(make_card(BASIC_DEF), owning_player_id=P1) - board.add_card_to_area(entity, hand) - return board - - -# ---------------------------------------------------------------------- -# 14. Zisu -# ---------------------------------------------------------------------- - -class TestZisu(TrainerDeckTestBase): - async def test_draws_until_1_more_than_opponent(self): - for _ in range(2): - self.add_to("hand", make_card(BASIC_DEF), P2) - for _ in range(5): - self.add_to("deck", make_card(BASIC_DEF), P1) - source = self.add_to("hand", make_card(SUPPORTER_FILLER_DEF), P1) - await trainers.zisu(EffectContext(self.session, P1, source, None)) - # 1 (source already removed via play_trainer path not used here, but - # ctx.hand() only sees what's actually in the hand area) + drawn to 3. - self.assertEqual(len(self.board.find_player_area(P1, "hand").children), - self.session.board_state.find_player_area(P2, "hand").children.__len__() + 1) - - -# ---------------------------------------------------------------------- -# 15. Pokemon Center Lady (unblocked by ctx.cure_all_conditions) -# ---------------------------------------------------------------------- - -class TestPokemonCenterLady(TrainerDeckTestBase): - async def test_heals_60_and_cures_all_conditions(self): - pokemon = self.add_to("activePokemonArea", make_card(PLAIN_MON_DEF), P1) - pokemon.set_attribute(AttrID.HP, 20) - pokemon.set_attribute(AttrID.SPECIAL_CONDITIONS, ["Poisoned", "Asleep"]) - self.session.sleep_checkup_coins[pokemon.entity_id] = 1 - self.session.poison_counters[pokemon.entity_id] = 2 - source = self.add_to("hand", make_card(SUPPORTER_FILLER_DEF), P1) - self.session.chooser_replies = [[pokemon.entity_id]] - await trainers.pokemon_center_lady(EffectContext(self.session, P1, source, None)) - self.assertEqual(pokemon.get_attribute(AttrID.HP), 80) - self.assertEqual(pokemon.get_attribute(AttrID.SPECIAL_CONDITIONS), []) - self.assertNotIn(pokemon.entity_id, self.session.sleep_checkup_coins) - self.assertNotIn(pokemon.entity_id, self.session.poison_counters) - - async def test_condition_requires_damage_or_conditions(self): - pokemon = self.add_to("activePokemonArea", make_card(PLAIN_MON_DEF), P1) - self.assertFalse(trainers.center_lady_playable(self.board, P1)) - pokemon.set_attribute(AttrID.HP, 90) - self.assertTrue(trainers.center_lady_playable(self.board, P1)) - - def test_reprints_wired(self): - for set_code, filename in [ - ("SWSH1", "PokmonCenterLady_176.py"), - ("SWSH35", "PokmonCenterLady_60.py"), - ("SWSH4", "PokmonCenterLady_185.py"), - ]: - card = load_script(set_code, filename) - self.assertIs(card.effect, trainers.pokemon_center_lady) - self.assertIs(card.condition, trainers.center_lady_playable) - - -# ---------------------------------------------------------------------- -# 16. Deferred cards: still effect=unimplemented (missing primitives) -# ---------------------------------------------------------------------- - -class TestDeferredCards(unittest.TestCase): - - def test_grant_reprints_stay_unimplemented(self): - from spirit.game.data_utils import unimplemented - for set_code, filename in [ - ("SWSH10", "Grant_144.py"), ("SWSH10", "Grant_185.py"), - ("SWSH10", "Grant_203.py"), - ]: - card = load_script(set_code, filename) - self.assertIs(card.effect, unimplemented) - - def test_choy_reprints_stay_unimplemented(self): - from spirit.game.data_utils import unimplemented - for set_code, filename in [ - ("SWSH10", "Choy_137.py"), ("SWSH10", "Choy_182.py"), - ("SWSH10", "Choy_200.py"), - ]: - card = load_script(set_code, filename) - self.assertIs(card.effect, unimplemented) - - -if __name__ == "__main__": - unittest.main() diff --git a/spirit/tests/test_starter_content.py b/spirit/tests/test_starter_content.py deleted file mode 100644 index 6cec5e0..0000000 --- a/spirit/tests/test_starter_content.py +++ /dev/null @@ -1,142 +0,0 @@ -import unittest -import os -import tempfile -from unittest.mock import patch - -from spirit.database import db_manager - - -class TestStarterContent(unittest.TestCase): - @classmethod - def setUpClass(cls): - cls.db_fd, cls.db_path = tempfile.mkstemp() - cls.db_patcher = patch('spirit.database.connection.DB_PATH', cls.db_path) - cls.db_patcher.start() - - from spirit.database.setup_db import setup_database - setup_database() - - @classmethod - def tearDownClass(cls): - cls.db_patcher.stop() - try: - os.close(cls.db_fd) - os.unlink(cls.db_path) - except OSError: - pass - - def test_eligible_booster_sets_excludes_placeholder_sets(self): - from spirit.game.set_utils import eligible_booster_sets, card_script_counts - - eligible = eligible_booster_sets() - counts = card_script_counts() - - self.assertNotIn("BW1", eligible) - self.assertNotIn("CUSTOM", eligible) - self.assertNotIn("Free_Energy", eligible) - self.assertIn("SWSH12", eligible) - for code in eligible: - self.assertGreater(counts[code], 10) - - def test_starter_decks_resolve_to_60_cards(self): - from spirit.game.starter_content import STARTER_DECKS, build_deck_data - - for deck_name, decklist in STARTER_DECKS: - deck_data = build_deck_data(deck_name, decklist) - self.assertEqual(deck_data["deckName"], deck_name) - self.assertEqual(len(deck_data["piles"]["deck"]), 60, - f"Deck '{deck_name}' did not resolve all 60 cards") - self.assertTrue(deck_data["deckID"]) - attr_names = {a["name"] for a in deck_data["attributes"]} - self.assertIn(200670, attr_names) # coin - self.assertIn(200680, attr_names) # sleeve - self.assertIn(200690, attr_names) # deck box - - def test_every_eligible_set_has_booster_product(self): - from spirit.game.set_utils import eligible_booster_sets - from spirit.game.starter_content import starter_booster_packs - - packs = starter_booster_packs() - pack_keys = {p.key.upper() for p in packs} - for code in eligible_booster_sets(): - self.assertIn(code.upper(), pack_keys, - f"No booster pack product for eligible set {code}") - - def test_create_account_grants_starter_content(self): - from spirit.database.accounts import create_account - from spirit.database.player_data import get_decks_by_account_id, get_collection_by_account_id - from spirit.game.starter_content import starter_booster_packs, STARTER_BOOSTER_PACK_COUNT - - account = create_account("starter_tester", "pw123") - self.assertIsNotNone(account) - account_id = account["account_id"] - - decks = get_decks_by_account_id(account_id) - deck_names = {d["name"] for d in decks} - self.assertIn("Lugia VSTAR", deck_names) - self.assertIn("Mew VMAX", deck_names) - self.assertIn("Lost Zone Box", deck_names) - self.assertIn("Regigigas", deck_names) - for d in decks: - self.assertEqual(len(d["deck_data"]["piles"]["deck"]), 60) - - collection = {c["archetype_id"]: c for c in get_collection_by_account_id(account_id)} - for pack in starter_booster_packs(): - self.assertIn(pack.guid, collection, f"Missing starter packs for {pack.key}") - self.assertEqual(collection[pack.guid]["nontradable_count"], STARTER_BOOSTER_PACK_COUNT) - - # Starter cosmetics (coin, sleeve, deck box) are granted - from spirit.game.starter_content import STARTER_COSMETICS - for cosmetic_guid in STARTER_COSMETICS: - self.assertIn(cosmetic_guid, collection, f"Missing starter cosmetic {cosmetic_guid}") - - # Deck cards were added to the collection too - lugia = next(d for d in decks if d["name"] == "Lugia VSTAR") - for guid in set(lugia["deck_data"]["piles"]["deck"]): - self.assertIn(guid, collection) - - def test_new_account_starting_wallet(self): - from spirit.database.accounts import create_account - from spirit.database.player_data import get_wallet_by_account_id - - account = create_account("wallet_tester", "pw123") - wallet = get_wallet_by_account_id(account["account_id"]) - self.assertEqual(wallet["coins"], 1000) - self.assertEqual(wallet["gems"], 0) - self.assertEqual(wallet["tickets"], 100) - - def test_account_does_not_own_every_card(self): - """The account only owns starter-deck cards + packs, not the whole card pool.""" - from spirit.database.accounts import create_account - from spirit.database.player_data import ( - get_merged_collection_payload, get_decks_by_account_id) - from spirit.game.scripts.cards import loader as card_loader - - account = create_account("scoping_tester", "pw123") - account_id = account["account_id"] - - payload = get_merged_collection_payload(account_id) - owned = {c["archetypeID"] for c in payload} - - # Every non-free card in the payload must come from a starter deck or a pack - free_energy = {c.guid.lower() for c in card_loader.load_all() - if c.key == "Free_Energy"} - deck_guids = set() - for d in get_decks_by_account_id(account_id): - deck_guids.update(g.lower() for g in d["deck_data"]["piles"]["deck"]) - - # The full card pool is far larger than what a new account should own - all_cards = {c.guid.lower() for c in card_loader.load_all()} - self.assertLess(len(owned), len(all_cards), - "New account should not own the entire card pool") - - for guid in owned: - if guid in free_energy or guid in deck_guids: - continue - # Anything else must be an owned product (booster pack), never a bulk card - self.assertNotIn(guid, all_cards, - f"Owns unexpected card not from a starter deck: {guid}") - - -if __name__ == '__main__': - unittest.main() diff --git a/spirit/tests/test_system.py b/spirit/tests/test_system.py deleted file mode 100644 index 95bba2b..0000000 --- a/spirit/tests/test_system.py +++ /dev/null @@ -1,117 +0,0 @@ -import unittest -import os -from unittest.mock import MagicMock -from spirit.packets.handlers.system import SystemHandler -from spirit.network.message_names import InboundMsg - -class MockClientHandler: - def __init__(self): - self.addr = ("127.0.0.1", 54321) - self.sent_packets = [] - - async def send_packet(self, data, request_id=0, flags=0): - self.sent_packets.append(data) - -class TestSystemHandler(unittest.IsolatedAsyncioTestCase): - def setUp(self): - self.log_file = "client_errors.log" - if os.path.exists(self.log_file): - os.remove(self.log_file) - - def tearDown(self): - if os.path.exists(self.log_file): - try: - os.remove(self.log_file) - except OSError: - pass - - async def test_log_client_error_writes_to_file(self): - client = MockClientHandler() - handler = SystemHandler(client) - - payload = { - "name": "NullReferenceException", - "reason": "Object reference not set to an instance of an object at Playmat.Start()", - "logID": "test-log-123", - "debugInfo": { - "UnityVersion": "2019.4.28f1", - "Scene": "Playmat" - } - } - - # Handle message - await handler.handle_log_client_error(payload, request_id=45, flags=0) - - # Assert log file exists - self.assertTrue(os.path.exists(self.log_file)) - - # Check content - with open(self.log_file, "r", encoding="utf-8") as f: - content = f.read() - - self.assertIn("NullReferenceException", content) - self.assertIn("Object reference not set to an instance of an object at Playmat.Start()", content) - self.assertIn("test-log-123", content) - self.assertIn("UnityVersion: 2019.4.28f1", content) - self.assertIn("Scene: Playmat", content) - - async def test_benign_serialized_state_error_downranked_not_errorlevel(self): - """The known-benign 'game is in progress' client error is logged at INFO (not ERROR) - but is still persisted to client_errors.log.""" - client = MockClientHandler() - handler = SystemHandler(client) - - payload = { - "name": "ClientError", - "reason": "InvalidOperationException: Serialized game state can't be loaded while a game is in progress!", - "logID": "benign-log-1", - "debugInfo": {"Scene": "Playmat"}, - } - - with self.assertLogs(level="INFO") as captured: - await handler.handle_log_client_error(payload, request_id=1, flags=0) - - # Down-ranked: an INFO 'Benign client error' line, and NO ERROR-level record. - joined = "\n".join(captured.output) - self.assertIn("Benign client error", joined) - self.assertFalse( - any(rec.levelname == "ERROR" for rec in captured.records), - "Benign client error should not be logged at ERROR level", - ) - - # Still recorded to file for the audit trail. - self.assertTrue(os.path.exists(self.log_file)) - with open(self.log_file, "r", encoding="utf-8") as f: - content = f.read() - self.assertIn("benign-log-1", content) - self.assertIn("game is in progress", content) - - async def test_benign_render_texture_error_downranked_not_errorlevel(self): - """The known-benign 'Releasing render texture' client warning (stock client bug in - AvatarCamRenderTextureController teardown) is logged at INFO (not ERROR) but is - still persisted to client_errors.log.""" - client = MockClientHandler() - handler = SystemHandler(client) - - payload = { - "name": "ClientError", - "reason": "Releasing render texture that is set as Camera.targetTexture!\n", - "logID": "benign-log-2", - "debugInfo": {"AdditionalInfo": "Active Scene: VersusScreen"}, - } - - with self.assertLogs(level="INFO") as captured: - await handler.handle_log_client_error(payload, request_id=2, flags=0) - - joined = "\n".join(captured.output) - self.assertIn("Benign client error", joined) - self.assertFalse( - any(rec.levelname == "ERROR" for rec in captured.records), - "Benign client error should not be logged at ERROR level", - ) - - self.assertTrue(os.path.exists(self.log_file)) - with open(self.log_file, "r", encoding="utf-8") as f: - content = f.read() - self.assertIn("benign-log-2", content) - self.assertIn("Releasing render texture", content) diff --git a/spirit/tests/test_tournaments.py b/spirit/tests/test_tournaments.py deleted file mode 100644 index ff36d87..0000000 --- a/spirit/tests/test_tournaments.py +++ /dev/null @@ -1,738 +0,0 @@ -import asyncio -import os -import tempfile -import unittest -import uuid -from unittest.mock import patch - -DAY_MS = 86400000 -FAR = 4102444800000 - - -def make_definition(**overrides): - d = { - "name": "Test Cup", "title": "Test Cup", "description": "A test tournament", - "previewTime": 1, "startTime": 2, "entryClosingTime": FAR, - "resolutionTime": FAR + 1, "disappearTime": FAR + 2, - "maxRuns": 2, "prizeBy": "wins", - "run": { - "entryFee": [{"currency": "Tokens", "amount": 100}], - "allowDeckSwitching": True, - "maxWins": 3, "maxLosses": 2, "maxGames": 0, - "prizeTable": [ - {"start": 0, "end": 2, "rewards": [ - {"rewardType": "Tokens", "rewardAmount": 50}]}, - {"start": 3, "end": 3, "rewards": [ - {"rewardType": "Tokens", "rewardAmount": 500}, - {"rewardType": "Archetype", "rewardAmount": 1, - "rewardProductID": "aaaa-bbbb-cccc"}]}, - ], - }, - "leaderboard": { - "runs": 0, "winValue": 3, "lossValue": 1, - "prizeTable": [ - {"start": 1, "end": 1, "rewards": [ - {"rewardType": "Tokens", "rewardAmount": 1000}]}, - ], - }, - } - d.update(overrides) - return d - - -class TournamentDBTestCase(unittest.TestCase): - @classmethod - def setUpClass(cls): - cls.db_fd, cls.db_path = tempfile.mkstemp() - cls.db_patcher = patch('spirit.database.connection.DB_PATH', cls.db_path) - cls.db_patcher.start() - from spirit.database.setup_db import setup_database - setup_database() - - @classmethod - def tearDownClass(cls): - cls.db_patcher.stop() - try: - os.close(cls.db_fd) - os.unlink(cls.db_path) - except OSError: - pass - - def _new_account(self, name=None): - from spirit.database.accounts import create_account - name = name or f"tour_{uuid.uuid4().hex[:10]}" - return create_account(name, "pw123")["account_id"] - - def _new_tournament(self, **overrides): - from spirit.database import tournament_data - return tournament_data.upsert_tournament(make_definition(**overrides)) - - -class TestTournamentData(TournamentDBTestCase): - def test_join_deducts_fee_and_enforces_limits(self): - from spirit.database import tournament_data - from spirit.database.player_data import get_wallet_by_account_id - - t = self._new_tournament() - account_id = self._new_account() - coins_before = get_wallet_by_account_id(account_id)["coins"] - - entry, err = tournament_data.create_entry( - account_id, t["tournament_id"], t["definition"], "Tokens", {}) - self.assertIsNone(err) - self.assertEqual(get_wallet_by_account_id(account_id)["coins"], coins_before - 100) - - # one active run at a time - _, err = tournament_data.create_entry( - account_id, t["tournament_id"], t["definition"], "Tokens", {}) - self.assertIn("active run", err) - - # bad currency rejected - other = self._new_account() - _, err = tournament_data.create_entry( - other, t["tournament_id"], t["definition"], "Gems", {}) - self.assertIn("currency", err.lower()) - - def test_join_rejects_insufficient_funds(self): - from spirit.database import tournament_data - from spirit.database.player_data import get_wallet_by_account_id, update_wallet - - t = self._new_tournament() - account_id = self._new_account() - update_wallet(account_id, 5, 0, 0) - _, err = tournament_data.create_entry( - account_id, t["tournament_id"], t["definition"], "Tokens", {}) - self.assertIn("afford", err) - self.assertEqual(get_wallet_by_account_id(account_id)["coins"], 5) - - def test_max_runs_enforced(self): - from spirit.database import tournament_data - - t = self._new_tournament() - account_id = self._new_account() - for _ in range(2): # maxRuns = 2 - entry, err = tournament_data.create_entry( - account_id, t["tournament_id"], t["definition"], "Tokens", {}) - self.assertIsNone(err) - tournament_data.finish_entry( - entry["entry_id"], account_id, t["definition"], resigned=True) - _, err = tournament_data.create_entry( - account_id, t["tournament_id"], t["definition"], "Tokens", {}) - self.assertIn("runs remaining", err) - - def test_run_completion_and_prize_grant(self): - from spirit.database import tournament_data - from spirit.database.player_data import get_wallet_by_account_id, get_collection_by_account_id - - t = self._new_tournament() - run = t["definition"]["run"] - account_id = self._new_account() - entry, _ = tournament_data.create_entry( - account_id, t["tournament_id"], t["definition"], "Tokens", {}) - entry_id = entry["entry_id"] - - # two wins: not complete yet - for _ in range(2): - result = tournament_data.record_game_result( - entry_id, True, "opp-1", "Opponent", run) - self.assertFalse(result["run_complete"]) - # third win caps the run (maxWins=3) - result = tournament_data.record_game_result( - entry_id, True, "opp-1", "Opponent", run) - self.assertTrue(result["run_complete"]) - self.assertEqual(len(result["history"]), 3) - self.assertEqual(result["history"][0]["gameResult"], "Win") - - coins_before = get_wallet_by_account_id(account_id)["coins"] - finished, granted = tournament_data.finish_entry( - entry_id, account_id, t["definition"]) - self.assertEqual(finished["status"], "complete") - self.assertEqual(len(granted), 2) # 3-wins row: 500 tokens + card - self.assertEqual(get_wallet_by_account_id(account_id)["coins"], coins_before + 500) - collection = {c["archetype_id"]: c for c in get_collection_by_account_id(account_id)} - self.assertEqual(collection["aaaa-bbbb-cccc"]["nontradable_count"], 1) - - # idempotent: second finish grants nothing - _, granted = tournament_data.finish_entry( - entry_id, account_id, t["definition"]) - self.assertEqual(granted, []) - self.assertEqual(get_wallet_by_account_id(account_id)["coins"], coins_before + 500) - - def test_resign_grants_current_row(self): - from spirit.database import tournament_data - from spirit.database.player_data import get_wallet_by_account_id - - t = self._new_tournament() - account_id = self._new_account() - entry, _ = tournament_data.create_entry( - account_id, t["tournament_id"], t["definition"], "Tokens", {}) - tournament_data.record_game_result( - entry["entry_id"], True, "opp", "Opp", t["definition"]["run"]) - - coins_before = get_wallet_by_account_id(account_id)["coins"] - finished, granted = tournament_data.finish_entry( - entry["entry_id"], account_id, t["definition"], resigned=True) - self.assertEqual(finished["status"], "resigned") - self.assertEqual(granted[0]["rewardAmount"], 50) # 0-2 wins row - self.assertEqual(get_wallet_by_account_id(account_id)["coins"], coins_before + 50) - - def test_standings_and_leaderboard_claim(self): - from spirit.database import tournament_data - - t = self._new_tournament() - run = t["definition"]["run"] - winner = self._new_account() - loser = self._new_account() - e1, _ = tournament_data.create_entry(winner, t["tournament_id"], t["definition"], "Tokens", {}) - e2, _ = tournament_data.create_entry(loser, t["tournament_id"], t["definition"], "Tokens", {}) - tournament_data.record_game_result(e1["entry_id"], True, loser, "Loser", run) - tournament_data.record_game_result(e2["entry_id"], False, winner, "Winner", run) - - standings = tournament_data.leaderboard_standings( - t["tournament_id"], t["definition"]) - self.assertEqual(standings[0]["accountID"], winner) - self.assertEqual(standings[0]["points"], 3.0) # 1 win * winValue 3 - self.assertEqual(standings[0]["rank"], 1) - self.assertEqual(standings[1]["accountID"], loser) - self.assertEqual(standings[1]["points"], 1.0) # 1 loss * lossValue 1 - - rank, granted = tournament_data.claim_leaderboard_reward( - winner, t["tournament_id"], t["definition"], standings) - self.assertEqual(rank, 1) - self.assertEqual(granted[0]["rewardAmount"], 1000) - self.assertTrue(tournament_data.has_claimed_leaderboard(winner, t["tournament_id"])) - - # second claim rejected - rank, err = tournament_data.claim_leaderboard_reward( - winner, t["tournament_id"], t["definition"], standings) - self.assertIsNone(rank) - self.assertIn("already claimed", err) - - # rank 2 has no prize row -> empty grant but claim recorded - rank, granted = tournament_data.claim_leaderboard_reward( - loser, t["tournament_id"], t["definition"], standings) - self.assertEqual(rank, 2) - self.assertEqual(granted, []) - - -class TestTournamentManager(TournamentDBTestCase): - def test_wire_shape_and_states(self): - from spirit.game.tournament_manager import ( - TournamentDef, STATE_OPEN, STATE_PREVIEW, STATE_ENTRY_CLOSED, - STATE_RESOLVED, STATE_HIDDEN, - ) - now = 1000000 - d = TournamentDef("tid-1", make_definition( - previewTime=now - 10, startTime=now - 5, - entryClosingTime=now + 10, resolutionTime=now + 20, - disappearTime=now + 30), True) - self.assertEqual(d.state(now), STATE_OPEN) - self.assertEqual(d.state(now - 7), STATE_PREVIEW) - self.assertEqual(d.state(now + 15), STATE_ENTRY_CLOSED) - self.assertEqual(d.state(now + 25), STATE_RESOLVED) - self.assertEqual(d.state(now + 35), STATE_HIDDEN) - - wire = d.to_client_dict() - self.assertEqual(wire["id"], "tid-1") - self.assertEqual(wire["title"], {"id": "Test Cup"}) - self.assertEqual(wire["run"]["maxWins"], 3) - self.assertEqual(wire["run"]["entryFee"], [{"currency": "Tokens", "amount": 100}]) - # every reward must hint the only registered RewardDefinition subclass - for prize in wire["run"]["prizeTable"] + wire["leaderboard"]["prizeTable"]: - for reward in prize["rewards"]: - self.assertEqual(reward["name"], "NoReward") - self.assertIn("rewardType", reward) - self.assertIn("rewardAmount", reward) - self.assertIn("flavor", reward) - self.assertNotIn("rewardProductID", reward) - self.assertIsNone(wire["limited"]) - self.assertIsNone(wire["league"]) - - def test_validate_definition(self): - from spirit.game.tournament_manager import validate_definition - self.assertIsNone(validate_definition(make_definition())) - self.assertIsNotNone(validate_definition({"name": ""})) - bad = make_definition() - bad["run"] = {"maxWins": 0, "maxLosses": 0, "maxGames": 0} - self.assertIsNotNone(validate_definition(bad)) - bad = make_definition() - bad["run"]["entryFee"] = [{"currency": "Doubloons", "amount": 5}] - self.assertIsNotNone(validate_definition(bad)) - bad = make_definition() - bad["run"]["prizeTable"][0]["rewards"] = [{"rewardType": "Archetype"}] - self.assertIsNotNone(validate_definition(bad)) - self.assertIsNotNone(validate_definition(make_definition(maxSize=5))) - self.assertIsNotNone( - validate_definition(make_definition(matchStructure="Swiss"))) - self.assertIsNone(validate_definition(make_definition(maxSize=8))) - - def test_manager_reload_and_visibility(self): - from spirit.database import tournament_data - from spirit.game.tournament_manager import TournamentManager - - t = self._new_tournament() - manager = TournamentManager() - manager.reload_from_db() - self.assertIsNotNone(manager.get(t["tournament_id"])) - self.assertIn(t["tournament_id"], - [x.tournament_id for x in manager.visible_tournaments()]) - - tournament_data.upsert_tournament( - definition=None, tournament_id=t["tournament_id"], enabled=False) - manager.reload_from_db() - self.assertNotIn(t["tournament_id"], - [x.tournament_id for x in manager.visible_tournaments()]) - - -class MockPlayer: - def __init__(self, account_id, username): - self.account_id = account_id - self.username = username - self.decks = [] - - def get_wallet_data(self): - from spirit.database.player_data import get_wallet_by_account_id - return dict(get_wallet_by_account_id(self.account_id) or {}) - - -class MockServer: - def __init__(self): - self.clients = [] - - -class MockClientHandler: - def __init__(self, account_id, username): - self.player = MockPlayer(account_id, username) - self.sent_packets = [] - self.addr = ("127.0.0.1", 4242) - self.server = MockServer() - self.server.clients.append(self) - - async def send_packet(self, response_body, request_id, flags=0): - self.sent_packets.append(response_body) - - -class TestTournamentHandler(TournamentDBTestCase, unittest.IsolatedAsyncioTestCase): - def _handler(self, account_id): - from spirit.packets.handlers.tournaments import TournamentHandler - client = MockClientHandler(account_id, "tester") - return TournamentHandler(client), client - - def _sync_manager(self): - from spirit.game.tournament_manager import TournamentManager - TournamentManager().reload_from_db() - - async def test_get_active_async_tournaments(self): - t = self._new_tournament() - self._sync_manager() - account_id = self._new_account() - handler, client = self._handler(account_id) - - await handler.handle_get_active_async_tournaments({}, 7, 0) - msg = client.sent_packets[-1] - self.assertEqual(msg["messageName"], "ActiveAsyncTournaments") - ids = [d["id"] for d in msg["tournamentDefinitions"]] - self.assertIn(t["tournament_id"], ids) - self.assertEqual(msg["tournamentProgress"], []) - self.assertEqual(msg["claimedLeaderboard"], {}) - - async def test_legacy_events_scene_list(self): - t = self._new_tournament() - self._sync_manager() - handler, client = self._handler(self._new_account()) - await handler.handle_get_active_tournaments_legacy({}, 3, 0) - msg = client.sent_packets[-1] - self.assertEqual(msg["messageName"], "AvailableTournamentList") - # null tournamentQueues NREs the client handler — must be a dict - self.assertIsInstance(msg["tournamentQueues"], dict) - entry = next(e for e in msg["tournamentList"] - if e["tournamentID"] == t["tournament_id"]) - # entryFee/prizes must be non-null arrays (three renderers deref them) - self.assertEqual(entry["entryFee"], [{"feeType": "Tokens", "feeAmount": 100}]) - self.assertTrue(entry["active"]) - self.assertEqual(entry["maxSize"], 8) - self.assertEqual(entry["matchStructure"], "SingleElimination") - prize_types = {p["prizeType"]["type"] for p in entry["prizes"]} - self.assertEqual(prize_types, {"Tokens", "Archetype"}) - for p in entry["prizes"]: - self.assertIn("startPlace", p) - self.assertIn("endPlace", p) - - await handler.handle_subscribe_to_tournament_channel({}, 4, 0) - msg = client.sent_packets[-1] - self.assertEqual(msg["messageName"], "SubscribeToTournamentChannelSuccessful") - self.assertIn(t["tournament_id"].lower(), msg["tournamentQueues"]) - - async def test_join_flow_and_errors(self): - t = self._new_tournament() - self._sync_manager() - account_id = self._new_account() - handler, client = self._handler(account_id) - deck_id = str(uuid.uuid4()) - client.player.decks = [{ - "id": deck_id, "name": "My Deck", - "deck_data": {"cards": [{"guid": "g-1", "count": 2}]} - }] - - await handler.handle_join_async_tournament( - {"tournamentID": t["tournament_id"], "currency": "Tokens", - "deckID": deck_id}, 5, 0) - joined = client.sent_packets[0] - self.assertEqual(joined["messageName"], "AsyncTournamentJoined") - self.assertEqual(joined["tournamentID"], t["tournament_id"]) - self.assertEqual(joined["deck"]["piles"]["deck"], ["g-1", "g-1"]) - self.assertEqual(joined["progress"]["wins"], 0) - # wallet push follows the join - self.assertEqual(client.sent_packets[1]["messageName"], "CurrentWallet") - - # progress now rides the listing - await handler.handle_get_active_async_tournaments({}, 8, 0) - listing = client.sent_packets[-1] - self.assertEqual(len(listing["tournamentProgress"]), 1) - self.assertEqual(listing["tournamentProgress"][0]["entryID"], joined["entryID"]) - - # double-join rejected - await handler.handle_join_async_tournament( - {"tournamentID": t["tournament_id"], "currency": "Tokens"}, 6, 0) - error = client.sent_packets[-1] - self.assertEqual(error["messageName"], "JoinAsyncTournamentError") - self.assertIn("active run", error["error"]["id"]) - - # unknown tournament rejected - await handler.handle_join_async_tournament( - {"tournamentID": str(uuid.uuid4()), "currency": "Tokens"}, 7, 0) - self.assertEqual(client.sent_packets[-1]["messageName"], "JoinAsyncTournamentError") - - async def test_join_rejected_before_open(self): - t = self._new_tournament(startTime=FAR - 10, entryClosingTime=FAR - 5) - self._sync_manager() - handler, client = self._handler(self._new_account()) - await handler.handle_join_async_tournament( - {"tournamentID": t["tournament_id"], "currency": "Tokens"}, 1, 0) - error = client.sent_packets[-1] - self.assertEqual(error["messageName"], "JoinAsyncTournamentError") - self.assertIn("not open", error["error"]["id"]) - - async def test_number_of_runs_and_history(self): - from spirit.database import tournament_data - t = self._new_tournament() - self._sync_manager() - account_id = self._new_account() - entry, _ = tournament_data.create_entry( - account_id, t["tournament_id"], t["definition"], "Tokens", {}) - tournament_data.record_game_result( - entry["entry_id"], True, "opp", "Rival", t["definition"]["run"]) - handler, client = self._handler(account_id) - - await handler.handle_get_number_of_player_runs( - {"tournamentID": t["tournament_id"]}, 2, 0) - self.assertEqual(client.sent_packets[-1]["runs"], 1) - - await handler.handle_get_async_tournament_game_history( - {"tournamentID": t["tournament_id"], "entryID": entry["entry_id"]}, 3, 0) - history = client.sent_packets[-1] - self.assertEqual(history["messageName"], "AsyncTournamentGameHistoryList") - self.assertEqual(history["games"][0]["gameResult"], "Win") - self.assertEqual(history["games"][0]["opponentName"], "Rival") - - async def test_start_game_queues_with_tournament_context(self): - from spirit.database import tournament_data - from spirit.game.session.manager import GameSessionManager - t = self._new_tournament() - self._sync_manager() - account_id = self._new_account() - deck_json = {"deckID": "d", "deckName": "D", "piles": {"deck": ["g-1"] * 60}, - "attributes": []} - entry, _ = tournament_data.create_entry( - account_id, t["tournament_id"], t["definition"], "Tokens", deck_json) - handler, client = self._handler(account_id) - - manager = GameSessionManager() - manager.queues = {} - manager.pending_pairings = {} - manager.active_sessions = {} - - await handler.handle_start_async_tournament_game( - {"entryID": entry["entry_id"]}, 9, 0) - queue_name = f"AsyncTournament_{t['tournament_id']}" - self.assertIn(queue_name, manager.queues) - queued = manager.queues[queue_name][0] - self.assertEqual(queued["tournament_context"]["entry_id"], entry["entry_id"]) - self.assertEqual( - client.sent_packets[-1]["messageName"], "MatchQueueEntered") - manager.queues = {} - - async def test_start_game_rejects_finished_run(self): - from spirit.database import tournament_data - t = self._new_tournament() - self._sync_manager() - account_id = self._new_account() - entry, _ = tournament_data.create_entry( - account_id, t["tournament_id"], t["definition"], "Tokens", {}) - tournament_data.finish_entry( - entry["entry_id"], account_id, t["definition"], resigned=True) - handler, client = self._handler(account_id) - await handler.handle_start_async_tournament_game( - {"entryID": entry["entry_id"]}, 1, 0) - error = client.sent_packets[-1] - self.assertEqual(error["messageName"], "StartAsyncTournamentGameError") - - async def test_tournament_pairing_carries_context(self): - from spirit.game.session.manager import GameSessionManager - manager = GameSessionManager() - manager.queues = {} - manager.pending_pairings = {} - manager.auto_confirm_ready = False - - c1 = MockClientHandler("acc-1", "P1") - c2 = MockClientHandler("acc-2", "P2") - await manager.add_to_queue( - c1, "AsyncTournament_t1", {"piles": {"deck": []}}, {}, 0, - tournament_context={"tournament_id": "t1", "entry_id": "e1"}) - await manager.add_to_queue( - c2, "AsyncTournament_t1", {"piles": {"deck": []}}, {}, 0, - tournament_context={"tournament_id": "t1", "entry_id": "e2"}) - pairing = list(manager.pending_pairings.values())[0] - self.assertEqual(pairing["tournament"]["tournament_id"], "t1") - self.assertEqual(pairing["tournament"]["entries"], - {"acc-2": "e2", "acc-1": "e1"}) - manager.pending_pairings = {} - manager.auto_confirm_ready = True - - -class TestLiveTournament(TournamentDBTestCase, unittest.IsolatedAsyncioTestCase): - def setUp(self): - super().setUp() - from spirit.game.live_tournament import LiveTournamentManager - LiveTournamentManager._instance = None - - def _sync_manager(self): - from spirit.game.tournament_manager import TournamentManager - TournamentManager().reload_from_db() - - def _handler_for(self, username): - from spirit.packets.handlers.tournaments import TournamentHandler - account_id = self._new_account() - client = MockClientHandler(account_id, username) - deck_id = str(uuid.uuid4()) - client.player.decks = [{"id": deck_id, "name": "Deck", - "deck_data": {"piles": {"deck": ["g-1"] * 60}}}] - return TournamentHandler(client), client, deck_id - - async def test_join_insufficient_funds(self): - from spirit.database.player_data import update_wallet - t = self._new_tournament() - self._sync_manager() - handler, client, deck_id = self._handler_for("Poor") - update_wallet(client.player.account_id, 5, 0, 0) - await handler.handle_join_tournament( - {"tournamentID": t["tournament_id"], "deck": deck_id}, 1, 0) - failed = next(p for p in client.sent_packets - if p["messageName"] == "JoinTournamentFailed") - # WalletFailed drives the client's local refund; wallet push resyncs - self.assertEqual(failed["reason"]["id"], "event.join.error.WalletFailed") - self.assertEqual(client.sent_packets[-1]["messageName"], "CurrentWallet") - - async def test_leave_queue_refunds_fee(self): - from spirit.database.player_data import get_wallet_by_account_id - t = self._new_tournament() - self._sync_manager() - handler, client, deck_id = self._handler_for("Leaver") - account_id = client.player.account_id - start_coins = get_wallet_by_account_id(account_id)["coins"] - - await handler.handle_join_tournament( - {"tournamentID": t["tournament_id"], "deck": deck_id}, 1, 0) - names = [p["messageName"] for p in client.sent_packets] - self.assertIn("TournamentQueueJoined", names) - self.assertEqual(get_wallet_by_account_id(account_id)["coins"], - start_coins - 100) - - await handler.handle_leave_tournament_queue( - {"tournamentID": t["tournament_id"]}, 2, 0) - names = [p["messageName"] for p in client.sent_packets] - self.assertIn("TournamentQueueLeft", names) - self.assertEqual(get_wallet_by_account_id(account_id)["coins"], start_coins) - - # leaving again fails - await handler.handle_leave_tournament_queue( - {"tournamentID": t["tournament_id"]}, 3, 0) - self.assertEqual(client.sent_packets[-1]["messageName"], - "TournamentQueueLeftFailed") - - async def test_two_player_bracket_end_to_end(self): - from spirit.game.session.manager import GameSessionManager - from spirit.game.live_tournament import LiveTournamentManager - from spirit.database.player_data import get_wallet_by_account_id - - run = { - "entryFee": [{"currency": "Tokens", "amount": 100}], - "allowDeckSwitching": True, - "maxWins": 3, "maxLosses": 2, "maxGames": 0, - "prizeTable": [ - {"start": 1, "end": 1, "rewards": [ - {"rewardType": "Tokens", "rewardAmount": 500}]}, - {"start": 2, "end": 2, "rewards": [ - {"rewardType": "Tokens", "rewardAmount": 50}]}, - ], - } - t = self._new_tournament(maxSize=2, run=run) - self._sync_manager() - h1, c1, d1 = self._handler_for("Alice") - h2, c2, d2 = self._handler_for("Bob") - - manager = GameSessionManager() - manager.queues = {} - dispatched = [] - with patch.object(GameSessionManager, "_dispatch_ready_check", - lambda self, gid, qn, clients, delay=1.5: dispatched.append(gid)): - await h1.handle_join_tournament( - {"tournamentID": t["tournament_id"], "deck": d1}, 1, 0) - await h2.handle_join_tournament( - {"tournamentID": t["tournament_id"], "deck": d2}, 2, 0) - - for client in (c1, c2): - names = [p["messageName"] for p in client.sent_packets] - self.assertIn("TournamentQueueJoined", names) - self.assertIn("TournamentStarted", names) - self.assertIn("TournamentRoundUpdated", names) - - started = next(p for p in c1.sent_packets - if p["messageName"] == "TournamentStarted") - self.assertEqual(started["size"], 2) - - round_upd = next(p for p in c1.sent_packets - if p["messageName"] == "TournamentRoundUpdated") - progress = round_upd["tournamentData"] - self.assertIsNotNone(progress["tournamentData"]) # nested G.g NRE trap - matchup = progress["matchups"][0] - self.assertEqual(len(matchup["players"]), 2) # G.j NRE trap - self.assertIsNone(matchup["winner"]) - self.assertEqual(matchup["round"], 1) - self.assertEqual(matchup["table"], 0) - - # the bracket game rides the normal pairing pipeline with legacy context - game_id = matchup["gameID"] - self.assertIn(game_id, dispatched) - pairing = manager.pending_pairings.pop(game_id) - self.assertEqual(pairing["legacy_tournament"]["tournament_id"], - t["tournament_id"]) - active_id = pairing["legacy_tournament"]["active_id"] - self.assertEqual(active_id, round_upd["activeTournamentID"]) - - # reconnect answer while the bracket runs - await h1.handle_is_user_in_active_tournament({}, 5, 0) - self.assertEqual(c1.sent_packets[-1]["activeTournamentID"], active_id) - await h1.handle_get_tournament_in_progress( - {"activeTournamentID": active_id}, 6, 0) - in_progress = c1.sent_packets[-1] - self.assertEqual(in_progress["messageName"], "TournamentsInProgressData") - self.assertEqual(len(in_progress["tournamentData"]), 1) - - live = LiveTournamentManager() - winner_id = c1.player.account_id - loser_id = c2.player.account_id - coins_before = get_wallet_by_account_id(winner_id)["coins"] - await live.record_game_result(active_id, game_id, winner_id) - - completed = next(p for p in c1.sent_packets - if p["messageName"] == "TournamentCompleted") - self.assertEqual(completed["finalStandings"][0]["accountID"], winner_id) - self.assertEqual(completed["finalStandings"][1]["accountID"], loser_id) - self.assertEqual(completed["prizes"][0]["rewardAmount"], 500) - self.assertIsNone(completed["prizes"][0]["rewardProductID"]) - self.assertEqual( - completed["tournamentData"]["matchups"][0]["winner"]["accountID"], - winner_id) - loser_completed = next(p for p in c2.sent_packets - if p["messageName"] == "TournamentCompleted") - self.assertEqual(loser_completed["prizes"][0]["rewardAmount"], 50) - self.assertEqual(get_wallet_by_account_id(winner_id)["coins"], - coins_before + 500) - self.assertEqual(live.active, {}) - - async def test_four_player_bracket_advances_rounds(self): - from spirit.game.session.manager import GameSessionManager - from spirit.game.live_tournament import LiveTournamentManager - import spirit.game.live_tournament as lt_module - - t = self._new_tournament(maxSize=4, delayBetweenRounds=3) - self._sync_manager() - players = [self._handler_for(f"P{i}") for i in range(4)] - - manager = GameSessionManager() - manager.queues = {} - real_sleep = asyncio.sleep - async def _no_sleep(_secs): - await real_sleep(0) - with patch.object(GameSessionManager, "_dispatch_ready_check", - lambda self, gid, qn, clients, delay=1.5: None), \ - patch.object(lt_module.asyncio, "sleep", _no_sleep): - for i, (handler, _, deck_id) in enumerate(players): - await handler.handle_join_tournament( - {"tournamentID": t["tournament_id"], "deck": deck_id}, i, 0) - - live = LiveTournamentManager() - self.assertEqual(len(live.active), 1) - tournament = list(live.active.values())[0] - round1 = [m for m in tournament.matchups if m.round == 1] - self.assertEqual(len(round1), 2) - self.assertEqual([m.table for m in round1], [0, 1]) - - # resolve round 1: table winners advance - w1 = round1[0].players[0] - w2 = round1[1].players[1] - await live.record_game_result( - tournament.active_id, round1[0].game_id, w1.account_id) - names = [p["messageName"] for p in players[0][1].sent_packets] - self.assertNotIn("TournamentNextRoundStarting", names) - await live.record_game_result( - tournament.active_id, round1[1].game_id, w2.account_id) - for _ in range(3): # let the spawned next-round task run - await asyncio.sleep(0) - - # next round paired from the table-ordered winners (sleep patched out) - round2 = [m for m in tournament.matchups if m.round == 2] - self.assertEqual(len(round2), 1) - self.assertEqual({p.account_id for p in round2[0].players}, - {w1.account_id, w2.account_id}) - names = [p["messageName"] for p in players[0][1].sent_packets] - self.assertIn("TournamentNextRoundStarting", names) - - await live.record_game_result( - tournament.active_id, round2[0].game_id, w2.account_id) - self.assertTrue(tournament.completed) - standings = tournament.final_standings() - self.assertEqual(standings[0].account_id, w2.account_id) - self.assertEqual(standings[1].account_id, w1.account_id) - - async def test_withdraw_forfeits_pending_matchup(self): - from spirit.game.session.manager import GameSessionManager - from spirit.game.live_tournament import LiveTournamentManager - - t = self._new_tournament(maxSize=2) - self._sync_manager() - h1, c1, d1 = self._handler_for("Stay") - h2, c2, d2 = self._handler_for("Quit") - manager = GameSessionManager() - manager.queues = {} - with patch.object(GameSessionManager, "_dispatch_ready_check", - lambda self, gid, qn, clients, delay=1.5: None): - await h1.handle_join_tournament( - {"tournamentID": t["tournament_id"], "deck": d1}, 1, 0) - await h2.handle_join_tournament( - {"tournamentID": t["tournament_id"], "deck": d2}, 2, 0) - live = LiveTournamentManager() - tournament = list(live.active.values())[0] - await h2.handle_leave_active_tournament( - {"activeTournamentID": tournament.active_id, - "tournamentID": t["tournament_id"]}, 3, 0) - self.assertEqual(c2.sent_packets[-1]["messageName"], "TournamentLeft") - completed = next(p for p in c1.sent_packets - if p["messageName"] == "TournamentCompleted") - self.assertEqual(completed["finalStandings"][0]["accountID"], - c1.player.account_id) - - -if __name__ == "__main__": - unittest.main() diff --git a/spirit/tests/test_trade.py b/spirit/tests/test_trade.py deleted file mode 100644 index 30e17fe..0000000 --- a/spirit/tests/test_trade.py +++ /dev/null @@ -1,410 +0,0 @@ -import unittest -import os -import tempfile -from unittest.mock import MagicMock, AsyncMock, patch - -from spirit.database import db_manager - -CARD_A = "aaaaaaaa-1111-2222-3333-444444444444" -CARD_B = "bbbbbbbb-1111-2222-3333-444444444444" - - -class TestTradeSystem(unittest.IsolatedAsyncioTestCase): - @classmethod - def setUpClass(cls): - cls.db_fd, cls.db_path = tempfile.mkstemp() - cls.db_patcher = patch('spirit.database.connection.DB_PATH', cls.db_path) - cls.db_patcher.start() - - from spirit.database.setup_db import setup_database - setup_database() - - @classmethod - def tearDownClass(cls): - cls.db_patcher.stop() - try: - os.close(cls.db_fd) - os.unlink(cls.db_path) - except OSError: - pass - - def setUp(self): - from spirit.database import db_session, TradeOffer, Collection - with db_session() as session: - session.query(TradeOffer).delete() - session.query(Collection).filter( - Collection.archetype_id.in_([CARD_A, CARD_B])).delete(synchronize_session=False) - - def _give(self, account_id, guid, count): - from spirit.database.player_data import add_to_collection - add_to_collection(account_id, guid, count=count, is_tradable=True) - - def _tradable_count(self, account_id, guid): - from spirit.database.player_data import get_collection_by_account_id - for c in get_collection_by_account_id(account_id): - if c["archetype_id"] == guid: - return c["tradable_count"] - return 0 - - def _make_handler(self, account_id, server=None): - from spirit.packets.handlers.trade import TradeHandler - from spirit.game.models.player import Player - - mock_client = MagicMock() - mock_client.send_packet = AsyncMock() - mock_client.addr = ("127.0.0.1", 9999) - mock_client.server = server or MagicMock(clients=[]) - mock_client.player = Player({ - "account_id": account_id, - "username": f"user_{account_id[:6]}", - "screen_name": f"User {account_id[:6]}" - }) - return TradeHandler(mock_client), mock_client - - def _sent_by_name(self, mock_client, name): - return [c.args[0] for c in mock_client.send_packet.call_args_list - if isinstance(c.args[0], dict) and c.args[0].get("messageName") == name] - - # ------------------------------------------------------------- data layer - - def test_create_offer_requires_ownership(self): - from spirit.database import economy_data - - offer, error = economy_data.create_trade_offer( - "acc-t-poor", {CARD_A: 1}, {CARD_B: 1}) - self.assertIsNone(offer) - self.assertEqual(error, "trade.createLot.notEnoughCards.body") - - self._give("acc-t-rich", CARD_A, 2) - offer, error = economy_data.create_trade_offer( - "acc-t-rich", {CARD_A: 2}, {CARD_B: 1}) - self.assertIsNone(error) - self.assertEqual(offer["status"], "open") - - def test_accept_swaps_items_atomically(self): - from spirit.database import economy_data - - seller, buyer = "acc-t-seller", "acc-t-buyer" - self._give(seller, CARD_A, 3) - self._give(buyer, CARD_B, 2) - - offer, _ = economy_data.create_trade_offer(seller, {CARD_A: 3}, {CARD_B: 2}) - accepted, error = economy_data.accept_trade_offer(offer["offer_id"], buyer) - - self.assertIsNone(error) - self.assertEqual(accepted["status"], "accepted") - self.assertEqual(accepted["accepted_by"], buyer) - - self.assertEqual(self._tradable_count(seller, CARD_A), 0) - self.assertEqual(self._tradable_count(seller, CARD_B), 2) - self.assertEqual(self._tradable_count(buyer, CARD_A), 3) - self.assertEqual(self._tradable_count(buyer, CARD_B), 0) - - def test_accept_fails_without_requested_items_and_rolls_back(self): - from spirit.database import economy_data - - seller, buyer = "acc-t-seller2", "acc-t-buyer2" - self._give(seller, CARD_A, 1) - # Buyer owns nothing - - offer, _ = economy_data.create_trade_offer(seller, {CARD_A: 1}, {CARD_B: 1}) - accepted, error = economy_data.accept_trade_offer(offer["offer_id"], buyer) - - self.assertIsNone(accepted) - self.assertEqual(error, "trade.createLot.notEnoughCards.body") - # Nothing moved, offer still open - self.assertEqual(self._tradable_count(seller, CARD_A), 1) - self.assertEqual(economy_data.get_trade_offer(offer["offer_id"])["status"], "open") - - def test_accept_cancels_stale_offer_when_seller_spent_items(self): - from spirit.database import economy_data - from spirit.database.player_data import remove_from_collection - - seller, buyer = "acc-t-seller3", "acc-t-buyer3" - self._give(seller, CARD_A, 1) - self._give(buyer, CARD_B, 1) - - offer, _ = economy_data.create_trade_offer(seller, {CARD_A: 1}, {CARD_B: 1}) - remove_from_collection(seller, CARD_A, count=1, is_tradable=True) - - accepted, error = economy_data.accept_trade_offer(offer["offer_id"], buyer) - self.assertIsNone(accepted) - self.assertIsNotNone(error) - self.assertEqual(economy_data.get_trade_offer(offer["offer_id"])["status"], "cancelled") - - def test_private_offer_only_acceptable_by_recipient(self): - from spirit.database import economy_data - - seller, friend, stranger = "acc-t-s4", "acc-t-f4", "acc-t-x4" - self._give(seller, CARD_A, 1) - self._give(friend, CARD_B, 1) - self._give(stranger, CARD_B, 1) - - offer, _ = economy_data.create_trade_offer( - seller, {CARD_A: 1}, {CARD_B: 1}, recipient_id=friend) - - _, error = economy_data.accept_trade_offer(offer["offer_id"], stranger) - self.assertIsNotNone(error) - - accepted, error = economy_data.accept_trade_offer(offer["offer_id"], friend) - self.assertIsNone(error) - self.assertEqual(accepted["accepted_by"], friend) - - # ------------------------------------------------------------- handlers - - async def test_create_and_view_public_board(self): - from spirit.network.message_names import OutboundMsg - - seller_id = "acc-t-h-seller" - self._give(seller_id, CARD_A, 2) - - seller, seller_client = self._make_handler(seller_id) - await seller.handle_create_lot({ - "forTrade": {CARD_A: 2}, "toReceive": {CARD_B: 1}, - "privateTo": None, "message": "", "expirationType": 0 - }, 70, 0) - - created = self._sent_by_name(seller_client, OutboundMsg.LOT_CREATED.value) - self.assertEqual(len(created), 1) - lot_id = created[0]["lotID"] - - # Another player sees it on the public board - viewer, viewer_client = self._make_handler("acc-t-h-viewer") - await viewer.handle_view_public_trades({"offset": 0, "limit": 50}, 71, 0) - - lots = self._sent_by_name(viewer_client, OutboundMsg.LOTS_RETRIEVED.value)[0]["lots"] - self.assertEqual(len(lots), 1) - lot = lots[0] - self.assertEqual(lot["id"], lot_id) - self.assertEqual(len(lot["items"]), 2) - self.assertEqual(lot["cardPrice"], {CARD_B: 1}) - - # The seller does NOT see their own lot on the public board - seller_client.send_packet.reset_mock() - await seller.handle_view_public_trades({"offset": 0, "limit": 50}, 72, 0) - lots = self._sent_by_name(seller_client, OutboundMsg.LOTS_RETRIEVED.value)[0]["lots"] - self.assertEqual(len(lots), 0) - - # But sees it under My Lots - seller_client.send_packet.reset_mock() - await seller.handle_view_my_lots({"offset": 0, "limit": 50}, 73, 0) - lots = self._sent_by_name(seller_client, OutboundMsg.MY_LOTS_RETRIEVED.value)[0]["lots"] - self.assertEqual(len(lots), 1) - - async def test_accept_trade_handler_swaps_and_notifies(self): - from spirit.database import economy_data - from spirit.network.message_names import OutboundMsg - - seller_id, buyer_id = "acc-t-h-s2", "acc-t-h-b2" - self._give(seller_id, CARD_A, 1) - self._give(buyer_id, CARD_B, 1) - - offer, _ = economy_data.create_trade_offer(seller_id, {CARD_A: 1}, {CARD_B: 1}) - - # Seller is online on a mock server - server = MagicMock() - seller_handler, seller_client = self._make_handler(seller_id, server) - server.clients = [seller_client] - - buyer_handler, buyer_client = self._make_handler(buyer_id, server) - await buyer_handler.handle_accept_trade({"lotID": offer["offer_id"]}, 80, 0) - - sold = self._sent_by_name(buyer_client, OutboundMsg.LOT_SOLD.value) - self.assertEqual(len(sold), 1) - self.assertTrue(self._sent_by_name(buyer_client, OutboundMsg.COLLECTION_COUNT_FOUND.value)) - - # Owner notified too - self.assertTrue(self._sent_by_name(seller_client, OutboundMsg.LOT_SOLD.value)) - - self.assertEqual(self._tradable_count(buyer_id, CARD_A), 1) - self.assertEqual(self._tradable_count(seller_id, CARD_B), 1) - - async def test_remove_lot(self): - from spirit.database import economy_data - from spirit.network.message_names import OutboundMsg - - seller_id = "acc-t-h-s3" - self._give(seller_id, CARD_A, 1) - offer, _ = economy_data.create_trade_offer(seller_id, {CARD_A: 1}, {CARD_B: 1}) - - handler, client = self._make_handler(seller_id) - await handler.handle_remove_lot({"lotID": offer["offer_id"]}, 90, 0) - - removed = self._sent_by_name(client, OutboundMsg.LOT_REMOVED.value) - self.assertEqual(len(removed), 1) - self.assertEqual(economy_data.get_trade_offer(offer["offer_id"])["status"], "cancelled") - - async def test_count_handshake_precedes_pagination(self): - """The client asks for a count first; the reply must carry the true total.""" - from spirit.database import economy_data - from spirit.network.message_names import OutboundMsg - - seller_id = "acc-t-count-s" - self._give(seller_id, CARD_A, 3) - economy_data.create_trade_offer(seller_id, {CARD_A: 1}, {CARD_B: 1}) - economy_data.create_trade_offer(seller_id, {CARD_A: 2}, {CARD_B: 2}) - - # Owner: My Lots count - seller, seller_client = self._make_handler(seller_id) - await seller.handle_view_my_lots_count({}, 200, 0) - counts = self._sent_by_name(seller_client, OutboundMsg.MY_LOTS_RETRIEVED_COUNT.value) - self.assertEqual(len(counts), 1) - self.assertEqual(counts[0]["count"], 2) - - # Public count from another viewer sees both (not their own) - viewer, viewer_client = self._make_handler("acc-t-count-v") - await viewer.handle_view_public_trades_count({}, 201, 0) - pub = self._sent_by_name(viewer_client, OutboundMsg.LOTS_RETRIEVED_COUNT.value) - self.assertEqual(pub[0]["count"], 2) - - # The seller's own public count excludes their own lots - seller_client.send_packet.reset_mock() - await seller.handle_view_public_trades_count({}, 202, 0) - pub = self._sent_by_name(seller_client, OutboundMsg.LOTS_RETRIEVED_COUNT.value) - self.assertEqual(pub[0]["count"], 0) - - async def test_public_count_matches_paginated_results(self): - """Count and page must agree so the client paginates to completion.""" - from spirit.database import economy_data - from spirit.network.message_names import OutboundMsg - - # Two sellers post public lots; viewer is a third party - for i, sid in enumerate(["acc-t-pc-1", "acc-t-pc-2"]): - self._give(sid, CARD_A, 1) - economy_data.create_trade_offer(sid, {CARD_A: 1}, {CARD_B: 1}) - - viewer, client = self._make_handler("acc-t-pc-viewer") - await viewer.handle_view_public_trades_count({}, 210, 0) - count = self._sent_by_name(client, OutboundMsg.LOTS_RETRIEVED_COUNT.value)[0]["count"] - - client.send_packet.reset_mock() - await viewer.handle_view_public_trades({"offset": 0, "limit": count}, 211, 0) - lots = self._sent_by_name(client, OutboundMsg.LOTS_RETRIEVED.value)[0]["lots"] - self.assertEqual(len(lots), count) - - async def test_private_count_and_view(self): - from spirit.database import economy_data - from spirit.network.message_names import OutboundMsg - - seller_id, friend_id = "acc-t-pv-s", "acc-t-pv-f" - self._give(seller_id, CARD_A, 1) - economy_data.create_trade_offer( - seller_id, {CARD_A: 1}, {CARD_B: 1}, recipient_id=friend_id) - - friend, client = self._make_handler(friend_id) - await friend.handle_view_private_trades_count({}, 220, 0) - count = self._sent_by_name(client, OutboundMsg.PRIVATE_LOTS_RETRIEVED_COUNT.value) - self.assertEqual(count[0]["count"], 1) - - client.send_packet.reset_mock() - await friend.handle_view_private_trades({"offset": 0, "limit": 1}, 221, 0) - lots = self._sent_by_name(client, OutboundMsg.PRIVATE_LOTS_RETRIEVED.value)[0]["lots"] - self.assertEqual(len(lots), 1) - - async def test_trade_history_count_and_view(self): - from spirit.database import economy_data - from spirit.network.message_names import OutboundMsg - - seller_id, buyer_id = "acc-t-hist-s", "acc-t-hist-b" - self._give(seller_id, CARD_A, 1) - self._give(buyer_id, CARD_B, 1) - offer, _ = economy_data.create_trade_offer(seller_id, {CARD_A: 1}, {CARD_B: 1}) - economy_data.accept_trade_offer(offer["offer_id"], buyer_id) - - buyer, client = self._make_handler(buyer_id) - await buyer.handle_view_trade_history_count({"accountID": buyer_id}, 230, 0) - count = self._sent_by_name(client, OutboundMsg.TRADE_HISTORY_RETRIEVED_COUNT.value) - self.assertEqual(count[0]["count"], 1) - - client.send_packet.reset_mock() - await buyer.handle_view_trade_history( - {"accountID": buyer_id, "offset": 0, "limit": 50}, 231, 0) - res = self._sent_by_name(client, OutboundMsg.TRADE_HISTORY_RETRIEVED.value)[0] - self.assertEqual(res["offset"], 0) - self.assertEqual(len(res["history"]), 1) - - def test_count_handlers_registered(self): - from spirit.packets.handlers.trade import TradeHandler - from spirit.network.message_names import InboundMsg - from unittest.mock import MagicMock - - handlers = TradeHandler(MagicMock()).get_handlers() - for name in (InboundMsg.VIEW_MY_LOTS_COUNT, InboundMsg.VIEW_ALL_PRIVATE_TRADES_COUNT, - InboundMsg.VIEW_ALL_PUBLIC_TRADES_COUNT, InboundMsg.VIEW_TRADE_HISTORY_2_COUNT, - InboundMsg.VIEW_TRADE_HISTORY_2): - self.assertIn(name.value, handlers) - - async def test_search_for_lots(self): - from spirit.database import economy_data - from spirit.network.message_names import OutboundMsg - - seller_id = "acc-t-h-s5" - self._give(seller_id, CARD_A, 1) - economy_data.create_trade_offer(seller_id, {CARD_A: 1}, {CARD_B: 1}) - - viewer, client = self._make_handler("acc-t-h-v5") - await viewer.handle_search_for_lots({"archetypes": [CARD_A]}, 91, 0) - lots = self._sent_by_name(client, OutboundMsg.LOTS_RETRIEVED.value)[0]["lots"] - self.assertEqual(len(lots), 1) - - client.send_packet.reset_mock() - await viewer.handle_search_for_lots({"archetypes": [CARD_B]}, 92, 0) - lots = self._sent_by_name(client, OutboundMsg.LOTS_RETRIEVED.value)[0]["lots"] - self.assertEqual(len(lots), 0) - - async def test_archetype_flags_handlers(self): - from spirit.database.player_data import get_archetype_flags - from spirit.network.message_names import OutboundMsg - - user_id = "acc-flags-test" - handler, client = self._make_handler(user_id) - - # Test SetArchetypeForTradeCount - await handler.handle_set_archetype_for_trade_count({ - "archetypeID": CARD_A, - "forTrade": 5 - }, 123, 0) - - # Verify Outbound Msg - res = self._sent_by_name(client, OutboundMsg.ARCHETYPE_FOR_TRADE_SET.value)[0] - self.assertEqual(res["archetypeID"], CARD_A) - self.assertEqual(res["forTrade"], 5) - - # Test SetArchetypeReview - await handler.handle_set_archetype_review({ - "archetypeID": CARD_A, - "review": 1 - }, 124, 0) - - # Verify Outbound Msg - res = self._sent_by_name(client, OutboundMsg.ARCHETYPE_REVIEW_SET.value)[0] - self.assertEqual(res["archetypeID"], CARD_A) - self.assertEqual(res["review"], 1) - - # Retrieve flags via player_data helper - flags = get_archetype_flags(user_id) - self.assertEqual(len(flags), 1) - self.assertEqual(flags[0]["archetypeID"], CARD_A) - self.assertEqual(flags[0]["forTrade"], 5) - self.assertEqual(flags[0]["review"], 1) - - async def test_get_create_lot_prices_handler(self): - from spirit.network.message_names import OutboundMsg - from spirit.game.attributes import AttrID - - handler, client = self._make_handler("acc-prices-test") - await handler.handle_get_create_lot_prices({}, 125, 0) - - # Verify Outbound Msg - res = self._sent_by_name(client, OutboundMsg.CREATE_LOT_PRICES.value)[0] - prices = res["prices"] - self.assertEqual(prices[0]["name"], AttrID.TRAINER_TOKENS.value) - self.assertEqual(prices[0]["value"], 8) - self.assertEqual(prices[1]["name"], AttrID.TRAINER_TOKENS.value) - self.assertEqual(prices[1]["value"], 24) - self.assertEqual(prices[2]["name"], AttrID.TRAINER_TOKENS.value) - self.assertEqual(prices[2]["value"], 48) - - -if __name__ == '__main__': - unittest.main() diff --git a/spirit/tests/test_trainer_effects.py b/spirit/tests/test_trainer_effects.py deleted file mode 100644 index 31ce1c7..0000000 --- a/spirit/tests/test_trainer_effects.py +++ /dev/null @@ -1,251 +0,0 @@ -"""Tests for scripted trainer effects (resolve_trainer_effect + Worker).""" - -import importlib.util -import os -import unittest -import uuid - -from spirit.game.attributes import GameSequence, PokemonTypes -from spirit.game.data_utils import ( - TRAINER_EFFECTS_BY_GUID, - PokemonCardDef, - StadiumCardDef, - SupporterCardDef, -) -from spirit.game.models.board import create_card_entity -from spirit.game.models.card import Card - -from spirit.game.session.game_session import GameSession - -P1 = "player-1" -P2 = "player-2" - -WORKER_SCRIPT = os.path.join( - os.path.dirname(__file__), "..", "game", "scripts", "cards", "SWSH12", "Worker_167.py" -) - - -def make_card(card_def) -> Card: - d = card_def.to_archetype_dict() - return Card(d["guid"], d["key"], d["attributes"]) - - -def load_worker_def(): - spec = importlib.util.spec_from_file_location("test_worker_script", WORKER_SCRIPT) - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module.card - - -DECK_FILLER = PokemonCardDef( - guid="00000000-0000-0000-0000-0000000000f1", - key="BW1", name="com.test.pokemon.Filler.Name", - collector_number=1, set_code="BW1", rarity=1, - hp=50, elements=[PokemonTypes.COLORLESS], -) - -TEST_STADIUM = StadiumCardDef( - guid="00000000-0000-0000-0000-0000000000d1", - key="BW1", name="com.test.trainer.Stadium.Name", - collector_number=90, set_code="BW1", rarity=1, -) - -PLAIN_SUPPORTER = SupporterCardDef( - guid="00000000-0000-0000-0000-0000000000c1", - key="BW1", name="com.test.trainer.Plain.Name", - collector_number=91, set_code="BW1", rarity=1, -) - - -class MockClientHandler: - def __init__(self, account_id, username): - self.player = type("P", (), { - "account_id": account_id, "username": username, - "screen_name": username, "avatar_decks": [], - })() - self.addr = ("127.0.0.1", 12345) - self.sent_packets = [] - - async def send_packet(self, data, request_id=0, flags=0): - self.sent_packets.append(data) - - -class TestTrainerEffectRegistry(unittest.TestCase): - def test_effect_registers_by_lowercase_guid(self): - async def noop(ctx): - pass - - SupporterCardDef( - guid="AAAAAAAA-0000-0000-0000-0000000000AA", - key="BW1", name="com.test.trainer.Reg.Name", - collector_number=92, set_code="BW1", rarity=1, - effect=noop, - ) - self.assertIs( - TRAINER_EFFECTS_BY_GUID["aaaaaaaa-0000-0000-0000-0000000000aa"], noop - ) - - def test_worker_script_registers_its_effect(self): - card_def = load_worker_def() - self.assertIn(card_def.guid.lower(), TRAINER_EFFECTS_BY_GUID) - - -class TestExecutePlayTrainer(unittest.IsolatedAsyncioTestCase): - def setUp(self): - self.client1 = MockClientHandler(P1, "Ash") - self.client2 = MockClientHandler(P2, "Gary") - pairing = { - "is_solo": False, - "format": "Standard", - "players": { - P1: {"client": self.client1, "deck": {"deckID": "d1", "deckName": "A", "cards": []}, "ready": True}, - P2: {"client": self.client2, "deck": {"deckID": "d2", "deckName": "B", "cards": []}, "ready": True}, - }, - } - self.session = GameSession(str(uuid.uuid4()), pairing) - self.board = self.session.board_state - - self.worker_card = make_card(load_worker_def()) - self.worker = self.add_to("hand", self.worker_card) - filler = make_card(DECK_FILLER) - self.deck_cards = [self.add_to("deck", filler) for _ in range(5)] - self.session.turn_state.begin_turn(P1) - self.session.turn_state.begin_turn(P1) # turn 2: supporters legal - - def add_to(self, area_name, card, player_id=P1): - entity = create_card_entity(card, owning_player_id=player_id) - self.board.add_card_to_area(entity, self.board.find_player_area(player_id, area_name)) - return entity - - def add_stadium_in_play(self, player_id=P2): - entity = create_card_entity(make_card(TEST_STADIUM), owning_player_id=player_id) - self.board.add_card_to_area(entity, self.board.find_global_area("activeStadium")) - entity.owning_player_id = player_id # global area add clears the owner - return entity - - def sequence_brackets(self, client, name): - """Inner messages of each sequence bracket with the given name. - - Nested child brackets are folded in: their Start/Stop markers are - skipped and their inner messages count as the parent's.""" - brackets = [] - current = None - depth = 0 - for packet in client.sent_packets: - msg = packet.get("msg") if isinstance(packet, dict) else None - if not isinstance(msg, dict): - continue - if msg.get("name") == "StartSequence": - if current is not None: - depth += 1 - elif msg["value"].get("name") == name: - current = [] - depth = 0 - elif msg.get("name") == "StopSequence": - if current is not None: - if depth == 0: - brackets.append(current) - current = None - else: - depth -= 1 - elif current is not None: - current.append(msg) - return brackets - - def bracket_order(self, client): - """Sequence bracket names in send order.""" - return [ - packet["msg"]["value"].get("name") - for packet in client.sent_packets - if isinstance(packet, dict) and isinstance(packet.get("msg"), dict) - and packet["msg"].get("name") == "StartSequence" - ] - - async def test_worker_draws_three_and_discards_the_stadium(self): - stadium = self.add_stadium_in_play(player_id=P2) - await self.session._execute_play_trainer(P1, self.worker) - - hand = self.board.find_player_area(P1, "hand") - deck = self.board.find_player_area(P1, "deck") - self.assertEqual(len(hand.children), 3) - self.assertEqual(len(deck.children), 2) - self.assertIn(self.worker, self.board.find_player_area(P1, "discard").children) - self.assertIn(stadium, self.board.find_player_area(P2, "discard").children) - self.assertTrue(self.session.turn_state.supporter_played) - - trainer_area = self.board.find_global_area("activeTrainer") - discard_area = self.board.find_player_area(P1, "discard") - - # Owner: placement and discard ride separate TrainerCard brackets so - # the card sits in the slot while the effect resolves between them. - brackets = self.sequence_brackets(self.client1, GameSequence.TRAINER_CARD.value) - self.assertEqual(len(brackets), 2) - self.assertEqual([m["name"] for m in brackets[0]], ["EntityMoved"]) - self.assertEqual(brackets[0][0]["value"]["destinationID"], trainer_area.entity_id) - self.assertEqual([m["name"] for m in brackets[1]], ["EntityMoved"]) - self.assertEqual(brackets[1][0]["value"]["destinationID"], discard_area.entity_id) - self.assertEqual(brackets[1][0]["value"]["entityID"], self.worker.entity_id) - - # Non-owner placement is a PlayCard bracket (plain move: k.z delegation - # presents the card large, then flies it into the slot); discard is a - # plain TrainerCard bracket afterwards. - opp_play = self.sequence_brackets(self.client2, GameSequence.PLAY_CARD.value) - self.assertEqual( - [m["name"] for m in opp_play[0]], ["RevealCardToAllEffect", "EntityMoved"] - ) - self.assertEqual(opp_play[0][1]["value"]["destinationID"], trainer_area.entity_id) - opp_trainer = self.sequence_brackets(self.client2, GameSequence.TRAINER_CARD.value) - self.assertEqual(len(opp_trainer), 1) - self.assertEqual(opp_trainer[0][0]["value"]["destinationID"], discard_area.entity_id) - - # Effect resolves between placement and discard: draws ride their own - # Draw bracket (moves in a nested GroupedMove child), the stadium - # move a plain GroupedMove. - self.assertEqual( - self.bracket_order(self.client1)[-5:], - [GameSequence.TRAINER_CARD.value, GameSequence.DRAW.value, - GameSequence.GROUPED_MOVE.value, GameSequence.GROUPED_MOVE.value, - GameSequence.TRAINER_CARD.value], - ) - self.assertEqual( - self.bracket_order(self.client2)[-6:], - [GameSequence.SERIAL_SEQUENCE.value, GameSequence.PLAY_CARD.value, - GameSequence.DRAW.value, GameSequence.GROUPED_MOVE.value, - GameSequence.GROUPED_MOVE.value, GameSequence.TRAINER_CARD.value], - ) - - # The Draw bracket matches _broadcast_draw's shape exactly: intros - # first (drawer only), then the moves. - draw = self.sequence_brackets(self.client1, GameSequence.DRAW.value) - self.assertEqual(len(draw), 1) - names = [m["name"] for m in draw[0]] - self.assertEqual(names, ["EntityIntroduced"] * 3 + ["EntityMoved"] * 3) - - opp_draw = self.sequence_brackets(self.client2, GameSequence.DRAW.value) - opp_names = [m["name"] for m in opp_draw[0]] - self.assertEqual(opp_names, ["EntityMoved"] * 3) - - grouped = self.sequence_brackets(self.client1, GameSequence.GROUPED_MOVE.value) - self.assertEqual(grouped[-1][-1]["value"]["entityID"], stadium.entity_id) - - async def test_worker_without_a_stadium_just_draws(self): - await self.session._execute_play_trainer(P1, self.worker) - - self.assertEqual(len(self.board.find_player_area(P1, "hand").children), 3) - self.assertIn(self.worker, self.board.find_player_area(P1, "discard").children) - - async def test_unscripted_trainer_still_plays_to_discard(self): - plain = self.add_to("hand", make_card(PLAIN_SUPPORTER)) - await self.session._execute_play_trainer(P1, plain) - - self.assertIn(plain, self.board.find_player_area(P1, "discard").children) - self.assertTrue(self.session.turn_state.supporter_played) - brackets = self.sequence_brackets(self.client1, GameSequence.TRAINER_CARD.value) - self.assertEqual(len(brackets), 2) - self.assertEqual([m["name"] for m in brackets[0]], ["EntityMoved"]) - self.assertEqual([m["name"] for m in brackets[1]], ["EntityMoved"]) - self.assertFalse(self.sequence_brackets(self.client1, GameSequence.DRAW.value)) - - -if __name__ == "__main__": - unittest.main() diff --git a/spirit/tests/test_triggers_conditions.py b/spirit/tests/test_triggers_conditions.py deleted file mode 100644 index bd31410..0000000 --- a/spirit/tests/test_triggers_conditions.py +++ /dev/null @@ -1,787 +0,0 @@ -"""Tests for the trigger-system expansion (ON_EVOLVE/ON_KNOCKED_OUT/ -BETWEEN_TURNS) and the special-condition checkup engine (poison/burn/ -paralysis/confusion), plus the T8 leave-Active-spot effect cleanup.""" - -import unittest -import uuid -from unittest.mock import AsyncMock, patch - -from spirit.game.attributes import AttrID, GameSequence, PokemonStage, PokemonTypes, SpecialConditions -from spirit.game.card_effects.pokemon import UnfazedFatPassive, condition_attack -from spirit.game.data_utils import ( - Ability, - Attack, - PokemonCardDef, - StadiumCardDef, - Triggers, -) -from spirit.game.models.board import create_card_entity -from spirit.game.models.card import Card -from spirit.game.session.effects import EffectContext, resolve_attack -from spirit.game.session.game_session import GameSession -from spirit.game.session.constants import SelectionKind -from spirit.game.session.legal_actions import ACTION_RETREAT, compute_legal_actions -from spirit.game.session.passives import Passive - -P1 = "player-1" -P2 = "player-2" - - -def make_card(card_def) -> Card: - d = card_def.to_archetype_dict() - return Card(d["guid"], d["key"], d["attributes"]) - - -# ---------------------------------------------------------------------- -# Shared card fixtures -# ---------------------------------------------------------------------- - -BASIC_DEF = PokemonCardDef( - guid="70000000-0000-0000-0000-000000000001", - key="TST", name="com.test.pokemon.TrigBasic.Name", - collector_number=1, set_code="TST", rarity=1, - hp=60, elements=[PokemonTypes.COLORLESS], retreat_cost=0, -) -BASIC = make_card(BASIC_DEF) - -PLAIN_DEF = PokemonCardDef( - guid="70000000-0000-0000-0000-000000000002", - key="TST", name="com.test.pokemon.TrigPlain.Name", - collector_number=2, set_code="TST", rarity=1, - hp=100, elements=[PokemonTypes.COLORLESS], retreat_cost=0, -) -PLAIN = make_card(PLAIN_DEF) - -LOW_HP_DEF = PokemonCardDef( - guid="70000000-0000-0000-0000-000000000003", - key="TST", name="com.test.pokemon.TrigLowHP.Name", - collector_number=3, set_code="TST", rarity=1, - hp=5, elements=[PokemonTypes.COLORLESS], retreat_cost=0, -) -LOW_HP = make_card(LOW_HP_DEF) - -EVOLVE_MARKER_CALLS = [] - - -async def _evolve_marker_effect(ctx): - EVOLVE_MARKER_CALLS.append(ctx.source.entity_id) - - -EVOLUTION_DEF = PokemonCardDef( - guid="70000000-0000-0000-0000-000000000004", - key="TST", name="com.test.pokemon.TrigEvo.Name", - collector_number=4, set_code="TST", rarity=1, - hp=120, elements=[PokemonTypes.COLORLESS], - stage=PokemonStage.STAGE1, retreat_cost=0, - evolves_from="com.test.pokemon.TrigBasic.Name", - abilities=[Ability("On Evolve Marker", trigger=Triggers.ON_EVOLVE, - effect=_evolve_marker_effect)], -) -EVOLUTION = make_card(EVOLUTION_DEF) - - -class _LocksAllAbilitiesPassive(Passive): - """Test-local stand-in for a Path to the Peak-style stadium.""" - def blocks_abilities(self, pokemon, carrier) -> bool: - return True - - -LOCK_STADIUM_DEF = StadiumCardDef( - passive=_LocksAllAbilitiesPassive(), - guid="70000000-0000-0000-0000-000000000005", - key="TST", name="com.test.trainer.LockStadium.Name", - collector_number=5, set_code="TST", rarity=0, -) -LOCK_STADIUM = make_card(LOCK_STADIUM_DEF) - -KO_TRIGGER_CALLS = [] - - -async def _ko_marker_effect(ctx): - KO_TRIGGER_CALLS.append((ctx.ko_from_attack, ctx.ko_attacker)) - - -KO_TARGET_DEF = PokemonCardDef( - guid="70000000-0000-0000-0000-000000000006", - key="TST", name="com.test.pokemon.TrigKOTarget.Name", - collector_number=6, set_code="TST", rarity=1, - hp=10, elements=[PokemonTypes.COLORLESS], retreat_cost=0, - abilities=[Ability("On KO Marker", trigger=Triggers.ON_KNOCKED_OUT, - effect=_ko_marker_effect)], -) -KO_TARGET = make_card(KO_TARGET_DEF) - -LETHAL_ATTACKER_DEF = PokemonCardDef( - guid="70000000-0000-0000-0000-000000000007", - key="TST", name="com.test.pokemon.TrigLethal.Name", - collector_number=7, set_code="TST", rarity=1, - hp=100, elements=[PokemonTypes.COLORLESS], retreat_cost=0, - abilities=[Attack("Smash", cost={PokemonTypes.COLORLESS: 1}, damage=50)], -) -LETHAL_ATTACKER = make_card(LETHAL_ATTACKER_DEF) -LETHAL_ATTACK = LETHAL_ATTACKER_DEF.abilities[0] - -CHAIN_CALLS = [] - - -async def _chain_a_effect(ctx): - CHAIN_CALLS.append("A") - await ctx.knock_out(ctx.opponent_active()) - - -async def _chain_b_effect(ctx): - CHAIN_CALLS.append("B") - - -CHAIN_A_DEF = PokemonCardDef( - guid="70000000-0000-0000-0000-000000000008", - key="TST", name="com.test.pokemon.TrigChainA.Name", - collector_number=8, set_code="TST", rarity=1, - hp=10, elements=[PokemonTypes.COLORLESS], retreat_cost=0, - abilities=[Ability("Chain A", trigger=Triggers.ON_KNOCKED_OUT, effect=_chain_a_effect)], -) -CHAIN_A = make_card(CHAIN_A_DEF) - -CHAIN_B_DEF = PokemonCardDef( - guid="70000000-0000-0000-0000-000000000009", - key="TST", name="com.test.pokemon.TrigChainB.Name", - collector_number=9, set_code="TST", rarity=1, - hp=100, elements=[PokemonTypes.COLORLESS], retreat_cost=0, - abilities=[ - Attack("Zap", cost={PokemonTypes.COLORLESS: 1}, damage=50), - Ability("Chain B", trigger=Triggers.ON_KNOCKED_OUT, effect=_chain_b_effect), - ], -) -CHAIN_B = make_card(CHAIN_B_DEF) -CHAIN_B_ATTACK = CHAIN_B_DEF.abilities[0] - -BETWEEN_TURNS_CALLS = [] - - -async def _between_turns_effect(ctx): - BETWEEN_TURNS_CALLS.append(ctx.source.entity_id) - - -BETWEEN_TURNS_DEF = PokemonCardDef( - guid="70000000-0000-0000-0000-00000000000a", - key="TST", name="com.test.pokemon.TrigBetween.Name", - collector_number=10, set_code="TST", rarity=1, - hp=100, elements=[PokemonTypes.COLORLESS], retreat_cost=0, - abilities=[Ability("Between Marker", trigger=Triggers.BETWEEN_TURNS, - effect=_between_turns_effect)], -) -BETWEEN_TURNS_POKEMON = make_card(BETWEEN_TURNS_DEF) - -CONFUSED_ATTACKER_DEF = PokemonCardDef( - guid="70000000-0000-0000-0000-00000000000b", - key="TST", name="com.test.pokemon.TrigConfused.Name", - collector_number=11, set_code="TST", rarity=1, - hp=100, elements=[PokemonTypes.COLORLESS], retreat_cost=0, - abilities=[Attack("Tackle", cost={PokemonTypes.COLORLESS: 1}, damage=30)], -) -CONFUSED_ATTACKER = make_card(CONFUSED_ATTACKER_DEF) -CONFUSED_ATTACK = CONFUSED_ATTACKER_DEF.abilities[0] - -LOW_HP_CONFUSED_DEF = PokemonCardDef( - guid="70000000-0000-0000-0000-00000000000c", - key="TST", name="com.test.pokemon.TrigConfusedLowHP.Name", - collector_number=12, set_code="TST", rarity=1, - hp=20, elements=[PokemonTypes.COLORLESS], retreat_cost=0, - abilities=[Attack("Tackle", cost={PokemonTypes.COLORLESS: 1}, damage=30)], -) -LOW_HP_CONFUSED = make_card(LOW_HP_CONFUSED_DEF) -LOW_HP_CONFUSED_ATTACK = LOW_HP_CONFUSED_DEF.abilities[0] - -VSTAR_LOCK_DEF = PokemonCardDef( - guid="70000000-0000-0000-0000-00000000001b", - key="TST", name="com.test.pokemon.TrigVstarLock.Name", - collector_number=27, set_code="TST", rarity=1, - hp=100, elements=[PokemonTypes.COLORLESS], retreat_cost=0, - abilities=[Attack("Star Burst", cost={PokemonTypes.COLORLESS: 1}, damage=30, - vstar=True, locks_next_turn=True)], -) -VSTAR_LOCK_ATTACKER = make_card(VSTAR_LOCK_DEF) -VSTAR_LOCK_ATTACK = VSTAR_LOCK_DEF.abilities[0] - -POISON_ATTACK_DEF = PokemonCardDef( - guid="70000000-0000-0000-0000-00000000000d", - key="TST", name="com.test.pokemon.TrigPoisonAttacker.Name", - collector_number=13, set_code="TST", rarity=1, - hp=100, elements=[PokemonTypes.COLORLESS], retreat_cost=0, - abilities=[Attack("Poison Sting", cost={PokemonTypes.COLORLESS: 1}, damage=10, - effect=condition_attack(SpecialConditions.POISONED))], -) -POISON_ATTACKER = make_card(POISON_ATTACK_DEF) -POISON_ATTACK = POISON_ATTACK_DEF.abilities[0] - -FLIP_PARALYZE_DEF = PokemonCardDef( - guid="70000000-0000-0000-0000-00000000000e", - key="TST", name="com.test.pokemon.TrigFlipAttacker.Name", - collector_number=14, set_code="TST", rarity=1, - hp=100, elements=[PokemonTypes.COLORLESS], retreat_cost=0, - abilities=[Attack("Static Shock", cost={PokemonTypes.COLORLESS: 1}, - effect=condition_attack(SpecialConditions.PARALYZED, flip=True))], -) -FLIP_PARALYZE_ATTACKER = make_card(FLIP_PARALYZE_DEF) -FLIP_PARALYZE_ATTACK = FLIP_PARALYZE_DEF.abilities[0] - -MULTI_CONDITION_DEF = PokemonCardDef( - guid="70000000-0000-0000-0000-00000000000f", - key="TST", name="com.test.pokemon.TrigMultiCondition.Name", - collector_number=15, set_code="TST", rarity=1, - hp=100, elements=[PokemonTypes.COLORLESS], retreat_cost=0, - abilities=[Attack( - "Toxic Burn", cost={PokemonTypes.COLORLESS: 1}, damage=10, - effect=condition_attack(SpecialConditions.POISONED, SpecialConditions.BURNED, - self_conditions=(SpecialConditions.CONFUSED,)), - )], -) -MULTI_CONDITION_ATTACKER = make_card(MULTI_CONDITION_DEF) -MULTI_CONDITION_ATTACK = MULTI_CONDITION_DEF.abilities[0] - -# UnfazedFatPassive's `target is carrier` check assumes an ABILITY passive -# (carrier = the Pokemon itself), not a tool/energy attachment's carrier. -SHIELDED_DEFENDER_DEF = PokemonCardDef( - guid="70000000-0000-0000-0000-000000000010", - key="TST", name="com.test.pokemon.TrigShieldedDefender.Name", - collector_number=16, set_code="TST", rarity=1, - hp=100, elements=[PokemonTypes.COLORLESS], retreat_cost=0, - abilities=[Ability("Unfazed Fat", passive=UnfazedFatPassive())], -) -SHIELDED_DEFENDER = make_card(SHIELDED_DEFENDER_DEF) - -LOCK_ATTACKER_DEF = PokemonCardDef( - guid="70000000-0000-0000-0000-000000000011", - key="TST", name="com.test.pokemon.TrigLockAttacker.Name", - collector_number=17, set_code="TST", rarity=1, - hp=100, elements=[PokemonTypes.COLORLESS], retreat_cost=0, - abilities=[Attack("Overexert", cost={PokemonTypes.COLORLESS: 1}, damage=10, - locks_next_turn=True)], -) -LOCK_ATTACKER = make_card(LOCK_ATTACKER_DEF) -LOCK_ATTACK = LOCK_ATTACKER_DEF.abilities[0] - - -class MockClientHandler: - def __init__(self, account_id, username): - self.player = type("P", (), { - "account_id": account_id, "username": username, - "screen_name": username, "avatar_decks": [], - })() - self.addr = ("127.0.0.1", 12345) - self.sent_packets = [] - - async def send_packet(self, data, request_id=0, flags=0): - self.sent_packets.append(data) - - -class TriggerTestBase(unittest.IsolatedAsyncioTestCase): - def setUp(self): - self.client1 = MockClientHandler(P1, "Ash") - self.client2 = MockClientHandler(P2, "Gary") - pairing = { - "is_solo": False, - "format": "Standard", - "players": { - P1: {"client": self.client1, "deck": {"deckID": "d1", "deckName": "A", "cards": []}, "ready": True}, - P2: {"client": self.client2, "deck": {"deckID": "d2", "deckName": "B", "cards": []}, "ready": True}, - }, - } - self.session = GameSession(str(uuid.uuid4()), pairing) - self.board = self.session.board_state - self.session.turn_state.begin_turn(P1) - EVOLVE_MARKER_CALLS.clear() - KO_TRIGGER_CALLS.clear() - CHAIN_CALLS.clear() - BETWEEN_TURNS_CALLS.clear() - - def add_to(self, area_name, card, player_id=P1): - entity = create_card_entity(card, owning_player_id=player_id) - self.board.add_card_to_area(entity, self.board.find_player_area(player_id, area_name)) - return entity - - def add_stadium(self, card, player_id=P1): - entity = create_card_entity(card, owning_player_id=player_id) - self.board.add_card_to_area(entity, self.board.find_global_area("activeStadium")) - return entity - - def brackets(self, client, name): - """Inner messages of each sequence bracket with the given name.""" - out = [] - current = None - for packet in client.sent_packets: - msg = packet.get("msg") if isinstance(packet, dict) else None - if not isinstance(msg, dict): - continue - if msg.get("name") == "StartSequence" and msg["value"].get("name") == name: - current = [] - elif msg.get("name") == "StopSequence" and current is not None: - out.append(current) - current = None - elif current is not None: - current.append(msg) - return out - - -NO_SLEEP = patch("spirit.game.session.game_session.asyncio.sleep", new=AsyncMock()) - - -# ---------------------------------------------------------------------- -# T1/T2/T3: ON_EVOLVE / ON_KNOCKED_OUT / BETWEEN_TURNS -# ---------------------------------------------------------------------- - -class TestOnEvolveTrigger(TriggerTestBase): - def _evolve_entry(self, evo_card): - entries = compute_legal_actions(self.board, self.session.turn_state, P1, self.session.game_id) - return next(e for e in entries if e["entityID"] == evo_card.entity_id) - - async def test_fires_after_evolve(self): - self.session.turn_state.begin_turn(P2) - self.session.turn_state.begin_turn(P1) # turn 3: evolving is legal - target = self.add_to("bench", BASIC, P1) - evo_card = self.add_to("hand", EVOLUTION, P1) - entry = self._evolve_entry(evo_card) - - await self.session._execute_evolve(P1, evo_card, entry, [target.entity_id]) - - self.assertEqual(EVOLVE_MARKER_CALLS, [evo_card.entity_id]) - - async def test_does_not_fire_when_path_locked(self): - self.session.turn_state.begin_turn(P2) - self.session.turn_state.begin_turn(P1) - self.add_stadium(LOCK_STADIUM, P2) - target = self.add_to("bench", BASIC, P1) - evo_card = self.add_to("hand", EVOLUTION, P1) - entry = self._evolve_entry(evo_card) - - await self.session._execute_evolve(P1, evo_card, entry, [target.entity_id]) - - self.assertEqual(EVOLVE_MARKER_CALLS, []) - - -class TestEvolveCure(TriggerTestBase): - async def test_evolving_clears_pre_evolution_conditions(self): - self.session.turn_state.begin_turn(P2) - self.session.turn_state.begin_turn(P1) - target = self.add_to("bench", BASIC, P1) - target.set_attribute(AttrID.SPECIAL_CONDITIONS, ["Poisoned"]) - self.session.poison_counters[target.entity_id] = 3 - evo_card = self.add_to("hand", EVOLUTION, P1) - entries = compute_legal_actions(self.board, self.session.turn_state, P1, self.session.game_id) - entry = next(e for e in entries if e["entityID"] == evo_card.entity_id) - - await self.session._execute_evolve(P1, evo_card, entry, [target.entity_id]) - - self.assertEqual(target.get_attribute(AttrID.SPECIAL_CONDITIONS), []) - self.assertNotIn(target.entity_id, self.session.poison_counters) - cure_brackets = self.brackets(self.client1, GameSequence.REMOVE_SPECIAL_CONDITION.value) - self.assertEqual(len(cure_brackets), 1) - self.assertEqual(cure_brackets[0][0]["value"]["key"], "Target") - self.assertEqual(cure_brackets[0][0]["value"]["value"], target.entity_id) - - -class TestOnKnockedOutTrigger(TriggerTestBase): - async def test_fires_with_ko_from_attack_and_attacker(self): - attacker = self.add_to("activePokemonArea", LETHAL_ATTACKER, P1) - target = self.add_to("activePokemonArea", KO_TARGET, P2) - self.add_to("bench", BASIC, P2) # promotion candidate - - await resolve_attack(self.session, P1, attacker, LETHAL_ATTACK, LETHAL_ATTACK.ability_id) - - self.assertEqual(KO_TRIGGER_CALLS, [(True, attacker)]) - discard = self.board.find_player_area(P2, "discard") - self.assertIn(target, discard.children) - - async def test_does_not_fire_when_path_locked(self): - attacker = self.add_to("activePokemonArea", LETHAL_ATTACKER, P1) - self.add_stadium(LOCK_STADIUM, P1) - target = self.add_to("activePokemonArea", KO_TARGET, P2) - self.add_to("bench", BASIC, P2) - - await resolve_attack(self.session, P1, attacker, LETHAL_ATTACK, LETHAL_ATTACK.ability_id) - - self.assertEqual(KO_TRIGGER_CALLS, []) - - async def test_chained_knockout_recursion(self): - attacker = self.add_to("activePokemonArea", CHAIN_B, P1) - self.add_to("bench", BASIC, P1) # P1 promotion candidate - chain_a = self.add_to("activePokemonArea", CHAIN_A, P2) - self.add_to("bench", BASIC, P2) # P2 promotion candidate - - await resolve_attack(self.session, P1, attacker, CHAIN_B_ATTACK, CHAIN_B_ATTACK.ability_id) - - self.assertEqual(CHAIN_CALLS, ["A", "B"]) - self.assertIn(attacker, self.board.find_player_area(P1, "discard").children) - self.assertIn(chain_a, self.board.find_player_area(P2, "discard").children) - - -class TestBetweenTurnsTrigger(TriggerTestBase): - async def test_fires_each_checkup_for_every_in_play_pokemon(self): - p1_active = self.add_to("activePokemonArea", BETWEEN_TURNS_POKEMON, P1) - p2_active = self.add_to("activePokemonArea", PLAIN, P2) - - with NO_SLEEP: - await self.session._run_pokemon_checkup(P1) - - self.assertEqual(BETWEEN_TURNS_CALLS, [p1_active.entity_id]) - - with NO_SLEEP: - await self.session._run_pokemon_checkup(P1) - - self.assertEqual(BETWEEN_TURNS_CALLS, [p1_active.entity_id, p1_active.entity_id]) - - -# ---------------------------------------------------------------------- -# T4: Special-condition checkup completion (poison/burn/sleep/paralysis) -# ---------------------------------------------------------------------- - -class TestPoisonCheckup(TriggerTestBase): - async def test_bracket_order_and_default_damage(self): - active = self.add_to("activePokemonArea", PLAIN, P2) - active.set_attribute(AttrID.SPECIAL_CONDITIONS, ["Poisoned"]) - - with NO_SLEEP: - await self.session._checkup_poison(active) - - self.assertEqual(active.get_attribute(AttrID.HP), 90) - brackets = self.brackets(self.client1, GameSequence.POISON_DAMAGE.value) - self.assertEqual(len(brackets), 1) - names = [m["name"] for m in brackets[0]] - self.assertEqual(names, ["PlaceDamageEffect", "AttributeModified"]) - self.assertEqual(brackets[0][0]["value"]["amount"], 10) - self.assertEqual(brackets[0][0]["value"]["destinationID"], active.entity_id) - - async def test_elevated_poison_counters(self): - active = self.add_to("activePokemonArea", PLAIN, P2) - active.set_attribute(AttrID.SPECIAL_CONDITIONS, ["Poisoned"]) - self.session.poison_counters[active.entity_id] = 3 - - with NO_SLEEP: - await self.session._checkup_poison(active) - - self.assertEqual(active.get_attribute(AttrID.HP), 70) - - async def test_poison_knockout_path(self): - low = self.add_to("activePokemonArea", LOW_HP, P2) - self.add_to("bench", BASIC, P2) - low.set_attribute(AttrID.SPECIAL_CONDITIONS, ["Poisoned"]) - - with NO_SLEEP: - await self.session._checkup_poison(low) - - discard = self.board.find_player_area(P2, "discard") - self.assertIn(low, discard.children) - - -class TestBurnCheckup(TriggerTestBase): - async def test_tick_then_cure_on_heads(self): - active = self.add_to("activePokemonArea", PLAIN, P2) - active.set_attribute(AttrID.SPECIAL_CONDITIONS, ["Burned"]) - - with patch("spirit.game.session.game_session.random.choice", return_value=0), NO_SLEEP: - await self.session._checkup_burn(P2, active) - - self.assertEqual(active.get_attribute(AttrID.HP), 80) - self.assertEqual(active.get_attribute(AttrID.SPECIAL_CONDITIONS), []) - self.assertEqual(len(self.brackets(self.client1, GameSequence.BURN_DAMAGE.value)), 1) - self.assertEqual(len(self.brackets(self.client1, GameSequence.FLIP_FOR_BURN.value)), 1) - self.assertEqual(len(self.brackets(self.client1, GameSequence.REMOVE_SPECIAL_CONDITION.value)), 1) - - async def test_tails_stays_burned(self): - active = self.add_to("activePokemonArea", PLAIN, P2) - active.set_attribute(AttrID.SPECIAL_CONDITIONS, ["Burned"]) - - with patch("spirit.game.session.game_session.random.choice", return_value=1), NO_SLEEP: - await self.session._checkup_burn(P2, active) - - self.assertEqual(active.get_attribute(AttrID.HP), 80) - self.assertEqual(active.get_attribute(AttrID.SPECIAL_CONDITIONS), ["Burned"]) - self.assertEqual(len(self.brackets(self.client1, GameSequence.REMOVE_SPECIAL_CONDITION.value)), 0) - - async def test_poison_and_burn_both_tick(self): - active = self.add_to("activePokemonArea", PLAIN, P2) - active.set_attribute(AttrID.SPECIAL_CONDITIONS, ["Poisoned", "Burned"]) - - with patch("spirit.game.session.game_session.random.choice", return_value=1), NO_SLEEP: - await self.session._checkup_poison(active) - await self.session._checkup_burn(P2, active) - - self.assertEqual(active.get_attribute(AttrID.HP), 70) # 100 - 10 - 20 - - -class TestSleepCheckupUnchanged(TriggerTestBase): - async def test_wake_on_all_heads(self): - active = self.add_to("activePokemonArea", PLAIN, P2) - active.set_attribute(AttrID.SPECIAL_CONDITIONS, ["Asleep"]) - - with patch("spirit.game.session.game_session.random.choice", return_value=0), NO_SLEEP: - await self.session._checkup_sleep(P2, active) - - self.assertEqual(active.get_attribute(AttrID.SPECIAL_CONDITIONS), []) - - async def test_stays_asleep_on_any_tails(self): - active = self.add_to("activePokemonArea", PLAIN, P2) - active.set_attribute(AttrID.SPECIAL_CONDITIONS, ["Asleep"]) - - with patch("spirit.game.session.game_session.random.choice", return_value=1), NO_SLEEP: - await self.session._checkup_sleep(P2, active) - - self.assertEqual(active.get_attribute(AttrID.SPECIAL_CONDITIONS), ["Asleep"]) - - -class TestParalysisCure(TriggerTestBase): - async def test_cures_after_owner_next_turn_not_same_turn(self): - active = self.add_to("activePokemonArea", PLAIN, P1) - active.set_attribute(AttrID.SPECIAL_CONDITIONS, ["Paralyzed"]) - self.session.paralyzed_since[active.entity_id] = self.session.turn_state.turn_number - - with NO_SLEEP: - await self.session._run_pokemon_checkup(P1) - self.assertIn("Paralyzed", active.get_attribute(AttrID.SPECIAL_CONDITIONS)) - - self.session.turn_state.begin_turn(P2) - self.session.turn_state.begin_turn(P1) - with NO_SLEEP: - await self.session._run_pokemon_checkup(P1) - self.assertNotIn("Paralyzed", active.get_attribute(AttrID.SPECIAL_CONDITIONS) or []) - - -# ---------------------------------------------------------------------- -# T5: Confusion attack flip -# ---------------------------------------------------------------------- - -class TestConfusionAttackFlip(TriggerTestBase): - async def test_heads_proceeds_with_normal_attack(self): - attacker = self.add_to("activePokemonArea", CONFUSED_ATTACKER, P1) - defender = self.add_to("activePokemonArea", PLAIN, P2) - attacker.set_attribute(AttrID.SPECIAL_CONDITIONS, ["Confused"]) - entry = {"selectableAction": {"actionID": CONFUSED_ATTACK.ability_id}} - - with patch("spirit.game.session.game_session.random.choice", return_value=0), NO_SLEEP: - turn_over = await self.session._execute_attack(P1, attacker, entry) - - self.assertTrue(turn_over) - self.assertEqual(defender.get_attribute(AttrID.HP), 70) - self.assertEqual(attacker.get_attribute(AttrID.HP), 100) - self.assertEqual(len(self.brackets(self.client1, GameSequence.FLIP_FOR_CONFUSION.value)), 1) - self.assertEqual(len(self.brackets(self.client1, GameSequence.HURT_FROM_CONFUSION.value)), 0) - - async def test_tails_hurts_self_and_ends_turn_without_attacking(self): - attacker = self.add_to("activePokemonArea", CONFUSED_ATTACKER, P1) - defender = self.add_to("activePokemonArea", PLAIN, P2) - attacker.set_attribute(AttrID.SPECIAL_CONDITIONS, ["Confused"]) - entry = {"selectableAction": {"actionID": CONFUSED_ATTACK.ability_id}} - - with patch("spirit.game.session.game_session.random.choice", return_value=1), NO_SLEEP: - turn_over = await self.session._execute_attack(P1, attacker, entry) - - self.assertTrue(turn_over) - self.assertEqual(defender.get_attribute(AttrID.HP), 100) - self.assertEqual(attacker.get_attribute(AttrID.HP), 70) - hurt_brackets = self.brackets(self.client1, GameSequence.HURT_FROM_CONFUSION.value) - self.assertEqual(len(hurt_brackets), 1) - self.assertEqual([m["name"] for m in hurt_brackets[0]], - ["PlaceDamageEffect", "AttributeModified"]) - - async def test_tails_does_not_consume_vstar_or_apply_locks(self): - attacker = self.add_to("activePokemonArea", VSTAR_LOCK_ATTACKER, P1) - self.add_to("activePokemonArea", PLAIN, P2) - attacker.set_attribute(AttrID.SPECIAL_CONDITIONS, ["Confused"]) - entry = {"selectableAction": {"actionID": VSTAR_LOCK_ATTACK.ability_id}} - - with patch("spirit.game.session.game_session.random.choice", return_value=1), NO_SLEEP: - turn_over = await self.session._execute_attack(P1, attacker, entry) - - # A confusion-failed attack never happened: no VSTAR use, no lock. - self.assertTrue(turn_over) - self.assertNotIn(P1, self.session.turn_state.vstar_used) - self.assertEqual(self.session.turn_state.attack_locks, {}) - - async def test_tails_self_knockout_promotes_new_active(self): - attacker = self.add_to("activePokemonArea", LOW_HP_CONFUSED, P1) - bench = self.add_to("bench", BASIC, P1) - self.add_to("activePokemonArea", PLAIN, P2) - attacker.set_attribute(AttrID.SPECIAL_CONDITIONS, ["Confused"]) - entry = {"selectableAction": {"actionID": LOW_HP_CONFUSED_ATTACK.ability_id}} - - with patch("spirit.game.session.game_session.random.choice", return_value=1), NO_SLEEP: - await self.session._execute_attack(P1, attacker, entry) - - discard = self.board.find_player_area(P1, "discard") - self.assertIn(attacker, discard.children) - active_area = self.board.find_player_area(P1, "activePokemonArea") - self.assertEqual(active_area.children, [bench]) - - -# ---------------------------------------------------------------------- -# T6: Condition bookkeeping (mutual exclusivity) -# ---------------------------------------------------------------------- - -class TestMutualExclusivity(TriggerTestBase): - async def test_asleep_confused_paralyzed_replace_each_other(self): - active = self.add_to("activePokemonArea", PLAIN, P2) - ctx = EffectContext(self.session, P1, active, None) - - await ctx.apply_special_condition(active, SpecialConditions.ASLEEP, checkup_coins=2) - self.assertEqual(self.session.sleep_checkup_coins.get(active.entity_id), 2) - - await ctx.apply_special_condition(active, SpecialConditions.PARALYZED) - self.assertEqual(active.get_attribute(AttrID.SPECIAL_CONDITIONS), ["Paralyzed"]) - self.assertNotIn(active.entity_id, self.session.sleep_checkup_coins) - - await ctx.apply_special_condition(active, SpecialConditions.CONFUSED) - self.assertEqual(active.get_attribute(AttrID.SPECIAL_CONDITIONS), ["Confused"]) - self.assertNotIn(active.entity_id, self.session.paralyzed_since) - - async def test_poison_and_burn_stack_with_everything(self): - active = self.add_to("activePokemonArea", PLAIN, P2) - ctx = EffectContext(self.session, P1, active, None) - - await ctx.apply_special_condition(active, SpecialConditions.CONFUSED) - await ctx.apply_special_condition(active, SpecialConditions.POISONED, poison_counters=2) - await ctx.apply_special_condition(active, SpecialConditions.BURNED) - - self.assertEqual( - set(active.get_attribute(AttrID.SPECIAL_CONDITIONS)), - {"Confused", "Poisoned", "Burned"}, - ) - self.assertEqual(self.session.poison_counters.get(active.entity_id), 2) - - -# ---------------------------------------------------------------------- -# T7: condition_attack factory -# ---------------------------------------------------------------------- - -class TestConditionAttackFactory(TriggerTestBase): - async def test_no_flip_applies_condition_and_damage(self): - attacker = self.add_to("activePokemonArea", POISON_ATTACKER, P1) - defender = self.add_to("activePokemonArea", PLAIN, P2) - - await resolve_attack(self.session, P1, attacker, POISON_ATTACK, POISON_ATTACK.ability_id) - - self.assertEqual(defender.get_attribute(AttrID.HP), 90) - self.assertIn("Poisoned", defender.get_attribute(AttrID.SPECIAL_CONDITIONS)) - - async def test_flip_heads_applies_condition(self): - attacker = self.add_to("activePokemonArea", FLIP_PARALYZE_ATTACKER, P1) - defender = self.add_to("activePokemonArea", PLAIN, P2) - - with patch("spirit.game.session.effects.random.choice", return_value=0): - await resolve_attack(self.session, P1, attacker, FLIP_PARALYZE_ATTACK, - FLIP_PARALYZE_ATTACK.ability_id) - - self.assertIn("Paralyzed", defender.get_attribute(AttrID.SPECIAL_CONDITIONS) or []) - - async def test_flip_tails_applies_nothing(self): - attacker = self.add_to("activePokemonArea", FLIP_PARALYZE_ATTACKER, P1) - defender = self.add_to("activePokemonArea", PLAIN, P2) - - with patch("spirit.game.session.effects.random.choice", return_value=1): - await resolve_attack(self.session, P1, attacker, FLIP_PARALYZE_ATTACK, - FLIP_PARALYZE_ATTACK.ability_id) - - self.assertNotIn("Paralyzed", defender.get_attribute(AttrID.SPECIAL_CONDITIONS) or []) - - async def test_multi_condition_and_self_condition(self): - attacker = self.add_to("activePokemonArea", MULTI_CONDITION_ATTACKER, P1) - defender = self.add_to("activePokemonArea", PLAIN, P2) - - await resolve_attack(self.session, P1, attacker, MULTI_CONDITION_ATTACK, - MULTI_CONDITION_ATTACK.ability_id) - - self.assertEqual( - set(defender.get_attribute(AttrID.SPECIAL_CONDITIONS)), - {"Poisoned", "Burned"}, - ) - self.assertEqual(attacker.get_attribute(AttrID.SPECIAL_CONDITIONS), ["Confused"]) - - async def test_blocked_by_effect_shield_but_damage_still_applies(self): - attacker = self.add_to("activePokemonArea", POISON_ATTACKER, P1) - defender = self.add_to("activePokemonArea", SHIELDED_DEFENDER, P2) - - await resolve_attack(self.session, P1, attacker, POISON_ATTACK, POISON_ATTACK.ability_id) - - self.assertEqual(defender.get_attribute(AttrID.HP), 90) # damage isn't an effect - self.assertNotIn("Poisoned", defender.get_attribute(AttrID.SPECIAL_CONDITIONS) or []) - - -# ---------------------------------------------------------------------- -# T8: Effects clear when a Pokemon leaves the Active spot / play -# ---------------------------------------------------------------------- - -class TestRetreatClearsEffects(TriggerTestBase): - async def test_retreat_clears_conditions_and_broadcasts(self): - active = self.add_to("activePokemonArea", BASIC, P1) - bench = self.add_to("bench", BASIC, P1) - # Asleep/Paralyzed would themselves block the retreat OFFER (legal_actions' - # immobilization gate, tested elsewhere) -- build the entry directly to - # exercise the executor's cleanup regardless of the condition applied. - active.set_attribute(AttrID.SPECIAL_CONDITIONS, ["Poisoned", "Asleep"]) - self.session.poison_counters[active.entity_id] = 2 - self.session.sleep_checkup_coins[active.entity_id] = 2 - - entry = {"targetInfoLst": [ - {"name": SelectionKind.RETREAT_NEW_ACTIVE.value, "validTargets": [bench.entity_id]}, - ]} - await self.session._execute_retreat(P1, active, entry, [bench.entity_id]) - - self.assertEqual(active.get_attribute(AttrID.SPECIAL_CONDITIONS), []) - self.assertNotIn(active.entity_id, self.session.poison_counters) - self.assertNotIn(active.entity_id, self.session.sleep_checkup_coins) - cure_brackets = self.brackets(self.client1, GameSequence.REMOVE_SPECIAL_CONDITION.value) - self.assertEqual(len(cure_brackets), 1) - self.assertEqual([m["name"] for m in cure_brackets[0]], - ["EntityIDDataEffect", "AttributeModified"]) - - async def test_locked_attack_survives_retreat_and_repromotion(self): - attacker = self.add_to("activePokemonArea", LOCK_ATTACKER, P1) - bench = self.add_to("bench", BASIC, P1) - self.session.turn_state.lock_attack(attacker.entity_id, LOCK_ATTACK.ability_id) - self.assertTrue(self.session.turn_state.attack_locked(attacker.entity_id, LOCK_ATTACK.ability_id)) - - entries = compute_legal_actions(self.board, self.session.turn_state, P1, self.session.game_id) - entry = next(e for e in entries if e["selectableAction"]["description"] == ACTION_RETREAT) - await self.session._execute_retreat(P1, attacker, entry, [bench.entity_id]) - - self.assertFalse(self.session.turn_state.attack_locked(attacker.entity_id, LOCK_ATTACK.ability_id)) - - -class TestSwitchActiveClearsEffects(TriggerTestBase): - async def test_switch_active_drops_attack_locks_and_still_cures(self): - old_active = self.add_to("activePokemonArea", PLAIN, P1) - new_active = self.add_to("bench", BASIC, P1) - old_active.set_attribute(AttrID.SPECIAL_CONDITIONS, ["Asleep"]) - self.session.sleep_checkup_coins[old_active.entity_id] = 2 - self.session.turn_state.lock_attack(old_active.entity_id, "some-ability-id") - - ctx = EffectContext(self.session, P1, old_active, None) - result = await ctx.switch_active(P1, new_active) - - self.assertTrue(result) - self.assertEqual(old_active.get_attribute(AttrID.SPECIAL_CONDITIONS), []) - self.assertNotIn(old_active.entity_id, self.session.sleep_checkup_coins) - self.assertFalse(self.session.turn_state.attack_locked(old_active.entity_id, "some-ability-id")) - # Choreography is unchanged: the cure rides ctx._messages same as before. - names = [m["name"] for _, m, bracket in ctx._messages - if bracket == GameSequence.REMOVE_SPECIAL_CONDITION.value] - self.assertEqual(names, ["EntityIDDataEffect", "AttributeModified"]) - - -class TestLeavePlayClearsEffects(TriggerTestBase): - async def test_shuffle_into_deck_clears_state_silently(self): - pokemon = self.add_to("bench", PLAIN, P1) - pokemon.set_attribute(AttrID.SPECIAL_CONDITIONS, ["Poisoned"]) - self.session.poison_counters[pokemon.entity_id] = 3 - ctx = EffectContext(self.session, P1, pokemon, None) - - await ctx.shuffle_into_deck([pokemon], P1) - - self.assertEqual(pokemon.get_attribute(AttrID.SPECIAL_CONDITIONS), []) - self.assertNotIn(pokemon.entity_id, self.session.poison_counters) - cure_msgs = [m for _, m, bracket in ctx._messages - if bracket == GameSequence.REMOVE_SPECIAL_CONDITION.value] - self.assertEqual(cure_msgs, []) - - -if __name__ == "__main__": - unittest.main() diff --git a/spirit/tests/test_versus.py b/spirit/tests/test_versus.py deleted file mode 100644 index c1c2020..0000000 --- a/spirit/tests/test_versus.py +++ /dev/null @@ -1,96 +0,0 @@ -import unittest -import os -import tempfile -import json -from spirit.game.models.versus import Reward, VersusTier, VersusSeason -from spirit.game.season_manager import VersusSeasonManager - -class TestVersusModels(unittest.TestCase): - def test_reward_serialization(self): - data = { - "name": "5 Tokens", - "rewardType": "Tokens", - "rewardAmount": 5, - "rewardCurrency": "prizeTrainerCoin" - } - reward = Reward.from_dict(data) - self.assertEqual(reward.name, "5 Tokens") - self.assertEqual(reward.reward_type, "Tokens") - self.assertEqual(reward.reward_amount, 5) - self.assertEqual(reward.reward_currency, "prizeTrainerCoin") - - serialized = reward.to_dict() - self.assertEqual(serialized["name"], "5 Tokens") - self.assertEqual(serialized["rewardType"], "Tokens") - self.assertEqual(serialized["rewardAmount"], 5) - self.assertEqual(serialized["rewardCurrency"], "prizeTrainerCoin") - - def test_tier_serialization(self): - data = { - "rewards": { - "10": [ - { - "name": "5 Tokens", - "rewardType": "Tokens", - "rewardAmount": 5 - } - ] - } - } - tier = VersusTier.from_dict(data) - self.assertIn(10, tier.rewards) - self.assertEqual(len(tier.rewards[10]), 1) - self.assertEqual(tier.rewards[10][0].name, "5 Tokens") - - serialized = tier.to_dict() - self.assertIn("10", serialized["rewards"]) - self.assertEqual(serialized["rewards"]["10"][0]["name"], "5 Tokens") - - def test_season_serialization(self): - data = { - "seasonID": "Season1", - "startTime": 1000, - "endTime": 5000, - "description": {"id": "SpiritPTCGO Season"}, - "tiers": [ - { - "rewards": { - "10": [ - { - "name": "5 Tokens", - "rewardType": "Tokens", - "rewardAmount": 5 - } - ] - } - } - ], - "resetRewardID": "Reset1" - } - season = VersusSeason.from_dict(data) - self.assertEqual(season.season_id, "Season1") - self.assertEqual(season.start_time, 1000) - self.assertEqual(season.end_time, 5000) - self.assertEqual(season.reset_reward_id, "Reset1") - self.assertEqual(len(season.tiers), 1) - - serialized = season.to_dict() - self.assertEqual(serialized["seasonID"], "Season1") - self.assertEqual(serialized["tiers"][0]["rewards"]["10"][0]["name"], "5 Tokens") - - -class TestVersusSeasonManager(unittest.TestCase): - def test_manager_singleton_and_loading(self): - # Trigger initialization of singleton - manager = VersusSeasonManager() - self.assertIsNotNone(manager) - - # Verify active season returns either a season or None, without raising exceptions - active = manager.get_active_season() - # Since we just added versus_seasons.json with start_time=0 and end_time=4102444800000, - # it should load and active should be Season1. - if active is not None: - self.assertEqual(active.season_id, "Season1") - -if __name__ == "__main__": - unittest.main() diff --git a/spirit/tests/test_versus_progress.py b/spirit/tests/test_versus_progress.py deleted file mode 100644 index 14a222b..0000000 --- a/spirit/tests/test_versus_progress.py +++ /dev/null @@ -1,151 +0,0 @@ -import os -import tempfile -import unittest -from unittest.mock import patch - -from spirit.game.models.versus import VersusSeason - - -def _season(season_id="TestSeason"): - return VersusSeason.from_dict({ - "seasonID": season_id, - "startTime": 0, - "endTime": 4102444800000, - "description": {"id": "Test"}, - "tiers": [ - {"rewards": { - "10": [ - {"name": "5 Tokens", "rewardType": "Tokens", - "rewardAmount": 5, "rewardCurrency": "prizeTrainerCoin"}, - {"name": "Reward Card", "rewardType": "Archetype", - "rewardAmount": 2, "rewardProductID": "aaaa-bbbb"} - ], - "50": [ - {"name": "20 Tokens", "rewardType": "Tokens", - "rewardAmount": 20, "rewardCurrency": "prizeTrainerCoin"} - ] - }}, - ], - "resetRewardID": "" - }) - - -class TestVersusProgress(unittest.TestCase): - @classmethod - def setUpClass(cls): - cls.db_fd, cls.db_path = tempfile.mkstemp() - cls.db_patcher = patch('spirit.database.connection.DB_PATH', cls.db_path) - cls.db_patcher.start() - from spirit.database.setup_db import setup_database - setup_database() - - @classmethod - def tearDownClass(cls): - cls.db_patcher.stop() - try: - os.close(cls.db_fd) - os.unlink(cls.db_path) - except OSError: - pass - - def _new_account(self, name): - from spirit.database.accounts import create_account - return create_account(name, "pw123")["account_id"] - - def test_threshold_grants_once(self): - from spirit.database import versus_data - from spirit.database.player_data import get_wallet_by_account_id, get_collection_by_account_id - - account_id = self._new_account("versus_tester") - season = _season() - coins_before = get_wallet_by_account_id(account_id)["coins"] - - r = versus_data.award_match_points(account_id, True, season=season) - self.assertEqual(r["points"], versus_data.VERSUS_POINTS_PER_WIN) - self.assertEqual(r["granted"], []) - - # cross the 10-point tier - while r["points"] < 10: - r = versus_data.award_match_points(account_id, True, season=season) - self.assertEqual(len(r["granted"]), 2) - - wallet = get_wallet_by_account_id(account_id) - self.assertEqual(wallet["coins"], coins_before + 5) - collection = {c["archetype_id"]: c for c in get_collection_by_account_id(account_id)} - self.assertEqual(collection["aaaa-bbbb"]["nontradable_count"], 2) - - # further matches do not re-grant the crossed tier - r = versus_data.award_match_points(account_id, False, season=season) - self.assertEqual(r["granted"], []) - wallet = get_wallet_by_account_id(account_id) - self.assertEqual(wallet["coins"], coins_before + 5) - - def test_season_change_resets_points_keeps_all_time(self): - from spirit.database import versus_data - - account_id = self._new_account("versus_resetter") - r = versus_data.award_match_points(account_id, True, season=_season("SeasonA")) - self.assertEqual(r["points"], 5) - r = versus_data.award_match_points(account_id, True, season=_season("SeasonB")) - self.assertEqual(r["points"], 5) # reset by the new season - self.assertEqual(r["all_time_points"], 10) - - points, all_time = versus_data.get_progress(account_id, "SeasonB") - self.assertEqual((points, all_time), (5, 10)) - points, _ = versus_data.get_progress(account_id, "SeasonC") - self.assertEqual(points, 0) - - def test_account_attributes_carry_season_points(self): - from spirit.database import versus_data - from spirit.game.account_attributes import build_account_attributes - from spirit.game.attributes import AttrID - from spirit.game.season_manager import VersusSeasonManager - - account_id = self._new_account("versus_attrs") - active = VersusSeasonManager().get_active_season() - self.assertIsNotNone(active) - versus_data.award_match_points(account_id, True, season=active) - - attrs = {a["name"]: a["value"] for a in build_account_attributes(account_id)} - self.assertEqual(attrs[AttrID.SEASON_POINTS.value], versus_data.VERSUS_POINTS_PER_WIN) - self.assertEqual(attrs[AttrID.ALL_TIME_SEASON_POINTS.value], versus_data.VERSUS_POINTS_PER_WIN) - self.assertIn(AttrID.FRIEND_CHAT_MODE.value, attrs) - - -class TestVersusSeasonWireShape(unittest.TestCase): - """The client chevron renderers get_Item reward[0]'s ArchetypeID unguarded - and sort rewards by the "index" field — the wire must order cards first, - products next, tokens (null product) last.""" - - def test_reward_ordering_null_tokens_and_indexes(self): - from spirit.game.scripts.cards import loader as card_loader - card_guid = card_loader.load_all()[0].guid - season = VersusSeason.from_dict({ - "seasonID": "Wire", "startTime": 0, "endTime": 1, "description": {"id": "x"}, - "tiers": [{"rewards": {"10": [ - {"name": "5 Tokens", "rewardType": "Tokens", "rewardAmount": 5, - "rewardProductID": "00000000-0000-0000-0000-000000000000"}, - {"name": "A Pack", "rewardType": "Archetype", "rewardAmount": 1, - "rewardProductID": "d017c195-0000-0000-0000-000000000001"}, - {"name": "A Card", "rewardType": "Archetype", "rewardAmount": 1, - "rewardProductID": card_guid}, - ]}}] - }) - rewards = season.to_dict()["tiers"][0]["rewards"]["10"] - self.assertEqual([r["index"] for r in rewards], [0, 1, 2]) - self.assertEqual(rewards[0]["rewardProductID"].lower(), card_guid.lower()) - self.assertEqual(rewards[1]["rewardProductID"], - "d017c195-0000-0000-0000-000000000001") - self.assertEqual(rewards[2]["rewardType"], "Tokens") - # zero GUID must become null: ArchetypeID(Guid.Empty) is non-null - # client-side and misses the archetype cache (KeyNotFoundException) - self.assertIsNone(rewards[2]["rewardProductID"]) - - def test_tokens_without_product_id_serialize_null(self): - from spirit.game.models.versus import Reward - r = Reward(name="5 Tokens", reward_type="Tokens", reward_amount=5) - self.assertIsNone(r.to_dict()["rewardProductID"]) - - -if __name__ == "__main__": - unittest.main() diff --git a/spirit/tests/test_weakness_resistance.py b/spirit/tests/test_weakness_resistance.py deleted file mode 100644 index cc3f73a..0000000 --- a/spirit/tests/test_weakness_resistance.py +++ /dev/null @@ -1,130 +0,0 @@ -import unittest -import json -from spirit.game.attributes import AttrID, PokemonTypes, CardType, PokemonStage -from spirit.game.data_utils import PokemonCardDef -from spirit.game.models.card import PokemonCard - -class TestWeaknessResistance(unittest.TestCase): - def test_pokemon_card_def_serializes_weakness_resistance_operators_and_amounts(self): - # 1. Define a Pokemon with weakness and resistance - card_def = PokemonCardDef( - guid="test-pokemon-guid", - key="BW1", - name="com.direwolfdigital.cake.data.archetypes.pokemon.Watchog.Name", - collector_number=79, - set_code="BW1", - rarity=1, - hp=90, - elements=[PokemonTypes.COLORLESS], - stage=PokemonStage.STAGE1, - retreat_cost=1, - weakness_type=PokemonTypes.FIGHTING, - weakness_amount=2, - resistance_type=PokemonTypes.PSYCHIC, - resistance_amount=30 - ) - - archetype = card_def.to_archetype_dict() - attrs = archetype["attributes"] - - # Verify weakness attributes are serialized correctly - self.assertEqual(attrs[str(AttrID.WEAKNESS_TYPES.value)]["value"], f"[{PokemonTypes.FIGHTING.value}]") - self.assertEqual(attrs[str(AttrID.WEAKNESS_OPERATOR.value)]["value"], "x") - self.assertEqual(attrs[str(AttrID.WEAKNESS_AMOUNT.value)]["value"], 2) - - # Verify resistance attributes are serialized correctly - self.assertEqual(attrs[str(AttrID.RESISTANCE_TYPES.value)]["value"], PokemonTypes.PSYCHIC.value) - self.assertEqual(attrs[str(AttrID.RESISTANCE_OPERATOR.value)]["value"], "-") - self.assertEqual(attrs[str(AttrID.RESISTANCE_AMOUNT.value)]["value"], 30) - - def test_pokemon_card_model_dynamic_operator_and_amount_fallbacks(self): - # 1. Set up attributes representing a loaded Pokemon card with weakness/resistance but missing operators/amounts - attributes = { - str(AttrID.CARD_TYPE.value): {"type": "int", "value": CardType.POKEMON.value}, - str(AttrID.HP.value): {"type": "int", "value": 90}, - str(AttrID.STAGE.value): {"type": "int", "value": PokemonStage.STAGE1.value}, - str(AttrID.POKEMON_TYPES.value): {"type": "json", "value": json.dumps([PokemonTypes.COLORLESS.value])}, - str(AttrID.RETREAT_COST.value): {"type": "int", "value": 1}, - str(AttrID.WEAKNESS_TYPES.value): {"type": "json", "value": json.dumps([PokemonTypes.FIGHTING.value])}, - str(AttrID.RESISTANCE_TYPES.value): {"type": "int", "value": PokemonTypes.PSYCHIC.value}, - } - - pokemon_card = PokemonCard(guid="test-dynamic-guid", key="BW1", attributes=attributes) - - # Convert to archetype attributes - final_attrs = pokemon_card.to_archetype_attributes(download_key="BW1") - - # Verify that weakness and resistance operators and amounts were dynamically added as fallbacks - self.assertEqual(final_attrs[str(AttrID.WEAKNESS_OPERATOR.value)]["value"], "x") - self.assertEqual(final_attrs[str(AttrID.WEAKNESS_AMOUNT.value)]["value"], 2) - self.assertEqual(final_attrs[str(AttrID.RESISTANCE_OPERATOR.value)]["value"], "-") - self.assertEqual(final_attrs[str(AttrID.RESISTANCE_AMOUNT.value)]["value"], 30) - - def test_pokemon_card_model_does_not_add_fallbacks_when_unset(self): - # 1. Set up a Pokemon with NO weakness and NO resistance - attributes = { - str(AttrID.CARD_TYPE.value): {"type": "int", "value": CardType.POKEMON.value}, - str(AttrID.HP.value): {"type": "int", "value": 90}, - str(AttrID.STAGE.value): {"type": "int", "value": PokemonStage.BASIC.value}, - str(AttrID.POKEMON_TYPES.value): {"type": "json", "value": json.dumps([PokemonTypes.COLORLESS.value])}, - str(AttrID.RETREAT_COST.value): {"type": "int", "value": 1}, - str(AttrID.WEAKNESS_TYPES.value): {"type": "json", "value": "[]"}, - str(AttrID.RESISTANCE_TYPES.value): {"type": "int", "value": PokemonTypes.UNSET.value}, - } - - pokemon_card = PokemonCard(guid="test-dynamic-guid", key="BW1", attributes=attributes) - - final_attrs = pokemon_card.to_archetype_attributes(download_key="BW1") - - # Verify operators and amounts are NOT added when weakness or resistance are unset - self.assertNotIn(str(AttrID.WEAKNESS_OPERATOR.value), final_attrs) - self.assertNotIn(str(AttrID.WEAKNESS_AMOUNT.value), final_attrs) - self.assertNotIn(str(AttrID.RESISTANCE_OPERATOR.value), final_attrs) - self.assertNotIn(str(AttrID.RESISTANCE_AMOUNT.value), final_attrs) - - def test_evolution_logic_name_always_populated_on_non_pokemon_defs(self): - from spirit.game.data_utils import ItemCardDef, EnergyCardDef - - # 1. Test Trainer Def - trainer_def = ItemCardDef( - guid="test-trainer-guid", - key="BW1", - name="com.direwolfdigital.cake.data.archetypes.trainer.Potion.Name", - collector_number=100, - set_code="BW1", - rarity=0 - ) - trainer_arch = trainer_def.to_archetype_dict() - self.assertEqual(trainer_arch["attributes"][str(AttrID.EVOLUTION_LOGIC_NAME.value)]["value"], "Potion") - - # 2. Test Energy Def - energy_def = EnergyCardDef( - guid="test-energy-guid", - key="BW1", - name="com.direwolfdigital.cake.data.archetypes.energy.DoubleColorless.Name", - collector_number=101, - set_code="BW1", - rarity=0, - energy_type=PokemonTypes.COLORLESS, - is_special=True - ) - energy_arch = energy_def.to_archetype_dict() - self.assertEqual(energy_arch["attributes"][str(AttrID.EVOLUTION_LOGIC_NAME.value)]["value"], "DoubleColorless") - - def test_evolution_logic_name_always_populated_on_non_pokemon_models(self): - from spirit.game.models.card import Card - - # Setup loaded attributes without EVOLUTION_LOGIC_NAME - attributes = { - str(AttrID.CARD_TYPE.value): {"type": "int", "value": CardType.ENERGY.value}, - str(AttrID.NAME.value): {"type": "json", "value": json.dumps({"id": "DoubleColorless"})}, - } - card = Card(guid="test-energy-model", key="BW1", attributes=attributes) - final_attrs = card.to_archetype_attributes(download_key="BW1") - - # Verify it got added dynamically - self.assertIn(str(AttrID.EVOLUTION_LOGIC_NAME.value), final_attrs) - self.assertEqual(final_attrs[str(AttrID.EVOLUTION_LOGIC_NAME.value)]["value"], "DoubleColorless") - -if __name__ == "__main__": - unittest.main()