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