feat(backend): add the wire types, the game loop and token verification

Records for the JSON the clients read, leaving out only the fields
TypeScript leaves undefined. GameLoop is the single thread that owns live
state; ManualScheduler replaces Jest's fake timers in tests. Keycloak
tokens are verified with jose4j and the same checks as before.
This commit is contained in:
Halit Aksoy 2026-10-16 11:09:16 +03:00
parent f0ffd6ba87
commit f0246cca61
25 changed files with 700 additions and 1 deletions

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