Завершить контентную архитектуру этапа 4
This commit is contained in:
+7
-2
@@ -10,8 +10,8 @@ android {
|
||||
applicationId 'com.projectsacrifice.game'
|
||||
minSdk 23
|
||||
targetSdk 34
|
||||
versionCode 7
|
||||
versionName '0.6.0-stage3-complete'
|
||||
versionCode 8
|
||||
versionName '0.7.0-stage4-content'
|
||||
|
||||
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
|
||||
}
|
||||
@@ -38,8 +38,13 @@ android {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation project(':level-format')
|
||||
testImplementation 'junit:junit:4.13.2'
|
||||
androidTestImplementation 'androidx.test:runner:1.5.2'
|
||||
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
|
||||
androidTestImplementation 'androidx.test:rules:1.5.0'
|
||||
}
|
||||
|
||||
tasks.named('preBuild').configure {
|
||||
dependsOn ':level-format:validateLevels'
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ public final class LevelEightPlaythroughTest {
|
||||
.recoverAfterClockExpires(driver, 20_000L);
|
||||
assertTrue("Recovery run " + run + ": " + result.diagnostic(), result.completed());
|
||||
assertEquals("Recovery must not hide a reset/death shortcut", 0, result.deaths());
|
||||
assertTrace(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -39,6 +40,7 @@ public final class LevelEightPlaythroughTest {
|
||||
.completeIntendedRoute(driver, 15_000L);
|
||||
assertTrue("Intended run " + run + ": " + result.diagnostic(), result.completed());
|
||||
assertEquals("The intended route is deathless", 0, result.deaths());
|
||||
assertTrace(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -51,9 +53,16 @@ public final class LevelEightPlaythroughTest {
|
||||
|
||||
assertTrue("Stressed scheduling: " + result.diagnostic(), result.completed());
|
||||
assertEquals("The stressed intended route is deathless", 0, result.deaths());
|
||||
assertTrace(result);
|
||||
}
|
||||
}
|
||||
|
||||
private static void assertTrace(LevelEightScenario.Result result) {
|
||||
assertTrue(result.traceJson(), result.traceJson().contains("\"traceVersion\":1"));
|
||||
assertTrue(result.traceJson(), result.traceJson().contains("\"level\":8"));
|
||||
assertTrue(result.diagnostic(), result.diagnostic().contains("traceId=" + result.traceId()));
|
||||
}
|
||||
|
||||
private static AndroidDriver driver(ActivityScenario<MainActivity> activity) {
|
||||
return driver(activity, 0L);
|
||||
}
|
||||
|
||||
@@ -11,19 +11,26 @@ final class LevelEightScenario {
|
||||
private final boolean completed;
|
||||
private final int deaths;
|
||||
private final String diagnostic;
|
||||
private final String traceJson;
|
||||
private final String traceId;
|
||||
|
||||
Result(boolean completed, int deaths, String diagnostic) {
|
||||
Result(boolean completed, int deaths, String diagnostic, BotCommandTrace trace) {
|
||||
this.completed = completed;
|
||||
this.deaths = deaths;
|
||||
this.diagnostic = diagnostic;
|
||||
this.traceJson = trace.json();
|
||||
this.traceId = trace.id();
|
||||
this.diagnostic = diagnostic + "; traceId=" + traceId;
|
||||
}
|
||||
|
||||
boolean completed() { return completed; }
|
||||
int deaths() { return deaths; }
|
||||
String diagnostic() { return diagnostic; }
|
||||
String traceJson() { return traceJson; }
|
||||
String traceId() { return traceId; }
|
||||
}
|
||||
|
||||
Result recoverAfterClockExpires(LevelPlaytestBot.Driver driver, long timeoutMillis) {
|
||||
driver = new BotTraceDriver(driver, "Level 8 recovery route");
|
||||
driver.startLevel(8);
|
||||
GameView.AutomationSnapshot snapshot = driver.snapshot();
|
||||
PlaytestSimulationBudget budget = PlaytestSimulationBudget.start(
|
||||
@@ -71,6 +78,7 @@ final class LevelEightScenario {
|
||||
}
|
||||
|
||||
Result completeIntendedRoute(LevelPlaytestBot.Driver driver, long timeoutMillis) {
|
||||
driver = new BotTraceDriver(driver, "Level 8 intended route");
|
||||
driver.startLevel(8);
|
||||
GameView.AutomationSnapshot snapshot = driver.snapshot();
|
||||
PlaytestSimulationBudget budget = PlaytestSimulationBudget.start(
|
||||
@@ -99,6 +107,7 @@ final class LevelEightScenario {
|
||||
&& MovingPlatformLaunchPolicy.readyToBoard(current, 1_050f));
|
||||
if (failed(snapshot, budget, driver)) return snapshot;
|
||||
|
||||
traceStep(driver, "Jump onto the moving platform");
|
||||
driver.setInput(false, true, true);
|
||||
driver.waitForNextDecision(64L);
|
||||
driver.setInput(false, true, false);
|
||||
@@ -112,6 +121,7 @@ final class LevelEightScenario {
|
||||
current -> MovingPlatformLaunchPolicy.ready(current, LAUNCH_PLATFORM_X));
|
||||
if (failed(snapshot, budget, driver)) return snapshot;
|
||||
|
||||
traceStep(driver, "Jump from the moving platform");
|
||||
driver.setInput(false, true, true);
|
||||
driver.waitForNextDecision(64L);
|
||||
driver.setInput(false, true, false);
|
||||
@@ -148,6 +158,7 @@ final class LevelEightScenario {
|
||||
return failure("Reach the open-door jump", snapshot, budget, driver);
|
||||
}
|
||||
|
||||
traceStep(driver, "Final ledge jump");
|
||||
driver.setInput(false, true, true);
|
||||
driver.waitForNextDecision(64L);
|
||||
driver.setInput(false, true, false);
|
||||
@@ -155,15 +166,17 @@ final class LevelEightScenario {
|
||||
GameView.AutomationSnapshot::completed);
|
||||
driver.setInput(false, false, false);
|
||||
if (!snapshot.completed()) return failure(step, snapshot, budget, driver);
|
||||
BotCommandTrace trace = finishTrace(driver, "COMPLETED", step, snapshot);
|
||||
return new Result(true, snapshot.deaths(), step + " completed; "
|
||||
+ budget.timingState(snapshot.elapsedMilliseconds(), driver.currentTimeMillis())
|
||||
+ "; " + state(snapshot));
|
||||
+ "; " + state(snapshot), trace);
|
||||
}
|
||||
|
||||
private GameView.AutomationSnapshot positionForFinalJump(
|
||||
LevelPlaytestBot.Driver driver, PlaytestSimulationBudget budget
|
||||
) {
|
||||
lastStep = "Reach the open-door jump";
|
||||
traceStep(driver, lastStep);
|
||||
GameView.AutomationSnapshot snapshot = driver.snapshot();
|
||||
while (!budget.exhausted(snapshot.elapsedMilliseconds(), driver.currentTimeMillis())
|
||||
&& snapshot.playerAlive() && !snapshot.completed() && snapshot.allDoorsOpen()) {
|
||||
@@ -188,6 +201,7 @@ final class LevelEightScenario {
|
||||
LevelPlaytestBot.Driver driver, PlaytestSimulationBudget budget, String step,
|
||||
LevelPlaytestBot.SnapshotCondition completion
|
||||
) {
|
||||
traceStep(driver, step);
|
||||
driver.setInput(false, true, true);
|
||||
driver.waitForNextDecision(64L);
|
||||
driver.setInput(false, true, false);
|
||||
@@ -198,6 +212,7 @@ final class LevelEightScenario {
|
||||
LevelPlaytestBot.Driver driver, PlaytestSimulationBudget budget
|
||||
) {
|
||||
lastStep = "Return beneath the recovery relay";
|
||||
traceStep(driver, lastStep);
|
||||
GameView.AutomationSnapshot snapshot = driver.snapshot();
|
||||
while (!budget.exhausted(snapshot.elapsedMilliseconds(), driver.currentTimeMillis())
|
||||
&& snapshot.playerAlive() && !snapshot.completed()) {
|
||||
@@ -223,6 +238,7 @@ final class LevelEightScenario {
|
||||
LevelPlaytestBot.Input input, LevelPlaytestBot.SnapshotCondition completion
|
||||
) {
|
||||
lastStep = step;
|
||||
traceStep(driver, step);
|
||||
GameView.AutomationSnapshot snapshot = driver.snapshot();
|
||||
while (!budget.exhausted(snapshot.elapsedMilliseconds(), driver.currentTimeMillis())
|
||||
&& snapshot.playerAlive()
|
||||
@@ -260,10 +276,24 @@ final class LevelEightScenario {
|
||||
|
||||
private Result failure(String step, GameView.AutomationSnapshot snapshot,
|
||||
PlaytestSimulationBudget budget, LevelPlaytestBot.Driver driver) {
|
||||
BotCommandTrace trace = finishTrace(driver, "FAILED", step, snapshot);
|
||||
return new Result(false, snapshot.deaths(), step + " failed; lastStep=" + lastStep
|
||||
+ "; " + budget.timingState(
|
||||
snapshot.elapsedMilliseconds(), driver.currentTimeMillis())
|
||||
+ "; " + state(snapshot));
|
||||
+ "; " + state(snapshot), trace);
|
||||
}
|
||||
|
||||
private static void traceStep(LevelPlaytestBot.Driver driver, String step) {
|
||||
if (driver instanceof BotTraceDriver) ((BotTraceDriver) driver).step(step);
|
||||
}
|
||||
|
||||
private static BotCommandTrace finishTrace(LevelPlaytestBot.Driver driver, String outcome,
|
||||
String step,
|
||||
GameView.AutomationSnapshot snapshot) {
|
||||
if (!(driver instanceof BotTraceDriver)) {
|
||||
throw new IllegalStateException("Level 8 scenario requires its tracing driver");
|
||||
}
|
||||
return ((BotTraceDriver) driver).finish(outcome, step, snapshot);
|
||||
}
|
||||
|
||||
private static String state(GameView.AutomationSnapshot snapshot) {
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/** Immutable, portable journal of public bot inputs and observable checkpoints. */
|
||||
final class BotCommandTrace {
|
||||
static final int TRACE_VERSION = 1;
|
||||
|
||||
private final String json;
|
||||
private final String id;
|
||||
|
||||
private BotCommandTrace(String json) {
|
||||
this.json = json;
|
||||
this.id = sha256(json);
|
||||
}
|
||||
|
||||
String json() { return json; }
|
||||
String id() { return id; }
|
||||
|
||||
static Builder builder(int levelNumber, String scenario,
|
||||
GameView.AutomationSnapshot initial) {
|
||||
return new Builder(levelNumber, scenario, initial);
|
||||
}
|
||||
|
||||
static final class Builder {
|
||||
private final int levelNumber;
|
||||
private final int levelFormatVersion;
|
||||
private final int physicsVersion;
|
||||
private final String scenario;
|
||||
private final long simulationStartedAt;
|
||||
private final List<Event> events = new ArrayList<>();
|
||||
private String lastInputKey;
|
||||
private String outcome = "RUNNING";
|
||||
|
||||
Builder(int levelNumber, String scenario, GameView.AutomationSnapshot initial) {
|
||||
this.levelNumber = levelNumber;
|
||||
LevelDefinition level = LevelCatalog.get(levelNumber - 1);
|
||||
this.levelFormatVersion = level.formatVersion;
|
||||
this.physicsVersion = level.physicsVersion;
|
||||
this.scenario = scenario == null ? "" : scenario;
|
||||
this.simulationStartedAt = initial.elapsedMilliseconds();
|
||||
events.add(Event.state(0L, "START", "", false, false, false, initial));
|
||||
}
|
||||
|
||||
void input(String step, boolean left, boolean right, boolean jump,
|
||||
GameView.AutomationSnapshot snapshot) {
|
||||
String key = safe(step) + '|' + left + '|' + right + '|' + jump;
|
||||
if (key.equals(lastInputKey)) return;
|
||||
lastInputKey = key;
|
||||
events.add(Event.state(elapsed(snapshot), "INPUT", safe(step),
|
||||
left, right, jump, snapshot));
|
||||
}
|
||||
|
||||
void checkpoint(String step, GameView.AutomationSnapshot snapshot) {
|
||||
lastInputKey = null;
|
||||
events.add(Event.state(elapsed(snapshot), "CHECKPOINT", safe(step),
|
||||
false, false, false, snapshot));
|
||||
}
|
||||
|
||||
BotCommandTrace finish(String outcome, String step,
|
||||
GameView.AutomationSnapshot snapshot) {
|
||||
this.outcome = safe(outcome);
|
||||
events.add(Event.state(elapsed(snapshot), "FINISH", safe(step),
|
||||
false, false, false, snapshot));
|
||||
return new BotCommandTrace(json());
|
||||
}
|
||||
|
||||
private long elapsed(GameView.AutomationSnapshot snapshot) {
|
||||
return Math.max(0L, snapshot.elapsedMilliseconds() - simulationStartedAt);
|
||||
}
|
||||
|
||||
private String json() {
|
||||
StringBuilder value = new StringBuilder();
|
||||
value.append('{')
|
||||
.append("\"traceVersion\":").append(TRACE_VERSION).append(',')
|
||||
.append("\"level\":").append(levelNumber).append(',')
|
||||
.append("\"levelFormatVersion\":").append(levelFormatVersion).append(',')
|
||||
.append("\"physicsVersion\":").append(physicsVersion).append(',')
|
||||
.append("\"scenario\":\"").append(escape(scenario)).append("\",")
|
||||
.append("\"outcome\":\"").append(escape(outcome)).append("\",")
|
||||
.append("\"events\":[");
|
||||
for (int i = 0; i < events.size(); i++) {
|
||||
if (i > 0) value.append(',');
|
||||
events.get(i).appendJson(value);
|
||||
}
|
||||
return value.append("]}\n").toString();
|
||||
}
|
||||
}
|
||||
|
||||
private static final class Event {
|
||||
final long simulationMs;
|
||||
final String kind;
|
||||
final String step;
|
||||
final boolean left;
|
||||
final boolean right;
|
||||
final boolean jump;
|
||||
final float x;
|
||||
final float y;
|
||||
final boolean alive;
|
||||
final boolean completed;
|
||||
final int deaths;
|
||||
final int corpses;
|
||||
final boolean allDoorsOpen;
|
||||
|
||||
private Event(long simulationMs, String kind, String step,
|
||||
boolean left, boolean right, boolean jump,
|
||||
GameView.AutomationSnapshot snapshot) {
|
||||
this.simulationMs = simulationMs;
|
||||
this.kind = kind;
|
||||
this.step = step;
|
||||
this.left = left;
|
||||
this.right = right;
|
||||
this.jump = jump;
|
||||
this.x = snapshot.playerX();
|
||||
this.y = snapshot.playerY();
|
||||
this.alive = snapshot.playerAlive();
|
||||
this.completed = snapshot.completed();
|
||||
this.deaths = snapshot.deaths();
|
||||
this.corpses = snapshot.corpseCount();
|
||||
this.allDoorsOpen = snapshot.allDoorsOpen();
|
||||
}
|
||||
|
||||
static Event state(long simulationMs, String kind, String step,
|
||||
boolean left, boolean right, boolean jump,
|
||||
GameView.AutomationSnapshot snapshot) {
|
||||
return new Event(simulationMs, kind, step, left, right, jump, snapshot);
|
||||
}
|
||||
|
||||
void appendJson(StringBuilder value) {
|
||||
value.append('{')
|
||||
.append("\"simulationMs\":").append(simulationMs).append(',')
|
||||
.append("\"kind\":\"").append(escape(kind)).append("\",")
|
||||
.append("\"step\":\"").append(escape(step)).append("\",")
|
||||
.append("\"input\":{")
|
||||
.append("\"left\":").append(left).append(',')
|
||||
.append("\"right\":").append(right).append(',')
|
||||
.append("\"jump\":").append(jump).append("},")
|
||||
.append("\"state\":{")
|
||||
.append("\"x\":").append(number(x)).append(',')
|
||||
.append("\"y\":").append(number(y)).append(',')
|
||||
.append("\"alive\":").append(alive).append(',')
|
||||
.append("\"completed\":").append(completed).append(',')
|
||||
.append("\"deaths\":").append(deaths).append(',')
|
||||
.append("\"corpses\":").append(corpses).append(',')
|
||||
.append("\"allDoorsOpen\":").append(allDoorsOpen)
|
||||
.append("}}");
|
||||
}
|
||||
}
|
||||
|
||||
private static String safe(String value) {
|
||||
return value == null ? "" : value;
|
||||
}
|
||||
|
||||
private static String number(float value) {
|
||||
if (!Float.isFinite(value)) return "null";
|
||||
if (value == (long) value) return Long.toString((long) value);
|
||||
return Float.toString(value);
|
||||
}
|
||||
|
||||
private static String escape(String value) {
|
||||
StringBuilder result = new StringBuilder();
|
||||
for (int i = 0; i < value.length(); i++) {
|
||||
char c = value.charAt(i);
|
||||
switch (c) {
|
||||
case '"': result.append("\\\""); break;
|
||||
case '\\': result.append("\\\\"); break;
|
||||
case '\b': result.append("\\b"); break;
|
||||
case '\f': result.append("\\f"); break;
|
||||
case '\n': result.append("\\n"); break;
|
||||
case '\r': result.append("\\r"); break;
|
||||
case '\t': result.append("\\t"); break;
|
||||
default:
|
||||
if (c < 0x20) result.append(String.format("\\u%04x", (int) c));
|
||||
else result.append(c);
|
||||
}
|
||||
}
|
||||
return result.toString();
|
||||
}
|
||||
|
||||
private static String sha256(String value) {
|
||||
try {
|
||||
byte[] digest = MessageDigest.getInstance("SHA-256")
|
||||
.digest(value.getBytes(StandardCharsets.UTF_8));
|
||||
StringBuilder result = new StringBuilder();
|
||||
for (byte item : digest) result.append(String.format("%02x", item & 0xff));
|
||||
return result.toString();
|
||||
} catch (NoSuchAlgorithmException impossible) {
|
||||
throw new IllegalStateException("SHA-256 is unavailable", impossible);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
/** Adds versioned command tracing to bespoke bots without changing their input boundary. */
|
||||
final class BotTraceDriver implements LevelPlaytestBot.Driver {
|
||||
private final LevelPlaytestBot.Driver delegate;
|
||||
private final String scenario;
|
||||
private BotCommandTrace.Builder trace;
|
||||
private GameView.AutomationSnapshot latest;
|
||||
private String step = "Start";
|
||||
|
||||
BotTraceDriver(LevelPlaytestBot.Driver delegate, String scenario) {
|
||||
if (delegate == null) throw new IllegalArgumentException("driver is required");
|
||||
this.delegate = delegate;
|
||||
this.scenario = scenario == null ? "" : scenario;
|
||||
}
|
||||
|
||||
void step(String value) {
|
||||
step = value == null ? "" : value;
|
||||
}
|
||||
|
||||
BotCommandTrace finish(String outcome, String finalStep,
|
||||
GameView.AutomationSnapshot snapshot) {
|
||||
ensureStarted();
|
||||
latest = snapshot == null ? latest : snapshot;
|
||||
return trace.finish(outcome, finalStep, latest);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void startLevel(int oneBasedLevelNumber) {
|
||||
delegate.startLevel(oneBasedLevelNumber);
|
||||
latest = delegate.snapshot();
|
||||
trace = BotCommandTrace.builder(oneBasedLevelNumber, scenario, latest);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setInput(boolean moveLeft, boolean moveRight, boolean jump) {
|
||||
ensureStarted();
|
||||
delegate.setInput(moveLeft, moveRight, jump);
|
||||
trace.input(step, moveLeft, moveRight, jump, latest);
|
||||
}
|
||||
|
||||
@Override
|
||||
public GameView.AutomationSnapshot snapshot() {
|
||||
latest = delegate.snapshot();
|
||||
return latest;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void waitForNextDecision(long millis) {
|
||||
delegate.waitForNextDecision(millis);
|
||||
}
|
||||
|
||||
@Override
|
||||
public long currentTimeMillis() {
|
||||
return delegate.currentTimeMillis();
|
||||
}
|
||||
|
||||
private void ensureStarted() {
|
||||
if (trace == null || latest == null) {
|
||||
throw new IllegalStateException("startLevel must be called before tracing input");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,41 +2,6 @@ package com.projectsacrifice.game;
|
||||
|
||||
/** All player-facing copy behind one typed, Android-independent interface. */
|
||||
public final class GameStrings {
|
||||
private static final String[] EN_TITLES = {
|
||||
"The Volunteer", "A Useful End", "Dead Weight", "The Long Lift",
|
||||
"Express Delivery", "Cold Comfort", "Live Wire", "Borrowed Time",
|
||||
"Two Good Reasons", "The Last Procession"
|
||||
};
|
||||
private static final String[] RU_TITLES = {
|
||||
"Рыцарь-доброволец", "Полезный конец", "Мёртвый груз", "Долгий подъём",
|
||||
"Срочная доставка", "Холодное утешение", "Живой провод", "Время взаймы",
|
||||
"Две веские причины", "Последнее шествие"
|
||||
};
|
||||
private static final String[] EN_HINTS = {
|
||||
"Reach the relic. Arrows move; the winged button jumps.",
|
||||
"The spikes keep every volunteer. Build a path from what remains.",
|
||||
"Leave a body on the brass plate so the next volunteer can pass.",
|
||||
"Touch the lever, then ride the moving platform to the upper gallery.",
|
||||
"The saw throws to the right. Deliver a volunteer to the distant plate.",
|
||||
"The frost makes a solid block. Use the frozen volunteer as a step.",
|
||||
"A fallen volunteer conducts the current and keeps the relay alive.",
|
||||
"Jump for the far blue relay if the timed gate closes.",
|
||||
"The final gate needs weight and power at the same time.",
|
||||
"Launch, freeze, conduct, ascend. Every lesson opens the last relic."
|
||||
};
|
||||
private static final String[] RU_HINTS = {
|
||||
"Доберитесь до реликвии. Стрелки двигают; кнопка с крылом — прыжок.",
|
||||
"Шипы сохраняют каждого добровольца. Проложите путь из того, что осталось.",
|
||||
"Оставьте тело на латунной плите, чтобы следующий доброволец прошёл.",
|
||||
"Коснитесь рычага, затем поднимитесь на платформе к верхней галерее.",
|
||||
"Пила бросает вправо. Доставьте добровольца на дальнюю плиту.",
|
||||
"Мороз создаёт твёрдый блок. Используйте замёрзшего добровольца как ступень.",
|
||||
"Павший доброволец проводит ток и поддерживает реле.",
|
||||
"Прыгните к дальнему синему реле, если ворота по таймеру закроются.",
|
||||
"Последним воротам одновременно нужны вес и питание.",
|
||||
"Разгонитесь, замёрзните, проведите ток, поднимитесь. Каждый урок открывает последнюю реликвию."
|
||||
};
|
||||
|
||||
private final GameLanguage language;
|
||||
|
||||
private GameStrings(GameLanguage language) {
|
||||
@@ -308,11 +273,13 @@ public final class GameStrings {
|
||||
}
|
||||
|
||||
public String levelTitle(int oneBasedLevelNumber) {
|
||||
return (russian() ? RU_TITLES : EN_TITLES)[levelIndex(oneBasedLevelNumber)];
|
||||
LevelDefinition level = level(oneBasedLevelNumber);
|
||||
return russian() ? level.titleRu : level.title;
|
||||
}
|
||||
|
||||
public String levelHint(int oneBasedLevelNumber) {
|
||||
return (russian() ? RU_HINTS : EN_HINTS)[levelIndex(oneBasedLevelNumber)];
|
||||
LevelDefinition level = level(oneBasedLevelNumber);
|
||||
return russian() ? level.hintRu : level.hint;
|
||||
}
|
||||
|
||||
private String volunteerWord(int count) {
|
||||
@@ -329,8 +296,9 @@ public final class GameStrings {
|
||||
return index >= 0 && index <= 2 ? index : 1;
|
||||
}
|
||||
|
||||
private static int levelIndex(int oneBasedLevelNumber) {
|
||||
private static LevelDefinition level(int oneBasedLevelNumber) {
|
||||
int index = oneBasedLevelNumber - 1;
|
||||
return index >= 0 && index < EN_TITLES.length ? index : 0;
|
||||
if (index < 0 || index >= LevelCatalog.all().size()) index = 0;
|
||||
return LevelCatalog.get(index);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,197 +0,0 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
import static com.projectsacrifice.game.LevelDefinition.HazardType.ELECTRIC;
|
||||
import static com.projectsacrifice.game.LevelDefinition.HazardType.FREEZER;
|
||||
import static com.projectsacrifice.game.LevelDefinition.HazardType.PIT;
|
||||
import static com.projectsacrifice.game.LevelDefinition.HazardType.SAW;
|
||||
import static com.projectsacrifice.game.LevelDefinition.HazardType.SPIKES;
|
||||
import static com.projectsacrifice.game.LevelDefinition.SwitchType.PRESSURE;
|
||||
import static com.projectsacrifice.game.LevelDefinition.SwitchType.TIMED;
|
||||
import static com.projectsacrifice.game.LevelDefinition.SwitchType.TOGGLE;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
/** Ten original vertical-slice puzzles with a one-mechanic-at-a-time learning curve. */
|
||||
public final class LevelCatalog {
|
||||
private static final float FLOOR_Y = 880f;
|
||||
private static final List<LevelDefinition> LEVELS = Collections.unmodifiableList(Arrays.asList(
|
||||
level1(), level2(), level3(), level4(), level5(),
|
||||
level6(), level7(), level8(), level9(), level10()
|
||||
));
|
||||
|
||||
private LevelCatalog() {}
|
||||
|
||||
public static List<LevelDefinition> all() { return LEVELS; }
|
||||
|
||||
public static LevelDefinition get(int index) {
|
||||
if (index < 0 || index >= LEVELS.size()) {
|
||||
throw new IllegalArgumentException("Unknown level index: " + index);
|
||||
}
|
||||
return LEVELS.get(index);
|
||||
}
|
||||
|
||||
private static LevelDefinition level1() {
|
||||
return level(
|
||||
1, "The Volunteer", "Reach the relic. Arrows move; the winged button jumps.",
|
||||
1920, 130, 760, goal(1690, 770), 0, 30,
|
||||
platforms(floor(0, 1920), ledge(640, 760, 230), ledge(1060, 690, 250)),
|
||||
hazards(), switches(), doors()
|
||||
);
|
||||
}
|
||||
|
||||
private static LevelDefinition level2() {
|
||||
return level(
|
||||
2, "A Useful End", "The spikes keep every volunteer. Build a path from what remains.",
|
||||
2050, 130, 760, goal(1830, 770), 3, 55,
|
||||
platforms(floor(0, 2050), ledge(1180, 740, 240)),
|
||||
hazards(hazard(SPIKES, 610, 850, 260, 30)), switches(), doors()
|
||||
);
|
||||
}
|
||||
|
||||
private static LevelDefinition level3() {
|
||||
return level(
|
||||
3, "Dead Weight", "Leave a body on the brass plate so the next volunteer can pass.",
|
||||
2050, 130, 760, goal(1810, 770), 1, 50,
|
||||
platforms(floor(0, 2050)),
|
||||
hazards(hazard(SAW, 565, 760, 92, 92, null, 0, -150)),
|
||||
switches(sw(PRESSURE, 540, 860, 150, 20, "gate", 0, 1)),
|
||||
doors(door(1110, 570, 78, 310, "gate"))
|
||||
);
|
||||
}
|
||||
|
||||
private static LevelDefinition level4() {
|
||||
return level(
|
||||
4, "The Long Lift", "Touch the lever, then ride the moving platform to the upper gallery.",
|
||||
2200, 130, 760, goal(1960, 390), 0, 60,
|
||||
platforms(
|
||||
floor(0, 1500), floor(1820, 380), ledge(1660, 500, 540),
|
||||
moving(1040, 790, 260, 34, 0, -390, 5.2f, "lift")
|
||||
),
|
||||
hazards(hazard(PIT, 1500, 880, 320, 200)),
|
||||
switches(sw(TOGGLE, 520, 820, 72, 60, "lift", 0, 1)), doors()
|
||||
);
|
||||
}
|
||||
|
||||
private static LevelDefinition level5() {
|
||||
return level(
|
||||
5, "Express Delivery", "The saw throws to the right. Deliver a volunteer to the distant plate.",
|
||||
2300, 130, 760, goal(2080, 770), 1, 65,
|
||||
platforms(floor(0, 2300), ledge(850, 800, 330)),
|
||||
hazards(hazard(SAW, 570, 760, 96, 96, null, 500, -700)),
|
||||
switches(sw(PRESSURE, 930, 780, 160, 20, "gate", 0, 1)),
|
||||
doors(door(1390, 560, 82, 320, "gate"))
|
||||
);
|
||||
}
|
||||
|
||||
private static LevelDefinition level6() {
|
||||
return level(
|
||||
6, "Cold Comfort", "The frost makes a solid block. Use the frozen volunteer as a step.",
|
||||
2150, 130, 760, goal(1880, 500), 1, 65,
|
||||
platforms(floor(0, 1120), ledge(810, 590, 520), ledge(1430, 610, 570)),
|
||||
hazards(hazard(FREEZER, 690, 776, 100, 104)), switches(), doors()
|
||||
);
|
||||
}
|
||||
|
||||
private static LevelDefinition level7() {
|
||||
return level(
|
||||
7, "Live Wire", "A fallen volunteer conducts the current and keeps the relay alive.",
|
||||
2150, 130, 760, goal(1930, 770), 1, 55,
|
||||
platforms(floor(0, 2150)),
|
||||
hazards(hazard(ELECTRIC, 650, 780, 110, 100, "power", 0, 0)),
|
||||
switches(), doors(door(1130, 560, 84, 320, "power"))
|
||||
);
|
||||
}
|
||||
|
||||
private static LevelDefinition level8() {
|
||||
return level(
|
||||
8, "Borrowed Time", "Jump for the far blue relay if the timed gate closes.",
|
||||
2450, 130, 760, goal(2220, 600), 0, 38,
|
||||
platforms(
|
||||
floor(0, 1040), floor(1510, 940), ledge(1980, 710, 470),
|
||||
moving(910, 770, 320, 34, 380, 0, 4.0f, "clock")
|
||||
),
|
||||
hazards(hazard(PIT, 1040, 880, 470, 200)),
|
||||
switches(
|
||||
sw(TIMED, 560, 810, 90, 70, "clock", 8.0f, 1),
|
||||
sw(TIMED, 1600, 650, 90, 70, "clock", 3.5f, 1)
|
||||
),
|
||||
doors(door(1770, 560, 82, 320, "clock"))
|
||||
);
|
||||
}
|
||||
|
||||
private static LevelDefinition level9() {
|
||||
return level(
|
||||
9, "Two Good Reasons", "The final gate needs weight and power at the same time.",
|
||||
2550, 130, 760, goal(2310, 770), 2, 80,
|
||||
platforms(floor(0, 2550)),
|
||||
hazards(
|
||||
hazard(SPIKES, 520, 850, 130, 30),
|
||||
hazard(ELECTRIC, 910, 780, 110, 100, "power", 0, 0)
|
||||
),
|
||||
switches(sw(PRESSURE, 510, 842, 160, 38, "weight", 0, 1)),
|
||||
doors(door(1510, 540, 86, 340, "weight", "power"))
|
||||
);
|
||||
}
|
||||
|
||||
private static LevelDefinition level10() {
|
||||
return level(
|
||||
10, "The Last Procession", "Launch, freeze, conduct, ascend. Every lesson opens the last relic.",
|
||||
3100, 130, 760, goal(2880, 330), 3, 120,
|
||||
platforms(
|
||||
floor(0, 1940), floor(2140, 960), ledge(2420, 440, 680),
|
||||
moving(1800, 790, 290, 34, 0, -410, 10.0f, "lift")
|
||||
),
|
||||
hazards(
|
||||
hazard(SAW, 520, 760, 96, 96, null, 700, -240),
|
||||
hazard(FREEZER, 1110, 776, 100, 104),
|
||||
hazard(ELECTRIC, 1510, 780, 110, 100, "power", 0, 0),
|
||||
hazard(PIT, 1940, 880, 200, 200)
|
||||
),
|
||||
switches(sw(PRESSURE, 860, 860, 160, 20, "lift", 0, 1)),
|
||||
doors(door(2310, 310, 82, 570, "power"))
|
||||
);
|
||||
}
|
||||
|
||||
private static LevelDefinition level(
|
||||
int number, String title, String hint, float width,
|
||||
float spawnX, float spawnY, LevelDefinition.Box goal,
|
||||
int targetDeaths, int targetSeconds,
|
||||
List<LevelDefinition.Platform> platforms,
|
||||
List<LevelDefinition.Hazard> hazards,
|
||||
List<LevelDefinition.Switch> switches,
|
||||
List<LevelDefinition.Door> doors
|
||||
) {
|
||||
return new LevelDefinition(number, title, hint, width, spawnX, spawnY, goal,
|
||||
targetDeaths, targetSeconds, platforms, hazards, switches, doors);
|
||||
}
|
||||
|
||||
private static LevelDefinition.Box box(float x, float y, float w, float h) {
|
||||
return new LevelDefinition.Box(x, y, w, h);
|
||||
}
|
||||
|
||||
private static LevelDefinition.Box goal(float x, float y) { return box(x, y, 86, 110); }
|
||||
private static LevelDefinition.Platform floor(float x, float w) { return new LevelDefinition.Platform(box(x, FLOOR_Y, w, 200)); }
|
||||
private static LevelDefinition.Platform ledge(float x, float y, float w) { return new LevelDefinition.Platform(box(x, y, w, 34)); }
|
||||
private static LevelDefinition.Platform moving(float x, float y, float w, float h, float tx, float ty, float period, String control) {
|
||||
return new LevelDefinition.Platform(box(x, y, w, h), tx, ty, period, control);
|
||||
}
|
||||
private static LevelDefinition.Hazard hazard(LevelDefinition.HazardType type, float x, float y, float w, float h) {
|
||||
return new LevelDefinition.Hazard(type, box(x, y, w, h));
|
||||
}
|
||||
private static LevelDefinition.Hazard hazard(LevelDefinition.HazardType type, float x, float y, float w, float h, String relay, float ix, float iy) {
|
||||
return new LevelDefinition.Hazard(type, box(x, y, w, h), relay, ix, iy);
|
||||
}
|
||||
private static LevelDefinition.Switch sw(LevelDefinition.SwitchType type, float x, float y, float w, float h, String id, float duration, int mass) {
|
||||
return new LevelDefinition.Switch(type, box(x, y, w, h), id, duration, mass);
|
||||
}
|
||||
private static LevelDefinition.Door door(float x, float y, float w, float h, String... ids) {
|
||||
return new LevelDefinition.Door(box(x, y, w, h), ids);
|
||||
}
|
||||
|
||||
@SafeVarargs private static List<LevelDefinition.Platform> platforms(LevelDefinition.Platform... values) { return Arrays.asList(values); }
|
||||
@SafeVarargs private static List<LevelDefinition.Hazard> hazards(LevelDefinition.Hazard... values) { return Arrays.asList(values); }
|
||||
@SafeVarargs private static List<LevelDefinition.Switch> switches(LevelDefinition.Switch... values) { return Arrays.asList(values); }
|
||||
@SafeVarargs private static List<LevelDefinition.Door> doors(LevelDefinition.Door... values) { return Arrays.asList(values); }
|
||||
}
|
||||
@@ -1,161 +0,0 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
/** Immutable, Android-independent level data. */
|
||||
public final class LevelDefinition {
|
||||
public enum HazardType { SPIKES, SAW, FREEZER, ELECTRIC, PIT }
|
||||
public enum SwitchType { PRESSURE, TOGGLE, TIMED }
|
||||
|
||||
public static final class Box {
|
||||
public final float x;
|
||||
public final float y;
|
||||
public final float width;
|
||||
public final float height;
|
||||
|
||||
public Box(float x, float y, float width, float height) {
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
}
|
||||
|
||||
public float right() { return x + width; }
|
||||
public float bottom() { return y + height; }
|
||||
}
|
||||
|
||||
public static final class Platform {
|
||||
public final Box box;
|
||||
public final float travelX;
|
||||
public final float travelY;
|
||||
public final float periodSeconds;
|
||||
public final String controlId;
|
||||
|
||||
public Platform(Box box) {
|
||||
this(box, 0f, 0f, 0f, null);
|
||||
}
|
||||
|
||||
public Platform(Box box, float travelX, float travelY, float periodSeconds, String controlId) {
|
||||
this.box = box;
|
||||
this.travelX = travelX;
|
||||
this.travelY = travelY;
|
||||
this.periodSeconds = periodSeconds;
|
||||
this.controlId = controlId;
|
||||
}
|
||||
|
||||
public boolean isMoving() { return travelX != 0f || travelY != 0f; }
|
||||
}
|
||||
|
||||
public static final class Hazard {
|
||||
public final HazardType type;
|
||||
public final Box box;
|
||||
public final String relayId;
|
||||
public final float impulseX;
|
||||
public final float impulseY;
|
||||
|
||||
public Hazard(HazardType type, Box box) {
|
||||
this(type, box, null, 0f, 0f);
|
||||
}
|
||||
|
||||
public Hazard(HazardType type, Box box, String relayId, float impulseX, float impulseY) {
|
||||
this.type = type;
|
||||
this.box = box;
|
||||
this.relayId = relayId;
|
||||
this.impulseX = impulseX;
|
||||
this.impulseY = impulseY;
|
||||
}
|
||||
}
|
||||
|
||||
public static final class Switch {
|
||||
public final SwitchType type;
|
||||
public final Box box;
|
||||
public final String signalId;
|
||||
public final float durationSeconds;
|
||||
public final int requiredMass;
|
||||
|
||||
public Switch(SwitchType type, Box box, String signalId, float durationSeconds, int requiredMass) {
|
||||
this.type = type;
|
||||
this.box = box;
|
||||
this.signalId = signalId;
|
||||
this.durationSeconds = durationSeconds;
|
||||
this.requiredMass = requiredMass;
|
||||
}
|
||||
}
|
||||
|
||||
public static final class Door {
|
||||
public final Box box;
|
||||
public final List<String> requiredSignals;
|
||||
|
||||
public Door(Box box, String... requiredSignals) {
|
||||
this.box = box;
|
||||
this.requiredSignals = Collections.unmodifiableList(Arrays.asList(requiredSignals));
|
||||
}
|
||||
}
|
||||
|
||||
public final int number;
|
||||
public final String title;
|
||||
public final String hint;
|
||||
public final float worldWidth;
|
||||
public final float spawnX;
|
||||
public final float spawnY;
|
||||
public final Box goal;
|
||||
public final int targetDeaths;
|
||||
public final int targetSeconds;
|
||||
public final List<Platform> platforms;
|
||||
public final List<Hazard> hazards;
|
||||
public final List<Switch> switches;
|
||||
public final List<Door> doors;
|
||||
|
||||
public LevelDefinition(
|
||||
int number,
|
||||
String title,
|
||||
String hint,
|
||||
float worldWidth,
|
||||
float spawnX,
|
||||
float spawnY,
|
||||
Box goal,
|
||||
int targetDeaths,
|
||||
int targetSeconds,
|
||||
List<Platform> platforms,
|
||||
List<Hazard> hazards,
|
||||
List<Switch> switches,
|
||||
List<Door> doors
|
||||
) {
|
||||
this.number = number;
|
||||
this.title = title;
|
||||
this.hint = hint;
|
||||
this.worldWidth = worldWidth;
|
||||
this.spawnX = spawnX;
|
||||
this.spawnY = spawnY;
|
||||
this.goal = goal;
|
||||
this.targetDeaths = targetDeaths;
|
||||
this.targetSeconds = targetSeconds;
|
||||
this.platforms = Collections.unmodifiableList(platforms);
|
||||
this.hazards = Collections.unmodifiableList(hazards);
|
||||
this.switches = Collections.unmodifiableList(switches);
|
||||
this.doors = Collections.unmodifiableList(doors);
|
||||
}
|
||||
|
||||
public boolean containsMechanic(HazardType type) {
|
||||
for (Hazard hazard : hazards) {
|
||||
if (hazard.type == type) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public boolean containsMechanic(SwitchType type) {
|
||||
for (Switch item : switches) {
|
||||
if (item.type == type) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public boolean hasMovingPlatform() {
|
||||
for (Platform platform : platforms) {
|
||||
if (platform.isMoving()) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -127,14 +127,18 @@ public final class LevelPlaytestBot {
|
||||
private final int corpseCount;
|
||||
private final String diagnostic;
|
||||
private final String lastStep;
|
||||
private final String traceJson;
|
||||
private final String traceId;
|
||||
|
||||
Result(boolean completed, boolean doorOpened, int deaths, int corpseCount,
|
||||
String diagnostic, String lastStep) {
|
||||
String diagnostic, String lastStep, BotCommandTrace trace) {
|
||||
this.completed = completed;
|
||||
this.doorOpened = doorOpened;
|
||||
this.deaths = deaths;
|
||||
this.corpseCount = corpseCount;
|
||||
this.diagnostic = diagnostic;
|
||||
this.traceJson = trace.json();
|
||||
this.traceId = trace.id();
|
||||
this.diagnostic = diagnostic + "; traceId=" + traceId;
|
||||
this.lastStep = lastStep;
|
||||
}
|
||||
|
||||
@@ -144,6 +148,8 @@ public final class LevelPlaytestBot {
|
||||
public int corpseCount() { return corpseCount; }
|
||||
public String diagnostic() { return diagnostic; }
|
||||
public String lastStep() { return lastStep; }
|
||||
public String traceJson() { return traceJson; }
|
||||
public String traceId() { return traceId; }
|
||||
}
|
||||
|
||||
public Result run(Driver driver, Scenario scenario, long timeoutMillis) {
|
||||
@@ -154,6 +160,8 @@ public final class LevelPlaytestBot {
|
||||
String lastStep = scenario.steps.isEmpty() ? null : scenario.steps.get(0).name;
|
||||
boolean doorOpened = false;
|
||||
GameView.AutomationSnapshot last = driver.snapshot();
|
||||
BotCommandTrace.Builder trace = BotCommandTrace.builder(
|
||||
scenario.levelNumber, scenario.name, last);
|
||||
long scenarioStartedAtSimulation = last.elapsedMilliseconds();
|
||||
long stepStartedAtSimulation = scenarioStartedAtSimulation;
|
||||
|
||||
@@ -164,20 +172,22 @@ public final class LevelPlaytestBot {
|
||||
last.elapsedMilliseconds(), scenarioStartedAtSimulation);
|
||||
doorOpened |= last.allDoorsOpen();
|
||||
if (last.completed()) {
|
||||
driver.setInput(false, false, false);
|
||||
applyInput(driver, trace, Input.none(), lastStep, last);
|
||||
BotCommandTrace finishedTrace = trace.finish("COMPLETED", lastStep, last);
|
||||
return new Result(true, doorOpened, last.deaths(), last.corpseCount(),
|
||||
scenario.name + " completed; step=" + lastStep + "; "
|
||||
+ timingState(simulationElapsed, wallElapsed) + "; "
|
||||
+ snapshotState(last),
|
||||
lastStep);
|
||||
lastStep, finishedTrace);
|
||||
}
|
||||
if (wallElapsed >= wallWatchdogMillis) {
|
||||
driver.setInput(false, false, false);
|
||||
applyInput(driver, trace, Input.none(), lastStep, last);
|
||||
BotCommandTrace finishedTrace = trace.finish("WALL_WATCHDOG", lastStep, last);
|
||||
return new Result(false, doorOpened, last.deaths(), last.corpseCount(),
|
||||
scenario.name + " stopped by wall-clock watchdog; step=" + lastStep
|
||||
+ "; " + timingState(simulationElapsed, wallElapsed) + "; "
|
||||
+ snapshotState(last),
|
||||
lastStep);
|
||||
lastStep, finishedTrace);
|
||||
}
|
||||
if (simulationElapsed >= timeoutMillis) break;
|
||||
if (stepIndex >= scenario.steps.size()) break;
|
||||
@@ -185,34 +195,43 @@ public final class LevelPlaytestBot {
|
||||
Step step = scenario.steps.get(stepIndex);
|
||||
lastStep = step.name;
|
||||
if (step.completion.reached(last)) {
|
||||
driver.setInput(false, false, false);
|
||||
trace.checkpoint(step.name, last);
|
||||
applyInput(driver, trace, Input.none(), step.name, last);
|
||||
stepIndex++;
|
||||
stepStartedAtSimulation = last.elapsedMilliseconds();
|
||||
continue;
|
||||
}
|
||||
if (elapsed(last.elapsedMilliseconds(), stepStartedAtSimulation)
|
||||
>= step.timeoutMillis) {
|
||||
driver.setInput(false, false, false);
|
||||
applyInput(driver, trace, Input.none(), step.name, last);
|
||||
BotCommandTrace finishedTrace = trace.finish("STEP_TIMEOUT", step.name, last);
|
||||
return new Result(false, doorOpened, last.deaths(), last.corpseCount(),
|
||||
scenario.name + " timed out in simulation; step=" + step.name
|
||||
+ "; " + timingState(simulationElapsed, wallElapsed) + "; "
|
||||
+ snapshotState(last),
|
||||
step.name);
|
||||
step.name, finishedTrace);
|
||||
}
|
||||
|
||||
driver.setInput(step.input.moveLeft, step.input.moveRight, step.input.jump);
|
||||
applyInput(driver, trace, step.input, step.name, last);
|
||||
driver.waitForNextDecision(DECISION_MILLIS);
|
||||
}
|
||||
|
||||
driver.setInput(false, false, false);
|
||||
applyInput(driver, trace, Input.none(), lastStep, last);
|
||||
long wallElapsed = elapsed(driver.currentTimeMillis(), scenarioStartedAtWall);
|
||||
long simulationElapsed = elapsed(
|
||||
last.elapsedMilliseconds(), scenarioStartedAtSimulation);
|
||||
BotCommandTrace finishedTrace = trace.finish("SIMULATION_BUDGET", lastStep, last);
|
||||
return new Result(false, doorOpened, last.deaths(), last.corpseCount(),
|
||||
scenario.name + " exhausted its simulation budget; step=" + lastStep
|
||||
+ "; " + timingState(simulationElapsed, wallElapsed) + "; "
|
||||
+ snapshotState(last),
|
||||
lastStep);
|
||||
lastStep, finishedTrace);
|
||||
}
|
||||
|
||||
private static void applyInput(Driver driver, BotCommandTrace.Builder trace, Input input,
|
||||
String step, GameView.AutomationSnapshot snapshot) {
|
||||
driver.setInput(input.moveLeft, input.moveRight, input.jump);
|
||||
trace.input(step, input.moveLeft, input.moveRight, input.jump, snapshot);
|
||||
}
|
||||
|
||||
private static long wallWatchdogMillis(long simulationBudgetMillis) {
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.io.StringReader;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
public final class LevelCatalogLoaderTest {
|
||||
@Test
|
||||
public void versionedIndexLoadsSequentialLevelsThroughResourceBoundary() {
|
||||
Map<String, String> resources = new HashMap<>();
|
||||
resources.put("levels/index.json", "{\"formatVersion\":1,\"physicsVersion\":1,"
|
||||
+ "\"levels\":[\"level-01.json\"]}");
|
||||
resources.put("levels/level-01.json", LevelJsonLoaderTest.validLevelJson(1));
|
||||
|
||||
List<LevelDefinition> levels = LevelCatalogLoader.load(path -> {
|
||||
String value = resources.get(path);
|
||||
return value == null ? null : new StringReader(value);
|
||||
}, "levels/index.json");
|
||||
|
||||
assertEquals(1, levels.size());
|
||||
assertEquals(1, levels.get(0).number);
|
||||
assertEquals("The Test", levels.get(0).title);
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,19 @@ public final class LevelCatalogTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void bundledCatalogCarriesLocalizedCopyAndVersionedContent() {
|
||||
LevelDefinition first = LevelCatalog.get(0);
|
||||
LevelDefinition last = LevelCatalog.get(9);
|
||||
|
||||
assertEquals(LevelFormat.CURRENT_FORMAT_VERSION, first.formatVersion);
|
||||
assertEquals(LevelFormat.CURRENT_PHYSICS_VERSION, first.physicsVersion);
|
||||
assertEquals("Рыцарь-доброволец", first.titleRu);
|
||||
assertEquals("Последнее шествие", last.titleRu);
|
||||
assertFalse(first.hintRu.trim().isEmpty());
|
||||
assertFalse(last.hintRu.trim().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void everyLevelHasValidSpawnGoalAndGeometry() {
|
||||
for (LevelDefinition level : LevelCatalog.all()) {
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
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;
|
||||
|
||||
import java.io.StringReader;
|
||||
|
||||
public final class LevelJsonLoaderTest {
|
||||
@Test
|
||||
public void versionedJsonLoadsLocalizedLevelThroughPublicContract() {
|
||||
String json = validLevelJson(1);
|
||||
|
||||
LevelDefinition level = LevelJsonLoader.load(new StringReader(json), "test-level.json");
|
||||
|
||||
assertEquals(1, level.formatVersion);
|
||||
assertEquals(1, level.physicsVersion);
|
||||
assertEquals(1, level.number);
|
||||
assertEquals("The Test", level.title);
|
||||
assertEquals("Reach the goal.", level.hint);
|
||||
assertEquals("Испытание", level.titleRu);
|
||||
assertEquals("Доберитесь до цели.", level.hintRu);
|
||||
assertEquals(1920f, level.worldWidth, 0f);
|
||||
assertEquals(1080f, level.worldHeight, 0f);
|
||||
assertEquals(1, level.platforms.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void unknownFormatVersionIsRejectedWithSourcePath() {
|
||||
try {
|
||||
LevelJsonLoader.load(new StringReader(validLevelJson(2)), "future-level.json");
|
||||
fail("Unknown format versions must fail closed");
|
||||
} catch (IllegalArgumentException error) {
|
||||
assertTrue(error.getMessage(), error.getMessage().contains("future-level.json.formatVersion"));
|
||||
assertTrue(error.getMessage(), error.getMessage().contains("expected 1"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void doorWithUnknownSignalIsRejectedBeforeEnteringCatalog() {
|
||||
String json = validLevelJson(1).replace(
|
||||
"\"hazards\":[],\"switches\":[],\"doors\":[]",
|
||||
"\"hazards\":[],"
|
||||
+ "\"switches\":[{\"type\":\"PRESSURE\","
|
||||
+ "\"box\":{\"x\":540,\"y\":860,\"width\":150,\"height\":20},"
|
||||
+ "\"signalId\":\"gate\",\"durationSeconds\":0,\"requiredMass\":1}],"
|
||||
+ "\"doors\":[{\"box\":{\"x\":1110,\"y\":570,\"width\":78,\"height\":310},"
|
||||
+ "\"requiredSignals\":[\"missing\"]}]");
|
||||
|
||||
try {
|
||||
LevelJsonLoader.load(new StringReader(json), "broken-signal.json");
|
||||
fail("Unknown signal references must be rejected");
|
||||
} catch (IllegalArgumentException error) {
|
||||
assertTrue(error.getMessage(), error.getMessage().contains(
|
||||
"broken-signal.json.doors[0].requiredSignals[0]"));
|
||||
assertTrue(error.getMessage(), error.getMessage().contains("missing signal 'missing'"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void nonPositiveGeometryIsRejectedWithObjectPath() {
|
||||
String json = validLevelJson(1).replace(
|
||||
"\"width\":1920,\"height\":200",
|
||||
"\"width\":0,\"height\":200");
|
||||
|
||||
try {
|
||||
LevelJsonLoader.load(new StringReader(json), "flat-platform.json");
|
||||
fail("Non-positive platform geometry must be rejected");
|
||||
} catch (IllegalArgumentException error) {
|
||||
assertTrue(error.getMessage(), error.getMessage().contains(
|
||||
"flat-platform.json.platforms[0].box.width"));
|
||||
assertTrue(error.getMessage(), error.getMessage().contains("must be positive"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void currentPuzzleObjectsLoadWithoutJavaGeometryFallback() {
|
||||
String json = validLevelJson(1)
|
||||
.replace(
|
||||
"{\"box\":{\"x\":0,\"y\":880,\"width\":1920,\"height\":200}}",
|
||||
"{\"box\":{\"x\":0,\"y\":880,\"width\":1920,\"height\":200},"
|
||||
+ "\"travelX\":0,\"travelY\":-300,\"periodSeconds\":5.2,"
|
||||
+ "\"controlId\":\"lift\"}")
|
||||
.replace(
|
||||
"\"hazards\":[],\"switches\":[],\"doors\":[]",
|
||||
"\"hazards\":[{\"type\":\"SAW\","
|
||||
+ "\"box\":{\"x\":565,\"y\":760,\"width\":92,\"height\":92},"
|
||||
+ "\"impulseX\":500,\"impulseY\":-700}],"
|
||||
+ "\"switches\":[{\"type\":\"PRESSURE\","
|
||||
+ "\"box\":{\"x\":540,\"y\":860,\"width\":150,\"height\":20},"
|
||||
+ "\"signalId\":\"lift\",\"durationSeconds\":0,\"requiredMass\":1}],"
|
||||
+ "\"doors\":[{\"box\":{\"x\":1110,\"y\":570,\"width\":78,\"height\":310},"
|
||||
+ "\"requiredSignals\":[\"lift\"]}]");
|
||||
|
||||
LevelDefinition level = LevelJsonLoader.load(new StringReader(json), "mechanics.json");
|
||||
|
||||
assertEquals(1, level.platforms.size());
|
||||
assertEquals(-300f, level.platforms.get(0).travelY, 0f);
|
||||
assertEquals(5.2f, level.platforms.get(0).periodSeconds, 0f);
|
||||
assertEquals("lift", level.platforms.get(0).controlId);
|
||||
assertEquals(LevelDefinition.HazardType.SAW, level.hazards.get(0).type);
|
||||
assertEquals(500f, level.hazards.get(0).impulseX, 0f);
|
||||
assertEquals(1, level.switches.size());
|
||||
assertEquals(1, level.doors.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void timedSwitchRequiresPositiveDuration() {
|
||||
String json = validLevelJson(1).replace(
|
||||
"\"hazards\":[],\"switches\":[],\"doors\":[]",
|
||||
"\"hazards\":[],"
|
||||
+ "\"switches\":[{\"type\":\"TIMED\","
|
||||
+ "\"box\":{\"x\":560,\"y\":810,\"width\":90,\"height\":70},"
|
||||
+ "\"signalId\":\"clock\",\"durationSeconds\":0,\"requiredMass\":1}],"
|
||||
+ "\"doors\":[]");
|
||||
|
||||
try {
|
||||
LevelJsonLoader.load(new StringReader(json), "zero-timer.json");
|
||||
fail("A timed switch with no active window must be rejected");
|
||||
} catch (IllegalArgumentException error) {
|
||||
assertTrue(error.getMessage(), error.getMessage().contains(
|
||||
"zero-timer.json.switches[0].durationSeconds"));
|
||||
assertTrue(error.getMessage(), error.getMessage().contains("must be positive"));
|
||||
}
|
||||
}
|
||||
|
||||
static String validLevelJson(int formatVersion) {
|
||||
return "{"
|
||||
+ "\"formatVersion\":" + formatVersion + ","
|
||||
+ "\"physicsVersion\":1,"
|
||||
+ "\"number\":1,"
|
||||
+ "\"copy\":{"
|
||||
+ "\"en\":{\"title\":\"The Test\",\"hint\":\"Reach the goal.\"},"
|
||||
+ "\"ru\":{\"title\":\"Испытание\",\"hint\":\"Доберитесь до цели.\"}},"
|
||||
+ "\"world\":{\"width\":1920,\"height\":1080},"
|
||||
+ "\"spawn\":{\"x\":130,\"y\":760},"
|
||||
+ "\"goal\":{\"x\":1690,\"y\":770,\"width\":86,\"height\":110},"
|
||||
+ "\"targets\":{\"deaths\":0,\"seconds\":30},"
|
||||
+ "\"platforms\":[{\"box\":{\"x\":0,\"y\":880,\"width\":1920,\"height\":200}}],"
|
||||
+ "\"hazards\":[],\"switches\":[],\"doors\":[]"
|
||||
+ "}";
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,51 @@ public final class LevelPlaytestBotTest {
|
||||
driver.inputReleases >= 2);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void resultExposesDeterministicVersionedCommandTrace() {
|
||||
ScriptedDriver driver = new ScriptedDriver();
|
||||
LevelPlaytestBot.Scenario scenario = LevelPlaytestBot.Scenario
|
||||
.builder(7, "Trace scenario")
|
||||
.step("Create a corpse", 1_000L, LevelPlaytestBot.Input.right(),
|
||||
snapshot -> snapshot.deaths() >= 1)
|
||||
.step("Reach the goal", 1_000L, LevelPlaytestBot.Input.left(),
|
||||
GameView.AutomationSnapshot::completed)
|
||||
.build();
|
||||
|
||||
LevelPlaytestBot.Result result = new LevelPlaytestBot().run(driver, scenario, 5_000L);
|
||||
String trace = result.traceJson();
|
||||
|
||||
assertEquals(trace, result.traceJson());
|
||||
assertTrue(trace, trace.contains("\"traceVersion\":1"));
|
||||
assertTrue(trace, trace.contains("\"level\":7"));
|
||||
assertTrue(trace, trace.contains("\"levelFormatVersion\":1"));
|
||||
assertTrue(trace, trace.contains("\"physicsVersion\":1"));
|
||||
assertTrue(trace, trace.contains("\"scenario\":\"Trace scenario\""));
|
||||
assertTrue(trace, trace.contains("\"kind\":\"INPUT\""));
|
||||
assertTrue(trace, trace.contains("\"simulationMs\":"));
|
||||
assertFalse(trace, trace.contains("wallElapsed"));
|
||||
assertFalse(trace, trace.contains("device"));
|
||||
assertTrue(result.diagnostic(), result.diagnostic().contains("traceId=" + result.traceId()));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void tracingDriverCoversBespokeProductionInputScenarios() {
|
||||
BotTraceDriver driver = new BotTraceDriver(new ScriptedDriver(), "Bespoke route");
|
||||
driver.startLevel(8);
|
||||
driver.step("Board platform");
|
||||
driver.setInput(false, true, true);
|
||||
driver.waitForNextDecision(16L);
|
||||
GameView.AutomationSnapshot snapshot = driver.snapshot();
|
||||
|
||||
BotCommandTrace trace = driver.finish("COMPLETED", "Board platform", snapshot);
|
||||
|
||||
assertTrue(trace.json(), trace.json().contains("\"scenario\":\"Bespoke route\""));
|
||||
assertTrue(trace.json(), trace.json().contains("\"step\":\"Board platform\""));
|
||||
assertTrue(trace.json(), trace.json().contains(
|
||||
"\"input\":{\"left\":false,\"right\":true,\"jump\":true}"));
|
||||
assertEquals(64, trace.id().length());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void sameDriverCanRestartTheCompleteScenarioThreeTimes() {
|
||||
ScriptedDriver driver = new ScriptedDriver();
|
||||
|
||||
Reference in New Issue
Block a user