From 51c3be4c5b5e4d67b093f0f7aaafc96ed244e26d Mon Sep 17 00:00:00 2001 From: RobDavenport Date: Sat, 23 May 2026 16:26:11 +0900 Subject: [PATCH 01/15] Add production readiness validation plan --- .github/workflows/ci.yml | 99 + .gitignore | 4 +- AGENTS.md | 5 +- README.md | 4 +- crates/framesmith-fspack/src/view/event.rs | 2 +- crates/framesmith-fspack/src/view/hitbox.rs | 9 +- crates/framesmith-fspack/src/view/mod.rs | 6 +- crates/framesmith-runtime-wasm/src/lib.rs | 566 +++- .../tests/integration.rs | 13 +- crates/framesmith-runtime/src/cancel.rs | 72 +- .../framesmith-runtime/src/collision/mod.rs | 133 +- .../src/collision/shapes.rs | 78 +- crates/framesmith-runtime/src/frame.rs | 6 +- crates/framesmith-runtime/src/lib.rs | 20 +- crates/framesmith-runtime/src/resource.rs | 11 +- docs/README.md | 34 +- docs/architecture.md | 13 +- docs/character-authoring-guide.md | 25 +- docs/cli.md | 19 +- docs/combat-coverage.md | 56 + docs/data-formats.md | 46 +- docs/design.md | 6 +- docs/export-fidelity-contract.json | 102 + docs/export-fidelity-contract.md | 140 + docs/implementation-history.md | 33 + docs/mcp-server.md | 6 +- docs/movement-reference.md | 29 +- .../2026-01-31-framesmith-runtime-phase2.md | 714 ---- docs/plans/2026-01-31-framesmith-runtime.md | 1458 --------- docs/plans/2026-01-31-training-mode-design.md | 334 -- docs/plans/2026-02-01-global-states-design.md | 2916 ----------------- .../2026-02-01-state-tags-cancel-rules.md | 1685 ---------- ...-training-rendercore-unification-design.md | 158 - .../2026-02-01-variant-overlay-system.md | 1133 ------- ...2026-02-02-fspk-character-props-pushbox.md | 622 ---- .../2026-02-03-cancel-condition-bitfield.md | 691 ---- docs/plans/2026-02-03-fspk-refactor.md | 1383 -------- docs/production-gap-backlog.md | 297 ++ docs/production-handoff-decision.md | 72 + docs/production-readiness-plan.md | 443 +++ docs/release-runbook.md | 156 + docs/runtime-api.md | 65 +- docs/runtime-guide.md | 172 +- docs/schema-migration.md | 113 + docs/training-scenario-contract.md | 50 + docs/variant-editing-decision.md | 111 + docs/windows-installer-smoke-test.md | 53 + docs/zx-fspack.md | 17 +- package-lock.json | 705 ++-- package.json | 11 +- playwright.config.ts | 24 + schemas/rules.schema.json | 95 +- src-tauri/src/bin/framesmith.rs | 18 +- src-tauri/src/bin/mcp.rs | 7 +- src-tauri/src/codegen/fspk/builders.rs | 5 +- src-tauri/src/codegen/fspk/export.rs | 122 +- src-tauri/src/codegen/fspk/moves.rs | 35 +- src-tauri/src/codegen/fspk/packing.rs | 86 +- src-tauri/src/codegen/fspk/properties.rs | 42 +- src-tauri/src/codegen/fspk/sections.rs | 4 +- src-tauri/src/codegen/fspk/types.rs | 2 +- src-tauri/src/codegen/fspk/utils.rs | 8 +- src-tauri/src/codegen/fspk_format.rs | 8 +- src-tauri/src/codegen/json_blob.rs | 5 +- src-tauri/src/commands/character.rs | 40 +- src-tauri/src/commands/export.rs | 78 +- src-tauri/src/commands/mod.rs | 11 +- src-tauri/src/commands/project.rs | 20 +- src-tauri/src/globals/mod.rs | 74 +- src-tauri/src/lib.rs | 2 +- src-tauri/src/mcp/handlers.rs | 337 +- src-tauri/src/mcp/mod.rs | 2 +- src-tauri/src/rules/apply.rs | 38 +- src-tauri/src/rules/matchers.rs | 35 +- src-tauri/src/rules/registry.rs | 11 +- src-tauri/src/rules/validate.rs | 13 +- src-tauri/src/schema/mod.rs | 17 +- src-tauri/src/variant/mod.rs | 125 +- src-tauri/tests/docs_cli_examples.rs | 156 + src-tauri/tests/export_fidelity_contract.rs | 336 ++ src-tauri/tests/fspk_roundtrip.rs | 354 +- src-tauri/tests/mcp_commands_test.rs | 38 +- src-tauri/tests/pipeline_e2e.rs | 31 +- src-tauri/tests/production_docs.rs | 233 ++ src/lib/components/GlobalStateEditor.svelte | 8 +- .../components/training/DummySettings.svelte | 27 + src/lib/schemaFixture.test.ts | 41 + src/lib/stores/character.svelte.ts | 49 +- src/lib/training/InputBuffer.test.ts | 24 + src/lib/training/InputBuffer.ts | 30 +- src/lib/training/InputManager.test.ts | 2 + src/lib/training/TrainingSession.ts | 28 + src/lib/training/TrainingSync.test.ts | 25 +- src/lib/training/buildMoveList.test.ts | 24 + src/lib/training/buildMoveList.ts | 19 +- src/lib/training/cancelIntegration.test.ts | 61 +- src/lib/training/index.ts | 1 + src/lib/types.ts | 54 +- src/lib/utils.test.ts | 36 + src/lib/utils.ts | 19 +- src/lib/views/CancelGraph.svelte | 283 +- src/lib/views/CharacterOverview.svelte | 57 +- src/lib/views/FrameDataTable.svelte | 19 +- src/lib/views/StateEditor.svelte | 64 +- src/lib/views/TrainingMode.svelte | 93 +- src/lib/views/training/TrainingLoop.test.ts | 325 ++ src/lib/views/training/TrainingLoop.ts | 131 +- src/routes/training/DetachedTraining.svelte | 167 +- tests/e2e/editor-smoke.spec.ts | 353 ++ 109 files changed, 6917 insertions(+), 12331 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 docs/combat-coverage.md create mode 100644 docs/export-fidelity-contract.json create mode 100644 docs/export-fidelity-contract.md create mode 100644 docs/implementation-history.md delete mode 100644 docs/plans/2026-01-31-framesmith-runtime-phase2.md delete mode 100644 docs/plans/2026-01-31-framesmith-runtime.md delete mode 100644 docs/plans/2026-01-31-training-mode-design.md delete mode 100644 docs/plans/2026-02-01-global-states-design.md delete mode 100644 docs/plans/2026-02-01-state-tags-cancel-rules.md delete mode 100644 docs/plans/2026-02-01-training-rendercore-unification-design.md delete mode 100644 docs/plans/2026-02-01-variant-overlay-system.md delete mode 100644 docs/plans/2026-02-02-fspk-character-props-pushbox.md delete mode 100644 docs/plans/2026-02-03-cancel-condition-bitfield.md delete mode 100644 docs/plans/2026-02-03-fspk-refactor.md create mode 100644 docs/production-gap-backlog.md create mode 100644 docs/production-handoff-decision.md create mode 100644 docs/production-readiness-plan.md create mode 100644 docs/release-runbook.md create mode 100644 docs/schema-migration.md create mode 100644 docs/training-scenario-contract.md create mode 100644 docs/variant-editing-decision.md create mode 100644 docs/windows-installer-smoke-test.md create mode 100644 playwright.config.ts create mode 100644 src-tauri/tests/docs_cli_examples.rs create mode 100644 src-tauri/tests/export_fidelity_contract.rs create mode 100644 src-tauri/tests/production_docs.rs create mode 100644 src/lib/schemaFixture.test.ts create mode 100644 src/lib/utils.test.ts create mode 100644 src/lib/views/training/TrainingLoop.test.ts create mode 100644 tests/e2e/editor-smoke.spec.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..0368be8 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,99 @@ +name: CI + +on: + push: + pull_request: + +jobs: + windows: + name: Windows Checks + runs-on: windows-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: npm + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + with: + targets: wasm32-unknown-unknown + + - name: Install wasm-pack + uses: taiki-e/install-action@v2 + with: + tool: wasm-pack@0.10.3 + + - name: Install npm dependencies + run: npm ci + + - name: Dependency audit + run: npm audit + + - name: Install Playwright browser + run: npx playwright install chromium + + - name: TypeScript and Svelte check + run: npm run check + + - name: Frontend and logic tests + run: npm run test:run + + - name: Rebuild WASM package + run: npm run wasm:build + + - name: Refresh generated schemas + run: cargo run --manifest-path src-tauri/Cargo.toml --bin generate_schema + + - name: Verify generated files are current + run: git diff --exit-code -- schemas/rules.schema.json src/lib/wasm + + - name: Browser smoke tests + run: npm run test:e2e + + - name: Build web frontend + run: npm run build + + - name: Rust formatting + run: | + cargo fmt --check --manifest-path src-tauri/Cargo.toml + cargo fmt --check --manifest-path crates/framesmith-runtime/Cargo.toml + cargo fmt --check --manifest-path crates/framesmith-runtime-wasm/Cargo.toml + cargo fmt --check --manifest-path crates/framesmith-fspack/Cargo.toml + + - name: Test Tauri backend + run: cargo test --manifest-path src-tauri/Cargo.toml + + - name: Test runtime crate + run: cargo test --manifest-path crates/framesmith-runtime/Cargo.toml + + - name: Test runtime WASM crate + run: cargo test --manifest-path crates/framesmith-runtime-wasm/Cargo.toml + + - name: Test FSPK reader crate + run: cargo test --manifest-path crates/framesmith-fspack/Cargo.toml + + - name: Clippy Tauri backend + run: cargo clippy --manifest-path src-tauri/Cargo.toml --all-targets -- -D warnings + + - name: Clippy runtime crates + run: | + cargo clippy --manifest-path crates/framesmith-runtime/Cargo.toml --all-targets -- -D warnings + cargo clippy --manifest-path crates/framesmith-runtime-wasm/Cargo.toml --all-targets -- -D warnings + cargo clippy --manifest-path crates/framesmith-fspack/Cargo.toml --all-targets -- -D warnings + + - name: Build Tauri app + run: npm run tauri build + + - name: Upload Windows installers + uses: actions/upload-artifact@v4 + with: + name: framesmith-windows-installers + path: | + src-tauri/target/release/bundle/msi/*.msi + src-tauri/target/release/bundle/nsis/*setup.exe diff --git a/.gitignore b/.gitignore index 027f5fe..ee5c56e 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,8 @@ node_modules /build /.svelte-kit /package +/playwright-report +/test-results .env .env.* !.env.example @@ -13,4 +15,4 @@ vite.config.ts.timestamp-* # Export output directory (keep .gitkeep) /exports/* !/exports/.gitkeep -nul \ No newline at end of file +nul diff --git a/AGENTS.md b/AGENTS.md index 4568535..a216941 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -109,8 +109,7 @@ framesmith/ README.md, design.md, data-formats.md, rules-spec.md, zx-fspack.md, mcp-server.md, runtime-guide.md, runtime-api.md, cli.md, global-states.md, character-authoring-guide.md, - movement-reference.md - plans/ # Implementation plans (removed when done) + movement-reference.md, implementation-history.md ``` ## Task-Type Routing @@ -157,7 +156,7 @@ npm run test:run # vitest (training, rendercore tests) cargo run --bin mcp -- --characters-dir ../characters # CLI export (from framesmith/src-tauri/) -cargo run --bin framesmith -- export --all --project .. --out-dir ../exports +cargo run --bin framesmith-cli -- export --project .. --all --out-dir ../exports ``` ## Editor Views diff --git a/README.md b/README.md index 23c3ebe..9945526 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ It manages portable character data on disk (JSON) and exports runtime-specific f - State editor with sprite and GLTF preview - Cancel graph view for route visualization - Rules system for defaults and validation -- Export adapters (`json-blob`, `zx-fspack`) +- Export adapters (`json-blob`, `fspk`) - MCP server for scripted and LLM-assisted workflows ## Framesmith project format @@ -73,7 +73,7 @@ See `docs/mcp-server.md` for tools, resources, and integration details. ```bash cd src-tauri -cargo run --bin framesmith -- export --project .. --all --out-dir ../exports +cargo run --bin framesmith-cli -- export --project .. --all --out-dir ../exports ``` See `docs/cli.md` for full CLI reference. diff --git a/crates/framesmith-fspack/src/view/event.rs b/crates/framesmith-fspack/src/view/event.rs index 3d8dca0..8432a7a 100644 --- a/crates/framesmith-fspack/src/view/event.rs +++ b/crates/framesmith-fspack/src/view/event.rs @@ -158,7 +158,7 @@ impl<'a> EventArgView<'a> { if self.tag() != EVENT_ARG_TAG_I64 { return None; } - Some(read_i64_le(self.data, 12)?) + read_i64_le(self.data, 12) } pub fn value_f32(&self) -> Option { diff --git a/crates/framesmith-fspack/src/view/hitbox.rs b/crates/framesmith-fspack/src/view/hitbox.rs index 7b7bbf7..36920ca 100644 --- a/crates/framesmith-fspack/src/view/hitbox.rs +++ b/crates/framesmith-fspack/src/view/hitbox.rs @@ -3,8 +3,8 @@ use crate::bytes::{read_u16_le, read_u32_le, read_u8}; use crate::fixed::{Q12_4, Q8_8}; -/// HitWindow record size (24 bytes) -pub const HIT_WINDOW_SIZE: usize = 24; +/// HitWindow record size (28 bytes) +pub const HIT_WINDOW_SIZE: usize = 28; /// Shape record size (12 bytes) pub const SHAPE_SIZE: usize = 12; @@ -24,7 +24,7 @@ pub const SHAPE_KIND_CAPSULE: u8 = 3; /// Zero-copy view over hit windows section. /// -/// Each entry is a HitWindow24 (24 bytes). +/// Each entry is a HitWindow28 (28 bytes). #[derive(Clone, Copy)] pub struct HitWindowsView<'a> { data: &'a [u8], @@ -74,7 +74,7 @@ impl<'a> HitWindowsView<'a> { } } -/// Zero-copy view over a single HitWindow24 record (24 bytes minimum). +/// Zero-copy view over a single HitWindow28 record. /// /// Layout: /// - 0: start_f (u8) @@ -91,6 +91,7 @@ impl<'a> HitWindowsView<'a> { /// - 16-17: shapes_len (u16) /// - 18-21: cancels_off (u32) /// - 22-23: cancels_len (u16) +/// /// Optional extended fields (backwards-compatible): /// - 24-25: hit_pushback (i16, Q12.4 fixed-point) /// - 26-27: block_pushback (i16, Q12.4 fixed-point) diff --git a/crates/framesmith-fspack/src/view/mod.rs b/crates/framesmith-fspack/src/view/mod.rs index 6a520d5..77c2b6a 100644 --- a/crates/framesmith-fspack/src/view/mod.rs +++ b/crates/framesmith-fspack/src/view/mod.rs @@ -59,7 +59,7 @@ pub const SECTION_KEYFRAMES_KEYS: u32 = 3; /// Array of StateRecord structs pub const SECTION_STATES: u32 = 4; -/// Array of HitWindow24 structs +/// Array of HitWindow28 structs pub const SECTION_HIT_WINDOWS: u32 = 5; /// Array of HurtWindow12 structs @@ -205,7 +205,7 @@ impl<'a> PackView<'a> { // Parse section headers let mut sections = [SectionInfo::default(); MAX_SECTIONS]; - for i in 0..section_count { + for (i, section) in sections.iter_mut().enumerate().take(section_count) { let header_offset = HEADER_SIZE + i * SECTION_HEADER_SIZE; let kind = read_u32_le(bytes, header_offset).ok_or(Error::OutOfBounds)?; @@ -221,7 +221,7 @@ impl<'a> PackView<'a> { return Err(Error::OutOfBounds); } - sections[i] = SectionInfo { + *section = SectionInfo { kind, offset, len, diff --git a/crates/framesmith-runtime-wasm/src/lib.rs b/crates/framesmith-runtime-wasm/src/lib.rs index 588c1b3..4490aec 100644 --- a/crates/framesmith-runtime-wasm/src/lib.rs +++ b/crates/framesmith-runtime-wasm/src/lib.rs @@ -7,7 +7,7 @@ use framesmith_fspack::PackView; use framesmith_runtime::{ available_cancels, check_hits, check_pushbox, init_resources, next_frame, CharacterState as RtCharacterState, FrameInput, HitResult as RtHitResult, - PushboxResult as RtPushboxResult, + PushboxResult as RtPushboxResult, MAX_RESOURCES, }; use serde::{Deserialize, Serialize}; use wasm_bindgen::prelude::*; @@ -40,6 +40,7 @@ pub enum DummyState { pub struct CharacterState { pub current_state: u32, pub frame: u32, + pub instance_duration: u32, pub hit_confirmed: bool, pub block_confirmed: bool, pub resources: Vec, @@ -50,6 +51,7 @@ impl From<&RtCharacterState> for CharacterState { CharacterState { current_state: state.current_state as u32, frame: state.frame as u32, + instance_duration: state.instance_duration as u32, hit_confirmed: state.hit_confirmed, block_confirmed: state.block_confirmed, resources: state.resources.iter().map(|&r| r as u32).collect(), @@ -57,11 +59,46 @@ impl From<&RtCharacterState> for CharacterState { } } +impl CharacterState { + fn to_runtime(&self) -> Result { + if self.current_state > u16::MAX as u32 { + return Err("Snapshot current_state exceeds u16".to_string()); + } + if self.frame > u8::MAX as u32 { + return Err("Snapshot frame exceeds u8".to_string()); + } + if self.instance_duration > u8::MAX as u32 { + return Err("Snapshot instance_duration exceeds u8".to_string()); + } + if self.resources.len() > MAX_RESOURCES { + return Err("Snapshot has too many resource values".to_string()); + } + + let mut resources = [0_u16; MAX_RESOURCES]; + for (idx, value) in self.resources.iter().enumerate() { + if *value > u16::MAX as u32 { + return Err("Snapshot resource value exceeds u16".to_string()); + } + resources[idx] = *value as u16; + } + + Ok(RtCharacterState { + current_state: self.current_state as u16, + frame: self.frame as u8, + instance_duration: self.instance_duration as u8, + hit_confirmed: self.hit_confirmed, + block_confirmed: self.block_confirmed, + resources, + }) + } +} + /// Hit result exposed to JavaScript. #[derive(Clone, Debug, Serialize, Deserialize)] pub struct HitResult { pub attacker_move: u32, pub window_index: u32, + pub blocked: bool, pub damage: u32, pub chip_damage: u32, pub hitstun: u32, @@ -72,11 +109,12 @@ pub struct HitResult { pub block_pushback: i32, } -impl From<&RtHitResult> for HitResult { - fn from(hit: &RtHitResult) -> Self { +impl HitResult { + fn from_runtime(hit: &RtHitResult, blocked: bool) -> Self { HitResult { attacker_move: hit.attacker_move as u32, window_index: hit.window_index as u32, + blocked, damage: hit.damage as u32, chip_damage: hit.chip_damage as u32, hitstun: hit.hitstun as u32, @@ -89,6 +127,18 @@ impl From<&RtHitResult> for HitResult { } } +impl From<&RtHitResult> for HitResult { + fn from(hit: &RtHitResult) -> Self { + Self::from_runtime(hit, false) + } +} + +#[derive(Clone, Copy, Debug)] +struct TrainingHit { + result: RtHitResult, + blocked: bool, +} + /// Push separation result exposed to JavaScript. /// Contains the (dx, dy) separation values if characters are overlapping. #[derive(Clone, Debug, Serialize, Deserialize)] @@ -119,6 +169,17 @@ pub struct FrameResult { pub push_separation: Option, } +/// Serializable snapshot used by training mode for frame step-back. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct TrainingSnapshot { + pub player: CharacterState, + pub dummy: CharacterState, + pub player_x: i32, + pub player_y: i32, + pub dummy_x: i32, + pub dummy_y: i32, +} + /// Training session for simulating a player character against a dummy. /// /// Holds the FSPK data and character states for both player and dummy. @@ -134,7 +195,7 @@ pub struct TrainingSession { player_pos: (i32, i32), dummy_pos: (i32, i32), // Last hit results (cached for hit_results() call) - last_hits: Vec, + last_hits: Vec, } #[wasm_bindgen] @@ -182,7 +243,11 @@ impl TrainingSession { /// /// # Returns /// A FrameResult containing the new states and any hits that occurred. - pub fn tick(&mut self, player_input: u32, dummy_behavior: DummyState) -> Result { + pub fn tick( + &mut self, + player_input: u32, + dummy_behavior: DummyState, + ) -> Result { // PackView::parse is zero-copy: it just validates the header and stores // offsets into the existing byte slice. Re-parsing each frame is cheap // (~100ns) and avoids lifetime complexity from caching the view. @@ -200,12 +265,6 @@ impl TrainingSession { }, }; - // Build dummy input based on behavior - let dummy_state = self.compute_dummy_state(dummy_behavior, &dummy_pack); - let dummy_frame_input = FrameInput { - requested_state: dummy_state, - }; - // Advance player state let player_result = next_frame(&self.player_state, &player_pack, &player_frame_input); self.player_state = player_result.state; @@ -215,8 +274,10 @@ impl TrainingSession { Self::handle_move_ended(&mut self.player_state, &player_pack); } - // Advance dummy state - let dummy_result = next_frame(&self.dummy_state, &dummy_pack, &dummy_frame_input); + // Apply authored dummy stance/block behavior, then let the runtime + // advance the selected state normally. + Self::apply_dummy_behavior(&mut self.dummy_state, dummy_behavior, &dummy_pack); + let dummy_result = next_frame(&self.dummy_state, &dummy_pack, &FrameInput::default()); self.dummy_state = dummy_result.state; // Handle move completion for dummy @@ -259,8 +320,12 @@ impl TrainingSession { if let Some(shape) = shapes.get_at(hw.shapes_off(), j) { hw_info.push_str(&format!( " shape[{}]: kind={}, x={}, y={}, w={}, h={}", - j, shape.kind(), shape.x_px(), shape.y_px(), - shape.width_px(), shape.height_px() + j, + shape.kind(), + shape.x_px(), + shape.y_px(), + shape.width_px(), + shape.height_px() )); } } @@ -272,10 +337,14 @@ impl TrainingSession { // Get dummy hurtbox info let mut hrt_info = String::new(); if let Some(dummy_moves) = dummy_pack.states() { - if let Some(dummy_mv) = dummy_moves.get(self.dummy_state.current_state as usize) { + if let Some(dummy_mv) = + dummy_moves.get(self.dummy_state.current_state as usize) + { if let Some(hurt_windows) = dummy_pack.hurt_windows() { for i in 0..dummy_mv.hurt_windows_len() as usize { - if let Some(hrt) = hurt_windows.get_at(dummy_mv.hurt_windows_off(), i) { + if let Some(hrt) = + hurt_windows.get_at(dummy_mv.hurt_windows_off(), i) + { hrt_info.push_str(&format!( " hrt[{}]: frames={}-{}, shapes_off={}, shapes_len={}", i, hrt.start_frame(), hrt.end_frame(), @@ -284,11 +353,16 @@ impl TrainingSession { if let Some(shapes) = dummy_pack.shapes() { for j in 0..hrt.shapes_len() as usize { - if let Some(shape) = shapes.get_at(hrt.shapes_off(), j) { + if let Some(shape) = + shapes.get_at(hrt.shapes_off(), j) + { hrt_info.push_str(&format!( " shape[{}]: x={}, y={}, w={}, h={}", - j, shape.x_px(), shape.y_px(), - shape.width_px(), shape.height_px() + j, + shape.x_px(), + shape.y_px(), + shape.width_px(), + shape.height_px() )); } } @@ -316,10 +390,31 @@ impl TrainingSession { // Store hits for later retrieval self.last_hits.clear(); + let dummy_is_blocking = Self::dummy_is_blocking(dummy_behavior); for hit in hits_result.iter() { - self.last_hits.push(*hit); - // Report hit on player state - framesmith_runtime::report_hit(&mut self.player_state); + self.last_hits.push(TrainingHit { + result: *hit, + blocked: dummy_is_blocking, + }); + if dummy_is_blocking { + framesmith_runtime::report_block(&mut self.player_state); + Self::enter_reaction_state( + &mut self.dummy_state, + &dummy_pack, + &["blockstun", "block_stun", "guard_stun"], + &["blockstun", "block", "guard"], + hit.blockstun, + ); + } else { + framesmith_runtime::report_hit(&mut self.player_state); + Self::enter_reaction_state( + &mut self.dummy_state, + &dummy_pack, + &["hitstun", "hit_stun"], + &["hitstun"], + hit.hitstun, + ); + } } // Also check dummy attacking player (for reversals, etc.) @@ -333,8 +428,18 @@ impl TrainingSession { ); for hit in dummy_hits_result.iter() { - self.last_hits.push(*hit); + self.last_hits.push(TrainingHit { + result: *hit, + blocked: false, + }); framesmith_runtime::report_hit(&mut self.dummy_state); + Self::enter_reaction_state( + &mut self.player_state, + &player_pack, + &["hitstun", "hit_stun"], + &["hitstun"], + hit.hitstun, + ); } // Check pushbox collision @@ -351,7 +456,11 @@ impl TrainingSession { let result = FrameResult { player: CharacterState::from(&self.player_state), dummy: CharacterState::from(&self.dummy_state), - hits: self.last_hits.iter().map(HitResult::from).collect(), + hits: self + .last_hits + .iter() + .map(|hit| HitResult::from_runtime(&hit.result, hit.blocked)) + .collect(), push_separation: push_sep.as_ref().map(PushSeparation::from), }; @@ -388,11 +497,44 @@ impl TrainingSession { /// Get the hit results from the last tick. pub fn hit_results(&self) -> Result { - let hits: Vec = self.last_hits.iter().map(HitResult::from).collect(); + let hits: Vec = self + .last_hits + .iter() + .map(|hit| HitResult::from_runtime(&hit.result, hit.blocked)) + .collect(); serde_wasm_bindgen::to_value(&hits) .map_err(|e| JsError::new(&format!("Serialization error: {:?}", e))) } + /// Capture the deterministic session state for training frame step-back. + pub fn snapshot(&self) -> Result { + let snapshot = TrainingSnapshot { + player: CharacterState::from(&self.player_state), + dummy: CharacterState::from(&self.dummy_state), + player_x: self.player_pos.0, + player_y: self.player_pos.1, + dummy_x: self.dummy_pos.0, + dummy_y: self.dummy_pos.1, + }; + + serde_wasm_bindgen::to_value(&snapshot) + .map_err(|e| JsError::new(&format!("Serialization error: {:?}", e))) + } + + /// Restore a snapshot previously returned by snapshot(). + pub fn restore(&mut self, snapshot: JsValue) -> Result<(), JsError> { + let snapshot: TrainingSnapshot = serde_wasm_bindgen::from_value(snapshot) + .map_err(|e| JsError::new(&format!("Snapshot deserialization error: {:?}", e)))?; + + self.player_state = snapshot.player.to_runtime().map_err(|e| JsError::new(&e))?; + self.dummy_state = snapshot.dummy.to_runtime().map_err(|e| JsError::new(&e))?; + self.player_pos = (snapshot.player_x, snapshot.player_y); + self.dummy_pos = (snapshot.dummy_x, snapshot.dummy_y); + self.last_hits.clear(); + + Ok(()) + } + /// Reset the session to initial state. pub fn reset(&mut self) -> Result<(), JsError> { // Zero-copy parse; see comment in tick() for rationale. @@ -476,38 +618,171 @@ impl TrainingSession { } impl TrainingSession { - /// Compute what state the dummy should transition to based on its behavior. - fn compute_dummy_state(&self, behavior: DummyState, _pack: &PackView) -> Option { - // For now, dummy just stays in its current state - // Future: map behavior to specific states (crouch, block, etc.) + fn dummy_is_blocking(behavior: DummyState) -> bool { + matches!( + behavior, + DummyState::BlockStand | DummyState::BlockCrouch | DummyState::BlockAuto + ) + } + + fn apply_dummy_behavior(state: &mut RtCharacterState, behavior: DummyState, pack: &PackView) { + if let Some(target) = Self::compute_dummy_state(behavior, pack) { + if state.current_state != target { + state.current_state = target; + state.frame = 0; + state.instance_duration = 0; + state.hit_confirmed = false; + state.block_confirmed = false; + } + } + } + + /// Compute what authored state the dummy should transition to based on its behavior. + fn compute_dummy_state(behavior: DummyState, pack: &PackView) -> Option { match behavior { - DummyState::Stand => None, // Stay idle - DummyState::Crouch => Some(1), // Assume state 1 is crouch (game-specific) - DummyState::Jump => Some(2), // Assume state 2 is jump - DummyState::BlockStand => None, // Block is handled by game logic - DummyState::BlockCrouch => Some(1), // Crouching block - DummyState::BlockAuto => None, // Auto-block handled by game logic + DummyState::Stand => Self::find_authored_state( + pack, + &["0_idle", "idle", "stand", "standing"], + &["idle", "stand"], + &[], + ), + DummyState::Crouch => Self::find_authored_state( + pack, + &["1_crouch", "crouch", "2_crouch"], + &["crouch"], + &[], + ) + .or_else(|| Self::compute_dummy_state(DummyState::Stand, pack)), + DummyState::Jump => Self::find_authored_state( + pack, + &["8_jump", "jump"], + &["jump", "airborne", "aerial"], + &["j."], + ) + .or_else(|| Self::compute_dummy_state(DummyState::Stand, pack)), + DummyState::BlockStand | DummyState::BlockAuto => Self::find_authored_state( + pack, + &["blockstun", "block_stun", "block_stand", "stand_block"], + &["blockstun", "block", "guard"], + &[], + ) + .or_else(|| Self::compute_dummy_state(DummyState::Stand, pack)), + DummyState::BlockCrouch => Self::find_authored_state( + pack, + &["block_crouch", "crouch_block", "blockstun", "block_stun"], + &["blockstun", "block", "guard"], + &[], + ) + .or_else(|| Self::compute_dummy_state(DummyState::Crouch, pack)), + } + } + + fn enter_reaction_state( + state: &mut RtCharacterState, + pack: &PackView, + inputs: &[&str], + tags: &[&str], + duration: u8, + ) { + if let Some(target) = Self::find_authored_state(pack, inputs, tags, &[]) { + state.current_state = target; + state.frame = 0; + state.instance_duration = duration.max(1); + state.hit_confirmed = false; + state.block_confirmed = false; + } + } + + fn find_authored_state( + pack: &PackView, + inputs: &[&str], + tags: &[&str], + input_prefixes: &[&str], + ) -> Option { + Self::find_state_by_input(pack, inputs) + .or_else(|| Self::find_state_by_tags(pack, tags)) + .or_else(|| Self::find_state_by_input_prefix(pack, input_prefixes)) + } + + fn find_state_by_input(pack: &PackView, inputs: &[&str]) -> Option { + for input in inputs { + if let Some((idx, _)) = pack.find_state_by_input(input) { + if idx <= u16::MAX as usize { + return Some(idx as u16); + } + } + } + None + } + + fn find_state_by_tags(pack: &PackView, tags: &[&str]) -> Option { + if tags.is_empty() { + return None; + } + + let states = pack.states()?; + for idx in 0..states.len().min(u16::MAX as usize + 1) { + let Some(mut state_tags) = pack.state_tags(idx) else { + continue; + }; + if state_tags.any(|tag| tags.contains(&tag)) { + return Some(idx as u16); + } + } + None + } + + fn find_state_by_input_prefix(pack: &PackView, prefixes: &[&str]) -> Option { + if prefixes.is_empty() { + return None; + } + + let states = pack.states()?; + let extras = pack.state_extras()?; + for idx in 0..states.len().min(u16::MAX as usize + 1) { + let extra = extras.get(idx)?; + let (off, len) = extra.input(); + let Some(input) = pack.string(off, len) else { + continue; + }; + if prefixes.iter().any(|prefix| input.starts_with(prefix)) { + return Some(idx as u16); + } } + None } /// Handle move completion - either loop system states or return to idle. fn handle_move_ended(state: &mut RtCharacterState, pack: &PackView) { - // Check if current state is a system state (state 0 = idle, state 1 = crouch) - // System states loop back to frame 0 instead of transitioning - const IDLE_STATE: u16 = 0; - const MAX_SYSTEM_STATE: u16 = 1; // States 0-1 are system states that loop - - if state.current_state <= MAX_SYSTEM_STATE { - // System state - loop back to frame 0 + if Self::is_looping_stance_state(pack, state.current_state) { state.frame = 0; } else { - // Attack/action state ended - return to idle - state.current_state = IDLE_STATE; + state.current_state = + Self::compute_dummy_state(DummyState::Stand, pack).unwrap_or_default(); state.frame = 0; + state.instance_duration = 0; state.hit_confirmed = false; state.block_confirmed = false; } } + + fn is_looping_stance_state(pack: &PackView, state_idx: u16) -> bool { + let Some(extras) = pack.state_extras() else { + return state_idx <= 1; + }; + let Some(extra) = extras.get(state_idx as usize) else { + return false; + }; + let (off, len) = extra.input(); + let Some(input) = pack.string(off, len) else { + return false; + }; + + matches!( + input, + "0_idle" | "idle" | "stand" | "standing" | "1_crouch" | "crouch" | "2_crouch" + ) + } } #[cfg(test)] @@ -524,7 +799,7 @@ mod tests { let rt_state = RtCharacterState { current_state: 5, frame: 10, - instance_duration: 0, + instance_duration: 12, hit_confirmed: true, block_confirmed: false, resources: [100, 50, 0, 0, 0, 0, 0, 0], @@ -534,6 +809,7 @@ mod tests { assert_eq!(js_state.current_state, 5); assert_eq!(js_state.frame, 10); + assert_eq!(js_state.instance_duration, 12); assert!(js_state.hit_confirmed); assert!(!js_state.block_confirmed); assert_eq!(js_state.resources.len(), 8); @@ -541,6 +817,54 @@ mod tests { assert_eq!(js_state.resources[1], 50); } + #[test] + fn character_state_restore_conversion_validates_bounds() { + let js_state = CharacterState { + current_state: 5, + frame: 10, + instance_duration: 12, + hit_confirmed: true, + block_confirmed: false, + resources: vec![100, 50], + }; + + let rt_state = js_state.to_runtime().unwrap(); + + assert_eq!(rt_state.current_state, 5); + assert_eq!(rt_state.frame, 10); + assert_eq!(rt_state.instance_duration, 12); + assert!(rt_state.hit_confirmed); + assert!(!rt_state.block_confirmed); + assert_eq!(rt_state.resources[0], 100); + assert_eq!(rt_state.resources[1], 50); + assert_eq!(rt_state.resources[2], 0); + } + + #[test] + fn character_state_restore_conversion_rejects_invalid_values() { + let invalid_state = CharacterState { + current_state: u16::MAX as u32 + 1, + frame: 10, + instance_duration: 0, + hit_confirmed: false, + block_confirmed: false, + resources: vec![], + }; + + assert!(invalid_state.to_runtime().is_err()); + + let invalid_resources = CharacterState { + current_state: 0, + frame: 0, + instance_duration: 0, + hit_confirmed: false, + block_confirmed: false, + resources: vec![0; MAX_RESOURCES + 1], + }; + + assert!(invalid_resources.to_runtime().is_err()); + } + #[test] fn hit_result_conversion() { let rt_hit = RtHitResult { @@ -559,8 +883,158 @@ mod tests { let js_hit = HitResult::from(&rt_hit); assert_eq!(js_hit.attacker_move, 3); + assert!(!js_hit.blocked); assert_eq!(js_hit.damage, 50); assert_eq!(js_hit.hitstun, 15); assert_eq!(js_hit.hit_pushback, 20); } + + #[test] + fn hit_result_can_mark_blocked_contacts() { + let rt_hit = RtHitResult { + attacker_move: 3, + window_index: 0, + damage: 50, + chip_damage: 5, + hitstun: 15, + blockstun: 10, + hitstop: 8, + guard: 1, + hit_pushback: 20, + block_pushback: 15, + }; + + let js_hit = HitResult::from_runtime(&rt_hit, true); + + assert!(js_hit.blocked); + assert_eq!(js_hit.damage, 50); + assert_eq!(js_hit.chip_damage, 5); + assert_eq!(js_hit.blockstun, 10); + } + + fn test_char_pack() -> PackView<'static> { + PackView::parse(include_bytes!("../../../exports/test_char.fspk")) + .expect("test_char.fspk should parse") + } + + #[test] + fn target_training_fixture_resolves_authored_reaction_states() { + let pack = test_char_pack(); + let (hitstun_idx, _) = pack + .find_state_by_input("hitstun") + .expect("target fixture should export a hitstun state"); + let (blockstun_idx, _) = pack + .find_state_by_input("blockstun") + .expect("target fixture should export a blockstun state"); + + let hitstun_tags: Vec<_> = pack + .state_tags(hitstun_idx) + .expect("hitstun tags should decode") + .collect(); + let blockstun_tags: Vec<_> = pack + .state_tags(blockstun_idx) + .expect("blockstun tags should decode") + .collect(); + assert!(hitstun_tags.contains(&"hitstun")); + assert!(blockstun_tags.contains(&"blockstun")); + + assert_eq!( + TrainingSession::compute_dummy_state(DummyState::BlockStand, &pack), + Some(blockstun_idx as u16) + ); + assert_eq!( + TrainingSession::compute_dummy_state(DummyState::BlockAuto, &pack), + Some(blockstun_idx as u16) + ); + + let mut state = RtCharacterState::default(); + TrainingSession::enter_reaction_state( + &mut state, + &pack, + &["hitstun", "hit_stun"], + &["hitstun"], + 17, + ); + assert_eq!(state.current_state, hitstun_idx as u16); + assert_eq!(state.frame, 0); + assert_eq!(state.instance_duration, 17); + + TrainingSession::enter_reaction_state( + &mut state, + &pack, + &["blockstun", "block_stun", "guard_stun"], + &["blockstun", "block", "guard"], + 11, + ); + assert_eq!(state.current_state, blockstun_idx as u16); + assert_eq!(state.frame, 0); + assert_eq!(state.instance_duration, 11); + } + + #[test] + fn target_training_fixture_preserves_resource_and_throw_policies() { + let pack = test_char_pack(); + + let resources = pack + .resource_defs() + .expect("target fixture should export resource definitions"); + let resource_names: Vec<_> = (0..resources.len()) + .map(|idx| { + let resource = resources.get(idx).expect("resource record"); + pack.string(resource.name_off(), resource.name_len()) + .expect("resource name") + }) + .collect(); + assert!(resource_names.contains(&"heat")); + assert!(resource_names.contains(&"ammo")); + assert!(resource_names.contains(&"level")); + assert!(resource_names.contains(&"install_active")); + + let (_, throw_state) = pack + .find_state_by_input("5T") + .expect("target fixture should export ground throw input"); + assert_eq!(throw_state.state_type(), 5, "throw state type encoding"); + assert_eq!(throw_state.guard(), 3, "unblockable guard encoding"); + + let (_, heavy_state) = pack + .find_state_by_input("5H") + .expect("target fixture should export 5H"); + let extras = pack + .state_extras() + .expect("target fixture should export state extras"); + let heavy_extras = extras + .get(heavy_state.state_id() as usize) + .expect("5H state extras"); + let (delta_off, delta_len) = heavy_extras.resource_deltas(); + assert!( + delta_len >= 2, + "5H meter gain should export resource deltas" + ); + + let deltas = pack + .move_resource_deltas() + .expect("target fixture should export move resource deltas"); + let mut has_whiff_meter = false; + let mut has_hit_meter = false; + for idx in 0..delta_len as usize { + let delta = deltas + .get_at(delta_off, idx) + .expect("5H resource delta should decode"); + let name = pack + .string(delta.name_off(), delta.name_len()) + .expect("delta resource name"); + if name == "meter" + && delta.trigger() == framesmith_fspack::RESOURCE_DELTA_TRIGGER_ON_USE + { + has_whiff_meter = true; + } + if name == "meter" + && delta.trigger() == framesmith_fspack::RESOURCE_DELTA_TRIGGER_ON_HIT + { + has_hit_meter = true; + } + } + assert!(has_whiff_meter, "whiff meter gain should be exported"); + assert!(has_hit_meter, "hit meter gain should be exported"); + } } diff --git a/crates/framesmith-runtime-wasm/tests/integration.rs b/crates/framesmith-runtime-wasm/tests/integration.rs index 694fb04..21f1c3c 100644 --- a/crates/framesmith-runtime-wasm/tests/integration.rs +++ b/crates/framesmith-runtime-wasm/tests/integration.rs @@ -21,16 +21,14 @@ fn can_parse_real_fspk() { // Verify it has states assert!(pack.states().is_some(), "Should have states section"); let states = pack.states().unwrap(); - assert!(states.len() > 0, "Should have at least one state"); + assert!(!states.is_empty(), "Should have at least one state"); } /// Test runtime simulation with real FSPK data. #[test] fn simulate_with_real_fspk() { use framesmith_fspack::PackView; - use framesmith_runtime::{ - init_resources, next_frame, CharacterState as RtState, FrameInput, - }; + use framesmith_runtime::{init_resources, next_frame, CharacterState as RtState, FrameInput}; let fspk_data = include_bytes!("../../../exports/glitch.fspk"); let pack = PackView::parse(fspk_data).unwrap(); @@ -47,7 +45,10 @@ fn simulate_with_real_fspk() { } // State should have advanced - assert!(state.frame > 0 || state.current_state > 0, "State should have progressed"); + assert!( + state.frame > 0 || state.current_state > 0, + "State should have progressed" + ); } #[test] @@ -67,6 +68,7 @@ fn character_state_conversion_roundtrip() { assert_eq!(js_state.current_state, 5); assert_eq!(js_state.frame, 10); + assert_eq!(js_state.instance_duration, 0); assert!(js_state.hit_confirmed); assert!(!js_state.block_confirmed); assert_eq!(js_state.resources, vec![100, 50, 25, 0, 0, 0, 0, 0]); @@ -93,6 +95,7 @@ fn hit_result_conversion_roundtrip() { assert_eq!(js_hit.attacker_move, 3); assert_eq!(js_hit.window_index, 1); + assert!(!js_hit.blocked); assert_eq!(js_hit.damage, 100); assert_eq!(js_hit.chip_damage, 10); assert_eq!(js_hit.hitstun, 20); diff --git a/crates/framesmith-runtime/src/cancel.rs b/crates/framesmith-runtime/src/cancel.rs index 99c2e96..2a9a2d0 100644 --- a/crates/framesmith-runtime/src/cancel.rs +++ b/crates/framesmith-runtime/src/cancel.rs @@ -1,6 +1,9 @@ use crate::state::CharacterState; use framesmith_fspack::PackView; +#[cfg(feature = "alloc")] +use alloc::vec::Vec; + /// Action cancel IDs (offset from move_count). /// These map to CancelFlags on the current move. pub const ACTION_CHAIN: u16 = 0; @@ -79,11 +82,11 @@ pub fn can_cancel_to(state: &CharacterState, pack: &PackView, target: u16) -> bo // bit 0 = hit, bit 1 = block, bit 2 = whiff let condition = rule.condition(); let condition_met = if state.hit_confirmed { - condition & 0b001 != 0 // HIT bit + condition & 0b001 != 0 // HIT bit } else if state.block_confirmed { - condition & 0b010 != 0 // BLOCK bit + condition & 0b010 != 0 // BLOCK bit } else { - condition & 0b100 != 0 // WHIFF bit + condition & 0b100 != 0 // WHIFF bit }; if !condition_met { continue; @@ -110,12 +113,54 @@ pub fn can_cancel_to(state: &CharacterState, pack: &PackView, target: u16) -> bo false } +/// Return every move state that can currently be cancelled into. +/// +/// This enumerates regular move/state targets only. Game-defined action cancels +/// use IDs above the move count and remain delegated to the engine. +#[cfg(feature = "alloc")] +#[must_use] +pub fn available_cancels(state: &CharacterState, pack: &PackView) -> Vec { + let Some(states) = pack.states() else { + return Vec::new(); + }; + + let mut cancels = Vec::new(); + for target in 0..states.len().min(u16::MAX as usize + 1) { + let target = target as u16; + if can_cancel_to(state, pack, target) { + cancels.push(target); + } + } + cancels +} + +/// Write every currently valid move-state cancel target into a caller buffer. +/// +/// Returns the number of targets written. If the buffer is too small, remaining +/// valid targets are skipped. Game-defined action cancels are not enumerated. +#[must_use] +pub fn available_cancels_buf(state: &CharacterState, pack: &PackView, buf: &mut [u16]) -> usize { + let Some(states) = pack.states() else { + return 0; + }; + + let mut written = 0; + for target in 0..states.len().min(u16::MAX as usize + 1) { + let target = target as u16; + if can_cancel_to(state, pack, target) { + if let Some(slot) = buf.get_mut(written) { + *slot = target; + written += 1; + } else { + break; + } + } + } + written +} + /// Check if an action cancel is allowed based on current move's cancel flags. -fn check_action_cancel( - state: &CharacterState, - pack: &PackView, - action_id: u16, -) -> bool { +fn check_action_cancel(state: &CharacterState, pack: &PackView, action_id: u16) -> bool { let moves = match pack.states() { Some(m) => m, None => return false, @@ -182,4 +227,15 @@ mod tests { } // Parsing empty data fails, which is expected } + + #[test] + fn available_cancels_buf_returns_zero_for_empty_pack() { + let state = CharacterState::default(); + let empty_data: [u8; 0] = []; + if let Ok(pack) = PackView::parse(&empty_data) { + let mut buf = [0u16; 4]; + assert_eq!(available_cancels_buf(&state, &pack, &mut buf), 0); + } + // Parsing empty data fails, which is expected. + } } diff --git a/crates/framesmith-runtime/src/collision/mod.rs b/crates/framesmith-runtime/src/collision/mod.rs index 4ee6414..22c594b 100644 --- a/crates/framesmith-runtime/src/collision/mod.rs +++ b/crates/framesmith-runtime/src/collision/mod.rs @@ -2,8 +2,8 @@ mod shapes; // Re-export shape types and functions for backward compatibility pub use shapes::{ - Aabb, Capsule, Circle, - aabb_circle_overlap, aabb_overlap, capsule_overlap, circle_overlap, shapes_overlap, + aabb_circle_overlap, aabb_overlap, capsule_overlap, circle_overlap, shapes_overlap, Aabb, + Capsule, Circle, }; use crate::state::CharacterState; @@ -275,7 +275,7 @@ pub fn calculate_pushbox_separation(p1_aabb: &Aabb, p2_aabb: &Aabb) -> Option (i t_num }; - ( - p1.0 + (dx * t) / len_sq, - p1.1 + (dy * t) / len_sq, - ) + (p1.0 + (dx * t) / len_sq, p1.1 + (dy * t) / len_sq) } /// Compute squared distance between closest points on two line segments. -fn segment_distance_sq( - a1: (i64, i64), a2: (i64, i64), - b1: (i64, i64), b2: (i64, i64), -) -> i64 { +fn segment_distance_sq(a1: (i64, i64), a2: (i64, i64), b1: (i64, i64), b2: (i64, i64)) -> i64 { // Find closest point on segment A to segment B's closest point to A let closest_on_b_to_a1 = closest_point_on_segment(b1, b2, a1); let closest_on_a = closest_point_on_segment(a1, a2, closest_on_b_to_a1); @@ -284,14 +278,28 @@ mod tests { #[test] fn aabb_circle_overlap_detects_intersection() { - let aabb = Aabb { x: 0, y: 0, w: 20, h: 20 }; - let circle = Circle { x: 25, y: 10, r: 10 }; + let aabb = Aabb { + x: 0, + y: 0, + w: 20, + h: 20, + }; + let circle = Circle { + x: 25, + y: 10, + r: 10, + }; assert!(aabb_circle_overlap(&aabb, &circle)); // circle touches right edge } #[test] fn aabb_circle_overlap_detects_no_intersection() { - let aabb = Aabb { x: 0, y: 0, w: 20, h: 20 }; + let aabb = Aabb { + x: 0, + y: 0, + w: 20, + h: 20, + }; let circle = Circle { x: 35, y: 10, r: 5 }; assert!(!aabb_circle_overlap(&aabb, &circle)); // too far right } @@ -299,24 +307,60 @@ mod tests { #[test] fn capsule_overlap_detects_intersection() { // Two overlapping horizontal capsules - let a = Capsule { x1: 0, y1: 0, x2: 20, y2: 0, r: 5 }; - let b = Capsule { x1: 15, y1: 0, x2: 35, y2: 0, r: 5 }; + let a = Capsule { + x1: 0, + y1: 0, + x2: 20, + y2: 0, + r: 5, + }; + let b = Capsule { + x1: 15, + y1: 0, + x2: 35, + y2: 0, + r: 5, + }; assert!(capsule_overlap(&a, &b)); } #[test] fn capsule_overlap_detects_no_intersection() { // Two non-overlapping capsules - let a = Capsule { x1: 0, y1: 0, x2: 10, y2: 0, r: 5 }; - let b = Capsule { x1: 30, y1: 0, x2: 40, y2: 0, r: 5 }; + let a = Capsule { + x1: 0, + y1: 0, + x2: 10, + y2: 0, + r: 5, + }; + let b = Capsule { + x1: 30, + y1: 0, + x2: 40, + y2: 0, + r: 5, + }; assert!(!capsule_overlap(&a, &b)); } #[test] fn capsule_overlap_edge_touching_is_not_overlap() { // Two capsules exactly touching (distance == sum of radii) - let a = Capsule { x1: 0, y1: 0, x2: 10, y2: 0, r: 5 }; - let b = Capsule { x1: 20, y1: 0, x2: 30, y2: 0, r: 5 }; + let a = Capsule { + x1: 0, + y1: 0, + x2: 10, + y2: 0, + r: 5, + }; + let b = Capsule { + x1: 20, + y1: 0, + x2: 30, + y2: 0, + r: 5, + }; assert!(!capsule_overlap(&a, &b)); // distance 10 == 5+5 } } diff --git a/crates/framesmith-runtime/src/frame.rs b/crates/framesmith-runtime/src/frame.rs index 6c8efa2..2fc2f0c 100644 --- a/crates/framesmith-runtime/src/frame.rs +++ b/crates/framesmith-runtime/src/frame.rs @@ -23,11 +23,7 @@ fn advance_frame_counter(state: &CharacterState) -> CharacterState { /// # Returns /// New state and whether the move ended this frame. #[must_use] -pub fn next_frame( - state: &CharacterState, - pack: &PackView, - input: &FrameInput, -) -> FrameResult { +pub fn next_frame(state: &CharacterState, pack: &PackView, input: &FrameInput) -> FrameResult { // Try to transition if a state was requested if let Some(target) = input.requested_state { if crate::cancel::can_cancel_to(state, pack, target) { diff --git a/crates/framesmith-runtime/src/lib.rs b/crates/framesmith-runtime/src/lib.rs index 77f3a9f..91cc3a9 100644 --- a/crates/framesmith-runtime/src/lib.rs +++ b/crates/framesmith-runtime/src/lib.rs @@ -10,12 +10,22 @@ pub mod resource; pub mod state; // Re-export main types -pub use state::{CharacterState, FrameInput, FrameResult, MAX_RESOURCES}; -pub use state::{report_block, report_hit}; +#[cfg(feature = "alloc")] +pub use cancel::available_cancels; +pub use cancel::{ + available_cancels_buf, can_cancel_to, ACTION_CHAIN, ACTION_JUMP, ACTION_SPECIAL, ACTION_SUPER, +}; +pub use collision::{ + aabb_circle_overlap, aabb_overlap, calculate_pushbox_separation, capsule_overlap, check_hits, + check_pushbox, circle_overlap, shapes_overlap, Aabb, Capsule, CheckHitsResult, Circle, + HitResult, PushboxResult, MAX_HIT_RESULTS, +}; pub use frame::next_frame; -pub use cancel::{can_cancel_to, ACTION_CHAIN, ACTION_SPECIAL, ACTION_SUPER, ACTION_JUMP}; -pub use collision::{aabb_circle_overlap, aabb_overlap, calculate_pushbox_separation, capsule_overlap, check_hits, check_pushbox, circle_overlap, shapes_overlap, Aabb, Capsule, CheckHitsResult, Circle, HitResult, PushboxResult, MAX_HIT_RESULTS}; -pub use resource::{apply_resource_costs, check_resource_preconditions, init_resources, resource, set_resource}; +pub use resource::{ + apply_resource_costs, check_resource_preconditions, init_resources, resource, set_resource, +}; +pub use state::{report_block, report_hit}; +pub use state::{CharacterState, FrameInput, FrameResult, MAX_RESOURCES}; // Re-export fspack for convenience pub use framesmith_fspack::PackView; diff --git a/crates/framesmith-runtime/src/resource.rs b/crates/framesmith-runtime/src/resource.rs index 2713255..6aa91f2 100644 --- a/crates/framesmith-runtime/src/resource.rs +++ b/crates/framesmith-runtime/src/resource.rs @@ -55,7 +55,11 @@ pub fn apply_resource_costs( if current < cost.amount() { all_paid = false; } - set_resource(state, res_idx as u8, current.saturating_sub(cost.amount())); + set_resource( + state, + res_idx as u8, + current.saturating_sub(cost.amount()), + ); break; } } @@ -115,7 +119,8 @@ pub fn check_resource_preconditions( // Find resource index by name for res_idx in 0..resource_defs.len().min(MAX_RESOURCES) { if let Some(def) = resource_defs.get(res_idx) { - if def.name_off() == precond.name_off() && def.name_len() == precond.name_len() { + if def.name_off() == precond.name_off() && def.name_len() == precond.name_len() + { let current = resource(state, res_idx as u8); if !check_precondition_value(current, precond.min(), precond.max()) { return false; @@ -198,7 +203,7 @@ mod tests { fn resource_primitives_support_deduction() { let mut state = CharacterState::default(); set_resource(&mut state, 0, 100); // meter - set_resource(&mut state, 1, 50); // heat + set_resource(&mut state, 1, 50); // heat // Simulate deducting 30 from resource 0, 10 from resource 1 let costs = [(0u8, 30u16), (1u8, 10u16)]; diff --git a/docs/README.md b/docs/README.md index db9f707..deb7bf7 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,7 +1,7 @@ # Framesmith Documentation Status: active -Last reviewed: 2026-02-09 +Last reviewed: 2026-05-23 ## Source of truth map @@ -17,11 +17,22 @@ Last reviewed: 2026-02-09 | Runtime integration | [`runtime-guide.md`](runtime-guide.md) | | Runtime API | [`runtime-api.md`](runtime-api.md) | | CLI usage | [`cli.md`](cli.md) | +| Production handoff decision | [`production-handoff-decision.md`](production-handoff-decision.md) | +| Variant editing decision | [`variant-editing-decision.md`](variant-editing-decision.md) | +| Export fidelity contract | [`export-fidelity-contract.md`](export-fidelity-contract.md) | +| Combat mechanic coverage | [`combat-coverage.md`](combat-coverage.md) | +| Training scenario contract | [`training-scenario-contract.md`](training-scenario-contract.md) | +| Production gap backlog | [`production-gap-backlog.md`](production-gap-backlog.md) | +| Release runbook | [`release-runbook.md`](release-runbook.md) | +| Schema migration notes | [`schema-migration.md`](schema-migration.md) | +| Windows installer smoke test | [`windows-installer-smoke-test.md`](windows-installer-smoke-test.md) | | Global states | [`global-states.md`](global-states.md) | | Architecture overview | [`architecture.md`](architecture.md) | | Troubleshooting | [`troubleshooting.md`](troubleshooting.md) | | Character authoring flow | [`character-authoring-guide.md`](character-authoring-guide.md) | | Movement reference | [`movement-reference.md`](movement-reference.md) | +| Implementation history | [`implementation-history.md`](implementation-history.md) | +| Production readiness | [`production-readiness-plan.md`](production-readiness-plan.md) | ## Reading order @@ -29,9 +40,12 @@ Last reviewed: 2026-02-09 - Editing schema or files on disk: read [`data-formats.md`](data-formats.md) - Changing validation/rules behavior: read [`rules-spec.md`](rules-spec.md) - Integrating external tools/LLMs: read [`mcp-server.md`](mcp-server.md) -- Implementing export/runtime work: read [`zx-fspack.md`](zx-fspack.md) and [`runtime-guide.md`](runtime-guide.md) +- Implementing export/runtime work: read [`production-handoff-decision.md`](production-handoff-decision.md), [`variant-editing-decision.md`](variant-editing-decision.md), [`combat-coverage.md`](combat-coverage.md), [`training-scenario-contract.md`](training-scenario-contract.md), [`export-fidelity-contract.md`](export-fidelity-contract.md), [`zx-fspack.md`](zx-fspack.md), and [`runtime-guide.md`](runtime-guide.md) - Understanding the system architecture: read [`architecture.md`](architecture.md) - Debugging issues: read [`troubleshooting.md`](troubleshooting.md) +- Tracking release blockers: read [`production-readiness-plan.md`](production-readiness-plan.md), [`production-gap-backlog.md`](production-gap-backlog.md), and [`release-runbook.md`](release-runbook.md) +- Migrating older project data: read [`schema-migration.md`](schema-migration.md) +- Testing Windows release artifacts: read [`windows-installer-smoke-test.md`](windows-installer-smoke-test.md) ## Document set @@ -44,14 +58,26 @@ Last reviewed: 2026-02-09 | [`runtime-guide.md`](runtime-guide.md) | Runtime integration path | | [`runtime-api.md`](runtime-api.md) | Runtime API reference | | [`cli.md`](cli.md) | CLI commands and examples | +| [`production-handoff-decision.md`](production-handoff-decision.md) | Canonical production handoff and FSPK v1 movement policy | +| [`variant-editing-decision.md`](variant-editing-decision.md) | Variant overlay editing policy for the first production target | +| [`export-fidelity-contract.md`](export-fidelity-contract.md) | Adapter field-preservation contract and known FSPK limits | +| [`combat-coverage.md`](combat-coverage.md) | Fighting-game mechanic support and engine-owned gaps | +| [`training-scenario-contract.md`](training-scenario-contract.md) | Executable target training scenarios and ownership policy | +| [`production-gap-backlog.md`](production-gap-backlog.md) | Concrete implementation issues for external gates and future target-game gaps | +| [`release-runbook.md`](release-runbook.md) | Repeatable release-candidate validation and evidence capture steps | +| [`schema-migration.md`](schema-migration.md) | Migration notes for current character, cancel, variant, and adapter schema changes | +| [`windows-installer-smoke-test.md`](windows-installer-smoke-test.md) | Manual MSI/NSIS smoke-test steps and evidence to record | | [`global-states.md`](global-states.md) | Global state model and usage | | [`character-authoring-guide.md`](character-authoring-guide.md) | Authoring workflow guidance | | [`movement-reference.md`](movement-reference.md) | Movement and notation reference | | [`architecture.md`](architecture.md) | System architecture and data pipeline overview | | [`troubleshooting.md`](troubleshooting.md) | Common issues and solutions | | [`design.md`](design.md) | Design rationale and roadmap notes | +| [`implementation-history.md`](implementation-history.md) | Archive index for completed temporary plans | +| [`production-readiness-plan.md`](production-readiness-plan.md) | Production readiness blockers, acceptance criteria, and checklist | ## Plans -Implementation plans under `docs/plans/` are intentionally temporary. -When work is complete, update permanent docs in this folder and then remove the plan file. +There are no active checked-in implementation plans. Completed temporary plans +were migrated into permanent docs and summarized in +[`implementation-history.md`](implementation-history.md). diff --git a/docs/architecture.md b/docs/architecture.md index 12bd48d..c414383 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,7 +1,7 @@ # Framesmith Architecture **Status:** Active -**Last reviewed:** 2026-02-20 +**Last reviewed:** 2026-05-23 This document describes Framesmith's system architecture, the data pipeline from authoring to runtime, and the responsibility boundaries between layers. @@ -26,7 +26,7 @@ flowchart TD subgraph Export ["Export Layer"] VAL["Validation Pipeline\n(rules/validate.rs)"] JB["json-blob adapter\n(single JSON file)"] - FSPK["zx-fspack adapter\n(.fspk binary)"] + FSPK["fspk adapter\n(.fspk binary)"] end subgraph Runtime ["Runtime Layer"] @@ -66,7 +66,7 @@ flowchart LR subgraph Backend ["Rust Backend"] CMDS["Tauri Commands\n(character, project, export)"] SCHEMA["Schema Types\n(State, Character, CancelTable)"] - CODEGEN["Codegen Adapters\n(json-blob, zx-fspack)"] + CODEGEN["Codegen Adapters\n(json-blob, fspk)"] RMOD["Rules Engine\n(validate, apply, match)"] end @@ -90,11 +90,11 @@ Character data lives on disk as a directory of JSON files. Each character has it ### Export layer -The export pipeline reads JSON files, applies rule defaults, runs the shared validation pipeline (`rules/validate.rs`), and produces output through one of two adapters. The `json-blob` adapter emits a single resolved JSON file. The `zx-fspack` adapter emits a compact `.fspk` binary using fixed-size records for zero-copy deserialization. Both adapters are invoked identically from the UI, CLI, and MCP server -- validation is never bypassed. +The export pipeline reads JSON files, applies rule defaults, runs the shared validation pipeline (`rules/validate.rs`), and produces output through one of two adapters. The `json-blob` adapter emits a single resolved JSON file. The `fspk` adapter emits a compact `.fspk` binary using fixed-size records for zero-copy deserialization. Both adapters are invoked identically from the UI, CLI, and MCP server -- validation is never bypassed. ### Runtime layer -`framesmith-fspack` is a `no_std` crate that provides zero-copy views over `.fspk` binary data. `framesmith-runtime` builds on it to implement the core simulation: frame-by-frame state advancement, cancel validation (explicit chains, tag-based rules, deny lists), hit detection (AABB hitbox/hurtbox overlap), and resource management. The runtime is stateless and deterministic -- `CharacterState` is 22 bytes, `Copy`, and designed for rollback netcode. +`framesmith-fspack` is a `no_std` crate that provides zero-copy views over `.fspk` binary data. `framesmith-runtime` builds on it to implement the core simulation: frame-by-frame state advancement, cancel validation (tag-based rules plus deny lists), hit detection (AABB hitbox/hurtbox overlap), and resource management. The runtime is stateless and deterministic -- `CharacterState` is 22 bytes, `Copy`, and designed for rollback netcode. ### WASM layer @@ -108,7 +108,7 @@ The export pipeline reads JSON files, applies rule defaults, runs the shared val - **FSPK is fixed-size records.** The binary format uses fixed-size records exclusively for zero-copy deserialization. Variable-length encodings (MessagePack, JSON) are not used in FSPK sections. -- **Variant inheritance is authoring-only.** The `base` field on states enables inheritance during editing, but variants are fully resolved (flattened) at export time. The runtime never sees inheritance. +- **Variant inheritance is authoring-only.** The `base` field on states enables inheritance during loading/export, but variants are fully resolved (flattened) before runtime handoff. The runtime never sees inheritance. Overlay-aware UI editing is deferred for the first production target; resolved variants are inspectable but read-only in the editor. - **Nested properties are flattened.** `Object` and `Array` values in `PropertyValue` are flattened to dot-path keys at export (e.g., `movement.distance`). The binary format contains only flat key-value pairs. @@ -118,5 +118,6 @@ The export pipeline reads JSON files, applies rule defaults, runs the shared val - [Rules Spec](rules-spec.md) -- validation and defaults system (SSOT) - [ZX FSPK Format](zx-fspack.md) -- binary pack format details - [Runtime Guide](runtime-guide.md) -- runtime integration patterns +- [Variant Editing Decision](variant-editing-decision.md) -- first-target variant overlay policy - [MCP Server](mcp-server.md) -- AI/LLM tool integration - [CLI](cli.md) -- headless export commands diff --git a/docs/character-authoring-guide.md b/docs/character-authoring-guide.md index dbe706c..d85eb79 100644 --- a/docs/character-authoring-guide.md +++ b/docs/character-authoring-guide.md @@ -219,24 +219,13 @@ Movement states need cancel rules to transition between each other and into atta ```json { - "chains": { - "0_idle": ["0_walk_forward", "0_walk_backward", "1_crouch", "66", "44"], - "0_walk_forward": ["0_idle", "0_walk_backward", "1_crouch", "66"], - "0_walk_backward": ["0_idle", "0_walk_forward", "1_crouch", "44"], - "1_crouch": ["0_idle", "0_walk_forward", "0_walk_backward"] - }, "tag_rules": [ - { - "from_tag": "system", - "to_tag": "normal", - "condition": "on_complete" - }, - { - "from_tag": "normal", - "to_tag": "special", - "condition": "on_hit_or_block" - } - ] + { "from": "system", "to": "movement", "on": "always" }, + { "from": "movement", "to": "system", "on": "always" }, + { "from": "system", "to": "normal", "on": "always" }, + { "from": "normal", "to": "special", "on": ["hit", "block"] } + ], + "deny": {} } ``` @@ -247,6 +236,8 @@ Instead of explicit pairs, use tags for common patterns: - Tag movement states as `system` or `movement` - Tag attack types as `normal`, `special`, `super` - Define rules like "normals cancel into specials on hit" +- Use `deny` for specific routes that should be blocked even when a broad tag + rule would otherwise allow them ## Using Global States diff --git a/docs/cli.md b/docs/cli.md index 9f5edf4..b2967c9 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -1,20 +1,22 @@ # Framesmith CLI **Status:** Active -**Last reviewed:** 2026-01-30 +**Last reviewed:** 2026-05-23 Framesmith includes a small Rust CLI for automation tasks like exporting `.fspk` packs. The CLI binary lives at `src-tauri/src/bin/framesmith.rs`. +The export examples in this document are executed by +`cargo test --manifest-path src-tauri/Cargo.toml --test docs_cli_examples`. ## Build ```bash cd src-tauri -cargo build --bin framesmith --release +cargo build --bin framesmith-cli --release ``` -The binary will be at `src-tauri/target/release/framesmith.exe`. +The binary will be at `src-tauri/target/release/framesmith-cli.exe`. ## Export @@ -27,7 +29,7 @@ The `export` command runs the same rules + validation pipeline as the app: ```bash cd src-tauri -cargo run --bin framesmith -- export --project .. --character test_char --out ../exports/test_char.fspk +cargo run --bin framesmith-cli -- export --project .. --character test_char --out ../exports/test_char.fspk ``` If `--out` is omitted, it defaults to `/exports/.fspk`. @@ -36,7 +38,7 @@ If `--out` is omitted, it defaults to `/exports/.fspk`. ```bash cd src-tauri -cargo run --bin framesmith -- export --project .. --all --out-dir ../exports +cargo run --bin framesmith-cli -- export --project .. --all --out-dir ../exports ``` ### Characters Directory @@ -45,12 +47,15 @@ You can point directly at a `characters/` directory instead of a project root: ```bash cd src-tauri -cargo run --bin framesmith -- export --characters-dir ../characters --all --out-dir ../exports +cargo run --bin framesmith-cli -- export --characters-dir ../characters --all --out-dir ../exports ``` Or set `FRAMESMITH_CHARACTERS_DIR`. ### Adapters -- `--adapter zx-fspack` (default) writes `.fspk` +- `--adapter fspk` (default) writes `.fspk` - `--adapter json-blob` writes `.json` (`--pretty` supported) + +`zx-fspack` is accepted as a legacy alias for `fspk`, but new docs and scripts +should use `fspk`. diff --git a/docs/combat-coverage.md b/docs/combat-coverage.md new file mode 100644 index 0000000..ef58e40 --- /dev/null +++ b/docs/combat-coverage.md @@ -0,0 +1,56 @@ +# Combat Coverage + +Status: active +Last reviewed: 2026-05-22 + +This matrix classifies common fighting-game mechanics by current Framesmith +support. It should be reviewed before choosing Framesmith as the production +authoring pipeline for a game. + +## Coverage Levels + +- `supported end-to-end`: authoring data, export, runtime behavior, and tests + exist in the current pipeline. +- `exported as data only`: Framesmith preserves or emits data, but the game + engine applies behavior. +- `engine-owned`: the game engine owns the mechanic today; Framesmith may expose + related authoring fields. +- `out of scope`: not currently represented in the data model or runtime plan. + +## Mechanic Matrix + +| Mechanic | Current Level | Notes | +|----------|---------------|-------| +| Core state timing | supported end-to-end | `startup`, `active`, `recovery`, `total`, and `next_frame()` are covered by runtime tests. | +| Tag-rule cancels | supported end-to-end | `tag_rules`, `deny`, cancel conditions, frame windows, and resource preconditions are exported to FSPK and tested. | +| Legacy hitboxes/hurtboxes | supported end-to-end | Legacy rectangular hit/hurt windows export to FSPK and are consumed by `check_hits()`. | +| Pushboxes | supported end-to-end | `pushboxes[]` export to FSPK and are consumed by `check_pushbox()`. Stage/corner policy remains engine-owned. | +| Character resources | supported end-to-end | Resource definitions, resource costs, and resource preconditions are exported and consumed by the runtime. | +| State tags and custom properties | exported as data only | Tags and properties export to FSPK; game-specific property behavior is engine-owned. | +| Events and event args | exported as data only | Event emits and primitive args export to FSPK; dispatch timing and side effects are engine-owned. | +| Resource deltas | exported as data only | FSPK stores resource deltas. The engine applies them when a hit/block/whiff/event becomes authoritative. | +| Meter gain | exported as data only | Legacy `meter_gain` is derived into meter resource deltas for FSPK when nonzero. Runtime does not auto-apply hit/whiff gain. | +| Hitstop | exported as data only | Hit windows carry hitstop. The engine schedules attacker/defender freeze and rollback-authoritative timing. | +| Blockstop | engine-owned | No separate blockstop field exists. Engines can use hitstop or custom properties until a dedicated field is added. | +| Chip damage | exported as data only | `hits[].chip_damage` exists in JSON. FSPK v1 legacy hit windows currently encode chip damage as zero. | +| Multi-hit attacks | exported as data only | `hits[]` exists in JSON. FSPK v1 exports legacy `hitboxes[]`, not the advanced `hits[]` model. | +| Throws | engine-owned | States can be typed/tagged as throws, but throw boxes, throw tech, invulnerability, and throw-vs-hit resolution are engine-owned. | +| Projectiles/spawned entities | engine-owned | JSON can describe `on_use.spawn_entity`; FSPK v1 does not serialize projectile behavior. | +| Movement curves/velocity | engine-owned | `json-blob` preserves `movement`; FSPK v1 does not serialize movement values and runtime does not apply movement. | +| Forced movement/launch/knockback | engine-owned | JSON has `on_hit.knockback`; FSPK v1 does not serialize it and runtime does not apply launch/forced movement. | +| Status/timed effects | engine-owned | JSON has status effect structures; FSPK v1 does not serialize timed effect behavior. | +| State transition events | engine-owned | JSON has `on_use.enters_state`; FSPK v1 does not serialize transition events and runtime does not auto-transition. | + +## Production Decision + +Use `json-blob` as the production handoff when the target game needs the full +combat model today. Use `fspk` as the production handoff only if the target game +accepts the current runtime subset or commits to the missing FSPK/runtime work +listed above. + +See also: + +- [`export-fidelity-contract.md`](export-fidelity-contract.md) +- [`runtime-guide.md`](runtime-guide.md) +- [`data-formats.md`](data-formats.md) +- [`production-gap-backlog.md`](production-gap-backlog.md) diff --git a/docs/data-formats.md b/docs/data-formats.md index e7f8502..eecf40f 100644 --- a/docs/data-formats.md +++ b/docs/data-formats.md @@ -1,7 +1,7 @@ # Framesmith Data Formats **Status:** Active -**Last reviewed:** 2026-02-02 +**Last reviewed:** 2026-05-22 Framesmith stores character data as a directory of JSON files. The Rust types in `src-tauri/src/schema/mod.rs` are the canonical definitions. @@ -148,11 +148,24 @@ Inheritance rules: - Single-level only (no chaining) - Variant fields override base fields (shallow merge) - `null` removes inherited fields -- Resolved `id` field is set during loading +- Resolved `id` field is set during loading and export. The resolved variant + keeps the base gameplay `input`, so `id` is the unique authoring/editor key + for variants such as `5H~level1`. + +Current editing rule: + +- The State Editor can inspect resolved variants by `id`. +- Saving a resolved variant through the UI or `save_move` is blocked because the + loaded data is a resolved snapshot, not the original overlay patch. +- To change a variant today, edit its overlay file, such as + `characters/test_char/states/5H~level1.json`, then reload/export. +- Overlay-aware editing is explicitly deferred for the first production target; + see [`variant-editing-decision.md`](variant-editing-decision.md). If reopened, + it must write only the overlay diff, not the resolved state. ### Minimal (Core) State -These “core” fields are what the current UI surfaces and what the current exporters primarily use. +These core fields are what the current UI surfaces and what the current exporters primarily use. ```json { @@ -194,14 +207,14 @@ Moves also support additional optional fields (all are optional unless stated ot - `trigger`: `press | release | hold` (default behavior is `press` when omitted) - `parent`: string (for follow-ups / strings) - `total`: number (override total duration) -- `hits[]`: multi-hit model with shaped hitboxes (currently not exported by `zx-fspack` v1) +- `hits[]`: multi-hit model with shaped hitboxes (currently not exported by `fspk` v1) - `preconditions[]`: requirements to use the move (meter/charge/state/etc.) - `costs[]`: meter/health/resource costs - `movement`: distance/velocity-based movement data - `super_freeze`: cinematic freeze parameters - `on_use`, `on_hit`, `on_block`: gameplay effects + notification events - `notifies[]`: timeline-triggered notification events -- `advanced_hurtboxes[]`: shaped hurtboxes with flags (currently not exported by `zx-fspack` v1) +- `advanced_hurtboxes[]`: shaped hurtboxes with flags (currently not exported by `fspk` v1) - `pushboxes[]`: body collision boxes for character-to-character push separation (same format as hurtboxes) ## Events (Notification) @@ -249,16 +262,23 @@ Central cancel relationship table: ```json { - "chains": { - "5L": ["5L", "5M"], - "5M": ["5H"] - }, - "special_cancels": ["5L", "5M", "5H"], - "super_cancels": ["5H"], - "jump_cancels": ["5H"] + "tag_rules": [ + { "from": "system", "to": "any", "on": "always" }, + { "from": "normal", "to": "special", "on": ["hit", "block"] }, + { "from": "normal", "to": "super", "on": ["hit", "block"] }, + { "from": "5l", "to": "5m", "on": ["hit", "block"] } + ], + "deny": { + "5L": ["236P"] + } } ``` +`tag_rules[]` describe selector-based cancel routes. `from` and `to` match state +tags, state `type`, exact state `input`, resolved state `id`, or `any`. `deny` +maps source input/id notation to target input/id notations that must be blocked +even when a tag rule would otherwise allow the route. + ## Rules Files - Project rules: `/framesmith.rules.json` @@ -269,4 +289,4 @@ Rules semantics, matching, and built-in validations are specified in `docs/rules ## Export Outputs - `json-blob`: a single JSON blob containing the resolved character + moves (after rule defaults are applied). It includes optional/advanced fields when present. -- `zx-fspack`: a compact binary pack documented in `docs/zx-fspack.md` +- `fspk`: a compact binary pack documented in `docs/zx-fspack.md` diff --git a/docs/design.md b/docs/design.md index 797f18f..070fc06 100644 --- a/docs/design.md +++ b/docs/design.md @@ -1,7 +1,7 @@ # Framesmith Design Notes **Status:** Active -**Last reviewed:** 2026-01-30 +**Last reviewed:** 2026-05-22 This document describes Framesmith’s intended shape and the current implementation boundaries. For the on-disk JSON formats, use `docs/data-formats.md` (canonical for file layout) and `docs/rules-spec.md` (canonical for rules semantics). @@ -29,7 +29,7 @@ Implemented (today): - Rules system: apply defaults + validate moves; optional registry for resources/events - Export adapters: - `json-blob` (single JSON blob) - - `zx-fspack` (compact binary pack) + - `fspk` (compact binary pack) - MCP server for programmatic workflows and LLM integration Not implemented (yet): @@ -82,7 +82,7 @@ Exports are run through the same validation/defaulting pipeline used by saving: Adapters: - `json-blob`: emits a single JSON blob containing resolved character + moves. -- `zx-fspack`: emits a `.fspk` binary pack for constrained runtimes (see `docs/zx-fspack.md`). +- `fspk`: emits a `.fspk` binary pack for constrained runtimes (see `docs/zx-fspack.md`). ## MCP Server diff --git a/docs/export-fidelity-contract.json b/docs/export-fidelity-contract.json new file mode 100644 index 0000000..e82a178 --- /dev/null +++ b/docs/export-fidelity-contract.json @@ -0,0 +1,102 @@ +{ + "version": 1, + "status_values": ["preserved", "derived", "omitted", "engine-owned"], + "adapters": { + "json-blob": { + "character": { + "id": { "status": "preserved", "notes": "Serialized directly." }, + "name": { "status": "preserved", "notes": "Serialized directly." }, + "properties": { "status": "preserved", "notes": "Serialized directly, including nested values." }, + "resources": { "status": "preserved", "notes": "Serialized directly." } + }, + "state": { + "input": { "status": "preserved", "notes": "Serialized directly." }, + "name": { "status": "preserved", "notes": "Serialized directly." }, + "tags": { "status": "preserved", "notes": "Serialized directly." }, + "startup": { "status": "preserved", "notes": "Serialized directly." }, + "active": { "status": "preserved", "notes": "Serialized directly." }, + "recovery": { "status": "preserved", "notes": "Serialized directly." }, + "damage": { "status": "preserved", "notes": "Serialized directly." }, + "hitstun": { "status": "preserved", "notes": "Serialized directly." }, + "blockstun": { "status": "preserved", "notes": "Serialized directly." }, + "hitstop": { "status": "preserved", "notes": "Serialized directly." }, + "guard": { "status": "preserved", "notes": "Serialized directly." }, + "hitboxes": { "status": "preserved", "notes": "Serialized directly." }, + "hurtboxes": { "status": "preserved", "notes": "Serialized directly." }, + "pushback": { "status": "preserved", "notes": "Serialized directly." }, + "meter_gain": { "status": "preserved", "notes": "Serialized directly." }, + "animation": { "status": "preserved", "notes": "Serialized directly." }, + "type": { "status": "preserved", "notes": "Serialized as the state type field." }, + "trigger": { "status": "preserved", "notes": "Serialized directly when present." }, + "parent": { "status": "preserved", "notes": "Serialized directly when present." }, + "total": { "status": "preserved", "notes": "Serialized directly when present." }, + "hits": { "status": "preserved", "notes": "Serialized directly when present." }, + "preconditions": { "status": "preserved", "notes": "Serialized directly when present." }, + "costs": { "status": "preserved", "notes": "Serialized directly when present." }, + "movement": { "status": "preserved", "notes": "Serialized directly when present." }, + "super_freeze": { "status": "preserved", "notes": "Serialized directly when present." }, + "on_use": { "status": "preserved", "notes": "Serialized directly when present." }, + "on_hit": { "status": "preserved", "notes": "Serialized directly when present." }, + "on_block": { "status": "preserved", "notes": "Serialized directly when present." }, + "notifies": { "status": "preserved", "notes": "Serialized directly." }, + "advanced_hurtboxes": { "status": "preserved", "notes": "Serialized directly when present." }, + "pushboxes": { "status": "preserved", "notes": "Serialized directly." }, + "properties": { "status": "preserved", "notes": "Serialized directly, including nested values." }, + "base": { "status": "preserved", "notes": "Serialized when present. Loaded resolved variants normally omit this authoring-only field." }, + "id": { "status": "preserved", "notes": "Serialized when present and required to distinguish resolved variants." } + }, + "cancel_table": { + "tag_rules": { "status": "preserved", "notes": "Serialized directly." }, + "deny": { "status": "preserved", "notes": "Serialized directly." } + } + }, + "fspk": { + "character": { + "id": { "status": "derived", "notes": "Used to derive mesh asset keys, not stored as a top-level character id." }, + "name": { "status": "omitted", "notes": "Display name is editor-facing and not present in FSPK v1." }, + "properties": { "status": "preserved", "notes": "Stored in CHARACTER_PROPS; nested values are flattened to dotted property names." }, + "resources": { "status": "preserved", "notes": "Stored in RESOURCE_DEFS." } + }, + "state": { + "input": { "status": "preserved", "notes": "Stored in STATE_EXTRAS as input notation. FSPK v1 does not separately store resolved variant id." }, + "name": { "status": "omitted", "notes": "Display name is editor-facing and not present in FSPK v1." }, + "tags": { "status": "preserved", "notes": "Stored in STATE_TAG_RANGES and STATE_TAGS." }, + "startup": { "status": "preserved", "notes": "Stored in StateRecord." }, + "active": { "status": "preserved", "notes": "Stored in StateRecord." }, + "recovery": { "status": "preserved", "notes": "Stored in StateRecord." }, + "damage": { "status": "preserved", "notes": "Stored in StateRecord and copied into legacy hit windows." }, + "hitstun": { "status": "preserved", "notes": "Stored in StateRecord and copied into legacy hit windows." }, + "blockstun": { "status": "preserved", "notes": "Stored in StateRecord and copied into legacy hit windows." }, + "hitstop": { "status": "preserved", "notes": "Stored in StateRecord and copied into legacy hit windows." }, + "guard": { "status": "preserved", "notes": "Stored in StateRecord and legacy hit windows as an enum value." }, + "hitboxes": { "status": "preserved", "notes": "Legacy rectangular hitboxes are stored as HIT_WINDOWS plus SHAPES." }, + "hurtboxes": { "status": "preserved", "notes": "Legacy rectangular hurtboxes are stored as HURT_WINDOWS plus SHAPES." }, + "pushback": { "status": "preserved", "notes": "Stored on legacy hit windows as fixed-point hit/block pushback." }, + "meter_gain": { "status": "derived", "notes": "Exported as meter resource deltas when nonzero and not already declared." }, + "animation": { "status": "derived", "notes": "Mapped to MESH_KEYS and KEYFRAMES_KEYS indices." }, + "type": { "status": "derived", "notes": "Known types are encoded in StateRecord and also emitted as state tags; unknown custom types encode as 255 in StateRecord." }, + "trigger": { "status": "preserved", "notes": "Stored in StateRecord as an enum value." }, + "parent": { "status": "omitted", "notes": "Follow-up ownership remains engine-side in FSPK v1." }, + "total": { "status": "derived", "notes": "Stored in StateRecord; derived from startup+active+recovery when omitted." }, + "hits": { "status": "omitted", "notes": "The advanced multi-hit model is not exported by FSPK v1; legacy hitboxes are exported instead." }, + "preconditions": { "status": "preserved", "notes": "Resource preconditions are stored; meter, charge, state, grounded, airborne, health, entity_count, combo_count, opponent_state, and distance variants are not represented in FSPK v1." }, + "costs": { "status": "preserved", "notes": "Resource costs are stored; meter and health costs are not represented in FSPK v1." }, + "movement": { "status": "engine-owned", "notes": "Authored movement is not represented in FSPK v1; runtime movement ownership is still a production blocker." }, + "super_freeze": { "status": "omitted", "notes": "Not represented in FSPK v1." }, + "on_use": { "status": "preserved", "notes": "Events and resource_deltas are stored; enters_state, spawn_entity, and gain_meter are not represented in FSPK v1." }, + "on_hit": { "status": "preserved", "notes": "Events and resource_deltas are stored; gain_meter, heal, status, knockback, wall_bounce, and ground_bounce are not represented in FSPK v1." }, + "on_block": { "status": "preserved", "notes": "Events and resource_deltas are stored; gain_meter and pushback are not represented in FSPK v1." }, + "notifies": { "status": "preserved", "notes": "Stored in MOVE_NOTIFIES plus EVENT_EMITS/EVENT_ARGS." }, + "advanced_hurtboxes": { "status": "omitted", "notes": "Advanced shaped hurtboxes and flags are not represented in FSPK v1; legacy hurtboxes are exported." }, + "pushboxes": { "status": "preserved", "notes": "Stored as PUSH_WINDOWS plus SHAPES." }, + "properties": { "status": "preserved", "notes": "Stored in STATE_PROPS; nested values are flattened to dotted property names." }, + "base": { "status": "omitted", "notes": "Authoring-only inheritance field; variants should be flattened before FSPK export." }, + "id": { "status": "omitted", "notes": "FSPK v1 does not store resolved variant id separately from gameplay input. This is a known limitation for variant-heavy runtime handoff." } + }, + "cancel_table": { + "tag_rules": { "status": "preserved", "notes": "Stored in CANCEL_TAG_RULES. Selector resolution is currently based on input/type/tags." }, + "deny": { "status": "preserved", "notes": "Stored in CANCEL_DENIES as state index pairs. Current exporter resolves deny entries by gameplay input." } + } + } + } +} diff --git a/docs/export-fidelity-contract.md b/docs/export-fidelity-contract.md new file mode 100644 index 0000000..40b7329 --- /dev/null +++ b/docs/export-fidelity-contract.md @@ -0,0 +1,140 @@ +# Export Fidelity Contract + +Status: active +Last reviewed: 2026-05-23 + +This document explains the machine-readable contract in +`docs/export-fidelity-contract.json`. The contract exists so export behavior +cannot drift silently as the Rust schema changes. + +## Status Values + +- `preserved`: the adapter carries the field or an accepted structural + equivalent. +- `derived`: the adapter intentionally transforms the field into a runtime form. +- `omitted`: the adapter does not carry the field. +- `engine-owned`: the field is authored in Framesmith, but the consuming game or + runtime currently owns the behavior. + +## Adapter Policy + +- `json-blob` is the canonical production handoff for the first production + target. It serializes `CharacterData` directly and is the safest handoff when + a game needs every authored field. +- `fspk` is a compact runtime pack. It preserves the current runtime-critical + subset and intentionally derives or omits editor-facing and unresolved + gameplay fields. + +See [`production-handoff-decision.md`](production-handoff-decision.md) for the +formal handoff decision and FSPK v1 movement policy. + +## Known FSPK V1 Limits + +- Resolved variant `id` is not stored separately from gameplay `input`. +- Advanced `hits[]` are not exported; legacy `hitboxes[]` are exported. +- Advanced hurtbox shapes and flags are not exported; legacy `hurtboxes[]` are + exported. +- Only resource-based `preconditions[]` and `costs[]` are exported. +- `movement`, `super_freeze`, some `on_use`, `on_hit`, and `on_block` gameplay + fields still need an ownership decision before `fspk` can be a full production + handoff. + +## FSPK V1 Lossy Examples + +These examples are intentional v1 behavior. A production game that needs these +fields at runtime should use `json-blob` as the complete handoff or fund the +corresponding FSPK v2/runtime work. + +### Resolved Variant Identity + +Input: + +```json +{ + "id": "5H~level2", + "input": "5H", + "name": "Standing Heavy Level 2" +} +``` + +FSPK v1 result: `input` is preserved as `5H`; `id` and `name` are omitted. The +runtime cannot distinguish this resolved variant from another state with the +same gameplay input unless the consuming game keeps a side table. + +### Advanced Multi-Hit Data + +Input: + +```json +{ + "input": "236P", + "hits": [ + { "frames": [6, 7], "damage": 20 }, + { "frames": [12, 13], "damage": 30 } + ], + "hitboxes": [{ "frames": [6, 13], "box": { "x": 20, "y": -40, "w": 32, "h": 18 } }] +} +``` + +FSPK v1 result: `hits[]` is omitted. The legacy `hitboxes[]` data is exported +as `HIT_WINDOWS` plus `SHAPES`. + +### Movement Ownership + +Input: + +```json +{ + "input": "66", + "movement": { "distance": 80, "direction": "forward" } +} +``` + +FSPK v1 result: `movement` is not serialized. The game engine owns dash +distance, collision against stage bounds/corners, and rollback state for +movement. + +### Advanced Hurtbox Flags + +Input: + +```json +{ + "input": "j.H", + "advanced_hurtboxes": [ + { "frames": [0, 12], "shape": { "type": "circle", "x": 0, "y": -48, "r": 18 }, "flags": ["airborne"] } + ] +} +``` + +FSPK v1 result: `advanced_hurtboxes[]` is omitted. Only legacy rectangular +`hurtboxes[]` are exported as `HURT_WINDOWS` plus `SHAPES`. + +### Super Freeze + +Input: + +```json +{ + "input": "236236P", + "super_freeze": { "frames": 40, "darken": 0.6, "zoom": 1.2 } +} +``` + +FSPK v1 result: `super_freeze` is omitted. The game engine must schedule camera, +screen-darkening, and freeze behavior from JSON data or engine-side scripts. + +The Rust test `export_fidelity_contract_covers_current_schema_direct_fields` +checks that every direct `Character`, `State`, and `CancelTable` field has a +classification for every adapter listed in the JSON contract. + +The Rust test `fspk_preserved_and_derived_fields_have_named_roundtrip_coverage` +checks that every `fspk` field classified as `preserved` or `derived` is mapped +to at least one named `fspk_roundtrip` test that reads the exported bytes through +`framesmith-fspack`. + +When a target game promotes an omitted or engine-owned field into the runtime +handoff, start from the matching item in +[`production-gap-backlog.md`](production-gap-backlog.md), update the JSON +contract, and add the required roundtrip/runtime tests before changing this +document's status language. diff --git a/docs/implementation-history.md b/docs/implementation-history.md new file mode 100644 index 0000000..0f26672 --- /dev/null +++ b/docs/implementation-history.md @@ -0,0 +1,33 @@ +# Implementation History + +Status: archived +Last reviewed: 2026-05-23 + +This document replaces completed temporary plans that previously lived under +`docs/plans/`. It is not a source of truth for current behavior. Use the linked +permanent documents and tests for normative details. + +## Migrated Plans + +| Historical plan | Durable home | +|-----------------|--------------| +| Framesmith runtime scaffold and phase 2 runtime work | [`runtime-guide.md`](runtime-guide.md), [`runtime-api.md`](runtime-api.md), [`combat-coverage.md`](combat-coverage.md) | +| Training mode design | [`runtime-guide.md`](runtime-guide.md), [`troubleshooting.md`](troubleshooting.md), [`production-readiness-plan.md`](production-readiness-plan.md) | +| Training/rendercore unification | [`architecture.md`](architecture.md), `src/lib/rendercore/`, `src/lib/training/` tests | +| State tags, cancel rules, and move-to-state terminology | [`data-formats.md`](data-formats.md), [`rules-spec.md`](rules-spec.md), [`zx-fspack.md`](zx-fspack.md), [`runtime-guide.md`](runtime-guide.md) | +| Variant overlay system | [`data-formats.md`](data-formats.md), [`variant-editing-decision.md`](variant-editing-decision.md), [`architecture.md`](architecture.md) | +| Global states | [`global-states.md`](global-states.md), [`data-formats.md`](data-formats.md), [`mcp-server.md`](mcp-server.md) | +| FSPK character properties and pushboxes | [`zx-fspack.md`](zx-fspack.md), [`runtime-guide.md`](runtime-guide.md), [`runtime-api.md`](runtime-api.md), [`export-fidelity-contract.md`](export-fidelity-contract.md) | +| Cancel condition bitfield | [`data-formats.md`](data-formats.md), [`zx-fspack.md`](zx-fspack.md), [`runtime-guide.md`](runtime-guide.md) | +| FSPK module refactor and adapter rename | [`architecture.md`](architecture.md), [`cli.md`](cli.md), [`zx-fspack.md`](zx-fspack.md), [`export-fidelity-contract.md`](export-fidelity-contract.md) | + +## Current Rule + +Do not keep completed implementation plans under `docs/plans/`. When a new plan +is finished: + +1. Move lasting decisions, examples, and compatibility notes into permanent + docs. +2. Move remaining release blockers into + [`production-readiness-plan.md`](production-readiness-plan.md). +3. Delete the temporary plan file. diff --git a/docs/mcp-server.md b/docs/mcp-server.md index 4617a72..2cda692 100644 --- a/docs/mcp-server.md +++ b/docs/mcp-server.md @@ -1,7 +1,7 @@ # Framesmith MCP Server **Status:** Active -**Last reviewed:** 2026-02-01 +**Last reviewed:** 2026-05-22 Framesmith ships an MCP server binary at `src-tauri/src/bin/mcp.rs`. It exposes tools for inspecting and editing character data on disk, with the same validation pipeline used by the app/exporters. @@ -125,7 +125,9 @@ Example usage (conceptual): ```text export_character({ "character_id": "test_char", - "adapter": "zx-fspack", + "adapter": "fspk", "output_path": "exports/test_char.fspk" }) ``` + +`zx-fspack` is accepted as a legacy alias for `fspk`. diff --git a/docs/movement-reference.md b/docs/movement-reference.md index 4223427..aaea60e 100644 --- a/docs/movement-reference.md +++ b/docs/movement-reference.md @@ -1,5 +1,8 @@ # Movement Reference +Status: active +Last reviewed: 2026-05-23 + Technical reference for defining and applying movement in Framesmith states. ## Movement Field Schema @@ -179,6 +182,26 @@ This is useful for: - Dashes with startup and recovery that don't move - Air dashes with brief momentum windows +## Export And Runtime Ownership + +Movement is currently an authoring contract, not a built-in runtime simulation +contract: + +- `json-blob` preserves the full `movement` object. +- `fspk` v1 marks movement states by type but does not serialize movement + values. +- `framesmith-runtime` does not apply movement curves, velocity, gravity, floor + collision, wall collision, or stage bounds. +- The game engine owns deterministic movement application and must include any + velocity or movement accumulator state in its rollback state. + +Use this reference when consuming `json-blob` data or when planning a future +FSPK movement section. + +The production handoff decision formally accepts `json-blob` as the movement +handoff for FSPK v1. See +[`production-handoff-decision.md`](production-handoff-decision.md). + ## Applying Movement in Your Engine ### Per-Frame Processing @@ -247,7 +270,7 @@ For rollback netcode: 2. **UI Preview**: The State Editor shows movement fields but does not visualize the motion path. -3. **Runtime Simulation**: Framesmith exports movement data; your engine is responsible for applying it. +3. **Runtime Simulation**: Framesmith preserves movement data in `json-blob`; your engine is responsible for applying it. `fspk` v1 does not include movement values. 4. **Easing Curves**: Only standard easing functions are supported. Custom curves require engine-side implementation. @@ -255,4 +278,6 @@ For rollback netcode: - `docs/data-formats.md` - Full state schema - `docs/character-authoring-guide.md` - Creating movement states -- `docs/runtime-guide.md` - Integrating with game engines +- `docs/runtime-guide.md` - Integrating with game engines and ownership boundaries +- `docs/export-fidelity-contract.md` - Adapter field coverage and FSPK limits +- `docs/production-handoff-decision.md` - Canonical production handoff and FSPK v1 movement policy diff --git a/docs/plans/2026-01-31-framesmith-runtime-phase2.md b/docs/plans/2026-01-31-framesmith-runtime-phase2.md deleted file mode 100644 index ca27e42..0000000 --- a/docs/plans/2026-01-31-framesmith-runtime-phase2.md +++ /dev/null @@ -1,714 +0,0 @@ -# Framesmith Runtime Phase 2: Complete TODO Features - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** Implement the remaining TODO features in framesmith-runtime: resource costs, timing windows, cancel flags, and additional shape overlap types. - -**Architecture:** Extend existing pure functions with additional logic. Resource costs are deducted during cancel transitions. Timing windows filter cancel availability by current frame. Cancel flags (chain, special, jump, etc.) control action cancels. Shape overlap adds circle and capsule collision detection. - -**Tech Stack:** Rust, no_std, framesmith-fspack for data access. - ---- - -## Task 1: Implement Resource Cost Deduction - -**Files:** -- Modify: `crates/framesmith-runtime/src/frame.rs` -- Modify: `crates/framesmith-runtime/src/resource.rs` - -**Step 1: Write the test in resource.rs** - -```rust -#[test] -fn deduct_resource_costs() { - let mut state = CharacterState::default(); - set_resource(&mut state, 0, 100); // meter - set_resource(&mut state, 1, 50); // heat - - // Simulate deducting 30 from resource 0, 10 from resource 1 - let costs = [(0u8, 30u16), (1u8, 10u16)]; - for (idx, amount) in costs { - let current = resource(&state, idx); - set_resource(&mut state, idx, current.saturating_sub(amount)); - } - - assert_eq!(resource(&state, 0), 70); - assert_eq!(resource(&state, 1), 40); -} -``` - -**Step 2: Run test to verify it passes** - -Run: `cd crates/framesmith-runtime && cargo test resource::tests::deduct_resource_costs` -Expected: PASS (this is just verifying existing helpers work for deduction) - -**Step 3: Add apply_resource_costs function to resource.rs** - -```rust -/// Apply resource costs for a move transition. -/// -/// Deducts costs from state. Returns true if all costs were paid, -/// false if any resource was insufficient (costs still deducted). -pub fn apply_resource_costs( - state: &mut CharacterState, - pack: &framesmith_fspack::PackView, - move_index: u16, -) -> bool { - let extras = match pack.move_extras() { - Some(e) => e, - None => return true, - }; - let extra = match extras.get(move_index as usize) { - Some(e) => e, - None => return true, - }; - let costs_view = match pack.move_resource_costs() { - Some(c) => c, - None => return true, - }; - let resource_defs = pack.resource_defs(); - - let (off, len) = extra.resource_costs(); - let mut all_paid = true; - - for i in 0..len as usize { - if let Some(cost) = costs_view.get_at(off, i) { - // Find resource index by name - if let Some(defs) = &resource_defs { - for res_idx in 0..defs.len().min(MAX_RESOURCES) { - if let Some(def) = defs.get(res_idx) { - if def.name_off() == cost.name_off() && def.name_len() == cost.name_len() { - let current = resource(state, res_idx as u8); - if current < cost.amount() { - all_paid = false; - } - set_resource(state, res_idx as u8, current.saturating_sub(cost.amount())); - break; - } - } - } - } - } - } - - all_paid -} -``` - -**Step 4: Update frame.rs to call apply_resource_costs** - -Replace the TODO comment in `next_frame`: - -```rust - // Apply resource costs - crate::resource::apply_resource_costs(&mut new_state, pack, target); -``` - -**Step 5: Export apply_resource_costs from lib.rs** - -Add to lib.rs exports: -```rust -pub use resource::{resource, set_resource, init_resources, apply_resource_costs}; -``` - -**Step 6: Run all tests** - -Run: `cd crates/framesmith-runtime && cargo test` -Expected: PASS - -**Step 7: Commit** - -```bash -git add crates/framesmith-runtime/src/frame.rs crates/framesmith-runtime/src/resource.rs crates/framesmith-runtime/src/lib.rs -git commit -m "feat(runtime): implement resource cost deduction on cancel" -``` - ---- - -## Task 2: Implement Resource Precondition Checking - -**Files:** -- Modify: `crates/framesmith-runtime/src/cancel.rs` -- Modify: `crates/framesmith-runtime/src/resource.rs` - -**Step 1: Write the test in resource.rs** - -```rust -#[test] -fn check_preconditions_passes_when_met() { - let mut state = CharacterState::default(); - set_resource(&mut state, 0, 50); - - // Precondition: resource 0 must be >= 25 - // This would need pack data, so we test the helper directly - assert!(check_precondition_value(50, Some(25), None)); - assert!(check_precondition_value(50, None, Some(100))); - assert!(check_precondition_value(50, Some(25), Some(100))); -} - -#[test] -fn check_preconditions_fails_when_not_met() { - assert!(!check_precondition_value(20, Some(25), None)); // below min - assert!(!check_precondition_value(150, None, Some(100))); // above max - assert!(!check_precondition_value(20, Some(25), Some(100))); // below min - assert!(!check_precondition_value(150, Some(25), Some(100))); // above max -} -``` - -**Step 2: Add check_precondition_value helper to resource.rs** - -```rust -/// Check if a resource value satisfies a precondition. -#[inline] -pub fn check_precondition_value(value: u16, min: Option, max: Option) -> bool { - if let Some(m) = min { - if value < m { - return false; - } - } - if let Some(m) = max { - if value > m { - return false; - } - } - true -} - -/// Check all resource preconditions for a move. -/// -/// Returns true if all preconditions are satisfied. -pub fn check_resource_preconditions( - state: &CharacterState, - pack: &framesmith_fspack::PackView, - move_index: u16, -) -> bool { - let extras = match pack.move_extras() { - Some(e) => e, - None => return true, - }; - let extra = match extras.get(move_index as usize) { - Some(e) => e, - None => return true, - }; - let preconditions_view = match pack.move_resource_preconditions() { - Some(p) => p, - None => return true, - }; - let resource_defs = pack.resource_defs(); - - let (off, len) = extra.resource_preconditions(); - - for i in 0..len as usize { - if let Some(precond) = preconditions_view.get_at(off, i) { - // Find resource index by name - if let Some(defs) = &resource_defs { - for res_idx in 0..defs.len().min(MAX_RESOURCES) { - if let Some(def) = defs.get(res_idx) { - if def.name_off() == precond.name_off() && def.name_len() == precond.name_len() { - let current = resource(state, res_idx as u8); - if !check_precondition_value(current, precond.min(), precond.max()) { - return false; - } - break; - } - } - } - } - } - } - - true -} -``` - -**Step 3: Update can_cancel_to in cancel.rs to check preconditions** - -Add precondition check before returning true for a cancel target: - -```rust - if cancel_target == target { - // Check resource preconditions - if !crate::resource::check_resource_preconditions(state, pack, target) { - continue; - } - return true; - } -``` - -**Step 4: Export check_resource_preconditions from lib.rs** - -Add to lib.rs exports: -```rust -pub use resource::{resource, set_resource, init_resources, apply_resource_costs, check_resource_preconditions}; -``` - -**Step 5: Run all tests** - -Run: `cd crates/framesmith-runtime && cargo test` -Expected: PASS - -**Step 6: Commit** - -```bash -git add crates/framesmith-runtime/src/cancel.rs crates/framesmith-runtime/src/resource.rs crates/framesmith-runtime/src/lib.rs -git commit -m "feat(runtime): add resource precondition checking for cancels" -``` - ---- - -## Task 3: Implement Cancel Flags for Action Cancels - -**Files:** -- Modify: `crates/framesmith-runtime/src/cancel.rs` - -**Step 1: Write the test** - -```rust -#[test] -fn action_cancel_constants_match_flags() { - // Action IDs map to cancel flags - assert_eq!(ACTION_CHAIN, 0); - assert_eq!(ACTION_SPECIAL, 1); - assert_eq!(ACTION_SUPER, 2); - assert_eq!(ACTION_JUMP, 3); -} -``` - -**Step 2: Add action constants and implement check_action_cancel** - -```rust -/// Action cancel IDs (offset from move_count). -/// These map to CancelFlags on the current move. -pub const ACTION_CHAIN: u16 = 0; -pub const ACTION_SPECIAL: u16 = 1; -pub const ACTION_SUPER: u16 = 2; -pub const ACTION_JUMP: u16 = 3; - -/// Check if an action cancel is allowed based on current move's cancel flags. -fn check_action_cancel( - state: &CharacterState, - pack: &PackView, - action_id: u16, -) -> bool { - let moves = match pack.moves() { - Some(m) => m, - None => return false, - }; - let current_move = match moves.get(state.current_move as usize) { - Some(m) => m, - None => return false, - }; - - let flags = current_move.cancel_flags(); - - match action_id { - ACTION_CHAIN => flags.chain, - ACTION_SPECIAL => flags.special, - ACTION_SUPER => flags.super_cancel, - ACTION_JUMP => flags.jump, - _ => true, // Unknown actions delegated to game - } -} -``` - -**Step 3: Run all tests** - -Run: `cd crates/framesmith-runtime && cargo test` -Expected: PASS - -**Step 4: Commit** - -```bash -git add crates/framesmith-runtime/src/cancel.rs -git commit -m "feat(runtime): implement cancel flags for action cancels" -``` - ---- - -## Task 4: Implement Circle Overlap - -**Files:** -- Modify: `crates/framesmith-runtime/src/collision.rs` - -**Step 1: Write the test** - -```rust -#[test] -fn circle_overlap_detects_intersection() { - // Two overlapping circles - let a = Circle { x: 0, y: 0, r: 10 }; - let b = Circle { x: 15, y: 0, r: 10 }; - assert!(circle_overlap(&a, &b)); // distance 15 < 10+10 -} - -#[test] -fn circle_overlap_detects_no_intersection() { - // Two non-overlapping circles - let a = Circle { x: 0, y: 0, r: 10 }; - let b = Circle { x: 25, y: 0, r: 10 }; - assert!(!circle_overlap(&a, &b)); // distance 25 > 10+10 -} - -#[test] -fn circle_overlap_edge_touching_is_not_overlap() { - // Circles exactly touching - let a = Circle { x: 0, y: 0, r: 10 }; - let b = Circle { x: 20, y: 0, r: 10 }; - assert!(!circle_overlap(&a, &b)); // distance 20 == 10+10 -} -``` - -**Step 2: Add Circle struct and circle_overlap function** - -```rust -/// Circle for collision detection. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct Circle { - pub x: i32, - pub y: i32, - pub r: u32, -} - -impl Circle { - /// Create a Circle from a ShapeView at a given position offset. - pub fn from_shape(shape: &ShapeView, offset_x: i32, offset_y: i32) -> Self { - Circle { - x: shape.x_px() + offset_x, - y: shape.y_px() + offset_y, - r: shape.radius_px(), - } - } -} - -/// Check if two circles overlap. -/// -/// Edge-touching is NOT considered overlap. -#[must_use] -#[inline] -pub fn circle_overlap(a: &Circle, b: &Circle) -> bool { - let dx = (a.x as i64) - (b.x as i64); - let dy = (a.y as i64) - (b.y as i64); - let dist_sq = dx * dx + dy * dy; - let radii_sum = (a.r as i64) + (b.r as i64); - dist_sq < radii_sum * radii_sum -} -``` - -**Step 3: Update shapes_overlap to handle circles** - -```rust -pub fn shapes_overlap( - a: &ShapeView, - a_offset: (i32, i32), - b: &ShapeView, - b_offset: (i32, i32), -) -> bool { - use framesmith_fspack::{SHAPE_KIND_AABB, SHAPE_KIND_CIRCLE}; - - match (a.kind(), b.kind()) { - (SHAPE_KIND_AABB, SHAPE_KIND_AABB) => { - let aabb_a = Aabb::from_shape(a, a_offset.0, a_offset.1); - let aabb_b = Aabb::from_shape(b, b_offset.0, b_offset.1); - aabb_overlap(&aabb_a, &aabb_b) - } - (SHAPE_KIND_CIRCLE, SHAPE_KIND_CIRCLE) => { - let circle_a = Circle::from_shape(a, a_offset.0, a_offset.1); - let circle_b = Circle::from_shape(b, b_offset.0, b_offset.1); - circle_overlap(&circle_a, &circle_b) - } - (SHAPE_KIND_AABB, SHAPE_KIND_CIRCLE) | (SHAPE_KIND_CIRCLE, SHAPE_KIND_AABB) => { - let (aabb, aabb_off, circle, circle_off) = if a.kind() == SHAPE_KIND_AABB { - (a, a_offset, b, b_offset) - } else { - (b, b_offset, a, a_offset) - }; - let aabb = Aabb::from_shape(aabb, aabb_off.0, aabb_off.1); - let circle = Circle::from_shape(circle, circle_off.0, circle_off.1); - aabb_circle_overlap(&aabb, &circle) - } - _ => false, // Capsule and rotated rect not yet supported - } -} -``` - -**Step 4: Add aabb_circle_overlap helper** - -```rust -/// Check if an AABB and circle overlap. -#[must_use] -#[inline] -pub fn aabb_circle_overlap(aabb: &Aabb, circle: &Circle) -> bool { - // Find closest point on AABB to circle center - let closest_x = (circle.x).clamp(aabb.x, aabb.x.saturating_add(aabb.w as i32)); - let closest_y = (circle.y).clamp(aabb.y, aabb.y.saturating_add(aabb.h as i32)); - - let dx = (circle.x as i64) - (closest_x as i64); - let dy = (circle.y as i64) - (closest_y as i64); - let dist_sq = dx * dx + dy * dy; - let r = circle.r as i64; - - dist_sq < r * r -} -``` - -**Step 5: Export Circle and circle_overlap from lib.rs** - -```rust -pub use collision::{check_hits, shapes_overlap, aabb_overlap, circle_overlap, aabb_circle_overlap, Aabb, Circle, HitResult, CheckHitsResult, MAX_HIT_RESULTS}; -``` - -**Step 6: Run all tests** - -Run: `cd crates/framesmith-runtime && cargo test` -Expected: PASS - -**Step 7: Commit** - -```bash -git add crates/framesmith-runtime/src/collision.rs crates/framesmith-runtime/src/lib.rs -git commit -m "feat(runtime): add circle collision detection" -``` - ---- - -## Task 5: Implement Capsule Overlap - -**Files:** -- Modify: `crates/framesmith-runtime/src/collision.rs` - -**Step 1: Write the test** - -```rust -#[test] -fn capsule_overlap_detects_intersection() { - // Two overlapping capsules (horizontal) - let a = Capsule { x1: 0, y1: 0, x2: 20, y2: 0, r: 5 }; - let b = Capsule { x1: 15, y1: 0, x2: 35, y2: 0, r: 5 }; - assert!(capsule_overlap(&a, &b)); -} - -#[test] -fn capsule_overlap_detects_no_intersection() { - // Two non-overlapping capsules - let a = Capsule { x1: 0, y1: 0, x2: 10, y2: 0, r: 5 }; - let b = Capsule { x1: 30, y1: 0, x2: 40, y2: 0, r: 5 }; - assert!(!capsule_overlap(&a, &b)); -} -``` - -**Step 2: Add Capsule struct** - -```rust -/// Capsule (line segment with radius) for collision detection. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct Capsule { - pub x1: i32, - pub y1: i32, - pub x2: i32, - pub y2: i32, - pub r: u32, -} - -impl Capsule { - /// Create a Capsule from a ShapeView at a given position offset. - pub fn from_shape(shape: &ShapeView, offset_x: i32, offset_y: i32) -> Self { - // Capsule uses a,b for p1, c,d for p2, e for radius - let x1 = shape.x_px() + offset_x; - let y1 = shape.y_px() + offset_y; - // c_raw and d_raw are x2,y2 for capsule - let x2 = (shape.c_raw() as i32 >> 4) + offset_x; - let y2 = (shape.d_raw() as i32 >> 4) + offset_y; - // e_raw is radius (Q8.8 format) - let r = ((shape.e_raw() as i32) >> 8).max(0) as u32; - Capsule { x1, y1, x2, y2, r } - } -} -``` - -**Step 3: Add segment distance helper** - -```rust -/// Compute squared distance between closest points on two line segments. -fn segment_distance_sq( - a1: (i64, i64), a2: (i64, i64), - b1: (i64, i64), b2: (i64, i64), -) -> i64 { - // Simplified: find closest point on each segment to the other - let closest_a = closest_point_on_segment(a1, a2, closest_point_on_segment(b1, b2, a1)); - let closest_b = closest_point_on_segment(b1, b2, closest_a); - let dx = closest_a.0 - closest_b.0; - let dy = closest_a.1 - closest_b.1; - dx * dx + dy * dy -} - -/// Find closest point on segment (p1, p2) to point p. -fn closest_point_on_segment(p1: (i64, i64), p2: (i64, i64), p: (i64, i64)) -> (i64, i64) { - let dx = p2.0 - p1.0; - let dy = p2.1 - p1.1; - let len_sq = dx * dx + dy * dy; - - if len_sq == 0 { - return p1; // Degenerate segment - } - - // Project p onto line, clamped to [0, 1] - let t_num = (p.0 - p1.0) * dx + (p.1 - p1.1) * dy; - let t = if t_num <= 0 { - 0 - } else if t_num >= len_sq { - len_sq - } else { - t_num - }; - - ( - p1.0 + (dx * t) / len_sq, - p1.1 + (dy * t) / len_sq, - ) -} -``` - -**Step 4: Add capsule_overlap function** - -```rust -/// Check if two capsules overlap. -#[must_use] -pub fn capsule_overlap(a: &Capsule, b: &Capsule) -> bool { - let a1 = (a.x1 as i64, a.y1 as i64); - let a2 = (a.x2 as i64, a.y2 as i64); - let b1 = (b.x1 as i64, b.y1 as i64); - let b2 = (b.x2 as i64, b.y2 as i64); - - let dist_sq = segment_distance_sq(a1, a2, b1, b2); - let radii_sum = (a.r as i64) + (b.r as i64); - - dist_sq < radii_sum * radii_sum -} -``` - -**Step 5: Update shapes_overlap to handle capsules** - -Add to the match in shapes_overlap: - -```rust - (SHAPE_KIND_CAPSULE, SHAPE_KIND_CAPSULE) => { - let cap_a = Capsule::from_shape(a, a_offset.0, a_offset.1); - let cap_b = Capsule::from_shape(b, b_offset.0, b_offset.1); - capsule_overlap(&cap_a, &cap_b) - } -``` - -Add the import at top of function: -```rust - use framesmith_fspack::{SHAPE_KIND_AABB, SHAPE_KIND_CIRCLE, SHAPE_KIND_CAPSULE}; -``` - -**Step 6: Export Capsule and capsule_overlap from lib.rs** - -```rust -pub use collision::{check_hits, shapes_overlap, aabb_overlap, circle_overlap, aabb_circle_overlap, capsule_overlap, Aabb, Circle, Capsule, HitResult, CheckHitsResult, MAX_HIT_RESULTS}; -``` - -**Step 7: Run all tests** - -Run: `cd crates/framesmith-runtime && cargo test` -Expected: PASS - -**Step 8: Commit** - -```bash -git add crates/framesmith-runtime/src/collision.rs crates/framesmith-runtime/src/lib.rs -git commit -m "feat(runtime): add capsule collision detection" -``` - ---- - -## Task 6: Remove Remaining TODO Comments - -**Files:** -- Modify: `crates/framesmith-runtime/src/frame.rs` -- Modify: `crates/framesmith-runtime/src/cancel.rs` -- Modify: `crates/framesmith-runtime/src/collision.rs` - -**Step 1: Remove TODO from frame.rs** - -The TODO was replaced with actual implementation in Task 1. Verify it's gone. - -**Step 2: Remove TODO from cancel.rs line 42** - -Replace with: -```rust - // Preconditions checked above -``` - -**Step 3: Remove TODO from cancel.rs line 64** - -Already replaced with implementation in Task 3. - -**Step 4: Remove TODO from cancel.rs line 81 (available_cancels)** - -Add precondition check and remove TODO: -```rust - if let Some(target) = cancels.get_at(off, i) { - // Filter by preconditions (timing windows not implemented) - if crate::resource::check_resource_preconditions(state, pack, target) { - result.push(target); - } - } -``` - -**Step 5: Remove TODO from collision.rs** - -The TODO was replaced with implementations in Tasks 4-5. Update comment: -```rust - _ => false, // Rotated rect not yet supported -``` - -**Step 6: Run all tests and clippy** - -Run: `cd crates/framesmith-runtime && cargo test && cargo clippy -- -D warnings` -Expected: PASS - -**Step 7: Commit** - -```bash -git add crates/framesmith-runtime/ -git commit -m "chore(runtime): remove remaining TODO comments" -``` - ---- - -## Verification - -After completing all tasks: - -1. **Run all tests:** - ```bash - cd crates/framesmith-runtime && cargo test - cd crates/framesmith-fspack && cargo test - ``` - -2. **Check no_std compatibility:** - ```bash - cd crates/framesmith-runtime && cargo check --no-default-features - ``` - -3. **Run clippy:** - ```bash - cd crates/framesmith-runtime && cargo clippy -- -D warnings - ``` - -4. **Verify no TODOs remain:** - ```bash - grep -r "TODO" crates/framesmith-runtime/src/ - ``` - Expected: No output - ---- - -## Future Tasks (Not in This Plan) - -- [ ] Add timing window filtering (requires HitWindow extended format) -- [ ] Add rotated rectangle overlap -- [ ] Add capsule-circle and capsule-AABB overlap -- [ ] Performance optimization for collision broad phase diff --git a/docs/plans/2026-01-31-framesmith-runtime.md b/docs/plans/2026-01-31-framesmith-runtime.md deleted file mode 100644 index 7e55e50..0000000 --- a/docs/plans/2026-01-31-framesmith-runtime.md +++ /dev/null @@ -1,1458 +0,0 @@ -# Framesmith Runtime Implementation Plan - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** Create a no_std stateless runtime library for simulating fighting game character state machines. - -**Architecture:** Pure functions operating on plain data structs. `CharacterState` holds move/frame/resources. `next_frame()` computes the next state without mutation. Cancel logic uses the pack's cancel table as single source of truth. Collision helpers resolve hitbox/hurtbox interactions and return attack data for games to apply. - -**Tech Stack:** Rust, no_std, depends on framesmith-fspack for data access. - ---- - -## Task 1: Create Crate Scaffold - -**Files:** -- Create: `crates/framesmith-runtime/Cargo.toml` -- Create: `crates/framesmith-runtime/src/lib.rs` - -**Step 1: Create Cargo.toml** - -```toml -[package] -name = "framesmith-runtime" -version = "0.1.0" -edition = "2021" -description = "no_std stateless runtime for fighting game character simulation" -license = "MIT OR Apache-2.0" - -[features] -default = [] -alloc = ["framesmith-fspack/alloc"] -std = ["alloc", "framesmith-fspack/std"] - -[dependencies] -framesmith-fspack = { path = "../framesmith-fspack" } - -[dev-dependencies] -``` - -**Step 2: Create lib.rs with module structure** - -```rust -#![no_std] - -#[cfg(feature = "alloc")] -extern crate alloc; - -pub mod state; -pub mod frame; -pub mod cancel; -pub mod collision; -pub mod resource; - -pub use state::{CharacterState, FrameInput, FrameResult}; -pub use frame::next_frame; -pub use cancel::{can_cancel_to, available_cancels}; -pub use collision::{check_hits, shapes_overlap, HitResult}; -pub use resource::{resource, set_resource}; - -#[cfg(test)] -extern crate std; -``` - -**Step 3: Verify crate compiles** - -Run: `cd crates/framesmith-runtime && cargo check` -Expected: Errors about missing modules (expected at this stage) - -**Step 4: Commit scaffold** - -```bash -git add crates/framesmith-runtime/ -git commit -m "feat(runtime): scaffold framesmith-runtime crate" -``` - ---- - -## Task 2: Implement CharacterState - -**Files:** -- Create: `crates/framesmith-runtime/src/state.rs` - -**Step 1: Write the test** - -```rust -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn character_state_is_copy_and_default() { - let state = CharacterState::default(); - let copy = state; // Copy - assert_eq!(state.current_move, copy.current_move); - assert_eq!(state.frame, 0); - assert!(!state.hit_confirmed); - assert!(!state.block_confirmed); - } - - #[test] - fn character_state_size_is_small() { - // State should be small enough for cheap copies (rollback netcode) - assert!(core::mem::size_of::() <= 32); - } -} -``` - -**Step 2: Run test to verify it fails** - -Run: `cd crates/framesmith-runtime && cargo test` -Expected: FAIL - CharacterState not defined - -**Step 3: Implement CharacterState** - -```rust -/// Maximum number of resource pools per character. -pub const MAX_RESOURCES: usize = 8; - -/// Character simulation state. -/// -/// This struct is intentionally small, `Copy`, and deterministic for: -/// - Cheap cloning (rollback netcode) -/// - No heap allocations (no_std compatible) -/// - Predictable simulation (no floats, no randomness) -#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)] -pub struct CharacterState { - /// Current move index (0 = idle by convention). - pub current_move: u16, - /// Current frame within the move (0-indexed). - pub frame: u8, - /// Move connected with a hit (opens on-hit cancel windows). - pub hit_confirmed: bool, - /// Move was blocked (opens on-block cancel windows). - pub block_confirmed: bool, - /// Resource pool values (meter, heat, ammo, etc.). - pub resources: [u16; MAX_RESOURCES], -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn character_state_is_copy_and_default() { - let state = CharacterState::default(); - let copy = state; - assert_eq!(state.current_move, copy.current_move); - assert_eq!(state.frame, 0); - assert!(!state.hit_confirmed); - assert!(!state.block_confirmed); - } - - #[test] - fn character_state_size_is_small() { - assert!(core::mem::size_of::() <= 32); - } -} -``` - -**Step 4: Run test to verify it passes** - -Run: `cd crates/framesmith-runtime && cargo test` -Expected: PASS - -**Step 5: Commit** - -```bash -git add crates/framesmith-runtime/src/state.rs -git commit -m "feat(runtime): add CharacterState struct" -``` - ---- - -## Task 3: Implement FrameInput and FrameResult - -**Files:** -- Modify: `crates/framesmith-runtime/src/state.rs` - -**Step 1: Write the test** - -```rust -#[test] -fn frame_input_default_has_no_requested_move() { - let input = FrameInput::default(); - assert!(input.requested_move.is_none()); -} - -#[test] -fn frame_result_can_hold_events() { - let result = FrameResult { - state: CharacterState::default(), - move_ended: false, - }; - assert!(!result.move_ended); -} -``` - -**Step 2: Run test to verify it fails** - -Run: `cd crates/framesmith-runtime && cargo test` -Expected: FAIL - FrameInput, FrameResult not defined - -**Step 3: Implement FrameInput and FrameResult** - -```rust -/// Input for a single frame of simulation. -#[derive(Clone, Copy, Debug, Default)] -pub struct FrameInput { - /// Move to transition to, if cancel is valid. - /// `None` means continue current move. - pub requested_move: Option, -} - -/// Result of simulating one frame. -#[derive(Clone, Copy, Debug)] -pub struct FrameResult { - /// The new character state after this frame. - pub state: CharacterState, - /// True if the move reached its final frame. - /// Game decides whether to loop or transition. - pub move_ended: bool, -} -``` - -**Step 4: Run test to verify it passes** - -Run: `cd crates/framesmith-runtime && cargo test` -Expected: PASS - -**Step 5: Commit** - -```bash -git add crates/framesmith-runtime/src/state.rs -git commit -m "feat(runtime): add FrameInput and FrameResult" -``` - ---- - -## Task 4: Implement Resource Helpers - -**Files:** -- Create: `crates/framesmith-runtime/src/resource.rs` - -**Step 1: Write the test** - -```rust -#[cfg(test)] -mod tests { - use super::*; - use crate::state::CharacterState; - - #[test] - fn get_and_set_resource() { - let mut state = CharacterState::default(); - assert_eq!(resource(&state, 0), 0); - - set_resource(&mut state, 0, 100); - assert_eq!(resource(&state, 0), 100); - - set_resource(&mut state, 7, 50); - assert_eq!(resource(&state, 7), 50); - } - - #[test] - fn out_of_bounds_resource_returns_zero() { - let state = CharacterState::default(); - assert_eq!(resource(&state, 8), 0); - assert_eq!(resource(&state, 255), 0); - } -} -``` - -**Step 2: Run test to verify it fails** - -Run: `cd crates/framesmith-runtime && cargo test` -Expected: FAIL - resource, set_resource not defined - -**Step 3: Implement resource helpers** - -```rust -use crate::state::{CharacterState, MAX_RESOURCES}; - -/// Get the current value of a resource by index. -/// -/// Returns 0 if the index is out of bounds. -#[inline] -pub fn resource(state: &CharacterState, index: u8) -> u16 { - state.resources.get(index as usize).copied().unwrap_or(0) -} - -/// Set a resource value by index. -/// -/// Does nothing if the index is out of bounds. -#[inline] -pub fn set_resource(state: &mut CharacterState, index: u8, value: u16) { - if let Some(slot) = state.resources.get_mut(index as usize) { - *slot = value; - } -} - -/// Initialize resources from pack's resource definitions. -pub fn init_resources(state: &mut CharacterState, pack: &framesmith_fspack::PackView) { - // Reset all to zero first - state.resources = [0; MAX_RESOURCES]; - - if let Some(defs) = pack.resource_defs() { - for i in 0..defs.len().min(MAX_RESOURCES) { - if let Some(def) = defs.get(i) { - state.resources[i] = def.start(); - } - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::state::CharacterState; - - #[test] - fn get_and_set_resource() { - let mut state = CharacterState::default(); - assert_eq!(resource(&state, 0), 0); - - set_resource(&mut state, 0, 100); - assert_eq!(resource(&state, 0), 100); - - set_resource(&mut state, 7, 50); - assert_eq!(resource(&state, 7), 50); - } - - #[test] - fn out_of_bounds_resource_returns_zero() { - let state = CharacterState::default(); - assert_eq!(resource(&state, 8), 0); - assert_eq!(resource(&state, 255), 0); - } -} -``` - -**Step 4: Run test to verify it passes** - -Run: `cd crates/framesmith-runtime && cargo test` -Expected: PASS - -**Step 5: Commit** - -```bash -git add crates/framesmith-runtime/src/resource.rs -git commit -m "feat(runtime): add resource get/set helpers" -``` - ---- - -## Task 5: Implement Basic next_frame (Frame Advancement Only) - -**Files:** -- Create: `crates/framesmith-runtime/src/frame.rs` - -**Step 1: Write the test** - -```rust -#[cfg(test)] -mod tests { - use super::*; - use crate::state::{CharacterState, FrameInput}; - - #[test] - fn next_frame_advances_frame_counter() { - let state = CharacterState { - current_move: 0, - frame: 5, - ..Default::default() - }; - let input = FrameInput::default(); - - // Without a pack, we need a mock or minimal test - // For now, test the pure frame advancement logic - let next = advance_frame_counter(&state); - assert_eq!(next.frame, 6); - assert_eq!(next.current_move, 0); - } - - #[test] - fn frame_counter_saturates_at_max() { - let state = CharacterState { - frame: 255, - ..Default::default() - }; - let next = advance_frame_counter(&state); - assert_eq!(next.frame, 255); // Saturates, doesn't wrap - } -} -``` - -**Step 2: Run test to verify it fails** - -Run: `cd crates/framesmith-runtime && cargo test` -Expected: FAIL - advance_frame_counter not defined - -**Step 3: Implement frame advancement** - -```rust -use crate::state::{CharacterState, FrameInput, FrameResult}; -use framesmith_fspack::PackView; - -/// Advance frame counter by 1, saturating at u8::MAX. -#[inline] -fn advance_frame_counter(state: &CharacterState) -> CharacterState { - CharacterState { - frame: state.frame.saturating_add(1), - ..*state - } -} - -/// Compute the next frame state for a character. -/// -/// This is a pure function - it does not mutate the input state. -/// The game decides whether to apply the returned state. -/// -/// # Arguments -/// * `state` - Current character state -/// * `pack` - Character data pack (moves, cancels, etc.) -/// * `input` - Frame input (requested move, etc.) -/// -/// # Returns -/// New state and whether the move ended this frame. -pub fn next_frame( - state: &CharacterState, - pack: &PackView, - input: &FrameInput, -) -> FrameResult { - // Try to transition if a move was requested - if let Some(target) = input.requested_move { - if crate::cancel::can_cancel_to(state, pack, target) { - let mut new_state = *state; - new_state.current_move = target; - new_state.frame = 0; - new_state.hit_confirmed = false; - new_state.block_confirmed = false; - // TODO: Apply resource costs - return FrameResult { - state: new_state, - move_ended: false, - }; - } - } - - // Advance frame - let new_state = advance_frame_counter(state); - - // Check if move ended - let move_ended = if let Some(moves) = pack.moves() { - if let Some(mv) = moves.get(state.current_move as usize) { - new_state.frame >= mv.total_frames() as u8 - } else { - false - } - } else { - false - }; - - FrameResult { - state: new_state, - move_ended, - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::state::{CharacterState, FrameInput}; - - #[test] - fn next_frame_advances_frame_counter() { - let state = CharacterState { - current_move: 0, - frame: 5, - ..Default::default() - }; - - let next = advance_frame_counter(&state); - assert_eq!(next.frame, 6); - assert_eq!(next.current_move, 0); - } - - #[test] - fn frame_counter_saturates_at_max() { - let state = CharacterState { - frame: 255, - ..Default::default() - }; - let next = advance_frame_counter(&state); - assert_eq!(next.frame, 255); - } -} -``` - -**Step 4: Run test to verify it passes** - -Run: `cd crates/framesmith-runtime && cargo test` -Expected: PASS - -**Step 5: Commit** - -```bash -git add crates/framesmith-runtime/src/frame.rs -git commit -m "feat(runtime): add next_frame with frame advancement" -``` - ---- - -## Task 6: Implement Cancel Logic (can_cancel_to) - -**Files:** -- Create: `crates/framesmith-runtime/src/cancel.rs` - -**Step 1: Write the test** - -```rust -#[cfg(test)] -mod tests { - use super::*; - use crate::state::CharacterState; - - #[test] - fn cannot_cancel_to_same_move_by_default() { - let state = CharacterState { - current_move: 5, - frame: 10, - ..Default::default() - }; - // Without pack data, can_cancel_to should return false - // This tests the fallback behavior - } -} -``` - -**Step 2: Run test to verify it fails** - -Run: `cd crates/framesmith-runtime && cargo test` -Expected: FAIL - cancel module not defined - -**Step 3: Implement can_cancel_to** - -```rust -use crate::state::CharacterState; -use framesmith_fspack::PackView; - -/// Check if a cancel from current state to target move is valid. -/// -/// This checks: -/// - Cancel routes from the cancel table -/// - Resource preconditions -/// - Hit/block state for on-hit/on-block cancels -/// -/// # Arguments -/// * `state` - Current character state -/// * `pack` - Character data pack -/// * `target` - Target move ID (or action ID if >= move_count) -/// -/// # Returns -/// `true` if the cancel is valid right now. -pub fn can_cancel_to( - state: &CharacterState, - pack: &PackView, - target: u16, -) -> bool { - let moves = match pack.moves() { - Some(m) => m, - None => return false, - }; - - let move_count = moves.len() as u16; - - // Check if target is a game-defined action (>= move_count) - // The runtime allows these; game decides if the action is valid - if target >= move_count { - // Check if current move allows this action via cancel flags - return check_action_cancel(state, pack, target - move_count); - } - - // Check chain cancels from move extras - if let Some(extras) = pack.move_extras() { - if let Some(extra) = extras.get(state.current_move as usize) { - if let Some(cancels) = pack.cancels() { - let (off, len) = extra.cancels(); - for i in 0..len as usize { - if let Some(cancel_target) = cancels.get_at(off, i) { - if cancel_target == target { - // TODO: Check timing windows and preconditions - return true; - } - } - } - } - } - } - - false -} - -/// Check if an action cancel is allowed (jump, dash, etc.). -fn check_action_cancel( - _state: &CharacterState, - _pack: &PackView, - _action_id: u16, -) -> bool { - // TODO: Check cancel flags on current move - // For now, delegate to game (return true and let game validate) - true -} - -/// Get all valid cancel targets from current state. -/// -/// Returns move IDs (< move_count) and action IDs (>= move_count). -#[cfg(feature = "alloc")] -pub fn available_cancels( - state: &CharacterState, - pack: &PackView, -) -> alloc::vec::Vec { - let mut result = alloc::vec::Vec::new(); - - if let Some(extras) = pack.move_extras() { - if let Some(extra) = extras.get(state.current_move as usize) { - if let Some(cancels) = pack.cancels() { - let (off, len) = extra.cancels(); - for i in 0..len as usize { - if let Some(target) = cancels.get_at(off, i) { - // TODO: Filter by timing window and preconditions - result.push(target); - } - } - } - } - } - - result -} - -/// Get available cancels into a fixed-size buffer. -/// -/// Returns the number of cancels written. -pub fn available_cancels_buf( - state: &CharacterState, - pack: &PackView, - buf: &mut [u16], -) -> usize { - let mut count = 0; - - if let Some(extras) = pack.move_extras() { - if let Some(extra) = extras.get(state.current_move as usize) { - if let Some(cancels) = pack.cancels() { - let (off, len) = extra.cancels(); - for i in 0..len as usize { - if count >= buf.len() { - break; - } - if let Some(target) = cancels.get_at(off, i) { - buf[count] = target; - count += 1; - } - } - } - } - } - - count -} - -#[cfg(test)] -mod tests { - #[test] - fn cancel_module_compiles() { - // Basic smoke test - assert!(true); - } -} -``` - -**Step 4: Run test to verify it passes** - -Run: `cd crates/framesmith-runtime && cargo test` -Expected: PASS - -**Step 5: Commit** - -```bash -git add crates/framesmith-runtime/src/cancel.rs -git commit -m "feat(runtime): add cancel logic (can_cancel_to, available_cancels)" -``` - ---- - -## Task 7: Implement Shape Overlap (AABB) - -**Files:** -- Create: `crates/framesmith-runtime/src/collision.rs` - -**Step 1: Write the test** - -```rust -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn aabb_overlap_detects_intersection() { - // Two overlapping boxes - let a = Aabb { x: 0, y: 0, w: 10, h: 10 }; - let b = Aabb { x: 5, y: 5, w: 10, h: 10 }; - assert!(aabb_overlap(&a, &b)); - } - - #[test] - fn aabb_overlap_detects_no_intersection() { - // Two non-overlapping boxes - let a = Aabb { x: 0, y: 0, w: 10, h: 10 }; - let b = Aabb { x: 20, y: 20, w: 10, h: 10 }; - assert!(!aabb_overlap(&a, &b)); - } - - #[test] - fn aabb_overlap_edge_touching_is_not_overlap() { - // Boxes touching at edge - let a = Aabb { x: 0, y: 0, w: 10, h: 10 }; - let b = Aabb { x: 10, y: 0, w: 10, h: 10 }; - assert!(!aabb_overlap(&a, &b)); - } -} -``` - -**Step 2: Run test to verify it fails** - -Run: `cd crates/framesmith-runtime && cargo test` -Expected: FAIL - Aabb, aabb_overlap not defined - -**Step 3: Implement AABB overlap** - -```rust -use crate::state::CharacterState; -use framesmith_fspack::{PackView, ShapeView, SHAPE_KIND_AABB}; - -/// Axis-aligned bounding box for collision detection. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct Aabb { - pub x: i32, - pub y: i32, - pub w: u32, - pub h: u32, -} - -impl Aabb { - /// Create an AABB from a ShapeView at a given position offset. - pub fn from_shape(shape: &ShapeView, offset_x: i32, offset_y: i32) -> Self { - Aabb { - x: shape.x_px() + offset_x, - y: shape.y_px() + offset_y, - w: shape.width_px(), - h: shape.height_px(), - } - } -} - -/// Check if two AABBs overlap. -/// -/// Edge-touching is NOT considered overlap. -#[inline] -pub fn aabb_overlap(a: &Aabb, b: &Aabb) -> bool { - let a_right = a.x.saturating_add(a.w as i32); - let a_bottom = a.y.saturating_add(a.h as i32); - let b_right = b.x.saturating_add(b.w as i32); - let b_bottom = b.y.saturating_add(b.h as i32); - - a.x < b_right && a_right > b.x && a.y < b_bottom && a_bottom > b.y -} - -/// Check if two shapes overlap. -/// -/// Currently only supports AABB shapes. -pub fn shapes_overlap( - a: &ShapeView, - a_offset: (i32, i32), - b: &ShapeView, - b_offset: (i32, i32), -) -> bool { - // For now, only handle AABB - if a.kind() == SHAPE_KIND_AABB && b.kind() == SHAPE_KIND_AABB { - let aabb_a = Aabb::from_shape(a, a_offset.0, a_offset.1); - let aabb_b = Aabb::from_shape(b, b_offset.0, b_offset.1); - return aabb_overlap(&aabb_a, &aabb_b); - } - - // TODO: Handle other shape types (circle, capsule, rotated rect) - false -} - -/// Result of a hit interaction. -#[derive(Clone, Copy, Debug)] -pub struct HitResult { - /// Move ID of the attacking move. - pub attacker_move: u16, - /// Index of the hit window that connected. - pub window_index: u16, - /// Damage value from the hit window. - pub damage: u16, - /// Chip damage (0 if not blocking). - pub chip_damage: u16, - /// Hitstun frames. - pub hitstun: u8, - /// Blockstun frames. - pub blockstun: u8, - /// Hitstop frames (for both attacker and defender). - pub hitstop: u8, - /// Guard type (high/mid/low). - pub guard: u8, - // TODO: Add pushback when HitWindow is expanded -} - -/// Check all hitbox vs hurtbox interactions between two characters. -/// -/// Returns hit results for the game to process. -pub fn check_hits( - attacker_state: &CharacterState, - attacker_pack: &PackView, - attacker_pos: (i32, i32), - defender_state: &CharacterState, - defender_pack: &PackView, - defender_pos: (i32, i32), -) -> CheckHitsResult { - let mut result = CheckHitsResult::new(); - - let attacker_frame = attacker_state.frame; - let defender_frame = defender_state.frame; - - // Get attacker's active hitboxes - let attacker_moves = match attacker_pack.moves() { - Some(m) => m, - None => return result, - }; - let attacker_move = match attacker_moves.get(attacker_state.current_move as usize) { - Some(m) => m, - None => return result, - }; - - // Get defender's active hurtboxes - let defender_moves = match defender_pack.moves() { - Some(m) => m, - None => return result, - }; - let defender_move = match defender_moves.get(defender_state.current_move as usize) { - Some(m) => m, - None => return result, - }; - - let hit_windows = match attacker_pack.hit_windows() { - Some(h) => h, - None => return result, - }; - let hurt_windows = match defender_pack.hurt_windows() { - Some(h) => h, - None => return result, - }; - let attacker_shapes = match attacker_pack.shapes() { - Some(s) => s, - None => return result, - }; - let defender_shapes = match defender_pack.shapes() { - Some(s) => s, - None => return result, - }; - - // Iterate attacker's hit windows active this frame - for hw_idx in 0..attacker_move.hit_windows_len() as usize { - let hw = match hit_windows.get_at(attacker_move.hit_windows_off(), hw_idx) { - Some(h) => h, - None => continue, - }; - - // Check if hit window is active this frame - if attacker_frame < hw.start_frame() || attacker_frame > hw.end_frame() { - continue; - } - - // Iterate defender's hurt windows active this frame - for hrt_idx in 0..defender_move.hurt_windows_len() as usize { - let hrt = match hurt_windows.get_at(defender_move.hurt_windows_off(), hrt_idx) { - Some(h) => h, - None => continue, - }; - - // Check if hurt window is active this frame - if defender_frame < hrt.start_frame() || defender_frame > hrt.end_frame() { - continue; - } - - // Check shape overlaps - if check_window_overlap( - &hw, &attacker_shapes, attacker_pos, - &hrt, &defender_shapes, defender_pos, - ) { - result.push(HitResult { - attacker_move: attacker_state.current_move, - window_index: hw_idx as u16, - damage: hw.damage(), - chip_damage: hw.chip_damage(), - hitstun: hw.hitstun(), - blockstun: hw.blockstun(), - hitstop: hw.hitstop(), - guard: hw.guard(), - }); - // Only one hit per hit window per frame - break; - } - } - } - - result -} - -/// Check if any hitbox shape overlaps any hurtbox shape. -fn check_window_overlap( - hit_window: &framesmith_fspack::HitWindowView, - hit_shapes: &framesmith_fspack::ShapesView, - hit_pos: (i32, i32), - hurt_window: &framesmith_fspack::HurtWindowView, - hurt_shapes: &framesmith_fspack::ShapesView, - hurt_pos: (i32, i32), -) -> bool { - for i in 0..hit_window.shapes_len() as usize { - let hit_shape = match hit_shapes.get_at(hit_window.shapes_off(), i) { - Some(s) => s, - None => continue, - }; - - for j in 0..hurt_window.shapes_len() as usize { - let hurt_shape = match hurt_shapes.get_at(hurt_window.shapes_off(), j) { - Some(s) => s, - None => continue, - }; - - if shapes_overlap(&hit_shape, hit_pos, &hurt_shape, hurt_pos) { - return true; - } - } - } - - false -} - -/// Fixed-capacity result buffer for hit checks (no_std friendly). -pub struct CheckHitsResult { - hits: [Option; 8], - count: usize, -} - -impl CheckHitsResult { - pub fn new() -> Self { - Self { - hits: [None; 8], - count: 0, - } - } - - pub fn push(&mut self, hit: HitResult) { - if self.count < 8 { - self.hits[self.count] = Some(hit); - self.count += 1; - } - } - - pub fn len(&self) -> usize { - self.count - } - - pub fn is_empty(&self) -> bool { - self.count == 0 - } - - pub fn get(&self, index: usize) -> Option<&HitResult> { - if index < self.count { - self.hits[index].as_ref() - } else { - None - } - } - - pub fn iter(&self) -> impl Iterator { - self.hits[..self.count].iter().filter_map(|h| h.as_ref()) - } -} - -impl Default for CheckHitsResult { - fn default() -> Self { - Self::new() - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn aabb_overlap_detects_intersection() { - let a = Aabb { x: 0, y: 0, w: 10, h: 10 }; - let b = Aabb { x: 5, y: 5, w: 10, h: 10 }; - assert!(aabb_overlap(&a, &b)); - } - - #[test] - fn aabb_overlap_detects_no_intersection() { - let a = Aabb { x: 0, y: 0, w: 10, h: 10 }; - let b = Aabb { x: 20, y: 20, w: 10, h: 10 }; - assert!(!aabb_overlap(&a, &b)); - } - - #[test] - fn aabb_overlap_edge_touching_is_not_overlap() { - let a = Aabb { x: 0, y: 0, w: 10, h: 10 }; - let b = Aabb { x: 10, y: 0, w: 10, h: 10 }; - assert!(!aabb_overlap(&a, &b)); - } - - #[test] - fn check_hits_result_capacity() { - let mut result = CheckHitsResult::new(); - assert!(result.is_empty()); - - for i in 0..10 { - result.push(HitResult { - attacker_move: i, - window_index: 0, - damage: 10, - chip_damage: 0, - hitstun: 10, - blockstun: 5, - hitstop: 3, - guard: 0, - }); - } - - // Should cap at 8 - assert_eq!(result.len(), 8); - } -} -``` - -**Step 4: Run test to verify it passes** - -Run: `cd crates/framesmith-runtime && cargo test` -Expected: PASS - -**Step 5: Commit** - -```bash -git add crates/framesmith-runtime/src/collision.rs -git commit -m "feat(runtime): add collision detection (AABB, check_hits)" -``` - ---- - -## Task 8: Implement Hit Reporting - -**Files:** -- Modify: `crates/framesmith-runtime/src/state.rs` - -**Step 1: Write the test** - -```rust -#[test] -fn report_hit_sets_flag() { - let mut state = CharacterState::default(); - assert!(!state.hit_confirmed); - - report_hit(&mut state); - assert!(state.hit_confirmed); -} - -#[test] -fn report_block_sets_flag() { - let mut state = CharacterState::default(); - assert!(!state.block_confirmed); - - report_block(&mut state); - assert!(state.block_confirmed); -} -``` - -**Step 2: Run test to verify it fails** - -Run: `cd crates/framesmith-runtime && cargo test` -Expected: FAIL - report_hit, report_block not defined - -**Step 3: Implement hit reporting** - -Add to `state.rs`: - -```rust -/// Report that the current move connected with a hit. -/// -/// This opens on-hit cancel windows. -#[inline] -pub fn report_hit(state: &mut CharacterState) { - state.hit_confirmed = true; -} - -/// Report that the current move was blocked. -/// -/// This opens on-block cancel windows. -#[inline] -pub fn report_block(state: &mut CharacterState) { - state.block_confirmed = true; -} -``` - -**Step 4: Run test to verify it passes** - -Run: `cd crates/framesmith-runtime && cargo test` -Expected: PASS - -**Step 5: Commit** - -```bash -git add crates/framesmith-runtime/src/state.rs -git commit -m "feat(runtime): add report_hit and report_block" -``` - ---- - -## Task 9: Update lib.rs Exports - -**Files:** -- Modify: `crates/framesmith-runtime/src/lib.rs` - -**Step 1: Write the test** - -```rust -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn public_api_is_accessible() { - let state = CharacterState::default(); - let _input = FrameInput::default(); - let _ = resource::resource(&state, 0); - } -} -``` - -**Step 2: Finalize lib.rs exports** - -```rust -#![no_std] - -#[cfg(feature = "alloc")] -extern crate alloc; - -pub mod cancel; -pub mod collision; -pub mod frame; -pub mod resource; -pub mod state; - -// Re-export main types -pub use state::{CharacterState, FrameInput, FrameResult, MAX_RESOURCES}; -pub use state::{report_hit, report_block}; -pub use frame::next_frame; -pub use cancel::can_cancel_to; -pub use collision::{check_hits, shapes_overlap, aabb_overlap, Aabb, HitResult, CheckHitsResult}; -pub use resource::{resource, set_resource, init_resources}; - -#[cfg(feature = "alloc")] -pub use cancel::available_cancels; - -// Re-export fspack for convenience -pub use framesmith_fspack::PackView; - -#[cfg(test)] -extern crate std; - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn public_api_is_accessible() { - let state = CharacterState::default(); - let _input = FrameInput::default(); - let _ = resource::resource(&state, 0); - } -} -``` - -**Step 3: Run all tests** - -Run: `cd crates/framesmith-runtime && cargo test` -Expected: PASS - -**Step 4: Run cargo clippy** - -Run: `cd crates/framesmith-runtime && cargo clippy -- -D warnings` -Expected: PASS (or fix any warnings) - -**Step 5: Commit** - -```bash -git add crates/framesmith-runtime/src/lib.rs -git commit -m "feat(runtime): finalize public API exports" -``` - ---- - -## Task 10: Add HitWindow Pushback to fspack - -**Files:** -- Modify: `crates/framesmith-fspack/src/view.rs` - -**Step 1: Write the test** - -Add test to `view.rs`: - -```rust -#[test] -fn hit_window_has_pushback_accessors() { - // Build a HitWindow32 with pushback data - let mut data = [0u8; 32]; - // Set hit_pushback at offset 24 (Q12.4: 32 = 2.0 pixels) - data[24] = 32; - data[25] = 0; - // Set block_pushback at offset 26 (Q12.4: 16 = 1.0 pixel) - data[26] = 16; - data[27] = 0; - - // For now, test placeholder methods -} -``` - -**Step 2: Add pushback accessors to HitWindowView** - -The current HitWindow is 24 bytes. We use the reserved byte at offset 3 and add new fields. For backwards compatibility, reads from shorter windows return 0. - -```rust -// Add to HitWindowView impl: - -/// Hit pushback (Q12.4 fixed-point). Returns 0 if not present. -pub fn hit_pushback_raw(&self) -> i16 { - if self.data.len() >= 26 { - read_u16_le(self.data, 24).unwrap_or(0) as i16 - } else { - 0 - } -} - -/// Block pushback (Q12.4 fixed-point). Returns 0 if not present. -pub fn block_pushback_raw(&self) -> i16 { - if self.data.len() >= 28 { - read_u16_le(self.data, 26).unwrap_or(0) as i16 - } else { - 0 - } -} - -/// Hit pushback in pixels. -pub fn hit_pushback_px(&self) -> i32 { - (self.hit_pushback_raw() as i32) >> 4 -} - -/// Block pushback in pixels. -pub fn block_pushback_px(&self) -> i32 { - (self.block_pushback_raw() as i32) >> 4 -} -``` - -**Step 3: Run tests** - -Run: `cd crates/framesmith-fspack && cargo test` -Expected: PASS - -**Step 4: Commit** - -```bash -git add crates/framesmith-fspack/src/view.rs -git commit -m "feat(fspack): add pushback accessors to HitWindowView" -``` - ---- - -## Task 11: Update HitResult to Include Pushback - -**Files:** -- Modify: `crates/framesmith-runtime/src/collision.rs` - -**Step 1: Update HitResult struct** - -```rust -/// Result of a hit interaction. -#[derive(Clone, Copy, Debug)] -pub struct HitResult { - /// Move ID of the attacking move. - pub attacker_move: u16, - /// Index of the hit window that connected. - pub window_index: u16, - /// Damage value from the hit window. - pub damage: u16, - /// Chip damage (0 if not blocking). - pub chip_damage: u16, - /// Hitstun frames. - pub hitstun: u8, - /// Blockstun frames. - pub blockstun: u8, - /// Hitstop frames (for both attacker and defender). - pub hitstop: u8, - /// Guard type (high/mid/low). - pub guard: u8, - /// Hit pushback in pixels (applied on hit). - pub hit_pushback: i32, - /// Block pushback in pixels (applied on block). - pub block_pushback: i32, -} -``` - -**Step 2: Update check_hits to populate pushback** - -Update the HitResult creation in `check_hits`: - -```rust -result.push(HitResult { - attacker_move: attacker_state.current_move, - window_index: hw_idx as u16, - damage: hw.damage(), - chip_damage: hw.chip_damage(), - hitstun: hw.hitstun(), - blockstun: hw.blockstun(), - hitstop: hw.hitstop(), - guard: hw.guard(), - hit_pushback: hw.hit_pushback_px(), - block_pushback: hw.block_pushback_px(), -}); -``` - -**Step 3: Run tests** - -Run: `cd crates/framesmith-runtime && cargo test` -Expected: PASS - -**Step 4: Commit** - -```bash -git add crates/framesmith-runtime/src/collision.rs -git commit -m "feat(runtime): add pushback to HitResult" -``` - ---- - -## Task 12: Integration Test with Real Pack Data - -**Files:** -- Create: `crates/framesmith-runtime/tests/integration.rs` - -**Step 1: Write integration test** - -```rust -//! Integration tests using real pack data. - -use framesmith_runtime::*; - -// This test requires a test fixture - skip for now if not available -#[test] -#[ignore = "requires test fixture"] -fn roundtrip_with_test_character() { - // TODO: Load test_char.fspk fixture - // let pack_data = include_bytes!("../fixtures/test_char.fspk"); - // let pack = PackView::parse(pack_data).unwrap(); - // - // let mut state = CharacterState::default(); - // init_resources(&mut state, &pack); - // - // // Simulate a few frames - // let input = FrameInput::default(); - // let result = next_frame(&state, &pack, &input); - // assert!(!result.move_ended); -} - -#[test] -fn state_is_deterministic() { - let state = CharacterState { - current_move: 5, - frame: 10, - hit_confirmed: true, - block_confirmed: false, - resources: [100, 50, 0, 0, 0, 0, 0, 0], - }; - - let copy1 = state; - let copy2 = state; - - assert_eq!(copy1, copy2); - assert_eq!(copy1.current_move, 5); - assert_eq!(copy1.frame, 10); - assert!(copy1.hit_confirmed); -} -``` - -**Step 2: Run test** - -Run: `cd crates/framesmith-runtime && cargo test` -Expected: PASS (ignored test doesn't run) - -**Step 3: Commit** - -```bash -git add crates/framesmith-runtime/tests/ -git commit -m "test(runtime): add integration test scaffold" -``` - ---- - -## Verification - -After completing all tasks: - -1. **Run all tests:** - ```bash - cd crates/framesmith-runtime && cargo test - cd crates/framesmith-fspack && cargo test - ``` - -2. **Check no_std compatibility:** - ```bash - cd crates/framesmith-runtime && cargo check --no-default-features - ``` - -3. **Run clippy:** - ```bash - cd crates/framesmith-runtime && cargo clippy -- -D warnings - ``` - -4. **Verify public API:** - ```rust - use framesmith_runtime::{ - CharacterState, FrameInput, FrameResult, - next_frame, can_cancel_to, check_hits, - report_hit, report_block, - resource, set_resource, init_resources, - }; - ``` - ---- - -## Future Tasks (Not in This Plan) - -- [ ] Add timing window checks to `can_cancel_to` -- [ ] Implement resource cost deduction in `next_frame` -- [ ] Add circle and capsule shape overlap -- [ ] Export pushback in `zx_fspack.rs` codegen -- [ ] Framesmith editor simulator integration -- [ ] Example game demonstrating runtime usage diff --git a/docs/plans/2026-01-31-training-mode-design.md b/docs/plans/2026-01-31-training-mode-design.md deleted file mode 100644 index 72b1b76..0000000 --- a/docs/plans/2026-01-31-training-mode-design.md +++ /dev/null @@ -1,334 +0,0 @@ -# Training Mode Design - -**Status:** Approved -**Created:** 2026-01-31 - -## Overview - -Training mode allows character creators to play as their character against a configurable dummy, providing immediate feedback on how the character feels and validating frame data, cancel routes, and combos. - -## Key Decisions - -| Aspect | Decision | -|--------|----------| -| **Goal** | Full training mode - play as character against configurable dummy | -| **Input methods** | Keyboard (real-time) + sequence recorder (precise combos) | -| **Dummy V1** | Stand, crouch, jump, block (standing/crouching/auto) | -| **Visuals** | Side-by-side 2D view, classic fighting game camera | -| **HUD** | Developer-focused: frame data, cancels, hitbox toggle, input history | -| **Location** | Fifth main view + detachable window for live editing workflow | -| **Data sync** | User toggle: live sync OR reload on save | -| **Runtime** | WASM in browser (compiled from framesmith-runtime) | -| **Input config** | Project-level in `framesmith.rules.json` | -| **Notation** | Numpad (industry standard, matches existing move files) | - -## Architecture - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ Framesmith Editor │ -│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────────┐ │ -│ │ Move Editor │ │ Frame Data │ │ Training Mode (View 5) │ │ -│ │ │ │ Table │ │ │ │ -│ └─────────────┘ └─────────────┘ └─────────────────────────┘ │ -│ │ │ │ │ -│ └────────────────┼──────────────────────┘ │ -│ ▼ │ -│ character.svelte.ts (store) │ -│ │ │ -│ ┌────────────────┼────────────────┐ │ -│ ▼ ▼ ▼ │ -│ Save to disk Live sync to Auto-reload to │ -│ (Tauri cmd) Training Mode detached window │ -└─────────────────────────────────────────────────────────────────┘ - │ - ┌────────────┴────────────┐ - ▼ ▼ - ┌─────────────────┐ ┌─────────────────────┐ - │ Training Mode │ │ Training Mode │ - │ (embedded view) │ │ (detached window) │ - └────────┬────────┘ └──────────┬──────────┘ - │ │ - └──────────┬───────────────┘ - ▼ - ┌─────────────────┐ - │ framesmith- │ - │ runtime.wasm │ - │ (in-browser) │ - └─────────────────┘ -``` - -## WASM Integration - -### New crate: `framesmith-runtime-wasm` - -Thin wrapper around `framesmith-runtime` exposing functions to JavaScript via `wasm-bindgen`. - -``` -crates/ - framesmith-runtime/ # Existing, no changes needed - framesmith-runtime-wasm/ # New wrapper crate - Cargo.toml - src/lib.rs # wasm-bindgen bindings -``` - -### TypeScript API - -```typescript -export class TrainingSession { - static new(player_fspk: Uint8Array, dummy_fspk: Uint8Array): TrainingSession; - tick(player_input: number, dummy_state: DummyState): FrameResult; - player_state(): CharacterState; - dummy_state(): CharacterState; - available_cancels(): number[]; - hit_results(): HitResult[]; -} - -export interface CharacterState { - current_move: number; - frame: number; - hit_confirmed: boolean; - block_confirmed: boolean; - resources: number[]; -} - -export interface FrameResult { - player: CharacterState; - dummy: CharacterState; - hits: HitResult[]; -} -``` - -## Input System - -### Project configuration (`framesmith.rules.json`) - -```json -{ - "training_inputs": { - "directions": { - "up": "KeyW", - "down": "KeyS", - "left": "KeyA", - "right": "KeyD" - }, - "buttons": { - "L": "KeyU", - "M": "KeyI", - "H": "KeyO", - "P": "KeyJ", - "K": "KeyK", - "S": "KeyL" - } - } -} -``` - -### Input handling flow - -``` -Keyboard Event (keydown/keyup) - │ - ▼ -┌─────────────────────┐ -│ InputManager.svelte │ Tracks held keys, converts to numpad + buttons -└─────────────────────┘ - │ - ▼ -┌─────────────────────┐ -│ Input Buffer │ Stores recent inputs, detects motions (236, etc.) -└─────────────────────┘ - │ - ▼ -┌─────────────────────┐ -│ Move Resolver │ Matches buffer to move names, checks available_cancels() -└─────────────────────┘ - │ - ▼ - Pass move index to WASM tick() -``` - -### Input sequence recorder - -```typescript -interface RecordedSequence { - name: string; - inputs: { frame: number; input: string }[]; -} -``` - -- Record keyboard inputs with frame timing -- Manually build sequences via UI -- Playback at real speed or frame-by-frame -- Save/load sequences to project folder - -## Dummy Behavior - -### States (V1) - -| State | Behavior | -|-------|----------| -| `stand` | Idle standing, takes hits standing | -| `crouch` | Crouch animation, takes hits crouching | -| `jump` | Jumps in place on loop, takes hits airborne | -| `block_stand` | Blocks standing (high) | -| `block_crouch` | Blocks crouching (low) | -| `block_auto` | Blocks high/low based on attack type | - -### Configuration - -```typescript -interface DummyConfig { - state: 'stand' | 'crouch' | 'jump' | 'block_stand' | 'block_crouch' | 'block_auto'; - recovery: 'neutral' | 'reversal'; - reversal_move?: string; - counter_on_hit: boolean; -} -``` - -## Visual Display & HUD - -``` -┌─────────────────────────────────────────────────────────────────────┐ -│ [Player Health ████████████] [Dummy Health ████████████]│ -│ [Meter ████░░░░] [Heat 3] [Meter ░░░░░░░░] │ -├─────────────────────────────────────────────────────────────────────┤ -│ │ -│ ┌─────────┐ ┌─────────┐ │ -│ │ Player │ │ Dummy │ │ -│ │ Sprite/ │ │ Sprite/ │ │ -│ │ GLTF │ │ GLTF │ │ -│ └─────────┘ └─────────┘ │ -│ │ -├─────────────────────────────────────────────────────────────────────┤ -│ 5L (3/2/9) frame 4 │ Standing │ Combo: 3 hits 847 │ -│ Cancels: 5M, 2M, 5H, 236P │ +6 on hit │ [Input History] │ -│ │ -2 on block │ ↓ 5L │ -│ [▶][⏸][⏮][⏭] Speed: 1x │ │ ↓ 5M │ -└─────────────────────────────────────────────────────────────────────┘ -``` - -### HUD elements - -| Element | Description | -|---------|-------------| -| Health bars | Damage visualization, resets on command | -| Resource meters | All character resources | -| Move name + frame data | Current move, startup/active/recovery | -| Frame counter | Current frame within move | -| Available cancels | Live list from `available_cancels()` | -| Frame advantage | On hit / on block | -| Combo display | Hit count + total damage | -| Input history | Scrolling list of recent inputs | -| Playback controls | Play/pause, step, speed | - -### Hitbox overlay (toggle) - -- Red = hitboxes (attack areas) -- Green = hurtboxes (vulnerable areas) -- Blue = pushboxes (collision) - -## Data Sync - -### Two modes (user toggle) - -| Mode | Behavior | Use case | -|------|----------|----------| -| **Live sync** | Changes reflect instantly as you edit | Rapid iteration | -| **Sync on save** | Reloads only on file save | Stability | - -### Detached window communication - -Uses `BroadcastChannel` API for cross-window sync: - -```typescript -const channel = new BroadcastChannel('framesmith-training-sync'); - -// Main window sends -channel.postMessage({ type: 'change', character: data }); - -// Detached window receives -channel.onmessage = (event) => reloadCharacterData(event.data.character); -``` - -## File Structure - -### New files - -``` -crates/ - framesmith-runtime-wasm/ - Cargo.toml - src/lib.rs - -src/lib/ - wasm/ - framesmith_runtime.js - framesmith_runtime.d.ts - framesmith_runtime_bg.wasm - - training/ - TrainingSession.ts - InputManager.svelte.ts - InputBuffer.ts - MoveResolver.ts - DummyController.ts - SequenceRecorder.ts - - components/ - training/ - TrainingViewport.svelte - TrainingHUD.svelte - DummySettings.svelte - InputHistory.svelte - PlaybackControls.svelte - SequencePanel.svelte - - views/ - TrainingMode.svelte -``` - -### Modified files - -- `src/routes/+page.svelte` - Add Training Mode to view switcher -- `src/lib/stores/character.svelte.ts` - Add change/save events -- `src-tauri/src/lib.rs` - Command to open detached window -- `framesmith.rules.json` schema - Add `training_inputs` -- `package.json` - Add wasm-pack build scripts - -## Implementation Phases - -### Phase 1: WASM Foundation -- Create `framesmith-runtime-wasm` crate -- Set up wasm-pack build pipeline -- TypeScript wrapper (`TrainingSession.ts`) -- Verify: load FSPK → tick → get state - -### Phase 2: Input System -- `InputManager` - keyboard handling -- `InputBuffer` - motion detection -- `MoveResolver` - match inputs to moves -- Project config for `training_inputs` - -### Phase 3: Core Training View -- `TrainingViewport` - render player + dummy -- Basic `TrainingHUD` - health, resources, move name -- `DummyController` - state machine -- Add as fifth view - -### Phase 4: Developer Overlay -- Frame counter, cancel windows, frame advantage -- Hitbox/hurtbox overlay toggle -- Input history display -- Combo counter + damage - -### Phase 5: Detached Window + Sync -- Tauri command for detached window -- BroadcastChannel sync -- Live / save sync toggle - -### Phase 6: Sequence Recorder -- Record inputs with timing -- Manual sequence builder -- Playback controls -- Save/load sequences diff --git a/docs/plans/2026-02-01-global-states-design.md b/docs/plans/2026-02-01-global-states-design.md deleted file mode 100644 index 7d42c63..0000000 --- a/docs/plans/2026-02-01-global-states-design.md +++ /dev/null @@ -1,2916 +0,0 @@ -# Global States Implementation Plan - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** Add support for global states - shared state definitions at the project level that characters can opt into with explicit includes and shallow-merge overrides. - -**Architecture:** Global states are stored in `globals/states/` at the project root. Characters opt-in via `globals.json` with explicit includes. Resolution happens during character loading, before rules application. Shallow merge semantics keep the mental model simple. - -**Tech Stack:** Rust (Tauri backend), TypeScript/Svelte frontend, Vitest for TS tests, inline Rust tests. - ---- - -## Task 1: Add GlobalInclude Schema Types - -**Files:** -- Modify: `src-tauri/src/schema/mod.rs` - -**Step 1: Write the failing test for GlobalInclude deserialization** - -Add to the `#[cfg(test)] mod tests` section at the end of the file: - -```rust -#[test] -fn global_include_basic() { - let json = r#"{ "state": "burst", "as": "burst" }"#; - let include: GlobalInclude = serde_json::from_str(json).unwrap(); - assert_eq!(include.state, "burst"); - assert_eq!(include.alias, "burst"); - assert!(include.overrides.is_none()); -} - -#[test] -fn global_include_with_override() { - let json = r#"{ - "state": "idle", - "as": "idle", - "override": { "animation": "ryu_idle" } - }"#; - let include: GlobalInclude = serde_json::from_str(json).unwrap(); - assert_eq!(include.state, "idle"); - assert!(include.overrides.is_some()); - let overrides = include.overrides.unwrap(); - assert_eq!(overrides.get("animation").unwrap(), "ryu_idle"); -} - -#[test] -fn globals_manifest_deserialization() { - let json = r#"{ - "includes": [ - { "state": "burst", "as": "burst" }, - { "state": "idle", "as": "idle", "override": { "animation": "ryu_idle" } } - ] - }"#; - let manifest: GlobalsManifest = serde_json::from_str(json).unwrap(); - assert_eq!(manifest.includes.len(), 2); -} -``` - -**Step 2: Run tests to verify they fail** - -Run: `cd src-tauri && cargo test global_include` -Expected: FAIL - "cannot find type `GlobalInclude`" - -**Step 3: Implement GlobalInclude and GlobalsManifest types** - -Add after the `CancelTable` struct (around line 280): - -```rust -/// A reference to a global state with optional overrides -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct GlobalInclude { - /// Name of the global state file (without .json) - pub state: String, - /// Alias for this character (the input name to use) - #[serde(rename = "as")] - pub alias: String, - /// Optional field overrides (shallow merge) - #[serde(rename = "override", skip_serializing_if = "Option::is_none")] - pub overrides: Option>, -} - -/// Character's global state manifest (globals.json) -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct GlobalsManifest { - pub includes: Vec, -} -``` - -**Step 4: Run tests to verify they pass** - -Run: `cd src-tauri && cargo test global_include` -Expected: All 3 tests PASS - -**Step 5: Commit** - -```bash -cd src-tauri && git add src/schema/mod.rs -git commit -m "$(cat <<'EOF' -feat(schema): add GlobalInclude and GlobalsManifest types - -Add schema types for global states opt-in system: -- GlobalInclude: references a global state with alias and optional overrides -- GlobalsManifest: character's globals.json structure - -Co-Authored-By: Claude Opus 4.5 -EOF -)" -``` - ---- - -## Task 2: Add Global State Loading Functions - -**Files:** -- Create: `src-tauri/src/globals/mod.rs` -- Modify: `src-tauri/src/lib.rs` (add module declaration) - -**Step 1: Create the globals module with failing test** - -Create `src-tauri/src/globals/mod.rs`: - -```rust -//! Global states loading and resolution - -use crate::schema::{GlobalsManifest, State}; -use std::collections::HashMap; -use std::path::Path; - -/// Errors that can occur during global state operations -#[derive(Debug, Clone)] -pub enum GlobalsError { - /// Global state file not found - NotFound { state: String }, - /// Alias conflicts with a local state - AliasConflict { alias: String }, - /// Duplicate alias in includes - DuplicateAlias { alias: String }, - /// IO error reading file - IoError { path: String, message: String }, - /// JSON parse error - ParseError { path: String, message: String }, -} - -impl std::fmt::Display for GlobalsError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - GlobalsError::NotFound { state } => { - write!(f, "Global state '{}' not found in globals/states/", state) - } - GlobalsError::AliasConflict { alias } => { - write!(f, "Global alias '{}' conflicts with local state file", alias) - } - GlobalsError::DuplicateAlias { alias } => { - write!(f, "Duplicate global alias '{}' in globals.json", alias) - } - GlobalsError::IoError { path, message } => { - write!(f, "Failed to read '{}': {}", path, message) - } - GlobalsError::ParseError { path, message } => { - write!(f, "Failed to parse '{}': {}", path, message) - } - } - } -} - -impl std::error::Error for GlobalsError {} - -/// Load the globals manifest for a character -/// -/// Returns None if globals.json doesn't exist (globals are optional) -pub fn load_globals_manifest(character_dir: &Path) -> Result, GlobalsError> { - let manifest_path = character_dir.join("globals.json"); - if !manifest_path.exists() { - return Ok(None); - } - - let content = std::fs::read_to_string(&manifest_path).map_err(|e| GlobalsError::IoError { - path: manifest_path.display().to_string(), - message: e.to_string(), - })?; - - let manifest: GlobalsManifest = - serde_json::from_str(&content).map_err(|e| GlobalsError::ParseError { - path: manifest_path.display().to_string(), - message: e.to_string(), - })?; - - Ok(Some(manifest)) -} - -/// Load a single global state from the project's globals/states/ directory -pub fn load_global_state(project_dir: &Path, state_name: &str) -> Result { - let state_path = project_dir.join("globals").join("states").join(format!("{}.json", state_name)); - - if !state_path.exists() { - return Err(GlobalsError::NotFound { - state: state_name.to_string(), - }); - } - - let content = std::fs::read_to_string(&state_path).map_err(|e| GlobalsError::IoError { - path: state_path.display().to_string(), - message: e.to_string(), - })?; - - let state: State = serde_json::from_str(&content).map_err(|e| GlobalsError::ParseError { - path: state_path.display().to_string(), - message: e.to_string(), - })?; - - Ok(state) -} - -/// List all available global states in a project -pub fn list_global_states(project_dir: &Path) -> Result, GlobalsError> { - let globals_dir = project_dir.join("globals").join("states"); - - if !globals_dir.exists() { - return Ok(Vec::new()); - } - - let mut states = Vec::new(); - let entries = std::fs::read_dir(&globals_dir).map_err(|e| GlobalsError::IoError { - path: globals_dir.display().to_string(), - message: e.to_string(), - })?; - - for entry in entries { - let entry = entry.map_err(|e| GlobalsError::IoError { - path: globals_dir.display().to_string(), - message: e.to_string(), - })?; - - let path = entry.path(); - if path.extension().map_or(false, |ext| ext == "json") { - if let Some(stem) = path.file_stem() { - states.push(stem.to_string_lossy().to_string()); - } - } - } - - states.sort(); - Ok(states) -} - -#[cfg(test)] -mod tests { - use super::*; - use std::fs; - use tempfile::TempDir; - - fn create_test_project() -> TempDir { - let dir = TempDir::new().unwrap(); - - // Create globals/states directory - let globals_dir = dir.path().join("globals").join("states"); - fs::create_dir_all(&globals_dir).unwrap(); - - // Create a test global state - let burst_state = r#"{ - "id": "burst", - "input": "burst", - "name": "Burst", - "type": "system", - "startup": 12, - "active": 4, - "recovery": 24, - "total": 40 - }"#; - fs::write(globals_dir.join("burst.json"), burst_state).unwrap(); - - // Create character directory - let char_dir = dir.path().join("characters").join("test_char"); - fs::create_dir_all(&char_dir).unwrap(); - - dir - } - - #[test] - fn load_globals_manifest_missing_returns_none() { - let dir = create_test_project(); - let char_dir = dir.path().join("characters").join("test_char"); - - let result = load_globals_manifest(&char_dir).unwrap(); - assert!(result.is_none()); - } - - #[test] - fn load_globals_manifest_exists() { - let dir = create_test_project(); - let char_dir = dir.path().join("characters").join("test_char"); - - let manifest = r#"{ "includes": [{ "state": "burst", "as": "burst" }] }"#; - fs::write(char_dir.join("globals.json"), manifest).unwrap(); - - let result = load_globals_manifest(&char_dir).unwrap(); - assert!(result.is_some()); - assert_eq!(result.unwrap().includes.len(), 1); - } - - #[test] - fn load_global_state_success() { - let dir = create_test_project(); - - let state = load_global_state(dir.path(), "burst").unwrap(); - assert_eq!(state.input, "burst"); - assert_eq!(state.startup, Some(12)); - } - - #[test] - fn load_global_state_not_found() { - let dir = create_test_project(); - - let result = load_global_state(dir.path(), "nonexistent"); - assert!(matches!(result, Err(GlobalsError::NotFound { .. }))); - } - - #[test] - fn list_global_states_empty() { - let dir = TempDir::new().unwrap(); - let states = list_global_states(dir.path()).unwrap(); - assert!(states.is_empty()); - } - - #[test] - fn list_global_states_found() { - let dir = create_test_project(); - let states = list_global_states(dir.path()).unwrap(); - assert_eq!(states, vec!["burst"]); - } -} -``` - -**Step 2: Add module declaration to lib.rs** - -In `src-tauri/src/lib.rs`, add after `mod variant;`: - -```rust -mod globals; -``` - -**Step 3: Run tests to verify they pass** - -Run: `cd src-tauri && cargo test globals::` -Expected: All 6 tests PASS - -**Step 4: Commit** - -```bash -cd src-tauri && git add src/globals/mod.rs src/lib.rs -git commit -m "$(cat <<'EOF' -feat(globals): add global state loading functions - -Add module for loading global states: -- load_globals_manifest: loads character's globals.json (optional) -- load_global_state: loads a single global state from project -- list_global_states: lists all available global states -- GlobalsError: error types with descriptive messages - -Co-Authored-By: Claude Opus 4.5 -EOF -)" -``` - ---- - -## Task 3: Implement Shallow Merge for State Overrides - -**Files:** -- Modify: `src-tauri/src/globals/mod.rs` - -**Step 1: Write failing tests for apply_overrides** - -Add to the `#[cfg(test)] mod tests` section: - -```rust -#[test] -fn apply_overrides_replaces_top_level_field() { - let base = State { - input: "idle".to_string(), - startup: Some(5), - ..Default::default() - }; - - let mut overrides = serde_json::Map::new(); - overrides.insert("startup".to_string(), serde_json::json!(10)); - - let result = apply_overrides(base, &overrides, "idle").unwrap(); - assert_eq!(result.startup, Some(10)); - assert_eq!(result.input, "idle"); // unchanged -} - -#[test] -fn apply_overrides_replaces_array_entirely() { - let base = State { - input: "idle".to_string(), - tags: Some(vec!["normal".to_string(), "ground".to_string()]), - ..Default::default() - }; - - let mut overrides = serde_json::Map::new(); - overrides.insert("tags".to_string(), serde_json::json!(["special"])); - - let result = apply_overrides(base, &overrides, "idle").unwrap(); - assert_eq!(result.tags, Some(vec!["special".to_string()])); -} - -#[test] -fn apply_overrides_replaces_nested_object() { - let base = State { - input: "idle".to_string(), - movement: Some(crate::schema::Movement { - x: Some(10.0), - y: Some(5.0), - ..Default::default() - }), - ..Default::default() - }; - - let mut overrides = serde_json::Map::new(); - overrides.insert("movement".to_string(), serde_json::json!({ "x": 20.0 })); - - let result = apply_overrides(base, &overrides, "idle").unwrap(); - let movement = result.movement.unwrap(); - assert_eq!(movement.x, Some(20.0)); - assert!(movement.y.is_none()); // replaced entirely, not merged -} - -#[test] -fn apply_overrides_sets_alias_as_input() { - let base = State { - input: "walk_forward".to_string(), - ..Default::default() - }; - - let overrides = serde_json::Map::new(); - let result = apply_overrides(base, &overrides, "66").unwrap(); - assert_eq!(result.input, "66"); // alias replaces input -} - -#[test] -fn apply_overrides_null_removes_field() { - let base = State { - input: "idle".to_string(), - startup: Some(5), - ..Default::default() - }; - - let mut overrides = serde_json::Map::new(); - overrides.insert("startup".to_string(), serde_json::Value::Null); - - let result = apply_overrides(base, &overrides, "idle").unwrap(); - assert!(result.startup.is_none()); -} -``` - -**Step 2: Run tests to verify they fail** - -Run: `cd src-tauri && cargo test apply_overrides` -Expected: FAIL - "cannot find function `apply_overrides`" - -**Step 3: Implement apply_overrides function** - -Add before the `#[cfg(test)]` section in `src-tauri/src/globals/mod.rs`: - -```rust -/// Apply overrides to a global state using shallow merge semantics -/// -/// - Top-level fields are replaced entirely -/// - Arrays are replaced (not concatenated) -/// - Nested objects are replaced as units (not deep merged) -/// - Null values remove the field -/// - The alias becomes the new input -pub fn apply_overrides( - base: State, - overrides: &serde_json::Map, - alias: &str, -) -> Result { - // Convert base to JSON, apply overrides, convert back - let mut base_json = serde_json::to_value(&base).map_err(|e| GlobalsError::ParseError { - path: "state serialization".to_string(), - message: e.to_string(), - })?; - - if let serde_json::Value::Object(ref mut map) = base_json { - for (key, value) in overrides { - if value.is_null() { - map.remove(key); - } else { - map.insert(key.clone(), value.clone()); - } - } - // Always set input to alias - map.insert("input".to_string(), serde_json::Value::String(alias.to_string())); - } - - let result: State = serde_json::from_value(base_json).map_err(|e| GlobalsError::ParseError { - path: "state deserialization".to_string(), - message: e.to_string(), - })?; - - Ok(result) -} -``` - -**Step 4: Run tests to verify they pass** - -Run: `cd src-tauri && cargo test apply_overrides` -Expected: All 5 tests PASS - -**Step 5: Commit** - -```bash -cd src-tauri && git add src/globals/mod.rs -git commit -m "$(cat <<'EOF' -feat(globals): implement shallow merge for state overrides - -Add apply_overrides function with shallow merge semantics: -- Top-level fields replaced entirely -- Arrays replaced (not concatenated) -- Nested objects replaced as units -- Null values remove fields -- Alias becomes the new input - -Co-Authored-By: Claude Opus 4.5 -EOF -)" -``` - ---- - -## Task 4: Implement Full Global Resolution with Validation - -**Files:** -- Modify: `src-tauri/src/globals/mod.rs` - -**Step 1: Write failing tests for resolve_globals** - -Add to the `#[cfg(test)] mod tests` section: - -```rust -#[test] -fn resolve_globals_basic() { - let dir = create_test_project(); - let char_dir = dir.path().join("characters").join("test_char"); - - let manifest = r#"{ "includes": [{ "state": "burst", "as": "burst" }] }"#; - fs::write(char_dir.join("globals.json"), manifest).unwrap(); - - let local_inputs: std::collections::HashSet = std::collections::HashSet::new(); - - let (states, warnings) = resolve_globals(dir.path(), &char_dir, &local_inputs).unwrap(); - assert_eq!(states.len(), 1); - assert_eq!(states[0].input, "burst"); - assert!(warnings.is_empty()); -} - -#[test] -fn resolve_globals_with_alias() { - let dir = create_test_project(); - let char_dir = dir.path().join("characters").join("test_char"); - - let manifest = r#"{ "includes": [{ "state": "burst", "as": "reversal" }] }"#; - fs::write(char_dir.join("globals.json"), manifest).unwrap(); - - let local_inputs: std::collections::HashSet = std::collections::HashSet::new(); - - let (states, _) = resolve_globals(dir.path(), &char_dir, &local_inputs).unwrap(); - assert_eq!(states[0].input, "reversal"); -} - -#[test] -fn resolve_globals_conflict_with_local() { - let dir = create_test_project(); - let char_dir = dir.path().join("characters").join("test_char"); - - let manifest = r#"{ "includes": [{ "state": "burst", "as": "5L" }] }"#; - fs::write(char_dir.join("globals.json"), manifest).unwrap(); - - let mut local_inputs: std::collections::HashSet = std::collections::HashSet::new(); - local_inputs.insert("5L".to_string()); - - let result = resolve_globals(dir.path(), &char_dir, &local_inputs); - assert!(matches!(result, Err(GlobalsError::AliasConflict { .. }))); -} - -#[test] -fn resolve_globals_duplicate_alias() { - let dir = create_test_project(); - let char_dir = dir.path().join("characters").join("test_char"); - - // Add another global state - let idle_state = r#"{ "id": "idle", "input": "idle", "name": "Idle" }"#; - fs::write(dir.path().join("globals/states/idle.json"), idle_state).unwrap(); - - let manifest = r#"{ "includes": [ - { "state": "burst", "as": "same_alias" }, - { "state": "idle", "as": "same_alias" } - ] }"#; - fs::write(char_dir.join("globals.json"), manifest).unwrap(); - - let local_inputs: std::collections::HashSet = std::collections::HashSet::new(); - - let result = resolve_globals(dir.path(), &char_dir, &local_inputs); - assert!(matches!(result, Err(GlobalsError::DuplicateAlias { .. }))); -} - -#[test] -fn resolve_globals_warns_on_unknown_override_field() { - let dir = create_test_project(); - let char_dir = dir.path().join("characters").join("test_char"); - - let manifest = r#"{ "includes": [{ - "state": "burst", - "as": "burst", - "override": { "nonexistent_field": 123 } - }] }"#; - fs::write(char_dir.join("globals.json"), manifest).unwrap(); - - let local_inputs: std::collections::HashSet = std::collections::HashSet::new(); - - let (_, warnings) = resolve_globals(dir.path(), &char_dir, &local_inputs).unwrap(); - assert!(!warnings.is_empty()); - assert!(warnings[0].contains("nonexistent_field")); -} - -#[test] -fn resolve_globals_no_manifest_returns_empty() { - let dir = create_test_project(); - let char_dir = dir.path().join("characters").join("test_char"); - // No globals.json created - - let local_inputs: std::collections::HashSet = std::collections::HashSet::new(); - - let (states, warnings) = resolve_globals(dir.path(), &char_dir, &local_inputs).unwrap(); - assert!(states.is_empty()); - assert!(warnings.is_empty()); -} -``` - -**Step 2: Run tests to verify they fail** - -Run: `cd src-tauri && cargo test resolve_globals` -Expected: FAIL - "cannot find function `resolve_globals`" - -**Step 3: Implement resolve_globals function** - -Add before the `#[cfg(test)]` section: - -```rust -use std::collections::HashSet; - -/// Known State field names for override validation -const KNOWN_STATE_FIELDS: &[&str] = &[ - "id", "input", "name", "type", "tags", "base", - "startup", "active", "recovery", "total", - "damage", "chip_damage", "hitstun", "blockstun", "hitstop", - "guard", "animation", "animation_offset", - "hitboxes", "hurtboxes", "pushboxes", - "movement", "on_hit", "on_block", "on_use", - "hit", "precondition", "cost", "meter_gain", "notifies", - "can_be_canceled_by", "meter_on_hit", "meter_on_whiff", -]; - -/// Resolve all global states for a character -/// -/// Returns (resolved_states, warnings) -/// - resolved_states: Global states with overrides applied -/// - warnings: Non-fatal issues (unknown override fields) -pub fn resolve_globals( - project_dir: &Path, - character_dir: &Path, - local_inputs: &HashSet, -) -> Result<(Vec, Vec), GlobalsError> { - let manifest = match load_globals_manifest(character_dir)? { - Some(m) => m, - None => return Ok((Vec::new(), Vec::new())), - }; - - let mut resolved = Vec::new(); - let mut warnings = Vec::new(); - let mut seen_aliases = HashSet::new(); - - for include in &manifest.includes { - // Check for duplicate aliases - if seen_aliases.contains(&include.alias) { - return Err(GlobalsError::DuplicateAlias { - alias: include.alias.clone(), - }); - } - seen_aliases.insert(include.alias.clone()); - - // Check for conflict with local states - if local_inputs.contains(&include.alias) { - return Err(GlobalsError::AliasConflict { - alias: include.alias.clone(), - }); - } - - // Load the global state - let base_state = load_global_state(project_dir, &include.state)?; - - // Validate override fields and collect warnings - if let Some(ref overrides) = include.overrides { - for key in overrides.keys() { - if !KNOWN_STATE_FIELDS.contains(&key.as_str()) { - warnings.push(format!( - "Override field '{}' not present in global state '{}'", - key, include.state - )); - } - } - } - - // Apply overrides - let empty_overrides = serde_json::Map::new(); - let overrides = include.overrides.as_ref().unwrap_or(&empty_overrides); - let resolved_state = apply_overrides(base_state, overrides, &include.alias)?; - - resolved.push(resolved_state); - } - - Ok((resolved, warnings)) -} -``` - -**Step 4: Run tests to verify they pass** - -Run: `cd src-tauri && cargo test resolve_globals` -Expected: All 6 tests PASS - -**Step 5: Run all globals tests** - -Run: `cd src-tauri && cargo test globals::` -Expected: All 17 tests PASS - -**Step 6: Commit** - -```bash -cd src-tauri && git add src/globals/mod.rs -git commit -m "$(cat <<'EOF' -feat(globals): implement full global resolution with validation - -Add resolve_globals function that: -- Loads character's globals.json manifest -- Validates for duplicate aliases -- Validates for conflicts with local states -- Warns on unknown override fields -- Applies overrides and returns resolved states - -Co-Authored-By: Claude Opus 4.5 -EOF -)" -``` - ---- - -## Task 5: Integrate Globals into Character Loading - -**Files:** -- Modify: `src-tauri/src/commands.rs` -- Modify: `src-tauri/src/globals/mod.rs` (make public) - -**Step 1: Make globals module public** - -In `src-tauri/src/lib.rs`, change: - -```rust -mod globals; -``` - -to: - -```rust -pub mod globals; -``` - -**Step 2: Update load_character_files to return local inputs** - -In `src-tauri/src/commands.rs`, modify the `load_character_files` function signature and return type. Add to the return tuple tracking of local inputs: - -First, find the current function (around line 43) and note its return type. We need to also return a `HashSet` of local state inputs. - -**Step 3: Integrate globals into load_character** - -In `src-tauri/src/commands.rs`, in the `load_character` function (around line 214), after loading local states but before flattening variants: - -```rust -// After: let (char_path, character, named_states, cancel_table) = load_character_files(...)?; - -// Collect local inputs for conflict detection -let local_inputs: std::collections::HashSet = named_states - .iter() - .map(|(_, state)| state.input.clone()) - .collect(); - -// Resolve global states -let project_dir = char_path.parent().and_then(|p| p.parent()).ok_or_else(|| { - "Could not determine project directory".to_string() -})?; - -let (global_states, global_warnings) = crate::globals::resolve_globals( - project_dir, - &char_path, - &local_inputs, -).map_err(|e| e.to_string())?; - -// Log warnings (could also return them to frontend) -for warning in &global_warnings { - eprintln!("Warning: {}", warning); -} - -// Combine: local states + global states (as named tuples for variant processing) -let mut all_named_states = named_states; -for state in global_states { - all_named_states.push((state.input.clone(), state)); -} - -// Continue with flatten_variants using all_named_states instead of named_states -``` - -**Step 4: Write integration test** - -Create `src-tauri/tests/globals_integration.rs`: - -```rust -//! Integration tests for global states - -use std::fs; -use tempfile::TempDir; - -fn create_test_project_with_globals() -> TempDir { - let dir = TempDir::new().unwrap(); - - // Create project structure - fs::create_dir_all(dir.path().join("globals/states")).unwrap(); - fs::create_dir_all(dir.path().join("characters/test_char/states")).unwrap(); - - // Project rules - fs::write( - dir.path().join("framesmith.rules.json"), - r#"{ "version": 1 }"#, - ).unwrap(); - - // Global state - fs::write( - dir.path().join("globals/states/burst.json"), - r#"{ - "id": "burst", - "input": "burst", - "name": "Burst", - "type": "system", - "startup": 12, - "active": 4, - "recovery": 24, - "total": 40 - }"#, - ).unwrap(); - - // Character - fs::write( - dir.path().join("characters/test_char/character.json"), - r#"{ "id": "test_char", "name": "Test Character", "health": 10000 }"#, - ).unwrap(); - - // Local state - fs::write( - dir.path().join("characters/test_char/states/5L.json"), - r#"{ "input": "5L", "name": "Light Attack", "startup": 5 }"#, - ).unwrap(); - - // Cancel table - fs::write( - dir.path().join("characters/test_char/cancel_table.json"), - r#"{ "cancels": [] }"#, - ).unwrap(); - - // Globals manifest - fs::write( - dir.path().join("characters/test_char/globals.json"), - r#"{ "includes": [{ "state": "burst", "as": "burst" }] }"#, - ).unwrap(); - - dir -} - -#[test] -fn test_character_includes_global_states() { - // This test verifies that global states are included in character loading - // Note: This would require importing the Tauri commands which may need - // additional setup. For now, we test the globals module directly. - - use std::collections::HashSet; - - let dir = create_test_project_with_globals(); - let project_dir = dir.path(); - let char_dir = project_dir.join("characters/test_char"); - - let local_inputs: HashSet = ["5L".to_string()].into_iter().collect(); - - let (globals, warnings) = framesmith::globals::resolve_globals( - project_dir, - &char_dir, - &local_inputs, - ).unwrap(); - - assert_eq!(globals.len(), 1); - assert_eq!(globals[0].input, "burst"); - assert!(warnings.is_empty()); -} -``` - -**Step 5: Run the integration test** - -Run: `cd src-tauri && cargo test globals_integration` -Expected: PASS - -**Step 6: Run clippy** - -Run: `cd src-tauri && cargo clippy --all-targets` -Expected: No warnings - -**Step 7: Commit** - -```bash -cd src-tauri && git add src/lib.rs src/commands.rs tests/globals_integration.rs src/globals/mod.rs -git commit -m "$(cat <<'EOF' -feat(commands): integrate global states into character loading - -Global states are now resolved during character loading: -- Loaded after local states -- Conflict detection with local inputs -- Combined before variant flattening -- Warnings logged for unknown override fields - -Co-Authored-By: Claude Opus 4.5 -EOF -)" -``` - ---- - -## Task 6: Add Global States to Export Pipeline - -**Files:** -- Modify: `src-tauri/src/codegen/json_blob.rs` -- Modify: `src-tauri/src/codegen/zx_fspack.rs` (if needed) - -**Step 1: Verify exports already work** - -Since globals are resolved before export and become regular states, the export should already include them. Write a test to verify: - -In `src-tauri/src/codegen/json_blob.rs`, add to tests: - -```rust -#[test] -fn export_includes_global_derived_states() { - // Global states become regular states after resolution, - // so they should be included in exports automatically. - // This test documents that expectation. - - use crate::schema::{State, Character, CancelTable}; - - let states = vec![ - State { - id: Some("5L".to_string()), - input: "5L".to_string(), - name: Some("Light Attack".to_string()), - ..Default::default() - }, - State { - id: Some("burst".to_string()), - input: "burst".to_string(), - name: Some("Burst".to_string()), - r#type: Some("system".to_string()), - ..Default::default() - }, - ]; - - let character = Character { - id: "test".to_string(), - name: "Test".to_string(), - ..Default::default() - }; - - let cancel_table = CancelTable::default(); - - let json = export_json_blob(&states, &character, &cancel_table); - - // Verify both states are in the export - assert!(json.contains("\"5L\"")); - assert!(json.contains("\"burst\"")); - assert!(json.contains("\"system\"")); // type field from global -} -``` - -**Step 2: Run the test** - -Run: `cd src-tauri && cargo test export_includes_global` -Expected: PASS - -**Step 3: Commit** - -```bash -cd src-tauri && git add src/codegen/json_blob.rs -git commit -m "$(cat <<'EOF' -test(codegen): verify global states included in exports - -Add test documenting that global-derived states are included -in exports since they become regular states after resolution. - -Co-Authored-By: Claude Opus 4.5 -EOF -)" -``` - ---- - -## Task 7: Add MCP Tools for Global States - -**Files:** -- Modify: `src-tauri/src/bin/mcp.rs` - -**Step 1: Add list_global_states tool** - -In the MCP server, add a new tool parameter struct and handler: - -```rust -#[derive(Debug, Deserialize, JsonSchema)] -pub struct ListGlobalStatesParam {} - -// In the tool handler match: -"list_global_states" => { - let project_dir = get_project_dir()?; - let states = framesmith::globals::list_global_states(&project_dir) - .map_err(|e| mcp_error(format!("Failed to list global states: {}", e)))?; - - let result: Vec = states - .iter() - .map(|name| { - let state = framesmith::globals::load_global_state(&project_dir, name).ok(); - serde_json::json!({ - "id": name, - "name": state.as_ref().and_then(|s| s.name.clone()), - "type": state.as_ref().and_then(|s| s.r#type.clone()), - }) - }) - .collect(); - - Ok(CallToolResult::success(vec![Content::text( - serde_json::to_string_pretty(&result).unwrap(), - )])) -} -``` - -**Step 2: Add get_global_state tool** - -```rust -#[derive(Debug, Deserialize, JsonSchema)] -pub struct GlobalStateIdParam { - /// The ID of the global state - pub id: String, -} - -// In the tool handler match: -"get_global_state" => { - let params: GlobalStateIdParam = serde_json::from_value(arguments.clone()) - .map_err(|e| mcp_error(format!("Invalid parameters: {}", e)))?; - - let project_dir = get_project_dir()?; - let state = framesmith::globals::load_global_state(&project_dir, ¶ms.id) - .map_err(|e| mcp_error(format!("Failed to load global state: {}", e)))?; - - Ok(CallToolResult::success(vec![Content::text( - serde_json::to_string_pretty(&state).unwrap(), - )])) -} -``` - -**Step 3: Add create_global_state tool** - -```rust -#[derive(Debug, Deserialize, JsonSchema)] -pub struct CreateGlobalStateParam { - /// The ID for the new global state - pub id: String, - /// The state data - pub state: serde_json::Value, -} - -// In the tool handler match: -"create_global_state" => { - let params: CreateGlobalStateParam = serde_json::from_value(arguments.clone()) - .map_err(|e| mcp_error(format!("Invalid parameters: {}", e)))?; - - let project_dir = get_project_dir()?; - let globals_dir = project_dir.join("globals").join("states"); - - // Create directory if needed - std::fs::create_dir_all(&globals_dir) - .map_err(|e| mcp_error(format!("Failed to create globals directory: {}", e)))?; - - let state_path = globals_dir.join(format!("{}.json", params.id)); - - if state_path.exists() { - return Err(mcp_error(format!("Global state '{}' already exists", params.id))); - } - - // Validate state structure - let _state: framesmith::schema::State = serde_json::from_value(params.state.clone()) - .map_err(|e| mcp_error(format!("Invalid state data: {}", e)))?; - - std::fs::write(&state_path, serde_json::to_string_pretty(¶ms.state).unwrap()) - .map_err(|e| mcp_error(format!("Failed to write global state: {}", e)))?; - - Ok(CallToolResult::success(vec![Content::text( - format!("Created global state '{}'", params.id), - )])) -} -``` - -**Step 4: Add update_global_state tool** - -```rust -#[derive(Debug, Deserialize, JsonSchema)] -pub struct UpdateGlobalStateParam { - /// The ID of the global state to update - pub id: String, - /// The updated state data - pub state: serde_json::Value, -} - -// In the tool handler match: -"update_global_state" => { - let params: UpdateGlobalStateParam = serde_json::from_value(arguments.clone()) - .map_err(|e| mcp_error(format!("Invalid parameters: {}", e)))?; - - let project_dir = get_project_dir()?; - let state_path = project_dir.join("globals").join("states").join(format!("{}.json", params.id)); - - if !state_path.exists() { - return Err(mcp_error(format!("Global state '{}' not found", params.id))); - } - - // Validate state structure - let _state: framesmith::schema::State = serde_json::from_value(params.state.clone()) - .map_err(|e| mcp_error(format!("Invalid state data: {}", e)))?; - - std::fs::write(&state_path, serde_json::to_string_pretty(¶ms.state).unwrap()) - .map_err(|e| mcp_error(format!("Failed to write global state: {}", e)))?; - - Ok(CallToolResult::success(vec![Content::text( - format!("Updated global state '{}'", params.id), - )])) -} -``` - -**Step 5: Add delete_global_state tool** - -```rust -#[derive(Debug, Deserialize, JsonSchema)] -pub struct DeleteGlobalStateParam { - /// The ID of the global state to delete - pub id: String, -} - -// In the tool handler match: -"delete_global_state" => { - let params: DeleteGlobalStateParam = serde_json::from_value(arguments.clone()) - .map_err(|e| mcp_error(format!("Invalid parameters: {}", e)))?; - - let project_dir = get_project_dir()?; - let state_path = project_dir.join("globals").join("states").join(format!("{}.json", params.id)); - - if !state_path.exists() { - return Err(mcp_error(format!("Global state '{}' not found", params.id))); - } - - // Check if any characters reference this state - let characters_dir = project_dir.join("characters"); - if characters_dir.exists() { - for entry in std::fs::read_dir(&characters_dir).map_err(|e| mcp_error(e.to_string()))? { - let entry = entry.map_err(|e| mcp_error(e.to_string()))?; - let globals_path = entry.path().join("globals.json"); - if globals_path.exists() { - let content = std::fs::read_to_string(&globals_path) - .map_err(|e| mcp_error(e.to_string()))?; - if content.contains(&format!("\"state\": \"{}\"", params.id)) { - return Err(mcp_error(format!( - "Cannot delete: global state '{}' is referenced by character '{}'", - params.id, - entry.file_name().to_string_lossy() - ))); - } - } - } - } - - std::fs::remove_file(&state_path) - .map_err(|e| mcp_error(format!("Failed to delete global state: {}", e)))?; - - Ok(CallToolResult::success(vec![Content::text( - format!("Deleted global state '{}'", params.id), - )])) -} -``` - -**Step 6: Register tools in list_tools** - -Add to the tools vector in the `list_tools` handler: - -```rust -ToolInfo { - name: "list_global_states".into(), - description: Some("List all global states in the project".into()), - input_schema: schemars::schema_for!(ListGlobalStatesParam).schema.into(), -}, -ToolInfo { - name: "get_global_state".into(), - description: Some("Get a specific global state by ID".into()), - input_schema: schemars::schema_for!(GlobalStateIdParam).schema.into(), -}, -ToolInfo { - name: "create_global_state".into(), - description: Some("Create a new global state".into()), - input_schema: schemars::schema_for!(CreateGlobalStateParam).schema.into(), -}, -ToolInfo { - name: "update_global_state".into(), - description: Some("Update an existing global state".into()), - input_schema: schemars::schema_for!(UpdateGlobalStateParam).schema.into(), -}, -ToolInfo { - name: "delete_global_state".into(), - description: Some("Delete a global state (checks for references first)".into()), - input_schema: schemars::schema_for!(DeleteGlobalStateParam).schema.into(), -}, -``` - -**Step 7: Build and verify** - -Run: `cd src-tauri && cargo build --bin mcp` -Expected: Build succeeds - -**Step 8: Commit** - -```bash -cd src-tauri && git add src/bin/mcp.rs -git commit -m "$(cat <<'EOF' -feat(mcp): add global state management tools - -Add MCP tools for managing global states: -- list_global_states: list all globals in project -- get_global_state: get a specific global by ID -- create_global_state: create new global with validation -- update_global_state: update existing global -- delete_global_state: delete with reference checking - -Co-Authored-By: Claude Opus 4.5 -EOF -)" -``` - ---- - -## Task 8: Add Tauri Commands for Frontend - -**Files:** -- Modify: `src-tauri/src/commands.rs` -- Modify: `src-tauri/src/lib.rs` (register commands) - -**Step 1: Add list_global_states command** - -In `src-tauri/src/commands.rs`, add: - -```rust -#[derive(Debug, Clone, Serialize)] -pub struct GlobalStateSummary { - pub id: String, - pub name: Option, - pub r#type: Option, -} - -#[tauri::command] -pub fn list_global_states(project_path: String) -> Result, String> { - let project_dir = std::path::Path::new(&project_path); - - let state_ids = crate::globals::list_global_states(project_dir) - .map_err(|e| e.to_string())?; - - let mut summaries = Vec::new(); - for id in state_ids { - let state = crate::globals::load_global_state(project_dir, &id) - .map_err(|e| e.to_string())?; - summaries.push(GlobalStateSummary { - id, - name: state.name, - r#type: state.r#type, - }); - } - - Ok(summaries) -} -``` - -**Step 2: Add get_global_state command** - -```rust -#[tauri::command] -pub fn get_global_state(project_path: String, state_id: String) -> Result { - let project_dir = std::path::Path::new(&project_path); - crate::globals::load_global_state(project_dir, &state_id) - .map_err(|e| e.to_string()) -} -``` - -**Step 3: Add save_global_state command** - -```rust -#[tauri::command] -pub fn save_global_state( - project_path: String, - state_id: String, - state: schema::State, -) -> Result<(), String> { - let project_dir = std::path::Path::new(&project_path); - let globals_dir = project_dir.join("globals").join("states"); - - std::fs::create_dir_all(&globals_dir) - .map_err(|e| format!("Failed to create globals directory: {}", e))?; - - let state_path = globals_dir.join(format!("{}.json", state_id)); - let json = serde_json::to_string_pretty(&state) - .map_err(|e| format!("Failed to serialize state: {}", e))?; - - std::fs::write(&state_path, json) - .map_err(|e| format!("Failed to write global state: {}", e))?; - - Ok(()) -} -``` - -**Step 4: Add delete_global_state command** - -```rust -#[tauri::command] -pub fn delete_global_state(project_path: String, state_id: String) -> Result<(), String> { - let project_dir = std::path::Path::new(&project_path); - let state_path = project_dir.join("globals").join("states").join(format!("{}.json", state_id)); - - if !state_path.exists() { - return Err(format!("Global state '{}' not found", state_id)); - } - - // Check for references - let characters_dir = project_dir.join("characters"); - if characters_dir.exists() { - for entry in std::fs::read_dir(&characters_dir).map_err(|e| e.to_string())? { - let entry = entry.map_err(|e| e.to_string())?; - let globals_path = entry.path().join("globals.json"); - if globals_path.exists() { - let content = std::fs::read_to_string(&globals_path).map_err(|e| e.to_string())?; - if content.contains(&format!("\"state\": \"{}\"", state_id)) { - return Err(format!( - "Cannot delete: global state '{}' is referenced by character '{}'", - state_id, - entry.file_name().to_string_lossy() - )); - } - } - } - } - - std::fs::remove_file(&state_path) - .map_err(|e| format!("Failed to delete global state: {}", e))?; - - Ok(()) -} -``` - -**Step 5: Register commands in lib.rs** - -In `src-tauri/src/lib.rs`, add to the `invoke_handler` list: - -```rust -commands::list_global_states, -commands::get_global_state, -commands::save_global_state, -commands::delete_global_state, -``` - -**Step 6: Build and test** - -Run: `cd src-tauri && cargo build` -Expected: Build succeeds - -**Step 7: Commit** - -```bash -cd src-tauri && git add src/commands.rs src/lib.rs -git commit -m "$(cat <<'EOF' -feat(commands): add Tauri commands for global states - -Add frontend-accessible commands: -- list_global_states: list all with summaries -- get_global_state: get full state data -- save_global_state: create or update -- delete_global_state: delete with reference checking - -Co-Authored-By: Claude Opus 4.5 -EOF -)" -``` - ---- - -## Task 9: Add TypeScript Types for Globals - -**Files:** -- Modify: `src/lib/types.ts` - -**Step 1: Add GlobalInclude type** - -In `src/lib/types.ts`, add after the existing types: - -```typescript -/** A reference to a global state with optional overrides */ -export interface GlobalInclude { - /** Name of the global state file (without .json) */ - state: string; - /** Alias for this character (the input name to use) */ - as: string; - /** Optional field overrides (shallow merge) */ - override?: Record; -} - -/** Character's global state manifest */ -export interface GlobalsManifest { - includes: GlobalInclude[]; -} - -/** Summary of a global state for listing */ -export interface GlobalStateSummary { - id: string; - name?: string; - type?: string; -} -``` - -**Step 2: Run type checking** - -Run: `npm run check` -Expected: No errors - -**Step 3: Commit** - -```bash -git add src/lib/types.ts -git commit -m "$(cat <<'EOF' -feat(types): add TypeScript types for global states - -Add GlobalInclude, GlobalsManifest, and GlobalStateSummary -types matching the Rust schema. - -Co-Authored-By: Claude Opus 4.5 -EOF -)" -``` - ---- - -## Task 10: Add Globals Store - -**Files:** -- Create: `src/lib/stores/globals.svelte.ts` - -**Step 1: Create the globals store** - -```typescript -/** - * Store for managing global states at the project level - */ -import { invoke } from '@tauri-apps/api/core'; -import type { State, GlobalStateSummary } from '$lib/types'; -import { getProjectPath } from './project.svelte'; - -// Reactive state -let globalStateList = $state([]); -let currentGlobalState = $state(null); -let selectedGlobalId = $state(null); -let loading = $state(false); -let error = $state(null); - -// Getters -export function getGlobalStateList(): GlobalStateSummary[] { - return globalStateList; -} - -export function getCurrentGlobalState(): State | null { - return currentGlobalState; -} - -export function getSelectedGlobalId(): string | null { - return selectedGlobalId; -} - -export function isLoading(): boolean { - return loading; -} - -export function getError(): string | null { - return error; -} - -// Actions -export async function loadGlobalStateList(): Promise { - const projectPath = getProjectPath(); - if (!projectPath) { - globalStateList = []; - return; - } - - loading = true; - error = null; - - try { - globalStateList = await invoke('list_global_states', { - projectPath, - }); - } catch (e) { - error = e instanceof Error ? e.message : String(e); - globalStateList = []; - } finally { - loading = false; - } -} - -export async function selectGlobalState(id: string | null): Promise { - selectedGlobalId = id; - - if (!id) { - currentGlobalState = null; - return; - } - - const projectPath = getProjectPath(); - if (!projectPath) { - error = 'No project open'; - return; - } - - loading = true; - error = null; - - try { - currentGlobalState = await invoke('get_global_state', { - projectPath, - stateId: id, - }); - } catch (e) { - error = e instanceof Error ? e.message : String(e); - currentGlobalState = null; - } finally { - loading = false; - } -} - -export async function saveGlobalState(id: string, state: State): Promise { - const projectPath = getProjectPath(); - if (!projectPath) { - error = 'No project open'; - return false; - } - - loading = true; - error = null; - - try { - await invoke('save_global_state', { - projectPath, - stateId: id, - state, - }); - - // Refresh list - await loadGlobalStateList(); - - // If this is the current state, refresh it - if (selectedGlobalId === id) { - await selectGlobalState(id); - } - - return true; - } catch (e) { - error = e instanceof Error ? e.message : String(e); - return false; - } finally { - loading = false; - } -} - -export async function deleteGlobalState(id: string): Promise { - const projectPath = getProjectPath(); - if (!projectPath) { - error = 'No project open'; - return false; - } - - loading = true; - error = null; - - try { - await invoke('delete_global_state', { - projectPath, - stateId: id, - }); - - // Clear selection if deleted - if (selectedGlobalId === id) { - selectedGlobalId = null; - currentGlobalState = null; - } - - // Refresh list - await loadGlobalStateList(); - - return true; - } catch (e) { - error = e instanceof Error ? e.message : String(e); - return false; - } finally { - loading = false; - } -} - -export async function createGlobalState(id: string, state: State): Promise { - return saveGlobalState(id, state); -} - -// Reset on project change -export function resetGlobalsStore(): void { - globalStateList = []; - currentGlobalState = null; - selectedGlobalId = null; - loading = false; - error = null; -} -``` - -**Step 2: Run type checking** - -Run: `npm run check` -Expected: No errors - -**Step 3: Commit** - -```bash -git add src/lib/stores/globals.svelte.ts -git commit -m "$(cat <<'EOF' -feat(stores): add globals store for project-level states - -Add reactive store for managing global states: -- List, select, save, delete operations -- Integrates with project store -- Error handling and loading states - -Co-Authored-By: Claude Opus 4.5 -EOF -)" -``` - ---- - -## Task 11: Create GlobalStateList Component - -**Files:** -- Create: `src/lib/components/GlobalStateList.svelte` - -**Step 1: Create the component** - -```svelte - - -
-
-

Global States

- 🌐 -
- - {#if loading} -
Loading...
- {:else if globalStates.length === 0} -
-

No global states defined.

-

Create globals in globals/states/

-
- {:else} -
    - {#each globalStates as state (state.id)} -
  • handleSelect(state.id)} - onkeydown={(e) => e.key === 'Enter' && handleSelect(state.id)} - role="button" - tabindex="0" - > -
    - {state.id} - {#if state.type} - {state.type} - {/if} -
    - {#if state.name && state.name !== state.id} - {state.name} - {/if} - -
  • - {/each} -
- {/if} -
- -{#if showDeleteConfirm} - -{/if} - - -``` - -**Step 2: Run type checking** - -Run: `npm run check` -Expected: No errors - -**Step 3: Commit** - -```bash -git add src/lib/components/GlobalStateList.svelte -git commit -m "$(cat <<'EOF' -feat(ui): add GlobalStateList component - -Display project-wide global states with: -- Visual indicator for global states -- Selection highlighting -- Delete with confirmation -- Empty state guidance - -Co-Authored-By: Claude Opus 4.5 -EOF -)" -``` - ---- - -## Task 12: Create GlobalStateEditor Component - -**Files:** -- Create: `src/lib/components/GlobalStateEditor.svelte` - -**Step 1: Create the component** - -```svelte - - -
- {#if !editingState} -
-

Select a global state to edit

-
- {:else} -
-

- 🌐 - {selectedId} -

- {#if hasChanges} - Unsaved changes - {/if} -
- - {#if saveError} -
{saveError}
- {/if} - -
{ e.preventDefault(); handleSave(); }}> -
- - handleFieldChange('name', e.currentTarget.value || null)} - /> -
- -
- - handleFieldChange('type', e.currentTarget.value || null)} - placeholder="e.g., system, normal, special" - /> -
- -
-
- - handleFieldChange('startup', e.currentTarget.valueAsNumber || null)} - /> -
- -
- - handleFieldChange('active', e.currentTarget.valueAsNumber || null)} - /> -
- -
- - handleFieldChange('recovery', e.currentTarget.valueAsNumber || null)} - /> -
- -
- - handleFieldChange('total', e.currentTarget.valueAsNumber || null)} - /> -
-
- -
- - { - const tags = e.currentTarget.value - .split(',') - .map(t => t.trim()) - .filter(t => t.length > 0); - handleFieldChange('tags', tags.length > 0 ? tags : null); - }} - placeholder="e.g., invulnerable, reversal" - /> -
- -
- - handleFieldChange('animation', e.currentTarget.value || null)} - /> -
- -
- - -
-
- {/if} -
- - -``` - -**Step 2: Run type checking** - -Run: `npm run check` -Expected: No errors - -**Step 3: Commit** - -```bash -git add src/lib/components/GlobalStateEditor.svelte -git commit -m "$(cat <<'EOF' -feat(ui): add GlobalStateEditor component - -Form-based editor for global states with: -- Basic frame data fields -- Tag editing -- Unsaved changes tracking -- Save/revert actions - -Co-Authored-By: Claude Opus 4.5 -EOF -)" -``` - ---- - -## Task 13: Create GlobalsManager View - -**Files:** -- Create: `src/lib/views/GlobalsManager.svelte` - -**Step 1: Create the view** - -```svelte - - -
- -
- -
-
- - -``` - -**Step 2: Run type checking** - -Run: `npm run check` -Expected: No errors - -**Step 3: Commit** - -```bash -git add src/lib/views/GlobalsManager.svelte -git commit -m "$(cat <<'EOF' -feat(views): add GlobalsManager view - -Compose GlobalStateList and GlobalStateEditor into -a full management view for project-level global states. - -Co-Authored-By: Claude Opus 4.5 -EOF -)" -``` - ---- - -## Task 14: Add Globals Tab to Main Layout - -**Files:** -- Modify: `src/routes/+page.svelte` or main layout file - -**Step 1: Find the main layout structure** - -Explore the current tab/navigation structure in the app to understand where to add the Globals tab. - -**Step 2: Add navigation for Globals Manager** - -This step depends on the existing UI structure. The goal is to add a "Globals" tab/link that navigates to the GlobalsManager view. Example integration: - -```svelte - - - - - -``` - -**Step 3: Create route if using file-based routing** - -If using SvelteKit routes, create `src/routes/globals/+page.svelte`: - -```svelte - - - -``` - -**Step 4: Run and verify** - -Run: `npm run dev` -Verify: Navigate to globals view, see empty state message. - -**Step 5: Commit** - -```bash -git add src/routes/globals/+page.svelte # or relevant files -git commit -m "$(cat <<'EOF' -feat(ui): add Globals tab to main navigation - -Users can now access the GlobalsManager view to browse -and edit project-wide global states. - -Co-Authored-By: Claude Opus 4.5 -EOF -)" -``` - ---- - -## Task 15: Update State List to Show Global-Derived States - -**Files:** -- Modify: `src/lib/views/FrameDataTable.svelte` (or equivalent state list) - -**Step 1: Update to show global indicator** - -In the state list component, add visual indication for states that came from globals. This requires the backend to pass through origin information, or we detect by checking if the state's input matches a known global include. - -For now, add a simple visual indicator column: - -```svelte - -Origin - - - - {#if state.type === 'system'} - 🌐 - {:else} - Local - {/if} - -``` - -**Step 2: Run and verify** - -Run: `npm run dev` -Verify: States show origin indicator - -**Step 3: Commit** - -```bash -git add src/lib/views/FrameDataTable.svelte -git commit -m "$(cat <<'EOF' -feat(ui): show global indicator in state list - -Display 🌐 indicator for states originating from globals -(currently detected by type="system" field). - -Co-Authored-By: Claude Opus 4.5 -EOF -)" -``` - ---- - -## Task 16: Add Character Globals Editor - -**Files:** -- Create: `src/lib/components/CharacterGlobalsEditor.svelte` - -**Step 1: Create the component for editing character's globals.json** - -```svelte - - -
-

Global State Includes

- - {#if error} -
{error}
- {/if} - - {#if loading} -

Loading...

- {:else} -
- {#each manifest.includes as include, index (index)} -
- {include.state} - - -
- {/each} -
- -
- -
- -
- -
- {/if} -
- - -``` - -**Step 2: Add to character overview or settings** - -Integrate this component into the character editing UI. - -**Step 3: Run and verify** - -Run: `npm run dev` -Verify: Can add/remove global includes for a character - -**Step 4: Commit** - -```bash -git add src/lib/components/CharacterGlobalsEditor.svelte -git commit -m "$(cat <<'EOF' -feat(ui): add CharacterGlobalsEditor component - -Allow characters to configure their global state includes: -- Add/remove global states -- Customize alias (input name) -- Save to globals.json - -Co-Authored-By: Claude Opus 4.5 -EOF -)" -``` - ---- - -## Task 17: Documentation - -**Files:** -- Create: `docs/global-states.md` - -**Step 1: Write documentation** - -```markdown -# Global States - -Global states are shared state definitions that live at the project level and can be opted into by any character. - -## Use Cases - -- **System mechanics**: Burst, Roman Cancel, hard knockdown -- **Common states**: Idle, walk, crouch, jump -- **Character archetypes**: Shared specials for similar characters - -## Project Structure - -``` -project/ - globals/ - states/ - burst.json - idle.json - walk_forward.json - characters/ - ryu/ - globals.json # Which globals this character uses - states/ - 5L.json # Local states -``` - -## Character Opt-In - -Characters opt into globals via `globals.json`: - -```json -{ - "includes": [ - { "state": "burst", "as": "burst" }, - { "state": "idle", "as": "idle", "override": { "animation": "ryu_idle" } }, - { "state": "walk_forward", "as": "66" } - ] -} -``` - -### Fields - -- `state`: Name of the global state file (without `.json`) -- `as`: Input name for this character (allows renaming) -- `override`: Optional field overrides - -## Override Behavior - -Overrides use **shallow merge** semantics: - -- Top-level fields are replaced entirely -- Arrays are replaced (not concatenated) -- Nested objects are replaced as units -- `null` removes a field - -Example: -```json -// Global: { "damage": 100, "tags": ["normal", "ground"] } -// Override: { "damage": 80 } -// Result: { "damage": 80, "tags": ["normal", "ground"] } -``` - -## Validation - -The system validates: - -- Global state exists -- Alias doesn't conflict with local states -- No duplicate aliases -- Unknown override fields generate warnings - -## MCP Tools - -- `list_global_states`: List all project globals -- `get_global_state`: Get a specific global -- `create_global_state`: Create new global -- `update_global_state`: Update existing global -- `delete_global_state`: Delete (checks references) - -## UI - -- **Globals Manager**: Browse and edit project-wide globals -- **Character Globals Editor**: Configure includes per character -- **State List**: Shows 🌐 indicator for global-derived states -``` - -**Step 2: Commit** - -```bash -git add docs/global-states.md -git commit -m "$(cat <<'EOF' -docs: add global states documentation - -Document global states feature including: -- Project structure -- Character opt-in via globals.json -- Override behavior (shallow merge) -- Validation rules -- MCP tools -- UI components - -Co-Authored-By: Claude Opus 4.5 -EOF -)" -``` - ---- - -## Task 18: Final Integration Testing - -**Step 1: Run all backend tests** - -Run: `cd src-tauri && cargo test` -Expected: All tests pass - -**Step 2: Run clippy** - -Run: `cd src-tauri && cargo clippy --all-targets` -Expected: No warnings - -**Step 3: Run frontend checks** - -Run: `npm run check` -Expected: No errors - -**Step 4: Run frontend tests** - -Run: `npm run test:run` -Expected: All tests pass - -**Step 5: Manual E2E test** - -1. Start the app: `npm run tauri dev` -2. Open or create a project -3. Create a global state via Globals Manager -4. Add global include to a character -5. Verify state appears in character's state list -6. Export character and verify global-derived state is included -7. Delete global and verify warning about references - -**Step 6: Final commit** - -```bash -git add -A -git commit -m "$(cat <<'EOF' -feat: global states implementation complete - -Implements project-wide global states that characters can opt into: -- Rust: schema types, loading, shallow merge, validation -- Backend: Tauri commands, MCP tools -- Frontend: GlobalsManager, CharacterGlobalsEditor -- Docs: full documentation - -Characters explicitly declare globals via globals.json with -optional per-character overrides using shallow merge semantics. - -Co-Authored-By: Claude Opus 4.5 -EOF -)" -``` - ---- - -## Summary - -This plan implements global states in 18 tasks: - -1. Schema types (Rust) -2. Loading functions (Rust) -3. Shallow merge (Rust) -4. Full resolution with validation (Rust) -5. Character loading integration (Rust) -6. Export pipeline verification (Rust) -7. MCP tools (Rust) -8. Tauri commands (Rust) -9. TypeScript types -10. Globals store -11. GlobalStateList component -12. GlobalStateEditor component -13. GlobalsManager view -14. Navigation integration -15. State list global indicator -16. CharacterGlobalsEditor component -17. Documentation -18. Final integration testing - -Each task follows TDD with explicit file paths, code, and expected outcomes. diff --git a/docs/plans/2026-02-01-state-tags-cancel-rules.md b/docs/plans/2026-02-01-state-tags-cancel-rules.md deleted file mode 100644 index 68b2af5..0000000 --- a/docs/plans/2026-02-01-state-tags-cancel-rules.md +++ /dev/null @@ -1,1685 +0,0 @@ -# Move → State + Tags + Cancel Rules Implementation Plan - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** Refactor framesmith from "Move" to "State" terminology, add flexible tag-based categorization, and implement tag-based cancel rules. - -**Architecture:** States are the fundamental unit (attacks, reactions, neutral, system). Tags provide flexible categorization. Cancel rules use both tag patterns (normal→special) and explicit routes (rekkas, target combos). Runtime stays minimal; tags evaluated at cancel-check time. - -**Tech Stack:** Rust (schema, runtime, fspack), TypeScript/Svelte (frontend), FSPK binary format - -**Reference:** Design document at `C:\Users\rdave\.claude\plans\snug-churning-hummingbird.md` - ---- - -## Phase 1: Schema + Tag Newtype - -### Task 1.1: Add Tag Newtype and Error Type - -**Files:** -- Modify: `src-tauri/src/schema/mod.rs` - -**Step 1: Write the failing test for Tag validation** - -Add at the bottom of the file, before the closing of the module: - -```rust -#[cfg(test)] -mod tag_tests { - use super::*; - - #[test] - fn tag_valid_lowercase() { - let tag = Tag::new("normal").unwrap(); - assert_eq!(tag.as_str(), "normal"); - } - - #[test] - fn tag_valid_with_underscore() { - let tag = Tag::new("on_hit").unwrap(); - assert_eq!(tag.as_str(), "on_hit"); - } - - #[test] - fn tag_valid_with_numbers() { - let tag = Tag::new("rekka1").unwrap(); - assert_eq!(tag.as_str(), "rekka1"); - } - - #[test] - fn tag_rejects_empty() { - assert!(Tag::new("").is_err()); - } - - #[test] - fn tag_rejects_uppercase() { - assert!(Tag::new("Normal").is_err()); - } - - #[test] - fn tag_rejects_spaces() { - assert!(Tag::new("on hit").is_err()); - } - - #[test] - fn tag_rejects_special_chars() { - assert!(Tag::new("normal!").is_err()); - } -} -``` - -**Step 2: Run test to verify it fails** - -Run: `cd src-tauri && cargo test tag_tests` -Expected: FAIL - `Tag` type not found - -**Step 3: Implement Tag newtype** - -Add after the imports at the top of `src-tauri/src/schema/mod.rs`: - -```rust -/// Error type for invalid tags -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum TagError { - Empty, - InvalidChars, -} - -impl std::fmt::Display for TagError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - TagError::Empty => write!(f, "tag cannot be empty"), - TagError::InvalidChars => write!(f, "tag must be lowercase alphanumeric with underscores"), - } - } -} - -impl std::error::Error for TagError {} - -/// Validated tag for state categorization. -/// -/// Tags are lowercase alphanumeric strings with underscores. -/// Games use tags for cancel rules and filtering. -#[derive(Clone, Debug, PartialEq, Eq, Hash)] -pub struct Tag(String); - -impl Tag { - /// Create a new tag, validating the format. - pub fn new(s: impl Into) -> Result { - let s = s.into(); - if s.is_empty() { - return Err(TagError::Empty); - } - if !s.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_') { - return Err(TagError::InvalidChars); - } - Ok(Tag(s)) - } - - /// Get the tag as a string slice. - pub fn as_str(&self) -> &str { - &self.0 - } -} - -impl Serialize for Tag { - fn serialize(&self, serializer: S) -> Result { - self.0.serialize(serializer) - } -} - -impl<'de> Deserialize<'de> for Tag { - fn deserialize>(deserializer: D) -> Result { - let s = String::deserialize(deserializer)?; - Tag::new(s).map_err(serde::de::Error::custom) - } -} - -impl schemars::JsonSchema for Tag { - fn schema_name() -> String { - "Tag".to_string() - } - - fn json_schema(gen: &mut schemars::SchemaGenerator) -> schemars::Schema { - let mut schema = gen.subschema_for::(); - if let schemars::Schema::Object(ref mut obj) = schema { - obj.metadata().description = Some("Lowercase alphanumeric tag with underscores".to_string()); - } - schema - } -} -``` - -**Step 4: Run test to verify it passes** - -Run: `cd src-tauri && cargo test tag_tests` -Expected: PASS (all 7 tests) - -**Step 5: Commit** - -```bash -git add src-tauri/src/schema/mod.rs -git commit -m "feat(schema): add Tag newtype with validation - -Co-Authored-By: Claude Opus 4.5 " -``` - ---- - -### Task 1.2: Add tags field to Move struct - -**Files:** -- Modify: `src-tauri/src/schema/mod.rs` - -**Step 1: Write the failing test** - -Add to the existing tests section: - -```rust -#[test] -fn move_with_tags_deserializes() { - let json = r#"{ - "input": "5L", - "name": "Light", - "tags": ["normal", "light"], - "startup": 5, - "active": 2, - "recovery": 10, - "damage": 500, - "hitstun": 15, - "blockstun": 10, - "hitstop": 10, - "guard": "mid", - "hitboxes": [], - "hurtboxes": [], - "pushback": { "hit": 5, "block": 8 }, - "meter_gain": { "hit": 100, "whiff": 20 }, - "animation": "5L" - }"#; - - let mv: Move = serde_json::from_str(json).expect("move should parse"); - assert_eq!(mv.tags.len(), 2); - assert_eq!(mv.tags[0].as_str(), "normal"); - assert_eq!(mv.tags[1].as_str(), "light"); -} - -#[test] -fn move_without_tags_deserializes_empty() { - let json = r#"{ - "input": "5L", - "name": "Light", - "startup": 5, - "active": 2, - "recovery": 10, - "damage": 500, - "hitstun": 15, - "blockstun": 10, - "hitstop": 10, - "guard": "mid", - "hitboxes": [], - "hurtboxes": [], - "pushback": { "hit": 5, "block": 8 }, - "meter_gain": { "hit": 100, "whiff": 20 }, - "animation": "5L" - }"#; - - let mv: Move = serde_json::from_str(json).expect("move should parse"); - assert!(mv.tags.is_empty()); -} -``` - -**Step 2: Run test to verify it fails** - -Run: `cd src-tauri && cargo test move_with_tags` -Expected: FAIL - no field `tags` on type `Move` - -**Step 3: Add tags field to Move struct** - -In the `Move` struct definition, add after the `name` field: - -```rust -#[serde(default)] -pub tags: Vec, -``` - -And in `impl Default for Move`, add: - -```rust -tags: Vec::new(), -``` - -**Step 4: Run test to verify it passes** - -Run: `cd src-tauri && cargo test move_with_tags && cargo test move_without_tags` -Expected: PASS - -**Step 5: Commit** - -```bash -git add src-tauri/src/schema/mod.rs -git commit -m "feat(schema): add tags field to Move struct - -Co-Authored-By: Claude Opus 4.5 " -``` - ---- - -### Task 1.3: Update frontend types.ts - -**Files:** -- Modify: `src/lib/types.ts` - -**Step 1: Add Tag type and update Move interface** - -Find the `Move` interface and add: - -```typescript -// Add near the top with other type definitions -export type Tag = string; // Validated on backend, just string on frontend - -// In the Move interface, add after 'name': -export interface Move { - input: string; - name: string; - tags: Tag[]; // ADD THIS LINE - // ... rest of existing fields -} -``` - -**Step 2: Verify TypeScript compiles** - -Run: `cd src && npx tsc --noEmit` -Expected: No errors (or only pre-existing errors unrelated to tags) - -**Step 3: Commit** - -```bash -git add src/lib/types.ts -git commit -m "feat(frontend): add tags field to Move type - -Co-Authored-By: Claude Opus 4.5 " -``` - ---- - -### Task 1.4: Verify existing moves load with empty tags - -**Step 1: Run the full test suite** - -Run: `cd src-tauri && cargo test` -Expected: All existing tests pass - -**Step 2: Manual verification** - -Run: `npm run tauri dev` -- Open an existing project -- Verify moves load without errors -- Check browser console for any deserialization errors - -**Step 3: Commit checkpoint** - -```bash -git add -A -git commit -m "checkpoint: phase 1 complete - tags field added - -Co-Authored-By: Claude Opus 4.5 " -``` - ---- - -## Phase 2: FSPK Tag Support - -### Task 2.1: Add section constants for tags - -**Files:** -- Modify: `crates/framesmith-fspack/src/view.rs` - -**Step 1: Add section constants** - -Find the section constant definitions and add: - -```rust -/// Section containing tag range pointers (parallel to STATES) -pub const SECTION_STATE_TAG_RANGES: u32 = 17; -/// Section containing tag StrRefs -pub const SECTION_STATE_TAGS: u32 = 18; -``` - -Also add size constant: - -```rust -/// Size of a state tag range entry (off: u32, count: u16, pad: u16) -pub const STATE_TAG_RANGE_SIZE: usize = 8; -``` - -**Step 2: Verify it compiles** - -Run: `cd crates/framesmith-fspack && cargo check` -Expected: Compiles without errors - -**Step 3: Commit** - -```bash -git add crates/framesmith-fspack/src/view.rs -git commit -m "feat(fspack): add section constants for state tags - -Co-Authored-By: Claude Opus 4.5 " -``` - ---- - -### Task 2.2: Implement StateTagRangesView reader - -**Files:** -- Modify: `crates/framesmith-fspack/src/view.rs` - -**Step 1: Write the test** - -```rust -#[cfg(test)] -mod state_tag_tests { - use super::*; - - #[test] - fn state_tag_range_view_returns_none_for_missing_section() { - // Minimal valid pack with no tag sections - let data = build_minimal_pack_without_tags(); - let pack = PackView::parse(&data).unwrap(); - assert!(pack.state_tag_ranges().is_none()); - } -} -``` - -**Step 2: Implement StateTagRangesView** - -```rust -/// View into STATE_TAG_RANGES section -pub struct StateTagRangesView<'a> { - data: &'a [u8], -} - -impl<'a> StateTagRangesView<'a> { - /// Get the tag range (offset, count) for a state by index - pub fn get(&self, index: usize) -> Option<(u32, u16)> { - let offset = index * STATE_TAG_RANGE_SIZE; - if offset + STATE_TAG_RANGE_SIZE > self.data.len() { - return None; - } - let slice = &self.data[offset..offset + STATE_TAG_RANGE_SIZE]; - let off = read_u32(slice, 0); - let count = read_u16(slice, 4); - Some((off, count)) - } - - /// Number of entries - pub fn len(&self) -> usize { - self.data.len() / STATE_TAG_RANGE_SIZE - } - - pub fn is_empty(&self) -> bool { - self.len() == 0 - } -} - -impl<'a> PackView<'a> { - /// Get the state tag ranges section - pub fn state_tag_ranges(&self) -> Option> { - self.section(SECTION_STATE_TAG_RANGES) - .map(|data| StateTagRangesView { data }) - } - - /// Get tags for a state by index - pub fn state_tags(&self, state_idx: usize) -> Option> { - let ranges = self.state_tag_ranges()?; - let (off, count) = ranges.get(state_idx)?; - let tags_section = self.section(SECTION_STATE_TAGS)?; - - Some((0..count).filter_map(move |i| { - let tag_offset = (off as usize) + (i as usize) * 8; // StrRef is 8 bytes - if tag_offset + 8 > tags_section.len() { - return None; - } - let str_off = read_u32(tags_section, tag_offset); - let str_len = read_u16(tags_section, tag_offset + 4); - self.string(str_off, str_len) - })) - } -} -``` - -**Step 3: Run test to verify** - -Run: `cd crates/framesmith-fspack && cargo test state_tag` -Expected: PASS - -**Step 4: Commit** - -```bash -git add crates/framesmith-fspack/src/view.rs -git commit -m "feat(fspack): add StateTagRangesView reader - -Co-Authored-By: Claude Opus 4.5 " -``` - ---- - -### Task 2.3: Update zx_fspack.rs encoder to write tags - -**Files:** -- Modify: `src-tauri/src/codegen/zx_fspack.rs` - -**Step 1: Add tag encoding to pack generation** - -Find the pack generation function and add after moves are written: - -```rust -// Write STATE_TAG_RANGES section (parallel to moves) -let mut tag_ranges: Vec = Vec::new(); -let mut tag_strrefs: Vec = Vec::new(); - -for mv in &char_data.moves { - let tag_offset = tag_strrefs.len() as u32; - let tag_count = mv.tags.len() as u16; - - // Write range entry - tag_ranges.extend_from_slice(&tag_offset.to_le_bytes()); - tag_ranges.extend_from_slice(&tag_count.to_le_bytes()); - tag_ranges.extend_from_slice(&0u16.to_le_bytes()); // padding - - // Write tag StrRefs - for tag in &mv.tags { - let (str_off, str_len) = string_table.intern(tag.as_str()); - tag_strrefs.extend_from_slice(&str_off.to_le_bytes()); - tag_strrefs.extend_from_slice(&str_len.to_le_bytes()); - tag_strrefs.extend_from_slice(&0u16.to_le_bytes()); // padding - } -} - -// Add sections if there are any tags -if !tag_ranges.is_empty() { - sections.push(Section { - kind: SECTION_STATE_TAG_RANGES, - data: tag_ranges, - align: 4, - }); - sections.push(Section { - kind: SECTION_STATE_TAGS, - data: tag_strrefs, - align: 4, - }); -} -``` - -**Step 2: Verify it compiles** - -Run: `cd src-tauri && cargo check` -Expected: Compiles - -**Step 3: Commit** - -```bash -git add src-tauri/src/codegen/zx_fspack.rs -git commit -m "feat(codegen): encode state tags in FSPK - -Co-Authored-By: Claude Opus 4.5 " -``` - ---- - -### Task 2.4: Add roundtrip test for tags - -**Files:** -- Modify: `src-tauri/tests/zx_fspack_roundtrip.rs` - -**Step 1: Write the roundtrip test** - -```rust -#[test] -fn tags_survive_roundtrip() { - let mut char_data = create_test_character(); - char_data.moves[0].tags = vec![ - Tag::new("normal").unwrap(), - Tag::new("light").unwrap(), - ]; - - let pack_bytes = export_zx_fspack(&char_data).unwrap(); - let pack = PackView::parse(&pack_bytes).unwrap(); - - let tags: Vec<&str> = pack.state_tags(0).unwrap().collect(); - assert_eq!(tags, vec!["normal", "light"]); -} - -#[test] -fn empty_tags_roundtrip() { - let char_data = create_test_character(); // moves have no tags - - let pack_bytes = export_zx_fspack(&char_data).unwrap(); - let pack = PackView::parse(&pack_bytes).unwrap(); - - // Should return empty iterator, not None - let tags: Vec<&str> = pack.state_tags(0).unwrap_or_default().collect(); - assert!(tags.is_empty()); -} -``` - -**Step 2: Run the test** - -Run: `cd src-tauri && cargo test tags_survive_roundtrip` -Expected: PASS - -**Step 3: Commit checkpoint** - -```bash -git add -A -git commit -m "checkpoint: phase 2 complete - FSPK tag support - -Co-Authored-By: Claude Opus 4.5 " -``` - ---- - -## Phase 3: Tag-Based Cancel Rules - -### Task 3.1: Update CancelTable schema - -**Files:** -- Modify: `src-tauri/src/schema/mod.rs` - -**Step 1: Write the test** - -```rust -#[test] -fn cancel_table_with_tag_rules_deserializes() { - let json = r#"{ - "tag_rules": [ - { "from": "normal", "to": "special", "on": "hit" }, - { "from": "hitstun", "to": "burst" } - ], - "chains": { "5L": ["5M", "5H"] }, - "deny": { "2H": ["jump"] } - }"#; - - let ct: CancelTable = serde_json::from_str(json).expect("should parse"); - assert_eq!(ct.tag_rules.len(), 2); - assert_eq!(ct.tag_rules[0].from, "normal"); - assert_eq!(ct.deny.get("2H"), Some(&vec!["jump".to_string()])); -} -``` - -**Step 2: Run test to verify it fails** - -Run: `cd src-tauri && cargo test cancel_table_with_tag_rules` -Expected: FAIL - no field `tag_rules` - -**Step 3: Add CancelTagRule and update CancelTable** - -```rust -/// Condition for when a cancel rule applies -#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema, Default)] -#[serde(rename_all = "snake_case")] -pub enum CancelCondition { - #[default] - Always, - Hit, - Block, - Whiff, -} - -/// Tag-based cancel rule -#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)] -pub struct CancelTagRule { - /// Source state must have this tag (or "any") - pub from: String, - /// Target state must have this tag (or "any") - pub to: String, - /// When the cancel is allowed - #[serde(default)] - pub on: CancelCondition, - /// Minimum frame to allow cancel (0 = no minimum) - #[serde(default)] - pub after_frame: u8, - /// Maximum frame to allow cancel (255 = no maximum) - #[serde(default = "default_max_frame")] - pub before_frame: u8, -} - -fn default_max_frame() -> u8 { 255 } - -/// Cancel table defining all state relationships -#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema, Default)] -pub struct CancelTable { - /// Tag-based cancel rules (general patterns) - #[serde(default)] - pub tag_rules: Vec, - /// Explicit chain routes (target combos, rekkas) - #[serde(default)] - pub chains: std::collections::HashMap>, - /// Explicit deny overrides - #[serde(default)] - pub deny: std::collections::HashMap>, - // Legacy fields for backward compat during migration - #[serde(default)] - pub special_cancels: Vec, - #[serde(default)] - pub super_cancels: Vec, - #[serde(default)] - pub jump_cancels: Vec, -} -``` - -**Step 4: Run test to verify it passes** - -Run: `cd src-tauri && cargo test cancel_table_with_tag_rules` -Expected: PASS - -**Step 5: Commit** - -```bash -git add src-tauri/src/schema/mod.rs -git commit -m "feat(schema): add tag_rules and deny to CancelTable - -Co-Authored-By: Claude Opus 4.5 " -``` - ---- - -### Task 3.2: Add FSPK sections for cancel rules - -**Files:** -- Modify: `crates/framesmith-fspack/src/view.rs` - -**Step 1: Add section constants** - -```rust -/// Section containing tag-based cancel rules -pub const SECTION_CANCEL_TAG_RULES: u32 = 19; -/// Section containing explicit deny pairs -pub const SECTION_CANCEL_DENIES: u32 = 20; - -/// Size of a cancel tag rule entry (24 bytes) -pub const CANCEL_TAG_RULE_SIZE: usize = 24; -/// Size of a cancel deny entry (4 bytes: from u16, to u16) -pub const CANCEL_DENY_SIZE: usize = 4; -``` - -**Step 2: Implement CancelTagRuleView** - -```rust -/// View into a single cancel tag rule -pub struct CancelTagRuleView<'a> { - data: &'a [u8], - pack: &'a PackView<'a>, -} - -impl<'a> CancelTagRuleView<'a> { - pub fn from_tag(&self) -> Option<&'a str> { - let off = read_u32(self.data, 0); - let len = read_u16(self.data, 4); - if off == 0xFFFFFFFF { return None; } // "any" - self.pack.string(off, len) - } - - pub fn to_tag(&self) -> Option<&'a str> { - let off = read_u32(self.data, 8); - let len = read_u16(self.data, 12); - if off == 0xFFFFFFFF { return None; } // "any" - self.pack.string(off, len) - } - - pub fn condition(&self) -> u8 { - read_u8(self.data, 16) - } - - pub fn min_frame(&self) -> u8 { - read_u8(self.data, 17) - } - - pub fn max_frame(&self) -> u8 { - read_u8(self.data, 18) - } - - pub fn flags(&self) -> u8 { - read_u8(self.data, 19) - } -} - -/// View into cancel tag rules section -pub struct CancelTagRulesView<'a> { - data: &'a [u8], - pack: &'a PackView<'a>, -} - -impl<'a> CancelTagRulesView<'a> { - pub fn get(&self, index: usize) -> Option> { - let offset = index * CANCEL_TAG_RULE_SIZE; - if offset + CANCEL_TAG_RULE_SIZE > self.data.len() { - return None; - } - Some(CancelTagRuleView { - data: &self.data[offset..offset + CANCEL_TAG_RULE_SIZE], - pack: self.pack, - }) - } - - pub fn len(&self) -> usize { - self.data.len() / CANCEL_TAG_RULE_SIZE - } - - pub fn iter(&self) -> impl Iterator> { - (0..self.len()).filter_map(move |i| self.get(i)) - } -} - -impl<'a> PackView<'a> { - pub fn cancel_tag_rules(&self) -> Option> { - self.section(SECTION_CANCEL_TAG_RULES) - .map(|data| CancelTagRulesView { data, pack: self }) - } - - pub fn cancel_denies(&self) -> Option<&'a [u8]> { - self.section(SECTION_CANCEL_DENIES) - } - - pub fn has_cancel_deny(&self, from: u16, to: u16) -> bool { - let Some(denies) = self.cancel_denies() else { return false }; - let count = denies.len() / CANCEL_DENY_SIZE; - for i in 0..count { - let off = i * CANCEL_DENY_SIZE; - let deny_from = read_u16(denies, off); - let deny_to = read_u16(denies, off + 2); - if deny_from == from && deny_to == to { - return true; - } - } - false - } -} -``` - -**Step 3: Verify it compiles** - -Run: `cd crates/framesmith-fspack && cargo check` -Expected: Compiles - -**Step 4: Commit** - -```bash -git add crates/framesmith-fspack/src/view.rs -git commit -m "feat(fspack): add cancel tag rules and denies views - -Co-Authored-By: Claude Opus 4.5 " -``` - ---- - -### Task 3.3: Update encoder to write cancel rules - -**Files:** -- Modify: `src-tauri/src/codegen/zx_fspack.rs` - -**Step 1: Add cancel rule encoding** - -```rust -// Encode cancel tag rules -let mut cancel_rules_data: Vec = Vec::new(); -for rule in &char_data.cancel_table.tag_rules { - // from_tag StrRef - let (from_off, from_len) = if rule.from == "any" { - (0xFFFFFFFFu32, 0u16) - } else { - string_table.intern(&rule.from) - }; - cancel_rules_data.extend_from_slice(&from_off.to_le_bytes()); - cancel_rules_data.extend_from_slice(&from_len.to_le_bytes()); - cancel_rules_data.extend_from_slice(&0u16.to_le_bytes()); // pad - - // to_tag StrRef - let (to_off, to_len) = if rule.to == "any" { - (0xFFFFFFFFu32, 0u16) - } else { - string_table.intern(&rule.to) - }; - cancel_rules_data.extend_from_slice(&to_off.to_le_bytes()); - cancel_rules_data.extend_from_slice(&to_len.to_le_bytes()); - cancel_rules_data.extend_from_slice(&0u16.to_le_bytes()); // pad - - // condition, min_frame, max_frame, flags - let condition: u8 = match rule.on { - CancelCondition::Always => 0, - CancelCondition::Hit => 1, - CancelCondition::Block => 2, - CancelCondition::Whiff => 3, - }; - cancel_rules_data.push(condition); - cancel_rules_data.push(rule.after_frame); - cancel_rules_data.push(rule.before_frame); - cancel_rules_data.push(0); // flags - - // padding to 24 bytes - cancel_rules_data.extend_from_slice(&0u32.to_le_bytes()); -} - -if !cancel_rules_data.is_empty() { - sections.push(Section { - kind: SECTION_CANCEL_TAG_RULES, - data: cancel_rules_data, - align: 4, - }); -} - -// Encode cancel denies -let mut denies_data: Vec = Vec::new(); -for (from_input, deny_list) in &char_data.cancel_table.deny { - let from_idx = move_input_to_index.get(from_input.as_str()); - for to_input in deny_list { - let to_idx = move_input_to_index.get(to_input.as_str()); - if let (Some(&from), Some(&to)) = (from_idx, to_idx) { - denies_data.extend_from_slice(&(from as u16).to_le_bytes()); - denies_data.extend_from_slice(&(to as u16).to_le_bytes()); - } - } -} - -if !denies_data.is_empty() { - sections.push(Section { - kind: SECTION_CANCEL_DENIES, - data: denies_data, - align: 4, - }); -} -``` - -**Step 2: Verify it compiles** - -Run: `cd src-tauri && cargo check` -Expected: Compiles - -**Step 3: Commit** - -```bash -git add src-tauri/src/codegen/zx_fspack.rs -git commit -m "feat(codegen): encode cancel tag rules and denies in FSPK - -Co-Authored-By: Claude Opus 4.5 " -``` - ---- - -### Task 3.4: Update runtime can_cancel_to() with tag rules - -**Files:** -- Modify: `crates/framesmith-runtime/src/cancel.rs` - -**Step 1: Write the test** - -```rust -#[cfg(test)] -mod tag_cancel_tests { - use super::*; - - #[test] - fn tag_rule_allows_normal_to_special_on_hit() { - // Setup pack with: - // - state 0 tagged "normal" - // - state 1 tagged "special" - // - tag rule: from="normal", to="special", on=hit - let pack = build_test_pack_with_tag_rule("normal", "special", 1); // 1 = on_hit - - let mut state = CharacterState::default(); - state.current_move = 0; - state.hit_confirmed = true; - - assert!(can_cancel_to(&state, &pack, 1)); - } - - #[test] - fn tag_rule_denies_normal_to_special_on_whiff() { - let pack = build_test_pack_with_tag_rule("normal", "special", 1); // on_hit only - - let mut state = CharacterState::default(); - state.current_move = 0; - state.hit_confirmed = false; // whiffed - - assert!(!can_cancel_to(&state, &pack, 1)); - } - - #[test] - fn explicit_deny_overrides_tag_rule() { - let pack = build_test_pack_with_deny(0, 1); // deny 0->1 - - let mut state = CharacterState::default(); - state.current_move = 0; - state.hit_confirmed = true; - - assert!(!can_cancel_to(&state, &pack, 1)); - } -} -``` - -**Step 2: Implement tag rule evaluation in can_cancel_to** - -```rust -/// Check if source state has a specific tag -fn state_has_tag(pack: &PackView, state_idx: u16, tag: &str) -> bool { - pack.state_tags(state_idx as usize) - .map(|tags| tags.any(|t| t == tag)) - .unwrap_or(false) -} - -/// Context for cancel checking -pub struct CancelContext { - pub frame: u8, - pub hit_confirmed: bool, - pub block_confirmed: bool, -} - -impl CancelContext { - fn matches_condition(&self, condition: u8) -> bool { - match condition { - 0 => true, // always - 1 => self.hit_confirmed, // on_hit - 2 => self.block_confirmed, // on_block - 3 => !self.hit_confirmed && !self.block_confirmed, // on_whiff - _ => false, - } - } -} - -pub fn can_cancel_to(state: &CharacterState, pack: &PackView, target: u16) -> bool { - let from = state.current_move; - - // 1. Explicit deny always wins - if pack.has_cancel_deny(from, target) { - return false; - } - - // 2. Check explicit chain routes (existing logic) - if let Some(extras) = pack.move_extras() { - if let Some(extra) = extras.get(from as usize) { - let (off, len) = extra.cancels(); - if let Some(cancels) = pack.cancels() { - for i in 0..len { - let cancel_target = read_u16(cancels, (off as usize) + (i as usize) * 2); - if cancel_target == target { - return true; - } - } - } - } - } - - // 3. Check tag rules - let ctx = CancelContext { - frame: state.frame, - hit_confirmed: state.hit_confirmed, - block_confirmed: state.block_confirmed, - }; - - if let Some(rules) = pack.cancel_tag_rules() { - for rule in rules.iter() { - // Check from tag matches (None means "any") - let from_matches = rule.from_tag() - .map(|t| state_has_tag(pack, from, t)) - .unwrap_or(true); - - if !from_matches { continue; } - - // Check to tag matches - let to_matches = rule.to_tag() - .map(|t| state_has_tag(pack, target, t)) - .unwrap_or(true); - - if !to_matches { continue; } - - // Check condition - if !ctx.matches_condition(rule.condition()) { continue; } - - // Check frame range - if ctx.frame < rule.min_frame() { continue; } - if ctx.frame > rule.max_frame() { continue; } - - return true; - } - } - - false -} -``` - -**Step 3: Run tests** - -Run: `cd crates/framesmith-runtime && cargo test tag_cancel` -Expected: PASS - -**Step 4: Commit** - -```bash -git add crates/framesmith-runtime/src/cancel.rs -git commit -m "feat(runtime): implement tag-based cancel rule evaluation - -Co-Authored-By: Claude Opus 4.5 " -``` - ---- - -### Task 3.5: Add integration test for cancel rules - -**Files:** -- Modify: `src-tauri/tests/zx_fspack_roundtrip.rs` - -**Step 1: Write integration test** - -```rust -#[test] -fn cancel_tag_rules_roundtrip() { - let mut char_data = create_test_character(); - - // Add tags to moves - char_data.moves[0].tags = vec![Tag::new("normal").unwrap()]; - char_data.moves[1].tags = vec![Tag::new("special").unwrap()]; - - // Add tag rule - char_data.cancel_table.tag_rules = vec![ - CancelTagRule { - from: "normal".to_string(), - to: "special".to_string(), - on: CancelCondition::Hit, - after_frame: 0, - before_frame: 255, - }, - ]; - - let pack_bytes = export_zx_fspack(&char_data).unwrap(); - let pack = PackView::parse(&pack_bytes).unwrap(); - - // Verify rule exists - let rules = pack.cancel_tag_rules().unwrap(); - assert_eq!(rules.len(), 1); - - let rule = rules.get(0).unwrap(); - assert_eq!(rule.from_tag(), Some("normal")); - assert_eq!(rule.to_tag(), Some("special")); - assert_eq!(rule.condition(), 1); // on_hit -} -``` - -**Step 2: Run test** - -Run: `cd src-tauri && cargo test cancel_tag_rules_roundtrip` -Expected: PASS - -**Step 3: Commit checkpoint** - -```bash -git add -A -git commit -m "checkpoint: phase 3 complete - tag-based cancel rules - -Co-Authored-By: Claude Opus 4.5 " -``` - ---- - -## Phase 4: Runtime Changes (instance_duration) - -### Task 4.1: Add instance_duration to CharacterState - -**Files:** -- Modify: `crates/framesmith-runtime/src/state.rs` - -**Step 1: Update the size test to expect 23 bytes** - -```rust -#[test] -fn character_state_size_is_small() { - // Updated: now 23 bytes with instance_duration - assert_eq!(core::mem::size_of::(), 23); -} -``` - -**Step 2: Run test to verify it fails** - -Run: `cd crates/framesmith-runtime && cargo test character_state_size` -Expected: FAIL - expected 23, got 22 - -**Step 3: Add instance_duration field** - -```rust -#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)] -pub struct CharacterState { - /// Current move index (0 = idle by convention). - pub current_move: u16, - /// Current frame within the move (0-indexed). - pub frame: u8, - /// Instance-specific duration override. 0 = use state's default total(). - pub instance_duration: u8, - /// Move connected with a hit (opens on-hit cancel windows). - pub hit_confirmed: bool, - /// Move was blocked (opens on-block cancel windows). - pub block_confirmed: bool, - /// Resource pool values (meter, heat, ammo, etc.). - pub resources: [u16; MAX_RESOURCES], -} -``` - -**Step 4: Run test to verify it passes** - -Run: `cd crates/framesmith-runtime && cargo test character_state_size` -Expected: PASS - -**Step 5: Commit** - -```bash -git add crates/framesmith-runtime/src/state.rs -git commit -m "feat(runtime): add instance_duration to CharacterState - -Co-Authored-By: Claude Opus 4.5 " -``` - ---- - -### Task 4.2: Update next_frame to respect instance_duration - -**Files:** -- Modify: `crates/framesmith-runtime/src/frame.rs` - -**Step 1: Write the test** - -```rust -#[test] -fn instance_duration_overrides_state_total() { - let pack = build_test_pack_with_move_total(20); // state has total=20 - - let mut state = CharacterState::default(); - state.current_move = 0; - state.instance_duration = 10; // override to 10 frames - state.frame = 9; - - let result = next_frame(&state, &pack, &FrameInput::default()); - - // Frame 9 -> 10, but duration is 10, so move_ended should be true - assert!(result.move_ended); -} - -#[test] -fn zero_instance_duration_uses_state_total() { - let pack = build_test_pack_with_move_total(20); - - let mut state = CharacterState::default(); - state.current_move = 0; - state.instance_duration = 0; // use default - state.frame = 19; - - let result = next_frame(&state, &pack, &FrameInput::default()); - - // Frame 19 -> 20, state total is 20, so move_ended should be true - assert!(result.move_ended); -} -``` - -**Step 2: Run test to verify it fails** - -Run: `cd crates/framesmith-runtime && cargo test instance_duration` -Expected: FAIL - instance_duration not considered - -**Step 3: Update next_frame logic** - -In the `next_frame` function, update the move_ended calculation: - -```rust -// Calculate effective duration -let effective_duration = if state.instance_duration > 0 { - state.instance_duration -} else { - move_data.total() as u8 -}; - -let move_ended = new_state.frame >= effective_duration; -``` - -**Step 4: Run test to verify it passes** - -Run: `cd crates/framesmith-runtime && cargo test instance_duration` -Expected: PASS - -**Step 5: Commit checkpoint** - -```bash -git add -A -git commit -m "checkpoint: phase 4 complete - instance_duration support - -Co-Authored-By: Claude Opus 4.5 " -``` - ---- - -## Phase 5: The Big Rename - -### Task 5.1: Rename in Schema (Move → State) - -**Files:** -- Modify: `src-tauri/src/schema/mod.rs` - -**Step 1: Global find/replace in schema file** - -Replace: -- `pub struct Move` → `pub struct State` -- `impl Default for Move` → `impl Default for State` -- `Move {` → `State {` (in default impl) -- `fn move_` → `fn state_` (any helper functions) -- Update doc comments: "Move" → "State", "move" → "state" - -**Step 2: Verify it compiles** - -Run: `cd src-tauri && cargo check` -Expected: Many errors in other files referencing `Move` - -**Step 3: Fix commands.rs references** - -Update imports and usages in `src-tauri/src/commands.rs` - -**Step 4: Fix codegen references** - -Update `src-tauri/src/codegen/zx_fspack.rs` - -**Step 5: Verify it compiles** - -Run: `cd src-tauri && cargo check` -Expected: Compiles - -**Step 6: Run tests** - -Run: `cd src-tauri && cargo test` -Expected: All pass - -**Step 7: Commit** - -```bash -git add src-tauri/ -git commit -m "refactor(schema): rename Move to State - -Co-Authored-By: Claude Opus 4.5 " -``` - ---- - -### Task 5.2: Rename in Runtime - -**Files:** -- Modify: `crates/framesmith-runtime/src/state.rs` -- Modify: `crates/framesmith-runtime/src/frame.rs` -- Modify: `crates/framesmith-runtime/src/cancel.rs` -- Modify: `crates/framesmith-runtime/src/resource.rs` - -**Step 1: Rename CharacterState fields** - -```rust -pub struct CharacterState { - pub current_state: u16, // was current_move - // ... rest unchanged -} - -pub struct FrameInput { - pub requested_state: Option, // was requested_move -} -``` - -**Step 2: Update all references in runtime crate** - -Global find/replace: -- `current_move` → `current_state` -- `requested_move` → `requested_state` - -**Step 3: Verify it compiles** - -Run: `cd crates/framesmith-runtime && cargo check` -Expected: Compiles - -**Step 4: Run tests** - -Run: `cd crates/framesmith-runtime && cargo test` -Expected: All pass - -**Step 5: Commit** - -```bash -git add crates/framesmith-runtime/ -git commit -m "refactor(runtime): rename move to state in CharacterState - -Co-Authored-By: Claude Opus 4.5 " -``` - ---- - -### Task 5.3: Rename in FSPK - -**Files:** -- Modify: `crates/framesmith-fspack/src/view.rs` -- Modify: `crates/framesmith-fspack/src/lib.rs` - -**Step 1: Rename view types** - -- `MoveView` → `StateView` -- `MovesView` → `StatesView` -- `MoveExtrasRecordView` → `StateExtrasRecordView` -- `MoveExtrasView` → `StateExtrasView` -- `SECTION_MOVES` → `SECTION_STATES` (constant name only, value stays 4) - -**Step 2: Update lib.rs re-exports** - -**Step 3: Verify it compiles** - -Run: `cd crates/framesmith-fspack && cargo check` -Expected: Compiles - -**Step 4: Commit** - -```bash -git add crates/framesmith-fspack/ -git commit -m "refactor(fspack): rename Move views to State views - -Co-Authored-By: Claude Opus 4.5 " -``` - ---- - -### Task 5.4: Rename in Frontend - -**Files:** -- Modify: `src/lib/types.ts` -- Rename: `src/lib/views/MoveEditor.svelte` → `src/lib/views/StateEditor.svelte` -- Modify: `src/lib/stores/character.svelte.ts` -- Modify: `src/lib/views/FrameDataTable.svelte` -- Modify: `src/lib/training/MoveResolver.ts` → `StateResolver.ts` - -**Step 1: Update types.ts** - -```typescript -// Rename interface -export interface State { // was Move - input: string; - name: string; - tags: Tag[]; - // ... -} -``` - -**Step 2: Rename MoveEditor.svelte** - -```bash -git mv src/lib/views/MoveEditor.svelte src/lib/views/StateEditor.svelte -``` - -**Step 3: Update imports throughout frontend** - -**Step 4: Verify TypeScript compiles** - -Run: `npx tsc --noEmit` -Expected: No errors - -**Step 5: Commit** - -```bash -git add src/ -git commit -m "refactor(frontend): rename Move to State - -Co-Authored-By: Claude Opus 4.5 " -``` - ---- - -### Task 5.5: Rename in WASM wrapper - -**Files:** -- Modify: `crates/framesmith-runtime-wasm/src/lib.rs` - -**Step 1: Update field names** - -```rust -pub struct CharacterState { - pub current_state: u32, // was current_move - // ... -} -``` - -**Step 2: Verify it compiles** - -Run: `cd crates/framesmith-runtime-wasm && cargo check` -Expected: Compiles - -**Step 3: Commit** - -```bash -git add crates/framesmith-runtime-wasm/ -git commit -m "refactor(wasm): rename move to state - -Co-Authored-By: Claude Opus 4.5 " -``` - ---- - -### Task 5.6: Update all tests - -**Step 1: Find and update all test files** - -Run: `grep -r "current_move\|Move\b" --include="*.rs" --include="*.ts" crates/ src-tauri/ src/` - -Update each occurrence. - -**Step 2: Run full test suite** - -Run: `cargo test && npm test` -Expected: All pass - -**Step 3: Commit checkpoint** - -```bash -git add -A -git commit -m "checkpoint: phase 5 complete - the big rename - -Co-Authored-By: Claude Opus 4.5 " -``` - ---- - -## Phase 6: Migration Script - -### Task 6.1: Create migration script - -**Files:** -- Create: `scripts/migrate-project.ts` - -**Step 1: Write the migration script** - -```typescript -#!/usr/bin/env npx ts-node - -import * as fs from 'fs'; -import * as path from 'path'; - -interface OldState { - type?: string; - tags?: string[]; - [key: string]: unknown; -} - -interface OldCancelTable { - chains?: Record; - special_cancels?: string[]; - super_cancels?: string[]; - jump_cancels?: string[]; - tag_rules?: unknown[]; - deny?: Record; -} - -function migrateStateFile(filePath: string): void { - const content = fs.readFileSync(filePath, 'utf-8'); - const state: OldState = JSON.parse(content); - - // Convert type to tag - if (state.type && !state.tags?.includes(state.type)) { - state.tags = state.tags || []; - state.tags.push(state.type); - } - delete state.type; - - // Ensure tags exists - state.tags = state.tags || []; - - fs.writeFileSync(filePath, JSON.stringify(state, null, 2)); - console.log(`Migrated: ${filePath}`); -} - -function migrateCancelTable(filePath: string): void { - const content = fs.readFileSync(filePath, 'utf-8'); - const table: OldCancelTable = JSON.parse(content); - - // Convert legacy cancel lists to tag rules - table.tag_rules = table.tag_rules || []; - - // Note: Full conversion requires knowing which states have which tags - // This is a simplified migration that preserves legacy fields - // Users should manually update to tag_rules - - fs.writeFileSync(filePath, JSON.stringify(table, null, 2)); - console.log(`Migrated: ${filePath}`); -} - -function migrateProject(projectPath: string): void { - const charactersDir = path.join(projectPath, 'characters'); - - for (const charDir of fs.readdirSync(charactersDir)) { - const charPath = path.join(charactersDir, charDir); - if (!fs.statSync(charPath).isDirectory()) continue; - - // Migrate moves (states) - const movesDir = path.join(charPath, 'moves'); - if (fs.existsSync(movesDir)) { - for (const file of fs.readdirSync(movesDir)) { - if (file.endsWith('.json')) { - migrateStateFile(path.join(movesDir, file)); - } - } - } - - // Migrate cancel table - const cancelTablePath = path.join(charPath, 'cancel_table.json'); - if (fs.existsSync(cancelTablePath)) { - migrateCancelTable(cancelTablePath); - } - } - - console.log('Migration complete!'); -} - -// Run -const projectPath = process.argv[2] || '.'; -migrateProject(projectPath); -``` - -**Step 2: Make it executable** - -```bash -chmod +x scripts/migrate-project.ts -``` - -**Step 3: Test on a sample project** - -Run: `npx ts-node scripts/migrate-project.ts ./test-project` -Expected: Files migrated without errors - -**Step 4: Commit** - -```bash -git add scripts/migrate-project.ts -git commit -m "feat: add project migration script - -Co-Authored-By: Claude Opus 4.5 " -``` - ---- - -## Phase 7: Cleanup - -### Task 7.1: Update documentation - -**Files:** -- Modify: `docs/zx-fspack.md` -- Modify: `CLAUDE.md` - -**Step 1: Update zx-fspack.md** - -- Rename "MoveRecord" to "StateRecord" in docs -- Add sections 17-20 documentation -- Update examples to use "state" terminology - -**Step 2: Update CLAUDE.md** - -- Change "move" to "state" in terminology -- Update "moves/" to "states/" if directory renamed - -**Step 3: Commit** - -```bash -git add docs/ CLAUDE.md -git commit -m "docs: update terminology Move to State - -Co-Authored-By: Claude Opus 4.5 " -``` - ---- - -### Task 7.2: Final verification - -**Step 1: Run full test suite** - -```bash -cd src-tauri && cargo test -cd crates/framesmith-runtime && cargo test -cd crates/framesmith-fspack && cargo test -npm test -``` -Expected: All pass - -**Step 2: Run the app** - -```bash -npm run tauri dev -``` -- Create a new state with tags -- Verify cancel rules work -- Export to FSPK and verify - -**Step 3: Grep for remaining "move" references** - -```bash -grep -ri "move" --include="*.rs" --include="*.ts" --include="*.svelte" | grep -v "node_modules" | grep -v "target" -``` -Review each match - some may be intentional (e.g., "movement") - -**Step 4: Final commit** - -```bash -git add -A -git commit -m "checkpoint: phase 7 complete - Move to State refactor done - -Co-Authored-By: Claude Opus 4.5 " -``` - ---- - -## Verification Checklist - -After completing all phases, verify: - -- [ ] `cargo test` passes in all crates -- [ ] `npm test` passes -- [ ] App launches without errors -- [ ] Can create states with tags -- [ ] Tags display in frame data table -- [ ] Cancel rules with tags work (normal→special on hit) -- [ ] Explicit chains work (rekka sequences) -- [ ] Explicit deny works -- [ ] FSPK export includes tags and cancel rules -- [ ] FSPK import preserves tags and cancel rules -- [ ] Migration script works on sample project -- [ ] No remaining "Move" types in API surface diff --git a/docs/plans/2026-02-01-training-rendercore-unification-design.md b/docs/plans/2026-02-01-training-rendercore-unification-design.md deleted file mode 100644 index 04668c3..0000000 --- a/docs/plans/2026-02-01-training-rendercore-unification-design.md +++ /dev/null @@ -1,158 +0,0 @@ -\ - -**Status:** Approved -**Created:** 2026-02-01 - -## Overview - -Training Mode currently renders a placeholder viewport (`TrainingViewport.svelte`) while the State Editor preview renders real visuals via separate components (`SpritePreview.svelte` + `GltfPreview.svelte`). This plan unifies Training Mode and the State Editor preview behind a shared rendering core so both use the same asset loading, animation sampling, and rendering behavior. - -Primary goal: Training Mode 3D preview uses the same rendering core as the 2D/3D preview, with a single shared viewport (one scene/camera) rendering both player and dummy. - -Non-goals: - -- Changing Training Mode simulation logic (WASM tick loop) -- Reworking hitbox editor UX (existing overlays remain in the State Editor) - -## Key Decisions - -- **One shared viewport**: Training Mode renders both actors in a single scene/camera. -- **One shared core**: Extract a framework-agnostic render core (TypeScript) and use it from both State Editor preview and Training viewport. -- **Deterministic state indices**: Canonicalize state ordering so WASM indices map correctly to authoring data (and therefore to animations). - -## Problem: State Index Ordering Must Be Canonical - -Training Mode relies on `CharacterState.current_state` (WASM) and passes indices into `session.tick(...)`. Those indices must refer to the same ordered state list that the exporter wrote into the `.fspk`. - -Current risks: - -- Backend loads `states/*.json` via `fs::read_dir` without sorting, so `CharacterData.moves` order can vary by filesystem. -- Training Mode currently sorts moves for UI purposes, which can make indices diverge from pack order. - -Decision: - -- Define canonical state order as: sort by `State.input` ascending (after rules are applied). -- Enforce it in: - - `src-tauri/src/commands.rs` when producing `CharacterData.moves` - - `src-tauri/src/codegen/zx_fspack.rs` before packing into FSPK -- Training UI may still sort for display, but must preserve canonical indices for runtime. - -## Architecture - -Add a shared render core under `src/lib/rendercore/`: - -- `RenderCore` owns viewport lifecycle, resizing (DPR-aware), rendering clock, and an actor list. -- Actors are independent render units (sprite or glTF) managed by the core. -- Asset I/O is abstracted via an `AssetProvider` so embedded and detached windows can load assets consistently. - -Svelte components become thin wrappers: - -- State Editor preview uses the core in single-actor mode. -- Training viewport uses the core in training mode with two actors and a shared camera. - -## RenderCore API (Proposed) - -Core surface area (framework-agnostic TS, no Svelte): - -- Lifecycle: - - `mount(containerEl: HTMLElement)` - - `unmount()` -- Sizing: - - `setViewportSize(w: number, h: number, dpr: number)` -- Clock: - - `setClockMode('manual' | 'raf')` - - `renderOnce()` (manual) - - `start()` / `stop()` (raf) -- Scene: - - `setSceneMode('single' | 'training')` - - `setActors(actors: ActorSpec[])` -- Status: - - `getActorStatus(id)` returns `{ loading, error }` - -ActorSpec includes: - -- `id` (e.g. `p1`, `cpu`) -- `pos: { x, y }`, `facing: 'left' | 'right'` -- `visual`: - - `sprite`: `{ texturePath, clip, frameIndex }` - - `gltf`: `{ modelPath, clip, frameIndex }` - -## Rendering Approach - -Recommended implementation: a unified Three.js-backed viewport. - -- Sprites render as textured quads in the same Three.js scene. -- glTF renders as skinned meshes with `AnimationMixer`, sampled by `frameIndex / clip.fps`. -- Training Mode uses one renderer/canvas and a camera that frames both actors. - -State Editor overlay note: - -- Hitbox/hurtbox editing overlays remain outside the core. -- The core exposes enough camera/world transform helpers (or stable viewport rules) so overlays can remain as a separate layer. - -## Data Flow - -Training Mode per-frame: - -1. WASM sim tick updates `playerState` / `dummyState`. -2. Map `current_state` (index) to canonical `State`: - - `state = currentCharacter.moves[current_state]` -3. Map `State.animation` to `assets.animations[...]` clip. -4. Compute sampling frame: - - sprite: clamp `frameIndex` to `[0..frames-1]` - - glTF: sample time `t = frameIndex / clip.fps` -5. Pass two actors into `RenderCore` and render via the shared viewport. - -Asset loading: - -- Use a Tauri-backed `AssetProvider` that calls `read_character_asset_base64` with explicit `charactersDir + characterId`. -- Avoid relying on main-window stores so detached training can reuse the same provider. - -## Error Handling - -- Per-actor errors are isolated (P1 failure does not break CPU rendering). -- Missing animation/asset references produce a visible fallback (debug mesh/box) plus a clear error string. -- Async load cancellation uses a monotonic sequence counter per actor (`loadSeq`). -- `unmount()` disposes geometries/materials/textures/mixers and removes the renderer canvas. -- Rendering errors never throw into the Training Mode sim tick loop. - -## Testing + Verification - -Deterministic ordering: - -- Rust tests: - - `load_character` produces states sorted by `input`. - - Exporting with shuffled input yields identical FSPK bytes. - -RenderCore logic tests (TS): - -- Frame sampling math (sprite clamp, glTF time mapping). -- Load cancellation (late resolves ignored after prop changes). -- Error surfacing (missing keys -> fallback + error). - -Manual verification: - -- State Editor: sprite + glTF preview works; scrubbing works; hitbox overlay editing unchanged. -- Training Mode (embedded + detached): both actors render in one viewport; animation tracks `current_state` and `frame`; missing assets degrade cleanly. - -## File-Level Changes (Planned) - -New: - -- `src/lib/rendercore/RenderCore.ts` -- `src/lib/rendercore/assets/TauriAssetProvider.ts` -- `src/lib/rendercore/actors/GltfActor.ts` -- `src/lib/rendercore/actors/SpriteActor.ts` - -Modify: - -- `src/lib/components/MoveAnimationPreview.svelte` (use the shared core viewport) -- `src/lib/components/training/TrainingViewport.svelte` (replace placeholder DOM renderer) -- `src/lib/views/TrainingMode.svelte` (map WASM state indices to animation clips + pass actors) -- `src/routes/training/+page.svelte` (same as embedded mode) -- `src-tauri/src/commands.rs` (sort loaded states by `input`) -- `src-tauri/src/codegen/zx_fspack.rs` (enforce canonical sort before packing) - -## Open Questions - -- Sprite rendering implementation detail: GPU quad in Three.js (recommended) vs maintaining a separate Canvas2D stack. This plan assumes GPU quads to keep Training Mode as a single shared viewport. diff --git a/docs/plans/2026-02-01-variant-overlay-system.md b/docs/plans/2026-02-01-variant-overlay-system.md deleted file mode 100644 index 0adc6fc..0000000 --- a/docs/plans/2026-02-01-variant-overlay-system.md +++ /dev/null @@ -1,1133 +0,0 @@ -# Variant/Overlay System Implementation Plan - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** Add variant inheritance to reduce state duplication while flattening to pure data on export. - -**Architecture:** Variants use tilde-separated filenames (`5H~level1.json`) with optional `base` field. Deep merge at load time, flatten before export. Single-level inheritance only. - -**Tech Stack:** Rust (serde_json for merging), Tauri commands, existing schema types - ---- - -## Task 1: Add Schema Fields for Variants - -**Files:** -- Modify: `src-tauri/src/schema/mod.rs:212-249` - -**Step 1: Write failing test for base field deserialization** - -Add to the `#[cfg(test)] mod tests` block in `src-tauri/src/schema/mod.rs`: - -```rust -#[test] -fn state_with_base_field_deserializes() { - let json = r#"{ - "base": "5H", - "damage": 80 - }"#; - - let state: State = serde_json::from_str(json).expect("state should parse"); - assert_eq!(state.base.as_deref(), Some("5H")); - assert_eq!(state.damage, 80); -} - -#[test] -fn state_with_id_field_deserializes() { - let json = r#"{ - "id": "5H~level1", - "input": "5H", - "damage": 80 - }"#; - - let state: State = serde_json::from_str(json).expect("state should parse"); - assert_eq!(state.id.as_deref(), Some("5H~level1")); -} -``` - -**Step 2: Run test to verify it fails** - -Run: `cd src-tauri && cargo test state_with_base_field_deserializes` -Expected: FAIL with "unknown field `base`" - -**Step 3: Add base and id fields to State struct** - -In `src-tauri/src/schema/mod.rs`, add to the `State` struct (after line 248, before the closing brace): - -```rust - /// Base state this variant inherits from (authoring only, not exported). - #[serde(skip_serializing_if = "Option::is_none")] - pub base: Option, - /// Unique state ID (set during resolution, used in exports). - #[serde(skip_serializing_if = "Option::is_none")] - pub id: Option, -``` - -Also update the `Default` impl to include: -```rust - base: None, - id: None, -``` - -**Step 4: Run test to verify it passes** - -Run: `cd src-tauri && cargo test state_with_base_field` -Expected: PASS - -**Step 5: Commit** - -```bash -git add src-tauri/src/schema/mod.rs -git commit -m "feat(schema): add base and id fields to State for variant support" -``` - ---- - -## Task 2: Create Variant Module with Parsing Functions - -**Files:** -- Create: `src-tauri/src/variant/mod.rs` -- Modify: `src-tauri/src/lib.rs` - -**Step 1: Write failing tests for variant name parsing** - -Create `src-tauri/src/variant/mod.rs`: - -```rust -//! Variant resolution for state inheritance. -//! -//! Variants allow states to inherit from base states with targeted overrides. -//! Filename convention: `{base}~{variant}.json` (e.g., `5H~level1.json`) - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn parse_base_state_no_tilde() { - let (base, variant) = parse_variant_name("5H"); - assert_eq!(base, "5H"); - assert_eq!(variant, None); - } - - #[test] - fn parse_simple_variant() { - let (base, variant) = parse_variant_name("5H~level1"); - assert_eq!(base, "5H"); - assert_eq!(variant, Some("level1")); - } - - #[test] - fn parse_hold_notation_as_base() { - // 5S~ is a base state (hold input), not a variant - let (base, variant) = parse_variant_name("5S~"); - assert_eq!(base, "5S~"); - assert_eq!(variant, None); - } - - #[test] - fn parse_hold_variant() { - // 5S~~installed is a variant of 5S~ - let (base, variant) = parse_variant_name("5S~~installed"); - assert_eq!(base, "5S~"); - assert_eq!(variant, Some("installed")); - } - - #[test] - fn parse_rekka_notation() { - // 236K~K is NOT a variant - it's a rekka follow-up with input "K" and parent "236K" - // The tilde here is part of the filename convention for rekkas - // Our system treats this as base "236K" variant "K" but the file has no "base" field - // so it won't be treated as inheriting - let (base, variant) = parse_variant_name("236K~K"); - assert_eq!(base, "236K"); - assert_eq!(variant, Some("K")); - } - - #[test] - fn is_variant_checks_correctly() { - assert!(!is_variant_filename("5H")); - assert!(is_variant_filename("5H~level1")); - assert!(!is_variant_filename("5S~")); // empty variant = base - assert!(is_variant_filename("5S~~installed")); - } -} -``` - -**Step 2: Run test to verify it fails** - -Run: `cd src-tauri && cargo test variant` -Expected: FAIL with "cannot find function `parse_variant_name`" - -**Step 3: Implement parsing functions** - -Add above the tests in `src-tauri/src/variant/mod.rs`: - -```rust -/// Parse a state name into (base, variant) components. -/// -/// Splits on the **last** tilde. If the portion after the last tilde is empty, -/// treats the whole name as a base state (e.g., `5S~` is a hold input, not a variant). -/// -/// # Examples -/// - `"5H"` → `("5H", None)` -/// - `"5H~level1"` → `("5H", Some("level1"))` -/// - `"5S~"` → `("5S~", None)` (empty variant = base state) -/// - `"5S~~installed"` → `("5S~", Some("installed"))` -pub fn parse_variant_name(name: &str) -> (&str, Option<&str>) { - match name.rfind('~') { - Some(pos) => { - let variant_part = &name[pos + 1..]; - if variant_part.is_empty() { - // Empty variant portion means this is a base state - (name, None) - } else { - (&name[..pos], Some(variant_part)) - } - } - None => (name, None), - } -} - -/// Check if a filename represents a variant (has non-empty variant portion). -pub fn is_variant_filename(name: &str) -> bool { - parse_variant_name(name).1.is_some() -} -``` - -**Step 4: Run test to verify it passes** - -Run: `cd src-tauri && cargo test variant` -Expected: PASS - -**Step 5: Add module to lib.rs** - -In `src-tauri/src/lib.rs`, add after line 4: - -```rust -pub mod variant; -``` - -**Step 6: Commit** - -```bash -git add src-tauri/src/variant/mod.rs src-tauri/src/lib.rs -git commit -m "feat(variant): add variant name parsing functions" -``` - ---- - -## Task 3: Implement Deep Merge for States - -**Files:** -- Modify: `src-tauri/src/variant/mod.rs` - -**Step 1: Write failing tests for deep merge** - -Add to the tests module in `src-tauri/src/variant/mod.rs`: - -```rust -use crate::schema::{GuardType, MeterGain, OnHit, Pushback, State}; - -#[test] -fn merge_scalars_override() { - let base = State { - input: "5H".to_string(), - name: "Standing Heavy".to_string(), - damage: 50, - hitstun: 20, - ..Default::default() - }; - let overlay = State { - damage: 80, - ..Default::default() - }; - - let resolved = resolve_variant(&base, &overlay, "5H~level1"); - - assert_eq!(resolved.id.as_deref(), Some("5H~level1")); - assert_eq!(resolved.input, "5H"); // inherited - assert_eq!(resolved.name, "Standing Heavy"); // inherited - assert_eq!(resolved.damage, 80); // overridden - assert_eq!(resolved.hitstun, 20); // inherited -} - -#[test] -fn merge_objects_deep() { - let base = State { - on_hit: Some(OnHit { - gain_meter: Some(10), - ground_bounce: Some(false), - ..Default::default() - }), - ..Default::default() - }; - let overlay = State { - on_hit: Some(OnHit { - ground_bounce: Some(true), - wall_bounce: Some(true), - ..Default::default() - }), - ..Default::default() - }; - - let resolved = resolve_variant(&base, &overlay, "5H~level1"); - - let on_hit = resolved.on_hit.unwrap(); - assert_eq!(on_hit.gain_meter, Some(10)); // inherited - assert_eq!(on_hit.ground_bounce, Some(true)); // overridden - assert_eq!(on_hit.wall_bounce, Some(true)); // added -} - -#[test] -fn merge_arrays_replace() { - use crate::schema::{FrameHitbox, Rect}; - - let base = State { - hitboxes: vec![FrameHitbox { - frames: (8, 12), - r#box: Rect { x: 0, y: -50, w: 40, h: 20 }, - }], - ..Default::default() - }; - let overlay = State { - hitboxes: vec![FrameHitbox { - frames: (8, 14), - r#box: Rect { x: 0, y: -55, w: 50, h: 25 }, - }], - ..Default::default() - }; - - let resolved = resolve_variant(&base, &overlay, "5H~level1"); - - assert_eq!(resolved.hitboxes.len(), 1); - assert_eq!(resolved.hitboxes[0].frames, (8, 14)); // replaced, not merged -} - -#[test] -fn merge_inherits_input_from_base() { - let base = State { - input: "5H".to_string(), - ..Default::default() - }; - let overlay = State { - // no input specified - damage: 80, - ..Default::default() - }; - - let resolved = resolve_variant(&base, &overlay, "5H~level1"); - - assert_eq!(resolved.input, "5H"); -} -``` - -**Step 2: Run test to verify it fails** - -Run: `cd src-tauri && cargo test merge_scalars` -Expected: FAIL with "cannot find function `resolve_variant`" - -**Step 3: Implement resolve_variant using JSON merge** - -Add to `src-tauri/src/variant/mod.rs` (above the tests): - -```rust -use crate::schema::State; - -/// Deep merge two JSON values. -/// -/// - Scalars: overlay replaces base -/// - Objects: recursively merge (overlay fields override base fields) -/// - Arrays: overlay replaces base entirely -/// - Explicit null in overlay: clears the field -fn deep_merge(base: serde_json::Value, overlay: serde_json::Value) -> serde_json::Value { - use serde_json::Value; - - match (base, overlay) { - // Both objects: recursively merge - (Value::Object(mut base_map), Value::Object(overlay_map)) => { - for (key, overlay_val) in overlay_map { - if overlay_val.is_null() { - // Explicit null clears the field - base_map.remove(&key); - } else if let Some(base_val) = base_map.remove(&key) { - base_map.insert(key, deep_merge(base_val, overlay_val)); - } else { - base_map.insert(key, overlay_val); - } - } - Value::Object(base_map) - } - // Overlay is not an object, or base is not an object: overlay wins - (_, overlay) => overlay, - } -} - -/// Resolve a variant by merging overlay onto base state. -/// -/// - Sets `id` to the resolved state ID -/// - Inherits `input` from base if not specified in overlay -/// - Deep merges objects, replaces arrays -/// - Clears the `base` field in the result (not needed after resolution) -pub fn resolve_variant(base: &State, overlay: &State, resolved_id: &str) -> State { - // Convert both to JSON values - let base_json = serde_json::to_value(base).expect("base should serialize"); - let overlay_json = serde_json::to_value(overlay).expect("overlay should serialize"); - - // Deep merge - let mut merged = deep_merge(base_json, overlay_json); - - // Set the resolved ID - if let serde_json::Value::Object(ref mut map) = merged { - map.insert("id".to_string(), serde_json::Value::String(resolved_id.to_string())); - // Clear the base field - it's authoring metadata, not runtime data - map.remove("base"); - // If input is empty/default, inherit from base - if map.get("input").map(|v| v.as_str() == Some("")).unwrap_or(true) { - map.insert("input".to_string(), serde_json::Value::String(base.input.clone())); - } - } - - // Deserialize back to State - serde_json::from_value(merged).expect("merged state should deserialize") -} -``` - -**Step 4: Run test to verify it passes** - -Run: `cd src-tauri && cargo test merge_` -Expected: PASS (all merge tests) - -**Step 5: Commit** - -```bash -git add src-tauri/src/variant/mod.rs -git commit -m "feat(variant): implement deep merge for state resolution" -``` - ---- - -## Task 4: Implement Variant Validation - -**Files:** -- Modify: `src-tauri/src/variant/mod.rs` - -**Step 1: Write failing tests for validation** - -Add to the tests module in `src-tauri/src/variant/mod.rs`: - -```rust -#[test] -fn validate_base_exists() { - let states = vec![ - ("5H~level1".to_string(), State { base: Some("5H".to_string()), ..Default::default() }), - ]; - let base_names: std::collections::HashSet<_> = std::iter::empty().collect(); - - let errors = validate_variants(&states, &base_names); - - assert_eq!(errors.len(), 1); - assert!(errors[0].contains("Base state '5H' not found")); -} - -#[test] -fn validate_base_field_matches_filename() { - let states = vec![ - ("5H~level1".to_string(), State { base: Some("2H".to_string()), ..Default::default() }), - ]; - let base_names: std::collections::HashSet<_> = ["5H".to_string(), "2H".to_string()].into_iter().collect(); - - let errors = validate_variants(&states, &base_names); - - assert_eq!(errors.len(), 1); - assert!(errors[0].contains("doesn't match filename")); -} - -#[test] -fn validate_no_chained_inheritance() { - let states = vec![ - ("5H~level1".to_string(), State { base: Some("5H".to_string()), ..Default::default() }), - ("5H~level1~enhanced".to_string(), State { base: Some("5H~level1".to_string()), ..Default::default() }), - ]; - let base_names: std::collections::HashSet<_> = ["5H".to_string()].into_iter().collect(); - let variant_names: std::collections::HashSet<_> = ["5H~level1".to_string()].into_iter().collect(); - - let errors = validate_variants_no_chain(&states, &base_names, &variant_names); - - assert_eq!(errors.len(), 1); - assert!(errors[0].contains("cannot inherit from another variant")); -} - -#[test] -fn validate_passes_for_valid_variant() { - let states = vec![ - ("5H~level1".to_string(), State { base: Some("5H".to_string()), ..Default::default() }), - ]; - let base_names: std::collections::HashSet<_> = ["5H".to_string()].into_iter().collect(); - - let errors = validate_variants(&states, &base_names); - - assert!(errors.is_empty()); -} -``` - -**Step 2: Run test to verify it fails** - -Run: `cd src-tauri && cargo test validate_base_exists` -Expected: FAIL with "cannot find function `validate_variants`" - -**Step 3: Implement validation functions** - -Add to `src-tauri/src/variant/mod.rs`: - -```rust -use std::collections::HashSet; - -/// Validate variant states have existing bases and matching base fields. -pub fn validate_variants( - states: &[(String, State)], - base_names: &HashSet, -) -> Vec { - let mut errors = Vec::new(); - - for (name, state) in states { - if let Some(ref declared_base) = state.base { - let (implied_base, variant_part) = parse_variant_name(name); - - // Check base exists - if !base_names.contains(declared_base) { - errors.push(format!( - "Variant '{}': Base state '{}' not found", - name, declared_base - )); - } - - // Check declared base matches filename-implied base - if variant_part.is_some() && declared_base != implied_base { - errors.push(format!( - "Variant '{}': Base field '{}' doesn't match filename implied base '{}'", - name, declared_base, implied_base - )); - } - } - } - - errors -} - -/// Validate that variants don't inherit from other variants (single-level only). -pub fn validate_variants_no_chain( - states: &[(String, State)], - base_names: &HashSet, - variant_names: &HashSet, -) -> Vec { - let mut errors = validate_variants(states, base_names); - - for (name, state) in states { - if let Some(ref declared_base) = state.base { - if variant_names.contains(declared_base) { - errors.push(format!( - "Variant '{}': Variants cannot inherit from another variant ('{}')", - name, declared_base - )); - } - } - } - - errors -} -``` - -**Step 4: Run test to verify it passes** - -Run: `cd src-tauri && cargo test validate_` -Expected: PASS - -**Step 5: Commit** - -```bash -git add src-tauri/src/variant/mod.rs -git commit -m "feat(variant): add validation for base existence and no chaining" -``` - ---- - -## Task 5: Implement Flatten Function - -**Files:** -- Modify: `src-tauri/src/variant/mod.rs` - -**Step 1: Write failing test for flatten** - -Add to the tests module: - -```rust -#[test] -fn flatten_resolves_all_variants() { - let base = State { - input: "5H".to_string(), - name: "Standing Heavy".to_string(), - damage: 50, - ..Default::default() - }; - let variant1 = State { - base: Some("5H".to_string()), - damage: 60, - ..Default::default() - }; - let variant2 = State { - base: Some("5H".to_string()), - damage: 75, - ..Default::default() - }; - - let states = vec![ - ("5H".to_string(), base), - ("5H~level1".to_string(), variant1), - ("5H~level2".to_string(), variant2), - ]; - - let flattened = flatten_variants(states).unwrap(); - - assert_eq!(flattened.len(), 3); - - // Base state gets id set - assert_eq!(flattened[0].id.as_deref(), Some("5H")); - assert_eq!(flattened[0].damage, 50); - - // Variants are resolved - let v1 = flattened.iter().find(|s| s.id.as_deref() == Some("5H~level1")).unwrap(); - assert_eq!(v1.input, "5H"); - assert_eq!(v1.damage, 60); - assert!(v1.base.is_none()); // base field cleared - - let v2 = flattened.iter().find(|s| s.id.as_deref() == Some("5H~level2")).unwrap(); - assert_eq!(v2.damage, 75); -} - -#[test] -fn flatten_errors_on_missing_base() { - let variant = State { - base: Some("5H".to_string()), - damage: 60, - ..Default::default() - }; - - let states = vec![ - ("5H~level1".to_string(), variant), - ]; - - let result = flatten_variants(states); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("Base state '5H' not found")); -} -``` - -**Step 2: Run test to verify it fails** - -Run: `cd src-tauri && cargo test flatten_resolves` -Expected: FAIL with "cannot find function `flatten_variants`" - -**Step 3: Implement flatten_variants** - -Add to `src-tauri/src/variant/mod.rs`: - -```rust -use std::collections::HashMap; - -/// Flatten all variants into fully resolved states. -/// -/// - Base states get their `id` set to their name -/// - Variants are merged with their base and get `id` set to their full name -/// - Returns error if any variant references a non-existent base -pub fn flatten_variants(states: Vec<(String, State)>) -> Result, String> { - // Separate base states from variants - let mut base_map: HashMap = HashMap::new(); - let mut variants: Vec<(String, State)> = Vec::new(); - - for (name, state) in states { - if state.base.is_some() { - variants.push((name, state)); - } else { - base_map.insert(name, state); - } - } - - // Validate variants - let base_names: HashSet = base_map.keys().cloned().collect(); - let variant_names: HashSet = variants.iter().map(|(n, _)| n.clone()).collect(); - let errors = validate_variants_no_chain(&variants, &base_names, &variant_names); - if !errors.is_empty() { - return Err(errors.join("; ")); - } - - // Resolve base states (just set id) - let mut result: Vec = Vec::new(); - for (name, mut state) in base_map.clone() { - state.id = Some(name); - result.push(state); - } - - // Resolve variants - for (name, overlay) in variants { - let base_name = overlay.base.as_ref().unwrap(); - let base = base_map.get(base_name).ok_or_else(|| { - format!("Base state '{}' not found for variant '{}'", base_name, name) - })?; - let resolved = resolve_variant(base, &overlay, &name); - result.push(resolved); - } - - Ok(result) -} -``` - -**Step 4: Run test to verify it passes** - -Run: `cd src-tauri && cargo test flatten_` -Expected: PASS - -**Step 5: Commit** - -```bash -git add src-tauri/src/variant/mod.rs -git commit -m "feat(variant): implement flatten_variants for export resolution" -``` - ---- - -## Task 6: Integrate Variants into Character Loading - -**Files:** -- Modify: `src-tauri/src/commands.rs` - -**Step 1: Write integration test** - -Create a test in `src-tauri/src/variant/mod.rs` that simulates the load flow: - -```rust -#[test] -fn integration_load_and_flatten() { - // Simulate loading files - let base_json = r#"{ - "input": "5H", - "name": "Standing Heavy", - "damage": 50, - "hitstun": 20, - "on_hit": { "gain_meter": 10 } - }"#; - - let variant_json = r#"{ - "base": "5H", - "damage": 80, - "on_hit": { "ground_bounce": true } - }"#; - - let base: State = serde_json::from_str(base_json).unwrap(); - let variant: State = serde_json::from_str(variant_json).unwrap(); - - let states = vec![ - ("5H".to_string(), base), - ("5H~level1".to_string(), variant), - ]; - - let flattened = flatten_variants(states).unwrap(); - - assert_eq!(flattened.len(), 2); - - let resolved = flattened.iter().find(|s| s.id.as_deref() == Some("5H~level1")).unwrap(); - assert_eq!(resolved.input, "5H"); - assert_eq!(resolved.damage, 80); - assert_eq!(resolved.hitstun, 20); // inherited - let on_hit = resolved.on_hit.as_ref().unwrap(); - assert_eq!(on_hit.gain_meter, Some(10)); // inherited - assert_eq!(on_hit.ground_bounce, Some(true)); // overridden -} -``` - -**Step 2: Run test to verify it passes** - -Run: `cd src-tauri && cargo test integration_load` -Expected: PASS - -**Step 3: Update load_character_files in commands.rs** - -Modify `load_character_files` function to return states with their names. Find the function (around line 40-95) and update the state loading section: - -```rust -fn load_character_files( - characters_dir: &str, - character_id: &str, -) -> Result<(PathBuf, Character, Vec<(String, crate::schema::State)>, CancelTable), String> { - // ... existing validation code ... - - // Load all states with their names - let states_dir = char_path.join("states"); - let mut moves = vec![]; - if states_dir.exists() { - for entry in fs::read_dir(&states_dir).map_err(|e| e.to_string())? { - let entry = entry.map_err(|e| e.to_string())?; - let state_path = entry.path(); - if state_path.extension().map(|e| e == "json").unwrap_or(false) { - let content = fs::read_to_string(&state_path).map_err(|e| { - format!( - "Failed to read state file {:?}: {}", - state_path.file_name(), - e - ) - })?; - let mv: State = serde_json::from_str(&content) - .map_err(|e| format!("Invalid state file {:?}: {}", state_path.file_name(), e))?; - - // Extract state name from filename (without .json extension) - let state_name = state_path - .file_stem() - .and_then(|s| s.to_str()) - .ok_or_else(|| format!("Invalid state filename: {:?}", state_path))? - .to_string(); - - moves.push((state_name, mv)); - } - } - } - - // ... rest of function ... - Ok((char_path, character, moves, cancel_table)) -} -``` - -**Step 4: Update CharacterData and callers** - -Update `CharacterData` struct and the `load_character` command to flatten variants: - -```rust -#[derive(Debug, Clone, serde::Serialize)] -pub struct CharacterData { - pub character: Character, - pub moves: Vec, // Flattened, resolved states - pub cancel_table: CancelTable, -} - -#[tauri::command] -pub fn load_character(characters_dir: String, character_id: String) -> Result { - let (_, character, named_moves, cancel_table) = load_character_files(&characters_dir, &character_id)?; - - // Flatten variants - let moves = crate::variant::flatten_variants(named_moves)?; - - Ok(CharacterData { - character, - moves, - cancel_table, - }) -} -``` - -**Step 5: Run all tests to verify nothing broke** - -Run: `cd src-tauri && cargo test` -Expected: All tests PASS - -**Step 6: Commit** - -```bash -git add src-tauri/src/commands.rs -git commit -m "feat(commands): integrate variant flattening into character loading" -``` - ---- - -## Task 7: Add Test Character Variant Examples - -**Files:** -- Modify: `characters/test_char/character.json` -- Create: `characters/test_char/states/5H~level1.json` -- Create: `characters/test_char/states/5H~level2.json` -- Create: `characters/test_char/states/5H~level3.json` -- Create: `characters/test_char/states/236P~installed.json` - -**Step 1: Update character.json with new resources** - -Edit `characters/test_char/character.json`: - -```json -{ - "id": "test_char", - "name": "TEST_CHAR", - "archetype": "all-rounder", - "health": 1000, - "walk_speed": 4.5, - "back_walk_speed": 3.2, - "jump_height": 120, - "jump_duration": 45, - "dash_distance": 80, - "dash_duration": 18, - "resources": [ - { "name": "heat", "start": 0, "max": 100 }, - { "name": "ammo", "start": 6, "max": 6 }, - { "name": "level", "start": 0, "max": 3 }, - { "name": "install_active", "start": 0, "max": 1 } - ] -} -``` - -**Step 2: Create level variants for 5H** - -Create `characters/test_char/states/5H~level1.json`: - -```json -{ - "base": "5H", - "damage": 100, - "hitstun": 26, - "preconditions": [ - { "type": "resource", "name": "level", "min": 1 } - ] -} -``` - -Create `characters/test_char/states/5H~level2.json`: - -```json -{ - "base": "5H", - "damage": 115, - "hitstun": 28, - "preconditions": [ - { "type": "resource", "name": "level", "min": 2 } - ], - "on_hit": { - "wall_bounce": true, - "ground_bounce": true - } -} -``` - -Create `characters/test_char/states/5H~level3.json`: - -```json -{ - "base": "5H", - "damage": 130, - "hitstun": 30, - "startup": 12, - "preconditions": [ - { "type": "resource", "name": "level", "min": 3 } - ], - "on_hit": { - "wall_bounce": true, - "ground_bounce": true, - "gain_meter": 20 - }, - "hitboxes": [ - { "frames": [12, 17], "box": { "x": 10, "y": -60, "w": 60, "h": 30 } } - ] -} -``` - -**Step 3: Create install variant for 236P** - -Create `characters/test_char/states/236P~installed.json`: - -```json -{ - "base": "236P", - "name": "Enhanced Fireball", - "startup": 8, - "damage": 120, - "preconditions": [ - { "type": "resource", "name": "install_active", "min": 1 } - ], - "on_use": { - "spawn_entity": { - "type": "projectile", - "tag": "fireball", - "data": "enhanced_fireball_projectile" - } - } -} -``` - -**Step 4: Verify by running the app** - -Run: `cd framesmith && npm run tauri dev` -Load test_char and verify: -- 5H, 5H~level1, 5H~level2, 5H~level3 all appear -- 236P, 236P~installed both appear -- Variants show correct resolved damage values - -**Step 5: Commit** - -```bash -git add characters/test_char/ -git commit -m "feat(test_char): add level and install variant examples" -``` - ---- - -## Task 8: Update Exporters to Use Flattened Data - -**Files:** -- Modify: `src-tauri/src/codegen/json_blob.rs` -- Modify: `src-tauri/src/codegen/zx_fspack.rs` - -**Step 1: Verify exporters receive flattened data** - -The exporters already receive `CharacterData` which now contains flattened moves. No changes needed to the export logic itself. - -Add a test to verify export includes variant IDs: - -In `src-tauri/src/codegen/json_blob.rs`: - -```rust -#[cfg(test)] -mod tests { - use super::*; - use crate::schema::{CancelTable, Character, State}; - - #[test] - fn export_includes_state_ids() { - let character = Character { - id: "test".to_string(), - name: "Test".to_string(), - archetype: "rushdown".to_string(), - health: 1000, - walk_speed: 4.0, - back_walk_speed: 3.0, - jump_height: 100, - jump_duration: 40, - dash_distance: 80, - dash_duration: 15, - resources: vec![], - }; - - let moves = vec![ - State { - id: Some("5H".to_string()), - input: "5H".to_string(), - damage: 50, - ..Default::default() - }, - State { - id: Some("5H~level1".to_string()), - input: "5H".to_string(), - damage: 80, - ..Default::default() - }, - ]; - - let data = CharacterData { - character, - moves, - cancel_table: CancelTable::default(), - }; - - let json = export_json_blob(&data).unwrap(); - - assert!(json.contains("\"id\":\"5H\"")); - assert!(json.contains("\"id\":\"5H~level1\"")); - } -} -``` - -**Step 2: Run test to verify** - -Run: `cd src-tauri && cargo test export_includes` -Expected: PASS - -**Step 3: Commit** - -```bash -git add src-tauri/src/codegen/json_blob.rs -git commit -m "test(codegen): verify export includes state IDs" -``` - ---- - -## Task 9: Update Rules Registry for New Resources - -**Files:** -- Modify: `framesmith.rules.json` - -**Step 1: Add level and install_active to project rules** - -Edit `framesmith.rules.json` to include the new resources: - -```json -{ - "version": 1, - "registry": { - "resources": ["heat", "ammo", "level", "install_active"], - "events": { - "gain_heat": { - "contexts": ["on_hit", "notify"], - "args": { - "amount": { "type": "i64" } - } - } - } - } -} -``` - -**Step 2: Run validation** - -Run: `cd framesmith && npm run tauri dev` -Open test_char and verify no validation errors for the new resources. - -**Step 3: Commit** - -```bash -git add framesmith.rules.json -git commit -m "feat(rules): add level and install_active resources to registry" -``` - ---- - -## Task 10: Final Integration Test - -**Step 1: Run all Rust tests** - -Run: `cd src-tauri && cargo test` -Expected: All tests PASS - -**Step 2: Run clippy** - -Run: `cd src-tauri && cargo clippy --all-targets` -Expected: No warnings - -**Step 3: Manual test in app** - -1. Run: `npm run tauri dev` -2. Load test_char -3. Verify Frame Data Table shows all states including variants -4. Verify 5H~level3 shows damage=130, startup=12 -5. Export to JSON blob -6. Verify exported JSON contains all variant states with correct IDs - -**Step 4: Final commit** - -```bash -git add -A -git commit -m "feat: complete variant/overlay system implementation - -- Add base and id fields to State schema -- Implement variant name parsing (split on last tilde) -- Implement deep merge for state resolution -- Add validation for base existence and no chaining -- Integrate flattening into character loading -- Add test_char examples (level and install variants) -- Update rules registry for new resources" -``` - ---- - -## Verification Checklist - -- [ ] `cargo test` passes in src-tauri -- [ ] `cargo clippy --all-targets` has no warnings -- [ ] App loads test_char without errors -- [ ] Variants appear in Frame Data Table -- [ ] Variants show correct resolved values -- [ ] JSON export includes all variants with IDs -- [ ] FSPK export includes all variants diff --git a/docs/plans/2026-02-02-fspk-character-props-pushbox.md b/docs/plans/2026-02-02-fspk-character-props-pushbox.md deleted file mode 100644 index 3b89bde..0000000 --- a/docs/plans/2026-02-02-fspk-character-props-pushbox.md +++ /dev/null @@ -1,622 +0,0 @@ -# FSPK Character Properties & Push Boxes - Complete Implementation Plan - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** Add `SECTION_CHARACTER_PROPS` for dynamic key-value character properties and `SECTION_PUSH_WINDOWS` for body collision boxes to the FSPK binary format, with full integration into training mode and UI editors. - -**Scope:** This plan covers the complete vertical slice: -- Data layer (schema, FSPK format, export) -- Runtime layer (collision helpers, WASM bindings) -- Integration layer (training mode, UI editors) - -**Tech Stack:** Rust (no_std for framesmith-fspack, std for export), TypeScript/Svelte for UI - ---- - -## Phase 1: Data Layer (Tasks 1-10) - -### Task 1: Add Q24.8 Fixed-Point Helpers - -**Files:** `src-tauri/src/codegen/zx_fspack_format.rs` - -**Step 1:** Add Q24.8 conversion functions after existing Q12.4 helpers (~line 217): - -```rust -/// Convert a floating-point value to Q24.8 fixed point. -/// Range: ±8,388,607.996, Precision: 1/256 ≈ 0.0039 -#[inline] -pub fn to_q24_8(value: f64) -> i32 { - (value * 256.0).round() as i32 -} - -/// Convert Q24.8 fixed point back to floating-point. -#[inline] -pub fn from_q24_8(raw: i32) -> f64 { - raw as f64 / 256.0 -} -``` - -**Step 2:** Add tests for Q24.8 in the `tests` module: - -```rust -#[test] -fn test_q24_8_conversion() { - assert_eq!(to_q24_8(10000.0), 2_560_000); - assert_eq!(from_q24_8(2_560_000), 10000.0); - assert_eq!(to_q24_8(4.5), 1152); - assert_eq!(from_q24_8(1152), 4.5); - assert_eq!(to_q24_8(-3.5), -896); - assert_eq!(from_q24_8(-896), -3.5); - assert_eq!(to_q24_8(0.0), 0); - assert_eq!(from_q24_8(0), 0.0); -} -``` - -**Verify:** `cd src-tauri && cargo test zx_fspack_format::tests::test_q24_8` - ---- - -### Task 2: Define SECTION_CHARACTER_PROPS Constants - -**Files:** `src-tauri/src/codegen/zx_fspack_format.rs`, `crates/framesmith-fspack/src/view.rs` - -**Step 1:** Add to format file after `SECTION_CANCEL_DENIES` (~line 104): - -```rust -/// Array of CharacterProp12 structs (dynamic key-value properties) -pub const SECTION_CHARACTER_PROPS: u32 = 21; - -/// Character property record size: name_off(4) + name_len(2) + type(1) + reserved(1) + value(4) = 12 bytes -pub const CHARACTER_PROP12_SIZE: usize = 12; - -/// Property type: Q24.8 signed fixed-point number -pub const PROP_TYPE_Q24_8: u8 = 0; -/// Property type: boolean (value != 0) -pub const PROP_TYPE_BOOL: u8 = 1; -/// Property type: string reference (u16 offset + u16 len in value field) -pub const PROP_TYPE_STR: u8 = 2; -``` - -**Step 2:** Add to view.rs after `SECTION_CANCEL_DENIES` (~line 90): - -```rust -/// Array of CharacterProp12 structs -pub const SECTION_CHARACTER_PROPS: u32 = 21; -``` - ---- - -### Task 3: Define SECTION_PUSH_WINDOWS Constants - -**Files:** `src-tauri/src/codegen/zx_fspack_format.rs`, `crates/framesmith-fspack/src/view.rs` - -**Step 1:** Add to format file after `SECTION_CHARACTER_PROPS`: - -```rust -/// Array of PushWindow12 structs (body collision boxes, same format as HurtWindow12) -pub const SECTION_PUSH_WINDOWS: u32 = 22; - -/// Push window record size (same as hurt window): start(1) + end(1) + pad(2) + shapes_off(4) + shapes_len(2) + pad(2) = 12 bytes -pub const PUSH_WINDOW12_SIZE: usize = 12; -``` - -**Step 2:** Add to view.rs: - -```rust -/// Array of PushWindow12 structs (body collision) -pub const SECTION_PUSH_WINDOWS: u32 = 22; -``` - ---- - -### Task 4: Update JSON Schema - Character Properties - -**Files:** `src-tauri/src/schema/mod.rs` - -**Step 1:** Add PropertyValue enum before Character struct: - -```rust -/// A character property value (dynamic key-value). -#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)] -#[serde(untagged)] -pub enum PropertyValue { - Number(f64), - Bool(bool), - String(String), -} -``` - -**Step 2:** Replace fixed fields in Character struct with properties map: - -```rust -#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)] -pub struct Character { - pub id: String, - pub name: String, - #[serde(default)] - pub properties: std::collections::BTreeMap, - #[serde(default)] - pub resources: Vec, -} -``` - -**Step 3:** Remove old fixed fields: `archetype`, `health`, `walk_speed`, `back_walk_speed`, `jump_height`, `jump_duration`, `dash_distance`, `dash_duration` - -**Verify:** `cd src-tauri && cargo check` - ---- - -### Task 5: Update JSON Schema - Push Boxes - -**Files:** `src-tauri/src/schema/mod.rs` - -Add pushboxes field to State struct after `advanced_hurtboxes`: - -```rust -/// Push boxes for body collision (same format as hurtboxes) -#[serde(default)] -pub pushboxes: Vec, -``` - -**Verify:** `cd src-tauri && cargo check` - ---- - -### Task 6: Add CharacterPropsView to framesmith-fspack - -**Files:** `crates/framesmith-fspack/src/view.rs` - -**Step 1:** Add CharacterPropView struct: - -```rust -/// View into a single character property record. -#[derive(Clone, Copy)] -pub struct CharacterPropView<'a> { - data: &'a [u8], -} - -impl<'a> CharacterPropView<'a> { - pub fn name(&self) -> (u32, u16) { - let off = read_u32_le(self.data, 0); - let len = read_u16_le(self.data, 4); - (off, len) - } - - pub fn value_type(&self) -> u8 { - read_u8(self.data, 6) - } - - pub fn value_raw(&self) -> u32 { - read_u32_le(self.data, 8) - } - - pub fn as_q24_8(&self) -> i32 { - read_i32_le(self.data, 8) - } - - pub fn as_bool(&self) -> bool { - read_u8(self.data, 8) != 0 - } - - pub fn as_str_ref(&self) -> (u16, u16) { - let off = read_u16_le(self.data, 8); - let len = read_u16_le(self.data, 10); - (off, len) - } -} -``` - -**Step 2:** Add CharacterPropsView struct and PackView method. - ---- - -### Task 7: Add PushWindowsView to framesmith-fspack - -**Files:** `crates/framesmith-fspack/src/view.rs` - -Reuse HurtWindowView layout: - -```rust -pub type PushWindowView<'a> = HurtWindowView<'a>; - -#[derive(Clone, Copy)] -pub struct PushWindowsView<'a> { - data: &'a [u8], -} - -impl<'a> PushWindowsView<'a> { - pub fn get_at(&self, offset: u32, index: usize) -> Option> { - let start = offset as usize + index * HURT_WINDOW_SIZE; - let end = start + HURT_WINDOW_SIZE; - if end <= self.data.len() { - Some(HurtWindowView { data: &self.data[start..end] }) - } else { - None - } - } -} -``` - -Add `push_windows()` method to PackView. - ---- - -### Task 8: Extend StateView for Push Windows - -**Files:** `crates/framesmith-fspack/src/view.rs`, `src-tauri/src/codegen/zx_fspack_format.rs` - -**Step 1:** Update STATE_RECORD_SIZE from 32 to 36 bytes in both files. - -**Step 2:** Add push window accessors to StateView: - -```rust -pub fn push_windows_off(&self) -> u16 { - read_u16_le(self.data, 32) -} - -pub fn push_windows_len(&self) -> u16 { - read_u16_le(self.data, 34) -} -``` - ---- - -### Task 9: Export Character Properties - -**Files:** `src-tauri/src/codegen/zx_fspack.rs` - -Add `pack_character_props()` function and call it in `export_zx_fspack()`. Add SECTION_CHARACTER_PROPS to output sections. - ---- - -### Task 10: Export Push Windows - -**Files:** `src-tauri/src/codegen/zx_fspack.rs` - -Update `pack_moves()` to also pack push windows following the hurt window pattern. Update pack_move_record to write push_off and push_len at bytes 32-35. Add SECTION_PUSH_WINDOWS to output sections. - ---- - -## Phase 2: Runtime Layer (Tasks 11-13) - -### Task 11: Add check_pushbox Runtime Helper - -**Files:** `crates/framesmith-runtime/src/collision.rs`, `crates/framesmith-runtime/src/lib.rs` - -Add `check_pushbox()` function that: -1. Gets push windows for both characters -2. Finds active push window for current frame -3. Checks AABB overlap -4. Returns separation vector (dx, dy) if overlapping - -Export from lib.rs: `pub use collision::check_pushbox;` - ---- - -### Task 12: Expose Pushbox Collision in WASM - -**Files:** `crates/framesmith-runtime-wasm/src/lib.rs` - -**Step 1:** Add to FrameResult struct: - -```rust -pub push_separation: Option<(i32, i32)>, // (dx, dy) if characters are overlapping -``` - -**Step 2:** Call check_pushbox in tick() after hit detection: - -```rust -let push_sep = check_pushbox( - &self.player_state, &player_pack, (player_pos_x, player_pos_y), - &self.dummy_state, &dummy_pack, (dummy_pos_x, dummy_pos_y), -); -result.push_separation = push_sep; -``` - -**Step 3:** Ensure FrameResult is properly exposed via wasm-bindgen. - ---- - -### Task 13: Add Property Accessors to WASM - -**Files:** `crates/framesmith-runtime-wasm/src/lib.rs` - -Add method to read character property by name: - -```rust -#[wasm_bindgen] -impl TrainingSession { - pub fn get_property(&self, name: &str) -> Option { - let pack = PackView::parse(&self.player_fspk).ok()?; - let props = pack.character_props()?; - let strings = pack.strings()?; - - for i in 0..props.len() { - let prop = props.get(i)?; - let (off, len) = prop.name(); - let prop_name = strings.get(off, len)?; - if prop_name == name { - return Some(from_q24_8(prop.as_q24_8())); - } - } - None - } -} -``` - ---- - -## Phase 3: Training Mode Integration (Tasks 14-17) - -### Task 14: Update TrainingMode Property Reading - -**Files:** `src/lib/views/TrainingMode.svelte` - -**Step 1:** Update health initialization (line ~257): - -```typescript -// Before: maxHealth = currentCharacter.character.health; -maxHealth = currentCharacter.character.properties?.health ?? 1000; -``` - -**Step 2:** Update walk speed reading (lines ~542-544): - -```typescript -// Before: const walkSpeed = char.walk_speed; -const walkSpeed = char.properties?.walk_speed ?? 4.5; -const backWalkSpeed = char.properties?.back_walk_speed ?? 3.2; -``` - -**Step 3:** Add type helper: - -```typescript -function getCharProp(char: Character, key: string, fallback: number): number { - const val = char.properties?.[key]; - return typeof val === 'number' ? val : fallback; -} -``` - ---- - -### Task 15: Add Pushbox Rendering to HitboxOverlay - -**Files:** `src/lib/components/training/HitboxOverlay.svelte` - -**Step 1:** Add pushbox colors: - -```typescript -const PUSHBOX_COLOR = 'rgba(255, 255, 0, 0.4)'; // Yellow -const PUSHBOX_STROKE = '#FFFF00'; -``` - -**Step 2:** Add pushbox drawing after hurtbox drawing: - -```typescript -if (move.pushboxes) { - for (const pb of move.pushboxes) { - const [startFrame, endFrame] = pb.frames; - if (currentFrame >= startFrame && currentFrame <= endFrame) { - const box = pb.box; - ctx.fillStyle = PUSHBOX_COLOR; - ctx.strokeStyle = PUSHBOX_STROKE; - ctx.fillRect(offsetX + box.x * scale, offsetY - (box.y + box.h) * scale, box.w * scale, box.h * scale); - ctx.strokeRect(offsetX + box.x * scale, offsetY - (box.y + box.h) * scale, box.w * scale, box.h * scale); - } - } -} -``` - ---- - -### Task 16: Handle Push Separation in TrainingMode - -**Files:** `src/lib/views/TrainingMode.svelte` - -In the tick handler, after processing hits: - -```typescript -if (result.push_separation) { - const [dx, _dy] = result.push_separation; - playerPosX += dx; - dummyPosX -= dx; -} -``` - ---- - -### Task 17: Add Pushbox Toggle to Training Debug View - -**Files:** `src/lib/views/TrainingMode.svelte` - -Add pushbox toggle to debug overlay controls alongside existing hitbox/hurtbox toggles. - ---- - -## Phase 4: UI Editor Integration (Tasks 18-21) - -### Task 18: Update CharacterOverview for Dynamic Properties - -**Files:** `src/lib/views/CharacterOverview.svelte` - -Replace hardcoded property display (lines 113-144) with dynamic iteration: - -```svelte -{#each Object.entries(character.properties ?? {}) as [key, value]} -
- {formatPropertyName(key)} - {formatPropertyValue(value)} -
-{/each} -``` - -Add helper functions for formatting property names and values. - ---- - -### Task 19: Add Character Property Editor Component - -**Files:** `src/lib/components/CharacterPropertyEditor.svelte` (NEW) - -Create a component for editing the dynamic properties map with: -- Key-value editing for each property -- Type-aware inputs (number/boolean/string) -- Add/remove property buttons - ---- - -### Task 20: Add Pushbox Section to StateEditor - -**Files:** `src/lib/views/StateEditor.svelte` - -Add pushboxes section after hurtboxes (~line 555): - -```svelte - - {#if state.pushboxes?.length} - {#each state.pushboxes as pb, i} - - {/each} - {:else} -

No pushboxes defined

- {/if} - -
-``` - -Add `addPushbox()` and `removePushbox()` helper functions. - ---- - -### Task 21: Add Pushbox Layer to MoveAnimationPreview - -**Files:** `src/lib/components/MoveAnimationPreview.svelte` - -**Step 1:** Extend Layer type: - -```typescript -type Layer = "hitboxes" | "hurtboxes" | "pushboxes"; -``` - -**Step 2:** Add layer colors: - -```typescript -const LAYER_COLORS = { - hitboxes: { fill: 'rgba(255, 0, 0, 0.3)', stroke: '#FF0000' }, - hurtboxes: { fill: 'rgba(0, 255, 0, 0.3)', stroke: '#00FF00' }, - pushboxes: { fill: 'rgba(255, 255, 0, 0.3)', stroke: '#FFFF00' }, -}; -``` - -**Step 3:** Update layer selector UI and `getLayerArray()` to handle pushboxes. - ---- - -## Phase 5: Test Data & Validation (Tasks 22-24) - -### Task 22: Update Test Character Data - -**Files:** `characters/test_char/character.json`, `characters/test_char/states/0_idle.json` - -**character.json:** -```json -{ - "id": "test_char", - "name": "TEST_CHAR", - "properties": { - "archetype": "all-rounder", - "health": 1000, - "walk_speed": 4.5, - "back_walk_speed": 3.2, - "jump_height": 120, - "jump_duration": 45, - "dash_distance": 80, - "dash_duration": 18 - }, - "resources": [...] -} -``` - -**0_idle.json:** Add pushboxes array: -```json -{ - "pushboxes": [ - { "frames": [0, 1], "box": { "x": -12, "y": -70, "w": 24, "h": 70 } } - ] -} -``` - ---- - -### Task 23: Run Full Test Suite - -```bash -cd src-tauri && cargo test -cd src-tauri && cargo clippy --all-targets -npm run check -npm run test:run -``` - ---- - -### Task 24: Manual Integration Test - -1. Open Framesmith with test_char -2. Verify character properties display correctly in CharacterOverview -3. Edit a property value, save, verify it persists -4. Open StateEditor for 0_idle, verify pushbox section appears -5. Add/edit a pushbox, save, verify it persists -6. Open Training Mode, verify: - - Health initializes from properties.health - - Walk speed uses properties.walk_speed - - Pushboxes render in overlay (yellow) - - Push separation works when characters overlap - ---- - -## Phase 6: Documentation (Task 25) - -### Task 25: Update Documentation - -**Files:** `docs/zx-fspack.md`, `docs/data-formats.md` - -- Document SECTION_CHARACTER_PROPS (section 21) format -- Document SECTION_PUSH_WINDOWS (section 22) format -- Update character.json example with properties map -- Add pushboxes to state documentation - ---- - -## Summary - -| Phase | Tasks | Description | -|-------|-------|-------------| -| 1 | 1-10 | Data layer (schema, FSPK, export) | -| 2 | 11-13 | Runtime layer (collision, WASM) | -| 3 | 14-17 | Training mode integration | -| 4 | 18-21 | UI editor integration | -| 5 | 22-24 | Test data & validation | -| 6 | 25 | Documentation | - -**Total Tasks:** 25 - -**Critical Dependencies:** -- Tasks 1-10 must complete before 11-13 (runtime needs format) -- Tasks 4-5 must complete before 14-21 (TypeScript needs schema) -- Tasks 11-13 must complete before 14-17 (training mode needs WASM) - ---- - -## Verification Checklist - -- [ ] `cargo test` passes -- [ ] `cargo clippy --all-targets` has no warnings -- [ ] `npm run check` passes -- [ ] `npm run test:run` passes -- [ ] Test character loads with new properties format -- [ ] Character properties editable in UI -- [ ] Pushboxes editable in StateEditor -- [ ] Pushboxes visible in MoveAnimationPreview -- [ ] Training mode reads properties correctly -- [ ] Training mode renders pushboxes -- [ ] Push collision separation works -- [ ] FSPK export includes both new sections diff --git a/docs/plans/2026-02-03-cancel-condition-bitfield.md b/docs/plans/2026-02-03-cancel-condition-bitfield.md deleted file mode 100644 index f80ca5b..0000000 --- a/docs/plans/2026-02-03-cancel-condition-bitfield.md +++ /dev/null @@ -1,691 +0,0 @@ -# Cancel Condition Bitfield Implementation Plan - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** Convert cancel conditions from discrete enum to bitfield, enabling combinations like "hit+block but not whiff", and remove deprecated `chains` field. - -**Architecture:** Replace `CancelCondition` enum (Always/Hit/Block/Whiff) with a `u8` bitfield where bit 0=hit, bit 1=block, bit 2=whiff. Remove `chains`, `special_cancels`, `super_cancels`, `jump_cancels` fields from `CancelTable`. All cancel logic flows through `tag_rules` with bitfield conditions. - -**Tech Stack:** Rust (schema, codegen, runtime), TypeScript/Svelte (UI), JSON (data format) - ---- - -## Overview - -### Bitfield Definition - -| Bit | Meaning | JSON syntax | -|-----|---------|-------------| -| 0 | on_hit | `"hit"` in array | -| 1 | on_block | `"block"` in array | -| 2 | on_whiff | `"whiff"` in array | - -### Common Values - -| Value | Binary | JSON | Meaning | -|-------|--------|------|---------| -| 7 | `0b111` | `"always"` or `["hit", "block", "whiff"]` | always | -| 3 | `0b011` | `["hit", "block"]` | hit + block (most common) | -| 1 | `0b001` | `["hit"]` or `"hit"` | hit only | -| 2 | `0b010` | `["block"]` or `"block"` | block only | -| 4 | `0b100` | `["whiff"]` or `"whiff"` | whiff only | -| 5 | `0b101` | `["hit", "whiff"]` | hit + whiff | -| 6 | `0b110` | `["block", "whiff"]` | block + whiff | - -### JSON Format - -The `on` field accepts either a string shorthand or an array: - -```json -// Shorthand for common cases -{ "from": "normal", "to": "special", "on": "always" } -{ "from": "normal", "to": "special", "on": "hit" } - -// Array for combinations -{ "from": "normal", "to": "special", "on": ["hit", "block"] } -{ "from": "normal", "to": "super", "on": ["hit"] } -``` - -### Files Changed - -| File | Change | -|------|--------| -| `src-tauri/src/schema/mod.rs` | Replace `CancelCondition` enum, remove `chains`/legacy fields from `CancelTable` | -| `src-tauri/src/codegen/zx_fspack.rs` | Update condition encoding, remove chain-related code | -| `crates/framesmith-fspack/src/view.rs` | Update condition decoding docs | -| `crates/framesmith-runtime/src/cancel.rs` | Update bitfield evaluation, remove chain cancel logic | -| `characters/test_char/cancel_table.json` | Update to new format | -| `src-tauri/tests/zx_fspack_roundtrip.rs` | Update tests | - ---- - -## Task 1: Update Schema - CancelCondition to Bitfield - -**Files:** -- Modify: `src-tauri/src/schema/mod.rs:372-443` - -**Step 1.1: Replace CancelCondition enum with bitfield type** - -Replace the existing `CancelCondition` enum (lines 372-381) with: - -```rust -/// Bit flags for cancel conditions -pub mod cancel_flags { - pub const HIT: u8 = 0b001; - pub const BLOCK: u8 = 0b010; - pub const WHIFF: u8 = 0b100; - pub const ALWAYS: u8 = 0b111; -} - -/// Cancel condition as a bitfield. -/// -/// Serializes as either a string shorthand ("always", "hit", "block", "whiff") -/// or an array of conditions (["hit", "block"]). -#[derive(Debug, Clone, PartialEq, Default)] -pub struct CancelCondition(pub u8); - -impl CancelCondition { - pub const ALWAYS: Self = Self(cancel_flags::ALWAYS); - pub const HIT: Self = Self(cancel_flags::HIT); - pub const BLOCK: Self = Self(cancel_flags::BLOCK); - pub const WHIFF: Self = Self(cancel_flags::WHIFF); - - /// Check if this condition matches the given hit/block state. - pub fn matches(&self, hit_confirmed: bool, block_confirmed: bool) -> bool { - if hit_confirmed && (self.0 & cancel_flags::HIT != 0) { - return true; - } - if block_confirmed && (self.0 & cancel_flags::BLOCK != 0) { - return true; - } - if !hit_confirmed && !block_confirmed && (self.0 & cancel_flags::WHIFF != 0) { - return true; - } - false - } - - /// Convert to the binary format value (same as inner u8). - pub fn to_binary(&self) -> u8 { - self.0 - } -} - -impl schemars::JsonSchema for CancelCondition { - fn schema_name() -> String { - "CancelCondition".to_string() - } - - fn json_schema(_gen: &mut schemars::gen::SchemaGenerator) -> schemars::schema::Schema { - use schemars::schema::{Schema, SchemaObject, InstanceType, SingleOrVec}; - - // Accept string or array of strings - let mut schema = SchemaObject::default(); - schema.instance_type = Some(SingleOrVec::Vec(vec![ - InstanceType::String, - InstanceType::Array, - ])); - Schema::Object(schema) - } -} - -impl Serialize for CancelCondition { - fn serialize(&self, serializer: S) -> Result { - match self.0 { - cancel_flags::ALWAYS => serializer.serialize_str("always"), - cancel_flags::HIT => serializer.serialize_str("hit"), - cancel_flags::BLOCK => serializer.serialize_str("block"), - cancel_flags::WHIFF => serializer.serialize_str("whiff"), - bits => { - // Serialize as array for combinations - use serde::ser::SerializeSeq; - let mut seq = serializer.serialize_seq(None)?; - if bits & cancel_flags::HIT != 0 { - seq.serialize_element("hit")?; - } - if bits & cancel_flags::BLOCK != 0 { - seq.serialize_element("block")?; - } - if bits & cancel_flags::WHIFF != 0 { - seq.serialize_element("whiff")?; - } - seq.end() - } - } - } -} - -impl<'de> Deserialize<'de> for CancelCondition { - fn deserialize>(deserializer: D) -> Result { - use serde::de::{self, Visitor, SeqAccess}; - - struct CancelConditionVisitor; - - impl<'de> Visitor<'de> for CancelConditionVisitor { - type Value = CancelCondition; - - fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { - formatter.write_str("a string ('always', 'hit', 'block', 'whiff') or array of conditions") - } - - fn visit_str(self, v: &str) -> Result { - match v { - "always" => Ok(CancelCondition(cancel_flags::ALWAYS)), - "hit" => Ok(CancelCondition(cancel_flags::HIT)), - "block" => Ok(CancelCondition(cancel_flags::BLOCK)), - "whiff" => Ok(CancelCondition(cancel_flags::WHIFF)), - _ => Err(de::Error::unknown_variant(v, &["always", "hit", "block", "whiff"])), - } - } - - fn visit_seq>(self, mut seq: A) -> Result { - let mut bits: u8 = 0; - while let Some(s) = seq.next_element::<&str>()? { - match s { - "hit" => bits |= cancel_flags::HIT, - "block" => bits |= cancel_flags::BLOCK, - "whiff" => bits |= cancel_flags::WHIFF, - _ => return Err(de::Error::unknown_variant(s, &["hit", "block", "whiff"])), - } - } - if bits == 0 { - return Err(de::Error::custom("cancel condition array cannot be empty")); - } - Ok(CancelCondition(bits)) - } - } - - deserializer.deserialize_any(CancelConditionVisitor) - } -} -``` - -**Step 1.2: Simplify CancelTable - remove deprecated fields** - -Replace `CancelTable` struct (lines 424-443) with: - -```rust -/// Cancel table defining all state relationships -#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema, Default)] -pub struct CancelTable { - /// Tag-based cancel rules (general patterns) - #[serde(default)] - pub tag_rules: Vec, - /// Explicit deny overrides - #[serde(default)] - pub deny: std::collections::HashMap>, -} -``` - -**Step 1.3: Run cargo check** - -Run: `cd src-tauri && cargo check` -Expected: Compilation errors in codegen (expected - we'll fix those next) - -**Step 1.4: Commit schema changes** - -```bash -git add src-tauri/src/schema/mod.rs -git commit -m "feat(schema): convert CancelCondition to bitfield, remove chains - -- CancelCondition now uses u8 bitfield (hit=0b001, block=0b010, whiff=0b100) -- Supports JSON as string shorthand or array: \"hit\", [\"hit\", \"block\"] -- Remove chains, special_cancels, super_cancels, jump_cancels from CancelTable -- All cancel logic now flows through tag_rules - -BREAKING CHANGE: chains field removed from cancel_table.json - -Co-Authored-By: Claude Opus 4.5 " -``` - ---- - -## Task 2: Update FSPK Codegen - -**Files:** -- Modify: `src-tauri/src/codegen/zx_fspack.rs:325-358, 445-465, 680-720, 1167-1172` - -**Step 2.1: Remove CancelLookup chain-related fields** - -Find `CancelLookup` struct (~line 327) and remove chain-related fields. Replace with: - -```rust -/// Precomputed lookup tables for cancel encoding. -/// -/// Contains HashSets for each cancel type, keyed by move input notation. -pub struct CancelLookup<'a> { - /// Moves that can cancel into specials (from legacy special_cancels, kept for flag compat) - pub specials: std::collections::HashSet<&'a str>, - /// Moves that can cancel into supers - pub supers: std::collections::HashSet<&'a str>, - /// Moves that can jump cancel - pub jumps: std::collections::HashSet<&'a str>, - /// Map from input notation to move index - pub input_to_index: std::collections::HashMap<&'a str, u16>, -} -``` - -**Step 2.2: Remove PackedMoveData cancel fields** - -Find `PackedMoveData` struct and remove: -- `cancels: Vec` -- `cancel_info: Vec<(u32, u16)>` - -**Step 2.3: Remove chain cancel flag logic** - -Find the move packing loop that sets `CANCEL_FLAG_CHAIN` (~line 448) and remove: - -```rust -// DELETE this block: -if lookup.chains.contains(input) { - flags |= super::zx_fspack_format::CANCEL_FLAG_CHAIN; -} -``` - -Also remove the chain cancel routes packing (~lines 461-475): - -```rust -// DELETE this block: -// Pack chain cancel routes into CANCELS_U16 section -if let Some(targets) = lookup.chain_routes.get(input) { - // ... entire block -} -``` - -**Step 2.4: Update CancelLookup construction** - -Find where `CancelLookup` is constructed (~line 680) and simplify: - -```rust -// Build cancel lookup from cancel_table -let cancel_lookup = CancelLookup { - specials: std::collections::HashSet::new(), // No longer used - supers: std::collections::HashSet::new(), - jumps: std::collections::HashSet::new(), - input_to_index: { - let mut map = std::collections::HashMap::new(); - for (i, mv) in char_data.states.iter().enumerate() { - map.insert(mv.input.as_str(), i as u16); - } - map - }, -}; -``` - -**Step 2.5: Update condition encoding** - -Find the condition encoding (~line 1167) and replace: - -```rust -// condition (1 byte) - now a bitfield -let condition: u8 = rule.on.to_binary(); -write_u8(&mut cancel_tag_rules_data, condition); -``` - -**Step 2.6: Remove CANCELS_U16 section emission** - -Find where `SECTION_CANCELS_U16` is added to sections (~line 1252) and remove it, since chains no longer exist: - -```rust -// DELETE: -sections.push(SectionData { - kind: SECTION_CANCELS_U16, - align: 2, - bytes: packed.cancels, -}); -``` - -**Step 2.7: Run cargo check** - -Run: `cd src-tauri && cargo check` -Expected: May have errors in tests (expected - we'll fix those in Task 4) - -**Step 2.8: Commit codegen changes** - -```bash -git add src-tauri/src/codegen/zx_fspack.rs -git commit -m "feat(codegen): update FSPK export for bitfield conditions - -- Remove chain cancel encoding (CANCELS_U16 section no longer emitted) -- CancelCondition now written as raw bitfield u8 -- Simplify CancelLookup to only track input->index mapping - -Co-Authored-By: Claude Opus 4.5 " -``` - ---- - -## Task 3: Update Runtime Cancel Evaluation - -**Files:** -- Modify: `crates/framesmith-runtime/src/cancel.rs:38-130` - -**Step 3.1: Remove chain cancel checking** - -Delete the entire chain cancels block (lines 58-76): - -```rust -// DELETE this entire block: -// 2. Check explicit chain cancels from move extras (rekkas, target combos) -if let Some(extras) = pack.state_extras() { - // ... entire block -} -``` - -**Step 3.2: Update condition evaluation to use bitfield** - -Replace the condition matching block (~lines 99-109) with: - -```rust -// Check condition bitfield -// bit 0 = hit, bit 1 = block, bit 2 = whiff -let condition = rule.condition(); -let condition_met = if state.hit_confirmed { - condition & 0b001 != 0 // HIT bit -} else if state.block_confirmed { - condition & 0b010 != 0 // BLOCK bit -} else { - condition & 0b100 != 0 // WHIFF bit -}; -if !condition_met { - continue; -} -``` - -**Step 3.3: Update function doc comment** - -Update the doc comment for `can_cancel_to` to reflect the new priority: - -```rust -/// Check if a cancel from current state to target move is valid. -/// -/// This checks (in priority order): -/// 1. Explicit denies - block specific cancels -/// 2. Tag-based rules (patterns like "normal->special on hit+block") -/// -/// Resource preconditions are checked for tag rules. -``` - -**Step 3.4: Remove available_cancels functions or simplify** - -The `available_cancels` and `available_cancels_buf` functions rely on the chain system. Either: -- Remove them entirely, OR -- Reimplement to iterate tag_rules (more complex) - -For now, remove them since they're behind `#[cfg(feature = "alloc")]` and can be re-added later if needed. - -**Step 3.5: Run cargo check in runtime crate** - -Run: `cd crates/framesmith-runtime && cargo check` -Expected: PASS - -**Step 3.6: Commit runtime changes** - -```bash -git add crates/framesmith-runtime/src/cancel.rs -git commit -m "feat(runtime): update cancel evaluation for bitfield conditions - -- Remove chain cancel checking (chains deprecated) -- Condition now evaluated as bitfield: hit=0b001, block=0b010, whiff=0b100 -- Simplify to: deny check -> tag rules only - -Co-Authored-By: Claude Opus 4.5 " -``` - ---- - -## Task 4: Update Test Data - -**Files:** -- Modify: `characters/test_char/cancel_table.json` - -**Step 4.1: Rewrite cancel_table.json with new format** - -Replace the entire file with: - -```json -{ - "tag_rules": [ - { "from": "system", "to": "any", "on": "always" }, - { "from": "movement", "to": "any", "on": "always" }, - { "from": "normal", "to": "special", "on": ["hit", "block"] }, - { "from": "normal", "to": "super", "on": ["hit", "block"] }, - { "from": "special", "to": "super", "on": ["hit", "block"] }, - - { "from": "5L", "to": "5L", "on": ["hit", "block"] }, - { "from": "5L", "to": "5M", "on": ["hit", "block"] }, - { "from": "5L", "to": "2L", "on": ["hit", "block"] }, - { "from": "5M", "to": "5H", "on": ["hit", "block"] }, - { "from": "2L", "to": "2L", "on": ["hit", "block"] }, - { "from": "2L", "to": "5M", "on": ["hit", "block"] }, - { "from": "j.L", "to": "j.L", "on": ["hit", "block"] }, - - { "from": "236K", "to": "236K~K", "on": ["hit", "block"] }, - { "from": "236K~K", "to": "236K~K~K", "on": ["hit", "block"] } - ], - "deny": {} -} -``` - -**Step 4.2: Add self-referential tags to states that need them** - -For each state that appears in `from` or `to` as a specific name (not a category tag), add a matching tag to that state's JSON. - -Modify `characters/test_char/states/5L.json` - add `"5L"` to tags: -```json -"tags": ["normal", "starter", "poke", "5L"], -``` - -Modify `characters/test_char/states/5M.json` - add tag: -```json -"tags": ["normal", "5M"], -``` - -Modify `characters/test_char/states/5H.json` - add tag: -```json -"tags": ["normal", "5H"], -``` - -Modify `characters/test_char/states/2L.json` - add tag: -```json -"tags": ["normal", "2L"], -``` - -Modify `characters/test_char/states/j.L.json` - add tag: -```json -"tags": ["normal", "aerial", "j.L"], -``` - -Modify `characters/test_char/states/236K.json` - add tag: -```json -"tags": ["special", "236K"], -``` - -Modify `characters/test_char/states/236K~K.json` - add tag: -```json -"tags": ["special", "236K~K"], -``` - -Modify `characters/test_char/states/236K~K~K.json` - add tag: -```json -"tags": ["special", "236K~K~K"], -``` - -**Step 4.3: Commit test data changes** - -```bash -git add characters/test_char/ -git commit -m "chore(test_char): update to bitfield cancel format - -- Convert cancel_table.json to use tag_rules only -- Add self-referential tags to states for explicit cancel routes -- Use [\"hit\", \"block\"] for combo-able cancels - -Co-Authored-By: Claude Opus 4.5 " -``` - ---- - -## Task 5: Update/Fix Tests - -**Files:** -- Modify: `src-tauri/tests/zx_fspack_roundtrip.rs` - -**Step 5.1: Remove chain-related tests** - -Find and remove these tests: -- `test_cancel_flags_exported` -- `test_chain_cancel_routes_exported` -- `test_move_extras_cancel_offsets` - -Or rewrite them to test the new tag_rules-only behavior. - -**Step 5.2: Add new condition bitfield test** - -Add a test for the new bitfield serialization: - -```rust -#[test] -fn test_cancel_condition_bitfield_roundtrip() { - use crate::schema::{CancelCondition, CancelTagRule, CancelTable}; - - // Test string shorthand - let json = r#"{"from": "normal", "to": "special", "on": "hit"}"#; - let rule: CancelTagRule = serde_json::from_str(json).unwrap(); - assert_eq!(rule.on.0, 0b001); - - // Test array format - let json = r#"{"from": "normal", "to": "special", "on": ["hit", "block"]}"#; - let rule: CancelTagRule = serde_json::from_str(json).unwrap(); - assert_eq!(rule.on.0, 0b011); - - // Test "always" shorthand - let json = r#"{"from": "any", "to": "any", "on": "always"}"#; - let rule: CancelTagRule = serde_json::from_str(json).unwrap(); - assert_eq!(rule.on.0, 0b111); - - // Test roundtrip serialization - let table = CancelTable { - tag_rules: vec![ - CancelTagRule { - from: "normal".to_string(), - to: "special".to_string(), - on: CancelCondition(0b011), // hit + block - after_frame: 0, - before_frame: 255, - }, - ], - deny: Default::default(), - }; - - let json = serde_json::to_string(&table).unwrap(); - let parsed: CancelTable = serde_json::from_str(&json).unwrap(); - assert_eq!(parsed.tag_rules[0].on.0, 0b011); -} -``` - -**Step 5.3: Run all tests** - -Run: `cd src-tauri && cargo test` -Expected: PASS - -**Step 5.4: Commit test updates** - -```bash -git add src-tauri/tests/ -git commit -m "test: update tests for bitfield cancel conditions - -- Remove chain-related tests (chains deprecated) -- Add test_cancel_condition_bitfield_roundtrip - -Co-Authored-By: Claude Opus 4.5 " -``` - ---- - -## Task 6: Update FSPK View Documentation - -**Files:** -- Modify: `crates/framesmith-fspack/src/view.rs:1996-2005` - -**Step 6.1: Update CancelTagRuleView docs** - -Update the doc comment for `CancelTagRuleView::condition()`: - -```rust -/// Get the condition bitfield. -/// -/// Bits: 0=hit, 1=block, 2=whiff -/// Common values: 7=always, 3=hit+block, 1=hit, 2=block, 4=whiff -pub fn condition(&self) -> u8 { - read_u8(self.data, 16).unwrap_or(0) -} -``` - -**Step 6.2: Run cargo check** - -Run: `cd crates/framesmith-fspack && cargo check` -Expected: PASS - -**Step 6.3: Commit docs update** - -```bash -git add crates/framesmith-fspack/src/view.rs -git commit -m "docs(fspack): document condition bitfield format - -Co-Authored-By: Claude Opus 4.5 " -``` - ---- - -## Task 7: Final Verification - -**Step 7.1: Run full test suite** - -```bash -cd src-tauri && cargo test -cd crates/framesmith-runtime && cargo test -cd crates/framesmith-fspack && cargo test -``` - -Expected: All tests PASS - -**Step 7.2: Run clippy** - -```bash -cd src-tauri && cargo clippy --all-targets -``` - -Expected: No warnings - -**Step 7.3: Test export with test_char** - -Run the Tauri dev server and verify test_char exports correctly: - -```bash -npm run tauri dev -``` - -Then in the app, open test_char and export to FSPK. Verify no errors. - -**Step 7.4: Final commit if any fixups needed** - -```bash -git add -A -git commit -m "chore: final cleanup for cancel bitfield migration - -Co-Authored-By: Claude Opus 4.5 " -``` - ---- - -## Summary - -| Task | Description | Estimated Complexity | -|------|-------------|---------------------| -| 1 | Schema: CancelCondition bitfield | Medium | -| 2 | Codegen: Remove chains, update encoding | Medium | -| 3 | Runtime: Bitfield evaluation | Low | -| 4 | Test data: Update cancel_table.json | Low | -| 5 | Tests: Update/remove chain tests | Medium | -| 6 | Docs: Update FSPK view docs | Low | -| 7 | Final verification | Low | diff --git a/docs/plans/2026-02-03-fspk-refactor.md b/docs/plans/2026-02-03-fspk-refactor.md deleted file mode 100644 index c89e715..0000000 --- a/docs/plans/2026-02-03-fspk-refactor.md +++ /dev/null @@ -1,1383 +0,0 @@ -# FSPK Module Refactor Implementation Plan - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** Rename `zx_fspack` → `fspk` and split the 2,773-line monolith into focused modules for LLM-friendliness. - -**Architecture:** Create `fspk/` subdirectory module with 8 focused files (~150-400 lines each). Rename `zx_fspack_format.rs` → `fspk_format.rs`. Update all callers. - -**Tech Stack:** Rust (Tauri backend), Svelte/TypeScript (frontend) - ---- - -## Task 1: Create fspk/ Directory Structure - -**Files:** -- Create: `src-tauri/src/codegen/fspk/mod.rs` - -**Step 1: Create the module directory and mod.rs** - -```rust -//! FSPK (Framesmith Pack) binary export adapter -//! -//! This module exports character data to the FSPK binary format. -//! The format is engine-agnostic and optimized for no_std/WASM runtimes. - -mod export; -mod moves; -mod packing; -mod properties; -mod sections; -mod types; -mod utils; - -pub use export::export_fspk; -pub use moves::{build_asset_keys, pack_moves}; -pub use properties::pack_character_props; -pub use types::{CancelLookup, PackedMoveData, StrRef, StringTable}; -``` - -**Step 2: Verify the directory was created** - -Run: `dir src-tauri\src\codegen\fspk` -Expected: mod.rs exists - ---- - -## Task 2: Extract types.rs - -**Files:** -- Create: `src-tauri/src/codegen/fspk/types.rs` - -**Step 1: Create types.rs with StringTable, CancelLookup, PackedMoveData, StrRef** - -Extract from `zx_fspack.rs` lines 50-111 (StringTable) and 324-344 (CancelLookup, PackedMoveData, StrRef): - -```rust -//! Core types for FSPK binary packing. - -use std::collections::HashMap; - -use super::utils::{checked_u16, checked_u32}; - -/// A string reference as (offset, length) pair into the string table. -pub type StrRef = (u32, u16); - -/// Interned string table for deduplication. -/// -/// Strings are stored as raw UTF-8 bytes. The `index` map stores (offset, length) -/// pairs for each unique string that has been interned. -pub struct StringTable { - data: Vec, - /// Map from string to (offset, length) in data - index: HashMap, -} - -impl StringTable { - /// Create a new empty string table. - pub fn new() -> Self { - Self { - data: Vec::new(), - index: HashMap::new(), - } - } - - /// Intern a string, returning its (offset, length) in the table. - /// - /// If the string was already interned, returns the existing location. - /// Otherwise, appends the string to the data and records its location. - pub fn intern(&mut self, s: &str) -> Result<(u32, u16), String> { - if let Some(&loc) = self.index.get(s) { - return Ok(loc); - } - - let offset = checked_u32(self.data.len(), "string table offset")?; - let len = checked_u16(s.len(), "string table string length")?; - self.data.extend_from_slice(s.as_bytes()); - self.index.insert(s.to_string(), (offset, len)); - Ok((offset, len)) - } - - /// Consume the string table and return the raw byte data. - pub fn into_bytes(self) -> Vec { - self.data - } - - /// Get the current byte length of the string table data. - #[allow(dead_code)] - pub fn len(&self) -> usize { - self.data.len() - } - - /// Check if the string table is empty. - #[allow(dead_code)] - pub fn is_empty(&self) -> bool { - self.data.is_empty() - } -} - -impl Default for StringTable { - fn default() -> Self { - Self::new() - } -} - -/// Cancel lookup data for export. -/// -/// Maps move input notation to move index for resolving cancel denies. -pub struct CancelLookup<'a> { - /// Map from input notation to move index - pub input_to_index: HashMap<&'a str, u16>, -} - -/// Packed move data with backing arrays. -pub struct PackedMoveData { - /// MOVES section: array of MoveRecord (36 bytes each) - pub moves: Vec, - /// SHAPES section: array of Shape12 (12 bytes each) - pub shapes: Vec, - /// HIT_WINDOWS section: array of HitWindow24 (24 bytes each) - pub hit_windows: Vec, - /// HURT_WINDOWS section: array of HurtWindow12 (12 bytes each) - pub hurt_windows: Vec, - /// PUSH_WINDOWS section: array of PushWindow12 (12 bytes each, same format as HurtWindow12) - pub push_windows: Vec, -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_string_table_intern_returns_same_location_for_same_string() { - let mut table = StringTable::new(); - let loc1 = table.intern("hello").unwrap(); - let loc2 = table.intern("hello").unwrap(); - assert_eq!(loc1, loc2, "Same string should return same location"); - } - - #[test] - fn test_string_table_intern_different_strings() { - let mut table = StringTable::new(); - let loc1 = table.intern("hello").unwrap(); - let loc2 = table.intern("world").unwrap(); - assert_ne!(loc1.0, loc2.0, "Different strings should have different offsets"); - assert_eq!(loc1.0, 0, "First string should start at offset 0"); - assert_eq!(loc1.1, 5, "\"hello\" has length 5"); - assert_eq!(loc2.0, 5, "Second string should start after first"); - assert_eq!(loc2.1, 5, "\"world\" has length 5"); - } - - #[test] - fn test_string_table_into_bytes() { - let mut table = StringTable::new(); - table.intern("abc").unwrap(); - table.intern("def").unwrap(); - let bytes = table.into_bytes(); - assert_eq!(bytes, b"abcdef"); - } -} -``` - -**Step 2: Verify compilation** - -Run: `cd src-tauri && cargo check` -Expected: Compiles (will have errors until all modules exist) - ---- - -## Task 3: Extract utils.rs - -**Files:** -- Create: `src-tauri/src/codegen/fspk/utils.rs` - -**Step 1: Create utils.rs with helper functions** - -Extract from `zx_fspack.rs` lines 22-48 and 557-592: - -```rust -//! Utility functions for FSPK binary packing. - -use crate::codegen::fspk_format::{write_u16_le, write_u32_le}; - -use super::types::StrRef; - -pub fn checked_u16(value: usize, what: &str) -> Result { - u16::try_from(value).map_err(|_| format!("{} overflows u16: {}", what, value)) -} - -pub fn checked_u32(value: usize, what: &str) -> Result { - u32::try_from(value).map_err(|_| format!("{} overflows u32: {}", what, value)) -} - -pub fn align_up(value: usize, align: u32) -> Result { - if align == 0 { - return Err("alignment must be non-zero".to_string()); - } - if !align.is_power_of_two() { - return Err(format!("alignment must be power of two, got {}", align)); - } - - let align = align as usize; - if align == 1 { - return Ok(value); - } - - let mask = align - 1; - let v = value - .checked_add(mask) - .ok_or_else(|| "align_up overflow".to_string())?; - Ok(v & !mask) -} - -/// Write a string reference (StrRef) to the buffer. -/// -/// StrRef layout: offset(u32) + length(u16) + padding(u16) -pub fn write_strref(buf: &mut Vec, strref: StrRef) { - write_u32_le(buf, strref.0); // offset - write_u16_le(buf, strref.1); // length - write_u16_le(buf, 0); // padding -} - -pub fn write_range(buf: &mut Vec, off: u32, len: u16) { - write_u32_le(buf, off); - write_u16_le(buf, len); - write_u16_le(buf, 0); -} - -pub fn write_i32_le(buf: &mut Vec, value: i32) { - buf.extend_from_slice(&value.to_le_bytes()); -} - -pub fn write_i64_le(buf: &mut Vec, value: i64) { - buf.extend_from_slice(&value.to_le_bytes()); -} - -pub fn write_u64_le(buf: &mut Vec, value: u64) { - buf.extend_from_slice(&value.to_le_bytes()); -} - -/// Write a section header to the buffer. -/// -/// Section header layout: kind(u32) + offset(u32) + length(u32) + alignment(u32) -pub fn write_section_header(buf: &mut Vec, kind: u32, offset: u32, length: u32, alignment: u32) { - write_u32_le(buf, kind); - write_u32_le(buf, offset); - write_u32_le(buf, length); - write_u32_le(buf, alignment); -} -``` - -**Step 2: Verify compilation** - -Run: `cd src-tauri && cargo check` - ---- - -## Task 4: Extract packing.rs - -**Files:** -- Create: `src-tauri/src/codegen/fspk/packing.rs` - -**Step 1: Create packing.rs with shape/hitbox/move record packing** - -Extract from `zx_fspack.rs` lines 117-322: - -```rust -//! Binary record packing for shapes, hitboxes, and move records. - -use crate::codegen::fspk_format::{ - to_q12_4, to_q12_4_unsigned, HIT_WINDOW24_SIZE, HURT_WINDOW12_SIZE, SHAPE12_SIZE, - SHAPE_KIND_AABB, STATE_RECORD_SIZE, -}; -use crate::schema::{FrameHitbox, GuardType, Rect, State}; - -/// Pack a Rect into a Shape12 (AABB) structure. -/// -/// Shape12 layout: -/// - kind (u8): shape type (0 = AABB) -/// - flags (u8): reserved -/// - a (i16): x position (Q12.4) -/// - b (i16): y position (Q12.4) -/// - c (u16): width (Q12.4 unsigned) -/// - d (u16): height (Q12.4 unsigned) -/// - e (i16): unused for AABB -pub fn pack_shape(rect: &Rect) -> [u8; SHAPE12_SIZE] { - let mut buf = [0u8; SHAPE12_SIZE]; - buf[0] = SHAPE_KIND_AABB; // kind - buf[1] = 0; // flags - - let x = to_q12_4(rect.x as f32); - let y = to_q12_4(rect.y as f32); - let w = to_q12_4_unsigned(rect.w as f32); - let h = to_q12_4_unsigned(rect.h as f32); - - buf[2..4].copy_from_slice(&x.to_le_bytes()); // a = x - buf[4..6].copy_from_slice(&y.to_le_bytes()); // b = y - buf[6..8].copy_from_slice(&w.to_le_bytes()); // c = w - buf[8..10].copy_from_slice(&h.to_le_bytes()); // d = h - buf[10..12].copy_from_slice(&0i16.to_le_bytes()); // e = 0 - - buf -} - -/// Convert GuardType to u8 for binary encoding. -pub fn guard_type_to_u8(guard: &GuardType) -> u8 { - match guard { - GuardType::High => 0, - GuardType::Mid => 1, - GuardType::Low => 2, - GuardType::Unblockable => 3, - } -} - -/// Pack a FrameHitbox into a HitWindow24 structure. -/// -/// HitWindow24 layout (24 bytes) - must match view.rs HitWindowView: -/// - 0: start_frame (u8) -/// - 1: end_frame (u8) -/// - 2: guard (u8) -/// - 3: reserved (u8) -/// - 4-5: damage (u16 LE) -/// - 6-7: chip_damage (u16 LE) -/// - 8: hitstun (u8) -/// - 9: blockstun (u8) -/// - 10: hitstop (u8) -/// - 11: reserved (u8) -/// - 12-15: shapes_off (u32 LE) -/// - 16-17: shapes_len (u16 LE) -/// - 18-21: cancels_off (u32 LE) -/// - 22-23: cancels_len (u16 LE) -pub fn pack_hit_window( - hb: &FrameHitbox, - shapes_off: u32, - damage: u16, - hitstun: u8, - blockstun: u8, - hitstop: u8, - guard: u8, -) -> [u8; HIT_WINDOW24_SIZE] { - let mut buf = [0u8; HIT_WINDOW24_SIZE]; - - buf[0] = hb.frames.0; // start_frame - buf[1] = hb.frames.1; // end_frame - buf[2] = guard; // guard - buf[3] = 0; // reserved - buf[4..6].copy_from_slice(&damage.to_le_bytes()); // damage - buf[6..8].copy_from_slice(&0u16.to_le_bytes()); // chip_damage (TODO: add to schema) - buf[8] = hitstun; // hitstun - buf[9] = blockstun; // blockstun - buf[10] = hitstop; // hitstop - buf[11] = 0; // reserved - buf[12..16].copy_from_slice(&shapes_off.to_le_bytes()); // shapes_off - buf[16..18].copy_from_slice(&1u16.to_le_bytes()); // shapes_len = 1 - // bytes 18-27 are cancels/pushback (already zeroed, not used in v1) - - buf -} - -/// Pack a FrameHitbox into a HurtWindow12 structure. -/// -/// HurtWindow12 layout (12 bytes): -/// - frame_start (u8): first active frame -/// - frame_end (u8): last active frame -/// - shape_off (u32): offset into SHAPES section -/// - shape_count (u16): number of shapes (always 1 for v1) -/// - flags (u16): hurtbox flags (invuln, armor, etc.) -/// - reserved (2 bytes): padding -pub fn pack_hurt_window(hb: &FrameHitbox, shapes_off: u32) -> [u8; HURT_WINDOW12_SIZE] { - let mut buf = [0u8; HURT_WINDOW12_SIZE]; - - buf[0] = hb.frames.0; // frame_start - buf[1] = hb.frames.1; // frame_end - buf[2..6].copy_from_slice(&shapes_off.to_le_bytes()); // shape_off - buf[6..8].copy_from_slice(&1u16.to_le_bytes()); // shape_count = 1 - buf[8..10].copy_from_slice(&0u16.to_le_bytes()); // flags = 0 for v1 - // bytes 10-11 are reserved/padding (already zeroed) - - buf -} - -/// Convert move type string to u8 for binary encoding. -/// Maps common type strings to fixed IDs for runtime compatibility. -pub fn move_type_to_u8(move_type: Option<&String>) -> u8 { - match move_type.map(|s| s.as_str()) { - Some("normal") => 0, - Some("command_normal") => 1, - Some("special") => 2, - Some("super") => 3, - Some("movement") => 4, - Some("throw") => 5, - Some("ex") => 6, - Some("rekka") => 7, - Some(_) => 255, // unknown custom type - None => 0, // default to normal - } -} - -/// Convert TriggerType to u8 for binary encoding. -pub fn trigger_type_to_u8(trigger: Option<&crate::schema::TriggerType>) -> u8 { - use crate::schema::TriggerType; - match trigger { - Some(TriggerType::Press) => 0, - Some(TriggerType::Release) => 1, - Some(TriggerType::Hold) => 2, - None => 0, // default to Press - } -} - -/// Pack a Move into a MoveRecord structure. -/// -/// MoveRecord layout (36 bytes): -/// - 0-1: move_id (u16) -/// - 2-3: mesh_key (u16) -/// - 4-5: keyframes_key (u16) -/// - 6: move_type (u8) -/// - 7: trigger (u8) -/// - 8: guard (u8) -/// - 9: flags (u8) -/// - 10: startup (u8) -/// - 11: active (u8) -/// - 12: recovery (u8) -/// - 13: reserved (u8) -/// - 14-15: total (u16) -/// - 16-17: damage (u16) -/// - 18: hitstun (u8) -/// - 19: blockstun (u8) -/// - 20: hitstop (u8) -/// - 21: reserved (u8) -/// - 22-25: hit_windows_off (u32) -/// - 26-27: hit_windows_len (u16) -/// - 28-29: hurt_windows_off (u16) -/// - 30-31: hurt_windows_len (u16) -/// - 32-33: push_windows_off (u16) -/// - 34-35: push_windows_len (u16) -#[allow(clippy::too_many_arguments)] // Binary record packing requires all fields -pub fn pack_move_record( - move_id: u16, - mesh_key: u16, - keyframes_key: u16, - mv: &State, - hit_windows_off: u32, - hit_windows_len: u16, - hurt_windows_off: u16, - hurt_windows_len: u16, - push_windows_off: u16, - push_windows_len: u16, - flags: u8, -) -> [u8; STATE_RECORD_SIZE] { - let mut buf = [0u8; STATE_RECORD_SIZE]; - - buf[0..2].copy_from_slice(&move_id.to_le_bytes()); // move_id - buf[2..4].copy_from_slice(&mesh_key.to_le_bytes()); // mesh_key - buf[4..6].copy_from_slice(&keyframes_key.to_le_bytes()); // keyframes_key - buf[6] = move_type_to_u8(mv.move_type.as_ref()); // move_type - buf[7] = trigger_type_to_u8(mv.trigger.as_ref()); // trigger - buf[8] = guard_type_to_u8(&mv.guard); // guard - buf[9] = flags; // cancel flags - buf[10] = mv.startup; // startup - buf[11] = mv.active; // active - buf[12] = mv.recovery; // recovery - buf[13] = 0; // reserved - let total = mv - .total - .map(|t| t as u16) - .unwrap_or_else(|| (mv.startup as u16) + (mv.active as u16) + (mv.recovery as u16)); - buf[14..16].copy_from_slice(&total.to_le_bytes()); // total - buf[16..18].copy_from_slice(&mv.damage.to_le_bytes()); // damage - buf[18] = mv.hitstun; // hitstun - buf[19] = mv.blockstun; // blockstun - buf[20] = mv.hitstop; // hitstop - buf[21] = 0; // reserved - buf[22..26].copy_from_slice(&hit_windows_off.to_le_bytes()); // hit_windows_off - buf[26..28].copy_from_slice(&hit_windows_len.to_le_bytes()); // hit_windows_len - buf[28..30].copy_from_slice(&hurt_windows_off.to_le_bytes()); // hurt_windows_off (u16) - buf[30..32].copy_from_slice(&hurt_windows_len.to_le_bytes()); // hurt_windows_len - buf[32..34].copy_from_slice(&push_windows_off.to_le_bytes()); // push_windows_off (u16) - buf[34..36].copy_from_slice(&push_windows_len.to_le_bytes()); // push_windows_len - - buf -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::codegen::fspk_format::SHAPE_KIND_AABB; - use crate::schema::{MeterGain, Pushback}; - - fn make_test_rect() -> Rect { - Rect { x: 10, y: 20, w: 50, h: 60 } - } - - fn make_test_hitbox() -> FrameHitbox { - FrameHitbox { - frames: (5, 8), - r#box: make_test_rect(), - } - } - - #[test] - fn test_pack_shape() { - let rect = make_test_rect(); - let shape = pack_shape(&rect); - - assert_eq!(shape.len(), SHAPE12_SIZE); - assert_eq!(shape[0], SHAPE_KIND_AABB); - assert_eq!(shape[1], 0); // flags - - // x=10 -> Q12.4 = 160 = 0x00A0 - let x = i16::from_le_bytes([shape[2], shape[3]]); - assert_eq!(x, 160); - - // y=20 -> Q12.4 = 320 = 0x0140 - let y = i16::from_le_bytes([shape[4], shape[5]]); - assert_eq!(y, 320); - - // w=50 -> Q12.4 = 800 = 0x0320 - let w = u16::from_le_bytes([shape[6], shape[7]]); - assert_eq!(w, 800); - - // h=60 -> Q12.4 = 960 = 0x03C0 - let h = u16::from_le_bytes([shape[8], shape[9]]); - assert_eq!(h, 960); - } - - #[test] - fn test_pack_hit_window() { - let hb = make_test_hitbox(); - let hw = pack_hit_window(&hb, 100, 500, 12, 8, 10, 1); - - assert_eq!(hw.len(), HIT_WINDOW24_SIZE); - assert_eq!(hw[0], 5); // frame_start - assert_eq!(hw[1], 8); // frame_end - assert_eq!(hw[2], 1); // guard (mid) - } - - #[test] - fn test_pack_hurt_window() { - let hb = make_test_hitbox(); - let hw = pack_hurt_window(&hb, 200); - - assert_eq!(hw.len(), HURT_WINDOW12_SIZE); - assert_eq!(hw[0], 5); // frame_start - assert_eq!(hw[1], 8); // frame_end - - let shape_off = u32::from_le_bytes([hw[2], hw[3], hw[4], hw[5]]); - assert_eq!(shape_off, 200); - } - - #[test] - fn test_guard_type_encoding() { - assert_eq!(guard_type_to_u8(&GuardType::High), 0); - assert_eq!(guard_type_to_u8(&GuardType::Mid), 1); - assert_eq!(guard_type_to_u8(&GuardType::Low), 2); - assert_eq!(guard_type_to_u8(&GuardType::Unblockable), 3); - } - - #[test] - fn test_negative_coordinates() { - let rect = Rect { x: -50, y: -100, w: 30, h: 40 }; - let shape = pack_shape(&rect); - - let x = i16::from_le_bytes([shape[2], shape[3]]); - assert_eq!(x, -800); // -50 -> Q12.4 = -800 - - let y = i16::from_le_bytes([shape[4], shape[5]]); - assert_eq!(y, -1600); // -100 -> Q12.4 = -1600 - } -} -``` - -**Step 2: Verify compilation** - -Run: `cd src-tauri && cargo check` - ---- - -## Task 5: Extract moves.rs - -**Files:** -- Create: `src-tauri/src/codegen/fspk/moves.rs` - -**Step 1: Create moves.rs with pack_moves and build_asset_keys** - -Extract from `zx_fspack.rs` lines 346-497: - -```rust -//! Move packing and asset key generation. - -use std::collections::HashMap; - -use crate::codegen::fspk_format::KEY_NONE; -use crate::commands::CharacterData; -use crate::schema::State; - -use super::packing::{guard_type_to_u8, pack_hit_window, pack_hurt_window, pack_move_record, pack_shape}; -use super::types::{CancelLookup, PackedMoveData, StrRef, StringTable}; -use super::utils::{checked_u16, checked_u32}; - -/// Pack all moves into binary sections. -/// -/// Returns packed move data with all backing arrays. -/// -/// The `anim_to_index` map provides indices into the MESH_KEYS/KEYFRAMES_KEYS arrays -/// for each animation name. If None, all moves use KEY_NONE for asset references. -/// -/// The `cancel_lookup` provides cancel information for setting MoveRecord.flags. -/// If None, all flags are 0. -pub fn pack_moves( - moves: &[State], - anim_to_index: Option<&HashMap>, - cancel_lookup: Option<&CancelLookup>, -) -> Result { - let mut packed = PackedMoveData { - moves: Vec::new(), - shapes: Vec::new(), - hit_windows: Vec::new(), - hurt_windows: Vec::new(), - push_windows: Vec::new(), - }; - - for (idx, mv) in moves.iter().enumerate() { - let move_id = checked_u16(idx, "move_id")?; - - // Look up animation index if map is provided - let anim_index = anim_to_index - .and_then(|map| { - if mv.animation.is_empty() { - None - } else { - map.get(&mv.animation).copied() - } - }) - .unwrap_or(KEY_NONE); - - // Track offsets before adding this move's data - let hit_windows_off = checked_u32(packed.hit_windows.len(), "hit_windows_off")?; - let hurt_windows_off = checked_u16(packed.hurt_windows.len(), "hurt_windows_off")?; - let push_windows_off = checked_u16(packed.push_windows.len(), "push_windows_off")?; - - // Pack hitboxes -> shapes + hit_windows - for hb in &mv.hitboxes { - let shape_off = checked_u32(packed.shapes.len(), "shape_off")?; - packed.shapes.extend_from_slice(&pack_shape(&hb.r#box)); - packed.hit_windows.extend_from_slice(&pack_hit_window( - hb, - shape_off, - mv.damage, - mv.hitstun, - mv.blockstun, - mv.hitstop, - guard_type_to_u8(&mv.guard), - )); - } - - // Pack hurtboxes -> shapes + hurt_windows - for hb in &mv.hurtboxes { - let shape_off = checked_u32(packed.shapes.len(), "shape_off")?; - packed.shapes.extend_from_slice(&pack_shape(&hb.r#box)); - packed.hurt_windows.extend_from_slice(&pack_hurt_window(hb, shape_off)); - } - - // Pack pushboxes -> shapes + push_windows (same 12-byte format as hurt windows) - for pb in &mv.pushboxes { - let shape_off = checked_u32(packed.shapes.len(), "shape_off")?; - packed.shapes.extend_from_slice(&pack_shape(&pb.r#box)); - packed.push_windows.extend_from_slice(&pack_hurt_window(pb, shape_off)); - } - - // Calculate lengths - let hit_windows_len = checked_u16(mv.hitboxes.len(), "hit_windows_len")?; - let hurt_windows_len = checked_u16(mv.hurtboxes.len(), "hurt_windows_len")?; - let push_windows_len = checked_u16(mv.pushboxes.len(), "push_windows_len")?; - - // Cancel flags are now handled via tag_rules, so MoveRecord.flags is always 0 - let flags: u8 = 0; - let _ = cancel_lookup; // Silence unused warning; used later for deny resolution - - // Pack move record - mesh_key and keyframes_key both use the same animation index - packed.moves.extend_from_slice(&pack_move_record( - move_id, - anim_index, // mesh_key - anim_index, // keyframes_key - mv, - hit_windows_off, - hit_windows_len, - hurt_windows_off, - hurt_windows_len, - push_windows_off, - push_windows_len, - flags, - )); - } - - Ok(packed) -} - -/// Build asset key arrays from character data. -/// -/// Returns two vectors of string references: -/// - `mesh_keys`: Keys for mesh assets, format: "{character_id}.{animation}" -/// - `keyframes_keys`: Keys for keyframes assets, format: "{animation}" -/// -/// Keys are sorted deterministically by their string value to ensure -/// reproducible output. Duplicate animations are deduplicated. -pub fn build_asset_keys( - char_data: &CharacterData, - strings: &mut StringTable, -) -> Result<(Vec, Vec), String> { - // Collect unique animation names - let mut animations: Vec<&str> = char_data - .moves - .iter() - .filter(|m| !m.animation.is_empty()) - .map(|m| m.animation.as_str()) - .collect(); - - // Deduplicate and sort for determinism - animations.sort(); - animations.dedup(); - - let character_id = &char_data.character.id; - - // Build mesh keys: "{character_id}.{animation}" - let mesh_keys: Vec = animations - .iter() - .map(|anim| { - let mesh_key = format!("{}.{}", character_id, anim); - strings.intern(&mesh_key) - }) - .collect::, _>>()?; - - // Build keyframes keys: just the animation name - let keyframes_keys: Vec = animations - .iter() - .map(|anim| strings.intern(anim)) - .collect::, _>>()?; - - Ok((mesh_keys, keyframes_keys)) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::codegen::fspk_format::{HURT_WINDOW12_SIZE, STATE_RECORD_SIZE}; - use crate::schema::{CancelTable, Character, FrameHitbox, GuardType, MeterGain, Pushback, Rect, State}; - use std::collections::BTreeMap; - - fn make_test_character(id: &str) -> Character { - use crate::schema::PropertyValue; - let mut properties = BTreeMap::new(); - properties.insert("health".to_string(), PropertyValue::Number(1000.0)); - - Character { - id: id.to_string(), - name: "Test Character".to_string(), - properties, - resources: vec![], - } - } - - fn make_test_move(input: &str, animation: &str) -> State { - State { - input: input.to_string(), - name: format!("{} attack", input), - tags: vec![], - startup: 5, - active: 3, - recovery: 10, - damage: 50, - hitstun: 15, - blockstun: 10, - hitstop: 5, - guard: GuardType::Mid, - hitboxes: vec![], - hurtboxes: vec![], - pushback: Pushback { hit: 10, block: 5 }, - meter_gain: MeterGain { hit: 10, whiff: 5 }, - animation: animation.to_string(), - ..Default::default() - } - } - - fn make_empty_cancel_table() -> CancelTable { - CancelTable::default() - } - - #[test] - fn test_build_asset_keys_deterministic() { - let char_data = CharacterData { - character: make_test_character("test_char"), - moves: vec![ - make_test_move("5H", "stand_heavy"), - make_test_move("5L", "stand_light"), - make_test_move("5M", "stand_medium"), - ], - cancel_table: make_empty_cancel_table(), - }; - - let mut strings1 = StringTable::new(); - let (mesh_keys1, kf_keys1) = build_asset_keys(&char_data, &mut strings1).unwrap(); - - let char_data2 = CharacterData { - character: make_test_character("test_char"), - moves: vec![ - make_test_move("5M", "stand_medium"), - make_test_move("5L", "stand_light"), - make_test_move("5H", "stand_heavy"), - ], - cancel_table: make_empty_cancel_table(), - }; - - let mut strings2 = StringTable::new(); - let (mesh_keys2, kf_keys2) = build_asset_keys(&char_data2, &mut strings2).unwrap(); - - assert_eq!(mesh_keys1.len(), mesh_keys2.len()); - assert_eq!(kf_keys1.len(), kf_keys2.len()); - assert_eq!(strings1.into_bytes(), strings2.into_bytes()); - } - - #[test] - fn test_build_asset_keys_deduplication() { - let char_data = CharacterData { - character: make_test_character("test_char"), - moves: vec![ - make_test_move("5L", "stand_light"), - make_test_move("2L", "stand_light"), // Same animation - make_test_move("5M", "stand_medium"), - ], - cancel_table: make_empty_cancel_table(), - }; - - let mut strings = StringTable::new(); - let (mesh_keys, kf_keys) = build_asset_keys(&char_data, &mut strings).unwrap(); - - assert_eq!(mesh_keys.len(), 2, "Duplicate animations should be deduplicated"); - assert_eq!(kf_keys.len(), 2); - } - - #[test] - fn test_pack_moves_empty() { - let packed = pack_moves(&[], None, None).unwrap(); - assert_eq!(packed.moves.len(), 0); - assert_eq!(packed.shapes.len(), 0); - assert_eq!(packed.hit_windows.len(), 0); - assert_eq!(packed.hurt_windows.len(), 0); - } - - #[test] - fn test_pack_moves_count_matches() { - let moves = vec![ - make_test_move("5L", "stand_light"), - make_test_move("5M", "stand_medium"), - ]; - let packed = pack_moves(&moves, None, None).unwrap(); - - let move_count = packed.moves.len() / STATE_RECORD_SIZE; - assert_eq!(move_count, 2); - } -} -``` - -**Step 2: Verify compilation** - -Run: `cd src-tauri && cargo check` - ---- - -## Task 6: Extract properties.rs - -**Files:** -- Create: `src-tauri/src/codegen/fspk/properties.rs` - -**Step 1: Create properties.rs with pack_character_props** - -Extract from `zx_fspack.rs` lines 499-555: - -```rust -//! Character properties packing. - -use crate::codegen::fspk_format::{ - to_q24_8, write_u16_le, write_u32_le, write_u8, PROP_TYPE_BOOL, PROP_TYPE_Q24_8, PROP_TYPE_STR, -}; -use crate::schema::Character; - -use super::types::StringTable; -use super::utils::checked_u16; - -/// Pack character properties into the CHARACTER_PROPS section. -/// -/// Each property record is 12 bytes (CHARACTER_PROP12_SIZE): -/// - bytes 0-3: name offset (u32) into string pool -/// - bytes 4-5: name length (u16) -/// - bytes 6: value type (u8) - 0=Q24.8 number, 1=bool, 2=string ref -/// - byte 7: reserved/padding -/// - bytes 8-11: value (u32/i32 depending on type) -/// -/// For string values, the value field contains (offset: u16, len: u16) packed into u32. -pub fn pack_character_props( - character: &Character, - strings: &mut StringTable, -) -> Result, String> { - use crate::schema::PropertyValue; - - // CHARACTER_PROP12_SIZE = 12 bytes per property - let mut data = Vec::with_capacity(character.properties.len() * 12); - - // BTreeMap iterates in sorted key order, ensuring deterministic output - for (name, value) in &character.properties { - // Write name reference (offset + length) - let (name_off, name_len) = strings.intern(name)?; - write_u32_le(&mut data, name_off); - write_u16_le(&mut data, name_len); - - // Write type and value based on PropertyValue variant - match value { - PropertyValue::Number(n) => { - write_u8(&mut data, PROP_TYPE_Q24_8); - write_u8(&mut data, 0); // reserved - // Convert f64 to Q24.8 fixed-point and write as i32 - let q24_8 = to_q24_8(*n); - data.extend_from_slice(&q24_8.to_le_bytes()); - } - PropertyValue::Bool(b) => { - write_u8(&mut data, PROP_TYPE_BOOL); - write_u8(&mut data, 0); // reserved - // Write boolean as u32 (0 or 1) - let val: u32 = if *b { 1 } else { 0 }; - write_u32_le(&mut data, val); - } - PropertyValue::String(s) => { - write_u8(&mut data, PROP_TYPE_STR); - write_u8(&mut data, 0); // reserved - // Write string reference as u16 offset + u16 length pair - let (str_off, str_len) = strings.intern(s)?; - let str_off_u16 = checked_u16(str_off as usize, "string property value offset")?; - write_u16_le(&mut data, str_off_u16); - write_u16_le(&mut data, str_len); - } - } - } - - Ok(data) -} -``` - -**Step 2: Verify compilation** - -Run: `cd src-tauri && cargo check` - ---- - -## Task 7: Extract sections.rs - -**Files:** -- Create: `src-tauri/src/codegen/fspk/sections.rs` - -**Step 1: Create sections.rs with section building helpers** - -This file will contain helper functions for building the various optional sections (events, resources, tags, cancels). Extract the repetitive event arg packing logic into a reusable function. - -```rust -//! Section building helpers for FSPK export. - -use crate::codegen::fspk_format::{write_u16_le, write_u32_le, write_u8}; -use crate::schema::{EventArgValue, EventEmit}; - -use super::types::{StrRef, StringTable}; -use super::utils::{checked_u16, checked_u32, write_i64_le, write_range, write_strref, write_u64_le}; - -// Event argument type tags -pub const EVENT_ARG_TAG_BOOL: u8 = 0; -pub const EVENT_ARG_TAG_I64: u8 = 1; -pub const EVENT_ARG_TAG_F32: u8 = 2; -pub const EVENT_ARG_TAG_STRING: u8 = 3; - -// Resource delta trigger types -pub const RESOURCE_DELTA_TRIGGER_ON_USE: u8 = 0; -pub const RESOURCE_DELTA_TRIGGER_ON_HIT: u8 = 1; -pub const RESOURCE_DELTA_TRIGGER_ON_BLOCK: u8 = 2; - -/// Sentinel value for optional u16 fields -pub const OPT_U16_NONE: u16 = u16::MAX; - -/// Pack event arguments into the event_args buffer. -/// -/// Returns (args_off, args_len) for the packed arguments. -pub fn pack_event_args( - args: &std::collections::BTreeMap, - event_args_data: &mut Vec, - strings: &mut StringTable, -) -> Result<(u32, u16), String> { - let args_off = checked_u32(event_args_data.len(), "event_args_off")?; - let args_len = checked_u16(args.len(), "event_args_len")?; - - for (k, v) in args { - let key = strings.intern(k)?; - write_strref(event_args_data, key); - - match v { - EventArgValue::Bool(b) => { - write_u8(event_args_data, EVENT_ARG_TAG_BOOL); - write_u8(event_args_data, 0); - write_u16_le(event_args_data, 0); - write_u64_le(event_args_data, if *b { 1 } else { 0 }); - } - EventArgValue::I64(i) => { - write_u8(event_args_data, EVENT_ARG_TAG_I64); - write_u8(event_args_data, 0); - write_u16_le(event_args_data, 0); - write_i64_le(event_args_data, *i); - } - EventArgValue::F32(f) => { - write_u8(event_args_data, EVENT_ARG_TAG_F32); - write_u8(event_args_data, 0); - write_u16_le(event_args_data, 0); - write_u64_le(event_args_data, f.to_bits() as u64); - } - EventArgValue::String(s) => { - write_u8(event_args_data, EVENT_ARG_TAG_STRING); - write_u8(event_args_data, 0); - write_u16_le(event_args_data, 0); - let vref = strings.intern(s)?; - write_u32_le(event_args_data, vref.0); - write_u16_le(event_args_data, vref.1); - write_u16_le(event_args_data, 0); - } - } - } - - Ok((args_off, args_len)) -} - -/// Pack event emits into the event_emits buffer. -/// -/// Returns (emits_off, emits_len) for the packed emits. -pub fn pack_event_emits( - events: &[EventEmit], - event_emits_data: &mut Vec, - event_args_data: &mut Vec, - strings: &mut StringTable, -) -> Result<(u32, u16), String> { - let emits_off = checked_u32(event_emits_data.len(), "event_emits_off")?; - let emits_len = checked_u16(events.len(), "event_emits_len")?; - - for emit in events { - let (args_off, args_len) = pack_event_args(&emit.args, event_args_data, strings)?; - - let id = strings.intern(&emit.id)?; - write_strref(event_emits_data, id); - write_range(event_emits_data, args_off, args_len); - } - - Ok((emits_off, emits_len)) -} -``` - -**Step 2: Verify compilation** - -Run: `cd src-tauri && cargo check` - ---- - -## Task 8: Extract export.rs (main orchestrator) - -**Files:** -- Create: `src-tauri/src/codegen/fspk/export.rs` - -**Step 1: Create export.rs with export_fspk function** - -This is the main orchestrator - extract from `zx_fspack.rs` lines 594-1332. Due to the length, I'll provide the structure: - -```rust -//! Main FSPK export function. - -use std::collections::HashMap; - -use crate::codegen::fspk_format::{ - write_u16_le, write_u32_le, write_u8, FLAGS_RESERVED, HEADER_SIZE, MAGIC, - SECTION_CANCEL_DENIES, SECTION_CANCEL_TAG_RULES, SECTION_CHARACTER_PROPS, - SECTION_EVENT_ARGS, SECTION_EVENT_EMITS, SECTION_HEADER_SIZE, SECTION_HIT_WINDOWS, - SECTION_HURT_WINDOWS, SECTION_KEYFRAMES_KEYS, SECTION_MESH_KEYS, SECTION_MOVE_NOTIFIES, - SECTION_MOVE_RESOURCE_COSTS, SECTION_MOVE_RESOURCE_DELTAS, - SECTION_MOVE_RESOURCE_PRECONDITIONS, SECTION_PUSH_WINDOWS, SECTION_RESOURCE_DEFS, - SECTION_SHAPES, SECTION_STATES, SECTION_STATE_EXTRAS, SECTION_STATE_TAGS, - SECTION_STATE_TAG_RANGES, SECTION_STRING_TABLE, STATE_EXTRAS72_SIZE, STRREF_SIZE, -}; -use crate::commands::CharacterData; - -use super::moves::{build_asset_keys, pack_moves}; -use super::properties::pack_character_props; -use super::sections::{ - pack_event_emits, OPT_U16_NONE, RESOURCE_DELTA_TRIGGER_ON_BLOCK, - RESOURCE_DELTA_TRIGGER_ON_HIT, RESOURCE_DELTA_TRIGGER_ON_USE, -}; -use super::types::{CancelLookup, StringTable}; -use super::utils::{ - align_up, checked_u16, checked_u32, write_i32_le, write_range, write_section_header, - write_strref, -}; - -/// Export character data to FSPK binary format. -/// -/// Returns the packed binary data as a Vec. -#[allow(clippy::vec_init_then_push)] // Intentional: base sections first, optional sections conditionally added -pub fn export_fspk(char_data: &CharacterData) -> Result, String> { - // [Full implementation extracted from zx_fspack.rs lines 598-1332] - // The logic stays the same, just using the new module paths - - // ... (full implementation) -} - -#[cfg(test)] -mod tests { - // Integration tests for export_fspk - // Extract from zx_fspack.rs lines 1622-2507 -} -``` - -The full implementation would be copied from the original file with updated imports. - -**Step 2: Verify compilation** - -Run: `cd src-tauri && cargo check` - ---- - -## Task 9: Rename zx_fspack_format.rs to fspk_format.rs - -**Files:** -- Rename: `src-tauri/src/codegen/zx_fspack_format.rs` → `src-tauri/src/codegen/fspk_format.rs` - -**Step 1: Rename the file** - -Run: `git mv src-tauri/src/codegen/zx_fspack_format.rs src-tauri/src/codegen/fspk_format.rs` - -**Step 2: Update module doc comment** - -Change line 1-3 from: -```rust -//! ZX FSPK (Framesmith Pack) Binary Format Constants -//! -//! This module defines the binary format for exporting character data to the ZX runtime. -``` - -To: -```rust -//! FSPK (Framesmith Pack) Binary Format Constants -//! -//! This module defines the binary format for exporting character data. -``` - -**Step 3: Verify compilation** - -Run: `cd src-tauri && cargo check` - ---- - -## Task 10: Update codegen/mod.rs - -**Files:** -- Modify: `src-tauri/src/codegen/mod.rs` - -**Step 1: Update module declarations** - -Change from: -```rust -mod json_blob; -mod zx_fspack; -pub mod zx_fspack_format; - -pub use json_blob::{export_json_blob, export_json_blob_pretty}; -pub use zx_fspack::export_zx_fspack; -``` - -To: -```rust -mod fspk; -mod json_blob; -pub mod fspk_format; - -pub use fspk::export_fspk; -pub use json_blob::{export_json_blob, export_json_blob_pretty}; -``` - -**Step 2: Verify compilation** - -Run: `cd src-tauri && cargo check` - ---- - -## Task 11: Update commands.rs - -**Files:** -- Modify: `src-tauri/src/commands.rs` - -**Step 1: Update import** - -Change line 1 from: -```rust -use crate::codegen::{export_json_blob, export_json_blob_pretty, export_zx_fspack}; -``` - -To: -```rust -use crate::codegen::{export_fspk, export_json_blob, export_json_blob_pretty}; -``` - -**Step 2: Update adapter name in export_character (line 531)** - -Change: -```rust -"zx-fspack" => { - let bytes = export_zx_fspack(&char_data)?; -``` - -To: -```rust -"fspk" => { - let bytes = export_fspk(&char_data)?; -``` - -**Step 3: Update get_character_fspk (line 623)** - -Change: -```rust -let bytes = export_zx_fspack(&char_data)?; -``` - -To: -```rust -let bytes = export_fspk(&char_data)?; -``` - -**Step 4: Verify compilation** - -Run: `cd src-tauri && cargo check` - ---- - -## Task 12: Update frontend - -**Files:** -- Modify: `src/lib/views/CharacterOverview.svelte` - -**Step 1: Update adapter value (line 97)** - -Change: -```svelte -if (exportAdapter === "zx-fspack") { -``` - -To: -```svelte -if (exportAdapter === "fspk") { -``` - -**Step 2: Update option value (line 195)** - -Change: -```svelte - -``` - -To: -```svelte - -``` - -**Step 3: Update disabled check (line 201)** - -Change: -```svelte -disabled={exportAdapter === "zx-fspack"} -``` - -To: -```svelte -disabled={exportAdapter === "fspk"} -``` - -**Step 4: Verify frontend compiles** - -Run: `cd src-tauri && npm run check` - ---- - -## Task 13: Delete old zx_fspack.rs - -**Files:** -- Delete: `src-tauri/src/codegen/zx_fspack.rs` - -**Step 1: Remove the old file** - -Run: `git rm src-tauri/src/codegen/zx_fspack.rs` - -**Step 2: Verify compilation** - -Run: `cd src-tauri && cargo check` - ---- - -## Task 14: Run Full Test Suite - -**Step 1: Run Rust tests** - -Run: `cd src-tauri && cargo test` -Expected: All tests pass - -**Step 2: Run clippy** - -Run: `cd src-tauri && cargo clippy --all-targets` -Expected: No warnings - -**Step 3: Run frontend checks** - -Run: `npm run check` -Expected: No errors - ---- - -## Task 15: Commit - -**Step 1: Stage changes** - -Run: `git add -A` - -**Step 2: Commit** - -```bash -git commit -m "$(cat <<'EOF' -refactor(codegen): rename zx_fspack to fspk and split into modules - -- Rename zx_fspack.rs -> fspk/ subdirectory module -- Rename zx_fspack_format.rs -> fspk_format.rs -- Split 2,773-line monolith into 8 focused modules: - - types.rs: StringTable, CancelLookup, PackedMoveData - - utils.rs: Helper functions (checked_*, write_*) - - packing.rs: Shape/hitbox/move record packing - - moves.rs: pack_moves, build_asset_keys - - properties.rs: pack_character_props - - sections.rs: Event/resource section helpers - - export.rs: Main export_fspk orchestrator - - mod.rs: Public API -- Update frontend adapter name from "zx-fspack" to "fspk" - -This improves LLM-friendliness by keeping each file under 400 lines -and removes the misleading "zx_" prefix since FSPK is engine-agnostic. - -Co-Authored-By: Claude Opus 4.5 -EOF -)" -``` - ---- - -## Verification - -After completing all tasks: - -1. `cargo clippy --all-targets` - No warnings -2. `cargo test` - All tests pass (including roundtrip tests with framesmith-fspack crate) -3. `npm run check` - No TypeScript/Svelte errors -4. Manual test: Open Framesmith, load a character, export as FSPK - should work diff --git a/docs/production-gap-backlog.md b/docs/production-gap-backlog.md new file mode 100644 index 0000000..998c9af --- /dev/null +++ b/docs/production-gap-backlog.md @@ -0,0 +1,297 @@ +# Production Gap Backlog + +Status: active +Last reviewed: 2026-05-23 + +This backlog converts production-readiness gaps into concrete implementation +issues. A gap becomes release-blocking only when the target game requires +Framesmith, FSPK, or the runtime to own that behavior instead of accepting the +current `json-blob` handoff or engine-owned policy. + +## How To Use This Backlog + +For each production candidate: + +1. Compare the target game's combat requirements with + [`combat-coverage.md`](combat-coverage.md), + [`training-scenario-contract.md`](training-scenario-contract.md), and + [`export-fidelity-contract.md`](export-fidelity-contract.md). +2. Mark each relevant backlog item as required, deferred, or not applicable in + the release notes or project issue tracker. +3. Implement required items with the tests listed under that item. +4. Update the export contract, training scenario contract, runtime docs, and + this backlog before considering the item complete. + +## External Release Gates + +### PROD-CI-001: Clean-Checkout CI Certification + +Trigger: every release candidate. + +Scope: + +- Push the exact candidate commit to GitHub. +- Let `.github/workflows/ci.yml` run on a fresh Windows runner. +- Download or inspect the uploaded `framesmith-windows-installers` artifact. + +Required evidence: + +- GitHub Actions run URL. +- Commit SHA. +- Pass/fail status. +- Artifact names and sizes. + +Acceptance criteria: + +- The CI workflow passes without local-only files or generated drift. +- Installer artifacts are produced by CI, not only by a developer workspace. + +### PROD-CI-002: Required CI Before Merge + +Trigger: before using the repository as a production source of truth. + +Scope: + +- Protect `main` or configure an equivalent ruleset. +- Require the CI workflow before merge. +- Require pull requests or another reviewed-change policy if the production + game team uses shared branches. + +Required evidence: + +- Branch protection or ruleset screenshot/export. +- A blocked merge attempt or ruleset configuration showing CI is required. + +Acceptance criteria: + +- Production branches cannot accept changes without a green CI result. + +### PROD-WIN-001: Windows Installer Manual Smoke + +Trigger: every Windows release candidate. + +Scope: + +- Run [`windows-installer-smoke-test.md`](windows-installer-smoke-test.md) for + MSI and NSIS installers. +- Record the evidence requested by that document. + +Required evidence: + +- Windows version and architecture. +- Installer source: local path or CI artifact URL. +- MSI result. +- NSIS result. + +Acceptance criteria: + +- Installed apps launch, load `TEST_CHAR`, start Training Mode, export, and + uninstall cleanly. + +## Export And Runtime Gaps + +### FSPK-MOVE-001: Runtime-Owned Movement Export + +Trigger: the target game requires FSPK/runtime, not `json-blob`, to own +movement curves, velocity, dash distance, launch, or forced movement. + +Implementation scope: + +- Add FSPK sections for movement data or a versioned FSPK v2 movement model. +- Add reader APIs in `framesmith-fspack`. +- Add runtime APIs that make ownership explicit. +- Update `docs/export-fidelity-contract.json`, + [`movement-reference.md`](movement-reference.md), and + [`runtime-guide.md`](runtime-guide.md). + +Required tests: + +- FSPK roundtrip tests for movement data. +- Runtime tests for authored movement behavior or documented pass-through + ownership. +- Export contract tests proving movement is no longer silently omitted for the + chosen adapter. + +Acceptance criteria: + +- A consuming engine can reconstruct the target game's movement policy from the + exported data and runtime API without relying on undocumented side tables. + +### FSPK-HIT-001: Advanced Hit Model Export + +Trigger: the target game requires FSPK/runtime to own multi-hit attacks, chip +damage, block damage, per-hit reactions, or advanced hit metadata. + +Implementation scope: + +- Serialize `hits[]` or a versioned equivalent. +- Preserve chip damage and per-hit timing. +- Add FSPK reader views for the new data. +- Update [`export-fidelity-contract.md`](export-fidelity-contract.md) and + [`combat-coverage.md`](combat-coverage.md). + +Required tests: + +- FSPK roundtrip tests for multi-hit attacks and chip damage. +- Runtime or WASM tests for authored hit/block behavior that uses the advanced + hit model. +- Browser smoke coverage if editor-visible behavior changes. + +Acceptance criteria: + +- The runtime-facing handoff can distinguish every authored hit window that the + target game requires. + +### RUNTIME-THROW-001: Throw Collision And Tech Policy + +Trigger: the target game requires Framesmith to own throw boxes, throw tech, +throw invulnerability, or throw-vs-strike priority. + +Implementation scope: + +- Add authoring fields for throw boxes and tech windows. +- Add FSPK serialization or document `json-blob` as the only supported handoff. +- Add runtime collision and priority APIs if runtime-owned. +- Add training scenarios before implementation. + +Required tests: + +- Schema validation tests for throw authoring fields. +- FSPK roundtrip tests if exported to FSPK. +- Runtime/WASM tests for throw priority and throw-tech outcomes. +- Training scenario tests for the target fixture. + +Acceptance criteria: + +- A throw cannot be confused with a strike purely because both share input + notation or tags. + +### RUNTIME-FREEZE-001: Hitstop And Blockstop Scheduling + +Trigger: the target game requires Framesmith or the runtime to schedule +attacker/defender freeze, blockstop, rollback timing, or freeze-sensitive +events. + +Implementation scope: + +- Decide whether blockstop is a separate field or derived from hitstop. +- Serialize required data in the chosen handoff. +- Add runtime APIs for freeze state and frame stepping. +- Document rollback and event-ordering responsibilities. + +Required tests: + +- Schema tests for any new blockstop fields. +- FSPK roundtrip tests for freeze metadata if exported. +- Runtime tests for frame advancement during freeze. +- Training tests that prove hitstun/blockstun timing still matches authored + data when freeze is active. + +Acceptance criteria: + +- The target game can reproduce the same freeze and reaction timing in training + mode and in-engine integration. + +### RUNTIME-RESOURCE-001: Resource Side-Effect Timing + +Trigger: the target game requires Framesmith runtime to apply meter/resource +gain, costs, refunds, or on-hit/on-block/on-whiff deltas automatically. + +Implementation scope: + +- Define authoritative timing for resource side effects. +- Add runtime state mutation APIs or keep mutation engine-owned with examples. +- Update training scenarios for hit, block, whiff, and cancel costs. + +Required tests: + +- Runtime tests for resource costs and deltas at each supported timing point. +- WASM tests proving snapshots/restores include resource side effects. +- Export contract tests for any new serialized timing fields. + +Acceptance criteria: + +- Resource totals are deterministic across live play, training reset, and + step-back/restore. + +### RUNTIME-STAGE-001: Stage Bounds, Corners, And Push Resolution + +Trigger: the target game requires Framesmith runtime to own stage bounds, +corner behavior, push priority, or collision clamping. + +Implementation scope: + +- Define stage/corner inputs to the runtime. +- Add deterministic push resolution APIs. +- Decide whether stage data belongs in project config, character data, or the + consuming game. + +Required tests: + +- Runtime tests for pushbox collision against another actor and against stage + bounds. +- Training tests for corner push behavior. +- Documentation examples showing engine integration. + +Acceptance criteria: + +- Pushbox behavior is deterministic and documented for rollback integration. + +### RUNTIME-EVENT-001: Transition Events And Spawned Entities + +Trigger: the target game requires Framesmith/FSPK/runtime to own +`on_use.enters_state`, projectile spawning, visual/audio event dispatch, or +status-effect application. + +Implementation scope: + +- Version event payloads and entity spawn data in the chosen handoff. +- Add runtime APIs for emitted actions or keep application engine-owned with a + stable event stream. +- Update MCP and CLI examples if authoring workflows change. + +Required tests: + +- Schema and validation tests for event payloads. +- FSPK roundtrip tests if event payloads become runtime-owned. +- Runtime tests for transition or emitted-action ordering. +- Documentation examples showing how the engine consumes the event stream. + +Acceptance criteria: + +- The target game can replay state transitions and spawned effects from the + exported handoff without undocumented interpretation rules. + +## Platform Gaps + +### PLATFORM-LINUX-001: Linux Package Support + +Trigger: Linux becomes a supported release target. + +Required work: + +- Define package format and dependencies. +- Add CI package build. +- Add manual smoke-test steps matching the Windows installer smoke depth. +- Add artifact upload and release evidence. + +Acceptance criteria: + +- Linux is no longer just "builds on a developer machine"; it has repeatable + CI artifacts and smoke-test evidence. + +### PLATFORM-MAC-001: macOS Package Support + +Trigger: macOS becomes a supported release target. + +Required work: + +- Define signing/notarization expectations. +- Add CI package build or documented manual build path. +- Add manual smoke-test steps matching the Windows installer smoke depth. +- Add artifact upload and release evidence. + +Acceptance criteria: + +- macOS support includes install, launch, load, training, export, and uninstall + evidence. diff --git a/docs/production-handoff-decision.md b/docs/production-handoff-decision.md new file mode 100644 index 0000000..8377f89 --- /dev/null +++ b/docs/production-handoff-decision.md @@ -0,0 +1,72 @@ +# Production Handoff Decision + +Status: active +Last reviewed: 2026-05-23 + +This document records the current production handoff policy for teams using +Framesmith in a real game pipeline. + +## Decision + +For the first production target, `json-blob` is the canonical source-of-truth +runtime handoff. + +`fspk` v1 is a compact validated runtime pack for the subset currently covered +by `docs/export-fidelity-contract.md`, but it is not the only authoritative +handoff for a full game integration. A game may generate FSPK from the canonical +JSON data as a cache or fast-load runtime artifact. + +## Why + +- `json-blob` preserves every resolved `CharacterData` field. +- `fspk` v1 intentionally omits or derives editor-facing fields, resolved + variant identity, advanced hit data, movement values, advanced hurtbox flags, + and super-freeze behavior. +- Shipping a game from `json-blob` plus optional FSPK caches avoids pretending + the current binary format is full-fidelity before FSPK v2 exists. + +## Movement Policy + +Movement is `json-blob` only for FSPK v1. + +Framesmith can author `movement` values and `json-blob` preserves them. FSPK v1 +may mark a state as movement by type, but it does not serialize movement +distance, velocity, acceleration, frame ranges, or easing. The consuming engine +owns movement application, collision against floors/walls/corners, stage bounds, +and any velocity or movement accumulator values needed for rollback. + +## Example Pipeline + +Canonical handoff: + +```bash +cd src-tauri +cargo run --bin framesmith-cli -- export --project .. --character test_char --adapter json-blob --pretty --out ../exports/test_char.json +``` + +Optional runtime pack: + +```bash +cd src-tauri +cargo run --bin framesmith-cli -- export --project .. --character test_char --adapter fspk --out ../exports/test_char.fspk +``` + +Engine-side policy: + +- Load `json-blob` for full authored data, tooling, debugging, movement, and + mechanics not represented in FSPK v1. +- Load `fspk` for covered runtime-fast paths such as state timing, legacy hit + and hurt windows, pushboxes, resource records, events, tags, cancel rules, and + compact properties. +- Treat FSPK as a generated cache unless the target game explicitly accepts the + v1 subset as complete for its combat model. + +## When This Decision Changes + +Use FSPK as the canonical handoff only after FSPK v2 or later has: + +- A migration plan for existing packs. +- Field classifications updated in `docs/export-fidelity-contract.json`. +- Roundtrip tests for every newly preserved or derived field. +- Runtime or engine-consumption examples for movement and any newly runtime-owned + combat mechanics. diff --git a/docs/production-readiness-plan.md b/docs/production-readiness-plan.md new file mode 100644 index 0000000..7c9e13c --- /dev/null +++ b/docs/production-readiness-plan.md @@ -0,0 +1,443 @@ +# Production Readiness Plan + +Status: active +Last reviewed: 2026-05-23 + +This document is the durable checklist for deciding when Framesmith can be used +as a production character-authoring pipeline for a real fighting game. + +Current assessment: Framesmith is no longer blocked by the build and schema +drift found in the initial audit. The current workspace builds, tests, rebuilds +WASM from source, runs browser smoke tests, produces Windows packages, and has +an executable first-target training scenario contract. It is still not finished +as a production game-development pipeline because clean-checkout CI enforcement, +branch protection, and manual platform smoke testing remain open. + +## Readiness Definition + +Framesmith is production ready when all of these are true: + +- The editor, backend, runtime crates, WASM runtime, CLI, MCP server, and + packaged desktop app build from source on supported platforms. +- Frontend types, Rust schema types, docs, generated schemas, generated WASM, + and sample project data agree on the same data model. +- Exported data either preserves every game-facing field, or each intentional + adapter limitation is documented, tested, and accepted by the target game. +- Training mode validates real authored data and covers common fighting-game + states such as idle, crouch, jump, blockstun, hitstun, cancels, resources, + throws, and reset behavior. +- CI runs Rust tests, TypeScript tests, Svelte checks, clippy, WASM builds, + browser smoke tests, app builds, and export compatibility checks. +- Documentation examples work as written. + +## Current Snapshot + +Verified on 2026-05-23 in the current Windows workspace: + +| Check | Result | Notes | +|-------|--------|-------| +| `npm ci` | Pass | Reinstalls the locked frontend/tooling dependency graph. | +| `npm audit` | Pass | Frontend/tooling dependency audit reports 0 vulnerabilities after dependency refresh and the narrow `cookie@0.7.2` override. | +| Tauri npm/Rust package alignment | Pass | `@tauri-apps/api`, `@tauri-apps/cli`, and `@tauri-apps/plugin-opener` are pinned to the Rust-aligned 2.9/2.5 minor lines; `npm run tauri build` verifies the match. | +| `npm run check` | Pass | `svelte-check` reports 0 errors and 0 warnings. | +| `npm run test:run` | Pass | 204 Vitest tests pass. | +| `npm run test:e2e` | Pass | 4 Playwright smoke tests cover project load, editor save, variant read-only behavior, cancel graph, globals, export, embedded training startup, separate dummy character/FSPK selection, and detached training startup through BroadcastChannel sync. | +| `npm run wasm:build` | Pass | Rebuilds `src/lib/wasm/` from `crates/framesmith-runtime-wasm`. | +| `cargo run --manifest-path src-tauri/Cargo.toml --bin generate_schema` | Pass | Refreshes `schemas/rules.schema.json`; the audit found and accepted generated schema drift. | +| `npm run build` | Pass | Production web build succeeds with rebuilt WASM. | +| `npm run test:run -- src/lib/views/training/TrainingLoop.test.ts src/lib/training/buildMoveList.test.ts src/lib/training/InputManager.test.ts` | Pass | 43 targeted training tests cover dummy behavior propagation, hit/block damage, combo reset, push separation, authored movement, throw inputs, and input mapping. | +| `npm run tauri build` | Pass | Produces `framesmith.exe`, MSI, and NSIS installer on Windows. | +| `cargo test --manifest-path src-tauri/Cargo.toml` | Pass | Backend, schema, codegen, globals, MCP, and pipeline tests pass, including 26 MCP command tests. | +| `cargo test --manifest-path src-tauri/Cargo.toml --test docs_cli_examples` | Pass | Executes documented `framesmith-cli` export examples against a temporary project. | +| `cargo test --manifest-path src-tauri/Cargo.toml --test export_fidelity_contract` | Pass | Contract covers schema fields, maps every preserved/derived FSPK field to named roundtrip tests, and keeps lossy examples documented. | +| `cargo test --manifest-path src-tauri/Cargo.toml --test fspk_roundtrip` | Pass | 21 FSPK reader roundtrip tests cover preserved/derived contract fields. | +| `cargo test --manifest-path src-tauri/Cargo.toml --test production_docs` | Pass | Keeps production docs linked, variant deferral documented, and temporary plans migrated. | +| `cargo test --manifest-path crates/framesmith-runtime/Cargo.toml` | Pass | Runtime unit and integration tests pass. | +| `cargo test --manifest-path crates/framesmith-runtime-wasm/Cargo.toml` | Pass | WASM wrapper tests pass, including first-target hitstun/blockstun/resource/throw scenario checks against `exports/test_char.fspk`. | +| `cargo test --manifest-path crates/framesmith-fspack/Cargo.toml` | Pass | no_std FSPK reader tests pass. | +| `cargo clippy --manifest-path src-tauri/Cargo.toml --all-targets -- -D warnings` | Pass | CI-level backend clippy gate is clean. | +| Runtime crate clippy with `-D warnings` | Pass | `framesmith-runtime`, `framesmith-runtime-wasm`, and `framesmith-fspack` are clean. | +| Cargo fmt checks | Pass | Formatting is clean for `src-tauri` and runtime crates. | +| CI workflow | Added | `.github/workflows/ci.yml` checks dependency audit, generated files, formatting, frontend, browser smoke tests, Rust tests, backend/runtime clippy, Tauri packaging, and uploads Windows installer artifacts; branch protection still must be configured in GitHub. | + +This is not yet a clean-checkout certification. CI should be allowed to run on a +fresh runner before marking clean-checkout reproducibility complete. + +## Completed Since Audit + +- Restored runtime cancel availability APIs and rebuilt WASM from source. +- Aligned frontend types and views with property-based characters plus + `tag_rules`/`deny` cancel tables. +- Added fixture tests so schema drift against `characters/test_char` fails fast. +- Normalized binary adapter naming to `fspk`, while retaining `zx-fspack` as a + legacy alias. +- Updated CLI, MCP, README, and docs to use `framesmith-cli` and `fspk`. +- Added Windows CI covering frontend checks, Vitest, WASM rebuild, Playwright, + web build, Rust tests, clippy, Tauri packaging, generated-file checks, and + Windows installer artifact upload. +- Added Playwright smoke tests that generate real FSPK and JSON fixtures through + the Rust CLI, then exercise the editor against those resolved outputs. +- Fixed State Editor and save-path Svelte proxy serialization bugs found by the + smoke suite. +- Fixed the detached Training Mode route so BroadcastChannel data refreshes do + not retrack mutable session state and repeatedly recreate the WASM session. +- Added generated-output ignores for Playwright artifacts. +- Refreshed `schemas/rules.schema.json` from the Rust schema generator. +- Updated frontend/tooling dependencies within existing semver ranges and added + a narrow `cookie@0.7.2` override so `npm audit` reports 0 vulnerabilities. +- Pinned Tauri npm packages to the Rust-aligned minor versions after the + dependency refresh exposed a package-build mismatch. +- Brought clippy to zero warnings for the backend and runtime crates. +- Added [`schema-migration.md`](schema-migration.md) with migration examples for + character properties, cancel tables, variants, and the `fspk` adapter rename. +- Added [`training-scenario-contract.md`](training-scenario-contract.md) with + executable first-target training scenarios and named test evidence. +- Added [`windows-installer-smoke-test.md`](windows-installer-smoke-test.md) so + the remaining manual Windows installer gate has repeatable steps and evidence + to record. + +## Remaining Production Blockers + +### 1. Clean-Checkout And CI Enforcement + +Status: local CI definition complete; GitHub clean-run and branch protection +remain external. + +Actions: + +- Let GitHub Actions run the new CI workflow on a clean runner. +- Make the CI workflow required before merges. +- Keep Windows as the first supported packaging target. +- Keep Linux and macOS out of the first production target until platform + dependencies, package formats, and manual smoke-test steps are documented. +- Track external release gates through + [`production-gap-backlog.md`](production-gap-backlog.md) and execute + [`release-runbook.md`](release-runbook.md) for each release candidate. + +Acceptance criteria: + +- A fresh CI runner passes the complete workflow. +- Protected branches reject merges without green CI. +- Windows installer artifacts are produced by CI, not only locally. +- Linux and macOS are either verified or explicitly not supported for the + release target. + +### 2. Full-Fidelity Export Contract + +Status: complete for the first production target; future target-specific +scenarios are tracked in [`production-gap-backlog.md`](production-gap-backlog.md). + +Original risk: + +- `json-blob` is the most complete authoring handoff. +- `fspk` is compact and runtime-friendly, but it is still a runtime subset. +- FSPK field loss needed an explicit contract so omitted fields were deliberate + adapter policy rather than silent export drift. + +Completed: + +- Added `docs/export-fidelity-contract.json` as the machine-readable field + classification contract for `json-blob` and `fspk`. +- Added `docs/export-fidelity-contract.md` with adapter policy and known FSPK + v1 limits. +- Added Rust tests that compare the contract against current direct + `Character`, `State`, and `CancelTable` schema fields and reject unknown or + unexplained statuses. +- Added a contract coverage audit requiring every `preserved` or `derived` + FSPK field to name at least one roundtrip test through `framesmith-fspack`. +- Added missing roundtrip coverage for character-id-derived mesh keys, + character properties, and `on_block` events/resource deltas. +- Added explicit lossy FSPK v1 examples for resolved variant identity, + advanced multi-hit data, movement, advanced hurtbox flags, and super freeze. +- Formalized `json-blob` as the canonical first production handoff with `fspk` + v1 as an optional runtime subset/cache. + +Maintenance triggers: + +- Keep fixture coverage for tags, cancel rules, properties, resources, events, + pushboxes, hit windows, and schema sections. +- When an omitted or engine-owned field becomes required by a target game, open + or implement the matching item from + [`production-gap-backlog.md`](production-gap-backlog.md) and add the listed + tests before changing the export contract status. + +Acceptance criteria: + +- No field can be silently lost during export. +- Every intentional adapter limitation has a test and a documentation entry. + +### 3. Variant Identity And Editing Semantics + +Status: complete for the current first-target scenario contract; future target +games may add stricter scenarios. + +Problem: + +- Variant overlays resolve with unique `id` values but may share the same + gameplay `input`. +- Editor selection and graph nodes now use `State.id` when present and fall + back to `input`. +- Resolved variants are read-only in the current State Editor and through + `save_move` because loaded variants are resolved snapshots, not overlay diffs. + +Completed: + +- Added shared frontend state identity helpers. +- Updated State Editor and Frame Data selection to distinguish base states from + variants. +- Updated Cancel Graph nodes and deny matching to distinguish resolved variant + IDs from shared gameplay inputs. +- Blocked `save_move` for resolved variants so a variant cannot overwrite its + base state. +- Added unit, backend, and browser smoke coverage for variant identity/read-only + behavior. +- Added [`variant-editing-decision.md`](variant-editing-decision.md), which + explicitly keeps variants as JSON-authored overlays for the first production + target and defines when overlay-aware UI editing must be reopened. + +Maintenance triggers: + +- Reopen overlay-aware variant editing only if the target game rejects JSON-only + overlay authoring or needs non-programmer variant editing in the UI. +- If reopened, implement direct overlay-file editing, inherited/overridden field + display, and tests proving a fully resolved state cannot be serialized back + into an overlay patch. + +Acceptance criteria: + +- For the first production target, a game team can author variants as JSON + overlays, inspect resolved variants in the editor, export and test them, and + cannot accidentally overwrite a base move or another variant through the + current UI/MCP save path. + +### 4. Movement, Collision, And Engine Boundaries + +Status: complete for the first production target ownership contract; future +runtime-owned movement work is tracked in +[`production-gap-backlog.md`](production-gap-backlog.md). + +Problem: + +- Movement authoring exists. +- Runtime docs currently put stage bounds, corners, full movement simulation, + and some resource/effect application on the consuming game engine. +- FSPK movement support and runtime movement ownership need a hard contract. + +Completed: + +- Added a runtime ownership contract to `docs/runtime-guide.md`. +- Updated `docs/runtime-api.md` with `PushboxResult` and `check_pushbox()`. +- Updated `docs/movement-reference.md` to state that `json-blob` preserves + movement while `fspk` v1 does not serialize movement values. +- Linked movement/export limitations through the export-fidelity contract. +- Formalized movement as `json-blob` only for FSPK v1 in + `docs/production-handoff-decision.md`. +- Added engine-consumption examples for applying `json-blob` movement and FSPK + resource deltas in `docs/runtime-guide.md`. + +Maintenance triggers: + +- Add roundtrip/export tests for any movement data that becomes runtime-owned. +- Use `FSPK-MOVE-001` from + [`production-gap-backlog.md`](production-gap-backlog.md) if a target game + requires movement in the compact runtime handoff. + +Acceptance criteria: + +- The runtime guide tells an engine implementer exactly what Framesmith provides + and exactly what the engine must supply. +- If movement becomes runtime-owned, FSPK movement export and runtime tests exist. + +### 5. Combat Data Coverage + +Status: complete for first-target classification; target-required engine-owned +mechanics are tracked in [`production-gap-backlog.md`](production-gap-backlog.md). + +Completed: + +- Added `docs/combat-coverage.md` with a mechanic-by-mechanic production + support matrix. +- Classified current coverage for chip damage, multi-hit attacks, throws, + projectiles/entities, hitstop, blockstop, forced movement, resource gain, + status effects, and state transition events. + +Maintenance triggers: + +- Turn target-game-required `engine-owned` or `exported as data only` mechanics + into concrete FSPK/runtime implementation issues. +- Add roundtrip/runtime tests when a mechanic moves to `supported end-to-end`. +- Use [`production-gap-backlog.md`](production-gap-backlog.md) as the canonical + issue source for those implementation decisions. + +Acceptance criteria: + +- A production game can decide whether Framesmith already covers its combat + model or which engine glue/FSPK v2 work is required. + +### 6. Training Mode Maturity + +Status: complete for the current first-target training contract; future +runtime-owned policies are tracked in +[`production-gap-backlog.md`](production-gap-backlog.md). + +Completed: + +- Training mode loads in browser smoke tests from rebuilt WASM and real CLI + exported FSPK data. +- Runtime, WASM wrapper, input, cancel, dummy-controller, frame-advantage, and + render-mapping unit tests pass. +- Added WASM `TrainingSession.snapshot()`/`restore()` support for deterministic + browser training rewind. +- Added bounded step-back history for embedded and detached training mode, + restoring visible loop state, WASM session state, and input-buffer state. +- Added Vitest coverage for input-buffer snapshots and TrainingLoop step-back, + reset, and empty-history behavior. +- Updated the WASM training wrapper so dummy stand/crouch/jump/block behavior + resolves authored FSPK states by input/tag lookup instead of fixed indices + when the pack provides those states. +- Added blocked-hit reporting to WASM `HitResult` and TrainingLoop chip-damage + handling for blocking dummy modes. +- Added throw-button (`T`) input support for authored inputs such as `5T`. +- Added TrainingLoop behavior coverage for dummy behavior propagation, + hit/block damage, combo reset, push separation, authored movement, and throw + input resolution. +- Added embedded Training Mode dummy-character selection that loads separate + dummy character data and FSPK bytes before creating the two-pack WASM + `TrainingSession`. +- Extended Playwright smoke coverage so Training Mode switches to a separate + dummy character/FSPK in the embedded view. +- Added detached route browser smoke coverage through BroadcastChannel sync and + real exported FSPK bytes. +- Fixed the detached route reinitialization loop that could blank the session + after startup when reactive state changes retriggered initialization. +- Added first-target WASM scenario coverage proving authored hitstun/blockstun + state routing plus resource/throw policy preservation for `test_char`. +- Added [`training-scenario-contract.md`](training-scenario-contract.md) as the + documented minimum training behavior contract for the first production target. + +Maintenance triggers: + +- Add new scenario rows and tests before making Framesmith own currently + engine-owned policies such as throw tech, throw priority, hitstop scheduling, + resource side-effect timing, or stage/corner behavior. +- Link each new scenario to the matching + [`production-gap-backlog.md`](production-gap-backlog.md) item. + +Acceptance criteria: + +- Training mode demonstrates real authored behavior, not just startup success. + +### 7. Documentation And Release Hygiene + +Status: local documentation complete; release-candidate evidence remains tied +to external CI and installer smoke gates. + +Completed: + +- Added integration coverage that executes the documented `framesmith-cli` + export examples against a temporary project and parses the generated FSPK. +- Fixed the stale `AGENTS.md` CLI export command to use `framesmith-cli`. +- Migrated completed temporary `docs/plans/` content into + [`implementation-history.md`](implementation-history.md) and permanent docs, + then removed the stale plan files. +- Added [`schema-migration.md`](schema-migration.md) for the current schema + migration path and release-candidate verification commands. +- Added [`windows-installer-smoke-test.md`](windows-installer-smoke-test.md) for + MSI/NSIS release-candidate smoke testing. +- Added [`production-gap-backlog.md`](production-gap-backlog.md) and + [`release-runbook.md`](release-runbook.md) so future production gaps and + release-candidate evidence have permanent homes. + +Maintenance triggers: + +- Keep docs updated after each completed item. +- Run the release checklist below for every candidate release. +- Record release evidence with [`release-runbook.md`](release-runbook.md). + +Acceptance criteria: + +- New contributors can follow docs without discovering command drift. +- Release artifacts are reproducible from a clean checkout. + +## Release Checklist + +Run this checklist before a tagged release: + +- [ ] Version bump in package metadata and Tauri config. +- [ ] `npm ci` from a clean checkout. +- [ ] `npm audit`. +- [ ] `npm ls @tauri-apps/api @tauri-apps/cli @tauri-apps/plugin-opener`. +- [ ] `cargo run --manifest-path src-tauri/Cargo.toml --bin generate_schema`. +- [ ] Generated JSON schemas committed with no unexpected drift. +- [ ] `npm run wasm:build`. +- [ ] `npm run check`. +- [ ] `npm run test:run`. +- [ ] `npm run test:e2e`. +- [ ] `npm run build`. +- [ ] `cargo fmt --check` for `src-tauri` and runtime crates. +- [ ] `cargo test` for `src-tauri`, `framesmith-runtime`, + `framesmith-runtime-wasm`, and `framesmith-fspack`. +- [ ] `cargo clippy --all-targets -- -D warnings` for the backend and runtime + crates. +- [ ] `npm run tauri build`. +- [ ] Installer smoke test on Windows. +- [ ] Linux package smoke test, if Linux is supported. +- [ ] macOS package smoke test, if macOS is supported. +- [ ] Documentation examples checked against current commands. +- [ ] Known export limitations reviewed against the target game. + +## Recommended Execution Order + +1. Run the new CI workflow on a clean runner and enforce it before merges. +2. Keep the canonical export contract and export-fidelity reporting current. +3. Review [`production-gap-backlog.md`](production-gap-backlog.md) with the + target game's required mechanics. +4. Reopen overlay-aware variant editing only if the target game rejects + JSON-only overlay authoring. +5. Implement movement/combat gaps only when the target game rejects the current + engine-owned/json-blob handoff. +6. Keep the training scenario contract current when a game adds stricter + hitstun/blockstun/resource/throw policies. +7. Package and smoke test every supported platform. + +## Production Readiness Checklist + +- [x] WASM source builds in the verified workspace. +- [x] Generated WASM artifacts are reproducible in the verified workspace. +- [x] Frontend types match the current Rust/data schema for audited fields. +- [x] Current sample project loads in browser smoke tests. +- [x] Cancel graph supports `tag_rules` and `deny`. +- [x] Character overview supports property-based characters. +- [x] Binary adapter names are consistent in docs, UI, CLI, MCP, and code. +- [x] Backend and runtime clippy checks have zero warnings. +- [x] CI workflow exists. +- [x] Browser smoke tests cover core editor workflows. +- [x] FSPK limitations are documented at a high level. +- [x] Training mode rebuilds from source and passes startup smoke coverage. +- [x] Release checklist exists. +- [x] Windows package builds in the verified workspace. +- [x] Variant selection and save blocking are data-loss-safe. +- [x] Export adapter field classifications are machine-checked against the Rust schema. +- [x] Runtime ownership boundaries are documented. +- [x] Combat mechanic coverage is classified. +- [x] Training mode step-back restores runtime, loop, and input-buffer state. +- [x] Documented `framesmith-cli` export examples are executed in automation. +- [x] Every `preserved`/`derived` FSPK field has named roundtrip coverage. +- [x] Intended lossy FSPK export cases have explicit documented examples. +- [x] Canonical first production handoff is documented as `json-blob` plus optional FSPK cache. +- [x] FSPK movement ownership is explicitly accepted as json-blob-only for the first production target. +- [x] Runtime guide includes engine-consumption examples for movement and resource deltas. +- [x] Production gap backlog exists for target-game-required runtime/FSPK work. +- [x] Release runbook exists for clean-checkout, CI, branch-protection, and installer evidence. +- [x] Frontend/tooling dependency audit reports 0 vulnerabilities in the verified workspace. +- [x] Tauri npm packages are pinned to the Rust-compatible minor line. +- [ ] Clean-checkout CI run has passed. +- [ ] CI is required before merges. +- [x] Overlay-aware variant editing is implemented or explicitly deferred for + the target game. +- [x] Target-game training scenarios cover hitstun/blockstun/resource/throw + policies. +- [x] Stale temporary plans are migrated or removed. +- [ ] Windows installer is manually smoke tested. +- [x] Linux package is out of scope for the first supported target. +- [x] macOS package is out of scope for the first supported target. diff --git a/docs/release-runbook.md b/docs/release-runbook.md new file mode 100644 index 0000000..6218fb2 --- /dev/null +++ b/docs/release-runbook.md @@ -0,0 +1,156 @@ +# Release Runbook + +Status: active +Last reviewed: 2026-05-23 + +Use this runbook with +[`production-readiness-plan.md`](production-readiness-plan.md) before tagging a +Framesmith release candidate. + +## Inputs + +Record these before starting: + +```text +Candidate version: +Candidate commit SHA: +Target branch: +Supported platforms: +Target game / integration: +Release owner: +``` + +## Version And Metadata + +Framesmith currently keeps these version values aligned: + +- `package.json` `version` +- `src-tauri/Cargo.toml` `package.version` +- `src-tauri/tauri.conf.json` `version` + +Before a tagged release, update all three values together and include the +version in the release evidence. For an audit-only candidate, keep the existing +version and record that no tag is being cut. + +## Clean Checkout Verification + +Run from a clean checkout of the candidate commit: + +```bash +npm ci +npm audit +npm ls @tauri-apps/api @tauri-apps/cli @tauri-apps/plugin-opener +cargo run --manifest-path src-tauri/Cargo.toml --bin generate_schema +npm run wasm:build +git diff --exit-code -- schemas/rules.schema.json src/lib/wasm +npm run check +npm run test:run +npm run test:e2e +npm run build +cargo fmt --check --manifest-path src-tauri/Cargo.toml +cargo fmt --check --manifest-path crates/framesmith-runtime/Cargo.toml +cargo fmt --check --manifest-path crates/framesmith-runtime-wasm/Cargo.toml +cargo fmt --check --manifest-path crates/framesmith-fspack/Cargo.toml +cargo test --manifest-path src-tauri/Cargo.toml +cargo test --manifest-path crates/framesmith-runtime/Cargo.toml +cargo test --manifest-path crates/framesmith-runtime-wasm/Cargo.toml +cargo test --manifest-path crates/framesmith-fspack/Cargo.toml +cargo clippy --manifest-path src-tauri/Cargo.toml --all-targets -- -D warnings +cargo clippy --manifest-path crates/framesmith-runtime/Cargo.toml --all-targets -- -D warnings +cargo clippy --manifest-path crates/framesmith-runtime-wasm/Cargo.toml --all-targets -- -D warnings +cargo clippy --manifest-path crates/framesmith-fspack/Cargo.toml --all-targets -- -D warnings +npm run tauri build +``` + +Expected local package outputs: + +```text +src-tauri/target/release/framesmith.exe +src-tauri/target/release/bundle/msi/Framesmith__x64_en-US.msi +src-tauri/target/release/bundle/nsis/Framesmith__x64-setup.exe +``` + +## GitHub CI Verification + +After pushing the candidate commit: + +1. Open the GitHub Actions run for `.github/workflows/ci.yml`. +2. Confirm the run passed for the candidate SHA. +3. Confirm the `framesmith-windows-installers` artifact exists. +4. Download the artifact or record the artifact URL for installer smoke + testing. + +Record: + +```text +GitHub Actions URL: +Candidate SHA: +CI status: +Artifact name: +Artifact URL or download source: +``` + +## Branch Protection Verification + +Before treating `main` as production-protected: + +1. Configure a branch protection rule or repository ruleset for `main`. +2. Require the CI workflow to pass before merge. +3. Require pull requests if the team uses reviewed changes. +4. Record evidence that a non-green change cannot merge. + +Record: + +```text +Protected branch/ruleset: +Required status checks: +Review requirement: +Evidence location: +``` + +## Installer Smoke Verification + +Run [`windows-installer-smoke-test.md`](windows-installer-smoke-test.md) for +both installer formats. + +Record: + +```text +Windows version: +Architecture: +MSI source: +MSI result: +NSIS source: +NSIS result: +Warnings: +``` + +## Target Game Fit Review + +Before declaring Framesmith production-ready for a game, review: + +- [`production-handoff-decision.md`](production-handoff-decision.md) +- [`export-fidelity-contract.md`](export-fidelity-contract.md) +- [`combat-coverage.md`](combat-coverage.md) +- [`training-scenario-contract.md`](training-scenario-contract.md) +- [`production-gap-backlog.md`](production-gap-backlog.md) + +Record every required backlog item as implemented, deferred, or not applicable. +If a required item is deferred, the release is not production-ready for that +target game. + +## Final Evidence Template + +```text +Version: +Commit: +CI run: +Local validation: +Installer smoke: +Supported platforms: +Target-game required backlog items: +Known accepted limitations: +Decision: ready / not ready +Decision owner: +Date: +``` diff --git a/docs/runtime-api.md b/docs/runtime-api.md index a80c05c..60a7aa8 100644 --- a/docs/runtime-api.md +++ b/docs/runtime-api.md @@ -1,7 +1,7 @@ # Framesmith Runtime API Reference **Status:** Active -**Last reviewed:** 2026-02-01 +**Last reviewed:** 2026-05-22 Complete API documentation for `framesmith-runtime`. @@ -156,6 +156,28 @@ impl CheckHitsResult { --- +### PushboxResult + +Deterministic horizontal separation for overlapping pushboxes. + +```rust +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct PushboxResult { + /// Separation to apply to player 1. + pub p1_dx: i32, + + /// Separation to apply to player 2. + pub p2_dx: i32, +} +``` + +**Notes:** +- Negative `dx` means move left; positive `dx` means move right. +- The engine applies these deltas after its own floor, wall, corner, and stage + boundary policy. + +--- + ### Shape Types #### Aabb @@ -333,11 +355,10 @@ pub fn can_cancel_to( **Evaluation order:** 1. If `target >= move_count`: Check action cancel flags 2. Check explicit denies (always blocks if present) -3. Check explicit chain cancels from state extras -4. Check tag-based cancel rules +3. Check tag-based cancel rules **Notes:** -- Resource preconditions are checked for both explicit chains and tag rules +- Resource preconditions are checked for tag-rule targets - Frame range conditions are checked for tag rules - Hit/block conditions are checked for tag rules @@ -364,7 +385,8 @@ pub fn available_cancels( **Notes:** - Requires the `alloc` feature - Filters by resource preconditions -- Returns explicit chain cancel targets only (not tag-based matches) +- Enumerates regular move/state targets accepted by `can_cancel_to` +- Does not enumerate game-defined action cancel IDs above `move_count` --- @@ -427,6 +449,39 @@ pub fn check_hits( --- +### check_pushbox + +Check active pushboxes for two characters and calculate horizontal separation. + +```rust +#[must_use] +pub fn check_pushbox( + p1_state: &CharacterState, + p1_pack: &PackView, + p1_pos: (i32, i32), + p2_state: &CharacterState, + p2_pack: &PackView, + p2_pos: (i32, i32), +) -> Option +``` + +**Arguments:** +- `p1_state` - Player 1's current state +- `p1_pack` - Player 1's character pack +- `p1_pos` - Player 1 position `(x, y)` in pixels +- `p2_state` - Player 2's current state +- `p2_pack` - Player 2's character pack +- `p2_pos` - Player 2 position `(x, y)` in pixels + +**Returns:** `Some(PushboxResult)` when exported active pushboxes overlap, +otherwise `None`. + +**Notes:** +- The function computes separation only; it does not mutate world positions. +- Stage bounds, corner behavior, and push priority are engine-owned. + +--- + ### report_hit Report that the current state connected with a hit. diff --git a/docs/runtime-guide.md b/docs/runtime-guide.md index 44c0b8a..f5ef4f0 100644 --- a/docs/runtime-guide.md +++ b/docs/runtime-guide.md @@ -1,7 +1,7 @@ # Framesmith Runtime Integration Guide **Status:** Active -**Last reviewed:** 2026-02-01 +**Last reviewed:** 2026-05-23 ## Overview @@ -14,6 +14,126 @@ 3. **`no_std` compatible**: No heap allocations (unless `alloc` feature is enabled) 4. **Rollback-ready**: Cheap state cloning enables efficient rollback netcode +## Runtime Ownership Contract + +The runtime is intentionally small. It consumes `.fspk` data and answers +deterministic questions; the game engine still owns policy, positioning, and +presentation. + +| Area | Framesmith Provides | Engine Owns | +|------|---------------------|-------------| +| State timing | `next_frame()` advances state frame counters and reports `move_ended`. | Which state to enter after a move ends, including idle, landing, wakeup, and scripted transitions. | +| Cancel validation | `can_cancel_to()`, `available_cancels()`, tag rules, explicit denies, and resource preconditions. | Input parsing, buffering, command priority, and mapping commands to state indices. | +| Resource costs | Resource costs and resource preconditions for exported resource records. | Applying resource deltas from hits, blocks, whiffs, events, round rules, and scripted game logic. | +| Hit detection | Active hit window vs hurt window overlap plus `HitResult` data. | Whether contact is hit/block/whiff, guard rules, damage application, health, hitstop/blockstop scheduling, combo rules, proration, and defender state transitions. | +| Pushboxes | `check_pushbox()` returns deterministic horizontal separation for overlapping exported pushboxes. | Applying separation to world positions, corner rules, stage bounds, collision priority, and vertical/platform behavior. | +| Movement | Authored movement fields are available in `json-blob` and editor data. | Applying movement curves/velocity, gravity, floor/wall collision, air state, and stage constraints. FSPK v1 does not serialize movement values. | +| Projectiles/entities | Authoring fields can describe spawn intent in JSON. | Entity lifecycle, collision, ownership, rollback state, and rendering. FSPK v1 does not serialize spawned entity behavior. | +| Effects/events | FSPK stores event emits and primitive args for supported event locations. | Dispatching events to VFX/SFX/gameplay systems and deciding when one-shot effects become authoritative in rollback. | + +For field-by-field export coverage, see +[`export-fidelity-contract.md`](export-fidelity-contract.md). For the canonical +production handoff and FSPK v1 movement policy, see +[`production-handoff-decision.md`](production-handoff-decision.md). + +## Engine Consumption Examples + +### Applying Authored Movement From `json-blob` + +FSPK v1 does not serialize movement values. Engines that need authored movement +should read it from the canonical `json-blob` handoff and include any mutable +velocity/accumulator values in engine rollback state. + +```typescript +type Facing = 1 | -1; +type Vec2 = { x: number; y: number }; + +function applyAuthoredMovement( + state: State, + frame: number, + position: Vec2, + velocity: Vec2, + facing: Facing +) { + const movement = state.movement; + if (!movement) return; + + const total = state.total ?? state.startup + state.active + state.recovery; + const [start, end] = movement.frames ?? [0, total - 1]; + if (frame < start || frame > end) return; + + if (movement.distance !== undefined && movement.direction) { + const frames = Math.max(1, end - start + 1); + const sign = movement.direction === "backward" ? -1 : 1; + position.x += (movement.distance / frames) * sign * facing; + return; + } + + if (movement.velocity) { + position.x += velocity.x * facing; + position.y += velocity.y; + velocity.x += movement.acceleration?.x ?? 0; + velocity.y += movement.acceleration?.y ?? 0; + } +} +``` + +### Applying FSPK Resource Deltas In The Engine + +FSPK v1 stores resource deltas, but the core runtime does not decide when to +apply every delta. Engines should apply the records when their authoritative +gameplay event occurs: on use, on hit, or on block. +`resource_index_for_name()` below is engine-owned because resource lookup policy +depends on how the game maps named resource definitions to runtime slots. + +```rust +use framesmith_fspack::{ + PackView, + RESOURCE_DELTA_TRIGGER_ON_BLOCK, + RESOURCE_DELTA_TRIGGER_ON_HIT, + RESOURCE_DELTA_TRIGGER_ON_USE, +}; +use framesmith_runtime::{resource, set_resource, CharacterState}; + +fn apply_resource_deltas_for_trigger( + pack: &PackView, + state: &mut CharacterState, + state_index: usize, + trigger: u8, +) { + let Some(extras) = pack.state_extras() else { return }; + let Some(deltas) = pack.move_resource_deltas() else { return }; + let Some(extra) = extras.get(state_index) else { return }; + + let (off, len) = extra.resource_deltas(); + for i in 0..len as usize { + let Some(delta) = deltas.get_at(off, i) else { continue }; + if delta.trigger() != trigger { + continue; + } + + let Some(name) = pack.string(delta.name_off(), delta.name_len()) else { continue }; + let Some(resource_index) = resource_index_for_name(name) else { continue }; + + let current = resource(state, resource_index); + let next = (current as i32 + delta.delta()).clamp(0, u16::MAX as i32) as u16; + set_resource(state, resource_index, next); + } +} + +fn on_move_started(pack: &PackView, state: &mut CharacterState, state_index: usize) { + apply_resource_deltas_for_trigger(pack, state, state_index, RESOURCE_DELTA_TRIGGER_ON_USE); +} + +fn on_attack_hit(pack: &PackView, state: &mut CharacterState, state_index: usize) { + apply_resource_deltas_for_trigger(pack, state, state_index, RESOURCE_DELTA_TRIGGER_ON_HIT); +} + +fn on_attack_blocked(pack: &PackView, state: &mut CharacterState, state_index: usize) { + apply_resource_deltas_for_trigger(pack, state, state_index, RESOURCE_DELTA_TRIGGER_ON_BLOCK); +} +``` + ## Quick Start ### Minimal Example @@ -92,11 +212,10 @@ let result = next_frame(&state, &pack, &input); Players request state transitions via `FrameInput::requested_state`. The runtime validates cancels based on: 1. **Explicit denies** - Hard blocks between specific states -2. **Explicit chain cancels** - State-specific cancel routes (rekkas, target combos) -3. **Tag-based rules** - Pattern rules like "normals can cancel into specials on hit" +2. **Tag-based rules** - Pattern rules like "normals can cancel into specials on hit" ```rust -use framesmith_runtime::{can_cancel_to, available_cancels}; +use framesmith_runtime::{available_cancels, available_cancels_buf, can_cancel_to}; // Check if a specific cancel is valid if can_cancel_to(&state, &pack, target_state) { @@ -117,9 +236,9 @@ let count = available_cancels_buf(&state, &pack, &mut buf); **Cancel conditions:** - `always` - Cancel allowed anytime in frame range -- `on_hit` - Only after `report_hit()` called -- `on_block` - Only after `report_block()` called -- `on_whiff` - Only when neither hit nor block confirmed +- `hit` - Only after `report_hit()` called +- `block` - Only after `report_block()` called +- `whiff` - Only when neither hit nor block confirmed ### Action Cancels @@ -190,6 +309,29 @@ report_block(&mut attacker_state); This updates `hit_confirmed` or `block_confirmed` on the state, which tag-based cancel rules check. +## Pushbox Separation + +Use `check_pushbox()` to detect body overlap for the current frame: + +```rust +use framesmith_runtime::check_pushbox; + +let separation = check_pushbox( + &p1_state, &p1_pack, p1_pos, + &p2_state, &p2_pack, p2_pos, +); + +if let Some(sep) = separation { + // Engine applies these deltas to world positions after stage/corner policy. + p1_pos.0 += sep.p1_dx; + p2_pos.0 += sep.p2_dx; +} +``` + +The runtime only calculates overlap resolution from exported pushboxes. The +engine must clamp the result to floors, walls, corners, camera bounds, and any +game-specific collision priority rules. + ## Resources ### Resource Pool Management @@ -329,6 +471,21 @@ function tick() { } ``` +For browser training tools that need frame step-back, capture and restore +session snapshots. The snapshot covers the deterministic runtime state and +character positions; the host app must also restore its own input history, +health/combo counters, camera, and UI state. + +```typescript +const beforeFrame = session.snapshot(); + +const result = session.tick(playerInput, DummyState.Stand); +render(result); + +// Step back to the exact runtime state from before the tick. +session.restore(beforeFrame); +``` + ## Troubleshooting ### Cancel Not Working @@ -386,4 +543,5 @@ init_resources(&mut state, &pack); // Sets starting values - [Runtime API Reference](runtime-api.md) - Complete type and function documentation - [ZX FSPK Format](zx-fspack.md) - Binary pack format specification +- [Export Fidelity Contract](export-fidelity-contract.md) - Adapter field coverage and FSPK limits - [Data Formats](data-formats.md) - On-disk JSON formats diff --git a/docs/schema-migration.md b/docs/schema-migration.md new file mode 100644 index 0000000..af72efd --- /dev/null +++ b/docs/schema-migration.md @@ -0,0 +1,113 @@ +# Schema Migration Notes + +Status: active +Last reviewed: 2026-05-23 + +This document records authoring-data migrations that matter when updating an +older Framesmith project to the current schema. + +## Current Canonical Shape + +- `character.json` stores custom gameplay fields in `character.properties`. +- `character.resources[]` declares named resources such as heat, ammo, level, + or install flags. +- State files may use `id` to distinguish resolved variants that share a + gameplay `input`. +- `cancel_table.json` supports both legacy `rules[]` and tag-based + `tag_rules[]` plus `deny`. +- The binary export adapter is named `fspk`; `zx-fspack` is accepted only as a + legacy alias. + +## Character Properties + +Older projects may have fixed character fields at the top level. Move them into +`properties` so rules, UI, JSON export, and FSPK property export see the same +data. + +Before: + +```json +{ + "id": "glitch", + "name": "GLITCH", + "health": 10000, + "walk_speed": 4.0 +} +``` + +After: + +```json +{ + "id": "glitch", + "name": "GLITCH", + "properties": { + "health": 10000, + "walk_speed": 4.0 + }, + "resources": [] +} +``` + +## Cancel Tables + +Legacy route rules can remain in `rules[]`. Prefer `tag_rules[]` for production +characters because tags survive state renames and variant expansion. + +```json +{ + "rules": [ + { "from": "5L", "to": "5M", "on": ["hit", "block"] } + ], + "tag_rules": [ + { "from": "normal", "to": "special", "on": ["hit", "block"] } + ], + "deny": { + "5H": ["5H"] + } +} +``` + +## Variant States + +Resolved variants are read-only editor snapshots. Author variants as overlay +JSON files and give resolved states stable `id` values when they share the same +gameplay `input`. + +```json +{ + "id": "5H~level2", + "input": "5H", + "name": "Standing Heavy Level 2", + "tags": ["5h", "variant"] +} +``` + +Do not serialize a fully resolved state back into an overlay patch. The current +State Editor, MCP `update_move`, and Tauri `save_move` path reject resolved +variant saves. + +## Export Adapter Rename + +Use: + +```bash +cargo run --bin framesmith-cli -- export --project .. --character test_char --adapter fspk --out ../exports/test_char.fspk +``` + +The `zx-fspack` adapter name remains as a compatibility alias for old scripts, +but new docs and automation should use `fspk`. + +## Verification + +After migration, run: + +```bash +npm run check +npm run test:run +cargo test --manifest-path src-tauri/Cargo.toml --test pipeline_e2e +cargo test --manifest-path src-tauri/Cargo.toml --test export_fidelity_contract +``` + +For a release candidate, run the full checklist in +[`production-readiness-plan.md`](production-readiness-plan.md). diff --git a/docs/training-scenario-contract.md b/docs/training-scenario-contract.md new file mode 100644 index 0000000..3844fac --- /dev/null +++ b/docs/training-scenario-contract.md @@ -0,0 +1,50 @@ +# Training Scenario Contract + +Status: active +Last reviewed: 2026-05-23 + +This document defines the current first-target training scenarios that must keep +working before Framesmith can be considered production ready for game +development. + +The current target fixture is `characters/test_char` exported to +`exports/test_char.fspk`. A real game may add stricter policies, but these +scenarios are the minimum executable contract for authored training behavior. + +## Scenario Matrix + +| Scenario | Policy | Evidence | +|----------|--------|----------| +| Authored hitstun | A hit routes the defender into an authored `hitstun` state found by input or tag and sets `instance_duration` from the hit's `hitstun`. | `target_training_fixture_resolves_authored_reaction_states` in `crates/framesmith-runtime-wasm/src/lib.rs` | +| Authored blockstun | Blocking dummy modes route the defender into an authored `blockstun` state found by input or tag and set `instance_duration` from `blockstun`. | `target_training_fixture_resolves_authored_reaction_states` in `crates/framesmith-runtime-wasm/src/lib.rs` | +| Resource policy | FSPK preserves target fixture resource definitions and resource deltas; the engine applies resource side effects at the authoritative hit/block/use timing. | `target_training_fixture_preserves_resource_and_throw_policies` and `fspk_exports_resources_and_events_sections` | +| Throw input policy | Throw states can be authored as `type: "throw"` and resolved from inputs such as `5T`; throw collision, teching, invulnerability, and priority remain engine-owned. | `target_training_fixture_preserves_resource_and_throw_policies`, `supports authored throw inputs that use the T button`, and `resolves throw inputs through the regular input path` | +| Roundtrip reload | The exported pack must parse after generation, preserve state inputs/tags/resources, and remain usable by the training WASM wrapper. | `fspk_roundtrip`, `export_fidelity_contract`, and `crates/framesmith-runtime-wasm` tests | +| Embedded training smoke | The editor Training view loads rebuilt WASM and real CLI-exported FSPK bytes, then can switch to a separate dummy character/FSPK. | `loads training mode from rebuilt WASM and exported FSPK data` in `tests/e2e/editor-smoke.spec.ts` | +| Detached training smoke | The detached route receives character/project data over BroadcastChannel and starts from real exported FSPK bytes. | `loads detached training mode through BroadcastChannel sync` in `tests/e2e/editor-smoke.spec.ts` | + +## Engine-Owned Policies + +The current contract intentionally leaves these policies to the consuming game: + +- Throw boxes, throw tech, throw invulnerability, and throw-vs-strike priority. +- Resource side-effect timing beyond exported costs/preconditions/deltas. +- Hitstop, blockstop, rollback freeze scheduling, and visual/audio event + dispatch. +- Stage bounds, corner behavior, forced movement, launch, and knockdown state + machines. + +If a target game wants Framesmith to own any of these behaviors, add a concrete +scenario here before changing runtime or FSPK behavior, then link the scenario +to the matching item in [`production-gap-backlog.md`](production-gap-backlog.md). + +## Verification Commands + +Run these before changing this contract: + +```bash +npm run test:run -- src/lib/views/training/TrainingLoop.test.ts src/lib/training/buildMoveList.test.ts src/lib/training/InputManager.test.ts +cargo test --manifest-path crates/framesmith-runtime-wasm/Cargo.toml +cargo test --manifest-path src-tauri/Cargo.toml --test fspk_roundtrip +npm run test:e2e +``` diff --git a/docs/variant-editing-decision.md b/docs/variant-editing-decision.md new file mode 100644 index 0000000..b2fb318 --- /dev/null +++ b/docs/variant-editing-decision.md @@ -0,0 +1,111 @@ +# Variant Editing Decision + +Status: active +Last reviewed: 2026-05-23 + +## Decision + +Overlay-aware variant editing is explicitly deferred for the first production +target. Variant overlays remain JSON-authored files, while the editor may load +and inspect resolved variants as read-only states. + +This is an accepted production constraint for the first target, not an +unresolved data-loss bug. The current safe behavior is: + +- Variant overlay files are authored under `characters/{id}/states/`, such as + `characters/test_char/states/5H~level2.json`. +- Loading a character resolves each overlay against its base state and gives + the resolved state a unique `id`. +- The State Editor and Frame Data Table select resolved variants by `id`, not + only by gameplay `input`. +- Saving a resolved variant through the State Editor, MCP `update_move`, or + backend `save_move` is rejected because the loaded value is a resolved + snapshot, not the original overlay patch. +- Exports continue to include resolved variants. `json-blob` is the canonical + first production handoff; `fspk` v1 is an optional compact runtime subset. + +## Supported Workflow + +Create a base state: + +```json +{ + "input": "5H", + "name": "Standing Heavy", + "startup": 12, + "active": 4, + "recovery": 24, + "damage": 80 +} +``` + +Create an overlay file at `characters/test_char/states/5H~level2.json`: + +```json +{ + "input": "5H", + "base": "5H", + "name": "Standing Heavy Level 2", + "damage": 100, + "meter_gain": 12 +} +``` + +Reload the project, inspect the resolved state as `5H~level2`, and export: + +```bash +cargo run --bin framesmith-cli -- export --project .. --character test_char --adapter json-blob --pretty --out ../exports/test_char.json +cargo run --bin framesmith-cli -- export --project .. --character test_char --adapter fspk --out ../exports/test_char.fspk +``` + +## Why This Is Deferred + +Overlay-aware editing is not just a form-save feature. To be safe, the editor +must know which fields are inherited, which fields are explicitly overridden, +and when an inherited value should be removed. Writing the resolved state back +to `5H~level2.json` would duplicate base data into the overlay and could make a +future base-state balance change silently stop affecting the variant. + +The first production target can accept JSON overlay authoring because: + +- The runtime handoff receives resolved output and does not consume inheritance. +- Data-loss protection is already enforced by selection identity and save + blocking. +- Variant overlays are small, readable JSON files that can be reviewed in + source control. +- The canonical production handoff is `json-blob`, so no FSPK v1 limitation + blocks variant testing or gameplay integration. + +## Reopen Criteria + +Implement overlay-aware variant editing before a production release if any of +these become true: + +- The target game needs non-programmer designers to edit variant overlays + without touching JSON. +- The team needs to compare inherited and overridden fields visually in the + editor. +- Variant patches need field-level delete semantics beyond the current JSON + overlay behavior. +- A target-game review rejects JSON-only variant authoring as an unacceptable + workflow risk. + +When reopened, the implementation must: + +- Open the overlay file directly instead of the resolved snapshot. +- Show inherited, overridden, and deleted fields distinctly. +- Save only the overlay diff. +- Include tests proving a fully resolved state cannot be serialized back into an + overlay patch. + +## Acceptance For First Target + +For the first production target, variant authoring is accepted when all of +these stay true: + +- Resolved variants are selectable and inspectable by unique `id`. +- Resolved variants cannot overwrite base state files or overlay files through + `save_move`. +- The JSON overlay workflow is documented in `data-formats.md`. +- Export tests continue to prove resolved variants appear in production + handoff output. diff --git a/docs/windows-installer-smoke-test.md b/docs/windows-installer-smoke-test.md new file mode 100644 index 0000000..fb2ed2b --- /dev/null +++ b/docs/windows-installer-smoke-test.md @@ -0,0 +1,53 @@ +# Windows Installer Smoke Test + +Status: active +Last reviewed: 2026-05-23 + +Run this manual smoke test for every Windows release candidate after +`npm run tauri build` or after downloading the CI `framesmith-windows-installers` +artifact. + +## Artifacts + +Expected files: + +```text +src-tauri/target/release/bundle/msi/Framesmith__x64_en-US.msi +src-tauri/target/release/bundle/nsis/Framesmith__x64-setup.exe +``` + +## MSI Smoke Test + +1. Install the MSI on a Windows machine or clean VM. +2. Launch Framesmith from the Start menu. +3. Open the repository project folder. +4. Load `TEST_CHAR`. +5. Switch to State Editor and select `5L`. +6. Switch to Training and confirm `P1` and `CPU` appear without an initialization + error. +7. Export `TEST_CHAR` as `json-blob`. +8. Close Framesmith. +9. Uninstall Framesmith from Windows Apps. + +Pass criteria: + +- The app launches without Windows SmartScreen or installer errors beyond the + expected unsigned-build warning. +- The project opens and `TEST_CHAR` loads. +- Training starts from the packaged WASM and FSPK path. +- Export completes without an IPC error. +- Uninstall removes the app entry. + +## NSIS Smoke Test + +Repeat the same flow with the NSIS `setup.exe`. + +## Evidence To Record + +For the release note or checklist, record: + +- Windows version and architecture. +- Installer type tested: MSI, NSIS, or both. +- Artifact source: local build path or CI run URL. +- Framesmith version. +- Pass/fail result and any warnings. diff --git a/docs/zx-fspack.md b/docs/zx-fspack.md index 3c7c9c5..ce38627 100644 --- a/docs/zx-fspack.md +++ b/docs/zx-fspack.md @@ -1,7 +1,7 @@ # ZX FSPK Export Format **Status:** Active -**Last reviewed:** 2026-01-30 +**Last reviewed:** 2026-05-22 ## Overview @@ -16,16 +16,16 @@ FSPK (Framesmith Pack) is a compact binary format for storing fighting game char ### Components -- **Framesmith export adapter** (`zx-fspack`): Converts character JSON to FSPK binary +- **Framesmith export adapter** (`fspk`): Converts character JSON to FSPK binary - **`framesmith-fspack` crate**: `no_std` Rust library for reading FSPK files at runtime ## Exporting from Framesmith -Use the `zx-fspack` adapter when exporting a character. +Use the `fspk` adapter when exporting a character. -- In the app UI: Character Overview -> Export -> "ZX FSPK (Binary)" -- In the CLI: `cd src-tauri && cargo run --bin framesmith -- export --project .. --character test_char --out ../exports/test_char.fspk` -- Programmatically: call the `export_character` command with `adapter = "zx-fspack"` +- In the app UI: Character Overview -> Export -> "FSPK (Binary)" +- In the CLI: `cd src-tauri && cargo run --bin framesmith-cli -- export --project .. --character test_char --out ../exports/test_char.fspk` +- Programmatically: call the `export_character` command with `adapter = "fspk"` ```rust // Tauri command signature (Rust side) @@ -33,12 +33,15 @@ Use the `zx-fspack` adapter when exporting a character. export_character( "/characters".to_string(), "test_char".to_string(), - "zx-fspack".to_string(), + "fspk".to_string(), "exports/test_char.fspk".to_string(), false, )?; ``` +`zx-fspack` is accepted as a legacy adapter alias for older integrations, but +`fspk` is canonical. + This produces a `.fspk` binary file containing: - Character state data (frame counts, damage, hitstun, etc.) - Hitbox and hurtbox geometry diff --git a/package-lock.json b/package-lock.json index 7dceac4..6649f34 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,26 +1,27 @@ { - "name": "d-developmentnethercore-projectframesmith", + "name": "framesmith", "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "d-developmentnethercore-projectframesmith", + "name": "framesmith", "version": "0.1.0", "license": "MIT", "dependencies": { - "@tauri-apps/api": "^2", - "@tauri-apps/plugin-opener": "^2", + "@tauri-apps/api": "2.9.1", + "@tauri-apps/plugin-opener": "2.5.3", "@threlte/core": "^8.3.1", "@threlte/extras": "^9.7.1", "@types/three": "^0.182.0", "three": "^0.182.0" }, "devDependencies": { + "@playwright/test": "^1.60.0", "@sveltejs/adapter-static": "^3.0.6", "@sveltejs/kit": "^2.9.0", "@sveltejs/vite-plugin-svelte": "^5.0.0", - "@tauri-apps/cli": "^2", + "@tauri-apps/cli": "2.9.6", "@types/node": "^25.1.0", "svelte": "^5.0.0", "svelte-check": "^4.0.0", @@ -522,6 +523,22 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@playwright/test": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz", + "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@polka/url": { "version": "1.0.0-next.29", "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", @@ -530,9 +547,9 @@ "license": "MIT" }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.0.tgz", - "integrity": "sha512-tPgXB6cDTndIe1ah7u6amCI1T0SsnlOuKgg10Xh3uizJk4e5M1JGaUMk7J4ciuAUcFpbOiNhm2XIjP9ON0dUqA==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", + "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==", "cpu": [ "arm" ], @@ -544,9 +561,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.0.tgz", - "integrity": "sha512-sa4LyseLLXr1onr97StkU1Nb7fWcg6niokTwEVNOO7awaKaoRObQ54+V/hrF/BP1noMEaaAW6Fg2d/CfLiq3Mg==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz", + "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==", "cpu": [ "arm64" ], @@ -558,9 +575,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.0.tgz", - "integrity": "sha512-/NNIj9A7yLjKdmkx5dC2XQ9DmjIECpGpwHoGmA5E1AhU0fuICSqSWScPhN1yLCkEdkCwJIDu2xIeLPs60MNIVg==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz", + "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==", "cpu": [ "arm64" ], @@ -572,9 +589,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.0.tgz", - "integrity": "sha512-xoh8abqgPrPYPr7pTYipqnUi1V3em56JzE/HgDgitTqZBZ3yKCWI+7KUkceM6tNweyUKYru1UMi7FC060RyKwA==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz", + "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==", "cpu": [ "x64" ], @@ -586,9 +603,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.0.tgz", - "integrity": "sha512-PCkMh7fNahWSbA0OTUQ2OpYHpjZZr0hPr8lId8twD7a7SeWrvT3xJVyza+dQwXSSq4yEQTMoXgNOfMCsn8584g==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz", + "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==", "cpu": [ "arm64" ], @@ -600,9 +617,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.0.tgz", - "integrity": "sha512-1j3stGx+qbhXql4OCDZhnK7b01s6rBKNybfsX+TNrEe9JNq4DLi1yGiR1xW+nL+FNVvI4D02PUnl6gJ/2y6WJA==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz", + "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==", "cpu": [ "x64" ], @@ -614,13 +631,16 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.0.tgz", - "integrity": "sha512-eyrr5W08Ms9uM0mLcKfM/Uzx7hjhz2bcjv8P2uynfj0yU8GGPdz8iYrBPhiLOZqahoAMB8ZiolRZPbbU2MAi6Q==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz", + "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==", "cpu": [ "arm" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -628,13 +648,16 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.0.tgz", - "integrity": "sha512-Xds90ITXJCNyX9pDhqf85MKWUI4lqjiPAipJ8OLp8xqI2Ehk+TCVhF9rvOoN8xTbcafow3QOThkNnrM33uCFQA==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz", + "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==", "cpu": [ "arm" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -642,13 +665,16 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.0.tgz", - "integrity": "sha512-Xws2KA4CLvZmXjy46SQaXSejuKPhwVdaNinldoYfqruZBaJHqVo6hnRa8SDo9z7PBW5x84SH64+izmldCgbezw==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz", + "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -656,13 +682,16 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.0.tgz", - "integrity": "sha512-hrKXKbX5FdaRJj7lTMusmvKbhMJSGWJ+w++4KmjiDhpTgNlhYobMvKfDoIWecy4O60K6yA4SnztGuNTQF+Lplw==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz", + "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -670,13 +699,16 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.0.tgz", - "integrity": "sha512-6A+nccfSDGKsPm00d3xKcrsBcbqzCTAukjwWK6rbuAnB2bHaL3r9720HBVZ/no7+FhZLz/U3GwwZZEh6tOSI8Q==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz", + "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==", "cpu": [ "loong64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -684,13 +716,16 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.0.tgz", - "integrity": "sha512-4P1VyYUe6XAJtQH1Hh99THxr0GKMMwIXsRNOceLrJnaHTDgk1FTcTimDgneRJPvB3LqDQxUmroBclQ1S0cIJwQ==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz", + "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==", "cpu": [ "loong64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -698,13 +733,16 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.0.tgz", - "integrity": "sha512-8Vv6pLuIZCMcgXre6c3nOPhE0gjz1+nZP6T+hwWjr7sVH8k0jRkH+XnfjjOTglyMBdSKBPPz54/y1gToSKwrSQ==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz", + "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==", "cpu": [ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -712,13 +750,16 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.0.tgz", - "integrity": "sha512-r1te1M0Sm2TBVD/RxBPC6RZVwNqUTwJTA7w+C/IW5v9Ssu6xmxWEi+iJQlpBhtUiT1raJ5b48pI8tBvEjEFnFA==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz", + "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==", "cpu": [ "ppc64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -726,13 +767,16 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.0.tgz", - "integrity": "sha512-say0uMU/RaPm3CDQLxUUTF2oNWL8ysvHkAjcCzV2znxBr23kFfaxocS9qJm+NdkRhF8wtdEEAJuYcLPhSPbjuQ==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz", + "integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==", "cpu": [ "riscv64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -740,13 +784,16 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.0.tgz", - "integrity": "sha512-/MU7/HizQGsnBREtRpcSbSV1zfkoxSTR7wLsRmBPQ8FwUj5sykrP1MyJTvsxP5KBq9SyE6kH8UQQQwa0ASeoQQ==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz", + "integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==", "cpu": [ "riscv64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -754,13 +801,16 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.0.tgz", - "integrity": "sha512-Q9eh+gUGILIHEaJf66aF6a414jQbDnn29zeu0eX3dHMuysnhTvsUvZTCAyZ6tJhUjnvzBKE4FtuaYxutxRZpOg==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz", + "integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==", "cpu": [ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -768,13 +818,16 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.0.tgz", - "integrity": "sha512-OR5p5yG5OKSxHReWmwvM0P+VTPMwoBS45PXTMYaskKQqybkS3Kmugq1W+YbNWArF8/s7jQScgzXUhArzEQ7x0A==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz", + "integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -782,13 +835,16 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.0.tgz", - "integrity": "sha512-XeatKzo4lHDsVEbm1XDHZlhYZZSQYym6dg2X/Ko0kSFgio+KXLsxwJQprnR48GvdIKDOpqWqssC3iBCjoMcMpw==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz", + "integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -796,9 +852,9 @@ ] }, "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.0.tgz", - "integrity": "sha512-Lu71y78F5qOfYmubYLHPcJm74GZLU6UJ4THkf/a1K7Tz2ycwC2VUbsqbJAXaR6Bx70SRdlVrt2+n5l7F0agTUw==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz", + "integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==", "cpu": [ "x64" ], @@ -810,9 +866,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.0.tgz", - "integrity": "sha512-v5xwKDWcu7qhAEcsUubiav7r+48Uk/ENWdr82MBZZRIm7zThSxCIVDfb3ZeRRq9yqk+oIzMdDo6fCcA5DHfMyA==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz", + "integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==", "cpu": [ "arm64" ], @@ -824,9 +880,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.0.tgz", - "integrity": "sha512-XnaaaSMGSI6Wk8F4KK3QP7GfuuhjGchElsVerCplUuxRIzdvZ7hRBpLR0omCmw+kI2RFJB80nenhOoGXlJ5TfQ==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz", + "integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==", "cpu": [ "arm64" ], @@ -838,9 +894,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.0.tgz", - "integrity": "sha512-3K1lP+3BXY4t4VihLw5MEg6IZD3ojSYzqzBG571W3kNQe4G4CcFpSUQVgurYgib5d+YaCjeFow8QivWp8vuSvA==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz", + "integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==", "cpu": [ "ia32" ], @@ -852,9 +908,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.0.tgz", - "integrity": "sha512-MDk610P/vJGc5L5ImE4k5s+GZT3en0KoK1MKPXCRgzmksAMk79j4h3k1IerxTNqwDLxsGxStEZVBqG0gIqZqoA==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz", + "integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==", "cpu": [ "x64" ], @@ -866,9 +922,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.0.tgz", - "integrity": "sha512-Zv7v6q6aV+VslnpwzqKAmrk5JdVkLUzok2208ZXGipjb+msxBr/fJPZyeEXiFgH7k62Ak0SLIfxQRZQvTuf7rQ==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz", + "integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==", "cpu": [ "x64" ], @@ -887,9 +943,9 @@ "license": "MIT" }, "node_modules/@sveltejs/acorn-typescript": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.8.tgz", - "integrity": "sha512-esgN+54+q0NjB0Y/4BomT9samII7jGwNy/2a3wNZbT2A2RpmXsXwUt24LvLhx6jUq2gVk4cWEvcRO6MFQbOfNA==", + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.10.tgz", + "integrity": "sha512-4WfKk68eTih+MiJD4fSbxN7E8kVBmTMPWHUPYjvl2N0rMs53YLTT8/YjKU5Dtnz5LqDjl7LEw4U7lXR2W3J5WA==", "license": "MIT", "peerDependencies": { "acorn": "^8.9.0" @@ -906,25 +962,23 @@ } }, "node_modules/@sveltejs/kit": { - "version": "2.50.1", - "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.50.1.tgz", - "integrity": "sha512-XRHD2i3zC4ukhz2iCQzO4mbsts081PAZnnMAQ7LNpWeYgeBmwMsalf0FGSwhFXBbtr2XViPKnFJBDCckWqrsLw==", + "version": "2.60.1", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.60.1.tgz", + "integrity": "sha512-mQjlkNo+rJvpln7V2IGY2j99BqhcFbS4UN0AQNKNYfhBAFZTuCDAdW3a1sgf330mvtNvsBXn3HpAhcmvdJTcIQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/cookie": "^0.6.0", "acorn": "^8.14.1", "cookie": "^0.6.0", - "devalue": "^5.6.2", + "devalue": "^5.8.1", "esm-env": "^1.2.2", "kleur": "^4.1.5", "magic-string": "^0.30.5", "mrmime": "^2.0.0", - "sade": "^1.8.1", - "set-cookie-parser": "^2.6.0", + "set-cookie-parser": "^3.0.0", "sirv": "^3.0.0" }, "bin": { @@ -935,10 +989,10 @@ }, "peerDependencies": { "@opentelemetry/api": "^1.0.0", - "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0", + "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0", "svelte": "^4.0.0 || ^5.0.0-next.0", - "typescript": "^5.3.3", - "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0" + "typescript": "^5.3.3 || ^6.0.0", + "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0" }, "peerDependenciesMeta": { "@opentelemetry/api": { @@ -955,7 +1009,6 @@ "integrity": "sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^4.0.1", "debug": "^4.4.1", @@ -1089,6 +1142,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -1106,6 +1162,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -1123,6 +1182,9 @@ "riscv64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -1140,6 +1202,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -1157,6 +1222,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -1242,22 +1310,19 @@ } }, "node_modules/@threlte/core": { - "version": "8.3.1", - "resolved": "https://registry.npmjs.org/@threlte/core/-/core-8.3.1.tgz", - "integrity": "sha512-qKjjNCQ+40hyeBcfOMh/8ef5x/j5PG5Wmo/L9Ye0aDCcdD6fCewWxfp7tV/J3CxPzX1dEp1JGK7sjyc7ntZSrg==", + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/@threlte/core/-/core-8.5.14.tgz", + "integrity": "sha512-9UPW8zgkXPk6zEaJSvNuL9mDa0fHqCBzDXioUDt6flRUEvhh+nR8Q6XMjea6s+frReFw7WnKcDobPsGNPbtdLg==", "license": "MIT", - "dependencies": { - "mitt": "^3.0.1" - }, "peerDependencies": { "svelte": ">=5", "three": ">=0.160" } }, "node_modules/@threlte/extras": { - "version": "9.7.1", - "resolved": "https://registry.npmjs.org/@threlte/extras/-/extras-9.7.1.tgz", - "integrity": "sha512-SGm59HDCdHxADFHuweHfFDknwubkCPodyK0pbfsVtOWWOX26gE2xfK7aKolh6YFDiPAjWjGxN0jIgkNbbr1ohg==", + "version": "9.19.0", + "resolved": "https://registry.npmjs.org/@threlte/extras/-/extras-9.19.0.tgz", + "integrity": "sha512-vj5A2Gh0hIDId86TK26tRhQPEuxQ1oVFGsnyrjWP3pTPSxHCcyqpi7mStlcc+QdUxkuUYyCKG64aD2TZdsmGdA==", "license": "MIT", "dependencies": { "@threejs-kit/instanced-sprite-mesh": "^2.5.1", @@ -1304,20 +1369,19 @@ "license": "MIT" }, "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", "license": "MIT" }, "node_modules/@types/node": { - "version": "25.1.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.1.0.tgz", - "integrity": "sha512-t7frlewr6+cbx+9Ohpl0NOTKXZNV9xHRmNOvql47BFJKcEG1CxtxlPEEe+gR9uhVWM4DwhnvTF110mIL4yP9RA==", + "version": "25.9.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz", + "integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "undici-types": "~7.16.0" + "undici-types": ">=7.24.0 <7.24.7" } }, "node_modules/@types/stats.js": { @@ -1331,7 +1395,6 @@ "resolved": "https://registry.npmjs.org/@types/three/-/three-0.182.0.tgz", "integrity": "sha512-WByN9V3Sbwbe2OkWuSGyoqQO8Du6yhYaXtXLoA5FkKTUJorZ+yOHBZ35zUUPQXlAKABZmbYp5oAqpA4RBjtJ/Q==", "license": "MIT", - "peer": true, "dependencies": { "@dimforge/rapier3d-compat": "~0.12.0", "@tweenjs/tween.js": "~23.1.3", @@ -1342,6 +1405,12 @@ "meshoptimizer": "~0.22.0" } }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT" + }, "node_modules/@types/webxr": { "version": "0.5.24", "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", @@ -1349,31 +1418,31 @@ "license": "MIT" }, "node_modules/@vitest/expect": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz", - "integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.7.tgz", + "integrity": "sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w==", "dev": true, "license": "MIT", "dependencies": { - "@standard-schema/spec": "^1.0.0", + "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.0.18", - "@vitest/utils": "4.0.18", - "chai": "^6.2.1", - "tinyrainbow": "^3.0.3" + "@vitest/spy": "4.1.7", + "@vitest/utils": "4.1.7", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/mocker": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz", - "integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.7.tgz", + "integrity": "sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.0.18", + "@vitest/spy": "4.1.7", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -1382,7 +1451,7 @@ }, "peerDependencies": { "msw": "^2.4.9", - "vite": "^6.0.0 || ^7.0.0-0" + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "msw": { @@ -1394,26 +1463,26 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz", - "integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.7.tgz", + "integrity": "sha512-umgCarTOYQWIaDMvGDRZij+6b9oVeLIyJzfN+AS88e0ZOU3QTgNNSTtjQOpcvWr3np1N0j4WgZj+sb3oYBDscw==", "dev": true, "license": "MIT", "dependencies": { - "tinyrainbow": "^3.0.3" + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/runner": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz", - "integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.7.tgz", + "integrity": "sha512-BapjmAQ2aI78WdMEfeUWivnfVzB+VPGwWRQcJE0OUq7qEeEcBsCSf+0T5iREBNE5nBb4wA5Ya0W6IA+sghdEFw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.0.18", + "@vitest/utils": "4.1.7", "pathe": "^2.0.3" }, "funding": { @@ -1421,13 +1490,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz", - "integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.7.tgz", + "integrity": "sha512-ZacLzja+TmJeZ1h14xW2FB/WpeimUD3haBXQPyJqxvo8jQTmfeA8zv58mtjN2C7EHXZDYVcVYdYmAxjkWVvKCw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.0.18", + "@vitest/pretty-format": "4.1.7", + "@vitest/utils": "4.1.7", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -1436,9 +1506,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz", - "integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.7.tgz", + "integrity": "sha512-kbkI5LMWakyuTIvs6fUJ5qdIVb1XVKsYJAT4OJ938cHMROYMSfmoQdZy0aaAnjbbc8F61vkoTqz/Az+/HiIu5Q==", "dev": true, "license": "MIT", "funding": { @@ -1446,31 +1516,31 @@ } }, "node_modules/@vitest/utils": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz", - "integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.7.tgz", + "integrity": "sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.0.18", - "tinyrainbow": "^3.0.3" + "@vitest/pretty-format": "4.1.7", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@webgpu/types": { - "version": "0.1.69", - "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.69.tgz", - "integrity": "sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ==", + "version": "0.1.70", + "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.70.tgz", + "integrity": "sha512-LFiNHHKMvmAEvwVew3JLJmTdShhbdwRFSImUshGhE2mGE8ybQzIo63l5uRp+YKnNx+8Qno8Kf6gN+DKMreIJCA==", "license": "BSD-3-Clause" }, "node_modules/acorn": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", - "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1479,9 +1549,9 @@ } }, "node_modules/aria-query": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", - "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", + "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", "license": "Apache-2.0", "engines": { "node": ">= 0.4" @@ -1563,10 +1633,17 @@ "node": ">=6" } }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/cookie": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", - "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", "dev": true, "license": "MIT", "engines": { @@ -1602,9 +1679,9 @@ } }, "node_modules/devalue": { - "version": "5.6.2", - "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.2.tgz", - "integrity": "sha512-nPRkjWzzDQlsejL1WVifk5rvcFi/y1onBRxjaFMjZeR9mFpqu2gmAZ9xUB9/IEanEP/vBtGeGganC/GO1fmufg==", + "version": "5.8.1", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz", + "integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==", "license": "MIT" }, "node_modules/diet-sprite": { @@ -1620,9 +1697,9 @@ "license": "ISC" }, "node_modules/es-module-lexer": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", "dev": true, "license": "MIT" }, @@ -1675,12 +1752,20 @@ "license": "MIT" }, "node_modules/esrap": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.2.tgz", - "integrity": "sha512-zA6497ha+qKvoWIK+WM9NAh5ni17sKZKhbS5B3PoYbBvaYHZWoS33zmFybmyqpn07RLUxSmn+RCls2/XF+d0oQ==", + "version": "2.2.9", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.9.tgz", + "integrity": "sha512-4KijP+NxCWthMCUC3qHbE6n4vCjqgJS1uAYKhuT/GWfFTf1Qyive2TgOjep+gzbSzRfnNyaN/UU9YmdOt8Eg0A==", "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" + }, + "peerDependencies": { + "@typescript-eslint/types": "^8.2.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/types": { + "optional": true + } } }, "node_modules/estree-walker": { @@ -1722,15 +1807,15 @@ } }, "node_modules/fflate": { - "version": "0.8.2", - "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", - "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", "license": "MIT" }, "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -1792,12 +1877,6 @@ "integrity": "sha512-IebiK79sqIy+E4EgOr+CAw+Ke8hAspXKzBd0JdgEmPHiAwmvEj2S4h1rfvo+o/BnfEYd/jAOg5IeeIjzlzSnDg==", "license": "MIT" }, - "node_modules/mitt": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", - "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", - "license": "MIT" - }, "node_modules/mri": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", @@ -1826,9 +1905,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", "dev": true, "funding": [ { @@ -1870,12 +1949,11 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -1883,10 +1961,42 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", "dev": true, "funding": [ { @@ -1904,7 +2014,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -1936,9 +2046,9 @@ } }, "node_modules/rollup": { - "version": "4.57.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.0.tgz", - "integrity": "sha512-e5lPJi/aui4TO1LpAXIRLySmwXSE8k3b9zoGfd42p67wzxog4WHjiZF3M2uheQih4DGyc25QEV4yRBbpueNiUA==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz", + "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==", "dev": true, "license": "MIT", "dependencies": { @@ -1952,34 +2062,41 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.57.0", - "@rollup/rollup-android-arm64": "4.57.0", - "@rollup/rollup-darwin-arm64": "4.57.0", - "@rollup/rollup-darwin-x64": "4.57.0", - "@rollup/rollup-freebsd-arm64": "4.57.0", - "@rollup/rollup-freebsd-x64": "4.57.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.57.0", - "@rollup/rollup-linux-arm-musleabihf": "4.57.0", - "@rollup/rollup-linux-arm64-gnu": "4.57.0", - "@rollup/rollup-linux-arm64-musl": "4.57.0", - "@rollup/rollup-linux-loong64-gnu": "4.57.0", - "@rollup/rollup-linux-loong64-musl": "4.57.0", - "@rollup/rollup-linux-ppc64-gnu": "4.57.0", - "@rollup/rollup-linux-ppc64-musl": "4.57.0", - "@rollup/rollup-linux-riscv64-gnu": "4.57.0", - "@rollup/rollup-linux-riscv64-musl": "4.57.0", - "@rollup/rollup-linux-s390x-gnu": "4.57.0", - "@rollup/rollup-linux-x64-gnu": "4.57.0", - "@rollup/rollup-linux-x64-musl": "4.57.0", - "@rollup/rollup-openbsd-x64": "4.57.0", - "@rollup/rollup-openharmony-arm64": "4.57.0", - "@rollup/rollup-win32-arm64-msvc": "4.57.0", - "@rollup/rollup-win32-ia32-msvc": "4.57.0", - "@rollup/rollup-win32-x64-gnu": "4.57.0", - "@rollup/rollup-win32-x64-msvc": "4.57.0", + "@rollup/rollup-android-arm-eabi": "4.60.4", + "@rollup/rollup-android-arm64": "4.60.4", + "@rollup/rollup-darwin-arm64": "4.60.4", + "@rollup/rollup-darwin-x64": "4.60.4", + "@rollup/rollup-freebsd-arm64": "4.60.4", + "@rollup/rollup-freebsd-x64": "4.60.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", + "@rollup/rollup-linux-arm-musleabihf": "4.60.4", + "@rollup/rollup-linux-arm64-gnu": "4.60.4", + "@rollup/rollup-linux-arm64-musl": "4.60.4", + "@rollup/rollup-linux-loong64-gnu": "4.60.4", + "@rollup/rollup-linux-loong64-musl": "4.60.4", + "@rollup/rollup-linux-ppc64-gnu": "4.60.4", + "@rollup/rollup-linux-ppc64-musl": "4.60.4", + "@rollup/rollup-linux-riscv64-gnu": "4.60.4", + "@rollup/rollup-linux-riscv64-musl": "4.60.4", + "@rollup/rollup-linux-s390x-gnu": "4.60.4", + "@rollup/rollup-linux-x64-gnu": "4.60.4", + "@rollup/rollup-linux-x64-musl": "4.60.4", + "@rollup/rollup-openbsd-x64": "4.60.4", + "@rollup/rollup-openharmony-arm64": "4.60.4", + "@rollup/rollup-win32-arm64-msvc": "4.60.4", + "@rollup/rollup-win32-ia32-msvc": "4.60.4", + "@rollup/rollup-win32-x64-gnu": "4.60.4", + "@rollup/rollup-win32-x64-msvc": "4.60.4", "fsevents": "~2.3.2" } }, + "node_modules/rollup/node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, "node_modules/sade": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", @@ -1994,9 +2111,9 @@ } }, "node_modules/set-cookie-parser": { - "version": "2.7.2", - "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", - "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.0.tgz", + "integrity": "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==", "dev": true, "license": "MIT" }, @@ -2040,30 +2157,30 @@ "license": "MIT" }, "node_modules/std-env": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", - "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", "dev": true, "license": "MIT" }, "node_modules/svelte": { - "version": "5.48.5", - "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.48.5.tgz", - "integrity": "sha512-NB3o70OxfmnE5UPyLr8uH3IV02Q43qJVAuWigYmsSOYsS0s/rHxP0TF81blG0onF/xkhNvZw4G8NfzIX+By5ZQ==", + "version": "5.55.9", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.9.tgz", + "integrity": "sha512-fTjjT8cHLDwigcu2j3pv7Jq04LklXevPB8uBgyHNiTXv+RMNvVnrjS4UEYrLMkhuq1vpCodHjiW+z/95SDs/fg==", "license": "MIT", - "peer": true, "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", - "@sveltejs/acorn-typescript": "^1.0.5", + "@sveltejs/acorn-typescript": "^1.0.10", "@types/estree": "^1.0.5", + "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", - "aria-query": "^5.3.1", + "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", - "devalue": "^5.6.2", + "devalue": "^5.8.1", "esm-env": "^1.2.1", - "esrap": "^2.2.1", + "esrap": "^2.2.9", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", @@ -2074,9 +2191,9 @@ } }, "node_modules/svelte-check": { - "version": "4.3.5", - "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.3.5.tgz", - "integrity": "sha512-e4VWZETyXaKGhpkxOXP+B/d0Fp/zKViZoJmneZWe/05Y2aqSKj3YN2nLfYPJBQ87WEiY4BQCQ9hWGu9mPT1a1Q==", + "version": "4.4.8", + "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.4.8.tgz", + "integrity": "sha512-67adfgBox5eNSNIvIIwgFizKGdcRrGpiMoNO2obHcYuLz7iTa8Xgm/NGU3ntMFnNm8K1grFOIG6HhMLX/vcN8w==", "dev": true, "license": "MIT", "dependencies": { @@ -2101,8 +2218,7 @@ "version": "0.182.0", "resolved": "https://registry.npmjs.org/three/-/three-0.182.0.tgz", "integrity": "sha512-GbHabT+Irv+ihI1/f5kIIsZ+Ef9Sl5A1Y7imvS5RQjWgtTPfPnZ43JmlYI7NtCRDK9zir20lQpfg8/9Yd02OvQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/three-instanced-uniforms-mesh": { "version": "0.52.4", @@ -2117,9 +2233,9 @@ } }, "node_modules/three-mesh-bvh": { - "version": "0.9.7", - "resolved": "https://registry.npmjs.org/three-mesh-bvh/-/three-mesh-bvh-0.9.7.tgz", - "integrity": "sha512-EYSJbykeAjhVxwZjuUYq/kelIbqBoV9sbAgvZ+j1xCgZyNYSkr51WDJWS4WIfK2OX6YcjBGoTicX4RoOVQzx0g==", + "version": "0.9.10", + "resolved": "https://registry.npmjs.org/three-mesh-bvh/-/three-mesh-bvh-0.9.10.tgz", + "integrity": "sha512-UOlTgPIeqUURcwaG8knxvBaruwZlC4X3/WSHEFO7rYvMVv/YNUrkfFEszvfj36pXV88dCHoHNnIp0PifkirnTQ==", "license": "MIT", "peerDependencies": { "three": ">= 0.159.0" @@ -2155,9 +2271,9 @@ "license": "MIT" }, "node_modules/tinyexec": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", - "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.2.tgz", + "integrity": "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==", "dev": true, "license": "MIT", "engines": { @@ -2165,14 +2281,14 @@ } }, "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -2182,9 +2298,9 @@ } }, "node_modules/tinyrainbow": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", - "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", "dev": true, "license": "MIT", "engines": { @@ -2246,7 +2362,6 @@ "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -2256,19 +2371,18 @@ } }, "node_modules/undici-types": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", - "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", "dev": true, "license": "MIT" }, "node_modules/vite": { - "version": "6.4.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", - "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", + "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -2338,10 +2452,25 @@ } } }, + "node_modules/vite/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/vitefu": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.1.tgz", - "integrity": "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz", + "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==", "dev": true, "license": "MIT", "workspaces": [ @@ -2350,7 +2479,7 @@ "tests/projects/workspace/packages/*" ], "peerDependencies": { - "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "vite": { @@ -2359,31 +2488,31 @@ } }, "node_modules/vitest": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz", - "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.7.tgz", + "integrity": "sha512-flYyaFd2CgoCoU+0UKt3pxksgC+S02iTDN0n3LtqaMeXsI9SBcdNujc2k0DeFLzUn/0k538yNjOSdwgCqcrwJA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.0.18", - "@vitest/mocker": "4.0.18", - "@vitest/pretty-format": "4.0.18", - "@vitest/runner": "4.0.18", - "@vitest/snapshot": "4.0.18", - "@vitest/spy": "4.0.18", - "@vitest/utils": "4.0.18", - "es-module-lexer": "^1.7.0", - "expect-type": "^1.2.2", + "@vitest/expect": "4.1.7", + "@vitest/mocker": "4.1.7", + "@vitest/pretty-format": "4.1.7", + "@vitest/runner": "4.1.7", + "@vitest/snapshot": "4.1.7", + "@vitest/spy": "4.1.7", + "@vitest/utils": "4.1.7", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", - "std-env": "^3.10.0", + "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", - "tinyrainbow": "^3.0.3", - "vite": "^6.0.0 || ^7.0.0", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "bin": { @@ -2399,12 +2528,15 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.0.18", - "@vitest/browser-preview": "4.0.18", - "@vitest/browser-webdriverio": "4.0.18", - "@vitest/ui": "4.0.18", + "@vitest/browser-playwright": "4.1.7", + "@vitest/browser-preview": "4.1.7", + "@vitest/browser-webdriverio": "4.1.7", + "@vitest/coverage-istanbul": "4.1.7", + "@vitest/coverage-v8": "4.1.7", + "@vitest/ui": "4.1.7", "happy-dom": "*", - "jsdom": "*" + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "@edge-runtime/vm": { @@ -2425,6 +2557,12 @@ "@vitest/browser-webdriverio": { "optional": true }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, "@vitest/ui": { "optional": true }, @@ -2433,6 +2571,9 @@ }, "jsdom": { "optional": true + }, + "vite": { + "optional": false } } }, diff --git a/package.json b/package.json index e2370f9..ff8c1be 100644 --- a/package.json +++ b/package.json @@ -11,29 +11,34 @@ "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "test": "vitest", "test:run": "vitest run", + "test:e2e": "playwright test", "tauri": "tauri", "wasm:build": "wasm-pack build crates/framesmith-runtime-wasm --target web --out-dir ../../src/lib/wasm", "wasm:build:dev": "wasm-pack build crates/framesmith-runtime-wasm --target web --out-dir ../../src/lib/wasm --dev" }, "license": "MIT", "dependencies": { - "@tauri-apps/api": "^2", - "@tauri-apps/plugin-opener": "^2", + "@tauri-apps/api": "2.9.1", + "@tauri-apps/plugin-opener": "2.5.3", "@threlte/core": "^8.3.1", "@threlte/extras": "^9.7.1", "@types/three": "^0.182.0", "three": "^0.182.0" }, "devDependencies": { + "@playwright/test": "^1.60.0", "@sveltejs/adapter-static": "^3.0.6", "@sveltejs/kit": "^2.9.0", "@sveltejs/vite-plugin-svelte": "^5.0.0", - "@tauri-apps/cli": "^2", + "@tauri-apps/cli": "2.9.6", "@types/node": "^25.1.0", "svelte": "^5.0.0", "svelte-check": "^4.0.0", "typescript": "~5.6.2", "vite": "^6.0.3", "vitest": "^4.0.18" + }, + "overrides": { + "cookie": "0.7.2" } } diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..f9cdd15 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,24 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: 'tests/e2e', + fullyParallel: false, + retries: process.env.CI ? 1 : 0, + reporter: process.env.CI ? [['github'], ['html', { open: 'never' }]] : 'list', + use: { + baseURL: 'http://127.0.0.1:14230', + trace: 'retain-on-failure', + }, + webServer: { + command: 'npm run dev -- --host 127.0.0.1 --port 14230 --strictPort', + url: 'http://127.0.0.1:14230', + reuseExistingServer: false, + timeout: 120_000, + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], +}); diff --git a/schemas/rules.schema.json b/schemas/rules.schema.json index e8123ec..ebc8597 100644 --- a/schemas/rules.schema.json +++ b/schemas/rules.schema.json @@ -1,7 +1,7 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", "title": "RulesFile", - "description": "Root structure for a Framesmith rules file.\nRules files define default values (apply) and validation constraints (validate) for moves.", + "description": "Root structure for a Framesmith rules file.\nRules files define:\n- a registry of known resources/events (`registry`)\n- default values (`apply`)\n- validation constraints (`validate`)\n- property schemas for strict validation (`properties`)\n- tag schemas for strict validation (`tags`)", "type": "object", "properties": { "apply": { @@ -12,6 +12,18 @@ "$ref": "#/$defs/ApplyRule" } }, + "properties": { + "description": "Property schema definitions. When present, enables strict validation\nrequiring all properties to be declared in the schema.", + "anyOf": [ + { + "$ref": "#/$defs/PropertySchema" + }, + { + "type": "null" + } + ], + "default": null + }, "registry": { "description": "Optional registry of resources and events used by this project/character.", "anyOf": [ @@ -23,6 +35,17 @@ } ] }, + "tags": { + "description": "Tag schema definitions. When present, enables strict validation\nrequiring all tags to be declared in the schema.", + "type": [ + "array", + "null" + ], + "default": null, + "items": { + "type": "string" + } + }, "validate": { "description": "Rules that enforce constraints on matching moves.", "type": "array", @@ -241,10 +264,68 @@ } } }, + "MoveTypesConfig": { + "description": "Configuration for move types and their filter groupings.", + "type": "object", + "properties": { + "filter_groups": { + "description": "Filter groups mapping group names to lists of types.\nE.g., {\"normals\": [\"normal\", \"command_normal\"], \"specials\": [\"special\", \"super\", \"ex\"]}", + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "string" + } + }, + "default": {} + }, + "types": { + "description": "List of valid move type strings (e.g., \"normal\", \"special\", \"super\", \"ex\").", + "type": "array", + "default": [], + "items": { + "type": "string" + } + } + } + }, + "PropertySchema": { + "description": "Property schema definitions for character and state properties.\nProperty names in this schema become IDs (indices) in the exported FSPK,\neliminating duplicate string storage across states.", + "type": "object", + "properties": { + "character": { + "description": "Character-level property names (e.g., \"health\", \"walkSpeed\", \"dashSpeed\").\nIndex in this array becomes the schema ID for that property.", + "type": "array", + "default": [], + "items": { + "type": "string" + } + }, + "state": { + "description": "State-level property names (e.g., \"startup\", \"active\", \"damage\").\nIndex in this array becomes the schema ID for that property.", + "type": "array", + "default": [], + "items": { + "type": "string" + } + } + } + }, "RulesRegistry": { "description": "Registry of resource IDs and event definitions.", "type": "object", "properties": { + "chain_order": { + "description": "Chain order for deriving chain cancel edges from tags (e.g., [\"L\", \"M\", \"H\"]).", + "type": [ + "array", + "null" + ], + "default": null, + "items": { + "type": "string" + } + }, "events": { "description": "Known event definitions keyed by event ID.", "type": "object", @@ -253,6 +334,18 @@ }, "default": {} }, + "move_types": { + "description": "Move type configuration for filtering and categorization.", + "anyOf": [ + { + "$ref": "#/$defs/MoveTypesConfig" + }, + { + "type": "null" + } + ], + "default": null + }, "resources": { "description": "Known resource IDs (e.g. \"heat\", \"ammo\").", "type": "array", diff --git a/src-tauri/src/bin/framesmith.rs b/src-tauri/src/bin/framesmith.rs index 7f90a84..5e8ceec 100644 --- a/src-tauri/src/bin/framesmith.rs +++ b/src-tauri/src/bin/framesmith.rs @@ -3,8 +3,8 @@ //! Minimal CLI intended for automation (e.g. exporting .fspk packs). //! //! Examples: -//! cargo run --bin framesmith -- export --project .. --character test_char --out ..\\exports\\test_char.fspk -//! cargo run --bin framesmith -- export --project .. --all --out-dir ..\\exports +//! cargo run --bin framesmith-cli -- export --project .. --character test_char --out ..\\exports\\test_char.fspk +//! cargo run --bin framesmith-cli -- export --project .. --all --out-dir ..\\exports use std::env; use std::fs; @@ -14,14 +14,14 @@ use std::process; #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] enum Adapter { #[default] - ZxFspack, + Fspk, JsonBlob, } impl Adapter { fn parse(s: &str) -> Result { match s { - "fspk" => Ok(Self::ZxFspack), + "fspk" | "zx-fspack" => Ok(Self::Fspk), "json-blob" => Ok(Self::JsonBlob), _ => Err(format!("Unknown adapter: {}", s)), } @@ -29,14 +29,14 @@ impl Adapter { fn as_str(self) -> &'static str { match self { - Self::ZxFspack => "fspk", + Self::Fspk => "fspk", Self::JsonBlob => "json-blob", } } fn default_ext(self) -> &'static str { match self { - Self::ZxFspack => ".fspk", + Self::Fspk => ".fspk", Self::JsonBlob => ".json", } } @@ -56,7 +56,7 @@ struct ExportArgs { } fn usage() -> &'static str { - "Framesmith CLI\n\nUSAGE:\n framesmith export [options]\n\nOPTIONS:\n --project Project root (expects /characters)\n --characters-dir Characters directory (overrides --project)\n --character Character ID (folder name under characters dir)\n --all Export all characters\n --out Output file (single-character export)\n --out-dir Output directory (export all)\n --adapter Adapter: fspk (default), json-blob\n --pretty Pretty JSON output (json-blob only)\n --keep-going Continue exporting others after an error (export all only)\n -h, --help Print help\n\nENV:\n FRAMESMITH_CHARACTERS_DIR Default characters directory if not provided\n" + "Framesmith CLI\n\nUSAGE:\n framesmith-cli export [options]\n\nOPTIONS:\n --project Project root (expects /characters)\n --characters-dir Characters directory (overrides --project)\n --character Character ID (folder name under characters dir)\n --all Export all characters\n --out Output file (single-character export)\n --out-dir Output directory (export all)\n --adapter Adapter: fspk (default), json-blob\n --pretty Pretty JSON output (json-blob only)\n --keep-going Continue exporting others after an error (export all only)\n -h, --help Print help\n\nENV:\n FRAMESMITH_CHARACTERS_DIR Default characters directory if not provided\n" } fn main() { @@ -85,7 +85,7 @@ fn real_main() -> Result<(), String> { fn cmd_export(args: &[String]) -> Result<(), String> { let mut cfg = ExportArgs { - adapter: Adapter::ZxFspack, + adapter: Adapter::Fspk, ..Default::default() }; @@ -153,7 +153,7 @@ fn cmd_export(args: &[String]) -> Result<(), String> { return Err("--out-dir cannot be used with --character (use --out)".to_string()); } - if cfg.adapter == Adapter::ZxFspack && cfg.pretty { + if cfg.adapter == Adapter::Fspk && cfg.pretty { return Err("--pretty is only supported for json-blob".to_string()); } diff --git a/src-tauri/src/bin/mcp.rs b/src-tauri/src/bin/mcp.rs index 2e084ac..948982b 100644 --- a/src-tauri/src/bin/mcp.rs +++ b/src-tauri/src/bin/mcp.rs @@ -1,10 +1,13 @@ use anyhow::Result; use clap::Parser; use framesmith_lib::mcp::handlers::FramesmithMcp; -use rmcp::{ServiceExt, transport::stdio}; +use rmcp::{transport::stdio, ServiceExt}; #[derive(Parser, Debug)] -#[command(name = "framesmith-mcp", about = "Framesmith MCP server for character data")] +#[command( + name = "framesmith-mcp", + about = "Framesmith MCP server for character data" +)] struct Args { /// Path to the characters directory (overrides FRAMESMITH_CHARACTERS_DIR env var) #[arg(long, short = 'c')] diff --git a/src-tauri/src/codegen/fspk/builders.rs b/src-tauri/src/codegen/fspk/builders.rs index fe3a4df..f743f19 100644 --- a/src-tauri/src/codegen/fspk/builders.rs +++ b/src-tauri/src/codegen/fspk/builders.rs @@ -105,7 +105,10 @@ mod tests { let mut table = StringTable::new(); let loc1 = table.intern("hello").unwrap(); let loc2 = table.intern("world").unwrap(); - assert_ne!(loc1.0, loc2.0, "Different strings should have different offsets"); + assert_ne!( + loc1.0, loc2.0, + "Different strings should have different offsets" + ); assert_eq!(loc1.0, 0, "First string should start at offset 0"); assert_eq!(loc1.1, 5, "\"hello\" has length 5"); assert_eq!(loc2.0, 5, "Second string should start after first"); diff --git a/src-tauri/src/codegen/fspk/export.rs b/src-tauri/src/codegen/fspk/export.rs index 71968ab..e685cae 100644 --- a/src-tauri/src/codegen/fspk/export.rs +++ b/src-tauri/src/codegen/fspk/export.rs @@ -3,15 +3,14 @@ use std::collections::HashMap; use crate::codegen::fspk_format::{ - write_u16_le, write_u32_le, write_u8, FLAGS_RESERVED, HEADER_SIZE, MAGIC, - SCHEMA_HEADER_SIZE, SECTION_CANCEL_DENIES, SECTION_CANCEL_TAG_RULES, SECTION_CHARACTER_PROPS, - SECTION_EVENT_ARGS, SECTION_EVENT_EMITS, SECTION_HEADER_SIZE, SECTION_HIT_WINDOWS, - SECTION_HURT_WINDOWS, SECTION_KEYFRAMES_KEYS, SECTION_MESH_KEYS, SECTION_MOVE_NOTIFIES, - SECTION_MOVE_RESOURCE_COSTS, SECTION_MOVE_RESOURCE_DELTAS, - SECTION_MOVE_RESOURCE_PRECONDITIONS, SECTION_PUSH_WINDOWS, SECTION_RESOURCE_DEFS, - SECTION_SCHEMA, SECTION_SHAPES, SECTION_STATES, SECTION_STATE_EXTRAS, SECTION_STATE_PROPS, - SECTION_STATE_TAGS, SECTION_STATE_TAG_RANGES, SECTION_STRING_TABLE, STATE_EXTRAS72_SIZE, - STRREF_SIZE, + write_u16_le, write_u32_le, write_u8, FLAGS_RESERVED, HEADER_SIZE, MAGIC, SCHEMA_HEADER_SIZE, + SECTION_CANCEL_DENIES, SECTION_CANCEL_TAG_RULES, SECTION_CHARACTER_PROPS, SECTION_EVENT_ARGS, + SECTION_EVENT_EMITS, SECTION_HEADER_SIZE, SECTION_HIT_WINDOWS, SECTION_HURT_WINDOWS, + SECTION_KEYFRAMES_KEYS, SECTION_MESH_KEYS, SECTION_MOVE_NOTIFIES, SECTION_MOVE_RESOURCE_COSTS, + SECTION_MOVE_RESOURCE_DELTAS, SECTION_MOVE_RESOURCE_PRECONDITIONS, SECTION_PUSH_WINDOWS, + SECTION_RESOURCE_DEFS, SECTION_SCHEMA, SECTION_SHAPES, SECTION_STATES, SECTION_STATE_EXTRAS, + SECTION_STATE_PROPS, SECTION_STATE_TAGS, SECTION_STATE_TAG_RANGES, SECTION_STRING_TABLE, + STATE_EXTRAS72_SIZE, STRREF_SIZE, }; use crate::commands::CharacterData; use crate::rules::MergedRules; @@ -121,21 +120,37 @@ pub fn export_fspk( .map(|x| x.events.as_slice()) .unwrap_or(&[]); - let (on_use_emits_off, on_use_emits_len) = - pack_event_emits(on_use_events, &mut event_emits_data, &mut event_args_data, &mut strings)?; + let (on_use_emits_off, on_use_emits_len) = pack_event_emits( + on_use_events, + &mut event_emits_data, + &mut event_args_data, + &mut strings, + )?; - let (on_hit_emits_off, on_hit_emits_len) = - pack_event_emits(on_hit_events, &mut event_emits_data, &mut event_args_data, &mut strings)?; + let (on_hit_emits_off, on_hit_emits_len) = pack_event_emits( + on_hit_events, + &mut event_emits_data, + &mut event_args_data, + &mut strings, + )?; - let (on_block_emits_off, on_block_emits_len) = - pack_event_emits(on_block_events, &mut event_emits_data, &mut event_args_data, &mut strings)?; + let (on_block_emits_off, on_block_emits_len) = pack_event_emits( + on_block_events, + &mut event_emits_data, + &mut event_args_data, + &mut strings, + )?; // Move notifies let notifies_off = checked_u32(move_notifies_data.len(), "notifies_off")?; let notifies_len = checked_u16(mv.notifies.len(), "notifies_len")?; for notify in &mv.notifies { - let (notify_emits_off, notify_emits_len) = - pack_event_emits(¬ify.events, &mut event_emits_data, &mut event_args_data, &mut strings)?; + let (notify_emits_off, notify_emits_len) = pack_event_emits( + ¬ify.events, + &mut event_emits_data, + &mut event_args_data, + &mut strings, + )?; // MoveNotify12: frame(u16) + pad(u16) + emits_off(u32) + emits_len(u16) + pad(u16) write_u16_le(&mut move_notifies_data, notify.frame); @@ -201,6 +216,26 @@ pub fn export_fspk( .ok_or_else(|| "move resource deltas count overflows u16".to_string())?; } } + if mv.meter_gain.whiff != 0 { + let already_declared_meter_gain = mv + .on_use + .as_ref() + .map(|on_use| on_use.resource_deltas.iter().any(|d| d.name == "meter")) + .unwrap_or(false); + if !already_declared_meter_gain { + let rname = strings.intern("meter")?; + write_strref(&mut move_resource_deltas_data, rname); + write_i32_le(&mut move_resource_deltas_data, mv.meter_gain.whiff as i32); + write_u8( + &mut move_resource_deltas_data, + RESOURCE_DELTA_TRIGGER_ON_USE, + ); + move_resource_deltas_data.extend_from_slice(&[0, 0, 0]); + deltas_len = deltas_len + .checked_add(1) + .ok_or_else(|| "move resource deltas count overflows u16".to_string())?; + } + } if let Some(on_hit) = &mv.on_hit { for d in &on_hit.resource_deltas { let rname = strings.intern(&d.name)?; @@ -216,6 +251,26 @@ pub fn export_fspk( .ok_or_else(|| "move resource deltas count overflows u16".to_string())?; } } + if mv.meter_gain.hit != 0 { + let already_declared_meter_gain = mv + .on_hit + .as_ref() + .map(|on_hit| on_hit.resource_deltas.iter().any(|d| d.name == "meter")) + .unwrap_or(false); + if !already_declared_meter_gain { + let rname = strings.intern("meter")?; + write_strref(&mut move_resource_deltas_data, rname); + write_i32_le(&mut move_resource_deltas_data, mv.meter_gain.hit as i32); + write_u8( + &mut move_resource_deltas_data, + RESOURCE_DELTA_TRIGGER_ON_HIT, + ); + move_resource_deltas_data.extend_from_slice(&[0, 0, 0]); + deltas_len = deltas_len + .checked_add(1) + .ok_or_else(|| "move resource deltas count overflows u16".to_string())?; + } + } if let Some(on_block) = &mv.on_block { for d in &on_block.resource_deltas { let rname = strings.intern(&d.name)?; @@ -478,7 +533,9 @@ pub fn export_fspk( }; return Err(format!( "Tag '{}' for state '{}' is not defined in the tag schema{}", - tag.as_str(), mv.input, suggestion_text + tag.as_str(), + mv.input, + suggestion_text )); } } @@ -806,7 +863,10 @@ mod tests { use std::collections::BTreeMap; let mut properties = BTreeMap::new(); - properties.insert("archetype".to_string(), PropertyValue::String("rushdown".to_string())); + properties.insert( + "archetype".to_string(), + PropertyValue::String("rushdown".to_string()), + ); properties.insert("health".to_string(), PropertyValue::Number(1000.0)); properties.insert("walk_speed".to_string(), PropertyValue::Number(3.5)); properties.insert("back_walk_speed".to_string(), PropertyValue::Number(2.5)); @@ -953,9 +1013,9 @@ mod tests { "Total length should match actual output size" ); - // Verify section count: 8 base + STATE_EXTRAS + CHARACTER_PROPS = 10 + // Verify section count: 8 base + STATE_EXTRAS + MOVE_RESOURCE_DELTAS + CHARACTER_PROPS = 11 let section_count = u32::from_le_bytes([bytes[12], bytes[13], bytes[14], bytes[15]]); - assert_eq!(section_count, 10, "Section count should be 10"); + assert_eq!(section_count, 11, "Section count should be 11"); } #[test] @@ -968,10 +1028,7 @@ mod tests { }; let result = export_fspk(&char_data, None); - assert!( - result.is_ok(), - "export_fspk should succeed with no moves" - ); + assert!(result.is_ok(), "export_fspk should succeed with no moves"); let bytes = result.unwrap(); @@ -1047,10 +1104,10 @@ mod tests { } // MOVE_EXTRAS and CHARACTER_PROPS are expected when there are moves. - // 8 base + STATE_EXTRAS + CHARACTER_PROPS = 10 + // 8 base + STATE_EXTRAS + MOVE_RESOURCE_DELTAS + CHARACTER_PROPS = 11 assert_eq!( - section_count, 10, - "Expected STATE_EXTRAS and CHARACTER_PROPS sections to be present" + section_count, 11, + "Expected STATE_EXTRAS, MOVE_RESOURCE_DELTAS, and CHARACTER_PROPS sections to be present" ); let extras_kind_off = HEADER_SIZE + 8 * SECTION_HEADER_SIZE; let extras_kind = u32::from_le_bytes([ @@ -1207,8 +1264,8 @@ mod tests { // Parse with framesmith_fspack reader let pack = framesmith_fspack::PackView::parse(&bytes).expect("parse should succeed"); - // 8 base + STATE_EXTRAS + CHARACTER_PROPS = 10 sections - assert_eq!(pack.section_count(), 10); + // 8 base + STATE_EXTRAS + MOVE_RESOURCE_DELTAS + CHARACTER_PROPS = 11 sections + assert_eq!(pack.section_count(), 11); // Verify move count matches let moves = pack.states().expect("should have MOVES section"); @@ -1482,7 +1539,10 @@ mod tests { }; let result = export_fspk(&char_data, Some(&rules)); - assert!(result.is_err(), "export should fail with missing properties"); + assert!( + result.is_err(), + "export should fail with missing properties" + ); let err = result.unwrap_err(); assert!( err.contains("not defined in the schema"), diff --git a/src-tauri/src/codegen/fspk/moves.rs b/src-tauri/src/codegen/fspk/moves.rs index 7f648db..92bd448 100644 --- a/src-tauri/src/codegen/fspk/moves.rs +++ b/src-tauri/src/codegen/fspk/moves.rs @@ -6,7 +6,10 @@ use crate::codegen::fspk_format::KEY_NONE; use crate::commands::CharacterData; use crate::schema::State; -use super::packing::{guard_type_to_u8, pack_hit_window, pack_hurt_window, pack_move_record, pack_shape}; +use super::packing::{ + guard_type_to_u8, pack_hit_window, pack_hurt_window, pack_move_record, pack_shape, + HitWindowPackParams, +}; use super::types::{CancelLookup, PackedMoveData, StrRef, StringTable}; use super::utils::{checked_u16, checked_u32}; @@ -57,12 +60,16 @@ pub fn pack_moves( packed.shapes.extend_from_slice(&pack_shape(&hb.r#box)); packed.hit_windows.extend_from_slice(&pack_hit_window( hb, - shape_off, - mv.damage, - mv.hitstun, - mv.blockstun, - mv.hitstop, - guard_type_to_u8(&mv.guard), + HitWindowPackParams { + shapes_off: shape_off, + damage: mv.damage, + hitstun: mv.hitstun, + blockstun: mv.blockstun, + hitstop: mv.hitstop, + guard: guard_type_to_u8(&mv.guard), + hit_pushback: mv.pushback.hit, + block_pushback: mv.pushback.block, + }, )); } @@ -70,14 +77,18 @@ pub fn pack_moves( for hb in &mv.hurtboxes { let shape_off = checked_u32(packed.shapes.len(), "shape_off")?; packed.shapes.extend_from_slice(&pack_shape(&hb.r#box)); - packed.hurt_windows.extend_from_slice(&pack_hurt_window(hb, shape_off)); + packed + .hurt_windows + .extend_from_slice(&pack_hurt_window(hb, shape_off)); } // Pack pushboxes -> shapes + push_windows (same 12-byte format as hurt windows) for pb in &mv.pushboxes { let shape_off = checked_u32(packed.shapes.len(), "shape_off")?; packed.shapes.extend_from_slice(&pack_shape(&pb.r#box)); - packed.push_windows.extend_from_slice(&pack_hurt_window(pb, shape_off)); + packed + .push_windows + .extend_from_slice(&pack_hurt_window(pb, shape_off)); } // Calculate lengths @@ -246,7 +257,11 @@ mod tests { let mut strings = StringTable::new(); let (mesh_keys, kf_keys) = build_asset_keys(&char_data, &mut strings).unwrap(); - assert_eq!(mesh_keys.len(), 2, "Duplicate animations should be deduplicated"); + assert_eq!( + mesh_keys.len(), + 2, + "Duplicate animations should be deduplicated" + ); assert_eq!(kf_keys.len(), 2); } diff --git a/src-tauri/src/codegen/fspk/packing.rs b/src-tauri/src/codegen/fspk/packing.rs index c3d2f23..9fdc6f5 100644 --- a/src-tauri/src/codegen/fspk/packing.rs +++ b/src-tauri/src/codegen/fspk/packing.rs @@ -45,9 +45,21 @@ pub fn guard_type_to_u8(guard: &GuardType) -> u8 { } } -/// Pack a FrameHitbox into a HitWindow24 structure. +/// Values that are packed alongside a hitbox into a HitWindow28 record. +pub struct HitWindowPackParams { + pub shapes_off: u32, + pub damage: u16, + pub hitstun: u8, + pub blockstun: u8, + pub hitstop: u8, + pub guard: u8, + pub hit_pushback: i32, + pub block_pushback: i32, +} + +/// Pack a FrameHitbox into a HitWindow28 structure. /// -/// HitWindow24 layout (24 bytes) - must match view.rs HitWindowView: +/// HitWindow28 layout (28 bytes) - must match view.rs HitWindowView: /// - 0: start_frame (u8) /// - 1: end_frame (u8) /// - 2: guard (u8) @@ -62,30 +74,26 @@ pub fn guard_type_to_u8(guard: &GuardType) -> u8 { /// - 16-17: shapes_len (u16 LE) /// - 18-21: cancels_off (u32 LE) /// - 22-23: cancels_len (u16 LE) -pub fn pack_hit_window( - hb: &FrameHitbox, - shapes_off: u32, - damage: u16, - hitstun: u8, - blockstun: u8, - hitstop: u8, - guard: u8, -) -> [u8; HIT_WINDOW24_SIZE] { +/// - 24-25: hit_pushback (i16 Q12.4 LE) +/// - 26-27: block_pushback (i16 Q12.4 LE) +pub fn pack_hit_window(hb: &FrameHitbox, params: HitWindowPackParams) -> [u8; HIT_WINDOW24_SIZE] { let mut buf = [0u8; HIT_WINDOW24_SIZE]; buf[0] = hb.frames.0; // start_frame buf[1] = hb.frames.1; // end_frame - buf[2] = guard; // guard + buf[2] = params.guard; // guard buf[3] = 0; // reserved - buf[4..6].copy_from_slice(&damage.to_le_bytes()); // damage + buf[4..6].copy_from_slice(¶ms.damage.to_le_bytes()); // damage buf[6..8].copy_from_slice(&0u16.to_le_bytes()); // chip_damage (TODO: add to schema) - buf[8] = hitstun; // hitstun - buf[9] = blockstun; // blockstun - buf[10] = hitstop; // hitstop + buf[8] = params.hitstun; // hitstun + buf[9] = params.blockstun; // blockstun + buf[10] = params.hitstop; // hitstop buf[11] = 0; // reserved - buf[12..16].copy_from_slice(&shapes_off.to_le_bytes()); // shapes_off + buf[12..16].copy_from_slice(¶ms.shapes_off.to_le_bytes()); // shapes_off buf[16..18].copy_from_slice(&1u16.to_le_bytes()); // shapes_len = 1 - // bytes 18-27 are cancels/pushback (already zeroed, not used in v1) + // bytes 18-23 are cancel refs (zeroed; explicit chains moved to tag rules) + buf[24..26].copy_from_slice(&to_q12_4(params.hit_pushback as f32).to_le_bytes()); + buf[26..28].copy_from_slice(&to_q12_4(params.block_pushback as f32).to_le_bytes()); buf } @@ -106,7 +114,7 @@ pub fn pack_hurt_window(hb: &FrameHitbox, shapes_off: u32) -> [u8; HURT_WINDOW12 buf[2..4].copy_from_slice(&0u16.to_le_bytes()); // flags = 0 for v1 buf[4..8].copy_from_slice(&shapes_off.to_le_bytes()); // shapes_off buf[8..10].copy_from_slice(&1u16.to_le_bytes()); // shapes_len = 1 - // bytes 10-11 are padding (already zeroed) + // bytes 10-11 are padding (already zeroed) buf } @@ -218,7 +226,12 @@ mod tests { use crate::codegen::fspk_format::SHAPE_KIND_AABB; fn make_test_rect() -> Rect { - Rect { x: 10, y: 20, w: 50, h: 60 } + Rect { + x: 10, + y: 20, + w: 50, + h: 60, + } } fn make_test_hitbox() -> FrameHitbox { @@ -257,12 +270,26 @@ mod tests { #[test] fn test_pack_hit_window() { let hb = make_test_hitbox(); - let hw = pack_hit_window(&hb, 100, 500, 12, 8, 10, 1); + let hw = pack_hit_window( + &hb, + HitWindowPackParams { + shapes_off: 100, + damage: 500, + hitstun: 12, + blockstun: 8, + hitstop: 10, + guard: 1, + hit_pushback: 4, + block_pushback: 6, + }, + ); assert_eq!(hw.len(), HIT_WINDOW24_SIZE); assert_eq!(hw[0], 5); // frame_start assert_eq!(hw[1], 8); // frame_end assert_eq!(hw[2], 1); // guard (mid) + assert_eq!(i16::from_le_bytes([hw[24], hw[25]]), 64); + assert_eq!(i16::from_le_bytes([hw[26], hw[27]]), 96); } #[test] @@ -297,7 +324,12 @@ mod tests { #[test] fn test_negative_coordinates() { - let rect = Rect { x: -50, y: -100, w: 30, h: 40 }; + let rect = Rect { + x: -50, + y: -100, + w: 30, + h: 40, + }; let shape = pack_shape(&rect); let x = i16::from_le_bytes([shape[2], shape[3]]); @@ -309,11 +341,16 @@ mod tests { #[test] fn pack_hurt_window_matches_view_format() { - use framesmith_fspack::bytes::{read_u8, read_u16_le, read_u32_le}; + use framesmith_fspack::bytes::{read_u16_le, read_u32_le, read_u8}; let hb = FrameHitbox { frames: (3, 7), - r#box: Rect { x: 0, y: 0, w: 10, h: 20 }, + r#box: Rect { + x: 0, + y: 0, + w: 10, + h: 20, + }, }; let shapes_off: u32 = 0x1234_5678; @@ -330,5 +367,4 @@ mod tests { assert_eq!(read_u32_le(&buf, 4), Some(0x1234_5678), "shapes_off"); assert_eq!(read_u16_le(&buf, 8), Some(1), "shapes_len should be 1"); } - } diff --git a/src-tauri/src/codegen/fspk/properties.rs b/src-tauri/src/codegen/fspk/properties.rs index 1ab41cf..a6d5bbb 100644 --- a/src-tauri/src/codegen/fspk/properties.rs +++ b/src-tauri/src/codegen/fspk/properties.rs @@ -21,9 +21,7 @@ use super::utils::checked_u16; /// /// - `{"movement": {"distance": 80}}` becomes `{"movement.distance": 80}` /// - `{"effects": [1, 2]}` becomes `{"effects.0": 1, "effects.1": 2}` -fn flatten_properties( - props: &BTreeMap, -) -> BTreeMap { +fn flatten_properties(props: &BTreeMap) -> BTreeMap { let mut flat = BTreeMap::new(); flatten_into("", props, &mut flat); flat @@ -361,14 +359,20 @@ mod tests { fn flatten_nested_object() { let mut movement = BTreeMap::new(); movement.insert("distance".to_string(), PropertyValue::Number(80.0)); - movement.insert("direction".to_string(), PropertyValue::String("forward".to_string())); + movement.insert( + "direction".to_string(), + PropertyValue::String("forward".to_string()), + ); let mut props = BTreeMap::new(); props.insert("movement".to_string(), PropertyValue::Object(movement)); let flat = flatten_properties(&props); assert_eq!(flat.len(), 2); - assert_eq!(flat.get("movement.distance"), Some(&PropertyValue::Number(80.0))); + assert_eq!( + flat.get("movement.distance"), + Some(&PropertyValue::Number(80.0)) + ); assert_eq!( flat.get("movement.direction"), Some(&PropertyValue::String("forward".to_string())) @@ -408,7 +412,10 @@ mod tests { let flat = flatten_properties(&props); assert_eq!(flat.len(), 1); - assert_eq!(flat.get("outer.inner.value"), Some(&PropertyValue::Number(42.0))); + assert_eq!( + flat.get("outer.inner.value"), + Some(&PropertyValue::Number(42.0)) + ); } #[test] @@ -417,7 +424,10 @@ mod tests { let mut props = BTreeMap::new(); props.insert("health".to_string(), PropertyValue::Number(10000.0)); - props.insert("archetype".to_string(), PropertyValue::String("rushdown".to_string())); + props.insert( + "archetype".to_string(), + PropertyValue::String("rushdown".to_string()), + ); let char = Character { id: "test".to_string(), @@ -509,7 +519,9 @@ mod tests { let schema_names = vec!["health", "walkSpeed"]; let mut strings = StringTable::new(); - let data = pack_character_props_with_schema(&char, &schema_lookup, &schema_names, &mut strings).unwrap(); + let data = + pack_character_props_with_schema(&char, &schema_lookup, &schema_names, &mut strings) + .unwrap(); // 2 properties * 8 bytes = 16 bytes assert_eq!(data.len(), 16); @@ -534,7 +546,8 @@ mod tests { let schema_names = vec!["health"]; let mut strings = StringTable::new(); - let result = pack_character_props_with_schema(&char, &schema_lookup, &schema_names, &mut strings); + let result = + pack_character_props_with_schema(&char, &schema_lookup, &schema_names, &mut strings); assert!(result.is_err()); let err = result.unwrap_err(); @@ -556,7 +569,9 @@ mod tests { let schema_names = vec!["startup", "damage"]; let mut strings = StringTable::new(); - let (data, count) = pack_state_props_with_schema(&props, &schema_lookup, &schema_names, &mut strings).unwrap(); + let (data, count) = + pack_state_props_with_schema(&props, &schema_lookup, &schema_names, &mut strings) + .unwrap(); // 2 properties * 8 bytes = 16 bytes assert_eq!(count, 2); @@ -581,7 +596,9 @@ mod tests { let schema_names = vec!["startup", "movement.distance"]; let mut strings = StringTable::new(); - let (data, count) = pack_state_props_with_schema(&props, &schema_lookup, &schema_names, &mut strings).unwrap(); + let (data, count) = + pack_state_props_with_schema(&props, &schema_lookup, &schema_names, &mut strings) + .unwrap(); // 2 flattened properties * 8 bytes = 16 bytes assert_eq!(count, 2); @@ -600,7 +617,8 @@ mod tests { let schema_names = vec!["startup"]; let mut strings = StringTable::new(); - let result = pack_state_props_with_schema(&props, &schema_lookup, &schema_names, &mut strings); + let result = + pack_state_props_with_schema(&props, &schema_lookup, &schema_names, &mut strings); assert!(result.is_err()); let err = result.unwrap_err(); diff --git a/src-tauri/src/codegen/fspk/sections.rs b/src-tauri/src/codegen/fspk/sections.rs index 8dc8dad..6d2667b 100644 --- a/src-tauri/src/codegen/fspk/sections.rs +++ b/src-tauri/src/codegen/fspk/sections.rs @@ -4,7 +4,9 @@ use crate::codegen::fspk_format::{write_u16_le, write_u32_le, write_u8}; use crate::schema::{EventArgValue, EventEmit}; use super::types::StringTable; -use super::utils::{checked_u16, checked_u32, write_i64_le, write_range, write_strref, write_u64_le}; +use super::utils::{ + checked_u16, checked_u32, write_i64_le, write_range, write_strref, write_u64_le, +}; // Event argument type tags pub const EVENT_ARG_TAG_BOOL: u8 = 0; diff --git a/src-tauri/src/codegen/fspk/types.rs b/src-tauri/src/codegen/fspk/types.rs index 4b75502..3697cd3 100644 --- a/src-tauri/src/codegen/fspk/types.rs +++ b/src-tauri/src/codegen/fspk/types.rs @@ -19,7 +19,7 @@ pub struct PackedMoveData { pub moves: Vec, /// SHAPES section: array of Shape12 (12 bytes each) pub shapes: Vec, - /// HIT_WINDOWS section: array of HitWindow24 (24 bytes each) + /// HIT_WINDOWS section: array of HitWindow28 (28 bytes each) pub hit_windows: Vec, /// HURT_WINDOWS section: array of HurtWindow12 (12 bytes each) pub hurt_windows: Vec, diff --git a/src-tauri/src/codegen/fspk/utils.rs b/src-tauri/src/codegen/fspk/utils.rs index 1642d64..e178232 100644 --- a/src-tauri/src/codegen/fspk/utils.rs +++ b/src-tauri/src/codegen/fspk/utils.rs @@ -62,7 +62,13 @@ pub fn write_u64_le(buf: &mut Vec, value: u64) { /// Write a section header to the buffer. /// /// Section header layout: kind(u32) + offset(u32) + length(u32) + alignment(u32) -pub fn write_section_header(buf: &mut Vec, kind: u32, offset: u32, length: u32, alignment: u32) { +pub fn write_section_header( + buf: &mut Vec, + kind: u32, + offset: u32, + length: u32, + alignment: u32, +) { write_u32_le(buf, kind); write_u32_le(buf, offset); write_u32_le(buf, length); diff --git a/src-tauri/src/codegen/fspk_format.rs b/src-tauri/src/codegen/fspk_format.rs index 2822213..47e2be7 100644 --- a/src-tauri/src/codegen/fspk_format.rs +++ b/src-tauri/src/codegen/fspk_format.rs @@ -55,7 +55,7 @@ pub const SECTION_KEYFRAMES_KEYS: u32 = 3; /// Array of StateRecord structs pub const SECTION_STATES: u32 = 4; -/// Array of HitWindow24 structs +/// Array of HitWindow28 structs pub const SECTION_HIT_WINDOWS: u32 = 5; /// Array of HurtWindow12 structs @@ -215,8 +215,8 @@ pub const STRREF_SIZE: usize = 8; /// Shape encoding size: kind(1) + flags(1) + a(2) + b(2) + c(2) + d(2) + e(2) pub const SHAPE12_SIZE: usize = 12; -/// Hit window size (see HitWindow24 struct in module docs) -pub const HIT_WINDOW24_SIZE: usize = 24; +/// Hit window size (see HitWindow28 struct in module docs) +pub const HIT_WINDOW24_SIZE: usize = 28; /// Hurt window size (see HurtWindow12 struct in module docs) pub const HURT_WINDOW12_SIZE: usize = 12; @@ -385,7 +385,7 @@ mod tests { ); assert_eq!(STRREF_SIZE, 8, "StrRef size must be 8 bytes"); assert_eq!(SHAPE12_SIZE, 12, "Shape12 size must be 12 bytes"); - assert_eq!(HIT_WINDOW24_SIZE, 24, "HitWindow24 size must be 24 bytes"); + assert_eq!(HIT_WINDOW24_SIZE, 28, "HitWindow28 size must be 28 bytes"); assert_eq!(HURT_WINDOW12_SIZE, 12, "HurtWindow12 size must be 12 bytes"); assert_eq!(STATE_RECORD_SIZE, 36, "StateRecord size must be 36 bytes"); } diff --git a/src-tauri/src/codegen/json_blob.rs b/src-tauri/src/codegen/json_blob.rs index 18f1600..4b361de 100644 --- a/src-tauri/src/codegen/json_blob.rs +++ b/src-tauri/src/codegen/json_blob.rs @@ -19,7 +19,10 @@ mod tests { /// Create a minimal test character. fn make_test_character(id: &str) -> Character { let mut properties = BTreeMap::new(); - properties.insert("archetype".to_string(), PropertyValue::String("rushdown".to_string())); + properties.insert( + "archetype".to_string(), + PropertyValue::String("rushdown".to_string()), + ); properties.insert("health".to_string(), PropertyValue::Number(1000.0)); properties.insert("walk_speed".to_string(), PropertyValue::Number(4.0)); properties.insert("back_walk_speed".to_string(), PropertyValue::Number(3.0)); diff --git a/src-tauri/src/commands/character.rs b/src-tauri/src/commands/character.rs index 3b332d9..0a7faaf 100644 --- a/src-tauri/src/commands/character.rs +++ b/src-tauri/src/commands/character.rs @@ -115,8 +115,9 @@ pub(super) fn load_character_files( e ) })?; - let mv: State = serde_json::from_str(&content) - .map_err(|e| format!("Invalid state file {:?}: {}", state_path.file_name(), e))?; + let mv: State = serde_json::from_str(&content).map_err(|e| { + format!("Invalid state file {:?}: {}", state_path.file_name(), e) + })?; moves.push((state_name, mv)); } } @@ -292,6 +293,15 @@ pub fn save_move(characters_dir: String, character_id: String, mv: State) -> Res return Err("Invalid move input".to_string()); } + if let Some(id) = mv.id.as_deref() { + if id != mv.input { + return Err( + "Resolved variant states are read-only via save_move; edit the overlay file directly until overlay-aware editing is implemented." + .to_string(), + ); + } + } + let char_path = Path::new(&characters_dir).join(&character_id); // Load rules for registry-aware validation. @@ -321,7 +331,8 @@ pub fn save_move(characters_dir: String, character_id: String, mv: State) -> Res .map_err(|e| format!("Invalid {}: {}", char_file.display(), e))?; let registry = crate::rules::merged_registry(project_rules.as_ref(), character_rules.as_ref()); - let mut issues = crate::rules::validate_character_resources_with_registry(&character, ®istry); + let mut issues = + crate::rules::validate_character_resources_with_registry(&character, ®istry); for issue in issues.iter_mut() { issue.field = format!("character.{}", issue.field); } @@ -344,9 +355,7 @@ pub fn save_move(characters_dir: String, character_id: String, mv: State) -> Res return Err(format!("Validation errors: {}", errors.join("; "))); } - let state_path = char_path - .join("states") - .join(format!("{}.json", mv.input)); + let state_path = char_path.join("states").join(format!("{}.json", mv.input)); let content = serde_json::to_string_pretty(&mv) .map_err(|e| format!("Failed to serialize move: {}", e))?; @@ -402,7 +411,7 @@ pub fn create_character( fs::create_dir_all(char_path.join("states")) .map_err(|e| format!("Failed to create states directory: {}", e))?; - // Create cancel_table.json with empty chains + // Create cancel_table.json with empty tag rules. let cancel_table = CancelTable::default(); let cancel_json = serde_json::to_string_pretty(&cancel_table) @@ -488,8 +497,7 @@ pub fn delete_character(characters_dir: String, character_id: String) -> Result< } // Delete the character directory recursively - fs::remove_dir_all(&char_path) - .map_err(|e| format!("Failed to delete character: {}", e))?; + fs::remove_dir_all(&char_path).map_err(|e| format!("Failed to delete character: {}", e))?; Ok(()) } @@ -514,9 +522,7 @@ pub fn validate_move_input(input: &str) -> Result<(), String> { || c == '_' || c == '-' }) { - return Err( - "Move input can only contain letters, numbers, +, [], ., -, and _".to_string(), - ); + return Err("Move input can only contain letters, numbers, +, [], ., -, and _".to_string()); } Ok(()) @@ -571,7 +577,10 @@ pub fn create_move( hitboxes: vec![], hurtboxes: vec![], pushback: crate::schema::Pushback { hit: 5, block: 8 }, - meter_gain: crate::schema::MeterGain { hit: 100, whiff: 20 }, + meter_gain: crate::schema::MeterGain { + hit: 100, + whiff: 20, + }, animation: input.clone(), move_type: None, trigger: None, @@ -605,10 +614,7 @@ pub fn create_move( /// /// Returns the FSPK data as base64-encoded string. #[tauri::command] -pub fn get_character_fspk( - characters_dir: String, - character_id: String, -) -> Result { +pub fn get_character_fspk(characters_dir: String, character_id: String) -> Result { let (char_path, character, named_moves, cancel_table) = load_character_files(&characters_dir, &character_id)?; diff --git a/src-tauri/src/commands/export.rs b/src-tauri/src/commands/export.rs index b0224a3..4ee3615 100644 --- a/src-tauri/src/commands/export.rs +++ b/src-tauri/src/commands/export.rs @@ -7,6 +7,44 @@ use super::character::{ load_character_files, project_rules_path, resolve_and_merge_globals, CharacterData, }; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ExportAdapter { + Fspk, + JsonBlob, +} + +impl ExportAdapter { + pub fn parse(adapter: &str) -> Result { + match adapter { + "fspk" | "zx-fspack" => Ok(Self::Fspk), + "json-blob" => Ok(Self::JsonBlob), + _ => Err(format!("Unknown adapter: {}", adapter)), + } + } + + pub fn as_str(self) -> &'static str { + match self { + Self::Fspk => "fspk", + Self::JsonBlob => "json-blob", + } + } + + pub fn default_ext(self) -> &'static str { + match self { + Self::Fspk => ".fspk", + Self::JsonBlob => ".json", + } + } +} + +pub fn normalize_export_adapter(adapter: &str) -> Result<&'static str, String> { + Ok(ExportAdapter::parse(adapter)?.as_str()) +} + +pub fn export_adapter_default_ext(adapter: &str) -> Result<&'static str, String> { + Ok(ExportAdapter::parse(adapter)?.default_ext()) +} + #[tauri::command] pub fn export_character( characters_dir: String, @@ -15,6 +53,8 @@ pub fn export_character( output_path: String, pretty: bool, ) -> Result<(), String> { + let adapter = ExportAdapter::parse(&adapter)?; + let (char_path, character, named_moves, cancel_table) = load_character_files(&characters_dir, &character_id)?; @@ -42,7 +82,8 @@ pub fn export_character( let mut error_messages = Vec::new(); let registry = crate::rules::merged_registry(project_rules.as_ref(), character_rules.as_ref()); - let char_issues = crate::rules::validate_character_resources_with_registry(&character, ®istry); + let char_issues = + crate::rules::validate_character_resources_with_registry(&character, ®istry); error_messages.extend( char_issues .into_iter() @@ -85,15 +126,15 @@ pub fn export_character( cancel_table, }; - let output = match adapter.as_str() { - "json-blob" => { + let output = match adapter { + ExportAdapter::JsonBlob => { if pretty { export_json_blob_pretty(&char_data)? } else { export_json_blob(&char_data)? } } - "fspk" => { + ExportAdapter::Fspk => { let merged_rules = crate::rules::MergedRules::merge(project_rules.as_ref(), character_rules.as_ref()); let bytes = export_fspk(&char_data, Some(&merged_rules))?; @@ -101,13 +142,40 @@ pub fn export_character( .map_err(|e| format!("Failed to write export file: {}", e))?; return Ok(()); } - _ => return Err(format!("Unknown adapter: {}", adapter)), }; fs::write(&output_path, output).map_err(|e| format!("Failed to write export file: {}", e))?; Ok(()) } +#[cfg(test)] +mod export_adapter_tests { + use super::*; + + #[test] + fn fspk_is_the_canonical_binary_adapter_name() { + assert_eq!(normalize_export_adapter("fspk").unwrap(), "fspk"); + assert_eq!(export_adapter_default_ext("fspk").unwrap(), ".fspk"); + } + + #[test] + fn zx_fspack_is_accepted_as_a_legacy_alias() { + assert_eq!(normalize_export_adapter("zx-fspack").unwrap(), "fspk"); + assert_eq!(export_adapter_default_ext("zx-fspack").unwrap(), ".fspk"); + } + + #[test] + fn json_blob_adapter_keeps_json_extension() { + assert_eq!(normalize_export_adapter("json-blob").unwrap(), "json-blob"); + assert_eq!(export_adapter_default_ext("json-blob").unwrap(), ".json"); + } + + #[test] + fn unknown_adapter_is_rejected() { + assert!(normalize_export_adapter("rust").is_err()); + } +} + #[derive(Debug, Clone, serde::Serialize)] pub struct GlobalStateSummary { pub id: String, diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index f8d625b..06ae1f2 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -11,8 +11,8 @@ pub use character::{ }; pub use export::{ - delete_global_state, export_character, get_global_state, list_global_states, - save_global_state, GlobalStateSummary, + delete_global_state, export_adapter_default_ext, export_character, get_global_state, + list_global_states, normalize_export_adapter, save_global_state, GlobalStateSummary, }; pub use project::{ @@ -82,7 +82,9 @@ mod tests { let temp_dir = TempDir::new().unwrap(); let characters_dir = setup_test_character(&temp_dir); - let file_path = Path::new(&characters_dir).join("test-char").join("hello.bin"); + let file_path = Path::new(&characters_dir) + .join("test-char") + .join("hello.bin"); fs::write(&file_path, b"hello").unwrap(); let b64 = read_character_asset_base64( @@ -205,7 +207,8 @@ mod tests { "test-char".to_string(), "5L".to_string(), "Light Punch".to_string(), - ).unwrap(); + ) + .unwrap(); // Try to create duplicate let result = create_move( diff --git a/src-tauri/src/commands/project.rs b/src-tauri/src/commands/project.rs index 049af4c..653bf56 100644 --- a/src-tauri/src/commands/project.rs +++ b/src-tauri/src/commands/project.rs @@ -206,9 +206,9 @@ pub async fn open_training_window( app: tauri::AppHandle, character_id: String, ) -> Result<(), String> { + use tauri::Manager; use tauri::WebviewUrl; use tauri::WebviewWindowBuilder; - use tauri::Manager; const WINDOW_LABEL: &str = "training-detached"; @@ -224,17 +224,13 @@ pub async fn open_training_window( // Build the URL with query params for the training route let url = format!("/training?character={}&detached=true", character_id); - let window = WebviewWindowBuilder::new( - &app, - WINDOW_LABEL, - WebviewUrl::App(url.into()), - ) - .title("Framesmith - Training Mode") - .inner_size(1024.0, 768.0) - .min_inner_size(800.0, 600.0) - .resizable(true) - .build() - .map_err(|e| format!("Failed to create training window: {}", e))?; + let window = WebviewWindowBuilder::new(&app, WINDOW_LABEL, WebviewUrl::App(url.into())) + .title("Framesmith - Training Mode") + .inner_size(1024.0, 768.0) + .min_inner_size(800.0, 600.0) + .resizable(true) + .build() + .map_err(|e| format!("Failed to create training window: {}", e))?; // Focus the new window window diff --git a/src-tauri/src/globals/mod.rs b/src-tauri/src/globals/mod.rs index eb38167..29213c6 100644 --- a/src-tauri/src/globals/mod.rs +++ b/src-tauri/src/globals/mod.rs @@ -26,7 +26,11 @@ impl std::fmt::Display for GlobalsError { write!(f, "Global state '{}' not found in globals/states/", state) } GlobalsError::AliasConflict { alias } => { - write!(f, "Global alias '{}' conflicts with local state file", alias) + write!( + f, + "Global alias '{}' conflicts with local state file", + alias + ) } GlobalsError::DuplicateAlias { alias } => { write!(f, "Duplicate global alias '{}' in globals.json", alias) @@ -46,7 +50,9 @@ impl std::error::Error for GlobalsError {} /// Load the globals manifest for a character /// /// Returns None if globals.json doesn't exist (globals are optional) -pub fn load_globals_manifest(character_dir: &Path) -> Result, GlobalsError> { +pub fn load_globals_manifest( + character_dir: &Path, +) -> Result, GlobalsError> { let manifest_path = character_dir.join("globals.json"); if !manifest_path.exists() { return Ok(None); @@ -68,7 +74,10 @@ pub fn load_globals_manifest(character_dir: &Path) -> Result Result { - let state_path = project_dir.join("globals").join("states").join(format!("{}.json", state_name)); + let state_path = project_dir + .join("globals") + .join("states") + .join(format!("{}.json", state_name)); if !state_path.exists() { return Err(GlobalsError::NotFound { @@ -148,27 +157,55 @@ pub fn apply_overrides( } } // Always set input to alias - map.insert("input".to_string(), serde_json::Value::String(alias.to_string())); + map.insert( + "input".to_string(), + serde_json::Value::String(alias.to_string()), + ); } - let result: State = serde_json::from_value(base_json).map_err(|e| GlobalsError::ParseError { - path: "state deserialization".to_string(), - message: e.to_string(), - })?; + let result: State = + serde_json::from_value(base_json).map_err(|e| GlobalsError::ParseError { + path: "state deserialization".to_string(), + message: e.to_string(), + })?; Ok(result) } /// Known State field names for override validation const KNOWN_STATE_FIELDS: &[&str] = &[ - "id", "input", "name", "type", "tags", "base", - "startup", "active", "recovery", "total", - "damage", "hitstun", "blockstun", "hitstop", - "guard", "animation", - "hitboxes", "hurtboxes", "pushback", - "movement", "on_hit", "on_block", "on_use", - "hits", "preconditions", "costs", "meter_gain", "notifies", - "trigger", "parent", "super_freeze", "advanced_hurtboxes", + "id", + "input", + "name", + "type", + "tags", + "base", + "startup", + "active", + "recovery", + "total", + "damage", + "hitstun", + "blockstun", + "hitstop", + "guard", + "animation", + "hitboxes", + "hurtboxes", + "pushback", + "movement", + "on_hit", + "on_block", + "on_use", + "hits", + "preconditions", + "costs", + "meter_gain", + "notifies", + "trigger", + "parent", + "super_freeze", + "advanced_hurtboxes", ]; /// Resolve all global states for a character @@ -366,7 +403,10 @@ mod tests { }; let mut overrides = serde_json::Map::new(); - overrides.insert("movement".to_string(), serde_json::json!({ "distance": 20 })); + overrides.insert( + "movement".to_string(), + serde_json::json!({ "distance": 20 }), + ); let result = apply_overrides(base, &overrides, "idle").unwrap(); let movement = result.movement.unwrap(); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 71f2290..d58f8f1 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,10 +1,10 @@ pub mod codegen; pub mod commands; +pub mod globals; pub mod mcp; pub mod rules; pub mod schema; pub mod variant; -pub mod globals; use commands::{ clone_character, create_character, create_move, create_project, delete_character, diff --git a/src-tauri/src/mcp/handlers.rs b/src-tauri/src/mcp/handlers.rs index 7492abb..60b2ea8 100644 --- a/src-tauri/src/mcp/handlers.rs +++ b/src-tauri/src/mcp/handlers.rs @@ -1,7 +1,6 @@ use std::borrow::Cow; use std::path::{Path, PathBuf}; -use serde::Deserialize; use rmcp::{ handler::server::tool::ToolRouter, model::{ @@ -12,6 +11,7 @@ use rmcp::{ service::{RequestContext, RoleServer}, tool, tool_handler, tool_router, ErrorData as McpError, ServerHandler, }; +use serde::Deserialize; /// The rules specification documentation (SSOT). const RULES_SPEC_MD: &str = include_str!("../../../docs/rules-spec.md"); @@ -66,7 +66,9 @@ pub struct ExportCharacterParam { pub character_id: String, #[schemars(description = "Export adapter: 'fspk' (default) or 'json-blob'")] pub adapter: Option, - #[schemars(description = "Output file path, relative to the project root or absolute under the project root")] + #[schemars( + description = "Output file path, relative to the project root or absolute under the project root" + )] pub output_path: String, #[schemars(description = "Pretty JSON output (json-blob only)")] pub pretty: Option, @@ -76,7 +78,9 @@ pub struct ExportCharacterParam { pub struct ExportAllCharactersParam { #[schemars(description = "Export adapter: 'fspk' (default) or 'json-blob'")] pub adapter: Option, - #[schemars(description = "Output directory, relative to the project root or absolute under the project root")] + #[schemars( + description = "Output directory, relative to the project root or absolute under the project root" + )] pub out_dir: String, #[schemars(description = "Pretty JSON output (json-blob only)")] pub pretty: Option, @@ -131,14 +135,21 @@ impl FramesmithMcp { )])) } - #[tool(description = "Export a character to a file (runs validation + rules). Supports fspk (.fspk) and json-blob (.json).")] + #[tool( + description = "Export a character to a file (runs validation + rules). Supports fspk (.fspk) and json-blob (.json)." + )] async fn export_character( &self, rmcp::handler::server::wrapper::Parameters(params): rmcp::handler::server::wrapper::Parameters, ) -> Result { - use crate::commands::export_character; + use crate::commands::{export_character, normalize_export_adapter}; - let adapter = params.adapter.unwrap_or_else(|| "fspk".to_string()); + let adapter = normalize_export_adapter(params.adapter.as_deref().unwrap_or("fspk")) + .map_err(|e| McpError { + code: rmcp::model::ErrorCode::INVALID_PARAMS, + message: Cow::from(e), + data: None, + })?; let pretty = params.pretty.unwrap_or(false); if adapter == "fspk" && pretty { return Err(McpError { @@ -171,7 +182,7 @@ impl FramesmithMcp { export_character( self.characters_dir.clone(), params.character_id, - adapter, + adapter.to_string(), output_path.to_string_lossy().to_string(), pretty, ) @@ -194,14 +205,23 @@ impl FramesmithMcp { ))])) } - #[tool(description = "Export all characters to a directory (runs validation + rules). Returns a JSON array of per-character results.")] + #[tool( + description = "Export all characters to a directory (runs validation + rules). Returns a JSON array of per-character results." + )] async fn export_all_characters( &self, rmcp::handler::server::wrapper::Parameters(params): rmcp::handler::server::wrapper::Parameters, ) -> Result { - use crate::commands::export_character; + use crate::commands::{ + export_adapter_default_ext, export_character, normalize_export_adapter, + }; - let adapter = params.adapter.unwrap_or_else(|| "fspk".to_string()); + let adapter = normalize_export_adapter(params.adapter.as_deref().unwrap_or("fspk")) + .map_err(|e| McpError { + code: rmcp::model::ErrorCode::INVALID_PARAMS, + message: Cow::from(e), + data: None, + })?; let pretty = params.pretty.unwrap_or(false); let keep_going = params.keep_going.unwrap_or(false); if adapter == "fspk" && pretty { @@ -213,11 +233,14 @@ impl FramesmithMcp { } let project_root = project_root_from_characters_dir(&self.characters_dir); - let out_dir = resolve_output_path_under_project(&project_root, ¶ms.out_dir).map_err(|e| McpError { - code: rmcp::model::ErrorCode::INVALID_PARAMS, - message: Cow::from(e), - data: None, - })?; + let out_dir = + resolve_output_path_under_project(&project_root, ¶ms.out_dir).map_err(|e| { + McpError { + code: rmcp::model::ErrorCode::INVALID_PARAMS, + message: Cow::from(e), + data: None, + } + })?; std::fs::create_dir_all(&out_dir).map_err(|e| McpError { code: rmcp::model::ErrorCode::INTERNAL_ERROR, @@ -235,14 +258,18 @@ impl FramesmithMcp { data: None, })?; - let ext = adapter_default_ext(&adapter); + let ext = export_adapter_default_ext(adapter).map_err(|e| McpError { + code: rmcp::model::ErrorCode::INVALID_PARAMS, + message: Cow::from(e), + data: None, + })?; let mut results: Vec = Vec::new(); for id in ids { let out_path = out_dir.join(format!("{}{}", id, ext)); let res = export_character( self.characters_dir.clone(), id.clone(), - adapter.clone(), + adapter.to_string(), out_path.to_string_lossy().to_string(), pretty, ); @@ -296,18 +323,23 @@ impl FramesmithMcp { Ok(CallToolResult::success(vec![Content::text(json)])) } - #[tool(description = "Get complete character data including properties, all states, and cancel table")] + #[tool( + description = "Get complete character data including properties, all states, and cancel table" + )] async fn get_character( &self, rmcp::handler::server::wrapper::Parameters(params): rmcp::handler::server::wrapper::Parameters, ) -> Result { use crate::commands::load_character; - let data = load_character(self.characters_dir.clone(), params.character_id).map_err(|e| McpError { - code: rmcp::model::ErrorCode::INTERNAL_ERROR, - message: Cow::from(e), - data: None, - })?; + let data = + load_character(self.characters_dir.clone(), params.character_id).map_err(|e| { + McpError { + code: rmcp::model::ErrorCode::INTERNAL_ERROR, + message: Cow::from(e), + data: None, + } + })?; let json = serde_json::to_string_pretty(&data).map_err(|e| McpError { code: rmcp::model::ErrorCode::INTERNAL_ERROR, @@ -325,17 +357,25 @@ impl FramesmithMcp { ) -> Result { use crate::commands::load_character; - let data = load_character(self.characters_dir.clone(), params.character_id.clone()).map_err(|e| McpError { - code: rmcp::model::ErrorCode::INTERNAL_ERROR, - message: Cow::from(e), - data: None, - })?; + let data = load_character(self.characters_dir.clone(), params.character_id.clone()) + .map_err(|e| McpError { + code: rmcp::model::ErrorCode::INTERNAL_ERROR, + message: Cow::from(e), + data: None, + })?; - let mv = data.moves.iter().find(|m| m.input == params.state_input).ok_or_else(|| McpError { - code: rmcp::model::ErrorCode::INVALID_PARAMS, - message: Cow::from(format!("State '{}' not found for character '{}'", params.state_input, params.character_id)), - data: None, - })?; + let mv = data + .moves + .iter() + .find(|m| m.input == params.state_input) + .ok_or_else(|| McpError { + code: rmcp::model::ErrorCode::INVALID_PARAMS, + message: Cow::from(format!( + "State '{}' not found for character '{}'", + params.state_input, params.character_id + )), + data: None, + })?; let json = serde_json::to_string_pretty(&mv).map_err(|e| McpError { code: rmcp::model::ErrorCode::INTERNAL_ERROR, @@ -346,7 +386,9 @@ impl FramesmithMcp { Ok(CallToolResult::success(vec![Content::text(json)])) } - #[tool(description = "Update a state's data. Provide complete state object - it will overwrite the existing state file.")] + #[tool( + description = "Update a state's data. Provide complete state object - it will overwrite the existing state file." + )] async fn update_state( &self, rmcp::handler::server::wrapper::Parameters(params): rmcp::handler::server::wrapper::Parameters, @@ -425,38 +467,47 @@ impl FramesmithMcp { ))])) } - #[tool(description = "Get a compact frame data table for a character - shows startup, active, recovery, damage, and advantage for all states")] + #[tool( + description = "Get a compact frame data table for a character - shows startup, active, recovery, damage, and advantage for all states" + )] async fn get_frame_data_table( &self, rmcp::handler::server::wrapper::Parameters(params): rmcp::handler::server::wrapper::Parameters, ) -> Result { use crate::commands::load_character; - let data = load_character(self.characters_dir.clone(), params.character_id).map_err(|e| McpError { - code: rmcp::model::ErrorCode::INTERNAL_ERROR, - message: Cow::from(e), - data: None, - })?; + let data = + load_character(self.characters_dir.clone(), params.character_id).map_err(|e| { + McpError { + code: rmcp::model::ErrorCode::INTERNAL_ERROR, + message: Cow::from(e), + data: None, + } + })?; - let rows: Vec = data.moves.iter().map(|m| { - let total = m.startup as u16 + m.active as u16 + m.recovery as u16; - let advantage_on_hit = m.hitstun as i16 - m.recovery as i16; - let advantage_on_block = m.blockstun as i16 - m.recovery as i16; - FrameDataRow { - input: m.input.clone(), - name: m.name.clone(), - startup: m.startup, - active: m.active, - recovery: m.recovery, - total, - damage: m.damage, - hitstun: m.hitstun, - blockstun: m.blockstun, - advantage_on_hit, - advantage_on_block, - guard: format!("{:?}", m.guard).to_lowercase(), - } - }).collect(); + let rows: Vec = data + .moves + .iter() + .map(|m| { + let total = m.startup as u16 + m.active as u16 + m.recovery as u16; + let advantage_on_hit = m.hitstun as i16 - m.recovery as i16; + let advantage_on_block = m.blockstun as i16 - m.recovery as i16; + FrameDataRow { + input: m.input.clone(), + name: m.name.clone(), + startup: m.startup, + active: m.active, + recovery: m.recovery, + total, + damage: m.damage, + hitstun: m.hitstun, + blockstun: m.blockstun, + advantage_on_hit, + advantage_on_block, + guard: format!("{:?}", m.guard).to_lowercase(), + } + }) + .collect(); let json = serde_json::to_string_pretty(&rows).map_err(|e| McpError { code: rmcp::model::ErrorCode::INTERNAL_ERROR, @@ -467,18 +518,23 @@ impl FramesmithMcp { Ok(CallToolResult::success(vec![Content::text(json)])) } - #[tool(description = "List all states for a character with basic stats (input, name, startup, damage)")] + #[tool( + description = "List all states for a character with basic stats (input, name, startup, damage)" + )] async fn list_states( &self, rmcp::handler::server::wrapper::Parameters(params): rmcp::handler::server::wrapper::Parameters, ) -> Result { use crate::commands::load_character; - let data = load_character(self.characters_dir.clone(), params.character_id).map_err(|e| McpError { - code: rmcp::model::ErrorCode::INTERNAL_ERROR, - message: Cow::from(e), - data: None, - })?; + let data = + load_character(self.characters_dir.clone(), params.character_id).map_err(|e| { + McpError { + code: rmcp::model::ErrorCode::INTERNAL_ERROR, + message: Cow::from(e), + data: None, + } + })?; #[derive(serde::Serialize)] struct StateSummary { @@ -488,12 +544,16 @@ impl FramesmithMcp { damage: u16, } - let summaries: Vec = data.moves.iter().map(|m| StateSummary { - input: m.input.clone(), - name: m.name.clone(), - startup: m.startup, - damage: m.damage, - }).collect(); + let summaries: Vec = data + .moves + .iter() + .map(|m| StateSummary { + input: m.input.clone(), + name: m.name.clone(), + startup: m.startup, + damage: m.damage, + }) + .collect(); let json = serde_json::to_string_pretty(&summaries).map_err(|e| McpError { code: rmcp::model::ErrorCode::INTERNAL_ERROR, @@ -504,18 +564,21 @@ impl FramesmithMcp { Ok(CallToolResult::success(vec![Content::text(json)])) } - #[tool(description = "Get the cancel table showing all cancel relationships (chains, special cancels, super cancels, jump cancels)")] + #[tool(description = "Get the cancel table showing tag rules and explicit deny routes")] async fn get_cancel_table( &self, rmcp::handler::server::wrapper::Parameters(params): rmcp::handler::server::wrapper::Parameters, ) -> Result { use crate::commands::load_character; - let data = load_character(self.characters_dir.clone(), params.character_id).map_err(|e| McpError { - code: rmcp::model::ErrorCode::INTERNAL_ERROR, - message: Cow::from(e), - data: None, - })?; + let data = + load_character(self.characters_dir.clone(), params.character_id).map_err(|e| { + McpError { + code: rmcp::model::ErrorCode::INTERNAL_ERROR, + message: Cow::from(e), + data: None, + } + })?; let json = serde_json::to_string_pretty(&data.cancel_table).map_err(|e| McpError { code: rmcp::model::ErrorCode::INTERNAL_ERROR, @@ -534,7 +597,10 @@ impl FramesmithMcp { use std::path::Path; // Validate character_id - if params.character_id.contains("..") || params.character_id.contains('/') || params.character_id.contains('\\') { + if params.character_id.contains("..") + || params.character_id.contains('/') + || params.character_id.contains('\\') + { return Err(McpError { code: rmcp::model::ErrorCode::INVALID_PARAMS, message: Cow::from("Invalid character ID"), @@ -543,7 +609,10 @@ impl FramesmithMcp { } // Validate state_input - if params.state_input.contains("..") || params.state_input.contains('/') || params.state_input.contains('\\') { + if params.state_input.contains("..") + || params.state_input.contains('/') + || params.state_input.contains('\\') + { return Err(McpError { code: rmcp::model::ErrorCode::INVALID_PARAMS, message: Cow::from("Invalid state input"), @@ -579,7 +648,9 @@ impl FramesmithMcp { ))])) } - #[tool(description = "Get the JSON Schema for rules files. Use this schema for IDE autocomplete when editing framesmith.rules.json files.")] + #[tool( + description = "Get the JSON Schema for rules files. Use this schema for IDE autocomplete when editing framesmith.rules.json files." + )] async fn get_rules_schema(&self) -> Result { use crate::rules::generate_rules_schema; @@ -593,7 +664,9 @@ impl FramesmithMcp { Ok(CallToolResult::success(vec![Content::text(json)])) } - #[tool(description = "Get the list of built-in validation rules that always run on states. These cannot be disabled.")] + #[tool( + description = "Get the list of built-in validation rules that always run on states. These cannot be disabled." + )] async fn get_builtin_validations(&self) -> Result { use crate::rules::get_builtin_validations; @@ -610,12 +683,11 @@ impl FramesmithMcp { #[tool(description = "List all global states in the project")] async fn list_global_states(&self) -> Result { let project_dir = project_root_from_characters_dir(&self.characters_dir); - let states = crate::globals::list_global_states(&project_dir) - .map_err(|e| McpError { - code: rmcp::model::ErrorCode::INTERNAL_ERROR, - message: Cow::from(format!("Failed to list global states: {}", e)), - data: None, - })?; + let states = crate::globals::list_global_states(&project_dir).map_err(|e| McpError { + code: rmcp::model::ErrorCode::INTERNAL_ERROR, + message: Cow::from(format!("Failed to list global states: {}", e)), + data: None, + })?; let mut result: Vec = Vec::new(); for name in &states { @@ -639,8 +711,8 @@ impl FramesmithMcp { ) -> Result { validate_global_state_id(¶ms.id)?; let project_dir = project_root_from_characters_dir(&self.characters_dir); - let state = crate::globals::load_global_state(&project_dir, ¶ms.id) - .map_err(|e| McpError { + let state = + crate::globals::load_global_state(&project_dir, ¶ms.id).map_err(|e| McpError { code: rmcp::model::ErrorCode::INVALID_PARAMS, message: Cow::from(format!("Failed to load global state: {}", e)), data: None, @@ -688,9 +760,10 @@ impl FramesmithMcp { data: None, })?; - Ok(CallToolResult::success(vec![Content::text( - format!("Created global state '{}'", params.id), - )])) + Ok(CallToolResult::success(vec![Content::text(format!( + "Created global state '{}'", + params.id + ))])) } #[tool(description = "Update an existing global state")] @@ -700,7 +773,10 @@ impl FramesmithMcp { ) -> Result { validate_global_state_id(¶ms.id)?; let project_dir = project_root_from_characters_dir(&self.characters_dir); - let state_path = project_dir.join("globals").join("states").join(format!("{}.json", params.id)); + let state_path = project_dir + .join("globals") + .join("states") + .join(format!("{}.json", params.id)); if !state_path.exists() { return Err(McpError { @@ -722,9 +798,10 @@ impl FramesmithMcp { data: None, })?; - Ok(CallToolResult::success(vec![Content::text( - format!("Updated global state '{}'", params.id), - )])) + Ok(CallToolResult::success(vec![Content::text(format!( + "Updated global state '{}'", + params.id + ))])) } #[tool(description = "Delete a global state (checks for references first)")] @@ -734,7 +811,10 @@ impl FramesmithMcp { ) -> Result { validate_global_state_id(¶ms.id)?; let project_dir = project_root_from_characters_dir(&self.characters_dir); - let state_path = project_dir.join("globals").join("states").join(format!("{}.json", params.id)); + let state_path = project_dir + .join("globals") + .join("states") + .join(format!("{}.json", params.id)); if !state_path.exists() { return Err(McpError { @@ -753,7 +833,8 @@ impl FramesmithMcp { if globals_path.exists() { if let Ok(content) = std::fs::read_to_string(&globals_path) { if content.contains(&format!("\"state\": \"{}\"", params.id)) - || content.contains(&format!("\"state\":\"{}\"", params.id)) { + || content.contains(&format!("\"state\":\"{}\"", params.id)) + { return Err(McpError { code: rmcp::model::ErrorCode::INVALID_PARAMS, message: Cow::from(format!( @@ -776,9 +857,10 @@ impl FramesmithMcp { data: None, })?; - Ok(CallToolResult::success(vec![Content::text( - format!("Deleted global state '{}'", params.id), - )])) + Ok(CallToolResult::success(vec![Content::text(format!( + "Deleted global state '{}'", + params.id + ))])) } } @@ -884,16 +966,14 @@ impl ServerHandler for FramesmithMcp { contents: vec![ResourceContents::text(guide, &request.uri)], }) } - "framesmith://rules_guide" => { - Ok(ReadResourceResult { - contents: vec![ResourceContents::text(RULES_SPEC_MD, &request.uri)], - }) - } + "framesmith://rules_guide" => Ok(ReadResourceResult { + contents: vec![ResourceContents::text(RULES_SPEC_MD, &request.uri)], + }), _ => Err(McpError { code: rmcp::model::ErrorCode::INVALID_PARAMS, message: Cow::from(format!("Unknown resource: {}", request.uri)), data: None, - }) + }), }) } } @@ -905,7 +985,10 @@ fn project_root_from_characters_dir(characters_dir: &str) -> PathBuf { .to_path_buf() } -fn resolve_output_path_under_project(project_root: &Path, user_path: &str) -> Result { +fn resolve_output_path_under_project( + project_root: &Path, + user_path: &str, +) -> Result { if user_path.trim().is_empty() { return Err("output path cannot be empty".to_string()); } @@ -918,17 +1001,26 @@ fn resolve_output_path_under_project(project_root: &Path, user_path: &str) -> Re }; // Canonicalize a stable project root. - let root_canon = project_root - .canonicalize() - .map_err(|e| format!("Failed to resolve project root {}: {}", project_root.display(), e))?; + let root_canon = project_root.canonicalize().map_err(|e| { + format!( + "Failed to resolve project root {}: {}", + project_root.display(), + e + ) + })?; // Canonicalize the output parent dir (file may not exist yet). let parent = abs .parent() .ok_or_else(|| "Invalid output path".to_string())?; let parent_canon = parent.canonicalize().or_else(|_| { - std::fs::create_dir_all(parent) - .map_err(|e| format!("Failed to create output directory {}: {}", parent.display(), e))?; + std::fs::create_dir_all(parent).map_err(|e| { + format!( + "Failed to create output directory {}: {}", + parent.display(), + e + ) + })?; parent.canonicalize().map_err(|e| { format!( "Failed to resolve output directory {}: {}", @@ -948,14 +1040,6 @@ fn resolve_output_path_under_project(project_root: &Path, user_path: &str) -> Re Ok(abs) } -fn adapter_default_ext(adapter: &str) -> &'static str { - match adapter { - "fspk" => ".fspk", - "json-blob" => ".json", - _ => ".bin", - } -} - /// Validate a global state ID for path safety fn validate_global_state_id(id: &str) -> Result<(), McpError> { if id.is_empty() { @@ -977,10 +1061,15 @@ fn validate_global_state_id(id: &str) -> Result<(), McpError> { fn find_character_dir_names(characters_dir: &str) -> Result, String> { let mut ids: Vec = Vec::new(); - let rd = std::fs::read_dir(characters_dir) - .map_err(|e| format!("Failed to read characters directory {}: {}", characters_dir, e))?; + let rd = std::fs::read_dir(characters_dir).map_err(|e| { + format!( + "Failed to read characters directory {}: {}", + characters_dir, e + ) + })?; for entry in rd { - let entry = entry.map_err(|e| format!("Failed to read characters directory entry: {}", e))?; + let entry = + entry.map_err(|e| format!("Failed to read characters directory entry: {}", e))?; let path = entry.path(); if !path.is_dir() { continue; diff --git a/src-tauri/src/mcp/mod.rs b/src-tauri/src/mcp/mod.rs index 4c089ad..0158cc9 100644 --- a/src-tauri/src/mcp/mod.rs +++ b/src-tauri/src/mcp/mod.rs @@ -1,5 +1,5 @@ +pub mod handlers; pub mod validation; mod validators; -pub mod handlers; pub use handlers::*; diff --git a/src-tauri/src/rules/apply.rs b/src-tauri/src/rules/apply.rs index e38ce65..56f7e10 100644 --- a/src-tauri/src/rules/apply.rs +++ b/src-tauri/src/rules/apply.rs @@ -155,8 +155,8 @@ pub fn apply_rules_to_move( #[cfg(test)] mod tests { - use super::*; use super::super::matchers::StringOrVec; + use super::*; const RULES_VERSION: u32 = 1; @@ -171,14 +171,6 @@ mod tests { } } - fn make_valid_move() -> crate::schema::State { - let mut mv = crate::schema::State::default(); - mv.input = "5L".to_string(); - mv.startup = 1; - mv.active = 1; - mv - } - #[test] fn test_apply_set_must_be_object() { let err = serde_json::from_str::( @@ -219,10 +211,12 @@ mod tests { }, ]); - let mut mv = crate::schema::State::default(); - mv.input = "236P".to_string(); - mv.move_type = Some("special".to_string()); - mv.hitstop = 0; + let mv = crate::schema::State { + input: "236P".to_string(), + move_type: Some("special".to_string()), + hitstop: 0, + ..Default::default() + }; let resolved = apply_rules_to_move(Some(&project), None, &mv).unwrap(); assert_eq!(resolved.hitstop, 10); @@ -241,9 +235,11 @@ mod tests { set: serde_json::json!({ "hitstop": 8 }), }]); - let mut mv = crate::schema::State::default(); - mv.input = "5L".to_string(); - mv.hitstop = 6; + let mv = crate::schema::State { + input: "5L".to_string(), + hitstop: 6, + ..Default::default() + }; let resolved = apply_rules_to_move(Some(&project), None, &mv).unwrap(); assert_eq!(resolved.hitstop, 6); @@ -268,10 +264,12 @@ mod tests { set: serde_json::json!({ "hitstop": 9 }), }]); - let mut mv = crate::schema::State::default(); - mv.input = "5L".to_string(); - mv.move_type = Some("normal".to_string()); - mv.hitstop = 0; + let mv = crate::schema::State { + input: "5L".to_string(), + move_type: Some("normal".to_string()), + hitstop: 0, + ..Default::default() + }; let resolved = apply_rules_to_move(Some(&project), Some(&character), &mv).unwrap(); assert_eq!(resolved.hitstop, 9); diff --git a/src-tauri/src/rules/matchers.rs b/src-tauri/src/rules/matchers.rs index 7e67ed4..2667efa 100644 --- a/src-tauri/src/rules/matchers.rs +++ b/src-tauri/src/rules/matchers.rs @@ -123,7 +123,10 @@ pub fn matches_move(spec: &MatchSpec, mv: &crate::schema::State) -> bool { } if let Some(tags) = &spec.tags { - if !tags.iter().all(|t| mv.tags.iter().any(|tag| tag.as_str() == t)) { + if !tags + .iter() + .all(|t| mv.tags.iter().any(|tag| tag.as_str() == t)) + { return false; } } @@ -156,10 +159,12 @@ mod tests { #[test] fn test_matches_move_or_within_field_and_across_fields() { - let mut mv = crate::schema::State::default(); - mv.input = "2L".to_string(); - mv.move_type = Some("command_normal".to_string()); - mv.guard = crate::schema::GuardType::Unblockable; + let mv = crate::schema::State { + input: "2L".to_string(), + move_type: Some("command_normal".to_string()), + guard: crate::schema::GuardType::Unblockable, + ..Default::default() + }; // OR within a field let spec = MatchSpec { @@ -191,8 +196,10 @@ mod tests { #[test] fn test_matches_move_button_extraction() { - let mut mv = crate::schema::State::default(); - mv.input = "j.H".to_string(); + let mut mv = crate::schema::State { + input: "j.H".to_string(), + ..Default::default() + }; let spec = MatchSpec { r#type: None, @@ -218,12 +225,14 @@ mod tests { #[test] fn test_matches_move_tags_and() { - let mut mv = crate::schema::State::default(); - mv.input = "5L".to_string(); - mv.tags = vec![ - crate::schema::Tag::new("starter").unwrap(), - crate::schema::Tag::new("reversal").unwrap(), - ]; + let mv = crate::schema::State { + input: "5L".to_string(), + tags: vec![ + crate::schema::Tag::new("starter").unwrap(), + crate::schema::Tag::new("reversal").unwrap(), + ], + ..Default::default() + }; let spec = MatchSpec { r#type: None, diff --git a/src-tauri/src/rules/registry.rs b/src-tauri/src/rules/registry.rs index c7990d3..bb87296 100644 --- a/src-tauri/src/rules/registry.rs +++ b/src-tauri/src/rules/registry.rs @@ -468,11 +468,12 @@ mod tests { } fn make_valid_move() -> crate::schema::State { - let mut mv = crate::schema::State::default(); - mv.input = "5L".to_string(); - mv.startup = 1; - mv.active = 1; - mv + crate::schema::State { + input: "5L".to_string(), + startup: 1, + active: 1, + ..Default::default() + } } #[test] diff --git a/src-tauri/src/rules/validate.rs b/src-tauri/src/rules/validate.rs index a198712..f2c5476 100644 --- a/src-tauri/src/rules/validate.rs +++ b/src-tauri/src/rules/validate.rs @@ -384,8 +384,8 @@ pub fn get_builtin_validations() -> Vec { #[cfg(test)] mod tests { - use super::*; use super::super::matchers::StringOrVec; + use super::*; const RULES_VERSION: u32 = 1; @@ -401,11 +401,12 @@ mod tests { } fn make_valid_move() -> crate::schema::State { - let mut mv = crate::schema::State::default(); - mv.input = "5L".to_string(); - mv.startup = 1; - mv.active = 1; - mv + crate::schema::State { + input: "5L".to_string(), + startup: 1, + active: 1, + ..Default::default() + } } #[test] diff --git a/src-tauri/src/schema/mod.rs b/src-tauri/src/schema/mod.rs index ec90d35..09ff179 100644 --- a/src-tauri/src/schema/mod.rs +++ b/src-tauri/src/schema/mod.rs @@ -82,7 +82,6 @@ impl schemars::JsonSchema for Tag { } } - /// A character property value (dynamic key-value). /// /// Supports nested structures via Array and Object variants for @@ -150,7 +149,6 @@ pub struct Character { pub resources: Vec, } - /// Character assets manifest (textures, models, animations). #[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)] pub struct CharacterAssets { @@ -335,7 +333,6 @@ impl Default for State { } } - #[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)] #[serde(rename_all = "lowercase")] pub enum GuardType { @@ -345,7 +342,6 @@ pub enum GuardType { Unblockable, } - /// Bit flags for cancel conditions pub mod cancel_flags { pub const HIT: u8 = 0b001; @@ -442,7 +438,7 @@ impl Serialize for CancelCondition { impl<'de> Deserialize<'de> for CancelCondition { fn deserialize>(deserializer: D) -> Result { - use serde::de::{self, Visitor, SeqAccess}; + use serde::de::{self, SeqAccess, Visitor}; struct CancelConditionVisitor; @@ -450,7 +446,9 @@ impl<'de> Deserialize<'de> for CancelCondition { type Value = CancelCondition; fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { - formatter.write_str("a string ('always', 'hit', 'block', 'whiff') or array of conditions") + formatter.write_str( + "a string ('always', 'hit', 'block', 'whiff') or array of conditions", + ) } fn visit_str(self, v: &str) -> Result { @@ -459,7 +457,10 @@ impl<'de> Deserialize<'de> for CancelCondition { "hit" => Ok(CancelCondition(cancel_flags::HIT)), "block" => Ok(CancelCondition(cancel_flags::BLOCK)), "whiff" => Ok(CancelCondition(cancel_flags::WHIFF)), - _ => Err(de::Error::unknown_variant(v, &["always", "hit", "block", "whiff"])), + _ => Err(de::Error::unknown_variant( + v, + &["always", "hit", "block", "whiff"], + )), } } @@ -540,8 +541,6 @@ pub struct CancelTable { // Advanced Move Data Types // ============================================================================ - - #[cfg(test)] mod tests { use super::*; diff --git a/src-tauri/src/variant/mod.rs b/src-tauri/src/variant/mod.rs index 24dc042..3bae802 100644 --- a/src-tauri/src/variant/mod.rs +++ b/src-tauri/src/variant/mod.rs @@ -15,7 +15,9 @@ fn is_default_value(v: &serde_json::Value) -> bool { Value::String(s) => s.is_empty(), Value::Array(arr) => arr.is_empty(), Value::Object(obj) => obj.is_empty(), - Value::Number(n) => n.as_u64() == Some(0) || n.as_i64() == Some(0) || n.as_f64() == Some(0.0), + Value::Number(n) => { + n.as_u64() == Some(0) || n.as_i64() == Some(0) || n.as_f64() == Some(0.0) + } Value::Bool(_) => false, // bools are never considered default (false is meaningful) } } @@ -54,10 +56,20 @@ pub fn resolve_variant(base: &State, overlay: &State, resolved_id: &str) -> Stat let mut merged = deep_merge(base_json, overlay_json); if let serde_json::Value::Object(ref mut map) = merged { - map.insert("id".to_string(), serde_json::Value::String(resolved_id.to_string())); + map.insert( + "id".to_string(), + serde_json::Value::String(resolved_id.to_string()), + ); map.remove("base"); - if map.get("input").map(|v| v.as_str() == Some("")).unwrap_or(true) { - map.insert("input".to_string(), serde_json::Value::String(base.input.clone())); + if map + .get("input") + .map(|v| v.as_str() == Some("")) + .unwrap_or(true) + { + map.insert( + "input".to_string(), + serde_json::Value::String(base.input.clone()), + ); } } @@ -88,10 +100,7 @@ pub fn is_variant_filename(name: &str) -> bool { } /// Validate variant states have existing bases and matching base fields. -pub fn validate_variants( - states: &[(String, State)], - base_names: &HashSet, -) -> Vec { +pub fn validate_variants(states: &[(String, State)], base_names: &HashSet) -> Vec { let mut errors = Vec::new(); for (name, state) in states { @@ -187,7 +196,10 @@ pub fn flatten_variants(states: Vec<(String, State)>) -> Result, Stri for (name, overlay) in variants { let base_name = overlay.base.as_ref().unwrap(); let base = base_map.get(base_name).ok_or_else(|| { - format!("Base state '{}' not found for variant '{}'", base_name, name) + format!( + "Base state '{}' not found for variant '{}'", + base_name, name + ) })?; let resolved = resolve_variant(base, &overlay, &name); result.push(resolved); @@ -300,14 +312,24 @@ mod tests { let base = State { hitboxes: vec![FrameHitbox { frames: (8, 12), - r#box: Rect { x: 0, y: -50, w: 40, h: 20 }, + r#box: Rect { + x: 0, + y: -50, + w: 40, + h: 20, + }, }], ..Default::default() }; let overlay = State { hitboxes: vec![FrameHitbox { frames: (8, 14), - r#box: Rect { x: 0, y: -55, w: 50, h: 25 }, + r#box: Rect { + x: 0, + y: -55, + w: 50, + h: 25, + }, }], ..Default::default() }; @@ -336,9 +358,13 @@ mod tests { #[test] fn validate_base_exists() { - let states = vec![ - ("5H~level1".to_string(), State { base: Some("5H".to_string()), ..Default::default() }), - ]; + let states = vec![( + "5H~level1".to_string(), + State { + base: Some("5H".to_string()), + ..Default::default() + }, + )]; let base_names: std::collections::HashSet<_> = std::iter::empty().collect(); let errors = validate_variants(&states, &base_names); @@ -349,10 +375,15 @@ mod tests { #[test] fn validate_base_field_matches_filename() { - let states = vec![ - ("5H~level1".to_string(), State { base: Some("2H".to_string()), ..Default::default() }), - ]; - let base_names: std::collections::HashSet<_> = ["5H".to_string(), "2H".to_string()].into_iter().collect(); + let states = vec![( + "5H~level1".to_string(), + State { + base: Some("2H".to_string()), + ..Default::default() + }, + )]; + let base_names: std::collections::HashSet<_> = + ["5H".to_string(), "2H".to_string()].into_iter().collect(); let errors = validate_variants(&states, &base_names); @@ -363,11 +394,24 @@ mod tests { #[test] fn validate_no_chained_inheritance() { let states = vec![ - ("5H~level1".to_string(), State { base: Some("5H".to_string()), ..Default::default() }), - ("5H~level1~enhanced".to_string(), State { base: Some("5H~level1".to_string()), ..Default::default() }), + ( + "5H~level1".to_string(), + State { + base: Some("5H".to_string()), + ..Default::default() + }, + ), + ( + "5H~level1~enhanced".to_string(), + State { + base: Some("5H~level1".to_string()), + ..Default::default() + }, + ), ]; let base_names: std::collections::HashSet<_> = ["5H".to_string()].into_iter().collect(); - let variant_names: std::collections::HashSet<_> = ["5H~level1".to_string()].into_iter().collect(); + let variant_names: std::collections::HashSet<_> = + ["5H~level1".to_string()].into_iter().collect(); let errors = validate_variants_no_chain(&states, &base_names, &variant_names); @@ -377,9 +421,13 @@ mod tests { #[test] fn validate_passes_for_valid_variant() { - let states = vec![ - ("5H~level1".to_string(), State { base: Some("5H".to_string()), ..Default::default() }), - ]; + let states = vec![( + "5H~level1".to_string(), + State { + base: Some("5H".to_string()), + ..Default::default() + }, + )]; let base_names: std::collections::HashSet<_> = ["5H".to_string()].into_iter().collect(); let errors = validate_variants(&states, &base_names); @@ -416,15 +464,24 @@ mod tests { assert_eq!(flattened.len(), 3); - let base_resolved = flattened.iter().find(|s| s.id.as_deref() == Some("5H")).unwrap(); + let base_resolved = flattened + .iter() + .find(|s| s.id.as_deref() == Some("5H")) + .unwrap(); assert_eq!(base_resolved.damage, 50); - let v1 = flattened.iter().find(|s| s.id.as_deref() == Some("5H~level1")).unwrap(); + let v1 = flattened + .iter() + .find(|s| s.id.as_deref() == Some("5H~level1")) + .unwrap(); assert_eq!(v1.input, "5H"); assert_eq!(v1.damage, 60); assert!(v1.base.is_none()); - let v2 = flattened.iter().find(|s| s.id.as_deref() == Some("5H~level2")).unwrap(); + let v2 = flattened + .iter() + .find(|s| s.id.as_deref() == Some("5H~level2")) + .unwrap(); assert_eq!(v2.damage, 75); } @@ -436,9 +493,7 @@ mod tests { ..Default::default() }; - let states = vec![ - ("5H~level1".to_string(), variant), - ]; + let states = vec![("5H~level1".to_string(), variant)]; let result = flatten_variants(states); assert!(result.is_err()); @@ -464,16 +519,16 @@ mod tests { let base: State = serde_json::from_str(base_json).unwrap(); let variant: State = serde_json::from_str(variant_json).unwrap(); - let states = vec![ - ("5H".to_string(), base), - ("5H~level1".to_string(), variant), - ]; + let states = vec![("5H".to_string(), base), ("5H~level1".to_string(), variant)]; let flattened = flatten_variants(states).unwrap(); assert_eq!(flattened.len(), 2); - let resolved = flattened.iter().find(|s| s.id.as_deref() == Some("5H~level1")).unwrap(); + let resolved = flattened + .iter() + .find(|s| s.id.as_deref() == Some("5H~level1")) + .unwrap(); assert_eq!(resolved.input, "5H"); assert_eq!(resolved.damage, 80); assert_eq!(resolved.hitstun, 20); diff --git a/src-tauri/tests/docs_cli_examples.rs b/src-tauri/tests/docs_cli_examples.rs new file mode 100644 index 0000000..0eb0f3b --- /dev/null +++ b/src-tauri/tests/docs_cli_examples.rs @@ -0,0 +1,156 @@ +use framesmith_fspack::PackView; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; +use tempfile::TempDir; + +const CLI_DOC: &str = include_str!("../../docs/cli.md"); +const README: &str = include_str!("../../README.md"); +const ZX_FSPACK_DOC: &str = include_str!("../../docs/zx-fspack.md"); +const AGENTS_DOC: &str = include_str!("../../AGENTS.md"); + +fn cli_bin() -> PathBuf { + option_env!("CARGO_BIN_EXE_framesmith-cli") + .map(PathBuf::from) + .unwrap_or_else(|| { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("target") + .join("debug") + .join(format!("framesmith-cli{}", std::env::consts::EXE_SUFFIX)) + }) +} + +fn repo_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("src-tauri has repo parent") + .to_path_buf() +} + +fn copy_dir_all(src: &Path, dst: &Path) { + fs::create_dir_all(dst).expect("create destination directory"); + for entry in fs::read_dir(src).expect("read source directory") { + let entry = entry.expect("read directory entry"); + let ty = entry.file_type().expect("read entry type"); + let from = entry.path(); + let to = dst.join(entry.file_name()); + if ty.is_dir() { + copy_dir_all(&from, &to); + } else { + fs::copy(&from, &to).expect("copy file"); + } + } +} + +fn temp_project() -> TempDir { + let temp = tempfile::tempdir().expect("create temp project"); + let root = temp.path(); + let repo = repo_root(); + + fs::create_dir_all(root.join("src-tauri")).expect("create temp src-tauri"); + fs::copy( + repo.join("framesmith.rules.json"), + root.join("framesmith.rules.json"), + ) + .expect("copy project rules"); + copy_dir_all(&repo.join("characters"), &root.join("characters")); + + let globals = repo.join("globals"); + if globals.exists() { + copy_dir_all(&globals, &root.join("globals")); + } + + temp +} + +fn run_doc_example(cwd: &Path, args: &[&str]) { + let output = Command::new(cli_bin()) + .current_dir(cwd) + .args(args) + .output() + .expect("run framesmith-cli doc example"); + + assert!( + output.status.success(), + "framesmith-cli failed\nstatus: {}\nstdout:\n{}\nstderr:\n{}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} + +fn assert_valid_fspk(path: &Path) { + let bytes = fs::read(path).unwrap_or_else(|e| panic!("read {}: {}", path.display(), e)); + let pack = + PackView::parse(&bytes).unwrap_or_else(|e| panic!("parse {}: {:?}", path.display(), e)); + let states = pack.states().expect("FSPK has states section"); + assert!(!states.is_empty(), "FSPK should contain at least one state"); +} + +#[test] +fn cli_reference_export_examples_run_against_temp_project() { + assert!(CLI_DOC.contains( + "cargo run --bin framesmith-cli -- export --project .. --character test_char --out ../exports/test_char.fspk" + )); + assert!(CLI_DOC.contains( + "cargo run --bin framesmith-cli -- export --project .. --all --out-dir ../exports" + )); + assert!(CLI_DOC.contains( + "cargo run --bin framesmith-cli -- export --characters-dir ../characters --all --out-dir ../exports" + )); + + let temp = temp_project(); + let cwd = temp.path().join("src-tauri"); + + run_doc_example( + &cwd, + &[ + "export", + "--project", + "..", + "--character", + "test_char", + "--out", + "../exports/test_char.fspk", + ], + ); + assert_valid_fspk(&temp.path().join("exports/test_char.fspk")); + + run_doc_example( + &cwd, + &[ + "export", + "--project", + "..", + "--all", + "--out-dir", + "../exports", + ], + ); + assert_valid_fspk(&temp.path().join("exports/test_char.fspk")); + + run_doc_example( + &cwd, + &[ + "export", + "--characters-dir", + "../characters", + "--all", + "--out-dir", + "../exports", + ], + ); + assert_valid_fspk(&temp.path().join("exports/test_char.fspk")); +} + +#[test] +fn readme_and_format_docs_cli_examples_match_the_tested_export_command() { + let tested_command = + "cargo run --bin framesmith-cli -- export --project .. --all --out-dir ../exports"; + let tested_single_command = "cargo run --bin framesmith-cli -- export --project .. --character test_char --out ../exports/test_char.fspk"; + + assert!(README.contains(tested_command)); + assert!(AGENTS_DOC.contains(tested_command)); + assert!(ZX_FSPACK_DOC.contains(tested_single_command)); + assert!(!AGENTS_DOC.contains("cargo run --bin framesmith -- export")); +} diff --git a/src-tauri/tests/export_fidelity_contract.rs b/src-tauri/tests/export_fidelity_contract.rs new file mode 100644 index 0000000..764ba43 --- /dev/null +++ b/src-tauri/tests/export_fidelity_contract.rs @@ -0,0 +1,336 @@ +use framesmith_lib::schema::{CancelTable, Character, State}; +use schemars::JsonSchema; +use serde::Deserialize; +use std::collections::{BTreeMap, BTreeSet}; + +const CONTRACT_JSON: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../docs/export-fidelity-contract.json" +)); +const CONTRACT_DOC: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../docs/export-fidelity-contract.md" +)); +const HANDOFF_DOC: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../docs/production-handoff-decision.md" +)); +const FSPK_ROUNDTRIP_TEST_SOURCE: &str = include_str!("fspk_roundtrip.rs"); + +#[derive(Debug, Deserialize)] +struct ExportContract { + version: u8, + status_values: BTreeSet, + adapters: BTreeMap, +} + +#[derive(Debug, Deserialize)] +struct AdapterContract { + character: BTreeMap, + state: BTreeMap, + cancel_table: BTreeMap, +} + +#[derive(Debug, Deserialize)] +struct FieldContract { + status: String, + notes: String, +} + +fn load_contract() -> ExportContract { + serde_json::from_str(CONTRACT_JSON).expect("export fidelity contract should parse") +} + +fn schema_fields() -> BTreeSet { + let schema = schemars::schema_for!(T); + let value = serde_json::to_value(schema).expect("schema should serialize"); + let props = value + .get("properties") + .and_then(|v| v.as_object()) + .unwrap_or_else(|| panic!("schema has no object properties: {value}")); + + props.keys().cloned().collect() +} + +fn contract_fields(fields: &BTreeMap) -> BTreeSet { + fields.keys().cloned().collect() +} + +fn fspk_roundtrip_coverage() -> BTreeMap<&'static str, Vec<&'static str>> { + BTreeMap::from([ + ( + "character.id", + vec!["fspk_mesh_keys_include_character_id_and_animation"], + ), + ( + "character.properties", + vec!["character_properties_scalar_survive_roundtrip"], + ), + ( + "character.resources", + vec!["fspk_exports_resources_and_events_sections"], + ), + ("state.input", vec!["fspk_exports_move_input_notation"]), + ("state.tags", vec!["tags_survive_roundtrip"]), + ( + "state.startup", + vec!["fspk_move_record_fields_match_reader_layout"], + ), + ( + "state.active", + vec!["fspk_move_record_fields_match_reader_layout"], + ), + ( + "state.recovery", + vec!["fspk_move_record_fields_match_reader_layout"], + ), + ( + "state.damage", + vec!["fspk_move_record_fields_match_reader_layout"], + ), + ( + "state.hitstun", + vec!["fspk_move_record_fields_match_reader_layout"], + ), + ( + "state.blockstun", + vec!["fspk_move_record_fields_match_reader_layout"], + ), + ( + "state.hitstop", + vec!["fspk_move_record_fields_match_reader_layout"], + ), + ( + "state.guard", + vec!["fspk_move_record_fields_match_reader_layout"], + ), + ( + "state.hitboxes", + vec!["fspk_move_record_fields_match_reader_layout"], + ), + ( + "state.hurtboxes", + vec!["fspk_move_record_fields_match_reader_layout"], + ), + ( + "state.pushback", + vec!["fspk_exports_pushback_and_meter_gain_to_runtime_sections"], + ), + ( + "state.meter_gain", + vec!["fspk_exports_pushback_and_meter_gain_to_runtime_sections"], + ), + ( + "state.animation", + vec!["fspk_mesh_keys_include_character_id_and_animation"], + ), + ( + "state.type", + vec!["fspk_move_record_fields_match_reader_layout"], + ), + ( + "state.trigger", + vec!["fspk_move_record_fields_match_reader_layout"], + ), + ( + "state.total", + vec!["fspk_move_record_fields_match_reader_layout"], + ), + ( + "state.preconditions", + vec!["fspk_exports_resources_and_events_sections"], + ), + ( + "state.costs", + vec!["fspk_exports_resources_and_events_sections"], + ), + ( + "state.on_use", + vec!["fspk_exports_resources_and_events_sections"], + ), + ( + "state.on_hit", + vec!["fspk_exports_resources_and_events_sections"], + ), + ( + "state.on_block", + vec!["fspk_exports_resources_and_events_sections"], + ), + ( + "state.notifies", + vec!["fspk_exports_resources_and_events_sections"], + ), + ("state.pushboxes", vec!["fspk_pushbox_chain_roundtrip"]), + ( + "state.properties", + vec![ + "state_properties_scalar_survive_roundtrip", + "state_properties_nested_flattened_on_export", + ], + ), + ("cancel_table.tag_rules", vec!["cancel_tag_rules_roundtrip"]), + ("cancel_table.deny", vec!["cancel_denies_roundtrip"]), + ]) +} + +#[test] +fn export_fidelity_contract_covers_current_schema_direct_fields() { + let contract = load_contract(); + assert_eq!(contract.version, 1); + + let character_fields = schema_fields::(); + let state_fields = schema_fields::(); + let cancel_table_fields = schema_fields::(); + + for (adapter_name, adapter) in &contract.adapters { + assert_eq!( + contract_fields(&adapter.character), + character_fields, + "{adapter_name} character field classifications must match schema" + ); + assert_eq!( + contract_fields(&adapter.state), + state_fields, + "{adapter_name} state field classifications must match schema" + ); + assert_eq!( + contract_fields(&adapter.cancel_table), + cancel_table_fields, + "{adapter_name} cancel table field classifications must match schema" + ); + } +} + +#[test] +fn fspk_preserved_and_derived_fields_have_named_roundtrip_coverage() { + let contract = load_contract(); + let adapter = contract + .adapters + .get("fspk") + .expect("fspk adapter contract"); + let coverage = fspk_roundtrip_coverage(); + + for (section_name, fields) in [ + ("character", &adapter.character), + ("state", &adapter.state), + ("cancel_table", &adapter.cancel_table), + ] { + for (field_name, field) in fields { + if field.status == "preserved" || field.status == "derived" { + let key = format!("{section_name}.{field_name}"); + let test_names = coverage + .get(key.as_str()) + .unwrap_or_else(|| panic!("{key} needs named FSPK roundtrip coverage")); + assert!( + !test_names.is_empty(), + "{key} coverage must list at least one test" + ); + + for test_name in test_names { + assert!( + FSPK_ROUNDTRIP_TEST_SOURCE.contains(&format!("fn {test_name}(")), + "{key} references missing fspk_roundtrip test '{test_name}'" + ); + } + } + } + } + + for key in coverage.keys() { + let (section_name, field_name) = key + .split_once('.') + .unwrap_or_else(|| panic!("invalid coverage key {key}")); + let fields = match section_name { + "character" => &adapter.character, + "state" => &adapter.state, + "cancel_table" => &adapter.cancel_table, + _ => panic!("unknown coverage section {section_name}"), + }; + let field = fields + .get(field_name) + .unwrap_or_else(|| panic!("coverage key {key} is not in the contract")); + assert!( + field.status == "preserved" || field.status == "derived", + "coverage key {key} should only target preserved or derived fields" + ); + } +} + +#[test] +fn export_fidelity_contract_documents_known_lossy_examples() { + for required in [ + "## FSPK V1 Lossy Examples", + "### Resolved Variant Identity", + "\"id\": \"5H~level2\"", + "FSPK v1 result: `input` is preserved as `5H`; `id` and `name` are omitted.", + "### Advanced Multi-Hit Data", + "\"hits\"", + "FSPK v1 result: `hits[]` is omitted.", + "### Movement Ownership", + "\"movement\"", + "FSPK v1 result: `movement` is not serialized.", + "### Advanced Hurtbox Flags", + "\"advanced_hurtboxes\"", + "FSPK v1 result: `advanced_hurtboxes[]` is omitted.", + "### Super Freeze", + "\"super_freeze\"", + "FSPK v1 result: `super_freeze` is omitted.", + ] { + assert!( + CONTRACT_DOC.contains(required), + "export fidelity docs should include lossy example text: {required}" + ); + } +} + +#[test] +fn production_handoff_decision_documents_json_blob_and_movement_policy() { + for required in [ + "# Production Handoff Decision", + "For the first production target, `json-blob` is the canonical source-of-truth", + "`fspk` v1 is a compact validated runtime pack", + "Movement is `json-blob` only for FSPK v1.", + "cargo run --bin framesmith-cli -- export --project .. --character test_char --adapter json-blob --pretty --out ../exports/test_char.json", + "cargo run --bin framesmith-cli -- export --project .. --character test_char --adapter fspk --out ../exports/test_char.fspk", + "Use FSPK as the canonical handoff only after FSPK v2 or later", + ] { + assert!( + HANDOFF_DOC.contains(required), + "handoff decision should document: {required}" + ); + } + + assert!(CONTRACT_DOC.contains("production-handoff-decision.md")); +} + +#[test] +fn export_fidelity_contract_statuses_are_known_and_explained() { + let contract = load_contract(); + let expected_statuses = BTreeSet::from([ + "derived".to_string(), + "engine-owned".to_string(), + "omitted".to_string(), + "preserved".to_string(), + ]); + assert_eq!(contract.status_values, expected_statuses); + + for (adapter_name, adapter) in &contract.adapters { + for (section_name, fields) in [ + ("character", &adapter.character), + ("state", &adapter.state), + ("cancel_table", &adapter.cancel_table), + ] { + for (field_name, field) in fields { + assert!( + contract.status_values.contains(&field.status), + "{adapter_name}.{section_name}.{field_name} has unknown status '{}'", + field.status + ); + assert!( + !field.notes.trim().is_empty(), + "{adapter_name}.{section_name}.{field_name} needs explanatory notes" + ); + } + } + } +} diff --git a/src-tauri/tests/fspk_roundtrip.rs b/src-tauri/tests/fspk_roundtrip.rs index 2ab37ca..89809d1 100644 --- a/src-tauri/tests/fspk_roundtrip.rs +++ b/src-tauri/tests/fspk_roundtrip.rs @@ -6,7 +6,10 @@ fn make_test_character(id: &str) -> framesmith_lib::schema::Character { use std::collections::BTreeMap; let mut properties = BTreeMap::new(); - properties.insert("archetype".to_string(), PropertyValue::String("test".to_string())); + properties.insert( + "archetype".to_string(), + PropertyValue::String("test".to_string()), + ); properties.insert("health".to_string(), PropertyValue::Number(1000.0)); properties.insert("walk_speed".to_string(), PropertyValue::Number(3.0)); properties.insert("back_walk_speed".to_string(), PropertyValue::Number(3.0)); @@ -54,6 +57,41 @@ fn fspk_export_roundtrips_through_reader() { ); } +#[test] +fn fspk_mesh_keys_include_character_id_and_animation() { + use framesmith_lib::commands::CharacterData; + use framesmith_lib::schema::{CancelTable, GuardType, MeterGain, Pushback, State}; + + let char_data = CharacterData { + character: make_test_character("test_char"), + moves: vec![State { + input: "5L".to_string(), + name: "Test Jab".to_string(), + guard: GuardType::Mid, + animation: "stand_light".to_string(), + pushback: Pushback { hit: 0, block: 0 }, + meter_gain: MeterGain { hit: 0, whiff: 0 }, + ..Default::default() + }], + cancel_table: CancelTable::default(), + }; + + let bytes = codegen::export_fspk(&char_data, None).expect("export"); + let pack = framesmith_fspack::PackView::parse(&bytes).expect("parse"); + + let mesh_keys = pack.mesh_keys().expect("mesh keys"); + let (mesh_off, mesh_len) = mesh_keys.get(0).expect("mesh key 0"); + let mesh_key = pack.string(mesh_off, mesh_len).expect("mesh key string"); + assert_eq!(mesh_key, "test_char.stand_light"); + + let keyframes_keys = pack.keyframes_keys().expect("keyframes keys"); + let (keyframes_off, keyframes_len) = keyframes_keys.get(0).expect("keyframes key 0"); + let keyframes_key = pack + .string(keyframes_off, keyframes_len) + .expect("keyframes key string"); + assert_eq!(keyframes_key, "stand_light"); +} + #[test] fn fspk_move_record_fields_match_reader_layout() { use framesmith_lib::commands::CharacterData; @@ -294,7 +332,8 @@ fn fspk_exports_resources_and_events_sections() { use framesmith_lib::commands::CharacterData; use framesmith_lib::schema::{ CancelTable, CharacterResource, Cost, EventArgValue, EventEmit, GuardType, MeterGain, - StateNotify, OnHit, OnUse, Precondition, Pushback, ResourceDelta, State, TriggerType, + OnBlock, OnHit, OnUse, Precondition, Pushback, ResourceDelta, State, StateNotify, + TriggerType, }; use std::collections::BTreeMap; @@ -367,6 +406,31 @@ fn fspk_exports_resources_and_events_sections() { ..Default::default() }; + // Move 3: on_block event emit + resource delta + let mut block_args = BTreeMap::new(); + block_args.insert( + "surface".to_string(), + EventArgValue::String("guard".to_string()), + ); + let mv3 = State { + input: "5H".to_string(), + name: "Block event".to_string(), + guard: GuardType::Mid, + animation: "stand_heavy".to_string(), + on_block: Some(OnBlock { + events: vec![EventEmit { + id: "sfx.block".to_string(), + args: block_args, + }], + resource_deltas: vec![ResourceDelta { + name: "heat".to_string(), + delta: 2, + }], + ..Default::default() + }), + ..Default::default() + }; + let mut character = make_test_character("t"); character.resources = vec![CharacterResource { name: "heat".to_string(), @@ -392,6 +456,11 @@ fn fspk_exports_resources_and_events_sections() { meter_gain: MeterGain { hit: 0, whiff: 0 }, ..mv2 }, + State { + pushback: Pushback { hit: 0, block: 0 }, + meter_gain: MeterGain { hit: 0, whiff: 0 }, + ..mv3 + }, ], cancel_table: CancelTable::default(), }; @@ -414,7 +483,7 @@ fn fspk_exports_resources_and_events_sections() { // Per-move extras exist and point into backing arrays let extras = pack.state_extras().expect("expected MOVE_EXTRAS section"); - assert_eq!(extras.len(), 3); + assert_eq!(extras.len(), 4); let emits = pack.event_emits().expect("expected EVENT_EMITS section"); let args = pack.event_args().expect("expected EVENT_ARGS section"); @@ -431,6 +500,10 @@ fn fspk_exports_resources_and_events_sections() { .find_state_by_input("236P") .expect("state 236P should exist") .0; + let idx_5h = pack + .find_state_by_input("5H") + .expect("state 5H should exist") + .0; // 5L: on_hit emit -> id + args let ex_5l = extras.get(idx_5l).expect("extras 5L"); @@ -509,6 +582,31 @@ fn fspk_exports_resources_and_events_sections() { d0.trigger(), framesmith_fspack::RESOURCE_DELTA_TRIGGER_ON_USE ); + + // 5H: on_block emit + resource delta + let ex_5h = extras.get(idx_5h).expect("extras 5H"); + let (on_block_off, on_block_len) = ex_5h.on_block_emits(); + assert_eq!(on_block_len, 1); + let block_emit = emits.get_at(on_block_off, 0).expect("5H on_block emit 0"); + let block_id = pack + .string(block_emit.id_off(), block_emit.id_len()) + .expect("on_block emit id"); + assert_eq!(block_id, "sfx.block"); + + let (block_delta_off, block_delta_len) = ex_5h.resource_deltas(); + assert_eq!(block_delta_len, 1); + let block_delta = deltas + .get_at(block_delta_off, 0) + .expect("on_block resource delta"); + let block_delta_name = pack + .string(block_delta.name_off(), block_delta.name_len()) + .expect("on_block delta name"); + assert_eq!(block_delta_name, "heat"); + assert_eq!(block_delta.delta(), 2); + assert_eq!( + block_delta.trigger(), + framesmith_fspack::RESOURCE_DELTA_TRIGGER_ON_BLOCK + ); } #[test] @@ -559,12 +657,103 @@ fn fspk_exports_move_input_notation() { } #[test] -fn tags_survive_roundtrip() { +fn fspk_exports_pushback_and_meter_gain_to_runtime_sections() { use framesmith_lib::commands::CharacterData; use framesmith_lib::schema::{ - CancelTable, GuardType, MeterGain, Pushback, State, Tag, + CancelTable, FrameHitbox, GuardType, MeterGain, Pushback, Rect, State, + }; + + let char_data = CharacterData { + character: make_test_character("t"), + moves: vec![State { + input: "5L".to_string(), + name: "Test Jab".to_string(), + guard: GuardType::Mid, + hitboxes: vec![FrameHitbox { + frames: (5, 7), + r#box: Rect { + x: 8, + y: -52, + w: 28, + h: 16, + }, + }], + hurtboxes: vec![FrameHitbox { + frames: (0, 14), + r#box: Rect { + x: -14, + y: -72, + w: 28, + h: 72, + }, + }], + pushboxes: vec![FrameHitbox { + frames: (0, 14), + r#box: Rect { + x: -15, + y: -72, + w: 30, + h: 72, + }, + }], + animation: "stand_light".to_string(), + pushback: Pushback { hit: 3, block: 6 }, + meter_gain: MeterGain { hit: 6, whiff: 2 }, + ..Default::default() + }], + cancel_table: CancelTable::default(), }; + let bytes = codegen::export_fspk(&char_data, None).expect("export zx-fspack bytes"); + let pack = framesmith_fspack::PackView::parse(&bytes).expect("parse exported pack"); + let (_, state) = pack + .find_state_by_input("5L") + .expect("state 5L should exist"); + + let hit_windows = pack.hit_windows().expect("hit windows section"); + let hit_window = hit_windows + .get_at(state.hit_windows_off(), 0) + .expect("first hit window"); + assert_eq!(hit_window.hit_pushback_px(), 3); + assert_eq!(hit_window.block_pushback_px(), 6); + + let extras = pack.state_extras().expect("state extras"); + let (deltas_off, deltas_len) = extras + .get(state.state_id() as usize) + .expect("extras") + .resource_deltas(); + assert_eq!(deltas_len, 2); + + let deltas = pack.move_resource_deltas().expect("move resource deltas"); + let first = deltas.get_at(deltas_off, 0).expect("whiff meter delta"); + let second = deltas.get_at(deltas_off, 1).expect("hit meter delta"); + + let first_name = pack + .string(first.name_off(), first.name_len()) + .expect("first delta name"); + let second_name = pack + .string(second.name_off(), second.name_len()) + .expect("second delta name"); + + assert_eq!(first_name, "meter"); + assert_eq!(second_name, "meter"); + assert_eq!( + first.trigger(), + framesmith_fspack::RESOURCE_DELTA_TRIGGER_ON_USE + ); + assert_eq!( + second.trigger(), + framesmith_fspack::RESOURCE_DELTA_TRIGGER_ON_HIT + ); + assert_eq!(first.delta(), 2); + assert_eq!(second.delta(), 6); +} + +#[test] +fn tags_survive_roundtrip() { + use framesmith_lib::commands::CharacterData; + use framesmith_lib::schema::{CancelTable, GuardType, MeterGain, Pushback, State, Tag}; + let char_data = CharacterData { character: make_test_character("t"), moves: vec![ @@ -648,8 +837,7 @@ fn empty_tags_roundtrip() { fn cancel_tag_rules_roundtrip() { use framesmith_lib::commands::CharacterData; use framesmith_lib::schema::{ - CancelCondition, CancelTable, CancelTagRule, GuardType, MeterGain, Pushback, - State, Tag, + CancelCondition, CancelTable, CancelTagRule, GuardType, MeterGain, Pushback, State, Tag, }; // Create moves with tags @@ -784,7 +972,7 @@ fn cancel_denies_roundtrip() { #[test] fn test_cancel_condition_bitfield_roundtrip() { - use framesmith_lib::schema::{CancelCondition, CancelTable, CancelTagRule, cancel_flags}; + use framesmith_lib::schema::{cancel_flags, CancelCondition, CancelTable, CancelTagRule}; // Test string shorthand let json = r#"{"from": "normal", "to": "special", "on": "hit"}"#; @@ -803,21 +991,22 @@ fn test_cancel_condition_bitfield_roundtrip() { // Test roundtrip serialization let table = CancelTable { - tag_rules: vec![ - CancelTagRule { - from: "normal".to_string(), - to: "special".to_string(), - on: CancelCondition(cancel_flags::HIT | cancel_flags::BLOCK), // hit + block - after_frame: 0, - before_frame: 255, - }, - ], + tag_rules: vec![CancelTagRule { + from: "normal".to_string(), + to: "special".to_string(), + on: CancelCondition(cancel_flags::HIT | cancel_flags::BLOCK), // hit + block + after_frame: 0, + before_frame: 255, + }], deny: Default::default(), }; let json = serde_json::to_string(&table).unwrap(); let parsed: CancelTable = serde_json::from_str(&json).unwrap(); - assert_eq!(parsed.tag_rules[0].on.0, cancel_flags::HIT | cancel_flags::BLOCK); + assert_eq!( + parsed.tag_rules[0].on.0, + cancel_flags::HIT | cancel_flags::BLOCK + ); } // ============================================================================= @@ -827,7 +1016,7 @@ fn test_cancel_condition_bitfield_roundtrip() { /// Decoded property value from FSPK binary format. #[derive(Debug, PartialEq)] enum DecodedPropValue { - Number(f64), // Q24.8 converted back to f64 + Number(f64), // Q24.8 converted back to f64 Bool(bool), String(String), } @@ -886,6 +1075,62 @@ fn decode_property_records( result } +#[test] +fn character_properties_scalar_survive_roundtrip() { + use framesmith_lib::commands::CharacterData; + use framesmith_lib::schema::{ + CancelTable, Character, GuardType, MeterGain, PropertyValue, Pushback, State, + }; + use std::collections::BTreeMap; + + let mut properties = BTreeMap::new(); + properties.insert("health".to_string(), PropertyValue::Number(1200.0)); + properties.insert("can_air_dash".to_string(), PropertyValue::Bool(true)); + properties.insert( + "archetype".to_string(), + PropertyValue::String("rushdown".to_string()), + ); + + let char_data = CharacterData { + character: Character { + id: "test_char".to_string(), + name: "Test Character".to_string(), + properties, + resources: vec![], + }, + moves: vec![State { + input: "5L".to_string(), + name: "Test Jab".to_string(), + guard: GuardType::Mid, + animation: "stand_light".to_string(), + pushback: Pushback { hit: 0, block: 0 }, + meter_gain: MeterGain { hit: 0, whiff: 0 }, + ..Default::default() + }], + cancel_table: CancelTable::default(), + }; + + let bytes = codegen::export_fspk(&char_data, None).expect("export"); + let pack = framesmith_fspack::PackView::parse(&bytes).expect("parse"); + let props_raw = pack + .get_section(framesmith_fspack::SECTION_CHARACTER_PROPS) + .expect("CHARACTER_PROPS section"); + let decoded = decode_property_records(props_raw, pack.string_pool()); + + assert_eq!( + decoded.get("health"), + Some(&DecodedPropValue::Number(1200.0)) + ); + assert_eq!( + decoded.get("can_air_dash"), + Some(&DecodedPropValue::Bool(true)) + ); + assert_eq!( + decoded.get("archetype"), + Some(&DecodedPropValue::String("rushdown".to_string())) + ); +} + #[test] fn state_properties_scalar_survive_roundtrip() { use framesmith_lib::commands::CharacterData; @@ -897,7 +1142,10 @@ fn state_properties_scalar_survive_roundtrip() { let mut props = BTreeMap::new(); props.insert("custom_startup".to_string(), PropertyValue::Number(5.0)); props.insert("is_ex".to_string(), PropertyValue::Bool(true)); - props.insert("effect".to_string(), PropertyValue::String("spark".to_string())); + props.insert( + "effect".to_string(), + PropertyValue::String("spark".to_string()), + ); let char_data = CharacterData { character: make_test_character("t"), @@ -919,7 +1167,8 @@ fn state_properties_scalar_survive_roundtrip() { // Verify STATE_PROPS section exists assert!( - pack.get_section(framesmith_fspack::SECTION_STATE_PROPS).is_some(), + pack.get_section(framesmith_fspack::SECTION_STATE_PROPS) + .is_some(), "STATE_PROPS section should exist" ); @@ -931,7 +1180,10 @@ fn state_properties_scalar_survive_roundtrip() { let decoded = decode_property_records(props_raw, string_pool); assert_eq!(decoded.len(), 3); - assert_eq!(decoded.get("custom_startup"), Some(&DecodedPropValue::Number(5.0))); + assert_eq!( + decoded.get("custom_startup"), + Some(&DecodedPropValue::Number(5.0)) + ); assert_eq!(decoded.get("is_ex"), Some(&DecodedPropValue::Bool(true))); assert_eq!( decoded.get("effect"), @@ -950,7 +1202,10 @@ fn state_properties_nested_flattened_on_export() { // Create nested Object - will be flattened to "movement.distance", "movement.direction" let mut movement = BTreeMap::new(); movement.insert("distance".to_string(), PropertyValue::Number(80.0)); - movement.insert("direction".to_string(), PropertyValue::String("forward".to_string())); + movement.insert( + "direction".to_string(), + PropertyValue::String("forward".to_string()), + ); // Create nested Array - will be flattened to "effects.0", "effects.1", "effects.2" let effects = vec![ @@ -960,7 +1215,10 @@ fn state_properties_nested_flattened_on_export() { ]; let mut props = BTreeMap::new(); - props.insert("movement".to_string(), PropertyValue::Object(movement.clone())); + props.insert( + "movement".to_string(), + PropertyValue::Object(movement.clone()), + ); props.insert("effects".to_string(), PropertyValue::Array(effects.clone())); let char_data = CharacterData { @@ -986,16 +1244,28 @@ fn state_properties_nested_flattened_on_export() { let decoded = decode_property_records(props_raw, string_pool); // Nested Object is flattened with dot notation - assert_eq!(decoded.get("movement.distance"), Some(&DecodedPropValue::Number(80.0))); + assert_eq!( + decoded.get("movement.distance"), + Some(&DecodedPropValue::Number(80.0)) + ); assert_eq!( decoded.get("movement.direction"), Some(&DecodedPropValue::String("forward".to_string())) ); // Nested Array is flattened with index notation - assert_eq!(decoded.get("effects.0"), Some(&DecodedPropValue::String("spark".to_string()))); - assert_eq!(decoded.get("effects.1"), Some(&DecodedPropValue::Number(2.0))); - assert_eq!(decoded.get("effects.2"), Some(&DecodedPropValue::Bool(true))); + assert_eq!( + decoded.get("effects.0"), + Some(&DecodedPropValue::String("spark".to_string())) + ); + assert_eq!( + decoded.get("effects.1"), + Some(&DecodedPropValue::Number(2.0)) + ); + assert_eq!( + decoded.get("effects.2"), + Some(&DecodedPropValue::Bool(true)) + ); // Total: 2 from movement + 3 from effects = 5 flattened properties assert_eq!(decoded.len(), 5); @@ -1004,9 +1274,7 @@ fn state_properties_nested_flattened_on_export() { #[test] fn state_without_properties_has_no_props_raw() { use framesmith_lib::commands::CharacterData; - use framesmith_lib::schema::{ - CancelTable, GuardType, MeterGain, Pushback, State, - }; + use framesmith_lib::schema::{CancelTable, GuardType, MeterGain, Pushback, State}; // State with empty properties let char_data = CharacterData { @@ -1029,7 +1297,8 @@ fn state_without_properties_has_no_props_raw() { // STATE_PROPS section should not exist when no state has properties assert!( - pack.get_section(framesmith_fspack::SECTION_STATE_PROPS).is_none(), + pack.get_section(framesmith_fspack::SECTION_STATE_PROPS) + .is_none(), "STATE_PROPS section should not exist when no states have properties" ); @@ -1168,13 +1437,14 @@ fn schema_section_present_when_rules_have_property_schema() { // Verify SECTION_SCHEMA exists assert!( - pack.get_section(framesmith_fspack::SECTION_SCHEMA).is_some(), + pack.get_section(framesmith_fspack::SECTION_SCHEMA) + .is_some(), "SECTION_SCHEMA should be present when rules have property schema" ); // Verify schema can be read let schema = pack.schema().expect("schema should be parseable"); - assert_eq!(schema.char_prop_count(), 8); // 8 character props + assert_eq!(schema.char_prop_count(), 8); // 8 character props assert_eq!(schema.state_prop_count(), 2); // 2 state props assert_eq!(schema.tag_count(), 2); @@ -1212,7 +1482,8 @@ fn schema_section_absent_when_no_property_schema() { // Verify SECTION_SCHEMA is absent assert!( - pack.get_section(framesmith_fspack::SECTION_SCHEMA).is_none(), + pack.get_section(framesmith_fspack::SECTION_SCHEMA) + .is_none(), "SECTION_SCHEMA should be absent when no property schema" ); assert!(pack.schema().is_none()); @@ -1279,7 +1550,9 @@ fn schema_based_property_records_are_8_bytes() { ); // Verify we can read schema-based properties - let schema_props = pack.schema_character_props().expect("schema character props"); + let schema_props = pack + .schema_character_props() + .expect("schema character props"); assert_eq!(schema_props.len(), 2); // Get the schema for name lookups @@ -1287,7 +1560,9 @@ fn schema_based_property_records_are_8_bytes() { // Read the properties let prop0 = schema_props.get(0).expect("prop 0"); - let prop0_name = schema.char_prop_name(prop0.schema_id()).expect("prop 0 name"); + let prop0_name = schema + .char_prop_name(prop0.schema_id()) + .expect("prop 0 name"); assert!(prop0_name == "health" || prop0_name == "walkSpeed"); } @@ -1335,7 +1610,10 @@ fn export_with_schema_rejects_unknown_property() { // Export should fail with helpful error let result = codegen::export_fspk(&char_data, Some(&merged)); - assert!(result.is_err(), "Export should fail when property not in schema"); + assert!( + result.is_err(), + "Export should fail when property not in schema" + ); let err = result.unwrap_err(); assert!( err.contains("unknownProp") || err.contains("unknown"), diff --git a/src-tauri/tests/mcp_commands_test.rs b/src-tauri/tests/mcp_commands_test.rs index f83d6e8..45a29c6 100644 --- a/src-tauri/tests/mcp_commands_test.rs +++ b/src-tauri/tests/mcp_commands_test.rs @@ -4,7 +4,7 @@ //! for both happy paths and error cases. use framesmith_lib::commands::{ - create_character, create_move, delete_character, list_characters, load_character, + create_character, create_move, delete_character, list_characters, load_character, save_move, }; use std::fs; use std::path::Path; @@ -59,7 +59,8 @@ fn create_test_character(characters_dir: &str, id: &str) { fs::create_dir_all(char_path.join("states")).unwrap(); // Create cancel_table.json - let cancel_json = r#"{"chains": {}, "special_cancels": [], "super_cancels": [], "jump_cancels": []}"#; + let cancel_json = + r#"{"chains": {}, "special_cancels": [], "super_cancels": [], "jump_cancels": []}"#; fs::write(char_path.join("cancel_table.json"), cancel_json).unwrap(); // Create empty rules.json for character @@ -267,6 +268,39 @@ fn test_create_move_duplicate_returns_error() { assert!(result.unwrap_err().contains("already exists")); } +#[test] +fn save_move_rejects_resolved_variant_to_protect_base_state() { + let temp_dir = TempDir::new().unwrap(); + let characters_dir = setup_test_project(&temp_dir); + + create_test_character(&characters_dir, "test-char"); + + let mut mv = create_move( + characters_dir.clone(), + "test-char".to_string(), + "5H".to_string(), + "Heavy Punch".to_string(), + ) + .unwrap(); + mv.id = Some("5H~level1".to_string()); + mv.damage = 900; + + let result = save_move(characters_dir.clone(), "test-char".to_string(), mv); + + assert!(result.is_err()); + assert!(result + .unwrap_err() + .contains("Resolved variant states are read-only")); + + let state_path = Path::new(&characters_dir) + .join("test-char") + .join("states") + .join("5H.json"); + let saved: serde_json::Value = + serde_json::from_str(&fs::read_to_string(state_path).unwrap()).unwrap(); + assert_eq!(saved["damage"], 500); +} + #[test] fn test_create_move_validates_input() { let temp_dir = TempDir::new().unwrap(); diff --git a/src-tauri/tests/pipeline_e2e.rs b/src-tauri/tests/pipeline_e2e.rs index 4936708..7f5e916 100644 --- a/src-tauri/tests/pipeline_e2e.rs +++ b/src-tauri/tests/pipeline_e2e.rs @@ -8,11 +8,8 @@ use framesmith_lib::{codegen, commands}; #[test] fn full_pipeline_load_export_parse_verify() { // Step 1: Load real test_char - let char_data = commands::load_character( - "../characters".to_string(), - "test_char".to_string(), - ) - .expect("load test_char"); + let char_data = commands::load_character("../characters".to_string(), "test_char".to_string()) + .expect("load test_char"); // Verify loaded data is non-trivial assert!(!char_data.moves.is_empty(), "test_char should have moves"); @@ -70,9 +67,7 @@ fn full_pipeline_load_export_parse_verify() { ); // Verify resources section - let resources = pack - .resource_defs() - .expect("resource defs should exist"); + let resources = pack.resource_defs().expect("resource defs should exist"); assert!( resources.len() >= 2, "test_char should have at least 2 resources, got {}", @@ -82,11 +77,8 @@ fn full_pipeline_load_export_parse_verify() { #[test] fn pipeline_all_moves_have_valid_extras() { - let char_data = commands::load_character( - "../characters".to_string(), - "test_char".to_string(), - ) - .expect("load test_char"); + let char_data = commands::load_character("../characters".to_string(), "test_char".to_string()) + .expect("load test_char"); let bytes = codegen::export_fspk(&char_data, None).expect("export FSPK"); let pack = framesmith_fspack::PackView::parse(&bytes).expect("parse FSPK"); @@ -94,11 +86,7 @@ fn pipeline_all_moves_have_valid_extras() { let states = pack.states().expect("states section"); let extras = pack.state_extras().expect("state extras section"); - assert_eq!( - states.len(), - extras.len(), - "every state should have extras" - ); + assert_eq!(states.len(), extras.len(), "every state should have extras"); // Verify each move's input can be read from extras for i in 0..extras.len() { @@ -117,11 +105,8 @@ fn pipeline_all_moves_have_valid_extras() { #[test] fn pipeline_tags_survive_full_chain() { - let char_data = commands::load_character( - "../characters".to_string(), - "test_char".to_string(), - ) - .expect("load test_char"); + let char_data = commands::load_character("../characters".to_string(), "test_char".to_string()) + .expect("load test_char"); // Count moves with tags let moves_with_tags: Vec<_> = char_data diff --git a/src-tauri/tests/production_docs.rs b/src-tauri/tests/production_docs.rs new file mode 100644 index 0000000..06293d9 --- /dev/null +++ b/src-tauri/tests/production_docs.rs @@ -0,0 +1,233 @@ +const PRODUCTION_PLAN: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../docs/production-readiness-plan.md" +)); +const DOCS_INDEX: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../docs/README.md")); +const DATA_FORMATS: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../docs/data-formats.md" +)); +const ARCHITECTURE_DOC: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../docs/architecture.md" +)); +const VARIANT_DECISION: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../docs/variant-editing-decision.md" +)); +const IMPLEMENTATION_HISTORY: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../docs/implementation-history.md" +)); +const SCHEMA_MIGRATION: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../docs/schema-migration.md" +)); +const TRAINING_SCENARIO_CONTRACT: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../docs/training-scenario-contract.md" +)); +const WINDOWS_INSTALLER_SMOKE_TEST: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../docs/windows-installer-smoke-test.md" +)); +const PRODUCTION_GAP_BACKLOG: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../docs/production-gap-backlog.md" +)); +const RELEASE_RUNBOOK: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../docs/release-runbook.md" +)); +const CHARACTER_COMMANDS: &str = include_str!("../src/commands/character.rs"); + +#[test] +fn variant_editing_deferral_is_documented_and_linked() { + for required in [ + "# Variant Editing Decision", + "Overlay-aware variant editing is explicitly deferred for the first production", + "Variant overlays remain JSON-authored files", + "Saving a resolved variant through the State Editor, MCP `update_move`, or", + "Save only the overlay diff", + "Resolved variants cannot overwrite base state files or overlay files through", + ] { + assert!( + VARIANT_DECISION.contains(required), + "variant editing decision should document: {required}" + ); + } + + for linked_doc in [PRODUCTION_PLAN, DOCS_INDEX, DATA_FORMATS, ARCHITECTURE_DOC] { + assert!( + linked_doc.contains("variant-editing-decision.md"), + "permanent docs should link variant-editing-decision.md" + ); + } + + assert!(PRODUCTION_PLAN + .contains("[x] Overlay-aware variant editing is implemented or explicitly deferred for")); +} + +#[test] +fn save_move_guard_stays_aligned_with_variant_editing_policy() { + assert!(CHARACTER_COMMANDS.contains("if let Some(id) = mv.id.as_deref()")); + assert!(CHARACTER_COMMANDS.contains("if id != mv.input")); + assert!(CHARACTER_COMMANDS.contains("Resolved variant states are read-only via save_move")); +} + +#[test] +fn temporary_plan_docs_are_migrated_or_removed() { + let plans_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../docs/plans"); + if plans_dir.exists() { + let remaining_plans: Vec<_> = std::fs::read_dir(&plans_dir) + .expect("docs/plans should be readable") + .filter_map(Result::ok) + .filter(|entry| entry.path().extension().is_some_and(|ext| ext == "md")) + .map(|entry| entry.path()) + .collect(); + + assert!( + remaining_plans.is_empty(), + "completed temporary plans should be migrated or removed: {remaining_plans:?}" + ); + } + + for required in [ + "# Implementation History", + "This document replaces completed temporary plans", + "Variant overlay system", + "FSPK module refactor and adapter rename", + "Do not keep completed implementation plans under `docs/plans/`.", + ] { + assert!( + IMPLEMENTATION_HISTORY.contains(required), + "implementation history should document: {required}" + ); + } + + assert!(DOCS_INDEX.contains("implementation-history.md")); + assert!(PRODUCTION_PLAN.contains("[x] Stale temporary plans are migrated or removed.")); +} + +#[test] +fn schema_migration_notes_are_documented_and_linked() { + for required in [ + "# Schema Migration Notes", + "Move them into", + "`character.properties`", + "`tag_rules[]` plus `deny`", + "Resolved variants are read-only editor snapshots.", + "The `zx-fspack` adapter name remains as a compatibility alias", + "cargo test --manifest-path src-tauri/Cargo.toml --test export_fidelity_contract", + ] { + assert!( + SCHEMA_MIGRATION.contains(required), + "schema migration notes should document: {required}" + ); + } + + assert!(DOCS_INDEX.contains("schema-migration.md")); + assert!(PRODUCTION_PLAN.contains("Added [`schema-migration.md`](schema-migration.md)")); +} + +#[test] +fn training_scenario_contract_is_documented_and_linked() { + for required in [ + "# Training Scenario Contract", + "Authored hitstun", + "Authored blockstun", + "Resource policy", + "Throw input policy", + "Detached training smoke", + "target_training_fixture_resolves_authored_reaction_states", + "loads detached training mode through BroadcastChannel sync", + ] { + assert!( + TRAINING_SCENARIO_CONTRACT.contains(required), + "training scenario contract should document: {required}" + ); + } + + assert!(DOCS_INDEX.contains("training-scenario-contract.md")); + assert!(PRODUCTION_PLAN + .contains("Added [`training-scenario-contract.md`](training-scenario-contract.md)")); + assert!(PRODUCTION_PLAN + .contains("[x] Target-game training scenarios cover hitstun/blockstun/resource/throw")); +} + +#[test] +fn windows_installer_smoke_test_is_documented_and_linked() { + for required in [ + "# Windows Installer Smoke Test", + "Framesmith__x64_en-US.msi", + "Framesmith__x64-setup.exe", + "Training starts from the packaged WASM and FSPK path.", + "Evidence To Record", + ] { + assert!( + WINDOWS_INSTALLER_SMOKE_TEST.contains(required), + "installer smoke test should document: {required}" + ); + } + + assert!(DOCS_INDEX.contains("windows-installer-smoke-test.md")); + assert!(PRODUCTION_PLAN.contains("windows-installer-smoke-test.md")); +} + +#[test] +fn production_gap_backlog_covers_external_and_target_game_gaps() { + for required in [ + "# Production Gap Backlog", + "PROD-CI-001", + "PROD-CI-002", + "PROD-WIN-001", + "FSPK-MOVE-001", + "FSPK-HIT-001", + "RUNTIME-THROW-001", + "RUNTIME-FREEZE-001", + "RUNTIME-RESOURCE-001", + "RUNTIME-STAGE-001", + "RUNTIME-EVENT-001", + "PLATFORM-LINUX-001", + "PLATFORM-MAC-001", + ] { + assert!( + PRODUCTION_GAP_BACKLOG.contains(required), + "production gap backlog should document: {required}" + ); + } + + for linked_doc in [DOCS_INDEX, PRODUCTION_PLAN, TRAINING_SCENARIO_CONTRACT] { + assert!( + linked_doc.contains("production-gap-backlog.md"), + "permanent docs should link production-gap-backlog.md" + ); + } +} + +#[test] +fn release_runbook_covers_candidate_evidence() { + for required in [ + "# Release Runbook", + "package.json", + "src-tauri/Cargo.toml", + "src-tauri/tauri.conf.json", + "npm ci", + "npm audit", + "npm ls @tauri-apps/api @tauri-apps/cli @tauri-apps/plugin-opener", + "git diff --exit-code -- schemas/rules.schema.json src/lib/wasm", + "GitHub Actions URL", + "Protected branch/ruleset", + "windows-installer-smoke-test.md", + "Final Evidence Template", + ] { + assert!( + RELEASE_RUNBOOK.contains(required), + "release runbook should document: {required}" + ); + } + + assert!(DOCS_INDEX.contains("release-runbook.md")); + assert!(PRODUCTION_PLAN.contains("release-runbook.md")); + assert!(PRODUCTION_PLAN.contains("[x] Release runbook exists for clean-checkout")); +} diff --git a/src/lib/components/GlobalStateEditor.svelte b/src/lib/components/GlobalStateEditor.svelte index 8b30439..ea5d00b 100644 --- a/src/lib/components/GlobalStateEditor.svelte +++ b/src/lib/components/GlobalStateEditor.svelte @@ -13,6 +13,10 @@ let saving = $state(false); let saveError = $state(null); + function cloneState(state: State): State { + return $state.snapshot(state) as State; + } + const currentState = $derived(getCurrentGlobalState()); const selectedId = $derived(getSelectedGlobalId()); const storeError = $derived(getError()); @@ -20,7 +24,7 @@ // Sync local state when selection changes $effect(() => { if (currentState) { - editingState = structuredClone(currentState); + editingState = cloneState(currentState); hasChanges = false; saveError = null; } else { @@ -53,7 +57,7 @@ function handleRevert() { if (currentState) { - editingState = structuredClone(currentState); + editingState = cloneState(currentState); hasChanges = false; saveError = null; } diff --git a/src/lib/components/training/DummySettings.svelte b/src/lib/components/training/DummySettings.svelte index d3f5a86..8757764 100644 --- a/src/lib/components/training/DummySettings.svelte +++ b/src/lib/components/training/DummySettings.svelte @@ -10,12 +10,19 @@ */ import type { DummyConfig, DummyState, DummyRecovery } from '$lib/training'; + import type { CharacterSummary } from '$lib/types'; interface Props { /** Current dummy configuration. */ config: DummyConfig; /** Available moves for reversal selection. */ availableMoves?: string[]; + /** Available characters for dummy pack selection. */ + availableCharacters?: CharacterSummary[]; + /** Currently selected dummy character ID. */ + selectedCharacterId?: string; + /** Callback when dummy character changes. */ + onCharacterChange?: (characterId: string) => void; /** Callback when state changes. */ onStateChange?: (state: DummyState) => void; /** Callback when recovery changes. */ @@ -33,6 +40,9 @@ let { config, availableMoves = [], + availableCharacters = [], + selectedCharacterId = '', + onCharacterChange, onStateChange, onRecoveryChange, onReversalMoveChange, @@ -62,6 +72,11 @@ onStateChange?.(target.value as DummyState); } + function handleCharacterChange(event: Event) { + const target = event.target as HTMLSelectElement; + onCharacterChange?.(target.value); + } + function handleRecoveryChange(event: Event) { const target = event.target as HTMLSelectElement; onRecoveryChange?.(target.value as DummyRecovery); @@ -87,6 +102,18 @@ {#if !collapsed}
+ + {#if availableCharacters.length > 0} +
+ + +
+ {/if} +
diff --git a/src/lib/schemaFixture.test.ts b/src/lib/schemaFixture.test.ts new file mode 100644 index 0000000..70de7d2 --- /dev/null +++ b/src/lib/schemaFixture.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import type { CancelTable, Character } from '$lib/types'; + +function readJson(relativePath: string): T { + return JSON.parse(readFileSync(join(process.cwd(), relativePath), 'utf8')) as T; +} + +function hasOwn(value: object, key: string): boolean { + return Object.prototype.hasOwnProperty.call(value, key); +} + +describe('checked sample schema fixtures', () => { + it('uses the property-based character schema for test_char', () => { + const character = readJson('characters/test_char/character.json'); + + expect(character.id).toBe('test_char'); + expect(character.properties.archetype).toBe('all-rounder'); + expect(character.properties.health).toBe(1000); + expect(Array.isArray(character.resources)).toBe(true); + expect(character.resources[0]).toEqual({ name: 'heat', start: 0, max: 100 }); + + expect(hasOwn(character, 'archetype')).toBe(false); + expect(hasOwn(character, 'health')).toBe(false); + expect(hasOwn(character, 'walk_speed')).toBe(false); + }); + + it('uses tag-rule cancel tables for test_char', () => { + const cancelTable = readJson('characters/test_char/cancel_table.json'); + + expect(cancelTable.tag_rules.length).toBeGreaterThan(0); + expect(cancelTable.tag_rules).toContainEqual({ from: 'system', to: 'any', on: 'always' }); + expect(cancelTable.deny).toEqual({}); + + expect(hasOwn(cancelTable, 'chains')).toBe(false); + expect(hasOwn(cancelTable, 'special_cancels')).toBe(false); + expect(hasOwn(cancelTable, 'super_cancels')).toBe(false); + expect(hasOwn(cancelTable, 'jump_cancels')).toBe(false); + }); +}); diff --git a/src/lib/stores/character.svelte.ts b/src/lib/stores/character.svelte.ts index 361d5b5..2f7ca5d 100644 --- a/src/lib/stores/character.svelte.ts +++ b/src/lib/stores/character.svelte.ts @@ -3,6 +3,7 @@ import type { CharacterData, CharacterSummary, State, MergedRegistry } from "$li import { loadAssets, resetAssetsState } from "./assets.svelte"; import { getProjectPath } from "./project.svelte"; import { TrainingSync, createMainWindowSync } from "$lib/training"; +import { getStateKey, isResolvedVariantState } from "$lib/utils"; // Training sync instance (created lazily) let trainingSync: TrainingSync | null = null; @@ -47,7 +48,7 @@ function notifyCharacterSave(): void { let characterList = $state([]); let currentCharacter = $state(null); let rulesRegistry = $state(null); -let selectedMoveInput = $state(null); +let selectedMoveKey = $state(null); let loading = $state(false); let error = $state(null); @@ -82,12 +83,16 @@ export function getRulesRegistry() { } export function getSelectedMove(): State | null { - if (!currentCharacter || !selectedMoveInput) return null; - return currentCharacter.moves.find((m) => m.input === selectedMoveInput) ?? null; + if (!currentCharacter || !selectedMoveKey) return null; + return currentCharacter.moves.find((m) => getStateKey(m) === selectedMoveKey) ?? null; } export function getSelectedMoveInput() { - return selectedMoveInput; + return selectedMoveKey; +} + +export function getSelectedMoveKey() { + return selectedMoveKey; } /** @@ -95,12 +100,15 @@ export function getSelectedMoveInput() { * Use this when $derived(getterFn()) doesn't track properly. */ export const characterStore = { + get selectedMoveKey() { + return selectedMoveKey; + }, get selectedMoveInput() { - return selectedMoveInput; + return selectedMoveKey; }, get selectedMove(): State | null { - if (!currentCharacter || !selectedMoveInput) return null; - return currentCharacter.moves.find((m) => m.input === selectedMoveInput) ?? null; + if (!currentCharacter || !selectedMoveKey) return null; + return currentCharacter.moves.find((m) => getStateKey(m) === selectedMoveKey) ?? null; }, get currentCharacter() { return currentCharacter; @@ -150,7 +158,7 @@ export async function selectCharacter(characterId: string): Promise { const seq = ++selectSeq; loading = true; error = null; - selectedMoveInput = null; + selectedMoveKey = null; rulesRegistry = null; resetAssetsState(); try { @@ -186,15 +194,15 @@ export async function selectCharacter(characterId: string): Promise { } } -export function selectMove(input: string): void { - selectedMoveInput = input; +export function selectMove(key: string): void { + selectedMoveKey = key; } export function clearSelection(): void { selectSeq++; currentCharacter = null; rulesRegistry = null; - selectedMoveInput = null; + selectedMoveKey = null; resetAssetsState(); } @@ -203,7 +211,7 @@ export function resetCharacterState(): void { characterList = []; currentCharacter = null; rulesRegistry = null; - selectedMoveInput = null; + selectedMoveKey = null; error = null; resetAssetsState(); } @@ -216,20 +224,27 @@ export async function saveMove(mv: State): Promise { if (!currentCharacter) { throw new Error("No character selected"); } + if (isResolvedVariantState(mv)) { + throw new Error( + "Resolved variant states are read-only in the State Editor. Edit the overlay JSON directly until overlay-aware editing is implemented." + ); + } + const savedMove = $state.snapshot(mv) as State; loading = true; error = null; try { await invoke("save_move", { charactersDir, characterId: currentCharacter.character.id, - mv, + mv: savedMove, }); // Update local state - const index = currentCharacter.moves.findIndex((m) => m.input === mv.input); + const savedMoveKey = getStateKey(savedMove); + const index = currentCharacter.moves.findIndex((m) => getStateKey(m) === savedMoveKey); if (index >= 0) { - currentCharacter.moves[index] = mv; + currentCharacter.moves[index] = savedMove; } // Notify training windows of save @@ -320,7 +335,7 @@ export async function deleteCharacter(characterId: string): Promise { // Clear selection if deleted character was selected if (currentCharacter?.character.id === characterId) { currentCharacter = null; - selectedMoveInput = null; + selectedMoveKey = null; resetAssetsState(); } @@ -348,7 +363,7 @@ export async function createMove(input: string, name: string): Promise { currentCharacter.moves = [...currentCharacter.moves, mv]; // Select the new move - selectedMoveInput = mv.input; + selectedMoveKey = getStateKey(mv); return mv; } diff --git a/src/lib/training/InputBuffer.test.ts b/src/lib/training/InputBuffer.test.ts index 9cad48b..5ebb1bd 100644 --- a/src/lib/training/InputBuffer.test.ts +++ b/src/lib/training/InputBuffer.test.ts @@ -44,6 +44,30 @@ describe('InputBuffer', () => { it('should return null for latest when empty', () => { expect(buffer.latest()).toBeNull(); }); + + it('should snapshot and restore input history', () => { + buffer.push({ direction: 2, buttons: ['L'] }); + buffer.push({ direction: 3, buttons: ['M'] }); + + const snapshot = buffer.snapshot(); + buffer.clear(); + buffer.push({ direction: 6, buttons: ['H'] }); + buffer.restore(snapshot); + + expect(buffer.length).toBe(2); + expect(buffer.latest()).toEqual({ direction: 3, buttons: ['M'] }); + }); + + it('should not share mutable button arrays with snapshots', () => { + const input: InputSnapshot = { direction: 5, buttons: ['L'] }; + buffer.push(input); + + const snapshot = buffer.snapshot(); + input.buttons.push('M'); + snapshot[0].buttons.push('H'); + + expect(buffer.latest()).toEqual({ direction: 5, buttons: ['L'] }); + }); }); describe('numpad direction values', () => { diff --git a/src/lib/training/InputBuffer.ts b/src/lib/training/InputBuffer.ts index ce1f074..dc87f82 100644 --- a/src/lib/training/InputBuffer.ts +++ b/src/lib/training/InputBuffer.ts @@ -6,7 +6,7 @@ */ /** Button names used in the input system. */ -export type ButtonName = 'L' | 'M' | 'H' | 'P' | 'K' | 'S'; +export type ButtonName = 'L' | 'M' | 'H' | 'P' | 'K' | 'S' | 'T'; /** * A snapshot of input state at a single frame. @@ -25,6 +25,9 @@ export interface InputSnapshot { buttons: ButtonName[]; } +/** Serializable copy of the input buffer contents. */ +export type InputBufferSnapshot = InputSnapshot[]; + /** * Pattern for detecting motion inputs (quarter circles, dragon punches, etc.) */ @@ -103,7 +106,10 @@ export class InputBuffer { if (this.buffer.length >= this.capacity) { this.buffer.shift(); } - this.buffer.push(snapshot); + this.buffer.push({ + direction: snapshot.direction, + buttons: [...snapshot.buttons], + }); } /** @@ -123,6 +129,26 @@ export class InputBuffer { this.buffer = []; } + /** + * Capture the current buffer contents for deterministic rewind. + */ + snapshot(): InputBufferSnapshot { + return this.buffer.map(snapshot => ({ + direction: snapshot.direction, + buttons: [...snapshot.buttons], + })); + } + + /** + * Restore a previous buffer snapshot. + */ + restore(snapshot: InputBufferSnapshot): void { + this.buffer = snapshot.slice(-this.capacity).map(input => ({ + direction: input.direction, + buttons: [...input.buttons], + })); + } + /** * Detect a motion input (e.g., 236P for fireball). * diff --git a/src/lib/training/InputManager.test.ts b/src/lib/training/InputManager.test.ts index bfefd93..19e0dc3 100644 --- a/src/lib/training/InputManager.test.ts +++ b/src/lib/training/InputManager.test.ts @@ -20,6 +20,7 @@ describe('InputManager', () => { P: 'KeyJ', K: 'KeyK', S: 'KeyL', + T: 'KeyP', }, }; @@ -233,6 +234,7 @@ describe('InputManager', () => { P: 'KeyA', K: 'KeyS', S: 'KeyD', + T: 'KeyF', }, }; diff --git a/src/lib/training/TrainingSession.ts b/src/lib/training/TrainingSession.ts index 24438bf..9356027 100644 --- a/src/lib/training/TrainingSession.ts +++ b/src/lib/training/TrainingSession.ts @@ -41,6 +41,7 @@ function toWasmDummyState(state: DummyState): number { export interface CharacterState { current_state: number; frame: number; + instance_duration: number; hit_confirmed: boolean; block_confirmed: boolean; resources: number[]; @@ -52,6 +53,7 @@ export interface CharacterState { export interface HitResult { attacker_move: number; window_index: number; + blocked: boolean; damage: number; chip_damage: number; hitstun: number; @@ -80,6 +82,18 @@ export interface FrameResult { push_separation?: PushSeparation; } +/** + * Deterministic session snapshot for frame step-back. + */ +export interface TrainingSnapshot { + player: CharacterState; + dummy: CharacterState; + player_x: number; + player_y: number; + dummy_x: number; + dummy_y: number; +} + /** * No move requested (continue current move). */ @@ -193,6 +207,20 @@ export class TrainingSession { return this.session.hit_results(); } + /** + * Capture the current deterministic session state. + */ + snapshot(): TrainingSnapshot { + return this.session.snapshot(); + } + + /** + * Restore a snapshot previously returned by snapshot(). + */ + restore(snapshot: TrainingSnapshot): void { + this.session.restore(snapshot); + } + /** * Reset the session to initial state. */ diff --git a/src/lib/training/TrainingSync.test.ts b/src/lib/training/TrainingSync.test.ts index bf94d08..7f500ef 100644 --- a/src/lib/training/TrainingSync.test.ts +++ b/src/lib/training/TrainingSync.test.ts @@ -16,21 +16,22 @@ const mockCharacterData: CharacterData = { character: { id: 'test-char', name: 'Test Character', - archetype: 'Test', - health: 10000, - walk_speed: 4.0, - back_walk_speed: 3.0, - jump_height: 120, - jump_duration: 45, - dash_distance: 80, - dash_duration: 18, + properties: { + archetype: 'Test', + health: 10000, + walk_speed: 4.0, + back_walk_speed: 3.0, + jump_height: 120, + jump_duration: 45, + dash_distance: 80, + dash_duration: 18, + }, + resources: [], }, moves: [], cancel_table: { - chains: {}, - special_cancels: [], - super_cancels: [], - jump_cancels: [], + tag_rules: [], + deny: {}, }, }; diff --git a/src/lib/training/buildMoveList.test.ts b/src/lib/training/buildMoveList.test.ts index fdaccfe..c5cb9cd 100644 --- a/src/lib/training/buildMoveList.test.ts +++ b/src/lib/training/buildMoveList.test.ts @@ -47,6 +47,17 @@ describe('buildMoveList', () => { expect(resolver.resolve(buffer, ['L'])).toBeNull(); }); + it('supports authored throw inputs that use the T button', () => { + const resolver = new MoveResolver(buildMoveList([{ input: '5T', type: 'throw' }])); + const buffer = new InputBuffer(); + buffer.push({ direction: 5, buttons: ['T'] }); + + const result = resolver.resolve(buffer, ['T']); + + expect(result?.name).toBe('5T'); + expect(result?.index).toBe(0); + }); + it('treats motion inputs containing 0 as unparseable (index preserved)', () => { const moves = [{ input: '2360P', type: 'special' }]; const list = buildMoveList(moves); @@ -55,4 +66,17 @@ describe('buildMoveList', () => { expect(list.moves[0]?.name).toBe('2360P'); expect(list.moves[0]?.input.type).not.toBe('motion'); }); + + it('preserves indices for malformed move entries without registering them as inputs', () => { + const list = buildMoveList([ + { input: '5L', type: 'normal' }, + { name: 'Variant Overlay', type: 'normal' }, + { input: '5M', type: 'normal' }, + ]); + + expect(list.moves.map(m => m.name)).toEqual(['5L', 'Variant Overlay', '5M']); + expect(list.moveNameToIndex.get('5L')).toBe(0); + expect(list.moveNameToIndex.get('Variant Overlay')).toBeUndefined(); + expect(list.moveNameToIndex.get('5M')).toBe(2); + }); }); diff --git a/src/lib/training/buildMoveList.ts b/src/lib/training/buildMoveList.ts index b66ba81..94278a3 100644 --- a/src/lib/training/buildMoveList.ts +++ b/src/lib/training/buildMoveList.ts @@ -2,7 +2,8 @@ import type { ButtonName } from './InputBuffer'; import type { MoveDefinition, MoveList } from './MoveResolver'; export interface CanonicalMoveRef { - input: string; + input?: string | null; + name?: string | null; type?: string; } @@ -14,7 +15,7 @@ const NEVER_MATCHING_INPUT: MoveDefinition['input'] = { }; function isButtonName(value: string): value is ButtonName { - return value === 'L' || value === 'M' || value === 'H' || value === 'P' || value === 'K' || value === 'S'; + return value === 'L' || value === 'M' || value === 'H' || value === 'P' || value === 'K' || value === 'S' || value === 'T'; } export function buildMoveList(moves?: CanonicalMoveRef[] | null): MoveList { @@ -27,14 +28,18 @@ export function buildMoveList(moves?: CanonicalMoveRef[] | null): MoveList { for (let index = 0; index < moves.length; index++) { const move = moves[index]; - const parsed = parseInputNotation(move.input); + const input = typeof move?.input === 'string' ? move.input : ''; + const name = input || (typeof move?.name === 'string' ? move.name : `move_${index}`); + const parsed = input ? parseInputNotation(input) : null; defs.push({ - name: move.input, + name, input: parsed ?? NEVER_MATCHING_INPUT, priority: getMoveTypePriority(move.type), }); - moveNameToIndex.set(move.input, index); + if (input) { + moveNameToIndex.set(input, index); + } } return { moves: defs, moveNameToIndex }; @@ -59,7 +64,7 @@ function getMoveTypePriority(type: string | undefined): number { } function parseInputNotation(input: string): MoveDefinition['input'] | null { - const simpleMatch = input.match(/^([1-9])([LMHPKS])$/); + const simpleMatch = input.match(/^([1-9])([LMHPKST])$/); if (simpleMatch) { const button = simpleMatch[2]; if (!isButtonName(button)) { @@ -73,7 +78,7 @@ function parseInputNotation(input: string): MoveDefinition['input'] | null { } // Reject any digits outside 1-9 (e.g. 0) to avoid parsing invalid motions. - const motionMatch = input.match(/^([1-9]{3,})([LMHPKS])$/); + const motionMatch = input.match(/^([1-9]{3,})([LMHPKST])$/); if (motionMatch) { const button = motionMatch[2]; if (!isButtonName(button)) { diff --git a/src/lib/training/cancelIntegration.test.ts b/src/lib/training/cancelIntegration.test.ts index f715240..a1f7e0f 100644 --- a/src/lib/training/cancelIntegration.test.ts +++ b/src/lib/training/cancelIntegration.test.ts @@ -8,9 +8,9 @@ * 1. MoveResolver - determines which move the player input matches * 2. WASM Runtime (can_cancel_to) - determines if that move is allowed from current state * - * Key insight: MoveResolver allows all moves when availableCancels is empty, - * but the WASM runtime still needs tag_rules to permit the transition. - * Without tag_rules for "system -> any", the player stays stuck in idle. + * Key insight: MoveResolver filters against the state indices returned by + * available_cancels(), while the WASM runtime remains the authority for + * tag-rule cancel validity. */ import { describe, it, expect } from 'vitest'; import { MoveResolver, type MoveList } from './MoveResolver'; @@ -20,15 +20,12 @@ import { InputBuffer } from './InputBuffer'; * Mock cancel table structure matching characters/test_char/cancel_table.json */ interface CancelTable { - tag_rules?: Array<{ + tag_rules: Array<{ from: string; to: string; - on: 'always' | 'hit' | 'block' | 'whiff'; + on: 'always' | 'hit' | 'block' | 'whiff' | Array<'hit' | 'block' | 'whiff'>; }>; - chains: Record; - special_cancels: string[]; - super_cancels: string[]; - jump_cancels: string[]; + deny: Record; } /** @@ -119,18 +116,11 @@ describe('Cancel System Integration', () => { const validCancelTable: CancelTable = { tag_rules: [ { from: 'system', to: 'any', on: 'always' }, - { from: 'normal', to: 'special', on: 'hit' }, - { from: 'normal', to: 'super', on: 'hit' }, - { from: 'special', to: 'super', on: 'hit' }, + { from: 'normal', to: 'special', on: ['hit', 'block'] }, + { from: 'normal', to: 'super', on: ['hit', 'block'] }, + { from: 'special', to: 'super', on: ['hit', 'block'] }, ], - chains: { - '5L': ['5L', '5M', '2L'], - '5M': ['5H'], - '2L': ['2L', '5M'], - }, - special_cancels: ['5L', '5M', '5H', '2L', '6M'], - super_cancels: ['5H', '236P'], - jump_cancels: ['5H', '6M'], + deny: {}, }; // tag_rules must exist and include system -> any rule @@ -142,25 +132,14 @@ describe('Cancel System Integration', () => { }); }); - it('documents the asymmetry between available_cancels and can_cancel_to', () => { + it('documents available_cancels as the enumerated can_cancel_to state targets', () => { /** - * This test documents an important asymmetry in the runtime: + * available_cancels() enumerates regular move/state targets accepted by + * can_cancel_to() under the current tag-rule model. * - * - available_cancels() only returns EXPLICIT chain cancels from state_extras - * (e.g., 5L can chain to 5L, 5M, 2L as defined in the chains table) - * - * - can_cancel_to() checks BOTH explicit chains AND tag-based rules - * (e.g., system states can cancel to ANY move via tag_rules) - * - * This means the HUD's "available cancels" list may not show all options, - * but the actual game logic will still allow transitions based on tag rules. - * - * For idle (state 0, type "system"): - * - available_cancels() returns [] (no explicit chains) - * - can_cancel_to(5L) returns true (via "system -> any" tag rule) - * - * The MoveResolver handles this by treating empty availableCancels as - * "allow all moves" which lets the WASM runtime make the final decision. + * For idle (state 0, type "system"), a "system -> any" rule should make + * the attack state indices available. MoveResolver then restricts input + * matching to that explicit list instead of relying on legacy chain fields. */ const resolver = new MoveResolver(createTestMoveList()); @@ -169,8 +148,8 @@ describe('Cancel System Integration', () => { // Simulate player in idle pressing L buffer.push({ direction: 5, buttons: ['L'] }); - // Empty availableCancels (what idle would return) - const availableCancels: number[] = []; + // Idle can cancel to all attack states through the system -> any tag rule. + const availableCancels: number[] = [2, 3, 4, 5, 6, 7]; // MoveResolver allows the input to be resolved const resolved = resolver.resolve(buffer, ['L'], availableCancels); @@ -227,7 +206,7 @@ describe('Cancel System Integration', () => { buffer.push({ direction: 5, buttons: ['L'] }); - // During 5L, can only cancel to explicit chains (5L, 5M, 2L) + // During 5L, can only cancel to the runtime's enumerated tag-rule targets. // Indices: 5L=2, 5M=3, 2L=5 const result = resolver.resolve(buffer, ['L'], [2, 3, 5]); @@ -241,7 +220,7 @@ describe('Cancel System Integration', () => { buffer.push({ direction: 5, buttons: ['H'] }); - // During 5L, 5H is not in the chain list + // During 5L, 5H is not in the available cancel list. // Only allow indices 2, 3, 5 (5L, 5M, 2L) const result = resolver.resolve(buffer, ['H'], [2, 3, 5]); diff --git a/src/lib/training/index.ts b/src/lib/training/index.ts index 45dd638..c141e27 100644 --- a/src/lib/training/index.ts +++ b/src/lib/training/index.ts @@ -11,6 +11,7 @@ export { InputManager, type TrainingInputConfig } from './InputManager.svelte'; export { InputBuffer, type InputSnapshot, + type InputBufferSnapshot, type ButtonName, type MotionPattern, type ChargePattern, diff --git a/src/lib/types.ts b/src/lib/types.ts index 70afbf6..5e9e4d8 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -319,24 +319,27 @@ export interface MeterGain { /** * A dynamic character property value. - * Matches the Rust enum: Number(f64) | Bool(bool) | String(String) + * Matches the Rust enum: + * Number(f64) | Bool(bool) | String(String) | Array | Object. */ -export type PropertyValue = number | boolean | string; +export type PropertyValue = + | number + | boolean + | string + | PropertyValue[] + | { [key: string]: PropertyValue }; + +export interface CharacterResource { + name: string; + start: number; + max: number; +} export interface Character { id: string; name: string; - archetype: string; - // Legacy fixed fields (deprecated - use properties map instead) - health: number; - walk_speed: number; - back_walk_speed: number; - jump_height: number; - jump_duration: number; - dash_distance: number; - dash_duration: number; - // Dynamic properties map (preferred over fixed fields) - properties?: Record; + properties: Record; + resources: CharacterResource[]; } // ============================================================================= @@ -404,17 +407,32 @@ export interface State { on_use?: OnUse; on_hit?: OnHit; on_block?: OnBlock; + + // Flexible per-state properties map for engine-specific data. + properties?: Record; + + // Variant resolution fields. + base?: string; + id?: string; } // ============================================================================= // Cancel Table and Character Data // ============================================================================= +export type CancelCondition = "always" | "hit" | "block" | "whiff" | Array<"hit" | "block" | "whiff">; + +export interface CancelTagRule { + from: string; + to: string; + on?: CancelCondition; + after_frame?: number; + before_frame?: number; +} + export interface CancelTable { - chains: Record; - special_cancels: string[]; - super_cancels: string[]; - jump_cancels: string[]; + tag_rules: CancelTagRule[]; + deny: Record; } export interface CharacterData { @@ -426,7 +444,7 @@ export interface CharacterData { export interface CharacterSummary { id: string; name: string; - archetype: string; + archetype?: string; move_count: number; } diff --git a/src/lib/utils.test.ts b/src/lib/utils.test.ts new file mode 100644 index 0000000..55a42f4 --- /dev/null +++ b/src/lib/utils.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; +import { getStateKey, isResolvedVariantState } from './utils'; +import type { State } from './types'; + +const baseState: State = { + input: '5H', + name: 'Standing Heavy', + tags: [], + startup: 10, + active: 3, + recovery: 20, + damage: 700, + hitstun: 20, + blockstun: 15, + hitstop: 10, + guard: 'mid', + hitboxes: [], + hurtboxes: [], + pushback: { hit: 5, block: 8 }, + meter_gain: { hit: 100, whiff: 20 }, + animation: '5H', +}; + +describe('state identity helpers', () => { + it('uses input for base states', () => { + expect(getStateKey(baseState)).toBe('5H'); + expect(isResolvedVariantState(baseState)).toBe(false); + }); + + it('uses id for resolved variants that share gameplay input', () => { + const variant = { ...baseState, id: '5H~level1' }; + + expect(getStateKey(variant)).toBe('5H~level1'); + expect(isResolvedVariantState(variant)).toBe(true); + }); +}); diff --git a/src/lib/utils.ts b/src/lib/utils.ts index ef71a30..29af749 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -2,11 +2,10 @@ * Shared utility functions for Framesmith */ -import type { Character } from './types'; +import type { Character, State } from './types'; /** * Get a character property with fallback. - * Prefers the dynamic properties map, falls back to legacy fixed fields, then the default. * * @param char - The character object * @param key - Property key (e.g., 'health', 'walk_speed') @@ -14,11 +13,17 @@ import type { Character } from './types'; * @returns The property value or fallback */ export function getCharProp(char: Character, key: string, fallback: number): number { - // Prefer properties map - const val = char.properties?.[key]; + const val = char.properties[key]; if (typeof val === 'number') return val; - // Fall back to legacy fixed fields (cast through unknown to avoid type error) - const legacyVal = (char as unknown as Record)[key]; - if (typeof legacyVal === 'number') return legacyVal; return fallback; } + +/** Stable authoring/editor key for states that may be resolved variants. */ +export function getStateKey(state: State): string { + return state.id ?? state.input; +} + +/** True when a loaded state is a resolved overlay variant rather than a base state. */ +export function isResolvedVariantState(state: State): boolean { + return typeof state.id === 'string' && state.id.length > 0 && state.id !== state.input; +} diff --git a/src/lib/views/CancelGraph.svelte b/src/lib/views/CancelGraph.svelte index cf2e2c6..2763b54 100644 --- a/src/lib/views/CancelGraph.svelte +++ b/src/lib/views/CancelGraph.svelte @@ -1,23 +1,19 @@ @@ -372,21 +375,21 @@ {#each nodePositions as node} (hoveredMove = node.input)} + class:dimmed={!isNodeHighlighted(node.key)} + class:hovered={hoveredMove === node.key} + onmouseenter={() => (hoveredMove = node.key)} onmouseleave={() => (hoveredMove = null)} - onfocus={() => (hoveredMove = node.input)} + onfocus={() => (hoveredMove = node.key)} onblur={() => (hoveredMove = null)} onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); - hoveredMove = hoveredMove === node.input ? null : node.input; + hoveredMove = hoveredMove === node.key ? null : node.key; } }} role="button" tabindex="0" - aria-label={`${node.input} - ${node.name}`} + aria-label={`${node.label} - ${node.name}`} > - {#if hasJumpCancel(node.input)} + {#if hasJumpCancel(node.key)} - {node.input} + {node.label} {/each} @@ -447,12 +450,15 @@ {#if hoveredMove} - {@const hoveredNode = nodePositions.find((n) => n.input === hoveredMove)} + {@const hoveredNode = nodePositions.find((n) => n.key === hoveredMove)} {@const outgoing = edges.filter((e) => e.from === hoveredMove)} {@const incoming = edges.filter((e) => e.to === hoveredMove)} {#if hoveredNode}
- {hoveredNode.input} - {hoveredNode.name} + {hoveredNode.label} - {hoveredNode.name} + {#if hoveredNode.label !== hoveredNode.input} + ({hoveredNode.input}) + {/if}
{#if outgoing.length > 0}
@@ -632,6 +638,13 @@ font-size: 15px; } + .hover-input { + color: var(--text-secondary); + font-family: monospace; + font-size: 12px; + margin-left: 4px; + } + .hover-connections { margin-top: 8px; display: flex; diff --git a/src/lib/views/CharacterOverview.svelte b/src/lib/views/CharacterOverview.svelte index e8af8d0..6b2adfc 100644 --- a/src/lib/views/CharacterOverview.svelte +++ b/src/lib/views/CharacterOverview.svelte @@ -1,7 +1,7 @@
- {#if moves.length === 0} {:else} {#each moves as move} - + {/each} {/if} @@ -192,6 +210,12 @@
+ {#if isReadOnlyVariant} +
+ Resolved variants are read-only here. Edit the overlay JSON directly until overlay-aware editing is implemented. +
+ {/if} +

Basic

@@ -637,7 +661,7 @@
- + {#if saveStatus} {saveStatus} @@ -650,7 +674,7 @@
(editingMove = m)} assets={assets} @@ -694,6 +718,16 @@ font-weight: 500; } + .variant-readonly-notice { + margin-bottom: 16px; + padding: 10px 12px; + border: 1px solid var(--warning); + border-radius: 4px; + color: var(--text-primary); + background: var(--bg-secondary); + font-size: 13px; + } + .editor-layout { display: grid; grid-template-columns: 1fr 300px; @@ -832,6 +866,16 @@ border-color: var(--accent-hover); } + .save-btn:disabled { + cursor: not-allowed; + opacity: 0.55; + } + + .save-btn:disabled:hover { + background: var(--accent); + border-color: var(--accent); + } + .save-status { font-size: 13px; color: var(--success); diff --git a/src/lib/views/TrainingMode.svelte b/src/lib/views/TrainingMode.svelte index 1bbcfba..b7fa31e 100644 --- a/src/lib/views/TrainingMode.svelte +++ b/src/lib/views/TrainingMode.svelte @@ -30,9 +30,9 @@ import { pickAnimationKey } from '$lib/training/pickAnimationKey'; import { buildMoveList } from '$lib/training/buildMoveList'; import { TrainingLoop } from './training/TrainingLoop'; - import { getCurrentCharacter, getTrainingSync } from '$lib/stores/character.svelte'; + import { getCharacterList, getCurrentCharacter, getTrainingSync } from '$lib/stores/character.svelte'; import { getProjectPath } from '$lib/stores/project.svelte'; - import type { CharacterAssets } from '$lib/types'; + import type { CharacterAssets, CharacterData } from '$lib/types'; import type { ActorSpec, Facing } from '$lib/rendercore/types'; import { buildActorSpecForMoveAnimation, getMoveForStateIndex } from '$lib/training/renderMapping'; @@ -47,6 +47,8 @@ let trainingLoop: TrainingLoop | null = $state(null); let dummyController: DummyController | null = $state(null); let moveResolver: MoveResolver | null = $state(null); + let dummyCharacterData = $state(null); + let selectedDummyCharacterId = $state(null); let isInitializing = $state(true); let initError = $state(null); @@ -96,6 +98,16 @@ return currentCharacter?.character.id ?? null; }); + const characterList = $derived(getCharacterList()); + + const dummyCharacterId = $derived.by((): string | null => { + return selectedDummyCharacterId ?? characterId; + }); + + const activeDummyCharacter = $derived.by(() => { + return dummyCharacterData ?? currentCharacter; + }); + // Default input configuration const defaultInputConfig: TrainingInputConfig = { directions: { @@ -111,6 +123,7 @@ P: 'KeyJ', K: 'KeyK', S: 'KeyL', + T: 'KeyP', }, }; @@ -127,6 +140,21 @@ } } + function decodeBase64Bytes(base64: string): Uint8Array { + const binaryString = atob(base64); + const bytes = new Uint8Array(binaryString.length); + for (let i = 0; i < binaryString.length; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + return bytes; + } + + function handleDummyCharacterChange(nextCharacterId: string) { + if (!characterId || nextCharacterId === dummyCharacterId) return; + selectedDummyCharacterId = nextCharacterId === characterId ? null : nextCharacterId; + void initialize(); + } + $effect(() => { const dir = charactersDir; const id = characterId; @@ -191,24 +219,36 @@ const charactersDir = `${projectPath}/characters`; const characterId = currentCharacter.character.id; + const nextDummyCharacterId = selectedDummyCharacterId ?? characterId; // Get FSPK bytes from Tauri - const fspkBase64 = await invoke('get_character_fspk', { + const playerFspkBase64 = await invoke('get_character_fspk', { charactersDir, characterId, }); if (destroyed || seq !== initSeq) return; - // Decode base64 to Uint8Array - const binaryString = atob(fspkBase64); - const fspkBytes = new Uint8Array(binaryString.length); - for (let i = 0; i < binaryString.length; i++) { - fspkBytes[i] = binaryString.charCodeAt(i); - } + const [nextDummyCharacter, dummyFspkBase64] = nextDummyCharacterId === characterId + ? [currentCharacter, playerFspkBase64] + : await Promise.all([ + invoke('load_character', { + charactersDir, + characterId: nextDummyCharacterId, + }), + invoke('get_character_fspk', { + charactersDir, + characterId: nextDummyCharacterId, + }), + ]); - // Create training session (using same character for player and dummy) - const session = await TrainingSession.create(fspkBytes, fspkBytes); + if (destroyed || seq !== initSeq) return; + + const playerFspkBytes = decodeBase64Bytes(playerFspkBase64); + const dummyFspkBytes = decodeBase64Bytes(dummyFspkBase64); + + // Create training session with independently loaded player and dummy packs. + const session = await TrainingSession.create(playerFspkBytes, dummyFspkBytes); if (destroyed || seq !== initSeq) { session.free(); @@ -229,6 +269,7 @@ moveResolver = nextMoveResolver; dummyController = nextDummyController; + dummyCharacterData = nextDummyCharacter; // Create training loop const loop = new TrainingLoop({ @@ -238,6 +279,7 @@ moveResolver: nextMoveResolver, dummyController: nextDummyController, character: currentCharacter.character, + dummyCharacter: nextDummyCharacter.character, moves: currentCharacter.moves, onError: (error) => { initError = error; @@ -308,7 +350,7 @@ return; } if (event.code === 'Comma') { - // Step back (not implemented - would need state history) + trainingLoop.stepBack(); return; } // Box overlay toggles @@ -345,6 +387,7 @@ trainingLoop = null; moveResolver = null; dummyController = null; + dummyCharacterData = null; } // Lifecycle @@ -369,7 +412,8 @@ const specs: ActorSpec[] = []; const assets = renderAssets; - const char = currentCharacter; + const playerChar = currentCharacter; + const dummyChar = activeDummyCharacter; const state = loopState; if (!assets) { @@ -377,11 +421,11 @@ else if (renderAssetsError) errs.push(`Assets error: ${renderAssetsError}`); } - if (!assets || !char || !state?.playerState || !state?.dummyState) { + if (!assets || !playerChar || !dummyChar || !state?.playerState || !state?.dummyState) { return { actors: specs, error: errs.length ? errs.join('\n') : null }; } - const resolveOne = (actorId: string, charState: CharacterState, pos: { x: number; y: number }, facing: Facing) => { + const resolveOne = (actorId: string, char: CharacterData, charState: CharacterState, pos: { x: number; y: number }, facing: Facing) => { const move = getMoveForStateIndex(char.moves, charState.current_state); if (!move) { errs.push(`${actorId}: State index out of bounds: ${charState.current_state}`); @@ -403,8 +447,8 @@ if (built.spec) specs.push(built.spec); }; - resolveOne('p1', state.playerState, { x: state.playerX, y: state.playerY }, 'right'); - resolveOne('cpu', state.dummyState, { x: state.dummyX, y: state.dummyY }, 'left'); + resolveOne('p1', playerChar, state.playerState, { x: state.playerX, y: state.playerY }, 'right'); + resolveOne('cpu', dummyChar, state.dummyState, { x: state.dummyX, y: state.dummyY }, 'left'); return { actors: specs, error: errs.length ? errs.join('\n') : null }; }); @@ -430,7 +474,7 @@ if (!state) return { health: 0, maxHealth: 0, resources: [] }; return { health: state.dummyHealth, - maxHealth: state.maxHealth, + maxHealth: state.dummyMaxHealth, resources: state.dummyState?.resources ? state.dummyState.resources.slice(0, 2).map((v, i) => ({ name: i === 0 ? 'Meter' : 'Heat', @@ -510,8 +554,8 @@ const dummyHitboxData = $derived.by(() => { const state = loopState; - const move = state?.dummyState && currentCharacter - ? getMoveForStateIndex(currentCharacter.moves, state.dummyState.current_state) + const move = state?.dummyState && activeDummyCharacter + ? getMoveForStateIndex(activeDummyCharacter.moves, state.dummyState.current_state) : null; return { move, @@ -601,7 +645,10 @@ {#if dummyController} m.input) ?? []} + availableMoves={activeDummyCharacter?.moves.map(m => m.input) ?? []} + availableCharacters={characterList} + selectedCharacterId={dummyCharacterId ?? ''} + onCharacterChange={handleDummyCharacterChange} onStateChange={(state) => dummyController?.setState(state)} onRecoveryChange={(recovery) => dummyController?.setRecovery(recovery)} onReversalMoveChange={(move) => dummyController?.setReversalMove(move)} @@ -619,7 +666,7 @@ isPlaying={loopState?.isPlaying ?? false} speed={loopState?.playbackSpeed ?? 1} onPlayPause={() => trainingLoop?.togglePlayPause()} - onStepBack={() => {}} + onStepBack={() => trainingLoop?.stepBack()} onStepForward={() => trainingLoop?.stepForward()} onSpeedChange={(speed) => trainingLoop?.setPlaybackSpeed(speed)} /> @@ -637,7 +684,7 @@
Attacks: - U I O / J K L + U I O / J K L / P
Boxes: diff --git a/src/lib/views/training/TrainingLoop.test.ts b/src/lib/views/training/TrainingLoop.test.ts new file mode 100644 index 0000000..757dec4 --- /dev/null +++ b/src/lib/views/training/TrainingLoop.test.ts @@ -0,0 +1,325 @@ +import { describe, expect, it } from 'vitest'; +import type { Writable } from 'svelte/store'; +import { InputBuffer, InputManager, MoveResolver, DummyController } from '$lib/training'; +import { DummyState, type CharacterState, type FrameResult, type HitResult, type PushSeparation, type TrainingSnapshot } from '$lib/training/TrainingSession'; +import type { MoveDefinition } from '$lib/training/MoveResolver'; +import type { Character, State } from '$lib/types'; +import { TrainingLoop, type TrainingLoopConfig } from './TrainingLoop'; + +function getStoreValue(store: Writable): T { + let value: T | undefined; + const unsubscribe = store.subscribe(nextValue => { + value = nextValue; + }); + unsubscribe(); + return value as T; +} + +function makeCharacterState(overrides: Partial = {}): CharacterState { + return { + current_state: 0, + frame: 0, + instance_duration: 0, + hit_confirmed: false, + block_confirmed: false, + resources: [0, 0, 0, 0, 0, 0, 0, 0], + ...overrides, + }; +} + +function cloneCharacterState(state: CharacterState): CharacterState { + return { + ...state, + resources: [...state.resources], + }; +} + +class MockTrainingSession { + player = makeCharacterState(); + dummy = makeCharacterState(); + playerX = 350; + playerY = 0; + dummyX = 450; + dummyY = 0; + nextHits: HitResult[] = []; + nextPushSeparation: PushSeparation | undefined; + availableCancelTargets: number[] = []; + lastPlayerInput: number | null = null; + lastDummyState: DummyState | null = null; + + tick(playerInput: number, dummyState: DummyState): FrameResult { + this.lastPlayerInput = playerInput; + this.lastDummyState = dummyState; + this.player = makeCharacterState({ ...this.player, frame: this.player.frame + 1 }); + this.dummy = makeCharacterState({ ...this.dummy, frame: this.dummy.frame + 1 }); + + const hits = this.nextHits.map(hit => ({ ...hit })); + const pushSeparation = this.nextPushSeparation + ? { ...this.nextPushSeparation } + : undefined; + this.nextHits = []; + this.nextPushSeparation = undefined; + + return { + player: cloneCharacterState(this.player), + dummy: cloneCharacterState(this.dummy), + hits, + push_separation: pushSeparation, + }; + } + + playerState(): CharacterState { + return cloneCharacterState(this.player); + } + + dummyState(): CharacterState { + return cloneCharacterState(this.dummy); + } + + availableCancels(): number[] { + return [...this.availableCancelTargets]; + } + + reset(): void { + this.player = makeCharacterState(); + this.dummy = makeCharacterState(); + this.playerX = 350; + this.playerY = 0; + this.dummyX = 450; + this.dummyY = 0; + } + + setPositions(playerX: number, playerY: number, dummyX: number, dummyY: number): void { + this.playerX = playerX; + this.playerY = playerY; + this.dummyX = dummyX; + this.dummyY = dummyY; + } + + snapshot(): TrainingSnapshot { + return { + player: cloneCharacterState(this.player), + dummy: cloneCharacterState(this.dummy), + player_x: this.playerX, + player_y: this.playerY, + dummy_x: this.dummyX, + dummy_y: this.dummyY, + }; + } + + restore(snapshot: TrainingSnapshot): void { + this.player = cloneCharacterState(snapshot.player); + this.dummy = cloneCharacterState(snapshot.dummy); + this.playerX = snapshot.player_x; + this.playerY = snapshot.player_y; + this.dummyX = snapshot.dummy_x; + this.dummyY = snapshot.dummy_y; + } +} + +function createHit(overrides: Partial = {}): HitResult { + return { + attacker_move: 0, + window_index: 0, + blocked: false, + damage: 25, + chip_damage: 4, + hitstun: 18, + blockstun: 12, + hitstop: 6, + guard: 1, + hit_pushback: 10, + block_pushback: 6, + ...overrides, + }; +} + +function createLoop(options: { + character?: Character; + moves?: State[]; + dummyController?: DummyController; + moveResolver?: MoveResolver; +} = {}): { loop: TrainingLoop; session: MockTrainingSession; inputManager: InputManager } { + const session = new MockTrainingSession(); + const character = options.character ?? { + id: 'test', + name: 'Test Character', + properties: { + health: 100, + }, + resources: [], + } as Character; + const inputManager = new InputManager({ + directions: { + up: 'KeyW', + down: 'KeyS', + left: 'KeyA', + right: 'KeyD', + }, + buttons: { + L: 'KeyU', + M: 'KeyI', + H: 'KeyO', + P: 'KeyJ', + K: 'KeyK', + S: 'KeyL', + T: 'KeyP', + }, + }); + + const loop = new TrainingLoop({ + session: session as unknown as TrainingLoopConfig['session'], + inputManager, + inputBuffer: new InputBuffer(), + moveResolver: options.moveResolver ?? new MoveResolver({ moves: [], moveNameToIndex: new Map() }), + dummyController: options.dummyController ?? new DummyController(), + character, + moves: options.moves ?? [], + }); + + return { loop, session, inputManager }; +} + +describe('TrainingLoop rewind history', () => { + it('steps back to the previous loop and WASM session state', () => { + const { loop, session } = createLoop(); + + loop.state.update(state => ({ + ...state, + playerX: 312, + dummyX: 488, + })); + loop.stepForward(); + expect(getStoreValue(loop.state).frameCount).toBe(1); + expect(session.playerState().frame).toBe(1); + expect(session.playerX).toBe(312); + expect(session.dummyX).toBe(488); + + session.setPositions(999, 0, 999, 0); + loop.stepBack(); + const rewound = getStoreValue(loop.state); + + expect(rewound.isPlaying).toBe(false); + expect(rewound.frameCount).toBe(0); + expect(rewound.playerState?.frame).toBe(0); + expect(rewound.playerX).toBe(312); + expect(rewound.dummyX).toBe(488); + expect(session.playerState().frame).toBe(0); + expect(session.playerX).toBe(312); + expect(session.dummyX).toBe(488); + + loop.stepForward(); + expect(getStoreValue(loop.state).frameCount).toBe(1); + expect(session.playerState().frame).toBe(1); + }); + + it('keeps the current state when stepping back without history', () => { + const { loop, session } = createLoop(); + + loop.stepBack(); + const state = getStoreValue(loop.state); + + expect(state.isPlaying).toBe(false); + expect(state.frameCount).toBe(0); + expect(session.playerState().frame).toBe(0); + }); + + it('clears rewind history when resetting training state', () => { + const { loop, session } = createLoop(); + + loop.stepForward(); + loop.stepForward(); + loop.resetHealth(); + + const reset = getStoreValue(loop.state); + expect(reset.frameCount).toBe(0); + expect(reset.inputHistory).toEqual([]); + expect(reset.dummyHealth).toBe(reset.maxHealth); + expect(session.playerState().frame).toBe(0); + + loop.stepBack(); + const afterBack = getStoreValue(loop.state); + expect(afterBack.frameCount).toBe(0); + expect(session.playerState().frame).toBe(0); + }); +}); + +describe('TrainingLoop behavior coverage', () => { + it('passes authored dummy stance choices to the WASM session', () => { + const dummyController = new DummyController(); + dummyController.setState('crouch'); + const { loop, session } = createLoop({ dummyController }); + + loop.stepForward(); + + expect(session.lastDummyState).toBe(DummyState.Crouch); + }); + + it('applies full damage on hit and chip damage on block', () => { + const { loop, session } = createLoop(); + + session.nextHits = [createHit({ damage: 25, chip_damage: 3, blocked: false })]; + loop.stepForward(); + + let state = getStoreValue(loop.state); + expect(state.dummyHealth).toBe(75); + expect(state.comboHits).toBe(1); + expect(state.comboDamage).toBe(25); + + session.nextHits = [createHit({ damage: 50, chip_damage: 5, blocked: true })]; + loop.stepForward(); + + state = getStoreValue(loop.state); + expect(state.dummyHealth).toBe(70); + expect(state.comboHits).toBe(2); + expect(state.comboDamage).toBe(30); + }); + + it('resets combo tracking after the quiet reset window', () => { + const { loop, session } = createLoop(); + + session.nextHits = [createHit()]; + loop.stepForward(); + expect(getStoreValue(loop.state).comboHits).toBe(1); + + for (let i = 0; i < 60; i++) { + loop.stepForward(); + } + + const state = getStoreValue(loop.state); + expect(state.comboHits).toBe(0); + expect(state.comboDamage).toBe(0); + }); + + it('applies runtime push separation and authored movement', () => { + const moves = [{ + input: '66', + name: 'Forward Dash', + type: 'movement', + startup: 1, + active: 1, + recovery: 1, + total: 3, + movement: { type: 'dash', distance: 30, direction: 'forward' }, + }] as unknown as State[]; + const { loop, session } = createLoop({ moves }); + session.nextPushSeparation = { player_dx: -4, dummy_dx: 6 }; + + loop.stepForward(); + + const state = getStoreValue(loop.state); + expect(state.playerX).toBe(356); + expect(state.dummyX).toBe(456); + }); + + it('resolves throw inputs through the regular input path', () => { + const moves: MoveDefinition[] = [{ name: '5T', input: { type: 'simple', direction: 5, button: 'T' }, priority: 50 }]; + const moveResolver = new MoveResolver({ moves, moveNameToIndex: new Map([['5T', 0]]) }); + const { loop, session, inputManager } = createLoop({ moveResolver }); + + inputManager.handleKeyDown('KeyP'); + loop.stepForward(); + + expect(session.lastPlayerInput).toBe(0); + }); +}); diff --git a/src/lib/views/training/TrainingLoop.ts b/src/lib/views/training/TrainingLoop.ts index 4e4deb5..bbb6313 100644 --- a/src/lib/views/training/TrainingLoop.ts +++ b/src/lib/views/training/TrainingLoop.ts @@ -17,6 +17,7 @@ import { NO_INPUT, type CharacterState, type FrameResult, + type TrainingSnapshot, } from '$lib/training/TrainingSession'; import { InputManager, @@ -24,17 +25,26 @@ import { MoveResolver, DummyController, type InputSnapshot, + type InputBufferSnapshot, } from '$lib/training'; import type { Character, State } from '$lib/types'; import { getCharProp } from '$lib/utils'; // Constants const INPUT_HISTORY_MAX = 30; +const HISTORY_MAX = 300; const COMBO_RESET_FRAMES = 60; // Reset combo after 1 second of no hits // Stage boundaries const MIN_X = 50; const MAX_X = 750; +const TRAINING_DEBUG_LOGS = import.meta.env.DEV && import.meta.env.MODE !== 'test'; + +interface TrainingLoopHistoryEntry { + state: TrainingLoopState; + session: TrainingSnapshot; + inputBuffer: InputBufferSnapshot; +} /** * Game state managed by the training loop @@ -57,6 +67,7 @@ export interface TrainingLoopState { playerHealth: number; dummyHealth: number; maxHealth: number; + dummyMaxHealth: number; // Combo tracking comboHits: number; @@ -82,10 +93,38 @@ export interface TrainingLoopConfig { moveResolver: MoveResolver; dummyController: DummyController; character: Character; + dummyCharacter?: Character; moves: State[]; onError?: (error: string) => void; } +function cloneInputSnapshot(snapshot: InputSnapshot): InputSnapshot { + return { + direction: snapshot.direction, + buttons: [...snapshot.buttons], + }; +} + +function cloneCharacterState(state: CharacterState | null): CharacterState | null { + if (!state) { + return null; + } + + return { + ...state, + resources: [...state.resources], + }; +} + +function cloneLoopState(state: TrainingLoopState): TrainingLoopState { + return { + ...state, + playerState: cloneCharacterState(state.playerState), + dummyState: cloneCharacterState(state.dummyState), + inputHistory: state.inputHistory.map(cloneInputSnapshot), + }; +} + /** * TrainingLoop manages the game loop and simulation state. */ @@ -96,6 +135,7 @@ export class TrainingLoop { private moveResolver: MoveResolver; private dummyController: DummyController; private character: Character; + private dummyCharacter: Character; private moves: State[]; private onError?: (error: string) => void; @@ -103,6 +143,7 @@ export class TrainingLoop { private animationFrameId: number | null = null; private loopSeq = 0; private lastTime = 0; + private history: TrainingLoopHistoryEntry[] = []; // Reactive stores for state public state: Writable; @@ -114,11 +155,13 @@ export class TrainingLoop { this.moveResolver = config.moveResolver; this.dummyController = config.dummyController; this.character = config.character; + this.dummyCharacter = config.dummyCharacter ?? config.character; this.moves = config.moves; this.onError = config.onError; // Initialize state with defaults const maxHealth = getCharProp(this.character, 'health', 1000); + const dummyMaxHealth = getCharProp(this.dummyCharacter, 'health', maxHealth); this.state = writable({ frameCount: 0, playerState: this.session.playerState(), @@ -128,8 +171,9 @@ export class TrainingLoop { dummyX: 450, dummyY: 0, playerHealth: maxHealth, - dummyHealth: maxHealth, + dummyHealth: dummyMaxHealth, maxHealth, + dummyMaxHealth, comboHits: 0, comboDamage: 0, comboResetTimer: 0, @@ -232,6 +276,26 @@ export class TrainingLoop { }); } + /** + * Step back one frame using the bounded rewind history. + */ + stepBack(): void { + this.state.update(state => { + const previous = this.history.pop(); + if (!previous) { + return { ...state, isPlaying: false }; + } + + this.session.restore(previous.session); + this.inputBuffer.restore(previous.inputBuffer); + + return { + ...cloneLoopState(previous.state), + isPlaying: false, + }; + }); + } + /** * Set playback speed */ @@ -247,20 +311,50 @@ export class TrainingLoop { */ resetHealth(): void { this.session.reset(); + this.history = []; + this.inputBuffer.clear(); this.state.update(state => ({ ...state, + frameCount: 0, + playerState: this.session.playerState(), + dummyState: this.session.dummyState(), + playerX: 350, + playerY: 0, + dummyX: 450, + dummyY: 0, playerHealth: state.maxHealth, - dummyHealth: state.maxHealth, + dummyHealth: state.dummyMaxHealth, comboHits: 0, comboDamage: 0, comboResetTimer: 0, + inputHistory: [], + frameAccumulator: 0, })); } + private captureHistory(state: TrainingLoopState): TrainingLoopHistoryEntry { + return { + state: cloneLoopState(state), + session: this.session.snapshot(), + inputBuffer: this.inputBuffer.snapshot(), + }; + } + + private pushHistory(entry: TrainingLoopHistoryEntry): void { + this.history.push(entry); + if (this.history.length > HISTORY_MAX) { + this.history.shift(); + } + } + /** * Tick one frame of simulation */ private tickOneFrame(state: TrainingLoopState): TrainingLoopState { + // Keep the runtime snapshot aligned with the visible positions. + this.session.setPositions(state.playerX, state.playerY, state.dummyX, state.dummyY); + const historyEntry = this.captureHistory(state); + // Get input snapshot and add to buffer const snapshot = this.inputManager.getSnapshot(); this.inputBuffer.push(snapshot); @@ -283,9 +377,6 @@ export class TrainingLoop { // Get dummy state const wasmDummyState = this.dummyController.getWasmState(); - // Sync positions to WASM for hit detection - this.session.setPositions(state.playerX, state.playerY, state.dummyX, state.dummyY); - // Tick simulation with error handling for WASM errors let result: FrameResult; try { @@ -301,7 +392,7 @@ export class TrainingLoop { // Log state transitions for debugging const prevState = state.playerState?.current_state; - if (prevState !== result.player.current_state) { + if (TRAINING_DEBUG_LOGS && prevState !== result.player.current_state) { const move = this.moves[result.player.current_state]; console.log('[STATE]', { from: prevState, @@ -322,20 +413,23 @@ export class TrainingLoop { const hits = result.hits; if (hits.length > 0) { - console.log('[HIT]', { - playerPos: { x: playerX, y: playerY }, - dummyPos: { x: state.dummyX, y: state.dummyY }, - playerState: result.player.current_state, - playerFrame: result.player.frame, - hits: hits.map(h => ({ damage: h.damage, move: h.attacker_move })), - }); + if (TRAINING_DEBUG_LOGS) { + console.log('[HIT]', { + playerPos: { x: playerX, y: playerY }, + dummyPos: { x: state.dummyX, y: state.dummyY }, + playerState: result.player.current_state, + playerFrame: result.player.frame, + hits: hits.map(h => ({ damage: h.damage, move: h.attacker_move })), + }); + } for (const hit of hits) { // Apply damage to dummy (player attacking) - dummyHealth = Math.max(0, dummyHealth - hit.damage); + const damage = hit.blocked ? hit.chip_damage : hit.damage; + dummyHealth = Math.max(0, dummyHealth - damage); // Track combo comboHits++; - comboDamage += hit.damage; + comboDamage += damage; } // Reset combo timer on hit comboResetTimer = COMBO_RESET_FRAMES; @@ -358,7 +452,7 @@ export class TrainingLoop { dummyX += result.push_separation.dummy_dx; } - return { + const nextState = { ...state, frameCount: state.frameCount + 1, playerState: result.player, @@ -373,6 +467,10 @@ export class TrainingLoop { comboResetTimer, inputHistory, }; + + this.pushHistory(historyEntry); + + return nextState; } /** @@ -424,5 +522,6 @@ export class TrainingLoop { */ dispose(): void { this.stop(); + this.history = []; } } diff --git a/src/routes/training/DetachedTraining.svelte b/src/routes/training/DetachedTraining.svelte index 6fd593e..0def230 100644 --- a/src/routes/training/DetachedTraining.svelte +++ b/src/routes/training/DetachedTraining.svelte @@ -6,7 +6,7 @@ * Used by the training page when in detached window mode. */ - import { onMount, onDestroy } from 'svelte'; + import { onMount, onDestroy, untrack } from 'svelte'; import TrainingViewport from '$lib/components/training/TrainingViewport.svelte'; import TrainingHUD from '$lib/components/training/TrainingHUD.svelte'; import InputHistory from '$lib/components/training/InputHistory.svelte'; @@ -17,6 +17,7 @@ NO_INPUT, type CharacterState, type FrameResult, + type TrainingSnapshot, } from '$lib/training/TrainingSession'; import { InputManager, @@ -26,6 +27,7 @@ calculateSimpleFrameAdvantage, type TrainingInputConfig, type InputSnapshot, + type InputBufferSnapshot, } from '$lib/training'; import { pickAnimationKey } from '$lib/training/pickAnimationKey'; import { buildMoveList } from '$lib/training/buildMoveList'; @@ -98,12 +100,106 @@ // Input history let inputHistory = $state([]); const INPUT_HISTORY_MAX = 30; + const HISTORY_MAX = 300; // Playback controls let isPlaying = $state(true); let playbackSpeed = $state(1); let frameAccumulator = $state(0); + interface DetachedTrainingState { + frameCount: number; + playerState: CharacterState | null; + dummyState: CharacterState | null; + playerX: number; + playerY: number; + dummyX: number; + dummyY: number; + playerHealth: number; + dummyHealth: number; + comboHits: number; + comboDamage: number; + comboResetTimer: number; + inputHistory: InputSnapshot[]; + frameAccumulator: number; + } + + interface DetachedHistoryEntry { + state: DetachedTrainingState; + session: TrainingSnapshot; + inputBuffer: InputBufferSnapshot; + } + + let history: DetachedHistoryEntry[] = []; + + function cloneInputSnapshot(snapshot: InputSnapshot): InputSnapshot { + return { + direction: snapshot.direction, + buttons: [...snapshot.buttons], + }; + } + + function cloneCharacterState(state: CharacterState | null): CharacterState | null { + if (!state) return null; + return { + ...state, + resources: [...state.resources], + }; + } + + function captureTrainingState(): DetachedTrainingState { + return { + frameCount, + playerState: cloneCharacterState(playerState), + dummyState: cloneCharacterState(dummyState), + playerX, + playerY, + dummyX, + dummyY, + playerHealth, + dummyHealth, + comboHits, + comboDamage, + comboResetTimer, + inputHistory: inputHistory.map(cloneInputSnapshot), + frameAccumulator, + }; + } + + function restoreTrainingState(state: DetachedTrainingState) { + frameCount = state.frameCount; + playerState = cloneCharacterState(state.playerState); + dummyState = cloneCharacterState(state.dummyState); + playerX = state.playerX; + playerY = state.playerY; + dummyX = state.dummyX; + dummyY = state.dummyY; + playerHealth = state.playerHealth; + dummyHealth = state.dummyHealth; + comboHits = state.comboHits; + comboDamage = state.comboDamage; + comboResetTimer = state.comboResetTimer; + inputHistory = state.inputHistory.map(cloneInputSnapshot); + frameAccumulator = state.frameAccumulator; + } + + function captureHistory(): DetachedHistoryEntry | null { + if (!session || !inputBuffer) return null; + return { + state: captureTrainingState(), + session: session.snapshot(), + inputBuffer: inputBuffer.snapshot(), + }; + } + + function pushHistory(entry: DetachedHistoryEntry | null) { + if (!entry) return; + history.push(entry); + if (history.length > HISTORY_MAX) { + history.shift(); + } + } + // Developer overlay toggles let showHitboxes = $state(false); let dummySettingsCollapsed = $state(false); @@ -123,13 +219,13 @@ P: 'KeyJ', K: 'KeyK', S: 'KeyL', + T: 'KeyP', }, }; // Initialize or reinitialize the training session - async function reinitializeSession() { + async function reinitializeSession(nextFspkBytes: Uint8Array, character: CharacterData) { const seq = ++sessionSeq; - if (!fspkBytes || !currentCharacter) return; try { // Stop current game loop @@ -140,7 +236,7 @@ session = null; // Create new session - const nextSession = await TrainingSession.create(fspkBytes, fspkBytes); + const nextSession = await TrainingSession.create(nextFspkBytes, nextFspkBytes); if (destroyed || seq !== sessionSeq) { nextSession.free(); @@ -150,12 +246,24 @@ session = nextSession; // Update move resolver - moveResolver = new MoveResolver(buildMoveList(currentCharacter?.moves)); + moveResolver = new MoveResolver(buildMoveList(character.moves)); + history = []; + inputBuffer?.clear(); // Reset health - maxHealth = getCharProp(currentCharacter.character, 'health', 1000); + maxHealth = getCharProp(character.character, 'health', 1000); playerHealth = maxHealth; dummyHealth = maxHealth; + comboHits = 0; + comboDamage = 0; + comboResetTimer = 0; + frameCount = 0; + inputHistory = []; + frameAccumulator = 0; + playerX = 200; + playerY = 0; + dummyX = 600; + dummyY = 0; // Get initial state playerState = session.playerState(); @@ -174,8 +282,12 @@ // Watch for fspkBytes changes $effect(() => { - if (fspkBytes) { - void reinitializeSession(); + const nextFspkBytes = fspkBytes; + const character = currentCharacter; + if (nextFspkBytes && character) { + untrack(() => { + void reinitializeSession(nextFspkBytes, character); + }); } }); @@ -234,6 +346,9 @@ return; } + session.setPositions(playerX, playerY, dummyX, dummyY); + const historyEntry = captureHistory(); + const snapshot = inputManager.getSnapshot(); inputBuffer.push(snapshot); inputHistory = [...inputHistory.slice(-(INPUT_HISTORY_MAX - 1)), snapshot]; @@ -280,6 +395,7 @@ } frameCount++; + pushHistory(historyEntry); } // Keyboard handlers @@ -299,6 +415,10 @@ stepForward(); return; } + if (event.code === 'Comma') { + stepBack(); + return; + } if (event.code === 'KeyH') { showHitboxes = !showHitboxes; return; @@ -325,7 +445,13 @@ } function stepBack() { - // Not implemented - requires state history + isPlaying = false; + const previous = history.pop(); + if (!previous || !session || !inputBuffer) return; + + session.restore(previous.session); + inputBuffer.restore(previous.inputBuffer); + restoreTrainingState(previous.state); } function setPlaybackSpeed(speed: PlaybackSpeed) { @@ -335,7 +461,21 @@ function resetHealth() { playerHealth = maxHealth; dummyHealth = maxHealth; + comboHits = 0; + comboDamage = 0; + comboResetTimer = 0; + frameCount = 0; + inputHistory = []; + frameAccumulator = 0; + playerX = 200; + playerY = 0; + dummyX = 600; + dummyY = 0; + history = []; + inputBuffer?.clear(); session?.reset(); + playerState = session?.playerState() ?? null; + dummyState = session?.dummyState() ?? null; } // Cleanup @@ -347,6 +487,7 @@ inputBuffer = null; moveResolver = null; dummyController = null; + history = []; session?.free(); session = null; } @@ -570,10 +711,10 @@ Movement: WASD
-
- Attacks: - U I O / J K L -
+
+ Attacks: + U I O / J K L / P +
Hitboxes: H diff --git a/tests/e2e/editor-smoke.spec.ts b/tests/e2e/editor-smoke.spec.ts new file mode 100644 index 0000000..3de8615 --- /dev/null +++ b/tests/e2e/editor-smoke.spec.ts @@ -0,0 +1,353 @@ +import { expect, test, type Page } from '@playwright/test'; +import { execFileSync } from 'node:child_process'; +import { mkdirSync, readFileSync } from 'node:fs'; +import { isAbsolute, join } from 'node:path'; + +type JsonValue = unknown; + +const projectPath = process.cwd(); +const characterId = 'test_char'; +const fixtureDir = join(projectPath, 'test-results', 'e2e'); +const fspkPath = join(projectPath, 'test-results', 'e2e', `${characterId}.fspk`); +const jsonBlobPath = join(projectPath, 'test-results', 'e2e', `${characterId}.json`); +let fixtureCache: { characterData: CharacterData; fspkBase64: string } | null = null; + +function readJson(relativePath: string): T { + const path = isAbsolute(relativePath) ? relativePath : join(projectPath, relativePath); + return JSON.parse(readFileSync(path, 'utf8')) as T; +} + +interface CharacterData { + character: { + id: string; + name: string; + properties: Record; + }; + moves: Array>; + cancel_table: Record; +} + +function ensureExportFixtures(): { characterData: CharacterData; fspkBase64: string } { + if (fixtureCache) { + return fixtureCache; + } + + mkdirSync(fixtureDir, { recursive: true }); + + execFileSync( + 'cargo', + [ + 'run', + '--manifest-path', + 'src-tauri/Cargo.toml', + '--bin', + 'framesmith-cli', + '--', + 'export', + '--project', + '.', + '--character', + characterId, + '--out', + fspkPath, + ], + { cwd: projectPath, stdio: 'inherit' } + ); + + execFileSync( + 'cargo', + [ + 'run', + '--manifest-path', + 'src-tauri/Cargo.toml', + '--bin', + 'framesmith-cli', + '--', + 'export', + '--project', + '.', + '--character', + characterId, + '--adapter', + 'json-blob', + '--out', + jsonBlobPath, + ], + { cwd: projectPath, stdio: 'inherit' } + ); + + fixtureCache = { + characterData: readJson(jsonBlobPath), + fspkBase64: readFileSync(fspkPath).toString('base64'), + }; + return fixtureCache; +} + +async function installTauriMock(page: Page) { + const { characterData, fspkBase64 } = ensureExportFixtures(); + const archetype = characterData.character.properties.archetype; + + await page.addInitScript( + (payload: Record) => { + const { characterData, fspkBase64, projectPath, characterId, archetype } = payload as { + characterData: CharacterData; + fspkBase64: string; + projectPath: string; + characterId: string; + archetype: unknown; + }; + const currentCharacter = structuredClone(characterData); + const dummyCharacter = structuredClone(characterData); + dummyCharacter.character.id = 'dummy_char'; + dummyCharacter.character.name = 'Training Dummy'; + dummyCharacter.character.properties.health = 750; + const globals = [ + { + id: 'burst', + name: 'Burst', + type: 'system', + }, + ]; + const burstState = { + input: 'burst', + name: 'Burst', + type: 'system', + tags: ['system'], + startup: 1, + active: 1, + recovery: 0, + damage: 0, + hitstun: 0, + blockstun: 0, + hitstop: 0, + guard: 'mid', + hitboxes: [], + hurtboxes: [], + pushback: { hit: 0, block: 0 }, + meter_gain: { hit: 0, whiff: 0 }, + animation: 'burst', + }; + + (window as any).__framesmithLastSave = null; + (window as any).__framesmithLastExport = null; + + const stateKey = (move: { input: string; id?: string }) => move.id ?? move.input; + + (window as any).__TAURI_INTERNALS__ = { + invoke: async (cmd: string, args: Record = {}) => { + switch (cmd) { + case 'open_folder_dialog': + return projectPath; + case 'validate_project': + return { name: 'framesmith', path: projectPath, character_count: 2 }; + case 'list_characters': + return [ + { + id: characterId, + name: currentCharacter.character.name, + archetype, + move_count: currentCharacter.moves.length, + }, + { + id: 'dummy_char', + name: dummyCharacter.character.name, + archetype: 'training', + move_count: dummyCharacter.moves.length, + }, + ]; + case 'load_character': + return structuredClone(args.characterId === 'dummy_char' ? dummyCharacter : currentCharacter); + case 'load_rules_registry': + return { + resources: ['heat', 'ammo', 'level', 'install_active'], + move_types: { + types: ['system', 'normal', 'command_normal', 'special', 'super', 'movement', 'throw'], + filter_groups: { + normals: ['normal', 'command_normal'], + specials: ['special', 'ex', 'rekka'], + supers: ['super'], + }, + }, + chain_order: ['L', 'M', 'H'], + }; + case 'load_character_assets': + return { version: 1, textures: {}, models: {}, animations: {} }; + case 'read_character_asset_base64': + return ''; + case 'save_move': { + const mv = args.mv as { input: string; id?: string }; + if (mv.id && mv.id !== mv.input) { + throw new Error('Resolved variant states are read-only via save_move'); + } + const index = currentCharacter.moves.findIndex((move) => stateKey(move as { input: string; id?: string }) === stateKey(mv)); + if (index >= 0) currentCharacter.moves[index] = structuredClone(mv); + (window as any).__framesmithLastSave = structuredClone(mv); + return null; + } + case 'export_character': + (window as any).__framesmithLastExport = structuredClone(args); + return null; + case 'get_character_fspk': + return fspkBase64; + case 'list_global_states': + return structuredClone(globals); + case 'get_global_state': + return structuredClone(burstState); + case 'save_global_state': + case 'delete_global_state': + case 'open_training_window': + return null; + default: + throw new Error(`Unhandled mocked Tauri command: ${cmd}`); + } + }, + transformCallback: () => 1, + unregisterCallback: () => null, + runCallback: () => null, + callbacks: new Map(), + convertFileSrc: (filePath: string) => filePath, + metadata: { + currentWindow: { label: 'main' }, + currentWebview: { label: 'main' }, + }, + }; + }, + { characterData, fspkBase64, projectPath, characterId, archetype } as Record + ); +} + +async function installMainWindowSyncResponder(page: Page) { + const { characterData } = ensureExportFixtures(); + + await page.evaluate( + (payload: Record) => { + const { characterData, projectPath } = payload as { + characterData: CharacterData; + projectPath: string; + }; + const channel = new BroadcastChannel('framesmith-training-sync'); + channel.onmessage = (event: MessageEvent) => { + const message = event.data as { type?: string }; + if (message.type === 'request-sync') { + channel.postMessage({ + type: 'character-change', + character: structuredClone(characterData), + }); + channel.postMessage({ type: 'project-path', path: projectPath }); + } + if (message.type === 'ping') { + channel.postMessage({ type: 'pong' }); + } + }; + (window as any).__framesmithTrainingChannel = channel; + }, + { characterData, projectPath } as Record + ); +} + +async function openMockedEditor(page: Page) { + await installTauriMock(page); + await page.goto('/'); +} + +test('loads the sample project and exercises core editor workflows', async ({ page }) => { + await openMockedEditor(page); + + await page.getByRole('button', { name: 'Open...' }).click(); + + await expect(page.locator('.project-name')).toHaveText('framesmith'); + await expect(page.getByRole('button', { name: /TEST_CHAR/ })).toBeVisible(); + + await page.getByRole('button', { name: /TEST_CHAR/ }).click(); + await expect(page.getByRole('heading', { name: 'TEST_CHAR' })).toBeVisible(); + await expect(page.locator('.archetype-badge')).toHaveText('all-rounder'); + await expect(page.getByText('Tag Rules')).toBeVisible(); + + await page.getByRole('button', { name: 'Frame Data' }).click(); + await expect(page.getByText('Standing Light')).toBeVisible(); + + await page.getByRole('button', { name: 'State Editor' }).click(); + await page.getByLabel('Move:').selectOption('5L'); + await expect(page.getByLabel('Name')).toHaveValue('Standing Light'); + + await page.getByLabel('Startup').fill('8'); + await page.getByRole('button', { name: /Pushboxes/ }).click(); + await page.getByRole('button', { name: '+ Add Pushbox' }).click(); + await page.getByRole('button', { name: 'Save Move' }).click(); + await expect(page.getByText('Saved!')).toBeVisible(); + await expect + .poll(() => page.evaluate(() => (window as any).__framesmithLastSave?.startup)) + .toBe(8); + + await page.getByRole('button', { name: 'Cancel Graph' }).click(); + await expect(page.getByText('Edge Types')).toBeVisible(); + await expect(page.getByText('5L')).toBeVisible(); + + await page.getByRole('button', { name: 'Globals' }).click(); + await expect(page.getByText('Global States')).toBeVisible(); + await page.getByRole('option', { name: /burst/i }).click(); + await expect(page.getByLabel('Name')).toHaveValue('Burst'); + + await page.getByRole('button', { name: 'Overview' }).click(); + await page.getByRole('button', { name: 'Export Character' }).click(); + await expect(page.getByText('Exported to exports/test_char.json')).toBeVisible(); + await expect + .poll(() => page.evaluate(() => (window as any).__framesmithLastExport?.adapter)) + .toBe('json-blob'); +}); + +test('selects resolved variants by id and keeps them read-only in the editor', async ({ page }) => { + await openMockedEditor(page); + + await page.getByRole('button', { name: 'Open...' }).click(); + await page.getByRole('button', { name: /TEST_CHAR/ }).click(); + await page.getByRole('button', { name: 'State Editor' }).click(); + + await page.getByLabel('Move:').selectOption('5H~level1'); + + await expect(page.getByLabel('Move:')).toHaveValue('5H~level1'); + await expect(page.getByText('Resolved variants are read-only here.')).toBeVisible(); + await expect(page.getByLabel('Input')).toHaveValue('5H'); + await expect(page.getByRole('button', { name: 'Save Move' })).toBeDisabled(); +}); + +test('loads training mode from rebuilt WASM and exported FSPK data', async ({ page }) => { + await openMockedEditor(page); + + await page.getByRole('button', { name: 'Open...' }).click(); + await page.getByRole('button', { name: /TEST_CHAR/ }).click(); + await page.getByRole('button', { name: 'Training', exact: true }).click(); + + await expect(page.getByText('Initializing training mode...')).toBeVisible(); + await expect(page.getByText('Failed to initialize training mode')).not.toBeVisible({ timeout: 15_000 }); + await expect(page.getByText('P1', { exact: true })).toBeVisible({ timeout: 15_000 }); + await expect(page.getByText('CPU', { exact: true })).toBeVisible(); + + const dummyCharacterSelect = page.getByLabel('Character', { exact: true }); + await expect(dummyCharacterSelect).toHaveValue(characterId); + await dummyCharacterSelect.selectOption('dummy_char'); + await expect(page.getByText('Failed to initialize training mode')).not.toBeVisible({ timeout: 15_000 }); + await expect(dummyCharacterSelect).toHaveValue('dummy_char'); + await expect(page.getByText('CPU', { exact: true })).toBeVisible(); +}); + +test('loads detached training mode through BroadcastChannel sync', async ({ page, context }) => { + test.setTimeout(60_000); + + await installTauriMock(page); + + const mainPage = await context.newPage(); + await installTauriMock(mainPage); + await mainPage.goto('/', { waitUntil: 'domcontentloaded' }); + await installMainWindowSyncResponder(mainPage); + + await page.goto(`/training?detached=true&character=${characterId}`, { + waitUntil: 'domcontentloaded', + }); + + await expect(page.getByText('P1', { exact: true })).toBeVisible({ timeout: 15_000 }); + await expect(page.getByText('CPU', { exact: true })).toBeVisible(); + await expect(page.getByText('Frame:')).toBeVisible(); + + await mainPage.close(); +}); From c5414ee2d019746931fcee38ca5038baf8b0eac7 Mon Sep 17 00:00:00 2001 From: RobDavenport Date: Sat, 23 May 2026 16:30:02 +0900 Subject: [PATCH 02/15] Record production readiness release evidence --- docs/README.md | 2 + docs/production-readiness-plan.md | 10 +++ docs/release-evidence-2026-05-23.md | 122 ++++++++++++++++++++++++++++ src-tauri/tests/production_docs.rs | 26 ++++++ 4 files changed, 160 insertions(+) create mode 100644 docs/release-evidence-2026-05-23.md diff --git a/docs/README.md b/docs/README.md index deb7bf7..c4566eb 100644 --- a/docs/README.md +++ b/docs/README.md @@ -24,6 +24,7 @@ Last reviewed: 2026-05-23 | Training scenario contract | [`training-scenario-contract.md`](training-scenario-contract.md) | | Production gap backlog | [`production-gap-backlog.md`](production-gap-backlog.md) | | Release runbook | [`release-runbook.md`](release-runbook.md) | +| Current release evidence | [`release-evidence-2026-05-23.md`](release-evidence-2026-05-23.md) | | Schema migration notes | [`schema-migration.md`](schema-migration.md) | | Windows installer smoke test | [`windows-installer-smoke-test.md`](windows-installer-smoke-test.md) | | Global states | [`global-states.md`](global-states.md) | @@ -65,6 +66,7 @@ Last reviewed: 2026-05-23 | [`training-scenario-contract.md`](training-scenario-contract.md) | Executable target training scenarios and ownership policy | | [`production-gap-backlog.md`](production-gap-backlog.md) | Concrete implementation issues for external gates and future target-game gaps | | [`release-runbook.md`](release-runbook.md) | Repeatable release-candidate validation and evidence capture steps | +| [`release-evidence-2026-05-23.md`](release-evidence-2026-05-23.md) | Current candidate validation evidence and external release blockers | | [`schema-migration.md`](schema-migration.md) | Migration notes for current character, cancel, variant, and adapter schema changes | | [`windows-installer-smoke-test.md`](windows-installer-smoke-test.md) | Manual MSI/NSIS smoke-test steps and evidence to record | | [`global-states.md`](global-states.md) | Global state model and usage | diff --git a/docs/production-readiness-plan.md b/docs/production-readiness-plan.md index 7c9e13c..30a42aa 100644 --- a/docs/production-readiness-plan.md +++ b/docs/production-readiness-plan.md @@ -63,6 +63,13 @@ Verified on 2026-05-23 in the current Windows workspace: This is not yet a clean-checkout certification. CI should be allowed to run on a fresh runner before marking clean-checkout reproducibility complete. +Current candidate evidence is recorded in +[`release-evidence-2026-05-23.md`](release-evidence-2026-05-23.md). The +candidate branch `codex-production-readiness-plan` was pushed at commit +`51c3be4c5b5e4d67b093f0f7aaafc96ed244e26d`, but no GitHub Actions run was +observed for that SHA through the available connector, and the connector cannot +create a pull request for the branch. + ## Completed Since Audit - Restored runtime cancel availability APIs and rebuilt WASM from source. @@ -347,6 +354,8 @@ Completed: - Added [`production-gap-backlog.md`](production-gap-backlog.md) and [`release-runbook.md`](release-runbook.md) so future production gaps and release-candidate evidence have permanent homes. +- Added [`release-evidence-2026-05-23.md`](release-evidence-2026-05-23.md) for + the current candidate branch and external release-gate evidence. Maintenance triggers: @@ -429,6 +438,7 @@ Run this checklist before a tagged release: - [x] Runtime guide includes engine-consumption examples for movement and resource deltas. - [x] Production gap backlog exists for target-game-required runtime/FSPK work. - [x] Release runbook exists for clean-checkout, CI, branch-protection, and installer evidence. +- [x] Current candidate release evidence is recorded. - [x] Frontend/tooling dependency audit reports 0 vulnerabilities in the verified workspace. - [x] Tauri npm packages are pinned to the Rust-compatible minor line. - [ ] Clean-checkout CI run has passed. diff --git a/docs/release-evidence-2026-05-23.md b/docs/release-evidence-2026-05-23.md new file mode 100644 index 0000000..dea22bf --- /dev/null +++ b/docs/release-evidence-2026-05-23.md @@ -0,0 +1,122 @@ +# Release Evidence 2026-05-23 + +Status: candidate evidence, not production-ready +Last reviewed: 2026-05-23 + +This record follows [`release-runbook.md`](release-runbook.md) for the current +production-readiness candidate. + +## Candidate + +```text +Candidate version: 0.1.0 +Candidate branch: codex-production-readiness-plan +Candidate commit SHA: 51c3be4c5b5e4d67b093f0f7aaafc96ed244e26d +Target branch: main +Supported platforms for this candidate: Windows +Target game / integration: first production target contract in production-readiness-plan.md +Release owner: repository owner / maintainer +``` + +Branch: + +```text +https://github.com/RobDavenport/framesmith/tree/codex-production-readiness-plan +``` + +Commit: + +```text +https://github.com/RobDavenport/framesmith/commit/51c3be4c5b5e4d67b093f0f7aaafc96ed244e26d +``` + +## Local Validation + +These commands passed in the Windows workspace before pushing the branch: + +```bash +npm ci +npm audit +npm ls @tauri-apps/api @tauri-apps/cli @tauri-apps/plugin-opener +cargo run --manifest-path src-tauri/Cargo.toml --bin generate_schema +npm run wasm:build +git diff --exit-code -- src/lib/wasm +npm run check +npm run test:run +npm run test:e2e +npm run build +cargo fmt --check --manifest-path src-tauri/Cargo.toml +cargo fmt --check --manifest-path crates/framesmith-runtime/Cargo.toml +cargo fmt --check --manifest-path crates/framesmith-runtime-wasm/Cargo.toml +cargo fmt --check --manifest-path crates/framesmith-fspack/Cargo.toml +cargo test --manifest-path src-tauri/Cargo.toml +cargo test --manifest-path crates/framesmith-runtime/Cargo.toml +cargo test --manifest-path crates/framesmith-runtime-wasm/Cargo.toml +cargo test --manifest-path crates/framesmith-fspack/Cargo.toml +cargo clippy --manifest-path src-tauri/Cargo.toml --all-targets -- -D warnings +cargo clippy --manifest-path crates/framesmith-runtime/Cargo.toml --all-targets -- -D warnings +cargo clippy --manifest-path crates/framesmith-runtime-wasm/Cargo.toml --all-targets -- -D warnings +cargo clippy --manifest-path crates/framesmith-fspack/Cargo.toml --all-targets -- -D warnings +npm run tauri build +git diff --check +``` + +Local package outputs: + +```text +src-tauri/target/release/framesmith.exe +src-tauri/target/release/bundle/msi/Framesmith_0.1.0_x64_en-US.msi +src-tauri/target/release/bundle/nsis/Framesmith_0.1.0_x64-setup.exe +``` + +## GitHub CI State + +Observed state on 2026-05-23: + +```text +Candidate SHA: 51c3be4c5b5e4d67b093f0f7aaafc96ed244e26d +Branch pushed: yes +GitHub workflow runs for candidate SHA: none observed through the GitHub connector +Pull request: not created +Pull request creation attempt: GitHub connector returned 403 Resource not accessible by integration +``` + +The branch must still be opened as a pull request or otherwise run through +GitHub Actions before the clean-checkout CI gate can be marked complete. + +## Branch Protection State + +Observed state on 2026-05-23: + +```text +Branch/ruleset evidence: not available through the current connector +Required CI evidence: not available +Blocked merge evidence: not available +``` + +The maintainer must configure or verify branch protection/rulesets requiring +the CI workflow before the branch-protection gate can be marked complete. + +## Installer Smoke State + +Observed state on 2026-05-23: + +```text +Windows version: not recorded +Architecture: not recorded +MSI source: local build path exists +MSI result: not manually smoke tested +NSIS source: local build path exists +NSIS result: not manually smoke tested +Warnings: unsigned-build warning expected but not manually verified +``` + +Run [`windows-installer-smoke-test.md`](windows-installer-smoke-test.md) on a +Windows machine or clean VM before marking the installer gate complete. + +## Decision + +```text +Decision: not ready +Reason: GitHub clean-checkout CI, required-CI branch protection, and manual Windows installer smoke evidence are still missing. +``` diff --git a/src-tauri/tests/production_docs.rs b/src-tauri/tests/production_docs.rs index 06293d9..57541f8 100644 --- a/src-tauri/tests/production_docs.rs +++ b/src-tauri/tests/production_docs.rs @@ -39,6 +39,10 @@ const RELEASE_RUNBOOK: &str = include_str!(concat!( env!("CARGO_MANIFEST_DIR"), "/../docs/release-runbook.md" )); +const RELEASE_EVIDENCE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../docs/release-evidence-2026-05-23.md" +)); const CHARACTER_COMMANDS: &str = include_str!("../src/commands/character.rs"); #[test] @@ -231,3 +235,25 @@ fn release_runbook_covers_candidate_evidence() { assert!(PRODUCTION_PLAN.contains("release-runbook.md")); assert!(PRODUCTION_PLAN.contains("[x] Release runbook exists for clean-checkout")); } + +#[test] +fn current_release_evidence_records_external_blockers() { + for required in [ + "# Release Evidence 2026-05-23", + "codex-production-readiness-plan", + "51c3be4c5b5e4d67b093f0f7aaafc96ed244e26d", + "GitHub workflow runs for candidate SHA: none observed", + "GitHub connector returned 403 Resource not accessible by integration", + "MSI result: not manually smoke tested", + "Decision: not ready", + ] { + assert!( + RELEASE_EVIDENCE.contains(required), + "release evidence should document: {required}" + ); + } + + assert!(DOCS_INDEX.contains("release-evidence-2026-05-23.md")); + assert!(PRODUCTION_PLAN.contains("release-evidence-2026-05-23.md")); + assert!(PRODUCTION_PLAN.contains("[x] Current candidate release evidence is recorded.")); +} From fe6f44ae86fe4305301dd501d8fcdb0cb6874046 Mon Sep 17 00:00:00 2001 From: RobDavenport Date: Sat, 23 May 2026 16:31:34 +0900 Subject: [PATCH 03/15] Stabilize release evidence record --- docs/production-readiness-plan.md | 7 +++---- docs/release-evidence-2026-05-23.md | 7 ++++--- src-tauri/tests/production_docs.rs | 4 ++-- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/production-readiness-plan.md b/docs/production-readiness-plan.md index 30a42aa..fe4b7a8 100644 --- a/docs/production-readiness-plan.md +++ b/docs/production-readiness-plan.md @@ -65,10 +65,9 @@ fresh runner before marking clean-checkout reproducibility complete. Current candidate evidence is recorded in [`release-evidence-2026-05-23.md`](release-evidence-2026-05-23.md). The -candidate branch `codex-production-readiness-plan` was pushed at commit -`51c3be4c5b5e4d67b093f0f7aaafc96ed244e26d`, but no GitHub Actions run was -observed for that SHA through the available connector, and the connector cannot -create a pull request for the branch. +candidate branch `codex-production-readiness-plan` was pushed, but no GitHub +Actions run was observed for the pushed SHAs through the available connector, +and the connector cannot create a pull request for the branch. ## Completed Since Audit diff --git a/docs/release-evidence-2026-05-23.md b/docs/release-evidence-2026-05-23.md index dea22bf..30e6d04 100644 --- a/docs/release-evidence-2026-05-23.md +++ b/docs/release-evidence-2026-05-23.md @@ -11,7 +11,8 @@ production-readiness candidate. ```text Candidate version: 0.1.0 Candidate branch: codex-production-readiness-plan -Candidate commit SHA: 51c3be4c5b5e4d67b093f0f7aaafc96ed244e26d +Candidate branch head SHA: record with `git ls-remote --heads origin codex-production-readiness-plan` +Local validation baseline SHA: 51c3be4c5b5e4d67b093f0f7aaafc96ed244e26d Target branch: main Supported platforms for this candidate: Windows Target game / integration: first production target contract in production-readiness-plan.md @@ -24,7 +25,7 @@ Branch: https://github.com/RobDavenport/framesmith/tree/codex-production-readiness-plan ``` -Commit: +Local validation baseline commit: ```text https://github.com/RobDavenport/framesmith/commit/51c3be4c5b5e4d67b093f0f7aaafc96ed244e26d @@ -76,7 +77,7 @@ Observed state on 2026-05-23: ```text Candidate SHA: 51c3be4c5b5e4d67b093f0f7aaafc96ed244e26d Branch pushed: yes -GitHub workflow runs for candidate SHA: none observed through the GitHub connector +GitHub workflow runs for the observed pushed SHAs: none observed through the GitHub connector Pull request: not created Pull request creation attempt: GitHub connector returned 403 Resource not accessible by integration ``` diff --git a/src-tauri/tests/production_docs.rs b/src-tauri/tests/production_docs.rs index 57541f8..494567e 100644 --- a/src-tauri/tests/production_docs.rs +++ b/src-tauri/tests/production_docs.rs @@ -241,8 +241,8 @@ fn current_release_evidence_records_external_blockers() { for required in [ "# Release Evidence 2026-05-23", "codex-production-readiness-plan", - "51c3be4c5b5e4d67b093f0f7aaafc96ed244e26d", - "GitHub workflow runs for candidate SHA: none observed", + "Local validation baseline SHA: 51c3be4c5b5e4d67b093f0f7aaafc96ed244e26d", + "GitHub workflow runs for the observed pushed SHAs: none observed", "GitHub connector returned 403 Resource not accessible by integration", "MSI result: not manually smoke tested", "Decision: not ready", From 333b1ba8d222267c10b7f9537be21a0a70060f75 Mon Sep 17 00:00:00 2001 From: RobDavenport Date: Sat, 23 May 2026 17:24:51 +0900 Subject: [PATCH 04/15] Build WASM before CI type checks --- .github/workflows/ci.yml | 27 ++++++++++++++-------- docs/production-readiness-plan.md | 16 ++++++++----- docs/release-evidence-2026-05-23.md | 27 ++++++++++++++-------- docs/release-runbook.md | 3 ++- src-tauri/tests/production_docs.rs | 35 ++++++++++++++++++++++++++--- 5 files changed, 81 insertions(+), 27 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0368be8..dc1f948 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,6 +35,24 @@ jobs: - name: Dependency audit run: npm audit + - name: Rebuild WASM package + run: npm run wasm:build + + - name: Verify WASM package exists + run: | + if (-not (Test-Path src/lib/wasm/framesmith_runtime_wasm.js)) { + throw "Missing generated WASM JavaScript binding" + } + if (-not (Test-Path src/lib/wasm/framesmith_runtime_wasm.d.ts)) { + throw "Missing generated WASM TypeScript declarations" + } + + - name: Refresh generated schemas + run: cargo run --manifest-path src-tauri/Cargo.toml --bin generate_schema + + - name: Verify generated files are current + run: git diff --exit-code -- schemas/rules.schema.json + - name: Install Playwright browser run: npx playwright install chromium @@ -44,15 +62,6 @@ jobs: - name: Frontend and logic tests run: npm run test:run - - name: Rebuild WASM package - run: npm run wasm:build - - - name: Refresh generated schemas - run: cargo run --manifest-path src-tauri/Cargo.toml --bin generate_schema - - - name: Verify generated files are current - run: git diff --exit-code -- schemas/rules.schema.json src/lib/wasm - - name: Browser smoke tests run: npm run test:e2e diff --git a/docs/production-readiness-plan.md b/docs/production-readiness-plan.md index fe4b7a8..7f68fb2 100644 --- a/docs/production-readiness-plan.md +++ b/docs/production-readiness-plan.md @@ -13,6 +13,11 @@ an executable first-target training scenario contract. It is still not finished as a production game-development pipeline because clean-checkout CI enforcement, branch protection, and manual platform smoke testing remain open. +PR #1 produced one clean-checkout CI failure on 2026-05-23 because the workflow +ran frontend type checking before rebuilding ignored WASM bindings. The workflow +has been corrected to build and verify WASM before `npm run check`; the +clean-run gate remains open until GitHub Actions passes on the repaired branch. + ## Readiness Definition Framesmith is production ready when all of these are true: @@ -58,16 +63,16 @@ Verified on 2026-05-23 in the current Windows workspace: | `cargo clippy --manifest-path src-tauri/Cargo.toml --all-targets -- -D warnings` | Pass | CI-level backend clippy gate is clean. | | Runtime crate clippy with `-D warnings` | Pass | `framesmith-runtime`, `framesmith-runtime-wasm`, and `framesmith-fspack` are clean. | | Cargo fmt checks | Pass | Formatting is clean for `src-tauri` and runtime crates. | -| CI workflow | Added | `.github/workflows/ci.yml` checks dependency audit, generated files, formatting, frontend, browser smoke tests, Rust tests, backend/runtime clippy, Tauri packaging, and uploads Windows installer artifacts; branch protection still must be configured in GitHub. | +| CI workflow | Repair pending verification | `.github/workflows/ci.yml` checks dependency audit, builds and verifies generated WASM before frontend type checks, checks generated schemas, runs formatting, frontend, browser smoke tests, Rust tests, backend/runtime clippy, Tauri packaging, and uploads Windows installer artifacts; branch protection still must be configured in GitHub. | This is not yet a clean-checkout certification. CI should be allowed to run on a fresh runner before marking clean-checkout reproducibility complete. Current candidate evidence is recorded in [`release-evidence-2026-05-23.md`](release-evidence-2026-05-23.md). The -candidate branch `codex-production-readiness-plan` was pushed, but no GitHub -Actions run was observed for the pushed SHAs through the available connector, -and the connector cannot create a pull request for the branch. +candidate branch `codex-production-readiness-plan` is open as PR #1. The first +GitHub Actions run failed because generated WASM was not built before +type-checking; the workflow has been repaired and still needs a passing rerun. ## Completed Since Audit @@ -111,7 +116,7 @@ remain external. Actions: -- Let GitHub Actions run the new CI workflow on a clean runner. +- Let GitHub Actions rerun the repaired CI workflow on a clean runner. - Make the CI workflow required before merges. - Keep Windows as the first supported packaging target. - Keep Linux and macOS out of the first production target until platform @@ -378,6 +383,7 @@ Run this checklist before a tagged release: - [ ] `cargo run --manifest-path src-tauri/Cargo.toml --bin generate_schema`. - [ ] Generated JSON schemas committed with no unexpected drift. - [ ] `npm run wasm:build`. +- [ ] Generated WASM JavaScript and TypeScript bindings exist. - [ ] `npm run check`. - [ ] `npm run test:run`. - [ ] `npm run test:e2e`. diff --git a/docs/release-evidence-2026-05-23.md b/docs/release-evidence-2026-05-23.md index 30e6d04..0d3c7fc 100644 --- a/docs/release-evidence-2026-05-23.md +++ b/docs/release-evidence-2026-05-23.md @@ -11,7 +11,7 @@ production-readiness candidate. ```text Candidate version: 0.1.0 Candidate branch: codex-production-readiness-plan -Candidate branch head SHA: record with `git ls-remote --heads origin codex-production-readiness-plan` +Candidate branch head SHA before CI repair: fe6f44ae86fe4305301dd501d8fcdb0cb6874046 Local validation baseline SHA: 51c3be4c5b5e4d67b093f0f7aaafc96ed244e26d Target branch: main Supported platforms for this candidate: Windows @@ -72,18 +72,27 @@ src-tauri/target/release/bundle/nsis/Framesmith_0.1.0_x64-setup.exe ## GitHub CI State -Observed state on 2026-05-23: +Observed state on 2026-05-23 after PR #1 was opened: ```text -Candidate SHA: 51c3be4c5b5e4d67b093f0f7aaafc96ed244e26d +Pull request: https://github.com/RobDavenport/framesmith/pull/1 +Candidate SHA: fe6f44ae86fe4305301dd501d8fcdb0cb6874046 Branch pushed: yes -GitHub workflow runs for the observed pushed SHAs: none observed through the GitHub connector -Pull request: not created -Pull request creation attempt: GitHub connector returned 403 Resource not accessible by integration +GitHub Actions run: https://github.com/RobDavenport/framesmith/actions/runs/26327908309 +Workflow/job: CI / Windows Checks +CI status: failed +Failing step: TypeScript and Svelte check +Failure summary: missing generated module `$lib/wasm/framesmith_runtime_wasm.js` ``` -The branch must still be opened as a pull request or otherwise run through -GitHub Actions before the clean-checkout CI gate can be marked complete. +Root cause: the workflow ran `npm run check` before `npm run wasm:build`. +`src/lib/wasm/` is generated and ignored, so a clean GitHub checkout does not +contain the WASM JavaScript or TypeScript bindings until the build step runs. + +Repair action: the workflow now rebuilds the WASM package before frontend type +checking and explicitly verifies the generated JavaScript and TypeScript binding +files exist. The clean-checkout CI gate remains open until a repaired PR run +passes. ## Branch Protection State @@ -119,5 +128,5 @@ Windows machine or clean VM before marking the installer gate complete. ```text Decision: not ready -Reason: GitHub clean-checkout CI, required-CI branch protection, and manual Windows installer smoke evidence are still missing. +Reason: GitHub clean-checkout CI is currently failing pending repair verification; required-CI branch protection and manual Windows installer smoke evidence are still missing. ``` diff --git a/docs/release-runbook.md b/docs/release-runbook.md index 6218fb2..48eec79 100644 --- a/docs/release-runbook.md +++ b/docs/release-runbook.md @@ -42,7 +42,8 @@ npm audit npm ls @tauri-apps/api @tauri-apps/cli @tauri-apps/plugin-opener cargo run --manifest-path src-tauri/Cargo.toml --bin generate_schema npm run wasm:build -git diff --exit-code -- schemas/rules.schema.json src/lib/wasm +pwsh -NoProfile -Command "if (-not (Test-Path 'src/lib/wasm/framesmith_runtime_wasm.js')) { throw 'Missing generated WASM JavaScript binding' }; if (-not (Test-Path 'src/lib/wasm/framesmith_runtime_wasm.d.ts')) { throw 'Missing generated WASM TypeScript declarations' }" +git diff --exit-code -- schemas/rules.schema.json npm run check npm run test:run npm run test:e2e diff --git a/src-tauri/tests/production_docs.rs b/src-tauri/tests/production_docs.rs index 494567e..9809258 100644 --- a/src-tauri/tests/production_docs.rs +++ b/src-tauri/tests/production_docs.rs @@ -43,6 +43,10 @@ const RELEASE_EVIDENCE: &str = include_str!(concat!( env!("CARGO_MANIFEST_DIR"), "/../docs/release-evidence-2026-05-23.md" )); +const CI_WORKFLOW: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../.github/workflows/ci.yml" +)); const CHARACTER_COMMANDS: &str = include_str!("../src/commands/character.rs"); #[test] @@ -219,7 +223,9 @@ fn release_runbook_covers_candidate_evidence() { "npm ci", "npm audit", "npm ls @tauri-apps/api @tauri-apps/cli @tauri-apps/plugin-opener", - "git diff --exit-code -- schemas/rules.schema.json src/lib/wasm", + "Test-Path 'src/lib/wasm/framesmith_runtime_wasm.js'", + "Test-Path 'src/lib/wasm/framesmith_runtime_wasm.d.ts'", + "git diff --exit-code -- schemas/rules.schema.json", "GitHub Actions URL", "Protected branch/ruleset", "windows-installer-smoke-test.md", @@ -242,8 +248,11 @@ fn current_release_evidence_records_external_blockers() { "# Release Evidence 2026-05-23", "codex-production-readiness-plan", "Local validation baseline SHA: 51c3be4c5b5e4d67b093f0f7aaafc96ed244e26d", - "GitHub workflow runs for the observed pushed SHAs: none observed", - "GitHub connector returned 403 Resource not accessible by integration", + "https://github.com/RobDavenport/framesmith/pull/1", + "https://github.com/RobDavenport/framesmith/actions/runs/26327908309", + "CI status: failed", + "workflow ran `npm run check` before `npm run wasm:build`", + "workflow now rebuilds the WASM package before frontend type", "MSI result: not manually smoke tested", "Decision: not ready", ] { @@ -257,3 +266,23 @@ fn current_release_evidence_records_external_blockers() { assert!(PRODUCTION_PLAN.contains("release-evidence-2026-05-23.md")); assert!(PRODUCTION_PLAN.contains("[x] Current candidate release evidence is recorded.")); } + +#[test] +fn ci_builds_wasm_before_frontend_typecheck() { + let rebuild_wasm = CI_WORKFLOW + .find("name: Rebuild WASM package") + .expect("CI should rebuild the WASM package"); + let verify_wasm = CI_WORKFLOW + .find("name: Verify WASM package exists") + .expect("CI should verify generated WASM bindings exist"); + let typecheck = CI_WORKFLOW + .find("name: TypeScript and Svelte check") + .expect("CI should run frontend type checks"); + + assert!( + rebuild_wasm < verify_wasm && verify_wasm < typecheck, + "clean CI must build ignored WASM bindings before type checking imports" + ); + assert!(CI_WORKFLOW.contains("Test-Path src/lib/wasm/framesmith_runtime_wasm.js")); + assert!(CI_WORKFLOW.contains("Test-Path src/lib/wasm/framesmith_runtime_wasm.d.ts")); +} From a568acfc81e4eb48390b8175fda49eb3bd1db1a0 Mon Sep 17 00:00:00 2001 From: RobDavenport Date: Sat, 23 May 2026 17:36:58 +0900 Subject: [PATCH 05/15] Generate WASM test fixture in CI --- .github/workflows/ci.yml | 3 +++ docs/production-readiness-plan.md | 11 +++++++++-- docs/release-evidence-2026-05-23.md | 21 +++++++++++++++++++++ docs/release-runbook.md | 1 + src-tauri/tests/production_docs.rs | 20 ++++++++++++++++++++ 5 files changed, 54 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dc1f948..a8ab3a2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -81,6 +81,9 @@ jobs: - name: Test runtime crate run: cargo test --manifest-path crates/framesmith-runtime/Cargo.toml + - name: Build runtime WASM test fixture + run: cargo run --manifest-path src-tauri/Cargo.toml --bin framesmith-cli -- export --project . --character test_char --adapter fspk --out exports/test_char.fspk + - name: Test runtime WASM crate run: cargo test --manifest-path crates/framesmith-runtime-wasm/Cargo.toml diff --git a/docs/production-readiness-plan.md b/docs/production-readiness-plan.md index 7f68fb2..70ba953 100644 --- a/docs/production-readiness-plan.md +++ b/docs/production-readiness-plan.md @@ -17,6 +17,10 @@ PR #1 produced one clean-checkout CI failure on 2026-05-23 because the workflow ran frontend type checking before rebuilding ignored WASM bindings. The workflow has been corrected to build and verify WASM before `npm run check`; the clean-run gate remains open until GitHub Actions passes on the repaired branch. +The next PR run passed that gate and exposed a second clean-checkout assumption: +runtime WASM tests needed ignored `exports/test_char.fspk` output. CI and the +release runbook now generate that fixture from tracked character data before the +runtime WASM crate test. ## Readiness Definition @@ -63,7 +67,7 @@ Verified on 2026-05-23 in the current Windows workspace: | `cargo clippy --manifest-path src-tauri/Cargo.toml --all-targets -- -D warnings` | Pass | CI-level backend clippy gate is clean. | | Runtime crate clippy with `-D warnings` | Pass | `framesmith-runtime`, `framesmith-runtime-wasm`, and `framesmith-fspack` are clean. | | Cargo fmt checks | Pass | Formatting is clean for `src-tauri` and runtime crates. | -| CI workflow | Repair pending verification | `.github/workflows/ci.yml` checks dependency audit, builds and verifies generated WASM before frontend type checks, checks generated schemas, runs formatting, frontend, browser smoke tests, Rust tests, backend/runtime clippy, Tauri packaging, and uploads Windows installer artifacts; branch protection still must be configured in GitHub. | +| CI workflow | Repair pending verification | `.github/workflows/ci.yml` checks dependency audit, builds and verifies generated WASM before frontend type checks, checks generated schemas, generates the runtime WASM FSPK test fixture from tracked data, runs formatting, frontend, browser smoke tests, Rust tests, backend/runtime clippy, Tauri packaging, and uploads Windows installer artifacts; branch protection still must be configured in GitHub. | This is not yet a clean-checkout certification. CI should be allowed to run on a fresh runner before marking clean-checkout reproducibility complete. @@ -72,7 +76,9 @@ Current candidate evidence is recorded in [`release-evidence-2026-05-23.md`](release-evidence-2026-05-23.md). The candidate branch `codex-production-readiness-plan` is open as PR #1. The first GitHub Actions run failed because generated WASM was not built before -type-checking; the workflow has been repaired and still needs a passing rerun. +type-checking. The second run passed that gate and failed because the runtime +WASM crate expected an ignored FSPK fixture. The workflow and runbook now +generate the fixture before runtime WASM tests and still need a passing rerun. ## Completed Since Audit @@ -389,6 +395,7 @@ Run this checklist before a tagged release: - [ ] `npm run test:e2e`. - [ ] `npm run build`. - [ ] `cargo fmt --check` for `src-tauri` and runtime crates. +- [ ] Runtime WASM test fixture generated with `framesmith-cli`. - [ ] `cargo test` for `src-tauri`, `framesmith-runtime`, `framesmith-runtime-wasm`, and `framesmith-fspack`. - [ ] `cargo clippy --all-targets -- -D warnings` for the backend and runtime diff --git a/docs/release-evidence-2026-05-23.md b/docs/release-evidence-2026-05-23.md index 0d3c7fc..f5a7ba5 100644 --- a/docs/release-evidence-2026-05-23.md +++ b/docs/release-evidence-2026-05-23.md @@ -94,6 +94,27 @@ checking and explicitly verifies the generated JavaScript and TypeScript binding files exist. The clean-checkout CI gate remains open until a repaired PR run passes. +Second observed state on 2026-05-23 after the WASM-order repair: + +```text +Pull request: https://github.com/RobDavenport/framesmith/pull/1 +Candidate SHA: 333b1ba8d222267c10b7f9537be21a0a70060f75 +GitHub Actions run: https://github.com/RobDavenport/framesmith/actions/runs/26328098854 +Workflow/job: CI / Windows Checks +CI status: failed +Failing step: Test runtime WASM crate +Failure summary: missing ignored fixture `exports/test_char.fspk` +``` + +Root cause: `framesmith-runtime-wasm` test code includes +`exports/test_char.fspk` at compile time, but `exports/*.fspk` artifacts are +generated and ignored. The local workspace had the file from a previous export; +a clean GitHub checkout did not. + +Repair action: the workflow now exports `characters/test_char` with +`framesmith-cli` before running the runtime WASM crate tests. The runbook records +the same fixture-generation step for clean local release verification. + ## Branch Protection State Observed state on 2026-05-23: diff --git a/docs/release-runbook.md b/docs/release-runbook.md index 48eec79..e085d49 100644 --- a/docs/release-runbook.md +++ b/docs/release-runbook.md @@ -54,6 +54,7 @@ cargo fmt --check --manifest-path crates/framesmith-runtime-wasm/Cargo.toml cargo fmt --check --manifest-path crates/framesmith-fspack/Cargo.toml cargo test --manifest-path src-tauri/Cargo.toml cargo test --manifest-path crates/framesmith-runtime/Cargo.toml +cargo run --manifest-path src-tauri/Cargo.toml --bin framesmith-cli -- export --project . --character test_char --adapter fspk --out exports/test_char.fspk cargo test --manifest-path crates/framesmith-runtime-wasm/Cargo.toml cargo test --manifest-path crates/framesmith-fspack/Cargo.toml cargo clippy --manifest-path src-tauri/Cargo.toml --all-targets -- -D warnings diff --git a/src-tauri/tests/production_docs.rs b/src-tauri/tests/production_docs.rs index 9809258..cd410b9 100644 --- a/src-tauri/tests/production_docs.rs +++ b/src-tauri/tests/production_docs.rs @@ -225,6 +225,7 @@ fn release_runbook_covers_candidate_evidence() { "npm ls @tauri-apps/api @tauri-apps/cli @tauri-apps/plugin-opener", "Test-Path 'src/lib/wasm/framesmith_runtime_wasm.js'", "Test-Path 'src/lib/wasm/framesmith_runtime_wasm.d.ts'", + "cargo run --manifest-path src-tauri/Cargo.toml --bin framesmith-cli -- export --project . --character test_char --adapter fspk --out exports/test_char.fspk", "git diff --exit-code -- schemas/rules.schema.json", "GitHub Actions URL", "Protected branch/ruleset", @@ -253,6 +254,9 @@ fn current_release_evidence_records_external_blockers() { "CI status: failed", "workflow ran `npm run check` before `npm run wasm:build`", "workflow now rebuilds the WASM package before frontend type", + "https://github.com/RobDavenport/framesmith/actions/runs/26328098854", + "Failing step: Test runtime WASM crate", + "workflow now exports `characters/test_char` with", "MSI result: not manually smoke tested", "Decision: not ready", ] { @@ -286,3 +290,19 @@ fn ci_builds_wasm_before_frontend_typecheck() { assert!(CI_WORKFLOW.contains("Test-Path src/lib/wasm/framesmith_runtime_wasm.js")); assert!(CI_WORKFLOW.contains("Test-Path src/lib/wasm/framesmith_runtime_wasm.d.ts")); } + +#[test] +fn ci_generates_runtime_wasm_fixture_before_crate_test() { + let build_fixture = CI_WORKFLOW + .find("name: Build runtime WASM test fixture") + .expect("CI should build the runtime WASM FSPK test fixture"); + let wasm_tests = CI_WORKFLOW + .find("name: Test runtime WASM crate") + .expect("CI should run runtime WASM crate tests"); + + assert!( + build_fixture < wasm_tests, + "clean CI must generate ignored exports/test_char.fspk before WASM crate tests" + ); + assert!(CI_WORKFLOW.contains("--bin framesmith-cli -- export --project . --character test_char --adapter fspk --out exports/test_char.fspk")); +} From 0b442b53ff827be491f61ba1c11eee1c3c386be3 Mon Sep 17 00:00:00 2001 From: RobDavenport Date: Sat, 23 May 2026 17:52:11 +0900 Subject: [PATCH 06/15] Use generated FSPK fixture in WASM tests --- .../tests/integration.rs | 13 ++++++------- docs/production-readiness-plan.md | 8 ++++++-- docs/release-evidence-2026-05-23.md | 19 +++++++++++++++++++ src-tauri/tests/production_docs.rs | 3 +++ 4 files changed, 34 insertions(+), 9 deletions(-) diff --git a/crates/framesmith-runtime-wasm/tests/integration.rs b/crates/framesmith-runtime-wasm/tests/integration.rs index 21f1c3c..146b479 100644 --- a/crates/framesmith-runtime-wasm/tests/integration.rs +++ b/crates/framesmith-runtime-wasm/tests/integration.rs @@ -6,16 +6,16 @@ use framesmith_runtime_wasm::{CharacterState, DummyState, HitResult}; -/// Test that we can load the real glitch.fspk and parse it. +const TEST_CHAR_FSPK: &[u8] = include_bytes!("../../../exports/test_char.fspk"); + +/// Test that we can load the generated test_char.fspk and parse it. #[test] fn can_parse_real_fspk() { use framesmith_fspack::PackView; - // Load the real test file - let fspk_data = include_bytes!("../../../exports/glitch.fspk"); - let pack = PackView::parse(fspk_data); + let pack = PackView::parse(TEST_CHAR_FSPK); - assert!(pack.is_ok(), "Should parse glitch.fspk successfully"); + assert!(pack.is_ok(), "Should parse test_char.fspk successfully"); let pack = pack.unwrap(); // Verify it has states @@ -30,8 +30,7 @@ fn simulate_with_real_fspk() { use framesmith_fspack::PackView; use framesmith_runtime::{init_resources, next_frame, CharacterState as RtState, FrameInput}; - let fspk_data = include_bytes!("../../../exports/glitch.fspk"); - let pack = PackView::parse(fspk_data).unwrap(); + let pack = PackView::parse(TEST_CHAR_FSPK).unwrap(); // Initialize state let mut state = RtState::default(); diff --git a/docs/production-readiness-plan.md b/docs/production-readiness-plan.md index 70ba953..a6b8b96 100644 --- a/docs/production-readiness-plan.md +++ b/docs/production-readiness-plan.md @@ -20,7 +20,9 @@ clean-run gate remains open until GitHub Actions passes on the repaired branch. The next PR run passed that gate and exposed a second clean-checkout assumption: runtime WASM tests needed ignored `exports/test_char.fspk` output. CI and the release runbook now generate that fixture from tracked character data before the -runtime WASM crate test. +runtime WASM crate test. A follow-up run showed the same test crate still +referenced the ignored legacy `exports/glitch.fspk`; those integration tests now +use the generated `test_char.fspk` fixture instead. ## Readiness Definition @@ -78,7 +80,9 @@ candidate branch `codex-production-readiness-plan` is open as PR #1. The first GitHub Actions run failed because generated WASM was not built before type-checking. The second run passed that gate and failed because the runtime WASM crate expected an ignored FSPK fixture. The workflow and runbook now -generate the fixture before runtime WASM tests and still need a passing rerun. +generate the fixture before runtime WASM tests. A third run exposed a remaining +legacy `glitch.fspk` include in the same test crate; the test now uses the +generated `test_char.fspk` fixture and still needs a passing rerun. ## Completed Since Audit diff --git a/docs/release-evidence-2026-05-23.md b/docs/release-evidence-2026-05-23.md index f5a7ba5..f746441 100644 --- a/docs/release-evidence-2026-05-23.md +++ b/docs/release-evidence-2026-05-23.md @@ -115,6 +115,25 @@ Repair action: the workflow now exports `characters/test_char` with `framesmith-cli` before running the runtime WASM crate tests. The runbook records the same fixture-generation step for clean local release verification. +Third observed state on 2026-05-23 after the fixture-generation repair: + +```text +Pull request: https://github.com/RobDavenport/framesmith/pull/1 +Candidate SHA: a568acfc81e4eb48390b8175fda49eb3bd1db1a0 +GitHub Actions run: https://github.com/RobDavenport/framesmith/actions/runs/26328384684 +Workflow/job: CI / Windows Checks +CI status: failed +Failing step: Test runtime WASM crate +Failure summary: integration tests still included ignored legacy fixture `exports/glitch.fspk` +``` + +Root cause: the runtime WASM integration tests referenced `exports/glitch.fspk`, +but no tracked `characters/glitch` source exists to regenerate that ignored +binary artifact in a clean checkout. + +Repair action: the integration tests now use the generated `test_char.fspk` +fixture that CI builds from tracked `characters/test_char` data. + ## Branch Protection State Observed state on 2026-05-23: diff --git a/src-tauri/tests/production_docs.rs b/src-tauri/tests/production_docs.rs index cd410b9..6d4f89a 100644 --- a/src-tauri/tests/production_docs.rs +++ b/src-tauri/tests/production_docs.rs @@ -257,6 +257,9 @@ fn current_release_evidence_records_external_blockers() { "https://github.com/RobDavenport/framesmith/actions/runs/26328098854", "Failing step: Test runtime WASM crate", "workflow now exports `characters/test_char` with", + "https://github.com/RobDavenport/framesmith/actions/runs/26328384684", + "ignored legacy fixture `exports/glitch.fspk`", + "integration tests now use the generated `test_char.fspk`", "MSI result: not manually smoke tested", "Decision: not ready", ] { From 577ffe6b835de8eb5ece0fcf524501a86f58cf68 Mon Sep 17 00:00:00 2001 From: RobDavenport Date: Sat, 23 May 2026 18:11:57 +0900 Subject: [PATCH 07/15] Record passing clean CI evidence --- docs/production-readiness-plan.md | 24 +++++++++++++----------- docs/release-evidence-2026-05-23.md | 22 +++++++++++++++++++++- src-tauri/tests/production_docs.rs | 5 +++++ 3 files changed, 39 insertions(+), 12 deletions(-) diff --git a/docs/production-readiness-plan.md b/docs/production-readiness-plan.md index a6b8b96..862c6c2 100644 --- a/docs/production-readiness-plan.md +++ b/docs/production-readiness-plan.md @@ -10,8 +10,8 @@ Current assessment: Framesmith is no longer blocked by the build and schema drift found in the initial audit. The current workspace builds, tests, rebuilds WASM from source, runs browser smoke tests, produces Windows packages, and has an executable first-target training scenario contract. It is still not finished -as a production game-development pipeline because clean-checkout CI enforcement, -branch protection, and manual platform smoke testing remain open. +as a production game-development pipeline because branch protection and manual +platform smoke testing remain open. PR #1 produced one clean-checkout CI failure on 2026-05-23 because the workflow ran frontend type checking before rebuilding ignored WASM bindings. The workflow @@ -22,7 +22,9 @@ runtime WASM tests needed ignored `exports/test_char.fspk` output. CI and the release runbook now generate that fixture from tracked character data before the runtime WASM crate test. A follow-up run showed the same test crate still referenced the ignored legacy `exports/glitch.fspk`; those integration tests now -use the generated `test_char.fspk` fixture instead. +use the generated `test_char.fspk` fixture instead. Run `26328577057` passed on +GitHub for candidate SHA `0b442b53ff827be491f61ba1c11eee1c3c386be3` and +uploaded the `framesmith-windows-installers` artifact. ## Readiness Definition @@ -69,10 +71,11 @@ Verified on 2026-05-23 in the current Windows workspace: | `cargo clippy --manifest-path src-tauri/Cargo.toml --all-targets -- -D warnings` | Pass | CI-level backend clippy gate is clean. | | Runtime crate clippy with `-D warnings` | Pass | `framesmith-runtime`, `framesmith-runtime-wasm`, and `framesmith-fspack` are clean. | | Cargo fmt checks | Pass | Formatting is clean for `src-tauri` and runtime crates. | -| CI workflow | Repair pending verification | `.github/workflows/ci.yml` checks dependency audit, builds and verifies generated WASM before frontend type checks, checks generated schemas, generates the runtime WASM FSPK test fixture from tracked data, runs formatting, frontend, browser smoke tests, Rust tests, backend/runtime clippy, Tauri packaging, and uploads Windows installer artifacts; branch protection still must be configured in GitHub. | +| CI workflow | Pass | `.github/workflows/ci.yml` passed on GitHub Actions run `26328577057`, checking dependency audit, generated WASM, generated schemas, runtime WASM FSPK fixture generation, formatting, frontend, browser smoke tests, Rust tests, backend/runtime clippy, Tauri packaging, and Windows installer artifact upload; branch protection still must be configured in GitHub. | -This is not yet a clean-checkout certification. CI should be allowed to run on a -fresh runner before marking clean-checkout reproducibility complete. +Clean-checkout CI is verified for candidate SHA +`0b442b53ff827be491f61ba1c11eee1c3c386be3`. Required-branch enforcement and +manual installer smoke testing are still open. Current candidate evidence is recorded in [`release-evidence-2026-05-23.md`](release-evidence-2026-05-23.md). The @@ -82,7 +85,8 @@ type-checking. The second run passed that gate and failed because the runtime WASM crate expected an ignored FSPK fixture. The workflow and runbook now generate the fixture before runtime WASM tests. A third run exposed a remaining legacy `glitch.fspk` include in the same test crate; the test now uses the -generated `test_char.fspk` fixture and still needs a passing rerun. +generated `test_char.fspk` fixture. The next run passed and uploaded installer +artifact `framesmith-windows-installers`. ## Completed Since Audit @@ -121,12 +125,10 @@ generated `test_char.fspk` fixture and still needs a passing rerun. ### 1. Clean-Checkout And CI Enforcement -Status: local CI definition complete; GitHub clean-run and branch protection -remain external. +Status: GitHub clean-run complete; branch protection remains external. Actions: -- Let GitHub Actions rerun the repaired CI workflow on a clean runner. - Make the CI workflow required before merges. - Keep Windows as the first supported packaging target. - Keep Linux and macOS out of the first production target until platform @@ -457,7 +459,7 @@ Run this checklist before a tagged release: - [x] Current candidate release evidence is recorded. - [x] Frontend/tooling dependency audit reports 0 vulnerabilities in the verified workspace. - [x] Tauri npm packages are pinned to the Rust-compatible minor line. -- [ ] Clean-checkout CI run has passed. +- [x] Clean-checkout CI run has passed. - [ ] CI is required before merges. - [x] Overlay-aware variant editing is implemented or explicitly deferred for the target game. diff --git a/docs/release-evidence-2026-05-23.md b/docs/release-evidence-2026-05-23.md index f746441..d21f1b0 100644 --- a/docs/release-evidence-2026-05-23.md +++ b/docs/release-evidence-2026-05-23.md @@ -12,6 +12,7 @@ production-readiness candidate. Candidate version: 0.1.0 Candidate branch: codex-production-readiness-plan Candidate branch head SHA before CI repair: fe6f44ae86fe4305301dd501d8fcdb0cb6874046 +Candidate branch head SHA with passing CI: 0b442b53ff827be491f61ba1c11eee1c3c386be3 Local validation baseline SHA: 51c3be4c5b5e4d67b093f0f7aaafc96ed244e26d Target branch: main Supported platforms for this candidate: Windows @@ -134,6 +135,24 @@ binary artifact in a clean checkout. Repair action: the integration tests now use the generated `test_char.fspk` fixture that CI builds from tracked `characters/test_char` data. +Passing observed state on 2026-05-23: + +```text +Pull request: https://github.com/RobDavenport/framesmith/pull/1 +Candidate SHA: 0b442b53ff827be491f61ba1c11eee1c3c386be3 +GitHub Actions run: https://github.com/RobDavenport/framesmith/actions/runs/26328577057 +Workflow/job: CI / Windows Checks +CI status: passed +Installer artifact: framesmith-windows-installers +Artifact ID: 7175845263 +Artifact digest: sha256:bc208ae2bc18fd2435ed7b18079e37b2d00ec2c72a499eeaf601fe8de849c4f3 +Artifact expires: 2026-08-21T08:52:31Z +``` + +Clean-checkout CI is now verified for this candidate SHA. The release remains +not production-ready until branch protection requires the CI gate and the Windows +installer artifact is manually smoke tested. + ## Branch Protection State Observed state on 2026-05-23: @@ -155,6 +174,7 @@ Observed state on 2026-05-23: Windows version: not recorded Architecture: not recorded MSI source: local build path exists +CI installer artifact: framesmith-windows-installers, artifact ID 7175845263 MSI result: not manually smoke tested NSIS source: local build path exists NSIS result: not manually smoke tested @@ -168,5 +188,5 @@ Windows machine or clean VM before marking the installer gate complete. ```text Decision: not ready -Reason: GitHub clean-checkout CI is currently failing pending repair verification; required-CI branch protection and manual Windows installer smoke evidence are still missing. +Reason: GitHub clean-checkout CI now passes, but required-CI branch protection and manual Windows installer smoke evidence are still missing. ``` diff --git a/src-tauri/tests/production_docs.rs b/src-tauri/tests/production_docs.rs index 6d4f89a..7c03088 100644 --- a/src-tauri/tests/production_docs.rs +++ b/src-tauri/tests/production_docs.rs @@ -260,6 +260,10 @@ fn current_release_evidence_records_external_blockers() { "https://github.com/RobDavenport/framesmith/actions/runs/26328384684", "ignored legacy fixture `exports/glitch.fspk`", "integration tests now use the generated `test_char.fspk`", + "Candidate branch head SHA with passing CI: 0b442b53ff827be491f61ba1c11eee1c3c386be3", + "https://github.com/RobDavenport/framesmith/actions/runs/26328577057", + "CI status: passed", + "Artifact ID: 7175845263", "MSI result: not manually smoke tested", "Decision: not ready", ] { @@ -272,6 +276,7 @@ fn current_release_evidence_records_external_blockers() { assert!(DOCS_INDEX.contains("release-evidence-2026-05-23.md")); assert!(PRODUCTION_PLAN.contains("release-evidence-2026-05-23.md")); assert!(PRODUCTION_PLAN.contains("[x] Current candidate release evidence is recorded.")); + assert!(PRODUCTION_PLAN.contains("[x] Clean-checkout CI run has passed.")); } #[test] From 369e367a2715c9d67050faa9579be60aef0b7f35 Mon Sep 17 00:00:00 2001 From: RobDavenport Date: Sat, 23 May 2026 18:34:02 +0900 Subject: [PATCH 08/15] Stabilize browser smoke waits --- tests/e2e/editor-smoke.spec.ts | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/tests/e2e/editor-smoke.spec.ts b/tests/e2e/editor-smoke.spec.ts index 3de8615..3e81799 100644 --- a/tests/e2e/editor-smoke.spec.ts +++ b/tests/e2e/editor-smoke.spec.ts @@ -250,7 +250,16 @@ async function openMockedEditor(page: Page) { await page.goto('/'); } +async function expectTrainingHudReady(page: Page) { + await expect(page.getByText('Failed to initialize training mode')).not.toBeVisible({ timeout: 15_000 }); + await expect(page.locator('.hud')).toBeVisible({ timeout: 15_000 }); + await expect(page.locator('.health-section.player .health-label')).toHaveText('P1'); + await expect(page.locator('.health-section.dummy .health-label')).toHaveText('CPU'); +} + test('loads the sample project and exercises core editor workflows', async ({ page }) => { + test.setTimeout(60_000); + await openMockedEditor(page); await page.getByRole('button', { name: 'Open...' }).click(); @@ -264,7 +273,8 @@ test('loads the sample project and exercises core editor workflows', async ({ pa await expect(page.getByText('Tag Rules')).toBeVisible(); await page.getByRole('button', { name: 'Frame Data' }).click(); - await expect(page.getByText('Standing Light')).toBeVisible(); + await expect(page.locator('.frame-table')).toBeVisible({ timeout: 15_000 }); + await expect(page.locator('.frame-table tbody')).toContainText('Standing Light', { timeout: 15_000 }); await page.getByRole('button', { name: 'State Editor' }).click(); await page.getByLabel('Move:').selectOption('5L'); @@ -312,23 +322,21 @@ test('selects resolved variants by id and keeps them read-only in the editor', a }); test('loads training mode from rebuilt WASM and exported FSPK data', async ({ page }) => { + test.setTimeout(60_000); + await openMockedEditor(page); await page.getByRole('button', { name: 'Open...' }).click(); await page.getByRole('button', { name: /TEST_CHAR/ }).click(); await page.getByRole('button', { name: 'Training', exact: true }).click(); - await expect(page.getByText('Initializing training mode...')).toBeVisible(); - await expect(page.getByText('Failed to initialize training mode')).not.toBeVisible({ timeout: 15_000 }); - await expect(page.getByText('P1', { exact: true })).toBeVisible({ timeout: 15_000 }); - await expect(page.getByText('CPU', { exact: true })).toBeVisible(); + await expectTrainingHudReady(page); const dummyCharacterSelect = page.getByLabel('Character', { exact: true }); await expect(dummyCharacterSelect).toHaveValue(characterId); await dummyCharacterSelect.selectOption('dummy_char'); - await expect(page.getByText('Failed to initialize training mode')).not.toBeVisible({ timeout: 15_000 }); + await expectTrainingHudReady(page); await expect(dummyCharacterSelect).toHaveValue('dummy_char'); - await expect(page.getByText('CPU', { exact: true })).toBeVisible(); }); test('loads detached training mode through BroadcastChannel sync', async ({ page, context }) => { @@ -345,8 +353,7 @@ test('loads detached training mode through BroadcastChannel sync', async ({ page waitUntil: 'domcontentloaded', }); - await expect(page.getByText('P1', { exact: true })).toBeVisible({ timeout: 15_000 }); - await expect(page.getByText('CPU', { exact: true })).toBeVisible(); + await expectTrainingHudReady(page); await expect(page.getByText('Frame:')).toBeVisible(); await mainPage.close(); From 3123a35b73f830d2fdb3909f1510e2edfcf1f304 Mon Sep 17 00:00:00 2001 From: RobDavenport Date: Sat, 23 May 2026 19:19:55 +0900 Subject: [PATCH 09/15] Record current CI evidence --- docs/production-readiness-plan.md | 24 ++++++++++++---- docs/release-evidence-2026-05-23.md | 44 +++++++++++++++++++++++++++-- src-tauri/tests/production_docs.rs | 9 ++++-- 3 files changed, 66 insertions(+), 11 deletions(-) diff --git a/docs/production-readiness-plan.md b/docs/production-readiness-plan.md index 862c6c2..5804045 100644 --- a/docs/production-readiness-plan.md +++ b/docs/production-readiness-plan.md @@ -24,7 +24,12 @@ runtime WASM crate test. A follow-up run showed the same test crate still referenced the ignored legacy `exports/glitch.fspk`; those integration tests now use the generated `test_char.fspk` fixture instead. Run `26328577057` passed on GitHub for candidate SHA `0b442b53ff827be491f61ba1c11eee1c3c386be3` and -uploaded the `framesmith-windows-installers` artifact. +uploaded the `framesmith-windows-installers` artifact. A later evidence-only +commit exposed browser-smoke timing flakiness; the smoke tests now wait on +stable frame-table and training HUD elements. Current-head run `26329764725` +passed for candidate SHA `369e367a2715c9d67050faa9579be60aef0b7f35` and +uploaded installer artifact `7176181990`. Check the latest PR head before merge +because evidence-only commits can trigger another CI run. ## Readiness Definition @@ -71,11 +76,12 @@ Verified on 2026-05-23 in the current Windows workspace: | `cargo clippy --manifest-path src-tauri/Cargo.toml --all-targets -- -D warnings` | Pass | CI-level backend clippy gate is clean. | | Runtime crate clippy with `-D warnings` | Pass | `framesmith-runtime`, `framesmith-runtime-wasm`, and `framesmith-fspack` are clean. | | Cargo fmt checks | Pass | Formatting is clean for `src-tauri` and runtime crates. | -| CI workflow | Pass | `.github/workflows/ci.yml` passed on GitHub Actions run `26328577057`, checking dependency audit, generated WASM, generated schemas, runtime WASM FSPK fixture generation, formatting, frontend, browser smoke tests, Rust tests, backend/runtime clippy, Tauri packaging, and Windows installer artifact upload; branch protection still must be configured in GitHub. | +| CI workflow | Pass | `.github/workflows/ci.yml` passed on GitHub Actions run `26329764725`, checking dependency audit, generated WASM, generated schemas, runtime WASM FSPK fixture generation, formatting, frontend, browser smoke tests, Rust tests, backend/runtime clippy, Tauri packaging, and Windows installer artifact upload; branch protection still must be configured in GitHub. | Clean-checkout CI is verified for candidate SHA -`0b442b53ff827be491f61ba1c11eee1c3c386be3`. Required-branch enforcement and -manual installer smoke testing are still open. +`369e367a2715c9d67050faa9579be60aef0b7f35`. Required-branch enforcement and +manual installer smoke testing are still open; if another commit lands, the new +PR head must also pass CI. Current candidate evidence is recorded in [`release-evidence-2026-05-23.md`](release-evidence-2026-05-23.md). The @@ -86,7 +92,9 @@ WASM crate expected an ignored FSPK fixture. The workflow and runbook now generate the fixture before runtime WASM tests. A third run exposed a remaining legacy `glitch.fspk` include in the same test crate; the test now uses the generated `test_char.fspk` fixture. The next run passed and uploaded installer -artifact `framesmith-windows-installers`. +artifact `framesmith-windows-installers`. A subsequent evidence-only commit +exposed browser-smoke timing flakiness; the tests were hardened and current-head +run `26329764725` passed. ## Completed Since Audit @@ -127,6 +135,12 @@ artifact `framesmith-windows-installers`. Status: GitHub clean-run complete; branch protection remains external. +Automation note: the available GitHub connector reports admin permission on the +repository but does not expose branch-protection or ruleset mutation. The local +environment does not have `gh`, and Git credential lookup timed out, so required +CI enforcement still needs to be configured in GitHub settings or through an +authenticated API client. + Actions: - Make the CI workflow required before merges. diff --git a/docs/release-evidence-2026-05-23.md b/docs/release-evidence-2026-05-23.md index d21f1b0..cf2740c 100644 --- a/docs/release-evidence-2026-05-23.md +++ b/docs/release-evidence-2026-05-23.md @@ -12,7 +12,7 @@ production-readiness candidate. Candidate version: 0.1.0 Candidate branch: codex-production-readiness-plan Candidate branch head SHA before CI repair: fe6f44ae86fe4305301dd501d8fcdb0cb6874046 -Candidate branch head SHA with passing CI: 0b442b53ff827be491f61ba1c11eee1c3c386be3 +Latest observed passing CI SHA before this evidence update: 369e367a2715c9d67050faa9579be60aef0b7f35 Local validation baseline SHA: 51c3be4c5b5e4d67b093f0f7aaafc96ed244e26d Target branch: main Supported platforms for this candidate: Windows @@ -42,7 +42,8 @@ npm audit npm ls @tauri-apps/api @tauri-apps/cli @tauri-apps/plugin-opener cargo run --manifest-path src-tauri/Cargo.toml --bin generate_schema npm run wasm:build -git diff --exit-code -- src/lib/wasm +pwsh -NoProfile -Command "if (-not (Test-Path 'src/lib/wasm/framesmith_runtime_wasm.js')) { throw 'Missing generated WASM JavaScript binding' }; if (-not (Test-Path 'src/lib/wasm/framesmith_runtime_wasm.d.ts')) { throw 'Missing generated WASM TypeScript declarations' }" +git diff --exit-code -- schemas/rules.schema.json npm run check npm run test:run npm run test:e2e @@ -53,6 +54,7 @@ cargo fmt --check --manifest-path crates/framesmith-runtime-wasm/Cargo.toml cargo fmt --check --manifest-path crates/framesmith-fspack/Cargo.toml cargo test --manifest-path src-tauri/Cargo.toml cargo test --manifest-path crates/framesmith-runtime/Cargo.toml +cargo run --manifest-path src-tauri/Cargo.toml --bin framesmith-cli -- export --project . --character test_char --adapter fspk --out exports/test_char.fspk cargo test --manifest-path crates/framesmith-runtime-wasm/Cargo.toml cargo test --manifest-path crates/framesmith-fspack/Cargo.toml cargo clippy --manifest-path src-tauri/Cargo.toml --all-targets -- -D warnings @@ -153,6 +155,41 @@ Clean-checkout CI is now verified for this candidate SHA. The release remains not production-ready until branch protection requires the CI gate and the Windows installer artifact is manually smoke tested. +Fourth observed state on 2026-05-23 after an evidence-only commit: + +```text +Pull request: https://github.com/RobDavenport/framesmith/pull/1 +Candidate SHA: 577ffe6b835de8eb5ece0fcf524501a86f58cf68 +GitHub Actions run: https://github.com/RobDavenport/framesmith/actions/runs/26329160394 +Workflow/job: CI / Windows Checks +CI status: failed +Failing step: Browser smoke tests +Failure summary: Playwright smoke tests waited on transient text and short timeouts +``` + +Repair action: browser smoke tests now wait on stable frame-table and training +HUD elements, avoid asserting the transient initialization message, and give the +heavier smoke paths a 60-second test budget. + +Latest passing observed state before this evidence update on 2026-05-23: + +```text +Pull request: https://github.com/RobDavenport/framesmith/pull/1 +Candidate SHA: 369e367a2715c9d67050faa9579be60aef0b7f35 +GitHub Actions run: https://github.com/RobDavenport/framesmith/actions/runs/26329764725 +Workflow/job: CI / Windows Checks +CI status: passed +Installer artifact: framesmith-windows-installers +Artifact ID: 7176181990 +Artifact digest: sha256:ec363840dbfcf4c4b45d51e7199b10f4f60a6cfa5a597d8ffd7135eb0a906184 +Artifact expires: 2026-08-21T09:56:05Z +``` + +Clean-checkout CI is verified for this observed candidate SHA. Before merging, +the latest PR head must still show a passing CI check. The release remains not +production-ready until branch protection requires the CI gate and the Windows +installer artifact is manually smoke tested. + ## Branch Protection State Observed state on 2026-05-23: @@ -161,6 +198,7 @@ Observed state on 2026-05-23: Branch/ruleset evidence: not available through the current connector Required CI evidence: not available Blocked merge evidence: not available +Automation note: repository connector reports admin permission, but exposes no branch-protection/ruleset mutation; GitHub CLI is not installed; local Git credential lookup timed out without returning usable API credentials. ``` The maintainer must configure or verify branch protection/rulesets requiring @@ -174,7 +212,7 @@ Observed state on 2026-05-23: Windows version: not recorded Architecture: not recorded MSI source: local build path exists -CI installer artifact: framesmith-windows-installers, artifact ID 7175845263 +CI installer artifact: framesmith-windows-installers, artifact ID 7176181990 MSI result: not manually smoke tested NSIS source: local build path exists NSIS result: not manually smoke tested diff --git a/src-tauri/tests/production_docs.rs b/src-tauri/tests/production_docs.rs index 7c03088..0cdcdbd 100644 --- a/src-tauri/tests/production_docs.rs +++ b/src-tauri/tests/production_docs.rs @@ -260,10 +260,13 @@ fn current_release_evidence_records_external_blockers() { "https://github.com/RobDavenport/framesmith/actions/runs/26328384684", "ignored legacy fixture `exports/glitch.fspk`", "integration tests now use the generated `test_char.fspk`", - "Candidate branch head SHA with passing CI: 0b442b53ff827be491f61ba1c11eee1c3c386be3", + "Latest observed passing CI SHA before this evidence update: 369e367a2715c9d67050faa9579be60aef0b7f35", "https://github.com/RobDavenport/framesmith/actions/runs/26328577057", - "CI status: passed", - "Artifact ID: 7175845263", + "https://github.com/RobDavenport/framesmith/actions/runs/26329160394", + "browser smoke tests now wait on stable frame-table and training", + "https://github.com/RobDavenport/framesmith/actions/runs/26329764725", + "Artifact ID: 7176181990", + "Automation note: repository connector reports admin permission", "MSI result: not manually smoke tested", "Decision: not ready", ] { From 4df4b57ee9a0066f0a669dea3bd7c95ad0b75883 Mon Sep 17 00:00:00 2001 From: RobDavenport Date: Sat, 23 May 2026 19:45:39 +0900 Subject: [PATCH 10/15] Verify installer outputs in CI --- .github/workflows/ci.yml | 17 +++++++++++++++++ docs/production-readiness-plan.md | 2 +- docs/release-evidence-2026-05-23.md | 9 +++++++++ docs/release-runbook.md | 1 + src-tauri/tests/production_docs.rs | 25 +++++++++++++++++++++++++ 5 files changed, 53 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a8ab3a2..3ca084d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -102,6 +102,23 @@ jobs: - name: Build Tauri app run: npm run tauri build + - name: Verify Windows installer outputs + run: | + $msi = Get-ChildItem -Path src-tauri/target/release/bundle/msi -Filter *.msi -File + $nsis = Get-ChildItem -Path src-tauri/target/release/bundle/nsis -Filter *setup.exe -File + if ($msi.Count -eq 0) { + throw "No MSI installer was produced" + } + if ($nsis.Count -eq 0) { + throw "No NSIS setup executable was produced" + } + foreach ($file in @($msi + $nsis)) { + if ($file.Length -le 0) { + throw "Installer output is empty: $($file.FullName)" + } + Write-Host "$($file.Name) $($file.Length) bytes" + } + - name: Upload Windows installers uses: actions/upload-artifact@v4 with: diff --git a/docs/production-readiness-plan.md b/docs/production-readiness-plan.md index 5804045..9c1ee90 100644 --- a/docs/production-readiness-plan.md +++ b/docs/production-readiness-plan.md @@ -76,7 +76,7 @@ Verified on 2026-05-23 in the current Windows workspace: | `cargo clippy --manifest-path src-tauri/Cargo.toml --all-targets -- -D warnings` | Pass | CI-level backend clippy gate is clean. | | Runtime crate clippy with `-D warnings` | Pass | `framesmith-runtime`, `framesmith-runtime-wasm`, and `framesmith-fspack` are clean. | | Cargo fmt checks | Pass | Formatting is clean for `src-tauri` and runtime crates. | -| CI workflow | Pass | `.github/workflows/ci.yml` passed on GitHub Actions run `26329764725`, checking dependency audit, generated WASM, generated schemas, runtime WASM FSPK fixture generation, formatting, frontend, browser smoke tests, Rust tests, backend/runtime clippy, Tauri packaging, and Windows installer artifact upload; branch protection still must be configured in GitHub. | +| CI workflow | Pass | `.github/workflows/ci.yml` passed on GitHub Actions run `26329764725`, checking dependency audit, generated WASM, generated schemas, runtime WASM FSPK fixture generation, formatting, frontend, browser smoke tests, Rust tests, backend/runtime clippy, Tauri packaging, non-empty Windows installer outputs, and Windows installer artifact upload; branch protection still must be configured in GitHub. | Clean-checkout CI is verified for candidate SHA `369e367a2715c9d67050faa9579be60aef0b7f35`. Required-branch enforcement and diff --git a/docs/release-evidence-2026-05-23.md b/docs/release-evidence-2026-05-23.md index cf2740c..0fdaee2 100644 --- a/docs/release-evidence-2026-05-23.md +++ b/docs/release-evidence-2026-05-23.md @@ -62,6 +62,7 @@ cargo clippy --manifest-path crates/framesmith-runtime/Cargo.toml --all-targets cargo clippy --manifest-path crates/framesmith-runtime-wasm/Cargo.toml --all-targets -- -D warnings cargo clippy --manifest-path crates/framesmith-fspack/Cargo.toml --all-targets -- -D warnings npm run tauri build +pwsh -NoProfile -Command "$msi = Get-ChildItem -Path 'src-tauri/target/release/bundle/msi' -Filter '*.msi' -File; $nsis = Get-ChildItem -Path 'src-tauri/target/release/bundle/nsis' -Filter '*setup.exe' -File; if ($msi.Count -eq 0) { throw 'No MSI installer was produced' }; if ($nsis.Count -eq 0) { throw 'No NSIS setup executable was produced' }; foreach ($file in @($msi + $nsis)) { if ($file.Length -le 0) { throw \"Installer output is empty: $($file.FullName)\" } }" git diff --check ``` @@ -190,6 +191,14 @@ the latest PR head must still show a passing CI check. The release remains not production-ready until branch protection requires the CI gate and the Windows installer artifact is manually smoke tested. +Additional hardening after this run: the CI workflow now verifies that at least +one MSI and one NSIS setup executable exist and that all installer outputs are +non-empty before uploading the `framesmith-windows-installers` artifact. Shell +attempts to download and inspect artifact `7176298714` from the signed file URL +failed in this environment because PowerShell/curl could not complete the TLS +download; GitHub artifact metadata remains the authoritative evidence for the +remote artifact until manual smoke testing downloads it. + ## Branch Protection State Observed state on 2026-05-23: diff --git a/docs/release-runbook.md b/docs/release-runbook.md index e085d49..694f2eb 100644 --- a/docs/release-runbook.md +++ b/docs/release-runbook.md @@ -62,6 +62,7 @@ cargo clippy --manifest-path crates/framesmith-runtime/Cargo.toml --all-targets cargo clippy --manifest-path crates/framesmith-runtime-wasm/Cargo.toml --all-targets -- -D warnings cargo clippy --manifest-path crates/framesmith-fspack/Cargo.toml --all-targets -- -D warnings npm run tauri build +pwsh -NoProfile -Command "$msi = Get-ChildItem -Path 'src-tauri/target/release/bundle/msi' -Filter '*.msi' -File; $nsis = Get-ChildItem -Path 'src-tauri/target/release/bundle/nsis' -Filter '*setup.exe' -File; if ($msi.Count -eq 0) { throw 'No MSI installer was produced' }; if ($nsis.Count -eq 0) { throw 'No NSIS setup executable was produced' }; foreach ($file in @($msi + $nsis)) { if ($file.Length -le 0) { throw \"Installer output is empty: $($file.FullName)\" } }" ``` Expected local package outputs: diff --git a/src-tauri/tests/production_docs.rs b/src-tauri/tests/production_docs.rs index 0cdcdbd..9e011e9 100644 --- a/src-tauri/tests/production_docs.rs +++ b/src-tauri/tests/production_docs.rs @@ -226,6 +226,8 @@ fn release_runbook_covers_candidate_evidence() { "Test-Path 'src/lib/wasm/framesmith_runtime_wasm.js'", "Test-Path 'src/lib/wasm/framesmith_runtime_wasm.d.ts'", "cargo run --manifest-path src-tauri/Cargo.toml --bin framesmith-cli -- export --project . --character test_char --adapter fspk --out exports/test_char.fspk", + "No MSI installer was produced", + "No NSIS setup executable was produced", "git diff --exit-code -- schemas/rules.schema.json", "GitHub Actions URL", "Protected branch/ruleset", @@ -266,6 +268,8 @@ fn current_release_evidence_records_external_blockers() { "browser smoke tests now wait on stable frame-table and training", "https://github.com/RobDavenport/framesmith/actions/runs/26329764725", "Artifact ID: 7176181990", + "the CI workflow now verifies that at least", + "attempts to download and inspect artifact `7176298714`", "Automation note: repository connector reports admin permission", "MSI result: not manually smoke tested", "Decision: not ready", @@ -317,3 +321,24 @@ fn ci_generates_runtime_wasm_fixture_before_crate_test() { ); assert!(CI_WORKFLOW.contains("--bin framesmith-cli -- export --project . --character test_char --adapter fspk --out exports/test_char.fspk")); } + +#[test] +fn ci_verifies_windows_installers_before_upload() { + let build_tauri = CI_WORKFLOW + .find("name: Build Tauri app") + .expect("CI should build the Tauri app"); + let verify_installers = CI_WORKFLOW + .find("name: Verify Windows installer outputs") + .expect("CI should verify Windows installer outputs"); + let upload = CI_WORKFLOW + .find("name: Upload Windows installers") + .expect("CI should upload Windows installers"); + + assert!( + build_tauri < verify_installers && verify_installers < upload, + "CI must verify installer outputs before artifact upload" + ); + assert!(CI_WORKFLOW.contains("No MSI installer was produced")); + assert!(CI_WORKFLOW.contains("No NSIS setup executable was produced")); + assert!(CI_WORKFLOW.contains("Installer output is empty")); +} From ef14779e718a5ffd22666f9533f6b9023db42355 Mon Sep 17 00:00:00 2001 From: RobDavenport Date: Sat, 23 May 2026 20:12:45 +0900 Subject: [PATCH 11/15] Fix installer output verification --- .github/workflows/ci.yml | 3 ++- docs/release-evidence-2026-05-23.md | 18 +++++++++++++++++- docs/release-runbook.md | 2 +- src-tauri/tests/production_docs.rs | 3 +++ 4 files changed, 23 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3ca084d..9a65588 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -112,7 +112,8 @@ jobs: if ($nsis.Count -eq 0) { throw "No NSIS setup executable was produced" } - foreach ($file in @($msi + $nsis)) { + $installerFiles = @($msi) + @($nsis) + foreach ($file in $installerFiles) { if ($file.Length -le 0) { throw "Installer output is empty: $($file.FullName)" } diff --git a/docs/release-evidence-2026-05-23.md b/docs/release-evidence-2026-05-23.md index 0fdaee2..a5e8a26 100644 --- a/docs/release-evidence-2026-05-23.md +++ b/docs/release-evidence-2026-05-23.md @@ -62,7 +62,7 @@ cargo clippy --manifest-path crates/framesmith-runtime/Cargo.toml --all-targets cargo clippy --manifest-path crates/framesmith-runtime-wasm/Cargo.toml --all-targets -- -D warnings cargo clippy --manifest-path crates/framesmith-fspack/Cargo.toml --all-targets -- -D warnings npm run tauri build -pwsh -NoProfile -Command "$msi = Get-ChildItem -Path 'src-tauri/target/release/bundle/msi' -Filter '*.msi' -File; $nsis = Get-ChildItem -Path 'src-tauri/target/release/bundle/nsis' -Filter '*setup.exe' -File; if ($msi.Count -eq 0) { throw 'No MSI installer was produced' }; if ($nsis.Count -eq 0) { throw 'No NSIS setup executable was produced' }; foreach ($file in @($msi + $nsis)) { if ($file.Length -le 0) { throw \"Installer output is empty: $($file.FullName)\" } }" +pwsh -NoProfile -Command "$msi = Get-ChildItem -Path 'src-tauri/target/release/bundle/msi' -Filter '*.msi' -File; $nsis = Get-ChildItem -Path 'src-tauri/target/release/bundle/nsis' -Filter '*setup.exe' -File; if ($msi.Count -eq 0) { throw 'No MSI installer was produced' }; if ($nsis.Count -eq 0) { throw 'No NSIS setup executable was produced' }; $installerFiles = @($msi) + @($nsis); foreach ($file in $installerFiles) { if ($file.Length -le 0) { throw \"Installer output is empty: $($file.FullName)\" } }" git diff --check ``` @@ -199,6 +199,22 @@ failed in this environment because PowerShell/curl could not complete the TLS download; GitHub artifact metadata remains the authoritative evidence for the remote artifact until manual smoke testing downloads it. +Fifth observed state on 2026-05-23 after adding installer-output verification: + +```text +Pull request: https://github.com/RobDavenport/framesmith/pull/1 +Candidate SHA: 4df4b57ee9a0066f0a669dea3bd7c95ad0b75883 +GitHub Actions run: https://github.com/RobDavenport/framesmith/actions/runs/26330825628 +Workflow/job: CI / Windows Checks +CI status: failed +Failing step: Verify Windows installer outputs +Failure summary: verifier treated single `FileInfo` values as arrays before concatenation +``` + +Repair action: the verifier now wraps both installer query results with `@(...)` +before concatenating them, so it handles the one-MSI/one-NSIS case observed in +CI while preserving the non-empty installer-output gate. + ## Branch Protection State Observed state on 2026-05-23: diff --git a/docs/release-runbook.md b/docs/release-runbook.md index 694f2eb..94b3a8e 100644 --- a/docs/release-runbook.md +++ b/docs/release-runbook.md @@ -62,7 +62,7 @@ cargo clippy --manifest-path crates/framesmith-runtime/Cargo.toml --all-targets cargo clippy --manifest-path crates/framesmith-runtime-wasm/Cargo.toml --all-targets -- -D warnings cargo clippy --manifest-path crates/framesmith-fspack/Cargo.toml --all-targets -- -D warnings npm run tauri build -pwsh -NoProfile -Command "$msi = Get-ChildItem -Path 'src-tauri/target/release/bundle/msi' -Filter '*.msi' -File; $nsis = Get-ChildItem -Path 'src-tauri/target/release/bundle/nsis' -Filter '*setup.exe' -File; if ($msi.Count -eq 0) { throw 'No MSI installer was produced' }; if ($nsis.Count -eq 0) { throw 'No NSIS setup executable was produced' }; foreach ($file in @($msi + $nsis)) { if ($file.Length -le 0) { throw \"Installer output is empty: $($file.FullName)\" } }" +pwsh -NoProfile -Command "$msi = Get-ChildItem -Path 'src-tauri/target/release/bundle/msi' -Filter '*.msi' -File; $nsis = Get-ChildItem -Path 'src-tauri/target/release/bundle/nsis' -Filter '*setup.exe' -File; if ($msi.Count -eq 0) { throw 'No MSI installer was produced' }; if ($nsis.Count -eq 0) { throw 'No NSIS setup executable was produced' }; $installerFiles = @($msi) + @($nsis); foreach ($file in $installerFiles) { if ($file.Length -le 0) { throw \"Installer output is empty: $($file.FullName)\" } }" ``` Expected local package outputs: diff --git a/src-tauri/tests/production_docs.rs b/src-tauri/tests/production_docs.rs index 9e011e9..ba77983 100644 --- a/src-tauri/tests/production_docs.rs +++ b/src-tauri/tests/production_docs.rs @@ -228,6 +228,7 @@ fn release_runbook_covers_candidate_evidence() { "cargo run --manifest-path src-tauri/Cargo.toml --bin framesmith-cli -- export --project . --character test_char --adapter fspk --out exports/test_char.fspk", "No MSI installer was produced", "No NSIS setup executable was produced", + "$installerFiles = @($msi) + @($nsis)", "git diff --exit-code -- schemas/rules.schema.json", "GitHub Actions URL", "Protected branch/ruleset", @@ -270,6 +271,8 @@ fn current_release_evidence_records_external_blockers() { "Artifact ID: 7176181990", "the CI workflow now verifies that at least", "attempts to download and inspect artifact `7176298714`", + "https://github.com/RobDavenport/framesmith/actions/runs/26330825628", + "verifier now wraps both installer query results", "Automation note: repository connector reports admin permission", "MSI result: not manually smoke tested", "Decision: not ready", From 1b9d0e47f1ebde5f14476ab91a6000612d8c153e Mon Sep 17 00:00:00 2001 From: RobDavenport Date: Sat, 23 May 2026 21:17:17 +0900 Subject: [PATCH 12/15] Record repaired PR CI evidence --- docs/production-readiness-plan.md | 24 +++++++++++++++--------- docs/release-evidence-2026-05-23.md | 25 ++++++++++++++++++++++--- src-tauri/tests/production_docs.rs | 5 ++++- 3 files changed, 41 insertions(+), 13 deletions(-) diff --git a/docs/production-readiness-plan.md b/docs/production-readiness-plan.md index 9c1ee90..934eb56 100644 --- a/docs/production-readiness-plan.md +++ b/docs/production-readiness-plan.md @@ -24,12 +24,15 @@ runtime WASM crate test. A follow-up run showed the same test crate still referenced the ignored legacy `exports/glitch.fspk`; those integration tests now use the generated `test_char.fspk` fixture instead. Run `26328577057` passed on GitHub for candidate SHA `0b442b53ff827be491f61ba1c11eee1c3c386be3` and -uploaded the `framesmith-windows-installers` artifact. A later evidence-only -commit exposed browser-smoke timing flakiness; the smoke tests now wait on -stable frame-table and training HUD elements. Current-head run `26329764725` -passed for candidate SHA `369e367a2715c9d67050faa9579be60aef0b7f35` and -uploaded installer artifact `7176181990`. Check the latest PR head before merge -because evidence-only commits can trigger another CI run. +uploaded the `framesmith-windows-installers` artifact. Later PR runs exposed +browser-smoke timing flakiness and a Windows installer verifier bug in the +single-MSI/single-NSIS case; the smoke tests now wait on stable frame-table and +training HUD elements, and the verifier wraps both installer query results +before concatenation. The latest code-bearing PR run observed before this +documentation update, `26332001870`, passed for candidate SHA +`ef14779e718a5ffd22666f9533f6b9023db42355` and uploaded installer artifact +`7176775382`. Check the latest PR head before merge because evidence-only +commits can trigger another CI run. ## Readiness Definition @@ -76,10 +79,10 @@ Verified on 2026-05-23 in the current Windows workspace: | `cargo clippy --manifest-path src-tauri/Cargo.toml --all-targets -- -D warnings` | Pass | CI-level backend clippy gate is clean. | | Runtime crate clippy with `-D warnings` | Pass | `framesmith-runtime`, `framesmith-runtime-wasm`, and `framesmith-fspack` are clean. | | Cargo fmt checks | Pass | Formatting is clean for `src-tauri` and runtime crates. | -| CI workflow | Pass | `.github/workflows/ci.yml` passed on GitHub Actions run `26329764725`, checking dependency audit, generated WASM, generated schemas, runtime WASM FSPK fixture generation, formatting, frontend, browser smoke tests, Rust tests, backend/runtime clippy, Tauri packaging, non-empty Windows installer outputs, and Windows installer artifact upload; branch protection still must be configured in GitHub. | +| CI workflow | Pass | `.github/workflows/ci.yml` passed on GitHub Actions run `26332001870`, checking dependency audit, generated WASM, generated schemas, runtime WASM FSPK fixture generation, formatting, frontend, browser smoke tests, Rust tests, backend/runtime clippy, Tauri packaging, non-empty Windows installer outputs, and Windows installer artifact upload; branch protection still must be configured in GitHub. | Clean-checkout CI is verified for candidate SHA -`369e367a2715c9d67050faa9579be60aef0b7f35`. Required-branch enforcement and +`ef14779e718a5ffd22666f9533f6b9023db42355`. Required-branch enforcement and manual installer smoke testing are still open; if another commit lands, the new PR head must also pass CI. @@ -94,7 +97,10 @@ legacy `glitch.fspk` include in the same test crate; the test now uses the generated `test_char.fspk` fixture. The next run passed and uploaded installer artifact `framesmith-windows-installers`. A subsequent evidence-only commit exposed browser-smoke timing flakiness; the tests were hardened and current-head -run `26329764725` passed. +run `26329764725` passed. A later installer-output verification hardening commit +failed because the verifier did not handle single `FileInfo` values, then run +`26332001870` passed after wrapping both installer query results before +concatenation. ## Completed Since Audit diff --git a/docs/release-evidence-2026-05-23.md b/docs/release-evidence-2026-05-23.md index a5e8a26..706b0f0 100644 --- a/docs/release-evidence-2026-05-23.md +++ b/docs/release-evidence-2026-05-23.md @@ -12,7 +12,7 @@ production-readiness candidate. Candidate version: 0.1.0 Candidate branch: codex-production-readiness-plan Candidate branch head SHA before CI repair: fe6f44ae86fe4305301dd501d8fcdb0cb6874046 -Latest observed passing CI SHA before this evidence update: 369e367a2715c9d67050faa9579be60aef0b7f35 +Latest observed passing CI SHA before this evidence update: ef14779e718a5ffd22666f9533f6b9023db42355 Local validation baseline SHA: 51c3be4c5b5e4d67b093f0f7aaafc96ed244e26d Target branch: main Supported platforms for this candidate: Windows @@ -172,7 +172,7 @@ Repair action: browser smoke tests now wait on stable frame-table and training HUD elements, avoid asserting the transient initialization message, and give the heavier smoke paths a 60-second test budget. -Latest passing observed state before this evidence update on 2026-05-23: +Passing observed state after browser-smoke hardening on 2026-05-23: ```text Pull request: https://github.com/RobDavenport/framesmith/pull/1 @@ -215,6 +215,25 @@ Repair action: the verifier now wraps both installer query results with `@(...)` before concatenating them, so it handles the one-MSI/one-NSIS case observed in CI while preserving the non-empty installer-output gate. +Latest passing observed state before this evidence update on 2026-05-23: + +```text +Pull request: https://github.com/RobDavenport/framesmith/pull/1 +Candidate SHA: ef14779e718a5ffd22666f9533f6b9023db42355 +GitHub Actions run: https://github.com/RobDavenport/framesmith/actions/runs/26332001870 +Workflow/job: CI / Windows Checks +CI status: passed +Installer artifact: framesmith-windows-installers +Artifact ID: 7176775382 +Artifact digest: sha256:41245e80a68d4c02a3089d81cfafae6d86a981eb6cecdc07428eb2c553fff5ac +Artifact expires: 2026-08-21T11:55:22Z +``` + +This run passed the repaired `Verify Windows installer outputs` step and the +artifact upload. The release remains not production-ready until branch +protection requires the CI gate and the Windows installer artifact is manually +smoke tested. + ## Branch Protection State Observed state on 2026-05-23: @@ -237,7 +256,7 @@ Observed state on 2026-05-23: Windows version: not recorded Architecture: not recorded MSI source: local build path exists -CI installer artifact: framesmith-windows-installers, artifact ID 7176181990 +CI installer artifact: framesmith-windows-installers, artifact ID 7176775382 MSI result: not manually smoke tested NSIS source: local build path exists NSIS result: not manually smoke tested diff --git a/src-tauri/tests/production_docs.rs b/src-tauri/tests/production_docs.rs index ba77983..ae31cce 100644 --- a/src-tauri/tests/production_docs.rs +++ b/src-tauri/tests/production_docs.rs @@ -263,7 +263,7 @@ fn current_release_evidence_records_external_blockers() { "https://github.com/RobDavenport/framesmith/actions/runs/26328384684", "ignored legacy fixture `exports/glitch.fspk`", "integration tests now use the generated `test_char.fspk`", - "Latest observed passing CI SHA before this evidence update: 369e367a2715c9d67050faa9579be60aef0b7f35", + "Latest observed passing CI SHA before this evidence update: ef14779e718a5ffd22666f9533f6b9023db42355", "https://github.com/RobDavenport/framesmith/actions/runs/26328577057", "https://github.com/RobDavenport/framesmith/actions/runs/26329160394", "browser smoke tests now wait on stable frame-table and training", @@ -273,6 +273,9 @@ fn current_release_evidence_records_external_blockers() { "attempts to download and inspect artifact `7176298714`", "https://github.com/RobDavenport/framesmith/actions/runs/26330825628", "verifier now wraps both installer query results", + "https://github.com/RobDavenport/framesmith/actions/runs/26332001870", + "Artifact ID: 7176775382", + "passed the repaired `Verify Windows installer outputs` step", "Automation note: repository connector reports admin permission", "MSI result: not manually smoke tested", "Decision: not ready", From 0e724996c8f7795402a3afc49e7e894ca0e15796 Mon Sep 17 00:00:00 2001 From: RobDavenport Date: Sat, 23 May 2026 21:43:20 +0900 Subject: [PATCH 13/15] Document required branch protection gate --- docs/README.md | 4 +- docs/branch-protection-setup.md | 86 +++++++++++++++++++++++++++++ docs/production-gap-backlog.md | 6 +- docs/production-readiness-plan.md | 31 ++++++++++- docs/release-evidence-2026-05-23.md | 5 +- docs/release-runbook.md | 12 ++-- src-tauri/tests/production_docs.rs | 50 +++++++++++++++++ 7 files changed, 186 insertions(+), 8 deletions(-) create mode 100644 docs/branch-protection-setup.md diff --git a/docs/README.md b/docs/README.md index c4566eb..24be561 100644 --- a/docs/README.md +++ b/docs/README.md @@ -24,6 +24,7 @@ Last reviewed: 2026-05-23 | Training scenario contract | [`training-scenario-contract.md`](training-scenario-contract.md) | | Production gap backlog | [`production-gap-backlog.md`](production-gap-backlog.md) | | Release runbook | [`release-runbook.md`](release-runbook.md) | +| Branch protection setup | [`branch-protection-setup.md`](branch-protection-setup.md) | | Current release evidence | [`release-evidence-2026-05-23.md`](release-evidence-2026-05-23.md) | | Schema migration notes | [`schema-migration.md`](schema-migration.md) | | Windows installer smoke test | [`windows-installer-smoke-test.md`](windows-installer-smoke-test.md) | @@ -44,7 +45,7 @@ Last reviewed: 2026-05-23 - Implementing export/runtime work: read [`production-handoff-decision.md`](production-handoff-decision.md), [`variant-editing-decision.md`](variant-editing-decision.md), [`combat-coverage.md`](combat-coverage.md), [`training-scenario-contract.md`](training-scenario-contract.md), [`export-fidelity-contract.md`](export-fidelity-contract.md), [`zx-fspack.md`](zx-fspack.md), and [`runtime-guide.md`](runtime-guide.md) - Understanding the system architecture: read [`architecture.md`](architecture.md) - Debugging issues: read [`troubleshooting.md`](troubleshooting.md) -- Tracking release blockers: read [`production-readiness-plan.md`](production-readiness-plan.md), [`production-gap-backlog.md`](production-gap-backlog.md), and [`release-runbook.md`](release-runbook.md) +- Tracking release blockers: read [`production-readiness-plan.md`](production-readiness-plan.md), [`production-gap-backlog.md`](production-gap-backlog.md), [`release-runbook.md`](release-runbook.md), and [`branch-protection-setup.md`](branch-protection-setup.md) - Migrating older project data: read [`schema-migration.md`](schema-migration.md) - Testing Windows release artifacts: read [`windows-installer-smoke-test.md`](windows-installer-smoke-test.md) @@ -66,6 +67,7 @@ Last reviewed: 2026-05-23 | [`training-scenario-contract.md`](training-scenario-contract.md) | Executable target training scenarios and ownership policy | | [`production-gap-backlog.md`](production-gap-backlog.md) | Concrete implementation issues for external gates and future target-game gaps | | [`release-runbook.md`](release-runbook.md) | Repeatable release-candidate validation and evidence capture steps | +| [`branch-protection-setup.md`](branch-protection-setup.md) | Exact required CI branch-protection settings and evidence template | | [`release-evidence-2026-05-23.md`](release-evidence-2026-05-23.md) | Current candidate validation evidence and external release blockers | | [`schema-migration.md`](schema-migration.md) | Migration notes for current character, cancel, variant, and adapter schema changes | | [`windows-installer-smoke-test.md`](windows-installer-smoke-test.md) | Manual MSI/NSIS smoke-test steps and evidence to record | diff --git a/docs/branch-protection-setup.md b/docs/branch-protection-setup.md new file mode 100644 index 0000000..7a670bc --- /dev/null +++ b/docs/branch-protection-setup.md @@ -0,0 +1,86 @@ +# Branch Protection Setup + +Status: active +Last reviewed: 2026-05-23 + +Use this when closing the `CI is required before merges` production-readiness +gate. This is an external repository setting, so it cannot be proven by a +source commit alone. + +Official GitHub references: + +- +- +- + +## Required Rule + +Repository: + +```text +RobDavenport/framesmith +``` + +Branch name pattern: + +```text +main +``` + +Required status check: + +```text +Windows Checks +``` + +GitHub may display this as: + +```text +CI / Windows Checks +``` + +Required settings: + +- Require status checks to pass before merging. +- Require branches to be up to date before merging. +- Select the `Windows Checks` check from the `CI` workflow. +- Require a pull request before merging if `main` is a shared production + branch. +- Do not allow bypassing the above settings, if available for the repository. +- Keep the default protected-branch behavior that blocks force pushes and + branch deletion. + +## Verification + +Record evidence before marking the gate complete: + +```text +Repository: +Protected branch or ruleset: +Branch pattern: +Required status checks: +Strict up-to-date requirement: +Pull request requirement: +Bypass policy: +Evidence URL or screenshot: +Blocked merge evidence: +Verified by: +Verified date: +``` + +Acceptable blocked-merge evidence: + +- A pull request merge box showing that `Windows Checks` is required before + merge. +- A branch protection or ruleset settings screen showing `Windows Checks` as a + required check for `main`. +- An authenticated API response from the branch-protection endpoint showing + `Windows Checks` in required status checks. + +## Current Tooling Limit + +The current repository connector can inspect repository metadata and pull +request checks, but it does not expose branch-protection or ruleset mutation. +This workspace also does not have GitHub CLI installed. Configure this setting +through GitHub's web UI or an authenticated admin API client, then paste the +evidence into the active release evidence document. diff --git a/docs/production-gap-backlog.md b/docs/production-gap-backlog.md index 998c9af..41c249b 100644 --- a/docs/production-gap-backlog.md +++ b/docs/production-gap-backlog.md @@ -53,7 +53,9 @@ Trigger: before using the repository as a production source of truth. Scope: - Protect `main` or configure an equivalent ruleset. -- Require the CI workflow before merge. +- Follow [`branch-protection-setup.md`](branch-protection-setup.md). +- Require `Windows Checks` from the `CI` workflow before merge. +- Require branches to be up to date before merging. - Require pull requests or another reviewed-change policy if the production game team uses shared branches. @@ -65,6 +67,8 @@ Required evidence: Acceptance criteria: - Production branches cannot accept changes without a green CI result. +- Release evidence names the protected branch or ruleset and records the + blocked-merge proof. ### PROD-WIN-001: Windows Installer Manual Smoke diff --git a/docs/production-readiness-plan.md b/docs/production-readiness-plan.md index 934eb56..9ae98d5 100644 --- a/docs/production-readiness-plan.md +++ b/docs/production-readiness-plan.md @@ -134,6 +134,9 @@ concatenation. - Added [`windows-installer-smoke-test.md`](windows-installer-smoke-test.md) so the remaining manual Windows installer gate has repeatable steps and evidence to record. +- Added [`branch-protection-setup.md`](branch-protection-setup.md) with the + exact `Windows Checks` requirement and evidence template for the remaining CI + enforcement gate. ## Remaining Production Blockers @@ -149,7 +152,8 @@ authenticated API client. Actions: -- Make the CI workflow required before merges. +- Follow [`branch-protection-setup.md`](branch-protection-setup.md) and require + `Windows Checks` from the `CI` workflow before merges to `main`. - Keep Windows as the first supported packaging target. - Keep Linux and macOS out of the first production target until platform dependencies, package formats, and manual smoke-test steps are documented. @@ -390,6 +394,8 @@ Completed: - Added [`production-gap-backlog.md`](production-gap-backlog.md) and [`release-runbook.md`](release-runbook.md) so future production gaps and release-candidate evidence have permanent homes. +- Added [`branch-protection-setup.md`](branch-protection-setup.md) for exact + required-check configuration and branch-protection evidence capture. - Added [`release-evidence-2026-05-23.md`](release-evidence-2026-05-23.md) for the current candidate branch and external release-gate evidence. @@ -406,6 +412,28 @@ Acceptance criteria: ## Release Checklist +The checklist below is the reusable template for a tagged release. For the +current `0.1.0` candidate, the machine-verifiable items are covered by the +current snapshot and GitHub Actions evidence above. The only unresolved +candidate items are branch protection enforcement and manual Windows installer +smoke testing. + +Current candidate release-checklist evidence: + +| Area | Current candidate status | Evidence | +|------|--------------------------|----------| +| Version metadata | Pass | `package.json`, `src-tauri/Cargo.toml`, and `src-tauri/tauri.conf.json` all declare `0.1.0`. | +| Dependency install, audit, and Tauri package alignment | Pass | Current snapshot records `npm ci`, `npm audit`, and Tauri package alignment; CI run `26332439997` passed dependency install and audit. | +| Generated schemas and WASM bindings | Pass | CI run `26332439997` passed schema refresh, generated-file drift check, WASM rebuild, and generated WASM existence checks. | +| Frontend checks, Vitest, Playwright, and web build | Pass | CI run `26332439997` passed `npm run check`, `npm run test:run`, browser smoke tests, and `npm run build`. | +| Rust formatting, tests, and clippy | Pass | CI run `26332439997` passed formatting, backend/runtime/FSPK tests, runtime WASM fixture generation, and clippy with warnings denied. | +| Windows Tauri package build and artifact upload | Pass | CI run `26332439997` passed `npm run tauri build`, installer-output verification, and uploaded `framesmith-windows-installers`. | +| Documentation examples and export limitations | Pass | `docs_cli_examples`, `export_fidelity_contract`, `fspk_roundtrip`, and `production_docs` tests cover documented CLI examples, field preservation, lossy examples, and release docs. | +| Target-game fit review | Pass for the first production target | `production-handoff-decision.md`, `combat-coverage.md`, `training-scenario-contract.md`, and `production-gap-backlog.md` document current ownership and future target-game gaps. | +| Branch protection | Open external gate | Follow `branch-protection-setup.md`; no branch/ruleset evidence has been recorded yet. | +| Windows installer smoke | Open external gate | Run `windows-installer-smoke-test.md` against the MSI and NSIS installers and record evidence. | +| Linux and macOS smoke | Not in first target scope | Both platforms are explicitly out of scope for the first supported target. | + Run this checklist before a tagged release: - [ ] Version bump in package metadata and Tauri config. @@ -476,6 +504,7 @@ Run this checklist before a tagged release: - [x] Runtime guide includes engine-consumption examples for movement and resource deltas. - [x] Production gap backlog exists for target-game-required runtime/FSPK work. - [x] Release runbook exists for clean-checkout, CI, branch-protection, and installer evidence. +- [x] Branch protection setup is documented with the exact required CI check. - [x] Current candidate release evidence is recorded. - [x] Frontend/tooling dependency audit reports 0 vulnerabilities in the verified workspace. - [x] Tauri npm packages are pinned to the Rust-compatible minor line. diff --git a/docs/release-evidence-2026-05-23.md b/docs/release-evidence-2026-05-23.md index 706b0f0..523d4e4 100644 --- a/docs/release-evidence-2026-05-23.md +++ b/docs/release-evidence-2026-05-23.md @@ -246,7 +246,10 @@ Automation note: repository connector reports admin permission, but exposes no b ``` The maintainer must configure or verify branch protection/rulesets requiring -the CI workflow before the branch-protection gate can be marked complete. +the `Windows Checks` job from the CI workflow before the branch-protection gate +can be marked complete. Follow +[`branch-protection-setup.md`](branch-protection-setup.md) and record the +resulting evidence here. ## Installer Smoke State diff --git a/docs/release-runbook.md b/docs/release-runbook.md index 94b3a8e..c35bf4e 100644 --- a/docs/release-runbook.md +++ b/docs/release-runbook.md @@ -97,17 +97,21 @@ Artifact URL or download source: Before treating `main` as production-protected: -1. Configure a branch protection rule or repository ruleset for `main`. -2. Require the CI workflow to pass before merge. -3. Require pull requests if the team uses reviewed changes. -4. Record evidence that a non-green change cannot merge. +1. Follow [`branch-protection-setup.md`](branch-protection-setup.md). +2. Configure a branch protection rule or repository ruleset for `main`. +3. Require `Windows Checks` from the `CI` workflow to pass before merge. +4. Require branches to be up to date before merging. +5. Require pull requests if the team uses reviewed changes. +6. Record evidence that a non-green or pending-check change cannot merge. Record: ```text Protected branch/ruleset: Required status checks: +Strict up-to-date requirement: Review requirement: +Bypass policy: Evidence location: ``` diff --git a/src-tauri/tests/production_docs.rs b/src-tauri/tests/production_docs.rs index ae31cce..8d0e99e 100644 --- a/src-tauri/tests/production_docs.rs +++ b/src-tauri/tests/production_docs.rs @@ -39,6 +39,10 @@ const RELEASE_RUNBOOK: &str = include_str!(concat!( env!("CARGO_MANIFEST_DIR"), "/../docs/release-runbook.md" )); +const BRANCH_PROTECTION_SETUP: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../docs/branch-protection-setup.md" +)); const RELEASE_EVIDENCE: &str = include_str!(concat!( env!("CARGO_MANIFEST_DIR"), "/../docs/release-evidence-2026-05-23.md" @@ -232,6 +236,8 @@ fn release_runbook_covers_candidate_evidence() { "git diff --exit-code -- schemas/rules.schema.json", "GitHub Actions URL", "Protected branch/ruleset", + "Strict up-to-date requirement", + "Bypass policy", "windows-installer-smoke-test.md", "Final Evidence Template", ] { @@ -246,6 +252,48 @@ fn release_runbook_covers_candidate_evidence() { assert!(PRODUCTION_PLAN.contains("[x] Release runbook exists for clean-checkout")); } +#[test] +fn branch_protection_setup_covers_required_ci_gate() { + for required in [ + "# Branch Protection Setup", + "RobDavenport/framesmith", + "Branch name pattern:", + "main", + "Required status check:", + "Windows Checks", + "CI / Windows Checks", + "Require status checks to pass before merging.", + "Require branches to be up to date before merging.", + "Do not allow bypassing the above settings", + "Blocked merge evidence:", + "branch-protection endpoint", + ] { + assert!( + BRANCH_PROTECTION_SETUP.contains(required), + "branch protection setup should document: {required}" + ); + } + + for linked_doc in [ + DOCS_INDEX, + PRODUCTION_PLAN, + PRODUCTION_GAP_BACKLOG, + RELEASE_RUNBOOK, + RELEASE_EVIDENCE, + ] { + assert!( + linked_doc.contains("branch-protection-setup.md"), + "permanent docs should link branch-protection-setup.md" + ); + } + + assert!(PRODUCTION_PLAN + .contains("[x] Branch protection setup is documented with the exact required CI check.")); + assert!(PRODUCTION_PLAN.contains( + "Follow `branch-protection-setup.md`; no branch/ruleset evidence has been recorded yet." + )); +} + #[test] fn current_release_evidence_records_external_blockers() { for required in [ @@ -276,6 +324,8 @@ fn current_release_evidence_records_external_blockers() { "https://github.com/RobDavenport/framesmith/actions/runs/26332001870", "Artifact ID: 7176775382", "passed the repaired `Verify Windows installer outputs` step", + "branch-protection-setup.md", + "`Windows Checks` job from the CI workflow", "Automation note: repository connector reports admin permission", "MSI result: not manually smoke tested", "Decision: not ready", From 767f94b5ceeb6e8ce9d4d0402dd10a11c098a29e Mon Sep 17 00:00:00 2001 From: RobDavenport Date: Sat, 23 May 2026 22:58:34 +0900 Subject: [PATCH 14/15] Add release gate evidence helpers --- docs/branch-protection-setup.md | 13 +++ docs/production-readiness-plan.md | 5 +- docs/release-evidence-2026-05-23.md | 5 +- docs/release-runbook.md | 12 +++ docs/windows-installer-smoke-test.md | 10 ++ scripts/check-branch-protection.ps1 | 100 ++++++++++++++++++ .../verify-windows-installer-artifacts.ps1 | 48 +++++++++ src-tauri/tests/production_docs.rs | 78 ++++++++++++++ .../github-branch-protection-main.json | 23 ++++ 9 files changed, 292 insertions(+), 2 deletions(-) create mode 100644 scripts/check-branch-protection.ps1 create mode 100644 scripts/verify-windows-installer-artifacts.ps1 create mode 100644 tests/fixtures/github-branch-protection-main.json diff --git a/docs/branch-protection-setup.md b/docs/branch-protection-setup.md index 7a670bc..83f8ab4 100644 --- a/docs/branch-protection-setup.md +++ b/docs/branch-protection-setup.md @@ -77,6 +77,19 @@ Acceptable blocked-merge evidence: - An authenticated API response from the branch-protection endpoint showing `Windows Checks` in required status checks. +Optional command-line verification after the rule is configured: + +```powershell +$env:GITHUB_TOKEN = '' +.\scripts\check-branch-protection.ps1 -Owner RobDavenport -Repo framesmith -Branch main -RequiredCheck 'Windows Checks' -RequirePullRequest +``` + +To verify a saved branch-protection API response without network access: + +```powershell +.\scripts\check-branch-protection.ps1 -ProtectionJsonPath tests\fixtures\github-branch-protection-main.json -RequirePullRequest +``` + ## Current Tooling Limit The current repository connector can inspect repository metadata and pull diff --git a/docs/production-readiness-plan.md b/docs/production-readiness-plan.md index 9ae98d5..f13e1d8 100644 --- a/docs/production-readiness-plan.md +++ b/docs/production-readiness-plan.md @@ -137,6 +137,8 @@ concatenation. - Added [`branch-protection-setup.md`](branch-protection-setup.md) with the exact `Windows Checks` requirement and evidence template for the remaining CI enforcement gate. +- Added reusable PowerShell evidence helpers for branch-protection API + verification and MSI/NSIS artifact integrity checks. ## Remaining Production Blockers @@ -431,7 +433,7 @@ Current candidate release-checklist evidence: | Documentation examples and export limitations | Pass | `docs_cli_examples`, `export_fidelity_contract`, `fspk_roundtrip`, and `production_docs` tests cover documented CLI examples, field preservation, lossy examples, and release docs. | | Target-game fit review | Pass for the first production target | `production-handoff-decision.md`, `combat-coverage.md`, `training-scenario-contract.md`, and `production-gap-backlog.md` document current ownership and future target-game gaps. | | Branch protection | Open external gate | Follow `branch-protection-setup.md`; no branch/ruleset evidence has been recorded yet. | -| Windows installer smoke | Open external gate | Run `windows-installer-smoke-test.md` against the MSI and NSIS installers and record evidence. | +| Windows installer smoke | Open external gate | Run `windows-installer-smoke-test.md` against the MSI and NSIS installers and record evidence; `scripts/verify-windows-installer-artifacts.ps1` verifies artifact contents before install. | | Linux and macOS smoke | Not in first target scope | Both platforms are explicitly out of scope for the first supported target. | Run this checklist before a tagged release: @@ -505,6 +507,7 @@ Run this checklist before a tagged release: - [x] Production gap backlog exists for target-game-required runtime/FSPK work. - [x] Release runbook exists for clean-checkout, CI, branch-protection, and installer evidence. - [x] Branch protection setup is documented with the exact required CI check. +- [x] Branch protection and installer artifact evidence helpers exist. - [x] Current candidate release evidence is recorded. - [x] Frontend/tooling dependency audit reports 0 vulnerabilities in the verified workspace. - [x] Tauri npm packages are pinned to the Rust-compatible minor line. diff --git a/docs/release-evidence-2026-05-23.md b/docs/release-evidence-2026-05-23.md index 523d4e4..d8a3981 100644 --- a/docs/release-evidence-2026-05-23.md +++ b/docs/release-evidence-2026-05-23.md @@ -249,7 +249,8 @@ The maintainer must configure or verify branch protection/rulesets requiring the `Windows Checks` job from the CI workflow before the branch-protection gate can be marked complete. Follow [`branch-protection-setup.md`](branch-protection-setup.md) and record the -resulting evidence here. +resulting evidence here. `scripts/check-branch-protection.ps1` can validate an +authenticated branch-protection API response or a saved JSON response. ## Installer Smoke State @@ -268,6 +269,8 @@ Warnings: unsigned-build warning expected but not manually verified Run [`windows-installer-smoke-test.md`](windows-installer-smoke-test.md) on a Windows machine or clean VM before marking the installer gate complete. +`scripts/verify-windows-installer-artifacts.ps1` can verify MSI/NSIS artifact +contents before the manual install/uninstall smoke flow. ## Decision diff --git a/docs/release-runbook.md b/docs/release-runbook.md index c35bf4e..072d21c 100644 --- a/docs/release-runbook.md +++ b/docs/release-runbook.md @@ -82,6 +82,11 @@ After pushing the candidate commit: 3. Confirm the `framesmith-windows-installers` artifact exists. 4. Download the artifact or record the artifact URL for installer smoke testing. +5. If the artifact is downloaded, extract it and verify installer contents: + +```powershell +.\scripts\verify-windows-installer-artifacts.ps1 -Path -Version 0.1.0 +``` Record: @@ -104,6 +109,13 @@ Before treating `main` as production-protected: 5. Require pull requests if the team uses reviewed changes. 6. Record evidence that a non-green or pending-check change cannot merge. +Optional API verification: + +```powershell +$env:GITHUB_TOKEN = '' +.\scripts\check-branch-protection.ps1 -Owner RobDavenport -Repo framesmith -Branch main -RequiredCheck 'Windows Checks' -RequirePullRequest +``` + Record: ```text diff --git a/docs/windows-installer-smoke-test.md b/docs/windows-installer-smoke-test.md index fb2ed2b..38181e4 100644 --- a/docs/windows-installer-smoke-test.md +++ b/docs/windows-installer-smoke-test.md @@ -16,6 +16,16 @@ src-tauri/target/release/bundle/msi/Framesmith__x64_en-US.msi src-tauri/target/release/bundle/nsis/Framesmith__x64-setup.exe ``` +Before installing, verify the artifact directory contains non-empty MSI and +NSIS outputs: + +```powershell +.\scripts\verify-windows-installer-artifacts.ps1 -Path src-tauri\target\release\bundle -Version 0.1.0 +``` + +For a downloaded CI artifact, extract the ZIP and point `-Path` at the extracted +directory. + ## MSI Smoke Test 1. Install the MSI on a Windows machine or clean VM. diff --git a/scripts/check-branch-protection.ps1 b/scripts/check-branch-protection.ps1 new file mode 100644 index 0000000..677e3b6 --- /dev/null +++ b/scripts/check-branch-protection.ps1 @@ -0,0 +1,100 @@ +[CmdletBinding()] +param( + [string]$Owner = "RobDavenport", + [string]$Repo = "framesmith", + [string]$Branch = "main", + [string]$RequiredCheck = "Windows Checks", + [string]$ProtectionJsonPath, + [string]$GitHubToken = $env:GITHUB_TOKEN, + [switch]$RequirePullRequest +) + +$ErrorActionPreference = "Stop" + +function Fail { + param([string]$Message) + Write-Error $Message + exit 1 +} + +function Read-ProtectionJson { + if ($ProtectionJsonPath) { + if (-not (Test-Path -LiteralPath $ProtectionJsonPath)) { + Fail "Branch protection JSON file not found: $ProtectionJsonPath" + } + + return Get-Content -LiteralPath $ProtectionJsonPath -Raw | ConvertFrom-Json + } + + if (-not $GitHubToken) { + Fail "GITHUB_TOKEN is required unless -ProtectionJsonPath points to a saved branch-protection response." + } + + $headers = @{ + "Accept" = "application/vnd.github+json" + "Authorization" = "Bearer $GitHubToken" + "User-Agent" = "framesmith-readiness-check" + "X-GitHub-Api-Version" = "2022-11-28" + } + $uri = "https://api.github.com/repos/$Owner/$Repo/branches/$Branch/protection" + + return Invoke-RestMethod -Uri $uri -Headers $headers -Method Get +} + +$protection = Read-ProtectionJson + +if (-not $protection.required_status_checks) { + Fail "Branch protection does not require status checks." +} + +$statusChecks = $protection.required_status_checks +$contexts = @() + +if ($statusChecks.contexts) { + $contexts += @($statusChecks.contexts) +} + +if ($statusChecks.checks) { + foreach ($check in @($statusChecks.checks)) { + if ($check.context) { + $contexts += $check.context + } + } +} + +$contexts = @($contexts | Where-Object { $_ } | Select-Object -Unique) +$acceptedNames = @($RequiredCheck, "CI / $RequiredCheck") +$hasRequiredCheck = $false + +foreach ($name in $acceptedNames) { + if ($contexts -contains $name) { + $hasRequiredCheck = $true + } +} + +if (-not $hasRequiredCheck) { + Fail "Required status check '$RequiredCheck' was not found. Found: $($contexts -join ', ')" +} + +if ($statusChecks.strict -ne $true) { + Fail "Branch protection does not require branches to be up to date before merging." +} + +if ($RequirePullRequest -and -not $protection.required_pull_request_reviews) { + Fail "Pull request review/merge policy is not enabled." +} + +if ($protection.allow_force_pushes -and $protection.allow_force_pushes.enabled -eq $true) { + Fail "Branch protection allows force pushes." +} + +if ($protection.allow_deletions -and $protection.allow_deletions.enabled -eq $true) { + Fail "Branch protection allows branch deletion." +} + +Write-Host "Branch protection verified." +Write-Host "Repository: $Owner/$Repo" +Write-Host "Branch: $Branch" +Write-Host "Required checks: $($contexts -join ', ')" +Write-Host "Strict up-to-date requirement: $($statusChecks.strict)" +Write-Host "Pull request policy present: $([bool]$protection.required_pull_request_reviews)" diff --git a/scripts/verify-windows-installer-artifacts.ps1 b/scripts/verify-windows-installer-artifacts.ps1 new file mode 100644 index 0000000..fabe8cf --- /dev/null +++ b/scripts/verify-windows-installer-artifacts.ps1 @@ -0,0 +1,48 @@ +[CmdletBinding()] +param( + [string]$Path = "src-tauri/target/release/bundle", + [string]$Version, + [int64]$MinimumBytes = 1024 +) + +$ErrorActionPreference = "Stop" + +function Fail { + param([string]$Message) + Write-Error $Message + exit 1 +} + +if (-not (Test-Path -LiteralPath $Path)) { + Fail "Installer artifact path not found: $Path" +} + +$msi = @(Get-ChildItem -LiteralPath $Path -Filter "*.msi" -File -Recurse) +$nsis = @(Get-ChildItem -LiteralPath $Path -Filter "*setup.exe" -File -Recurse) + +if ($Version) { + $msi = @($msi | Where-Object { $_.Name -like "*$Version*" }) + $nsis = @($nsis | Where-Object { $_.Name -like "*$Version*" }) +} + +if ($msi.Count -eq 0) { + Fail "No MSI installer was found under $Path." +} + +if ($nsis.Count -eq 0) { + Fail "No NSIS setup executable was found under $Path." +} + +$installerFiles = @($msi) + @($nsis) +foreach ($file in $installerFiles) { + if ($file.Length -lt $MinimumBytes) { + Fail "Installer output is smaller than $MinimumBytes bytes: $($file.FullName)" + } +} + +Write-Host "Windows installer artifacts verified." +Write-Host "Path: $Path" +Write-Host "Version filter: $(if ($Version) { $Version } else { '' })" +foreach ($file in $installerFiles) { + Write-Host "$($file.Name) $($file.Length) bytes" +} diff --git a/src-tauri/tests/production_docs.rs b/src-tauri/tests/production_docs.rs index 8d0e99e..0497465 100644 --- a/src-tauri/tests/production_docs.rs +++ b/src-tauri/tests/production_docs.rs @@ -51,6 +51,18 @@ const CI_WORKFLOW: &str = include_str!(concat!( env!("CARGO_MANIFEST_DIR"), "/../.github/workflows/ci.yml" )); +const CHECK_BRANCH_PROTECTION_SCRIPT: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../scripts/check-branch-protection.ps1" +)); +const VERIFY_INSTALLER_ARTIFACTS_SCRIPT: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../scripts/verify-windows-installer-artifacts.ps1" +)); +const BRANCH_PROTECTION_FIXTURE: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../tests/fixtures/github-branch-protection-main.json" +)); const CHARACTER_COMMANDS: &str = include_str!("../src/commands/character.rs"); #[test] @@ -173,6 +185,7 @@ fn windows_installer_smoke_test_is_documented_and_linked() { "# Windows Installer Smoke Test", "Framesmith__x64_en-US.msi", "Framesmith__x64-setup.exe", + ".\\scripts\\verify-windows-installer-artifacts.ps1", "Training starts from the packaged WASM and FSPK path.", "Evidence To Record", ] { @@ -267,6 +280,8 @@ fn branch_protection_setup_covers_required_ci_gate() { "Do not allow bypassing the above settings", "Blocked merge evidence:", "branch-protection endpoint", + ".\\scripts\\check-branch-protection.ps1", + "tests\\fixtures\\github-branch-protection-main.json", ] { assert!( BRANCH_PROTECTION_SETUP.contains(required), @@ -294,6 +309,67 @@ fn branch_protection_setup_covers_required_ci_gate() { )); } +#[test] +fn release_evidence_helper_scripts_are_documented_and_auditable() { + for required in [ + "GITHUB_TOKEN is required unless -ProtectionJsonPath", + "https://api.github.com/repos/$Owner/$Repo/branches/$Branch/protection", + "Required status check '$RequiredCheck' was not found", + "Branch protection does not require branches to be up to date", + "Branch protection verified.", + ] { + assert!( + CHECK_BRANCH_PROTECTION_SCRIPT.contains(required), + "branch protection helper should contain: {required}" + ); + } + + for required in [ + "No MSI installer was found", + "No NSIS setup executable was found", + "Installer output is smaller than", + "Windows installer artifacts verified.", + ] { + assert!( + VERIFY_INSTALLER_ARTIFACTS_SCRIPT.contains(required), + "installer artifact helper should contain: {required}" + ); + } + + for required in [ + "\"strict\": true", + "\"Windows Checks\"", + "\"required_pull_request_reviews\"", + "\"allow_force_pushes\"", + "\"enabled\": false", + ] { + assert!( + BRANCH_PROTECTION_FIXTURE.contains(required), + "branch protection fixture should contain: {required}" + ); + } + + for linked_doc in [ + PRODUCTION_PLAN, + RELEASE_RUNBOOK, + RELEASE_EVIDENCE, + BRANCH_PROTECTION_SETUP, + WINDOWS_INSTALLER_SMOKE_TEST, + ] { + assert!( + linked_doc.contains("scripts\\check-branch-protection.ps1") + || linked_doc.contains("scripts/check-branch-protection.ps1") + || linked_doc.contains(".\\scripts\\check-branch-protection.ps1") + || linked_doc.contains("scripts/verify-windows-installer-artifacts.ps1") + || linked_doc.contains(".\\scripts\\verify-windows-installer-artifacts.ps1"), + "release docs should reference at least one helper script" + ); + } + + assert!(PRODUCTION_PLAN + .contains("[x] Branch protection and installer artifact evidence helpers exist.")); +} + #[test] fn current_release_evidence_records_external_blockers() { for required in [ @@ -326,6 +402,8 @@ fn current_release_evidence_records_external_blockers() { "passed the repaired `Verify Windows installer outputs` step", "branch-protection-setup.md", "`Windows Checks` job from the CI workflow", + "scripts/check-branch-protection.ps1", + "scripts/verify-windows-installer-artifacts.ps1", "Automation note: repository connector reports admin permission", "MSI result: not manually smoke tested", "Decision: not ready", diff --git a/tests/fixtures/github-branch-protection-main.json b/tests/fixtures/github-branch-protection-main.json new file mode 100644 index 0000000..1b48c2f --- /dev/null +++ b/tests/fixtures/github-branch-protection-main.json @@ -0,0 +1,23 @@ +{ + "required_status_checks": { + "strict": true, + "contexts": [ + "Windows Checks" + ], + "checks": [ + { + "context": "Windows Checks", + "app_id": 15368 + } + ] + }, + "required_pull_request_reviews": { + "required_approving_review_count": 1 + }, + "allow_force_pushes": { + "enabled": false + }, + "allow_deletions": { + "enabled": false + } +} From 498cea3c94d7d717174ca446c34455bfcea0d4e0 Mon Sep 17 00:00:00 2001 From: RobDavenport Date: Sat, 23 May 2026 23:20:50 +0900 Subject: [PATCH 15/15] Run release gate helpers in CI --- .github/workflows/ci.yml | 20 ++++------------ docs/production-readiness-plan.md | 8 ++++--- docs/release-evidence-2026-05-23.md | 8 ++++--- docs/release-runbook.md | 5 ++-- src-tauri/tests/production_docs.rs | 37 +++++++++++++++++++++++------ 5 files changed, 47 insertions(+), 31 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9a65588..fecf294 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -99,26 +99,14 @@ jobs: cargo clippy --manifest-path crates/framesmith-runtime-wasm/Cargo.toml --all-targets -- -D warnings cargo clippy --manifest-path crates/framesmith-fspack/Cargo.toml --all-targets -- -D warnings + - name: Verify branch protection helper fixture + run: .\scripts\check-branch-protection.ps1 -ProtectionJsonPath tests\fixtures\github-branch-protection-main.json -RequirePullRequest + - name: Build Tauri app run: npm run tauri build - name: Verify Windows installer outputs - run: | - $msi = Get-ChildItem -Path src-tauri/target/release/bundle/msi -Filter *.msi -File - $nsis = Get-ChildItem -Path src-tauri/target/release/bundle/nsis -Filter *setup.exe -File - if ($msi.Count -eq 0) { - throw "No MSI installer was produced" - } - if ($nsis.Count -eq 0) { - throw "No NSIS setup executable was produced" - } - $installerFiles = @($msi) + @($nsis) - foreach ($file in $installerFiles) { - if ($file.Length -le 0) { - throw "Installer output is empty: $($file.FullName)" - } - Write-Host "$($file.Name) $($file.Length) bytes" - } + run: .\scripts\verify-windows-installer-artifacts.ps1 -Path src-tauri\target\release\bundle - name: Upload Windows installers uses: actions/upload-artifact@v4 diff --git a/docs/production-readiness-plan.md b/docs/production-readiness-plan.md index f13e1d8..781db72 100644 --- a/docs/production-readiness-plan.md +++ b/docs/production-readiness-plan.md @@ -138,7 +138,8 @@ concatenation. exact `Windows Checks` requirement and evidence template for the remaining CI enforcement gate. - Added reusable PowerShell evidence helpers for branch-protection API - verification and MSI/NSIS artifact integrity checks. + verification and MSI/NSIS artifact integrity checks; CI runs the helper + fixture and the installer artifact verifier. ## Remaining Production Blockers @@ -429,7 +430,7 @@ Current candidate release-checklist evidence: | Generated schemas and WASM bindings | Pass | CI run `26332439997` passed schema refresh, generated-file drift check, WASM rebuild, and generated WASM existence checks. | | Frontend checks, Vitest, Playwright, and web build | Pass | CI run `26332439997` passed `npm run check`, `npm run test:run`, browser smoke tests, and `npm run build`. | | Rust formatting, tests, and clippy | Pass | CI run `26332439997` passed formatting, backend/runtime/FSPK tests, runtime WASM fixture generation, and clippy with warnings denied. | -| Windows Tauri package build and artifact upload | Pass | CI run `26332439997` passed `npm run tauri build`, installer-output verification, and uploaded `framesmith-windows-installers`. | +| Windows Tauri package build and artifact upload | Pass | CI run `26332439997` passed `npm run tauri build`, installer-output verification, and uploaded `framesmith-windows-installers`; current CI also runs the reusable installer artifact verifier. | | Documentation examples and export limitations | Pass | `docs_cli_examples`, `export_fidelity_contract`, `fspk_roundtrip`, and `production_docs` tests cover documented CLI examples, field preservation, lossy examples, and release docs. | | Target-game fit review | Pass for the first production target | `production-handoff-decision.md`, `combat-coverage.md`, `training-scenario-contract.md`, and `production-gap-backlog.md` document current ownership and future target-game gaps. | | Branch protection | Open external gate | Follow `branch-protection-setup.md`; no branch/ruleset evidence has been recorded yet. | @@ -507,7 +508,8 @@ Run this checklist before a tagged release: - [x] Production gap backlog exists for target-game-required runtime/FSPK work. - [x] Release runbook exists for clean-checkout, CI, branch-protection, and installer evidence. - [x] Branch protection setup is documented with the exact required CI check. -- [x] Branch protection and installer artifact evidence helpers exist. +- [x] Branch protection and installer artifact evidence helpers exist and are + exercised by CI. - [x] Current candidate release evidence is recorded. - [x] Frontend/tooling dependency audit reports 0 vulnerabilities in the verified workspace. - [x] Tauri npm packages are pinned to the Rust-compatible minor line. diff --git a/docs/release-evidence-2026-05-23.md b/docs/release-evidence-2026-05-23.md index d8a3981..91216ff 100644 --- a/docs/release-evidence-2026-05-23.md +++ b/docs/release-evidence-2026-05-23.md @@ -62,7 +62,7 @@ cargo clippy --manifest-path crates/framesmith-runtime/Cargo.toml --all-targets cargo clippy --manifest-path crates/framesmith-runtime-wasm/Cargo.toml --all-targets -- -D warnings cargo clippy --manifest-path crates/framesmith-fspack/Cargo.toml --all-targets -- -D warnings npm run tauri build -pwsh -NoProfile -Command "$msi = Get-ChildItem -Path 'src-tauri/target/release/bundle/msi' -Filter '*.msi' -File; $nsis = Get-ChildItem -Path 'src-tauri/target/release/bundle/nsis' -Filter '*setup.exe' -File; if ($msi.Count -eq 0) { throw 'No MSI installer was produced' }; if ($nsis.Count -eq 0) { throw 'No NSIS setup executable was produced' }; $installerFiles = @($msi) + @($nsis); foreach ($file in $installerFiles) { if ($file.Length -le 0) { throw \"Installer output is empty: $($file.FullName)\" } }" +.\scripts\verify-windows-installer-artifacts.ps1 -Path src-tauri\target\release\bundle -Version 0.1.0 git diff --check ``` @@ -250,7 +250,8 @@ the `Windows Checks` job from the CI workflow before the branch-protection gate can be marked complete. Follow [`branch-protection-setup.md`](branch-protection-setup.md) and record the resulting evidence here. `scripts/check-branch-protection.ps1` can validate an -authenticated branch-protection API response or a saved JSON response. +authenticated branch-protection API response or a saved JSON response; CI +exercises the saved-response fixture so the helper does not drift silently. ## Installer Smoke State @@ -270,7 +271,8 @@ Warnings: unsigned-build warning expected but not manually verified Run [`windows-installer-smoke-test.md`](windows-installer-smoke-test.md) on a Windows machine or clean VM before marking the installer gate complete. `scripts/verify-windows-installer-artifacts.ps1` can verify MSI/NSIS artifact -contents before the manual install/uninstall smoke flow. +contents before the manual install/uninstall smoke flow. The CI installer +verification step uses this helper before uploading the artifact. ## Decision diff --git a/docs/release-runbook.md b/docs/release-runbook.md index 072d21c..2af751f 100644 --- a/docs/release-runbook.md +++ b/docs/release-runbook.md @@ -62,7 +62,7 @@ cargo clippy --manifest-path crates/framesmith-runtime/Cargo.toml --all-targets cargo clippy --manifest-path crates/framesmith-runtime-wasm/Cargo.toml --all-targets -- -D warnings cargo clippy --manifest-path crates/framesmith-fspack/Cargo.toml --all-targets -- -D warnings npm run tauri build -pwsh -NoProfile -Command "$msi = Get-ChildItem -Path 'src-tauri/target/release/bundle/msi' -Filter '*.msi' -File; $nsis = Get-ChildItem -Path 'src-tauri/target/release/bundle/nsis' -Filter '*setup.exe' -File; if ($msi.Count -eq 0) { throw 'No MSI installer was produced' }; if ($nsis.Count -eq 0) { throw 'No NSIS setup executable was produced' }; $installerFiles = @($msi) + @($nsis); foreach ($file in $installerFiles) { if ($file.Length -le 0) { throw \"Installer output is empty: $($file.FullName)\" } }" +.\scripts\verify-windows-installer-artifacts.ps1 -Path src-tauri\target\release\bundle -Version 0.1.0 ``` Expected local package outputs: @@ -82,7 +82,8 @@ After pushing the candidate commit: 3. Confirm the `framesmith-windows-installers` artifact exists. 4. Download the artifact or record the artifact URL for installer smoke testing. -5. If the artifact is downloaded, extract it and verify installer contents: +5. If the artifact is downloaded, extract it and verify installer contents. This + is the same helper CI runs before artifact upload: ```powershell .\scripts\verify-windows-installer-artifacts.ps1 -Path -Version 0.1.0 diff --git a/src-tauri/tests/production_docs.rs b/src-tauri/tests/production_docs.rs index 0497465..1decb3e 100644 --- a/src-tauri/tests/production_docs.rs +++ b/src-tauri/tests/production_docs.rs @@ -243,9 +243,7 @@ fn release_runbook_covers_candidate_evidence() { "Test-Path 'src/lib/wasm/framesmith_runtime_wasm.js'", "Test-Path 'src/lib/wasm/framesmith_runtime_wasm.d.ts'", "cargo run --manifest-path src-tauri/Cargo.toml --bin framesmith-cli -- export --project . --character test_char --adapter fspk --out exports/test_char.fspk", - "No MSI installer was produced", - "No NSIS setup executable was produced", - "$installerFiles = @($msi) + @($nsis)", + ".\\scripts\\verify-windows-installer-artifacts.ps1 -Path src-tauri\\target\\release\\bundle -Version 0.1.0", "git diff --exit-code -- schemas/rules.schema.json", "GitHub Actions URL", "Protected branch/ruleset", @@ -367,7 +365,7 @@ fn release_evidence_helper_scripts_are_documented_and_auditable() { } assert!(PRODUCTION_PLAN - .contains("[x] Branch protection and installer artifact evidence helpers exist.")); + .contains("[x] Branch protection and installer artifact evidence helpers exist and are")); } #[test] @@ -472,7 +470,32 @@ fn ci_verifies_windows_installers_before_upload() { build_tauri < verify_installers && verify_installers < upload, "CI must verify installer outputs before artifact upload" ); - assert!(CI_WORKFLOW.contains("No MSI installer was produced")); - assert!(CI_WORKFLOW.contains("No NSIS setup executable was produced")); - assert!(CI_WORKFLOW.contains("Installer output is empty")); + assert!(CI_WORKFLOW.contains(".\\scripts\\verify-windows-installer-artifacts.ps1 -Path")); +} + +#[test] +fn ci_exercises_release_gate_helpers() { + let branch_helper = CI_WORKFLOW + .find("name: Verify branch protection helper fixture") + .expect("CI should verify the branch protection helper fixture"); + let build_tauri = CI_WORKFLOW + .find("name: Build Tauri app") + .expect("CI should build the Tauri app after helper validation"); + let installer_helper = CI_WORKFLOW + .find("name: Verify Windows installer outputs") + .expect("CI should verify installer outputs with the release helper"); + let upload = CI_WORKFLOW + .find("name: Upload Windows installers") + .expect("CI should upload verified installer outputs"); + + assert!( + branch_helper < build_tauri && build_tauri < installer_helper && installer_helper < upload, + "CI should validate release helpers before relying on uploaded installers" + ); + assert!(CI_WORKFLOW.contains( + ".\\scripts\\check-branch-protection.ps1 -ProtectionJsonPath tests\\fixtures\\github-branch-protection-main.json -RequirePullRequest" + )); + assert!(CI_WORKFLOW.contains( + ".\\scripts\\verify-windows-installer-artifacts.ps1 -Path src-tauri\\target\\release\\bundle" + )); }