import { IsNull, LessThan, MoreThan } from "typeorm"; import { initialSfen, makeSfen, parseSfen } from "shogiops/sfen"; import { makeUsi, parseUsi } from "shogiops/util"; import { redisClient } from "../../db/redis.js"; import { genId } from "../../misc/gen-id.js"; import { ShogiGames, Users } from "../../models/index.js"; import { publishShogiGameStream, publishShogiStream } from "../stream.js"; const INVITATION_TIMEOUT_MS = 1000 * 20; const RULES = "standard"; async function getGame(id) { return await ShogiGames.findOne({ where: { id }, relations: { user1: true, user2: true } }); } async function publishGame(id, type, body) { publishShogiGameStream(id, type, body); } function colorUserId(game, color) { const senteUserId = game.sente === 2 ? game.user2Id : game.user1Id; const goteUserId = game.sente === 2 ? game.user1Id : game.user2Id; return color === "sente" ? senteUserId : goteUserId; } async function endGame(game, winnerId, surrenderedUserId) { await ShogiGames.update(game.id, { isEnded: true, endedAt: new Date(), winnerId, surrenderedUserId }); const fresh = await getGame(game.id); if (fresh) { await publishGame(game.id, "ended", { winnerId, game: await ShogiGames.packDetail(fresh) }); } } export async function matchSpecificUser(me, target, multiple = false) { if (!multiple) { const since = genId(new Date(Date.now() - 1000 * 60 * 3)); const games = await ShogiGames.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(`shogi:matchSpecific:${me.id}`, Date.now() - INVITATION_TIMEOUT_MS, "+inf"); if (invitations.includes(target.id)) { await redisClient.zrem(`shogi:matchSpecific:${me.id}`, target.id); return await matched(target.id, me.id); } const pipeline = redisClient.pipeline(); pipeline.zadd(`shogi:matchSpecific:${target.id}`, Date.now(), me.id); pipeline.expire(`shogi:matchSpecific:${target.id}`, 120); await pipeline.exec(); publishShogiStream(target.id, "invited", { user: await Users.pack(me.id, target, { detail: false }) }); return null; } export async function matchAnyUser(me, multiple = false) { if (!multiple) { const since = genId(new Date(Date.now() - 1000 * 60 * 3)); const games = await ShogiGames.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(`shogi: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(`shogi:matchSpecific:${me.id}`, inviterId); return await matched(inviterId, me.id); } const matchings = await redisClient.zrevrange("shogi:matchAny", 0, 1); const matchedUserId = matchings.find((id)=>id !== me.id); if (matchedUserId) { await redisClient.zrem("shogi:matchAny", me.id, matchedUserId); return await matched(matchedUserId, me.id); } const pipeline = redisClient.pipeline(); pipeline.zadd("shogi:matchAny", Date.now(), me.id); pipeline.expire("shogi:matchAny", 15); await pipeline.exec(); return null; } async function matched(parentId, childId) { const game = await ShogiGames.insert({ id: genId(), startedAt: null, endedAt: null, user1Id: parentId, user2Id: childId, user1Ready: false, user2Ready: false, sente: null, isStarted: false, isEnded: false, winnerId: null, surrenderedUserId: null, sfen: initialSfen(RULES), logs: [] }).then((x)=>getGame(x.identifiers[0].id)); if (!game) throw new Error("failed to create shogi game"); publishShogiStream(parentId, "matched", { game: await ShogiGames.packDetail(game) }); return game; } export async function cancelMatch(user, userId) { if (userId) await redisClient.zrem(`shogi:matchSpecific:${userId}`, user.id); await redisClient.zrem("shogi:matchAny", user.id); } export async function getInvitations(user) { return await redisClient.zrangebyscore(`shogi: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 ShogiGames.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); } async function startGame(gameId) { const game = await getGame(gameId); if (!game || game.isStarted || game.isEnded || !game.user1Ready || !game.user2Ready) return; await ShogiGames.update(game.id, { startedAt: new Date(), isStarted: true, sente: Math.random() > 0.5 ? 1 : 2 }); const fresh = await getGame(game.id); if (!fresh) return; await publishGame(game.id, "started", { game: await ShogiGames.packDetail(fresh) }); } export async function putMove(gameId, user, usi, id) { const game = await getGame(gameId); if (!game || !game.isStarted || game.isEnded || game.sente == null) return; if (game.user1Id !== user.id && game.user2Id !== user.id) return; const pos = parseSfen(RULES, game.sfen).unwrap(); const myColor = colorUserId(game, pos.turn) === user.id ? pos.turn : null; if (myColor == null) return; const move = parseUsi(usi); if (!move || !pos.isLegal(move)) return; pos.play(move); const nextSfen = makeSfen(pos); const log = { id: id ?? null, at: Date.now(), userId: user.id, usi: makeUsi(move), sfen: nextSfen }; const logs = [ ...game.logs ?? [], log ]; await ShogiGames.update(game.id, { sfen: nextSfen, logs }); await publishGame(game.id, "log", log); const outcome = pos.outcome(); const fresh = await getGame(game.id); if (!fresh) return; if (outcome) { const winnerId = outcome.winner ? colorUserId(fresh, outcome.winner) : null; await endGame(fresh, winnerId, null); } } 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, user.id); } 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 ShogiGames.delete(game.id); await publishGame(game.id, "canceled", { userId: user.id }); } export async function cleanupOpenGames() { await ShogiGames.delete({ id: LessThan(genId(new Date(Date.now() - 1000 * 60 * 10))), isStarted: false, startedAt: IsNull() }); }