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)
|
||||
? (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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
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