Add profile photo uploads and custom difficulty support
This commit is contained in:
19
.dockerignore
Normal file
19
.dockerignore
Normal file
@@ -0,0 +1,19 @@
|
||||
.git
|
||||
**/.git
|
||||
node_modules
|
||||
**/node_modules
|
||||
.next
|
||||
**/.next
|
||||
.env*
|
||||
**/.env*
|
||||
.cache
|
||||
**/.cache
|
||||
.data
|
||||
artifacts
|
||||
coverage
|
||||
playwright-report
|
||||
test-results
|
||||
*.log
|
||||
*.hprof
|
||||
desktop/dist
|
||||
public/generated
|
||||
52
Dockerfile
Normal file
52
Dockerfile
Normal file
@@ -0,0 +1,52 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
FROM node:22-bookworm-slim AS dependencies
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --no-audit --no-fund
|
||||
|
||||
FROM dependencies AS builder
|
||||
COPY . .
|
||||
# Next.js embeds NEXT_PUBLIC_* values into browser bundles; the server secret is runtime-only.
|
||||
ARG NEXT_PUBLIC_SUPABASE_URL
|
||||
ARG NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY
|
||||
ENV NODE_ENV=production \
|
||||
NEXT_TELEMETRY_DISABLED=1 \
|
||||
NODE_OPTIONS=--max-old-space-size=640 \
|
||||
NEXT_PUBLIC_SUPABASE_URL=${NEXT_PUBLIC_SUPABASE_URL} \
|
||||
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=${NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY}
|
||||
RUN if [ -z "$NEXT_PUBLIC_SUPABASE_URL" ] || [ -z "$NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY" ]; then \
|
||||
echo "Pass NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY as Docker build arguments." >&2; \
|
||||
exit 1; \
|
||||
fi && npm run build
|
||||
|
||||
FROM node:22-bookworm-slim AS production-dependencies
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --omit=dev --no-audit --no-fund
|
||||
|
||||
FROM node:22-bookworm-slim AS runner
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production \
|
||||
NEXT_TELEMETRY_DISABLED=1 \
|
||||
NODE_OPTIONS=--max-old-space-size=256 \
|
||||
PORT=3000 \
|
||||
HOSTNAME=0.0.0.0 \
|
||||
MINEGEO_HOST=0.0.0.0 \
|
||||
MINEGEO_WORKER_HEAP_MB=512 \
|
||||
UV_THREADPOOL_SIZE=2
|
||||
|
||||
COPY --from=production-dependencies --chown=node:node /app/node_modules ./node_modules
|
||||
COPY --from=builder --chown=node:node /app/.next ./.next
|
||||
COPY --from=builder --chown=node:node /app/public ./public
|
||||
COPY --chown=node:node package.json tsconfig.json next.config.ts ./
|
||||
COPY --chown=node:node scripts/supervise.mjs scripts/runtime-config.mjs scripts/assets.mjs ./scripts/
|
||||
COPY --chown=node:node worker ./worker
|
||||
COPY --chown=node:node shared ./shared
|
||||
COPY --chown=node:node lib ./lib
|
||||
COPY --chown=node:node renderer ./renderer
|
||||
RUN mkdir -p /app/.cache && chown node:node /app/.cache
|
||||
|
||||
USER node
|
||||
EXPOSE 3000
|
||||
CMD ["node", "scripts/supervise.mjs", "production"]
|
||||
@@ -177,6 +177,29 @@
|
||||
box-shadow: 0 12px 30px #0002;
|
||||
min-width: 0;
|
||||
}
|
||||
.account-avatar-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
margin: 24px 0;
|
||||
}
|
||||
.account-avatar-preview {
|
||||
width: 84px;
|
||||
height: 84px;
|
||||
object-fit: cover;
|
||||
box-shadow: 3px 3px #03070b;
|
||||
}
|
||||
.account-avatar-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.account-avatar-input .field {
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
.account-avatar-input .field input {
|
||||
min-height: 40px;
|
||||
padding: 8px;
|
||||
}
|
||||
.settings-heading {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
|
||||
@@ -18,7 +18,7 @@ export default async function DashboardPage() {
|
||||
? required(
|
||||
await admin()
|
||||
.from("game_sessions")
|
||||
.select("id,difficulty,created_at,current_round")
|
||||
.select("id,difficulty,difficulty_settings,created_at,current_round")
|
||||
.eq("owner_id", account.id)
|
||||
.gte("created_at", historyCutoff)
|
||||
.order("created_at", { ascending: false })
|
||||
@@ -52,7 +52,17 @@ export default async function DashboardPage() {
|
||||
<div className="draft-row" key={s.id}>
|
||||
<span>
|
||||
{formatDate(s.created_at)} ·{" "}
|
||||
{t(s.difficulty[0].toUpperCase() + s.difficulty.slice(1))}{" "}
|
||||
{s.difficulty_settings &&
|
||||
typeof s.difficulty_settings === "object" &&
|
||||
!Array.isArray(s.difficulty_settings) &&
|
||||
typeof s.difficulty_settings.name === "string"
|
||||
? ["easy", "normal", "hard"].includes(s.difficulty)
|
||||
? t(s.difficulty_settings.name)
|
||||
: s.difficulty_settings.name
|
||||
: t(
|
||||
s.difficulty[0].toUpperCase() +
|
||||
s.difficulty.slice(1),
|
||||
)}{" "}
|
||||
· {s.current_round}
|
||||
{t("/5 rounds")}
|
||||
</span>
|
||||
|
||||
@@ -801,7 +801,7 @@ nav a:hover,
|
||||
}
|
||||
.difficulty-options {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 10px;
|
||||
margin: 25px 0;
|
||||
}
|
||||
@@ -829,6 +829,51 @@ nav a:hover,
|
||||
font-size: 10px;
|
||||
margin-top: 5px;
|
||||
}
|
||||
.difficulty-library {
|
||||
margin: -12px 0 24px;
|
||||
}
|
||||
.difficulty-builder {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
max-width: 430px;
|
||||
margin-top: 12px;
|
||||
padding: 18px;
|
||||
border: 1px solid var(--line);
|
||||
background: #0d160f;
|
||||
}
|
||||
.difficulty-builder label {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
.difficulty-builder > input,
|
||||
.difficulty-builder-time input {
|
||||
min-height: 42px;
|
||||
padding: 9px 11px;
|
||||
border: 1px solid #34452e;
|
||||
background: var(--panel);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
}
|
||||
.difficulty-builder-time {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
}
|
||||
.difficulty-builder-time input {
|
||||
width: 120px;
|
||||
}
|
||||
.difficulty-builder-time span {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
.difficulty-builder .difficulty-builder-zoom {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
}
|
||||
.difficulty-builder-zoom input {
|
||||
accent-color: var(--green);
|
||||
}
|
||||
.prose {
|
||||
max-width: 850px;
|
||||
}
|
||||
|
||||
@@ -238,6 +238,8 @@ input:focus-visible {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
flex-shrink: 0;
|
||||
display: block;
|
||||
object-fit: cover;
|
||||
background: #314034;
|
||||
box-shadow: 2px 2px #03070b;
|
||||
}
|
||||
|
||||
@@ -83,6 +83,10 @@
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.multiplayer-avatar {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
.multiplayer-table-wrap {
|
||||
overflow-x: auto;
|
||||
margin: 28px 0;
|
||||
@@ -99,6 +103,13 @@
|
||||
.multiplayer-final .multiplayer-you {
|
||||
background: #8dce6614;
|
||||
}
|
||||
.multiplayer-final-avatar {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
margin-right: 6px;
|
||||
}
|
||||
.multiplayer-scoreboard {
|
||||
position: absolute;
|
||||
z-index: 20;
|
||||
@@ -139,6 +150,10 @@
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.multiplayer-scoreboard .multiplayer-avatar {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
}
|
||||
.multiplayer-scoreboard li small {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
|
||||
@@ -82,8 +82,10 @@ export default async function Settings({
|
||||
</section>
|
||||
{active && (
|
||||
<AccountSettings
|
||||
accountId={account.id}
|
||||
email={account.email ?? ""}
|
||||
displayName={profile.display_name}
|
||||
avatarPath={profile.avatar_path}
|
||||
appearance={profile}
|
||||
passwordUpdated={query.password === "updated"}
|
||||
/>
|
||||
|
||||
@@ -4,22 +4,31 @@ import { useI18n } from "@/components/I18nProvider";
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { UserRound, LockKeyhole, Mail } from "lucide-react";
|
||||
import { createBrowserClient } from "@supabase/ssr";
|
||||
import { api } from "./StartGame";
|
||||
import { PasswordForm } from "./PasswordForm";
|
||||
import { displayNameSchema } from "@/shared/account";
|
||||
import {
|
||||
defaultGlowColor,
|
||||
profileAvatarBucket,
|
||||
profileAvatarMaxBytes,
|
||||
profileAvatarMimeTypes,
|
||||
type ProfileAppearance,
|
||||
} from "@/shared/profile-appearance";
|
||||
import { Username } from "./Username";
|
||||
import { PlayerAvatar } from "./PixelArt";
|
||||
export function AccountSettings({
|
||||
accountId,
|
||||
email,
|
||||
displayName,
|
||||
avatarPath,
|
||||
appearance,
|
||||
passwordUpdated = false,
|
||||
}: {
|
||||
accountId: string;
|
||||
email: string;
|
||||
displayName: string;
|
||||
avatarPath: string | null;
|
||||
appearance: ProfileAppearance;
|
||||
passwordUpdated?: boolean;
|
||||
}) {
|
||||
@@ -36,6 +45,80 @@ export function AccountSettings({
|
||||
const [colorBusy, setColorBusy] = useState(false),
|
||||
[colorError, setColorError] = useState(""),
|
||||
[colorSaved, setColorSaved] = useState(false);
|
||||
const [photoPath, setPhotoPath] = useState(avatarPath),
|
||||
[photoBusy, setPhotoBusy] = useState(false),
|
||||
[photoError, setPhotoError] = useState(""),
|
||||
[photoSaved, setPhotoSaved] = useState(false);
|
||||
async function uploadAvatar(event: React.ChangeEvent<HTMLInputElement>) {
|
||||
const input = event.currentTarget;
|
||||
const file = input.files?.[0];
|
||||
input.value = "";
|
||||
setPhotoError("");
|
||||
setPhotoSaved(false);
|
||||
if (!file) return;
|
||||
if (file.size > profileAvatarMaxBytes) {
|
||||
setPhotoError("Images must be 5 MB or smaller.");
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!profileAvatarMimeTypes.includes(
|
||||
file.type as (typeof profileAvatarMimeTypes)[number],
|
||||
)
|
||||
) {
|
||||
setPhotoError("Choose a JPG, PNG or WebP image.");
|
||||
return;
|
||||
}
|
||||
const extension =
|
||||
file.type === "image/jpeg"
|
||||
? "jpg"
|
||||
: file.type === "image/png"
|
||||
? "png"
|
||||
: "webp";
|
||||
const path = `${accountId}/${crypto.randomUUID()}.${extension}`;
|
||||
const client = createBrowserClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
|
||||
);
|
||||
setPhotoBusy(true);
|
||||
let uploaded = false;
|
||||
try {
|
||||
const { error: uploadError } = await client.storage
|
||||
.from(profileAvatarBucket)
|
||||
.upload(path, file, {
|
||||
cacheControl: "31536000",
|
||||
contentType: file.type,
|
||||
upsert: false,
|
||||
});
|
||||
if (uploadError)
|
||||
throw new Error("Could not upload your profile photo. Please try again.");
|
||||
uploaded = true;
|
||||
await api("profile", { avatarPath: path }, "PATCH");
|
||||
setPhotoPath(path);
|
||||
setPhotoSaved(true);
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
if (uploaded)
|
||||
await client.storage.from(profileAvatarBucket).remove([path]);
|
||||
setPhotoError((error as Error).message);
|
||||
} finally {
|
||||
setPhotoBusy(false);
|
||||
}
|
||||
}
|
||||
async function removeAvatar() {
|
||||
setPhotoBusy(true);
|
||||
setPhotoError("");
|
||||
setPhotoSaved(false);
|
||||
try {
|
||||
await api("profile", { avatarPath: null }, "PATCH");
|
||||
setPhotoPath(null);
|
||||
setPhotoSaved(true);
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
setPhotoError((error as Error).message);
|
||||
} finally {
|
||||
setPhotoBusy(false);
|
||||
}
|
||||
}
|
||||
async function saveColor(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
setColorBusy(true);
|
||||
@@ -104,6 +187,45 @@ export function AccountSettings({
|
||||
</div>
|
||||
</div>
|
||||
<form onSubmit={save}>
|
||||
<div className="account-avatar-control">
|
||||
<PlayerAvatar
|
||||
id={accountId}
|
||||
avatarPath={photoPath}
|
||||
className="account-avatar-preview"
|
||||
/>
|
||||
<div className="account-avatar-input">
|
||||
<label className="field">
|
||||
{t("Profile photo")}
|
||||
<input
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
onChange={uploadAvatar}
|
||||
disabled={photoBusy}
|
||||
/>
|
||||
<small>{t("JPG, PNG or WebP · Maximum size: 5 MB.")}</small>
|
||||
</label>
|
||||
{photoPath && (
|
||||
<button
|
||||
type="button"
|
||||
className="button"
|
||||
onClick={removeAvatar}
|
||||
disabled={photoBusy}
|
||||
>
|
||||
{t("Remove photo")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{photoError && (
|
||||
<p role="alert" className="error-message">
|
||||
{t(photoError)}
|
||||
</p>
|
||||
)}
|
||||
{photoSaved && (
|
||||
<p role="status" className="success-message">
|
||||
{t("Profile photo saved.")}
|
||||
</p>
|
||||
)}
|
||||
<label className="field">
|
||||
{t("Display name")}
|
||||
<input
|
||||
|
||||
@@ -15,7 +15,7 @@ import { Brand } from "./Brand";
|
||||
import { Panorama } from "./Panorama";
|
||||
import { GuessMap, type Pin } from "./GuessMap";
|
||||
import { api } from "./StartGame";
|
||||
import { difficulties, type Difficulty, type Bounds } from "@/shared/protocol";
|
||||
import type { DifficultyPreset, Bounds } from "@/shared/protocol";
|
||||
import { roundClock } from "@/shared/round-clock";
|
||||
import { ReportContent } from "./ReportContent";
|
||||
export type RoundResult = {
|
||||
@@ -28,7 +28,8 @@ export type RoundResult = {
|
||||
export type SessionView = {
|
||||
id: string;
|
||||
name: string;
|
||||
difficulty: Difficulty;
|
||||
difficulty: string;
|
||||
difficultySettings: DifficultyPreset;
|
||||
round: number;
|
||||
bounds: Bounds;
|
||||
map: string;
|
||||
@@ -74,7 +75,7 @@ export function Game({ id }: { id: string }) {
|
||||
s.rounds[s.round].deadline!,
|
||||
performance.now(),
|
||||
)
|
||||
: difficulties[s.difficulty].seconds,
|
||||
: s.difficultySettings.seconds,
|
||||
);
|
||||
} catch (e) {
|
||||
if (version === loadVersion.current) setError((e as Error).message);
|
||||
@@ -196,7 +197,7 @@ export function Game({ id }: { id: string }) {
|
||||
<Panorama
|
||||
key={session.round}
|
||||
urls={current.faces}
|
||||
zoom={difficulties[session.difficulty].zoom}
|
||||
zoom={session.difficultySettings.zoom}
|
||||
onReady={ready}
|
||||
onError={onError}
|
||||
/>
|
||||
@@ -289,7 +290,9 @@ export function Game({ id }: { id: string }) {
|
||||
</span>
|
||||
<span>
|
||||
<MousePointer2 size={12} />{" "}
|
||||
{session?.difficulty === "hard" ? t("NO ZOOM") : t("SCROLL TO ZOOM")}
|
||||
{session && !session.difficultySettings.zoom
|
||||
? t("NO ZOOM")
|
||||
: t("SCROLL TO ZOOM")}
|
||||
</span>
|
||||
</div>
|
||||
{(!session || !loaded.panorama || !loaded.map) && !error && (
|
||||
|
||||
@@ -104,7 +104,10 @@ export function LeaderboardTable({
|
||||
</td>
|
||||
<th scope="row">
|
||||
<div className="leaderboard-player">
|
||||
<PlayerAvatar id={entry.player_id} />
|
||||
<PlayerAvatar
|
||||
id={entry.player_id}
|
||||
avatarPath={entry.avatar_path}
|
||||
/>
|
||||
<span className="player-name">
|
||||
<Username name={entry.display_name} appearance={entry} />
|
||||
{entry.player_id === playerId && (
|
||||
@@ -152,7 +155,10 @@ export function WinnersPodium({ leaders }: { leaders: LeaderboardEntry[] }) {
|
||||
place: String(index + 1).padStart(2, "0"),
|
||||
})}
|
||||
</span>
|
||||
<PlayerAvatar id={entry.player_id} />
|
||||
<PlayerAvatar
|
||||
id={entry.player_id}
|
||||
avatarPath={entry.avatar_path}
|
||||
/>
|
||||
<h2>
|
||||
<Username name={entry.display_name} appearance={entry} />
|
||||
</h2>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
import { Username } from "./Username";
|
||||
import { PlayerAvatar } from "./PixelArt";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
@@ -15,7 +16,6 @@ import { GuessMap, type Pin } from "./GuessMap";
|
||||
import { ReportContent } from "./ReportContent";
|
||||
import { useI18n } from "./I18nProvider";
|
||||
import { api } from "@/lib/client-api";
|
||||
import { difficulties } from "@/shared/protocol";
|
||||
import type { RoomView, RoomMedia } from "@/shared/multiplayer";
|
||||
|
||||
export function MultiplayerGame({
|
||||
@@ -101,7 +101,7 @@ export function MultiplayerGame({
|
||||
{media && (
|
||||
<Panorama
|
||||
urls={media.faces}
|
||||
zoom={difficulties[room.difficulty].zoom}
|
||||
zoom={room.difficultySettings.zoom}
|
||||
onReady={panoramaReady}
|
||||
onError={mediaError}
|
||||
/>
|
||||
@@ -139,6 +139,11 @@ export function MultiplayerGame({
|
||||
<ul>
|
||||
{room.players.map((player) => (
|
||||
<li key={player.id}>
|
||||
<PlayerAvatar
|
||||
id={player.id}
|
||||
avatarPath={player.appearance?.avatar_path}
|
||||
className="multiplayer-avatar"
|
||||
/>
|
||||
<span>
|
||||
<Username name={player.name} appearance={player.appearance} />
|
||||
{player.id === room.you && <small> {t("(you)")}</small>}
|
||||
|
||||
@@ -12,6 +12,8 @@ import { api } from "@/lib/client-api";
|
||||
import { roundClock } from "@/shared/round-clock";
|
||||
import type { RoomView } from "@/shared/multiplayer";
|
||||
import { Username } from "./Username";
|
||||
import { PlayerAvatar } from "./PixelArt";
|
||||
import { difficulties } from "@/shared/protocol";
|
||||
|
||||
export function MultiplayerRoom({ id }: { id: string }) {
|
||||
const { t, formatNumber } = useI18n();
|
||||
@@ -181,6 +183,18 @@ export function MultiplayerRoom({ id }: { id: string }) {
|
||||
<span className="eyebrow">
|
||||
<Users size={16} /> {t("Multiplayer")} · {room.name}
|
||||
</span>
|
||||
<p className="muted">
|
||||
{t("Difficulty")}: <strong>
|
||||
{room.difficultySettings.id in difficulties
|
||||
? t(room.difficultySettings.name)
|
||||
: room.difficultySettings.name}
|
||||
</strong>
|
||||
{" · "}
|
||||
{room.difficultySettings.seconds} {t("s per round")} ·{" "}
|
||||
{room.difficultySettings.zoom
|
||||
? t("Zoom allowed")
|
||||
: t("No zoom")}
|
||||
</p>
|
||||
<h1>
|
||||
{finished
|
||||
? t("Final standings")
|
||||
@@ -317,6 +331,11 @@ export function MultiplayerRoom({ id }: { id: string }) {
|
||||
{room.players.map((player) => (
|
||||
<li key={player.id}>
|
||||
<span>
|
||||
<PlayerAvatar
|
||||
id={player.id}
|
||||
avatarPath={player.appearance?.avatar_path}
|
||||
className="multiplayer-avatar"
|
||||
/>
|
||||
{player.id === room.hostId && (
|
||||
<Crown size={17} aria-label={t("Room owner")} />
|
||||
)}{" "}
|
||||
@@ -419,6 +438,11 @@ export function MultiplayerRoom({ id }: { id: string }) {
|
||||
>
|
||||
<td>{player.rank}</td>
|
||||
<td>
|
||||
<PlayerAvatar
|
||||
id={player.id}
|
||||
avatarPath={player.appearance?.avatar_path}
|
||||
className="multiplayer-final-avatar"
|
||||
/>
|
||||
<Username
|
||||
name={player.name}
|
||||
appearance={player.appearance}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { profileAvatarUrl } from "@/shared/profile-appearance";
|
||||
|
||||
export function PixelTrophy({ className = "" }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
@@ -22,7 +24,27 @@ export function PixelTrophy({ className = "" }: { className?: string }) {
|
||||
}
|
||||
|
||||
// Generated markers, not player skins. No third-party avatar requests.
|
||||
export function PlayerAvatar({ id }: { id: string }) {
|
||||
export function PlayerAvatar({
|
||||
id,
|
||||
avatarPath,
|
||||
className = "",
|
||||
}: {
|
||||
id: string;
|
||||
avatarPath?: string | null;
|
||||
className?: string;
|
||||
}) {
|
||||
const avatarUrl = profileAvatarUrl(avatarPath);
|
||||
if (avatarUrl)
|
||||
return (
|
||||
<img
|
||||
className={`player-avatar ${className}`.trim()}
|
||||
src={avatarUrl}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
);
|
||||
const seed = [...id].reduce(
|
||||
(value, char) => (value * 31 + char.charCodeAt(0)) >>> 0,
|
||||
0,
|
||||
@@ -32,7 +54,7 @@ export function PlayerAvatar({ id }: { id: string }) {
|
||||
const shirt = ["#68876a", "#697e91", "#a68a50", "#8b6e7f"][(seed >>> 5) % 4];
|
||||
return (
|
||||
<svg
|
||||
className="player-avatar"
|
||||
className={`player-avatar ${className}`.trim()}
|
||||
viewBox="0 0 8 8"
|
||||
aria-hidden="true"
|
||||
shapeRendering="crispEdges"
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useRouter } from "next/navigation";
|
||||
import { api } from "./StartGame";
|
||||
import { GuessMap } from "./GuessMap";
|
||||
import type { SessionView } from "./Game";
|
||||
import { difficulties } from "@/shared/protocol";
|
||||
export function Results({ id }: { id: string }) {
|
||||
const { t, formatNumber } = useI18n();
|
||||
const [session, setSession] = useState<SessionView | null>(null),
|
||||
@@ -40,7 +41,9 @@ export function Results({ id }: { id: string }) {
|
||||
<>
|
||||
<span className="eyebrow">
|
||||
{t("EXPEDITION COMPLETE ·")}{" "}
|
||||
{t(session.difficulty[0].toUpperCase() + session.difficulty.slice(1))}
|
||||
{session.difficultySettings.id in difficulties
|
||||
? t(session.difficultySettings.name)
|
||||
: session.difficultySettings.name}
|
||||
</span>
|
||||
<h1>{t("Every guess is a story.")}</h1>
|
||||
<p className="lead">
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
"use client";
|
||||
import { useI18n } from "@/components/I18nProvider";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { useEffect, useRef, useState, type FormEvent } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ArrowRight, Timer, ZoomIn, Users } from "lucide-react";
|
||||
import { difficulties, type Difficulty } from "@/shared/protocol";
|
||||
import {
|
||||
difficulties,
|
||||
type DifficultyPreset,
|
||||
} from "@/shared/protocol";
|
||||
import { api } from "@/lib/client-api";
|
||||
export { api } from "@/lib/client-api";
|
||||
export function StartGame({
|
||||
@@ -16,13 +19,65 @@ export function StartGame({
|
||||
hostName: string | null;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const [difficulty, setDifficulty] = useState<Difficulty>("normal"),
|
||||
const [difficulty, setDifficulty] = useState("normal"),
|
||||
[customPresets, setCustomPresets] = useState<DifficultyPreset[]>([]),
|
||||
[showDifficultyForm, setShowDifficultyForm] = useState(false),
|
||||
[difficultyName, setDifficultyName] = useState(""),
|
||||
[difficultySeconds, setDifficultySeconds] = useState(90),
|
||||
[difficultyZoom, setDifficultyZoom] = useState(true),
|
||||
[savingDifficulty, setSavingDifficulty] = useState(false),
|
||||
[mode, setMode] = useState<"solo" | "multiplayer">("solo"),
|
||||
[playerLimit, setPlayerLimit] = useState(4),
|
||||
[loading, setLoading] = useState(false),
|
||||
[error, setError] = useState("");
|
||||
const router = useRouter();
|
||||
const starting = useRef(false);
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
api<{ presets: DifficultyPreset[] }>("difficulty-presets")
|
||||
.then(({ presets }) => {
|
||||
if (!disposed) setCustomPresets(presets);
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
disposed = true;
|
||||
};
|
||||
}, []);
|
||||
const options: DifficultyPreset[] = [
|
||||
...Object.entries(difficulties).map(([id, preset]) => ({
|
||||
id,
|
||||
name: preset.label,
|
||||
seconds: preset.seconds,
|
||||
zoom: preset.zoom,
|
||||
})),
|
||||
...customPresets,
|
||||
];
|
||||
async function saveDifficulty(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (savingDifficulty) return;
|
||||
setSavingDifficulty(true);
|
||||
setError("");
|
||||
try {
|
||||
const { preset } = await api<{ preset: DifficultyPreset }>(
|
||||
"difficulty-presets",
|
||||
{
|
||||
name: difficultyName.trim(),
|
||||
seconds: difficultySeconds,
|
||||
zoom: difficultyZoom,
|
||||
},
|
||||
);
|
||||
setCustomPresets((current) => [...current, preset]);
|
||||
setDifficulty(preset.id);
|
||||
setDifficultyName("");
|
||||
setDifficultySeconds(90);
|
||||
setDifficultyZoom(true);
|
||||
setShowDifficultyForm(false);
|
||||
} catch (failure) {
|
||||
setError((failure as Error).message);
|
||||
} finally {
|
||||
setSavingDifficulty(false);
|
||||
}
|
||||
}
|
||||
async function start() {
|
||||
if (starting.current) return;
|
||||
starting.current = true;
|
||||
@@ -67,27 +122,97 @@ export function StartGame({
|
||||
</button>
|
||||
</div>
|
||||
<div className="difficulty-options">
|
||||
{Object.entries(difficulties).map(([key, value]) => (
|
||||
{options.map((option) => (
|
||||
<button
|
||||
key={key}
|
||||
aria-pressed={difficulty === key}
|
||||
className={difficulty === key ? "selected" : ""}
|
||||
key={option.id}
|
||||
aria-pressed={difficulty === option.id}
|
||||
className={difficulty === option.id ? "selected" : ""}
|
||||
disabled={loading}
|
||||
onClick={() => setDifficulty(key as Difficulty)}
|
||||
onClick={() => setDifficulty(option.id)}
|
||||
>
|
||||
<strong>{t(value.label)}</strong>
|
||||
<strong>
|
||||
{option.id in difficulties ? t(option.name) : option.name}
|
||||
</strong>
|
||||
<span>
|
||||
<Timer size={13} />
|
||||
{value.seconds}
|
||||
{option.seconds}
|
||||
{t("s per round")}
|
||||
</span>
|
||||
<span>
|
||||
<ZoomIn size={13} />
|
||||
{value.zoom ? t("Zoom allowed") : t("No zoom")}
|
||||
{option.zoom ? t("Zoom allowed") : t("No zoom")}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="difficulty-library">
|
||||
<button
|
||||
type="button"
|
||||
className="text-link"
|
||||
aria-expanded={showDifficultyForm}
|
||||
onClick={() => {
|
||||
setShowDifficultyForm((visible) => !visible);
|
||||
setError("");
|
||||
}}
|
||||
disabled={loading || savingDifficulty}
|
||||
>
|
||||
{showDifficultyForm
|
||||
? t("Cancel")
|
||||
: "+ " + t("Create custom difficulty")}
|
||||
</button>
|
||||
{showDifficultyForm && (
|
||||
<form className="difficulty-builder" onSubmit={saveDifficulty}>
|
||||
<label htmlFor="custom-difficulty-name">
|
||||
{t("Difficulty name")}
|
||||
</label>
|
||||
<input
|
||||
id="custom-difficulty-name"
|
||||
value={difficultyName}
|
||||
onChange={(event) => setDifficultyName(event.target.value)}
|
||||
minLength={2}
|
||||
maxLength={40}
|
||||
required
|
||||
autoComplete="off"
|
||||
placeholder={t("Name this difficulty")}
|
||||
disabled={savingDifficulty || loading}
|
||||
/>
|
||||
<label htmlFor="custom-difficulty-seconds">
|
||||
{t("Round time")}
|
||||
</label>
|
||||
<div className="difficulty-builder-time">
|
||||
<input
|
||||
id="custom-difficulty-seconds"
|
||||
type="number"
|
||||
min={15}
|
||||
max={600}
|
||||
step={15}
|
||||
value={difficultySeconds}
|
||||
onChange={(event) =>
|
||||
setDifficultySeconds(Number(event.target.value))
|
||||
}
|
||||
required
|
||||
disabled={savingDifficulty || loading}
|
||||
/>
|
||||
<span>{t("s per round")}</span>
|
||||
</div>
|
||||
<label className="difficulty-builder-zoom">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={difficultyZoom}
|
||||
onChange={(event) => setDifficultyZoom(event.target.checked)}
|
||||
disabled={savingDifficulty || loading}
|
||||
/>
|
||||
{t("Allow zoom")}
|
||||
</label>
|
||||
<button
|
||||
className="button primary"
|
||||
disabled={savingDifficulty || loading}
|
||||
>
|
||||
{savingDifficulty ? t("Saving…") : t("Save difficulty")}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
<p className="muted">
|
||||
{t("5 rounds · 25,000 possible points · No movement")}
|
||||
</p>
|
||||
|
||||
@@ -15,6 +15,12 @@ score zero. The owner can advance from the round standings, or the next round
|
||||
starts automatically after 20 seconds. The final table ranks total points;
|
||||
equal totals share a place.
|
||||
|
||||
Choose one of the built-in difficulties or create a named custom difficulty
|
||||
with its own round time and zoom setting. Custom difficulties are saved in the
|
||||
shared MineGeo catalog and can be reused on any map. Multiplayer rooms snapshot
|
||||
the selected settings when created, so every invited player follows the same
|
||||
rules.
|
||||
|
||||
Players join without an account. A separate HttpOnly cookie identifies each
|
||||
browser, and the room link acts as the invitation. Only the server can access
|
||||
the room tables and functions. Clients receive the current round's signed
|
||||
@@ -43,6 +49,9 @@ Apply `supabase/migrations/20261007071259_multiplayer_rooms.sql` to a new databa
|
||||
before deploying the web update. It has already been applied to MineGeo's
|
||||
configured Supabase project. Deployments continue through `npm run deploy`
|
||||
without a Git-provider connection.
|
||||
Custom difficulty presets also require
|
||||
`supabase/migrations/20261013095201_custom_difficulty_presets.sql` before the
|
||||
web update. No additional environment variables are needed.
|
||||
Chat additionally requires `supabase/migrations/20261007155622_multiplayer_chat.sql`.
|
||||
|
||||
- `npm test`: validation and existing regression checks.
|
||||
|
||||
@@ -114,3 +114,13 @@ permissions. Closed reports move to Done and are deleted from MineGeo and Linear
|
||||
after three days through a durable workflow; no cron job is required.
|
||||
Glow colors and user prefixes also require `20261008075322_profile_identity.sql`
|
||||
before deploying the updated account, catalog, and multiplayer pages.
|
||||
|
||||
Custom difficulty presets for solo and multiplayer games require
|
||||
`20261013095201_custom_difficulty_presets.sql` before deploying. The migration
|
||||
stores the shared presets and snapshots their settings in each game and room.
|
||||
No additional Vercel environment variables are needed.
|
||||
|
||||
Account photo uploads require `20261013123545_profile_avatars.sql` before
|
||||
deploying. The migration creates the public avatar bucket with a 5 MiB file
|
||||
limit and owner-scoped upload/delete policies. Existing public Supabase
|
||||
environment variables are sufficient; no additional secrets are needed.
|
||||
|
||||
@@ -21,6 +21,11 @@ import { createHash } from "node:crypto";
|
||||
import { recoveryCookie, verifyRecoveryGrant } from "./password-recovery";
|
||||
import { requestOrigin } from "./request-origin";
|
||||
import { issueInitialPassword } from "@/shared/initial-password";
|
||||
import {
|
||||
profileAvatarBucket,
|
||||
profileAvatarMaxBytes,
|
||||
profileAvatarMimeTypes,
|
||||
} from "@/shared/profile-appearance";
|
||||
|
||||
type Context = {
|
||||
jsonBody: (r: NextRequest) => Promise<unknown>;
|
||||
@@ -102,6 +107,41 @@ export async function accountApi(
|
||||
{ error: "Earn a username glow before choosing its color" },
|
||||
403,
|
||||
);
|
||||
if (
|
||||
body.avatarPath !== undefined &&
|
||||
body.avatarPath !== null &&
|
||||
body.avatarPath !== current.avatar_path
|
||||
) {
|
||||
const [ownerId, filename] = body.avatarPath.split("/");
|
||||
if (
|
||||
ownerId !== account.id ||
|
||||
!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.(jpg|png|webp)$/i.test(
|
||||
filename ?? "",
|
||||
)
|
||||
)
|
||||
return json({ error: "Choose a valid profile photo" }, 400);
|
||||
const uploaded = await admin()
|
||||
.storage.from(profileAvatarBucket)
|
||||
.info(body.avatarPath);
|
||||
if (uploaded.error || !uploaded.data)
|
||||
return json(
|
||||
{ error: "Upload your profile photo again" },
|
||||
400,
|
||||
);
|
||||
if (
|
||||
!profileAvatarMimeTypes.includes(
|
||||
uploaded.data.contentType as (typeof profileAvatarMimeTypes)[number],
|
||||
) ||
|
||||
!uploaded.data.size ||
|
||||
uploaded.data.size > profileAvatarMaxBytes
|
||||
) {
|
||||
await admin().storage.from(profileAvatarBucket).remove([body.avatarPath]);
|
||||
return json(
|
||||
{ error: "Choose an image no larger than 5 MB" },
|
||||
400,
|
||||
);
|
||||
}
|
||||
}
|
||||
const profile = required(
|
||||
await admin()
|
||||
.from("profiles")
|
||||
@@ -112,13 +152,25 @@ export async function accountApi(
|
||||
...(body.glowColor !== undefined
|
||||
? { glow_color: body.glowColor }
|
||||
: {}),
|
||||
...(body.avatarPath !== undefined
|
||||
? { avatar_path: body.avatarPath }
|
||||
: {}),
|
||||
})
|
||||
.eq("id", account.id)
|
||||
.select(
|
||||
"id,display_name,role,name_glow,glow_color,bug_finder,custom_prefix,custom_prefix_icon",
|
||||
"id,display_name,avatar_path,role,name_glow,glow_color,bug_finder,custom_prefix,custom_prefix_icon",
|
||||
)
|
||||
.single(),
|
||||
);
|
||||
if (
|
||||
body.avatarPath !== undefined &&
|
||||
body.avatarPath !== current.avatar_path
|
||||
) {
|
||||
if (current.avatar_path?.startsWith(`${account.id}/`))
|
||||
await admin()
|
||||
.storage.from(profileAvatarBucket)
|
||||
.remove([current.avatar_path]);
|
||||
}
|
||||
return json({ profile });
|
||||
}
|
||||
return json({ error: "Method not allowed" }, 405);
|
||||
|
||||
38
lib/api.ts
38
lib/api.ts
@@ -7,7 +7,7 @@ import { accountControlsApi } from "./account-controls-api";
|
||||
import {
|
||||
datasetSchema,
|
||||
faces,
|
||||
difficulties,
|
||||
customDifficultySchema,
|
||||
parseUploadManifest,
|
||||
panoramaPath,
|
||||
viewpointSchema,
|
||||
@@ -144,6 +144,36 @@ export async function handle(request: NextRequest, path: string[]) {
|
||||
headers: { "Cache-Control": "no-store" },
|
||||
});
|
||||
}
|
||||
if (path[0] === "difficulty-presets" && path.length === 1) {
|
||||
if (method === "GET") {
|
||||
const presets = required(
|
||||
await client
|
||||
.from("difficulty_presets")
|
||||
.select("id,name,seconds,zoom")
|
||||
.order("name", { ascending: true }),
|
||||
);
|
||||
return NextResponse.json({ presets });
|
||||
}
|
||||
if (method === "POST") {
|
||||
const body = customDifficultySchema.parse(await jsonBody(request));
|
||||
const address =
|
||||
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
|
||||
"unknown";
|
||||
await limit("difficulty-presets:" + hash(address), 8, 3600);
|
||||
const { data, error } = await client
|
||||
.from("difficulty_presets")
|
||||
.insert(body)
|
||||
.select("id,name,seconds,zoom")
|
||||
.single();
|
||||
if (error?.code === "23505")
|
||||
throw new ServiceError(
|
||||
"A difficulty with that name already exists",
|
||||
409,
|
||||
);
|
||||
checkServiceError(error);
|
||||
return NextResponse.json({ preset: data });
|
||||
}
|
||||
}
|
||||
if (path[0] === "health" && path.length === 1 && method === "GET") {
|
||||
const { error } = await client
|
||||
.from("catalog_servers")
|
||||
@@ -172,7 +202,10 @@ export async function handle(request: NextRequest, path: string[]) {
|
||||
const body = z
|
||||
.object({
|
||||
datasetId: idSchema,
|
||||
difficulty: z.enum(["easy", "normal", "hard"]),
|
||||
difficulty: z.union([
|
||||
z.enum(["easy", "normal", "hard"]),
|
||||
idSchema,
|
||||
]),
|
||||
})
|
||||
.parse(await jsonBody(request));
|
||||
const secret = cookieSecret(request) || randomBytes(32).toString("hex");
|
||||
@@ -231,6 +264,7 @@ export async function handle(request: NextRequest, path: string[]) {
|
||||
{
|
||||
id: s.id,
|
||||
difficulty: s.difficulty,
|
||||
difficultySettings: s.difficulty_settings,
|
||||
round: s.current_round,
|
||||
bounds: manifest.bounds,
|
||||
name: manifest.name,
|
||||
|
||||
@@ -64,7 +64,7 @@ export async function catalog() {
|
||||
await client
|
||||
.from("profiles")
|
||||
.select(
|
||||
"id,display_name,name_glow,glow_color,role,bug_finder,custom_prefix,custom_prefix_icon",
|
||||
"id,display_name,avatar_path,name_glow,glow_color,role,bug_finder,custom_prefix,custom_prefix_icon",
|
||||
)
|
||||
.in("id", [
|
||||
...new Set(
|
||||
@@ -106,7 +106,7 @@ export async function getMap(id: string) {
|
||||
? admin()
|
||||
.from("profiles")
|
||||
.select(
|
||||
"display_name,name_glow,glow_color,role,bug_finder,custom_prefix,custom_prefix_icon",
|
||||
"display_name,avatar_path,name_glow,glow_color,role,bug_finder,custom_prefix,custom_prefix_icon",
|
||||
)
|
||||
.eq("id", map.owner_id)
|
||||
.maybeSingle()
|
||||
|
||||
@@ -102,11 +102,20 @@ async function announcementSource(
|
||||
);
|
||||
hostName = profile.display_name;
|
||||
}
|
||||
const difficultySettings =
|
||||
room.difficulty_settings &&
|
||||
typeof room.difficulty_settings === "object" &&
|
||||
!Array.isArray(room.difficulty_settings)
|
||||
? room.difficulty_settings
|
||||
: null;
|
||||
return {
|
||||
id: room.id,
|
||||
name: map.data.name,
|
||||
owner: hostName,
|
||||
difficulty: room.difficulty,
|
||||
difficulty:
|
||||
typeof difficultySettings?.name === "string"
|
||||
? difficultySettings.name
|
||||
: room.difficulty,
|
||||
players: players.length,
|
||||
limit: room.player_limit,
|
||||
};
|
||||
|
||||
@@ -35,7 +35,7 @@ export async function leaderboard({
|
||||
await admin()
|
||||
.from("profiles")
|
||||
.select(
|
||||
"id,name_glow,glow_color,role,bug_finder,custom_prefix,custom_prefix_icon",
|
||||
"id,avatar_path,name_glow,glow_color,role,bug_finder,custom_prefix,custom_prefix_icon",
|
||||
)
|
||||
.in("id", ids),
|
||||
)
|
||||
|
||||
@@ -25,7 +25,7 @@ export async function multiplayerAppearance(room: string, ids: string[]) {
|
||||
await db
|
||||
.from("profiles")
|
||||
.select(
|
||||
"id,display_name,name_glow,glow_color,role,bug_finder,custom_prefix,custom_prefix_icon",
|
||||
"id,display_name,avatar_path,name_glow,glow_color,role,bug_finder,custom_prefix,custom_prefix_icon",
|
||||
)
|
||||
.in("id", accounts),
|
||||
);
|
||||
|
||||
@@ -21,7 +21,7 @@ export async function accountProfile(account: User) {
|
||||
await admin()
|
||||
.from("profiles")
|
||||
.select(
|
||||
"id,display_name,role,name_glow,glow_color,bug_finder,custom_prefix,custom_prefix_icon,account_status,account_reason,account_changed_at",
|
||||
"id,display_name,avatar_path,role,name_glow,glow_color,bug_finder,custom_prefix,custom_prefix_icon,account_status,account_reason,account_changed_at",
|
||||
)
|
||||
.eq("id", account.id)
|
||||
.single(),
|
||||
|
||||
@@ -2,6 +2,12 @@ import type { NextConfig } from "next";
|
||||
import { withWorkflow } from "workflow/next";
|
||||
const config: NextConfig = {
|
||||
poweredByHeader: false,
|
||||
cacheMaxMemorySize: 16 * 1024 * 1024,
|
||||
enablePrerenderSourceMaps: false,
|
||||
experimental: {
|
||||
cpus: 1,
|
||||
preloadEntriesOnStart: false,
|
||||
},
|
||||
outputFileTracingIncludes: { "/downloads": ["./public/tools/**/*.jar"] },
|
||||
serverExternalPackages: ["pg"],
|
||||
turbopack: { root: process.cwd() },
|
||||
|
||||
33
package-lock.json
generated
33
package-lock.json
generated
@@ -21,6 +21,7 @@
|
||||
"server-only": "0.0.1",
|
||||
"sharp": "0.35.5",
|
||||
"three": "0.186.1",
|
||||
"tsx": "4.20.6",
|
||||
"tus-js-client": "4.3.1",
|
||||
"workflow": "5.2.0",
|
||||
"zod": "4.1.12"
|
||||
@@ -37,7 +38,6 @@
|
||||
"agent-browser": "0.21.0",
|
||||
"prettier": "3.6.2",
|
||||
"tailwindcss": "4.1.14",
|
||||
"tsx": "4.20.6",
|
||||
"typescript": "5.9.3"
|
||||
},
|
||||
"engines": {
|
||||
@@ -280,7 +280,6 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -297,7 +296,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -314,7 +312,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -331,7 +328,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -348,7 +344,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -365,7 +360,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -382,7 +376,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -399,7 +392,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -416,7 +408,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -433,7 +424,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -450,7 +440,6 @@
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -467,7 +456,6 @@
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -484,7 +472,6 @@
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -501,7 +488,6 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -518,7 +504,6 @@
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -535,7 +520,6 @@
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -552,7 +536,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -569,7 +552,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -586,7 +568,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -603,7 +584,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -620,7 +600,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -637,7 +616,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -654,7 +632,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -671,7 +648,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -688,7 +664,6 @@
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -705,7 +680,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -6471,7 +6445,6 @@
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
|
||||
"integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
@@ -6776,7 +6749,6 @@
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
@@ -6855,7 +6827,6 @@
|
||||
"version": "4.14.3",
|
||||
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.3.tgz",
|
||||
"integrity": "sha512-++QEw4DIY7WGoukz+/+A/8dGYPT9l9yIadnmSgZ8Rjr3YVSVDipQSO9CdnJo9ePqFqUUqh+wk9uIaoiAwsiPkA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"resolve-pkg-maps": "^1.0.0"
|
||||
@@ -8558,7 +8529,6 @@
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
|
||||
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
|
||||
@@ -9305,7 +9275,6 @@
|
||||
"version": "4.20.6",
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.20.6.tgz",
|
||||
"integrity": "sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"esbuild": "~0.25.0",
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
"server-only": "0.0.1",
|
||||
"sharp": "0.35.5",
|
||||
"three": "0.186.1",
|
||||
"tsx": "4.20.6",
|
||||
"tus-js-client": "4.3.1",
|
||||
"workflow": "5.2.0",
|
||||
"zod": "4.1.12"
|
||||
@@ -58,7 +59,6 @@
|
||||
"agent-browser": "0.21.0",
|
||||
"prettier": "3.6.2",
|
||||
"tailwindcss": "4.1.14",
|
||||
"tsx": "4.20.6",
|
||||
"typescript": "5.9.3"
|
||||
},
|
||||
"engines": {
|
||||
|
||||
@@ -234,7 +234,11 @@ export async function deploy(
|
||||
const files = [];
|
||||
for (const file of payload.files) {
|
||||
const data = Buffer.from(file.data || "", "base64");
|
||||
if (file.encoding !== "base64" || data.length <= 10 * 1024 * 1024) {
|
||||
const isDesktopReleasePart = file.file.startsWith(".desktop-release/part-");
|
||||
if (
|
||||
file.encoding !== "base64" ||
|
||||
(data.length <= 10 * 1024 * 1024 && !isDesktopReleasePart)
|
||||
) {
|
||||
files.push(file);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -42,10 +42,14 @@ export const profileUpdateSchema = z
|
||||
.object({
|
||||
displayName: displayNameSchema.optional(),
|
||||
glowColor: glowColorSchema.optional(),
|
||||
avatarPath: z.string().max(128).nullable().optional(),
|
||||
})
|
||||
.strict()
|
||||
.refine(
|
||||
(value) => value.displayName !== undefined || value.glowColor !== undefined,
|
||||
(value) =>
|
||||
value.displayName !== undefined ||
|
||||
value.glowColor !== undefined ||
|
||||
value.avatarPath !== undefined,
|
||||
"Choose a profile change",
|
||||
);
|
||||
export const initialPasswordSchema = z
|
||||
|
||||
@@ -151,6 +151,7 @@ export type Database = {
|
||||
revision_id: string;
|
||||
host_player_id: string;
|
||||
difficulty: string;
|
||||
difficulty_settings: Json;
|
||||
player_limit: number;
|
||||
status: string;
|
||||
current_round: number;
|
||||
@@ -162,6 +163,7 @@ export type Database = {
|
||||
revision_id: string;
|
||||
host_player_id: string;
|
||||
difficulty: string;
|
||||
difficulty_settings: Json;
|
||||
player_limit: number;
|
||||
status?: string;
|
||||
current_round?: number;
|
||||
@@ -176,6 +178,28 @@ export type Database = {
|
||||
};
|
||||
Relationships: [];
|
||||
};
|
||||
difficulty_presets: {
|
||||
Row: {
|
||||
created_at: string;
|
||||
id: string;
|
||||
name: string;
|
||||
seconds: number;
|
||||
zoom: boolean;
|
||||
};
|
||||
Insert: {
|
||||
created_at?: string;
|
||||
id?: string;
|
||||
name: string;
|
||||
seconds: number;
|
||||
zoom: boolean;
|
||||
};
|
||||
Update: {
|
||||
name?: string;
|
||||
seconds?: number;
|
||||
zoom?: boolean;
|
||||
};
|
||||
Relationships: [];
|
||||
};
|
||||
multiplayer_rounds: {
|
||||
Row: {
|
||||
room_id: string;
|
||||
@@ -476,6 +500,7 @@ export type Database = {
|
||||
created_at: string;
|
||||
current_round: number;
|
||||
difficulty: string;
|
||||
difficulty_settings: Json;
|
||||
id: string;
|
||||
owner_id: string | null;
|
||||
revision_id: string;
|
||||
@@ -485,6 +510,7 @@ export type Database = {
|
||||
created_at?: string;
|
||||
current_round?: number;
|
||||
difficulty: string;
|
||||
difficulty_settings: Json;
|
||||
id?: string;
|
||||
owner_id?: string | null;
|
||||
revision_id: string;
|
||||
@@ -494,6 +520,7 @@ export type Database = {
|
||||
created_at?: string;
|
||||
current_round?: number;
|
||||
difficulty?: string;
|
||||
difficulty_settings?: Json;
|
||||
id?: string;
|
||||
owner_id?: string | null;
|
||||
revision_id?: string;
|
||||
@@ -540,6 +567,7 @@ export type Database = {
|
||||
};
|
||||
profiles: {
|
||||
Row: AccountStatusFields & {
|
||||
avatar_path: string | null;
|
||||
glow_color: string;
|
||||
bug_finder: boolean;
|
||||
custom_prefix: string | null;
|
||||
@@ -551,6 +579,7 @@ export type Database = {
|
||||
role: string;
|
||||
};
|
||||
Insert: Partial<AccountStatusFields> & {
|
||||
avatar_path?: string | null;
|
||||
glow_color?: string;
|
||||
bug_finder?: boolean;
|
||||
custom_prefix?: string | null;
|
||||
@@ -562,6 +591,7 @@ export type Database = {
|
||||
role?: string;
|
||||
};
|
||||
Update: Partial<AccountStatusFields> & {
|
||||
avatar_path?: string | null;
|
||||
glow_color?: string;
|
||||
bug_finder?: boolean;
|
||||
custom_prefix?: string | null;
|
||||
|
||||
@@ -169,6 +169,16 @@
|
||||
"← My account": "← Мой аккаунт",
|
||||
"Your public profile.": "Твой публичный профиль.",
|
||||
"A name for the maps and places you share.": "Имя для карт и мест, которыми ты делишься.",
|
||||
"Profile photo": "Фото профиля",
|
||||
"JPG, PNG or WebP · Maximum size: 5 MB.": "JPG, PNG или WebP · Максимальный размер: 5 МБ.",
|
||||
"Images must be 5 MB or smaller.": "Изображение должно быть не больше 5 МБ.",
|
||||
"Choose a JPG, PNG or WebP image.": "Выбери изображение JPG, PNG или WebP.",
|
||||
"Could not upload your profile photo. Please try again.": "Не удалось загрузить фото профиля. Попробуй ещё раз.",
|
||||
"Upload your profile photo again": "Загрузи фото профиля ещё раз",
|
||||
"Choose a valid profile photo": "Выбери подходящее фото профиля",
|
||||
"Choose an image no larger than 5 MB": "Выбери изображение размером не больше 5 МБ",
|
||||
"Remove photo": "Удалить фото",
|
||||
"Profile photo saved.": "Фото профиля сохранено.",
|
||||
"2–32 characters. Display names do not have to be unique.": "От 2 до 32 символов. Имя не обязано быть уникальным.",
|
||||
"Used for sign-in and recovery. Only you can see it.": "Для входа и восстановления. Почту видишь только ты.",
|
||||
"Save display name": "Сохранить имя",
|
||||
@@ -230,6 +240,13 @@
|
||||
"Terrain of {name}": "Рельеф карты {name}",
|
||||
"YOUR EXPEDITION": "ТВОЯ ЭКСПЕДИЦИЯ",
|
||||
"Choose your challenge.": "Выбери сложность.",
|
||||
"Difficulty": "Сложность",
|
||||
"Create custom difficulty": "Создать свою сложность",
|
||||
"Difficulty name": "Название сложности",
|
||||
"Name this difficulty": "Например, «Хардкор»",
|
||||
"Round time": "Время на раунд",
|
||||
"Allow zoom": "Разрешить приближение",
|
||||
"Save difficulty": "Сохранить сложность",
|
||||
"s per round": "с на раунд",
|
||||
"Zoom allowed": "Можно приближать",
|
||||
"No zoom": "Без приближения",
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { z } from "zod";
|
||||
import { displayNameSchema } from "./account";
|
||||
import type { Bounds, Difficulty } from "./protocol";
|
||||
import type { Bounds, DifficultyPreset } from "./protocol";
|
||||
|
||||
export const createRoomSchema = z
|
||||
.object({
|
||||
datasetId: z.string().uuid(),
|
||||
difficulty: z.enum(["easy", "normal", "hard"]),
|
||||
difficulty: z.union([
|
||||
z.enum(["easy", "normal", "hard"]),
|
||||
z.string().uuid(),
|
||||
]),
|
||||
playerLimit: z.number().int().min(2).max(16),
|
||||
})
|
||||
.strict();
|
||||
@@ -53,7 +56,8 @@ export type RoomView = {
|
||||
id: string;
|
||||
datasetId: string;
|
||||
name: string;
|
||||
difficulty: Difficulty;
|
||||
difficulty: string;
|
||||
difficultySettings: DifficultyPreset;
|
||||
playerLimit: number;
|
||||
playerCount: number;
|
||||
status: "lobby" | "loading" | "playing" | "results" | "finished" | "closed";
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { z } from "zod";
|
||||
export const defaultGlowColor = "#ffe6a1";
|
||||
export const profileAvatarBucket = "minegeo-avatars";
|
||||
export const profileAvatarMaxBytes = 5 * 1024 * 1024;
|
||||
export const profileAvatarMimeTypes = [
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/webp",
|
||||
] as const;
|
||||
export const glowColorSchema = z
|
||||
.string()
|
||||
.regex(/^#[a-fA-F0-9]{6}$/, "Choose a valid glow color")
|
||||
@@ -28,6 +35,7 @@ export const prefixUpdateSchema = z
|
||||
.object({ prefix: customPrefixSchema.nullable(), icon: z.enum(prefixIcons) })
|
||||
.strict();
|
||||
export type ProfileAppearance = {
|
||||
avatar_path?: string | null;
|
||||
name_glow?: boolean;
|
||||
glow_color?: string;
|
||||
role?: string;
|
||||
@@ -36,7 +44,7 @@ export type ProfileAppearance = {
|
||||
custom_prefix_icon?: string;
|
||||
};
|
||||
export const appearanceColumns =
|
||||
"name_glow,glow_color,role,bug_finder,custom_prefix,custom_prefix_icon" as const;
|
||||
"avatar_path,name_glow,glow_color,role,bug_finder,custom_prefix,custom_prefix_icon" as const;
|
||||
export type BadgeUser = ProfileAppearance & {
|
||||
id: string;
|
||||
display_name: string;
|
||||
@@ -44,3 +52,10 @@ export type BadgeUser = ProfileAppearance & {
|
||||
export function glowColor(value?: string) {
|
||||
return glowColorSchema.safeParse(value).data ?? defaultGlowColor;
|
||||
}
|
||||
|
||||
export function profileAvatarUrl(path?: string | null) {
|
||||
const base = process.env.NEXT_PUBLIC_SUPABASE_URL?.replace(/\/$/, "");
|
||||
if (!base || !path) return null;
|
||||
const encodedPath = path.split("/").map(encodeURIComponent).join("/");
|
||||
return `${base}/storage/v1/object/public/${profileAvatarBucket}/${encodedPath}`;
|
||||
}
|
||||
|
||||
@@ -171,6 +171,35 @@ export const difficulties = {
|
||||
hard: { seconds: 30, zoom: false, label: "Hard" },
|
||||
} as const;
|
||||
export type Difficulty = keyof typeof difficulties;
|
||||
export type DifficultyPreset = {
|
||||
id: string;
|
||||
name: string;
|
||||
seconds: number;
|
||||
zoom: boolean;
|
||||
};
|
||||
export const customDifficultySchema = z
|
||||
.object({
|
||||
name: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(2)
|
||||
.max(40)
|
||||
.refine(
|
||||
(name) =>
|
||||
!/[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/u.test(
|
||||
name,
|
||||
),
|
||||
"Difficulty names cannot contain hidden controls",
|
||||
),
|
||||
seconds: z
|
||||
.number()
|
||||
.int()
|
||||
.min(15)
|
||||
.max(600)
|
||||
.refine((seconds) => seconds % 15 === 0),
|
||||
zoom: z.boolean(),
|
||||
})
|
||||
.strict();
|
||||
export function scoreGuess(
|
||||
a: Pick<Point, "x" | "z">,
|
||||
b: Pick<Point, "x" | "z">,
|
||||
|
||||
361
supabase/migrations/20261013095201_custom_difficulty_presets.sql
Normal file
361
supabase/migrations/20261013095201_custom_difficulty_presets.sql
Normal file
@@ -0,0 +1,361 @@
|
||||
-- Store shared custom difficulty presets and snapshot their rules in each game.
|
||||
-- Writes stay behind the MineGeo server API; the exposed public schema grants
|
||||
-- no table access to anon or authenticated clients.
|
||||
|
||||
create table public.difficulty_presets (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
name text not null check (
|
||||
name = btrim(name)
|
||||
and length(name) between 2 and 40
|
||||
and name !~ '[[:cntrl:]]'
|
||||
and name !~ U&'[\\202A-\\202E\\2066-\\2069]'
|
||||
),
|
||||
seconds integer not null check (
|
||||
seconds between 15 and 600 and seconds % 15 = 0
|
||||
),
|
||||
zoom boolean not null,
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
create unique index difficulty_presets_name
|
||||
on public.difficulty_presets (lower(name));
|
||||
alter table public.difficulty_presets enable row level security;
|
||||
revoke all on public.difficulty_presets from public, anon, authenticated;
|
||||
grant select, insert on public.difficulty_presets to service_role;
|
||||
|
||||
alter table public.game_sessions
|
||||
drop constraint if exists game_sessions_difficulty_check;
|
||||
alter table public.game_sessions
|
||||
add column difficulty_settings jsonb;
|
||||
update public.game_sessions
|
||||
set difficulty_settings = jsonb_build_object(
|
||||
'id', difficulty,
|
||||
'name', initcap(difficulty),
|
||||
'seconds', case difficulty when 'easy' then 180 when 'hard' then 30 else 90 end,
|
||||
'zoom', difficulty <> 'hard'
|
||||
);
|
||||
alter table public.game_sessions
|
||||
alter column difficulty_settings set not null;
|
||||
alter table public.game_sessions
|
||||
add constraint game_sessions_difficulty_check check (
|
||||
difficulty in ('easy', 'normal', 'hard')
|
||||
or difficulty ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
|
||||
);
|
||||
|
||||
alter table public.multiplayer_rooms
|
||||
drop constraint if exists multiplayer_rooms_difficulty_check;
|
||||
alter table public.multiplayer_rooms
|
||||
add column difficulty_settings jsonb;
|
||||
update public.multiplayer_rooms
|
||||
set difficulty_settings = jsonb_build_object(
|
||||
'id', difficulty,
|
||||
'name', initcap(difficulty),
|
||||
'seconds', case difficulty when 'easy' then 180 when 'hard' then 30 else 90 end,
|
||||
'zoom', difficulty <> 'hard'
|
||||
);
|
||||
alter table public.multiplayer_rooms
|
||||
alter column difficulty_settings set not null;
|
||||
alter table public.multiplayer_rooms
|
||||
add constraint multiplayer_rooms_difficulty_check check (
|
||||
difficulty in ('easy', 'normal', 'hard')
|
||||
or difficulty ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
|
||||
);
|
||||
|
||||
create or replace function public.resolve_difficulty_preset(p_id text)
|
||||
returns jsonb language plpgsql stable security invoker set search_path = '' as $$
|
||||
declare preset jsonb;
|
||||
begin
|
||||
case p_id
|
||||
when 'easy' then
|
||||
return jsonb_build_object('id', 'easy', 'name', 'Easy', 'seconds', 180, 'zoom', true);
|
||||
when 'normal' then
|
||||
return jsonb_build_object('id', 'normal', 'name', 'Normal', 'seconds', 90, 'zoom', true);
|
||||
when 'hard' then
|
||||
return jsonb_build_object('id', 'hard', 'name', 'Hard', 'seconds', 30, 'zoom', false);
|
||||
end case;
|
||||
|
||||
select jsonb_build_object(
|
||||
'id', p.id::text,
|
||||
'name', p.name,
|
||||
'seconds', p.seconds,
|
||||
'zoom', p.zoom
|
||||
)
|
||||
into preset
|
||||
from public.difficulty_presets p
|
||||
where p.id::text = lower(p_id);
|
||||
if not found then raise exception 'Choose a saved difficulty'; end if;
|
||||
return preset;
|
||||
end;
|
||||
$$;
|
||||
revoke all on function public.resolve_difficulty_preset(text)
|
||||
from public, anon, authenticated;
|
||||
grant execute on function public.resolve_difficulty_preset(text) to service_role;
|
||||
|
||||
create or replace function public.create_game(
|
||||
p_dataset uuid,
|
||||
p_difficulty text,
|
||||
p_owner uuid,
|
||||
p_access_hash text
|
||||
) returns uuid language plpgsql security invoker set search_path = '' as $$
|
||||
declare
|
||||
sid uuid;
|
||||
revision uuid;
|
||||
rev public.revisions;
|
||||
vp jsonb;
|
||||
n integer := 0;
|
||||
settings jsonb;
|
||||
begin
|
||||
settings := public.resolve_difficulty_preset(p_difficulty);
|
||||
revision := public.playable_map_revision(p_dataset, p_owner);
|
||||
select * into rev from public.revisions where id = revision;
|
||||
if not found then raise exception 'Map unavailable'; end if;
|
||||
if jsonb_array_length(rev.manifest->'viewpoints') < 5 then
|
||||
raise exception 'Map has fewer than five viewpoints';
|
||||
end if;
|
||||
insert into public.game_sessions(
|
||||
revision_id, owner_id, access_hash, difficulty, difficulty_settings
|
||||
) values (rev.id, p_owner, p_access_hash, p_difficulty, settings)
|
||||
returning id into sid;
|
||||
for vp in
|
||||
select value from jsonb_array_elements(rev.manifest->'viewpoints')
|
||||
order by random() limit 5
|
||||
loop
|
||||
insert into public.game_rounds(session_id, round_number, viewpoint)
|
||||
values (sid, n, vp);
|
||||
n := n + 1;
|
||||
end loop;
|
||||
return sid;
|
||||
end;
|
||||
$$;
|
||||
|
||||
create or replace function public.activate_round(
|
||||
p_session uuid,
|
||||
p_round integer,
|
||||
p_access_hash text
|
||||
) returns timestamptz language plpgsql security invoker set search_path = '' as $$
|
||||
declare
|
||||
s public.game_sessions;
|
||||
r public.game_rounds;
|
||||
seconds integer;
|
||||
begin
|
||||
select * into s from public.game_sessions
|
||||
where id = p_session and access_hash = p_access_hash for update;
|
||||
if not found then raise exception 'Session not found'; end if;
|
||||
if s.current_round <> p_round then raise exception 'Round is not current'; end if;
|
||||
select * into r from public.game_rounds
|
||||
where session_id = p_session and round_number = p_round for update;
|
||||
if r.result is not null then raise exception 'Round already finished'; end if;
|
||||
if r.deadline is not null then return r.deadline; end if;
|
||||
seconds := (s.difficulty_settings->>'seconds')::integer;
|
||||
update public.game_rounds
|
||||
set deadline = clock_timestamp() + make_interval(secs => seconds)
|
||||
where session_id = p_session and round_number = p_round
|
||||
returning deadline into r.deadline;
|
||||
return r.deadline;
|
||||
end;
|
||||
$$;
|
||||
|
||||
create or replace function public.create_map_room(
|
||||
p_dataset uuid,
|
||||
p_difficulty text,
|
||||
p_name text,
|
||||
p_limit integer,
|
||||
p_access_hash text,
|
||||
p_owner uuid
|
||||
) returns uuid language plpgsql security invoker set search_path = '' as $$
|
||||
declare
|
||||
rid uuid;
|
||||
revision uuid;
|
||||
pid uuid := gen_random_uuid();
|
||||
rev public.revisions;
|
||||
vp jsonb;
|
||||
n integer := 0;
|
||||
settings jsonb;
|
||||
begin
|
||||
if p_limit is null or p_limit not between 2 and 16 then
|
||||
raise exception 'Choose between 2 and 16 players';
|
||||
end if;
|
||||
settings := public.resolve_difficulty_preset(p_difficulty);
|
||||
revision := public.playable_map_revision(p_dataset, p_owner);
|
||||
select * into rev from public.revisions where id = revision;
|
||||
if not found then raise exception 'Map unavailable'; end if;
|
||||
if coalesce(jsonb_array_length(rev.manifest->'viewpoints'), 0) < 5 then
|
||||
raise exception 'Map has fewer than five viewpoints';
|
||||
end if;
|
||||
insert into public.multiplayer_rooms(
|
||||
revision_id, host_player_id, difficulty, difficulty_settings, player_limit
|
||||
) values (rev.id, pid, p_difficulty, settings, p_limit)
|
||||
returning id into rid;
|
||||
insert into public.multiplayer_players(
|
||||
id, room_id, access_hash, display_name
|
||||
) values (pid, rid, p_access_hash, btrim(p_name));
|
||||
for vp in
|
||||
select value from jsonb_array_elements(rev.manifest->'viewpoints')
|
||||
order by random() limit 5
|
||||
loop
|
||||
insert into public.multiplayer_rounds(room_id, round_number, viewpoint)
|
||||
values (rid, n, vp);
|
||||
n := n + 1;
|
||||
end loop;
|
||||
return rid;
|
||||
end;
|
||||
$$;
|
||||
|
||||
create or replace function public.create_multiplayer_room(
|
||||
p_dataset uuid,
|
||||
p_difficulty text,
|
||||
p_name text,
|
||||
p_limit integer,
|
||||
p_access_hash text
|
||||
) returns uuid language sql security invoker set search_path = '' as $$
|
||||
select public.create_map_room(
|
||||
p_dataset, p_difficulty, p_name, p_limit, p_access_hash, null
|
||||
);
|
||||
$$;
|
||||
|
||||
create or replace function public.multiplayer_tick(p_room uuid)
|
||||
returns void language plpgsql security invoker set search_path = '' as $$
|
||||
declare
|
||||
room public.multiplayer_rooms;
|
||||
turn public.multiplayer_rounds;
|
||||
seconds integer;
|
||||
begin
|
||||
select * into room from public.multiplayer_rooms where id = p_room for update;
|
||||
if room.status not in ('finished', 'closed')
|
||||
and room.created_at < clock_timestamp() - interval '24 hours' then
|
||||
update public.multiplayer_rooms
|
||||
set status = 'closed', transition_at = null where id = p_room;
|
||||
return;
|
||||
end if;
|
||||
if room.status = 'results' and room.transition_at <= clock_timestamp() then
|
||||
update public.multiplayer_rooms
|
||||
set current_round = current_round + 1,
|
||||
status = 'loading',
|
||||
transition_at = clock_timestamp() + interval '30 seconds'
|
||||
where id = p_room returning * into room;
|
||||
end if;
|
||||
if room.status = 'loading' and (
|
||||
room.transition_at <= clock_timestamp()
|
||||
or not exists (
|
||||
select 1 from public.multiplayer_players
|
||||
where room_id = p_room and not has_left
|
||||
and ready_round <> room.current_round
|
||||
)
|
||||
) then
|
||||
seconds := (room.difficulty_settings->>'seconds')::integer;
|
||||
update public.multiplayer_rounds
|
||||
set deadline = clock_timestamp() + make_interval(secs => seconds)
|
||||
where room_id = p_room and round_number = room.current_round;
|
||||
update public.multiplayer_rooms
|
||||
set status = 'playing', transition_at = null
|
||||
where id = p_room returning * into room;
|
||||
end if;
|
||||
if room.status <> 'playing' then return; end if;
|
||||
select * into turn from public.multiplayer_rounds
|
||||
where room_id = p_room and round_number = room.current_round;
|
||||
insert into public.multiplayer_guesses(room_id, player_id, round_number, result)
|
||||
select p_room, p.id, room.current_round,
|
||||
jsonb_build_object(
|
||||
'score', 0, 'distance', null, 'timedOut', true, 'guess', null,
|
||||
'actual', jsonb_build_object(
|
||||
'x', turn.viewpoint->'x', 'z', turn.viewpoint->'z'
|
||||
)
|
||||
)
|
||||
from public.multiplayer_players p
|
||||
where p.room_id = p_room
|
||||
and (p.has_left or turn.deadline <= clock_timestamp())
|
||||
on conflict (room_id, player_id, round_number) do nothing;
|
||||
if not exists (
|
||||
select 1 from public.multiplayer_players p
|
||||
where p.room_id = p_room and not exists (
|
||||
select 1 from public.multiplayer_guesses g
|
||||
where g.room_id = p_room and g.player_id = p.id
|
||||
and g.round_number = room.current_round
|
||||
)
|
||||
) then
|
||||
update public.multiplayer_rounds set closed_at = clock_timestamp()
|
||||
where room_id = p_room and round_number = room.current_round;
|
||||
update public.multiplayer_rooms
|
||||
set status = case when current_round = 4 then 'finished' else 'results' end,
|
||||
transition_at = case when current_round = 4 then null
|
||||
else clock_timestamp() + interval '20 seconds' end
|
||||
where id = p_room;
|
||||
end if;
|
||||
end;
|
||||
$$;
|
||||
|
||||
create or replace function public.multiplayer_snapshot(
|
||||
p_room uuid,
|
||||
p_access_hash text
|
||||
) returns jsonb language sql security invoker set search_path = '' as $$
|
||||
with room as (select * from public.multiplayer_rooms where id = p_room),
|
||||
viewer as (
|
||||
select p.id from public.multiplayer_players p
|
||||
where p.room_id = p_room and p.access_hash = p_access_hash and not p.has_left
|
||||
),
|
||||
totals as (
|
||||
select p.*,
|
||||
coalesce((
|
||||
select sum((g.result->>'score')::integer)
|
||||
from public.multiplayer_guesses g
|
||||
join public.multiplayer_rounds r
|
||||
on r.room_id = g.room_id and r.round_number = g.round_number
|
||||
where g.room_id = p_room and g.player_id = p.id and r.closed_at is not null
|
||||
), 0) as total
|
||||
from public.multiplayer_players p where p.room_id = p_room
|
||||
), ranked as (
|
||||
select *, dense_rank() over (order by total desc) as rank from totals
|
||||
)
|
||||
select jsonb_build_object(
|
||||
'id', room.id,
|
||||
'datasetId', rev.dataset_id,
|
||||
'name', rev.manifest->>'name',
|
||||
'difficulty', room.difficulty,
|
||||
'difficultySettings', room.difficulty_settings,
|
||||
'playerLimit', room.player_limit,
|
||||
'playerCount', (select count(*) from totals where not has_left),
|
||||
'status', room.status,
|
||||
'round', room.current_round,
|
||||
'hostId', room.host_player_id,
|
||||
'you', (select id from viewer),
|
||||
'serverNow', floor(extract(epoch from clock_timestamp()) * 1000),
|
||||
'transitionAt', room.transition_at,
|
||||
'players', case when exists(select 1 from viewer) then coalesce((
|
||||
select jsonb_agg(jsonb_build_object(
|
||||
'id', p.id,
|
||||
'name', p.display_name,
|
||||
'left', p.has_left,
|
||||
'ready', p.ready_round = room.current_round,
|
||||
'submitted', exists(
|
||||
select 1 from public.multiplayer_guesses g
|
||||
where g.room_id = p_room and g.player_id = p.id
|
||||
and g.round_number = room.current_round
|
||||
),
|
||||
'total', p.total,
|
||||
'rank', p.rank
|
||||
) order by p.rank, p.joined_at, p.id)
|
||||
from ranked p
|
||||
), '[]'::jsonb) else '[]'::jsonb end,
|
||||
'rounds', case when exists(select 1 from viewer) then coalesce((
|
||||
select jsonb_agg(jsonb_build_object(
|
||||
'number', r.round_number,
|
||||
'deadline', r.deadline,
|
||||
'closedAt', r.closed_at,
|
||||
'results', case when r.closed_at is not null then coalesce((
|
||||
select jsonb_agg(
|
||||
g.result || jsonb_build_object('playerId', g.player_id)
|
||||
order by g.player_id
|
||||
)
|
||||
from public.multiplayer_guesses g
|
||||
where g.room_id = p_room and g.round_number = r.round_number
|
||||
), '[]'::jsonb) else '[]'::jsonb end
|
||||
) order by r.round_number)
|
||||
from public.multiplayer_rounds r where r.room_id = p_room
|
||||
), '[]'::jsonb) else '[]'::jsonb end,
|
||||
'myGuess', case when exists(select 1 from viewer) then (
|
||||
select g.result->'guess' from public.multiplayer_guesses g
|
||||
where g.room_id = p_room and g.player_id = (select id from viewer)
|
||||
and g.round_number = room.current_round
|
||||
) else null end
|
||||
)
|
||||
from room join public.revisions rev on rev.id = room.revision_id;
|
||||
$$;
|
||||
34
supabase/migrations/20261013123545_profile_avatars.sql
Normal file
34
supabase/migrations/20261013123545_profile_avatars.sql
Normal file
@@ -0,0 +1,34 @@
|
||||
alter table public.profiles
|
||||
add column avatar_path text;
|
||||
|
||||
insert into storage.buckets(id, name, public, file_size_limit, allowed_mime_types)
|
||||
values (
|
||||
'minegeo-avatars',
|
||||
'minegeo-avatars',
|
||||
true,
|
||||
5242880,
|
||||
array['image/jpeg', 'image/png', 'image/webp']
|
||||
)
|
||||
on conflict (id) do update set
|
||||
name = excluded.name,
|
||||
public = excluded.public,
|
||||
file_size_limit = excluded.file_size_limit,
|
||||
allowed_mime_types = excluded.allowed_mime_types;
|
||||
|
||||
create policy "MineGeo users upload their own avatars"
|
||||
on storage.objects
|
||||
for insert
|
||||
to authenticated
|
||||
with check (
|
||||
bucket_id = 'minegeo-avatars'
|
||||
and (storage.foldername(name))[1] = (select auth.uid()::text)
|
||||
);
|
||||
|
||||
create policy "MineGeo users delete their own avatars"
|
||||
on storage.objects
|
||||
for delete
|
||||
to authenticated
|
||||
using (
|
||||
bucket_id = 'minegeo-avatars'
|
||||
and (storage.foldername(name))[1] = (select auth.uid()::text)
|
||||
);
|
||||
Reference in New Issue
Block a user