Зафиксировать матрицу четырёх миров
This commit is contained in:
@@ -28,6 +28,19 @@ public final class LevelContentTool {
|
||||
+ LevelFormat.CURRENT_PHYSICS_VERSION);
|
||||
return 0;
|
||||
}
|
||||
if (args != null && args.length == 3 && "validate-matrix".equals(args[0])) {
|
||||
Path matrixPath = Paths.get(args[1]).toAbsolutePath().normalize();
|
||||
ProductionContentMatrix matrix;
|
||||
try (Reader reader = Files.newBufferedReader(matrixPath, StandardCharsets.UTF_8)) {
|
||||
matrix = ProductionContentMatrix.load(reader, matrixPath.toString());
|
||||
}
|
||||
int authored = matrix.validateAuthoredPrefix(loadDirectory(args[2]), args[2]);
|
||||
output.println("Validated production content matrix: worlds="
|
||||
+ matrix.worldIds().size() + "; planned=" + matrix.entries().size()
|
||||
+ "; authored=" + authored + "; releaseTotal="
|
||||
+ matrix.releaseLevelCount());
|
||||
return 0;
|
||||
}
|
||||
if (args != null && args.length == 3 && "preview".equals(args[0])) {
|
||||
List<LevelDefinition> levels = loadDirectory(args[1]);
|
||||
Path destination = Paths.get(args[2]).toAbsolutePath().normalize();
|
||||
@@ -35,7 +48,8 @@ public final class LevelContentTool {
|
||||
output.println("Exported " + levels.size() + " level preview(s) to " + destination);
|
||||
return 0;
|
||||
}
|
||||
errors.println("Usage: validate <levels-directory> | preview <levels-directory> <output-directory>");
|
||||
errors.println("Usage: validate <levels-directory> | validate-matrix <matrix-file> "
|
||||
+ "<levels-directory> | preview <levels-directory> <output-directory>");
|
||||
return 2;
|
||||
} catch (IllegalArgumentException | IOException error) {
|
||||
errors.println(error.getMessage());
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.IOException;
|
||||
import java.io.Reader;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
/** Immutable release plan for production levels 18-69. */
|
||||
public final class ProductionContentMatrix {
|
||||
public static final String HEADER = "number\tworldId\tlevelId\trole\ttitle\tintroducedMechanic"
|
||||
+ "\tmechanics\ttargetDeaths\ttargetSeconds\tcollectible";
|
||||
public static final int LEGACY_LEVEL_COUNT = 17;
|
||||
public static final int LEVELS_PER_WORLD = 13;
|
||||
private static final List<String> EXPECTED_WORLDS = List.of(
|
||||
"foundations", "physics", "states", "mastery");
|
||||
|
||||
public enum Role { MAIN, CHALLENGE, FINALE }
|
||||
|
||||
public static final class Entry {
|
||||
public final int number;
|
||||
public final String worldId;
|
||||
public final String levelId;
|
||||
public final Role role;
|
||||
public final String title;
|
||||
public final String introducedMechanic;
|
||||
public final List<String> mechanics;
|
||||
public final int targetDeaths;
|
||||
public final int targetSeconds;
|
||||
public final boolean collectible;
|
||||
|
||||
private Entry(int number, String worldId, String levelId, Role role, String title,
|
||||
String introducedMechanic, List<String> mechanics, int targetDeaths,
|
||||
int targetSeconds, boolean collectible) {
|
||||
this.number = number;
|
||||
this.worldId = worldId;
|
||||
this.levelId = levelId;
|
||||
this.role = role;
|
||||
this.title = title;
|
||||
this.introducedMechanic = introducedMechanic;
|
||||
this.mechanics = Collections.unmodifiableList(new ArrayList<>(mechanics));
|
||||
this.targetDeaths = targetDeaths;
|
||||
this.targetSeconds = targetSeconds;
|
||||
this.collectible = collectible;
|
||||
}
|
||||
}
|
||||
|
||||
private final List<Entry> entries;
|
||||
|
||||
private ProductionContentMatrix(List<Entry> entries, String source) {
|
||||
validate(entries, source);
|
||||
this.entries = Collections.unmodifiableList(new ArrayList<>(entries));
|
||||
}
|
||||
|
||||
public static ProductionContentMatrix load(Reader source, String sourceName) {
|
||||
if (source == null) throw new IllegalArgumentException("content matrix reader is required");
|
||||
String name = sourceName == null || sourceName.trim().isEmpty()
|
||||
? "content matrix" : sourceName;
|
||||
List<Entry> entries = new ArrayList<>();
|
||||
try {
|
||||
BufferedReader reader = source instanceof BufferedReader
|
||||
? (BufferedReader) source : new BufferedReader(source);
|
||||
String header = reader.readLine();
|
||||
if (header != null && header.startsWith("\ufeff")) header = header.substring(1);
|
||||
if (!HEADER.equals(header)) {
|
||||
throw new IllegalArgumentException(name + " has an unsupported header");
|
||||
}
|
||||
String line;
|
||||
int lineNumber = 1;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
lineNumber++;
|
||||
if (line.trim().isEmpty() || line.startsWith("#")) continue;
|
||||
entries.add(parse(line, name, lineNumber));
|
||||
}
|
||||
} catch (IOException error) {
|
||||
throw new IllegalArgumentException(name + " could not be read", error);
|
||||
}
|
||||
return new ProductionContentMatrix(entries, name);
|
||||
}
|
||||
|
||||
public List<Entry> entries() {
|
||||
return entries;
|
||||
}
|
||||
|
||||
public List<String> worldIds() {
|
||||
return EXPECTED_WORLDS;
|
||||
}
|
||||
|
||||
public List<Entry> entriesForWorld(String worldId) {
|
||||
List<Entry> result = new ArrayList<>();
|
||||
for (Entry entry : entries) if (entry.worldId.equals(worldId)) result.add(entry);
|
||||
return Collections.unmodifiableList(result);
|
||||
}
|
||||
|
||||
public int releaseLevelCount() {
|
||||
return LEGACY_LEVEL_COUNT + entries.size();
|
||||
}
|
||||
|
||||
/** Ensures every authored production level is the exact leading prefix of this plan. */
|
||||
public int validateAuthoredPrefix(List<LevelDefinition> authoredLevels, String sourceName) {
|
||||
if (authoredLevels == null) throw new IllegalArgumentException("authored levels are required");
|
||||
String source = sourceName == null || sourceName.trim().isEmpty()
|
||||
? "authored catalog" : sourceName;
|
||||
int authoredProduction = 0;
|
||||
for (LevelDefinition level : authoredLevels) {
|
||||
if (level.number <= LEGACY_LEVEL_COUNT) continue;
|
||||
int matrixIndex = level.number - LEGACY_LEVEL_COUNT - 1;
|
||||
if (matrixIndex < 0 || matrixIndex >= entries.size()) {
|
||||
throw new IllegalArgumentException(source + " level " + level.number
|
||||
+ " is outside the production matrix");
|
||||
}
|
||||
Entry planned = entries.get(matrixIndex);
|
||||
requireEqual(source, level.number, "level id", planned.levelId, level.levelId);
|
||||
requireEqual(source, level.number, "world id", planned.worldId, level.worldId);
|
||||
requireEqual(source, level.number, "title", planned.title, level.title);
|
||||
if (planned.targetDeaths != level.targetDeaths) {
|
||||
throw mismatch(source, level.number, "target deaths",
|
||||
Integer.toString(planned.targetDeaths), Integer.toString(level.targetDeaths));
|
||||
}
|
||||
if (planned.targetSeconds != level.targetSeconds) {
|
||||
throw mismatch(source, level.number, "target seconds",
|
||||
Integer.toString(planned.targetSeconds), Integer.toString(level.targetSeconds));
|
||||
}
|
||||
if (planned.collectible != (level.collectible != null)) {
|
||||
throw mismatch(source, level.number, "collectible",
|
||||
Boolean.toString(planned.collectible), Boolean.toString(level.collectible != null));
|
||||
}
|
||||
authoredProduction++;
|
||||
}
|
||||
return authoredProduction;
|
||||
}
|
||||
|
||||
private static Entry parse(String line, String source, int lineNumber) {
|
||||
String[] fields = line.split("\\t", -1);
|
||||
if (fields.length != 10) {
|
||||
throw new IllegalArgumentException(source + ":" + lineNumber
|
||||
+ " expected 10 tab-separated fields but found " + fields.length);
|
||||
}
|
||||
for (int index = 0; index < fields.length; index++) fields[index] = fields[index].trim();
|
||||
int number = integer(fields[0], source, lineNumber, "number");
|
||||
Role role;
|
||||
try {
|
||||
role = Role.valueOf(required(fields[3], source, lineNumber, "role"));
|
||||
} catch (IllegalArgumentException error) {
|
||||
throw new IllegalArgumentException(source + ":" + lineNumber
|
||||
+ " has invalid role '" + fields[3] + "'");
|
||||
}
|
||||
List<String> mechanics = new ArrayList<>();
|
||||
for (String mechanic : required(fields[6], source, lineNumber, "mechanics").split(",")) {
|
||||
String trimmed = mechanic.trim();
|
||||
if (trimmed.isEmpty()) {
|
||||
throw new IllegalArgumentException(source + ":" + lineNumber
|
||||
+ " has an empty mechanic");
|
||||
}
|
||||
mechanics.add(trimmed);
|
||||
}
|
||||
String collectible = required(fields[9], source, lineNumber, "collectible");
|
||||
if (!"true".equals(collectible) && !"false".equals(collectible)) {
|
||||
throw new IllegalArgumentException(source + ":" + lineNumber
|
||||
+ " has invalid collectible value '" + collectible + "'");
|
||||
}
|
||||
return new Entry(number,
|
||||
required(fields[1], source, lineNumber, "worldId"),
|
||||
required(fields[2], source, lineNumber, "levelId"), role,
|
||||
required(fields[4], source, lineNumber, "title"),
|
||||
required(fields[5], source, lineNumber, "introducedMechanic"), mechanics,
|
||||
integer(fields[7], source, lineNumber, "targetDeaths"),
|
||||
integer(fields[8], source, lineNumber, "targetSeconds"),
|
||||
Boolean.parseBoolean(collectible));
|
||||
}
|
||||
|
||||
private static void validate(List<Entry> entries, String source) {
|
||||
int expectedSize = EXPECTED_WORLDS.size() * LEVELS_PER_WORLD;
|
||||
if (entries.size() != expectedSize) {
|
||||
throw new IllegalArgumentException(source + " expected " + expectedSize
|
||||
+ " production levels but found " + entries.size());
|
||||
}
|
||||
Set<String> ids = new HashSet<>();
|
||||
for (int index = 0; index < entries.size(); index++) {
|
||||
Entry entry = entries.get(index);
|
||||
int expectedNumber = LEGACY_LEVEL_COUNT + index + 1;
|
||||
if (entry.number != expectedNumber) {
|
||||
throw new IllegalArgumentException(source + " expected level number "
|
||||
+ expectedNumber + " but found " + entry.number);
|
||||
}
|
||||
String expectedWorld = EXPECTED_WORLDS.get(index / LEVELS_PER_WORLD);
|
||||
if (!expectedWorld.equals(entry.worldId)) {
|
||||
throw new IllegalArgumentException(source + " level " + entry.number
|
||||
+ " expected world '" + expectedWorld + "' but found '" + entry.worldId + "'");
|
||||
}
|
||||
if (!ids.add(entry.levelId)) {
|
||||
throw new IllegalArgumentException(source + " duplicates level id '"
|
||||
+ entry.levelId + "'");
|
||||
}
|
||||
if (!entry.levelId.startsWith(entry.worldId + ".")) {
|
||||
throw new IllegalArgumentException(source + " level " + entry.number
|
||||
+ " id must start with '" + entry.worldId + ".'");
|
||||
}
|
||||
int worldIndex = index % LEVELS_PER_WORLD;
|
||||
Role expectedRole = worldIndex == 11 ? Role.CHALLENGE
|
||||
: worldIndex == 12 ? Role.FINALE : Role.MAIN;
|
||||
if (entry.role != expectedRole) {
|
||||
throw new IllegalArgumentException(source + " level " + entry.number
|
||||
+ " expected role " + expectedRole + " but found " + entry.role);
|
||||
}
|
||||
boolean introduces = !"-".equals(entry.introducedMechanic);
|
||||
if (entry.role != Role.MAIN && introduces) {
|
||||
throw new IllegalArgumentException(source + " level " + entry.number
|
||||
+ " cannot introduce a mechanic in role " + entry.role);
|
||||
}
|
||||
if (introduces && !entry.mechanics.contains(entry.introducedMechanic)) {
|
||||
throw new IllegalArgumentException(source + " level " + entry.number
|
||||
+ " introduced mechanic '" + entry.introducedMechanic
|
||||
+ "' is absent from mechanics");
|
||||
}
|
||||
if (entry.targetDeaths < 0 || entry.targetSeconds <= 0) {
|
||||
throw new IllegalArgumentException(source + " level " + entry.number
|
||||
+ " has invalid challenge targets");
|
||||
}
|
||||
if (!entry.collectible) {
|
||||
throw new IllegalArgumentException(source + " level " + entry.number
|
||||
+ " must define a collectible");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static String required(String value, String source, int line, String field) {
|
||||
if (value == null || value.isEmpty()) {
|
||||
throw new IllegalArgumentException(source + ":" + line + " requires " + field);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private static int integer(String value, String source, int line, String field) {
|
||||
try {
|
||||
return Integer.parseInt(required(value, source, line, field));
|
||||
} catch (NumberFormatException error) {
|
||||
throw new IllegalArgumentException(source + ":" + line
|
||||
+ " has invalid integer for " + field + ": '" + value + "'");
|
||||
}
|
||||
}
|
||||
|
||||
private static void requireEqual(String source, int number, String field,
|
||||
String expected, String actual) {
|
||||
if (!expected.equals(actual)) throw mismatch(source, number, field, expected, actual);
|
||||
}
|
||||
|
||||
private static IllegalArgumentException mismatch(String source, int number, String field,
|
||||
String expected, String actual) {
|
||||
return new IllegalArgumentException(source + " level " + number + " " + field
|
||||
+ " expected '" + expected + "' but found '" + actual + "'");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
number worldId levelId role title introducedMechanic mechanics targetDeaths targetSeconds collectible
|
||||
18 foundations foundations.threefold-proof MAIN Threefold Proof challenge-stars movement,jump,challenge-stars 0 30 true
|
||||
19 foundations foundations.first-return MAIN First Return - movement,jump 0 28 true
|
||||
20 foundations foundations.covered-path MAIN Covered Path corpse-bridge movement,jump,death,corpse-bridge,spikes 2 42 true
|
||||
21 foundations foundations.held-open MAIN Held Open pressure movement,jump,death,pressure,door 1 45 true
|
||||
22 foundations foundations.clockwork-step MAIN Clockwork Step toggle movement,jump,toggle,moving-platform 0 48 true
|
||||
23 foundations foundations.remote-volunteer MAIN Remote Volunteer saw-launch movement,jump,death,saw-launch,pressure,door 1 50 true
|
||||
24 foundations foundations.frozen-step MAIN Frozen Step frozen-body movement,jump,death,frozen-body 1 48 true
|
||||
25 foundations foundations.living-wire MAIN Living Wire conductive-body movement,jump,death,conductive-body,door 1 52 true
|
||||
26 foundations foundations.borrowed-time MAIN Borrowed Time timed-switch movement,jump,timed-switch,moving-platform,pit 0 55 true
|
||||
27 foundations foundations.two-keys MAIN Two Keys signal-and movement,jump,death,pressure,conductive-body,signal-and,door 2 65 true
|
||||
28 foundations foundations.clean-restart MAIN Clean Restart - movement,jump,death,saw-launch,frozen-body,conductive-body 3 75 true
|
||||
29 foundations foundations.minimum-cost CHALLENGE Minimum Cost - movement,jump,death,corpse-bridge,pressure,timed-switch 2 65 true
|
||||
30 foundations foundations.foundation-final FINALE Foundation Relay - movement,jump,death,corpse-bridge,pressure,toggle,timed-switch,moving-platform,door 3 90 true
|
||||
31 physics physics.mass-basics MAIN Weight Class weighted-object movement,jump,weighted-object,pressure,door 0 45 true
|
||||
32 physics physics.stack-order MAIN Stack Order object-stacking movement,jump,weighted-object,object-stacking 0 50 true
|
||||
33 physics physics.launch-window MAIN Launch Window - movement,jump,saw-launch,moving-platform 1 55 true
|
||||
34 physics physics.crusher-rhythm MAIN Press Cycle crusher movement,jump,death,crusher,pressure 1 55 true
|
||||
35 physics physics.rolling-signal MAIN Rolling Signal moving-hazard movement,jump,weighted-object,moving-hazard,pressure 0 60 true
|
||||
36 physics physics.returning-bolt MAIN Returning Bolt projectile movement,jump,death,projectile,pressure 1 60 true
|
||||
37 physics physics.blast-delivery MAIN Blast Delivery explosion movement,jump,death,explosion,weighted-object 1 65 true
|
||||
38 physics physics.moving-load MAIN Moving Load - movement,jump,weighted-object,moving-platform,pit 0 65 true
|
||||
39 physics physics.counter-balance MAIN Counter Balance - movement,jump,weighted-object,object-stacking,signal-and 0 70 true
|
||||
40 physics physics.pit-ferry MAIN Pit Ferry - movement,jump,death,corpse-bridge,moving-platform,pit 2 75 true
|
||||
41 physics physics.mass-and-power MAIN Mass and Power - movement,jump,weighted-object,conductive-body,signal-and,door 1 80 true
|
||||
42 physics physics.no-spare-mass CHALLENGE No Spare Mass - movement,jump,weighted-object,object-stacking,crusher 0 75 true
|
||||
43 physics physics.physics-final FINALE Chain Reaction - movement,jump,death,weighted-object,projectile,explosion,crusher,moving-platform,door 3 100 true
|
||||
44 states states.pinned-body MAIN Pinned Lesson pinned-body movement,jump,death,pinned-body,pressure 1 50 true
|
||||
45 states states.crushed-path MAIN Low Profile crushed-body movement,jump,death,crushed-body,crusher 1 55 true
|
||||
46 states states.burned-bridge MAIN Ashen Bridge burned-body movement,jump,death,burned-body,fire 2 60 true
|
||||
47 states states.frozen-weight MAIN Cold Mass - movement,jump,death,frozen-body,weighted-object,pressure 1 60 true
|
||||
48 states states.conductive-clock MAIN Live Timer - movement,jump,death,conductive-body,timed-switch,door 1 65 true
|
||||
49 states states.transformed-bridge MAIN Changed Support - movement,jump,death,pinned-body,crushed-body,corpse-bridge 2 70 true
|
||||
50 states states.frozen-conductor MAIN Cold Circuit - movement,jump,death,frozen-body,conductive-body,signal-and 2 75 true
|
||||
51 states states.burned-switch MAIN False Weight - movement,jump,death,burned-body,pressure,door 2 75 true
|
||||
52 states states.crushed-projectile MAIN Low Crossing - movement,jump,death,crushed-body,projectile 1 70 true
|
||||
53 states states.timed-freeze MAIN Melting Window - movement,jump,death,frozen-body,timed-switch,moving-platform 1 75 true
|
||||
54 states states.state-sequence MAIN State Sequence - movement,jump,death,pinned-body,crushed-body,burned-body,door 3 90 true
|
||||
55 states states.state-limit CHALLENGE State Limit - movement,jump,death,frozen-body,conductive-body,burned-body 3 85 true
|
||||
56 states states.states-final FINALE Matter Final - movement,jump,death,frozen-body,conductive-body,pinned-body,crushed-body,burned-body,timed-switch,door 4 110 true
|
||||
57 mastery mastery.relay-chain MAIN Relay Chain - movement,jump,death,conductive-body,signal-and,timed-switch,door 2 80 true
|
||||
58 mastery mastery.moving-window MAIN Moving Window - movement,jump,death,moving-platform,timed-switch,crusher,pit 1 85 true
|
||||
59 mastery mastery.body-procession MAIN Body Procession - movement,jump,death,frozen-body,conductive-body,corpse-bridge 3 90 true
|
||||
60 mastery mastery.hazard-gauntlet MAIN Hazard Gauntlet - movement,jump,death,saw-launch,crusher,projectile,fire 3 95 true
|
||||
61 mastery mastery.weighted-states MAIN Weighted States - movement,jump,death,weighted-object,frozen-body,burned-body,signal-and 2 95 true
|
||||
62 mastery mastery.restart-proof MAIN Restart Proof - movement,jump,death,corpse-bridge,timed-switch,moving-platform,pit 2 90 true
|
||||
63 mastery mastery.vertical-procession MAIN Vertical Procession - movement,jump,death,pinned-body,moving-platform,pressure,door 2 100 true
|
||||
64 mastery mastery.blast-and-freeze MAIN Blast and Freeze - movement,jump,death,explosion,frozen-body,weighted-object 2 95 true
|
||||
65 mastery mastery.four-signals MAIN Four Signals - movement,jump,death,pressure,toggle,timed-switch,conductive-body,signal-and,door 3 110 true
|
||||
66 mastery mastery.recovery-master MAIN Recovery Master - movement,jump,death,timed-switch,moving-platform,pit,door 1 100 true
|
||||
67 mastery mastery.final-rehearsal MAIN Final Rehearsal - movement,jump,death,saw-launch,frozen-body,conductive-body,weighted-object,moving-platform 4 120 true
|
||||
68 mastery mastery.perfect-procession CHALLENGE Perfect Procession - movement,jump,death,frozen-body,conductive-body,weighted-object,timed-switch,signal-and 3 110 true
|
||||
69 mastery mastery.mastery-final FINALE Last Reliquary - movement,jump,death,corpse-bridge,frozen-body,conductive-body,pinned-body,crushed-body,burned-body,weighted-object,moving-platform,signal-and,door 5 150 true
|
||||
|
Reference in New Issue
Block a user