Skip to main content

manabrew_protocol/game/
mod.rs

1use std::collections::{BTreeMap, HashMap};
2
3use serde::{Deserialize, Serialize};
4use ts_rs::TS;
5
6use crate::{prompts::common::TargetRef, TokenScript};
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, TS)]
9#[ts(export, export_to = "game/index.ts")]
10pub enum ManaColor {
11    #[serde(rename = "W")]
12    White,
13    #[serde(rename = "U")]
14    Blue,
15    #[serde(rename = "B")]
16    Black,
17    #[serde(rename = "R")]
18    Red,
19    #[serde(rename = "G")]
20    Green,
21    #[serde(rename = "C")]
22    Colorless,
23}
24
25#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
26#[serde(
27    tag = "kind",
28    rename_all = "camelCase",
29    rename_all_fields = "camelCase"
30)]
31#[ts(export, export_to = "game/index.ts")]
32pub enum CardChoiceDto {
33    Color { colors: Vec<ManaColor> },
34    Type { values: Vec<String> },
35    NamedCard { names: Vec<String> },
36    ChosenCard { count: usize },
37    Number { value: i32 },
38    Mode { value: String },
39    Player { player_id: String, name: String },
40}
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
43#[serde(rename_all = "camelCase")]
44#[ts(export, export_to = "game/index.ts")]
45pub struct Mana {
46    pub color: ManaColor,
47    pub amount: i32,
48}
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, TS)]
51#[serde(rename_all = "camelCase")]
52#[ts(export, export_to = "game/index.ts")]
53pub enum PlayerCounterKind {
54    Poison,
55    Energy,
56    Experience,
57    Radiation,
58    Ticket,
59}
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, TS)]
62#[serde(rename_all = "camelCase")]
63#[ts(export, export_to = "game/index.ts")]
64pub enum ZoneKind {
65    Battlefield,
66    Hand,
67    Library,
68    Graveyard,
69    Exile,
70    Command,
71}
72
73#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize, TS)]
74#[serde(rename_all = "camelCase")]
75#[ts(export, export_to = "game/index.ts")]
76pub enum StepKind {
77    #[default]
78    Untap,
79    Upkeep,
80    Draw,
81    Main1,
82    CombatBegin,
83    CombatDeclareAttackers,
84    CombatDeclareBlockers,
85    CombatFirstStrikeDamage,
86    CombatDamage,
87    CombatEnd,
88    Main2,
89    EndOfTurn,
90    Cleanup,
91}
92
93#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize, TS)]
94#[serde(rename_all = "camelCase")]
95#[ts(export, export_to = "game/index.ts")]
96pub enum DayTime {
97    #[default]
98    Neither,
99    Day,
100    Night,
101}
102
103#[allow(clippy::large_enum_variant)]
104#[derive(Debug, Clone, Serialize, Deserialize, TS)]
105#[serde(
106    tag = "visibility",
107    rename_all = "camelCase",
108    rename_all_fields = "camelCase"
109)]
110#[ts(export, export_to = "game/index.ts")]
111pub enum CardView {
112    Visible(CardDto),
113    Hidden { id: String },
114}
115
116// One entry per (zone, owner) pair; battlefield cards are bucketed by controller.
117#[derive(Debug, Clone, Serialize, Deserialize, TS)]
118#[serde(rename_all = "camelCase")]
119#[ts(export, export_to = "game/index.ts")]
120pub struct ZoneDto {
121    pub zone: ZoneKind,
122    pub owner_id: String,
123    // Ordered top-first where order is public knowledge
124    // count can be > cards.len if hidden cards are present (library)
125    pub cards: Vec<CardView>,
126    pub count: usize,
127}
128
129#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
130#[serde(rename_all = "camelCase")]
131#[ts(export, export_to = "game/index.ts")]
132pub struct GameViewDto {
133    pub game_id: String,
134    pub turn: u32,
135    pub step: StepKind,
136    pub combat_assignments: Vec<CombatAssignmentDto>,
137    pub active_player_id: String,
138    pub priority_player_id: String,
139    pub players: Vec<PlayerDto>,
140    pub zones: Vec<ZoneDto>,
141    pub stack: Vec<StackObjectDto>,
142    pub game_over: bool,
143    pub winner_id: Option<String>,
144    pub monarch_id: Option<String>,
145    pub initiative_holder_id: Option<String>,
146    pub day_time: DayTime,
147}
148
149#[derive(Debug, Clone, Serialize, Deserialize, TS)]
150#[serde(rename_all = "camelCase")]
151#[ts(export, export_to = "game/index.ts")]
152pub struct CombatAssignmentDto {
153    pub blocker_id: String,
154    pub attacker_id: String,
155}
156
157#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, TS)]
158#[serde(rename_all = "camelCase")]
159#[ts(export, export_to = "game/index.ts")]
160pub enum PlayerStatus {
161    #[default]
162    Playing,
163    Lost,
164    Conceded,
165}
166
167#[derive(Debug, Clone, Serialize, Deserialize, TS)]
168#[serde(rename_all = "camelCase")]
169#[ts(export, export_to = "game/index.ts")]
170pub struct PlayerDto {
171    pub id: String,
172    pub name: String,
173    pub status: PlayerStatus,
174    pub is_human: bool,
175    pub life: i32,
176    pub counters: BTreeMap<PlayerCounterKind, u32>,
177    pub mana_pool: BTreeMap<ManaColor, u32>,
178    #[ts(type = "Record<string, number>")]
179    pub commander_damage: HashMap<String, i32>,
180    pub has_city_blessing: bool,
181    pub has_enduring_story: bool,
182    pub ring_level: i32,
183    pub speed: i32,
184}
185
186#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
187#[serde(rename_all = "camelCase", default)]
188#[ts(export, export_to = "game/index.ts")]
189pub struct CardIdentity {
190    pub name: String,
191    pub set_code: String,
192    pub card_number: String,
193    pub is_token: bool,
194    #[serde(default, skip_serializing_if = "Option::is_none")]
195    #[ts(optional)]
196    pub token_script: Option<TokenScript>,
197}
198
199#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
200#[serde(rename_all = "camelCase", default)]
201#[ts(export, export_to = "game/index.ts")]
202pub struct ClassLevelDto {
203    pub level: i32,
204    pub oracle: String,
205    #[serde(default, skip_serializing_if = "Option::is_none")]
206    #[ts(optional)]
207    pub cost: Option<String>,
208}
209
210#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
211#[serde(rename_all = "camelCase", default)]
212#[ts(export, export_to = "game/index.ts")]
213pub struct SagaChapterDto {
214    pub chapters: Vec<i32>,
215    pub oracle: String,
216}
217
218#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
219#[serde(rename_all = "camelCase", default)]
220#[ts(export, export_to = "game/index.ts")]
221pub struct CardDto {
222    pub id: String,
223    pub identity: CardIdentity,
224    pub color: String,
225    pub mana_cost: String,
226    pub cmc: i32,
227    pub types: Vec<String>,
228    pub subtypes: Vec<String>,
229    pub supertypes: Vec<String>,
230    pub power: Option<String>,
231    pub toughness: Option<String>,
232    #[serde(skip_serializing_if = "Option::is_none")]
233    #[ts(optional)]
234    pub base_power: Option<i32>,
235    #[serde(skip_serializing_if = "Option::is_none")]
236    #[ts(optional)]
237    pub base_toughness: Option<i32>,
238    #[serde(default, skip_serializing_if = "Option::is_none")]
239    #[ts(optional)]
240    pub final_chapter: Option<i32>,
241    #[serde(default, skip_serializing_if = "Option::is_none")]
242    #[ts(optional)]
243    pub class_level: Option<i32>,
244    pub class_levels: Vec<ClassLevelDto>,
245    pub saga_chapters: Vec<SagaChapterDto>,
246    pub text: String,
247    #[serde(default)]
248    pub choices: Vec<CardChoiceDto>,
249    pub controller_id: String,
250    pub owner_id: String,
251    pub tapped: bool,
252    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
253    pub is_crewed: bool,
254    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
255    pub is_attacking: bool,
256    #[serde(default, skip_serializing_if = "Option::is_none")]
257    #[ts(optional)]
258    pub attacking_player_id: Option<String>,
259    #[serde(default, skip_serializing_if = "Option::is_none")]
260    #[ts(optional)]
261    pub attack_target_id: Option<String>,
262    pub keywords: Vec<String>,
263    // Keyed by the engine's canonical `CounterType` display form ("P1P1",
264    // "Loyalty", one-off counter names uppercase); both producers must match it.
265    #[ts(type = "Record<string, number>")]
266    pub counters: BTreeMap<String, u32>,
267    pub damage: i32,
268    pub summoning_sick: bool,
269    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
270    pub is_copy: bool,
271    pub is_double_faced: bool,
272    pub is_transformed: bool,
273    pub is_face_down: bool,
274    pub is_bestowed: bool,
275    pub phased_out: bool,
276    pub exerted: bool,
277    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
278    pub is_ring_bearer: bool,
279    #[serde(skip_serializing_if = "Option::is_none")]
280    #[ts(optional)]
281    pub attached_to: Option<String>,
282    #[serde(default, skip_serializing_if = "Vec::is_empty")]
283    pub attachment_ids: Vec<String>,
284    // Mutate pile: the card ids merged under this top card.
285    #[serde(default, skip_serializing_if = "Vec::is_empty")]
286    pub merged_card_ids: Vec<String>,
287    #[serde(skip_serializing_if = "Option::is_none")]
288    #[ts(optional)]
289    pub flashback_cost: Option<String>,
290    #[serde(skip_serializing_if = "Option::is_none")]
291    #[ts(optional)]
292    pub kicker_cost: Option<String>,
293    #[serde(skip_serializing_if = "Option::is_none")]
294    #[ts(optional)]
295    pub effective_mana_cost: Option<String>,
296    #[serde(skip_serializing_if = "Option::is_none")]
297    #[ts(optional)]
298    pub madness_cost: Option<String>,
299    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
300    pub is_madness_exiled: bool,
301    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
302    pub is_plotted: bool,
303    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
304    pub is_warp_exiled: bool,
305    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
306    pub foil: bool,
307    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
308    pub would_die_in_combat: bool,
309}
310
311#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
312#[serde(rename_all = "camelCase", default)]
313#[ts(export, export_to = "game/index.ts")]
314pub struct StackObjectDto {
315    pub id: String,
316    pub source_id: String,
317    pub controller_id: String,
318    pub owner_id: String,
319    pub identity: CardIdentity,
320    pub text: String,
321    pub is_permanent_spell: bool,
322    pub is_casting: bool,
323    pub is_double_faced: bool,
324    pub face_index: u8,
325    pub targets: Vec<TargetRef>,
326}
327
328#[cfg(test)]
329mod stack_object_tests {
330    use super::{CardIdentity, StackObjectDto};
331
332    #[test]
333    fn serializes_card_face_fields() {
334        let stack_object = StackObjectDto {
335            id: "stack-1".into(),
336            source_id: "card-1".into(),
337            controller_id: "player-0".into(),
338            owner_id: "player-1".into(),
339            identity: CardIdentity::default(),
340            text: String::new(),
341            is_permanent_spell: true,
342            is_casting: true,
343            is_double_faced: true,
344            face_index: 1,
345            targets: Vec::new(),
346        };
347
348        let value = serde_json::to_value(stack_object).unwrap();
349
350        assert_eq!(value["ownerId"], "player-1");
351        assert_eq!(value["isDoubleFaced"], true);
352        assert_eq!(value["faceIndex"], 1);
353    }
354
355    #[test]
356    fn defaults_missing_card_face_fields() {
357        let value = serde_json::json!({
358            "id": "stack-1",
359            "sourceId": "card-1",
360            "controllerId": "player-0"
361        });
362
363        let stack_object: StackObjectDto = serde_json::from_value(value).unwrap();
364
365        assert!(stack_object.owner_id.is_empty());
366        assert!(!stack_object.is_double_faced);
367        assert_eq!(stack_object.face_index, 0);
368    }
369}
370
371#[derive(
372    Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, TS, strum_macros::Display,
373)]
374#[serde(rename_all = "camelCase")]
375#[ts(export, export_to = "game/index.ts")]
376pub enum TargetingIntent {
377    #[default]
378    Damage,
379    Destroy,
380    Sacrifice,
381    Exile,
382    Bounce,
383    Mill,
384    Discard,
385    Counter,
386    Tap,
387    Untap,
388    Copy,
389    Buff,
390    Debuff,
391    Heal,
392    LoseLife,
393    Reveal,
394    Draw,
395    Fetch,
396    GainControl,
397    Fight,
398    Attach,
399    Attack,
400    Block,
401    Hostile,
402    Friendly,
403}
404
405#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
406#[serde(rename_all = "camelCase")]
407#[ts(export, export_to = "game/index.ts")]
408pub struct PlaymatSettings {
409    #[serde(default, skip_serializing_if = "Option::is_none")]
410    #[ts(optional)]
411    pub opacity: Option<f32>,
412    #[serde(default, skip_serializing_if = "Option::is_none")]
413    #[ts(optional)]
414    pub texture: Option<f32>,
415    #[serde(default, skip_serializing_if = "Option::is_none")]
416    #[ts(optional)]
417    pub border_width: Option<f32>,
418    #[serde(default, skip_serializing_if = "Option::is_none")]
419    #[ts(optional)]
420    pub border_color: Option<String>,
421    #[serde(default, skip_serializing_if = "Option::is_none")]
422    #[ts(optional)]
423    pub fit: Option<String>,
424    #[serde(default, skip_serializing_if = "Option::is_none")]
425    #[ts(optional)]
426    pub offset_x: Option<f32>,
427    #[serde(default, skip_serializing_if = "Option::is_none")]
428    #[ts(optional)]
429    pub offset_y: Option<f32>,
430    #[serde(default, skip_serializing_if = "Option::is_none")]
431    #[ts(optional)]
432    pub zoom: Option<f32>,
433    #[serde(default, skip_serializing_if = "Option::is_none")]
434    #[ts(optional)]
435    pub blur: Option<f32>,
436    #[serde(default, skip_serializing_if = "Option::is_none")]
437    #[ts(optional)]
438    pub brightness: Option<f32>,
439    #[serde(default, skip_serializing_if = "Option::is_none")]
440    #[ts(optional)]
441    pub color: Option<String>,
442}
443
444#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
445#[ts(export, export_to = "game/index.ts")]
446pub enum GameFormat {
447    Any,
448    Standard,
449    Pioneer,
450    Modern,
451    Legacy,
452    Vintage,
453    Pauper,
454    Premodern,
455    Commander,
456    Brawl,
457    Oathbreaker,
458    Draft,
459    Sealed,
460}
461
462#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default, TS)]
463#[ts(export, export_to = "game/index.ts")]
464pub enum EngineKind {
465    #[default]
466    Manabrew,
467    Forge,
468    Ironsmith,
469}