Move app to apps/web-react in pnpm workspace layout

This commit is contained in:
Kalle
2026-08-16 09:20:33 +03:00
parent 7e365ccfcf
commit bbc8ea57af
2913 changed files with 227 additions and 250 deletions

View File

@@ -0,0 +1,58 @@
import {
type InsertQueryNode,
type KyselyPlugin,
type PluginTransformQueryArgs,
type PluginTransformResultArgs,
type QueryResult,
RawNode,
type RootOperationNode,
SelectionNode,
SelectQueryNode,
type UnknownRow,
ValuesNode,
WhereNode,
} from "kysely";
/**
* Makes inserting an empty array of values a no-op instead of a syntax error.
* Kysely compiles `.values([])` into invalid SQL, so without this plugin every
* dynamic multi-row insert would need a length check before it. The empty
* insert is rewritten into `INSERT INTO "T" SELECT * FROM "T" WHERE 0` which
* inserts zero rows and returns zero rows for any `returning` clause.
*/
export class EmptyValuesNoopPlugin implements KyselyPlugin {
transformQuery(args: PluginTransformQueryArgs): RootOperationNode {
if (args.node.kind !== "InsertQueryNode" || !isEmptyInsert(args.node)) {
return args.node;
}
const { columns: _columns, ...node } = args.node;
return Object.freeze({
...node,
values: selectNothingFrom(args.node),
});
}
async transformResult(
args: PluginTransformResultArgs,
): Promise<QueryResult<UnknownRow>> {
return args.result;
}
}
function isEmptyInsert(node: InsertQueryNode) {
return (
node.values !== undefined &&
ValuesNode.is(node.values) &&
node.values.values.length === 0
);
}
function selectNothingFrom(node: InsertQueryNode): SelectQueryNode {
return Object.freeze({
...SelectQueryNode.createFrom([node.into!]),
selections: Object.freeze([SelectionNode.createSelectAll()]),
where: WhereNode.create(RawNode.createWithSql("0")),
});
}

View File

@@ -0,0 +1,42 @@
import { readFileSync } from "node:fs";
import { describe, expect, test } from "vitest";
import { JSON_COLUMNS } from "./json-columns";
describe("JSON_COLUMNS", () => {
test("matches the JSONColumnType declarations in tables.ts", () => {
expect([...JSON_COLUMNS].sort()).toEqual(jsonColumnsFromTablesSource());
});
});
function jsonColumnsFromTablesSource() {
const source = readFileSync(new URL("./tables.ts", import.meta.url), "utf8");
const jsonColumnsByInterface = new Map<string, string[]>();
const interfaceRegex = /(?:export )?interface (\w+) \{([\s\S]*?)\n\}/g;
for (const match of source.matchAll(interfaceRegex)) {
const [, interfaceName, body] = match;
if (interfaceName === "DB") continue;
const columns = [];
for (const line of body.split("\n")) {
const columnMatch = line.match(
/^\s*(\w+)\??:\s*.*JSONColumnType(?:Nullable)?</,
);
if (columnMatch) columns.push(columnMatch[1]);
}
if (columns.length > 0) {
jsonColumnsByInterface.set(interfaceName, columns);
}
}
const dbInterfaceBody = source.slice(source.indexOf("export interface DB {"));
const entries = [];
for (const match of dbInterfaceBody.matchAll(/^\t(\w+): (\w+);/gm)) {
const [, tableName, interfaceName] = match;
for (const column of jsonColumnsByInterface.get(interfaceName) ?? []) {
entries.push(`${tableName}.${column}`);
}
}
return entries.sort();
}

View File

@@ -0,0 +1,51 @@
/**
* Every "Table.column" whose text content is a JSON document. The node-sqlite
* dialect parses only these columns (plus computed expression columns) when
* reading rows; all other text columns stay plain strings even if a user typed
* something JSON-shaped into them. Kept in sync with the JSONColumnType
* declarations of tables.ts by json-columns.test.ts.
*/
export const JSON_COLUMNS: ReadonlySet<string> = new Set([
"AllTeam.customTheme",
"AllTeam.mapModePreferences",
"Build.abilities",
"Build.modes",
"CalendarEvent.tags",
"GroupMatch.memento",
"IngestedMatch.data",
"LFGPost.languages",
"Notification.meta",
"NotificationUserSubscription.subscription",
"ScrimPost.visibility",
"SplatoonPlayer.peakXp",
"Team.customTheme",
"Team.mapModePreferences",
"Tournament.castTwitchAccounts",
"Tournament.castedMatchesInfo",
"Tournament.preparedMaps",
"Tournament.seedingSnapshot",
"Tournament.settings",
"TournamentAuditLog.metadata",
"TournamentMatch.opponentOne",
"TournamentMatch.opponentTwo",
"TournamentOrganization.socials",
"TournamentOrganizationSeries.substringMatches",
"TournamentOrganizationSeries.tierHistory",
"TournamentResult.setResults",
"TournamentRound.maps",
"TournamentStage.settings",
"TournamentTeam.activeRosterUserIds",
"User.buildSorting",
"User.customTheme",
"User.favoriteBadgeIds",
"User.favoriteTrophyIds",
"User.hiddenCardStats",
"User.hiddenTrophyIds",
"User.languages",
"User.mapModePreferences",
"User.preferences",
"User.pronouns",
"User.unverifiedPeakXP",
"User.weaponPool",
"UserWidget.widget",
]);

View File

@@ -0,0 +1,131 @@
import { sql } from "kysely";
import { describe, expect, test } from "vitest";
import * as TournamentFactory from "~/db/seed/factories/TournamentFactory";
import * as UserFactory from "~/db/seed/factories/UserFactory";
import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server";
import { commonUserSelect, jsonArrayFrom } from "~/utils/kysely.server";
import { withUserId } from "~/utils/Test";
import { computedJsonColumns } from "./json-selections";
import { db } from "./sql";
import type { Tables } from "./tables";
const JSON_SHAPED_TEXT = '{"note":"gg"}';
describe("computedJsonColumns", () => {
test("recognizes a json helper selection but not a coalesce over user text", () => {
const query = db
.selectFrom("User")
.select((eb) => [
...commonUserSelect(eb, { inTournament: true }),
jsonArrayFrom(
eb
.selectFrom("UserWeapon")
.select("UserWeapon.weaponSplId")
.whereRef("UserWeapon.userId", "=", "User.id"),
).as("weapons"),
]);
// `username` is `coalesce("User"."tournamentName", "User"."username")`, which
// SQLite reports the same way as the weapons subquery: as a computed column
expect(computedJsonColumns(query.compile().query)).toEqual(
new Set(["weapons"]),
);
});
test("recognizes a json column contributed by another branch of a compound select", () => {
const query = db
.selectFrom("CalendarEventResultTeam")
.select([
sql<Tables["TournamentResult"]["setResults"]>`null`.as("setResults"),
])
.unionAll(
db.selectFrom("TournamentResult").select("TournamentResult.setResults"),
);
expect(computedJsonColumns(query.compile().query)).toEqual(
new Set(["setResults"]),
);
});
test("recognizes a json selection passed through a derived table", () => {
const query = db
.selectFrom((eb) =>
eb
.selectFrom("Build")
.select((innerEb) => [
"Build.id",
jsonArrayFrom(
innerEb
.selectFrom("BuildWeapon")
.select("BuildWeapon.weaponSplId")
.whereRef("BuildWeapon.buildId", "=", "Build.id"),
).as("weapons"),
])
.as("Inner"),
)
.select(["Inner.id", "Inner.weapons"]);
expect(computedJsonColumns(query.compile().query)).toEqual(
new Set(["weapons"]),
);
});
});
describe("reading rows", () => {
test("keeps a JSON-object-shaped in-tournament name as text", async () => {
const [organizer, member] = await UserFactory.createMany(2);
const tournament = await TournamentFactory.create({
authorId: organizer.id,
});
await withUserId(organizer.id, () =>
TournamentTeamRepository.upsertRegistration({
tournamentId: tournament.id,
name: "Team Olive",
teamId: null,
avatarImgId: null,
ownerUserId: member.id,
ownerChange: null,
membersToAdd: [member.id],
membersToRemove: [],
inGameNameUpdates: [],
tournamentNameUpdates: [
{ userId: member.id, tournamentName: JSON_SHAPED_TEXT },
],
}),
);
const row = await db
.selectFrom("User")
.select((eb) => commonUserSelect(eb, { inTournament: true }))
.where("User.id", "=", member.id)
.executeTakeFirstOrThrow();
// rendered as a bare JSX child on public tournament pages, so an object here
// is "Objects are not valid as a React child" for everyone viewing them
expect(row.username).toBe(JSON_SHAPED_TEXT);
});
test("parses json columns and json helper selections", async () => {
const user = await UserFactory.create(undefined, {
matchProfile: { languages: ["en", "ja"] },
});
const row = await db
.selectFrom("User")
.select((eb) => [
"User.languages",
jsonArrayFrom(
eb
.selectFrom("User as Self")
.select("Self.id")
.whereRef("Self.id", "=", "User.id"),
).as("self"),
])
.where("User.id", "=", user.id)
.executeTakeFirstOrThrow();
expect(row.languages).toEqual(["en", "ja"]);
expect(row.self).toEqual([{ id: user.id }]);
});
});

View File

@@ -0,0 +1,153 @@
import {
AggregateFunctionNode,
AliasNode,
ColumnNode,
FunctionNode,
IdentifierNode,
type OperationNode,
RawNode,
ReferenceNode,
type RootOperationNode,
SelectQueryNode,
} from "kysely";
import { JSON_COLUMNS } from "./json-columns";
/** SQL shapes {@link jsonValuedNode} recognizes: the subqueries the json helpers emit and direct `json*(` function calls (e.g. `jsonBuildObject`, `json_set`). */
const JSON_EXPRESSION_PREFIX =
/^\s*(\(select\s+(coalesce\()?)?json(_\w+)?\s*\(/i;
const NO_NAMES: ReadonlySet<string> = new Set();
/** Output names of the query's computed result columns whose value is a JSON document, e.g. the subquery a `jsonArrayFrom` selection compiles to. */
export function computedJsonColumns(
query: RootOperationNode,
): ReadonlySet<string> {
// only select queries have computed result columns: `returning` selections keep
// the origin metadata of the column they write to
return SelectQueryNode.is(query) ? outputNames(query) : NO_NAMES;
}
/** Output name a selection comes back under, or `undefined` for selections that have none (`selectAll()`). */
export function selectionOutputName(
selection: OperationNode,
): string | undefined {
if (ReferenceNode.is(selection) && ColumnNode.is(selection.column)) {
return selection.column.column.name;
}
if (ColumnNode.is(selection)) {
return selection.column.name;
}
if (AliasNode.is(selection) && IdentifierNode.is(selection.alias)) {
return selection.alias.name;
}
return undefined;
}
/** Whether a select list entry resolves to a JSON document. */
export function jsonValuedSelection(
selection: OperationNode,
sources?: SourceOutputNames,
): boolean {
if (AliasNode.is(selection)) return jsonValuedNode(selection.node, sources);
return jsonValuedReference(selection, sources);
}
/** Whether an expression resolves to a JSON document. */
export function jsonValuedNode(
node: OperationNode,
sources?: SourceOutputNames,
): boolean {
if (RawNode.is(node)) {
return JSON_EXPRESSION_PREFIX.test(node.sqlFragments[0] ?? "");
}
if (AggregateFunctionNode.is(node) || FunctionNode.is(node)) {
return node.func.startsWith("json");
}
if (SelectQueryNode.is(node)) {
const selections = node.selections ?? [];
return (
selections.length === 1 &&
jsonValuedSelection(selections[0].selection, sourceOutputNames(node))
);
}
return jsonValuedReference(node, sources);
}
/** JSON-valued output names of the derived tables and CTEs a query selects from, by the name they are visible under. */
type SourceOutputNames = ReadonlyMap<string, ReadonlySet<string>>;
function outputNames(select: SelectQueryNode): ReadonlySet<string> {
const sources = sourceOutputNames(select);
const names = new Set<string>();
for (const { selection } of select.selections ?? []) {
const name = selectionOutputName(selection);
if (name && jsonValuedSelection(selection, sources)) {
names.add(name);
}
}
// a compound select is named after its first branch, but any branch can be the
// one contributing the JSON document
for (const { expression } of select.setOperations ?? []) {
if (!SelectQueryNode.is(expression)) continue;
for (const name of outputNames(expression)) {
names.add(name);
}
}
return names;
}
function sourceOutputNames(select: SelectQueryNode): SourceOutputNames {
const sources = new Map<string, ReadonlySet<string>>();
for (const cte of select.with?.expressions ?? []) {
if (!SelectQueryNode.is(cte.expression)) continue;
sources.set(
cte.name.table.table.identifier.name,
outputNames(cte.expression),
);
}
const tables = [
...(select.from?.froms ?? []),
...(select.joins ?? []).map((join) => join.table),
];
for (const table of tables) {
if (
!AliasNode.is(table) ||
!SelectQueryNode.is(table.node) ||
!IdentifierNode.is(table.alias)
) {
continue;
}
sources.set(table.alias.name, outputNames(table.node));
}
return sources;
}
function jsonValuedReference(node: OperationNode, sources?: SourceOutputNames) {
if (
!ReferenceNode.is(node) ||
!ColumnNode.is(node.column) ||
node.table === undefined
) {
return false;
}
const table = node.table.table.identifier.name;
const column = node.column.column.name;
return (
JSON_COLUMNS.has(`${table}.${column}`) ||
Boolean(sources?.get(table)?.has(column))
);
}

View File

@@ -0,0 +1,456 @@
import type {
DatabaseSync,
SQLInputValue,
SQLOutputValue,
StatementSync,
} from "node:sqlite";
import {
CompiledQuery,
createQueryId,
type DatabaseConnection,
type Dialect,
type Driver,
IdentifierNode,
type Kysely,
type QueryCompiler,
type QueryResult,
RawNode,
type RootOperationNode,
SelectQueryNode,
SqliteAdapter,
SqliteIntrospector,
SqliteQueryCompiler,
} from "kysely";
/**
* Query kinds whose compiled SQL is stable enough to keep a prepared statement
* around for. Everything else (DDL, raw SQL, `begin`/`commit`) is prepared fresh.
*/
const CACHEABLE_QUERY_KINDS = new Set([
"SelectQueryNode",
"InsertQueryNode",
"UpdateQueryNode",
"DeleteQueryNode",
"MergeQueryNode",
]);
/**
* Leading keywords of raw statements that can not change the schema, so the
* column lists the statement cache is holding stay valid across them. Raw DDL
* (`create`, `alter`, `drop`, ...) is not here and clears the cache.
*/
const SCHEMA_PRESERVING_RAW_COMMANDS = new Set([
"begin",
"commit",
"rollback",
"savepoint",
"release",
"select",
"with",
"insert",
"update",
"delete",
"replace",
"pragma",
"analyze",
"explain",
]);
const STATEMENT_CACHE_SIZE = 5000;
const NO_JSON_OUTPUT_NAMES: ReadonlySet<string> = new Set();
export interface NodeSqliteDialectConfig {
database: DatabaseSync;
/**
* Keeps prepared statements around between queries, keyed by their SQL. Saves
* a re-compile per query at the cost of holding onto the compiled programs.
* Off by default because it assumes the schema does not change under the
* connection, which is not true while migrations run.
*/
cacheStatements?: boolean;
/**
* "Table.column" names whose text content is a JSON document. When given,
* result values of these columns are parsed into objects. Other text columns
* are always returned verbatim, so JSON-shaped user input stays a string.
* Origin metadata from `statement.columns()` sees through aliases, views,
* subqueries and CTEs, so the names here are the underlying table names
* (e.g. `AllTeam`, not the `Team` view).
*/
jsonColumns?: ReadonlySet<string>;
/**
* Output names of a query's computed result columns whose value is a JSON
* document, which is what `jsonArrayFrom`/`jsonObjectFrom` subqueries compile
* to. SQLite reports no origin for a computed expression, so only the query's
* own AST tells those apart from an ordinary `coalesce(...)` over user text.
* Called once per prepared statement. Requires {@link jsonColumns}.
*/
computedJsonColumns?: (query: RootOperationNode) => ReadonlySet<string>;
}
/**
* Kysely dialect backed by Node's built-in `node:sqlite` module, replacing the
* `better-sqlite3` native addon that Kysely's own `SqliteDialect` expects.
*
* Rows come back from `node:sqlite` as arrays rather than objects: the objects it
* builds itself are both slower to produce and have a `null` prototype, which is
* not what the rest of the codebase (or Kysely's own dialects) hand out.
*/
export class NodeSqliteDialect implements Dialect {
readonly #config: NodeSqliteDialectConfig;
constructor(config: NodeSqliteDialectConfig) {
this.#config = config;
}
createDriver(): Driver {
return new NodeSqliteDriver(this.#config);
}
createQueryCompiler(): QueryCompiler {
return new SqliteQueryCompiler();
}
createAdapter(): SqliteAdapter {
return new SqliteAdapter();
}
createIntrospector(db: Kysely<any>): SqliteIntrospector {
return new SqliteIntrospector(db);
}
}
class NodeSqliteDriver implements Driver {
readonly #config: NodeSqliteDialectConfig;
#connection?: NodeSqliteConnection;
constructor(config: NodeSqliteDialectConfig) {
this.#config = config;
}
async init(): Promise<void> {
this.#connection = new NodeSqliteConnection(this.#config);
}
async acquireConnection(): Promise<DatabaseConnection> {
return this.#connection!;
}
async beginTransaction(connection: DatabaseConnection): Promise<void> {
await connection.executeQuery(CompiledQuery.raw("begin"));
}
async commitTransaction(connection: DatabaseConnection): Promise<void> {
await connection.executeQuery(CompiledQuery.raw("commit"));
}
async rollbackTransaction(connection: DatabaseConnection): Promise<void> {
await connection.executeQuery(CompiledQuery.raw("rollback"));
}
async savepoint(
connection: DatabaseConnection,
savepointName: string,
compileQuery: QueryCompiler["compileQuery"],
): Promise<void> {
await connection.executeQuery(
compileQuery(
savepointCommand("savepoint", savepointName),
createQueryId(),
),
);
}
async rollbackToSavepoint(
connection: DatabaseConnection,
savepointName: string,
compileQuery: QueryCompiler["compileQuery"],
): Promise<void> {
await connection.executeQuery(
compileQuery(
savepointCommand("rollback to", savepointName),
createQueryId(),
),
);
}
async releaseSavepoint(
connection: DatabaseConnection,
savepointName: string,
compileQuery: QueryCompiler["compileQuery"],
): Promise<void> {
await connection.executeQuery(
compileQuery(savepointCommand("release", savepointName), createQueryId()),
);
}
async releaseConnection(): Promise<void> {
// the single connection is never handed back to a pool
}
async destroy(): Promise<void> {
this.#connection?.dispose();
this.#config.database.close();
}
}
/** Which result columns of one query hold a JSON document: by column origin, and by output name for the columns that have no origin. */
interface JsonColumns {
byOrigin: ReadonlySet<string>;
byOutputName: ReadonlySet<string>;
}
interface PreparedStatement {
statement: StatementSync;
/** Empty for statements that return no rows, which is how writes are detected. */
columnNames: string[];
/** Per result column: parse text values as JSON when building rows. */
jsonColumnFlags: boolean[];
/** Kept for re-deriving the flags when the column list turns out to be stale. */
jsonColumns?: JsonColumns;
}
class NodeSqliteConnection implements DatabaseConnection {
readonly #database: DatabaseSync;
readonly #cacheStatements: boolean;
readonly #jsonColumns?: ReadonlySet<string>;
readonly #computedJsonColumns?: (
query: RootOperationNode,
) => ReadonlySet<string>;
readonly #cache = new Map<string, PreparedStatement>();
constructor(config: NodeSqliteDialectConfig) {
this.#database = config.database;
this.#cacheStatements = config.cacheStatements ?? false;
this.#jsonColumns = config.jsonColumns;
this.#computedJsonColumns = config.computedJsonColumns;
}
async executeQuery<R>(compiledQuery: CompiledQuery): Promise<QueryResult<R>> {
const prepared = this.#preparedStatementFor(compiledQuery);
const parameters = compiledQuery.parameters as SQLInputValue[];
if (prepared.columnNames.length > 0) {
return { rows: readRows<R>(prepared, parameters) };
}
const { changes, lastInsertRowid } = prepared.statement.run(...parameters);
return {
insertId: BigInt(lastInsertRowid),
numAffectedRows: BigInt(changes),
rows: [],
};
}
async *streamQuery<R>(
compiledQuery: CompiledQuery,
): AsyncIterableIterator<QueryResult<R>> {
if (!SelectQueryNode.is(compiledQuery.query)) {
throw new Error(
"Sqlite driver only supports streaming of select queries",
);
}
// deliberately uncached: the cursor stays open across yields, so sharing the
// statement with another query would reset it mid-iteration
const prepared = prepare(
this.#database,
compiledQuery.sql,
this.#jsonColumnsFor(compiledQuery.query),
);
const parameters = compiledQuery.parameters as SQLInputValue[];
for (const row of prepared.statement.iterate(...parameters)) {
yield {
rows: [toRow<R>(prepared, row as unknown as SQLOutputValue[])],
};
}
}
dispose() {
this.#cache.clear();
}
#preparedStatementFor(compiledQuery: CompiledQuery): PreparedStatement {
const { sql, query } = compiledQuery;
if (!this.#cacheStatements || !CACHEABLE_QUERY_KINDS.has(query.kind)) {
// a schema change invalidates every column list the cache is holding
if (query.kind !== "RawNode" || canChangeSchema(sql)) {
this.#cache.clear();
}
return prepare(this.#database, sql, this.#jsonColumnsFor(query));
}
const cached = this.#cache.get(sql);
if (cached) {
// re-insert so the least recently used entry stays at the front
this.#cache.delete(sql);
this.#cache.set(sql, cached);
return cached;
}
const prepared = prepare(this.#database, sql, this.#jsonColumnsFor(query));
if (this.#cache.size >= STATEMENT_CACHE_SIZE) {
this.#cache.delete(this.#cache.keys().next().value!);
}
this.#cache.set(sql, prepared);
return prepared;
}
#jsonColumnsFor(query: RootOperationNode): JsonColumns | undefined {
if (!this.#jsonColumns) return undefined;
return {
byOrigin: this.#jsonColumns,
byOutputName: this.#computedJsonColumns?.(query) ?? NO_JSON_OUTPUT_NAMES,
};
}
}
function canChangeSchema(sql: string) {
const firstKeyword = sql
.trimStart()
.split(/[\s;(]/, 1)[0]
.toLowerCase();
return !SCHEMA_PRESERVING_RAW_COMMANDS.has(firstKeyword);
}
function prepare(
database: DatabaseSync,
sql: string,
jsonColumns: JsonColumns | undefined,
): PreparedStatement {
const statement = database.prepare(sql);
statement.setReturnArrays(true);
return { statement, jsonColumns, ...columnMetadata(statement, jsonColumns) };
}
function columnMetadata(
statement: StatementSync,
jsonColumns: JsonColumns | undefined,
) {
const columns = statement.columns();
return {
columnNames: columns.map((it) => it.name),
jsonColumnFlags: columns.map((it) => {
if (!jsonColumns) return false;
// a null origin is a computed expression (a jsonArrayFrom subquery, but also
// e.g. a coalesce over user text), which only the query itself can classify
if (it.column === null) return jsonColumns.byOutputName.has(it.name);
return jsonColumns.byOrigin.has(`${it.table}.${it.column}`);
}),
};
}
function readRows<R>(
prepared: PreparedStatement,
parameters: SQLInputValue[],
): R[] {
const rawRows = prepared.statement.all(
...parameters,
) as unknown as SQLOutputValue[][];
if (rawRows.length === 0) return [];
// `select *` widens when a migration adds a column, leaving a cached statement
// with a stale column list until the next read notices the mismatch
if (rawRows[0].length !== prepared.columnNames.length) {
Object.assign(
prepared,
columnMetadata(prepared.statement, prepared.jsonColumns),
);
}
const rows = new Array<R>(rawRows.length);
for (let i = 0; i < rawRows.length; i++) {
rows[i] = toRow<R>(prepared, rawRows[i]);
}
return rows;
}
function toRow<R>(prepared: PreparedStatement, rawRow: SQLOutputValue[]): R {
const { columnNames, jsonColumnFlags } = prepared;
const row: Record<string, unknown> = {};
for (let i = 0; i < columnNames.length; i++) {
const value = rawRow[i];
row[columnNames[i]] =
jsonColumnFlags[i] && typeof value === "string" && maybeJson(value)
? parseJsonValue(value)
: value;
}
return row as R;
}
function maybeJson(value: string) {
return (
(value.startsWith("{") && value.endsWith("}")) ||
(value.startsWith("[") && value.endsWith("]"))
);
}
function parseJsonValue(value: string): unknown {
let parsed: unknown;
try {
parsed = JSON.parse(value);
} catch {
return value;
}
sanitizeParsedJson(parsed);
return parsed;
}
/** Strips `__proto__` and `constructor.prototype` so the parsed document can not prototype-pollute downstream merges. */
function sanitizeParsedJson(value: unknown) {
if (Array.isArray(value)) {
for (const item of value) {
sanitizeParsedJson(item);
}
return;
}
if (!isPlainObject(value)) return;
for (const key of Object.keys(value)) {
if (key === "__proto__") {
delete value[key];
continue;
}
const child = value[key];
if (
key === "constructor" &&
isPlainObject(child) &&
Object.hasOwn(child, "prototype")
) {
delete child.prototype;
}
sanitizeParsedJson(child);
}
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
if (typeof value !== "object" || value === null) return false;
const proto = Object.getPrototypeOf(value);
return proto === null || proto === Object.prototype;
}
function savepointCommand(command: string, savepointName: string) {
return RawNode.createWithChildren([
RawNode.createWithSql(`${command} `),
IdentifierNode.create(savepointName),
]);
}

View File

@@ -0,0 +1,20 @@
import { resetFactories } from "~/db/seed/core/defineFactory";
import { deleteAllRows } from "~/db/wipe";
import { markDatabaseClean } from "~/db/write-tracker";
/**
* Resets all data in the database by deleting all rows from every table,
* except for SQLite system tables and the kysely migration bookkeeping tables
* (`kysely_migration` and `kysely_migration_lock`).
*
* Tests do not call this — `app/test-setup.ts` runs it after every vitest test that
* wrote anything, and the e2e reset fixture before every test. Call it by hand only
* to wipe *within* a test.
*/
export const dbReset = async () => {
await deleteAllRows();
resetFactories();
// last, because the deletes above are themselves writes
markDatabaseClean();
};

View File

@@ -0,0 +1,10 @@
export const ADMIN_TEST_AVATAR = "6fc41a44b069a0d2152ac06d1e496c6c";
export const NZAP_TEST_DISCORD_ID = "455039198672453645";
export const NZAP_TEST_AVATAR = "f809176af93132c3db5f0a5019e96339"; // https://cdn.discordapp.com/avatars/455039198672453645/f809176af93132c3db5f0a5019e96339.webp?size=160
export const NZAP_TEST_ID = 2;
export const REGULAR_USER_TEST_ID = 2;
export const ORG_ADMIN_TEST_ID = 3;
// Matches STAFF_IDS[0] (Panda) so the seeded user is recognized as STAFF.
export const STAFF_TEST_ID = 11329;
export const STAFF_TEST_DISCORD_ID = "138757634500067328";

View File

@@ -0,0 +1,104 @@
import {
IN_GAME_NAME,
sanitizeInGameName,
} from "~/features/user-page/in-game-name";
import { abilities } from "~/modules/in-game-lists/abilities";
import {
clothesGearIds,
headGearIds,
shoesGearIds,
} from "~/modules/in-game-lists/gear-ids";
import { rankedModesShort } from "~/modules/in-game-lists/modes";
import { stageIds } from "~/modules/in-game-lists/stage-ids";
import type {
Ability,
BuildAbilitiesTuple,
MainWeaponId,
ModeWithStage,
} from "~/modules/in-game-lists/types";
import {
canonicalWeaponSplId,
mainWeaponIds,
} from "~/modules/in-game-lists/weapon-ids";
import invariant from "~/utils/invariant";
import { faker } from "./faker";
const STACKABLE_ABILITIES = abilities
.filter((ability) => ability.type === "STACKABLE")
.map((ability) => ability.name);
export function mainWeapon(): MainWeaponId {
return faker.helpers.arrayElement(mainWeaponIds);
}
const CANONICAL_MAIN_WEAPON_IDS = mainWeaponIds.filter(
(id) => canonicalWeaponSplId(id) === id,
);
/** `count` main weapons distinct down to their canonical id, e.g. a weapon pool or
* a multi-weapon build's. */
export function mainWeapons(count: number): MainWeaponId[] {
return faker.helpers.arrayElements(CANONICAL_MAIN_WEAPON_IDS, count);
}
/** The gear of a build: a head, clothes and shoes item. */
export function gear() {
return {
headGearSplId: faker.helpers.arrayElement(headGearIds),
clothesGearSplId: faker.helpers.arrayElement(clothesGearIds),
shoesGearSplId: faker.helpers.arrayElement(shoesGearIds),
};
}
/**
* An in-game name with its discriminator, e.g. `Agent 4#1859`. `name` is sanitized and
* truncated the way the real thing is, so the result always passes `inGameNameIsValid`.
*/
export function inGameName(name = faker.person.firstName()): string {
const discriminator = faker.string.alphanumeric({
length: {
min: IN_GAME_NAME.DISCRIMINATOR_MIN_LENGTH,
max: IN_GAME_NAME.DISCRIMINATOR_MAX_LENGTH,
},
casing: "lower",
});
return `${sanitizedName(name)}#${discriminator}`;
}
/**
* A map list of `count` maps, rotating through the ranked modes and never repeating
* a stage, the way a real one looks. Callers add whatever `source` their domain uses.
*/
export function mapList(count: number): ModeWithStage[] {
const stages = faker.helpers.arrayElements(stageIds, count);
return stages.map((stageId, i) => ({
mode: rankedModesShort[i % rankedModesShort.length],
stageId,
}));
}
/**
* The abilities of a build: a main and three subs per gear slot. All of them are
* stackable ones, which every slot allows.
*/
export function buildAbilities(): BuildAbilitiesTuple {
return [gearAbilities(), gearAbilities(), gearAbilities()];
}
function sanitizedName(name: string): string {
const characters = [...sanitizeInGameName(name)].slice(
0,
IN_GAME_NAME.NAME_MAX_LENGTH,
);
invariant(characters.length > 0, `No valid in-game name characters: ${name}`);
return characters.join("");
}
function gearAbilities(): [Ability, Ability, Ability, Ability] {
const draw = () => faker.helpers.arrayElement(STACKABLE_ABILITIES);
return [draw(), draw(), draw(), draw()];
}

View File

@@ -0,0 +1,16 @@
import {
type AuthenticatedUser,
userAsyncLocalStorage,
} from "~/features/auth/core/user-context.server";
/**
* Runs `fn` inside the acting-user store, so that repository functions resolving
* the actor via `actorId()` see `userId` as the acting user. Needed because seeding
* happens outside a request, where there is no acting user at all.
*/
export function actAs<T>(userId: number, fn: () => T): T {
return userAsyncLocalStorage.run(
{ user: { id: userId } as AuthenticatedUser },
fn,
);
}

View File

@@ -0,0 +1,43 @@
import { type RawBuilder, sql } from "kysely";
import { db } from "~/db/sql";
import type { Tables } from "~/db/tables";
import { dateToDatabaseTimestamp } from "~/utils/dates";
/** Tables `backdate` can address: those with an `id` for it to key the update on. */
type BackdatableTable = {
[T in keyof Tables]: "id" extends keyof Tables[T] ? T : never;
}[keyof Tables];
/** The only columns `backdate` may touch. */
type TimestampColumn<T extends BackdatableTable> = Extract<
keyof Tables[T],
`${string}At`
>;
/**
* Moves a row's timestamps into the past. Every production write stamps *now*, so
* a seed that needs rows looking old — an expired vote, a season's worth of matches
* — has no way to ask for one.
*/
export async function backdate<T extends BackdatableTable>(
table: T,
id: number,
timestamps: Partial<Record<TimestampColumn<T>, Date>>,
) {
const assignments: RawBuilder<unknown>[] = [];
for (const [column, date] of Object.entries(timestamps)) {
// so that a caller passing its own optional dates through needs no filtering
if (!date) continue;
assignments.push(
sql`${sql.ref(column)} = ${dateToDatabaseTimestamp(date as Date)}`,
);
}
if (assignments.length === 0) return;
await sql`update ${sql.table(table)} set ${sql.join(assignments)} where "id" = ${id}`.execute(
db,
);
}

View File

@@ -0,0 +1,133 @@
import { resetFaker } from "./faker";
/** Arguments `defaults` does not supply, which the caller therefore has to pass. */
type RequiredArgs<Args, Defaults> = Omit<Args, keyof Defaults>;
type CreateArgs<Args, Defaults> = RequiredArgs<Args, Defaults> & Partial<Args>;
/** `null` says "all defaults", for when only `options` is of interest. */
type CreateParams<Args, Defaults, Options> = [
keyof RequiredArgs<Args, Defaults>,
] extends [never]
? [overrides?: Partial<Args> | null, options?: Options]
: [overrides: CreateArgs<Args, Defaults>, options?: Options];
type CreateManyParams<Args, Defaults, Options> = [
keyof RequiredArgs<Args, Defaults>,
] extends [never]
? [
count: number,
overrides?: ManyOverrides<Args, Defaults> | null,
options?: Options,
]
: [
count: number,
overrides: ManyOverrides<Args, Defaults>,
options?: Options,
];
type ManyOverrides<Args, Defaults> =
| CreateArgs<Args, Defaults>
| ((index: number) => CreateArgs<Args, Defaults>);
export type Factory<Args, Row, Defaults, Options> = {
/** Inserts one row. Anything not given is defaulted. */
create: (...args: CreateParams<Args, Defaults, Options>) => Promise<Row>;
/** Inserts `count` rows. Overrides may be per-index. */
createMany: (
...args: CreateManyParams<Args, Defaults, Options>
) => Promise<Row[]>;
};
const sequenceResets = new Set<() => void>();
/**
* Defines a factory: a thin wrapper around a repository write function that fills
* arguments with a plausible default and lets the caller override any of them.
*
* `Args` is inferred from `insert`, so factories never restate column types. What
* `defaults` leaves out — foreign keys above all, which a factory must not invent —
* becomes a required argument of `create`.
*
* Defaults are drawn eagerly, before overrides are applied, so that which fields a
* caller happens to override does not shift the values every later row gets.
*
* `applyOptions` runs after the insert and is how a factory hands back a row in a
* later state (a concluded match, a finalized tournament). It gets there by running
* the app's own operations, never by writing the resulting rows itself.
*/
export function defineFactory<
Args,
Row,
Defaults extends Partial<Args> = Record<never, never>,
Options = never,
>({
defaults,
insert,
applyOptions,
}: {
/** Omitted by a factory whose every argument is a foreign key it must not invent. */
defaults?: (ctx: { seq: number }) => Defaults;
insert: (args: Args) => Promise<Row>;
applyOptions?: (row: Row, options: Options) => Promise<void>;
}): Factory<Args, Row, Defaults, Options> {
let seq = 0;
sequenceResets.add(() => {
seq = 0;
});
const insertOne = async (args: Args, options?: Options) => {
const row = await insert(args);
if (applyOptions && options) {
await applyOptions(row, options);
}
return row;
};
// `create` requires everything `defaults` doesn't supply, so the merge is a
// complete `Args` — something the compiler can't work out from the spread
const build = (overrides: Partial<Args>) =>
({
...defaults?.({ seq: ++seq }),
...overrides,
}) as Args;
return {
create: (...args) => insertOne(build(args[0] ?? {}), args[1]),
createMany: async (...args) => {
const [count, overrides, options] = args;
const rows: Row[] = [];
for (let index = 0; index < count; index++) {
rows.push(
await insertOne(build(overridesAt(overrides, index)), options),
);
}
return rows;
},
};
}
/**
* Reseeds faker and zeroes every factory's sequence, so that a run of the dev seed
* or a test starting from an empty database produces the same values as the last.
*/
export function resetFactories() {
resetFaker();
for (const reset of sequenceResets) {
reset();
}
}
function overridesAt<Args, Defaults>(
overrides: ManyOverrides<Args, Defaults> | null | undefined,
index: number,
): Partial<Args> {
if (!overrides) return {};
return typeof overrides === "function" ? overrides(index) : overrides;
}

View File

@@ -0,0 +1,55 @@
import { base, en, Faker } from "@faker-js/faker";
const FAKER_SEED = 5800;
const MAX_UNIQUE_ATTEMPTS = 100;
/**
* Faker instance dedicated to seeding. Deliberately not the global singleton, so
* that app code or a test drawing from `faker` cannot shift what the seed produces.
*/
export const faker = new Faker({ locale: [en, base] });
faker.seed(FAKER_SEED);
const usedUniqueValues = new Set<unknown>();
const seededFakers: Faker[] = [faker];
/** A deterministic faker for another locale, reseeded by `resetFaker` with the rest. */
export function createSeededFaker(
locale: ConstructorParameters<typeof Faker>[0]["locale"],
) {
const instance = new Faker({ locale });
instance.seed(FAKER_SEED);
seededFakers.push(instance);
return instance;
}
/**
* Draws from `generate` until it produces a value that has not been drawn before,
* for values that should look real but still be unique (e.g. a Discord name).
* Values with a unique constraint should be derived from the factory's `seq` instead.
*/
export function unique<T>(generate: () => T): T {
for (let attempt = 0; attempt < MAX_UNIQUE_ATTEMPTS; attempt++) {
const value = generate();
if (!usedUniqueValues.has(value)) {
usedUniqueValues.add(value);
return value;
}
}
throw new Error(
`Could not draw a unique value in ${MAX_UNIQUE_ATTEMPTS} attempts`,
);
}
/** Reseeds every faker instance and forgets every value drawn via `unique`. */
export function resetFaker() {
for (const instance of seededFakers) {
instance.seed(FAKER_SEED);
}
usedUniqueValues.clear();
}

View File

@@ -0,0 +1,39 @@
import { sql } from "kysely";
import { db } from "~/db/sql";
/**
* Moves a user to a fixed id. Production permission logic keys off literal user
* ids (`ADMIN_ID`, `STAFF_IDS`), so the users those refer to have to land on them
* for the app to consider them an admin or staff at all.
*
* Throws if the id is already taken, since taking it would mean deleting somebody
* else's rows. Create the pinned users before any other.
*/
export async function pinUserId(userId: number, pinnedId: number) {
if (userId === pinnedId) return pinnedId;
const occupant = await db
.selectFrom("User")
.select("id")
.where("id", "=", pinnedId)
.executeTakeFirst();
if (occupant) {
throw new Error(
`Can't pin user ${userId} to the id ${pinnedId}, it is already taken. Users with a pinned id have to be created before any other user.`,
);
}
// raw because `User.id` is `GeneratedAlways`, which Kysely refuses to update
await sql`update "User" set "id" = ${pinnedId} where "id" = ${userId}`.execute(
db,
);
// the search index is kept in sync by triggers that don't watch `id`, so its
// entry would keep pointing at the id the user just moved off of
await sql`insert into "UserSearch"("UserSearch") values ('rebuild')`.execute(
db,
);
return pinnedId;
}

View File

@@ -0,0 +1,14 @@
import { db } from "~/db/sql";
/**
* Moves every season stamped row (skills and the aggregated stats keyed off them)
* to the given season. Concluding a match stamps its results with the season that
* is current then, so matches backdated into an older season still leave their
* results in the ongoing one — a seed that needs a season looking played out and
* over has no other way to ask for one.
*/
export async function reseason(season: number) {
await db.updateTable("Skill").set({ season }).execute();
await db.updateTable("MapResult").set({ season }).execute();
await db.updateTable("PlayerResult").set({ season }).execute();
}

View File

@@ -0,0 +1,108 @@
import { ar, base, en, ja, ko, ru } from "@faker-js/faker";
import { USER } from "~/features/user-page/user-page-constants";
import { createSeededFaker, faker } from "./faker";
import * as SplatoonFaker from "./SplatoonFaker";
const fakerJa = createSeededFaker([ja, en, base]);
const fakerKo = createSeededFaker([ko, en, base]);
const fakerRu = createSeededFaker([ru, en, base]);
const fakerAr = createSeededFaker([ar, en, base]);
const LOCALIZED_FAKERS = [fakerJa, fakerKo, fakerRu, fakerAr];
/** Custom names that stress the UI: length extremes, non-Latin scripts, emoji. */
export const CUSTOM_NAMES = [
"S",
"Maximum length custom name".padEnd(USER.CUSTOM_NAME_MAX_LENGTH, "!"),
"イカ墨侍・銀河",
"오징어대장",
"Кальмаротрон3000",
"حبار المحيط",
"Squidちゃん★",
"🦑✨ Splat Queen ✨🦑",
"6-Star Player 🌟🌟🌟🌟🌟🌟",
"xX_sniper_Xx",
];
/** Kana-only in-game names — the Switch keyboard allows no kanji or emoji, so a
* kanji display name pairs with one of these. */
const KANA_NAMES = [
"スプラちゃん",
"いかタコどん",
"カラマリX",
"タコゾネスあ",
"ハイカラねこ",
];
export function customName(): string {
return localized().person.firstName();
}
export function kanaInGameName(): string {
return SplatoonFaker.inGameName(faker.helpers.arrayElement(KANA_NAMES));
}
export function teamName(): string {
return faker.helpers.arrayElement([
() => "Δq",
() => `${fakerJa.person.lastName()}${fakerJa.animal.type()}団`,
() => `${fakerRu.word.adjective()} ${fakerRu.animal.type()}`,
() => `${faker.word.adjective()} ${faker.animal.type()} 🦑`,
() => `${faker.company.name()}`,
() => `${faker.word.adjective()} ${faker.word.noun()}`,
])();
}
export function buildTitle(): string {
return faker.helpers.arrayElement([
() => "⭐",
() => `${fakerJa.word.adjective()}ビルド`,
() => `${faker.word.adjective()} ${faker.word.noun()} 💥`,
() => faker.lorem.words(3),
])();
}
export function eventName(): string {
return faker.helpers.arrayElement([
() => `${fakerKo.location.city()} 컵`,
() => `${faker.word.adjective()} ${faker.word.noun()} Cup ✨`,
() => `${faker.company.name()} Open`,
])();
}
export function postText(): string {
return faker.helpers.arrayElement([
() => fakerJa.lorem.paragraph(),
() => fakerAr.lorem.paragraph(),
() => faker.lorem.paragraphs({ min: 1, max: 4 }),
])();
}
/** A bio at the exact max length, heavy on markdown. */
export function maxLengthBio(): string {
const blocks = [
"# Achievements",
"- **Winner** of [Paddling Pool](https://sendou.ink) `#253`",
"- *Runner-up* at ~~everything else~~",
"## Weapons",
"1. `Splattershot`\n2. **Tentatek** — *the classic*",
"> The real zones were the friends we made along the way",
"---",
fakerJa.lorem.paragraph(),
faker.lorem.paragraph(),
];
let bio = "";
for (let i = 0; ; i++) {
const block = blocks[i % blocks.length];
if (bio.length + block.length + 2 >= USER.BIO_MAX_LENGTH) break;
bio += `${block}\n\n`;
}
return bio.padEnd(USER.BIO_MAX_LENGTH, "!").slice(0, USER.BIO_MAX_LENGTH);
}
function localized() {
return faker.helpers.arrayElement([faker, ...LOCALIZED_FAKERS]);
}

View File

@@ -0,0 +1,207 @@
[
{
"code": "sundae",
"displayName": "4v4 Sundaes",
"hue": null
},
{
"code": "zones",
"displayName": "Dapple SZ Speedladder",
"hue": null
},
{
"code": "ebtv",
"displayName": "EBTV League",
"hue": -72
},
{
"code": "girls",
"displayName": "Girls Duo Cup",
"hue": null
},
{
"code": "idtga",
"displayName": "It's Dangerous to go Alone",
"hue": null
},
{
"code": "beta_top1",
"displayName": "Beta's Events",
"hue": null
},
{
"code": "beta_top2",
"displayName": "Launch Day",
"hue": null
},
{
"code": "beta_top3",
"displayName": "Launch Day",
"hue": null
},
{
"code": "pair",
"displayName": "League Rush (Pair)",
"hue": null
},
{
"code": "quad",
"displayName": "League Rush (Quad)",
"hue": null
},
{
"code": "lobster",
"displayName": "Lobster Crossfire",
"hue": null
},
{
"code": "monday",
"displayName": "Monday Afterparty",
"hue": null
},
{
"code": "pool1",
"displayName": "Paddling Pool Weekly",
"hue": null
},
{
"code": "snapshot_gold",
"displayName": "Snapshot (Alpha)",
"hue": null
},
{
"code": "snapshot_silver",
"displayName": "Snapshot (Beta)",
"hue": null
},
{
"code": "snapshot_bronze",
"displayName": "Snapshot (Gamma)",
"hue": null
},
{
"code": "superjump_alpha",
"displayName": "Superjump",
"hue": null
},
{
"code": "superjump_beta",
"displayName": "Superjump (Beta bracket)",
"hue": null
},
{
"code": "superjump_gamma",
"displayName": "Superjump (Gamma bracket)",
"hue": null
},
{
"code": "toni_kensa",
"displayName": "Toni Kensa Cup",
"hue": null
},
{
"code": "pool2",
"displayName": "Golden Paddling Pool Event",
"hue": null
},
{
"code": "lutipink",
"displayName": "LUTI Season 12 (Div X)",
"hue": null
},
{
"code": "lutired",
"displayName": "LUTI Season 12 (Div 1)",
"hue": null
},
{
"code": "lutiorange",
"displayName": "LUTI Season 12 (Div 2)",
"hue": null
},
{
"code": "lutiyellow",
"displayName": "LUTI Season 12 (Div 3)",
"hue": null
},
{
"code": "lutilimegreen",
"displayName": "LUTI Season 12 (Div 4)",
"hue": null
},
{
"code": "lutigreen",
"displayName": "LUTI Season 12 (Div 5)",
"hue": null
},
{
"code": "lutiblue",
"displayName": "LUTI Season 12 (Div 6)",
"hue": null
},
{
"code": "lutipurple",
"displayName": "LUTI Season 12 (Div 7)",
"hue": null
},
{
"code": "lutitan",
"displayName": "LUTI Season 12 (Div 8)",
"hue": null
},
{
"code": "lutitan",
"displayName": "LUTI Season 12 (Div 9)",
"hue": null
},
{
"code": "tidal_tuesdays",
"displayName": "Tidal Tuesdays",
"hue": null
},
{
"code": "squid_junction",
"displayName": "Squid Junction",
"hue": null
},
{
"code": "triton",
"displayName": "Triton-Cup",
"hue": null
},
{
"code": "cake",
"displayName": "Yay's SUPER AWESOME Birthday Bash!",
"hue": null
},
{
"code": "20xx",
"displayName": "a 20XX Series tournament",
"hue": null
},
{
"code": "itz_red",
"displayName": "In The Zone 1-9",
"hue": null
},
{
"code": "itz_orange",
"displayName": "In The Zone 10-19",
"hue": null
},
{
"code": "itz_blue",
"displayName": "In The Zone 20-29",
"hue": null
},
{
"code": "patreon",
"displayName": "Supporter",
"hue": null
},
{
"code": "patreon_plus",
"displayName": "Supporter+",
"hue": null
}
]

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,107 @@
import { BADGE } from "~/features/badges/badges-constants";
import { faker } from "../core/faker";
import badges from "../data/badges.json";
import * as BadgeFactory from "../factories/BadgeFactory";
import * as UserFactory from "../factories/UserFactory";
import type { SeededUsers } from "./users";
const HOMEMADE_BADGE_COUNT = 5;
const NZAP_BADGE_COUNT = 20;
const ADMIN_BADGE_COUNT = 3;
const NZAP_FAVORITE_BADGE_COUNT = BADGE.SMALL_BADGES_PER_DISPLAY_PAGE + 1;
const MANY_OWNERS_COUNT = 60;
const MANY_BADGES_USER_BADGE_COUNT = 25;
export type SeededBadges = {
ids: number[];
};
export async function seedBadges(users: SeededUsers): Promise<SeededBadges> {
const ownableUserIds = [
...users.showcaseIds,
...users.showcaseIds,
...users.crowdIds,
];
const manyBadgesUserId = users.favoriteBadgeUserIds[0];
const ids: number[] = [];
for (const [i, badge] of badges.entries()) {
const created = await BadgeFactory.create(
{
code: badge.code,
displayName: badge.displayName,
hue: badge.hue,
authorId: i < HOMEMADE_BADGE_COUNT ? users.adminId : null,
},
{
ownerIds: fakeOwnerIds({
index: i,
users,
ownableUserIds,
manyBadgesUserId,
}),
managerIds:
i < HOMEMADE_BADGE_COUNT
? [users.adminId]
: i < 10
? [users.nzapId]
: undefined,
},
);
ids.push(created.id);
}
await seedFavoriteBadges(users, ids);
return { ids };
}
function fakeOwnerIds({
index,
users,
ownableUserIds,
manyBadgesUserId,
}: {
index: number;
users: SeededUsers;
ownableUserIds: number[];
manyBadgesUserId: number;
}) {
const ownerIds = faker.helpers.arrayElements(
ownableUserIds,
index === 0 ? MANY_OWNERS_COUNT : faker.number.int({ min: 1, max: 24 }),
);
if (index < NZAP_BADGE_COUNT) {
ownerIds.push(users.nzapId);
}
if (index < ADMIN_BADGE_COUNT) {
ownerIds.push(users.adminId);
}
if (index < MANY_BADGES_USER_BADGE_COUNT) {
ownerIds.push(manyBadgesUserId);
}
// favorite badges are picked from the first few, so their pickers own them
if (index < 3) {
ownerIds.push(...users.favoriteBadgeUserIds);
}
return [...new Set(ownerIds)];
}
async function seedFavoriteBadges(users: SeededUsers, badgeIds: number[]) {
for (const [i, userId] of users.favoriteBadgeUserIds.entries()) {
await UserFactory.updateProfile(userId, {
favoriteBadgeIds: [badgeIds[i % 3]],
});
}
// a supporter picks a whole row of small badges alongside the big one
await UserFactory.updateProfile(users.nzapId, {
favoriteBadgeIds: badgeIds.slice(0, NZAP_FAVORITE_BADGE_COUNT),
});
}

View File

@@ -0,0 +1,39 @@
import { faker } from "../core/faker";
import * as showcaseNames from "../core/showcaseNames";
import * as BuildFactory from "../factories/BuildFactory";
import type { SeededUsers } from "./users";
const NZAP_BUILD_COUNT = 100;
const SPLATTERSHOT_ID = 40;
const ONE_WEAPON_BUILD_COUNT = 40;
const CROWD_BUILD_COUNT = 360;
export async function seedBuilds(users: SeededUsers) {
await BuildFactory.createMany(NZAP_BUILD_COUNT, (i) => ({
ownerId: users.nzapId,
title: showcaseNames.buildTitle(),
isPrivate: i % 8 === 0 ? (1 as const) : (0 as const),
}));
// one weapon with builds from many users, so its weapon page paginates
const splattershotOwners = faker.helpers.arrayElements(
[...users.showcaseIds, ...users.crowdIds],
ONE_WEAPON_BUILD_COUNT,
);
for (const ownerId of splattershotOwners) {
await BuildFactory.create({
ownerId,
weaponSplIds: [SPLATTERSHOT_ID],
});
}
for (let i = 0; i < CROWD_BUILD_COUNT; i++) {
await BuildFactory.create({
ownerId: faker.helpers.arrayElement([
...users.showcaseIds,
...users.crowdIds,
]),
isPrivate: faker.number.float(1) < 0.05 ? (1 as const) : (0 as const),
});
}
}

View File

@@ -0,0 +1,134 @@
import { dateToDatabaseTimestamp } from "~/utils/dates";
import invariant from "~/utils/invariant";
import { faker } from "../core/faker";
import * as showcaseNames from "../core/showcaseNames";
import * as CalendarEventFactory from "../factories/CalendarEventFactory";
import * as CalendarEventResultFactory from "../factories/CalendarEventResultFactory";
import type { SeededBadges } from "./badges";
import type { SeededUsers } from "./users";
const EVENT_COUNT = 200;
const RESULT_TARGET_USER_RESULTS = 8;
/** Results N-ZAP is placed in, enough for his results page to paginate. */
const NZAP_RESULT_COUNT = 30;
export type SeededCalendarEvents = {
/** Result teams N-ZAP played on, in the order they were reported. */
nzapResultTeamIds: number[];
};
export async function seedCalendarEvents(
users: SeededUsers,
badges: SeededBadges,
): Promise<SeededCalendarEvents> {
const authorPool = [...users.showcaseIds, ...users.crowdIds];
const nzapResultTeamIds: number[] = [];
for (let i = 0; i < EVENT_COUNT; i++) {
const startTime = fakeStartTime(i);
const event = await CalendarEventFactory.create({
name: showcaseNames.eventName(),
authorId: i === 0 ? users.nzapId : faker.helpers.arrayElement(authorPool),
badges:
faker.number.float(1) < 0.25
? faker.helpers.arrayElements(badges.ids, { min: 1, max: 3 })
: [],
startTimes: fakeStartTimes(startTime),
// the rest fall back to the default logo, as an event without one does
hasAvatar: faker.number.float(1) < 0.4,
});
const isPast = startTime.getTime() < Date.now();
if (isPast && faker.number.float(1) < 0.6) {
const withNzap = nzapResultTeamIds.length < NZAP_RESULT_COUNT;
const result = await CalendarEventResultFactory.create({
eventId: event.id,
results: fakeResults(users, withNzap ? users.nzapId : null),
});
if (withNzap) {
nzapResultTeamIds.push(nzapTeamId(result.teams, users.nzapId));
}
}
}
return { nzapResultTeamIds };
}
function nzapTeamId(
teams: Awaited<ReturnType<typeof CalendarEventResultFactory.create>>["teams"],
nzapId: number,
) {
const team = teams.find((team) =>
team.players.some((player) => player.id === nzapId),
);
invariant(team, "N-ZAP was not placed in the results");
return team.id;
}
function fakeStartTime(index: number) {
const startTime =
index % 2 === 0
? faker.date.soon({ days: 42 })
: faker.date.recent({ days: 240 });
startTime.setMinutes(0, 0, 0);
return startTime;
}
function fakeStartTimes(startTime: Date) {
const isTwoDayEvent = faker.number.float(1) < 0.1;
if (!isTwoDayEvent) return [dateToDatabaseTimestamp(startTime)];
const secondDay = new Date(startTime);
secondDay.setDate(secondDay.getDate() + 1);
return [
dateToDatabaseTimestamp(startTime),
dateToDatabaseTimestamp(secondDay),
];
}
function fakeResults(users: SeededUsers, nzapId: number | null) {
const placementCount = faker.helpers.arrayElement([1, 2, 3, 3, 3, 8]);
const usedUserIds = new Set<number>();
// spread over the placements, so his results are not all of the same podium spot
const nzapPlacementIdx = faker.number.int({ max: placementCount - 1 });
const drawUserId = () => {
// weighted toward the showcase set so their result lists paginate
const pool =
faker.number.float(1) < 0.7
? users.showcaseIds.slice(0, RESULT_TARGET_USER_RESULTS * 4)
: users.crowdIds;
const userId = faker.helpers.arrayElement(
pool.filter((id) => !usedUserIds.has(id)),
);
usedUserIds.add(userId);
return userId;
};
return Array.from({ length: placementCount }, (_, i) => ({
placement: i + 1,
teamName: showcaseNames.teamName(),
players: [
...(nzapId !== null && i === nzapPlacementIdx
? [{ name: null, userId: nzapId }]
: []),
...Array.from(
{ length: faker.helpers.arrayElement([1, 2, 3, 4, 4, 4, 5]) },
() => {
const isUnregisteredPlayer = faker.number.float(1) < 0.2;
return isUnregisteredPlayer
? { name: faker.person.firstName(), userId: null }
: { name: null, userId: drawUserId() };
},
),
],
}));
}

View File

@@ -0,0 +1,31 @@
import * as ResultHighlightFactory from "../factories/ResultHighlightFactory";
import type { SeededCalendarEvents } from "./calendar";
import type { SeededTournaments } from "./tournaments";
import type { SeededUsers } from "./users";
/** Calendar event results N-ZAP highlights, few enough that the highlights view of
* his results page is a page shorter than the full one. */
const NZAP_CALENDAR_HIGHLIGHT_COUNT = 6;
/**
* Highlights some of N-ZAP's results, so that his profile has a highlighted results
* widget and his results page opens on the highlights view.
*/
export async function seedResultHighlights({
users,
calendarEvents,
tournaments,
}: {
users: SeededUsers;
calendarEvents: SeededCalendarEvents;
tournaments: SeededTournaments;
}) {
await ResultHighlightFactory.replaceAll({
userId: users.nzapId,
resultTeamIds: calendarEvents.nzapResultTeamIds.slice(
0,
NZAP_CALENDAR_HIGHLIGHT_COUNT,
),
resultTournamentTeamIds: tournaments.nzapTeamIds,
});
}

View File

@@ -0,0 +1,219 @@
import { sub } from "date-fns";
import { Config } from "~/config";
import type { Tables } from "~/db/tables";
import type { Notification } from "~/features/notifications/notifications-types";
import type { MainWeaponId, ModeShort } from "~/modules/in-game-lists/types";
import {
getArtFilename,
SEED_ART_URLS,
} from "../../../../scripts/seed-art-urls";
import { NZAP_TEST_DISCORD_ID } from "../constants";
import { faker } from "../core/faker";
import placements from "../data/placements.json";
import * as ArtFactory from "../factories/ArtFactory";
import * as FriendRequestFactory from "../factories/FriendRequestFactory";
import * as FriendshipFactory from "../factories/FriendshipFactory";
import * as LiveStreamFactory from "../factories/LiveStreamFactory";
import * as NotificationFactory from "../factories/NotificationFactory";
import * as SplatoonRotationFactory from "../factories/SplatoonRotationFactory";
import * as UserReportFactory from "../factories/UserReportFactory";
import * as XRankPlacementFactory from "../factories/XRankPlacementFactory";
import type { SeededSendouQ } from "./sendouq";
import type { SeededTournaments } from "./tournaments";
import type { SeededUsers } from "./users";
const NZAP_PLAYER_SPL_ID = "qx6imlx72tfeqrhqfnmm";
const FRIEND_COUNT = 8;
const STREAM_COUNT = 20;
export async function seedMisc({
users,
sendouq,
tournaments,
}: {
users: SeededUsers;
sendouq: SeededSendouQ;
tournaments: SeededTournaments;
}) {
await seedXRankPlacements(users);
await seedArts(users);
await seedFriends(users);
await seedNotifications(users, tournaments);
await seedUserReports(users, sendouq);
await LiveStreamFactory.replaceAll(
users.showcaseIds.slice(0, STREAM_COUNT).map((userId) => ({ userId })),
);
await SplatoonRotationFactory.replaceAll();
}
async function seedXRankPlacements(users: SeededUsers) {
// a top 500 player who is a site user without plus membership
const unaffiliatedTopPlayerId = users.crowdIds[users.crowdIds.length - 1];
for (const [i, placement] of placements.entries()) {
const playerUserId =
placement.playerSplId === NZAP_PLAYER_SPL_ID
? users.nzapId
: i === 0
? unaffiliatedTopPlayerId
: undefined;
await XRankPlacementFactory.create(
{
...placement,
mode: placement.mode as ModeShort,
region: placement.region as Tables["XRankPlacement"]["region"],
weaponSplId: placement.weaponSplId as MainWeaponId,
playerUserId,
},
{ refreshPeakXp: i === placements.length - 1 },
);
}
}
async function seedArts(users: SeededUsers) {
let nextUrl = 0;
for (const authorId of [users.nzapId, ...users.artistIds]) {
const artCount = faker.helpers.arrayElement([1, 2, 3, 3, 4]);
for (let i = 0; i < artCount; i++) {
await ArtFactory.create({
authorId,
url: getArtFilename(nextUrl++ % SEED_ART_URLS.length),
description:
faker.number.float(1) < 0.5 ? faker.lorem.paragraph() : null,
linkedUsers:
i === 1
? [
...(authorId === users.nzapId ? [] : [users.nzapId]),
...faker.helpers.arrayElements(users.showcaseIds, {
min: 0,
max: 2,
}),
]
: [],
});
}
}
}
async function seedFriends(users: SeededUsers) {
const friendIds = users.showcaseIds.slice(0, FRIEND_COUNT);
for (const friendId of friendIds) {
await FriendshipFactory.create({
userOneId: users.nzapId,
userTwoId: friendId,
});
}
// mutual friends between some of them, so user cards show the overlap
await FriendshipFactory.create({
userOneId: friendIds[0],
userTwoId: friendIds[1],
});
await FriendshipFactory.create({
userOneId: friendIds[0],
userTwoId: friendIds[2],
});
await FriendRequestFactory.create({
senderId: users.showcaseIds[FRIEND_COUNT],
receiverId: users.nzapId,
});
}
async function seedNotifications(
users: SeededUsers,
tournaments: SeededTournaments,
) {
const { id: tournamentId, name: tournamentName } = tournaments.regOpen;
const notifications: Notification[] = [
{ type: "PLUS_SUGGESTION_ADDED", meta: { tier: 1 } },
{ type: "SEASON_STARTED", meta: { seasonNth: 1 } },
{
type: "TO_ADDED_TO_TEAM",
meta: {
adderUsername: "N-ZAP",
teamName: "Chimera",
tournamentId,
tournamentName,
tournamentTeamId: 1,
},
},
{
type: "TO_BRACKET_STARTED",
meta: {
tournamentId,
tournamentName,
bracketIdx: 0,
bracketName: "Main Bracket",
},
},
{ type: "BADGE_ADDED", meta: { badgeName: "4v4 Sundaes", badgeId: 1 } },
{
type: "TAGGED_TO_ART",
meta: {
adderUsername: "N-ZAP",
adderDiscordId: NZAP_TEST_DISCORD_ID,
artId: 1,
},
},
{ type: "SQ_ADDED_TO_GROUP", meta: { adderUsername: "N-ZAP" } },
{ type: "SQ_NEW_MATCH", meta: { matchId: 100 } },
{ type: "PLUS_VOTING_STARTED", meta: { seasonNth: 1 } },
{
type: "TO_CHECK_IN_OPENED",
meta: { tournamentId, tournamentName },
pictureUrl: `${Config.staticAssetsUrl}/img/tournament-logos/pn.avif`,
},
];
for (const [i, notification] of notifications.entries()) {
const createdAt = sub(new Date(), {
days: notifications.length - 1 - i,
minutes: i * 17,
});
await NotificationFactory.create(
{
notification,
users: [
{ userId: users.adminId, seen: i <= 7 ? 1 : 0 },
{ userId: users.nzapId, seen: i <= 7 ? 1 : 0 },
],
},
{ createdAt },
);
}
}
async function seedUserReports(users: SeededUsers, sendouq: SeededSendouQ) {
// uneven spread over the trailing year so the admin tab's bar graph shows variety
const monthsAgoDistribution = [
0, 0, 0, 1, 2, 2, 2, 2, 5, 5, 7, 8, 10, 11, 11,
];
for (const [i, monthsAgo] of monthsAgoDistribution.entries()) {
await UserReportFactory.create(
{
reportedUserId: users.nzapId,
reporterUserId: users.showcaseIds[10 + i],
matchId:
i % 3 === 0
? faker.helpers.arrayElement(sendouq.recentMatchIds)
: null,
},
{
createdAt: sub(new Date(), {
months: monthsAgo,
days: (i * 3) % 7,
hours: i,
}),
},
);
}
}

View File

@@ -0,0 +1,38 @@
import * as TournamentOrganizationFactory from "../factories/TournamentOrganizationFactory";
import type { SeededUsers } from "./users";
export type SeededOrganization = {
id: number;
name: string;
seriesNames: string[];
};
export async function seedOrganizations(
users: SeededUsers,
): Promise<SeededOrganization[]> {
const created = await TournamentOrganizationFactory.create(
{ name: "sendou.ink", ownerId: users.adminId },
{
avatarFileName: "default.png",
description: "Sendou.ink official tournaments",
socials: [
"https://bsky.app/profile/sendou.ink",
"https://twitch.tv/sendou",
],
series: [
{
name: "PICNIC",
description: "PICNIC tournament series",
showLeaderboard: false,
},
],
members: [
{ userId: users.orgAdminId, role: "ADMIN" },
{ userId: users.nzapId, role: "MEMBER" },
],
isEstablished: true,
},
);
return [{ id: created.id, name: "sendou.ink", seriesNames: ["PICNIC"] }];
}

View File

@@ -0,0 +1,75 @@
import { lastCompletedVoting } from "~/features/plus-voting/core/voting-time";
import {
PLUS_DOWNVOTE,
PLUS_UPVOTE,
} from "~/features/plus-voting/plus-voting-constants";
import { faker } from "../core/faker";
import * as PlusSuggestionFactory from "../factories/PlusSuggestionFactory";
import * as PlusVoteFactory from "../factories/PlusVoteFactory";
import type { SeededUsers } from "./users";
const TIER_SIZES = [30, 50, 70];
const FAILED_PER_TIER = 8;
const SUGGESTED_COUNT = 110;
export async function seedPlus(users: SeededUsers) {
const memberIds = await seedLastMonthsVoting(users);
await PlusVoteFactory.syncTiers();
await seedSuggestions(users, memberIds);
}
async function seedLastMonthsVoting(users: SeededUsers) {
const candidateIds = [
users.adminId,
...users.showcaseIds,
...users.crowdIds.slice(0, 80),
].filter((id) => id !== users.nzapId);
const memberIds: number[] = [];
let nextCandidate = 0;
for (const [tierIndex, size] of TIER_SIZES.entries()) {
const tier = tierIndex + 1;
for (let i = 0; i < size; i++) {
const votedId = candidateIds[nextCandidate++];
await PlusVoteFactory.create({
authorId: users.adminId,
votedId,
tier,
score: PLUS_UPVOTE,
});
memberIds.push(votedId);
}
for (let i = 0; i < FAILED_PER_TIER; i++) {
await PlusVoteFactory.create({
authorId: users.adminId,
votedId: candidateIds[nextCandidate++],
tier,
score: PLUS_DOWNVOTE,
});
}
}
return memberIds;
}
async function seedSuggestions(users: SeededUsers, memberIds: number[]) {
const { month, year } = lastCompletedVoting(new Date());
const suggestableIds = users.crowdIds.slice(100, 100 + SUGGESTED_COUNT * 2);
for (let i = 0; i < SUGGESTED_COUNT; i++) {
const suggestedId = suggestableIds[i];
const isLastMonths = i % 3 === 0;
const suggesterId = faker.helpers.arrayElement(memberIds);
await PlusSuggestionFactory.create({
authorId: suggesterId,
suggestedId,
tier: faker.helpers.arrayElement([1, 2, 3]),
...(isLastMonths ? { month, year } : {}),
});
}
}

View File

@@ -0,0 +1,138 @@
import { add } from "date-fns";
import { databaseTimestampNow, dateToDatabaseTimestamp } from "~/utils/dates";
import { faker } from "../core/faker";
import * as showcaseNames from "../core/showcaseNames";
import * as AssociationFactory from "../factories/AssociationFactory";
import * as LFGPostFactory from "../factories/LFGPostFactory";
import * as ScrimPostFactory from "../factories/ScrimPostFactory";
import type { SeededTeams } from "./teams";
import type { SeededUsers } from "./users";
const SCRIM_POST_COUNT = 20;
const LFG_POST_COUNT = 9;
const ASSOCIATION_COUNT = 3;
export async function seedScrimsAndLFG(users: SeededUsers, teams: SeededTeams) {
await seedScrimPosts(users, teams);
await seedLFGPosts(users, teams);
await seedAssociations(users);
}
async function seedScrimPosts(users: SeededUsers, teams: SeededTeams) {
const userPool = [...users.showcaseIds, ...users.crowdIds];
let next = 0;
const takeUsers = (count: number) => {
const taken = userPool.slice(next, next + count);
next += count;
return taken.map((userId, i) => ({
userId,
isOwner: i === 0 ? (1 as const) : (0 as const),
}));
};
// an accepted scrim between the admin's and N-ZAP's rosters
await ScrimPostFactory.create(
{
startsAt: dateToDatabaseTimestamp(add(new Date(), { hours: 2 })),
isScheduledForFuture: true,
managedByAnyone: true,
users: [{ userId: users.adminId, isOwner: 1 }, ...takeUsers(3)],
},
{
requests: [
{
users: [{ userId: users.nzapId, isOwner: 1 }, ...takeUsers(3)],
isAccepted: true,
},
],
},
);
for (let i = 0; i < SCRIM_POST_COUNT; i++) {
const divs = faker.number.float(1) < 0.8 ? fakeDivRange() : null;
const startsAt =
faker.number.float(1) < 0.5
? databaseTimestampNow()
: dateToDatabaseTimestamp(
faker.date.between({
from: new Date(),
to: add(new Date(), { days: 7 }),
}),
);
await ScrimPostFactory.create(
{
startsAt,
isScheduledForFuture: true,
managedByAnyone: true,
maxDiv: divs?.maxDiv,
minDiv: divs?.minDiv,
teamId:
faker.number.float(1) < 0.4
? faker.helpers.arrayElement(teams.ids)
: null,
text: faker.number.float(1) < 0.5 ? showcaseNames.postText() : null,
maps: faker.helpers.arrayElement(["SZ", "ALL", "RANKED", null, null]),
users: takeUsers(faker.helpers.arrayElement([4, 4, 4, 5, 5, 6])),
},
{
requests: i < 3 ? [{ users: takeUsers(4) }] : undefined,
},
);
}
}
async function seedLFGPosts(users: SeededUsers, teams: SeededTeams) {
const authorIds = [
users.adminId,
...faker.helpers.arrayElements(
[...users.showcaseIds, ...users.crowdIds],
LFG_POST_COUNT - 2,
),
];
for (const authorId of authorIds) {
await LFGPostFactory.create({
authorId,
text: showcaseNames.postText(),
});
}
// posted by the owner of the team it is looking for players for
await LFGPostFactory.create({
authorId: users.nzapId,
type: "TEAM_FOR_PLAYER",
teamId: teams.allianceRogueId,
timezone: "Europe/Stockholm",
});
}
async function seedAssociations(users: SeededUsers) {
for (let i = 0; i < ASSOCIATION_COUNT; i++) {
const ownerId =
i === ASSOCIATION_COUNT - 1
? faker.helpers.arrayElement(users.showcaseIds)
: users.adminId;
await AssociationFactory.create(
{ name: faker.company.name(), userId: ownerId },
{
memberUserIds: [
...(ownerId === users.adminId ? [] : [users.adminId]),
...faker.helpers.arrayElements(
users.showcaseIds.filter((id) => id !== ownerId),
faker.helpers.arrayElement([6, 10, 16]),
),
],
},
);
}
}
function fakeDivRange() {
return {
maxDiv: faker.helpers.arrayElement([0, 1, 2, 3, 4, 5]),
minDiv: faker.helpers.arrayElement([6, 7, 8, 9, 10, 11]),
};
}

View File

@@ -0,0 +1,262 @@
import { sub } from "date-fns";
import { FULL_GROUP_SIZE, SENDOUQ } from "~/features/sendouq/q-constants";
import invariant from "~/utils/invariant";
import { faker } from "../core/faker";
import * as SQGroupFactory from "../factories/SQGroupFactory";
import * as SQMatchFactory from "../factories/SQMatchFactory";
import * as SQReportedWeaponFactory from "../factories/SQReportedWeaponFactory";
import type { SeededTeams } from "./teams";
import type { SeededUsers } from "./users";
const RECENT_MATCH_COUNT = 300;
const OLDER_MATCH_COUNT = 140;
const SQUAD_COUNT = 8;
const LOOKING_GROUP_COUNT = 10;
const REPORTED_MAP_COUNT = 4;
/** N-ZAP's unconfirmed match, on an id worth remembering. Every other match is
* created before it, so the squad matches make up the difference. */
const NZAP_MATCH_ID = 500;
const SQUAD_MATCH_COUNT =
NZAP_MATCH_ID - 1 - RECENT_MATCH_COUNT - OLDER_MATCH_COUNT;
export type SeededSendouQ = {
recentMatchIds: number[];
};
export async function seedSendouQ(
users: SeededUsers,
teams: SeededTeams,
): Promise<SeededSendouQ> {
const playerIds = users.showcaseIds;
const recentMatchIds: number[] = [];
for (let i = 0; i < RECENT_MATCH_COUNT; i++) {
const match = await seedConcludedMatch(
playerIds,
sub(new Date(), {
days: faker.number.int({ min: 0, max: 60 }),
hours: faker.number.int({ min: 0, max: 23 }),
}),
);
recentMatchIds.push(match.id);
}
for (let i = 0; i < OLDER_MATCH_COUNT; i++) {
await seedConcludedMatch(
playerIds,
sub(new Date(), {
days: faker.number.int({ min: 61, max: 600 }),
hours: faker.number.int({ min: 0, max: 23 }),
}),
);
}
await seedSquadMatches(teams);
await seedNzapReportedMatch(users, teams);
await seedNzapCanceledMatches(users, teams);
await seedLookingGroups(users);
return { recentMatchIds };
}
/** A match N-ZAP's team has reported but the other has not confirmed, so it is the
* other team's to report and N-ZAP's group is free to queue again. His side is
* Alliance Rogue's lineup, so the match is one of a team against a pickup group. */
async function seedNzapReportedMatch(users: SeededUsers, teams: SeededTeams) {
const opponentIds = users.crowdIds.slice(-88, -84);
const match = await SQMatchFactory.create(
{
alphaUserIds: allianceRogueLineup(teams),
bravoUserIds: opponentIds,
isMatchmade: true,
},
{ isReported: true, createdAt: sub(new Date(), { hours: 1 }) },
);
invariant(
match.id === NZAP_MATCH_ID,
`N-ZAP's match was created on id ${match.id}, not ${NZAP_MATCH_ID}`,
);
}
/** One canceled match of each form the cancel reports take, so that the staff-only
* views have every one of them to show: the two teams pointing at the same player,
* at different ones, and a match staff canceled without either team's account of it. */
async function seedNzapCanceledMatches(users: SeededUsers, teams: SeededTeams) {
const [nzapId, ...teammateIds] = allianceRogueLineup(teams);
const opponentIds = users.crowdIds.slice(-104, -88);
const opponentGroup = (nth: number) =>
opponentIds.slice(nth * FULL_GROUP_SIZE, (nth + 1) * FULL_GROUP_SIZE);
await SQMatchFactory.create(
{
alphaUserIds: [nzapId, ...teammateIds],
bravoUserIds: opponentGroup(0),
isMatchmade: true,
},
{
cancel: {
requested: {
reason:
"Their player never came back to the lobby after the second map. Waited 15 minutes and then gave up.",
nominatedUserIds: [opponentGroup(0)[2]],
},
accepted: {
reason:
"Our teammate's console crashed and he couldn't get back online. Sorry for wasting everyone's time.",
nominatedUserIds: [opponentGroup(0)[2]],
},
},
createdAt: sub(new Date(), { days: 2, hours: 4 }),
},
);
await SQMatchFactory.create(
{
alphaUserIds: opponentGroup(1),
bravoUserIds: [nzapId, ...teammateIds],
isMatchmade: true,
},
{
cancel: {
requested: {
reason: "Opponent left the lobby after going down 0-2.",
nominatedUserIds: [nzapId],
},
accepted: {
reason:
"Power outage on my end, nothing intentional. They were flaming in chat the whole set before that.",
nominatedUserIds: [opponentGroup(1)[0], opponentGroup(1)[3]],
},
},
createdAt: sub(new Date(), { days: 9, hours: 11 }),
},
);
// a teammate owns the group, so that the report against N-ZAP is his own team's
await SQMatchFactory.create(
{
alphaUserIds: opponentGroup(2),
bravoUserIds: [...teammateIds, nzapId],
isMatchmade: true,
},
{
cancel: {
requested: {
reason: maxLengthCancelReason(),
nominatedUserIds: [nzapId, opponentGroup(2)[1]],
},
accepted: {
reason: "N-ZAP had to leave for work mid-set, our bad.",
nominatedUserIds: [nzapId],
},
},
createdAt: sub(new Date(), { days: 20, hours: 2 }),
},
);
await SQMatchFactory.create(
{
alphaUserIds: [nzapId, ...teammateIds],
bravoUserIds: opponentGroup(3),
isMatchmade: true,
},
{
canceledByStaffUserId: users.staffId,
createdAt: sub(new Date(), { days: 35, hours: 7 }),
},
);
}
/** A reason at the exact max length, for how a wall of text lays out. */
function maxLengthCancelReason() {
return faker.lorem
.paragraphs(5)
.replaceAll("\n", " ")
.slice(0, SENDOUQ.CANCEL_REASON_MAX_LENGTH);
}
function allianceRogueLineup(teams: SeededTeams) {
const allianceRogue = teams.squads.find(
(squad) => squad.teamId === teams.allianceRogueId,
);
invariant(allianceRogue, "Alliance Rogue has no full lineup");
return allianceRogue.memberUserIds;
}
/** Fixed team lineups playing together repeatedly, so their identifier skills reach
* the match count the team leaderboard requires. */
async function seedSquadMatches(teams: SeededTeams) {
const squads = teams.squads.slice(0, SQUAD_COUNT);
for (let i = 0; i < SQUAD_MATCH_COUNT; i++) {
const [alpha, bravo] = faker.helpers.arrayElements(squads, 2);
await SQMatchFactory.create(
{
alphaUserIds: alpha.memberUserIds,
bravoUserIds: bravo.memberUserIds,
},
{
isConcluded: true,
createdAt: sub(new Date(), {
days: faker.number.int({ min: 0, max: 45 }),
hours: faker.number.int({ min: 0, max: 23 }),
}),
},
);
}
}
async function seedConcludedMatch(playerIds: number[], createdAt: Date) {
const players = faker.helpers.arrayElements(playerIds, 8);
const match = await SQMatchFactory.create(
{
alphaUserIds: players.slice(0, 4),
bravoUserIds: players.slice(4),
},
{ isConcluded: true, createdAt, confirmedAt: createdAt },
);
if (faker.number.float(1) < 0.7) {
// the first four maps are always played, whoever won
await SQReportedWeaponFactory.createMany(
players.length * REPORTED_MAP_COUNT,
(i) => ({
groupMatchId: match.id,
mapIndex: Math.floor(i / players.length),
userId: players[i % players.length],
}),
);
}
return match;
}
async function seedLookingGroups(users: SeededUsers) {
// the tail of the crowd is free of tournament rosters, so these read as their own scene
const availableUserIds = [users.nzapId, ...users.crowdIds.slice(-80)];
const groupIds: number[] = [];
for (let i = 0; i < LOOKING_GROUP_COUNT; i++) {
const memberCount =
i === 0 ? 4 : faker.helpers.arrayElement([1, 1, 2, 3, 4]);
const memberUserIds = availableUserIds.splice(0, memberCount);
const group = await SQGroupFactory.create(
{ memberUserIds },
{
likedByGroupIds:
groupIds.length > 1
? faker.helpers.arrayElements(groupIds, { min: 0, max: 2 })
: undefined,
},
);
groupIds.push(group.id);
}
}

View File

@@ -0,0 +1,81 @@
import { faker } from "../core/faker";
import * as showcaseNames from "../core/showcaseNames";
import * as TeamFactory from "../factories/TeamFactory";
import type { SeededUsers } from "./users";
const TEAM_COUNT = 40;
const SECONDARY_TEAM_COUNT = 10;
export type SeededTeams = {
allianceRogueId: number;
ids: number[];
/** Four members of a shared team, e.g. a lineup for the SQ team leaderboard. */
squads: Array<{ teamId: number; name: string; memberUserIds: number[] }>;
};
export async function seedTeams(users: SeededUsers): Promise<SeededTeams> {
const memberPool = [...users.showcaseIds, ...users.crowdIds];
let nextMember = 0;
const takeMembers = (count: number) => {
const members = memberPool.slice(nextMember, nextMember + count);
nextMember += count;
return members;
};
const allianceRogue = await TeamFactory.create(
{
name: "Alliance Rogue",
memberUserIds: [users.nzapId, ...takeMembers(4)],
},
{ avatarUrl: "alliance-rogue.png" },
);
const ids: number[] = [allianceRogue.id];
const squads: SeededTeams["squads"] = [
{
teamId: allianceRogue.id,
name: allianceRogue.name,
memberUserIds: allianceRogue.memberUserIds.slice(0, 4),
},
];
for (let i = 1; i < TEAM_COUNT; i++) {
const memberCount = faker.helpers.arrayElement([
1, 2, 3, 4, 4, 4, 4, 5, 5, 5, 6, 7, 8,
]);
const memberUserIds = takeMembers(memberCount);
const team = await TeamFactory.create(
{
name: i === 1 ? "Team Olive" : showcaseNames.teamName(),
memberUserIds,
},
i === 1 || faker.number.float(1) < 0.3 ? { hasAvatar: true } : undefined,
);
ids.push(team.id);
if (memberCount >= 4) {
squads.push({
teamId: team.id,
name: team.name,
memberUserIds: memberUserIds.slice(0, 4),
});
}
}
// showcase users double as members of a secondary team; disjoint chunks so
// nobody exceeds the two-team limit
for (let i = 0; i < SECONDARY_TEAM_COUNT; i++) {
const memberUserIds = users.showcaseIds.slice(i * 4, i * 4 + 4);
const team = await TeamFactory.create({
name: showcaseNames.teamName(),
isMainTeam: false,
memberUserIds,
});
ids.push(team.id);
}
return { allianceRogueId: allianceRogue.id, ids, squads };
}

View File

@@ -0,0 +1,615 @@
import { sub } from "date-fns";
import type { TournamentSettings } from "~/db/tables-json";
import { MapPool } from "~/features/map-list-generator/core/map-pool";
import { BANNED_MAPS } from "~/features/match-profile/banned-maps";
import type { TournamentTierNumber } from "~/features/tournament/core/tiering";
import { rankedModesShort } from "~/modules/in-game-lists/modes";
import { stageIds } from "~/modules/in-game-lists/stage-ids";
import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
import { dateToDatabaseTimestamp } from "~/utils/dates";
import { faker, unique } from "../core/faker";
import * as showcaseNames from "../core/showcaseNames";
import * as ImageFactory from "../factories/ImageFactory";
import * as SavedCalendarEventFactory from "../factories/SavedCalendarEventFactory";
import * as TournamentFactory from "../factories/TournamentFactory";
import * as TournamentLFGTeamFactory from "../factories/TournamentLFGTeamFactory";
import * as TournamentStreamerFactory from "../factories/TournamentStreamerFactory";
import * as TournamentTeamFactory from "../factories/TournamentTeamFactory";
import type { SeededBadges } from "./badges";
import type { SeededOrganization } from "./organizations";
import type { SeededTeams } from "./teams";
import type { SeededTrophies } from "./trophies";
import type { SeededUsers } from "./users";
/** Series the played-out tournaments of the past are named off. The four the seed
* puts in a state worth opening are named off a series of their own. */
const TOURNAMENT_NAME_STEMS = [
{ name: "PICNIC", avatarFileName: "picnic.png" },
{ name: "The Depths", avatarFileName: "the-depths.png" },
{ name: "Leagues Under The Ink", avatarFileName: "luti.png" },
];
const HISTORICAL_COUNT = 5;
/** Showcase users seeded into every played tournament, so their results paginate. */
const CORE_PLAYER_COUNT = 8;
/** Share of a tournament's teams that register as one of the site's teams, the rest
* being pickups put together for the tournament. */
const REGISTERED_TEAM_SHARE = 0.4;
/** Solo players looking for a team in a tournament whose registration has closed. */
const SUB_COUNT = 7;
/** One team's registration: the site team it registers as, when it is one of them. */
type Roster = {
teamId: number | null;
name: string;
memberUserIds: number[];
};
type Progression = TournamentSettings["bracketProgression"];
const DOUBLE_ELIMINATION: Progression = [
{
type: "double_elimination",
name: "Main Bracket",
requiresCheckIn: false,
settings: {},
},
];
const DOUBLE_ELIMINATION_WITH_UNDERGROUND: Progression = [
...DOUBLE_ELIMINATION,
{
type: "single_elimination",
name: "Underground Bracket",
requiresCheckIn: false,
settings: {},
sources: [{ bracketIdx: 0, placements: [-1, -2] }],
},
];
const SINGLE_ELIMINATION: Progression = [
{
type: "single_elimination",
name: "Bracket",
requiresCheckIn: false,
settings: { thirdPlaceMatch: true },
},
];
const ROUND_ROBIN_TO_SINGLE_ELIMINATION: Progression = [
{
type: "round_robin",
name: "Groups Stage",
requiresCheckIn: false,
settings: {},
},
{
type: "single_elimination",
name: "Final Stage",
requiresCheckIn: false,
settings: {},
sources: [{ bracketIdx: 0, placements: [1, 2] }],
},
];
const ROUND_ROBIN_TO_TOP_CUT_AND_LOWER: Progression = [
...ROUND_ROBIN_TO_SINGLE_ELIMINATION,
{
type: "single_elimination",
name: "Lower Bracket",
requiresCheckIn: false,
settings: {},
sources: [{ bracketIdx: 0, placements: [3, 4] }],
},
];
const SWISS_TO_SINGLE_ELIMINATION: Progression = [
{
type: "swiss",
name: "Swiss",
requiresCheckIn: false,
settings: { groupCount: 1, roundCount: 4 },
},
{
type: "single_elimination",
name: "Top Cut",
requiresCheckIn: false,
settings: {},
sources: [{ bracketIdx: 0, placements: [1, 2, 3, 4] }],
},
];
export type SeededTournaments = {
/** The one with registration still open, which the notifications are about. */
regOpen: { id: number; name: string };
/** Teams N-ZAP played on in the tournaments that were played to the end. */
nzapTeamIds: number[];
};
export async function seedTournaments({
users,
organizations,
badges,
teams,
trophies,
}: {
users: SeededUsers;
organizations: SeededOrganization[];
badges: SeededBadges;
teams: SeededTeams;
trophies: SeededTrophies;
}): Promise<SeededTournaments> {
const rosters = rosterBuilder(users, teams);
const inTheZone = await seedInTheZone({
users,
organizations,
rosters,
trophies,
});
await seedPaddlingPool({ users, organizations, rosters });
await seedLowInk({ users, organizations, rosters });
await seedSwimOrSink({ users, organizations, rosters });
const nzapTeamIds = await seedHistoricalTournaments({
users,
organizations,
badges,
rosters,
trophies,
});
return { regOpen: inTheZone, nzapTeamIds };
}
type Ctx = {
users: SeededUsers;
organizations: SeededOrganization[];
rosters: ReturnType<typeof rosterBuilder>;
};
/** #1 double elim, TO maps — reg open and a couple of days out, so it has both
* registered teams (some of them still short of a full roster) and LFG teams. */
async function seedInTheZone({
users,
organizations,
rosters,
trophies,
}: Ctx & { trophies: SeededTrophies }) {
const name = nameFor("In The Zone");
const tournament = await TournamentFactory.create({
name,
authorId: users.adminId,
organizationId: organizations[0]?.id,
avatarFileName: "in-the-zone.png",
startTimes: [dateToDatabaseTimestamp(daysFromNow(2))],
mapPickingStyle: "TO",
mapPoolMaps: toSetMapPool(),
bracketProgression: DOUBLE_ELIMINATION,
enableSubs: true,
trophyId: trophies.ids[0],
});
const teamRosters = rosters.take({
teamCount: 10,
teamSize: 4,
pinned: [{ teamIdx: 0, userId: users.adminId }],
});
for (const [i, roster] of teamRosters.entries()) {
await TournamentTeamFactory.create({
tournamentId: tournament.id,
team: fakeTeamProfile(roster),
// not every registered roster is full while reg is still open
memberUserIds:
i % 3 === 2 ? roster.memberUserIds.slice(0, 3) : roster.memberUserIds,
hasAvatar: roster.teamId === null && i % 4 === 0,
});
}
await seedTournamentExtras(tournament.id, users);
return { id: tournament.id, name };
}
/** #2 double elim with an underground bracket, AUTO_SZ, ranked — bracket started,
* not a single set reported yet. N-ZAP is seeded past the byes of the first round,
* so he has a match of his own going on. */
async function seedPaddlingPool({ users, rosters }: Ctx) {
const tournament = await TournamentFactory.create({
name: nameFor("Paddling Pool"),
authorId: users.adminId,
avatarFileName: "paddling-pool.png",
startTimes: [dateToDatabaseTimestamp(hoursAgo(1))],
mapPickingStyle: "AUTO_SZ",
bracketProgression: DOUBLE_ELIMINATION_WITH_UNDERGROUND,
isRanked: true,
});
const teamRosters = rosters.take({
teamCount: 12,
teamSize: 4,
pinned: [{ teamIdx: 4, userId: users.nzapId }],
});
await registerTeams({
tournamentId: tournament.id,
rosters: teamRosters,
isCheckedIn: true,
mapPool: () => counterpickMapPool("AUTO_SZ"),
});
await TournamentFactory.startBracket(tournament.id);
await seedSubs(tournament.id, users);
}
/** #3 swiss → SE, TO maps — swiss played to the end, the top cut waiting to be started. */
async function seedLowInk({ users, rosters }: Ctx) {
const tournament = await TournamentFactory.create({
name: nameFor("Low Ink"),
authorId: users.adminId,
startTimes: [dateToDatabaseTimestamp(hoursAgo(4))],
mapPickingStyle: "TO",
mapPoolMaps: toSetMapPool(),
bracketProgression: SWISS_TO_SINGLE_ELIMINATION,
swissGroupCount: 1,
swissRoundCount: 4,
});
const teamRosters = rosters.take({
teamCount: 8,
teamSize: 4,
pinned: [{ teamIdx: 0, userId: users.nzapId }],
});
await registerTeams({
tournamentId: tournament.id,
rosters: teamRosters,
isCheckedIn: true,
});
await TournamentFactory.playOut(tournament.id, 0);
}
/** #4 round robin → SE, TO maps — everybody checked in, first bracket not started.
* The one upcoming tournament N-ZAP is not registered in, so it is his saved one. */
async function seedSwimOrSink({ users, rosters }: Ctx) {
const tournament = await TournamentFactory.create({
name: nameFor("Swim or Sink"),
authorId: users.adminId,
avatarFileName: "swim-or-sink.png",
startTimes: [dateToDatabaseTimestamp(hoursAgo(1))],
mapPickingStyle: "TO",
mapPoolMaps: toSetMapPool(),
bracketProgression: ROUND_ROBIN_TO_SINGLE_ELIMINATION,
teamsPerGroup: 4,
});
const teamRosters = rosters.take({ teamCount: 12, teamSize: 4 });
await registerTeams({
tournamentId: tournament.id,
rosters: teamRosters,
isCheckedIn: true,
});
await SavedCalendarEventFactory.create({
userId: users.nzapId,
tournamentId: tournament.id,
});
}
async function seedHistoricalTournaments({
users,
badges,
rosters,
trophies,
}: Ctx & { badges: SeededBadges; trophies: SeededTrophies }) {
const nzapTeamIds: number[] = [];
const seriesLogoImgIds = new Map<string, number>();
for (let i = 0; i < HISTORICAL_COUNT; i++) {
const progression = faker.helpers.weightedArrayElement([
{ value: DOUBLE_ELIMINATION, weight: 5 },
{ value: ROUND_ROBIN_TO_SINGLE_ELIMINATION, weight: 3 },
{ value: SINGLE_ELIMINATION, weight: 1 },
{ value: DOUBLE_ELIMINATION_WITH_UNDERGROUND, weight: 1 },
{ value: ROUND_ROBIN_TO_TOP_CUT_AND_LOWER, weight: 1 },
]);
// recent ones ranked and within the front page's week-long results window
const isRecent = i < 3;
const startsAt = isRecent
? sub(new Date(), { days: 1 + i, hours: 3 })
: sub(new Date(), { months: 1 + (i % 8), days: (i * 7) % 28 });
const badgeId = i % 3 === 0 ? badges.ids[i % badges.ids.length] : undefined;
const stem = TOURNAMENT_NAME_STEMS[i % TOURNAMENT_NAME_STEMS.length];
const authorId = faker.helpers.arrayElement(users.showcaseIds);
const tournament = await TournamentFactory.create(
{
name: nameFor(stem.name),
avatarImgId: await seriesLogoImgId(seriesLogoImgIds, stem, authorId),
authorId,
startTimes: [dateToDatabaseTimestamp(startsAt)],
mapPickingStyle: isRecent ? "AUTO_SZ" : "AUTO_ALL",
mapPoolMaps: isRecent ? undefined : tiebreakerMapPool(),
bracketProgression: progression,
teamsPerGroup: 4,
isRanked: isRecent,
badges: badgeId ? [badgeId] : [],
trophyId: trophies.ids[i % trophies.ids.length],
},
{ tier: ((i % 3) + 1) as TournamentTierNumber },
);
// on the top seed of the first one, so that a win of his is finalized, and
// further down another, so his result list is not all first places
const nzapRosterIdx = i === 0 ? 0 : i === 2 ? 5 : null;
const teamRosters = rosters.take({
teamCount: 8,
teamSize: 4,
pinned:
nzapRosterIdx !== null
? [{ teamIdx: nzapRosterIdx, userId: users.nzapId }]
: [],
});
const teams = await registerTeams({
tournamentId: tournament.id,
rosters: teamRosters,
isCheckedIn: true,
registeredAt: sub(startsAt, { days: 2 }),
mapPool: () => counterpickMapPool(isRecent ? "AUTO_SZ" : "AUTO_ALL"),
});
if (nzapRosterIdx !== null) {
nzapTeamIds.push(teams[nzapRosterIdx].id);
}
await TournamentFactory.playOut(tournament.id, "all");
}
return nzapTeamIds;
}
/** Every edition of a series shares the one logo image of it, an image row not being
* allowed the url of another. */
async function seriesLogoImgId(
imgIds: Map<string, number>,
stem: (typeof TOURNAMENT_NAME_STEMS)[number],
authorId: number,
) {
const existing = imgIds.get(stem.name);
if (existing) return existing;
const image = await ImageFactory.create(
{ submitterUserId: authorId, url: stem.avatarFileName },
{ isValidated: true },
);
imgIds.set(stem.name, image.id);
return image.id;
}
/** Solo players looking to sub in a tournament whose registration has closed, the
* admin among them so that the state of having posted is one of the two profiles'.
* Drawn from the tail of the crowd, which no tournament roster reaches. */
async function seedSubs(tournamentId: number, users: SeededUsers) {
const userIds = [
users.adminId,
...users.crowdIds.slice(300, 300 + SUB_COUNT - 1),
];
for (const [i, userId] of userIds.entries()) {
await TournamentLFGTeamFactory.create({
tournamentId,
userId,
isStayAsSub: true,
lfgNote: i % 3 === 0 ? undefined : showcaseNames.postText(),
});
}
}
async function seedTournamentExtras(tournamentId: number, users: SeededUsers) {
for (const twitchAccount of ["sendou", "nzap_stream"]) {
await TournamentStreamerFactory.create({ tournamentId, twitchAccount });
}
const lfgUserIds = [users.nzapId, ...users.showcaseIds.slice(90, 95)];
const lfgTeamIds: number[] = [];
for (const [i, userId] of lfgUserIds.entries()) {
const team = await TournamentLFGTeamFactory.create(
{ tournamentId, userId },
{ likedTeamIds: lfgTeamIds.slice(0, i % 3) },
);
lfgTeamIds.push(team.id);
}
}
async function registerTeams({
tournamentId,
rosters,
isCheckedIn,
registeredAt,
mapPool,
}: {
tournamentId: number;
rosters: Roster[];
isCheckedIn?: boolean;
registeredAt?: Date;
mapPool?: () => MapPool;
}) {
const teams = [];
for (const [i, roster] of rosters.entries()) {
teams.push(
await TournamentTeamFactory.create(
{
tournamentId,
team: fakeTeamProfile(roster),
memberUserIds: roster.memberUserIds,
mapPool: mapPool?.(),
registeredAt,
// a team of the site shows its own logo when the registration has none
hasAvatar: roster.teamId === null && i % 5 === 0,
},
{ isCheckedIn },
),
);
}
return teams;
}
function rosterBuilder(users: SeededUsers, teams: SeededTeams) {
const corePlayers = users.showcaseIds.slice(0, CORE_PLAYER_COUNT);
const pool = [
...users.showcaseIds.slice(CORE_PLAYER_COUNT),
...users.crowdIds.slice(0, 300),
];
return {
/** Rosters for one tournament: some of the site's teams registering as
* themselves, core players spread over the rest, and the remaining seats drawn
* without replacement within the tournament. A `pinned` user is added to a
* roster of their own as its owner, and kept out of everybody else's. */
take({
teamCount,
teamSize,
pinned = [],
}: {
teamCount: number;
teamSize: number;
pinned?: Array<{ teamIdx: number; userId: number }>;
}): Roster[] {
const pinnedUserIds = new Set(pinned.map((pin) => pin.userId));
const registering = faker.helpers
.shuffle(
teams.squads.filter((squad) =>
squad.memberUserIds.every((id) => !pinnedUserIds.has(id)),
),
)
.slice(0, Math.round(teamCount * REGISTERED_TEAM_SHARE));
// a tournament can not have two teams of the same name
const takenNames = new Set(registering.map((squad) => squad.name));
const takenUserIds = new Set([
...pinnedUserIds,
...registering.flatMap((squad) => squad.memberUserIds),
]);
const isFree = (userId: number) => !takenUserIds.has(userId);
const shuffled = faker.helpers.shuffle(pool.filter(isFree));
const freeCorePlayers = corePlayers.filter(isFree);
// the teams of the site take the first team slots a pin does not want
const pinnedIdxs = new Set(pinned.map((pin) => pin.teamIdx));
const registeringIdxs = Array.from({ length: teamCount }, (_, i) => i)
.filter((i) => !pinnedIdxs.has(i))
.slice(0, registering.length);
return Array.from({ length: teamCount }, (_, i) => {
const registeringIdx = registeringIdxs.indexOf(i);
if (registeringIdx !== -1) {
const squad = registering[registeringIdx];
return {
teamId: squad.teamId,
name: squad.name,
memberUserIds: squad.memberUserIds.slice(0, teamSize),
};
}
const memberUserIds: number[] = [];
if (teamSize >= 2 && freeCorePlayers.length > 0) {
memberUserIds.push(freeCorePlayers.shift()!);
}
while (memberUserIds.length < teamSize) {
memberUserIds.push(shuffled.pop()!);
}
const pin = pinned.find((pin) => pin.teamIdx === i);
if (pin) {
memberUserIds.unshift(pin.userId);
}
return {
teamId: null,
name: pickupTeamName(takenNames),
memberUserIds,
};
});
},
};
}
/** A name for a team put together for one tournament, taken by no other team of it. */
function pickupTeamName(takenNames: Set<string>) {
let name = unique(() => showcaseNames.teamName());
while (takenNames.has(name)) {
name = unique(() => showcaseNames.teamName());
}
takenNames.add(name);
return name;
}
function fakeTeamProfile(roster: Roster) {
return {
name: roster.name,
prefersNotToHost: faker.number.float(1) < 0.2 ? (1 as const) : (0 as const),
teamId: roster.teamId,
};
}
/** Series name and the edition of it this tournament is, as they are named. */
function nameFor(stem: string) {
return `${stem} ${faker.number.int({ min: 2, max: 120 })}`;
}
function toSetMapPool() {
return mapsPerMode(7);
}
function tiebreakerMapPool() {
return mapsPerMode(1);
}
function mapsPerMode(count: number) {
return rankedModesShort.flatMap((mode) =>
legalStages(mode)
.slice(0, count)
.map((stageId) => ({ mode, stageId })),
);
}
function counterpickMapPool(style: "AUTO_SZ" | "AUTO_ALL") {
const pairs =
style === "AUTO_SZ"
? faker.helpers
.arrayElements(legalStages("SZ"), 6)
.map((stageId) => ({ mode: "SZ" as const, stageId }))
: rankedModesShort.flatMap((mode) =>
faker.helpers
.arrayElements(legalStages(mode), 2)
.map((stageId) => ({ mode, stageId })),
);
return new MapPool(pairs);
}
function legalStages(mode: ModeShort): StageId[] {
return stageIds.filter((stageId) => !BANNED_MAPS[mode].includes(stageId));
}
function daysFromNow(days: number) {
return new Date(Date.now() + days * 24 * 60 * 60 * 1000);
}
function hoursAgo(hours: number) {
return new Date(Date.now() - hours * 60 * 60 * 1000);
}

View File

@@ -0,0 +1,125 @@
import * as TrophyRepository from "~/features/trophies/TrophyRepository.server";
import {
SUPPORTER_TROPHY_CODE,
XP_TROPHY_CODE_PREFIX,
} from "~/features/trophies/trophies-constants";
import { faker } from "../core/faker";
import trophies from "../data/trophies.json";
import * as TrophyFactory from "../factories/TrophyFactory";
import type { SeededOrganization } from "./organizations";
import type { SeededUsers } from "./users";
const PENDING_COUNT = 5;
const PARTIALLY_APPROVED_COUNT = 2;
const ACCEPTED_COUNT = 3;
const DECLINED_COUNT = 3;
export type SeededTrophies = {
/** Ids of the trophies tournaments can be given as a prize. */
ids: number[];
};
export async function seedTrophies({
users,
organizations,
}: {
users: SeededUsers;
organizations: SeededOrganization[];
}): Promise<SeededTrophies> {
const ids: number[] = [];
for (const [name, model] of Object.entries(trophies)) {
const trophy = await TrophyFactory.create({
name,
model,
organizationId: organizations[0]?.id ?? null,
creatorId: users.adminId,
managerId: users.nzapId,
});
ids.push(trophy.id);
}
await seedPendingTrophies({ users, organizations });
return { ids };
}
/**
* Creates the trophies awarded off something other than a tournament win and hands
* them to everybody eligible, as the nightly sync does. Runs last of the seed: who
* is eligible follows from the patrons and X Rank placements seeded before it.
*/
export async function seedSpecialTrophies() {
const models = TrophyFactory.MODELS;
await TrophyFactory.create({
name: "Supporter",
model: models[0],
code: SUPPORTER_TROPHY_CODE,
});
await TrophyFactory.create({
name: "3000 X Power",
model: models[1],
code: `${XP_TROPHY_CODE_PREFIX}3000`,
});
await TrophyFactory.create({
name: "2600 X Power",
model: models[2],
code: `${XP_TROPHY_CODE_PREFIX}2600`,
});
await TrophyRepository.syncSpecialTrophies();
}
async function seedPendingTrophies({
users,
organizations,
}: {
users: SeededUsers;
organizations: SeededOrganization[];
}) {
const organizationId = organizations[0].id;
const submitterIds = users.showcaseIds.slice(0, 10);
const submission = (index: number) => ({
organizationId,
submitterUserId: faker.helpers.arrayElement(submitterIds),
description: faker.lorem.sentence(),
name: `Pending trophy ${index + 1}`,
});
await TrophyFactory.createManyPending(PENDING_COUNT, (index) =>
submission(index),
);
await TrophyFactory.createManyPending(
PARTIALLY_APPROVED_COUNT,
(index) => ({
...submission(index),
name: `Partially approved trophy ${index + 1}`,
}),
{ approverUserIds: [users.adminId] },
);
await TrophyFactory.createManyPending(
ACCEPTED_COUNT,
(index) => ({
...submission(index),
name: `Accepted trophy ${index + 1}`,
}),
{ approverUserIds: [users.adminId, users.staffId, users.orgAdminId] },
);
await TrophyFactory.createManyPending(
DECLINED_COUNT,
(index) => ({
...submission(index),
name: `Declined trophy ${index + 1}`,
}),
{
declinedBy: {
userId: users.adminId,
reason: faker.lorem.sentence(),
},
},
);
}

View File

@@ -0,0 +1,362 @@
import type { UserMapModePreferences } from "~/db/tables-json";
import { ADMIN_DISCORD_ID } from "~/features/admin/admin-constants";
import { BANNED_MAPS } from "~/features/match-profile/banned-maps";
import { AMOUNT_OF_MAPS_IN_POOL_PER_MODE } from "~/features/match-profile/match-profile-constants";
import { LUTI_DIVS } from "~/features/scrims/scrims-constants";
import { PRESET_COLORS } from "~/features/tier-list-maker/tier-list-maker-constants";
import type { UnifiedLanguageCode } from "~/modules/i18n/config";
import { modesShort } from "~/modules/in-game-lists/modes";
import { stageIds } from "~/modules/in-game-lists/stage-ids";
import {
ADMIN_TEST_AVATAR,
NZAP_TEST_AVATAR,
NZAP_TEST_DISCORD_ID,
STAFF_TEST_DISCORD_ID,
} from "../constants";
import { faker } from "../core/faker";
import * as SplatoonFaker from "../core/SplatoonFaker";
import * as showcaseNames from "../core/showcaseNames";
import * as UserFactory from "../factories/UserFactory";
const SHOWCASE_COUNT = 100;
const CROWD_COUNT = 396;
export type SeededUsers = {
adminId: number;
nzapId: number;
orgAdminId: number;
staffId: number;
/** The curated first ~100, wired into everything later modules seed. */
showcaseIds: number[];
crowdIds: number[];
/** Showcase users with the artist role, some with commissions open. */
artistIds: number[];
/** Showcase users whose profile can gain favorite badges without losing anything. */
favoriteBadgeUserIds: number[];
};
export async function seedUsers(): Promise<SeededUsers> {
// the plainest of the two profiles: no supporter perks, so the old profile page
const admin = await UserFactory.createAdmin(
{
discordId: ADMIN_DISCORD_ID,
discordName: "Sendou",
discordUniqueName: "sendou",
discordAvatar: ADMIN_TEST_AVATAR,
twitch: "Sendou",
profile: {
country: "FI",
customUrl: "sendou",
inGameName: "Sendou#1234",
bio: showcaseNames.postText(),
weapons: [{ weaponSplId: 200, isFavorite: 0 }],
},
friendCode: "0109-8080-3707",
},
{
roles: ["VIDEO_ADDER", "TOURNAMENT_ORGANIZER", "ARTIST"],
matchProfile: { mapModePreferences: fakePreferences(), vc: "YES" },
},
);
const nzap = await UserFactory.createRegular(
{
discordId: NZAP_TEST_DISCORD_ID,
discordName: "N-ZAP",
discordUniqueName: "nzap",
discordAvatar: NZAP_TEST_AVATAR,
twitch: "nzap_stream",
youtubeId: "UCWbJLXByvsfQvTcR4HLPs5Q",
bsky: "nzap.bsky.social",
profile: {
country: "SE",
customUrl: "nzap",
motionSens: 50,
stickSens: 5,
pronouns: JSON.stringify({ subject: "they", object: "them" }),
inGameName: "N-ZAP#5678",
bio: showcaseNames.maxLengthBio(),
weapons: ([200, 1100, 2000, 4000] as const).map((weaponSplId) => ({
weaponSplId,
isFavorite: 0 as const,
})),
},
friendCode: "1234-5678-9012",
},
{
patronTier: 2,
roles: ["VIDEO_ADDER", "TOURNAMENT_ORGANIZER", "ARTIST"],
matchProfile: {
mapModePreferences: fakePreferences(),
vc: "YES",
languages: ["en", "ja"],
weaponPool: SplatoonFaker.mainWeapons(4).map((id) => ({
id,
isFavorite: false,
})),
},
card: { shortBio: "Supporter of sendou.ink" },
preferences: { newProfileEnabled: true },
widgets: nzapWidgets(),
},
);
const orgAdmin = await UserFactory.createOrgAdmin(null, {
roles: ["API_ACCESSER"],
});
const showcase = await seedShowcaseUsers();
const crowd = await UserFactory.createMany(CROWD_COUNT);
for (const [i, user] of crowd.entries()) {
if (i % 2 === 0) continue;
await UserFactory.grant(user.id, {
matchProfile: {
weaponPool: SplatoonFaker.mainWeapons(
faker.helpers.arrayElement([1, 2, 3, 4]),
).map((id) => ({ id, isFavorite: faker.number.float(1) < 0.3 })),
},
});
}
const staff = await UserFactory.createStaff({
discordId: STAFF_TEST_DISCORD_ID,
discordName: "Panda",
});
return {
adminId: admin.id,
nzapId: nzap.id,
orgAdminId: orgAdmin.id,
staffId: staff.id,
showcaseIds: showcase.ids,
crowdIds: crowd.map((user) => user.id),
artistIds: showcase.artistIds,
favoriteBadgeUserIds: showcase.favoriteBadgeUserIds,
};
}
async function seedShowcaseUsers() {
const ids: number[] = [];
const artistIds: number[] = [];
const favoriteBadgeUserIds: number[] = [];
for (const [i, customName] of showcaseNames.CUSTOM_NAMES.entries()) {
const hasKanji = /[一-龯]/u.test(customName);
const user = await UserFactory.create(
{
profile: {
customName,
inGameName: hasKanji
? showcaseNames.kanaInGameName()
: SplatoonFaker.inGameName(),
bio: i === 0 ? showcaseNames.maxLengthBio() : undefined,
weapons: [],
},
},
showcaseOptions(),
);
ids.push(user.id);
favoriteBadgeUserIds.push(user.id);
}
const noProfileUser = await UserFactory.create({
profile: null,
friendCode: null,
});
ids.push(noProfileUser.id);
const maximalUser = await UserFactory.create(
{
profile: {
customName: showcaseNames.customName(),
customUrl: "maximal",
country: "JP",
bio: showcaseNames.maxLengthBio(),
pronouns: JSON.stringify({ subject: "they", object: "them" }),
motionSens: -25,
stickSens: 10,
inGameName: showcaseNames.kanaInGameName(),
weapons: SplatoonFaker.mainWeapons(5).map((weaponSplId) => ({
weaponSplId,
isFavorite: 1,
})),
},
},
{
...showcaseOptions(),
patronTier: 2,
card: {
shortBio: faker.lorem.sentence(),
bannerPresetImg: String(faker.helpers.arrayElement(stageIds)),
unverifiedPeakXP: fakePeakXp(),
},
},
);
ids.push(maximalUser.id);
for (let i = ids.length; i < SHOWCASE_COUNT; i++) {
const isArtist = artistIds.length < 12;
const commissionsOpen = isArtist && faker.number.float(1) < 0.5;
const user = await UserFactory.create(
{
profile: {
customName:
faker.number.float(1) < 0.6
? showcaseNames.customName()
: undefined,
customUrl: faker.number.float(1) < 0.2 ? `showcase-${i}` : undefined,
country:
faker.number.float(1) < 0.8 ? UserFactory.fakeCountry() : undefined,
bio:
faker.number.float(1) < 0.5 ? showcaseNames.postText() : undefined,
inGameName:
faker.number.float(1) < 0.4
? showcaseNames.kanaInGameName()
: SplatoonFaker.inGameName(),
commissionsOpen: commissionsOpen ? 1 : undefined,
commissionText: commissionsOpen ? faker.lorem.paragraph() : undefined,
weapons: SplatoonFaker.mainWeapons(
faker.helpers.arrayElement([1, 2, 3, 4]),
).map((weaponSplId) => ({
weaponSplId,
isFavorite: faker.number.float(1) < 0.2 ? 1 : 0,
})),
},
},
{
...showcaseOptions(),
roles: isArtist ? ["ARTIST"] : undefined,
},
);
ids.push(user.id);
if (isArtist) {
artistIds.push(user.id);
}
}
return { ids, artistIds, favoriteBadgeUserIds };
}
/** Widget profile of the one seeded supporter: both slots filled, and every widget
* whose content other modules seed onto N-ZAP. */
function nzapWidgets(): NonNullable<
Parameters<typeof UserFactory.create>[1]
>["widgets"] {
return [
{ id: "bio-md", settings: { bio: showcaseNames.maxLengthBio() } },
{ id: "teams" },
{ id: "organizations" },
{ id: "patron-since" },
{
id: "sens",
settings: { controller: "s2-pro-con", motionSens: 50, stickSens: 5 },
},
{ id: "timezone", settings: { timezone: "Europe/Stockholm" } },
{ id: "social-links" },
{ id: "weapon-pool" },
{ id: "badges-owned" },
{ id: "trophies-owned" },
{ id: "builds" },
{ id: "videos" },
{ id: "art", settings: { source: "ALL" } },
{ id: "x-rank-peaks", settings: { division: "both" } },
{ id: "peak-sp" },
{ id: "peak-xp" },
{ id: "friends" },
{ id: "highlighted-results" },
];
}
function showcaseOptions(): Parameters<typeof UserFactory.create>[1] {
return {
matchProfile: {
mapModePreferences: fakePreferences(),
vc: faker.helpers.arrayElement([
"YES",
"YES",
"YES",
"NO",
"LISTEN_ONLY",
]),
languages: fakeLanguages(),
weaponPool: SplatoonFaker.mainWeapons(
faker.helpers.arrayElement([1, 2, 3, 4]),
).map((id) => ({ id, isFavorite: faker.number.float(1) < 0.3 })),
},
div:
faker.number.float(1) < 0.6
? faker.helpers.arrayElement(LUTI_DIVS)
: undefined,
card: {
shortBio: faker.number.float(1) < 0.6 ? faker.lorem.sentence() : null,
bannerPresetImg: fakeBannerPresetImg(),
unverifiedPeakXP: faker.number.float(1) < 0.4 ? fakePeakXp() : null,
},
};
}
function fakeLanguages(): UnifiedLanguageCode[] {
const languages: UnifiedLanguageCode[] =
faker.number.float(1) > 0.1 ? ["en"] : [];
for (const language of ["es", "fr", "de", "it", "ja"] as const) {
if (faker.number.float(1) > 0.9) languages.push(language);
}
return languages;
}
function fakePreferences(): UserMapModePreferences {
const modes: UserMapModePreferences["modes"] = modesShort.flatMap((mode) => {
if (faker.number.float(1) > 0.5 && mode !== "SZ") return [];
return {
mode,
preference:
faker.number.float(1) > (mode === "SZ" ? 0.2 : 0.5)
? ("PREFER" as const)
: ("AVOID" as const),
};
});
return {
modes,
pool: modesShort.flatMap((mode) => {
const preference = modes.find((m) => m.mode === mode);
if (preference?.preference === "AVOID") return [];
return {
mode,
stages: faker.helpers
.shuffle(stageIds)
.filter((stageId) => !BANNED_MAPS[mode].includes(stageId))
.slice(0, AMOUNT_OF_MAPS_IN_POOL_PER_MODE),
};
}),
};
}
/** Self-reported peak XP with exactly one division defined, as the column expects. */
function fakePeakXp() {
const points = faker.number.int({ min: 2000, max: 3500 });
const isTentatek = faker.datatype.boolean();
return {
overall: points,
tentatek: isTentatek ? points : null,
takoroka: isTentatek ? null : points,
};
}
/** Mix of the three banner sources: null, a stage banner, an explicit color. */
function fakeBannerPresetImg() {
const roll = faker.number.float(1);
if (roll < 0.34) return null;
if (roll < 0.67) return String(faker.helpers.arrayElement(stageIds));
return faker.helpers.arrayElement(PRESET_COLORS);
}

View File

@@ -0,0 +1,64 @@
import { videoMatchTypes } from "~/features/vods/vods-constants";
import {
secondsToHoursMinutesSecondString,
youtubeIdToYoutubeUrl,
} from "~/features/vods/vods-utils";
import { faker } from "../core/faker";
import * as SplatoonFaker from "../core/SplatoonFaker";
import * as VodFactory from "../factories/VodFactory";
import type { SeededUsers } from "./users";
const VOD_COUNT = 6;
const REAL_YOUTUBE_ID = "M4aV-BQWlVg";
export async function seedVods(users: SeededUsers) {
await VodFactory.create({
type: "TOURNAMENT",
youtubeUrl: youtubeIdToYoutubeUrl(REAL_YOUTUBE_ID),
date: { day: 2, month: 2, year: 2023 },
submitterUserId: users.nzapId,
title: "LUTI Division X Tournament - ABBF (THRONE) vs. Ascension",
pov: { type: "USER", userId: users.nzapId },
matches: fakeMatches(7),
});
for (let i = 0; i < VOD_COUNT - 1; i++) {
const type = videoMatchTypes[i % videoMatchTypes.length];
const povUserId = faker.helpers.arrayElement(users.showcaseIds);
await VodFactory.create({
type,
submitterUserId:
i % 4 === 0
? users.nzapId
: faker.helpers.arrayElement(users.showcaseIds),
date: {
day: faker.number.int({ min: 1, max: 28 }),
month: faker.number.int({ min: 0, max: 11 }),
year: faker.helpers.arrayElement([2023, 2024, 2025]),
},
pov:
type === "CAST"
? undefined
: faker.number.float(1) < 0.8
? { type: "USER", userId: povUserId }
: { type: "NAME", name: faker.person.firstName() },
matches: fakeMatches(faker.helpers.arrayElement([3, 4, 5, 6])),
});
}
}
function fakeMatches(count: number) {
let secondsAt = 13;
return SplatoonFaker.mapList(count).map((map) => {
const startsAt = secondsToHoursMinutesSecondString(secondsAt);
secondsAt += faker.number.int({ min: 180, max: 500 });
return {
...map,
startsAt,
weapons: SplatoonFaker.mainWeapons(faker.number.float(1) < 0.3 ? 8 : 1),
};
});
}

View File

@@ -0,0 +1,19 @@
import * as ApiRepository from "~/features/api/ApiRepository.server";
import type { ApiTokenType } from "~/features/api/api-types";
import { defineFactory } from "../core/defineFactory";
type InsertArgs = {
userId: number;
type: ApiTokenType;
};
/**
* Creates API tokens. `userId` is whose token it is — note that having one and being
* allowed to use it are separate things, the permission coming from the user's roles.
* The token itself is the repository's own.
*/
export const { create } = defineFactory({
defaults: () => ({ type: "read" as const }),
insert: ({ userId, type }: InsertArgs) =>
ApiRepository.generateToken(userId, type),
});

View File

@@ -0,0 +1,26 @@
import * as ArtRepository from "~/features/art/ArtRepository.server";
import { databaseTimestampNow } from "~/utils/dates";
import { actAs } from "../core/actAs";
import { defineFactory } from "../core/defineFactory";
type InsertArgs = Parameters<typeof ArtRepository.insert>[0] & {
authorId: number;
};
/**
* Creates art.
*
* Validated by default, as art uploaded by a patron is, so that the listings
* reading through the validated-images view can see it.
*/
export const { create, createMany } = defineFactory({
defaults: ({ seq }) => ({
url: `art-${seq}.png`,
validatedAt: databaseTimestampNow(),
description: null,
linkedUsers: [] as number[],
tags: [] as InsertArgs["tags"],
}),
insert: ({ authorId, ...args }: InsertArgs) =>
actAs(authorId, () => ArtRepository.insert(args)),
});

View File

@@ -0,0 +1,24 @@
import * as AssociationRepository from "~/features/associations/AssociationRepository.server";
import { defineFactory } from "../core/defineFactory";
import { faker } from "../core/faker";
type Options = {
/** Members besides the owner, who is an admin of the association regardless. */
memberUserIds?: number[];
};
/** Creates associations. `userId` is the owner, added as its admin by the repository. */
export const { create } = defineFactory({
defaults: () => ({
name: faker.company.name(),
}),
insert: AssociationRepository.insert,
applyOptions: async (association, { memberUserIds }: Options) => {
for (const userId of memberUserIds ?? []) {
await AssociationRepository.insertMember({
associationId: association.id,
userId,
});
}
},
});

View File

@@ -0,0 +1,29 @@
import * as BadgeRepository from "~/features/badges/BadgeRepository.server";
import { defineFactory } from "../core/defineFactory";
import { faker } from "../core/faker";
type Options = {
/** Who has won the badge; repeat an id for multiple wins. */
ownerIds?: number[];
/** Who can assign the badge to tournaments and winners. */
managerIds?: number[];
};
export const { create, createMany } = defineFactory({
defaults: ({ seq }) => ({
code: `badge-${seq}`,
displayName: faker.lorem.words(2),
hue: null,
authorId: null,
}),
insert: BadgeRepository.insert,
applyOptions: async (badge, { ownerIds, managerIds }: Options) => {
if (ownerIds?.length) {
await BadgeRepository.replaceOwners({ badgeId: badge.id, ownerIds });
}
if (managerIds?.length) {
await BadgeRepository.replaceManagers({ badgeId: badge.id, managerIds });
}
},
});

View File

@@ -0,0 +1,34 @@
import * as BuildRepository from "~/features/builds/BuildRepository.server";
import { modesShort } from "~/modules/in-game-lists/modes";
import { defineFactory } from "../core/defineFactory";
import { faker } from "../core/faker";
import * as SplatoonFaker from "../core/SplatoonFaker";
/**
* Creates builds. `ownerId` is whose build it is. The ability and weapon rows every
* build listing is read through are what the repository derives from `abilities` and
* `weaponSplIds`; a multi-weapon build is `weaponSplIds` with more than one entry.
*/
const NO_GEAR = {
headGearSplId: null,
clothesGearSplId: null,
shoesGearSplId: null,
};
export const { create, createMany } = defineFactory({
defaults: () => ({
title: faker.lorem.words(3),
description: faker.number.float(1) < 0.4 ? faker.lorem.paragraph() : null,
modes:
faker.number.float(1) < 0.7
? faker.helpers.arrayElements(modesShort, { min: 1, max: 3 })
: null,
...(faker.number.float(1) < 0.85 ? SplatoonFaker.gear() : NO_GEAR),
weaponSplIds: SplatoonFaker.mainWeapons(
faker.helpers.arrayElement([1, 1, 1, 1, 2, 2, 3, 4, 5]),
),
abilities: SplatoonFaker.buildAbilities(),
isPrivate: 0 as const,
}),
insert: BuildRepository.insert,
});

View File

@@ -0,0 +1,70 @@
import * as CalendarRepository from "~/features/calendar/CalendarRepository.server";
import { tags } from "~/features/calendar/calendar-constants";
import { databaseTimestampNow } from "~/utils/dates";
import { defineFactory } from "../core/defineFactory";
import { faker } from "../core/faker";
import * as ImageFactory from "./ImageFactory";
type EventTags = NonNullable<
Parameters<typeof CalendarRepository.insert>[0]["tags"]
>;
type InsertArgs = Omit<
Parameters<typeof CalendarRepository.insert>[0],
"isFullTournament" | "bracketProgression" | "mapPickingStyle"
>;
/**
* What every calendar event is defaulted to, tournaments included — a tournament is
* a calendar event with one attached, see `TournamentFactory`.
*/
export const eventDefaults = () => ({
name: faker.company.name(),
description: faker.number.float(1) < 0.4 ? faker.lorem.paragraph() : null,
discordInviteCode: faker.number.float(1) < 0.3 ? faker.lorem.word() : null,
bracketUrl: faker.internet.url(),
organizationId: null,
tags: fakeTags(),
badges: [],
rules: null,
startTimes: [databaseTimestampNow()],
});
function fakeTags(): EventTags | null {
if (faker.number.float(1) < 0.5) return null;
return faker.helpers.arrayElements(Object.keys(tags) as EventTags, {
min: 1,
max: 3,
});
}
type Options = {
/** Gives the event a logo, submitted by its author the way one is in production. */
hasAvatar?: boolean;
};
export const { create } = defineFactory({
defaults: eventDefaults,
insert: async ({ hasAvatar, ...args }: InsertArgs & Options) => {
const avatarImgId = hasAvatar
? (
await ImageFactory.create(
{ submitterUserId: args.authorId },
{ isValidated: true },
)
).id
: args.avatarImgId;
const { eventId } = await CalendarRepository.insert({
...args,
avatarImgId,
isFullTournament: false,
bracketProgression: null,
// only read for events with a tournament of their own
mapPickingStyle: "AUTO_ALL",
});
return { id: eventId };
},
});

View File

@@ -0,0 +1,26 @@
import * as CalendarRepository from "~/features/calendar/CalendarRepository.server";
import { defineFactory } from "../core/defineFactory";
import { faker } from "../core/faker";
/**
* Creates reported results for a non-tournament calendar event, the way the event's
* organizer reports them. `results` decides the placements and who played.
*
* Returns the result teams as they were created, so that a caller can pick one of
* them (a result to highlight, say) by who played on it.
*/
export const { create } = defineFactory({
defaults: () => ({
participantCount: faker.number.int({ min: 10, max: 250 }),
}),
insert: async (
args: Parameters<typeof CalendarRepository.upsertReportedScores>[0],
) => {
await CalendarRepository.upsertReportedScores(args);
return {
eventId: args.eventId,
teams: await CalendarRepository.findResultsByEventId(args.eventId),
};
},
});

View File

@@ -0,0 +1,11 @@
import * as FriendRepository from "~/features/friends/FriendRepository.server";
import { defineFactory } from "../core/defineFactory";
/**
* Creates pending friend requests: `senderId` has asked `receiverId` to be friends.
* A request that was accepted is a friendship instead, see `FriendshipFactory`.
*/
export const { create, createMany } = defineFactory({
defaults: () => ({}),
insert: FriendRepository.insertFriendRequest,
});

View File

@@ -0,0 +1,28 @@
import * as FriendRepository from "~/features/friends/FriendRepository.server";
import { defineFactory } from "../core/defineFactory";
type InsertArgs = Omit<
Parameters<typeof FriendRepository.insertFriendship>[0],
"friendRequestId"
>;
/**
* Creates friendships between `userOneId` and `userTwoId`, in the order production
* makes them: the request one sent the other is created first and consumed by the
* friendship. Which of the two is stored as the first user is the repository's own.
*/
export const { create } = defineFactory({
defaults: () => ({}),
insert: async ({ userOneId, userTwoId }: InsertArgs) => {
const request = await FriendRepository.insertFriendRequest({
senderId: userOneId,
receiverId: userTwoId,
});
return FriendRepository.insertFriendship({
userOneId,
userTwoId,
friendRequestId: request.id,
});
},
});

View File

@@ -0,0 +1,22 @@
import * as GroupMatchContinueVoteRepository from "~/features/sendouq-match/GroupMatchContinueVoteRepository.server";
import { actAs } from "../core/actAs";
import { defineFactory } from "../core/defineFactory";
type InsertArgs = Parameters<
typeof GroupMatchContinueVoteRepository.castOwnVote
>[0] & {
/** The group member whose vote it is, on whose behalf it is cast. */
userId: number;
};
/**
* Creates the votes a SendouQ group casts on carrying on with the same teammates
* after a match. A vote against clears the group's votes in favour, since those
* were for carrying on at a size the group no longer has — the repository's own
* doing, which is why the votes go through it in the order they were cast.
*/
export const { create } = defineFactory({
defaults: () => ({ isContinuing: true }),
insert: ({ userId, ...args }: InsertArgs) =>
actAs(userId, () => GroupMatchContinueVoteRepository.castOwnVote(args)),
});

View File

@@ -0,0 +1,33 @@
import * as ImageRepository from "~/features/img-upload/ImageRepository.server";
import { numberedLogoFilename } from "../../../../scripts/seed-art-urls";
import { defineFactory } from "../core/defineFactory";
type InsertArgs = Parameters<typeof ImageRepository.insert>[0];
type Options = {
/** Has an admin approved the image, making it show wherever it is used? */
isValidated: boolean;
};
/**
* Creates user submitted images, unvalidated as the repository function makes them.
* An image on its own is an orphan — the queries counting images for approval only
* see it once something (a calendar event avatar, a team logo) points at it.
*
* The url is one of the numbered logos seeded to the local image storage, so that an
* image the caller does not name renders in dev instead of 404ing.
*
* Art brings its own image, see `ArtFactory`.
*/
export const { create } = defineFactory({
defaults: ({ seq }) => ({
url: numberedLogoFilename(seq),
}),
insert: (args: Omit<InsertArgs, "validatedAt">) =>
ImageRepository.insert({ ...args, validatedAt: null }),
applyOptions: async (image, { isValidated }: Options) => {
if (!isValidated) return;
await ImageRepository.validateById(image.id);
},
});

View File

@@ -0,0 +1,20 @@
import * as LFGRepository from "~/features/lfg/LFGRepository.server";
import { TIMEZONES } from "~/features/lfg/lfg-constants";
import { defineFactory } from "../core/defineFactory";
import { faker } from "../core/faker";
/** Creates LFG posts. `authorId` is who is looking; team-flavored types take a `teamId`. */
export const { create } = defineFactory({
defaults: () => ({
text: faker.lorem.paragraphs({ min: 1, max: 4 }),
timezone: faker.helpers.arrayElement(TIMEZONES),
type: faker.helpers.arrayElement([
"PLAYER_FOR_TEAM" as const,
"COACH_FOR_TEAM" as const,
]),
teamId: null,
plusTierVisibility: null,
languages: null,
}),
insert: LFGRepository.insertPost,
});

View File

@@ -0,0 +1,29 @@
import type { Tables } from "~/db/tables";
import * as LiveStreamRepository from "~/features/live-streams/LiveStreamRepository.server";
import { faker } from "../core/faker";
type Stream = Omit<Tables["LiveStream"], "id">;
type StreamOverrides = Partial<Stream> & Pick<Stream, "userId">;
/**
* Replaces the live streams with one per entry, the same write the twitch poller
* does. A later call replaces the earlier's streams, so seed them all at once.
*/
export function replaceAll(streams: StreamOverrides[]) {
return LiveStreamRepository.replaceAll(streams.map(fillStream));
}
function fillStream(overrides: StreamOverrides): Stream {
return {
viewerCount: faker.helpers.weightedArrayElement([
{ value: faker.number.int({ min: 5, max: 30 }), weight: 5 },
{ value: faker.number.int({ min: 31, max: 100 }), weight: 3 },
{ value: faker.number.int({ min: 101, max: 500 }), weight: 2 },
{ value: faker.number.int({ min: 501, max: 2000 }), weight: 1 },
]),
thumbnailUrl: faker.image.urlPicsumPhotos({ width: 320, height: 180 }),
twitch: `stream_${overrides.userId}`,
...overrides,
};
}

View File

@@ -0,0 +1,11 @@
import * as LogInLinkRepository from "~/features/auth/LogInLinkRepository.server";
import { defineFactory } from "../core/defineFactory";
/**
* Creates the single use links the log in with a code flow hands out. `userId` is
* who the link logs in; its code and expiry are the repository's own.
*/
export const { create } = defineFactory({
insert: ({ userId }: { userId: number }) =>
LogInLinkRepository.insert(userId),
});

View File

@@ -0,0 +1,26 @@
import type { TablesInsertable } from "~/db/tables";
import * as NotificationRepository from "~/features/notifications/NotificationRepository.server";
import type { Notification } from "~/features/notifications/notifications-types";
import { backdate } from "../core/backdate";
import { defineFactory } from "../core/defineFactory";
type InsertArgs = {
notification: Notification;
/** Who receives it, and whether they have seen it. */
users: Array<Omit<TablesInsertable["NotificationUser"], "notificationId">>;
};
type Options = {
/** When the notification was sent, for one that should look older than now. */
createdAt?: Date;
};
/** Creates notifications, delivered to `users` the way `notify` delivers them. */
export const { create } = defineFactory({
defaults: () => ({}),
insert: ({ notification, users }: InsertArgs) =>
NotificationRepository.insert(notification, users),
applyOptions: async (notification, { createdAt }: Options) => {
await backdate("Notification", notification.id, { createdAt });
},
});

View File

@@ -0,0 +1,28 @@
import * as PlusSuggestionRepository from "~/features/plus-suggestions/PlusSuggestionRepository.server";
import {
nextNonCompletedVoting,
rangeToMonthYear,
} from "~/features/plus-voting/core";
import invariant from "~/utils/invariant";
import { defineFactory } from "../core/defineFactory";
import { faker } from "../core/faker";
/**
* Creates plus server suggestions, `authorId` suggesting `suggestedId` for `tier`.
* Defaults to the upcoming voting's month, i.e. a suggestion that is currently open.
*/
export const { create } = defineFactory({
defaults: () => ({
...upcomingVotingMonthYear(),
tier: 1,
text: faker.lorem.lines(),
}),
insert: PlusSuggestionRepository.insert,
});
function upcomingVotingMonthYear() {
const range = nextNonCompletedVoting(new Date());
invariant(range, "No next voting found");
return rangeToMonthYear(range);
}

View File

@@ -0,0 +1,51 @@
import { sub } from "date-fns";
import { db } from "~/db/sql";
import * as AdminRepository from "~/features/admin/AdminRepository.server";
import { lastCompletedVoting } from "~/features/plus-voting/core/voting-time";
import type { UpsertManyPlusVotesArgs } from "~/features/plus-voting/PlusVotingRepository.server";
import * as PlusVotingRepository from "~/features/plus-voting/PlusVotingRepository.server";
import { PLUS_UPVOTE } from "~/features/plus-voting/plus-voting-constants";
import { dateToDatabaseTimestamp } from "~/utils/dates";
import { defineFactory } from "../core/defineFactory";
type Vote = UpsertManyPlusVotesArgs[number];
const VOTING_ENDED_AGO = { minutes: 5 };
/**
* Creates plus server votes, `authorId` being who cast the vote and `votedId` who it
* was cast on. Defaults to a vote of the latest completed voting, which is one that
* already counts towards the plus tiers.
*/
export const { create, createMany } = defineFactory({
defaults: () => ({
...lastCompletedVoting(new Date()),
tier: 1,
score: PLUS_UPVOTE,
becomesValidAt: dateToDatabaseTimestamp(sub(new Date(), VOTING_ENDED_AGO)),
}),
insert: async (vote: Vote) => {
// `upsertMany` replaces every vote its author cast that month, being one
// submission of the voting form, so the votes cast before this one are read
// back and sent along
const alreadyCast = await db
.selectFrom("PlusVote")
.selectAll()
.where("authorId", "=", vote.authorId)
.where("month", "=", vote.month)
.where("year", "=", vote.year)
.execute();
await PlusVotingRepository.upsertMany([...alreadyCast, vote]);
return vote;
},
});
/** Recounts the plus tiers from the latest completed voting, as the monthly sync does. */
export async function syncTiers() {
const tiers = await PlusVotingRepository.findAllPlusTiersFromLatestVoting();
if (tiers.length === 0) return;
await AdminRepository.replacePlusTiers(tiers);
}

View File

@@ -0,0 +1,19 @@
import * as UserRepository from "~/features/user-page/UserRepository.server";
import { actAs } from "../core/actAs";
type ReplaceAllArgs = {
userId: number;
/** Calendar event result teams the user played on. */
resultTeamIds: number[];
/** Tournament teams the user played on. */
resultTournamentTeamIds: number[];
};
/**
* Replaces the results a user has highlighted on their profile, the same write the
* highlight picking page does. A later call replaces the earlier's highlights, so
* seed them all at once.
*/
export function replaceAll({ userId, ...args }: ReplaceAllArgs) {
return actAs(userId, () => UserRepository.updateOwnResultHighlights(args));
}

View File

@@ -0,0 +1,70 @@
import { db } from "~/db/sql";
import * as SQGroupRepository from "~/features/sendouq/SQGroupRepository.server";
import invariant from "~/utils/invariant";
import { defineFactory } from "../core/defineFactory";
type InsertArgs = Omit<
Parameters<typeof SQGroupRepository.insert>[0],
"userId"
> & {
/** The group's members, the first of them its creator. */
memberUserIds: number[];
};
type Options = {
/** Was the group made in the matchmaking UI? */
isMatchmade?: boolean;
/** Groups that have liked this one. */
likedByGroupIds?: number[];
};
/**
* Creates SendouQ groups. The first of `memberUserIds` is the creator, whose
* membership the repository creates with the group; the rest join it the way they do
* in production. Invite and chat codes are the repository's own.
*/
export const { create } = defineFactory({
defaults: () => ({
status: "ACTIVE" as const,
}),
insert: async ({ memberUserIds, ...args }: InsertArgs) => {
const [creatorUserId, ...otherMemberUserIds] = memberUserIds;
invariant(creatorUserId, "A group needs at least one member");
const group = await SQGroupRepository.insert({
...args,
userId: creatorUserId,
});
for (const userId of otherMemberUserIds) {
await SQGroupRepository.insertMember(group.id, { userId });
}
return { id: group.id, memberUserIds };
},
applyOptions: async (group, { isMatchmade, likedByGroupIds }: Options) => {
for (const likerGroupId of likedByGroupIds ?? []) {
const liker = await db
.selectFrom("GroupMember")
.select("GroupMember.userId")
.where("GroupMember.groupId", "=", likerGroupId)
.executeTakeFirstOrThrow();
await SQGroupRepository.insertLike({
likerGroupId,
targetGroupId: group.id,
createdByUserId: liker.userId,
});
}
if (isMatchmade) {
// written directly because the only production write of the column is
// `morphGroups`, which needs two separate groups to merge into one
await db
.updateTable("Group")
.set({ matchmade: 1 })
.where("id", "=", group.id)
.execute();
}
},
});

View File

@@ -0,0 +1,225 @@
import { db } from "~/db/sql";
import { SENDOUQ_BEST_OF } from "~/features/sendouq/q-constants";
import * as SQMatchRepository from "~/features/sendouq-match/SQMatchRepository.server";
import invariant from "~/utils/invariant";
import { actAs } from "../core/actAs";
import { backdate } from "../core/backdate";
import { defineFactory } from "../core/defineFactory";
import * as SplatoonFaker from "../core/SplatoonFaker";
import * as SQGroupFactory from "./SQGroupFactory";
type InsertArgs = Omit<
Parameters<typeof SQMatchRepository.insert>[0],
"alphaGroupId" | "bravoGroupId"
> & {
/** Members of the alpha group, the first of them its owner. */
alphaUserIds: number[];
/** Members of the bravo group, the first of them its owner. */
bravoUserIds: number[];
/** Were the two groups made in the matchmaking UI, rather than by inviting? */
isMatchmade?: boolean;
};
type Group = Awaited<ReturnType<typeof SQGroupFactory.create>>;
/** One team's account of why the match was canceled. */
type CancelReport = {
reason: string;
/** Players of either team nominated as a cause of the cancellation. */
nominatedUserIds: number[];
};
type Options = {
/** Play the match out, alpha winning every map, up to both teams having agreed
* on the score. Leaves both groups inactive, as a real concluded match does. */
isConcluded?: boolean;
/** Cancel the match the way the two teams do: alpha's owner requests the
* cancellation and bravo's owner accepts it, each giving their own account. */
cancel?: { requested: CancelReport; accepted: CancelReport };
/** Cancel the match the way staff does, leaving neither team an account of it. */
canceledByStaffUserId?: number;
/** Play the match out, alpha winning every map, and report the score as alpha —
* leaving bravo still to confirm it. Alpha's group goes inactive, as reporting
* makes it, so its members are free to queue again. */
isReported?: boolean;
/** When the match was made, for one that should look older than now. */
createdAt?: Date;
/** When the two groups had agreed on the score. Needs `isConcluded`. */
confirmedAt?: Date;
};
/**
* Creates SendouQ matches together with the two groups playing them: a match is
* only ever made out of two full groups, the way the matchmaking UI makes one, so
* the groups are not the caller's to bring. Both are returned with the match.
*/
export const { create } = defineFactory({
defaults: () => ({
mapList: SplatoonFaker.mapList(SENDOUQ_BEST_OF).map((map) => ({
...map,
source: "BOTH" as const,
})),
memento: { users: {}, groups: {}, pools: [] },
}),
insert: async ({
alphaUserIds,
bravoUserIds,
isMatchmade,
...args
}: InsertArgs) => {
const alphaGroup = await SQGroupFactory.create(
{ memberUserIds: alphaUserIds },
{ isMatchmade },
);
const bravoGroup = await SQGroupFactory.create(
{ memberUserIds: bravoUserIds },
{ isMatchmade },
);
const match = await SQMatchRepository.insert({
...args,
alphaGroupId: alphaGroup.id,
bravoGroupId: bravoGroup.id,
});
return { ...match, alphaGroup, bravoGroup };
},
applyOptions: async (
match,
{
isConcluded,
isReported,
cancel,
canceledByStaffUserId,
createdAt,
confirmedAt,
}: Options,
) => {
if (isConcluded) {
await playOutMatch(match.id);
}
if (isReported) {
await reportMatch(match.id);
}
if (cancel) {
await cancelMatch(match, cancel);
}
if (canceledByStaffUserId) {
await cancelMatchAsStaff(match.id, canceledByStaffUserId);
}
await backdate("GroupMatch", match.id, { createdAt, confirmedAt });
if (createdAt) {
await backdateSkills(match.id, createdAt);
}
},
});
/** Concluding stamps the skill rows *now*; move them to when the match was played
* so the season progression chart spreads over days. */
async function backdateSkills(matchId: number, createdAt: Date) {
const skills = await db
.selectFrom("Skill")
.select("id")
.where("groupMatchId", "=", matchId)
.execute();
for (const skill of skills) {
await backdate("Skill", skill.id, { createdAt });
}
}
async function cancelMatch(
match: { id: number; alphaGroup: Group; bravoGroup: Group },
{ requested, accepted }: { requested: CancelReport; accepted: CancelReport },
) {
const request = await SQMatchRepository.requestCancelMatch({
matchId: match.id,
requestedByUserId: match.alphaGroup.memberUserIds[0],
...requested,
});
invariant(
request.status === "REQUESTED",
`Requesting the cancellation resulted in ${request.status}`,
);
const acceptance = await SQMatchRepository.acceptCancelMatch({
matchId: match.id,
acceptedByUserId: match.bravoGroup.memberUserIds[0],
...accepted,
});
invariant(
acceptance.status === "ACCEPTED",
`Accepting the cancellation resulted in ${acceptance.status}`,
);
}
async function cancelMatchAsStaff(matchId: number, staffUserId: number) {
const result = await actAs(staffUserId, () =>
SQMatchRepository.cancelMatch({ matchId, isAdminReport: true }),
);
invariant(
result.status === "CANCEL_CONFIRMED",
`Canceling the match as staff resulted in ${result.status}`,
);
}
async function playOutMatch(matchId: number) {
const { winnerId, reportedCount } = await reportMatch(matchId);
const match = await SQMatchRepository.findById(matchId);
invariant(match, "Match not found");
const confirmation = await SQMatchRepository.reportMapWinner({
matchId,
winnerId,
reportedByUserId: match.groupBravo.members[0].id,
reportedCount: reportedCount + 1,
});
invariant(
confirmation.status === "MATCH_FINALIZED",
`Confirming the score resulted in ${confirmation.status}`,
);
}
/** Reports every map as alpha, up to the score being in but not yet confirmed. */
async function reportMatch(matchId: number) {
const match = await SQMatchRepository.findById(matchId);
invariant(match, "Match not found");
const winnerId = match.groupAlpha.id;
const reportedByUserId = match.groupAlpha.members[0].id;
let reportedCount = 0;
let result = await SQMatchRepository.reportMapWinner({
matchId,
winnerId,
reportedByUserId,
reportedCount,
});
while (result.status === "MAP_REPORTED") {
reportedCount++;
result = await SQMatchRepository.reportMapWinner({
matchId,
winnerId,
reportedByUserId,
reportedCount,
});
}
invariant(
result.status === "MATCH_REPORTED",
`Reporting the deciding map resulted in ${result.status}`,
);
return { winnerId, reportedCount };
}

View File

@@ -0,0 +1,26 @@
import * as SQGroupRepository from "~/features/sendouq/SQGroupRepository.server";
import { defineFactory } from "../core/defineFactory";
type InsertArgs = Parameters<typeof SQGroupRepository.insertReadyCheck>[0];
type Options = {
/** Members who confirm they are ready right after the check starts. */
confirmedByUserIds?: number[];
};
/**
* Creates the ready check two matched up groups go through before their match is
* created. Both groups have to be active, since a ready check is what takes them
* out of the looking pool.
*/
export const { create } = defineFactory({
insert: (args: InsertArgs) => SQGroupRepository.insertReadyCheck(args),
applyOptions: async (readyCheck, { confirmedByUserIds }: Options) => {
for (const userId of confirmedByUserIds ?? []) {
await SQGroupRepository.insertReadyCheckConfirmation({
readyCheckId: readyCheck.id,
userId,
});
}
},
});

View File

@@ -0,0 +1,23 @@
import * as ReportedWeaponRepository from "~/features/sendouq-match/ReportedWeaponRepository.server";
import { actAs } from "../core/actAs";
import { defineFactory } from "../core/defineFactory";
import * as SplatoonFaker from "../core/SplatoonFaker";
type InsertArgs = Parameters<typeof ReportedWeaponRepository.upsertOwn>[0] & {
userId: number;
};
/**
* Creates the weapons a SendouQ match's players report having used. `userId` is the
* player whose weapon it was, on whose behalf it is reported.
*
* `mapIndex` is not defaulted: it is what identifies the row, so a second weapon
* without one would replace the first rather than add to it.
*/
export const { createMany } = defineFactory({
defaults: () => ({
weaponSplId: SplatoonFaker.mainWeapon(),
}),
insert: ({ userId, ...args }: InsertArgs) =>
actAs(userId, () => ReportedWeaponRepository.upsertOwn(args)),
});

View File

@@ -0,0 +1,23 @@
import * as SavedCalendarEventRepository from "~/features/tournament/SavedCalendarEventRepository.server";
import { actAs } from "../core/actAs";
import { defineFactory } from "../core/defineFactory";
type InsertArgs = {
/** Who saved the tournament. */
userId: number;
tournamentId: number;
};
/**
* Saves a tournament for a user, the way the star on its page does. Only a
* tournament can be saved, a calendar event without one having nothing to save.
*/
export const { create } = defineFactory({
insert: async ({ userId, tournamentId }: InsertArgs) => {
await actAs(userId, () =>
SavedCalendarEventRepository.saveOwn(tournamentId),
);
return { userId, tournamentId };
},
});

View File

@@ -0,0 +1,57 @@
import * as ScrimPostRepository from "~/features/scrims/ScrimPostRepository.server";
import { databaseTimestampNow } from "~/utils/dates";
import { defineFactory } from "../core/defineFactory";
type InsertRequestArgs = Parameters<
typeof ScrimPostRepository.insertRequest
>[0];
type Request = Pick<InsertRequestArgs, "users"> &
Partial<Pick<InsertRequestArgs, "startsAt">> & {
/** Books the scrim, the way accepting a request does in production. Only one
* request of a post may be accepted. */
isAccepted?: boolean;
};
type Options = {
/** Requests made to the post, created in the order given. */
requests?: Array<Request>;
};
/**
* Creates scrim posts. `users` is the side offering the scrim, one of them its owner.
* The requests made to the post, and which of them booked it, are `options`.
*/
export const { create } = defineFactory({
defaults: () => ({
startsAt: databaseTimestampNow(),
rangeEndsAt: null,
maxDiv: null,
minDiv: null,
teamId: null,
text: null,
maps: null,
mapsTournamentId: null,
visibility: null,
managedByAnyone: false,
isScheduledForFuture: false,
}),
insert: async (args: Parameters<typeof ScrimPostRepository.insert>[0]) => ({
id: await ScrimPostRepository.insert(args),
}),
applyOptions: async (post, { requests }: Options) => {
for (const request of requests ?? []) {
const requestId = await ScrimPostRepository.insertRequest({
scrimPostId: post.id,
teamId: null,
message: null,
startsAt: request.startsAt ?? null,
users: request.users,
});
if (request.isAccepted) {
await ScrimPostRepository.acceptRequest(requestId);
}
}
},
});

View File

@@ -0,0 +1,28 @@
import { rating } from "openskill";
import { db } from "~/db/sql";
import * as SkillRepository from "~/features/mmr/SkillRepository.server";
import { defineFactory } from "../core/defineFactory";
type Options = {
/** How many matches the skill has behind it, deciding if it counts for rankings. */
matchesCount: number;
};
/**
* Creates a user's starting skill of a season. `mu` is the rating the ordinal every
* ranking reads is derived from, so a user given a higher `mu` than another ranks
* above them.
*/
export const { create, createMany } = defineFactory({
defaults: () => ({ season: 1, ...rating() }),
insert: SkillRepository.addInitialSkill,
applyOptions: async (skill, { matchesCount }: Options) => {
// written directly because production only raises `matchesCount` while reporting
// a SendouQ match or finalizing a tournament, and a starting skill has neither
await db
.updateTable("Skill")
.set({ matchesCount })
.where("id", "=", skill.id)
.execute();
},
});

View File

@@ -0,0 +1,37 @@
import type { TablesInsertable } from "~/db/tables";
import * as SplatoonRotationRepository from "~/features/splatoon-rotations/SplatoonRotationRepository.server";
import { rankedModesShort } from "~/modules/in-game-lists/modes";
import { stageIds } from "~/modules/in-game-lists/stage-ids";
import { faker } from "../core/faker";
type Rotation = Omit<TablesInsertable["SplatoonRotation"], "id">;
const ROTATION_TYPES = ["SERIES", "OPEN", "X"] as const;
const ROTATIONS_PER_TYPE = 12;
const TWO_HOURS = 2 * 60 * 60;
/**
* Replaces the Splatoon rotations with a schedule starting from the current
* two-hour slot, the same write the rotation sync routine does.
*/
export function replaceAll() {
const nowUnix = Math.floor(Date.now() / 1000);
const currentSlotStartsAt = nowUnix - (nowUnix % TWO_HOURS);
const rotations: Rotation[] = ROTATION_TYPES.flatMap((type) =>
Array.from({ length: ROTATIONS_PER_TYPE }, (_, slot) => {
const [stageId1, stageId2] = faker.helpers.arrayElements(stageIds, 2);
return {
type,
mode: faker.helpers.arrayElement(rankedModesShort),
stageId1,
stageId2,
startsAt: currentSlotStartsAt + slot * TWO_HOURS,
endsAt: currentSlotStartsAt + (slot + 1) * TWO_HOURS,
};
}),
);
return SplatoonRotationRepository.replaceAll(rotations);
}

View File

@@ -0,0 +1,86 @@
import type { UserMapModePreferences } from "~/db/tables-json";
import * as TeamRepository from "~/features/team/TeamRepository.server";
import { TEAM } from "~/features/team/team-constants";
import invariant from "~/utils/invariant";
import { actAs } from "../core/actAs";
import { defineFactory } from "../core/defineFactory";
import * as ImageFactory from "./ImageFactory";
type InsertArgs = Omit<
Parameters<typeof TeamRepository.insert>[0],
"ownerUserId"
> & {
/** The team's members, the first of them its owner. */
memberUserIds: number[];
};
type Options = {
/** Gives the team a logo, submitted by its owner the way one is in production. */
hasAvatar?: boolean;
/** Filename of the logo; one seeded to the local image storage renders in dev. */
avatarUrl?: string;
/** SendouQ map & mode preferences, saved as the team edit page saves them. */
mapModePreferences?: UserMapModePreferences;
};
/**
* Creates teams. The first of `memberUserIds` is the owner, whose membership the
* repository creates with the team; the rest join it the way they do in production,
* within the team count a non-patron is allowed. Custom url and invite code are the
* repository's own, the custom url following from the name.
*/
export const { create } = defineFactory({
defaults: ({ seq }) => ({
name: `Team ${seq}`,
isMainTeam: true,
}),
insert: async ({ memberUserIds, ...args }: InsertArgs) => {
const [ownerUserId, ...otherMemberUserIds] = memberUserIds;
invariant(ownerUserId, "A team needs at least an owner");
const team = await TeamRepository.insert({ ...args, ownerUserId });
for (const userId of otherMemberUserIds) {
await actAs(userId, () =>
TeamRepository.insertOwnMembership({
teamId: team.id,
maxTeamsAllowed: TEAM.MAX_TEAM_COUNT_NON_PATRON,
}),
);
}
return { ...team, name: args.name, ownerUserId, memberUserIds };
},
applyOptions: async (
team,
{ hasAvatar, avatarUrl, mapModePreferences }: Options,
) => {
if (mapModePreferences) {
await TeamRepository.updateMapModePreferences({
id: team.id,
mapModePreferences,
});
}
if (!hasAvatar && !avatarUrl) return;
const image = await ImageFactory.create(
avatarUrl
? { submitterUserId: team.ownerUserId, url: avatarUrl }
: { submitterUserId: team.ownerUserId },
{ isValidated: true },
);
// the team edit page saves the whole profile at once; everything besides the
// name is still empty on a team the repository has only just inserted
await TeamRepository.update({
id: team.id,
name: team.name,
bio: null,
bsky: null,
tag: null,
avatarImgId: image.id,
bannerImgId: null,
});
},
});

View File

@@ -0,0 +1,502 @@
import * as R from "remeda";
import type { TournamentSettings } from "~/db/tables-json";
import * as CalendarRepository from "~/features/calendar/CalendarRepository.server";
import * as Standings from "~/features/tournament/core/Standings";
import type { TournamentTierNumber } from "~/features/tournament/core/tiering";
import * as TournamentRepository from "~/features/tournament/TournamentRepository.server";
import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server";
import * as BracketRepository from "~/features/tournament-bracket/BracketRepository.server";
import * as Engine from "~/features/tournament-bracket/core/engine";
import { finalizeTournament } from "~/features/tournament-bracket/core/finalizeTournament.server";
import {
clearTournamentDataCache,
tournamentFromDB,
} from "~/features/tournament-bracket/core/Tournament.server";
import { resolveMatchMapList } from "~/features/tournament-match/core/mapList.server";
import { reportScore } from "~/features/tournament-match/core/reportScore.server";
import * as TournamentMatchRepository from "~/features/tournament-match/TournamentMatchRepository.server";
import invariant from "~/utils/invariant";
import { defineFactory } from "../core/defineFactory";
import { eventDefaults } from "./CalendarEventFactory";
import * as TournamentTeamFactory from "./TournamentTeamFactory";
const SINGLE_ELIMINATION: TournamentSettings["bracketProgression"] = [
{
name: "Bracket",
type: "single_elimination",
requiresCheckIn: false,
settings: {
thirdPlaceMatch: false,
},
},
];
/** Every round of a bracket the factory starts is played on these, unless the
* caller passes a `maps` option. A bracket can not be created without a map list,
* and picking one is the organizer's job. */
const ROUND_MAPS = {
count: 3,
type: "BEST_OF",
list: ([1, 2, 3] as const).map((stageId) => ({
mode: "SZ" as const,
stageId,
})),
} satisfies RoundMaps;
/** The maps every round of a factory-started bracket is played on. */
export type RoundMaps = Omit<Engine.RoundMapsInput, "roundId">;
/** The wrapping calendar event is not the caller's to choose, so it is not an argument. */
type InsertArgs = Omit<
Parameters<typeof CalendarRepository.insert>[0],
"isFullTournament"
>;
type Options = {
/** Confirmed tier, as starting the first bracket computes one. */
tier?: TournamentTierNumber;
};
/** Brackets to play out fully: one by its idx in the progression, several, or
* `"all"` for every bracket followed by finalizing the tournament. */
type PlayedBrackets = number | number[] | "all";
/**
* Creates tournaments. Aggregate factory: the `CalendarEvent` wrapping the
* tournament and its start date are created with it, because there is no such thing
* as a tournament without one. Returns both ids.
*
* The bracket is a single elimination one unless `bracketProgression` says otherwise.
*/
export const { create } = defineFactory({
defaults: () => ({
...eventDefaults(),
mapPickingStyle: "TO" as const,
bracketProgression: SINGLE_ELIMINATION,
}),
insert: async (args: InsertArgs) => {
const { eventId, tournamentId } = await CalendarRepository.insert({
...args,
isFullTournament: true,
});
invariant(tournamentId, "Expected the tournament to be created");
return { id: tournamentId, eventId };
},
applyOptions: async (tournament, { tier }: Options) => {
if (!tier) return;
await TournamentRepository.updateTournamentTier({
tournamentId: tournament.id,
tier,
});
},
});
/**
* Creates a tournament that has been played. Every entry of `teamRosters` registers
* as a team owned by the first of its users and checks in, and the brackets
* `playedOut` names are played out off that seeding — the first bracket when not
* given, `"all"` for the whole tournament played and finalized.
*
* Returns the teams and the matches played alongside the tournament, so that a
* test can carry on from wherever `playedOut` left the tournament.
*/
export async function createPlayed(
overrides: Parameters<typeof create>[0],
{
teamRosters,
playedOut = 0,
maps,
...options
}: Options & {
teamRosters: number[][];
playedOut?: PlayedBrackets;
maps?: RoundMaps;
},
) {
const tournament = await create(overrides, options);
const teams: Awaited<ReturnType<typeof TournamentTeamFactory.create>>[] = [];
for (const memberUserIds of teamRosters) {
teams.push(
await TournamentTeamFactory.create(
{ tournamentId: tournament.id, memberUserIds },
{ isCheckedIn: true },
),
);
}
const matches = await playOut(tournament.id, playedOut, { maps });
return { ...tournament, teams, matches };
}
/**
* Plays brackets out fully, in the order given: each is started and every match of
* it played. `"all"` plays every bracket of the progression and then finalizes the
* tournament the same way the organizer's finalize button does: results on
* profiles, the tournament's badges awarded to its winning team, skills and
* leaderboard entries.
*
* Returns the matches played, in play order.
*/
export async function playOut(
tournamentId: number,
brackets: PlayedBrackets = 0,
{ maps }: { maps?: RoundMaps } = {},
): Promise<PlayedMatch[]> {
const tournament = await tournamentFromDB(tournamentId);
const bracketIdxs =
brackets === "all"
? tournament.ctx.settings.bracketProgression.map((_, idx) => idx)
: [brackets].flat();
const matches: PlayedMatch[] = [];
for (const bracketIdx of bracketIdxs) {
await startBracket(tournamentId, { bracketIdx, maps });
let progressed = true;
while (progressed) {
const playedThisPass = await playMatches(tournamentId);
matches.push(...playedThisPass);
progressed =
playedThisPass.length > 0 ||
(await generateNextSwissRound(tournamentId, bracketIdx));
}
}
if (brackets === "all") {
await finalize(tournamentId);
}
return matches;
}
/**
* Starts one of the tournament's brackets, seeded by the teams that are in it —
* the same teams the organizer would see offered on the bracket page. Every round
* is played on `maps`, or the factory's default SZ Bo3 list.
*
* Later brackets of a progression are started by calling this again once the
* matches they source their teams from have been played.
*
* Returns the matches the bracket was created with, in the generator's own order.
*/
export async function startBracket(
tournamentId: number,
{
bracketIdx = 0,
maps = ROUND_MAPS,
}: { bracketIdx?: number; maps?: RoundMaps } = {},
) {
const tournament = await tournamentFromDB(tournamentId);
const bracket = tournament.bracketByIdx(bracketIdx);
invariant(bracket, `Tournament has no bracket at index ${bracketIdx}`);
const seeding = bracket.seeding;
invariant(seeding?.length, `Bracket at index ${bracketIdx} has no teams`);
const createInput: Engine.CreateBracketInput = {
type: bracket.type,
seeding,
settings: bracket.settings,
};
await BracketRepository.insertBracket({
tournamentId,
name: bracket.name,
bracket: Engine.create({
...createInput,
maps: roundMapsFor(Engine.create(createInput), bracket.type, maps),
}),
});
await persistSeeds(tournament);
clearTournamentDataCache(tournamentId);
const started = await tournamentFromDB(tournamentId);
const startedBracket = started.bracketByIdx(bracketIdx);
invariant(startedBracket, `Bracket at index ${bracketIdx} was not created`);
return startedBracket.data.match.map((match) => ({ id: match.id }));
}
/**
* Marks a match as casted by a Twitch account, adding the account to the
* tournament's cast accounts first, the way the organizer's stream page does.
*/
export async function castMatch({
tournamentId,
matchId,
twitchAccount,
}: {
tournamentId: number;
matchId: number;
twitchAccount: string;
}) {
const tournament = await TournamentRepository.findById(tournamentId);
invariant(tournament, `Tournament ${tournamentId} not found`);
await TournamentRepository.updateCastTwitchAccounts({
tournamentId,
castTwitchAccounts: [
...(tournament.castTwitchAccounts ?? []),
twitchAccount,
],
});
await TournamentRepository.setMatchAsCasted({
tournamentId,
matchId,
twitchAccount,
});
clearTournamentDataCache(tournamentId);
}
interface PlayedMatch {
id: number;
/** Index of the bracket the match belongs to in the progression. */
bracketIdx: number;
/** Number of the bracket group the match belongs to, e.g. its round robin pool. */
groupNumber: number;
winnerTeamId: number;
loserTeamId: number;
}
/**
* Plays out every match both of whose teams are known, the higher seeded team
* winning each map of it. Every map goes through `reportScore`, the same function
* the match page reports through, so the bracket, the standings and the
* participation rows end up exactly as they do when the teams play it.
*
* One pass only: matches the played ones advance teams into are left for the next
* call, so a caller can stop after any round. `playOut` plays to the end.
*/
export async function playMatches(
tournamentId: number,
): Promise<PlayedMatch[]> {
const tournament = await tournamentFromDB(tournamentId);
const played = playableMatches(tournament);
for (const match of played) {
await setActiveRosters(tournamentId, match);
await playOutMatch(tournamentId, match);
}
clearTournamentDataCache(tournamentId);
return played;
}
/**
* Generates the matches of a swiss bracket's next round, each of its groups the
* same way the organizer's advance button does. Swiss pairs a round off the
* standings of the one before it, so the matches of a round only exist once the
* previous round has been played.
*
* Returns whether there was a round left to generate.
*/
async function generateNextSwissRound(
tournamentId: number,
bracketIdx: number,
) {
const tournament = await tournamentFromDB(tournamentId);
const bracket = tournament.bracketByIdx(bracketIdx);
if (bracket?.type !== "swiss" || bracket.preview) return false;
let generated = false;
for (const group of bracket.data.group) {
const groupsMatches = bracket.data.match.filter(
(match) => match.groupId === group.id,
);
const generatedRoundCount = new Set(
groupsMatches.map((match) => match.roundId),
).size;
if (generatedRoundCount >= bracket.swissRoundCount) continue;
const round = Engine.generateRound(bracket.data, {
groupId: group.id,
standings: bracket.standings,
settings: bracket.settings,
});
if (!round.ok) continue;
const stageId = groupsMatches[0]?.stageId;
invariant(stageId, `Swiss group ${group.id} has no matches`);
await BracketRepository.insertRoundMatches({
stageId,
round: round.value,
});
generated = true;
}
if (generated) {
clearTournamentDataCache(tournamentId);
}
return generated;
}
/** Writes the seeds the bracket was created off, the same way starting a bracket
* through the site does. Without them the seeds shown are recomputed from the teams'
* seeding skills every time, so they drift out of sync with the started bracket as
* soon as those change. */
async function persistSeeds(
tournament: Awaited<ReturnType<typeof tournamentFromDB>>,
) {
const isAllSeedsPersisted = tournament.ctx.teams.every(
(team) => typeof team.seed === "number",
);
if (isAllSeedsPersisted) return;
await TournamentRepository.updateTeamSeeds({
tournamentId: tournament.ctx.id,
teamIds: tournament.ctx.teams.map((team) => team.id),
});
}
function roundMapsFor(
bracket: Engine.BracketData,
type: Engine.StageType,
maps: RoundMaps,
): Engine.RoundMapsInput[] {
// round robin and swiss share one map list per round number across their groups
const rounds =
type === "round_robin" || type === "swiss"
? R.uniqueBy(bracket.round, (round) => round.number)
: bracket.round;
return rounds.map((round) => ({ roundId: round.id, ...maps }));
}
function playableMatches(
tournament: Awaited<ReturnType<typeof tournamentFromDB>>,
): PlayedMatch[] {
return tournament.brackets.flatMap((bracket, bracketIdx) => {
if (bracket.preview) return [];
const groupNumbers = new Map(
bracket.data.group.map((group) => [group.id, group.number]),
);
return bracket.data.match
.filter((match) => bracket.matchStatus(match.id) === "STARTED")
.flatMap((match) =>
match.opponent1?.id && match.opponent2?.id
? [
{
id: match.id,
bracketIdx,
groupNumber: groupNumbers.get(match.groupId)!,
winnerTeamId: match.opponent1.id,
loserTeamId: match.opponent2.id,
},
]
: [],
);
});
}
async function setActiveRosters(tournamentId: number, match: PlayedMatch) {
const tournament = await tournamentFromDB(tournamentId);
for (const teamId of [match.winnerTeamId, match.loserTeamId]) {
const team = tournament.teamById(teamId);
invariant(team, `Team ${teamId} is not in the tournament`);
invariant(
team.memberUserIds.length >= tournament.minMembersPerTeam,
`Team ${teamId} has too few members to play a match`,
);
// a team without subs plays with everybody it has, so it is never asked
if (team.memberUserIds.length === tournament.minMembersPerTeam) continue;
await TournamentTeamRepository.setActiveRoster({
teamId,
activeRosterUserIds: team.memberUserIds.slice(
0,
tournament.minMembersPerTeam,
),
});
}
}
async function playOutMatch(tournamentId: number, match: PlayedMatch) {
let position = 0;
let setOver = false;
while (!setOver) {
// rehydrated per map, the previous report having moved the score on
const tournament = await tournamentFromDB(tournamentId);
const matchRow = await findMatch(match.id);
const reported = await reportScore({
match: matchRow,
tournament,
mapList: await resolveMatchMapList({ match: matchRow, tournament }),
user: { id: tournament.ctx.author.id },
position,
winnerTeamId: match.winnerTeamId,
// brackets that count knockouts refuse a report without one
ko: false,
});
invariant(
reported,
`Map ${position} of match ${match.id} was already reported`,
);
setOver = reported.setOver;
position++;
}
}
async function finalize(tournamentId: number) {
const tournament = await tournamentFromDB(tournamentId);
const event = await CalendarRepository.findById(tournament.ctx.eventId, {
includeBadgePrizes: true,
includeTrophy: true,
});
const winner = winningTeam(tournament);
await finalizeTournament({
tournament,
badgeReceivers: event?.badgePrizes?.length
? event.badgePrizes.map((badge) => ({
badgeId: badge.id,
tournamentTeamId: winner.team.id,
userIds: winner.team.memberUserIds,
}))
: undefined,
trophyReceiver: event?.trophy
? {
trophyId: event.trophy.id,
userIds: winner.team.memberUserIds,
}
: undefined,
});
}
/** Whose the tournament's prizes are, as the organizer typically assigns them. */
function winningTeam(tournament: Awaited<ReturnType<typeof tournamentFromDB>>) {
const winner = Standings.flattenStandings(
Standings.tournamentStandings(tournament),
).find((standing) => standing.placement === 1);
invariant(winner, "Tournament to award prizes for has no winner");
return winner;
}
async function findMatch(matchId: number) {
const match = await TournamentMatchRepository.findMatchById(matchId);
invariant(match, `Match ${matchId} not found`);
return match;
}

View File

@@ -0,0 +1,19 @@
import * as TournamentLFGRepository from "~/features/tournament-lfg/TournamentLFGRepository.server";
import { defineFactory } from "../core/defineFactory";
type Options = {
/** Teams this team has liked in the LFG matchmaking view. */
likedTeamIds?: number[];
};
export const { create } = defineFactory({
insert: TournamentLFGRepository.insertPlaceholderTeam,
applyOptions: async (team, { likedTeamIds }: Options) => {
for (const targetTeamId of likedTeamIds ?? []) {
await TournamentLFGRepository.insertLike({
likerTeamId: team.id,
targetTeamId,
});
}
},
});

View File

@@ -0,0 +1,113 @@
import type { Tables } from "~/db/tables";
import * as TournamentOrganizationRepository from "~/features/tournament-organization/TournamentOrganizationRepository.server";
import invariant from "~/utils/invariant";
import { defineFactory } from "../core/defineFactory";
import * as ImageFactory from "./ImageFactory";
type Member = {
userId: number;
role: Tables["TournamentOrganizationMember"]["role"];
};
type InsertArgs = Parameters<typeof TournamentOrganizationRepository.insert>[0];
type UpdateArgs = Parameters<typeof TournamentOrganizationRepository.update>[0];
type Options = {
/** Members besides the owner, who is an admin of the organization regardless. */
members?: Array<Member>;
isEstablished?: boolean;
description?: string | null;
socials?: string[] | null;
series?: UpdateArgs["series"];
/** Filename of the logo; one seeded to the local image storage renders in dev. */
avatarFileName?: string;
};
/**
* Creates tournament organizations. `ownerId` is who owns it, added as its admin by
* the repository. Slug follows from the name, as it does in production.
*/
export const { create, createMany } = defineFactory({
defaults: ({ seq }) => ({
name: `Organization ${seq}`,
}),
insert: async (args: InsertArgs) => ({
...(await TournamentOrganizationRepository.insert(args)),
ownerId: args.ownerId,
}),
applyOptions: async (
org,
{
members,
isEstablished,
description,
socials,
series,
avatarFileName,
}: Options,
) => {
if (isEstablished) {
await TournamentOrganizationRepository.updateIsEstablished(org.id, true);
}
if (
members ||
description !== undefined ||
socials ||
series ||
avatarFileName
) {
await applyUpdate(org, {
members,
description,
socials,
series,
avatarFileName,
});
}
},
});
async function applyUpdate(
{ slug, ownerId }: { slug: string; ownerId: number },
{
members,
description,
socials,
series,
avatarFileName,
}: Omit<Options, "isEstablished">,
) {
const org = await TournamentOrganizationRepository.findBySlug(slug);
invariant(org, "Organization not found");
const avatar = avatarFileName
? await ImageFactory.create(
{ submitterUserId: ownerId, url: avatarFileName },
{ isValidated: true },
)
: null;
// the org edit page saves everything at once, so what exists already is read
// back and sent along
await TournamentOrganizationRepository.update({
id: org.id,
name: org.name,
description: description !== undefined ? description : org.description,
socials: socials ?? org.socials,
avatarImgId: avatar?.id,
members: [
...org.members.map((member) => ({
userId: member.id,
role: member.role,
roleDisplayName: member.roleDisplayName,
})),
...(members ?? []).map((member) => ({
...member,
roleDisplayName: null,
})),
],
series: series ?? [],
badges: [],
});
}

View File

@@ -0,0 +1,26 @@
import * as ReportedWeaponRepository from "~/features/sendouq-match/ReportedWeaponRepository.server";
import { actAs } from "../core/actAs";
import { defineFactory } from "../core/defineFactory";
import * as SplatoonFaker from "../core/SplatoonFaker";
type InsertArgs = Parameters<
typeof ReportedWeaponRepository.upsertOwnTournament
>[0] & {
/** The player whose weapon it was, on whose behalf it is reported. */
userId: number;
};
/**
* Creates the weapons a tournament match's players report having used. `createdAt`
* is an argument of the write itself: reporting is open long after the games were
* played, so the app stamps the weapon with the tournament's start instead of now.
*
* `mapIndex` is not defaulted, for the same reason it is not in `SQReportedWeaponFactory`.
*/
export const { createMany } = defineFactory({
defaults: () => ({
weaponSplId: SplatoonFaker.mainWeapon(),
}),
insert: ({ userId, ...args }: InsertArgs) =>
actAs(userId, () => ReportedWeaponRepository.upsertOwnTournament(args)),
});

View File

@@ -0,0 +1,24 @@
import * as LiveStreamRepository from "~/features/live-streams/LiveStreamRepository.server";
import invariant from "~/utils/invariant";
import { defineFactory } from "../core/defineFactory";
type InsertArgs = Parameters<
typeof LiveStreamRepository.insertTournamentStreamers
>[0][number];
export const { create } = defineFactory({
defaults: ({ seq }) => ({
twitchAccount: `streamer_${seq}`,
userId: null,
}),
insert: async (args: InsertArgs) => {
const [row] = await LiveStreamRepository.insertTournamentStreamers([args]);
invariant(
row,
`${args.twitchAccount} is already a streamer of tournament ${args.tournamentId}`,
);
return row;
},
});

View File

@@ -0,0 +1,101 @@
import type { MapPool } from "~/features/map-list-generator/core/map-pool";
import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server";
import * as TournamentLFGRepository from "~/features/tournament-lfg/TournamentLFGRepository.server";
import invariant from "~/utils/invariant";
import { actAs } from "../core/actAs";
import { backdate } from "../core/backdate";
import { defineFactory } from "../core/defineFactory";
import { faker } from "../core/faker";
import * as ImageFactory from "./ImageFactory";
type InsertArgs = Omit<
Parameters<typeof TournamentTeamRepository.insert>[0],
"userId" | "additionalMemberUserIds"
> & {
/** The team's members, the first of them its owner. */
memberUserIds: number[];
/** Gives the team a logo, submitted by its owner. */
hasAvatar?: boolean;
/** Counterpick map pool, for tournaments whose map picking style asks teams for one. */
mapPool?: MapPool;
/** When the team registered, for one that should look older than now. */
registeredAt?: Date;
};
type Options = {
/** Has the team checked in to the tournament? */
isCheckedIn?: boolean;
/** Is the team looking for more players on the tournament's LFG page? */
isLooking?: boolean;
};
/**
* Creates tournament teams. The first of `memberUserIds` is the owner, on whose
* behalf the team is registered; the rest are added to it the way they are in
* production. Invite code and in-game names are the repository's own.
*
* A player looking for a team without one to register is a placeholder team
* instead, see `TournamentLFGTeamFactory`.
*/
export const { create } = defineFactory({
defaults: () => ({
team: {
name: faker.company.name(),
prefersNotToHost: 0 as const,
teamId: null,
},
avatarImgId: null,
}),
insert: async ({
memberUserIds,
hasAvatar,
mapPool,
registeredAt,
...args
}: InsertArgs) => {
const [ownerUserId, ...additionalMemberUserIds] = memberUserIds;
invariant(ownerUserId, "A team needs at least an owner");
const avatarImgId = hasAvatar
? (
await ImageFactory.create(
{ submitterUserId: ownerUserId },
{ isValidated: true },
)
).id
: args.avatarImgId;
const team = await actAs(ownerUserId, () =>
TournamentTeamRepository.insert({
...args,
avatarImgId,
userId: ownerUserId,
additionalMemberUserIds,
}),
);
if (mapPool) {
await TournamentTeamRepository.upsertCounterpickMaps({
tournamentTeamId: team.id,
mapPool,
});
}
if (registeredAt) {
await backdate("TournamentTeam", team.id, { createdAt: registeredAt });
}
return { id: team.id, ownerUserId, memberUserIds };
},
applyOptions: async (team, { isCheckedIn, isLooking }: Options) => {
if (isCheckedIn) {
await actAs(team.ownerUserId, () =>
TournamentTeamRepository.checkIn(team.id),
);
}
if (isLooking) {
await TournamentLFGRepository.startLooking(team.id);
}
},
});

View File

@@ -0,0 +1,72 @@
import { db } from "~/db/sql";
import type { TablesInsertable } from "~/db/tables";
import * as TrophyRepository from "~/features/trophies/TrophyRepository.server";
import { defineFactory } from "../core/defineFactory";
// the e2e process loads this factory as plain ESM, where the attribute is required
import trophies from "../data/trophies.json" with { type: "json" };
/** Compressed model states of real trophies, one of which every trophy is given. */
export const MODELS = Object.values(trophies);
/**
* Creates trophies. A trophy only comes into existence in production once a
* submission has gathered its approvals, which `createPending` covers — one that
* merely has to exist is written directly. Winning one is `TournamentFactory`'s
* job: a trophy is awarded by finalizing the tournament it was the prize of.
*/
export const { create } = defineFactory({
defaults: ({ seq }) => ({
name: `Trophy ${seq}`,
model: MODELS[seq % MODELS.length],
code: null,
organizationId: null,
creatorId: null,
managerId: null,
}),
insert: (args: TablesInsertable["Trophy"]) =>
db
.insertInto("Trophy")
.values(args)
.returning("id")
.executeTakeFirstOrThrow(),
});
type PendingOptions = {
/** Who approves the submission; enough of them and the trophy is created. */
approverUserIds?: number[];
/** Who turns the submission down, and why. */
declinedBy?: { userId: number; reason: string };
};
/**
* Creates trophy submissions awaiting review. The options review the submission the
* way the review page does, so one can be brought to any point of its life.
*/
export const { create: createPending, createMany: createManyPending } =
defineFactory({
defaults: ({ seq }) => ({
name: `Pending trophy ${seq}`,
model: MODELS[seq % MODELS.length],
description: "",
}),
insert: TrophyRepository.createPending,
applyOptions: async (
pending,
{ approverUserIds, declinedBy }: PendingOptions,
) => {
for (const userId of approverUserIds ?? []) {
await TrophyRepository.addApproval({
pendingTrophyId: pending.id,
userId,
});
}
if (declinedBy) {
await TrophyRepository.declinePending({
id: pending.id,
reason: declinedBy.reason,
declinedByUserId: declinedBy.userId,
});
}
},
});

View File

@@ -0,0 +1,449 @@
import { add } from "date-fns";
import { db } from "~/db/sql";
import type { Tables } from "~/db/tables";
import type { CustomTheme, Pronouns, UserPreferences } from "~/db/tables-json";
import * as AdminRepository from "~/features/admin/AdminRepository.server";
import { ADMIN_ID } from "~/features/admin/admin-constants";
import * as MatchProfileRepository from "~/features/match-profile/MatchProfileRepository.server";
import * as UserCardRepository from "~/features/user-card/UserCardRepository.server";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import invariant from "~/utils/invariant";
import { toDBBoolean } from "~/utils/sql";
import {
ORG_ADMIN_TEST_ID,
REGULAR_USER_TEST_ID,
STAFF_TEST_ID,
} from "../constants";
import { actAs } from "../core/actAs";
import { defineFactory } from "../core/defineFactory";
import { faker, unique } from "../core/faker";
import { pinUserId } from "../core/pinUserId";
import * as SplatoonFaker from "../core/SplatoonFaker";
type UpsertArgs = Parameters<typeof UserRepository.upsert>[0] & {
/** Profile fields, saved as the profile page saves them. `null` for a bare profile. */
profile?: Partial<ProfileArgs> | null;
/** Nintendo friend code, `null` for a user who never submitted one. */
friendCode?: string | null;
};
type ProfileArgs = Parameters<typeof UserRepository.updateOwnProfile>[0];
type MatchProfileArgs = Parameters<
typeof MatchProfileRepository.updateOwnMatchProfile
>[0];
type Role = "ARTIST" | "VIDEO_ADDER" | "TOURNAMENT_ORGANIZER" | "API_ACCESSER";
type Options = {
plusTier?: Tables["PlusTier"]["tier"];
/** Patron who started supporting now and has a year left of it. */
patronTier?: NonNullable<Tables["User"]["patronTier"]>;
roles?: Array<Role>;
/** SendouQ match profile, submitted as the user themselves. */
matchProfile?: Partial<MatchProfileArgs>;
/** Ban as an admin lays one down: `1` for good, or the date it lifts on. */
ban?: Omit<Parameters<typeof AdminRepository.banUser>[0], "userId">;
/** Division the user played their last season in. */
div?: NonNullable<Tables["User"]["div"]>;
/** Weapon pool, submitted as the user themselves. */
weapons?: Parameters<typeof UserRepository.updateOwnProfile>[0]["weapons"];
/** User card fields, submitted as the user themselves. */
card?: Partial<CardArgs>;
/** Profile page widgets, replacing whatever the user had. Only shown to a user
* who is a supporter and has `newProfileEnabled` in their `preferences`. */
widgets?: Parameters<typeof UserRepository.upsertWidgets>[1];
/** Preferences, merged into the ones the user has, as the settings pages save them. */
preferences?: UserPreferences;
/** Custom theme, saved as the settings page saves it. Only shown to a supporter. */
customTheme?: CustomTheme;
};
type CardArgs = Parameters<typeof UserCardRepository.updateOwnCard>[0];
const EMPTY_CARD: CardArgs = {
shortBio: null,
bannerPresetImg: null,
bannerImgId: null,
unverifiedPeakXP: null,
xpDivision: null,
hiddenCardStats: [],
};
const PATRONAGE_LENGTH = { years: 1 };
const EMPTY_MATCH_PROFILE: MatchProfileArgs = {
mapModePreferences: { modes: [], pool: [] },
vc: "NO",
languages: [],
weaponPool: [],
noScreen: 0,
};
const GRANT_ROLE: Record<Role, (userId: number) => Promise<unknown>> = {
ARTIST: AdminRepository.makeArtistByUserId,
VIDEO_ADDER: AdminRepository.makeVideoAdderByUserId,
TOURNAMENT_ORGANIZER: AdminRepository.makeTournamentOrganizerByUserId,
API_ACCESSER: AdminRepository.makeApiAccesserByUserId,
};
/** Creates users. Columns outside `UserRepository.upsert` (patron status, plus tier,
* match profile) are set by the repository function that owns them. */
export const { create, createMany } = defineFactory({
defaults: ({ seq }) => ({
discordId: fakeDiscordId(seq),
discordName: unique(() => faker.internet.username()),
discordUniqueName: null,
discordAvatar: null,
twitch: null,
youtubeId: null,
bsky: null,
profile: fakeProfile(),
friendCode: fakeFriendCode(),
}),
insert: async ({ profile, friendCode, ...args }: UpsertArgs) => {
const user = await UserRepository.upsert(args);
if (profile && Object.keys(profile).length > 0) {
await updateProfile(user.id, profile);
}
if (friendCode) {
await UserRepository.insertFriendCode({
userId: user.id,
submitterUserId: user.id,
friendCode,
});
}
return user;
},
applyOptions: (user, options: Options) => grant(user.id, options),
});
function fakeFriendCode() {
return `${faker.string.numeric(4)}-${faker.string.numeric(4)}-${faker.string.numeric(4)}`;
}
/** A real-looking Discord snowflake: 42 bits of timestamp, `seq` in the low 22.
* Must stay 10+ characters — `userByIdentifierQuery` resolves shorter numeric
* identifiers as row ids, which would break `/u/<discordId>` links. */
function fakeDiscordId(seq: number) {
const DISCORD_EPOCH_MS = 1420070400000n;
const FAKE_CREATED_AT_MS = 1685577600000n; // 2023-06-01
return String(((FAKE_CREATED_AT_MS - DISCORD_EPOCH_MS) << 22n) + BigInt(seq));
}
/** Saves profile fields as the user, for a user that already exists. The profile page
* submits every field at once, prefilled with what the user has, so the fields the
* caller leaves out keep their current value instead of being cleared. */
export async function updateProfile(
userId: number,
profile: Partial<ProfileArgs>,
) {
const current = await currentProfile(userId);
return actAs(userId, () =>
UserRepository.updateOwnProfile({ ...current, ...profile }),
);
}
async function currentProfile(userId: number): Promise<ProfileArgs> {
const user = await db
.selectFrom("User")
.select([
"country",
"bio",
"customUrl",
"customName",
"motionSens",
"stickSens",
"pronouns",
"inGameName",
"battlefy",
"showDiscordUniqueName",
"commissionText",
"commissionsOpen",
"favoriteBadgeIds",
"favoriteTrophyIds",
"hiddenTrophyIds",
"customAvatarImgId",
])
.where("id", "=", userId)
.executeTakeFirstOrThrow();
const weapons = await db
.selectFrom("UserWeapon")
.select(["weaponSplId", "isFavorite"])
.where("userId", "=", userId)
.orderBy("order", "asc")
.execute();
return {
...user,
pronouns: user.pronouns ? JSON.stringify(user.pronouns) : null,
weapons,
};
}
/** Pronouns as the profile page saves them: the subject and object forms as one
* JSON object, not the `he/him` string they are displayed as. */
export function fakePronouns() {
return JSON.stringify(
faker.helpers.arrayElement([
{ subject: "he", object: "him" },
{ subject: "she", object: "her" },
{ subject: "they", object: "them" },
] satisfies Pronouns[]),
);
}
/** Country biased the way the player base is: US and the big European scenes
* first, anything possible. */
export function fakeCountry() {
return faker.helpers.weightedArrayElement([
{ value: "US", weight: 30 },
{ value: "FR", weight: 8 },
{ value: "DE", weight: 8 },
{ value: "GB", weight: 7 },
{ value: "ES", weight: 5 },
{ value: "IT", weight: 5 },
{ value: "NL", weight: 4 },
{ value: faker.location.countryCode(), weight: 33 },
]);
}
function fakeProfile(): Partial<ProfileArgs> | null {
if (faker.number.float(1) < 0.2) return null;
const chance = (probability: number) => faker.number.float(1) < probability;
return {
country: fakeCountry(),
bio: chance(0.4)
? faker.lorem.paragraphs(faker.helpers.arrayElement([1, 1, 2, 3]), "\n\n")
: undefined,
inGameName: chance(0.5) ? SplatoonFaker.inGameName() : undefined,
motionSens: chance(0.3)
? faker.helpers.arrayElement([-50, -30, -10, 0, 10, 30, 50])
: undefined,
stickSens: chance(0.3)
? faker.helpers.arrayElement([-50, -20, 0, 20, 50])
: undefined,
pronouns: chance(0.2) ? fakePronouns() : undefined,
weapons: chance(0.6)
? SplatoonFaker.mainWeapons(
faker.helpers.arrayElement([1, 2, 3, 4, 5]),
).map((weaponSplId) => ({
weaponSplId,
isFavorite: toDBBoolean(faker.number.float(1) < 0.2),
}))
: [],
};
}
/**
* Creates the admin user, on the id the app's permission logic treats as an admin.
* Also who `wrappedAction({ user: "admin" })` submits as.
*
* Has to be created before any other user, see {@link pinUserId}.
*/
export const createAdmin = (
overrides?: Partial<UpsertArgs> | null,
options?: Options,
) => createPinned(ADMIN_ID, overrides, options);
/**
* Creates the user that `wrappedAction({ user: "regular" })` submits as. Has no
* permissions of any kind unless `options` gives it some; use for the "somebody
* else" side of a permission test.
*
* Has to be created before any user without a pinned id, see {@link pinUserId}.
*/
export const createRegular = (
overrides?: Partial<UpsertArgs> | null,
options?: Options,
) => createPinned(REGULAR_USER_TEST_ID, overrides, options);
/** Creates the user on the id org permission checks treat as an org admin. */
export const createOrgAdmin = (
overrides?: Partial<UpsertArgs> | null,
options?: Options,
) => createPinned(ORG_ADMIN_TEST_ID, overrides, options);
/** Creates the user on the id the app's permission logic treats as staff. Create
* after every unpinned user, so the ids in between stay free. */
export const createStaff = (
overrides?: Partial<UpsertArgs> | null,
options?: Options,
) => createPinned(STAFF_TEST_ID, overrides, options);
async function createPinned(
pinnedId: number,
overrides?: Partial<UpsertArgs> | null,
options?: Options,
) {
// rows keyed by the user id have to wait until after the pinning changes it
const { profile, friendCode, ...upsertOverrides } = overrides ?? {};
const user = await create({
...upsertOverrides,
profile: null,
friendCode: null,
});
const id = await pinUserId(user.id, pinnedId);
if (profile && Object.keys(profile).length > 0) {
await updateProfile(id, profile);
}
if (friendCode) {
await UserRepository.insertFriendCode({
userId: id,
submitterUserId: id,
friendCode,
});
}
if (options) {
await grant(id, options);
}
return { id };
}
/**
* A set of interchangeable users referred to by the order they were created in.
* For tests needing more users than it makes sense to give names to; prefer
* destructuring `createMany` into named users when there are only a few.
*
* @example
* const users = UserFactory.pool();
*
* beforeEach(async () => {
* await users.create(20);
* });
*
* test("notifies both", () => notify({ userIds: [users.id(1), users.id(2)] }));
*/
export function pool() {
let created: Array<{ id: number }> = [];
return {
/** Creates `count` users, replacing the ones the pool held before. */
create: async (...args: Parameters<typeof createMany>) => {
created = await createMany(...args);
},
/** Id of the `position`th user of the pool, counting from one. */
id: (position: number) => {
const user = created[position - 1];
invariant(user, `No user at position ${position} in the pool`);
return user.id;
},
/** Ids of the first `count` users of the pool, all of them by default. */
ids: (count = created.length) => {
invariant(
created.length >= count,
`Pool has ${created.length} users, ${count} asked for`,
);
return created.slice(0, count).map((user) => user.id);
},
};
}
/**
* What `create`'s second argument does, applied to a user that already exists — for
* the tests whose users come from shared setup, or that want two of them given
* different things.
*/
export async function grant(
userId: number,
{
plusTier,
patronTier,
roles,
matchProfile,
ban,
div,
weapons,
card,
widgets,
preferences,
customTheme,
}: Options,
) {
if (card) {
await actAs(userId, () =>
UserCardRepository.updateOwnCard({ ...EMPTY_CARD, ...card }),
);
}
if (typeof plusTier === "number") {
await setPlusTier(userId, plusTier);
}
if (typeof patronTier === "number") {
await AdminRepository.forcePatron({
id: userId,
patronTier,
patronStartedAt: new Date(),
patronExpiresAt: add(new Date(), PATRONAGE_LENGTH),
});
}
for (const role of roles ?? []) {
await GRANT_ROLE[role](userId);
}
if (matchProfile) {
await actAs(userId, () =>
MatchProfileRepository.updateOwnMatchProfile({
...EMPTY_MATCH_PROFILE,
...matchProfile,
}),
);
}
if (ban) {
await AdminRepository.banUser({ userId, ...ban });
}
if (div) {
await UserRepository.updateManyDivs([{ userId, div }]);
}
if (weapons) {
// the profile page saves every field at once; everything besides the weapons
// is still empty on a user the repository has only just upserted
await actAs(userId, () => UserRepository.updateOwnProfile({ weapons }));
}
if (widgets) {
await UserRepository.upsertWidgets(userId, widgets);
}
if (preferences) {
await actAs(userId, () => UserRepository.updateOwnPreferences(preferences));
}
if (customTheme) {
await actAs(userId, () => UserRepository.updateOwnCustomTheme(customTheme));
}
}
async function setPlusTier(userId: number, plusTier: number) {
// `replacePlusTiers` replaces every row, being the monthly recount of who is in
// what tier, so the tiers granted before this one are read back and sent along
const others = await db
.selectFrom("PlusTier")
.select(["userId", "tier"])
.where("userId", "!=", userId)
.execute();
await AdminRepository.replacePlusTiers([
...others.map((other) => ({ userId: other.userId, plusTier: other.tier })),
{ userId, plusTier },
]);
}

View File

@@ -0,0 +1,23 @@
import * as UserReportRepository from "~/features/user-report/UserReportRepository.server";
import { USER_REPORT_CATEGORIES } from "~/features/user-report/user-report-constants";
import { backdate } from "../core/backdate";
import { defineFactory } from "../core/defineFactory";
import { faker } from "../core/faker";
type Options = {
/** When the report was made, for one that should look older than now. */
createdAt?: Date;
};
/** Creates user reports: `reporterUserId` reporting `reportedUserId`. */
export const { create } = defineFactory({
defaults: () => ({
category: faker.helpers.arrayElement(USER_REPORT_CATEGORIES),
description: faker.lorem.sentences({ min: 1, max: 3 }),
matchId: null,
}),
insert: UserReportRepository.upsert,
applyOptions: async (report, { createdAt }: Options) => {
await backdate("UserReport", report.id, { createdAt });
},
});

View File

@@ -0,0 +1,30 @@
import * as VodRepository from "~/features/vods/VodRepository.server";
import { defineFactory } from "../core/defineFactory";
import { faker } from "../core/faker";
import * as SplatoonFaker from "../core/SplatoonFaker";
const PUBLISHED_ON = { day: 1, month: 0, year: 2024 };
/**
* Creates vods. `submitterUserId` is who added it, `pov` whose point of view it is
* from — a user, a plain name, or nobody at all for a cast. Defaults to one match on
* a random map, the match and player rows every vod listing is read through being
* what the repository derives from `matches`.
*/
export const { create, createMany } = defineFactory({
defaults: ({ seq }) => ({
title: faker.lorem.words(3),
youtubeUrl: `https://www.youtube.com/watch?v=vod${seq}`,
date: PUBLISHED_ON,
type: "TOURNAMENT" as const,
matches: [
{
...SplatoonFaker.mapList(1)[0],
startsAt: "0:00",
weapons: [SplatoonFaker.mainWeapon()],
},
],
isValidated: true,
}),
insert: VodRepository.insert,
});

View File

@@ -0,0 +1,65 @@
import * as XRankPlacementRepository from "~/features/top-search/XRankPlacementRepository.server";
import invariant from "~/utils/invariant";
import { defineFactory } from "../core/defineFactory";
import { faker } from "../core/faker";
import * as SplatoonFaker from "../core/SplatoonFaker";
const PLACED_ON = { month: 1, year: 2024 };
type InsertArgs = Omit<
XRankPlacementRepository.XRankPlacementInsertArgs,
"playerSplId"
> & {
/** Defaults to one derived from `playerUserId`, for a player who only ever
* appears as that user. */
playerSplId?: string;
};
type Options = {
/** Derive `SplatoonPlayer.peakXp` from the placements, as the import does once
* it has added a month's worth of them. */
refreshPeakXp?: boolean;
};
/**
* Creates X Rank placements. `playerSplId` is the in-game id of the player who
* placed — the repository creates their `SplatoonPlayer` row if the id is new, so
* repeating an id places the same player again. `playerUserId` is the site user
* whose results they are, and stands in for the in-game id when none is given.
*
* Rank counts up, since a month's leaderboard has no two players at the same rank.
*/
export const { create } = defineFactory({
defaults: ({ seq }) => ({
badges: "",
bannerSplId: 1,
mode: "SZ" as const,
month: PLACED_ON.month,
year: PLACED_ON.year,
name: faker.internet.username(),
nameDiscriminator: String(seq).padStart(4, "0"),
power: faker.number.int({ min: 2000, max: 3300 }),
rank: seq,
region: "WEST" as const,
title: faker.lorem.words(2),
weaponSplId: SplatoonFaker.mainWeapon(),
}),
insert: async ({ playerSplId, ...args }: InsertArgs) => {
const splId = playerSplId ?? `player-${args.playerUserId}`;
invariant(
playerSplId || args.playerUserId,
"A placement needs either an in-game id or a user to derive one from",
);
const [id] = await XRankPlacementRepository.insertMany([
{ ...args, playerSplId: splId },
]);
return { id };
},
applyOptions: async (_placement, { refreshPeakXp }: Options) => {
if (!refreshPeakXp) return;
await XRankPlacementRepository.refreshAllPeakXp();
},
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 284 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

View File

@@ -0,0 +1,56 @@
import { deleteAllRows } from "~/db/wipe";
import { clearAllTournamentDataCache } from "~/features/tournament-bracket/core/Tournament.server";
import { withoutInfoLogs } from "~/utils/logger";
import { resetFactories } from "./core/defineFactory";
import { resetFaker } from "./core/faker";
import { seedBadges } from "./dev/badges";
import { seedBuilds } from "./dev/builds";
import { seedCalendarEvents } from "./dev/calendar";
import { seedResultHighlights } from "./dev/highlights";
import { seedMisc } from "./dev/misc";
import { seedOrganizations } from "./dev/organizations";
import { seedPlus } from "./dev/plus";
import { seedScrimsAndLFG } from "./dev/scrims-lfg";
import { seedSendouQ } from "./dev/sendouq";
import { seedTeams } from "./dev/teams";
import { seedTournaments } from "./dev/tournaments";
import { seedSpecialTrophies, seedTrophies } from "./dev/trophies";
import { seedUsers } from "./dev/users";
import { seedVods } from "./dev/vods";
export async function seed() {
await deleteAllRows();
resetFactories();
const users = await runModule(() => seedUsers());
const badges = await runModule(() => seedBadges(users));
const organizations = await runModule(() => seedOrganizations(users));
const trophies = await runModule(() =>
seedTrophies({ users, organizations }),
);
const teams = await runModule(() => seedTeams(users));
const calendarEvents = await runModule(() =>
seedCalendarEvents(users, badges),
);
const tournaments = await runModule(() =>
seedTournaments({ users, organizations, badges, teams, trophies }),
);
await runModule(() =>
seedResultHighlights({ users, calendarEvents, tournaments }),
);
const sendouq = await runModule(() => seedSendouQ(users, teams));
await runModule(() => seedPlus(users));
await runModule(() => seedBuilds(users));
await runModule(() => seedScrimsAndLFG(users, teams));
await runModule(() => seedVods(users));
await runModule(() => seedMisc({ users, sendouq, tournaments }));
await runModule(() => seedSpecialTrophies());
clearAllTournamentDataCache();
}
function runModule<T>(module: () => Promise<T>) {
resetFaker();
return withoutInfoLogs(module);
}

View File

@@ -0,0 +1,178 @@
import { DatabaseSync } from "node:sqlite";
import { styleText } from "node:util";
import { Kysely, type LogEvent } from "kysely";
import { format } from "sql-formatter";
import { ServerConfig } from "~/config.server";
import { logger } from "~/utils/logger";
import { roundToNDecimalPlaces } from "~/utils/number";
import { EmptyValuesNoopPlugin } from "./empty-values-noop-plugin";
import { JSON_COLUMNS } from "./json-columns";
import { computedJsonColumns } from "./json-selections";
import { NodeSqliteDialect } from "./node-sqlite-dialect";
import type { DB } from "./tables";
import { WriteTrackerPlugin } from "./write-tracker";
const sql = new DatabaseSync(
ServerConfig.isTest ? ":memory:" : ServerConfig.dbPath,
);
if (ServerConfig.isTest) {
applyMigratedSchema(sql);
}
sql.exec("PRAGMA journal_mode = WAL");
// The synchronous=NORMAL setting provides the best balance between performance and safety for most applications running in WAL mode.
// You lose durability across power lose with synchronous NORMAL in WAL mode, but that is not important for most applications.
// Transactions are still atomic, consistent, and isolated, which are the most important characteristics in most use cases.
// Source: https://sqlite.org/pragma.html
sql.exec("PRAGMA synchronous = NORMAL");
sql.exec("PRAGMA foreign_keys = ON");
sql.exec("PRAGMA busy_timeout = 5000");
// 64MB page cache (default is 2MB)
sql.exec("PRAGMA cache_size = -65536");
// lets reads come straight from the OS page cache without read() syscalls
// Source: https://sqlite.org/mmap.html
sql.exec("PRAGMA mmap_size = 3221225472");
// see https://sqlite.org/pragma.html#pragma_optimize — recommended for long-lived
// connections; pair with a periodic `PRAGMA optimize;` (see OptimizeDatabase routine)
sql.exec("PRAGMA optimize = 0x10002");
// Strips diacritics so accent-insensitive name searches are possible
// (e.g. "cafe" matches "Café"). Combined with LIKE's built-in ASCII
// case-insensitivity this also folds case for the resulting latin letters.
sql.function("unaccent", { deterministic: true }, (value) =>
typeof value === "string"
? value.normalize("NFD").replace(/\p{M}/gu, "")
: value,
);
export const db = new Kysely<DB>({
dialect: new NodeSqliteDialect({
database: sql,
cacheStatements: true,
jsonColumns: JSON_COLUMNS,
computedJsonColumns,
}),
log,
plugins: [new EmptyValuesNoopPlugin(), new WriteTrackerPlugin()],
});
// Every test worker gets its own in-memory database, built by replaying the
// schema of the migrated (and otherwise empty) file that scripts/ensure-test-db.ts
// creates in vitest's globalSetup. Replaying the DDL rather than copying the file
// is what `node:sqlite` allows: unlike better-sqlite3 it cannot deserialize a
// database into memory.
function applyMigratedSchema(target: DatabaseSync) {
const source = new DatabaseSync("db-test.sqlite3", {
readOnly: true,
timeout: 5000,
});
try {
// virtual tables create their own shadow tables (e.g. UserSearch_data), so
// replaying those would fail on a table that already exists
const statements = source
.prepare(`
SELECT sql FROM sqlite_master m
WHERE sql IS NOT NULL
AND name NOT LIKE 'sqlite_%'
AND NOT EXISTS (
SELECT 1 FROM sqlite_master AS vt
WHERE vt.sql LIKE 'CREATE VIRTUAL TABLE%'
AND m.name LIKE vt.name || '_%'
)
ORDER BY m.rowid
`)
.all() as Array<{ sql: string }>;
for (const statement of statements) {
target.exec(statement.sql);
}
} finally {
source.close();
}
}
function log(event: LogEvent) {
if (ServerConfig.sqlLog === "trunc" || ServerConfig.sqlLog === "full") {
logQuery(event);
} else {
logError(event);
}
}
function logQuery(event: LogEvent) {
const isSelectQuery = Boolean((event.query.query as any).from?.froms);
if (event.level === "query" && isSelectQuery) {
const from = () =>
(event.query.query as any).from.froms.map(
// plain tables have the name under table, aliased tables and
// subqueries under alias
(f: any) => f.table?.identifier?.name ?? f.alias?.name ?? "unknown",
);
// biome-ignore lint/suspicious/noConsole: dev only
console.log(styleText("blue", `-- SQLITE QUERY to "${from()}" --`));
// biome-ignore lint/suspicious/noConsole: dev only
console.log(
styleText(
millisToColor(event.queryDurationMillis),
`${roundToNDecimalPlaces(event.queryDurationMillis, 1)}ms`,
),
);
// biome-ignore lint/suspicious/noConsole: dev only
console.log(formatSql(event.query.sql, event.query.parameters));
} else {
logError(event);
}
}
function logError(event: LogEvent) {
if (
event.level === "error" &&
// it seems that this error happens everytime something goes wrong inside transaction
// my guess is that the transaction is already implicitly rolled back in the case of error
// but kysely also does it explicitly -> fails because there is no transaction to rollback.
// this `logError` function at least makes it so that due to that the error doesn't get logged
// but of course the best solution would also avoid useless rollbacks, something for the future
// btw this particular check is here just to avoid the double "no transaction is active" log
!(event.error as any).message.includes("no transaction is active")
) {
logger.error(event.error);
}
}
function millisToColor(millis: number) {
if (millis < 1) {
return "bgGreen";
}
if (millis < 5) {
return "green";
}
if (millis < 50) {
return "yellow";
}
return "red";
}
function formatSql(sql: string, params: readonly unknown[]) {
const formatted = format(sql);
const lines = formatted.split("\n");
if (ServerConfig.sqlLog === "full" || lines.length <= 11) {
return addParams(formatted, params);
}
const linesNotShown = lines.length - 10;
return `${lines.slice(0, 10).join("\n")}\n... (${linesNotShown} more lines) ...\n`;
}
function addParams(sql: string, params: readonly unknown[]) {
const coloredParams = params.map((param) =>
styleText("yellow", JSON.stringify(param)),
);
return sql.replace(/\?/g, () => coloredParams.shift() || "");
}

View File

@@ -0,0 +1,249 @@
/**
* Shapes of JSON column payloads that have no natural feature home. Kept apart from
* `tables.ts` so that what is declared there stays close to the `DB` interface. Payloads
* that do belong to a feature (e.g. `StoredWidget`, `Notification`) live with that feature
* and are imported by `tables.ts` directly.
*/
import type { DBBoolean } from "~/db/tables";
import type { CalendarFilters } from "~/features/calendar/calendar-types";
import type { TieredSkill } from "~/features/mmr/tiered.server";
import type { ScrimFilters } from "~/features/scrims/scrims-types";
import type { CustomThemeVar } from "~/features/theme/theme-constants";
import type * as PickBan from "~/features/tournament-bracket/core/PickBan";
import type * as Progression from "~/features/tournament-bracket/core/Progression";
import type {
ActionType,
WhoSide,
} from "~/features/tournament-bracket/tournament-bracket-constants";
import type {
ObjectPronoun,
SubjectPronoun,
} from "~/features/user-page/user-page-constants";
import type {
MainWeaponId,
ModeShort,
StageId,
} from "~/modules/in-game-lists/types";
export type CustomTheme = Omit<Record<CustomThemeVar, number>, "--_chat-h"> & {
"--_chat-h": number | null;
};
// missing means "neutral"
export type Preference = "AVOID" | "PREFER";
export interface UserMapModePreferences {
modes: Array<{
mode: ModeShort;
/** Users opinion on the mode, `undefined` means neutral */
preference?: Preference;
}>;
pool: Array<{
mode: ModeShort;
stages: StageId[];
}>;
}
export interface WeaponPoolEntry {
weaponSplId: MainWeaponId;
isFavorite: DBBoolean;
}
export interface UserPreferences {
disableBuildAbilitySorting?: boolean;
disallowScrimPickupsFromUntrusted?: boolean;
defaultCalendarFilters?: CalendarFilters;
defaultScrimsFilters?: ScrimFilters;
/**
* What time format the user prefers?
*
* "auto" = use browser default (default value)
* "24h" = 24 hour format (e.g. 14:00)
* "12h" = 12 hour format (e.g. 2:00 PM)
* */
clockFormat?: "24h" | "12h" | "auto";
/** Is the new widget based user page enabled? (Supporter early preview) */
newProfileEnabled?: boolean;
/** Is spoiler-free mode enabled? Hides recent tournament results and scores until the user chooses to reveal them. */
spoilerFreeMode?: boolean;
weaponReportDefaultOpen?: boolean;
}
export type Pronouns = {
subject: SubjectPronoun;
object: ObjectPronoun;
};
export interface PeakXP {
/** Peak XP across all divisions */
overall: number;
/** Peak XP (Takoroka division) */
takoroka: number | null;
/** Peak XP (Tentatek division) */
tentatek: number | null;
}
export type UserSkillDifference =
| {
calculated: true;
spDiff: number;
oldSp?: number;
newSp?: number;
}
| CalculatingSkill;
export type GroupSkillDifference =
| {
calculated: true;
oldSp: number;
newSp: number;
}
| CalculatingSkill;
export type ParsedMemento = {
users: Record<
number,
{
skill?: TieredSkill | "CALCULATING";
skillDifference?: UserSkillDifference;
}
>;
groups: Record<
number,
{
tier?: TieredSkill["tier"];
skillDifference?: GroupSkillDifference;
}
>;
modePreferences?: Partial<
Record<ModeShort, Array<{ userId: number; preference?: Preference }>>
>;
/** mapPreferences of season 2 */
mapPreferences?: Array<{ userId: number; preference?: Preference }[]>;
pools: Array<{
userId: number;
pool: UserMapModePreferences["pool"];
teamName?: string;
}>;
};
export interface TournamentSettings {
bracketProgression: Progression.ParsedBracket[];
/** @deprecated use bracketProgression instead */
teamsPerGroup?: number;
/** @deprecated use bracketProgression instead */
thirdPlaceMatch?: boolean;
isRanked?: boolean;
enableNoScreenToggle?: boolean;
/** Enable the subs tab, default true */
enableSubs?: boolean;
requireInGameNames?: boolean;
isInvitational?: boolean;
/** Can teams add subs on their own while tournament is in progress? */
autonomousSubs?: boolean;
/** Timestamp (SQLite format) when reg closes, if missing then means closes at start time */
regClosesAt?: number;
/** @deprecated use bracketProgression instead */
swiss?: {
groupCount: number;
roundCount: number;
};
minMembersPerTeam?: number;
/** Maximum number of team members that can be registered (only applies to 4v4 tournaments) */
maxMembersPerTeam?: number;
isTest?: boolean;
isDraft?: boolean;
requireSendouQParticipation?: boolean;
}
export interface CastedMatchesInfo {
/** Array for matches that are locked because they are pending to be casted */
lockedMatches: Array<{ twitchAccount: string; matchId: number }>;
/** What matches are streamed currently & where */
castedMatches: { twitchAccount: string; matchId: number }[];
castedMatchHistory?: Array<{
twitchAccount: string;
matchId: number;
timestamp: number;
}>;
}
export interface SeedingSnapshot {
savedAt: number;
teams: Array<{
teamId: number;
members: Array<{ userId: number; username: string }>;
}>;
}
export interface PreparedMaps {
authorId: number;
createdAt: number;
maps: Array<TournamentRoundMaps & { roundId: number; groupId: number }>;
eliminationTeamCount?: number;
}
export interface TournamentRoundMaps {
list?: Array<{ mode: ModeShort; stageId: StageId }> | null;
count: number;
type: "BEST_OF" | "PLAY_ALL";
pickBan?: PickBan.Type | null;
customFlow?: CustomPickBanFlow | null;
}
export interface CustomPickBanStep {
action: ActionType;
side?: WhoSide;
}
export interface CustomPickBanFlow {
preSet: CustomPickBanStep[];
postGame: CustomPickBanStep[];
}
// when updating this also update `settingsFromFormValues` in calendar-progression-form.ts
export interface TournamentStageSettings {
// SE
thirdPlaceMatch?: boolean;
// RR
teamsPerGroup?: number;
/** (RR only) When true, teams are split into A and B divisions and matches only pair A-vs-B. Only valid on starting brackets. */
hasAbDivisions?: boolean;
// SWISS
groupCount?: number;
// SWISS
roundCount?: number;
/** (Swiss only) Number of wins required for a team to advance early. When set, teams advance at this win count and are eliminated at (roundCount - advanceThreshold + 1) losses. */
advanceThreshold?: number;
}
export interface TournamentAuditLogMetadata {
bracketIdx?: number;
/** The new in-game name, for `UPDATE_IN_GAME_NAME` events. */
inGameName?: string;
/** The new tournament name, for `UPDATE_TOURNAMENT_NAME` events. `null` = it was cleared. */
tournamentName?: string | null;
}
/**
* The result of sets in the tournament.
* E.g. ["W", "L", null] would mean the user won the first set, lost the second and did not play the third.
* */
export type WinLossParticipationArray = Array<"W" | "L" | null>;
export interface NotificationSubscription {
endpoint: string;
keys: {
auth: string;
p256dh: string;
};
}
type CalculatingSkill = {
calculated: false;
matchesCount: number;
matchesCountNeeded: number;
/** Freshly calculated skill */
newSp?: number;
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,29 @@
import { sql } from "kysely";
import { db } from "~/db/sql";
/**
* Deletes every row of every table, leaving the schema (and the kysely
* migration bookkeeping tables) in place.
*/
export async function deleteAllRows() {
// virtual tables and their shadow tables (e.g. UserSearch_data) can not be
// deleted from directly; the fts index stays in sync via the User triggers
const { rows: tables } = await sql<{ name: string }>`
SELECT name FROM sqlite_master
WHERE type='table'
AND name NOT LIKE 'sqlite_%'
AND name NOT LIKE 'kysely_migration%'
AND sql NOT LIKE 'CREATE VIRTUAL TABLE%'
AND NOT EXISTS (
SELECT 1 FROM sqlite_master AS vt
WHERE vt.sql LIKE 'CREATE VIRTUAL TABLE%'
AND sqlite_master.name LIKE vt.name || '_%'
)
`.execute(db);
await sql`PRAGMA foreign_keys = OFF`.execute(db);
for (const table of tables) {
await sql`DELETE FROM ${sql.table(table.name)}`.execute(db);
}
await sql`PRAGMA foreign_keys = ON`.execute(db);
}

View File

@@ -0,0 +1,41 @@
import type {
KyselyPlugin,
PluginTransformQueryArgs,
PluginTransformResultArgs,
QueryResult,
RootOperationNode,
UnknownRow,
} from "kysely";
let dirty = false;
/**
* Records whether anything has been written to the database, so that a caller can
* react to writes it did not make itself: the vitest teardown wipes only when a
* test wrote something, and an e2e worker flushes the app server's caches only
* when its factories wrote something.
*/
export class WriteTrackerPlugin implements KyselyPlugin {
transformQuery(args: PluginTransformQueryArgs): RootOperationNode {
if (args.node.kind !== "SelectQueryNode") {
dirty = true;
}
return args.node;
}
async transformResult(
args: PluginTransformResultArgs,
): Promise<QueryResult<UnknownRow>> {
return args.result;
}
}
/** Whether the database has been written to since the last {@link markDatabaseClean}. */
export function isDatabaseDirty() {
return dirty;
}
export function markDatabaseClean() {
dirty = false;
}