send the SectorHeartbeat the type-0 client is built around

audit finding #16. steady state was a full SectorState (21903) every
four ticks - a hard client reset each time - and the per-turn channel
the client is actually written around was never driven.

added SectorHeartbeatMessage (21902): wire is vint serverTurn, vint
checksum, with optional command and server-tick-data blocks the client's
decode treats as absent at end of stream. the session now emits one per
turn (every 10 ticks); the client reads serverTick as 10*serverTurn and
verifies its predicted checksum for that tick against ours via
LogicGameMode::endTurnReceivedFromServer. a match keeps it in sync with
no reset; a mismatch flips it out of sync, which makes it request a
sector state - which request_sector_state already serves. now that the
idle divergence is fixed the early-game turns verify cleanly.

format confirmed against SectorHeartbeatMessage::decode/encode and
getMessageType (21902) in the client. the full snapshots still go out
too; routing the bot's plays as commands inside the heartbeat and then
dropping the periodic snapshot is the next step, but this stands up the
channel and the checksum verification.

harness: the session emits heartbeats at ticks 10 and 20, and the first
decodes to (turn 1, checksum_at(10)).
This commit is contained in:
WiseDev 2026-08-24 11:40:03 +03:00
parent 2933d2ff7e
commit 7ead67d219
4 changed files with 65 additions and 2 deletions

View file

@ -10,13 +10,14 @@ pub fn snapshot_interval_ticks() -> i32 {
}
pub const MAX_CATCH_UP_TICKS: i32 = 40;
pub const CHECKSUM_HISTORY: usize = 256;
pub const TICKS_PER_TURN: i32 = 10;
pub const BOT_DEPLOY_AHEAD: i32 = 2000;
use std::time::Instant;
use logic::battle::{
LogicGameMode, LogicGameObjectEntry, LogicVector2, BATTLE_TICKS_PER_SECOND, BATTLE_TYPE_PVP,
};
use logic::battle::{verify_snapshot, LogicBattleEvent};
use logic::SectorStateMessage;
use logic::{SectorHeartbeatMessage, SectorStateMessage};
use logic::{BattleEventMessage, LogicDataRef, LogicRandom};
use titan::LogicLong;
use service_rpc::{AccountRef, WireMessage};
@ -238,12 +239,23 @@ impl BattleSession {
self.mode.time.tick = self.tick;
self.release_queued(self.tick);
self.mode.battle.tick(self.tick);
if let Ok(checksum) = self.mode.calculate_checksum() {
let this_checksum = self.mode.calculate_checksum().ok();
if let Some(checksum) = this_checksum {
if self.recent_checksums.len() >= CHECKSUM_HISTORY {
self.recent_checksums.pop_front();
}
self.recent_checksums.push_back((self.tick, checksum));
}
if self.pushes_snapshots() && self.tick % TICKS_PER_TURN == 0 {
if let Some(checksum) = this_checksum {
let turn = self.tick / TICKS_PER_TURN;
if let Ok(message) =
crate::wire::encode(&SectorHeartbeatMessage::new(turn, checksum))
{
self.outbound.push(message);
}
}
}
if self.pushes_snapshots() && self.tick >= self.next_snapshot {
self.next_snapshot = self.tick + snapshot_interval_ticks();
if let Some(message) = self.snapshot_message() {

View file

@ -478,3 +478,34 @@ fn a_troop_in_range_damages_the_tower_with_the_client_hit_model() {
.expect("tower still present");
assert!(hp < start_hp, "the knight's attacks should reduce tower hp ({hp} !< {start_hp})");
}
#[test]
fn the_session_emits_a_heartbeat_every_turn() {
let root = assets();
if let Ok(t) = LogicDataTables::load_from_dir(&root) {
LogicDataTables::install(Arc::new(t));
}
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)],
[None, None],
1,
)
.expect("battle");
let mut session = game_service::battle_session::BattleSession::new(mode, Vec::new());
session.advance_to(25);
let out = session.take_outbound();
let heartbeats: Vec<&service_rpc::WireMessage> = out
.iter()
.filter(|m| m.message_type == 21902)
.collect();
assert_eq!(heartbeats.len(), 2, "expected a heartbeat at ticks 10 and 20");
let mut reader = titan::ByteStreamReader::new(&heartbeats[0].payload);
let turn = reader.read_vint().unwrap();
let checksum = reader.read_vint().unwrap();
assert_eq!(turn, 1);
assert_eq!(Some(checksum), session.checksum_at(10));
}

View file

@ -14,6 +14,7 @@ mod own_home_data;
mod battle_event;
mod request_sector_state;
mod sector_command;
mod sector_heartbeat;
mod sector_state;
mod server_error;
mod start_mission;
@ -38,6 +39,7 @@ pub use own_home_data::OwnHomeDataMessage;
pub use battle_event::{BattleEventMessage, SendBattleEventMessage};
pub use request_sector_state::RequestSectorStateMessage;
pub use sector_command::SectorCommandMessage;
pub use sector_heartbeat::SectorHeartbeatMessage;
pub use sector_state::SectorStateMessage;
pub use server_error::ServerErrorMessage;
pub use start_mission::StartMissionMessage;
@ -56,6 +58,7 @@ pub mod message_type {
pub const HOME_LOGIC_STOPPED: u16 = 14105;
pub const CANCEL_MATCHMAKE: u16 = 14107;
pub const REQUEST_SECTOR_STATE: u16 = 12903;
pub const SECTOR_HEARTBEAT: u16 = 21902;
pub const SECTOR_COMMAND: u16 = 12904;
pub const SEND_BATTLE_EVENT: u16 = 12951;
pub const BATTLE_EVENT: u16 = 22952;

View file

@ -0,0 +1,17 @@
use titan::{ByteStreamWriter, Message};
#[derive(Debug, Default, Clone, PartialEq, Eq, Message)]
#[message(id = 21902, direction = "server", name = "SectorHeartbeatMessage")]
#[codec(raw)]
pub struct SectorHeartbeatMessage {
pub body: Vec<u8>,
}
impl SectorHeartbeatMessage {
pub fn new(server_turn: i32, checksum: i32) -> Self {
let mut writer = ByteStreamWriter::new();
writer.write_vint(server_turn);
writer.write_vint(checksum);
Self {
body: writer.into_inner(),
}
}
}