weiss_core/env/
mod.rs

1//! Game environment and advance-until-decision loop.
2//!
3//! Related docs:
4//! - <https://github.com/victorwp288/weiss-schwarz-simulator/blob/main/docs/README.md>
5//! - <https://github.com/victorwp288/weiss-schwarz-simulator/blob/main/docs/engine_architecture.md>
6//! - <https://github.com/victorwp288/weiss-schwarz-simulator/blob/main/docs/rl_contract.md>
7
8use crate::config::RewardConfig;
9use crate::db::{
10    AbilitySpec, AbilityTemplate, CardId, CardStatic, CountCmp, CountZone, ZoneCountCondition,
11};
12use crate::state::{ModifierDuration, ModifierKind, TerminalResult};
13
14mod actions;
15mod advance;
16mod cache;
17mod constants;
18mod core;
19mod debug_events;
20mod debug_fingerprints;
21mod debug_validate;
22mod fault;
23mod lifecycle;
24mod live_abilities;
25mod obs;
26mod shared;
27mod types;
28
29mod interaction;
30mod modifiers;
31mod movement;
32mod phases;
33mod visibility;
34
35#[cfg(feature = "test-harness")]
36/// Test harness helpers exposed only when the `test-harness` feature is enabled.
37pub mod harness;
38
39pub use actions::legal_action_ids_cached_into;
40pub use core::GameEnv;
41pub use types::{DebugConfig, EngineErrorCode, EnvInfo, FaultRecord, FaultSource, StepOutcome};
42
43pub(crate) use cache::{ActionCache, EnvScratch};
44pub use constants::{CHECK_TIMING_QUIESCENCE_CAP, HAND_LIMIT, STACK_AUTO_RESOLVE_CAP};
45pub(crate) use constants::{
46    MAX_CHOICE_OPTIONS, TRIGGER_EFFECT_BOUNCE, TRIGGER_EFFECT_DRAW, TRIGGER_EFFECT_GATE,
47    TRIGGER_EFFECT_POOL_MOVE, TRIGGER_EFFECT_POOL_STOCK, TRIGGER_EFFECT_SHOT, TRIGGER_EFFECT_SOUL,
48    TRIGGER_EFFECT_STANDBY, TRIGGER_EFFECT_TREASURE_MOVE, TRIGGER_EFFECT_TREASURE_STOCK,
49};
50pub(crate) use types::{
51    DamageIntentLocal, DamageResolveResult, TriggerCompileContext, VisibilityContext,
52};
53
54impl GameEnv {
55    /// Add a temporary or permanent modifier to a stage slot.
56    pub fn add_modifier(
57        &mut self,
58        source: CardId,
59        target_player: u8,
60        target_slot: u8,
61        kind: ModifierKind,
62        magnitude: i32,
63        duration: ModifierDuration,
64    ) -> Option<u32> {
65        self.add_modifier_instance(
66            source,
67            None,
68            target_player,
69            target_slot,
70            kind,
71            magnitude,
72            duration,
73            crate::state::ModifierLayer::Effect,
74        )
75    }
76
77    /// Mark rule-action fixpoint work as stale for the next engine advance.
78    ///
79    /// Any mutation that can enable or disable rule actions should call this so
80    /// `run_rule_actions_if_needed` re-evaluates invariants before the next
81    /// decision boundary.
82    pub(crate) fn mark_rule_actions_dirty(&mut self) {
83        self.rule_actions_dirty = true;
84    }
85
86    /// Mark continuous-modifier derived state as stale for recomputation.
87    ///
88    /// This is paired with power/cache invalidation and ensures continuous
89    /// effects are reapplied exactly once on the next advance pass.
90    pub(crate) fn mark_continuous_modifiers_dirty(&mut self) {
91        self.continuous_modifiers_dirty = true;
92    }
93
94    fn run_rule_actions_if_needed(&mut self) {
95        if self.state.turn.phase != self.last_rule_action_phase {
96            self.rule_actions_dirty = true;
97            self.last_rule_action_phase = self.state.turn.phase;
98        }
99        if !self.rule_actions_dirty {
100            return;
101        }
102        self.rule_actions_dirty = false;
103        self.resolve_rule_actions_until_stable();
104    }
105
106    fn card_set_allowed(&self, card: &CardStatic) -> bool {
107        match (&self.curriculum.allowed_card_sets_cache, &card.card_set) {
108            (None, _) => true,
109            (Some(set), Some(set_id)) => set.contains(set_id),
110            (Some(_), None) => false,
111        }
112    }
113
114    fn ability_conditions_met(
115        &self,
116        controller: u8,
117        conditions: &crate::db::AbilityDefConditions,
118    ) -> bool {
119        if conditions.requires_approx_effects && !self.curriculum.enable_approx_effects {
120            return false;
121        }
122        if let Some(turn) = conditions.turn {
123            let is_self_turn = self.state.turn.active_player == controller;
124            match turn {
125                crate::db::ConditionTurn::SelfTurn if !is_self_turn => return false,
126                crate::db::ConditionTurn::OpponentTurn if is_self_turn => return false,
127                _ => {}
128            }
129        }
130        if let Some(max_memory) = conditions.self_memory_at_most {
131            if self.state.players[controller as usize].memory.len() > max_memory as usize {
132                return false;
133            }
134        }
135        if !conditions.self_memory_card_ids_any.is_empty() {
136            let has_required = self.state.players[controller as usize]
137                .memory
138                .iter()
139                .any(|card_inst| conditions.self_memory_card_ids_any.contains(&card_inst.id));
140            if !has_required {
141                return false;
142            }
143        }
144        if conditions.trigger_check_revealed_climax {
145            let Some(ctx) = self.state.turn.attack.as_ref() else {
146                return false;
147            };
148            if self.state.turn.active_player != controller {
149                return false;
150            }
151            let Some(trigger_card) = ctx.trigger_card else {
152                return false;
153            };
154            let Some(card) = self.db.get(trigger_card) else {
155                return false;
156            };
157            if card.card_type != crate::db::CardType::Climax {
158                return false;
159            }
160        }
161        if let Some(required_icon) = conditions.trigger_check_revealed_icon {
162            let Some(ctx) = self.state.turn.attack.as_ref() else {
163                return false;
164            };
165            if self.state.turn.active_player != controller {
166                return false;
167            }
168            let Some(trigger_card) = ctx.trigger_card else {
169                return false;
170            };
171            let Some(card) = self.db.get(trigger_card) else {
172                return false;
173            };
174            if !card.triggers.contains(&required_icon) {
175                return false;
176            }
177        }
178        if let Some(zone_condition) = conditions.zone_count.as_ref() {
179            if !self.zone_count_condition_met(controller, zone_condition) {
180                return false;
181            }
182        }
183        let Some(climax_condition) = conditions.climax_area.as_ref() else {
184            return true;
185        };
186        let target_player = match climax_condition.side {
187            crate::state::TargetSide::SelfSide => controller,
188            crate::state::TargetSide::Opponent => 1 - controller,
189        } as usize;
190        let climax = self
191            .state
192            .players
193            .get(target_player)
194            .map(|player| &player.climax);
195        let Some(climax) = climax else {
196            return false;
197        };
198        if climax.is_empty() {
199            return false;
200        }
201        if climax_condition.card_ids.is_empty() {
202            return true;
203        }
204        climax
205            .iter()
206            .any(|card_inst| climax_condition.card_ids.contains(&card_inst.id))
207    }
208
209    pub(in crate::env) fn auto_ability_conditions_met(
210        &self,
211        controller: u8,
212        _source_card: CardId,
213        spec: &AbilitySpec,
214    ) -> bool {
215        let AbilityTemplate::AbilityDef(def) = &spec.template else {
216            return true;
217        };
218        self.ability_conditions_met(controller, &def.conditions)
219    }
220
221    /// Compute terminal reward from `perspective` using the configured reward table.
222    ///
223    /// Non-terminal states always produce `0.0`; faults/timeouts are treated as
224    /// draw-equivalent rewards.
225    pub(crate) fn terminal_reward_for(&self, perspective: u8) -> f32 {
226        let RewardConfig {
227            terminal_win,
228            terminal_loss,
229            terminal_draw,
230            ..
231        } = &self.config.reward;
232        match self.state.terminal {
233            Some(TerminalResult::Win { winner }) => {
234                if winner == perspective {
235                    *terminal_win
236                } else {
237                    *terminal_loss
238                }
239            }
240            Some(TerminalResult::Draw | TerminalResult::Timeout) => *terminal_draw,
241            None => 0.0,
242        }
243    }
244
245    pub(in crate::env) fn zone_count_value_for_condition(
246        &self,
247        controller: u8,
248        condition: &ZoneCountCondition,
249    ) -> usize {
250        let player = match condition.side {
251            crate::state::TargetSide::SelfSide => controller,
252            crate::state::TargetSide::Opponent => 1 - controller,
253        } as usize;
254        let matches_id = |card_id: CardId| {
255            condition.card_ids.is_empty() || condition.card_ids.contains(&card_id)
256        };
257        match condition.zone {
258            CountZone::Stock => self.state.players[player]
259                .stock
260                .iter()
261                .filter(|card| matches_id(card.id))
262                .count(),
263            CountZone::WaitingRoom => self.state.players[player]
264                .waiting_room
265                .iter()
266                .filter(|card| matches_id(card.id))
267                .count(),
268            CountZone::Hand => self.state.players[player]
269                .hand
270                .iter()
271                .filter(|card| matches_id(card.id))
272                .count(),
273            CountZone::Stage => self.state.players[player]
274                .stage
275                .iter()
276                .filter(|slot| slot.card.map(|card| matches_id(card.id)).unwrap_or(false))
277                .count(),
278            CountZone::BackStage => {
279                if self.curriculum.reduced_stage_mode {
280                    0
281                } else {
282                    self.state.players[player]
283                        .stage
284                        .iter()
285                        .enumerate()
286                        .filter(|(idx, slot)| {
287                            *idx >= 3 && slot.card.map(|card| matches_id(card.id)).unwrap_or(false)
288                        })
289                        .count()
290                }
291            }
292            CountZone::WaitingRoomClimax => self.state.players[player]
293                .waiting_room
294                .iter()
295                .filter(|card| {
296                    if !matches_id(card.id) {
297                        return false;
298                    }
299                    self.db
300                        .get(card.id)
301                        .map(|static_card| static_card.card_type == crate::db::CardType::Climax)
302                        .unwrap_or(false)
303                })
304                .count(),
305            CountZone::LevelTotal => self.state.players[player]
306                .level
307                .iter()
308                .filter(|card| matches_id(card.id))
309                .map(|card| {
310                    self.db
311                        .get(card.id)
312                        .map(|static_card| static_card.level as usize)
313                        .unwrap_or(0)
314                })
315                .sum(),
316        }
317    }
318
319    pub(in crate::env) fn zone_count_condition_met(
320        &self,
321        controller: u8,
322        condition: &ZoneCountCondition,
323    ) -> bool {
324        let count = self.zone_count_value_for_condition(controller, condition);
325        match condition.cmp {
326            CountCmp::AtLeast => count >= condition.value as usize,
327            CountCmp::AtMost => count <= condition.value as usize,
328        }
329    }
330}
331
332#[cfg(test)]
333mod tests;