1
0
Fork 0

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:
Jason Fraley 2026-09-08 15:57:01 -04:00
parent 6d4b5ad297
commit bf491fc9cd
5 changed files with 99 additions and 4 deletions

View file

@ -41,6 +41,11 @@ export default async function CustomMinefieldPlayPage({
const mines = Array.isArray(minefield.mines)
? (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 (
<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>
</div>
<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>
</div>
@ -69,8 +75,10 @@ export default async function CustomMinefieldPlayPage({
rows: minefield.rows,
cols: minefield.cols,
mines,
exposeEdgeMines: minefield.exposeEdgeMines ?? true,
}}
currentUserId={user?.id ?? 0}
isAuthor={isAuthor}
/>
</div>
);

View file

@ -4,6 +4,7 @@ import { useState } from "react";
import { Bomb, Loader2 } from "lucide-react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
@ -34,6 +35,7 @@ export function CreateMinefieldDialog({
const [rows, setRows] = useState(9);
const [cols, setCols] = useState(9);
const [mines, setMines] = useState<Set<number>>(new Set());
const [exposeEdgeMines, setExposeEdgeMines] = useState(true);
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
@ -78,7 +80,13 @@ export function CreateMinefieldDialog({
row: Math.floor(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);
if (result.success) {
setOpen(false);
@ -189,7 +197,7 @@ export function CreateMinefieldDialog({
role="gridcell"
aria-label={`row ${Math.floor(index / cols) + 1} col ${(index % cols) + 1}${painted ? " mine" : ""}`}
className={cn(
"size-5 rounded-sm border transition-colors",
"flex size-5 items-center justify-center rounded-sm border transition-colors",
painted
? "border-destructive bg-destructive text-destructive-foreground"
: "border-emerald-700/60 bg-emerald-900/40 hover:border-emerald-400 hover:bg-emerald-700",
@ -208,6 +216,24 @@ export function CreateMinefieldDialog({
</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>}
</div>

View file

@ -3,6 +3,7 @@
import { useCallback, useState } from "react";
import { CustomMinesweeperBoard, type CustomMinefieldData } from "./CustomMinesweeperBoard";
import { CustomMinefieldLeaderboard } from "./CustomMinefieldLeaderboard";
import { ExposureRuleToggle } from "./ExposureRuleToggle";
import { useRealtimePresence } from "@/hooks/useRealtimePresence";
import { useSidebar } from "@/components/ui/sidebar";
import { useEffect } from "react";
@ -10,9 +11,11 @@ import { useEffect } from "react";
export function CustomMinefieldArena({
minefield,
currentUserId,
isAuthor,
}: {
minefield: CustomMinefieldData;
currentUserId: number | string;
isAuthor: boolean;
}) {
const [refreshKey, setRefreshKey] = useState(0);
const [playing, setPlaying] = useState(false);
@ -33,6 +36,9 @@ export function CustomMinefieldArena({
onGameRecorded={() => setRefreshKey((key) => key + 1)}
onPhaseChange={onPhaseChange}
/>
{isAuthor && (
<ExposureRuleToggle minefieldId={minefield.id} exposeEdgeMines={minefield.exposeEdgeMines} />
)}
<CustomMinefieldLeaderboard
minefieldId={minefield.id}
refreshKey={refreshKey}

View file

@ -85,7 +85,8 @@ export function CustomMinefieldBrowser({
<div className="flex w-full max-w-5xl flex-col gap-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<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>
<CreateMinefieldDialog unlocked={unlocked} />
</div>

View 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>
);
}