Fix backup recovery when primary progress save is missing

This commit is contained in:
NINI
2026-10-05 12:17:34 +09:00
parent 503c56a19e
commit babaee3608
4 changed files with 72 additions and 2 deletions
@@ -87,6 +87,51 @@ namespace MagnetismHeroes.Tests
Assert.That(store.TryLoad(out var restored, out error), Is.True, error); Assert.That(store.TryLoad(out var restored, out error), Is.True, error);
Assert.That(restored.puzzleStates.Single().stageId, Is.EqualTo(1)); Assert.That(restored.puzzleStates.Single().stageId, Is.EqualTo(1));
} }
[Test] public void MissingPrimaryRestoresEntireValidBackup()
{
var definitions = new[] { _stage,
new StageDefinition(2, StageKind.Counting, new[] { 1, 1 }, new[] { 2 }),
new StageDefinition(6, StageKind.Composition, new[] { 1, 1 }, new[] { 2 }) };
var session = new GameSession(definitions, new LocalProgressStore(_path, definitions));
session.StartStage(1); session.MoveGroup(session.Board.Groups[0].id, new Position3(1, 2, 3));
session.Count(session.Board.Balls[0].id); session.MarkTutorialSeen("merge");
session.StartStage(2); session.MoveAndMerge(session.Board.Groups[0].id, new Position3(2, 1, -3), session.Board.Groups[1].id);
session.Count(session.Board.Balls[0].id);
session.CommitCamera(new CameraState { target = new Position3(1, 2, 3), rotation = new Rotation4(0, .6f, 0, .8f), distance = 11 });
var store = new LocalProgressStore(_path, definitions);
Assert.That(store.TryLoad(out var expected, out var error), Is.True, error);
var six = new BoardRules(definitions[2]); six.TryMoveGroup(six.Groups[0].id, new Position3(-1, 2, 4));
expected.puzzleStates = expected.puzzleStates.Concat(new[] { six.ToPuzzleState(GameSession.DefaultCamera()) }).ToArray();
Assert.That(store.TrySave(expected, out error), Is.True, error);
var expectedJson = File.ReadAllText(_path);
Assert.That(store.TrySave(expected, out error), Is.True, error); File.Delete(_path);
Assert.That(store.TryLoad(out var loaded, out error), Is.True, error);
Assert.That(error, Does.Contain("Recovered valid backup"));
Assert.That(loaded.puzzleStates.Length, Is.EqualTo(2));
Assert.That(loaded.puzzleStates.Single(state => state.stageId == 2).countedBallIds.Length, Is.EqualTo(1));
Assert.That(store.TrySave(loaded, out error), Is.True, error);
Assert.That(File.ReadAllText(_path), Is.EqualTo(expectedJson), "Every contract field, including free play and tutorial progress, must survive.");
}
[Test] public void MissingPrimaryWithInvalidBackupPreservesEvidenceThroughTwoSaves()
{
File.WriteAllText(_path + ".bak", "distinct-backup-only-damage");
var store = Store(); Assert.That(store.TryLoad(out var loaded, out var error), Is.False);
Assert.That(loaded, Is.Null); Assert.That(error, Does.Contain("Invalid primary and backup"));
Assert.That(File.Exists(_path), Is.False);
Assert.That(File.ReadAllText(_path + ".bak"), Is.EqualTo("distinct-backup-only-damage"));
Assert.That(store.TrySave(new ProgressSave(), out error), Is.True, error);
Assert.That(store.TrySave(Puzzle(), out error), Is.True, error);
Assert.That(Directory.GetFiles(_directory, "*.bak.damaged-*").Select(File.ReadAllText), Does.Contain("distinct-backup-only-damage"));
Assert.That(store.TryLoad(out loaded, out error), Is.True, error);
Assert.That(loaded.puzzleStates.Single().stageId, Is.EqualTo(1));
}
[Test] public void MissingPrimaryAndBackupStartsFreshWithoutRecoveryError()
{
Assert.That(Store().TryLoad(out var loaded, out var error), Is.True, error);
Assert.That(error, Is.Null); Assert.That(loaded.puzzleStates, Is.Empty);
Assert.That(loaded.completedStageIds, Is.Empty); Assert.That(loaded.seenTutorialIds, Is.Empty);
Assert.That(loaded.latestFreePlay, Is.Null); Assert.That(Directory.GetFiles(_directory), Is.Empty);
}
[Test] public void InvalidGroupMembershipDoesNotReplaceAValidSave() [Test] public void InvalidGroupMembershipDoesNotReplaceAValidSave()
{ {
var store = Store(); var progress = Puzzle(); Assert.That(store.TrySave(progress, out _), Is.True); var store = Store(); var progress = Puzzle(); Assert.That(store.TrySave(progress, out _), Is.True);
@@ -27,7 +27,7 @@ namespace MagnetismHeroes.UnityRuntime.Persistence
public bool TryLoad(out ProgressSave save, out string error) public bool TryLoad(out ProgressSave save, out string error)
{ {
save = null; error = null; save = null; error = null;
if (!File.Exists(_path)) { save = new ProgressSave(); return true; } if (!File.Exists(_path) && !File.Exists(_path + ".bak")) { save = new ProgressSave(); return true; }
if (Read(_path, out save, out error)) return true; if (Read(_path, out save, out error)) return true;
var originalError = error; var originalError = error;
if (Read(_path + ".bak", out save, out var backupError)) if (Read(_path + ".bak", out save, out var backupError))
+1 -1
View File
@@ -8,7 +8,7 @@ JSON v1은 앱 전용 `progress.json`에 확정 상태만 기록한다. 각 단
확정 이동·합체·분리·계수·공급·반납, 카메라 조작 종료, 화면 이동, 일시정지에서 저장한다. 미확정 드래그와 선택·접촉 표시를 저장하지 않는다. 저장 실패는 현재 화면에 짧은 한국어 안내를 보여주고 실제 파일 오류를 개발 로그에 남긴다. 다음 저장 성공 시 안내를 비운다. 복원은 Home에서 시작하며 이전 계수나 성공 음성을 반복하지 않는다. 같은 보드의 자유놀이 조작 중에는 마지막 숫자 대기열을 보존하고 화면 이동·일시정지는 음성을 정리한다. 확정 이동·합체·분리·계수·공급·반납, 카메라 조작 종료, 화면 이동, 일시정지에서 저장한다. 미확정 드래그와 선택·접촉 표시를 저장하지 않는다. 저장 실패는 현재 화면에 짧은 한국어 안내를 보여주고 실제 파일 오류를 개발 로그에 남긴다. 다음 저장 성공 시 안내를 비운다. 복원은 Home에서 시작하며 이전 계수나 성공 음성을 반복하지 않는다. 같은 보드의 자유놀이 조작 중에는 마지막 숫자 대기열을 보존하고 화면 이동·일시정지는 음성을 정리한다.
임시 파일을 디스크에 flush한 뒤 primary를 교체한다. 유효 primary를 backup으로 보존하며 불완전 `.tmp`는 읽지 않는다. 손상 primary는 유효 backup 전체로 복구한다. 다음 저장 전에 손상 원본을 `.damaged-*`로 복사하며, 손상 primary를 유효 backup 위에 덮어쓰지 않는다. 둘 다 손상되면 정상 초기 상태와 안내를 표시하고 primary·backup의 서로 다른 원본을 각각 보관해 이후 정상 저장에서도 증거를 보존한다. 임시 파일을 디스크에 flush한 뒤 primary를 교체한다. 유효 primary를 backup으로 보존하며 불완전 `.tmp`는 읽지 않는다. 손상되었거나 없는 primary는 유효 backup 전체로 복구한다. primary·backup이 모두 없을 때만 새 진행을 시작한다. primary 없이 손상 backup만 남아 있으면 읽기 실패를 기록하고 정상 초기 상태로 진입하며 두 번의 후속 저장에서도 backup 원문을 보관한다. 다음 저장 전에 손상 원본을 `.damaged-*`로 복사하며, 손상 primary를 유효 backup 위에 덮어쓰지 않는다. 둘 다 손상되면 정상 초기 상태와 안내를 표시하고 primary·backup의 서로 다른 원본을 각각 보관해 이후 정상 저장에서도 증거를 보존한다.
검증 명령: 검증 명령:
+25
View File
@@ -146,6 +146,19 @@ try {
Adb @('logcat','-d','-s','Unity','AndroidRuntime') | Set-Content -LiteralPath (Join-Path $EvidenceDir 'android-runtime.log') -Encoding utf8 Adb @('logcat','-d','-s','Unity','AndroidRuntime') | Set-Content -LiteralPath (Join-Path $EvidenceDir 'android-runtime.log') -Encoding utf8
$runtime=Get-Content -LiteralPath (Join-Path $EvidenceDir 'android-runtime.log') -Raw $runtime=Get-Content -LiteralPath (Join-Path $EvidenceDir 'android-runtime.log') -Raw
Check 'actual-write-errors-logged' ($runtime -match '\[ProgressSave\].*(denied|directory|UnauthorizedAccess|Access)') 'Actual filesystem error in developer log' Check 'actual-write-errors-logged' ($runtime -match '\[ProgressSave\].*(denied|directory|UnauthorizedAccess|Access)') 'Actual filesystem error in developer log'
# Missing primary must restore the whole backup, including both unfinished boards and latest Free.
Adb @('shell','am','force-stop',$package) | Out-Null
$missingExpected=Save 'missing-primary-valid-backup-before'
Check 'missing-primary-fixture-full-state' ($missingExpected.puzzleStates.Count -eq 2 -and $null -ne $missingExpected.latestFreePlay -and @($missingExpected.puzzleStates | Where-Object stageId -eq 2)[0].countedBallIds.Count -eq 1) 'Actual two unfinished boards, positive partial count and latest Free retained'
Adb @('push',(Join-Path $EvidenceDir 'missing-primary-valid-backup-before.json'),($remote+'.bak')) | Out-Null
Adb @('shell','rm',$remote) | Out-Null
Step 'missing-primary-valid-backup-home-notice' @('shell','am','start','-W','-n',$activity) 6500
$missingLoadLog=Adb @('logcat','-d','-s','Unity','AndroidRuntime')
$missingLoadLog | Set-Content -LiteralPath (Join-Path $EvidenceDir 'missing-primary-valid-load.log') -Encoding utf8
Check 'missing-primary-valid-recovery-logged' ($missingLoadLog -match '\[ProgressLoad\] Recovered valid backup:') 'Backup-only recovery reaches existing recovery notice branch'
Step 'missing-primary-valid-backup-free' @('shell','input','tap','1200','810')
Check 'missing-primary-valid-backup-whole-restore' (Same $missingExpected (Save 'missing-primary-valid-backup-after')) 'Every saved field restores from backup alone'
Step 'missing-primary-valid-backup-home' @('shell','input','tap','312','108')
SelectStage 'two-final-count' 2; $secondCount=Save 'two-before-final-count'; $secondBoard=@($secondCount.puzzleStates | Where-Object stageId -eq 2)[0] SelectStage 'two-final-count' 2; $secondCount=Save 'two-before-final-count'; $secondBoard=@($secondCount.puzzleStates | Where-Object stageId -eq 2)[0]
$uncounted=@($secondBoard.balls | Where-Object {$secondBoard.countedBallIds -notcontains $_.id})[0] $uncounted=@($secondBoard.balls | Where-Object {$secondBoard.countedBallIds -notcontains $_.id})[0]
$countAudio=RecordAudio 'audio-stage-two-final-count' 8 $countAudio=RecordAudio 'audio-stage-two-final-count' 8
@@ -177,6 +190,18 @@ try {
Check 'both-damage-forensic-preserved' ((Adb @('shell','cat',($remote+'.damaged-*'))) -match 'damaged-both-primary' -and (Adb @('shell','cat',($remote+'.bak'))) -eq 'damaged-both-backup') 'Primary forensic and damaged backup retained' Check 'both-damage-forensic-preserved' ((Adb @('shell','cat',($remote+'.damaged-*'))) -match 'damaged-both-primary' -and (Adb @('shell','cat',($remote+'.bak'))) -eq 'damaged-both-backup') 'Primary forensic and damaged backup retained'
Step 'both-damage-second-normal-save' @('shell','input','tap','1200','562'); $secondInitial=Save 'both-damaged-second-initial' Step 'both-damage-second-normal-save' @('shell','input','tap','1200','562'); $secondInitial=Save 'both-damaged-second-initial'
Check 'both-damage-two-normal-saves-forensic-preserved' ((Adb @('shell','cat',($remote+'.damaged-*'))) -match 'damaged-both-primary' -and (Adb @('shell','cat',($remote+'.bak.damaged-*'))) -match 'damaged-both-backup' -and $secondInitial.puzzleStates.Count -eq 1) 'Both distinct damaged byte strings survive two successful normal writes' Check 'both-damage-two-normal-saves-forensic-preserved' ((Adb @('shell','cat',($remote+'.damaged-*'))) -match 'damaged-both-primary' -and (Adb @('shell','cat',($remote+'.bak.damaged-*'))) -match 'damaged-both-backup' -and $secondInitial.puzzleStates.Count -eq 1) 'Both distinct damaged byte strings survive two successful normal writes'
Adb @('shell','am','force-stop',$package) | Out-Null
[IO.File]::WriteAllText($badBackup,'damaged-backup-only'); Adb @('push',$badBackup,($remote+'.bak')) | Out-Null
Adb @('shell','rm',$remote) | Out-Null; Adb @('logcat','-c') | Out-Null
Step 'missing-primary-invalid-backup-home-notice' @('shell','am','start','-W','-n',$activity) 6500
Check 'missing-primary-invalid-original-preserved-on-load' ((Adb @('shell','cat',($remote+'.bak'))) -eq 'damaged-backup-only' -and (Adb @('shell','ls',($remote+'*'))) -notmatch '(?m)/progress\.json$') 'Invalid backup bytes remain and no primary is created by failed load'
$invalidLoadLog=Adb @('logcat','-d','-s','Unity','AndroidRuntime')
$invalidLoadLog | Set-Content -LiteralPath (Join-Path $EvidenceDir 'missing-primary-invalid-load.log') -Encoding utf8
Check 'missing-primary-invalid-read-failure-logged' ($invalidLoadLog -match '\[ProgressLoad\] Invalid primary and backup:') 'Backup-only damage is recorded as read failure'
Step 'missing-primary-invalid-new-selection' @('shell','input','tap','1200','616'); $missingInitial=Save 'missing-primary-invalid-first-save'
Check 'missing-primary-invalid-normal-initial' ($missingInitial.completedStageIds.Count -eq 0 -and $missingInitial.puzzleStates.Count -eq 0 -and $missingInitial.seenTutorialIds.Count -eq 0 -and $null -eq $missingInitial.latestFreePlay) 'Invalid backup alone enters normal fresh progress'
Step 'missing-primary-invalid-second-save' @('shell','input','tap','1200','562'); $missingSecond=Save 'missing-primary-invalid-second-save'
Check 'missing-primary-invalid-two-saves-forensic-preserved' ((Adb @('shell','cat',($remote+'.bak.damaged-*'))) -match 'damaged-backup-only' -and $missingSecond.puzzleStates.Count -eq 1) 'Distinct backup-only damage survives two successful saves'
$result.sourceCommitEnd=(& git rev-parse HEAD); $result.sourceDirtyEnd=[bool](& git status --porcelain) $result.sourceCommitEnd=(& git rev-parse HEAD); $result.sourceDirtyEnd=[bool](& git status --porcelain)
Check 'clean-source-end' (!$result.sourceDirtyEnd -and $result.sourceCommitEnd -eq $result.sourceCommit) $result.sourceCommitEnd Check 'clean-source-end' (!$result.sourceDirtyEnd -and $result.sourceCommitEnd -eq $result.sourceCommit) $result.sourceCommitEnd
$result.status='passed' $result.status='passed'