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;
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 ring_level: i32,
165    pub speed: i32,
166}
167
168#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
169#[serde(rename_all = "camelCase", default)]
170#[ts(export, export_to = "game/index.ts")]
171pub struct CardIdentity {
172    pub name: String,
173    pub set_code: String,
174    pub card_number: String,
175    pub is_token: bool,
176}
177
178#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
179#[serde(rename_all = "camelCase", default)]
180#[ts(export, export_to = "game/index.ts")]
181pub struct ClassLevelDto {
182    pub level: i32,
183    pub oracle: String,
184    #[serde(default, skip_serializing_if = "Option::is_none")]
185    #[ts(optional)]
186    pub cost: Option<String>,
187}
188
189#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
190#[serde(rename_all = "camelCase", default)]
191#[ts(export, export_to = "game/index.ts")]
192pub struct SagaChapterDto {
193    pub chapters: Vec<i32>,
194    pub oracle: String,
195}
196
197#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
198#[serde(rename_all = "camelCase", default)]
199#[ts(export, export_to = "game/index.ts")]
200pub struct CardDto {
201    pub id: String,
202    pub identity: CardIdentity,
203    pub color: String,
204    pub mana_cost: String,
205    pub cmc: i32,
206    pub types: Vec<String>,
207    pub subtypes: Vec<String>,
208    pub supertypes: Vec<String>,
209    pub power: Option<String>,
210    pub toughness: Option<String>,
211    #[serde(skip_serializing_if = "Option::is_none")]
212    #[ts(optional)]
213    pub base_power: Option<i32>,
214    #[serde(skip_serializing_if = "Option::is_none")]
215    #[ts(optional)]
216    pub base_toughness: Option<i32>,
217    #[serde(default, skip_serializing_if = "Option::is_none")]
218    #[ts(optional)]
219    pub final_chapter: Option<i32>,
220    #[serde(default, skip_serializing_if = "Option::is_none")]
221    #[ts(optional)]
222    pub class_level: Option<i32>,
223    pub class_levels: Vec<ClassLevelDto>,
224    pub saga_chapters: Vec<SagaChapterDto>,
225    pub text: String,
226    pub controller_id: String,
227    pub owner_id: String,
228    pub tapped: bool,
229    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
230    pub is_crewed: bool,
231    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
232    pub is_attacking: bool,
233    #[serde(default, skip_serializing_if = "Option::is_none")]
234    #[ts(optional)]
235    pub attacking_player_id: Option<String>,
236    #[serde(default, skip_serializing_if = "Option::is_none")]
237    #[ts(optional)]
238    pub attack_target_id: Option<String>,
239    pub keywords: Vec<String>,
240    // Keyed by the engine's canonical `CounterType` display form ("P1P1",
241    // "Loyalty", one-off counter names uppercase); both producers must match it.
242    #[ts(type = "Record<string, number>")]
243    pub counters: BTreeMap<String, u32>,
244    pub damage: i32,
245    pub summoning_sick: bool,
246    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
247    pub is_copy: bool,
248    pub is_double_faced: bool,
249    pub is_transformed: bool,
250    pub is_face_down: bool,
251    pub is_bestowed: bool,
252    pub phased_out: bool,
253    pub exerted: bool,
254    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
255    pub is_ring_bearer: bool,
256    #[serde(skip_serializing_if = "Option::is_none")]
257    #[ts(optional)]
258    pub attached_to: Option<String>,
259    #[serde(default, skip_serializing_if = "Vec::is_empty")]
260    pub attachment_ids: Vec<String>,
261    // Mutate pile: the card ids merged under this top card.
262    #[serde(default, skip_serializing_if = "Vec::is_empty")]
263    pub merged_card_ids: Vec<String>,
264    #[serde(skip_serializing_if = "Option::is_none")]
265    #[ts(optional)]
266    pub flashback_cost: Option<String>,
267    #[serde(skip_serializing_if = "Option::is_none")]
268    #[ts(optional)]
269    pub kicker_cost: Option<String>,
270    #[serde(skip_serializing_if = "Option::is_none")]
271    #[ts(optional)]
272    pub effective_mana_cost: Option<String>,
273    #[serde(skip_serializing_if = "Option::is_none")]
274    #[ts(optional)]
275    pub madness_cost: Option<String>,
276    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
277    pub is_madness_exiled: bool,
278    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
279    pub is_plotted: bool,
280    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
281    pub is_warp_exiled: bool,
282    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
283    pub foil: bool,
284    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
285    pub would_die_in_combat: bool,
286}
287
288#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
289#[serde(rename_all = "camelCase", default)]
290#[ts(export, export_to = "game/index.ts")]
291pub struct StackObjectDto {
292    pub id: String,
293    pub source_id: String,
294    pub controller_id: String,
295    pub owner_id: String,
296    pub identity: CardIdentity,
297    pub text: String,
298    pub is_permanent_spell: bool,
299    pub is_casting: bool,
300    pub is_double_faced: bool,
301    pub face_index: u8,
302    pub targets: Vec<TargetRef>,
303}
304
305#[cfg(test)]
306mod stack_object_tests {
307    use super::{CardIdentity, StackObjectDto};
308
309    #[test]
310    fn serializes_card_face_fields() {
311        let stack_object = StackObjectDto {
312            id: "stack-1".into(),
313            source_id: "card-1".into(),
314            controller_id: "player-0".into(),
315            owner_id: "player-1".into(),
316            identity: CardIdentity::default(),
317            text: String::new(),
318            is_permanent_spell: true,
319            is_casting: true,
320            is_double_faced: true,
321            face_index: 1,
322            targets: Vec::new(),
323        };
324
325        let value = serde_json::to_value(stack_object).unwrap();
326
327        assert_eq!(value["ownerId"], "player-1");
328        assert_eq!(value["isDoubleFaced"], true);
329        assert_eq!(value["faceIndex"], 1);
330    }
331
332    #[test]
333    fn defaults_missing_card_face_fields() {
334        let value = serde_json::json!({
335            "id": "stack-1",
336            "sourceId": "card-1",
337            "controllerId": "player-0"
338        });
339
340        let stack_object: StackObjectDto = serde_json::from_value(value).unwrap();
341
342        assert!(stack_object.owner_id.is_empty());
343        assert!(!stack_object.is_double_faced);
344        assert_eq!(stack_object.face_index, 0);
345    }
346}
347
348#[derive(
349    Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, TS, strum_macros::Display,
350)]
351#[serde(rename_all = "camelCase")]
352#[ts(export, export_to = "game/index.ts")]
353pub enum TargetingIntent {
354    #[default]
355    Damage,
356    Destroy,
357    Sacrifice,
358    Exile,
359    Bounce,
360    Mill,
361    Discard,
362    Counter,
363    Tap,
364    Untap,
365    Copy,
366    Buff,
367    Debuff,
368    Heal,
369    LoseLife,
370    Reveal,
371    Draw,
372    Fetch,
373    GainControl,
374    Fight,
375    Attach,
376    Attack,
377    Block,
378    Hostile,
379    Friendly,
380}
381
382#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
383#[serde(rename_all = "camelCase")]
384#[ts(export, export_to = "game/index.ts")]
385pub struct PlaymatSettings {
386    #[serde(default, skip_serializing_if = "Option::is_none")]
387    #[ts(optional)]
388    pub opacity: Option<f32>,
389    #[serde(default, skip_serializing_if = "Option::is_none")]
390    #[ts(optional)]
391    pub texture: Option<f32>,
392    #[serde(default, skip_serializing_if = "Option::is_none")]
393    #[ts(optional)]
394    pub border_width: Option<f32>,
395    #[serde(default, skip_serializing_if = "Option::is_none")]
396    #[ts(optional)]
397    pub border_color: Option<String>,
398    #[serde(default, skip_serializing_if = "Option::is_none")]
399    #[ts(optional)]
400    pub fit: Option<String>,
401    #[serde(default, skip_serializing_if = "Option::is_none")]
402    #[ts(optional)]
403    pub offset_x: Option<f32>,
404    #[serde(default, skip_serializing_if = "Option::is_none")]
405    #[ts(optional)]
406    pub offset_y: Option<f32>,
407    #[serde(default, skip_serializing_if = "Option::is_none")]
408    #[ts(optional)]
409    pub zoom: Option<f32>,
410    #[serde(default, skip_serializing_if = "Option::is_none")]
411    #[ts(optional)]
412    pub blur: Option<f32>,
413    #[serde(default, skip_serializing_if = "Option::is_none")]
414    #[ts(optional)]
415    pub brightness: Option<f32>,
416    #[serde(default, skip_serializing_if = "Option::is_none")]
417    #[ts(optional)]
418    pub color: Option<String>,
419}