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