essences/
items.rs

1use crate::prelude::*;
2use chrono::{DateTime, Utc};
3use enum_iterator::Sequence;
4use strum_macros::{Display, EnumIter, EnumString};
5
6use crate::class::ClassId;
7use crate::currency::CurrencyUnit;
8pub use crate::flip::WorldSide;
9use crate::skins::SkinId;
10
11#[declare]
12pub type AttributeId = Uuid;
13
14#[derive(PartialEq, Eq, Default, Hash, Debug, Clone, Deserialize, Serialize, Tsify, JsonSchema)]
15pub struct Attribute {
16    #[schemars(schema_with = "id_schema")]
17    pub id: AttributeId,
18
19    #[schemars(title = "Название атрибута")]
20    pub name: i18n::I18nString,
21
22    #[schemars(title = "Префикс аттрибута")]
23    pub prefix: Option<i18n::I18nString>,
24
25    #[schemars(title = "Суффикс атрибута")]
26    pub suffix: Option<i18n::I18nString>,
27
28    #[schemars(title = "Код атрибута")]
29    pub code: String,
30
31    #[schemars(title = "Иконка аттрибута", schema_with = "webp_url_schema")]
32    pub icon: String,
33
34    #[schemars(title = "Иконка", schema_with = "asset_attribute_icon_schema")]
35    pub icon_path: String,
36
37    #[schemars(title = "Технический идентификатор", range(min = 0, max = 63))]
38    pub db_code: u8,
39
40    #[schemars(title = "Делитель значения аттрибута")]
41    pub denominator: Option<i64>,
42
43    #[schemars(title = "Является ли значение процентом")]
44    pub is_percent: bool,
45
46    #[schemars(
47        title = "Нативная функция вычисления значения",
48        description = "Имя нативной функции категории item_attribute, вычисляющей значение атрибута.",
49        schema_with = "item_attribute_ref_schema"
50    )]
51    #[serde(default)]
52    pub calculation_behavior: Option<String>,
53
54    #[schemars(
55        title = "Приоритет атрибута",
56        description = "Приоритет атрибута, чем меньше значение - тем выше приоритет"
57    )]
58    pub order: i64,
59
60    #[schemars(title = "Показывать ли атрибут в ui")]
61    pub is_ui_visible: bool,
62
63    #[schemars(title = "Описание аттрибута")]
64    pub description: i18n::I18nString,
65
66    #[schemars(
67        title = "Базовое значение атрибута",
68        description = "Стартовое значение стата до бонусов (например received_damage = 10000 = 100%). \
69            Применяется в get_entity_stat. null = нет базового значения."
70    )]
71    #[serde(default)]
72    pub base_value: Option<i64>,
73}
74
75#[derive(
76    Debug,
77    Clone,
78    Copy,
79    EnumString,
80    Sequence,
81    Display,
82    Deserialize,
83    Serialize,
84    Hash,
85    Eq,
86    PartialEq,
87    EnumIter,
88    Default,
89    JsonSchema,
90    Tsify,
91)]
92#[tsify(namespace)]
93pub enum ItemType {
94    #[default]
95    Weapon,
96    Torso,
97    Head,
98    Gloves,
99    Shoulders,
100    Boots,
101    Legs,
102    Waist,
103    Neck,
104    Ring,
105}
106
107impl ItemType {
108    /// Whether this equipment type has independent Real and Fantasy slots.
109    pub const fn supports_world_side(self) -> bool {
110        matches!(
111            self,
112            Self::Weapon | Self::Torso | Self::Head | Self::Shoulders | Self::Gloves
113        )
114    }
115}
116
117/// Logical equipment slot used by equip and item-power comparisons.
118///
119/// Fixed equipment types always normalize to `world_side = None`; flip-capable
120/// types keep their Real/Fantasy side. A missing side on a flip-capable item is
121/// retained as `None` so inventories can still hydrate safely during rollout.
122#[derive(Debug, Clone, Copy, Hash, Eq, PartialEq)]
123pub struct EquipmentSlotKey {
124    item_type: ItemType,
125    world_side: Option<WorldSide>,
126}
127
128impl EquipmentSlotKey {
129    pub const fn new(item_type: ItemType, world_side: Option<WorldSide>) -> Self {
130        Self {
131            item_type,
132            world_side: if item_type.supports_world_side() {
133                world_side
134            } else {
135                None
136            },
137        }
138    }
139
140    pub const fn item_type(self) -> ItemType {
141        self.item_type
142    }
143
144    pub const fn world_side(self) -> Option<WorldSide> {
145        self.world_side
146    }
147}
148
149#[declare]
150pub type ItemRarityId = Uuid;
151
152#[derive(Default, PartialEq, Eq, Hash, Debug, Clone, Serialize, Deserialize, Tsify, JsonSchema)]
153pub struct ItemRarity {
154    #[schemars(schema_with = "id_schema")]
155    pub id: ItemRarityId,
156    #[schemars(title = "Код редкости (числовой тир, для генерации оппонентов)")]
157    #[serde(default)]
158    pub code: i64,
159    #[schemars(title = "Название редкости")]
160    pub name: i18n::I18nString,
161    #[schemars(title = "Цвет редкости текста", schema_with = "color_schema")]
162    pub text_color: String,
163    #[schemars(title = "URL картинки редкости", schema_with = "webp_url_schema")]
164    pub rarity_icon: String,
165    #[schemars(title = "Картинка", schema_with = "asset_item_rarity_icon_schema")]
166    pub rarity_icon_path: String,
167    #[schemars(
168        title = "URL картинки ленточки под айтемом",
169        schema_with = "webp_url_schema"
170    )]
171    pub ribbon_icon: String,
172    #[schemars(
173        title = "Лента-подложка",
174        schema_with = "asset_item_ribbon_icon_schema"
175    )]
176    pub ribbon_icon_path: String,
177    #[schemars(
178        title = "Приоритет редкости",
179        description = "Приоритет редкости, чем меньше значение - тем выше приоритет"
180    )]
181    pub order: i64,
182
183    #[schemars(
184        title = "Качество редкости (q)",
185        description = "Множитель силы предметов этой редкости (item_rarity_q). Используется в balance::eff_item."
186    )]
187    #[serde(default)]
188    pub q: i64,
189
190    #[schemars(
191        title = "Цена продажи (sell_q)",
192        description = "Множитель Gold при продаже предмета этой редкости. Отделён от q намеренно: \
193                       q задаёт силу и растёт плавно, а цена продажи должна следовать лестнице цен \
194                       сундука, чтобы продажи закрывали 78-82% стоимости следующего апгрейда. \
195                       Одно число не может делать и то и другое. Используется только в item_price."
196    )]
197    pub sell_q: i64,
198}
199
200#[derive(Debug, Serialize, Deserialize, Clone, JsonSchema, Tsify)]
201pub struct ItemAttributeSettings {
202    #[schemars(title = "Количество опциональных атрибутов у элемента")]
203    pub optional_attributes_count: u64,
204
205    #[schemars(
206        title = "Идентификаторы опциональных атрибутов",
207        schema_with = "attribute_link_id_array_schema"
208    )]
209    pub optional_attributes_ids: Vec<AttributeId>,
210}
211
212#[declare]
213pub type ItemTemplateId = Uuid;
214
215#[derive(Debug, Serialize, Deserialize, Clone, JsonSchema, Tsify)]
216pub struct ItemTemplate {
217    #[schemars(schema_with = "id_schema")]
218    pub id: ItemTemplateId,
219
220    #[schemars(title = "Название предмета")]
221    pub name: i18n::I18nString,
222
223    #[schemars(title = "URL картинки", schema_with = "webp_url_schema")]
224    pub icon_url: String,
225
226    #[schemars(title = "Иконка", schema_with = "asset_item_icon_schema")]
227    pub icon_path: String,
228
229    #[schemars(title = "Тип предмета")]
230    pub item_type: ItemType,
231
232    /// Сторона flip-предмета; `None` для постоянных типов экипировки.
233    pub world_side: Option<WorldSide>,
234
235    #[schemars(title = "Id редкости", schema_with = "item_rarity_link_id_schema")]
236    pub rarity_id: ItemRarityId,
237
238    #[schemars(title = "Настройки атрибутов для предмета")]
239    pub attributes_settings: ItemAttributeSettings,
240
241    #[schemars(title = "Id скина", schema_with = "option_skin_link_id_schema")]
242    pub skin_id: Option<SkinId>,
243
244    #[schemars(title = "Исключить из выдачи мимика")]
245    pub exclude_from_mimic: bool,
246
247    #[schemars(
248        title = "Требуемый класс",
249        description = "Если задан, предмет может быть получен только игроком с этим классом"
250    )]
251    pub required_class: Option<ClassId>,
252
253    #[schemars(
254        title = "Фиксированная сила предмета",
255        description = "Если задано, заменяет случайный разброс силы (item_fixed_power) в balance::attr_spread_for_item."
256    )]
257    #[serde(default)]
258    pub fixed_power: Option<f64>,
259
260    #[schemars(
261        title = "Код предмета для мимика",
262        description = "Если задан, квест может выдать именно этот предмет из сундука: SetCustomValue(next_mimic_item_code, <код>) у персонажа резолвится в этот шаблон через content::get_item_by_code (item_id_by_mimic_code)."
263    )]
264    #[serde(default)]
265    pub next_mimic_item_code: Option<i64>,
266}
267
268impl ItemTemplate {
269    pub const fn equipment_slot_key(&self) -> EquipmentSlotKey {
270        EquipmentSlotKey::new(self.item_type, self.world_side)
271    }
272}
273
274#[derive(Debug, Deserialize, Serialize, Clone, Eq, PartialEq, Hash, JsonSchema, Tsify)]
275pub struct ItemAttribute {
276    pub attr_id: AttributeId,
277    pub value: i32,
278}
279
280#[derive(Debug, PartialEq, Eq, Deserialize, Serialize, Clone, Default, Hash, JsonSchema, Tsify)]
281#[tsify(from_wasm_abi)]
282pub struct Item {
283    pub id: Uuid,
284    pub item_template_id: ItemTemplateId,
285    pub item_type: ItemType,
286    /// Сторона flip-предмета; `None` для постоянных типов экипировки.
287    pub world_side: Option<WorldSide>,
288    pub rarity: ItemRarity,
289    pub level: i64,
290    pub name: i18n::I18nString,
291    pub icon_url: String,
292    pub icon_path: String,
293    pub is_equipped: bool,
294    pub price: Vec<CurrencyUnit>,
295    pub experience: i64,
296    pub attributes: Vec<ItemAttribute>,
297
298    /// Small symmetric power variance applied at roll time (seeded RNG, symmetric
299    /// around 0). Stored and loaded from the `inventories.power_bonus` column.
300    /// Existing items loaded without the column default to 0 (no jitter) via the
301    /// `DEFAULT 0` migration. The jitter is added as a direct additive to the
302    /// computed `character_power` / `item_power` so it affects both the displayed
303    /// Combat-Power and the auto-equip comparison — two items from the same
304    /// template at the same level will show different power if their jitter differs.
305    pub power_bonus: i32,
306
307    /// Срок истечения временного предмета; `None` — постоянный. Истёкший
308    /// удаляется функцией `sweep_expired_items`.
309    pub expires_at: Option<DateTime<Utc>>,
310}
311
312impl Item {
313    pub const fn equipment_slot_key(&self) -> EquipmentSlotKey {
314        EquipmentSlotKey::new(self.item_type, self.world_side)
315    }
316}
317
318#[cfg(test)]
319mod tests {
320    use super::{EquipmentSlotKey, ItemType};
321    use crate::flip::WorldSide;
322
323    #[test]
324    fn world_side_is_supported_only_by_the_five_flip_types() {
325        for item_type in [
326            ItemType::Weapon,
327            ItemType::Torso,
328            ItemType::Head,
329            ItemType::Shoulders,
330            ItemType::Gloves,
331        ] {
332            assert!(item_type.supports_world_side(), "{item_type} must flip");
333        }
334
335        for item_type in [
336            ItemType::Legs,
337            ItemType::Boots,
338            ItemType::Waist,
339            ItemType::Neck,
340            ItemType::Ring,
341        ] {
342            assert!(
343                !item_type.supports_world_side(),
344                "{item_type} must remain fixed"
345            );
346        }
347    }
348
349    #[test]
350    fn head_and_gloves_keep_side_while_legs_normalize_to_one_slot() {
351        assert_ne!(
352            EquipmentSlotKey::new(ItemType::Head, Some(WorldSide::Real)),
353            EquipmentSlotKey::new(ItemType::Head, Some(WorldSide::Fantasy))
354        );
355        assert_ne!(
356            EquipmentSlotKey::new(ItemType::Gloves, Some(WorldSide::Real)),
357            EquipmentSlotKey::new(ItemType::Gloves, Some(WorldSide::Fantasy))
358        );
359        assert_eq!(
360            EquipmentSlotKey::new(ItemType::Legs, Some(WorldSide::Real)),
361            EquipmentSlotKey::new(ItemType::Legs, Some(WorldSide::Fantasy))
362        );
363        assert_eq!(
364            EquipmentSlotKey::new(ItemType::Legs, Some(WorldSide::Real)).world_side(),
365            None
366        );
367    }
368}