package app.playmancala; import java.sql.SQLException; import java.util.SplittableRandom; 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; import app.playmancala.bot.BotPlayer; 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; // 0: half the processors. @ConfigProperty(name = "mancala.bot-search-threads") int botSearchThreads; private GameLoop loop; // Valkey writes, in order. private ExecutorService storeWriter; // Migrations and saving finished games. private ScheduledExecutorService recorderIo; // Token checks. private ExecutorService verifier; // Working out the bot's moves. private ExecutorService botSearch; 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(); int searchThreads = botSearchThreads > 0 ? botSearchThreads : Math.max(1, Runtime.getRuntime().availableProcessors() / 2); botSearch = Executors.newFixedThreadPool(searchThreads, Thread.ofPlatform().name("bot-search-", 0).daemon().factory()); 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); new BotPlayer(gameManager, gameStore, loop, botSearch, new SplittableRandom()); 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(); botSearch.shutdownNow(); } 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); } }