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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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