Move app to apps/web-react in pnpm workspace layout
58
apps/web-react/app/db/empty-values-noop-plugin.ts
Normal 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")),
|
||||
});
|
||||
}
|
||||
42
apps/web-react/app/db/json-columns.test.ts
Normal 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();
|
||||
}
|
||||
51
apps/web-react/app/db/json-columns.ts
Normal 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",
|
||||
]);
|
||||
131
apps/web-react/app/db/json-selections.test.ts
Normal 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 }]);
|
||||
});
|
||||
});
|
||||
153
apps/web-react/app/db/json-selections.ts
Normal 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))
|
||||
);
|
||||
}
|
||||
456
apps/web-react/app/db/node-sqlite-dialect.ts
Normal 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),
|
||||
]);
|
||||
}
|
||||
20
apps/web-react/app/db/reset.ts
Normal 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();
|
||||
};
|
||||
10
apps/web-react/app/db/seed/constants.ts
Normal 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";
|
||||
104
apps/web-react/app/db/seed/core/SplatoonFaker.ts
Normal 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()];
|
||||
}
|
||||
16
apps/web-react/app/db/seed/core/actAs.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
43
apps/web-react/app/db/seed/core/backdate.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
133
apps/web-react/app/db/seed/core/defineFactory.ts
Normal 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;
|
||||
}
|
||||
55
apps/web-react/app/db/seed/core/faker.ts
Normal 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();
|
||||
}
|
||||
39
apps/web-react/app/db/seed/core/pinUserId.ts
Normal 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;
|
||||
}
|
||||
14
apps/web-react/app/db/seed/core/reseason.ts
Normal 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();
|
||||
}
|
||||
108
apps/web-react/app/db/seed/core/showcaseNames.ts
Normal 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]);
|
||||
}
|
||||
207
apps/web-react/app/db/seed/data/badges.json
Normal 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
|
||||
}
|
||||
]
|
||||
60002
apps/web-react/app/db/seed/data/placements.json
Normal file
5
apps/web-react/app/db/seed/data/trophies.json
Normal file
107
apps/web-react/app/db/seed/dev/badges.ts
Normal 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),
|
||||
});
|
||||
}
|
||||
39
apps/web-react/app/db/seed/dev/builds.ts
Normal 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),
|
||||
});
|
||||
}
|
||||
}
|
||||
134
apps/web-react/app/db/seed/dev/calendar.ts
Normal 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() };
|
||||
},
|
||||
),
|
||||
],
|
||||
}));
|
||||
}
|
||||
31
apps/web-react/app/db/seed/dev/highlights.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
219
apps/web-react/app/db/seed/dev/misc.ts
Normal 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,
|
||||
}),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
38
apps/web-react/app/db/seed/dev/organizations.ts
Normal 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"] }];
|
||||
}
|
||||
75
apps/web-react/app/db/seed/dev/plus.ts
Normal 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 } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
138
apps/web-react/app/db/seed/dev/scrims-lfg.ts
Normal 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]),
|
||||
};
|
||||
}
|
||||
262
apps/web-react/app/db/seed/dev/sendouq.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
81
apps/web-react/app/db/seed/dev/teams.ts
Normal 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 };
|
||||
}
|
||||
615
apps/web-react/app/db/seed/dev/tournaments.ts
Normal 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);
|
||||
}
|
||||
125
apps/web-react/app/db/seed/dev/trophies.ts
Normal 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(),
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
362
apps/web-react/app/db/seed/dev/users.ts
Normal 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);
|
||||
}
|
||||
64
apps/web-react/app/db/seed/dev/vods.ts
Normal 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),
|
||||
};
|
||||
});
|
||||
}
|
||||
19
apps/web-react/app/db/seed/factories/ApiTokenFactory.ts
Normal 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),
|
||||
});
|
||||
26
apps/web-react/app/db/seed/factories/ArtFactory.ts
Normal 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)),
|
||||
});
|
||||
24
apps/web-react/app/db/seed/factories/AssociationFactory.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
29
apps/web-react/app/db/seed/factories/BadgeFactory.ts
Normal 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 });
|
||||
}
|
||||
},
|
||||
});
|
||||
34
apps/web-react/app/db/seed/factories/BuildFactory.ts
Normal 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,
|
||||
});
|
||||
70
apps/web-react/app/db/seed/factories/CalendarEventFactory.ts
Normal 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 };
|
||||
},
|
||||
});
|
||||
@@ -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),
|
||||
};
|
||||
},
|
||||
});
|
||||
11
apps/web-react/app/db/seed/factories/FriendRequestFactory.ts
Normal 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,
|
||||
});
|
||||
28
apps/web-react/app/db/seed/factories/FriendshipFactory.ts
Normal 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,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -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)),
|
||||
});
|
||||
33
apps/web-react/app/db/seed/factories/ImageFactory.ts
Normal 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);
|
||||
},
|
||||
});
|
||||
20
apps/web-react/app/db/seed/factories/LFGPostFactory.ts
Normal 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,
|
||||
});
|
||||
29
apps/web-react/app/db/seed/factories/LiveStreamFactory.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
11
apps/web-react/app/db/seed/factories/LogInLinkFactory.ts
Normal 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),
|
||||
});
|
||||
26
apps/web-react/app/db/seed/factories/NotificationFactory.ts
Normal 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 });
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
51
apps/web-react/app/db/seed/factories/PlusVoteFactory.ts
Normal 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);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
70
apps/web-react/app/db/seed/factories/SQGroupFactory.ts
Normal 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();
|
||||
}
|
||||
},
|
||||
});
|
||||
225
apps/web-react/app/db/seed/factories/SQMatchFactory.ts
Normal 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 };
|
||||
}
|
||||
26
apps/web-react/app/db/seed/factories/SQReadyCheckFactory.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -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)),
|
||||
});
|
||||
@@ -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 };
|
||||
},
|
||||
});
|
||||
57
apps/web-react/app/db/seed/factories/ScrimPostFactory.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
28
apps/web-react/app/db/seed/factories/SkillFactory.ts
Normal 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();
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
86
apps/web-react/app/db/seed/factories/TeamFactory.ts
Normal 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,
|
||||
});
|
||||
},
|
||||
});
|
||||
502
apps/web-react/app/db/seed/factories/TournamentFactory.ts
Normal 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;
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -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: [],
|
||||
});
|
||||
}
|
||||
@@ -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)),
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
});
|
||||
101
apps/web-react/app/db/seed/factories/TournamentTeamFactory.ts
Normal 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);
|
||||
}
|
||||
},
|
||||
});
|
||||
72
apps/web-react/app/db/seed/factories/TrophyFactory.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
449
apps/web-react/app/db/seed/factories/UserFactory.ts
Normal 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 },
|
||||
]);
|
||||
}
|
||||
23
apps/web-react/app/db/seed/factories/UserReportFactory.ts
Normal 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 });
|
||||
},
|
||||
});
|
||||
30
apps/web-react/app/db/seed/factories/VodFactory.ts
Normal 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,
|
||||
});
|
||||
@@ -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();
|
||||
},
|
||||
});
|
||||
BIN
apps/web-react/app/db/seed/img/alliance-rogue.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
apps/web-react/app/db/seed/img/default.png
Normal file
|
After Width: | Height: | Size: 9.8 KiB |
BIN
apps/web-react/app/db/seed/img/in-the-zone.png
Normal file
|
After Width: | Height: | Size: 4.5 KiB |
BIN
apps/web-react/app/db/seed/img/luti.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
apps/web-react/app/db/seed/img/paddling-pool.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
apps/web-react/app/db/seed/img/picnic.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
apps/web-react/app/db/seed/img/swim-or-sink.png
Normal file
|
After Width: | Height: | Size: 284 KiB |
BIN
apps/web-react/app/db/seed/img/the-depths.png
Normal file
|
After Width: | Height: | Size: 99 KiB |
56
apps/web-react/app/db/seed/index.ts
Normal 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);
|
||||
}
|
||||
178
apps/web-react/app/db/sql.ts
Normal 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() || "");
|
||||
}
|
||||
249
apps/web-react/app/db/tables-json.ts
Normal 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;
|
||||
};
|
||||
1432
apps/web-react/app/db/tables.ts
Normal file
29
apps/web-react/app/db/wipe.ts
Normal 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);
|
||||
}
|
||||
41
apps/web-react/app/db/write-tracker.ts
Normal 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;
|
||||
}
|
||||