mirror of
https://github.com/Bratah123/Spirit-PTCGO.git
synced 2026-09-08 20:55:40 -05:00
FEAT: Add github issue templates
This commit is contained in:
32
.github/ISSUE_TEMPLATE/bug-report.md
vendored
Normal file
32
.github/ISSUE_TEMPLATE/bug-report.md
vendored
Normal 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
20
.github/ISSUE_TEMPLATE/suggestion.md
vendored
Normal 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.
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user