feat: use d1 for game storage

This commit is contained in:
uku 2024-11-21 17:12:29 +01:00
parent ee5ed04bcb
commit 5423802ae5
Signed by: uku
SSH key fingerprint: SHA256:4P0aN6M8ajKukNi6aPOaX0LacanGYtlfjmN+m/sHY/o
11 changed files with 133 additions and 96 deletions

1
src/app.d.ts vendored
View file

@ -13,7 +13,6 @@ declare global {
}
interface Platform {
env?: {
TCL_GUESSR_KV: KVNamespace;
TCL_GUESSR_D1: D1Database;
};
}

View file

@ -1,7 +1,5 @@
import { error } from "@sveltejs/kit";
import type { CheckResponse, ClientGameData, GameOptions, ServerGameData } from "./types";
type FetchType = typeof fetch;
import type { CheckResponse, ClientGameData, GameOptions } from "./types";
const metroUrl =
"https://data.grandlyon.com/geoserver/sytral/ows?SERVICE=WFS&VERSION=2.0.0&request=GetFeature&typename=sytral:tcl_sytral.tcllignemf_2_0_0&outputFormat=application/json&SRSNAME=EPSG:4171&sortBy=gid";
@ -15,21 +13,25 @@ let lazyTram: [GeoJSON.Feature, string][] | null = null;
let lazyStops: GeoJSON.Feature[] | null = null;
export async function createGame(
userId: string,
mode: string,
stops: GeoJSON.Feature[],
kv: KVNamespace<string>,
db: D1Database,
): Promise<ClientGameData> {
const uuid = crypto.randomUUID();
const stopNames = stops.map((s) => s.properties!.nom);
const serverData: ServerGameData = {
mode,
stops,
distances: new Array(stops.length).fill(-1),
scores: new Array(stops.length).fill(-1),
};
const gameSql = db
.prepare(
"INSERT INTO game (id, user_id, mode, time, total_score) VALUES (?, ?, ?, unixepoch('now'), 0)",
)
.bind(uuid, userId, mode);
await kv.put(`game:${uuid}`, JSON.stringify(serverData), { expirationTtl: 600 });
const roundSql = db.prepare(
"INSERT INTO round (game_id, points, distance, stop_name) VALUES (?, -1, -1, ?)",
);
await db.batch([gameSql, ...stopNames.map((s) => roundSql.bind(uuid, s))]);
return {
gameId: uuid,
@ -39,36 +41,42 @@ export async function createGame(
export async function saveScore(
uuid: string,
index: number,
stopName: string,
distanceCalc: (latlng: [number, number]) => number,
scoreCalc: (dist: number) => number,
kv: KVNamespace<string>,
db: D1Database,
): Promise<CheckResponse | null> {
const serverData: ServerGameData | null = await kv.get(`game:${uuid}`, "json");
if (!serverData) return null;
const stop = await findStopByName(stopName);
if (stop === null) return null;
if (serverData.scores[index] != -1) {
return null;
} else {
const coords = (serverData.stops[index].geometry as GeoJSON.Point).coordinates;
const latlng: [number, number] = [coords[1], coords[0]];
const row = await db
.prepare("SELECT id, game_id, stop_name, points FROM round WHERE game_id = ? AND stop_name = ?")
.bind(uuid, stopName)
.first();
const distance = Math.round(distanceCalc(latlng));
const score = Math.round(scoreCalc(distance));
if (row === null || row.points !== -1) return null;
serverData.distances[index] = distance;
serverData.scores[index] = score;
await kv.put(`game:${uuid}`, JSON.stringify(serverData), { expirationTtl: 600 });
const coords = (stop.geometry as GeoJSON.Point).coordinates;
const latlng: [number, number] = [coords[1], coords[0]];
return {
distance,
score,
solution: latlng,
};
}
const distance = Math.round(distanceCalc(latlng));
const score = Math.round(scoreCalc(distance));
await db.batch([
db
.prepare("UPDATE round SET points = ?, distance = ? WHERE id = ?")
.bind(score, distance, row.id),
db.prepare("UPDATE game SET total_score = total_score + ? WHERE id = ?").bind(score, uuid),
]);
return {
distance,
score,
solution: latlng,
};
}
export async function getMetro(fetch: FetchType): Promise<[GeoJSON.Feature, string][]> {
export async function getMetro(): Promise<[GeoJSON.Feature, string][]> {
if (!lazyMetro) {
const metro: GeoJSON.FeatureCollection = await fetch(metroUrl).then((r) => r.json());
@ -81,7 +89,7 @@ export async function getMetro(fetch: FetchType): Promise<[GeoJSON.Feature, stri
return lazyMetro;
}
export async function getTram(fetch: FetchType): Promise<[GeoJSON.Feature, string][]> {
export async function getTram(): Promise<[GeoJSON.Feature, string][]> {
if (!lazyTram) {
const tram: GeoJSON.FeatureCollection = await fetch(tramUrl).then((r) => r.json());
@ -94,7 +102,7 @@ export async function getTram(fetch: FetchType): Promise<[GeoJSON.Feature, strin
return lazyTram;
}
async function getStops(fetch: FetchType): Promise<GeoJSON.Feature[]> {
async function getStops(): Promise<GeoJSON.Feature[]> {
if (!lazyStops) {
const stops: GeoJSON.FeatureCollection = await fetch(stopsUrl).then((r) => r.json());
lazyStops = stops.features;
@ -103,11 +111,8 @@ async function getStops(fetch: FetchType): Promise<GeoJSON.Feature[]> {
return lazyStops;
}
export async function getMergedStops(
fetch: FetchType,
lineCodes: Set<string>,
): Promise<GeoJSON.Feature[]> {
const stops = await getStops(fetch);
export async function getMergedStops(lineCodes: Set<string>): Promise<GeoJSON.Feature[]> {
const stops = await getStops();
const crossingStops = stops.filter((f) => {
if (f.properties?.desserte) {
@ -124,26 +129,37 @@ export async function getMergedStops(
const mergedStops = Array.from(new Set(crossingStops.map((f) => f.properties?.nom))).map(
(nom) => {
const matching = crossingStops.filter((f) => f.properties?.nom === nom);
const longs = matching.map((f) => (f.geometry as GeoJSON.Point).coordinates[0]);
const lonAvg = longs.reduce((a, b) => a + b) / longs.length;
const lats = matching.map((f) => (f.geometry as GeoJSON.Point).coordinates[1]);
const latAvg = lats.reduce((a, b) => a + b) / lats.length;
const desserte = matching.map((f) => f.properties?.desserte).join(",");
const feature = matching[0];
feature.geometry = { type: "Point", coordinates: [lonAvg, latAvg] };
if (feature.properties) feature.properties.desserte = desserte;
return feature;
return mergeStops(matching)!;
},
);
return mergedStops;
}
async function findStopByName(name: string): Promise<GeoJSON.Feature | null> {
const stops = await getStops();
return mergeStops(stops.filter((f) => f.properties?.nom === name));
}
function mergeStops(stops: GeoJSON.Feature[]): GeoJSON.Feature | null {
if (stops.length === 0) return null;
const longs = stops.map((f) => (f.geometry as GeoJSON.Point).coordinates[0]);
const lonAvg = longs.reduce((a, b) => a + b) / longs.length;
const lats = stops.map((f) => (f.geometry as GeoJSON.Point).coordinates[1]);
const latAvg = lats.reduce((a, b) => a + b) / lats.length;
const desserte = stops.map((f) => f.properties?.desserte).join(",");
const feature = stops[0];
feature.geometry = { type: "Point", coordinates: [lonAvg, latAvg] };
if (feature.properties) feature.properties.desserte = desserte;
return feature;
}
export function parseOptions(url: URL): GameOptions {
const mode = url.searchParams.get("mode");
if (mode !== "easy" && mode !== "hard" && mode !== "extreme demon ultra miguel") {

View file

@ -9,13 +9,6 @@ export interface MapData {
stops: [number, number][];
}
export interface ServerGameData {
mode: string;
stops: GeoJSON.Feature[];
distances: number[];
scores: number[];
}
export interface ClientGameData {
stopNames: string[];
gameId: string;
@ -23,7 +16,7 @@ export interface ClientGameData {
export interface CheckData {
gameId: string;
index: number;
stopName: string;
latlng: [number, number];
}

View file

@ -4,23 +4,23 @@ import type { RequestHandler } from "./$types";
import type { CheckData } from "$lib/types";
export const POST: RequestHandler = async ({ request, platform }) => {
const kv = platform?.env?.TCL_GUESSR_KV;
if (!kv) error(500, "could not connect to kv");
const db = platform?.env?.TCL_GUESSR_D1;
if (!db) error(500, "could not connect to d1");
const data: CheckData = await request.json();
const solution = await saveScore(
data.gameId,
data.index,
data.stopName,
(solution) => calcDistance(data.latlng, solution),
(distance) => calculatePoints(distance),
kv,
db,
);
if (solution) {
return Response.json(solution);
} else {
return new Response(null, { status: 404 });
return error(404);
}
};

View file

@ -2,25 +2,28 @@ import { error } from "@sveltejs/kit";
import type { RequestHandler } from "./$types";
import { createGame, getMergedStops, getMetro, getTram, parseOptions, sample } from "$lib";
export const GET: RequestHandler = async ({ fetch, url, platform }) => {
const kv = platform?.env?.TCL_GUESSR_KV;
if (!kv) error(500, "could not connect to kv");
export const GET: RequestHandler = async ({ url, platform, locals }) => {
const db = platform?.env?.TCL_GUESSR_D1 ?? null;
if (db === null) error(500, "could not connect to d1");
const user = locals.user;
if (user === null) error(401, "not logged in");
const options = parseOptions(url);
const lineColors: [GeoJSON.Feature, string][] = [];
if (options.metro) lineColors.push(...(await getMetro(fetch)));
if (options.tram) lineColors.push(...(await getTram(fetch)));
if (options.metro) lineColors.push(...(await getMetro()));
if (options.tram) lineColors.push(...(await getTram()));
const lineCodes = new Set<string>(lineColors.map(([f]) => f.properties!.code_ligne));
const crossingStops = await getMergedStops(fetch, lineCodes);
const crossingStops = await getMergedStops(lineCodes);
const randomStops = sample(crossingStops, 5);
if (!randomStops) {
error(400, "could not select random stop");
}
const gameData = await createGame(options.mode, randomStops, kv);
const gameData = await createGame(user.id, options.mode, randomStops, db);
return Response.json(gameData);
};

View file

@ -2,21 +2,21 @@ import { getMergedStops, getMetro, getTram, parseOptions } from "$lib";
import type { MapData } from "$lib/types";
import type { RequestHandler } from "./$types";
export const GET: RequestHandler = async ({ fetch, url }) => {
export const GET: RequestHandler = async ({ url }) => {
const options = parseOptions(url);
const mapData: MapData = { lines: [], stops: [] };
if (options.mode !== "extreme demon ultra miguel") {
const lineColors: [GeoJSON.Feature, string][] = [];
if (options.metro) lineColors.push(...(await getMetro(fetch)));
if (options.tram) lineColors.push(...(await getTram(fetch)));
if (options.metro) lineColors.push(...(await getMetro()));
if (options.tram) lineColors.push(...(await getTram()));
mapData.lines = lineColors;
if (options.mode === "easy") {
const lineCodes = new Set<string>(lineColors.map(([f]) => f.properties!.code_ligne));
const crossingStops = await getMergedStops(fetch, lineCodes);
const crossingStops = await getMergedStops(lineCodes);
const strippedStops: [number, number][] = crossingStops.map((f) => {
const coords = (f.geometry as GeoJSON.Point).coordinates;

View file

@ -71,7 +71,7 @@
const checkData: CheckData = {
gameId: gameData.gameId,
index: currentIndex,
stopName: gameData.stopNames[currentIndex],
latlng: [playerMarker.getLatLng().lat, playerMarker.getLatLng().lng],
};

View file

@ -1,16 +1,27 @@
import type { PageServerLoad } from "./$types";
import { error } from "@sveltejs/kit";
import type { ServerGameData } from "$lib/types";
interface JoinedRoundScore {
mode: string;
total_score: number;
points: number;
distance: number;
stop_name: string;
}
export const load: PageServerLoad = async ({ url, platform }) => {
const kv = platform?.env?.TCL_GUESSR_KV;
if (!kv) error(500, "could not connect to kv");
const db = platform?.env?.TCL_GUESSR_D1;
if (!db) error(500, "could not connect to d1");
const gameId = url.searchParams.get("gameId");
if (!gameId) error(400, "gameId was not specified");
const gameData: ServerGameData | null = await kv.get(`game:${gameId}`, "json");
if (!gameData) error(404, "could not fetch game data");
const { results } = await db
.prepare(
"SELECT game.mode, game.total_score, round.points, round.distance, round.stop_name FROM game INNER JOIN round ON round.game_id = game.id WHERE game.id = ?;",
)
.bind(gameId)
.all<JoinedRoundScore>();
return { gameData, gameId };
return { rounds: results, gameId };
};

View file

@ -7,9 +7,9 @@
}
const props: Props = $props();
const gameData = props.data.gameData;
const totalScore = gameData.scores.reduce((a, b) => a + b);
const totalScore = props.data.rounds[0].total_score;
const mode = props.data.rounds[0].mode;
</script>
<div class="container">
@ -17,15 +17,15 @@
<h2>score total: {totalScore}</h2>
<span>mode: {gameData.mode}</span>
<span>mode: {mode}</span>
<table>
<tbody>
{#each gameData.stops as stop, i}
{#each props.data.rounds as round}
<tr>
<td>{stop.properties!.nom}</td>
<td>{gameData.scores[i]} points</td>
<td>{gameData.distances[i]} metres</td>
<td>{round.stop_name}</td>
<td>{round.points} points</td>
<td>{round.distance} metres</td>
</tr>
{/each}
</tbody>