HuggingWizards / game /engine.py
Quazim0t0's picture
Upload 127 files
7e5faa7 verified
Raw
History Blame Contribute Delete
66.3 kB
"""Authoritative co-op arena simulation.
One shared arena: a boss in the center, minions that spawn, and up to
MAX_PLAYERS wizards. The engine ticks on the CPU at TICK_HZ and produces a
JSON-serialisable snapshot that is broadcast to every connected client.
The engine knows nothing about networking or the LLM. The server drives it:
- feed inputs via `set_input` / `add_player` / `remove_player`
- call `tick(dt)` every frame
- when `status == "intermission"`, ask the Game Master for a decision and pass
it to `apply_gm_decision`, then call `start_round`.
"""
from __future__ import annotations
import math
import random
import time
from dataclasses import dataclass, field
from game import skills as skillmod
ARENA_W = 1280
ARENA_H = 720
CENTER = (ARENA_W / 2, ARENA_H / 2)
MAX_PLAYERS = 8
TICK_HZ = 20
# Upgrade definitions: id -> (label, cost). Cost is legacy (gold shop); the
# survivors-mode progression hands these out as level-up cards instead.
UPGRADES = {
"damage": ("Spell Power", 30),
"attack_speed": ("Cast Speed", 30),
"move_speed": ("Boots", 25),
"max_hp": ("Vitality", 25),
"multishot": ("Multishot", 60),
}
# Level-up cards. id -> (label, description, rarity). The stat ids above plus a
# few survivors-flavored extras. The Game Master picks which cards are offered.
CARDS = {
# --- originals ---
"auto_attack": ("Auto-Cast", "Attack automatically — no need to hold Space", "common"),
"damage": ("Spell Power", "+6 spell damage", "common"),
"attack_speed": ("Quick Cast", "Cast faster", "common"),
"move_speed": ("Swift Boots", "+22 move speed", "common"),
"max_hp": ("Vitality", "+25 max HP (and heal)", "common"),
"multishot": ("Multishot", "+1 projectile", "rare"),
"pierce": ("Piercing Bolt", "Shots pierce +1 enemy", "rare"),
"regen": ("Regeneration", "+2 HP/sec", "uncommon"),
"projectile_speed": ("Arcane Velocity", "Faster, longer shots", "uncommon"),
"aoe": ("Arcane Nova", "Periodic burst damages nearby foes", "rare"),
"summoner": ("Spirit Caller", "Summon spirits that fight for you", "rare"),
# --- 20 new base power-ups ---
"crit": ("Critical Strike", "+8% chance to deal double damage", "uncommon"),
"lifesteal": ("Vampirism", "Heal for a % of damage dealt", "uncommon"),
"armor": ("Iron Hide", "-2 flat damage taken", "common"),
"dodge": ("Evasion", "+5% chance to avoid a hit", "uncommon"),
"thorns": ("Spiked Aura", "Melee attackers take damage back", "uncommon"),
"magnet": ("Lodestone", "Pull XP gems from much farther", "common"),
"xp_boost": ("Quick Study", "+15% XP from gems", "uncommon"),
"gold_boost": ("Prosperity", "+20% gold from rewards", "common"),
"knockback": ("Force Bolt", "Shots knock enemies back", "common"),
"explosive": ("Explosive Shots", "Shots burst for splash damage", "rare"),
"burn": ("Ignite", "Shots set enemies on fire (damage over time)", "uncommon"),
"chain": ("Chain Spark", "Hits arc to a nearby enemy", "rare"),
"big_shots": ("Heavy Orbs", "Bigger, easier-to-land projectiles", "common"),
"homing": ("Seeker Bolts", "Shots curve toward enemies", "rare"),
"giant": ("Titan Growth", "+30 max HP and you grow larger", "uncommon"),
"glass_cannon": ("Glass Cannon", "+12 damage but -15 max HP", "rare"),
"berserker": ("Berserker", "Big damage boost while below half HP", "uncommon"),
"shield": ("Aegis", "Start each wave with a damage-absorbing shield", "uncommon"),
"slow_aura": ("Frost Presence", "Nearby enemies are slowed", "uncommon"),
"fortune": ("Lucky Charm", "Better gem tiers and rarer drops", "rare"),
# --- boss-exclusive (only offered during the matching boss; see THEME_CARDS) ---
"war_drums": ("War Drums", "Periodically summon a warband", "rare"),
"savage_roar": ("Savage Roar", "Each kill erupts in an AoE blast", "rare"),
"iron_nova": ("Iron Detonation", "Heavy slowing nova every few casts", "rare"),
"bulwark": ("Bulwark", "Periodically restore a chunk of HP", "rare"),
"impaling_charge": ("Impaling Charge", "Burst of piercing lances", "rare"),
"arrow_storm": ("Arrow Storm", "A constant rain of arrows", "rare"),
}
ALL_CARD_IDS = list(CARDS.keys())
# Cards that grant a runtime skill spec rather than a stat point.
SKILL_CARDS = set(skillmod.CARD_SKILLS.keys())
# One-time cards excluded from draws once owned.
# (auto_attack handled below in BINARY_CARDS)
# Boss-exclusive cards: each is ONLY offered while its boss theme is active.
# theme index = (round-1)//5 -> Orc / Iron Legion / Lancer / Archer (then cycles)
THEME_CARDS = {
0: ["war_drums", "savage_roar"], # Orc Horde
1: ["iron_nova", "bulwark"], # Iron Legion
2: ["impaling_charge"], # Lancer Host
3: ["arrow_storm"], # Archer Coven
}
# Every theme-exclusive id, so they can be filtered out of normal draws.
THEME_ONLY = {cid for ids in THEME_CARDS.values() for cid in ids}
def theme_cards_for(theme: int) -> list[str]:
return THEME_CARDS.get(theme % len(THEME_CARDS), [])
# One-time cards excluded from draws once owned: Auto-Cast and every skill card
# (re-granting a skill would just duplicate it).
BINARY_CARDS = {"auto_attack"} | SKILL_CARDS
# XP gem tiers 1..7 -> XP value (increasing). The client colors/sizes by tier.
GEM_TIER_XP = {1: 1, 2: 2, 3: 4, 4: 7, 5: 12, 6: 20, 7: 35}
# Timed AURAS — ONLY found as floor pickups (never cards). Each lasts dur seconds
# and applies temporary multipliers. "sheet" picks a Retro Impact effect sheet.
AURAS = {
"inferno": {"label": "Inferno Aura", "dur": 40, "sheet": "a", "color": "#ff7a4a",
"dmg": 1.6},
"frost": {"label": "Frost Aura", "dur": 50, "sheet": "b", "color": "#7ee0ff",
"enemy_slow": 0.45, "move": 1.15},
"haste": {"label": "Haste Aura", "dur": 35, "sheet": "c", "color": "#a6ff8c",
"atk": 0.6, "move": 1.4},
"vampire": {"label": "Vampiric Aura", "dur": 40, "sheet": "d", "color": "#ff6e6e",
"lifesteal": 0.18},
"warding": {"label": "Warding Aura", "dur": 55, "sheet": "e", "color": "#ffd45e",
"dmg_red": 0.45},
"fury": {"label": "Fury Aura", "dur": 30, "sheet": "f", "color": "#c08cff",
"dmg": 1.35, "atk": 0.7},
}
ALL_AURA_IDS = list(AURAS.keys())
PENDING_AUTOPICK_SEC = 15.0 # auto-choose a card if the player dithers
# Special boss encounters. They replace the themed boss on certain waves, tint the
# whole screen, attack with a unique projectile style, and — when defeated — grant
# every surviving wizard the boss's own attack power.
SPECIAL_BOSSES = {
"aegis": {"name": "AEGIS", "hue": "red", "hp_mult": 2.3, "dmg_mult": 1.3,
"style": "holy", "reward": "holy_judgment"},
"toaster": {"name": "TOASTER BOT", "hue": "green", "hp_mult": 1.9, "dmg_mult": 1.15,
"style": "glitch", "reward": "glitch_rift"},
}
def special_boss_for(rnd: int) -> str | None:
"""Aegis on waves 3,9,15… Toaster Bot on waves 5,11,17…"""
if rnd >= 3 and (rnd - 3) % 6 == 0:
return "aegis"
if rnd >= 5 and (rnd - 5) % 6 == 0:
return "toaster"
return None
# Enemy archetypes: id -> stat multipliers/overrides relative to the base minion.
MINION_TYPES = {
"grunt": {"hp": 1.0, "speed": 1.0, "dmg": 1.0, "scale": 1.0},
"fast": {"hp": 0.6, "speed": 1.8, "dmg": 0.7, "scale": 0.85},
"tank": {"hp": 2.6, "speed": 0.6, "dmg": 1.6, "scale": 1.3},
}
# Boss attack patterns — the Game Master picks one per round. Multipliers scale
# how often each attack fires (>1 = more often); flags switch firing styles.
BOSS_PATTERNS = {
"balanced": {"shoot": 1.0, "nova": 1.0, "charge": 1.0, "spawn": 1.0,
"label": ""},
"artillery": {"shoot": 0.6, "nova": 2.0, "charge": 0.4, "spawn": 0.8,
"label": "ARTILLERY", "slow_heavy": True},
"sniper": {"shoot": 1.5, "nova": 0.45, "charge": 0.6, "spawn": 0.7,
"label": "SNIPER", "snipe": True},
"swarm": {"shoot": 0.55, "nova": 0.6, "charge": 0.5, "spawn": 2.4,
"label": "SWARMLORD"},
"berserker": {"shoot": 0.7, "nova": 0.8, "charge": 2.6, "spawn": 0.7,
"label": "BERSERKER"},
}
ALL_PATTERN_IDS = list(BOSS_PATTERNS.keys())
# Blessings the Game Master may bestow on individual wizards between rounds:
# any timed aura, an extra life, or a full heal. Applied at next round start.
BLESSINGS = ["extra_life", "full_heal"] + ALL_AURA_IDS
MAX_LIVES = 5
def _clamp(v, lo, hi):
return lo if v < lo else hi if v > hi else v
@dataclass
class Projectile:
x: float
y: float
vx: float
vy: float
dmg: float
owner: str # player id, or "boss"/"minion"
hostile: bool # True = damages players, False = damages enemies
ttl: float = 2.5
radius: float = 10.0
pierce: int = 0 # remaining extra enemies this shot can pass through
hit_ids: set = field(default_factory=set) # minion uids already hit (for pierce)
# power-up effect payload (player shots only)
homing: bool = False
burn: float = 0.0 # burn dps applied on hit (Ignite)
explode: float = 0.0 # AoE radius on hit (Explosive)
knock: float = 0.0 # knockback impulse (Force Bolt)
chain: int = 0 # extra chain hits (Chain Spark)
crit: bool = False # was a critical hit (visual)
style: str = "" # visual style: "" | "holy" | "glitch"
def to_dict(self):
d = {"x": round(self.x, 1), "y": round(self.y, 1),
"hostile": self.hostile, "r": self.radius}
if self.style:
d["s"] = self.style
return d
@dataclass
class Minion:
x: float
y: float
hp: float
max_hp: float
speed: float
dmg: float
uid: int = 0 # stable id so the client can track hit/death effects
kind: str = "grunt" # grunt | fast | tank
scale: float = 1.0
attack_cd: float = 0.0
hurt: float = 0.0
burn: float = 0.0 # burn damage-per-second (Ignite)
burn_t: float = 0.0 # burn time remaining
alive: bool = True
def to_dict(self):
return {"id": self.uid,
"x": round(self.x, 1), "y": round(self.y, 1),
"hp": self.hp, "max_hp": self.max_hp, "kind": self.kind,
"scale": self.scale, "hurt": self.hurt > 0,
"burn": self.burn_t > 0}
@dataclass
class Ally:
"""A summoned spirit that fights for a player (from a summon_ally skill)."""
x: float
y: float
owner: str # player id (kills/damage credit the summoner)
uid: int = 0
hp: float = 30.0
max_hp: float = 30.0
speed: float = 150.0
dmg: float = 12.0
attack_cd: float = 0.0
ttl: float = 14.0
color: str = "#a6ff8c"
def to_dict(self):
return {"id": self.uid, "x": round(self.x, 1), "y": round(self.y, 1),
"hp": self.hp, "max_hp": self.max_hp, "color": self.color}
@dataclass
class Pickup:
"""A rare floor power-up. kind 'card' grants a permanent card; kind 'aura'
grants a timed aura buff. Walk over it to claim."""
x: float
y: float
card: str # card id (kind=card) or aura id (kind=aura)
kind: str = "card" # card | aura
uid: int = 0
ttl: float = 22.0
def to_dict(self):
d = {"id": self.uid, "x": round(self.x, 1), "y": round(self.y, 1),
"card": self.card, "kind": self.kind}
if self.kind == "aura":
d["color"] = AURAS.get(self.card, {}).get("color", "#fff")
d["sheet"] = AURAS.get(self.card, {}).get("sheet", "a")
return d
@dataclass
class Gem:
x: float
y: float
value: int = 1
tier: int = 1
ttl: float = 18.0
def to_dict(self):
return {"x": round(self.x, 1), "y": round(self.y, 1),
"v": self.value, "t": self.tier}
@dataclass
class Boss:
hp: float = 1000
max_hp: float = 1000
dmg: float = 12
x: float = CENTER[0]
y: float = CENTER[1]
shoot_cd: float = 0.0
spawn_cd: float = 0.0
spawn_interval: float = 4.0
hurt: float = 0.0
# attack state machine
state: str = "idle" # idle | telegraph | charge
state_t: float = 0.0 # time remaining in the current state
charge_cd: float = 6.0 # until next charge
nova_cd: float = 5.0 # until next ground shockwave
cvx: float = 0.0
cvy: float = 0.0
aggro: float = 1.0 # Director-tunable attack frequency multiplier
special: str = "" # "" | "aegis" | "toaster"
name: str = "" # display name (special bosses)
special_cd: float = 4.0 # until next signature attack
pattern: str = "balanced" # Director-chosen attack pattern
atk_speed: float = 1.0 # Director-chosen attack cadence multiplier
def to_dict(self):
return {"x": round(self.x, 1), "y": round(self.y, 1),
"hp": self.hp, "max_hp": self.max_hp,
"hurt": self.hurt > 0, "state": self.state,
"special": self.special, "name": self.name,
"pattern": self.pattern, "atk_speed": self.atk_speed,
"pattern_label": BOSS_PATTERNS.get(self.pattern, {}).get("label", ""),
"phase": 1 if self.hp > self.max_hp * 0.5 else 2}
@dataclass
class Player:
pid: str
name: str
x: float = 200.0
y: float = 360.0
hp: float = 100.0
max_hp: float = 100.0
speed: float = 180.0
facing: float = 0.0 # radians, last aim direction
alive: bool = True
gold: int = 0
level: int = 1
# input
up: bool = False
down: bool = False
left: bool = False
right: bool = False
attacking: bool = False
attack_timer: float = 0.0
moving: bool = False
hurt: float = 0.0
# upgrade levels (gained via level-up cards)
up_damage: int = 0
up_attack_speed: int = 0
up_move_speed: int = 0
up_max_hp: int = 0
up_multishot: int = 0
up_pierce: int = 0
up_regen: int = 0
up_projectile_speed: int = 0
up_auto_attack: int = 0
# new base power-ups
up_crit: int = 0
up_lifesteal: int = 0
up_armor: int = 0
up_dodge: int = 0
up_thorns: int = 0
up_magnet: int = 0
up_xp_boost: int = 0
up_gold_boost: int = 0
up_knockback: int = 0
up_explosive: int = 0
up_burn: int = 0
up_chain: int = 0
up_big_shots: int = 0
up_homing: int = 0
up_giant: int = 0
up_glass_cannon: int = 0
up_berserker: int = 0
up_shield: int = 0
up_slow_aura: int = 0
up_fortune: int = 0
shield: float = 0.0 # current absorb (from Aegis)
flash: str = "" # transient pickup/announce text for this player
flash_t: float = 0.0
# lives / character / timed auras
lives: int = 3
char: str = "warrior" # chosen character sprite set
respawn_t: float = 0.0 # >0 while waiting to respawn after a death
auras: list = field(default_factory=list) # active timed auras [{type,t}]
pending_until: float = 0.0 # auto-pick deadline for pending_cards
# XP / character level
xp: int = 0
xp_needed: int = 5
pending_cards: list = field(default_factory=list) # card ids offered now
skill_cards: set = field(default_factory=set) # owned skill-card ids
# runtime skill specs (from cards or Game-Master-authored requests) + state
skills: list = field(default_factory=list)
_attack_count: int = 0
# per-round stats
dmg_dealt: float = 0.0
kills: int = 0
# ---- timed-aura helpers ----
def _aura_prod(self, key: str, default: float = 1.0) -> float:
v = default
for a in self.auras:
spec = AURAS.get(a["type"], {})
if key in spec:
v *= spec[key]
return v
def _aura_max(self, key: str) -> float:
v = 0.0
for a in self.auras:
spec = AURAS.get(a["type"], {})
if key in spec:
v = max(v, spec[key])
return v
# derived stats
@property
def damage(self) -> float:
# base + Spell Power + Glass Cannon; Berserker adds more while wounded
d = 14 + self.up_damage * 6 + self.up_glass_cannon * 12
if self.up_berserker and self.hp < self.max_hp * 0.5:
d *= 1 + 0.15 * self.up_berserker
return d * self._aura_prod("dmg")
@property
def size_mult(self) -> float:
# Wizards start minion-sized (0.55) and grow a step every level, capped
# a touch above boss size (2.9) — a long run literally makes you a
# bigger, harder-to-dodge-with target. Titan Growth stacks on top.
return min(2.9, 0.55 + 0.12 * (self.level - 1)) * (1 + self.up_giant * 0.18)
@property
def hit_radius(self) -> float:
# Server-authoritative hurtbox — grows with size, so dodging gets
# harder as you level (the client renders at the same scale).
return 10.0 + 12.0 * self.size_mult
@property
def attack_interval(self) -> float:
return max(0.12, (0.55 - self.up_attack_speed * 0.05) * self._aura_prod("atk"))
@property
def move_speed(self) -> float:
return (180 + self.up_move_speed * 22) * self._aura_prod("move")
@property
def shots(self) -> int:
return 1 + self.up_multishot
@property
def projectile_speed(self) -> float:
return 520 + self.up_projectile_speed * 90
@property
def projectile_ttl(self) -> float:
return 2.5 + self.up_projectile_speed * 0.6
@property
def regen(self) -> float:
return self.up_regen * 2.0 # hp per second
def to_dict(self):
return {
"id": self.pid, "name": self.name,
"x": round(self.x, 1), "y": round(self.y, 1),
"hp": round(self.hp, 1), "max_hp": self.max_hp,
"facing": round(self.facing, 3), "alive": self.alive,
"gold": self.gold, "level": self.level, "moving": self.moving,
"attacking": self.attack_timer > 0, "hurt": self.hurt > 0,
"xp": self.xp, "xp_needed": self.xp_needed,
"pending_cards": list(self.pending_cards),
"pending_left": round(max(0.0, self.pending_until - time.time()), 1) if self.pending_cards else 0,
"lives": self.lives, "char": self.char,
"auras": [{"type": a["type"], "t": round(a["t"], 1),
"color": AURAS.get(a["type"], {}).get("color", "#fff"),
"label": AURAS.get(a["type"], {}).get("label", a["type"])}
for a in self.auras],
"size": round(self.size_mult, 2),
"shield": round(self.shield, 1),
"flash": self.flash if self.flash_t > 0 else "",
"skills": [{"name": s["name"], "effect": s["effect"], "color": s.get("color", "#b48cff")}
for s in self.skills],
# owned level per card (skill cards report 1 when owned)
"upgrades": {k: (1 if k in self.skill_cards else getattr(self, f"up_{k}", 0))
for k in CARDS},
"stats": {"dmg": round(self.dmg_dealt), "kills": self.kills},
}
class Engine:
def __init__(self):
self.players: dict[str, Player] = {}
self.minions: list[Minion] = []
self._minion_seq = 0
self.projectiles: list[Projectile] = []
self.gems: list[Gem] = []
self.allies: list[Ally] = []
self._ally_seq = 0
self.pickups: list[Pickup] = []
self._pickup_seq = 0
self.boss = Boss()
self.round = 0
self.status = "lobby" # lobby | active | intermission
self.status_msg = "Waiting for wizards to join..."
self.gm_message = ""
self.intermission_until = 0.0
self.round_started_at = 0.0
self.skill_request_pid: str | None = None
self.skill_request_used = False
# Cards the Game Master is currently offering on level-up (ids). Empty =>
# the full set is available (deterministic default).
self.card_pool: list[str] = list(ALL_CARD_IDS)
# config applied each round (mutated by the Game Master)
self.cfg = {
"boss_hp": 1000, "boss_damage": 12,
"minion_hp": 40, "minion_count": 6, "spawn_interval": 4.0,
"boss_aggro": 1.0, # Director-tunable attack frequency
"boss_attack_speed": 1.0, # Director-tunable cadence (mercy-floored)
"boss_pattern": "balanced", # Director-chosen attack pattern
# wave composition: relative weights of each enemy archetype
"wave": {"grunt": 1.0, "fast": 0.0, "tank": 0.0},
}
# one-shot blessings the GM granted for the coming round: {pid: blessing}
self.pending_blessings: dict[str, str] = {}
# transient FX events for the client (drained each snapshot)
self._events: list[dict] = []
def _event(self, fx: str, x: float, y: float, **kw):
"""Queue a one-shot visual event for the next snapshot broadcast."""
if len(self._events) < 40:
self._events.append({"fx": fx, "x": round(x, 1), "y": round(y, 1), **kw})
# ---- lobby management -------------------------------------------------
@property
def active_players(self) -> list[Player]:
return list(self.players.values())
def can_join(self) -> bool:
return len(self.players) < MAX_PLAYERS
def add_player(self, pid: str, name: str, char: str = "warrior") -> Player:
spawn_angle = random.uniform(0, math.tau)
p = Player(
pid=pid, name=name[:16] or "Wizard", char=char or "soldier",
x=CENTER[0] + math.cos(spawn_angle) * 420,
y=CENTER[1] + math.sin(spawn_angle) * 280,
)
p.x = _clamp(p.x, 40, ARENA_W - 40)
p.y = _clamp(p.y, 40, ARENA_H - 40)
# if joining mid-wave, jump straight into the fight
if self.status == "active":
p.hp = p.max_hp
self.players[pid] = p
if self.status == "lobby" and len(self.players) >= 1:
self.status_msg = "Press START or wait for more wizards."
return p
def remove_player(self, pid: str):
self.players.pop(pid, None)
def set_input(self, pid: str, data: dict):
p = self.players.get(pid)
if not p:
return
p.up = bool(data.get("up"))
p.down = bool(data.get("down"))
p.left = bool(data.get("left"))
p.right = bool(data.get("right"))
p.attacking = bool(data.get("attack"))
def _apply_card(self, p: Player, key: str):
"""Apply a chosen level-up card's effect to a player."""
if key in SKILL_CARDS:
self._add_skill(p, skillmod.CARD_SKILLS[key])
p.skill_cards.add(key)
return
setattr(p, f"up_{key}", getattr(p, f"up_{key}") + 1)
if key in ("max_hp", "giant", "glass_cannon"):
self._recompute_max_hp(p)
if key == "max_hp":
p.hp = min(p.max_hp, p.hp + 25)
if key == "shield":
p.shield = max(p.shield, 20 * p.up_shield)
def _recompute_max_hp(self, p: Player):
p.max_hp = max(20, 100 + p.up_max_hp * 25 + p.up_giant * 30 - p.up_glass_cannon * 15)
p.hp = min(p.hp, p.max_hp)
def _add_skill(self, p: Player, spec: dict) -> dict | None:
"""Validate and attach a skill spec to a player (caps the count)."""
safe = skillmod.validate_skill(spec)
if not safe:
return None
safe = dict(safe)
safe["_cd"] = safe.get("interval", 0.0) # periodic cooldown timer
if len(p.skills) >= skillmod.MAX_SKILLS_PER_PLAYER:
p.skills.pop(0)
p.skills.append(safe)
return safe
def add_runtime_skill(self, pid: str, spec: dict) -> dict | None:
"""Hot-plug a Game-Master-authored skill onto a player (the LLM flow)."""
p = self.players.get(pid)
if not p:
return None
return self._add_skill(p, spec)
def top_player_id(self) -> str | None:
"""Highest-damage player of the round just ended (gets the skill request)."""
if not self.players:
return None
return max(self.players.values(), key=lambda p: p.dmg_dealt).pid
def choose_card(self, pid: str, key: str) -> bool:
"""Player picks one of the cards currently offered to them."""
p = self.players.get(pid)
if not p or key not in CARDS or key not in p.pending_cards:
return False
self._apply_card(p, key)
p.pending_cards = [] # one pick per level-up
p.pending_until = 0.0
return True
def buy_upgrade(self, pid: str, key: str) -> bool:
"""Legacy gold shop (kept for compatibility; survivors mode uses cards)."""
p = self.players.get(pid)
if not p or key not in UPGRADES:
return False
if self.status not in ("intermission", "lobby"):
return False
_, cost = UPGRADES[key]
if p.gold < cost:
return False
p.gold -= cost
self._apply_card(p, key)
return True
def _owns(self, p: Player, cid: str) -> bool:
"""Whether a one-time card is already taken (stat binary or a skill card)."""
if cid in SKILL_CARDS:
return cid in p.skill_cards
return bool(getattr(p, f"up_{cid}", 0))
def _draw_cards(self, p: Player, n: int = 3) -> list[str]:
"""Draw up to n distinct card ids for a player, honoring boss-exclusive gating."""
theme = max(0, (self.round - 1) // 5)
theme_cards = theme_cards_for(theme)
pool = [c for c in self.card_pool if c in CARDS] or list(ALL_CARD_IDS)
# boss-exclusive cards: only the current boss's; drop all other themes'
pool = [c for c in pool if c not in THEME_ONLY or c in theme_cards]
# ensure this boss's exclusives are eligible even if the Director omitted them
for tc in theme_cards:
if tc not in pool:
pool.append(tc)
# drop one-time cards already owned
pool = [c for c in pool if not (c in BINARY_CARDS and self._owns(p, c))]
chosen: list[str] = []
# guarantee Auto-Cast is offered early so it doesn't get lost in RNG
if not p.up_auto_attack and p.level <= 3:
chosen.append("auto_attack")
rest = [c for c in pool if c not in chosen]
random.shuffle(rest)
# bias one slot toward a boss-exclusive card when available (feels special)
avail_theme = [c for c in theme_cards if c in rest]
if avail_theme and len(chosen) < n:
pick = random.choice(avail_theme)
chosen.append(pick); rest.remove(pick)
for c in rest:
if len(chosen) >= n:
break
chosen.append(c)
return chosen[:n]
def _grant_xp(self, p: Player, amount: int):
if not p.alive:
return
p.xp += amount
leveled = False
while p.xp >= p.xp_needed:
p.xp -= p.xp_needed
p.level += 1
p.xp_needed = int(p.xp_needed * 1.45) + 3
leveled = True
if leveled and not p.pending_cards:
p.pending_cards = self._draw_cards(p, 3)
p.pending_until = time.time() + PENDING_AUTOPICK_SEC
# ---- round lifecycle --------------------------------------------------
def start_round(self):
if not self.players:
self.status = "lobby"
return
self.round += 1
self.minions.clear()
self.projectiles.clear()
self.gems.clear()
self.allies.clear()
self.pickups.clear()
self.boss = Boss(
hp=self.cfg["boss_hp"], max_hp=self.cfg["boss_hp"],
dmg=self.cfg["boss_damage"], spawn_interval=self.cfg["spawn_interval"],
aggro=self.cfg.get("boss_aggro", 1.0),
pattern=self.cfg.get("boss_pattern", "balanced"),
atk_speed=self.cfg.get("boss_attack_speed", 1.0),
)
# special boss encounter? (Aegis / Toaster Bot) — buff and rename it
sp = special_boss_for(self.round)
if sp:
cfg = SPECIAL_BOSSES[sp]
self.boss.special = sp
self.boss.name = cfg["name"]
hp = int(self.cfg["boss_hp"] * cfg["hp_mult"])
self.boss.hp = self.boss.max_hp = hp
self.boss.dmg = int(self.cfg["boss_damage"] * cfg["dmg_mult"])
# XP / level / cards persist across the run; only revive & reset per-round
# combat stats. Pending card picks carry over so a level-up isn't lost.
for p in self.players.values():
self._recompute_max_hp(p)
p.hp = p.max_hp
p.alive = p.lives > 0 # eliminated wizards stay down (awaiting removal)
p.respawn_t = 0.0
p.dmg_dealt = 0.0
p.kills = 0
p.shield = 20 * p.up_shield # Aegis refreshes each wave
ang = random.uniform(0, math.tau)
p.x = _clamp(CENTER[0] + math.cos(ang) * 420, 40, ARENA_W - 40)
p.y = _clamp(CENTER[1] + math.sin(ang) * 280, 40, ARENA_H - 40)
self._apply_blessings()
self.status = "active"
if self.boss.special:
self.status_msg = f"Wave {self.round}{self.boss.name} has arrived!"
else:
self.status_msg = f"Wave {self.round} — survive and slay the boss!"
self.round_started_at = time.time()
def _grant_boss_reward(self, special: str):
"""All living wizards gain the defeated special boss's signature attack."""
cfg = SPECIAL_BOSSES.get(special, {})
reward = cfg.get("reward")
spec = skillmod.CARD_SKILLS.get(reward)
if not spec:
return
name = spec.get("name", reward)
for p in self.players.values():
if p.alive and reward not in p.skill_cards:
self._add_skill(p, spec)
p.skill_cards.add(reward)
p.flash = f"Gained {name} from {cfg.get('name', 'the boss')}!"
p.flash_t = 3.0
def _apply_blessings(self):
"""Consume the Game Master's one-shot blessings at round start."""
for pid, blessing in self.pending_blessings.items():
p = self.players.get(pid)
if not p or blessing not in BLESSINGS:
continue
if blessing == "extra_life":
p.lives = min(MAX_LIVES, p.lives + 1)
if p.lives > 0 and not p.alive and p.respawn_t <= 0:
p.alive = True
p.hp = p.max_hp
p.flash = "🙏 Blessed: an extra life!"
elif blessing == "full_heal":
p.hp = p.max_hp
p.flash = "🙏 Blessed: fully healed!"
else: # a timed aura
spec = AURAS.get(blessing, {})
existing = next((a for a in p.auras if a["type"] == blessing), None)
if existing:
existing["t"] = spec.get("dur", 40)
else:
p.auras.append({"type": blessing, "t": spec.get("dur", 40)})
p.flash = f"🙏 Blessed: {spec.get('label', blessing)}!"
p.flash_t = 4.0
self._event("bless", p.x, p.y, color="#ffe9a0")
self.pending_blessings = {}
def _respawn(self, p: Player):
p.alive = True
p.respawn_t = 0.0
p.hp = p.max_hp
p.hurt = 1.0 # brief invuln flicker
ang = random.uniform(0, math.tau)
p.x = _clamp(CENTER[0] + math.cos(ang) * 420, 40, ARENA_W - 40)
p.y = _clamp(CENTER[1] + math.sin(ang) * 280, 40, ARENA_H - 40)
def round_summary(self) -> dict:
"""Compact, model-friendly summary of the round that just ended."""
won = self.boss.hp <= 0
return {
"round": self.round,
"result": "victory" if won else "defeat",
"duration_sec": round(time.time() - self.round_started_at, 1),
"boss_max_hp": self.boss.max_hp,
"players": [
{
"id": p.pid, "name": p.name, "level": p.level,
"survived": p.alive,
"damage_dealt": round(p.dmg_dealt),
"kills": p.kills, "gold": p.gold,
"lives_left": p.lives,
"hp_pct": round(100 * p.hp / p.max_hp) if p.alive else 0,
}
for p in self.players.values()
],
}
def apply_gm_decision(self, decision: dict):
"""Apply a (validated) Game Master decision to rewards + next config."""
rewards = decision.get("rewards", {}) or {}
for pid, amount in rewards.items():
p = self.players.get(pid)
if p:
amt = int(_clamp(int(amount), 0, 1000)) * (1 + 0.2 * p.up_gold_boost) # Prosperity
p.gold += int(amt)
nxt = decision.get("next_round", {}) or {}
self.cfg["boss_hp"] = int(_clamp(int(nxt.get("boss_hp", self.cfg["boss_hp"])), 300, 100000))
self.cfg["boss_damage"] = int(_clamp(int(nxt.get("boss_damage", self.cfg["boss_damage"])), 4, 80))
self.cfg["minion_hp"] = int(_clamp(int(nxt.get("minion_hp", self.cfg["minion_hp"])), 15, 2000))
self.cfg["minion_count"] = int(_clamp(int(nxt.get("minion_count", self.cfg["minion_count"])), 0, 40))
self.cfg["spawn_interval"] = float(_clamp(float(nxt.get("spawn_interval", self.cfg["spawn_interval"])), 1.0, 10.0))
self.cfg["boss_aggro"] = float(_clamp(float(nxt.get("boss_aggro", self.cfg["boss_aggro"])), 0.6, 3.0))
self.cfg["boss_attack_speed"] = float(_clamp(float(nxt.get("boss_attack_speed", self.cfg["boss_attack_speed"])), 0.5, 2.0))
# boss attack pattern for the coming round
pattern = nxt.get("boss_pattern", decision.get("boss_pattern"))
if pattern in BOSS_PATTERNS:
self.cfg["boss_pattern"] = pattern
# per-player blessings (validated against roster + known blessings)
blessings = decision.get("blessings")
if isinstance(blessings, dict):
self.pending_blessings = {
pid: b for pid, b in blessings.items()
if pid in self.players and b in BLESSINGS
}
# wave composition (enemy archetype weights)
wave = nxt.get("wave")
if isinstance(wave, dict):
clean = {}
for k in MINION_TYPES:
try:
clean[k] = max(0.0, float(wave.get(k, 0.0)))
except Exception:
clean[k] = 0.0
if sum(clean.values()) > 0:
self.cfg["wave"] = clean
# offered level-up card pool
pool = decision.get("card_pool")
if isinstance(pool, list):
valid = [c for c in pool if c in CARDS]
self.card_pool = valid if valid else list(ALL_CARD_IDS)
self.gm_message = str(decision.get("message", ""))[:240]
def _pick_minion_kind(self) -> str:
weights = self.cfg.get("wave") or {"grunt": 1.0}
kinds = [k for k in MINION_TYPES if weights.get(k, 0) > 0] or ["grunt"]
w = [weights.get(k, 0) for k in kinds]
return random.choices(kinds, weights=w, k=1)[0]
def begin_intermission(self, seconds: float = 12.0):
self.status = "intermission"
self.intermission_until = time.time() + seconds
won = self.boss.hp <= 0
self.status_msg = ("Victory! " if won else "Defeated... ") + \
"The Game Master is deciding rewards & the next round."
# the round's top damage-dealer may ask the GM for a custom power-up
self.skill_request_pid = self.top_player_id()
self.skill_request_used = False
# ---- simulation -------------------------------------------------------
def _spawn_minion(self):
# Minions are summoned by the boss: they emerge from around it.
ang = random.uniform(0, math.tau)
dist = random.uniform(50, 95)
x = _clamp(self.boss.x + math.cos(ang) * dist, 30, ARENA_W - 30)
y = _clamp(self.boss.y + math.sin(ang) * dist, 30, ARENA_H - 30)
kind = self._pick_minion_kind()
t = MINION_TYPES[kind]
hp = self.cfg["minion_hp"] * t["hp"]
self._minion_seq += 1
self.minions.append(Minion(
x=x, y=y, hp=hp, max_hp=hp,
speed=(70 + self.round * 4) * t["speed"],
dmg=(6 + self.round) * t["dmg"],
uid=self._minion_seq, kind=kind, scale=t["scale"],
))
def _nearest_player(self, x, y):
best, bd = None, 1e18
for p in self.players.values():
if not p.alive:
continue
d = (p.x - x) ** 2 + (p.y - y) ** 2
if d < bd:
best, bd = p, d
return best
def _nearest_enemy(self, x, y):
"""Closest minion, else the boss."""
best, bd = None, 1e18
for m in self.minions:
if not m.alive:
continue
d = (m.x - x) ** 2 + (m.y - y) ** 2
if d < bd:
best, bd = m, d
if self.boss.hp > 0:
d = (self.boss.x - x) ** 2 + (self.boss.y - y) ** 2
if d < bd or best is None:
best, bd = self.boss, d
return best
def tick(self, dt: float):
if self.status != "active":
return
for p in self.players.values():
p.hurt = max(0.0, p.hurt - dt)
p.attack_timer = max(0.0, p.attack_timer - dt)
if p.flash_t > 0:
p.flash_t -= dt
# timed auras count down and expire
if p.auras:
for a in p.auras:
a["t"] -= dt
p.auras = [a for a in p.auras if a["t"] > 0]
# auto-pick a pending card if the player dithers past the deadline
if p.pending_cards and time.time() >= p.pending_until:
self.choose_card(p.pid, p.pending_cards[0])
if not p.alive:
# respawn after a short delay while lives remain
if p.lives > 0 and p.respawn_t > 0:
p.respawn_t -= dt
if p.respawn_t <= 0:
self._respawn(p)
continue
if p.regen and p.hp < p.max_hp:
p.hp = min(p.max_hp, p.hp + p.regen * dt)
# periodic skills
for sk in p.skills:
if sk["trigger"] == "periodic":
sk["_cd"] = sk.get("_cd", sk.get("interval", 5.0)) - dt
if sk["_cd"] <= 0:
sk["_cd"] = sk.get("interval", 5.0)
self._fire_skill(p, sk)
dx = (1 if p.right else 0) - (1 if p.left else 0)
dy = (1 if p.down else 0) - (1 if p.up else 0)
p.moving = dx != 0 or dy != 0
if p.moving:
inv = 1.0 / math.hypot(dx, dy)
p.x = _clamp(p.x + dx * inv * p.move_speed * dt, 24, ARENA_W - 24)
p.y = _clamp(p.y + dy * inv * p.move_speed * dt, 24, ARENA_H - 24)
# aim at nearest enemy for satisfying co-op feel
tgt = self._nearest_enemy(p.x, p.y)
if tgt is not None:
p.facing = math.atan2(tgt.y - p.y, tgt.x - p.x)
# attack (Auto-Cast fires without holding the key)
if (p.attacking or p.up_auto_attack) and p.attack_timer <= 0:
self._player_shoot(p)
p.attack_timer = p.attack_interval
self._tick_minions(dt)
self._tick_boss(dt)
self._tick_allies(dt)
self._tick_projectiles(dt)
self._tick_gems(dt)
self._tick_pickups(dt)
self.minions = [m for m in self.minions if m.alive]
self.allies = [a for a in self.allies if a.ttl > 0 and a.hp > 0]
# round end conditions
if self.boss.hp <= 0:
# boss drops a shower of high-tier gems
for _ in range(10):
ang = random.uniform(0, math.tau)
d = random.uniform(20, 140)
gx = _clamp(self.boss.x + math.cos(ang) * d, 30, ARENA_W - 30)
gy = _clamp(self.boss.y + math.sin(ang) * d, 30, ARENA_H - 30)
g = self._make_gem(gx, gy, "tank")
g.tier = int(_clamp(g.tier + 2, 1, 7))
g.value = GEM_TIER_XP[g.tier]
self.gems.append(g)
# special boss defeated -> every survivor gains its attack power
if self.boss.special:
self._grant_boss_reward(self.boss.special)
self.begin_intermission()
elif self.players and all(not p.alive and p.respawn_t <= 0 for p in self.players.values()):
# every wizard is down with no respawn pending (all eliminated)
self.begin_intermission()
def _tick_allies(self, dt: float):
"""Summoned spirits chase the nearest enemy and fire bolts at it."""
for a in self.allies:
a.ttl -= dt
a.attack_cd = max(0.0, a.attack_cd - dt)
tgt = self._nearest_enemy(a.x, a.y)
if tgt is None:
continue
dist = math.hypot(tgt.x - a.x, tgt.y - a.y)
ang = math.atan2(tgt.y - a.y, tgt.x - a.x)
if dist > 180:
a.x += math.cos(ang) * a.speed * dt
a.y += math.sin(ang) * a.speed * dt
if a.attack_cd <= 0:
self.projectiles.append(Projectile(
x=a.x, y=a.y, vx=math.cos(ang) * 480, vy=math.sin(ang) * 480,
dmg=a.dmg, owner=a.owner, hostile=False, radius=7, ttl=1.5,
))
a.attack_cd = 0.8
def _tick_gems(self, dt: float):
"""XP gems drift toward nearby living players and are auto-collected."""
alive = []
for g in self.gems:
g.ttl -= dt
if g.ttl <= 0:
continue
tgt = self._nearest_player(g.x, g.y)
collected = False
if tgt is not None:
pull = 130.0 + tgt.up_magnet * 55 # Lodestone
grab = 26.0 + tgt.up_magnet * 10
d = math.hypot(tgt.x - g.x, tgt.y - g.y)
if d <= grab:
amount = g.value * (1 + 0.15 * tgt.up_xp_boost) # Quick Study
self._grant_xp(tgt, max(1, round(amount)))
collected = True
elif d <= pull:
ang = math.atan2(tgt.y - g.y, tgt.x - g.x)
g.x += math.cos(ang) * 240 * dt
g.y += math.sin(ang) * 240 * dt
if not collected:
alive.append(g)
self.gems = alive
def _tick_pickups(self, dt: float):
"""Floor power-ups: walk over one to claim the card instantly."""
alive = []
for pk in self.pickups:
pk.ttl -= dt
if pk.ttl <= 0:
continue
taken = False
for p in self.players.values():
if p.alive and (p.x - pk.x) ** 2 + (p.y - pk.y) ** 2 < 34 ** 2:
if pk.kind == "aura":
spec = AURAS.get(pk.card, {})
# refresh if already active, else add
existing = next((a for a in p.auras if a["type"] == pk.card), None)
if existing:
existing["t"] = spec.get("dur", 40)
else:
p.auras.append({"type": pk.card, "t": spec.get("dur", 40)})
p.flash = f"{spec.get('label', pk.card)}!"
else:
self._apply_card(p, pk.card)
p.flash = f"Power-up: {CARDS.get(pk.card, (pk.card,))[0]}!"
p.flash_t = 2.5
taken = True
break
if not taken:
alive.append(pk)
self.pickups = alive
def _player_shoot(self, p: Player):
spread = 0.18
n = p.shots
base = p.facing
radius = 9 + p.up_big_shots * 4
for i in range(n):
off = 0 if n == 1 else (i - (n - 1) / 2) * spread
ang = base + off
dmg = p.damage
crit = p.up_crit and random.random() < min(0.6, 0.08 * p.up_crit)
if crit:
dmg *= 2
self.projectiles.append(Projectile(
x=p.x, y=p.y,
vx=math.cos(ang) * p.projectile_speed,
vy=math.sin(ang) * p.projectile_speed,
dmg=dmg, owner=p.pid, hostile=False, radius=radius,
ttl=p.projectile_ttl, pierce=p.up_pierce,
homing=p.up_homing > 0, burn=p.up_burn * 4.0,
explode=60.0 if p.up_explosive else 0.0,
knock=p.up_knockback * 9.0, chain=p.up_chain, crit=bool(crit),
))
# attack-triggered skills
p._attack_count += 1
for sk in p.skills:
if sk["trigger"] == "every_n_attacks" and p._attack_count % sk.get("n", 5) == 0:
self._fire_skill(p, sk)
# ---- skill effects ----------------------------------------------------
def _fire_skill(self, p: Player, sk: dict):
eff = sk["effect"]
color = sk.get("color", "#b48cff")
if eff == "aoe_damage":
self._event("aoe_frost" if sk.get("slow") else "aoe", p.x, p.y,
r=sk["radius"], color=color)
r2 = sk["radius"] ** 2
for m in self.minions:
if m.alive and (m.x - p.x) ** 2 + (m.y - p.y) ** 2 <= r2:
m.hp -= sk["damage"]; m.hurt = 0.15
if sk.get("slow"):
m.speed *= (1 - sk["slow"])
p.dmg_dealt += sk["damage"]
if m.hp <= 0:
m.alive = False
self._on_minion_killed(m, p)
if self.boss.hp > 0 and (self.boss.x - p.x) ** 2 + (self.boss.y - p.y) ** 2 <= r2:
self.boss.hp -= sk["damage"]; self.boss.hurt = 0.1; p.dmg_dealt += sk["damage"]
elif eff == "projectile_nova":
self._event("nova", p.x, p.y, color=color)
cnt = sk["count"]
for k in range(cnt):
ang = k * math.tau / cnt
self.projectiles.append(Projectile(
x=p.x, y=p.y, vx=math.cos(ang) * 460, vy=math.sin(ang) * 460,
dmg=sk["damage"], owner=p.pid, hostile=False, radius=8, ttl=1.8,
))
elif eff == "heal":
self._event("heal", p.x, p.y, color=color)
p.hp = min(p.max_hp, p.hp + sk["amount"])
elif eff == "shield":
self._event("shield", p.x, p.y, color=color)
p.shield = min(120.0, p.shield + sk["amount"])
elif eff == "summon_ally":
self._event("summon", p.x, p.y, color=color)
for _ in range(sk["count"]):
self._summon_ally(p)
def _summon_ally(self, p: Player):
if len(self.allies) >= 24:
return
self._ally_seq += 1
ang = random.uniform(0, math.tau)
self.allies.append(Ally(
x=_clamp(p.x + math.cos(ang) * 40, 24, ARENA_W - 24),
y=_clamp(p.y + math.sin(ang) * 40, 24, ARENA_H - 24),
owner=p.pid, uid=self._ally_seq,
hp=30 + p.level * 3, max_hp=30 + p.level * 3,
dmg=10 + p.up_damage * 3,
))
def _tick_minions(self, dt: float):
for m in self.minions:
m.hurt = max(0.0, m.hurt - dt)
m.attack_cd = max(0.0, m.attack_cd - dt)
# Ignite: burn damage over time
if m.burn_t > 0:
m.burn_t -= dt
m.hp -= m.burn * dt
if m.hp <= 0:
m.alive = False
self._on_minion_killed(m, None)
continue
tgt = self._nearest_player(m.x, m.y)
if tgt is None:
continue
# Frost Presence card + Frost Aura: nearby enemies move slower
speed = m.speed
slow = 0.12 * tgt.up_slow_aura + tgt._aura_max("enemy_slow")
if slow and (tgt.x - m.x) ** 2 + (tgt.y - m.y) ** 2 < 150 ** 2:
speed *= max(0.25, 1 - slow)
ang = math.atan2(tgt.y - m.y, tgt.x - m.x)
dist = math.hypot(tgt.x - m.x, tgt.y - m.y)
reach = 20 + tgt.hit_radius # bigger wizards are easier to reach
if dist > reach:
m.x += math.cos(ang) * speed * dt
m.y += math.sin(ang) * speed * dt
elif m.attack_cd <= 0:
self._damage_player(tgt, m.dmg)
# Spiked Aura: reflect damage to the attacker
if tgt.up_thorns:
m.hp -= 5 * tgt.up_thorns
m.hurt = 0.15
if m.hp <= 0:
m.alive = False
self._on_minion_killed(m, tgt)
m.attack_cd = 1.0
def _tick_boss(self, dt: float):
b = self.boss
if b.hp <= 0:
return
b.hurt = max(0.0, b.hurt - dt)
b.shoot_cd = max(0.0, b.shoot_cd - dt)
b.spawn_cd = max(0.0, b.spawn_cd - dt)
b.charge_cd = max(0.0, b.charge_cd - dt)
b.nova_cd = max(0.0, b.nova_cd - dt)
phase2 = b.hp <= b.max_hp * 0.5
# combined attack cadence: aggro (difficulty) x atk_speed (GM mercy knob)
agg = b.aggro * (1.3 if phase2 else 1.0) * _clamp(b.atk_speed, 0.5, 2.0)
pat = BOSS_PATTERNS.get(b.pattern, BOSS_PATTERNS["balanced"])
# ---- charge attack: telegraph, then rush a player and melee hard ----
if b.state == "telegraph":
b.state_t -= dt
if b.state_t <= 0:
b.state = "charge"
b.state_t = 0.55
return # boss is winding up, no other actions
if b.state == "charge":
b.state_t -= dt
b.x = _clamp(b.x + b.cvx * dt, 40, ARENA_W - 40)
b.y = _clamp(b.y + b.cvy * dt, 40, ARENA_H - 40)
for p in self.players.values():
if p.alive and (p.x - b.x) ** 2 + (p.y - b.y) ** 2 < (54 + p.hit_radius) ** 2:
self._damage_player(p, b.dmg * 2.5) # massive melee
if b.state_t <= 0:
b.state = "idle"
b.charge_cd = max(2.0, 8.0 / (agg * pat["charge"]))
return
# drift back toward the centre when idle
b.x += (CENTER[0] - b.x) * min(1.0, dt * 0.6)
b.y += (CENTER[1] - b.y) * min(1.0, dt * 0.6)
# decide whether to start a charge (hunts down players who keep distance)
if b.charge_cd <= 0:
tgt = self._nearest_player(b.x, b.y)
if tgt:
ang = math.atan2(tgt.y - b.y, tgt.x - b.x)
speed = 620 + self.round * 8
b.cvx, b.cvy = math.cos(ang) * speed, math.sin(ang) * speed
b.state = "telegraph"
b.state_t = 0.7
return
# everything below creates boss projectiles; tag them with the boss's
# visual style (holy / glitch) at the end.
n0 = len(self.projectiles)
bstyle = SPECIAL_BOSSES.get(b.special, {}).get("style", "")
# spawn minions (the SWARMLORD pattern leans hard on this)
if b.spawn_cd <= 0 and len(self.minions) < 30:
burst = max(1, round((self.cfg["minion_count"] / 3) * pat["spawn"]))
for _ in range(burst):
self._spawn_minion()
b.spawn_cd = b.spawn_interval
# ---- special boss signature attacks ----
if b.special:
b.special_cd = max(0.0, b.special_cd - dt)
if b.special_cd <= 0:
self._boss_signature(b)
b.special_cd = max(2.5, 5.0 / agg)
# ---- ground shockwave: dense ring that punishes standing still ----
if b.nova_cd <= 0:
heavy = pat.get("slow_heavy")
n = 20 if phase2 else 14
spd, rad, dmul = (160, 18, 1.3) if heavy else (200, 13, 1.0)
for k in range(n):
ang = k * math.tau / n
self.projectiles.append(Projectile(
x=b.x, y=b.y, vx=math.cos(ang) * spd, vy=math.sin(ang) * spd,
dmg=b.dmg * dmul, owner="boss", hostile=True, ttl=4.5, radius=rad,
))
b.nova_cd = max(1.8, 6.0 / (agg * pat["nova"]))
# ---- aimed spread / radial bursts (style depends on the pattern) ----
if b.shoot_cd <= 0:
tgt = self._nearest_player(b.x, b.y)
if pat.get("snipe") and tgt:
# SNIPER: one fast, painful, well-aimed bolt
ang = math.atan2(tgt.y - b.y, tgt.x - b.x)
self.projectiles.append(Projectile(
x=b.x, y=b.y, vx=math.cos(ang) * 540, vy=math.sin(ang) * 540,
dmg=b.dmg * 1.8, owner="boss", hostile=True, ttl=3.5, radius=9,
))
b.shoot_cd = 1.3 / (agg * pat["shoot"])
elif pat.get("slow_heavy") and tgt:
# ARTILLERY: lob big slow orbs at the target
ang = math.atan2(tgt.y - b.y, tgt.x - b.x) + random.uniform(-0.15, 0.15)
self.projectiles.append(Projectile(
x=b.x, y=b.y, vx=math.cos(ang) * 170, vy=math.sin(ang) * 170,
dmg=b.dmg * 1.4, owner="boss", hostile=True, ttl=5.5, radius=20,
))
b.shoot_cd = 1.6 / (agg * pat["shoot"])
elif phase2:
for k in range(12):
ang = k * math.tau / 12 + random.uniform(-0.1, 0.1)
self.projectiles.append(Projectile(
x=b.x, y=b.y, vx=math.cos(ang) * 240, vy=math.sin(ang) * 240,
dmg=b.dmg, owner="boss", hostile=True, ttl=4, radius=12,
))
b.shoot_cd = 2.2 / (agg * pat["shoot"])
elif tgt:
ang = math.atan2(tgt.y - b.y, tgt.x - b.x)
for off in (-0.22, -0.11, 0, 0.11, 0.22):
self.projectiles.append(Projectile(
x=b.x, y=b.y,
vx=math.cos(ang + off) * 280, vy=math.sin(ang + off) * 280,
dmg=b.dmg, owner="boss", hostile=True, ttl=4, radius=12,
))
b.shoot_cd = 1.8 / (agg * pat["shoot"])
# apply the boss's projectile style to everything it just fired
if bstyle:
for pr in self.projectiles[n0:]:
pr.style = bstyle
def _boss_signature(self, b: Boss):
"""A unique telegraphed attack per special boss."""
if b.special == "aegis":
# Holy Cross: a rotating + and x of holy bolts that punish all angles
base = random.uniform(0, math.tau)
for k in range(8):
ang = base + k * math.tau / 8
self.projectiles.append(Projectile(
x=b.x, y=b.y, vx=math.cos(ang) * 260, vy=math.sin(ang) * 260,
dmg=b.dmg * 1.2, owner="boss", hostile=True, ttl=4.5, radius=16,
))
# plus aimed holy spears at the nearest two players
for p in list(self.players.values())[:2]:
if p.alive:
ang = math.atan2(p.y - b.y, p.x - b.x)
self.projectiles.append(Projectile(
x=b.x, y=b.y, vx=math.cos(ang) * 360, vy=math.sin(ang) * 360,
dmg=b.dmg * 1.6, owner="boss", hostile=True, ttl=4, radius=18,
))
elif b.special == "toaster":
# Glitch Portals: open rifts near players that spit projectiles outward
for _ in range(3):
tgt = self._nearest_player(b.x, b.y)
px = random.uniform(150, ARENA_W - 150)
py = random.uniform(120, ARENA_H - 120)
if tgt and random.random() < 0.6:
px, py = tgt.x + random.uniform(-80, 80), tgt.y + random.uniform(-80, 80)
for k in range(6):
ang = k * math.tau / 6
self.projectiles.append(Projectile(
x=px, y=py, vx=math.cos(ang) * 210, vy=math.sin(ang) * 210,
dmg=b.dmg, owner="boss", hostile=True, ttl=3.5, radius=14,
))
def _tick_projectiles(self, dt: float):
alive = []
for pr in self.projectiles:
# Seeker Bolts: steer toward the nearest enemy
if pr.homing and not pr.hostile:
tgt = self._nearest_enemy(pr.x, pr.y)
if tgt is not None:
sp = math.hypot(pr.vx, pr.vy) or 1.0
want = math.atan2(tgt.y - pr.y, tgt.x - pr.x)
cur = math.atan2(pr.vy, pr.vx)
d = (want - cur + math.pi) % math.tau - math.pi
cur += max(-6 * dt, min(6 * dt, d))
pr.vx, pr.vy = math.cos(cur) * sp, math.sin(cur) * sp
pr.x += pr.vx * dt
pr.y += pr.vy * dt
pr.ttl -= dt
if pr.ttl <= 0 or pr.x < -40 or pr.x > ARENA_W + 40 or pr.y < -40 or pr.y > ARENA_H + 40:
continue
hit = False
if pr.hostile:
for p in self.players.values():
if p.alive and (p.x - pr.x) ** 2 + (p.y - pr.y) ** 2 < (pr.radius + p.hit_radius) ** 2:
self._damage_player(p, pr.dmg)
hit = True
break
else:
owner = self.players.get(pr.owner)
for m in self.minions:
if not m.alive or m.uid in pr.hit_ids:
continue
if (m.x - pr.x) ** 2 + (m.y - pr.y) ** 2 < (pr.radius + 22) ** 2:
m.hp -= pr.dmg
m.hurt = 0.15
pr.hit_ids.add(m.uid)
if owner:
owner.dmg_dealt += pr.dmg
self._apply_hit_effects(pr, m, owner)
if m.hp <= 0:
m.alive = False
self._on_minion_killed(m, owner)
hit = True
break
if not hit and self.boss.hp > 0 and \
(self.boss.x - pr.x) ** 2 + (self.boss.y - pr.y) ** 2 < (pr.radius + 70) ** 2:
self.boss.hp -= pr.dmg
self.boss.hurt = 0.1
if owner:
owner.dmg_dealt += pr.dmg
if owner.up_lifesteal:
self._heal(owner, pr.dmg * 0.03 * owner.up_lifesteal)
hit = True
# pierce: a player shot can pass through extra enemies before dying
if hit and not pr.hostile and pr.pierce > 0:
pr.pierce -= 1
hit = False
if not hit:
alive.append(pr)
self.projectiles = alive
def _heal(self, p: Player, amt: float):
p.hp = min(p.max_hp, p.hp + amt)
def _apply_hit_effects(self, pr: Projectile, m: Minion, owner: "Player | None"):
"""Run on a player shot striking a minion: lifesteal/burn/knock/explode/chain."""
if owner:
ls = 0.03 * owner.up_lifesteal + owner._aura_max("lifesteal")
if ls:
self._heal(owner, pr.dmg * ls)
if pr.burn:
m.burn = max(m.burn, pr.burn)
m.burn_t = 3.0
if pr.knock:
ang = math.atan2(m.y - pr.y, m.x - pr.x)
m.x = _clamp(m.x + math.cos(ang) * pr.knock, 20, ARENA_W - 20)
m.y = _clamp(m.y + math.sin(ang) * pr.knock, 20, ARENA_H - 20)
if pr.explode:
r2 = pr.explode ** 2
for o in self.minions:
if o is m or not o.alive or o.uid in pr.hit_ids:
continue
if (o.x - m.x) ** 2 + (o.y - m.y) ** 2 <= r2:
o.hp -= pr.dmg * 0.5
o.hurt = 0.15
if owner:
owner.dmg_dealt += pr.dmg * 0.5
if o.hp <= 0:
o.alive = False
self._on_minion_killed(o, owner)
if pr.chain:
hit_extra = 0
for o in sorted(self.minions, key=lambda q: (q.x - m.x) ** 2 + (q.y - m.y) ** 2):
if hit_extra >= pr.chain:
break
if o is m or not o.alive or o.uid in pr.hit_ids:
continue
if (o.x - m.x) ** 2 + (o.y - m.y) ** 2 <= 170 ** 2:
o.hp -= pr.dmg * 0.5
o.hurt = 0.15
pr.hit_ids.add(o.uid)
hit_extra += 1
if owner:
owner.dmg_dealt += pr.dmg * 0.5
if o.hp <= 0:
o.alive = False
self._on_minion_killed(o, owner)
def _on_minion_killed(self, m: Minion, owner: "Player | None"):
fortune = owner.up_fortune if owner else 0
if owner:
owner.kills += 1
for sk in owner.skills:
if sk["trigger"] == "on_kill":
self._fire_skill(owner, sk)
self.gems.append(self._make_gem(m.x, m.y, m.kind, fortune))
# ~1/100 chance a minion drops a power-up card (Lucky Charm raises it)
if random.random() < 0.01 * (1 + 0.5 * fortune):
self._drop_pickup(m.x, m.y)
# rarer: a timed aura drops on the floor (~1/200, Lucky Charm raises it)
if random.random() < 0.005 * (1 + 0.5 * fortune):
self._drop_aura(m.x, m.y)
def _drop_pickup(self, x: float, y: float):
pool = [c for c in ALL_CARD_IDS
if c not in THEME_ONLY and c not in SKILL_CARDS and c != "auto_attack"]
if not pool:
return
self._pickup_seq += 1
self.pickups.append(Pickup(x=x, y=y, card=random.choice(pool), uid=self._pickup_seq))
def _drop_aura(self, x: float, y: float):
self._pickup_seq += 1
self.pickups.append(Pickup(x=x, y=y, card=random.choice(ALL_AURA_IDS),
kind="aura", uid=self._pickup_seq, ttl=18.0))
def _make_gem(self, x: float, y: float, kind: str, fortune: int = 0) -> Gem:
"""Pick a gem tier (1..7) by enemy type + wave, with a little variance."""
base = {"grunt": 1, "fast": 2, "tank": 4}.get(kind, 1)
tier = base + (1 if random.random() < 0.3 else 0) + self.round // 6 + fortune
tier = int(_clamp(tier, 1, 7))
return Gem(x=x, y=y, tier=tier, value=GEM_TIER_XP[tier])
def _damage_player(self, p: Player, dmg: float):
if not p.alive or p.hurt > 0:
return
# Evasion: chance to avoid the hit entirely
if p.up_dodge and random.random() < min(0.5, 0.05 * p.up_dodge):
p.hurt = 0.2
return
# Iron Hide: flat damage reduction
if p.up_armor:
dmg = max(1.0, dmg - 2 * p.up_armor)
# Warding aura: percent damage reduction
red = p._aura_max("dmg_red")
if red:
dmg *= (1 - red)
# Aegis: shield absorbs first
if p.shield > 0:
absorbed = min(p.shield, dmg)
p.shield -= absorbed
dmg -= absorbed
p.hp -= dmg
p.hurt = 0.4
if p.hp <= 0:
p.hp = 0
p.alive = False
p.lives -= 1 # spend a life; >0 respawns, 0 = eliminated
if p.lives > 0:
p.respawn_t = 3.0
p.auras = [] # auras fade on death
# ---- snapshot ---------------------------------------------------------
def snapshot(self) -> dict:
events, self._events = self._events, []
return {
"events": events,
"t": round(time.time(), 2),
"round": self.round,
"status": self.status,
"status_msg": self.status_msg,
"gm_message": self.gm_message,
"intermission_left": max(0, round(self.intermission_until - time.time(), 1))
if self.status == "intermission" else 0,
"boss": self.boss.to_dict() if self.boss.hp > 0 or self.status != "lobby" else None,
"theme": max(0, (self.round - 1) // 5), # enemy skin set, swaps every 5 waves
"hue": SPECIAL_BOSSES[self.boss.special]["hue"]
if self.boss.special and self.boss.hp > 0 else None,
"players": [p.to_dict() for p in self.players.values()],
"minions": [m.to_dict() for m in self.minions],
"allies": [a.to_dict() for a in self.allies],
"projectiles": [pr.to_dict() for pr in self.projectiles],
"gems": [g.to_dict() for g in self.gems],
"pickups": [pk.to_dict() for pk in self.pickups],
"arena": {"w": ARENA_W, "h": ARENA_H},
"max_players": MAX_PLAYERS,
"upgrades": {k: {"label": v[0], "cost": v[1]} for k, v in UPGRADES.items()},
"cards": {k: {"label": v[0], "desc": v[1], "rarity": v[2]}
for k, v in CARDS.items()},
"card_pool": list(self.card_pool),
"skill_request": (
{"pid": self.skill_request_pid, "used": self.skill_request_used}
if self.status == "intermission" else None
),
}