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.
This commit is contained in:
WiseDev 2026-08-24 14:26:17 +03:00
parent 7ead67d219
commit ea9d9be4c4
3 changed files with 126 additions and 1 deletions

View file

@ -78,6 +78,25 @@ impl BattleSession {
self.next_snapshot = self.tick; self.next_snapshot = self.tick;
true 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) { pub fn spend_mana(&mut self, owner: i32, cost: i32) {
for entry in self.mode.battle.objects.objects.iter_mut() { for entry in self.mode.battle.objects.objects.iter_mut() {
if entry.owner_index() != owner { if entry.owner_index() != owner {
@ -467,6 +486,7 @@ impl BattleRegistry {
}; };
let cost = card.data().map(|row| row.int("ManaCost")).unwrap_or(0); let cost = card.data().map(|row| row.int("ManaCost")).unwrap_or(0);
session.spend_mana(owner, cost); session.spend_mana(owner, cost);
session.play_from_hand(owner, slot);
let entries = summon(&card, position, owner, session.next_instance()); let entries = summon(&card, position, owner, session.next_instance());
session.reserve_instances(entries.len()); session.reserve_instances(entries.len());
let spawned = entries.len(); let spawned = entries.len();

View file

@ -104,7 +104,7 @@ fn the_bot_deploys_in_front_of_its_own_towers() {
LogicDataRef::None, LogicDataRef::None,
LogicDataRef::by_name(table::ARENAS, "Arena_T"), LogicDataRef::by_name(table::ARENAS, "Arena_T"),
vec![avatar(5), avatar(0)], vec![avatar(5), avatar(0)],
[Some(deck.clone()), Some(deck)], [Some(deck.clone()), Some(deck.clone())],
7, 7,
) )
.expect("battle"); .expect("battle");
@ -509,3 +509,63 @@ fn the_session_emits_a_heartbeat_every_turn() {
assert_eq!(turn, 1); assert_eq!(turn, 1);
assert_eq!(Some(checksum), session.checksum_at(10)); 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");
}

View file

@ -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: &str = "MANA_REGEN_MS";
pub const GLOBAL_MANA_REGEN_END: &str = "MANA_REGEN_MS_END"; 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_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 MAX_MOVE_STEP: i64 = 250;
pub const COLLISION_PUSH_LIMIT: i64 = 150; pub const COLLISION_PUSH_LIMIT: i64 = 150;
pub const GLOBAL_MANA_SPEED_UP_SECONDS: &str = "MANA_SPEED_UP_WHEN_REMAINING_SECONDS"; pub const GLOBAL_MANA_SPEED_UP_SECONDS: &str = "MANA_SPEED_UP_WHEN_REMAINING_SECONDS";
@ -224,12 +227,54 @@ impl LogicBattle {
self.activate_summoners(); self.activate_summoners();
self.advance_deploy(); self.advance_deploy();
self.regenerate_mana(tick); self.regenerate_mana(tick);
self.cycle_summoner_decks(tick);
self.retarget(); self.retarget();
self.resolve_collisions(); self.resolve_collisions();
self.move_objects(); self.move_objects();
self.resolve_attacks(); self.resolve_attacks();
self.remove_dead(); 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) { fn activate_summoners(&mut self) {
let activate_ms = crate::LogicGlobals::number(GLOBAL_KING_ACTIVATE_TIME_MS).max(0); let activate_ms = crate::LogicGlobals::number(GLOBAL_KING_ACTIVATE_TIME_MS).max(0);
let total = self let total = self