263 lines
10 KiB
JavaScript
263 lines
10 KiB
JavaScript
const WIDTH = 7;
|
|
const HEIGHT = 6;
|
|
const HISTORY_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
|
|
|
function caller(meta) {
|
|
return {
|
|
id: Number(meta.caller.id),
|
|
username: String(meta.caller.username || `Player #${meta.caller.id}`).trim(),
|
|
role: String(meta.caller.role || "user"),
|
|
};
|
|
}
|
|
|
|
function parseBoard(value) {
|
|
try {
|
|
const board = JSON.parse(value);
|
|
return Array.isArray(board) && board.length === WIDTH * HEIGHT
|
|
? board.map((cell) => cell === "R" || cell === "Y" ? cell : "")
|
|
: Array(WIDTH * HEIGHT).fill("");
|
|
} catch {
|
|
return Array(WIDTH * HEIGHT).fill("");
|
|
}
|
|
}
|
|
|
|
function getGame(ctx, gameId) {
|
|
return ctx.db.prepare(`
|
|
SELECT id, status, player_r_id AS playerRId, player_r_name AS playerRName,
|
|
player_r_role AS playerRRole, player_y_id AS playerYId, player_y_name AS playerYName,
|
|
player_y_role AS playerYRole, client_id AS clientId, board, turn, winner,
|
|
created_at AS createdAt, updated_at AS updatedAt
|
|
FROM connect_four_games WHERE id = ?
|
|
`).get(gameId);
|
|
}
|
|
|
|
function serialize(game, callerId) {
|
|
if (!game) return null;
|
|
const yourColor = Number(game.playerRId) === callerId ? "R" : Number(game.playerYId) === callerId ? "Y" : null;
|
|
let message = "Waiting for an opponent";
|
|
if (game.status === "active") message = `${game.turn === "R" ? "Red" : "Yellow"}'s turn`;
|
|
else if (game.status === "won") message = `${game.winner === "R" ? "Red" : "Yellow"} wins`;
|
|
else if (game.status === "draw") message = "Draw";
|
|
return {
|
|
id: game.id,
|
|
status: game.status,
|
|
board: parseBoard(game.board),
|
|
turn: game.turn,
|
|
winner: game.winner || null,
|
|
message,
|
|
yourColor,
|
|
playerR: { id: Number(game.playerRId), username: game.playerRName, role: game.playerRRole },
|
|
playerY: game.clientId
|
|
? { id: null, username: game.playerYName, role: "client", clientId: game.clientId }
|
|
: game.playerYId == null ? null : { id: Number(game.playerYId), username: game.playerYName, role: game.playerYRole },
|
|
createdAt: Number(game.createdAt),
|
|
updatedAt: Number(game.updatedAt),
|
|
};
|
|
}
|
|
|
|
function openChallenges(ctx, callerId) {
|
|
return ctx.db.prepare(`
|
|
SELECT id, player_r_id AS playerRId, player_r_name AS playerRName,
|
|
player_r_role AS playerRRole, created_at AS createdAt
|
|
FROM connect_four_games
|
|
WHERE status = 'open'
|
|
ORDER BY created_at ASC
|
|
LIMIT 30
|
|
`).all().map((game) => ({
|
|
id: game.id,
|
|
username: game.playerRName,
|
|
role: game.playerRRole,
|
|
createdAt: Number(game.createdAt),
|
|
canAccept: Number(game.playerRId) !== callerId,
|
|
}));
|
|
}
|
|
|
|
function currentGame(ctx, callerId) {
|
|
return ctx.db.prepare(`
|
|
SELECT id FROM connect_four_games
|
|
WHERE status IN ('open', 'active') AND (player_r_id = ? OR player_y_id = ?)
|
|
ORDER BY updated_at DESC LIMIT 1
|
|
`).get(callerId, callerId);
|
|
}
|
|
|
|
function hasConnectFour(board, row, column, color) {
|
|
const directions = [[0, 1], [1, 0], [1, 1], [1, -1]];
|
|
return directions.some(([rowStep, columnStep]) => {
|
|
let count = 1;
|
|
for (const sign of [-1, 1]) {
|
|
let nextRow = row + rowStep * sign;
|
|
let nextColumn = column + columnStep * sign;
|
|
while (nextRow >= 0 && nextRow < HEIGHT && nextColumn >= 0 && nextColumn < WIDTH && board[nextRow * WIDTH + nextColumn] === color) {
|
|
count += 1;
|
|
nextRow += rowStep * sign;
|
|
nextColumn += columnStep * sign;
|
|
}
|
|
}
|
|
return count >= 4;
|
|
});
|
|
}
|
|
|
|
function broadcast(ctx, gameId) {
|
|
ctx.broadcast("changed", { gameId, at: Date.now() });
|
|
}
|
|
|
|
function applyMove(ctx, game, color, column) {
|
|
if (!game || game.status !== "active") throw new Error("Match is not active");
|
|
if (game.turn !== color) throw new Error("It is not your turn");
|
|
if (!Number.isInteger(column) || column < 0 || column >= WIDTH) throw new Error("Invalid column");
|
|
const board = parseBoard(game.board);
|
|
let row = -1;
|
|
for (let candidate = HEIGHT - 1; candidate >= 0; candidate -= 1) {
|
|
if (!board[candidate * WIDTH + column]) {
|
|
row = candidate;
|
|
break;
|
|
}
|
|
}
|
|
if (row < 0) throw new Error("Column is full");
|
|
board[row * WIDTH + column] = color;
|
|
const won = hasConnectFour(board, row, column, color);
|
|
const draw = !won && board.every(Boolean);
|
|
const status = won ? "won" : draw ? "draw" : "active";
|
|
const turn = color === "R" ? "Y" : "R";
|
|
ctx.db.prepare(`
|
|
UPDATE connect_four_games SET board = ?, turn = ?, status = ?, winner = ?, updated_at = ?
|
|
WHERE id = ? AND status = 'active'
|
|
`).run(JSON.stringify(board), turn, status, won ? color : null, Date.now(), game.id);
|
|
broadcast(ctx, game.id);
|
|
return getGame(ctx, game.id);
|
|
}
|
|
|
|
export default {
|
|
setup(ctx) {
|
|
ctx.db.exec(`
|
|
CREATE TABLE IF NOT EXISTS connect_four_games (
|
|
id TEXT PRIMARY KEY,
|
|
status TEXT NOT NULL,
|
|
player_r_id INTEGER NOT NULL,
|
|
player_r_name TEXT NOT NULL,
|
|
player_r_role TEXT NOT NULL,
|
|
player_y_id INTEGER,
|
|
player_y_name TEXT,
|
|
player_y_role TEXT,
|
|
client_id TEXT,
|
|
board TEXT NOT NULL,
|
|
turn TEXT NOT NULL,
|
|
winner TEXT,
|
|
created_at INTEGER NOT NULL,
|
|
updated_at INTEGER NOT NULL
|
|
);
|
|
CREATE INDEX IF NOT EXISTS connect_four_status ON connect_four_games(status, updated_at);
|
|
`);
|
|
const columns = ctx.db.prepare("PRAGMA table_info(connect_four_games)").all();
|
|
if (!columns.some((column) => column.name === "client_id")) {
|
|
ctx.db.exec("ALTER TABLE connect_four_games ADD COLUMN client_id TEXT");
|
|
}
|
|
ctx.db.prepare("DELETE FROM connect_four_games WHERE status NOT IN ('open', 'active') AND updated_at < ?")
|
|
.run(Date.now() - HISTORY_TTL_MS);
|
|
ctx.log.info("Connect Four multiplayer ready");
|
|
},
|
|
|
|
onEvent(ctx, clientId, event, payload) {
|
|
if (event !== "move") return;
|
|
const gameId = typeof payload?.gameId === "string" ? payload.gameId : "";
|
|
const game = getGame(ctx, gameId);
|
|
if (!game || game.clientId !== clientId) return;
|
|
try {
|
|
applyMove(ctx, game, "Y", Number(payload?.column));
|
|
} catch (error) {
|
|
ctx.log.warn(`Rejected client move from ${clientId}: ${error instanceof Error ? error.message : String(error)}`);
|
|
}
|
|
},
|
|
|
|
rpc: {
|
|
snapshot(ctx, params, meta) {
|
|
const user = caller(meta);
|
|
let game = null;
|
|
const requestedId = typeof params?.gameId === "string" ? params.gameId : "";
|
|
if (requestedId) {
|
|
const requested = getGame(ctx, requestedId);
|
|
if (requested && (Number(requested.playerRId) === user.id || Number(requested.playerYId) === user.id)) game = requested;
|
|
}
|
|
if (!game) {
|
|
const current = currentGame(ctx, user.id);
|
|
if (current) game = getGame(ctx, current.id);
|
|
}
|
|
return { game: serialize(game, user.id), challenges: openChallenges(ctx, user.id) };
|
|
},
|
|
|
|
createChallenge(ctx, _params, meta) {
|
|
const user = caller(meta);
|
|
const active = currentGame(ctx, user.id);
|
|
if (active) return { game: serialize(getGame(ctx, active.id), user.id) };
|
|
const id = crypto.randomUUID();
|
|
const now = Date.now();
|
|
ctx.db.prepare(`
|
|
INSERT INTO connect_four_games
|
|
(id, status, player_r_id, player_r_name, player_r_role, board, turn, created_at, updated_at)
|
|
VALUES (?, 'open', ?, ?, ?, ?, 'R', ?, ?)
|
|
`).run(id, user.id, user.username, user.role, JSON.stringify(Array(WIDTH * HEIGHT).fill("")), now, now);
|
|
broadcast(ctx, id);
|
|
return { game: serialize(getGame(ctx, id), user.id) };
|
|
},
|
|
|
|
startClientMatch(ctx, params, meta) {
|
|
const user = caller(meta);
|
|
const clientId = typeof params?.clientId === "string" ? params.clientId.trim() : "";
|
|
if (!clientId || clientId.length > 256) throw new Error("Invalid client");
|
|
const clientName = typeof params?.clientName === "string" && params.clientName.trim()
|
|
? params.clientName.trim().slice(0, 96)
|
|
: clientId;
|
|
const existing = ctx.db.prepare(`
|
|
SELECT id FROM connect_four_games
|
|
WHERE status = 'active' AND player_r_id = ? AND client_id = ?
|
|
ORDER BY updated_at DESC LIMIT 1
|
|
`).get(user.id, clientId);
|
|
if (existing) return { game: serialize(getGame(ctx, existing.id), user.id) };
|
|
const active = currentGame(ctx, user.id);
|
|
if (active) {
|
|
const activeGame = getGame(ctx, active.id);
|
|
if (activeGame.status === "open") {
|
|
ctx.db.prepare("UPDATE connect_four_games SET status = 'cancelled', updated_at = ? WHERE id = ?")
|
|
.run(Date.now(), active.id);
|
|
} else {
|
|
throw new Error("Finish your current match first");
|
|
}
|
|
}
|
|
const id = crypto.randomUUID();
|
|
const now = Date.now();
|
|
ctx.db.prepare(`
|
|
INSERT INTO connect_four_games
|
|
(id, status, player_r_id, player_r_name, player_r_role, player_y_name,
|
|
player_y_role, client_id, board, turn, created_at, updated_at)
|
|
VALUES (?, 'active', ?, ?, ?, ?, 'client', ?, ?, 'R', ?, ?)
|
|
`).run(id, user.id, user.username, user.role, clientName, clientId, JSON.stringify(Array(WIDTH * HEIGHT).fill("")), now, now);
|
|
broadcast(ctx, id);
|
|
return { game: serialize(getGame(ctx, id), user.id) };
|
|
},
|
|
|
|
acceptChallenge(ctx, params, meta) {
|
|
const user = caller(meta);
|
|
const gameId = typeof params?.gameId === "string" ? params.gameId : "";
|
|
const active = currentGame(ctx, user.id);
|
|
if (active) throw new Error("Finish your current match first");
|
|
const updated = ctx.db.prepare(`
|
|
UPDATE connect_four_games
|
|
SET status = 'active', player_y_id = ?, player_y_name = ?, player_y_role = ?, updated_at = ?
|
|
WHERE id = ? AND status = 'open' AND player_r_id != ?
|
|
`).run(user.id, user.username, user.role, Date.now(), gameId, user.id);
|
|
if (updated.changes !== 1) throw new Error("Challenge is no longer available");
|
|
broadcast(ctx, gameId);
|
|
return { game: serialize(getGame(ctx, gameId), user.id) };
|
|
},
|
|
|
|
move(ctx, params, meta) {
|
|
const user = caller(meta);
|
|
const gameId = typeof params?.gameId === "string" ? params.gameId : "";
|
|
const game = getGame(ctx, gameId);
|
|
const color = Number(game?.playerRId) === user.id ? "R" : Number(game?.playerYId) === user.id ? "Y" : "";
|
|
if (!color) throw new Error("You are not in this match");
|
|
return { game: serialize(applyMove(ctx, game, color, Number(params?.column)), user.id) };
|
|
},
|
|
},
|
|
};
|