FEAT: Add github issue templates

This commit is contained in:
Brandon Nguyen
2026-07-06 17:51:23 -07:00
parent 34652dbe40
commit d15773b625
40 changed files with 52 additions and 12602 deletions

32
.github/ISSUE_TEMPLATE/bug-report.md vendored Normal file
View File

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

20
.github/ISSUE_TEMPLATE/suggestion.md vendored Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -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, "<generated>", "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()

View File

@@ -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<PokemonTypes,int> 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()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<a.g>(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()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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