From 00323d79160ff576a11ce8dfc50c06f3be0f8808 Mon Sep 17 00:00:00 2001 From: "E.Gavrilov" Date: Tue, 18 Aug 2026 16:20:56 +0300 Subject: [PATCH 01/13] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D1=82=D1=8C=20=D0=BE=D0=B1=D1=83=D1=87=D0=B5=D0=BD=D0=B8=D0=B5?= =?UTF-8?q?=20=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 должны сохраниться. From dcb16ef4d0ddca7eff873cf0960f4f05bafa10dd Mon Sep 17 00:00:00 2001 From: "E.Gavrilov" Date: Tue, 18 Aug 2026 16:22:38 +0300 Subject: [PATCH 02/13] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D1=82=D1=8C=20=D0=BF=D0=BE=D1=81=D1=82=D0=BE=D1=8F=D0=BD=D0=BD?= =?UTF-8?q?=D1=83=D1=8E=20=D0=BB=D0=BE=D0=BA=D0=B0=D0=BB=D0=B8=D0=B7=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D1=8E=20RU=20=D0=B8=20EN?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../game/GameLanguagePersistenceTest.java | 89 ++++++ .../projectsacrifice/game/GameLanguage.java | 26 ++ .../game/GameLanguageStore.java | 24 ++ .../projectsacrifice/game/GameStrings.java | 253 ++++++++++++++++ .../com/projectsacrifice/game/GameView.java | 285 ++++++++++++++---- .../game/GameStringsTest.java | 43 +++ docs/STAGE3_LOCALIZATION_QA.md | 59 ++++ 7 files changed, 721 insertions(+), 58 deletions(-) create mode 100644 app/src/androidTest/java/com/projectsacrifice/game/GameLanguagePersistenceTest.java create mode 100644 app/src/main/java/com/projectsacrifice/game/GameLanguage.java create mode 100644 app/src/main/java/com/projectsacrifice/game/GameLanguageStore.java create mode 100644 app/src/main/java/com/projectsacrifice/game/GameStrings.java create mode 100644 app/src/test/java/com/projectsacrifice/game/GameStringsTest.java create mode 100644 docs/STAGE3_LOCALIZATION_QA.md diff --git a/app/src/androidTest/java/com/projectsacrifice/game/GameLanguagePersistenceTest.java b/app/src/androidTest/java/com/projectsacrifice/game/GameLanguagePersistenceTest.java new file mode 100644 index 0000000..45bc1a9 --- /dev/null +++ b/app/src/androidTest/java/com/projectsacrifice/game/GameLanguagePersistenceTest.java @@ -0,0 +1,89 @@ +package com.projectsacrifice.game; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import android.os.SystemClock; +import android.view.MotionEvent; + +import androidx.test.core.app.ActivityScenario; +import androidx.test.ext.junit.runners.AndroidJUnit4; + +import org.junit.Test; +import org.junit.runner.RunWith; + +@RunWith(AndroidJUnit4.class) +public final class GameLanguagePersistenceTest { + @Test + public void settingsLanguageSwitchSurvivesRecreationWithoutChangingProgressOrControls() { + try (ActivityScenario activity = ActivityScenario.launch(MainActivity.class)) { + int[] expectedUnlocked = new int[1]; + int[] expectedBestDeaths = new int[1]; + long[] expectedBestTime = new long[1]; + + activity.onActivity(current -> { + GameView view = current.gameViewForAutomation(); + view.resetLanguageForAutomation(); + view.resetControlSettingsForAutomation(); + view.selectNextControlSizeForAutomation(); + view.selectNextControlOpacityForAutomation(); + view.toggleHapticsForAutomation(); + + ProgressStore progress = new ProgressStore(current); + progress.recordCompletion(2, 4, 22_222L); + expectedUnlocked[0] = progress.unlockedCount(); + expectedBestDeaths[0] = progress.bestDeaths(2); + expectedBestTime[0] = progress.bestTimeMs(2); + }); + + tap(activity, 1_700f, 115f); // Settings from level select. + activity.onActivity(current -> assertTrue(current.gameViewForAutomation() + .settingsVisibleForAutomation())); + tap(activity, 1_700f, 115f); // Language switch on Settings. + activity.onActivity(current -> { + GameView view = current.gameViewForAutomation(); + assertEquals(GameLanguage.RU, view.languageForAutomation()); + assertTrue(view.visibleLocalizedTextForAutomation().contains("НАСТРОЙКИ")); + assertTrue(view.visibleLocalizedTextForAutomation().contains("Русский")); + }); + + activity.recreate(); + + activity.onActivity(current -> { + GameView view = current.gameViewForAutomation(); + assertEquals(GameLanguage.RU, view.languageForAutomation()); + assertFalse(view.settingsVisibleForAutomation()); + + ControlSettings settings = view.controlSettingsForAutomation(); + assertEquals("Large", settings.controlSizeLabel()); + assertEquals("High", settings.controlOpacityLabel()); + assertFalse(settings.hapticsEnabled()); + + ProgressStore progress = new ProgressStore(current); + assertEquals(expectedUnlocked[0], progress.unlockedCount()); + assertEquals(expectedBestDeaths[0], progress.bestDeaths(2)); + assertEquals(expectedBestTime[0], progress.bestTimeMs(2)); + + view.setLanguageForAutomation(GameLanguage.EN); + view.resetControlSettingsForAutomation(); + }); + } + } + + private static void tap(ActivityScenario activity, float canvasX, float canvasY) { + activity.onActivity(current -> { + GameView view = current.gameViewForAutomation(); + float scale = Math.min(view.getWidth() / 1_920f, view.getHeight() / 1_080f); + float x = (view.getWidth() - 1_920f * scale) * 0.5f + canvasX * scale; + float y = (view.getHeight() - 1_080f * scale) * 0.5f + canvasY * scale; + long now = SystemClock.uptimeMillis(); + MotionEvent down = MotionEvent.obtain(now, now, MotionEvent.ACTION_DOWN, x, y, 0); + view.dispatchTouchEvent(down); + down.recycle(); + MotionEvent up = MotionEvent.obtain(now, now, MotionEvent.ACTION_UP, x, y, 0); + view.dispatchTouchEvent(up); + up.recycle(); + }); + } +} diff --git a/app/src/main/java/com/projectsacrifice/game/GameLanguage.java b/app/src/main/java/com/projectsacrifice/game/GameLanguage.java new file mode 100644 index 0000000..34188df --- /dev/null +++ b/app/src/main/java/com/projectsacrifice/game/GameLanguage.java @@ -0,0 +1,26 @@ +package com.projectsacrifice.game; + +/** Player-selected language, independent from the device locale. */ +public enum GameLanguage { + EN("en"), + RU("ru"); + + private final String code; + + GameLanguage(String code) { + this.code = code; + } + + public String code() { + return code; + } + + public GameLanguage next() { + return this == EN ? RU : EN; + } + + public static GameLanguage fromCode(String code) { + if (RU.code.equalsIgnoreCase(code)) return RU; + return EN; + } +} diff --git a/app/src/main/java/com/projectsacrifice/game/GameLanguageStore.java b/app/src/main/java/com/projectsacrifice/game/GameLanguageStore.java new file mode 100644 index 0000000..2585b00 --- /dev/null +++ b/app/src/main/java/com/projectsacrifice/game/GameLanguageStore.java @@ -0,0 +1,24 @@ +package com.projectsacrifice.game; + +import android.content.Context; +import android.content.SharedPreferences; + +/** Persists the explicit in-game language without mutating progress or controls. */ +final class GameLanguageStore { + private static final String FILE = "project_sacrifice_language_v1"; + private static final String KEY_LANGUAGE = "language"; + private final SharedPreferences preferences; + + GameLanguageStore(Context context) { + preferences = context.getSharedPreferences(FILE, Context.MODE_PRIVATE); + } + + GameLanguage load() { + return GameLanguage.fromCode(preferences.getString(KEY_LANGUAGE, GameLanguage.EN.code())); + } + + void save(GameLanguage language) { + preferences.edit().putString(KEY_LANGUAGE, + language == null ? GameLanguage.EN.code() : language.code()).apply(); + } +} diff --git a/app/src/main/java/com/projectsacrifice/game/GameStrings.java b/app/src/main/java/com/projectsacrifice/game/GameStrings.java new file mode 100644 index 0000000..1791e85 --- /dev/null +++ b/app/src/main/java/com/projectsacrifice/game/GameStrings.java @@ -0,0 +1,253 @@ +package com.projectsacrifice.game; + +/** All player-facing copy behind one typed, Android-independent interface. */ +public final class GameStrings { + private static final String[] EN_TITLES = { + "The Volunteer", "A Useful End", "Dead Weight", "The Long Lift", + "Express Delivery", "Cold Comfort", "Live Wire", "Borrowed Time", + "Two Good Reasons", "The Last Procession" + }; + private static final String[] RU_TITLES = { + "Рыцарь-доброволец", "Полезный конец", "Мёртвый груз", "Долгий подъём", + "Срочная доставка", "Холодное утешение", "Живой провод", "Время взаймы", + "Две веские причины", "Последнее шествие" + }; + private static final String[] EN_HINTS = { + "Reach the relic. Arrows move; the winged button jumps.", + "The spikes keep every volunteer. Build a path from what remains.", + "Leave a body on the brass plate so the next volunteer can pass.", + "Touch the lever, then ride the moving platform to the upper gallery.", + "The saw throws to the right. Deliver a volunteer to the distant plate.", + "The frost makes a solid block. Use the frozen volunteer as a step.", + "A fallen volunteer conducts the current and keeps the relay alive.", + "Jump for the far blue relay if the timed gate closes.", + "The final gate needs weight and power at the same time.", + "Launch, freeze, conduct, ascend. Every lesson opens the last relic." + }; + private static final String[] RU_HINTS = { + "Доберитесь до реликвии. Стрелки двигают; кнопка с крылом — прыжок.", + "Шипы сохраняют каждого добровольца. Проложите путь из того, что осталось.", + "Оставьте тело на латунной плите, чтобы следующий доброволец прошёл.", + "Коснитесь рычага, затем поднимитесь на платформе к верхней галерее.", + "Пила бросает вправо. Доставьте добровольца на дальнюю плиту.", + "Мороз создаёт твёрдый блок. Используйте замёрзшего добровольца как ступень.", + "Павший доброволец проводит ток и поддерживает реле.", + "Прыгните к дальнему синему реле, если ворота по таймеру закроются.", + "Последним воротам одновременно нужны вес и питание.", + "Разгонитесь, замёрзните, проведите ток, поднимитесь. Каждый урок открывает последнюю реликвию." + }; + + private final GameLanguage language; + + private GameStrings(GameLanguage language) { + this.language = language == null ? GameLanguage.EN : language; + } + + public static GameStrings forLanguage(GameLanguage language) { + return new GameStrings(language); + } + + public GameLanguage language() { + return language; + } + + private boolean russian() { + return language == GameLanguage.RU; + } + + public String appContentDescription() { + return russian() + ? "Project Sacrifice. Платформенная головоломка из десяти уровней." + : "Project Sacrifice. Ten-level puzzle platform game."; + } + + public String projectTitle() { return "PROJECT SACRIFICE"; } + public String tagline() { + return russian() ? "Каждый конец оставляет путь вперёд" + : "Every ending leaves a way forward"; + } + public String locked() { return russian() ? "ЗАКРЫТО" : "LOCKED"; } + public String tapToEnter() { return russian() ? "Нажмите, чтобы войти" : "Tap to enter"; } + public String levelSelectFooter() { + return russian() + ? "Альбомная ориентация · мультитач · прогресс сохраняется автоматически" + : "Landscape · multitouch · progress saves automatically"; + } + + public String settingsButton() { + return russian() ? "Настройки" : "Settings"; + } + + public String settingsTitle() { return russian() ? "НАСТРОЙКИ" : "SETTINGS"; } + public String settingsInstruction() { + return russian() ? "Нажмите на строку, чтобы изменить. Всё сохраняется автоматически." + : "Tap a row to change it. Changes save automatically."; + } + public String controlSizeSetting() { return russian() ? "Размер управления" : "Control size"; } + public String controlOpacitySetting() { return russian() ? "Прозрачность управления" : "Control opacity"; } + public String vibrationSetting() { return russian() ? "Вибрация" : "Vibration"; } + public String languageSetting() { return russian() ? "Язык" : "Language"; } + public String languageName() { return russian() ? "Русский" : "English"; } + public String controlSizeValue(int index) { + String[] values = russian() + ? new String[]{"Маленький", "Средний", "Большой"} + : new String[]{"Small", "Medium", "Large"}; + return values[validPreset(index)]; + } + public String controlOpacityValue(int index) { + String[] values = russian() + ? new String[]{"Низкая", "Средняя", "Высокая"} + : new String[]{"Low", "Medium", "High"}; + return values[validPreset(index)]; + } + public String hapticsValue(boolean enabled) { + if (russian()) return enabled ? "Вкл." : "Выкл."; + return enabled ? "On" : "Off"; + } + public String back() { return russian() ? "Назад" : "Back"; } + + public String hudStats(int deaths, long elapsedMilliseconds) { + if (russian()) { + return "Добровольцы " + deaths + " Время " + GameMath.formatTime(elapsedMilliseconds); + } + return "Volunteers " + deaths + " Time " + GameMath.formatTime(elapsedMilliseconds); + } + public String restart() { return russian() ? "Заново" : "Restart"; } + public String respawning() { + return russian() ? "Следующий доброволец уже в пути…" + : "Another volunteer is on the way…"; + } + + public String levelBestStats(int deaths, long elapsedMilliseconds) { + return "✓ " + deaths + " " + volunteerWord(deaths) + " · " + + GameMath.formatTime(elapsedMilliseconds); + } + public String relicClaimed() { return russian() ? "РЕЛИКВИЯ ПОЛУЧЕНА" : "RELIC CLAIMED"; } + public String completion() { return russian() ? "Завершение ★" : "Completion ★"; } + public String completionDeaths(boolean earned, int deaths, int targetDeaths) { + return (earned ? "★" : "☆") + " " + deaths + " / " + targetDeaths + " " + + volunteerWord(deaths); + } + public String completionTime(boolean earned, long elapsedMilliseconds, long targetMilliseconds) { + return (earned ? "★" : "☆") + " " + GameMath.formatTime(elapsedMilliseconds) + + " / " + GameMath.formatTime(targetMilliseconds); + } + public String levelSelect() { return russian() ? "Выбор уровня" : "Level select"; } + public String replay() { return russian() ? "Повторить" : "Replay"; } + public String nextLevel() { return russian() ? "Следующий уровень" : "Next level"; } + public String paused() { return russian() ? "ПАУЗА" : "PAUSED"; } + public String resume() { return russian() ? "Продолжить" : "Resume"; } + + // Result-flow hooks used by the Stage 3 result screen. + public String currentResult() { return russian() ? "Текущий результат" : "Current result"; } + public String bestResult() { return russian() ? "Лучший результат" : "Best result"; } + public String deathsStat() { return russian() ? "Добровольцы" : "Volunteers"; } + public String timeStat() { return russian() ? "Время" : "Time"; } + public String newBest() { return russian() ? "НОВЫЙ РЕКОРД" : "NEW BEST"; } + + public String currentResultLine(int deaths, long timeMs) { + return currentResult() + " · " + deaths + " " + volunteerWord(deaths) + + " · " + GameMath.formatTime(timeMs); + } + + public String bestResultLine(int deaths, long timeMs) { + return bestResult() + " · " + deaths + " " + volunteerWord(deaths) + + " · " + GameMath.formatTime(timeMs); + } + + public String targetsResultLine(int deaths, long timeMs) { + String label = russian() ? "Цели" : "Targets"; + return label + " · " + deaths + " " + volunteerWord(deaths) + + " · " + GameMath.formatTime(timeMs); + } + + public String nextGoalDeaths(long deaths) { + return russian() + ? "Следующая цель · пройти с " + deaths + " добровольцами" + : "Next goal · finish with " + deaths + " volunteers"; + } + + public String nextGoalTime(long timeMs) { + return (russian() ? "Следующая цель · пройти за " : "Next goal · finish in ") + + GameMath.formatTime(timeMs); + } + + public String nextGoalLevel(long level) { + return (russian() ? "Следующая цель · перейти на уровень " + : "Next goal · enter level ") + level; + } + + public String nextGoalPersonalBest() { + return russian() ? "Следующая цель · улучшить личный рекорд" + : "Next goal · improve your personal best"; + } + + // Onboarding hooks keep new tutorial Canvas copy out of GameView conditionals. + public String tutorialWelcomeTitle() { + return russian() ? "Смерть — это инструмент" : "Death is a tool"; + } + public String tutorialWelcomeBody() { + return russian() + ? "Двигайтесь стрелками и прыгайте кнопкой с крылом. Доберитесь до реликвии." + : "Move with the arrows and jump with the winged button. Reach the relic."; + } + public String tutorialSacrificeTitle() { + return russian() ? "Оставьте путь вперёд" : "Leave a way forward"; + } + public String tutorialSacrificeBody() { + return russian() + ? "После смерти тело остаётся в мире: закрывает шипы, держит плиты и становится ступенью." + : "A body stays in the world after death: cover spikes, hold plates, and make a step."; + } + public String tutorialSkip() { return russian() ? "Пропустить" : "Skip"; } + public String tutorialNext() { return russian() ? "Далее" : "Next"; } + public String tutorialStart() { return russian() ? "Начать" : "Start"; } + public String tutorialDone() { return russian() ? "Готово" : "Done"; } + public String tutorialMovementTitle() { return russian() ? "ДВИЖЕНИЕ" : "MOVE"; } + public String tutorialMovementBody() { + return russian() ? "Удерживайте левую или правую стрелку, чтобы двигаться." + : "Hold the left or right arrow to explore."; + } + public String tutorialJumpTitle() { return russian() ? "ДВИЖЕНИЕ + ПРЫЖОК" : "MOVE + JUMP"; } + public String tutorialJumpBody() { + return russian() ? "Используйте два пальца: держите направление и нажмите Прыжок." + : "Use two fingers: hold a direction and tap Jump."; + } + public String tutorialCorpseTitle() { + return russian() ? "СМЕРТЬ ОСТАВЛЯЕТ ИНСТРУМЕНТ" : "DEATH LEAVES A TOOL"; + } + public String tutorialCorpseBody() { + return russian() ? "Павший доброволец остаётся. Используйте тело, чтобы открыть путь." + : "A fallen volunteer remains. Use the body to open a path."; + } + public String tutorialProgress(int step, int total) { + return step + " / " + total; + } + + public String levelTitle(int oneBasedLevelNumber) { + return (russian() ? RU_TITLES : EN_TITLES)[levelIndex(oneBasedLevelNumber)]; + } + + public String levelHint(int oneBasedLevelNumber) { + return (russian() ? RU_HINTS : EN_HINTS)[levelIndex(oneBasedLevelNumber)]; + } + + private String volunteerWord(int count) { + if (!russian()) return count == 1 ? "volunteer" : "volunteers"; + int mod100 = Math.abs(count) % 100; + int mod10 = Math.abs(count) % 10; + if (mod100 >= 11 && mod100 <= 14) return "добровольцев"; + if (mod10 == 1) return "доброволец"; + if (mod10 >= 2 && mod10 <= 4) return "добровольца"; + return "добровольцев"; + } + + private static int validPreset(int index) { + return index >= 0 && index <= 2 ? index : 1; + } + + private static int levelIndex(int oneBasedLevelNumber) { + int index = oneBasedLevelNumber - 1; + return index >= 0 && index < EN_TITLES.length ? index : 0; + } +} diff --git a/app/src/main/java/com/projectsacrifice/game/GameView.java b/app/src/main/java/com/projectsacrifice/game/GameView.java index c61a37a..5919eaa 100644 --- a/app/src/main/java/com/projectsacrifice/game/GameView.java +++ b/app/src/main/java/com/projectsacrifice/game/GameView.java @@ -16,6 +16,7 @@ import android.view.MotionEvent; import android.view.View; import java.util.ArrayList; +import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Locale; @@ -162,6 +163,7 @@ public final class GameView extends View implements Runnable { private final ProgressStore progress; private final ControlSettingsStore controlSettingsStore; private final OnboardingStore onboardingStore; + private final GameLanguageStore languageStore; private final SparseArray pointers = new SparseArray<>(); private final Player player = new Player(); private final ArrayList corpses = new ArrayList<>(); @@ -190,6 +192,8 @@ public final class GameView extends View implements Runnable { private ControlSettings controlSettings; private OnboardingFlow onboardingFlow; private CompletionResult completionResult; + private GameLanguage language; + private GameStrings strings; public GameView(Context context) { super(context); @@ -198,12 +202,15 @@ public final class GameView extends View implements Runnable { controlSettings = controlSettingsStore.load(); onboardingStore = new OnboardingStore(context); onboardingFlow = OnboardingFlow.firstRun(onboardingStore.isComplete()); + languageStore = new GameLanguageStore(context); + language = languageStore.load(); + strings = GameStrings.forLanguage(language); paint.setTypeface(android.graphics.Typeface.create("sans", android.graphics.Typeface.NORMAL)); stroke.setStyle(Paint.Style.STROKE); stroke.setStrokeCap(Paint.Cap.ROUND); setFocusable(true); setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_YES); - setContentDescription("Project Sacrifice. Ten-level puzzle platform game."); + setContentDescription(strings.appContentDescription()); running = true; postOnAnimation(this); } @@ -942,11 +949,15 @@ public final class GameView extends View implements Runnable { private void drawHud(Canvas canvas) { paint.setColor(Color.argb(178, 15, 20, 34)); canvas.drawRoundRect(new RectF(28, 26, 940, 138), 24, 24, paint); - drawText(canvas, String.format(Locale.US, "%02d %s", level.number, level.title), 56, 72, 34, Color.rgb(248, 220, 151), false); - drawText(canvas, level.hint, 56, 116, 25, Color.rgb(231, 231, 235), false); - drawText(canvas, "Volunteers " + deaths + " Time " + GameMath.formatTime((long) (levelElapsed * 1000)), 1010, 82, 31, Color.WHITE, false); + drawText(canvas, String.format(Locale.US, "%02d %s", level.number, + strings.levelTitle(level.number)), 56, 72, 34, + Color.rgb(248, 220, 151), false); + drawFittedText(canvas, strings.levelHint(level.number), 56, 116, 25, 16, + 850, Color.rgb(231, 231, 235), false); + drawText(canvas, strings.hudStats(deaths, (long) (levelElapsed * 1000)), + 1010, 82, 31, Color.WHITE, false); - drawHudButton(canvas, new RectF(1608, 26, 1770, 112), "Restart", false); + drawHudButton(canvas, new RectF(1608, 26, 1770, 112), strings.restart(), false); drawHudButton(canvas, new RectF(1790, 26, 1892, 112), "II", false); float controlScale = controlSettings.controlScale(); @@ -954,7 +965,8 @@ public final class GameView extends View implements Runnable { drawControlCircle(canvas, 365, 925, 92 * controlScale, "›", rightPressed); drawControlCircle(canvas, 1760, 900, 116 * controlScale, "▲", jumpPressed); if (!player.alive) { - drawCenteredText(canvas, "Another volunteer is on the way…", VIEW_WIDTH * 0.5f, 235, 38, Color.rgb(250, 222, 153)); + drawCenteredText(canvas, strings.respawning(), VIEW_WIDTH * 0.5f, 235, 38, + Color.rgb(250, 222, 153)); } } @@ -974,8 +986,10 @@ public final class GameView extends View implements Runnable { private void drawLevelSelect(Canvas canvas) { drawSky(canvas); - drawCenteredText(canvas, "PROJECT SACRIFICE", VIEW_WIDTH * 0.5f, 120, 64, Color.rgb(248, 212, 126)); - drawCenteredText(canvas, "Every ending leaves a way forward", VIEW_WIDTH * 0.5f, 177, 29, Color.rgb(225, 226, 233)); + drawCenteredText(canvas, strings.projectTitle(), VIEW_WIDTH * 0.5f, 120, 64, + Color.rgb(248, 212, 126)); + drawCenteredText(canvas, strings.tagline(), VIEW_WIDTH * 0.5f, 177, 29, + Color.rgb(225, 226, 233)); int unlocked = progress.unlockedCount(); float startX = 110f; float cardW = 310f; @@ -994,35 +1008,43 @@ public final class GameView extends View implements Runnable { stroke.setColor(complete ? Color.rgb(109, 222, 143) : (enabled ? Color.rgb(205, 164, 83) : Color.rgb(73, 77, 91))); stroke.setStrokeWidth(5f); canvas.drawRoundRect(card, 22, 22, stroke); - drawCenteredText(canvas, enabled ? String.format(Locale.US, "%02d", i + 1) : "LOCKED", card.centerX(), top + 72, enabled ? 48 : 27, + drawCenteredText(canvas, enabled ? String.format(Locale.US, "%02d", i + 1) : strings.locked(), card.centerX(), top + 72, enabled ? 48 : 27, enabled ? Color.rgb(248, 210, 118) : Color.rgb(120, 123, 135)); LevelDefinition item = LevelCatalog.get(i); - drawCenteredText(canvas, item.title, card.centerX(), top + 124, 28, enabled ? Color.WHITE : Color.rgb(111, 114, 125)); + drawCenteredFittedText(canvas, strings.levelTitle(item.number), card.centerX(), + top + 124, 28, 20, cardW - 28, + enabled ? Color.WHITE : Color.rgb(111, 114, 125)); if (complete) { int bestDeaths = progress.bestDeaths(i + 1); long bestTime = progress.bestTimeMs(i + 1); - drawCenteredText(canvas, "✓ " + bestDeaths + " volunteers · " + GameMath.formatTime(bestTime), card.centerX(), top + 185, 21, Color.rgb(138, 231, 163)); + drawCenteredFittedText(canvas, strings.levelBestStats(bestDeaths, bestTime), + card.centerX(), top + 185, 21, 17, cardW - 24, + Color.rgb(138, 231, 163)); } else if (enabled) { - drawCenteredText(canvas, "Tap to enter", card.centerX(), top + 190, 22, Color.rgb(202, 204, 214)); + drawCenteredText(canvas, strings.tapToEnter(), card.centerX(), top + 190, + 22, Color.rgb(202, 204, 214)); } } - drawHudButton(canvas, new RectF(1560, 70, 1850, 160), "Settings", false); - drawCenteredText(canvas, "Landscape · multitouch · progress saves automatically", VIEW_WIDTH * 0.5f, 980, 24, Color.argb(190, 230, 230, 236)); + drawHudButton(canvas, new RectF(1560, 70, 1850, 160), strings.settingsButton(), false); + drawCenteredText(canvas, strings.levelSelectFooter(), VIEW_WIDTH * 0.5f, 980, 24, + Color.argb(190, 230, 230, 236)); } private void drawSettings(Canvas canvas) { drawSky(canvas); - drawCenteredText(canvas, "SETTINGS", VIEW_WIDTH * 0.5f, 145, 64, + drawCenteredText(canvas, strings.settingsTitle(), VIEW_WIDTH * 0.5f, 145, 64, Color.rgb(248, 212, 126)); - drawCenteredText(canvas, "Tap a row to change it. Changes save automatically.", + drawCenteredText(canvas, strings.settingsInstruction(), VIEW_WIDTH * 0.5f, 205, 27, Color.rgb(225, 226, 233)); - drawSettingRow(canvas, new RectF(420, 280, 1500, 410), "Control size", - controlSettings.controlSizeLabel()); - drawSettingRow(canvas, new RectF(420, 450, 1500, 580), "Control opacity", - controlSettings.controlOpacityLabel()); - drawSettingRow(canvas, new RectF(420, 620, 1500, 750), "Vibration", - controlSettings.hapticsLabel()); - drawHudButton(canvas, new RectF(710, 850, 1210, 960), "Back", true); + drawText(canvas, strings.languageSetting(), 1_370f, 126f, 27f, Color.WHITE, true); + drawHudButton(canvas, new RectF(1580, 70, 1850, 160), strings.languageName(), false); + drawSettingRow(canvas, new RectF(420, 280, 1500, 410), strings.controlSizeSetting(), + strings.controlSizeValue(controlSettings.controlSizeIndex())); + drawSettingRow(canvas, new RectF(420, 450, 1500, 580), strings.controlOpacitySetting(), + strings.controlOpacityValue(controlSettings.controlOpacityIndex())); + drawSettingRow(canvas, new RectF(420, 620, 1500, 750), strings.vibrationSetting(), + strings.hapticsValue(controlSettings.hapticsEnabled())); + drawHudButton(canvas, new RectF(710, 850, 1210, 960), strings.back(), true); } private void drawSettingRow(Canvas canvas, RectF box, String label, String value) { @@ -1039,43 +1061,44 @@ 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, 205, 64, Color.rgb(250, 211, 111)); - drawCenteredText(canvas, level.title, VIEW_WIDTH * 0.5f, 263, 34, Color.WHITE); + drawCenteredText(canvas, strings.relicClaimed(), VIEW_WIDTH * 0.5f, 205, 64, + Color.rgb(250, 211, 111)); + drawCenteredText(canvas, strings.levelTitle(level.number), VIEW_WIDTH * 0.5f, 263, + 34, Color.WHITE); CompletionResult result = completionResult; if (result != null) { - drawCenteredText(canvas, - "This run · " + result.currentDeaths() + " volunteers · " - + GameMath.formatTime(result.currentTimeMs()), + drawCenteredText(canvas, strings.currentResultLine(result.currentDeaths(), + result.currentTimeMs()), VIEW_WIDTH * 0.5f, 380, 34, Color.WHITE); - drawCenteredText(canvas, - "Saved best · " + result.bestDeaths() + " volunteers · " - + GameMath.formatTime(result.bestTimeMs()), + drawCenteredText(canvas, strings.bestResultLine(result.bestDeaths(), + result.bestTimeMs()), VIEW_WIDTH * 0.5f, 437, 31, Color.rgb(128, 229, 153)); - drawCenteredText(canvas, - "Targets · " + result.targetDeaths() + " volunteers · " - + GameMath.formatTime(result.targetTimeMs()), + drawCenteredText(canvas, strings.targetsResultLine(result.targetDeaths(), + 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); + drawHudButton(canvas, new RectF(345, 735, 755, 850), strings.levelSelect(), false); + drawHudButton(canvas, new RectF(775, 735, 1145, 850), strings.replay(), false); + if (levelIndex + 1 < LevelCatalog.all().size()) { + drawHudButton(canvas, new RectF(1165, 735, 1575, 850), strings.nextLevel(), true); + } } private String nextGoalText(CompletionResult result) { switch (result.nextGoal()) { case TARGET_DEATHS: - return "Next goal · finish with " + result.nextGoalValue() + " volunteers"; + return strings.nextGoalDeaths(result.nextGoalValue()); case TARGET_TIME: - return "Next goal · finish in " + GameMath.formatTime(result.nextGoalValue()); + return strings.nextGoalTime(result.nextGoalValue()); case NEXT_LEVEL: - return "Next goal · enter level " + result.nextGoalValue(); + return strings.nextGoalLevel(result.nextGoalValue()); case PERSONAL_BEST: default: - return "Next goal · improve your personal best"; + return strings.nextGoalPersonalBest(); } } @@ -1092,37 +1115,64 @@ public final class GameView extends View implements Runnable { String progressLabel; switch (onboardingFlow.step()) { case MOVEMENT: - title = "MOVE"; - message = "Hold the left or right arrow to explore."; - progressLabel = "1 / 3"; + title = strings.tutorialMovementTitle(); + message = strings.tutorialMovementBody(); + progressLabel = strings.tutorialProgress(1, 3); break; case MULTITOUCH_JUMP: - title = "MOVE + JUMP"; - message = "Use two fingers: hold a direction and tap Jump."; - progressLabel = "2 / 3"; + title = strings.tutorialJumpTitle(); + message = strings.tutorialJumpBody(); + progressLabel = strings.tutorialProgress(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"; + title = strings.tutorialCorpseTitle(); + message = strings.tutorialCorpseBody(); + progressLabel = strings.tutorialProgress(3, 3); break; } - drawText(canvas, title, 575, 230, 30, Color.rgb(250, 211, 111), true); - drawText(canvas, message, 575, 278, 27, Color.WHITE, false); + drawFittedText(canvas, title, 575, 230, 30, 21, 740, + Color.rgb(250, 211, 111), true); + drawFittedText(canvas, message, 575, 278, 27, 18, 740, 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(965, 298, 1125, 355), strings.tutorialSkip(), false); drawHudButton(canvas, new RectF(1145, 298, 1345, 355), - onboardingFlow.step() == OnboardingFlow.Step.CORPSES_ARE_TOOLS ? "Done" : "Next", + onboardingFlow.step() == OnboardingFlow.Step.CORPSES_ARE_TOOLS + ? strings.tutorialDone() : strings.tutorialNext(), true); } + private String onboardingTitle() { + switch (onboardingFlow.step()) { + case MOVEMENT: + return strings.tutorialMovementTitle(); + case MULTITOUCH_JUMP: + return strings.tutorialJumpTitle(); + case CORPSES_ARE_TOOLS: + default: + return strings.tutorialCorpseTitle(); + } + } + + private String onboardingBody() { + switch (onboardingFlow.step()) { + case MOVEMENT: + return strings.tutorialMovementBody(); + case MULTITOUCH_JUMP: + return strings.tutorialJumpBody(); + case CORPSES_ARE_TOOLS: + default: + return strings.tutorialCorpseBody(); + } + } + private void drawPause(Canvas canvas) { paint.setColor(Color.argb(218, 8, 12, 23)); canvas.drawRect(0, 0, VIEW_WIDTH, VIEW_HEIGHT, paint); - drawCenteredText(canvas, "PAUSED", VIEW_WIDTH * 0.5f, 350, 68, Color.rgb(248, 212, 126)); - drawHudButton(canvas, new RectF(610, 485, 1310, 610), "Resume", true); - drawHudButton(canvas, new RectF(610, 650, 1310, 775), "Level select", false); + drawCenteredText(canvas, strings.paused(), VIEW_WIDTH * 0.5f, 350, 68, + Color.rgb(248, 212, 126)); + drawHudButton(canvas, new RectF(610, 485, 1310, 610), strings.resume(), true); + drawHudButton(canvas, new RectF(610, 650, 1310, 775), strings.levelSelect(), false); } private void drawHudButton(Canvas canvas, RectF box, String label, boolean primary) { @@ -1148,6 +1198,30 @@ public final class GameView extends View implements Runnable { canvas.drawText(text, x, baseline, paint); } + private void drawFittedText(Canvas canvas, String text, float x, float baseline, + float preferredSize, float minimumSize, float maximumWidth, + int color, boolean bold) { + float size = fittedTextSize(text, preferredSize, minimumSize, maximumWidth, bold); + drawText(canvas, text, x, baseline, size, color, bold); + } + + private void drawCenteredFittedText(Canvas canvas, String text, float x, float baseline, + float preferredSize, float minimumSize, + float maximumWidth, int color) { + float size = fittedTextSize(text, preferredSize, minimumSize, maximumWidth, true); + drawCenteredText(canvas, text, x, baseline, size, color); + } + + private float fittedTextSize(String text, float preferredSize, float minimumSize, + float maximumWidth, boolean bold) { + paint.setTextSize(preferredSize); + paint.setTypeface(android.graphics.Typeface.create("sans", + bold ? android.graphics.Typeface.BOLD : android.graphics.Typeface.NORMAL)); + float measured = paint.measureText(text); + if (measured <= maximumWidth || measured <= 0f) return preferredSize; + return Math.max(minimumSize, preferredSize * maximumWidth / measured); + } + private void drawCenteredText(Canvas canvas, String text, float x, float baseline, float size, int color) { paint.setShader(null); paint.setStyle(Paint.Style.FILL); @@ -1331,7 +1405,10 @@ public final class GameView extends View implements Runnable { } private void handleSettingsTap(float x, float y) { - if (contains(420, 280, 1500, 410, x, y)) { + if (contains(1580, 70, 1850, 160, x, y)) { + updateLanguage(language.next()); + emitHaptic(HapticFeedbackConstants.KEYBOARD_TAP); + } else if (contains(420, 280, 1500, 410, x, y)) { updateControlSettings(controlSettings.nextControlSize()); emitHaptic(HapticFeedbackConstants.KEYBOARD_TAP); } else if (contains(420, 450, 1500, 580, x, y)) { @@ -1458,6 +1535,98 @@ public final class GameView extends View implements Runnable { nextLevelFromResult(); } + public GameLanguage languageForAutomation() { + return language; + } + + public void resetLanguageForAutomation() { + updateLanguage(GameLanguage.EN); + } + + public void selectNextLanguageForAutomation() { + updateLanguage(language.next()); + } + + public void setLanguageForAutomation(GameLanguage updated) { + updateLanguage(updated); + } + + /** Exact localized copy sources that the current Canvas state renders. */ + public List visibleLocalizedTextForAutomation() { + ArrayList visible = new ArrayList<>(); + if (screen == Screen.LEVELS) { + visible.add(strings.projectTitle()); + visible.add(strings.tagline()); + for (int i = 0; i < LevelCatalog.all().size(); i++) { + boolean enabled = i < progress.unlockedCount(); + visible.add(enabled ? String.format(Locale.US, "%02d", i + 1) : strings.locked()); + visible.add(strings.levelTitle(i + 1)); + if (progress.isCompleted(i + 1)) { + visible.add(strings.levelBestStats(progress.bestDeaths(i + 1), + progress.bestTimeMs(i + 1))); + } else if (enabled) { + visible.add(strings.tapToEnter()); + } + } + visible.add(strings.settingsButton()); + visible.add(strings.levelSelectFooter()); + } else if (screen == Screen.SETTINGS) { + visible.add(strings.settingsTitle()); + visible.add(strings.settingsInstruction()); + visible.add(strings.languageSetting()); + visible.add(strings.languageName()); + visible.add(strings.controlSizeSetting()); + visible.add(strings.controlSizeValue(controlSettings.controlSizeIndex())); + visible.add(strings.controlOpacitySetting()); + visible.add(strings.controlOpacityValue(controlSettings.controlOpacityIndex())); + visible.add(strings.vibrationSetting()); + visible.add(strings.hapticsValue(controlSettings.hapticsEnabled())); + visible.add(strings.back()); + } else if (level != null) { + visible.add(String.format(Locale.US, "%02d %s", level.number, + strings.levelTitle(level.number))); + visible.add(strings.levelHint(level.number)); + visible.add(strings.hudStats(deaths, (long) (levelElapsed * 1000))); + visible.add(strings.restart()); + if (!player.alive) visible.add(strings.respawning()); + if (screen == Screen.COMPLETE) { + visible.add(strings.relicClaimed()); + visible.add(strings.levelTitle(level.number)); + if (completionResult != null) { + visible.add(strings.currentResultLine(completionResult.currentDeaths(), + completionResult.currentTimeMs())); + visible.add(strings.bestResultLine(completionResult.bestDeaths(), + completionResult.bestTimeMs())); + visible.add(strings.targetsResultLine(completionResult.targetDeaths(), + completionResult.targetTimeMs())); + visible.add(nextGoalText(completionResult)); + } + visible.add(strings.levelSelect()); + visible.add(strings.replay()); + if (levelIndex + 1 < LevelCatalog.all().size()) visible.add(strings.nextLevel()); + } else if (userPaused) { + visible.add(strings.paused()); + visible.add(strings.resume()); + visible.add(strings.levelSelect()); + } else if (onboardingFlow.isVisible()) { + visible.add(onboardingTitle()); + visible.add(onboardingBody()); + visible.add(strings.tutorialSkip()); + visible.add(onboardingFlow.step() == OnboardingFlow.Step.CORPSES_ARE_TOOLS + ? strings.tutorialDone() : strings.tutorialNext()); + } + } + return Collections.unmodifiableList(visible); + } + + private void updateLanguage(GameLanguage updated) { + language = updated == null ? GameLanguage.EN : updated; + strings = GameStrings.forLanguage(language); + languageStore.save(language); + setContentDescription(strings.appContentDescription()); + invalidate(); + } + private void updateControlSettings(ControlSettings updated) { controlSettings = updated; controlSettingsStore.save(updated); diff --git a/app/src/test/java/com/projectsacrifice/game/GameStringsTest.java b/app/src/test/java/com/projectsacrifice/game/GameStringsTest.java new file mode 100644 index 0000000..8ff7583 --- /dev/null +++ b/app/src/test/java/com/projectsacrifice/game/GameStringsTest.java @@ -0,0 +1,43 @@ +package com.projectsacrifice.game; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; + +import org.junit.Test; + +public final class GameStringsTest { + @Test + public void missingOrUnknownLanguageSafelyFallsBackToEnglish() { + assertEquals(GameLanguage.EN, GameLanguage.fromCode(null)); + assertEquals(GameLanguage.EN, GameLanguage.fromCode("de")); + + GameStrings strings = GameStrings.forLanguage(GameLanguage.fromCode("de")); + assertEquals("Settings", strings.settingsButton()); + assertEquals("The Volunteer", strings.levelTitle(1)); + } + + @Test + public void russianPackLocalizesTheCompletePlayerFacingContract() { + GameStrings english = GameStrings.forLanguage(GameLanguage.EN); + GameStrings russian = GameStrings.forLanguage(GameLanguage.RU); + + assertEquals("Настройки", russian.settingsButton()); + assertEquals("Размер управления", russian.controlSizeSetting()); + assertEquals("Русский", russian.languageName()); + assertEquals("Рыцарь-доброволец", russian.levelTitle(1)); + assertEquals("Последнее шествие", russian.levelTitle(10)); + assertEquals("Добровольцы 2 Время 00:07", russian.hudStats(2, 7_000L)); + assertEquals("✓ 2 добровольца · 00:07", russian.levelBestStats(2, 7_000L)); + assertEquals("★ 2 / 3 добровольца", russian.completionDeaths(true, 2, 3)); + assertEquals("Смерть — это инструмент", russian.tutorialWelcomeTitle()); + assertFalse(russian.tutorialSacrificeBody().trim().isEmpty()); + + for (int level = 1; level <= 10; level++) { + assertFalse(russian.levelTitle(level).trim().isEmpty()); + assertFalse(russian.levelHint(level).trim().isEmpty()); + assertNotEquals(english.levelTitle(level), russian.levelTitle(level)); + assertNotEquals(english.levelHint(level), russian.levelHint(level)); + } + } +} diff --git a/docs/STAGE3_LOCALIZATION_QA.md b/docs/STAGE3_LOCALIZATION_QA.md new file mode 100644 index 0000000..e3580b5 --- /dev/null +++ b/docs/STAGE3_LOCALIZATION_QA.md @@ -0,0 +1,59 @@ +# Stage 3 RU/EN localization QA + +Date: 2026-08-18 +Scope: persistent, explicit English/Russian language selection for the Canvas UI. + +## Delivered contract + +- English is the default for missing, null, or unknown stored language codes. +- The Settings screen exposes a dedicated English/Russian switch without moving the + existing control-size, opacity, vibration, or Back touch targets. +- The selected language is stored separately from progress and control preferences + and is restored when `MainActivity` is recreated. +- `GameStrings` owns all player-facing copy: level select, settings, HUD, respawn, + pause, completion, buttons, statistics, ten level titles and hints. +- Result-flow and onboarding copy has typed RU/EN hooks ready for the other Stage 3 + slices; new Canvas code does not need language conditionals. +- Long localized hints, card titles, and best-result summaries are fitted to their + existing Canvas bounds. Gameplay geometry and Android `minSdk 23` are unchanged. +- Automation exposes current/set/toggle language and a read-only snapshot of the + localized copy rendered by the current screen. + +## TDD evidence + +1. RED: the fallback test failed compilation with seven missing `GameLanguage` / + `GameStrings` symbols. GREEN: English default and safe unknown/null fallback. +2. RED: the complete Russian-pack test failed compilation with ten missing copy + methods. GREEN: complete RU pack, ten titles/hints, statistics, plural forms, + result hooks, and onboarding hooks. +3. RED: the persistence device test failed compilation with four missing GameView + language hooks. GREEN: Settings toggle plus a dedicated preference store. +4. RED: the integrated acceptance extension failed compilation with three missing + setter/snapshot hooks. GREEN: explicit language setter and current-screen copy + snapshot. + +## Automated checks + +| Gate | Result | +| --- | --- | +| `testDebugUnitTest` | GREEN, 22/22 JVM tests | +| `lintDebug` | GREEN | +| `assembleDebug` | GREEN | +| `compileDebugAndroidTestJavaWithJavac` | GREEN, including `GameLanguagePersistenceTest` | +| Canvas raw-copy audit | GREEN; only language-neutral `II` and numeric formats remain in draw calls | + +`GameLanguagePersistenceTest` is compiled here but intentionally not executed in +this isolated stream because emulator ownership belongs to final Stage 3 integration. +It performs real Settings taps and verifies Russian visible-copy snapshot, Activity +recreation, progress, control size, opacity, and vibration preservation. + +## Integration notes + +- `GameView.java` will conflict with accessibility/onboarding/result branches. Keep + their UI behavior and route every new visible string through the existing + `GameStrings` result/onboarding hooks. +- `ControlSettings.java` is not modified; localized preset labels are derived from + its stable package-visible preset indexes. +- `LevelCatalog.java` and level geometry are not modified. Canvas rendering now uses + `GameStrings.levelTitle/levelHint`; the existing English fields remain compatible + with gameplay tests and older callers. From 21775542d597922d1196a623393cfd93fad11685 Mon Sep 17 00:00:00 2001 From: "E.Gavrilov" Date: Tue, 18 Aug 2026 16:25:31 +0300 Subject: [PATCH 03/13] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D1=82=D1=8C=20=D0=B4=D0=BE=D1=81=D1=82=D1=83=D0=BF=D0=BD=D1=8B?= =?UTF-8?q?=D0=B9=20=D0=B8=D0=BD=D1=82=D0=B5=D1=80=D1=84=D0=B5=D0=B9=D1=81?= =?UTF-8?q?=20=D1=8D=D1=82=D0=B0=D0=BF=D0=B0=203?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../game/AccessibilityPersistenceTest.java | 163 ++++++++++ .../game/AccessibilityScreenDescription.java | 59 ++++ .../game/AccessibilitySettings.java | 69 +++++ .../game/AccessibilitySettingsStore.java | 28 ++ .../game/AccessibilityStatus.java | 19 ++ .../projectsacrifice/game/GameStrings.java | 83 +++++ .../com/projectsacrifice/game/GameView.java | 293 ++++++++++++++++-- .../AccessibilityScreenDescriptionTest.java | 35 +++ .../game/AccessibilitySettingsTest.java | 53 ++++ .../game/AccessibilityStatusTest.java | 19 ++ docs/STAGE3_ACCESSIBILITY_QA.md | 56 ++++ 11 files changed, 854 insertions(+), 23 deletions(-) create mode 100644 app/src/androidTest/java/com/projectsacrifice/game/AccessibilityPersistenceTest.java create mode 100644 app/src/main/java/com/projectsacrifice/game/AccessibilityScreenDescription.java create mode 100644 app/src/main/java/com/projectsacrifice/game/AccessibilitySettings.java create mode 100644 app/src/main/java/com/projectsacrifice/game/AccessibilitySettingsStore.java create mode 100644 app/src/main/java/com/projectsacrifice/game/AccessibilityStatus.java create mode 100644 app/src/test/java/com/projectsacrifice/game/AccessibilityScreenDescriptionTest.java create mode 100644 app/src/test/java/com/projectsacrifice/game/AccessibilitySettingsTest.java create mode 100644 app/src/test/java/com/projectsacrifice/game/AccessibilityStatusTest.java create mode 100644 docs/STAGE3_ACCESSIBILITY_QA.md diff --git a/app/src/androidTest/java/com/projectsacrifice/game/AccessibilityPersistenceTest.java b/app/src/androidTest/java/com/projectsacrifice/game/AccessibilityPersistenceTest.java new file mode 100644 index 0000000..d5635dc --- /dev/null +++ b/app/src/androidTest/java/com/projectsacrifice/game/AccessibilityPersistenceTest.java @@ -0,0 +1,163 @@ +package com.projectsacrifice.game; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import android.os.SystemClock; +import android.view.MotionEvent; + +import androidx.test.core.app.ActivityScenario; +import androidx.test.ext.junit.runners.AndroidJUnit4; + +import org.junit.Test; +import org.junit.runner.RunWith; + +@RunWith(AndroidJUnit4.class) +public final class AccessibilityPersistenceTest { + @Test + public void accessibilityAndControlChoicesSurviveActivityRecreationTogether() { + try (ActivityScenario activity = ActivityScenario.launch(MainActivity.class)) { + activity.onActivity(current -> { + GameView view = current.gameViewForAutomation(); + view.resetControlSettingsForAutomation(); + view.resetAccessibilitySettingsForAutomation(); + view.selectNextControlSizeForAutomation(); + view.selectNextTextSizeForAutomation(); + view.toggleHighContrastForAutomation(); + }); + + activity.recreate(); + + activity.onActivity(current -> { + GameView view = current.gameViewForAutomation(); + assertEquals("Large", view.controlSettingsForAutomation().controlSizeLabel()); + assertEquals("Large", view.accessibilitySettingsForAutomation().textSizeLabel()); + assertTrue(view.accessibilitySettingsForAutomation().highContrastEnabled()); + view.resetControlSettingsForAutomation(); + view.resetAccessibilitySettingsForAutomation(); + }); + } + } + + @Test + public void descriptionsFollowScreensWhileBackAlwaysHasOneDeterministicResult() { + try (ActivityScenario activity = ActivityScenario.launch(MainActivity.class)) { + activity.onActivity(current -> assertContains( + current.gameViewForAutomation(), "Level select")); + + activity.onActivity(current -> current.gameViewForAutomation() + .openSettingsForAutomation()); + activity.onActivity(current -> { + assertContains(current.gameViewForAutomation(), "Settings"); + assertContains(current.gameViewForAutomation(), "Text size"); + current.onBackPressed(); + assertContains(current.gameViewForAutomation(), "Level select"); + }); + + activity.onActivity(current -> { + GameView view = current.gameViewForAutomation(); + view.startLevelForAutomation(1); + assertContains(view, "Playing level 1"); + view.pauseForAutomation(); + assertContains(view, "Paused on level 1"); + current.onBackPressed(); + assertContains(view, "Playing level 1"); + assertFalse(view.automationSnapshot().visiblyPaused()); + view.showCompleteForAutomation(); + assertContains(view, "Level complete"); + current.onBackPressed(); + assertContains(view, "Level select"); + }); + } + } + + @Test + public void textAndContrastPresetsDoNotMoveContentOutsideTheSafeViewport() { + try (ActivityScenario activity = ActivityScenario.launch(MainActivity.class)) { + activity.onActivity(current -> { + GameView view = current.gameViewForAutomation(); + float[] before = view.safeViewportForAutomation(); + view.selectNextTextSizeForAutomation(); + view.toggleHighContrastForAutomation(); + float[] after = view.safeViewportForAutomation(); + + assertTrue(before[0] >= 0f); + assertTrue(before[1] >= 0f); + assertTrue(before[2] <= view.getWidth()); + assertTrue(before[3] <= view.getHeight()); + assertEquals(before[0], after[0], 0f); + assertEquals(before[1], after[1], 0f); + assertEquals(before[2], after[2], 0f); + assertEquals(before[3], after[3], 0f); + view.resetAccessibilitySettingsForAutomation(); + }); + } + } + + @Test + public void accessibilityRowsChangeThePersistedSettingsThroughRealTouchInput() { + try (ActivityScenario activity = ActivityScenario.launch(MainActivity.class)) { + activity.onActivity(current -> { + GameView view = current.gameViewForAutomation(); + view.resetAccessibilitySettingsForAutomation(); + view.openSettingsForAutomation(); + }); + + tap(activity, 220f, 345f); + tap(activity, 220f, 515f); + + activity.onActivity(current -> { + GameView view = current.gameViewForAutomation(); + assertEquals(1.25f, view.textScaleForAutomation(), 0f); + assertTrue(view.highContrastForAutomation()); + assertTrue(view.screenDescriptionForAutomation().contains("Text size Large")); + assertTrue(view.screenDescriptionForAutomation().contains("High contrast On")); + view.resetAccessibilitySettingsForAutomation(); + }); + } + } + + @Test + public void criticalSignalAndDoorStateRemainsTextualWhenVibrationIsOff() { + try (ActivityScenario activity = ActivityScenario.launch(MainActivity.class)) { + activity.onActivity(current -> { + GameView view = current.gameViewForAutomation(); + view.resetControlSettingsForAutomation(); + view.toggleHapticsForAutomation(); + view.startLevelForAutomation(3); + + assertEquals("Signals 0/1 · Doors 0/1 · Volunteer active", + view.criticalVisibleStatusForAutomation()); + assertTrue(view.screenDescriptionForAutomation().contains( + view.criticalVisibleStatusForAutomation())); + view.resetControlSettingsForAutomation(); + }); + } + } + + private static void tap(ActivityScenario activity, float canvasX, float canvasY) { + touch(activity, MotionEvent.ACTION_DOWN, canvasX, canvasY); + touch(activity, MotionEvent.ACTION_UP, canvasX, canvasY); + } + + private static void touch(ActivityScenario activity, int action, + float canvasX, float canvasY) { + activity.onActivity(current -> { + GameView view = current.gameViewForAutomation(); + float scale = Math.min(view.getWidth() / 1_920f, view.getHeight() / 1_080f); + float x = (view.getWidth() - 1_920f * scale) * 0.5f + canvasX * scale; + float y = (view.getHeight() - 1_080f * scale) * 0.5f + canvasY * scale; + long now = SystemClock.uptimeMillis(); + MotionEvent event = MotionEvent.obtain(now, now, action, x, y, 0); + view.dispatchTouchEvent(event); + event.recycle(); + }); + } + + private static void assertContains(GameView view, String expected) { + String description = String.valueOf(view.getContentDescription()); + assertTrue("Expected '" + expected + "' in '" + description + "'", + description.contains(expected)); + } +} diff --git a/app/src/main/java/com/projectsacrifice/game/AccessibilityScreenDescription.java b/app/src/main/java/com/projectsacrifice/game/AccessibilityScreenDescription.java new file mode 100644 index 0000000..d5cea9e --- /dev/null +++ b/app/src/main/java/com/projectsacrifice/game/AccessibilityScreenDescription.java @@ -0,0 +1,59 @@ +package com.projectsacrifice.game; + +final class AccessibilityScreenDescription { + private AccessibilityScreenDescription() { + } + + static String levelSelect(int unlocked, int total) { + return "Level select. " + unlocked + " of " + total + + " unlocked. Choose an unlocked level or open Settings."; + } + + static String settings(ControlSettings controls, AccessibilitySettings accessibility) { + return "Settings. Control size " + controls.controlSizeLabel() + + ". Control opacity " + controls.controlOpacityLabel() + + ". Vibration " + controls.hapticsLabel() + + ". Text size " + accessibility.textSizeLabel() + + ". High contrast " + accessibility.highContrastLabel() + + ". Back."; + } + + static String playing(LevelDefinition level, int deaths, String elapsed, + String criticalState, boolean paused) { + if (paused) { + return "Paused on level " + level.number + ", " + level.title + + ". Resume or return to level select."; + } + return "Playing level " + level.number + ", " + level.title + + ". " + deaths + " volunteers. Time " + elapsed + + ". " + criticalState + + ". Move left, move right, jump, restart, pause."; + } + + static String complete(LevelDefinition level, int deaths, String elapsed, + boolean hasNextLevel) { + return "Level complete. " + level.title + ". " + deaths + + " volunteers. Time " + elapsed + + ". Level select. Replay." + + (hasNextLevel ? " Next level." : ""); + } + + static String levelSelect(GameStrings strings, int unlocked, int total) { + return strings.levelSelectDescription(unlocked, total); + } + + static String settings(GameStrings strings, ControlSettings controls, + AccessibilitySettings accessibility) { + return strings.settingsDescription(controls, accessibility); + } + + static String playing(GameStrings strings, LevelDefinition level, int deaths, + String elapsed, String criticalState, boolean paused) { + return strings.playingDescription(level.number, deaths, elapsed, criticalState, paused); + } + + static String complete(GameStrings strings, LevelDefinition level, int deaths, + String elapsed, boolean hasNextLevel) { + return strings.completeDescription(level.number, deaths, elapsed, hasNextLevel); + } +} diff --git a/app/src/main/java/com/projectsacrifice/game/AccessibilitySettings.java b/app/src/main/java/com/projectsacrifice/game/AccessibilitySettings.java new file mode 100644 index 0000000..efe9d1f --- /dev/null +++ b/app/src/main/java/com/projectsacrifice/game/AccessibilitySettings.java @@ -0,0 +1,69 @@ +package com.projectsacrifice.game; + +public final class AccessibilitySettings { + private enum TextSize { + STANDARD("Standard", 1f), + LARGE("Large", 1.25f); + + final String label; + final float scale; + + TextSize(String label, float scale) { + this.label = label; + this.scale = scale; + } + } + + private final TextSize textSize; + private final boolean highContrastEnabled; + + private AccessibilitySettings(TextSize textSize, boolean highContrastEnabled) { + this.textSize = textSize; + this.highContrastEnabled = highContrastEnabled; + } + + public static AccessibilitySettings defaults() { + return new AccessibilitySettings(TextSize.STANDARD, false); + } + + static AccessibilitySettings restore(int textSizeIndex, boolean highContrastEnabled) { + TextSize[] values = TextSize.values(); + TextSize restored = textSizeIndex >= 0 && textSizeIndex < values.length + ? values[textSizeIndex] : TextSize.STANDARD; + return new AccessibilitySettings(restored, highContrastEnabled); + } + + public String textSizeLabel() { + return textSize.label; + } + + public float textScale() { + return textSize.scale; + } + + public float canvasTextSize(float baseSize) { + return baseSize * textSize.scale; + } + + public AccessibilitySettings nextTextSize() { + TextSize[] values = TextSize.values(); + return new AccessibilitySettings(values[(textSize.ordinal() + 1) % values.length], + highContrastEnabled); + } + + public boolean highContrastEnabled() { + return highContrastEnabled; + } + + public String highContrastLabel() { + return highContrastEnabled ? "On" : "Off"; + } + + public AccessibilitySettings toggleHighContrast() { + return new AccessibilitySettings(textSize, !highContrastEnabled); + } + + int textSizeIndex() { + return textSize.ordinal(); + } +} diff --git a/app/src/main/java/com/projectsacrifice/game/AccessibilitySettingsStore.java b/app/src/main/java/com/projectsacrifice/game/AccessibilitySettingsStore.java new file mode 100644 index 0000000..9c10436 --- /dev/null +++ b/app/src/main/java/com/projectsacrifice/game/AccessibilitySettingsStore.java @@ -0,0 +1,28 @@ +package com.projectsacrifice.game; + +import android.content.Context; +import android.content.SharedPreferences; + +final class AccessibilitySettingsStore { + private static final String FILE = "project_sacrifice_accessibility_settings_v1"; + private final SharedPreferences preferences; + + AccessibilitySettingsStore(Context context) { + preferences = context.getSharedPreferences(FILE, Context.MODE_PRIVATE); + } + + AccessibilitySettings load() { + AccessibilitySettings defaults = AccessibilitySettings.defaults(); + return AccessibilitySettings.restore( + preferences.getInt("text_size", defaults.textSizeIndex()), + preferences.getBoolean("high_contrast", defaults.highContrastEnabled()) + ); + } + + void save(AccessibilitySettings settings) { + preferences.edit() + .putInt("text_size", settings.textSizeIndex()) + .putBoolean("high_contrast", settings.highContrastEnabled()) + .apply(); + } +} diff --git a/app/src/main/java/com/projectsacrifice/game/AccessibilityStatus.java b/app/src/main/java/com/projectsacrifice/game/AccessibilityStatus.java new file mode 100644 index 0000000..1b5fcfb --- /dev/null +++ b/app/src/main/java/com/projectsacrifice/game/AccessibilityStatus.java @@ -0,0 +1,19 @@ +package com.projectsacrifice.game; + +final class AccessibilityStatus { + private AccessibilityStatus() { + } + + static String describe(int activeSignals, int totalSignals, int openDoors, int totalDoors, + boolean playerAlive) { + return "Signals " + activeSignals + "/" + totalSignals + + " · Doors " + openDoors + "/" + totalDoors + + " · Volunteer " + (playerAlive ? "active" : "respawning"); + } + + static String describe(GameStrings strings, int activeSignals, int totalSignals, + int openDoors, int totalDoors, boolean playerAlive) { + return strings.accessibilityStatus(activeSignals, totalSignals, openDoors, totalDoors, + playerAlive); + } +} diff --git a/app/src/main/java/com/projectsacrifice/game/GameStrings.java b/app/src/main/java/com/projectsacrifice/game/GameStrings.java index 1791e85..f380093 100644 --- a/app/src/main/java/com/projectsacrifice/game/GameStrings.java +++ b/app/src/main/java/com/projectsacrifice/game/GameStrings.java @@ -104,6 +104,16 @@ public final class GameStrings { if (russian()) return enabled ? "Вкл." : "Выкл."; return enabled ? "On" : "Off"; } + public String accessibilityTitle() { return russian() ? "ДОСТУПНОСТЬ" : "ACCESSIBILITY"; } + public String textSizeSetting() { return russian() ? "Размер текста" : "Text size"; } + public String textSizeValue(float scale) { + return scale > 1f ? (russian() ? "Крупный" : "Large") + : (russian() ? "Обычный" : "Standard"); + } + public String highContrastSetting() { return russian() ? "Контраст" : "High contrast"; } + public String highContrastValue(boolean enabled) { + return hapticsValue(enabled); + } public String back() { return russian() ? "Назад" : "Back"; } public String hudStats(int deaths, long elapsedMilliseconds) { @@ -224,6 +234,79 @@ public final class GameStrings { return step + " / " + total; } + public String accessibilityStatus(int activeSignals, int totalSignals, + int openDoors, int totalDoors, boolean playerAlive) { + if (russian()) { + return "Сигналы " + activeSignals + "/" + totalSignals + + " · Двери " + openDoors + "/" + totalDoors + + " · Доброволец " + (playerAlive ? "активен" : "возрождается"); + } + return "Signals " + activeSignals + "/" + totalSignals + + " · Doors " + openDoors + "/" + totalDoors + + " · Volunteer " + (playerAlive ? "active" : "respawning"); + } + + public String levelSelectDescription(int unlocked, int total) { + if (russian()) { + return "Выбор уровня. Открыто " + unlocked + " из " + total + + ". Выберите открытый уровень или Настройки."; + } + return "Level select. " + unlocked + " of " + total + + " unlocked. Choose an unlocked level or open Settings."; + } + + public String settingsDescription(ControlSettings controls, + AccessibilitySettings accessibility) { + if (russian()) { + return "Настройки. Размер управления " + + controlSizeValue(controls.controlSizeIndex()) + + ". Прозрачность управления " + + controlOpacityValue(controls.controlOpacityIndex()) + + ". Вибрация " + hapticsValue(controls.hapticsEnabled()) + + ". Размер текста " + textSizeValue(accessibility.textScale()) + + ". Контраст " + highContrastValue(accessibility.highContrastEnabled()) + + ". Назад."; + } + return "Settings. Control size " + controls.controlSizeLabel() + + ". Control opacity " + controls.controlOpacityLabel() + + ". Vibration " + controls.hapticsLabel() + + ". Text size " + accessibility.textSizeLabel() + + ". High contrast " + accessibility.highContrastLabel() + + ". Back."; + } + + public String playingDescription(int levelNumber, int deaths, String elapsed, + String criticalState, boolean paused) { + if (paused) { + return russian() + ? "Пауза на уровне " + levelNumber + ", " + levelTitle(levelNumber) + + ". Продолжить или вернуться к выбору уровня." + : "Paused on level " + levelNumber + ", " + levelTitle(levelNumber) + + ". Resume or return to level select."; + } + if (russian()) { + return "Идёт уровень " + levelNumber + ", " + levelTitle(levelNumber) + + ". Добровольцы " + deaths + ". Время " + elapsed + ". " + + criticalState + ". Движение влево и вправо, прыжок, заново, пауза."; + } + return "Playing level " + levelNumber + ", " + levelTitle(levelNumber) + + ". " + deaths + " volunteers. Time " + elapsed + ". " + criticalState + + ". Move left, move right, jump, restart, pause."; + } + + public String completeDescription(int levelNumber, int deaths, String elapsed, + boolean hasNextLevel) { + if (russian()) { + return "Уровень завершён. " + levelTitle(levelNumber) + ". Добровольцы " + + deaths + ". Время " + elapsed + + ". Выбор уровня. Повторить." + + (hasNextLevel ? " Следующий уровень." : ""); + } + return "Level complete. " + levelTitle(levelNumber) + ". " + deaths + + " volunteers. Time " + elapsed + ". Level select. Replay." + + (hasNextLevel ? " Next level." : ""); + } + public String levelTitle(int oneBasedLevelNumber) { return (russian() ? RU_TITLES : EN_TITLES)[levelIndex(oneBasedLevelNumber)]; } diff --git a/app/src/main/java/com/projectsacrifice/game/GameView.java b/app/src/main/java/com/projectsacrifice/game/GameView.java index 5919eaa..b514c8f 100644 --- a/app/src/main/java/com/projectsacrifice/game/GameView.java +++ b/app/src/main/java/com/projectsacrifice/game/GameView.java @@ -14,13 +14,16 @@ import android.util.SparseArray; import android.view.HapticFeedbackConstants; import android.view.MotionEvent; import android.view.View; +import android.view.accessibility.AccessibilityEvent; import java.util.ArrayList; import java.util.Collections; +import java.util.HashSet; import java.util.HashMap; import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.Set; /** * A dependency-free 2D puzzle-platformer renderer and deterministic physics loop. @@ -164,6 +167,7 @@ public final class GameView extends View implements Runnable { private final ControlSettingsStore controlSettingsStore; private final OnboardingStore onboardingStore; private final GameLanguageStore languageStore; + private final AccessibilitySettingsStore accessibilitySettingsStore; private final SparseArray pointers = new SparseArray<>(); private final Player player = new Player(); private final ArrayList corpses = new ArrayList<>(); @@ -194,6 +198,8 @@ public final class GameView extends View implements Runnable { private CompletionResult completionResult; private GameLanguage language; private GameStrings strings; + private AccessibilitySettings accessibilitySettings; + private String lastAccessibilityStateKey = ""; public GameView(Context context) { super(context); @@ -205,12 +211,14 @@ public final class GameView extends View implements Runnable { languageStore = new GameLanguageStore(context); language = languageStore.load(); strings = GameStrings.forLanguage(language); + accessibilitySettingsStore = new AccessibilitySettingsStore(context); + accessibilitySettings = accessibilitySettingsStore.load(); paint.setTypeface(android.graphics.Typeface.create("sans", android.graphics.Typeface.NORMAL)); stroke.setStyle(Paint.Style.STROKE); stroke.setStrokeCap(Paint.Cap.ROUND); setFocusable(true); setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_YES); - setContentDescription(strings.appContentDescription()); + refreshAccessibilityDescription(false); running = true; postOnAnimation(this); } @@ -239,12 +247,14 @@ public final class GameView extends View implements Runnable { pointers.clear(); deriveControls(); lastFrameNanos = System.nanoTime(); + refreshAccessibilityDescription(false); } public boolean handleBack() { if (screen == Screen.LEVELS) return false; if (screen == Screen.SETTINGS) { screen = Screen.LEVELS; + refreshAccessibilityDescription(true); return true; } if (screen == Screen.COMPLETE) { @@ -258,6 +268,7 @@ public final class GameView extends View implements Runnable { } pointers.clear(); deriveControls(); + refreshAccessibilityDescription(true); return true; } @@ -281,6 +292,7 @@ public final class GameView extends View implements Runnable { for (LevelDefinition.Platform item : level.platforms) platformStates.add(new PlatformState(item)); spawnPlayer(); lastFrameNanos = System.nanoTime(); + refreshAccessibilityDescription(true); } private void restartLevel() { @@ -320,6 +332,8 @@ public final class GameView extends View implements Runnable { checkGoal(); } + refreshAccessibilityDescriptionIfStateChanged(); + float targetCamera = player.alive ? player.x - 610f + player.facing * 110f : cameraX; targetCamera = GameMath.clamp(targetCamera, 0f, Math.max(0f, level.worldWidth - VIEW_WIDTH)); cameraX += (targetCamera - cameraX) * Math.min(1f, dt * 4.5f); @@ -656,6 +670,7 @@ public final class GameView extends View implements Runnable { pointers.clear(); deriveControls(); emitHaptic(HapticFeedbackConstants.LONG_PRESS); + refreshAccessibilityDescription(true); } private RectF playerRect() { @@ -673,9 +688,7 @@ public final class GameView extends View implements Runnable { @Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); - canvasScale = Math.min(getWidth() / VIEW_WIDTH, getHeight() / VIEW_HEIGHT); - canvasOffsetX = (getWidth() - VIEW_WIDTH * canvasScale) * 0.5f; - canvasOffsetY = (getHeight() - VIEW_HEIGHT * canvasScale) * 0.5f; + updateViewport(getWidth(), getHeight()); paint.setShader(outsideGradient); canvas.drawRect(0, 0, getWidth(), getHeight(), paint); @@ -714,6 +727,12 @@ public final class GameView extends View implements Runnable { } private void drawSky(Canvas canvas) { + if (accessibilitySettings.highContrastEnabled()) { + canvas.drawColor(Color.BLACK); + paint.setColor(Color.rgb(255, 214, 77)); + canvas.drawCircle(1560, 170, 88, paint); + return; + } paint.setShader(skyGradient); canvas.drawRect(0, 0, VIEW_WIDTH, VIEW_HEIGHT, paint); paint.setShader(null); @@ -745,10 +764,19 @@ public final class GameView extends View implements Runnable { for (PlatformState state : platformStates) { RectF box = state.rect(); boolean moving = state.definition.isMoving(); - paint.setColor(moving ? Color.rgb(172, 126, 55) : Color.rgb(69, 74, 91)); + paint.setColor(accessibilitySettings.highContrastEnabled() + ? (moving ? Color.rgb(255, 191, 0) : Color.rgb(56, 56, 56)) + : (moving ? Color.rgb(172, 126, 55) : Color.rgb(69, 74, 91))); canvas.drawRoundRect(box, 10, 10, paint); - paint.setColor(moving ? Color.rgb(242, 195, 92) : Color.rgb(117, 121, 137)); + paint.setColor(accessibilitySettings.highContrastEnabled() + ? Color.WHITE + : (moving ? Color.rgb(242, 195, 92) : Color.rgb(117, 121, 137))); canvas.drawRoundRect(new RectF(box.left, box.top, box.right, box.top + Math.min(14f, box.height())), 8, 8, paint); + if (accessibilitySettings.highContrastEnabled()) { + stroke.setColor(Color.WHITE); + stroke.setStrokeWidth(5f); + canvas.drawRoundRect(box, 10, 10, stroke); + } if (moving) { stroke.setColor(Color.argb(120, 244, 214, 137)); stroke.setStrokeWidth(5f); @@ -771,6 +799,7 @@ public final class GameView extends View implements Runnable { canvas.drawRoundRect(new RectF(box.left - 8, box.top + 6, box.right + 8, box.bottom + 12), 6, 6, paint); paint.setColor(active ? Color.rgb(250, 194, 76) : Color.rgb(174, 126, 55)); canvas.drawRoundRect(box, 7, 7, paint); + drawWorldLabel(canvas, active ? "DOWN" : "UP", box.centerX(), box.top - 18); } else { paint.setColor(Color.rgb(50, 56, 70)); canvas.drawRoundRect(box, 12, 12, paint); @@ -785,6 +814,7 @@ public final class GameView extends View implements Runnable { Float remaining = timedSignals.get(item.signalId); drawWorldLabel(canvas, String.format(Locale.US, "%.1f", remaining == null ? 0f : remaining), box.centerX(), box.top - 52); } + drawWorldLabel(canvas, active ? "ON" : "OFF", box.centerX(), box.bottom + 32); } } } @@ -877,6 +907,7 @@ public final class GameView extends View implements Runnable { paint.setColor(Color.rgb(239, 195, 92)); canvas.drawCircle(box.centerX(), box.centerY(), 15, paint); } + drawWorldLabel(canvas, open ? "OPEN" : "LOCKED", box.centerX(), box.top - 20); } } @@ -949,13 +980,24 @@ public final class GameView extends View implements Runnable { private void drawHud(Canvas canvas) { paint.setColor(Color.argb(178, 15, 20, 34)); canvas.drawRoundRect(new RectF(28, 26, 940, 138), 24, 24, paint); + canvas.save(); + canvas.clipRect(40, 30, 925, 135); drawText(canvas, String.format(Locale.US, "%02d %s", level.number, strings.levelTitle(level.number)), 56, 72, 34, Color.rgb(248, 220, 151), false); drawFittedText(canvas, strings.levelHint(level.number), 56, 116, 25, 16, 850, Color.rgb(231, 231, 235), false); + canvas.restore(); + canvas.save(); + canvas.clipRect(990, 28, 1590, 130); drawText(canvas, strings.hudStats(deaths, (long) (levelElapsed * 1000)), 1010, 82, 31, Color.WHITE, false); + canvas.restore(); + paint.setColor(accessibilitySettings.highContrastEnabled() + ? Color.BLACK : Color.argb(178, 15, 20, 34)); + canvas.drawRoundRect(new RectF(590, 139, 1330, 185), 18, 18, paint); + drawCenteredText(canvas, criticalStatusText(), VIEW_WIDTH * 0.5f, 170, 22, + accessibilitySettings.highContrastEnabled() ? Color.WHITE : Color.rgb(218, 221, 230)); drawHudButton(canvas, new RectF(1608, 26, 1770, 112), strings.restart(), false); drawHudButton(canvas, new RectF(1790, 26, 1892, 112), "II", false); @@ -973,11 +1015,15 @@ public final class GameView extends View implements Runnable { private void drawControlCircle(Canvas canvas, float x, float y, float radius, String label, boolean active) { int inactiveFillAlpha = Math.round(190f * controlSettings.controlOpacity()); int inactiveStrokeAlpha = Math.round(230f * controlSettings.controlOpacity()); - paint.setColor(active ? Color.argb(220, 245, 190, 74) - : Color.argb(inactiveFillAlpha, 22, 28, 43)); + paint.setColor(accessibilitySettings.highContrastEnabled() + ? (active ? Color.rgb(255, 214, 77) : Color.BLACK) + : (active ? Color.argb(220, 245, 190, 74) + : Color.argb(inactiveFillAlpha, 22, 28, 43))); canvas.drawCircle(x, y, radius, paint); - stroke.setColor(active ? Color.rgb(255, 232, 169) - : Color.argb(inactiveStrokeAlpha, 240, 240, 245)); + stroke.setColor(accessibilitySettings.highContrastEnabled() + ? Color.WHITE + : (active ? Color.rgb(255, 232, 169) + : Color.argb(inactiveStrokeAlpha, 240, 240, 245))); stroke.setStrokeWidth(5f); canvas.drawCircle(x, y, radius - 5, stroke); drawCenteredText(canvas, label, x, y + 21, 68 * controlSettings.controlScale(), @@ -1003,12 +1049,17 @@ public final class GameView extends View implements Runnable { RectF card = new RectF(left, top, left + cardW, top + cardH); boolean enabled = i < unlocked; boolean complete = progress.isCompleted(i + 1); - paint.setColor(enabled ? Color.argb(220, 38, 45, 65) : Color.argb(185, 25, 29, 42)); + paint.setColor(accessibilitySettings.highContrastEnabled() + ? Color.BLACK + : (enabled ? Color.argb(220, 38, 45, 65) : Color.argb(185, 25, 29, 42))); canvas.drawRoundRect(card, 22, 22, paint); stroke.setColor(complete ? Color.rgb(109, 222, 143) : (enabled ? Color.rgb(205, 164, 83) : Color.rgb(73, 77, 91))); stroke.setStrokeWidth(5f); canvas.drawRoundRect(card, 22, 22, stroke); - drawCenteredText(canvas, enabled ? String.format(Locale.US, "%02d", i + 1) : strings.locked(), card.centerX(), top + 72, enabled ? 48 : 27, + canvas.save(); + canvas.clipRect(card.left + 10, card.top + 8, card.right - 10, card.bottom - 8); + drawCenteredText(canvas, enabled ? String.format(Locale.US, "%02d", i + 1) + : strings.locked(), card.centerX(), top + 72, enabled ? 48 : 27, enabled ? Color.rgb(248, 210, 118) : Color.rgb(120, 123, 135)); LevelDefinition item = LevelCatalog.get(i); drawCenteredFittedText(canvas, strings.levelTitle(item.number), card.centerX(), @@ -1024,6 +1075,7 @@ public final class GameView extends View implements Runnable { drawCenteredText(canvas, strings.tapToEnter(), card.centerX(), top + 190, 22, Color.rgb(202, 204, 214)); } + canvas.restore(); } drawHudButton(canvas, new RectF(1560, 70, 1850, 160), strings.settingsButton(), false); drawCenteredText(canvas, strings.levelSelectFooter(), VIEW_WIDTH * 0.5f, 980, 24, @@ -1044,18 +1096,47 @@ public final class GameView extends View implements Runnable { strings.controlOpacityValue(controlSettings.controlOpacityIndex())); drawSettingRow(canvas, new RectF(420, 620, 1500, 750), strings.vibrationSetting(), strings.hapticsValue(controlSettings.hapticsEnabled())); + drawCenteredText(canvas, strings.accessibilityTitle(), 220, 240, 25, + Color.rgb(248, 212, 126)); + drawCompactSettingRow(canvas, new RectF(55, 280, 385, 410), + strings.textSizeSetting(), + strings.textSizeValue(accessibilitySettings.textScale())); + drawCompactSettingRow(canvas, new RectF(55, 450, 385, 580), + strings.highContrastSetting(), + strings.highContrastValue(accessibilitySettings.highContrastEnabled())); drawHudButton(canvas, new RectF(710, 850, 1210, 960), strings.back(), true); } - private void drawSettingRow(Canvas canvas, RectF box, String label, String value) { - paint.setColor(Color.argb(220, 38, 45, 65)); + private void drawCompactSettingRow(Canvas canvas, RectF box, String label, String value) { + paint.setColor(accessibilitySettings.highContrastEnabled() + ? Color.BLACK : Color.argb(220, 38, 45, 65)); canvas.drawRoundRect(box, 22, 22, paint); - stroke.setColor(Color.rgb(139, 145, 160)); + stroke.setColor(accessibilitySettings.highContrastEnabled() + ? Color.WHITE : Color.rgb(139, 145, 160)); stroke.setStrokeWidth(4f); canvas.drawRoundRect(box, 22, 22, stroke); + canvas.save(); + canvas.clipRect(box.left + 8f, box.top + 8f, box.right - 8f, box.bottom - 8f); + drawCenteredText(canvas, label, box.centerX(), box.top + 47f, 24f, Color.WHITE); + drawCenteredText(canvas, value, box.centerX(), box.top + 98f, 28f, + Color.rgb(248, 212, 126)); + canvas.restore(); + } + + private void drawSettingRow(Canvas canvas, RectF box, String label, String value) { + paint.setColor(accessibilitySettings.highContrastEnabled() + ? Color.BLACK : Color.argb(220, 38, 45, 65)); + canvas.drawRoundRect(box, 22, 22, paint); + stroke.setColor(accessibilitySettings.highContrastEnabled() + ? Color.WHITE : Color.rgb(139, 145, 160)); + stroke.setStrokeWidth(4f); + canvas.drawRoundRect(box, 22, 22, stroke); + canvas.save(); + canvas.clipRect(box.left + 10f, box.top + 8f, box.right - 10f, box.bottom - 8f); drawText(canvas, label, box.left + 55f, box.centerY() + 11f, 32f, Color.WHITE, true); drawHudButton(canvas, new RectF(1_090f, box.top + 24f, 1_445f, box.bottom - 24f), value, false); + canvas.restore(); } private void drawComplete(Canvas canvas) { @@ -1176,12 +1257,19 @@ public final class GameView extends View implements Runnable { } private void drawHudButton(Canvas canvas, RectF box, String label, boolean primary) { - paint.setColor(primary ? Color.rgb(226, 169, 63) : Color.argb(210, 45, 52, 71)); + paint.setColor(accessibilitySettings.highContrastEnabled() + ? (primary ? Color.rgb(255, 214, 77) : Color.BLACK) + : (primary ? Color.rgb(226, 169, 63) : Color.argb(210, 45, 52, 71))); canvas.drawRoundRect(box, 22, 22, paint); - stroke.setColor(primary ? Color.rgb(255, 224, 146) : Color.rgb(139, 145, 160)); + stroke.setColor(accessibilitySettings.highContrastEnabled() + ? Color.WHITE + : (primary ? Color.rgb(255, 224, 146) : Color.rgb(139, 145, 160))); stroke.setStrokeWidth(4f); canvas.drawRoundRect(box, 22, 22, stroke); + canvas.save(); + canvas.clipRect(box.left + 8f, box.top + 5f, box.right - 8f, box.bottom - 5f); drawCenteredText(canvas, label, box.centerX(), box.centerY() + 10, Math.min(31, box.height() * 0.34f), primary ? Color.rgb(35, 36, 43) : Color.WHITE); + canvas.restore(); } private void drawWorldLabel(Canvas canvas, String label, float x, float y) { @@ -1191,8 +1279,8 @@ public final class GameView extends View implements Runnable { private void drawText(Canvas canvas, String text, float x, float baseline, float size, int color, boolean bold) { paint.setShader(null); paint.setStyle(Paint.Style.FILL); - paint.setTextSize(size); - paint.setColor(color); + paint.setTextSize(accessibilitySettings.canvasTextSize(size)); + paint.setColor(accessibleTextColor(color)); paint.setTextAlign(Paint.Align.LEFT); paint.setTypeface(android.graphics.Typeface.create("sans", bold ? android.graphics.Typeface.BOLD : android.graphics.Typeface.NORMAL)); canvas.drawText(text, x, baseline, paint); @@ -1225,8 +1313,8 @@ public final class GameView extends View implements Runnable { private void drawCenteredText(Canvas canvas, String text, float x, float baseline, float size, int color) { paint.setShader(null); paint.setStyle(Paint.Style.FILL); - paint.setTextSize(size); - paint.setColor(color); + paint.setTextSize(accessibilitySettings.canvasTextSize(size)); + paint.setColor(accessibleTextColor(color)); paint.setTextAlign(Paint.Align.CENTER); paint.setTypeface(android.graphics.Typeface.create("sans", android.graphics.Typeface.BOLD)); canvas.drawText(text, x, baseline, paint); @@ -1236,6 +1324,16 @@ public final class GameView extends View implements Runnable { return Color.argb((int) (255 * alpha), Color.red(color), Color.green(color), Color.blue(color)); } + private int accessibleTextColor(int color) { + if (!accessibilitySettings.highContrastEnabled()) return color; + int red = Color.red(color); + int green = Color.green(color); + int blue = Color.blue(color); + if (red < 90 && green < 90 && blue < 90) return Color.BLACK; + if (red > 180 && green > 140 && blue < 170) return Color.rgb(255, 214, 77); + return Color.WHITE; + } + @Override public boolean onTouchEvent(MotionEvent event) { PointF point = toCanvasPoint(event.getX(event.getActionIndex()), event.getY(event.getActionIndex())); @@ -1287,6 +1385,7 @@ public final class GameView extends View implements Runnable { userPaused = true; pointers.clear(); deriveControls(); + refreshAccessibilityDescription(true); return true; } } @@ -1334,6 +1433,7 @@ public final class GameView extends View implements Runnable { if (contains(1560, 70, 1850, 160, x, y)) { screen = Screen.SETTINGS; emitHaptic(HapticFeedbackConstants.KEYBOARD_TAP); + refreshAccessibilityDescription(true); return; } float startX = 110f; @@ -1384,6 +1484,7 @@ public final class GameView extends View implements Runnable { screen = Screen.LEVELS; pointers.clear(); deriveControls(); + refreshAccessibilityDescription(true); } private void replayFromResult() { @@ -1402,10 +1503,17 @@ public final class GameView extends View implements Runnable { userPaused = false; screen = Screen.LEVELS; } + refreshAccessibilityDescription(true); } private void handleSettingsTap(float x, float y) { - if (contains(1580, 70, 1850, 160, x, y)) { + if (contains(55, 280, 385, 410, x, y)) { + updateAccessibilitySettings(accessibilitySettings.nextTextSize()); + emitHaptic(HapticFeedbackConstants.KEYBOARD_TAP); + } else if (contains(55, 450, 385, 580, x, y)) { + updateAccessibilitySettings(accessibilitySettings.toggleHighContrast()); + emitHaptic(HapticFeedbackConstants.KEYBOARD_TAP); + } else if (contains(1580, 70, 1850, 160, x, y)) { updateLanguage(language.next()); emitHaptic(HapticFeedbackConstants.KEYBOARD_TAP); } else if (contains(420, 280, 1500, 410, x, y)) { @@ -1420,6 +1528,7 @@ public final class GameView extends View implements Runnable { } else if (contains(710, 850, 1210, 960, x, y)) { screen = Screen.LEVELS; emitHaptic(HapticFeedbackConstants.KEYBOARD_TAP); + refreshAccessibilityDescription(true); } } @@ -1431,6 +1540,12 @@ public final class GameView extends View implements Runnable { return new PointF((x - canvasOffsetX) / Math.max(0.001f, canvasScale), (y - canvasOffsetY) / Math.max(0.001f, canvasScale)); } + private void updateViewport(float width, float height) { + canvasScale = Math.min(width / VIEW_WIDTH, height / VIEW_HEIGHT); + canvasOffsetX = (width - VIEW_WIDTH * canvasScale) * 0.5f; + canvasOffsetY = (height - VIEW_HEIGHT * canvasScale) * 0.5f; + } + private static boolean contains(float left, float top, float right, float bottom, float x, float y) { return x >= left && x <= right && y >= top && y <= bottom; } @@ -1581,6 +1696,11 @@ public final class GameView extends View implements Runnable { visible.add(strings.controlOpacityValue(controlSettings.controlOpacityIndex())); visible.add(strings.vibrationSetting()); visible.add(strings.hapticsValue(controlSettings.hapticsEnabled())); + visible.add(strings.accessibilityTitle()); + visible.add(strings.textSizeSetting()); + visible.add(strings.textSizeValue(accessibilitySettings.textScale())); + visible.add(strings.highContrastSetting()); + visible.add(strings.highContrastValue(accessibilitySettings.highContrastEnabled())); visible.add(strings.back()); } else if (level != null) { visible.add(String.format(Locale.US, "%02d %s", level.number, @@ -1623,18 +1743,145 @@ public final class GameView extends View implements Runnable { language = updated == null ? GameLanguage.EN : updated; strings = GameStrings.forLanguage(language); languageStore.save(language); - setContentDescription(strings.appContentDescription()); + refreshAccessibilityDescription(false); invalidate(); } + public AccessibilitySettings accessibilitySettingsForAutomation() { + return accessibilitySettings; + } + + public void resetAccessibilitySettingsForAutomation() { + updateAccessibilitySettings(AccessibilitySettings.defaults()); + } + + public void selectNextTextSizeForAutomation() { + updateAccessibilitySettings(accessibilitySettings.nextTextSize()); + } + + public void toggleHighContrastForAutomation() { + updateAccessibilitySettings(accessibilitySettings.toggleHighContrast()); + } + + public float textScaleForAutomation() { + return accessibilitySettings.textScale(); + } + + public boolean highContrastForAutomation() { + return accessibilitySettings.highContrastEnabled(); + } + + public String screenDescriptionForAutomation() { + return currentAccessibilityDescription(); + } + + public String criticalVisibleStatusForAutomation() { + return criticalStatusText(); + } + + public void openSettingsForAutomation() { + screen = Screen.SETTINGS; + refreshAccessibilityDescription(false); + } + + public void pauseForAutomation() { + if (screen == Screen.PLAYING) { + userPaused = true; + pointers.clear(); + deriveControls(); + refreshAccessibilityDescription(false); + } + } + + public void showCompleteForAutomation() { + if (level != null) { + screen = Screen.COMPLETE; + pointers.clear(); + deriveControls(); + refreshAccessibilityDescription(false); + } + } + + public float[] safeViewportForAutomation() { + float width = getWidth(); + float height = getHeight(); + if (width <= 0f || height <= 0f) return new float[] {0f, 0f, 0f, 0f}; + float scale = Math.min(width / VIEW_WIDTH, height / VIEW_HEIGHT); + float left = (width - VIEW_WIDTH * scale) * 0.5f; + float top = (height - VIEW_HEIGHT * scale) * 0.5f; + return new float[] {left, top, left + VIEW_WIDTH * scale, top + VIEW_HEIGHT * scale}; + } + private void updateControlSettings(ControlSettings updated) { controlSettings = updated; controlSettingsStore.save(updated); pointers.clear(); deriveControls(); + refreshAccessibilityDescription(false); invalidate(); } + private void updateAccessibilitySettings(AccessibilitySettings updated) { + accessibilitySettings = updated; + accessibilitySettingsStore.save(updated); + refreshAccessibilityDescription(false); + invalidate(); + } + + private String criticalStatusText() { + if (level == null) return AccessibilityStatus.describe(strings, 0, 0, 0, 0, true); + Set signalIds = new HashSet<>(); + int activeSignals = 0; + for (LevelDefinition.Switch item : level.switches) signalIds.add(item.signalId); + for (String signalId : signalIds) if (signalActive(signalId)) activeSignals++; + int openDoors = 0; + for (LevelDefinition.Door door : level.doors) if (doorOpen(door)) openDoors++; + return AccessibilityStatus.describe(strings, activeSignals, signalIds.size(), openDoors, + level.doors.size(), player.alive); + } + + private String currentAccessibilityDescription() { + if (screen == Screen.LEVELS) { + return AccessibilityScreenDescription.levelSelect(strings, progress.unlockedCount(), + LevelCatalog.all().size()); + } + if (screen == Screen.SETTINGS) { + return AccessibilityScreenDescription.settings(strings, controlSettings, + accessibilitySettings); + } + if (screen == Screen.COMPLETE) { + return AccessibilityScreenDescription.complete(strings, level, deaths, + GameMath.formatTime((long) (levelElapsed * 1000f)), + levelIndex + 1 < LevelCatalog.all().size()); + } + return AccessibilityScreenDescription.playing(strings, level, deaths, + GameMath.formatTime((long) (levelElapsed * 1000f)), criticalStatusText(), + userPaused); + } + + private String accessibilityStateKey() { + return screen + "|" + userPaused + "|" + deaths + "|" + criticalStatusText() + + "|" + controlSettings.controlSizeLabel() + + "|" + controlSettings.controlOpacityLabel() + + "|" + controlSettings.hapticsEnabled() + + "|" + accessibilitySettings.textSizeLabel() + + "|" + accessibilitySettings.highContrastEnabled() + + "|" + language; + } + + private void refreshAccessibilityDescriptionIfStateChanged() { + String stateKey = accessibilityStateKey(); + if (!stateKey.equals(lastAccessibilityStateKey)) refreshAccessibilityDescription(false); + } + + private void refreshAccessibilityDescription(boolean announce) { + lastAccessibilityStateKey = accessibilityStateKey(); + setContentDescription(currentAccessibilityDescription()); + if (announce && isAttachedToWindow()) { + sendAccessibilityEvent(AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED); + } + } + public void setAutomationInput(boolean moveLeft, boolean moveRight, boolean jump) { boolean jumpWasPressed = jumpPressed; leftPressed = moveLeft; diff --git a/app/src/test/java/com/projectsacrifice/game/AccessibilityScreenDescriptionTest.java b/app/src/test/java/com/projectsacrifice/game/AccessibilityScreenDescriptionTest.java new file mode 100644 index 0000000..8c6398b --- /dev/null +++ b/app/src/test/java/com/projectsacrifice/game/AccessibilityScreenDescriptionTest.java @@ -0,0 +1,35 @@ +package com.projectsacrifice.game; + +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +public final class AccessibilityScreenDescriptionTest { + @Test + public void everyPlayerFacingScreenNamesItsStateAndAvailableActions() { + String levels = AccessibilityScreenDescription.levelSelect(3, 10); + assertTrue(levels.contains("Level select")); + assertTrue(levels.contains("3 of 10 unlocked")); + assertTrue(levels.contains("Settings")); + + String settings = AccessibilityScreenDescription.settings( + ControlSettings.defaults(), AccessibilitySettings.defaults()); + assertTrue(settings.contains("Settings")); + assertTrue(settings.contains("Text size Standard")); + assertTrue(settings.contains("High contrast Off")); + assertTrue(settings.contains("Back")); + + String playing = AccessibilityScreenDescription.playing( + LevelCatalog.get(0), 2, "01:23", "Signals 1/1", false); + assertTrue(playing.contains("Playing level 1")); + assertTrue(playing.contains("2 volunteers")); + assertTrue(playing.contains("Signals 1/1")); + assertTrue(playing.contains("Move left, move right, jump, restart, pause")); + + String complete = AccessibilityScreenDescription.complete( + LevelCatalog.get(0), 2, "01:23", true); + assertTrue(complete.contains("Level complete")); + assertTrue(complete.contains("Replay")); + assertTrue(complete.contains("Next level")); + } +} diff --git a/app/src/test/java/com/projectsacrifice/game/AccessibilitySettingsTest.java b/app/src/test/java/com/projectsacrifice/game/AccessibilitySettingsTest.java new file mode 100644 index 0000000..2cc0859 --- /dev/null +++ b/app/src/test/java/com/projectsacrifice/game/AccessibilitySettingsTest.java @@ -0,0 +1,53 @@ +package com.projectsacrifice.game; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import org.junit.Test; + +public final class AccessibilitySettingsTest { + @Test + public void textSizeCyclesBetweenReadablePresetsAndWraps() { + AccessibilitySettings settings = AccessibilitySettings.defaults(); + + assertEquals("Standard", settings.textSizeLabel()); + assertEquals(1f, settings.textScale(), 0f); + + settings = settings.nextTextSize(); + assertEquals("Large", settings.textSizeLabel()); + assertEquals(1.25f, settings.textScale(), 0f); + + assertEquals("Standard", settings.nextTextSize().textSizeLabel()); + } + + @Test + public void highContrastIsExplicitAndReversible() { + AccessibilitySettings settings = AccessibilitySettings.defaults(); + + assertFalse(settings.highContrastEnabled()); + assertEquals("Off", settings.highContrastLabel()); + + settings = settings.toggleHighContrast(); + assertTrue(settings.highContrastEnabled()); + assertEquals("On", settings.highContrastLabel()); + assertFalse(settings.toggleHighContrast().highContrastEnabled()); + } + + @Test + public void invalidPersistedPresetFallsBackWithoutChangingContrastChoice() { + AccessibilitySettings settings = AccessibilitySettings.restore(99, true); + + assertEquals("Standard", settings.textSizeLabel()); + assertTrue(settings.highContrastEnabled()); + } + + @Test + public void largePresetScalesEveryCanvasTextRequestWithoutMovingLayout() { + AccessibilitySettings large = AccessibilitySettings.defaults().nextTextSize(); + + assertEquals(30f, large.canvasTextSize(24f), 0f); + assertEquals(80f, large.canvasTextSize(64f), 0f); + assertEquals(24f, AccessibilitySettings.defaults().canvasTextSize(24f), 0f); + } + +} diff --git a/app/src/test/java/com/projectsacrifice/game/AccessibilityStatusTest.java b/app/src/test/java/com/projectsacrifice/game/AccessibilityStatusTest.java new file mode 100644 index 0000000..49c1d9b --- /dev/null +++ b/app/src/test/java/com/projectsacrifice/game/AccessibilityStatusTest.java @@ -0,0 +1,19 @@ +package com.projectsacrifice.game; + +import static org.junit.Assert.assertEquals; + +import org.junit.Test; + +public final class AccessibilityStatusTest { + @Test + public void criticalGameplayStateIsRepresentedByTextNotOnlyColorOrVibration() { + assertEquals( + "Signals 1/2 · Doors 0/1 · Volunteer respawning", + AccessibilityStatus.describe(1, 2, 0, 1, false) + ); + assertEquals( + "Signals 0/0 · Doors 0/0 · Volunteer active", + AccessibilityStatus.describe(0, 0, 0, 0, true) + ); + } +} diff --git a/docs/STAGE3_ACCESSIBILITY_QA.md b/docs/STAGE3_ACCESSIBILITY_QA.md new file mode 100644 index 0000000..52f4603 --- /dev/null +++ b/docs/STAGE3_ACCESSIBILITY_QA.md @@ -0,0 +1,56 @@ +# Stage 3 accessibility QA + +Дата: 18 августа 2026 года. Slice: масштаб текста, high contrast, non-audio state +и семантика Canvas-экранов. + +## Реализованный контракт + +- Settings содержит сохраняемые пресеты `Text size: Standard/Large` + (`1.0×/1.25×`) и `High contrast: Off/On`; +- настройки хранятся отдельно от control settings и progress, поэтому миграция не + меняет существующие сохранения; +- оба общих Canvas text helper применяют выбранный масштаб ко всему тексту; + viewport и hit mapping остаются неизменными, а фиксированные HUD/card/button + области обрезают переполнение внутри своих границ; +- high contrast использует чёрный фон, непрозрачные кнопки, белые контуры и + бело-жёлтый текст; активные механизмы не различаются только цветом; +- `ON/OFF`, `UP/DOWN`, `OPEN/LOCKED` и постоянная строка + `Signals · Doors · Volunteer` дают видимое представление критического состояния + при выключенной вибрации; +- content description меняется для level select, settings, playing, pause и + complete; системный Back сохраняет прежний детерминированный контракт; +- read-only automation surface: `textScaleForAutomation()`, + `highContrastForAutomation()`, `screenDescriptionForAutomation()`, + `criticalVisibleStatusForAutomation()` и `safeViewportForAutomation()`. + +## TDD evidence + +- RED: `AccessibilitySettings` отсутствовал, focused JVM-test завершался шестью + `cannot find symbol`; +- GREEN: `Standard/Large`, `Off/On` и безопасное восстановление persisted index; +- RED → GREEN отдельными циклами: `highContrastEnabled`, `restore`, + `canvasTextSize`, `AccessibilityStatus.describe` и + `AccessibilityScreenDescription`; +- RED device compile: отсутствовали persisted/public Settings surface, + screen transition и safe-viewport hooks; +- GREEN device compile: пять focused Android acceptance методов компилируются и + проверяют recreation, реальные Settings taps, screen/Back semantics, + неизменный safe viewport и textual state при `Vibration: Off`. + +## Проверки этого isolated slice + +- focused accessibility JVM tests — GREEN; +- все JVM tests — обязательный gate перед коммитом; +- `lintDebug`, `assembleDebug`, `compileDebugAndroidTestJavaWithJavac` — обязательный + gate перед коммитом; +- device execution намеренно оставлен финальной интеграционной ветке: этот stream + не запускает и не меняет общий emulator. + +## Интеграционные замечания + +- `GameView` одновременно изменяется onboarding/result и localization slices; + при merge нужно сохранить единые text helpers и вызывать refresh semantics после + всех новых screen transitions; +- localized строки должны заменить английский текст и в Canvas, и в content + descriptions одним источником, не удаляя read-only acceptance hooks; +- minSdk 23, control settings, progress format и Stage 2 physics/levels не менялись. From de3b9485c1f10a41a617e1d9623e6b0a3dab1731 Mon Sep 17 00:00:00 2001 From: "E.Gavrilov" Date: Tue, 18 Aug 2026 16:54:24 +0300 Subject: [PATCH 04/13] =?UTF-8?q?=D0=9F=D1=80=D0=BE=D0=B2=D0=B5=D1=80?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20=D1=81=D0=BE=D0=B2=D0=BC=D0=B5=D1=81=D1=82?= =?UTF-8?q?=D0=B8=D0=BC=D0=BE=D1=81=D1=82=D1=8C=20=D1=84=D1=83=D0=BD=D0=BA?= =?UTF-8?q?=D1=86=D0=B8=D0=B9=20=D1=8D=D1=82=D0=B0=D0=BF=D0=B0=203?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../game/AccessibilityPersistenceTest.java | 3 + .../game/ControlSettingsPersistenceTest.java | 6 +- .../game/StageThreeAcceptanceTest.java | 146 ++++++++++++++++++ 3 files changed, 152 insertions(+), 3 deletions(-) create mode 100644 app/src/androidTest/java/com/projectsacrifice/game/StageThreeAcceptanceTest.java diff --git a/app/src/androidTest/java/com/projectsacrifice/game/AccessibilityPersistenceTest.java b/app/src/androidTest/java/com/projectsacrifice/game/AccessibilityPersistenceTest.java index d5635dc..0ea1dde 100644 --- a/app/src/androidTest/java/com/projectsacrifice/game/AccessibilityPersistenceTest.java +++ b/app/src/androidTest/java/com/projectsacrifice/game/AccessibilityPersistenceTest.java @@ -100,6 +100,8 @@ public final class AccessibilityPersistenceTest { try (ActivityScenario activity = ActivityScenario.launch(MainActivity.class)) { activity.onActivity(current -> { GameView view = current.gameViewForAutomation(); + view.resetControlSettingsForAutomation(); + view.toggleHapticsForAutomation(); view.resetAccessibilitySettingsForAutomation(); view.openSettingsForAutomation(); }); @@ -113,6 +115,7 @@ public final class AccessibilityPersistenceTest { assertTrue(view.highContrastForAutomation()); assertTrue(view.screenDescriptionForAutomation().contains("Text size Large")); assertTrue(view.screenDescriptionForAutomation().contains("High contrast On")); + view.resetControlSettingsForAutomation(); view.resetAccessibilitySettingsForAutomation(); }); } diff --git a/app/src/androidTest/java/com/projectsacrifice/game/ControlSettingsPersistenceTest.java b/app/src/androidTest/java/com/projectsacrifice/game/ControlSettingsPersistenceTest.java index d22f237..c94b5e0 100644 --- a/app/src/androidTest/java/com/projectsacrifice/game/ControlSettingsPersistenceTest.java +++ b/app/src/androidTest/java/com/projectsacrifice/game/ControlSettingsPersistenceTest.java @@ -22,13 +22,13 @@ public final class ControlSettingsPersistenceTest { try (ActivityScenario activity = ActivityScenario.launch(MainActivity.class)) { activity.onActivity(current -> current.gameViewForAutomation() .resetControlSettingsForAutomation()); - - tap(activity, 1_700f, 115f); + activity.onActivity(current -> current.gameViewForAutomation() + .openSettingsForAutomation()); activity.onActivity(current -> assertTrue(current.gameViewForAutomation() .settingsVisibleForAutomation())); + tap(activity, 1_250f, 685f); tap(activity, 1_250f, 345f); tap(activity, 1_250f, 515f); - tap(activity, 1_250f, 685f); activity.recreate(); diff --git a/app/src/androidTest/java/com/projectsacrifice/game/StageThreeAcceptanceTest.java b/app/src/androidTest/java/com/projectsacrifice/game/StageThreeAcceptanceTest.java new file mode 100644 index 0000000..2852997 --- /dev/null +++ b/app/src/androidTest/java/com/projectsacrifice/game/StageThreeAcceptanceTest.java @@ -0,0 +1,146 @@ +package com.projectsacrifice.game; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import android.os.SystemClock; +import android.view.MotionEvent; + +import androidx.test.core.app.ActivityScenario; +import androidx.test.ext.junit.runners.AndroidJUnit4; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.util.List; + +@RunWith(AndroidJUnit4.class) +public final class StageThreeAcceptanceTest { + @Test + public void localizedAccessibleSettingsSurviveRecreationWithoutChangingProgress() { + try (ActivityScenario activity = ActivityScenario.launch(MainActivity.class)) { + int[] unlocked = new int[1]; + int[] bestDeaths = new int[1]; + long[] bestTime = new long[1]; + + activity.onActivity(owner -> { + GameView view = owner.gameViewForAutomation(); + view.resetLanguageForAutomation(); + view.resetControlSettingsForAutomation(); + view.toggleHapticsForAutomation(); + view.resetAccessibilitySettingsForAutomation(); + ProgressStore progress = new ProgressStore(owner); + unlocked[0] = progress.unlockedCount(); + bestDeaths[0] = progress.bestDeaths(1); + bestTime[0] = progress.bestTimeMs(1); + }); + + tap(activity, 1_700f, 115f); // Settings. + tap(activity, 1_700f, 115f); // RU. + tap(activity, 220f, 345f); // Large text. + tap(activity, 220f, 515f); // High contrast. + tap(activity, 1_250f, 345f); // Large controls. + tap(activity, 1_250f, 515f); // High control opacity. + + activity.onActivity(owner -> { + GameView view = owner.gameViewForAutomation(); + assertEquals(GameLanguage.RU, view.languageForAutomation()); + assertEquals(1.25f, view.textScaleForAutomation(), 0f); + assertTrue(view.highContrastForAutomation()); + assertTrue(view.visibleLocalizedTextForAutomation().contains("ДОСТУПНОСТЬ")); + assertTrue(view.visibleLocalizedTextForAutomation().contains("Крупный")); + assertTrue(view.screenDescriptionForAutomation().contains("Настройки")); + }); + + activity.recreate(); + + activity.onActivity(owner -> { + GameView view = owner.gameViewForAutomation(); + assertEquals(GameLanguage.RU, view.languageForAutomation()); + assertEquals(1.25f, view.textScaleForAutomation(), 0f); + assertTrue(view.highContrastForAutomation()); + assertEquals("Large", view.controlSettingsForAutomation().controlSizeLabel()); + assertEquals("High", view.controlSettingsForAutomation().controlOpacityLabel()); + assertFalse(view.controlSettingsForAutomation().hapticsEnabled()); + + ProgressStore progress = new ProgressStore(owner); + assertEquals(unlocked[0], progress.unlockedCount()); + assertEquals(bestDeaths[0], progress.bestDeaths(1)); + assertEquals(bestTime[0], progress.bestTimeMs(1)); + + view.resetLanguageForAutomation(); + view.resetControlSettingsForAutomation(); + view.resetAccessibilitySettingsForAutomation(); + }); + } + } + + @Test + public void russianTutorialAndResultRemainNonBlockingAndExposeEveryOutcome() { + try (ActivityScenario activity = ActivityScenario.launch(MainActivity.class)) { + activity.onActivity(owner -> { + GameView view = owner.gameViewForAutomation(); + view.setLanguageForAutomation(GameLanguage.RU); + view.resetOnboardingForAutomation(); + view.startLevelForAutomation(1); + + assertTrue(view.onboardingVisibleForAutomation()); + assertContainsPrefix(view.visibleLocalizedTextForAutomation(), "ДВИЖЕНИЕ"); + view.setAutomationInput(false, true, false); + assertTrue(view.automationSnapshot().inputActive()); + + view.advanceOnboardingForAutomation(); + assertContainsPrefix(view.visibleLocalizedTextForAutomation(), + "ДВИЖЕНИЕ + ПРЫЖОК"); + view.advanceOnboardingForAutomation(); + assertContainsPrefix(view.visibleLocalizedTextForAutomation(), + "СМЕРТЬ ОСТАВЛЯЕТ ИНСТРУМЕНТ"); + view.advanceOnboardingForAutomation(); + assertFalse(view.onboardingVisibleForAutomation()); + + view.finishLevelForAutomation(2, 42_000L); + List result = view.visibleLocalizedTextForAutomation(); + assertContainsPrefix(result, "Текущий результат"); + assertContainsPrefix(result, "Лучший результат"); + assertContainsPrefix(result, "Цели"); + assertContainsPrefix(result, "Следующая цель"); + assertTrue(view.screenDescriptionForAutomation().contains("Уровень завершён")); + view.setLanguageForAutomation(GameLanguage.EN); + }); + + activity.recreate(); + + activity.onActivity(owner -> { + GameView view = owner.gameViewForAutomation(); + view.startLevelForAutomation(1); + assertFalse(view.onboardingVisibleForAutomation()); + assertEquals(GameLanguage.EN, view.languageForAutomation()); + view.setAutomationInput(false, false, false); + }); + } + } + + private static void assertContainsPrefix(List values, String prefix) { + for (String value : values) { + if (value.startsWith(prefix)) return; + } + throw new AssertionError("Missing prefix '" + prefix + "' in " + values); + } + + private static void tap(ActivityScenario activity, float canvasX, float canvasY) { + activity.onActivity(owner -> { + GameView view = owner.gameViewForAutomation(); + float scale = Math.min(view.getWidth() / 1_920f, view.getHeight() / 1_080f); + float x = (view.getWidth() - 1_920f * scale) * 0.5f + canvasX * scale; + float y = (view.getHeight() - 1_080f * scale) * 0.5f + canvasY * scale; + long now = SystemClock.uptimeMillis(); + MotionEvent down = MotionEvent.obtain(now, now, MotionEvent.ACTION_DOWN, x, y, 0); + view.dispatchTouchEvent(down); + down.recycle(); + MotionEvent up = MotionEvent.obtain(now, now, MotionEvent.ACTION_UP, x, y, 0); + view.dispatchTouchEvent(up); + up.recycle(); + }); + } +} From 88a1ba9b34a2b50ee4db3e3b970ef914a8c5d2b4 Mon Sep 17 00:00:00 2001 From: "E.Gavrilov" Date: Tue, 18 Aug 2026 17:15:05 +0300 Subject: [PATCH 05/13] =?UTF-8?q?=D0=A3=D0=B1=D1=80=D0=B0=D1=82=D1=8C=20?= =?UTF-8?q?=D0=B0=D0=BB=D0=BB=D0=BE=D0=BA=D0=B0=D1=86=D0=B8=D0=B8=20=D0=B8?= =?UTF-8?q?=D0=B7=20=D0=B8=D0=B3=D1=80=D0=BE=D0=B2=D0=BE=D0=B3=D0=BE=20hot?= =?UTF-8?q?=20path?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../game/AccessibilityStateCache.java | 58 +++++++++ .../com/projectsacrifice/game/GameView.java | 122 +++++++++++++----- .../game/AccessibilityStateCacheTest.java | 47 +++++++ 3 files changed, 196 insertions(+), 31 deletions(-) create mode 100644 app/src/main/java/com/projectsacrifice/game/AccessibilityStateCache.java create mode 100644 app/src/test/java/com/projectsacrifice/game/AccessibilityStateCacheTest.java diff --git a/app/src/main/java/com/projectsacrifice/game/AccessibilityStateCache.java b/app/src/main/java/com/projectsacrifice/game/AccessibilityStateCache.java new file mode 100644 index 0000000..b58d20e --- /dev/null +++ b/app/src/main/java/com/projectsacrifice/game/AccessibilityStateCache.java @@ -0,0 +1,58 @@ +package com.projectsacrifice.game; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** Allocation-free critical-state comparison after a level has been configured. */ +final class AccessibilityStateCache { + private List signalIds = Collections.emptyList(); + private GameLanguage language; + private int activeSignals = Integer.MIN_VALUE; + private int openDoors = Integer.MIN_VALUE; + private int totalDoors = Integer.MIN_VALUE; + private boolean playerAlive; + private String statusText; + + void configureSignals(List sourceIds) { + ArrayList unique = new ArrayList<>(); + for (String signalId : sourceIds) { + if (!unique.contains(signalId)) unique.add(signalId); + } + signalIds = Collections.unmodifiableList(unique); + language = null; + activeSignals = Integer.MIN_VALUE; + openDoors = Integer.MIN_VALUE; + totalDoors = Integer.MIN_VALUE; + statusText = null; + } + + List signalIds() { + return signalIds; + } + + boolean update(GameStrings strings, int updatedActiveSignals, int updatedOpenDoors, + int updatedTotalDoors, boolean updatedPlayerAlive) { + GameLanguage updatedLanguage = strings.language(); + if (statusText != null + && language == updatedLanguage + && activeSignals == updatedActiveSignals + && openDoors == updatedOpenDoors + && totalDoors == updatedTotalDoors + && playerAlive == updatedPlayerAlive) { + return false; + } + language = updatedLanguage; + activeSignals = updatedActiveSignals; + openDoors = updatedOpenDoors; + totalDoors = updatedTotalDoors; + playerAlive = updatedPlayerAlive; + statusText = AccessibilityStatus.describe(strings, activeSignals, signalIds.size(), + openDoors, totalDoors, playerAlive); + return true; + } + + String statusText() { + return statusText; + } +} diff --git a/app/src/main/java/com/projectsacrifice/game/GameView.java b/app/src/main/java/com/projectsacrifice/game/GameView.java index b514c8f..900edb5 100644 --- a/app/src/main/java/com/projectsacrifice/game/GameView.java +++ b/app/src/main/java/com/projectsacrifice/game/GameView.java @@ -18,12 +18,10 @@ import android.view.accessibility.AccessibilityEvent; import java.util.ArrayList; import java.util.Collections; -import java.util.HashSet; import java.util.HashMap; import java.util.List; import java.util.Locale; import java.util.Map; -import java.util.Set; /** * A dependency-free 2D puzzle-platformer renderer and deterministic physics loop. @@ -175,6 +173,8 @@ public final class GameView extends View implements Runnable { private final Map toggleSignals = new HashMap<>(); private final Map timedSignals = new HashMap<>(); private final Map previousSwitchContact = new HashMap<>(); + private final AccessibilityStateCache accessibilityStateCache = + new AccessibilityStateCache(); private Screen screen = Screen.LEVELS; private LevelDefinition level; @@ -199,7 +199,16 @@ public final class GameView extends View implements Runnable { private GameLanguage language; private GameStrings strings; private AccessibilitySettings accessibilitySettings; - private String lastAccessibilityStateKey = ""; + private String cachedLevelHeader = ""; + private String cachedLevelHint = ""; + private float cachedLevelHintSize = 25f; + private int cachedHudLevelNumber = -1; + private GameLanguage cachedHudLanguage; + private float cachedHudTextScale = Float.NaN; + private String cachedHudStats = ""; + private long cachedHudStatsSecond = Long.MIN_VALUE; + private int cachedHudStatsDeaths = Integer.MIN_VALUE; + private GameLanguage cachedHudStatsLanguage; public GameView(Context context) { super(context); @@ -289,6 +298,8 @@ public final class GameView extends View implements Runnable { timedSignals.clear(); previousSwitchContact.clear(); completionResult = null; + configureAccessibilitySignals(); + invalidateHudTextCache(); for (LevelDefinition.Platform item : level.platforms) platformStates.add(new PlatformState(item)); spawnPlayer(); lastFrameNanos = System.nanoTime(); @@ -978,20 +989,19 @@ public final class GameView extends View implements Runnable { } private void drawHud(Canvas canvas) { + ensureHudTextCache(); paint.setColor(Color.argb(178, 15, 20, 34)); canvas.drawRoundRect(new RectF(28, 26, 940, 138), 24, 24, paint); canvas.save(); canvas.clipRect(40, 30, 925, 135); - drawText(canvas, String.format(Locale.US, "%02d %s", level.number, - strings.levelTitle(level.number)), 56, 72, 34, - Color.rgb(248, 220, 151), false); - drawFittedText(canvas, strings.levelHint(level.number), 56, 116, 25, 16, - 850, Color.rgb(231, 231, 235), false); + drawText(canvas, cachedLevelHeader, 56, 72, 34, Color.rgb(248, 220, 151), + false); + drawText(canvas, cachedLevelHint, 56, 116, cachedLevelHintSize, + Color.rgb(231, 231, 235), false); canvas.restore(); canvas.save(); canvas.clipRect(990, 28, 1590, 130); - drawText(canvas, strings.hudStats(deaths, (long) (levelElapsed * 1000)), - 1010, 82, 31, Color.WHITE, false); + drawText(canvas, hudStatsText(), 1010, 82, 31, Color.WHITE, false); canvas.restore(); paint.setColor(accessibilitySettings.highContrastEnabled() ? Color.BLACK : Color.argb(178, 15, 20, 34)); @@ -1743,6 +1753,7 @@ public final class GameView extends View implements Runnable { language = updated == null ? GameLanguage.EN : updated; strings = GameStrings.forLanguage(language); languageStore.save(language); + invalidateHudTextCache(); refreshAccessibilityDescription(false); invalidate(); } @@ -1824,20 +1835,76 @@ public final class GameView extends View implements Runnable { private void updateAccessibilitySettings(AccessibilitySettings updated) { accessibilitySettings = updated; accessibilitySettingsStore.save(updated); + invalidateHudTextCache(); refreshAccessibilityDescription(false); invalidate(); } - private String criticalStatusText() { - if (level == null) return AccessibilityStatus.describe(strings, 0, 0, 0, 0, true); - Set signalIds = new HashSet<>(); + private void invalidateHudTextCache() { + cachedHudLevelNumber = -1; + cachedHudLanguage = null; + cachedHudTextScale = Float.NaN; + cachedHudStatsSecond = Long.MIN_VALUE; + cachedHudStatsDeaths = Integer.MIN_VALUE; + cachedHudStatsLanguage = null; + } + + private void ensureHudTextCache() { + if (level == null) return; + float textScale = accessibilitySettings.textScale(); + if (cachedHudLevelNumber == level.number + && cachedHudLanguage == language + && Float.compare(cachedHudTextScale, textScale) == 0) { + return; + } + cachedHudLevelNumber = level.number; + cachedHudLanguage = language; + cachedHudTextScale = textScale; + cachedLevelHeader = String.format(Locale.US, "%02d %s", level.number, + strings.levelTitle(level.number)); + cachedLevelHint = strings.levelHint(level.number); + cachedLevelHintSize = fittedTextSize(cachedLevelHint, 25f, 16f, 850f, false); + } + + private String hudStatsText() { + long displayedSecond = (long) (levelElapsed * 1000f) / 1000L; + if (cachedHudStatsSecond != displayedSecond + || cachedHudStatsDeaths != deaths + || cachedHudStatsLanguage != language) { + cachedHudStatsSecond = displayedSecond; + cachedHudStatsDeaths = deaths; + cachedHudStatsLanguage = language; + cachedHudStats = strings.hudStats(deaths, displayedSecond * 1000L); + } + return cachedHudStats; + } + + private void configureAccessibilitySignals() { + ArrayList signalIds = new ArrayList<>(); + if (level != null) { + for (LevelDefinition.Switch item : level.switches) signalIds.add(item.signalId); + } + accessibilityStateCache.configureSignals(signalIds); + } + + private boolean updateCriticalStatusCache() { int activeSignals = 0; - for (LevelDefinition.Switch item : level.switches) signalIds.add(item.signalId); - for (String signalId : signalIds) if (signalActive(signalId)) activeSignals++; + if (level != null) { + for (String signalId : accessibilityStateCache.signalIds()) { + if (signalActive(signalId)) activeSignals++; + } + } int openDoors = 0; - for (LevelDefinition.Door door : level.doors) if (doorOpen(door)) openDoors++; - return AccessibilityStatus.describe(strings, activeSignals, signalIds.size(), openDoors, - level.doors.size(), player.alive); + if (level != null) { + for (LevelDefinition.Door door : level.doors) if (doorOpen(door)) openDoors++; + } + return accessibilityStateCache.update(strings, activeSignals, openDoors, + level == null ? 0 : level.doors.size(), level == null || player.alive); + } + + private String criticalStatusText() { + if (accessibilityStateCache.statusText() == null) updateCriticalStatusCache(); + return accessibilityStateCache.statusText(); } private String currentAccessibilityDescription() { @@ -1859,23 +1926,16 @@ public final class GameView extends View implements Runnable { userPaused); } - private String accessibilityStateKey() { - return screen + "|" + userPaused + "|" + deaths + "|" + criticalStatusText() - + "|" + controlSettings.controlSizeLabel() - + "|" + controlSettings.controlOpacityLabel() - + "|" + controlSettings.hapticsEnabled() - + "|" + accessibilitySettings.textSizeLabel() - + "|" + accessibilitySettings.highContrastEnabled() - + "|" + language; - } - private void refreshAccessibilityDescriptionIfStateChanged() { - String stateKey = accessibilityStateKey(); - if (!stateKey.equals(lastAccessibilityStateKey)) refreshAccessibilityDescription(false); + if (updateCriticalStatusCache()) applyAccessibilityDescription(false); } private void refreshAccessibilityDescription(boolean announce) { - lastAccessibilityStateKey = accessibilityStateKey(); + updateCriticalStatusCache(); + applyAccessibilityDescription(announce); + } + + private void applyAccessibilityDescription(boolean announce) { setContentDescription(currentAccessibilityDescription()); if (announce && isAttachedToWindow()) { sendAccessibilityEvent(AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED); diff --git a/app/src/test/java/com/projectsacrifice/game/AccessibilityStateCacheTest.java b/app/src/test/java/com/projectsacrifice/game/AccessibilityStateCacheTest.java new file mode 100644 index 0000000..73de1c1 --- /dev/null +++ b/app/src/test/java/com/projectsacrifice/game/AccessibilityStateCacheTest.java @@ -0,0 +1,47 @@ +package com.projectsacrifice.game; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.assertNotSame; + +import java.util.Arrays; + +import org.junit.Test; + +public final class AccessibilityStateCacheTest { + @Test + public void unchangedFramesReuseOneStatusAndOneDeduplicatedSignalSet() { + AccessibilityStateCache cache = new AccessibilityStateCache(); + cache.configureSignals(Arrays.asList("lift", "lift", "door")); + + assertEquals(Arrays.asList("lift", "door"), cache.signalIds()); + assertTrue(cache.update(GameStrings.forLanguage(GameLanguage.EN), 1, 0, 1, true)); + String first = cache.statusText(); + + for (int frame = 0; frame < 600; frame++) { + assertFalse(cache.update(GameStrings.forLanguage(GameLanguage.EN), 1, 0, 1, true)); + assertSame(first, cache.statusText()); + } + } + + @Test + public void criticalStateAndLanguageChangesInvalidateExactlyOnce() { + AccessibilityStateCache cache = new AccessibilityStateCache(); + cache.configureSignals(Arrays.asList("plate")); + cache.update(GameStrings.forLanguage(GameLanguage.EN), 0, 0, 1, true); + String initial = cache.statusText(); + + assertTrue(cache.update(GameStrings.forLanguage(GameLanguage.EN), 1, 1, 1, false)); + String changed = cache.statusText(); + assertNotSame(initial, changed); + assertTrue(changed.contains("Signals 1/1")); + assertTrue(changed.contains("Volunteer respawning")); + assertFalse(cache.update(GameStrings.forLanguage(GameLanguage.EN), 1, 1, 1, false)); + + assertTrue(cache.update(GameStrings.forLanguage(GameLanguage.RU), 1, 1, 1, false)); + assertTrue(cache.statusText().contains("Сигналы 1/1")); + assertFalse(cache.update(GameStrings.forLanguage(GameLanguage.RU), 1, 1, 1, false)); + } +} From 650bfb3533bdec6dd27a7854fb6baa9a60ba3f26 Mon Sep 17 00:00:00 2001 From: "E.Gavrilov" Date: Tue, 18 Aug 2026 17:13:34 +0300 Subject: [PATCH 06/13] =?UTF-8?q?=D0=A1=D1=82=D0=B0=D0=B1=D0=B8=D0=BB?= =?UTF-8?q?=D0=B8=D0=B7=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D1=82=D1=8C=20=D0=B1?= =?UTF-8?q?=D0=BE=D1=82=D0=BE=D0=B2=20=D0=BF=D1=80=D0=B8=20=D0=BC=D0=B5?= =?UTF-8?q?=D0=B4=D0=BB=D0=B5=D0=BD=D0=BD=D0=BE=D0=BC=20=D1=80=D0=B5=D0=BD?= =?UTF-8?q?=D0=B4=D0=B5=D1=80=D0=B8=D0=BD=D0=B3=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../game/EarlyLevelScenarios.java | 11 ++- .../game/LevelPlaytestBot.java | 59 ++++++++++++-- .../game/LevelPlaytestBotTest.java | 79 +++++++++++++++++++ 3 files changed, 138 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/com/projectsacrifice/game/EarlyLevelScenarios.java b/app/src/main/java/com/projectsacrifice/game/EarlyLevelScenarios.java index a3359ce..503f520 100644 --- a/app/src/main/java/com/projectsacrifice/game/EarlyLevelScenarios.java +++ b/app/src/main/java/com/projectsacrifice/game/EarlyLevelScenarios.java @@ -45,7 +45,9 @@ public final class EarlyLevelScenarios { EarlyLevelScenarios::respawnedAtStart) .step("Approach the completed bridge", 3_000L, LevelPlaytestBot.Input.right(), snapshot -> snapshot.playerX() >= 470f) - .step("Cross the corpse bridge", 3_000L, LevelPlaytestBot.Input.rightJump(), + .step("Land on the far bridge body", 3_000L, LevelPlaytestBot.Input.rightJump(), + snapshot -> snapshot.playerOnGround() && snapshot.playerX() >= 800f) + .step("Jump from the bridge to safe floor", 3_000L, LevelPlaytestBot.Input.rightJump(), snapshot -> snapshot.playerX() >= 900f) .step("Release jump before the final ledge", 2_000L, LevelPlaytestBot.Input.right(), snapshot -> snapshot.playerX() >= 1_030f) @@ -65,7 +67,7 @@ public final class EarlyLevelScenarios { .step("Wait for the next volunteer", 2_000L, LevelPlaytestBot.Input.none(), EarlyLevelScenarios::respawnedAtStart) .step("Approach the saw", 3_000L, LevelPlaytestBot.Input.right(), - snapshot -> snapshot.playerX() >= 390f) + snapshot -> snapshot.playerX() >= 300f) .step("Jump over the saw and plate body", 3_000L, LevelPlaytestBot.Input.rightJump(), snapshot -> snapshot.playerX() >= 700f) .step("Reach the relic through the open door", 5_000L, LevelPlaytestBot.Input.right(), @@ -76,7 +78,10 @@ public final class EarlyLevelScenarios { public static LevelPlaytestBot.Scenario levelFour() { return LevelPlaytestBot.Scenario.builder(4, "Level 4: toggle lift to gallery") .step("Cross the toggle once and activate the lift", 3_000L, LevelPlaytestBot.Input.right(), - snapshot -> snapshot.playerX() >= 800f) + GameView.AutomationSnapshot::firstMovingPlatformPowered) + .step("Approach the powered moving lift", 3_000L, LevelPlaytestBot.Input.right(), + snapshot -> snapshot.firstMovingPlatformPowered() + && snapshot.playerX() >= 800f) .step("Jump onto the moving lift", 3_000L, LevelPlaytestBot.Input.rightJump(), snapshot -> snapshot.playerX() >= 1_050f && snapshot.playerY() < 690f) .step("Ride the lift to its upper phase", 7_000L, LevelPlaytestBot.Input.none(), diff --git a/app/src/main/java/com/projectsacrifice/game/LevelPlaytestBot.java b/app/src/main/java/com/projectsacrifice/game/LevelPlaytestBot.java index e04a067..343fbd0 100644 --- a/app/src/main/java/com/projectsacrifice/game/LevelPlaytestBot.java +++ b/app/src/main/java/com/projectsacrifice/game/LevelPlaytestBot.java @@ -6,6 +6,9 @@ import java.util.List; /** Runs black-box acceptance scenarios through the same input and state boundary as touch play. */ public final class LevelPlaytestBot { + private static final long MIN_WALL_WATCHDOG_MILLIS = 120_000L; + private static final long WALL_WATCHDOG_MULTIPLIER = 10L; + public interface Driver { void startLevel(int oneBasedLevelNumber); void setInput(boolean moveLeft, boolean moveRight, boolean jump); @@ -123,22 +126,38 @@ public final class LevelPlaytestBot { public Result run(Driver driver, Scenario scenario, long timeoutMillis) { driver.startLevel(scenario.levelNumber); - long scenarioStartedAt = driver.currentTimeMillis(); - long stepStartedAt = scenarioStartedAt; + long scenarioStartedAtWall = driver.currentTimeMillis(); + long wallWatchdogMillis = wallWatchdogMillis(timeoutMillis); int stepIndex = 0; String lastStep = scenario.steps.isEmpty() ? null : scenario.steps.get(0).name; boolean doorOpened = false; GameView.AutomationSnapshot last = driver.snapshot(); + long scenarioStartedAtSimulation = last.elapsedMilliseconds(); + long stepStartedAtSimulation = scenarioStartedAtSimulation; - while (driver.currentTimeMillis() - scenarioStartedAt < timeoutMillis) { + while (true) { last = driver.snapshot(); + long wallElapsed = elapsed(driver.currentTimeMillis(), scenarioStartedAtWall); + long simulationElapsed = elapsed( + last.elapsedMilliseconds(), scenarioStartedAtSimulation); doorOpened |= last.allDoorsOpen(); if (last.completed()) { driver.setInput(false, false, false); return new Result(true, doorOpened, last.deaths(), last.corpseCount(), - scenario.name + " completed; step=" + lastStep + "; " + snapshotState(last), + scenario.name + " completed; step=" + lastStep + "; " + + timingState(simulationElapsed, wallElapsed) + "; " + + snapshotState(last), lastStep); } + if (wallElapsed >= wallWatchdogMillis) { + driver.setInput(false, false, false); + return new Result(false, doorOpened, last.deaths(), last.corpseCount(), + scenario.name + " stopped by wall-clock watchdog; step=" + lastStep + + "; " + timingState(simulationElapsed, wallElapsed) + "; " + + snapshotState(last), + lastStep); + } + if (simulationElapsed >= timeoutMillis) break; if (stepIndex >= scenario.steps.size()) break; Step step = scenario.steps.get(stepIndex); @@ -146,13 +165,16 @@ public final class LevelPlaytestBot { if (step.completion.reached(last)) { driver.setInput(false, false, false); stepIndex++; - stepStartedAt = driver.currentTimeMillis(); + stepStartedAtSimulation = last.elapsedMilliseconds(); continue; } - if (driver.currentTimeMillis() - stepStartedAt >= step.timeoutMillis) { + if (elapsed(last.elapsedMilliseconds(), stepStartedAtSimulation) + >= step.timeoutMillis) { driver.setInput(false, false, false); return new Result(false, doorOpened, last.deaths(), last.corpseCount(), - scenario.name + " timed out; step=" + step.name + "; " + snapshotState(last), + scenario.name + " timed out in simulation; step=" + step.name + + "; " + timingState(simulationElapsed, wallElapsed) + "; " + + snapshotState(last), step.name); } @@ -161,11 +183,32 @@ public final class LevelPlaytestBot { } driver.setInput(false, false, false); + long wallElapsed = elapsed(driver.currentTimeMillis(), scenarioStartedAtWall); + long simulationElapsed = elapsed( + last.elapsedMilliseconds(), scenarioStartedAtSimulation); return new Result(false, doorOpened, last.deaths(), last.corpseCount(), - scenario.name + " did not complete; step=" + lastStep + "; " + snapshotState(last), + scenario.name + " exhausted its simulation budget; step=" + lastStep + + "; " + timingState(simulationElapsed, wallElapsed) + "; " + + snapshotState(last), lastStep); } + private static long wallWatchdogMillis(long simulationBudgetMillis) { + if (simulationBudgetMillis > Long.MAX_VALUE / WALL_WATCHDOG_MULTIPLIER) { + return Long.MAX_VALUE; + } + return Math.max(MIN_WALL_WATCHDOG_MILLIS, + simulationBudgetMillis * WALL_WATCHDOG_MULTIPLIER); + } + + private static long elapsed(long current, long startedAt) { + return Math.max(0L, current - startedAt); + } + + private static String timingState(long simulationElapsed, long wallElapsed) { + return "simulationElapsed=" + simulationElapsed + ", wallElapsed=" + wallElapsed; + } + private static String snapshotState(GameView.AutomationSnapshot snapshot) { return "x=" + snapshot.playerX() + ", y=" + snapshot.playerY() diff --git a/app/src/test/java/com/projectsacrifice/game/LevelPlaytestBotTest.java b/app/src/test/java/com/projectsacrifice/game/LevelPlaytestBotTest.java index 88eaa36..b7fa858 100644 --- a/app/src/test/java/com/projectsacrifice/game/LevelPlaytestBotTest.java +++ b/app/src/test/java/com/projectsacrifice/game/LevelPlaytestBotTest.java @@ -68,6 +68,41 @@ public final class LevelPlaytestBotTest { assertTrue(result.diagnostic(), result.diagnostic().contains("allDoorsOpen=false")); } + @Test + public void slowRenderingUsesSimulationElapsedBudgetsInsteadOfHostTime() { + SlowRenderingDriver driver = new SlowRenderingDriver(true); + LevelPlaytestBot.Scenario scenario = LevelPlaytestBot.Scenario + .builder(1, "Slow rendering") + .step("Reach simulated checkpoint", 200L, LevelPlaytestBot.Input.right(), + GameView.AutomationSnapshot::completed) + .build(); + + LevelPlaytestBot.Result result = new LevelPlaytestBot().run(driver, scenario, 500L); + + assertTrue(result.diagnostic(), result.completed()); + assertTrue("The host clock must be allowed to outrun simulated gameplay", + driver.wallMillis > 500L); + assertEquals(96L, driver.simulationMillis); + } + + @Test + public void frozenSimulationStopsAtWallClockWatchdogWithDiagnostic() { + SlowRenderingDriver driver = new SlowRenderingDriver(false); + LevelPlaytestBot.Scenario scenario = LevelPlaytestBot.Scenario + .builder(1, "Frozen rendering") + .step("Wait for simulation", 1_000L, LevelPlaytestBot.Input.right(), + GameView.AutomationSnapshot::completed) + .build(); + + LevelPlaytestBot.Result result = new LevelPlaytestBot().run(driver, scenario, 5_000L); + + assertFalse(result.completed()); + assertEquals("Wait for simulation", result.lastStep()); + assertTrue(result.diagnostic(), result.diagnostic().contains("wall-clock watchdog")); + assertTrue(result.diagnostic(), result.diagnostic().contains("simulationElapsed=0")); + assertTrue(result.diagnostic(), result.diagnostic().contains("wallElapsed=120000")); + } + @Test public void levelFiveUsesTheGenericNamedScenario() { LevelFiveDriver driver = new LevelFiveDriver(); @@ -160,6 +195,50 @@ public final class LevelPlaytestBotTest { } } + private static final class SlowRenderingDriver implements LevelPlaytestBot.Driver { + private final boolean advancesSimulation; + private long wallMillis; + private long simulationMillis; + private boolean movingRight; + private boolean completed; + + SlowRenderingDriver(boolean advancesSimulation) { + this.advancesSimulation = advancesSimulation; + } + + @Override + public void startLevel(int oneBasedLevelNumber) { + wallMillis = 0L; + simulationMillis = 0L; + movingRight = false; + completed = false; + } + + @Override + public void setInput(boolean moveLeft, boolean moveRight, boolean jump) { + movingRight = moveRight; + } + + @Override + public GameView.AutomationSnapshot snapshot() { + return new GameView.AutomationSnapshot( + completed, true, false, false, movingRight, + 1, simulationMillis, 130f, 760f, 0, 0); + } + + @Override + public void waitForNextDecision(long millis) { + wallMillis += advancesSimulation ? 1_000L : 60_000L; + if (advancesSimulation) simulationMillis += millis; + if (movingRight && simulationMillis >= 96L) completed = true; + } + + @Override + public long currentTimeMillis() { + return wallMillis; + } + } + private static final class LevelFiveDriver implements LevelPlaytestBot.Driver { private int startedLevel; private long nowMillis; From da3777b4cc7925a2f19eccd556dd271843a29aea Mon Sep 17 00:00:00 2001 From: "E.Gavrilov" Date: Tue, 18 Aug 2026 17:21:56 +0300 Subject: [PATCH 07/13] =?UTF-8?q?=D0=A1=D1=82=D0=B0=D0=B1=D0=B8=D0=BB?= =?UTF-8?q?=D0=B8=D0=B7=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D1=82=D1=8C=20=D0=BF?= =?UTF-8?q?=D1=80=D0=BE=D1=85=D0=BE=D0=B4=D1=8B=20=D0=BF=D0=B8=D0=BB=D1=8B?= =?UTF-8?q?=20=D1=83=D1=80=D0=BE=D0=B2=D0=BD=D1=8F=2010?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../game/LateLevelScenarios.java | 36 ++++++++++++++----- .../game/LevelPlaytestBot.java | 17 +++++++++ .../game/LevelPlaytestBotTest.java | 35 ++++++++++++++++++ 3 files changed, 79 insertions(+), 9 deletions(-) diff --git a/app/src/androidTest/java/com/projectsacrifice/game/LateLevelScenarios.java b/app/src/androidTest/java/com/projectsacrifice/game/LateLevelScenarios.java index 437fffc..2ca71ce 100644 --- a/app/src/androidTest/java/com/projectsacrifice/game/LateLevelScenarios.java +++ b/app/src/androidTest/java/com/projectsacrifice/game/LateLevelScenarios.java @@ -122,13 +122,19 @@ final class LateLevelScenarios { snapshot -> snapshot.playerAlive() && snapshot.playerX() < 250f) .step("Approach the launch saw", 2_500L, LevelPlaytestBot.Input.right(), - snapshot -> snapshot.playerX() >= 350f) + LevelPlaytestBot.whileDeathsAtMost(1, + LevelPlaytestBot.groundedAliveAtOrBeyond(280f))) .step("Take off before the launch saw", 1_000L, LevelPlaytestBot.Input.rightJump(), - snapshot -> snapshot.playerAlive() && snapshot.playerY() <= 680f) + LevelPlaytestBot.whileDeathsAtMost(1, + snapshot -> snapshot.playerAlive() + && !snapshot.playerOnGround() + && snapshot.playerY() <= 680f)) .step("Clear the launch saw", 2_000L, LevelPlaytestBot.Input.right(), - snapshot -> snapshot.playerAlive() && snapshot.playerX() >= 680f) + LevelPlaytestBot.whileDeathsAtMost(1, + snapshot -> snapshot.playerAlive() + && snapshot.playerX() >= 680f)) .step("Approach the delivered lift body", 1_500L, LevelPlaytestBot.Input.right(), snapshot -> snapshot.playerX() >= 730f) @@ -143,13 +149,19 @@ final class LateLevelScenarios { snapshot -> snapshot.playerAlive() && snapshot.playerX() < 250f) .step("Return to the launch saw", 2_500L, LevelPlaytestBot.Input.right(), - snapshot -> snapshot.playerX() >= 350f) + LevelPlaytestBot.whileDeathsAtMost(2, + LevelPlaytestBot.groundedAliveAtOrBeyond(280f))) .step("Take off before the saw again", 1_000L, LevelPlaytestBot.Input.rightJump(), - snapshot -> snapshot.playerAlive() && snapshot.playerY() <= 680f) + LevelPlaytestBot.whileDeathsAtMost(2, + snapshot -> snapshot.playerAlive() + && !snapshot.playerOnGround() + && snapshot.playerY() <= 680f)) .step("Clear the saw again", 2_000L, LevelPlaytestBot.Input.right(), - snapshot -> snapshot.playerAlive() && snapshot.playerX() >= 680f) + LevelPlaytestBot.whileDeathsAtMost(2, + snapshot -> snapshot.playerAlive() + && snapshot.playerX() >= 680f)) .step("Approach the lift body again", 1_500L, LevelPlaytestBot.Input.right(), snapshot -> snapshot.playerX() >= 730f) @@ -190,13 +202,19 @@ final class LateLevelScenarios { snapshot -> snapshot.playerAlive() && snapshot.playerX() < 250f) .step("Approach the saw for the final crossing", 2_500L, LevelPlaytestBot.Input.right(), - snapshot -> snapshot.playerX() >= 350f) + LevelPlaytestBot.whileDeathsAtMost(3, + LevelPlaytestBot.groundedAliveAtOrBeyond(280f))) .step("Take off for the final saw crossing", 1_000L, LevelPlaytestBot.Input.rightJump(), - snapshot -> snapshot.playerAlive() && snapshot.playerY() <= 680f) + LevelPlaytestBot.whileDeathsAtMost(3, + snapshot -> snapshot.playerAlive() + && !snapshot.playerOnGround() + && snapshot.playerY() <= 680f)) .step("Clear the saw for the final crossing", 2_000L, LevelPlaytestBot.Input.right(), - snapshot -> snapshot.playerAlive() && snapshot.playerX() >= 680f) + LevelPlaytestBot.whileDeathsAtMost(3, + snapshot -> snapshot.playerAlive() + && snapshot.playerX() >= 680f)) .step("Approach the retained lift body", 1_500L, LevelPlaytestBot.Input.right(), snapshot -> snapshot.playerX() >= 730f) diff --git a/app/src/main/java/com/projectsacrifice/game/LevelPlaytestBot.java b/app/src/main/java/com/projectsacrifice/game/LevelPlaytestBot.java index 343fbd0..e0c6c85 100644 --- a/app/src/main/java/com/projectsacrifice/game/LevelPlaytestBot.java +++ b/app/src/main/java/com/projectsacrifice/game/LevelPlaytestBot.java @@ -47,6 +47,23 @@ public final class LevelPlaytestBot { boolean reached(GameView.AutomationSnapshot snapshot); } + public static SnapshotCondition groundedAliveAtOrBeyond(float minimumX) { + return snapshot -> snapshot.playerAlive() + && snapshot.playerOnGround() + && snapshot.playerX() >= minimumX; + } + + public static SnapshotCondition whileDeathsAtMost( + int maximumDeaths, SnapshotCondition completion) { + return snapshot -> { + if (snapshot.deaths() > maximumDeaths) { + throw new AssertionError("Route expected at most " + maximumDeaths + + " deaths but observed " + snapshot.deaths()); + } + return completion.reached(snapshot); + }; + } + public static final class Scenario { private final int levelNumber; private final String name; diff --git a/app/src/test/java/com/projectsacrifice/game/LevelPlaytestBotTest.java b/app/src/test/java/com/projectsacrifice/game/LevelPlaytestBotTest.java index b7fa858..98bcc9a 100644 --- a/app/src/test/java/com/projectsacrifice/game/LevelPlaytestBotTest.java +++ b/app/src/test/java/com/projectsacrifice/game/LevelPlaytestBotTest.java @@ -3,6 +3,7 @@ package com.projectsacrifice.game; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; import org.junit.Test; @@ -127,6 +128,40 @@ public final class LevelPlaytestBotTest { assertFalse(snapshot.playerOnMovingPlatform()); } + @Test + public void groundedApproachWaitsForAliveGroundedPlayerAtCheckpoint() { + LevelPlaytestBot.SnapshotCondition checkpoint = + LevelPlaytestBot.groundedAliveAtOrBeyond(280f); + + assertFalse(checkpoint.reached(snapshot(true, true, 279f, 1))); + assertFalse(checkpoint.reached(snapshot(false, true, 280f, 1))); + assertFalse(checkpoint.reached(snapshot(true, false, 280f, 1))); + assertTrue(checkpoint.reached(snapshot(true, true, 280f, 1))); + } + + @Test + public void expectedDeathGuardFailsBeforeAnAccidentalRetry() { + LevelPlaytestBot.SnapshotCondition guarded = LevelPlaytestBot.whileDeathsAtMost( + 1, snapshot -> snapshot.playerX() >= 280f); + + assertTrue(guarded.reached(snapshot(true, false, 320f, 1))); + try { + guarded.reached(snapshot(false, false, 470f, 2)); + fail("An accidental second death must fail the route immediately"); + } catch (AssertionError expected) { + assertTrue(expected.getMessage(), expected.getMessage().contains("expected at most 1")); + assertTrue(expected.getMessage(), expected.getMessage().contains("observed 2")); + } + } + + private static GameView.AutomationSnapshot snapshot( + boolean alive, boolean onGround, float x, int deaths) { + return new GameView.AutomationSnapshot( + false, alive, false, false, false, + 10, 0L, x, 760f, deaths, deaths, + Float.NaN, Float.NaN, false, false, Float.NaN, onGround); + } + private static final class ScriptedDriver implements LevelPlaytestBot.Driver { private int startedLevel; private long nowMillis; From f07d1cf8304711c107ce1d746f5ccc54ee8e1690 Mon Sep 17 00:00:00 2001 From: "E.Gavrilov" Date: Tue, 18 Aug 2026 17:26:59 +0300 Subject: [PATCH 08/13] =?UTF-8?q?=D0=A1=D1=82=D0=B0=D0=B1=D0=B8=D0=BB?= =?UTF-8?q?=D0=B8=D0=B7=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D1=82=D1=8C=20=D0=BF?= =?UTF-8?q?=D0=BE=D1=81=D0=B0=D0=B4=D0=BA=D1=83=20=D0=B1=D0=BE=D1=82=D0=B0?= =?UTF-8?q?=20=D0=BD=D0=B0=20=D0=BF=D0=BB=D0=B0=D1=82=D1=84=D0=BE=D1=80?= =?UTF-8?q?=D0=BC=D1=83=20=D1=83=D1=80=D0=BE=D0=B2=D0=BD=D1=8F=208?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../game/LevelEightScenario.java | 144 +++++++++++------- .../game/MovingPlatformLaunchPolicy.java | 13 ++ .../game/PlaytestSimulationBudget.java | 56 +++++++ .../game/MovingPlatformLaunchPolicyTest.java | 27 ++++ .../game/PlaytestSimulationBudgetTest.java | 21 +++ 5 files changed, 204 insertions(+), 57 deletions(-) create mode 100644 app/src/main/java/com/projectsacrifice/game/MovingPlatformLaunchPolicy.java create mode 100644 app/src/main/java/com/projectsacrifice/game/PlaytestSimulationBudget.java create mode 100644 app/src/test/java/com/projectsacrifice/game/MovingPlatformLaunchPolicyTest.java create mode 100644 app/src/test/java/com/projectsacrifice/game/PlaytestSimulationBudgetTest.java diff --git a/app/src/androidTest/java/com/projectsacrifice/game/LevelEightScenario.java b/app/src/androidTest/java/com/projectsacrifice/game/LevelEightScenario.java index 905c8a7..2c5ae00 100644 --- a/app/src/androidTest/java/com/projectsacrifice/game/LevelEightScenario.java +++ b/app/src/androidTest/java/com/projectsacrifice/game/LevelEightScenario.java @@ -3,6 +3,9 @@ package com.projectsacrifice.game; /** Black-box level-8 scenarios driven only through the production input boundary. */ final class LevelEightScenario { private static final long DECISION_MILLIS = 32L; + private static final float LAUNCH_PLATFORM_X = 1_150f; + + private String lastStep = "Start level 8"; static final class Result { private final boolean completed; @@ -21,145 +24,161 @@ final class LevelEightScenario { } Result recoverAfterClockExpires(LevelPlaytestBot.Driver driver, long timeoutMillis) { - long deadline = driver.currentTimeMillis() + timeoutMillis; driver.startLevel(8); - GameView.AutomationSnapshot snapshot = crossPit(driver, deadline); - if (failed(snapshot, deadline, driver)) { - return failure("Cross the pit before the clock expires", snapshot); + GameView.AutomationSnapshot snapshot = driver.snapshot(); + PlaytestSimulationBudget budget = PlaytestSimulationBudget.start( + snapshot.elapsedMilliseconds(), driver.currentTimeMillis(), timeoutMillis); + snapshot = crossPit(driver, budget); + if (failed(snapshot, budget, driver)) { + return failure("Cross the pit before the clock expires", snapshot, budget, driver); } - snapshot = await(driver, deadline, "Wait for the far-side trap", + snapshot = await(driver, budget, "Wait for the far-side trap", LevelPlaytestBot.Input.none(), current -> current.playerX() >= 1_510f && !current.allDoorsOpen()); - if (failed(snapshot, deadline, driver)) { - return failure("Wait for the far-side trap", snapshot); + if (failed(snapshot, budget, driver)) { + return failure("Wait for the far-side trap", snapshot, budget, driver); } - snapshot = await(driver, deadline, "Return beneath the recovery relay", + snapshot = await(driver, budget, "Return beneath the recovery relay", LevelPlaytestBot.Input.left(), current -> current.playerX() <= 1_680f && current.playerY() >= 779f); - if (failed(snapshot, deadline, driver)) { - return failure("Return beneath the recovery relay", snapshot); + if (failed(snapshot, budget, driver)) { + return failure("Return beneath the recovery relay", snapshot, budget, driver); } - snapshot = pulseLeftJumpThenAwait(driver, deadline, "Jump into the recovery relay", + snapshot = pulseLeftJumpThenAwait(driver, budget, "Jump into the recovery relay", current -> current.allDoorsOpen()); - if (failed(snapshot, deadline, driver)) { - return failure("Jump into the recovery relay", snapshot); + if (failed(snapshot, budget, driver)) { + return failure("Jump into the recovery relay", snapshot, budget, driver); } - snapshot = await(driver, deadline, "Land after rearming the clock", + snapshot = await(driver, budget, "Land after rearming the clock", LevelPlaytestBot.Input.none(), current -> current.playerY() >= 779f); - if (failed(snapshot, deadline, driver)) { - return failure("Land after rearming the clock", snapshot); + if (failed(snapshot, budget, driver)) { + return failure("Land after rearming the clock", snapshot, budget, driver); } - return finishFromFarSide(driver, deadline, snapshot, "Finish after recovery"); + return finishFromFarSide(driver, budget, snapshot, "Finish after recovery"); } Result completeIntendedRoute(LevelPlaytestBot.Driver driver, long timeoutMillis) { - long deadline = driver.currentTimeMillis() + timeoutMillis; driver.startLevel(8); - GameView.AutomationSnapshot snapshot = crossPit(driver, deadline); - if (failed(snapshot, deadline, driver)) { - return failure("Cross the pit before the clock expires", snapshot); + GameView.AutomationSnapshot snapshot = driver.snapshot(); + PlaytestSimulationBudget budget = PlaytestSimulationBudget.start( + snapshot.elapsedMilliseconds(), driver.currentTimeMillis(), timeoutMillis); + snapshot = crossPit(driver, budget); + if (failed(snapshot, budget, driver)) { + return failure("Cross the pit before the clock expires", snapshot, budget, driver); } - return finishFromFarSide(driver, deadline, snapshot, "Finish on the original timer"); + return finishFromFarSide(driver, budget, snapshot, "Finish on the original timer"); } - private GameView.AutomationSnapshot crossPit(LevelPlaytestBot.Driver driver, long deadline) { - GameView.AutomationSnapshot snapshot = await(driver, deadline, "Trigger the left timer", + private GameView.AutomationSnapshot crossPit(LevelPlaytestBot.Driver driver, + PlaytestSimulationBudget budget) { + GameView.AutomationSnapshot snapshot = await(driver, budget, "Trigger the left timer", LevelPlaytestBot.Input.right(), GameView.AutomationSnapshot::allDoorsOpen); - if (failed(snapshot, deadline, driver)) return snapshot; + if (failed(snapshot, budget, driver)) return snapshot; long timerStartedAt = snapshot.elapsedMilliseconds(); - snapshot = await(driver, deadline, "Approach the pit", + snapshot = await(driver, budget, "Approach the pit", LevelPlaytestBot.Input.right(), current -> current.playerX() >= 760f); - if (failed(snapshot, deadline, driver)) return snapshot; + if (failed(snapshot, budget, driver)) return snapshot; - snapshot = await(driver, deadline, "Wait for the moving platform", + snapshot = await(driver, budget, "Wait for the moving platform", LevelPlaytestBot.Input.none(), current -> current.elapsedMilliseconds() >= timerStartedAt + 2_250L && current.firstMovingPlatformX() <= 1_050f); - if (failed(snapshot, deadline, driver)) return snapshot; + if (failed(snapshot, budget, driver)) return snapshot; driver.setInput(false, true, true); driver.waitForNextDecision(64L); driver.setInput(false, true, false); - snapshot = await(driver, deadline, "Land on the moving platform", + snapshot = await(driver, budget, "Land on the moving platform", LevelPlaytestBot.Input.right(), GameView.AutomationSnapshot::playerOnMovingPlatform); - if (failed(snapshot, deadline, driver)) return snapshot; + if (failed(snapshot, budget, driver)) return snapshot; + + snapshot = await(driver, budget, "Ride the moving platform to the launch zone", + LevelPlaytestBot.Input.none(), + current -> MovingPlatformLaunchPolicy.ready(current, LAUNCH_PLATFORM_X)); + if (failed(snapshot, budget, driver)) return snapshot; driver.setInput(false, true, true); driver.waitForNextDecision(64L); driver.setInput(false, true, false); - snapshot = await(driver, deadline, "Jump from the moving platform to the far floor", + snapshot = await(driver, budget, "Jump from the moving platform to the far floor", LevelPlaytestBot.Input.right(), current -> current.playerX() >= 1_510f); - if (failed(snapshot, deadline, driver)) return snapshot; - snapshot = awaitFarFloorLandingOnOriginalTimer(driver, deadline); - if (failed(snapshot, deadline, driver)) return snapshot; - return await(driver, deadline, "Move under the relay after landing", + if (failed(snapshot, budget, driver)) return snapshot; + snapshot = awaitFarFloorLandingOnOriginalTimer(driver, budget); + if (failed(snapshot, budget, driver)) return snapshot; + return await(driver, budget, "Move under the relay after landing", LevelPlaytestBot.Input.right(), current -> current.playerX() >= 1_600f && current.playerY() >= 779f && current.allDoorsOpen()); } private GameView.AutomationSnapshot awaitFarFloorLandingOnOriginalTimer( - LevelPlaytestBot.Driver driver, long deadline + LevelPlaytestBot.Driver driver, PlaytestSimulationBudget budget ) { - GameView.AutomationSnapshot snapshot = await(driver, deadline, + GameView.AutomationSnapshot snapshot = await(driver, budget, "Land on the far floor", LevelPlaytestBot.Input.right(), current -> current.playerX() >= 1_510f && current.playerY() >= 779f && current.allDoorsOpen()); - if (failed(snapshot, deadline, driver)) return snapshot; - return await(driver, deadline, "Stabilize on the far floor", + if (failed(snapshot, budget, driver)) return snapshot; + return await(driver, budget, "Stabilize on the far floor", LevelPlaytestBot.Input.left(), current -> current.playerX() < 1_580f && current.playerY() >= 779f && current.allDoorsOpen()); } - private Result finishFromFarSide(LevelPlaytestBot.Driver driver, long deadline, + private Result finishFromFarSide(LevelPlaytestBot.Driver driver, + PlaytestSimulationBudget budget, GameView.AutomationSnapshot snapshot, String step) { - snapshot = await(driver, deadline, "Reach the open-door jump", + snapshot = await(driver, budget, "Reach the open-door jump", LevelPlaytestBot.Input.right(), current -> current.playerX() >= 1_730f && current.allDoorsOpen()); - if (failed(snapshot, deadline, driver)) { - return failure("Reach the open-door jump", snapshot); + if (failed(snapshot, budget, driver)) { + return failure("Reach the open-door jump", snapshot, budget, driver); } driver.setInput(false, true, true); driver.waitForNextDecision(64L); driver.setInput(false, true, false); - snapshot = await(driver, deadline, step, LevelPlaytestBot.Input.right(), + snapshot = await(driver, budget, step, LevelPlaytestBot.Input.right(), GameView.AutomationSnapshot::completed); driver.setInput(false, false, false); - if (!snapshot.completed()) return failure(step, snapshot); - return new Result(true, snapshot.deaths(), step + " completed; " + state(snapshot)); + if (!snapshot.completed()) return failure(step, snapshot, budget, driver); + return new Result(true, snapshot.deaths(), step + " completed; " + + budget.timingState(snapshot.elapsedMilliseconds(), driver.currentTimeMillis()) + + "; " + state(snapshot)); } private GameView.AutomationSnapshot pulseLeftJumpThenAwait( - LevelPlaytestBot.Driver driver, long deadline, String step, + LevelPlaytestBot.Driver driver, PlaytestSimulationBudget budget, String step, LevelPlaytestBot.SnapshotCondition completion ) { driver.setInput(true, false, true); driver.waitForNextDecision(64L); driver.setInput(true, false, false); - return await(driver, deadline, step, LevelPlaytestBot.Input.left(), completion); + return await(driver, budget, step, LevelPlaytestBot.Input.left(), completion); } private GameView.AutomationSnapshot await( - LevelPlaytestBot.Driver driver, long deadline, String step, + LevelPlaytestBot.Driver driver, PlaytestSimulationBudget budget, String step, LevelPlaytestBot.Input input, LevelPlaytestBot.SnapshotCondition completion ) { + lastStep = step; GameView.AutomationSnapshot snapshot = driver.snapshot(); - while (driver.currentTimeMillis() < deadline && snapshot.playerAlive() + while (!budget.exhausted(snapshot.elapsedMilliseconds(), driver.currentTimeMillis()) + && snapshot.playerAlive() && !snapshot.completed() && !completion.reached(snapshot)) { setInput(driver, input); driver.waitForNextDecision(DECISION_MILLIS); snapshot = driver.snapshot(); } - if (driver.currentTimeMillis() >= deadline || !snapshot.playerAlive()) { + if (budget.exhausted(snapshot.elapsedMilliseconds(), driver.currentTimeMillis()) + || !snapshot.playerAlive()) { driver.setInput(false, false, false); } return snapshot; @@ -177,14 +196,20 @@ final class LevelEightScenario { } } - private static boolean failed(GameView.AutomationSnapshot snapshot, long deadline, + private static boolean failed(GameView.AutomationSnapshot snapshot, + PlaytestSimulationBudget budget, LevelPlaytestBot.Driver driver) { return !snapshot.completed() - && (!snapshot.playerAlive() || driver.currentTimeMillis() >= deadline); + && (!snapshot.playerAlive() + || budget.exhausted(snapshot.elapsedMilliseconds(), driver.currentTimeMillis())); } - private static Result failure(String step, GameView.AutomationSnapshot snapshot) { - return new Result(false, snapshot.deaths(), step + " failed; " + state(snapshot)); + private Result failure(String step, GameView.AutomationSnapshot snapshot, + PlaytestSimulationBudget budget, LevelPlaytestBot.Driver driver) { + return new Result(false, snapshot.deaths(), step + " failed; lastStep=" + lastStep + + "; " + budget.timingState( + snapshot.elapsedMilliseconds(), driver.currentTimeMillis()) + + "; " + state(snapshot)); } private static String state(GameView.AutomationSnapshot snapshot) { @@ -194,6 +219,11 @@ final class LevelEightScenario { + ", doorOpen=" + snapshot.allDoorsOpen() + ", completed=" + snapshot.completed() + ", elapsedMs=" + snapshot.elapsedMilliseconds() - + ", deaths=" + snapshot.deaths(); + + ", deaths=" + snapshot.deaths() + + ", platformX=" + snapshot.firstMovingPlatformX() + + ", platformY=" + snapshot.firstMovingPlatformY() + + ", onMovingPlatform=" + snapshot.playerOnMovingPlatform() + + ", onGround=" + snapshot.playerOnGround() + + ", platformPowered=" + snapshot.firstMovingPlatformPowered(); } } diff --git a/app/src/main/java/com/projectsacrifice/game/MovingPlatformLaunchPolicy.java b/app/src/main/java/com/projectsacrifice/game/MovingPlatformLaunchPolicy.java new file mode 100644 index 0000000..2049df4 --- /dev/null +++ b/app/src/main/java/com/projectsacrifice/game/MovingPlatformLaunchPolicy.java @@ -0,0 +1,13 @@ +package com.projectsacrifice.game; + +/** Stable public-state gate for a single moving-platform launch. */ +final class MovingPlatformLaunchPolicy { + private MovingPlatformLaunchPolicy() {} + + static boolean ready(GameView.AutomationSnapshot snapshot, float minimumPlatformX) { + return snapshot.playerOnMovingPlatform() + && snapshot.playerOnGround() + && snapshot.firstMovingPlatformPowered() + && snapshot.firstMovingPlatformX() >= minimumPlatformX; + } +} diff --git a/app/src/main/java/com/projectsacrifice/game/PlaytestSimulationBudget.java b/app/src/main/java/com/projectsacrifice/game/PlaytestSimulationBudget.java new file mode 100644 index 0000000..e392692 --- /dev/null +++ b/app/src/main/java/com/projectsacrifice/game/PlaytestSimulationBudget.java @@ -0,0 +1,56 @@ +package com.projectsacrifice.game; + +/** Simulation-first timeout with a separate watchdog for a stalled device. */ +final class PlaytestSimulationBudget { + private static final long MIN_WALL_WATCHDOG_MILLIS = 120_000L; + private static final long WALL_WATCHDOG_MULTIPLIER = 10L; + + private final long simulationStartedAt; + private final long wallStartedAt; + private final long simulationBudgetMillis; + private final long wallWatchdogMillis; + + private PlaytestSimulationBudget(long simulationStartedAt, long wallStartedAt, + long simulationBudgetMillis) { + this.simulationStartedAt = simulationStartedAt; + this.wallStartedAt = wallStartedAt; + this.simulationBudgetMillis = simulationBudgetMillis; + this.wallWatchdogMillis = wallWatchdogMillis(simulationBudgetMillis); + } + + static PlaytestSimulationBudget start(long simulationStartedAt, long wallStartedAt, + long simulationBudgetMillis) { + return new PlaytestSimulationBudget( + simulationStartedAt, wallStartedAt, simulationBudgetMillis); + } + + boolean exhausted(long simulationNow, long wallNow) { + return simulationElapsed(simulationNow) >= simulationBudgetMillis + || wallElapsed(wallNow) >= wallWatchdogMillis; + } + + String timingState(long simulationNow, long wallNow) { + return "simulationElapsed=" + simulationElapsed(simulationNow) + + ", wallElapsed=" + wallElapsed(wallNow); + } + + private long simulationElapsed(long simulationNow) { + return elapsed(simulationNow, simulationStartedAt); + } + + private long wallElapsed(long wallNow) { + return elapsed(wallNow, wallStartedAt); + } + + private static long wallWatchdogMillis(long simulationBudgetMillis) { + if (simulationBudgetMillis > Long.MAX_VALUE / WALL_WATCHDOG_MULTIPLIER) { + return Long.MAX_VALUE; + } + return Math.max(MIN_WALL_WATCHDOG_MILLIS, + simulationBudgetMillis * WALL_WATCHDOG_MULTIPLIER); + } + + private static long elapsed(long current, long startedAt) { + return Math.max(0L, current - startedAt); + } +} diff --git a/app/src/test/java/com/projectsacrifice/game/MovingPlatformLaunchPolicyTest.java b/app/src/test/java/com/projectsacrifice/game/MovingPlatformLaunchPolicyTest.java new file mode 100644 index 0000000..b56184b --- /dev/null +++ b/app/src/test/java/com/projectsacrifice/game/MovingPlatformLaunchPolicyTest.java @@ -0,0 +1,27 @@ +package com.projectsacrifice.game; + +import org.junit.Test; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public final class MovingPlatformLaunchPolicyTest { + @Test + public void launchRequiresStablePoweredSupportInTheLaunchZone() { + assertFalse(MovingPlatformLaunchPolicy.ready(snapshot(910f, true, true, true), 1_150f)); + assertFalse(MovingPlatformLaunchPolicy.ready(snapshot(1_150f, false, true, true), 1_150f)); + assertFalse(MovingPlatformLaunchPolicy.ready(snapshot(1_150f, true, false, true), 1_150f)); + assertFalse(MovingPlatformLaunchPolicy.ready(snapshot(1_150f, true, true, false), 1_150f)); + assertTrue(MovingPlatformLaunchPolicy.ready(snapshot(1_150f, true, true, true), 1_150f)); + } + + private static GameView.AutomationSnapshot snapshot( + float platformX, boolean supported, boolean grounded, boolean powered + ) { + return new GameView.AutomationSnapshot( + false, true, true, false, false, 8, + 4_000L, 1_100f, 670f, 0, 0, + platformX, 770f, supported, powered, Float.NaN, grounded + ); + } +} diff --git a/app/src/test/java/com/projectsacrifice/game/PlaytestSimulationBudgetTest.java b/app/src/test/java/com/projectsacrifice/game/PlaytestSimulationBudgetTest.java new file mode 100644 index 0000000..977ba8f --- /dev/null +++ b/app/src/test/java/com/projectsacrifice/game/PlaytestSimulationBudgetTest.java @@ -0,0 +1,21 @@ +package com.projectsacrifice.game; + +import org.junit.Test; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public final class PlaytestSimulationBudgetTest { + @Test + public void slowRenderingUsesSimulationBudgetWithASeparateWallWatchdog() { + PlaytestSimulationBudget budget = PlaytestSimulationBudget.start(0L, 0L, 500L); + + assertFalse("Host time may outrun active simulation", budget.exhausted(96L, 1_000L)); + assertTrue("Simulation budget remains authoritative", budget.exhausted(500L, 5_000L)); + + PlaytestSimulationBudget frozen = PlaytestSimulationBudget.start(0L, 0L, 5_000L); + assertFalse(frozen.exhausted(0L, 119_999L)); + assertTrue("Frozen rendering stops at the same watchdog as the generic bot", + frozen.exhausted(0L, 120_000L)); + } +} From f06467fcfa7b3caf3e8430ed61c0df9e9e8385da Mon Sep 17 00:00:00 2001 From: "E.Gavrilov" Date: Tue, 18 Aug 2026 17:33:05 +0300 Subject: [PATCH 09/13] =?UTF-8?q?=D0=A1=D1=82=D0=B0=D0=B1=D0=B8=D0=BB?= =?UTF-8?q?=D0=B8=D0=B7=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D1=82=D1=8C=20=D1=80?= =?UTF-8?q?=D0=B0=D0=BD=D0=BD=D0=B8=D0=B5=20=D1=81=D1=86=D0=B5=D0=BD=D0=B0?= =?UTF-8?q?=D1=80=D0=B8=D0=B8=20=D0=BF=D0=BE=D1=81=D0=BB=D0=B5=20=D1=8D?= =?UTF-8?q?=D1=82=D0=B0=D0=BF=D0=B0=203?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../game/GameLifecycleTest.java | 22 +++++++++++++++++-- .../game/EarlyLevelScenarios.java | 12 +++++++--- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/app/src/androidTest/java/com/projectsacrifice/game/GameLifecycleTest.java b/app/src/androidTest/java/com/projectsacrifice/game/GameLifecycleTest.java index 7a47f74..4c7497d 100644 --- a/app/src/androidTest/java/com/projectsacrifice/game/GameLifecycleTest.java +++ b/app/src/androidTest/java/com/projectsacrifice/game/GameLifecycleTest.java @@ -49,13 +49,31 @@ public final class GameLifecycleTest { GameView.AutomationSnapshot resumed = snapshot(instrumentation, view[0]); assertFalse("Back from the visible pause screen must resume the puzzle", resumed.visiblyPaused()); runOnGame(instrumentation, view[0], false, false, true, false); - sleep(250L); + GameView.AutomationSnapshot moving = waitForMovement( + instrumentation, view[0], resumed.playerX(), 10_000L); runOnGame(instrumentation, view[0], false, false, false, false); - GameView.AutomationSnapshot moving = snapshot(instrumentation, view[0]); assertTrue("The resumed simulation must accept movement", moving.playerX() > resumed.playerX() + 20f); } } + private static GameView.AutomationSnapshot waitForMovement( + Instrumentation instrumentation, GameView gameView, float startX, long timeoutMillis) { + long deadline = System.currentTimeMillis() + timeoutMillis; + GameView.AutomationSnapshot current = snapshot(instrumentation, gameView); + while (System.currentTimeMillis() < deadline) { + if (current.playerX() > startX + 20f) return current; + sleep(24L); + current = snapshot(instrumentation, gameView); + } + throw new AssertionError( + "The resumed simulation did not move within " + timeoutMillis + + "ms; startX=" + startX + + ", finalX=" + current.playerX() + + ", alive=" + current.playerAlive() + + ", paused=" + current.visiblyPaused() + + ", inputActive=" + current.inputActive()); + } + private static void createRetainedCorpse(Instrumentation instrumentation, GameView gameView) { runOnGame(instrumentation, gameView, true, false, true, false); long deadline = System.currentTimeMillis() + 8_000L; diff --git a/app/src/main/java/com/projectsacrifice/game/EarlyLevelScenarios.java b/app/src/main/java/com/projectsacrifice/game/EarlyLevelScenarios.java index 503f520..e11a365 100644 --- a/app/src/main/java/com/projectsacrifice/game/EarlyLevelScenarios.java +++ b/app/src/main/java/com/projectsacrifice/game/EarlyLevelScenarios.java @@ -67,9 +67,15 @@ public final class EarlyLevelScenarios { .step("Wait for the next volunteer", 2_000L, LevelPlaytestBot.Input.none(), EarlyLevelScenarios::respawnedAtStart) .step("Approach the saw", 3_000L, LevelPlaytestBot.Input.right(), - snapshot -> snapshot.playerX() >= 300f) - .step("Jump over the saw and plate body", 3_000L, LevelPlaytestBot.Input.rightJump(), - snapshot -> snapshot.playerX() >= 700f) + snapshot -> snapshot.playerAlive() + && snapshot.playerOnGround() + && snapshot.playerX() >= 280f) + .step("Take off before the saw", 1_000L, LevelPlaytestBot.Input.rightJump(), + snapshot -> snapshot.playerAlive() + && !snapshot.playerOnGround() + && snapshot.playerY() <= 680f) + .step("Clear the saw and plate body", 3_000L, LevelPlaytestBot.Input.right(), + snapshot -> snapshot.playerAlive() && snapshot.playerX() >= 700f) .step("Reach the relic through the open door", 5_000L, LevelPlaytestBot.Input.right(), GameView.AutomationSnapshot::completed) .build(); From a8d72024f20a13c7edd67323d7292011901c146d Mon Sep 17 00:00:00 2001 From: "E.Gavrilov" Date: Tue, 18 Aug 2026 17:33:30 +0300 Subject: [PATCH 10/13] =?UTF-8?q?=D0=A1=D1=82=D0=B0=D0=B1=D0=B8=D0=BB?= =?UTF-8?q?=D0=B8=D0=B7=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D1=82=D1=8C=20=D0=BF?= =?UTF-8?q?=D0=B5=D1=80=D0=B5=D1=85=D0=BE=D0=B4=20=D1=87=D0=B5=D1=80=D0=B5?= =?UTF-8?q?=D0=B7=20=D0=BF=D0=B8=D0=BB=D1=83=20=D1=83=D1=80=D0=BE=D0=B2?= =?UTF-8?q?=D0=BD=D1=8F=205?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../game/LevelPlaytestBot.java | 6 ++- .../game/LevelPlaytestBotTest.java | 42 +++++++++++++++++-- 2 files changed, 43 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/projectsacrifice/game/LevelPlaytestBot.java b/app/src/main/java/com/projectsacrifice/game/LevelPlaytestBot.java index e0c6c85..6ef240d 100644 --- a/app/src/main/java/com/projectsacrifice/game/LevelPlaytestBot.java +++ b/app/src/main/java/com/projectsacrifice/game/LevelPlaytestBot.java @@ -250,8 +250,10 @@ public final class LevelPlaytestBot { snapshot -> snapshot.playerAlive() && snapshot.playerX() < 250f) .step("Approach the saw", 4_000L, Input.right(), snapshot -> snapshot.playerAlive() && snapshot.playerX() >= 390f) - .step("Jump over the saw", 2_000L, Input.rightJump(), - snapshot -> snapshot.playerX() >= 430f) + .step("Take off before the saw", 1_000L, Input.rightJump(), + snapshot -> snapshot.playerAlive() && snapshot.playerY() <= 700f) + .step("Clear the saw", 2_000L, Input.right(), + snapshot -> snapshot.playerAlive() && snapshot.playerX() >= 680f) .step("Run to the ledge", 4_000L, Input.right(), snapshot -> snapshot.playerX() >= 750f) .step("Jump onto the ledge", 2_000L, Input.rightJump(), diff --git a/app/src/test/java/com/projectsacrifice/game/LevelPlaytestBotTest.java b/app/src/test/java/com/projectsacrifice/game/LevelPlaytestBotTest.java index 98bcc9a..53774dc 100644 --- a/app/src/test/java/com/projectsacrifice/game/LevelPlaytestBotTest.java +++ b/app/src/test/java/com/projectsacrifice/game/LevelPlaytestBotTest.java @@ -118,6 +118,17 @@ public final class LevelPlaytestBotTest { assertTrue(result.doorOpened()); } + @Test + public void levelFiveDelayedDecisionStillClearsTheSawWithOneVolunteer() { + LevelFiveDriver driver = new LevelFiveDriver(true); + + LevelPlaytestBot.Result result = new LevelPlaytestBot().proveLevelFive(driver, 25_000L); + + assertTrue(result.diagnostic(), result.completed()); + assertEquals(1, result.deaths()); + assertEquals(1, result.corpseCount()); + } + @Test public void automationSnapshotDefaultsToNoMovingPlatform() { GameView.AutomationSnapshot snapshot = new GameView.AutomationSnapshot( @@ -275,6 +286,7 @@ public final class LevelPlaytestBotTest { } private static final class LevelFiveDriver implements LevelPlaytestBot.Driver { + private final boolean overshootApproach; private int startedLevel; private long nowMillis; private boolean moveRight; @@ -285,6 +297,16 @@ public final class LevelPlaytestBotTest { private int deaths; private int corpses; private float x = 130f; + private float y = 760f; + private boolean sawTakeoffObserved; + + LevelFiveDriver() { + this(false); + } + + LevelFiveDriver(boolean overshootApproach) { + this.overshootApproach = overshootApproach; + } @Override public void startLevel(int oneBasedLevelNumber) { @@ -301,7 +323,7 @@ public final class LevelPlaytestBotTest { public GameView.AutomationSnapshot snapshot() { return new GameView.AutomationSnapshot( completed, alive, doorOpen, false, moveRight || jump, - startedLevel, nowMillis, x, 760f, deaths, corpses); + startedLevel, nowMillis, x, y, deaths, corpses); } @Override @@ -310,6 +332,7 @@ public final class LevelPlaytestBotTest { if (!alive) { alive = true; x = 130f; + y = 760f; } else if (deaths == 0 && moveRight && !jump) { deaths = 1; corpses = 1; @@ -317,11 +340,24 @@ public final class LevelPlaytestBotTest { doorOpen = true; x = 570f; } else if (moveRight && jump) { + if (x < 750f) { + sawTakeoffObserved = true; + y = 690f; + } x += 60f; } else if (moveRight && x < 390f) { - x = 400f; + x = overshootApproach ? 440f : 400f; } else if (moveRight && x < 750f) { - x = 760f; + if (overshootApproach && !sawTakeoffObserved) { + deaths++; + corpses++; + alive = false; + x = 510f; + y = 780f; + } else { + x = 760f; + y = 760f; + } } else if (moveRight && x < 1_390f) { x = 1_500f; } else if (moveRight) { From 9d792bdee4cd456cb8269e1ed53132dc34ea7da0 Mon Sep 17 00:00:00 2001 From: "E.Gavrilov" Date: Tue, 18 Aug 2026 18:27:33 +0300 Subject: [PATCH 11/13] =?UTF-8?q?=D0=97=D0=B0=D0=B2=D0=B5=D1=80=D1=88?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20=D0=B8=D0=BD=D1=82=D0=B5=D0=B3=D1=80=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D0=BE=D0=BD=D0=BD=D1=83=D1=8E=20=D1=83=D1=81=D1=82?= =?UTF-8?q?=D0=BE=D0=B9=D1=87=D0=B8=D0=B2=D0=BE=D1=81=D1=82=D1=8C=20=D0=B1?= =?UTF-8?q?=D0=BE=D1=82=D0=BE=D0=B2=20=D1=8D=D1=82=D0=B0=D0=BF=D0=B0=203?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../game/GameLifecycleTest.java | 11 ++- .../game/LateLevelScenarios.java | 26 ++++-- .../game/LevelEightScenario.java | 87 +++++++++++++++---- .../game/EarlyLevelScenarios.java | 11 +-- .../com/projectsacrifice/game/GameView.java | 23 ++++- .../game/LevelPlaytestBot.java | 9 +- .../game/MovingPlatformLaunchPolicy.java | 6 ++ .../game/MovingPlatformLaunchPolicyTest.java | 27 ++++-- 8 files changed, 156 insertions(+), 44 deletions(-) diff --git a/app/src/androidTest/java/com/projectsacrifice/game/GameLifecycleTest.java b/app/src/androidTest/java/com/projectsacrifice/game/GameLifecycleTest.java index 4c7497d..5add511 100644 --- a/app/src/androidTest/java/com/projectsacrifice/game/GameLifecycleTest.java +++ b/app/src/androidTest/java/com/projectsacrifice/game/GameLifecycleTest.java @@ -2,7 +2,6 @@ package com.projectsacrifice.game; import android.app.Instrumentation; -import androidx.lifecycle.Lifecycle; import androidx.test.core.app.ActivityScenario; import androidx.test.ext.junit.runners.AndroidJUnit4; import androidx.test.platform.app.InstrumentationRegistry; @@ -21,12 +20,16 @@ public final class GameLifecycleTest { public void returningFromBackgroundKeepsPuzzleVisiblePausedAndClearsHeldInput() { Instrumentation instrumentation = InstrumentationRegistry.getInstrumentation(); try (ActivityScenario scenario = ActivityScenario.launch(MainActivity.class)) { + final MainActivity[] activity = new MainActivity[1]; final GameView[] view = new GameView[1]; - scenario.onActivity(activity -> view[0] = activity.gameViewForAutomation()); + scenario.onActivity(current -> { + activity[0] = current; + view[0] = current.gameViewForAutomation(); + }); createRetainedCorpse(instrumentation, view[0]); runOnGame(instrumentation, view[0], false, false, true, false); - scenario.moveToState(Lifecycle.State.STARTED); + instrumentation.runOnMainSync(activity[0]::onPause); GameView.AutomationSnapshot backgroundStart = snapshot(instrumentation, view[0]); sleep(700L); GameView.AutomationSnapshot backgroundEnd = snapshot(instrumentation, view[0]); @@ -37,7 +40,7 @@ public final class GameLifecycleTest { assertEquals(backgroundStart.playerX(), backgroundEnd.playerX(), 0.01f); assertEquals(backgroundStart.elapsedMilliseconds(), backgroundEnd.elapsedMilliseconds()); - scenario.moveToState(Lifecycle.State.RESUMED); + instrumentation.runOnMainSync(activity[0]::onResume); GameView.AutomationSnapshot returned = snapshot(instrumentation, view[0]); assertTrue("Returning to an active puzzle must show the pause screen", returned.visiblyPaused()); assertFalse("Held touch input must not survive backgrounding", returned.inputActive()); diff --git a/app/src/androidTest/java/com/projectsacrifice/game/LateLevelScenarios.java b/app/src/androidTest/java/com/projectsacrifice/game/LateLevelScenarios.java index 2ca71ce..0cc41de 100644 --- a/app/src/androidTest/java/com/projectsacrifice/game/LateLevelScenarios.java +++ b/app/src/androidTest/java/com/projectsacrifice/game/LateLevelScenarios.java @@ -20,10 +20,13 @@ final class LateLevelScenarios { snapshot -> snapshot.playerX() >= 626f && snapshot.playerY() >= 668f && snapshot.playerY() <= 690f) - .step("Jump from the block to the first ledge", 2_500L, + .step("Land on the first ledge from the frozen block", 3_000L, LevelPlaytestBot.Input.rightJump(), - snapshot -> snapshot.playerX() >= 750f - && snapshot.playerY() <= 505f) + LevelPlaytestBot.whileDeathsAtMost(1, + snapshot -> snapshot.playerAlive() + && snapshot.playerOnGround() + && snapshot.playerX() >= 810f + && snapshot.playerY() <= 500f)) .step("Cross the first ledge", 3_000L, LevelPlaytestBot.Input.right(), snapshot -> snapshot.playerX() >= 1_230f) .step("Jump the gap to the second ledge", 2_500L, @@ -117,19 +120,24 @@ final class LateLevelScenarios { LevelPlaytestBot.Input.right(), snapshot -> snapshot.deaths() == 1 && snapshot.corpseCount() == 1) + .step("Observe the delivered body power the lift", 3_000L, + LevelPlaytestBot.Input.none(), + snapshot -> snapshot.deaths() == 1 + && snapshot.corpseCount() == 1 + && snapshot.firstMovingPlatformPowered()) .step("Wait for the freezer volunteer", 3_000L, LevelPlaytestBot.Input.none(), snapshot -> snapshot.playerAlive() && snapshot.playerX() < 250f) .step("Approach the launch saw", 2_500L, LevelPlaytestBot.Input.right(), LevelPlaytestBot.whileDeathsAtMost(1, - LevelPlaytestBot.groundedAliveAtOrBeyond(280f))) + LevelPlaytestBot.groundedAliveAtOrBeyond(320f))) .step("Take off before the launch saw", 1_000L, LevelPlaytestBot.Input.rightJump(), LevelPlaytestBot.whileDeathsAtMost(1, snapshot -> snapshot.playerAlive() && !snapshot.playerOnGround() - && snapshot.playerY() <= 680f)) + && snapshot.playerY() <= 580f)) .step("Clear the launch saw", 2_000L, LevelPlaytestBot.Input.right(), LevelPlaytestBot.whileDeathsAtMost(1, @@ -150,13 +158,13 @@ final class LateLevelScenarios { .step("Return to the launch saw", 2_500L, LevelPlaytestBot.Input.right(), LevelPlaytestBot.whileDeathsAtMost(2, - LevelPlaytestBot.groundedAliveAtOrBeyond(280f))) + LevelPlaytestBot.groundedAliveAtOrBeyond(320f))) .step("Take off before the saw again", 1_000L, LevelPlaytestBot.Input.rightJump(), LevelPlaytestBot.whileDeathsAtMost(2, snapshot -> snapshot.playerAlive() && !snapshot.playerOnGround() - && snapshot.playerY() <= 680f)) + && snapshot.playerY() <= 580f)) .step("Clear the saw again", 2_000L, LevelPlaytestBot.Input.right(), LevelPlaytestBot.whileDeathsAtMost(2, @@ -203,13 +211,13 @@ final class LateLevelScenarios { .step("Approach the saw for the final crossing", 2_500L, LevelPlaytestBot.Input.right(), LevelPlaytestBot.whileDeathsAtMost(3, - LevelPlaytestBot.groundedAliveAtOrBeyond(280f))) + LevelPlaytestBot.groundedAliveAtOrBeyond(320f))) .step("Take off for the final saw crossing", 1_000L, LevelPlaytestBot.Input.rightJump(), LevelPlaytestBot.whileDeathsAtMost(3, snapshot -> snapshot.playerAlive() && !snapshot.playerOnGround() - && snapshot.playerY() <= 680f)) + && snapshot.playerY() <= 580f)) .step("Clear the saw for the final crossing", 2_000L, LevelPlaytestBot.Input.right(), LevelPlaytestBot.whileDeathsAtMost(3, diff --git a/app/src/androidTest/java/com/projectsacrifice/game/LevelEightScenario.java b/app/src/androidTest/java/com/projectsacrifice/game/LevelEightScenario.java index 2c5ae00..3666368 100644 --- a/app/src/androidTest/java/com/projectsacrifice/game/LevelEightScenario.java +++ b/app/src/androidTest/java/com/projectsacrifice/game/LevelEightScenario.java @@ -33,6 +33,14 @@ final class LevelEightScenario { return failure("Cross the pit before the clock expires", snapshot, budget, driver); } + snapshot = await(driver, budget, "Move clear of the platform sweep", + LevelPlaytestBot.Input.right(), + current -> current.playerX() >= 1_680f + && current.playerY() >= 779f && current.allDoorsOpen()); + if (failed(snapshot, budget, driver)) { + return failure("Move clear of the platform sweep", snapshot, budget, driver); + } + snapshot = await(driver, budget, "Wait for the far-side trap", LevelPlaytestBot.Input.none(), current -> current.playerX() >= 1_510f && !current.allDoorsOpen()); @@ -40,21 +48,21 @@ final class LevelEightScenario { return failure("Wait for the far-side trap", snapshot, budget, driver); } - snapshot = await(driver, budget, "Return beneath the recovery relay", - LevelPlaytestBot.Input.left(), - current -> current.playerX() <= 1_680f && current.playerY() >= 779f); + snapshot = positionBeneathRecoveryRelay(driver, budget); if (failed(snapshot, budget, driver)) { return failure("Return beneath the recovery relay", snapshot, budget, driver); } - snapshot = pulseLeftJumpThenAwait(driver, budget, "Jump into the recovery relay", + snapshot = pulseRightJumpThenAwait(driver, budget, "Jump into the recovery relay", current -> current.allDoorsOpen()); if (failed(snapshot, budget, driver)) { return failure("Jump into the recovery relay", snapshot, budget, driver); } snapshot = await(driver, budget, "Land after rearming the clock", - LevelPlaytestBot.Input.none(), current -> current.playerY() >= 779f); + LevelPlaytestBot.Input.right(), + current -> current.playerX() >= 1_650f + && current.playerY() >= 779f && current.allDoorsOpen()); if (failed(snapshot, budget, driver)) { return failure("Land after rearming the clock", snapshot, budget, driver); } @@ -85,10 +93,10 @@ final class LevelEightScenario { LevelPlaytestBot.Input.right(), current -> current.playerX() >= 760f); if (failed(snapshot, budget, driver)) return snapshot; - snapshot = await(driver, budget, "Wait for the moving platform", + snapshot = await(driver, budget, "Wait for the outbound moving platform", LevelPlaytestBot.Input.none(), current -> current.elapsedMilliseconds() >= timerStartedAt + 2_250L - && current.firstMovingPlatformX() <= 1_050f); + && MovingPlatformLaunchPolicy.readyToBoard(current, 1_050f)); if (failed(snapshot, budget, driver)) return snapshot; driver.setInput(false, true, true); @@ -135,10 +143,8 @@ final class LevelEightScenario { private Result finishFromFarSide(LevelPlaytestBot.Driver driver, PlaytestSimulationBudget budget, GameView.AutomationSnapshot snapshot, String step) { - snapshot = await(driver, budget, "Reach the open-door jump", - LevelPlaytestBot.Input.right(), - current -> current.playerX() >= 1_730f && current.allDoorsOpen()); - if (failed(snapshot, budget, driver)) { + snapshot = positionForFinalJump(driver, budget); + if (failed(snapshot, budget, driver) || !snapshot.allDoorsOpen()) { return failure("Reach the open-door jump", snapshot, budget, driver); } @@ -154,14 +160,62 @@ final class LevelEightScenario { + "; " + state(snapshot)); } - private GameView.AutomationSnapshot pulseLeftJumpThenAwait( + private GameView.AutomationSnapshot positionForFinalJump( + LevelPlaytestBot.Driver driver, PlaytestSimulationBudget budget + ) { + lastStep = "Reach the open-door jump"; + GameView.AutomationSnapshot snapshot = driver.snapshot(); + while (!budget.exhausted(snapshot.elapsedMilliseconds(), driver.currentTimeMillis()) + && snapshot.playerAlive() && !snapshot.completed() && snapshot.allDoorsOpen()) { + if (snapshot.playerOnGround() + && snapshot.playerX() >= 1_690f && snapshot.playerX() <= 1_730f) { + driver.setInput(false, false, false); + return snapshot; + } + if (snapshot.playerX() > 1_730f) { + driver.setInput(true, false, false); + } else { + driver.setInput(false, true, false); + } + driver.waitForNextDecision(DECISION_MILLIS); + snapshot = driver.snapshot(); + } + driver.setInput(false, false, false); + return snapshot; + } + + private GameView.AutomationSnapshot pulseRightJumpThenAwait( LevelPlaytestBot.Driver driver, PlaytestSimulationBudget budget, String step, LevelPlaytestBot.SnapshotCondition completion ) { - driver.setInput(true, false, true); + driver.setInput(false, true, true); driver.waitForNextDecision(64L); - driver.setInput(true, false, false); - return await(driver, budget, step, LevelPlaytestBot.Input.left(), completion); + driver.setInput(false, true, false); + return await(driver, budget, step, LevelPlaytestBot.Input.right(), completion); + } + + private GameView.AutomationSnapshot positionBeneathRecoveryRelay( + LevelPlaytestBot.Driver driver, PlaytestSimulationBudget budget + ) { + lastStep = "Return beneath the recovery relay"; + GameView.AutomationSnapshot snapshot = driver.snapshot(); + while (!budget.exhausted(snapshot.elapsedMilliseconds(), driver.currentTimeMillis()) + && snapshot.playerAlive() && !snapshot.completed()) { + if (snapshot.playerOnGround() + && snapshot.playerX() >= 1_620f && snapshot.playerX() <= 1_650f) { + driver.setInput(false, false, false); + return snapshot; + } + if (snapshot.playerX() > 1_650f) { + driver.setInput(true, false, false); + } else { + driver.setInput(false, true, false); + } + driver.waitForNextDecision(DECISION_MILLIS); + snapshot = driver.snapshot(); + } + driver.setInput(false, false, false); + return snapshot; } private GameView.AutomationSnapshot await( @@ -224,6 +278,7 @@ final class LevelEightScenario { + ", platformY=" + snapshot.firstMovingPlatformY() + ", onMovingPlatform=" + snapshot.playerOnMovingPlatform() + ", onGround=" + snapshot.playerOnGround() - + ", platformPowered=" + snapshot.firstMovingPlatformPowered(); + + ", platformPowered=" + snapshot.firstMovingPlatformPowered() + + ", platformMovingRight=" + snapshot.firstMovingPlatformMovingRight(); } } diff --git a/app/src/main/java/com/projectsacrifice/game/EarlyLevelScenarios.java b/app/src/main/java/com/projectsacrifice/game/EarlyLevelScenarios.java index e11a365..995941f 100644 --- a/app/src/main/java/com/projectsacrifice/game/EarlyLevelScenarios.java +++ b/app/src/main/java/com/projectsacrifice/game/EarlyLevelScenarios.java @@ -67,15 +67,16 @@ public final class EarlyLevelScenarios { .step("Wait for the next volunteer", 2_000L, LevelPlaytestBot.Input.none(), EarlyLevelScenarios::respawnedAtStart) .step("Approach the saw", 3_000L, LevelPlaytestBot.Input.right(), - snapshot -> snapshot.playerAlive() + LevelPlaytestBot.whileDeathsAtMost(1, snapshot -> snapshot.playerAlive() && snapshot.playerOnGround() - && snapshot.playerX() >= 280f) + && snapshot.playerX() >= 390f)) .step("Take off before the saw", 1_000L, LevelPlaytestBot.Input.rightJump(), - snapshot -> snapshot.playerAlive() + LevelPlaytestBot.whileDeathsAtMost(1, snapshot -> snapshot.playerAlive() && !snapshot.playerOnGround() - && snapshot.playerY() <= 680f) + && snapshot.playerY() <= 620f)) .step("Clear the saw and plate body", 3_000L, LevelPlaytestBot.Input.right(), - snapshot -> snapshot.playerAlive() && snapshot.playerX() >= 700f) + LevelPlaytestBot.whileDeathsAtMost(1, snapshot -> + snapshot.playerAlive() && snapshot.playerX() >= 700f)) .step("Reach the relic through the open door", 5_000L, LevelPlaytestBot.Input.right(), GameView.AutomationSnapshot::completed) .build(); diff --git a/app/src/main/java/com/projectsacrifice/game/GameView.java b/app/src/main/java/com/projectsacrifice/game/GameView.java index 900edb5..195fbea 100644 --- a/app/src/main/java/com/projectsacrifice/game/GameView.java +++ b/app/src/main/java/com/projectsacrifice/game/GameView.java @@ -59,6 +59,7 @@ public final class GameView extends View implements Runnable { private final float firstMovingPlatformY; private final boolean playerOnMovingPlatform; private final boolean firstMovingPlatformPowered; + private final boolean firstMovingPlatformMovingRight; private final float firstCorpseX; private final boolean playerOnGround; @@ -68,7 +69,7 @@ public final class GameView extends View implements Runnable { float playerX, float playerY, int deaths, int corpseCount) { this(completed, playerAlive, allDoorsOpen, visiblyPaused, inputActive, levelNumber, elapsedMilliseconds, playerX, playerY, deaths, corpseCount, - Float.NaN, Float.NaN, false, false, Float.NaN, false); + Float.NaN, Float.NaN, false, false, false, Float.NaN, false); } AutomationSnapshot(boolean completed, boolean playerAlive, boolean allDoorsOpen, @@ -79,6 +80,21 @@ public final class GameView extends View implements Runnable { boolean playerOnMovingPlatform, boolean firstMovingPlatformPowered, float firstCorpseX, boolean playerOnGround) { + this(completed, playerAlive, allDoorsOpen, visiblyPaused, inputActive, levelNumber, + elapsedMilliseconds, playerX, playerY, deaths, corpseCount, + firstMovingPlatformX, firstMovingPlatformY, playerOnMovingPlatform, + firstMovingPlatformPowered, false, firstCorpseX, playerOnGround); + } + + AutomationSnapshot(boolean completed, boolean playerAlive, boolean allDoorsOpen, + boolean visiblyPaused, boolean inputActive, int levelNumber, + long elapsedMilliseconds, + float playerX, float playerY, int deaths, int corpseCount, + float firstMovingPlatformX, float firstMovingPlatformY, + boolean playerOnMovingPlatform, + boolean firstMovingPlatformPowered, + boolean firstMovingPlatformMovingRight, float firstCorpseX, + boolean playerOnGround) { this.completed = completed; this.playerAlive = playerAlive; this.allDoorsOpen = allDoorsOpen; @@ -94,6 +110,7 @@ public final class GameView extends View implements Runnable { this.firstMovingPlatformY = firstMovingPlatformY; this.playerOnMovingPlatform = playerOnMovingPlatform; this.firstMovingPlatformPowered = firstMovingPlatformPowered; + this.firstMovingPlatformMovingRight = firstMovingPlatformMovingRight; this.firstCorpseX = firstCorpseX; this.playerOnGround = playerOnGround; } @@ -113,6 +130,7 @@ public final class GameView extends View implements Runnable { public float firstMovingPlatformY() { return firstMovingPlatformY; } public boolean playerOnMovingPlatform() { return playerOnMovingPlatform; } public boolean firstMovingPlatformPowered() { return firstMovingPlatformPowered; } + public boolean firstMovingPlatformMovingRight() { return firstMovingPlatformMovingRight; } public float firstCorpseX() { return firstCorpseX; } public boolean playerOnGround() { return playerOnGround; } } @@ -1958,11 +1976,13 @@ public final class GameView extends View implements Runnable { float firstMovingPlatformX = Float.NaN; float firstMovingPlatformY = Float.NaN; boolean firstMovingPlatformPowered = false; + boolean firstMovingPlatformMovingRight = false; for (PlatformState state : platformStates) { if (state.definition.isMoving()) { firstMovingPlatformX = state.x; firstMovingPlatformY = state.y; firstMovingPlatformPowered = signalActive(state.definition.controlId); + firstMovingPlatformMovingRight = state.x > state.previousX; break; } } @@ -1986,6 +2006,7 @@ public final class GameView extends View implements Runnable { firstMovingPlatformY, playerOnMovingPlatform, firstMovingPlatformPowered, + firstMovingPlatformMovingRight, corpses.isEmpty() ? Float.NaN : corpses.get(0).x, player.onGround ); diff --git a/app/src/main/java/com/projectsacrifice/game/LevelPlaytestBot.java b/app/src/main/java/com/projectsacrifice/game/LevelPlaytestBot.java index 6ef240d..dda0763 100644 --- a/app/src/main/java/com/projectsacrifice/game/LevelPlaytestBot.java +++ b/app/src/main/java/com/projectsacrifice/game/LevelPlaytestBot.java @@ -6,6 +6,7 @@ import java.util.List; /** Runs black-box acceptance scenarios through the same input and state boundary as touch play. */ public final class LevelPlaytestBot { + private static final long DECISION_MILLIS = 16L; private static final long MIN_WALL_WATCHDOG_MILLIS = 120_000L; private static final long WALL_WATCHDOG_MULTIPLIER = 10L; @@ -58,7 +59,11 @@ public final class LevelPlaytestBot { return snapshot -> { if (snapshot.deaths() > maximumDeaths) { throw new AssertionError("Route expected at most " + maximumDeaths - + " deaths but observed " + snapshot.deaths()); + + " deaths but observed " + snapshot.deaths() + + "; x=" + snapshot.playerX() + + ", y=" + snapshot.playerY() + + ", alive=" + snapshot.playerAlive() + + ", grounded=" + snapshot.playerOnGround()); } return completion.reached(snapshot); }; @@ -196,7 +201,7 @@ public final class LevelPlaytestBot { } driver.setInput(step.input.moveLeft, step.input.moveRight, step.input.jump); - driver.waitForNextDecision(32L); + driver.waitForNextDecision(DECISION_MILLIS); } driver.setInput(false, false, false); diff --git a/app/src/main/java/com/projectsacrifice/game/MovingPlatformLaunchPolicy.java b/app/src/main/java/com/projectsacrifice/game/MovingPlatformLaunchPolicy.java index 2049df4..448ba88 100644 --- a/app/src/main/java/com/projectsacrifice/game/MovingPlatformLaunchPolicy.java +++ b/app/src/main/java/com/projectsacrifice/game/MovingPlatformLaunchPolicy.java @@ -4,6 +4,12 @@ package com.projectsacrifice.game; final class MovingPlatformLaunchPolicy { private MovingPlatformLaunchPolicy() {} + static boolean readyToBoard(GameView.AutomationSnapshot snapshot, float maximumPlatformX) { + return snapshot.firstMovingPlatformPowered() + && snapshot.firstMovingPlatformMovingRight() + && snapshot.firstMovingPlatformX() <= maximumPlatformX; + } + static boolean ready(GameView.AutomationSnapshot snapshot, float minimumPlatformX) { return snapshot.playerOnMovingPlatform() && snapshot.playerOnGround() diff --git a/app/src/test/java/com/projectsacrifice/game/MovingPlatformLaunchPolicyTest.java b/app/src/test/java/com/projectsacrifice/game/MovingPlatformLaunchPolicyTest.java index b56184b..c4deac6 100644 --- a/app/src/test/java/com/projectsacrifice/game/MovingPlatformLaunchPolicyTest.java +++ b/app/src/test/java/com/projectsacrifice/game/MovingPlatformLaunchPolicyTest.java @@ -6,22 +6,35 @@ import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; public final class MovingPlatformLaunchPolicyTest { + @Test + public void boardingWaitsForThePoweredOutboundPhaseNearTheLeftFloor() { + assertFalse(MovingPlatformLaunchPolicy.readyToBoard( + snapshot(1_000f, false, true, true, false), 1_050f)); + assertFalse(MovingPlatformLaunchPolicy.readyToBoard( + snapshot(1_000f, false, true, false, true), 1_050f)); + assertFalse(MovingPlatformLaunchPolicy.readyToBoard( + snapshot(1_100f, false, true, true, true), 1_050f)); + assertTrue(MovingPlatformLaunchPolicy.readyToBoard( + snapshot(1_000f, false, true, true, true), 1_050f)); + } + @Test public void launchRequiresStablePoweredSupportInTheLaunchZone() { - assertFalse(MovingPlatformLaunchPolicy.ready(snapshot(910f, true, true, true), 1_150f)); - assertFalse(MovingPlatformLaunchPolicy.ready(snapshot(1_150f, false, true, true), 1_150f)); - assertFalse(MovingPlatformLaunchPolicy.ready(snapshot(1_150f, true, false, true), 1_150f)); - assertFalse(MovingPlatformLaunchPolicy.ready(snapshot(1_150f, true, true, false), 1_150f)); - assertTrue(MovingPlatformLaunchPolicy.ready(snapshot(1_150f, true, true, true), 1_150f)); + assertFalse(MovingPlatformLaunchPolicy.ready(snapshot(910f, true, true, true, true), 1_150f)); + assertFalse(MovingPlatformLaunchPolicy.ready(snapshot(1_150f, false, true, true, true), 1_150f)); + assertFalse(MovingPlatformLaunchPolicy.ready(snapshot(1_150f, true, false, true, true), 1_150f)); + assertFalse(MovingPlatformLaunchPolicy.ready(snapshot(1_150f, true, true, false, true), 1_150f)); + assertTrue(MovingPlatformLaunchPolicy.ready(snapshot(1_150f, true, true, true, true), 1_150f)); } private static GameView.AutomationSnapshot snapshot( - float platformX, boolean supported, boolean grounded, boolean powered + float platformX, boolean supported, boolean grounded, boolean powered, + boolean movingRight ) { return new GameView.AutomationSnapshot( false, true, true, false, false, 8, 4_000L, 1_100f, 670f, 0, 0, - platformX, 770f, supported, powered, Float.NaN, grounded + platformX, 770f, supported, powered, movingRight, Float.NaN, grounded ); } } From fa68e2e04aab534ee3eaa0eedd883ed574fca4bb Mon Sep 17 00:00:00 2001 From: "E.Gavrilov" Date: Tue, 18 Aug 2026 18:58:27 +0300 Subject: [PATCH 12/13] =?UTF-8?q?=D0=A3=D1=81=D1=82=D1=80=D0=B0=D0=BD?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20=D0=BD=D0=B0=D0=B3=D1=80=D1=83=D0=B7=D0=BE?= =?UTF-8?q?=D1=87=D0=BD=D1=8B=D0=B5=20=D1=84=D0=BB=D0=B0=D0=BA=D0=B8=20?= =?UTF-8?q?=D1=84=D0=B8=D0=BD=D0=B0=D0=BB=D1=8C=D0=BD=D0=BE=D0=B9=20=D0=BC?= =?UTF-8?q?=D0=B0=D1=82=D1=80=D0=B8=D1=86=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../game/LateLevelScenarios.java | 9 +++++--- .../game/EarlyLevelScenarios.java | 2 +- .../com/projectsacrifice/game/GameView.java | 23 +++++++++++++++++++ .../game/LevelPlaytestBotTest.java | 1 + 4 files changed, 31 insertions(+), 4 deletions(-) diff --git a/app/src/androidTest/java/com/projectsacrifice/game/LateLevelScenarios.java b/app/src/androidTest/java/com/projectsacrifice/game/LateLevelScenarios.java index 0cc41de..2335c23 100644 --- a/app/src/androidTest/java/com/projectsacrifice/game/LateLevelScenarios.java +++ b/app/src/androidTest/java/com/projectsacrifice/game/LateLevelScenarios.java @@ -17,7 +17,8 @@ final class LateLevelScenarios { LevelPlaytestBot.Input.rightJump(), snapshot -> snapshot.playerX() >= 640f) .step("Stand on the frozen block", 2_500L, LevelPlaytestBot.Input.none(), - snapshot -> snapshot.playerX() >= 626f + snapshot -> snapshot.playerOnGround() + && snapshot.playerX() >= 626f && snapshot.playerY() >= 668f && snapshot.playerY() <= 690f) .step("Land on the first ledge from the frozen block", 3_000L, @@ -277,7 +278,8 @@ final class LateLevelScenarios { && (snapshot.playerOnMovingPlatform() || (snapshot.playerY() >= 770f && !Float.isNaN(snapshot.firstMovingPlatformY()) - && snapshot.firstMovingPlatformY() >= 780f))) + && snapshot.firstMovingPlatformY() >= 750f + && snapshot.firstMovingPlatformMovingDown()))) .step("Take off toward the moving lift", 1_000L, LevelPlaytestBot.Input.rightJump(), snapshot -> snapshot.playerAlive() @@ -285,7 +287,8 @@ final class LateLevelScenarios { || snapshot.playerY() <= 670f)) .step("Board the moving lift", 2_000L, LevelPlaytestBot.Input.right(), - GameView.AutomationSnapshot::playerOnMovingPlatform) + LevelPlaytestBot.whileDeathsAtMost(3, + GameView.AutomationSnapshot::playerOnMovingPlatform)) .step("Brake toward the lift center", 1_000L, LevelPlaytestBot.Input.left(), snapshot -> snapshot.playerOnMovingPlatform() diff --git a/app/src/main/java/com/projectsacrifice/game/EarlyLevelScenarios.java b/app/src/main/java/com/projectsacrifice/game/EarlyLevelScenarios.java index 995941f..0432212 100644 --- a/app/src/main/java/com/projectsacrifice/game/EarlyLevelScenarios.java +++ b/app/src/main/java/com/projectsacrifice/game/EarlyLevelScenarios.java @@ -69,7 +69,7 @@ public final class EarlyLevelScenarios { .step("Approach the saw", 3_000L, LevelPlaytestBot.Input.right(), LevelPlaytestBot.whileDeathsAtMost(1, snapshot -> snapshot.playerAlive() && snapshot.playerOnGround() - && snapshot.playerX() >= 390f)) + && snapshot.playerX() >= 360f)) .step("Take off before the saw", 1_000L, LevelPlaytestBot.Input.rightJump(), LevelPlaytestBot.whileDeathsAtMost(1, snapshot -> snapshot.playerAlive() && !snapshot.playerOnGround() diff --git a/app/src/main/java/com/projectsacrifice/game/GameView.java b/app/src/main/java/com/projectsacrifice/game/GameView.java index 195fbea..87dcde9 100644 --- a/app/src/main/java/com/projectsacrifice/game/GameView.java +++ b/app/src/main/java/com/projectsacrifice/game/GameView.java @@ -60,6 +60,7 @@ public final class GameView extends View implements Runnable { private final boolean playerOnMovingPlatform; private final boolean firstMovingPlatformPowered; private final boolean firstMovingPlatformMovingRight; + private final boolean firstMovingPlatformMovingDown; private final float firstCorpseX; private final boolean playerOnGround; @@ -95,6 +96,23 @@ public final class GameView extends View implements Runnable { boolean firstMovingPlatformPowered, boolean firstMovingPlatformMovingRight, float firstCorpseX, boolean playerOnGround) { + this(completed, playerAlive, allDoorsOpen, visiblyPaused, inputActive, levelNumber, + elapsedMilliseconds, playerX, playerY, deaths, corpseCount, + firstMovingPlatformX, firstMovingPlatformY, playerOnMovingPlatform, + firstMovingPlatformPowered, firstMovingPlatformMovingRight, false, + firstCorpseX, playerOnGround); + } + + AutomationSnapshot(boolean completed, boolean playerAlive, boolean allDoorsOpen, + boolean visiblyPaused, boolean inputActive, int levelNumber, + long elapsedMilliseconds, + float playerX, float playerY, int deaths, int corpseCount, + float firstMovingPlatformX, float firstMovingPlatformY, + boolean playerOnMovingPlatform, + boolean firstMovingPlatformPowered, + boolean firstMovingPlatformMovingRight, + boolean firstMovingPlatformMovingDown, float firstCorpseX, + boolean playerOnGround) { this.completed = completed; this.playerAlive = playerAlive; this.allDoorsOpen = allDoorsOpen; @@ -111,6 +129,7 @@ public final class GameView extends View implements Runnable { this.playerOnMovingPlatform = playerOnMovingPlatform; this.firstMovingPlatformPowered = firstMovingPlatformPowered; this.firstMovingPlatformMovingRight = firstMovingPlatformMovingRight; + this.firstMovingPlatformMovingDown = firstMovingPlatformMovingDown; this.firstCorpseX = firstCorpseX; this.playerOnGround = playerOnGround; } @@ -131,6 +150,7 @@ public final class GameView extends View implements Runnable { public boolean playerOnMovingPlatform() { return playerOnMovingPlatform; } public boolean firstMovingPlatformPowered() { return firstMovingPlatformPowered; } public boolean firstMovingPlatformMovingRight() { return firstMovingPlatformMovingRight; } + public boolean firstMovingPlatformMovingDown() { return firstMovingPlatformMovingDown; } public float firstCorpseX() { return firstCorpseX; } public boolean playerOnGround() { return playerOnGround; } } @@ -1977,12 +1997,14 @@ public final class GameView extends View implements Runnable { float firstMovingPlatformY = Float.NaN; boolean firstMovingPlatformPowered = false; boolean firstMovingPlatformMovingRight = false; + boolean firstMovingPlatformMovingDown = false; for (PlatformState state : platformStates) { if (state.definition.isMoving()) { firstMovingPlatformX = state.x; firstMovingPlatformY = state.y; firstMovingPlatformPowered = signalActive(state.definition.controlId); firstMovingPlatformMovingRight = state.x > state.previousX; + firstMovingPlatformMovingDown = state.y > state.previousY; break; } } @@ -2007,6 +2029,7 @@ public final class GameView extends View implements Runnable { playerOnMovingPlatform, firstMovingPlatformPowered, firstMovingPlatformMovingRight, + firstMovingPlatformMovingDown, corpses.isEmpty() ? Float.NaN : corpses.get(0).x, player.onGround ); diff --git a/app/src/test/java/com/projectsacrifice/game/LevelPlaytestBotTest.java b/app/src/test/java/com/projectsacrifice/game/LevelPlaytestBotTest.java index 53774dc..9c6d456 100644 --- a/app/src/test/java/com/projectsacrifice/game/LevelPlaytestBotTest.java +++ b/app/src/test/java/com/projectsacrifice/game/LevelPlaytestBotTest.java @@ -137,6 +137,7 @@ public final class LevelPlaytestBotTest { assertTrue(Float.isNaN(snapshot.firstMovingPlatformY())); assertFalse(snapshot.playerOnMovingPlatform()); + assertFalse(snapshot.firstMovingPlatformMovingDown()); } @Test From 55b90fb0a51f9d9093ef2aaf81432f5996967ca5 Mon Sep 17 00:00:00 2001 From: "E.Gavrilov" Date: Tue, 18 Aug 2026 19:28:51 +0300 Subject: [PATCH 13/13] =?UTF-8?q?=D0=97=D0=B0=D0=B2=D0=B5=D1=80=D1=88?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20=D1=82=D1=80=D0=B5=D1=82=D0=B8=D0=B9=20?= =?UTF-8?q?=D1=8D=D1=82=D0=B0=D0=BF=20=D0=B8=20=D0=BF=D0=BE=D0=B4=D0=B3?= =?UTF-8?q?=D0=BE=D1=82=D0=BE=D0=B2=D0=B8=D1=82=D1=8C=20=D1=81=D0=B1=D0=BE?= =?UTF-8?q?=D1=80=D0=BA=D1=83=200.6.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 12 +++- app/build.gradle | 4 +- docs/CURRENT_ROADMAP.md | 17 +++++- docs/STAGE3_ACCESSIBILITY_QA.md | 6 ++ docs/STAGE3_LOCALIZATION_QA.md | 6 ++ docs/STAGE3_ONBOARDING_RESULT_QA.md | 7 +++ docs/STAGE3_QA_REPORT.md | 93 +++++++++++++++++++++++++++++ 7 files changed, 139 insertions(+), 6 deletions(-) create mode 100644 docs/STAGE3_QA_REPORT.md diff --git a/README.md b/README.md index e6b7a67..9a20ccf 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,13 @@ Death does not reset the level. A new volunteer respawns after a short delay whi - `Restart` restores the current level. - `II` pauses the game and exposes level selection. - `Settings` on the level-select screen cycles three control sizes, three - opacity presets, and vibration on/off. Changes persist across app restarts. + opacity presets, vibration on/off, Standard/Large text, high contrast, and + English/Russian. Changes persist across app restarts without resetting progress. + +The first level includes a three-step, non-blocking tutorial for movement, +multitouch jumping, and using retained bodies. Completion shows the current run, +saved best, targets, a next goal, and Level select/Replay/Next level routes. Critical +signal, door, and respawn state remains visible when vibration is disabled. The movement constants include 130 ms coyote time and 140 ms input buffering. @@ -59,6 +65,10 @@ matrix with: ./gradlew connectedDebugAndroidTest ``` +The Stage 3 device suite additionally checks persisted controls, RU/EN copy, +accessibility presets, tutorial/result navigation, Back behavior, and safe viewports +on 20:9, 16:9, and 16:10 tablet-sized displays. + The debug APK is written to `app/build/outputs/apk/debug/app-debug.apk`. It is signed with Android's standard debug key and can be installed with: ```sh diff --git a/app/build.gradle b/app/build.gradle index ca520fa..16efb37 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -10,8 +10,8 @@ android { applicationId 'com.projectsacrifice.game' minSdk 23 targetSdk 34 - versionCode 6 - versionName '0.5.0-stage3-controls' + versionCode 7 + versionName '0.6.0-stage3-complete' testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner' } diff --git a/docs/CURRENT_ROADMAP.md b/docs/CURRENT_ROADMAP.md index 9c00773..cbb15ed 100644 --- a/docs/CURRENT_ROADMAP.md +++ b/docs/CURRENT_ROADMAP.md @@ -6,7 +6,7 @@ ## 1. Текущее состояние -Инженерная часть этапа 2 завершена; этап 3 начат с настроек мобильного управления: +Инженерная часть этапов 2 и 3 завершена: - реализованы 10 последовательных уровней; - работают движение, прыжок и мобильное multitouch-управление; @@ -19,6 +19,12 @@ - тело, доставленное пилой на `PRESSURE` уровня 10, фиксируется после оседания и больше не соскальзывает с плиты; - restart из промежуточного состояния уровня 10 очищает тела, сигналы, таймер и удерживаемый ввод; - Settings screen сохраняет размер и opacity touch-controls и режим вибрации; три form-factor контракта и защита от повторного held-jump покрыты device-тестами; +- добавлены сохраняемые Standard/Large text и high-contrast режимы, видимый статус сигналов/дверей/respawn и локализованные content descriptions; +- короткое трёхшаговое обучение не блокирует gameplay и после Skip/Done не показывается повторно; +- экран результата разделяет текущий и лучший результаты, показывает targets, следующую цель и рабочие Level select/Replay/Next level переходы; +- весь Canvas UI, десять названий и подсказок доступны на русском и английском; язык хранится отдельно и не меняет progress/control settings; +- Stage 3 focused matrix: 14/14 методов без retries; UI contracts GREEN на 20:9, 16:9 и логическом 16:10 tablet viewport; +- final Stage 3 Android 16 matrix: `OK (30 tests)` без filters/retries за `1,409.366 s`; все intended routes 1–10, recovery/stress, lifecycle, restart и UI/persistence contracts GREEN; - final integrated Android 16 matrix: 16/16 методов, 36 игровых выполнений, lifecycle и restart; wall time `9:32.496`; - финальный debug APK проверен на Android 16: установка, холодный запуск, ZIP-структура и v1/v2-подписи корректны. @@ -176,10 +182,15 @@ Performance-оговорка: исторический критерий этап 8. холодный запуск и проверка crash-буфера; 9. ручной smoke-тест уровня 1 и одного сложного комбинированного уровня. -## 5. Завершение этапа 2 и следующая итерация +## 5. Завершение этапов 2–3 и следующая итерация Инженерный scope этапа 2 закрыт: уровни 1–10 проходят intended route по три раза, уровень 8 имеет restart-free recovery, уровень 10 использует все три тела и сохраняет тело на lift-плите, а restart возвращает чистое состояние. Передаваемые результаты — обновлённый проект, проверенный APK и [`STAGE2_QA_REPORT.md`](STAGE2_QA_REPORT.md). -Этап 3 начат задачей `goon-game-controls-settings`: добавлены сохраняемые пресеты размера/opacity controls и вибрации, фактическое расширение touch target и защита от повторного прыжка при удержании. Проверки выполнены на 20:9, логическом 16:9 и tablet-sized экране; подробности находятся в [`STAGE3_CONTROLS_QA.md`](STAGE3_CONTROLS_QA.md). Следующие slices этапа 3 — accessibility, обучение/result screen и RU/EN локализация. +Этап 3 завершён: к сохраняемым пресетам управления добавлены accessibility, +неблокирующее обучение, полный result flow и RU/EN локализация. Совместный +device-gate проверяет сохранность progress/settings, а UI contracts выполнены на +20:9, 16:9 и tablet-sized экране. Подробный итог находится в +[`STAGE3_QA_REPORT.md`](STAGE3_QA_REPORT.md). Следующая инженерная итерация — этап +4, перенос описаний уровней в валидируемый формат и инструменты контента. Параллельно внешний QA должен выполнить `goon-game-human-playtest`: по две ручные сессии каждого уровня без подсказок разработчика. До получения этого протокола milestone Vertical Slice нельзя называть принятым внешними тестировщиками. diff --git a/docs/STAGE3_ACCESSIBILITY_QA.md b/docs/STAGE3_ACCESSIBILITY_QA.md index 52f4603..1a2edd1 100644 --- a/docs/STAGE3_ACCESSIBILITY_QA.md +++ b/docs/STAGE3_ACCESSIBILITY_QA.md @@ -54,3 +54,9 @@ - localized строки должны заменить английский текст и в Canvas, и в content descriptions одним источником, не удаляя read-only acceptance hooks; - minSdk 23, control settings, progress format и Stage 2 physics/levels не менялись. + +## Финальная интеграция + +Все пять device-методов выполнены в составе unfiltered Android 16 gate: +`OK (30 tests)`, `1,409.366 s`. Итоговая сборка и form-factor evidence описаны в +[`STAGE3_QA_REPORT.md`](STAGE3_QA_REPORT.md). diff --git a/docs/STAGE3_LOCALIZATION_QA.md b/docs/STAGE3_LOCALIZATION_QA.md index e3580b5..a635efc 100644 --- a/docs/STAGE3_LOCALIZATION_QA.md +++ b/docs/STAGE3_LOCALIZATION_QA.md @@ -57,3 +57,9 @@ recreation, progress, control size, opacity, and vibration preservation. - `LevelCatalog.java` and level geometry are not modified. Canvas rendering now uses `GameStrings.levelTitle/levelHint`; the existing English fields remain compatible with gameplay tests and older callers. + +## Final integration + +`GameLanguagePersistenceTest` and the combined Stage 3 RU/EN acceptance tests passed +inside the unfiltered Android 16 gate: `OK (30 tests)`, `1,409.366 s`. See +[`STAGE3_QA_REPORT.md`](STAGE3_QA_REPORT.md) for the release evidence. diff --git a/docs/STAGE3_ONBOARDING_RESULT_QA.md b/docs/STAGE3_ONBOARDING_RESULT_QA.md index 04ae5c6..604abe1 100644 --- a/docs/STAGE3_ONBOARDING_RESULT_QA.md +++ b/docs/STAGE3_ONBOARDING_RESULT_QA.md @@ -55,3 +55,10 @@ BUILD SUCCESSFUL `Saved best`. 4. По очереди проверить `Level select`, `Replay`, `Next level`; завершённость уровня, unlocked level и control preset должны сохраниться. + +## Финальная интеграция + +`OnboardingStoreTest`, оба `OnboardingAndResultFlowTest` и объединённый RU/EN +acceptance прошли в составе unfiltered Android 16 gate: `OK (30 tests)`, +`1,409.366 s`. Релизная версия назначена `0.6.0-stage3-complete`; общий отчёт: +[`STAGE3_QA_REPORT.md`](STAGE3_QA_REPORT.md). diff --git a/docs/STAGE3_QA_REPORT.md b/docs/STAGE3_QA_REPORT.md new file mode 100644 index 0000000..2805c51 --- /dev/null +++ b/docs/STAGE3_QA_REPORT.md @@ -0,0 +1,93 @@ +# Stage 3 QA report + +Дата: 18 августа 2026 года. + +Версия: `0.6.0-stage3-complete` (`versionCode=7`). + +## Результат + +Инженерный scope этапа 3 завершён. В общую сборку интегрированы: + +- сохраняемые размер и прозрачность touch-controls и режим вибрации; +- Standard/Large text, high contrast, видимые состояния сигналов, дверей и respawn; +- локализованные accessibility descriptions; +- полная RU/EN локализация Canvas UI, десяти названий и подсказок уровней; +- неблокирующее трёхшаговое обучение с постоянным Skip/Done; +- result screen с текущим и лучшим результатом, targets, следующей целью и + рабочими переходами Levels/Replay/Next; +- совместная сохранность языка, accessibility, controls и прогресса после recreate; +- state-driven боты уровней 1–10, restart-free recovery уровня 8 и проверка + неподвижного тела на lift-плите уровня 10. + +## Автоматические проверки + +Финальный clean gate: + +```text +./gradlew clean testDebugUnitTest lintDebug assembleDebug assembleDebugAndroidTest +BUILD SUCCESSFUL +66 actionable tasks +42/42 JVM tests +``` + +Финальный unfiltered device gate выполнен на холодно запущенном Android 16 AVD, +физический размер `1080x2400` (20:9), после установки точных APK clean-сборки и +`pm clear com.projectsacrifice.game.debug`: + +```text +AndroidJUnitRunner +Tests run: 30 +Time: 1,409.366 s +OK (30 tests) +``` + +Runner запускался без `-e class`, retries и повторного запуска отдельных методов. +Матрица включает все intended routes уровней 1–10 по три раза, дополнительные +stressed/recovery маршруты уровня 8, throttled mastery и sliding-body regression +уровня 10, lifecycle, restart и все Stage 3 persistence/UI contracts. + +До финального объединения UI contracts также прошли на логических viewport: + +- 16:9 safe viewport — GREEN, `66.208 s`; +- 16:10 tablet-sized `2560x1600` landscape — GREEN, `167.823 s`; +- focused Stage 3 matrix — 14/14 GREEN, `576.355 s`. + +Последующие изменения касались automation/read-only snapshot и не меняли UI +геометрию; финальный 20:9 gate повторно проверил весь интегрированный интерфейс. + +## RED → GREEN evidence + +Первый полный 30-тестовый прогон был завершён полностью и дал 27/30 GREEN. Три +нагрузочные границы были исправлены без изменения физики уровней или retry: + +1. Level 3: takeoff начинался на несколько пикселей поздно (`x=505.67633`, + `y=664.41797`, лишняя смерть). State-driven точка старта получила запас и + повторно прошла 3/3 внутри полного load-order. +2. Level 6: координатный checkpoint не доказывал опору на frozen block; добавлен + обязательный `playerOnGround`, после чего landing route прошёл 3/3. +3. Level 10: бот мог прыгнуть на лифт во время его восходящей фазы и упасть в PIT + (`x=1942.7063`, deaths=4). Snapshot расширен read-only направлением движения; + посадка разрешается на нисходящей фазе, normal 3/3 и throttled route GREEN. + +Level 8 отдельно получил исходящую фазу горизонтальной платформы, безопасное +позиционирование под recovery relay и перед финальным прыжком. Финальный suite +подтвердил stressed 1/1, intended 3/3 и recovery 3/3 без смерти/restart. + +## Проверка APK + +Финальный `app-debug.apk` проверен как самостоятельный устанавливаемый артефакт: + +- ZIP/APK-структура: `unzip -t` без ошибок; +- package: `com.projectsacrifice.game.debug`; +- версия: `versionCode=7`, `versionName=0.6.0-stage3-complete-debug`; +- `minSdkVersion=23`, `targetSdkVersion=34`; +- подпись: `apksigner verify` GREEN, схемы v1 и v2; +- SHA-256: `7be25ea96cf0de7ea1930335b3570bd9def93cdb48029e24e4c9c14078e4195d`; +- clean install и cold launch на Android 16: `Status: ok`, `LaunchState: COLD`, + `TotalTime: 1401 ms`; crash-буфер пуст. + +## Границы приёмки + +Инженерный Stage 3 gate закрыт. Отдельная задача ручного внешнего playtest остаётся +открытой: реальные тестировщики должны пройти каждый уровень без подсказок +разработчика. Этот внешний протокол не подменяется автоматическими ботами.