diff --git a/docs/story-player.md b/docs/story-player.md index af98a4c3..3dc1ae54 100644 --- a/docs/story-player.md +++ b/docs/story-player.md @@ -17,10 +17,10 @@ The episodes come from the **story sites** in the storage bucket, one per game r (`assetConfig.storySite`, `PUBLIC_STORY_SITE`, default `https://storage.bdon.moe/moenotes`; TW/HK/MO, every text language) and the JP site (`assetConfig.storySiteJp`, `PUBLIC_STORY_SITE_JP`, default `https://storage.bdon.moe/moenotes/jp`; Japanese only, JP-only episodes such as the event stories). They are separate -sites because JP Live2D model ids overlap the international ones. The player reads both indexes and lists them as one -(`mergeStorySiteEntries`): an episode on both plays from the international site; an unreadable site leaves only its -stories out. Each site is the layout `nnnotes web` writes (the format is ournotes-player's -`docs/story-data-format.md`, manifests `ournotes.story-manifest/2`): +sites because JP Live2D model ids overlap the international ones. The server reads each regional index, matches entries to that region's MasterData, and returns only officially released +stories through `/api/v1/story-site/index`. The browser receives same-origin proxy roots, not the storage bucket URLs; +an episode on both sites remains associated with its source region. Each site is the layout `nnnotes web` writes (the +format is ournotes-player's `docs/story-data-format.md`, manifests `ournotes.story-manifest/2`): ```text {storySite}/stories.json story index (ournotes.stories/1: titles, languages, sizes), read at runtime @@ -35,22 +35,32 @@ bucket lacks. The bucket's CORS allows `https://bdon.moe` (and `http://localhost origin needs that origin added there. The player version (`ournotes-player` in package.json) must read what that workflow writes (`STORY_PLAYER_REF` there). -- The picker lists the build's story data (`getBuildStories`: names, groups, episode numbers and banners, localized at - build time) for the episodes `stories.json` lists, in the story pages' order and in their three lists: main story by - chapter (with its banner; main, another and extra episodes as separate runs), bond stories by pair, and the other - talks by type. Episodes the build does not know yet are listed last under the other talks by the site's own titles. - Chapter and episode artwork keep their source server; entries played from the JP site use the JP Japanese asset - catalog, including episodes already present in the international MasterData whose artwork is not published there. -- A `?story=` the index does not list is opened from its manifest (its `story` block is the index entry): a site build - publishes each manifest as it lands and rewrites `stories.json` at its end. The story page's link checks the manifest - the same way (a `HEAD`), so it never leads to a missing story. +- The picker intersects the filtered cloud index with the locally built story catalog (`getBuildStories`: names, groups, + episode numbers and banners, localized at build time). Cloud-only rows are omitted because cloud presence is not proof + of an in-game release. `/api/v1/story-site/index` checks the same-region MasterData release date and rejects unknown, + missing-date and malformed-date records. The global picker stays locked until every region that lists a story has + officially released it. Chapter and episode artwork keep their source server; entries played from the JP site use the + JP Japanese asset catalog. +- A `?story=` is only an id hint: unknown or unreleased ids are never fetched directly from a cloud manifest. The + story-scoped `/api/v1/story-site/{tw|jp}/stories/{advId}.json` proxy repeats the regional MasterData check, validates + the upstream manifest's `advId` and region, and only serves content-addressed assets and model manifests listed by + that episode's manifests. The proxy rewrites the story root so the player loads those resources through the same + gate. This intentionally drops the previous early-manifest shortcut, where the storage workflow could publish a + manifest before rewriting `stories.json`. The story-page link checks the filtered regional index instead of probing a + public manifest with `HEAD`. - The story starts in the first of the locale's text languages that the episode has (masterdata's order, `assetLanguageOrder`); the controls' labels follow the locale. Switching the language in the header goes through the player (`setLanguage`: it loads that language's files and restarts at the current line), not a reload. - A story's `assets/` files, its models' included, are kept in the browser once downloaded (`storyPlayerFetch`: the - player file cache of `src/lib/cache/player-files.ts` the Live2D viewer uses too), so replaying an episode or switching - its language fetches only the manifests again; the Live2D viewer reads the models the story site has from it, so a - model seen there is already kept too. The settings' Data tab shows what is kept (backgrounds with the images, voices and music with the audio) and clears it ([browser-cache.md](browser-cache.md)). + player file cache of `src/lib/cache/player-files.ts`), so replaying an episode or switching its language fetches only + the manifests again. The settings' Data tab shows what is kept (backgrounds with the images, voices and music with the + audio) and clears it ([browser-cache.md](browser-cache.md)). + +**Deployment limitation:** the same-origin proxy gates requests made through this site, but the configured storage +bucket is still publicly addressable. This repository cannot prevent a caller from requesting a known raw +`{storySite}/stories/.json`, `{storySite}/models/.json`, or `{storySite}/assets/` URL directly. To satisfy +a no-direct-link guarantee, the bucket/CDN must also deny public reads (or serve gated URLs only); until that is changed, +release protection is not complete outside the Moenotes application. ## Layout diff --git a/src/app/api/v1/live2d/[server]/[source]/[...path]/route.ts b/src/app/api/v1/live2d/[server]/[source]/[...path]/route.ts new file mode 100644 index 00000000..0843e851 --- /dev/null +++ b/src/app/api/v1/live2d/[server]/[source]/[...path]/route.ts @@ -0,0 +1,61 @@ +import type { NextRequest } from "next/server"; +import { buildFetch } from "@/lib/build/fetch"; +import { getGatedLive2DBundle, getApprovedLive2DResourcePath, isLive2DSource } from "@/lib/live2d/site-access"; + +export const dynamic = "force-dynamic"; + +interface RouteContext { + params: Promise<{ server: string; source: string; path: string[] }>; +} + +const NO_STORE = { "Cache-Control": "private, no-store, max-age=0" }; +const NOT_FOUND = () => Response.json({ error: "Live2D resource not found" }, { status: 404, headers: NO_STORE }); + +/** Proxy model manifests and assets only for costume identities released in the requested server's MasterData. */ +export async function GET(_request: NextRequest, { params }: RouteContext) { + const { server, source, path } = await params; + if (!Array.isArray(path) || !isLive2DSource(source)) return NOT_FOUND(); + + const manifestMatch = path.length === 2 && path[0] === "models" + ? /^([a-z0-9_]+)\.json$/.exec(path[1] ?? "") + : null; + const isModelAssetPath = path.length === 4 && path[0] === "models" && path[2] === "assets"; + const modelId = manifestMatch?.[1] ?? (isModelAssetPath ? path[1] : undefined); + if (!modelId || !/^[a-z0-9_]+$/.test(modelId)) return NOT_FOUND(); + + const resourcePath = isModelAssetPath + ? getApprovedLive2DResourcePath(path.slice(2).join("/")) + : null; + if (!manifestMatch && (!isModelAssetPath || !resourcePath)) return NOT_FOUND(); + + const bundle = await getGatedLive2DBundle(server, source, modelId).catch((error: unknown) => { + console.error("[live2d] failed to verify a model bundle:", error); + return null; + }); + if (!bundle) return NOT_FOUND(); + + if (manifestMatch) return Response.json(bundle.manifest, { headers: NO_STORE }); + if (!resourcePath || !bundle.assetPaths.has(resourcePath)) return NOT_FOUND(); + + const upstreamUrl = new URL(resourcePath, `${bundle.root.replace(/\/+$/, "")}/`); + let upstream: Response; + try { + upstream = await buildFetch(upstreamUrl, { cache: "no-store", redirect: "error" }); + } catch (error) { + console.error("[live2d] failed to fetch a verified model asset:", error); + return Response.json({ error: "Model resource is temporarily unavailable" }, { status: 502, headers: NO_STORE }); + } + if (!upstream.ok || !upstream.body) return NOT_FOUND(); + + const headers = new Headers(NO_STORE); + const contentType = upstream.headers.get("content-type"); + const contentEncoding = upstream.headers.get("content-encoding"); + if (contentType) headers.set("Content-Type", contentType); + if (contentEncoding) headers.set("Content-Encoding", contentEncoding); + return new Response(upstream.body, { status: 200, headers }); +} + +export async function HEAD(request: NextRequest, context: RouteContext) { + const response = await GET(request, context); + return new Response(null, { status: response.status, headers: response.headers }); +} diff --git a/src/app/api/v1/live2d/index/route.ts b/src/app/api/v1/live2d/index/route.ts new file mode 100644 index 00000000..ef2ec04c --- /dev/null +++ b/src/app/api/v1/live2d/index/route.ts @@ -0,0 +1,22 @@ +import type { NextRequest } from "next/server"; +import { isGameServer } from "@/config/servers"; +import { getReleasedLive2DModels } from "@/lib/live2d/site-access"; + +export const dynamic = "force-dynamic"; + +const NO_STORE = { "Cache-Control": "private, no-store, max-age=0" }; + +/** A cloud model index is only a locator; models are listed only after regional MasterData verifies their costume. */ +export async function GET(request: NextRequest) { + const server = request.nextUrl.searchParams.get("server") ?? ""; + if (!isGameServer(server)) { + return Response.json({ error: "Invalid game server" }, { status: 400, headers: NO_STORE }); + } + try { + const models = await getReleasedLive2DModels(server); + return Response.json({ models }, { headers: { ...NO_STORE, "X-Moenotes-Live2D-Region": server } }); + } catch (error) { + console.error("[live2d] failed to verify the regional model index:", error); + return Response.json({ error: "Model index is temporarily unavailable" }, { status: 502, headers: NO_STORE }); + } +} diff --git a/src/app/api/v1/story-site/[server]/[...path]/route.ts b/src/app/api/v1/story-site/[server]/[...path]/route.ts new file mode 100644 index 00000000..4fd06082 --- /dev/null +++ b/src/app/api/v1/story-site/[server]/[...path]/route.ts @@ -0,0 +1,85 @@ +import type { NextRequest } from "next/server"; +import { buildFetch } from "@/lib/build/fetch"; +import { getStorySites } from "@/lib/story/player-data"; +import { + getApprovedStoryResourcePath, + getGatedStoryBundle, + isStorySiteRegion, +} from "@/lib/story/site-access"; + +export const dynamic = "force-dynamic"; + +interface RouteContext { + params: Promise<{ server: string; path: string[] }>; +} + +const PRIVATE_NO_STORE = { "Cache-Control": "private, no-store, max-age=0" }; +const NOT_FOUND = () => Response.json({ error: "Story resource not found" }, { status: 404, headers: PRIVATE_NO_STORE }); + +/** + * Proxy a story manifest or one of the assets it names. The episode's regional MasterData release date is checked + * before every request, and file requests must be listed by that released episode's story/model manifests. + */ +export async function GET(_request: NextRequest, { params }: RouteContext) { + const { server, path } = await params; + if (!isStorySiteRegion(server) || !Array.isArray(path)) return NOT_FOUND(); + + const manifestMatch = path.length === 2 && path[0] === "stories" + ? /^([1-9]\d*)\.json$/.exec(path[1] ?? "") + : null; + if (manifestMatch) { + const advId = Number(manifestMatch[1]); + const bundle = await getGatedStoryBundle(server, advId).catch((error: unknown) => { + console.error("[story-player] failed to verify a story manifest:", error); + return null; + }); + return bundle + ? Response.json(bundle.manifest, { headers: PRIVATE_NO_STORE }) + : NOT_FOUND(); + } + + const resourceMatch = path.length >= 4 && path[0] === "stories" + ? /^([1-9]\d*)$/.exec(path[1] ?? "") + : null; + if (!resourceMatch) return NOT_FOUND(); + + const advId = Number(resourceMatch[1]); + const resourcePath = getApprovedStoryResourcePath(path.slice(2).join("/")); + if (!resourcePath) return NOT_FOUND(); + + const bundle = await getGatedStoryBundle(server, advId).catch((error: unknown) => { + console.error("[story-player] failed to verify a story resource:", error); + return null; + }); + if (!bundle) return NOT_FOUND(); + + const modelManifest = bundle.modelManifests.get(resourcePath); + if (modelManifest) { + return Response.json(modelManifest, { headers: PRIVATE_NO_STORE }); + } + if (!bundle.assetPaths.has(resourcePath)) return NOT_FOUND(); + + const site = getStorySites().find((candidate) => candidate.server === server); + if (!site) return NOT_FOUND(); + const upstreamUrl = new URL(resourcePath, `${site.root.replace(/\/+$/, "")}/`); + let upstream: Response; + try { + upstream = await buildFetch(upstreamUrl, { cache: "no-store", redirect: "error" }); + } catch (error) { + console.error("[story-player] failed to fetch a verified story asset:", error); + return Response.json({ error: "Story resource is temporarily unavailable" }, { status: 502, headers: PRIVATE_NO_STORE }); + } + if (!upstream.ok || !upstream.body) return NOT_FOUND(); + + const headers = new Headers(PRIVATE_NO_STORE); + const contentType = upstream.headers.get("content-type"); + const contentEncoding = upstream.headers.get("content-encoding"); + if (contentType) headers.set("Content-Type", contentType); + if (contentEncoding) headers.set("Content-Encoding", contentEncoding); + return new Response(upstream.body, { status: 200, headers }); +} + +export async function HEAD(request: NextRequest, context: RouteContext) { + const response = await GET(request, context); + return new Response(null, { status: response.status, headers: response.headers }); +} diff --git a/src/app/api/v1/story-site/index/route.ts b/src/app/api/v1/story-site/index/route.ts new file mode 100644 index 00000000..2688a581 --- /dev/null +++ b/src/app/api/v1/story-site/index/route.ts @@ -0,0 +1,32 @@ +import type { NextRequest } from "next/server"; +import { + getReleasedStorySiteIndex, + isStorySiteRegion, + toPublicStoryIndexEntry, +} from "@/lib/story/site-access"; + +export const dynamic = "force-dynamic"; + +/** + * A cloud listing is only a locator, never release evidence. Return a small, sanitized list only after the same-region + * MasterData confirms each story's official in-game release time. + */ +export async function GET(request: NextRequest) { + const server = request.nextUrl.searchParams.get("server") ?? ""; + if (!isStorySiteRegion(server)) { + return Response.json({ error: "Unsupported story site" }, { status: 400, headers: { "Cache-Control": "no-store" } }); + } + + try { + const { stories } = await getReleasedStorySiteIndex(server); + return Response.json({ stories: stories.map(toPublicStoryIndexEntry) }, { + headers: { + "Cache-Control": "public, max-age=30, stale-while-revalidate=60", + "X-Moenotes-Story-Region": server, + }, + }); + } catch (error) { + console.error("[story-player] failed to verify the regional story index:", error); + return Response.json({ error: "Story index is temporarily unavailable" }, { status: 502, headers: { "Cache-Control": "no-store" } }); + } +} diff --git a/src/components/cards/CardDetail.tsx b/src/components/cards/CardDetail.tsx index 93fb2b01..275b096d 100644 --- a/src/components/cards/CardDetail.tsx +++ b/src/components/cards/CardDetail.tsx @@ -18,7 +18,7 @@ import { localizePath } from "@/i18n/routing"; import { getRoutePathById } from "@/lib/route/registry"; import { useNow } from "@/lib/schedule/use-now"; import { isSpoiler, useSpoiler } from "@/lib/settings/spoiler"; -import { SpoilerNotice, DeveloperTestingBanner } from "@/components/shared/SpoilerNotice"; +import SpoilerNotice from "@/components/shared/SpoilerNotice"; import { getCardBackgroundUrl, getCardCharacterUrl, @@ -137,7 +137,6 @@ function CardDetailView({ locale, data, materials, serverSwitch }: { locale: App return (
- {isUnreleased && } {card.characterName}} diff --git a/src/components/characters/CharacterDetail.tsx b/src/components/characters/CharacterDetail.tsx index 28461762..fd6da005 100644 --- a/src/components/characters/CharacterDetail.tsx +++ b/src/components/characters/CharacterDetail.tsx @@ -37,7 +37,7 @@ interface Props { locale: AppLocale; characterId: number; initialData: ServerFacetedValue; - progressionData: CharacterProgressionData | null; + progressionData: ServerFacetedValue | null; /** Bonds, missions and related entries (build-character-extras.ts). */ extrasData: ServerFacetedValue; servers: GameServer[]; @@ -52,12 +52,16 @@ interface DetailData { export default function CharacterDetail({ locale, initialData, progressionData, extrasData, servers }: Props) { const [server, pickServer] = useContentServer(locale, servers); const data = useMemo(() => moveReleaseUrls(valueForServer(initialData, server), entityServer(initialData, server)), [initialData, server]); + const progression = useMemo( + () => progressionData?.servers.includes(server) ? valueForServer(progressionData, server) : null, + [progressionData, server], + ); const extras = useMemo(() => moveReleaseUrls(valueForServer(extrasData, server), entityServer(extrasData, server)), [extrasData, server]); // The hero carries the server switch beside the name, so the scope leaves its own out. const serverSwitch = ; return ( - + ); } diff --git a/src/components/events/EventDetail.tsx b/src/components/events/EventDetail.tsx index c696314c..abcf6534 100644 --- a/src/components/events/EventDetail.tsx +++ b/src/components/events/EventDetail.tsx @@ -32,7 +32,7 @@ import { useNow } from "@/lib/schedule/use-now"; import { useDisplayTimeZone } from "@/lib/schedule/use-display-time-zone"; import { useSpoiler } from "@/lib/settings/spoiler"; import { SPOILER_REFERENCE_NOW } from "@/lib/settings/spoiler-reference"; -import { SpoilerNotice, DeveloperTestingBanner } from "@/components/shared/SpoilerNotice"; +import SpoilerNotice from "@/components/shared/SpoilerNotice"; import { entityServer, valueForServer, type ServerFacetedValue } from "@/lib/servers/facets"; import { useAssetUrl, useContentServer } from "@/lib/servers/use-content-server"; @@ -119,7 +119,6 @@ function EventDetailView({ locale, server, event, serverSwitch, schedules }: { l return (
- {isUnreleased && } - {isUnreleased && } - {isUnreleased && } - -
- {t(locale, "settings.spoiler.devTestingBadge")}: - {t(locale, "settings.spoiler.devOnly")} -
-
- ); -} - export default SpoilerNotice; diff --git a/src/components/story/StoryPlayerLink.tsx b/src/components/story/StoryPlayerLink.tsx index e16986ad..4792e3cb 100644 --- a/src/components/story/StoryPlayerLink.tsx +++ b/src/components/story/StoryPlayerLink.tsx @@ -7,7 +7,7 @@ import { hasSiteStory } from "@/lib/story/player-client"; import { getStoryPlayerHref } from "@/lib/story/player-data"; /** - * The story player's link for an episode, shown once the story site is known to have it (a HEAD of its manifest). + * The story player's link for an episode, shown once a regional release-filtered index lists it. * `button` on a story page's header; `compact` among a dialog's header actions. */ export default function StoryPlayerLink({ locale, advId, variant = "button" }: { locale: AppLocale; advId: number; variant?: "button" | "compact" }) { diff --git a/src/components/support-cards/SupportCardDetail.tsx b/src/components/support-cards/SupportCardDetail.tsx index c15c5636..b7f930db 100644 --- a/src/components/support-cards/SupportCardDetail.tsx +++ b/src/components/support-cards/SupportCardDetail.tsx @@ -18,7 +18,7 @@ import { localizePath } from "@/i18n/routing"; import { getRoutePathById } from "@/lib/route/registry"; import { useNow } from "@/lib/schedule/use-now"; import { isSpoiler, useSpoiler } from "@/lib/settings/spoiler"; -import { SpoilerNotice, DeveloperTestingBanner } from "@/components/shared/SpoilerNotice"; +import SpoilerNotice from "@/components/shared/SpoilerNotice"; import { getSupportCardFullUrl, getSupportCardThumbnailUrl, @@ -125,7 +125,6 @@ function SupportCardDetailView({ locale, data, materials, serverSwitch }: { loca return (
- {isUnreleased && } ; + bands: Array<{ id: number; name: string; servers: GameServer[] }>; } -type ListState = { kind: "loading" } | { kind: "error"; detail: string } | { kind: "ready"; models: Live2DModel[] }; +type ListState = { kind: "loading" } | { kind: "error"; detail: string } | { kind: "ready"; server: GameServer; models: Live2DModel[] }; type Kind = Live2DModel["kind"]; const KINDS: readonly Kind[] = ["story", "live", "side"]; @@ -43,7 +49,8 @@ interface ModelEntry { * The Live2D viewer: the quick filter finds a character (grouped by band, side characters after them) and narrows the * costumes by type and text; the panel beside the stage switches the character's costumes, motions and expressions. */ -export default function Live2DViewer({ locale, characters, bands }: Props) { +export default function Live2DViewer({ locale, servers, characters, bands }: Props) { + const [server, pickServer] = useContentServer(locale, servers); const [list, setList] = useState({ kind: "loading" }); const [listAttempt, setListAttempt] = useState(0); const [subject, setSubject] = useState(null); @@ -56,18 +63,26 @@ export default function Live2DViewer({ locale, characters, bands }: Props) { useEffect(() => { const controller = new AbortController(); setList({ kind: "loading" }); - fetchLive2DModels(controller.signal).then( - (models) => setList({ kind: "ready", models }), + setRestored(false); + setModelId(null); + setSubject(null); + fetchLive2DModels(controller.signal, server).then( + (models) => setList({ kind: "ready", server, models }), (error: unknown) => { if (!controller.signal.aborted) setList({ kind: "error", detail: error instanceof Error ? error.message : String(error) }); }, ); return () => controller.abort(); - }, [listAttempt]); + }, [listAttempt, server]); - const characterById = useMemo(() => new Map(characters.map((character) => [character.id, character])), [characters]); + const currentList: ListState = list.kind === "ready" && list.server !== server ? { kind: "loading" } : list; + + const characterById = useMemo( + () => new Map(characters.filter((character) => character.servers.includes(server)).map((character) => [character.id, character])), + [characters, server], + ); const entries = useMemo(() => { - if (list.kind !== "ready") return []; + if (list.kind !== "ready" || list.server !== server) return []; return list.models.map((model) => { const name = (model.characterId !== null ? characterById.get(model.characterId)?.name : undefined) ?? (model.sideName ?? model.id).replaceAll("_", " "); const costume = costumeLabel(locale, model); @@ -107,15 +122,14 @@ export default function Live2DViewer({ locale, characters, bands }: Props) { // The address names the model: open it (and its owner) once the list has arrived. useEffect(() => { - if (list.kind !== "ready" || restored) return; - const requested = parseLive2DViewerSearch(window.location.search); - const found = requested ? list.models.find((model) => model.id === requested) : undefined; + if (list.kind !== "ready" || list.server !== server || restored) return; + const found = findRequestedLive2DModel(window.location.search, list.models); if (found) { setModelId(found.id); setSubject(subjectOf(found)); } setRestored(true); - }, [list, restored]); + }, [list, restored, server]); useEffect(() => { if (!restored) return; @@ -195,8 +209,9 @@ export default function Live2DViewer({ locale, characters, bands }: Props) { ]); return ( -
- + +
+ {current ? (
) : ( - setListAttempt((value) => value + 1)} /> + setListAttempt((value) => value + 1)} /> )}

{t(locale, "live2d.credit")}

-
+
+ ); } diff --git a/src/components/tools/StoryPlayerTool.tsx b/src/components/tools/StoryPlayerTool.tsx index 1f1cf1f2..70fc5511 100644 --- a/src/components/tools/StoryPlayerTool.tsx +++ b/src/components/tools/StoryPlayerTool.tsx @@ -9,9 +9,11 @@ import { t } from "@/i18n"; import { localizePath } from "@/i18n/routing"; import Modal from "@/components/shared/Modal"; import Popover from "@/components/shared/Popover"; -import { fetchSiteStory, fetchStorySite, type StoryRuntimes } from "@/lib/story/player-client"; +import { fetchStorySite, type StoryRuntimes } from "@/lib/story/player-client"; import { buildStoryPlayerEntries, + isStoryPlayerEntryReleased, + isStoryPlayerRequestReleased, formatMegabytes, getStoryManifestUrl, getStoryPlayerHref, @@ -28,7 +30,7 @@ import { import StoryStage from "@/components/tools/StoryStage"; import StoryPickerDialog from "@/components/tools/StoryPickerDialog"; import SpoilerNotice from "@/components/shared/SpoilerNotice"; -import { isSpoiler, useSpoilerSetting } from "@/lib/settings/spoiler"; +import { useSpoilerSetting } from "@/lib/settings/spoiler"; import { StageSignature } from "@/components/tools/Live2DStage"; import { AutoIcon, @@ -65,8 +67,6 @@ export default function StoryPlayerTool({ locale, stories }: Props) { const [pickerOpen, setPickerOpen] = useState(false); const [infoOpen, setInfoOpen] = useState(false); const [runtimes, setRuntimes] = useState(null); - // A story the address names that the index does not list (yet), read from its manifest. - const [linked, setLinked] = useState(null); useEffect(() => { const controller = new AbortController(); @@ -82,43 +82,25 @@ export default function StoryPlayerTool({ locale, stories }: Props) { const entries = useMemo(() => { if (site.kind !== "ready") return []; - const siteEntries = linked && !site.entries.some((entry) => entry.advId === linked.advId) ? [...site.entries, linked] : site.entries; - return buildStoryPlayerEntries(stories, siteEntries, locale, t(locale, "storyPlayer.otherEpisodes")); - }, [site, linked, stories, locale]); + return buildStoryPlayerEntries(stories, site.entries); + }, [site, stories]); - const { showSpoiler, now } = useSpoilerSetting(); - const visibleEntries = useMemo(() => { - if (showSpoiler) return entries; - return entries.filter((entry) => !entry.startAt || !isSpoiler(entry.startAt, now)); - }, [entries, showSpoiler, now]); - const rawCurrent = useMemo(() => entries.find((entry) => entry.advId === advId) ?? null, [entries, advId]); - const isUnreleased = Boolean(rawCurrent?.startAt && isSpoiler(rawCurrent.startAt, now)); + const { now } = useSpoilerSetting(); + const visibleEntries = useMemo( + () => entries.filter((entry) => isStoryPlayerEntryReleased(entry, now)), + [entries, now], + ); const index = useMemo(() => visibleEntries.findIndex((entry) => entry.advId === advId), [visibleEntries, advId]); - const current = !showSpoiler && isUnreleased ? null : (index >= 0 ? visibleEntries[index]! : rawCurrent); + const blockedRequest = site.kind === "ready" && advId !== null + && !isStoryPlayerRequestReleased(advId, entries, now); + const current = !blockedRequest && index >= 0 ? visibleEntries[index]! : null; - // The address names the story: open it once the index has arrived (or from its manifest, when the index lacks it). + // Accept the address only as an id. Unknown/unreleased ids are never fetched from a cloud manifest directly. useEffect(() => { if (site.kind !== "ready" || restored) return; const requested = parseStoryPlayerSearch(window.location.search); - if (requested === null || site.entries.some((entry) => entry.advId === requested)) { - if (requested !== null) setAdvId(requested); - setRestored(true); - return; - } - const controller = new AbortController(); - fetchSiteStory(requested, controller.signal) - .then((entry) => { - if (controller.signal.aborted) return; - if (entry) { - setLinked(entry); - setAdvId(entry.advId); - } - setRestored(true); - }) - .catch(() => { - if (!controller.signal.aborted) setRestored(true); - }); - return () => controller.abort(); + if (requested !== null) setAdvId(requested); + setRestored(true); }, [site, restored]); // Shareable address of the episode shown; a dialog's own history entry is left alone. @@ -137,7 +119,7 @@ export default function StoryPlayerTool({ locale, stories }: Props) { return (
- {!showSpoiler && isUnreleased ? ( + {blockedRequest ? ( ) : current ? ( ; +export type Live2DAssetSource = "chart" | "story"; + +/** Same-origin API paths used by the cloud-backed player proxies. */ +export function getLive2DIndexApiPath(): string { + return "/api/v1/live2d/index"; +} + +export function getLive2DProxyApiRoot(server: GameServer, source: Live2DAssetSource): string { + return `/api/v1/live2d/${server}/${source}`; +} + +export function getStorySiteProxyApiRoot(server: StorySiteApiServer): string { + return `/api/v1/story-site/${server}`; +} + export const routeRegistry = [ { id: "home", diff --git a/src/i18n/messages/en-US.ts b/src/i18n/messages/en-US.ts index 9fd0fce5..f5a1e2fe 100644 --- a/src/i18n/messages/en-US.ts +++ b/src/i18n/messages/en-US.ts @@ -1211,7 +1211,7 @@ export const enUS = { title: "Spoiler content filter", showLabel: "Show spoiler content", description: "Display upcoming events, cards, songs, stories, and other data not yet officially released on the selected server.", - eulaNotice: "This content is not available before its in-game release.", + eulaNotice: "Only content officially released in the relevant region is shown. Online availability alone does not count as an in-game release.", devOptionBadge: "Developer option", devOnly: "This feature is for developer testing only", restrictedTitle: "Content not yet public", diff --git a/src/i18n/messages/ja-JP.ts b/src/i18n/messages/ja-JP.ts index d1cfa4ea..2f8bb96b 100644 --- a/src/i18n/messages/ja-JP.ts +++ b/src/i18n/messages/ja-JP.ts @@ -1246,7 +1246,7 @@ export const jaJP = { title: "ネタバレフィルター", showLabel: "ネタバレコンテンツの表示", description: "対象サーバーでまだ正式公開されていないイベント・カード・楽曲・ストーリーなどのデータを表示します。", - eulaNotice: "このコンテンツはゲーム内で公開されるまで表示されません。", + eulaNotice: "該当する地域のゲーム内で正式公開された後にのみ表示します。オンラインで取得可能であることは、正式公開を意味しません。", devOptionBadge: "開発者向けオプション", devOnly: "この機能は開発者テスト専用です", restrictedTitle: "コンテンツはまだ公開されていません", diff --git a/src/i18n/messages/ko-KR.ts b/src/i18n/messages/ko-KR.ts index c6c4d2bb..bd82dabd 100644 --- a/src/i18n/messages/ko-KR.ts +++ b/src/i18n/messages/ko-KR.ts @@ -1200,7 +1200,7 @@ export const koKR = { title: "스포일러 필터", showLabel: "스포일러 콘텐츠 표시", description: "선택한 서버에 아직 정식 공개되지 않은 이벤트, 카드, 악곡, 스토리 등의 데이터를 표시합니다.", - eulaNotice: "이 콘텐츠는 게임 내에서 공개되기 전까지 표시되지 않습니다.", + eulaNotice: "해당 지역 버전의 게임에서 정식 공개된 뒤에만 표시합니다. 온라인에서 이용 가능하더라도 정식 공개를 의미하지 않습니다.", devOptionBadge: "개발자 옵션", devOnly: "해당 기능은 개발자 테스트 전용입니다", restrictedTitle: "아직 공개되지 않은 콘텐츠", diff --git a/src/i18n/messages/zh-CN.ts b/src/i18n/messages/zh-CN.ts index cfab744a..aec05736 100644 --- a/src/i18n/messages/zh-CN.ts +++ b/src/i18n/messages/zh-CN.ts @@ -1249,7 +1249,7 @@ export const zhCN = { title: "剧透内容过滤", showLabel: "剧透内容显示", description: "显示尚未在对应区服正式发布的活动、卡面、乐曲与剧情等数据。", - eulaNotice: "该内容尚未在游戏内公开,将在公开后显示。", + eulaNotice: "仅在对应地区版本的游戏内正式公开后显示。线上已可获取不代表游戏内已正式公开。", devOptionBadge: "开发者选项", devOnly: "该功能仅供开发者测试", restrictedTitle: "内容尚未公开", diff --git a/src/i18n/messages/zh-TW.ts b/src/i18n/messages/zh-TW.ts index 699d61c5..33e7835f 100644 --- a/src/i18n/messages/zh-TW.ts +++ b/src/i18n/messages/zh-TW.ts @@ -1248,7 +1248,7 @@ export const zhTW = { title: "劇透內容過濾", showLabel: "劇透內容顯示", description: "顯示尚未在對應區服正式發布的活動、卡面、樂曲與劇情等資料。", - eulaNotice: "此內容尚未在遊戲內公開,將於公開後顯示。", + eulaNotice: "僅在對應地區版本的遊戲內正式公開後顯示。線上已可取得不代表遊戲內已正式公開。", devOptionBadge: "開發者選項", devOnly: "該功能僅供開發者測試", restrictedTitle: "內容尚未公開", diff --git a/src/lib/cache/player-files.ts b/src/lib/cache/player-files.ts index 45e0a363..2d51f511 100644 --- a/src/lib/cache/player-files.ts +++ b/src/lib/cache/player-files.ts @@ -113,7 +113,11 @@ export function getPlayerFileKey(url: string, base = pageBase()): string | null if (parsed.search) return null; const href = `${parsed.origin}${parsed.pathname}`; const root = siteAssetRoots(base).find((prefix) => href.startsWith(prefix)); - return root && CONTENT_ADDRESSED_NAME.test(href.slice(root.length)) ? href : null; + if (root && CONTENT_ADDRESSED_NAME.test(href.slice(root.length))) return href; + // Story playback uses a same-origin, release-gated resource proxy; its files stay story-scoped in the URL. + const storyProxyAsset = parsed.pathname.match(/^\/api\/v1\/story-site\/(?:tw|jp)\/stories\/\d+\/assets\/([^/]+)$/)?.[1]; + const live2DProxyAsset = parsed.pathname.match(/^\/api\/v1\/live2d\/(?:tw|jp|kr|en)\/(?:chart|story)\/models\/[a-z0-9_]+\/assets\/([^/]+)$/)?.[1]; + return [storyProxyAsset, live2DProxyAsset].some((name) => Boolean(name && CONTENT_ADDRESSED_NAME.test(name))) ? href : null; } /** diff --git a/src/lib/characters/data.ts b/src/lib/characters/data.ts index ca825094..14afa83b 100644 --- a/src/lib/characters/data.ts +++ b/src/lib/characters/data.ts @@ -2,6 +2,7 @@ import type { AppLocale } from "@/config/locales"; import type { RawBand, RawText } from "@/lib/cards/data"; import { localizeMasterText } from "@/lib/masterdata/localize-text"; import type { EntityLink } from "@/lib/route/entity-link"; +import { parseStoryReleaseTime } from "@/lib/story/release"; export interface RawCharacter { id: number; @@ -194,12 +195,23 @@ export interface CostumeViewModel { groupId: number; name: string; iconPath: string; + /** The costume group's in-game release time; the public viewer gates artwork and model links on this field. */ + startAt: string; isDefault: boolean; isInitial: boolean; live2dPath: string; costumeType: number; } +/** + * Costume groups with a MasterData timestamp use that server's official release time. An untimed `isInitial` costume + * is an explicit in-game launch entry; every other missing or malformed date fails closed. + */ +export function isCostumeReleased(costume: Pick, now: number): boolean { + const releaseTime = parseStoryReleaseTime(costume.startAt); + return releaseTime === null ? costume.isInitial : Number.isFinite(now) && releaseTime <= now; +} + export interface RankRewardGroup { rank: number; rewards: Array<{ kind: string; id: number; count: number; name: string; imageUrl: string; link?: EntityLink }>; @@ -241,6 +253,7 @@ export function normalizeCharacterCostumes( groupId, name, iconPath: group?.iconPath ?? "", + startAt: group?.startAt ?? "", isDefault: costume.isDefault, isInitial: group?.isInitial ?? false, live2dPath: costume.live2dPath ?? "", diff --git a/src/lib/characters/release.ts b/src/lib/characters/release.ts new file mode 100644 index 00000000..a101cabf --- /dev/null +++ b/src/lib/characters/release.ts @@ -0,0 +1,61 @@ +import type { AppLocale } from "@/config/locales"; +import type { GameServer } from "@/config/servers"; +import type { CharacterViewModel } from "@/lib/characters/data"; +import { getBuildCards, getBuildCharacters, getBuildStories, getBuildSupportCards } from "@/lib/masterdata/build-data"; +import { isReleaseFacetReleasedForServer } from "@/lib/story/release"; +import { restrictFacetServers, type ServerFaceted } from "@/lib/servers/facets"; + +/** + * Characters are not authorized by MasterCharacter presence alone: that table can land before a new character is in + * game. For each region, at least one officially released member/support card or story must refer to the character. + */ +export async function getBuildReleasedCharacterServers(locale: AppLocale, now = Date.now()): Promise> { + const [cards, supportCards, stories] = await Promise.all([ + getBuildCards(locale), + getBuildSupportCards(locale), + getBuildStories(locale), + ]); + const released = new Map>(); + const add = (characterId: number, server: GameServer) => { + if (characterId <= 0) return; + const servers = released.get(characterId) ?? new Set(); + servers.add(server); + released.set(characterId, servers); + }; + for (const card of cards) { + if (card.characterId <= 0) continue; + for (const server of card.servers) if (isReleaseFacetReleasedForServer(card, server, now)) add(card.characterId, server); + } + for (const card of supportCards) { + for (const server of card.servers) { + if (isReleaseFacetReleasedForServer(card, server, now)) { + for (const characterId of card.characterIds) add(characterId, server); + } + } + } + for (const story of stories) { + for (const server of story.servers) { + if (isReleaseFacetReleasedForServer(story, server, now)) { + for (const characterId of story.characterIds) add(characterId, server); + } + } + } + return new Map([...released].map(([id, servers]) => [id, [...servers]])); +} + +/** Regions in which the character has positive in-game release evidence; useful for route existence and names. */ +export async function getBuildReleasedCharacterIds(locale: AppLocale, now = Date.now()): Promise> { + return new Set((await getBuildReleasedCharacterServers(locale, now)).keys()); +} + +/** Remove MasterCharacter fields for regions with no released card/story evidence. */ +export async function filterReleasedCharacterFacets( + characters: readonly ServerFaceted[], + releasedServers: ReadonlyMap, +): Promise[]> { + return characters.flatMap((character) => { + const allowed = releasedServers.get(character.id) ?? []; + const safe = restrictFacetServers(character, allowed); + return safe ? [safe] : []; + }); +} diff --git a/src/lib/characters/voices.ts b/src/lib/characters/voices.ts index 499122ee..c6688fc4 100644 --- a/src/lib/characters/voices.ts +++ b/src/lib/characters/voices.ts @@ -258,6 +258,20 @@ export function voiceLinesByCharacter(lines: readonly VoiceLine[]): Record, + releasedMemberCardIds: ReadonlySet, +): VoiceLine[] { + return lines.filter((line) => + releasedCharacterIds.has(line.characterId) + && (line.source !== "memberCard" || releasedMemberCardIds.has(line.cardId ?? line.id)) + && (!line.partnerId || releasedCharacterIds.has(line.partnerId)) + && (!line.birthdayCharacterId || releasedCharacterIds.has(line.birthdayCharacterId)), + ); +} + // ---- Payload (`/character-voices.json`) ---- /** diff --git a/src/lib/live2d/client.ts b/src/lib/live2d/client.ts index fef09a59..6c643217 100644 --- a/src/lib/live2d/client.ts +++ b/src/lib/live2d/client.ts @@ -1,45 +1,77 @@ import type { AssetStore } from "ournotes-player/live2d"; -import { assetConfig } from "@/config/assets"; +import { GAME_SERVERS, type GameServer } from "@/config/servers"; +import { getLive2DIndexApiPath, getLive2DProxyApiRoot } from "@/config/routes"; import { createPlayerFileFetch } from "@/lib/cache/player-files"; -import { getLive2DModelsIndexUrl, mergeLive2DModelIndexes, type Live2DModel, type Live2DModelEntry } from "@/lib/live2d/models"; +import type { Live2DModel } from "@/lib/live2d/models"; -/** Network side of the Live2D viewer: the published model indexes, a model's files and Live2D Cubism Core. */ +/** Network side of the Live2D viewer: a release-gated regional index, model files and Live2D Cubism Core. */ /** A model's files come from the browser's player file cache once downloaded (they are content-addressed). */ const modelFileFetch = createPlayerFileFetch("live2d"); +/** Models are listed only by the same-origin endpoint after it checks the selected region's costume MasterData. */ +export async function fetchLive2DModels(signal: AbortSignal, server: GameServer): Promise { + const response = await fetch(`${getLive2DIndexApiPath()}?server=${encodeURIComponent(server)}`, { + signal, + credentials: "same-origin", + cache: "no-store", + headers: { Accept: "application/json" }, + }); + if (!response.ok) throw new Error(`Live2D model index: HTTP ${response.status}`); + const payload = (await response.json()) as { models?: unknown }; + if (!Array.isArray(payload.models)) return []; + const manifestPrefixes = (["chart", "story"] as const) + .map((source) => `${getLive2DProxyApiRoot(server, source)}/models/`); + return payload.models.filter((model): model is Live2DModel => + typeof model === "object" && model !== null + && typeof (model as Live2DModel).id === "string" + && /^[a-z0-9_]+$/.test((model as Live2DModel).id) + && typeof (model as Live2DModel).manifestUrl === "string" + && manifestPrefixes.some((prefix) => (model as Live2DModel).manifestUrl.startsWith(prefix)) + && typeof (model as Live2DModel).kind === "string" + && ["story", "live", "side"].includes((model as Live2DModel).kind), + ); +} + /** - * The viewer's models: the model site's `models.json` and the story site's, merged by mergeLive2DModelIndexes. A site - * that cannot be read only takes its models out; the model site's failure is thrown when neither can. + * The release-gated root for one model's files. ournotes-player 0.1.8's AssetStore defaults to `../` from + * `models/.json` and ignores a manifest `root`, which would request the unscoped `/assets/` route. Give it + * the model directory explicitly so its requests stay at `models//assets/` and the proxy can recheck that + * exact model's release and manifest allowlist. */ -export async function fetchLive2DModels(signal: AbortSignal): Promise { - const [modelSite, storySite] = await Promise.allSettled([ - fetchModelIndex(assetConfig.chartSite, signal), - fetchModelIndex(assetConfig.storySite, signal), - ]); - if (modelSite.status === "rejected" && storySite.status === "rejected") throw modelSite.reason; - return mergeLive2DModelIndexes( - modelSite.status === "fulfilled" ? modelSite.value : [], - storySite.status === "fulfilled" ? storySite.value : [], - ); +export function getLive2DModelAssetBase(manifestUrl: string, pageUrl = currentPageUrl()): string { + const page = new URL(pageUrl); + const manifest = new URL(manifestUrl, page); + if (manifest.origin !== page.origin || manifest.search || manifest.hash) { + throw new Error("Live2D model manifest must be a same-origin proxy URL without a query or fragment"); + } + const modelId = live2DModelIdFromProxyManifest(manifest.pathname); + if (!modelId) throw new Error("Live2D model manifest is not a release-gated model route"); + return new URL(`./${modelId}/`, manifest).href; +} + +function live2DModelIdFromProxyManifest(pathname: string): string | null { + for (const server of GAME_SERVERS) { + for (const source of ["chart", "story"] as const) { + const prefix = `${getLive2DProxyApiRoot(server, source)}/models/`; + if (!pathname.startsWith(prefix)) continue; + const match = /^([a-z0-9_]+)\.json$/.exec(pathname.slice(prefix.length)); + if (match) return match[1]!; + } + } + return null; +} + +function currentPageUrl(): string { + if (typeof window === "undefined") throw new Error("Live2D asset URLs can only be resolved in a browser page"); + return window.location.href; } /** One model's files (its manifest and every asset it lists), with byte progress. */ export async function loadLive2DModelAssets(manifestUrl: string, signal: AbortSignal, onProgress: (loaded: number, total: number) => void): Promise { const { AssetStore } = await import("ournotes-player/live2d"); - return AssetStore.fromManifest(manifestUrl, { signal, onProgress, fetch: modelFileFetch }); -} - -/** `models.json` of a player site; no models when the site has none yet (404). */ -async function fetchModelIndex(site: string, signal: AbortSignal): Promise { - const url = getLive2DModelsIndexUrl(site); - const response = await fetch(url, { signal, credentials: "omit", headers: { Accept: "application/json" } }); - if (response.status === 404) return []; - if (!response.ok) throw new Error(`${url}: HTTP ${response.status}`); - const index = (await response.json()) as { models?: unknown }; - return Array.isArray(index.models) - ? index.models.filter((entry): entry is Live2DModelEntry => typeof entry === "object" && entry !== null && typeof (entry as Live2DModelEntry).id === "string") - : []; + const base = getLive2DModelAssetBase(manifestUrl); + return AssetStore.fromManifest(manifestUrl, { signal, onProgress, fetch: modelFileFetch, base }); } let core: Promise | null = null; diff --git a/src/lib/live2d/models.ts b/src/lib/live2d/models.ts index 343a654e..2457dcea 100644 --- a/src/lib/live2d/models.ts +++ b/src/lib/live2d/models.ts @@ -84,6 +84,12 @@ export function parseLive2DViewerSearch(search: string): string | null { return /^[a-z0-9_]+$/.test(id) ? id : null; } +/** A direct `?model=` request selects only a model already present in the release-filtered regional list. */ +export function findRequestedLive2DModel(search: string, releasedModels: readonly Live2DModel[]): Live2DModel | null { + const id = parseLive2DViewerSearch(search); + return id ? releasedModels.find((model) => model.id === id) ?? null : null; +} + /** * A models.json entry as the picker shows it. Ids are `[adv_]live2d___` for the 25 characters (NNN * is the MasterCharacter id, as is the group `_adv` / `_live`) and `adv_live2d_sub__` for side diff --git a/src/lib/live2d/site-access.ts b/src/lib/live2d/site-access.ts new file mode 100644 index 00000000..4c43cfa5 --- /dev/null +++ b/src/lib/live2d/site-access.ts @@ -0,0 +1,234 @@ +import { assetConfig } from "@/config/assets"; +import { getLive2DProxyApiRoot } from "@/config/routes"; +import { isGameServer, type GameServer } from "@/config/servers"; +import { buildFetch } from "@/lib/build/fetch"; +import { costumeGroupId, isCostumeReleased, type RawCharacterCostume, type RawCharacterCostumeGroup } from "@/lib/characters/data"; +import { table } from "@/lib/masterdata/build-core"; +import { getStorySites } from "@/lib/story/player-data"; +import { getLive2DModelManifestUrl, type Live2DModel, type Live2DModelEntry, parseLive2DModel } from "@/lib/live2d/models"; + +export type Live2DSource = "chart" | "story"; + +export interface ReleasedLive2DEntry { + server: GameServer; + source: Live2DSource; + root: string; + entry: Live2DModelEntry; +} + +export interface GatedLive2DBundle { + root: string; + manifest: Record; + assetPaths: ReadonlySet; +} + +const MODEL_ID = /^[a-z0-9_]+$/; +const ASSET_PATH = /^assets\/[a-f0-9]{64}(?:\.[a-z0-9_-]+)+$/; +const indexCache = new Map }>(); +const bundleCache = new Map }>(); +const INDEX_TTL_MS = 15_000; +const BUNDLE_TTL_MS = 30_000; + +/** Clear short-lived cloud lookups between proxy-handler tests. */ +export function resetLive2DSiteAccessCachesForTests(): void { + indexCache.clear(); + bundleCache.clear(); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** MasterCharacterCostume.live2dPath names one exact model, under a group directory. */ +export function live2DModelIdFromCostumePath(value: unknown): string | null { + if (typeof value !== "string") return null; + const parts = value.split("/").filter(Boolean); + const id = parts.at(-1) ?? ""; + return MODEL_ID.test(id) ? id : null; +} + +/** The exact model ids the selected server's released costume groups authorize. */ +export function releasedLive2DModelIds( + costumes: readonly RawCharacterCostume[], + groups: readonly RawCharacterCostumeGroup[], + now: number, +): Set { + const groupsById = new Map(groups.map((group) => [group.id, group])); + const ids = new Set(); + for (const costume of costumes) { + const group = groupsById.get(costumeGroupId(costume)); + if (!group || !isCostumeReleased({ startAt: group.startAt ?? "", isInitial: group.isInitial === true }, now)) continue; + const id = live2DModelIdFromCostumePath(costume.live2dPath); + if (id) ids.add(id); + } + return ids; +} + +function sourceRoot(server: GameServer, source: Live2DSource): string | null { + if (source === "chart") return assetConfig.chartSite; + return getStorySites().find((site) => site.server === server)?.root ?? null; +} + +function proxyRoot(server: GameServer, source: Live2DSource): string { + return getLive2DProxyApiRoot(server, source); +} + +async function fetchIndex(root: string): Promise { + const url = `${root.replace(/\/+$/, "")}/models.json`; + const response = await buildFetch(url, { + cache: "no-store", + credentials: "omit", + headers: { Accept: "application/json" }, + redirect: "error", + }); + if (response.status === 404) return []; + if (!response.ok) throw new Error(`Live2D model index returned HTTP ${response.status}`); + const value: unknown = await response.json(); + if (!isRecord(value) || !Array.isArray(value.models)) return []; + const entries: Live2DModelEntry[] = []; + for (const candidate of value.models) { + if (!isRecord(candidate) || typeof candidate.id !== "string" || !MODEL_ID.test(candidate.id)) continue; + // Never trust a cloud path; the proxy derives the manifest name from this validated id. + if (candidate.manifest !== undefined && candidate.manifest !== `models/${candidate.id}.json`) continue; + const group = typeof candidate.group === "string" && /^[a-zA-Z0-9_-]+$/.test(candidate.group) ? candidate.group : undefined; + const character = typeof candidate.character === "number" && Number.isSafeInteger(candidate.character) && candidate.character > 0 + ? candidate.character + : undefined; + const bytes = typeof candidate.bytes === "number" && Number.isFinite(candidate.bytes) && candidate.bytes >= 0 + ? candidate.bytes + : undefined; + entries.push({ + id: candidate.id, + manifest: `models/${candidate.id}.json`, + ...(group ? { group } : {}), + ...(character ? { character } : {}), + ...(bytes !== undefined ? { bytes } : {}), + }); + } + return entries; +} + +async function cachedIndex(server: GameServer, source: Live2DSource, root: string): Promise { + const key = `${server}:${source}`; + const cached = indexCache.get(key); + if (cached && cached.expiresAt > Date.now()) return cached.promise; + const promise = fetchIndex(root).catch((error: unknown) => { + indexCache.delete(key); + throw error; + }); + indexCache.set(key, { expiresAt: Date.now() + INDEX_TTL_MS, promise }); + return promise; +} + +/** A sanitized regional index intersected with exact costume identities from released MasterData. */ +export async function getReleasedLive2DEntries(server: GameServer, now = Date.now()): Promise { + const [costumes, groups] = await Promise.all([ + table("MasterCharacterCostume.json", server), + table("MasterCharacterCostumeGroup.json", server), + ]); + const allowed = releasedLive2DModelIds(costumes._allData, groups._allData, now); + if (!Number.isFinite(now) || allowed.size === 0) return []; + + const sources: Live2DSource[] = ["chart", "story"]; + const indexes = await Promise.all(sources.map(async (source) => { + const root = sourceRoot(server, source); + if (!root) return [] as ReleasedLive2DEntry[]; + const entries = await cachedIndex(server, source, root); + return entries + .filter((entry) => allowed.has(entry.id)) + .map((entry): ReleasedLive2DEntry => ({ server, source, root, entry })); + })); + + // When both sites contain the same officially released model, prefer the story site's smaller files, as before. + const byId = new Map(); + for (const entry of indexes.flat()) if (entry.source === "chart" || !byId.has(entry.entry.id)) byId.set(entry.entry.id, entry); + for (const entry of indexes.flat()) if (entry.source === "story") byId.set(entry.entry.id, entry); + return [...byId.values()].map((entry) => ({ + ...entry, + entry: { ...entry.entry, manifest: `models/${entry.entry.id}.json` }, + })); +} + +/** Publicly serializable Live2D models; the browser receives only same-origin, per-region manifest URLs. */ +export async function getReleasedLive2DModels(server: GameServer, now = Date.now()): Promise { + const entries = await getReleasedLive2DEntries(server, now); + return entries.map(({ source, entry }) => parseLive2DModel(entry, proxyRoot(server, source))); +} + +function collectAssetPaths(files: unknown, output: Set): boolean { + if (!isRecord(files)) return false; + for (const file of Object.values(files)) { + if (!isRecord(file)) return false; + if (typeof file.asset === "string") { + if (!ASSET_PATH.test(file.asset)) return false; + output.add(file.asset); + } + if (file.parts !== undefined) { + if (!Array.isArray(file.parts)) return false; + for (const part of file.parts) { + if (!Array.isArray(part) || typeof part[1] !== "string" || !ASSET_PATH.test(part[1])) return false; + output.add(part[1]); + } + } + if (typeof file.asset !== "string" && file.parts === undefined) return false; + } + return true; +} + +async function fetchModelBundle(entry: ReleasedLive2DEntry): Promise { + const url = getLive2DModelManifestUrl(entry.entry.id, entry.root); + const response = await buildFetch(url, { + cache: "no-store", + credentials: "omit", + headers: { Accept: "application/json" }, + redirect: "error", + }); + if (!response.ok) return null; + const value: unknown = await response.json().catch(() => null); + if (!isRecord(value) || ![2, 3].includes(Number(value.format)) || value.id !== entry.entry.id) return null; + const assetPaths = new Set(); + if (!collectAssetPaths(value.files, assetPaths)) return null; + return { root: entry.root, manifest: value, assetPaths }; +} + +/** The model bundle gate rechecks regional MasterData on every manifest and asset request. */ +export async function getGatedLive2DBundle( + serverValue: string, + sourceValue: string, + modelId: string, + now = Date.now(), +): Promise { + if (!isGameServer(serverValue) || (sourceValue !== "chart" && sourceValue !== "story") || !MODEL_ID.test(modelId)) return null; + const server = serverValue; + const source = sourceValue as Live2DSource; + const entry = (await getReleasedLive2DEntries(server, now)).find((candidate) => candidate.source === source && candidate.entry.id === modelId); + if (!entry) return null; + + const key = `${server}:${source}:${modelId}`; + const cached = bundleCache.get(key); + if (cached && cached.expiresAt > Date.now()) return cached.promise; + const promise = fetchModelBundle(entry).catch((error: unknown) => { + bundleCache.delete(key); + throw error; + }); + bundleCache.set(key, { expiresAt: Date.now() + BUNDLE_TTL_MS, promise }); + return promise; +} + +/** A path under the model root that a validated model manifest may name. */ +export function getApprovedLive2DResourcePath(path: unknown): string | null { + if (typeof path !== "string" || path.includes("\\") || path.includes("?") || path.includes("#")) return null; + const segments = path.split("/"); + if (segments.some((segment) => !segment || segment === "." || segment === "..")) return null; + return ASSET_PATH.test(path) ? path : null; +} + +/** Each listed model may only use the source that published its approved index entry. */ +export function isLive2DSource(value: string): value is Live2DSource { + return value === "chart" || value === "story"; +} + +/** Explicit proxy URL builder, used in tests and route handlers. */ +export function live2DModelProxyManifest(server: GameServer, source: Live2DSource, modelId: string): string { + return `${proxyRoot(server, source)}/models/${encodeURIComponent(modelId)}.json`; +} diff --git a/src/lib/markdown/render.ts b/src/lib/markdown/render.ts index 3153645c..fb8e100a 100644 --- a/src/lib/markdown/render.ts +++ b/src/lib/markdown/render.ts @@ -56,6 +56,7 @@ import { absolutePageUrl, absoluteUrl, buildPageMetadata } from "@/lib/seo/metad import { valueForServer, type ServerFacetedValue } from "@/lib/servers/facets"; import { findStoryChapter } from "@/lib/story/chapters"; import { storyEpisodeLabel } from "@/lib/story/labels"; +import { isStoryReleasedAcrossServers } from "@/lib/story/release"; import { parseStoryChapterPath } from "@/lib/story/paths"; import { inStorySection, type StorySection } from "@/lib/story/sections"; @@ -659,7 +660,8 @@ async function renderComponentMarkdown( "story-other": "other", }; const category = sectionMap[component] ?? "main"; - const stories = (await getBuildStories(locale)).filter((s) => inStorySection(category, s)); + const now = Date.now(); + const stories = (await getBuildStories(locale)).filter((s) => isStoryReleasedAcrossServers(s, now) && inStorySection(category, s)); return [ `# ${defaultTitle}`, "", @@ -683,7 +685,9 @@ async function renderComponentMarkdown( if (!Number.isSafeInteger(chapterId) || chapterId <= 0) return null; const parsed = parseStoryChapterPath(pathname); if (!parsed) return null; - const chapter = findStoryChapter(await getBuildStories(locale), chapterId); + const now = Date.now(); + const publicStories = (await getBuildStories(locale)).filter((story) => isStoryReleasedAcrossServers(story, now)); + const chapter = findStoryChapter(publicStories, chapterId); if (!chapter || chapter.category !== parsed.section) return null; return [ `# ${chapter.name || `Chapter ${chapterId}`}`, @@ -716,7 +720,7 @@ async function renderComponentMarkdown( const advId = Number(paramId); if (!Number.isFinite(advId)) return null; const detail = await getBuildStoryDetail(locale, advId); - if (detail.servers.length === 0 && !detail.story && !detail.script) return null; + if (!detail.story || !isStoryReleasedAcrossServers(detail.story, Date.now())) return null; const heading = detail.title || `ADV ${advId}`; const out: string[] = [ `# ${heading}`, diff --git a/src/lib/masterdata/build-character-voices.ts b/src/lib/masterdata/build-character-voices.ts index 7a6550c4..e49f0007 100644 --- a/src/lib/masterdata/build-character-voices.ts +++ b/src/lib/masterdata/build-character-voices.ts @@ -1,9 +1,13 @@ +import { DEFAULT_LOCALE } from "@/config/locales"; import type { GameServer } from "@/config/servers"; import { PRIMARY_SERVER } from "@/config/servers"; import { eachServer, memo, table, texts, titleRow } from "@/lib/masterdata/build-core"; +import { cardsOn, storiesOn, supportCardsOn } from "@/lib/masterdata/build-data"; +import { isStoryReleasedAt } from "@/lib/story/release"; import { getVoiceTypeName } from "@/lib/characters/data"; import { collectVoiceLines, + filterReleasedCharacterVoiceLines, voiceLinesByCharacter, type CharacterVoicesPayload, type RawCharacterVoiceRow, @@ -45,7 +49,7 @@ function soundPathsOn(server: GameServer): Promise<(soundId: number) => string> /** Every voice line one server has (new tables read as empty where a server lacks or breaks them). */ export function voiceLinesOn(server: GameServer): Promise { return memo(`voice-lines:${server}`, async () => { - const [talks, characterVoices, memberCards, liveCharacters, gekisouVoices, dialogueCommons, dialoguePairs, liveStarts, soundOf] = await Promise.all([ + const [talks, characterVoices, memberCards, liveCharacters, gekisouVoices, dialogueCommons, dialoguePairs, liveStarts, soundOf, cards, supportCards, stories] = await Promise.all([ table("MasterTalk.json", server).catch(() => empty()), table("MasterCharacterVoice.json", server).catch(() => empty()), table("MasterMemberCard.json", server).catch(() => empty()), @@ -55,8 +59,19 @@ export function voiceLinesOn(server: GameServer): Promise { table("MasterLiveDialogueFixedPair.json", server).catch(() => empty()), table("MasterLiveStartCharacterVoice.json", server).catch(() => empty()), soundPathsOn(server), + cardsOn(server, DEFAULT_LOCALE), + supportCardsOn(server, DEFAULT_LOCALE), + storiesOn(server, DEFAULT_LOCALE), ]); - return collectVoiceLines({ + const now = Date.now(); + const releasedCards = cards.filter((card) => isStoryReleasedAt(card.startAt, now)); + const releasedCardIds = new Set(releasedCards.map((card) => card.id)); + const releasedCharacters = new Set([ + ...releasedCards.map((card) => card.characterId), + ...supportCards.filter((card) => isStoryReleasedAt(card.startAt, now)).flatMap((card) => card.characterIds), + ...stories.filter((story) => isStoryReleasedAt(story.startAt, now)).flatMap((story) => story.characterIds), + ].filter((id) => id > 0)); + const lines = collectVoiceLines({ talks: talks._allData, characterVoices: characterVoices._allData, memberCards: memberCards._allData, @@ -66,6 +81,7 @@ export function voiceLinesOn(server: GameServer): Promise { dialoguePairs: dialoguePairs._allData, liveStarts: liveStarts._allData, }, soundOf, getVoiceTypeName); + return filterReleasedCharacterVoiceLines(lines, releasedCharacters, releasedCardIds); }); } diff --git a/src/lib/masterdata/build-data.ts b/src/lib/masterdata/build-data.ts index 6e9a2dae..df61c499 100644 --- a/src/lib/masterdata/build-data.ts +++ b/src/lib/masterdata/build-data.ts @@ -16,7 +16,7 @@ import { toSearchTextRecord, } from "@/lib/masterdata/build-core"; import type { ContentSearchEntry } from "@/lib/search/content-entry"; -import { mergeServerLists, type ServerFaceted, type ServerFacetedValue } from "@/lib/servers/facets"; +import { mergeServerLists, mergeServerValues, type ServerFaceted, type ServerFacetedValue } from "@/lib/servers/facets"; import { normalizeCards, type CardViewModel, @@ -51,6 +51,7 @@ import { buildSupportCardGrowth, type RawSupportCardRank, type SupportCardGrowth import { getRoutePathById } from "@/lib/route/registry"; import { costumeCharacterId, + isCostumeReleased, normalizeCharacterCostumes, normalizeCharacters, normalizeRankRewards, @@ -316,12 +317,18 @@ export function characterProgressionOn(server: GameServer, locale: AppLocale): P }); } -export async function getBuildCharacterProgression(locale: AppLocale, characterId: number): Promise { - const merged = await mergedValue(`character-progression:${locale}:${characterId}`, async (server) => { +export async function getBuildCharacterProgression( + locale: AppLocale, + characterId: number, +): Promise | null> { + const now = Date.now(); + const perServer = await eachServer(async (server) => { const map = await characterProgressionOn(server, locale); - return map.get(characterId) ?? null; + const progression = map.get(characterId); + if (!progression) return null; + return { ...progression, costumes: progression.costumes.filter((costume) => isCostumeReleased(costume, now)) }; }); - return merged?.value ?? null; + return mergeServerValues(perServer); } export function supportCardsOn(server: GameServer, locale: AppLocale): Promise { diff --git a/src/lib/route/masterdata-params.ts b/src/lib/route/masterdata-params.ts index a7143e0d..51cebb4d 100644 --- a/src/lib/route/masterdata-params.ts +++ b/src/lib/route/masterdata-params.ts @@ -2,6 +2,7 @@ import { getBuildMasterData } from "@/lib/masterdata/build-snapshot"; import { getBuildServers } from "@/lib/masterdata/build-servers"; import { validateMasterTable } from "@/lib/cards/data"; import { groupStoryChapters } from "@/lib/story/chapters"; +import { isStoryReleasedAcrossServers } from "@/lib/story/release"; import { isChapterSection } from "@/lib/story/sections"; import type { RouteStaticParamConfig } from "@/types/route"; import { DEFAULT_LOCALE } from "@/config/locales"; @@ -45,12 +46,19 @@ async function rowsOfEveryServer(file: string, optional = false): Promise { - return detailParamsFromRows(kind, await rowsOfEveryServer(tables[kind])); + const rows = await rowsOfEveryServer(tables[kind]); + if (kind === "characters") { + const { getBuildReleasedCharacterIds } = await import("@/lib/characters/release"); + const released = await getBuildReleasedCharacterIds(DEFAULT_LOCALE); + return detailParamsFromRows(kind, rows.filter((row) => released.has(row.id))); + } + return detailParamsFromRows(kind, rows); } export async function getMasterdataStoryParams(): Promise { const { getBuildStories } = await import("@/lib/masterdata/build-data"); - const stories = await getBuildStories(DEFAULT_LOCALE); + const now = Date.now(); + const stories = (await getBuildStories(DEFAULT_LOCALE)).filter((story) => isStoryReleasedAcrossServers(story, now)); return [...new Set(stories.map((story) => story.advId))] .filter((id) => Number.isSafeInteger(id) && id > 0) .sort((a, b) => a - b) @@ -60,7 +68,9 @@ export async function getMasterdataStoryParams(): Promise { const { getBuildStories } = await import("@/lib/masterdata/build-data"); - const chapters = groupStoryChapters(await getBuildStories(DEFAULT_LOCALE)).filter((chapter) => isChapterSection(chapter.category)); + const now = Date.now(); + const stories = (await getBuildStories(DEFAULT_LOCALE)).filter((story) => isStoryReleasedAcrossServers(story, now)); + const chapters = groupStoryChapters(stories).filter((chapter) => isChapterSection(chapter.category)); return chapters .sort((a, b) => a.chapterId - b.chapterId) .map((chapter) => ({ diff --git a/src/lib/route/static-paths.ts b/src/lib/route/static-paths.ts index 4aa7d1b0..9c8b2563 100644 --- a/src/lib/route/static-paths.ts +++ b/src/lib/route/static-paths.ts @@ -21,6 +21,7 @@ import { import { getBuildHelpTopics } from "@/lib/masterdata/build-help"; import { getBuildShops } from "@/lib/masterdata/build-shop"; import { groupStoryChapters } from "@/lib/story/chapters"; +import { isStoryReleasedAcrossServers } from "@/lib/story/release"; import { isChapterSection } from "@/lib/story/sections"; export interface StaticLocalizedPathProps { @@ -89,7 +90,9 @@ const detailLabelLoaders: Record Promise { const storyRoute = plainRoute(findRouteById("story")); // Only main and event stories have chapter pages; birthday chapters are listed as episodes. - const chapters = groupStoryChapters(await getBuildStories(locale)).filter((chapter) => isChapterSection(chapter.category)); + const now = Date.now(); + const publicStories = (await getBuildStories(locale)).filter((story) => isStoryReleasedAcrossServers(story, now)); + const chapters = groupStoryChapters(publicStories).filter((chapter) => isChapterSection(chapter.category)); return byId(chapters, (chapter) => chapter.chapterId, (chapter) => ({ label: chapter.name || `Chapter ${chapter.chapterId}`, ancestors: [storyRoute, plainRoute(findRouteById(chapter.category === "main" ? "main-story" : "event-story"))].filter((r): r is AppRoute => r !== null), @@ -98,7 +101,9 @@ const detailLabelLoaders: Record Promise { const storyRoute = plainRoute(findRouteById("story")); // An ADV in several lists takes its first one's title and category. - const stories = (await getBuildStories(locale)).filter((story, index, all) => all.findIndex((other) => other.advId === story.advId) === index); + const now = Date.now(); + const publicStories = (await getBuildStories(locale)).filter((story) => isStoryReleasedAcrossServers(story, now)); + const stories = publicStories.filter((story, index, all) => all.findIndex((other) => other.advId === story.advId) === index); return byId(stories, (story) => story.advId, (story) => ({ label: story.title || `ADV ${story.advId}`, ancestors: [storyRoute, plainRoute(findRouteById(storyCategoryRoutes[story.category] ?? "other-story"))].filter((r): r is AppRoute => r !== null), diff --git a/src/lib/search/content-index.ts b/src/lib/search/content-index.ts index 050fc012..2c333d8d 100644 --- a/src/lib/search/content-index.ts +++ b/src/lib/search/content-index.ts @@ -1,16 +1,79 @@ -import { getBuildContentSearchIndex } from "@/lib/masterdata/build-data"; +import { DEFAULT_LOCALE } from "@/config/locales"; +import { + getBuildCards, + getBuildContentSearchIndex, + getBuildEvents, + getBuildGachas, + getBuildMusic, + getBuildStories, + getBuildSupportCards, + getBuildRewardEntries, +} from "@/lib/masterdata/build-data"; +import type { ServerFaceted } from "@/lib/servers/facets"; +import { isReleaseFacetReleasedAcrossServers, parseStoryReleaseTime } from "@/lib/story/release"; import type { ContentSearchEntry } from "@/lib/search/content-entry"; import { CONTENT_SEARCH_SOURCES } from "@/lib/search/content-sources"; export type { ContentSearchEntry }; +/** Search entries whose MasterData is schedule-bearing and must have a regional release record. */ +const RELEASE_GATED_SEARCH_KINDS = new Set(["card", "support-card", "music", "story", "gacha", "event", "reward"]); + +/** Pure filter used by the public search API and its release regression tests. */ +export function filterReleasedSearchEntries( + entries: readonly ContentSearchEntry[], + releasedByKey: ReadonlyMap, + now: number, +): ContentSearchEntry[] { + return entries.filter((entry) => { + if (RELEASE_GATED_SEARCH_KINDS.has(entry.kind)) return releasedByKey.get(entry.key) === true; + if (entry.startAt !== undefined && entry.startAt.trim() !== "") { + const release = parseStoryReleaseTime(entry.startAt); + return release !== null && Number.isFinite(now) && release <= now; + } + return true; + }); +} + /** * The build-time body of `/search-index.json`: every searchable content entity (cards, songs, characters, stories, * gacha, events, rewards, …, then each domain source of content-sources.ts). Static routes are not here — they ship in * the command palette's own bundle, localized by `t()`; the same entry on the client merges both sources. Titles * carry the MasterText row's five language cells so the browser localizes them; hrefs are locale-free detail paths. + * Release-sensitive entries are removed here, before this public API serializes their titles and search text. */ export async function buildContentSearchIndex(): Promise { - const [builtIn, ...extra] = await Promise.all([getBuildContentSearchIndex(), ...CONTENT_SEARCH_SOURCES.map((load) => load())]); - return [...builtIn!, ...extra.flat()]; + const [builtIn, ...loaded] = await Promise.all([ + getBuildContentSearchIndex(), + ...CONTENT_SEARCH_SOURCES.map((load) => load()), + ]); + const extra = loaded.flat(); + const now = Date.now(); + const released = new Map(); + const record = ( + kind: string, + entries: readonly ServerFaceted[], + id: (entry: ServerFaceted) => string | number, + ) => { + for (const entry of entries) released.set(`${kind}:${id(entry)}`, isReleaseFacetReleasedAcrossServers(entry, now)); + }; + + const [cards, supportCards, music, stories, gachas, events, rewards] = await Promise.all([ + getBuildCards(DEFAULT_LOCALE), + getBuildSupportCards(DEFAULT_LOCALE), + getBuildMusic(DEFAULT_LOCALE), + getBuildStories(DEFAULT_LOCALE), + getBuildGachas(DEFAULT_LOCALE), + getBuildEvents(DEFAULT_LOCALE), + getBuildRewardEntries(DEFAULT_LOCALE), + ]); + record("card", cards, (entry) => entry.id); + record("support-card", supportCards, (entry) => entry.id); + record("music", music, (entry) => entry.id); + record("story", stories, (entry) => entry.advId); + record("gacha", gachas, (entry) => entry.id); + record("event", events, (entry) => entry.id); + record("reward", rewards, (entry) => entry.slug); + + return filterReleasedSearchEntries([...builtIn!, ...extra], released, now); } diff --git a/src/lib/seo/entity-seo.ts b/src/lib/seo/entity-seo.ts index d266dd44..92c58729 100644 --- a/src/lib/seo/entity-seo.ts +++ b/src/lib/seo/entity-seo.ts @@ -8,6 +8,7 @@ import { getSupportCardFullUrl } from "@/lib/support-cards/assets"; import { getBuildCardDetail, getBuildCharacterDetail, + getBuildCards, getBuildEventDetail, getBuildExchangeDetail, getBuildGachaDetail, @@ -17,6 +18,7 @@ import { getBuildStoryDetail, getBuildSupportCardDetail, } from "@/lib/masterdata/build-data"; +import { getBuildReleasedCharacterIds } from "@/lib/characters/release"; import { getBuildHelpTopicDetail } from "@/lib/masterdata/build-help"; import { getBuildItemDetail } from "@/lib/masterdata/build-item-sources"; import { getBuildShopDetail } from "@/lib/masterdata/build-shop"; @@ -24,6 +26,7 @@ import { buildDynamicPath, findRouteById, getRoutePathById } from "@/lib/route/r import { absolutePageUrl, type PageMetadata } from "@/lib/seo/metadata"; import { valueForServer, type ServerFacetedValue } from "@/lib/servers/facets"; import { findStoryChapter } from "@/lib/story/chapters"; +import { isReleaseFacetReleasedAcrossServers, isStoryReleasedAcrossServers } from "@/lib/story/release"; import { storyEpisodeLabel } from "@/lib/story/labels"; import { parseStoryChapterPath } from "@/lib/story/paths"; import { storyCategoryKey } from "@/lib/story/sections"; @@ -375,12 +378,16 @@ export async function resolveEntitySeo( case "character-detail": { const charId = Number(paramId); - if (!Number.isFinite(charId)) return null; + if (!Number.isFinite(charId) || !(await getBuildReleasedCharacterIds(locale)).has(charId)) return null; const faceted = await getBuildCharacterDetail(locale, charId); const picked = pickValue(faceted); const char = picked?.value.character ?? null; if (!char) return null; - const cards = picked?.value.cards ?? []; + const now = Date.now(); + const publicCardIds = new Set((await getBuildCards(locale)) + .filter((card) => isReleaseFacetReleasedAcrossServers(card, now)) + .map((card) => card.id)); + const cards = (picked?.value.cards ?? []).filter((card) => publicCardIds.has(card.id)); const summary = truncateText( [char.name, char.bandName, char.voiceActor ? `CV: ${char.voiceActor}` : "", char.catchCopy, char.description] .filter(Boolean) @@ -427,7 +434,9 @@ export async function resolveEntitySeo( if (!Number.isSafeInteger(chapterId) || chapterId <= 0) return null; const parsed = parseStoryChapterPath(pathname); if (!parsed) return null; - const chapter = findStoryChapter(await getBuildStories(locale), chapterId); + const now = Date.now(); + const publicStories = (await getBuildStories(locale)).filter((story) => isStoryReleasedAcrossServers(story, now)); + const chapter = findStoryChapter(publicStories, chapterId); // One chapter has one section: an event chapter under /story/main/... is not a second page for it. if (!chapter || chapter.category !== parsed.section) return null; @@ -475,6 +484,7 @@ export async function resolveEntitySeo( const advId = Number(paramId); if (!Number.isFinite(advId)) return null; const detail = await getBuildStoryDetail(locale, advId); + if (!detail.story || !isStoryReleasedAcrossServers(detail.story, Date.now())) return null; const heading = detail.title || `ADV ${advId}`; const dialoguePreview = (detail.script?.lines ?? []) .filter((l) => l.text.trim()) diff --git a/src/lib/servers/facets.ts b/src/lib/servers/facets.ts index 58e47a25..e9c08bdc 100644 --- a/src/lib/servers/facets.ts +++ b/src/lib/servers/facets.ts @@ -105,6 +105,27 @@ export function valueForServer(faceted: ServerFacetedValue, server: GameSe return variant ? { ...faceted.value, ...variant } : faceted.value; } +/** Restrict a merged entity to approved regions, promoting the first approved region to the serialized base value. */ +export function restrictFacetServers(entity: ServerFaceted, allowed: readonly GameServer[]): ServerFaceted | null { + const servers = entity.servers.filter((server) => allowed.includes(server)); + const firstServer = servers[0]; + if (!firstServer) return null; + const first = forServer(entity, firstServer) as T & { servers?: GameServer[]; serverVariants?: ServerVariants }; + const { servers: _servers, serverVariants: _variants, ...base } = first; + const serverVariants: ServerVariants = {}; + for (const server of servers.slice(1)) { + const resolved = forServer(entity, server) as T & { servers?: GameServer[]; serverVariants?: ServerVariants }; + const { servers: _serverList, serverVariants: _serverVariants, ...other } = resolved; + const changed = changedFields(base as T, other as T); + if (changed) serverVariants[server] = changed; + } + return { + ...(base as T), + servers, + ...(Object.keys(serverVariants).length > 0 ? { serverVariants } : {}), + }; +} + /** Some fields of a value as their own faceted value, so that one page can hand parts of it to several islands. */ export function pickFacet(faceted: ServerFacetedValue, keys: readonly K[]): ServerFacetedValue> { const pick = (value: Partial): Partial> => Object.fromEntries(keys.filter((key) => key in value).map((key) => [key, value[key]])) as Partial>; diff --git a/src/lib/settings/spoiler.ts b/src/lib/settings/spoiler.ts index f1bb0974..0fc7b4e6 100644 --- a/src/lib/settings/spoiler.ts +++ b/src/lib/settings/spoiler.ts @@ -20,9 +20,10 @@ export function setShowSpoiler(_on: boolean): void { } export interface SpoilerState { - isUnlocked: boolean; - showSpoiler: boolean; - setShowSpoiler: (on: boolean) => void; + isUnlocked: false; + /** Always false; retained only so existing filters share one locked public state. */ + showSpoiler: false; + setShowSpoiler: typeof setShowSpoiler; now: number | null; } diff --git a/src/lib/story/player-client.ts b/src/lib/story/player-client.ts index 89d11854..4e59b0ee 100644 --- a/src/lib/story/player-client.ts +++ b/src/lib/story/player-client.ts @@ -1,7 +1,7 @@ import { assetConfig } from "@/config/assets"; import { createPlayerFileFetch } from "@/lib/cache/player-files"; import { loadCubismCore } from "@/lib/live2d/client"; -import { getStoriesIndexUrl, getStoryManifestUrl, getStorySiteRoots, mergeStorySiteEntries, type StorySiteEntry } from "@/lib/story/player-data"; +import { getStorySiteProxyRoot, getStorySites, type StorySiteEntry, type StorySiteRegion } from "@/lib/story/player-data"; /** Network side of the story player: the published story index, one story's presence and the page-side scripts. */ @@ -11,64 +11,39 @@ import { getStoriesIndexUrl, getStoryManifestUrl, getStorySiteRoots, mergeStoryS */ export const storyPlayerFetch = createPlayerFileFetch("story"); -/** `stories.json` of one site, its entries marked with the site; no stories when the site has none yet (404). */ -async function fetchSiteIndex(root: string, signal: AbortSignal): Promise { - const response = await fetch(getStoriesIndexUrl(root), { signal, credentials: "omit", headers: { Accept: "application/json" } }); +/** A site's server-filtered public index; unreleased cloud entries never cross into the browser. */ +async function fetchSiteIndex(site: { server: StorySiteRegion }, signal: AbortSignal): Promise { + const url = `/api/v1/story-site/index?server=${encodeURIComponent(site.server)}`; + const response = await fetch(url, { signal, credentials: "same-origin", headers: { Accept: "application/json" } }); if (response.status === 404) return []; - if (!response.ok) throw new Error(`${root}/stories.json: HTTP ${response.status}`); + if (!response.ok) throw new Error(`${url}: HTTP ${response.status}`); const index = (await response.json()) as { stories?: unknown }; return Array.isArray(index.stories) ? index.stories - .filter((entry): entry is Omit => typeof entry === "object" && entry !== null - && typeof (entry as StorySiteEntry).advId === "number" && typeof (entry as StorySiteEntry).manifest === "string") - .map((entry) => ({ ...entry, root })) + .filter((entry): entry is Omit => typeof entry === "object" && entry !== null + && typeof (entry as StorySiteEntry).advId === "number" && typeof (entry as StorySiteEntry).manifest === "string" + && Array.isArray((entry as StorySiteEntry).languages)) + .map((entry) => ({ ...entry, root: getStorySiteProxyRoot(site.server), server: site.server })) : []; } /** - * The stories of every story site (getStorySiteRoots), an episode on several from the first. A site that cannot be - * read leaves only its stories out; the list fails only when no site can be read. + * The filtered stories of every regional site. A site that cannot be read leaves only its stories out; the list fails + * only when no site can be read. Keep duplicate ids until the local release metadata selects the eligible region. */ export async function fetchStorySite(signal: AbortSignal): Promise { - const results = await Promise.allSettled(getStorySiteRoots().map((root) => fetchSiteIndex(root, signal))); + const results = await Promise.allSettled(getStorySites().map((site) => fetchSiteIndex(site, signal))); const sites = results.flatMap((result) => (result.status === "fulfilled" ? [result.value] : [])); if (sites.length === 0) throw results.find((result): result is PromiseRejectedResult => result.status === "rejected")?.reason; for (const result of results) if (result.status === "rejected") console.warn("[story-player] a story site could not be read:", result.reason); - return mergeStorySiteEntries(sites); + return sites.flat(); } -/** - * One story's entry read from its manifest (whose `story` block is the index entry), for a story the indexes do not - * list yet: a build publishes each manifest as it lands and rewrites stories.json at its end. The first site that has - * it; null when none does. - */ -export async function fetchSiteStory(advId: number, signal: AbortSignal): Promise { - const manifest = `stories/${advId}.json`; - for (const root of getStorySiteRoots()) { - const response = await fetch(getStoryManifestUrl(root, manifest), { signal, credentials: "omit", headers: { Accept: "application/json" } }).catch(() => null); - if (!response?.ok) continue; - const body = (await response.json()) as { audio?: boolean; story?: Partial }; - const story = body.story; - if (!story || story.advId !== advId || !Array.isArray(story.languages)) continue; - return { - id: String(advId), advId, root, manifest, titles: story.titles ?? {}, languages: story.languages, - language: story.language ?? story.languages[0] ?? "ja", playbackMode: story.playbackMode ?? 0, audio: body.audio ?? true, - }; - } - return null; -} - -/** Whether a story site has the episode (a HEAD of its manifest, so a story page need not read the whole indexes). */ +/** Whether the server-side, release-filtered regional indexes make this episode available to the player. */ export async function hasSiteStory(advId: number, signal: AbortSignal): Promise { - const found = await Promise.all(getStorySiteRoots().map(async (root) => { - try { - const response = await fetch(getStoryManifestUrl(root, `stories/${advId}.json`), { method: "HEAD", signal, credentials: "omit" }); - return response.ok; - } catch { - return false; - } - })); - return found.includes(true); + if (!Number.isSafeInteger(advId) || advId <= 0) return false; + const results = await Promise.allSettled(getStorySites().map((site) => fetchSiteIndex(site, signal))); + return results.some((result) => result.status === "fulfilled" && result.value.some((entry) => entry.advId === advId)); } /** What the page could load besides Cubism Core: missing optional scripts only take their part out of the story. */ diff --git a/src/lib/story/player-data.ts b/src/lib/story/player-data.ts index 6e4f2ce0..a6019b99 100644 --- a/src/lib/story/player-data.ts +++ b/src/lib/story/player-data.ts @@ -1,17 +1,21 @@ import { assetConfig } from "@/config/assets"; +import { getStorySiteProxyApiRoot } from "@/config/routes"; import type { AppLocale } from "@/config/locales"; -import { PRIMARY_SERVER, type GameServer } from "@/config/servers"; +import type { GameServer } from "@/config/servers"; import { getRoutePathById } from "@/lib/route/registry"; import { localizePath } from "@/i18n/routing"; import { assetLanguageOrder, serverAssetUrl } from "@/lib/assets/release"; import { getImageAssetUrl } from "@/lib/assets/url"; import type { StoryCategory, StoryEpisodeKind } from "@/lib/story/data"; +import { SPOILER_REFERENCE_NOW } from "@/lib/settings/spoiler-reference"; +import { isStoryReleasedAt } from "@/lib/story/release"; /** The story player's data: the story site's index entries and the episodes the page knows from the build. */ /** The story languages of ournotes-player (the site's language codes, as the asset server's). */ export const STORY_LANGUAGES = ["ja", "en", "zh-Hant", "zh-Hans", "ko"] as const; export type StoryLanguage = typeof STORY_LANGUAGES[number]; +export type StorySiteRegion = Extract; /** * The story sites' roots, in precedence order: the international site (TW/HK/MO, every text language), then JP (its own @@ -21,11 +25,26 @@ export function getStorySiteRoots(): string[] { return [...new Set([assetConfig.storySite, assetConfig.storySiteJp].filter(Boolean))]; } +/** The configured roots with the game region whose release schedule governs each root. */ +export function getStorySites(): Array<{ root: string; server: StorySiteRegion }> { + return getStorySiteRoots().map((root) => ({ + root, + server: root === assetConfig.storySiteJp && root !== assetConfig.storySite ? "jp" : "tw", + })); +} + +/** Browser-facing root: every manifest and resource goes through the same-origin release-gated proxy. */ +export function getStorySiteProxyRoot(server: StorySiteRegion): string { + return getStorySiteProxyApiRoot(server); +} + /** A `stories.json` entry (ournotes.stories/1) as the player page reads it. */ export interface StorySiteEntry { id: string; advId: number; - /** The story site the entry comes from (a root of getStorySiteRoots); its manifest and files are under it. */ + /** Region whose in-game release controls access to this story site. */ + server: StorySiteRegion; + /** Same-origin proxy root for the regional story site; manifests and every listed resource go through its gate. */ root: string; /** The manifest path under the site root (`stories/.json`). */ manifest: string; @@ -55,6 +74,10 @@ export interface StoryPickerStory { assetServer: GameServer; category: StoryCategory; title: string; + /** The regional MasterData catalogs that contain this story. */ + servers: GameServer[]; + /** Each region's own official in-game release date; cloud availability is never used as a release signal. */ + releaseDates: Partial>; /** The group the episode belongs to (a chapter, a character's bond, a spot) and its name. */ groupId: string; groupTitle: string; @@ -70,49 +93,64 @@ export interface StoryPickerStory { /** The episode's banner (asset path without extension, text-bearing: per locale); empty when it has none. */ image: string; searchText: string; - startAt?: string; } -/** An episode the story site has, with what the picker, the search and the header show. */ +/** A locally verified episode the cloud story site has, with what the picker, search and header show. */ export interface StoryPlayerEntry extends Omit { site: StorySiteEntry; - /** "other": an episode the build does not know (published after it), listed by the site's own titles. */ + /** "other" is retained in the type for old consumers, but unknown cloud-only rows are never constructed. */ category: StoryCategory | "other"; + /** The release date for the selected cloud site's region. */ + startAt: string; section: StorySection; } /** - * The site's episodes in the story pages' order (the build's episodes it has), then the ones the build does not know - * yet in advId order, grouped under `otherGroupTitle`. + * Only joins cloud entries to the locally maintained, region-faceted game catalog. A story which exists only in the + * cloud has no trusted in-game release date, so it is omitted rather than treated as public. */ export function buildStoryPlayerEntries( stories: readonly StoryPickerStory[], siteEntries: readonly StorySiteEntry[], - locale: AppLocale, - otherGroupTitle: string, ): StoryPlayerEntry[] { - const byAdv = new Map(siteEntries.map((entry) => [entry.advId, entry])); const listed = new Set(); - const known: StoryPlayerEntry[] = []; + const result: StoryPlayerEntry[] = []; for (const story of stories) { - const site = byAdv.get(story.advId); - if (!site || listed.has(story.advId)) continue; + if (listed.has(story.advId)) continue; + const site = siteEntries.find((entry) => entry.advId === story.advId && story.servers.includes(entry.server)); + if (!site) continue; + const startAt = story.releaseDates[site.server]; + // Missing region data is not proof of a release, even if a public storage index has the episode. + if (startAt === undefined) continue; listed.add(story.advId); - const title = story.title || storySiteTitle(site, locale); - const assetServer = site.root === assetConfig.storySiteJp && site.root !== assetConfig.storySite ? "jp" : story.assetServer; - known.push({ ...story, assetServer, site, title, section: storySection(story.category), searchText: `${story.searchText} ${title} ${story.advId}`.toLocaleLowerCase() }); - } - const unknown = siteEntries - .filter((entry) => !listed.has(entry.advId)) - .sort((a, b) => a.advId - b.advId) - .map((site): StoryPlayerEntry => { - const title = storySiteTitle(site, locale); - return { - advId: site.advId, assetServer: PRIMARY_SERVER, site, category: "other", section: "other", title, groupId: "site", groupTitle: otherGroupTitle, groupSubtitle: "", - groupImage: "", episodeLabel: "", episodeKind: null, episodeNote: "", image: "", searchText: `${title} ${site.advId}`.toLocaleLowerCase(), - }; + const title = story.title || `ADV ${story.advId}`; + const assetServer = site.server === "jp" ? "jp" : story.assetServer; + result.push({ + ...story, + assetServer, + site, + startAt, + title, + section: storySection(story.category), + searchText: `${story.searchText} ${title} ${story.advId}`.toLocaleLowerCase(), }); - return [...known, ...unknown]; + } + return result; +} + +/** A cloud-backed episode is playable only when every known regional version is officially released. */ +export function isStoryPlayerEntryReleased(entry: StoryPlayerEntry, now?: number | null): boolean { + if (entry.servers.length === 0 || !entry.servers.includes(entry.site.server)) return false; + return entry.servers.every((server) => { + const startAt = entry.releaseDates[server]; + return isStoryReleasedAt(startAt, now ?? SPOILER_REFERENCE_NOW); + }); +} + +/** A query-string id is playable only if it resolves to a locally verified, regionally released index entry. */ +export function isStoryPlayerRequestReleased(advId: number, entries: readonly StoryPlayerEntry[], now?: number | null): boolean { + const entry = entries.find((candidate) => candidate.advId === advId); + return Boolean(entry && isStoryPlayerEntryReleased(entry, now)); } export function getStoryPlayerArtworkUrl(path: string, locale: AppLocale, server: GameServer): string { diff --git a/src/lib/story/release.ts b/src/lib/story/release.ts new file mode 100644 index 00000000..bff10fdd --- /dev/null +++ b/src/lib/story/release.ts @@ -0,0 +1,101 @@ +import type { GameServer } from "@/config/servers"; +import { restrictFacetServers, type ServerFaceted } from "@/lib/servers/facets"; +import { parseMasterDate } from "@/lib/schedule"; + +/** The schedule-bearing shape shared by story view models and their merged regional variants. */ +export type StoryReleaseFaceted = ServerFaceted<{ startAt: string }>; + +const MASTER_RELEASE_DATE = /^(\d{4})[-/](\d{1,2})[-/](\d{1,2})(?:\s+(\d{1,2}):(\d{2})(?::(\d{2}))?)?([+-]\d{2}:\d{2})?$/; + +/** + * Parse a release time only when MasterData supplied a real, non-empty date and time. `parseMasterDate` is intentionally + * permissive for display/schedule use (an absent bound means an open schedule); a release gate must instead fail closed. + */ +export function parseStoryReleaseTime(value: unknown): number | null { + if (typeof value !== "string" || value.trim() === "") return null; + const match = value.trim().match(MASTER_RELEASE_DATE); + if (!match) return null; + + const [, yearText, monthText, dayText, hourText = "0", minuteText = "0", secondText = "0", offsetText] = match; + const year = Number(yearText); + const month = Number(monthText); + const day = Number(dayText); + const hour = Number(hourText); + const minute = Number(minuteText); + const second = Number(secondText); + if (year < 2000 || month < 1 || month > 12 || day < 1 || day > 31 || hour > 23 || minute > 59 || second > 59) return null; + + const date = new Date(Date.UTC(year, month - 1, day)); + if (date.getUTCFullYear() !== year || date.getUTCMonth() !== month - 1 || date.getUTCDate() !== day) return null; + if (offsetText) { + const offsetHour = Number(offsetText.slice(1, 3)); + const offsetMinute = Number(offsetText.slice(4, 6)); + if (offsetHour > 14 || offsetMinute > 59 || (offsetHour === 14 && offsetMinute !== 0)) return null; + } + + const parsed = parseMasterDate(value); + return parsed !== null && parsed > 0 ? parsed : null; +} + +/** Whether a single regional MasterData release date is valid and has passed. */ +export function isStoryReleasedAt(startAt: unknown, now: number): boolean { + if (!Number.isFinite(now)) return false; + const releaseTime = parseStoryReleaseTime(startAt); + return releaseTime !== null && releaseTime <= now; +} + +/** Release dates as each server's own MasterData reports them. */ +export function storyReleaseDates(story: StoryReleaseFaceted): Partial> { + const dates: Partial> = {}; + for (const server of story.servers) { + const variant = story.serverVariants?.[server]; + const startAt = variant && Object.hasOwn(variant, "startAt") ? variant.startAt : story.startAt; + if (typeof startAt === "string") dates[server] = startAt; + } + return dates; +} + +/** + * The story player and global story pages have no region selector at request time. Fail closed until every server + * whose MasterData contains the story has reached its own in-game start time; a cloud upload or another region's + * release is not sufficient evidence that this story is public everywhere it is listed. + */ +export function isReleaseFacetReleasedAcrossServers(story: StoryReleaseFaceted, now: number): boolean { + if (story.servers.length === 0 || !Number.isFinite(now)) return false; + const dates = storyReleaseDates(story); + return story.servers.every((server) => isStoryReleasedAt(dates[server], now)); +} + +/** Whether one server's authoritative MasterData release date has passed. Unknown dates and absent regions fail closed. */ +export function isReleaseFacetReleasedForServer(story: StoryReleaseFaceted, server: GameServer, now: number): boolean { + if (!story.servers.includes(server)) return false; + return isStoryReleasedAt(storyReleaseDates(story)[server], now); +} + +/** + * Keep only the server variants whose own in-game release date has passed. When the source's base server is not yet + * released, promote the first released server's fields to the base so an unreleased variant is not serialized as the + * fallback value. Used for region-switchable public catalog payloads. + */ +export function filterReleasedFacetsByServer( + entries: readonly ServerFaceted[], + now: number, +): ServerFaceted[] { + if (!Number.isFinite(now)) return []; + return entries.flatMap((entry) => { + const releasedServers = entry.servers.filter((server) => isReleaseFacetReleasedForServer(entry, server, now)); + const safe = restrictFacetServers(entry, releasedServers); + return safe ? [safe] : []; + }); +} + +/** Story-specific name for the shared, regional MasterData release gate. */ +export function isStoryReleasedAcrossServers(story: StoryReleaseFaceted, now: number): boolean { + return isReleaseFacetReleasedAcrossServers(story, now); +} + +/** Whether the authoritative MasterData for one specific server says the story is already in game. */ +export function isStoryReleasedForServer(story: StoryReleaseFaceted, server: GameServer, now: number): boolean { + if (!story.servers.includes(server)) return false; + return isStoryReleasedAt(storyReleaseDates(story)[server], now); +} diff --git a/src/lib/story/site-access.ts b/src/lib/story/site-access.ts new file mode 100644 index 00000000..57982553 --- /dev/null +++ b/src/lib/story/site-access.ts @@ -0,0 +1,246 @@ +import { DEFAULT_LOCALE } from "@/config/locales"; +import { buildFetch } from "@/lib/build/fetch"; +import { getBuildStories } from "@/lib/masterdata/build-data"; +import { isStoryReleasedForServer } from "@/lib/story/release"; +import { STORY_LANGUAGES, getStorySites, type StorySiteEntry, type StorySiteRegion } from "@/lib/story/player-data"; + +interface ReleasedStoryIndexEntry extends Omit { + /** The path from the untrusted upstream index; never sent to the browser. */ + sourceManifest: string; +} + +interface StorySite { + root: string; + server: StorySiteRegion; +} + +export interface ReleasedStorySiteIndex { + site: StorySite; + stories: ReleasedStoryIndexEntry[]; +} + +export interface GatedStoryBundle { + /** Rewritten story manifest whose relative resources stay under the story-scoped proxy URL. */ + manifest: Record; + /** Paths under the upstream site root which this released story's manifests actually reference. */ + assetPaths: ReadonlySet; + /** Model manifests reachable from this story, keyed by their upstream site-relative path. */ + modelManifests: ReadonlyMap>; +} + +const ASSET_PATH = /^assets\/[a-f0-9]{64}(?:\.[a-z0-9_-]+)+$/; +const MODEL_PATH = /^models\/[a-z0-9_]+\.json$/; +const storyBundleCache = new Map }>(); +const STORY_BUNDLE_TTL_MS = 30_000; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** Only index paths that name the same episode and cannot leave the stories directory. */ +export function getIndexedStoryManifestPath(value: unknown, advId: number): string | null { + if (typeof value !== "string" || !Number.isSafeInteger(advId) || advId <= 0) return null; + const segments = value.split("/"); + if (segments.length < 2 || segments.length > 3 || segments[0] !== "stories" || segments.at(-1) !== `${advId}.json`) return null; + if (segments.some((segment) => !segment || segment === "." || segment === ".." || !/^[a-zA-Z0-9_.-]+$/.test(segment))) return null; + return value; +} + +function supportsRegion(entry: Record, server: StorySiteRegion): boolean { + if (entry.regions === undefined) return true; + return Array.isArray(entry.regions) && entry.regions.every((region) => typeof region === "string") && entry.regions.includes(server); +} + +function sanitizeIndexEntry(entry: Record, advId: number, sourceManifest: string): ReleasedStoryIndexEntry | null { + const languages = Array.isArray(entry.languages) + ? [...new Set(entry.languages.filter((language): language is typeof STORY_LANGUAGES[number] => + typeof language === "string" && (STORY_LANGUAGES as readonly string[]).includes(language)))] + : []; + if (languages.length === 0) return null; + + const titles: Partial> = {}; + if (isRecord(entry.titles)) { + for (const language of STORY_LANGUAGES) { + const title = entry.titles[language]; + if (typeof title === "string" && title.trim()) titles[language] = title; + } + } + const language = typeof entry.language === "string" && languages.includes(entry.language as typeof languages[number]) + ? entry.language + : languages[0]!; + + let size: StorySiteEntry["size"]; + if (isRecord(entry.size) && typeof entry.size.common === "number" && Number.isFinite(entry.size.common) && entry.size.common >= 0) { + const languageSizes: Partial> = {}; + if (isRecord(entry.size.languages)) { + for (const [key, value] of Object.entries(entry.size.languages)) { + if (typeof value === "number" && Number.isFinite(value) && value >= 0) languageSizes[key] = value; + } + } + size = { common: entry.size.common, languages: languageSizes }; + } + + return { + id: String(advId), + advId, + manifest: `stories/${advId}.json`, + sourceManifest, + titles, + languages, + language, + playbackMode: entry.playbackMode === 1 ? 1 : 0, + audio: entry.audio !== false, + ...(size ? { size } : {}), + }; +} + +/** The server-side story index after intersecting it with official release data for that same region. */ +export async function getReleasedStorySiteIndex(server: StorySiteRegion, now = Date.now()): Promise { + const site = getStorySites().find((candidate) => candidate.server === server); + if (!site) return { site: { root: "", server }, stories: [] }; + + const [response, masterStories] = await Promise.all([ + buildFetch(`${site.root}/stories.json`, { cache: "no-store", headers: { Accept: "application/json" }, redirect: "error" }), + getBuildStories(DEFAULT_LOCALE), + ]); + if (response.status === 404) return { site, stories: [] }; + if (!response.ok) throw new Error(`Story site index returned HTTP ${response.status}`); + + const index: unknown = await response.json(); + if (!isRecord(index) || index.format !== "ournotes.stories/1" || !Array.isArray(index.stories)) { + return { site, stories: [] }; + } + + const localByAdvId = new Map(); + for (const story of masterStories) { + if (story.servers.includes(server) && isStoryReleasedForServer(story, server, now)) { + localByAdvId.set(story.advId, story); + } + } + + const stories: ReleasedStoryIndexEntry[] = []; + for (const rawEntry of index.stories) { + if (!isRecord(rawEntry) || typeof rawEntry.advId !== "number" || !Number.isSafeInteger(rawEntry.advId) || rawEntry.advId <= 0) continue; + const advId = rawEntry.advId as number; + const story = localByAdvId.get(advId); + const sourceManifest = getIndexedStoryManifestPath(rawEntry.manifest, advId); + if (!story || !sourceManifest || !supportsRegion(rawEntry, server)) continue; + const sanitized = sanitizeIndexEntry(rawEntry, advId, sourceManifest); + if (sanitized) stories.push(sanitized); + } + return { site, stories }; +} + +function collectAssetPaths(files: unknown, output: Set): boolean { + if (!isRecord(files)) return false; + for (const file of Object.values(files)) { + if (!isRecord(file)) return false; + if (typeof file.asset === "string") { + if (!ASSET_PATH.test(file.asset)) return false; + output.add(file.asset); + } + if (file.parts !== undefined) { + if (!Array.isArray(file.parts)) return false; + for (const part of file.parts) { + if (!Array.isArray(part) || typeof part[1] !== "string" || !ASSET_PATH.test(part[1])) return false; + output.add(part[1]); + } + } + if (typeof file.asset !== "string" && file.parts === undefined) return false; + } + return true; +} + +function addStoryAssets(manifest: Record, output: Set): boolean { + if (!collectAssetPaths(manifest.files, output)) return false; + if (manifest.languages !== undefined) { + if (!isRecord(manifest.languages)) return false; + for (const language of Object.values(manifest.languages)) { + if (!isRecord(language) || !collectAssetPaths(language.files, output)) return false; + } + } + return true; +} + +async function fetchJson(siteRoot: string, path: string): Promise | null> { + const url = new URL(path, `${siteRoot.replace(/\/+$/, "")}/`); + const root = new URL(`${siteRoot.replace(/\/+$/, "")}/`); + if (url.origin !== root.origin || !url.pathname.startsWith(root.pathname)) return null; + const response = await buildFetch(url, { cache: "no-store", headers: { Accept: "application/json" }, redirect: "error" }); + if (!response.ok) return null; + const value: unknown = await response.json().catch(() => null); + return isRecord(value) ? value : null; +} + +async function loadStoryBundle(server: StorySiteRegion, advId: number): Promise { + const { site, stories } = await getReleasedStorySiteIndex(server); + if (!site.root) return null; + const entry = stories.find((story) => story.advId === advId); + if (!entry) return null; + + const manifest = await fetchJson(site.root, entry.sourceManifest); + if (!manifest || manifest.format !== "ournotes.story-manifest/2" || manifest.advId !== advId) return null; + if (!supportsRegion(manifest, server)) return null; + + const assetPaths = new Set(); + if (!addStoryAssets(manifest, assetPaths)) return null; + + const modelManifests = new Map>(); + if (manifest.models !== undefined) { + if (!isRecord(manifest.models)) return null; + for (const modelPath of Object.values(manifest.models)) { + if (typeof modelPath !== "string" || !MODEL_PATH.test(modelPath)) return null; + const modelManifest = await fetchJson(site.root, modelPath); + if (!modelManifest || !Number.isInteger(modelManifest.format) || !collectAssetPaths(modelManifest.files, assetPaths)) return null; + modelManifests.set(modelPath, modelManifest); + } + } + + return { + manifest: { ...manifest, root: `./${advId}/` }, + assetPaths, + modelManifests, + }; +} + +/** + * The same-origin resource proxy checks MasterData release before consulting this short cache, so a cloud index or a + * directly requested manifest can never authorize an unknown, missing-date or unreleased episode. + */ +export async function getGatedStoryBundle(server: StorySiteRegion, advId: number): Promise { + if (!Number.isSafeInteger(advId) || advId <= 0) return null; + const stories = await getBuildStories(DEFAULT_LOCALE); + const local = stories.find((story) => story.advId === advId); + if (!local || !isStoryReleasedForServer(local, server, Date.now())) return null; + + const key = `${server}:${advId}`; + const cached = storyBundleCache.get(key); + if (cached && cached.expiresAt > Date.now()) return cached.promise; + + const promise = loadStoryBundle(server, advId).catch((error: unknown) => { + storyBundleCache.delete(key); + throw error; + }); + storyBundleCache.set(key, { expiresAt: Date.now() + STORY_BUNDLE_TTL_MS, promise }); + return promise; +} + +/** The allowed upstream root path for one already validated resource request. */ +export function getApprovedStoryResourcePath(path: unknown): string | null { + if (typeof path !== "string" || path.includes("\\") || path.includes("?") || path.includes("#")) return null; + const segments = path.split("/"); + if (segments.some((segment) => !segment || segment === "." || segment === "..")) return null; + if (ASSET_PATH.test(path) || MODEL_PATH.test(path)) return path; + return null; +} + +/** Public index metadata without the upstream manifest path used only by the proxy. */ +export function toPublicStoryIndexEntry(entry: ReleasedStoryIndexEntry): Omit { + const { sourceManifest: _sourceManifest, ...publicEntry } = entry; + return publicEntry; +} + +/** Server names accepted by the story-site routes; the sites are only published for TW and JP. */ +export function isStorySiteRegion(value: string): value is StorySiteRegion { + return value === "tw" || value === "jp"; +} diff --git a/src/server-pages/CatalogServerPages.tsx b/src/server-pages/CatalogServerPages.tsx index f3e8b3f2..e766aed5 100644 --- a/src/server-pages/CatalogServerPages.tsx +++ b/src/server-pages/CatalogServerPages.tsx @@ -19,11 +19,15 @@ import ShopExplorer from "@/components/shop/ShopExplorer"; import SkillsExplorer from "@/components/skills/SkillsExplorer"; import StampsExplorer from "@/components/stamps/StampsExplorer"; import StoryExplorer from "@/components/story/StoryExplorer"; +import { isReleaseFacetReleasedAcrossServers, isStoryReleasedAcrossServers, isStoryReleasedAt } from "@/lib/story/release"; +import { getBuildReleasedCharacterIds } from "@/lib/characters/release"; +import { valueForServer, type ServerFaceted, type ServerFacetedValue } from "@/lib/servers/facets"; import { inStorySection, type StorySection } from "@/lib/story/sections"; import SupportCardsExplorer from "@/components/support-cards/SupportCardsExplorer"; import TgwCardView from "@/components/tgw-card/TgwCardView"; import TitlesExplorer from "@/components/titles/TitlesExplorer"; import type { AppLocale } from "@/config/locales"; +import type { GameServer } from "@/config/servers"; import { haneokaEngineDocument, haneokaUrl, siteConfig } from "@/config/site"; import { t } from "@/i18n"; import { getLegalTexts } from "@/i18n/legal"; @@ -66,12 +70,41 @@ import { getNavChildren } from "@/lib/route/registry"; import { MASTER_TIME_ZONE } from "@/lib/schedule"; import type { AppRoute } from "@/types/route"; +function releasedFacets(entries: ServerFaceted[], now: number): ServerFaceted[] { + return entries.filter((entry) => isReleaseFacetReleasedAcrossServers(entry, now)); +} + +function releasedCalendar }>(data: ServerFacetedValue, now: number): ServerFacetedValue { + const datesByEntry = new Map>(); + for (const server of data.servers) { + for (const entry of valueForServer(data, server).entries) { + const dates = datesByEntry.get(entry.id) ?? new Map(); + dates.set(server, entry.startAt); + datesByEntry.set(entry.id, dates); + } + } + const publicIds = new Set([...datesByEntry].flatMap(([id, dates]) => + dates.size > 0 && [...dates.values()].every((date) => isStoryReleasedAt(date, now)) ? [id] : [], + )); + const filter = (value: Partial): Partial => { + const entries = value.entries; + return Array.isArray(entries) ? { ...value, entries: entries.filter((entry) => publicIds.has(entry.id)) } as Partial : value; + }; + const serverVariants = Object.fromEntries(Object.entries(data.serverVariants ?? {}).map(([server, value]) => [server, filter(value)])) as ServerFacetedValue["serverVariants"]; + return { + value: filter(data.value) as T, + servers: data.servers, + ...(serverVariants && Object.keys(serverVariants).length > 0 ? { serverVariants } : {}), + }; +} + export async function CardsServerPage({ locale }: { locale: AppLocale }) { - const [cards, servers, skillNames] = await Promise.all([ + const [allCards, servers, skillNames] = await Promise.all([ getBuildCards(locale), getBuildServers(), getBuildCardSkillNames(locale), ]); + const cards = releasedFacets(allCards, Date.now()); return ( @@ -118,11 +153,12 @@ export async function SupportCardsServerPage({ locale }: { locale: AppLocale }) } export async function MusicServerPage({ locale }: { locale: AppLocale }) { - const [songs, categories, servers] = await Promise.all([ + const [allSongs, categories, servers] = await Promise.all([ getBuildMusic(locale), getBuildMusicCategories(locale), getBuildServers(), ]); + const songs = releasedFacets(allSongs, Date.now()); const categoryNames: Array<[number, string]> = categories.map((category) => [category.id, category.name]); return ( @@ -132,7 +168,14 @@ export async function MusicServerPage({ locale }: { locale: AppLocale }) { } export async function CharactersServerPage({ locale }: { locale: AppLocale }) { - const [characters, servers] = await Promise.all([getBuildCharacters(locale), getBuildServers()]); + const [allCharacters, servers] = await Promise.all([getBuildCharacters(locale), getBuildServers()]); + const releasedCharacterIds = await getBuildReleasedCharacterIds(locale); + const characters = { + ...allCharacters, + characters: allCharacters.characters.filter((character) => releasedCharacterIds.has(character.id)), + }; + const releasedBandIds = new Set(characters.characters.map((character) => character.bandId)); + characters.bands = characters.bands.filter((band) => releasedBandIds.has(band.id)); return ( @@ -141,13 +184,16 @@ export async function CharactersServerPage({ locale }: { locale: AppLocale }) { } export async function EventsServerPage({ locale }: { locale: AppLocale }) { - const [events, { bands }, servers, arenaCatalogs] = await Promise.all([ + const [allEvents, { characters, bands }, servers, arenaCatalogs, releasedCharacterIds] = await Promise.all([ getBuildEvents(locale), getBuildCharacters(locale), getBuildServers(), getBuildArenaCatalogs(locale), + getBuildReleasedCharacterIds(locale), ]); - const bandOptions: Array<[number, string]> = bands.map((band) => [band.id, band.name]); + const events = releasedFacets(allEvents, Date.now()); + const publicBandIds = new Set(characters.filter((character) => releasedCharacterIds.has(character.id)).map((character) => character.bandId)); + const bandOptions: Array<[number, string]> = bands.filter((band) => publicBandIds.has(band.id)).map((band) => [band.id, band.name]); return ( = bands.map((band) => [band.id, band.name]); + const gachas = releasedFacets(allGachas, Date.now()); + const publicBandIds = new Set(characters.filter((character) => releasedCharacterIds.has(character.id)).map((character) => character.bandId)); + const bandOptions: Array<[number, string]> = bands.filter((band) => publicBandIds.has(band.id)).map((band) => [band.id, band.name]); return ( @@ -176,13 +225,19 @@ export async function GachaServerPage({ locale }: { locale: AppLocale }) { } export async function CalendarServerPage({ locale }: { locale: AppLocale }) { - const [{ data, birthdays }, { characters }, servers] = await Promise.all([ + const [calendar, { characters }, servers, releasedCharacterIds] = await Promise.all([ getBuildCalendar(locale), getBuildCharacters(locale), getBuildServers(), + getBuildReleasedCharacterIds(locale), ]); - const characterOptions = characters.map((character) => ({ id: character.id, name: character.name })); - const buildMonth = monthKey(monthOf(calendarDayAt(Date.now(), MASTER_TIME_ZONE))); + const now = Date.now(); + const data = releasedCalendar(calendar.data, now); + const birthdays = calendar.birthdays.filter((birthday) => releasedCharacterIds.has(birthday.characterId)); + const characterOptions = characters + .filter((character) => releasedCharacterIds.has(character.id)) + .map((character) => ({ id: character.id, name: character.name })); + const buildMonth = monthKey(monthOf(calendarDayAt(now, MASTER_TIME_ZONE))); return ( inStorySection(category, story)); + const now = Date.now(); + const stories = allStories.filter((story) => isStoryReleasedAcrossServers(story, now) && inStorySection(category, story)); + const releasedCharacterIds = new Set(stories.flatMap((story) => story.characterIds)); + const readerCharacters = readerLookup.characters.filter((character) => releasedCharacterIds.has(character.id)); + const readerTextIds = new Set(readerCharacters.flatMap((character) => [character.nameTextID, character.shortNameTextID])); + const readerTexts = readerLookup.texts.filter((text) => readerTextIds.has(text.id)); const bands: Array<[number, string]> = characterData ? characterData.bands.map((band) => [band.id, band.name]) : []; return ( @@ -227,8 +287,8 @@ export async function StoryServerPage({ bands={bands} initialCategory={category} initialStories={stories} - initialCharacters={readerLookup.characters} - initialTexts={readerLookup.texts} + initialCharacters={readerCharacters} + initialTexts={readerTexts} /> ); @@ -257,11 +317,12 @@ export async function BandItemsServerPage({ locale }: { locale: AppLocale }) { } export async function StampsServerPage({ locale }: { locale: AppLocale }) { - const [stamps, bandNames, servers] = await Promise.all([ + const [allStamps, bandNames, servers] = await Promise.all([ getBuildStamps(locale), getBuildBandNames(locale), getBuildServers(), ]); + const stamps = releasedFacets(allStamps, Date.now()); return ( @@ -270,11 +331,12 @@ export async function StampsServerPage({ locale }: { locale: AppLocale }) { } export async function ComicsServerPage({ locale }: { locale: AppLocale }) { - const [comics, bandNames, servers] = await Promise.all([ + const [allComics, bandNames, servers] = await Promise.all([ getBuildComics(locale), getBuildBandNames(locale), getBuildServers(), ]); + const comics = releasedFacets(allComics, Date.now()); return ( @@ -283,13 +345,17 @@ export async function ComicsServerPage({ locale }: { locale: AppLocale }) { } export async function TitlesServerPage({ locale }: { locale: AppLocale }) { - const [titles, { characters, bands }, servers] = await Promise.all([ + const [allTitles, { characters, bands }, servers, releasedCharacterIds] = await Promise.all([ getBuildDegrees(locale), getBuildCharacters(locale), getBuildServers(), + getBuildReleasedCharacterIds(locale), ]); - const bandOptions: Array<[number, string]> = bands.map((band) => [band.id, band.name]); - const characterOptions = characters.map((character) => ({ id: character.id, name: character.name, bandId: character.bandId })); + const titles = releasedFacets(allTitles, Date.now()); + const publicCharacters = characters.filter((character) => releasedCharacterIds.has(character.id)); + const publicBandIds = new Set(publicCharacters.map((character) => character.bandId)); + const bandOptions: Array<[number, string]> = bands.filter((band) => publicBandIds.has(band.id)).map((band) => [band.id, band.name]); + const characterOptions = publicCharacters.map((character) => ({ id: character.id, name: character.name, bandId: character.bandId })); return ( @@ -316,7 +382,8 @@ export async function ExchangeServerPage({ locale }: { locale: AppLocale }) { } export async function RewardsServerPage({ locale }: { locale: AppLocale }) { - const [entries, servers] = await Promise.all([getBuildRewardEntries(locale), getBuildServers()]); + const [allEntries, servers] = await Promise.all([getBuildRewardEntries(locale), getBuildServers()]); + const entries = releasedFacets(allEntries, Date.now()); return ( @@ -325,7 +392,8 @@ export async function RewardsServerPage({ locale }: { locale: AppLocale }) { } export async function ShopServerPage({ locale }: { locale: AppLocale }) { - const [shops, servers] = await Promise.all([getBuildShops(locale), getBuildServers()]); + const [allShops, servers] = await Promise.all([getBuildShops(locale), getBuildServers()]); + const shops = releasedFacets(allShops, Date.now()); return ( diff --git a/src/server-pages/DetailServerPages.tsx b/src/server-pages/DetailServerPages.tsx index eececdb5..554cc5ab 100644 --- a/src/server-pages/DetailServerPages.tsx +++ b/src/server-pages/DetailServerPages.tsx @@ -12,11 +12,13 @@ import BreadcrumbSync from "@/components/shell/BreadcrumbSync"; import ShopDetail from "@/components/shop/ShopDetail"; import StoryDetail from "@/components/story/StoryDetail"; import StoryChapterPage from "@/components/story/StoryChapterPage"; +import SpoilerNotice from "@/components/shared/SpoilerNotice"; import SupportCardDetail from "@/components/support-cards/SupportCardDetail"; import type { AppLocale } from "@/config/locales"; import { notFound } from "next/navigation"; import { getBuildMemberCardMaterials, getBuildSupportCardMaterials } from "@/lib/masterdata/build-card-materials"; import { getBuildCharacterExtras } from "@/lib/masterdata/build-character-extras"; +import { getBuildReleasedCharacterIds } from "@/lib/characters/release"; import { getBuildCardDetail, getBuildCharacterDetail, @@ -38,6 +40,7 @@ import { getBuildServers } from "@/lib/masterdata/build-servers"; import { getBuildShopDetail, getBuildShopNeighbors } from "@/lib/masterdata/build-shop"; import { getDetailBreadcrumb } from "@/lib/route/static-paths"; import type { DetailSemanticSeoData } from "@/lib/seo/entity-seo"; +import { isStoryReleasedAcrossServers } from "@/lib/story/release"; import { inStorySection, type StoryChapterSection } from "@/lib/story/sections"; interface DetailPageContext { @@ -83,6 +86,7 @@ export async function SupportCardDetailServerPage({ locale, pathname, paramId, s export async function CharacterDetailServerPage({ locale, pathname, paramId, semantic }: DetailPageContext) { const characterId = Number(paramId); + if (!Number.isSafeInteger(characterId) || !(await getBuildReleasedCharacterIds(locale)).has(characterId)) notFound(); const [initialData, progressionData, extrasData, servers, breadcrumb] = await Promise.all([ getBuildCharacterDetail(locale, characterId), getBuildCharacterProgression(locale, characterId), @@ -246,8 +250,16 @@ export async function HelpDetailServerPage({ locale, pathname, paramId, semantic export async function StoryDetailServerPage({ locale, pathname, paramId, semantic }: DetailPageContext) { const advId = Number(paramId); - const [detail, servers, breadcrumb] = await Promise.all([ - getBuildStoryDetail(locale, advId), + const detail = await getBuildStoryDetail(locale, advId); + if (!detail.story || !isStoryReleasedAcrossServers(detail.story, Date.now())) { + return ( + <> + + + + ); + } + const [servers, breadcrumb] = await Promise.all([ getBuildServers(), getDetailBreadcrumb("story-detail", paramId, locale), ]); @@ -274,13 +286,23 @@ export async function StoryDetailServerPage({ locale, pathname, paramId, semanti export async function StoryChapterServerPage({ locale, pathname, paramId, section, semantic }: DetailPageContext & { section: StoryChapterSection }) { const chapterId = Number(paramId); - const [allStories, servers, breadcrumb] = await Promise.all([ - getBuildStories(locale), + const allStories = await getBuildStories(locale); + const chapterStories = allStories.filter((story) => story.chapterId === chapterId && inStorySection(section, story)); + if (chapterStories.length === 0) notFound(); + const now = Date.now(); + const stories = chapterStories.filter((story) => isStoryReleasedAcrossServers(story, now)); + if (stories.length === 0) { + return ( + <> + + + + ); + } + const [servers, breadcrumb] = await Promise.all([ getBuildServers(), getDetailBreadcrumb("story-chapter", paramId, locale), ]); - const stories = allStories.filter((story) => story.chapterId === chapterId && inStorySection(section, story)); - if (stories.length === 0) notFound(); return ( <> diff --git a/src/server-pages/ToolServerPages.tsx b/src/server-pages/ToolServerPages.tsx index 2a87697d..ec0ef6cd 100644 --- a/src/server-pages/ToolServerPages.tsx +++ b/src/server-pages/ToolServerPages.tsx @@ -34,6 +34,8 @@ import { getBuildSupportCards, } from "@/lib/masterdata/build-data"; import { getBuildGuessSongs } from "@/lib/masterdata/build-guess-music"; +import { filterReleasedCharacterFacets, getBuildReleasedCharacterServers } from "@/lib/characters/release"; +import { restrictFacetServers } from "@/lib/servers/facets"; import { getBuildServers } from "@/lib/masterdata/build-servers"; import { getBuildThemeBands } from "@/lib/masterdata/build-settings"; import { getBuildPlayerCatalogue } from "@/lib/masterdata/player-fields"; @@ -41,6 +43,7 @@ import { getBuildRecognitionSource } from "@/lib/masterdata/recognition-source"; import { getBuildGameSaveTables } from "@/lib/masterdata/save-tables"; import { getRoutePathById } from "@/lib/route/registry"; import { storyEpisodeLabel } from "@/lib/story/labels"; +import { isStoryReleasedAcrossServers, storyReleaseDates } from "@/lib/story/release"; import type { StoryPickerStory } from "@/lib/story/player-data"; export async function DeckWorkspaceServerPage({ @@ -130,25 +133,45 @@ export async function ChartPreview3DServerPage({ locale }: { locale: AppLocale } } export async function Live2DViewerServerPage({ locale }: { locale: AppLocale }) { - const { characters, bands } = await getBuildCharacters(locale); + const [data, servers, releasedCharacterServers] = await Promise.all([ + getBuildCharacters(locale), + getBuildServers(), + getBuildReleasedCharacterServers(locale), + ]); + const characters = await filterReleasedCharacterFacets(data.characters, releasedCharacterServers); + const bandServers = new Map>(); + for (const character of characters) { + const set = bandServers.get(character.bandId) ?? new Set<(typeof servers)[number]>(); + character.servers.forEach((server) => set.add(server)); + bandServers.set(character.bandId, set); + } const pickerCharacters = characters.map((character) => ({ id: character.id, name: character.name, bandId: character.bandId, + servers: character.servers, })); - const pickerBands = [...bands].sort((a, b) => a.id - b.id).map((band) => ({ id: band.id, name: band.name })); + const pickerBands = data.bands.flatMap((band) => { + const safe = restrictFacetServers(band, [...(bandServers.get(band.id) ?? [])]); + return safe ? [{ id: safe.id, name: safe.name, servers: safe.servers }] : []; + }).sort((a, b) => a.id - b.id); return ( - + ); } export async function StoryPlayerServerPage({ locale }: { locale: AppLocale }) { - const stories: StoryPickerStory[] = (await getBuildStories(locale)).map((story) => ({ + const now = Date.now(); + const stories: StoryPickerStory[] = (await getBuildStories(locale)) + .filter((story) => isStoryReleasedAcrossServers(story, now)) + .map((story) => ({ advId: story.advId, assetServer: story.servers[0] ?? PRIMARY_SERVER, category: story.category, + servers: story.servers, + releaseDates: storyReleaseDates(story), title: story.title, groupId: story.groupId, groupTitle: story.groupTitle || story.bandName, @@ -166,7 +189,6 @@ export async function StoryPlayerServerPage({ locale }: { locale: AppLocale }) { ? `Story/Banner/${story.assets.banner ? "Episode" : "Chapter"}/${story.assets.banner || story.assets.image}` : "", searchText: story.searchText, - startAt: story.startAt, })); return ( diff --git a/tests/character-extras.test.ts b/tests/character-extras.test.ts index 9218ce55..4a55d2cc 100644 --- a/tests/character-extras.test.ts +++ b/tests/character-extras.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"; import { characterVoiceLines, collectVoiceLines, + filterReleasedCharacterVoiceLines, groupVoiceLines, voiceAudioUrl, voiceLinesByCharacter, @@ -95,6 +96,14 @@ describe("character voices", () => { expect(characterVoiceLines(payload, "jp", 2).map((line) => line.source)).toEqual(["talk", "liveDialogueFixedPair"]); expect(characterVoiceLines(payload, "tw", 99)).toEqual([]); }); + + test("the public payload omits unreleased speakers, partners, birthday references and card gacha voices", () => { + const futureCardLine = { source: "memberCard" as const, id: 200, characterId: 1, text: "Gacha_200", sound: sound(2200), cardId: 200 }; + const safe = filterReleasedCharacterVoiceLines([...lines, futureCardLine], new Set([1]), new Set([100])); + expect(safe.some((line) => line.characterId === 2 || line.partnerId === 2 || line.birthdayCharacterId === 2)).toBe(false); + expect(safe.filter((line) => line.source === "memberCard").map((line) => line.cardId)).toEqual([100]); + expect(safe.some((line) => line.text === "Talk_3")).toBe(false); + }); }); describe("character bonds", () => { diff --git a/tests/live2d-models.test.ts b/tests/live2d-models.test.ts index 0be549cb..2b6ce493 100644 --- a/tests/live2d-models.test.ts +++ b/tests/live2d-models.test.ts @@ -10,6 +10,7 @@ import { parseLive2DViewerSearch, } from "../src/lib/live2d/models"; import { IDENTITY_VIEW, MAX_ZOOM, clampView, toNdc, zoomAbout } from "../src/lib/live2d/view"; +import { releasedLive2DModelIds } from "../src/lib/live2d/site-access"; import { enUS } from "../src/i18n/messages/en-US"; const model = (id: string, group: string, character?: number) => @@ -44,6 +45,24 @@ describe("Live2D model site addresses", () => { }); }); +describe("Live2D release gate", () => { + test("cloud model ids are authorized only by exact costume paths in released regional MasterData", () => { + const groups = [ + { id: 1, characterID: 1, costumeNameTextId: "", iconPath: "", isInitial: true, isChangeable: false, specialConditionMemberCardId: 0, startAt: "" }, + { id: 2, characterID: 1, costumeNameTextId: "", iconPath: "", isInitial: false, isChangeable: true, specialConditionMemberCardId: 0, startAt: "2026/11/01 00:00:00" }, + { id: 3, characterID: 1, costumeNameTextId: "", iconPath: "", isInitial: false, isChangeable: true, specialConditionMemberCardId: 0, startAt: "2026/12/01 00:00:00" }, + ]; + const costumes = [ + { id: 1, characterID: 1, groupID: 1, costumeType: 2, costumeId: 1, isDefault: true, live2dPath: "001_adv/live2d_tomori_001_initial" }, + { id: 2, characterID: 1, groupID: 2, costumeType: 2, costumeId: 2, isDefault: false, live2dPath: "001_adv/adv_live2d_tomori_001_casual" }, + { id: 3, characterID: 1, groupID: 3, costumeType: 2, costumeId: 3, isDefault: false, live2dPath: "001_adv/adv_live2d_tomori_001_future" }, + { id: 4, characterID: 1, groupID: 99, costumeType: 2, costumeId: 4, isDefault: false, live2dPath: "001_adv/adv_live2d_tomori_001_unknown" }, + ]; + const allowed = releasedLive2DModelIds(costumes, groups, Date.parse("2026-11-15T00:00:00Z")); + expect(allowed).toEqual(new Set(["live2d_tomori_001_initial", "adv_live2d_tomori_001_casual"])); + }); +}); + describe("Live2D models", () => { test("a numbered group names the character, even without the master data's mapping", () => { const rana = model("adv_live2d_rana_003_school_winter_hs_1st_glasses", "003_adv"); diff --git a/tests/live2d-proxy-runtime.test.ts b/tests/live2d-proxy-runtime.test.ts new file mode 100644 index 00000000..62fbfe94 --- /dev/null +++ b/tests/live2d-proxy-runtime.test.ts @@ -0,0 +1,224 @@ +import { afterEach, describe, expect, spyOn, test } from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { NextRequest } from "next/server"; +import { GET as getLive2DIndex } from "../src/app/api/v1/live2d/index/route"; +import { GET as getLive2DResource } from "../src/app/api/v1/live2d/[server]/[source]/[...path]/route"; +import { assetConfig } from "../src/config/assets"; +import { findRequestedLive2DModel, type Live2DModel } from "../src/lib/live2d/models"; +import { getLive2DModelAssetBase } from "../src/lib/live2d/client"; +import { resetLive2DSiteAccessCachesForTests } from "../src/lib/live2d/site-access"; +import { resetMasterDataSnapshotForTests } from "../src/lib/masterdata/build-snapshot"; +import { getApprovedStoryResourcePath } from "../src/lib/story/site-access"; + +const originalMasterDataDir = process.env.MOENOTES_MASTERDATA_DIR; +const cleanups: Array<() => void> = []; + +afterEach(() => { + while (cleanups.length > 0) cleanups.pop()?.(); + resetLive2DSiteAccessCachesForTests(); + resetMasterDataSnapshotForTests(); +}); + +function createReleasedAndFutureCostumes(): string { + const root = mkdtempSync(join(tmpdir(), "moenotes-live2d-proxy-test-")); + cleanups.push(() => rmSync(root, { recursive: true, force: true })); + process.env.MOENOTES_MASTERDATA_DIR = root; + cleanups.push(() => { + if (originalMasterDataDir === undefined) delete process.env.MOENOTES_MASTERDATA_DIR; + else process.env.MOENOTES_MASTERDATA_DIR = originalMasterDataDir; + }); + + mkdirSync(join(root, "jp"), { recursive: true }); + writeFileSync(join(root, "current_version.json"), JSON.stringify({ + dataVersion: "live2d-proxy-test-v1", + regions: { jp: { version: "1.0.0/jp-test", assetVersion: "1.0.0", requiredTables: 2, availableTables: 2 } }, + })); + writeFileSync(join(root, "jp", "MasterCharacterCostume.json"), JSON.stringify([ + { _id: 1001, _characterID: 1, _groupID: 101, _costumeType: 2, _costumeId: 1, _isDefault: true, _live2dPath: "001_live/live2d_tomori_001_live_01" }, + { _id: 1002, _characterID: 1, _groupID: 102, _costumeType: 2, _costumeId: 2, _isDefault: false, _live2dPath: "001_live/live2d_tomori_001_live_future" }, + ])); + writeFileSync(join(root, "jp", "MasterCharacterCostumeGroup.json"), JSON.stringify([ + { _id: 101, _characterID: 1, _costumeNameTextId: "", _iconPath: "", _isInitial: true, _isChangeable: false, _specialConditionMemberCardId: 0, _startAt: "" }, + { _id: 102, _characterID: 1, _costumeNameTextId: "", _iconPath: "", _isInitial: false, _isChangeable: true, _specialConditionMemberCardId: 0, _startAt: "2999/01/01 00:00:00" }, + ])); + resetMasterDataSnapshotForTests(); + resetLive2DSiteAccessCachesForTests(); + return root; +} + +function live2DResourceRequest(path: string[], query = ""): NextRequest { + const suffix = path.map((part) => encodeURIComponent(part)).join("/"); + return new NextRequest(`https://moenotes.test/api/v1/live2d/jp/chart/${suffix}${query}`); +} + +function live2DResourceContext(path: string[]) { + return { params: Promise.resolve({ server: "jp", source: "chart", path }) }; +} + +describe("ournotes-player 0.1.8 Live2D proxy runtime", () => { + test("the real AssetStore resolves listed files under the model-scoped proxy directory", async () => { + const modelId = "adv_live2d_anon_002_casual_spring_01"; + const hash = "a".repeat(64); + const manifestUrl = `https://moenotes.test/api/v1/live2d/jp/chart/models/${modelId}.json`; + const expectedAssetUrl = `https://moenotes.test/api/v1/live2d/jp/chart/models/${modelId}/assets/${hash}.moc3`; + const base = getLive2DModelAssetBase(manifestUrl, "https://moenotes.test/tools/live2d"); + const calls: string[] = []; + const { AssetStore } = await import("ournotes-player/live2d"); + const store = await AssetStore.fromManifest(manifestUrl, { + base, + fetch: async (input) => { + const url = String(input); + calls.push(url); + if (url === manifestUrl) { + return Response.json({ + format: 3, + id: modelId, + root: "../", + files: { "model.moc3": { asset: `assets/${hash}.moc3`, size: 3 } }, + }); + } + if (url === expectedAssetUrl) return new Response(new Uint8Array([1, 2, 3])); + return new Response("not found", { status: 404 }); + }, + }); + + expect(base).toBe(`https://moenotes.test/api/v1/live2d/jp/chart/models/${modelId}/`); + expect(calls).toEqual([manifestUrl, expectedAssetUrl]); + expect(store.has("model.moc3")).toBe(true); + }); + + test("the same Next route releases listed assets only, and denies direct unknown, malformed and unreleased ids", async () => { + createReleasedAndFutureCostumes(); + const releasedId = "live2d_tomori_001_live_01"; + const futureId = "live2d_tomori_001_live_future"; + const listedHash = "b".repeat(64); + const unlistedHash = "c".repeat(64); + const assetBytes = new Uint8Array([7, 8, 9]); + const calls: string[] = []; + const fetcher = spyOn(globalThis, "fetch").mockImplementation(async (input) => { + const url = String(input); + calls.push(url); + if (url === `${assetConfig.chartSite}/models.json`) { + return Response.json({ models: [ + { id: releasedId, manifest: `models/${releasedId}.json`, group: "001_live", character: 1 }, + { id: futureId, manifest: `models/${futureId}.json`, group: "001_live", character: 1 }, + ] }); + } + if (url === `${assetConfig.storySiteJp}/models.json`) return new Response("not found", { status: 404 }); + if (url === `${assetConfig.chartSite}/models/${releasedId}.json`) { + return Response.json({ + format: 3, + id: releasedId, + files: { "model.moc3": { asset: `assets/${listedHash}.moc3`, size: assetBytes.length } }, + }); + } + if (url === `${assetConfig.chartSite}/assets/${listedHash}.moc3`) return new Response(assetBytes); + return new Response("not found", { status: 404 }); + }); + cleanups.push(() => fetcher.mockRestore()); + + const indexResponse = await getLive2DIndex(new NextRequest("https://moenotes.test/api/v1/live2d/index?server=jp")); + expect(indexResponse.status).toBe(200); + const index = await indexResponse.json() as { models: Live2DModel[] }; + expect(index.models.map((model) => model.id)).toEqual([releasedId]); + expect(findRequestedLive2DModel(`?model=${releasedId}`, index.models)?.id).toBe(releasedId); + expect(findRequestedLive2DModel(`?model=${futureId}`, index.models)).toBeNull(); + expect(findRequestedLive2DModel("?model=unknown_model", index.models)).toBeNull(); + expect(findRequestedLive2DModel("?model=../unreleased", index.models)).toBeNull(); + + const manifestResponse = await getLive2DResource( + live2DResourceRequest(["models", `${releasedId}.json`]), + live2DResourceContext(["models", `${releasedId}.json`]), + ); + expect(manifestResponse.status).toBe(200); + + const listedResponse = await getLive2DResource( + live2DResourceRequest(["models", releasedId, "assets", `${listedHash}.moc3`]), + live2DResourceContext(["models", releasedId, "assets", `${listedHash}.moc3`]), + ); + expect(listedResponse.status).toBe(200); + expect([...new Uint8Array(await listedResponse.arrayBuffer())]).toEqual([...assetBytes]); + + const deniedPaths = [ + ["assets", `${listedHash}.moc3`], + ["models", "unknown_model.json"], + ["models", "unknown_model", "assets", `${listedHash}.moc3`], + ["models", releasedId, "assets", `${unlistedHash}.moc3`], + ["models", futureId, "assets", `${listedHash}.moc3`], + ["models", `${futureId}.json`], + ["models", "../release.json"], + ["models", releasedId, "assets", "nested", `${listedHash}.moc3`], + ]; + for (const path of deniedPaths) { + const response = await getLive2DResource(live2DResourceRequest(path), live2DResourceContext(path)); + expect(response.status).toBe(404); + } + + expect(calls).not.toContain(`${assetConfig.chartSite}/assets/${unlistedHash}.moc3`); + expect(calls).not.toContain(`${assetConfig.chartSite}/models/${futureId}.json`); + }); + + test("rejects a foreign or non-proxy model manifest before AssetStore can fetch assets", () => { + expect(() => getLive2DModelAssetBase("https://evil.example/api/v1/live2d/jp/chart/models/model_a.json", "https://moenotes.test/tools/live2d")) + .toThrow(/same-origin/); + expect(() => getLive2DModelAssetBase("https://moenotes.test/chart-site/models/model_a.json", "https://moenotes.test/tools/live2d")) + .toThrow(/release-gated/); + expect(() => getLive2DModelAssetBase("https://moenotes.test/api/v1/live2d/jp/chart/models/model_a.json?x=1", "https://moenotes.test/tools/live2d")) + .toThrow(/query or fragment/); + }); +}); + +describe("ournotes-player 0.1.8 story proxy runtime", () => { + test("story model manifests and their listed assets resolve only inside the episode-scoped proxy", async () => { + const advId = 24680; + const modelId = "adv_live2d_anon_002_casual_spring_01"; + const hash = "d".repeat(64); + const manifestUrl = `https://moenotes.test/api/v1/story-site/jp/stories/${advId}.json`; + const modelUrl = `https://moenotes.test/api/v1/story-site/jp/stories/${advId}/models/${modelId}.json`; + const assetUrl = `https://moenotes.test/api/v1/story-site/jp/stories/${advId}/assets/${hash}.moc3`; + const calls: string[] = []; + const { loadStoryStore } = await import("ournotes-player/story"); + const store = await loadStoryStore(manifestUrl, { + lang: "ja", + fetch: async (input) => { + const url = String(input); + calls.push(url); + if (url === manifestUrl) { + return Response.json({ + format: "ournotes.story-manifest/2", + advId, + language: "ja", + root: `./${advId}/`, + files: {}, + languages: { ja: { files: {} } }, + models: { [modelId]: `models/${modelId}.json` }, + }); + } + if (url === modelUrl) { + return Response.json({ + format: 3, + id: modelId, + files: { "model.moc3": { asset: `assets/${hash}.moc3`, size: 3 } }, + }); + } + if (url === assetUrl) return new Response(new Uint8Array([4, 5, 6])); + return new Response("not found", { status: 404 }); + }, + }); + + expect(calls).toEqual([manifestUrl, modelUrl, assetUrl]); + expect(store.has(`live2d/${modelId}/model.moc3`)).toBe(true); + + const routeResources = calls.slice(1).map((url) => { + const pathname = new URL(url).pathname; + const routePath = pathname.split("/").slice(5); + expect(routePath[0]).toBe("stories"); + expect(routePath[1]).toBe(String(advId)); + return getApprovedStoryResourcePath(routePath.slice(2).join("/")); + }); + expect(routeResources).toEqual([`models/${modelId}.json`, `assets/${hash}.moc3`]); + expect(getApprovedStoryResourcePath(`models/${modelId}.json`)).toBe(`models/${modelId}.json`); + }); +}); diff --git a/tests/player-files.test.ts b/tests/player-files.test.ts index df2265fd..e748d501 100644 --- a/tests/player-files.test.ts +++ b/tests/player-files.test.ts @@ -13,6 +13,15 @@ describe("player file cache keys", () => { expect(getPlayerFileKey(`${assetConfig.chartSite}/assets/${hash}.moc3`)).toBe(`${assetConfig.chartSite}/assets/${hash}.moc3`); }); + test("release-gated Live2D proxy assets are cacheable, not their manifests or arbitrary paths", () => { + const base = "https://bdon.moe"; + const asset = `/api/v1/live2d/tw/chart/models/adv_live2d_anon_002_casual_spring_01/assets/${hash}.moc3.gz`; + expect(getPlayerFileKey(asset, base)).toBe(`${base}${asset}`); + expect(getPlayerFileKey(`/api/v1/live2d/tw/chart/models/adv_live2d_anon_002_casual_spring_01.json`, base)).toBeNull(); + expect(getPlayerFileKey(`/api/v1/live2d/tw/chart/models/adv_live2d_anon_002_casual_spring_01/assets/sub/${hash}.moc3`, base)).toBeNull(); + expect(getPlayerFileKey(`/api/v1/live2d/jp/story/models/adv_live2d_anon_002_casual_spring_01/assets/${hash}.moc3?v=1`, base)).toBeNull(); + }); + test("the key leaves out the hash", () => { expect(getPlayerFileKey(`${assetConfig.chartSite}/assets/${hash}.png#frame`)).toBe(`${assetConfig.chartSite}/assets/${hash}.png`); }); diff --git a/tests/search-release.test.ts b/tests/search-release.test.ts new file mode 100644 index 00000000..73cf8d0c --- /dev/null +++ b/tests/search-release.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, test } from "bun:test"; +import type { ContentSearchEntry } from "../src/lib/search/content-entry"; +import { filterReleasedSearchEntries } from "../src/lib/search/content-index"; + +function searchEntry(key: string, kind: ContentSearchEntry["kind"], startAt?: string): ContentSearchEntry { + return { key, kind, href: "/story/1", title: { english: "Search title" }, searchText: {}, ...(startAt === undefined ? {} : { startAt }) }; +} + +describe("public search release filtering", () => { + const now = Date.UTC(2026, 9, 27, 12); + + test("story and schedule-bearing entries require an explicit all-region MasterData approval", () => { + const entries = [ + searchEntry("story:1", "story", "2026/10/01 12:00:00"), + searchEntry("story:2", "story", "2026/10/01 12:00:00"), + searchEntry("card:3", "card", "2026/10/01 12:00:00"), + ]; + const released = new Map([["story:1", true], ["story:2", false], ["card:3", true]]); + expect(filterReleasedSearchEntries(entries, released, now).map((entry) => entry.key)).toEqual(["story:1", "card:3"]); + expect(filterReleasedSearchEntries([entries[0]!], new Map(), now)).toEqual([]); + }); + + test("malformed, future and missing-date schedule entries are not searchable", () => { + const entries = [ + searchEntry("comic:future", "comic", "2026/11/01 12:00:00"), + searchEntry("comic:malformed", "comic", "not a MasterData date"), + searchEntry("comic:released", "comic", "2026/10/01 12:00:00"), + searchEntry("character:1", "character"), + ]; + expect(filterReleasedSearchEntries(entries, new Map(), now).map((entry) => entry.key)).toEqual(["comic:released", "character:1"]); + }); +}); diff --git a/tests/spoiler-settings.test.tsx b/tests/spoiler-settings.test.tsx index 23de02c2..c8c29139 100644 --- a/tests/spoiler-settings.test.tsx +++ b/tests/spoiler-settings.test.tsx @@ -4,6 +4,7 @@ import { Md3Provider } from "../src/components/md3/Md3Provider"; import { ContentSettings } from "../src/components/shell/SettingsDrawer"; import SpoilerNotice from "../src/components/shared/SpoilerNotice"; import { t } from "../src/i18n"; +import { buildThemeBootstrapScript } from "../src/lib/settings/apply-theme"; import { getShowSpoiler, isSpoilerUnlocked, setShowSpoiler } from "../src/lib/settings/spoiler"; const CORE_LOCALES = ["zh-CN", "zh-TW", "ja-JP", "en-US", "ko-KR"] as const; @@ -47,6 +48,12 @@ describe("public spoiler controls", () => { expect(store.get(SHOW_SPOILER_KEY)).toBe("1"); }); + test("the early theme bootstrap no longer recognizes a spoiler-override URL", () => { + const script = buildThemeBootstrapScript().toLowerCase(); + expect(script).not.toContain("spoiler=allow"); + expect(script).not.toContain("moenotes:show-spoiler"); + }); + test("does not render a spoiler switch in ContentSettings across the five core locales", () => { for (const locale of CORE_LOCALES) { const html = renderToStaticMarkup( diff --git a/tests/story-release.test.ts b/tests/story-release.test.ts new file mode 100644 index 00000000..8483d7bc --- /dev/null +++ b/tests/story-release.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, test } from "bun:test"; +import type { ServerFaceted } from "../src/lib/servers/facets"; +import { isStoryReleasedAcrossServers, isStoryReleasedAt, isStoryReleasedForServer, parseStoryReleaseTime, storyReleaseDates } from "../src/lib/story/release"; + +describe("regional story release gate", () => { + const now = Date.UTC(2026, 9, 27, 12); + const story: ServerFaceted<{ startAt: string }> = { + startAt: "2026/10/01 12:00:00", + servers: ["tw", "jp"], + serverVariants: { jp: { startAt: "2026/11/01 12:00:00" } }, + }; + + test("uses each server's own in-game release time rather than the first server's date", () => { + expect(storyReleaseDates(story)).toEqual({ + tw: "2026/10/01 12:00:00", + jp: "2026/11/01 12:00:00", + }); + expect(isStoryReleasedForServer(story, "tw", now)).toBe(true); + expect(isStoryReleasedForServer(story, "jp", now)).toBe(false); + expect(isStoryReleasedAcrossServers(story, now)).toBe(false); + }); + + test("opens only after every region containing the story has officially released it", () => { + const released = { ...story, serverVariants: { jp: { startAt: "2026/10/20 12:00:00" } } }; + expect(isStoryReleasedAcrossServers(released, now)).toBe(true); + }); + + test("fails closed when no region has the story or a regional date is missing", () => { + expect(isStoryReleasedAcrossServers({ startAt: "", servers: [] }, now)).toBe(false); + expect(isStoryReleasedAcrossServers({ startAt: "2026/10/01 12:00:00", servers: ["tw"], serverVariants: { tw: { startAt: "" } } }, now)).toBe(false); + }); + + test("does not treat missing, malformed, or impossible release dates as already released", () => { + for (const value of [undefined, null, "", "null", "not a date", "2026/02/29 12:00:00", "2026/10/27 25:00:00", "2026/10/27 12:00:00+15:00"]) { + expect(parseStoryReleaseTime(value)).toBeNull(); + expect(isStoryReleasedAt(value, now)).toBe(false); + } + }); + + test("release boundary is inclusive and a non-finite clock fails closed", () => { + const release = "2026/10/27 20:00:00+08:00"; + const releaseTime = parseStoryReleaseTime(release); + expect(releaseTime).not.toBeNull(); + expect(isStoryReleasedAt(release, releaseTime! - 1)).toBe(false); + expect(isStoryReleasedAt(release, releaseTime!)).toBe(true); + expect(isStoryReleasedAt(release, Number.NaN)).toBe(false); + }); +}); diff --git a/tests/story-site-access.test.ts b/tests/story-site-access.test.ts new file mode 100644 index 00000000..ed8b4043 --- /dev/null +++ b/tests/story-site-access.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, test } from "bun:test"; +import { getApprovedStoryResourcePath, getIndexedStoryManifestPath, isStorySiteRegion } from "../src/lib/story/site-access"; + +describe("story-site resource access", () => { + test("accepts only an index manifest path for the same numeric episode", () => { + expect(getIndexedStoryManifestPath("stories/123.json", 123)).toBe("stories/123.json"); + expect(getIndexedStoryManifestPath("stories/jp/123.json", 123)).toBe("stories/jp/123.json"); + for (const [path, advId] of [ + ["stories/124.json", 123], + ["../stories/123.json", 123], + ["stories/%2e%2e/123.json", 123], + ["https://example.invalid/stories/123.json", 123], + ["stories/123.json?x=1", 123], + ["stories/jp/other/123.json", 123], + ["stories/123.json", 0], + ] as const) { + expect(getIndexedStoryManifestPath(path, advId)).toBeNull(); + } + }); + + test("only permits content-addressed assets and model manifests under a released story", () => { + const asset = `assets/${"a".repeat(64)}.json.gz`; + expect(getApprovedStoryResourcePath(asset)).toBe(asset); + expect(getApprovedStoryResourcePath("models/adv_live2d_rana_003_casual.json")).toBe("models/adv_live2d_rana_003_casual.json"); + for (const path of [ + "../stories/123.json", + "/assets/file.png", + "assets/../../private.json", + "assets/not-a-hash.png", + "models/../../outside.json", + "models/Other-Model.json", + "assets/a".repeat(64), + `assets/${"b".repeat(64)}.png?download=1`, + `assets/${"c".repeat(64)}.png#fragment`, + `assets/${"d".repeat(64)}.png\\..\\secret`, + ]) { + expect(getApprovedStoryResourcePath(path)).toBeNull(); + } + }); + + test("only configured story regions are accepted", () => { + expect(isStorySiteRegion("tw")).toBe(true); + expect(isStorySiteRegion("jp")).toBe(true); + expect(isStorySiteRegion("kr")).toBe(false); + expect(isStorySiteRegion("../../jp")).toBe(false); + }); +}); diff --git a/tests/story-sites.test.ts b/tests/story-sites.test.ts index f6e4bc26..0628d4dd 100644 --- a/tests/story-sites.test.ts +++ b/tests/story-sites.test.ts @@ -1,10 +1,44 @@ import { describe, expect, test } from "bun:test"; import { assetConfig } from "../src/config/assets"; import { getPlayerFileKey } from "../src/lib/cache/player-files"; -import { buildStoryPlayerEntries, getStoryManifestUrl, getStoryPlayerArtworkUrl, getStorySiteRoots, mergeStorySiteEntries, type StorySiteEntry } from "../src/lib/story/player-data"; +import { + buildStoryPlayerEntries, + getStoryManifestUrl, + getStoryPlayerArtworkUrl, + getStorySiteProxyRoot, + getStorySiteRoots, + isStoryPlayerEntryReleased, + isStoryPlayerRequestReleased, + mergeStorySiteEntries, + type StoryPickerStory, + type StorySiteEntry, +} from "../src/lib/story/player-data"; -function entry(advId: number, root: string, languages: string[]): StorySiteEntry { - return { id: String(advId), advId, root, manifest: `stories/${advId}.json`, titles: {}, languages, language: languages[0]!, playbackMode: 0, audio: true }; +function entry(advId: number, server: "tw" | "jp", languages: string[]): StorySiteEntry { + return { + id: String(advId), advId, server, root: getStorySiteProxyRoot(server), manifest: `stories/${advId}.json`, + titles: {}, languages, language: languages[0]!, playbackMode: 0, audio: true, + }; +} + +function story(advId: number, servers: StoryPickerStory["servers"], releaseDates: StoryPickerStory["releaseDates"]): StoryPickerStory { + return { + advId, + assetServer: servers[0] ?? "tw", + category: "event", + title: `EP${advId}`, + servers, + releaseDates, + groupId: "chapter:6", + groupTitle: "Event", + groupSubtitle: "", + groupImage: "Story/Banner/Chapter/ui_banner_chapter_6", + episodeLabel: "EPISODE 1", + episodeKind: "main", + episodeNote: "", + image: "Story/Banner/Episode/ui_banner_chapter_6_episode_1", + searchText: "", + }; } describe("story sites", () => { @@ -12,30 +46,65 @@ describe("story sites", () => { expect(getStorySiteRoots()).toEqual([assetConfig.storySite, assetConfig.storySiteJp]); }); - test("an episode on both sites plays from the first; JP-only episodes are added", () => { - const tw = [entry(10000, assetConfig.storySite, ["zh-Hant", "ja"])]; - const jp = [entry(10000, assetConfig.storySiteJp, ["ja"]), entry(10946, assetConfig.storySiteJp, ["ja"])]; + test("an episode on both sites keeps its region on each index entry", () => { + const tw = [entry(10000, "tw", ["zh-Hant", "ja"])]; + const jp = [entry(10000, "jp", ["ja"]), entry(10946, "jp", ["ja"])]; const merged = mergeStorySiteEntries([tw, jp]); - expect(merged.map((item) => [item.advId, item.root])).toEqual([[10000, assetConfig.storySite], [10946, assetConfig.storySiteJp]]); + expect(merged.map((item) => [item.advId, item.root, item.server])).toEqual([ + [10000, getStorySiteProxyRoot("tw"), "tw"], + [10946, getStorySiteProxyRoot("jp"), "jp"], + ]); }); - test("a manifest resolves against its own site", () => { - expect(getStoryManifestUrl(assetConfig.storySiteJp, "stories/10946.json")).toBe(`${assetConfig.storySiteJp}/stories/10946.json`); + test("a manifest resolves against its own same-origin regional proxy", () => { + expect(getStoryManifestUrl(getStorySiteProxyRoot("jp"), "stories/10946.json")) + .toBe("/api/v1/story-site/jp/stories/10946.json"); }); - test("JP event episodes use JP artwork even when the build's base data comes from TW", () => { - const site = [entry(10946, assetConfig.storySiteJp, ["ja"])]; - const [built] = buildStoryPlayerEntries([{ - advId: 10946, assetServer: "tw", category: "event", title: "EP1", groupId: "chapter:6", groupTitle: "Event", groupSubtitle: "", groupImage: "Story/Banner/Chapter/ui_banner_chapter_6", - episodeLabel: "EPISODE 1", episodeKind: "main", episodeNote: "", image: "Story/Banner/Episode/ui_banner_chapter_6_episode_1", searchText: "", - }], site, "ja-JP", "Other"); + test("JP event episodes use JP artwork and JP's release date", () => { + const site = [entry(10946, "jp", ["ja"])]; + const [built] = buildStoryPlayerEntries([story(10946, ["jp"], { jp: "2026/10/01 12:00:00" })], site); expect(built?.section).toBe("event"); - expect(built?.site.root).toBe(assetConfig.storySiteJp); + expect(built?.site.root).toBe(getStorySiteProxyRoot("jp")); + expect(built?.site.server).toBe("jp"); + expect(built?.startAt).toBe("2026/10/01 12:00:00"); expect(built?.assetServer).toBe("jp"); + expect(isStoryPlayerEntryReleased(built!, Date.UTC(2026, 9, 27))).toBe(true); expect(getStoryPlayerArtworkUrl(built!.image, "zh-CN", built!.assetServer)).toBe(`${assetConfig.api}/jp/ja/Story/Banner/Episode/ui_banner_chapter_6_episode_1/ui_banner_chapter_6_episode_1.webp`); expect(getStoryPlayerArtworkUrl(built!.groupImage, "zh-CN", built!.assetServer)).toBe(`${assetConfig.api}/jp/ja/Story/Banner/Chapter/ui_banner_chapter_6/ui_banner_chapter_6.webp`); }); + test("hides a story until every region that contains it has released it", () => { + const site = [entry(10000, "tw", ["zh-Hant", "ja"])]; + const [built] = buildStoryPlayerEntries([story(10000, ["tw", "jp"], { + tw: "2026/10/01 12:00:00", + jp: "2026/11/01 12:00:00", + })], site); + expect(isStoryPlayerEntryReleased(built!, Date.UTC(2026, 9, 27))).toBe(false); + expect(isStoryPlayerEntryReleased(built!, Date.UTC(2026, 11, 1))).toBe(true); + }); + + test("omits cloud-only entries without a locally verified release record", () => { + expect(buildStoryPlayerEntries([], [entry(99999, "jp", ["ja"])] )).toEqual([]); + }); + + test("a direct ?story id is denied unless it matches a verified and released entry", () => { + const site = [entry(10000, "tw", ["zh-Hant", "ja"])]; + const [released] = buildStoryPlayerEntries([story(10000, ["tw", "jp"], { + tw: "2026/10/01 12:00:00", + jp: "2026/10/20 12:00:00", + })], site); + const [notReleased] = buildStoryPlayerEntries([story(10001, ["tw"], { tw: "2026/11/01 12:00:00" })], [entry(10001, "tw", ["ja"])]); + expect(isStoryPlayerRequestReleased(10000, [released!], Date.UTC(2026, 9, 27))).toBe(true); + expect(isStoryPlayerRequestReleased(10001, [notReleased!], Date.UTC(2026, 9, 27))).toBe(false); + expect(isStoryPlayerRequestReleased(99999, [released!], Date.UTC(2026, 9, 27))).toBe(false); + }); + + test("does not match an international cloud entry to a story that only exists in JP MasterData", () => { + const built = buildStoryPlayerEntries([story(99998, ["jp"], { jp: "2026/10/01 12:00:00" })], [entry(99998, "tw", ["ja"]) ]); + expect(built).toEqual([]); + }); + test.each(["zh-CN", "zh-TW", "ja-JP", "en-US", "ko-KR"] as const)("JP episode banners use the Japanese catalog in %s", (locale) => { expect(getStoryPlayerArtworkUrl("Story/Banner/Episode/ui_banner_chapter_6_episode_2", locale, "jp")) .toBe(`${assetConfig.api}/jp/ja/Story/Banner/Episode/ui_banner_chapter_6_episode_2/ui_banner_chapter_6_episode_2.webp`); @@ -48,13 +117,14 @@ describe("story sites", () => { test("episodes without artwork keep an empty URL", () => { expect(getStoryPlayerArtworkUrl("", "zh-CN", "jp")).toBe(""); - const [unknown] = buildStoryPlayerEntries([], [entry(99999, assetConfig.storySiteJp, ["ja"])], "zh-CN", "Other"); - expect(unknown?.image).toBe(""); - expect(getStoryPlayerArtworkUrl(unknown!.image, "zh-CN", unknown!.assetServer)).toBe(""); + expect(buildStoryPlayerEntries([], [entry(99999, "jp", ["ja"])] )).toEqual([]); }); - test("the JP site's assets are kept in the player file cache", () => { + test("the same-origin player proxy keeps only its content-addressed assets in the file cache", () => { const hash = "a".repeat(64); - expect(getPlayerFileKey(`${assetConfig.storySiteJp}/assets/${hash}.m4a`)).toBe(`${assetConfig.storySiteJp}/assets/${hash}.m4a`); + const proxied = `/api/v1/story-site/jp/stories/10946/assets/${hash}.m4a`; + expect(getPlayerFileKey(proxied, "https://bdon.moe/tools/story-player")).toBe(`https://bdon.moe${proxied}`); + expect(getPlayerFileKey(`/api/v1/story-site/jp/stories/10946/assets/${hash}.m4a?x=1`, "https://bdon.moe/tools/story-player")).toBeNull(); + expect(getPlayerFileKey(`/api/v1/story-site/jp/stories/10946/assets/sub/${hash}.m4a`, "https://bdon.moe/tools/story-player")).toBeNull(); }); });