feat(games): track Reverse: 1999, which resets at 05:00

Adds the r1999 GameId and its metadata, plus GameMeta.resetHourLocal for
the fact that makes it unlike every other game here: it rolls its day at
05:00 server time rather than 04:00, on a single global UTC-5 server, so
its day-key boundary is 10:00 UTC.

resetOffsets could not carry that. Landing 10:00 UTC through the offset
would mean claiming a UTC-6 server, and serverOffsetUtc answers more
questions than this one. The new field overrides RESET_HOUR_LOCAL per game
and is absent everywhere else, so no existing reader's logged day moved —
a test pins that for the games that already had readers.

Both facts are read off the source, not assumed: all 154 rows of the
wiki's event list state (UTC-5) and run 05:00 to 04:59, an event ending
one minute before the reset the next one begins on.

No adapter yet, so the game has no events until one lands.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Lucas Winther
2026-08-17 22:45:40 +02:00
co-authored by Claude Opus 5
parent 7926a70850
commit 1a65a1f06c
6 changed files with 105 additions and 3 deletions
+6 -1
View File
@@ -170,7 +170,12 @@ Two more key spaces have the same property, for the same reason:
A game whose server map differs lists the affected regions in `resetOffsets` (`games.ts`) —
Endfield serves Europe off the Americas machine, so `europe` is UTC-5 there and its reset is
09:00 UTC, not 03:00. Keep that override **per region**: a blanket per-game offset drags the
regions that do have their own server onto someone else's clock. Every day-key function takes an
regions that do have their own server onto someone else's clock.
A game that rolls on a different *hour* says so in `resetHourLocal` instead — Reverse: 1999 resets
at 05:00, not 04:00, so its day rolls at 10:00 UTC on its single UTC-5 server. Do not encode that
as a bent `resetOffsets` value: shifting a game's stated server offset to land the right instant
would misreport the server clock to everything else that asks for it. Both fields are absent for
every game that takes the default, which is why adding the second one moved nobody's day keys. Every day-key function takes an
optional `game`**anything reading or writing a tick must pass it**, or it writes under one
clock and reads under another. A day that drops out of `dailyDays` renders no pip, so a tick on it
becomes unreachable; check real fixture windows before changing an offset.
+11 -1
View File
@@ -9,7 +9,7 @@
import { z } from "zod";
export const GameId = z.enum([
"genshin", "hsr", "zzz", "wuwa", "arknights", "endfield", "nte", "nikki", "p5x",
"genshin", "hsr", "zzz", "wuwa", "arknights", "endfield", "nte", "nikki", "p5x", "r1999",
]);
export const EventType = z.enum([
@@ -258,6 +258,7 @@ reader cannot see.
| Arknights, all regions | 04:00 | UTC-7 (one Global server) | 11:00 | 13:00 / 12:00 |
| Endfield, Europe | 04:00 | UTC-5 (on the Americas server) | 09:00 | 11:00 / 10:00 |
| Endfield, Asia / Americas | 04:00 | regional default | 20:00 / 09:00 | — |
| Reverse: 1999, all regions | **05:00** | UTC-5 (one global server) | 10:00 | 12:00 / 11:00 |
Arknights is the one game whose override covers **all three** regions, and it is not a blanket
per-game offset of the kind this section warns about below: the game genuinely runs a single Global
@@ -270,6 +271,15 @@ an assumption, not a verified server map — neither source states one. Confirm
before relying on it, and note that adding an override later re-labels the game-day of ticks readers
have already logged, which is the change this table warns about below.
**Reverse: 1999 is the one game that rolls on a different hour**, and it needs its own field rather
than a bent offset. It runs a single global server on UTC-5 and resets at **05:00**, so its day rolls
at 10:00 UTC. `resetOffsets` alone cannot say that: landing 10:00 UTC through the offset would mean
claiming a UTC-6 server, which is a lie every other reader of `serverOffsetUtc` would inherit. So
`GameMeta.resetHourLocal` overrides `RESET_HOUR_LOCAL` per game, and is absent — meaning 04:00 — for
everything else, which is why introducing it moved no existing day key. Both facts come off the
source: all 154 rows of the wiki's event list state `(UTC-5)`, and every one runs 05:00 → 04:59, an
event ending one minute before the reset the next one starts on.
These server offsets are **fixed and do not observe DST**, so the reader's local reset time moves by
an hour across the European clock change while the UTC instant stays put.
+19 -1
View File
@@ -137,6 +137,24 @@ export function serverOffsetUtc(region: Region, game?: LaneId): number {
return override ?? REGION_RESET_UTC_OFFSET[region];
}
/**
* The hour of its own server day a game rolls over on.
*
* Almost always `RESET_HOUR_LOCAL`. A game that resets on a different hour says
* so in `resetHourLocal` (`games.ts`) — Reverse: 1999 rolls at 05:00 — and that
* cannot be folded into `serverOffsetUtc`: shifting a game's stated offset to
* land the right reset instant would misreport the server clock to everything
* else that asks for it.
*
* A lane the reader invented has no server to know about and takes the default,
* for the same reason `serverOffsetUtc` does.
*/
export function resetHourFor(game?: LaneId): number {
const override =
game === undefined ? undefined : GAMES[game as GameId]?.resetHourLocal;
return override ?? RESET_HOUR_LOCAL;
}
/**
* Offset from UTC midnight to this game's reset instant.
*
@@ -147,7 +165,7 @@ export function serverOffsetUtc(region: Region, game?: LaneId): number {
* a data change, not a constant.
*/
function shift(region: Region, game?: LaneId): number {
return serverOffsetUtc(region, game) * HOUR - RESET_HOUR_LOCAL * HOUR;
return serverOffsetUtc(region, game) * HOUR - resetHourFor(game) * HOUR;
}
/**
+21
View File
@@ -39,6 +39,21 @@ export interface GameMeta {
* docs/DATA-MODEL.md.
*/
resetOffsets?: Partial<Record<Region, number>> | undefined;
/**
* The hour of the server's own day this game rolls over on, when it is not
* `RESET_HOUR_LOCAL` (04:00).
*
* Most gacha servers reset at 04:00 local. Reverse: 1999 resets at 05:00, and
* the difference is not cosmetic: `resetOffsets` alone cannot express it,
* because bending a game's stated server offset to land the right instant
* would put every other reader of that offset an hour out.
*
* Like `resetOffsets` this feeds `dayKey`, which is a **localStorage key**, so
* the same warning applies — changing it for a game that already has readers
* re-labels the game-day their logged ticks fall in. Absent means 04:00, which
* is why adding this field moved no existing game.
*/
resetHourLocal?: number | undefined;
}
export const GAMES: Record<GameId, GameMeta> = {
@@ -66,6 +81,12 @@ export const GAMES: Record<GameId, GameMeta> = {
// from the regional default, and an offset invented here would move real
// readers' day keys. Add one only against evidence — see games.ts § resetOffsets.
p5x: { id: "p5x", name: "Persona 5: The Phantom X", short: "P5X", hue: "#D62246" , studio: "Perfect World", dailyTasks: "Daily missions, stamina" },
// Reverse: 1999 runs one global server on a fixed UTC-5 and rolls its day at
// **05:00**, not the 04:00 every other game here uses. Both facts come from
// the source rather than from habit: all 154 rows on the wiki's event list
// state `(UTC-5)`, and every one of them starts at 05:00 and ends at 04:59 —
// an event ending one minute before the reset that the next one begins on.
r1999: { id: "r1999", name: "Reverse: 1999", short: "R1999", hue: "#C9A227" , studio: "Bluepoch", dailyTasks: "Daily missions", resetOffsets: { asia: -5, america: -5, europe: -5 }, resetHourLocal: 5 },
};
export const GAME_LIST: GameMeta[] = Object.values(GAMES);
+1
View File
@@ -10,6 +10,7 @@ export const GameId = z.enum([
"nte", // Neverness to Everness
"nikki", // Infinity Nikki
"p5x", // Persona 5: The Phantom X
"r1999", // Reverse: 1999
]);
export type GameId = z.infer<typeof GameId>;
+47
View File
@@ -8,6 +8,8 @@ import {
isDaily,
msUntilReset,
nextResetMs,
resetHourFor,
RESET_HOUR_LOCAL,
resolveDaily,
streakOf,
} from "../src/shared/daily.ts";
@@ -358,3 +360,48 @@ describe("dailiesId", () => {
expect(dailiesId("genshin").split(":")).toHaveLength(2);
});
});
describe("a game whose day rolls on a different hour", () => {
// Reverse: 1999 runs one global server on UTC-5 and resets at 05:00 rather
// than 04:00, so its day rolls at 10:00 UTC. Both facts are read off the
// source: every row of its event list states (UTC-5) and runs 05:00 → 04:59.
test("rolls on 10:00 UTC, not the 09:00 a UTC-5 game would", () => {
expect(dayKey(at("2026-08-16T09:59:59Z"), "europe", "r1999")).toBe(
"2026-08-15",
);
expect(dayKey(at("2026-08-16T10:00:00Z"), "europe", "r1999")).toBe(
"2026-08-16",
);
expect(nextResetMs(at("2026-08-16T08:00:00Z"), "europe", "r1999")).toBe(
at("2026-08-16T10:00:00Z"),
);
});
test("every region gets the same answer, because there is one server", () => {
const instant = at("2026-08-16T09:30:00Z");
const keys = (["asia", "america", "europe"] as const).map((r) =>
dayKey(instant, r, "r1999"),
);
expect(new Set(keys).size).toBe(1);
});
test("resetHourFor defaults to 04:00 for every other game", () => {
// Adding the field must not have moved a single existing reader's day key,
// which is why it is an override rather than a per-game table.
expect(resetHourFor("r1999")).toBe(5);
for (const game of ["genshin", "endfield", "arknights", undefined] as const) {
expect(resetHourFor(game)).toBe(RESET_HOUR_LOCAL);
}
// A lane the reader invented has no server map and takes the default too.
expect(resetHourFor("mygame:my-own-game")).toBe(RESET_HOUR_LOCAL);
});
test("an event's checklist counts days on the 05:00 clock", () => {
const start = at("2026-08-13T10:00:00Z"); // an r1999 reset
expect(dailyDays(start, start + 3 * DAY, "europe", "r1999")).toEqual([
"2026-08-13",
"2026-08-14",
"2026-08-15",
]);
});
});