This commit is contained in:
2026-04-01 18:31:33 +02:00
parent 6e23e32bb0
commit b5c7c5305a
95 changed files with 9609 additions and 2374 deletions

0
backend/game/__init__.py Normal file
View File

611
backend/game/card.py Normal file
View 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
View 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
View 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