Skip to main content

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