Files
2026-07-26 18:25:37 +09:00

251 lines
8.5 KiB
JavaScript

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()
});
}