From e773f13c27da4711028b3aa85b01cc55f009b81e Mon Sep 17 00:00:00 2001 From: hoainho Date: Mon, 5 Oct 2026 20:18:04 +0700 Subject: [PATCH] R04: separate player and world animation clocks --- .../plans/2026-10-05-r04-player-clock.md | 48 +++ docs/features/combat.md | 4 +- src/game/combat/CombatEngine.ts | 92 ++++-- src/rendering/AnimationController.ts | 10 +- src/rendering/GameRenderer.ts | 7 +- tests/combat/time-dilation.test.ts | 306 +++++++++++++++++- tests/rendering/browser-smoke.ts | 61 +++- 7 files changed, 486 insertions(+), 42 deletions(-) create mode 100644 .opencode/plans/2026-10-05-r04-player-clock.md diff --git a/.opencode/plans/2026-10-05-r04-player-clock.md b/.opencode/plans/2026-10-05-r04-player-clock.md new file mode 100644 index 0000000..17fa518 --- /dev/null +++ b/.opencode/plans/2026-10-05-r04-player-clock.md @@ -0,0 +1,48 @@ +# R04 player/world animation clock split + +## Goal +Keep Aren's finite actions and native looping animation on simulation time while hostile/world animation remains on the scaled world clock, without breaking serialized checkpoint state. + +## Context +- Branch: `task/R04-responsive-player-clock-20261005-a` from current main `0ef65af`. +- Parent issue: #25 remains open until genuine native rig/root-motion/contact and real-application acceptance pass. +- Contract: `docs/features/combat.md`; implementation: `src/game/combat/CombatEngine.ts`, `src/rendering/AnimationController.ts`, `src/rendering/GameRenderer.ts`. +- Existing implementation edits are in the assigned source/test files and need independent completion/review. Never convert fixture evidence into production acceptance. +- Workers must skip build/tests/formatters mid-flight and must not use a browser. The coordinator runs integrated verification once after both implementation slices and reviews. + +## Tasks + +### T1 — Simulation-clock combat actions and schema migration `[x]` +Files: `src/game/combat/CombatEngine.ts`, `tests/combat/time-dilation.test.ts`. +Acceptance: +- Aren finite action start/end/sample progression is based on `state.tick` and advances once per fixed simulation step through Perfect Dodge slow motion; non-player actions continue to use `worldTick` and `worldScale`. +- Checkpoint reset does not rebase Aren's simulation-clock action timestamps; it still rebases non-player/world-clock timestamps. +- Engine schema v3 migrates v2 player live/checkpoint action progress while preserving `normalizedTime`; v1 simulation-clock saves remain compatible. +- Deterministic tests cover player/enemy rates, a player action started during slow motion, action completion, v1 compatibility, and v2 live/checkpoint migration. +- Keep traversal's authored world-clock behavior unchanged. Do not claim native asset or game acceptance. + +### T2 — Renderer loop sampling clock routing `[x]` +Files: `src/rendering/AnimationController.ts`, `src/rendering/GameRenderer.ts`, `tests/rendering/browser-smoke.ts`. +Acceptance: +- One-shot clips still use authoritative `normalizedTime` snapshots. +- Loop clips sample actual native clip duration from an authoritative clock: Aren from `simulationTick / 60`, all other actors from `worldTimeSeconds`. +- Existing callers/previews remain compatible. +- Browser renderer smoke covers a player and hostile loop under 0.25 world-time scale and detects the two distinct samples. +- Fix any existing renderer smoke failure caused by this slice; do not suppress unrelated errors or loosen assertions. + +### T3 — Independent spec-compliance review `[x]` +T1 and T2 independently received Astra `SPEC_OK` and then fresh `QUALITY_OK` reviews. T1 caveats were traced through update/recovery/renderer consumers; no patch-introduced defect found. Static review only; runtime evidence is recorded under T5. Spec jobs: `R04CombatSpec` and `R04RendererSpec`; quality jobs: `R04CombatQuality` and `R04RendererQuality`; all review jobs used `openai-codex/gpt-6-astra`. + +### T4 — Independent code-quality review `[x]` +Fresh Astra reviewers returned `QUALITY_OK` for both slices after spec review. Combat state lifetime, JSON persistence/checkpoints, migration caveats, renderer clock ownership, preview/runtime transitions, and production-path smoke coverage were checked; no actionable introduced defects. Implementer jobs were `R04CombatClock` and `R04RendererClock` (`openai-codex/gpt-6.1-sol`). Quality-review durations were 95s and 55.8s; absolute agent start/end times and usage were not exposed. + +### T5 — Integrated verification `[x]` +Coordinator observed `./node_modules/.bin/tsc --noEmit`; `/tmp/wild-crown-bun/node_modules/.bin/bun test tests/combat/time-dilation.test.ts` (21 pass / 225 assertions); `/tmp/wild-crown-bun/node_modules/.bin/bun test` (565 pass / 1,507 assertions); and `PATH=/Users/hoainho/.nvm/versions/node/v20.18.1/bin:$PATH npm run build` (pass; Vite warned that 504.94 kB and 568.75 kB chunks exceed 500 kB). Chromium `/tests/rendering/browser-smoke.html` passed with zero page errors; two Three.js `skinning` warnings remain. `/tests/app/browser-smoke.html` passed all seven lifecycle checks with `failure: null` and cleanup true. The real development route and built preview both reject New Game with `greenwake-environment` blocked; this is the genuine fail-closed gate, not playable acceptance. Bun runs on ARM64 required installing the lockfile-matched optional Rollup/esbuild ARM64 binaries locally with `--no-save --package-lock=false`; initial full-suite failures were only missing platform binaries, and the complete suite passed after repair. Absolute verification timestamps and usage were not exposed. + +### T6 — User-test readiness decision `[x]` +`NOT READY FOR USER TEST`. Production preview confirms New Game is blocked because `greenwake-environment` is `evidenceStatus: blocked`, `runtimeUrl: null`, `nativeGlb: null`. Required P6 actors also lack native GLBs/clip mappings; only approved Aren/Asha concept crops exist and both are `providerDispatch: false`, `modelReady: false`. Do not bypass: exact per-asset rights/Tripo grants or supplied native GLBs with provenance, workspace host-image consent, an official Jev harness `>=0.2.5` (upstream official `0.2.3` is incompatible), plus native rig/root-motion/contact evidence and full P6 application acceptance remain prerequisites. Keep #25 open. + +## Boundaries +- No new Tripo jobs, reference uploads, consent changes, fake/greybox product assets, or weakened readiness gates. +- No changes outside each task's owned files except this harness; workers may commit only their owned source/test files when explicitly authorized by the coordinator. No push, merge, issue close, or browser use by workers. +- One reviewed increment is not full-game or playtest readiness. diff --git a/docs/features/combat.md b/docs/features/combat.md index 2551f21..8f55ae8 100644 --- a/docs/features/combat.md +++ b/docs/features/combat.md @@ -11,8 +11,8 @@ The engine maps all 17 approved Wild Flow actions to semantic action IDs. Dodge `CombatSnapshot.worldClock` distinguishes integer `simulationTick` from fractional 60-Hz `worldTick` (`worldTimeSeconds = worldTick / 60`). Perfect Dodge applies a data-driven 0.25 world-time scale for 12 simulation ticks; all unique-hit extensions in one uninterrupted slow-motion episode share an 18-simulation-tick (0.30-second) total elapsed-plus-remaining budget. Pause, reset, region load, and expiry end the episode. It grants 20 Instinct once per attack-instance hit group, opens a 36-simulation-tick counter window, reduces Aren's actual recovery by 12 simulation ticks and the assigned companion's ability cooldown by 18 simulation ticks, and exposes the attacker for 36 world ticks with 1.5× damage. The actual hit-window/i-frame intersection is evaluated in worldTick; input, counter, recovery, and reward windows remain simulation-tick based. -Enemy hazard/telegraph/AI recovery, stagger and possession, actor animation progress, companion navigation, movable-object animation, and StoryCue lifetimes advance on the scaled world clock. Input edges, dodge charges/iframes, player hit/counter windows, costs, rewards, and idempotency identities remain simulation-tick based. RAF/camera time is not scaled. CombatEngine.setTimePolicy('paused') freezes world time; resume and encounter/region load restore normal policy without rewinding the region clock. Checkpoint reset keeps worldTick monotonic and rebases actor world-clock deadlines (including non-player invulnerability) and lastAttackTick, while updating attackSelectionWorldOffset to preserve the virtual attack-choice clock across retries; it restores the simulation-clock counter window and player i-frames. Cues from the discarded attempt are cleared. Presentation cues keep their simulation tick/instance identity and receive engine-owned worldTimeSeconds at creation. -Runtime EngineState serialization uses schema version 2. Restoring schema-v1 data without a fractional world clock aligns worldTick to the legacy simulation tick so absolute hazard and actor deadlines remain valid. Legacy queued StoryCue timestamps derive from cue.tick / 60, and in-flight movableObjects.startedAtTick migrates to startedAtWorldTick. Version-2 fractional clock values, attackSelectionWorldOffset, and slow-motion episode elapsed ticks round-trip exactly; older v2 data defaults the offset to zero. This runtime migration does not change campaign IndexedDB saves. +Enemy hazard/telegraph/AI recovery, stagger and possession, non-player actor animation progress, companion navigation, movable-object animation, and StoryCue lifetimes advance on the scaled world clock. Aren's movement and finite action sampling advance on simulation ticks; GameRenderer samples looping Aren clips from simulationTick and other actor loops from worldTimeSeconds. Input edges, dodge charges/iframes, player hit/counter windows, costs, rewards, and idempotency identities remain simulation-tick based. RAF/camera time is not scaled. CombatEngine.setTimePolicy('paused') freezes both clocks; resume and encounter/region load restore normal policy without rewinding the region clock. Checkpoint reset keeps worldTick monotonic and rebases world-clock deadlines, non-player action timestamps (including non-player invulnerability), and lastAttackTick, while updating attackSelectionWorldOffset to preserve the virtual attack-choice clock across retries; it restores the simulation-clock counter window and player i-frames. Cues from the discarded attempt are cleared. Presentation cues keep their simulation tick/instance identity and receive engine-owned worldTimeSeconds at creation. +Runtime EngineState serialization uses schema version 3. Restoring schema-v1 data without a fractional world clock aligns worldTick to the legacy simulation tick so absolute hazard and actor deadlines remain valid. Legacy queued StoryCue timestamps derive from cue.tick / 60, and in-flight movableObjects.startedAtTick migrates to startedAtWorldTick. Version-2 fractional clock values, attackSelectionWorldOffset, and slow-motion episode elapsed ticks round-trip exactly; older v2 data defaults the offset to zero. Version-2 player action progress migrates to simulation-clock deadlines for both the live state and checkpoint state without changing its sampled normalized progress. This runtime migration does not change campaign IndexedDB saves. Light 1–3, Heavy, Charged Heavy, Root Tether, Ferocity, Corruption, and Cleanse use the same target, reach, facing, and simulation-time rules. Cleanse is interruptible and resolves only when Aren stays within range through the active window. Encounter resolution emits stable, idempotency-keyed `WorldEvent`s; restoration is a separate semantic event, not a rendering side effect. Safe checkpoint restoration restores pre-reward state and uses the same deterministic event keys, so campaign processing remains idempotent. diff --git a/src/game/combat/CombatEngine.ts b/src/game/combat/CombatEngine.ts index 06f3e95..29598c6 100644 --- a/src/game/combat/CombatEngine.ts +++ b/src/game/combat/CombatEngine.ts @@ -43,6 +43,7 @@ export interface CameraBasis { forward: Vec3; right: Vec3 } export interface CombatInput extends InputFrame { dodgeDirection?: EvasionDirection; heavyHeldTicks?: number; targetCycle?: -1 | 1; tetherPressed?: boolean; cleansePressed?: boolean; ferocityPressed?: boolean; corruptionPressed?: boolean; interactTargetId?: ActorId; interactionId?:string; interactionTargetId?:string; choiceOptionId?:string; traversalId?: string } export interface CombatActor extends ActorSnapshot { radius: number; collisionHeight:number; maxVitality: number; large: boolean; aerial: boolean; command?: CompanionCommand; corruptionCoreIds?: readonly string[]; cleansedCoreIds?: string[]; + /** Finite actions use simulation ticks for playerId, world ticks for every other actor; traversal owns its world-clock sample separately. */ recoveringUntil: number; invulnerableUntil: number; staggerUntil: number; possessedUntil: number; weakPointUntil?:number; possessionInstanceId?:string; possessionSourceId?:ActorId; actionStartedTick: number; actionEndsTick: number; } export interface LiveHazard extends HazardDefinition { @@ -62,7 +63,7 @@ export interface LiveMovableObject extends MovableObjectSnapshot { actorId?: ActorId; } export interface EngineState { - schemaVersion: 2; tick: number; worldTick:number; worldTimeScale:number; timePolicy:CombatSnapshot['worldClock']['policy']; slowMotionRemainingTicks:number; slowMotionElapsedTicks:number; accumulator: number; regionId: RegionId; runId: string; encounterId?: string; encounter: BossEncounterDefinition | null; worldFacts: Readonly>; + schemaVersion: 3; tick: number; worldTick:number; worldTimeScale:number; timePolicy:CombatSnapshot['worldClock']['policy']; slowMotionRemainingTicks:number; slowMotionElapsedTicks:number; accumulator: number; regionId: RegionId; runId: string; encounterId?: string; encounter: BossEncounterDefinition | null; worldFacts: Readonly>; actors: CombatActor[]; playerId: ActorId; player: PlayerResources; targetId?: ActorId; arena: CombatArena; hazards: LiveHazard[]; telegraphs: LiveTelegraph[]; movableObjects: LiveMovableObject[]; events: CombatEvent[]; worldEvents: WorldEvent[]; seenWorldKeys: string[]; perfectDodgeKeys: string[]; phaseIndex: number; phaseLatch: number; phaseActionFlags:string[]; @@ -71,7 +72,7 @@ export interface EngineState { eventSequence: number; rewardEmitted: boolean; checkpoint: EncounterCheckpoint | null; traversalGates: TraversalGate[]; pendingImpact?: PendingImpact; presentationCues: StoryCue[]; traversal?: LiveTraversal; } -export type EngineStateSeed = Partial> & { schemaVersion?: 1 | 2 }; +export type EngineStateSeed = Partial> & { schemaVersion?: 1 | 2 | 3 }; export interface TraversalGate { id:string; routeId:string; from:Vec3; to:Vec3; anchors:readonly Vec3[]; fromRegion:RegionId; toRegion:RegionId; width:number; rise:number; unlocked:boolean; unlockFact?:string; traversalEvent:string; colliderIds:readonly string[]; traversalActionId:ActionId; traversal?:RegionRouteDefinition['traversal'] } interface LiveTraversal { gate:TraversalGate; points:Vec3[]; lengths:number[]; length:number; segment:number; segmentDistance:number; startedAtWorldTick:number; lastSafePose:Vec3; lastSafeFacing:Vec3; standingHeight:number; } export interface EncounterStart { encounterId:string; regionId:RegionId; runId:string; playerPosition?:Vec3; bossPosition?:Vec3; companionSlots?:{combat?:CompanionId;utility?:CompanionId;scout?:'vara'}; actors?:readonly CombatActor[]; worldFacts?:Readonly>;} @@ -463,7 +464,17 @@ export class CombatEngine { this.state.tick = checkpoint.tick; const worldOffset=this.state.worldTick-checkpoint.worldTick; this.state.actors = checkpoint.actors.map(cloneActor); - for(const actor of this.state.actors){actor.recoveringUntil=shiftDeadline(actor.recoveringUntil,worldOffset);if(actor.id!==this.state.playerId)actor.invulnerableUntil=shiftDeadline(actor.invulnerableUntil,worldOffset);actor.staggerUntil=shiftDeadline(actor.staggerUntil,worldOffset);actor.possessedUntil=shiftDeadline(actor.possessedUntil,worldOffset);if(actor.weakPointUntil!==undefined)actor.weakPointUntil=shiftDeadline(actor.weakPointUntil,worldOffset);if(actor.actionEndsTick>0){actor.actionStartedTick+=worldOffset;actor.actionEndsTick+=worldOffset;}} + for(const actor of this.state.actors){ + actor.recoveringUntil=shiftDeadline(actor.recoveringUntil,worldOffset); + if(actor.id!==this.state.playerId)actor.invulnerableUntil=shiftDeadline(actor.invulnerableUntil,worldOffset); + actor.staggerUntil=shiftDeadline(actor.staggerUntil,worldOffset); + actor.possessedUntil=shiftDeadline(actor.possessedUntil,worldOffset); + if(actor.weakPointUntil!==undefined)actor.weakPointUntil=shiftDeadline(actor.weakPointUntil,worldOffset); + if(actor.id!==this.state.playerId&&actor.actionEndsTick>0){ + actor.actionStartedTick+=worldOffset; + actor.actionEndsTick+=worldOffset; + } + } // Rebase the selector timestamp and its virtual-clock offset together. this.state.lastAttackTick=shiftDeadline(checkpoint.lastAttackTick,worldOffset); this.state.attackSelectionWorldOffset=(checkpoint.attackSelectionWorldOffset??0)+worldOffset; @@ -504,12 +515,17 @@ export class CombatEngine { if(actor.downed)continue; if(actor.possessed&&actor.possessedUntil>0&&this.state.worldTick>=actor.possessedUntil)this.breakPossession(actor,true); const actionDuration=Math.max(1,actor.actionEndsTick-actor.actionStartedTick); - if(actor.actionEndsTick>0)actor.normalizedTime=Math.min(1,actor.normalizedTime+worldScale/actionDuration); + if(actor.actionEndsTick>0){ + // Aren's persisted timestamps are authoritative, including the exact completion tick. + actor.normalizedTime=actor.id===this.state.playerId + ?Math.min(1,Math.max(0,(this.state.tick-actor.actionStartedTick)/actionDuration)) + :Math.min(1,actor.normalizedTime+worldScale/actionDuration); + } if(actor.actionEndsTick>0&&actor.normalizedTime>=1){ actor.phase='idle'; actor.actionId=ACTION_CLIPS.idle!; actor.normalizedTime=0; - actor.actionStartedTick=this.state.worldTick; + actor.actionStartedTick=actor.id===this.state.playerId?this.state.tick:this.state.worldTick; actor.actionEndsTick=0; } } @@ -563,8 +579,8 @@ export class CombatEngine { this.state.actionRecoveryUntil=this.state.actionUntil; this.state.actionCancelable=true; player.actionId='aren-charged-heavy-charge' as ActionId; - player.actionStartedTick=this.state.worldTick;player.normalizedTime=0; - player.actionEndsTick=this.state.worldTick+(this.state.actionUntil-this.state.tick); + player.actionStartedTick=this.state.tick;player.normalizedTime=0; + player.actionEndsTick=this.state.actionUntil; player.phase='anticipation'; this.emit('cue','Charged Heavy: release Heavy to strike; Dodge cancels the charge.',player.id,this.state.targetId,'charged_heavy'); return; @@ -602,7 +618,7 @@ export class CombatEngine { const moving=Math.hypot(dx,dz)>.05; if(moving){ player.actionId=(target?'aren-strafe':'aren-walk') as ActionId; - player.phase='idle';player.actionStartedTick=this.state.worldTick;player.actionEndsTick=0;player.normalizedTime=0; + player.phase='idle';player.actionStartedTick=this.state.tick;player.actionEndsTick=0;player.normalizedTime=0; }else if(player.actionEndsTick===0||player.normalizedTime>=1){ player.actionId=ACTION_CLIPS.idle!;player.phase='idle'; } @@ -631,7 +647,7 @@ export class CombatEngine { const attackId=('aren-'+action) as AttackId; const target=object.objectId as ActorId; this.state.targetId=target;this.state.actionId=action;this.state.actionUntil=this.state.tick+duration;this.state.actionRecoveryUntil=this.state.actionUntil+18;this.state.actionCancelable=false; - player.actionId=ACTION_CLIPS[action]!;player.actionStartedTick=this.state.worldTick;player.normalizedTime=0;player.actionEndsTick=this.state.worldTick+duration+18;player.phase='anticipation';player.targetId=target; + player.actionId=ACTION_CLIPS[action]!;player.actionStartedTick=this.state.tick;player.normalizedTime=0;player.actionEndsTick=this.state.tick+duration+18;player.phase='anticipation';player.targetId=target; const cue='Aren draws the staff back toward the loose snare.'; const id=this.state.runId+':'+this.state.tick+':'+interaction.id+':'+action; this.state.telegraphs.push({id,attackId,sourceId:player.id,targetPosition:copy(object.position),phase:'windup',remainingTicks:activeAt-this.state.tick,cue}); @@ -657,7 +673,7 @@ export class CombatEngine { const attackId=`aren-${action}` as AttackId; this.state.targetId=interaction.targetId as ActorId; this.state.actionId=action;this.state.actionUntil=this.state.tick+duration;this.state.actionRecoveryUntil=this.state.actionUntil+18;this.state.actionCancelable=false; - player.actionId=ACTION_CLIPS[action]!;player.actionStartedTick=this.state.worldTick;player.normalizedTime=0;player.actionEndsTick=this.state.worldTick+duration+18;player.phase='anticipation';player.targetId=this.state.targetId;player.facing=facingFrom(sub(position,player.position)); + player.actionId=ACTION_CLIPS[action]!;player.actionStartedTick=this.state.tick;player.normalizedTime=0;player.actionEndsTick=this.state.tick+duration+18;player.phase='anticipation';player.targetId=this.state.targetId;player.facing=facingFrom(sub(position,player.position)); const cue=action==='cleanse'?'Root light gathers around the world mechanism.':'Aren draws back the staff for a heavy strike.'; const id=`${this.state.runId}:${this.state.tick}:${interaction.id}:${action}`; this.state.telegraphs.push({id,attackId,sourceId:player.id,targetPosition:copy(position),phase:'windup',remainingTicks:activeAt-this.state.tick,cue}); @@ -681,9 +697,9 @@ export class CombatEngine { this.state.bufferedAction=undefined; player.position=moved; player.velocity=scale(sub(moved,start),1/(profile.durationTicks*FIXED_STEP_SECONDS)); - player.actionStartedTick=this.state.worldTick; + player.actionStartedTick=this.state.tick; player.normalizedTime=0; - player.actionEndsTick=this.state.worldTick+profile.durationTicks+profile.recoveryTicks; + player.actionEndsTick=this.state.tick+profile.durationTicks+profile.recoveryTicks; player.actionId=(ACTION_CLIPS[profile.id]??ACTION_CLIPS.stumble_recovery!) as ActionId; player.phase='start'; player.invulnerableUntil=this.state.tick+profile.iframes; @@ -762,8 +778,8 @@ export class CombatEngine { this.state.actionRecoveryUntil=this.state.actionUntil+18; this.state.actionCancelable=false; player.actionId=(ACTION_CLIPS[action]??`aren-${action}`) as ActionId; - player.actionStartedTick=this.state.worldTick;player.normalizedTime=0; - player.actionEndsTick=this.state.worldTick+duration+18; + player.actionStartedTick=this.state.tick;player.normalizedTime=0; + player.actionEndsTick=this.state.tick+duration+18; player.phase='anticipation'; player.targetId=target.id; player.facing=facingFrom(sub(target.position,player.position)); @@ -1307,7 +1323,7 @@ export class CombatEngine { export function createEngineState():EngineState{ const player=makeActor('aren','human','player',vec(0,0,0),false,false,100); return { - schemaVersion:2,tick:0,worldTick:0,worldTimeScale:1,timePolicy:'normal',slowMotionRemainingTicks:0,slowMotionElapsedTicks:0,accumulator:0,regionId:'greenwake',runId:'unstarted',encounter:null,worldFacts:{}, + schemaVersion:3,tick:0,worldTick:0,worldTimeScale:1,timePolicy:'normal',slowMotionRemainingTicks:0,slowMotionElapsedTicks:0,accumulator:0,regionId:'greenwake',runId:'unstarted',encounter:null,worldFacts:{}, actors:[player],playerId:'aren',player:{vitality:100,maxVitality:100,instinct:0,ferocity:100,corruption:0,dodgeCharges:2,maxDodgeCharges:2,nextDodgeRechargeTick:0}, movableObjects:[], arena:{bounds:{min:vec(-12,0,-12),max:vec(12,0,12)},groundY:0,surfaces:[],colliders:[],routes:[],obstacles:[],cameraBasis:{forward:vec(0,0,-1),right:vec(1,0,0)},traversalAffordances:[]}, @@ -1319,14 +1335,54 @@ export function createEngineState():EngineState{ export function hydrateEngineState(seed:EngineStateSeed):EngineState{ const base=createEngineState();const state={...base,...structuredClone(seed)} as EngineState; const legacy=seed.schemaVersion===1||seed.worldTick===undefined; - state.schemaVersion=2; + // Versionless states with an explicit world clock follow the v2 timestamp convention. + const legacyPlayerActionClock=seed.worldTick!==undefined&&(seed.schemaVersion===2||seed.schemaVersion===undefined); + state.schemaVersion=3; state.worldTick=legacy?seed.tick??base.tick:seed.worldTick??base.worldTick; state.timePolicy=legacy?'normal':seed.timePolicy??base.timePolicy; state.slowMotionRemainingTicks=legacy?0:seed.slowMotionRemainingTicks??base.slowMotionRemainingTicks; - state.slowMotionElapsedTicks=legacy?0:seed.slowMotionElapsedTicks??0;state.attackSelectionWorldOffset=legacy?0:seed.attackSelectionWorldOffset??0; + state.slowMotionElapsedTicks=legacy?0:seed.slowMotionElapsedTicks??0; + state.attackSelectionWorldOffset=legacy?0:seed.attackSelectionWorldOffset??0; if(state.timePolicy==='perfect-dodge-slow'&&state.slowMotionRemainingTicks<=0)state.timePolicy='normal'; state.worldTimeScale=state.timePolicy==='paused'?0:state.timePolicy==='perfect-dodge-slow'?PERFECT_DODGE_EFFECTS.worldTimeScale:1; - state.actors=(seed.actors??base.actors).map(cloneActor);state.player={...base.player,...seed.player};state.arena={...base.arena,...seed.arena};state.hazards=seed.hazards??[];state.telegraphs=seed.telegraphs??[];state.events=seed.events??[];state.worldEvents=seed.worldEvents??[];state.worldFacts=seed.worldFacts??{};state.seenWorldKeys=seed.seenWorldKeys??[];state.perfectDodgeKeys=seed.perfectDodgeKeys??[];state.phaseActionFlags=seed.phaseActionFlags??[];state.presentationCues=(seed.presentationCues ?? []).map(cue => ({ ...cue, worldTick: cue.worldTick ?? (cue.worldTimeSeconds === undefined ? cue.tick : cue.worldTimeSeconds * 60), worldTimeSeconds: cue.worldTimeSeconds ?? cue.tick / 60 }));state.movableObjects=(seed.movableObjects??[]).map(object=>{const migrated={...object};if(migrated.startedAtWorldTick===undefined&&'startedAtTick' in migrated)migrated.startedAtWorldTick=(migrated as typeof migrated & {startedAtTick:number}).startedAtTick;delete (migrated as typeof migrated & {startedAtTick?:number}).startedAtTick;return migrated;});if(state.checkpoint){state.checkpoint.worldTick=state.checkpoint.worldTick??state.checkpoint.tick;state.checkpoint.lastAttackTick=state.checkpoint.lastAttackTick??state.checkpoint.tick;state.checkpoint.counterUntil=state.checkpoint.counterUntil??0;}return state; + state.actors=(seed.actors??base.actors).map(cloneActor); + state.player={...base.player,...seed.player}; + state.arena={...base.arena,...seed.arena}; + state.hazards=seed.hazards??[]; + state.telegraphs=seed.telegraphs??[]; + state.events=seed.events??[]; + state.worldEvents=seed.worldEvents??[]; + state.worldFacts=seed.worldFacts??{}; + state.seenWorldKeys=seed.seenWorldKeys??[]; + state.perfectDodgeKeys=seed.perfectDodgeKeys??[]; + state.phaseActionFlags=seed.phaseActionFlags??[]; + state.presentationCues=(seed.presentationCues??[]).map(cue=>({...cue,worldTick:cue.worldTick??(cue.worldTimeSeconds===undefined?cue.tick:cue.worldTimeSeconds*60),worldTimeSeconds:cue.worldTimeSeconds??cue.tick/60})); + state.movableObjects=(seed.movableObjects??[]).map(object=>{ + const migrated={...object}; + if(migrated.startedAtWorldTick===undefined&&'startedAtTick' in migrated)migrated.startedAtWorldTick=(migrated as typeof migrated & {startedAtTick:number}).startedAtTick; + delete (migrated as typeof migrated & {startedAtTick?:number}).startedAtTick; + return migrated; + }); + if(state.checkpoint){ + state.checkpoint.worldTick=state.checkpoint.worldTick??state.checkpoint.tick; + state.checkpoint.lastAttackTick=state.checkpoint.lastAttackTick??state.checkpoint.tick; + state.checkpoint.counterUntil=state.checkpoint.counterUntil??0; + } + if(legacyPlayerActionClock){ + migrateLegacyPlayerActionClock(state.actors,state.playerId,state.tick); + if(state.checkpoint)migrateLegacyPlayerActionClock(state.checkpoint.actors,state.playerId,state.checkpoint.tick); + } + return state; +} +function migrateLegacyPlayerActionClock(actors:readonly CombatActor[],playerId:ActorId,simulationTick:number):void{ + for(const actor of actors){ + if(actor.id!==playerId||actor.actionEndsTick<=0)continue; + // Preserve the saved sample, not elapsed world time: v2 normalizedTime was accumulated independently. + const duration=Math.max(1,actor.actionEndsTick-actor.actionStartedTick); + const progress=Number.isFinite(actor.normalizedTime)?Math.min(1,Math.max(0,actor.normalizedTime)):0; + actor.actionStartedTick=simulationTick-progress*duration; + actor.actionEndsTick=actor.actionStartedTick+duration; + } } export function makeActor(id:ActorId,species:string,allegiance:CombatActor['allegiance'],position:Vec3,large:boolean,aerial:boolean,vitality:number,archetypeId:ActorId=id):CombatActor{ const idle=ACTION_CLIPS[`${archetypeId}-idle`]??ACTION_CLIPS['actor-idle']!; diff --git a/src/rendering/AnimationController.ts b/src/rendering/AnimationController.ts index 2677175..c2affae 100644 --- a/src/rendering/AnimationController.ts +++ b/src/rendering/AnimationController.ts @@ -57,7 +57,8 @@ export class AnimationController { } } - play(actionId: ActionId | string, phase: string, normalizedTime?: number): void { + /** Idle loops sample actor-clock seconds modulo loaded duration; finite actions sample normalized snapshot progress. */ + play(actionId: ActionId | string, phase: string, normalizedTime?: number, loopTimeSeconds?: number): void { if (this.disposed) throw new Error(`Animation controller for ${this.assetId} has been disposed.`); const id = String(actionId); const next = this.actions.get(id); @@ -78,9 +79,12 @@ export class AnimationController { const loops = phase === 'idle'; next.setLoop(loops ? LoopRepeat : LoopOnce, loops ? Infinity : 1); next.clampWhenFinished = !loops; + // Advance mixer fades, not authoritative action time; playPreview explicitly opts into local playback. next.paused = true; - if (normalizedTime !== undefined && Number.isFinite(normalizedTime)) { - const duration = next.getClip().duration; + const duration = next.getClip().duration; + if (loops && loopTimeSeconds !== undefined && Number.isFinite(loopTimeSeconds) && duration > 0) { + next.time = ((loopTimeSeconds % duration) + duration) % duration; + } else if (normalizedTime !== undefined && Number.isFinite(normalizedTime)) { const progress = loops ? ((normalizedTime % 1) + 1) % 1 : Math.max(0, Math.min(1, normalizedTime)); if (duration > 0) next.time = duration * progress; } diff --git a/src/rendering/GameRenderer.ts b/src/rendering/GameRenderer.ts index f72dc28..fff2eb6 100644 --- a/src/rendering/GameRenderer.ts +++ b/src/rendering/GameRenderer.ts @@ -419,7 +419,12 @@ export class GameRenderer { instance.root.visible = actor.visible && (!visibleWhen || this.worldFacts[visibleWhen] === true); instance.root.position.set(actor.position.x, actor.position.y, actor.position.z); instance.root.rotation.y = Math.atan2(actor.facing.x, actor.facing.z); - instance.animation.play(actor.actionId, actor.phase, actor.normalizedTime); + const clock = this.currentCombat?.worldClock; + // Clock selection belongs to the actor snapshot, never to render delta or clip playback speed. + const loopTimeSeconds = clock + ? actor.allegiance === 'player' ? clock.simulationTick / 60 : clock.worldTimeSeconds + : undefined; + instance.animation.play(actor.actionId, actor.phase, actor.normalizedTime, loopTimeSeconds); } private resize(): void { diff --git a/tests/combat/time-dilation.test.ts b/tests/combat/time-dilation.test.ts index 76f36e8..67d17fd 100644 --- a/tests/combat/time-dilation.test.ts +++ b/tests/combat/time-dilation.test.ts @@ -3,6 +3,7 @@ import * as THREE from 'three'; import { StoryEffects } from '../../src/effects/StoryEffects.js'; import { ATTACKS } from '../../src/game/combat/content.js'; import { CombatEngine, createEngineState, makeActor, type CombatInput, type EngineState, type EngineStateSeed, type LiveHazard } from '../../src/game/combat/CombatEngine.js'; +import type { RegionRouteDefinition } from '../../src/game/contracts.js'; import { createCompanionState } from '../../src/game/companions/CompanionSystem.js'; import { vec, type CombatArena } from '../../src/game/combat/geometry.js'; import { REGIONS } from '../../src/content/campaign.js'; @@ -56,13 +57,143 @@ function startClockEngine(duplicateHazard = false): CombatEngine { } describe('Perfect Dodge world-clock time dilation', () => { - it('slows hazard and actor animation progress while simulation input and counter damage remain live', () => { + for (const worldScale of [1, 0.25]) { + it(`advances Aren by simulation ticks and every other actor by world scale ${worldScale}`, () => { + const state = makeClockState(); + state.tick = 20; + state.worldTick = 10; + state.hazards = []; + state.timePolicy = worldScale === 1 ? 'normal' : 'perfect-dodge-slow'; + state.worldTimeScale = worldScale; + state.slowMotionRemainingTicks = worldScale === 1 ? 0 : 8; + state.actors.push( + makeActor('clock-ally', 'human', 'ally', vec(4, 0, 0), false, false, 100), + makeActor('clock-neutral', 'human', 'neutral', vec(5, 0, 0), false, false, 100), + makeActor('clock-wild', 'human', 'wild', vec(6, 0, 0), false, false, 100), + ); + for (const actor of state.actors) { + actor.phase = 'active'; + actor.actionStartedTick = actor.id === state.playerId ? 12 : 2; + actor.actionEndsTick = actor.actionStartedTick + 40; + actor.normalizedTime = 0.2; + } + const engine = new CombatEngine(state); + for (let step = 0; step < 4; step++) engine.fixedStep(idle); + const advanced = engine.snapshotState(); + expect(advanced.tick).toBe(24); + expect(advanced.worldTick).toBe(10 + 4 * worldScale); + for (const actor of advanced.actors) { + const player = actor.id === advanced.playerId; + expect(actor.normalizedTime).toBeCloseTo(0.2 + 4 * (player ? 1 : worldScale) / 40); + expect(actor.actionStartedTick).toBe(player ? 12 : 2); + expect(actor.actionEndsTick).toBe(player ? 52 : 42); + } + }); + } + + it('starts a finite player action during slow motion on simulation time and completes on its exact deadline', () => { + const state = makeClockState(); + state.tick = 60; + state.worldTick = 45.25; + state.hazards = []; + state.timePolicy = 'perfect-dodge-slow'; + state.worldTimeScale = 0.25; + state.slowMotionRemainingTicks = 12; + const engine = new CombatEngine(state); + engine.fixedStep({ ...idle, heavyPressed: true }); + const started = engine.snapshotState(); + expect(started.tick).toBe(61); + expect(started.worldTick).toBe(45.5); + expect(started.actors.find(actor => actor.id === started.playerId)).toMatchObject({ + actionId: 'aren-heavy', actionStartedTick: 61, actionEndsTick: 124, normalizedTime: 0, + }); + engine.fixedStep(idle); + expect(engine.snapshotState().actors.find(actor => actor.id === started.playerId)?.normalizedTime).toBeCloseTo(1 / 63); + const restored = new CombatEngine(); + restored.restore(engine.serialize()); + expect(restored.snapshotState().actors).toEqual(engine.snapshotState().actors); + for (let step = 0; step < 61; step++) restored.fixedStep(idle); + expect(restored.snapshotState().tick).toBe(123); + expect(restored.snapshotState().actors.find(actor => actor.id === started.playerId)).toMatchObject({ + actionId: 'aren-heavy', actionStartedTick: 61, actionEndsTick: 124, + }); + expect(restored.snapshotState().actors.find(actor => actor.id === started.playerId)?.normalizedTime).toBeCloseTo(62 / 63); + restored.fixedStep(idle); + expect(restored.snapshotState().tick).toBe(124); + expect(restored.snapshotState().actionId).toBe('idle'); + expect(restored.snapshotState().actors.find(actor => actor.id === started.playerId)).toMatchObject({ + actionId: 'aren-idle', actionStartedTick: 124, actionEndsTick: 0, normalizedTime: 0, phase: 'idle', + }); + }); + + it('completes a finite Aren sample while a same-duration world actor remains slowed', () => { + const state = makeClockState(); + state.tick = 20; + state.worldTick = 10; + state.hazards = []; + state.timePolicy = 'perfect-dodge-slow'; + state.worldTimeScale = 0.25; + state.slowMotionRemainingTicks = 8; + for (const actor of state.actors) { + actor.phase = 'active'; + actor.actionStartedTick = actor.id === state.playerId ? 18 : 8; + actor.actionEndsTick = actor.actionStartedTick + 4; + actor.normalizedTime = 0.5; + } + const engine = new CombatEngine(state); + engine.fixedStep(idle); + expect(engine.snapshotState().actors.find(actor => actor.id === state.playerId)?.normalizedTime).toBe(0.75); + engine.fixedStep(idle); + const completed = engine.snapshotState(); + expect(completed.actors.find(actor => actor.id === state.playerId)).toMatchObject({ + actionId: 'aren-idle', actionStartedTick: 22, actionEndsTick: 0, normalizedTime: 0, phase: 'idle', + }); + expect(completed.actors.find(actor => actor.id !== state.playerId)).toMatchObject({ + actionStartedTick: 8, actionEndsTick: 12, normalizedTime: 0.625, phase: 'active', + }); + }); + + it('keeps contextual traversal movement and sampling on world time during slow motion', () => { + const state = makeClockState(); + state.tick = 60; + state.worldTick = 45.25; + state.hazards = []; + state.timePolicy = 'perfect-dodge-slow'; + state.worldTimeScale = 0.25; + state.slowMotionRemainingTicks = 12; + const route: RegionRouteDefinition = { + id: 'clock-vault', from: vec(0, 0, 0), to: vec(4, 0, 0), anchors: [], + destinationRegionId: state.regionId, kind: 'path', traversalActionId: 'aren-staff-vault', + traversal: { kind: 'vault', durationTicks: 4, startRadius: 1 }, colliderIds: [], traversalEvent: 'clock-vaulted', + }; + const engine = new CombatEngine(state); + engine.setTraversalGates([route]); + engine.fixedStep({ ...idle, interactPressed: true, traversalId: route.id }); + expect(engine.isTraversing).toBe(true); + expect(engine.snapshotState().actors.find(actor => actor.id === state.playerId)).toMatchObject({ + actionStartedTick: 45.5, actionEndsTick: 0, normalizedTime: 0, + }); + for (let step = 0; step < 4; step++) engine.fixedStep(idle); + expect(engine.snapshotState()).toMatchObject({ tick: 65, worldTick: 46.5 }); + expect(engine.snapshotState().actors.find(actor => actor.id === state.playerId)).toMatchObject({ + actionStartedTick: 45.5, actionEndsTick: 0, normalizedTime: 0.25, position: vec(1, 0, 0), + }); + expect(engine.isTraversing).toBe(true); + expect(engine.consumeEvents().filter(event => event.kind === 'traversal')).toHaveLength(0); + }); + + it('slows hazards and hostile animation while Aren sampling and simulation input remain live', () => { const normal = startClockEngine(); normal.fixedStep(idle); normal.fixedStep(idle); expect(normal.snapshot().hazards[0]?.active).toBe(true); - const engine = startClockEngine(); + const slowState = makeClockState(); + const enemy = slowState.actors.find(actor => actor.id === 'hunter-melee-west')!; + enemy.phase = 'active'; + enemy.actionStartedTick = 0; + enemy.actionEndsTick = 100; + const engine = new CombatEngine(slowState); engine.fixedStep({ ...idle, dodgePressed: true, dodgeDirection: 'left' }); const afterDodge = engine.snapshotState(); expect(engine.snapshot().worldClock).toMatchObject({ simulationTick: 1, worldTick: 1, timeScale: 0.25, policy: 'perfect-dodge-slow' }); @@ -72,7 +203,8 @@ describe('Perfect Dodge world-clock time dilation', () => { expect(cue.worldTimeSeconds).toBe(engine.snapshot().worldClock.worldTimeSeconds); expect(afterDodge.actionRecoveryUntil - afterDodge.actionUntil).toBe(6); expect(afterDodge.companionState.slots.asha?.readyAt).toBe(32); - expect(afterDodge.actors.find(actor => actor.id === 'hunter-melee-west')?.weakPointUntil).toBeGreaterThan(afterDodge.worldTick); + const enemyAfterDodge = afterDodge.actors.find(actor => actor.id === 'hunter-melee-west')!; + expect(enemyAfterDodge.normalizedTime).toBeCloseTo(1 / 100); expect(engine.snapshot().actors.find(actor => actor.id === 'hunter-melee-west')).toMatchObject({ weakPointExposed: true }); const restored = new CombatEngine(); restored.restore(engine.serialize()); @@ -81,15 +213,19 @@ describe('Perfect Dodge world-clock time dilation', () => { engine.fixedStep(idle); const slowed = engine.snapshotState(); const player = slowed.actors.find(actor => actor.id === 'aren')!; - expect(slowed.worldTick).toBe(1.25); - expect(engine.snapshot().hazards[0]?.active).toBe(false); - expect(engine.snapshot().worldClock.worldTimeSeconds).toBeCloseTo(1.25 / 60); - expect(player.normalizedTime).toBeCloseTo(0.25 / (player.actionEndsTick - player.actionStartedTick)); + const enemyDuringSlowMotion = slowed.actors.find(actor => actor.id === 'hunter-melee-west')!; + expect(player.actionStartedTick).toBe(slowed.tick - 1); + expect(player.normalizedTime).toBeCloseTo(1 / (player.actionEndsTick - player.actionStartedTick)); + expect(enemyDuringSlowMotion.normalizedTime - enemyAfterDodge.normalizedTime).toBeCloseTo(0.25 / 100); engine.fixedStep({ ...idle, lightPressed: true }); - expect(engine.snapshotState().actionId).toBe('light_1'); - expect(engine.snapshotState().pendingImpact?.counter).toBe(true); - expect(engine.snapshotState().actors.find(actor => actor.id === 'aren')?.actionId).toBe('aren-light-1'); + const counterAction = engine.snapshotState(); + const playerAction = counterAction.actors.find(actor => actor.id === 'aren')!; + expect(counterAction.actionId).toBe('light_1'); + expect(counterAction.pendingImpact?.counter).toBe(true); + expect(playerAction.actionId).toBe('aren-light-1'); + expect(playerAction.actionStartedTick).toBe(counterAction.tick); + expect(playerAction.actionEndsTick).toBe(counterAction.actionRecoveryUntil); for (let step = 0; step < 12; step++) engine.fixedStep(idle); expect(engine.snapshotState().actors.find(actor => actor.id === 'hunter-melee-west')?.vitality).toBe(88); const recoveryUntil = engine.snapshotState().actionRecoveryUntil; @@ -97,8 +233,9 @@ describe('Perfect Dodge world-clock time dilation', () => { const recovered = engine.snapshotState(); const actorAfterRecovery = recovered.actors.find(actor => actor.id === 'aren')!; expect(recovered.tick).toBe(recoveryUntil + 1); - expect(actorAfterRecovery.actionId).toBe('aren-light-1'); - expect(actorAfterRecovery.normalizedTime).toBeLessThan(1); + expect(actorAfterRecovery.actionId).toBe('aren-idle'); + expect(actorAfterRecovery.normalizedTime).toBe(0); + expect(actorAfterRecovery.actionEndsTick).toBe(0); }); it('keeps ordinary dodges at normal time when the threat is outside the perfect window', () => { const state = makeClockState(); @@ -361,6 +498,9 @@ describe('Perfect Dodge world-clock time dilation', () => { state.hazards[0]!.activeUntil = 63; state.hazards[0]!.damage = 10; state.actors.find(actor => actor.id === 'hunter-melee-west')!.recoveringUntil = 70; + Object.assign(state.actors.find(actor => actor.id === state.playerId)!, { + actionId: 'aren-light-1', phase: 'active', actionStartedTick: 44, actionEndsTick: 84, normalizedTime: 0.25, + }); state.checkpoint = { tick: 54, worldTick: 54, lastAttackTick: state.lastAttackTick, counterUntil: state.counterUntil, actors: structuredClone(state.actors), player: structuredClone(state.player), phaseIndex: 0, phaseLatch: 0, phaseActionFlags: [], rewardEmitted: false, companions: structuredClone(state.companionState) }; const legacy = JSON.parse(JSON.stringify(state)) as Record; legacy.schemaVersion = 1; @@ -375,14 +515,28 @@ describe('Perfect Dodge world-clock time dilation', () => { const engine = new CombatEngine(); engine.restore(JSON.stringify(legacy)); - expect(engine.snapshotState()).toMatchObject({ schemaVersion: 2, tick: 54, worldTick: 54, timePolicy: 'normal' }); + expect(engine.snapshotState()).toMatchObject({ schemaVersion: 3, tick: 54, worldTick: 54, timePolicy: 'normal' }); expect(engine.snapshotState().checkpoint?.lastAttackTick).toBe(54); + expect(engine.snapshotState().actors.find(actor => actor.id === state.playerId)).toMatchObject({ + actionStartedTick: 44, actionEndsTick: 84, normalizedTime: 0.25, + }); + expect(engine.snapshotState().checkpoint?.actors.find(actor => actor.id === state.playerId)).toMatchObject({ + actionStartedTick: 44, actionEndsTick: 84, normalizedTime: 0.25, + }); engine.fixedStep(idle); expect(engine.snapshotState().worldTick).toBe(55); expect(engine.snapshot().hazards[0]?.active).toBe(true); expect(engine.snapshotState().player.vitality).toBe(90); expect(engine.snapshotState().checkpoint?.worldTick).toBe(54); expect(engine.snapshotState().actors.find(actor => actor.id === 'hunter-melee-west')?.recoveringUntil).toBe(70); + expect(engine.snapshotState().actors.find(actor => actor.id === state.playerId)?.normalizedTime).toBeCloseTo(0.275); + engine.resetToCheckpoint(); + expect(engine.snapshotState()).toMatchObject({ tick: 54, worldTick: 55 }); + expect(engine.snapshotState().actors.find(actor => actor.id === state.playerId)).toMatchObject({ + actionStartedTick: 44, actionEndsTick: 84, normalizedTime: 0.25, + }); + engine.fixedStep(idle); + expect(engine.snapshotState().actors.find(actor => actor.id === state.playerId)?.normalizedTime).toBeCloseTo(0.275); const fractional = new CombatEngine(); const fractionalState = createEngineState(); @@ -391,9 +545,133 @@ describe('Perfect Dodge world-clock time dilation', () => { const fractionalSource = new CombatEngine(fractionalState); fractional.restore(fractionalSource.serialize()); expect(fractional.snapshot().worldClock.worldTick).toBe(7.25); - expect(fractional.snapshotState().schemaVersion).toBe(2); + expect(fractional.snapshotState().schemaVersion).toBe(3); }); + for (const schemaVersion of [2, undefined] as const) { + it(`migrates ${schemaVersion === 2 ? 'v2' : 'unversioned world-clock'} player samples without changing live or checkpoint progress`, () => { + const state = makeClockState(); + state.tick = 60; + state.worldTick = 45.25; + state.hazards = []; + state.timePolicy = 'perfect-dodge-slow'; + state.worldTimeScale = 0.25; + state.slowMotionRemainingTicks = 6; + state.actionId = 'light_1'; + state.actionUntil = 65; + state.actionRecoveryUntil = 83; + const player = state.actors.find(actor => actor.id === state.playerId)!; + Object.assign(player, { + actionId: 'aren-light-1', phase: 'active', actionStartedTick: 34.25, actionEndsTick: 74.25, normalizedTime: 0.25, + }); + const enemy = state.actors.find(actor => actor.id !== state.playerId)!; + Object.assign(enemy, { + phase: 'active', actionStartedTick: 37.25, actionEndsTick: 77.25, normalizedTime: 0.2, recoveringUntil: 77.25, + }); + const checkpointActors = structuredClone(state.actors); + Object.assign(checkpointActors.find(actor => actor.id === state.playerId)!, { + actionStartedTick: 32.25, actionEndsTick: 72.25, normalizedTime: 0.1, + }); + Object.assign(checkpointActors.find(actor => actor.id !== state.playerId)!, { + actionStartedTick: 27.25, actionEndsTick: 67.25, recoveringUntil: 67.25, + }); + state.checkpoint = { + tick: 50, worldTick: 35.25, lastAttackTick: 0, counterUntil: 0, actors: checkpointActors, + player: structuredClone(state.player), phaseIndex: 0, phaseLatch: 0, phaseActionFlags: [], + rewardEmitted: false, companions: structuredClone(state.companionState), + }; + const legacy = JSON.parse(JSON.stringify(state)) as EngineStateSeed; + if (schemaVersion === undefined) delete legacy.schemaVersion; + else legacy.schemaVersion = schemaVersion; + const engine = new CombatEngine(); + engine.restore(JSON.stringify(legacy)); + const migrated = engine.snapshotState(); + expect(migrated.schemaVersion).toBe(3); + expect(migrated.actors.find(actor => actor.id === state.playerId)).toMatchObject({ + actionStartedTick: 50, actionEndsTick: 90, normalizedTime: 0.25, + }); + expect(migrated.checkpoint!.actors.find(actor => actor.id === state.playerId)).toMatchObject({ + actionStartedTick: 46, actionEndsTick: 86, normalizedTime: 0.1, + }); + expect(migrated.actors.find(actor => actor.id === enemy.id)).toEqual(enemy); + expect(migrated.checkpoint!.actors.find(actor => actor.id === enemy.id)).toEqual(checkpointActors.find(actor => actor.id === enemy.id)); + expect(migrated.actionUntil).toBe(state.actionUntil); + expect(migrated.actionRecoveryUntil).toBe(state.actionRecoveryUntil); + const roundTrip = new CombatEngine(JSON.parse(engine.serialize()) as EngineStateSeed); + expect(roundTrip.snapshotState().actors).toEqual(migrated.actors); + expect(roundTrip.snapshotState().checkpoint).toEqual(migrated.checkpoint); + roundTrip.fixedStep(idle); + expect(roundTrip.snapshotState()).toMatchObject({ tick: 61, worldTick: 45.5 }); + expect(roundTrip.snapshotState().actors.find(actor => actor.id === state.playerId)?.normalizedTime).toBeCloseTo(0.275); + expect(roundTrip.snapshotState().actors.find(actor => actor.id === enemy.id)?.normalizedTime).toBeCloseTo(0.20625); + roundTrip.resetToCheckpoint(); + expect(roundTrip.snapshotState()).toMatchObject({ tick: 50, worldTick: 45.5, timePolicy: 'normal' }); + expect(roundTrip.snapshotState().actors.find(actor => actor.id === state.playerId)).toMatchObject({ + actionStartedTick: 46, actionEndsTick: 86, normalizedTime: 0.1, + }); + expect(roundTrip.snapshotState().actors.find(actor => actor.id === enemy.id)).toMatchObject({ + actionStartedTick: 37.5, actionEndsTick: 77.5, normalizedTime: 0.2, recoveringUntil: 77.5, + }); + roundTrip.fixedStep(idle); + expect(roundTrip.snapshotState().actors.find(actor => actor.id === state.playerId)?.normalizedTime).toBeCloseTo(0.125); + expect(roundTrip.snapshotState().actors.find(actor => actor.id === enemy.id)?.normalizedTime).toBeCloseTo(0.225); + roundTrip.resetToCheckpoint(); + expect(roundTrip.snapshotState().actors.find(actor => actor.id === state.playerId)).toMatchObject({ + actionStartedTick: 46, actionEndsTick: 86, normalizedTime: 0.1, + }); + expect(roundTrip.snapshotState().actors.find(actor => actor.id === enemy.id)).toMatchObject({ + actionStartedTick: 38.5, actionEndsTick: 78.5, normalizedTime: 0.2, + }); + expect(roundTrip.snapshotState().checkpoint).toEqual(migrated.checkpoint); + for (let step = 0; step < 36; step++) roundTrip.fixedStep(idle); + expect(roundTrip.snapshotState().tick).toBe(86); + expect(roundTrip.snapshotState().actors.find(actor => actor.id === state.playerId)).toMatchObject({ + actionId: 'aren-idle', actionStartedTick: 86, actionEndsTick: 0, normalizedTime: 0, + }); + }); + } + + it('retains v3 player action timestamps through restore and repeated monotonic-world checkpoint resets', () => { + const state = makeClockState(); + state.tick = 60; + state.worldTick = 45.25; + state.hazards = []; + Object.assign(state.actors.find(actor => actor.id === state.playerId)!, { + actionId: 'aren-light-1', phase: 'active', actionStartedTick: 50, actionEndsTick: 90, normalizedTime: 0.25, + }); + const checkpointActors = structuredClone(state.actors); + Object.assign(checkpointActors.find(actor => actor.id === state.playerId)!, { + actionStartedTick: 46, actionEndsTick: 86, normalizedTime: 0.1, + }); + Object.assign(checkpointActors.find(actor => actor.id !== state.playerId)!, { + phase: 'active', actionStartedTick: 27.25, actionEndsTick: 67.25, normalizedTime: 0.2, recoveringUntil: 67.25, + }); + state.checkpoint = { + tick: 50, worldTick: 35.25, lastAttackTick: 0, counterUntil: 0, actors: checkpointActors, + player: structuredClone(state.player), phaseIndex: 0, phaseLatch: 0, phaseActionFlags: [], + rewardEmitted: false, companions: structuredClone(state.companionState), + }; + const engine = new CombatEngine(); + engine.restore(JSON.stringify(state)); + expect(engine.snapshotState().actors).toEqual(state.actors); + expect(engine.snapshotState().checkpoint).toEqual(state.checkpoint); + for (let retry = 0; retry < 2; retry++) { + engine.resetToCheckpoint(); + expect(engine.snapshotState().actors.find(actor => actor.id === state.playerId)).toMatchObject({ + actionStartedTick: 46, actionEndsTick: 86, normalizedTime: 0.1, + }); + const retryWorldTick = engine.snapshotState().worldTick; + expect(engine.snapshotState().actors.find(actor => actor.id !== state.playerId)).toMatchObject({ + actionStartedTick: retryWorldTick - 8, actionEndsTick: retryWorldTick + 32, normalizedTime: 0.2, + }); + engine.fixedStep(idle); + expect(engine.snapshotState().actors.find(actor => actor.id === state.playerId)?.normalizedTime).toBeCloseTo(0.125); + expect(engine.snapshotState().actors.find(actor => actor.id !== state.playerId)?.normalizedTime).toBeCloseTo(0.225); + } + expect(engine.snapshotState().checkpoint).toEqual(state.checkpoint); + }); + + it('hydrates legacy queued StoryCue timestamps and ages the hydrated cue through StoryEffects', () => { const source = new CombatEngine(makeClockState()); const cue = source.queuePresentationCue({ kind: 'hit', phase: 'hit', position: vec(0, 0, 0), durationTicks: 6, priority: 'essential' }); diff --git a/tests/rendering/browser-smoke.ts b/tests/rendering/browser-smoke.ts index a577da3..d53c4ac 100644 --- a/tests/rendering/browser-smoke.ts +++ b/tests/rendering/browser-smoke.ts @@ -41,6 +41,7 @@ async function run(): Promise { const sampledIdleTime = animation.activeTime!; animation.update(0.5); assert(animation.activeTime === sampledIdleTime, 'mixer delta advances fades without advancing snapshot-sampled action time'); + assert(animation.activeDuration === 2 && Math.abs(firstMesh.skeleton.bones[0]!.position.y - 0.05) < 1e-6, 'loaded two-second idle clip keeps its normalized sample while mixer fades reach full weight'); animation.play('attack', 'active', 0.7); const sampledAttackTime = animation.activeTime!; assert(close(firstMesh.skeleton.bones[0]!.position.x, 0), 'verified native root-motion curve is consumed; x=' + firstMesh.skeleton.bones[0]!.position.x + ', t=' + animation.activeTime); @@ -119,7 +120,12 @@ async function run(): Promise { const region = fixtureRegion(); await renderer.loadRegion(region, [recipe], recipeDefinition); const actor = fixtureActor(); - const combat: CombatSnapshot = { actors: [actor], movableObjects: [], player: { vitality: 1, maxVitality: 1, instinct: 0, dodgeCharges: 0, maxDodgeCharges: 0 }, telegraphs: [{ id: 'warning', attackId: 'test' as never, sourceId: 'aren', targetPosition: { x: 0, y: 0, z: -2 }, phase: 'anticipation', remainingTicks: 4 }], hazards: [], events: [] }; + const combat: CombatSnapshot = { + actors: [actor], movableObjects: [], worldClock: { simulationTick: 30, worldTick: 7.5, worldTimeSeconds: 0.125, timeScale: 0.25, policy: 'perfect-dodge-slow' }, + player: { vitality: 1, maxVitality: 1, instinct: 0, dodgeCharges: 0, maxDodgeCharges: 0 }, + telegraphs: [{ id: 'warning', attackId: 'test' as never, sourceId: 'aren', targetPosition: { x: 0, y: 0, z: -2 }, phase: 'anticipation', remainingTicks: 4 }], + hazards: [], events: [], + }; renderer.sync([actor], combat, 1 / 60); const evidence = renderer.capture(); assert(evidence.regionId === 'greenwake', 'composed environment recipe commits with a region scene'); @@ -154,6 +160,49 @@ async function run(): Promise { const neutralMaterial = (neutralWalkway as Mesh & { material: { color: Color } })?.material; assert(neutralMaterial && neutralMaterial.color.getHexString() === baselineHex, 'cleared restoration facts restore authored surface color'); + const clockCharacter = { + ...character, + requiredActionIds: [...character.requiredActionIds, 'clock-strike'], + actionToClipMapping: { ...character.actionToClipMapping, 'clock-strike': 'ClockStrike' }, + rootMotion: { ...character.rootMotion, 'clock-strike': { mode: 'in-place', evidenceStatus: 'verified' } }, + } as AssetManifestEntry; + const arenManifest = { ...clockCharacter, id: 'actor-aren', actorId: 'aren' } as AssetManifestEntry; + const arenPlacement: RegionEntityDefinition = { id: 'aren', actorId: 'aren', kind: 'actor', assetId: 'actor-aren', position: { x: 0, y: 0, z: 0 }, placement: 'ground' }; + const clockHunterManifest = { ...clockCharacter, id: 'actor-clock-hunter', actorId: 'poacher-hunter' } as AssetManifestEntry; + const clockHunterPlacement: RegionEntityDefinition = { id: 'clock-hunter', actorId: 'poacher-hunter', kind: 'actor', assetId: clockHunterManifest.id, position: { x: 2, y: 0, z: 0 }, placement: 'ground' }; + const clockHunter: ActorSnapshot = { ...actor, id: 'clock-hunter' as never, archetypeId: 'poacher-hunter' as never, allegiance: 'hostile', position: clockHunterPlacement.position }; + const clockActors = [actor, clockHunter]; + const clockCombat: CombatSnapshot = { ...combat, actors: clockActors, telegraphs: [] }; + await renderer.loadRegion(fixtureRegion([], [], undefined, [arenPlacement, clockHunterPlacement]), [recipe, arenManifest, clockHunterManifest], recipeDefinition); + // Finish the 0.12s presentation fade with snapshot clocks held fixed before inspecting native bone samples. + renderer.sync(clockActors, clockCombat, 0.2); + const playerHips = renderer.resolveSocket('aren', 'hips'); + const hostileHips = renderer.resolveSocket('clock-hunter', 'hips'); + assert(playerHips && hostileHips, 'production renderer loads both synthetic native actor rigs for clock-domain sampling'); + const playerLoopSample = playerHips.position.y; + const hostileLoopSample = hostileHips.position.y; + assert(Math.abs(playerLoopSample - 0.05) < 1e-6, 'player loop clip samples simulation time during world slow-motion'); + assert(Math.abs(hostileLoopSample - 0.0125) < 1e-6 && Math.abs(playerLoopSample - hostileLoopSample * 4) < 1e-6, 'hostile and player native loop samples use distinct clock domains at world scale 0.25'); + renderer.sync(clockActors, clockCombat, 0.9); + assert(playerHips.position.y === playerLoopSample && hostileHips.position.y === hostileLoopSample, 'unchanged authoritative clocks keep native loop samples fixed across different render deltas'); + const wrappedCombat: CombatSnapshot = { ...clockCombat, worldClock: { ...combat.worldClock, simulationTick: 150, worldTick: 37.5, worldTimeSeconds: 0.625 } }; + renderer.sync(clockActors, wrappedCombat, 1 / 60); + assert(Math.abs(playerHips.position.y - 0.05) < 1e-6 && Math.abs(hostileHips.position.y - 0.0625) < 1e-6, 'native loops wrap actor-clock seconds by their loaded two-second duration, not a one-second normalized period'); + const clockStrikes: ActorSnapshot[] = clockActors.map((snapshot) => ({ ...snapshot, actionId: 'clock-strike' as never, phase: 'active', normalizedTime: 0.25 })); + const strikeCombat: CombatSnapshot = { ...wrappedCombat, actors: clockStrikes }; + renderer.sync(clockStrikes, strikeCombat, 0.5); + assert(Math.abs(playerHips.position.y - 0.05) < 1e-6 && Math.abs(hostileHips.position.y - 0.05) < 1e-6, 'player and hostile one-shot clips use normalized snapshot progress instead of either loop clock'); + const progressedStrikes: ActorSnapshot[] = clockStrikes.map((snapshot) => ({ ...snapshot, normalizedTime: snapshot.allegiance === 'player' ? 0.75 : 0.375 })); + const progressedCombat: CombatSnapshot = { ...strikeCombat, actors: progressedStrikes }; + renderer.sync(progressedStrikes, progressedCombat, 1 / 60); + assert(Math.abs(playerHips.position.y - 0.15) < 1e-6 && Math.abs(hostileHips.position.y - 0.075) < 1e-6, 'native one-shot samples follow each actor normalized progress even when both clock values are unchanged'); + const playerStrikeSample = playerHips.position.y; + const hostileStrikeSample = hostileHips.position.y; + renderer.sync(progressedStrikes, progressedCombat, 1); + assert(playerHips.position.y === playerStrikeSample && hostileHips.position.y === hostileStrikeSample, 'renderer delta cannot advance snapshot-sampled player or hostile one-shots'); + const finishedStrikes: ActorSnapshot[] = clockStrikes.map((snapshot) => ({ ...snapshot, normalizedTime: 1 })); + renderer.sync(finishedStrikes, { ...strikeCombat, actors: finishedStrikes }, 1 / 60); + assert(Math.abs(playerHips.position.y - 0.2) < 1e-6 && Math.abs(hostileHips.position.y - 0.2) < 1e-6, 'finite native clips retain their normalized endpoint instead of wrapping like loops'); const hunterManifest = { ...character, id: 'actor-poacher-hunter', actorId: 'poacher-hunter' } as AssetManifestEntry; const scoutManifest = { ...character, id: 'actor-poacher-scout', actorId: 'poacher-scout' } as AssetManifestEntry; const hunterPlacements: RegionEntityDefinition[] = [ @@ -167,7 +216,7 @@ async function run(): Promise { const hunterRanged: ActorSnapshot = { ...hunterWest, id: 'hunter-ranged' as never, archetypeId: 'poacher-scout' as never, position: { x: 0, y: 0, z: -3 } }; const hunters = [hunterWest, hunterEast, hunterRanged]; const huntersCombat = { ...combat, actors: hunters, telegraphs: [] }; - renderer.sync(hunters, huntersCombat, 1 / 60); + renderer.sync(hunters, huntersCombat, 0.2); const placedActors = new Map(); renderer.scene.traverse((node) => { if (typeof node.userData.actorInstanceId === 'string') placedActors.set(node.userData.actorInstanceId, node); }); const westRig = placedActors.get('hunter-melee-west'); @@ -175,6 +224,8 @@ async function run(): Promise { const rangedRig = placedActors.get('hunter-ranged'); assert(westRig && eastRig && rangedRig && westRig !== eastRig && findSkinnedMesh(westRig).skeleton !== findSkinnedMesh(eastRig).skeleton && findSkinnedMesh(eastRig).skeleton !== findSkinnedMesh(rangedRig).skeleton, 'two camp melee instances share actor-poacher-hunter while all three placed actors keep independent skeletons'); assert(renderer.capture().actorCount === 3, 'renderer retains all camp runtime instance IDs without archetype-key overwrite'); + const enemyLoopSample = findSkinnedMesh(westRig!).skeleton.bones[0]!.position.y; + assert(Math.abs(enemyLoopSample - 0.0125) < 1e-6, 'hostile loop clip remains sampled on scaled world time'); const horizontalCollider: RegionColliderDefinition = { id: 'horizontal', kind: 'capsule', from: { x: 0, y: 0, z: 0 }, to: { x: 0, y: 0, z: 10 }, radius: 0.5, blocksMovement: true, blocksCamera: true }; await renderer.loadRegion(fixtureRegion([], [horizontalCollider]), [recipe], recipeDefinition); let colliderMesh = renderer.scene.getObjectByName('camera-collider:horizontal') as Mesh; @@ -396,10 +447,12 @@ async function createTestSkinnedGlb(color = 0x9fc887): Promise<{ url: string; ou mesh.bind(new Skeleton([hips])); const root = new Group(); root.add(mesh); - const idle = new AnimationClip('Idle', 1, [new VectorKeyframeTrack('hips.position', [0, 1], [0, 0, 0, 0, 0.1, 0])]); + // Synthetic test motion only: a known two-second clip with linear y = 0.1 * sampleSeconds. + const idle = new AnimationClip('Idle', 2, [new VectorKeyframeTrack('hips.position', [0, 2], [0, 0, 0, 0, 0.2, 0])]); const strike = new AnimationClip('Strike', 1, [new VectorKeyframeTrack('hips.position', [0, 1], [0, 0, 0, 0.1, 0, 0])]); + const clockStrike = new AnimationClip('ClockStrike', 2, [new VectorKeyframeTrack('hips.position', [0, 2], [0, 0, 0, 0, 0.2, 0])]); const previewIdle = new AnimationClip('PreviewIdle', 1, [new VectorKeyframeTrack('hips.position', [0, 1], [0, 0, 0, 0, 0, 0.1])]); - const binary = await new GLTFExporter().parseAsync(root, { binary: true, animations: [idle, strike, previewIdle] }) as ArrayBuffer; + const binary = await new GLTFExporter().parseAsync(root, { binary: true, animations: [idle, strike, clockStrike, previewIdle] }) as ArrayBuffer; return { url: URL.createObjectURL(new Blob([binary], { type: 'model/gltf-binary' })), outputHash: await hash(binary) }; } async function createTestStaticGlb(): Promise<{ url: string; outputHash: string }> {