Files
website/server/game-service.ts
T
2024-07-02 18:36:05 -04:00

324 lines
8.9 KiB
TypeScript

import { Game, PreGame } from "./types.ts";
import {
assertEquals,
assertNotEquals,
} from "https://deno.land/std@0.224.0/assert/mod.ts";
type GamePrimaryKey = ["phenix", "games", string];
type date = [number, number, number];
type time = [number, number];
type DateIndex = ["phenix", "indexes", "game-date", ...date, ...time];
export class GameService {
readonly kv: Deno.Kv;
private readonly DATE_VALIDATOR =
/^(?<year>\d{4})-(?<month>\d{1,2})-(?<day>\d{1,2})$/;
private readonly TIME_VALIDATOR = /^(?<hour>\d{1,2})(:|h)(?<minute>\d{1,2})$/;
private readonly VALID_TEAMS: string[] = [
"jaune",
"rouge",
"bleu",
"gris",
"vert",
"noir",
"orange",
"mauve",
];
constructor(kv: Deno.Kv) {
this.kv = kv;
}
private chronologicalGameOrder(game1: Game, game2: Game): number {
if (game1.date < game2.date) {
return 1;
}
if (game1.date > game2.date) {
return -1;
}
if (game1.time < game2.time) {
return 1;
}
if (game1.time > game2.time) {
return -1;
}
return 0;
}
async getAllGames(): Promise<Game[]> {
const ite = this.kv.list<Game>({ prefix: ["phenix", "games"] });
return (await Array.fromAsync(ite))
.map((e) => e.value)
.sort(this.chronologicalGameOrder);
}
private gamePrimaryKey(id: string): GamePrimaryKey {
return ["phenix", "games", id];
}
private dateIndex(date: string, time: string): DateIndex | null {
let destructedDate = this.DATE_VALIDATOR.exec(date)?.groups;
let destructedTime = this.TIME_VALIDATOR.exec(time)?.groups;
if (!destructedDate || !destructedTime) {
return null;
}
return [
"phenix",
"indexes",
"game-date",
parseInt(destructedDate.year),
parseInt(destructedDate.month),
parseInt(destructedDate.day),
parseInt(destructedTime.hour),
parseInt(destructedTime.minute),
];
}
private cleanTime(raw: string): string {
const groups = this.TIME_VALIDATOR.exec(raw)!.groups!;
return `${groups.hour.padStart(2, "0")}:${groups.minute.padStart(2, "0")}`;
}
private cleanDate(raw: string): string {
const groups = this.DATE_VALIDATOR.exec(raw)!.groups!;
return `${groups.year}-${groups.month.padStart(2, "0")}-${groups.day.padStart(2, "0")}`;
}
private extractYearFromDate(date: string): number {
return parseInt(this.DATE_VALIDATOR.exec(date)!.groups!.year);
}
private createGame(preGame: PreGame): Game {
const ret = {
...preGame,
id: crypto.randomUUID(),
practice: false,
rain: false,
year: this.extractYearFromDate(preGame.date),
homeScore: null,
awayScore: null,
};
ret.time = this.cleanTime(ret.time);
ret.date = this.cleanDate(ret.date);
return ret;
}
private validatePregate(preGame: PreGame): string {
if (!this.DATE_VALIDATOR.test(preGame.date)) {
return `Invalide date format ${preGame.date}`;
}
if (!this.TIME_VALIDATOR.test(preGame.time)) {
return `Invalide time format ${preGame.time}`;
}
if (!this.VALID_TEAMS.includes(preGame.away)) {
return `Invalide away format ${preGame.away}`;
}
if (!this.VALID_TEAMS.includes(preGame.home)) {
return `Invalide home format ${preGame.home}`;
}
if (preGame.home === preGame.away) {
return `Home and away teams must be different (received ${preGame.home} for both)`;
}
return "";
}
async createNewGame(preGame: PreGame): Promise<[Game | null, string]> {
if (this.validatePregate(preGame) !== "") {
return [null, this.validatePregate(preGame)];
}
const game = this.createGame(preGame);
const dateIndex = this.dateIndex(game.date, game.time);
if (!dateIndex) {
return [null, "Cound not parse date or time"];
}
const precondition = await this.kv.get<string>(dateIndex);
if (precondition.value !== null) {
return [null, "game already exists " + precondition.value];
}
const res = await this.kv
.atomic()
.check(precondition)
.set(this.gamePrimaryKey(game.id), game)
.set(dateIndex, game.id)
.commit();
if (!res.ok) {
return [null, "Transaction failed"];
}
return [game, ""];
}
async getGameById(id: string): Promise<Game | null> {
const res = await this.kv.get<Game>(this.gamePrimaryKey(id));
return res.value;
}
async deleteGameById(id: string): Promise<boolean> {
const precond = await this.kv.get<Game>(this.gamePrimaryKey(id));
if (precond.value === null) {
return false;
}
const res = await this.kv
.atomic()
.check(precond)
.delete(this.gamePrimaryKey(precond.value.id))
.delete(this.dateIndex(precond.value.date, precond.value.time)!)
.commit();
return res.ok;
}
async updateGame(id: string, partial: Partial<Game>): Promise<boolean> {
delete partial.id;
const precond = await this.kv.get<Game>(this.gamePrimaryKey(id));
if (precond.value === null) {
return false;
}
const res = await this.kv
.atomic()
.check(precond)
.delete(this.gamePrimaryKey(precond.value.id))
.delete(this.dateIndex(precond.value.date, precond.value.time)!)
.set(
this.dateIndex(precond.value.date, precond.value.time)!,
precond.value.id,
)
.set(this.gamePrimaryKey(precond.value.id), {
...precond.value,
...partial,
})
.commit();
return res.ok;
}
}
Deno.test("saving simple games", async (t) => {
const kv = await Deno.openKv();
const service = new GameService(kv);
async function deleteAllKeys() {
const keys = kv.list({ prefix: [] });
for await (const entry of keys) {
await kv.delete(entry.key);
}
}
function validePreGame(seed: number): PreGame {
let year = ((645 * seed + 162) % 10000) + 1;
let month = ((335 * seed + 71) % 12) + 1;
let day = ((72625 * seed + 176) % 28) + 1;
return {
date: `${(year + "").padStart(4, "0")}-${(month + "").padStart(2, "0")}-${(day + "").padStart(2, "0")}`,
time: "18:45",
away: "jaune",
home: "rouge",
};
}
await deleteAllKeys();
await t.step("creating games", async () => {
const preGame = validePreGame(7276);
const [game, error] = await service.createNewGame(preGame);
assertEquals(error, "");
const ret = await service.getGameById(game!.id);
assertEquals(game, ret);
});
await deleteAllKeys();
await t.step("creating games with old date format", async () => {
const preGame = validePreGame(7276);
preGame.date = "2017-8-5";
const [game, error] = await service.createNewGame(preGame);
assertEquals(error, "");
assertEquals(game?.date, "2017-08-05");
});
await deleteAllKeys();
await t.step("creating games with old time format", async () => {
const preGame = validePreGame(7276);
preGame.time = "9:00";
const [game, error] = await service.createNewGame(preGame);
assertEquals(error, "");
assertEquals(game?.time, "09:00");
});
await deleteAllKeys();
await t.step(
"saving two games in the same timeslot should fail",
async () => {
const preGame = validePreGame(7276);
let [_, error] = await service.createNewGame(preGame);
assertEquals(error, "");
[_, error] = await service.createNewGame(preGame);
assertNotEquals(error, "");
},
);
await deleteAllKeys();
await t.step("saving games with bad fields", async () => {
let i = 0;
for (const key of Object.keys(validePreGame(7276))) {
let [_, error] = await service.createNewGame({
...validePreGame(7262 + i),
[key]: `Broken ${key}`,
});
assertNotEquals(error, "");
i++;
}
});
await t.step("saving games with the same home and away teams", async () => {
const preGame = validePreGame(7276);
preGame.home = preGame.away;
let [_, error] = await service.createNewGame(preGame);
assertNotEquals(error, "");
});
await deleteAllKeys();
await t.step("listing games", async () => {
for (let i = 0; i < 10; i++) {
await service.createNewGame(validePreGame(666 + i));
}
assertEquals((await service.getAllGames()).length, 10);
});
await deleteAllKeys();
await t.step("deleting games", async () => {
let [game, error] = await service.createNewGame(validePreGame(8992));
await service.deleteGameById(game!.id);
assertEquals((await service.getAllGames()).length, 0);
[game, error] = await service.createNewGame(validePreGame(8992));
assertEquals(error, "");
});
await deleteAllKeys();
await t.step("updating game scores", async () => {
let [game, _] = await service.createNewGame(validePreGame(8992));
const ret = await service.updateGame(game!.id, {
homeScore: 10,
awayScore: 32,
});
assertEquals(ret, true);
game = await service.getGameById(game!.id);
assertEquals(game!.homeScore, 10);
assertEquals(game!.awayScore, 32);
});
await deleteAllKeys();
await kv.close();
});