Discover: let players hide hacks without an AI label, and show the November 2 deadline (#105)

This commit is contained in:
Jared Schoeny
2026-10-03 19:10:58 -06:00
committed by GitHub
parent 39897c4c37
commit a8a50dcc89
3 changed files with 71 additions and 29 deletions

View File

@@ -9,7 +9,7 @@ import { baseGameLabel, baseRoms, PLATFORM_NAMES, type Platform } from "@/data/b
const RAIL_PLATFORMS: Platform[] = ["GBA", "GBC", "GB", "NDS"];
import { DISCOVER_COMPLETION_STATUSES } from "@/app/discover/search-params";
import type { DiscoverHack } from "@/types/discover";
import { AI_AREAS, AI_PRESETS, aiFilterActive, aiPresetOf, matchesAiFilter, normalizeAiFilter, NO_AI_FILTER, type AiArea, type AiFilter } from "@/utils/aiDisclosure";
import { AI_AREAS, AI_DISCLOSURE_DEADLINE, AI_PRESETS, aiFilterActive, aiPresetOf, matchesAiFilter, normalizeAiFilter, NO_AI_FILTER, type AiArea, type AiFilter } from "@/utils/aiDisclosure";
import { useCloseOnDesktop, useDialog } from "@/hooks/useDialog";
/** The filterable part of the Discover URL state. The sheet edits a draft copy of this. */
@@ -124,7 +124,7 @@ export function FilterFields({ value, onChange, tagGroups, counts, readyCount, o
// Ticking code yourself allows small use by default; only the presets are strict about it.
// Ticking an area leaves Minor, since it's a limit across all areas rather than a pick of them.
const toggleAiArea = (k: AiArea) =>
setAi({ hide: toggleValue(value.ai.hide, k), smallCode: k === "code" ? !value.ai.hide.includes("code") : value.ai.smallCode, minor: false });
setAi({ ...value.ai, hide: toggleValue(value.ai.hide, k), smallCode: k === "code" ? !value.ai.hide.includes("code") : value.ai.smallCode, minor: false });
const visibleGames = ROM_GAMES.map((g) => ({ ...g, dumps: g.dumps.filter((d) => !romQuery || g.label.toLowerCase().includes(romQuery) || d.label.toLowerCase().includes(romQuery)) })).filter((g) => g.dumps.length > 0);
const visibleGroups = tagGroups
@@ -228,9 +228,9 @@ export function FilterFields({ value, onChange, tagGroups, counts, readyCount, o
className={rowH}
label={p.label}
hint={"hint" in p ? p.hint : undefined}
count={counts.ai(p.filter)}
count={counts.ai({ ...p.filter, disclosedOnly: value.ai.disclosedOnly })}
checked={aiPreset === p.value}
onChange={() => setAi(p.filter)}
onChange={() => setAi({ ...p.filter, disclosedOnly: value.ai.disclosedOnly })}
/>
))}
{/* Shows while the areas match no preset; ticking back to a preset's areas selects it again. */}
@@ -266,6 +266,14 @@ export function FilterFields({ value, onChange, tagGroups, counts, readyCount, o
))}
</div>
</Disclosure>
<Check
className={`mt-1 ${rowH}`}
label="Hide hacks without an AI label"
hint={`Creators have until ${AI_DISCLOSURE_DEADLINE} to add missing labels, per the Terms.`}
count={counts.ai({ ...value.ai, disclosedOnly: true })}
checked={value.ai.disclosedOnly}
onChange={() => setAi({ ...value.ai, disclosedOnly: !value.ai.disclosedOnly })}
/>
</Group>
<Group title="Tags">
@@ -389,7 +397,8 @@ function Check({ label, hint, count, checked, onChange, className = "", ready =
<label className={`group/check relative flex cursor-pointer select-none gap-2.5 rounded-md text-sm ${hint ? "items-start py-1.5" : "items-center"} ${className}`}>
<input type="checkbox" checked={checked} onChange={onChange} className="peer sr-only" />
<span className={`${BOX} ${hint ? "mt-px" : ""} ${ready ? "peer-checked:border-ready peer-checked:bg-ready" : "peer-checked:border-accent-deep peer-checked:bg-accent-deep"}`} />
<span className="min-w-0 flex-1 truncate">
{/* A hint wraps, like Radio's; truncating would cut it to one line. */}
<span className={`min-w-0 flex-1 ${hint ? "" : "truncate"}`}>
{label}
{hint && <small className="mt-0.5 block text-xs leading-[1.35] text-text-3">{hint}</small>}
</span>

View File

@@ -27,6 +27,7 @@ import { formatRelativeDate } from "@/utils/format";
import { MenuItem } from "@headlessui/react";
import Image from "next/image";
import Link from "next/link";
import { AI_DISCLOSURE_DEADLINE } from "@/utils/aiDisclosure";
import { FaCircleCheck, FaDiscord, FaGithub, FaTwitter } from "react-icons/fa6";
import { FiAlertTriangle, FiArrowUpRight, FiInfo, FiMail, FiUpload } from "react-icons/fi";
import { RiArchiveStackFill } from "react-icons/ri";
@@ -387,7 +388,22 @@ export default function HackDetailView({ metadata, downloads, canEdit, canUpload
</RailGroup>
<RailGroup title="AI use">
{editing ? <DraftAiLabel initial={metadata.ai} /> : metadata.ai ? <AiLabel disclosure={metadata.ai} /> : <AiLabelMissing />}
{editing ? <DraftAiLabel initial={metadata.ai} /> : metadata.ai ? (
<AiLabel disclosure={metadata.ai} />
) : (
// Archives may never get a label, so the deadline is only for creators' own uploads.
<AiLabelMissing>
{!isArchive && (
<p className="mt-1.5 text-[12.5px]">
The{" "}
<Link href="/terms" prefetch={false} className="text-link-hd">
Terms
</Link>{" "}
give creators until {AI_DISCLOSURE_DEADLINE} to fill it in.
</p>
)}
</AiLabelMissing>
)}
</RailGroup>
{!isInformationalArchive && (

View File

@@ -50,6 +50,9 @@ export interface AiDisclosure {
type AiRow = Pick<Tables<"hacks">, `ai_${AiArea}` | "ai_note" | "ai_disclosed_at">;
/** When hacks from before disclosure was required must have a label, per the Terms (Section 6). */
export const AI_DISCLOSURE_DEADLINE = "November 2";
/** Columns to select wherever a hack's label is needed. */
export const AI_SELECT = "ai_graphics,ai_music,ai_story,ai_translation,ai_events,ai_code,ai_note,ai_disclosed_at";
@@ -73,13 +76,15 @@ export function aiKind(levels: AiLevels): AiKind {
/**
* Discover's AI filter: hide hacks whose label shows AI in any of `hide`. With `smallCode`, code at
* "A little" (a bug fix or a few) still passes. `minor` is a separate mode that ignores `hide`: AI in
* at most MINOR_MAX_AREAS areas, none above Some. Hacks without a label pass every filter; most use
* no AI, and reports catch the rest.
* at most MINOR_MAX_AREAS areas, none above Some. Hacks without a label pass unless `disclosedOnly`;
* most use no AI, and reports catch the rest. `disclosedOnly` sits beside the pick, so switching
* presets keeps it.
*/
export interface AiFilter {
hide: readonly AiArea[];
smallCode: boolean;
minor: boolean;
disclosedOnly: boolean;
}
const MINOR_MAX_AREAS = 2;
@@ -88,19 +93,19 @@ const CONTENT_AREAS = AI_AREAS.map((a) => a.key).filter((k) => k !== "code");
/** Named filters, shown as radios; any other pick of areas is "Custom". */
export const AI_PRESETS = [
{ value: "any", label: "Any", filter: { hide: [], smallCode: false, minor: false } },
{ value: "any", label: "Any", filter: { hide: [], smallCode: false, minor: false, disclosedOnly: false } },
{
value: "minor",
label: "Minor AI usage",
hint: `Up to ${MINOR_MAX_AREAS} areas, none above Some`,
filter: { hide: [], smallCode: false, minor: true },
filter: { hide: [], smallCode: false, minor: true, disclosedOnly: false },
},
{ value: "no-content", label: "No AI content", filter: { hide: CONTENT_AREAS, smallCode: false, minor: false } },
{ value: "no-content", label: "No AI content", filter: { hide: CONTENT_AREAS, smallCode: false, minor: false, disclosedOnly: false } },
{
value: "none",
label: "No direct AI usage",
hint: "Nothing AI-generated was intentionally added by the creator",
filter: { hide: AI_AREAS.map((a) => a.key), smallCode: false, minor: false },
filter: { hide: AI_AREAS.map((a) => a.key), smallCode: false, minor: false, disclosedOnly: false },
},
] as const satisfies readonly { value: string; label: string; hint?: string; filter: AiFilter }[];
export type AiPreset = (typeof AI_PRESETS)[number]["value"];
@@ -109,50 +114,62 @@ export const NO_AI_FILTER: AiFilter = AI_PRESETS[0].filter;
/** Areas in label order, smallCode only while code is hidden, and no areas with minor, so equal filters serialize the same. */
export function normalizeAiFilter(f: AiFilter): AiFilter {
if (f.minor) return { hide: [], smallCode: false, minor: true };
const { disclosedOnly } = f;
if (f.minor) return { hide: [], smallCode: false, minor: true, disclosedOnly };
const hide = AI_AREAS.map((a) => a.key).filter((k) => f.hide.includes(k));
return { hide, smallCode: f.smallCode && hide.includes("code"), minor: false };
return { hide, smallCode: f.smallCode && hide.includes("code"), minor: false, disclosedOnly };
}
/** Whether the filter hides anything; "Any" doesn't. */
/** Whether the filter hides anything; "Any" alone doesn't. */
export function aiFilterActive(f: AiFilter) {
return f.minor || f.hide.length > 0;
return f.minor || f.hide.length > 0 || f.disclosedOnly;
}
/** Stored value (Discover saves the pick): a preset's name, or the areas joined by "." ("code-small" for code with small use allowed). */
const DISCLOSED_TOKEN = "disclosed";
/**
* Stored value (Discover saves the pick): a preset's name, or the areas joined by "." ("code-small" for
* code with small use allowed), then ".disclosed" with disclosedOnly.
*/
export function aiFilterToken(f: AiFilter): string {
const n = normalizeAiFilter(f);
const preset = AI_PRESETS.find((p) => sameAiFilter(p.filter, n));
if (preset) return preset.value;
return n.hide.map((k) => (k === "code" && n.smallCode ? "code-small" : k)).join(".");
const preset = aiPresetOf(n);
const pick = preset !== "custom" ? preset : n.hide.map((k) => (k === "code" && n.smallCode ? "code-small" : k)).join(".");
return n.disclosedOnly ? `${pick}.${DISCLOSED_TOKEN}` : pick;
}
function sameAiFilter(a: AiFilter, b: AiFilter) {
/** Compares the pick only; disclosedOnly doesn't change which preset a filter is. */
function samePick(a: AiFilter, b: AiFilter) {
return a.minor === b.minor && a.smallCode === b.smallCode && a.hide.length === b.hide.length && a.hide.every((k, i) => k === b.hide[i]);
}
export function parseAiFilter(token: string | undefined): AiFilter {
const preset = AI_PRESETS.find((p) => p.value === token);
if (preset || !token) return preset?.filter ?? NO_AI_FILTER;
if (!token) return NO_AI_FILTER;
const parts = token.split(".");
const disclosedOnly = parts.includes(DISCLOSED_TOKEN);
const preset = AI_PRESETS.find((p) => parts.includes(p.value));
if (preset) return { ...preset.filter, disclosedOnly };
const hide = AI_AREAS.map((a) => a.key).filter((k) => parts.includes(k) || (k === "code" && parts.includes("code-small")));
return normalizeAiFilter({ hide, smallCode: parts.includes("code-small"), minor: false });
return normalizeAiFilter({ hide, smallCode: parts.includes("code-small"), minor: false, disclosedOnly });
}
/** The preset a filter matches, or "custom". */
/** The preset a filter's pick matches, or "custom". */
export function aiPresetOf(f: AiFilter): AiPreset | "custom" {
const token = aiFilterToken(f);
return AI_PRESETS.find((p) => p.value === token)?.value ?? "custom";
const n = normalizeAiFilter(f);
return AI_PRESETS.find((p) => samePick(p.filter, n))?.value ?? "custom";
}
/** Discover's note on what the AI filter hides: the preset's name, or a general one for a custom pick. */
export function aiFilterLabel(f: AiFilter): string {
const preset = aiPresetOf(f);
return preset === "custom" ? "Some AI usage hidden" : AI_PRESETS.find((p) => p.value === preset)!.label;
if (preset === "custom") return "Some AI usage hidden";
// "Any" is only active with disclosedOnly.
if (preset === "any") return "Hacks without an AI label hidden";
return AI_PRESETS.find((p) => p.value === preset)!.label;
}
export function matchesAiFilter(levels: AiLevels | null, f: AiFilter) {
if (!levels) return true;
if (!levels) return !f.disclosedOnly;
if (f.minor) {
const used = AI_AREAS.filter((a) => levels[a.key] !== "none");
return used.length <= MINOR_MAX_AREAS && used.every((a) => AI_LEVEL_STEP[levels[a.key]] <= AI_LEVEL_STEP.some);