tabras / budget.py
vvennelakanti's picture
finished base
a8afc36
Raw
History Blame Contribute Delete
5.38 kB
from dataclasses import dataclass
from random import Random
from typing import Sequence
from primitives import Effect, PrimitiveId, School, primitive_allowed_for_school, render_effect
MAX_ENERGY_COST = 5
MIRACLE_BUDGET_CAP = 8
@dataclass(frozen=True)
class EffectPlan:
primitive_id: PrimitiveId
weight: int = 1
@dataclass(frozen=True)
class CardSpec:
name: str
cost: int
school: School
theme: str
effect_plans: tuple[EffectPlan, ...]
flavor: str = ""
art_prompt: str = ""
miracle: bool = False
@dataclass(frozen=True)
class Card:
name: str
cost: int
school: School
theme: str
effects: tuple[Effect, ...]
flavor: str = ""
art_prompt: str = ""
art_uri: str = ""
# Return fixed, parseable rules text for the card.
def rules_text(self) -> str:
return " ".join(render_effect(effect) for effect in self.effects)
# Convert energy cost into point budget.
def budget_for_energy(cost: int) -> int:
if cost < 1 or cost > MAX_ENERGY_COST:
raise ValueError(f"card cost must be 1..{MAX_ENERGY_COST}")
return cost
# Roll the bounded high-variance miracle budget.
def miracle_budget(cost: int, rng: Random) -> int:
return min(MIRACLE_BUDGET_CAP, budget_for_energy(cost) * rng.randint(1, 3))
# Build a fully costed card from model-chosen effect shapes.
def cost_card(spec: CardSpec, rng: Random | None = None) -> Card:
validate_card_spec(spec)
point_budget = miracle_budget(spec.cost, rng or Random()) if spec.miracle else budget_for_energy(spec.cost)
effects = cost_effects(point_budget, spec.effect_plans, spec.school)
return Card(spec.name, spec.cost, spec.school, spec.theme, effects, spec.flavor, spec.art_prompt)
# Build fully costed effects from weighted primitive plans.
def cost_effects(point_budget: int, plans: Sequence[EffectPlan], school: School | None = None) -> tuple[Effect, ...]:
usable_plans = tuple(plans[:point_budget])
total_weight = sum(plan.weight for plan in usable_plans)
taxed = len(usable_plans) > 1
effects: list[Effect] = []
spent = 0
for index, plan in enumerate(usable_plans):
remaining = point_budget - spent
points = remaining if index == len(usable_plans) - 1 else max(1, point_budget * plan.weight // total_weight)
spent += points
effects.append(cost_effect(plan.primitive_id, points, taxed, school))
return tuple(effects)
# Build one fully costed effect from an allocated point budget.
def cost_effect(primitive_id: PrimitiveId, points: int, taxed: bool = False, school: School | None = None) -> Effect:
if points < 1:
raise ValueError("effect points must be positive")
match primitive_id:
case "deal":
return Effect("deal", amount=damage_amount(points, taxed))
case "burn":
return Effect("burn", amount=burn_amount(points, taxed), duration=2)
case "bomb":
return Effect("bomb", amount=damage_amount(points, taxed), delay=3)
case "block":
return Effect("block", amount=points * (2 if taxed else 3))
case "ward":
return Effect("ward", amount=points)
case "weak":
return Effect("weak", amount=damage_amount(points, taxed), duration=1)
case "draw":
return Effect("draw", amount=max(1, points * 2 // 3))
case "energy":
return Effect("energy", amount=max(1, points // 2))
case "initiative":
return Effect("initiative", duration=1)
case "multi_hit":
return Effect("multi_hit", amount=max(1, points), hits=2 if points < 4 else 3)
case "vulnerable":
return Effect("vulnerable", amount=max(1, points), duration=1 if points < 4 else 2)
case "conditional":
return Effect("conditional", amount=conditional_amount(points, taxed), condition="opponent_below_half")
case "scaling":
return Effect("scaling", amount=points, condition=scaling_condition(school))
# Return direct damage for a point allocation.
def damage_amount(points: int, taxed: bool) -> int:
return max(1, points * 2 - (1 if taxed else 0))
# Return burn damage per tick for a point allocation.
def burn_amount(points: int, taxed: bool) -> int:
return max(1, points - (1 if taxed and points > 1 else 0))
# Return maximum scaled conditional damage for a point allocation.
def conditional_amount(points: int, taxed: bool) -> int:
return max(1, points - (1 if taxed and points > 1 else 0))
# Return the scaling counter used by a school.
def scaling_condition(school: School | None) -> str:
return "shield_charge" if school == "earth" else "cards_played"
# Validate a model-authored card spec before costing it.
def validate_card_spec(spec: CardSpec) -> None:
budget_for_energy(spec.cost)
if not spec.effect_plans:
raise ValueError("card must have at least one effect")
for plan in spec.effect_plans:
validate_effect_plan(plan, spec.school)
# Validate one model-chosen effect plan.
def validate_effect_plan(plan: EffectPlan, school: School) -> None:
if plan.weight < 1:
raise ValueError("effect weight must be positive")
if not primitive_allowed_for_school(plan.primitive_id, school):
raise ValueError(f"{plan.primitive_id} is not allowed for {school}")