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