Add replay system
This commit is contained in:
Generated
+1
@@ -3558,6 +3558,7 @@ dependencies = [
|
|||||||
"rhai",
|
"rhai",
|
||||||
"ron 0.12.2",
|
"ron 0.12.2",
|
||||||
"serde",
|
"serde",
|
||||||
|
"serde_json",
|
||||||
"strum",
|
"strum",
|
||||||
"strum_macros",
|
"strum_macros",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ bevy-inspector-egui = "0.37.0"
|
|||||||
bevy_egui = "0.40.0"
|
bevy_egui = "0.40.0"
|
||||||
bevy_ggrs = { git = "https://github.com/gschup/bevy_ggrs" }
|
bevy_ggrs = { git = "https://github.com/gschup/bevy_ggrs" }
|
||||||
serde = "1.0.229"
|
serde = "1.0.229"
|
||||||
|
serde_json = "1.0.145"
|
||||||
bincode = "1.3.3"
|
bincode = "1.3.3"
|
||||||
gltf = "1.4.1"
|
gltf = "1.4.1"
|
||||||
ron = "0.12.0"
|
ron = "0.12.0"
|
||||||
|
|||||||
+10
@@ -1,5 +1,6 @@
|
|||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
#[derive(Parser, Debug, Clone, Resource)]
|
#[derive(Parser, Debug, Clone, Resource)]
|
||||||
#[command(version, about, long_about = None)]
|
#[command(version, about, long_about = None)]
|
||||||
@@ -12,4 +13,13 @@ pub struct Args {
|
|||||||
pub check_distance: usize,
|
pub check_distance: usize,
|
||||||
#[clap(long, default_value_t = 1)]
|
#[clap(long, default_value_t = 1)]
|
||||||
pub players: usize,
|
pub players: usize,
|
||||||
|
/// Record a replay to this path.
|
||||||
|
#[clap(long, conflicts_with = "play_replay")]
|
||||||
|
pub record_replay: Option<PathBuf>,
|
||||||
|
/// Play a replay from this path.
|
||||||
|
#[clap(long, conflicts_with = "record_replay")]
|
||||||
|
pub play_replay: Option<PathBuf>,
|
||||||
|
/// Replay output format. JSON is useful for inspection; binary is compact.
|
||||||
|
#[clap(long, value_enum, default_value_t = crate::replay::ReplayFormat::Binary)]
|
||||||
|
pub replay_format: crate::replay::ReplayFormat,
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-1
@@ -11,7 +11,7 @@ use bevy_ggrs::RollbackFrameRate;
|
|||||||
use enum_cycling::EnumCycle;
|
use enum_cycling::EnumCycle;
|
||||||
use enum_cycling_derive::EnumCycle;
|
use enum_cycling_derive::EnumCycle;
|
||||||
|
|
||||||
use crate::player::Player;
|
use crate::{player::Player, replay::StopReplayRecording};
|
||||||
|
|
||||||
pub const PLAYER_DEBUG_ROW_COUNT: usize = 6;
|
pub const PLAYER_DEBUG_ROW_COUNT: usize = 6;
|
||||||
|
|
||||||
@@ -118,6 +118,9 @@ enum DebugFlag {
|
|||||||
|
|
||||||
#[derive(Clone, Copy)]
|
#[derive(Clone, Copy)]
|
||||||
enum DebugMenuEntry {
|
enum DebugMenuEntry {
|
||||||
|
StopRecording {
|
||||||
|
label: &'static str,
|
||||||
|
},
|
||||||
Toggle {
|
Toggle {
|
||||||
label: &'static str,
|
label: &'static str,
|
||||||
flag: DebugFlag,
|
flag: DebugFlag,
|
||||||
@@ -189,6 +192,9 @@ const RENDERING_MENU: &[DebugMenuEntry] = &[
|
|||||||
];
|
];
|
||||||
|
|
||||||
const ROOT_MENU: &[DebugMenuEntry] = &[
|
const ROOT_MENU: &[DebugMenuEntry] = &[
|
||||||
|
DebugMenuEntry::StopRecording {
|
||||||
|
label: "Stop replay recording",
|
||||||
|
},
|
||||||
DebugMenuEntry::Submenu {
|
DebugMenuEntry::Submenu {
|
||||||
label: "Fighter",
|
label: "Fighter",
|
||||||
entries: FIGHTER_MENU,
|
entries: FIGHTER_MENU,
|
||||||
@@ -446,6 +452,7 @@ fn handle_palette_input(
|
|||||||
DebugMenuPage::Static(entries) => {
|
DebugMenuPage::Static(entries) => {
|
||||||
if let Some(entry) = entries.get(settings.selected()) {
|
if let Some(entry) = entries.get(settings.selected()) {
|
||||||
match entry {
|
match entry {
|
||||||
|
DebugMenuEntry::StopRecording { .. } => {}
|
||||||
DebugMenuEntry::MotionSampling { .. } => settings.cycle_motion_sampling(),
|
DebugMenuEntry::MotionSampling { .. } => settings.cycle_motion_sampling(),
|
||||||
DebugMenuEntry::GameSpeed { .. } => {
|
DebugMenuEntry::GameSpeed { .. } => {
|
||||||
if direction > 0 {
|
if direction > 0 {
|
||||||
@@ -489,6 +496,9 @@ fn handle_palette_input(
|
|||||||
if gamepad.just_pressed(GamepadButton::South) {
|
if gamepad.just_pressed(GamepadButton::South) {
|
||||||
match page {
|
match page {
|
||||||
DebugMenuPage::Static(entries) if page_len != 0 => match entries[settings.selected()] {
|
DebugMenuPage::Static(entries) if page_len != 0 => match entries[settings.selected()] {
|
||||||
|
DebugMenuEntry::StopRecording { .. } => {
|
||||||
|
commands.insert_resource(StopReplayRecording);
|
||||||
|
}
|
||||||
DebugMenuEntry::Toggle { flag, .. } => {
|
DebugMenuEntry::Toggle { flag, .. } => {
|
||||||
settings.toggle_flag(flag);
|
settings.toggle_flag(flag);
|
||||||
commands.queue(SaveSettingsDeferred(Duration::from_secs_f32(0.1)));
|
commands.queue(SaveSettingsDeferred(Duration::from_secs_f32(0.1)));
|
||||||
@@ -550,6 +560,9 @@ fn draw_palette(
|
|||||||
for (index, entry) in entries.iter().enumerate() {
|
for (index, entry) in entries.iter().enumerate() {
|
||||||
let marker = if index == selected { ">" } else { " " };
|
let marker = if index == selected { ">" } else { " " };
|
||||||
match entry {
|
match entry {
|
||||||
|
DebugMenuEntry::StopRecording { label } => {
|
||||||
|
ui.label(format!("{marker} {label}"));
|
||||||
|
}
|
||||||
DebugMenuEntry::Toggle { label, flag } => {
|
DebugMenuEntry::Toggle { label, flag } => {
|
||||||
let state = if settings.flag(*flag) { "on" } else { "off" };
|
let state = if settings.flag(*flag) { "on" } else { "off" };
|
||||||
ui.label(format!("{marker} {label}: {state}"));
|
ui.label(format!("{marker} {label}: {state}"));
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ pub mod match_loading;
|
|||||||
mod math;
|
mod math;
|
||||||
pub mod netcode;
|
pub mod netcode;
|
||||||
pub mod player;
|
pub mod player;
|
||||||
|
pub mod replay;
|
||||||
pub mod schedule;
|
pub mod schedule;
|
||||||
pub mod scripting;
|
pub mod scripting;
|
||||||
pub mod stage;
|
pub mod stage;
|
||||||
@@ -84,6 +85,7 @@ impl Plugin for GamePlugin {
|
|||||||
netcode::FighterNetcodePlugin::default(),
|
netcode::FighterNetcodePlugin::default(),
|
||||||
GameplaySchedulePlugin,
|
GameplaySchedulePlugin,
|
||||||
input::FighterInputPlugin,
|
input::FighterInputPlugin,
|
||||||
|
replay::ReplayPlugin,
|
||||||
debug_tools::DebugToolsPlugin,
|
debug_tools::DebugToolsPlugin,
|
||||||
fighter::FighterPlugin,
|
fighter::FighterPlugin,
|
||||||
fighter::baked_animation::BakedAnimationPlugin,
|
fighter::baked_animation::BakedAnimationPlugin,
|
||||||
|
|||||||
+23
-1
@@ -5,6 +5,7 @@ use std::collections::HashMap;
|
|||||||
use bevy::{asset::UntypedHandle, gltf::Gltf, prelude::*};
|
use bevy::{asset::UntypedHandle, gltf::Gltf, prelude::*};
|
||||||
use bevy_asset_loader::prelude::*;
|
use bevy_asset_loader::prelude::*;
|
||||||
use bevy_ggrs::prelude::Session;
|
use bevy_ggrs::prelude::Session;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
AppState,
|
AppState,
|
||||||
@@ -27,7 +28,7 @@ use crate::{
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct MatchPlayer {
|
pub struct MatchPlayer {
|
||||||
pub handle: usize,
|
pub handle: usize,
|
||||||
pub fighter: FighterId,
|
pub fighter: FighterId,
|
||||||
@@ -146,7 +147,28 @@ pub fn initiate_default_match(
|
|||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
args: Res<Args>,
|
args: Res<Args>,
|
||||||
mut next_state: ResMut<NextState<AppState>>,
|
mut next_state: ResMut<NextState<AppState>>,
|
||||||
|
completed: Option<Res<crate::replay::ReplayComplete>>,
|
||||||
) {
|
) {
|
||||||
|
if completed.is_some() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Some(path) = &args.play_replay {
|
||||||
|
match crate::replay::load(path) {
|
||||||
|
Ok(replay) => {
|
||||||
|
let request = PendingMatch {
|
||||||
|
stage: replay.match_config.stage,
|
||||||
|
players: replay.match_config.players.clone(),
|
||||||
|
};
|
||||||
|
commands.insert_resource(crate::replay::LoadedReplay(replay));
|
||||||
|
initiate_match(&mut commands, &mut next_state, request);
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
error!("Replay load failed: {error}");
|
||||||
|
next_state.set(AppState::MatchLoadFailed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
let players = (0..args.players)
|
let players = (0..args.players)
|
||||||
.map(|handle| MatchPlayer {
|
.map(|handle| MatchPlayer {
|
||||||
handle,
|
handle,
|
||||||
|
|||||||
+288
@@ -0,0 +1,288 @@
|
|||||||
|
//! Deterministic input recording and playback.
|
||||||
|
|
||||||
|
use std::{collections::BTreeMap, fs, path::{Path, PathBuf}};
|
||||||
|
|
||||||
|
use bevy::prelude::*;
|
||||||
|
use bevy_ggrs::{prelude::*, PlayerInputs, RollbackFrameCount, SaveWorld};
|
||||||
|
use clap::ValueEnum;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::{args::Args, input::FighterInputFrame, match_loading::{MatchPlayer, PendingMatch}, netcode::GGRSCfg, schedule::GameplaySet, AppState};
|
||||||
|
|
||||||
|
const MAGIC: &[u8; 8] = b"SPRPLY01";
|
||||||
|
pub const REPLAY_VERSION: u32 = 1;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, ValueEnum)]
|
||||||
|
pub enum ReplayFormat {
|
||||||
|
#[default]
|
||||||
|
Binary,
|
||||||
|
Json,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct Replay {
|
||||||
|
pub replay_version: u32,
|
||||||
|
pub game_version: String,
|
||||||
|
pub match_config: ReplayMatch,
|
||||||
|
pub frames: Vec<ReplayFrame>,
|
||||||
|
pub checksums: Vec<ReplayChecksum>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct ReplayMatch {
|
||||||
|
pub stage: crate::stage::manifest::StageId,
|
||||||
|
pub players: Vec<MatchPlayer>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct ReplayFrame {
|
||||||
|
pub frame: i32,
|
||||||
|
pub inputs: Vec<FighterInputFrame>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct ReplayChecksum {
|
||||||
|
pub frame: i32,
|
||||||
|
pub checksum: u128,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Resource)]
|
||||||
|
pub struct LoadedReplay(pub Replay);
|
||||||
|
|
||||||
|
#[derive(Resource)]
|
||||||
|
pub struct ReplayComplete;
|
||||||
|
|
||||||
|
#[derive(Resource, Default)]
|
||||||
|
pub struct StopReplayRecording;
|
||||||
|
|
||||||
|
enum ReplayMode {
|
||||||
|
Record { path: PathBuf, format: ReplayFormat },
|
||||||
|
Playback { replay: Replay, mismatch: bool },
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Resource)]
|
||||||
|
struct ReplayRuntime {
|
||||||
|
mode: ReplayMode,
|
||||||
|
frames: BTreeMap<i32, Vec<FighterInputFrame>>,
|
||||||
|
checksums: BTreeMap<i32, u128>,
|
||||||
|
finished: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ReplayPlugin;
|
||||||
|
|
||||||
|
impl Plugin for ReplayPlugin {
|
||||||
|
fn build(&self, app: &mut App) {
|
||||||
|
app.add_systems(OnEnter(AppState::InMatch), start_replay)
|
||||||
|
.add_systems(
|
||||||
|
GgrsSchedule,
|
||||||
|
replay_inputs
|
||||||
|
.in_set(GameplaySet::Input)
|
||||||
|
.before(crate::input::buffer::postprocess_input),
|
||||||
|
)
|
||||||
|
.add_systems(SaveWorld, record_checksum.after(SaveWorldSystems::Checksum).before(SaveWorldSystems::Snapshot))
|
||||||
|
.add_systems(OnExit(AppState::InMatch), finish_recording)
|
||||||
|
.add_systems(Update, stop_recording.run_if(in_state(AppState::InMatch)))
|
||||||
|
.add_systems(Update, finish_playback.run_if(in_state(AppState::InMatch)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load(path: &Path) -> Result<Replay, String> {
|
||||||
|
let bytes = fs::read(path).map_err(|error| format!("cannot read replay {}: {error}", path.display()))?;
|
||||||
|
let replay = if bytes.starts_with(MAGIC) {
|
||||||
|
if bytes.len() < MAGIC.len() + 4 {
|
||||||
|
return Err("replay binary header is truncated".into());
|
||||||
|
}
|
||||||
|
let version = u32::from_le_bytes(bytes[8..12].try_into().unwrap());
|
||||||
|
if version != REPLAY_VERSION {
|
||||||
|
return Err(format!("unsupported replay version {version}"));
|
||||||
|
}
|
||||||
|
bincode::deserialize(&bytes[12..]).map_err(|error| format!("invalid binary replay: {error}"))?
|
||||||
|
} else {
|
||||||
|
serde_json::from_slice(&bytes).map_err(|error| format!("invalid JSON replay: {error}"))?
|
||||||
|
};
|
||||||
|
validate(&replay)?;
|
||||||
|
Ok(replay)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save(path: &Path, format: ReplayFormat, replay: &Replay) -> Result<(), String> {
|
||||||
|
validate(replay)?;
|
||||||
|
let bytes = match format {
|
||||||
|
ReplayFormat::Binary => {
|
||||||
|
let mut bytes = MAGIC.to_vec();
|
||||||
|
bytes.extend(REPLAY_VERSION.to_le_bytes());
|
||||||
|
bytes.extend(bincode::serialize(replay).map_err(|error| format!("cannot encode replay: {error}"))?);
|
||||||
|
bytes
|
||||||
|
}
|
||||||
|
ReplayFormat::Json => serde_json::to_vec_pretty(replay).map_err(|error| format!("cannot encode replay JSON: {error}"))?,
|
||||||
|
};
|
||||||
|
let temporary = path.with_extension("replay.tmp");
|
||||||
|
fs::write(&temporary, bytes).map_err(|error| format!("cannot write replay: {error}"))?;
|
||||||
|
fs::rename(&temporary, path).map_err(|error| format!("cannot finalize replay: {error}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate(replay: &Replay) -> Result<(), String> {
|
||||||
|
if replay.replay_version != REPLAY_VERSION {
|
||||||
|
return Err(format!("unsupported replay version {}", replay.replay_version));
|
||||||
|
}
|
||||||
|
if replay.players_are_invalid() {
|
||||||
|
return Err("replay player handles must cover 0..player_count".into());
|
||||||
|
}
|
||||||
|
if replay.frames.is_empty() {
|
||||||
|
return Err("replay must contain at least one frame".into());
|
||||||
|
}
|
||||||
|
if replay.frames.windows(2).any(|frames| frames[0].frame >= frames[1].frame) {
|
||||||
|
return Err("replay frames must be strictly ordered".into());
|
||||||
|
}
|
||||||
|
if replay.frames.iter().any(|frame| frame.inputs.len() != replay.match_config.players.len()) {
|
||||||
|
return Err("replay frame input count does not match player count".into());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Replay {
|
||||||
|
fn players_are_invalid(&self) -> bool {
|
||||||
|
let mut handles: Vec<_> = self.match_config.players.iter().map(|player| player.handle).collect();
|
||||||
|
handles.sort_unstable();
|
||||||
|
handles != (0..handles.len()).collect::<Vec<_>>()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start_replay(mut commands: Commands, args: Res<Args>, loaded: Option<Res<LoadedReplay>>) {
|
||||||
|
let mode = if let Some(replay) = loaded {
|
||||||
|
ReplayMode::Playback { replay: replay.0.clone(), mismatch: false }
|
||||||
|
} else if let Some(path) = args.record_replay.clone() {
|
||||||
|
ReplayMode::Record { path, format: args.replay_format }
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
commands.insert_resource(ReplayRuntime { mode, frames: BTreeMap::new(), checksums: BTreeMap::new(), finished: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
fn replay_inputs(mut inputs: ResMut<PlayerInputs<GGRSCfg>>, frame: Res<RollbackFrameCount>, runtime: Option<ResMut<ReplayRuntime>>) {
|
||||||
|
let Some(mut runtime) = runtime else { return };
|
||||||
|
match &mut runtime.mode {
|
||||||
|
ReplayMode::Record { .. } => {
|
||||||
|
runtime.frames.insert(frame.0, inputs.iter().map(|(input, _)| *input).collect());
|
||||||
|
}
|
||||||
|
ReplayMode::Playback { replay, mismatch } if !*mismatch => {
|
||||||
|
let Some(recorded) = replay.frames.iter().find(|recorded| recorded.frame == frame.0) else {
|
||||||
|
error!("Replay has no input for frame {}", frame.0);
|
||||||
|
*mismatch = true;
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
for ((input, _), recorded) in inputs.iter_mut().zip(&recorded.inputs) {
|
||||||
|
*input = *recorded;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ReplayMode::Playback { .. } => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn record_checksum(frame: Res<RollbackFrameCount>, checksum: Res<Checksum>, runtime: Option<ResMut<ReplayRuntime>>) {
|
||||||
|
let Some(mut runtime) = runtime else { return };
|
||||||
|
if matches!(&runtime.mode, ReplayMode::Record { .. }) {
|
||||||
|
runtime.checksums.insert(frame.0, checksum.0);
|
||||||
|
} else if let ReplayMode::Playback { replay, mismatch } = &mut runtime.mode {
|
||||||
|
if let Some(expected) = replay.checksums.iter().find(|value| value.frame == frame.0).map(|value| value.checksum) {
|
||||||
|
if expected != checksum.0 {
|
||||||
|
error!("Replay desync at frame {}: expected {:X}, got {:X}", frame.0, expected, checksum.0);
|
||||||
|
*mismatch = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stop_recording(
|
||||||
|
mut commands: Commands,
|
||||||
|
stop_request: Option<Res<StopReplayRecording>>,
|
||||||
|
runtime: Option<ResMut<ReplayRuntime>>,
|
||||||
|
request: Option<Res<PendingMatch>>,
|
||||||
|
) {
|
||||||
|
if stop_request.is_none() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
commands.remove_resource::<StopReplayRecording>();
|
||||||
|
finish_recording_inner(&mut commands, runtime, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn finish_recording(mut commands: Commands, runtime: Option<ResMut<ReplayRuntime>>, request: Option<Res<PendingMatch>>) {
|
||||||
|
finish_recording_inner(&mut commands, runtime, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn finish_recording_inner(
|
||||||
|
commands: &mut Commands,
|
||||||
|
runtime: Option<ResMut<ReplayRuntime>>,
|
||||||
|
request: Option<Res<PendingMatch>>,
|
||||||
|
) {
|
||||||
|
let Some(mut runtime) = runtime else { return };
|
||||||
|
let ReplayMode::Record { path, format } = &runtime.mode else { return };
|
||||||
|
if runtime.finished || runtime.frames.is_empty() { return; }
|
||||||
|
let Some(request) = request else { return };
|
||||||
|
let path = path.clone();
|
||||||
|
let format = *format;
|
||||||
|
let replay = Replay {
|
||||||
|
replay_version: REPLAY_VERSION,
|
||||||
|
game_version: env!("CARGO_PKG_VERSION").into(),
|
||||||
|
match_config: ReplayMatch { stage: request.stage, players: request.players.clone() },
|
||||||
|
frames: runtime.frames.iter().map(|(&frame, inputs)| ReplayFrame { frame, inputs: inputs.clone() }).collect(),
|
||||||
|
checksums: runtime.checksums.iter().map(|(&frame, &checksum)| ReplayChecksum { frame, checksum }).collect(),
|
||||||
|
};
|
||||||
|
if let Err(error) = save(&path, format, &replay) { error!("Failed to save replay: {error}"); }
|
||||||
|
runtime.finished = true;
|
||||||
|
commands.remove_resource::<ReplayRuntime>();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn finish_playback(mut commands: Commands, mut next_state: ResMut<NextState<AppState>>, frame: Res<RollbackFrameCount>, runtime: Option<Res<ReplayRuntime>>) {
|
||||||
|
let Some(runtime) = runtime else { return };
|
||||||
|
let ReplayMode::Playback { replay, mismatch } = &runtime.mode else { return };
|
||||||
|
if *mismatch || replay.frames.last().is_some_and(|last| frame.0 >= last.frame) {
|
||||||
|
commands.remove_resource::<ReplayRuntime>();
|
||||||
|
commands.remove_resource::<LoadedReplay>();
|
||||||
|
commands.insert_resource(ReplayComplete);
|
||||||
|
next_state.set(AppState::Idle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::{fighter::FighterId, math::{int::FGi32, vec::FGVec2}, match_loading::MatchPlayer, stage::manifest::StageId};
|
||||||
|
|
||||||
|
fn sample() -> Replay {
|
||||||
|
Replay {
|
||||||
|
replay_version: REPLAY_VERSION,
|
||||||
|
game_version: "test".into(),
|
||||||
|
match_config: ReplayMatch {
|
||||||
|
stage: StageId::TestStage,
|
||||||
|
players: vec![MatchPlayer { handle: 0, fighter: FighterId::TestFighter }],
|
||||||
|
},
|
||||||
|
frames: vec![ReplayFrame {
|
||||||
|
frame: 1,
|
||||||
|
inputs: vec![FighterInputFrame { movement: FGVec2::new(FGi32::from_num(1), FGi32::ZERO), ..default() }],
|
||||||
|
}],
|
||||||
|
checksums: vec![ReplayChecksum { frame: 1, checksum: 42 }],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn binary_and_json_round_trip() {
|
||||||
|
let replay = sample();
|
||||||
|
let directory = std::env::temp_dir();
|
||||||
|
let binary = directory.join(format!("game-test-replay-{}.bin", std::process::id()));
|
||||||
|
let json = directory.join(format!("game-test-replay-{}.json", std::process::id()));
|
||||||
|
|
||||||
|
save(&binary, ReplayFormat::Binary, &replay).unwrap();
|
||||||
|
save(&json, ReplayFormat::Json, &replay).unwrap();
|
||||||
|
assert_eq!(load(&binary).unwrap(), replay);
|
||||||
|
assert_eq!(load(&json).unwrap(), replay);
|
||||||
|
let _ = fs::remove_file(binary);
|
||||||
|
let _ = fs::remove_file(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn malformed_frame_input_count_is_rejected() {
|
||||||
|
let mut replay = sample();
|
||||||
|
replay.frames[0].inputs.clear();
|
||||||
|
assert!(validate(&replay).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user