Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# SRSim

一个面向《崩坏:星穹铁道》的 CLI 战斗模拟器原型。

## 当前引擎约定

- 时间线采用 `AV/AG` 模型,默认基础行动槽为 `10000`,行动顺序按最小 `AV` 决定。
- 速度变化会按照剩余 `AV × 旧速度 / 新速度` 重新换算,而不是简单重置回合。
- 行动提前 / 延后通过修改 `AG` 实现,支持与速度变化独立组合。
- 伤害结算已拆出基础乘区:基础伤害、暴击、增伤、弱化、防御、抗性、易伤、减伤与 Broken 乘区。
- `Unit` 已预留 Toughness 与基础战斗修正字段,便于继续扩展击破、状态与更复杂脚本层。

## 后续建议

- 补完事件总线,把 Battle Start、Wave Start、Turn Start、Weakness Break 等时点抽成统一钩子。
- 将 SP、Energy、Aggro 与 Super Break 细节配置化,避免第一版硬编码过死。
- 在角色脚本层继续扩展 Follow-Up、Extra Turn、Summon 与更复杂的目标选择逻辑。
66 changes: 61 additions & 5 deletions src/srsim/core/damage.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,70 @@ class DamageContext:
attacker: Unit
defender: Unit
multiplier: float
flat_damage: float = 0.0
can_crit: bool = True
force_crit: bool = False


def calculate_damage(context: DamageContext) -> int:
attacker_stats = context.attacker.snapshot_stats()
defender_stats = context.defender.snapshot_stats()

base = attacker_stats.atk * context.multiplier
# Simple defense mitigation; placeholder for full formula.
mitigation = defender_stats.defense * 0.3
dmg = max(0.0, base - mitigation)
return int(floor(dmg))
base_damage = attacker_stats.atk * context.multiplier + context.flat_damage
crit_multiplier = _calc_crit_multiplier(context, attacker_stats.crit_rate, attacker_stats.crit_dmg)
dmg_boost_multiplier = 1.0 + max(0.0, attacker_stats.dmg_boost)
weaken_multiplier = 1.0 - _clamp(context.attacker.modifiers.weaken, 0.0, 1.0)
defense_multiplier = _calc_def_multiplier(context)
resistance_multiplier = _calc_res_multiplier(context, defender_stats.resistance)
vulnerability_multiplier = 1.0 + max(0.0, context.defender.modifiers.vulnerability)
mitigation_multiplier = 1.0 - _clamp(context.defender.modifiers.mitigation, 0.0, 1.0)
broken_multiplier = 1.0 if context.defender.is_broken() else 0.9

damage = (
base_damage
* crit_multiplier
* dmg_boost_multiplier
* weaken_multiplier
* defense_multiplier
* resistance_multiplier
* vulnerability_multiplier
* mitigation_multiplier
* broken_multiplier
)
return int(floor(max(0.0, damage)))


def _calc_crit_multiplier(
context: DamageContext,
crit_rate: float,
crit_dmg: float,
) -> float:
if not context.can_crit:
return 1.0
if context.force_crit or crit_rate >= 1.0:
return 1.0 + crit_dmg
return 1.0


def _calc_def_multiplier(context: DamageContext) -> float:
attacker_level = context.attacker.level
defender_level = context.defender.level
effective_def_modifier = max(
0.0,
1.0
+ context.defender.modifiers.defense_bonus
- context.defender.modifiers.defense_reduction
- context.attacker.modifiers.defense_ignore,
)
numerator = attacker_level + 20
denominator = (defender_level + 20) * effective_def_modifier + attacker_level + 20
return numerator / denominator


def _calc_res_multiplier(context: DamageContext, target_resistance: float) -> float:
effective_res = _clamp(target_resistance - context.attacker.modifiers.res_pen, -1.0, 0.9)
return 1.0 - effective_res


def _clamp(value: float, minimum: float, maximum: float) -> float:
return max(minimum, min(maximum, value))
2 changes: 2 additions & 0 deletions src/srsim/core/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ def run(self, max_turns: int = 100) -> BattleOutcome:
ult_action = self.ai.choose_action(actor, self.state).action
if isinstance(ult_action, UltimateAction):
self._execute_action(ult_action)
if self.state.is_finished():
break

decision = self.ai.choose_action(actor, self.state)
self._execute_action(decision.action)
Expand Down
16 changes: 15 additions & 1 deletion src/srsim/core/stats.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,26 @@ class Stats:
defense: int
spd: int
max_energy: int = 100
crit_rate: float = 0.05
crit_dmg: float = 0.50
dmg_boost: float = 0.0
break_effect: float = 0.0
energy_regen_rate: float = 0.0
effect_res: float = 0.0
resistance: float = 0.0

def copy(self) -> Stats:
def copy(self) -> "Stats":
return Stats(
max_hp=self.max_hp,
atk=self.atk,
defense=self.defense,
spd=self.spd,
max_energy=self.max_energy,
crit_rate=self.crit_rate,
crit_dmg=self.crit_dmg,
dmg_boost=self.dmg_boost,
break_effect=self.break_effect,
energy_regen_rate=self.energy_regen_rate,
effect_res=self.effect_res,
resistance=self.resistance,
)
8 changes: 6 additions & 2 deletions src/srsim/core/timeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,18 @@
class Timeline:
units: list[Unit]

def __post_init__(self) -> None:
for index, unit in enumerate(self.units):
unit.spawn_order = index

def alive_units(self) -> list[Unit]:
return [unit for unit in self.units if not unit.is_defeated()]

def next_actor(self) -> Unit | None:
alive = self.alive_units()
if not alive:
return None
next_unit = min(alive, key=lambda unit: unit.current_action_value)
next_unit = min(alive, key=lambda unit: (unit.current_action_value, unit.spawn_order))
tick = next_unit.current_action_value
for unit in alive:
unit.speed_tick(tick)
Expand All @@ -24,7 +28,7 @@ def next_actor(self) -> Unit | None:
def reschedule(self, unit: Unit, advance_ratio: float = 0.0, delay_ratio: float = 0.0) -> None:
unit.reset_action_value(advance_ratio=advance_ratio, delay_ratio=delay_ratio)

def fast_forward(self, amount: int) -> None:
def fast_forward(self, amount: float) -> None:
for unit in self.alive_units():
unit.speed_tick(amount)

Expand Down
105 changes: 90 additions & 15 deletions src/srsim/core/unit.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,38 @@
from .enums import Faction
from .stats import Stats

ACTION_GAUGE_BASE = 10000.0

def action_value_from_spd(spd: int) -> int:
base = max(1, int(10000 / max(1, spd)))
return base

@dataclass
class SpeedState:
current_spd: float
current_av: float
current_ag: float


@dataclass
class CombatModifiers:
damage_boost: float = 0.0
weaken: float = 0.0
defense_bonus: float = 0.0
defense_reduction: float = 0.0
defense_ignore: float = 0.0
resistance: float = 0.0
res_pen: float = 0.0
vulnerability: float = 0.0
mitigation: float = 0.0


@dataclass
class ToughnessState:
current: int
maximum: int
broken: bool = False


def action_value_from_spd(spd: float) -> float:
return ACTION_GAUGE_BASE / max(1.0, spd)


@dataclass
Expand All @@ -20,21 +48,37 @@ class Unit:
kit: UnitKit
hp: int = field(init=False)
energy: int = field(init=False)
base_action_value: int = field(init=False)
current_action_value: int = field(init=False)
speed_state: SpeedState = field(init=False)
modifiers: CombatModifiers = field(default_factory=CombatModifiers)
toughness: ToughnessState | None = None
spawn_order: int = 0

def __post_init__(self) -> None:
self.hp = self.base_stats.max_hp
self.energy = 0
self.base_action_value = action_value_from_spd(self.base_stats.spd)
self.current_action_value = self.base_action_value
base_av = action_value_from_spd(self.base_stats.spd)
self.speed_state = SpeedState(
current_spd=float(self.base_stats.spd),
current_av=base_av,
current_ag=base_av * float(self.base_stats.spd),
)

@property
def base_action_value(self) -> float:
return action_value_from_spd(self.base_stats.spd)

@property
def current_action_value(self) -> float:
return self.speed_state.current_av

def is_defeated(self) -> bool:
return self.hp <= 0

def reset_action_value(self, advance_ratio: float = 0.0, delay_ratio: float = 0.0) -> None:
offset = self.base_action_value * (delay_ratio - advance_ratio)
self.current_action_value = max(0, int(self.base_action_value + offset))
self.speed_state.current_av = action_value_from_spd(self.speed_state.current_spd)
self.speed_state.current_ag = self.speed_state.current_av * self.speed_state.current_spd
if advance_ratio != 0.0 or delay_ratio != 0.0:
self.modify_action_gauge(advance_ratio=advance_ratio, delay_ratio=delay_ratio)

def take_damage(self, amount: int) -> int:
damage = max(0, amount)
Expand All @@ -60,11 +104,42 @@ def energy_full(self) -> bool:
return self.energy >= self.base_stats.max_energy

def snapshot_stats(self) -> Stats:
# Placeholder for future buff system, currently return base.
return self.base_stats.copy()

def speed_tick(self, delta: int) -> None:
self.current_action_value = max(0, self.current_action_value - delta)
stats = self.base_stats.copy()
stats.spd = int(round(self.speed_state.current_spd))
stats.dmg_boost += self.modifiers.damage_boost
stats.resistance += self.modifiers.resistance
return stats

def speed_tick(self, delta: float) -> None:
self.speed_state.current_av = max(0.0, self.speed_state.current_av - delta)
self.speed_state.current_ag = self.speed_state.current_av * self.speed_state.current_spd

def set_speed(self, new_spd: float) -> None:
bounded_spd = max(1.0, new_spd)
current_av = self.speed_state.current_av
current_spd = self.speed_state.current_spd
self.speed_state.current_av = current_av * current_spd / bounded_spd
self.speed_state.current_spd = bounded_spd
self.speed_state.current_ag = self.speed_state.current_av * bounded_spd

def modify_action_gauge(self, advance_ratio: float = 0.0, delay_ratio: float = 0.0) -> None:
new_ag = max(
0.0,
self.speed_state.current_ag - ACTION_GAUGE_BASE * (advance_ratio - delay_ratio),
)
self.speed_state.current_ag = new_ag
self.speed_state.current_av = new_ag / self.speed_state.current_spd

def mark_broken(self, broken: bool) -> None:
if self.toughness is None:
return
self.toughness.broken = broken

def is_broken(self) -> bool:
return self.toughness is not None and self.toughness.broken

def __repr__(self) -> str:
return f"{self.name}(HP={self.hp}, EN={self.energy}, AV={self.current_action_value})"
return (
f"{self.name}(HP={self.hp}, EN={self.energy}, SPD={self.speed_state.current_spd}, "
f"AV={self.speed_state.current_av:.2f})"
)
77 changes: 73 additions & 4 deletions tests/test_battle.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
from math import isclose

from srsim.core.abilities import ActionConfig, UnitKit
from srsim.core.battle_state import BattleState
from srsim.core.damage import DamageContext, calculate_damage
from srsim.core.engine import BattleEngine
from srsim.core.enums import ActionType, Faction
from srsim.core.stats import Stats
from srsim.core.unit import Unit
from srsim.core.timeline import Timeline
from srsim.core.unit import CombatModifiers, ToughnessState, Unit


def build_test_unit(name: str, faction: Faction) -> Unit:
stats = Stats(max_hp=800, atk=100, defense=80, spd=100, max_energy=100)
def build_test_unit(name: str, faction: Faction, spd: int = 100) -> Unit:
stats = Stats(max_hp=800, atk=100, defense=80, spd=spd, max_energy=100)
kit = UnitKit(
basic=ActionConfig(
name=f"{name} Basic",
Expand All @@ -30,7 +34,7 @@ def build_test_unit(name: str, faction: Faction) -> Unit:
action_type=ActionType.ULTIMATE,
),
)
return Unit(unit_id=name, name=name, faction=faction, level=1, base_stats=stats, kit=kit)
return Unit(unit_id=name, name=name, faction=faction, level=80, base_stats=stats, kit=kit)


def test_battle_runs_to_completion() -> None:
Expand All @@ -40,3 +44,68 @@ def test_battle_runs_to_completion() -> None:
outcome = BattleEngine(state).run(max_turns=30)
assert outcome.winner in {"allies", "enemies", "draw"}
assert outcome.turns > 0


def test_speed_change_recalculates_remaining_action_value() -> None:
unit = build_test_unit("A", Faction.ALLY, spd=100)
unit.speed_tick(40.0)

unit.set_speed(125.0)

assert isclose(unit.current_action_value, 48.0, rel_tol=0.0, abs_tol=1e-6)
assert isclose(unit.speed_state.current_ag, 6000.0, rel_tol=0.0, abs_tol=1e-6)


def test_advance_forward_uses_action_gauge_math() -> None:
unit = build_test_unit("A", Faction.ALLY, spd=100)

unit.modify_action_gauge(advance_ratio=0.5)

assert isclose(unit.current_action_value, 50.0, rel_tol=0.0, abs_tol=1e-6)
assert isclose(unit.speed_state.current_ag, 5000.0, rel_tol=0.0, abs_tol=1e-6)


def test_timeline_uses_action_value_then_spawn_order() -> None:
slow = build_test_unit("Slow", Faction.ALLY, spd=100)
fast = build_test_unit("Fast", Faction.ENEMY, spd=125)
timeline = Timeline([slow, fast])

first_actor = timeline.next_actor()
assert first_actor is fast

timeline.reschedule(first_actor)
second_actor = timeline.next_actor()

assert second_actor is slow


def test_damage_formula_applies_def_resistance_vulnerability_and_broken() -> None:
attacker = build_test_unit("A", Faction.ALLY)
defender = build_test_unit("B", Faction.ENEMY)
attacker.base_stats = Stats(
max_hp=800,
atk=1000,
defense=100,
spd=100,
crit_rate=1.0,
crit_dmg=1.0,
dmg_boost=0.5,
max_energy=100,
)
attacker.hp = attacker.base_stats.max_hp
attacker.modifiers = CombatModifiers(defense_ignore=0.2, res_pen=0.1)
defender.base_stats = Stats(
max_hp=1200,
atk=100,
defense=400,
spd=90,
max_energy=100,
resistance=0.2,
)
defender.hp = defender.base_stats.max_hp
defender.modifiers = CombatModifiers(vulnerability=0.25, mitigation=0.1)
defender.toughness = ToughnessState(current=0, maximum=90, broken=True)

damage = calculate_damage(DamageContext(attacker=attacker, defender=defender, multiplier=2.0))

assert damage == 3375
Loading