From 00323d79160ff576a11ce8dfc50c06f3be0f8808 Mon Sep 17 00:00:00 2001 From: "E.Gavrilov" Date: Tue, 18 Aug 2026 16:20:56 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D1=82?= =?UTF-8?q?=D1=8C=20=D0=BE=D0=B1=D1=83=D1=87=D0=B5=D0=BD=D0=B8=D0=B5=20?= =?UTF-8?q?=D0=B8=20=D0=BF=D0=BE=D0=BB=D0=BD=D1=8B=D0=B9=20=D1=8D=D0=BA?= =?UTF-8?q?=D1=80=D0=B0=D0=BD=20=D1=80=D0=B5=D0=B7=D1=83=D0=BB=D1=8C=D1=82?= =?UTF-8?q?=D0=B0=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../game/OnboardingAndResultFlowTest.java | 86 +++++++ .../game/OnboardingStoreTest.java | 28 +++ .../game/CompletionResult.java | 67 ++++++ .../com/projectsacrifice/game/GameView.java | 216 ++++++++++++++++-- .../projectsacrifice/game/OnboardingFlow.java | 45 ++++ .../game/OnboardingStore.java | 33 +++ .../game/CompletionResultTest.java | 48 ++++ .../game/OnboardingFlowTest.java | 38 +++ docs/STAGE3_ONBOARDING_RESULT_QA.md | 57 +++++ 9 files changed, 604 insertions(+), 14 deletions(-) create mode 100644 app/src/androidTest/java/com/projectsacrifice/game/OnboardingAndResultFlowTest.java create mode 100644 app/src/androidTest/java/com/projectsacrifice/game/OnboardingStoreTest.java create mode 100644 app/src/main/java/com/projectsacrifice/game/CompletionResult.java create mode 100644 app/src/main/java/com/projectsacrifice/game/OnboardingFlow.java create mode 100644 app/src/main/java/com/projectsacrifice/game/OnboardingStore.java create mode 100644 app/src/test/java/com/projectsacrifice/game/CompletionResultTest.java create mode 100644 app/src/test/java/com/projectsacrifice/game/OnboardingFlowTest.java create mode 100644 docs/STAGE3_ONBOARDING_RESULT_QA.md diff --git a/app/src/androidTest/java/com/projectsacrifice/game/OnboardingAndResultFlowTest.java b/app/src/androidTest/java/com/projectsacrifice/game/OnboardingAndResultFlowTest.java new file mode 100644 index 0000000..3ab7a67 --- /dev/null +++ b/app/src/androidTest/java/com/projectsacrifice/game/OnboardingAndResultFlowTest.java @@ -0,0 +1,86 @@ +package com.projectsacrifice.game; + +import androidx.test.core.app.ActivityScenario; +import androidx.test.ext.junit.runners.AndroidJUnit4; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +@RunWith(AndroidJUnit4.class) +public final class OnboardingAndResultFlowTest { + @Test + public void tutorialIsOptionalNonBlockingAndDoesNotRepeatAfterRecreation() { + try (ActivityScenario activity = ActivityScenario.launch(MainActivity.class)) { + activity.onActivity(owner -> { + GameView view = owner.gameViewForAutomation(); + view.resetOnboardingForAutomation(); + view.startLevelForAutomation(1); + assertTrue(view.onboardingVisibleForAutomation()); + assertEquals(OnboardingFlow.Step.MOVEMENT, + view.onboardingStepForAutomation()); + + view.setAutomationInput(false, true, false); + assertTrue("Tutorial must not disable gameplay controls", + view.automationSnapshot().inputActive()); + + view.advanceOnboardingForAutomation(); + view.advanceOnboardingForAutomation(); + view.advanceOnboardingForAutomation(); + assertFalse(view.onboardingVisibleForAutomation()); + }); + + activity.recreate(); + activity.onActivity(owner -> { + GameView view = owner.gameViewForAutomation(); + view.startLevelForAutomation(1); + assertFalse(view.onboardingVisibleForAutomation()); + }); + } + } + + @Test + public void completionShowsRunAndSavedBestAndAllResultRoutesPreserveState() { + try (ActivityScenario activity = ActivityScenario.launch(MainActivity.class)) { + activity.onActivity(owner -> { + GameView view = owner.gameViewForAutomation(); + view.selectNextControlSizeForAutomation(); + String selectedSize = view.controlSettingsForAutomation().controlSizeLabel(); + + view.startLevelForAutomation(1); + view.finishLevelForAutomation(2, 42_000L); + CompletionResult result = view.completionResultForAutomation(); + assertNotNull(result); + assertTrue(view.resultVisibleForAutomation()); + assertEquals(2, result.currentDeaths()); + assertEquals(42_000L, result.currentTimeMs()); + assertTrue(result.bestDeaths() <= result.currentDeaths()); + assertTrue(result.bestTimeMs() <= result.currentTimeMs()); + assertTrue(view.levelCompletedForAutomation(1)); + + view.replayFromResultForAutomation(); + assertEquals(1, view.automationSnapshot().levelNumber()); + assertFalse(view.automationSnapshot().completed()); + assertEquals(selectedSize, view.controlSettingsForAutomation().controlSizeLabel()); + assertTrue(view.levelCompletedForAutomation(1)); + + view.finishLevelForAutomation(1, 35_000L); + view.nextLevelFromResultForAutomation(); + assertEquals(2, view.automationSnapshot().levelNumber()); + assertEquals(selectedSize, view.controlSettingsForAutomation().controlSizeLabel()); + assertTrue(view.levelCompletedForAutomation(1)); + + view.finishLevelForAutomation(1, 35_000L); + view.levelsFromResultForAutomation(); + assertTrue(view.levelsVisibleForAutomation()); + assertEquals(selectedSize, view.controlSettingsForAutomation().controlSizeLabel()); + assertTrue(view.levelCompletedForAutomation(2)); + view.resetControlSettingsForAutomation(); + }); + } + } +} diff --git a/app/src/androidTest/java/com/projectsacrifice/game/OnboardingStoreTest.java b/app/src/androidTest/java/com/projectsacrifice/game/OnboardingStoreTest.java new file mode 100644 index 0000000..9950b1a --- /dev/null +++ b/app/src/androidTest/java/com/projectsacrifice/game/OnboardingStoreTest.java @@ -0,0 +1,28 @@ +package com.projectsacrifice.game; + +import android.content.Context; + +import androidx.test.platform.app.InstrumentationRegistry; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import androidx.test.ext.junit.runners.AndroidJUnit4; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +@RunWith(AndroidJUnit4.class) +public final class OnboardingStoreTest { + @Test + public void dismissalSurvivesStoreRecreation() { + Context context = InstrumentationRegistry.getInstrumentation().getTargetContext(); + OnboardingStore first = new OnboardingStore(context); + first.resetForAutomation(); + assertFalse(first.isComplete()); + + first.markComplete(); + + assertTrue(new OnboardingStore(context).isComplete()); + } +} diff --git a/app/src/main/java/com/projectsacrifice/game/CompletionResult.java b/app/src/main/java/com/projectsacrifice/game/CompletionResult.java new file mode 100644 index 0000000..0fe4063 --- /dev/null +++ b/app/src/main/java/com/projectsacrifice/game/CompletionResult.java @@ -0,0 +1,67 @@ +package com.projectsacrifice.game; + +/** Immutable completion statistics used by rendering and device-level navigation tests. */ +public final class CompletionResult { + public enum NextGoal { + TARGET_DEATHS, + TARGET_TIME, + NEXT_LEVEL, + PERSONAL_BEST + } + + private final int currentDeaths; + private final long currentTimeMs; + private final int bestDeaths; + private final long bestTimeMs; + private final int targetDeaths; + private final long targetTimeMs; + private final NextGoal nextGoal; + private final long nextGoalValue; + + private CompletionResult(int currentDeaths, long currentTimeMs, + int bestDeaths, long bestTimeMs, + int targetDeaths, long targetTimeMs, + NextGoal nextGoal, long nextGoalValue) { + this.currentDeaths = currentDeaths; + this.currentTimeMs = currentTimeMs; + this.bestDeaths = bestDeaths; + this.bestTimeMs = bestTimeMs; + this.targetDeaths = targetDeaths; + this.targetTimeMs = targetTimeMs; + this.nextGoal = nextGoal; + this.nextGoalValue = nextGoalValue; + } + + public static CompletionResult create(LevelDefinition level, + int currentDeaths, long currentTimeMs, + int bestDeaths, long bestTimeMs, + boolean hasNextLevel) { + long targetTimeMs = level.targetSeconds * 1_000L; + NextGoal goal; + long goalValue; + if (currentDeaths > level.targetDeaths) { + goal = NextGoal.TARGET_DEATHS; + goalValue = level.targetDeaths; + } else if (currentTimeMs > targetTimeMs) { + goal = NextGoal.TARGET_TIME; + goalValue = targetTimeMs; + } else if (hasNextLevel) { + goal = NextGoal.NEXT_LEVEL; + goalValue = level.number + 1L; + } else { + goal = NextGoal.PERSONAL_BEST; + goalValue = 0L; + } + return new CompletionResult(currentDeaths, currentTimeMs, bestDeaths, bestTimeMs, + level.targetDeaths, targetTimeMs, goal, goalValue); + } + + public int currentDeaths() { return currentDeaths; } + public long currentTimeMs() { return currentTimeMs; } + public int bestDeaths() { return bestDeaths; } + public long bestTimeMs() { return bestTimeMs; } + public int targetDeaths() { return targetDeaths; } + public long targetTimeMs() { return targetTimeMs; } + public NextGoal nextGoal() { return nextGoal; } + public long nextGoalValue() { return nextGoalValue; } +} diff --git a/app/src/main/java/com/projectsacrifice/game/GameView.java b/app/src/main/java/com/projectsacrifice/game/GameView.java index 610967b..c61a37a 100644 --- a/app/src/main/java/com/projectsacrifice/game/GameView.java +++ b/app/src/main/java/com/projectsacrifice/game/GameView.java @@ -161,6 +161,7 @@ public final class GameView extends View implements Runnable { 0, 0, 0, VIEW_HEIGHT, Color.rgb(20, 29, 55), Color.rgb(110, 81, 91), Shader.TileMode.CLAMP); private final ProgressStore progress; private final ControlSettingsStore controlSettingsStore; + private final OnboardingStore onboardingStore; private final SparseArray pointers = new SparseArray<>(); private final Player player = new Player(); private final ArrayList corpses = new ArrayList<>(); @@ -187,12 +188,16 @@ public final class GameView extends View implements Runnable { private float canvasOffsetX; private float canvasOffsetY; private ControlSettings controlSettings; + private OnboardingFlow onboardingFlow; + private CompletionResult completionResult; public GameView(Context context) { super(context); progress = new ProgressStore(context); controlSettingsStore = new ControlSettingsStore(context); controlSettings = controlSettingsStore.load(); + onboardingStore = new OnboardingStore(context); + onboardingFlow = OnboardingFlow.firstRun(onboardingStore.isComplete()); paint.setTypeface(android.graphics.Typeface.create("sans", android.graphics.Typeface.NORMAL)); stroke.setStyle(Paint.Style.STROKE); stroke.setStrokeCap(Paint.Cap.ROUND); @@ -236,7 +241,7 @@ public final class GameView extends View implements Runnable { return true; } if (screen == Screen.COMPLETE) { - screen = Screen.LEVELS; + levelsFromResult(); return true; } if (userPaused) { @@ -265,6 +270,7 @@ public final class GameView extends View implements Runnable { toggleSignals.clear(); timedSignals.clear(); previousSwitchContact.clear(); + completionResult = null; for (LevelDefinition.Platform item : level.platforms) platformStates.add(new PlatformState(item)); spawnPlayer(); lastFrameNanos = System.nanoTime(); @@ -622,7 +628,22 @@ public final class GameView extends View implements Runnable { private void checkGoal() { if (!intersects(player.x, player.y, PLAYER_WIDTH, PLAYER_HEIGHT, level.goal)) return; - progress.recordCompletion(level.number, deaths, (long) (levelElapsed * 1000f)); + finishLevel(deaths, (long) (levelElapsed * 1000f)); + } + + private void finishLevel(int completedDeaths, long completedTimeMs) { + long safeTimeMs = Math.max(0L, completedTimeMs); + deaths = Math.max(0, completedDeaths); + levelElapsed = safeTimeMs / 1_000f; + progress.recordCompletion(level.number, deaths, safeTimeMs); + completionResult = CompletionResult.create( + level, + deaths, + safeTimeMs, + progress.bestDeaths(level.number), + progress.bestTimeMs(level.number), + levelIndex + 1 < LevelCatalog.all().size() + ); player.vx = player.vy = 0f; screen = Screen.COMPLETE; pointers.clear(); @@ -664,6 +685,7 @@ public final class GameView extends View implements Runnable { drawGame(canvas); if (screen == Screen.COMPLETE) drawComplete(canvas); else if (userPaused) drawPause(canvas); + else if (onboardingFlow.isVisible()) drawOnboarding(canvas); } canvas.restore(); } @@ -1017,22 +1039,84 @@ public final class GameView extends View implements Runnable { private void drawComplete(Canvas canvas) { paint.setColor(Color.argb(218, 8, 12, 23)); canvas.drawRect(0, 0, VIEW_WIDTH, VIEW_HEIGHT, paint); - drawCenteredText(canvas, "RELIC CLAIMED", VIEW_WIDTH * 0.5f, 270, 64, Color.rgb(250, 211, 111)); - drawCenteredText(canvas, level.title, VIEW_WIDTH * 0.5f, 335, 34, Color.WHITE); + drawCenteredText(canvas, "RELIC CLAIMED", VIEW_WIDTH * 0.5f, 205, 64, Color.rgb(250, 211, 111)); + drawCenteredText(canvas, level.title, VIEW_WIDTH * 0.5f, 263, 34, Color.WHITE); - boolean deathStar = deaths <= level.targetDeaths; - boolean timeStar = levelElapsed <= level.targetSeconds; - drawCenteredText(canvas, "Completion ★", VIEW_WIDTH * 0.5f, 470, 38, Color.rgb(250, 211, 111)); - drawCenteredText(canvas, (deathStar ? "★" : "☆") + " " + deaths + " / " + level.targetDeaths + " volunteers", - VIEW_WIDTH * 0.5f, 535, 31, deathStar ? Color.rgb(128, 229, 153) : Color.rgb(210, 211, 218)); - drawCenteredText(canvas, (timeStar ? "★" : "☆") + " " + GameMath.formatTime((long) (levelElapsed * 1000)) + " / " + GameMath.formatTime(level.targetSeconds * 1000L), - VIEW_WIDTH * 0.5f, 588, 31, timeStar ? Color.rgb(128, 229, 153) : Color.rgb(210, 211, 218)); + CompletionResult result = completionResult; + if (result != null) { + drawCenteredText(canvas, + "This run · " + result.currentDeaths() + " volunteers · " + + GameMath.formatTime(result.currentTimeMs()), + VIEW_WIDTH * 0.5f, 380, 34, Color.WHITE); + drawCenteredText(canvas, + "Saved best · " + result.bestDeaths() + " volunteers · " + + GameMath.formatTime(result.bestTimeMs()), + VIEW_WIDTH * 0.5f, 437, 31, Color.rgb(128, 229, 153)); + drawCenteredText(canvas, + "Targets · " + result.targetDeaths() + " volunteers · " + + GameMath.formatTime(result.targetTimeMs()), + VIEW_WIDTH * 0.5f, 510, 28, Color.rgb(210, 211, 218)); + drawCenteredText(canvas, nextGoalText(result), VIEW_WIDTH * 0.5f, 585, 31, + Color.rgb(250, 211, 111)); + } drawHudButton(canvas, new RectF(345, 735, 755, 850), "Level select", false); drawHudButton(canvas, new RectF(775, 735, 1145, 850), "Replay", false); if (levelIndex + 1 < LevelCatalog.all().size()) drawHudButton(canvas, new RectF(1165, 735, 1575, 850), "Next level", true); } + private String nextGoalText(CompletionResult result) { + switch (result.nextGoal()) { + case TARGET_DEATHS: + return "Next goal · finish with " + result.nextGoalValue() + " volunteers"; + case TARGET_TIME: + return "Next goal · finish in " + GameMath.formatTime(result.nextGoalValue()); + case NEXT_LEVEL: + return "Next goal · enter level " + result.nextGoalValue(); + case PERSONAL_BEST: + default: + return "Next goal · improve your personal best"; + } + } + + private void drawOnboarding(Canvas canvas) { + RectF card = new RectF(535, 175, 1385, 375); + paint.setColor(Color.argb(232, 16, 22, 37)); + canvas.drawRoundRect(card, 24, 24, paint); + stroke.setColor(Color.rgb(226, 169, 63)); + stroke.setStrokeWidth(4f); + canvas.drawRoundRect(card, 24, 24, stroke); + + String title; + String message; + String progressLabel; + switch (onboardingFlow.step()) { + case MOVEMENT: + title = "MOVE"; + message = "Hold the left or right arrow to explore."; + progressLabel = "1 / 3"; + break; + case MULTITOUCH_JUMP: + title = "MOVE + JUMP"; + message = "Use two fingers: hold a direction and tap Jump."; + progressLabel = "2 / 3"; + break; + case CORPSES_ARE_TOOLS: + default: + title = "DEATH LEAVES A TOOL"; + message = "A fallen volunteer remains. Use the body to open a path."; + progressLabel = "3 / 3"; + break; + } + drawText(canvas, title, 575, 230, 30, Color.rgb(250, 211, 111), true); + drawText(canvas, message, 575, 278, 27, Color.WHITE, false); + drawText(canvas, progressLabel, 575, 337, 22, Color.rgb(180, 185, 198), false); + drawHudButton(canvas, new RectF(965, 298, 1125, 355), "Skip", false); + drawHudButton(canvas, new RectF(1145, 298, 1345, 355), + onboardingFlow.step() == OnboardingFlow.Step.CORPSES_ARE_TOOLS ? "Done" : "Next", + true); + } + private void drawPause(Canvas canvas) { paint.setColor(Color.argb(218, 8, 12, 23)); canvas.drawRect(0, 0, VIEW_WIDTH, VIEW_HEIGHT, paint); @@ -1112,6 +1196,14 @@ public final class GameView extends View implements Runnable { return true; } + if (onboardingFlow.isVisible() && action == MotionEvent.ACTION_UP + && handleOnboardingTap(point.x, point.y)) { + performClick(); + pointers.clear(); + deriveControls(); + return true; + } + if (action == MotionEvent.ACTION_DOWN || action == MotionEvent.ACTION_POINTER_DOWN) { if (contains(1608, 26, 1770, 112, point.x, point.y)) { restartLevel(); @@ -1188,9 +1280,46 @@ public final class GameView extends View implements Runnable { } private void handleCompleteTap(float x, float y) { - if (contains(345, 735, 755, 850, x, y)) screen = Screen.LEVELS; - else if (contains(775, 735, 1145, 850, x, y)) restartLevel(); - else if (contains(1165, 735, 1575, 850, x, y) && levelIndex + 1 < LevelCatalog.all().size()) startLevel(levelIndex + 1); + if (contains(345, 735, 755, 850, x, y)) levelsFromResult(); + else if (contains(775, 735, 1145, 850, x, y)) replayFromResult(); + else if (contains(1165, 735, 1575, 850, x, y)) nextLevelFromResult(); + } + + private boolean handleOnboardingTap(float x, float y) { + if (contains(965, 298, 1125, 355, x, y)) { + onboardingFlow.skip(); + onboardingStore.markComplete(); + invalidate(); + return true; + } + if (contains(1145, 298, 1345, 355, x, y)) { + advanceOnboarding(); + return true; + } + return false; + } + + private void advanceOnboarding() { + onboardingFlow.next(); + if (onboardingFlow.isComplete()) onboardingStore.markComplete(); + invalidate(); + } + + private void levelsFromResult() { + if (screen != Screen.COMPLETE) return; + screen = Screen.LEVELS; + pointers.clear(); + deriveControls(); + } + + private void replayFromResult() { + if (screen == Screen.COMPLETE) restartLevel(); + } + + private void nextLevelFromResult() { + if (screen == Screen.COMPLETE && levelIndex + 1 < LevelCatalog.all().size()) { + startLevel(levelIndex + 1); + } } private void handlePauseTap(float x, float y) { @@ -1254,6 +1383,34 @@ public final class GameView extends View implements Runnable { return screen == Screen.SETTINGS; } + public boolean levelsVisibleForAutomation() { + return screen == Screen.LEVELS; + } + + public boolean onboardingVisibleForAutomation() { + return screen == Screen.PLAYING && onboardingFlow.isVisible(); + } + + public OnboardingFlow.Step onboardingStepForAutomation() { + return onboardingFlow.step(); + } + + public void resetOnboardingForAutomation() { + onboardingStore.resetForAutomation(); + onboardingFlow = OnboardingFlow.firstRun(false); + invalidate(); + } + + public void advanceOnboardingForAutomation() { + advanceOnboarding(); + } + + public void skipOnboardingForAutomation() { + onboardingFlow.skip(); + onboardingStore.markComplete(); + invalidate(); + } + public void resetControlSettingsForAutomation() { updateControlSettings(ControlSettings.defaults()); } @@ -1270,6 +1427,37 @@ public final class GameView extends View implements Runnable { updateControlSettings(controlSettings.toggleHaptics()); } + public void finishLevelForAutomation(int completedDeaths, long completedTimeMs) { + if (screen != Screen.PLAYING || level == null) { + throw new IllegalStateException("A level must be playing before it can finish"); + } + finishLevel(completedDeaths, completedTimeMs); + } + + public CompletionResult completionResultForAutomation() { + return completionResult; + } + + public boolean resultVisibleForAutomation() { + return screen == Screen.COMPLETE; + } + + public boolean levelCompletedForAutomation(int oneBasedLevelNumber) { + return progress.isCompleted(oneBasedLevelNumber); + } + + public void levelsFromResultForAutomation() { + levelsFromResult(); + } + + public void replayFromResultForAutomation() { + replayFromResult(); + } + + public void nextLevelFromResultForAutomation() { + nextLevelFromResult(); + } + private void updateControlSettings(ControlSettings updated) { controlSettings = updated; controlSettingsStore.save(updated); diff --git a/app/src/main/java/com/projectsacrifice/game/OnboardingFlow.java b/app/src/main/java/com/projectsacrifice/game/OnboardingFlow.java new file mode 100644 index 0000000..b8cad49 --- /dev/null +++ b/app/src/main/java/com/projectsacrifice/game/OnboardingFlow.java @@ -0,0 +1,45 @@ +package com.projectsacrifice.game; + +/** Small state machine for the optional, first-run gameplay hints. */ +public final class OnboardingFlow { + public enum Step { + MOVEMENT, + MULTITOUCH_JUMP, + CORPSES_ARE_TOOLS + } + + private Step step; + private boolean complete; + + private OnboardingFlow(boolean complete) { + this.complete = complete; + step = Step.MOVEMENT; + } + + public static OnboardingFlow firstRun(boolean alreadyComplete) { + return new OnboardingFlow(alreadyComplete); + } + + public boolean isVisible() { + return !complete; + } + + public boolean isComplete() { + return complete; + } + + public Step step() { + return step; + } + + public void next() { + if (complete) return; + if (step == Step.MOVEMENT) step = Step.MULTITOUCH_JUMP; + else if (step == Step.MULTITOUCH_JUMP) step = Step.CORPSES_ARE_TOOLS; + else complete = true; + } + + public void skip() { + complete = true; + } +} diff --git a/app/src/main/java/com/projectsacrifice/game/OnboardingStore.java b/app/src/main/java/com/projectsacrifice/game/OnboardingStore.java new file mode 100644 index 0000000..acc109e --- /dev/null +++ b/app/src/main/java/com/projectsacrifice/game/OnboardingStore.java @@ -0,0 +1,33 @@ +package com.projectsacrifice.game; + +import android.content.Context; +import android.content.SharedPreferences; + +/** Versioned preference boundary for the optional first-run tutorial. */ +public final class OnboardingStore { + private static final String FILE = "project_sacrifice_onboarding_v1"; + private static final String KEY_COMPLETE = "complete"; + private static final String KEY_SCHEMA = "schema"; + private static final int SCHEMA_VERSION = 1; + + private final SharedPreferences preferences; + + public OnboardingStore(Context context) { + preferences = context.getSharedPreferences(FILE, Context.MODE_PRIVATE); + } + + public boolean isComplete() { + return preferences.getBoolean(KEY_COMPLETE, false); + } + + public void markComplete() { + preferences.edit() + .putInt(KEY_SCHEMA, SCHEMA_VERSION) + .putBoolean(KEY_COMPLETE, true) + .apply(); + } + + public void resetForAutomation() { + preferences.edit().clear().commit(); + } +} diff --git a/app/src/test/java/com/projectsacrifice/game/CompletionResultTest.java b/app/src/test/java/com/projectsacrifice/game/CompletionResultTest.java new file mode 100644 index 0000000..019f330 --- /dev/null +++ b/app/src/test/java/com/projectsacrifice/game/CompletionResultTest.java @@ -0,0 +1,48 @@ +package com.projectsacrifice.game; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; + +public final class CompletionResultTest { + @Test + public void resultKeepsThisRunAndSavedBestSeparateAndSuggestsTheMissingTarget() { + LevelDefinition level = LevelCatalog.get(0); + + CompletionResult result = CompletionResult.create( + level, + 2, + 42_000L, + 0, + 25_000L, + true + ); + + assertEquals(2, result.currentDeaths()); + assertEquals(42_000L, result.currentTimeMs()); + assertEquals(0, result.bestDeaths()); + assertEquals(25_000L, result.bestTimeMs()); + assertEquals(CompletionResult.NextGoal.TARGET_DEATHS, result.nextGoal()); + assertEquals(0, result.nextGoalValue()); + } + + @Test + public void masteredLevelPointsAtTheNextLevelOrPersonalBest() { + LevelDefinition first = LevelCatalog.get(0); + CompletionResult timeTarget = CompletionResult.create(first, 0, 31_000L, + 0, 25_000L, true); + assertEquals(CompletionResult.NextGoal.TARGET_TIME, timeTarget.nextGoal()); + assertEquals(30_000L, timeTarget.nextGoalValue()); + + CompletionResult next = CompletionResult.create(first, 0, 25_000L, + 0, 25_000L, true); + assertEquals(CompletionResult.NextGoal.NEXT_LEVEL, next.nextGoal()); + assertEquals(2, next.nextGoalValue()); + + LevelDefinition last = LevelCatalog.get(LevelCatalog.all().size() - 1); + CompletionResult lastResult = CompletionResult.create(last, + last.targetDeaths, last.targetSeconds * 900L, + last.targetDeaths, last.targetSeconds * 900L, false); + assertEquals(CompletionResult.NextGoal.PERSONAL_BEST, lastResult.nextGoal()); + } +} diff --git a/app/src/test/java/com/projectsacrifice/game/OnboardingFlowTest.java b/app/src/test/java/com/projectsacrifice/game/OnboardingFlowTest.java new file mode 100644 index 0000000..23e6241 --- /dev/null +++ b/app/src/test/java/com/projectsacrifice/game/OnboardingFlowTest.java @@ -0,0 +1,38 @@ +package com.projectsacrifice.game; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public final class OnboardingFlowTest { + @Test + public void firstRunExplainsControlsAndCorpsesThenStaysDismissed() { + OnboardingFlow flow = OnboardingFlow.firstRun(false); + + assertTrue(flow.isVisible()); + assertEquals(OnboardingFlow.Step.MOVEMENT, flow.step()); + + flow.next(); + assertEquals(OnboardingFlow.Step.MULTITOUCH_JUMP, flow.step()); + + flow.next(); + assertEquals(OnboardingFlow.Step.CORPSES_ARE_TOOLS, flow.step()); + + flow.next(); + assertFalse(flow.isVisible()); + assertTrue(flow.isComplete()); + assertFalse(OnboardingFlow.firstRun(flow.isComplete()).isVisible()); + } + + @Test + public void firstRunCanBeSkippedImmediately() { + OnboardingFlow flow = OnboardingFlow.firstRun(false); + + flow.skip(); + + assertFalse(flow.isVisible()); + assertTrue(flow.isComplete()); + } +} diff --git a/docs/STAGE3_ONBOARDING_RESULT_QA.md b/docs/STAGE3_ONBOARDING_RESULT_QA.md new file mode 100644 index 0000000..04ae5c6 --- /dev/null +++ b/docs/STAGE3_ONBOARDING_RESULT_QA.md @@ -0,0 +1,57 @@ +# Stage 3 onboarding and result QA + +Дата: 18 августа 2026 года. Базовая сборка: `0.5.0-stage3-controls-debug` +(`minSdk=23`). Этот документ описывает независимый slice обучения и результата; +версия финального Stage 3 APK будет назначена после интеграции всех веток. + +## Реализовано + +- при первом входе в уровень показывается компактная карточка из трёх подсказок: + движение, одновременные движение и прыжок двумя пальцами, использование тел; +- карточка не останавливает физику и не отключает экранное управление; +- доступны `Skip`, пошаговый `Next` и финальный `Done`; +- закрытие сохраняется в отдельном versioned storage + `project_sacrifice_onboarding_v1` и не изменяет progress/control settings; +- результат разделяет показатели текущего прохождения и сохранённые лучшие время + и число добровольцев; +- результат показывает оба target и выбирает понятную следующую цель: улучшить + число добровольцев, время, перейти на следующий уровень либо улучшить personal best; +- `Level select`, `Replay` и `Next level` используют общий navigation contract и + не очищают прогресс или настройки управления. + +## TDD evidence + +- `OnboardingFlowTest`: RED — `cannot find symbol: class OnboardingFlow`; GREEN — 2/2; +- `CompletionResultTest`: RED — `cannot find symbol: class CompletionResult`; GREEN — 2/2; +- `OnboardingStoreTest`: RED — `cannot find symbol: class OnboardingStore`; после + реализации AndroidTest compile GREEN; +- `OnboardingAndResultFlowTest`: RED — отсутствовали публичные GameView contracts + обучения/result navigation; после интеграции AndroidTest compile GREEN. + +## Выполненные проверки + +```text +./gradlew testDebugUnitTest lintDebug assembleDebug compileDebugAndroidTestJavaWithJavac +BUILD SUCCESSFUL +``` + +- JVM: 24/24, failures=0, errors=0; +- `lintDebug`: GREEN; +- `assembleDebug`: GREEN; +- все Android instrumentation sources: compile GREEN; +- `git diff --check`: GREEN. + +По ограничению stream-задачи emulator/connected tests в этой ветке не запускались. +После интеграции необходимо выполнить `OnboardingStoreTest` и +`OnboardingAndResultFlowTest` на Android 16 вместе с полным Stage 2 regression gate. + +## Приёмка на устройстве после интеграции + +1. Очистить данные приложения, открыть уровень 1 и убедиться, что карточка не + мешает одновременно удерживать направление и нажимать Jump. +2. Проверить отдельно `Skip` и полный путь `Next` → `Next` → `Done`, затем cold + launch: обучение не должно появиться повторно. +3. Завершить уровень хуже сохранённого результата: `This run` не должен подменять + `Saved best`. +4. По очереди проверить `Level select`, `Replay`, `Next level`; завершённость + уровня, unlocked level и control preset должны сохраниться.