From dca5910f8d996497995987d78977a3d94a3fdec7 Mon Sep 17 00:00:00 2001 From: TommrraraSnow Date: Fri, 20 Mar 2026 11:00:48 +0800 Subject: [PATCH] feat: align battle core with av and damage rules --- README.md | 17 ++++++ src/srsim/core/damage.py | 66 +++++++++++++++++++++-- src/srsim/core/engine.py | 2 + src/srsim/core/stats.py | 16 +++++- src/srsim/core/timeline.py | 8 ++- src/srsim/core/unit.py | 105 +++++++++++++++++++++++++++++++------ tests/test_battle.py | 77 +++++++++++++++++++++++++-- 7 files changed, 264 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index e69de29..e6ffbc7 100644 --- a/README.md +++ b/README.md @@ -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 与更复杂的目标选择逻辑。 diff --git a/src/srsim/core/damage.py b/src/srsim/core/damage.py index cf42a52..1f434f7 100644 --- a/src/srsim/core/damage.py +++ b/src/srsim/core/damage.py @@ -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)) diff --git a/src/srsim/core/engine.py b/src/srsim/core/engine.py index 6a39bf4..0207820 100644 --- a/src/srsim/core/engine.py +++ b/src/srsim/core/engine.py @@ -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) diff --git a/src/srsim/core/stats.py b/src/srsim/core/stats.py index 6f1b831..d7acbdf 100644 --- a/src/srsim/core/stats.py +++ b/src/srsim/core/stats.py @@ -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, ) diff --git a/src/srsim/core/timeline.py b/src/srsim/core/timeline.py index dc073a0..665ad0a 100644 --- a/src/srsim/core/timeline.py +++ b/src/srsim/core/timeline.py @@ -8,6 +8,10 @@ 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()] @@ -15,7 +19,7 @@ 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) @@ -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) diff --git a/src/srsim/core/unit.py b/src/srsim/core/unit.py index 2d14a1e..93da90b 100644 --- a/src/srsim/core/unit.py +++ b/src/srsim/core/unit.py @@ -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 @@ -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) @@ -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})" + ) diff --git a/tests/test_battle.py b/tests/test_battle.py index ae36af5..cdc0882 100644 --- a/tests/test_battle.py +++ b/tests/test_battle.py @@ -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", @@ -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: @@ -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