Skip to content

Commit e499999

Browse files
authored
Replay integration (#123)
## Description This PR delivers a full replay + telemetry feature set and hardens docs/wiki synchronization. It solves two core gaps: 1. No first-class way to record, serialize, and replay real P2P matches for deterministic debugging and validation. 2. Wiki mirror generation and SYNC-header validation were easier to drift or fail with less-actionable diagnostics. ### What changed - Added replay domain types and serialization: - `Replay<I>` and `ReplayMetadata` - `Replay::to_bytes()`, `Replay::from_bytes()`, `Replay::validate()`, `Replay::total_frames()` - Added replay playback session: - `ReplaySession<T>` implementing `Session<T>` - `ReplaySession::new()` and `ReplaySession::new_with_validation()` - Validation mode emits `SaveGameState` before `AdvanceFrame` and compares checksums frame-by-frame - Added replay recording support to P2P sessions: - `SessionBuilder::with_recording(bool)` - `P2PSession::is_recording()`, `P2PSession::into_replay()`, `P2PSession::take_replay()` - Recording now captures confirmed inputs and recorded checksums - Added session telemetry API and built-in collector: - `SessionTelemetry` trait - `TelemetryEvent` enum (`Rollback`, `PredictionMiss`, `NetworkStatsUpdate`, `FrameAdvance`) - `CollectingTelemetry` - `SessionBuilder::with_telemetry(...)` - P2P telemetry emission integrated in rollback, prediction miss, network stats polling, and frame advance paths - Added sync-layer helper used for telemetry: - `players_with_incorrect_predictions(...)` - Expanded public exports and docs: - Re-exports for replay/telemetry in `lib.rs` and prelude - New docs: replay and telemetry guides - Added MkDocs nav entries and wiki mirror pages/sidebar links - Hardened docs/wiki synchronization pipeline: - Added pre-commit `sync-wiki` hook before wiki consistency checks - `sync-wiki.py` now: - injects/normalizes reciprocal wiki SYNC headers - validates sidebar coverage before writing files - normalizes generated markdown EOF/newline format deterministically - `check-sync-headers.py` now reports case-mismatch hints and remediation guidance - Added/expanded script tests for sync behavior and diagnostics - Added `.gitignore` entries for local pre-commit/pre-push log artifacts ### Breaking change - Added `FortressEvent::ReplayDesync { frame, expected_checksum, actual_checksum }`. - Because `FortressEvent` is not `#[non_exhaustive]`, downstream exhaustive `match` statements must add a branch for `ReplayDesync`. ## Type of Change - [x] 🐛 Bug fix (non-breaking change that fixes an issue) - [x] ✨ New feature (non-breaking change that adds functionality) - [x] 💥 Breaking change (fix or feature that would cause existing functionality to change) - [x] 📚 Documentation (changes to documentation only) - [ ] ♻️ Refactor (code change that neither fixes a bug nor adds a feature) - [x] 🧪 Test (adding or updating tests) - [x] 🔧 CI/Build (changes to CI configuration or build process) ## Checklist ### Required - [ ] I have read the [CONTRIBUTING guide](../docs/contributing.md) - [ ] I have followed the **zero-panic policy**: - No `unwrap()` in production code - No `expect()` in production code - No `panic!()` or `todo!()` - All fallible operations return `Result` - [x] I have added tests that prove my fix is effective or my feature works - [ ] I have run `cargo fmt && cargo clippy --all-targets --features tokio,json` with no warnings - [ ] I have run `cargo nextest run` and all tests pass ### If Applicable - [x] I have updated the documentation accordingly - [x] I have added an entry to `CHANGELOG.md` for user-facing changes - [ ] I have updated relevant examples in the `examples/` directory - [ ] My changes generate no new compiler warnings ## Testing **Tests added/modified:** - Added replay model tests in `src/replay.rs` (serialization roundtrip, validation invariants, metadata/display, recorder behavior) - Added replay session tests in `src/sessions/replay_session.rs` (playback flow, completion behavior, validation mode, checksum mismatch event emission) - Added event display coverage for `ReplayDesync` in `src/lib.rs` - Added builder/session tests in `src/sessions/builder.rs` and `src/sessions/p2p_session.rs` for replay-session creation and recording API behavior - Added new script tests in `scripts/tests/test_check_sync_headers.py` - Expanded `scripts/tests/test_sync_wiki.py` coverage for SYNC header generation, sidebar coverage validation, idempotent output normalization, and fail-before-write behavior **Manual testing performed:** - Reviewed API docs and migration impact for new replay + telemetry APIs - Verified docs navigation additions and wiki mirror wiring in this branch - Full local command status (`cargo fmt`, `cargo clippy`, `cargo nextest`) intentionally left unchecked in checklist for final author confirmation ## Related Issues - N/A (no issue links provided in branch metadata) --- <!-- Thank you for contributing to Fortress Rollback! -->
1 parent afb1043 commit e499999

69 files changed

Lines changed: 6634 additions & 154 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/ci-docs.yml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,19 @@ jobs:
247247
path: code-fence-output.txt
248248
retention-days: 7
249249

250+
# ============================================================================
251+
# DOC CLAIMS - Check doc comments for misleading claims
252+
# ============================================================================
253+
doc-claims:
254+
name: Doc Claims Check
255+
runs-on: ubuntu-latest
256+
257+
steps:
258+
- uses: actions/checkout@v6
259+
260+
- name: Check doc comment accuracy
261+
run: ./scripts/ci/check-doc-claims.sh
262+
250263
link-check:
251264
name: Link Check
252265
runs-on: ubuntu-latest

.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,3 +62,8 @@ package.json
6262
node_modules/
6363
*.pyc
6464
*.pyo
65+
66+
pre-commit.txt
67+
pre-commit.log
68+
pre-push.txt
69+
pre-push.log

.llm/context.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,18 +216,23 @@ For protocol tests that poll in loops (`poll_remote_clients()` / protocol `poll(
216216

217217
**Exclude:** internal refactoring, test improvements, doc-only changes, CI/tooling, lint fixes.
218218

219+
**Unreleased code rule:** Never add separate "Fixed" or "Changed" entries for code that has not yet been released. Fixes to unreleased features should be folded into the existing "Added" entry describing that feature. The changelog should describe the final shipped state, not intermediate development history.
220+
219221
## Mandatory Linting
220222

221223
- **After Rust changes:** `cargo fmt && cargo clippy --all-targets --features tokio,json` (or `cargo c`)
222224
- **After workflow changes:** `actionlint` (no exceptions)
223225
- **After doc changes:** `cargo doc --no-deps`
224226
- **After markdown changes:** `npx markdownlint 'file.md' --config .markdownlint.json --fix`
227+
- **After shell-script changes:** `bash scripts/ci/check-shell-portability.sh`
225228
- **After `.llm/` changes:** All `.md` files under `.llm/` must be **300 lines or fewer** (enforced by pre-commit hook `llm-line-limit`)
226229
- **Link validation:** `./scripts/docs/check-links.sh`
227230
- **Spell check:** `typos`
228231
- **Vale (advisory):** `vale docs/` -- checks prose quality, non-blocking in CI
229232
- **Full pre-commit:** `cargo fmt && cargo clippy --all-targets --features tokio,json && cargo nextest run --no-capture`
230233

234+
Shell regex portability rule: avoid PCRE-style escapes in `grep -E`/`sed -E` (`\b`, `\s`, `\w`, etc.). Use POSIX-safe classes like `[[:space:]]`, `[[:alnum:]_]`, and token boundaries `(^|[^[:alnum:]_])word([^[:alnum:]_]|$)`.
235+
231236
## Skill Code Examples
232237

233238
Code examples in `.llm/skills/` must follow zero-panic rules with these exceptions:

.llm/skills/ci-cd-tooling/wiki-sync.md

Lines changed: 39 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,12 @@ Fix content in `docs/`, then re-sync.
1515

1616
## Link Format Rules
1717

18-
| Context | Format | Example |
19-
|---------|--------|---------|
20-
| `docs/` files | Lowercase with `.md` | `[Guide](user-guide.md)` |
21-
| `wiki/` files | PascalCase, no extension | `[Guide](User-Guide)` |
22-
| `wiki/_Sidebar.md` | PascalCase, no extension | `[User Guide](User-Guide)` |
23-
| External (non-docs) | Full GitHub URL | `[Code](https://github.com/.../blob/main/src/lib.rs)` |
18+
| Context | Format | Example |
19+
| ------------------- | ------------------------ | ----------------------------------------------------- |
20+
| `docs/` files | Lowercase with `.md` | `[Guide](user-guide.md)` |
21+
| `wiki/` files | PascalCase, no extension | `[Guide](User-Guide)` |
22+
| `wiki/_Sidebar.md` | PascalCase, no extension | `[User Guide](User-Guide)` |
23+
| External (non-docs) | Full GitHub URL | `[Code](https://github.com/.../blob/main/src/lib.rs)` |
2424

2525
Use standard markdown `[Text](Page)` syntax in sidebar -- NOT wiki-link `[[Page|Text]]` syntax (has URL generation bugs).
2626

@@ -57,6 +57,14 @@ docs/*.md --> sync-wiki.py
5757
+-- Add SYNC comment header
5858
+-- Generate _Sidebar.md
5959
--> wiki/*.md
60+
61+
Pre-commit enforcement now runs this sequence for docs/wiki changes:
62+
63+
1. `sync-wiki` regenerates `wiki/*.md` from `docs/`
64+
2. `wiki-consistency` validates links/sidebar/mapping integrity
65+
3. `check-sync-headers` validates reciprocal `<!-- SYNC: ... -->` headers
66+
67+
This prevents committing stale or manually-edited wiki mirrors.
6068
```
6169

6270
## MkDocs Conversion Patterns
@@ -124,26 +132,37 @@ python3 scripts/docs/validate-wiki-output.py # Check rendering issues
124132
3. All wiki pages have sidebar entries
125133
4. No special characters in wiki-link display text
126134

135+
`sync-wiki.py` also validates that every `WIKI_STRUCTURE` page appears in
136+
the generated sidebar and fails fast if any mapped page is missing.
137+
138+
`sync-wiki.py` now enforces deterministic writer normalization for generated
139+
markdown: non-empty outputs end with exactly one LF newline (trailing
140+
whitespace/newlines are normalized). This prevents churn with
141+
`end-of-file-fixer` and keeps repeated sync runs idempotent.
142+
143+
Sidebar coverage validation runs before any wiki writes, so an invalid sidebar
144+
template fails early without leaving partial regenerated output.
145+
127146
### Common Errors
128147

129-
| Error | Fix |
130-
|-------|-----|
148+
| Error | Fix |
149+
| -------------------------------- | ---------------------------- |
131150
| Link points to non-existent page | Add file or fix sidebar link |
132-
| `docs/file.md` not mapped | Add to `WIKI_STRUCTURE` |
133-
| Wiki page has no sidebar entry | Add to `generate_sidebar()` |
134-
| Stale WIKI_STRUCTURE mapping | Remove entry |
151+
| `docs/file.md` not mapped | Add to `WIKI_STRUCTURE` |
152+
| Wiki page has no sidebar entry | Add to `generate_sidebar()` |
153+
| Stale WIKI_STRUCTURE mapping | Remove entry |
135154

136155
## Markdown Link Validation
137156

138157
### Relative Path Resolution
139158

140159
Links resolve from the directory containing the markdown file:
141160

142-
| From | To Root | Example |
143-
|------|---------|---------|
144-
| `docs/` | `../` | `[README](../README.md)` |
145-
| `.github/` | `../` | `[Context](../.llm/context.md)` |
146-
| `.llm/skills/<category>/` | `../../../` | `[README](../../../README.md)` |
161+
| From | To Root | Example |
162+
| ------------------------- | ----------- | ------------------------------- |
163+
| `docs/` | `../` | `[README](../README.md)` |
164+
| `.github/` | `../` | `[Context](../.llm/context.md)` |
165+
| `.llm/skills/<category>/` | `../../../` | `[README](../../../README.md)` |
147166

148167
### Heading Anchor Generation Rules
149168

@@ -153,11 +172,11 @@ Links resolve from the directory containing the markdown file:
153172
4. ` / ` becomes `--`
154173
5. `~` removed
155174

156-
| Heading | Anchor |
157-
|---------|--------|
158-
| `## Quick Start` | `#quick-start` |
175+
| Heading | Anchor |
176+
| ------------------------------------ | ------------------------------ |
177+
| `## Quick Start` | `#quick-start` |
159178
| `## LAN / Local Network (~20ms RTT)` | `#lan--local-network-20ms-rtt` |
160-
| `## Web / WASM Integration` | `#web--wasm-integration` |
179+
| `## Web / WASM Integration` | `#web--wasm-integration` |
161180

162181
### Pipe Escaping in Tables
163182

.pre-commit-config.yaml

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,20 @@ repos:
102102
pass_filenames: false
103103
files: '\.rs$'
104104

105+
- id: check-doc-claims
106+
name: check doc comment accuracy
107+
entry: bash scripts/ci/check-doc-claims.sh
108+
language: system
109+
pass_filenames: false
110+
files: '\.rs$'
111+
112+
- id: check-derive-bounds
113+
name: check derive bounds
114+
entry: bash scripts/ci/check-derive-bounds.sh
115+
language: system
116+
pass_filenames: false
117+
files: '\.rs$'
118+
105119
# ══════════════════════════════════════════════════════════════════════
106120
# Documentation checks
107121
# ══════════════════════════════════════════════════════════════════════
@@ -134,6 +148,13 @@ repos:
134148
pass_filenames: false
135149
files: '\.md$'
136150

151+
- id: sync-wiki
152+
name: sync wiki mirrors
153+
entry: python scripts/docs/sync-wiki.py
154+
language: python
155+
pass_filenames: false
156+
files: '^(docs/.*\.md|wiki/.*\.md|scripts/docs/sync-wiki\.py)$'
157+
137158
- id: wiki-consistency
138159
name: wiki consistency check
139160
entry: python scripts/docs/check-wiki-consistency.py
@@ -148,6 +169,13 @@ repos:
148169
files: '^(docs/.*\.md|wiki/.*\.md)$'
149170
pass_filenames: false
150171

172+
- id: changelog-unreleased-rule
173+
name: changelog unreleased code rule
174+
entry: python scripts/hooks/check-changelog-unreleased.py
175+
language: python
176+
pass_filenames: false
177+
files: '^CHANGELOG\.md$'
178+
151179
- id: llm-line-limit
152180
name: check .llm file line limit (300)
153181
entry: python scripts/hooks/check-llm-line-limit.py
@@ -189,6 +217,13 @@ repos:
189217
files: '^scripts/(hooks/|build/|docs/|verification/|ci/)?[a-z][^/]*\.py$'
190218
pass_filenames: true
191219

220+
- id: check-shell-portability
221+
name: check shell portability
222+
entry: bash scripts/ci/check-shell-portability.sh
223+
language: system
224+
files: '\.sh$'
225+
pass_filenames: false
226+
192227
# ══════════════════════════════════════════════════════════════════════
193228
# CI validation (skip gracefully if tools not installed)
194229
# ══════════════════════════════════════════════════════════════════════

.typos.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@ resimulates = "resimulates"
1818
resimulating = "resimulating"
1919

2020
# Mathematical/algorithmic variable names
21-
ba = "ba" # b minus a
21+
ba = "ba" # b minus a
22+
ser = "ser" # Mermaid state diagram alias in docs/replay.md and wiki/Replay.md
2223

2324
# Technical terms
2425
clonable = "clonable"

CHANGELOG.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1414
1515
## [Unreleased]
1616

17+
## [0.8.0]
18+
19+
### Changed
20+
21+
- **Breaking:** `FortressEvent::ReplayDesync` — new variant added. Since `FortressEvent` is not `#[non_exhaustive]`, exhaustive matches must now handle this variant.
22+
- **Breaking:** `InvalidFrameReason::ReplayExhausted` — new variant added. Since `InvalidFrameReason` is not `#[non_exhaustive]`, exhaustive matches must now handle this variant.
23+
- **Breaking:** `Config::Input` now requires `Eq` in addition to `PartialEq`. Types used as `Config::Input` must derive or implement `Eq`. This ensures reflexive equality, which is a correctness requirement for deterministic rollback — non-reflexive types (e.g., floats with `NaN`) would cause phantom prediction misses and unnecessary rollbacks. All integer and struct-of-integer types already implement `Eq`; add `#[derive(Eq)]` to any custom input types that are missing it.
24+
25+
### Added
26+
27+
- `ReplaySession::new_with_validation()` constructor that enables checksum validation mode, emitting `SaveGameState` requests, comparing checksums against the replay recording, and flushing final-frame validation when `events()` is drained after completion
28+
- `ReplaySession::is_validating()` accessor to check if checksum validation mode is enabled
29+
- `SessionBuilder::start_replay_session_with_validation()` builder method for creating a validation-enabled replay session
30+
- `SessionTelemetry` trait for observing session performance events (rollbacks, prediction misses, frame advances, network stats)
31+
- `CollectingTelemetry` test helper that accumulates `TelemetryEvent` values for assertions
32+
- `TelemetryEvent` enum with `Rollback`, `PredictionMiss`, `NetworkStatsUpdate`, and `FrameAdvance` variants
33+
- `SessionBuilder::with_telemetry()` to attach a telemetry observer to P2P sessions
34+
- `Replay<I>` type for recorded match data with `to_bytes()` / `from_bytes()` serialization using deterministic bincode codec
35+
- `ReplayMetadata` type containing library version, player count, total frame count, and skipped frame count
36+
- `ReplaySession<T>` session type implementing `Session<T>` for deterministic replay playback
37+
- `SessionBuilder::with_recording(bool)` to enable input recording (including game state checksums) during P2P sessions
38+
- `SessionBuilder::start_replay_session(replay)` to create a replay playback session
39+
- `P2PSession::is_recording()` to check if replay recording is active
40+
- `P2PSession::into_replay()` to extract the recorded `Replay` after a session ends (consumes the session)
41+
- `P2PSession::take_replay()` to extract the recorded `Replay` without consuming the session (recording stops after extraction)
42+
- `Replay::validate()` to verify internal consistency of replay data
43+
- Re-exports `Replay`, `ReplayMetadata`, and `ReplaySession` in prelude
44+
1745
## [0.7.0]
1846

1947
### Added

Cargo.lock

Lines changed: 0 additions & 21 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -320,6 +320,12 @@ renamed_function_params = "warn" # Consistent parameter names across
320320
try_err = "warn" # Use ? instead of Err(e)?
321321
undocumented_unsafe_blocks = "warn" # Require SAFETY comments on unsafe
322322

323+
# cargo-shear: macroquad and z3 are optional deps behind feature flags
324+
# (graphical-examples and z3-verification). They cannot be dev-dependencies
325+
# because dev-deps cannot be feature-gated in Cargo.
326+
[package.metadata.cargo-shear]
327+
ignored = ["macroquad", "z3"]
328+
323329
[features]
324330
sync-send = []
325331
# Enable runtime invariant checking in release builds (for debugging production issues)
@@ -379,8 +385,6 @@ proptest = "1.11"
379385
serde_json = "1.0"
380386
# Benchmarking
381387
criterion = { version = "0.8", features = ["html_reports"] }
382-
# Additional testing utilities
383-
arbitrary = { version = "1.3", features = ["derive"] }
384388
# Macro utilities for data-driven tests
385389
pastey = "0.2"
386390

benches/p2p_session.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ use std::hint::black_box;
2525
use std::net::SocketAddr;
2626

2727
/// Simple test input type for benchmarking
28-
#[derive(Copy, Clone, PartialEq, Default, Serialize, Deserialize, Debug)]
28+
#[derive(Copy, Clone, PartialEq, Eq, Default, Serialize, Deserialize, Debug)]
2929
struct BenchInput {
3030
buttons: u8,
3131
stick_x: i8,

0 commit comments

Comments
 (0)