scroll.server/crates/game-service/tests/projectile_references.rs
WiseDev a585b0c18d resolve the deploy birth tick and stop wedging the client's own commands
- a card lands one tick after its command fires, since the client
  hashes after LogicTime::increaseTick, not before
- answer RequestSectorState immediately except in the two ticks after
  a firing tick, where a full update would teleport the client past
  the command it just queued - the old blanket refusal left every
  card the player tapped silently deleted while it held
- carry both pending commands and queued deploys in the same delivery
  ledger so the quiet window covers what the player actually paid for
- add the regression tests for the birth tick and the deferred answer
2026-08-28 16:27:16 +03:00

161 lines
5.5 KiB
Rust

use game_service::battle::BattleBuilder;
use logic::battle::{LogicObjectBody, LogicVector2};
use logic::data::{table, LogicDataRef, LogicDataTables};
use logic::model::LogicClientAvatar;
use std::path::Path;
use std::sync::Arc;
use titan::LogicLong;
fn assets() -> std::path::PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../assets")
}
fn avatar(low: i32) -> LogicClientAvatar {
let account = LogicLong::new(0, low);
LogicClientAvatar {
avatar_id: account,
account_id: account,
home_id: account,
name_change_state: -1,
..LogicClientAvatar::default()
}
}
#[test]
fn a_projectile_drops_its_reference_to_an_object_that_died() {
let root = assets();
let tables = LogicDataTables::load_from_dir(&root).expect("tables");
LogicDataTables::install(Arc::new(tables));
let builder = BattleBuilder::new(&root);
let mut mode = builder
.build(
LogicDataRef::by_name(table::LOCATIONS, "PvP_goblin"),
LogicDataRef::None,
LogicDataRef::by_name(table::ARENAS, "Arena_T"),
vec![avatar(5), avatar(0)],
[None, None],
1,
)
.expect("battle");
let spell = LogicDataRef::spell("SkeletonArmy");
for entry in builder.summon(&spell, LogicVector2::new(14500, 22000), 0, 0, 100) {
mode.battle.objects.push(entry);
}
let mut projectiles_seen = 0usize;
let mut stale = Vec::new();
for tick in 0..600 {
mode.battle.tick(tick);
let alive: Vec<_> = mode
.battle
.objects
.objects
.iter()
.map(|entry| entry.global_id)
.collect();
for entry in &mode.battle.objects.objects {
let LogicObjectBody::Projectile(projectile) = &entry.body else {
continue;
};
projectiles_seen += 1;
for (name, reference) in [("target", projectile.target), ("source", projectile.source)]
{
if reference.is_none() {
continue;
}
if !alive.contains(&reference) {
stale.push(format!(
"t={tick}: projectile {:?} still points {name} at {:?}, which is off the board",
entry.global_id.0, reference.0
));
}
}
}
}
assert!(
projectiles_seen > 0,
"no projectile was ever in the air, so the test proved nothing"
);
assert!(
stale.is_empty(),
"a projectile outlived one of its references:\n{}",
stale.join("\n")
);
}
#[test]
fn pending_physical_damage_matches_the_shots_in_the_air() {
let root = assets();
let tables = LogicDataTables::load_from_dir(&root).expect("tables");
LogicDataTables::install(Arc::new(tables));
let builder = BattleBuilder::new(&root);
let mut mode = builder
.build(
LogicDataRef::by_name(table::LOCATIONS, "PvP_goblin"),
LogicDataRef::None,
LogicDataRef::by_name(table::ARENAS, "Arena_T"),
vec![avatar(5), avatar(0)],
[None, None],
1,
)
.expect("battle");
for spell in ["Archer", "SkeletonArmy"] {
for entry in builder.summon(
&LogicDataRef::spell(spell),
LogicVector2::new(14500, 22000),
0,
0,
100,
) {
mode.battle.objects.push(entry);
}
}
let mut ever_positive = false;
for tick in 0..800 {
mode.battle.tick(tick);
let mut owed: Vec<(logic::battle::LogicGameObjectRef, i32)> = Vec::new();
for entry in &mode.battle.objects.objects {
let LogicObjectBody::Projectile(shot) = &entry.body else {
continue;
};
if shot.destroyed || shot.target.is_none() {
continue;
}
let Some(data) = entry.data.as_projectile() else {
continue;
};
if !data.uses_pending_physical_damage() {
continue;
}
let damage = data.damage(shot.level_index.max(0) as usize);
match owed.iter_mut().find(|(t, _)| *t == shot.target) {
Some((_, total)) => *total += damage,
None => owed.push((shot.target, damage)),
}
}
for entry in &mode.battle.objects.objects {
let held = match &entry.body {
LogicObjectBody::Character(character) => character.pending_physical_damage,
LogicObjectBody::Summoner(summoner) => summoner.character.pending_physical_damage,
LogicObjectBody::Projectile(_) => continue,
};
assert!(
held >= 0,
"t={tick}: {:?} holds a NEGATIVE pending of {held}, which aborts the client",
entry.global_id.0
);
let want = owed
.iter()
.find(|(t, _)| *t == entry.global_id)
.map(|(_, total)| *total)
.unwrap_or(0);
assert_eq!(
held, want,
"t={tick}: {:?} holds {held} pending but the shots in the air owe {want}",
entry.global_id.0
);
if held > 0 {
ever_positive = true;
}
}
}
assert!(
ever_positive,
"no shot ever registered pending damage, so the test proved nothing"
);
}