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:
co-authored by
Claude Opus 5
parent
7926a70850
commit
1a65a1f06c
@@ -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`) —
|
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
|
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
|
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
|
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
|
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.
|
becomes unreachable; check real fixture windows before changing an offset.
|
||||||
|
|||||||
+11
-1
@@ -9,7 +9,7 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
export const GameId = z.enum([
|
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([
|
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 |
|
| 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, 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 | — |
|
| 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
|
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
|
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
|
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.
|
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
|
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.
|
an hour across the European clock change while the UTC instant stays put.
|
||||||
|
|
||||||
|
|||||||
+19
-1
@@ -137,6 +137,24 @@ export function serverOffsetUtc(region: Region, game?: LaneId): number {
|
|||||||
return override ?? REGION_RESET_UTC_OFFSET[region];
|
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.
|
* 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.
|
* a data change, not a constant.
|
||||||
*/
|
*/
|
||||||
function shift(region: Region, game?: LaneId): number {
|
function shift(region: Region, game?: LaneId): number {
|
||||||
return serverOffsetUtc(region, game) * HOUR - RESET_HOUR_LOCAL * HOUR;
|
return serverOffsetUtc(region, game) * HOUR - resetHourFor(game) * HOUR;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -39,6 +39,21 @@ export interface GameMeta {
|
|||||||
* docs/DATA-MODEL.md.
|
* docs/DATA-MODEL.md.
|
||||||
*/
|
*/
|
||||||
resetOffsets?: Partial<Record<Region, number>> | undefined;
|
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> = {
|
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
|
// from the regional default, and an offset invented here would move real
|
||||||
// readers' day keys. Add one only against evidence — see games.ts § resetOffsets.
|
// 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" },
|
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);
|
export const GAME_LIST: GameMeta[] = Object.values(GAMES);
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export const GameId = z.enum([
|
|||||||
"nte", // Neverness to Everness
|
"nte", // Neverness to Everness
|
||||||
"nikki", // Infinity Nikki
|
"nikki", // Infinity Nikki
|
||||||
"p5x", // Persona 5: The Phantom X
|
"p5x", // Persona 5: The Phantom X
|
||||||
|
"r1999", // Reverse: 1999
|
||||||
]);
|
]);
|
||||||
export type GameId = z.infer<typeof GameId>;
|
export type GameId = z.infer<typeof GameId>;
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import {
|
|||||||
isDaily,
|
isDaily,
|
||||||
msUntilReset,
|
msUntilReset,
|
||||||
nextResetMs,
|
nextResetMs,
|
||||||
|
resetHourFor,
|
||||||
|
RESET_HOUR_LOCAL,
|
||||||
resolveDaily,
|
resolveDaily,
|
||||||
streakOf,
|
streakOf,
|
||||||
} from "../src/shared/daily.ts";
|
} from "../src/shared/daily.ts";
|
||||||
@@ -358,3 +360,48 @@ describe("dailiesId", () => {
|
|||||||
expect(dailiesId("genshin").split(":")).toHaveLength(2);
|
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",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user