feat(eod): add author exposure toggle and reduced-XP copy to custom play
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
parent
6d4b5ad297
commit
bf491fc9cd
5 changed files with 99 additions and 4 deletions
|
|
@ -41,6 +41,11 @@ export default async function CustomMinefieldPlayPage({
|
||||||
const mines = Array.isArray(minefield.mines)
|
const mines = Array.isArray(minefield.mines)
|
||||||
? (minefield.mines as { row: number; col: number }[])
|
? (minefield.mines as { row: number; col: number }[])
|
||||||
: [];
|
: [];
|
||||||
|
const authorId =
|
||||||
|
typeof minefield.author === "object" && minefield.author !== null
|
||||||
|
? minefield.author.id
|
||||||
|
: minefield.author;
|
||||||
|
const isAuthor = user !== null && String(authorId) === String(user.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-w-0 flex-col gap-6 p-5">
|
<div className="flex min-w-0 flex-col gap-6 p-5">
|
||||||
|
|
@ -58,7 +63,8 @@ export default async function CustomMinefieldPlayPage({
|
||||||
<h1 className="text-lg font-semibold">{minefield.name}</h1>
|
<h1 className="text-lg font-semibold">{minefield.name}</h1>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Custom minefields are unranked and do not affect official stats or XP.
|
Custom wins award reduced XP (10% of standard payouts); field authors bank 5% of each
|
||||||
|
payout.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -69,8 +75,10 @@ export default async function CustomMinefieldPlayPage({
|
||||||
rows: minefield.rows,
|
rows: minefield.rows,
|
||||||
cols: minefield.cols,
|
cols: minefield.cols,
|
||||||
mines,
|
mines,
|
||||||
|
exposeEdgeMines: minefield.exposeEdgeMines ?? true,
|
||||||
}}
|
}}
|
||||||
currentUserId={user?.id ?? 0}
|
currentUserId={user?.id ?? 0}
|
||||||
|
isAuthor={isAuthor}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { useState } from "react";
|
||||||
import { Bomb, Loader2 } from "lucide-react";
|
import { Bomb, Loader2 } from "lucide-react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
|
|
@ -34,6 +35,7 @@ export function CreateMinefieldDialog({
|
||||||
const [rows, setRows] = useState(9);
|
const [rows, setRows] = useState(9);
|
||||||
const [cols, setCols] = useState(9);
|
const [cols, setCols] = useState(9);
|
||||||
const [mines, setMines] = useState<Set<number>>(new Set());
|
const [mines, setMines] = useState<Set<number>>(new Set());
|
||||||
|
const [exposeEdgeMines, setExposeEdgeMines] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
|
@ -78,7 +80,13 @@ export function CreateMinefieldDialog({
|
||||||
row: Math.floor(key / cols),
|
row: Math.floor(key / cols),
|
||||||
col: key % cols,
|
col: key % cols,
|
||||||
}));
|
}));
|
||||||
const result = await createMinefield({ name: trimmed, rows, cols, mines: positions });
|
const result = await createMinefield({
|
||||||
|
name: trimmed,
|
||||||
|
rows,
|
||||||
|
cols,
|
||||||
|
mines: positions,
|
||||||
|
exposeEdgeMines,
|
||||||
|
});
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
|
|
@ -189,7 +197,7 @@ export function CreateMinefieldDialog({
|
||||||
role="gridcell"
|
role="gridcell"
|
||||||
aria-label={`row ${Math.floor(index / cols) + 1} col ${(index % cols) + 1}${painted ? " mine" : ""}`}
|
aria-label={`row ${Math.floor(index / cols) + 1} col ${(index % cols) + 1}${painted ? " mine" : ""}`}
|
||||||
className={cn(
|
className={cn(
|
||||||
"size-5 rounded-sm border transition-colors",
|
"flex size-5 items-center justify-center rounded-sm border transition-colors",
|
||||||
painted
|
painted
|
||||||
? "border-destructive bg-destructive text-destructive-foreground"
|
? "border-destructive bg-destructive text-destructive-foreground"
|
||||||
: "border-emerald-700/60 bg-emerald-900/40 hover:border-emerald-400 hover:bg-emerald-700",
|
: "border-emerald-700/60 bg-emerald-900/40 hover:border-emerald-400 hover:bg-emerald-700",
|
||||||
|
|
@ -208,6 +216,24 @@ export function CreateMinefieldDialog({
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-start gap-2 rounded-lg border border-border p-3">
|
||||||
|
<Checkbox
|
||||||
|
id="expose-edge-mines"
|
||||||
|
checked={exposeEdgeMines}
|
||||||
|
onCheckedChange={(value) => setExposeEdgeMines(value === true)}
|
||||||
|
className="mt-0.5"
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<label htmlFor="expose-edge-mines" className="text-sm font-medium leading-none">
|
||||||
|
Reveal bordering charges on failed runs
|
||||||
|
</label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Failed runs show mines that touch the swept area. Turn off to reveal none of the
|
||||||
|
remaining charges.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { CustomMinesweeperBoard, type CustomMinefieldData } from "./CustomMinesweeperBoard";
|
import { CustomMinesweeperBoard, type CustomMinefieldData } from "./CustomMinesweeperBoard";
|
||||||
import { CustomMinefieldLeaderboard } from "./CustomMinefieldLeaderboard";
|
import { CustomMinefieldLeaderboard } from "./CustomMinefieldLeaderboard";
|
||||||
|
import { ExposureRuleToggle } from "./ExposureRuleToggle";
|
||||||
import { useRealtimePresence } from "@/hooks/useRealtimePresence";
|
import { useRealtimePresence } from "@/hooks/useRealtimePresence";
|
||||||
import { useSidebar } from "@/components/ui/sidebar";
|
import { useSidebar } from "@/components/ui/sidebar";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
|
|
@ -10,9 +11,11 @@ import { useEffect } from "react";
|
||||||
export function CustomMinefieldArena({
|
export function CustomMinefieldArena({
|
||||||
minefield,
|
minefield,
|
||||||
currentUserId,
|
currentUserId,
|
||||||
|
isAuthor,
|
||||||
}: {
|
}: {
|
||||||
minefield: CustomMinefieldData;
|
minefield: CustomMinefieldData;
|
||||||
currentUserId: number | string;
|
currentUserId: number | string;
|
||||||
|
isAuthor: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [refreshKey, setRefreshKey] = useState(0);
|
const [refreshKey, setRefreshKey] = useState(0);
|
||||||
const [playing, setPlaying] = useState(false);
|
const [playing, setPlaying] = useState(false);
|
||||||
|
|
@ -33,6 +36,9 @@ export function CustomMinefieldArena({
|
||||||
onGameRecorded={() => setRefreshKey((key) => key + 1)}
|
onGameRecorded={() => setRefreshKey((key) => key + 1)}
|
||||||
onPhaseChange={onPhaseChange}
|
onPhaseChange={onPhaseChange}
|
||||||
/>
|
/>
|
||||||
|
{isAuthor && (
|
||||||
|
<ExposureRuleToggle minefieldId={minefield.id} exposeEdgeMines={minefield.exposeEdgeMines} />
|
||||||
|
)}
|
||||||
<CustomMinefieldLeaderboard
|
<CustomMinefieldLeaderboard
|
||||||
minefieldId={minefield.id}
|
minefieldId={minefield.id}
|
||||||
refreshKey={refreshKey}
|
refreshKey={refreshKey}
|
||||||
|
|
|
||||||
|
|
@ -85,7 +85,8 @@ export function CustomMinefieldBrowser({
|
||||||
<div className="flex w-full max-w-5xl flex-col gap-4">
|
<div className="flex w-full max-w-5xl flex-col gap-4">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Community minefields. Playing these does not affect your official stats or XP.
|
Community minefields. Wins award reduced XP (10% of standard payouts); field authors
|
||||||
|
bank 5% of each payout.
|
||||||
</p>
|
</p>
|
||||||
<CreateMinefieldDialog unlocked={unlocked} />
|
<CreateMinefieldDialog unlocked={unlocked} />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
54
src/components/frontend/eod/ExposureRuleToggle.tsx
Normal file
54
src/components/frontend/eod/ExposureRuleToggle.tsx
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { updateMinefield } from "@/app/(frontend)/eod/custom/actions";
|
||||||
|
|
||||||
|
export function ExposureRuleToggle({
|
||||||
|
minefieldId,
|
||||||
|
exposeEdgeMines,
|
||||||
|
}: {
|
||||||
|
minefieldId: number;
|
||||||
|
exposeEdgeMines: boolean;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [checked, setChecked] = useState(exposeEdgeMines);
|
||||||
|
const [pending, setPending] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const onCheckedChange = async (next: boolean) => {
|
||||||
|
setError(null);
|
||||||
|
setPending(true);
|
||||||
|
const result = await updateMinefield({ id: minefieldId, exposeEdgeMines: next });
|
||||||
|
setPending(false);
|
||||||
|
if (result.success) {
|
||||||
|
setChecked(next);
|
||||||
|
router.refresh();
|
||||||
|
} else {
|
||||||
|
setError(result.error ?? "Could not update the exposure rule.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex w-full max-w-5xl flex-col gap-1 rounded-lg border border-border bg-card p-3">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<label htmlFor="exposure-rule" className="text-sm font-medium">
|
||||||
|
Reveal bordering charges on failed runs
|
||||||
|
</label>
|
||||||
|
<Switch
|
||||||
|
id="exposure-rule"
|
||||||
|
checked={checked}
|
||||||
|
disabled={pending}
|
||||||
|
onCheckedChange={onCheckedChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{checked
|
||||||
|
? "Failed runs show mines touching the swept area; everything else stays hidden."
|
||||||
|
: "Failed runs reveal no remaining charges; swept cells turn gray with a question mark."}
|
||||||
|
</p>
|
||||||
|
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue