refactor(time): make resetHourLocal a per-region map

A game's servers need not agree on the reset hour any more than on the
offset. Reverse: 1999 and Nikke spell their 05:00 out for all three
regions, so no reader's day key moves.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Lucas Winther
2026-10-13 12:37:10 +02:00
co-authored by Claude Opus 5.5
parent bf9d5d45cd
commit 72fe6b618b
4 changed files with 21 additions and 14 deletions
+2 -1
View File
@@ -397,7 +397,8 @@ already logged, which is the change this table warns about below.
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
`GameMeta.resetHourLocal` overrides `RESET_HOUR_LOCAL` per region, like `resetOffsets` — a game's
servers need not agree on the hour any more than on the offset — 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.
+10 -5
View File
@@ -40,20 +40,25 @@ export interface GameMeta {
*/
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).
* The hour of the server's own day this game rolls over on, per region, where
* 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.
*
* Per region for the same reason `resetOffsets` is: a game's servers need not
* agree on the hour either. Girls' Frontline 2's Asia server rolls at 05:00
* UTC+8 while its global server rolls at 21:00 UTC-8, and one number per game
* would have to lie about one of them. A region absent here takes 04:00.
*
* 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;
resetHourLocal?: Partial<Record<Region, number>> | undefined;
}
export const GAMES: Record<GameId, GameMeta> = {
@@ -86,7 +91,7 @@ export const GAMES: Record<GameId, GameMeta> = {
// 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 },
r1999: { id: "r1999", name: "Reverse: 1999", short: "R1999", hue: "#C9A227" , studio: "Bluepoch", dailyTasks: "Daily missions", resetOffsets: { asia: -5, america: -5, europe: -5 }, resetHourLocal: { asia: 5, america: 5, europe: 5 } },
// No `resetOffsets` and no `resetHourLocal`, for the reason p5x has none: the
// source states no time of day anywhere — every date on bluearchive.wiki's
// Global schedule is a bare `YYYY-MM-DD`. Blue Archive Global does run one
@@ -149,7 +154,7 @@ export const GAMES: Record<GameId, GameMeta> = {
// 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 },
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: { asia: 5, america: 5, europe: 5 } },
uma: { id: "uma", name: "Umamusume: Pretty Derby", short: "Umamusume", hue: "#6FBF44", studio: "Cygames", dailyTasks: "Daily races, missions" },
// No `resetOffsets`, and the source is silent in the strongest sense: every
// boundary Honkai Impact 3rd has here is a **week bucket**, and every one of
+6 -5
View File
@@ -59,10 +59,11 @@ export function serverOffsetUtc(region: Region, game?: LaneId): number {
}
/**
* The hour of its own server day a game rolls over on.
* The hour of its own server day a game rolls over on, for the reader's region.
*
* 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
* so in `resetHourLocal` (`games.ts`), per region because a game's servers need
* not agree — Reverse: 1999 rolls at 05:00 everywhere — 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.
@@ -70,9 +71,9 @@ export function serverOffsetUtc(region: Region, game?: LaneId): number {
* 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 {
export function resetHourFor(region: Region, game?: LaneId): number {
const override =
game === undefined ? undefined : GAMES[game as GameId]?.resetHourLocal;
game === undefined ? undefined : GAMES[game as GameId]?.resetHourLocal?.[region];
return override ?? RESET_HOUR_LOCAL;
}
@@ -86,7 +87,7 @@ export function resetHourFor(game?: LaneId): number {
* here as a data change, not a constant.
*/
export function resetShiftMs(region: Region, game?: LaneId): number {
return serverOffsetUtc(region, game) * HOUR - resetHourFor(game) * HOUR;
return serverOffsetUtc(region, game) * HOUR - resetHourFor(region, game) * HOUR;
}
/**
+3 -3
View File
@@ -393,12 +393,12 @@ describe("a game whose day rolls on a different hour", () => {
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);
expect(resetHourFor("europe", "r1999")).toBe(5);
for (const game of ["genshin", "endfield", "arknights", undefined] as const) {
expect(resetHourFor(game)).toBe(RESET_HOUR_LOCAL);
expect(resetHourFor("europe", 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);
expect(resetHourFor("europe", "mygame:my-own-game")).toBe(RESET_HOUR_LOCAL);
});
test("an event's checklist counts days on the 05:00 clock", () => {