Initial commit: Plants turn-based battle game
This commit is contained in:
@@ -0,0 +1,333 @@
|
||||
from typing import List
|
||||
|
||||
from . import collection
|
||||
from .plants import POOL, VARIABLE_TYPE_UNITS, make_leveled, plant_rarity
|
||||
from .types import ALL_TYPES, TYPE_BEATS
|
||||
from .unit import ROLE_ATTACKER, ROLE_SUPPORT, ROLE_TANK, TEAM_ENEMY, TEAM_PLAYER, Unit
|
||||
|
||||
|
||||
DIFFICULTY_RARITIES = {
|
||||
1: ("rare",),
|
||||
2: ("rare", "super_rare"),
|
||||
3: ("rare", "super_rare"),
|
||||
4: ("super_rare", "super_super_rare"),
|
||||
5: ("super_super_rare",),
|
||||
}
|
||||
|
||||
# Low-rarity plants that join floor enemies at higher floors (deterministic pick).
|
||||
FLOOR_RARE_PLANTS = ["Marigold", "Daffodil", "Peony", "Primrose",
|
||||
"Hibiscus", "Dandelion", "Venus Flytrap"]
|
||||
# Low-rarity tank plants used as enemy tanks on later floors.
|
||||
FLOOR_TANK_PLANTS = ["Marigold", "Daffodil", "Venus Flytrap"]
|
||||
|
||||
|
||||
def floor_enemy_count(floor: int) -> int:
|
||||
"""Number of enemies on a battle floor: 1 on floors 1-3, 2 on 4-9, 3 on
|
||||
10-15, then a full 4-enemy team from floor 16 on."""
|
||||
if floor <= 3:
|
||||
return 1
|
||||
if floor <= 9:
|
||||
return 2
|
||||
if floor <= 15:
|
||||
return 3
|
||||
return 4
|
||||
|
||||
|
||||
def floor_enemy_names(floor: int, player_type: str) -> List[str]:
|
||||
"""Deterministic enemy names for a battle floor. Floors 1-3 are a single
|
||||
pinwheel; the team grows from there — 3+ enemies from floor 10 and always
|
||||
4 from floor 16. Teams mix pinwheels, butterflies and toadstools, and
|
||||
duplicates are allowed (e.g. two pinwheels). Tank plants join from floor
|
||||
40 and low-rarity plants from floor 45, replacing critter slots so a team
|
||||
never exceeds 4 enemies."""
|
||||
count = floor_enemy_count(floor)
|
||||
# Lead with an attacker; the rest cycle through the trio so each floor gets
|
||||
# a deterministic mix of roles (with repeats) built from the critters.
|
||||
names = ["Pinwheel"]
|
||||
cycle = ("Butterfly", "Toadstool", "Pinwheel", "Toadstool",
|
||||
"Butterfly", "Pinwheel", "Butterfly", "Toadstool")
|
||||
for i in range(1, count):
|
||||
names.append(cycle[(floor + i) % len(cycle)])
|
||||
# High floors mix in plants, replacing critter slots (4-enemy cap kept).
|
||||
if floor >= 40 and count >= 3:
|
||||
names[-1] = FLOOR_TANK_PLANTS[(floor // 2) % len(FLOOR_TANK_PLANTS)]
|
||||
if floor >= 45 and count >= 4:
|
||||
names[-2] = FLOOR_RARE_PLANTS[(floor * 7) % len(FLOOR_RARE_PLANTS)]
|
||||
return names
|
||||
|
||||
|
||||
def roll_enemy_types(names: List[str], player_type: str, floor: int) -> List[str]:
|
||||
"""Types for a floor's enemies (parallel to `names`). The floor 1-3 pinwheel is
|
||||
always the type the player is strong against; other critters roll a random
|
||||
type; plants keep their own fixed type."""
|
||||
import random
|
||||
types = []
|
||||
for i, n in enumerate(names):
|
||||
if n in VARIABLE_TYPE_UNITS:
|
||||
if floor <= 3:
|
||||
types.append(TYPE_BEATS.get(player_type, "garden"))
|
||||
else:
|
||||
types.append(random.choice(ALL_TYPES))
|
||||
else:
|
||||
types.append(POOL[n]["type"])
|
||||
return types
|
||||
|
||||
|
||||
def floor_enemy_level(floor: int) -> int:
|
||||
"""Enemy level for a floor (ramps toward ~20 at floor 100)."""
|
||||
return 1 + (floor - 1) // 5
|
||||
|
||||
|
||||
def make_floor_enemies(floor: int, player_type: str) -> List[Unit]:
|
||||
"""Build the enemy unit team for a floor, with level/stage scaling and a
|
||||
fresh roll of critter types."""
|
||||
names = floor_enemy_names(floor, player_type)
|
||||
types = roll_enemy_types(names, player_type, floor)
|
||||
return build_floor_enemies(names, types, floor)
|
||||
|
||||
|
||||
def build_floor_enemies(names: List[str], types: List[str], floor: int) -> List[Unit]:
|
||||
"""Build enemy units from an already-rolled name/type list (so the picker and
|
||||
the actual battle always show the same enemies)."""
|
||||
level = floor_enemy_level(floor)
|
||||
stage = max(1, min(5, 1 + (floor - 1) // 20))
|
||||
return [make_leveled(n, TEAM_ENEMY, level, stage, ptype=t)
|
||||
for n, t in zip(names, types)]
|
||||
|
||||
|
||||
def difficulty_pool(difficulty: int) -> List[str]:
|
||||
rarities = DIFFICULTY_RARITIES.get(difficulty, DIFFICULTY_RARITIES[1])
|
||||
return [n for n in POOL if plant_rarity(n) in rarities]
|
||||
|
||||
|
||||
TOWER_STAGES = 20
|
||||
TOWER_INJECT_FLOOR = 10
|
||||
TOWER_INJECT_CHANCE_MIN = 0.20
|
||||
TOWER_INJECT_CHANCE_MAX = 0.60
|
||||
|
||||
|
||||
def tower_inject_chance(stage: int) -> float:
|
||||
"""Chance (0..1) that a tower enemy at `stage` carries an injected ability."""
|
||||
if stage < TOWER_INJECT_FLOOR:
|
||||
return 0.0
|
||||
frac = min(1.0, (stage - TOWER_INJECT_FLOOR) / (TOWER_STAGES - TOWER_INJECT_FLOOR))
|
||||
return TOWER_INJECT_CHANCE_MIN + frac * (TOWER_INJECT_CHANCE_MAX - TOWER_INJECT_CHANCE_MIN)
|
||||
|
||||
|
||||
def roll_enemy_injection(name: str) -> dict:
|
||||
"""Roll an injected ability for an enemy plant, or None. It comes from a
|
||||
same-role plant's extractable skills; legendary plants only pull from other
|
||||
legendary plants so they stay strong. Never replaces a slot with the same
|
||||
ability it already has."""
|
||||
import random
|
||||
|
||||
from .skills import skill_to_dict
|
||||
my_rarity = plant_rarity(name)
|
||||
my_role = POOL[name]["role"]
|
||||
base = POOL[name]["skills"]
|
||||
candidates = []
|
||||
for other, spec in POOL.items():
|
||||
if other == name:
|
||||
continue
|
||||
if spec["role"] != my_role:
|
||||
continue
|
||||
if my_rarity == "super_super_rare" and plant_rarity(other) != "super_super_rare":
|
||||
continue
|
||||
for _, sk in collection.extractable_skills(other):
|
||||
candidates.append(sk)
|
||||
if not candidates:
|
||||
return None
|
||||
pairs = []
|
||||
for sk in candidates:
|
||||
for slot in (1, 2):
|
||||
if slot < len(base) and base[slot].name != sk.name:
|
||||
pairs.append((sk, slot))
|
||||
if not pairs:
|
||||
return None
|
||||
sk, slot = random.choice(pairs)
|
||||
return {"index": slot, "source": name, "skill": skill_to_dict(sk)}
|
||||
|
||||
|
||||
def make_tower_enemies(names: List[str], level: int, stage: int) -> List[Unit]:
|
||||
"""Build a tower enemy team; each enemy independently rolls a chance to
|
||||
carry one injected ability (floors 10+) and gains runes at higher stages."""
|
||||
import random
|
||||
|
||||
from . import runes
|
||||
from .skills import skill_from_dict
|
||||
units = []
|
||||
estage = tower_enemy_stage(stage)
|
||||
for n in names:
|
||||
u = make_leveled(n, TEAM_ENEMY, level, estage)
|
||||
if random.random() < tower_inject_chance(stage):
|
||||
inj = roll_enemy_injection(n)
|
||||
if inj and inj["index"] < len(u.skills):
|
||||
u.skills[inj["index"]] = skill_from_dict(inj["skill"])
|
||||
u.injected_index = inj["index"]
|
||||
u.injected_name = inj["skill"]["name"]
|
||||
u.runes = runes.roll_enemy_runes(stage)
|
||||
units.append(u)
|
||||
return units
|
||||
|
||||
|
||||
def roll_tower_enemy_team(stage: int, level: int, exclude=()) -> tuple:
|
||||
"""Roll a full tower enemy team once, returning (names, injections, runes).
|
||||
The recipe is cached per stage so a retry rebuilds the exact same team
|
||||
instead of rerolling it."""
|
||||
import random
|
||||
|
||||
from . import runes
|
||||
names = roll_tower_enemies(stage, exclude=exclude)
|
||||
injections = []
|
||||
for n in names:
|
||||
inj = None
|
||||
if random.random() < tower_inject_chance(stage):
|
||||
inj = roll_enemy_injection(n)
|
||||
injections.append(inj)
|
||||
enemy_runes = [runes.roll_enemy_runes(stage) for _ in names]
|
||||
return names, injections, enemy_runes
|
||||
|
||||
|
||||
def build_tower_enemy_team(names: List[str], injections, enemy_runes,
|
||||
level: int, stage: int) -> List[Unit]:
|
||||
"""Rebuild tower enemy units from a previously rolled team recipe."""
|
||||
from .skills import skill_from_dict
|
||||
units = []
|
||||
estage = tower_enemy_stage(stage)
|
||||
for n, inj, rns in zip(names, injections, enemy_runes or []):
|
||||
u = make_leveled(n, TEAM_ENEMY, level, estage)
|
||||
if inj and inj["index"] < len(u.skills):
|
||||
u.skills[inj["index"]] = skill_from_dict(inj["skill"])
|
||||
u.injected_index = inj["index"]
|
||||
u.injected_name = inj["skill"]["name"]
|
||||
u.runes = list(rns) if rns else []
|
||||
units.append(u)
|
||||
return units
|
||||
|
||||
|
||||
def tower_pool(stage: int) -> tuple:
|
||||
"""Rarities available at a tower stage (stage 1 = rares only, ramping up)."""
|
||||
if stage <= 1:
|
||||
return ("rare",)
|
||||
if stage <= 3:
|
||||
return ("rare", "super_rare")
|
||||
if stage <= 5:
|
||||
return ("rare", "super_rare")
|
||||
if stage <= 8:
|
||||
return ("super_rare",)
|
||||
if stage <= 12:
|
||||
return ("super_rare", "super_super_rare")
|
||||
return ("super_super_rare",)
|
||||
|
||||
|
||||
def tower_enemy_stage(stage: int) -> int:
|
||||
"""Evolution stage for tower enemies: they evolve as the tower ramps, which
|
||||
multiplies their base stats (1.0 / 1.25 / 1.55)."""
|
||||
if stage >= 11:
|
||||
return 3
|
||||
if stage >= 5:
|
||||
return 2
|
||||
return 1
|
||||
|
||||
|
||||
def tower_enemy_level(player_level: int, stage: int) -> int:
|
||||
return max(1, min(33, player_level + (stage - 1) * 3 // 4))
|
||||
|
||||
|
||||
def roll_tower_enemies(stage: int, exclude=()) -> List[str]:
|
||||
"""Roll the tower enemy team for a stage. Count grows 1 -> 4; rarity ramps.
|
||||
Tanks are scarce at high stages (there are no super-super-rare tanks), so the
|
||||
tank slot is filled from the super-rare pool when the stage pool has none."""
|
||||
import random
|
||||
pool = [n for n in POOL
|
||||
if plant_rarity(n) in tower_pool(stage) and n not in exclude]
|
||||
count = 1 if stage == 1 else min(4, (stage + 3) // 2)
|
||||
if count == 1:
|
||||
return [random.choice(pool)]
|
||||
|
||||
def _by(role, source):
|
||||
return [n for n in source if POOL[n]["role"] == role]
|
||||
|
||||
tank_pool = _by(ROLE_TANK, pool)
|
||||
if not tank_pool:
|
||||
tank_pool = [n for n in POOL
|
||||
if POOL[n]["role"] == ROLE_TANK and plant_rarity(n) == "super_rare"
|
||||
and n not in exclude]
|
||||
|
||||
chosen = []
|
||||
for role, source in ((ROLE_TANK, tank_pool), (ROLE_SUPPORT, pool),
|
||||
(ROLE_ATTACKER, pool), (ROLE_ATTACKER, pool)):
|
||||
if len(chosen) >= count:
|
||||
break
|
||||
cands = [n for n in _by(role, source) if n not in chosen]
|
||||
if cands:
|
||||
chosen.append(random.choice(cands))
|
||||
remaining = [n for n in pool if n not in chosen]
|
||||
random.shuffle(remaining)
|
||||
return (chosen + remaining)[:count]
|
||||
|
||||
|
||||
def tower_reward(stage: int):
|
||||
"""Reward for defeating a tower stage: (water, tower_packs)."""
|
||||
if stage <= 5:
|
||||
return 1, 0
|
||||
return 0, 1 + (stage - 6) // 5
|
||||
|
||||
|
||||
def roll_enemies(difficulty: int, exclude=()) -> List[str]:
|
||||
"""Roll a 4-plant enemy team for a difficulty stage, aiming for
|
||||
1 tank + 1 support + 2 attackers. Falls back gracefully when a role
|
||||
isn't present in the pool (e.g. no legendary tanks on stage 5)."""
|
||||
import random
|
||||
pool = [n for n in difficulty_pool(difficulty) if n not in exclude]
|
||||
if len(pool) < 4: # safety fallback: never fewer than a full team
|
||||
pool = [n for n in POOL if n not in exclude]
|
||||
|
||||
def _by_role(role):
|
||||
return [n for n in pool if POOL[n]["role"] == role]
|
||||
|
||||
chosen = []
|
||||
for role in (ROLE_TANK, ROLE_SUPPORT, ROLE_ATTACKER, ROLE_ATTACKER):
|
||||
candidates = [n for n in _by_role(role) if n not in chosen]
|
||||
if candidates:
|
||||
chosen.append(random.choice(candidates))
|
||||
remaining = [n for n in pool if n not in chosen]
|
||||
random.shuffle(remaining)
|
||||
return (chosen + remaining)[:4]
|
||||
|
||||
|
||||
def pick_team(names: List[str], team: int) -> List[Unit]:
|
||||
units = []
|
||||
for n in names:
|
||||
prog = collection.get_progress(n)
|
||||
units.append(make_leveled(n, team, prog["level"], prog["stage"],
|
||||
ptype=collection.unit_type(n)))
|
||||
return units
|
||||
|
||||
|
||||
def make_enemy_team(names: List[str], level: int = 1, stage: int = 1) -> List[Unit]:
|
||||
return [make_leveled(n, TEAM_ENEMY, level, stage) for n in names]
|
||||
|
||||
|
||||
def player_team_level(names: List[str]) -> int:
|
||||
levels = [collection.get_progress(n)["level"] for n in names]
|
||||
return max(1, round(sum(levels) / max(1, len(levels))))
|
||||
|
||||
|
||||
def player_team_stage(names: List[str]) -> int:
|
||||
stages = [collection.get_progress(n)["stage"] for n in names]
|
||||
return max(1, round(sum(stages) / max(1, len(stages))))
|
||||
|
||||
|
||||
DEFAULT_PLAYERS = ["Lavender", "Marigold", "Sunflower", "Zinnia"]
|
||||
DEFAULT_ENEMIES = ["Rose", "Tulip", "Camelia", "Hellebore"]
|
||||
|
||||
|
||||
def make_roster():
|
||||
"""Deterministic level-1 rosters (used by tests; independent of save data)."""
|
||||
return ([make_leveled(n, TEAM_PLAYER, 1, 1) for n in DEFAULT_PLAYERS],
|
||||
[make_leveled(n, TEAM_ENEMY, 1, 1) for n in DEFAULT_ENEMIES])
|
||||
|
||||
|
||||
def all_names() -> List[str]:
|
||||
return list(POOL.keys())
|
||||
Reference in New Issue
Block a user