Добавить испытания и прогрессию по LevelID

This commit is contained in:
2026-08-19 09:25:32 +03:00
parent 259fcd316b
commit 3de8d45039
37 changed files with 968 additions and 63 deletions
@@ -0,0 +1,53 @@
package com.projectsacrifice.game;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
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 ChallengeCollectibleIntegrationTest {
@Test
public void collectedCacheSurvivesRecreationAndRestartResetsAttemptState() {
try (ActivityScenario<MainActivity> activity = ActivityScenario.launch(MainActivity.class)) {
activity.onActivity(current -> {
GameView view = current.gameViewForAutomation();
view.enableDeterministicAutomationClock();
view.startLevelForAutomation(18);
assertTrue(view.automationSnapshot().collectibleVisible());
view.setAutomationInput(false, true, false);
for (int decision = 0; decision < 400
&& view.automationSnapshot().playerX() < 650f; decision++) {
view.advanceDeterministicAutomation(16L);
}
assertTrue("approach x=" + view.automationSnapshot().playerX(),
view.automationSnapshot().playerX() >= 650f);
view.setAutomationInput(false, true, true);
for (int decision = 0; decision < 180
&& !view.automationSnapshot().collectibleFound(); decision++) {
view.advanceDeterministicAutomation(16L);
}
view.setAutomationInput(false, false, false);
assertTrue(view.automationSnapshot().collectibleFound());
assertFalse(view.automationSnapshot().collectibleVisible());
assertTrue(view.sessionSnapshotForAutomation().collectibleFound());
});
activity.recreate();
activity.onActivity(current -> {
GameView view = current.gameViewForAutomation();
assertTrue(view.automationSnapshot().visiblyPaused());
assertTrue(view.automationSnapshot().collectibleFound());
assertFalse(view.automationSnapshot().collectibleVisible());
view.startLevelForAutomation(18);
assertFalse(view.automationSnapshot().collectibleFound());
assertTrue(view.automationSnapshot().collectibleVisible());
});
}
}
}
@@ -0,0 +1,95 @@
package com.projectsacrifice.game;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import android.app.Instrumentation;
import android.content.Context;
import androidx.test.core.app.ActivityScenario;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.platform.app.InstrumentationRegistry;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
@RunWith(AndroidJUnit4.class)
public final class ChallengePlaythroughTest {
private Context context;
@Before
public void resetProgress() {
context = InstrumentationRegistry.getInstrumentation().getTargetContext();
context.getSharedPreferences("project_sacrifice_progress_v1", Context.MODE_PRIVATE)
.edit().clear().commit();
}
@After
public void cleanProgress() {
context.getSharedPreferences("project_sacrifice_progress_v1", Context.MODE_PRIVATE)
.edit().clear().commit();
}
@Test
public void intendedRouteEarnsAndPersistsAllThreeStarsThreeTimes() {
try (ActivityScenario<MainActivity> activity = ActivityScenario.launch(MainActivity.class)) {
Driver driver = Driver.from(activity);
for (int run = 1; run <= 3; run++) {
LevelPlaytestBot.Result result = new LevelPlaytestBot().run(
driver, ChallengeScenarios.levelEighteen(), 20_000L);
assertTrue("run=" + run + "; " + result.diagnostic(), result.completed());
assertEquals(0, result.deaths());
assertTrue(driver.snapshot().collectibleFound());
assertEquals(3, driver.result().challengeResult().stars());
assertEquals(3, driver.result().bestStars());
}
assertEquals(3, new ProgressStore(context).earnedStars(LevelCatalog.get(17)));
}
}
private static final class Driver implements LevelPlaytestBot.Driver {
private final Instrumentation instrumentation;
private final GameView view;
private Driver(Instrumentation instrumentation, GameView view) {
this.instrumentation = instrumentation;
this.view = view;
}
static Driver from(ActivityScenario<MainActivity> activity) {
GameView[] target = new GameView[1];
activity.onActivity(current -> target[0] = current.gameViewForAutomation());
return new Driver(InstrumentationRegistry.getInstrumentation(), target[0]);
}
@Override public void startLevel(int oneBasedLevelNumber) {
instrumentation.runOnMainSync(() -> {
view.enableDeterministicAutomationClock();
view.startLevelForAutomation(oneBasedLevelNumber);
});
}
@Override public void setInput(boolean left, boolean right, boolean jump) {
instrumentation.runOnMainSync(() -> view.setAutomationInput(left, right, jump));
}
@Override public GameView.AutomationSnapshot snapshot() {
GameView.AutomationSnapshot[] value = new GameView.AutomationSnapshot[1];
instrumentation.runOnMainSync(() -> value[0] = view.automationSnapshot());
return value[0];
}
CompletionResult result() {
CompletionResult[] value = new CompletionResult[1];
instrumentation.runOnMainSync(() -> value[0] = view.completionResultForAutomation());
return value[0];
}
@Override public void waitForNextDecision(long millis) {
instrumentation.runOnMainSync(() ->
view.advanceDeterministicAutomation(Math.max(16L, millis)));
}
}
}
@@ -0,0 +1,77 @@
package com.projectsacrifice.game;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import android.content.Context;
import android.content.SharedPreferences;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.platform.app.InstrumentationRegistry;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
@RunWith(AndroidJUnit4.class)
public final class ChallengeProgressPersistenceTest {
private Context context;
private SharedPreferences raw;
@Before
public void clearProgress() {
context = InstrumentationRegistry.getInstrumentation().getTargetContext();
raw = context.getSharedPreferences("project_sacrifice_progress_v1", Context.MODE_PRIVATE);
raw.edit().clear().commit();
}
@After
public void cleanProgress() {
raw.edit().clear().commit();
}
@Test
public void numericV2ProgressMigratesToStableLevelIdWithoutLoss() {
LevelDefinition level = LevelCatalog.get(0);
raw.edit().putInt("save_version", 2)
.putBoolean("level_1_completed", true)
.putInt("level_1_deaths", 2)
.putLong("level_1_time", 41_000L)
.commit();
ProgressStore progress = new ProgressStore(context);
assertEquals(3, progress.saveVersion());
assertTrue(progress.isCompleted(level.levelId));
assertEquals(2, progress.bestDeaths(level.levelId));
assertEquals(41_000L, progress.bestTimeMs(level.levelId));
assertTrue(raw.contains("level_id_" + level.levelId + "_completed"));
}
@Test
public void independentRunsAccumulateBestThreeStarsByLevelId() {
LevelDefinition base = LevelCatalog.get(0);
LevelDefinition level = new LevelDefinition(
base.formatVersion, base.physicsVersion, "foundations.star-lab", "foundations",
base.number, base.title, base.hint, base.titleRu, base.hintRu,
base.worldWidth, base.worldHeight, base.spawnX, base.spawnY, base.goal,
base.targetDeaths, base.targetSeconds,
new LevelDefinition.Box(500f, 700f, 50f, 50f), base.objects,
base.platforms, base.hazards, base.switches, base.doors);
ProgressStore progress = new ProgressStore(context);
progress.recordCompletion(level, base.targetDeaths,
base.targetSeconds * 1_000L + 5_000L, false);
assertEquals(1, progress.earnedStars(level));
progress.recordCompletion(level, base.targetDeaths + 2,
base.targetSeconds * 1_000L - 1_000L, true);
assertEquals(3, progress.earnedStars(level));
assertTrue(progress.collectibleFound(level.levelId));
assertEquals(base.targetDeaths, progress.bestDeaths(level.levelId));
assertEquals(base.targetSeconds * 1_000L - 1_000L,
progress.bestTimeMs(level.levelId));
}
}
@@ -0,0 +1,28 @@
package com.projectsacrifice.game;
/** Production-input route for the first three-goal challenge level. */
final class ChallengeScenarios {
private ChallengeScenarios() {}
static LevelPlaytestBot.Scenario levelEighteen() {
return LevelPlaytestBot.Scenario.builder(18, "Level 18 - threefold proof")
.step("Approach the optional cache", 3_000L,
LevelPlaytestBot.Input.right(),
LevelPlaytestBot.whileDeathsAtMost(0,
LevelPlaytestBot.groundedAliveAtOrBeyond(650f)))
.step("Jump through the optional cache", 2_000L,
LevelPlaytestBot.Input.rightJump(),
LevelPlaytestBot.whileDeathsAtMost(0,
snapshot -> snapshot.playerAlive()
&& snapshot.collectibleFound()))
.step("Land beyond the cache", 2_500L,
LevelPlaytestBot.Input.right(),
LevelPlaytestBot.whileDeathsAtMost(0,
LevelPlaytestBot.groundedAliveAtOrBeyond(1_050f)))
.step("Reach the relic within both efficiency targets", 5_000L,
LevelPlaytestBot.Input.right(),
LevelPlaytestBot.whileDeathsAtMost(0,
GameView.AutomationSnapshot::completed))
.build();
}
}
@@ -218,8 +218,10 @@ public final class LateLevelPlaythroughTest {
@Override
public void startLevel(int oneBasedLevelNumber) {
instrumentation.runOnMainSync(
() -> gameView.startLevelForAutomation(oneBasedLevelNumber));
instrumentation.runOnMainSync(() -> {
gameView.enableDeterministicAutomationClock();
gameView.startLevelForAutomation(oneBasedLevelNumber);
});
}
@Override
@@ -238,19 +240,16 @@ public final class LateLevelPlaythroughTest {
@Override
public void waitForNextDecision(long millis) {
try {
boolean inFinalTraversalWindow = throttleFinalTraversalOnly
&& lastSnapshot != null
&& lastSnapshot.levelNumber() == 10
&& lastSnapshot.deaths() == 3
&& lastSnapshot.playerX() >= 1_200f
&& lastSnapshot.playerX() <= 2_200f;
Thread.sleep(inFinalTraversalWindow
? Math.max(millis, minimumDecisionMillis) : millis);
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
throw new AssertionError("Late-level bot interrupted", interrupted);
}
boolean inFinalTraversalWindow = throttleFinalTraversalOnly
&& lastSnapshot != null
&& lastSnapshot.levelNumber() == 10
&& lastSnapshot.deaths() == 3
&& lastSnapshot.playerX() >= 1_200f
&& lastSnapshot.playerX() <= 2_200f;
long decisionMillis = inFinalTraversalWindow
? Math.max(millis, minimumDecisionMillis) : millis;
instrumentation.runOnMainSync(
() -> gameView.advanceDeterministicAutomation(decisionMillis));
}
}
}
@@ -38,6 +38,7 @@ public final class LevelPaginationTest {
assertTrue(visible.contains("Steam Charge"));
assertTrue(visible.contains("Ashen Counterweight"));
assertTrue(visible.contains("The Whole Weight"));
assertTrue(visible.contains("Threefold Proof"));
assertFalse(visible.contains("The Volunteer"));
assertTrue(visible.contains("Page 2 of 2"));
});
@@ -0,0 +1,37 @@
package com.projectsacrifice.game;
/** Immutable three-goal result for one completed level attempt. */
public final class ChallengeResult {
private final boolean casualtyEarned;
private final boolean timeEarned;
private final boolean collectibleAvailable;
private final boolean collectibleEarned;
private final int stars;
private ChallengeResult(boolean casualtyEarned, boolean timeEarned,
boolean collectibleAvailable, boolean collectibleEarned) {
this.casualtyEarned = casualtyEarned;
this.timeEarned = timeEarned;
this.collectibleAvailable = collectibleAvailable;
this.collectibleEarned = collectibleEarned;
stars = (casualtyEarned ? 1 : 0) + (timeEarned ? 1 : 0)
+ (collectibleEarned ? 1 : 0);
}
public static ChallengeResult evaluate(LevelDefinition level, int deaths,
long elapsedMs, boolean collectibleFound) {
if (level == null) throw new IllegalArgumentException("level is required");
boolean hasCollectible = level.collectible != null;
return new ChallengeResult(
Math.max(0, deaths) <= level.targetDeaths,
Math.max(0L, elapsedMs) <= level.targetSeconds * 1_000L,
hasCollectible,
!hasCollectible || collectibleFound);
}
public boolean casualtyEarned() { return casualtyEarned; }
public boolean timeEarned() { return timeEarned; }
public boolean collectibleAvailable() { return collectibleAvailable; }
public boolean collectibleEarned() { return collectibleEarned; }
public int stars() { return stars; }
}
@@ -5,6 +5,7 @@ public final class CompletionResult {
public enum NextGoal {
TARGET_DEATHS,
TARGET_TIME,
TARGET_COLLECTIBLE,
NEXT_LEVEL,
PERSONAL_BEST
}
@@ -15,12 +16,15 @@ public final class CompletionResult {
private final long bestTimeMs;
private final int targetDeaths;
private final long targetTimeMs;
private final ChallengeResult challengeResult;
private final int bestStars;
private final NextGoal nextGoal;
private final long nextGoalValue;
private CompletionResult(int currentDeaths, long currentTimeMs,
int bestDeaths, long bestTimeMs,
int targetDeaths, long targetTimeMs,
ChallengeResult challengeResult, int bestStars,
NextGoal nextGoal, long nextGoalValue) {
this.currentDeaths = currentDeaths;
this.currentTimeMs = currentTimeMs;
@@ -28,6 +32,8 @@ public final class CompletionResult {
this.bestTimeMs = bestTimeMs;
this.targetDeaths = targetDeaths;
this.targetTimeMs = targetTimeMs;
this.challengeResult = challengeResult;
this.bestStars = bestStars;
this.nextGoal = nextGoal;
this.nextGoalValue = nextGoalValue;
}
@@ -36,7 +42,21 @@ public final class CompletionResult {
int currentDeaths, long currentTimeMs,
int bestDeaths, long bestTimeMs,
boolean hasNextLevel) {
return create(level, currentDeaths, currentTimeMs, bestDeaths, bestTimeMs,
false, false, hasNextLevel);
}
public static CompletionResult create(LevelDefinition level,
int currentDeaths, long currentTimeMs,
int bestDeaths, long bestTimeMs,
boolean currentCollectible,
boolean bestCollectible,
boolean hasNextLevel) {
long targetTimeMs = level.targetSeconds * 1_000L;
ChallengeResult currentChallenges = ChallengeResult.evaluate(
level, currentDeaths, currentTimeMs, currentCollectible);
ChallengeResult bestChallenges = ChallengeResult.evaluate(
level, bestDeaths, bestTimeMs, bestCollectible);
NextGoal goal;
long goalValue;
if (currentDeaths > level.targetDeaths) {
@@ -45,6 +65,9 @@ public final class CompletionResult {
} else if (currentTimeMs > targetTimeMs) {
goal = NextGoal.TARGET_TIME;
goalValue = targetTimeMs;
} else if (!bestChallenges.collectibleEarned()) {
goal = NextGoal.TARGET_COLLECTIBLE;
goalValue = 0L;
} else if (hasNextLevel) {
goal = NextGoal.NEXT_LEVEL;
goalValue = level.number + 1L;
@@ -53,7 +76,8 @@ public final class CompletionResult {
goalValue = 0L;
}
return new CompletionResult(currentDeaths, currentTimeMs, bestDeaths, bestTimeMs,
level.targetDeaths, targetTimeMs, goal, goalValue);
level.targetDeaths, targetTimeMs, currentChallenges, bestChallenges.stars(),
goal, goalValue);
}
public int currentDeaths() { return currentDeaths; }
@@ -62,6 +86,8 @@ public final class CompletionResult {
public long bestTimeMs() { return bestTimeMs; }
public int targetDeaths() { return targetDeaths; }
public long targetTimeMs() { return targetTimeMs; }
public ChallengeResult challengeResult() { return challengeResult; }
public int bestStars() { return bestStars; }
public NextGoal nextGoal() { return nextGoal; }
public long nextGoalValue() { return nextGoalValue; }
}
@@ -133,6 +133,7 @@ public final class GameSessionSnapshot {
private float elapsedSeconds;
private int deaths;
private float respawnSeconds;
private boolean collectibleFound;
private PlayerData player = new PlayerData(
0f, 0f, 0f, 0f, true, false, 0f, 0f, -1, 1);
private final ArrayList<BodyData> bodies = new ArrayList<>();
@@ -151,6 +152,7 @@ public final class GameSessionSnapshot {
public Builder elapsedSeconds(float value) { elapsedSeconds = value; return this; }
public Builder deaths(int value) { deaths = value; return this; }
public Builder respawnSeconds(float value) { respawnSeconds = value; return this; }
public Builder collectibleFound(boolean value) { collectibleFound = value; return this; }
public Builder player(PlayerData value) { player = value; return this; }
public Builder addBody(BodyData value) { bodies.add(value); return this; }
public Builder addObject(ObjectData value) { objects.add(value); return this; }
@@ -179,6 +181,7 @@ public final class GameSessionSnapshot {
private final float elapsedSeconds;
private final int deaths;
private final float respawnSeconds;
private final boolean collectibleFound;
private final PlayerData player;
private final List<BodyData> bodies;
private final List<ObjectData> objects;
@@ -193,6 +196,7 @@ public final class GameSessionSnapshot {
elapsedSeconds = builder.elapsedSeconds;
deaths = builder.deaths;
respawnSeconds = builder.respawnSeconds;
collectibleFound = builder.collectibleFound;
player = builder.player;
bodies = Collections.unmodifiableList(new ArrayList<>(builder.bodies));
objects = Collections.unmodifiableList(new ArrayList<>(builder.objects));
@@ -211,6 +215,7 @@ public final class GameSessionSnapshot {
public float elapsedSeconds() { return elapsedSeconds; }
public int deaths() { return deaths; }
public float respawnSeconds() { return respawnSeconds; }
public boolean collectibleFound() { return collectibleFound; }
public PlayerData player() { return player; }
public List<BodyData> bodies() { return bodies; }
public List<ObjectData> objects() { return objects; }
@@ -126,6 +126,20 @@ public final class GameStrings {
return (earned ? "★" : "☆") + " " + GameMath.formatTime(elapsedMilliseconds)
+ " / " + GameMath.formatTime(targetMilliseconds);
}
public String starRow(int earnedStars) {
int safe = Math.max(0, Math.min(3, earnedStars));
StringBuilder row = new StringBuilder(3);
for (int star = 0; star < 3; star++) row.append(star < safe ? '★' : '☆');
return row.toString();
}
public String completionCollectible(boolean earned, boolean available) {
if (!available) {
return "★ " + (russian() ? "Основное прохождение" : "Core completion");
}
return (earned ? "★ " : "☆ ") + (russian()
? (earned ? "Тайник найден" : "Тайник не найден")
: (earned ? "Cache found" : "Cache not found"));
}
public String levelSelect() { return russian() ? "Выбор уровня" : "Level select"; }
public String replay() { return russian() ? "Повторить" : "Replay"; }
public String nextLevel() { return russian() ? "Следующий уровень" : "Next level"; }
@@ -166,6 +180,11 @@ public final class GameStrings {
+ GameMath.formatTime(timeMs);
}
public String nextGoalCollectible() {
return russian() ? "Следующая цель · найти тайник"
: "Next goal · find the cache";
}
public String nextGoalLevel(long level) {
return (russian() ? "Следующая цель · перейти на уровень "
: "Next goal · enter level ") + level;
@@ -85,6 +85,8 @@ public final class GameView extends View implements Runnable {
private float firstPhysicalObjectY = Float.NaN;
private int firstPhysicalObjectMass;
private String firstPhysicalObjectType = "";
private boolean collectibleFound;
private boolean collectibleVisible;
AutomationSnapshot(boolean completed, boolean playerAlive, boolean allDoorsOpen,
boolean visiblyPaused, boolean inputActive, int levelNumber,
@@ -247,6 +249,37 @@ public final class GameView extends View implements Runnable {
int physicalObjectCount, float firstPhysicalObjectX,
float firstPhysicalObjectY, int firstPhysicalObjectMass,
String firstPhysicalObjectType) {
this(completed, playerAlive, allDoorsOpen, visiblyPaused, inputActive, levelNumber,
elapsedMilliseconds, playerX, playerY, deaths, corpseCount,
firstMovingPlatformX, firstMovingPlatformY, playerOnMovingPlatform,
firstMovingPlatformPowered, firstMovingPlatformMovingRight,
firstMovingPlatformMovingDown, firstCorpseX, playerOnGround,
playerVelocityX, playerVelocityY, firstHazardActive, firstHazardX,
firstHazardY, firstCorpseBurned, firstCorpseCrushed,
firstCorpseActivatesPressure, firstCorpseWidth, firstCorpseHeight,
physicalObjectCount, firstPhysicalObjectX, firstPhysicalObjectY,
firstPhysicalObjectMass, firstPhysicalObjectType, false, false);
}
AutomationSnapshot(boolean completed, boolean playerAlive, boolean allDoorsOpen,
boolean visiblyPaused, boolean inputActive, int levelNumber,
long elapsedMilliseconds,
float playerX, float playerY, int deaths, int corpseCount,
float firstMovingPlatformX, float firstMovingPlatformY,
boolean playerOnMovingPlatform,
boolean firstMovingPlatformPowered,
boolean firstMovingPlatformMovingRight,
boolean firstMovingPlatformMovingDown, float firstCorpseX,
boolean playerOnGround, float playerVelocityX,
float playerVelocityY, boolean firstHazardActive,
float firstHazardX, float firstHazardY,
boolean firstCorpseBurned, boolean firstCorpseCrushed,
boolean firstCorpseActivatesPressure,
float firstCorpseWidth, float firstCorpseHeight,
int physicalObjectCount, float firstPhysicalObjectX,
float firstPhysicalObjectY, int firstPhysicalObjectMass,
String firstPhysicalObjectType,
boolean collectibleFound, boolean collectibleVisible) {
this(completed, playerAlive, allDoorsOpen, visiblyPaused, inputActive, levelNumber,
elapsedMilliseconds, playerX, playerY, deaths, corpseCount,
firstMovingPlatformX, firstMovingPlatformY, playerOnMovingPlatform,
@@ -261,6 +294,8 @@ public final class GameView extends View implements Runnable {
this.firstPhysicalObjectMass = firstPhysicalObjectMass;
this.firstPhysicalObjectType = firstPhysicalObjectType == null
? "" : firstPhysicalObjectType;
this.collectibleFound = collectibleFound;
this.collectibleVisible = collectibleVisible;
}
public boolean completed() { return completed; }
@@ -299,6 +334,8 @@ public final class GameView extends View implements Runnable {
public float firstPhysicalObjectY() { return firstPhysicalObjectY; }
public int firstPhysicalObjectMass() { return firstPhysicalObjectMass; }
public String firstPhysicalObjectType() { return firstPhysicalObjectType; }
public boolean collectibleFound() { return collectibleFound; }
public boolean collectibleVisible() { return collectibleVisible; }
}
private static final class Player implements IWeightedObject {
@@ -399,6 +436,7 @@ public final class GameView extends View implements Runnable {
private AudioSettings audioSettings;
private float lastFootstepAt = -10f;
private boolean deterministicAutomationClock;
private boolean collectibleFound;
private double deterministicAccumulatorSeconds;
private float lastDrawDurationMs;
private int performanceFrameCounter;
@@ -528,6 +566,7 @@ public final class GameView extends View implements Runnable {
previousSwitchContact.clear();
configurePuzzleSystem();
completionResult = null;
collectibleFound = false;
configureAccessibilitySignals();
invalidateHudTextCache();
for (LevelDefinition.Platform item : level.platforms) platformStates.add(new PlatformState(item));
@@ -582,7 +621,10 @@ public final class GameView extends View implements Runnable {
if (respawnTimer <= 0f) spawnPlayer();
} else {
checkHazards();
checkGoal();
if (player.alive) {
checkCollectible();
checkGoal();
}
}
if (player.alive && player.onGround && Math.abs(player.vx) >= 100f
@@ -1129,17 +1171,28 @@ public final class GameView extends View implements Runnable {
finishLevel(deaths, (long) (levelElapsed * 1000f));
}
private void checkCollectible() {
if (collectibleFound || level.collectible == null) return;
if (!intersects(player.x, player.y, PLAYER_WIDTH, PLAYER_HEIGHT, level.collectible)) return;
collectibleFound = true;
emitHaptic(HapticFeedbackConstants.KEYBOARD_TAP);
audioEngine.play(AudioCue.SWITCH);
refreshAccessibilityDescription(true);
}
private void finishLevel(int completedDeaths, long completedTimeMs) {
long safeTimeMs = Math.max(0L, completedTimeMs);
deaths = Math.max(0, completedDeaths);
levelElapsed = safeTimeMs / 1_000f;
progress.recordCompletion(level.number, deaths, safeTimeMs);
progress.recordCompletion(level, deaths, safeTimeMs, collectibleFound);
completionResult = CompletionResult.create(
level,
deaths,
safeTimeMs,
progress.bestDeaths(level.number),
progress.bestTimeMs(level.number),
collectibleFound,
progress.collectibleFound(level.levelId),
levelIndex + 1 < LevelCatalog.all().size()
);
player.vx = player.vy = 0f;
@@ -1211,7 +1264,8 @@ public final class GameView extends View implements Runnable {
+ level.hazards.size() + level.switches.size() * actors;
int drawPrimitives = 180 + platformStates.size() * 8
+ level.switches.size() * 9 + level.hazards.size() * 20
+ level.doors.size() * 12 + bodies * 28 + objects * 8;
+ level.doors.size() * 12 + bodies * 28 + objects * 8
+ (level.collectible == null ? 0 : 7);
Runtime runtime = Runtime.getRuntime();
long usedMemory = runtime.totalMemory() - runtime.freeMemory();
performanceMonitor.recordComplexity(bodies, collisionChecks,
@@ -1227,6 +1281,7 @@ public final class GameView extends View implements Runnable {
drawSwitches(canvas);
drawHazards(canvas);
drawDoors(canvas);
drawCollectible(canvas);
drawGoal(canvas);
for (PhysicalObjectState object : physicalObjects) drawPhysicalObject(canvas, object);
for (Body corpse : corpses) drawCorpse(canvas, corpse);
@@ -1547,6 +1602,26 @@ public final class GameView extends View implements Runnable {
canvas.drawCircle(box.centerX() - 6f, box.centerY() - 7f, 7f, paint);
}
private void drawCollectible(Canvas canvas) {
if (collectibleFound || level.collectible == null) return;
RectF box = rect(level.collectible);
float pulse = accessibilitySettings.reducedMotionEnabled()
? 0f : (float) Math.sin(levelElapsed * 4f) * 5f;
paint.setColor(accessibilitySettings.highContrastEnabled()
? Color.WHITE : Color.rgb(250, 211, 86));
path.reset();
path.moveTo(box.centerX(), box.top - pulse);
path.lineTo(box.right + pulse, box.centerY());
path.lineTo(box.centerX(), box.bottom + pulse);
path.lineTo(box.left - pulse, box.centerY());
path.close();
canvas.drawPath(path, paint);
stroke.setColor(Color.BLACK);
stroke.setStrokeWidth(4f);
canvas.drawPath(path, stroke);
drawWorldLabel(canvas, "★ CACHE", box.centerX(), box.top - 18f);
}
private void drawCorpse(Canvas canvas, Body corpse) {
if (corpse.crushed) {
paint.setColor(accessibilitySettings.highContrastEnabled()
@@ -1723,8 +1798,10 @@ public final class GameView extends View implements Runnable {
if (complete) {
int bestDeaths = progress.bestDeaths(i + 1);
long bestTime = progress.bestTimeMs(i + 1);
drawCenteredText(canvas, strings.starRow(progress.earnedStars(item)),
card.centerX(), top + 171, 34, Color.rgb(250, 211, 111));
drawCenteredFittedText(canvas, strings.levelBestStats(bestDeaths, bestTime),
card.centerX(), top + 185, 21, 17, cardW - 24,
card.centerX(), top + 209, 21, 17, cardW - 24,
Color.rgb(138, 231, 163));
} else if (enabled) {
drawCenteredText(canvas, strings.tapToEnter(), card.centerX(), top + 190,
@@ -1829,14 +1906,26 @@ public final class GameView extends View implements Runnable {
if (result != null) {
drawCenteredText(canvas, strings.currentResultLine(result.currentDeaths(),
result.currentTimeMs()),
VIEW_WIDTH * 0.5f, 380, 34, Color.WHITE);
VIEW_WIDTH * 0.5f, 340, 32, Color.WHITE);
drawCenteredText(canvas, strings.bestResultLine(result.bestDeaths(),
result.bestTimeMs()),
VIEW_WIDTH * 0.5f, 437, 31, Color.rgb(128, 229, 153));
VIEW_WIDTH * 0.5f, 389, 29, Color.rgb(128, 229, 153));
drawCenteredText(canvas, strings.targetsResultLine(result.targetDeaths(),
result.targetTimeMs()),
VIEW_WIDTH * 0.5f, 510, 28, Color.rgb(210, 211, 218));
drawCenteredText(canvas, nextGoalText(result), VIEW_WIDTH * 0.5f, 585, 31,
VIEW_WIDTH * 0.5f, 430, 27, Color.rgb(210, 211, 218));
ChallengeResult challenges = result.challengeResult();
drawCenteredText(canvas, strings.starRow(result.bestStars()),
VIEW_WIDTH * 0.5f, 472, 43, Color.rgb(250, 211, 111));
drawCenteredText(canvas, strings.completionDeaths(challenges.casualtyEarned(),
result.currentDeaths(), result.targetDeaths()),
VIEW_WIDTH * 0.5f, 518, 27, Color.rgb(210, 211, 218));
drawCenteredText(canvas, strings.completionTime(challenges.timeEarned(),
result.currentTimeMs(), result.targetTimeMs()),
VIEW_WIDTH * 0.5f, 558, 27, Color.rgb(210, 211, 218));
drawCenteredText(canvas, strings.completionCollectible(
challenges.collectibleEarned(), challenges.collectibleAvailable()),
VIEW_WIDTH * 0.5f, 598, 27, Color.rgb(210, 211, 218));
drawCenteredText(canvas, nextGoalText(result), VIEW_WIDTH * 0.5f, 657, 29,
Color.rgb(250, 211, 111));
}
@@ -1853,6 +1942,8 @@ public final class GameView extends View implements Runnable {
return strings.nextGoalDeaths(result.nextGoalValue());
case TARGET_TIME:
return strings.nextGoalTime(result.nextGoalValue());
case TARGET_COLLECTIBLE:
return strings.nextGoalCollectible();
case NEXT_LEVEL:
return strings.nextGoalLevel(result.nextGoalValue());
case PERSONAL_BEST:
@@ -2437,6 +2528,7 @@ public final class GameView extends View implements Runnable {
visible.add(enabled ? String.format(Locale.US, "%02d", i + 1) : strings.locked());
visible.add(strings.levelTitle(i + 1));
if (progress.isCompleted(i + 1)) {
visible.add(strings.starRow(progress.earnedStars(LevelCatalog.get(i))));
visible.add(strings.levelBestStats(progress.bestDeaths(i + 1),
progress.bestTimeMs(i + 1)));
} else if (enabled) {
@@ -2492,6 +2584,14 @@ public final class GameView extends View implements Runnable {
completionResult.bestTimeMs()));
visible.add(strings.targetsResultLine(completionResult.targetDeaths(),
completionResult.targetTimeMs()));
ChallengeResult challenges = completionResult.challengeResult();
visible.add(strings.starRow(completionResult.bestStars()));
visible.add(strings.completionDeaths(challenges.casualtyEarned(),
completionResult.currentDeaths(), completionResult.targetDeaths()));
visible.add(strings.completionTime(challenges.timeEarned(),
completionResult.currentTimeMs(), completionResult.targetTimeMs()));
visible.add(strings.completionCollectible(challenges.collectibleEarned(),
challenges.collectibleAvailable()));
visible.add(nextGoalText(completionResult));
}
visible.add(strings.levelSelect());
@@ -2797,6 +2897,7 @@ public final class GameView extends View implements Runnable {
.elapsedSeconds(levelElapsed)
.deaths(deaths)
.respawnSeconds(Math.max(0f, respawnTimer))
.collectibleFound(collectibleFound)
.player(new GameSessionSnapshot.PlayerData(
player.x, player.y, player.vx, player.vy,
player.alive, player.onGround, player.coyote,
@@ -2860,6 +2961,7 @@ public final class GameView extends View implements Runnable {
startLevel(index);
levelElapsed = snapshot.elapsedSeconds();
collectibleFound = snapshot.collectibleFound();
for (HazardRuntime runtime : hazardRuntimes) runtime.update(levelElapsed);
deaths = snapshot.deaths();
respawnTimer = snapshot.respawnSeconds();
@@ -3012,7 +3114,9 @@ public final class GameView extends View implements Runnable {
firstObject == null ? Float.NaN : firstObject.x,
firstObject == null ? Float.NaN : firstObject.y,
firstObject == null ? 0 : firstObject.mass,
firstObject == null ? "" : firstObject.type.name()
firstObject == null ? "" : firstObject.type.name(),
collectibleFound,
level != null && level.collectible != null && !collectibleFound
);
}
@@ -5,7 +5,7 @@ import android.content.SharedPreferences;
public final class ProgressStore {
private static final String FILE = "project_sacrifice_progress_v1";
public static final int CURRENT_SAVE_VERSION = 2;
public static final int CURRENT_SAVE_VERSION = 3;
private final SharedPreferences preferences;
public ProgressStore(Context context) {
@@ -18,26 +18,60 @@ public final class ProgressStore {
}
public boolean isCompleted(int levelNumber) {
return preferences.getBoolean(key(levelNumber, "completed"), false);
return isCompleted(levelId(levelNumber));
}
public boolean isCompleted(String levelId) {
return preferences.getBoolean(idKey(levelId, "completed"), false);
}
public int bestDeaths(int levelNumber) {
return preferences.getInt(key(levelNumber, "deaths"), Integer.MAX_VALUE);
return bestDeaths(levelId(levelNumber));
}
public int bestDeaths(String levelId) {
return preferences.getInt(idKey(levelId, "deaths"), Integer.MAX_VALUE);
}
public long bestTimeMs(int levelNumber) {
return preferences.getLong(key(levelNumber, "time"), Long.MAX_VALUE);
return bestTimeMs(levelId(levelNumber));
}
public long bestTimeMs(String levelId) {
return preferences.getLong(idKey(levelId, "time"), Long.MAX_VALUE);
}
public boolean collectibleFound(String levelId) {
return preferences.getBoolean(idKey(levelId, "collectible"), false);
}
public void recordCompletion(int levelNumber, int deaths, long elapsedMs) {
recordCompletion(LevelCatalog.get(levelNumber - 1), deaths, elapsedMs, false);
}
public void recordCompletion(LevelDefinition level, int deaths, long elapsedMs,
boolean collectibleFound) {
if (level == null) throw new IllegalArgumentException("level is required");
SharedPreferences.Editor editor = preferences.edit();
editor.putBoolean(key(levelNumber, "completed"), true);
editor.putInt(key(levelNumber, "deaths"), Math.min(deaths, bestDeaths(levelNumber)));
editor.putLong(key(levelNumber, "time"), Math.min(elapsedMs, bestTimeMs(levelNumber)));
editor.putInt("unlocked", Math.min(LevelCatalog.all().size(), Math.max(unlockedCount(), levelNumber + 1)));
editor.putBoolean(idKey(level.levelId, "completed"), true);
editor.putInt(idKey(level.levelId, "deaths"),
Math.min(Math.max(0, deaths), bestDeaths(level.levelId)));
editor.putLong(idKey(level.levelId, "time"),
Math.min(Math.max(0L, elapsedMs), bestTimeMs(level.levelId)));
if (collectibleFound || collectibleFound(level.levelId)) {
editor.putBoolean(idKey(level.levelId, "collectible"), true);
}
editor.putInt("unlocked", Math.min(LevelCatalog.all().size(),
Math.max(unlockedCount(), level.number + 1)));
editor.apply();
}
public int earnedStars(LevelDefinition level) {
if (level == null || !isCompleted(level.levelId)) return 0;
return ChallengeResult.evaluate(level, bestDeaths(level.levelId),
bestTimeMs(level.levelId), collectibleFound(level.levelId)).stars();
}
public int saveVersion() {
return preferences.getInt("save_version", 1);
}
@@ -45,11 +79,56 @@ public final class ProgressStore {
private void migrateIfSupported() {
int version = preferences.getInt("save_version", 1);
if (version >= 1 && version < CURRENT_SAVE_VERSION) {
preferences.edit().putInt("save_version", CURRENT_SAVE_VERSION).apply();
SharedPreferences.Editor editor = preferences.edit();
if (version < 3) migrateNumericKeys(editor);
editor.putInt("save_version", CURRENT_SAVE_VERSION).apply();
}
}
private void migrateNumericKeys(SharedPreferences.Editor editor) {
for (LevelDefinition level : LevelCatalog.all()) {
copyBooleanIfPresent(editor, key(level.number, "completed"),
idKey(level.levelId, "completed"));
copyIntIfPresent(editor, key(level.number, "deaths"),
idKey(level.levelId, "deaths"));
copyLongIfPresent(editor, key(level.number, "time"),
idKey(level.levelId, "time"));
}
}
private void copyBooleanIfPresent(SharedPreferences.Editor editor,
String oldKey, String newKey) {
if (preferences.contains(oldKey) && !preferences.contains(newKey)) {
editor.putBoolean(newKey, preferences.getBoolean(oldKey, false));
}
}
private void copyIntIfPresent(SharedPreferences.Editor editor,
String oldKey, String newKey) {
if (preferences.contains(oldKey) && !preferences.contains(newKey)) {
editor.putInt(newKey, preferences.getInt(oldKey, Integer.MAX_VALUE));
}
}
private void copyLongIfPresent(SharedPreferences.Editor editor,
String oldKey, String newKey) {
if (preferences.contains(oldKey) && !preferences.contains(newKey)) {
editor.putLong(newKey, preferences.getLong(oldKey, Long.MAX_VALUE));
}
}
private static String levelId(int levelNumber) {
if (levelNumber >= 1 && levelNumber <= LevelCatalog.all().size()) {
return LevelCatalog.get(levelNumber - 1).levelId;
}
return LevelDefinition.defaultLevelId(levelNumber);
}
private static String key(int levelNumber, String suffix) {
return "level_" + levelNumber + "_" + suffix;
}
private static String idKey(String levelId, String suffix) {
return "level_id_" + levelId + "_" + suffix;
}
}
@@ -15,7 +15,7 @@ public final class VersionedSessionCodec {
private static final int MAX_OBJECTS = 64;
private static final int MAX_PLATFORMS = 64;
private static final int MAX_MAP_ENTRIES = 128;
public static final int CURRENT_VERSION = 4;
public static final int CURRENT_VERSION = 5;
public enum Status { RESTORED, MIGRATED, UNSUPPORTED, CORRUPT }
@@ -50,6 +50,10 @@ public final class VersionedSessionCodec {
return encodeWithVersion(snapshot, 3);
}
static byte[] encodeLegacyV4(GameSessionSnapshot snapshot) {
return encodeWithVersion(snapshot, 4);
}
static byte[] encodeVersionForTest(int version) {
try {
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
@@ -89,6 +93,7 @@ public final class VersionedSessionCodec {
int objectCount = checkedCount(input.readInt(), MAX_OBJECTS);
for (int i = 0; i < objectCount; i++) builder.addObject(readObject(input));
}
if (version >= 5) builder.collectibleFound(input.readBoolean());
int platformCount = checkedCount(input.readInt(), MAX_PLATFORMS);
ArrayList<Float> motionTimes = new ArrayList<>(platformCount);
@@ -130,6 +135,7 @@ public final class VersionedSessionCodec {
writeObject(output, object);
}
}
if (version >= 5) output.writeBoolean(snapshot.collectibleFound());
output.writeInt(snapshot.platformMotionTimes().size());
for (Float time : snapshot.platformMotionTimes()) output.writeFloat(time);
writeBooleanMap(output, snapshot.toggleSignals());
@@ -0,0 +1,44 @@
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 ChallengeResultTest {
@Test
public void targetBoundariesEarnAllThreeIndependentStars() {
LevelDefinition base = LevelCatalog.get(0);
LevelDefinition level = new LevelDefinition(
base.formatVersion, base.physicsVersion, base.levelId, base.worldId,
base.number, base.title, base.hint, base.titleRu, base.hintRu,
base.worldWidth, base.worldHeight, base.spawnX, base.spawnY, base.goal,
base.targetDeaths, base.targetSeconds,
new LevelDefinition.Box(500f, 700f, 50f, 50f), base.objects,
base.platforms, base.hazards, base.switches, base.doors);
ChallengeResult result = ChallengeResult.evaluate(level,
level.targetDeaths, level.targetSeconds * 1_000L, true);
assertTrue(result.casualtyEarned());
assertTrue(result.timeEarned());
assertTrue(result.collectibleEarned());
assertTrue(result.collectibleAvailable());
assertEquals(3, result.stars());
}
@Test
public void missedGoalsRemainIndependentAndLegacyLevelHasNoImpossibleStar() {
LevelDefinition level = LevelCatalog.get(0);
ChallengeResult slowAndCostly = ChallengeResult.evaluate(level,
level.targetDeaths + 1, level.targetSeconds * 1_000L + 1L, false);
assertFalse(slowAndCostly.casualtyEarned());
assertFalse(slowAndCostly.timeEarned());
assertFalse(slowAndCostly.collectibleAvailable());
assertTrue(slowAndCostly.collectibleEarned());
assertEquals(1, slowAndCostly.stars());
}
}
@@ -42,7 +42,35 @@ public final class CompletionResultTest {
LevelDefinition last = LevelCatalog.get(LevelCatalog.all().size() - 1);
CompletionResult lastResult = CompletionResult.create(last,
last.targetDeaths, last.targetSeconds * 900L,
last.targetDeaths, last.targetSeconds * 900L, false);
last.targetDeaths, last.targetSeconds * 900L,
true, true, false);
assertEquals(CompletionResult.NextGoal.PERSONAL_BEST, lastResult.nextGoal());
}
@Test
public void collectibleIsThirdExplicitGoalAndStarsExposeCurrentAndBest() {
LevelDefinition base = LevelCatalog.get(0);
LevelDefinition level = new LevelDefinition(
base.formatVersion, base.physicsVersion, "foundations.result-lab", "foundations",
base.number, base.title, base.hint, base.titleRu, base.hintRu,
base.worldWidth, base.worldHeight, base.spawnX, base.spawnY, base.goal,
base.targetDeaths, base.targetSeconds,
new LevelDefinition.Box(500f, 700f, 50f, 50f), base.objects,
base.platforms, base.hazards, base.switches, base.doors);
CompletionResult result = CompletionResult.create(level, base.targetDeaths,
base.targetSeconds * 1_000L, base.targetDeaths,
base.targetSeconds * 900L, false, false, true);
assertEquals(2, result.challengeResult().stars());
assertEquals(2, result.bestStars());
assertEquals(CompletionResult.NextGoal.TARGET_COLLECTIBLE, result.nextGoal());
CompletionResult mastered = CompletionResult.create(level, base.targetDeaths,
base.targetSeconds * 1_000L, base.targetDeaths,
base.targetSeconds * 900L, true, true, true);
assertEquals(3, mastered.challengeResult().stars());
assertEquals(3, mastered.bestStars());
assertEquals(CompletionResult.NextGoal.NEXT_LEVEL, mastered.nextGoal());
}
}
@@ -33,6 +33,10 @@ public final class GameStringsTest {
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("★★☆", english.starRow(2));
assertEquals("★ Тайник найден", russian.completionCollectible(true, true));
assertEquals("☆ Тайник не найден", russian.completionCollectible(false, true));
assertEquals("Следующая цель · найти тайник", russian.nextGoalCollectible());
assertEquals("Смерть — это инструмент", russian.tutorialWelcomeTitle());
assertFalse(russian.tutorialSacrificeBody().trim().isEmpty());
@@ -1,6 +1,8 @@
package com.projectsacrifice.game;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;
import org.junit.Test;
@@ -26,4 +28,26 @@ public final class LevelCatalogLoaderTest {
assertEquals(1, levels.get(0).number);
assertEquals("The Test", levels.get(0).title);
}
@Test
public void duplicateStableLevelIdsFailBeforeProgressCanCollide() {
Map<String, String> resources = new HashMap<>();
resources.put("levels/index.json", "{\"formatVersion\":1,\"physicsVersion\":1,"
+ "\"levels\":[\"level-01.json\",\"level-02.json\"]}");
resources.put("levels/level-01.json", LevelJsonLoaderTest.validLevelJson(1)
.replace("\"number\":1,", "\"number\":1,\"id\":\"shared.level\","));
resources.put("levels/level-02.json", LevelJsonLoaderTest.validLevelJson(1)
.replace("\"number\":1,", "\"number\":2,\"id\":\"shared.level\","));
try {
LevelCatalogLoader.load(path -> {
String value = resources.get(path);
return value == null ? null : new StringReader(value);
}, "levels/index.json");
fail("Duplicate stable IDs must fail before they share one progress record");
} catch (IllegalArgumentException error) {
assertTrue(error.getMessage(), error.getMessage().contains(
"levels/index.json.levels[1] duplicates level id 'shared.level'"));
}
}
}
@@ -13,9 +13,9 @@ import java.util.Set;
public final class LevelCatalogTest {
@Test
public void productionCatalogContainsHazardsAndTwoCorpseStateTeachingLevels() {
public void productionCatalogContainsMechanicsAndChallengeTeachingLevels() {
List<LevelDefinition> levels = LevelCatalog.all();
assertEquals(17, levels.size());
assertEquals(18, levels.size());
Set<String> titles = new HashSet<>();
for (int i = 0; i < levels.size(); i++) {
LevelDefinition level = levels.get(i);
@@ -40,6 +40,10 @@ public final class LevelCatalogTest {
objectTypes);
assertEquals(9, weighted.switches.get(0).requiredMass);
assertFalse(weighted.doors.isEmpty());
LevelDefinition challenge = levels.get(17);
assertEquals("foundations.threefold-proof", challenge.levelId);
assertEquals("foundations", challenge.worldId);
assertTrue(challenge.collectible != null);
}
@Test
@@ -0,0 +1,25 @@
package com.projectsacrifice.game;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
import java.util.HashSet;
import java.util.Set;
import org.junit.Test;
public final class LevelIdentityTest {
@Test
public void everyCatalogEntryHasStableUniqueLevelAndWorldIds() {
Set<String> ids = new HashSet<>();
for (LevelDefinition level : LevelCatalog.all()) {
assertNotNull(level.levelId);
assertNotNull(level.worldId);
assertTrue(level.levelId.matches("[a-z0-9][a-z0-9._-]{2,63}"));
assertTrue(level.worldId.matches("[a-z0-9][a-z0-9._-]{2,31}"));
assertTrue("duplicate levelId " + level.levelId, ids.add(level.levelId));
}
assertEquals(LevelCatalog.all().size(), ids.size());
}
}
@@ -251,6 +251,36 @@ public final class LevelJsonLoaderTest {
}
}
@Test
public void explicitLevelIdentityAndCollectibleLoadFromData() {
String json = validLevelJson(1)
.replace("\"number\":1,", "\"number\":1,"
+ "\"id\":\"foundations.hidden-cache\","
+ "\"worldId\":\"foundations\",")
.replace("\"targets\":", "\"collectible\":{\"x\":840,\"y\":700,"
+ "\"width\":52,\"height\":52},\"targets\":");
LevelDefinition level = LevelJsonLoader.load(new StringReader(json), "challenge.json");
assertEquals("foundations.hidden-cache", level.levelId);
assertEquals("foundations", level.worldId);
assertEquals(840f, level.collectible.x, 0f);
assertEquals(52f, level.collectible.width, 0f);
}
@Test
public void invalidExplicitLevelIdFailsWithSourcePath() {
String json = validLevelJson(1).replace(
"\"number\":1,", "\"number\":1,\"id\":\"Not Stable!\",");
try {
LevelJsonLoader.load(new StringReader(json), "invalid-id.json");
fail("Unstable level IDs must fail closed");
} catch (IllegalArgumentException error) {
assertTrue(error.getMessage(), error.getMessage().contains("invalid-id.json.id"));
}
}
static String validLevelJson(int formatVersion) {
return "{"
+ "\"formatVersion\":" + formatVersion + ","
@@ -8,8 +8,8 @@ import org.junit.Test;
public final class LevelPageTest {
@Test
public void seventeenLevelsUseTwoBoundedTenCardPages() {
LevelPage page = new LevelPage(17, 10);
public void eighteenLevelsUseTwoBoundedTenCardPages() {
LevelPage page = new LevelPage(18, 10);
assertEquals(2, page.pageCount());
assertEquals(0, page.firstIndex());
@@ -19,7 +19,7 @@ public final class LevelPageTest {
page.next();
assertEquals(10, page.firstIndex());
assertEquals(17, page.endExclusive());
assertEquals(18, page.endExclusive());
assertTrue(page.hasPrevious());
assertFalse(page.hasNext());
@@ -31,5 +31,7 @@ public final class LevelPageTest {
assertEquals(1, page.pageIndex());
page.selectIndex(16);
assertEquals(1, page.pageIndex());
page.selectIndex(17);
assertEquals(1, page.pageIndex());
}
}
@@ -33,6 +33,7 @@ public final class VersionedSessionCodecTest {
assertEquals(LevelDefinition.PhysicalObjectType.BOX,
restored.objects().get(0).type());
assertEquals(3, restored.objects().get(1).mass());
assertTrue(restored.collectibleFound());
assertEquals(Arrays.asList(1.25f, 3.5f), restored.platformMotionTimes());
assertTrue(restored.toggleSignals().get("gate"));
assertEquals(2.25f, restored.timedSignals().get("clock"), 0f);
@@ -76,6 +77,16 @@ public final class VersionedSessionCodecTest {
assertTrue(decoded.snapshot().objects().isEmpty());
}
@Test
public void legacyV4MigratesWithCollectibleNotInvented() {
VersionedSessionCodec.DecodeResult decoded = VersionedSessionCodec.decode(
VersionedSessionCodec.encodeLegacyV4(sampleSnapshot()));
assertEquals(VersionedSessionCodec.Status.MIGRATED, decoded.status());
assertEquals(2, decoded.snapshot().objects().size());
assertFalse(decoded.snapshot().collectibleFound());
}
@Test
public void corruptAndFutureSnapshotsFailClosed() {
VersionedSessionCodec.DecodeResult corrupt =
@@ -109,6 +120,7 @@ public final class VersionedSessionCodecTest {
return GameSessionSnapshot.builder(5)
.elapsedSeconds(12.75f)
.deaths(2)
.collectibleFound(true)
.respawnSeconds(0.4f)
.player(new GameSessionSnapshot.PlayerData(
640f, 780f, 120f, -40f, false, false,