essences/
fighting.rs

1use crate::bundles::BundleId;
2use crate::dungeons::DungeonTemplateId;
3use crate::entity::{Coordinates, Entity, EntityId};
4use crate::game::EntityTemplateId;
5
6use crate::prelude::*;
7
8use strum_macros::{Display, EnumString};
9
10#[derive(
11    Clone,
12    Debug,
13    Default,
14    Serialize,
15    Deserialize,
16    PartialEq,
17    Eq,
18    Tsify,
19    EnumString,
20    Display,
21    JsonSchema,
22)]
23#[tsify(from_wasm_abi, into_wasm_abi)]
24pub enum EntityTeam {
25    #[default]
26    Ally,
27    Enemy,
28}
29
30#[derive(
31    Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq, Tsify, Display, JsonSchema,
32)]
33#[tsify(from_wasm_abi, into_wasm_abi)]
34pub enum EntityType {
35    #[schemars(title = "ПВЕ юнит")]
36    PVEEntity {
37        #[schemars(title = "ID юнита", schema_with = "entity_link_id_schema")]
38        entity_template_id: EntityTemplateId,
39    },
40    #[default]
41    #[schemars(title = "ПВП юнит")]
42    PVPEntity,
43}
44
45#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq, Tsify, JsonSchema)]
46pub struct FightEntity {
47    #[schemars(title = "Тип юнита")]
48    pub entity_type: EntityType,
49    #[schemars(title = "Координаты юнита")]
50    pub position: Coordinates,
51    #[schemars(title = "Нужна ли отрисовка большого хп бара")]
52    pub has_big_hp_bar: bool,
53    #[schemars(title = "Команда юнита")]
54    pub team: EntityTeam,
55}
56
57#[derive(
58    Clone,
59    Debug,
60    Default,
61    Serialize,
62    Deserialize,
63    PartialEq,
64    Eq,
65    Tsify,
66    EnumString,
67    Display,
68    JsonSchema,
69)]
70#[tsify(from_wasm_abi, into_wasm_abi)]
71pub enum FightType {
72    #[default]
73    CampaignFight,
74    CampaignBossFight,
75    ArenaPVP,
76    VassalPVP,
77    SingleFight,
78}
79
80#[declare]
81pub type FightTemplateId = Uuid;
82
83/// Typed, config-level mirror of `overlord_event_system`'s `WaveFightData` (the
84/// shape `prepare_fight_script` builds inline and passes to `ctx.spawn_wave`).
85/// Holds the wave data as typed config — pure data, no combat and no RNG. Field
86/// optionality mirrors `WaveFightData` exactly.
87#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Tsify, JsonSchema)]
88pub struct PrepareFightWaves {
89    #[schemars(title = "Относительные силы шаблонов сущностей")]
90    pub entities: Vec<PrepareFightEntityPower>,
91    #[schemars(title = "Волны спавна")]
92    pub waves: Vec<Vec<PrepareFightSpawn>>,
93    #[schemars(title = "Бюджет времени боя (сек), для нормализации HP мобов")]
94    pub time: f64,
95    #[serde(default)]
96    #[schemars(title = "Опорная сила волны (base_power для spawn_wave)")]
97    pub power: f64,
98    /// Streaming wave entry (docs/combat-feel-porting-plan.md, phase 3): when set, only this many
99    /// mobs of a wave are on the field at once — the rest wait in a pool and run in one-per-death.
100    /// `None` (`null` in config) = the previous behaviour bit-for-bit (the whole wave spawns at
101    /// once).
102    #[schemars(
103        title = "Streaming: сколько мобов волны на поле одновременно",
104        description = "Задано — волна выходит потоком: столько мобов на поле, остальные доливаются по мере смертей. Пусто — вся волна выходит сразу (как раньше)."
105    )]
106    pub stream_active_count: Option<u64>,
107
108    /// Boss summon (owner 2026-07-10): when set, the LAST wave of the template
109    /// is a summon wave — it never spawns on wave clear; it spawns the moment a
110    /// boss drops below this fraction of max HP (anchored at the player, normal
111    /// entrance runs). Boss killed before the trigger ⇒ the wave is skipped and
112    /// the fight ends. `None` = no summon wave, all waves behave as before.
113    #[schemars(
114        title = "Призыв босса: порог HP (доля max HP)",
115        description = "Если задано — ПОСЛЕДНЯЯ волна шаблона становится волной призыва: она выходит, когда HP босса падает ниже этой доли (0.3 = 30%). Босс убит раньше — волна пропускается. Пусто — обычные волны."
116    )]
117    pub summon_wave_at_hp_fraction: Option<f64>,
118
119    /// Reward/weight budget for the fight (growing-enemy-waves plan §2): "this
120    /// fight pays out / weighs as if it had N mobs". The content migrator writes
121    /// each fight's ORIGINAL total mob count here when it inflates concurrency.
122    /// `spawn_wave` stamps each mob with `wave_share = reward_mob_budget /
123    /// actual_total_mob_count` (per-10000 i64) so per-kill drop chance, pet-ult
124    /// charge fill and counterattack procs stay count-invariant. `None` (`null`)
125    /// ⇒ share 1.0 (all existing content bit-identical). Does NOT touch mob
126    /// HP/attack — the wave normalization already handles stats.
127    #[schemars(
128        title = "Бюджет наград/веса боя (как будто мобов N)",
129        description = "Мигратор пишет исходное число мобов боя. Каждый моб получает wave_share = бюджет / фактическое число мобов, чтобы дропы/заряд ульты пета/контратаки не зависели от числа мобов. Пусто — вес 1.0 (как раньше)."
130    )]
131    pub reward_mob_budget: Option<u64>,
132
133    /// Per-fight enemy damage multiplier: scales this fight's DAMAGE budget
134    /// (`eff_hp` in `spawn_wave`) only — mob HP, fight length and loot pacing
135    /// stay unchanged. Compensates fight shapes whose sustained concurrency
136    /// (many mobs on field at once) delivers the same total damage in a less
137    /// survivable profile. `None` (`null`) ⇒ 1.0 (no change).
138    #[schemars(
139        title = "Множитель урона врагов этого боя",
140        description = "Масштабирует только бюджет урона по игроку (0.8 = враги бьют на 20% слабее). HP мобов, длительность боя и награды не меняются. Пусто — 1.0."
141    )]
142    pub enemy_damage_mult: Option<f64>,
143}
144
145#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Tsify, JsonSchema)]
146pub struct PrepareFightEntityPower {
147    // Entity-link so the admin resolves `$ref(entities/Name~uuid)` → bare uuid in
148    // game_config.json, matching how the runtime `prepare_fight_script` sees it.
149    #[serde(default)]
150    #[schemars(schema_with = "option_entity_link_id_schema")]
151    pub entity_id: Option<String>,
152    #[serde(default)]
153    pub power: Option<f64>,
154}
155
156#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Tsify, JsonSchema)]
157pub struct PrepareFightSpawn {
158    #[schemars(schema_with = "entity_link_id_schema")]
159    pub entity_id: String,
160    #[serde(default)]
161    pub delay: Option<f64>,
162    #[serde(default)]
163    pub position: Option<Coordinates>,
164    /// Slot model (docs/combat-grid-migration-plan.md §3): exit cooldown from wave
165    /// start, in SECONDS (same unit as `delay`, unlike ms-based clock ticks).
166    /// Filled by the template migrator as `delay + (x − min_x волны) × 0.5`; inert
167    /// until the фаза-2 spawn director reads it — `delay`/`position` stay
168    /// authoritative for the old model meanwhile. `None` ⇒ 0.0 (exits immediately):
169    /// the admin build drops zero-valued optionals from game_config.json, so a
170    /// migrated `cooldown_seconds: 0` arrives here as absent — read via
171    /// `unwrap_or(0.0)`.
172    #[serde(default)]
173    #[schemars(
174        title = "Слоты: кулдаун выхода (сек от старта волны)",
175        description = "Слотовая модель: когда юнит выходит из-за края экрана. Пока сервер на старой модели — поле неактивно."
176    )]
177    pub cooldown_seconds: Option<f64>,
178    #[serde(default)]
179    #[schemars(
180        title = "Слоты: ряд посадки (0..2)",
181        description = "Слотовая модель: ряд, в котором юнит займёт слот. Пока сервер на старой модели — поле неактивно."
182    )]
183    pub row: Option<i64>,
184}
185
186// NOTE: `Eq` intentionally dropped — `prepare_fight_waves` carries f64 powers/
187// time/delays (not `Eq`). `FightTemplate` is never used as a hash/set key (only
188// `FightTemplateId` is), so `PartialEq` suffices.
189#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Tsify, JsonSchema, i18n::Translate)]
190pub struct FightTemplate {
191    #[schemars(schema_with = "id_schema")]
192    pub id: FightTemplateId,
193
194    #[schemars(title = "Название боя")]
195    #[translate]
196    pub title: i18n::I18nString,
197
198    #[schemars(title = "Мощь боя")]
199    pub power: Option<u64>,
200
201    // TODO #[schemars(schema_with = "battlefield_id_schema")]
202    // pub battlefield: BattlefieldId, // TODO mb location, not battlefield?
203    #[schemars(title = "Все существа в бою")]
204    pub fight_entities: Vec<FightEntity>,
205
206    #[schemars(title = "Максимальная длительность боя в тиках")]
207    pub max_duration_ticks: u64,
208
209    #[schemars(title = "Целевая ширина экрана (в клетках)")]
210    pub target_width_cells: u64,
211
212    #[schemars(title = "Fx на старте боя")]
213    // TODO needs implementation + maybe add delay ticks, so fx would have enought time to play?
214    pub starting_fx: String,
215
216    #[schemars(title = "Тип боя")]
217    pub fight_type: FightType,
218
219    #[schemars(title = "Количество волн в бою")]
220    pub waves_amount: i64,
221
222    #[schemars(
223        title = "Волны боя (типизированные)",
224        description = "Типизированные данные волн боя."
225    )]
226    #[serde(default)]
227    pub prepare_fight_waves: Option<PrepareFightWaves>,
228
229    #[schemars(
230        title = "Нативная функция начала боя",
231        description = "Имя нативной функции категории fight_start, выполняющей начало боя.",
232        schema_with = "fight_start_ref_schema"
233    )]
234    #[serde(default)]
235    pub start_behavior: Option<String>,
236
237    #[schemars(title = "Задник", schema_with = "asset_background_schema")]
238    pub background: String,
239
240    #[schemars(
241        title = "Время ожидания перед стартом следующего боя в тиках",
242        description = "Это момент анимации перебежки персонажа между битвами"
243    )]
244    pub start_fight_delay_ticks: Option<u64>,
245
246    #[schemars(
247        title = "Время ожидания перед стартом уже начавшегося боя в случае победы",
248        description = "Это момент, когда враги выбегают на экран"
249    )]
250    pub prepare_fight_win_duration_ticks: Option<u64>,
251
252    #[schemars(
253        title = "Время ожидания перед стартом уже начавшегося боя в случае поражения",
254        description = "Это момент, когда враги выбегают на экран"
255    )]
256    pub prepare_fight_lose_duration_ticks: Option<u64>,
257
258    #[schemars(title = "Время ожидания перед окончанием боя")]
259    pub end_fight_delay_ticks: Option<u64>,
260
261    #[schemars(
262        title = "Бандл награды за победу в бою",
263        schema_with = "option_bundle_id_schema"
264    )]
265    pub bundle_reward_id: Option<BundleId>,
266
267    #[schemars(title = "Останавливать ли бой после победы")]
268    pub stop_on_win: bool,
269
270    #[schemars(title = "Останавливать ли бой после поражения")]
271    pub stop_on_lose: bool,
272
273    #[schemars(title = "Показывать ли VS экран в начале боя")]
274    pub show_vs_screen: bool,
275
276    #[schemars(title = "Показывать ли стейджи")]
277    pub show_stages: Option<bool>,
278
279    #[schemars(
280        title = "Является ли подземельем",
281        description = "Бой подземелья (fight_template_is_dungeon): включает dungeon-таланты в init_fight."
282    )]
283    #[serde(default)]
284    pub is_dungeon: bool,
285
286    #[schemars(
287        title = "Является ли боссфайтом",
288        description = "Босс-бой (fight_template_is_bossfight): включает boss-таланты в init_fight."
289    )]
290    #[serde(default)]
291    pub is_bossfight: bool,
292}
293
294#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq, Tsify, JsonSchema)]
295pub struct ActiveDungeon {
296    pub id: DungeonTemplateId,
297    pub difficulty: i64,
298}
299
300/// One not-yet-released mob of a Streaming wave (docs/combat-feel-porting-plan.md, phase 3).
301/// Fully precomputed at wave start by `spawn_wave` — power-normalized attrs, position and the
302/// spawn id (drawn from the wave's RNG stream in config order, so seed determinism holds no
303/// matter when the release happens) — and converted to a `SpawnEntity` event on release.
304/// Deliberately a plain essences struct (not the event): the event type lives in
305/// `overlord_event_system`, which depends on this crate.
306#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq, Tsify, JsonSchema)]
307pub struct PendingWaveSpawn {
308    pub id: uuid::Uuid,
309    pub entity_template_id: EntityTemplateId,
310    pub position: Coordinates,
311    pub attributes: crate::entity::EntityAttributes,
312    pub has_big_hp_bar: bool,
313}
314
315#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq, Tsify, JsonSchema)]
316pub struct ActiveFight {
317    pub id: uuid::Uuid,
318    pub fight_id: FightTemplateId,
319    pub current_wave: i64,
320    pub player_id: EntityId,
321    pub party_player_id: Option<EntityId>,
322    pub entities: Vec<Entity>,
323    pub fight_stopped: bool,
324    pub fight_ended: bool,
325    pub max_duration_ticks: u64,
326    pub dungeon: Option<ActiveDungeon>,
327    pub paused: bool,
328    /// Streaming wave pool: mobs of the current wave not yet released to the field (released
329    /// one per enemy death while non-empty). The wave/fight is complete only when this is empty
330    /// AND no enemy is alive. `#[serde(default)]` keeps pre-existing saves loading (empty pool).
331    #[serde(default)]
332    pub pending_wave_spawns: Vec<PendingWaveSpawn>,
333    /// Boss-summon: ids of mobs spawned by a boss summon wave (marked `summoned`).
334    /// Recorded on spawn so kill quests can skip their deaths at quest-tick time
335    /// (the dead entity is already removed from `entities` by then, mirroring the
336    /// `party_player_id` pattern). Keeps a boss fight's kill-quest ticks flat.
337    /// `#[serde(default)]` keeps pre-existing saves loading (empty list).
338    #[serde(default)]
339    pub summoned_entity_ids: Vec<EntityId>,
340}
341
342impl ActiveFight {
343    pub fn get_player(&self) -> Option<&Entity> {
344        self.entities
345            .iter()
346            .find(|entity| entity.id == self.player_id)
347    }
348
349    pub fn get_player_mut(&mut self) -> Option<&mut Entity> {
350        self.entities
351            .iter_mut()
352            .find(|entity| entity.id == self.player_id)
353    }
354
355    pub fn get_party_player(&self) -> Option<&Entity> {
356        let party_id = self.party_player_id?;
357        self.entities.iter().find(|entity| entity.id == party_id)
358    }
359
360    pub fn get_party_player_mut(&mut self) -> Option<&mut Entity> {
361        let party_id = self.party_player_id?;
362        self.entities
363            .iter_mut()
364            .find(|entity| entity.id == party_id)
365    }
366
367    pub fn get_enemies_amount(&self) -> usize {
368        self.entities
369            .iter()
370            .filter(|entity| entity.team == EntityTeam::Enemy)
371            .count()
372    }
373}