From ea9d9be4c4cd90a00f8c826e43669daee0da47b9 Mon Sep 17 00:00:00 2001 From: WiseDev <83840010+wisedevik@users.noreply.github.com> Date: Mon, 24 Aug 2026 14:26:17 +0300 Subject: [PATCH] cycle the summoner deck on play and draw, RNG-free audit finding #3 (deck), after verifying the RNG stream is not involved: addSpellsInRandomOrder shuffles the INITIAL order with a separate Mersenne Twister, but the server is authoritative (ships the deck in the snapshot; the client decodes it, never reshuffles), and the in-match cycle is fully deterministic. so no RNG matching is needed. play: the command's slot is the DECK index (LogicDoSpellCommand::execute maps it to a hand slot via getSpellIndex, rejecting if it is not in hand). play_from_hand finds the hand slot holding that index, moves the card to the used pile and empties the slot - useSpellFromHand. spawn is untouched; it already reads the card by the deck index. draw: cycle_summoner_decks decrements spell_cooldown each tick and, when it hits 0 with an empty hand slot, slides the front of the draw pile in (refilling draw from used IN ORDER when it empties) and resets the timer to NEXT_SPELL_COOLDOWN_MILLISECONDS (2000 / _BOOST / _OVERTIME). field_240 (reshuffle) just counts down and stays 0 in normal play. harness: playing hand slot 0 empties it and banks the card in used; ticking past the cooldown refills the slot with a different card. --- crates/game-service/src/battle_session.rs | 20 +++++++ crates/game-service/tests/walk_to_tower.rs | 62 ++++++++++++++++++++- crates/logic/src/battle/logic_simulation.rs | 45 +++++++++++++++ 3 files changed, 126 insertions(+), 1 deletion(-) diff --git a/crates/game-service/src/battle_session.rs b/crates/game-service/src/battle_session.rs index c407cc4..fe5381b 100644 --- a/crates/game-service/src/battle_session.rs +++ b/crates/game-service/src/battle_session.rs @@ -78,6 +78,25 @@ impl BattleSession { self.next_snapshot = self.tick; true } + pub fn play_from_hand(&mut self, owner: i32, deck_index: i32) { + for entry in self.mode.battle.objects.objects.iter_mut() { + if entry.owner_index() != owner { + continue; + } + let logic::battle::LogicObjectBody::Summoner(summoner) = &mut entry.body else { + continue; + }; + let Some(deck) = summoner.deck.as_mut() else { + continue; + }; + if let Some(slot) = deck.hand.iter().position(|card| *card == deck_index) { + deck.hand[slot] = -1; + deck.used_pile.push(deck_index); + deck.last_used_index = deck_index; + } + return; + } + } pub fn spend_mana(&mut self, owner: i32, cost: i32) { for entry in self.mode.battle.objects.objects.iter_mut() { if entry.owner_index() != owner { @@ -467,6 +486,7 @@ impl BattleRegistry { }; let cost = card.data().map(|row| row.int("ManaCost")).unwrap_or(0); session.spend_mana(owner, cost); + session.play_from_hand(owner, slot); let entries = summon(&card, position, owner, session.next_instance()); session.reserve_instances(entries.len()); let spawned = entries.len(); diff --git a/crates/game-service/tests/walk_to_tower.rs b/crates/game-service/tests/walk_to_tower.rs index 372339e..f5bf1d0 100644 --- a/crates/game-service/tests/walk_to_tower.rs +++ b/crates/game-service/tests/walk_to_tower.rs @@ -104,7 +104,7 @@ fn the_bot_deploys_in_front_of_its_own_towers() { LogicDataRef::None, LogicDataRef::by_name(table::ARENAS, "Arena_T"), vec![avatar(5), avatar(0)], - [Some(deck.clone()), Some(deck)], + [Some(deck.clone()), Some(deck.clone())], 7, ) .expect("battle"); @@ -509,3 +509,63 @@ fn the_session_emits_a_heartbeat_every_turn() { assert_eq!(turn, 1); assert_eq!(Some(checksum), session.checksum_at(10)); } +#[test] +fn playing_a_card_cycles_the_summoner_hand() { + let root = assets(); + if let Ok(t) = LogicDataTables::load_from_dir(&root) { + LogicDataTables::install(Arc::new(t)); + } + let mut deck = logic::model::LogicSpellDeck::default(); + let names = [ + "Knight", "Archers", "Goblins", "Giant", "Fireball", "Arrows", "Minions", "Musketeer", + ]; + for (slot, name) in names.iter().enumerate().take(deck.slots.len()) { + deck.slots[slot] = Some(logic::model::LogicSpell { + data: LogicDataRef::spell(name), + ..logic::model::LogicSpell::default() + }); + } + let builder = BattleBuilder::new(&root); + let mode = builder + .build( + LogicDataRef::by_name(table::LOCATIONS, "PvP_goblin"), + LogicDataRef::None, + LogicDataRef::by_name(table::ARENAS, "Arena_T"), + vec![avatar(5), avatar(0)], + [Some(deck.clone()), Some(deck.clone())], + 7, + ) + .expect("battle"); + let mut session = game_service::battle_session::BattleSession::new(mode, Vec::new()); + let hand_of = |session: &game_service::battle_session::BattleSession| { + session + .mode() + .battle + .objects + .objects + .iter() + .find_map(|e| match &e.body { + logic::battle::LogicObjectBody::Summoner(s) if e.owner_index() == 0 => { + s.deck.as_ref().map(|d| (d.hand, d.used_pile.clone(), d.spell_cooldown)) + } + _ => None, + }) + .expect("player summoner with a deck") + }; + let (hand0, used0, _) = hand_of(&session); + let filled = deck.filled_slot_count(); + assert!(filled >= 5, "need a draw pile for this test"); + let played = hand0[0]; + assert!(played >= 0, "hand slot 0 holds a card"); + session.play_from_hand(0, played); + let (hand1, used1, _) = hand_of(&session); + assert_eq!(hand1[0], -1, "the played slot is now empty"); + assert!(used1.contains(&played), "the card went to the used pile"); + assert_eq!(used1.len(), used0.len() + 1); + for _ in 0..6 { + session.advance_to(session.tick() + MAX_CATCH_UP_TICKS); + } + let (hand2, _, _) = hand_of(&session); + assert_ne!(hand2[0], -1, "the slot refilled from the draw pile"); + assert_ne!(hand2[0], played, "with a different card than the one played"); +} diff --git a/crates/logic/src/battle/logic_simulation.rs b/crates/logic/src/battle/logic_simulation.rs index 51675c0..ba9e9a8 100644 --- a/crates/logic/src/battle/logic_simulation.rs +++ b/crates/logic/src/battle/logic_simulation.rs @@ -15,6 +15,9 @@ pub const GLOBAL_MAX_MANA: &str = "MAX_MANA"; pub const GLOBAL_MANA_REGEN: &str = "MANA_REGEN_MS"; pub const GLOBAL_MANA_REGEN_END: &str = "MANA_REGEN_MS_END"; pub const GLOBAL_MANA_REGEN_OVERTIME: &str = "MANA_REGEN_MS_OVERTIME"; +pub const GLOBAL_NEXT_SPELL_COOLDOWN: &str = "NEXT_SPELL_COOLDOWN_MILLISECONDS"; +pub const GLOBAL_NEXT_SPELL_COOLDOWN_BOOST: &str = "NEXT_SPELL_COOLDOWN_MILLISECONDS_BOOST"; +pub const GLOBAL_NEXT_SPELL_COOLDOWN_OVERTIME: &str = "NEXT_SPELL_COOLDOWN_MILLISECONDS_OVERTIME"; pub const MAX_MOVE_STEP: i64 = 250; pub const COLLISION_PUSH_LIMIT: i64 = 150; pub const GLOBAL_MANA_SPEED_UP_SECONDS: &str = "MANA_SPEED_UP_WHEN_REMAINING_SECONDS"; @@ -224,12 +227,54 @@ impl LogicBattle { self.activate_summoners(); self.advance_deploy(); self.regenerate_mana(tick); + self.cycle_summoner_decks(tick); self.retarget(); self.resolve_collisions(); self.move_objects(); self.resolve_attacks(); self.remove_dead(); } + fn cycle_summoner_decks(&mut self, tick: i32) { + let elapsed = tick / BATTLE_TICKS_PER_SECOND; + let cooldown = if self.is_on_overtime { + crate::LogicGlobals::number(GLOBAL_NEXT_SPELL_COOLDOWN_OVERTIME) + } else { + let seconds_left = (self.match_length_seconds() - elapsed).max(0); + let speed_up = crate::LogicGlobals::number(GLOBAL_MANA_SPEED_UP_SECONDS); + if seconds_left <= speed_up { + crate::LogicGlobals::number(GLOBAL_NEXT_SPELL_COOLDOWN_BOOST) + } else { + crate::LogicGlobals::number(GLOBAL_NEXT_SPELL_COOLDOWN) + } + }; + for entry in self.objects.objects.iter_mut() { + let LogicObjectBody::Summoner(summoner) = &mut entry.body else { + continue; + }; + let Some(deck) = summoner.deck.as_mut() else { + continue; + }; + if deck.field_240 > 0 { + deck.field_240 = (deck.field_240 - TICK_MILLISECONDS).max(0); + } + deck.spell_cooldown = (deck.spell_cooldown - TICK_MILLISECONDS).max(0); + if deck.spell_cooldown != 0 { + continue; + } + let Some(slot) = deck.hand.iter().position(|card| *card == -1) else { + continue; + }; + if deck.draw_pile.is_empty() { + let refill = std::mem::take(&mut deck.used_pile); + deck.draw_pile = refill; + } + if deck.draw_pile.is_empty() { + continue; + } + deck.hand[slot] = deck.draw_pile.remove(0); + deck.spell_cooldown = cooldown; + } + } fn activate_summoners(&mut self) { let activate_ms = crate::LogicGlobals::number(GLOBAL_KING_ACTIVATE_TIME_MS).max(0); let total = self