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    Attractions,
72    Junkyard,
73    Sideboard,
74}
75
76#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize, TS)]
77#[serde(rename_all = "camelCase")]
78#[ts(export, export_to = "game/index.ts")]
79pub enum StepKind {
80    #[default]
81    Untap,
82    Upkeep,
83    Draw,
84    Main1,
85    CombatBegin,
86    CombatDeclareAttackers,
87    CombatDeclareBlockers,
88    CombatFirstStrikeDamage,
89    CombatDamage,
90    CombatEnd,
91    Main2,
92    EndOfTurn,
93    Cleanup,
94}
95
96#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize, TS)]
97#[serde(rename_all = "camelCase")]
98#[ts(export, export_to = "game/index.ts")]
99pub enum DayTime {
100    #[default]
101    Neither,
102    Day,
103    Night,
104}
105
106#[allow(clippy::large_enum_variant)]
107#[derive(Debug, Clone, Serialize, Deserialize, TS)]
108#[serde(
109    tag = "visibility",
110    rename_all = "camelCase",
111    rename_all_fields = "camelCase"
112)]
113#[ts(export, export_to = "game/index.ts")]
114pub enum CardView {
115    Visible(CardDto),
116    Hidden { id: String },
117}
118
119// One entry per (zone, owner) pair; battlefield cards are bucketed by controller.
120#[derive(Debug, Clone, Serialize, Deserialize, TS)]
121#[serde(rename_all = "camelCase")]
122#[ts(export, export_to = "game/index.ts")]
123pub struct ZoneDto {
124    pub zone: ZoneKind,
125    pub owner_id: String,
126    // Ordered top-first where order is public knowledge
127    // count can be > cards.len if hidden cards are present (library)
128    pub cards: Vec<CardView>,
129    pub count: usize,
130}
131
132#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
133#[serde(rename_all = "camelCase")]
134#[ts(export, export_to = "game/index.ts")]
135pub struct GameViewDto {
136    pub game_id: String,
137    pub turn: u32,
138    pub step: StepKind,
139    pub combat_assignments: Vec<CombatAssignmentDto>,
140    pub active_player_id: String,
141    pub priority_player_id: String,
142    pub players: Vec<PlayerDto>,
143    pub zones: Vec<ZoneDto>,
144    pub stack: Vec<StackObjectDto>,
145    pub game_over: bool,
146    pub winner_id: Option<String>,
147    pub monarch_id: Option<String>,
148    pub initiative_holder_id: Option<String>,
149    pub day_time: DayTime,
150    #[ts(optional)]
151    pub active_plane_names: Option<Vec<String>>,
152    #[serde(default)]
153    pub checkpoints: Vec<CheckpointDto>,
154    #[serde(default)]
155    pub restore_vote: Option<RestoreVoteDto>,
156    #[serde(default)]
157    pub snapshot_recording: bool,
158}
159
160#[derive(Debug, Clone, Serialize, Deserialize, TS)]
161#[serde(rename_all = "camelCase")]
162#[ts(export, export_to = "game/index.ts")]
163pub struct RestoreVoteDto {
164    pub vote_id: u32,
165    pub checkpoint: CheckpointDto,
166    pub requested_by_player_id: String,
167    pub awaiting_player_ids: Vec<String>,
168    pub status: RestoreVoteStatus,
169}
170
171#[derive(Debug, Clone, Serialize, Deserialize, TS)]
172#[serde(
173    tag = "type",
174    rename_all = "camelCase",
175    rename_all_fields = "camelCase"
176)]
177#[ts(export, export_to = "game/index.ts")]
178pub enum RestoreVoteStatus {
179    Pending,
180    Approved,
181    Declined { player_id: String },
182    Unavailable,
183}
184
185#[derive(Debug, Clone, Serialize, Deserialize, TS)]
186#[serde(rename_all = "camelCase")]
187#[ts(export, export_to = "game/index.ts")]
188pub struct CheckpointDto {
189    pub checkpoint_id: u32,
190    pub turn: u32,
191    pub step: StepKind,
192    pub active_player_id: String,
193}
194
195#[derive(Debug, Clone, Serialize, Deserialize, TS)]
196#[serde(rename_all = "camelCase")]
197#[ts(export, export_to = "game/index.ts")]
198pub struct CombatAssignmentDto {
199    pub blocker_id: String,
200    pub attacker_id: String,
201}
202
203#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, TS)]
204#[serde(rename_all = "camelCase")]
205#[ts(export, export_to = "game/index.ts")]
206pub enum PlayerStatus {
207    #[default]
208    Playing,
209    Lost,
210    Conceded,
211}
212
213#[derive(Debug, Clone, Serialize, Deserialize, TS)]
214#[serde(rename_all = "camelCase")]
215#[ts(export, export_to = "game/index.ts")]
216pub struct DungeonStateDto {
217    pub name: String,
218    pub room: String,
219}
220
221#[derive(Debug, Clone, Serialize, Deserialize, TS)]
222#[serde(rename_all = "camelCase")]
223#[ts(export, export_to = "game/index.ts")]
224pub struct PlayerDto {
225    pub id: String,
226    pub name: String,
227    pub status: PlayerStatus,
228    pub is_human: bool,
229    pub life: i32,
230    #[serde(default)]
231    pub max_hand_size: i32,
232    #[serde(default)]
233    pub unlimited_hand_size: bool,
234    #[serde(default)]
235    pub lands_played_this_turn: i32,
236    #[serde(default)]
237    pub max_land_plays_per_turn: i32,
238    #[serde(default)]
239    pub unlimited_land_plays: bool,
240    #[serde(default)]
241    pub cards_drawn_this_turn: i32,
242    #[serde(default)]
243    pub damage_prevention: i32,
244    #[serde(default)]
245    pub is_extra_turn: bool,
246    #[serde(default)]
247    pub extra_turn_count: u32,
248    #[ts(optional)]
249    pub controlled_by: Option<String>,
250    #[serde(default)]
251    pub player_keywords: Vec<String>,
252    #[serde(default)]
253    #[ts(type = "Record<string, number>")]
254    pub commander_casts: HashMap<String, u32>,
255    #[ts(optional)]
256    pub dungeon_state: Option<DungeonStateDto>,
257    #[ts(optional)]
258    pub active_scheme_names: Option<Vec<String>>,
259    #[ts(optional)]
260    pub team_number: Option<i32>,
261    pub counters: BTreeMap<PlayerCounterKind, u32>,
262    pub mana_pool: BTreeMap<ManaColor, u32>,
263    #[ts(type = "Record<string, number>")]
264    pub commander_damage: HashMap<String, i32>,
265    pub has_city_blessing: bool,
266    pub has_enduring_story: bool,
267    pub ring_level: i32,
268    pub speed: i32,
269}
270
271#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
272#[serde(rename_all = "camelCase", default)]
273#[ts(export, export_to = "game/index.ts")]
274pub struct CardIdentity {
275    pub name: String,
276    pub set_code: String,
277    pub card_number: String,
278    pub is_token: bool,
279    #[serde(default, skip_serializing_if = "Option::is_none")]
280    #[ts(optional)]
281    pub token_script: Option<TokenScript>,
282}
283
284#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
285#[serde(rename_all = "camelCase", default)]
286#[ts(export, export_to = "game/index.ts")]
287pub struct ClassLevelDto {
288    pub level: i32,
289    pub oracle: String,
290    #[serde(default, skip_serializing_if = "Option::is_none")]
291    #[ts(optional)]
292    pub cost: Option<String>,
293}
294
295#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
296#[serde(rename_all = "camelCase", default)]
297#[ts(export, export_to = "game/index.ts")]
298pub struct SagaChapterDto {
299    pub chapters: Vec<i32>,
300    pub oracle: String,
301}
302
303#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
304#[serde(rename_all = "camelCase", default)]
305#[ts(export, export_to = "game/index.ts")]
306pub struct CardDto {
307    pub id: String,
308    pub identity: CardIdentity,
309    #[serde(
310        default,
311        deserialize_with = "crate::deck_dto::CardCosmetics::deserialize_optional",
312        skip_serializing_if = "Option::is_none"
313    )]
314    #[ts(optional)]
315    pub cosmetics: Option<crate::deck_dto::CardCosmetics>,
316    pub color: String,
317    pub mana_cost: String,
318    pub cmc: i32,
319    pub types: Vec<String>,
320    pub subtypes: Vec<String>,
321    pub supertypes: Vec<String>,
322    pub power: Option<String>,
323    pub toughness: Option<String>,
324    #[serde(skip_serializing_if = "Option::is_none")]
325    #[ts(optional)]
326    pub base_power: Option<i32>,
327    #[serde(skip_serializing_if = "Option::is_none")]
328    #[ts(optional)]
329    pub base_toughness: Option<i32>,
330    #[serde(default, skip_serializing_if = "Option::is_none")]
331    #[ts(optional)]
332    pub final_chapter: Option<i32>,
333    #[serde(default, skip_serializing_if = "Option::is_none")]
334    #[ts(optional)]
335    pub class_level: Option<i32>,
336    pub class_levels: Vec<ClassLevelDto>,
337    pub saga_chapters: Vec<SagaChapterDto>,
338    pub text: String,
339    #[serde(default)]
340    pub choices: Vec<CardChoiceDto>,
341    pub controller_id: String,
342    pub owner_id: String,
343    pub tapped: bool,
344    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
345    pub is_crewed: bool,
346    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
347    pub is_attacking: bool,
348    #[serde(default, skip_serializing_if = "Option::is_none")]
349    #[ts(optional)]
350    pub attacking_player_id: Option<String>,
351    #[serde(default, skip_serializing_if = "Option::is_none")]
352    #[ts(optional)]
353    pub attack_target_id: Option<String>,
354    pub keywords: Vec<String>,
355    #[serde(default, skip_serializing_if = "Option::is_none")]
356    #[ts(optional)]
357    pub keyword_labels: Option<BTreeMap<String, String>>,
358    #[serde(default, skip_serializing_if = "Vec::is_empty")]
359    pub granted_keywords: Vec<String>,
360    // Keyed by the engine's canonical `CounterType` display form ("P1P1",
361    // "Loyalty", one-off counter names uppercase); both producers must match it.
362    #[ts(type = "Record<string, number>")]
363    pub counters: BTreeMap<String, u32>,
364    pub damage: i32,
365    pub summoning_sick: bool,
366    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
367    pub is_copy: bool,
368    pub is_double_faced: bool,
369    pub is_transformed: bool,
370    pub is_face_down: bool,
371    pub is_bestowed: bool,
372    pub phased_out: bool,
373    pub exerted: bool,
374    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
375    pub is_ring_bearer: bool,
376    #[serde(skip_serializing_if = "Option::is_none")]
377    #[ts(optional)]
378    pub attached_to: Option<String>,
379    #[serde(default, skip_serializing_if = "Vec::is_empty")]
380    pub attachment_ids: Vec<String>,
381    // Mutate pile: the card ids merged under this top card.
382    #[serde(default, skip_serializing_if = "Vec::is_empty")]
383    pub merged_card_ids: Vec<String>,
384    #[serde(skip_serializing_if = "Option::is_none")]
385    #[ts(optional)]
386    pub flashback_cost: Option<String>,
387    #[serde(skip_serializing_if = "Option::is_none")]
388    #[ts(optional)]
389    pub kicker_cost: Option<String>,
390    #[serde(skip_serializing_if = "Option::is_none")]
391    #[ts(optional)]
392    pub effective_mana_cost: Option<String>,
393    #[serde(default, skip_serializing_if = "Option::is_none")]
394    #[ts(optional)]
395    pub commander_tax: Option<i32>,
396    #[serde(skip_serializing_if = "Option::is_none")]
397    #[ts(optional)]
398    pub madness_cost: Option<String>,
399    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
400    pub is_madness_exiled: bool,
401    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
402    pub is_plotted: bool,
403    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
404    pub is_warp_exiled: bool,
405    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
406    pub foil: bool,
407    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
408    pub would_die_in_combat: bool,
409    #[serde(default, skip_serializing_if = "Option::is_none")]
410    #[ts(optional)]
411    pub zone: Option<ZoneKind>,
412}
413
414#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
415#[serde(rename_all = "camelCase", default)]
416#[ts(export, export_to = "game/index.ts")]
417pub struct StackObjectDto {
418    pub id: String,
419    pub source_id: String,
420    pub controller_id: String,
421    pub owner_id: String,
422    pub identity: CardIdentity,
423    #[serde(default)]
424    pub foil: bool,
425    #[serde(
426        default,
427        deserialize_with = "crate::deck_dto::CardCosmetics::deserialize_optional",
428        skip_serializing_if = "Option::is_none"
429    )]
430    #[ts(optional)]
431    pub cosmetics: Option<crate::deck_dto::CardCosmetics>,
432    pub text: String,
433    #[serde(skip_serializing_if = "Option::is_none")]
434    #[ts(optional)]
435    pub source_ability_text: Option<String>,
436    pub is_permanent_spell: bool,
437    pub is_casting: bool,
438    pub is_double_faced: bool,
439    pub face_index: u8,
440    pub targets: Vec<TargetRef>,
441}
442
443#[cfg(test)]
444mod stack_object_tests {
445    use super::{CardIdentity, StackObjectDto};
446
447    #[test]
448    fn serializes_card_face_fields() {
449        let stack_object = StackObjectDto {
450            id: "stack-1".into(),
451            source_id: "card-1".into(),
452            controller_id: "player-0".into(),
453            owner_id: "player-1".into(),
454            identity: CardIdentity::default(),
455            foil: false,
456            cosmetics: None,
457            text: String::new(),
458            source_ability_text: Some("When this enters, draw a card.".into()),
459            is_permanent_spell: true,
460            is_casting: true,
461            is_double_faced: true,
462            face_index: 1,
463            targets: Vec::new(),
464        };
465
466        let value = serde_json::to_value(stack_object).unwrap();
467        assert_eq!(value["sourceAbilityText"], "When this enters, draw a card.");
468
469        assert_eq!(value["ownerId"], "player-1");
470        assert_eq!(value["isDoubleFaced"], true);
471        assert_eq!(value["faceIndex"], 1);
472    }
473
474    #[test]
475    fn defaults_missing_card_face_fields() {
476        let value = serde_json::json!({
477            "id": "stack-1",
478            "sourceId": "card-1",
479            "controllerId": "player-0"
480        });
481
482        let stack_object: StackObjectDto = serde_json::from_value(value).unwrap();
483
484        assert!(stack_object.owner_id.is_empty());
485        assert!(!stack_object.is_double_faced);
486        assert_eq!(stack_object.face_index, 0);
487    }
488}
489
490#[derive(
491    Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, TS, strum_macros::Display,
492)]
493#[serde(rename_all = "camelCase")]
494#[ts(export, export_to = "game/index.ts")]
495pub enum TargetingIntent {
496    #[default]
497    Damage,
498    Destroy,
499    Sacrifice,
500    Exile,
501    Bounce,
502    Mill,
503    Discard,
504    Counter,
505    Tap,
506    Untap,
507    Copy,
508    Buff,
509    Debuff,
510    Heal,
511    LoseLife,
512    Reveal,
513    Draw,
514    Fetch,
515    GainControl,
516    Fight,
517    Attach,
518    Attack,
519    Block,
520    Hostile,
521    Friendly,
522}
523
524#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
525#[serde(rename_all = "camelCase")]
526#[ts(export, export_to = "game/index.ts")]
527pub struct PlaymatSettings {
528    #[serde(default, skip_serializing_if = "Option::is_none")]
529    #[ts(optional)]
530    pub opacity: Option<f32>,
531    #[serde(default, skip_serializing_if = "Option::is_none")]
532    #[ts(optional)]
533    pub texture: Option<f32>,
534    #[serde(default, skip_serializing_if = "Option::is_none")]
535    #[ts(optional)]
536    pub border_width: Option<f32>,
537    #[serde(default, skip_serializing_if = "Option::is_none")]
538    #[ts(optional)]
539    pub border_color: Option<String>,
540    #[serde(default, skip_serializing_if = "Option::is_none")]
541    #[ts(optional)]
542    pub fit: Option<String>,
543    #[serde(default, skip_serializing_if = "Option::is_none")]
544    #[ts(optional)]
545    pub offset_x: Option<f32>,
546    #[serde(default, skip_serializing_if = "Option::is_none")]
547    #[ts(optional)]
548    pub offset_y: Option<f32>,
549    #[serde(default, skip_serializing_if = "Option::is_none")]
550    #[ts(optional)]
551    pub zoom: Option<f32>,
552    #[serde(default, skip_serializing_if = "Option::is_none")]
553    #[ts(optional)]
554    pub blur: Option<f32>,
555    #[serde(default, skip_serializing_if = "Option::is_none")]
556    #[ts(optional)]
557    pub brightness: Option<f32>,
558    #[serde(default, skip_serializing_if = "Option::is_none")]
559    #[ts(optional)]
560    pub color: Option<String>,
561}
562
563#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
564#[ts(export, export_to = "game/index.ts")]
565pub enum GameFormat {
566    Any,
567    Standard,
568    Pioneer,
569    Modern,
570    Legacy,
571    Vintage,
572    Pauper,
573    Premodern,
574    Commander,
575    PauperCommander,
576    Brawl,
577    Oathbreaker,
578    Draft,
579    Sealed,
580}
581
582#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default, TS)]
583#[ts(export, export_to = "game/index.ts")]
584pub enum EngineKind {
585    #[default]
586    Manabrew,
587    Forge,
588    Ironsmith,
589}