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    pub color: String,
310    pub mana_cost: String,
311    pub cmc: i32,
312    pub types: Vec<String>,
313    pub subtypes: Vec<String>,
314    pub supertypes: Vec<String>,
315    pub power: Option<String>,
316    pub toughness: Option<String>,
317    #[serde(skip_serializing_if = "Option::is_none")]
318    #[ts(optional)]
319    pub base_power: Option<i32>,
320    #[serde(skip_serializing_if = "Option::is_none")]
321    #[ts(optional)]
322    pub base_toughness: Option<i32>,
323    #[serde(default, skip_serializing_if = "Option::is_none")]
324    #[ts(optional)]
325    pub final_chapter: Option<i32>,
326    #[serde(default, skip_serializing_if = "Option::is_none")]
327    #[ts(optional)]
328    pub class_level: Option<i32>,
329    pub class_levels: Vec<ClassLevelDto>,
330    pub saga_chapters: Vec<SagaChapterDto>,
331    pub text: String,
332    #[serde(default)]
333    pub choices: Vec<CardChoiceDto>,
334    pub controller_id: String,
335    pub owner_id: String,
336    pub tapped: bool,
337    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
338    pub is_crewed: bool,
339    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
340    pub is_attacking: bool,
341    #[serde(default, skip_serializing_if = "Option::is_none")]
342    #[ts(optional)]
343    pub attacking_player_id: Option<String>,
344    #[serde(default, skip_serializing_if = "Option::is_none")]
345    #[ts(optional)]
346    pub attack_target_id: Option<String>,
347    pub keywords: Vec<String>,
348    // Keyed by the engine's canonical `CounterType` display form ("P1P1",
349    // "Loyalty", one-off counter names uppercase); both producers must match it.
350    #[ts(type = "Record<string, number>")]
351    pub counters: BTreeMap<String, u32>,
352    pub damage: i32,
353    pub summoning_sick: bool,
354    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
355    pub is_copy: bool,
356    pub is_double_faced: bool,
357    pub is_transformed: bool,
358    pub is_face_down: bool,
359    pub is_bestowed: bool,
360    pub phased_out: bool,
361    pub exerted: bool,
362    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
363    pub is_ring_bearer: bool,
364    #[serde(skip_serializing_if = "Option::is_none")]
365    #[ts(optional)]
366    pub attached_to: Option<String>,
367    #[serde(default, skip_serializing_if = "Vec::is_empty")]
368    pub attachment_ids: Vec<String>,
369    // Mutate pile: the card ids merged under this top card.
370    #[serde(default, skip_serializing_if = "Vec::is_empty")]
371    pub merged_card_ids: Vec<String>,
372    #[serde(skip_serializing_if = "Option::is_none")]
373    #[ts(optional)]
374    pub flashback_cost: Option<String>,
375    #[serde(skip_serializing_if = "Option::is_none")]
376    #[ts(optional)]
377    pub kicker_cost: Option<String>,
378    #[serde(skip_serializing_if = "Option::is_none")]
379    #[ts(optional)]
380    pub effective_mana_cost: Option<String>,
381    #[serde(default, skip_serializing_if = "Option::is_none")]
382    #[ts(optional)]
383    pub commander_tax: Option<i32>,
384    #[serde(skip_serializing_if = "Option::is_none")]
385    #[ts(optional)]
386    pub madness_cost: Option<String>,
387    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
388    pub is_madness_exiled: bool,
389    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
390    pub is_plotted: bool,
391    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
392    pub is_warp_exiled: bool,
393    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
394    pub foil: bool,
395    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
396    pub would_die_in_combat: bool,
397    #[serde(default, skip_serializing_if = "Option::is_none")]
398    #[ts(optional)]
399    pub zone: Option<ZoneKind>,
400}
401
402#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
403#[serde(rename_all = "camelCase", default)]
404#[ts(export, export_to = "game/index.ts")]
405pub struct StackObjectDto {
406    pub id: String,
407    pub source_id: String,
408    pub controller_id: String,
409    pub owner_id: String,
410    pub identity: CardIdentity,
411    pub text: String,
412    #[serde(skip_serializing_if = "Option::is_none")]
413    #[ts(optional)]
414    pub source_ability_text: Option<String>,
415    pub is_permanent_spell: bool,
416    pub is_casting: bool,
417    pub is_double_faced: bool,
418    pub face_index: u8,
419    pub targets: Vec<TargetRef>,
420}
421
422#[cfg(test)]
423mod stack_object_tests {
424    use super::{CardIdentity, StackObjectDto};
425
426    #[test]
427    fn serializes_card_face_fields() {
428        let stack_object = StackObjectDto {
429            id: "stack-1".into(),
430            source_id: "card-1".into(),
431            controller_id: "player-0".into(),
432            owner_id: "player-1".into(),
433            identity: CardIdentity::default(),
434            text: String::new(),
435            source_ability_text: Some("When this enters, draw a card.".into()),
436            is_permanent_spell: true,
437            is_casting: true,
438            is_double_faced: true,
439            face_index: 1,
440            targets: Vec::new(),
441        };
442
443        let value = serde_json::to_value(stack_object).unwrap();
444        assert_eq!(value["sourceAbilityText"], "When this enters, draw a card.");
445
446        assert_eq!(value["ownerId"], "player-1");
447        assert_eq!(value["isDoubleFaced"], true);
448        assert_eq!(value["faceIndex"], 1);
449    }
450
451    #[test]
452    fn defaults_missing_card_face_fields() {
453        let value = serde_json::json!({
454            "id": "stack-1",
455            "sourceId": "card-1",
456            "controllerId": "player-0"
457        });
458
459        let stack_object: StackObjectDto = serde_json::from_value(value).unwrap();
460
461        assert!(stack_object.owner_id.is_empty());
462        assert!(!stack_object.is_double_faced);
463        assert_eq!(stack_object.face_index, 0);
464    }
465}
466
467#[derive(
468    Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, TS, strum_macros::Display,
469)]
470#[serde(rename_all = "camelCase")]
471#[ts(export, export_to = "game/index.ts")]
472pub enum TargetingIntent {
473    #[default]
474    Damage,
475    Destroy,
476    Sacrifice,
477    Exile,
478    Bounce,
479    Mill,
480    Discard,
481    Counter,
482    Tap,
483    Untap,
484    Copy,
485    Buff,
486    Debuff,
487    Heal,
488    LoseLife,
489    Reveal,
490    Draw,
491    Fetch,
492    GainControl,
493    Fight,
494    Attach,
495    Attack,
496    Block,
497    Hostile,
498    Friendly,
499}
500
501#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
502#[serde(rename_all = "camelCase")]
503#[ts(export, export_to = "game/index.ts")]
504pub struct PlaymatSettings {
505    #[serde(default, skip_serializing_if = "Option::is_none")]
506    #[ts(optional)]
507    pub opacity: Option<f32>,
508    #[serde(default, skip_serializing_if = "Option::is_none")]
509    #[ts(optional)]
510    pub texture: Option<f32>,
511    #[serde(default, skip_serializing_if = "Option::is_none")]
512    #[ts(optional)]
513    pub border_width: Option<f32>,
514    #[serde(default, skip_serializing_if = "Option::is_none")]
515    #[ts(optional)]
516    pub border_color: Option<String>,
517    #[serde(default, skip_serializing_if = "Option::is_none")]
518    #[ts(optional)]
519    pub fit: Option<String>,
520    #[serde(default, skip_serializing_if = "Option::is_none")]
521    #[ts(optional)]
522    pub offset_x: Option<f32>,
523    #[serde(default, skip_serializing_if = "Option::is_none")]
524    #[ts(optional)]
525    pub offset_y: Option<f32>,
526    #[serde(default, skip_serializing_if = "Option::is_none")]
527    #[ts(optional)]
528    pub zoom: Option<f32>,
529    #[serde(default, skip_serializing_if = "Option::is_none")]
530    #[ts(optional)]
531    pub blur: Option<f32>,
532    #[serde(default, skip_serializing_if = "Option::is_none")]
533    #[ts(optional)]
534    pub brightness: Option<f32>,
535    #[serde(default, skip_serializing_if = "Option::is_none")]
536    #[ts(optional)]
537    pub color: Option<String>,
538}
539
540#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
541#[ts(export, export_to = "game/index.ts")]
542pub enum GameFormat {
543    Any,
544    Standard,
545    Pioneer,
546    Modern,
547    Legacy,
548    Vintage,
549    Pauper,
550    Premodern,
551    Commander,
552    Brawl,
553    Oathbreaker,
554    Draft,
555    Sealed,
556}
557
558#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default, TS)]
559#[ts(export, export_to = "game/index.ts")]
560pub enum EngineKind {
561    #[default]
562    Manabrew,
563    Forge,
564    Ironsmith,
565}