Добавить испытания и прогрессию по LevelID
This commit is contained in:
+53
@@ -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)));
|
||||
}
|
||||
}
|
||||
}
|
||||
+77
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user