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)]
190pub struct FightTemplate {
191    #[schemars(schema_with = "id_schema")]
192    pub id: FightTemplateId,
193
194    #[schemars(title = "Название боя")]
195    pub title: i18n::I18nString,
196
197    #[schemars(title = "Мощь боя")]
198    pub power: Option<u64>,
199
200    // TODO #[schemars(schema_with = "battlefield_id_schema")]
201    // pub battlefield: BattlefieldId, // TODO mb location, not battlefield?
202    #[schemars(title = "Все существа в бою")]
203    pub fight_entities: Vec<FightEntity>,
204
205    #[schemars(title = "Максимальная длительность боя в тиках")]
206    pub max_duration_ticks: u64,
207
208    #[schemars(title = "Целевая ширина экрана (в клетках)")]
209    pub target_width_cells: u64,
210
211    #[schemars(title = "Fx на старте боя")]
212    // TODO needs implementation + maybe add delay ticks, so fx would have enought time to play?
213    pub starting_fx: String,
214
215    #[schemars(title = "Тип боя")]
216    pub fight_type: FightType,
217
218    #[schemars(title = "Количество волн в бою")]
219    pub waves_amount: i64,
220
221    #[schemars(
222        title = "Волны боя (типизированные)",
223        description = "Типизированные данные волн боя."
224    )]
225    #[serde(default)]
226    pub prepare_fight_waves: Option<PrepareFightWaves>,
227
228    #[schemars(
229        title = "Нативная функция начала боя",
230        description = "Имя нативной функции категории fight_start, выполняющей начало боя.",
231        schema_with = "fight_start_ref_schema"
232    )]
233    #[serde(default)]
234    pub start_behavior: Option<String>,
235
236    #[schemars(title = "Задник", schema_with = "asset_background_schema")]
237    pub background: String,
238
239    #[schemars(
240        title = "Время ожидания перед стартом следующего боя в тиках",
241        description = "Это момент анимации перебежки персонажа между битвами"
242    )]
243    pub start_fight_delay_ticks: Option<u64>,
244
245    #[schemars(
246        title = "Время ожидания перед стартом уже начавшегося боя в случае победы",
247        description = "Это момент, когда враги выбегают на экран"
248    )]
249    pub prepare_fight_win_duration_ticks: Option<u64>,
250
251    #[schemars(
252        title = "Время ожидания перед стартом уже начавшегося боя в случае поражения",
253        description = "Это момент, когда враги выбегают на экран"
254    )]
255    pub prepare_fight_lose_duration_ticks: Option<u64>,
256
257    #[schemars(title = "Время ожидания перед окончанием боя")]
258    pub end_fight_delay_ticks: Option<u64>,
259
260    #[schemars(
261        title = "Бандл награды за победу в бою",
262        schema_with = "option_bundle_id_schema"
263    )]
264    pub bundle_reward_id: Option<BundleId>,
265
266    #[schemars(title = "Останавливать ли бой после победы")]
267    pub stop_on_win: bool,
268
269    #[schemars(title = "Останавливать ли бой после поражения")]
270    pub stop_on_lose: bool,
271
272    #[schemars(title = "Показывать ли VS экран в начале боя")]
273    pub show_vs_screen: bool,
274
275    #[schemars(title = "Показывать ли стейджи")]
276    pub show_stages: Option<bool>,
277
278    #[schemars(
279        title = "Является ли подземельем",
280        description = "Бой подземелья (fight_template_is_dungeon): включает dungeon-таланты в init_fight."
281    )]
282    #[serde(default)]
283    pub is_dungeon: bool,
284
285    #[schemars(
286        title = "Является ли боссфайтом",
287        description = "Босс-бой (fight_template_is_bossfight): включает boss-таланты в init_fight."
288    )]
289    #[serde(default)]
290    pub is_bossfight: bool,
291}
292
293#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq, Tsify, JsonSchema)]
294pub struct ActiveDungeon {
295    pub id: DungeonTemplateId,
296    pub difficulty: i64,
297}
298
299/// One not-yet-released mob of a Streaming wave (docs/combat-feel-porting-plan.md, phase 3).
300/// Fully precomputed at wave start by `spawn_wave` — power-normalized attrs, position and the
301/// spawn id (drawn from the wave's RNG stream in config order, so seed determinism holds no
302/// matter when the release happens) — and converted to a `SpawnEntity` event on release.
303/// Deliberately a plain essences struct (not the event): the event type lives in
304/// `overlord_event_system`, which depends on this crate.
305#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq, Tsify, JsonSchema)]
306pub struct PendingWaveSpawn {
307    pub id: uuid::Uuid,
308    pub entity_template_id: EntityTemplateId,
309    pub position: Coordinates,
310    pub attributes: crate::entity::EntityAttributes,
311    pub has_big_hp_bar: bool,
312}
313
314#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq, Tsify, JsonSchema)]
315pub struct ActiveFight {
316    pub id: uuid::Uuid,
317    pub fight_id: FightTemplateId,
318    pub current_wave: i64,
319    pub player_id: EntityId,
320    pub party_player_id: Option<EntityId>,
321    pub entities: Vec<Entity>,
322    pub fight_stopped: bool,
323    pub fight_ended: bool,
324    pub max_duration_ticks: u64,
325    pub dungeon: Option<ActiveDungeon>,
326    pub paused: bool,
327    /// Streaming wave pool: mobs of the current wave not yet released to the field (released
328    /// one per enemy death while non-empty). The wave/fight is complete only when this is empty
329    /// AND no enemy is alive. `#[serde(default)]` keeps pre-existing saves loading (empty pool).
330    #[serde(default)]
331    pub pending_wave_spawns: Vec<PendingWaveSpawn>,
332    /// Boss-summon: ids of mobs spawned by a boss summon wave (marked `summoned`).
333    /// Recorded on spawn so kill quests can skip their deaths at quest-tick time
334    /// (the dead entity is already removed from `entities` by then, mirroring the
335    /// `party_player_id` pattern). Keeps a boss fight's kill-quest ticks flat.
336    /// `#[serde(default)]` keeps pre-existing saves loading (empty list).
337    #[serde(default)]
338    pub summoned_entity_ids: Vec<EntityId>,
339}
340
341impl ActiveFight {
342    pub fn get_player(&self) -> Option<&Entity> {
343        self.entities
344            .iter()
345            .find(|entity| entity.id == self.player_id)
346    }
347
348    pub fn get_player_mut(&mut self) -> Option<&mut Entity> {
349        self.entities
350            .iter_mut()
351            .find(|entity| entity.id == self.player_id)
352    }
353
354    pub fn get_party_player(&self) -> Option<&Entity> {
355        let party_id = self.party_player_id?;
356        self.entities.iter().find(|entity| entity.id == party_id)
357    }
358
359    pub fn get_party_player_mut(&mut self) -> Option<&mut Entity> {
360        let party_id = self.party_player_id?;
361        self.entities
362            .iter_mut()
363            .find(|entity| entity.id == party_id)
364    }
365
366    pub fn get_enemies_amount(&self) -> usize {
367        self.entities
368            .iter()
369            .filter(|entity| entity.team == EntityTeam::Enemy)
370            .count()
371    }
372}