🐐
This commit is contained in:
0
backend/game/__init__.py
Normal file
0
backend/game/__init__.py
Normal file
611
backend/game/card.py
Normal file
611
backend/game/card.py
Normal file
@@ -0,0 +1,611 @@
|
||||
import logging
|
||||
from math import sqrt, cbrt
|
||||
from enum import Enum
|
||||
from typing import NamedTuple
|
||||
from urllib.parse import quote
|
||||
from datetime import datetime, timedelta
|
||||
from time import sleep
|
||||
|
||||
from core.config import WIKIRANK_USER_AGENT
|
||||
HEADERS = {"User-Agent": WIKIRANK_USER_AGENT}
|
||||
|
||||
logger = logging.getLogger("app")
|
||||
|
||||
class CardType(Enum):
|
||||
other = 0
|
||||
person = 1
|
||||
location = 2
|
||||
artwork = 3
|
||||
life_form = 4
|
||||
event = 5
|
||||
group = 6
|
||||
science_thing = 7
|
||||
vehicle = 8
|
||||
organization = 9
|
||||
|
||||
class CardRarity(Enum):
|
||||
common = 0
|
||||
uncommon = 1
|
||||
rare = 2
|
||||
super_rare = 3
|
||||
epic = 4
|
||||
legendary = 5
|
||||
|
||||
class Card(NamedTuple):
|
||||
name: str
|
||||
generated_at: datetime
|
||||
image_link: str
|
||||
card_rarity: CardRarity
|
||||
card_type: CardType
|
||||
wikidata_instance: str
|
||||
text: str
|
||||
attack: int
|
||||
defense: int
|
||||
cost: int
|
||||
|
||||
def __str__(self) -> str:
|
||||
return_string = "┏"+"━"*50+"┓\n"
|
||||
return_string += "┃"+f"{self.name:{' '}<50}"+"┃\n"
|
||||
return_string += "┠"+"─"*50+"┨\n"
|
||||
card_type = "Type: "+self.card_type.name
|
||||
if self.card_type == CardType.other:
|
||||
card_type += f" ({self.wikidata_instance})"
|
||||
return_string += "┃"+f"{card_type:{' '}<50}"+"┃\n"
|
||||
return_string += "┠"+"─"*50+"┨\n"
|
||||
rarity_text = f"Rarity: {self.card_rarity.name}"
|
||||
return_string += "┃"+f"{rarity_text:<50}"+"┃\n"
|
||||
return_string += "┠"+"─"*50+"┨\n"
|
||||
link = "Image: "+self.image_link
|
||||
return_string += "┃"+f"{link:{' '}<50}"+"┃\n"
|
||||
return_string += "┠"+"─"*50+"┨\n"
|
||||
return_string += "┃"+f"{'*'*self.cost:{' '}<50}"+"┃\n"
|
||||
return_string += "┠"+"─"*50+"┨\n"
|
||||
lines = []
|
||||
words = self.text.split(" ")
|
||||
current_line = ""
|
||||
for w in words:
|
||||
if len(current_line + " " + w) < 50:
|
||||
current_line += " " + w
|
||||
elif len(current_line) < 40 and len(w) > 10:
|
||||
new_len = 48 - len(current_line)
|
||||
current_line += " " + w[:new_len] + "-"
|
||||
lines.append(current_line)
|
||||
current_line = w[new_len:]
|
||||
else:
|
||||
lines.append(current_line)
|
||||
current_line = w
|
||||
|
||||
lines.append(current_line)
|
||||
|
||||
for l in lines :
|
||||
return_string += "┃"+f"{l:{' '}<50}"+"┃\n"
|
||||
return_string += "┠"+"─"*50+"┨\n"
|
||||
|
||||
date_text = str(self.generated_at.date())
|
||||
stats = f"{self.attack}/{self.defense}"
|
||||
spaces = 50 - (len(date_text) + len(stats))
|
||||
return_string += "┃"+date_text + " "*spaces + stats + "┃\n"
|
||||
return_string += "┗"+"━"*50+"┛"
|
||||
return return_string
|
||||
|
||||
WIKIDATA_INSTANCE_TYPE_MAP = {
|
||||
"Q5": CardType.person, # human
|
||||
"Q95074": CardType.person, # character
|
||||
"Q215627": CardType.person, # person
|
||||
"Q15632617": CardType.person, # fictional human
|
||||
"Q22988604": CardType.person, # fictional human
|
||||
|
||||
"Q1004": CardType.artwork, # comics
|
||||
"Q7889": CardType.artwork, # video game
|
||||
"Q11424": CardType.artwork, # film
|
||||
"Q11410": CardType.artwork, # game
|
||||
"Q15416": CardType.artwork, # television program
|
||||
"Q24862": CardType.artwork, # short film
|
||||
"Q11032": CardType.artwork, # newspaper
|
||||
"Q25379": CardType.artwork, # play
|
||||
"Q41298": CardType.artwork, # magazine
|
||||
"Q482994": CardType.artwork, # album
|
||||
"Q134556": CardType.artwork, # single
|
||||
"Q169930": CardType.artwork, # EP
|
||||
"Q196600": CardType.artwork, # media franchise
|
||||
"Q202866": CardType.artwork, # animated film
|
||||
"Q277759": CardType.artwork, # book series
|
||||
"Q734698": CardType.artwork, # collectible card game
|
||||
"Q506240": CardType.artwork, # television film
|
||||
"Q738377": CardType.artwork, # student newspaper
|
||||
"Q2031291": CardType.artwork, # musical release
|
||||
"Q1259759": CardType.artwork, # miniseries
|
||||
"Q3305213": CardType.artwork, # painting
|
||||
"Q3177859": CardType.artwork, # dedicated deck card game
|
||||
"Q5398426": CardType.artwork, # television series
|
||||
"Q7725634": CardType.artwork, # literary work
|
||||
"Q1761818": CardType.artwork, # advertising campaign
|
||||
"Q1446621": CardType.artwork, # recital
|
||||
"Q1868552": CardType.artwork, # local newspaper
|
||||
"Q3244175": CardType.artwork, # tabletop game
|
||||
"Q2031291": CardType.artwork, # musical release
|
||||
"Q63952888": CardType.artwork, # anime television series
|
||||
"Q47461344": CardType.artwork, # written work
|
||||
"Q71631512": CardType.artwork, # tabletop role-playing game supplement
|
||||
"Q21198342": CardType.artwork, # manga series
|
||||
"Q58483083": CardType.artwork, # dramatico-musical work
|
||||
"Q24634210": CardType.artwork, # podcast show
|
||||
"Q105543609": CardType.artwork, # musical work / composition
|
||||
"Q106499608": CardType.artwork, # literary reading
|
||||
"Q117467246": CardType.artwork, # animated television series
|
||||
"Q106042566": CardType.artwork, # single album
|
||||
|
||||
"Q515": CardType.location, # city
|
||||
"Q8502": CardType.location, # mountain
|
||||
"Q4022": CardType.location, # river
|
||||
"Q6256": CardType.location, # country
|
||||
"Q15284": CardType.location, # municipality
|
||||
"Q27686": CardType.location, # hotel
|
||||
"Q41176": CardType.location, # building
|
||||
"Q23442": CardType.location, # island
|
||||
"Q82794": CardType.location, # geographic region
|
||||
"Q34442": CardType.location, # road
|
||||
"Q486972": CardType.location, # human settlement
|
||||
"Q192611": CardType.location, # electoral unit
|
||||
"Q398141": CardType.location, # school district
|
||||
"Q133056": CardType.location, # mountain pass
|
||||
"Q3624078": CardType.location, # sovereign state
|
||||
"Q1093829": CardType.location, # city in the United States
|
||||
"Q7930989": CardType.location, # city/town
|
||||
"Q1250464": CardType.location, # realm
|
||||
"Q3146899": CardType.location, # diocese of the catholic church
|
||||
"Q17350442": CardType.location, # venue
|
||||
"Q23764314": CardType.location, # sports location
|
||||
"Q12076836": CardType.location, # administrative territorial entity of a single country
|
||||
"Q35145263": CardType.location, # natural geographic object
|
||||
"Q15642541": CardType.location, # human-geographic territorial entity
|
||||
|
||||
"Q16521": CardType.life_form, # taxon
|
||||
"Q38829": CardType.life_form, # breed
|
||||
"Q310890": CardType.life_form, # monotypic taxon
|
||||
"Q23038290": CardType.life_form, # fossil taxon
|
||||
"Q12045585": CardType.life_form, # cattle breed
|
||||
|
||||
"Q198": CardType.event, # war
|
||||
"Q8465": CardType.event, # civil war
|
||||
"Q844482": CardType.event, # killing
|
||||
"Q141022": CardType.event, # eclipse
|
||||
"Q103495": CardType.event, # world war
|
||||
"Q350604": CardType.event, # armed conflict
|
||||
"Q217327": CardType.event, # suicide attack
|
||||
"Q750215": CardType.event, # mass murder
|
||||
"Q898712": CardType.event, # aircraft hijacking
|
||||
"Q997267": CardType.event, # skirmish
|
||||
"Q178561": CardType.event, # battle
|
||||
"Q273120": CardType.event, # protest
|
||||
"Q1190554": CardType.event, # occurrence
|
||||
"Q1361229": CardType.event, # conquest
|
||||
"Q2223653": CardType.event, # terrorist attack
|
||||
"Q2672648": CardType.event, # social conflict
|
||||
"Q2627975": CardType.event, # ceremony"
|
||||
"Q16510064": CardType.event, # sporting event
|
||||
"Q10688145": CardType.event, # season
|
||||
"Q13418847": CardType.event, # historical event
|
||||
"Q13406554": CardType.event, # sports competition
|
||||
"Q15275719": CardType.event, # recurring event
|
||||
"Q27968055": CardType.event, # recurring event edition
|
||||
"Q15091377": CardType.event, # cycling race
|
||||
"Q87267404": CardType.event, # formula race
|
||||
"Q114609228": CardType.event, # recurring sporting event
|
||||
|
||||
"Q7278": CardType.group, # political party
|
||||
"Q476028": CardType.group, # association football club
|
||||
"Q732717": CardType.group, # law enforcement agency
|
||||
"Q215380": CardType.group, # musical group
|
||||
"Q176799": CardType.group, # military unit
|
||||
"Q178790": CardType.group, # labor union
|
||||
"Q851990": CardType.group, # people group
|
||||
"Q2367225": CardType.group, # university and college sports club
|
||||
"Q4801149": CardType.group, # artillery brigade
|
||||
"Q9248092": CardType.group, # infantry division
|
||||
"Q7210356": CardType.group, # political organization
|
||||
"Q5419137": CardType.group, # veterans' organization
|
||||
"Q6979593": CardType.group, # national association football team
|
||||
"Q1194951": CardType.group, # national sports team
|
||||
"Q1539532": CardType.group, # sports season of a sports club
|
||||
"Q13393265": CardType.group, # basketball team
|
||||
"Q17148672": CardType.group, # social club
|
||||
"Q12973014": CardType.group, # sports team
|
||||
"Q11446438": CardType.group, # female idol group
|
||||
"Q10517054": CardType.group, # handball team
|
||||
"Q135408445": CardType.group, # men's national association football team
|
||||
"Q120143756": CardType.group, # division
|
||||
"Q134601727": CardType.group, # group of persons
|
||||
"Q127334927": CardType.group, # band
|
||||
|
||||
"Q523": CardType.science_thing, # star
|
||||
"Q318": CardType.science_thing, # galaxy
|
||||
"Q6999": CardType.science_thing, # astronomical object
|
||||
"Q7187": CardType.science_thing, # gene
|
||||
"Q8054": CardType.science_thing, # protein
|
||||
"Q12136": CardType.science_thing, # disease
|
||||
"Q65943": CardType.science_thing, # theorem
|
||||
"Q12140": CardType.science_thing, # medication
|
||||
"Q11276": CardType.science_thing, # globular cluster
|
||||
"Q83373": CardType.science_thing, # quasar
|
||||
"Q177719": CardType.science_thing, # medical diagnosis
|
||||
"Q898273": CardType.science_thing, # protein domain
|
||||
"Q134808": CardType.science_thing, # vaccine
|
||||
"Q168845": CardType.science_thing, # star cluster
|
||||
"Q1491746": CardType.science_thing, # galaxy group
|
||||
"Q2465832": CardType.science_thing, # branch of science
|
||||
"Q1341811": CardType.science_thing, # astronomical maser
|
||||
"Q1840368": CardType.science_thing, # cloud type
|
||||
"Q2154519": CardType.science_thing, # astrophysical x-ray source
|
||||
"Q3132741": CardType.science_thing, # substellar object
|
||||
"Q15636229": CardType.science_thing, # surgery procedure
|
||||
"Q11862829": CardType.science_thing, # academic discipline
|
||||
"Q78088984": CardType.science_thing, # study type
|
||||
"Q17444909": CardType.science_thing, # astronomical object type
|
||||
"Q24034552": CardType.science_thing, # mathematical concept
|
||||
"Q12089225": CardType.science_thing, # mineral species
|
||||
"Q55640599": CardType.science_thing, # group of chemical entities
|
||||
"Q17339814": CardType.science_thing, # group or class of chemical substances
|
||||
"Q119459661": CardType.science_thing, # scientific activity
|
||||
"Q113145171": CardType.science_thing, # type of chemical entity
|
||||
|
||||
"Q1420": CardType.vehicle, # car
|
||||
"Q42889": CardType.vehicle, # vehicle
|
||||
"Q11446": CardType.vehicle, # ship
|
||||
"Q43193": CardType.vehicle, # truck
|
||||
"Q25956": CardType.vehicle, # space station
|
||||
"Q39804": CardType.vehicle, # cruise ship
|
||||
"Q170483": CardType.vehicle, # sailing ship
|
||||
"Q964162": CardType.vehicle, # express train
|
||||
"Q848944": CardType.vehicle, # merchant vessel
|
||||
"Q189418": CardType.vehicle, # brigantine
|
||||
"Q281019": CardType.vehicle, # ghost ship
|
||||
"Q811704": CardType.vehicle, # rolling stock class
|
||||
"Q673687": CardType.vehicle, # racing automobile
|
||||
"Q174736": CardType.vehicle, # destroyer
|
||||
"Q484000": CardType.vehicle, # unmanned aerial vehicle
|
||||
"Q559026": CardType.vehicle, # ship class
|
||||
"Q830335": CardType.vehicle, # protected cruiser
|
||||
"Q928235": CardType.vehicle, # sloop-of-war
|
||||
"Q391022": CardType.vehicle, # research vessel
|
||||
"Q202527": CardType.vehicle, # minesweeper
|
||||
"Q1229765": CardType.vehicle, # watercraft
|
||||
"Q2031121": CardType.vehicle, # warship
|
||||
"Q1185562": CardType.vehicle, # light aircraft carrier
|
||||
"Q7233751": CardType.vehicle, # post ship
|
||||
"Q3231690": CardType.vehicle, # automobile model
|
||||
"Q1428357": CardType.vehicle, # submarine class
|
||||
"Q1499623": CardType.vehicle, # destroyer escort
|
||||
"Q4818021": CardType.vehicle, # attack submarine
|
||||
"Q45296117": CardType.vehicle, # aircraft type
|
||||
"Q15141321": CardType.vehicle, # train service
|
||||
"Q19832486": CardType.vehicle, # locomotive class
|
||||
"Q23866334": CardType.vehicle, # motorcycle model
|
||||
"Q29048322": CardType.vehicle, # vehicle model
|
||||
"Q137188246": CardType.vehicle, # combat vehicle model
|
||||
"Q100709275": CardType.vehicle, # combat vehicle family
|
||||
|
||||
"Q43229": CardType.organization, # organization
|
||||
"Q47913": CardType.organization, # intelligence agency
|
||||
"Q35535": CardType.organization, # police
|
||||
"Q740752": CardType.organization, # transport company
|
||||
"Q4830453": CardType.organization, # business
|
||||
"Q4671277": CardType.organization, # academic institution
|
||||
"Q2659904": CardType.organization, # government organization
|
||||
|
||||
"Q686822": CardType.other, # bill (written work)
|
||||
}
|
||||
|
||||
import asyncio
|
||||
import httpx
|
||||
|
||||
async def _get_random_summary_async(client: httpx.AsyncClient) -> dict:
|
||||
try:
|
||||
response = await client.get(
|
||||
"https://en.wikipedia.org/api/rest_v1/page/random/summary",
|
||||
headers=HEADERS,
|
||||
follow_redirects=False,
|
||||
)
|
||||
if response.status_code in (301, 302, 303, 307, 308):
|
||||
# Extract the title from the redirect location and re-encode it
|
||||
location = response.headers["location"]
|
||||
title = location.split("/page/summary/")[-1]
|
||||
response = await client.get(
|
||||
f"https://en.wikipedia.org/api/rest_v1/page/summary/{quote(title, safe='%')}",
|
||||
headers=HEADERS,
|
||||
follow_redirects=False,
|
||||
)
|
||||
except:
|
||||
return {}
|
||||
|
||||
if not response.is_success:
|
||||
logger.error(
|
||||
"Error in request:" +
|
||||
str(response.status_code) +
|
||||
response.text
|
||||
)
|
||||
return {}
|
||||
|
||||
return response.json()
|
||||
|
||||
async def _get_page_summary_async(client: httpx.AsyncClient, title: str) -> dict:
|
||||
try:
|
||||
response = await client.get(
|
||||
f"https://en.wikipedia.org/api/rest_v1/page/summary/{title}",
|
||||
headers=HEADERS,
|
||||
follow_redirects=False,
|
||||
)
|
||||
if response.status_code in (301, 302, 303, 307, 308):
|
||||
# Extract the title from the redirect location and re-encode it
|
||||
location = response.headers["location"]
|
||||
title = location.split("/page/summary/")[-1]
|
||||
response = await client.get(
|
||||
f"https://en.wikipedia.org/api/rest_v1/page/summary/{quote(title, safe='%')}",
|
||||
headers=HEADERS,
|
||||
follow_redirects=False,
|
||||
)
|
||||
except:
|
||||
return {}
|
||||
|
||||
if not response.is_success:
|
||||
logger.error(
|
||||
"Error in request:" +
|
||||
str(response.status_code) +
|
||||
response.text
|
||||
)
|
||||
return {}
|
||||
|
||||
return response.json()
|
||||
|
||||
async def _get_superclasses(client: httpx.AsyncClient, qid: str) -> list[str]:
|
||||
try:
|
||||
response = await client.get(
|
||||
"https://www.wikidata.org/wiki/Special:EntityData/" + qid + ".json",
|
||||
headers=HEADERS
|
||||
)
|
||||
except:
|
||||
return []
|
||||
if not response.is_success:
|
||||
return []
|
||||
entity = response.json().get("entities", {}).get(qid, {})
|
||||
subclass_claims = entity.get("claims", {}).get("P279", [])
|
||||
return [
|
||||
c.get("mainsnak", {}).get("datavalue", {}).get("value", {}).get("id")
|
||||
for c in subclass_claims
|
||||
if c.get("mainsnak", {}).get("datavalue")
|
||||
]
|
||||
|
||||
async def _infer_card_type_async(client: httpx.AsyncClient, entity_id: str) -> tuple[CardType, str, int]:
|
||||
try:
|
||||
response = await client.get(
|
||||
"https://www.wikidata.org/wiki/Special:EntityData/" + entity_id + ".json",
|
||||
headers=HEADERS
|
||||
)
|
||||
except:
|
||||
return CardType.other, "", 0
|
||||
|
||||
if not response.is_success:
|
||||
return CardType.other, "", 0
|
||||
|
||||
entity = response.json().get("entities", {}).get(entity_id, {})
|
||||
claims = entity.get("claims", {})
|
||||
instance_of_claims = claims.get("P31", [])
|
||||
qids = [
|
||||
c.get("mainsnak", {}).get("datavalue", {}).get("value", {}).get("id")
|
||||
for c in instance_of_claims
|
||||
if c.get("mainsnak", {}).get("datavalue")
|
||||
]
|
||||
|
||||
sitelinks = entity.get("sitelinks", {})
|
||||
language_count = sum(
|
||||
1 for key in sitelinks
|
||||
if key.endswith("wiki")
|
||||
and key not in ("commonswiki", "wikidatawiki", "specieswiki")
|
||||
)
|
||||
|
||||
# First pass: direct match
|
||||
for qid in qids:
|
||||
if qid in WIKIDATA_INSTANCE_TYPE_MAP:
|
||||
return WIKIDATA_INSTANCE_TYPE_MAP[qid], qid, language_count
|
||||
|
||||
# Second pass: check superclasses of each instance-of QID
|
||||
superclass_results = sum(
|
||||
await asyncio.gather(*[_get_superclasses(client, qid) for qid in qids if qid]),
|
||||
[])
|
||||
for superclass_qid in superclass_results:
|
||||
if superclass_qid in WIKIDATA_INSTANCE_TYPE_MAP:
|
||||
return WIKIDATA_INSTANCE_TYPE_MAP[superclass_qid], superclass_qid, language_count
|
||||
|
||||
# Third pass: check superclasses of each superclass
|
||||
superclass_results2 = sum(
|
||||
await asyncio.gather(*[_get_superclasses(client, qid) for qid in superclass_results if qid])
|
||||
,[])
|
||||
for superclass_qid2 in superclass_results2:
|
||||
if superclass_qid2 in WIKIDATA_INSTANCE_TYPE_MAP:
|
||||
return WIKIDATA_INSTANCE_TYPE_MAP[superclass_qid2], superclass_qid2, language_count
|
||||
|
||||
# Fallback: classify by presence of specific claims
|
||||
CLAIMS_FALLBACK = {
|
||||
"P625": CardType.location, # coordinate location
|
||||
"P437": CardType.artwork, # distribution format
|
||||
}
|
||||
for prop, fallback_type in CLAIMS_FALLBACK.items():
|
||||
if prop in claims:
|
||||
return fallback_type, (qids[0] if qids else ""), language_count
|
||||
|
||||
return CardType.other, (qids[0] if qids != [] else ""), language_count
|
||||
|
||||
async def _get_wikirank_score(client: httpx.AsyncClient, title: str) -> float | None:
|
||||
"""Returns a quality score from 0-100, or None if unavailable."""
|
||||
try:
|
||||
response = await client.get(
|
||||
f"https://api.wikirank.net/api.php?name={quote(title, safe='')}&lang=en",
|
||||
headers=HEADERS
|
||||
)
|
||||
except:
|
||||
return None
|
||||
if not response.is_success:
|
||||
return None
|
||||
data = response.json()
|
||||
result = data.get("result",{})
|
||||
if result == "not found":
|
||||
return None
|
||||
return result.get("en",{}).get("quality")
|
||||
|
||||
def _score_to_rarity(score: float | None) -> CardRarity:
|
||||
if score is None:
|
||||
return CardRarity.common
|
||||
if score >= 95:
|
||||
return CardRarity.legendary
|
||||
if score >= 80:
|
||||
return CardRarity.epic
|
||||
if score >= 65:
|
||||
return CardRarity.super_rare
|
||||
if score >= 50:
|
||||
return CardRarity.rare
|
||||
if score >= 35:
|
||||
return CardRarity.uncommon
|
||||
return CardRarity.common
|
||||
|
||||
RARITY_MULTIPLIER = {
|
||||
CardRarity.common: 1,
|
||||
CardRarity.uncommon: 1.1,
|
||||
CardRarity.rare: 1.3,
|
||||
CardRarity.super_rare: 1.7,
|
||||
CardRarity.epic: 2.5,
|
||||
CardRarity.legendary: 3,
|
||||
}
|
||||
|
||||
async def _get_monthly_pageviews(client: httpx.AsyncClient, title: str) -> int | None:
|
||||
# Uses the last full month
|
||||
today = datetime.now()
|
||||
first_of_month = today.replace(day=1)
|
||||
last_month_end = first_of_month - timedelta(days=1)
|
||||
last_month_start = last_month_end.replace(day=1)
|
||||
start = last_month_start.strftime("%Y%m01")
|
||||
end = last_month_end.strftime("%Y%m%d")
|
||||
|
||||
try:
|
||||
response = await client.get(
|
||||
f"https://wikimedia.org/api/rest_v1/metrics/pageviews/per-article/en.wikipedia/all-access/all-agents/{quote(title, safe='%')}/monthly/{start}/{end}",
|
||||
headers=HEADERS,
|
||||
)
|
||||
if not response.is_success:
|
||||
return None
|
||||
items = response.json().get("items", [])
|
||||
return items[0]["views"] if items else None
|
||||
except:
|
||||
return None
|
||||
|
||||
def _pageviews_to_defense(views: int | None) -> int:
|
||||
if views is None:
|
||||
return 0
|
||||
return int(sqrt(views))
|
||||
|
||||
async def _get_card_async(client: httpx.AsyncClient, page_title: str|None = None) -> Card|None:
|
||||
if page_title is None:
|
||||
summary = await _get_random_summary_async(client)
|
||||
else:
|
||||
summary = await _get_page_summary_async(client, quote(page_title))
|
||||
if summary == {}:
|
||||
return None
|
||||
|
||||
title = summary["title"]
|
||||
entity_id = summary.get("wikibase_item")
|
||||
text = summary.get("extract", "")
|
||||
|
||||
card_type_task = (
|
||||
_infer_card_type_async(client, entity_id)
|
||||
if entity_id
|
||||
else asyncio.sleep(0, result=(CardType.other, "", 0))
|
||||
)
|
||||
wikirank_task = _get_wikirank_score(client, title)
|
||||
pageviews_task = _get_monthly_pageviews(client, title)
|
||||
(card_type, instance, language_count), score, views = await asyncio.gather(
|
||||
card_type_task, wikirank_task, pageviews_task
|
||||
)
|
||||
if (
|
||||
language_count == 0 or
|
||||
score is None or
|
||||
views is None
|
||||
):
|
||||
error_message = f"Could not generate card '{title}': "
|
||||
if language_count == 0:
|
||||
error_message += "No language pages found"
|
||||
elif score is None:
|
||||
error_message += "No wikirank score"
|
||||
elif views is None:
|
||||
error_message += "No monthly view data"
|
||||
logger.warning(error_message)
|
||||
return None
|
||||
|
||||
rarity = _score_to_rarity(score)
|
||||
multiplier = RARITY_MULTIPLIER[rarity]
|
||||
|
||||
attack = min(2500,int(((language_count*1.5)**1.2)*multiplier**2))
|
||||
defense = min(2500,int(_pageviews_to_defense(views)*max(multiplier,(multiplier**2)/2)))
|
||||
|
||||
return Card(
|
||||
name=summary["title"],
|
||||
generated_at=datetime.now(),
|
||||
image_link=summary.get("thumbnail", {}).get("source", ""),
|
||||
card_rarity=rarity,
|
||||
card_type=card_type,
|
||||
wikidata_instance=instance,
|
||||
text=text,
|
||||
attack=attack,
|
||||
defense=defense,
|
||||
cost=min(10,max(1,int(((attack**2+defense**2)**0.18)/1.5)))
|
||||
)
|
||||
|
||||
async def _get_cards_async(size: int) -> list[Card]:
|
||||
logger.debug(f"Generating {size} cards")
|
||||
async with httpx.AsyncClient(follow_redirects=True) as client:
|
||||
cards = await asyncio.gather(*[_get_card_async(client) for _ in range(size)])
|
||||
|
||||
return [c for c in cards if c is not None]
|
||||
|
||||
async def _get_specific_card_async(title: str) -> Card|None:
|
||||
async with httpx.AsyncClient(follow_redirects=True) as client:
|
||||
return await _get_card_async(client, title)
|
||||
|
||||
# Sync entrypoints
|
||||
def generate_cards(size: int) -> list[Card]:
|
||||
cards = []
|
||||
remaining = size
|
||||
while remaining > 0:
|
||||
batch = min(remaining,10)
|
||||
logger.warning(f"Generating {batch} cards ({len(cards)}/{size})")
|
||||
cards += asyncio.run(_get_cards_async(batch))
|
||||
remaining = size - len(cards)
|
||||
if remaining > 0:
|
||||
sleep(4)
|
||||
|
||||
return cards
|
||||
|
||||
def generate_card(title: str) -> Card|None:
|
||||
return asyncio.run(_get_specific_card_async(title))
|
||||
|
||||
# Cards helper function
|
||||
def compute_deck_type(cards: list) -> str | None:
|
||||
if len(cards) == 0:
|
||||
return None
|
||||
avg_atk = sum(c.attack for c in cards) / len(cards)
|
||||
avg_def = sum(c.defense for c in cards) / len(cards)
|
||||
avg_cost = sum(c.cost for c in cards) / len(cards)
|
||||
|
||||
if all(c.cost > 6 for c in cards):
|
||||
return "Unplayable"
|
||||
if sum(1 for c in cards if c.cost >= 7) == 1:
|
||||
return "God Card"
|
||||
if sum(1 for c in cards if c.cost >= 7) > 1:
|
||||
return "Pantheon"
|
||||
if avg_cost >= 3.2:
|
||||
return "Control"
|
||||
if avg_cost <= 1.8:
|
||||
return "Rush"
|
||||
if avg_def > avg_atk * 1.5:
|
||||
return "Wall"
|
||||
if avg_atk > avg_def * 1.5:
|
||||
return "Aggro"
|
||||
return "Balanced"
|
||||
472
backend/game/manager.py
Normal file
472
backend/game/manager.py
Normal file
@@ -0,0 +1,472 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import random
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import WebSocket
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from game.rules import (
|
||||
GameState, CardInstance, PlayerState, action_play_card, action_sacrifice,
|
||||
action_end_turn, create_game, CombatEvent, GameResult, BOARD_SIZE
|
||||
)
|
||||
from core.models import Card as CardModel, Deck as DeckModel, DeckCard as DeckCardModel, User as UserModel
|
||||
from game.card import compute_deck_type
|
||||
from ai.engine import AI_USER_ID, run_ai_turn, get_random_personality, choose_cards
|
||||
|
||||
logger = logging.getLogger("app")
|
||||
|
||||
## Storage
|
||||
|
||||
active_games: dict[str, GameState] = {}
|
||||
active_deck_ids: dict[str, str|None] = {} # user_id -> deck_id
|
||||
connections: dict[str, dict[str, WebSocket]] = {} # game_id -> {user_id -> websocket}
|
||||
|
||||
@dataclass
|
||||
class QueueEntry:
|
||||
user_id: str
|
||||
deck_id: str
|
||||
websocket: WebSocket
|
||||
|
||||
queue: list[QueueEntry] = []
|
||||
queue_lock = asyncio.Lock()
|
||||
|
||||
## Game Result
|
||||
|
||||
def record_game_result(state: GameState, db: Session):
|
||||
if state.result is None or state.result.winner_id is None:
|
||||
return
|
||||
|
||||
winner_id_str = state.result.winner_id
|
||||
loser_id_str = state.opponent_id(winner_id_str)
|
||||
|
||||
# Skip database updates for AI battles
|
||||
if AI_USER_ID not in [winner_id_str, loser_id_str]:
|
||||
winner = db.query(UserModel).filter(UserModel.id == uuid.UUID(winner_id_str)).first()
|
||||
if winner:
|
||||
winner.wins += 1
|
||||
|
||||
loser = db.query(UserModel).filter(UserModel.id == uuid.UUID(loser_id_str)).first()
|
||||
if loser:
|
||||
loser.losses += 1
|
||||
|
||||
winner_deck_id = active_deck_ids.get(winner_id_str)
|
||||
loser_deck_id = active_deck_ids.get(loser_id_str)
|
||||
|
||||
if AI_USER_ID not in [winner_id_str, loser_id_str]:
|
||||
if winner_deck_id:
|
||||
deck = db.query(DeckModel).filter(DeckModel.id == uuid.UUID(winner_deck_id)).first()
|
||||
if deck:
|
||||
deck.times_played += 1
|
||||
deck.wins += 1
|
||||
else:
|
||||
logger.warning(f"record_game_result: no deck_id found for winner {winner_id_str}")
|
||||
|
||||
if loser_deck_id:
|
||||
deck = db.query(DeckModel).filter(DeckModel.id == uuid.UUID(loser_deck_id)).first()
|
||||
if deck:
|
||||
deck.times_played += 1
|
||||
deck.losses += 1
|
||||
else:
|
||||
logger.warning(f"record_game_result: no deck_id found for loser {loser_id_str}")
|
||||
|
||||
db.commit()
|
||||
|
||||
## Serialization
|
||||
|
||||
def serialize_card(card: CardInstance|None) -> dict | None:
|
||||
if card is None:
|
||||
return None
|
||||
return {
|
||||
"instance_id": card.instance_id,
|
||||
"card_id": card.card_id,
|
||||
"name": card.name,
|
||||
"attack": card.attack,
|
||||
"defense": card.defense,
|
||||
"max_defense": card.max_defense,
|
||||
"cost": card.cost,
|
||||
"card_type": card.card_type,
|
||||
"card_rarity": card.card_rarity,
|
||||
"image_link": card.image_link,
|
||||
"text": card.text,
|
||||
"is_favorite": card.is_favorite,
|
||||
"willing_to_trade": card.willing_to_trade,
|
||||
}
|
||||
|
||||
def serialize_player(player: PlayerState, hide_hand=False) -> dict:
|
||||
return {
|
||||
"user_id": player.user_id,
|
||||
"username": player.username,
|
||||
"deck_type": player.deck_type,
|
||||
"life": player.life,
|
||||
"energy": player.energy,
|
||||
"energy_cap": player.energy_cap,
|
||||
"board": [serialize_card(c) for c in player.board],
|
||||
"hand": [serialize_card(c) for c in player.hand] if not hide_hand else [],
|
||||
"hand_size": len(player.hand),
|
||||
"deck_size": len(player.deck),
|
||||
}
|
||||
|
||||
def serialize_event(event: CombatEvent) -> dict:
|
||||
return {
|
||||
"attacker_slot": event.attacker_slot,
|
||||
"attacker_name": event.attacker_name,
|
||||
"defender_slot": event.defender_slot,
|
||||
"defender_name": event.defender_name,
|
||||
"damage": event.damage,
|
||||
"defender_destroyed": event.defender_destroyed,
|
||||
"life_damage": event.life_damage,
|
||||
}
|
||||
|
||||
def serialize_state(state: GameState, perspective_user_id: str) -> dict:
|
||||
opponent_id = state.opponent_id(perspective_user_id)
|
||||
return {
|
||||
"game_id": state.game_id,
|
||||
"phase": state.phase,
|
||||
"turn": state.turn,
|
||||
"active_player_id": state.active_player_id,
|
||||
"player_order": state.player_order,
|
||||
"you": serialize_player(state.players[perspective_user_id]),
|
||||
"opponent": serialize_player(state.players[opponent_id], hide_hand=True),
|
||||
"last_combat_events": [serialize_event(e) for e in state.last_combat_events],
|
||||
"result": {
|
||||
"winner_id": state.result.winner_id,
|
||||
"reason": state.result.reason,
|
||||
} if state.result else None,
|
||||
"turn_started_at": state.turn_started_at.isoformat() if state.turn_started_at else None,
|
||||
}
|
||||
|
||||
|
||||
## Broadcasting
|
||||
|
||||
async def broadcast_state(game_id: str):
|
||||
state = active_games.get(game_id)
|
||||
if not state:
|
||||
return
|
||||
game_connections = connections.get(game_id, {})
|
||||
for user_id, ws in game_connections.items():
|
||||
try:
|
||||
await ws.send_json({
|
||||
"type": "state",
|
||||
"state": serialize_state(state, user_id),
|
||||
})
|
||||
except Exception as e:
|
||||
logger.debug(f"WebSocket send failed (stale connection): {e}")
|
||||
|
||||
if state.active_player_id == AI_USER_ID and not state.result:
|
||||
asyncio.create_task(run_ai_turn(game_id))
|
||||
|
||||
async def send_error(ws: WebSocket, message: str):
|
||||
await ws.send_json({"type": "error", "message": message})
|
||||
|
||||
|
||||
## Matchmaking
|
||||
|
||||
def load_deck_cards(deck_id: str, user_id: str, db: Session) -> list | None:
|
||||
deck = db.query(DeckModel).filter(
|
||||
DeckModel.id == uuid.UUID(deck_id),
|
||||
DeckModel.user_id == uuid.UUID(user_id)
|
||||
).first()
|
||||
if not deck:
|
||||
return None
|
||||
deck_card_ids = [
|
||||
dc.card_id for dc in db.query(DeckCardModel).filter(DeckCardModel.deck_id == deck.id).all()
|
||||
]
|
||||
cards = db.query(CardModel).filter(CardModel.id.in_(deck_card_ids)).all()
|
||||
return cards
|
||||
|
||||
async def try_match(db: Session):
|
||||
async with queue_lock:
|
||||
if len(queue) < 2:
|
||||
return
|
||||
|
||||
p1_entry = queue.pop(0)
|
||||
p2_entry = queue.pop(0)
|
||||
|
||||
p1_user = db.query(UserModel).filter(UserModel.id == uuid.UUID(p1_entry.user_id)).first()
|
||||
p2_user = db.query(UserModel).filter(UserModel.id == uuid.UUID(p2_entry.user_id)).first()
|
||||
|
||||
p1_cards = load_deck_cards(p1_entry.deck_id, p1_entry.user_id, db)
|
||||
p2_cards = load_deck_cards(p2_entry.deck_id, p2_entry.user_id, db)
|
||||
|
||||
p1_deck_type = compute_deck_type(p1_cards if p1_cards else [])
|
||||
p2_deck_type = compute_deck_type(p2_cards if p2_cards else [])
|
||||
|
||||
active_deck_ids[p1_entry.user_id] = p1_entry.deck_id
|
||||
active_deck_ids[p2_entry.user_id] = p2_entry.deck_id
|
||||
|
||||
if not p1_cards or not p2_cards or not p1_user or not p2_user:
|
||||
await send_error(p1_entry.websocket, "Failed to load deck")
|
||||
await send_error(p2_entry.websocket, "Failed to load deck")
|
||||
return
|
||||
|
||||
state = create_game(
|
||||
p1_entry.user_id, p1_user.username, p1_deck_type if p1_deck_type else "", p1_cards,
|
||||
p2_entry.user_id, p2_user.username, p2_deck_type if p2_deck_type else "", p2_cards,
|
||||
)
|
||||
|
||||
active_games[state.game_id] = state
|
||||
connections[state.game_id] = {
|
||||
p1_entry.user_id: p1_entry.websocket,
|
||||
p2_entry.user_id: p2_entry.websocket,
|
||||
}
|
||||
|
||||
# Notify both players the game has started
|
||||
for user_id, ws in connections[state.game_id].items():
|
||||
await ws.send_json({
|
||||
"type": "game_start",
|
||||
"game_id": state.game_id,
|
||||
})
|
||||
|
||||
await broadcast_state(state.game_id)
|
||||
|
||||
|
||||
## Direct challenge game creation (no WebSocket needed at creation time)
|
||||
|
||||
def create_challenge_game(
|
||||
challenger_id: str, challenger_deck_id: str,
|
||||
challenged_id: str, challenged_deck_id: str,
|
||||
db: Session
|
||||
) -> str:
|
||||
challenger = db.query(UserModel).filter(UserModel.id == uuid.UUID(challenger_id)).first()
|
||||
challenged = db.query(UserModel).filter(UserModel.id == uuid.UUID(challenged_id)).first()
|
||||
p1_cards = load_deck_cards(challenger_deck_id, challenger_id, db)
|
||||
p2_cards = load_deck_cards(challenged_deck_id, challenged_id, db)
|
||||
if not p1_cards or not p2_cards or not challenger or not challenged:
|
||||
raise ValueError("Could not load decks or players")
|
||||
p1_deck_type = compute_deck_type(p1_cards)
|
||||
p2_deck_type = compute_deck_type(p2_cards)
|
||||
state = create_game(
|
||||
challenger_id, challenger.username, p1_deck_type or "", p1_cards,
|
||||
challenged_id, challenged.username, p2_deck_type or "", p2_cards,
|
||||
)
|
||||
active_games[state.game_id] = state
|
||||
# Initialize with no websockets; players connect via /ws/game/{game_id} after redirect
|
||||
connections[state.game_id] = {challenger_id: None, challenged_id: None}
|
||||
active_deck_ids[challenger_id] = challenger_deck_id
|
||||
active_deck_ids[challenged_id] = challenged_deck_id
|
||||
return state.game_id
|
||||
|
||||
|
||||
## Action handler
|
||||
|
||||
async def handle_action(game_id: str, user_id: str, message: dict, db: Session):
|
||||
state = active_games.get(game_id)
|
||||
if not state:
|
||||
logger.warning(f"handle_action: game {game_id} not found")
|
||||
return
|
||||
if state.result:
|
||||
logger.warning(f"handle_action: game {game_id} already over")
|
||||
return
|
||||
if state.active_player_id != user_id:
|
||||
logger.warning(f"handle_action: not {user_id}'s turn, active is {state.active_player_id}")
|
||||
ws = connections[game_id].get(user_id)
|
||||
if ws:
|
||||
await send_error(ws, "It's not your turn")
|
||||
return
|
||||
|
||||
action = message.get("type")
|
||||
err = None
|
||||
|
||||
if action == "play_card":
|
||||
err = action_play_card(state, message["hand_index"], message["slot"])
|
||||
if not err:
|
||||
# Find the card that was just played
|
||||
slot = message["slot"]
|
||||
card_instance = state.players[user_id].board[slot]
|
||||
if card_instance:
|
||||
try:
|
||||
card = db.query(CardModel).filter(
|
||||
CardModel.id == uuid.UUID(card_instance.card_id)
|
||||
).first()
|
||||
if card:
|
||||
card.times_played += 1
|
||||
db.commit()
|
||||
except (SQLAlchemyError, ValueError) as e:
|
||||
logger.warning(f"Failed to increment times_played for card {card_instance.card_id}: {e}")
|
||||
db.rollback()
|
||||
elif action == "sacrifice":
|
||||
slot = message.get("slot")
|
||||
if slot is None:
|
||||
err = "No slot provided"
|
||||
else:
|
||||
# Find the card instance_id before it's removed
|
||||
card = state.players[user_id].board[slot]
|
||||
if card:
|
||||
# Notify opponent first
|
||||
opponent_id = state.opponent_id(user_id)
|
||||
opp_ws = connections[game_id].get(opponent_id)
|
||||
if opp_ws:
|
||||
try:
|
||||
await opp_ws.send_json({
|
||||
"type": "sacrifice_animation",
|
||||
"instance_id": card.instance_id,
|
||||
})
|
||||
except Exception as e:
|
||||
logger.debug(f"WebSocket send failed (stale connection): {e}")
|
||||
await asyncio.sleep(0.65)
|
||||
err = action_sacrifice(state, slot)
|
||||
elif action == "end_turn":
|
||||
err = action_end_turn(state)
|
||||
else:
|
||||
ws = connections[game_id].get(user_id)
|
||||
if ws:
|
||||
await send_error(ws, f"Unknown action: {action}")
|
||||
return
|
||||
|
||||
if err:
|
||||
ws = connections[game_id].get(user_id)
|
||||
if ws:
|
||||
await send_error(ws, err)
|
||||
return
|
||||
|
||||
await broadcast_state(game_id)
|
||||
|
||||
if state.result:
|
||||
record_game_result(state, db)
|
||||
for uid in list(connections.get(game_id,{}).keys()):
|
||||
active_deck_ids.pop(uid, None)
|
||||
active_games.pop(game_id, None)
|
||||
connections.pop(game_id, None)
|
||||
|
||||
DISCONNECT_GRACE_SECONDS = 15
|
||||
|
||||
async def handle_disconnect(game_id: str, user_id: str):
|
||||
await asyncio.sleep(DISCONNECT_GRACE_SECONDS)
|
||||
|
||||
# Check if game still exists and player hasn't reconnected
|
||||
if game_id not in active_games:
|
||||
return
|
||||
if user_id in connections.get(game_id, {}):
|
||||
return # player reconnected during grace period
|
||||
|
||||
state = active_games[game_id]
|
||||
if state.result:
|
||||
return # game already ended normally
|
||||
|
||||
winner_id = state.opponent_id(user_id)
|
||||
|
||||
state.result = GameResult(
|
||||
winner_id=winner_id,
|
||||
reason="Opponent disconnected"
|
||||
)
|
||||
state.phase = "end"
|
||||
|
||||
from core.database import SessionLocal
|
||||
db = SessionLocal()
|
||||
try:
|
||||
record_game_result(state, db)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
# Notify the remaining player
|
||||
winner_ws = connections[game_id].get(winner_id)
|
||||
if winner_ws:
|
||||
try:
|
||||
await winner_ws.send_json({
|
||||
"type": "state",
|
||||
"state": serialize_state(state, winner_id),
|
||||
})
|
||||
except Exception as e:
|
||||
logger.debug(f"WebSocket send failed (stale connection): {e}")
|
||||
|
||||
active_deck_ids.pop(user_id, None)
|
||||
active_deck_ids.pop(winner_id, None)
|
||||
active_games.pop(game_id, None)
|
||||
connections.pop(game_id, None)
|
||||
|
||||
TURN_TIME_LIMIT_SECONDS = 120
|
||||
|
||||
async def handle_timeout_claim(game_id: str, claimant_id: str, db: Session) -> str | None:
|
||||
state = active_games.get(game_id)
|
||||
if not state:
|
||||
return "Game not found"
|
||||
if state.result:
|
||||
return "Game already ended"
|
||||
if state.active_player_id == claimant_id:
|
||||
return "It's your turn"
|
||||
if not state.turn_started_at:
|
||||
return "No turn timer running"
|
||||
|
||||
elapsed = (datetime.now() - state.turn_started_at).total_seconds()
|
||||
if elapsed < TURN_TIME_LIMIT_SECONDS:
|
||||
return f"Timer has not expired yet ({int(TURN_TIME_LIMIT_SECONDS - elapsed)}s remaining)"
|
||||
|
||||
state.result = GameResult(
|
||||
winner_id=claimant_id,
|
||||
reason="Opponent ran out of time"
|
||||
)
|
||||
state.phase = "end"
|
||||
|
||||
record_game_result(state, db)
|
||||
await broadcast_state(game_id)
|
||||
|
||||
active_deck_ids.pop(state.active_player_id, None)
|
||||
active_deck_ids.pop(claimant_id, None)
|
||||
active_games.pop(game_id, None)
|
||||
connections.pop(game_id, None)
|
||||
|
||||
return None
|
||||
|
||||
def create_solo_game(
|
||||
user_id: str,
|
||||
username: str,
|
||||
player_cards: list,
|
||||
ai_cards: list,
|
||||
deck_id: str,
|
||||
difficulty: int = 5,
|
||||
) -> str:
|
||||
ai_personality = get_random_personality()
|
||||
ai_deck = choose_cards(ai_cards, difficulty, ai_personality)
|
||||
|
||||
player_deck_type = compute_deck_type(player_cards) or "Balanced"
|
||||
ai_deck_type = compute_deck_type(ai_deck) or "Balanced"
|
||||
|
||||
state = create_game(
|
||||
user_id, username, player_deck_type, player_cards,
|
||||
AI_USER_ID, "Computer", ai_deck_type, ai_deck,
|
||||
)
|
||||
|
||||
state.ai_difficulty = difficulty
|
||||
state.ai_personality = ai_personality.value
|
||||
|
||||
active_games[state.game_id] = state
|
||||
connections[state.game_id] = {}
|
||||
active_deck_ids[user_id] = deck_id
|
||||
active_deck_ids[AI_USER_ID] = None
|
||||
|
||||
if state.active_player_id == AI_USER_ID:
|
||||
asyncio.create_task(run_ai_turn(state.game_id))
|
||||
|
||||
return state.game_id
|
||||
|
||||
ANIMATION_DELAYS = {
|
||||
"pre_combat_pause": 0.5, # pause before end_turn
|
||||
"per_attack_pre": 0.1, # delay before each attack animation
|
||||
"lunge_duration": 0.42, # lunge animation duration
|
||||
"shake_duration": 0.4, # shake animation duration
|
||||
"damage_point": 0.22, # when damage is applied mid-shake
|
||||
"post_attack": 0.08, # gap between attacks
|
||||
"destroy_duration": 0.6, # crumble animation duration
|
||||
"post_combat_buffer": 0.3, # buffer after all animations finish
|
||||
}
|
||||
|
||||
def calculate_combat_animation_time(events: list[CombatEvent]) -> float:
|
||||
total = 0.0
|
||||
for event in events:
|
||||
total += ANIMATION_DELAYS["per_attack_pre"]
|
||||
# Lunge and shake run simultaneously, so take the longer of the two
|
||||
total += max(
|
||||
ANIMATION_DELAYS["lunge_duration"],
|
||||
ANIMATION_DELAYS["shake_duration"]
|
||||
)
|
||||
total += ANIMATION_DELAYS["post_attack"]
|
||||
if event.defender_destroyed:
|
||||
total += ANIMATION_DELAYS["destroy_duration"]
|
||||
|
||||
total += ANIMATION_DELAYS["post_combat_buffer"]
|
||||
return total
|
||||
|
||||
302
backend/game/rules.py
Normal file
302
backend/game/rules.py
Normal file
@@ -0,0 +1,302 @@
|
||||
import random
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from core.models import Card as CardModel
|
||||
|
||||
STARTING_LIFE = 1000
|
||||
MAX_ENERGY_CAP = 6
|
||||
BOARD_SIZE = 5
|
||||
HAND_SIZE = 5
|
||||
|
||||
@dataclass
|
||||
class CardInstance:
|
||||
instance_id: str
|
||||
card_id: str
|
||||
name: str
|
||||
attack: int
|
||||
defense: int
|
||||
max_defense: int
|
||||
cost: int
|
||||
card_type: str
|
||||
card_rarity: str
|
||||
image_link: str
|
||||
text: str
|
||||
is_favorite: bool = False
|
||||
willing_to_trade: bool = False
|
||||
|
||||
@classmethod
|
||||
def from_db_card(cls, card: CardModel) -> "CardInstance":
|
||||
return cls(
|
||||
instance_id=str(uuid.uuid4()),
|
||||
card_id=str(card.id),
|
||||
name=card.name,
|
||||
attack=card.attack,
|
||||
defense=card.defense,
|
||||
max_defense=card.defense,
|
||||
cost=card.cost,
|
||||
card_type=card.card_type,
|
||||
card_rarity=card.card_rarity,
|
||||
image_link=card.image_link or "",
|
||||
text=card.text,
|
||||
is_favorite=card.is_favorite,
|
||||
willing_to_trade=card.willing_to_trade,
|
||||
)
|
||||
|
||||
@dataclass
|
||||
class PlayerState:
|
||||
user_id: str
|
||||
username: str
|
||||
deck_type: str
|
||||
life: int = STARTING_LIFE
|
||||
hand: list[CardInstance] = field(default_factory=list)
|
||||
deck: list[CardInstance] = field(default_factory=list)
|
||||
board: list[Optional[CardInstance]] = field(default_factory=lambda: [None] * BOARD_SIZE)
|
||||
energy: int = 0
|
||||
energy_cap: int = 0
|
||||
|
||||
def draw_to_full(self):
|
||||
"""Draw cards until hand has HAND_SIZE cards or deck is empty."""
|
||||
while len(self.hand) < HAND_SIZE and self.deck:
|
||||
self.hand.append(self.deck.pop())
|
||||
|
||||
def refill_energy(self):
|
||||
self.energy = self.energy_cap
|
||||
|
||||
def increment_energy_cap(self):
|
||||
self.energy_cap = min(self.energy_cap + 1, MAX_ENERGY_CAP)
|
||||
|
||||
def has_playable_cards(self) -> bool:
|
||||
"""True if the player has any playable cards left in deck or hand or on board."""
|
||||
board_empty = all([c is None for c in self.board])
|
||||
non_played_cards = self.deck + self.hand
|
||||
return (not board_empty) or any(c.cost <= max(MAX_ENERGY_CAP,self.energy) for c in non_played_cards)
|
||||
|
||||
@dataclass
|
||||
class CombatEvent:
|
||||
attacker_slot: int
|
||||
attacker_name: str
|
||||
defender_slot: Optional[int] # None if attacking life
|
||||
defender_name: Optional[str]
|
||||
damage: int
|
||||
defender_destroyed: bool
|
||||
life_damage: int # > 0 if hitting life total
|
||||
|
||||
@dataclass
|
||||
class GameResult:
|
||||
winner_id: Optional[str] # None if still ongoing
|
||||
reason: Optional[str]
|
||||
|
||||
@dataclass
|
||||
class GameState:
|
||||
game_id: str
|
||||
players: dict[str, PlayerState]
|
||||
player_order: list[str] # [player1_id, player2_id]
|
||||
active_player_id: str
|
||||
phase: str # "main" | "combat" | "end"
|
||||
turn: int = 1
|
||||
result: Optional[GameResult] = None
|
||||
last_combat_events: list[CombatEvent] = field(default_factory=list)
|
||||
turn_started_at: Optional[datetime] = None
|
||||
ai_difficulty: int = 5 # 1-10, only used for AI games
|
||||
ai_personality: Optional[str] = None # AI personality type, only used for AI games
|
||||
|
||||
def opponent_id(self, player_id: str) -> str:
|
||||
return next(p for p in self.player_order if p != player_id)
|
||||
|
||||
def active_player(self) -> PlayerState:
|
||||
return self.players[self.active_player_id]
|
||||
|
||||
def opponent(self) -> PlayerState:
|
||||
return self.players[self.opponent_id(self.active_player_id)]
|
||||
|
||||
|
||||
def create_game(
|
||||
player1_id: str,
|
||||
player1_username: str,
|
||||
player1_deck_type: str,
|
||||
player1_cards: list,
|
||||
player2_id: str,
|
||||
player2_username: str,
|
||||
player2_deck_type: str,
|
||||
player2_cards: list,
|
||||
) -> GameState:
|
||||
def make_player(user_id, username, deck_type, cards):
|
||||
deck = [CardInstance.from_db_card(c) for c in cards]
|
||||
random.shuffle(deck)
|
||||
player = PlayerState(user_id=user_id, username=username, deck_type=deck_type, deck=deck)
|
||||
return player
|
||||
|
||||
p1 = make_player(player1_id, player1_username, player1_deck_type, player1_cards)
|
||||
p2 = make_player(player2_id, player2_username, player2_deck_type, player2_cards)
|
||||
|
||||
# Randomly decide who goes first
|
||||
order = [player1_id, player2_id]
|
||||
random.shuffle(order)
|
||||
first = order[0]
|
||||
|
||||
# First player starts with energy cap 1, draw immediately
|
||||
p1.increment_energy_cap()
|
||||
p2.increment_energy_cap()
|
||||
players = {player1_id: p1, player2_id: p2}
|
||||
players[first].refill_energy()
|
||||
players[first].draw_to_full()
|
||||
|
||||
state = GameState(
|
||||
game_id=str(uuid.uuid4()),
|
||||
players=players,
|
||||
player_order=order,
|
||||
active_player_id=first,
|
||||
phase="main",
|
||||
turn=1,
|
||||
)
|
||||
state.turn_started_at = datetime.now()
|
||||
|
||||
return state
|
||||
|
||||
|
||||
def check_win_condition(state: GameState) -> Optional[GameResult]:
|
||||
for pid, player in state.players.items():
|
||||
if player.life <= 0:
|
||||
return GameResult(
|
||||
winner_id=state.opponent_id(pid),
|
||||
reason="Life reduced to zero"
|
||||
)
|
||||
|
||||
for pid, player in state.players.items():
|
||||
opp = state.players[state.opponent_id(pid)]
|
||||
if any(c for c in player.board if c) and not opp.has_playable_cards():
|
||||
return GameResult(
|
||||
winner_id=pid,
|
||||
reason="Opponent has no playable cards remaining"
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def resolve_combat(state: GameState) -> list[CombatEvent]:
|
||||
active = state.active_player()
|
||||
opponent = state.opponent()
|
||||
events = []
|
||||
|
||||
for slot in range(BOARD_SIZE):
|
||||
attacker = active.board[slot]
|
||||
if attacker is None:
|
||||
continue
|
||||
|
||||
defender = opponent.board[slot]
|
||||
|
||||
if defender is None:
|
||||
# Direct life damage
|
||||
opponent.life -= attacker.attack
|
||||
events.append(CombatEvent(
|
||||
attacker_slot=slot,
|
||||
attacker_name=attacker.name,
|
||||
defender_slot=None,
|
||||
defender_name=None,
|
||||
damage=attacker.attack,
|
||||
defender_destroyed=False,
|
||||
life_damage=attacker.attack,
|
||||
))
|
||||
else:
|
||||
# Attack the opposing card
|
||||
defender.defense -= attacker.attack
|
||||
destroyed = defender.defense <= 0
|
||||
if destroyed:
|
||||
opponent.board[slot] = None
|
||||
|
||||
events.append(CombatEvent(
|
||||
attacker_slot=slot,
|
||||
attacker_name=attacker.name,
|
||||
defender_slot=slot,
|
||||
defender_name=defender.name,
|
||||
damage=attacker.attack,
|
||||
defender_destroyed=destroyed,
|
||||
life_damage=0,
|
||||
))
|
||||
|
||||
return events
|
||||
|
||||
|
||||
def action_play_card(state: GameState, hand_index: int, slot: int) -> str | None:
|
||||
if state.phase != "main":
|
||||
return "Not in main phase"
|
||||
|
||||
player = state.active_player()
|
||||
|
||||
if slot < 0 or slot >= BOARD_SIZE:
|
||||
return f"Invalid slot {slot}"
|
||||
if hand_index < 0 or hand_index >= len(player.hand):
|
||||
return "Invalid hand index"
|
||||
if player.board[slot] is not None:
|
||||
return "Slot already occupied"
|
||||
|
||||
card = player.hand[hand_index]
|
||||
|
||||
if card.cost > player.energy:
|
||||
return f"Not enough energy (have {player.energy}, need {card.cost})"
|
||||
|
||||
player.energy -= card.cost
|
||||
player.board[slot] = card
|
||||
player.hand.pop(hand_index)
|
||||
return None
|
||||
|
||||
|
||||
def action_sacrifice(state: GameState, slot: int) -> str | None:
|
||||
if state.phase != "main":
|
||||
return "Not in main phase"
|
||||
|
||||
player = state.active_player()
|
||||
|
||||
if slot < 0 or slot >= BOARD_SIZE:
|
||||
return f"Invalid slot {slot}"
|
||||
|
||||
card = player.board[slot]
|
||||
if card is None:
|
||||
return "No card in that slot"
|
||||
|
||||
# player.energy += card.cost
|
||||
player.energy += 1
|
||||
player.board[slot] = None
|
||||
return None
|
||||
|
||||
|
||||
def action_end_turn(state: GameState) -> str | None:
|
||||
if state.phase != "main":
|
||||
return "Not in main phase"
|
||||
|
||||
state.phase = "combat"
|
||||
|
||||
# Resolve combat
|
||||
events = resolve_combat(state)
|
||||
state.last_combat_events = events
|
||||
|
||||
# Check win condition after combat
|
||||
result = check_win_condition(state)
|
||||
if result:
|
||||
state.result = result
|
||||
state.phase = "end"
|
||||
return None
|
||||
|
||||
# Switch to next player
|
||||
next_player_id = state.opponent_id(state.active_player_id)
|
||||
state.active_player_id = next_player_id
|
||||
state.turn += 1
|
||||
|
||||
next_player = state.active_player()
|
||||
next_player.increment_energy_cap()
|
||||
next_player.refill_energy()
|
||||
next_player.draw_to_full()
|
||||
|
||||
# Check if next player can't do anything
|
||||
result = check_win_condition(state)
|
||||
if result:
|
||||
state.result = result
|
||||
state.phase = "end"
|
||||
return None
|
||||
|
||||
state.phase = "main"
|
||||
state.turn_started_at = datetime.now()
|
||||
return None
|
||||
Reference in New Issue
Block a user