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, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
26#[serde(rename_all = "camelCase")]
27#[ts(export, export_to = "game/index.ts")]
28pub struct Mana {
29    pub color: ManaColor,
30    pub amount: i32,
31}
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, TS)]
34#[serde(rename_all = "camelCase")]
35#[ts(export, export_to = "game/index.ts")]
36pub enum PlayerCounterKind {
37    Poison,
38    Energy,
39    Experience,
40    Radiation,
41    Ticket,
42}
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, TS)]
45#[serde(rename_all = "camelCase")]
46#[ts(export, export_to = "game/index.ts")]
47pub enum ZoneKind {
48    Battlefield,
49    Hand,
50    Library,
51    Graveyard,
52    Exile,
53    Command,
54}
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize, TS)]
57#[serde(rename_all = "camelCase")]
58#[ts(export, export_to = "game/index.ts")]
59pub enum StepKind {
60    #[default]
61    Untap,
62    Upkeep,
63    Draw,
64    Main1,
65    CombatBegin,
66    CombatDeclareAttackers,
67    CombatDeclareBlockers,
68    CombatFirstStrikeDamage,
69    CombatDamage,
70    CombatEnd,
71    Main2,
72    EndOfTurn,
73    Cleanup,
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 DayTime {
80    #[default]
81    Neither,
82    Day,
83    Night,
84}
85
86#[allow(clippy::large_enum_variant)]
87#[derive(Debug, Clone, Serialize, Deserialize, TS)]
88#[serde(
89    tag = "visibility",
90    rename_all = "camelCase",
91    rename_all_fields = "camelCase"
92)]
93#[ts(export, export_to = "game/index.ts")]
94pub enum CardView {
95    Visible(CardDto),
96    Hidden { id: String },
97}
98
99// One entry per (zone, owner) pair; battlefield cards are bucketed by controller.
100#[derive(Debug, Clone, Serialize, Deserialize, TS)]
101#[serde(rename_all = "camelCase")]
102#[ts(export, export_to = "game/index.ts")]
103pub struct ZoneDto {
104    pub zone: ZoneKind,
105    pub owner_id: String,
106    // Ordered top-first where order is public knowledge
107    // count can be > cards.len if hidden cards are present (library)
108    pub cards: Vec<CardView>,
109    pub count: usize,
110}
111
112#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
113#[serde(rename_all = "camelCase")]
114#[ts(export, export_to = "game/index.ts")]
115pub struct GameViewDto {
116    pub game_id: String,
117    pub turn: u32,
118    pub step: StepKind,
119    pub combat_assignments: Vec<CombatAssignmentDto>,
120    pub active_player_id: String,
121    pub priority_player_id: String,
122    pub players: Vec<PlayerDto>,
123    pub zones: Vec<ZoneDto>,
124    pub stack: Vec<StackObjectDto>,
125    pub game_over: bool,
126    pub winner_id: Option<String>,
127    pub monarch_id: Option<String>,
128    pub initiative_holder_id: Option<String>,
129    pub day_time: DayTime,
130}
131
132#[derive(Debug, Clone, Serialize, Deserialize, TS)]
133#[serde(rename_all = "camelCase")]
134#[ts(export, export_to = "game/index.ts")]
135pub struct CombatAssignmentDto {
136    pub blocker_id: String,
137    pub attacker_id: String,
138}
139
140#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, TS)]
141#[serde(rename_all = "camelCase")]
142#[ts(export, export_to = "game/index.ts")]
143pub enum PlayerStatus {
144    #[default]
145    Playing,
146    Lost,
147    Conceded,
148}
149
150#[derive(Debug, Clone, Serialize, Deserialize, TS)]
151#[serde(rename_all = "camelCase")]
152#[ts(export, export_to = "game/index.ts")]
153pub struct PlayerDto {
154    pub id: String,
155    pub name: String,
156    pub status: PlayerStatus,
157    pub is_human: bool,
158    pub life: i32,
159    pub counters: BTreeMap<PlayerCounterKind, u32>,
160    pub mana_pool: BTreeMap<ManaColor, u32>,
161    #[ts(type = "Record<string, number>")]
162    pub commander_damage: HashMap<String, i32>,
163    pub has_city_blessing: bool,
164    pub has_enduring_story: bool,
165    pub ring_level: i32,
166    pub speed: i32,
167}
168
169#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
170#[serde(rename_all = "camelCase", default)]
171#[ts(export, export_to = "game/index.ts")]
172pub struct CardIdentity {
173    pub name: String,
174    pub set_code: String,
175    pub card_number: String,
176    pub is_token: bool,
177    #[serde(default, skip_serializing_if = "Option::is_none")]
178    #[ts(optional)]
179    pub token_script: Option<TokenScript>,
180}
181
182#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
183#[serde(rename_all = "camelCase", default)]
184#[ts(export, export_to = "game/index.ts")]
185pub struct ClassLevelDto {
186    pub level: i32,
187    pub oracle: String,
188    #[serde(default, skip_serializing_if = "Option::is_none")]
189    #[ts(optional)]
190    pub cost: Option<String>,
191}
192
193#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
194#[serde(rename_all = "camelCase", default)]
195#[ts(export, export_to = "game/index.ts")]
196pub struct SagaChapterDto {
197    pub chapters: Vec<i32>,
198    pub oracle: String,
199}
200
201#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
202#[serde(rename_all = "camelCase", default)]
203#[ts(export, export_to = "game/index.ts")]
204pub struct CardDto {
205    pub id: String,
206    pub identity: CardIdentity,
207    pub color: String,
208    pub mana_cost: String,
209    pub cmc: i32,
210    pub types: Vec<String>,
211    pub subtypes: Vec<String>,
212    pub supertypes: Vec<String>,
213    pub power: Option<String>,
214    pub toughness: Option<String>,
215    #[serde(skip_serializing_if = "Option::is_none")]
216    #[ts(optional)]
217    pub base_power: Option<i32>,
218    #[serde(skip_serializing_if = "Option::is_none")]
219    #[ts(optional)]
220    pub base_toughness: Option<i32>,
221    #[serde(default, skip_serializing_if = "Option::is_none")]
222    #[ts(optional)]
223    pub final_chapter: Option<i32>,
224    #[serde(default, skip_serializing_if = "Option::is_none")]
225    #[ts(optional)]
226    pub class_level: Option<i32>,
227    pub class_levels: Vec<ClassLevelDto>,
228    pub saga_chapters: Vec<SagaChapterDto>,
229    pub text: String,
230    pub controller_id: String,
231    pub owner_id: String,
232    pub tapped: bool,
233    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
234    pub is_crewed: bool,
235    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
236    pub is_attacking: bool,
237    #[serde(default, skip_serializing_if = "Option::is_none")]
238    #[ts(optional)]
239    pub attacking_player_id: Option<String>,
240    #[serde(default, skip_serializing_if = "Option::is_none")]
241    #[ts(optional)]
242    pub attack_target_id: Option<String>,
243    pub keywords: Vec<String>,
244    // Keyed by the engine's canonical `CounterType` display form ("P1P1",
245    // "Loyalty", one-off counter names uppercase); both producers must match it.
246    #[ts(type = "Record<string, number>")]
247    pub counters: BTreeMap<String, u32>,
248    pub damage: i32,
249    pub summoning_sick: bool,
250    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
251    pub is_copy: bool,
252    pub is_double_faced: bool,
253    pub is_transformed: bool,
254    pub is_face_down: bool,
255    pub is_bestowed: bool,
256    pub phased_out: bool,
257    pub exerted: bool,
258    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
259    pub is_ring_bearer: bool,
260    #[serde(skip_serializing_if = "Option::is_none")]
261    #[ts(optional)]
262    pub attached_to: Option<String>,
263    #[serde(default, skip_serializing_if = "Vec::is_empty")]
264    pub attachment_ids: Vec<String>,
265    // Mutate pile: the card ids merged under this top card.
266    #[serde(default, skip_serializing_if = "Vec::is_empty")]
267    pub merged_card_ids: Vec<String>,
268    #[serde(skip_serializing_if = "Option::is_none")]
269    #[ts(optional)]
270    pub flashback_cost: Option<String>,
271    #[serde(skip_serializing_if = "Option::is_none")]
272    #[ts(optional)]
273    pub kicker_cost: Option<String>,
274    #[serde(skip_serializing_if = "Option::is_none")]
275    #[ts(optional)]
276    pub effective_mana_cost: Option<String>,
277    #[serde(skip_serializing_if = "Option::is_none")]
278    #[ts(optional)]
279    pub madness_cost: Option<String>,
280    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
281    pub is_madness_exiled: bool,
282    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
283    pub is_plotted: bool,
284    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
285    pub is_warp_exiled: bool,
286    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
287    pub foil: bool,
288    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
289    pub would_die_in_combat: bool,
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 StackObjectDto {
296    pub id: String,
297    pub source_id: String,
298    pub controller_id: String,
299    pub owner_id: String,
300    pub identity: CardIdentity,
301    pub text: String,
302    pub is_permanent_spell: bool,
303    pub is_casting: bool,
304    pub is_double_faced: bool,
305    pub face_index: u8,
306    pub targets: Vec<TargetRef>,
307}
308
309#[cfg(test)]
310mod stack_object_tests {
311    use super::{CardIdentity, StackObjectDto};
312
313    #[test]
314    fn serializes_card_face_fields() {
315        let stack_object = StackObjectDto {
316            id: "stack-1".into(),
317            source_id: "card-1".into(),
318            controller_id: "player-0".into(),
319            owner_id: "player-1".into(),
320            identity: CardIdentity::default(),
321            text: String::new(),
322            is_permanent_spell: true,
323            is_casting: true,
324            is_double_faced: true,
325            face_index: 1,
326            targets: Vec::new(),
327        };
328
329        let value = serde_json::to_value(stack_object).unwrap();
330
331        assert_eq!(value["ownerId"], "player-1");
332        assert_eq!(value["isDoubleFaced"], true);
333        assert_eq!(value["faceIndex"], 1);
334    }
335
336    #[test]
337    fn defaults_missing_card_face_fields() {
338        let value = serde_json::json!({
339            "id": "stack-1",
340            "sourceId": "card-1",
341            "controllerId": "player-0"
342        });
343
344        let stack_object: StackObjectDto = serde_json::from_value(value).unwrap();
345
346        assert!(stack_object.owner_id.is_empty());
347        assert!(!stack_object.is_double_faced);
348        assert_eq!(stack_object.face_index, 0);
349    }
350}
351
352#[derive(
353    Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, TS, strum_macros::Display,
354)]
355#[serde(rename_all = "camelCase")]
356#[ts(export, export_to = "game/index.ts")]
357pub enum TargetingIntent {
358    #[default]
359    Damage,
360    Destroy,
361    Sacrifice,
362    Exile,
363    Bounce,
364    Mill,
365    Discard,
366    Counter,
367    Tap,
368    Untap,
369    Copy,
370    Buff,
371    Debuff,
372    Heal,
373    LoseLife,
374    Reveal,
375    Draw,
376    Fetch,
377    GainControl,
378    Fight,
379    Attach,
380    Attack,
381    Block,
382    Hostile,
383    Friendly,
384}
385
386#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
387#[serde(rename_all = "camelCase")]
388#[ts(export, export_to = "game/index.ts")]
389pub struct PlaymatSettings {
390    #[serde(default, skip_serializing_if = "Option::is_none")]
391    #[ts(optional)]
392    pub opacity: Option<f32>,
393    #[serde(default, skip_serializing_if = "Option::is_none")]
394    #[ts(optional)]
395    pub texture: Option<f32>,
396    #[serde(default, skip_serializing_if = "Option::is_none")]
397    #[ts(optional)]
398    pub border_width: Option<f32>,
399    #[serde(default, skip_serializing_if = "Option::is_none")]
400    #[ts(optional)]
401    pub border_color: Option<String>,
402    #[serde(default, skip_serializing_if = "Option::is_none")]
403    #[ts(optional)]
404    pub fit: Option<String>,
405    #[serde(default, skip_serializing_if = "Option::is_none")]
406    #[ts(optional)]
407    pub offset_x: Option<f32>,
408    #[serde(default, skip_serializing_if = "Option::is_none")]
409    #[ts(optional)]
410    pub offset_y: Option<f32>,
411    #[serde(default, skip_serializing_if = "Option::is_none")]
412    #[ts(optional)]
413    pub zoom: Option<f32>,
414    #[serde(default, skip_serializing_if = "Option::is_none")]
415    #[ts(optional)]
416    pub blur: Option<f32>,
417    #[serde(default, skip_serializing_if = "Option::is_none")]
418    #[ts(optional)]
419    pub brightness: Option<f32>,
420    #[serde(default, skip_serializing_if = "Option::is_none")]
421    #[ts(optional)]
422    pub color: Option<String>,
423}
424
425#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
426#[ts(export, export_to = "game/index.ts")]
427pub enum GameFormat {
428    Any,
429    Standard,
430    Pioneer,
431    Modern,
432    Legacy,
433    Vintage,
434    Pauper,
435    Commander,
436    Brawl,
437    Oathbreaker,
438    Draft,
439    Sealed,
440}
441
442#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default, TS)]
443#[ts(export, export_to = "game/index.ts")]
444pub enum EngineKind {
445    #[default]
446    Manabrew,
447    Forge,
448    Ironsmith,
449}