Завершить этап 3

This commit is contained in:
2026-08-18 19:29:33 +03:00
42 changed files with 3254 additions and 178 deletions
+11 -1
View File
@@ -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
+2 -2
View File
@@ -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'
}
@@ -0,0 +1,166 @@
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<MainActivity> 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<MainActivity> 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<MainActivity> 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<MainActivity> activity = ActivityScenario.launch(MainActivity.class)) {
activity.onActivity(current -> {
GameView view = current.gameViewForAutomation();
view.resetControlSettingsForAutomation();
view.toggleHapticsForAutomation();
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.resetControlSettingsForAutomation();
view.resetAccessibilitySettingsForAutomation();
});
}
}
@Test
public void criticalSignalAndDoorStateRemainsTextualWhenVibrationIsOff() {
try (ActivityScenario<MainActivity> 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<MainActivity> activity, float canvasX, float canvasY) {
touch(activity, MotionEvent.ACTION_DOWN, canvasX, canvasY);
touch(activity, MotionEvent.ACTION_UP, canvasX, canvasY);
}
private static void touch(ActivityScenario<MainActivity> 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));
}
}
@@ -22,13 +22,13 @@ public final class ControlSettingsPersistenceTest {
try (ActivityScenario<MainActivity> 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();
@@ -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<MainActivity> 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<MainActivity> 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();
});
}
}
@@ -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<MainActivity> 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());
@@ -49,13 +52,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;
@@ -17,13 +17,17 @@ 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("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,18 +121,29 @@ 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(),
snapshot -> snapshot.playerX() >= 350f)
LevelPlaytestBot.whileDeathsAtMost(1,
LevelPlaytestBot.groundedAliveAtOrBeyond(320f)))
.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() <= 580f))
.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 +158,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(320f)))
.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() <= 580f))
.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 +211,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(320f)))
.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() <= 580f))
.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)
@@ -251,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()
@@ -259,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()
@@ -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,215 @@ 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, "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());
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",
LevelPlaytestBot.Input.left(),
current -> current.playerX() <= 1_680f && current.playerY() >= 779f);
if (failed(snapshot, deadline, driver)) {
return failure("Return beneath the recovery relay", snapshot);
snapshot = positionBeneathRecoveryRelay(driver, budget);
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 = pulseRightJumpThenAwait(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",
LevelPlaytestBot.Input.none(), current -> current.playerY() >= 779f);
if (failed(snapshot, deadline, driver)) {
return failure("Land after rearming the clock", snapshot);
snapshot = await(driver, budget, "Land after rearming the clock",
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);
}
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 outbound moving platform",
LevelPlaytestBot.Input.none(),
current -> current.elapsedMilliseconds() >= timerStartedAt + 2_250L
&& current.firstMovingPlatformX() <= 1_050f);
if (failed(snapshot, deadline, driver)) return snapshot;
&& MovingPlatformLaunchPolicy.readyToBoard(current, 1_050f));
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",
LevelPlaytestBot.Input.right(),
current -> current.playerX() >= 1_730f && current.allDoorsOpen());
if (failed(snapshot, deadline, driver)) {
return failure("Reach the open-door jump", snapshot);
snapshot = positionForFinalJump(driver, budget);
if (failed(snapshot, budget, driver) || !snapshot.allDoorsOpen()) {
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,
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(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);
return await(driver, deadline, step, LevelPlaytestBot.Input.left(), completion);
} else {
driver.setInput(false, true, false);
}
driver.waitForNextDecision(DECISION_MILLIS);
snapshot = driver.snapshot();
}
driver.setInput(false, false, false);
return snapshot;
}
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 +250,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 +273,12 @@ 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()
+ ", platformMovingRight=" + snapshot.firstMovingPlatformMovingRight();
}
}
@@ -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<MainActivity> 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<MainActivity> 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();
});
}
}
}
@@ -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());
}
}
@@ -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<MainActivity> 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<MainActivity> 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<String> 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<String> 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<MainActivity> 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();
});
}
}
@@ -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);
}
}
@@ -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();
}
}
@@ -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();
}
}
@@ -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<String> 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<String> sourceIds) {
ArrayList<String> 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<String> 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;
}
}
@@ -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);
}
}
@@ -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; }
}
@@ -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,9 +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.playerX() >= 390f)
.step("Jump over the saw and plate body", 3_000L, LevelPlaytestBot.Input.rightJump(),
snapshot -> snapshot.playerX() >= 700f)
LevelPlaytestBot.whileDeathsAtMost(1, snapshot -> snapshot.playerAlive()
&& snapshot.playerOnGround()
&& snapshot.playerX() >= 360f))
.step("Take off before the saw", 1_000L, LevelPlaytestBot.Input.rightJump(),
LevelPlaytestBot.whileDeathsAtMost(1, snapshot -> snapshot.playerAlive()
&& !snapshot.playerOnGround()
&& snapshot.playerY() <= 620f))
.step("Clear the saw and plate body", 3_000L, LevelPlaytestBot.Input.right(),
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();
@@ -76,7 +85,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(),
@@ -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;
}
}
@@ -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();
}
}
@@ -0,0 +1,336 @@
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 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) {
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 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)];
}
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;
}
}
File diff suppressed because it is too large Load Diff
@@ -6,6 +6,10 @@ 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;
public interface Driver {
void startLevel(int oneBasedLevelNumber);
void setInput(boolean moveLeft, boolean moveRight, boolean jump);
@@ -44,6 +48,27 @@ 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()
+ "; x=" + snapshot.playerX()
+ ", y=" + snapshot.playerY()
+ ", alive=" + snapshot.playerAlive()
+ ", grounded=" + snapshot.playerOnGround());
}
return completion.reached(snapshot);
};
}
public static final class Scenario {
private final int levelNumber;
private final String name;
@@ -123,22 +148,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,26 +187,50 @@ 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);
}
driver.setInput(step.input.moveLeft, step.input.moveRight, step.input.jump);
driver.waitForNextDecision(32L);
driver.waitForNextDecision(DECISION_MILLIS);
}
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()
@@ -190,8 +255,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(),
@@ -0,0 +1,19 @@
package com.projectsacrifice.game;
/** Stable public-state gate for a single moving-platform launch. */
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()
&& snapshot.firstMovingPlatformPowered()
&& snapshot.firstMovingPlatformX() >= minimumPlatformX;
}
}
@@ -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;
}
}
@@ -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();
}
}
@@ -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);
}
}
@@ -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"));
}
}
@@ -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);
}
}
@@ -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));
}
}
@@ -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)
);
}
}
@@ -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());
}
}
@@ -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));
}
}
}
@@ -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;
@@ -68,6 +69,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();
@@ -82,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(
@@ -90,6 +137,41 @@ public final class LevelPlaytestBotTest {
assertTrue(Float.isNaN(snapshot.firstMovingPlatformY()));
assertFalse(snapshot.playerOnMovingPlatform());
assertFalse(snapshot.firstMovingPlatformMovingDown());
}
@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 {
@@ -160,7 +242,52 @@ 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 final boolean overshootApproach;
private int startedLevel;
private long nowMillis;
private boolean moveRight;
@@ -171,6 +298,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) {
@@ -187,7 +324,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
@@ -196,6 +333,7 @@ public final class LevelPlaytestBotTest {
if (!alive) {
alive = true;
x = 130f;
y = 760f;
} else if (deaths == 0 && moveRight && !jump) {
deaths = 1;
corpses = 1;
@@ -203,11 +341,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) {
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) {
@@ -0,0 +1,40 @@
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 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, 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,
boolean movingRight
) {
return new GameView.AutomationSnapshot(
false, true, true, false, false, 8,
4_000L, 1_100f, 670f, 0, 0,
platformX, 770f, supported, powered, movingRight, Float.NaN, grounded
);
}
}
@@ -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());
}
}
@@ -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));
}
}
+14 -3
View File
@@ -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 нельзя называть принятым внешними тестировщиками.
+62
View File
@@ -0,0 +1,62 @@
# 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 не менялись.
## Финальная интеграция
Все пять device-методов выполнены в составе unfiltered Android 16 gate:
`OK (30 tests)`, `1,409.366 s`. Итоговая сборка и form-factor evidence описаны в
[`STAGE3_QA_REPORT.md`](STAGE3_QA_REPORT.md).
+65
View File
@@ -0,0 +1,65 @@
# 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.
## 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.
+64
View File
@@ -0,0 +1,64 @@
# 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 должны сохраниться.
## Финальная интеграция
`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).
+93
View File
@@ -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 остаётся
открытой: реальные тестировщики должны пройти каждый уровень без подсказок
разработчика. Этот внешний протокол не подменяется автоматическими ботами.