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