- 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
161 lines
5.5 KiB
Rust
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"
|
|
);
|
|
}
|