Fixed 267U.pre2
This commit is contained in:
@@ -0,0 +1,183 @@
|
||||
export const standardMap = [
|
||||
"--------",
|
||||
"--------",
|
||||
"--------",
|
||||
"---wb---",
|
||||
"---bw---",
|
||||
"--------",
|
||||
"--------",
|
||||
"--------"
|
||||
];
|
||||
export class ReversiEngine {
|
||||
map;
|
||||
mapWidth;
|
||||
mapHeight;
|
||||
board;
|
||||
turn = true;
|
||||
prevColor = null;
|
||||
opts;
|
||||
constructor(map, opts){
|
||||
this.opts = {
|
||||
isLlotheo: opts.isLlotheo ?? false,
|
||||
canPutEverywhere: opts.canPutEverywhere ?? false,
|
||||
loopedBoard: opts.loopedBoard ?? false
|
||||
};
|
||||
this.mapWidth = map[0]?.length ?? 0;
|
||||
this.mapHeight = map.length;
|
||||
const data = map.join("");
|
||||
this.board = data.split("").map((d)=>d === "-" ? null : d === "b" ? true : d === "w" ? false : undefined);
|
||||
this.map = data.split("").map((d)=>d === "-" || d === "b" || d === "w" ? "empty" : "null");
|
||||
if (!this.canPutSomewhere(true)) this.turn = this.canPutSomewhere(false) ? false : null;
|
||||
}
|
||||
get blackCount() {
|
||||
return this.board.filter((x)=>x === true).length;
|
||||
}
|
||||
get whiteCount() {
|
||||
return this.board.filter((x)=>x === false).length;
|
||||
}
|
||||
posToXy(pos) {
|
||||
return [
|
||||
pos % this.mapWidth,
|
||||
Math.floor(pos / this.mapWidth)
|
||||
];
|
||||
}
|
||||
xyToPos(x, y) {
|
||||
return x + y * this.mapWidth;
|
||||
}
|
||||
mapDataGet(pos) {
|
||||
const [x, y] = this.posToXy(pos);
|
||||
return x < 0 || y < 0 || x >= this.mapWidth || y >= this.mapHeight ? "null" : this.map[pos];
|
||||
}
|
||||
getPuttablePlaces(color) {
|
||||
return Array.from(this.board.keys()).filter((i)=>this.canPut(color, i));
|
||||
}
|
||||
canPutSomewhere(color) {
|
||||
return this.getPuttablePlaces(color).length > 0;
|
||||
}
|
||||
canPut(color, pos) {
|
||||
if (this.board[pos] !== null) return false;
|
||||
if (this.opts.canPutEverywhere) return this.mapDataGet(pos) === "empty";
|
||||
return this.effects(color, pos).length !== 0;
|
||||
}
|
||||
effects(color, initPos) {
|
||||
const enemyColor = !color;
|
||||
const diffVectors = [
|
||||
[
|
||||
0,
|
||||
-1
|
||||
],
|
||||
[
|
||||
1,
|
||||
-1
|
||||
],
|
||||
[
|
||||
1,
|
||||
0
|
||||
],
|
||||
[
|
||||
1,
|
||||
1
|
||||
],
|
||||
[
|
||||
0,
|
||||
1
|
||||
],
|
||||
[
|
||||
-1,
|
||||
1
|
||||
],
|
||||
[
|
||||
-1,
|
||||
0
|
||||
],
|
||||
[
|
||||
-1,
|
||||
-1
|
||||
]
|
||||
];
|
||||
return diffVectors.flatMap(([dx, dy])=>{
|
||||
const found = [];
|
||||
let [x, y] = this.posToXy(initPos);
|
||||
while(true){
|
||||
x += dx;
|
||||
y += dy;
|
||||
if (this.opts.loopedBoard && this.xyToPos(x = (x % this.mapWidth + this.mapWidth) % this.mapWidth, y = (y % this.mapHeight + this.mapHeight) % this.mapHeight) === initPos) {
|
||||
return found;
|
||||
}
|
||||
if (x === -1 || y === -1 || x === this.mapWidth || y === this.mapHeight) return [];
|
||||
const pos = this.xyToPos(x, y);
|
||||
if (this.mapDataGet(pos) === "null") return [];
|
||||
const stone = this.board[pos];
|
||||
if (stone === null) return [];
|
||||
if (stone === enemyColor) found.push(pos);
|
||||
if (stone === color) return found;
|
||||
}
|
||||
});
|
||||
}
|
||||
putStone(pos) {
|
||||
const color = this.turn;
|
||||
if (color == null) return;
|
||||
this.board[pos] = color;
|
||||
for (const effect of this.effects(color, pos)){
|
||||
this.board[effect] = color;
|
||||
}
|
||||
this.prevColor = color;
|
||||
this.turn = this.canPutSomewhere(!color) ? !color : this.canPutSomewhere(color) ? color : null;
|
||||
}
|
||||
get isEnded() {
|
||||
return this.turn == null;
|
||||
}
|
||||
get winner() {
|
||||
if (!this.isEnded) return null;
|
||||
if (this.blackCount === this.whiteCount) return null;
|
||||
return this.opts.isLlotheo === this.blackCount > this.whiteCount ? false : true;
|
||||
}
|
||||
calcCrc32() {
|
||||
let hash = 0;
|
||||
const text = JSON.stringify({
|
||||
board: this.board,
|
||||
turn: this.turn
|
||||
});
|
||||
for(let i = 0; i < text.length; i++){
|
||||
hash = hash * 31 + text.charCodeAt(i) | 0;
|
||||
}
|
||||
return hash.toString();
|
||||
}
|
||||
}
|
||||
export function serializeLogs(logs) {
|
||||
const serialized = [];
|
||||
for(let i = 0; i < logs.length; i++){
|
||||
const log = logs[i];
|
||||
const timeDelta = i === 0 ? log.time : log.time - logs[i - 1].time;
|
||||
serialized.push([
|
||||
timeDelta,
|
||||
log.player ? 1 : 0,
|
||||
0,
|
||||
log.pos
|
||||
]);
|
||||
}
|
||||
return serialized;
|
||||
}
|
||||
export function deserializeLogs(logs) {
|
||||
const deserialized = [];
|
||||
let time = 0;
|
||||
for (const log of logs){
|
||||
time += log[0];
|
||||
if (log[2] === 0) {
|
||||
deserialized.push({
|
||||
time,
|
||||
player: log[1] === 1,
|
||||
operation: "put",
|
||||
pos: log[3]
|
||||
});
|
||||
}
|
||||
}
|
||||
return deserialized;
|
||||
}
|
||||
export function restoreGame(env) {
|
||||
const game = new ReversiEngine(env.map, env);
|
||||
for (const log of deserializeLogs(env.logs)){
|
||||
if (log.operation === "put") game.putStone(log.pos);
|
||||
}
|
||||
return game;
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
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()
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user