add turns and update token logic.

This commit is contained in:
2025-02-17 15:21:03 -08:00
parent f413b74a1f
commit c08a15f01f
34 changed files with 698 additions and 151 deletions

View File

@ -4,7 +4,7 @@ import type { State } from "./State";
export interface GameData { export interface GameData {
isStarted: boolean; isStarted: boolean;
players: Id[]; players: { id: Id; username: string }[];
state: State; state: State;
} }
@ -17,7 +17,11 @@ export function isGameData(target: unknown): target is GameData {
const { players } = target as any; const { players } = target as any;
for (const player of players) { for (const player of players) {
if (!isId(player)) { if (!isId(player.id)) {
return false;
}
if (!hasProperty(player, "username", "string")) {
return false; return false;
} }
} }

View File

@ -98,10 +98,8 @@ export class SeatPlayers implements GameEvent {
/** /**
* RollForFirst takes a player index and a value which represents the pips on the die * RollForFirst takes a player index and a value which represents the pips on the die
* that the player rolled. It represents and attempt to roll the highest die and go * that the player rolled. It represents an attempt to roll the highest die and go
* first. * first.
*axpected to re-roll as a tie breaker. Re-rolling continues until someone
* wins.
*/ */
export class RollForFirst implements GameEvent { export class RollForFirst implements GameEvent {
kind: GameEventKind.RollForFirst; kind: GameEventKind.RollForFirst;
@ -279,7 +277,7 @@ export class Hold implements GameEvent {
// Detect two threes of a kind: if the number of held values was six, and the // Detect two threes of a kind: if the number of held values was six, and the
// set of unique values is two, then there MUST have been two threes of a // set of unique values is two, then there MUST have been two threes of a
// kind. // kind.
total = 1_500; total = 1_600;
} else { } else {
// A player can use a "push" if they are using every one of their rolled // A player can use a "push" if they are using every one of their rolled
// dice. // dice.

View File

@ -1,19 +1,11 @@
import { ObjectId } from "mongodb"; import { ObjectId } from "mongodb";
export type Id = ObjectId; export type Id = string;
export function createId(): Id { export function createId(): Id {
return new ObjectId(); return new ObjectId().toString();
}
export function idFromString(str: string) {
return new ObjectId(str);
}
export function stringFromId(id: Id) {
return id.toString();
} }
export function isId(target: unknown): target is Id { export function isId(target: unknown): target is Id {
return target instanceof ObjectId; return typeof target === "string";
} }

View File

@ -4,6 +4,7 @@ import { hasProperty } from "$lib/validation";
export type ServerResponse = export type ServerResponse =
| { item: Listing<unknown> } | { item: Listing<unknown> }
| { items: Listing<unknown>[] } | { items: Listing<unknown>[] }
| { access_token: string }
| { error: string }; | { error: string };
export function isServerResponse(target: unknown): target is ServerResponse { export function isServerResponse(target: unknown): target is ServerResponse {
@ -14,5 +15,6 @@ export function isServerResponse(target: unknown): target is ServerResponse {
if (hasProperty(target, "item", "object")) return true; if (hasProperty(target, "item", "object")) return true;
if (hasProperty(target, "items", "object[]")) return true; if (hasProperty(target, "items", "object[]")) return true;
if (hasProperty(target, "error", "string")) return true; if (hasProperty(target, "error", "string")) return true;
if (hasProperty(target, "access_token", "string")) return true;
return false; return false;
} }

View File

@ -0,0 +1,32 @@
<script>
import { getMeContext } from "$lib/meContext";
const { players } = $props();
const me = getMeContext();
</script>
<div class="list">
<ol>
{#each players as player}
{#if me !== null && player.id === me.id}
<li class="you">you</li>
{:else}
<li>{player.username}</li>
{/if}
{/each}
</ol>
</div>
<style>
.you {
font-weight: bold;
}
.list {
border: 1pt gray solid;
background: white;
height: 100%;
width: 100%;
}
</style>

14
src/lib/me.ts Normal file
View File

@ -0,0 +1,14 @@
import { hasProperty } from "./validation";
export interface Me {
id: string;
role: string;
username: string;
}
export function isMe(target: unknown): target is Me {
if (!hasProperty(target, "id", "string")) return false;
if (!hasProperty(target, "role", "string")) return false;
if (!hasProperty(target, "username", "string")) return false;
return true;
}

14
src/lib/meContext.ts Normal file
View File

@ -0,0 +1,14 @@
import { getContext, hasContext, setContext } from "svelte";
import type { Me } from "./me";
export function setMeContext(me: Me) {
setContext("me", me);
}
export function getMeContext(): Me | null {
if (hasContext("me")) {
return getContext("me");
}
return null;
}

View File

@ -3,7 +3,7 @@ import type { GameData } from "../GameData";
import type { State } from "../State"; import type { State } from "../State";
export class Game implements GameData { export class Game implements GameData {
players: Id[]; players: { id: Id; username: string }[];
isStarted: boolean; isStarted: boolean;
state: State; state: State;
@ -13,8 +13,17 @@ export class Game implements GameData {
this.state = {}; this.state = {};
} }
addPlayer(id: Id) { static from(data: GameData) {
this.players.push(id); const game = new Game();
game.players = data.players;
game.isStarted = data.isStarted;
game.state = data.state;
return game;
}
addPlayer(id: Id, username: string) {
this.players.push({ id, username });
} }
start() { start() {

View File

@ -0,0 +1,15 @@
import * as jwt from "jsonwebtoken";
import { hasProperty } from "../validation";
export type ServerJwtPayload = jwt.JwtPayload & {
username: string;
role: string;
sub: string;
};
export function isServerJwtPayload(target: unknown): target is ServerJwtPayload {
if (!hasProperty(target, "username", "string")) return false;
if (!hasProperty(target, "role", "string")) return false;
if (!hasProperty(target, "sub", "string")) return false;
return true;
}

View File

@ -3,10 +3,15 @@ import jwt from "jsonwebtoken";
import { type Method, type RouteAuthRule } from "./routeAuth"; import { type Method, type RouteAuthRule } from "./routeAuth";
import type { Listing } from "$lib/Listing"; import type { Listing } from "$lib/Listing";
import type { LoginData } from "$lib/Login"; import type { LoginData } from "$lib/Login";
import { isServerJwtPayload, type ServerJwtPayload } from "./ServerJwtPayload";
export type LocalCredentials = ( export type LocalCredentials = (
| { kind: "Basic"; payload: { username: string; password: string } } | { kind: "Basic"; payload: { username: string; password: string } }
| { kind: "Bearer"; payload: jwt.JwtPayload | string } | {
kind: "Bearer";
payload: ServerJwtPayload;
role: string;
}
| { kind: "None" } | { kind: "None" }
) & { role: string }; ) & { role: string };
@ -17,13 +22,15 @@ export enum AuthorizationResult {
} }
export async function createToken(listing: Listing<LoginData>, secret: string) { export async function createToken(listing: Listing<LoginData>, secret: string) {
return await jwt.sign( const serverPayload: ServerJwtPayload = {
{ sub: listing.id, username: listing.data.username, role: listing.data.role }, sub: listing.id,
secret, username: listing.data.username,
{ role: listing.data.role,
};
return await jwt.sign(serverPayload, secret, {
expiresIn: "1d", expiresIn: "1d",
}, });
);
} }
export async function authenticate( export async function authenticate(
@ -31,7 +38,6 @@ export async function authenticate(
jwtSecret: string, jwtSecret: string,
): Promise<LocalCredentials | null> { ): Promise<LocalCredentials | null> {
const authHeader = event.request.headers.get("authorization"); const authHeader = event.request.headers.get("authorization");
let tokenKind: "Basic" | "Bearer" | "None";
let tokenRole: string; let tokenRole: string;
let tokenDesc: LocalCredentials; let tokenDesc: LocalCredentials;
@ -39,27 +45,24 @@ export async function authenticate(
if (!authHeader) { if (!authHeader) {
// This is a stranger: they have no token and they will be assigned the default // This is a stranger: they have no token and they will be assigned the default
// role. // role.
tokenKind = "None";
tokenRole = "default"; tokenRole = "default";
tokenDesc = { kind: "None", role: tokenRole }; tokenDesc = { kind: "None", role: tokenRole };
} else { } else {
const [kind, token] = authHeader.split(" "); const [kind, token] = authHeader.split(" ");
if (kind === "Bearer") { if (kind === "Bearer") {
tokenKind = "Bearer";
// The role can be derived from the JWT token. // The role can be derived from the JWT token.
const payload = await jwt.verify(token, jwtSecret); const payload = await jwt.verify(token, jwtSecret);
if (typeof payload === "string") { if (!isServerJwtPayload(payload)) {
// I do not assign, and don't know what to do with, these kinds of
// tokens. Perhaps an error should be logged here, since this is a
// weird thing to have stumbled on.
return null; return null;
// user should have a bearer token
} }
tokenRole = payload.role; tokenRole = payload.role;
tokenDesc = { kind: "Bearer", payload, role: tokenRole }; tokenDesc = {
kind: "Bearer",
payload,
role: tokenRole,
};
if (!tokenRole) { if (!tokenRole) {
// Something has gone wrong: I should not have issued a token without a // Something has gone wrong: I should not have issued a token without a
@ -70,7 +73,6 @@ export async function authenticate(
const decoded = Buffer.from(token, "base64").toString("ascii"); const decoded = Buffer.from(token, "base64").toString("ascii");
const [username, password] = decoded.split(":"); const [username, password] = decoded.split(":");
tokenKind = "Basic";
tokenRole = "default"; tokenRole = "default";
tokenDesc = { kind: "Basic", payload: { username, password }, role: tokenRole }; tokenDesc = { kind: "Basic", payload: { username, password }, role: tokenRole };
@ -95,6 +97,13 @@ export function isAuthorized(
const { role: tokenRole, kind: tokenKind } = creds; const { role: tokenRole, kind: tokenKind } = creds;
const rules = roleRules[tokenRole]; const rules = roleRules[tokenRole];
if (parts[0] !== "api") {
// All the routes for the backed server are prefixed with api, and the backed
// server is where all the authorization is going to happen so we will ignore
// calls to the frontend.
return AuthorizationResult.Allowed;
}
let hasMatchingAllow = false; let hasMatchingAllow = false;
for (const rule of rules) { for (const rule of rules) {
if (matchesRequest(parts, method, rule)) { if (matchesRequest(parts, method, rule)) {

View File

@ -1,16 +0,0 @@
export async function getRequestBody<T = unknown>(
req: Request,
validation?: (target: unknown) => target is T,
): Promise<T> {
if (req.body === null) {
throw new Error("no body is present on the request");
}
const body = await req.json();
if (validation && !validation(body)) {
throw new Error("body validation failed");
}
return body;
}

View File

@ -1,3 +1,4 @@
import type { Id } from "$lib/Id";
import type { Listing } from "$lib/Listing"; import type { Listing } from "$lib/Listing";
import { import {
MongoClient, MongoClient,
@ -6,10 +7,15 @@ import {
type Document, type Document,
type WithId, type WithId,
} from "mongodb"; } from "mongodb";
import type { Id } from "../Id";
type ListingFromMongo = Omit<Listing, "id"> & { _id: ObjectId }; type ListingFromMongo = Omit<Listing, "id"> & { _id: ObjectId };
export enum ServerCollections {
Games = "games",
Logins = "logins",
Turns = "turns",
}
const uri = `mongodb://127.0.0.1:27017`; const uri = `mongodb://127.0.0.1:27017`;
const DATABASE = "ten-thousand"; const DATABASE = "ten-thousand";
let cachedClient: MongoClient | null = null; let cachedClient: MongoClient | null = null;
@ -29,14 +35,23 @@ async function getClient() {
return c; return c;
} }
export async function writeListing(col: string, listing: Listing) { export async function writeNewListing(col: ServerCollections, listing: Listing) {
const client = await getClient(); const client = await getClient();
await client.db(DATABASE).collection(col).insertOne(fixListingForMongo(listing)); await client.db(DATABASE).collection(col).insertOne(fixListingForMongo(listing));
} }
export async function writeUpdatedListing(col: ServerCollections, listing: Listing) {
const client = await getClient();
await client
.db(DATABASE)
.collection(col)
.replaceOne({ _id: new ObjectId(listing.id) }, fixListingForMongo(listing));
}
export async function listCollection<T>( export async function listCollection<T>(
col: string, col: ServerCollections,
dataGuard: (target: unknown) => target is Listing<T>, dataGuard: (target: unknown) => target is Listing<T>,
) { ) {
const client = await getClient(); const client = await getClient();
@ -46,19 +61,30 @@ export async function listCollection<T>(
} }
export async function readListingById<T>( export async function readListingById<T>(
col: string, col: ServerCollections,
id: Id, id: Id,
dataGuard: (target: unknown) => target is Listing<T>, dataGuard: (target: unknown) => target is Listing<T>,
) { ) {
const client = await getClient(); const client = await getClient();
const res = await client.db(DATABASE).collection(col).findOne({ _id: id }); const res = await client
.db(DATABASE)
.collection(col)
.findOne({ _id: idFromString(id) });
if (res === null) return null; if (res === null) return null;
return fixListingFromMongo(res, dataGuard); return fixListingFromMongo(res, dataGuard);
} }
export function idFromString(str: string) {
return new ObjectId(str);
}
export function stringFromId(id: ObjectId) {
return id.toString();
}
export async function readListingByQuery<T>( export async function readListingByQuery<T>(
col: string, col: ServerCollections,
query: object, query: object,
dataGuard: (target: unknown) => target is Listing<T>, dataGuard: (target: unknown) => target is Listing<T>,
): Promise<Listing<T> | null> { ): Promise<Listing<T> | null> {
@ -73,7 +99,7 @@ export async function readListingByQuery<T>(
function fixListingForMongo(listing: Listing): ListingFromMongo { function fixListingForMongo(listing: Listing): ListingFromMongo {
const { id, ...rest } = listing; const { id, ...rest } = listing;
return { return {
_id: id, _id: idFromString(id),
...rest, ...rest,
}; };
} }
@ -83,7 +109,7 @@ function fixListingFromMongo<T>(
dataGuard: (target: unknown) => target is Listing<T>, dataGuard: (target: unknown) => target is Listing<T>,
): Listing<T> { ): Listing<T> {
const { _id, ...rest } = target; const { _id, ...rest } = target;
const adjusted = { id: _id, ...rest }; const adjusted = { id: stringFromId(_id), ...rest };
if (!dataGuard(adjusted)) { if (!dataGuard(adjusted)) {
throw new Error("the returned document does not conform to the provided type"); throw new Error("the returned document does not conform to the provided type");

View File

@ -0,0 +1,46 @@
import type { LocalCredentials } from "./auth";
export enum ResourceId {
Game = "gameid",
}
export function getUser(locals: { user: LocalCredentials }) {
// SvelteKit screws up this type somehow, and it's important to explicitely set it
// here.
const user: LocalCredentials = locals.user;
if (user.kind !== "Bearer") {
throw new Error("bad user information");
}
return user;
}
export function getParam(params: Partial<Record<string, string>>, resource: ResourceId) {
const id = params[resource];
if (!id) {
return null;
}
return id;
}
export async function getBody<T>(
request: Request,
dataGuard: (body: unknown) => body is T,
) {
let body: unknown;
try {
body = await request.json();
} catch (err) {
return null;
}
if (!dataGuard(body)) {
throw new Error("data guard failed");
}
return body;
}

View File

@ -6,6 +6,14 @@ export function singleResponse(item: unknown) {
return Response.json({ item }); return Response.json({ item });
} }
export function createdResponse(id: string) {
return Response.json({ item: id }, { status: 201 });
}
export function tokenResponse(token: string) {
return Response.json({ access_token: token });
}
export function badRequestResponse(error: string = "Bad Request") { export function badRequestResponse(error: string = "Bad Request") {
return Response.json({ error }, { status: 400 }); return Response.json({ error }, { status: 400 });
} }

View File

@ -420,6 +420,20 @@ describe("auth", () => {
result: AuthorizationResult.Unauthenticated, result: AuthorizationResult.Unauthenticated,
}, },
}, },
{
heading: "allows a user to hit any endpoint that doesn't start with api",
conditions: {
token: {
kind: "None",
role: "default",
},
method: "GET",
path: "/resource",
},
expectations: {
result: AuthorizationResult.Allowed,
},
},
{ {
heading: "correctly matches a route with a trailing /", heading: "correctly matches a route with a trailing /",
conditions: { conditions: {

View File

@ -39,9 +39,14 @@ describe("ServerResponse", () => {
error: "something is wrong", error: "something is wrong",
}; };
const accessTokenResponse = {
access_token: "Bearer somekindoftokenfromtheserver",
};
expect(isServerResponse(singleServerResponse)).to.be.true; expect(isServerResponse(singleServerResponse)).to.be.true;
expect(isServerResponse(listServerResponse)).to.be.true; expect(isServerResponse(listServerResponse)).to.be.true;
expect(isServerResponse(errorServerResponse)).to.be.true; expect(isServerResponse(errorServerResponse)).to.be.true;
expect(isServerResponse(accessTokenResponse)).to.be.true;
}); });
}); });
}); });

View File

@ -0,0 +1,49 @@
import { isServerResponse } from "$lib/ServerResponse";
import { redirect, type ServerLoad } from "@sveltejs/kit";
import { isMe, type Me } from "$lib/me";
import { setMeContext } from "$lib/meContext";
export const load: ServerLoad = async ({ request, cookies, fetch }) => {
const url = new URL(request.url);
const token = cookies.get("access_token");
let me: Me | null = null;
if (token) {
const res = await fetch("/api/me", {
headers: [["authorization", token]],
});
const body = await res.json();
if (!isServerResponse(body)) {
throw new Error("missing or malformed body");
}
if (!("item" in body)) {
throw new Error("expected to receive an item");
}
const item = body.item;
if (!isMe(item)) {
throw new Error("expected to receive users 'me' object");
}
me = item;
}
if (!token && url.pathname !== "/login" && url.pathname !== "/login/__data.json") {
console.log("REDIRECTING", url.pathname);
const baseUrl = `${url.protocol}${url.host}`;
const loginUrl = new URL("login", baseUrl);
loginUrl.searchParams.set("to", url.pathname);
redirect(302, loginUrl);
} else {
console.log("no need to redirect");
}
return {
token,
me,
};
};

22
src/routes/+layout.svelte Normal file
View File

@ -0,0 +1,22 @@
<script lang="ts">
import { setMeContext } from "$lib/meContext.js";
let { children, data } = $props();
const me = data.me;
if (me) {
setMeContext(me);
}
</script>
<main>
{@render children()}
</main>
<style>
main {
max-width: 60rem;
margin: auto;
}
</style>

View File

@ -1,2 +1,73 @@
<h1>Welcome to SvelteKit</h1> <h1>Play Ten Thousand!</h1>
<p>Visit <a href="https://svelte.dev/docs/kit">svelte.dev/docs/kit</a> to read the documentation</p> <a href="/games">Find a game</a>
<h2>What is Ten Thousand?</h2>
<p>
Ten Thousand is a dice game where players take turns rolling dice and scoring points to
try to reach 10,000 points.
</p>
<h2>How do I play?</h2>
<p>
The game starts with each player rolling to see who goes first. Each player then takes
turns rolling up to six dice, putting aside any scoring dice. Each time a player scores,
they can roll again to try to score more. Be careful, however, because if a player rolls
and doesn't score any points, they lose all the points they have scored during their
turn and their turn ends.
</p>
<p>
There are many variations to this game, this server uses the variation that I was taught
and have played many times, often obnoxiously loudly, at bars.
</p>
<h3>Basic Rules</h3>
<p>
Players start with six dice. Each die or dice they chose to score with, they put aside
and then roll with all the remaining dice. If all the dice have been rolled and set
aside for scoring, the player gets to re-roll all the dice, keeping whatever score they
already rolled up to that point. The player does not have to set aside scoring dice, and
can opt instead to re-roll them, hoping for a better score.
</p>
<p>
Once a player decides to hold, all the points they scored during their turn officially
get added to their score. A player can hold with as many or as few points as they like
with two exceptions: if a player <em>can</em> roll all six dice, they <em>must</em> roll
them; and the first time a player holds for points, their score <em>must</em> be worth at
least 1,000 points.
</p>
<p>
The final special scenario is called a "push." If a player has exactly two non-scoring
dice remaining, and those dice make a two of a kind (&#9859; &#9859;) then the player
takes those as if they were scoring dice worth zero points. Pushes can occur if a player
has decided to score all other dice which they have just rolled, and the dice they have
remaining are a push, or if the player has rolled two dice, and that roll has resulted
in a two of a kind. If a player has a push, they <em>must</em> take it, and since a push
can only result in six dice being available to roll again, a player who has rolled a push
must also roll again.
</p>
<p></p>
<p>
Once a player has hit 10,000, each other player gets one more turn. At the end, the
player with the most points (not necessarily the player who hit 10,000 first) wins.
</p>
<h3>Scoring</h3>
<ol>
<li>Ones (&#9856;) can be scored on their own for 100 points</li>
<li>Fives (&#9860;) can be scored on their own for 50 points</li>
<li>
Three of a kind (&#9857; &#9857; &#9857;) can be held for their face value, x100,
e.g., three twos are worth 200 points. The only exception is ones, three ones are
worth 1,000 points.
</li>
<li>
Four of a kind (&#9858; &#9858; &#9858; &#9858;) and five of a kind (&#9858; &#9858;
&#9858; &#9858; &#9858;) and six of a kind (&#9858; &#9858; &#9858; &#9858; &#9858;
&#9858;) are treated like a three of a kind where each additional matching die is
worth x2. So four threes is 600 (300 x 2), five threes is 1,200 (300 x 2 x 2), and six
threes are worth 2,400 (300 x 2 x 2 x 2).
</li>
<li>
Two threes of a kind (&#9861; &#9861; &#9861; &#9859; &#9859; &#9859;) is worth 1,600
points.
</li>
<li>
A run of 6 (&#9856; &#9857; &#9858; &#9859; &#9860; &#9861;) is worth 2,000 points.
</li>
</ol>

View File

@ -2,19 +2,26 @@ import type { RequestHandler } from "@sveltejs/kit";
import { listResponse, singleResponse } from "$lib/server/responseBodies"; import { listResponse, singleResponse } from "$lib/server/responseBodies";
import { createNewListing } from "$lib/server/modifyListing"; import { createNewListing } from "$lib/server/modifyListing";
import { Game } from "$lib/server/Game"; import { Game } from "$lib/server/Game";
import { listCollection, writeListing } from "$lib/server/mongo"; import { listCollection, ServerCollections, writeNewListing } from "$lib/server/mongo";
import { isListing } from "$lib/Listing"; import { isListing } from "$lib/Listing";
import type { GameData } from "$lib/GameData"; import type { GameData } from "$lib/GameData";
import { getUser } from "$lib/server/requestTools";
export const GET: RequestHandler = async (): Promise<Response> => { export const GET: RequestHandler = async (): Promise<Response> => {
const games = await listCollection("games", isListing<GameData>); const games = await listCollection(ServerCollections.Games, isListing<GameData>);
return listResponse(games); return listResponse(games);
}; };
export const POST: RequestHandler = async (): Promise<Response> => { export const POST: RequestHandler = async ({ locals }): Promise<Response> => {
const newListing = createNewListing(new Game()); const user = getUser(locals);
const { username, sub } = user.payload;
const game = new Game();
await writeListing("games", newListing); game.addPlayer(sub, username);
const newListing = createNewListing(game);
await writeNewListing(ServerCollections.Games, newListing);
return singleResponse(newListing.id); return singleResponse(newListing.id);
}; };

View File

@ -1,7 +1,7 @@
import type { GameData } from "$lib/GameData"; import type { GameData } from "$lib/GameData";
import { idFromString, type Id } from "$lib/Id";
import { isListing } from "$lib/Listing"; import { isListing } from "$lib/Listing";
import { readListingById } from "$lib/server/mongo"; import { readListingById, ServerCollections } from "$lib/server/mongo";
import { getParam, ResourceId } from "$lib/server/requestTools";
import { import {
badRequestResponse, badRequestResponse,
notFoundResponse, notFoundResponse,
@ -10,20 +10,13 @@ import {
import type { RequestHandler } from "@sveltejs/kit"; import type { RequestHandler } from "@sveltejs/kit";
export const GET: RequestHandler = async ({ params }): Promise<Response> => { export const GET: RequestHandler = async ({ params }): Promise<Response> => {
const idStr = params["gameid"]; const id = getParam(params, ResourceId.Game);
if (!idStr) { if (!id) {
return badRequestResponse("missing gameid parameter"); return badRequestResponse("missing gameid parameter");
} }
let id: Id; const game = await readListingById(ServerCollections.Games, id, isListing<GameData>);
try {
id = idFromString(idStr);
} catch (err) {
return notFoundResponse();
}
const game = await readListingById("games", id, isListing<GameData>);
if (!game) { if (!game) {
return notFoundResponse(); return notFoundResponse();

View File

@ -0,0 +1,59 @@
import type { GameData } from "$lib/GameData";
import { getGameEvent, isGameEventData } from "$lib/GameEvent";
import { isListing } from "$lib/Listing";
import { updateListing } from "$lib/server/modifyListing";
import { createNewListing } from "$lib/server/modifyListing";
import { writeNewListing } from "$lib/server/mongo";
import {
readListingById,
ServerCollections,
writeUpdatedListing,
} from "$lib/server/mongo";
import { getBody, getParam, getUser, ResourceId } from "$lib/server/requestTools";
import {
badRequestResponse,
createdResponse,
notFoundResponse,
singleResponse,
} from "$lib/server/responseBodies";
import type { RequestHandler } from "@sveltejs/kit";
export const POST: RequestHandler = async ({
locals,
params,
request,
}): Promise<Response> => {
const id = getParam(params, ResourceId.Game);
if (id === null) {
return badRequestResponse("missing gameid parameter");
}
const body = await getBody(request, isGameEventData);
if (!body) {
return badRequestResponse("missing game event in body");
}
const user = getUser(locals);
const game = await readListingById(ServerCollections.Games, id, isListing<GameData>);
if (!game) {
return notFoundResponse();
}
const { state } = game.data;
const event = getGameEvent(game.data, body);
try {
event.run(state);
game.data.isStarted = true;
} catch (err) {
return badRequestResponse(`illegal turn: ${err}`);
}
await writeNewListing(ServerCollections.Turns, createNewListing(body));
await writeUpdatedListing(ServerCollections.Games, updateListing(game, game.data));
return singleResponse(game);
};

View File

@ -0,0 +1,16 @@
import type { LocalCredentials } from "$lib/server/auth";
import { singleResponse } from "$lib/server/responseBodies";
import { error, type RequestHandler } from "@sveltejs/kit";
export const GET: RequestHandler = async ({ locals }): Promise<Response> => {
const user: LocalCredentials = locals.user;
if (user.kind !== "Bearer") {
error(401, "user not logged in");
}
return singleResponse({
id: user.payload.sub,
username: user.payload.username,
role: user.role,
});
};

View File

@ -1,10 +1,9 @@
import { isListing } from "$lib/Listing"; import { isListing } from "$lib/Listing";
import { isLoginData } from "$lib/Login"; import { isLoginData } from "$lib/Login";
import { readListingByQuery } from "$lib/server/mongo"; import { readListingByQuery, ServerCollections } from "$lib/server/mongo";
import { import {
badRequestResponse, badRequestResponse,
notFoundResponse, tokenResponse,
singleResponse,
unauthorizedResponse, unauthorizedResponse,
} from "$lib/server/responseBodies"; } from "$lib/server/responseBodies";
import type { RequestHandler } from "@sveltejs/kit"; import type { RequestHandler } from "@sveltejs/kit";
@ -22,7 +21,7 @@ export const POST: RequestHandler = async ({ locals }): Promise<Response> => {
const { username, password } = user.payload; const { username, password } = user.payload;
const listing = await readListingByQuery( const listing = await readListingByQuery(
"logins", ServerCollections.Logins,
{ {
"data.username": username, "data.username": username,
}, },
@ -30,12 +29,12 @@ export const POST: RequestHandler = async ({ locals }): Promise<Response> => {
); );
if (!listing) { if (!listing) {
return notFoundResponse(); return unauthorizedResponse();
} }
if (await compare(password, listing.data.password)) { if (await compare(password, listing.data.password)) {
const token = await createToken(listing, JWT_SECRET); const token = await createToken(listing, JWT_SECRET);
return singleResponse(token); return tokenResponse(token);
} }
return badRequestResponse("wrong password"); return badRequestResponse("wrong password");

View File

@ -1,6 +1,6 @@
import { hashPassword, isLoginData } from "$lib/Login"; import { hashPassword, isLoginData } from "$lib/Login";
import { createNewListing } from "$lib/server/modifyListing"; import { createNewListing } from "$lib/server/modifyListing";
import { writeListing } from "$lib/server/mongo"; import { ServerCollections, writeNewListing } from "$lib/server/mongo";
import { import {
badRequestResponse, badRequestResponse,
forbiddenResponse, forbiddenResponse,
@ -30,7 +30,7 @@ export const POST: RequestHandler = async ({ request }): Promise<Response> => {
const listing = createNewListing(body); const listing = createNewListing(body);
try { try {
await writeListing("logins", listing); await writeNewListing(ServerCollections.Logins, listing);
return singleResponse(listing.id); return singleResponse(listing.id);
} catch (err) { } catch (err) {
return serverErrorResponse(); return serverErrorResponse();

View File

@ -0,0 +1,13 @@
import { error } from "@sveltejs/kit";
import type { PageServerLoad } from "./$types";
import { getPageData } from "./getPageData";
export const load: PageServerLoad = async ({ fetch, cookies }) => {
const token = cookies.get("access_token");
if (!token) {
error(401);
}
return getPageData(fetch, token);
};

View File

@ -1,52 +1,59 @@
<script lang="ts"> <script lang="ts">
type GameData = {id: number, name: string, players: string[]} import PlayerList from "$lib/components/PlayerList.svelte";
import type { GameData } from "$lib/GameData.js";
import type { Listing } from "$lib/Listing";
const games: GameData[] = [ const { data } = $props();
{id: 1, name: "The Worst", players: ["Bob", "Ted", "George"]}, const games = data.games;
{id: 2, name: "The Best", players: ["Shelly", "William", "Abby"]}, const prettyDate = (date: Date) => {
{id: 3, name: "The One with the Treasure Chest", players: ["Jack"]}, return `${date.toLocaleString()}`;
]; };
</script> </script>
<main> <h1>Let&#146;s Play Ten Thousand</h1>
<h1>Let&#146;s Play Ten Thousand</h1>
<table> <h2>Games</h2>
<thead> <div class="game-list">
<tr>
<td>No.</td>
<td>Name</td>
<td>Players</td>
</tr>
</thead>
<tbody>
{#each games as game} {#each games as game}
{@render GameRow(game)} {@render GameRow(game)}
{/each} {/each}
</tbody> </div>
</table>
</main>
{#snippet GameRow (game: GameData)} {#snippet GameRow(game: Listing<GameData>)}
<tr> <div class="game-listing">
<td>{game.id}</td> <div>{prettyDate(new Date(game.createdAt))}</div>
<td>{game.name}</td> <div>
<td>{game.players.length}</td> {#each games as game}
</tr> <PlayerList players={game.data.players} />
{/each}
</div>
<form method="GET" action={`/games/${game.id}`}>
<input type="submit" value="JOIN" />
</form>
</div>
{/snippet} {/snippet}
<style> <style>
main { .game-list {
width: 60rem; width: 100%;
margin: auto;
} }
td { .game-listing {
border: solid black 1px; display: flex;
padding: 1rem; gap: 3rem;
padding: 0.5rem;
} }
table { .game-listing > div {
flex: 1 1 auto;
}
form {
display: flex;
flex: 1 1 auto;
}
form input {
width: 100%; width: 100%;
} }
</style> </style>

View File

@ -2,15 +2,21 @@ import { isGameData, type GameData } from "$lib/GameData";
import { isListing } from "$lib/Listing"; import { isListing } from "$lib/Listing";
import { isServerResponse } from "$lib/ServerResponse"; import { isServerResponse } from "$lib/ServerResponse";
import { error } from "@sveltejs/kit"; import { error } from "@sveltejs/kit";
import type { PageLoad } from "./$types"; import type { PageServerLoad } from "./$types";
export const load: PageLoad = async ({ fetch, params }) => { export const load: PageServerLoad = async ({ fetch, params, cookies }) => {
const url = `/api/games/${params.gameid}`; const url = `/api/games/${params.gameid}`;
let res: Response; let res: Response;
let body: unknown; let body: unknown;
const token = cookies.get("access_token");
if (!token) {
error(401);
}
try { try {
res = await fetch(url); res = await fetch(url, { headers: [["Authorization", token]] });
body = await res.json(); body = await res.json();
} catch (err) { } catch (err) {
error(500, "unable to call API"); error(500, "unable to call API");
@ -25,8 +31,8 @@ export const load: PageLoad = async ({ fetch, params }) => {
} }
if ("item" in body && isListing<GameData>(body.item, isGameData)) { if ("item" in body && isListing<GameData>(body.item, isGameData)) {
return body.item; return { game: body.item };
} else { } else {
error(500, "expected response body to contain game data"); error(res.status, "unable to fetch game data");
} }
}; };

View File

@ -1,15 +1,43 @@
<script lang="ts"> <script lang="ts">
import type { PageData } from "./$types" import PlayerList from "$lib/components/PlayerList.svelte";
let { data }: { data: PageData } = $props(); import type { PageData } from "./$types";
let { data: page }: { data: PageData } = $props();
const handleSubmit = () => {};
</script> </script>
<main> {#if page.game.data.isStarted}
<h1>This is game {data.id}</h1> <h1>This is game {page.game.id}</h1>
</main> {:else}
<h1>This is some lobby</h1>
<div id="lobby">
<div id="players">
<PlayerList players={page.game.data.players} />
</div>
<div id="chat" style="background: pink"></div>
</div>
<div id="controls">
<input type="button" value="Start Game" onsubmit={handleSubmit} />
</div>
{/if}
<style> <style>
main { #lobby {
width: 60rem; display: flex;
margin: auto; width: 100%;
gap: 1rem;
min-height: 30rem;
}
#players {
flex: 1;
}
#chat {
flex: 2 2;
}
#controls {
margin: 1rem 0;
text-align: right;
} }
</style> </style>

View File

@ -0,0 +1,50 @@
import { isGameData, type GameData } from "$lib/GameData";
import { isListing, type Listing } from "$lib/Listing";
import { isServerResponse } from "$lib/ServerResponse";
import { error } from "@sveltejs/kit";
export async function getPageData(
svelteKitFetch: typeof fetch,
token: string,
): Promise<{ games: Listing<GameData>[] }> {
const url = "/api/games";
let res: Response;
let body: unknown;
try {
res = await svelteKitFetch(url, {
headers: [["Authorization", token]],
});
body = await res.json();
} catch (err) {
console.log(err);
error(500, "unable to call API");
}
if (res.status === 404) {
error(404, `Not Found`);
}
if (!isServerResponse(body)) {
console.log("wasn't server response");
error(500, "expected to receive a properly formatted server response body");
}
if ("items" in body) {
if (
body.items.reduce((result, item) => {
if (result) {
return isListing<GameData>(item, isGameData);
}
return false;
}, true)
) {
return { games: body.items as Listing<GameData>[] };
} else {
error(500, "malformed API response");
}
} else {
error(res.status, "unable to fetch game data");
}
}

View File

@ -0,0 +1,54 @@
<script lang="ts">
import { isMe } from "$lib/me";
import { setMeContext } from "$lib/meContext";
import { isServerResponse } from "$lib/ServerResponse";
let username: string = $state("");
let password: string = $state("");
const handleSubmit = async (e: SubmitEvent) => {
e.preventDefault();
let res = await fetch("/api/token", {
headers: [["authorization", `Basic ${btoa(`${username}:${password}`)}`]],
method: "POST",
});
const params = new URLSearchParams(window.location.search);
const toPage = params.get("to") ?? "/";
let body: unknown;
try {
body = await res.json();
if (!isServerResponse(body)) {
throw new Error("missing or malformed body");
}
if (!("access_token" in body)) {
throw new Error("expected to receive an access token");
}
document.cookie = `access_token=Bearer ${body.access_token} ; SameSite=Strict`;
window.location.replace(toPage);
} catch (e) {
console.error(e);
}
};
</script>
<main>
<h1>Login</h1>
<form onsubmit={handleSubmit}>
<div>
<label for="username">Username</label>
<input name="username" id="username" type="text" bind:value={username} />
</div>
<div>
<label for="password">Password</label>
<input name="password" id="password" type="password" bind:value={password} />
</div>
<div>
<input name="submit" id="submit" type="submit" value="Submit" />
</div>
</form>
</main>

View File

View File

@ -35,15 +35,15 @@ Content-Type: application/json
### ###
POST https://localhost:5173/api/games/de4cdb8c-0346-4ac6-a7a8-b4135b2d79e3/turns POST https://localhost:5173/api/games/67b39573a0fcb80dd13f2c8b/turns
Accept: application/json Accept: application/json
Content-Type: application/json Content-Type: application/json
Authorization: Bearer {{token}} Authorization: Bearer {{token}}
{ {
"kind": "Roll", "kind": "SeatPlayers",
"player": 2, "player": 1,
"value": 4 "value": 2
} }
### ###

View File

@ -1,4 +1,4 @@
import { configDefaults, coverageConfigDefaults, defineConfig } from "vitest/config"; import { coverageConfigDefaults, defineConfig } from "vitest/config";
import { sveltekit } from "@sveltejs/kit/vite"; import { sveltekit } from "@sveltejs/kit/vite";
import { readFileSync } from "fs"; import { readFileSync } from "fs";
@ -18,5 +18,6 @@ export default defineConfig({
coverage: { coverage: {
exclude: [...coverageConfigDefaults.exclude, "svelte.config.js"], exclude: [...coverageConfigDefaults.exclude, "svelte.config.js"],
}, },
environment: "jsdom",
}, },
}); });