2026-10-16 11:33:07 +03:00
|
|
|
package app.playmancala;
|
|
|
|
|
|
|
|
|
|
import java.sql.SQLException;
|
2026-10-16 14:18:45 +03:00
|
|
|
import java.util.SplittableRandom;
|
2026-10-16 11:33:07 +03:00
|
|
|
import java.util.concurrent.ExecutorService;
|
|
|
|
|
import java.util.concurrent.Executors;
|
|
|
|
|
import java.util.concurrent.ScheduledExecutorService;
|
|
|
|
|
import java.util.concurrent.TimeUnit;
|
|
|
|
|
|
|
|
|
|
import javax.sql.DataSource;
|
|
|
|
|
|
|
|
|
|
import org.eclipse.microprofile.config.inject.ConfigProperty;
|
|
|
|
|
import org.jboss.logging.Logger;
|
|
|
|
|
|
|
|
|
|
import app.playmancala.auth.TokenVerifier;
|
2026-10-16 14:18:45 +03:00
|
|
|
import app.playmancala.bot.BotPlayer;
|
2026-10-16 11:33:07 +03:00
|
|
|
import app.playmancala.game.GameClockScheduler;
|
|
|
|
|
import app.playmancala.game.GameManager;
|
|
|
|
|
import app.playmancala.game.GameRecorder;
|
|
|
|
|
import app.playmancala.game.GameStore;
|
|
|
|
|
import app.playmancala.game.LiveGames;
|
|
|
|
|
import app.playmancala.game.MatchMaker;
|
|
|
|
|
import app.playmancala.history.GameHistoryService;
|
|
|
|
|
import app.playmancala.history.Migrations;
|
|
|
|
|
import app.playmancala.history.PostgresGameHistoryRepository;
|
|
|
|
|
import app.playmancala.live.ValkeyCache;
|
|
|
|
|
import app.playmancala.live.ValkeyLiveGameStore;
|
|
|
|
|
import app.playmancala.loop.GameLoop;
|
|
|
|
|
import app.playmancala.rtmt.RtmtServer;
|
|
|
|
|
import io.quarkus.redis.datasource.RedisDataSource;
|
|
|
|
|
import io.quarkus.runtime.Startup;
|
|
|
|
|
import jakarta.annotation.PostConstruct;
|
|
|
|
|
import jakarta.annotation.PreDestroy;
|
|
|
|
|
import jakarta.enterprise.context.ApplicationScoped;
|
|
|
|
|
import jakarta.inject.Inject;
|
|
|
|
|
|
|
|
|
|
// Builds the backend from the infrastructure Quarkus provides and starts it (docs/specs/quarkus-backend, Startup).
|
|
|
|
|
@Startup
|
|
|
|
|
@ApplicationScoped
|
|
|
|
|
public class Backend {
|
|
|
|
|
private static final Logger LOG = Logger.getLogger(Backend.class);
|
|
|
|
|
private static final long MIGRATION_RETRY_MS = 30_000;
|
|
|
|
|
private static final long VALKEY_STARTUP_WAIT_MS = 5_000;
|
|
|
|
|
|
|
|
|
|
@Inject
|
|
|
|
|
DataSource dataSource;
|
|
|
|
|
|
|
|
|
|
@Inject
|
|
|
|
|
RedisDataSource redis;
|
|
|
|
|
|
|
|
|
|
@Inject
|
|
|
|
|
TokenVerifier tokenVerifier;
|
|
|
|
|
|
|
|
|
|
@ConfigProperty(name = "mancala.auth.legacy-userkey-auth")
|
|
|
|
|
String legacyUserKeyAuth;
|
|
|
|
|
|
|
|
|
|
@ConfigProperty(name = "mancala.game-clock-ms")
|
|
|
|
|
long gameClockMs;
|
|
|
|
|
|
2026-10-16 14:18:45 +03:00
|
|
|
// 0: half the processors.
|
|
|
|
|
@ConfigProperty(name = "mancala.bot-search-threads")
|
|
|
|
|
int botSearchThreads;
|
|
|
|
|
|
2026-10-16 11:33:07 +03:00
|
|
|
private GameLoop loop;
|
|
|
|
|
// Valkey writes, in order.
|
|
|
|
|
private ExecutorService storeWriter;
|
|
|
|
|
// Migrations and saving finished games.
|
|
|
|
|
private ScheduledExecutorService recorderIo;
|
|
|
|
|
// Token checks.
|
|
|
|
|
private ExecutorService verifier;
|
2026-10-16 14:18:45 +03:00
|
|
|
// Working out the bot's moves.
|
|
|
|
|
private ExecutorService botSearch;
|
2026-10-16 11:33:07 +03:00
|
|
|
|
|
|
|
|
private RtmtServer rtmt;
|
|
|
|
|
private GameStore gameStore;
|
|
|
|
|
private GameManager gameManager;
|
|
|
|
|
private LiveGames liveGames;
|
|
|
|
|
private GameHistoryService history;
|
|
|
|
|
private GameClockScheduler clockScheduler;
|
|
|
|
|
|
|
|
|
|
@PostConstruct
|
|
|
|
|
void start() {
|
|
|
|
|
loop = new GameLoop();
|
|
|
|
|
storeWriter = Executors.newSingleThreadExecutor(Thread.ofPlatform().name("valkey-writer").daemon().factory());
|
|
|
|
|
recorderIo = Executors.newSingleThreadScheduledExecutor(Thread.ofPlatform().name("game-recorder").daemon().factory());
|
|
|
|
|
verifier = Executors.newVirtualThreadPerTaskExecutor();
|
2026-10-16 14:18:45 +03:00
|
|
|
int searchThreads = botSearchThreads > 0 ? botSearchThreads
|
|
|
|
|
: Math.max(1, Runtime.getRuntime().availableProcessors() / 2);
|
|
|
|
|
botSearch = Executors.newFixedThreadPool(searchThreads,
|
|
|
|
|
Thread.ofPlatform().name("bot-search-", 0).daemon().factory());
|
2026-10-16 11:33:07 +03:00
|
|
|
|
|
|
|
|
ValkeyLiveGameStore liveGameStore = new ValkeyLiveGameStore(redis);
|
|
|
|
|
PostgresGameHistoryRepository repository = new PostgresGameHistoryRepository(dataSource);
|
|
|
|
|
GameRecorder recorder = new GameRecorder(repository, liveGameStore, recorderIo);
|
|
|
|
|
history = new GameHistoryService(repository, new ValkeyCache(redis), recorder::getUnsaved);
|
|
|
|
|
recorder.onSaved(history::onGameSaved);
|
|
|
|
|
|
|
|
|
|
// Exactly "true", like the Node backend.
|
|
|
|
|
rtmt = new RtmtServer(tokenVerifier, "true".equals(legacyUserKeyAuth), loop, verifier,
|
|
|
|
|
RtmtServer.DEFAULT_AUTH_TIMEOUT_MS);
|
|
|
|
|
gameStore = new GameStore();
|
|
|
|
|
gameManager = new GameManager(gameStore, rtmt, new MatchMaker(rtmt), liveGameStore, recorder, storeWriter,
|
|
|
|
|
loop::now, gameClockMs);
|
|
|
|
|
liveGames = new LiveGames(rtmt, gameStore, gameManager, loop);
|
2026-10-16 14:18:45 +03:00
|
|
|
new BotPlayer(gameManager, gameStore, loop, botSearch, new SplittableRandom());
|
2026-10-16 11:33:07 +03:00
|
|
|
clockScheduler = new GameClockScheduler(gameManager::endGamesOutOfTime, loop,
|
|
|
|
|
GameClockScheduler.CLOCK_CHECK_INTERVAL_MS);
|
|
|
|
|
|
|
|
|
|
// The game runs without the database; saved games wait in the recorder until migrations have run.
|
|
|
|
|
recorderIo.execute(this::migrate);
|
|
|
|
|
recorderIo.scheduleWithFixedDelay(() -> guarded(recorder::retryPending), 0, GameRecorder.RETRY_INTERVAL_MS,
|
|
|
|
|
TimeUnit.MILLISECONDS);
|
|
|
|
|
// The first task on the loop, so socket events queue behind it and reconnecting players find their games.
|
|
|
|
|
loop.execute(() -> {
|
|
|
|
|
waitForValkey();
|
|
|
|
|
gameManager.restoreLiveGames();
|
|
|
|
|
clockScheduler.start();
|
|
|
|
|
rtmt.start();
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@PreDestroy
|
|
|
|
|
void stop() {
|
|
|
|
|
loop.close();
|
|
|
|
|
storeWriter.shutdown();
|
|
|
|
|
recorderIo.shutdownNow();
|
|
|
|
|
verifier.shutdownNow();
|
2026-10-16 14:18:45 +03:00
|
|
|
botSearch.shutdownNow();
|
2026-10-16 11:33:07 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private void migrate() {
|
|
|
|
|
try {
|
|
|
|
|
Migrations.run(dataSource);
|
|
|
|
|
} catch (SQLException | RuntimeException e) {
|
|
|
|
|
LOG.errorf("(DB) Migrations failed, retrying in %ds: %s", MIGRATION_RETRY_MS / 1000, e.getMessage());
|
|
|
|
|
recorderIo.schedule(this::migrate, MIGRATION_RETRY_MS, TimeUnit.MILLISECONDS);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private void waitForValkey() {
|
|
|
|
|
long deadline = System.currentTimeMillis() + VALKEY_STARTUP_WAIT_MS;
|
|
|
|
|
while (true) {
|
|
|
|
|
try {
|
|
|
|
|
redis.execute("PING");
|
|
|
|
|
return;
|
|
|
|
|
} catch (RuntimeException e) {
|
|
|
|
|
if (System.currentTimeMillis() >= deadline) {
|
|
|
|
|
LOG.error("(Valkey) Not reachable yet; starting without restoring live games");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
try {
|
|
|
|
|
Thread.sleep(250);
|
|
|
|
|
} catch (InterruptedException e) {
|
|
|
|
|
Thread.currentThread().interrupt();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static void guarded(Runnable task) {
|
|
|
|
|
try {
|
|
|
|
|
task.run();
|
|
|
|
|
} catch (RuntimeException e) {
|
|
|
|
|
LOG.error("(Backend) Task failed", e);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public GameLoop loop() {
|
|
|
|
|
return loop;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public RtmtServer rtmt() {
|
|
|
|
|
return rtmt;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public GameStore gameStore() {
|
|
|
|
|
return gameStore;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public GameManager gameManager() {
|
|
|
|
|
return gameManager;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public LiveGames liveGames() {
|
|
|
|
|
return liveGames;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public GameHistoryService history() {
|
|
|
|
|
return history;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public TokenVerifier tokenVerifier() {
|
|
|
|
|
return tokenVerifier;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public boolean legacyUserKeyAuth() {
|
|
|
|
|
return "true".equals(legacyUserKeyAuth);
|
|
|
|
|
}
|
|
|
|
|
}
|