fix(daily): reset Endfield's European day on the server it's on
Endfield has two server groups, not three: Europe is served off the Americas machine on a fixed UTC-5, so a European player's day rolls at 09:00 UTC. We were resetting it six hours early, at 03:00, which ticked the wrong box every morning between those two instants. Adds GameMeta.resetOffsets, a sparse per-region override, and threads an optional `game` through every day-key function. Per region rather than per game on purpose: a blanket offset would drag Asia — which does have its own Endfield server — onto the Americas clock, moving day keys for readers who never had the bug. A regression test pins Asia's output as identical to before. Day keys are localStorage keys, so this re-labels ticks logged between 03:00 and 09:00 UTC by European Endfield players, one day backward. No tick is deleted and past days stay editable, but a streak can read as broken for a day. That is the cost of correcting a wrong reset; leaving it wrong is worse. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
6c5546ba53
commit
e6e4f7085a
@@ -83,7 +83,7 @@ src/client/ React app, service worker, manifest
|
|||||||
state/ progress, daily log, ignores, prefs, sort — all localStorage
|
state/ progress, daily log, ignores, prefs, sort — all localStorage
|
||||||
scripts/ build-feed.ts, parse-fixture.ts (offline), refresh-sources.ts (fetches)
|
scripts/ build-feed.ts, parse-fixture.ts (offline), refresh-sources.ts (fetches)
|
||||||
serve.ts static server + /api/health
|
serve.ts static server + /api/health
|
||||||
test/ 257 tests
|
test/ 301 tests
|
||||||
fixtures/<game>/ raw HTML + .expected.json per source — pinned, kept forever
|
fixtures/<game>/ raw HTML + .expected.json per source — pinned, kept forever
|
||||||
snapshots/ current page per source, rewritten by refresh — see its README
|
snapshots/ current page per source, rewritten by refresh — see its README
|
||||||
```
|
```
|
||||||
@@ -150,6 +150,13 @@ Two more key spaces have the same property, for the same reason:
|
|||||||
- **Game-day keys** (`dayKey`) are `YYYY-MM-DD` in *server-reset space*, not UTC — the day rolls at
|
- **Game-day keys** (`dayKey`) are `YYYY-MM-DD` in *server-reset space*, not UTC — the day rolls at
|
||||||
04:00 local server time. They are storage keys *and* they are compared with `<` and sorted, so the
|
04:00 local server time. They are storage keys *and* they are compared with `<` and sorted, so the
|
||||||
format is fixed. Changing the reset hour or the offsets moves every reader's streak by a day.
|
format is fixed. Changing the reset hour or the offsets moves every reader's streak by a day.
|
||||||
|
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
|
||||||
|
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.
|
||||||
|
|
||||||
The sanitizer at the ingest boundary recomputes an event ID only when a sanitized title actually
|
The sanitizer at the ingest boundary recomputes an event ID only when a sanitized title actually
|
||||||
changed *and* the ID was minted the standard way. If a change to it starts moving IDs on real
|
changed *and* the ID was minted the standard way. If a change to it starts moving IDs on real
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ src/
|
|||||||
shared/
|
shared/
|
||||||
schema.ts zod schemas — the contract, both sides ✓ built
|
schema.ts zod schemas — the contract, both sides ✓ built
|
||||||
time.ts clocks, urgency, region resets, captions ✓ built
|
time.ts clocks, urgency, region resets, captions ✓ built
|
||||||
games.ts per-game name and hue ✓ built
|
games.ts per-game name, hue, and reset clock ✓ built
|
||||||
feed.ts the /api/events.json wire contract ✓ built
|
feed.ts the /api/events.json wire contract ✓ built
|
||||||
client/ ✓ all built
|
client/ ✓ all built
|
||||||
main.tsx render + service worker registration
|
main.tsx render + service worker registration
|
||||||
|
|||||||
@@ -243,6 +243,38 @@ server time, so a player finishing at 02:00 is still on the previous day's daili
|
|||||||
computed against the reader's chosen region (`RESET_HOUR_LOCAL`, `dayKey`). Keys sort
|
computed against the reader's chosen region (`RESET_HOUR_LOCAL`, `dayKey`). Keys sort
|
||||||
lexicographically, which is what "how many days are left" and streak counting rely on.
|
lexicographically, which is what "how many days are left" and streak counting rely on.
|
||||||
|
|
||||||
|
**Not every game has a server per region.** `GameMeta.resetOffsets` records the regions where a
|
||||||
|
game's server clock differs from `REGION_RESET_UTC_OFFSET`. Endfield is the case this exists for: it
|
||||||
|
has two server groups rather than three, and Europe is served off the Americas machine on a fixed
|
||||||
|
UTC-5, so a European player's reset is 09:00 UTC — six hours after the HoYoverse/Kuro pattern.
|
||||||
|
Every day-key function takes an optional `game` for this reason, and **anything reading or writing a
|
||||||
|
tick must pass it**: a write under one clock and a read under another puts the tick on a day the
|
||||||
|
reader cannot see.
|
||||||
|
|
||||||
|
| Game | Reset (server local) | Server offset | Reset (UTC) | Copenhagen, summer / winter |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| Genshin, Star Rail, ZZZ, Wuwa, NTE | 04:00 | region (EU = UTC+1) | 03:00 | 05:00 / 04: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 | — |
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
The override is deliberately **per region, not per game**. A blanket per-game offset is the wrong
|
||||||
|
shape: it drags the regions that do have their own server onto somebody else's clock, moving day
|
||||||
|
keys for readers who never had the bug. List only the regions that actually differ.
|
||||||
|
|
||||||
|
Changing a value in that table is a **data change, not a constant change**: it re-labels the
|
||||||
|
game-day some already-logged ticks fall in, for readers in that region. Two consequences to check
|
||||||
|
before shipping one, both of which are invisible at runtime:
|
||||||
|
|
||||||
|
- A tick logged inside the shifted window reads as the adjacent day, which can show as a one-day
|
||||||
|
break in a streak. Recoverable — past days stay editable.
|
||||||
|
- If the shift moves a window's boundary, a day can drop out of `dailyDays` entirely. A day that is
|
||||||
|
not in `dailyDays` renders no pip, so a tick on it is **unreachable**: not deleted, but with no UI
|
||||||
|
path back to it and nothing server-side to recover from. Check the real fixture windows for the
|
||||||
|
affected game and region before changing an offset.
|
||||||
|
|
||||||
Three rules this store keeps, for the same reason the rest of the client does — nothing else holds
|
Three rules this store keeps, for the same reason the rest of the client does — nothing else holds
|
||||||
a copy:
|
a copy:
|
||||||
|
|
||||||
|
|||||||
@@ -103,6 +103,7 @@ export function App() {
|
|||||||
startsMs: row.clock.startsMs,
|
startsMs: row.clock.startsMs,
|
||||||
endsMs: row.clock.endsMs,
|
endsMs: row.clock.endsMs,
|
||||||
region: prefs.region,
|
region: prefs.region,
|
||||||
|
game: row.event.game,
|
||||||
now,
|
now,
|
||||||
logged: daily.daysFor(row.event.id),
|
logged: daily.daysFor(row.event.id),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -35,13 +35,33 @@ export function Dailies({
|
|||||||
daysFor: (id: string) => string[];
|
daysFor: (id: string) => string[];
|
||||||
onToggleDay: (id: string, day: string) => void;
|
onToggleDay: (id: string, day: string) => void;
|
||||||
}) {
|
}) {
|
||||||
if (games.length === 0 && events.length === 0) return null;
|
// Each game rolls on its own server clock, so "today" is asked per game
|
||||||
|
// rather than once for the section — Endfield's European day can still be
|
||||||
|
// yesterday's while every HoYo game has already turned over.
|
||||||
|
const chores = games.map((id) => ({
|
||||||
|
key: dailiesId(id),
|
||||||
|
game: gameMeta(id),
|
||||||
|
today: dayKey(now, region, id),
|
||||||
|
resetsIn: msUntilReset(now, region, id),
|
||||||
|
}));
|
||||||
|
const repeating = events.map((event) => ({
|
||||||
|
key: event.id,
|
||||||
|
event,
|
||||||
|
game: gameMeta(event.game),
|
||||||
|
today: dayKey(now, region, event.game),
|
||||||
|
resetsIn: msUntilReset(now, region, event.game),
|
||||||
|
}));
|
||||||
|
|
||||||
const today = dayKey(now, region);
|
const items = [...chores, ...repeating];
|
||||||
const doneEvents = events.filter((e) => daysFor(e.id).includes(today));
|
const total = items.length;
|
||||||
const done = games.filter((g) => daysFor(dailiesId(g)).includes(today));
|
const complete = items.filter((i) => daysFor(i.key).includes(i.today)).length;
|
||||||
const total = games.length + events.length;
|
|
||||||
const complete = done.length + doneEvents.length;
|
if (total === 0) return null;
|
||||||
|
|
||||||
|
// With mixed reset clocks there is no single "resets in", so the header
|
||||||
|
// reports the next one to land and says that it is the next one.
|
||||||
|
const soonest = Math.min(...items.map((i) => i.resetsIn));
|
||||||
|
const mixed = new Set(items.map((i) => i.resetsIn)).size > 1;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="border-b border-hairline px-4 py-4">
|
<section className="border-b border-hairline px-4 py-4">
|
||||||
@@ -50,45 +70,39 @@ export function Dailies({
|
|||||||
Today's dailies · {complete}/{total}
|
Today's dailies · {complete}/{total}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="tnum text-[0.6875rem] text-faint">
|
<p className="tnum text-[0.6875rem] text-faint">
|
||||||
resets in {formatRemaining(msUntilReset(now, region))}
|
{mixed ? "next reset in " : "resets in "}
|
||||||
|
{formatRemaining(soonest)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ul className="mt-2.5 flex flex-wrap gap-1.5">
|
<ul className="mt-2.5 flex flex-wrap gap-1.5">
|
||||||
{games.map((id) => {
|
{chores.map((chore) => (
|
||||||
const game = gameMeta(id);
|
<li key={chore.key}>
|
||||||
const key = dailiesId(id);
|
|
||||||
return (
|
|
||||||
<li key={key}>
|
|
||||||
<TickChip
|
<TickChip
|
||||||
label={game.short}
|
label={chore.game.short}
|
||||||
hue={game.hue}
|
hue={chore.game.hue}
|
||||||
title={game.dailyTasks}
|
title={chore.game.dailyTasks}
|
||||||
ariaLabel={`${game.name} dailies — ${game.dailyTasks}`}
|
ariaLabel={`${chore.game.name} dailies — ${chore.game.dailyTasks}`}
|
||||||
days={daysFor(key)}
|
days={daysFor(chore.key)}
|
||||||
today={today}
|
today={chore.today}
|
||||||
onToggle={() => onToggleDay(key, today)}
|
onToggle={() => onToggleDay(chore.key, chore.today)}
|
||||||
/>
|
/>
|
||||||
</li>
|
</li>
|
||||||
);
|
))}
|
||||||
})}
|
|
||||||
|
|
||||||
{events.map((event) => {
|
{repeating.map((row) => (
|
||||||
const game = gameMeta(event.game);
|
<li key={row.key}>
|
||||||
return (
|
|
||||||
<li key={event.id}>
|
|
||||||
<TickChip
|
<TickChip
|
||||||
label={event.title}
|
label={row.event.title}
|
||||||
hue={game.hue}
|
hue={row.game.hue}
|
||||||
title={`${game.name} — ${event.title}`}
|
title={`${row.game.name} — ${row.event.title}`}
|
||||||
ariaLabel={`${event.title} (${game.name})`}
|
ariaLabel={`${row.event.title} (${row.game.name})`}
|
||||||
days={daysFor(event.id)}
|
days={daysFor(row.key)}
|
||||||
today={today}
|
today={row.today}
|
||||||
onToggle={() => onToggleDay(event.id, today)}
|
onToggle={() => onToggleDay(row.key, row.today)}
|
||||||
/>
|
/>
|
||||||
</li>
|
</li>
|
||||||
);
|
))}
|
||||||
})}
|
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<p className="mt-2 text-[0.6875rem] leading-relaxed text-faint">
|
<p className="mt-2 text-[0.6875rem] leading-relaxed text-faint">
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import {
|
|||||||
msUntilReset,
|
msUntilReset,
|
||||||
type DailySummary,
|
type DailySummary,
|
||||||
} from "../../shared/daily.ts";
|
} from "../../shared/daily.ts";
|
||||||
import type { Region } from "../../shared/schema.ts";
|
import type { GameId, Region } from "../../shared/schema.ts";
|
||||||
import { formatRemaining } from "../../shared/time.ts";
|
import { formatRemaining } from "../../shared/time.ts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -23,6 +23,7 @@ export function DailyChecklist({
|
|||||||
startsMs,
|
startsMs,
|
||||||
endsMs,
|
endsMs,
|
||||||
region,
|
region,
|
||||||
|
game,
|
||||||
now,
|
now,
|
||||||
logged,
|
logged,
|
||||||
onToggleDay,
|
onToggleDay,
|
||||||
@@ -30,11 +31,13 @@ export function DailyChecklist({
|
|||||||
startsMs: number;
|
startsMs: number;
|
||||||
endsMs: number | null;
|
endsMs: number | null;
|
||||||
region: Region;
|
region: Region;
|
||||||
|
/** Whose reset clock the days are counted on — not every game shares one. */
|
||||||
|
game: GameId;
|
||||||
now: number;
|
now: number;
|
||||||
logged: string[];
|
logged: string[];
|
||||||
onToggleDay: (day: string) => void;
|
onToggleDay: (day: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const summary = dailySummary({ startsMs, endsMs, region, now, logged });
|
const summary = dailySummary({ startsMs, endsMs, region, game, now, logged });
|
||||||
const { days, today, doneToday, todayInWindow } = summary;
|
const { days, today, doneToday, todayInWindow } = summary;
|
||||||
const started = now >= startsMs;
|
const started = now >= startsMs;
|
||||||
|
|
||||||
@@ -43,7 +46,7 @@ export function DailyChecklist({
|
|||||||
<div className="flex items-baseline justify-between gap-3">
|
<div className="flex items-baseline justify-between gap-3">
|
||||||
<p className="eyebrow">Daily checklist</p>
|
<p className="eyebrow">Daily checklist</p>
|
||||||
<p className="tnum text-[0.6875rem] text-faint">
|
<p className="tnum text-[0.6875rem] text-faint">
|
||||||
resets in {formatRemaining(msUntilReset(now, region))}
|
resets in {formatRemaining(msUntilReset(now, region, game))}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -151,6 +151,7 @@ export function EventDetail({
|
|||||||
startsMs={clock.startsMs}
|
startsMs={clock.startsMs}
|
||||||
endsMs={clock.endsMs}
|
endsMs={clock.endsMs}
|
||||||
region={region}
|
region={region}
|
||||||
|
game={event.game}
|
||||||
now={now}
|
now={now}
|
||||||
logged={dailyDays}
|
logged={dailyDays}
|
||||||
onToggleDay={(day) => onToggleDay(event.id, day)}
|
onToggleDay={(day) => onToggleDay(event.id, day)}
|
||||||
|
|||||||
+51
-17
@@ -1,3 +1,4 @@
|
|||||||
|
import { GAMES } from "./games.ts";
|
||||||
import type { GachaEvent, GameId, Region } from "./schema.ts";
|
import type { GachaEvent, GameId, Region } from "./schema.ts";
|
||||||
import { DAY, HOUR, REGION_RESET_UTC_OFFSET } from "./time.ts";
|
import { DAY, HOUR, REGION_RESET_UTC_OFFSET } from "./time.ts";
|
||||||
|
|
||||||
@@ -113,9 +114,35 @@ export function dailyOverride(
|
|||||||
return desired === detected ? undefined : desired;
|
return desired === detected ? undefined : desired;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Offset from UTC midnight to this region's reset instant. */
|
/**
|
||||||
function shift(region: Region): number {
|
* The UTC offset of the server clock a reader's day rolls on.
|
||||||
return REGION_RESET_UTC_OFFSET[region] * HOUR - RESET_HOUR_LOCAL * HOUR;
|
*
|
||||||
|
* The reader's region is always the question; a game can just answer it
|
||||||
|
* differently. Most run a server per region and take the default. One that
|
||||||
|
* serves two regions off a single machine lists the regions that differ in
|
||||||
|
* `resetOffsets` — Endfield's European players sit on the Americas server, so
|
||||||
|
* `europe` resolves to UTC-5 there and to UTC+1 everywhere else.
|
||||||
|
*
|
||||||
|
* A blanket per-game offset would be the wrong shape: it would drag the regions
|
||||||
|
* that *do* have their own server onto somebody else's clock, which is a
|
||||||
|
* different bug in the same place.
|
||||||
|
*/
|
||||||
|
export function serverOffsetUtc(region: Region, game?: GameId): number {
|
||||||
|
const override = game === undefined ? undefined : GAMES[game].resetOffsets?.[region];
|
||||||
|
return override ?? REGION_RESET_UTC_OFFSET[region];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Offset from UTC midnight to this game's reset instant.
|
||||||
|
*
|
||||||
|
* Everything downstream of this is a **localStorage key**. Moving the reset
|
||||||
|
* hour, a region offset, or a game's own override re-labels the game-day some
|
||||||
|
* already-logged ticks fall in — at most by one day, and never by deleting one,
|
||||||
|
* but it is still the reader's streak moving under them. Treat a change here as
|
||||||
|
* a data change, not a constant.
|
||||||
|
*/
|
||||||
|
function shift(region: Region, game?: GameId): number {
|
||||||
|
return serverOffsetUtc(region, game) * HOUR - RESET_HOUR_LOCAL * HOUR;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -123,20 +150,24 @@ function shift(region: Region): number {
|
|||||||
*
|
*
|
||||||
* These are storage keys, and they are compared with `<` elsewhere in this
|
* These are storage keys, and they are compared with `<` elsewhere in this
|
||||||
* module, so the format is fixed and sortable on purpose.
|
* module, so the format is fixed and sortable on purpose.
|
||||||
|
*
|
||||||
|
* `game` is optional because a caller that has no particular game in hand — a
|
||||||
|
* generic "what day is it here?" — still gets the regional answer. Anything
|
||||||
|
* that reads or writes a tick should pass it.
|
||||||
*/
|
*/
|
||||||
export function dayKey(ms: number, region: Region): string {
|
export function dayKey(ms: number, region: Region, game?: GameId): string {
|
||||||
return new Date(ms + shift(region)).toISOString().slice(0, 10);
|
return new Date(ms + shift(region, game)).toISOString().slice(0, 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The next reset instant strictly after `ms`. */
|
/** The next reset instant strictly after `ms`. */
|
||||||
export function nextResetMs(ms: number, region: Region): number {
|
export function nextResetMs(ms: number, region: Region, game?: GameId): number {
|
||||||
const shifted = ms + shift(region);
|
const s = shift(region, game);
|
||||||
return Math.floor(shifted / DAY) * DAY + DAY - shift(region);
|
return Math.floor((ms + s) / DAY) * DAY + DAY - s;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** How long the reader has left to do today's dailies. */
|
/** How long the reader has left to do today's dailies. */
|
||||||
export function msUntilReset(ms: number, region: Region): number {
|
export function msUntilReset(ms: number, region: Region, game?: GameId): number {
|
||||||
return nextResetMs(ms, region) - ms;
|
return nextResetMs(ms, region, game) - ms;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -150,6 +181,7 @@ export function dailyDays(
|
|||||||
startsMs: number,
|
startsMs: number,
|
||||||
endsMs: number | null,
|
endsMs: number | null,
|
||||||
region: Region,
|
region: Region,
|
||||||
|
game?: GameId,
|
||||||
): string[] | null {
|
): string[] | null {
|
||||||
if (endsMs === null) return null;
|
if (endsMs === null) return null;
|
||||||
|
|
||||||
@@ -158,11 +190,11 @@ export function dailyDays(
|
|||||||
// reset: an event ending at 04:00 gives you nothing on that final day.
|
// reset: an event ending at 04:00 gives you nothing on that final day.
|
||||||
const last = endsMs - 1;
|
const last = endsMs - 1;
|
||||||
let cursor = startsMs;
|
let cursor = startsMs;
|
||||||
if (last < startsMs) return [dayKey(startsMs, region)];
|
if (last < startsMs) return [dayKey(startsMs, region, game)];
|
||||||
|
|
||||||
while (cursor <= last && out.length < MAX_DAYS) {
|
while (cursor <= last && out.length < MAX_DAYS) {
|
||||||
out.push(dayKey(cursor, region));
|
out.push(dayKey(cursor, region, game));
|
||||||
cursor = nextResetMs(cursor, region);
|
cursor = nextResetMs(cursor, region, game);
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
@@ -197,12 +229,14 @@ export function dailySummary(input: {
|
|||||||
startsMs: number;
|
startsMs: number;
|
||||||
endsMs: number | null;
|
endsMs: number | null;
|
||||||
region: Region;
|
region: Region;
|
||||||
|
/** Whose reset clock this runs on. Omitted falls back to the region's. */
|
||||||
|
game?: GameId | undefined;
|
||||||
now: number;
|
now: number;
|
||||||
logged: readonly string[];
|
logged: readonly string[];
|
||||||
}): DailySummary {
|
}): DailySummary {
|
||||||
const { startsMs, endsMs, region, now, logged } = input;
|
const { startsMs, endsMs, region, game, now, logged } = input;
|
||||||
const days = dailyDays(startsMs, endsMs, region);
|
const days = dailyDays(startsMs, endsMs, region, game);
|
||||||
const today = dayKey(now, region);
|
const today = dayKey(now, region, game);
|
||||||
const ticked = new Set(logged);
|
const ticked = new Set(logged);
|
||||||
|
|
||||||
const inWindow = days === null ? logged.slice() : days.filter((d) => ticked.has(d));
|
const inWindow = days === null ? logged.slice() : days.filter((d) => ticked.has(d));
|
||||||
@@ -220,7 +254,7 @@ export function dailySummary(input: {
|
|||||||
? null
|
? null
|
||||||
: days.filter((d) => d < today && !ticked.has(d)).length,
|
: days.filter((d) => d < today && !ticked.has(d)).length,
|
||||||
streak: streakOf(logged, today),
|
streak: streakOf(logged, today),
|
||||||
msUntilReset: msUntilReset(now, region),
|
msUntilReset: msUntilReset(now, region, game),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+25
-2
@@ -1,4 +1,4 @@
|
|||||||
import type { GameId } from "./schema.ts";
|
import type { GameId, Region } from "./schema.ts";
|
||||||
|
|
||||||
export interface GameMeta {
|
export interface GameMeta {
|
||||||
id: GameId;
|
id: GameId;
|
||||||
@@ -20,6 +20,24 @@ export interface GameMeta {
|
|||||||
* no hint at all.
|
* no hint at all.
|
||||||
*/
|
*/
|
||||||
dailyTasks: string;
|
dailyTasks: string;
|
||||||
|
/**
|
||||||
|
* Server clock offsets that differ from the regional default, per region.
|
||||||
|
*
|
||||||
|
* Not every game runs one server per region. Where a game serves two of our
|
||||||
|
* regions off a single machine, the reader's region is still the right
|
||||||
|
* question — it just gets a different answer for that game than
|
||||||
|
* `REGION_RESET_UTC_OFFSET` gives.
|
||||||
|
*
|
||||||
|
* Deliberately a sparse override rather than a full table: listing only the
|
||||||
|
* regions that actually differ keeps the diff to the fact that changed, and
|
||||||
|
* a region absent here keeps the default answer it has always had.
|
||||||
|
*
|
||||||
|
* This feeds `dayKey`, which is a **localStorage key**. Adding or changing an
|
||||||
|
* entry re-labels the game-day some already-logged ticks fall in, for readers
|
||||||
|
* in that region only — see `src/shared/daily.ts` § shift and
|
||||||
|
* docs/DATA-MODEL.md.
|
||||||
|
*/
|
||||||
|
resetOffsets?: Partial<Record<Region, number>> | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const GAMES: Record<GameId, GameMeta> = {
|
export const GAMES: Record<GameId, GameMeta> = {
|
||||||
@@ -28,7 +46,12 @@ export const GAMES: Record<GameId, GameMeta> = {
|
|||||||
zzz: { id: "zzz", name: "Zenless Zone Zero", short: "ZZZ", hue: "#F2A03D" , studio: "HoYoverse", dailyTasks: "Daily missions, battery" },
|
zzz: { id: "zzz", name: "Zenless Zone Zero", short: "ZZZ", hue: "#F2A03D" , studio: "HoYoverse", dailyTasks: "Daily missions, battery" },
|
||||||
wuwa: { id: "wuwa", name: "Wuthering Waves", short: "Wuwa", hue: "#3DD6A0" , studio: "Kuro Games", dailyTasks: "Daily activity, waveplate" },
|
wuwa: { id: "wuwa", name: "Wuthering Waves", short: "Wuwa", hue: "#3DD6A0" , studio: "Kuro Games", dailyTasks: "Daily activity, waveplate" },
|
||||||
arknights: { id: "arknights", name: "Arknights", short: "Arknights", hue: "#9AA3B8" , studio: "Hypergryph", dailyTasks: "Daily missions, sanity" },
|
arknights: { id: "arknights", name: "Arknights", short: "Arknights", hue: "#9AA3B8" , studio: "Hypergryph", dailyTasks: "Daily missions, sanity" },
|
||||||
endfield: { id: "endfield", name: "Arknights: Endfield", short: "Endfield", hue: "#E8635A" , studio: "Hypergryph", dailyTasks: "Daily missions" },
|
// Endfield has two server groups, not three: Europe is served off the same
|
||||||
|
// machine as the Americas, on a fixed UTC-5. So a European player's day rolls
|
||||||
|
// at 09:00 UTC — 11:00 in Copenhagen in summer, 10:00 in winter — six hours
|
||||||
|
// after the HoYo/Kuro pattern above. Asia has its own server and is unchanged,
|
||||||
|
// and `america` already resolves to -5, so Europe is the only real override.
|
||||||
|
endfield: { id: "endfield", name: "Arknights: Endfield", short: "Endfield", hue: "#E8635A" , studio: "Hypergryph", dailyTasks: "Daily missions", resetOffsets: { europe: -5 } },
|
||||||
nte: { id: "nte", name: "Neverness to Everness", short: "NTE", hue: "#C77DFF" , studio: "Hotta Studio", dailyTasks: "Daily tasks" },
|
nte: { id: "nte", name: "Neverness to Everness", short: "NTE", hue: "#C77DFF" , studio: "Hotta Studio", dailyTasks: "Daily tasks" },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -133,6 +133,86 @@ describe("dayKey", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("a game whose server map differs from the default", () => {
|
||||||
|
// Endfield has two server groups, not three: Europe is served off the
|
||||||
|
// Americas machine on a fixed UTC-5, so a European player's 04:00 reset is
|
||||||
|
// 09:00 UTC — six hours after the HoYo/Kuro Europe pattern. Asia has its own
|
||||||
|
// server and is unaffected.
|
||||||
|
const morning = at("2026-08-16T08:00:00Z");
|
||||||
|
|
||||||
|
test("the override beats the regional default for that region", () => {
|
||||||
|
expect(dayKey(morning, "europe", "endfield")).toBe("2026-08-15");
|
||||||
|
expect(dayKey(morning, "europe", "genshin")).toBe("2026-08-16");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rolls exactly on 09:00 UTC in Europe", () => {
|
||||||
|
expect(dayKey(at("2026-08-16T08:59:59Z"), "europe", "endfield")).toBe(
|
||||||
|
"2026-08-15",
|
||||||
|
);
|
||||||
|
expect(dayKey(at("2026-08-16T09:00:00Z"), "europe", "endfield")).toBe(
|
||||||
|
"2026-08-16",
|
||||||
|
);
|
||||||
|
expect(nextResetMs(morning, "europe", "endfield")).toBe(
|
||||||
|
at("2026-08-16T09:00:00Z"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a region with its own server keeps the answer it always had", () => {
|
||||||
|
// The override is per region on purpose. A blanket per-game offset would
|
||||||
|
// drag Asia — which genuinely has its own Endfield server — onto the
|
||||||
|
// Americas clock, moving a reader's already-logged day keys by eleven
|
||||||
|
// hours to fix a bug they never had.
|
||||||
|
for (const region of ["asia", "america"] as const) {
|
||||||
|
for (const hour of [0, 6, 9, 14, 20, 23]) {
|
||||||
|
const t = at(`2026-08-16T${String(hour).padStart(2, "0")}:30:00Z`);
|
||||||
|
expect(dayKey(t, region, "endfield")).toBe(dayKey(t, region));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a game with no override still follows its region", () => {
|
||||||
|
// Europe is UTC+1, so 03:00 UTC. Asia rolled six hours before that.
|
||||||
|
const instant = at("2026-08-16T02:00:00Z");
|
||||||
|
expect(dayKey(instant, "europe", "genshin")).toBe("2026-08-15");
|
||||||
|
expect(dayKey(instant, "asia", "genshin")).toBe("2026-08-16");
|
||||||
|
// …and matches the answer given with no game at all.
|
||||||
|
expect(dayKey(instant, "europe", "genshin")).toBe(dayKey(instant, "europe"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the checklist counts days on the clock that reader is actually on", () => {
|
||||||
|
const start = at("2026-08-16T09:00:00Z"); // an endfield europe reset
|
||||||
|
expect(dailyDays(start, start + 3 * DAY, "europe", "endfield")).toEqual([
|
||||||
|
"2026-08-16",
|
||||||
|
"2026-08-17",
|
||||||
|
"2026-08-18",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const summary = dailySummary({
|
||||||
|
startsMs: start,
|
||||||
|
endsMs: start + 3 * DAY,
|
||||||
|
region: "europe",
|
||||||
|
game: "endfield",
|
||||||
|
now: at("2026-08-17T08:00:00Z"), // still the 16th on a UTC-5 server
|
||||||
|
logged: [],
|
||||||
|
});
|
||||||
|
expect(summary.today).toBe("2026-08-16");
|
||||||
|
expect(summary.msUntilReset).toBe(HOUR);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an Asia reader's checklist is byte-identical to before the override", () => {
|
||||||
|
// The regression this guards: an Endfield event whose end lands between the
|
||||||
|
// two candidate resets loses its final claimable day if the wrong clock is
|
||||||
|
// used, and a day that leaves `dailyDays` can never be ticked or untucked
|
||||||
|
// again — the pip is simply not rendered.
|
||||||
|
const start = at("2026-08-06T04:00:00Z");
|
||||||
|
const end = at("2026-08-19T22:00:00Z");
|
||||||
|
expect(dailyDays(start, end, "asia", "endfield")).toEqual(
|
||||||
|
dailyDays(start, end, "asia"),
|
||||||
|
);
|
||||||
|
expect(dailyDays(start, end, "asia", "endfield")).toContain("2026-08-20");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("nextResetMs", () => {
|
describe("nextResetMs", () => {
|
||||||
test("is the next reset strictly after the instant given", () => {
|
test("is the next reset strictly after the instant given", () => {
|
||||||
const reset = at("2026-08-15T20:00:00Z"); // asia
|
const reset = at("2026-08-15T20:00:00Z"); // asia
|
||||||
|
|||||||
Reference in New Issue
Block a user