feat(ttt): match history done

This commit is contained in:
Maix0 2026-01-06 23:44:02 +01:00 committed by Maix0
parent 8f3ed71d8a
commit 2bf5e6e700
17 changed files with 1185 additions and 18 deletions

View file

@ -21,11 +21,12 @@ CREATE UNIQUE INDEX IF NOT EXISTS idx_blocked_user_pair ON blocked (user, blocke
CREATE TABLE IF NOT EXISTS tictactoe (
id TEXT PRIMARY KEY NOT NULL,
player1 TEXT NOT NULL,
player2 TEXT NOT NULL,
time TEXT NOT NULL default (datetime('now')),
playerX TEXT NOT NULL,
playerO TEXT NOT NULL,
outcome TEXT NOT NULL,
FOREIGN KEY(player1) REFERENCES user(id),
FOREIGN KEY(player2) REFERENCES user(id)
FOREIGN KEY(playerX) REFERENCES user(id),
FOREIGN KEY(playerO) REFERENCES user(id)
);
CREATE TABLE IF NOT EXISTS pong (

View file

@ -1,12 +1,19 @@
import UUID from '@shared/utils/uuid';
import type { Database } from './_base';
import { UserId } from './user';
import { isNullish } from '@shared/utils';
export type TicTacToeOutcome = 'winX' | 'winO' | 'other';
// describe every function in the object
export interface ITicTacToeDb extends Database {
setTTTGameOutcome(this: ITicTacToeDb, id: TTTGameId, player1: UserId, player2: UserId, outcome: string): void,
setTTTGameOutcome(this: ITicTacToeDb, id: TTTGameId, player1: UserId, player2: UserId, outcome: TicTacToeOutcome): void,
getAllTTTGameForUser(
this: ITicTacToeDb,
id: UserId,
): (TicTacToeGame & { nameX: string, nameO: string })[],
};
export const TicTacToeImpl: Omit<ITicTacToeDb, keyof Database> = {
/**
* @brief Write the outcome of the specified game to the database.
@ -14,21 +21,81 @@ export const TicTacToeImpl: Omit<ITicTacToeDb, keyof Database> = {
* @param gameId The game we want to write the outcome of.
*
*/
setTTTGameOutcome(this: ITicTacToeDb, id: TTTGameId, player1: UserId, player2: UserId, outcome: string): void {
setTTTGameOutcome(this: ITicTacToeDb, id: TTTGameId, playerX: UserId, playerO: UserId, outcome: TicTacToeOutcome): void {
// Find a way to retrieve the outcome of the game.
this.prepare('INSERT INTO tictactoe (id, player1, player2, outcome) VALUES (@id, @player1, @player2, @outcome)').run({ id, player1, player2, outcome });
this.prepare('INSERT INTO tictactoe (id, playerX, playerO, outcome) VALUES (@id, @playerX, @playerO, @outcome)').run({ id, playerX, playerO, outcome });
},
getAllTTTGameForUser(
this: ITicTacToeDb,
id: UserId,
): (TicTacToeGame & { nameX: string, nameO: string })[] {
const q = this.prepare(`
SELECT
tictactoe.*,
userX.name AS nameX,
userO.name AS nameO
FROM tictactoe
INNER JOIN user AS userX
ON tictactoe.playerX = userX.id
INNER JOIN user AS userO
ON tictactoe.playerO = userO.id
WHERE
tictactoe.playerX = @id
OR tictactoe.playerO = @id;
`);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return q.all({ id }).map((s: any) => {
const g: (TicTacToeGame & { nameX?: string, nameO?: string }) | undefined = TicTacToeGameFromRow(s);
if (isNullish(g)) return undefined;
g.nameX = s.nameX;
g.nameO = s.nameO;
if (isNullish(g.nameO) || isNullish(g.nameO)) return undefined;
return g as TicTacToeGame & { nameX: string, nameO: string };
}).filter(v => !isNullish(v));
}
};
export type TTTGameId = UUID & { readonly __brand: unique symbol };
export type TTTGameId = UUID & { readonly __uuid: unique symbol };
export type TicTacToeGame = {
readonly id: TTTGameId;
readonly player1: UserId;
readonly player2: UserId;
readonly outcome: string;
readonly time: Date;
readonly playerX: UserId;
readonly playerO: UserId;
readonly outcome: TicTacToeOutcome;
};
type TicTacToeGameTable = {
id: string;
time: string;
playerX: UserId;
playerO: UserId;
outcome: string;
};
function TicTacToeGameFromRow(r: Partial<TicTacToeGameTable> | undefined): TicTacToeGame | undefined {
if (isNullish(r)) return undefined;
if (isNullish(r.id)) return undefined;
if (isNullish(r.playerX)) return undefined;
if (isNullish(r.playerO)) return undefined;
if (isNullish(r.outcome)) return undefined;
if (isNullish(r.time)) return undefined;
if (r.outcome !== 'winX' && r.outcome !== 'winO' && r.outcome !== 'other') return undefined;
const date = Date.parse(r.time);
if (Number.isNaN(date)) return undefined;
return {
id: r.id as TTTGameId,
playerX: r.playerX,
playerO: r.playerO,
outcome: r.outcome,
time: new Date(date),
};
}
// this function will be able to be called from everywhere
// export async function freeFloatingExportedFunction(): Promise<boolean> {
// return false;

View file

@ -1917,6 +1917,202 @@
]
}
},
"/api/ttt/history/{user}": {
"get": {
"operationId": "tttHistory",
"parameters": [
{
"schema": {
"type": "string"
},
"in": "path",
"name": "user",
"required": true,
"description": "'me' | <userid>"
}
],
"responses": {
"200": {
"description": "Default Response",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"kind",
"msg",
"payload"
],
"properties": {
"kind": {
"enum": [
"success"
]
},
"msg": {
"enum": [
"ttthistory.success"
]
},
"payload": {
"type": "object",
"required": [
"data"
],
"properties": {
"data": {
"type": "array",
"items": {
"type": "object",
"required": [
"gameId",
"playerX",
"playerO",
"date",
"outcome"
],
"properties": {
"gameId": {
"type": "string",
"description": "gameId"
},
"playerX": {
"type": "object",
"required": [
"id",
"name"
],
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
}
}
},
"playerO": {
"type": "object",
"required": [
"id",
"name"
],
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
}
}
},
"date": {
"type": "string"
},
"outcome": {
"enum": [
"winX",
"winO",
"other"
]
}
}
}
}
}
}
}
}
}
}
},
"401": {
"description": "Default Response",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"type": "object",
"required": [
"kind",
"msg"
],
"properties": {
"kind": {
"enum": [
"notLoggedIn"
]
},
"msg": {
"enum": [
"auth.noCookie",
"auth.invalidKind",
"auth.noUser",
"auth.invalid"
]
}
}
},
{
"type": "object",
"required": [
"kind",
"msg"
],
"properties": {
"kind": {
"enum": [
"notLoggedIn"
]
},
"msg": {
"enum": [
"auth.noCookie",
"auth.invalidKind",
"auth.noUser",
"auth.invalid"
]
}
}
}
]
}
}
}
},
"404": {
"description": "Default Response",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"kind",
"msg"
],
"properties": {
"kind": {
"enum": [
"failure"
]
},
"msg": {
"enum": [
"ttthistory.failure.notfound"
]
}
}
}
}
}
}
},
"tags": [
"openapi_other"
]
}
},
"/api/pong/history/{user}": {
"get": {
"operationId": "pongHistory",

View file

@ -7,7 +7,201 @@
"components": {
"schemas": {}
},
"paths": {},
"paths": {
"/api/ttt/history/{user}": {
"get": {
"operationId": "tttHistory",
"parameters": [
{
"schema": {
"type": "string"
},
"in": "path",
"name": "user",
"required": true,
"description": "'me' | <userid>"
}
],
"responses": {
"200": {
"description": "Default Response",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"kind",
"msg",
"payload"
],
"properties": {
"kind": {
"enum": [
"success"
]
},
"msg": {
"enum": [
"ttthistory.success"
]
},
"payload": {
"type": "object",
"required": [
"data"
],
"properties": {
"data": {
"type": "array",
"items": {
"type": "object",
"required": [
"gameId",
"playerX",
"playerO",
"date",
"outcome"
],
"properties": {
"gameId": {
"type": "string",
"description": "gameId"
},
"playerX": {
"type": "object",
"required": [
"id",
"name"
],
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
}
}
},
"playerO": {
"type": "object",
"required": [
"id",
"name"
],
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
}
}
},
"date": {
"type": "string"
},
"outcome": {
"enum": [
"winX",
"winO",
"other"
]
}
}
}
}
}
}
}
}
}
}
},
"401": {
"description": "Default Response",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"type": "object",
"required": [
"kind",
"msg"
],
"properties": {
"kind": {
"enum": [
"notLoggedIn"
]
},
"msg": {
"enum": [
"auth.noCookie",
"auth.invalidKind",
"auth.noUser",
"auth.invalid"
]
}
}
},
{
"type": "object",
"required": [
"kind",
"msg"
],
"properties": {
"kind": {
"enum": [
"notLoggedIn"
]
},
"msg": {
"enum": [
"auth.noCookie",
"auth.invalidKind",
"auth.noUser",
"auth.invalid"
]
}
}
}
]
}
}
}
},
"404": {
"description": "Default Response",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"kind",
"msg"
],
"properties": {
"kind": {
"enum": [
"failure"
]
},
"msg": {
"enum": [
"ttthistory.failure.notfound"
]
}
}
}
}
}
}
}
}
}
},
"servers": [
{
"url": "https://local.maix.me:8888",

View file

@ -0,0 +1,58 @@
import { UserId } from '@shared/database/mixin/user';
import { isNullish, MakeStaticResponse, typeResponse } from '@shared/utils';
import { FastifyPluginAsync } from 'fastify';
import { Static, Type } from 'typebox';
const TTTHistoryParams = Type.Object({
user: Type.String({ description: '\'me\' | <userid>' }),
});
type TTTHistoryParams = Static<typeof TTTHistoryParams>;
const TTTHistoryResponse = {
'200': typeResponse('success', 'ttthistory.success', {
data: Type.Array(
Type.Object({
gameId: Type.String({ description: 'gameId' }),
playerX: Type.Object({id: Type.String(), name: Type.String()}),
playerO: Type.Object({id: Type.String(), name: Type.String()}),
date: Type.String(),
outcome: Type.Enum(['winX', 'winO', 'other']),
}),
),
}),
'404': typeResponse('failure', 'ttthistory.failure.notfound'),
};
type TTTHistoryResponse = MakeStaticResponse<typeof TTTHistoryResponse>;
const route: FastifyPluginAsync = async (fastify): Promise<void> => {
fastify.get<{ Params: TTTHistoryParams }>(
'/api/ttt/history/:user',
{
schema: {
params: TTTHistoryParams,
response: TTTHistoryResponse,
operationId: 'tttHistory',
},
config: { requireAuth: true },
},
async function(req, res) {
if (req.params.user === 'me') { req.params.user = req.authUser!.id; }
const user = this.db.getUser(req.params.user);
if (isNullish(user)) { return res.makeResponse(404, 'failure', 'ttthistory.failure.notfound'); }
const data = this.db.getAllTTTGameForUser(req.params.user as UserId);
if (isNullish(data)) { return res.makeResponse(404, 'failure', 'ttthistory.failure.notfound'); }
return res.makeResponse(200, 'success', 'ttthistory.success', {
data: data.map(v => ({
gameId: v.id,
playerX: { id: v.playerX, name: v.nameX },
playerO: { id: v.playerO, name: v.nameO },
date: v.time.toString(),
outcome: v.outcome,
})),
});
},
);
};
export default route;