Release: Quarkus backend, Keycloak login, game history, clock, bot, tutorial and rematch #3

Merged
jhalitaksoy merged 88 commits from develop into main 2026-10-17 03:23:38 +03:00
25 changed files with 700 additions and 1 deletions
Showing only changes of commit f0246cca61 - Show all commits

View File

@ -0,0 +1,7 @@
package app.playmancala.auth;
public class InvalidTokenException extends Exception {
public InvalidTokenException(String message) {
super(message);
}
}

View File

@ -0,0 +1,87 @@
package app.playmancala.auth;
import java.util.List;
import java.util.Map;
import org.jose4j.http.Get;
import org.jose4j.jwa.AlgorithmConstraints;
import org.jose4j.jwk.HttpsJwks;
import org.jose4j.jws.AlgorithmIdentifiers;
import org.jose4j.jwt.JwtClaims;
import org.jose4j.jwt.consumer.InvalidJwtException;
import org.jose4j.jwt.consumer.JwtConsumer;
import org.jose4j.jwt.consumer.JwtConsumerBuilder;
import org.jose4j.keys.resolvers.HttpsJwksVerificationKeyResolver;
import org.jose4j.keys.resolvers.VerificationKeyResolver;
import app.playmancala.model.AuthUser;
// Verifies Keycloak access tokens offline against the realm's published signing keys. Player ids are the token
// subject prefixed with "kc:", so they can never collide with ids that legacy userKey clients choose for themselves.
public class KeycloakTokenVerifier implements TokenVerifier {
public static final String KEYCLOAK_USER_ID_PREFIX = "kc:";
public static final String GUEST_ROLE = "guest";
private static final int JWKS_TIMEOUT_MS = 5_000;
// Keys are cached this long, and kept this long while Keycloak cannot be reached.
private static final long JWKS_CACHE_SECONDS = 600;
// A token with an unknown key id refetches the keys at most this often.
private static final long JWKS_REFRESH_COOLDOWN_MS = 30_000;
private final List<String> allowedClientIds;
private final JwtConsumer consumer;
// `jwksUrl` defaults to the issuer's certs endpoint.
public static KeycloakTokenVerifier create(String issuer, List<String> allowedClientIds, String jwksUrl) {
HttpsJwks jwks = new HttpsJwks(jwksUrl != null ? jwksUrl : issuer + "/protocol/openid-connect/certs");
Get get = new Get();
get.setConnectTimeout(JWKS_TIMEOUT_MS);
get.setReadTimeout(JWKS_TIMEOUT_MS);
jwks.setSimpleHttpGet(get);
jwks.setDefaultCacheDuration(JWKS_CACHE_SECONDS);
jwks.setRetainCacheOnErrorDuration(JWKS_CACHE_SECONDS * 1000);
jwks.setRefreshReprieveThreshold(JWKS_REFRESH_COOLDOWN_MS);
return new KeycloakTokenVerifier(issuer, allowedClientIds, new HttpsJwksVerificationKeyResolver(jwks));
}
public KeycloakTokenVerifier(String issuer, List<String> allowedClientIds, VerificationKeyResolver keys) {
this.allowedClientIds = List.copyOf(allowedClientIds);
this.consumer = new JwtConsumerBuilder()
.setExpectedIssuer(issuer)
.setRequireSubject()
.setRequireExpirationTime()
// Keycloak access tokens are checked by azp below, not by audience.
.setSkipDefaultAudienceValidation()
.setJwsAlgorithmConstraints(AlgorithmConstraints.ConstraintType.PERMIT, AlgorithmIdentifiers.RSA_USING_SHA256)
.setVerificationKeyResolver(keys)
.build();
}
@Override
public AuthUser verify(String token) throws InvalidTokenException {
JwtClaims claims;
try {
claims = consumer.processToClaims(token);
} catch (InvalidJwtException e) {
throw new InvalidTokenException(e.getMessage());
}
// Keycloak ID tokens and refresh tokens are signed by the same keys, only accept access tokens.
if (!"Bearer".equals(claims.getClaimValue("typ"))) {
throw new InvalidTokenException("not an access token");
}
Object azp = claims.getClaimValue("azp");
if (!(azp instanceof String clientId) || !allowedClientIds.contains(clientId)) {
throw new InvalidTokenException("token issued to an unknown client");
}
Object username = claims.getClaimValue("preferred_username");
return new AuthUser(KEYCLOAK_USER_ID_PREFIX + claims.getClaimValue("sub"),
username instanceof String name ? name : null, hasGuestRole(claims.getClaimValue("realm_access")));
}
private static boolean hasGuestRole(Object realmAccess) {
return realmAccess instanceof Map<?, ?> access && access.get("roles") instanceof List<?> roles
&& roles.contains(GUEST_ROLE);
}
}

View File

@ -0,0 +1,8 @@
package app.playmancala.auth;
import app.playmancala.model.AuthUser;
public interface TokenVerifier {
// The token's user. May block (fetching signing keys); call it off the game loop.
AuthUser verify(String token) throws InvalidTokenException;
}

View File

@ -0,0 +1,6 @@
package app.playmancala.loop;
@FunctionalInterface
public interface Cancellable {
void cancel();
}

View File

@ -0,0 +1,88 @@
package app.playmancala.loop;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import org.jboss.logging.Logger;
// The one thread that owns all live game state: games, matchmaking, previews and the WebSocket registry. Like the
// Node backend's event loop, it lets that state go without locks. Never block it on I/O.
public final class GameLoop implements Scheduler, AutoCloseable {
private static final Logger LOG = Logger.getLogger(GameLoop.class);
private final ScheduledExecutorService executor;
private volatile Thread thread;
public GameLoop() {
executor = Executors.newSingleThreadScheduledExecutor(runnable -> {
Thread loopThread = new Thread(runnable, "game-loop");
loopThread.setDaemon(true);
thread = loopThread;
return loopThread;
});
}
@Override
public long now() {
return System.currentTimeMillis();
}
@Override
public void execute(Runnable task) {
executor.execute(guarded(task));
}
@Override
public Cancellable schedule(Runnable task, long delayMs) {
Future<?> future = executor.schedule(guarded(task), delayMs, TimeUnit.MILLISECONDS);
return () -> future.cancel(false);
}
@Override
public Cancellable scheduleAtFixedRate(Runnable task, long periodMs) {
Future<?> future = executor.scheduleAtFixedRate(guarded(task), periodMs, periodMs, TimeUnit.MILLISECONDS);
return () -> future.cancel(false);
}
// Runs the task on the loop and waits for its result; for threads that need to read live state.
public <T> T call(Callable<T> task) {
if (Thread.currentThread() == thread) {
try {
return task.call();
} catch (RuntimeException e) {
throw e;
} catch (Exception e) {
throw new IllegalStateException(e);
}
}
try {
return executor.submit(task).get();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IllegalStateException("Interrupted while waiting for the game loop", e);
} catch (ExecutionException e) {
if (e.getCause() instanceof RuntimeException runtime) throw runtime;
throw new IllegalStateException(e.getCause());
}
}
@Override
public void close() {
executor.shutdownNow();
}
// An exception must not stop a repeating task or reach the executor, which would swallow it.
private static Runnable guarded(Runnable task) {
return () -> {
try {
task.run();
} catch (Throwable e) {
LOG.error("(GameLoop) Task failed", e);
}
};
}
}

View File

@ -0,0 +1,15 @@
package app.playmancala.loop;
// What the game logic needs from the game loop: running tasks and timers on the thread that owns the game state, and
// the time. Tests use ManualScheduler, whose clock only moves when the test advances it.
public interface Scheduler {
// Epoch milliseconds.
long now();
void execute(Runnable task);
Cancellable schedule(Runnable task, long delayMs);
// First run after one period.
Cancellable scheduleAtFixedRate(Runnable task, long periodMs);
}

View File

@ -0,0 +1,11 @@
package app.playmancala.model;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
// A logged in user. `id` is also the player id in games; `name` is null for legacy userKey clients.
public record AuthUser(
String id,
@JsonInclude(JsonInclude.Include.NON_NULL) String name,
@JsonProperty("isGuest") boolean isGuest) {
}

View File

@ -0,0 +1,36 @@
package app.playmancala.model;
// WebSocket channel names; must match packages/core/src/rtmt/channel_names.ts.
public final class Channels {
public static final String NEW_GAME = "new_game";
public static final String CANCEL_NEW_GAME = "cancel_new_game";
public static final String ON_GAME_START = "on_game_start";
public static final String GAME_MOVE = "game_move";
public static final String ON_GAME_UPDATE = "on_game_update";
public static final String LEAVE_GAME = "leave_game";
public static final String ON_GAME_END = "on_game_end";
public static final String ON_GAME_CRASHED = "on_game_crashed";
public static final String ON_GAME_USER_LEAVE = "on_game_user_leave";
public static final String PING = "ping";
public static final String PONG = "pong";
public static final String ON_USER_CONNECTION_CHANGE = "channel_on_user_connection_change";
public static final String LISTEN_GAME_EVENTS = "channel_listen_game_events";
public static final String UNLISTEN_GAME_EVENTS = "channel_unlisten_game_events";
// Auth handshake. client -> server: { token } right after connecting and again after a token refresh;
// other messages are ignored until the server answers on_auth with { user }.
public static final String AUTH = "auth";
public static final String ON_AUTH = "on_auth";
// server -> client: { reason }, sent right before the server closes the socket with CLOSE_AUTH_FAILED.
public static final String ON_AUTH_ERROR = "on_auth_error";
public static final int CLOSE_AUTH_FAILED = 4001;
// Home page previews. client -> server: subscribe_live_games / unsubscribe_live_games;
// server -> client: on_live_games with { games: LiveGameSummary[] }.
public static final String SUBSCRIBE_LIVE_GAMES = "subscribe_live_games";
public static final String UNSUBSCRIBE_LIVE_GAMES = "unsubscribe_live_games";
public static final String ON_LIVE_GAMES = "on_live_games";
// server -> client: { gameId }, when new_game is sent while the user has a game in progress.
public static final String ON_NEW_GAME_REJECTED = "on_new_game_rejected";
private Channels() {
}
}

View File

@ -0,0 +1,18 @@
package app.playmancala.model;
import java.util.List;
import app.playmancala.engine.MancalaGame;
// A live game as players and spectators receive it; leaves out spectator ids and other server-side state.
// It refers to the live MancalaGame, so serialise it on the game loop.
public record ClientGame(
String id,
MancalaGame mancalaGame,
GameUsersConnectionInfo gameUsersConnectionInfo,
long startedAt,
List<Long> moveTimes,
int spectatorCount,
GameClock clock,
long serverTime) {
}

View File

@ -0,0 +1,20 @@
package app.playmancala.model;
import com.fasterxml.jackson.annotation.JsonProperty;
public enum EndReason {
@JsonProperty("completed") COMPLETED,
@JsonProperty("left") LEFT,
@JsonProperty("timeout") TIMEOUT,
// Only in games saved before the game clock.
@JsonProperty("inactive") INACTIVE;
// The value stored in the games.end_reason column.
public String wireName() {
return name().toLowerCase();
}
public static EndReason fromWireName(String value) {
return valueOf(value.toUpperCase());
}
}

View File

@ -0,0 +1,24 @@
package app.playmancala.model;
import java.util.List;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonPropertyOrder;
// A finished game in lists. `board` is the final board; `result` is null (and sent as null) without a winner.
@JsonPropertyOrder({ "id", "player1", "player2", "board", "result", "endReason", "leftUserId", "startedAt", "endedAt",
"moveCount", "peakSpectators", "clockInitialMs" })
public record FinishedGameSummary(
String id,
PlayerSummary player1,
PlayerSummary player2,
List<Integer> board,
GameResult result,
EndReason endReason,
@JsonInclude(JsonInclude.Include.NON_NULL) String leftUserId,
long startedAt,
long endedAt,
int moveCount,
int peakSpectators,
@JsonInclude(JsonInclude.Include.NON_NULL) Long clockInitialMs) {
}

View File

@ -0,0 +1,12 @@
package app.playmancala.model;
import com.fasterxml.jackson.annotation.JsonInclude;
// The players' clocks (docs/specs/game-clock): each player's time left at the start of the current turn, and when
// that turn started. `turnStartedAt` is null when no clock runs, i.e. the game has ended.
public record GameClock(
long initialMs,
long player1Ms,
long player2Ms,
@JsonInclude(JsonInclude.Include.NON_NULL) Long turnStartedAt) {
}

View File

@ -0,0 +1,16 @@
package app.playmancala.model;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonPropertyOrder;
// on_game_end, sent to the players and spectators of a live game when it ends for any reason. `result` is null (and
// sent as null) when the game ended without a winner.
@JsonPropertyOrder({ "gameId", "result", "endReason", "clock", "winnerId", "leftUserId" })
public record GameEndMessage(
String gameId,
GameResult result,
EndReason endReason,
GameClock clock,
@JsonInclude(JsonInclude.Include.NON_NULL) String winnerId,
@JsonInclude(JsonInclude.Include.NON_NULL) String leftUserId) {
}

View File

@ -0,0 +1,36 @@
package app.playmancala.model;
import java.util.List;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonPropertyOrder;
// A saved, finished game: a FinishedGameSummary plus its moves and the number of distinct spectators. Every id in it
// is a public id.
@JsonPropertyOrder({ "id", "player1", "player2", "board", "result", "endReason", "leftUserId", "startedAt", "endedAt",
"moveCount", "peakSpectators", "clockInitialMs", "moves", "totalSpectators" })
public record GameRecord(
String id,
PlayerSummary player1,
PlayerSummary player2,
List<Integer> board,
GameResult result,
EndReason endReason,
@JsonInclude(JsonInclude.Include.NON_NULL) String leftUserId,
long startedAt,
long endedAt,
int moveCount,
int peakSpectators,
@JsonInclude(JsonInclude.Include.NON_NULL) Long clockInitialMs,
List<MoveRecord> moves,
int totalSpectators) {
public FinishedGameSummary toSummary() {
return new FinishedGameSummary(id, player1, player2, board, result, endReason, leftUserId, startedAt, endedAt,
moveCount, peakSpectators, clockInitialMs);
}
public boolean hasPlayer(String userId) {
return player1.userId().equals(userId) || player2.userId().equals(userId);
}
}

View File

@ -0,0 +1,18 @@
package app.playmancala.model;
import com.fasterxml.jackson.annotation.JsonProperty;
public enum GameResult {
@JsonProperty("player1") PLAYER1,
@JsonProperty("player2") PLAYER2,
@JsonProperty("draw") DRAW;
// The value stored in the games.result column.
public String wireName() {
return name().toLowerCase();
}
public static GameResult fromWireName(String value) {
return value == null ? null : valueOf(value.toUpperCase());
}
}

View File

@ -0,0 +1,4 @@
package app.playmancala.model;
public record GameUsersConnectionInfo(UserConnectionInfo user1ConnectionInfo, UserConnectionInfo user2ConnectionInfo) {
}

View File

@ -0,0 +1,15 @@
package app.playmancala.model;
import java.util.List;
// A live game for previews. Ids are public ids.
public record LiveGameSummary(
String id,
PlayerSummary player1,
PlayerSummary player2,
List<Integer> board,
String turnPlayerId,
int spectatorCount,
int moveCount,
long startedAt) {
}

View File

@ -0,0 +1,17 @@
package app.playmancala.model;
import java.util.List;
import com.fasterxml.jackson.annotation.JsonInclude;
import app.playmancala.engine.GameStep;
// A move of a saved game. `remainingMs` is the mover's clock after the move; null for games before the game clock.
public record MoveRecord(
String playerId,
int moveIndex,
List<Integer> boardSnapshot,
List<GameStep> gameSteps,
long at,
@JsonInclude(JsonInclude.Include.NON_NULL) Long remainingMs) {
}

View File

@ -0,0 +1,15 @@
package app.playmancala.model;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
// A player as captured when a game is created. In saved games and lists, `userId` is a public id (PublicIds).
public record PlayerSummary(
String userId,
@JsonInclude(JsonInclude.Include.NON_NULL) String name,
@JsonProperty("isGuest") boolean isGuest) {
public static PlayerSummary unknown(String userId) {
return new PlayerSummary(userId, null, true);
}
}

View File

@ -0,0 +1,8 @@
package app.playmancala.model;
import com.fasterxml.jackson.annotation.JsonProperty;
public enum Seat {
@JsonProperty("player1") PLAYER1,
@JsonProperty("player2") PLAYER2
}

View File

@ -0,0 +1,15 @@
package app.playmancala.model;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
public record UserConnectionInfo(
String userId,
@JsonInclude(JsonInclude.Include.NON_NULL) String name,
@JsonProperty("isGuest") boolean isGuest,
@JsonProperty("isOnline") boolean isOnline) {
public static UserConnectionInfo of(PlayerSummary player, boolean isOnline) {
return new UserConnectionInfo(player.userId(), player.name(), player.isGuest(), isOnline);
}
}

View File

@ -0,0 +1,112 @@
package app.playmancala.auth;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.util.Base64;
import java.util.List;
import java.util.Map;
import java.util.function.Consumer;
import java.util.stream.Stream;
import org.jose4j.jwk.JsonWebKey;
import org.jose4j.jwk.RsaJsonWebKey;
import org.jose4j.jwk.RsaJwkGenerator;
import org.jose4j.jws.AlgorithmIdentifiers;
import org.jose4j.jws.JsonWebSignature;
import org.jose4j.jwt.JwtClaims;
import org.jose4j.keys.resolvers.JwksVerificationKeyResolver;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import app.playmancala.model.AuthUser;
class KeycloakTokenVerifierTest {
private static final String ISSUER = "http://keycloak.test/realms/mancala";
private static RsaJsonWebKey key;
private static RsaJsonWebKey otherKey;
private static KeycloakTokenVerifier verifier;
@BeforeAll
static void createKeys() throws Exception {
key = RsaJwkGenerator.generateJwk(2048);
key.setKeyId("test-key");
otherKey = RsaJwkGenerator.generateJwk(2048);
otherKey.setKeyId("test-key");
JsonWebKey publicKey = JsonWebKey.Factory.newJwk(key.toParams(JsonWebKey.OutputControlLevel.PUBLIC_ONLY));
verifier = new KeycloakTokenVerifier(ISSUER, List.of("mancala-web"),
new JwksVerificationKeyResolver(List.of(publicKey)));
}
private static String sign(Consumer<JwtClaims> customize) {
return sign(customize, key);
}
private static String sign(Consumer<JwtClaims> customize, RsaJsonWebKey signingKey) {
try {
JwtClaims claims = new JwtClaims();
claims.setIssuer(ISSUER);
claims.setSubject("user-1");
claims.setIssuedAtToNow();
claims.setExpirationTimeMinutesInTheFuture(5);
claims.setClaim("typ", "Bearer");
claims.setClaim("azp", "mancala-web");
claims.setClaim("preferred_username", "alice");
customize.accept(claims);
JsonWebSignature jws = new JsonWebSignature();
jws.setPayload(claims.toJson());
jws.setKey(signingKey.getPrivateKey());
jws.setKeyIdHeaderValue(signingKey.getKeyId());
jws.setAlgorithmHeaderValue(AlgorithmIdentifiers.RSA_USING_SHA256);
return jws.getCompactSerialization();
} catch (Exception e) {
throw new IllegalStateException(e);
}
}
@Test
void returnsTheUserOfAValidAccessToken() throws Exception {
AuthUser user = verifier.verify(sign(claims -> claims.setClaim("realm_access", Map.of("roles", List.of("offline_access")))));
assertEquals(new AuthUser("kc:user-1", "alice", false), user);
}
@Test
void marksUsersWithTheGuestRoleAsGuests() throws Exception {
AuthUser user = verifier.verify(sign(claims -> {
claims.setClaim("preferred_username", "guest-1a2b3c4d");
claims.setClaim("realm_access", Map.of("roles", List.of("guest")));
}));
assertEquals(new AuthUser("kc:user-1", "guest-1a2b3c4d", true), user);
}
static Stream<Arguments> invalidTokens() {
return Stream.of(
Arguments.of("signed with another key", sign(claims -> { }, otherKey)),
Arguments.of("from another issuer", sign(claims -> claims.setIssuer("http://evil.test/realms/mancala"))),
Arguments.of("expired", sign(claims -> claims.setExpirationTimeMinutesInTheFuture(-1))),
Arguments.of("an ID token", sign(claims -> claims.setClaim("typ", "ID"))),
Arguments.of("a refresh token", sign(claims -> claims.setClaim("typ", "Refresh"))),
Arguments.of("issued to another client", sign(claims -> claims.setClaim("azp", "some-other-app"))),
Arguments.of("without a subject", sign(claims -> claims.unsetClaim("sub"))),
Arguments.of("not a JWT", "not-a-token"));
}
@ParameterizedTest(name = "rejects a token {0}")
@MethodSource("invalidTokens")
void rejectsInvalidTokens(String description, String token) {
assertThrows(InvalidTokenException.class, () -> verifier.verify(token));
}
@Test
void rejectsUnsignedTokens() {
Base64.Encoder base64 = Base64.getUrlEncoder().withoutPadding();
String header = base64.encodeToString("{\"alg\":\"none\"}".getBytes());
String payload = base64.encodeToString(("{\"iss\":\"" + ISSUER + "\",\"sub\":\"user-1\",\"typ\":\"Bearer\","
+ "\"azp\":\"mancala-web\",\"exp\":" + (System.currentTimeMillis() / 1000 + 300) + "}").getBytes());
assertThrows(InvalidTokenException.class, () -> verifier.verify(header + "." + payload + "."));
}
}

View File

@ -0,0 +1,85 @@
package app.playmancala.loop;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
// A Scheduler for tests, in place of Jest's fake timers: tasks run at once, timers when advance() reaches them.
public final class ManualScheduler implements Scheduler {
private long now;
private long sequence;
private final List<Timer> timers = new ArrayList<>();
private final class Timer implements Cancellable {
final Runnable task;
final long periodMs;
final long order = sequence++;
long dueAt;
boolean cancelled;
Timer(Runnable task, long dueAt, long periodMs) {
this.task = task;
this.dueAt = dueAt;
this.periodMs = periodMs;
}
@Override
public void cancel() {
cancelled = true;
timers.remove(this);
}
}
public ManualScheduler(long now) {
this.now = now;
}
@Override
public long now() {
return now;
}
public void setNow(long now) {
this.now = now;
}
@Override
public void execute(Runnable task) {
task.run();
}
@Override
public Cancellable schedule(Runnable task, long delayMs) {
Timer timer = new Timer(task, now + Math.max(0, delayMs), 0);
timers.add(timer);
return timer;
}
@Override
public Cancellable scheduleAtFixedRate(Runnable task, long periodMs) {
Timer timer = new Timer(task, now + periodMs, periodMs);
timers.add(timer);
return timer;
}
// Moves the clock forward, running the timers that come due in order, each at its own time.
public void advance(long ms) {
long target = now + ms;
while (true) {
Timer next = timers.stream()
.filter(timer -> timer.dueAt <= target)
.min(Comparator.<Timer>comparingLong(timer -> timer.dueAt).thenComparingLong(timer -> timer.order))
.orElse(null);
if (next == null) break;
now = next.dueAt;
if (next.periodMs > 0) next.dueAt += next.periodMs;
else timers.remove(next);
next.task.run();
}
now = target;
}
public int pendingTimers() {
return timers.size();
}
}

View File

@ -0,0 +1,26 @@
package app.playmancala.loop;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.Test;
class ManualSchedulerTest {
@Test
void runsTimersInOrderAtTheirOwnTime() {
ManualScheduler scheduler = new ManualScheduler(1_000);
List<String> runs = new ArrayList<>();
scheduler.scheduleAtFixedRate(() -> runs.add("tick@" + scheduler.now()), 100);
scheduler.schedule(() -> runs.add("once@" + scheduler.now()), 150);
Cancellable cancelled = scheduler.schedule(() -> runs.add("cancelled"), 50);
cancelled.cancel();
scheduler.advance(99);
assertEquals(List.of(), runs);
scheduler.advance(201);
assertEquals(List.of("tick@1100", "once@1150", "tick@1200", "tick@1300"), runs);
assertEquals(1_300, scheduler.now());
}
}

View File

@ -5,7 +5,7 @@ Work through the tasks in order; each ends with its tests passing and a commit.
- [x] 1. **Spec**: requirements, design and tasks. - [x] 1. **Spec**: requirements, design and tasks.
- [x] 2. **Project and engine**: Maven project in `apps/backend-quarkus` (Quarkus 3.40, Java 25); the engine port - [x] 2. **Project and engine**: Maven project in `apps/backend-quarkus` (Quarkus 3.40, Java 25); the engine port
with unit tests; `scripts/record-engine-games.js` and the parity test (R2). with unit tests; `scripts/record-engine-games.js` and the parity test (R2).
- [ ] 3. **Models, game loop and auth**: the wire types and the shared `ObjectMapper`; `GameLoop`, `Scheduler`, - [x] 3. **Models, game loop and auth**: the wire types and the shared `ObjectMapper`; `GameLoop`, `Scheduler`,
`ManualScheduler`; `KeycloakTokenVerifier` with unit tests. `ManualScheduler`; `KeycloakTokenVerifier` with unit tests.
- [ ] 4. **Game logic**: `GameStore`, `MatchMaker`, `GameClocks`, `GameSerialization`, `GameRecorder`, - [ ] 4. **Game logic**: `GameStore`, `MatchMaker`, `GameClocks`, `GameSerialization`, `GameRecorder`,
`GameManager`, `GameClockScheduler`, `LiveGames`, `PublicIds`, `CrashLog`; the in-memory fakes and the ported `GameManager`, `GameClockScheduler`, `LiveGames`, `PublicIds`, `CrashLog`; the in-memory fakes and the ported