import { IsNull, LessThan, MoreThan } from "typeorm"; import { redisClient } from "../../db/redis.js"; import { genId } from "../../misc/gen-id.js"; import { ReversiGames, Users } from "../../models/index.js"; import { deserializeLogs, restoreGame, serializeLogs, standardMap, ReversiEngine } from "./engine.js"; import { publishReversiGameStream, publishReversiStream } from "../stream.js"; const INVITATION_TIMEOUT_MS = 1000 * 20; const TIME_LIMIT_MIN = 5; const TIME_LIMIT_MAX = 300; const updateKeys = [ "map", "bw", "isLlotheo", "canPutEverywhere", "loopedBoard", "timeLimitForEachTurn" ]; async function getGame(id) { return await ReversiGames.findOne({ where: { id }, relations: { user1: true, user2: true } }); } async function publishGame(id, type, body) { publishReversiGameStream(id, type, body); } async function endGame(game, winnerId, reason) { await ReversiGames.update(game.id, { isEnded: true, endedAt: new Date(), winnerId, surrenderedUserId: reason === "surrender" ? winnerId === game.user1Id ? game.user2Id : game.user1Id : null, timeoutUserId: reason === "timeout" ? winnerId === game.user1Id ? game.user2Id : game.user1Id : null }); const fresh = await getGame(game.id); if (fresh) { await publishGame(game.id, "ended", { winnerId, game: await ReversiGames.packDetail(fresh) }); } } export function isValidUpdateKey(key) { return typeof key === "string" && updateKeys.includes(key); } export function isValidUpdateValue(key, value) { switch(key){ case "map": return Array.isArray(value) && value.length > 0 && value.every((row)=>typeof row === "string" && row.length <= 64); case "bw": return value === "random" || value === "1" || value === "2"; case "isLlotheo": case "canPutEverywhere": case "loopedBoard": return typeof value === "boolean"; case "timeLimitForEachTurn": return typeof value === "number" && value >= TIME_LIMIT_MIN && value <= TIME_LIMIT_MAX; } } export async function matchSpecificUser(me, target, multiple = false) { if (!multiple) { const since = genId(new Date(Date.now() - 1000 * 60 * 3)); const games = await ReversiGames.find({ where: [ { id: MoreThan(since), user1Id: me.id, user2Id: target.id, isStarted: false }, { id: MoreThan(since), user1Id: target.id, user2Id: me.id, isStarted: false } ], relations: { user1: true, user2: true }, order: { id: "DESC" } }); if (games.length > 0) return games[0]; } const invitations = await redisClient.zrangebyscore(`reversi:matchSpecific:${me.id}`, Date.now() - INVITATION_TIMEOUT_MS, "+inf"); if (invitations.includes(target.id)) { await redisClient.zrem(`reversi:matchSpecific:${me.id}`, target.id); return await matched(target.id, me.id, { noIrregularRules: false }); } const pipeline = redisClient.pipeline(); pipeline.zadd(`reversi:matchSpecific:${target.id}`, Date.now(), me.id); pipeline.expire(`reversi:matchSpecific:${target.id}`, 120); await pipeline.exec(); publishReversiStream(target.id, "invited", { user: await Users.pack(me.id, target, { detail: false }) }); return null; } export async function matchAnyUser(me, options, multiple = false) { if (!multiple) { const since = genId(new Date(Date.now() - 1000 * 60 * 3)); const games = await ReversiGames.find({ where: [ { id: MoreThan(since), user1Id: me.id, isStarted: false }, { id: MoreThan(since), user2Id: me.id, isStarted: false } ], relations: { user1: true, user2: true }, order: { id: "DESC" } }); if (games.length > 0) return games[0]; } const invitations = await redisClient.zrangebyscore(`reversi:matchSpecific:${me.id}`, Date.now() - INVITATION_TIMEOUT_MS, "+inf"); if (invitations.length > 0) { const inviterId = invitations[Math.floor(Math.random() * invitations.length)]; await redisClient.zrem(`reversi:matchSpecific:${me.id}`, inviterId); return await matched(inviterId, me.id, { noIrregularRules: false }); } const matchings = await redisClient.zrevrange("reversi:matchAny", 0, 2); const items = matchings.filter((id)=>!id.startsWith(me.id)); if (items.length > 0) { const [matchedUserId, option] = items[0].split(":"); await redisClient.zrem("reversi:matchAny", me.id, matchedUserId, `${me.id}:noIrregularRules`, `${matchedUserId}:noIrregularRules`); return await matched(matchedUserId, me.id, { noIrregularRules: options.noIrregularRules || option === "noIrregularRules" }); } const pipeline = redisClient.pipeline(); pipeline.zadd("reversi:matchAny", Date.now(), options.noIrregularRules ? `${me.id}:noIrregularRules` : me.id); pipeline.expire("reversi:matchAny", 15); await pipeline.exec(); return null; } async function matched(parentId, childId, options) { const game = await ReversiGames.insert({ id: genId(), startedAt: null, endedAt: null, user1Id: parentId, user2Id: childId, user1Ready: false, user2Ready: false, black: null, isStarted: false, isEnded: false, winnerId: null, surrenderedUserId: null, timeoutUserId: null, timeLimitForEachTurn: 90, logs: [], map: standardMap, bw: "random", noIrregularRules: options.noIrregularRules, isLlotheo: false, canPutEverywhere: false, loopedBoard: false, form1: null, form2: null, crc32: null }).then((x)=>getGame(x.identifiers[0].id)); if (!game) throw new Error("failed to create reversi game"); publishReversiStream(parentId, "matched", { game: await ReversiGames.packDetail(game) }); return game; } export async function cancelMatch(user, userId) { if (userId) await redisClient.zrem(`reversi:matchSpecific:${userId}`, user.id); await redisClient.zrem("reversi:matchAny", user.id, `${user.id}:noIrregularRules`); } export async function getInvitations(user) { return await redisClient.zrangebyscore(`reversi:matchSpecific:${user.id}`, Date.now() - INVITATION_TIMEOUT_MS, "+inf"); } export async function gameReady(gameId, user, ready) { const game = await getGame(gameId); if (!game || game.isStarted) return; if (game.user1Id !== user.id && game.user2Id !== user.id) return; const patch = game.user1Id === user.id ? { user1Ready: ready } : { user2Ready: ready }; await ReversiGames.update(game.id, patch); const fresh = await getGame(game.id); if (!fresh) return; await publishGame(game.id, "changeReadyStates", { user1: fresh.user1Ready, user2: fresh.user2Ready }); if (fresh.user1Ready && fresh.user2Ready) setTimeout(()=>startGame(fresh.id), 3000); } export async function updateSettings(gameId, user, key, value) { const game = await getGame(gameId); if (!game || game.isStarted) return; if (game.user1Id !== user.id && game.user2Id !== user.id) return; if (game.user1Id === user.id && game.user1Ready) return; if (game.user2Id === user.id && game.user2Ready) return; if (game.noIrregularRules && (key === "isLlotheo" || key === "canPutEverywhere" || key === "loopedBoard")) return; await ReversiGames.update(game.id, { [key]: value }); await publishGame(game.id, "updateSettings", { userId: user.id, key, value }); } async function startGame(gameId) { const game = await getGame(gameId); if (!game || game.isStarted || game.isEnded || !game.user1Ready || !game.user2Ready) return; const black = game.bw === "random" ? Math.random() > 0.5 ? 1 : 2 : parseInt(game.bw, 10); const engine = new ReversiEngine(game.map, game); await ReversiGames.update(game.id, { startedAt: new Date(), isStarted: true, black, crc32: engine.calcCrc32() }); const fresh = await getGame(game.id); if (!fresh) return; if (engine.isEnded) { const winnerId = engine.winner === true ? black === 1 ? game.user1Id : game.user2Id : engine.winner === false ? black === 1 ? game.user2Id : game.user1Id : null; await endGame(fresh, winnerId, null); return; } await redisClient.setex(`reversi:game:turnTimer:${game.id}:1`, fresh.timeLimitForEachTurn, ""); await publishGame(game.id, "started", { game: await ReversiGames.packDetail(fresh) }); } export async function putStone(gameId, user, pos, id) { const game = await getGame(gameId); if (!game || !game.isStarted || game.isEnded) return; if (game.user1Id !== user.id && game.user2Id !== user.id) return; const myColor = game.user1Id === user.id && game.black === 1 || game.user2Id === user.id && game.black === 2; const engine = restoreGame(game); if (engine.turn !== myColor || !engine.canPut(myColor, pos)) return; engine.putStone(pos); const logs = deserializeLogs(game.logs); const log = { time: Date.now(), player: myColor, operation: "put", pos }; logs.push(log); const serialized = serializeLogs(logs); await ReversiGames.update(game.id, { logs: serialized, crc32: engine.calcCrc32() }); await publishGame(game.id, "log", { ...log, id: id ?? null }); const fresh = await getGame(game.id); if (!fresh) return; if (engine.isEnded) { const winnerId = engine.winner === true ? game.black === 1 ? game.user1Id : game.user2Id : engine.winner === false ? game.black === 1 ? game.user2Id : game.user1Id : null; await endGame(fresh, winnerId, null); } else if (engine.turn != null) { await redisClient.setex(`reversi:game:turnTimer:${game.id}:${engine.turn ? "1" : "0"}`, fresh.timeLimitForEachTurn, ""); } } export async function surrender(gameId, user) { const game = await getGame(gameId); if (!game || game.isEnded) return; if (game.user1Id !== user.id && game.user2Id !== user.id) return; await endGame(game, game.user1Id === user.id ? game.user2Id : game.user1Id, "surrender"); } export async function cancelGame(gameId, user) { const game = await getGame(gameId); if (!game || game.isStarted) return; if (game.user1Id !== user.id && game.user2Id !== user.id) return; await ReversiGames.delete(game.id); await publishGame(game.id, "canceled", { userId: user.id }); } export async function checkTimeout(gameId) { const game = await getGame(gameId); if (!game || game.isEnded) return; const engine = restoreGame(game); if (engine.turn == null) return; const timer = await redisClient.exists(`reversi:game:turnTimer:${game.id}:${engine.turn ? "1" : "0"}`); if (timer === 0) { const winnerId = engine.turn ? game.black === 1 ? game.user2Id : game.user1Id : game.black === 1 ? game.user1Id : game.user2Id; await endGame(game, winnerId, "timeout"); } } export async function checkCrc(gameId, crc32) { const game = await getGame(gameId); if (!game) return null; return crc32.toString() !== game.crc32 ? game : null; } export async function cleanupOpenGames() { await ReversiGames.delete({ id: LessThan(genId(new Date(Date.now() - 1000 * 60 * 10))), isStarted: false, startedAt: IsNull() }); }