add turns and update token logic.
This commit is contained in:
@ -4,7 +4,7 @@ import type { State } from "./State";
|
||||
|
||||
export interface GameData {
|
||||
isStarted: boolean;
|
||||
players: Id[];
|
||||
players: { id: Id; username: string }[];
|
||||
state: State;
|
||||
}
|
||||
|
||||
@ -17,7 +17,11 @@ export function isGameData(target: unknown): target is GameData {
|
||||
const { players } = target as any;
|
||||
|
||||
for (const player of players) {
|
||||
if (!isId(player)) {
|
||||
if (!isId(player.id)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!hasProperty(player, "username", "string")) {
|
||||
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
|
||||
* 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.
|
||||
*axpected to re-roll as a tie breaker. Re-rolling continues until someone
|
||||
* wins.
|
||||
*/
|
||||
export class RollForFirst implements GameEvent {
|
||||
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
|
||||
// set of unique values is two, then there MUST have been two threes of a
|
||||
// kind.
|
||||
total = 1_500;
|
||||
total = 1_600;
|
||||
} else {
|
||||
// A player can use a "push" if they are using every one of their rolled
|
||||
// dice.
|
||||
|
@ -1,19 +1,11 @@
|
||||
import { ObjectId } from "mongodb";
|
||||
|
||||
export type Id = ObjectId;
|
||||
export type Id = string;
|
||||
|
||||
export function createId(): Id {
|
||||
return new ObjectId();
|
||||
}
|
||||
|
||||
export function idFromString(str: string) {
|
||||
return new ObjectId(str);
|
||||
}
|
||||
|
||||
export function stringFromId(id: Id) {
|
||||
return id.toString();
|
||||
return new ObjectId().toString();
|
||||
}
|
||||
|
||||
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 =
|
||||
| { item: Listing<unknown> }
|
||||
| { items: Listing<unknown>[] }
|
||||
| { access_token: string }
|
||||
| { error: string };
|
||||
|
||||
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, "items", "object[]")) return true;
|
||||
if (hasProperty(target, "error", "string")) return true;
|
||||
if (hasProperty(target, "access_token", "string")) return true;
|
||||
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";
|
||||
|
||||
export class Game implements GameData {
|
||||
players: Id[];
|
||||
players: { id: Id; username: string }[];
|
||||
isStarted: boolean;
|
||||
state: State;
|
||||
|
||||
@ -13,8 +13,17 @@ export class Game implements GameData {
|
||||
this.state = {};
|
||||
}
|
||||
|
||||
addPlayer(id: Id) {
|
||||
this.players.push(id);
|
||||
static from(data: GameData) {
|
||||
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() {
|
||||
|
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 { Listing } from "$lib/Listing";
|
||||
import type { LoginData } from "$lib/Login";
|
||||
import { isServerJwtPayload, type ServerJwtPayload } from "./ServerJwtPayload";
|
||||
|
||||
export type LocalCredentials = (
|
||||
| { kind: "Basic"; payload: { username: string; password: string } }
|
||||
| { kind: "Bearer"; payload: jwt.JwtPayload | string }
|
||||
| {
|
||||
kind: "Bearer";
|
||||
payload: ServerJwtPayload;
|
||||
role: string;
|
||||
}
|
||||
| { kind: "None" }
|
||||
) & { role: string };
|
||||
|
||||
@ -17,13 +22,15 @@ export enum AuthorizationResult {
|
||||
}
|
||||
|
||||
export async function createToken(listing: Listing<LoginData>, secret: string) {
|
||||
return await jwt.sign(
|
||||
{ sub: listing.id, username: listing.data.username, role: listing.data.role },
|
||||
secret,
|
||||
{
|
||||
expiresIn: "1d",
|
||||
},
|
||||
);
|
||||
const serverPayload: ServerJwtPayload = {
|
||||
sub: listing.id,
|
||||
username: listing.data.username,
|
||||
role: listing.data.role,
|
||||
};
|
||||
|
||||
return await jwt.sign(serverPayload, secret, {
|
||||
expiresIn: "1d",
|
||||
});
|
||||
}
|
||||
|
||||
export async function authenticate(
|
||||
@ -31,7 +38,6 @@ export async function authenticate(
|
||||
jwtSecret: string,
|
||||
): Promise<LocalCredentials | null> {
|
||||
const authHeader = event.request.headers.get("authorization");
|
||||
let tokenKind: "Basic" | "Bearer" | "None";
|
||||
let tokenRole: string;
|
||||
let tokenDesc: LocalCredentials;
|
||||
|
||||
@ -39,27 +45,24 @@ export async function authenticate(
|
||||
if (!authHeader) {
|
||||
// This is a stranger: they have no token and they will be assigned the default
|
||||
// role.
|
||||
tokenKind = "None";
|
||||
tokenRole = "default";
|
||||
tokenDesc = { kind: "None", role: tokenRole };
|
||||
} else {
|
||||
const [kind, token] = authHeader.split(" ");
|
||||
|
||||
if (kind === "Bearer") {
|
||||
tokenKind = "Bearer";
|
||||
|
||||
// The role can be derived from the JWT token.
|
||||
const payload = await jwt.verify(token, jwtSecret);
|
||||
if (typeof payload === "string") {
|
||||
// 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.
|
||||
if (!isServerJwtPayload(payload)) {
|
||||
return null;
|
||||
// user should have a bearer token
|
||||
}
|
||||
|
||||
tokenRole = payload.role;
|
||||
tokenDesc = { kind: "Bearer", payload, role: tokenRole };
|
||||
tokenDesc = {
|
||||
kind: "Bearer",
|
||||
payload,
|
||||
role: tokenRole,
|
||||
};
|
||||
|
||||
if (!tokenRole) {
|
||||
// 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 [username, password] = decoded.split(":");
|
||||
|
||||
tokenKind = "Basic";
|
||||
tokenRole = "default";
|
||||
|
||||
tokenDesc = { kind: "Basic", payload: { username, password }, role: tokenRole };
|
||||
@ -95,6 +97,13 @@ export function isAuthorized(
|
||||
const { role: tokenRole, kind: tokenKind } = creds;
|
||||
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;
|
||||
for (const rule of rules) {
|
||||
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 {
|
||||
MongoClient,
|
||||
@ -6,10 +7,15 @@ import {
|
||||
type Document,
|
||||
type WithId,
|
||||
} from "mongodb";
|
||||
import type { Id } from "../Id";
|
||||
|
||||
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 DATABASE = "ten-thousand";
|
||||
let cachedClient: MongoClient | null = null;
|
||||
@ -29,14 +35,23 @@ async function getClient() {
|
||||
return c;
|
||||
}
|
||||
|
||||
export async function writeListing(col: string, listing: Listing) {
|
||||
export async function writeNewListing(col: ServerCollections, listing: Listing) {
|
||||
const client = await getClient();
|
||||
|
||||
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>(
|
||||
col: string,
|
||||
col: ServerCollections,
|
||||
dataGuard: (target: unknown) => target is Listing<T>,
|
||||
) {
|
||||
const client = await getClient();
|
||||
@ -46,19 +61,30 @@ export async function listCollection<T>(
|
||||
}
|
||||
|
||||
export async function readListingById<T>(
|
||||
col: string,
|
||||
col: ServerCollections,
|
||||
id: Id,
|
||||
dataGuard: (target: unknown) => target is Listing<T>,
|
||||
) {
|
||||
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;
|
||||
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>(
|
||||
col: string,
|
||||
col: ServerCollections,
|
||||
query: object,
|
||||
dataGuard: (target: unknown) => target is Listing<T>,
|
||||
): Promise<Listing<T> | null> {
|
||||
@ -73,7 +99,7 @@ export async function readListingByQuery<T>(
|
||||
function fixListingForMongo(listing: Listing): ListingFromMongo {
|
||||
const { id, ...rest } = listing;
|
||||
return {
|
||||
_id: id,
|
||||
_id: idFromString(id),
|
||||
...rest,
|
||||
};
|
||||
}
|
||||
@ -83,7 +109,7 @@ function fixListingFromMongo<T>(
|
||||
dataGuard: (target: unknown) => target is Listing<T>,
|
||||
): Listing<T> {
|
||||
const { _id, ...rest } = target;
|
||||
const adjusted = { id: _id, ...rest };
|
||||
const adjusted = { id: stringFromId(_id), ...rest };
|
||||
|
||||
if (!dataGuard(adjusted)) {
|
||||
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 });
|
||||
}
|
||||
|
||||
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") {
|
||||
return Response.json({ error }, { status: 400 });
|
||||
}
|
||||
|
@ -420,6 +420,20 @@ describe("auth", () => {
|
||||
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 /",
|
||||
conditions: {
|
||||
|
@ -39,9 +39,14 @@ describe("ServerResponse", () => {
|
||||
error: "something is wrong",
|
||||
};
|
||||
|
||||
const accessTokenResponse = {
|
||||
access_token: "Bearer somekindoftokenfromtheserver",
|
||||
};
|
||||
|
||||
expect(isServerResponse(singleServerResponse)).to.be.true;
|
||||
expect(isServerResponse(listServerResponse)).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>
|
||||
<p>Visit <a href="https://svelte.dev/docs/kit">svelte.dev/docs/kit</a> to read the documentation</p>
|
||||
<h1>Play Ten Thousand!</h1>
|
||||
<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 { createNewListing } from "$lib/server/modifyListing";
|
||||
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 type { GameData } from "$lib/GameData";
|
||||
import { getUser } from "$lib/server/requestTools";
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
export const POST: RequestHandler = async (): Promise<Response> => {
|
||||
const newListing = createNewListing(new Game());
|
||||
export const POST: RequestHandler = async ({ locals }): Promise<Response> => {
|
||||
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);
|
||||
};
|
||||
|
@ -1,7 +1,7 @@
|
||||
import type { GameData } from "$lib/GameData";
|
||||
import { idFromString, type Id } from "$lib/Id";
|
||||
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 {
|
||||
badRequestResponse,
|
||||
notFoundResponse,
|
||||
@ -10,20 +10,13 @@ import {
|
||||
import type { RequestHandler } from "@sveltejs/kit";
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
let id: Id;
|
||||
try {
|
||||
id = idFromString(idStr);
|
||||
} catch (err) {
|
||||
return notFoundResponse();
|
||||
}
|
||||
|
||||
const game = await readListingById("games", id, isListing<GameData>);
|
||||
const game = await readListingById(ServerCollections.Games, id, isListing<GameData>);
|
||||
|
||||
if (!game) {
|
||||
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 { isLoginData } from "$lib/Login";
|
||||
import { readListingByQuery } from "$lib/server/mongo";
|
||||
import { readListingByQuery, ServerCollections } from "$lib/server/mongo";
|
||||
import {
|
||||
badRequestResponse,
|
||||
notFoundResponse,
|
||||
singleResponse,
|
||||
tokenResponse,
|
||||
unauthorizedResponse,
|
||||
} from "$lib/server/responseBodies";
|
||||
import type { RequestHandler } from "@sveltejs/kit";
|
||||
@ -22,7 +21,7 @@ export const POST: RequestHandler = async ({ locals }): Promise<Response> => {
|
||||
|
||||
const { username, password } = user.payload;
|
||||
const listing = await readListingByQuery(
|
||||
"logins",
|
||||
ServerCollections.Logins,
|
||||
{
|
||||
"data.username": username,
|
||||
},
|
||||
@ -30,12 +29,12 @@ export const POST: RequestHandler = async ({ locals }): Promise<Response> => {
|
||||
);
|
||||
|
||||
if (!listing) {
|
||||
return notFoundResponse();
|
||||
return unauthorizedResponse();
|
||||
}
|
||||
|
||||
if (await compare(password, listing.data.password)) {
|
||||
const token = await createToken(listing, JWT_SECRET);
|
||||
return singleResponse(token);
|
||||
return tokenResponse(token);
|
||||
}
|
||||
|
||||
return badRequestResponse("wrong password");
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { hashPassword, isLoginData } from "$lib/Login";
|
||||
import { createNewListing } from "$lib/server/modifyListing";
|
||||
import { writeListing } from "$lib/server/mongo";
|
||||
import { ServerCollections, writeNewListing } from "$lib/server/mongo";
|
||||
import {
|
||||
badRequestResponse,
|
||||
forbiddenResponse,
|
||||
@ -30,7 +30,7 @@ export const POST: RequestHandler = async ({ request }): Promise<Response> => {
|
||||
const listing = createNewListing(body);
|
||||
|
||||
try {
|
||||
await writeListing("logins", listing);
|
||||
await writeNewListing(ServerCollections.Logins, listing);
|
||||
return singleResponse(listing.id);
|
||||
} catch (err) {
|
||||
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">
|
||||
type GameData = {id: number, name: string, players: string[]}
|
||||
|
||||
const games: GameData[] = [
|
||||
{id: 1, name: "The Worst", players: ["Bob", "Ted", "George"]},
|
||||
{id: 2, name: "The Best", players: ["Shelly", "William", "Abby"]},
|
||||
{id: 3, name: "The One with the Treasure Chest", players: ["Jack"]},
|
||||
];
|
||||
import PlayerList from "$lib/components/PlayerList.svelte";
|
||||
import type { GameData } from "$lib/GameData.js";
|
||||
import type { Listing } from "$lib/Listing";
|
||||
|
||||
const { data } = $props();
|
||||
const games = data.games;
|
||||
const prettyDate = (date: Date) => {
|
||||
return `${date.toLocaleString()}`;
|
||||
};
|
||||
</script>
|
||||
|
||||
<main>
|
||||
<h1>Let’s Play Ten Thousand</h1>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<td>No.</td>
|
||||
<td>Name</td>
|
||||
<td>Players</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each games as game}
|
||||
{@render GameRow(game)}
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</main>
|
||||
<h1>Let’s Play Ten Thousand</h1>
|
||||
|
||||
{#snippet GameRow (game: GameData)}
|
||||
<tr>
|
||||
<td>{game.id}</td>
|
||||
<td>{game.name}</td>
|
||||
<td>{game.players.length}</td>
|
||||
</tr>
|
||||
<h2>Games</h2>
|
||||
<div class="game-list">
|
||||
{#each games as game}
|
||||
{@render GameRow(game)}
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#snippet GameRow(game: Listing<GameData>)}
|
||||
<div class="game-listing">
|
||||
<div>{prettyDate(new Date(game.createdAt))}</div>
|
||||
<div>
|
||||
{#each games as game}
|
||||
<PlayerList players={game.data.players} />
|
||||
{/each}
|
||||
</div>
|
||||
<form method="GET" action={`/games/${game.id}`}>
|
||||
<input type="submit" value="JOIN" />
|
||||
</form>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<style>
|
||||
main {
|
||||
width: 60rem;
|
||||
margin: auto;
|
||||
}
|
||||
.game-list {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
td {
|
||||
border: solid black 1px;
|
||||
padding: 1rem;
|
||||
}
|
||||
.game-listing {
|
||||
display: flex;
|
||||
gap: 3rem;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
}
|
||||
.game-listing > div {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
form {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
form input {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
|
@ -2,15 +2,21 @@ import { isGameData, type GameData } from "$lib/GameData";
|
||||
import { isListing } from "$lib/Listing";
|
||||
import { isServerResponse } from "$lib/ServerResponse";
|
||||
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}`;
|
||||
let res: Response;
|
||||
let body: unknown;
|
||||
|
||||
const token = cookies.get("access_token");
|
||||
|
||||
if (!token) {
|
||||
error(401);
|
||||
}
|
||||
|
||||
try {
|
||||
res = await fetch(url);
|
||||
res = await fetch(url, { headers: [["Authorization", token]] });
|
||||
body = await res.json();
|
||||
} catch (err) {
|
||||
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)) {
|
||||
return body.item;
|
||||
return { game: body.item };
|
||||
} 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">
|
||||
import type { PageData } from "./$types"
|
||||
let { data }: { data: PageData } = $props();
|
||||
import PlayerList from "$lib/components/PlayerList.svelte";
|
||||
import type { PageData } from "./$types";
|
||||
let { data: page }: { data: PageData } = $props();
|
||||
const handleSubmit = () => {};
|
||||
</script>
|
||||
|
||||
<main>
|
||||
<h1>This is game {data.id}</h1>
|
||||
</main>
|
||||
{#if page.game.data.isStarted}
|
||||
<h1>This is game {page.game.id}</h1>
|
||||
{: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>
|
||||
main {
|
||||
width: 60rem;
|
||||
margin: auto;
|
||||
}
|
||||
#lobby {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
gap: 1rem;
|
||||
min-height: 30rem;
|
||||
}
|
||||
|
||||
#players {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
#chat {
|
||||
flex: 2 2;
|
||||
}
|
||||
|
||||
#controls {
|
||||
margin: 1rem 0;
|
||||
text-align: right;
|
||||
}
|
||||
</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
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
{
|
||||
"kind": "Roll",
|
||||
"player": 2,
|
||||
"value": 4
|
||||
"kind": "SeatPlayers",
|
||||
"player": 1,
|
||||
"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 { readFileSync } from "fs";
|
||||
|
||||
@ -18,5 +18,6 @@ export default defineConfig({
|
||||
coverage: {
|
||||
exclude: [...coverageConfigDefaults.exclude, "svelte.config.js"],
|
||||
},
|
||||
environment: "jsdom",
|
||||
},
|
||||
});
|
||||
|
Reference in New Issue
Block a user