217 lines
7.0 KiB
Python
217 lines
7.0 KiB
Python
from __future__ import annotations
|
||
|
||
import random
|
||
|
||
# Rune kinds (gems). Each rune is an item equipped into a plant's slots.
|
||
RUNE_COLORS = ("sapphire", "ruby", "amethyst", "jade", "brown", "pearl")
|
||
RUNE_NAMES = {
|
||
"sapphire": "Sapphire Rune",
|
||
"ruby": "Ruby Rune",
|
||
"amethyst": "Amethyst Rune",
|
||
"jade": "Jade Rune",
|
||
"brown": "Brown Rune",
|
||
"pearl": "Pearl Rune",
|
||
}
|
||
|
||
# Tier classification: sapphire/ruby/amethyst/jade are Tier 1, brown & pearl Tier 2.
|
||
TIER1_COLORS = ("sapphire", "ruby", "amethyst", "jade")
|
||
TIER2_COLORS = ("brown", "pearl")
|
||
RUNE_TIERS = {c: 1 for c in TIER1_COLORS}
|
||
RUNE_TIERS.update({c: 2 for c in TIER2_COLORS})
|
||
|
||
# Sprite file (without extension) per rune color.
|
||
RUNE_ICON_FILES = {
|
||
"sapphire": "sapphire_rune",
|
||
"ruby": "ruby_rune",
|
||
"jade": "jade_rune",
|
||
"amethyst": "amethyst_rune",
|
||
"brown": "rune_brown",
|
||
"pearl": "pearl_rune",
|
||
}
|
||
|
||
# Old color names kept in earlier saves, mapped to the current gem names.
|
||
RUNE_COLOR_ALIASES = {
|
||
"blue": "sapphire",
|
||
"red": "ruby",
|
||
"white": "amethyst",
|
||
"yellow": "jade",
|
||
}
|
||
|
||
|
||
def canonical_color(color: str) -> str:
|
||
"""Map an old rune color name (blue/red/white/yellow) to the current gem."""
|
||
return RUNE_COLOR_ALIASES.get(color, color)
|
||
|
||
|
||
# Primitive effects: each rune rolls its own stat and stacks additively with
|
||
# other equipped runes of the same effect. Brown is rarer and rolls lower, but
|
||
# turns part of the damage this plant deals into a Wilt. Pearl rolls a chunk of
|
||
# critical hit chance.
|
||
RUNE_EFFECTS = {
|
||
"ruby": "damage", # +damage dealt
|
||
"sapphire": "shield", # +shielding
|
||
"jade": "healing", # +healing received
|
||
"amethyst": "damage_taken", # -damage taken
|
||
"brown": "wilt", # damage dealt also Wils the target
|
||
"pearl": "crit", # +critical hit chance
|
||
}
|
||
PCT_MIN = 25
|
||
PCT_MAX = 35
|
||
BROWN_PCT_MIN = 5
|
||
BROWN_PCT_MAX = 15
|
||
PEARL_PCT_MIN = 20
|
||
PEARL_PCT_MAX = 40
|
||
# a plant can't stack more than this many runes of the same type
|
||
MAX_RUNES_PER_TYPE = 2
|
||
# runes sacrificed to reroll one rune to a higher stat
|
||
RUNE_SACRIFICE_COST = 3
|
||
# coin costs to buy a random rune of a tier in the runes shop
|
||
T1_RUNE_COST = 100
|
||
T2_RUNE_COST = 150
|
||
# brown & pearl drop less often than the other runes (relative weights)
|
||
RUNE_WEIGHTS = {"sapphire": 1.0, "ruby": 1.0, "amethyst": 1.0, "jade": 1.0,
|
||
"brown": 0.75, "pearl": 0.75}
|
||
_EFFECT_TEXTS = {
|
||
"ruby": "+{pct}% damage dealt",
|
||
"sapphire": "+{pct}% shielding",
|
||
"jade": "+{pct}% healing",
|
||
"amethyst": "-{pct}% damage taken",
|
||
"brown": "deals {pct}% of damage as Wilt",
|
||
"pearl": "+{pct}% critical chance",
|
||
}
|
||
RUNE_SUMMARIES = {
|
||
"ruby": "Increases damage dealt",
|
||
"sapphire": "Increases shielding",
|
||
"jade": "Increases healing received",
|
||
"amethyst": "Reduces damage taken",
|
||
"brown": "Deals a portion of damage as Wilt",
|
||
"pearl": "Increases critical hit chance",
|
||
}
|
||
|
||
MAX_RUNE_SLOTS = 3
|
||
|
||
# Drop chances (test build)
|
||
BATTLE_DROP_CHANCE = 0.15 # rare rune from a normal floor battle win
|
||
PACK_DROP_CHANCE = 0.05 # rare additional rune from a seed pack
|
||
|
||
|
||
def random_color() -> str:
|
||
"""Roll a rune color; brown is weighted rarer than the others."""
|
||
weights = [RUNE_WEIGHTS.get(c, 1.0) for c in RUNE_COLORS]
|
||
return random.choices(RUNE_COLORS, weights=weights, k=1)[0]
|
||
|
||
|
||
def roll_pct(color: str = None) -> int:
|
||
"""Roll a rune's stat percentage. Brown rolls 5-15, pearl 20-40, the rest 25-35."""
|
||
if color == "brown":
|
||
return random.randint(BROWN_PCT_MIN, BROWN_PCT_MAX)
|
||
if color == "pearl":
|
||
return random.randint(PEARL_PCT_MIN, PEARL_PCT_MAX)
|
||
return random.randint(PCT_MIN, PCT_MAX)
|
||
|
||
|
||
def max_pct(color: str) -> int:
|
||
"""The highest stat a rune of `color` can roll (brown/pearl have their own caps)."""
|
||
if color == "brown":
|
||
return BROWN_PCT_MAX
|
||
if color == "pearl":
|
||
return PEARL_PCT_MAX
|
||
return PCT_MAX
|
||
|
||
|
||
def name(color: str) -> str:
|
||
return RUNE_NAMES.get(color, "Rune")
|
||
|
||
|
||
def tier(color: str) -> int:
|
||
return RUNE_TIERS.get(color, 1)
|
||
|
||
|
||
def roll_ratio(rune) -> float:
|
||
"""How close a rune's roll is to its own maximum (0..1). Each color is
|
||
compared to its own cap, so a maxed brown or pearl ranks equally with a
|
||
maxed ruby even though 15 < 35."""
|
||
cap = max_pct(rune.get("color"))
|
||
return rune.get("pct", 0) / cap
|
||
|
||
|
||
def effect_text(color: str, pct: int) -> str:
|
||
"""Human-readable effect for a rune of `color` with rolled stat `pct`."""
|
||
return _EFFECT_TEXTS.get(color, "").format(pct=pct)
|
||
|
||
|
||
_CARD_TEXTS = {
|
||
"ruby": "+{pct}% dmg",
|
||
"sapphire": "+{pct}% shield",
|
||
"jade": "+{pct}% heal",
|
||
"amethyst": "-{pct}% taken",
|
||
"brown": "Wilt {pct}%",
|
||
"pearl": "+{pct}% crit",
|
||
}
|
||
|
||
|
||
def card_text(color: str, pct: int) -> str:
|
||
"""Compact effect label that fits a small card (e.g. 'Wilt 12%')."""
|
||
return _CARD_TEXTS.get(color, "").format(pct=pct)
|
||
|
||
|
||
def effect_for(color: str) -> str:
|
||
return RUNE_EFFECTS.get(color, "")
|
||
|
||
|
||
_EFFECT_COLORS = {v: k for k, v in RUNE_EFFECTS.items()}
|
||
|
||
|
||
def color_for_effect(effect: str) -> str:
|
||
"""The rune colour whose effect is `effect` (for showing enemy runes)."""
|
||
return _EFFECT_COLORS.get(effect, "")
|
||
|
||
|
||
def summary(color: str) -> str:
|
||
return RUNE_SUMMARIES.get(color, "")
|
||
|
||
|
||
def roll_range(color: str) -> str:
|
||
"""The stat range a rune of `color` rolls between (brown/pearl are lower)."""
|
||
if color == "brown":
|
||
return f"{BROWN_PCT_MIN}–{BROWN_PCT_MAX}%"
|
||
if color == "pearl":
|
||
return f"{PEARL_PCT_MIN}–{PEARL_PCT_MAX}%"
|
||
return f"{PCT_MIN}–{PCT_MAX}%"
|
||
|
||
|
||
def slots_for_max_stage(max_stage: int) -> int:
|
||
"""Permanent rune slots unlocked by the highest evolution stage a plant has
|
||
ever reached (stage 1 -> 1 slot, stage 2 -> 2, stage 3 -> 3)."""
|
||
return min(MAX_RUNE_SLOTS, max(1, max_stage))
|
||
|
||
|
||
def enemy_rune_count(stage: int) -> int:
|
||
"""How many runes a tower enemy carries at `stage` (more as the tower ramps)."""
|
||
if stage < 3:
|
||
return 0
|
||
return min(3, 1 + (stage - 3) // 5)
|
||
|
||
|
||
def roll_enemy_runes(stage: int) -> list:
|
||
"""Roll the runes a single tower enemy carries at `stage`. Returns a list of
|
||
(effect_key, pct_fraction) pairs ready for `Unit.runes`. At most
|
||
MAX_RUNES_PER_TYPE runes of the same type are rolled (same rule as players)."""
|
||
count = enemy_rune_count(stage)
|
||
runes_list = []
|
||
counts = {}
|
||
while len(runes_list) < count:
|
||
color = random_color()
|
||
if counts.get(color, 0) >= MAX_RUNES_PER_TYPE:
|
||
continue # type already at the cap; roll another colour
|
||
counts[color] = counts.get(color, 0) + 1
|
||
runes_list.append((effect_for(color), roll_pct(color) / 100.0))
|
||
return runes_list
|
||
|
||
|
||
def per_rune_chance(source_chance: float) -> tuple:
|
||
"""Effective drop chance (low, high) for a specific rune colour from a drop
|
||
source, where brown's lower weight makes it the low end."""
|
||
weights = [RUNE_WEIGHTS.get(c, 1.0) for c in RUNE_COLORS]
|
||
total = sum(weights)
|
||
return source_chance * min(weights) / total, source_chance * max(weights) / total |