diff --git a/tools/android/exercise-final-progression.ps1 b/tools/android/exercise-final-progression.ps1 index f0d6c4a..10bb86d 100644 --- a/tools/android/exercise-final-progression.ps1 +++ b/tools/android/exercise-final-progression.ps1 @@ -3,14 +3,23 @@ param([Parameter(Mandatory)][string]$ApkPath,[Parameter(Mandatory)][string]$Evid # This suite never pushes a save or injects completed stages. Every checkpoint is read from the APK. function Board($State,[int]$Stage) { return @($State.puzzleStates | Where-Object stageId -eq $Stage)[0] } function Counts($Board) { return (@($Board.groups | ForEach-Object {$_.ballIds.Count} | Sort-Object) -join ',') } -function Orbit([string]$Name,$CurrentBoard,[switch]$Pitch) { +function Orbit([string]$Name,$CurrentBoard,[switch]$Pitch,[int]$YawPixels=120) { if(!$CurrentBoard){$saved=Save "$Name-view";$CurrentBoard=if($saved.puzzleStates.Count){$saved.puzzleStates[-1]}else{$saved.latestFreePlay}} $points=@($CurrentBoard.balls|ForEach-Object{,(BallPoint $CurrentBoard $_.id)}) foreach($y in @(300,700)){foreach($x in @(650,900,1150,1400,1650)){ if(@($points|Where-Object{[Math]::Pow($_[0]-$x,2)+[Math]::Pow($_[1]-$y,2)-lt22500}).Count-eq0){ - $endX=if($Pitch){$x}else{$x+120};$endY=if($Pitch){$y+120}else{$y} + $endX=if($Pitch){$x}else{$x+$YawPixels};$endY=if($Pitch){$y+120}else{$y} if($endY-gt750){continue} - Step $Name @('shell','input','swipe',"$x","$y","$endX","$endY",'350');return + if($YawPixels-ne120-and!$Pitch){ + # Latch the real >14px orbit drag, then reverse to the small net yaw. + try{ + NativePointer @($x,$y,81,1024);Start-Sleep -Milliseconds 100 + NativePointer @(($x+[Math]::Sign($YawPixels)*20),$y,81,1024);Start-Sleep -Milliseconds 100 + NativePointer @($endX,$endY,81,1024);Start-Sleep -Milliseconds 100 + }finally{NativePointer @($endX,$endY,81,0)} + Step $Name @() 500 + }else{Step $Name @('shell','input','swipe',"$x","$y","$endX","$endY",'350')} + return } }} throw 'No safe blank orbit start in the actual board view.' @@ -34,11 +43,43 @@ function RevealBall([int]$Stage,[string]$Id,[string]$Name) { } throw "No safe first-hit view for actual ball $Id within a full native orbit." } +function LiftMergeRoute([int]$Stage,[string]$Name,$Board,[int]$SourceCount=0,[int]$TargetCount=0) { + $eligible=@($Board.groups|Where-Object { + $candidate=$_ + ($SourceCount-eq0-or$candidate.ballIds.Count-eq$SourceCount)-and@($Board.groups|Where-Object {$_.id-ne$candidate.id-and($TargetCount-eq0-or$_.ballIds.Count-eq$TargetCount)}).Count-gt0 + }|Sort-Object {$_.ballIds.Count}) + $id=$null + foreach($group in $eligible){ + $low=@($Board.balls|Where-Object {$group.ballIds-contains$_.id-and$_.position.y-lt2}|Sort-Object {$_.position.y}) + if($low.Count){$id=$low[0].id;break} + } + if(!$id){return $null} + $board=RevealBall $Stage $id "$Name-reveal" + $point=BallPoint $board $id;$end=@($point[0],($point[1]-120)) + if(!(WorldSafe $end)){ + $unchanged=Save "$Name-no-safe-upward-endpoint" + $actual=Board $unchanged $Stage + Check "$Name-unsafe-endpoint-board-unchanged" ($null-ne$actual-and(Same $board $actual)) 'No native touch: the complete revealed board, counts and manual pose remain exact' + return [pscustomobject]@{State=$unchanged;Before=$board;SourceId=$id} + } + $after=NativeMoveAndHold $Name $board $id $end + $actual=Board $after $Stage;if(!$actual){$actual=$after.latestFreePlay} + Check "$Name-detour-manual-camera-preserved" (Same $board.camera $actual.camera) 'Every detour outcome retains the exact confirmed manual pose' + if($actual.groups.Count-lt$board.groups.Count){AssertNativeMerge $Name $board $actual $id $TargetCount;return [pscustomobject]@{State=$after;Before=$board;SourceId=$id}} + Check "$Name-detour-membership-preserved" (Same $board.groups $actual.groups) 'An upward route never injects a split or merge' + Check "$Name-detour-not-counted" (Same $board.countedBallIds $actual.countedBallIds) 'An actual detour does not count friends' + if(Same $board.balls $actual.balls){ + $commands.Add(@{command='Upward detour produced no observed ball movement; continue actual view search, never count as merge';sourceId=$id;utc=[DateTime]::UtcNow.ToString('o')}) + }else{AssertNativeTranslation $Name $board $actual $id} + return [pscustomobject]@{State=$after;Before=$board;SourceId=$id} +} function MergeReal([int]$Stage,[string]$Name,[int]$SourceCount=0,[int]$TargetCount=0) { for($attempt=0;$attempt-lt16;$attempt++) { $before=Save "$Name-before-$attempt"; $board=Board $before $Stage if($null-eq$board){return $before} - $oldCount=$board.groups.Count + # If the initial view has no safe pair, route upward before pitching further. + # Reproject the actual result within this attempt; the original bound stays sixteen. + for($projection=0;$projection-lt2;$projection++){ $rotation=[Numerics.Quaternion]::new($board.camera.rotation.x,$board.camera.rotation.y,$board.camera.rotation.z,$board.camera.rotation.w) $pairs=@(foreach($source in $board.groups){foreach($target in $board.groups){if($source.id-eq$target.id-or($SourceCount-gt0-and$source.ballIds.Count-ne$SourceCount)-or($TargetCount-gt0-and$target.ballIds.Count-ne$TargetCount)){continue};foreach($sid in $source.ballIds){foreach($tid in $target.ballIds){ $s=@($board.balls|Where-Object id -eq $sid)[0].position; $t=@($board.balls|Where-Object id -eq $tid)[0].position @@ -52,6 +93,19 @@ function MergeReal([int]$Stage,[string]$Name,[int]$SourceCount=0,[int]$TargetCou $targetPoint=BallPoint $board $candidate.tid if(WorldSafe @(($targetPoint[0]+$offset),$targetPoint[1])){$pair=$candidate;break} } + if($pair-or$attempt-ne0-or$projection-ne0){break} + $routed=LiftMergeRoute $Stage "$Name-upward-route" $board $SourceCount $TargetCount + if(!$routed){break} + $routedBoard=Board $routed.State $Stage + if(!$routedBoard-or$routedBoard.groups.Count-lt$routed.Before.groups.Count){ + $actual=if($routedBoard){$routedBoard}else{$routed.State.latestFreePlay} + if(!$routedBoard){Check "$Name-route-completed-stage" ($routed.State.completedStageIds-contains$Stage-and$actual.sourceStageId-eq$Stage) 'A missing puzzle requires actual completion and its own free-play board'} + AssertNativeMerge "$Name-route-actual" $routed.Before $actual $routed.SourceId $TargetCount + return $routed.State + } + $board=$routedBoard + } + $oldCount=$board.groups.Count if($pair) { $end=BallPoint $board $pair.tid # Try a short lateral contact offset, then orbit to expose another depth plane if blocked. @@ -63,7 +117,30 @@ function MergeReal([int]$Stage,[string]$Name,[int]$SourceCount=0,[int]$TargetCou } $board=$newBoard } - Orbit "$Name-orbit-$attempt" $board -Pitch:($attempt%4-eq3) + $fineYaw=0 + if(!$pair){ + # A coarse orbit can skip the narrow depth window of an otherwise safe pair. + # Choose a small improving yaw, then read the actual projection next attempt. + foreach($candidate in $pairs){ + if($candidate.depth-lt.9-or$candidate.depth-gt1.2-or!(BallClear $board $candidate.sid)){continue} + $target=BallPoint $board $candidate.tid + if(!(WorldSafe @(($target[0]+$offset),$target[1]))){continue} + $s=@($board.balls|Where-Object id -eq $candidate.sid)[0].position;$t=@($board.balls|Where-Object id -eq $candidate.tid)[0].position + $separation=[Numerics.Vector3]::new($s.x-$t.x,$s.y-$t.y,$s.z-$t.z) + $best=$candidate.depth + foreach($pixels in @(-8,8)){ + $predicted=[Numerics.Quaternion]::CreateFromAxisAngle([Numerics.Vector3]::UnitY,($pixels*.25*[Math]::PI/180))*$rotation + $depth=[Math]::Abs([Numerics.Vector3]::Transform($separation,[Numerics.Quaternion]::Inverse($predicted)).Z) + if($depth-lt$best){$best=$depth;$fineYaw=$pixels} + } + if($fineYaw){break} + } + } + if($fineYaw){ + Orbit "$Name-fine-orbit-$attempt" $board -YawPixels $fineYaw + $view=Board (Save "$Name-fine-orbit-$attempt-state") $Stage + Check "$Name-fine-orbit-preserves-board-$attempt" ($null-ne$view-and(Same $board.balls $view.balls)-and(Same $board.groups $view.groups)-and(Same $board.collectedBallIds $view.collectedBallIds)-and(Same $board.countedBallIds $view.countedBallIds)-and!(Same $board.camera $view.camera)) 'Actual manual pose changed; no ball, membership, collection or count edits' + }else{Orbit "$Name-orbit-$attempt" $board -Pitch:($attempt%4-eq3)} } throw "Native merge bound reached for Stage $Stage; all actual attempts retained." } diff --git a/tools/android/final-common.ps1 b/tools/android/final-common.ps1 index 9fdb5db..531d222 100644 --- a/tools/android/final-common.ps1 +++ b/tools/android/final-common.ps1 @@ -44,14 +44,11 @@ function NativeMoveAndHold([string]$Name,$Board,[string]$Id,[int[]]$End) { Step $Name @() 2500 return Save "$Name-dropped" } -function AssertNativeMerge([string]$Name,$Before,$After,[string]$SourceId,[int]$TargetCount=0) { +function AssertNativeTranslation([string]$Name,$Before,$After,[string]$SourceId) { $idsBefore=@($Before.balls.id|Sort-Object);$idsAfter=@($After.balls.id|Sort-Object) Check "$Name-exact-original-ids" (Same $idsBefore $idsAfter) 'No lost, duplicated or substituted IDs after actual native input' Check "$Name-manual-camera-preserved" (Same $Before.camera $After.camera) 'Automatic assistance never persists as the manual camera' $source=@($Before.groups|Where-Object {$_.ballIds-contains$SourceId})[0] - $merged=@($After.groups|Where-Object {$_.ballIds-contains$SourceId})[0] - $targets=@($Before.groups|Where-Object {$_.id-ne$source.id-and@($_.ballIds|Where-Object {$merged.ballIds-notcontains$_}).Count-eq0}) - Check "$Name-actual-source-and-target-membership" ($targets.Count-eq1-and$merged.ballIds.Count-eq($source.ballIds.Count+$targets[0].ballIds.Count)-and@($source.ballIds|Where-Object {$merged.ballIds-notcontains$_}).Count-eq0-and($TargetCount-eq0-or$targets[0].ballIds.Count-eq$TargetCount)) 'Grabbed source plus exactly one actual foreign group; requested target size retained' $firstBefore=@($Before.balls|Where-Object id -eq $SourceId)[0].position $firstAfter=@($After.balls|Where-Object id -eq $SourceId)[0].position $delta=[Numerics.Vector3]::new($firstAfter.x-$firstBefore.x,$firstAfter.y-$firstBefore.y,$firstAfter.z-$firstBefore.z) @@ -63,6 +60,13 @@ function AssertNativeMerge([string]$Name,$Before,$After,[string]$SourceId,[int]$ Check "$Name-rigid-source-$($ball.id)" (($movement-$delta).Length()-lt.0001) 'Every source member shares the same actual translation' }else{Check "$Name-stationary-foreign-$($ball.id)" (Same $ball.position $actual) 'Unselected foreign balls retain exact saved positions'} } +} +function AssertNativeMerge([string]$Name,$Before,$After,[string]$SourceId,[int]$TargetCount=0) { + AssertNativeTranslation $Name $Before $After $SourceId + $source=@($Before.groups|Where-Object {$_.ballIds-contains$SourceId})[0] + $merged=@($After.groups|Where-Object {$_.ballIds-contains$SourceId})[0] + $targets=@($Before.groups|Where-Object {$_.id-ne$source.id-and@($_.ballIds|Where-Object {$merged.ballIds-notcontains$_}).Count-eq0}) + Check "$Name-actual-source-and-target-membership" ($targets.Count-eq1-and$merged.ballIds.Count-eq($source.ballIds.Count+$targets[0].ballIds.Count)-and@($source.ballIds|Where-Object {$merged.ballIds-notcontains$_}).Count-eq0-and($TargetCount-eq0-or$targets[0].ballIds.Count-eq$TargetCount)) 'Grabbed source plus exactly one actual foreign group; requested target size retained' $distance=[double]::PositiveInfinity foreach($sid in $source.ballIds){foreach($tid in $targets[0].ballIds){ $s=@($After.balls|Where-Object id -eq $sid)[0].position;$t=@($After.balls|Where-Object id -eq $tid)[0].position diff --git a/tools/android/test-final-live-drag.ps1 b/tools/android/test-final-live-drag.ps1 index 10fa37f..28e2aea 100644 --- a/tools/android/test-final-live-drag.ps1 +++ b/tools/android/test-final-live-drag.ps1 @@ -1,4 +1,4 @@ -param([Parameter(Mandatory)][string]$EvidenceDir,[Parameter(Mandatory)][string]$Fixture,[string]$CommonSource=(Join-Path $PSScriptRoot 'final-common.ps1'),[string]$ProgressionSource=(Join-Path $PSScriptRoot 'exercise-final-progression.ps1')) +param([Parameter(Mandatory)][string]$EvidenceDir,[Parameter(Mandatory)][string]$Fixture,[string]$CommonSource=(Join-Path $PSScriptRoot 'final-common.ps1'),[string]$ProgressionSource=(Join-Path $PSScriptRoot 'exercise-final-progression.ps1'),[string]$BlockedFixture,[string]$LiftBefore,[string]$LiftAfter,[string]$FineFixture) $ErrorActionPreference='Stop' New-Item -ItemType Directory -Path $EvidenceDir -Force|Out-Null $checks=[Collections.Generic.List[object]]::new() @@ -22,6 +22,7 @@ function Adb([string[]]$Arguments){return 'Physical size: 1080x2400'} function Step([string]$Name,[string[]]$Arguments=@(),[int]$Delay=500){$commands.Add(@{name=$Name;arguments=$Arguments})} function Save([string]$Name){return $raw} function global:NativeMoveAndHold([string]$Name,$Board,[string]$Id,[int[]]$End){$commands.Add(@{nativeHeldMove=$true;id=$Id;end=$End});return $raw} +function global:LiftMergeRoute([int]$Stage,[string]$Name,$Board,[int]$SourceCount=0,[int]$TargetCount=0){return [pscustomobject]@{State=$raw;Before=$Board;SourceId=$Board.balls[0].id}} try{ foreach($name in @('BallPoint','WorldSafe','BallClear','Same')){ReadFunction $CommonSource $name} foreach($name in @('Board','Orbit','MergeReal')){ReadFunction $ProgressionSource $name} @@ -44,6 +45,8 @@ try{ $script:touches.Clear();$rejected=$false;try{NativeMoveAndHold 'changed-preview' $board 's05-b04' $end|Out-Null}catch{$rejected=$true} Record 'persisted-preview-is-rejected-and-contact-released' ($rejected-and$script:touches.Count-eq3-and$script:touches[-1][3]-eq0) $script:touches.ToArray() ReadFunction $CommonSource 'AssertNativeMerge' + $commonAst=[Management.Automation.Language.Parser]::ParseFile($CommonSource,[ref]$null,[ref]$null) + if($commonAst.Find({param($n)$n-is[Management.Automation.Language.FunctionDefinitionAst]-and$n.Name-eq'AssertNativeTranslation'},$true)){ReadFunction $CommonSource 'AssertNativeTranslation'} $success=$board|ConvertTo-Json -Depth 20|ConvertFrom-Json $source=@($board.groups|Where-Object {$_.ballIds-contains's05-b04'})[0] $target=@($board.groups|Where-Object {$_.ballIds-notcontains's05-b04'})[0] @@ -81,7 +84,126 @@ try{ $rejected=$false;try{AssertNativeMerge "offline-$kind" $board $bad 's05-b01' $targetSize}catch{$rejected=$true} Record 'invalid-group-translation-or-target-size-is-rejected' $rejected $kind } + if($BlockedFixture){ + if(!$LiftBefore-or!$LiftAfter){throw 'Blocked-route controls require actual root-observed lift before/after saves'} + $raw=Get-Content -LiteralPath $BlockedFixture -Raw|ConvertFrom-Json + $blocked=Board $raw 18 + if(!$blocked-or$blocked.balls.Count-ne8-or$blocked.groups.Count-ne2){throw 'Requires actual Stage18 failed 7+1 save'} + $commands.Clear();$script:routeCalls=0 + function Save([string]$Name){return $raw} + function global:NativeMoveAndHold([string]$Name,$Board,[string]$Id,[int[]]$End){$commands.Add(@{nativeHeldMove=$true;id=$Id;end=$End});return $raw} + function global:LiftMergeRoute([int]$Stage,[string]$Name,$Board,[int]$SourceCount=0,[int]$TargetCount=0){$script:routeCalls++;$commands.Add(@{route=$true});return [pscustomobject]@{State=$raw;Before=$Board;SourceId='s18-b04'}} + $bound=$false;try{MergeReal 18 'replay-actual-blocked18'|Out-Null}catch{$bound=$_.Exception.Message-match'Native merge bound reached'} + $orbits=@($commands|Where-Object {$_.arguments.Count-ge7-and$_.arguments[2]-eq'swipe'}) + Record 'actual-blocked18-uses-one-detour-with-original-attempt-bound' ($bound-and$script:routeCalls-eq1-and$orbits.Count-eq16-and$commands[0].route) @{boundedFailureRetained=$bound;detours=$script:routeCalls;orbitAttempts=$orbits.Count;routeBeforeFirstOrbit=$commands[0].route} + foreach($kind in @('missing-board-wrong-free','missing-board-without-completion','merged-without-contact')){ + $routeState=$raw|ConvertTo-Json -Depth 20|ConvertFrom-Json + if($kind-eq'merged-without-contact'){ + (Board $routeState 18).groups=@([pscustomobject]@{id='unproven';ballIds=@($blocked.balls.id)}) + @((Board $routeState 18).balls|Where-Object id -eq 's18-b04')[0].position.y+=.1 + } + else{ + $routeState.puzzleStates=@($routeState.puzzleStates|Where-Object stageId -ne 18) + if($kind-eq'missing-board-wrong-free'){$routeState.completedStageIds+=18} + else{$routeState.latestFreePlay=$blocked|ConvertTo-Json -Depth 20|ConvertFrom-Json;$routeState.latestFreePlay|Add-Member sourceStageId 18} + } + function global:LiftMergeRoute([int]$Stage,[string]$Name,$Board,[int]$SourceCount=0,[int]$TargetCount=0){return [pscustomobject]@{State=$routeState;Before=$Board;SourceId='s18-b04'}} + $errorText=$null;try{MergeReal 18 "invalid-caller-route-$kind"|Out-Null}catch{$errorText=$_.Exception.Message} + $expectedCheck=if($kind-eq'merged-without-contact'){'real-three-dimensional-contact'}else{'route-completed-stage'} + Record 'caller-rejects-unproven-route-completion-or-contact' ($errorText-match$expectedCheck) @{kind=$kind;error=$errorText} + } + if($FineFixture){ + function global:LiftMergeRoute {return $null} + function Save([string]$Name){return $raw|ConvertTo-Json -Depth 20|ConvertFrom-Json} + foreach($mutation in @('none','ball','membership','collection','count','camera-no-op')){ + $raw=Get-Content -LiteralPath $FineFixture -Raw|ConvertFrom-Json + $commands.Clear();$script:fineMoves=0;$script:orbitContacts=[Collections.Generic.List[object]]::new();$script:orbitStart=$null;$script:orbitLast=0;$script:orbitDragged=$false + function NativePointer([int[]]$Contacts){ + $script:orbitContacts.Add(@($Contacts)) + if($Contacts[3]-eq0){$script:orbitStart=$null;return} + if($null-eq$script:orbitStart){$script:orbitStart=$Contacts[0];$script:orbitLast=$Contacts[0];$script:orbitDragged=$false;return} + $script:orbitDragged=$script:orbitDragged-or[Math]::Abs($Contacts[0]-$script:orbitStart)-ge14 + if($script:orbitDragged-and$mutation-ne'camera-no-op'){ + $b=Board $raw 18 + $rotation=[Numerics.Quaternion]::new($b.camera.rotation.x,$b.camera.rotation.y,$b.camera.rotation.z,$b.camera.rotation.w) + $changed=[Numerics.Quaternion]::CreateFromAxisAngle([Numerics.Vector3]::UnitY,(($Contacts[0]-$script:orbitLast)*.25*[Math]::PI/180))*$rotation + $b.camera.rotation=[pscustomobject]@{x=$changed.X;y=$changed.Y;z=$changed.Z;w=$changed.W} + } + $script:orbitLast=$Contacts[0] + } + function Step([string]$Name,[string[]]$Arguments=@(),[int]$Delay=500){ + $commands.Add(@{name=$Name;arguments=$Arguments}) + $b=Board $raw 18 + switch($mutation){ + 'ball'{$b.balls[0].position.x+=.1} + 'membership'{$b.groups[0].ballIds+= 's18-b01'} + 'collection'{$b.collectedBallIds+= 's18-b04'} + 'count'{$b.countedBallIds+= 's18-b04'} + } + } + # Counterfactual camera/rigid-contact control derived from the exact failed pose. + # This proves the verifier oracle and never represents a native completion. + function global:NativeMoveAndHold([string]$Name,$Board,[string]$Id,[int[]]$End){ + $script:fineMoves++ + $after=$raw|ConvertTo-Json -Depth 20|ConvertFrom-Json;$b=Board $after 18 + $source=@($b.groups|Where-Object {$_.ballIds-contains$Id})[0] + $s=@($b.balls|Where-Object id -eq $Id)[0].position;$t=@($b.balls|Where-Object id -eq 's18-b04')[0].position + $delta=[Numerics.Vector3]::new($t.x+1-$s.x,$t.y-$s.y,$t.z-$s.z) + foreach($ball in $b.balls|Where-Object {$source.ballIds-contains$_.id}){$ball.position.x+=$delta.X;$ball.position.y+=$delta.Y;$ball.position.z+=$delta.Z} + $b.groups=@([pscustomobject]@{id='offline-contact';ballIds=@($b.balls.id)}) + return $after + } + $errorText=$null;$value=$null + try{$value=MergeReal 18 "fine-control-$mutation" 7 1}catch{$errorText=$_.Exception.Message} + $contacts=$script:orbitContacts.ToArray() + $smallYaw=$contacts.Count-eq4-and$contacts[0][3]-eq1024-and$contacts[1][3]-eq1024-and$contacts[2][3]-eq1024-and$contacts[3][3]-eq0-and[Math]::Abs($contacts[1][0]-$contacts[0][0])-eq20-and[Math]::Abs($contacts[2][0]-$contacts[0][0])-eq8-and$contacts[3][0]-eq$contacts[2][0] + $passed=if($mutation-eq'none'){$null-eq$errorText-and$script:fineMoves-eq1-and(Board $value 18).groups.Count-eq1}else{$errorText-match'fine-orbit-preserves-board'-and$script:fineMoves-eq0} + Record 'fine-yaw-from-actual-near-window-retains-strict-oracle' ($passed-and$smallYaw) @{mutation=$mutation;contacts=$contacts;commands=$commands.ToArray();nativeMockMoves=$script:fineMoves;error=$errorText;scope='Offline counterfactual geometry control with actual 14px drag latch from failed pose'} + } + function Step([string]$Name,[string[]]$Arguments=@(),[int]$Delay=500){$commands.Add(@{name=$Name;arguments=$Arguments})} + } + ReadFunction $CommonSource 'AssertNativeTranslation' + ReadFunction $ProgressionSource 'LiftMergeRoute' + $raw=Get-Content -LiteralPath $LiftBefore -Raw|ConvertFrom-Json + $lifted=Get-Content -LiteralPath $LiftAfter -Raw|ConvertFrom-Json + $board=Board $raw 18;$script:liftRequests=[Collections.Generic.List[object]]::new() + function RevealBall([int]$Stage,[string]$Id,[string]$Name){return $board} + function global:NativeMoveAndHold([string]$Name,$Board,[string]$Id,[int[]]$End){$script:liftRequests.Add(@{sourceId=$Id;end=$End});return $lifted} + $actual=LiftMergeRoute 18 'replay-actual-observed-lift' $board + $point=BallPoint $board 's18-b04' + Record 'actual-root-lift-save-pair-passes-rigid-ID-camera-controls' ($script:liftRequests.Count-eq1-and$script:liftRequests[0].sourceId-eq's18-b04'-and$script:liftRequests[0].end[0]-eq$point[0]-and$script:liftRequests[0].end[1]-eq($point[1]-120)-and(Same $actual.State $lifted)-and(Same $actual.Before $board)-and$actual.SourceId-eq's18-b04') 'Uses root actual before/drop saves; no synthetic native success or completion injection' + foreach($kind in @('wrong-target-size','foreign-ball-moved','camera-changed','missing-ID','no-op-camera-changed')){ + $script:liftRequests.Clear();$lifted=Get-Content -LiteralPath $LiftAfter -Raw|ConvertFrom-Json;$targetSize=0 + switch($kind){ + 'wrong-target-size'{$targetSize=4} + 'foreign-ball-moved'{(Board $lifted 18).balls[0].position.x+=.1} + 'camera-changed'{(Board $lifted 18).camera.distance+=.1} + 'missing-ID'{(Board $lifted 18).balls=@((Board $lifted 18).balls|Where-Object id -ne 's18-b01')} + 'no-op-camera-changed'{(Board $lifted 18).balls=$board.balls;(Board $lifted 18).camera.distance+=.1} + } + $rejected=$false;$value=$null;try{$value=LiftMergeRoute 18 "invalid-route-$kind" $board 0 $targetSize}catch{$rejected=$true} + $passed=if($kind-eq'wrong-target-size'){$null-eq$value-and$script:liftRequests.Count-eq0}else{$rejected} + Record 'invalid-route-result-or-ineligible-target-is-rejected' $passed $kind + } + $board=Board (Get-Content -LiteralPath $BlockedFixture -Raw|ConvertFrom-Json) 18 + $point=BallPoint $board 's18-b04' + if(WorldSafe @($point[0],($point[1]-120))){throw 'Actual unsafe-endpoint fixture is unexpectedly safe'} + foreach($kind in @('unchanged','disappeared','wrong-free','counts-changed','collection-changed','camera-changed')){ + $raw=Get-Content -LiteralPath $BlockedFixture -Raw|ConvertFrom-Json + switch($kind){ + 'disappeared'{$raw.puzzleStates=@($raw.puzzleStates|Where-Object stageId -ne 18)} + 'wrong-free'{$raw.puzzleStates=@($raw.puzzleStates|Where-Object stageId -ne 18);$raw.completedStageIds+=18} + 'counts-changed'{(Board $raw 18).countedBallIds+= 's18-b04'} + 'collection-changed'{(Board $raw 18).collectedBallIds+= 's18-b04'} + 'camera-changed'{(Board $raw 18).camera.distance+=.1} + } + $script:liftRequests.Clear();$rejected=$false;$value=$null + try{$value=LiftMergeRoute 18 "unsafe-route-$kind" $board}catch{$rejected=$true} + $passed=if($kind-eq'unchanged'){!$rejected-and(Same $value.State $raw)}else{$rejected} + Record 'unsafe-detour-requires-exact-present-board-without-touch' ($passed-and$script:liftRequests.Count-eq0) $kind + } + } }catch{Record 'production-function-evaluation' $false $_.Exception.Message} $passed=$checks.Count-gt0-and@($checks|Where-Object passed -ne $true).Count-eq0 -@{passed=$passed;scope='Offline actual failed save replay and native-input/assertion negative controls; no ADB, no Unity, no native success claim';fixture=$Fixture;fixtureSha256=(Get-FileHash -LiteralPath $Fixture).Hash;commonSource=$CommonSource;commonSha256=(Get-FileHash -LiteralPath $CommonSource).Hash;progressionSource=$ProgressionSource;progressionSha256=(Get-FileHash -LiteralPath $ProgressionSource).Hash;checks=$checks.ToArray()}|ConvertTo-Json -Depth 12|Set-Content -LiteralPath (Join-Path $EvidenceDir 'live-drag-result.json') +@{passed=$passed;scope='Offline actual failed save replay and native-input/assertion negative controls; no ADB, no Unity, no native success claim';fixture=$Fixture;fixtureSha256=(Get-FileHash -LiteralPath $Fixture).Hash;routeFixtures=@(@($BlockedFixture,$LiftBefore,$LiftAfter,$FineFixture)|Where-Object {$_}|ForEach-Object {@{path=$_;sha256=(Get-FileHash -LiteralPath $_).Hash}});commonSource=$CommonSource;commonSha256=(Get-FileHash -LiteralPath $CommonSource).Hash;progressionSource=$ProgressionSource;progressionSha256=(Get-FileHash -LiteralPath $ProgressionSource).Hash;checks=$checks.ToArray()}|ConvertTo-Json -Depth 12|Set-Content -LiteralPath (Join-Path $EvidenceDir 'live-drag-result.json') if(!$passed){exit 1};exit 0