mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-10-01 15:48:23 -05:00
Move app to apps/web-react in pnpm workspace layout
This commit is contained in:
67
apps/web-react/app/modules/cache/index.test.ts
vendored
Normal file
67
apps/web-react/app/modules/cache/index.test.ts
vendored
Normal file
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { LRUCache } from "./index";
|
||||
|
||||
describe("LRUCache", () => {
|
||||
test("stores and retrieves values", () => {
|
||||
const cache = new LRUCache<string, number>({ max: 2 });
|
||||
cache.set("a", 1);
|
||||
|
||||
expect(cache.get("a")).toBe(1);
|
||||
expect(cache.has("a")).toBe(true);
|
||||
});
|
||||
|
||||
test("returns undefined for missing keys", () => {
|
||||
const cache = new LRUCache<string, number>({ max: 2 });
|
||||
|
||||
expect(cache.get("missing")).toBeUndefined();
|
||||
expect(cache.has("missing")).toBe(false);
|
||||
});
|
||||
|
||||
test("evicts the least recently used entry once over capacity", () => {
|
||||
const cache = new LRUCache<string, number>({ max: 2 });
|
||||
cache.set("a", 1);
|
||||
cache.set("b", 2);
|
||||
cache.set("c", 3);
|
||||
|
||||
expect(cache.has("a")).toBe(false);
|
||||
expect(cache.get("b")).toBe(2);
|
||||
expect(cache.get("c")).toBe(3);
|
||||
});
|
||||
|
||||
test("counts reads as recent use", () => {
|
||||
const cache = new LRUCache<string, number>({ max: 2 });
|
||||
cache.set("a", 1);
|
||||
cache.set("b", 2);
|
||||
cache.get("a");
|
||||
cache.set("c", 3);
|
||||
|
||||
expect(cache.has("a")).toBe(true);
|
||||
expect(cache.has("b")).toBe(false);
|
||||
});
|
||||
|
||||
test("updates value without growing past capacity on re-set", () => {
|
||||
const cache = new LRUCache<string, number>({ max: 2 });
|
||||
cache.set("a", 1);
|
||||
cache.set("a", 2);
|
||||
|
||||
expect(cache.get("a")).toBe(2);
|
||||
});
|
||||
|
||||
test("deletes a single entry", () => {
|
||||
const cache = new LRUCache<string, number>({ max: 2 });
|
||||
cache.set("a", 1);
|
||||
cache.delete("a");
|
||||
|
||||
expect(cache.has("a")).toBe(false);
|
||||
});
|
||||
|
||||
test("clears all entries", () => {
|
||||
const cache = new LRUCache<string, number>({ max: 2 });
|
||||
cache.set("a", 1);
|
||||
cache.set("b", 2);
|
||||
cache.clear();
|
||||
|
||||
expect(cache.has("a")).toBe(false);
|
||||
expect(cache.has("b")).toBe(false);
|
||||
});
|
||||
});
|
||||
48
apps/web-react/app/modules/cache/index.ts
vendored
Normal file
48
apps/web-react/app/modules/cache/index.ts
vendored
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* A lightweight least-recently-used cache backed by a `Map`. Once `max` entries
|
||||
* are stored, inserting a new key evicts the least recently used one. Reading a
|
||||
* key via `get` marks it as most recently used.
|
||||
*/
|
||||
export class LRUCache<K, V> {
|
||||
private readonly max: number;
|
||||
private readonly map = new Map<K, V>();
|
||||
|
||||
constructor({ max }: { max: number }) {
|
||||
this.max = max;
|
||||
}
|
||||
|
||||
get(key: K): V | undefined {
|
||||
if (!this.map.has(key)) return undefined;
|
||||
|
||||
const value = this.map.get(key) as V;
|
||||
this.map.delete(key);
|
||||
this.map.set(key, value);
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
has(key: K): boolean {
|
||||
return this.map.has(key);
|
||||
}
|
||||
|
||||
set(key: K, value: V): void {
|
||||
if (this.map.has(key)) {
|
||||
this.map.delete(key);
|
||||
}
|
||||
|
||||
this.map.set(key, value);
|
||||
|
||||
if (this.map.size > this.max) {
|
||||
const oldest = this.map.keys().next().value as K;
|
||||
this.map.delete(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
delete(key: K): void {
|
||||
this.map.delete(key);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.map.clear();
|
||||
}
|
||||
}
|
||||
50
apps/web-react/app/modules/csv.test.ts
Normal file
50
apps/web-react/app/modules/csv.test.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import * as CSV from "./csv";
|
||||
|
||||
describe("CSV.serialize", () => {
|
||||
test("joins cells with commas and rows with CRLF", () => {
|
||||
expect(
|
||||
CSV.serialize([
|
||||
["a", "b"],
|
||||
["c", "d"],
|
||||
]),
|
||||
).toBe("a,b\r\nc,d");
|
||||
});
|
||||
|
||||
test("quotes cells containing the delimiter", () => {
|
||||
expect(CSV.serialize([["a,b", "c"]])).toBe('"a,b",c');
|
||||
});
|
||||
|
||||
test("escapes quotes by doubling them", () => {
|
||||
expect(CSV.serialize([['say "hi"']])).toBe('"say ""hi"""');
|
||||
});
|
||||
|
||||
test("quotes cells containing line breaks", () => {
|
||||
expect(CSV.serialize([["line1\nline2"]])).toBe('"line1\nline2"');
|
||||
expect(CSV.serialize([["line1\rline2"]])).toBe('"line1\rline2"');
|
||||
});
|
||||
|
||||
test("leaves plain cells unquoted", () => {
|
||||
expect(CSV.serialize([["plain", "text"]])).toBe("plain,text");
|
||||
});
|
||||
|
||||
test("handles empty input", () => {
|
||||
expect(CSV.serialize([])).toBe("");
|
||||
});
|
||||
|
||||
test("neutralizes formula injection by prefixing a quote", () => {
|
||||
expect(CSV.serialize([["=1+1"]])).toBe("'=1+1");
|
||||
expect(CSV.serialize([["+1"]])).toBe("'+1");
|
||||
expect(CSV.serialize([["@SUM(A1)"]])).toBe("'@SUM(A1)");
|
||||
expect(CSV.serialize([["=HYPERLINK(1,2)"]])).toBe('"\'=HYPERLINK(1,2)"');
|
||||
});
|
||||
|
||||
test("does not treat negative numbers as formulas", () => {
|
||||
expect(CSV.serialize([["-5"]])).toBe("-5");
|
||||
expect(CSV.serialize([["-5.5"]])).toBe("-5.5");
|
||||
});
|
||||
|
||||
test("guards a leading minus that is not numeric", () => {
|
||||
expect(CSV.serialize([["-cmd"]])).toBe("'-cmd");
|
||||
});
|
||||
});
|
||||
69
apps/web-react/app/modules/csv.ts
Normal file
69
apps/web-react/app/modules/csv.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
const DELIMITER = ",";
|
||||
const ROW_SEPARATOR = "\r\n";
|
||||
const QUOTE = '"';
|
||||
const CHARACTERS_REQUIRING_QUOTING = [DELIMITER, QUOTE, "\n", "\r"];
|
||||
|
||||
const FORMULA_PREFIX = "'";
|
||||
const FORMULA_TRIGGERS = ["=", "+", "@", "\t", "\r"];
|
||||
|
||||
/**
|
||||
* Byte order mark to prepend when writing the CSV to a file, so that Excel
|
||||
* reads the bytes as UTF-8 and renders non-ASCII characters (e.g. Japanese or
|
||||
* accented names) correctly.
|
||||
*/
|
||||
export const BOM = "\uFEFF";
|
||||
|
||||
/**
|
||||
* Serializes a two-dimensional array of cell values into an RFC 4180 compliant
|
||||
* CSV string. Cells are quoted only when they contain a delimiter, quote or
|
||||
* line break, and any quotes inside a cell are escaped by doubling them.
|
||||
*
|
||||
* Cells whose value could be interpreted as a formula by spreadsheet software
|
||||
* (a "CSV injection") are prefixed with a single quote, which neutralizes the
|
||||
* formula while keeping the value readable. This matters because the input may
|
||||
* be user-controlled (team names, usernames, ...).
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* serialize([
|
||||
* ["name", "note"],
|
||||
* ["Sendou", 'say "hi"'],
|
||||
* ["Test", "a,b"],
|
||||
* ]);
|
||||
* // name,note\r\nSendou,"say ""hi"""\r\nTest,"a,b"
|
||||
* ```
|
||||
*/
|
||||
export function serialize(rows: ReadonlyArray<ReadonlyArray<string>>): string {
|
||||
return rows.map(serializeRow).join(ROW_SEPARATOR);
|
||||
}
|
||||
|
||||
function serializeRow(row: ReadonlyArray<string>): string {
|
||||
return row.map(serializeCell).join(DELIMITER);
|
||||
}
|
||||
|
||||
function serializeCell(value: string): string {
|
||||
const safeValue = isFormulaInjectionRisk(value)
|
||||
? `${FORMULA_PREFIX}${value}`
|
||||
: value;
|
||||
|
||||
const needsQuoting = CHARACTERS_REQUIRING_QUOTING.some((character) =>
|
||||
safeValue.includes(character),
|
||||
);
|
||||
if (!needsQuoting) return safeValue;
|
||||
|
||||
return `${QUOTE}${safeValue.replaceAll(QUOTE, `${QUOTE}${QUOTE}`)}${QUOTE}`;
|
||||
}
|
||||
|
||||
function isFormulaInjectionRisk(value: string): boolean {
|
||||
const firstCharacter = value[0];
|
||||
if (!firstCharacter) return false;
|
||||
if (FORMULA_TRIGGERS.includes(firstCharacter)) return true;
|
||||
// "-" can legitimately begin a negative number, so only guard it when the
|
||||
// value isn't numeric and could therefore be read as a formula
|
||||
if (firstCharacter === "-") return !isNumeric(value);
|
||||
return false;
|
||||
}
|
||||
|
||||
function isNumeric(value: string): boolean {
|
||||
return value.trim() !== "" && Number.isFinite(Number(value));
|
||||
}
|
||||
90
apps/web-react/app/modules/discord-webhook.server.ts
Normal file
90
apps/web-react/app/modules/discord-webhook.server.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { logger } from "~/utils/logger";
|
||||
import { SENDOU_INK_BASE_URL, userAdminPage, userPage } from "~/utils/urls";
|
||||
|
||||
const EMBED_FIELD_VALUE_MAX_LENGTH = 1000;
|
||||
|
||||
interface ModWebhookEmbed {
|
||||
title: string;
|
||||
fields: Array<{ name: string; value: string }>;
|
||||
}
|
||||
|
||||
export interface WebhookUser {
|
||||
username: string;
|
||||
discordId: string;
|
||||
customUrl: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Posts a rich embed to the mod channel Discord webhook.
|
||||
* Fire-and-forget: meant to be called without awaiting, never throws, skipped with a log
|
||||
* line when `MOD_DISCORD_WEBHOOK_URL` is unset (e.g. in development).
|
||||
*/
|
||||
export function sendModDiscordWebhook(embed: ModWebhookEmbed) {
|
||||
sendDiscordWebhook("MOD_DISCORD_WEBHOOK_URL", embed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Posts a rich embed to the SendouQ canceled matches channel Discord webhook.
|
||||
* Fire-and-forget (see {@link sendModDiscordWebhook}).
|
||||
*/
|
||||
export function sendSQCancelDiscordWebhook(embed: ModWebhookEmbed) {
|
||||
sendDiscordWebhook("SQ_CANCEL_DISCORD_WEBHOOK_URL", embed);
|
||||
}
|
||||
|
||||
function sendDiscordWebhook(
|
||||
envVarName: "MOD_DISCORD_WEBHOOK_URL" | "SQ_CANCEL_DISCORD_WEBHOOK_URL",
|
||||
embed: ModWebhookEmbed,
|
||||
) {
|
||||
const webhookUrl = process.env[envVarName];
|
||||
if (!webhookUrl) {
|
||||
logger.info(`${envVarName} not set, skipping Discord webhook`);
|
||||
return;
|
||||
}
|
||||
|
||||
const body = {
|
||||
embeds: [{ ...embed, timestamp: new Date().toISOString() }],
|
||||
allowed_mentions: { parse: [] },
|
||||
};
|
||||
|
||||
fetch(webhookUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
logger.error(
|
||||
`Discord webhook responded with status ${response.status}`,
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.error("Failed to send Discord webhook", error);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Escapes then truncates free-text user input so it fits inside a Discord embed field
|
||||
* value without breaking the embed's markdown formatting.
|
||||
*/
|
||||
export function truncateEmbedValue(text: string) {
|
||||
const escaped = escapeMarkdown(text);
|
||||
if (escaped.length <= EMBED_FIELD_VALUE_MAX_LENGTH) return escaped;
|
||||
|
||||
return `${escaped.slice(0, EMBED_FIELD_VALUE_MAX_LENGTH)}…`;
|
||||
}
|
||||
|
||||
/** Markdown link to the user's admin tab (moderation actions & notes). */
|
||||
export function userAdminPageLink(user: WebhookUser) {
|
||||
return `[${escapeMarkdown(user.username)}](${SENDOU_INK_BASE_URL}${userAdminPage(user)})`;
|
||||
}
|
||||
|
||||
/** Markdown link to the user's profile page. */
|
||||
export function userPageLink(user: WebhookUser) {
|
||||
return `[${escapeMarkdown(user.username)}](${SENDOU_INK_BASE_URL}${userPage(user)})`;
|
||||
}
|
||||
|
||||
/** Backslash-escapes Discord markdown so user text can't forge links or break formatting. */
|
||||
export function escapeMarkdown(text: string) {
|
||||
return text.replace(/[\\`*_~|()[\]]/g, "\\$&");
|
||||
}
|
||||
100
apps/web-react/app/modules/i18n/InitialI18nStore.tsx
Normal file
100
apps/web-react/app/modules/i18n/InitialI18nStore.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import type { i18n } from "i18next";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMatches } from "react-router";
|
||||
import { DEFAULT_LANGUAGE } from "./config";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__initialI18nStore?: Record<string, Record<string, object>>;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Embeds the translation bundles the server rendered with (the page language
|
||||
* plus the English fallback) into the HTML so client-side i18next can
|
||||
* initialize from them instead of fetching every initial namespace over HTTP
|
||||
* before hydration can start.
|
||||
*/
|
||||
export function InitialI18nStore({ locale }: { locale: string }) {
|
||||
const { i18n } = useTranslation();
|
||||
const matches = useMatches();
|
||||
|
||||
// computed once so client-side navigations don't re-serialize the store;
|
||||
// on hydration the embedded store round-trips to the exact server markup
|
||||
const [scriptHtml] = React.useState(() => {
|
||||
const store = initialStore(i18n, locale, namespacesFromMatches(matches));
|
||||
|
||||
return `window.__initialI18nStore=${JSON.stringify(store).replace(
|
||||
/</g,
|
||||
"\\u003c",
|
||||
)}`;
|
||||
});
|
||||
|
||||
return (
|
||||
<script
|
||||
suppressHydrationWarning
|
||||
dangerouslySetInnerHTML={{ __html: scriptHtml }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function namespacesFromMatches(matches: Array<{ handle: unknown }>): string[] {
|
||||
const namespaces = matches.flatMap((match) => {
|
||||
const handle = match.handle;
|
||||
if (typeof handle !== "object" || handle === null) return [];
|
||||
if (!("i18n" in handle)) return [];
|
||||
const value = (handle as { i18n: unknown }).i18n;
|
||||
if (typeof value === "string") return [value];
|
||||
if (
|
||||
Array.isArray(value) &&
|
||||
value.every((item) => typeof item === "string")
|
||||
) {
|
||||
return value as string[];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
return [...new Set(namespaces)];
|
||||
}
|
||||
|
||||
function initialStore(
|
||||
i18nInstance: i18n,
|
||||
locale: string,
|
||||
namespaces: string[],
|
||||
): Record<string, Record<string, object>> {
|
||||
if (typeof window !== "undefined" && window.__initialI18nStore) {
|
||||
return window.__initialI18nStore;
|
||||
}
|
||||
|
||||
const languages =
|
||||
locale === DEFAULT_LANGUAGE ? [locale] : [locale, DEFAULT_LANGUAGE];
|
||||
|
||||
const store: Record<string, Record<string, object>> = {};
|
||||
for (const language of languages) {
|
||||
const bundles: Record<string, object> = {};
|
||||
for (const namespace of namespaces) {
|
||||
const bundle = i18nInstance.getResourceBundle(language, namespace);
|
||||
if (bundle) {
|
||||
bundles[namespace] = withoutUntranslated(bundle);
|
||||
}
|
||||
}
|
||||
store[language] = bundles;
|
||||
}
|
||||
|
||||
return store;
|
||||
}
|
||||
|
||||
function withoutUntranslated(value: object): object {
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const [key, child] of Object.entries(value)) {
|
||||
if (child === "") continue;
|
||||
if (typeof child === "object" && child !== null && !Array.isArray(child)) {
|
||||
result[key] = withoutUntranslated(child);
|
||||
} else {
|
||||
result[key] = child;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
145
apps/web-react/app/modules/i18n/config.ts
Normal file
145
apps/web-react/app/modules/i18n/config.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
export const DEFAULT_LANGUAGE = "en";
|
||||
|
||||
export const languages = [
|
||||
{
|
||||
code: "da",
|
||||
name: "Dansk",
|
||||
},
|
||||
{
|
||||
code: "de",
|
||||
name: "Deutsch",
|
||||
},
|
||||
{
|
||||
code: "en",
|
||||
name: "English",
|
||||
},
|
||||
{
|
||||
code: "es-ES",
|
||||
name: "Español (España)",
|
||||
},
|
||||
{
|
||||
code: "es-US",
|
||||
name: "Español (Latino)",
|
||||
},
|
||||
{
|
||||
code: "fr-CA",
|
||||
name: "Français (NoA)",
|
||||
},
|
||||
{
|
||||
code: "fr-EU",
|
||||
name: "Français (NoE)",
|
||||
},
|
||||
{
|
||||
code: "he",
|
||||
name: "עברית",
|
||||
},
|
||||
{
|
||||
code: "it",
|
||||
name: "Italiano",
|
||||
},
|
||||
{
|
||||
code: "ja",
|
||||
name: "日本語",
|
||||
},
|
||||
{
|
||||
code: "ko",
|
||||
name: "한국어",
|
||||
},
|
||||
{
|
||||
code: "pl",
|
||||
name: "Polski",
|
||||
},
|
||||
{
|
||||
code: "pt-BR",
|
||||
name: "Português do Brasil",
|
||||
},
|
||||
{
|
||||
code: "nl",
|
||||
name: "Nederlands",
|
||||
},
|
||||
{
|
||||
code: "ru",
|
||||
name: "Русский",
|
||||
},
|
||||
{
|
||||
code: "zh",
|
||||
name: "中文",
|
||||
},
|
||||
] as const;
|
||||
|
||||
export const languagesUnified = [
|
||||
{
|
||||
code: "da",
|
||||
name: "Dansk",
|
||||
},
|
||||
{
|
||||
code: "de",
|
||||
name: "Deutsch",
|
||||
},
|
||||
{
|
||||
code: "en",
|
||||
name: "English",
|
||||
},
|
||||
{
|
||||
code: "es",
|
||||
name: "Español",
|
||||
},
|
||||
{
|
||||
code: "fr",
|
||||
name: "Français",
|
||||
},
|
||||
{
|
||||
code: "he",
|
||||
name: "עברית",
|
||||
},
|
||||
{
|
||||
code: "it",
|
||||
name: "Italiano",
|
||||
},
|
||||
{
|
||||
code: "ja",
|
||||
name: "日本語",
|
||||
},
|
||||
{
|
||||
code: "ko",
|
||||
name: "한국어",
|
||||
},
|
||||
{
|
||||
code: "pl",
|
||||
name: "Polski",
|
||||
},
|
||||
{
|
||||
code: "pt",
|
||||
name: "Português",
|
||||
},
|
||||
{
|
||||
code: "nl",
|
||||
name: "Nederlands",
|
||||
},
|
||||
{
|
||||
code: "ru",
|
||||
name: "Русский",
|
||||
},
|
||||
{
|
||||
code: "zh",
|
||||
name: "中文(普通话)",
|
||||
},
|
||||
{
|
||||
code: "yue",
|
||||
name: "中文(廣東話)",
|
||||
},
|
||||
] as const;
|
||||
|
||||
export type LanguageCode = (typeof languages)[number]["code"];
|
||||
export type UnifiedLanguageCode = (typeof languagesUnified)[number]["code"];
|
||||
|
||||
export const config = {
|
||||
supportedLngs: languages.map((lang) => lang.code),
|
||||
fallbackLng: DEFAULT_LANGUAGE,
|
||||
defaultNS: "common",
|
||||
react: { useSuspense: false },
|
||||
interpolation: {
|
||||
escapeValue: false,
|
||||
},
|
||||
returnEmptyString: false,
|
||||
};
|
||||
149
apps/web-react/app/modules/i18n/i18next.server.ts
Normal file
149
apps/web-react/app/modules/i18n/i18next.server.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { AsyncLocalStorage } from "node:async_hooks";
|
||||
import {
|
||||
createInstance,
|
||||
type DefaultNamespace,
|
||||
type FlatNamespace,
|
||||
type i18n,
|
||||
type Namespace,
|
||||
type TFunction,
|
||||
} from "i18next";
|
||||
import { type FallbackNs, initReactI18next } from "react-i18next";
|
||||
import {
|
||||
createCookie,
|
||||
type MiddlewareFunction,
|
||||
type RouterContextProvider,
|
||||
} from "react-router";
|
||||
import { createI18nextMiddleware } from "remix-i18next";
|
||||
import invariant from "~/utils/invariant";
|
||||
import { config } from "./config";
|
||||
import { resources } from "./resources.server";
|
||||
|
||||
const TEN_YEARS_IN_SECONDS = 31_536_000 * 10;
|
||||
|
||||
export const i18nCookie = createCookie("i18n", {
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
maxAge: TEN_YEARS_IN_SECONDS,
|
||||
});
|
||||
|
||||
const [remixI18nextMiddleware, getLocaleFromContext, getInstanceFromContext] =
|
||||
createI18nextMiddleware({
|
||||
detection: {
|
||||
cookie: i18nCookie,
|
||||
supportedLanguages: config.supportedLngs,
|
||||
fallbackLanguage: config.fallbackLng,
|
||||
},
|
||||
i18next: {
|
||||
...config,
|
||||
resources,
|
||||
},
|
||||
plugins: [initReactI18next],
|
||||
});
|
||||
|
||||
interface I18nStore {
|
||||
locale: string;
|
||||
instance: i18n;
|
||||
}
|
||||
|
||||
const i18nAsyncLocalStorage = new AsyncLocalStorage<I18nStore>();
|
||||
|
||||
/**
|
||||
* Detects the request locale, initializes a request-scoped i18next instance and
|
||||
* makes both available to server helpers ({@link getLocale},
|
||||
* {@link getServerTFunction}) for the duration of the request.
|
||||
*/
|
||||
export const i18nMiddleware: MiddlewareFunction<Response> = (args, next) =>
|
||||
remixI18nextMiddleware(args, () =>
|
||||
i18nAsyncLocalStorage.run(
|
||||
{
|
||||
locale: getLocaleFromContext(args.context),
|
||||
instance: getInstanceFromContext(args.context),
|
||||
},
|
||||
() => next(),
|
||||
),
|
||||
);
|
||||
|
||||
/** The locale detected for the current request. */
|
||||
export function getLocale(): string {
|
||||
return currentStore().locale;
|
||||
}
|
||||
|
||||
/**
|
||||
* A `TFunction` bound to the current request's locale and the given namespaces.
|
||||
* Namespaces are already available in-memory so no async loading is needed.
|
||||
*/
|
||||
export function getServerTFunction<
|
||||
N extends
|
||||
| FlatNamespace
|
||||
| readonly [FlatNamespace, ...FlatNamespace[]] = DefaultNamespace,
|
||||
>(namespaces?: N): TFunction<FallbackNs<N>> {
|
||||
const { instance, locale } = currentStore();
|
||||
return instance.getFixedT(
|
||||
locale,
|
||||
namespaces as Namespace,
|
||||
) as unknown as TFunction<FallbackNs<N>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The request-scoped i18next instance, used to provide translations while
|
||||
* server-side rendering. Reads from the React Router context so it can be called
|
||||
* from `entry.server` where the request context is passed explicitly.
|
||||
*
|
||||
* Falls back to a shared default-language instance when the context is missing.
|
||||
* This happens when the render is an error boundary for a request whose
|
||||
* middleware chain threw before {@link i18nMiddleware} ran: without the fallback
|
||||
* the missing context would throw a second error that masks the original one.
|
||||
*/
|
||||
export function getI18nInstance(
|
||||
context: Readonly<RouterContextProvider>,
|
||||
): i18n {
|
||||
try {
|
||||
return getInstanceFromContext(context);
|
||||
} catch {
|
||||
return fallbackI18nInstance();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A `TFunction` bound to a fixed language, independent of the current request.
|
||||
* Use this when translating outside of a request (e.g. notifications) or when a
|
||||
* specific language is required regardless of the user's locale.
|
||||
*/
|
||||
export async function getFixedTForLanguage<
|
||||
N extends
|
||||
| FlatNamespace
|
||||
| readonly [FlatNamespace, ...FlatNamespace[]] = DefaultNamespace,
|
||||
>(language: string, namespaces?: N): Promise<TFunction<FallbackNs<N>>> {
|
||||
const instance = createInstance();
|
||||
await instance.init({ ...config, resources, lng: language });
|
||||
return instance.getFixedT(
|
||||
language,
|
||||
namespaces as Namespace,
|
||||
) as unknown as TFunction<FallbackNs<N>>;
|
||||
}
|
||||
|
||||
function currentStore(): I18nStore {
|
||||
const store = i18nAsyncLocalStorage.getStore();
|
||||
invariant(store, "i18n store not found, is i18nMiddleware registered?");
|
||||
return store;
|
||||
}
|
||||
|
||||
let sharedFallbackInstance: i18n | undefined;
|
||||
|
||||
function fallbackI18nInstance(): i18n {
|
||||
if (!sharedFallbackInstance) {
|
||||
const instance = createInstance();
|
||||
instance.use(initReactI18next);
|
||||
// initAsync: false makes init synchronous, which is safe here since
|
||||
// resources are already in-memory and lets this be called from the sync
|
||||
// getI18nInstance without awaiting.
|
||||
instance.init({
|
||||
...config,
|
||||
resources,
|
||||
lng: config.fallbackLng,
|
||||
initAsync: false,
|
||||
});
|
||||
sharedFallbackInstance = instance;
|
||||
}
|
||||
return sharedFallbackInstance;
|
||||
}
|
||||
65
apps/web-react/app/modules/i18n/loader.ts
Normal file
65
apps/web-react/app/modules/i18n/loader.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import i18next from "i18next";
|
||||
import LanguageDetector from "i18next-browser-languagedetector";
|
||||
import Backend from "i18next-http-backend";
|
||||
import { initReactI18next } from "react-i18next";
|
||||
import { config } from "./config";
|
||||
|
||||
export function i18nLoader() {
|
||||
return i18next
|
||||
.use(initReactI18next)
|
||||
.use(LanguageDetector)
|
||||
.use(Backend)
|
||||
.init({
|
||||
...config,
|
||||
ns: getInitialNamespaces(),
|
||||
// the SSR HTML embeds the initial namespaces (see InitialI18nStore) so
|
||||
// init needs no HTTP round-trips before hydration; anything not embedded
|
||||
// still loads via the backend thanks to partialBundledLanguages
|
||||
resources: window.__initialI18nStore,
|
||||
partialBundledLanguages: true,
|
||||
backend: {
|
||||
loadPath: (lng: any, ns: any) => {
|
||||
// use vite static asset fingerprinting
|
||||
return new URL(
|
||||
`../../../locales/${lng[0]}/${ns[0]}.json`,
|
||||
import.meta.url,
|
||||
).href;
|
||||
},
|
||||
},
|
||||
detection: {
|
||||
order: ["htmlTag"],
|
||||
caches: [],
|
||||
},
|
||||
// without this hydration fails in E2E tests
|
||||
initAsync: false,
|
||||
});
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__reactRouterRouteModules: Record<string, { handle?: unknown } | undefined>;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the list of namespaces used by the application server-side so they can be
|
||||
* set on i18next init options and preloaded before hydration.
|
||||
*
|
||||
* Vendored from remix-i18next (removed in v8).
|
||||
*/
|
||||
function getInitialNamespaces(): string[] {
|
||||
return Object.values(window.__reactRouterRouteModules).flatMap((route) => {
|
||||
const handle = route?.handle;
|
||||
if (typeof handle !== "object" || handle === null) return [];
|
||||
if (!("i18n" in handle)) return [];
|
||||
const namespaces = (handle as { i18n: unknown }).i18n;
|
||||
if (typeof namespaces === "string") return [namespaces];
|
||||
if (
|
||||
Array.isArray(namespaces) &&
|
||||
namespaces.every((value) => typeof value === "string")
|
||||
) {
|
||||
return namespaces as string[];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
}
|
||||
57
apps/web-react/app/modules/i18n/resources.browser.ts
Normal file
57
apps/web-react/app/modules/i18n/resources.browser.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import analyzer from "../../../locales/en/analyzer.json";
|
||||
import art from "../../../locales/en/art.json";
|
||||
import badges from "../../../locales/en/badges.json";
|
||||
import builds from "../../../locales/en/builds.json";
|
||||
import calendar from "../../../locales/en/calendar.json";
|
||||
import common from "../../../locales/en/common.json";
|
||||
import contributions from "../../../locales/en/contributions.json";
|
||||
import faq from "../../../locales/en/faq.json";
|
||||
import forms from "../../../locales/en/forms.json";
|
||||
import front from "../../../locales/en/front.json";
|
||||
import gameBadges from "../../../locales/en/game-badges.json";
|
||||
import gameMisc from "../../../locales/en/game-misc.json";
|
||||
import gear from "../../../locales/en/gear.json";
|
||||
import lfg from "../../../locales/en/lfg.json";
|
||||
import org from "../../../locales/en/org.json";
|
||||
import params from "../../../locales/en/params.json";
|
||||
import q from "../../../locales/en/q.json";
|
||||
import scrims from "../../../locales/en/scrims.json";
|
||||
import settings from "../../../locales/en/settings.json";
|
||||
import team from "../../../locales/en/team.json";
|
||||
import tierListMaker from "../../../locales/en/tier-list-maker.json";
|
||||
import tournament from "../../../locales/en/tournament.json";
|
||||
import user from "../../../locales/en/user.json";
|
||||
import vods from "../../../locales/en/vods.json";
|
||||
import weapons from "../../../locales/en/weapons.json";
|
||||
import welcome from "../../../locales/en/welcome.json";
|
||||
|
||||
export const resources = {
|
||||
en: {
|
||||
analyzer,
|
||||
art,
|
||||
badges,
|
||||
builds,
|
||||
calendar,
|
||||
common,
|
||||
contributions,
|
||||
faq,
|
||||
forms,
|
||||
front,
|
||||
"game-badges": gameBadges,
|
||||
"game-misc": gameMisc,
|
||||
gear,
|
||||
lfg,
|
||||
org,
|
||||
params,
|
||||
q,
|
||||
scrims,
|
||||
settings,
|
||||
team,
|
||||
"tier-list-maker": tierListMaker,
|
||||
tournament,
|
||||
user,
|
||||
vods,
|
||||
weapons,
|
||||
welcome,
|
||||
},
|
||||
};
|
||||
933
apps/web-react/app/modules/i18n/resources.server.ts
Normal file
933
apps/web-react/app/modules/i18n/resources.server.ts
Normal file
@@ -0,0 +1,933 @@
|
||||
import analyzerDa from "../../../locales/da/analyzer.json";
|
||||
import artDa from "../../../locales/da/art.json";
|
||||
import badgesDa from "../../../locales/da/badges.json";
|
||||
import buildsDa from "../../../locales/da/builds.json";
|
||||
import calendarDa from "../../../locales/da/calendar.json";
|
||||
import commonDa from "../../../locales/da/common.json";
|
||||
import contributionsDa from "../../../locales/da/contributions.json";
|
||||
import faqDa from "../../../locales/da/faq.json";
|
||||
import formsDa from "../../../locales/da/forms.json";
|
||||
import friendsDa from "../../../locales/da/friends.json";
|
||||
import frontDa from "../../../locales/da/front.json";
|
||||
import gameBadgesDa from "../../../locales/da/game-badges.json";
|
||||
import gameMiscDa from "../../../locales/da/game-misc.json";
|
||||
import gearDa from "../../../locales/da/gear.json";
|
||||
import lfgDa from "../../../locales/da/lfg.json";
|
||||
import orgDa from "../../../locales/da/org.json";
|
||||
import paramsDa from "../../../locales/da/params.json";
|
||||
import qDa from "../../../locales/da/q.json";
|
||||
import scrimsDa from "../../../locales/da/scrims.json";
|
||||
import settingsDa from "../../../locales/da/settings.json";
|
||||
import teamDa from "../../../locales/da/team.json";
|
||||
import tierListMakerDa from "../../../locales/da/tier-list-maker.json";
|
||||
import tournamentDa from "../../../locales/da/tournament.json";
|
||||
import trophiesDa from "../../../locales/da/trophies.json";
|
||||
import userDa from "../../../locales/da/user.json";
|
||||
import vodsDa from "../../../locales/da/vods.json";
|
||||
import weaponsDa from "../../../locales/da/weapons.json";
|
||||
import welcomeDa from "../../../locales/da/welcome.json";
|
||||
import analyzerDe from "../../../locales/de/analyzer.json";
|
||||
import artDe from "../../../locales/de/art.json";
|
||||
import badgesDe from "../../../locales/de/badges.json";
|
||||
import buildsDe from "../../../locales/de/builds.json";
|
||||
import calendarDe from "../../../locales/de/calendar.json";
|
||||
import commonDe from "../../../locales/de/common.json";
|
||||
import contributionsDe from "../../../locales/de/contributions.json";
|
||||
import faqDe from "../../../locales/de/faq.json";
|
||||
import formsDe from "../../../locales/de/forms.json";
|
||||
import friendsDe from "../../../locales/de/friends.json";
|
||||
import frontDe from "../../../locales/de/front.json";
|
||||
import gameBadgesDe from "../../../locales/de/game-badges.json";
|
||||
import gameMiscDe from "../../../locales/de/game-misc.json";
|
||||
import gearDe from "../../../locales/de/gear.json";
|
||||
import lfgDe from "../../../locales/de/lfg.json";
|
||||
import orgDe from "../../../locales/de/org.json";
|
||||
import paramsDe from "../../../locales/de/params.json";
|
||||
import qDe from "../../../locales/de/q.json";
|
||||
import scrimsDe from "../../../locales/de/scrims.json";
|
||||
import settingsDe from "../../../locales/de/settings.json";
|
||||
import teamDe from "../../../locales/de/team.json";
|
||||
import tierListMakerDe from "../../../locales/de/tier-list-maker.json";
|
||||
import tournamentDe from "../../../locales/de/tournament.json";
|
||||
import trophiesDe from "../../../locales/de/trophies.json";
|
||||
import userDe from "../../../locales/de/user.json";
|
||||
import vodsDe from "../../../locales/de/vods.json";
|
||||
import weaponsDe from "../../../locales/de/weapons.json";
|
||||
import welcomeDe from "../../../locales/de/welcome.json";
|
||||
import analyzer from "../../../locales/en/analyzer.json";
|
||||
import art from "../../../locales/en/art.json";
|
||||
import badges from "../../../locales/en/badges.json";
|
||||
import builds from "../../../locales/en/builds.json";
|
||||
import calendar from "../../../locales/en/calendar.json";
|
||||
import common from "../../../locales/en/common.json";
|
||||
import contributions from "../../../locales/en/contributions.json";
|
||||
import faq from "../../../locales/en/faq.json";
|
||||
import forms from "../../../locales/en/forms.json";
|
||||
import friends from "../../../locales/en/friends.json";
|
||||
import front from "../../../locales/en/front.json";
|
||||
import gameBadges from "../../../locales/en/game-badges.json";
|
||||
import gameMisc from "../../../locales/en/game-misc.json";
|
||||
import gear from "../../../locales/en/gear.json";
|
||||
import lfg from "../../../locales/en/lfg.json";
|
||||
import org from "../../../locales/en/org.json";
|
||||
import params from "../../../locales/en/params.json";
|
||||
import q from "../../../locales/en/q.json";
|
||||
import scrimsEn from "../../../locales/en/scrims.json";
|
||||
import settings from "../../../locales/en/settings.json";
|
||||
import team from "../../../locales/en/team.json";
|
||||
import tierListMaker from "../../../locales/en/tier-list-maker.json";
|
||||
import tournament from "../../../locales/en/tournament.json";
|
||||
import trophies from "../../../locales/en/trophies.json";
|
||||
import user from "../../../locales/en/user.json";
|
||||
import vods from "../../../locales/en/vods.json";
|
||||
import weapons from "../../../locales/en/weapons.json";
|
||||
import welcomeEn from "../../../locales/en/welcome.json";
|
||||
import analyzerEsEs from "../../../locales/es-ES/analyzer.json";
|
||||
import artEsEs from "../../../locales/es-ES/art.json";
|
||||
import badgesEsEs from "../../../locales/es-ES/badges.json";
|
||||
import buildsEsEs from "../../../locales/es-ES/builds.json";
|
||||
import calendarEsEs from "../../../locales/es-ES/calendar.json";
|
||||
import commonEsEs from "../../../locales/es-ES/common.json";
|
||||
import contributionsEsEs from "../../../locales/es-ES/contributions.json";
|
||||
import faqEsEs from "../../../locales/es-ES/faq.json";
|
||||
import formsEsEs from "../../../locales/es-ES/forms.json";
|
||||
import friendsEsEs from "../../../locales/es-ES/friends.json";
|
||||
import frontEsEs from "../../../locales/es-ES/front.json";
|
||||
import gameBadgesEsEs from "../../../locales/es-ES/game-badges.json";
|
||||
import gameMiscEsEs from "../../../locales/es-ES/game-misc.json";
|
||||
import gearEsEs from "../../../locales/es-ES/gear.json";
|
||||
import lfgEsEs from "../../../locales/es-ES/lfg.json";
|
||||
import orgEsEs from "../../../locales/es-ES/org.json";
|
||||
import paramsEsEs from "../../../locales/es-ES/params.json";
|
||||
import qEsEs from "../../../locales/es-ES/q.json";
|
||||
import scrimsEsEs from "../../../locales/es-ES/scrims.json";
|
||||
import settingsEsEs from "../../../locales/es-ES/settings.json";
|
||||
import teamEsEs from "../../../locales/es-ES/team.json";
|
||||
import tierListMakerEsEs from "../../../locales/es-ES/tier-list-maker.json";
|
||||
import tournamentEsEs from "../../../locales/es-ES/tournament.json";
|
||||
import trophiesEsEs from "../../../locales/es-ES/trophies.json";
|
||||
import userEsEs from "../../../locales/es-ES/user.json";
|
||||
import vodsEsEs from "../../../locales/es-ES/vods.json";
|
||||
import weaponsEsEs from "../../../locales/es-ES/weapons.json";
|
||||
import welcomeEsEs from "../../../locales/es-ES/welcome.json";
|
||||
import analyzerEsUs from "../../../locales/es-US/analyzer.json";
|
||||
import artEsUs from "../../../locales/es-US/art.json";
|
||||
import badgesEsUs from "../../../locales/es-US/badges.json";
|
||||
import buildsEsUs from "../../../locales/es-US/builds.json";
|
||||
import calendarEsUs from "../../../locales/es-US/calendar.json";
|
||||
import commonEsUs from "../../../locales/es-US/common.json";
|
||||
import contributionsEsUs from "../../../locales/es-US/contributions.json";
|
||||
import faqEsUs from "../../../locales/es-US/faq.json";
|
||||
import formsEsUs from "../../../locales/es-US/forms.json";
|
||||
import friendsEsUs from "../../../locales/es-US/friends.json";
|
||||
import frontEsUs from "../../../locales/es-US/front.json";
|
||||
import gameBadgesEsUs from "../../../locales/es-US/game-badges.json";
|
||||
import gameMiscEsUs from "../../../locales/es-US/game-misc.json";
|
||||
import gearEsUs from "../../../locales/es-US/gear.json";
|
||||
import lfgEsUs from "../../../locales/es-US/lfg.json";
|
||||
import orgEsUs from "../../../locales/es-US/org.json";
|
||||
import paramsEsUs from "../../../locales/es-US/params.json";
|
||||
import qEsUs from "../../../locales/es-US/q.json";
|
||||
import scrimsEsUs from "../../../locales/es-US/scrims.json";
|
||||
import settingsEsUs from "../../../locales/es-US/settings.json";
|
||||
import teamEsUs from "../../../locales/es-US/team.json";
|
||||
import tierListMakerEsUs from "../../../locales/es-US/tier-list-maker.json";
|
||||
import tournamentEsUs from "../../../locales/es-US/tournament.json";
|
||||
import trophiesEsUs from "../../../locales/es-US/trophies.json";
|
||||
import userEsUs from "../../../locales/es-US/user.json";
|
||||
import vodsEsUs from "../../../locales/es-US/vods.json";
|
||||
import weaponsEsUs from "../../../locales/es-US/weapons.json";
|
||||
import welcomeEsUs from "../../../locales/es-US/welcome.json";
|
||||
import analyzerFrCa from "../../../locales/fr-CA/analyzer.json";
|
||||
import artFrCa from "../../../locales/fr-CA/art.json";
|
||||
import badgesFrCa from "../../../locales/fr-CA/badges.json";
|
||||
import buildsFrCa from "../../../locales/fr-CA/builds.json";
|
||||
import calendarFrCa from "../../../locales/fr-CA/calendar.json";
|
||||
import commonFrCa from "../../../locales/fr-CA/common.json";
|
||||
import contributionsFrCa from "../../../locales/fr-CA/contributions.json";
|
||||
import faqFrCa from "../../../locales/fr-CA/faq.json";
|
||||
import formsFrCa from "../../../locales/fr-CA/forms.json";
|
||||
import friendsFrCa from "../../../locales/fr-CA/friends.json";
|
||||
import frontFrCa from "../../../locales/fr-CA/front.json";
|
||||
import gameBadgesFrCa from "../../../locales/fr-CA/game-badges.json";
|
||||
import gameMiscFrCa from "../../../locales/fr-CA/game-misc.json";
|
||||
import gearFrCa from "../../../locales/fr-CA/gear.json";
|
||||
import lfgFrCa from "../../../locales/fr-CA/lfg.json";
|
||||
import orgFrCa from "../../../locales/fr-CA/org.json";
|
||||
import paramsFrCa from "../../../locales/fr-CA/params.json";
|
||||
import qFrCa from "../../../locales/fr-CA/q.json";
|
||||
import scrimsFrCa from "../../../locales/fr-CA/scrims.json";
|
||||
import settingsFrCa from "../../../locales/fr-CA/settings.json";
|
||||
import teamFrCa from "../../../locales/fr-CA/team.json";
|
||||
import tierListMakerFrCa from "../../../locales/fr-CA/tier-list-maker.json";
|
||||
import tournamentFrCa from "../../../locales/fr-CA/tournament.json";
|
||||
import trophiesFrCa from "../../../locales/fr-CA/trophies.json";
|
||||
import userFrCa from "../../../locales/fr-CA/user.json";
|
||||
import vodsFrCa from "../../../locales/fr-CA/vods.json";
|
||||
import weaponsFrCa from "../../../locales/fr-CA/weapons.json";
|
||||
import welcomeFrCa from "../../../locales/fr-CA/welcome.json";
|
||||
import analyzerFrEu from "../../../locales/fr-EU/analyzer.json";
|
||||
import artFrEu from "../../../locales/fr-EU/art.json";
|
||||
import badgesFrEu from "../../../locales/fr-EU/badges.json";
|
||||
import buildsFrEu from "../../../locales/fr-EU/builds.json";
|
||||
import calendarFrEu from "../../../locales/fr-EU/calendar.json";
|
||||
import commonFrEu from "../../../locales/fr-EU/common.json";
|
||||
import contributionsFrEu from "../../../locales/fr-EU/contributions.json";
|
||||
import faqFrEu from "../../../locales/fr-EU/faq.json";
|
||||
import formsFrEu from "../../../locales/fr-EU/forms.json";
|
||||
import friendsFrEu from "../../../locales/fr-EU/friends.json";
|
||||
import frontFrEu from "../../../locales/fr-EU/front.json";
|
||||
import gameBadgesFrEu from "../../../locales/fr-EU/game-badges.json";
|
||||
import gameMiscFrEu from "../../../locales/fr-EU/game-misc.json";
|
||||
import gearFrEu from "../../../locales/fr-EU/gear.json";
|
||||
import lfgFrEu from "../../../locales/fr-EU/lfg.json";
|
||||
import orgFrEu from "../../../locales/fr-EU/org.json";
|
||||
import paramsFrEu from "../../../locales/fr-EU/params.json";
|
||||
import qFrEu from "../../../locales/fr-EU/q.json";
|
||||
import scrimsFrEu from "../../../locales/fr-EU/scrims.json";
|
||||
import settingsFrEu from "../../../locales/fr-EU/settings.json";
|
||||
import teamFrEu from "../../../locales/fr-EU/team.json";
|
||||
import tierListMakerFrEu from "../../../locales/fr-EU/tier-list-maker.json";
|
||||
import tournamentFrEu from "../../../locales/fr-EU/tournament.json";
|
||||
import trophiesFrEu from "../../../locales/fr-EU/trophies.json";
|
||||
import userFrEu from "../../../locales/fr-EU/user.json";
|
||||
import vodsFrEu from "../../../locales/fr-EU/vods.json";
|
||||
import weaponsFrEu from "../../../locales/fr-EU/weapons.json";
|
||||
import welcomeFrEu from "../../../locales/fr-EU/welcome.json";
|
||||
import analyzerHe from "../../../locales/he/analyzer.json";
|
||||
import artHe from "../../../locales/he/art.json";
|
||||
import badgesHe from "../../../locales/he/badges.json";
|
||||
import buildsHe from "../../../locales/he/builds.json";
|
||||
import calendarHe from "../../../locales/he/calendar.json";
|
||||
import commonHe from "../../../locales/he/common.json";
|
||||
import contributionsHe from "../../../locales/he/contributions.json";
|
||||
import faqHe from "../../../locales/he/faq.json";
|
||||
import formsHe from "../../../locales/he/forms.json";
|
||||
import friendsHe from "../../../locales/he/friends.json";
|
||||
import frontHe from "../../../locales/he/front.json";
|
||||
import gameBadgesHe from "../../../locales/he/game-badges.json";
|
||||
import gameMiscHe from "../../../locales/he/game-misc.json";
|
||||
import gearHe from "../../../locales/he/gear.json";
|
||||
import lfgHe from "../../../locales/he/lfg.json";
|
||||
import orgHe from "../../../locales/he/org.json";
|
||||
import paramsHe from "../../../locales/he/params.json";
|
||||
import qHe from "../../../locales/he/q.json";
|
||||
import scrimsHe from "../../../locales/he/scrims.json";
|
||||
import settingsHe from "../../../locales/he/settings.json";
|
||||
import teamHe from "../../../locales/he/team.json";
|
||||
import tierListMakerHe from "../../../locales/he/tier-list-maker.json";
|
||||
import tournamentHe from "../../../locales/he/tournament.json";
|
||||
import trophiesHe from "../../../locales/he/trophies.json";
|
||||
import userHe from "../../../locales/he/user.json";
|
||||
import vodsHe from "../../../locales/he/vods.json";
|
||||
import weaponsHe from "../../../locales/he/weapons.json";
|
||||
import welcomeHe from "../../../locales/he/welcome.json";
|
||||
import analyzerIt from "../../../locales/it/analyzer.json";
|
||||
import artIt from "../../../locales/it/art.json";
|
||||
import badgesIt from "../../../locales/it/badges.json";
|
||||
import buildsIt from "../../../locales/it/builds.json";
|
||||
import calendarIt from "../../../locales/it/calendar.json";
|
||||
import commonIt from "../../../locales/it/common.json";
|
||||
import contributionsIt from "../../../locales/it/contributions.json";
|
||||
import faqIt from "../../../locales/it/faq.json";
|
||||
import formsIt from "../../../locales/it/forms.json";
|
||||
import friendsIt from "../../../locales/it/friends.json";
|
||||
import frontIt from "../../../locales/it/front.json";
|
||||
import gameBadgesIt from "../../../locales/it/game-badges.json";
|
||||
import gameMiscIt from "../../../locales/it/game-misc.json";
|
||||
import gearIt from "../../../locales/it/gear.json";
|
||||
import lfgIt from "../../../locales/it/lfg.json";
|
||||
import orgIt from "../../../locales/it/org.json";
|
||||
import paramsIt from "../../../locales/it/params.json";
|
||||
import qIt from "../../../locales/it/q.json";
|
||||
import scrimsIt from "../../../locales/it/scrims.json";
|
||||
import settingsIt from "../../../locales/it/settings.json";
|
||||
import teamIt from "../../../locales/it/team.json";
|
||||
import tierListMakerIt from "../../../locales/it/tier-list-maker.json";
|
||||
import tournamentIt from "../../../locales/it/tournament.json";
|
||||
import trophiesIt from "../../../locales/it/trophies.json";
|
||||
import userIt from "../../../locales/it/user.json";
|
||||
import vodsIt from "../../../locales/it/vods.json";
|
||||
import weaponsIt from "../../../locales/it/weapons.json";
|
||||
import welcomeIt from "../../../locales/it/welcome.json";
|
||||
import analyzerJa from "../../../locales/ja/analyzer.json";
|
||||
import artJa from "../../../locales/ja/art.json";
|
||||
import badgesJa from "../../../locales/ja/badges.json";
|
||||
import buildsJa from "../../../locales/ja/builds.json";
|
||||
import calendarJa from "../../../locales/ja/calendar.json";
|
||||
import commonJa from "../../../locales/ja/common.json";
|
||||
import contributionsJa from "../../../locales/ja/contributions.json";
|
||||
import faqJa from "../../../locales/ja/faq.json";
|
||||
import formsJa from "../../../locales/ja/forms.json";
|
||||
import friendsJa from "../../../locales/ja/friends.json";
|
||||
import frontJa from "../../../locales/ja/front.json";
|
||||
import gameBadgesJa from "../../../locales/ja/game-badges.json";
|
||||
import gameMiscJa from "../../../locales/ja/game-misc.json";
|
||||
import gearJa from "../../../locales/ja/gear.json";
|
||||
import lfgJa from "../../../locales/ja/lfg.json";
|
||||
import orgJa from "../../../locales/ja/org.json";
|
||||
import paramsJa from "../../../locales/ja/params.json";
|
||||
import qJa from "../../../locales/ja/q.json";
|
||||
import scrimsJa from "../../../locales/ja/scrims.json";
|
||||
import settingsJa from "../../../locales/ja/settings.json";
|
||||
import teamJa from "../../../locales/ja/team.json";
|
||||
import tierListMakerJa from "../../../locales/ja/tier-list-maker.json";
|
||||
import tournamentJa from "../../../locales/ja/tournament.json";
|
||||
import trophiesJa from "../../../locales/ja/trophies.json";
|
||||
import userJa from "../../../locales/ja/user.json";
|
||||
import vodsJa from "../../../locales/ja/vods.json";
|
||||
import weaponsJa from "../../../locales/ja/weapons.json";
|
||||
import welcomeJa from "../../../locales/ja/welcome.json";
|
||||
import analyzerKo from "../../../locales/ko/analyzer.json";
|
||||
import artKo from "../../../locales/ko/art.json";
|
||||
import badgesKo from "../../../locales/ko/badges.json";
|
||||
import buildsKo from "../../../locales/ko/builds.json";
|
||||
import calendarKo from "../../../locales/ko/calendar.json";
|
||||
import commonKo from "../../../locales/ko/common.json";
|
||||
import contributionsKo from "../../../locales/ko/contributions.json";
|
||||
import faqKo from "../../../locales/ko/faq.json";
|
||||
import formsKo from "../../../locales/ko/forms.json";
|
||||
import friendsKo from "../../../locales/ko/friends.json";
|
||||
import frontKo from "../../../locales/ko/front.json";
|
||||
import gameBadgesKo from "../../../locales/ko/game-badges.json";
|
||||
import gameMiscKo from "../../../locales/ko/game-misc.json";
|
||||
import gearKo from "../../../locales/ko/gear.json";
|
||||
import lfgKo from "../../../locales/ko/lfg.json";
|
||||
import orgKo from "../../../locales/ko/org.json";
|
||||
import paramsKo from "../../../locales/ko/params.json";
|
||||
import qKo from "../../../locales/ko/q.json";
|
||||
import scrimsKo from "../../../locales/ko/scrims.json";
|
||||
import settingsKo from "../../../locales/ko/settings.json";
|
||||
import teamKo from "../../../locales/ko/team.json";
|
||||
import tierListMakerKo from "../../../locales/ko/tier-list-maker.json";
|
||||
import tournamentKo from "../../../locales/ko/tournament.json";
|
||||
import trophiesKo from "../../../locales/ko/trophies.json";
|
||||
import userKo from "../../../locales/ko/user.json";
|
||||
import vodsKo from "../../../locales/ko/vods.json";
|
||||
import weaponsKo from "../../../locales/ko/weapons.json";
|
||||
import welcomeKo from "../../../locales/ko/welcome.json";
|
||||
import analyzerNl from "../../../locales/nl/analyzer.json";
|
||||
import artNl from "../../../locales/nl/art.json";
|
||||
import badgesNl from "../../../locales/nl/badges.json";
|
||||
import buildsNl from "../../../locales/nl/builds.json";
|
||||
import calendarNl from "../../../locales/nl/calendar.json";
|
||||
import commonNl from "../../../locales/nl/common.json";
|
||||
import contributionsNl from "../../../locales/nl/contributions.json";
|
||||
import faqNl from "../../../locales/nl/faq.json";
|
||||
import formsNl from "../../../locales/nl/forms.json";
|
||||
import friendsNl from "../../../locales/nl/friends.json";
|
||||
import frontNl from "../../../locales/nl/front.json";
|
||||
import gameBadgesNl from "../../../locales/nl/game-badges.json";
|
||||
import gameMiscNl from "../../../locales/nl/game-misc.json";
|
||||
import gearNl from "../../../locales/nl/gear.json";
|
||||
import lfgNl from "../../../locales/nl/lfg.json";
|
||||
import orgNl from "../../../locales/nl/org.json";
|
||||
import paramsNl from "../../../locales/nl/params.json";
|
||||
import qNl from "../../../locales/nl/q.json";
|
||||
import scrimsNl from "../../../locales/nl/scrims.json";
|
||||
import settingsNl from "../../../locales/nl/settings.json";
|
||||
import teamNl from "../../../locales/nl/team.json";
|
||||
import tierListMakerNl from "../../../locales/nl/tier-list-maker.json";
|
||||
import tournamentNl from "../../../locales/nl/tournament.json";
|
||||
import trophiesNl from "../../../locales/nl/trophies.json";
|
||||
import userNl from "../../../locales/nl/user.json";
|
||||
import vodsNl from "../../../locales/nl/vods.json";
|
||||
import weaponsNl from "../../../locales/nl/weapons.json";
|
||||
import welcomeNl from "../../../locales/nl/welcome.json";
|
||||
import analyzerPl from "../../../locales/pl/analyzer.json";
|
||||
import artPl from "../../../locales/pl/art.json";
|
||||
import badgesPl from "../../../locales/pl/badges.json";
|
||||
import buildsPl from "../../../locales/pl/builds.json";
|
||||
import calendarPl from "../../../locales/pl/calendar.json";
|
||||
import commonPl from "../../../locales/pl/common.json";
|
||||
import contributionsPl from "../../../locales/pl/contributions.json";
|
||||
import faqPl from "../../../locales/pl/faq.json";
|
||||
import formsPl from "../../../locales/pl/forms.json";
|
||||
import friendsPl from "../../../locales/pl/friends.json";
|
||||
import frontPl from "../../../locales/pl/front.json";
|
||||
import gameBadgesPl from "../../../locales/pl/game-badges.json";
|
||||
import gameMiscPl from "../../../locales/pl/game-misc.json";
|
||||
import gearPl from "../../../locales/pl/gear.json";
|
||||
import lfgPl from "../../../locales/pl/lfg.json";
|
||||
import orgPl from "../../../locales/pl/org.json";
|
||||
import paramsPl from "../../../locales/pl/params.json";
|
||||
import qPl from "../../../locales/pl/q.json";
|
||||
import scrimsPl from "../../../locales/pl/scrims.json";
|
||||
import settingsPl from "../../../locales/pl/settings.json";
|
||||
import teamPl from "../../../locales/pl/team.json";
|
||||
import tierListMakerPl from "../../../locales/pl/tier-list-maker.json";
|
||||
import tournamentPl from "../../../locales/pl/tournament.json";
|
||||
import trophiesPl from "../../../locales/pl/trophies.json";
|
||||
import userPl from "../../../locales/pl/user.json";
|
||||
import vodsPl from "../../../locales/pl/vods.json";
|
||||
import weaponsPl from "../../../locales/pl/weapons.json";
|
||||
import welcomePl from "../../../locales/pl/welcome.json";
|
||||
import analyzerPtBr from "../../../locales/pt-BR/analyzer.json";
|
||||
import artPtBr from "../../../locales/pt-BR/art.json";
|
||||
import badgesPtBr from "../../../locales/pt-BR/badges.json";
|
||||
import buildsPtBr from "../../../locales/pt-BR/builds.json";
|
||||
import calendarPtBr from "../../../locales/pt-BR/calendar.json";
|
||||
import commonPtBr from "../../../locales/pt-BR/common.json";
|
||||
import contributionsPtBr from "../../../locales/pt-BR/contributions.json";
|
||||
import faqPtBr from "../../../locales/pt-BR/faq.json";
|
||||
import formsPtBr from "../../../locales/pt-BR/forms.json";
|
||||
import friendsPtBr from "../../../locales/pt-BR/friends.json";
|
||||
import frontPtBr from "../../../locales/pt-BR/front.json";
|
||||
import gameBadgesPtBr from "../../../locales/pt-BR/game-badges.json";
|
||||
import gameMiscPtBr from "../../../locales/pt-BR/game-misc.json";
|
||||
import gearPtBr from "../../../locales/pt-BR/gear.json";
|
||||
import lfgPtBr from "../../../locales/pt-BR/lfg.json";
|
||||
import orgPtBr from "../../../locales/pt-BR/org.json";
|
||||
import paramsPtBr from "../../../locales/pt-BR/params.json";
|
||||
import qPtBr from "../../../locales/pt-BR/q.json";
|
||||
import scrimsPtBr from "../../../locales/pt-BR/scrims.json";
|
||||
import settingsPtBr from "../../../locales/pt-BR/settings.json";
|
||||
import teamPtBr from "../../../locales/pt-BR/team.json";
|
||||
import tierListMakerPtBr from "../../../locales/pt-BR/tier-list-maker.json";
|
||||
import tournamentPtBr from "../../../locales/pt-BR/tournament.json";
|
||||
import trophiesPtBr from "../../../locales/pt-BR/trophies.json";
|
||||
import userPtBr from "../../../locales/pt-BR/user.json";
|
||||
import vodsPtBr from "../../../locales/pt-BR/vods.json";
|
||||
import weaponsPtBr from "../../../locales/pt-BR/weapons.json";
|
||||
import welcomePtBr from "../../../locales/pt-BR/welcome.json";
|
||||
import analyzerRu from "../../../locales/ru/analyzer.json";
|
||||
import artRu from "../../../locales/ru/art.json";
|
||||
import badgesRu from "../../../locales/ru/badges.json";
|
||||
import buildsRu from "../../../locales/ru/builds.json";
|
||||
import calendarRu from "../../../locales/ru/calendar.json";
|
||||
import commonRu from "../../../locales/ru/common.json";
|
||||
import contributionsRu from "../../../locales/ru/contributions.json";
|
||||
import faqRu from "../../../locales/ru/faq.json";
|
||||
import formsRu from "../../../locales/ru/forms.json";
|
||||
import friendsRu from "../../../locales/ru/friends.json";
|
||||
import frontRu from "../../../locales/ru/front.json";
|
||||
import gameBadgesRu from "../../../locales/ru/game-badges.json";
|
||||
import gameMiscRu from "../../../locales/ru/game-misc.json";
|
||||
import gearRu from "../../../locales/ru/gear.json";
|
||||
import lfgRu from "../../../locales/ru/lfg.json";
|
||||
import orgRu from "../../../locales/ru/org.json";
|
||||
import paramsRu from "../../../locales/ru/params.json";
|
||||
import qRu from "../../../locales/ru/q.json";
|
||||
import scrimsRu from "../../../locales/ru/scrims.json";
|
||||
import settingsRu from "../../../locales/ru/settings.json";
|
||||
import teamRu from "../../../locales/ru/team.json";
|
||||
import tierListMakerRu from "../../../locales/ru/tier-list-maker.json";
|
||||
import tournamentRu from "../../../locales/ru/tournament.json";
|
||||
import trophiesRu from "../../../locales/ru/trophies.json";
|
||||
import userRu from "../../../locales/ru/user.json";
|
||||
import vodsRu from "../../../locales/ru/vods.json";
|
||||
import weaponsRu from "../../../locales/ru/weapons.json";
|
||||
import welcomeRu from "../../../locales/ru/welcome.json";
|
||||
import analyzerZh from "../../../locales/zh/analyzer.json";
|
||||
import artZh from "../../../locales/zh/art.json";
|
||||
import badgesZh from "../../../locales/zh/badges.json";
|
||||
import buildsZh from "../../../locales/zh/builds.json";
|
||||
import calendarZh from "../../../locales/zh/calendar.json";
|
||||
import commonZh from "../../../locales/zh/common.json";
|
||||
import contributionsZh from "../../../locales/zh/contributions.json";
|
||||
import faqZh from "../../../locales/zh/faq.json";
|
||||
import formsZh from "../../../locales/zh/forms.json";
|
||||
import friendsZh from "../../../locales/zh/friends.json";
|
||||
import frontZh from "../../../locales/zh/front.json";
|
||||
import gameBadgesZh from "../../../locales/zh/game-badges.json";
|
||||
import gameMiscZh from "../../../locales/zh/game-misc.json";
|
||||
import gearZh from "../../../locales/zh/gear.json";
|
||||
import lfgZh from "../../../locales/zh/lfg.json";
|
||||
import orgZh from "../../../locales/zh/org.json";
|
||||
import paramsZh from "../../../locales/zh/params.json";
|
||||
import qZh from "../../../locales/zh/q.json";
|
||||
import scrimsZh from "../../../locales/zh/scrims.json";
|
||||
import settingsZh from "../../../locales/zh/settings.json";
|
||||
import teamZh from "../../../locales/zh/team.json";
|
||||
import tierListMakerZh from "../../../locales/zh/tier-list-maker.json";
|
||||
import tournamentZh from "../../../locales/zh/tournament.json";
|
||||
import trophiesZh from "../../../locales/zh/trophies.json";
|
||||
import userZh from "../../../locales/zh/user.json";
|
||||
import vodsZh from "../../../locales/zh/vods.json";
|
||||
import weaponsZh from "../../../locales/zh/weapons.json";
|
||||
import welcomeZh from "../../../locales/zh/welcome.json";
|
||||
|
||||
export const resources = {
|
||||
"es-US": {
|
||||
gear: gearEsUs,
|
||||
faq: faqEsUs,
|
||||
forms: formsEsUs,
|
||||
friends: friendsEsUs,
|
||||
weapons: weaponsEsUs,
|
||||
scrims: scrimsEsUs,
|
||||
settings: settingsEsUs,
|
||||
common: commonEsUs,
|
||||
"game-badges": gameBadgesEsUs,
|
||||
"game-misc": gameMiscEsUs,
|
||||
tournament: tournamentEsUs,
|
||||
front: frontEsUs,
|
||||
user: userEsUs,
|
||||
q: qEsUs,
|
||||
art: artEsUs,
|
||||
builds: buildsEsUs,
|
||||
lfg: lfgEsUs,
|
||||
vods: vodsEsUs,
|
||||
calendar: calendarEsUs,
|
||||
org: orgEsUs,
|
||||
params: paramsEsUs,
|
||||
badges: badgesEsUs,
|
||||
contributions: contributionsEsUs,
|
||||
team: teamEsUs,
|
||||
trophies: trophiesEsUs,
|
||||
"tier-list-maker": tierListMakerEsUs,
|
||||
analyzer: analyzerEsUs,
|
||||
welcome: welcomeEsUs,
|
||||
},
|
||||
en: {
|
||||
gear: gear,
|
||||
faq: faq,
|
||||
forms: forms,
|
||||
friends: friends,
|
||||
weapons: weapons,
|
||||
scrims: scrimsEn,
|
||||
settings: settings,
|
||||
common: common,
|
||||
"game-badges": gameBadges,
|
||||
"game-misc": gameMisc,
|
||||
tournament: tournament,
|
||||
front: front,
|
||||
user: user,
|
||||
q: q,
|
||||
art: art,
|
||||
builds: builds,
|
||||
lfg: lfg,
|
||||
vods: vods,
|
||||
calendar: calendar,
|
||||
org: org,
|
||||
params: params,
|
||||
badges: badges,
|
||||
contributions: contributions,
|
||||
team: team,
|
||||
trophies: trophies,
|
||||
"tier-list-maker": tierListMaker,
|
||||
analyzer: analyzer,
|
||||
welcome: welcomeEn,
|
||||
},
|
||||
ko: {
|
||||
gear: gearKo,
|
||||
faq: faqKo,
|
||||
forms: formsKo,
|
||||
friends: friendsKo,
|
||||
weapons: weaponsKo,
|
||||
scrims: scrimsKo,
|
||||
settings: settingsKo,
|
||||
common: commonKo,
|
||||
"game-badges": gameBadgesKo,
|
||||
"game-misc": gameMiscKo,
|
||||
tournament: tournamentKo,
|
||||
front: frontKo,
|
||||
user: userKo,
|
||||
q: qKo,
|
||||
art: artKo,
|
||||
builds: buildsKo,
|
||||
lfg: lfgKo,
|
||||
vods: vodsKo,
|
||||
calendar: calendarKo,
|
||||
org: orgKo,
|
||||
params: paramsKo,
|
||||
badges: badgesKo,
|
||||
contributions: contributionsKo,
|
||||
team: teamKo,
|
||||
trophies: trophiesKo,
|
||||
"tier-list-maker": tierListMakerKo,
|
||||
analyzer: analyzerKo,
|
||||
welcome: welcomeKo,
|
||||
},
|
||||
de: {
|
||||
gear: gearDe,
|
||||
faq: faqDe,
|
||||
forms: formsDe,
|
||||
friends: friendsDe,
|
||||
weapons: weaponsDe,
|
||||
scrims: scrimsDe,
|
||||
settings: settingsDe,
|
||||
common: commonDe,
|
||||
"game-badges": gameBadgesDe,
|
||||
"game-misc": gameMiscDe,
|
||||
tournament: tournamentDe,
|
||||
front: frontDe,
|
||||
user: userDe,
|
||||
q: qDe,
|
||||
art: artDe,
|
||||
builds: buildsDe,
|
||||
lfg: lfgDe,
|
||||
vods: vodsDe,
|
||||
calendar: calendarDe,
|
||||
org: orgDe,
|
||||
params: paramsDe,
|
||||
badges: badgesDe,
|
||||
contributions: contributionsDe,
|
||||
team: teamDe,
|
||||
trophies: trophiesDe,
|
||||
"tier-list-maker": tierListMakerDe,
|
||||
analyzer: analyzerDe,
|
||||
welcome: welcomeDe,
|
||||
},
|
||||
nl: {
|
||||
gear: gearNl,
|
||||
faq: faqNl,
|
||||
forms: formsNl,
|
||||
friends: friendsNl,
|
||||
weapons: weaponsNl,
|
||||
scrims: scrimsNl,
|
||||
settings: settingsNl,
|
||||
common: commonNl,
|
||||
"game-badges": gameBadgesNl,
|
||||
"game-misc": gameMiscNl,
|
||||
tournament: tournamentNl,
|
||||
front: frontNl,
|
||||
user: userNl,
|
||||
q: qNl,
|
||||
art: artNl,
|
||||
builds: buildsNl,
|
||||
lfg: lfgNl,
|
||||
vods: vodsNl,
|
||||
calendar: calendarNl,
|
||||
org: orgNl,
|
||||
params: paramsNl,
|
||||
badges: badgesNl,
|
||||
contributions: contributionsNl,
|
||||
team: teamNl,
|
||||
trophies: trophiesNl,
|
||||
"tier-list-maker": tierListMakerNl,
|
||||
analyzer: analyzerNl,
|
||||
welcome: welcomeNl,
|
||||
},
|
||||
"pt-BR": {
|
||||
gear: gearPtBr,
|
||||
faq: faqPtBr,
|
||||
forms: formsPtBr,
|
||||
friends: friendsPtBr,
|
||||
weapons: weaponsPtBr,
|
||||
scrims: scrimsPtBr,
|
||||
settings: settingsPtBr,
|
||||
common: commonPtBr,
|
||||
"game-badges": gameBadgesPtBr,
|
||||
"game-misc": gameMiscPtBr,
|
||||
tournament: tournamentPtBr,
|
||||
front: frontPtBr,
|
||||
user: userPtBr,
|
||||
q: qPtBr,
|
||||
art: artPtBr,
|
||||
builds: buildsPtBr,
|
||||
lfg: lfgPtBr,
|
||||
vods: vodsPtBr,
|
||||
calendar: calendarPtBr,
|
||||
org: orgPtBr,
|
||||
params: paramsPtBr,
|
||||
badges: badgesPtBr,
|
||||
contributions: contributionsPtBr,
|
||||
team: teamPtBr,
|
||||
trophies: trophiesPtBr,
|
||||
"tier-list-maker": tierListMakerPtBr,
|
||||
analyzer: analyzerPtBr,
|
||||
welcome: welcomePtBr,
|
||||
},
|
||||
zh: {
|
||||
gear: gearZh,
|
||||
faq: faqZh,
|
||||
forms: formsZh,
|
||||
friends: friendsZh,
|
||||
weapons: weaponsZh,
|
||||
scrims: scrimsZh,
|
||||
settings: settingsZh,
|
||||
common: commonZh,
|
||||
"game-badges": gameBadgesZh,
|
||||
"game-misc": gameMiscZh,
|
||||
tournament: tournamentZh,
|
||||
front: frontZh,
|
||||
user: userZh,
|
||||
q: qZh,
|
||||
art: artZh,
|
||||
builds: buildsZh,
|
||||
lfg: lfgZh,
|
||||
vods: vodsZh,
|
||||
calendar: calendarZh,
|
||||
org: orgZh,
|
||||
params: paramsZh,
|
||||
badges: badgesZh,
|
||||
contributions: contributionsZh,
|
||||
team: teamZh,
|
||||
trophies: trophiesZh,
|
||||
"tier-list-maker": tierListMakerZh,
|
||||
analyzer: analyzerZh,
|
||||
welcome: welcomeZh,
|
||||
},
|
||||
"fr-CA": {
|
||||
gear: gearFrCa,
|
||||
faq: faqFrCa,
|
||||
forms: formsFrCa,
|
||||
friends: friendsFrCa,
|
||||
weapons: weaponsFrCa,
|
||||
scrims: scrimsFrCa,
|
||||
settings: settingsFrCa,
|
||||
common: commonFrCa,
|
||||
"game-badges": gameBadgesFrCa,
|
||||
"game-misc": gameMiscFrCa,
|
||||
tournament: tournamentFrCa,
|
||||
front: frontFrCa,
|
||||
user: userFrCa,
|
||||
q: qFrCa,
|
||||
art: artFrCa,
|
||||
builds: buildsFrCa,
|
||||
lfg: lfgFrCa,
|
||||
vods: vodsFrCa,
|
||||
calendar: calendarFrCa,
|
||||
org: orgFrCa,
|
||||
params: paramsFrCa,
|
||||
badges: badgesFrCa,
|
||||
contributions: contributionsFrCa,
|
||||
team: teamFrCa,
|
||||
trophies: trophiesFrCa,
|
||||
"tier-list-maker": tierListMakerFrCa,
|
||||
analyzer: analyzerFrCa,
|
||||
welcome: welcomeFrCa,
|
||||
},
|
||||
ru: {
|
||||
gear: gearRu,
|
||||
faq: faqRu,
|
||||
forms: formsRu,
|
||||
friends: friendsRu,
|
||||
weapons: weaponsRu,
|
||||
scrims: scrimsRu,
|
||||
settings: settingsRu,
|
||||
common: commonRu,
|
||||
"game-badges": gameBadgesRu,
|
||||
"game-misc": gameMiscRu,
|
||||
tournament: tournamentRu,
|
||||
front: frontRu,
|
||||
user: userRu,
|
||||
q: qRu,
|
||||
art: artRu,
|
||||
builds: buildsRu,
|
||||
lfg: lfgRu,
|
||||
vods: vodsRu,
|
||||
calendar: calendarRu,
|
||||
org: orgRu,
|
||||
params: paramsRu,
|
||||
badges: badgesRu,
|
||||
contributions: contributionsRu,
|
||||
team: teamRu,
|
||||
trophies: trophiesRu,
|
||||
"tier-list-maker": tierListMakerRu,
|
||||
analyzer: analyzerRu,
|
||||
welcome: welcomeRu,
|
||||
},
|
||||
it: {
|
||||
gear: gearIt,
|
||||
faq: faqIt,
|
||||
forms: formsIt,
|
||||
friends: friendsIt,
|
||||
weapons: weaponsIt,
|
||||
scrims: scrimsIt,
|
||||
settings: settingsIt,
|
||||
common: commonIt,
|
||||
"game-badges": gameBadgesIt,
|
||||
"game-misc": gameMiscIt,
|
||||
tournament: tournamentIt,
|
||||
front: frontIt,
|
||||
user: userIt,
|
||||
q: qIt,
|
||||
art: artIt,
|
||||
builds: buildsIt,
|
||||
lfg: lfgIt,
|
||||
vods: vodsIt,
|
||||
calendar: calendarIt,
|
||||
org: orgIt,
|
||||
params: paramsIt,
|
||||
badges: badgesIt,
|
||||
contributions: contributionsIt,
|
||||
team: teamIt,
|
||||
trophies: trophiesIt,
|
||||
"tier-list-maker": tierListMakerIt,
|
||||
analyzer: analyzerIt,
|
||||
welcome: welcomeIt,
|
||||
},
|
||||
ja: {
|
||||
gear: gearJa,
|
||||
faq: faqJa,
|
||||
forms: formsJa,
|
||||
friends: friendsJa,
|
||||
weapons: weaponsJa,
|
||||
scrims: scrimsJa,
|
||||
settings: settingsJa,
|
||||
common: commonJa,
|
||||
"game-badges": gameBadgesJa,
|
||||
"game-misc": gameMiscJa,
|
||||
tournament: tournamentJa,
|
||||
front: frontJa,
|
||||
user: userJa,
|
||||
q: qJa,
|
||||
art: artJa,
|
||||
builds: buildsJa,
|
||||
lfg: lfgJa,
|
||||
vods: vodsJa,
|
||||
calendar: calendarJa,
|
||||
org: orgJa,
|
||||
params: paramsJa,
|
||||
badges: badgesJa,
|
||||
contributions: contributionsJa,
|
||||
team: teamJa,
|
||||
trophies: trophiesJa,
|
||||
"tier-list-maker": tierListMakerJa,
|
||||
analyzer: analyzerJa,
|
||||
welcome: welcomeJa,
|
||||
},
|
||||
da: {
|
||||
gear: gearDa,
|
||||
faq: faqDa,
|
||||
forms: formsDa,
|
||||
friends: friendsDa,
|
||||
weapons: weaponsDa,
|
||||
scrims: scrimsDa,
|
||||
settings: settingsDa,
|
||||
common: commonDa,
|
||||
"game-badges": gameBadgesDa,
|
||||
"game-misc": gameMiscDa,
|
||||
tournament: tournamentDa,
|
||||
front: frontDa,
|
||||
user: userDa,
|
||||
q: qDa,
|
||||
art: artDa,
|
||||
builds: buildsDa,
|
||||
lfg: lfgDa,
|
||||
vods: vodsDa,
|
||||
calendar: calendarDa,
|
||||
org: orgDa,
|
||||
params: paramsDa,
|
||||
badges: badgesDa,
|
||||
contributions: contributionsDa,
|
||||
team: teamDa,
|
||||
trophies: trophiesDa,
|
||||
"tier-list-maker": tierListMakerDa,
|
||||
analyzer: analyzerDa,
|
||||
welcome: welcomeDa,
|
||||
},
|
||||
"es-ES": {
|
||||
gear: gearEsEs,
|
||||
faq: faqEsEs,
|
||||
forms: formsEsEs,
|
||||
friends: friendsEsEs,
|
||||
weapons: weaponsEsEs,
|
||||
scrims: scrimsEsEs,
|
||||
settings: settingsEsEs,
|
||||
common: commonEsEs,
|
||||
"game-badges": gameBadgesEsEs,
|
||||
"game-misc": gameMiscEsEs,
|
||||
tournament: tournamentEsEs,
|
||||
front: frontEsEs,
|
||||
user: userEsEs,
|
||||
q: qEsEs,
|
||||
art: artEsEs,
|
||||
builds: buildsEsEs,
|
||||
lfg: lfgEsEs,
|
||||
vods: vodsEsEs,
|
||||
calendar: calendarEsEs,
|
||||
org: orgEsEs,
|
||||
params: paramsEsEs,
|
||||
badges: badgesEsEs,
|
||||
contributions: contributionsEsEs,
|
||||
team: teamEsEs,
|
||||
trophies: trophiesEsEs,
|
||||
"tier-list-maker": tierListMakerEsEs,
|
||||
analyzer: analyzerEsEs,
|
||||
welcome: welcomeEsEs,
|
||||
},
|
||||
he: {
|
||||
gear: gearHe,
|
||||
faq: faqHe,
|
||||
forms: formsHe,
|
||||
friends: friendsHe,
|
||||
weapons: weaponsHe,
|
||||
scrims: scrimsHe,
|
||||
settings: settingsHe,
|
||||
common: commonHe,
|
||||
"game-badges": gameBadgesHe,
|
||||
"game-misc": gameMiscHe,
|
||||
tournament: tournamentHe,
|
||||
front: frontHe,
|
||||
user: userHe,
|
||||
q: qHe,
|
||||
art: artHe,
|
||||
builds: buildsHe,
|
||||
lfg: lfgHe,
|
||||
vods: vodsHe,
|
||||
calendar: calendarHe,
|
||||
org: orgHe,
|
||||
params: paramsHe,
|
||||
badges: badgesHe,
|
||||
contributions: contributionsHe,
|
||||
team: teamHe,
|
||||
trophies: trophiesHe,
|
||||
"tier-list-maker": tierListMakerHe,
|
||||
analyzer: analyzerHe,
|
||||
welcome: welcomeHe,
|
||||
},
|
||||
"fr-EU": {
|
||||
gear: gearFrEu,
|
||||
faq: faqFrEu,
|
||||
forms: formsFrEu,
|
||||
friends: friendsFrEu,
|
||||
weapons: weaponsFrEu,
|
||||
scrims: scrimsFrEu,
|
||||
settings: settingsFrEu,
|
||||
common: commonFrEu,
|
||||
"game-badges": gameBadgesFrEu,
|
||||
"game-misc": gameMiscFrEu,
|
||||
tournament: tournamentFrEu,
|
||||
front: frontFrEu,
|
||||
user: userFrEu,
|
||||
q: qFrEu,
|
||||
art: artFrEu,
|
||||
builds: buildsFrEu,
|
||||
lfg: lfgFrEu,
|
||||
vods: vodsFrEu,
|
||||
calendar: calendarFrEu,
|
||||
org: orgFrEu,
|
||||
params: paramsFrEu,
|
||||
badges: badgesFrEu,
|
||||
contributions: contributionsFrEu,
|
||||
team: teamFrEu,
|
||||
trophies: trophiesFrEu,
|
||||
"tier-list-maker": tierListMakerFrEu,
|
||||
analyzer: analyzerFrEu,
|
||||
welcome: welcomeFrEu,
|
||||
},
|
||||
pl: {
|
||||
gear: gearPl,
|
||||
faq: faqPl,
|
||||
forms: formsPl,
|
||||
friends: friendsPl,
|
||||
weapons: weaponsPl,
|
||||
scrims: scrimsPl,
|
||||
settings: settingsPl,
|
||||
common: commonPl,
|
||||
"game-badges": gameBadgesPl,
|
||||
"game-misc": gameMiscPl,
|
||||
tournament: tournamentPl,
|
||||
front: frontPl,
|
||||
user: userPl,
|
||||
q: qPl,
|
||||
art: artPl,
|
||||
builds: buildsPl,
|
||||
lfg: lfgPl,
|
||||
vods: vodsPl,
|
||||
calendar: calendarPl,
|
||||
org: orgPl,
|
||||
params: paramsPl,
|
||||
badges: badgesPl,
|
||||
contributions: contributionsPl,
|
||||
team: teamPl,
|
||||
trophies: trophiesPl,
|
||||
"tier-list-maker": tierListMakerPl,
|
||||
analyzer: analyzerPl,
|
||||
welcome: welcomePl,
|
||||
},
|
||||
};
|
||||
|
||||
export type Namespace = keyof typeof resources.en;
|
||||
23
apps/web-react/app/modules/i18n/useChangeLanguage.ts
Normal file
23
apps/web-react/app/modules/i18n/useChangeLanguage.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { LanguageCode } from "~/modules/i18n/config";
|
||||
import { loadDateFnsLocale } from "~/utils/dates";
|
||||
|
||||
/**
|
||||
* Detect when the locale returned by the root route loader changes and call
|
||||
* `i18n.changeLanguage` with the new locale so translations load automatically.
|
||||
* The date-fns locale is loaded first so the re-render triggered by the
|
||||
* language change already has it available.
|
||||
*
|
||||
* Vendored from remix-i18next (removed in v8).
|
||||
*/
|
||||
export function useChangeLanguage(locale: string) {
|
||||
const { i18n } = useTranslation();
|
||||
React.useEffect(() => {
|
||||
if (i18n.language !== locale) {
|
||||
void loadDateFnsLocale(locale as LanguageCode).then(() =>
|
||||
i18n.changeLanguage(locale),
|
||||
);
|
||||
}
|
||||
}, [locale, i18n]);
|
||||
}
|
||||
79
apps/web-react/app/modules/in-game-lists/abilities.ts
Normal file
79
apps/web-react/app/modules/in-game-lists/abilities.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
// Reference for Ability Chunks of Primary Slot-Only abilities: https://splatoonwiki.org/wiki/Ability_chunk#Splatoon_3
|
||||
export const abilities = [
|
||||
{ name: "ISM", type: "STACKABLE", abilityChunkTypesRequired: [] },
|
||||
{ name: "ISS", type: "STACKABLE", abilityChunkTypesRequired: [] },
|
||||
{ name: "IRU", type: "STACKABLE", abilityChunkTypesRequired: [] },
|
||||
{ name: "RSU", type: "STACKABLE", abilityChunkTypesRequired: [] },
|
||||
{ name: "SSU", type: "STACKABLE", abilityChunkTypesRequired: [] },
|
||||
{ name: "SCU", type: "STACKABLE", abilityChunkTypesRequired: [] },
|
||||
{ name: "SS", type: "STACKABLE", abilityChunkTypesRequired: [] },
|
||||
{ name: "SPU", type: "STACKABLE", abilityChunkTypesRequired: [] },
|
||||
{ name: "QR", type: "STACKABLE", abilityChunkTypesRequired: [] },
|
||||
{ name: "QSJ", type: "STACKABLE", abilityChunkTypesRequired: [] },
|
||||
{ name: "BRU", type: "STACKABLE", abilityChunkTypesRequired: [] },
|
||||
{ name: "RES", type: "STACKABLE", abilityChunkTypesRequired: [] },
|
||||
{ name: "SRU", type: "STACKABLE", abilityChunkTypesRequired: [] },
|
||||
{ name: "IA", type: "STACKABLE", abilityChunkTypesRequired: [] },
|
||||
{
|
||||
name: "OG",
|
||||
type: "HEAD_MAIN_ONLY",
|
||||
abilityChunkTypesRequired: ["RSU", "SSU", "RES"],
|
||||
},
|
||||
{
|
||||
name: "LDE",
|
||||
type: "HEAD_MAIN_ONLY",
|
||||
abilityChunkTypesRequired: ["ISM", "ISS", "IRU"],
|
||||
},
|
||||
{
|
||||
name: "T",
|
||||
type: "HEAD_MAIN_ONLY",
|
||||
abilityChunkTypesRequired: ["SCU", "SS", "SPU"],
|
||||
},
|
||||
{
|
||||
name: "CB",
|
||||
type: "HEAD_MAIN_ONLY",
|
||||
abilityChunkTypesRequired: ["RSU", "SSU", "SCU"],
|
||||
},
|
||||
{
|
||||
name: "NS",
|
||||
type: "CLOTHES_MAIN_ONLY",
|
||||
abilityChunkTypesRequired: ["IRU", "RSU", "SSU"],
|
||||
},
|
||||
{
|
||||
name: "H",
|
||||
type: "CLOTHES_MAIN_ONLY",
|
||||
abilityChunkTypesRequired: ["QR", "BRU", "RES"],
|
||||
},
|
||||
{
|
||||
name: "TI",
|
||||
type: "CLOTHES_MAIN_ONLY",
|
||||
abilityChunkTypesRequired: ["ISM", "ISS", "IA"],
|
||||
},
|
||||
{
|
||||
name: "RP",
|
||||
type: "CLOTHES_MAIN_ONLY",
|
||||
abilityChunkTypesRequired: ["SS", "QR", "SRU"],
|
||||
},
|
||||
{ name: "AD", type: "CLOTHES_MAIN_ONLY", abilityChunkTypesRequired: [] },
|
||||
{
|
||||
name: "SJ",
|
||||
type: "SHOES_MAIN_ONLY",
|
||||
abilityChunkTypesRequired: ["QSJ", "SRU", "IA"],
|
||||
},
|
||||
{
|
||||
name: "OS",
|
||||
type: "SHOES_MAIN_ONLY",
|
||||
abilityChunkTypesRequired: ["IRU", "SPU", "BRU"],
|
||||
},
|
||||
{
|
||||
name: "DR",
|
||||
type: "SHOES_MAIN_ONLY",
|
||||
abilityChunkTypesRequired: ["QSJ", "RES", "IA"],
|
||||
},
|
||||
] as const;
|
||||
|
||||
export const abilitiesShort = abilities.map((ability) => ability.name);
|
||||
|
||||
export const mainOnlyAbilitiesShort = abilities
|
||||
.filter((ability) => ability.type !== "STACKABLE")
|
||||
.map((ability) => ability.name);
|
||||
23
apps/web-react/app/modules/in-game-lists/brand-ids.ts
Normal file
23
apps/web-react/app/modules/in-game-lists/brand-ids.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
export const brandIds = [
|
||||
"B00",
|
||||
"B01",
|
||||
"B02",
|
||||
"B03",
|
||||
"B04",
|
||||
"B05",
|
||||
"B06",
|
||||
"B07",
|
||||
"B08",
|
||||
"B09",
|
||||
"B10",
|
||||
"B11",
|
||||
"B15",
|
||||
"B16",
|
||||
"B17",
|
||||
"B18",
|
||||
"B19",
|
||||
"B20",
|
||||
"B97",
|
||||
"B98",
|
||||
"B99",
|
||||
] as const;
|
||||
1554
apps/web-react/app/modules/in-game-lists/game-badge-ids.ts
Normal file
1554
apps/web-react/app/modules/in-game-lists/game-badge-ids.ts
Normal file
File diff suppressed because it is too large
Load Diff
2
apps/web-react/app/modules/in-game-lists/games.ts
Normal file
2
apps/web-react/app/modules/in-game-lists/games.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export const gamesShort = ["S1", "S2", "S3"] as const;
|
||||
export const versusShort = ["1v1", "2v2", "3v3", "4v4"] as const;
|
||||
176
apps/web-react/app/modules/in-game-lists/gear-ids.ts
Normal file
176
apps/web-react/app/modules/in-game-lists/gear-ids.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
export const headGearBrandGrouped = {
|
||||
B00: [
|
||||
1, 4008, 6005, 8004, 8007, 8015, 24000, 24001, 24002, 24003, 24004, 24005,
|
||||
24006, 24007, 24012, 24013, 24014, 24015, 24016, 24017, 24018, 24019, 24020,
|
||||
24021, 24022, 24023, 24024, 24025, 24026, 24027, 24028, 24029, 24030, 24031,
|
||||
24032, 24033, 24034, 24035, 24036, 24037, 24038, 24039, 24040, 28000, 28001,
|
||||
],
|
||||
B01: [1012, 3016, 6000, 7021, 9001, 9002, 9009],
|
||||
B02: [1010, 1018, 1020, 1021, 3009, 4018, 8002],
|
||||
B03: [1033, 1037, 3008, 3014, 4017, 4023],
|
||||
B04: [1007, 1008, 1009, 3003, 3004, 3006, 3012, 3013, 7006, 7008, 9004],
|
||||
B05: [
|
||||
2004, 3001, 3002, 3005, 4000, 4007, 5000, 5001, 5002, 5003, 5004, 5007,
|
||||
5008, 5009, 5010, 7002, 7007, 7009, 7011, 7013, 7014, 7015, 7016, 7017,
|
||||
7018, 8000, 8001, 8003, 8010, 8011, 8012,
|
||||
],
|
||||
B06: [1006, 1011, 1025, 2008, 3010, 4001, 4003, 4011, 6001, 8008, 9007, 9008],
|
||||
B07: [
|
||||
1000, 1003, 1004, 1005, 1023, 1026, 1029, 1030, 1036, 2003, 2005, 2011,
|
||||
2012, 4005, 4006, 4009, 4013, 6006, 7000, 7004, 7005, 7012,
|
||||
],
|
||||
B08: [1024, 1027, 1031, 2000, 2002, 3000, 3011, 3028, 3031, 4015, 4016],
|
||||
B09: [1001, 1038, 2001, 2010, 3025, 4002, 4004, 4010, 4012, 4014, 4019, 4022],
|
||||
B10: [1014, 3007, 6002, 7020, 9003, 9005, 24008, 24009, 24010, 24011],
|
||||
B11: [1002, 6003],
|
||||
B15: [2009, 3015, 3027, 8005],
|
||||
B16: [1019, 3022, 3024, 8006, 8009],
|
||||
B17: [3017, 3018, 3023, 6004, 8016, 8018, 10003],
|
||||
B18: [1034, 1035, 2013, 3030, 4020, 4021, 5011, 5012, 7019, 8019],
|
||||
B19: [1028, 1032, 3026],
|
||||
B20: [1039, 3021, 3029, 3032, 8014, 8017],
|
||||
B97: [
|
||||
21000, 21001, 21002, 21003, 21004, 21005, 21007, 21008, 21009, 21010, 21011,
|
||||
21012, 21013, 21014, 21015, 21016, 21017, 21018, 21019, 21020, 21021, 21022,
|
||||
21023,
|
||||
],
|
||||
B98: [
|
||||
27000, 27004, 27109, 27306, 27307, 27311, 29200, 29201, 29202, 29203, 29204,
|
||||
],
|
||||
B99: [
|
||||
25000, 25001, 25002, 25003, 25004, 25005, 25006, 25007, 25008, 25009, 25010,
|
||||
25011, 25012, 25013, 25016, 25017, 25018, 25019, 25020, 25021, 25022,
|
||||
],
|
||||
} as const;
|
||||
|
||||
export const headGearIds = Object.values(headGearBrandGrouped).flat();
|
||||
|
||||
export const clothesGearBrandGrouped = {
|
||||
B00: [
|
||||
2, 1000, 1006, 1022, 1023, 1030, 1035, 1052, 1053, 1062, 1075, 1076, 1077,
|
||||
2008, 2010, 3000, 3001, 3002, 3003, 3005, 3013, 7001, 7002, 7006, 26000,
|
||||
28000, 28001,
|
||||
],
|
||||
B01: [
|
||||
1031, 1040, 1057, 1058, 1082, 1083, 2011, 2014, 2028, 3004, 4005, 5004,
|
||||
5008, 6000, 6001, 8004,
|
||||
],
|
||||
B02: [
|
||||
1007, 1008, 1041, 1051, 5016, 7016, 7017, 7020, 8001, 8018, 8024, 8030,
|
||||
8034, 8042, 9004, 9005, 10012, 10014,
|
||||
],
|
||||
B03: [
|
||||
1004, 1005, 1036, 1037, 1038, 1039, 1042, 1043, 2006, 2007, 5006, 5007,
|
||||
8000, 8010, 8011,
|
||||
],
|
||||
B04: [
|
||||
2001, 2004, 2018, 2019, 4004, 5003, 5014, 5015, 5024, 5025, 5026, 5028,
|
||||
5034, 7005, 8002, 8006, 8009, 8012, 10002, 10009,
|
||||
],
|
||||
B05: [3009, 5010, 5011, 5012, 5013, 5020, 5021, 5035, 5038, 5048, 8005],
|
||||
B06: [
|
||||
1010, 1020, 1021, 1026, 1029, 1034, 1046, 2023, 2024, 7003, 8008, 9002,
|
||||
9003, 10000, 10001,
|
||||
],
|
||||
B07: [
|
||||
1009, 1011, 1012, 1017, 1027, 1048, 1055, 1084, 1085, 1086, 1087, 1088,
|
||||
1089, 5065, 8027, 10006, 10007, 10010,
|
||||
],
|
||||
B08: [
|
||||
1018, 1019, 2000, 2003, 2005, 2013, 2016, 4000, 4003, 5005, 5009, 5040,
|
||||
5063, 7000, 7004, 8003, 8007, 8013, 8014, 8015, 8021, 8037, 8038, 10004,
|
||||
10008,
|
||||
],
|
||||
B09: [
|
||||
1015, 1016, 2009, 2012, 5000, 5002, 5017, 5019, 5027, 5039, 5051, 5052,
|
||||
5054, 5060, 9000, 9001, 9007, 9010, 9014,
|
||||
],
|
||||
B10: [
|
||||
1001, 1003, 1054, 1063, 1068, 1069, 1070, 3007, 3010, 3011, 4006, 6006,
|
||||
9009, 9016,
|
||||
],
|
||||
B11: [
|
||||
1013, 1014, 1049, 1050, 1078, 1079, 1081, 2002, 2021, 3006, 3008, 4001,
|
||||
4002, 4007, 5001, 5018, 5061, 6007, 6008, 7015, 7018, 7019, 8041,
|
||||
],
|
||||
B15: [
|
||||
1047, 1066, 1067, 2015, 2017, 2026, 2027, 5041, 5053, 5064, 7010, 7011,
|
||||
7012, 8017, 8020, 8022, 8023, 8025,
|
||||
],
|
||||
B16: [
|
||||
1033, 1056, 1059, 1060, 1095, 4008, 4009, 4010, 5033, 6003, 6004, 6005,
|
||||
7013, 9011, 9012, 10005,
|
||||
],
|
||||
B17: [
|
||||
1032, 5022, 5023, 5029, 5036, 5042, 5043, 5049, 5050, 5055, 5056, 7007,
|
||||
7008, 7009, 8019, 8026, 8028, 8035, 8036, 9008, 9013, 9015,
|
||||
],
|
||||
B18: [1092, 1093, 7021, 7022, 7023, 8032, 8039, 8043, 10013, 10015],
|
||||
B19: [1071, 1072, 1073, 1074, 1094, 5045, 5046, 5057, 5058, 5062],
|
||||
B20: [1090, 1091, 5047, 5059, 7024, 7025, 8031, 8033, 8040, 9017],
|
||||
B97: [
|
||||
21000, 21001, 21002, 21003, 21004, 21005, 21006, 21008, 21009, 21010, 21011,
|
||||
21012, 21013, 21014, 21015, 21016, 21017,
|
||||
],
|
||||
B98: [3, 1028, 2020, 2022, 7014, 27000, 27004, 27306, 27307, 27311, 29200],
|
||||
B99: [
|
||||
25000, 25001, 25002, 25003, 25004, 25005, 25006, 25007, 25008, 25009, 25010,
|
||||
25011, 25012, 25013, 25014, 25015, 25017, 25018, 25019, 25020, 25021, 25022,
|
||||
],
|
||||
} as const;
|
||||
|
||||
export const clothesGearIds = Object.values(clothesGearBrandGrouped).flat();
|
||||
|
||||
export const shoesGearBrandGrouped = {
|
||||
B00: [28000, 28001],
|
||||
B01: [
|
||||
1003, 1005, 1007, 1015, 1016, 1026, 1027, 2000, 2001, 2003, 2006, 2010,
|
||||
2011, 2012, 3031, 4017, 4021, 4022,
|
||||
],
|
||||
B02: [
|
||||
1, 1001, 1006, 2002, 2004, 2005, 2040, 4000, 4001, 4002, 4003, 4027, 4028,
|
||||
7000, 7001, 7002, 7003, 23007, 23015,
|
||||
],
|
||||
B03: [
|
||||
4014, 6000, 6001, 6002, 6003, 6006, 6007, 6008, 6013, 6014, 6017, 6019,
|
||||
6020, 6025, 6028, 6030, 8000, 8001, 8002, 8003, 8004, 8005, 8006, 8007,
|
||||
8008, 8009, 8014, 8015, 23002,
|
||||
],
|
||||
B04: [1000, 1002, 1004, 1017, 1018, 1019, 4008, 23000, 23004],
|
||||
B05: [],
|
||||
B06: [4011, 4012],
|
||||
B07: [],
|
||||
B08: [1008, 1009, 1014, 2008, 2009, 2013, 6012, 6018],
|
||||
B09: [
|
||||
1012, 1013, 3028, 3029, 4007, 4009, 4010, 4013, 5000, 5001, 5002, 6004,
|
||||
6005, 6009,
|
||||
],
|
||||
B10: [
|
||||
1021, 2014, 2015, 2019, 2023, 2024, 2029, 2030, 2031, 2032, 2033, 2037,
|
||||
2043, 2044, 3000, 3002, 3004, 3005, 3006, 3007, 3009, 3010, 3011, 3012,
|
||||
3014, 3018, 3020, 3021, 3022, 3030, 6010, 6011,
|
||||
],
|
||||
B11: [
|
||||
1010, 1011, 2016, 2020, 2028, 2034, 2035, 2039, 2042, 3001, 3003, 3008,
|
||||
3019, 3024, 4015, 4018, 23003, 23006,
|
||||
],
|
||||
B15: [4019, 6016, 6023, 6024, 8010, 8011, 8016],
|
||||
B16: [
|
||||
2017, 2018, 2022, 2025, 2026, 2027, 2038, 2050, 2051, 2053, 3015, 3016,
|
||||
3017, 3026, 23001, 23005, 23008, 23009, 23010,
|
||||
],
|
||||
B17: [1020, 1023, 1025, 2036, 2045, 2046, 2052, 3013],
|
||||
B18: [
|
||||
2047, 2048, 4023, 4024, 4025, 4026, 6027, 7004, 23011, 23012, 23013, 23016,
|
||||
],
|
||||
B19: [1022, 1024, 2049, 6021, 6022, 6026, 6029, 8013, 23014],
|
||||
B20: [3023, 3025, 3027, 4016, 4020],
|
||||
B97: [21001, 21002, 21003, 21004, 21005, 21006, 21007, 21008, 21009],
|
||||
B98: [27000, 27004, 27306, 27307, 27311, 29200],
|
||||
B99: [
|
||||
25000, 25001, 25002, 25003, 25004, 25005, 25006, 25007, 25008, 25009, 25010,
|
||||
25011, 25012, 25013, 25014, 25015, 25018, 25019, 25020, 25021, 25022,
|
||||
],
|
||||
} as const;
|
||||
|
||||
export const shoesGearIds = Object.values(shoesGearBrandGrouped).flat();
|
||||
6
apps/web-react/app/modules/in-game-lists/modes.ts
Normal file
6
apps/web-react/app/modules/in-game-lists/modes.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import type { RankedModeShort } from "./types";
|
||||
|
||||
export const modesShort = ["TW", "SZ", "TC", "RM", "CB"] as const;
|
||||
export const rankedModesShort = modesShort.slice(1) as RankedModeShort[];
|
||||
|
||||
export const modesShortWithSpecial = [...modesShort, "TB", "SR"] as const;
|
||||
60
apps/web-react/app/modules/in-game-lists/stage-ids.ts
Normal file
60
apps/web-react/app/modules/in-game-lists/stage-ids.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
// when adding new stage id also update the array in create-misc-json.ts and regenerate the jsons
|
||||
export const stageIds = [
|
||||
0, // Scorch Gorge
|
||||
1, // Eeltail Alley
|
||||
2, // Hagglefish Market
|
||||
3, // Undertow Spillway
|
||||
4, // Mincemeat Metalworks
|
||||
5, // Hammerhead Bridge
|
||||
6, // Museum d'Alfonsino
|
||||
7, // Mahi-Mahi Resort
|
||||
8, // Inkblot Art Academy
|
||||
9, // Sturgeon Shipyard
|
||||
10, // MakoMart
|
||||
11, // Wahoo World
|
||||
12, // Flounder Heights
|
||||
13, // Brinewater Springs
|
||||
14, // Manta Maria
|
||||
15, // Um'ami Ruins
|
||||
16, // Humpback Pump Track
|
||||
17, // Barnacle & Dime
|
||||
18, // Crableg Capital
|
||||
19, // Shipshape Cargo Co.
|
||||
20, // Bluefin Depot
|
||||
21, // Robo ROM-en
|
||||
22, // Marlin Airport
|
||||
23, // Lemuria Hub
|
||||
24, // Urchin Underpass
|
||||
] as const;
|
||||
|
||||
export const stagesObj = {
|
||||
SCORCH_GORGE: 0,
|
||||
EELTAIL_ALLEY: 1,
|
||||
HAGGLEFISH_MARKET: 2,
|
||||
UNDERTOW_SPILLWAY: 3,
|
||||
MINCEMEAT_METALWORKS: 4,
|
||||
HAMMERHEAD_BRIDGE: 5,
|
||||
MUSEUM_D_ALFONSINO: 6,
|
||||
MAHI_MAHI_RESORT: 7,
|
||||
INKBLOT_ART_ACADEMY: 8,
|
||||
STURGEON_SHIPYARD: 9,
|
||||
MAKOMART: 10,
|
||||
WAHOO_WORLD: 11,
|
||||
FLOUNDER_HEIGHTS: 12,
|
||||
BRINEWATER_SPRINGS: 13,
|
||||
MANTA_MARIA: 14,
|
||||
UM_AMI_RUINS: 15,
|
||||
HUMPBACK_PUMP_TRACK: 16,
|
||||
BARNACLE_AND_DIME: 17,
|
||||
CRABLEG_CAPITAL: 18,
|
||||
SHIPSHAPE_CARGO_CO: 19,
|
||||
BLUEFIN_DEPOT: 20,
|
||||
ROBO_ROM_EN: 21,
|
||||
MARLIN_AIRPORT: 22,
|
||||
LEMURIA_HUB: 23,
|
||||
URCHIN_UNDERPASS: 24,
|
||||
} as const;
|
||||
|
||||
export function shortStageName(fullName: string) {
|
||||
return fullName.split(" ")[0];
|
||||
}
|
||||
55
apps/web-react/app/modules/in-game-lists/types.ts
Normal file
55
apps/web-react/app/modules/in-game-lists/types.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import type { abilities } from "./abilities";
|
||||
import type { brandIds } from "./brand-ids";
|
||||
import type { modesShort, modesShortWithSpecial } from "./modes";
|
||||
import type { stageIds } from "./stage-ids";
|
||||
import type {
|
||||
mainWeaponIds,
|
||||
specialWeaponIds,
|
||||
subWeaponIds,
|
||||
} from "./weapon-ids";
|
||||
|
||||
export type ModeShort = (typeof modesShort)[number];
|
||||
export type ModeShortWithSpecial = (typeof modesShortWithSpecial)[number];
|
||||
export type RankedModeShort = Exclude<ModeShort, "TW">;
|
||||
|
||||
export type StageId = (typeof stageIds)[number];
|
||||
|
||||
export type ModeWithStage = { mode: ModeShort; stageId: StageId };
|
||||
|
||||
export type GearType = "HEAD" | "CLOTHES" | "SHOES";
|
||||
|
||||
export type Ability = (typeof abilities)[number]["name"];
|
||||
export type AbilityWithUnknown = (typeof abilities)[number]["name"] | "UNKNOWN";
|
||||
export type AbilityType = (typeof abilities)[number]["type"];
|
||||
|
||||
export type MainWeaponId = (typeof mainWeaponIds)[number];
|
||||
export type SubWeaponId = (typeof subWeaponIds)[number];
|
||||
export type SpecialWeaponId = (typeof specialWeaponIds)[number];
|
||||
|
||||
export type BuildAbilitiesTuple = [
|
||||
head: [main: Ability, s1: Ability, s2: Ability, s3: Ability],
|
||||
clothes: [main: Ability, s1: Ability, s2: Ability, s3: Ability],
|
||||
shoes: [main: Ability, s1: Ability, s2: Ability, s3: Ability],
|
||||
];
|
||||
export type BuildAbilitiesTupleWithUnknown = [
|
||||
head: [
|
||||
main: AbilityWithUnknown,
|
||||
s1: AbilityWithUnknown,
|
||||
s2: AbilityWithUnknown,
|
||||
s3: AbilityWithUnknown,
|
||||
],
|
||||
clothes: [
|
||||
main: AbilityWithUnknown,
|
||||
s1: AbilityWithUnknown,
|
||||
s2: AbilityWithUnknown,
|
||||
s3: AbilityWithUnknown,
|
||||
],
|
||||
shoes: [
|
||||
main: AbilityWithUnknown,
|
||||
s1: AbilityWithUnknown,
|
||||
s2: AbilityWithUnknown,
|
||||
s3: AbilityWithUnknown,
|
||||
],
|
||||
];
|
||||
|
||||
export type BrandId = (typeof brandIds)[number];
|
||||
79
apps/web-react/app/modules/in-game-lists/utils.test.ts
Normal file
79
apps/web-react/app/modules/in-game-lists/utils.test.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import type { MainWeaponId } from "./types";
|
||||
import { filterWeapon } from "./utils";
|
||||
|
||||
describe("filterWeapon", () => {
|
||||
const sBlast = { type: "MAIN" as const, id: 260 as MainWeaponId };
|
||||
|
||||
test("matches ignoring hyphens (e.g. 's blast' finds 'S-BLAST')", () => {
|
||||
expect(
|
||||
filterWeapon({
|
||||
weapon: sBlast,
|
||||
weaponName: "S-BLAST '92",
|
||||
searchTerm: "s blast",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("matches with the hyphen still present", () => {
|
||||
expect(
|
||||
filterWeapon({
|
||||
weapon: sBlast,
|
||||
weaponName: "S-BLAST '92",
|
||||
searchTerm: "s-blast",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("matches ignoring case", () => {
|
||||
expect(
|
||||
filterWeapon({
|
||||
weapon: sBlast,
|
||||
weaponName: "S-BLAST '92",
|
||||
searchTerm: "SBLAST",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
const neoSplash = { type: "MAIN" as const, id: 22 as MainWeaponId };
|
||||
|
||||
test("matches a full alt name", () => {
|
||||
expect(
|
||||
filterWeapon({
|
||||
weapon: neoSplash,
|
||||
weaponName: "Neo Splash-o-matic",
|
||||
searchTerm: "gecko",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("alt names match on the full alt name only, not a partial one", () => {
|
||||
expect(
|
||||
filterWeapon({
|
||||
weapon: neoSplash,
|
||||
weaponName: "Neo Splash-o-matic",
|
||||
searchTerm: "geck",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("alt names match on the full alt name only, also when the weapon has a single alt name", () => {
|
||||
expect(
|
||||
filterWeapon({
|
||||
weapon: { type: "MAIN", id: 10 as MainWeaponId },
|
||||
weaponName: "Splattershot Jr.",
|
||||
searchTerm: "vj",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("does not match unrelated weapon", () => {
|
||||
expect(
|
||||
filterWeapon({
|
||||
weapon: sBlast,
|
||||
weaponName: "S-BLAST '92",
|
||||
searchTerm: "splattershot",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
44
apps/web-react/app/modules/in-game-lists/utils.ts
Normal file
44
apps/web-react/app/modules/in-game-lists/utils.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { AnyWeapon } from "~/features/build-analyzer/analyzer-types";
|
||||
import { weaponAltNames } from "~/modules/in-game-lists/weapon-alt-names";
|
||||
import { abilities } from "./abilities";
|
||||
import type { Ability } from "./types";
|
||||
|
||||
export function isAbility(value: string): value is Ability {
|
||||
return Boolean(abilities.some((a) => a.name === value));
|
||||
}
|
||||
|
||||
const normalizeTerm = (term: string): string => {
|
||||
return term
|
||||
.normalize("NFD")
|
||||
.replace(/\p{M}/gu, "")
|
||||
.replace(/[^\p{L}\p{N}]/gu, "")
|
||||
.toLocaleLowerCase();
|
||||
};
|
||||
|
||||
export function filterWeapon({
|
||||
weapon,
|
||||
weaponName,
|
||||
searchTerm,
|
||||
}: {
|
||||
weapon: AnyWeapon;
|
||||
weaponName: string;
|
||||
searchTerm: string;
|
||||
}): boolean {
|
||||
const normalizedSearchTerm = normalizeTerm(searchTerm);
|
||||
const normalizedWeaponName = normalizeTerm(weaponName);
|
||||
|
||||
if (normalizedWeaponName.includes(normalizedSearchTerm)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (weapon.type === "MAIN") {
|
||||
const altNames = weaponAltNames.get(weapon.id);
|
||||
if (!altNames) return false;
|
||||
|
||||
const altNamesList = typeof altNames === "string" ? [altNames] : altNames;
|
||||
|
||||
return altNamesList.includes(normalizedSearchTerm);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
79
apps/web-react/app/modules/in-game-lists/weapon-alt-names.ts
Normal file
79
apps/web-react/app/modules/in-game-lists/weapon-alt-names.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import type { MainWeaponId } from "./types";
|
||||
|
||||
export const weaponAltNames = new Map<MainWeaponId, string[] | string>()
|
||||
.set(10, "vjr")
|
||||
.set(11, "cjr")
|
||||
.set(21, "nsplash")
|
||||
.set(22, ["gecko", "ksplash", "barazushi"])
|
||||
.set(32, ["color", "kaero", "emberz"])
|
||||
.set(41, "ttek")
|
||||
.set(42, ["shiny", "kshot", "emberz"])
|
||||
.set(46, "ttek")
|
||||
.set(60, "nzap")
|
||||
.set(61, "nzap")
|
||||
.set(72, ["frozen", "kpro", "barazushi"])
|
||||
.set(81, "96d")
|
||||
.set(82, ["k96", "emberz"])
|
||||
.set(91, ["cjs", "cjet"])
|
||||
.set(92, ["cobra", "kjet", "barazushi"])
|
||||
.set(212, ["shiny", "kblast", "emberz"])
|
||||
.set(220, "rb")
|
||||
.set(221, ["rb", "crb"])
|
||||
.set(251, "rpd")
|
||||
.set(252, ["winter", "krb", "barazushi"])
|
||||
.set(300, "l3")
|
||||
.set(301, "l3d")
|
||||
.set(302, ["shiny", "kl3", "emberz"])
|
||||
.set(310, "h3")
|
||||
.set(311, "h3d")
|
||||
.set(312, ["viper", "kh3", "barazushi"])
|
||||
.set(1002, ["angle", "kcarbon", "barazushi"])
|
||||
.set(1022, ["galaxy", "kdynamo", "emberz"])
|
||||
.set(1041, "swex")
|
||||
.set(1042, ["galaxy", "kswig", "emberz"])
|
||||
.set(1112, ["galaxy", "kocto", "kbrush", "emberz"])
|
||||
.set(1120, "vincent")
|
||||
.set(1121, "vincent")
|
||||
.set(1122, ["bronze", "kpain", "barazushi"])
|
||||
.set(2011, "zf")
|
||||
.set(2012, ["camo", "ksplat", "barazushi"])
|
||||
.set(2021, "zf")
|
||||
.set(2022, ["camo", "kscope", "barazushi"])
|
||||
.set(2070, ["pen", "pencil"])
|
||||
.set(2071, ["pen", "pencil"])
|
||||
.set(3000, "bucket")
|
||||
.set(3001, "bucket")
|
||||
.set(3005, "bucket")
|
||||
.set(3012, ["ashen", "ktri", "barazushi"])
|
||||
.set(3052, ["camo", "kdread", "emberz"])
|
||||
.set(4001, "zimi")
|
||||
.set(4002, ["rattle", "kmini", "emberz"])
|
||||
.set(4022, ["khydra", "emberz"])
|
||||
.set(4030, "bp")
|
||||
.set(4031, "bpn")
|
||||
.set(4050, "hedit")
|
||||
.set(4051, "hedit")
|
||||
.set(5001, "napples")
|
||||
.set(5002, ["nocturnal", "kdapple", "barazushi"])
|
||||
.set(5012, ["pink", "kdualies", "emberz"])
|
||||
.set(5030, "vds")
|
||||
.set(5031, "cds")
|
||||
.set(5032, ["hds", "kds", "emberz"])
|
||||
.set(5040, "detras")
|
||||
.set(5041, "letras")
|
||||
.set(6010, "tent")
|
||||
.set(6011, "tent")
|
||||
.set(6012, "tent")
|
||||
.set(6022, ["kcover", "emberz"])
|
||||
.set(7010, "bow")
|
||||
.set(7011, "bow")
|
||||
.set(7012, ["bow", "kbow", "emberz"])
|
||||
.set(7015, "bow")
|
||||
.set(7022, ["milk", "kreef", "barazushi"])
|
||||
.set(8000, ["sword", "chainsaw", "stamp"])
|
||||
.set(8001, ["sword", "chainsaw", "stamp"])
|
||||
.set(8002, ["sword", "chainsaw", "stamp", "kstamp", "emberz"])
|
||||
.set(8005, ["sword", "chainsaw", "stamp"])
|
||||
.set(8010, ["sword", "vwiper"])
|
||||
.set(8011, ["sword", "diper", "dwiper"])
|
||||
.set(8012, ["rust", "kwiper", "barazushi"]);
|
||||
41
apps/web-react/app/modules/in-game-lists/weapon-ids.test.ts
Normal file
41
apps/web-react/app/modules/in-game-lists/weapon-ids.test.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { weaponIdToArrayWithAlts, weaponIdToBaseWeaponId } from "./weapon-ids";
|
||||
|
||||
describe("weaponIdToArrayWithAlts", () => {
|
||||
test("handles weapon id without alts", () => {
|
||||
const id = 0;
|
||||
const result = weaponIdToArrayWithAlts(id);
|
||||
expect(result).toEqual([0]);
|
||||
});
|
||||
|
||||
test("handles weapon id with alts", () => {
|
||||
const id = 40;
|
||||
const result = weaponIdToArrayWithAlts(id);
|
||||
expect(result).toEqual([40, 45, 47]);
|
||||
});
|
||||
|
||||
test("handles alt weapon id", () => {
|
||||
const id = 45;
|
||||
const result = weaponIdToArrayWithAlts(id);
|
||||
expect(result).toEqual([40, 45, 47]);
|
||||
});
|
||||
|
||||
test("handles weapon id with only one alt", () => {
|
||||
const id = 41;
|
||||
const result = weaponIdToArrayWithAlts(id);
|
||||
expect(result).toEqual([41, 46]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("weaponIdToBaseWeaponId()", () => {
|
||||
test("returns correct IDs for 2nd & 3rd kit", () => {
|
||||
expect(weaponIdToBaseWeaponId(210)).toBe(210);
|
||||
expect(weaponIdToBaseWeaponId(211)).toBe(210);
|
||||
expect(weaponIdToBaseWeaponId(212)).toBe(210);
|
||||
});
|
||||
|
||||
test("returns correct ID for alt kits", () => {
|
||||
expect(weaponIdToBaseWeaponId(45)).toBe(40);
|
||||
expect(weaponIdToBaseWeaponId(47)).toBe(40);
|
||||
});
|
||||
});
|
||||
320
apps/web-react/app/modules/in-game-lists/weapon-ids.ts
Normal file
320
apps/web-react/app/modules/in-game-lists/weapon-ids.ts
Normal file
@@ -0,0 +1,320 @@
|
||||
import { assertUnreachable } from "~/utils/types";
|
||||
import type { MainWeaponId, SpecialWeaponId, SubWeaponId } from "./types";
|
||||
|
||||
export const weaponCategories = [
|
||||
{
|
||||
name: "SHOOTERS",
|
||||
weaponIds: [
|
||||
0, 1, 10, 11, 20, 21, 22, 30, 31, 32, 40, 41, 42, 45, 46, 47, 50, 51, 60,
|
||||
61, 70, 71, 72, 80, 81, 82, 90, 91, 92, 100, 101, 300, 301, 302, 310, 311,
|
||||
312, 400, 401,
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "BLASTERS",
|
||||
weaponIds: [
|
||||
200, 201, 205, 210, 211, 212, 220, 221, 230, 231, 240, 241, 250, 251, 252,
|
||||
260, 261,
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "ROLLERS",
|
||||
weaponIds: [
|
||||
1000, 1001, 1002, 1010, 1011, 1015, 1020, 1021, 1022, 1030, 1031, 1040,
|
||||
1041, 1042,
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "BRUSHES",
|
||||
weaponIds: [1100, 1101, 1110, 1111, 1112, 1115, 1120, 1121, 1122],
|
||||
},
|
||||
{
|
||||
name: "CHARGERS",
|
||||
weaponIds: [
|
||||
2000, 2001, 2010, 2011, 2012, 2015, 2020, 2021, 2022, 2030, 2031, 2040,
|
||||
2041, 2050, 2051, 2060, 2061, 2070, 2071,
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "SLOSHERS",
|
||||
weaponIds: [
|
||||
3000, 3001, 3005, 3010, 3011, 3012, 3020, 3021, 3030, 3031, 3040, 3041,
|
||||
3050, 3051, 3052,
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "SPLATLINGS",
|
||||
weaponIds: [
|
||||
4000, 4001, 4002, 4010, 4011, 4015, 4020, 4021, 4022, 4030, 4031, 4040,
|
||||
4041, 4050, 4051,
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "DUALIES",
|
||||
weaponIds: [
|
||||
5000, 5001, 5002, 5010, 5015, 5011, 5012, 5020, 5021, 5030, 5031, 5032,
|
||||
5040, 5041, 5050, 5051,
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "BRELLAS",
|
||||
weaponIds: [
|
||||
6000, 6001, 6005, 6010, 6011, 6012, 6020, 6021, 6022, 6030, 6031,
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "STRINGERS",
|
||||
weaponIds: [7010, 7011, 7012, 7015, 7020, 7021, 7022, 7030, 7031],
|
||||
},
|
||||
{
|
||||
name: "SPLATANAS",
|
||||
weaponIds: [8000, 8001, 8002, 8005, 8010, 8011, 8012, 8020, 8021],
|
||||
},
|
||||
] as const;
|
||||
|
||||
export const mainWeaponIds = weaponCategories
|
||||
.flatMap((category) => category.weaponIds)
|
||||
.sort((a, b) => a - b);
|
||||
|
||||
const weaponIdToAltId = new Map<MainWeaponId, MainWeaponId | MainWeaponId[]>([
|
||||
[40, [45, 47]],
|
||||
[41, 46],
|
||||
[200, 205],
|
||||
[1010, 1015],
|
||||
[1110, 1115],
|
||||
[2010, 2015],
|
||||
[3000, 3005],
|
||||
[4010, 4015],
|
||||
[5010, 5015],
|
||||
[6000, 6005],
|
||||
[7010, 7015],
|
||||
[8000, 8005],
|
||||
]);
|
||||
const altWeaponIdToId = new Map<MainWeaponId, MainWeaponId>([
|
||||
[45, 40],
|
||||
[47, 40],
|
||||
[46, 41],
|
||||
[205, 200],
|
||||
[1015, 1010],
|
||||
[1115, 1110],
|
||||
[2015, 2010],
|
||||
[3005, 3000],
|
||||
[4015, 4010],
|
||||
[5015, 5010],
|
||||
[6005, 6000],
|
||||
[7015, 7010],
|
||||
[8005, 8000],
|
||||
]);
|
||||
|
||||
/**
|
||||
* Folds an alt-skin weapon id to its base id, leaving non-alt ids untouched.
|
||||
*
|
||||
* Unlike {@link weaponIdToBaseWeaponId}, alt kits are preserved (e.g. Tentatek
|
||||
* Splattershot stays 41) — only cosmetic alt skins collapse to their base.
|
||||
*
|
||||
* Mirrors the `BuildWeapon.canonicalWeaponSplId` column so TypeScript callers
|
||||
* and SQL queries agree on what a weapon's canonical id is.
|
||||
*
|
||||
* @example
|
||||
* // Splattershot — base weapon, returned unchanged
|
||||
* canonicalWeaponSplId(40); // -> 40
|
||||
*
|
||||
* // Tentatek Splattershot — alt kit, returned unchanged
|
||||
* canonicalWeaponSplId(41); // -> 41
|
||||
*
|
||||
* // Hero Shot Replica — alt skin, folded to Splattershot
|
||||
* canonicalWeaponSplId(45); // -> 40
|
||||
*/
|
||||
export function canonicalWeaponSplId(weaponSplId: MainWeaponId): MainWeaponId {
|
||||
return altWeaponIdToId.get(weaponSplId) ?? weaponSplId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a given weapon ID to an array containing the weapon ID and its alternate IDs. For example if you enter the ID 40 (Splattershot) it will return [40, 45, 47] (Splattershot, Hero Shot Replica, Order Shot Replica)
|
||||
*/
|
||||
export function weaponIdToArrayWithAlts(weaponId: MainWeaponId) {
|
||||
const altId = weaponIdToAltId.get(weaponId);
|
||||
if (altId !== undefined) {
|
||||
return [weaponId, ...(Array.isArray(altId) ? altId : [altId])];
|
||||
}
|
||||
|
||||
const regularId = altWeaponIdToId.get(weaponId);
|
||||
if (regularId !== undefined) {
|
||||
const altId = weaponIdToAltId.get(regularId);
|
||||
if (altId !== undefined) {
|
||||
return [regularId, ...(Array.isArray(altId) ? altId : [altId])];
|
||||
}
|
||||
}
|
||||
|
||||
return [weaponId];
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines the type of weapon based on its ID
|
||||
*
|
||||
* @returns "ALT_SKIN" if the weapon is an alternate skin, "BASE" if it's a base weapon, or "ALT_KIT" if it's an alternate kit
|
||||
*
|
||||
* @example
|
||||
* // Splattershot is a base weapon
|
||||
* weaponIdToType(40); // -> "BASE"
|
||||
*
|
||||
* // Tentatek Splattershot is an alternate kit
|
||||
* weaponIdToType(41); // -> "ALT_KIT"
|
||||
*
|
||||
* // Hero Shot Replica is an alternate skin
|
||||
* weaponIdToType(45); // -> "ALT_SKIN"
|
||||
*/
|
||||
export const weaponIdToType = (weaponId: MainWeaponId) => {
|
||||
if (altWeaponIdToId.has(weaponId)) return "ALT_SKIN";
|
||||
if (weaponId === weaponIdToBaseWeaponId(weaponId)) return "BASE";
|
||||
|
||||
return "ALT_KIT";
|
||||
};
|
||||
|
||||
export const SPLAT_BOMB_ID = 0;
|
||||
export const SUCTION_BOMB_ID = 1;
|
||||
export const BURST_BOMB_ID = 2;
|
||||
export const SPRINKLER_ID = 3;
|
||||
export const SPLASH_WALL_ID = 4;
|
||||
export const FIZZY_BOMB_ID = 5;
|
||||
export const CURLING_BOMB_ID = 6;
|
||||
export const AUTO_BOMB_ID = 7;
|
||||
export const SQUID_BEAKON_ID = 8;
|
||||
export const POINT_SENSOR_ID = 9;
|
||||
export const INK_MINE_ID = 10;
|
||||
export const TOXIC_MIST_ID = 11;
|
||||
export const ANGLE_SHOOTER_ID = 12;
|
||||
export const TORPEDO_ID = 13;
|
||||
|
||||
export const subWeaponIds = [
|
||||
SPLAT_BOMB_ID,
|
||||
SUCTION_BOMB_ID,
|
||||
BURST_BOMB_ID,
|
||||
SPRINKLER_ID,
|
||||
SPLASH_WALL_ID,
|
||||
FIZZY_BOMB_ID,
|
||||
CURLING_BOMB_ID,
|
||||
AUTO_BOMB_ID,
|
||||
SQUID_BEAKON_ID,
|
||||
POINT_SENSOR_ID,
|
||||
INK_MINE_ID,
|
||||
TOXIC_MIST_ID,
|
||||
ANGLE_SHOOTER_ID,
|
||||
TORPEDO_ID,
|
||||
] as const;
|
||||
|
||||
export const nonBombSubWeaponIds = [
|
||||
SPRINKLER_ID,
|
||||
SPLASH_WALL_ID,
|
||||
SQUID_BEAKON_ID,
|
||||
POINT_SENSOR_ID,
|
||||
TOXIC_MIST_ID,
|
||||
] as SubWeaponId[];
|
||||
|
||||
export const TRIZOOKA_ID = 1;
|
||||
export const BIG_BUBBLER_ID = 2;
|
||||
export const ZIPCASTER_ID = 3;
|
||||
const TENTA_MISSILES_ID = 4;
|
||||
export const INK_STORM_ID = 5;
|
||||
export const BOOYAH_BOMB_ID = 6;
|
||||
export const WAVE_BREAKER_ID = 7;
|
||||
export const INK_VAC_ID = 8;
|
||||
export const KILLER_WAIL_ID = 9;
|
||||
const INKJET_ID = 10;
|
||||
const ULTRA_STAMP_ID = 11;
|
||||
export const CRAB_TANK_ID = 12;
|
||||
const REEF_SLIDER_ID = 13;
|
||||
const TRIPLE_INKSTRIKE_ID = 14;
|
||||
const TACTICOOLER_ID = 15;
|
||||
export const SUPER_CHUMP_ID = 16;
|
||||
const KRAKEN_ROYALE_ID = 17;
|
||||
export const TRIPLE_SPLASHDOWN_ID = 18;
|
||||
export const SPLATTERCOLOR_SCREEN_ID = 19;
|
||||
|
||||
export const specialWeaponIds = [
|
||||
TRIZOOKA_ID,
|
||||
BIG_BUBBLER_ID,
|
||||
ZIPCASTER_ID,
|
||||
TENTA_MISSILES_ID,
|
||||
INK_STORM_ID,
|
||||
BOOYAH_BOMB_ID,
|
||||
WAVE_BREAKER_ID,
|
||||
INK_VAC_ID,
|
||||
KILLER_WAIL_ID,
|
||||
INKJET_ID,
|
||||
ULTRA_STAMP_ID,
|
||||
CRAB_TANK_ID,
|
||||
REEF_SLIDER_ID,
|
||||
TRIPLE_INKSTRIKE_ID,
|
||||
TACTICOOLER_ID,
|
||||
SUPER_CHUMP_ID,
|
||||
KRAKEN_ROYALE_ID,
|
||||
TRIPLE_SPLASHDOWN_ID,
|
||||
SPLATTERCOLOR_SCREEN_ID,
|
||||
] as const;
|
||||
|
||||
export const nonDamagingSpecialWeaponIds = [BIG_BUBBLER_ID, TACTICOOLER_ID];
|
||||
|
||||
export const exampleMainWeaponIdWithSpecialWeaponId = (
|
||||
specialWeaponId: SpecialWeaponId,
|
||||
): MainWeaponId => {
|
||||
switch (specialWeaponId) {
|
||||
case TRIZOOKA_ID:
|
||||
return 40;
|
||||
case BIG_BUBBLER_ID:
|
||||
return 10;
|
||||
case ZIPCASTER_ID:
|
||||
return 8000;
|
||||
case TENTA_MISSILES_ID:
|
||||
return 1030;
|
||||
case INK_STORM_ID:
|
||||
return 3040;
|
||||
case BOOYAH_BOMB_ID:
|
||||
return 3020;
|
||||
case WAVE_BREAKER_ID:
|
||||
return 220;
|
||||
case INK_VAC_ID:
|
||||
return 2010;
|
||||
case KILLER_WAIL_ID:
|
||||
return 50;
|
||||
case INKJET_ID:
|
||||
return 3010;
|
||||
case ULTRA_STAMP_ID:
|
||||
return 4000;
|
||||
case CRAB_TANK_ID:
|
||||
return 20;
|
||||
case REEF_SLIDER_ID:
|
||||
return 5040;
|
||||
case TRIPLE_INKSTRIKE_ID:
|
||||
return 41;
|
||||
case TACTICOOLER_ID:
|
||||
return 60;
|
||||
case SUPER_CHUMP_ID:
|
||||
return 61;
|
||||
case KRAKEN_ROYALE_ID:
|
||||
return 4011;
|
||||
case TRIPLE_SPLASHDOWN_ID:
|
||||
return 211;
|
||||
case SPLATTERCOLOR_SCREEN_ID:
|
||||
return 401;
|
||||
default: {
|
||||
assertUnreachable(specialWeaponId);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculates the base weapon ID from a main weapon ID.
|
||||
*
|
||||
* @example
|
||||
* // Returns 40 (because Splattershot is the base weapon for its kit)
|
||||
* weaponIdToBaseWeaponId(40); // -> 40
|
||||
*
|
||||
* // (41 is Tentatek Splattershot, which is an alt kit of Splattershot)
|
||||
* weaponIdToBaseWeaponId(41); // -> 40
|
||||
*
|
||||
* // (45 is Hero Shot Replica, which is an alt skin of Splattershot)
|
||||
* weaponIdToBaseWeaponId(45); // -> 40
|
||||
*/
|
||||
export const weaponIdToBaseWeaponId = (id: MainWeaponId) =>
|
||||
(id - (id % 10)) as MainWeaponId;
|
||||
10
apps/web-react/app/modules/patreon/constants.ts
Normal file
10
apps/web-react/app/modules/patreon/constants.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export const PATREON_INITIAL_URL =
|
||||
"https://www.patreon.com/api/oauth2/v2/campaigns/2744004/members?include=currently_entitled_tiers,user&fields%5Buser%5D=social_connections&fields%5Bmember%5D=pledge_relationship_start";
|
||||
|
||||
// tier 1 lowest, tier 4 highest
|
||||
export const TIER_1_ID = "6959473";
|
||||
export const TIER_2_ID = "6381152";
|
||||
export const TIER_3_ID = "6381153";
|
||||
export const TIER_4_ID = "6959564";
|
||||
// free tier?
|
||||
export const UNKNOWN_TIER_ID = "10544667";
|
||||
1
apps/web-react/app/modules/patreon/index.ts
Normal file
1
apps/web-react/app/modules/patreon/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { updatePatreonData } from "./updater";
|
||||
74
apps/web-react/app/modules/patreon/schema.ts
Normal file
74
apps/web-react/app/modules/patreon/schema.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { z } from "zod";
|
||||
import {
|
||||
TIER_1_ID,
|
||||
TIER_2_ID,
|
||||
TIER_3_ID,
|
||||
TIER_4_ID,
|
||||
UNKNOWN_TIER_ID,
|
||||
} from "./constants";
|
||||
|
||||
export const patreonRateLimitSchema = z.object({
|
||||
errors: z.array(
|
||||
z.object({
|
||||
retry_after_seconds: z.number().optional(),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
export const patronResponseSchema = z.object({
|
||||
data: z.array(
|
||||
z.object({
|
||||
attributes: z.object({
|
||||
pledge_relationship_start: z.string().nullish(),
|
||||
}),
|
||||
id: z.string(),
|
||||
relationships: z.object({
|
||||
currently_entitled_tiers: z.object({
|
||||
data: z.array(
|
||||
z.object({
|
||||
id: z.enum([
|
||||
TIER_1_ID,
|
||||
TIER_2_ID,
|
||||
TIER_3_ID,
|
||||
TIER_4_ID,
|
||||
UNKNOWN_TIER_ID,
|
||||
]),
|
||||
type: z.string(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
user: z.object({
|
||||
data: z.object({ id: z.string(), type: z.string() }),
|
||||
links: z.object({ related: z.string() }),
|
||||
}),
|
||||
}),
|
||||
type: z.string(),
|
||||
}),
|
||||
),
|
||||
included: z
|
||||
.array(
|
||||
z.object({
|
||||
attributes: z.object({
|
||||
social_connections: z
|
||||
.object({
|
||||
discord: z
|
||||
.object({
|
||||
user_id: z.string(),
|
||||
})
|
||||
.nullish(),
|
||||
})
|
||||
.nullish(),
|
||||
}),
|
||||
id: z.string(),
|
||||
type: z.string(),
|
||||
}),
|
||||
)
|
||||
.nullish(),
|
||||
links: z.object({ next: z.string() }).nullish(),
|
||||
meta: z.object({
|
||||
pagination: z.object({
|
||||
cursors: z.object({ next: z.string().nullish() }),
|
||||
total: z.number(),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
189
apps/web-react/app/modules/patreon/updater.ts
Normal file
189
apps/web-react/app/modules/patreon/updater.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
import type { z } from "zod";
|
||||
import { ServerConfig } from "~/config.server";
|
||||
import { STAFF_DISCORD_IDS } from "~/features/admin/admin-constants";
|
||||
import * as TrophyRepository from "~/features/trophies/TrophyRepository.server";
|
||||
import * as UserRepository from "~/features/user-page/UserRepository.server";
|
||||
import { dateToDatabaseTimestamp } from "~/utils/dates";
|
||||
import { fetchWithTimeout } from "~/utils/fetch";
|
||||
import { logger } from "~/utils/logger";
|
||||
import type { Unpacked } from "~/utils/types";
|
||||
import {
|
||||
PATREON_INITIAL_URL,
|
||||
TIER_1_ID,
|
||||
TIER_2_ID,
|
||||
TIER_3_ID,
|
||||
TIER_4_ID,
|
||||
UNKNOWN_TIER_ID,
|
||||
} from "./constants";
|
||||
import { patreonRateLimitSchema, patronResponseSchema } from "./schema";
|
||||
|
||||
export async function updatePatreonData(): Promise<void> {
|
||||
const patrons: UserRepository.UpdatePatronDataArgs = [];
|
||||
const noDiscordConnected: Array<string> = [];
|
||||
const noDataIds: Array<string> = [];
|
||||
let nextUrlToFetchWith = PATREON_INITIAL_URL;
|
||||
|
||||
while (nextUrlToFetchWith) {
|
||||
const patronData = await fetchPatronData(nextUrlToFetchWith);
|
||||
|
||||
const parsed = parsePatronData(patronData);
|
||||
patrons.push(...parsed.patrons);
|
||||
noDiscordConnected.push(...parsed.noDiscordConnection);
|
||||
noDataIds.push(...parsed.noDataIds);
|
||||
|
||||
// TS freaks out if we don't keep nextUrlToFetchWith string so that's why this weird thing here
|
||||
nextUrlToFetchWith = patronData.links?.next ?? "";
|
||||
}
|
||||
|
||||
const patronsWithMods: UserRepository.UpdatePatronDataArgs = [
|
||||
...patrons,
|
||||
...STAFF_DISCORD_IDS.filter((discordId) =>
|
||||
patrons.every((p) => p.discordId !== discordId),
|
||||
).map((discordId) => ({
|
||||
discordId,
|
||||
patronTier: 4,
|
||||
patronStartedAt: dateToDatabaseTimestamp(new Date()),
|
||||
})),
|
||||
];
|
||||
|
||||
await UserRepository.updatePatronData(patronsWithMods);
|
||||
await TrophyRepository.syncSpecialTrophies();
|
||||
}
|
||||
|
||||
const MAX_RETRIES = 10;
|
||||
const DEFAULT_RETRY_AFTER_SECONDS = 10;
|
||||
const MAX_RETRY_AFTER_SECONDS = 60;
|
||||
|
||||
async function fetchPatronData(urlToFetch: string) {
|
||||
if (!ServerConfig.patreon.accessToken) {
|
||||
throw new Error("Missing Patreon access token");
|
||||
}
|
||||
|
||||
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
||||
const response = await fetchWithTimeout(
|
||||
urlToFetch,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${ServerConfig.patreon.accessToken}`,
|
||||
},
|
||||
},
|
||||
30_000,
|
||||
);
|
||||
|
||||
if (response.status === 429) {
|
||||
if (attempt === MAX_RETRIES) {
|
||||
throw new Error(
|
||||
`Patreon rate limit exceeded after ${MAX_RETRIES} retries`,
|
||||
);
|
||||
}
|
||||
|
||||
const parsed = patreonRateLimitSchema.safeParse(await response.json());
|
||||
const retryAfterSeconds = Math.min(
|
||||
parsed.success
|
||||
? (parsed.data.errors[0]?.retry_after_seconds ??
|
||||
DEFAULT_RETRY_AFTER_SECONDS)
|
||||
: DEFAULT_RETRY_AFTER_SECONDS,
|
||||
MAX_RETRY_AFTER_SECONDS,
|
||||
);
|
||||
|
||||
logger.warn(
|
||||
`Patreon rate limited, retrying in ${retryAfterSeconds}s (attempt ${attempt + 1}/${MAX_RETRIES})`,
|
||||
);
|
||||
await sleep(retryAfterSeconds * 1000);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Patreon response not successful. Status code was: ${response.status}`,
|
||||
);
|
||||
}
|
||||
|
||||
return patronResponseSchema.parse(await response.json());
|
||||
}
|
||||
|
||||
throw new Error("Unexpected end of fetch retry loop");
|
||||
}
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function parsePatronData({
|
||||
data,
|
||||
included,
|
||||
}: z.infer<typeof patronResponseSchema>) {
|
||||
const patronsWithIds: Array<
|
||||
{
|
||||
patreonId: string;
|
||||
} & Omit<Unpacked<UserRepository.UpdatePatronDataArgs>, "discordId">
|
||||
> = [];
|
||||
|
||||
for (const patron of data) {
|
||||
if (patron.relationships.currently_entitled_tiers.data.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const tier = [TIER_4_ID, TIER_3_ID, TIER_2_ID, TIER_1_ID].find((id) =>
|
||||
patron.relationships.currently_entitled_tiers.data.some(
|
||||
(tier) => tier.id === id,
|
||||
),
|
||||
);
|
||||
|
||||
patronsWithIds.push({
|
||||
patreonId: patron.relationships.user.data.id,
|
||||
patronStartedAt: dateToDatabaseTimestamp(
|
||||
new Date(patron.attributes.pledge_relationship_start ?? Date.now()),
|
||||
),
|
||||
patronTier: idToTierNumber(tier),
|
||||
});
|
||||
}
|
||||
|
||||
const result: {
|
||||
patrons: UserRepository.UpdatePatronDataArgs;
|
||||
noDiscordConnection: Array<string>;
|
||||
noDataIds: string[];
|
||||
} = {
|
||||
patrons: [],
|
||||
noDiscordConnection: [],
|
||||
noDataIds: [],
|
||||
};
|
||||
for (const extraData of included ?? []) {
|
||||
if (extraData.type !== "user") continue;
|
||||
|
||||
const patronData = patronsWithIds.find((p) => p.patreonId === extraData.id);
|
||||
if (!patronData) {
|
||||
result.noDataIds.push(extraData.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
const discordId = extraData.attributes.social_connections?.discord?.user_id;
|
||||
if (!discordId) {
|
||||
result.noDiscordConnection.push(extraData.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
result.patrons.push({
|
||||
patronStartedAt: patronData.patronStartedAt,
|
||||
discordId,
|
||||
patronTier: patronData.patronTier,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function idToTierNumber(id: string | undefined) {
|
||||
if (!id || id === UNKNOWN_TIER_ID) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tier = [null, TIER_1_ID, TIER_2_ID, TIER_3_ID, TIER_4_ID].indexOf(id);
|
||||
|
||||
if (tier === -1) {
|
||||
logger.warn("Unknown tier for patron", id);
|
||||
return null;
|
||||
}
|
||||
|
||||
return tier;
|
||||
}
|
||||
33
apps/web-react/app/modules/permissions/guards.server.ts
Normal file
33
apps/web-react/app/modules/permissions/guards.server.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { requireUser } from "~/features/auth/core/user.server";
|
||||
import type { EntityWithPermissions, Role } from "~/modules/permissions/types";
|
||||
import { hasPermission } from "./utils";
|
||||
|
||||
/**
|
||||
* Checks if a user has the required global role.
|
||||
*
|
||||
* @throws {Response} - Throws a 403 Forbidden response if the user does not have the required role.
|
||||
*/
|
||||
export function requireRole(role: Role) {
|
||||
const user = requireUser();
|
||||
if (!user.roles.includes(role)) {
|
||||
throw new Response("Forbidden", { status: 403 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a user has the required permission to perform an action on a given entity.
|
||||
*
|
||||
* @throws {Response} - Throws a 403 Forbidden response if the user does not have the required permission.
|
||||
*/
|
||||
export function requirePermission<
|
||||
T extends EntityWithPermissions,
|
||||
K extends keyof T["permissions"],
|
||||
>(obj: T, permission: K) {
|
||||
const user = requireUser();
|
||||
|
||||
if (hasPermission(obj, permission, user)) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Response("Forbidden", { status: 403 });
|
||||
}
|
||||
30
apps/web-react/app/modules/permissions/hooks.ts
Normal file
30
apps/web-react/app/modules/permissions/hooks.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { useUser } from "~/features/auth/core/user";
|
||||
import type { EntityWithPermissions, Role } from "~/modules/permissions/types";
|
||||
import { hasPermission } from "./utils";
|
||||
|
||||
/**
|
||||
* Determines whether a user has a specific global role.
|
||||
*
|
||||
* @returns A boolean indicating whether the user has the specified role. Always false if user is not logged in.
|
||||
*/
|
||||
export function useHasRole(role: Role) {
|
||||
const user = useUser();
|
||||
|
||||
if (!user) return false;
|
||||
|
||||
return user.roles.includes(role);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines whether a user has a specific permission for a given entity.
|
||||
*
|
||||
* @returns A boolean indicating whether the user has the specified permission. Always false if user is not logged in.
|
||||
*/
|
||||
export function useHasPermission<
|
||||
T extends EntityWithPermissions,
|
||||
K extends keyof T["permissions"],
|
||||
>(obj: T, permission: K) {
|
||||
const user = useUser();
|
||||
|
||||
return hasPermission(obj, permission, user);
|
||||
}
|
||||
81
apps/web-react/app/modules/permissions/mapper.server.ts
Normal file
81
apps/web-react/app/modules/permissions/mapper.server.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import type { UserWithPlusTier } from "~/utils/kysely.server";
|
||||
import { userDiscordIdIsAged } from "~/utils/users";
|
||||
import type { Role } from "./types";
|
||||
import {
|
||||
isAdmin,
|
||||
isDev,
|
||||
isQa,
|
||||
isScannerTester,
|
||||
isStaff,
|
||||
isSupporter,
|
||||
} from "./utils";
|
||||
|
||||
export function userRoles(
|
||||
user: Pick<
|
||||
UserWithPlusTier,
|
||||
| "id"
|
||||
| "discordId"
|
||||
| "plusTier"
|
||||
| "isArtist"
|
||||
| "isTournamentOrganizer"
|
||||
| "isVideoAdder"
|
||||
| "isApiAccesser"
|
||||
| "patronTier"
|
||||
>,
|
||||
) {
|
||||
const result: Array<Role> = [];
|
||||
|
||||
if (isAdmin(user)) {
|
||||
result.push("ADMIN");
|
||||
}
|
||||
|
||||
if (isStaff(user) || isAdmin(user)) {
|
||||
result.push("STAFF");
|
||||
}
|
||||
|
||||
if (isDev(user)) {
|
||||
result.push("DEV");
|
||||
}
|
||||
|
||||
if (isQa(user)) {
|
||||
result.push("QA");
|
||||
}
|
||||
|
||||
if (isScannerTester(user)) {
|
||||
result.push("SCANNER_TESTER");
|
||||
}
|
||||
|
||||
if (typeof user.patronTier === "number") {
|
||||
result.push("MINOR_SUPPORT");
|
||||
}
|
||||
|
||||
if (isSupporter(user)) {
|
||||
result.push("SUPPORTER");
|
||||
}
|
||||
|
||||
if (typeof user.plusTier === "number") {
|
||||
result.push("PLUS_SERVER_MEMBER");
|
||||
}
|
||||
|
||||
if (user.isArtist) {
|
||||
result.push("ARTIST");
|
||||
}
|
||||
|
||||
if (user.isVideoAdder) {
|
||||
result.push("VIDEO_ADDER");
|
||||
}
|
||||
|
||||
if (user.isTournamentOrganizer || isSupporter(user)) {
|
||||
result.push("TOURNAMENT_ADDER");
|
||||
}
|
||||
|
||||
if (userDiscordIdIsAged(user) || isSupporter(user)) {
|
||||
result.push("CALENDAR_EVENT_ADDER");
|
||||
}
|
||||
|
||||
if (user.isTournamentOrganizer || user.isApiAccesser || isSupporter(user)) {
|
||||
result.push("API_ACCESSER");
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
21
apps/web-react/app/modules/permissions/types.ts
Normal file
21
apps/web-react/app/modules/permissions/types.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
export type Permissions = Record<string, number[]>;
|
||||
|
||||
export type EntityWithPermissions = {
|
||||
permissions: Permissions;
|
||||
};
|
||||
|
||||
/** Represents a "global" role with permissions associated to it */
|
||||
export type Role =
|
||||
| "ADMIN"
|
||||
| "STAFF"
|
||||
| "PLUS_SERVER_MEMBER"
|
||||
| "VIDEO_ADDER"
|
||||
| "ARTIST"
|
||||
| "CALENDAR_EVENT_ADDER"
|
||||
| "TOURNAMENT_ADDER"
|
||||
| "API_ACCESSER"
|
||||
| "QA"
|
||||
| "DEV"
|
||||
| "SCANNER_TESTER"
|
||||
| "SUPPORTER" // patrons of "Supporter" tier or higher
|
||||
| "MINOR_SUPPORT"; // patrons of "Support" tier or higher
|
||||
53
apps/web-react/app/modules/permissions/utils.test.ts
Normal file
53
apps/web-react/app/modules/permissions/utils.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { ADMIN_ID } from "~/features/admin/admin-constants";
|
||||
|
||||
const e2e = vi.hoisted(() => ({ isTestRun: false }));
|
||||
|
||||
vi.mock("~/utils/e2e", () => ({
|
||||
get IS_E2E_TEST_RUN() {
|
||||
return e2e.isTestRun;
|
||||
},
|
||||
}));
|
||||
|
||||
import { hasPermission } from "./utils";
|
||||
|
||||
const REGULAR_USER_ID = ADMIN_ID + 1;
|
||||
const OTHER_USER_ID = ADMIN_ID + 2;
|
||||
|
||||
const entity = { permissions: { EDIT: [REGULAR_USER_ID] } };
|
||||
|
||||
describe("hasPermission", () => {
|
||||
beforeEach(() => {
|
||||
e2e.isTestRun = false;
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
test("returns false for a logged out user", () => {
|
||||
expect(hasPermission(entity, "EDIT", null)).toBe(false);
|
||||
});
|
||||
|
||||
test("returns true for a user holding the permission", () => {
|
||||
expect(hasPermission(entity, "EDIT", { id: REGULAR_USER_ID })).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false for a user not holding the permission", () => {
|
||||
expect(hasPermission(entity, "EDIT", { id: OTHER_USER_ID })).toBe(false);
|
||||
});
|
||||
|
||||
test("admin does not bypass the permission outside production", () => {
|
||||
expect(hasPermission(entity, "EDIT", { id: ADMIN_ID })).toBe(false);
|
||||
});
|
||||
|
||||
test("admin bypasses the permission in production", () => {
|
||||
vi.stubEnv("NODE_ENV", "production");
|
||||
|
||||
expect(hasPermission(entity, "EDIT", { id: ADMIN_ID })).toBe(true);
|
||||
});
|
||||
|
||||
test("admin does not bypass the permission in an e2e test run", () => {
|
||||
vi.stubEnv("NODE_ENV", "production");
|
||||
e2e.isTestRun = true;
|
||||
|
||||
expect(hasPermission(entity, "EDIT", { id: ADMIN_ID })).toBe(false);
|
||||
});
|
||||
});
|
||||
65
apps/web-react/app/modules/permissions/utils.ts
Normal file
65
apps/web-react/app/modules/permissions/utils.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import {
|
||||
ADMIN_ID,
|
||||
DEV_IDS,
|
||||
QA_IDS,
|
||||
SCANNER_TESTER_IDS,
|
||||
STAFF_IDS,
|
||||
} from "~/features/admin/admin-constants";
|
||||
import { IS_E2E_TEST_RUN } from "~/utils/e2e";
|
||||
import type { EntityWithPermissions } from "./types";
|
||||
|
||||
/**
|
||||
* Determines whether a user has a specific permission for a given entity.
|
||||
* Single source of truth shared by `requirePermission` and `useHasPermission`.
|
||||
*
|
||||
* @returns A boolean indicating whether the user has the specified permission. Always false if user is not logged in.
|
||||
*/
|
||||
export function hasPermission<
|
||||
T extends EntityWithPermissions,
|
||||
K extends keyof T["permissions"],
|
||||
>(obj: T, permission: K, user?: { id: number } | null) {
|
||||
if (!user) return false;
|
||||
|
||||
// admin can do anything in production but not in development or e2e tests for better testing
|
||||
if (
|
||||
process.env.NODE_ENV === "production" &&
|
||||
!IS_E2E_TEST_RUN &&
|
||||
isAdmin(user)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (obj.permissions as Record<K, number[]>)[permission].includes(user.id);
|
||||
}
|
||||
|
||||
export function isAdmin(user?: { id: number }) {
|
||||
return user?.id === ADMIN_ID;
|
||||
}
|
||||
|
||||
export function isStaff(user?: { id: number }) {
|
||||
if (!user) return false;
|
||||
|
||||
return STAFF_IDS.includes(user.id);
|
||||
}
|
||||
|
||||
export function isDev(user?: { id: number }) {
|
||||
if (!user) return false;
|
||||
|
||||
return DEV_IDS.includes(user.id);
|
||||
}
|
||||
|
||||
export function isQa(user?: { id: number }) {
|
||||
if (!user) return false;
|
||||
|
||||
return QA_IDS.includes(user.id);
|
||||
}
|
||||
|
||||
export function isScannerTester(user?: { id: number }) {
|
||||
if (!user) return false;
|
||||
|
||||
return SCANNER_TESTER_IDS.includes(user.id);
|
||||
}
|
||||
|
||||
export function isSupporter(user?: { patronTier: number | null }) {
|
||||
return typeof user?.patronTier === "number" && user.patronTier >= 2;
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
import { render } from "vitest-browser-react";
|
||||
import { z } from "zod";
|
||||
import { usePersistedMapState, usePersistedState } from "./hooks";
|
||||
import * as PersistedState from "./persisted-state";
|
||||
|
||||
const recentIds = PersistedState.define({
|
||||
key: "test-recent-ids",
|
||||
storage: "local",
|
||||
schema: z.array(z.number()),
|
||||
default: [],
|
||||
});
|
||||
|
||||
const counts = PersistedState.defineMap({
|
||||
keyPrefix: "test-counts__",
|
||||
storage: "local",
|
||||
schema: z.number(),
|
||||
default: 0,
|
||||
});
|
||||
|
||||
function RecentIds() {
|
||||
const [ids, setIds] = usePersistedState(recentIds);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div data-testid="ids">{ids.join(",")}</div>
|
||||
<button type="button" onClick={() => setIds([7])}>
|
||||
set
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setIds((previous) =>
|
||||
PersistedState.prependToRecentList(previous, 8, 3),
|
||||
)
|
||||
}
|
||||
>
|
||||
prepend
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CountsMirror() {
|
||||
const countsByKey = usePersistedMapState(counts);
|
||||
|
||||
return (
|
||||
<div data-testid="counts">
|
||||
{Object.entries(countsByKey)
|
||||
.map(([key, count]) => `${key}=${count}`)
|
||||
.sort()
|
||||
.join(",")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
describe("usePersistedState", () => {
|
||||
test("reads the default when nothing is stored", async () => {
|
||||
const screen = await render(<RecentIds />);
|
||||
|
||||
await expect.element(screen.getByTestId("ids")).toHaveTextContent("");
|
||||
});
|
||||
|
||||
test("writes persist and update every subscribed component", async () => {
|
||||
const screen = await render(
|
||||
<div>
|
||||
<RecentIds />
|
||||
<RecentIds />
|
||||
</div>,
|
||||
);
|
||||
|
||||
await screen.getByRole("button", { name: "set" }).first().click();
|
||||
|
||||
for (const element of screen.getByTestId("ids").elements()) {
|
||||
expect(element).toHaveTextContent("7");
|
||||
}
|
||||
expect(window.localStorage.getItem("test-recent-ids")).toBe("[7]");
|
||||
});
|
||||
|
||||
test("updater form reads the current value", async () => {
|
||||
window.localStorage.setItem("test-recent-ids", "[1,2,3]");
|
||||
const screen = await render(<RecentIds />);
|
||||
|
||||
await screen.getByRole("button", { name: "prepend" }).click();
|
||||
|
||||
await expect.element(screen.getByTestId("ids")).toHaveTextContent("8,1,2");
|
||||
});
|
||||
|
||||
test("storage events from other tabs sync the value", async () => {
|
||||
const screen = await render(<RecentIds />);
|
||||
|
||||
window.localStorage.setItem("test-recent-ids", "[4,5]");
|
||||
window.dispatchEvent(
|
||||
new StorageEvent("storage", { key: "test-recent-ids" }),
|
||||
);
|
||||
|
||||
await expect.element(screen.getByTestId("ids")).toHaveTextContent("4,5");
|
||||
});
|
||||
});
|
||||
|
||||
describe("usePersistedMapState", () => {
|
||||
test("reads all entries under the prefix and syncs entry writes", async () => {
|
||||
window.localStorage.setItem("test-counts__a", "1");
|
||||
const screen = await render(<CountsMirror />);
|
||||
|
||||
await expect.element(screen.getByTestId("counts")).toHaveTextContent("a=1");
|
||||
|
||||
PersistedState.writeMapEntry(counts, "b", 2);
|
||||
|
||||
await expect
|
||||
.element(screen.getByTestId("counts"))
|
||||
.toHaveTextContent("a=1,b=2");
|
||||
});
|
||||
});
|
||||
53
apps/web-react/app/modules/persisted-state/hooks.ts
Normal file
53
apps/web-react/app/modules/persisted-state/hooks.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import * as React from "react";
|
||||
import type {
|
||||
PersistedDefinition,
|
||||
PersistedMapDefinition,
|
||||
} from "./persisted-state";
|
||||
import * as PersistedState from "./persisted-state";
|
||||
|
||||
const SERVER_MAP_SNAPSHOT: Record<string, never> = {};
|
||||
|
||||
/**
|
||||
* Typed React state backed by a persisted definition. The value stays in sync
|
||||
* across components and browser tabs; the setter accepts a value or an updater.
|
||||
*/
|
||||
export function usePersistedState<T>(
|
||||
definition: PersistedDefinition<T>,
|
||||
): [T, (next: T | ((previous: T) => T)) => void] {
|
||||
const subscribeToDefinition = React.useCallback(
|
||||
(listener: () => void) => PersistedState.subscribe(definition, listener),
|
||||
[definition],
|
||||
);
|
||||
const value = React.useSyncExternalStore(
|
||||
subscribeToDefinition,
|
||||
() => PersistedState.read(definition),
|
||||
() => definition.default,
|
||||
);
|
||||
|
||||
const setValue = (next: T | ((previous: T) => T)) => {
|
||||
PersistedState.write(
|
||||
definition,
|
||||
typeof next === "function"
|
||||
? (next as (previous: T) => T)(PersistedState.read(definition))
|
||||
: next,
|
||||
);
|
||||
};
|
||||
|
||||
return [value, setValue];
|
||||
}
|
||||
|
||||
/** Read-only React view of a persisted map definition; write entries via `PersistedState.writeMapEntry`. */
|
||||
export function usePersistedMapState<T>(
|
||||
definition: PersistedMapDefinition<T>,
|
||||
): Record<string, T> {
|
||||
const subscribeToDefinition = React.useCallback(
|
||||
(listener: () => void) => PersistedState.subscribeMap(definition, listener),
|
||||
[definition],
|
||||
);
|
||||
|
||||
return React.useSyncExternalStore(
|
||||
subscribeToDefinition,
|
||||
() => PersistedState.readMap(definition),
|
||||
() => SERVER_MAP_SNAPSHOT,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { expect } from "vitest";
|
||||
import type {
|
||||
PersistedDefinition,
|
||||
PersistedMapDefinition,
|
||||
} from "./persisted-state";
|
||||
|
||||
/**
|
||||
* Asserts `decode(encode(x))` deep-equals `x` for every given example value,
|
||||
* and that a missing value decodes to the default. Every feature definition
|
||||
* registers a test with representative and edge-case values.
|
||||
*/
|
||||
export function assertRoundTrips<T>(
|
||||
definition: PersistedDefinition<T> | PersistedMapDefinition<T>,
|
||||
examples: NoInfer<T>[],
|
||||
) {
|
||||
expect
|
||||
.soft(definition.decode(null), "missing → default")
|
||||
.toEqual(definition.default);
|
||||
|
||||
for (const value of examples) {
|
||||
expect
|
||||
.soft(
|
||||
definition.decode(definition.encode(value)),
|
||||
`round trip of ${JSON.stringify(value)}`,
|
||||
)
|
||||
.toEqual(value);
|
||||
}
|
||||
}
|
||||
|
||||
/** Asserts that each given raw stored string decodes to the definition's default. */
|
||||
export function assertDecodesToDefault<T>(
|
||||
definition: PersistedDefinition<T> | PersistedMapDefinition<T>,
|
||||
raws: string[],
|
||||
) {
|
||||
for (const raw of raws) {
|
||||
expect
|
||||
.soft(definition.decode(raw), `${JSON.stringify(raw)} → default`)
|
||||
.toEqual(definition.default);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { z } from "zod";
|
||||
import * as PersistedState from "./persisted-state";
|
||||
import {
|
||||
assertDecodesToDefault,
|
||||
assertRoundTrips,
|
||||
} from "./persisted-state-test-utils";
|
||||
|
||||
const numberList = PersistedState.define({
|
||||
key: "test-number-list",
|
||||
storage: "local",
|
||||
schema: z.array(z.number()),
|
||||
default: [],
|
||||
});
|
||||
|
||||
const searchType = PersistedState.define({
|
||||
key: "test-search-type",
|
||||
storage: "local",
|
||||
schema: z.enum(["weapons", "users"]),
|
||||
default: "weapons",
|
||||
});
|
||||
|
||||
const dismissed = PersistedState.define({
|
||||
key: "test-dismissed",
|
||||
storage: "local",
|
||||
schema: z.boolean(),
|
||||
default: false,
|
||||
});
|
||||
|
||||
const counts = PersistedState.defineMap({
|
||||
keyPrefix: "test-counts__",
|
||||
storage: "local",
|
||||
schema: z.number(),
|
||||
default: 0,
|
||||
});
|
||||
|
||||
describe("define", () => {
|
||||
test("round-trips values", () => {
|
||||
assertRoundTrips(numberList, [[], [1, 2, 3]]);
|
||||
assertRoundTrips(searchType, ["weapons", "users"]);
|
||||
assertRoundTrips(dismissed, [true, false]);
|
||||
assertRoundTrips(counts, [0, 42]);
|
||||
});
|
||||
|
||||
test("malformed values decode to defaults", () => {
|
||||
assertDecodesToDefault(numberList, ["not json", '{"a":1}', '["a","b"]']);
|
||||
assertDecodesToDefault(searchType, ["teams", "[1,2]"]);
|
||||
assertDecodesToDefault(dismissed, ["yes"]);
|
||||
assertDecodesToDefault(counts, ["abc", "", "Infinity"]);
|
||||
});
|
||||
|
||||
test("decodes legacy plain-string values where the schema allows them", () => {
|
||||
expect(searchType.decode("users")).toBe("users");
|
||||
expect(dismissed.decode("true")).toBe(true);
|
||||
expect(counts.decode("7")).toBe(7);
|
||||
});
|
||||
});
|
||||
|
||||
describe("prependToRecentList", () => {
|
||||
test("prepends a new item", () => {
|
||||
expect(PersistedState.prependToRecentList([1, 2], 3, 5)).toEqual([3, 1, 2]);
|
||||
});
|
||||
|
||||
test("moves an existing item to the front", () => {
|
||||
expect(PersistedState.prependToRecentList([1, 2, 3], 2, 5)).toEqual([
|
||||
2, 1, 3,
|
||||
]);
|
||||
});
|
||||
|
||||
test("caps the list length", () => {
|
||||
expect(PersistedState.prependToRecentList([1, 2, 3], 4, 3)).toEqual([
|
||||
4, 1, 2,
|
||||
]);
|
||||
});
|
||||
});
|
||||
249
apps/web-react/app/modules/persisted-state/persisted-state.ts
Normal file
249
apps/web-react/app/modules/persisted-state/persisted-state.ts
Normal file
@@ -0,0 +1,249 @@
|
||||
import type { z } from "zod";
|
||||
|
||||
const readCaches = new WeakMap<
|
||||
object,
|
||||
{ raw: string | null; value: unknown }
|
||||
>();
|
||||
const mapCaches = new WeakMap<object, Record<string, unknown>>();
|
||||
const listenersByDefinition = new WeakMap<object, Set<() => void>>();
|
||||
|
||||
const SERVER_MAP_SNAPSHOT: Record<string, never> = {};
|
||||
|
||||
type StorageKind = "local" | "session";
|
||||
|
||||
interface DefinitionBase<T> {
|
||||
storage: StorageKind;
|
||||
default: T;
|
||||
/** Decodes a raw stored string. Total: resolves to the default for missing or malformed values, never throws. */
|
||||
decode: (raw: string | null) => T;
|
||||
/** Encodes a value into its stored string form. */
|
||||
encode: (value: T) => string;
|
||||
}
|
||||
|
||||
export interface PersistedDefinition<T> extends DefinitionBase<T> {
|
||||
key: string;
|
||||
}
|
||||
|
||||
export interface PersistedMapDefinition<T> extends DefinitionBase<T> {
|
||||
keyPrefix: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Declares a client state persisted in web storage. One definition per stored
|
||||
* key drives reads, writes, cross-tab sync and the `usePersistedState` hook.
|
||||
* Decoding is total: the default resolves for missing or malformed values,
|
||||
* legacy plain-string values are accepted where the schema allows them.
|
||||
*/
|
||||
export function define<S extends z.ZodType>(options: {
|
||||
key: string;
|
||||
storage: StorageKind;
|
||||
schema: S;
|
||||
default: z.output<S>;
|
||||
}): PersistedDefinition<z.output<S>> {
|
||||
return {
|
||||
key: options.key,
|
||||
storage: options.storage,
|
||||
default: options.default,
|
||||
...codec(options.schema, options.default),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Declares a keyed family of persisted values sharing a storage key prefix,
|
||||
* for maps whose entries are written independently (e.g. per chat room).
|
||||
*/
|
||||
export function defineMap<S extends z.ZodType>(options: {
|
||||
keyPrefix: string;
|
||||
storage: StorageKind;
|
||||
schema: S;
|
||||
default: z.output<S>;
|
||||
}): PersistedMapDefinition<z.output<S>> {
|
||||
return {
|
||||
keyPrefix: options.keyPrefix,
|
||||
storage: options.storage,
|
||||
default: options.default,
|
||||
...codec(options.schema, options.default),
|
||||
};
|
||||
}
|
||||
|
||||
/** Reads the persisted value, resolving to the default on the server or when the stored value is missing or malformed. */
|
||||
export function read<T>(definition: PersistedDefinition<T>): T {
|
||||
if (typeof window === "undefined") return definition.default;
|
||||
|
||||
const raw = readRaw(definition.storage, definition.key);
|
||||
const cached = readCaches.get(definition);
|
||||
if (cached && cached.raw === raw) return cached.value as T;
|
||||
|
||||
const value = definition.decode(raw);
|
||||
readCaches.set(definition, { raw, value });
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Writes the value to web storage and notifies this tab's subscribers (other tabs sync via the `storage` event). */
|
||||
export function write<T>(definition: PersistedDefinition<T>, value: T) {
|
||||
try {
|
||||
storageOf(definition.storage).setItem(
|
||||
definition.key,
|
||||
definition.encode(value),
|
||||
);
|
||||
} catch {
|
||||
// web storage may be unavailable
|
||||
}
|
||||
notify(definition);
|
||||
}
|
||||
|
||||
/** Subscribes to changes of the persisted value, from both this tab's writes and other tabs via the `storage` event. */
|
||||
export function subscribe<T>(
|
||||
definition: PersistedDefinition<T>,
|
||||
listener: () => void,
|
||||
) {
|
||||
const listeners = listenersOf(definition);
|
||||
listeners.add(listener);
|
||||
|
||||
const handleStorageEvent = (event: StorageEvent) => {
|
||||
if (event.key !== null && event.key !== definition.key) return;
|
||||
listener();
|
||||
};
|
||||
window.addEventListener("storage", handleStorageEvent);
|
||||
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
window.removeEventListener("storage", handleStorageEvent);
|
||||
};
|
||||
}
|
||||
|
||||
/** Reads all entries of a persisted map, keyed by the part of the storage key after the prefix. */
|
||||
export function readMap<T>(
|
||||
definition: PersistedMapDefinition<T>,
|
||||
): Record<string, T> {
|
||||
if (typeof window === "undefined") {
|
||||
return SERVER_MAP_SNAPSHOT as Record<string, T>;
|
||||
}
|
||||
|
||||
const cached = mapCaches.get(definition);
|
||||
if (cached) return cached as Record<string, T>;
|
||||
|
||||
const value = readMapFromStorage(definition);
|
||||
mapCaches.set(definition, value);
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Writes one entry of a persisted map and notifies this tab's subscribers (other tabs sync via the `storage` event). */
|
||||
export function writeMapEntry<T>(
|
||||
definition: PersistedMapDefinition<T>,
|
||||
entryKey: string,
|
||||
value: T,
|
||||
) {
|
||||
try {
|
||||
storageOf(definition.storage).setItem(
|
||||
`${definition.keyPrefix}${entryKey}`,
|
||||
definition.encode(value),
|
||||
);
|
||||
} catch {
|
||||
// web storage may be unavailable
|
||||
}
|
||||
mapCaches.delete(definition);
|
||||
notify(definition);
|
||||
}
|
||||
|
||||
/** Subscribes to changes of any entry of a persisted map, from both this tab's writes and other tabs via the `storage` event. */
|
||||
export function subscribeMap<T>(
|
||||
definition: PersistedMapDefinition<T>,
|
||||
listener: () => void,
|
||||
) {
|
||||
const listeners = listenersOf(definition);
|
||||
listeners.add(listener);
|
||||
|
||||
const handleStorageEvent = (event: StorageEvent) => {
|
||||
if (event.key !== null && !event.key.startsWith(definition.keyPrefix)) {
|
||||
return;
|
||||
}
|
||||
mapCaches.delete(definition);
|
||||
listener();
|
||||
};
|
||||
window.addEventListener("storage", handleStorageEvent);
|
||||
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
window.removeEventListener("storage", handleStorageEvent);
|
||||
};
|
||||
}
|
||||
|
||||
/** Moves `item` to the front of a most-recently-used list, deduplicating it and capping the list's length. */
|
||||
export function prependToRecentList<T>(
|
||||
items: readonly T[],
|
||||
item: T,
|
||||
maxLength: number,
|
||||
): T[] {
|
||||
return [item, ...items.filter((existing) => existing !== item)].slice(
|
||||
0,
|
||||
maxLength,
|
||||
);
|
||||
}
|
||||
|
||||
function codec<S extends z.ZodType>(schema: S, defaultValue: z.output<S>) {
|
||||
return {
|
||||
decode: (raw: string | null): z.output<S> => {
|
||||
if (raw === null) return defaultValue;
|
||||
const parsed = schema.safeParse(rawToJson(raw));
|
||||
return parsed.success ? parsed.data : defaultValue;
|
||||
},
|
||||
encode: (value: z.output<S>) => JSON.stringify(value),
|
||||
};
|
||||
}
|
||||
|
||||
function rawToJson(raw: string): unknown {
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
// legacy values were stored as plain strings
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
function storageOf(kind: StorageKind): Storage {
|
||||
return kind === "local" ? window.localStorage : window.sessionStorage;
|
||||
}
|
||||
|
||||
function readRaw(kind: StorageKind, key: string): string | null {
|
||||
try {
|
||||
return storageOf(kind).getItem(key);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readMapFromStorage<T>(
|
||||
definition: PersistedMapDefinition<T>,
|
||||
): Record<string, T> {
|
||||
const result: Record<string, T> = {};
|
||||
try {
|
||||
const storage = storageOf(definition.storage);
|
||||
for (let i = 0; i < storage.length; i++) {
|
||||
const key = storage.key(i);
|
||||
if (!key?.startsWith(definition.keyPrefix)) continue;
|
||||
|
||||
result[key.slice(definition.keyPrefix.length)] = definition.decode(
|
||||
storage.getItem(key),
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// web storage may be unavailable
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function listenersOf(definition: object): Set<() => void> {
|
||||
let listeners = listenersByDefinition.get(definition);
|
||||
if (!listeners) {
|
||||
listeners = new Set();
|
||||
listenersByDefinition.set(definition, listeners);
|
||||
}
|
||||
return listeners;
|
||||
}
|
||||
|
||||
function notify(definition: object) {
|
||||
for (const listener of listenersOf(definition)) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import React, { type JSX } from "react";
|
||||
|
||||
interface MasonryProps {
|
||||
children: React.ReactNode | React.ReactNode[];
|
||||
columnsCount?: number;
|
||||
gutter?: string;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
containerTag?: keyof JSX.IntrinsicElements;
|
||||
itemTag?: keyof JSX.IntrinsicElements;
|
||||
itemStyle?: React.CSSProperties;
|
||||
sequential?: boolean;
|
||||
}
|
||||
|
||||
interface MasonryState {
|
||||
columns: React.ReactNode[][];
|
||||
childRefs: React.RefObject<HTMLDivElement | null>[];
|
||||
hasDistributed: boolean;
|
||||
children?: React.ReactNode | React.ReactNode[];
|
||||
}
|
||||
|
||||
class Masonry extends React.Component<MasonryProps, MasonryState> {
|
||||
static defaultProps: Partial<MasonryProps> = {
|
||||
columnsCount: 3,
|
||||
gutter: "0",
|
||||
className: undefined,
|
||||
style: {},
|
||||
containerTag: "div",
|
||||
itemTag: "div",
|
||||
itemStyle: {},
|
||||
sequential: false,
|
||||
};
|
||||
|
||||
constructor(props: MasonryProps) {
|
||||
super(props);
|
||||
this.state = { columns: [], childRefs: [], hasDistributed: false };
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
if (!this.state.hasDistributed && !this.props.sequential) {
|
||||
this.distributeChildren();
|
||||
}
|
||||
}
|
||||
|
||||
static getDerivedStateFromProps(props: MasonryProps, state: MasonryState) {
|
||||
const { children, columnsCount } = props;
|
||||
const hasColumnsChanged = columnsCount !== state.columns.length;
|
||||
if (state && children === state.children && !hasColumnsChanged) return null;
|
||||
return {
|
||||
...Masonry.getEqualCountColumns(children, columnsCount!),
|
||||
children,
|
||||
hasDistributed: false,
|
||||
};
|
||||
}
|
||||
|
||||
shouldComponentUpdate(nextProps: MasonryProps) {
|
||||
return (
|
||||
nextProps.children !== this.state.children ||
|
||||
nextProps.columnsCount !== this.props.columnsCount
|
||||
);
|
||||
}
|
||||
|
||||
distributeChildren() {
|
||||
const { children, columnsCount } = this.props;
|
||||
const columnHeights = Array(columnsCount!).fill(0);
|
||||
|
||||
const isReady = this.state.childRefs.every(
|
||||
(ref) => ref.current?.getBoundingClientRect().height,
|
||||
);
|
||||
|
||||
if (!isReady) return;
|
||||
|
||||
const columns: React.ReactNode[][] = Array.from(
|
||||
{ length: columnsCount! },
|
||||
() => [],
|
||||
);
|
||||
let validIndex = 0;
|
||||
React.Children.forEach(children, (child) => {
|
||||
if (child && React.isValidElement(child)) {
|
||||
const childHeight =
|
||||
this.state.childRefs[validIndex].current!.getBoundingClientRect()
|
||||
.height;
|
||||
const minHeightColumnIndex = columnHeights.indexOf(
|
||||
Math.min(...columnHeights),
|
||||
);
|
||||
columnHeights[minHeightColumnIndex] += childHeight;
|
||||
columns[minHeightColumnIndex].push(child);
|
||||
validIndex++;
|
||||
}
|
||||
});
|
||||
|
||||
this.setState((p) => ({ ...p, columns, hasDistributed: true }));
|
||||
}
|
||||
|
||||
static getEqualCountColumns(
|
||||
children: React.ReactNode | React.ReactNode[],
|
||||
columnsCount: number,
|
||||
) {
|
||||
const columns: React.ReactNode[][] = Array.from(
|
||||
{ length: columnsCount },
|
||||
() => [],
|
||||
);
|
||||
let validIndex = 0;
|
||||
const childRefs: React.RefObject<HTMLDivElement | null>[] = [];
|
||||
React.Children.forEach(children, (child) => {
|
||||
if (child && React.isValidElement(child)) {
|
||||
const ref = React.createRef<HTMLDivElement>();
|
||||
childRefs.push(ref);
|
||||
columns[validIndex % columnsCount].push(
|
||||
<div
|
||||
style={{ display: "flex", justifyContent: "stretch" }}
|
||||
key={validIndex}
|
||||
ref={ref}
|
||||
>
|
||||
{child}
|
||||
</div>,
|
||||
);
|
||||
validIndex++;
|
||||
}
|
||||
});
|
||||
return { columns, childRefs };
|
||||
}
|
||||
|
||||
renderColumns() {
|
||||
const { gutter, itemTag, itemStyle } = this.props;
|
||||
return this.state.columns.map((column, i) =>
|
||||
React.createElement(
|
||||
itemTag!,
|
||||
{
|
||||
key: i,
|
||||
style: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyContent: "flex-start",
|
||||
alignContent: "stretch",
|
||||
flex: 1,
|
||||
width: 0,
|
||||
gap: gutter,
|
||||
...itemStyle,
|
||||
},
|
||||
},
|
||||
column.map((item) => item),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { gutter, className, style, containerTag } = this.props;
|
||||
|
||||
return React.createElement(
|
||||
containerTag!,
|
||||
{
|
||||
style: {
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
justifyContent: "center",
|
||||
alignContent: "stretch",
|
||||
boxSizing: "border-box",
|
||||
width: "100%",
|
||||
gap: gutter,
|
||||
...style,
|
||||
},
|
||||
className,
|
||||
},
|
||||
this.renderColumns(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Masonry;
|
||||
@@ -0,0 +1,59 @@
|
||||
// adapted from https://github.com/cedricdelpoux/react-responsive-masonry
|
||||
|
||||
import React from "react";
|
||||
import { useWindowSize } from "~/hooks/useWindowSize";
|
||||
import Masonry from "./Masonry";
|
||||
|
||||
const COLUMN_COUNTS = {
|
||||
L: 3,
|
||||
M: 2,
|
||||
S: 1,
|
||||
};
|
||||
|
||||
const BREAKPOINTS = {
|
||||
L: 900,
|
||||
M: 750,
|
||||
S: 350,
|
||||
} as const;
|
||||
|
||||
type Breakpoint = keyof typeof BREAKPOINTS;
|
||||
|
||||
const MasonryResponsive = ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode | React.ReactNode[];
|
||||
}) => {
|
||||
const breakpoint = useBreakpoint();
|
||||
|
||||
const columnsCount = COLUMN_COUNTS[breakpoint];
|
||||
|
||||
return (
|
||||
<div>
|
||||
{React.Children.map(children, (child, index) =>
|
||||
React.cloneElement(child as React.ReactElement<any>, {
|
||||
key: index,
|
||||
columnsCount,
|
||||
}),
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export function ResponsiveMasonry({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<MasonryResponsive>
|
||||
<Masonry gutter="1rem">{children}</Masonry>
|
||||
</MasonryResponsive>
|
||||
);
|
||||
}
|
||||
|
||||
function useBreakpoint(): Breakpoint {
|
||||
const { width } = useWindowSize();
|
||||
|
||||
const ascending = Object.entries(BREAKPOINTS).sort((a, b) => a[1] - b[1]);
|
||||
return ascending.reduce<Breakpoint>(
|
||||
(current, [name, minWidth]) =>
|
||||
width >= minWidth ? (name as Breakpoint) : current,
|
||||
ascending[0][0] as Breakpoint,
|
||||
);
|
||||
}
|
||||
194
apps/web-react/app/modules/search-params/hooks.browser.test.tsx
Normal file
194
apps/web-react/app/modules/search-params/hooks.browser.test.tsx
Normal file
@@ -0,0 +1,194 @@
|
||||
import * as React from "react";
|
||||
import { createBrowserRouter, RouterProvider } from "react-router";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
import { render } from "vitest-browser-react";
|
||||
import { z } from "zod";
|
||||
import { useSearchParam, useSearchParamsTyped } from "./hooks";
|
||||
import * as SearchParams from "./search-params";
|
||||
import { SP } from "./search-params";
|
||||
|
||||
const definition = SearchParams.define({
|
||||
page: SP.param(z.number().int().min(1), { default: 1, loader: true }),
|
||||
filters: SP.json(z.object({ q: z.string() }), {
|
||||
default: { q: "" },
|
||||
loader: true,
|
||||
resets: ["page"],
|
||||
}),
|
||||
view: SP.param(z.enum(["list", "grid"]), { default: "list", loader: false }),
|
||||
other: SP.param(z.number(), { default: 0, loader: false }),
|
||||
});
|
||||
|
||||
let loaderCalls = 0;
|
||||
let viewRenders = 0;
|
||||
|
||||
function TestComponent() {
|
||||
const [params, setParams] = useSearchParamsTyped(definition);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div data-testid="page">{params.page}</div>
|
||||
<div data-testid="view">{params.view}</div>
|
||||
<div data-testid="q">{params.filters.q}</div>
|
||||
<button type="button" onClick={() => setParams({ view: "grid" })}>
|
||||
set view
|
||||
</button>
|
||||
<button type="button" onClick={() => setParams({ page: 5 })}>
|
||||
set page
|
||||
</button>
|
||||
<button type="button" onClick={() => setParams({ page: 1 })}>
|
||||
reset page
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setParams({ filters: { q: "hello" } })}
|
||||
>
|
||||
set filters
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setParams({ view: "grid", page: 2 })}
|
||||
>
|
||||
mixed batch
|
||||
</button>
|
||||
<button type="button" onClick={() => setParams({ other: 9 })}>
|
||||
set other
|
||||
</button>
|
||||
<ViewOnly />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ViewOnly = React.memo(function ViewOnly() {
|
||||
const [view] = useSearchParam(definition, "view");
|
||||
viewRenders++;
|
||||
|
||||
return <div data-testid="view-only">{view}</div>;
|
||||
});
|
||||
|
||||
async function renderTestRouter() {
|
||||
loaderCalls = 0;
|
||||
viewRenders = 0;
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
path: "*",
|
||||
loader: () => {
|
||||
loaderCalls++;
|
||||
return null;
|
||||
},
|
||||
element: <TestComponent />,
|
||||
},
|
||||
]);
|
||||
|
||||
return { screen: await render(<RouterProvider router={router} />), router };
|
||||
}
|
||||
|
||||
function currentSearchParams() {
|
||||
return new URLSearchParams(window.location.search);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
window.history.replaceState(null, "", window.location.pathname);
|
||||
});
|
||||
|
||||
describe("useSearchParamsTyped", () => {
|
||||
test("loader: false writes update the URL without running loaders", async () => {
|
||||
const { screen } = await renderTestRouter();
|
||||
await expect.element(screen.getByTestId("view")).toHaveTextContent("list");
|
||||
const initialLoaderCalls = loaderCalls;
|
||||
|
||||
await screen.getByRole("button", { name: "set view" }).click();
|
||||
|
||||
await expect.element(screen.getByTestId("view")).toHaveTextContent("grid");
|
||||
expect(currentSearchParams().get("view")).toBe("grid");
|
||||
expect(loaderCalls).toBe(initialLoaderCalls);
|
||||
});
|
||||
|
||||
test("loader: true writes navigate and run loaders", async () => {
|
||||
const { screen } = await renderTestRouter();
|
||||
await expect.element(screen.getByTestId("page")).toHaveTextContent("1");
|
||||
const initialLoaderCalls = loaderCalls;
|
||||
|
||||
await screen.getByRole("button", { name: "set page", exact: true }).click();
|
||||
|
||||
await expect.element(screen.getByTestId("page")).toHaveTextContent("5");
|
||||
expect(currentSearchParams().get("page")).toBe("5");
|
||||
await expect.poll(() => loaderCalls).toBeGreaterThan(initialLoaderCalls);
|
||||
});
|
||||
|
||||
test("a mixed batch carries both changes in one navigation", async () => {
|
||||
const { screen } = await renderTestRouter();
|
||||
await expect.element(screen.getByTestId("page")).toHaveTextContent("1");
|
||||
|
||||
await screen.getByRole("button", { name: "mixed batch" }).click();
|
||||
|
||||
await expect.element(screen.getByTestId("page")).toHaveTextContent("2");
|
||||
await expect.element(screen.getByTestId("view")).toHaveTextContent("grid");
|
||||
expect(currentSearchParams().get("page")).toBe("2");
|
||||
expect(currentSearchParams().get("view")).toBe("grid");
|
||||
});
|
||||
|
||||
test("writes merge: params outside the batch are preserved", async () => {
|
||||
const { screen } = await renderTestRouter();
|
||||
window.history.replaceState(null, "", "?unrelated=yes&page=5");
|
||||
|
||||
await screen.getByRole("button", { name: "set view" }).click();
|
||||
|
||||
await expect.element(screen.getByTestId("view")).toHaveTextContent("grid");
|
||||
expect(currentSearchParams().get("unrelated")).toBe("yes");
|
||||
expect(currentSearchParams().get("page")).toBe("5");
|
||||
});
|
||||
|
||||
test("values equal to their default are removed from the URL", async () => {
|
||||
const { screen } = await renderTestRouter();
|
||||
window.history.replaceState(null, "", "?page=5");
|
||||
await expect.element(screen.getByTestId("page")).toHaveTextContent("5");
|
||||
|
||||
await screen.getByRole("button", { name: "reset page" }).click();
|
||||
|
||||
await expect.element(screen.getByTestId("page")).toHaveTextContent("1");
|
||||
expect(currentSearchParams().has("page")).toBe(false);
|
||||
});
|
||||
|
||||
test("declared resets reset other params on write", async () => {
|
||||
const { screen } = await renderTestRouter();
|
||||
window.history.replaceState(null, "", "?page=5");
|
||||
await expect.element(screen.getByTestId("page")).toHaveTextContent("5");
|
||||
|
||||
await screen.getByRole("button", { name: "set filters" }).click();
|
||||
|
||||
await expect.element(screen.getByTestId("q")).toHaveTextContent("hello");
|
||||
await expect.element(screen.getByTestId("page")).toHaveTextContent("1");
|
||||
expect(currentSearchParams().has("page")).toBe(false);
|
||||
});
|
||||
|
||||
test("external navigation syncs the params", async () => {
|
||||
const { screen, router } = await renderTestRouter();
|
||||
await expect.element(screen.getByTestId("page")).toHaveTextContent("1");
|
||||
|
||||
await router.navigate(`${window.location.pathname}?page=7`);
|
||||
|
||||
await expect.element(screen.getByTestId("page")).toHaveTextContent("7");
|
||||
});
|
||||
});
|
||||
|
||||
describe("useSearchParam", () => {
|
||||
test("rerenders only when the subscribed param changes", async () => {
|
||||
const { screen } = await renderTestRouter();
|
||||
await expect
|
||||
.element(screen.getByTestId("view-only"))
|
||||
.toHaveTextContent("list");
|
||||
const initialViewRenders = viewRenders;
|
||||
|
||||
await screen.getByRole("button", { name: "set other" }).click();
|
||||
await expect.poll(() => currentSearchParams().get("other")).toBe("9");
|
||||
|
||||
expect(viewRenders).toBe(initialViewRenders);
|
||||
|
||||
await screen.getByRole("button", { name: "set view" }).click();
|
||||
await expect
|
||||
.element(screen.getByTestId("view-only"))
|
||||
.toHaveTextContent("grid");
|
||||
expect(viewRenders).toBeGreaterThan(initialViewRenders);
|
||||
});
|
||||
});
|
||||
144
apps/web-react/app/modules/search-params/hooks.ts
Normal file
144
apps/web-react/app/modules/search-params/hooks.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import * as React from "react";
|
||||
import { useLocation, useNavigate } from "react-router";
|
||||
import type {
|
||||
ParamDef,
|
||||
SearchParamsDefinition,
|
||||
SearchParamsValues,
|
||||
} from "./search-params";
|
||||
import * as SearchParams from "./search-params";
|
||||
import * as Store from "./store";
|
||||
|
||||
type AnyShape = Record<string, ParamDef<any>>;
|
||||
|
||||
interface SetSearchParamsOptions {
|
||||
replace?: boolean;
|
||||
preventScrollReset?: boolean;
|
||||
/** Overrides the write channel derived from the written params' `loader`. */
|
||||
loader?: boolean;
|
||||
}
|
||||
|
||||
type SetSearchParams<Shape extends AnyShape> = (
|
||||
updates: Partial<SearchParamsValues<Shape>>,
|
||||
opts?: SetSearchParamsOptions,
|
||||
) => void;
|
||||
|
||||
const InitialSearchContext = React.createContext<string | null>(null);
|
||||
|
||||
/**
|
||||
* Provides the initial search string for server rendering and hydration.
|
||||
* Mounted once in the root route; the value never updates so consumers do not
|
||||
* rerender through it.
|
||||
*/
|
||||
export function SearchParamsProvider({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const location = useLocation();
|
||||
const [initialSearch] = React.useState(location.search);
|
||||
|
||||
return React.createElement(
|
||||
InitialSearchContext.Provider,
|
||||
{ value: initialSearch },
|
||||
children,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Typed search params state for a whole definition.
|
||||
*
|
||||
* Writes are merges: params not mentioned are preserved, declared `resets`
|
||||
* are applied and values equal to their default are removed from the URL. If
|
||||
* any written param is `loader: true` the batch writes through one navigation,
|
||||
* otherwise through `history.replaceState` without triggering loaders. A write
|
||||
* known not to change loader data can force the latter with `{ loader: false }`.
|
||||
*/
|
||||
export function useSearchParamsTyped<Shape extends AnyShape>(
|
||||
definition: SearchParamsDefinition<Shape>,
|
||||
): [SearchParamsValues<Shape>, SetSearchParams<Shape>] {
|
||||
const values = useDecodedValues(definition, definition.keys);
|
||||
const setParams = useSetSearchParams(definition);
|
||||
|
||||
return [values, setParams];
|
||||
}
|
||||
|
||||
/**
|
||||
* Focused subscription to a single param of a definition: rerenders only when
|
||||
* that param's raw string value changes.
|
||||
*/
|
||||
export function useSearchParam<
|
||||
Shape extends AnyShape,
|
||||
K extends keyof Shape & string,
|
||||
>(
|
||||
definition: SearchParamsDefinition<Shape>,
|
||||
key: K,
|
||||
): [
|
||||
SearchParamsValues<Shape>[K],
|
||||
(value: SearchParamsValues<Shape>[K], opts?: SetSearchParamsOptions) => void,
|
||||
] {
|
||||
const values = useDecodedValues(definition, [key]);
|
||||
const setParams = useSetSearchParams(definition);
|
||||
|
||||
const setValue = React.useCallback(
|
||||
(value: SearchParamsValues<Shape>[K], opts?: SetSearchParamsOptions) => {
|
||||
setParams({ [key]: value } as Partial<SearchParamsValues<Shape>>, opts);
|
||||
},
|
||||
[setParams, key],
|
||||
);
|
||||
|
||||
return [values[key], setValue];
|
||||
}
|
||||
|
||||
function useDecodedValues<Shape extends AnyShape>(
|
||||
definition: SearchParamsDefinition<Shape>,
|
||||
keys: string[],
|
||||
): SearchParamsValues<Shape> {
|
||||
const initialSearch = React.useContext(InitialSearchContext) ?? "";
|
||||
|
||||
const relevantSearch = React.useSyncExternalStore(
|
||||
Store.subscribe,
|
||||
() => SearchParams.pickRelevantSearch(keys, window.location.search),
|
||||
() => SearchParams.pickRelevantSearch(keys, initialSearch),
|
||||
);
|
||||
|
||||
return React.useMemo(
|
||||
() => definition.parse(new URLSearchParams(relevantSearch)),
|
||||
[definition, relevantSearch],
|
||||
);
|
||||
}
|
||||
|
||||
function useSetSearchParams<Shape extends AnyShape>(
|
||||
definition: SearchParamsDefinition<Shape>,
|
||||
): SetSearchParams<Shape> {
|
||||
const navigate = useNavigate();
|
||||
const navigateRef = React.useRef(navigate);
|
||||
navigateRef.current = navigate;
|
||||
|
||||
return React.useCallback(
|
||||
(updates, opts) => {
|
||||
const { loader, ...navigateOptions } = opts ?? {};
|
||||
const current = new URLSearchParams(window.location.search);
|
||||
const { next, navigationNeeded } = SearchParams.applyToSearchParams(
|
||||
definition,
|
||||
current,
|
||||
updates,
|
||||
);
|
||||
|
||||
const queryString = next.toString();
|
||||
if (queryString === current.toString()) return;
|
||||
|
||||
const url = `${window.location.pathname}${queryString ? `?${queryString}` : ""}${window.location.hash}`;
|
||||
|
||||
if (loader ?? navigationNeeded) {
|
||||
navigateRef.current(url, {
|
||||
replace: true,
|
||||
preventScrollReset: true,
|
||||
...navigateOptions,
|
||||
});
|
||||
} else {
|
||||
window.history.replaceState(window.history.state, "", url);
|
||||
}
|
||||
},
|
||||
[definition],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { expect } from "vitest";
|
||||
import type {
|
||||
ParamDef,
|
||||
SearchParamsDefinition,
|
||||
SearchParamsValues,
|
||||
} from "./search-params";
|
||||
import * as SearchParams from "./search-params";
|
||||
|
||||
type AnyShape = Record<string, ParamDef<any>>;
|
||||
|
||||
/**
|
||||
* Asserts `decode(encode(x))` deep-equals `x` for every given example value,
|
||||
* through both the plain and the compressed transport form, and additionally
|
||||
* through a full `href` → `parse` cycle. Every feature definition registers a
|
||||
* test with representative and edge-case values.
|
||||
*/
|
||||
export function assertRoundTrips<Shape extends AnyShape>(
|
||||
definition: SearchParamsDefinition<Shape>,
|
||||
examples: {
|
||||
[K in keyof Shape]: Array<SearchParamsValues<Shape>[K]>;
|
||||
},
|
||||
) {
|
||||
for (const key of definition.keys) {
|
||||
const def = definition.shape[key];
|
||||
|
||||
expect
|
||||
.soft(SearchParams.decodeParam(def, []), `${key}: absent → default`)
|
||||
.toEqual(def.default);
|
||||
|
||||
for (const value of examples[key]) {
|
||||
const encoded = SearchParams.encodeParam(def, value);
|
||||
expect
|
||||
.soft(
|
||||
SearchParams.decodeParam(def, encoded),
|
||||
`${key}: plain round trip of ${JSON.stringify(value)}`,
|
||||
)
|
||||
.toEqual(value);
|
||||
|
||||
const compressed =
|
||||
def.compress || encoded.length === 0
|
||||
? encoded
|
||||
: def
|
||||
.encodePlain(value)
|
||||
.map((plain) => SearchParams.compressTransportValue(plain));
|
||||
expect
|
||||
.soft(
|
||||
SearchParams.decodeParam(def, compressed),
|
||||
`${key}: compressed round trip of ${JSON.stringify(value)}`,
|
||||
)
|
||||
.toEqual(value);
|
||||
|
||||
const href = definition.href("/round-trip", {
|
||||
[key]: value,
|
||||
} as Partial<SearchParamsValues<Shape>>);
|
||||
const parsed = definition.parse(new URL(href, "http://localhost"));
|
||||
expect
|
||||
.soft(
|
||||
parsed[key],
|
||||
`${key}: href → parse round trip of ${JSON.stringify(value)}`,
|
||||
)
|
||||
.toEqual(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Asserts that each given raw URL value list decodes to the param's default. */
|
||||
export function assertDecodesToDefault<Shape extends AnyShape>(
|
||||
definition: SearchParamsDefinition<Shape>,
|
||||
key: keyof Shape & string,
|
||||
rawValuesList: string[][],
|
||||
) {
|
||||
const def = definition.shape[key];
|
||||
|
||||
for (const rawValues of rawValuesList) {
|
||||
expect
|
||||
.soft(
|
||||
SearchParams.decodeParam(def, rawValues),
|
||||
`${key}: ${JSON.stringify(rawValues)} → default`,
|
||||
)
|
||||
.toEqual(def.default);
|
||||
}
|
||||
}
|
||||
436
apps/web-react/app/modules/search-params/search-params.test.ts
Normal file
436
apps/web-react/app/modules/search-params/search-params.test.ts
Normal file
@@ -0,0 +1,436 @@
|
||||
import type { ShouldRevalidateFunction } from "react-router";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { z } from "zod";
|
||||
import * as SearchParams from "./search-params";
|
||||
import { SP } from "./search-params";
|
||||
import {
|
||||
assertDecodesToDefault,
|
||||
assertRoundTrips,
|
||||
} from "./search-params-test-utils";
|
||||
|
||||
const testDefinition = SearchParams.define({
|
||||
limit: SP.param(z.number().int().min(1).max(100), {
|
||||
default: 24,
|
||||
loader: true,
|
||||
}),
|
||||
name: SP.param(z.string().max(20), { default: "", loader: true }),
|
||||
enabled: SP.param(z.boolean(), { default: false, loader: false }),
|
||||
mode: SP.param(z.enum(["TW", "SZ", "TC"]), {
|
||||
default: "TW",
|
||||
loader: true,
|
||||
}),
|
||||
season: SP.param(z.number().int().nullable(), { loader: true }),
|
||||
ids: SP.param(z.array(z.number().int().positive()), {
|
||||
default: [],
|
||||
loader: false,
|
||||
}),
|
||||
filters: SP.json(
|
||||
z.object({ minValue: z.number(), tags: z.array(z.string()) }),
|
||||
{ default: { minValue: 0, tags: [] }, loader: true, resets: ["limit"] },
|
||||
),
|
||||
blob: SP.json(z.object({ text: z.string() }), {
|
||||
default: { text: "" },
|
||||
loader: false,
|
||||
compress: true,
|
||||
}),
|
||||
});
|
||||
|
||||
describe("SearchParams round trips", () => {
|
||||
test("round-trips representative and edge-case values", () => {
|
||||
assertRoundTrips(testDefinition, {
|
||||
limit: [24, 1, 100, 55],
|
||||
name: ["", "hello", "with space", "ä&=?#ö", "lz~sneaky", "lz~~x"],
|
||||
enabled: [true, false],
|
||||
mode: ["TW", "SZ", "TC"],
|
||||
season: [null, 5, 0, -3],
|
||||
ids: [[], [1], [1, 2, 3]],
|
||||
filters: [
|
||||
{ minValue: 0, tags: [] },
|
||||
{ minValue: 3, tags: ["a", "b"] },
|
||||
],
|
||||
blob: [{ text: "" }, { text: "a".repeat(500) }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("SearchParams.define", () => {
|
||||
test("decodes garbage to the default", () => {
|
||||
assertDecodesToDefault(testDefinition, "limit", [
|
||||
[""],
|
||||
["abc"],
|
||||
["0"],
|
||||
["101"],
|
||||
["1.5"],
|
||||
["Infinity"],
|
||||
["lz~%%%%"],
|
||||
]);
|
||||
assertDecodesToDefault(testDefinition, "enabled", [
|
||||
[""],
|
||||
["TRUE"],
|
||||
["1"],
|
||||
["yes"],
|
||||
]);
|
||||
assertDecodesToDefault(testDefinition, "mode", [[""], ["RM"], ["tw"]]);
|
||||
assertDecodesToDefault(testDefinition, "season", [[""], ["x"]]);
|
||||
assertDecodesToDefault(testDefinition, "filters", [
|
||||
[""],
|
||||
["{"],
|
||||
['{"minValue":"nope","tags":[]}'],
|
||||
["[1,2,3]"],
|
||||
]);
|
||||
});
|
||||
|
||||
test("parses a Request, URL and URLSearchParams", () => {
|
||||
const url = "http://localhost/builds?limit=50&mode=SZ";
|
||||
const expected = { limit: 50, mode: "SZ" };
|
||||
|
||||
expect(testDefinition.parse(new Request(url))).toMatchObject(expected);
|
||||
expect(testDefinition.parse(new URL(url))).toMatchObject(expected);
|
||||
expect(
|
||||
testDefinition.parse(new URLSearchParams("limit=50&mode=SZ")),
|
||||
).toMatchObject(expected);
|
||||
});
|
||||
|
||||
test("resolves every missing param to its default", () => {
|
||||
expect(testDefinition.parse(new URLSearchParams())).toEqual({
|
||||
limit: 24,
|
||||
name: "",
|
||||
enabled: false,
|
||||
mode: "TW",
|
||||
season: null,
|
||||
ids: [],
|
||||
filters: { minValue: 0, tags: [] },
|
||||
blob: { text: "" },
|
||||
});
|
||||
});
|
||||
|
||||
test("drops invalid array members instead of the whole array", () => {
|
||||
expect(
|
||||
testDefinition.parse(new URLSearchParams("ids=1&ids=x&ids=-2&ids=3")).ids,
|
||||
).toEqual([1, 3]);
|
||||
});
|
||||
|
||||
test("decodes legacy JSON-encoded arrays", () => {
|
||||
const definitionWithModes = SearchParams.define({
|
||||
modes: SP.param(z.array(z.enum(["SZ", "TC", "RM", "CB"])), {
|
||||
default: ["SZ", "TC", "RM", "CB"],
|
||||
loader: false,
|
||||
}),
|
||||
});
|
||||
|
||||
expect(
|
||||
SearchParams.decodeParam(definitionWithModes.shape.modes, [
|
||||
'["SZ","TC"]',
|
||||
]),
|
||||
).toEqual(["SZ", "TC"]);
|
||||
expect(testDefinition.parse(new URLSearchParams("ids=[1,2]")).ids).toEqual([
|
||||
1, 2,
|
||||
]);
|
||||
});
|
||||
|
||||
test("decodes legacy comma-joined numeric arrays", () => {
|
||||
expect(testDefinition.parse(new URLSearchParams("ids=1,2,3")).ids).toEqual([
|
||||
1, 2, 3,
|
||||
]);
|
||||
});
|
||||
|
||||
test("returns referentially equal values for the same raw input", () => {
|
||||
const first = testDefinition.parse(new URLSearchParams("ids=1&ids=2"));
|
||||
const second = testDefinition.parse(new URLSearchParams("ids=1&ids=2"));
|
||||
|
||||
expect(first.ids).toBe(second.ids);
|
||||
});
|
||||
|
||||
test("rejects schemas outside the derivation table at define time", () => {
|
||||
expect(() =>
|
||||
SP.param(z.object({ a: z.string() }) as any, {
|
||||
default: { a: "" },
|
||||
loader: true,
|
||||
}),
|
||||
).toThrow(/derive/);
|
||||
expect(() =>
|
||||
SP.param(z.string().transform((s) => s.length) as any, {
|
||||
default: 0,
|
||||
loader: true,
|
||||
}),
|
||||
).toThrow(/derive/);
|
||||
expect(() =>
|
||||
SP.param(z.array(z.array(z.number())) as any, {
|
||||
default: [],
|
||||
loader: true,
|
||||
}),
|
||||
).toThrow(/derive/);
|
||||
});
|
||||
|
||||
test("defaults .nullable() params to null without declaring it", () => {
|
||||
const omitted = SP.param(z.number().int().nullable(), { loader: true });
|
||||
const declared = SP.param(z.number().int().nullable(), {
|
||||
default: null,
|
||||
loader: true,
|
||||
});
|
||||
|
||||
expect(omitted.default).toBeNull();
|
||||
expect(SearchParams.decodeParam(omitted, [])).toBeNull();
|
||||
expect(SearchParams.decodeParam(omitted, ["nope"])).toBeNull();
|
||||
expect(SearchParams.encodeParam(omitted, null)).toEqual([]);
|
||||
expect(declared.default).toBeNull();
|
||||
});
|
||||
|
||||
test("rejects .optional() and non-null defaults for .nullable()", () => {
|
||||
expect(() =>
|
||||
SP.param(z.number().optional() as any, { default: 1, loader: true }),
|
||||
).toThrow(/nullable/);
|
||||
expect(() =>
|
||||
SP.param(z.number().nullable(), { default: 1 as any, loader: true }),
|
||||
).toThrow(/null as its default/);
|
||||
});
|
||||
|
||||
test("supports SP.custom codecs with total decode via issues", () => {
|
||||
const isoDate = z.codec(z.string(), z.date(), {
|
||||
decode: (value, payload) => {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
payload.issues.push({
|
||||
code: "custom",
|
||||
message: "invalid date",
|
||||
input: value,
|
||||
});
|
||||
return z.NEVER;
|
||||
}
|
||||
return date;
|
||||
},
|
||||
encode: (date) => date.toISOString(),
|
||||
});
|
||||
const customDefinition = SearchParams.define({
|
||||
from: SP.custom(isoDate.nullable(), { default: null, loader: true }),
|
||||
});
|
||||
|
||||
const value = new Date("2024-05-01T12:00:00.000Z");
|
||||
expect(
|
||||
customDefinition.parse(
|
||||
new URL(
|
||||
customDefinition.href("/x", { from: value }),
|
||||
"http://localhost",
|
||||
),
|
||||
).from,
|
||||
).toEqual(value);
|
||||
assertDecodesToDefault(customDefinition, "from", [["garbage"], [""]]);
|
||||
});
|
||||
|
||||
test("rejects resets pointing at unknown params", () => {
|
||||
expect(() =>
|
||||
SearchParams.define({
|
||||
a: SP.param(z.number(), { default: 0, loader: true, resets: ["b"] }),
|
||||
}),
|
||||
).toThrow(/unknown param/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SearchParams compression", () => {
|
||||
test("decodes a compressed arrival of any param identically to plain", () => {
|
||||
const def = testDefinition.shape.filters;
|
||||
const value = { minValue: 7, tags: ["x"] };
|
||||
const plain = def.encodePlain(value)[0];
|
||||
|
||||
expect(
|
||||
SearchParams.decodeParam(def, [
|
||||
SearchParams.compressTransportValue(plain),
|
||||
]),
|
||||
).toEqual(value);
|
||||
});
|
||||
|
||||
test("always emits the compressed form for compress: true params", () => {
|
||||
const encoded = SearchParams.encodeParam(testDefinition.shape.blob, {
|
||||
text: "hello world",
|
||||
});
|
||||
|
||||
expect(encoded).toHaveLength(1);
|
||||
expect(encoded[0]).toMatch(/^lz~/);
|
||||
});
|
||||
|
||||
test("resolves a corrupt compressed payload to the default", () => {
|
||||
expect(
|
||||
SearchParams.decodeParam(testDefinition.shape.blob, ["lz~$$$$"]),
|
||||
).toEqual({ text: "" });
|
||||
});
|
||||
|
||||
test("resolves a compression bomb to the default", () => {
|
||||
const bomb = SearchParams.compressTransportValue(
|
||||
JSON.stringify({ text: "a".repeat(10 * 1024 * 1024) }),
|
||||
);
|
||||
|
||||
expect(SearchParams.decodeParam(testDefinition.shape.blob, [bomb])).toEqual(
|
||||
{ text: "" },
|
||||
);
|
||||
});
|
||||
|
||||
test("compresses on demand only when it shortens the value", () => {
|
||||
const longFilters = {
|
||||
minValue: 1,
|
||||
tags: Array.from({ length: 30 }, (_, i) => `long-tag-number-${i}`),
|
||||
};
|
||||
const compactHref = testDefinition.href(
|
||||
"/x",
|
||||
{ filters: longFilters, limit: 50 },
|
||||
{ compress: true },
|
||||
);
|
||||
|
||||
const searchParams = new URL(compactHref, "http://localhost").searchParams;
|
||||
expect(searchParams.get("filters")).toMatch(/^lz~/);
|
||||
expect(searchParams.get("limit")).toBe("50");
|
||||
expect(
|
||||
testDefinition.parse(new URL(compactHref, "http://localhost")),
|
||||
).toMatchObject({ filters: longFilters, limit: 50 });
|
||||
});
|
||||
|
||||
test("compares the forms percent-encoded, not as raw strings", () => {
|
||||
// shorter than its compressed form as a raw string, longer once percent-encoded
|
||||
const filters = { minValue: 1, tags: ["ゲームのタグ"] };
|
||||
const compactHref = testDefinition.href(
|
||||
"/x",
|
||||
{ filters },
|
||||
{ compress: true },
|
||||
);
|
||||
|
||||
const searchParams = new URL(compactHref, "http://localhost").searchParams;
|
||||
expect(searchParams.get("filters")).toMatch(/^lz~/);
|
||||
expect(compactHref.length).toBeLessThan(
|
||||
testDefinition.href("/x", { filters }).length,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SearchParams.href", () => {
|
||||
test("omits values equal to their default", () => {
|
||||
expect(
|
||||
testDefinition.href("/builds", { limit: 24, mode: "SZ", season: null }),
|
||||
).toBe("/builds?mode=SZ");
|
||||
expect(testDefinition.href("/builds", { limit: 24 })).toBe("/builds");
|
||||
});
|
||||
|
||||
test("encodes arrays as repeated keys and empty arrays as one empty value", () => {
|
||||
const definitionWithDefault = SearchParams.define({
|
||||
modes: SP.param(z.array(z.enum(["SZ", "TC"])), {
|
||||
default: ["SZ", "TC"],
|
||||
loader: false,
|
||||
}),
|
||||
});
|
||||
|
||||
expect(testDefinition.href("/x", { ids: [1, 2] })).toBe("/x?ids=1&ids=2");
|
||||
expect(definitionWithDefault.href("/x", { modes: [] })).toBe("/x?modes=");
|
||||
expect(
|
||||
definitionWithDefault.parse(new URL("/x?modes=", "http://localhost"))
|
||||
.modes,
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SearchParams.applyToSearchParams", () => {
|
||||
test("preserves params outside the definition", () => {
|
||||
const { next } = SearchParams.applyToSearchParams(
|
||||
testDefinition,
|
||||
new URLSearchParams("unrelated=yes&limit=50"),
|
||||
{ mode: "SZ" },
|
||||
);
|
||||
|
||||
expect(next.get("unrelated")).toBe("yes");
|
||||
expect(next.get("limit")).toBe("50");
|
||||
expect(next.get("mode")).toBe("SZ");
|
||||
});
|
||||
|
||||
test("applies declared resets", () => {
|
||||
const { next } = SearchParams.applyToSearchParams(
|
||||
testDefinition,
|
||||
new URLSearchParams("limit=50&enabled=true"),
|
||||
{ filters: { minValue: 1, tags: [] } },
|
||||
);
|
||||
|
||||
expect(next.has("limit")).toBe(false);
|
||||
expect(next.get("enabled")).toBe("true");
|
||||
});
|
||||
|
||||
test("does not reset a param written in the same batch", () => {
|
||||
const { next } = SearchParams.applyToSearchParams(
|
||||
testDefinition,
|
||||
new URLSearchParams(),
|
||||
{ limit: 50, filters: { minValue: 1, tags: ["a"] } },
|
||||
);
|
||||
|
||||
expect(next.get("limit")).toBe("50");
|
||||
});
|
||||
|
||||
test("removes params written back to their default", () => {
|
||||
const { next } = SearchParams.applyToSearchParams(
|
||||
testDefinition,
|
||||
new URLSearchParams("mode=SZ"),
|
||||
{ mode: "TW" },
|
||||
);
|
||||
|
||||
expect(next.has("mode")).toBe(false);
|
||||
});
|
||||
|
||||
test("needs navigation exactly when a loader: true param is written", () => {
|
||||
expect(
|
||||
SearchParams.applyToSearchParams(testDefinition, new URLSearchParams(), {
|
||||
enabled: true,
|
||||
ids: [1],
|
||||
}).navigationNeeded,
|
||||
).toBe(false);
|
||||
expect(
|
||||
SearchParams.applyToSearchParams(testDefinition, new URLSearchParams(), {
|
||||
enabled: true,
|
||||
mode: "SZ",
|
||||
}).navigationNeeded,
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SearchParams.shouldRevalidate", () => {
|
||||
function run(
|
||||
currentSearch: string,
|
||||
nextSearch: string,
|
||||
overrides?: Partial<Parameters<ShouldRevalidateFunction>[0]>,
|
||||
) {
|
||||
return testDefinition.shouldRevalidate({
|
||||
currentUrl: new URL(`http://localhost/x${currentSearch}`),
|
||||
nextUrl: new URL(`http://localhost/x${nextSearch}`),
|
||||
currentParams: {},
|
||||
nextParams: {},
|
||||
defaultShouldRevalidate: true,
|
||||
...overrides,
|
||||
} as Parameters<ShouldRevalidateFunction>[0]);
|
||||
}
|
||||
|
||||
test("revalidates when a loader: true param's decoded value changes", () => {
|
||||
expect(run("?limit=50", "?limit=60")).toBe(true);
|
||||
expect(run("", "?mode=SZ")).toBe(true);
|
||||
});
|
||||
|
||||
test("does not revalidate for loader: false params", () => {
|
||||
expect(run("", "?enabled=true&ids=1")).toBe(false);
|
||||
});
|
||||
|
||||
test("does not revalidate for non-canonical but equal values", () => {
|
||||
expect(
|
||||
run(
|
||||
'?filters={"minValue":1,"tags":[]}',
|
||||
'?filters={"tags":[],"minValue":1}',
|
||||
),
|
||||
).toBe(false);
|
||||
expect(run("?limit=24", "")).toBe(false);
|
||||
});
|
||||
|
||||
test("defers to the default for other pathnames, submissions and unknown params", () => {
|
||||
expect(
|
||||
testDefinition.shouldRevalidate({
|
||||
currentUrl: new URL("http://localhost/x"),
|
||||
nextUrl: new URL("http://localhost/y"),
|
||||
currentParams: {},
|
||||
nextParams: {},
|
||||
defaultShouldRevalidate: true,
|
||||
} as Parameters<ShouldRevalidateFunction>[0]),
|
||||
).toBe(true);
|
||||
expect(run("", "", { formMethod: "POST" })).toBe(true);
|
||||
expect(run("?unrelated=1", "?unrelated=2")).toBe(true);
|
||||
});
|
||||
});
|
||||
609
apps/web-react/app/modules/search-params/search-params.ts
Normal file
609
apps/web-react/app/modules/search-params/search-params.ts
Normal file
@@ -0,0 +1,609 @@
|
||||
import type { ShouldRevalidateFunction } from "react-router";
|
||||
import { isDeepEqual } from "remeda";
|
||||
import { z } from "zod";
|
||||
import { compressToBase64, decompressFromBase64 } from "~/utils/compression";
|
||||
|
||||
const COMPRESSED_PREFIX = "lz~";
|
||||
const ESCAPED_PREFIX = "lz~~";
|
||||
const DECODE_CACHE_MAX_SIZE = 300;
|
||||
const MAX_DECOMPRESSED_VALUE_BYTES = 256 * 1024;
|
||||
const DEFAULT_MAX_PAGE = 1000;
|
||||
|
||||
const DECODE_FAILED = Symbol("DECODE_FAILED");
|
||||
|
||||
type ScalarBase = "string" | "number" | "boolean";
|
||||
|
||||
type EncodeMode = "canonical" | "compact";
|
||||
|
||||
interface ParamOptionsBase {
|
||||
/** Whether changing this param must run loaders. `false` params write through `history.replaceState` and never trigger revalidation. */
|
||||
loader: boolean;
|
||||
/** Param keys reset to their defaults whenever this param is written. */
|
||||
resets?: string[];
|
||||
/** The param's canonical encoding is the compressed form. Only for params whose values are inherently large. */
|
||||
compress?: boolean;
|
||||
/** The value schema reads the clock, so its decode results must never be cached. */
|
||||
timeDependent?: boolean;
|
||||
}
|
||||
|
||||
type DefaultOption<T> = {
|
||||
/** Value used when the param is missing or fails to decode. Values equal to it are omitted from the URL. Must be a static value. */
|
||||
default: T;
|
||||
};
|
||||
|
||||
type ParamOptions<T> = ParamOptionsBase &
|
||||
(unknown extends T
|
||||
? DefaultOption<T>
|
||||
: null extends T
|
||||
? {
|
||||
/** Omit it: a nullable param's default is always `null`. */
|
||||
default?: null;
|
||||
}
|
||||
: DefaultOption<T>);
|
||||
|
||||
type ResolvedParamOptions<T> = ParamOptionsBase & { default: T };
|
||||
|
||||
export interface ParamDef<T> {
|
||||
default: T;
|
||||
loader: boolean;
|
||||
resets: string[];
|
||||
compress: boolean;
|
||||
timeDependent: boolean;
|
||||
decodeValues: (values: string[]) => T;
|
||||
encodePlain: (value: T) => string[];
|
||||
decodeCache: Map<string, T>;
|
||||
}
|
||||
|
||||
type AnyShape = Record<string, ParamDef<any>>;
|
||||
|
||||
export type SearchParamsValues<Shape extends AnyShape> = {
|
||||
[K in keyof Shape]: Shape[K] extends ParamDef<infer T> ? T : never;
|
||||
};
|
||||
|
||||
export interface SearchParamsDefinition<Shape extends AnyShape> {
|
||||
shape: Shape;
|
||||
keys: string[];
|
||||
/** Decodes all params of the definition. Total: defaults resolve for missing or malformed values, never throws. */
|
||||
parse: (input: Request | URL | URLSearchParams) => SearchParamsValues<Shape>;
|
||||
/** Builds a href with the given values encoded as search params. Values equal to their default are omitted. */
|
||||
href: (
|
||||
path: string,
|
||||
values: Partial<SearchParamsValues<Shape>>,
|
||||
opts?: { compress?: boolean },
|
||||
) => string;
|
||||
/** Revalidates only when a `loader: true` param's decoded canonical value changed. */
|
||||
shouldRevalidate: ShouldRevalidateFunction;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a search params definition from param declarations (see `SP.param`,
|
||||
* `SP.json` and `SP.custom`). One definition per route or feature drives
|
||||
* loader parsing, client state, href building and revalidation.
|
||||
*/
|
||||
export function define<Shape extends AnyShape>(
|
||||
shape: Shape,
|
||||
): SearchParamsDefinition<Shape> {
|
||||
const keys = Object.keys(shape);
|
||||
|
||||
for (const [key, def] of Object.entries(shape)) {
|
||||
for (const resetKey of def.resets) {
|
||||
if (!keys.includes(resetKey)) {
|
||||
throw new Error(
|
||||
`Search param "${key}" resets unknown param "${resetKey}"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const definition: SearchParamsDefinition<Shape> = {
|
||||
shape,
|
||||
keys,
|
||||
parse: (input) => {
|
||||
const searchParams = toSearchParams(input);
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const key of keys) {
|
||||
result[key] = decodeParam(shape[key], searchParams.getAll(key));
|
||||
}
|
||||
return result as SearchParamsValues<Shape>;
|
||||
},
|
||||
href: (path, values, opts) => {
|
||||
const searchParams = new URLSearchParams();
|
||||
const mode: EncodeMode = opts?.compress ? "compact" : "canonical";
|
||||
for (const key of keys) {
|
||||
if (!(key in values)) continue;
|
||||
for (const encoded of encodeParam(shape[key], values[key], mode)) {
|
||||
searchParams.append(key, encoded);
|
||||
}
|
||||
}
|
||||
const queryString = searchParams.toString();
|
||||
if (!queryString) return path;
|
||||
|
||||
return `${path}${path.includes("?") ? "&" : "?"}${queryString}`;
|
||||
},
|
||||
shouldRevalidate: (args) => {
|
||||
if (args.currentUrl.pathname !== args.nextUrl.pathname) {
|
||||
return args.defaultShouldRevalidate;
|
||||
}
|
||||
if (args.formMethod && args.formMethod !== "GET") {
|
||||
return args.defaultShouldRevalidate;
|
||||
}
|
||||
if (args.currentUrl.href === args.nextUrl.href) {
|
||||
return args.defaultShouldRevalidate;
|
||||
}
|
||||
const current = args.currentUrl.searchParams;
|
||||
const next = args.nextUrl.searchParams;
|
||||
if (unknownParamsChanged(keys, current, next)) {
|
||||
return args.defaultShouldRevalidate;
|
||||
}
|
||||
for (const key of keys) {
|
||||
const def = shape[key];
|
||||
if (!def.loader) continue;
|
||||
if (
|
||||
!isDeepEqual(
|
||||
decodeParam(def, current.getAll(key)),
|
||||
decodeParam(def, next.getAll(key)),
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
};
|
||||
|
||||
return definition;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes one param from its raw URL values, resolving to the default when the
|
||||
* param is missing or malformed. Uses a per-param cache keyed on the raw values
|
||||
* so repeated decodes of the same string return the same reference. Params
|
||||
* declared `timeDependent` skip the cache and decode fresh every time.
|
||||
*/
|
||||
export function decodeParam<T>(def: ParamDef<T>, values: string[]): T {
|
||||
if (def.timeDependent) return def.decodeValues(values);
|
||||
|
||||
const cacheKey = JSON.stringify(values);
|
||||
if (def.decodeCache.has(cacheKey)) {
|
||||
return def.decodeCache.get(cacheKey) as T;
|
||||
}
|
||||
|
||||
const decoded = def.decodeValues(values);
|
||||
|
||||
if (def.decodeCache.size >= DECODE_CACHE_MAX_SIZE) {
|
||||
def.decodeCache.clear();
|
||||
}
|
||||
def.decodeCache.set(cacheKey, decoded);
|
||||
|
||||
return decoded;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodes one param value to its URL values. Returns an empty array (param
|
||||
* absent) for values equal to the default.
|
||||
*/
|
||||
export function encodeParam<T>(
|
||||
def: ParamDef<T>,
|
||||
value: T,
|
||||
mode: EncodeMode = "canonical",
|
||||
): string[] {
|
||||
if (isDeepEqual(value, def.default)) return [];
|
||||
|
||||
return def.encodePlain(value).map((plain) => wrapValue(plain, def, mode));
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a partial values update on top of the current search params,
|
||||
* preserving params outside the definition, applying declared `resets` and
|
||||
* omitting values equal to their defaults. A key written in the same batch is
|
||||
* never reset by another key of that batch.
|
||||
*/
|
||||
export function applyToSearchParams<Shape extends AnyShape>(
|
||||
definition: SearchParamsDefinition<Shape>,
|
||||
current: URLSearchParams,
|
||||
updates: Partial<SearchParamsValues<Shape>>,
|
||||
): { next: URLSearchParams; navigationNeeded: boolean } {
|
||||
const next = new URLSearchParams(current);
|
||||
let navigationNeeded = false;
|
||||
|
||||
const updatedKeys = definition.keys.filter((key) => key in updates);
|
||||
|
||||
const resetKeys = new Set<string>();
|
||||
for (const key of updatedKeys) {
|
||||
for (const resetKey of definition.shape[key].resets) {
|
||||
if (!(resetKey in updates)) resetKeys.add(resetKey);
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of updatedKeys) {
|
||||
const def = definition.shape[key];
|
||||
if (def.loader) {
|
||||
navigationNeeded = true;
|
||||
}
|
||||
|
||||
next.delete(key);
|
||||
for (const encoded of encodeParam(def, updates[key])) {
|
||||
next.append(key, encoded);
|
||||
}
|
||||
}
|
||||
|
||||
for (const resetKey of resetKeys) {
|
||||
if (definition.shape[resetKey].loader && next.has(resetKey)) {
|
||||
navigationNeeded = true;
|
||||
}
|
||||
next.delete(resetKey);
|
||||
}
|
||||
|
||||
return { next, navigationNeeded };
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes only the definition's keys out of a search string. Used as a
|
||||
* cheap fingerprint: it changes exactly when one of the definition's params
|
||||
* changes in the URL.
|
||||
*/
|
||||
export function pickRelevantSearch(keys: string[], search: string): string {
|
||||
const searchParams = new URLSearchParams(search);
|
||||
const picked = new URLSearchParams();
|
||||
for (const key of keys) {
|
||||
for (const value of searchParams.getAll(key)) {
|
||||
picked.append(key, value);
|
||||
}
|
||||
}
|
||||
return picked.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Param declaration helpers. `SP.param` is the canonical declaration deriving
|
||||
* the URL encoding from the value schema; `SP.json` and `SP.custom` are the
|
||||
* explicit helpers for shapes outside the derivation table.
|
||||
*/
|
||||
export const SP = {
|
||||
/**
|
||||
* Declares a param whose URL encoding is derived from the zod value
|
||||
* schema's type tree. Supported shapes: strings, numbers, booleans, string
|
||||
* and number enums/literals, same-base-type unions, arrays of those
|
||||
* (encoded as repeated keys) and a top-level `.nullable()` wrapper (`null`
|
||||
* encodes as param absent, so `default` is omitted for those). Anything else
|
||||
* is a `define()`-time error — use `SP.json` or `SP.custom` instead.
|
||||
*/
|
||||
param<S extends z.ZodType>(
|
||||
schema: S,
|
||||
opts: ParamOptions<z.output<S>>,
|
||||
): ParamDef<z.output<S>> {
|
||||
const resolved = resolveOptions(opts);
|
||||
let core: z.ZodType = schema;
|
||||
|
||||
if (core instanceof z.ZodOptional) {
|
||||
throw new Error(
|
||||
"Search params use .nullable() instead of .optional() (null encodes as param absent)",
|
||||
);
|
||||
}
|
||||
if (core instanceof z.ZodNullable) {
|
||||
if (resolved.default !== null) {
|
||||
throw new Error(
|
||||
"A .nullable() search param must have null as its default, otherwise null and the default could not be told apart in the URL",
|
||||
);
|
||||
}
|
||||
core = core.unwrap() as z.ZodType;
|
||||
}
|
||||
|
||||
if (core instanceof z.ZodArray) {
|
||||
const itemBase = deriveScalarBase(core.element as z.ZodType);
|
||||
if (!itemBase) {
|
||||
throw new Error(
|
||||
`Cannot derive an URL encoding for the array item schema of a search param (got ${describeSchema(core.element as z.ZodType)}). Use SP.json or SP.custom.`,
|
||||
);
|
||||
}
|
||||
return arrayParam(schema, core, itemBase, resolved);
|
||||
}
|
||||
|
||||
const base = deriveScalarBase(core);
|
||||
if (!base) {
|
||||
throw new Error(
|
||||
`Cannot derive an URL encoding for a search param schema (got ${describeSchema(core)}). Use SP.json or SP.custom.`,
|
||||
);
|
||||
}
|
||||
return scalarParam(schema, base, resolved);
|
||||
},
|
||||
|
||||
/** Declares the 1-based `page` param of a paginated route, as `useSearchParamPagination` expects it. */
|
||||
page(opts?: { max?: number; resets?: string[] }): ParamDef<number> {
|
||||
return SP.param(
|
||||
z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(opts?.max ?? DEFAULT_MAX_PAGE),
|
||||
{ default: 1, loader: true, resets: opts?.resets },
|
||||
);
|
||||
},
|
||||
|
||||
/** Declares a param encoded as `JSON.stringify` in a single value. For objects and whole-array-as-one-param values. */
|
||||
json<S extends z.ZodType>(
|
||||
schema: S,
|
||||
opts: ParamOptions<z.output<S>>,
|
||||
): ParamDef<z.output<S>> {
|
||||
const resolved = resolveOptions(opts);
|
||||
|
||||
return {
|
||||
...baseDef(resolved),
|
||||
decodeValues: (values) => {
|
||||
if (values.length === 0) return resolved.default;
|
||||
const plain = unwrapValue(values[0]);
|
||||
if (plain === DECODE_FAILED) return resolved.default;
|
||||
let json: unknown;
|
||||
try {
|
||||
json = JSON.parse(plain);
|
||||
} catch {
|
||||
return resolved.default;
|
||||
}
|
||||
const parsed = schema.safeParse(json);
|
||||
return parsed.success ? parsed.data : resolved.default;
|
||||
},
|
||||
encodePlain: (value) => [JSON.stringify(value)],
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Escape hatch: declares a param from a `z.codec(z.string(), valueSchema, ...)`
|
||||
* passed directly. The codec's `decode` may accept legacy formats while
|
||||
* `encode` always emits the canonical one.
|
||||
*/
|
||||
custom<Value>(
|
||||
codec: z.ZodType<Value, string | null>,
|
||||
opts: ParamOptions<Value>,
|
||||
): ParamDef<Value> {
|
||||
const resolved = resolveOptions(opts);
|
||||
|
||||
return {
|
||||
...baseDef(resolved),
|
||||
decodeValues: (values) => {
|
||||
if (values.length === 0) return resolved.default;
|
||||
const plain = unwrapValue(values[0]);
|
||||
if (plain === DECODE_FAILED) return resolved.default;
|
||||
const parsed = z.safeDecode(codec, plain);
|
||||
return parsed.success ? parsed.data : resolved.default;
|
||||
},
|
||||
encodePlain: (value) => {
|
||||
const encoded = z.safeEncode(codec, value);
|
||||
if (!encoded.success || typeof encoded.data !== "string") {
|
||||
throw new Error(
|
||||
"Encoding a search param value failed; SP.custom codecs must encode every value of their type",
|
||||
);
|
||||
}
|
||||
return [encoded.data];
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
function resolveOptions<T>(opts: ParamOptions<T>): ResolvedParamOptions<T> {
|
||||
const { default: defaultValue, ...rest } = opts as ParamOptionsBase & {
|
||||
default?: T;
|
||||
};
|
||||
|
||||
return { ...rest, default: (defaultValue ?? null) as T };
|
||||
}
|
||||
|
||||
function baseDef<T>(
|
||||
opts: ResolvedParamOptions<T>,
|
||||
): Pick<
|
||||
ParamDef<T>,
|
||||
"default" | "loader" | "resets" | "compress" | "timeDependent" | "decodeCache"
|
||||
> {
|
||||
return {
|
||||
default: opts.default,
|
||||
loader: opts.loader,
|
||||
resets: opts.resets ?? [],
|
||||
compress: opts.compress ?? false,
|
||||
timeDependent: opts.timeDependent ?? false,
|
||||
decodeCache: new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
function scalarParam<T>(
|
||||
schema: z.ZodType,
|
||||
base: ScalarBase,
|
||||
opts: ResolvedParamOptions<T>,
|
||||
): ParamDef<T> {
|
||||
return {
|
||||
...baseDef(opts),
|
||||
decodeValues: (values) => {
|
||||
if (values.length === 0) return opts.default;
|
||||
const plain = unwrapValue(values[0]);
|
||||
if (plain === DECODE_FAILED) return opts.default;
|
||||
const candidate = plainToScalar(plain, base);
|
||||
if (candidate === DECODE_FAILED) return opts.default;
|
||||
const parsed = schema.safeParse(candidate);
|
||||
return parsed.success ? (parsed.data as T) : opts.default;
|
||||
},
|
||||
encodePlain: (value) => [String(value)],
|
||||
};
|
||||
}
|
||||
|
||||
function arrayParam<T>(
|
||||
schema: z.ZodType,
|
||||
arraySchema: z.ZodArray,
|
||||
itemBase: ScalarBase,
|
||||
opts: ResolvedParamOptions<T>,
|
||||
): ParamDef<T> {
|
||||
const itemSchema = arraySchema.element as z.ZodType;
|
||||
|
||||
return {
|
||||
...baseDef(opts),
|
||||
decodeValues: (values) => {
|
||||
if (values.length === 0) return opts.default;
|
||||
|
||||
const plains: string[] = [];
|
||||
for (const value of values) {
|
||||
const plain = unwrapValue(value);
|
||||
if (plain !== DECODE_FAILED) plains.push(plain);
|
||||
}
|
||||
|
||||
let items = plains;
|
||||
if (plains.length === 1) {
|
||||
if (plains[0] === "") {
|
||||
items = [];
|
||||
} else if (plains[0].startsWith("[")) {
|
||||
// legacy decode fallback for JSON-encoded arrays
|
||||
try {
|
||||
const parsed = JSON.parse(plains[0]);
|
||||
if (Array.isArray(parsed)) {
|
||||
items = parsed.map((member) => String(member));
|
||||
}
|
||||
} catch {}
|
||||
} else if (itemBase === "number" && plains[0].includes(",")) {
|
||||
// legacy decode fallback for comma-joined numeric arrays
|
||||
items = plains[0].split(",");
|
||||
}
|
||||
}
|
||||
|
||||
const members: unknown[] = [];
|
||||
for (const item of items) {
|
||||
const candidate = plainToScalar(item, itemBase);
|
||||
if (candidate === DECODE_FAILED) continue;
|
||||
const parsed = itemSchema.safeParse(candidate);
|
||||
if (parsed.success) members.push(parsed.data);
|
||||
}
|
||||
|
||||
const parsed = schema.safeParse(members);
|
||||
return parsed.success ? (parsed.data as T) : opts.default;
|
||||
},
|
||||
encodePlain: (value) => {
|
||||
const items = value as unknown[];
|
||||
if (items.length === 0) return [""];
|
||||
return items.map((item) => String(item));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function plainToScalar(
|
||||
plain: string,
|
||||
base: ScalarBase,
|
||||
): string | number | boolean | typeof DECODE_FAILED {
|
||||
if (base === "string") return plain;
|
||||
|
||||
if (base === "number") {
|
||||
if (plain.trim() === "") return DECODE_FAILED;
|
||||
const parsed = Number(plain);
|
||||
return Number.isFinite(parsed) ? parsed : DECODE_FAILED;
|
||||
}
|
||||
|
||||
if (plain === "true") return true;
|
||||
if (plain === "false") return false;
|
||||
return DECODE_FAILED;
|
||||
}
|
||||
|
||||
function deriveScalarBase(schema: z.ZodType): ScalarBase | null {
|
||||
if (schema instanceof z.ZodString) return "string";
|
||||
if (schema instanceof z.ZodNumber) return "number";
|
||||
if (schema instanceof z.ZodBoolean) return "boolean";
|
||||
|
||||
if (schema instanceof z.ZodEnum) {
|
||||
return uniformTypeOf(schema.options);
|
||||
}
|
||||
if (schema instanceof z.ZodLiteral) {
|
||||
return uniformTypeOf(Array.from(schema.values));
|
||||
}
|
||||
if (schema instanceof z.ZodUnion) {
|
||||
const bases = (schema.options as z.ZodType[]).map(deriveScalarBase);
|
||||
if (bases[0] && bases.every((base) => base === bases[0])) {
|
||||
return bases[0];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function uniformTypeOf(values: unknown[]): ScalarBase | null {
|
||||
const types = new Set(values.map((value) => typeof value));
|
||||
if (types.size !== 1) return null;
|
||||
|
||||
const type = Array.from(types)[0];
|
||||
if (type === "string" || type === "number" || type === "boolean") {
|
||||
return type;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function describeSchema(schema: z.ZodType) {
|
||||
return schema.constructor.name;
|
||||
}
|
||||
|
||||
function toSearchParams(
|
||||
input: Request | URL | URLSearchParams,
|
||||
): URLSearchParams {
|
||||
if (input instanceof URLSearchParams) return input;
|
||||
if (input instanceof URL) return input.searchParams;
|
||||
return new URL(input.url).searchParams;
|
||||
}
|
||||
|
||||
function unknownParamsChanged(
|
||||
knownKeys: string[],
|
||||
current: URLSearchParams,
|
||||
next: URLSearchParams,
|
||||
): boolean {
|
||||
const unknownKeys = new Set<string>();
|
||||
for (const key of current.keys()) {
|
||||
if (!knownKeys.includes(key)) unknownKeys.add(key);
|
||||
}
|
||||
for (const key of next.keys()) {
|
||||
if (!knownKeys.includes(key)) unknownKeys.add(key);
|
||||
}
|
||||
|
||||
for (const key of unknownKeys) {
|
||||
if (!isDeepEqual(current.getAll(key), next.getAll(key))) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function wrapValue<T>(plain: string, def: ParamDef<T>, mode: EncodeMode) {
|
||||
if (def.compress) return compressTransportValue(plain);
|
||||
|
||||
if (mode === "compact") {
|
||||
const compressed = compressTransportValue(plain);
|
||||
const escaped = escapePlainValue(plain);
|
||||
if (urlEncodedLength(compressed) < urlEncodedLength(escaped)) {
|
||||
return compressed;
|
||||
}
|
||||
}
|
||||
|
||||
return escapePlainValue(plain);
|
||||
}
|
||||
|
||||
/** Length the value takes in the URL, i.e. percent-encoded as `URLSearchParams` writes it. */
|
||||
function urlEncodedLength(value: string) {
|
||||
return new URLSearchParams([["", value]]).toString().length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a plain encoded value in the compressed transport form. Any param can
|
||||
* arrive compressed like this; used by round-trip tests and share links.
|
||||
*/
|
||||
export function compressTransportValue(plain: string) {
|
||||
return `${COMPRESSED_PREFIX}${compressToBase64(plain, { urlSafe: true })}`;
|
||||
}
|
||||
|
||||
function escapePlainValue(plain: string) {
|
||||
if (!plain.startsWith(COMPRESSED_PREFIX)) return plain;
|
||||
|
||||
return `${ESCAPED_PREFIX}${plain.slice(COMPRESSED_PREFIX.length)}`;
|
||||
}
|
||||
|
||||
function unwrapValue(raw: string): string | typeof DECODE_FAILED {
|
||||
if (raw.startsWith(ESCAPED_PREFIX)) {
|
||||
return `${COMPRESSED_PREFIX}${raw.slice(ESCAPED_PREFIX.length)}`;
|
||||
}
|
||||
|
||||
if (raw.startsWith(COMPRESSED_PREFIX)) {
|
||||
const decompressed = decompressFromBase64(
|
||||
raw.slice(COMPRESSED_PREFIX.length),
|
||||
{ maxDecompressedBytes: MAX_DECOMPRESSED_VALUE_BYTES },
|
||||
);
|
||||
return decompressed === null ? DECODE_FAILED : decompressed;
|
||||
}
|
||||
|
||||
return raw;
|
||||
}
|
||||
46
apps/web-react/app/modules/search-params/store.ts
Normal file
46
apps/web-react/app/modules/search-params/store.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
let historyPatched = false;
|
||||
|
||||
/**
|
||||
* Subscribes to changes of the current URL search string, from any write
|
||||
* channel: react-router navigations (which call `history.pushState` /
|
||||
* `history.replaceState` under the hood), our own `history.replaceState`
|
||||
* writes and back/forward navigation.
|
||||
*
|
||||
* Note that our own `history.replaceState` writes (`loader: false` params) are
|
||||
* invisible to react-router: `useLocation()` keeps returning the search string
|
||||
* of the last navigation. Read those params through this module's hooks.
|
||||
*/
|
||||
export function subscribe(listener: () => void) {
|
||||
patchHistoryOnce();
|
||||
listeners.add(listener);
|
||||
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
function notify() {
|
||||
for (const listener of listeners) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
|
||||
function patchHistoryOnce() {
|
||||
if (historyPatched || typeof window === "undefined") return;
|
||||
historyPatched = true;
|
||||
|
||||
const originalPushState = window.history.pushState.bind(window.history);
|
||||
const originalReplaceState = window.history.replaceState.bind(window.history);
|
||||
|
||||
window.history.pushState = (...args) => {
|
||||
originalPushState(...args);
|
||||
notify();
|
||||
};
|
||||
window.history.replaceState = (...args) => {
|
||||
originalReplaceState(...args);
|
||||
notify();
|
||||
};
|
||||
window.addEventListener("popstate", notify);
|
||||
}
|
||||
@@ -0,0 +1,487 @@
|
||||
import { stageIds } from "~/modules/in-game-lists/stage-ids";
|
||||
import invariant from "~/utils/invariant";
|
||||
import { logger } from "~/utils/logger";
|
||||
import { seededRandom } from "~/utils/random";
|
||||
import { err, ok, type Result } from "~/utils/result";
|
||||
import type { ModeShort, StageId } from "../in-game-lists/types";
|
||||
import { DEFAULT_MAP_POOL } from "./constants";
|
||||
import type {
|
||||
TournamentMapListMap,
|
||||
TournamentMaplistInput,
|
||||
TournamentMaplistSource,
|
||||
} from "./types";
|
||||
|
||||
type ModeWithStageAndScore = TournamentMapListMap & { score: number };
|
||||
|
||||
const OPTIMAL_MAPLIST_SCORE = 0;
|
||||
const MAX_RECURSION_DEPTH = 5_000;
|
||||
|
||||
type MapListGenerationError =
|
||||
| "MAX_RECURSION_DEPTH_EXCEEDED"
|
||||
| "COULD_NOT_GENERATE_MAPLIST"
|
||||
| "MAPS_FOR_MODES_NOT_INCLUDED"
|
||||
| "DUPLICATE_MAPS_IN_MAP_POOL";
|
||||
|
||||
/**
|
||||
* Generates a map list balanced between the two teams' map pools. If generation fails
|
||||
* due to the recently played maps consideration, retries once without it.
|
||||
*/
|
||||
export function generateBalancedMapList(
|
||||
input: TournamentMaplistInput,
|
||||
): Result<Array<TournamentMapListMap>, MapListGenerationError> {
|
||||
const result = generateWithInput(input);
|
||||
|
||||
if (
|
||||
!result.ok &&
|
||||
result.error === "MAX_RECURSION_DEPTH_EXCEEDED" &&
|
||||
input.recentlyPlayedMaps
|
||||
) {
|
||||
logger.error(
|
||||
`Failed to generate map list with recently played maps consideration. Retrying without recently played maps. Team IDs: ${input.teams.map((t) => t.id).join(", ")}`,
|
||||
);
|
||||
return generateWithInput({
|
||||
...input,
|
||||
recentlyPlayedMaps: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function generateWithInput(
|
||||
input: TournamentMaplistInput,
|
||||
): Result<Array<TournamentMapListMap>, MapListGenerationError> {
|
||||
const validationError = validateInput(input);
|
||||
if (validationError) return err(validationError);
|
||||
|
||||
const { seededShuffle } = seededRandom(input.seed);
|
||||
// trying the least recently played maps first lets the search find a good
|
||||
// enough list before the recursion depth cap is hit
|
||||
const stages = seededShuffle(resolveCommonStages()).sort(
|
||||
(a, b) => recencyPenalty(a) - recencyPenalty(b),
|
||||
);
|
||||
const mapList: Array<ModeWithStageAndScore & { score: number }> = [];
|
||||
const bestMapList: { maps?: Array<ModeWithStageAndScore>; score: number } = {
|
||||
score: Number.POSITIVE_INFINITY,
|
||||
};
|
||||
const usedStages = new Set<number>();
|
||||
let depth = 0;
|
||||
|
||||
const backtrack = (): boolean => {
|
||||
if (++depth > MAX_RECURSION_DEPTH) {
|
||||
return false;
|
||||
}
|
||||
invariant(mapList.length <= input.count, "mapList.length > input.count");
|
||||
const mapListScore = rateMapList();
|
||||
if (typeof mapListScore === "number" && mapListScore < bestMapList.score) {
|
||||
bestMapList.maps = [...mapList];
|
||||
bestMapList.score = mapListScore;
|
||||
}
|
||||
|
||||
// There can't be better map list than this
|
||||
if (bestMapList.score === OPTIMAL_MAPLIST_SCORE) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const stageList =
|
||||
mapList.length < input.count - 1 || input.tiebreakerMaps.length === 0
|
||||
? resolveStages()
|
||||
: input.tiebreakerMaps.stageModePairs.map((p) => ({
|
||||
...p,
|
||||
score: 0,
|
||||
source: "TIEBREAKER" as const,
|
||||
}));
|
||||
|
||||
// tiebreaker/fallback lists at the last slot get their own key range so
|
||||
// their indices don't collide with the picked indices of the main list
|
||||
const usedStageKeyOffset = stageList === stages ? 0 : stages.length;
|
||||
|
||||
for (const [i, stage] of stageList.entries()) {
|
||||
const usedStageKey = i + usedStageKeyOffset;
|
||||
if (!stageIsOk(stage, usedStageKey)) continue;
|
||||
mapList.push(stage);
|
||||
usedStages.add(usedStageKey);
|
||||
|
||||
const continueSearch = backtrack();
|
||||
if (!continueSearch) return false;
|
||||
|
||||
usedStages.delete(usedStageKey);
|
||||
mapList.pop();
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const searchExhausted = backtrack();
|
||||
|
||||
// a complete list found before the depth cap was hit is still valid,
|
||||
// only its optimality is unproven
|
||||
if (bestMapList.maps) return ok(bestMapList.maps);
|
||||
if (!searchExhausted) return err("MAX_RECURSION_DEPTH_EXCEEDED");
|
||||
|
||||
return err("COULD_NOT_GENERATE_MAPLIST");
|
||||
|
||||
function resolveCommonStages() {
|
||||
const sorted = input.teams
|
||||
.slice()
|
||||
.sort((a, b) => a.id - b.id) as TournamentMaplistInput["teams"];
|
||||
|
||||
const result = sorted[0].maps.stageModePairs.map((pair) => ({
|
||||
...pair,
|
||||
score: 1,
|
||||
source: sorted[0].id as TournamentMaplistSource,
|
||||
}));
|
||||
|
||||
for (const stage of sorted[1].maps.stageModePairs) {
|
||||
const alreadyIncludedStage = result.find(
|
||||
(alreadyIncludedStage) =>
|
||||
alreadyIncludedStage.stageId === stage.stageId &&
|
||||
alreadyIncludedStage.mode === stage.mode,
|
||||
);
|
||||
|
||||
if (alreadyIncludedStage) {
|
||||
alreadyIncludedStage.score = 0;
|
||||
alreadyIncludedStage.source = "BOTH";
|
||||
} else {
|
||||
result.push({ ...stage, score: -1, source: sorted[1].id });
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
input.teams[0].maps.stages.length === 0 &&
|
||||
input.teams[1].maps.stages.length === 0
|
||||
) {
|
||||
// neither team submitted map, we go default
|
||||
result.push(
|
||||
...getDefaultMapPool().map((pair) => ({
|
||||
...pair,
|
||||
score: 0,
|
||||
source: "DEFAULT" as const,
|
||||
})),
|
||||
);
|
||||
} else if (
|
||||
input.teams[0].maps.stages.length === 0 ||
|
||||
input.teams[1].maps.stages.length === 0
|
||||
) {
|
||||
// let's set it up for later that if one team doesn't have stages set
|
||||
// we can make a maplist consisting of only stages from the team that did submit
|
||||
for (const stageObj of result) {
|
||||
stageObj.score = 0;
|
||||
}
|
||||
}
|
||||
|
||||
return result.sort((a, b) =>
|
||||
`${a.stageId}-${a.mode}`.localeCompare(`${b.stageId}-${b.mode}`),
|
||||
);
|
||||
}
|
||||
|
||||
function resolveStages() {
|
||||
if (utilizeOtherStageIdsWhenNoTiebreaker()) {
|
||||
// no overlap so we need to use a random map for tiebreaker
|
||||
|
||||
if (tournamentIsOneModeOnly()) {
|
||||
return seededShuffle([...stageIds])
|
||||
.filter(
|
||||
(stageId) =>
|
||||
!input.teams[0].maps.hasStage(stageId) &&
|
||||
!input.teams[1].maps.hasStage(stageId),
|
||||
)
|
||||
.map((stageId) => ({
|
||||
stageId,
|
||||
mode: input.modesIncluded[0],
|
||||
score: 0,
|
||||
source: "TIEBREAKER" as const,
|
||||
}));
|
||||
}
|
||||
return DEFAULT_MAP_POOL.stageModePairs
|
||||
.filter(
|
||||
(pair) =>
|
||||
!input.teams[0].maps.has(pair) && !input.teams[1].maps.has(pair),
|
||||
)
|
||||
.map((pair) => ({
|
||||
stageId: pair.stageId,
|
||||
mode: pair.mode,
|
||||
score: 0,
|
||||
source: "TIEBREAKER" as const,
|
||||
}));
|
||||
}
|
||||
|
||||
return stages;
|
||||
}
|
||||
|
||||
function validateInput(
|
||||
input: TournamentMaplistInput,
|
||||
): MapListGenerationError | null {
|
||||
const everyMapIsOfIncludedMode = input.teams.every((team) =>
|
||||
team.maps.stageModePairs.every((pair) =>
|
||||
input.modesIncluded.includes(pair.mode),
|
||||
),
|
||||
);
|
||||
if (!everyMapIsOfIncludedMode) return "MAPS_FOR_MODES_NOT_INCLUDED";
|
||||
|
||||
for (const team of input.teams) {
|
||||
const stringified = team.maps.stageModePairs.map(
|
||||
(p) => `${p.stageId}-${p.mode}`,
|
||||
);
|
||||
const unique = new Set(stringified);
|
||||
if (unique.size !== stringified.length) {
|
||||
return "DUPLICATE_MAPS_IN_MAP_POOL";
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function utilizeOtherStageIdsWhenNoTiebreaker() {
|
||||
if (mapList.length < input.count - 1) return false;
|
||||
|
||||
if (
|
||||
input.teams.every((team) => !team.maps.isEmpty()) &&
|
||||
!input.teams[0].maps.overlaps(input.teams[1].maps)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const teamsMapsLeftNotPicked =
|
||||
[...input.teams[0].maps, ...input.teams[1].maps].filter(
|
||||
(stage) =>
|
||||
!mapList.some(
|
||||
(map) => map.stageId === stage.stageId && map.mode === stage.mode,
|
||||
),
|
||||
).length > 0;
|
||||
if (!teamsMapsLeftNotPicked) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function getDefaultMapPool() {
|
||||
if (tournamentIsOneModeOnly()) {
|
||||
const mode = input.modesIncluded[0];
|
||||
|
||||
return stageIds.map((id) => ({ mode, stageId: id }));
|
||||
}
|
||||
|
||||
return DEFAULT_MAP_POOL.stageModePairs.filter(({ mode }) =>
|
||||
input.modesIncluded.includes(mode),
|
||||
);
|
||||
}
|
||||
|
||||
type StageValidatorInput = Pick<
|
||||
ModeWithStageAndScore,
|
||||
"score" | "stageId" | "mode" | "source"
|
||||
>;
|
||||
|
||||
// adding rules here can achieve to things
|
||||
// 1) adjust what kind of map list is generated
|
||||
// 2) optimize the algorithm my eliminating subtrees from consideration
|
||||
function stageIsOk(stage: StageValidatorInput, index: number) {
|
||||
if (usedStages.has(index)) return false;
|
||||
if (mapListAlreadyFull()) return false;
|
||||
if (isEarlyModeRepeat(stage)) return false;
|
||||
if (isNotFollowingModePattern(stage)) return false;
|
||||
if (isMakingThingsUnfair(stage)) return false;
|
||||
if (isStageRepeatWithoutBreak(stage)) return false;
|
||||
if (isSecondPickBySameTeamInRow(stage)) return false;
|
||||
if (wouldPreventTiebreaker(stage)) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function tournamentIsOneModeOnly() {
|
||||
return input.modesIncluded.length === 1;
|
||||
}
|
||||
|
||||
function mapListAlreadyFull() {
|
||||
return mapList.length === input.count;
|
||||
}
|
||||
|
||||
function isEarlyModeRepeat(stage: StageValidatorInput) {
|
||||
if (tournamentIsOneModeOnly()) return false;
|
||||
|
||||
// all modes already appeared
|
||||
if (mapList.length >= input.modesIncluded.length) return false;
|
||||
|
||||
if (
|
||||
mapList.some(
|
||||
(alreadyIncludedStage) => alreadyIncludedStage.mode === stage.mode,
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function isNotFollowingModePattern(stage: StageValidatorInput) {
|
||||
if (tournamentIsOneModeOnly()) return false;
|
||||
|
||||
if (input.followModeOrder) {
|
||||
return isNotFollowingModeOrder(stage);
|
||||
}
|
||||
|
||||
// not all modes appeared yet
|
||||
if (mapList.length < input.modesIncluded.length) return false;
|
||||
|
||||
let previousModeShouldBe: ModeShort | undefined;
|
||||
for (let i = 0; i < mapList.length; i++) {
|
||||
if (mapList[i].mode === stage.mode) {
|
||||
if (i === 0) {
|
||||
previousModeShouldBe = mapList[mapList.length - 1].mode;
|
||||
} else {
|
||||
previousModeShouldBe = mapList[i - 1].mode;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!previousModeShouldBe) return false;
|
||||
|
||||
return mapList[mapList.length - 1].mode !== previousModeShouldBe;
|
||||
}
|
||||
|
||||
function isNotFollowingModeOrder(stage: StageValidatorInput) {
|
||||
let currentIndex = 0;
|
||||
for (let i = 0; i < mapList.length; i++) {
|
||||
currentIndex++;
|
||||
if (currentIndex === input.modesIncluded.length) currentIndex = 0;
|
||||
}
|
||||
|
||||
return stage.mode !== input.modesIncluded[currentIndex];
|
||||
}
|
||||
|
||||
// don't allow making two picks from one team in row
|
||||
function isMakingThingsUnfair(stage: StageValidatorInput) {
|
||||
// allow to handle edge case where both teams have 100% overlap in one mode
|
||||
// but not others in Bo5 for example could make impossible to make map because e.g.
|
||||
// 1) overlap
|
||||
// 2) team 1 pick
|
||||
// 3) team 2 pick
|
||||
// 4) team 1 pick
|
||||
// 5) TIEBREAKER <- system has to allow
|
||||
// ---
|
||||
// but later we will with score make sure that we exhaust better options too
|
||||
if (stage.source === "TIEBREAKER") return false;
|
||||
|
||||
const score = mapList.reduce((acc, cur) => acc + cur.score, 0);
|
||||
const newScore = score + stage.score;
|
||||
|
||||
if (score !== 0 && newScore !== 0) return true;
|
||||
if (newScore !== 0 && mapList.length + 1 === input.count) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function isStageRepeatWithoutBreak(stage: StageValidatorInput) {
|
||||
const lastStage = mapList[mapList.length - 1];
|
||||
if (!lastStage) return false;
|
||||
|
||||
return lastStage.stageId === stage.stageId;
|
||||
}
|
||||
|
||||
function isSecondPickBySameTeamInRow(stage: StageValidatorInput) {
|
||||
const lastStage = mapList[mapList.length - 1];
|
||||
if (!lastStage) return false;
|
||||
if (stage.score === 0) return false;
|
||||
|
||||
return lastStage.score === stage.score;
|
||||
}
|
||||
|
||||
function wouldPreventTiebreaker(stage: StageValidatorInput) {
|
||||
// tiebreaker always guaranteed if maps are explicitly set
|
||||
if (input.tiebreakerMaps.length > 0) return false;
|
||||
|
||||
const commonMaps = input.teams[0].maps.stageModePairs.filter(
|
||||
({ stageId, mode }) =>
|
||||
input.teams[1].maps.stageModePairs.some(
|
||||
(pair) => pair.stageId === stageId && pair.mode === mode,
|
||||
),
|
||||
);
|
||||
|
||||
const newMapList = [...mapList, stage];
|
||||
|
||||
const newCommonMaps = commonMaps.filter(
|
||||
({ stageId, mode }) =>
|
||||
!newMapList.some(
|
||||
(pair) => pair.stageId === stageId && pair.mode === mode,
|
||||
),
|
||||
);
|
||||
|
||||
// there was at least one possible common map
|
||||
// to pick as tiebreaker but it (or they) got picked too early
|
||||
return (
|
||||
commonMaps.length > 0 &&
|
||||
// handles special case where both teams have the same maps in their pool
|
||||
commonMaps.length !== input.teams[0].maps.stageModePairs.length &&
|
||||
newCommonMaps.length === 0 &&
|
||||
newMapList.length !== input.count
|
||||
);
|
||||
}
|
||||
|
||||
function rateMapList() {
|
||||
// not a full map list
|
||||
if (mapList.length !== input.count) return;
|
||||
|
||||
let score = OPTIMAL_MAPLIST_SCORE;
|
||||
|
||||
const appearedMaps = new Map<StageId, number>();
|
||||
for (const stage of mapList) {
|
||||
const timesAppeared = appearedMaps.get(stage.stageId) ?? 0;
|
||||
|
||||
if (timesAppeared > 0) {
|
||||
score += timesAppeared;
|
||||
}
|
||||
|
||||
appearedMaps.set(stage.stageId, timesAppeared + 1);
|
||||
}
|
||||
|
||||
if (!lastMapIsAGoodTieBreaker()) {
|
||||
score += 1;
|
||||
}
|
||||
|
||||
const fairnessBalance = mapList.reduce((acc, cur) => acc + cur.score, 0);
|
||||
if (fairnessBalance !== 0) {
|
||||
score += 100;
|
||||
}
|
||||
|
||||
for (const map of mapList) {
|
||||
score += recencyPenalty(map);
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
function recencyPenalty(map: Pick<TournamentMapListMap, "stageId" | "mode">) {
|
||||
if (!input.recentlyPlayedMaps) return 0;
|
||||
|
||||
const recentIndex = input.recentlyPlayedMaps.findIndex(
|
||||
(recent) => recent.stageId === map.stageId && recent.mode === map.mode,
|
||||
);
|
||||
if (recentIndex === -1) return 0;
|
||||
|
||||
return Math.max(10 - Math.floor(recentIndex / 2) * 2, 0);
|
||||
}
|
||||
|
||||
function lastMapIsAGoodTieBreaker() {
|
||||
// guaranteed to be good if more than one mode
|
||||
if (!tournamentIsOneModeOnly()) return true;
|
||||
|
||||
// specifically made tiebreaker map is considered good
|
||||
const last = mapList[mapList.length - 1];
|
||||
if (last.source === "TIEBREAKER") return true;
|
||||
|
||||
// we can't have a map from pools of both teams if both didn't submit maps
|
||||
if (input.teams.some((team) => team.maps.stageModePairs.length === 0)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const tieBreakerMap = mapList[mapList.length - 1];
|
||||
|
||||
let appearanceCount = 0;
|
||||
|
||||
for (const team of input.teams) {
|
||||
for (const stage of team.maps.stages) {
|
||||
if (stage === tieBreakerMap.stageId) appearanceCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return appearanceCount === 2;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { MapPool } from "~/features/map-list-generator/core/map-pool";
|
||||
|
||||
export const sourceTypes = [
|
||||
"DEFAULT",
|
||||
"TIEBREAKER",
|
||||
"BOTH",
|
||||
"TO",
|
||||
"COUNTERPICK",
|
||||
"ROLL",
|
||||
] as const;
|
||||
|
||||
// this is only used as a fallback, in the case that map list generation has a bug
|
||||
export const DEFAULT_MAP_POOL = new MapPool([
|
||||
{ mode: "SZ", stageId: 6 },
|
||||
{ mode: "SZ", stageId: 8 },
|
||||
{ mode: "SZ", stageId: 9 },
|
||||
{ mode: "SZ", stageId: 15 },
|
||||
{ mode: "SZ", stageId: 17 },
|
||||
|
||||
{ mode: "TC", stageId: 1 },
|
||||
{ mode: "TC", stageId: 2 },
|
||||
{ mode: "TC", stageId: 10 },
|
||||
{ mode: "TC", stageId: 14 },
|
||||
{ mode: "TC", stageId: 16 },
|
||||
|
||||
{ mode: "RM", stageId: 0 },
|
||||
{ mode: "RM", stageId: 3 },
|
||||
{ mode: "RM", stageId: 9 },
|
||||
{ mode: "RM", stageId: 10 },
|
||||
{ mode: "RM", stageId: 17 },
|
||||
|
||||
{ mode: "CB", stageId: 0 },
|
||||
{ mode: "CB", stageId: 1 },
|
||||
{ mode: "CB", stageId: 8 },
|
||||
{ mode: "CB", stageId: 14 },
|
||||
{ mode: "CB", stageId: 16 },
|
||||
]);
|
||||
@@ -0,0 +1,931 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { MapPool } from "~/features/map-list-generator/core/map-pool";
|
||||
import { unwrap, unwrapErr } from "~/utils/result";
|
||||
import { rankedModesShort } from "../in-game-lists/modes";
|
||||
import type { RankedModeShort } from "../in-game-lists/types";
|
||||
import { generateBalancedMapList } from "./balanced-map-list";
|
||||
import { DEFAULT_MAP_POOL } from "./constants";
|
||||
import type { TournamentMaplistInput } from "./types";
|
||||
|
||||
const team1Picks = new MapPool([
|
||||
{ mode: "SZ", stageId: 4 },
|
||||
{ mode: "SZ", stageId: 5 },
|
||||
{ mode: "TC", stageId: 5 },
|
||||
{ mode: "TC", stageId: 6 },
|
||||
{ mode: "RM", stageId: 7 },
|
||||
{ mode: "RM", stageId: 8 },
|
||||
{ mode: "CB", stageId: 9 },
|
||||
{ mode: "CB", stageId: 10 },
|
||||
]);
|
||||
const team2Picks = new MapPool([
|
||||
{ mode: "SZ", stageId: 11 },
|
||||
{ mode: "SZ", stageId: 9 },
|
||||
{ mode: "TC", stageId: 2 },
|
||||
{ mode: "TC", stageId: 8 },
|
||||
{ mode: "RM", stageId: 7 },
|
||||
{ mode: "RM", stageId: 1 },
|
||||
{ mode: "CB", stageId: 2 },
|
||||
{ mode: "CB", stageId: 3 },
|
||||
]);
|
||||
const team2PicksNoOverlap = new MapPool([
|
||||
{ mode: "SZ", stageId: 11 },
|
||||
{ mode: "SZ", stageId: 9 },
|
||||
{ mode: "TC", stageId: 2 },
|
||||
{ mode: "TC", stageId: 8 },
|
||||
{ mode: "RM", stageId: 17 },
|
||||
{ mode: "RM", stageId: 1 },
|
||||
{ mode: "CB", stageId: 2 },
|
||||
{ mode: "CB", stageId: 3 },
|
||||
]);
|
||||
const tiebreakerPicks = new MapPool([
|
||||
{ mode: "SZ", stageId: 1 },
|
||||
{ mode: "TC", stageId: 11 },
|
||||
{ mode: "RM", stageId: 3 },
|
||||
{ mode: "CB", stageId: 4 },
|
||||
]);
|
||||
|
||||
const duplicationPicks = new MapPool([
|
||||
{ mode: "SZ", stageId: 4 },
|
||||
{ mode: "SZ", stageId: 5 },
|
||||
{ mode: "TC", stageId: 4 },
|
||||
{ mode: "TC", stageId: 5 },
|
||||
{ mode: "RM", stageId: 6 },
|
||||
{ mode: "RM", stageId: 7 },
|
||||
{ mode: "CB", stageId: 6 },
|
||||
{ mode: "CB", stageId: 7 },
|
||||
]);
|
||||
const duplicationTiebreaker = new MapPool([
|
||||
{ mode: "SZ", stageId: 7 },
|
||||
{ mode: "TC", stageId: 6 },
|
||||
{ mode: "RM", stageId: 5 },
|
||||
{ mode: "CB", stageId: 4 },
|
||||
]);
|
||||
|
||||
const generateMapsResult = ({
|
||||
count = 5,
|
||||
seed = "test",
|
||||
teams = [
|
||||
{
|
||||
id: 1,
|
||||
maps: team1Picks,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
maps: team2Picks,
|
||||
},
|
||||
],
|
||||
tiebreakerMaps = tiebreakerPicks,
|
||||
modesIncluded = [...rankedModesShort],
|
||||
followModeOrder = false,
|
||||
recentlyPlayedMaps,
|
||||
}: Partial<TournamentMaplistInput> = {}) => {
|
||||
return generateBalancedMapList({
|
||||
count,
|
||||
seed,
|
||||
teams,
|
||||
tiebreakerMaps,
|
||||
modesIncluded,
|
||||
followModeOrder,
|
||||
recentlyPlayedMaps,
|
||||
});
|
||||
};
|
||||
|
||||
const generateMaps = (args: Partial<TournamentMaplistInput> = {}) =>
|
||||
unwrap(generateMapsResult(args));
|
||||
|
||||
describe("Tournament map list generator", () => {
|
||||
test("Modes are spread evenly", () => {
|
||||
const mapList = generateMaps();
|
||||
const modes = new Set(rankedModesShort);
|
||||
|
||||
expect(mapList.length).toBe(5);
|
||||
|
||||
for (const [i, { mode }] of mapList.entries()) {
|
||||
const rankedMode = mode as RankedModeShort;
|
||||
if (!modes.has(rankedMode)) {
|
||||
expect(i).toBe(4);
|
||||
expect(mode).toBe(mapList[0].mode);
|
||||
}
|
||||
|
||||
modes.delete(rankedMode);
|
||||
}
|
||||
});
|
||||
|
||||
test("Follow mode order option", () => {
|
||||
const mapList = generateMaps({ followModeOrder: true });
|
||||
|
||||
expect(mapList[0].mode).toBe("SZ");
|
||||
expect(mapList[1].mode).toBe("TC");
|
||||
expect(mapList[2].mode).toBe("RM");
|
||||
expect(mapList[3].mode).toBe("CB");
|
||||
expect(mapList[4].mode).toBe("SZ");
|
||||
});
|
||||
|
||||
test("Equal picks", () => {
|
||||
let our = 0;
|
||||
let their = 0;
|
||||
let tiebreaker = 0;
|
||||
|
||||
const mapList = generateMaps();
|
||||
|
||||
for (const { stageId, mode } of mapList) {
|
||||
if (team1Picks.has({ stageId, mode })) {
|
||||
our++;
|
||||
}
|
||||
|
||||
if (team2Picks.has({ stageId, mode })) {
|
||||
their++;
|
||||
}
|
||||
|
||||
if (tiebreakerPicks.has({ stageId, mode })) {
|
||||
tiebreaker++;
|
||||
}
|
||||
}
|
||||
|
||||
expect(our).toBe(their);
|
||||
expect(tiebreaker).toBe(1);
|
||||
});
|
||||
|
||||
test("No stage repeats in optimal case", () => {
|
||||
const mapList = generateMaps();
|
||||
|
||||
const stages = new Set(mapList.map(({ stageId }) => stageId));
|
||||
|
||||
expect(stages.size).toBe(5);
|
||||
});
|
||||
|
||||
test("Always generates same maplist given same input", () => {
|
||||
const mapList1 = generateMaps();
|
||||
const mapList2 = generateMaps();
|
||||
|
||||
expect(mapList1.length).toBe(5);
|
||||
|
||||
for (let i = 0; i < mapList1.length; i++) {
|
||||
expect(mapList1[i].stageId).toBe(mapList2[i].stageId);
|
||||
expect(mapList1[i].mode).toBe(mapList2[i].mode);
|
||||
}
|
||||
});
|
||||
|
||||
test("Order of team doesn't matter regarding what maplist gets created", () => {
|
||||
const mapList1 = generateMaps();
|
||||
const mapList2 = generateMaps({
|
||||
teams: [
|
||||
{
|
||||
id: 2,
|
||||
maps: team2Picks,
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
maps: team1Picks,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(mapList1.length).toBe(5);
|
||||
|
||||
for (let i = 0; i < mapList1.length; i++) {
|
||||
expect(mapList1[i].stageId).toBe(mapList2[i].stageId);
|
||||
expect(mapList1[i].mode).toBe(mapList2[i].mode);
|
||||
}
|
||||
});
|
||||
|
||||
test("Order of maps in the list doesn't matter regarding what maplist gets created", () => {
|
||||
const mapList1 = generateMaps({
|
||||
teams: [
|
||||
{
|
||||
id: 1,
|
||||
maps: team1Picks,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
maps: team2Picks,
|
||||
},
|
||||
],
|
||||
});
|
||||
const mapList2 = generateMaps({
|
||||
teams: [
|
||||
{
|
||||
id: 1,
|
||||
maps: team1Picks,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
maps: new MapPool(team2Picks.stageModePairs.slice().reverse()),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(mapList1.length).toBe(5);
|
||||
|
||||
for (let i = 0; i < mapList1.length; i++) {
|
||||
expect(mapList1[i].stageId).toBe(mapList2[i].stageId);
|
||||
expect(mapList1[i].mode).toBe(mapList2[i].mode);
|
||||
}
|
||||
});
|
||||
|
||||
test("Uses other teams maps if one didn't submit maplist", () => {
|
||||
const mapList = generateMaps({
|
||||
teams: [
|
||||
{
|
||||
id: 1,
|
||||
maps: new MapPool([]),
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
maps: team2Picks,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(mapList.length).toBe(5);
|
||||
|
||||
for (let i = 0; i < mapList.length - 1; i++) {
|
||||
// map belongs to team 2 map list
|
||||
const map = mapList[i];
|
||||
expect(map).toBeTruthy();
|
||||
|
||||
expect(team2Picks.has({ mode: map.mode, stageId: map.stageId })).toBe(
|
||||
true,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("Creates map list even if neither team submitted maps", () => {
|
||||
const mapList = generateMaps({
|
||||
teams: [
|
||||
{
|
||||
id: 1,
|
||||
maps: new MapPool([]),
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
maps: new MapPool([]),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(mapList.length).toBe(5);
|
||||
});
|
||||
|
||||
test("Handles worst case with duplication", () => {
|
||||
const maplist = generateMaps({
|
||||
teams: [
|
||||
{
|
||||
id: 1,
|
||||
maps: duplicationPicks,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
maps: duplicationPicks,
|
||||
},
|
||||
],
|
||||
count: 7,
|
||||
tiebreakerMaps: duplicationTiebreaker,
|
||||
});
|
||||
|
||||
expect(maplist.length).toBe(7);
|
||||
|
||||
// all stages appear
|
||||
const stages = new Set(maplist.map(({ stageId }) => stageId));
|
||||
expect(stages.size).toBe(4);
|
||||
|
||||
// no consecutive stage replays
|
||||
for (let i = 0; i < maplist.length - 1; i++) {
|
||||
expect(maplist[i].stageId).not.toBe(maplist[i + 1].stageId);
|
||||
}
|
||||
});
|
||||
|
||||
const team2PicksWithSomeDuplication = new MapPool([
|
||||
{ mode: "SZ", stageId: 4 },
|
||||
{ mode: "SZ", stageId: 11 },
|
||||
{ mode: "TC", stageId: 5 },
|
||||
{ mode: "TC", stageId: 6 },
|
||||
{ mode: "RM", stageId: 7 },
|
||||
{ mode: "RM", stageId: 2 },
|
||||
{ mode: "CB", stageId: 9 },
|
||||
{ mode: "CB", stageId: 10 },
|
||||
]);
|
||||
|
||||
test("Keeps things fair when overlap", () => {
|
||||
const mapList = generateMaps({
|
||||
teams: [
|
||||
{
|
||||
id: 1,
|
||||
maps: team1Picks,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
maps: team2PicksWithSomeDuplication,
|
||||
},
|
||||
],
|
||||
count: 7,
|
||||
});
|
||||
|
||||
expect(mapList.length).toBe(7);
|
||||
|
||||
let team1PicksAppeared = 0;
|
||||
let team2PicksAppeared = 0;
|
||||
|
||||
for (const { stageId, mode } of mapList) {
|
||||
if (team1Picks.has({ stageId, mode })) {
|
||||
team1PicksAppeared++;
|
||||
}
|
||||
|
||||
if (team2PicksWithSomeDuplication.has({ stageId, mode })) {
|
||||
team2PicksAppeared++;
|
||||
}
|
||||
}
|
||||
|
||||
expect(team1PicksAppeared).toBe(team2PicksAppeared);
|
||||
});
|
||||
|
||||
test("No map picked by same team twice in row", () => {
|
||||
for (let i = 1; i <= 10; i++) {
|
||||
const mapList = generateMaps({
|
||||
teams: [
|
||||
{
|
||||
id: 1,
|
||||
maps: team1Picks,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
maps: team2Picks,
|
||||
},
|
||||
],
|
||||
seed: String(i),
|
||||
});
|
||||
|
||||
for (let j = 0; j < mapList.length - 1; j++) {
|
||||
if (typeof mapList[j].source !== "number") continue;
|
||||
expect(mapList[j].source).not.toBe(mapList[j + 1].source);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("Calculates all mode maps without tiebreaker", () => {
|
||||
const mapList = generateMaps({
|
||||
teams: [
|
||||
{
|
||||
id: 1,
|
||||
maps: team1Picks,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
maps: team2Picks,
|
||||
},
|
||||
],
|
||||
count: 7,
|
||||
tiebreakerMaps: new MapPool([]),
|
||||
});
|
||||
|
||||
// the one map both of them picked
|
||||
expect(mapList[6].stageId).toBe(7);
|
||||
expect(mapList[6].mode).toBe("RM");
|
||||
});
|
||||
|
||||
test("Calculates all mode maps without tiebreaker (no overlap)", () => {
|
||||
const mapList = generateMaps({
|
||||
teams: [
|
||||
{
|
||||
id: 1,
|
||||
maps: team1Picks,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
maps: team2PicksNoOverlap,
|
||||
},
|
||||
],
|
||||
count: 7,
|
||||
tiebreakerMaps: new MapPool([]),
|
||||
});
|
||||
|
||||
// default map pool contains the tiebreaker
|
||||
expect(
|
||||
DEFAULT_MAP_POOL.stageModePairs.some(
|
||||
(pair) =>
|
||||
pair.stageId === mapList[6].stageId && pair.mode === mapList[6].mode,
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
// neither teams map pool contains the tiebreaker
|
||||
expect(
|
||||
team1Picks.stageModePairs.some(
|
||||
(pair) =>
|
||||
pair.stageId === mapList[6].stageId && pair.mode === mapList[6].mode,
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
team2PicksNoOverlap.stageModePairs.some(
|
||||
(pair) =>
|
||||
pair.stageId === mapList[6].stageId && pair.mode === mapList[6].mode,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
const threeModesArgs: TournamentMaplistInput = {
|
||||
count: 7,
|
||||
seed: "1002",
|
||||
modesIncluded: ["TC", "TW", "RM"],
|
||||
tiebreakerMaps: new MapPool({
|
||||
TW: [],
|
||||
SZ: [],
|
||||
TC: [],
|
||||
RM: [],
|
||||
CB: [],
|
||||
}),
|
||||
teams: [
|
||||
{
|
||||
id: 1002,
|
||||
maps: new MapPool({
|
||||
TW: [9, 7, 6, 5, 3, 2, 0],
|
||||
SZ: [],
|
||||
TC: [9, 8, 7, 4, 1, 6, 2],
|
||||
RM: [9, 7, 6, 5, 3, 1, 0],
|
||||
CB: [],
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 1001,
|
||||
maps: new MapPool({
|
||||
TW: [8, 7, 5, 2, 9, 4, 3],
|
||||
SZ: [],
|
||||
TC: [7, 6, 5, 3, 2, 0, 9],
|
||||
RM: [9, 8, 6, 5, 3, 2, 7],
|
||||
CB: [],
|
||||
}),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
test("generates list of modes included length > 1 && < 4", () => {
|
||||
const maps = generateMaps(threeModesArgs);
|
||||
|
||||
expect(maps.length).toBe(7);
|
||||
});
|
||||
|
||||
// paddling pool 264
|
||||
test("handles 100% overlap in one mode and none in others", () => {
|
||||
// should not throw
|
||||
generateMaps({
|
||||
count: 5,
|
||||
followModeOrder: false,
|
||||
modesIncluded: ["SZ", "TC", "RM", "CB"],
|
||||
seed: "4866",
|
||||
teams: [
|
||||
{
|
||||
id: 2317,
|
||||
maps: new MapPool([
|
||||
{
|
||||
stageId: 2,
|
||||
mode: "SZ",
|
||||
},
|
||||
{
|
||||
stageId: 17,
|
||||
mode: "SZ",
|
||||
},
|
||||
{
|
||||
stageId: 2,
|
||||
mode: "TC",
|
||||
},
|
||||
{
|
||||
stageId: 10,
|
||||
mode: "TC",
|
||||
},
|
||||
{
|
||||
stageId: 0,
|
||||
mode: "RM",
|
||||
},
|
||||
{
|
||||
stageId: 3,
|
||||
mode: "RM",
|
||||
},
|
||||
{
|
||||
stageId: 6,
|
||||
mode: "CB",
|
||||
},
|
||||
{
|
||||
stageId: 21,
|
||||
mode: "CB",
|
||||
},
|
||||
]),
|
||||
},
|
||||
{
|
||||
id: 2322,
|
||||
maps: new MapPool([
|
||||
{
|
||||
stageId: 7,
|
||||
mode: "SZ",
|
||||
},
|
||||
{
|
||||
stageId: 18,
|
||||
mode: "SZ",
|
||||
},
|
||||
{
|
||||
stageId: 2,
|
||||
mode: "TC",
|
||||
},
|
||||
{
|
||||
stageId: 10,
|
||||
mode: "TC",
|
||||
},
|
||||
{
|
||||
stageId: 2,
|
||||
mode: "RM",
|
||||
},
|
||||
{
|
||||
stageId: 19,
|
||||
mode: "RM",
|
||||
},
|
||||
{
|
||||
stageId: 7,
|
||||
mode: "CB",
|
||||
},
|
||||
{
|
||||
stageId: 18,
|
||||
mode: "CB",
|
||||
},
|
||||
]),
|
||||
},
|
||||
],
|
||||
tiebreakerMaps: new MapPool([
|
||||
{
|
||||
stageId: 15,
|
||||
mode: "SZ",
|
||||
},
|
||||
{
|
||||
stageId: 0,
|
||||
mode: "CB",
|
||||
},
|
||||
{
|
||||
stageId: 16,
|
||||
mode: "RM",
|
||||
},
|
||||
{
|
||||
stageId: 8,
|
||||
mode: "TC",
|
||||
},
|
||||
]),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const team1SZPicks = new MapPool([
|
||||
{ mode: "SZ", stageId: 4 },
|
||||
{ mode: "SZ", stageId: 5 },
|
||||
{ mode: "SZ", stageId: 6 },
|
||||
{ mode: "SZ", stageId: 7 },
|
||||
{ mode: "SZ", stageId: 8 },
|
||||
{ mode: "SZ", stageId: 9 },
|
||||
]);
|
||||
const team2SZPicks = new MapPool([
|
||||
{ mode: "SZ", stageId: 1 },
|
||||
{ mode: "SZ", stageId: 2 },
|
||||
{ mode: "SZ", stageId: 3 },
|
||||
{ mode: "SZ", stageId: 9 },
|
||||
{ mode: "SZ", stageId: 10 },
|
||||
{ mode: "SZ", stageId: 11 },
|
||||
]);
|
||||
const team2SZPicksNoOverlap = new MapPool([
|
||||
{ mode: "SZ", stageId: 1 },
|
||||
{ mode: "SZ", stageId: 2 },
|
||||
{ mode: "SZ", stageId: 3 },
|
||||
{ mode: "SZ", stageId: 14 },
|
||||
{ mode: "SZ", stageId: 10 },
|
||||
{ mode: "SZ", stageId: 11 },
|
||||
]);
|
||||
|
||||
describe("TournamentMapListGeneratorOneMode", () => {
|
||||
test("Creates map list for one mode inferring from the team picks", () => {
|
||||
const mapList = generateMaps({
|
||||
teams: [
|
||||
{
|
||||
id: 1,
|
||||
maps: team1SZPicks,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
maps: team2SZPicks,
|
||||
},
|
||||
],
|
||||
modesIncluded: ["SZ"],
|
||||
tiebreakerMaps: new MapPool([]),
|
||||
});
|
||||
for (let i = 0; i < mapList.length - 1; i++) {
|
||||
expect(mapList[i].mode).toBe("SZ");
|
||||
}
|
||||
});
|
||||
|
||||
test("Creates one mode map list from empty map lists", () => {
|
||||
const mapList = generateMaps({
|
||||
teams: [
|
||||
{
|
||||
id: 1,
|
||||
maps: new MapPool([]),
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
maps: new MapPool([]),
|
||||
},
|
||||
],
|
||||
modesIncluded: ["SZ"],
|
||||
tiebreakerMaps: new MapPool([]),
|
||||
});
|
||||
for (let i = 0; i < mapList.length - 1; i++) {
|
||||
expect(mapList[i].mode).toBe("SZ");
|
||||
}
|
||||
});
|
||||
|
||||
test("Creates all different maps from empty map lists", () => {
|
||||
const mapList = generateMaps({
|
||||
teams: [
|
||||
{
|
||||
id: 1,
|
||||
maps: new MapPool([]),
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
maps: new MapPool([]),
|
||||
},
|
||||
],
|
||||
modesIncluded: ["SZ"],
|
||||
tiebreakerMaps: new MapPool([]),
|
||||
});
|
||||
|
||||
const stages = new Set(mapList.map(({ stageId }) => stageId));
|
||||
expect(stages.size).toBe(5);
|
||||
});
|
||||
|
||||
test("Tiebreaker is always from the maps of the teams when possible", () => {
|
||||
for (let i = 1; i <= 10; i++) {
|
||||
const mapList = generateMaps({
|
||||
teams: [
|
||||
{
|
||||
id: 1,
|
||||
maps: team1SZPicks,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
maps: team2SZPicks,
|
||||
},
|
||||
],
|
||||
modesIncluded: ["SZ"],
|
||||
seed: String(i),
|
||||
tiebreakerMaps: new MapPool([]),
|
||||
});
|
||||
|
||||
const last = mapList[mapList.length - 1];
|
||||
|
||||
expect(last?.mode).toBe("SZ");
|
||||
expect(last?.stageId).toBe(9);
|
||||
}
|
||||
});
|
||||
|
||||
test("Tiebreaker is from neither team's pool if no overlap", () => {
|
||||
const mapList = generateMaps({
|
||||
teams: [
|
||||
{
|
||||
id: 1,
|
||||
maps: team1SZPicks,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
maps: team2SZPicksNoOverlap,
|
||||
},
|
||||
],
|
||||
modesIncluded: ["SZ"],
|
||||
tiebreakerMaps: new MapPool([]),
|
||||
});
|
||||
|
||||
const last = mapList[mapList.length - 1];
|
||||
|
||||
expect(
|
||||
team1SZPicks.stageModePairs.some(
|
||||
({ stageId }) => stageId === last?.stageId,
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
team2SZPicksNoOverlap.stageModePairs.some(
|
||||
({ stageId }) => stageId === last?.stageId,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("Handles worst case duplication", () => {
|
||||
const mapList = generateMaps({
|
||||
teams: [
|
||||
{
|
||||
id: 1,
|
||||
maps: team1SZPicks,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
maps: team1SZPicks,
|
||||
},
|
||||
],
|
||||
modesIncluded: ["SZ"],
|
||||
tiebreakerMaps: new MapPool([]),
|
||||
count: 7,
|
||||
});
|
||||
|
||||
for (const [i, stage] of mapList.entries()) {
|
||||
if (i === 6) {
|
||||
expect(stage?.source).toBe("TIEBREAKER");
|
||||
} else {
|
||||
expect(stage?.source).toBe("BOTH");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("Handles one team submitted no maps", () => {
|
||||
const mapList = generateMaps({
|
||||
teams: [
|
||||
{
|
||||
id: 1,
|
||||
maps: team1SZPicks,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
maps: new MapPool([]),
|
||||
},
|
||||
],
|
||||
modesIncluded: ["SZ"],
|
||||
tiebreakerMaps: new MapPool([]),
|
||||
});
|
||||
|
||||
for (const stage of mapList) {
|
||||
expect(stage.source).toBe(1);
|
||||
}
|
||||
});
|
||||
|
||||
test('Returns an error if including modes not specified in "modesIncluded"', () => {
|
||||
const result = generateMapsResult({
|
||||
teams: [
|
||||
{
|
||||
id: 1,
|
||||
maps: team1Picks,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
maps: new MapPool([]),
|
||||
},
|
||||
],
|
||||
modesIncluded: ["SZ"],
|
||||
});
|
||||
|
||||
expect(unwrapErr(result)).toBe("MAPS_FOR_MODES_NOT_INCLUDED");
|
||||
});
|
||||
|
||||
test("Returns an error if duplicate maps in the pool", () => {
|
||||
const result = generateMapsResult({
|
||||
teams: [
|
||||
{
|
||||
id: 1,
|
||||
maps: new MapPool([
|
||||
{ mode: "SZ", stageId: 1 },
|
||||
{ mode: "SZ", stageId: 1 },
|
||||
]),
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
maps: new MapPool([]),
|
||||
},
|
||||
],
|
||||
modesIncluded: ["SZ"],
|
||||
});
|
||||
|
||||
expect(unwrapErr(result)).toBe("DUPLICATE_MAPS_IN_MAP_POOL");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Recently played maps", () => {
|
||||
test("One mode Bo7 avoids recently played maps when a full avoiding list exists", () => {
|
||||
const team1Pool = new MapPool(
|
||||
([1, 2, 3, 4, 5, 6] as const).map((stageId) => ({
|
||||
mode: "SZ" as const,
|
||||
stageId,
|
||||
})),
|
||||
);
|
||||
const team2Pool = new MapPool(
|
||||
([7, 8, 9, 10, 11, 12] as const).map((stageId) => ({
|
||||
mode: "SZ" as const,
|
||||
stageId,
|
||||
})),
|
||||
);
|
||||
// the bo5 both teams played right before this match
|
||||
const recentlyPlayedMaps = ([1, 7, 2, 8, 3] as const).map((stageId) => ({
|
||||
mode: "SZ" as const,
|
||||
stageId,
|
||||
}));
|
||||
|
||||
const mapList = generateMaps({
|
||||
count: 7,
|
||||
seed: "1000",
|
||||
teams: [
|
||||
{ id: 1, maps: team1Pool },
|
||||
{ id: 2, maps: team2Pool },
|
||||
],
|
||||
tiebreakerMaps: new MapPool([]),
|
||||
modesIncluded: ["SZ"],
|
||||
recentlyPlayedMaps,
|
||||
});
|
||||
|
||||
const recentMapsInList = mapList.filter((map) =>
|
||||
recentlyPlayedMaps.some(
|
||||
(recent) => recent.mode === map.mode && recent.stageId === map.stageId,
|
||||
),
|
||||
);
|
||||
|
||||
expect(recentMapsInList).toEqual([]);
|
||||
});
|
||||
|
||||
test("Avoids recently played maps when possible", () => {
|
||||
const recentlyPlayedMaps = [
|
||||
{ mode: "SZ" as const, stageId: 4 as const },
|
||||
{ mode: "TC" as const, stageId: 5 as const },
|
||||
];
|
||||
|
||||
const mapList = generateMaps({
|
||||
seed: "recent-test",
|
||||
recentlyPlayedMaps,
|
||||
});
|
||||
|
||||
const hasRecentMap = mapList.some((map) =>
|
||||
recentlyPlayedMaps.some(
|
||||
(recent) => recent.mode === map.mode && recent.stageId === map.stageId,
|
||||
),
|
||||
);
|
||||
|
||||
expect(hasRecentMap).toBe(false);
|
||||
});
|
||||
|
||||
test("Works correctly with no recently played maps", () => {
|
||||
const mapList = generateMaps({
|
||||
recentlyPlayedMaps: [],
|
||||
});
|
||||
|
||||
expect(mapList.length).toBe(5);
|
||||
});
|
||||
|
||||
test("Penalties decrease for maps further back in history", () => {
|
||||
const recentlyPlayedMaps = [
|
||||
{ mode: "SZ" as const, stageId: 4 as const },
|
||||
{ mode: "SZ" as const, stageId: 5 as const },
|
||||
{ mode: "TC" as const, stageId: 5 as const },
|
||||
{ mode: "TC" as const, stageId: 6 as const },
|
||||
{ mode: "RM" as const, stageId: 7 as const },
|
||||
{ mode: "RM" as const, stageId: 8 as const },
|
||||
];
|
||||
|
||||
const mapListWithRecent = generateMaps({
|
||||
seed: "history-test",
|
||||
recentlyPlayedMaps,
|
||||
});
|
||||
|
||||
const hasVeryRecentMap = mapListWithRecent.some((map) =>
|
||||
recentlyPlayedMaps
|
||||
.slice(0, 2)
|
||||
.some(
|
||||
(recent) =>
|
||||
recent.mode === map.mode && recent.stageId === map.stageId,
|
||||
),
|
||||
);
|
||||
|
||||
expect(hasVeryRecentMap).toBe(false);
|
||||
});
|
||||
|
||||
test("Still generates valid maplist even with many recently played maps", () => {
|
||||
const recentlyPlayedMaps = [
|
||||
...team1Picks.stageModePairs,
|
||||
...team2Picks.stageModePairs,
|
||||
];
|
||||
|
||||
const mapList = generateMaps({
|
||||
seed: "many-recent",
|
||||
recentlyPlayedMaps,
|
||||
});
|
||||
|
||||
expect(mapList.length).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Tiebreaker maps vs picked maps index collision", () => {
|
||||
test("generates a full map list when team picks and tiebreakers share list indices", () => {
|
||||
const result = generateMapsResult({
|
||||
count: 3,
|
||||
teams: [
|
||||
{ id: 1, maps: new MapPool([{ mode: "SZ", stageId: 4 }]) },
|
||||
{ id: 2, maps: new MapPool([{ mode: "SZ", stageId: 5 }]) },
|
||||
],
|
||||
tiebreakerMaps: new MapPool([
|
||||
{ mode: "SZ", stageId: 6 },
|
||||
{ mode: "SZ", stageId: 7 },
|
||||
]),
|
||||
modesIncluded: ["SZ"],
|
||||
});
|
||||
|
||||
const mapList = unwrap(result);
|
||||
|
||||
expect(mapList.length).toBe(3);
|
||||
expect(mapList[2].source).toBe("TIEBREAKER");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import invariant from "~/utils/invariant";
|
||||
import { sourceTypes } from "./constants";
|
||||
import type {
|
||||
DBTournamentMaplistSource,
|
||||
TournamentMaplistSource,
|
||||
} from "./types";
|
||||
|
||||
/** Converts a map's source into the form stored in the `source` columns of the database. */
|
||||
export function serializeMaplistSource(
|
||||
source: TournamentMaplistSource,
|
||||
): DBTournamentMaplistSource {
|
||||
return typeof source === "number" ? String(source) : source;
|
||||
}
|
||||
|
||||
/** Converts a `source` column value back into a map's source, resolving team ids back to numbers. */
|
||||
export function parseMaplistSource(
|
||||
source: DBTournamentMaplistSource,
|
||||
): TournamentMaplistSource {
|
||||
if (sourceTypes.includes(source as (typeof sourceTypes)[number])) {
|
||||
return source as (typeof sourceTypes)[number];
|
||||
}
|
||||
|
||||
const teamId = Number(source);
|
||||
|
||||
invariant(!Number.isNaN(teamId), `Invalid source: ${source}`);
|
||||
|
||||
return teamId;
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
// if one mode -> try to find common, otherwise something neither picked
|
||||
// if a tie breaker -> random tiebreaker
|
||||
// seed = always same
|
||||
|
||||
import { stageIds } from "~/modules/in-game-lists/stage-ids";
|
||||
import { logger } from "~/utils/logger";
|
||||
import { seededRandom } from "~/utils/random";
|
||||
import { modesShort } from "../in-game-lists/modes";
|
||||
import type { ModeWithStage } from "../in-game-lists/types";
|
||||
import type { TournamentMapListMap, TournamentMaplistInput } from "./types";
|
||||
|
||||
type StarterMapArgs = Pick<
|
||||
TournamentMaplistInput,
|
||||
"modesIncluded" | "tiebreakerMaps" | "seed" | "teams" | "recentlyPlayedMaps"
|
||||
>;
|
||||
|
||||
export function starterMap(args: StarterMapArgs): Array<TournamentMapListMap> {
|
||||
const { seededShuffle } = seededRandom(args.seed);
|
||||
|
||||
const isRecentlyPlayed = (map: ModeWithStage) => {
|
||||
return Boolean(
|
||||
args.recentlyPlayedMaps?.some(
|
||||
(recent) => recent.stageId === map.stageId && recent.mode === map.mode,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const commonMap = resolveRandomCommonMap(
|
||||
args.teams,
|
||||
seededShuffle,
|
||||
isRecentlyPlayed,
|
||||
);
|
||||
if (commonMap) {
|
||||
return [{ ...commonMap, source: "BOTH" }];
|
||||
}
|
||||
|
||||
if (!args.tiebreakerMaps.isEmpty()) {
|
||||
const tiebreakers = seededShuffle(args.tiebreakerMaps.stageModePairs);
|
||||
const nonRecentTiebreaker = tiebreakers.find((tb) => !isRecentlyPlayed(tb));
|
||||
const randomTiebreaker = nonRecentTiebreaker ?? tiebreakers[0];
|
||||
|
||||
return [
|
||||
{
|
||||
mode: randomTiebreaker.mode,
|
||||
stageId: randomTiebreaker.stageId,
|
||||
source: "TIEBREAKER",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// should be only one mode here always but just in case
|
||||
// making it capable of handling many modes too
|
||||
const allAvailableMaps = seededShuffle(
|
||||
args.modesIncluded
|
||||
.sort((a, b) => modesShort.indexOf(a) - modesShort.indexOf(b))
|
||||
.flatMap((mode) => stageIds.map((stageId) => ({ mode, stageId }))),
|
||||
);
|
||||
|
||||
for (const map of allAvailableMaps) {
|
||||
if (
|
||||
!args.teams.some((team) =>
|
||||
team.maps.stageModePairs.some(
|
||||
(teamMap) =>
|
||||
teamMap.mode === map.mode && teamMap.stageId === map.stageId,
|
||||
),
|
||||
) &&
|
||||
!isRecentlyPlayed(map)
|
||||
) {
|
||||
return [{ ...map, source: "DEFAULT" }];
|
||||
}
|
||||
}
|
||||
|
||||
for (const map of allAvailableMaps) {
|
||||
if (
|
||||
!args.teams.some((team) =>
|
||||
team.maps.stageModePairs.some(
|
||||
(teamMap) =>
|
||||
teamMap.mode === map.mode && teamMap.stageId === map.stageId,
|
||||
),
|
||||
)
|
||||
) {
|
||||
return [{ ...map, source: "DEFAULT" }];
|
||||
}
|
||||
}
|
||||
|
||||
logger.warn("starterMap: fallback choice");
|
||||
|
||||
return [{ ...allAvailableMaps[0], source: "DEFAULT" }];
|
||||
}
|
||||
|
||||
function resolveRandomCommonMap(
|
||||
teams: StarterMapArgs["teams"],
|
||||
shuffle: <T>(o: T[]) => T[],
|
||||
isRecentlyPlayed: (map: ModeWithStage) => boolean,
|
||||
): ModeWithStage | null {
|
||||
const teamOnePicks = shuffle(teams[0].maps.stageModePairs);
|
||||
const teamTwoPicks = shuffle(teams[1].maps.stageModePairs);
|
||||
|
||||
for (const map of teamOnePicks) {
|
||||
for (const map2 of teamTwoPicks) {
|
||||
if (
|
||||
map.mode === map2.mode &&
|
||||
map.stageId === map2.stageId &&
|
||||
!isRecentlyPlayed(map)
|
||||
) {
|
||||
return map;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const map of teamOnePicks) {
|
||||
for (const map2 of teamTwoPicks) {
|
||||
if (map.mode === map2.mode && map.stageId === map2.stageId) {
|
||||
return map;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { MapPool } from "~/features/map-list-generator/core/map-pool";
|
||||
import type { ModeShort, ModeWithStage } from "../in-game-lists/types";
|
||||
import type { sourceTypes } from "./constants";
|
||||
|
||||
export interface TournamentMaplistInput {
|
||||
count: number;
|
||||
seed: string;
|
||||
teams: [
|
||||
{
|
||||
id: number;
|
||||
maps: MapPool;
|
||||
},
|
||||
{
|
||||
id: number;
|
||||
maps: MapPool;
|
||||
},
|
||||
];
|
||||
tiebreakerMaps: MapPool;
|
||||
modesIncluded: ModeShort[];
|
||||
followModeOrder?: boolean;
|
||||
recentlyPlayedMaps?: ModeWithStage[];
|
||||
}
|
||||
|
||||
export type TournamentMaplistSource = number | (typeof sourceTypes)[number];
|
||||
|
||||
/**
|
||||
* How {@link TournamentMaplistSource} is stored in the database: one of the source types, or the
|
||||
* picking team's id in its string form. Convert with `serializeMaplistSource`/`parseMaplistSource`.
|
||||
*/
|
||||
export type DBTournamentMaplistSource =
|
||||
| (typeof sourceTypes)[number]
|
||||
// the team id, kept plain `string` so it survives Kysely's JSON helpers rewriting `${number}` to `number`
|
||||
| (string & {});
|
||||
|
||||
export type TournamentMapListMap = ModeWithStage & {
|
||||
source: TournamentMaplistSource;
|
||||
bannedByTournamentTeamId?: number;
|
||||
};
|
||||
55
apps/web-react/app/modules/twitch/fetch.ts
Normal file
55
apps/web-react/app/modules/twitch/fetch.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { logger } from "~/utils/logger";
|
||||
import { getToken, purgeCachedToken } from "./token";
|
||||
import { getTwitchEnvVars } from "./utils.server";
|
||||
|
||||
const MAX_RATE_LIMIT_RETRIES = 5;
|
||||
const MAX_RATE_LIMIT_WAIT_MS = 60_000;
|
||||
|
||||
export async function twitchFetch(
|
||||
url: string,
|
||||
{ isRetry = false, rateLimitRetries = 0 } = {},
|
||||
): Promise<Response> {
|
||||
const { TWITCH_CLIENT_ID } = getTwitchEnvVars();
|
||||
const token = await getToken();
|
||||
|
||||
const res = await fetch(url, {
|
||||
headers: [
|
||||
["Authorization", `Bearer ${token}`],
|
||||
["Client-Id", TWITCH_CLIENT_ID],
|
||||
],
|
||||
});
|
||||
|
||||
if (res.status === 401 && !isRetry) {
|
||||
purgeCachedToken();
|
||||
return twitchFetch(url, { isRetry: true });
|
||||
}
|
||||
|
||||
if (res.status === 429) {
|
||||
if (rateLimitRetries >= MAX_RATE_LIMIT_RETRIES) {
|
||||
throw new Error(
|
||||
`Twitch API rate limited after ${MAX_RATE_LIMIT_RETRIES} retries`,
|
||||
);
|
||||
}
|
||||
|
||||
const resetHeader = res.headers.get("Ratelimit-Reset");
|
||||
const resetTimestamp = resetHeader ? Number(resetHeader) : 0;
|
||||
const waitMs = Math.max(resetTimestamp * 1000 - Date.now(), 1000);
|
||||
|
||||
if (waitMs > MAX_RATE_LIMIT_WAIT_MS) {
|
||||
throw new Error(
|
||||
`Twitch API rate limit reset too far in the future (${Math.ceil(waitMs / 1000)}s)`,
|
||||
);
|
||||
}
|
||||
|
||||
logger.warn(`Twitch rate limited, waiting ${Math.ceil(waitMs / 1000)}s`);
|
||||
await new Promise((resolve) => setTimeout(resolve, waitMs));
|
||||
|
||||
return twitchFetch(url, { rateLimitRetries: rateLimitRetries + 1 });
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Twitch API request failed with status: ${res.status}`);
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
1
apps/web-react/app/modules/twitch/index.ts
Normal file
1
apps/web-react/app/modules/twitch/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { getStreams } from "./streams";
|
||||
62
apps/web-react/app/modules/twitch/schemas.ts
Normal file
62
apps/web-react/app/modules/twitch/schemas.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { z } from "zod";
|
||||
import type { Unpacked } from "~/utils/types";
|
||||
|
||||
export const streamsSchema = z.object({
|
||||
data: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
user_id: z.string(),
|
||||
user_login: z.string(),
|
||||
user_name: z.string(),
|
||||
game_id: z.string(),
|
||||
game_name: z.string(),
|
||||
type: z.string(),
|
||||
title: z.string(),
|
||||
viewer_count: z.number(),
|
||||
started_at: z.string(),
|
||||
language: z.string(),
|
||||
thumbnail_url: z.string(),
|
||||
tag_ids: z.array(z.unknown()),
|
||||
tags: z.array(z.string()).nullish(),
|
||||
is_mature: z.boolean(),
|
||||
}),
|
||||
),
|
||||
pagination: z.object({ cursor: z.string().nullish() }),
|
||||
});
|
||||
|
||||
export const tokenResponseSchema = z.object({
|
||||
access_token: z.string(),
|
||||
expires_in: z.number(),
|
||||
token_type: z.string(),
|
||||
});
|
||||
|
||||
export const usersSchema = z.object({
|
||||
data: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
login: z.string(),
|
||||
display_name: z.string(),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
export const videosSchema = z.object({
|
||||
data: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
user_id: z.string(),
|
||||
user_login: z.string(),
|
||||
title: z.string(),
|
||||
created_at: z.string(),
|
||||
duration: z.string(),
|
||||
view_count: z.number(),
|
||||
type: z.string(),
|
||||
}),
|
||||
),
|
||||
pagination: z.object({ cursor: z.string().nullish() }),
|
||||
});
|
||||
|
||||
export type StreamsResponse = z.infer<typeof streamsSchema>;
|
||||
export type RawStream = Unpacked<z.infer<typeof streamsSchema>["data"]>;
|
||||
export type UsersResponse = z.infer<typeof usersSchema>;
|
||||
export type RawVideo = Unpacked<z.infer<typeof videosSchema>["data"]>;
|
||||
147
apps/web-react/app/modules/twitch/streams.ts
Normal file
147
apps/web-react/app/modules/twitch/streams.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { cachified } from "@epic-web/cachified";
|
||||
import { cache } from "~/utils/cache.server";
|
||||
import { IS_E2E_TEST_RUN } from "~/utils/e2e";
|
||||
import { logger } from "~/utils/logger";
|
||||
import { twitchFetch } from "./fetch";
|
||||
import { type RawStream, type StreamsResponse, streamsSchema } from "./schemas";
|
||||
|
||||
// const STREAMS_MOCK = [
|
||||
// {
|
||||
// thumbnailUrl:
|
||||
// "https://static-cdn.jtvnw.net/previews-ttv/live_user_777_jared-{width}x{height}.jpg",
|
||||
// twitchUserName: "777_jared",
|
||||
// viewerCount: 129,
|
||||
// },
|
||||
// {
|
||||
// thumbnailUrl:
|
||||
// "https://static-cdn.jtvnw.net/previews-ttv/live_user_ano_rta-{width}x{height}.jpg",
|
||||
// twitchUserName: "ano_rta",
|
||||
// viewerCount: 50,
|
||||
// },
|
||||
// {
|
||||
// thumbnailUrl:
|
||||
// "https://static-cdn.jtvnw.net/previews-ttv/live_user_mikashita0104-{width}x{height}.jpg",
|
||||
// twitchUserName: "mikashita0104",
|
||||
// viewerCount: 49,
|
||||
// },
|
||||
// {
|
||||
// thumbnailUrl:
|
||||
// "https://static-cdn.jtvnw.net/previews-ttv/live_user_veenam-{width}x{height}.jpg",
|
||||
// twitchUserName: "veenam",
|
||||
// viewerCount: 42,
|
||||
// },
|
||||
// {
|
||||
// thumbnailUrl:
|
||||
// "https://static-cdn.jtvnw.net/previews-ttv/live_user_minijutopia-{width}x{height}.jpg",
|
||||
// twitchUserName: "minijutopia",
|
||||
// viewerCount: 25,
|
||||
// },
|
||||
// {
|
||||
// thumbnailUrl:
|
||||
// "https://static-cdn.jtvnw.net/previews-ttv/live_user_liligravybread-{width}x{height}.jpg",
|
||||
// twitchUserName: "liligravybread",
|
||||
// viewerCount: 25,
|
||||
// },
|
||||
// {
|
||||
// thumbnailUrl:
|
||||
// "https://static-cdn.jtvnw.net/previews-ttv/live_user_ajx_live-{width}x{height}.jpg",
|
||||
// twitchUserName: "ajx_live",
|
||||
// viewerCount: 19,
|
||||
// },
|
||||
// {
|
||||
// thumbnailUrl:
|
||||
// "https://static-cdn.jtvnw.net/previews-ttv/live_user_yuzuki729-{width}x{height}.jpg",
|
||||
// twitchUserName: "yuzuki729",
|
||||
// viewerCount: 18,
|
||||
// },
|
||||
// {
|
||||
// thumbnailUrl:
|
||||
// "https://static-cdn.jtvnw.net/previews-ttv/live_user_noname_nosplat-{width}x{height}.jpg",
|
||||
// twitchUserName: "noname_nosplat",
|
||||
// viewerCount: 14,
|
||||
// },
|
||||
// {
|
||||
// thumbnailUrl:
|
||||
// "https://static-cdn.jtvnw.net/previews-ttv/live_user_dosankoneet-{width}x{height}.jpg",
|
||||
// twitchUserName: "dosankoneet",
|
||||
// viewerCount: 13,
|
||||
// },
|
||||
// ];
|
||||
|
||||
export async function getStreams() {
|
||||
try {
|
||||
const result = await cachified({
|
||||
key: "twitch-streams",
|
||||
cache,
|
||||
// 2 minutes
|
||||
ttl: 1000 * 60 * 2,
|
||||
// 10 minutes
|
||||
staleWhileRevalidate: 1000 * 60 * 5 * 2,
|
||||
async getFreshValue() {
|
||||
return (await getAllStreams())
|
||||
.map(mapRawStream)
|
||||
.sort((a, b) => b.viewerCount - a.viewerCount);
|
||||
},
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (e) {
|
||||
logger.error(e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function mapRawStream(stream: RawStream) {
|
||||
return {
|
||||
thumbnailUrl: stream.thumbnail_url,
|
||||
twitchUserName: stream.user_login.toLowerCase(),
|
||||
viewerCount: stream.viewer_count,
|
||||
};
|
||||
}
|
||||
|
||||
const SPLATOON_3_TWITCH_GAME_ID = "1158884259";
|
||||
async function getAllStreams() {
|
||||
if (process.env.NODE_ENV === "test" || IS_E2E_TEST_RUN) return [];
|
||||
|
||||
const result: RawStream[] = [];
|
||||
|
||||
let cursor: string | undefined;
|
||||
let count = 0;
|
||||
while (true) {
|
||||
if (count === 50) {
|
||||
throw new Error("Stuck getting streams");
|
||||
}
|
||||
const { data, pagination } = await getStreamsChunk(cursor);
|
||||
|
||||
result.push(
|
||||
// filter to ensure each streamer appears only once
|
||||
...data.filter(
|
||||
(stream) =>
|
||||
!result.some(
|
||||
(existingStream) => existingStream.user_id === stream.user_id,
|
||||
),
|
||||
),
|
||||
);
|
||||
if (!pagination.cursor) {
|
||||
return result;
|
||||
}
|
||||
|
||||
cursor = pagination.cursor;
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
async function getStreamsChunk(cursor?: string): Promise<StreamsResponse> {
|
||||
const res = await twitchFetch(
|
||||
`https://api.twitch.tv/helix/streams?game_id=${SPLATOON_3_TWITCH_GAME_ID}&first=100&after=${
|
||||
cursor ?? ""
|
||||
}`,
|
||||
);
|
||||
|
||||
const parsed = streamsSchema.safeParse(await res.json());
|
||||
if (!parsed.success) {
|
||||
throw new Error(parsed.error.message);
|
||||
}
|
||||
|
||||
return parsed.data;
|
||||
}
|
||||
39
apps/web-react/app/modules/twitch/token.ts
Normal file
39
apps/web-react/app/modules/twitch/token.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { cachified } from "@epic-web/cachified";
|
||||
import { cache } from "~/utils/cache.server";
|
||||
import { tokenResponseSchema } from "./schemas";
|
||||
import { getTwitchEnvVars } from "./utils.server";
|
||||
|
||||
async function getFreshToken() {
|
||||
const { TWITCH_CLIENT_ID, TWITCH_CLIENT_SECRET } = getTwitchEnvVars();
|
||||
|
||||
const res = await fetch(
|
||||
`https://id.twitch.tv/oauth2/token?client_id=${TWITCH_CLIENT_ID}&client_secret=${TWITCH_CLIENT_SECRET}&grant_type=client_credentials`,
|
||||
{ method: "POST" },
|
||||
);
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
`Getting Twitch token failed with status code: ${res.status}`,
|
||||
);
|
||||
}
|
||||
|
||||
const parsed = tokenResponseSchema.safeParse(await res.json());
|
||||
if (!parsed.success) {
|
||||
throw new Error("Token response schema validation failed");
|
||||
}
|
||||
|
||||
return parsed.data.access_token;
|
||||
}
|
||||
|
||||
export function getToken() {
|
||||
return cachified({
|
||||
key: "twitch-token",
|
||||
cache,
|
||||
getFreshValue() {
|
||||
return getFreshToken();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function purgeCachedToken() {
|
||||
cache.delete("twitch-token");
|
||||
}
|
||||
16
apps/web-react/app/modules/twitch/utils.server.ts
Normal file
16
apps/web-react/app/modules/twitch/utils.server.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { ServerConfig } from "~/config.server";
|
||||
import invariant from "~/utils/invariant";
|
||||
|
||||
export const hasTwitchEnvVars = () =>
|
||||
Boolean(ServerConfig.twitch.clientId && ServerConfig.twitch.clientSecret);
|
||||
|
||||
export const getTwitchEnvVars = () => {
|
||||
const { clientId, clientSecret } = ServerConfig.twitch;
|
||||
invariant(clientId, "Missing TWITCH_CLIENT_ID env var, showing no streams");
|
||||
invariant(
|
||||
clientSecret,
|
||||
"Missing TWITCH_CLIENT_SECRET env var, showing no streams",
|
||||
);
|
||||
|
||||
return { TWITCH_CLIENT_ID: clientId, TWITCH_CLIENT_SECRET: clientSecret };
|
||||
};
|
||||
2
apps/web-react/app/modules/twitch/utils.ts
Normal file
2
apps/web-react/app/modules/twitch/utils.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export const twitchThumbnailUrlToSrc = (url: string) =>
|
||||
url.replace("{width}", "640").replace("{height}", "360");
|
||||
40
apps/web-react/app/modules/twitch/vods.test.ts
Normal file
40
apps/web-react/app/modules/twitch/vods.test.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { parseTwitchDuration } from "./vods";
|
||||
|
||||
describe("parseTwitchDuration()", () => {
|
||||
test("parses hours, minutes and seconds", () => {
|
||||
expect(parseTwitchDuration("1h2m3s")).toBe(3723);
|
||||
});
|
||||
|
||||
test("parses hours only", () => {
|
||||
expect(parseTwitchDuration("2h")).toBe(7200);
|
||||
});
|
||||
|
||||
test("parses minutes only", () => {
|
||||
expect(parseTwitchDuration("45m")).toBe(2700);
|
||||
});
|
||||
|
||||
test("parses seconds only", () => {
|
||||
expect(parseTwitchDuration("30s")).toBe(30);
|
||||
});
|
||||
|
||||
test("parses hours and minutes without seconds", () => {
|
||||
expect(parseTwitchDuration("1h30m")).toBe(5400);
|
||||
});
|
||||
|
||||
test("parses hours and seconds without minutes", () => {
|
||||
expect(parseTwitchDuration("2h15s")).toBe(7215);
|
||||
});
|
||||
|
||||
test("parses minutes and seconds without hours", () => {
|
||||
expect(parseTwitchDuration("5m10s")).toBe(310);
|
||||
});
|
||||
|
||||
test("returns 0 for empty string", () => {
|
||||
expect(parseTwitchDuration("")).toBe(0);
|
||||
});
|
||||
|
||||
test("parses large values", () => {
|
||||
expect(parseTwitchDuration("99h59m59s")).toBe(359999);
|
||||
});
|
||||
});
|
||||
79
apps/web-react/app/modules/twitch/vods.ts
Normal file
79
apps/web-react/app/modules/twitch/vods.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import * as R from "remeda";
|
||||
import { twitchFetch } from "./fetch";
|
||||
import {
|
||||
type RawVideo,
|
||||
type UsersResponse,
|
||||
usersSchema,
|
||||
videosSchema,
|
||||
} from "./schemas";
|
||||
|
||||
export async function getUsersByLogin(
|
||||
logins: string[],
|
||||
): Promise<UsersResponse["data"]> {
|
||||
if (logins.length === 0) return [];
|
||||
|
||||
const results: UsersResponse["data"] = [];
|
||||
|
||||
for (const batch of R.chunk(logins, 100)) {
|
||||
const params = batch.map((l) => `login=${encodeURIComponent(l)}`).join("&");
|
||||
|
||||
const res = await twitchFetch(
|
||||
`https://api.twitch.tv/helix/users?${params}`,
|
||||
);
|
||||
|
||||
const parsed = usersSchema.safeParse(await res.json());
|
||||
if (!parsed.success) {
|
||||
throw new Error(
|
||||
`Twitch users schema validation failed: ${parsed.error.message}`,
|
||||
);
|
||||
}
|
||||
|
||||
results.push(...parsed.data.data);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
export async function getArchiveVideos(userId: string): Promise<RawVideo[]> {
|
||||
const results: RawVideo[] = [];
|
||||
let cursor: string | undefined;
|
||||
|
||||
while (true) {
|
||||
const url = new URL("https://api.twitch.tv/helix/videos");
|
||||
url.searchParams.set("user_id", userId);
|
||||
url.searchParams.set("type", "archive");
|
||||
url.searchParams.set("first", "100");
|
||||
if (cursor) {
|
||||
url.searchParams.set("after", cursor);
|
||||
}
|
||||
|
||||
const res = await twitchFetch(url.toString());
|
||||
|
||||
const parsed = videosSchema.safeParse(await res.json());
|
||||
if (!parsed.success) {
|
||||
throw new Error(
|
||||
`Twitch videos schema validation failed: ${parsed.error.message}`,
|
||||
);
|
||||
}
|
||||
|
||||
results.push(...parsed.data.data);
|
||||
|
||||
if (!parsed.data.pagination.cursor) break;
|
||||
cursor = parsed.data.pagination.cursor;
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
const DURATION_REGEX = /(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?/;
|
||||
|
||||
export function parseTwitchDuration(duration: string): number {
|
||||
const match = DURATION_REGEX.exec(duration);
|
||||
if (!match) return 0;
|
||||
|
||||
const hours = Number(match[1] ?? 0);
|
||||
const minutes = Number(match[2] ?? 0);
|
||||
const seconds = Number(match[3] ?? 0);
|
||||
|
||||
return hours * 3600 + minutes * 60 + seconds;
|
||||
}
|
||||
Reference in New Issue
Block a user