add turns and update token logic.
This commit is contained in:
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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.
|
||||||
|
@ -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";
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
32
src/lib/components/PlayerList.svelte
Normal file
32
src/lib/components/PlayerList.svelte
Normal 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
14
src/lib/me.ts
Normal 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
14
src/lib/meContext.ts
Normal 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;
|
||||||
|
}
|
@ -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() {
|
||||||
|
15
src/lib/server/ServerJwtPayload.ts
Normal file
15
src/lib/server/ServerJwtPayload.ts
Normal 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;
|
||||||
|
}
|
@ -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)) {
|
||||||
|
@ -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;
|
|
||||||
}
|
|
@ -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");
|
||||||
|
46
src/lib/server/requestTools.ts
Normal file
46
src/lib/server/requestTools.ts
Normal 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;
|
||||||
|
}
|
@ -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 });
|
||||||
}
|
}
|
||||||
|
@ -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: {
|
||||||
|
@ -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;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
49
src/routes/+layout.server.ts
Normal file
49
src/routes/+layout.server.ts
Normal 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
22
src/routes/+layout.svelte
Normal 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>
|
@ -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 (⚃ ⚃) 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 (⚀) can be scored on their own for 100 points</li>
|
||||||
|
<li>Fives (⚄) can be scored on their own for 50 points</li>
|
||||||
|
<li>
|
||||||
|
Three of a kind (⚁ ⚁ ⚁) 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 (⚂ ⚂ ⚂ ⚂) and five of a kind (⚂ ⚂
|
||||||
|
⚂ ⚂ ⚂) and six of a kind (⚂ ⚂ ⚂ ⚂ ⚂
|
||||||
|
⚂) 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 (⚅ ⚅ ⚅ ⚃ ⚃ ⚃) is worth 1,600
|
||||||
|
points.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
A run of 6 (⚀ ⚁ ⚂ ⚃ ⚄ ⚅) is worth 2,000 points.
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
@ -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);
|
||||||
};
|
};
|
||||||
|
@ -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();
|
||||||
|
59
src/routes/api/games/[gameid]/[turns]/+server.ts
Normal file
59
src/routes/api/games/[gameid]/[turns]/+server.ts
Normal 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);
|
||||||
|
};
|
16
src/routes/api/me/+server.ts
Normal file
16
src/routes/api/me/+server.ts
Normal 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,
|
||||||
|
});
|
||||||
|
};
|
@ -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");
|
||||||
|
@ -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();
|
||||||
|
13
src/routes/games/+page.server.ts
Normal file
13
src/routes/games/+page.server.ts
Normal 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);
|
||||||
|
};
|
@ -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’s Play Ten Thousand</h1>
|
<h1>Let’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>
|
||||||
|
@ -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");
|
||||||
}
|
}
|
||||||
};
|
};
|
@ -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>
|
||||||
|
50
src/routes/games/getPageData.ts
Normal file
50
src/routes/games/getPageData.ts
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
54
src/routes/login/+page.svelte
Normal file
54
src/routes/login/+page.svelte
Normal 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>
|
0
src/routes/register/+page.svelte
Normal file
0
src/routes/register/+page.svelte
Normal 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
|
||||||
}
|
}
|
||||||
|
|
||||||
###
|
###
|
||||||
|
@ -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",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
Reference in New Issue
Block a user