Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,6 @@
# set the same variables in your Cloudflare build environment for deploys.
PUBLIC_AUTH0_DOMAIN=id.letsbuilda.dev
PUBLIC_AUTH0_CLIENT_ID=THoRnVJxMOfLxMAW0kaNW8nuP1q8q3qQ
PUBLIC_AUTH0_AUDIENCE=https://mundane.letsbuilda.dev
# The Mundane API (letsbuilda/mundane-backend). Locally: http://localhost:8787 (pywrangler dev).
PUBLIC_MUNDANE_API_URL=https://mundane.letsbuilda.dev
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ non-secret** environment variables:
| :----------------------- | :--------------------------- |
| `PUBLIC_AUTH0_DOMAIN` | Auth0 tenant domain |
| `PUBLIC_AUTH0_CLIENT_ID` | Auth0 SPA application client ID |
| `PUBLIC_AUTH0_AUDIENCE` | Auth0 API identifier for the Mundane backend |
| `PUBLIC_MUNDANE_API_URL` | Base URL of the Mundane API |

Copy `.env.example` to `.env` for local development, and set the same variables
in the Cloudflare build environment for deploys (Astro inlines `PUBLIC_*` values
Expand All @@ -58,6 +60,12 @@ In the Auth0 application (type: **Single Page Application**), add both your loca
**Allowed Callback URLs**, **Allowed Logout URLs**, and **Allowed Web Origins**,
and enable **Refresh Token Rotation**.

The `/mundane` page plays [Mundane](https://github.com/letsbuilda/mundane) against
[`mundane-backend`](https://github.com/letsbuilda/mundane-backend), sending the
player's access token with every request. That needs an Auth0 **API** with identifier
`https://mundane.letsbuilda.dev` (RS256) and **Allow Offline Access** turned on, so
the SPA's refresh tokens work for it.

## 👀 Want to learn more?

Feel free to check [our documentation](https://docs.astro.build) or jump into our [Discord server](https://astro.build/chat).
36 changes: 36 additions & 0 deletions public/css/main.css
Original file line number Diff line number Diff line change
Expand Up @@ -85,3 +85,39 @@ main {
margin: 0;
word-break: break-word;
}

.mundane-players {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr));
gap: 1rem;
}

.mundane-player {
padding: 1rem;
border: 1px solid #e2e2e2;
border-radius: 12px;
}

.mundane-player h2,
.mundane-player h3 {
margin: 0 0 0.5rem;
}

.mundane-button {
cursor: pointer;
margin: 0.25rem 0.25rem 0 0;
padding: 0.3rem 0.8rem;
border: 1px solid #d1d5db;
border-radius: 6px;
background: #f9fafb;
font: inherit;
}

.mundane-button:disabled {
cursor: not-allowed;
opacity: 0.5;
}

.mundane-error {
color: #b91c1c;
}
1 change: 1 addition & 0 deletions public/favicon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 4 additions & 0 deletions src/env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ interface ImportMetaEnv {
readonly PUBLIC_AUTH0_DOMAIN: string;
/** Auth0 SPA application client ID. Public, non-secret. */
readonly PUBLIC_AUTH0_CLIENT_ID: string;
/** Auth0 API identifier the backend accepts tokens for. Public, non-secret. */
readonly PUBLIC_AUTH0_AUDIENCE: string;
/** Base URL of the Mundane API. Public, non-secret. */
readonly PUBLIC_MUNDANE_API_URL: string;
}

interface ImportMeta {
Expand Down
1 change: 1 addition & 0 deletions src/layouts/BaseLayout.astro
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ const { title } = Astro.props;
<body>
<nav>
<a href="/">Home</a>
<a href="/mundane">Mundane</a>
<a href="/account">Account</a>
<span class="nav-spacer"></span>
<button id="btn-login" type="button" hidden>Log in</button>
Expand Down
13 changes: 11 additions & 2 deletions src/lib/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,12 @@ import {
// variables (see .env.example).
const domain = import.meta.env.PUBLIC_AUTH0_DOMAIN;
const clientId = import.meta.env.PUBLIC_AUTH0_CLIENT_ID;
// The Auth0 API that access tokens are minted for: the Mundane backend.
const audience = import.meta.env.PUBLIC_AUTH0_AUDIENCE;

if (!domain || !clientId) {
if (!domain || !clientId || !audience) {
throw new Error(
"Missing Auth0 configuration. Set PUBLIC_AUTH0_DOMAIN and PUBLIC_AUTH0_CLIENT_ID.",
"Missing Auth0 configuration. Set PUBLIC_AUTH0_DOMAIN, PUBLIC_AUTH0_CLIENT_ID and PUBLIC_AUTH0_AUDIENCE.",
);
}

Expand Down Expand Up @@ -55,6 +57,7 @@ async function bootstrap(): Promise<Auth0Client> {
cacheLocation: "localstorage",
authorizationParams: {
redirect_uri: window.location.origin,
audience,
scope: "openid profile email",
},
});
Expand Down Expand Up @@ -87,6 +90,12 @@ export async function getUser(): Promise<User | undefined> {
return client.getUser();
}

// An access token for the backend API (`audience`), refreshed silently as needed.
export async function getAccessToken(): Promise<string> {
const client = await getClient();
return client.getTokenSilently();
}

// Redirect to Auth0 to log in, returning to `returnTo` afterwards.
export async function login(
returnTo: string = window.location.pathname,
Expand Down
91 changes: 91 additions & 0 deletions src/lib/mundane.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { getAccessToken } from "./auth";

// Client for the Mundane API (letsbuilda/mundane-backend). Every call carries
// the signed-in player's access token; games belong to whoever created them.
const apiUrl = import.meta.env.PUBLIC_MUNDANE_API_URL;

if (!apiUrl) {
throw new Error("Missing Mundane configuration. Set PUBLIC_MUNDANE_API_URL.");
}

export interface Player {
name: string;
composure: number;
time: number;
hand: string[];
board: string[];
deck: string[];
discard: string[];
}

export interface StackItem {
id: number;
card_id: string;
controller: number;
target_id: number | null;
}

export interface GameState {
players: Player[];
active_player: number;
priority_player: number;
phase: string;
stack: StackItem[];
turn: number;
winner: number | null;
}

export type Action =
| { type: "play_card"; player: number; hand_index: number }
| {
type: "cast_instant";
player: number;
hand_index: number;
target_id?: number | null;
}
| { type: "pass_priority"; player: number };

// The API's error body (`detail`) is the reason a move was refused.
export class MundaneError extends Error {
constructor(
readonly status: number,
detail: string,
) {
super(detail);
}
}

async function call<T>(path: string, body?: unknown): Promise<T> {
const headers: Record<string, string> = {
Authorization: `Bearer ${await getAccessToken()}`,
};
const init: RequestInit = { method: body === undefined ? "GET" : "POST" };
if (body !== undefined) {
headers["Content-Type"] = "application/json";
init.body = JSON.stringify(body);
}
init.headers = headers;

const response = await fetch(`${apiUrl}${path}`, init);
const payload: unknown = await response.json();
if (!response.ok) {
const detail = (payload as { detail?: string }).detail;
throw new MundaneError(response.status, detail ?? "Request failed");
}
return payload as T;
}

export function createGame(): Promise<{ game_id: string; state: GameState }> {
return call("/games", {});
}

export function readGame(gameId: string): Promise<GameState> {
return call(`/games/${encodeURIComponent(gameId)}`);
}

export function submitAction(
gameId: string,
action: Action,
): Promise<GameState> {
return call(`/games/${encodeURIComponent(gameId)}/actions`, action);
}
160 changes: 160 additions & 0 deletions src/pages/mundane.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
---
import BaseLayout from "../layouts/BaseLayout.astro";
---

<BaseLayout title="Mundane · letsbuilda.dev">
<h1>Mundane</h1>
<p>No dragons. No spells. Just Tuesday.</p>

<p id="mundane-status">Loading&hellip;</p>
<button id="mundane-new" type="button" class="mundane-button" hidden>
New game
</button>

<section id="mundane-game" hidden>
<p id="mundane-turn"></p>
<div id="mundane-players" class="mundane-players"></div>
<h2>Stack</h2>
<ol id="mundane-stack"></ol>
<button id="mundane-pass" type="button" class="mundane-button">
Pass priority
</button>
<p id="mundane-error" class="mundane-error" role="alert"></p>
</section>

<script>
import { isAuthenticated, login } from "../lib/auth";
import {
type Action,
createGame,
type GameState,
MundaneError,
readGame,
submitAction,
} from "../lib/mundane";

const status = document.getElementById("mundane-status");
const newButton = document.getElementById("mundane-new");
const gameSection = document.getElementById("mundane-game");
const errorLine = document.getElementById("mundane-error");

// textContent (never innerHTML) keeps API values inert.
const el = (tag: string, text = ""): HTMLElement => {
const node = document.createElement(tag);
node.textContent = text;
return node;
};

let gameId = new URLSearchParams(window.location.search).get("game");

function showError(error: unknown) {
if (errorLine) {
errorLine.textContent =
error instanceof MundaneError ? error.message : String(error);
}
}

async function act(action: Action) {
if (!gameId) return;
try {
render(await submitAction(gameId, action));
} catch (error) {
showError(error);
}
}

function render(state: GameState) {
if (errorLine) errorLine.textContent = "";
const turn = document.getElementById("mundane-turn");
if (turn) {
const priority = state.players[state.priority_player]?.name ?? "";
turn.textContent =
state.winner === null
? `Turn ${state.turn}, ${state.phase}. ${priority} has priority.`
: `${state.players[state.winner]?.name} wins!`;
}

const players = document.getElementById("mundane-players");
players?.replaceChildren(
...state.players.map((player, index) => {
const card = el("article");
card.className = "mundane-player";
card.append(
el("h2", player.name),
el("p", `Composure ${player.composure} · Time ${player.time}`),
el("p", `Board: ${player.board.join(", ") || "—"}`),
);
const hand = el("ul");
player.hand.forEach((cardId, handIndex) => {
const item = el("li", `${cardId} `);
const mine = index === state.priority_player && state.winner === null;
for (const [label, type] of [
["Play", "play_card"],
["Cast", "cast_instant"],
] as const) {
const button = el("button", label) as HTMLButtonElement;
button.type = "button";
button.className = "mundane-button";
button.disabled = !mine;
button.addEventListener("click", () =>
act({ type, player: index, hand_index: handIndex }),
);
item.append(button);
}
hand.append(item);
});
card.append(el("h3", "Hand"), hand);
return card;
}),
);

const stack = document.getElementById("mundane-stack");
stack?.replaceChildren(
...state.stack.map((item) =>
el(
"li",
`${item.card_id} (${state.players[item.controller]?.name ?? "?"})`,
),
),
);

const pass = document.getElementById("mundane-pass");
if (pass instanceof HTMLButtonElement) {
pass.disabled = state.winner !== null;
pass.onclick = () =>
act({ type: "pass_priority", player: state.priority_player });
}

gameSection?.removeAttribute("hidden");
}

newButton?.addEventListener("click", async () => {
try {
const created = await createGame();
gameId = created.game_id;
const url = new URL(window.location.href);
url.searchParams.set("game", gameId);
window.history.replaceState({}, "", url);
render(created.state);
} catch (error) {
showError(error);
if (status) status.textContent = String(error);
}
});

if (!(await isAuthenticated())) {
// Gated page: games belong to a signed-in player.
await login(window.location.pathname + window.location.search);
} else {
newButton?.removeAttribute("hidden");
if (status) status.textContent = "";
if (gameId) {
try {
render(await readGame(gameId));
} catch (error) {
if (status) status.textContent = String(error);
}
}
}
</script>
</BaseLayout>
Loading