From bd385855c0ff8e838fa838e34806aa8123c51b15 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Wed, 19 Aug 2026 04:11:03 +0200 Subject: [PATCH] feat: add Nikke, the third Fandom template MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Its robots.txt was read in a browser and is the standard Fandom file: no Disallow: / for *, /api.php?action= explicitly allowed, and only the namespaces we never want refused. The AI crawlers it blocks by name are not us. That was the one thing missing — the API itself has always answered our own User-Agent with a 200, and it is only robots.txt that a datacentre address cannot read. The zone lives in the column header, Start(UTC+9) / End(UTC+9), and no date in any cell carries an offset. So the header is the safety property: a table that stops naming its zone is refused rather than read as UTC, which is the Blue Archive hazard arriving one column to the left. Two shapes worth knowing. Every title is an image read from , and the newest row is the one whose logo has not been uploaded yet — it renders as a red link reading "File:Persona on Frontline logo.png", so a reader that only understood the link title would silently drop today's live event. And story events state a clock only on the end, so the start keeps the day the page printed rather than being shifted nine hours into the previous one; the start's day is half an event ID. That is the FGO rule applied to the opposite gap. resetOffsets and resetHourLocal ship with the game because the page evidences both: events end 04:59:59 and the banner replacing them starts 05:00:00, one second later, on a stated UTC+9. Adding that later would re-label day keys readers had already logged ticks under. Seconds now survive the date helpers, since rounding 04:59:59 to the minute would make an event overlap the banner that succeeds it. Co-Authored-By: Claude Opus 5 (1M context) --- AGENTS.md | 77 +++++-- docs/DATA-MODEL.md | 13 +- docs/INGESTION.md | 3 +- docs/PRD.md | 4 +- .../fandom-events-2026-08-19.expected.json | 65 ++++++ fixtures/nikke/fandom-events-2026-08-19.html | 1 + src/ingest/adapters/index.ts | 16 ++ src/ingest/dates.ts | 61 ++++- src/ingest/parsers/fandom.ts | 211 +++++++++++++++++- src/shared/games.ts | 11 + src/shared/schema.ts | 3 + test/adapters/game8.test.ts | 99 ++++++++ 12 files changed, 533 insertions(+), 31 deletions(-) create mode 100644 fixtures/nikke/fandom-events-2026-08-19.expected.json create mode 100644 fixtures/nikke/fandom-events-2026-08-19.html diff --git a/AGENTS.md b/AGENTS.md index 1ebf20e..c6d580e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,8 +37,8 @@ A web app that aggregates live and upcoming events across popular gacha games, p calendar, sorts them by end date or by what the reader is partway through, tracks day-by-day progress on events that repeat daily, and lets a user mark events completed. -**Status: working app, refreshing itself on a schedule.** Schema, eight parsers, eighteen sources across -seventeen games, the full interface, offline support, a static server, a Docker image and CI all exist and +**Status: working app, refreshing itself on a schedule.** Schema, eight parsers, nineteen sources across +eighteen games, the full interface, offline support, a static server, a Docker image and CI all exist and are tested. The refresh runner (`bun run refresh`) fetches, caches raw snapshots and rebuilds the feed; `.github/workflows/refresh.yml` runs it twice a day and commits only when a page actually changed. The SQLite layer and the review queue are still specified in `docs/` but not built, so the @@ -106,7 +106,7 @@ re-verify a sample against the live page afterward. ``` src/shared/ schema.ts (the contract), time.ts, daily.ts, effort.ts, games.ts, feed.ts custom.ts — reader-authored games and events, and their key spaces -src/ingest/ html.ts, dates.ts (fourteen formats), merge.ts, sanitize.ts, robots.ts, snapshots.ts +src/ingest/ html.ts, dates.ts (fifteen formats), merge.ts, sanitize.ts, robots.ts, snapshots.ts parsers/ game8.ts, wikigg.ts, akwiki.ts, fandom.ts, bawiki.ts, holodori.ts, iopwiki.ts, stellasora.ts — keyed by SITE, not game adapters/ index.ts — SOURCES registry binding url+game+parser, and the sanitize seam @@ -119,7 +119,7 @@ src/client/ React app, service worker, manifest theme.ts — dark or light, and what a game hue reads as on each scripts/ build-feed.ts, build-static.ts, parse-fixture.ts (offline), refresh-sources.ts (fetches) serve.ts static server + /api/health -test/ 686 tests +test/ 700 tests fixtures// raw HTML + .expected.json per source — pinned, kept forever snapshots/ current page per source, rewritten by refresh — see its README ``` @@ -301,7 +301,7 @@ re-litigated each pass: | `stellasora.miraheze.org` | **Built** (2026-08-19), from the front page's `Current Banners` module and **not** `/wiki/Banner_List` — see § Stella Sora below | | `game8.co/games/Chaos-Zero-Nightmare` | **Built** (2026-08-19). Zero parser work — the existing `game8` parser reads it. The ninth game8 source, so fixture-backed in CI from day one | | `game8.co/games/Umamusume-Pretty-Derby` | **Built** (2026-08-19), off the stable `List of All Banners` page, not the monthly release-schedule pages whose URL changes every month. Cost a widening of `game8.ts`'s section and column vocabulary — see § Working on parsers | -| `nikke-…-international.fandom.com` | **Blocked, not declined** (2026-08-19). The API answers our User-Agent with a 200 and the data is the best of any unbuilt source — story events *and* dated pickup banners, with an evidenced 05:00 UTC+9 reset. But this wiki's `robots.txt` cannot be read from here **at all**: 403 to our fetcher and an unresolving Cloudflare challenge to a real browser. The permission has to be in writing before the adapter exists, so this waits on someone reading that file from an address Fandom serves | +| `nikke-…-international.fandom.com` | **Built** (2026-08-19), via `api.php` like Reverse: 1999 and FGO. Its `robots.txt` was read in a browser and is the standard Fandom file — see § Fandom below. Richest schedule of anything added in this pass: story events *and* dated pickup banners, with the reset clock evidenced on the page | | `infinitynikki.miraheze.org` | **Declined.** Exists and serves `robots.txt`, but the wiki is abandoned — front page last edited 11 February 2025 and `/wiki/Events` returns a permission error. Checked as a replacement for the stale Infinity Nikki Game8 page | | `prydwen.gg/infinity-nikki` | **Declined.** 404 — prydwen does not cover Infinity Nikki | | `grayravens.com` (Punishing: Gray Raven) | **Declined.** Conduct is fine; the data is not. The whole 626 KB `/wiki/Events` page contains exactly one date range, written as prose, one event per six-week patch | @@ -309,22 +309,27 @@ re-litigated each pass: | `blhx.fandom.com`, `azurlane-archive.fandom.com` | **Declined.** The two Fandom alternatives to the declined koumakan wiki are dead archives — `Event_Calendar` stops in **2021**, and the archive wiki's headings have nothing under them. Azur Lane still has no source | | Aether Gazer | **Do not build.** The developer confirmed no further content updates after 23 July 2026, with store listings removed 17 October 2026. The wiki dates nothing anyway — `Event_Guide_List` is an image gallery. A lane that will be empty by winter | -**The Infinity Nikki lane is knowingly stale, and there is nowhere to move it** (checked -2026-08-19). `game8.co/games/Infinity-Nikki/archives/487445` fetches, parses and passes every test — -and its page says `Last updated on: August 31, 2025`. It mentions the year 2026 zero times. Seven -events parse out of it, of which **five carry `endsAt: null`** and so read as live-with-unknown-end -forever, on a calendar whose whole purpose is telling a reader what is still on. That is worse than -an empty lane, and it is the failure this product exists to prevent — arriving through a source that -looks perfectly healthy to the runner, because a stale page is not a broken one. +**The Infinity Nikki lane is knowingly stale, and the fix is a decision rather than a search** +(checked 2026-08-19). `game8.co/games/Infinity-Nikki/archives/487445` fetches, parses and passes +every test — and its page says `Last updated on: August 31, 2025`. It mentions the year 2026 zero +times. Seven events parse out of it, of which **five carry `endsAt: null`** and so read as +live-with-unknown-end forever, on a calendar whose whole purpose is telling a reader what is still +on. That is worse than an empty lane, and it arrives through a source that looks perfectly healthy +to the runner, because a stale page is not a broken one. -Every replacement was checked and none works: `infinitynikki.wiki.gg` 401s (no such wiki), -`prydwen.gg` does not cover the game, `infinitynikki.miraheze.org` is abandoned (front page last -edited February 2025, `/wiki/Events` a permission error), Game8's own `Banners` page for the game is -staler still (May 2025) and its whole Infinity Nikki wiki is unmaintained, and the Fandom wiki is -behind the blanket 403 above. **This is a decision for the repository owner, not a bug to fix in -code**: either the lane stays and the footer's freshness disclosure carries it, or the game is -retired. Retiring it is not free — `nikki` is the first segment of every completion key that game's -readers have, so dropping the `GameId` orphans them with no server-side recovery (§ Event IDs). +A live replacement exists: `infinity-nikki.fandom.com` (the canonical host; the unhyphenated name +301s to it) is maintained, permitted by the same standard Fandom `robots.txt` as Nikke, and its +`Event` page carries `Current Events` / `Upcoming Events` tables of +`Event | Duration | Description | Type` with full dates and clocks on both sides. **What it does not +carry is a timezone on that column** — only prose elsewhere dating version launches `(UTC-7)` and a +note that rewards reset at `04:00 (Server Time)`. The durations run `04:00 → 03:59`, which only +lands on a reset boundary if the column is server-local, so the case for UTC-7 is strong and +circumstantial. It matters because the offset moves the *day*, and the start's day is half an event +ID. `docs/SOURCES.md` § 11 lays out the four options and recommends taking the printed date at day +precision, which invents nothing and treats these cells exactly as every Game8 date is already +treated — and which conflicts with § Blue Archive as written, so that rule needs narrowing if it is +chosen. **Not to be decided by an agent**: retiring the game instead would drop a `GameId` that +prefixes every completion key its readers hold, with no server-side recovery. `.github/ISSUE_TEMPLATE/feature_request.yml` points readers at that table by heading, so a source request can be checked against it before anyone writes it up — the loudest feedback on the first @@ -361,8 +366,19 @@ Two consequences, and neither is a licence to work around it: - The two built Fandom sources now report `skipped_robots` on **every** run, from any address we have, so `r1999` and `fgo` are permanently fixture-backed until someone refreshes them from an address Fandom serves. `fgo` has never had a snapshot at all. -- A **new** Fandom source cannot be added at all, because the permission cannot be read. That is - what blocks Nikke, whose data is otherwise the best of any unbuilt source. +- A **new** Fandom source can still be added, but only once someone reads that wiki's `robots.txt` + from an address Fandom serves and records it here. That is exactly how Nikke was cleared on + 2026-08-19: the file was read in a browser, is the standard Fandom file — no `Disallow: /` for + `*`, `/api.php?action=` explicitly allowed, only `Special:`, `User:`, `User_talk:`, `Template:`, + `Template_talk:`, `Help:` and `UserProfile:` refused — and the named AI crawlers it blocks + (`GPTBot`, `CCBot`, `OAI-SearchBot`, `ImagesiftBot`) are not us. + +**The 403 is on `robots.txt`, not on the API.** Worth separating, because it decides what is +possible: `api.php?action=parse` answers our own User-Agent with a `200` from here, on all three +Fandom wikis we read. Only the robots file is challenged. So an adapter can be *written and +fixture-backed* from any address; what it cannot do is pass the robots gate at refresh time, which +fails closed and skips. The permission is therefore a thing a human records once, and the freshness +is a thing that needs an address Fandom serves. One consequence to keep in mind: because `/robots.txt` is unreadable from a challenged address, the robots gate **fails closed there and the source is skipped**. That is a warning line rather than a @@ -371,6 +387,23 @@ broken build — `skipped_robots` does not touch the failure streak, and the run falls back to the checked-in fixture. Refreshing it means running `bun run refresh` from an address Fandom serves, which is how its first snapshot was taken. +**Three Fandom templates now, and the third states its zone in a column header.** The Nikke wiki's +`Event` page is `Event | Start(UTC+9) | End(UTC+9) | Archived(?)` for story events and +`Nikke | Start(UTC+9) | End(UTC+9)` for pickup banners. That header is the safety property, not a +convenience: no date in any cell carries an offset, so a table whose Start/End columns stop naming a +zone must be **refused** rather than read as UTC — the Blue Archive hazard, arriving one column to +the left, and `canParse` asserts the lookup. Two more things about it: + +- **Every title is an image, and the newest row is the one without one.** Names come from the + wrapping ``, but an event whose logo has not been uploaded yet renders as + a red link reading `File:Persona on Frontline logo.png` — so a reader that only understood + `` would silently drop *today's live event* and publish a calendar missing what is on + now. The file name is the fallback, and a test pins that exact row. +- **A start with no clock keeps the day the page printed.** Story events state a bare date on the + start and a clock on the end; converting the bare one from UTC+9 would move it to the previous + calendar day, and the start's day is half an event ID. That is the Fate/Grand Order rule below, + applied to the opposite gap — there, a zone with no clock; here, a clock on only one side. + **Two Fandom sources now, and the second one's page is chosen, not obvious.** `fategrandorder.fandom.com` publishes two schedules: `Event_List` opens "This page lists all Events in Fate/Grand Order Japan", and `Event_List_(US)` is the English server. They run months apart, each diff --git a/docs/DATA-MODEL.md b/docs/DATA-MODEL.md index b5c85df..a929065 100644 --- a/docs/DATA-MODEL.md +++ b/docs/DATA-MODEL.md @@ -10,7 +10,7 @@ import { z } from "zod"; export const GameId = z.enum([ "genshin", "hsr", "zzz", "wuwa", "arknights", "endfield", "nte", "nikki", "p5x", "r1999", - "ba", "fgo", "holodori", "gfl2", "stellasora", "czn", "uma", + "ba", "fgo", "holodori", "gfl2", "stellasora", "czn", "uma", "nikke", ]); export const EventType = z.enum([ @@ -308,6 +308,17 @@ nothing to add, which is the point worth carrying to the next game: an override the game moves nobody, while the same override added a year later re-labels every tick already logged under the old clock. Get it right at introduction or accept a migration. +Nikke is the third game to earn one from the source rather than from habit, after Arknights and +Reverse: 1999, and it earns `resetHourLocal` too: every schedule column on its wiki is headed +`Start(UTC+9)` / `End(UTC+9)`, and the rows show where the day turns — a story event ends at +04:59:59 and the pickup banner replacing it starts at 05:00:00, one second later. So UTC+9 for every +region, rolling at 05:00 rather than 04:00. It shipped in the same commit as the game, which costs +nothing while no reader has a day key for it. + +Note also that `nikke` and `nikki` are one letter apart and are different games — Goddess of Victory: +Nikke and Infinity Nikki. Both are the first segment of every completion key their game will ever +have, so a typo in either direction is silent data loss for real readers. + Infinity Nikki, P5X, Blue Archive, Chaos Zero Nightmare and Umamusume carry **no `resetOffsets` entry**, so they take the regional default. That is an assumption, not a verified server map — none of those sources states one. Two of the 2026-08-19 additions are worth separating out, because they diff --git a/docs/INGESTION.md b/docs/INGESTION.md index 92cabaf..1d7f4da 100644 --- a/docs/INGESTION.md +++ b/docs/INGESTION.md @@ -51,7 +51,7 @@ Consequences worth internalising: | `game8` | game8.co article calendars | Genshin, Star Rail, Wuthering Waves, ZZZ, Endfield, NTE, Infinity Nikki, Persona 5: The Phantom X, Chaos Zero Nightmare, Umamusume | | `wikigg` | wiki.gg MediaWiki `mp-event` templates | Endfield | | `akwiki` | arknights.wiki.gg's `mrfz-wtable` "Ongoing/upcoming" table | Arknights | -| `fandom` | Fandom wikis via the MediaWiki `action=parse` API — two page templates: `Event \| Time Period \| Version` wikitables, and FGO's picture-fenced `ONGOING EVENTS` blocks | Reverse: 1999, Fate/Grand Order | +| `fandom` | Fandom wikis via the MediaWiki `action=parse` API — three page templates: `Event \| Time Period \| Version` wikitables, FGO's picture-fenced `ONGOING EVENTS` blocks, and Nikke's `Event \| Start(UTC+9) \| End(UTC+9)` tables | Reverse: 1999, Fate/Grand Order, Nikke | | `bawiki` | bluearchive.wiki's rendered `/wiki/Events` — a JP/Global tabber over `Name (EN) \| Start date \| End date \| Notes` wikitables | Blue Archive | | `holodoriwiki` | holodori.wiki's rendered `/wiki/Events` — `Current Events` and `Past Events` wikitables over `Event \| Type \| Start Date \| End Date` | hololive Dreams | | `iopwiki` | iopwiki.com's `gf-table event-period` tables — `Title \| Period (start/end) \| Server \| Type \| Comment`, one table per event and one row per server | Girls' Frontline 2 | @@ -144,6 +144,7 @@ All live in `src/ingest/dates.ts`, each returning null rather than inferring any | `parseSlashClockZone` | `08/17/2026 8:00PM (JST)` (one boundary per column, 12-hour clock, **named** zone) | hololive Dreams | | `parseIsoClockRangeUtc` | `2026-08-06 13:00 - 2026-08-26 22:59 (UTC)` (whole range in one cell, **zone required**, nothing converted) | Girls' Frontline 2 | | `parseIsoOffsetInstant` | `2026-08-03T21:00-07:00` (a machine-readable `
\n
\t\"Event \t \t
\t\t \t\t\t \t\t \t\t \t\t \t
\n

Goddess of Victory: Nikke contains numerous limited-time events throughout the course of its history.\n

\n\n\n

List of Events[]

\n

Story Events[]

\n

These are the primary limited-time events of the game which usually tells the story of the featured Pickup Recruit Banner Nikkes.\n

Story Events have three formats:\n

\n\n

Event Archiving[]

\n

Starting with the School of Lock update, previous events began to be permanently added to the Archives system, accessible via the Command Center of the Outpost. These \"Archived\" events lack the currency, shop, Challenge Mode, mission systems, and other version-exclusives (Special Recruit banners, event Mission Passes, etc.), but otherwise allow players to freely access and play the event stages and story without ticket limits or arbitrary time-gating. Event mini-games are implemented as well, either in the Archive itself or, as of the Old Tales update, the Arcade.\n

New events are added to the Archives with every update.\n

Unlocking an archived event costs one Memory Film which can be obtained during current Events with event missions and event shops.\n

\n

\n

\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
Event\nStart
(UTC+9)\n
End
(UTC+9)\n
Archived
(?)\n
File:Persona on Frontline logo.png\n12 August 2026\n10 September 2026
04:59:59\n
\n
\"Project\n23 July 2026\n12 August 2026
04:59:59\n
\n
\"Wave\n2 July 2026\n23 July 2026
04:59:59\n
\n
\"Ark\n11 June 2026\n2 July 2026
04:59:59\n
\n
\"Bitter\n28 May 2026\n11 June 2026
04:59:59\n
\n
\"B-Side\n14 May 2026\n4 June 2026
04:59:59\n
\n
\"Star\n23 April 2026\n14 May 2026
04:59:59\n
\n
\"Good\n9 April 2026\n22 April 2026
04:59:59\n
\n
\"Fool\n1 April 2026\n4 April 2026
04:59:59\n
\n
\"2x2\n19 March 2026\n9 April 2026
04:59:59\n
\n
\"Enter\n5 March 2026\n19 March 2026
04:59:59\n
\n
\"Chocolate,\n13 February 2026\n16 February 2026
04:59:59\n
\n
\"Lie\n12 February 2026\n12 March 2026
04:59:59\n
\n
\"Fatal\n29 January 2026\n11 February 2026
04:59:59\n
\n
\"Sin\n15 January 2026\n29 January 2026
04:59:59\n
\n

\n

\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
Event\nStart
(UTC+9)\n
End
(UTC+9)\n
Archived
(?)\n
\"Ark\n30 December 2025\n20 January 2026
04:59:59\n
\n
\"Terminus\n4 December 2025\n30 December 2025
04:59:59\n
\n
\"Blank\n20 November 2025\n4 December 2025
04:59:59\n
\n
\"Goddess\n30 October 2025\n20 November 2025
04:59:59\n
Yes\n
\"Yes,\n16 October 2025\n29 October 2025
04:59:59\n
\n
\"Go!\n16 October 2025\n29 October 2025
04:59:59\n
\n
\"Reborn\n24 September 2025\n21 October 2025
04:59:59\n
\n
\"Coins\n4 September 2025\n23 September 2025
04:59:59\n
\n
\"ABSOLUTE\n7 August 2025\n4 September 2025
04:59:59\n
\n
\"Boom!\n17 July 2025\n7 August 2025
04:59:59\n
Yes\n
\"Over\n3 July 2025\n17 July 2025
04:59:59\n
Yes\n
\"Memories\n12 June 2025\n3 July 2025
04:59:59\n
Yes\n
\"Lord,\n29 May 2025\n11 June 2025
04:59:59\n
Yes\n
\"Arcane\n15 May 2025\n29 May 2025
04:59:59\n
\n
\"Unbreakable\n24 April 2025\n15 May 2025
04:59:59\n
Yes\n
\"True\n10 April 2025\n23 April 2025
04:59:59\n
Yes\n
\"Fool\n1 April 2025\n4 April 2025
04:59:59\n
Yes\n
\"New\n27 March 2025\n10 April 2025
04:59:59\n
Yes\n
\"For\n13 March 2025\n27 March 2025
04:59:59\n
Yes\n
\"Second\n20 February 2025\n13 March 2025
04:59:59\n
\n
\"Romantic\n30 January 2025\n19 February 2025
04:59:59\n
Yes\n
\"Wisdom\n16 January 2025\n30 January 2025
04:59:59\n
Yes\n
\"Out\n1 January 2025\n16 January 2025
04:59:59\n
Yes\n

\n

\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
Event\nStart
(UTC+9)\n
End
(UTC+9)\n
Archived
(?)\n
\"Footstep,\n26 December 2024\n16 January 2025
04:59:59\n
Yes\n
\"Ice\n5 December 2024\n26 December 2024
04:59:59\n
Yes\n
\"Secret\n21 November 2024\n5 December 2024
04:59:59\n
Yes\n
\"Old\n31 October 2024\n21 November 2024
04:59:59\n
Yes\n
\"Life\n17 October 2024\n30 October 2024
04:59:59\n
Yes\n
\"Phantom\n3 October 2024\n17 October 2024
04:59:59\n
Yes\n
\"Nonsense\n19 September 2024\n3 October 2024
04:59:59\n
Yes\n
\"Jinx\n19 September 2024\n3 October 2024
04:59:59\n
Yes\n
\"You\n21 August 2024\n19 September 2024
04:59:59\n
\n
\"Colorless\n8 August 2024\n21 August 2024
04:59:59\n
Yes\n
\"Juvenile\n25 July 2024\n8 August 2024
04:59:59\n
Yes\n
\"Beauty\n4 July 2024\n25 July 2024
04:59:59\n
Yes\n
\"Clay,\n20 June 2024\n3 July 2024
04:59:59\n
Yes\n
\"Golden\n30 May 2024\n20 June 2024
04:59:59\n
Yes\n
\"D.ark\n16 May 2024\n30 May 2024
04:59:59\n
Yes\n
\"Last\n25 April 2024\n16 May 2024
04:59:59\n
Yes\n
\"One\n11 April 2024\n23 April 2024
04:59:59\n
Yes\n
\"Liar's\n1 April 2024\n4 April 2024
04:59:59\n
Yes\n
\"Re-CIPE\n21 March 2024\n11 April 2024
04:59:59\n
\n
\"Kill\n7 March 2024\n20 March 2024
04:59:59\n
Yes\n
\"Booms\n22 February 2024\n6 March 2024
04:59:59\n
Yes\n
\"Perfect\n5 February 2024\n22 February 2024
04:59:59\n
Yes\n
\"Dirty\n25 January 2024\n8 February 2024
04:59:59\n
Yes\n
\"Lion\n11 January 2024\n25 January 2024
04:59:59\n
Yes\n

\n

\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
Event\nStart
(UTC+9)\n
End
(UTC+9)\n
Archived
(?)\n
\"New\n28 December 2023\n11 January 2024
04:59:59\n
Yes\n
\"NEVERLAND\n7 December 2023\n28 December 2023
04:59:59\n
Yes\n
\"Alone\n23 November 2023\n7 December 2023
04:59:59\n
Yes\n
\"Happy\n2 November 2023\n23 November 2023
04:59:59\n
\n
\"Red\n2 November 2023\n23 November 2023
04:59:59\n
Yes\n
\"A.C.P.U.!\n19 October 2023\n1 November 2023
04:59:59\n
Yes\n
\"Dazzling\n5 October 2023\n19 October 2023
04:59:59\n
Yes\n
\"School\n21 September 2023\n5 October 2023
04:59:59\n
Yes\n
\"OuteR\n1 September 2023\n28 September 2023
04:59:59\n
\n
\"First\n24 August 2023\n31 August 2023
04:59:59\n
Yes\n
\"Sea,\n3 August 2023\n24 August 2023
04:59:59\n
Yes\n
\"Golden\n20 July 2023\n3 August 2023
04:59:59\n
Yes\n
\"Nya\n6 July 2023\n20 July 2023
04:59:59\n
Yes\n
\"Blue\n15 June 2023\n6 July 2023
04:59:59\n
Yes\n
\"Queen's\n1 June 2023\n15 June 2023
04:59:59\n
Yes\n
\"Bunny\n18 May 2023\n1 June 2023
04:59:59\n
Yes\n
\"Over\n27 April 2023\n18 May 2023
04:59:59\n
Yes\n
\"License\n13 April 2023\n27 April 2023
04:59:59\n
Yes\n
\"Full\n1 April 2023\n1 April 2023
23:59:59\n
Yes\n
\"Cherry\n30 March 2023\n13 April 2023
04:59:59\n
Yes\n
\"Bow-Wow\n15 March 2023\n29 March 2023
04:59:59\n
Yes\n
\"BULLET\n22 February 2023\n15 March 2023
04:59:59\n
\n
\"Maid\n1 February 2023\n15 February 2023
04:59:59\n
Yes\n
\"D-Outsiders\n12 January 2023\n25 January 2023
04:59:59\n
Yes\n

\n

\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
Event\nStart
(UTC+9)\n
End
(UTC+9)\n
Archived
(?)\n
\"Brand\n28 December 2022\n11 January 2023
23:59:59\n
Yes\n
\"Miracle\n8 December 2022\n28 December 2022
04:59:59\n
Yes\n
\"Hightech\n24 November 2022\n8 December 2022
04:59:59\n
Yes\n
\"No\nNovember 10, 2022\nNovember 24, 2022
04:59:59\n
Yes\n

\n

Mini-Game[]

\n

Some Story Events, especially the category 2 events feature a Mini-Game that can be played. These Mini-Games often reward the player with items and currencies.\n

\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
Mini-Game\nEvent\nGenre\n
\"Hero\nHightech Toy\nBoard game\n
\"Gift\nMiracle Snow\nClicker game\n
\"White\nOver Zone\nVisual novel\n
\"Volt\nBlue Water Island\nBoard game\n
\"BBQ\nSea, You, Again\nStrategy/time management\n
\"Hacking\nOuteR: Automata\nShoot 'em up/puzzle\n
\"Memory\nRed Ash\nRogue-like shoot 'em up\n
\"Snowfall\nNEVERLAND\nVisual novel\n
\"Dessert\nPerfect Maid\nClicker game\n
\"Re-ADY\nRe:CIPE FOR YOU\nStrategy/time management\n
\"For\nLast Kingdom\nTower defense\n
\"Let's\nGolden Coin Rush\nVarious\n
\"Coral\nBeauty Full Shot\nRhythm and photography\n
File:Aegis the Diver logo.png\nBeauty Full Shot\nAdventure\n
\"Battle\nYou Can (Not) Evade\nStrategy\n
\"In\nOld Tales\nMetroidvania/platformer\n
\"Dragon\nIce Dragon Saga\nEndless hopper\n
\"Pirate\nRomantic Valentine\nStrategy/time management\n
\"Redsea\nSecond Quest\nBullet hell\n
\"Bubble\nUnbreakable Sphere\nBattle simulator\n
\"Naytiba\nMemories Teller\nBattle simulator\n
\"BFG\nBoom! The Ghost!\nGrouping puzzle game\n
\"Made\nCoins In Rush\nParkour\n
\"Salvation\nReborn Evil\nStrategy/resource management\n
\"Rebuild\nGoddess Fall\nResource management\n
\"AZX\nTerminus Ticket\nGrouping game\n
\"Lyco\nLie cause Recoil\nRhythm game\n
\"Tracing\nStar Anis\nRhythm game\n
File:FIGHT! ARK RANGER! logo.png\nArk Ranger\nStrategy/resource management\n
File:Island Breaker logo.png\nWave to You\nPuzzle/strategy\n
File:Midnight Phantom Runner logo.png\nPersona on Frontline\nMaze\n
\n

Recurring Events[]

\n

Recurring events that appear from time to time.\n

\n\n\n\n\n\n\n\n\n\n
Event\nDuration / Schedule\n
\"Event\n3 days\n
\"Event\nWeekends\n
\n

Mission Passes[]

\n

The Battle Pass system of the game. Mission Pass cycles on 1st of the month (UTC+9).\n

\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
Season\nCostume Reward\n
\"Mission\n
\"Maxwell
\n
\"Mission\n
\"Naga
\n
\"Mission\n
\"Trina
\n
\"Mission\n
\"Rapi
\n
\"Mission\n
\"Sora
\n
\"Mission\n
\"Zwei
\n
\"Mission\n
\"Velvet
\n
\"Mission\n
\"Emma
\n
\"Mission\n
\"Jackal
\n
\"Mission\n
\"Little
\n
\"Mission\n
\"K
\n
\"Mission\n
\"Exia
\n
\"Mission\n
\"Julia
\n
\"Mission\n
\"Centi
\n
\"Mission\n
\"Moran
\n
\"Mission\n
\"Cinderella
\n
\"Mission\n
\"Tia
\n
\"Mission\n
\"Biscuit
\n
\"Mission\n
\"Trony
\n
\"Mission\n
\"Zwei
\n
\"Mission\n
\"Volume
\n
\"Mission\n
\"D
\n
\"Mission\n
\"Miranda
\n
\"Mission\n
\"Leona
\n
\"Mission\n
\"Naga
\n
\"Mission\n
\"Maiden
\n
\"Mission\n
\"Folkwang
\n
\"Mission\n
\"Diesel
\n
\"Mission\n
\"Rosanna
\n
\"Mission\n
\"Exia
\n
\"Mission\n
\"Sugar
\n
\"Mission\n
\"Guillotine
\n
\"Mission\n
\"Noir
\n
\"Mission\n
\"Privaty
\n
\"Mission\n
\"Novel
\n
\"Mission\n
\"Brid
\n
\"Mission\n
\"Yan
\n
\"Mission\n
\"Frima
\n
\"Mission\n
\"Emma
\n
\"Mission\n
\"Viper
\n
\"Mission\n
\"Noise
\n
\"Mission\n
\"Dolla
\n
\"Mission\n
\"Signal
\n
\"Mission\n
\"Sugar
\n
\"Mission\n
\"Maxwell
\n
\"Mission\n
\"Diesel
\n
\n

Pick Up Recruitments[]

\n

Limited-time Pickup Recruitments that correlates with the Story Events.\n

\n

\n

\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
Nikke\nStart
(UTC+9)\n
End
(UTC+9)\n
\"Pick
\n
12 August 2026
x\n
10 September 2026
23:59:59[1]\n
\"Pick
\n
30 July 2026
05:00:00\n
20 August 2026
04:59:59[2]\n
\"Pick
\n
23 July 2026\n12 August 2026
23:59:59[3]\n
\"Pick
\n
9 July 2026
05:00:00\n
30 July 2026
04:59:59[4]\n
\"Pick
\n
2 July 2026\n23 July 2026
04:59:59[5]\n
\"Pick
\n
11 June 2026\n2 July 2026
04:59:59[6]\n
\"Pick
\n
28 May 2026\n11 June 2026
04:59:59[7]\n
\"Pick
\n
14 May 2026\n4 June 2026
04:59:59[8]\n
\"Pick
\n
30 April 2026
05:00:00\n
21 May 2026
04:59:59[9]\n
\"Pick
\n
23 April 2026\n14 May 2026
23:59:59[10]\n
\"Pick
\n
9 April 2026\n22 April 2026
23:59:59[11]\n
\"Pick
\n
19 March 2026\n9 April 2026
04:59:59[12]\n
\"Pick
\n
5 March 2026\n19 March 2026
04:59:59[13]\n
\"Pick
\n
19 February 2026
05:00:00\n
12 March 2026
04:59:59[14][15]\n
\"Pick
\n
12 February 2026\n
\"Pick
\n
29 January 2026\n11 February 2026
23:59:59[16]\n
\"Pick
\n
15 January 2026\n29 January 2026
04:59:59[17]\n
\"New
\n
\"Pick
\n
1 January 2026
00:00:00\n
22 January 2026
04:59:59[18][19]\n

\n

\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
Nikke\nStart
(UTC+9)\n
End
(UTC+9)\n
\"Pick
\n
11 December 2025
05:00:00\n
31 December 2025
23:59:59[20]\n
\"Pick
\n
4 December 2025\n25 December 2025
04:59:59[21]\n
\"Pick
\n
20 November 2025\n4 December 2025
04:59:59[22]\n
\"Pick
\n
6 November 2025
05:00:00\n
27 November 2025
04:59:59[23]\n
\"Pick
\n
30 October 2025\n20 November 2025
04:59:59[24]\n
\"Pick
\n
16 October 2025\n29 October 2025
23:59:59[25]\n
\"Pick
\n
2 October 2025\n21 October 2025
23:59:59[26][27]\n
\"Pick
\n
23 September 2025\n
\"Pick
\n
11 September 2025\n2 October 2025
04:59:59[28]\n
\"Pick
\n
4 September 2025\n23 September 2025
23:59:59[29]\n
\"Pick
\n
21 August 2025\n11 September 2025
04:59:59[30]\n
\"Pick
\n
14 August 2025\n4 September 2025
04:59:59[31][32]\n
\"Pick
\n
7 August 2025\n
\"Pick
\n
24 July 2025
05:00:00\n
14 August 2025
04:59:59[33]\n
\"Pick
\n
17 July 2025\n7 August 2025
04:59:59[34]\n
\"Pick
\n
3 July 2025\n17 July 2025
04:59:59[35]\n
\"Pick
\n
19 June 2025
05:00:00\n
10 July 2025
04:59:59[36]\n
\"Pick
\n
12 June 2025\n3 July 2025
04:59:59[37]\n
\"Pick
\n
29 May 2025\n11 June 2025
23:59:59[38]\n
\"Pick
\n
15 May 2025\n29 May 2025
04:59:59[39]\n
\"Pick
\n
1 May 2025
05:00:00\n
22 May 2025
04:59:59[40]\n
\"Pick
\n
24 April 2025\n15 May 2025
04:59:59[41]\n
\"Pick
\n
10 April 2025\n23 April 2025
23:59:59[42]\n
\"Pick
\n
27 March 2025\n10 April 2025
04:59:59[43]\n
\"Pick
\n
13 March 2025\n27 March 2025
04:59:59[44]\n
\"Mari
\n
\"Rei
\n
\"Pick
\n
27 February 2025
05:00:00\n
13 March 2025
04:59:59[45][46][47][48]\n
\"Asuka
\n
\"Pick
\n
20 February 2025\n
\"Pick
\n
6 February 2025
05:00:00\n
27 February 2025
04:59:59[49]\n
\"Pick
\n
30 January 2025
12:00:00\n
19 February 2025
23:59:59[50]\n
\"Pick
\n
16 January 2025\n30 January 2025
04:59:59[51]\n
\"New
\n
\"Pick
\n
1 January 2025
00:00:00\n
23 January 2025
04:59:59[52][53]\n

\n

\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
Nikke\nStart
(UTC+9)\n
End
(UTC+9)\n
\"Pick
\n
12 December 2024
05:00:00\n
31 December 2024
23:59:59[54]\n
\"Pick
\n
5 December 2024\n26 December 2024
04:59:59[55]\n
\"Pick
\n
21 November 2024\n5 December 2024
04:59:59[56]\n
\"Pick
\n
7 November 2024
05:00:00\n
28 November 2024
04:59:59[57]\n
\"Pick
\n
31 October 2024\n21 November 2024
04:59:59[58]\n
\"Pick
\n
17 October 2024\n30 October 2024
23:59:59[59]\n
\"Pick
\n
10 October 2024\n24 October 2024
04:59:59[60]\n
\"Pick
\n
3 October 2024\n17 October 2024
04:59:59[61]\n
\"Pick
\n
19 September 2024\n3 October 2024
04:59:59[62]\n
\"Pick
\n
5 September 2024
05:00:00\n
19 September 2024
04:59:59[63][64][65]\n
\"Pick
\n
29 August 2024
05:00:00\n
\"Pick
\n
22 August 2024\n
\"Pick
\n
8 August 2024\n21 August 2024
23:59:59[66]\n
\"Pick
\n
25 July 2024\n8 August 2024
04:59:59[67]\n
\"Pick
\n
11 July 2024
05:00:00\n
1 August 2024
04:59:59[68]\n
\"Pick
\n
4 July 2024\n25 July 2024
04:59:59[69]\n
\"Pick
\n
20 June 2024\n3 July 2024
23:59:59[70]\n
\"Pick
\n
6 June 2024
05:00:00\n
27 June 2024
04:59:59[71]\n
\"Pick
\n
30 May 2024\n20 June 2024
04:59:59[72]\n
\"Pick
\n
16 May 2024\n30 May 2024
04:59:59[73]\n
\"Pick
\n
25 April 2024\n16 May 2024
04:59:59[74]\n
\"Pick
\n
11 April 2024\n24 April 2024
23:59:59[75]\n
\"Pick
\n
28 March 2024
05:00:00\n
11 April 2024
04:59:59[76][77]\n
\"Pick
\n
21 March 2024\n
\"Pick
\n
7 March 2024\n20 March 2024
23:59:59[78]\n
\"Pick
\n
22 February 2024\n6 March 2024
23:59:59[79]\n
\"Pick
\n
15 February 2024
05:00:00\n
29 February 2024
04:59:59[80]\n
\"Pick
\n
5 February 2024\n22 February 2024
04:59:59[81]\n
\"Pick
\n
25 January 2024
12:00:00\n
8 February 2024
04:59:59[82]\n
\"Pick
\n
11 January 2024\n25 January 2024
04:59:59[83]\n
\"New
\n
\"Pick
\n
1 January 2024
00:00:00\n
18 January 2024
04:59:59[84][85]\n

\n

\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
Nikke\nStart
(UTC+9)\n
End
(UTC+9)\n
\"Pick
\n
14 December 2023
05:00:00\n
31 December 2023
23:59:59[86]\n
\"Pick
\n
7 December 2023\n28 December 2023
04:59:59[87]\n
\"Pick
\n
23 November 2023\n7 December 2023
04:59:59[88]\n
\"Pick
\n
2 November 2023\n23 November 2023
23:59:59[89]\n
\"Pick
\n
19 October 2023\n1 November 2023
23:59:59[90]\n
\"Pick
\n
12 October 2023
05:00:00\n
26 October 2023
04:59:59[91]\n
\"Pick
\n
5 October 2023
12:00:00\n
19 October 2023
04:59:59[92]\n
\"Pick
\n
21 September 2023\n5 October 2023
04:59:59[93]\n
\"Pick
\n
8 September 2023
05:00:00\n
28 September 2023
04:59:59[94][95]\n
\"Pick
\n
1 September 2023\n
\"Pick
\n
10 August 2023
05:00:00\n
31 August 2023
04:59:59[96]\n
\"Pick
\n
3 August 2023\n24 August 2023
04:59:59[97]\n
\"Pick
\n
20 July 2023\n3 August 2023
04:59:59[98]\n
\"Pick
\n
6 July 2023\n20 July 2023
04:59:59[99]\n
\"Pick
\n
22 June 2023
05:00:00\n
13 July 2023
04:59:59[100]\n
\"Pick
\n
15 June 2023\n6 July 2023
04:59:59[101]\n
\"Pick
\n
1 June 2023\n15 June 2023
04:59:59[102]\n
\"Pick
\n
25 May 2023
05:00:00\n
8 June 2023
04:59:59[103]\n
\"Pick
\n
18 May 2023\n1 June 2023
04:59:59[104]\n
\"Pick
\n
27 April 2023\n18 May 2023
04:59:59[105]\n
\"Pick
\n
\"Pick
\n
13 April 2023\n27 April 2023
04:59:59[106][107]\n
\"Pick
\n
30 March 2023\n13 April 2023
04:59:59[108]\n
\"Pick
\n
15 March 2023\n30 March 2023
04:59:59[109]\n
\"Pick
\n
\"Pick
\n
22 February 2023\n15 March 2023
04:59:59[110][111]\n
\"Pick
\n
8 February 2023
05:00:00\n
21 February 2023
23:59:59[112]\n
\"Pick
\n
1 February 2023\n15 February 2023
04:59:59[113]\n
\"Pick
\n
19 January 2023
00:00:00\n
1 February 2023
23:59:59[114]\n
\"Pick
\n
12 January 2023\n25 January 2023
23:59:59[115]\n
\"Pick
\n
1 January 2023
00:00:00\n
19 January 2023
04:59:59[116]\n

\n

\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
Nikke\nStart
(UTC+9)\n
End
(UTC+9)\n
\"Pick
\n
15 December 2022
05:00:00\n
28 December 2022
23:59:59[117][118]\n
\"Pick
\n
8 December 2022\n
\"Pick
\n
24 November 2022\n8 December 2022
04:59:59[119]\n
\"Pick
\n
10 November 2022\n24 November 2022
04:59:59[120]\n

\n

Costume Gacha[]

\n\n\n\n\n\n
Costume Gacha\nStart
(UTC+9)\n
End
(UTC+9)\n
\n

Web Event[]

\n\n\n\n\n\n\n\n\n\n\n\n\n
Event\nStart
(UTC+9)\n
End
(UTC+9)\n
\"New\nJanuary 4, 2024\nJanuary 18, 2024
04:59:59\n
\"Perfect\nFebruary 5, 2024\nFebruary 22, 2024\n
\n

Popularity Poll[]

\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
Event\nStart
(UTC+9)\n
End
(UTC+9)\n
\"Doki\nApril 12, 2023
12:00:00\n
April 18, 2023
23:59:59\n
\"Doki\nApril 8, 2024
12:00:00\n
April 15, 2024
23:59:59\n
\"Doki\nApril 8, 2025
12:00:00\n
April 14, 2025
23:59:59\n
\"Doki\nApril 8, 2026
12:00:00\n
April 14, 2026
23:59:59\n
\n

Other Events[]

\n

Miscellaneous events that do not fit on the aforementioned categories.\n

\n\n\n\n\n\n\n\n\n
Event\nStart
(UTC+9)\n
End
(UTC+9)\n
\"Event\nNovember 4, 2022
05:15:00\n
November 30, 2023
04:59:59\n
\n

References[]

\n
\n
Notes
\n


\n

\n
References
\n
    \n
  1. Queen (Makoto) https://x.com/NIKKE_en/status/2085538932356419959?s=20\n
  2. \n
  3. Maxwell: Ordinary Mechanic https://x.com/NIKKE_en/status/2082632268112449738\n
  4. \n
  5. Laplace: Ultimate Hero https://x.com/NIKKE_en/status/2080219452797571472\n
  6. \n
  7. Marciana: Marine Study https://x.com/NIKKE_en/status/2075022610950504656\n
  8. \n
  9. Cinderella: Crystal Wave https://x.com/NIKKE_en/status/2072611745710116961\n
  10. \n
  11. Ark Ranger Black https://x.com/NIKKE_en/status/2065047213408227463\n
  12. \n
  13. Prika https://x.com/NIKKE_en/status/2059884384682115415\n
  14. \n
  15. Mint https://x.com/NIKKE_en/status/2054853784527131063\n
  16. \n
  17. Neon: Vision Eye https://x.com/NIKKE_en/status/2049654973588451329\n
  18. \n
  19. Anis: Star https://x.com/NIKKE_en/status/2047076195356090382\n
  20. \n
  21. Snow Crane https://x.com/NIKKE_en/status/2042124710264316413\n
  22. \n
  23. Arcana: Fortune Mate https://x.com/NIKKE_en/status/2029440522109001971\n
  24. \n
  25. E.H. https://x.com/NIKKE_en/status/2029440522109001971\n
  26. \n
  27. Chisato Nishikigi https://x.com/NIKKE_en/status/2021709580749722094\n
  28. \n
  29. Takina Inoue https://x.com/NIKKE_en/status/2024287819745694132\n
  30. \n
  31. Velvet https://x.com/NIKKE_en/status/2016802749283786911\n
  32. \n
  33. Label https://x.com/NIKKE_en/status/2011683516413833443\n
  34. \n
  35. Snow White: Heavy Arms https://x.com/NIKKE_en/status/2006523265813229732\n
  36. \n
  37. Rapi: Red Hood https://x.com/NIKKE_en/status/2006168440965627977\n
  38. \n
  39. Brid: Silent Track https://x.com/NIKKE_en/status/1998920674266722598\n
  40. \n
  41. Diesel: Winter Sweets https://x.com/NIKKE_en/status/1996512289445306779\n
  42. \n
  43. Soline: Frost Ticket https://x.com/NIKKE_en/status/1991390445973434707\n
  44. \n
  45. Liberalio https://x.com/NIKKE_en/status/1982034772424790028\n
  46. \n
  47. Nayuta https://x.com/NIKKE_en/status/1982029224748962056\n
  48. \n
  49. Delta: Ninja Thief https://x.com/NIKKE_en/status/1978707590604636408\n
  50. \n
  51. Ada Wong https://x.com/NIKKE_en/status/1970612919839924480\n
  52. \n
  53. Jill Valentine https://x.com/NIKKE_en/status/1973553591119126631\n
  54. \n
  55. Milk: Blooming Bunny https://x.com/NIKKE_en/status/1965943377381494921\n
  56. \n
  57. Ade: Agent Bunny https://x.com/NIKKE_en/status/1963531687809487057\n
  58. \n
  59. Vesti: Tactical Upgrade https://x.com/NIKKE_en/status/1958348334583705712\n
  60. \n
  61. Emma: Tactical Upgrade https://x.com/NIKKE_en/status/1953387010015838363\n
  62. \n
  63. Eunhwa: Tactical Upgrade https://x.com/NIKKE_en/status/1955796515215302911\n
  64. \n
  65. Elegg: Boom and Shock https://x.com/NIKKE_en/status/1948186374030655883\n
  66. \n
  67. Dorothy: Serendipity https://x.com/NIKKE_en/status/1945776287173525704\n
  68. \n
  69. Sora https://x.com/NIKKE_en/status/1940670870269116559\n
  70. \n
  71. Raven https://x.com/NIKKE_en/status/1935502928569102403\n
  72. \n
  73. EVE https://x.com/NIKKE_en/status/1932924795601158297\n
  74. \n
  75. K https://x.com/NIKKE_en/status/1927972016596078783\n
  76. \n
  77. Arcana https://x.com/NIKKE_en/status/1922944042909958438\n
  78. \n
  79. Mihara: Bonding Chain https://x.com/NIKKE_en/status/1917730693322531316\n
  80. \n
  81. Little Mermaid https://x.com/NIKKE_en/status/1915171425440456710\n
  82. \n
  83. Crust https://x.com/NIKKE_en/status/1910264010433552503\n
  84. \n
  85. Bready https://x.com/NIKKE_en/status/1905328722989007336\n
  86. \n
  87. Trina https://x.com/NIKKE_en/status/1900080816673419309\n
  88. \n
  89. Asuka: WILLE https://x.com/NIKKE_en/status/1892370851464696292\n
  90. \n
  91. Asuka Shikinami Langley https://x.com/NIKKE_en/status/1892371128833778091\n
  92. \n
  93. Rei Ayanami (Tentative Name) https://x.com/NIKKE_en/status/1894915354234671185\n
  94. \n
  95. Rei Ayanami, Mari Makinami Illustrious https://x.com/NIKKE_en/status/1894930517499183110\n
  96. \n
  97. Anchor: Innocent Maid https://x.com/NIKKE_en/status/1887305214367310140\n
  98. \n
  99. Mast: Romantic Maid https://x.com/NIKKE_en/status/1884802465931145361\n
  100. \n
  101. Mana https://x.com/NIKKE_en/status/1879819943187030381\n
  102. \n
  103. Rapi: Red Hood https://x.com/NIKKE_en/status/1874259309569159416\n
  104. \n
  105. Scarlet: Black Shadow https://x.com/NIKKE_en/status/1871151851237740569\n
  106. \n
  107. Guillotine: Winter Slayer https://x.com/NIKKE_en/status/1867019067963355555\n
  108. \n
  109. Maiden: Ice Rose https://x.com/NIKKE_en/status/1864603148104413519\n
  110. \n
  111. Flora https://x.com/NIKKE_en/status/1857966922274402621\n
  112. \n
  113. Grave https://x.com/NIKKE_en/status/1854335490595443151\n
  114. \n
  115. Cinderella https://x.com/NIKKE_en/status/1851749708747694162\n
  116. \n
  117. Rumani https://x.com/NIKKE_en/status/1846797272282972545\n
  118. \n
  119. Phantom https://x.com/NIKKE_en/status/1844182330895737188\n
  120. \n
  121. Quency: Escape Queen https://x.com/NIKKE_en/status/1841675814531723370\n
  122. \n
  123. Rouge https://x.com/NIKKE_en/status/1836695501564952592\n
  124. \n
  125. Asuka Shikinami Langley https://x.com/NIKKE_en/status/1826386341979992569\n
  126. \n
  127. Rei Ayanami https://x.com/NIKKE_en/status/1828960801731829915\n
  128. \n
  129. Mari Makinami Illustrious https://x.com/NIKKE_en/status/1831497513343353259\n
  130. \n
  131. Zwei https://x.com/NIKKE_en/status/1821429910436491728\n
  132. \n
  133. Ein https://x.com/NIKKE_en/status/1816401855939051811\n
  134. \n
  135. Rosanna: Chic Ocean https://x.com/NIKKE_en/status/1811203807466172518\n
  136. \n
  137. Sakura: Bloom in Summer https://x.com/NIKKE_en/status/1808629387237150965\n
  138. \n
  139. Clay https://x.com/NIKKE_en/status/1803672904854294905\n
  140. \n
  141. Alice: Wonderland Bunny https://x.com/NIKKE_en/status/1798520220195459423\n
  142. \n
  143. Soda: Twinkling Bunny https://x.com/NIKKE_en/status/1796123134850158888\n
  144. \n
  145. Trony https://x.com/NIKKE_en/status/1790989306322165942\n
  146. \n
  147. Crown https://x.com/NIKKE_en/status/1783258397233533258\n
  148. \n
  149. Bay https://x.com/NIKKE_en/status/1778352924432663035\n
  150. \n
  151. Emilia https://x.com/nikke_en/status/1770578604352565276\n
  152. \n
  153. Rem https://x.com/NIKKE_en/status/1773153069775114742\n
  154. \n
  155. D: Killer Wife https://x.com/NIKKE_en/status/1765667477130764742\n
  156. \n
  157. Elegg https://x.com/NIKKE_en/status/1760552524279341383\n
  158. \n
  159. Ade https://x.com/nikke_en/status/1757932776156700829\n
  160. \n
  161. Privaty: Unkind Maid https://x.com/NIKKE_en/status/1754434075350138996\n
  162. \n
  163. Moran https://x.com/NIKKE_en/status/1750337729500532970\n
  164. \n
  165. Leona https://x.com/NIKKE_en/status/1745377533300719948\n
  166. \n
  167. Scarlet: Black Shadow https://x.com/NIKKE_en/status/1741625318991860000\n
  168. \n
  169. Modernia https://x.com/NIKKE_en/status/1739103709148184641\n
  170. \n
  171. Mica: Snow Buddy https://x.com/NIKKE_en/status/1735117438868394151\n
  172. \n
  173. Ludmilla: Winter Owner https://x.com/NIKKE_en/status/1732693961494433973\n
  174. \n
  175. Tove https://x.com/NIKKE_en/status/1727575228295835756\n
  176. \n
  177. Red Hood https://x.com/NIKKE_en/status/1719860993042296995\n
  178. \n
  179. Quiry https://x.com/NIKKE_en/status/1714891907116028118\n
  180. \n
  181. Tia https://x.com/NIKKE_en/status/1712271909990101094\n
  182. \n
  183. Naga https://x.com/NIKKE_en/status/1709765386227343825\n
  184. \n
  185. Marciana https://x.com/NIKKE_en/status/1704787574076723324\n
  186. \n
  187. 2B https://x.com/NIKKE_en/status/1697372840398299156\n
  188. \n
  189. A2 https://x.com/NIKKE_en/status/1699950718206038300\n
  190. \n
  191. Helm: Aquamarine https://x.com/NIKKE_en/status/1689456570948534273\n
  192. \n
  193. Anis: Sparkling Summer https://x.com/NIKKE_en/status/1687029400963829760\n
  194. \n
  195. Mast https://x.com/NIKKE_en/status/1681911839066578944\n
  196. \n
  197. Nero https://x.com/NIKKE_en/status/1676943690965700609\n
  198. \n
  199. Neon: Blue Ocean https://x.com/NIKKE_en/status/1671699565811224576\n
  200. \n
  201. Mary: Bay Goddess https://x.com/NIKKE_en/status/1669272565343461380\n
  202. \n
  203. Rosanna https://x.com/NIKKE_en/status/1664153576304721923\n
  204. \n
  205. Noir https://x.com/NIKKE_en/status/1661537611410731008\n
  206. \n
  207. Blanc https://x.com/NIKKE_en/status/1659157953235103745\n
  208. \n
  209. Dorothy https://x.com/NIKKE_en/status/1651352983806914560\n
  210. \n
  211. Alice https://x.com/NIKKE_en/status/1646381973747671042\n
  212. \n
  213. D https://x.com/NIKKE_en/status/1646380715469074433\n
  214. \n
  215. Sakura https://x.com/NIKKE_en/status/1641398639963537412\n
  216. \n
  217. Biscuit https://x.com/NIKKE_en/status/1635886944809885696\n
  218. \n
  219. Makima https://x.com/NIKKE_en/status/1628160167412920320\n
  220. \n
  221. Power https://x.com/NIKKE_en/status/1628160168004399104\n
  222. \n
  223. Soda https://x.com/NIKKE_en/status/1623124501721391105\n
  224. \n
  225. Cocoa https://x.com/NIKKE_en/status/1620685908612710401\n
  226. \n
  227. Viper https://x.com/NIKKE_en/status/1615876745227378688\n
  228. \n
  229. Jackal https://x.com/NIKKE_en/status/1613340033909489668\n
  230. \n
  231. Modernia https://x.com/NIKKE_en/status/1609353727126691842\n
  232. \n
  233. Rupee: Winter Shopper https://x.com/NIKKE_en/status/1600731375312805889\n
  234. \n
  235. Anne: Miracle Fairy https://x.com/NIKKE_en/status/1603193164717768710\n
  236. \n
  237. Laplace https://x.com/NIKKE_en/status/1595538959190626304\n
  238. \n
  239. Helm https://x.com/NIKKE_en/status/1590662387249868802\n
  240. \n
\n
\n


\n

\n
v · d · e
Events
2022
2023
2024
2025
2026
Event Quests
Miscellaneous
Web events
\n
v · d · e
Gameplay
\n\n\n\n\n
"}} \ No newline at end of file diff --git a/src/ingest/adapters/index.ts b/src/ingest/adapters/index.ts index 46daa26..7a466e1 100644 --- a/src/ingest/adapters/index.ts +++ b/src/ingest/adapters/index.ts @@ -143,6 +143,22 @@ const SOURCES: SourceSpec[] = [ url: "https://stellasora.miraheze.org/wiki/Main_Page", parserId: "stellasorawiki", }, + { + id: "nikke-fandom-events", + game: "nikke", + // The MediaWiki API, like the two Fandom sources above. This wiki's + // robots.txt was read in a browser on 2026-08-19 and is the standard Fandom + // file: no `Disallow: /` for `*`, `/api.php?action=` explicitly allowed, + // and only `Special:`, `User:`, `User_talk:`, `Template:`, `Template_talk:`, + // `Help:` and `UserProfile:` refused. The named AI crawlers it blocks + // (GPTBot, CCBot, OAI-SearchBot, ImagesiftBot) are not us. + // + // As with r1999 and fgo, the robots gate still fails closed from a + // datacentre address, so the scheduled refresh reports skipped_robots and + // this lane is fixture-backed until refreshed from an address Fandom serves. + url: "https://nikke-goddess-of-victory-international.fandom.com/api.php?action=parse&page=Event&prop=text&formatversion=2&format=json", + parserId: "fandom", + }, { id: "czn-game8-events", game: "czn", diff --git a/src/ingest/dates.ts b/src/ingest/dates.ts index 46d7b0a..c7ff2f7 100644 --- a/src/ingest/dates.ts +++ b/src/ingest/dates.ts @@ -34,9 +34,12 @@ function iso( d: number, hh = 0, mm = 0, + ss = 0, ): string | null { - if (m < 1 || m > 12 || d < 1 || d > 31 || hh > 23 || mm > 59) return null; - const date = new Date(Date.UTC(y, m - 1, d, hh, mm, 0, 0)); + if (m < 1 || m > 12 || d < 1 || d > 31 || hh > 23 || mm > 59 || ss > 59) { + return null; + } + const date = new Date(Date.UTC(y, m - 1, d, hh, mm, ss, 0)); // Rejects impossible calendar dates such as February 30. if (date.getUTCMonth() !== m - 1 || date.getUTCDate() !== d) return null; return date.toISOString(); @@ -379,8 +382,9 @@ function offsetIso( hh: number, mm: number, offsetMs: number, + ss = 0, ): string | null { - const local = iso(y, m, d, hh, mm); + const local = iso(y, m, d, hh, mm, ss); if (local === null) return null; return new Date(Date.parse(local) - offsetMs).toISOString(); } @@ -555,3 +559,54 @@ export function parseIsoOffsetInstant(input: string): ParsedInstant | null { if (Number.isNaN(value)) return null; return { iso: new Date(value).toISOString(), precision: "exact" }; } + +/** + * "12 August 2026" → 2026-08-12T00:00:00.000Z, day precision. + * "10 September 202604:59:59" → converted from `offsetMs`, exact precision. + * + * The Nikke wiki's schedule states its zone in the *column header* + * (`Start(UTC+9)`), not in the cell, so the offset arrives as an argument here + * rather than being read out of the text. A caller that cannot prove the zone + * must not call this. + * + * **A boundary with no clock keeps the day the page printed, unconverted.** + * That is the Fate/Grand Order rule (`AGENTS.md` § Fandom): there is no time of + * day to anchor a conversion to, and shifting a bare date by nine hours would + * move it to the previous calendar day — and the start's day is half an event + * ID. A boundary that does state a clock is converted, because then there is + * something real to convert. + * + * Day-first, unlike `parseMonthDayYear`: this wiki writes `12 August 2026` + * where Game8 writes `August 12, 2026`. The date and the clock arrive with no + * separator between them because they are separate elements in the markup, and + * reference markers (`[1]`) trail some cells, so the tail is tolerated rather + * than anchored. + */ +export function parseDayMonthYearClock( + input: string, + offsetMs: number, +): ParsedInstant | null { + const re = /^\s*(\d{1,2})\s+([A-Za-z]+)\.?\s+(\d{4})\s*(?:(\d{1,2}):(\d{2})(?::(\d{2}))?)?/; + const m = re.exec(input.replace(/\[\d+\]/g, " ")); + if (!m) return null; + + const month = monthNumber(m[2] ?? ""); + if (month === null) return null; + + const day = Number(m[1]); + const year = Number(m[3]); + + if (m[4] === undefined) { + // No clock: the printed day stands, exactly as it does on FGO's page. + const value = iso(year, month, day); + return value === null ? null : { iso: value, precision: "day" }; + } + + // Seconds matter here and are carried: this page ends its events at + // 04:59:59 and starts the next banner at 05:00:00, one second apart, and + // rounding that to the minute would make the two overlap. + const value = offsetIso( + year, month, day, Number(m[4]), Number(m[5]), offsetMs, Number(m[6] ?? 0), + ); + return value === null ? null : { iso: value, precision: "exact" }; +} diff --git a/src/ingest/parsers/fandom.ts b/src/ingest/parsers/fandom.ts index 9f30778..5a4c480 100644 --- a/src/ingest/parsers/fandom.ts +++ b/src/ingest/parsers/fandom.ts @@ -1,5 +1,9 @@ import { eventId, type GachaEvent } from "../../shared/schema.ts"; -import { parseFullRange, parseOrdinalDateTimeRange } from "../dates.ts"; +import { + parseDayMonthYearClock, + parseFullRange, + parseOrdinalDateTimeRange, +} from "../dates.ts"; import { text } from "../html.ts"; import type { ParseContext } from "../adapters/types.ts"; import { inferType } from "./game8.ts"; @@ -83,6 +87,32 @@ const FGO_DURATION = /\s*Duration:\s*<\/b>([^<]*)/i; */ const FGO_ARTICLE_SUFFIX = /\s*\(US\)\s*$/; +/** + * The Nikke wiki's schedule tables — the third template this parser reads, and + * the only one that states its timezone in the *header* rather than the cell: + * `Event | Start(UTC+9) | End(UTC+9) | Archived(?)` for story events, and + * `Nikke | Start(UTC+9) | End(UTC+9)` for pickup banners. + * + * That header is the safety property rather than a convenience. No date on this + * page carries an offset next to it, so a table whose Start/End columns stop + * naming a zone is one this reader must refuse rather than read as UTC — the + * Blue Archive hazard, arriving one column to the left. + */ +const NIKKE_ZONE_HEADER = /^(start|end)\s*\(utc([+-]\d{1,2})\)$/; + +/** + * A title cell's fallback, for when the wiki has no logo for the event yet. + * + * Every title on this page is an image and the name is normally recoverable + * from the wrapping ``. The newest row — today's live + * event — is the one most likely to have no logo uploaded, and then the cell is + * a red link reading `File:Persona on Frontline logo.png`. A reader that only + * understood `` would silently drop the single most important row on + * the page and publish a calendar missing what is on right now. + */ +const NIKKE_FILE_TITLE = + /^File:\s*(.+?)\s*(?:logo)?\.(?:png|jpe?g|gif|webp)$/i; + /** The rendered HTML inside an `action=parse` response, or null. */ export function renderedHtml(body: string): string | null { let payload: unknown; @@ -216,6 +246,173 @@ function parseFgoOngoingEvents( return out; } +/** + * True for the Nikke wiki's `Event` page, and the gate on the branch below. + * + * Asserts a schedule table whose Start/End headers name a zone, which is the + * one fact the reader cannot get from anywhere else on the page. + */ +function isNikkeEventPage(rendered: string): boolean { + return nikkeTables(rendered).length > 0; +} + +interface NikkeTable { + body: string; + startIdx: number; + endIdx: number; + offsetMs: number; + /** Pickup banners head their title column `Nikke`; story events, `Event`. */ + banner: boolean; + /** That first header, unsquashed — the table's own name for its rows. */ + label: string; +} + +/** Every table on the page whose Start/End columns state their offset. */ +function nikkeTables(rendered: string): NikkeTable[] { + const out: NikkeTable[] = []; + + for (const table of rendered.matchAll(/]*>([\s\S]*?)<\/table>/gi)) { + const body = table[1] ?? ""; + const headers = [...body.matchAll(/]*>([\s\S]*?)<\/th>/gi)].map((h) => + text(h[1] ?? "").toLowerCase().replace(/\s+/g, ""), + ); + + let startIdx = -1; + let endIdx = -1; + let offsetMs: number | null = null; + headers.forEach((h, i) => { + const m = NIKKE_ZONE_HEADER.exec(h); + if (m === null) return; + // Both columns must agree on the offset; a table stating two different + // ones is a shape this reader does not understand. + const hours = Number(m[2]); + const ms = hours * 60 * 60 * 1000; + if (offsetMs !== null && offsetMs !== ms) return; + offsetMs = ms; + if (m[1] === "start") startIdx = i; + else endIdx = i; + }); + + if (startIdx < 0 || endIdx < 0 || offsetMs === null) continue; + + const first = headers[0] ?? ""; + const label = text( + /]*>([\s\S]*?)<\/th>/i.exec(body)?.[1] ?? "", + ); + out.push({ + body, + startIdx, + endIdx, + offsetMs, + banner: first === "nikke", + label, + }); + } + + return out; +} + +/** + * The Nikke wiki's story events and pickup banners. + * + * Both tables are read because both are schedules our readers act on, and both + * state their zone the same way. What differs is how much they pin down: a + * story event's start is a bare date and its end carries a clock, while a + * banner usually carries one on both. `parseDayMonthYearClock` keeps a + * clockless boundary on the day the page printed rather than shifting it nine + * hours into the previous one — the Fate/Grand Order rule, and here it matters + * doubly because the start's day is half an event ID. + */ +function parseNikkeEvents(rendered: string, ctx: ParseContext): GachaEvent[] { + const nowMs = Date.parse(ctx.now); + const out: GachaEvent[] = []; + const seen = new Set(); + + for (const table of nikkeTables(rendered)) { + for (const row of table.body.matchAll(ROW)) { + const cells = [...(row[1] ?? "").matchAll(CELL)].map((c) => ({ + tag: c[1] ?? "", + html: c[2] ?? "", + })); + if (cells.length === 0 || cells.some((c) => c.tag === "h")) continue; + + const titleCell = cells[0]?.html ?? ""; + const title = nikkeTitle(titleCell); + if (title === null) continue; + + const start = parseDayMonthYearClock( + text(cells[table.startIdx]?.html ?? ""), + table.offsetMs, + ); + if (start === null) continue; + + const end = parseDayMonthYearClock( + text(cells[table.endIdx]?.html ?? ""), + table.offsetMs, + ); + if (end === null || end.iso <= start.iso) continue; + + // Five year-tabbed tables of history sit alongside the live one, so + // inclusion is decided against ctx.now as it is everywhere else here. + if (Date.parse(end.iso) < nowMs) continue; + + const id = eventId(ctx.game, title, start.iso); + if (seen.has(id)) continue; + seen.add(id); + + const href = ARTICLE_LINK.exec(titleCell)?.[1]; + + out.push({ + id, + game: ctx.game, + title, + // The table names what its rows are — "Costume Gacha", "Popularity + // Poll" — which the title alone never does. A pickup table is a + // character banner outright; everything else goes through the shared + // vocabulary with that label alongside the title. + type: table.banner ? "banner" : inferType(`${title} ${table.label}`), + summary: null, + startsAt: start.iso, + startPrecision: start.precision, + endsAt: end.iso, + endPrecision: end.precision, + // One worldwide server on a single stated offset, and the page draws no + // distinction between regions. + regionScoped: false, + regionEnds: null, + sourceUrl: + href === undefined + ? ctx.sourceUrl + : new URL(href, ctx.sourceUrl).toString(), + sourceId: ctx.sourceId, + status: "published", + // Docked where the source pinned less down: a start with no clock is a + // day, not an instant. + confidence: start.precision === "day" ? 0.9 : 0.95, + extractionMethod: "parser", + version: 1, + firstSeenAt: ctx.now, + updatedAt: ctx.now, + }); + } + } + + return out; +} + +/** A title cell's event name: the link's title, or the file name behind it. */ +function nikkeTitle(cell: string): string | null { + const linked = /]*title="([^"]*)"/i.exec(cell)?.[1]; + const raw = (linked ?? text(cell)).trim(); + if (raw.length === 0) return null; + + // "File:Persona on Frontline logo.png" -> "Persona on Frontline". Applied to + // the link title too: a red link carries the file name in both places. + const named = NIKKE_FILE_TITLE.exec(raw)?.[1]; + const title = (named ?? raw).trim(); + return title.length === 0 ? null : title; +} + export function parseFandomEventsPage( body: string, ctx: ParseContext, @@ -228,6 +425,14 @@ export function parseFandomEventsPage( // apart, and `canParse` asserts it, so a template change fails the source // loudly instead of routing an FGO page through the `Time Period` reader and // emptying the lane. + if (isNikkeEventPage(rendered)) { + return parseNikkeEvents(rendered, ctx).sort((a, b) => + a.startsAt === b.startsAt + ? a.id.localeCompare(b.id) + : a.startsAt.localeCompare(b.startsAt), + ); + } + if (isFgoEventList(rendered)) { return parseFgoOngoingEvents(rendered, ctx).sort((a, b) => a.startsAt === b.startsAt @@ -336,7 +541,9 @@ export const fandomParser: SourceParser = { if (rendered === null) return false; const isTimePeriodTable = /class="[^"]*wikitable/.test(rendered) && /Time Period/i.test(rendered); - return isTimePeriodTable || isFgoEventList(rendered); + return ( + isTimePeriodTable || isFgoEventList(rendered) || isNikkeEventPage(rendered) + ); }, parse: parseFandomEventsPage, }; diff --git a/src/shared/games.ts b/src/shared/games.ts index 51ece4b..188e276 100644 --- a/src/shared/games.ts +++ b/src/shared/games.ts @@ -139,6 +139,17 @@ export const GAMES: Record = { // which states the zone the *dates* are in and says nothing about where the // server's day rolls — and every row on it is a bare date anyway, with no // time of day to read a reset out of. Same silence as p5x and czn. + // Nikke runs one worldwide server on a Japanese clock that rolls its day at + // **05:00**, not 04:00 — and both halves come from the source rather than + // from habit. Every schedule column on the wiki is headed `Start(UTC+9)` / + // `End(UTC+9)`, and the rows themselves show the boundary: story events end + // at 04:59:59 and the pickup banner replacing them starts at 05:00:00, one + // second apart. That is the Reverse: 1999 evidence pattern exactly. + // + // Set in the same commit that ships the game, which costs nothing now and + // could not be added later without re-labelling day keys readers had already + // logged ticks under. + nikke: { id: "nikke", name: "Goddess of Victory: Nikke", short: "Nikke", hue: "#E4572E", studio: "Shift Up", dailyTasks: "Daily missions, outpost", resetOffsets: { asia: 9, america: 9, europe: 9 }, resetHourLocal: 5 }, uma: { id: "uma", name: "Umamusume: Pretty Derby", short: "Umamusume", hue: "#6FBF44", studio: "Cygames", dailyTasks: "Daily races, missions" }, }; diff --git a/src/shared/schema.ts b/src/shared/schema.ts index 39e6e81..3e6efc6 100644 --- a/src/shared/schema.ts +++ b/src/shared/schema.ts @@ -18,6 +18,9 @@ export const GameId = z.enum([ "stellasora", // Stella Sora "czn", // Chaos Zero Nightmare "uma", // Umamusume: Pretty Derby + // NOTE: one letter from "nikki" (Infinity Nikki) above, and both are the + // first segment of every completion key their game will ever have. + "nikke", // Goddess of Victory: Nikke ]); export type GameId = z.infer; diff --git a/test/adapters/game8.test.ts b/test/adapters/game8.test.ts index 852bae5..6cb4bf4 100644 --- a/test/adapters/game8.test.ts +++ b/test/adapters/game8.test.ts @@ -46,6 +46,7 @@ const CASES: Array<{ adapter: Adapter; fixture: string }> = [ { adapter: adapter("stellasora-stellasorawiki-events"), fixture: "fixtures/stellasora/stellasorawiki-events-2026-08-19" }, { adapter: adapter("czn-game8-events"), fixture: "fixtures/czn/game8-events-2026-08-19" }, { adapter: adapter("uma-game8-events"), fixture: "fixtures/uma/game8-events-2026-08-19" }, + { adapter: adapter("nikke-fandom-events"), fixture: "fixtures/nikke/fandom-events-2026-08-19" }, ]; async function runAdapter(adapter: Adapter, fixture: string) { @@ -1455,3 +1456,101 @@ describe("game8 column vocabulary", () => { expect(events).toEqual([]); }); }); + +describe("Nikke wiki (the third Fandom template)", () => { + const fixture = "fixtures/nikke/fandom-events-2026-08-19"; + const nikke = adapter("nikke-fandom-events"); + + function parse(html: string) { + return nikke.parse(html, { + now: NOW, + sourceUrl: nikke.url, + sourceId: nikke.id, + game: nikke.game, + }); + } + + /** An `action=parse` envelope around one schedule table. */ + const wrapped = (rows: string, startHead = "Start(UTC+9)", endHead = "End(UTC+9)") => + JSON.stringify({ + parse: { + text: ` + ${rows}
Event${startHead}${endHead}Archived(?)
`, + }, + }); + + test("keeps the live event whose logo has not been uploaded", async () => { + // The row that matters most and the one a naive reader drops: every title + // here is an image read from
, and the newest row is a red link + // reading "File:Persona on Frontline logo.png". Losing it publishes a + // calendar missing what is on right now. + const titles = (await runAdapter(nikke, fixture)).map((e) => e.title); + expect(titles).toContain("Persona on Frontline"); + for (const t of titles) expect(t).not.toMatch(/^File:/); + for (const t of titles) expect(t).not.toMatch(/\.png$/i); + }); + + test("converts the stated UTC+9, carrying seconds", async () => { + const events = await runAdapter(nikke, fixture); + const persona = events.find((e) => e.title === "Persona on Frontline"); + // Start is a bare date, so it keeps the day the page printed. + expect(persona?.startsAt).toBe("2026-08-12T00:00:00.000Z"); + expect(persona?.startPrecision).toBe("day"); + // End states 10 September 2026 04:59:59 (UTC+9) → 19:59:59Z the day before. + expect(persona?.endsAt).toBe("2026-09-09T19:59:59.000Z"); + expect(persona?.endPrecision).toBe("exact"); + }); + + test("a start with no clock keeps its printed day rather than shifting", () => { + // Nine hours would move a bare date to the previous calendar day, and the + // start's day is half an event ID. Same rule as the FGO page. + const events = parse( + wrapped(`x12 August 2026 + 10 September 202604:59:59`), + ); + expect(events[0]?.startsAt).toBe("2026-08-12T00:00:00.000Z"); + expect(events[0]?.id).toBe("nikke:bare-start:2026-08-12"); + }); + + test("refuses a table whose columns stop naming the zone", () => { + // No date on this page carries an offset next to it, so the header is the + // only evidence of the zone. Losing it must empty the table, not default + // the whole schedule to UTC — the Blue Archive hazard one column left. + expect(() => + parse( + wrapped( + `x12 August 2026 + 10 September 202604:59:59`, + "Start", + "End", + ), + ), + ).toThrow(/redesigned/); + }); + + test("reads the offset the header states, not a hardcoded one", () => { + const events = parse( + wrapped( + `x12 August 2026 + 10 September 202604:00:00`, + "Start(UTC+2)", + "End(UTC+2)", + ), + ); + expect(events[0]?.endsAt).toBe("2026-09-10T02:00:00.000Z"); + }); + + test("publishes nothing that has already ended", async () => { + for (const e of await runAdapter(nikke, fixture)) { + expect(e.endsAt === null || e.endsAt > NOW).toBe(true); + } + }); + + test("still reads the other two Fandom templates", async () => { + // The branch is additive: r1999 and FGO must be untouched by it. + expect((await runAdapter(adapter("r1999-fandom-events"), + "fixtures/r1999/fandom-events-2026-08-17")).length).toBeGreaterThan(0); + expect((await runAdapter(adapter("fgo-fandom-events"), + "fixtures/fgo/fandom-events-2026-08-18")).length).toBeGreaterThan(0); + }); +});