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