diff --git a/tools/android/exercise-final-progression.ps1 b/tools/android/exercise-final-progression.ps1 index d3c90ae..7aeb2c4 100644 --- a/tools/android/exercise-final-progression.ps1 +++ b/tools/android/exercise-final-progression.ps1 @@ -3,13 +3,13 @@ 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,[int]$YawPixels=120) { +function Orbit([string]$Name,$CurrentBoard,[switch]$Pitch,[int]$YawPixels=120,[int]$PitchPixels=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+$YawPixels};$endY=if($Pitch){$y+120}else{$y} - if($endY-gt750){continue} + $endX=if($Pitch){$x}else{$x+$YawPixels};$endY=if($Pitch){$y+$PitchPixels}else{$y} + if($endY-lt280-or$endY-gt750){continue} if($YawPixels-ne120-and!$Pitch){ # Latch the real >14px orbit drag, then reverse to the small net yaw. try{ @@ -257,7 +257,19 @@ function CountRemaining([int]$Stage) { $beforeCount=$board.countedBallIds.Count $id=$null foreach($ball in $uncounted){if(BallClear $board $ball.id){$id=$ball.id;break}} - if(!$id){Orbit "stage-$Stage-count-safe-view-$attempt" $board;continue} + if(!$id){ + $rotation=[Numerics.Quaternion]::new($board.camera.rotation.x,$board.camera.rotation.y,$board.camera.rotation.z,$board.camera.rotation.w) + # Local pitch reverses its elevation effect after crossing the vertical pole. + $pitchPixels=if([Numerics.Vector3]::Transform([Numerics.Vector3]::UnitY,$rotation).Y-ge0){-120}else{120} + if([Numerics.Vector3]::Transform(-[Numerics.Vector3]::UnitZ,$rotation).Y-lt.5){$pitchPixels=-$pitchPixels} + Orbit "stage-$Stage-count-safe-view-$attempt" $board -Pitch:($attempt%4-eq3) -PitchPixels $pitchPixels + $view=Save "stage-$Stage-count-view-after-$attempt" + $unchanged=$view|ConvertTo-Json -Depth 20|ConvertFrom-Json + $viewBoard=Board $unchanged $Stage + if($viewBoard){$viewBoard.camera=$board.camera} + Check "stage-$Stage-count-view-preserves-whole-save-$attempt" (Same $state $unchanged) 'Only the actual manual view may change while searching for an uncounted first hit' + continue + } TapBall "stage-$Stage-count-$attempt" $board $id $after=Save "stage-$Stage-count-$attempt-after"; $newBoard=Board $after $Stage if($null-eq$newBoard){return $after} diff --git a/tools/android/test-final-count-view.ps1 b/tools/android/test-final-count-view.ps1 new file mode 100644 index 0000000..222ab05 --- /dev/null +++ b/tools/android/test-final-count-view.ps1 @@ -0,0 +1,79 @@ +param([Parameter(Mandatory)][string]$EvidenceDir,[Parameter(Mandatory)][string]$Fixture,[string]$ProgressionSource=(Join-Path $PSScriptRoot 'exercise-final-progression.ps1')) +$ErrorActionPreference='Stop' +New-Item -ItemType Directory -Path $EvidenceDir -Force|Out-Null +$checks=[Collections.Generic.List[object]]::new();$commands=[Collections.Generic.List[object]]::new() +function Record($Id,$Passed,$Actual){$checks.Add(@{id=$Id;passed=[bool]$Passed;actual=$Actual})} +function Check($Id,$Passed,$Actual){if(!$Passed){throw "$Id : $Actual"}} +function ReadFunction($Path,$Name){ + $ast=[Management.Automation.Language.Parser]::ParseFile($Path,[ref]$null,[ref]$null) + $f=$ast.Find({param($n)$n-is[Management.Automation.Language.FunctionDefinitionAst]-and$n.Name-eq$Name},$true) + $expected=$f.Extent.Text.Replace("function $Name","function global:$Name") + . ([scriptblock]::Create($expected)) + $actual=(Get-Command $Name).ScriptBlock.Ast.Extent.Text + Record "$Name-production-body-identity" ($actual-ceq$expected) @{source=$Path;sha256=[Convert]::ToHexString([Security.Cryptography.SHA256]::HashData([Text.Encoding]::UTF8.GetBytes($actual)))} +} +function Adb($Arguments){return 'Physical size: 1080x2400'} +$common=Join-Path $PSScriptRoot 'final-common.ps1' +foreach($name in @('Same','BallPoint','BallClear','WorldSafe','TapBall')){ReadFunction $common $name} +foreach($name in @('Board','Orbit','CountRemaining')){ReadFunction $ProgressionSource $name} +$captured=Get-Content -LiteralPath $Fixture -Raw|ConvertFrom-Json +$stage=@($captured.puzzleStates|Where-Object {$_.balls.id-contains's16-b05'})[0].stageId +if(!$stage-or(Board $captured $stage).countedBallIds.Count-ne5){throw 'Requires the exact captured Stage16 five-of-six blocked count state'} +function Save($Name){if($Name-match'-count-\d+-state$'){$script:stateReads++};return $script:state|ConvertTo-Json -Depth 20|ConvertFrom-Json} +function Step($Name,$Arguments=@(),$Delay=500){ + $script:steps.Add(@{name=$Name;arguments=$Arguments}) + if($Arguments[2]-eq'swipe'){ + $b=Board $script:state $stage;$q=[Numerics.Quaternion]::new($b.camera.rotation.x,$b.camera.rotation.y,$b.camera.rotation.z,$b.camera.rotation.w) + $yaw=([int]$Arguments[5]-[int]$Arguments[3])*.25*[Math]::PI/180 + $pitch=([int]$Arguments[6]-[int]$Arguments[4])*.25*[Math]::PI/180 + $q=[Numerics.Quaternion]::CreateFromAxisAngle([Numerics.Vector3]::UnitY,$yaw)*$q*[Numerics.Quaternion]::CreateFromAxisAngle([Numerics.Vector3]::UnitX,$pitch) + if($script:mutation-ne'low-view-occluded'-or$pitch-ne0){$b.camera.rotation=[pscustomobject]@{x=$q.X;y=$q.Y;z=$q.Z;w=$q.W}} + switch($script:mutation){ + 'ball'{$b.balls[0].position.x+=.1} + 'membership'{$b.groups[0].ballIds=@($b.groups[0].ballIds|Select-Object -Skip 1)} + 'collection'{$b.collectedBallIds=@($b.collectedBallIds|Select-Object -Skip 1)} + 'count'{$b.countedBallIds+= 's16-b05'} + 'completed'{$script:state.completedStageIds+= $stage} + 'other-puzzle'{$script:state.puzzleStates[0].balls[0].position.x+=.1} + 'free-play'{$script:state.latestFreePlay.camera.distance+=.1} + 'tutorial'{$script:state.seenTutorialIds+= 'unobserved'} + } + } + if($Arguments[2]-eq'tap'){ + $b=Board $script:state $stage;$point=BallPoint $b 's16-b05' + $script:invalidTap=$script:invalidTap-or!(BallClear $b 's16-b05')-or[int]$Arguments[3]-ne$point[0]-or[int]$Arguments[4]-ne$point[1] + } + # A tap is observed, never translated into synthetic counting or completion. +} +try{ + foreach($kind in @('captured-past-pole','near-upper-limit','low-view-occluded','ball','membership','collection','count','completed','other-puzzle','free-play','tutorial')){ + $script:state=$captured|ConvertTo-Json -Depth 20|ConvertFrom-Json;$script:steps=[Collections.Generic.List[object]]::new();$script:mutation=$kind;$script:stateReads=0;$script:invalidTap=$false + if($kind-eq'near-upper-limit'){ + $q=[Numerics.Quaternion]::CreateFromAxisAngle([Numerics.Vector3]::UnitX,(89*[Math]::PI/180)) + (Board $script:state $stage).camera.rotation=[pscustomobject]@{x=$q.X;y=$q.Y;z=$q.Z;w=$q.W} + } + if($kind-eq'low-view-occluded'){ + # Counterfactual low camera and front sphere; yaw leaf remains stationary. + # This tests signed pitch selection, never an actual reachable scene claim. + $b=Board $script:state $stage;$b.camera.rotation=[pscustomobject]@{x=0;y=0;z=0;w=1} + $p=@($b.balls|Where-Object id -eq 's16-b05')[0].position + @($b.balls|Where-Object id -eq 's16-b04')[0].position=[pscustomobject]@{x=$p.x;y=$p.y;z=($p.z-1)} + } + $before=$script:state|ConvertTo-Json -Depth 20|ConvertFrom-Json + $blocked=!(BallClear (Board $before $stage) 's16-b05') + $errorText=$null;try{CountRemaining $stage|Out-Null}catch{$errorText=$_.Exception.Message} + $taps=@($script:steps|Where-Object {$_.arguments[2]-eq'tap'}) + $pitch=@($script:steps|Where-Object {$_.arguments[2]-eq'swipe'-and$_.arguments[3]-eq$_.arguments[5]}) + if($kind-in@('captured-past-pole','near-upper-limit','low-view-occluded')){ + $direction=if($kind-eq'near-upper-limit'){-120}else{120} + $correctPitch=$pitch.Count-gt0-and$pitch[0].name-match'count-safe-view'-and([int]$pitch[0].arguments[6]-[int]$pitch[0].arguments[4])-eq$direction + Record 'blocked-center-uses-lower-view-before-real-ID-tap-request' ($blocked-and$correctPitch-and$taps.Count-gt0-and!$script:invalidTap) @{kind=$kind;pitchCommands=$pitch;tapRequests=$taps.Count;scope='Offline projection and gesture requests only; scenery first hit and native count not evaluated'} + $unchanged=$script:state|ConvertTo-Json -Depth 20|ConvertFrom-Json;(Board $unchanged $stage).camera=(Board $before $stage).camera + Record 'forty-bound-no-fake-count-or-completion' ($errorText-match'Native counting bound reached'-and$script:stateReads-eq40-and(Same $before $unchanged)) @{kind=$kind;stateReads=$script:stateReads;gestureRequests=$script:steps.Count;error=$errorText} + Record 'lower-pitch-gesture-keeps-ui-safe-endpoints' ($correctPitch-and(WorldSafe @([int]$pitch[0].arguments[3],[int]$pitch[0].arguments[4]))-and(WorldSafe @([int]$pitch[0].arguments[5],[int]$pitch[0].arguments[6]))) $kind + }else{Record 'manual-view-search-rejects-save-mutation-before-tap' ($errorText-match'count-view-preserves-whole-save'-and$taps.Count-eq0) @{kind=$kind;error=$errorText}} + } +}catch{Record 'production-evaluation' $false $_.Exception.Message} +$passed=$checks.Count-gt0-and@($checks|Where-Object passed -ne $true).Count-eq0 +@{passed=$passed;scope='Captured state replay with production function body identity and leaf gesture mocks. No ADB, native first hit or completed-stage claim.';fixture=$Fixture;fixtureSha256=(Get-FileHash $Fixture).Hash;progressionSource=$ProgressionSource;progressionSha256=(Get-FileHash $ProgressionSource).Hash;commonSha256=(Get-FileHash $common).Hash;checks=$checks.ToArray()}|ConvertTo-Json -Depth 14|Set-Content (Join-Path $EvidenceDir 'count-view-result.json') +if(!$passed){exit 1};exit 0