Выделить event-driven PuzzleSystem
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import android.app.Instrumentation;
|
||||
|
||||
import androidx.test.core.app.ActivityScenario;
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||
import androidx.test.platform.app.InstrumentationRegistry;
|
||||
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
|
||||
@RunWith(AndroidJUnit4.class)
|
||||
public final class PuzzleSystemIntegrationTest {
|
||||
@Test
|
||||
public void pressureBodyPublishesSignalAndDoorEventsThroughProductionInput() {
|
||||
Instrumentation instrumentation = InstrumentationRegistry.getInstrumentation();
|
||||
try (ActivityScenario<MainActivity> activity = ActivityScenario.launch(MainActivity.class)) {
|
||||
GameView[] view = new GameView[1];
|
||||
activity.onActivity(current -> view[0] = current.gameViewForAutomation());
|
||||
|
||||
LevelPlaytestBot.Result result = new LevelPlaytestBot().run(
|
||||
new AndroidDriver(instrumentation, view[0]),
|
||||
EarlyLevelScenarios.levelThree(), 25_000L);
|
||||
assertTrue(result.diagnostic(), result.completed());
|
||||
|
||||
PuzzleSystem.Diagnostics[] diagnostics = new PuzzleSystem.Diagnostics[1];
|
||||
instrumentation.runOnMainSync(
|
||||
() -> diagnostics[0] = view[0].puzzleDiagnosticsForAutomation());
|
||||
assertTrue(diagnostics[0].updates() > 0L);
|
||||
assertTrue(diagnostics[0].signalActivatedTransitions() >= 1L);
|
||||
assertTrue(diagnostics[0].doorOpenedTransitions() >= 1L);
|
||||
assertEquals(1, diagnostics[0].openDoors());
|
||||
}
|
||||
}
|
||||
|
||||
private static final class AndroidDriver implements LevelPlaytestBot.Driver {
|
||||
private final Instrumentation instrumentation;
|
||||
private final GameView view;
|
||||
|
||||
AndroidDriver(Instrumentation instrumentation, GameView view) {
|
||||
this.instrumentation = instrumentation;
|
||||
this.view = view;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void startLevel(int oneBasedLevelNumber) {
|
||||
instrumentation.runOnMainSync(
|
||||
() -> view.startLevelForAutomation(oneBasedLevelNumber));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setInput(boolean moveLeft, boolean moveRight, boolean jump) {
|
||||
instrumentation.runOnMainSync(
|
||||
() -> view.setAutomationInput(moveLeft, moveRight, jump));
|
||||
}
|
||||
|
||||
@Override
|
||||
public GameView.AutomationSnapshot snapshot() {
|
||||
GameView.AutomationSnapshot[] result = new GameView.AutomationSnapshot[1];
|
||||
instrumentation.runOnMainSync(() -> result[0] = view.automationSnapshot());
|
||||
return result[0];
|
||||
}
|
||||
|
||||
@Override
|
||||
public void waitForNextDecision(long millis) {
|
||||
try {
|
||||
Thread.sleep(millis);
|
||||
} catch (InterruptedException interrupted) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new AssertionError("Puzzle integration bot interrupted", interrupted);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
/** Explicit state holder for one data-bound puzzle door. */
|
||||
public final class DoorController {
|
||||
public enum State {
|
||||
CLOSED,
|
||||
OPEN
|
||||
}
|
||||
|
||||
private final String id;
|
||||
private final List<String> requiredSignals;
|
||||
private State state = State.CLOSED;
|
||||
|
||||
public DoorController(String id, List<String> requiredSignals) {
|
||||
this.id = requireId(id, "id");
|
||||
if (requiredSignals == null) {
|
||||
throw new IllegalArgumentException("requiredSignals must not be null");
|
||||
}
|
||||
ArrayList<String> validated = new ArrayList<>(requiredSignals.size());
|
||||
for (String signal : requiredSignals) {
|
||||
validated.add(requireId(signal, "required signal"));
|
||||
}
|
||||
this.requiredSignals = Collections.unmodifiableList(validated);
|
||||
}
|
||||
|
||||
public String id() { return id; }
|
||||
public List<String> requiredSignals() { return requiredSignals; }
|
||||
public State state() { return state; }
|
||||
public boolean open() { return state == State.OPEN; }
|
||||
|
||||
boolean update(PuzzleSystem system) {
|
||||
boolean shouldOpen = true;
|
||||
for (String signal : requiredSignals) {
|
||||
if (!system.signalActive(signal)) {
|
||||
shouldOpen = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
State next = shouldOpen ? State.OPEN : State.CLOSED;
|
||||
if (next == state) return false;
|
||||
state = next;
|
||||
return true;
|
||||
}
|
||||
|
||||
void reset() {
|
||||
state = State.CLOSED;
|
||||
}
|
||||
|
||||
private static String requireId(String value, String name) {
|
||||
if (value == null || value.trim().isEmpty()) {
|
||||
throw new IllegalArgumentException(name + " must not be blank");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import android.view.accessibility.AccessibilityEvent;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.IdentityHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
@@ -228,9 +229,11 @@ public final class GameView extends View implements Runnable {
|
||||
private final CorpseManager corpseManager = new CorpseManager(MAX_CORPSES);
|
||||
private final List<Body> corpses = corpseManager.bodies();
|
||||
private final ArrayList<PlatformState> platformStates = new ArrayList<>();
|
||||
private final Map<String, Boolean> toggleSignals = new HashMap<>();
|
||||
private final Map<String, Float> timedSignals = new HashMap<>();
|
||||
private final Map<String, Boolean> previousSwitchContact = new HashMap<>();
|
||||
private final Map<LevelDefinition.Door, DoorController> doorControllers =
|
||||
new IdentityHashMap<>();
|
||||
private final Map<String, LevelDefinition.Door> doorsById = new HashMap<>();
|
||||
private PuzzleSystem puzzleSystem = new PuzzleSystem();
|
||||
private final AccessibilityStateCache accessibilityStateCache =
|
||||
new AccessibilityStateCache();
|
||||
private final VolunteerCanvasRenderer volunteerRenderer = new VolunteerCanvasRenderer();
|
||||
@@ -266,7 +269,6 @@ public final class GameView extends View implements Runnable {
|
||||
private AccessibilitySettings accessibilitySettings;
|
||||
private AudioSettings audioSettings;
|
||||
private float lastFootstepAt = -10f;
|
||||
private boolean previousAllDoorsOpen;
|
||||
private boolean deterministicAutomationClock;
|
||||
private double deterministicAccumulatorSeconds;
|
||||
private float lastDrawDurationMs;
|
||||
@@ -385,7 +387,6 @@ public final class GameView extends View implements Runnable {
|
||||
cameraShakeY = 0f;
|
||||
cameraController.reset();
|
||||
lastFootstepAt = -10f;
|
||||
previousAllDoorsOpen = false;
|
||||
performanceMonitor.reset();
|
||||
performanceFrameCounter = 0;
|
||||
userPaused = false;
|
||||
@@ -393,9 +394,8 @@ public final class GameView extends View implements Runnable {
|
||||
deriveControls();
|
||||
corpseManager.reset();
|
||||
platformStates.clear();
|
||||
toggleSignals.clear();
|
||||
timedSignals.clear();
|
||||
previousSwitchContact.clear();
|
||||
configurePuzzleSystem();
|
||||
completionResult = null;
|
||||
configureAccessibilitySignals();
|
||||
invalidateHudTextCache();
|
||||
@@ -451,17 +451,6 @@ public final class GameView extends View implements Runnable {
|
||||
lastFootstepAt = levelElapsed;
|
||||
audioEngine.play(AudioCue.FOOTSTEP);
|
||||
}
|
||||
boolean allDoorsOpen = allDoorsOpenNow();
|
||||
if (allDoorsOpen && !previousAllDoorsOpen) {
|
||||
audioEngine.play(AudioCue.DOOR);
|
||||
if (!level.doors.isEmpty()) {
|
||||
LevelDefinition.Door door = level.doors.get(0);
|
||||
cameraController.requestFocus(door.box.x + door.box.width * 0.5f,
|
||||
door.box.y + door.box.height * 0.5f, 0.35f);
|
||||
}
|
||||
}
|
||||
previousAllDoorsOpen = allDoorsOpen;
|
||||
|
||||
refreshAccessibilityDescriptionIfStateChanged();
|
||||
|
||||
CameraController.Frame camera = cameraController.update(dt,
|
||||
@@ -473,30 +462,85 @@ public final class GameView extends View implements Runnable {
|
||||
cameraShakeY = camera.shakeY();
|
||||
}
|
||||
|
||||
private void updateSignals(float dt) {
|
||||
for (Map.Entry<String, Float> item : new ArrayList<>(timedSignals.entrySet())) {
|
||||
timedSignals.put(item.getKey(), Math.max(0f, item.getValue() - dt));
|
||||
private void configurePuzzleSystem() {
|
||||
doorControllers.clear();
|
||||
doorsById.clear();
|
||||
ArrayList<DoorController> controllers = new ArrayList<>(level.doors.size());
|
||||
for (int index = 0; index < level.doors.size(); index++) {
|
||||
LevelDefinition.Door door = level.doors.get(index);
|
||||
String id = "door-" + index;
|
||||
DoorController controller = new DoorController(id, door.requiredSignals);
|
||||
controllers.add(controller);
|
||||
doorControllers.put(door, controller);
|
||||
doorsById.put(id, door);
|
||||
}
|
||||
puzzleSystem = new PuzzleSystem(controllers);
|
||||
}
|
||||
|
||||
private void updateSignals(float dt) {
|
||||
updateSignals(dt, true);
|
||||
}
|
||||
|
||||
private void updateSignals(float dt, boolean feedback) {
|
||||
ArrayList<PuzzleSystem.Input> inputs = new ArrayList<>(
|
||||
level.switches.size() + level.hazards.size());
|
||||
for (LevelDefinition.Switch item : level.switches) {
|
||||
boolean contact = switchContact(item);
|
||||
String contactKey = item.signalId + "@" + item.box.x;
|
||||
boolean previous = Boolean.TRUE.equals(previousSwitchContact.get(contactKey));
|
||||
if (item.type == LevelDefinition.SwitchType.TOGGLE && contact && !previous) {
|
||||
toggleSignals.put(item.signalId, !Boolean.TRUE.equals(toggleSignals.get(item.signalId)));
|
||||
switch (item.type) {
|
||||
case PRESSURE:
|
||||
inputs.add(PuzzleSystem.Input.pressure(
|
||||
contactKey, item.signalId, contact));
|
||||
break;
|
||||
case TOGGLE:
|
||||
inputs.add(PuzzleSystem.Input.toggle(
|
||||
contactKey, item.signalId, contact));
|
||||
break;
|
||||
case TIMED:
|
||||
inputs.add(PuzzleSystem.Input.timed(
|
||||
contactKey, item.signalId, contact, item.durationSeconds));
|
||||
break;
|
||||
}
|
||||
if (feedback && contact && !previous
|
||||
&& item.type != LevelDefinition.SwitchType.PRESSURE) {
|
||||
emitHaptic(HapticFeedbackConstants.KEYBOARD_TAP);
|
||||
}
|
||||
if (item.type == LevelDefinition.SwitchType.TIMED && contact && !previous) {
|
||||
timedSignals.put(item.signalId, item.durationSeconds);
|
||||
emitHaptic(HapticFeedbackConstants.KEYBOARD_TAP);
|
||||
}
|
||||
if (contact && !previous) audioEngine.play(AudioCue.SWITCH);
|
||||
if (contact && !previous && player.alive
|
||||
if (feedback && contact && !previous) audioEngine.play(AudioCue.SWITCH);
|
||||
if (feedback && contact && !previous && player.alive
|
||||
&& intersects(player.x, player.y, PLAYER_WIDTH, PLAYER_HEIGHT, item.box)) {
|
||||
characterStateMachine.beginInteraction();
|
||||
}
|
||||
previousSwitchContact.put(contactKey, contact);
|
||||
}
|
||||
|
||||
for (int index = 0; index < level.hazards.size(); index++) {
|
||||
LevelDefinition.Hazard hazard = level.hazards.get(index);
|
||||
if (hazard.relayId == null || hazard.relayId.isEmpty()) continue;
|
||||
boolean active = false;
|
||||
for (Body corpse : corpses) {
|
||||
if (corpse.conductive
|
||||
&& intersects(corpse.x, corpse.y, corpse.width, corpse.height,
|
||||
hazard.box)) {
|
||||
active = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
inputs.add(PuzzleSystem.Input.relay(
|
||||
"relay-" + index, hazard.relayId, active));
|
||||
}
|
||||
|
||||
puzzleSystem.update(dt, inputs);
|
||||
if (!feedback) return;
|
||||
for (PuzzleSystem.Event event : puzzleSystem.events()) {
|
||||
if (event.kind() != PuzzleSystem.Event.Kind.DOOR_OPENED) continue;
|
||||
audioEngine.play(AudioCue.DOOR);
|
||||
LevelDefinition.Door door = doorsById.get(event.targetId());
|
||||
if (door != null) {
|
||||
cameraController.requestFocus(door.box.x + door.box.width * 0.5f,
|
||||
door.box.y + door.box.height * 0.5f, 0.35f);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private boolean allDoorsOpenNow() {
|
||||
@@ -515,28 +559,12 @@ public final class GameView extends View implements Runnable {
|
||||
}
|
||||
|
||||
private boolean signalActive(String id) {
|
||||
if (id == null || id.isEmpty()) return true;
|
||||
if (Boolean.TRUE.equals(toggleSignals.get(id))) return true;
|
||||
Float time = timedSignals.get(id);
|
||||
if (time != null && time > 0f) return true;
|
||||
for (LevelDefinition.Switch item : level.switches) {
|
||||
if (item.signalId.equals(id) && item.type == LevelDefinition.SwitchType.PRESSURE && switchContact(item)) return true;
|
||||
}
|
||||
for (LevelDefinition.Hazard hazard : level.hazards) {
|
||||
if (id.equals(hazard.relayId)) {
|
||||
for (Body corpse : corpses) {
|
||||
if (corpse.conductive && intersects(corpse.x, corpse.y, corpse.width, corpse.height, hazard.box)) return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
return puzzleSystem.signalActive(id);
|
||||
}
|
||||
|
||||
private boolean doorOpen(LevelDefinition.Door door) {
|
||||
for (String signal : door.requiredSignals) {
|
||||
if (!signalActive(signal)) return false;
|
||||
}
|
||||
return true;
|
||||
DoorController controller = doorControllers.get(door);
|
||||
return controller != null && controller.open();
|
||||
}
|
||||
|
||||
private void updatePlatforms(float dt) {
|
||||
@@ -1003,8 +1031,9 @@ public final class GameView extends View implements Runnable {
|
||||
stroke.setStrokeWidth(7f);
|
||||
canvas.drawLine(box.centerX(), box.centerY(), box.centerX() + (active ? 20 : -20), box.top - 28, stroke);
|
||||
if (item.type == LevelDefinition.SwitchType.TIMED && active) {
|
||||
Float remaining = timedSignals.get(item.signalId);
|
||||
drawWorldLabel(canvas, String.format(Locale.US, "%.1f", remaining == null ? 0f : remaining), box.centerX(), box.top - 52);
|
||||
float remaining = puzzleSystem.remainingSeconds(item.signalId);
|
||||
drawWorldLabel(canvas, String.format(Locale.US, "%.1f", remaining),
|
||||
box.centerX(), box.top - 52);
|
||||
}
|
||||
drawWorldLabel(canvas, active ? "ON" : "OFF", box.centerX(), box.bottom + 32);
|
||||
}
|
||||
@@ -2282,10 +2311,11 @@ public final class GameView extends View implements Runnable {
|
||||
ArrayList<Float> motionTimes = new ArrayList<>(platformStates.size());
|
||||
for (PlatformState state : platformStates) motionTimes.add(state.motionTime);
|
||||
builder.platformMotionTimes(motionTimes);
|
||||
for (Map.Entry<String, Boolean> entry : toggleSignals.entrySet()) {
|
||||
PuzzleSystem.State puzzleState = puzzleSystem.snapshot();
|
||||
for (Map.Entry<String, Boolean> entry : puzzleState.toggleSignals().entrySet()) {
|
||||
builder.toggleSignal(entry.getKey(), Boolean.TRUE.equals(entry.getValue()));
|
||||
}
|
||||
for (Map.Entry<String, Float> entry : timedSignals.entrySet()) {
|
||||
for (Map.Entry<String, Float> entry : puzzleState.timedSignals().entrySet()) {
|
||||
builder.timedSignal(entry.getKey(), Math.max(0f, entry.getValue()));
|
||||
}
|
||||
for (Map.Entry<String, Boolean> entry : previousSwitchContact.entrySet()) {
|
||||
@@ -2316,12 +2346,10 @@ public final class GameView extends View implements Runnable {
|
||||
levelElapsed = snapshot.elapsedSeconds();
|
||||
deaths = snapshot.deaths();
|
||||
respawnTimer = snapshot.respawnSeconds();
|
||||
toggleSignals.clear();
|
||||
toggleSignals.putAll(snapshot.toggleSignals());
|
||||
timedSignals.clear();
|
||||
timedSignals.putAll(snapshot.timedSignals());
|
||||
previousSwitchContact.clear();
|
||||
previousSwitchContact.putAll(snapshot.previousSwitchContacts());
|
||||
puzzleSystem.restore(snapshot.toggleSignals(), snapshot.timedSignals(),
|
||||
snapshot.previousSwitchContacts());
|
||||
for (int i = 0; i < platformStates.size(); i++) {
|
||||
PlatformState state = platformStates.get(i);
|
||||
state.motionTime = snapshot.platformMotionTimes().get(i);
|
||||
@@ -2362,6 +2390,7 @@ public final class GameView extends View implements Runnable {
|
||||
corpseManager.transform(corpse, CorpseManager.Transformation.PINNED);
|
||||
}
|
||||
}
|
||||
updateSignals(0f, false);
|
||||
|
||||
leftPressed = false;
|
||||
rightPressed = false;
|
||||
@@ -2370,7 +2399,6 @@ public final class GameView extends View implements Runnable {
|
||||
userPaused = true;
|
||||
paused = false;
|
||||
lastFootstepAt = levelElapsed;
|
||||
previousAllDoorsOpen = allDoorsOpenNow();
|
||||
cameraController.reset();
|
||||
cameraX = cameraY = cameraShakeX = cameraShakeY = 0f;
|
||||
lastFrameNanos = System.nanoTime();
|
||||
@@ -2448,4 +2476,8 @@ public final class GameView extends View implements Runnable {
|
||||
public CorpseManager.Diagnostics corpseDiagnosticsForAutomation() {
|
||||
return corpseManager.diagnostics();
|
||||
}
|
||||
|
||||
public PuzzleSystem.Diagnostics puzzleDiagnosticsForAutomation() {
|
||||
return puzzleSystem.diagnostics();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/** Deterministic signal-channel state updated from one frame of puzzle inputs. */
|
||||
public final class PuzzleSystem {
|
||||
public enum SourceKind {
|
||||
TOGGLE,
|
||||
PRESSURE,
|
||||
TIMED,
|
||||
RELAY
|
||||
}
|
||||
|
||||
public static final class Input {
|
||||
private final String sourceId;
|
||||
private final String channelId;
|
||||
private final SourceKind kind;
|
||||
private final boolean active;
|
||||
private final float durationSeconds;
|
||||
|
||||
private Input(String sourceId, String channelId, SourceKind kind, boolean active,
|
||||
float durationSeconds) {
|
||||
this.sourceId = requireId(sourceId, "sourceId");
|
||||
this.channelId = requireId(channelId, "channelId");
|
||||
this.kind = kind;
|
||||
this.active = active;
|
||||
this.durationSeconds = durationSeconds;
|
||||
}
|
||||
|
||||
public static Input toggle(String sourceId, String channelId, boolean active) {
|
||||
return new Input(sourceId, channelId, SourceKind.TOGGLE, active, 0f);
|
||||
}
|
||||
|
||||
public static Input pressure(String sourceId, String channelId, boolean active) {
|
||||
return new Input(sourceId, channelId, SourceKind.PRESSURE, active, 0f);
|
||||
}
|
||||
|
||||
public static Input timed(String sourceId, String channelId, boolean active,
|
||||
float durationSeconds) {
|
||||
if (durationSeconds <= 0f) {
|
||||
throw new IllegalArgumentException("durationSeconds must be positive");
|
||||
}
|
||||
return new Input(sourceId, channelId, SourceKind.TIMED, active, durationSeconds);
|
||||
}
|
||||
|
||||
public static Input relay(String sourceId, String channelId, boolean active) {
|
||||
return new Input(sourceId, channelId, SourceKind.RELAY, active, 0f);
|
||||
}
|
||||
}
|
||||
|
||||
public static final class Event {
|
||||
public enum Kind {
|
||||
SIGNAL_ACTIVATED,
|
||||
SIGNAL_DEACTIVATED,
|
||||
DOOR_OPENED,
|
||||
DOOR_CLOSED
|
||||
}
|
||||
|
||||
private final Kind kind;
|
||||
private final String targetId;
|
||||
|
||||
private Event(Kind kind, String targetId) {
|
||||
this.kind = kind;
|
||||
this.targetId = targetId;
|
||||
}
|
||||
|
||||
public Kind kind() { return kind; }
|
||||
public String channelId() { return targetId; }
|
||||
public String targetId() { return targetId; }
|
||||
}
|
||||
|
||||
public static final class State {
|
||||
private final Map<String, Boolean> toggleSignals;
|
||||
private final Map<String, Float> timedSignals;
|
||||
|
||||
private State(Map<String, Boolean> toggleSignals,
|
||||
Map<String, Float> timedSignals) {
|
||||
this.toggleSignals = Collections.unmodifiableMap(
|
||||
new HashMap<>(toggleSignals));
|
||||
this.timedSignals = Collections.unmodifiableMap(
|
||||
new HashMap<>(timedSignals));
|
||||
}
|
||||
|
||||
public Map<String, Boolean> toggleSignals() { return toggleSignals; }
|
||||
public Map<String, Float> timedSignals() { return timedSignals; }
|
||||
}
|
||||
|
||||
public static final class Diagnostics {
|
||||
private final long updates;
|
||||
private final long signalActivatedTransitions;
|
||||
private final long signalDeactivatedTransitions;
|
||||
private final long doorOpenedTransitions;
|
||||
private final long doorClosedTransitions;
|
||||
private final int activeSignals;
|
||||
private final int openDoors;
|
||||
|
||||
private Diagnostics(long updates, long signalActivatedTransitions,
|
||||
long signalDeactivatedTransitions, long doorOpenedTransitions,
|
||||
long doorClosedTransitions, int activeSignals, int openDoors) {
|
||||
this.updates = updates;
|
||||
this.signalActivatedTransitions = signalActivatedTransitions;
|
||||
this.signalDeactivatedTransitions = signalDeactivatedTransitions;
|
||||
this.doorOpenedTransitions = doorOpenedTransitions;
|
||||
this.doorClosedTransitions = doorClosedTransitions;
|
||||
this.activeSignals = activeSignals;
|
||||
this.openDoors = openDoors;
|
||||
}
|
||||
|
||||
public long updates() { return updates; }
|
||||
public long signalActivatedTransitions() { return signalActivatedTransitions; }
|
||||
public long signalDeactivatedTransitions() { return signalDeactivatedTransitions; }
|
||||
public long doorOpenedTransitions() { return doorOpenedTransitions; }
|
||||
public long doorClosedTransitions() { return doorClosedTransitions; }
|
||||
public int activeSignals() { return activeSignals; }
|
||||
public int openDoors() { return openDoors; }
|
||||
}
|
||||
|
||||
private final Map<String, Boolean> toggleChannels = new HashMap<>();
|
||||
private final Map<String, Float> timedChannels = new HashMap<>();
|
||||
private final Map<String, Boolean> previousSourceActivity = new HashMap<>();
|
||||
private final Map<String, SourceKind> sourceKinds = new HashMap<>();
|
||||
private final Map<String, String> sourceChannels = new HashMap<>();
|
||||
private final Set<String> knownChannels = new LinkedHashSet<>();
|
||||
private final List<DoorController> doors;
|
||||
private List<Event> events = Collections.emptyList();
|
||||
private long updates;
|
||||
private long signalActivatedTransitions;
|
||||
private long signalDeactivatedTransitions;
|
||||
private long doorOpenedTransitions;
|
||||
private long doorClosedTransitions;
|
||||
|
||||
public PuzzleSystem() {
|
||||
this(Collections.emptyList());
|
||||
}
|
||||
|
||||
public PuzzleSystem(List<DoorController> doors) {
|
||||
if (doors == null) throw new IllegalArgumentException("doors must not be null");
|
||||
ArrayList<DoorController> validated = new ArrayList<>(doors.size());
|
||||
LinkedHashSet<String> ids = new LinkedHashSet<>();
|
||||
for (DoorController door : doors) {
|
||||
if (door == null) throw new IllegalArgumentException("door must not be null");
|
||||
if (!ids.add(door.id())) throw new IllegalArgumentException("duplicate door " + door.id());
|
||||
validated.add(door);
|
||||
}
|
||||
this.doors = Collections.unmodifiableList(validated);
|
||||
}
|
||||
|
||||
public void update(float deltaSeconds, List<Input> inputs) {
|
||||
if (deltaSeconds < 0f) throw new IllegalArgumentException("deltaSeconds must not be negative");
|
||||
if (inputs == null) throw new IllegalArgumentException("inputs must not be null");
|
||||
updates++;
|
||||
|
||||
LinkedHashSet<String> affected = new LinkedHashSet<>(knownChannels);
|
||||
for (Input input : inputs) {
|
||||
if (input == null) throw new IllegalArgumentException("input must not be null");
|
||||
affected.add(input.channelId);
|
||||
}
|
||||
|
||||
Map<String, Boolean> before = new HashMap<>();
|
||||
for (String channelId : affected) before.put(channelId, signalActive(channelId));
|
||||
|
||||
for (Map.Entry<String, Float> timer : new ArrayList<>(timedChannels.entrySet())) {
|
||||
timedChannels.put(timer.getKey(),
|
||||
Math.max(0f, timer.getValue() - deltaSeconds));
|
||||
}
|
||||
|
||||
for (Input input : inputs) {
|
||||
knownChannels.add(input.channelId);
|
||||
boolean previous = Boolean.TRUE.equals(previousSourceActivity.get(input.sourceId));
|
||||
if (input.kind == SourceKind.TOGGLE && input.active && !previous) {
|
||||
toggleChannels.put(input.channelId,
|
||||
!Boolean.TRUE.equals(toggleChannels.get(input.channelId)));
|
||||
}
|
||||
if (input.kind == SourceKind.TIMED && input.active && !previous) {
|
||||
timedChannels.put(input.channelId, input.durationSeconds);
|
||||
}
|
||||
sourceKinds.put(input.sourceId, input.kind);
|
||||
sourceChannels.put(input.sourceId, input.channelId);
|
||||
previousSourceActivity.put(input.sourceId, input.active);
|
||||
}
|
||||
|
||||
ArrayList<Event> nextEvents = new ArrayList<>();
|
||||
for (String channelId : affected) {
|
||||
boolean wasActive = Boolean.TRUE.equals(before.get(channelId));
|
||||
boolean isActive = signalActive(channelId);
|
||||
if (wasActive != isActive) {
|
||||
nextEvents.add(new Event(isActive
|
||||
? Event.Kind.SIGNAL_ACTIVATED
|
||||
: Event.Kind.SIGNAL_DEACTIVATED, channelId));
|
||||
if (isActive) signalActivatedTransitions++;
|
||||
else signalDeactivatedTransitions++;
|
||||
}
|
||||
}
|
||||
for (DoorController door : doors) {
|
||||
if (door.update(this)) {
|
||||
nextEvents.add(new Event(door.open()
|
||||
? Event.Kind.DOOR_OPENED
|
||||
: Event.Kind.DOOR_CLOSED, door.id()));
|
||||
if (door.open()) doorOpenedTransitions++;
|
||||
else doorClosedTransitions++;
|
||||
}
|
||||
}
|
||||
events = Collections.unmodifiableList(nextEvents);
|
||||
}
|
||||
|
||||
public boolean signalActive(String channelId) {
|
||||
if (channelId == null || channelId.isEmpty()) return true;
|
||||
if (Boolean.TRUE.equals(toggleChannels.get(channelId))) return true;
|
||||
if (remainingSeconds(channelId) > 0f) return true;
|
||||
for (Map.Entry<String, String> source : sourceChannels.entrySet()) {
|
||||
if (channelId.equals(source.getValue())
|
||||
&& (sourceKinds.get(source.getKey()) == SourceKind.PRESSURE
|
||||
|| sourceKinds.get(source.getKey()) == SourceKind.RELAY)
|
||||
&& Boolean.TRUE.equals(previousSourceActivity.get(source.getKey()))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public float remainingSeconds(String channelId) {
|
||||
Float remaining = timedChannels.get(channelId);
|
||||
return remaining == null ? 0f : Math.max(0f, remaining);
|
||||
}
|
||||
|
||||
public List<Event> events() {
|
||||
return events;
|
||||
}
|
||||
|
||||
public Diagnostics diagnostics() {
|
||||
int activeSignals = 0;
|
||||
for (String channelId : knownChannels) {
|
||||
if (signalActive(channelId)) activeSignals++;
|
||||
}
|
||||
int openDoors = 0;
|
||||
for (DoorController door : doors) {
|
||||
if (door.open()) openDoors++;
|
||||
}
|
||||
return new Diagnostics(updates, signalActivatedTransitions,
|
||||
signalDeactivatedTransitions, doorOpenedTransitions,
|
||||
doorClosedTransitions, activeSignals, openDoors);
|
||||
}
|
||||
|
||||
public State snapshot() {
|
||||
return new State(toggleChannels, timedChannels);
|
||||
}
|
||||
|
||||
public void restore(State state) {
|
||||
if (state == null) throw new IllegalArgumentException("state must not be null");
|
||||
restore(state.toggleSignals(), state.timedSignals());
|
||||
}
|
||||
|
||||
public void restore(State state, Map<String, Boolean> sourceActivity) {
|
||||
if (state == null) throw new IllegalArgumentException("state must not be null");
|
||||
restore(state.toggleSignals(), state.timedSignals(), sourceActivity);
|
||||
}
|
||||
|
||||
public void restore(Map<String, Boolean> toggles, Map<String, Float> timers) {
|
||||
restore(toggles, timers, Collections.emptyMap());
|
||||
}
|
||||
|
||||
public void restore(Map<String, Boolean> toggles, Map<String, Float> timers,
|
||||
Map<String, Boolean> sourceActivity) {
|
||||
if (toggles == null || timers == null) {
|
||||
throw new IllegalArgumentException("puzzle state maps must not be null");
|
||||
}
|
||||
if (sourceActivity == null) {
|
||||
throw new IllegalArgumentException("sourceActivity must not be null");
|
||||
}
|
||||
reset();
|
||||
toggleChannels.putAll(toggles);
|
||||
timedChannels.putAll(timers);
|
||||
previousSourceActivity.putAll(sourceActivity);
|
||||
knownChannels.addAll(toggles.keySet());
|
||||
knownChannels.addAll(timers.keySet());
|
||||
for (DoorController door : doors) door.update(this);
|
||||
events = Collections.emptyList();
|
||||
}
|
||||
|
||||
public void reset() {
|
||||
toggleChannels.clear();
|
||||
timedChannels.clear();
|
||||
previousSourceActivity.clear();
|
||||
sourceKinds.clear();
|
||||
sourceChannels.clear();
|
||||
knownChannels.clear();
|
||||
for (DoorController door : doors) door.reset();
|
||||
events = Collections.emptyList();
|
||||
updates = 0L;
|
||||
signalActivatedTransitions = 0L;
|
||||
signalDeactivatedTransitions = 0L;
|
||||
doorOpenedTransitions = 0L;
|
||||
doorClosedTransitions = 0L;
|
||||
}
|
||||
|
||||
private static String requireId(String value, String name) {
|
||||
if (value == null || value.trim().isEmpty()) {
|
||||
throw new IllegalArgumentException(name + " must not be blank");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.Arrays;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
public final class PuzzleSystemTest {
|
||||
@Test
|
||||
public void toggleChannelChangesOnlyOnRisingEdgesAndPublishesEvents() {
|
||||
PuzzleSystem system = new PuzzleSystem();
|
||||
PuzzleSystem.Input held = PuzzleSystem.Input.toggle("lever-1", "gate", true);
|
||||
|
||||
system.update(0.016f, Collections.singletonList(held));
|
||||
assertTrue(system.signalActive("gate"));
|
||||
assertEquals(PuzzleSystem.Event.Kind.SIGNAL_ACTIVATED,
|
||||
system.events().get(0).kind());
|
||||
assertEquals("gate", system.events().get(0).channelId());
|
||||
|
||||
system.update(0.016f, Collections.singletonList(held));
|
||||
assertTrue(system.signalActive("gate"));
|
||||
assertTrue(system.events().isEmpty());
|
||||
|
||||
system.update(0.016f, Collections.singletonList(
|
||||
PuzzleSystem.Input.toggle("lever-1", "gate", false)));
|
||||
system.update(0.016f, Collections.singletonList(held));
|
||||
assertFalse(system.signalActive("gate"));
|
||||
assertEquals(PuzzleSystem.Event.Kind.SIGNAL_DEACTIVATED,
|
||||
system.events().get(0).kind());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void pressureChannelAggregatesEveryActiveSourceWithoutFlicker() {
|
||||
PuzzleSystem system = new PuzzleSystem();
|
||||
|
||||
system.update(0.016f, Arrays.asList(
|
||||
PuzzleSystem.Input.pressure("plate-left", "weight", true),
|
||||
PuzzleSystem.Input.pressure("plate-right", "weight", false)));
|
||||
assertTrue(system.signalActive("weight"));
|
||||
assertEquals(1, system.events().size());
|
||||
|
||||
system.update(0.016f, Arrays.asList(
|
||||
PuzzleSystem.Input.pressure("plate-left", "weight", false),
|
||||
PuzzleSystem.Input.pressure("plate-right", "weight", true)));
|
||||
assertTrue(system.signalActive("weight"));
|
||||
assertTrue(system.events().isEmpty());
|
||||
|
||||
system.update(0.016f, Arrays.asList(
|
||||
PuzzleSystem.Input.pressure("plate-left", "weight", false),
|
||||
PuzzleSystem.Input.pressure("plate-right", "weight", false)));
|
||||
assertFalse(system.signalActive("weight"));
|
||||
assertEquals(PuzzleSystem.Event.Kind.SIGNAL_DEACTIVATED,
|
||||
system.events().get(0).kind());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void timedChannelStartsOnRisingEdgeExpiresAndCanBeRearmed() {
|
||||
PuzzleSystem system = new PuzzleSystem();
|
||||
|
||||
system.update(0f, Collections.singletonList(
|
||||
PuzzleSystem.Input.timed("clock-switch", "clock", true, 0.5f)));
|
||||
assertTrue(system.signalActive("clock"));
|
||||
assertEquals(0.5f, system.remainingSeconds("clock"), 0f);
|
||||
|
||||
system.update(0.2f, Collections.singletonList(
|
||||
PuzzleSystem.Input.timed("clock-switch", "clock", true, 0.5f)));
|
||||
assertEquals(0.3f, system.remainingSeconds("clock"), 0.0001f);
|
||||
assertTrue(system.events().isEmpty());
|
||||
|
||||
system.update(0.31f, Collections.singletonList(
|
||||
PuzzleSystem.Input.timed("clock-switch", "clock", false, 0.5f)));
|
||||
assertFalse(system.signalActive("clock"));
|
||||
assertEquals(PuzzleSystem.Event.Kind.SIGNAL_DEACTIVATED,
|
||||
system.events().get(0).kind());
|
||||
|
||||
system.update(0f, Collections.singletonList(
|
||||
PuzzleSystem.Input.timed("clock-switch", "clock", true, 0.5f)));
|
||||
assertTrue(system.signalActive("clock"));
|
||||
assertEquals(0.5f, system.remainingSeconds("clock"), 0f);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void andDoorPublishesExplicitOpenAndCloseTransitions() {
|
||||
DoorController exit = new DoorController("exit",
|
||||
Arrays.asList("weight", "power"));
|
||||
PuzzleSystem system = new PuzzleSystem(Collections.singletonList(exit));
|
||||
|
||||
system.update(0.016f, Arrays.asList(
|
||||
PuzzleSystem.Input.pressure("plate", "weight", true),
|
||||
PuzzleSystem.Input.relay("conductor", "power", false)));
|
||||
assertEquals(DoorController.State.CLOSED, exit.state());
|
||||
|
||||
system.update(0.016f, Arrays.asList(
|
||||
PuzzleSystem.Input.pressure("plate", "weight", true),
|
||||
PuzzleSystem.Input.relay("conductor", "power", true)));
|
||||
assertEquals(DoorController.State.OPEN, exit.state());
|
||||
assertTrue(hasEvent(system, PuzzleSystem.Event.Kind.DOOR_OPENED, "exit"));
|
||||
|
||||
system.update(0.016f, Arrays.asList(
|
||||
PuzzleSystem.Input.pressure("plate", "weight", false),
|
||||
PuzzleSystem.Input.relay("conductor", "power", true)));
|
||||
assertEquals(DoorController.State.CLOSED, exit.state());
|
||||
assertTrue(hasEvent(system, PuzzleSystem.Event.Kind.DOOR_CLOSED, "exit"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void resetAndRestoreOwnAnImmutablePuzzleStateSnapshot() {
|
||||
DoorController exit = new DoorController("exit", Arrays.asList("gate", "clock"));
|
||||
PuzzleSystem source = new PuzzleSystem(Collections.singletonList(exit));
|
||||
source.update(0f, Arrays.asList(
|
||||
PuzzleSystem.Input.toggle("lever", "gate", true),
|
||||
PuzzleSystem.Input.timed("timer", "clock", true, 2f)));
|
||||
PuzzleSystem.State saved = source.snapshot();
|
||||
|
||||
source.reset();
|
||||
assertFalse(source.signalActive("gate"));
|
||||
assertFalse(source.signalActive("clock"));
|
||||
assertEquals(DoorController.State.CLOSED, exit.state());
|
||||
|
||||
DoorController restoredDoor = new DoorController("exit",
|
||||
Arrays.asList("gate", "clock"));
|
||||
PuzzleSystem restored = new PuzzleSystem(Collections.singletonList(restoredDoor));
|
||||
restored.restore(saved, Collections.singletonMap("lever", true));
|
||||
|
||||
assertTrue(restored.signalActive("gate"));
|
||||
assertEquals(2f, restored.remainingSeconds("clock"), 0f);
|
||||
assertEquals(DoorController.State.OPEN, restoredDoor.state());
|
||||
restored.update(0f, Collections.singletonList(
|
||||
PuzzleSystem.Input.toggle("lever", "gate", true)));
|
||||
assertTrue("Held restored contact must not toggle again",
|
||||
restored.signalActive("gate"));
|
||||
assertEquals(Boolean.TRUE, saved.toggleSignals().get("gate"));
|
||||
assertEquals(2f, saved.timedSignals().get("clock"), 0f);
|
||||
try {
|
||||
saved.toggleSignals().put("other", true);
|
||||
throw new AssertionError("Puzzle state maps must be immutable");
|
||||
} catch (UnsupportedOperationException expected) {
|
||||
// Contract proven.
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean hasEvent(PuzzleSystem system, PuzzleSystem.Event.Kind kind,
|
||||
String targetId) {
|
||||
for (PuzzleSystem.Event event : system.events()) {
|
||||
if (event.kind() == kind && targetId.equals(event.targetId())) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user