Завершить контентную архитектуру этапа 4

This commit is contained in:
2026-08-18 22:03:38 +03:00
parent 2ddac2775f
commit 5051f316fe
39 changed files with 1954 additions and 259 deletions
+7 -2
View File
@@ -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();