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    #[serde(skip_serializing_if = "Option::is_none")]
322    #[ts(optional)]
323    pub source_ability_text: Option<String>,
324    pub is_permanent_spell: bool,
325    pub is_casting: bool,
326    pub is_double_faced: bool,
327    pub face_index: u8,
328    pub targets: Vec<TargetRef>,
329}
330
331#[cfg(test)]
332mod stack_object_tests {
333    use super::{CardIdentity, StackObjectDto};
334
335    #[test]
336    fn serializes_card_face_fields() {
337        let stack_object = StackObjectDto {
338            id: "stack-1".into(),
339            source_id: "card-1".into(),
340            controller_id: "player-0".into(),
341            owner_id: "player-1".into(),
342            identity: CardIdentity::default(),
343            text: String::new(),
344            source_ability_text: Some("When this enters, draw a card.".into()),
345            is_permanent_spell: true,
346            is_casting: true,
347            is_double_faced: true,
348            face_index: 1,
349            targets: Vec::new(),
350        };
351
352        let value = serde_json::to_value(stack_object).unwrap();
353        assert_eq!(value["sourceAbilityText"], "When this enters, draw a card.");
354
355        assert_eq!(value["ownerId"], "player-1");
356        assert_eq!(value["isDoubleFaced"], true);
357        assert_eq!(value["faceIndex"], 1);
358    }
359
360    #[test]
361    fn defaults_missing_card_face_fields() {
362        let value = serde_json::json!({
363            "id": "stack-1",
364            "sourceId": "card-1",
365            "controllerId": "player-0"
366        });
367
368        let stack_object: StackObjectDto = serde_json::from_value(value).unwrap();
369
370        assert!(stack_object.owner_id.is_empty());
371        assert!(!stack_object.is_double_faced);
372        assert_eq!(stack_object.face_index, 0);
373    }
374}
375
376#[derive(
377    Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, TS, strum_macros::Display,
378)]
379#[serde(rename_all = "camelCase")]
380#[ts(export, export_to = "game/index.ts")]
381pub enum TargetingIntent {
382    #[default]
383    Damage,
384    Destroy,
385    Sacrifice,
386    Exile,
387    Bounce,
388    Mill,
389    Discard,
390    Counter,
391    Tap,
392    Untap,
393    Copy,
394    Buff,
395    Debuff,
396    Heal,
397    LoseLife,
398    Reveal,
399    Draw,
400    Fetch,
401    GainControl,
402    Fight,
403    Attach,
404    Attack,
405    Block,
406    Hostile,
407    Friendly,
408}
409
410#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
411#[serde(rename_all = "camelCase")]
412#[ts(export, export_to = "game/index.ts")]
413pub struct PlaymatSettings {
414    #[serde(default, skip_serializing_if = "Option::is_none")]
415    #[ts(optional)]
416    pub opacity: Option<f32>,
417    #[serde(default, skip_serializing_if = "Option::is_none")]
418    #[ts(optional)]
419    pub texture: Option<f32>,
420    #[serde(default, skip_serializing_if = "Option::is_none")]
421    #[ts(optional)]
422    pub border_width: Option<f32>,
423    #[serde(default, skip_serializing_if = "Option::is_none")]
424    #[ts(optional)]
425    pub border_color: Option<String>,
426    #[serde(default, skip_serializing_if = "Option::is_none")]
427    #[ts(optional)]
428    pub fit: Option<String>,
429    #[serde(default, skip_serializing_if = "Option::is_none")]
430    #[ts(optional)]
431    pub offset_x: Option<f32>,
432    #[serde(default, skip_serializing_if = "Option::is_none")]
433    #[ts(optional)]
434    pub offset_y: Option<f32>,
435    #[serde(default, skip_serializing_if = "Option::is_none")]
436    #[ts(optional)]
437    pub zoom: Option<f32>,
438    #[serde(default, skip_serializing_if = "Option::is_none")]
439    #[ts(optional)]
440    pub blur: Option<f32>,
441    #[serde(default, skip_serializing_if = "Option::is_none")]
442    #[ts(optional)]
443    pub brightness: Option<f32>,
444    #[serde(default, skip_serializing_if = "Option::is_none")]
445    #[ts(optional)]
446    pub color: Option<String>,
447}
448
449#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
450#[ts(export, export_to = "game/index.ts")]
451pub enum GameFormat {
452    Any,
453    Standard,
454    Pioneer,
455    Modern,
456    Legacy,
457    Vintage,
458    Pauper,
459    Premodern,
460    Commander,
461    Brawl,
462    Oathbreaker,
463    Draft,
464    Sealed,
465}
466
467#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default, TS)]
468#[ts(export, export_to = "game/index.ts")]
469pub enum EngineKind {
470    #[default]
471    Manabrew,
472    Forge,
473    Ironsmith,
474}