Merge remote-tracking branch 'origin/main' into ingest

# Conflicts:
#	app/components/match-page/MatchTimeline.tsx
#	app/db/tables.ts
#	app/features/tournament-match/components/TournamentMatchTabs.tsx
#	app/features/tournament-match/loaders/to.$id.matches.$mid.server.ts
#	app/features/vods/loaders/vods.new.server.ts
#	db-test.sqlite3
#	e2e/seeds/db-seed-AB_RR.sqlite3
#	e2e/seeds/db-seed-DEFAULT.sqlite3
#	e2e/seeds/db-seed-FINALIZED_BRACKET.sqlite3
#	e2e/seeds/db-seed-IN_SQ_MATCH.sqlite3
#	e2e/seeds/db-seed-NO_SCRIMS.sqlite3
#	e2e/seeds/db-seed-NO_SQ_GROUPS.sqlite3
#	e2e/seeds/db-seed-NO_TOURNAMENT_TEAMS.sqlite3
#	e2e/seeds/db-seed-NZAP_IN_TEAM.sqlite3
#	e2e/seeds/db-seed-REG_OPEN.sqlite3
#	e2e/seeds/db-seed-SMALL_SOS.sqlite3
#	e2e/seeds/db-seed-TEAM_MAP_PREFS.sqlite3
#	knip.ts
#	package.json
#	pnpm-lock.yaml
#	vite.config.ts
This commit is contained in:
Kalle
2026-08-04 20:15:52 +03:00
1574 changed files with 70501 additions and 46458 deletions

View File

@@ -1,6 +1,6 @@
---
name: e2e
description: Run, debug, and manage Playwright e2e tests. Use when running e2e tests, debugging test failures, regenerating seed databases, or investigating test infrastructure issues.
description: Run, debug, and manage Playwright e2e tests. Use when running e2e tests, debugging test failures, writing new specs, or investigating test infrastructure issues.
---
# E2E Test Runner
@@ -8,27 +8,26 @@ description: Run, debug, and manage Playwright e2e tests. Use when running e2e t
## Architecture overview
- Tests live in `e2e/*.spec.ts`, config in `playwright.config.ts`
- Global setup (`e2e/global-setup.ts`) builds the app, creates per-worker databases, and starts one server per worker
- Port calculation: `E2E_BASE_PORT = PORT (from .env) + 500`. Default PORT is typically 4001, so base port = 4501. Workers use ports base+0 through base+3
- Worker databases: `db-test-e2e-0.sqlite3` through `db-test-e2e-3.sqlite3` in the project root
- Seed databases (pre-seeded snapshots): `e2e/seeds/db-seed-*.sqlite3`
- Page objects live in `e2e/pages/<feature>/` — every spec uses them; conventions in `docs/dev/e2e-page-objects.md`, gotchas in `docs/dev/e2e-page-objects-migration.md`
- Global setup (`e2e/global-setup.ts`) builds the app (skipped when no build input changed since the last e2e build — tracked via `.e2e-build-marker`), creates/migrates per-worker databases (via `scripts/ensure-test-db.ts`: pending migrations are applied, drifted databases are rebuilt), and starts one server per worker
- Port calculation: `E2E_BASE_PORT = PORT (from .env) + 500`. Worker N uses port base+N, except ports on the WHATWG fetch bad port list (e.g. 6679) are skipped — see `e2eWorkerPort` in `e2e/helpers/playwright.ts`
- Worker count: `E2E_WORKERS` env, defaulting to `min(8, max(4, cores - 2))`
- Worker databases: `db-test-e2e-<N>.sqlite3` in the project root; every test starts from a wiped database holding only the admin (Sendou) and N-ZAP users, and builds its own data with the `factories` fixture
- MinIO (S3-compatible storage) is started via Docker Compose if not already running
## Pre-flight checks (run before every test execution)
Before running tests, check for these common issues:
1. **Stale worker databases** — Files matching `db-test-e2e-*.sqlite3` in the project root can cause "table already exists" migration errors if the schema has changed since they were created. Run `pnpm run test:e2e:generate-seeds` to regenerate these from the seed databases.
2. **Port conflicts** — Check if anything is already listening on the e2e ports (base port through base+3):
1. **Port conflicts** — Check if anything is already listening on the e2e ports (base port + worker index):
```
lsof -i :4501-4504 2>/dev/null
lsof -i :4501-4508 2>/dev/null
```
If ports are occupied by leftover e2e servers, kill them. If occupied by something else, warn the user.
3. **Seed databases exist** — Verify `e2e/seeds/` contains the expected seed files. If missing, run `pnpm run test:e2e:generate-seeds`.
2. **Docker running** — MinIO requires Docker. Check with `docker info` if there are storage-related failures.
4. **Docker running** — MinIO requires Docker. Check with `docker info` if there are storage-related failures.
Stale worker databases (`db-test-e2e-*.sqlite3`) are handled automatically: global setup applies pending migrations and rebuilds databases whose migration history has drifted.
## Running tests
@@ -41,16 +40,18 @@ pnpm run test:e2e
```bash
pnpm exec playwright test e2e/<name>.spec.ts
```
Batch multiple files into one invocation — every invocation pays global setup.
### Flaky detection (repeats each test 10 times, stops on first failure)
```bash
pnpm run test:e2e:flaky-detect
```
### Regenerate seed databases (after schema/migration changes)
### Force a rebuild of the app
```bash
pnpm run test:e2e:generate-seeds
E2E_FORCE_BUILD=true pnpm run test:e2e
```
Global setup reuses the previous build when nothing under `app/`, `public/`, the lockfile, or the vite/react-router configs changed. Use this override if you suspect a stale build (e.g. after changing env-dependent build behavior).
## Debugging failures
@@ -62,10 +63,10 @@ Follow this funnel when tests fail:
### Step 2: Check infrastructure issues
Common infrastructure errors and fixes:
- **"table already exists"** → Stale worker DBs. Run `rm -f db-test-e2e-*.sqlite3`
- **"table already exists"** → Should not happen anymore (global setup rebuilds drifted worker DBs); if it does, `rm -f db-test-e2e-*.sqlite3` and investigate `scripts/ensure-test-db.ts`
- **"Server on port X did not start within timeout"** → Port conflict or app build error. Check ports with `lsof -i :<port>` and check for build errors
- **"MinIO failed to start"** → Docker not running or compose issue. Check `docker info`
- **Seed-related errors** → Run `pnpm run test:e2e:generate-seeds`
- **"Test ended with database writes the server never saw"** → A factory call was not followed by a helper that talks to the server; add a `navigate`/`impersonate` after the writes
### Step 3: Reduce to single debug worker
If the error is unclear, re-run with debug output and a single worker to see server logs:
@@ -75,41 +76,50 @@ E2E_DEBUG=true E2E_WORKERS=1 pnpm exec playwright test e2e/<failing-test>.spec.t
This shows stdout/stderr from the test server, which is hidden by default.
### Step 4: Examine trace artifacts
Playwright is configured with `trace: "retain-on-failure"`. After a failure, view the trace:
Playwright is configured with `trace: "retain-on-failure"`. After a failure, check `test-results/<test-folder>/error-context.md` for the page's accessibility snapshot at failure time, or view the trace:
```bash
pnpm exec playwright show-trace test-results/<test-folder>/trace.zip
```
### Re-render races
Skalop (websocket) is fully disconnected in e2e — the build has an empty `VITE_SKALOP_WS_URL` and worker servers get empty `SKALOP_SYSTEM_MESSAGE_URL`/`SKALOP_TOKEN` (see `e2e/global-setup.ts`), so cross-worker websocket crosstalk cannot cause flakes. Google Fonts are also blocked at the context level so font swaps never reflow the page mid-test. Re-renders from the test's own action revalidations can still swallow a React Aria press (press start registers, press end never fires — no POST); `waitForPOSTResponse` retries for this, so route flows through it rather than adding sleeps. When e2e tests for chat/websocket features are added, skalop needs a per-worker instance or stub with a runtime-derived WS URL.
## Test pattern reference
Every test follows this pattern — use these imports from `./helpers/playwright`, NOT raw Playwright APIs:
Every test builds its own data with factories and drives the UI through page objects:
```typescript
import { expect, impersonate, navigate, seed, test } from "./helpers/playwright";
import { NZAP_TEST_ID } from "~/db/seed/constants";
import { expect, impersonate, test } from "./helpers/playwright";
import { BuildsPage } from "./pages/builds/builds-page";
test.describe("Feature", () => {
test("does something", async ({ page }) => {
await seed(page); // Reset DB to a known seed state
await impersonate(page, USER_ID); // Log in as a specific user (default: admin)
await navigate({ page, url: "..." });// Navigate (waits for hydration)
// ... interact with the page ...
await submit(page); // Submit a form (waits for POST response)
});
test("does something", async ({ page, factories }) => {
await factories.BuildFactory.create({ ownerId: NZAP_TEST_ID });
await impersonate(page, NZAP_TEST_ID);
const builds = new BuildsPage(page);
await builds.goto();
// ... interact via page object methods, assert in the spec ...
});
});
```
Key rules:
- Use `navigate()` instead of `page.goto()` — it waits for hydration
- The database starts each test holding only the admin and N-ZAP; the `factories` worker fixture (see `e2e/helpers/factories.ts` for the registry) creates everything else
- Locators live in page objects under `e2e/pages/` — specs contain no raw `getByTestId`/`getByRole` calls; see `docs/dev/e2e-page-objects.md`
- Use `navigate()` instead of `page.goto()` — it waits for hydration (page objects' `goto()` methods wrap it)
- Use `submit()` instead of clicking submit buttons directly — it waits for the POST response
- Use `seed(page, variation?)` to reset the database. Available variations: DEFAULT, NO_TOURNAMENT_TEAMS, REG_OPEN, SMALL_SOS, NZAP_IN_TEAM, NO_SCRIMS, NO_SQ_GROUPS
- Use `impersonate(page, userId?)` to authenticate. Default is admin (ADMIN_ID)
- Use `impersonate(page, userId?)` to authenticate. Default is admin (ADMIN_ID); prefer N-ZAP (`NZAP_TEST_ID`) when the flow doesn't need admin rights
- Avoid `page.waitForTimeout` — use assertions or `waitFor` patterns instead
- Import `test` from `./helpers/playwright` (not from `@playwright/test`) — it includes worker port fixtures
- Import `test` from `./helpers/playwright` (not from `@playwright/test`) — it includes worker port fixtures and the database reset
- Factory writes must be followed by a helper that talks to the server (`navigate`, `impersonate`, `submit`) or the test fails with "writes the server never saw"
## Environment variables
| Variable | Purpose | Default |
|----------|---------|---------|
| `E2E_WORKERS` | Number of parallel workers | 4 |
| `E2E_WORKERS` | Number of parallel workers | min(8, max(4, cores 2)) |
| `E2E_DEBUG` | Show server stdout/stderr when "true" | unset |
| `E2E_FORCE_BUILD` | Rebuild the app even when inputs look unchanged | unset |
| `PORT` | Base port for dev server (e2e adds 500) | 5173 |

View File

@@ -0,0 +1,10 @@
---
name: search-params
description: Work with URL search param state via the unified app/modules/search-params system. Use when reading or writing query strings, URL-backed state, filters-in-URL, pagination, share links, or shouldRevalidate related to search params.
---
# Search params
All URL search param handling goes through `app/modules/search-params/`.
**Read [docs/dev/search-params.md](../../../docs/dev/search-params.md) now** — it is the single source of truth for the conventions (hard rules, defining params, the codec derivation table, loader/client APIs, href building, revalidation, testing).

View File

@@ -1,9 +1,9 @@
---
name: sendou-code-review
description: Multi-agent code review that checks the current diff from 7 angles (spec compliance, modernization, bugs, CLAUDE.md rules, abstraction reuse, security, DB query performance) and produces a unified review. Works on branch diffs vs main or staged changes.
description: Multi-agent code review that checks the current diff from multiple angles (spec compliance, modernization, bugs from two lenses, CLAUDE.md rules, abstraction reuse, security, DB query performance, test coverage, English copy proofreading) plus mechanical pre-checks, adversarially verifies every finding, and produces a unified review. Works on branch diffs vs main or uncommitted changes.
---
Review the current code changes from multiple angles using parallel sub-agents, then synthesize into a single high-quality review. If the diff contains no changes to Repository files or SQL/Kysely code, skip Agent 7 (DB Query Performance).
Review the current code changes from multiple angles using parallel sub-agents, adversarially verify each finding, then synthesize into a single high-quality review. If the diff contains no changes to Repository files or SQL/Kysely code, skip Agent 8 (DB Query Performance).
## Step 1: Determine what to review
@@ -11,39 +11,85 @@ Run these commands to figure out what to review:
```
git branch --show-current
git status --porcelain -uall
```
- If NOT on `main`: get the diff with `git diff main...HEAD` and also `git diff` for any unstaged changes on the branch. Combine them — this is the **full diff**.
- If on `main`: get staged changes with `git diff --cached`. If there are no staged changes, tell the user: "Nothing to review. Either check out a feature branch or stage some changes." and stop.
- If NOT on `main`: get the diff with `git diff main -- ':(exclude)locales/'` — this compares the working tree against main, so it covers committed, staged, AND unstaged changes in one diff.
- If on `main`: get uncommitted changes with `git diff HEAD -- ':(exclude)locales/'` (covers both staged and unstaged).
- **Translation files are excluded**: the `':(exclude)locales/'` pathspec keeps `locales/` JSON churn (mostly `i18n:sync` output) out of the review — it is noise, not reviewable code. Apply the same exclusion to every diff/stat command below.
- **But collect the English copy separately**: also run `git diff main -- 'locales/en/'` (or `git diff HEAD -- 'locales/en/'` on main), plus untracked files under `locales/en/`. This is the **copy diff** — it is NOT part of the full diff and goes only to Agent 10 (Copy Editor).
- **Untracked files**: plain diffs never show untracked files. For every untracked file in the `git status --porcelain -uall` output (these are not gitignored — git status already excludes ignored files), skip anything under `locales/`, then read the file's full content and append it to the review material clearly marked as "NEW UNTRACKED FILE: {path}". These files get reviewed like any other changed code.
- If the diff is empty and there are no untracked files, tell the user: "Nothing to review. Either check out a feature branch or make some changes." and stop.
The diff plus untracked file contents is the **full diff** referenced below.
Also run these in parallel:
- `cat .nvmrc` to get the Node.js version
- Read the `CLAUDE.md` and `AGENTS.md` files for the project rules
- `git diff main...HEAD --stat` (or `git diff --cached --stat` on main) to get the list of changed files
- `git diff main --stat -- ':(exclude)locales/'` (or `git diff HEAD --stat -- ':(exclude)locales/'` on main) to get the list of changed files
## Step 2: Collect context from the user
## Step 2: Mechanical pre-checks (no agents)
The user may have provided a GitHub issue URL or description of what the code should do as an argument. If they did, this will be used by the Spec Compliance agent. If not, the Spec Compliance agent will be skipped.
These rules are greppable and don't need an LLM. Run them yourself now; each hit becomes a **pre-confirmed finding** passed straight to the summarizer (they skip the verification stage).
## Step 3: Launch up to 7 parallel review agents
1. **Typecheck**: run `pnpm run typecheck`. If it fails on changed files, record each error as a pre-confirmed finding — the review should lead with these rather than agents flagging downstream symptoms. Continue with the review regardless.
2. **Undefined CSS variables**: for every `var(--...)` in added lines of CSS/TSX, verify the variable is defined in `app/styles/vars.css` or locally in one of the changed files. Undefined → finding.
3. **Unregistered routes**: for every new file added under a `routes/` directory (e.g. `app/features/*/routes/*.tsx`), grep `app/routes.ts` for its path. Not referenced → finding.
4. **Missing CSS module pairing**: a new `.tsx` file that defines components with non-trivial styling but has no matching `.module.css` file. Use judgement — files with no styling needs are fine.
5. **Leftover debug code**: grep added lines for `console.log`, `test.only`, `describe.only`, `it.only`.
## Step 3: Collect the spec
The user may have provided a GitHub issue URL or description of what the code should do as an argument. If they did, use it for the Spec Compliance agent.
If they did NOT, derive the intent instead of skipping:
- On a branch: run `git log main..HEAD --format="%s%n%b"` for commit messages. If a commit or the branch name references an issue (`#123`), fetch it with `gh issue view 123`. If a PR exists, fetch `gh pr view --json title,body`.
- Combine whatever you found into the spec. "Does the code do what the commits claim" is still a valuable check.
- Only skip the Spec Compliance agent if there is genuinely nothing: no user input, no commits (e.g. uncommitted changes on main), no PR.
## Step 4: Launch parallel finder agents
Launch these as parallel Agent calls. Each agent receives:
- The full diff
- The full diff (including untracked file contents)
- The list of changed files
- Access to read the full files for surrounding context
**Sharding for large diffs**: if the full diff exceeds ~2000 lines, do not warn and shrug — shard. Split the changed file list into roughly equal chunks (by line count) and run one instance of each finder agent per chunk, giving each instance its chunk's diff plus the full file list for context. Findings from all instances merge into the same pool.
**Agent budget — sharding must not explode usage on a massive branch:**
- Cap at **3 shards** no matter the diff size.
- If the diff exceeds ~4000 lines, only shard the high-signal finders (Agents 1, 3, 4, 7 — spec, both bug lenses, security). Run the rest (modernizer, CLAUDE.md, abstraction, test coverage) as a single instance each over the full diff; they degrade gracefully with size.
- Hard ceiling: **at most 20 finder agent calls** total. If the plan would exceed that, drop shards from the lowest-priority finders first (modernization, then CLAUDE.md, then abstraction).
- If the diff exceeds ~8000 lines, stop and ask the user before launching anything: proceed with the capped review (state the approximate agent count), or narrow the review to a subset they pick (e.g. specific directories/features), or review only the riskiest files (Repositories, actions/loaders, permission-related code) — recommend the last option.
**Important for all agents:**
- Only flag issues in the NEW code (lines added/changed in the diff). Do not flag pre-existing issues.
- Be specific: cite file paths and describe the exact problem and a concrete suggestion.
- If you find nothing meaningful, say so — do not manufacture issues.
- Be specific: cite file paths and line numbers, describe the exact problem and a concrete suggestion.
- If you find nothing meaningful, return an empty list — do not manufacture issues.
### Agent 1: Spec Compliance (skip if no issue/description provided)
**Structured output — every finder returns JSON**, an array of findings shaped:
```json
{
"file": "app/features/foo/FooPage.tsx",
"line": 42,
"category": "bug | security | db-performance | spec | claude-md | abstraction | modernization | test-coverage | copy",
"severity": "critical | warning | info",
"claim": "one-sentence statement of the problem",
"detail": "how it manifests / why it matters, 1-3 sentences",
"suggestion": "concrete fix"
}
```
Tell each agent to output ONLY the JSON array as its final message. Agent-specific extra fields are noted per agent below.
### Agent 1: Spec Compliance (skip only if no spec could be collected in Step 3)
```
You are reviewing code changes for spec compliance.
The user described the expected behavior as:
{user_provided_spec}
The expected behavior (from user description, commit messages, and/or linked issue/PR):
{spec}
Here is the diff:
{diff}
@@ -57,7 +103,7 @@ Read the full changed files for context. Check whether the implementation actual
Only flag real gaps between spec and implementation. Do not flag things the spec doesn't mention.
Return a list of issues, each with: file path, description of the gap, and what the spec expected.
Return ONLY a JSON array of findings: [{file, line, category: "spec", severity, claim, detail (what the spec expected vs what the code does), suggestion}]
```
### Agent 2: Modernizer
@@ -82,13 +128,36 @@ Read the full changed files for context. Check for:
Only flag cases where the modern approach is clearly better (more readable, shorter, or more performant). Do not flag stylistic preferences that are a wash.
Return a list of suggestions, each with: file path, the current code pattern, and the modern replacement.
Return ONLY a JSON array of findings: [{file, line, category: "modernization", severity: "info", claim (the current pattern), detail, suggestion (the modern replacement)}]
```
### Agent 3: Bug Finder
### Agent 3: Bug Finder — client lens
```
You are reviewing code changes for bugs.
You are reviewing code changes for bugs, focused on the CLIENT side: React components, hooks, state, data flow, and UI logic.
Here is the diff:
{diff}
Changed files: {file_list}
Read the full changed files for context. Look for (not a comprehensive list):
- Logic errors in components and hooks (wrong conditions, off-by-one, incorrect comparisons)
- Null/undefined access that could crash at render time
- State management bugs (stale closures, state derived incorrectly from props, missing dependency arrays — but note this project doesn't use useMemo/useCallback)
- Broken data flow between components (props passed but ignored, wrong prop, stale loader data)
- Form handling and optimistic UI mistakes
- Rendering bugs (wrong keys in lists, conditional rendering that hides errors)
Focus on bugs that would actually manifest in practice. Do not flag theoretical issues that are prevented by the surrounding code or type system.
Return ONLY a JSON array of findings: [{file, line, category: "bug", severity, claim, detail (how it manifests), suggestion}]
```
### Agent 4: Bug Finder — server/data lens
```
You are reviewing code changes for bugs, focused on the SERVER side: loaders, actions, Repository code, SQL, validation, and authorization-adjacent logic.
Here is the diff:
{diff}
@@ -98,19 +167,19 @@ Changed files: {file_list}
Read the full changed files for context. Look for (not a comprehensive list):
- Logic errors (wrong conditions, off-by-one, incorrect comparisons)
- Null/undefined access that could crash at runtime
- Race conditions or ordering issues
- Incorrect type assumptions
- Race conditions or ordering issues (concurrent actions, non-atomic read-then-write)
- Incorrect type assumptions at boundaries (request params, DB rows)
- Missing error handling at system boundaries (external APIs, user input)
- State management bugs (stale closures, missing dependency arrays — but note this project doesn't use useMemo/useCallback)
- Incorrect SQL queries (wrong joins, missing WHERE clauses, SQL injection)
- Broken data flow between components
- Incorrect SQL queries (wrong joins, missing WHERE clauses, wrong aggregation)
- Transactions missing where multiple writes must be atomic
- Data returned to the client that doesn't match what the component expects
Focus on bugs that would actually manifest in practice. Do not flag theoretical issues that are prevented by the surrounding code or type system.
Return a list of bugs, each with: file path, description of the bug, how it would manifest, and a suggested fix.
Return ONLY a JSON array of findings: [{file, line, category: "bug", severity, claim, detail (how it manifests), suggestion}]
```
### Agent 4: CLAUDE.md Compliance
### Agent 5: CLAUDE.md Compliance
```
You are reviewing code changes for compliance with the project's CLAUDE.md rules.
@@ -125,10 +194,10 @@ Changed files: {file_list}
Only flag clear violations. If a rule says "prefer" or "avoid", use judgement — a minor deviation in context is not a violation.
Return a list of violations, each with: file path, the rule violated (quote it), and the offending code.
Return ONLY a JSON array of findings: [{file, line, category: "claude-md", severity: "warning", claim (quote the rule violated), detail (the offending code), suggestion}]
```
### Agent 5: Abstraction Police
### Agent 6: Abstraction Police
```
You are reviewing code changes for proper reuse of existing abstractions and avoiding excessive copy-paste.
@@ -145,17 +214,17 @@ Your job is to search the codebase to check:
Use the "three strikes" rule: a small amount of duplication (2 instances) is acceptable. Three or more instances of the same pattern means it should be abstracted.
Search broadly — check `app/utils/`, `app/components/`, `app/hooks/`, and files adjacent to the changed files.
Search broadly — check `app/utils/`, `app/components/`, `app/hooks/`, `app/modules/`, and files adjacent to the changed files.
Do NOT flag:
- Simple one-liners that happen to look similar (e.g., `if (!user) return null`)
- Standard patterns that are idiomatic and don't benefit from abstraction
- Duplication that exists only in the old code (not introduced by this diff)
Return a list of issues, each with: file path, the duplicated pattern, where it already exists in the codebase (with file paths), and a suggestion for how to share it.
Return ONLY a JSON array of findings: [{file, line, category: "abstraction", severity, claim (the duplicated pattern), detail (where it already exists — cite file paths), suggestion (how to share it)}]
```
### Agent 6: Security
### Agent 7: Security
```
You are reviewing code changes for security vulnerabilities.
@@ -165,28 +234,36 @@ Here is the diff:
Changed files: {file_list}
Read the full changed files for context. This is a Remix/React Router web application with SQLite (via Kysely). Check for:
Read the full changed files for context. This is a Remix/React Router web application with SQLite (via Kysely).
Project auth conventions — check against these real patterns, not generic boilerplate:
- Server-side auth: `requireUser()` from `app/features/auth/core/user.server.ts` returns the authenticated user or throws.
- Role/permission guards: `requireRole(role)` and `requirePermission(...)` from `app/modules/permissions/guards.server.ts`. Helpers like `isAdmin`/`isStaff` live in `app/modules/permissions/utils.ts`.
- Client-side hooks `useHasRole`/`useHasPermission` (`app/modules/permissions/hooks.ts`) are for UI display only — they are NOT security boundaries. Every mutation (action) and sensitive loader must enforce auth server-side.
Check for:
- Loaders/actions that read or mutate data without the appropriate `requireUser`/`requireRole`/`requirePermission` guard, or that guard with the wrong scope (IDOR — can user A access or mutate user B's data by changing an id?)
- SQL injection (even with Kysely, check for raw queries or string interpolation in SQL)
- XSS (unescaped user input rendered as HTML, dangerouslySetInnerHTML with user data)
- CSRF vulnerabilities
- Authorization bypasses (missing permission checks, IDOR — can user A access user B's data?)
- Sensitive data exposure (tokens, passwords, or PII in logs, responses, or client-side code)
- Sensitive data exposure (tokens, passwords, or PII in logs, loader responses, or client-side code — remember everything a loader returns is visible to the client)
- Path traversal (user-controlled file paths)
- Insecure redirects (open redirect via user-controlled URLs)
- Missing input validation at system boundaries
Focus on real, exploitable vulnerabilities in the new code. Do not flag:
- General best-practice advice that isn't a concrete vulnerability
- Issues in frameworks/libraries (Remix, React) that handle security themselves
- Issues in frameworks/libraries (Remix, React) that handle security themselves (e.g. React's default escaping, framework CSRF handling)
- Pre-existing issues not introduced by this diff
Return a list of vulnerabilities, each with: file path, vulnerability type (e.g., "SQL Injection"), description, attack scenario, and suggested fix.
Return ONLY a JSON array of findings: [{file, line, category: "security", severity, claim (vulnerability type + summary), detail (attack scenario), suggestion}]
```
### Agent 7: DB Query Performance (skip if no Repository/Kysely changes in diff)
### Agent 8: DB Query Performance (skip if no Repository/Kysely changes in diff)
```
You are reviewing code changes for database query performance. This is a Remix/React Router web app using SQLite via Kysely. The dev database is at `db.sqlite3`.
You are reviewing code changes for database query performance. This is a Remix/React Router web app using SQLite via Kysely.
IMPORTANT: run all analysis against `db-prod.sqlite3` — a copy of the production database (~2GB, safe to experiment with). Do NOT use `db.sqlite3` if `db-prod.sqlite3` exists (the dev db is tiny, so its query plans and table sizes are meaningless for performance analysis).
Here is the diff:
{diff}
@@ -197,82 +274,178 @@ Your job:
1. **Identify new or changed DB queries** in the diff. These live in `*Repository.server.ts` files and use Kysely. Read the full changed Repository files for context.
2. **For each query**, do the following:
2. **Get the real SQL** — do not mentally compile Kysely. Write a small throwaway script at `scripts/tmp-review-compile.ts` (it must live inside the project so vite-node resolves the `~` alias; delete it when you are done) that rebuilds the query with the project's `db` instance and prints the compiled SQL:
a. **Run EXPLAIN QUERY PLAN** against the dev database (`db.sqlite3`) using the Bash tool:
```ts
import { db } from "~/db/sql";
const compiled = db
.selectFrom(/* ...rebuild the query from the Repository code... */)
.compile();
console.log(compiled.sql);
console.log(compiled.parameters);
```
Run it with: `DB_PATH=db-prod.sqlite3 VITE_PROD_MODE=true pnpm vite-node scripts/tmp-review-compile.ts` (this matches how the project's own `bench:db` script runs). Substitute realistic parameter values from the printed parameters.
3. **For each query**:
a. **Run EXPLAIN QUERY PLAN** against the prod copy:
```
sqlite3 db.sqlite3 "EXPLAIN QUERY PLAN <the SQL query>"
sqlite3 db-prod.sqlite3 "EXPLAIN QUERY PLAN <the compiled SQL with parameters substituted>"
```
To get the raw SQL from Kysely, read the query and mentally compile it. Substitute realistic placeholder values for any parameters.
b. **Check for missing indexes**: Look at the EXPLAIN output for "SCAN TABLE" (full table scan) vs "SEARCH TABLE ... USING INDEX" or "USING COVERING INDEX". A SCAN on a large table in a hot path is a red flag.
b. **Measure table sizes — do not guess**: `sqlite3 db-prod.sqlite3 "SELECT COUNT(*) FROM <table>"` for every table the query touches.
c. **Check existing indexes**: Run `sqlite3 db.sqlite3 ".indexes <table_name>"` and `sqlite3 db.sqlite3 "PRAGMA index_info(<index_name>)"` to see what indexes exist.
c. **Check for missing indexes**: Look at the EXPLAIN output for "SCAN <table>" (full table scan) vs "SEARCH <table> ... USING INDEX" or "USING COVERING INDEX". A SCAN on a large table in a hot path is a red flag. Check existing indexes with `sqlite3 db-prod.sqlite3 ".indexes <table_name>"` and `PRAGMA index_info(<index_name>)`.
d. **Assess query context** — reason about:
- **Table size**: Is this a table with thousands/millions of rows (e.g., SplatoonPlayer, Build, GroupMatch) or a small config-like table (e.g., CalendarEventTag, TournamentBadgeOwner)?
- **Call frequency**: Is this query in a hot path (page loader hit on every page view, API called frequently) or a cold path (admin action, background routine, rare user action)?
- **N+1 patterns**: Is the query called inside a loop when it could be batched?
Use the route file or caller to determine how the Repository function is invoked.
d. **Assess call frequency**: Is this query in a hot path (page loader hit on every page view, API called frequently) or a cold path (admin action, background routine, rare user action)? Use the route file or caller to determine how the Repository function is invoked. Also check for N+1 patterns — a query called inside a loop that could be batched.
3. **Severity assessment**: Weight your findings by impact:
- **Critical**: Full table scan on a large table in a hot path, or N+1 query pattern
- **Warning**: Full table scan on a medium table, or missing index on a frequently-filtered column
- **Info**: Scan on a small table or infrequent query — note it but don't flag as a problem
4. **Severity**:
- **critical**: Full table scan on a large table (measured, not guessed) in a hot path, or N+1 query pattern
- **warning**: Full table scan on a medium table, or missing index on a frequently-filtered column
- **info**: Scan on a small table or infrequent query — note it but don't flag as a problem
4. **Do NOT flag**:
5. **Do NOT flag**:
- Queries that already use appropriate indexes
- Scans on tiny tables (< ~100 rows) that are accessed infrequently
- Scans on tiny tables (< ~100 measured rows) that are accessed infrequently
- Pre-existing queries not changed in this diff
Return a list of findings, each with: file path, the query (or a description of it), EXPLAIN QUERY PLAN output, table size assessment (small/medium/large), call frequency assessment (hot/warm/cold), severity (critical/warning/info), and a concrete suggestion if action is needed (e.g., "add index on X(Y)" or "batch these N queries into one with WHERE IN").
Return ONLY a JSON array of findings: [{file, line, category: "db-performance", severity, claim, detail (EXPLAIN output + measured row counts + call frequency assessment), suggestion (e.g. "add index on X(Y)" or "batch these N queries into one with WHERE IN")}]
```
## Step 4: Summarize
After all agents complete, launch a single summarizer agent that receives ALL agent outputs.
### Agent 9: Test Coverage
```
You are the final reviewer synthesizing code review feedback from up to 7 specialized agents.
You are reviewing code changes for test coverage gaps. This project uses Vitest for unit/browser tests and Playwright for e2e tests.
Here are their findings:
Here is the diff:
{diff}
{all_agent_outputs}
Changed files: {file_list}
Compare what logic changed against what tests changed:
1. **Changed pure-logic Modules** (files imported as `* as Module`, typically with JSDoc'd functions): do they have unit tests (`*.test.ts` alongside or in a nearby location)? Were the tests updated when the logic changed?
2. **New Repository READ functions**: the project convention is that these get benchmark cases in `scripts/benchmark-db` (run via `pnpm run bench:db`). Flag new read functions with no benchmark case.
3. **New routes or significant new user flows**: is there any e2e spec covering them? Check the e2e test directory for related specs.
4. **Bug-fix-shaped changes** (a conditional tightened, an edge case handled): is there a regression test proving the fix?
Do NOT flag:
- Trivial or purely presentational changes (styling, copy, layout)
- Code that is impractical to unit test and already covered transitively by e2e
- Pre-existing untested code not touched by this diff
Return ONLY a JSON array of findings: [{file, line, category: "test-coverage", severity, claim (what changed without coverage), detail, suggestion (what kind of test to add and where)}]
```
### Agent 10: Copy Editor (skip if the copy diff is empty AND the full diff adds no English user-facing strings)
This agent receives the **copy diff** (the `locales/en/` changes collected in Step 1) instead of the full diff, plus any added English user-facing strings from the full diff (JSX text, aria-labels, error messages, notification texts). It is never sharded.
```
You are proofreading English texts that will be shown to users of sendou.ink, a competitive Splatoon community website.
New/changed English strings from translation files (locales/en/*.json):
{copy_diff}
New English user-facing strings found in code (if any):
{code_strings}
Only review ADDED or CHANGED strings, not pre-existing ones. Check each for:
- Typos and misspellings
- Grammar mistakes (subject-verb agreement, articles, tense)
- Broken i18next interpolation: mismatched or misspelled `{{variable}}` placeholders, wrong plural key forms — compare against how the key is used in the code if unsure
- Unclear or confusing phrasing — would a user reading this out of context understand what to do?
- Unnecessarily technical or jargon-y wording where plainer language would be friendlier (e.g. "Invalid input" vs telling the user what to fix)
- Inconsistent terminology or capitalization vs the rest of the site — read the existing `locales/en/*.json` files to learn the established terms (e.g. how features like SendouQ, scrims, tournaments are referred to) and match them
Severity: typos, grammar mistakes, and broken interpolation are "warning". Clarity/friendliness rewording is "info".
Do NOT flag:
- Established community/game terminology that only looks unusual (Splatoon terms, ability names, mode names)
- Intentionally short labels where brevity fits the UI
- Style preferences that are a wash — only suggest a rewording if it is clearly better
Return ONLY a JSON array of findings: [{file, line, category: "copy", severity, claim (the problematic text, quoted), detail, suggestion (the corrected/improved text, quoted)}]
```
## Step 5: Adversarial verification
Collect all findings from the finder agents into one pool (parse their JSON outputs). **Do not verify** mechanical pre-check findings from Step 2 (already confirmed by tooling), db-performance findings that include measured EXPLAIN/COUNT evidence (already confirmed by measurement), or copy findings (proofreading is judgement, not a falsifiable claim — the summarizer filters these instead). Everything else gets verified.
For each remaining finding, launch a verifier agent. Run them in parallel. If there are more than ~12 findings, batch them by file so each verifier handles 2-4 findings from the same file. Hard ceiling: **at most 10 verifier agent calls** — with more findings than fits, grow the batch size rather than the agent count (a verifier can handle up to ~6 findings if they share files).
```
You are a skeptical senior engineer. Your job is to REFUTE the following code review finding(s). Assume each is a false positive until the code proves otherwise.
Finding(s):
{finding_json}
Relevant diff hunks:
{diff_excerpt}
Read the actual files involved. For each finding, check:
- Does the claimed problem actually exist in the code as written?
- Is it prevented by the surrounding code, the type system, validation upstream, or the framework?
- For bugs: can you trace a concrete input/state that triggers it?
- For abstraction findings: does the cited existing code actually fit this use case?
- For spec findings: does the code actually diverge from the spec, or did the finder misread one of them?
- Is the finding about NEW code in the diff, or a pre-existing issue?
If uncertain, default to refuted — a review's value depends on zero false positives.
Return ONLY a JSON array, one entry per finding: [{claim: "<copy of the finding's claim>", verdict: "confirmed" | "refuted", reason: "1-2 sentences"}]
```
Discard refuted findings. Confirmed findings (plus the exempted pre-checked/measured ones) proceed to the summarizer.
## Step 6: Summarize
Launch a single summarizer agent that receives all surviving findings.
```
You are the final reviewer synthesizing verified code review findings from multiple specialized agents plus mechanical pre-checks.
Here are the surviving findings (all have passed adversarial verification, mechanical checking, or direct measurement):
{surviving_findings_json}
Your job:
1. **Deduplicate**: Multiple agents may flag the same issue from different angles. Merge these into a single finding.
2. **Filter**: Remove low-quality suggestions that are:
- Nitpicks that wouldn't matter in practice
- False positives or theoretical issues unlikely to occur
- Suggestions that would make the code worse or more complex
- Pre-existing issues not introduced by the diff
3. **Prioritize** using this order: Security > Bugs > DB Query Performance (critical/warning only) > Spec Violations > Abstraction Issues > CLAUDE.md Violations > Modernization Suggestions > DB Query Performance (info)
4. **Format** the output as a single cohesive review
1. **Deduplicate**: Multiple agents may flag the same issue from different angles. Merge these into a single finding, keeping the best explanation.
2. **Filter**: Even verified findings can be nitpicks. Remove suggestions that wouldn't matter in practice or would make the code worse or more complex.
3. **Prioritize** using this order: Typecheck failures > Security > Bugs > DB Query Performance (critical/warning only) > Spec Violations > Test Coverage > Abstraction Issues > CLAUDE.md Violations > Copy (warning: typos/grammar/broken interpolation) > Modernization Suggestions > Copy (info: clarity rewordings) > DB Query Performance (info)
Output format:
Return ONLY a JSON array of the final findings, in priority order, each shaped: {file, line, category, severity, claim, detail, suggestion}
```
## Step 7: Report
Report the summarizer's final findings with the **ReportFindings tool** (you, the main agent, call it — not a subagent): one call, findings ranked most-severe first, each with `file`, `line`, `summary` (claim + suggestion), `short_summary`, `category`, `failure_scenario` (from detail), and `verdict: "CONFIRMED"` for findings that passed verification or measurement, `"PLAUSIBLE"` otherwise. Do not also print the findings list as text — after the ReportFindings call, write only a short prose wrap-up (issue count, overall shape of the review, anything notable like sharding or skipped agents).
If the ReportFindings tool is not available in the session, fall back to printing this format instead:
### Code Review
**{N} issues found** (or "No issues found — looks good!" if none survive filtering)
For each surviving issue, in priority order:
For each finding, in priority order:
**{priority_number}. [{category}] {brief title}**
`{file_path}`
`{file_path}:{line}`
{description — 1-3 sentences explaining the problem and a concrete suggestion}
---
At the end, if there were modernization suggestions that survived filtering, group them under a separate "Suggestions" section (these are nice-to-haves, not blockers).
```
At the end, group surviving modernization suggestions and info-level copy rewordings under a separate "Suggestions" section (these are nice-to-haves, not blockers).
## Important notes
- Use `subagent_type: "Explore"` for Agent 5 (Abstraction Police) since it needs to search the codebase broadly
- Use `subagent_type: "general-purpose"` for Agent 7 (DB Query Performance) since it needs to run sqlite3 commands via Bash
- Use `subagent_type: "general-purpose"` for the other agents
- Use `model: "sonnet"` for agents 1-7 and `model: "opus"` for the summarizer
- Use `subagent_type: "Explore"` for Agent 6 (Abstraction Police) since it needs to search the codebase broadly
- Use `subagent_type: "general-purpose"` for Agent 8 (DB Query Performance) since it needs to run sqlite3 commands and vite-node scripts via Bash
- Use `subagent_type: "general-purpose"` for the other finder agents, the verifiers, and the summarizer
- Use `model: "sonnet"` for finder and verifier agents and `model: "opus"` for the summarizer
- Pass the actual diff content and file list to each agent — do not tell them to run git commands themselves
- If the diff is very large (>2000 lines), mention this to the user and note that the review may miss some issues
- Present the summarizer's output directly to the user as the final review
- If a finder agent returns malformed JSON, salvage what you can by reading its output; do not re-run it
- Present the review via ReportFindings as described in Step 7

View File

@@ -11,7 +11,7 @@ description: Help write, debug, and explore SQLite database queries using Kysely
|------|---------|-------------|
| `db.sqlite3` | Development database | Writing/testing new features, running the dev server |
| `db-prod.sqlite3` | Copy of production database | Exploring real data, investigating bugs, verifying assumptions about data shape |
| `db-test.sqlite3` | Unit test database (blank + migrations) | Unit tests only |
| `db-test.sqlite3` | Unit test database (blank + migrations, created automatically by test runs) | Unit tests only |
`db-prod.sqlite3` can be modified if the task requires it. **Never modify `db-copy.sqlite3`** — it is the untouched backup.

View File

@@ -27,6 +27,12 @@ VITE_STATIC_ASSETS_URL=https://sendou-assets.nyc3.cdn.digitaloceanspaces.com
TWITCH_CLIENT_ID=
TWITCH_CLIENT_SECRET=
// Discord webhook mod events (user reports, bans) are posted to (skipped when unset)
MOD_DISCORD_WEBHOOK_URL=
// Discord webhook SendouQ match cancellations are posted to (skipped when unset)
SQ_CANCEL_DISCORD_WEBHOOK_URL=
SKALOP_SYSTEM_MESSAGE_URL=http://localhost:5900/system
SKALOP_TOKEN=secret
REDIS_URL=redis://redis:6379

View File

@@ -23,10 +23,13 @@ jobs:
- name: Start MinIO
run: docker compose up -d minio
# the bucket, not the health endpoint: MinIO answers health/live while it is still
# bootstrapping, so a run whose bucket never got created would only surface much later
# as a 500 in the one test that uploads an image
- name: Wait for MinIO to be ready
run: |
for i in {1..30}; do
if curl -sf http://127.0.0.1:9000/minio/health/live; then
if curl -sf -o /dev/null http://127.0.0.1:9000/sendou/; then
echo "MinIO is ready"
exit 0
fi

View File

@@ -40,5 +40,3 @@ jobs:
run: pnpm run check-plural-collapse
- name: Check articles
run: pnpm run check-articles
- name: Check test DB migrations
run: pnpm run check-test-db-migrations

3
.gitignore vendored
View File

@@ -11,8 +11,6 @@ translation-progress.md
notes.md
db*.sqlite3*
!db-test.sqlite3
!e2e/seeds/*.sqlite3
dump
.DS_Store
@@ -33,6 +31,7 @@ dump
# Real baselines have pattern: *-chromium-darwin.png
**/__screenshots__/**/*-[0-9].png
.e2e-minio-started
.e2e-build-marker
notepad.txt

View File

@@ -44,6 +44,12 @@
- new routes need to be added to `routes.ts`
## Search params
- all URL search param handling goes through `app/modules/search-params/`, see [search-params.md](./docs/dev/search-params.md) for the conventions
- never use raw `useSearchParams` or `searchParams.get()`; declare params once per feature in a `<feature>-search-params.ts` definition (every param has a default, decode never fails). Enforced by the `no-raw-search-params` Biome plugin
- every definition gets a round-trip test via `assertRoundTrips`
## Styling
- use CSS modules
@@ -57,14 +63,15 @@
## SQL
- database is Sqlite3 used with the Kysely library
- database code should only be written in Repository files
- database is Sqlite3, driven by Node's built-in `node:sqlite` through a custom Kysely dialect (`app/db/node-sqlite-dialect.ts`)
- database code should only be written in Repository files, see [repositories.md](./docs/dev/repositories.md) for their conventions
- migrations are Kysely migrations in `/migrations`, scaffolded with `pnpm run migrate:new "description"` and applied with `pnpm run migrate up`, see [how-to.md](./docs/dev/how-to.md)
- down migrations are not needed, only up migrations
- every database id is of type number
- if we are working on a branch by default we should add to the migration this branch added instead of creating a brand new one
- `/app/db/tables.ts` contains all tables and columns available
- `/app/db/tables.ts` contains all tables and columns available, see [database-schemas.md](./docs/dev/database-schemas.md) for how columns should be typed (booleans, timestamps, JSON, enums, SQLite migration quirks)
- `db.sqlite3` is development database
- `db-test.sqlite3` is the unit test database (should be blank sans migrations ran)
- `db-test.sqlite3` is the unit test database (blank sans migrations; gitignored and created/migrated automatically when unit tests run)
- `db-prod.sqlite3` is a copy of the production environment db which can be freely experimented with
## Unit testing

View File

@@ -1,5 +1,7 @@
# Contributing
Thank you for taking interest in contributing to sendou.ink! Please note that mainly AI driven contributions from people who are not in the community or using the project are not wanted and will not be considered.
## How to contribute
Reading the [architecture.md](./docs/dev/architecture.md) file is highly recommended before writing any code to get up to the speed with how the project folder structure works and getting familiar with its concepts.
@@ -16,14 +18,14 @@ You can also just directly make a pull request but untracked feature requests mi
## Making pull request
1. Identify an issue to work on
First of all note that a well detailed issue that explains a problem from user point of view with clear examples and code level investigation is often more valuable than a pull request.
- Issues I have identified as potentially good for external contributors [are marked with the help wanted tag.](https://github.com/Sendouc/sendou.ink/issues?q=is%3Aissue+is%3Aopen+label%3A%22help+wanted%22)
- "good first issue" are tasks that might be smaller in scope and easier to get started with
When making a pull request what makes it easy to review (which makes it likely to be processed faster) include following:
2. Make pull request
- Tell me how I can test it and include unit / E2E tests if possible.
1) Change is pre-discussed in an issue or GitHub, direction agreed upon
2) If many unrelated changes are made, they are opened as separate pull requests (smaller is better generally speaking)
3) UI changes are showcased with screenshots before/after
4) Appropriate automated tests exist
## Help

View File

@@ -7,6 +7,7 @@ import styles from "./Ability.module.css";
import { Image } from "./Image";
const sizeMap = {
HUGE: 64,
MAIN: 42,
SUB: 32,
SUBTINY: 26,

View File

@@ -3,7 +3,7 @@ import * as React from "react";
import type { Tables } from "~/db/tables";
import { useHydrated } from "~/hooks/useHydrated";
import { LRUCache } from "~/modules/cache";
import { BLANK_IMAGE_URL, discordAvatarUrl } from "~/utils/urls";
import { BLANK_IMAGE_URL, resolveAvatarUrl } from "~/utils/urls";
import styles from "./Avatar.module.css";
const dimensions = {
@@ -132,19 +132,23 @@ export function Avatar({
const identiconSource = identiconInput ?? user?.discordId ?? "unknown";
const src = url
? url
: user?.customAvatarUrl && !isErrored
? user.customAvatarUrl
: user?.discordAvatar && !isErrored
? discordAvatarUrl({
discordAvatar: user.discordAvatar,
discordId: user.discordId,
size: size === "lg" || size === "xmd" ? "lg" : "sm",
})
: isClient
? generateIdenticon(identiconSource, dimensions[size], 7)
: BLANK_IMAGE_URL;
const userAvatarUrl = user
? resolveAvatarUrl({
customAvatarUrl: user.customAvatarUrl,
discordId: user.discordId,
discordAvatar: user.discordAvatar,
size: size === "lg" || size === "xmd" ? "lg" : "sm",
})
: undefined;
const avatarUrl = url ?? userAvatarUrl;
const src =
avatarUrl && !isErrored
? avatarUrl
: isClient
? generateIdenticon(identiconSource, dimensions[size], 7)
: BLANK_IMAGE_URL;
return (
<div className={clsx(styles.avatarWrapper, className)}>

View File

@@ -1,16 +1,30 @@
import clsx from "clsx";
import { Lock, MessageCircleMore, SquarePen, Trash } from "lucide-react";
import {
HardDriveDownload,
Lock,
MessageCircleMore,
SquarePen,
Trash,
} from "lucide-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import type { GearType, Tables, UserWithPlusTier } from "~/db/tables";
import type { Tables } from "~/db/tables";
import { useUser } from "~/features/auth/core/user";
import type { BuildWeaponWithTop500Info } from "~/features/builds/builds-types";
import {
BuildGraphic,
type BuildGraphicOwner,
} from "~/features/img-export/components/BuildGraphic";
import { ImageExportDialog } from "~/features/img-export/components/ImageExportDialog";
import type {
Ability as AbilityType,
BuildAbilitiesTuple,
GearType,
ModeShort,
} from "~/modules/in-game-lists/types";
import { canonicalWeaponSplId } from "~/modules/in-game-lists/weapon-ids";
import type { UserWithPlusTier } from "~/utils/kysely.server";
import { gearTypeToInitial } from "~/utils/strings";
import {
analyzerPage,
@@ -26,6 +40,7 @@ import { Ability } from "./Ability";
import styles from "./BuildCard.module.css";
import { LinkButton, SendouButton } from "./elements/Button";
import { SendouPopover } from "./elements/Popover";
import { SendouSwitch } from "./elements/Switch";
import { FormWithConfirm } from "./FormWithConfirm";
import { Image } from "./Image";
import { LocaleTime } from "./LocaleTime";
@@ -40,17 +55,27 @@ interface BuildProps {
| "headGearSplId"
| "shoesGearSplId"
| "updatedAt"
| "private"
| "isPrivate"
> & {
abilities: BuildAbilitiesTuple;
modes: ModeShort[] | null;
weapons: Array<BuildWeaponWithTop500Info>;
};
owner?: Pick<UserWithPlusTier, "discordId" | "username" | "plusTier">;
owner?: Pick<UserWithPlusTier, "discordId" | "username" | "plusTier"> &
Partial<
Pick<BuildGraphicOwner, "customUrl" | "discordAvatar" | "customAvatarUrl">
>;
/** Set to false when the page context already shows the owner (e.g. their own builds page) */
showOwner?: boolean;
canEdit?: boolean;
}
export function BuildCard({ build, owner, canEdit = false }: BuildProps) {
export function BuildCard({
build,
owner,
showOwner = true,
canEdit = false,
}: BuildProps) {
const user = useUser();
const { t } = useTranslation(["weapons", "builds", "common", "game-misc"]);
@@ -73,7 +98,7 @@ export function BuildCard({ build, owner, canEdit = false }: BuildProps) {
return (
<div
className={clsx(styles.card, { [styles.private]: build.private })}
className={clsx(styles.card, { [styles.private]: build.isPrivate })}
data-testid="build-card"
>
<div>
@@ -98,22 +123,22 @@ export function BuildCard({ build, owner, canEdit = false }: BuildProps) {
</h2>
</div>
<div className={styles.dateAuthorRow}>
{owner && (
{owner && showOwner ? (
<>
<Link to={userBuildsPage(owner)} className={styles.ownerLink}>
{owner.username}
</Link>
<div></div>
</>
)}
{owner?.plusTier ? (
) : null}
{owner?.plusTier && showOwner ? (
<>
<span>+{owner.plusTier}</span>
<div></div>
</>
) : null}
<div className="stack horizontal sm items-center">
{build.private ? (
{build.isPrivate ? (
<div className={styles.privateText}>
<Lock size={16} /> {t("common:build.private")}
</div>
@@ -178,6 +203,7 @@ export function BuildCard({ build, owner, canEdit = false }: BuildProps) {
path={navIconUrl("analyzer")}
/>
</LinkButton>
{owner ? <BuildImageExportDialog build={build} owner={owner} /> : null}
{description ? (
<SendouPopover
trigger={
@@ -227,6 +253,67 @@ export function BuildCard({ build, owner, canEdit = false }: BuildProps) {
);
}
function BuildImageExportDialog({
build,
owner,
}: {
build: BuildProps["build"];
owner: BuildGraphicOwner;
}) {
const { t } = useTranslation(["common"]);
const [showTitle, setShowTitle] = React.useState(true);
const [showAbilityPoints, setShowAbilityPoints] = React.useState(true);
const [showAbilityChunks, setShowAbilityChunks] = React.useState(false);
return (
<ImageExportDialog
trigger={
<SendouButton
shape="circle"
size="small"
variant="minimal"
icon={<HardDriveDownload />}
className={styles.smallText}
aria-label={t("common:imageExport.export")}
/>
}
heading={t("common:imageExport.export")}
filename={`build-${mySlugify(build.title)}`}
qrCodePath={analyzerPage({
weaponId: build.weapons[0].weaponSplId,
abilities: build.abilities.flat(),
})}
settings={
<>
<SendouSwitch isSelected={showTitle} onChange={setShowTitle}>
{t("common:imageExport.buildTitle")}
</SendouSwitch>
<SendouSwitch
isSelected={showAbilityPoints}
onChange={setShowAbilityPoints}
>
{t("common:imageExport.abilityPoints")}
</SendouSwitch>
<SendouSwitch
isSelected={showAbilityChunks}
onChange={setShowAbilityChunks}
>
{t("common:imageExport.abilityChunks")}
</SendouSwitch>
</>
}
>
<BuildGraphic
build={build}
owner={owner}
showTitle={showTitle}
showAbilityPoints={showAbilityPoints}
showAbilityChunks={showAbilityChunks}
/>
</ImageExportDialog>
);
}
function RoundWeaponImage({ weapon }: { weapon: BuildWeaponWithTop500Info }) {
const normalizedWeaponSplId = canonicalWeaponSplId(weapon.weaponSplId);

View File

@@ -45,7 +45,7 @@ export function Catcher() {
if (isNetworkError) {
return (
<Main>
<ErrorMain>
<ErrorGirlImage />
<h2 className="text-center">Connection error</h2>
<p className="text-center">
@@ -55,7 +55,7 @@ export function Catcher() {
<div className="mt-4 stack sm items-center">
<RefreshPageButton />
</div>
</Main>
</ErrorMain>
);
}
@@ -68,7 +68,7 @@ export function Catcher() {
})();
return (
<Main>
<ErrorMain>
<ErrorGirlImage />
<h2 className="text-center">Error happened</h2>
<p className="text-center">
@@ -84,7 +84,7 @@ export function Catcher() {
</div>
</div>
) : null}
</Main>
</ErrorMain>
);
}
@@ -92,7 +92,7 @@ export function Catcher() {
case 401:
if (!user) {
return (
<Main>
<ErrorMain>
<h2>Authentication required</h2>
<p>This page requires you to be logged in.</p>
<form action={LOG_IN_URL} method="post" className="mt-2">
@@ -100,36 +100,36 @@ export function Catcher() {
Log in via Discord
</SendouButton>
</form>
</Main>
</ErrorMain>
);
}
return (
<Main>
<ErrorMain>
<h2>Error 401 Unauthorized</h2>
<GetHelp />
</Main>
</ErrorMain>
);
case 403:
return (
<Main>
<ErrorMain>
<h2>Error 403 Forbidden</h2>
<p className="text-sm text-lighter font-semi-bold">
Your account doesn't have the required permissions to perform this
action.
</p>
<GetHelp />
</Main>
</ErrorMain>
);
case 404:
return (
<Main>
<ErrorMain>
<h2>Error {error.status} - Page not found</h2>
<GetHelp />
</Main>
</ErrorMain>
);
default:
return (
<Main>
<ErrorMain>
<h2>Error {error.status}</h2>
<GetHelp />
<div className="text-sm text-lighter font-semi-bold">
@@ -142,11 +142,16 @@ export function Catcher() {
? `\n${typeof error.data === "string" ? error.data : JSON.stringify(error.data, null, 2)}`
: null}
</pre>
</Main>
</ErrorMain>
);
}
}
/** Every branch of the error page, marked so tests can assert one is not shown. */
function ErrorMain({ children }: { children: React.ReactNode }) {
return <Main testId="error-page">{children}</Main>;
}
function GetHelp() {
return (
<p className="mt-2">

View File

@@ -13,6 +13,7 @@ import { useRef } from "react";
import { Line } from "react-chartjs-2";
import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat";
import { useHydrated } from "~/hooks/useHydrated";
import { useThemeColors } from "~/hooks/useThemeColors";
import styles from "./Chart.module.css";
ChartJS.register(
@@ -87,43 +88,19 @@ export default function Chart({
});
// Get the chart colors from CSS variables
const [colors, setColors] = React.useState({
accentHigh: "",
infoHigh: "",
secondHigh: "",
accentLow: "",
secondLow: "",
border: "",
borderHigh: "",
text: "",
const colors = useThemeColors({
// bright "high" variants for the curve lines so they stay legible on the dark chart
accentHigh: "--color-text-accent",
infoHigh: "--color-info-high",
secondHigh: "--color-second-high",
// low variants for the highlight marker fills (paired with a light border)
accentLow: "--color-accent-low",
secondLow: "--color-second-low",
border: "--color-border",
borderHigh: "--color-border-high",
text: "--color-text-high",
});
React.useEffect(() => {
const resolve = () => {
const get = (v: string) =>
getComputedStyle(document.documentElement).getPropertyValue(v).trim();
setColors({
// bright "high" variants for the curve lines so they stay legible on the dark chart
accentHigh: get("--color-text-accent"),
infoHigh: get("--color-info-high"),
secondHigh: get("--color-second-high"),
// low variants for the highlight marker fills (paired with a light border)
accentLow: get("--color-accent-low"),
secondLow: get("--color-second-low"),
border: get("--color-border"),
borderHigh: get("--color-border-high"),
text: get("--color-text-high"),
});
};
resolve();
const root = document.documentElement;
const observer = new MutationObserver(resolve);
observer.observe(root, { attributes: true, attributeFilter: ["class"] });
return () => observer.disconnect();
}, []);
const scaleDefaults = React.useMemo(
() => ({
grid: { color: colors.border },

View File

@@ -1,11 +1,11 @@
import { Check, Clipboard, PencilLine } from "lucide-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import type { CustomTheme } from "~/db/tables-json";
import {
CUSTOM_THEME_VARS,
type CustomTheme,
type CustomThemeVar,
} from "~/db/tables";
} from "~/features/theme/theme-constants";
import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
import {
ACCENT_CHROMA_MULTIPLIERS,

View File

@@ -0,0 +1,27 @@
.pagination {
display: flex;
flex-wrap: wrap;
gap: var(--s-3);
justify-content: center;
align-items: center;
max-width: 20rem;
margin: 0 auto;
margin-top: var(--s-2);
}
.button {
background-color: var(--color-bg);
border-radius: 100%;
padding: var(--s-1);
height: 24px;
width: 24px;
border: var(--border-style);
font-size: var(--font-xs);
color: var(--color-text-high);
}
.buttonActive {
color: var(--color-text-accent);
background-color: var(--color-bg-high);
border-color: var(--color-border-high);
}

View File

@@ -0,0 +1,38 @@
import { clsx } from "clsx";
import { SendouButton } from "~/components/elements/Button";
import styles from "./DotPagination.module.css";
export function DotPagination({
pagesCount,
currentPage,
setPage,
ariaLabelPrefix,
"data-testid": testId,
className,
}: {
pagesCount: number;
currentPage: number;
setPage: (page: number) => void;
ariaLabelPrefix: string;
"data-testid"?: string;
className?: string;
}) {
return (
<div className={clsx(styles.pagination, className)}>
{Array.from({ length: pagesCount }, (_, i) => (
<SendouButton
key={i}
variant="minimal"
aria-label={`${ariaLabelPrefix} page ${i + 1}`}
onPress={() => setPage(i + 1)}
className={clsx(styles.button, {
[styles.buttonActive]: currentPage === i + 1,
})}
data-testid={testId}
>
{i + 1}
</SendouButton>
))}
</div>
);
}

View File

@@ -63,7 +63,7 @@ export function EventsList({
const groupedEvents = events.reduce<Record<string, typeof events>>(
(acc, event) => {
const key = getDayKey(event.startTime);
const key = getDayKey(event.startsAt);
if (!acc[key]) {
acc[key] = [];
}
@@ -79,7 +79,7 @@ export function EventsList({
<>
{dayKeys.map((dayKey) => {
const dayEvents = groupedEvents[dayKey];
const firstDate = new Date(dayEvents[0].startTime * 1000);
const firstDate = new Date(dayEvents[0].startsAt * 1000);
return (
<div key={dayKey}>
@@ -89,7 +89,8 @@ export function EventsList({
key={`${event.type}-${event.id}`}
to={event.url}
imageUrl={event.logoUrl ?? undefined}
subtitle={timeFormatter.format(event.startTime)}
user={event.user ?? undefined}
subtitle={timeFormatter.format(event.startsAt)}
onClick={onClick}
>
{event.scrimStatus === "booked"

View File

@@ -12,7 +12,7 @@ export function Flag({
const { i18n } = useTranslation();
return (
<div
<span
className={clsx(`twf twf-${countryCode.toLowerCase()}`, {
"twf-s": tiny,
})}

View File

@@ -56,23 +56,17 @@ export function FormWithConfirm({
const isControlled = isOpen !== undefined;
const dialogOpen = isControlled ? isOpen : internalOpen;
const openDialog = React.useCallback(() => {
const openDialog = () => {
onOpenChange?.(true);
setInternalOpen(true);
}, [onOpenChange]);
const closeDialog = React.useCallback(() => {
};
const closeDialog = () => {
onOpenChange?.(false);
setInternalOpen(false);
}, [onOpenChange]);
};
invariant(!children || React.isValidElement(children));
React.useEffect(() => {
if (fetcher.state === "loading") {
closeDialog();
}
}, [fetcher.state, closeDialog]);
return (
<>
{isHydrated
@@ -84,6 +78,7 @@ export function FormWithConfirm({
ref={formRef}
method="post"
action={action}
onSubmit={closeDialog}
>
{fields?.map(([name, value]) => (
<input type="hidden" key={name} name={name} value={value} />

View File

@@ -1,13 +1,8 @@
import clsx from "clsx";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useFetcher } from "react-router";
import { Image } from "~/components/Image";
import { InfoPopover } from "~/components/InfoPopover";
import { Input } from "~/components/Input";
import { Label } from "~/components/Label";
import { SubmitButton } from "~/components/SubmitButton";
import { FRIEND_CODE_REGEXP_PATTERN } from "~/features/sendouq/q-constants";
import { addFriendCodeSchema } from "~/features/sendouq/q-schemas";
import { SendouForm } from "~/form/SendouForm";
import { navIconUrl, SENDOUQ_PAGE } from "~/utils/urls";
const FC_INFO_IMAGE_URL = navIconUrl("fc-info");
@@ -17,60 +12,39 @@ export function FriendCodeInput({
}: {
friendCode?: string | null;
}) {
const fetcher = useFetcher();
const { t } = useTranslation(["common"]);
const id = React.useId();
if (friendCode) {
return <div className="font-bold text-center">SW-{friendCode}</div>;
}
return (
<fetcher.Form method="post" action={SENDOUQ_PAGE}>
<input type="hidden" name="revalidateRoot" value="true" />
<div
className={clsx("stack sm horizontal items-end", {
"justify-center": friendCode,
})}
>
<div>
{!friendCode ? (
<div className="stack horizontal xs items-center">
<Label htmlFor={id}>{t("common:fc.title")}</Label>
<InfoPopover tiny>
<div className="stack sm">
<div className="text-xs font-bold">
{t("common:fc.whereToFind")}
</div>
<Image
path={FC_INFO_IMAGE_URL}
alt={t("common:fc.whereToFind")}
width={320}
/>
<SendouForm
schema={addFriendCodeSchema}
action={SENDOUQ_PAGE}
revalidateRoot
submitButtonText={t("common:actions.save")}
>
{({ FormField }) => (
<div className="stack sm">
<FormField name="friendCode" />
<div className="stack horizontal sm items-center text-lighter text-xs">
{t("common:fc.onceSetStaffOnly")}
<InfoPopover tiny>
<div className="stack sm">
<div className="text-xs font-bold">
{t("common:fc.whereToFind")}
</div>
</InfoPopover>
</div>
) : null}
{friendCode ? (
<div className="font-bold">SW-{friendCode}</div>
) : (
<Input
leftAddon="SW-"
id={id}
name="friendCode"
pattern={FRIEND_CODE_REGEXP_PATTERN}
placeholder="1234-5678-9012"
required
/>
)}
<Image
path={FC_INFO_IMAGE_URL}
alt={t("common:fc.whereToFind")}
width={320}
/>
</div>
</InfoPopover>
</div>
</div>
{!friendCode ? (
<SubmitButton _action="ADD_FRIEND_CODE" state={fetcher.state}>
{t("common:actions.save")}
</SubmitButton>
) : null}
</div>
{!friendCode ? (
<div className="text-lighter text-xs mt-2">
{t("common:fc.onceSetStaffOnly")}
</div>
) : null}
</fetcher.Form>
)}
</SendouForm>
);
}

View File

@@ -5,13 +5,13 @@ import {
SendouSelectItemSection,
} from "~/components/elements/Select";
import { Image } from "~/components/Image";
import type { GearType } from "~/db/tables";
import { brandIds } from "~/modules/in-game-lists/brand-ids";
import {
clothesGearBrandGrouped,
headGearBrandGrouped,
shoesGearBrandGrouped,
} from "~/modules/in-game-lists/gear-ids";
import type { GearType } from "~/modules/in-game-lists/types";
import { brandImageUrl, gearImageUrl } from "~/utils/urls";
import styles from "./WeaponSelect.module.css";

View File

@@ -10,6 +10,7 @@ export const Main = ({
bigger,
breakoutContainer,
style,
testId,
}: {
children: React.ReactNode;
className?: string;
@@ -18,6 +19,7 @@ export const Main = ({
bigger?: boolean;
breakoutContainer?: boolean;
style?: React.CSSProperties;
testId?: string;
}) => {
return (
<main
@@ -37,6 +39,7 @@ export const Main = ({
)
}
data-main-breakout={breakoutContainer || undefined}
data-testid={testId}
style={style}
>
{children}

View File

@@ -15,11 +15,12 @@ import {
import * as React from "react";
import { Dialog, Modal, ModalOverlay } from "react-aria-components";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import { Link, useLocation } from "react-router";
import { useUser } from "~/features/auth/core/user";
import { useChatContext } from "~/features/chat/useChatContext";
import { FriendMenu } from "~/features/friends/components/FriendMenu";
import { SENDOUQ_ACTIVITY_LABEL } from "~/features/friends/friends-constants";
import { canAccessTrophies } from "~/features/trophies/trophies-utils";
import { useLayoutSize } from "~/hooks/useMainContentWidth";
import { useUnseenFriendRequests } from "~/hooks/useUnseenFriendRequests";
import type { RootLoaderData } from "~/root";
@@ -27,6 +28,7 @@ import {
EVENTS_PAGE,
FRIENDS_PAGE,
navIconUrl,
SENDOU_INK_BASE_URL,
SETTINGS_PAGE,
SUPPORT_PAGE,
userPage,
@@ -44,6 +46,7 @@ import {
import { navItems } from "./layout/nav-items";
import styles from "./MobileNav.module.css";
import { NotificationDot } from "./NotificationDot";
import { ShareUrlButton } from "./ShareUrlButton";
import { StreamListItems } from "./StreamListItems";
type SidebarData = RootLoaderData["sidebar"] | undefined;
@@ -346,6 +349,7 @@ function MenuOverlay({
}) {
const { t } = useTranslation(["front", "common"]);
const user = useUser();
const location = useLocation();
return (
<ModalOverlay
@@ -377,6 +381,11 @@ function MenuOverlay({
{t("common:pages.support")}
</LinkButton>
) : null}
<ShareUrlButton
variant="minimal"
shape="square"
url={`${SENDOU_INK_BASE_URL}${location.pathname}${location.search}`}
/>
<button
type="button"
className={styles.panelCloseButton}
@@ -389,25 +398,29 @@ function MenuOverlay({
<nav aria-label={t("front:mobileNav.menu")}>
<ul className={styles.navGrid}>
{navItems.map((item) => (
<li key={item.name}>
<Link
to={`/${item.url}`}
className={styles.navItem}
onClick={onClose}
>
<div className={styles.navItemImage}>
<Image
path={navIconUrl(item.name)}
height={32}
width={32}
alt=""
/>
</div>
<span>{t(`common:pages.${item.name}` as any)}</span>
</Link>
</li>
))}
{navItems
.filter(
(item) => item.name !== "trophies" || canAccessTrophies(user),
)
.map((item) => (
<li key={item.name}>
<Link
to={`/${item.url}`}
className={styles.navItem}
onClick={onClose}
>
<div className={styles.navItemImage}>
<Image
path={navIconUrl(item.name)}
height={32}
width={32}
alt=""
/>
</div>
<span>{t(`common:pages.${item.name}` as any)}</span>
</Link>
</li>
))}
</ul>
</nav>

View File

@@ -0,0 +1,44 @@
import { Share2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import { SendouButton } from "~/components/elements/Button";
import { CopyToClipboardPopover } from "./CopyToClipboardPopover";
export function ShareUrlButton({
url,
...buttonProps
}: { url: string } & React.ComponentProps<typeof SendouButton>) {
const { t } = useTranslation(["common"]);
const canNativeShare =
typeof navigator !== "undefined" && typeof navigator.share === "function";
if (canNativeShare) {
return (
<SendouButton
variant="outlined"
size="small"
shape="circle"
icon={<Share2 />}
onPress={() => navigator.share({ url })}
aria-label={t("common:actions.share")}
{...buttonProps}
/>
);
}
return (
<CopyToClipboardPopover
url={url}
trigger={
<SendouButton
variant="outlined"
size="small"
shape="circle"
icon={<Share2 />}
aria-label={t("common:actions.share")}
{...buttonProps}
/>
}
/>
);
}

View File

@@ -268,6 +268,7 @@
.listLinkSubtitleRow {
display: flex;
align-items: center;
gap: var(--s-1-5);
width: 100%;
color: var(--color-text-high);
}

View File

@@ -16,6 +16,7 @@ interface StageSelectProps<Clearable extends boolean | undefined = undefined> {
clearable?: Clearable;
testId?: string;
isRequired?: boolean;
isDisabled?: boolean;
}
export function StageSelect<Clearable extends boolean | undefined = undefined>({
@@ -26,6 +27,7 @@ export function StageSelect<Clearable extends boolean | undefined = undefined>({
clearable,
testId = "stage-select",
isRequired,
isDisabled,
}: StageSelectProps<Clearable>) {
const { t } = useTranslation(["common", "game-misc"]);
const items = useStageItems();
@@ -54,6 +56,7 @@ export function StageSelect<Clearable extends boolean | undefined = undefined>({
clearable={clearable}
data-testid={testId}
isRequired={isRequired}
isDisabled={isDisabled}
>
{({ id, name }) => (
<SendouSelectItem key={id} id={id} textValue={name}>

View File

@@ -1,3 +1,24 @@
:root {
--tier-bg-1: hsl(45, 100%, 47%);
--tier-bg-2: hsl(280, 90%, 44%);
--tier-bg-3: hsl(280, 65%, 52%);
--tier-bg-4: hsl(212, 95%, 44%);
--tier-bg-5: hsl(212, 80%, 51%);
--tier-bg-6: hsl(145, 80%, 34%);
--tier-bg-7: hsl(145, 60%, 41%);
--tier-bg-8: hsl(220, 10%, 40%);
--tier-bg-9: hsl(220, 8%, 49%);
--tier-text-1: hsl(45, 100%, 12%);
--tier-text-2: hsl(280, 100%, 94%);
--tier-text-3: hsl(280, 100%, 95%);
--tier-text-4: hsl(212, 100%, 93%);
--tier-text-5: hsl(212, 100%, 95%);
--tier-text-6: hsl(145, 85%, 92%);
--tier-text-7: hsl(145, 75%, 94%);
--tier-text-8: hsl(220, 15%, 92%);
--tier-text-9: hsl(220, 15%, 95%);
}
.pill {
font-size: var(--font-2xs);
font-weight: var(--weight-bold);
@@ -11,46 +32,95 @@
}
.tierX {
background-color: hsl(45, 100%, 30%);
color: hsl(45, 100%, 90%);
--_polished-bg: var(--tier-bg-1);
background-color: var(--tier-bg-1);
color: var(--tier-text-1);
}
.tierSPlus {
background-color: hsl(280, 60%, 35%);
color: hsl(280, 80%, 90%);
--_polished-bg: var(--tier-bg-2);
background-color: var(--tier-bg-2);
color: var(--tier-text-2);
}
.tierS {
background-color: hsl(280, 50%, 40%);
color: hsl(280, 70%, 92%);
--_polished-bg: var(--tier-bg-3);
background-color: var(--tier-bg-3);
color: var(--tier-text-3);
}
.tierAPlus {
background-color: hsl(210, 60%, 35%);
color: hsl(210, 80%, 90%);
background-color: var(--tier-bg-4);
color: var(--tier-text-4);
}
.tierA {
background-color: hsl(210, 50%, 40%);
color: hsl(210, 70%, 92%);
background-color: var(--tier-bg-5);
color: var(--tier-text-5);
}
.tierBPlus {
background-color: hsl(140, 45%, 32%);
color: hsl(140, 60%, 90%);
background-color: var(--tier-bg-6);
color: var(--tier-text-6);
}
.tierB {
background-color: hsl(140, 35%, 38%);
color: hsl(140, 50%, 92%);
background-color: var(--tier-bg-7);
color: var(--tier-text-7);
}
.tierCPlus {
background-color: hsl(0, 0%, 35%);
color: hsl(0, 0%, 90%);
background-color: var(--tier-bg-8);
color: var(--tier-text-8);
}
.tierC {
background-color: hsl(0, 0%, 40%);
color: hsl(0, 0%, 92%);
background-color: var(--tier-bg-9);
color: var(--tier-text-9);
}
.polished {
position: relative;
overflow: hidden;
background-image: linear-gradient(
160deg,
hsl(from var(--_polished-bg) calc(h + 2) s calc(l + 14)),
var(--_polished-bg) 50%,
hsl(from var(--_polished-bg) calc(h - 3) s calc(l - 9))
);
&::after {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(
115deg,
transparent 35%,
hsl(0, 0%, 100%, 0.5) 50%,
transparent 65%
);
transform: translateX(-100%);
animation: shine-sweep 6s ease-in-out infinite;
}
}
@keyframes shine-sweep {
0% {
transform: translateX(-100%);
}
12% {
transform: translateX(100%);
}
100% {
transform: translateX(100%);
}
}
@media (prefers-reduced-motion: reduce) {
.polished::after {
animation: none;
visibility: hidden;
}
}

View File

@@ -15,6 +15,8 @@ const TIER_STYLE_CLASS: Record<number, string> = {
9: styles.tierC,
};
const POLISHED_TIERS = [1, 2, 3];
export function TierPill({
tier,
isTentative = false,
@@ -29,7 +31,9 @@ export function TierPill({
return (
<div
className={clsx(styles.pill, tierClass)}
className={clsx(styles.pill, tierClass, {
[styles.polished]: POLISHED_TIERS.includes(tier),
})}
data-testid={isTentative ? "tentative-tier" : "confirmed-tier"}
title={
isTentative

View File

@@ -78,3 +78,39 @@
outline: initial;
}
}
.weekSelection {
& .grid {
/* the week band has to run unbroken across the row, including the empty cells of a month's first and last week */
border-spacing: 0;
}
& tr:hover td,
& tr:has([data-selected]) td {
background-color: var(--color-bg-high);
}
& tr:hover td:first-child,
& tr:has([data-selected]) td:first-child {
border-start-start-radius: var(--radius-field);
border-end-start-radius: var(--radius-field);
}
& tr:hover td:last-child,
& tr:has([data-selected]) td:last-child {
border-start-end-radius: var(--radius-field);
border-end-end-radius: var(--radius-field);
}
& .cell {
&:hover {
background-color: transparent;
}
/* the week is what gets selected, so the day it was picked from is not called out */
&[data-selected] {
background-color: transparent;
color: inherit;
}
}
}

View File

@@ -17,14 +17,22 @@ import styles from "./Calendar.module.css";
export interface SendouCalendarProps<T extends DateValue>
extends CalendarProps<T> {
className?: string;
/** Highlights the whole week row rather than a single day, for pickers where choosing a day means choosing the week it belongs to. */
weekSelection?: boolean;
}
export function SendouCalendar<T extends DateValue>({
className,
weekSelection,
...rest
}: SendouCalendarProps<T>) {
return (
<Calendar className={clsx(className, styles.root)} {...rest}>
<Calendar
className={clsx(className, styles.root, {
[styles.weekSelection]: weekSelection,
})}
{...rest}
>
<header className={styles.header}>
<Button slot="previous" className={styles.navButton}>
<ChevronLeft className={styles.navIcon} />

View File

@@ -34,7 +34,7 @@
.modal {
width: 100%;
max-width: 28rem;
max-height: 80dvh;
max-height: min(80dvh, calc(var(--visual-viewport-height, 100dvh) - 10rem));
overflow-x: hidden;
overflow-y: auto;
border-radius: 1rem;
@@ -54,7 +54,7 @@
}
:global(html[data-fuse="true"]) .modal {
max-height: 72dvh;
max-height: min(72dvh, calc(var(--visual-viewport-height, 100dvh) - 10rem));
}
.fullScreenModal {

View File

@@ -0,0 +1,122 @@
import clsx from "clsx";
import * as React from "react";
import { ListBoxItem, type SelectProps } from "react-aria-components";
import { useFetcher } from "react-router";
import type { SearchLoaderData } from "~/features/search/routes/search";
import { SearchSelect } from "./SearchSelect";
import searchSelectStyles from "./SearchSelect.module.css";
import selectStyles from "./Select.module.css";
import { useEntitySearch } from "./useEntitySearch";
export type OrganizationSearchResult = Extract<
NonNullable<SearchLoaderData>["results"][number],
{ type: "organization" }
>;
interface OrganizationSearchProps<T extends object>
extends Omit<SelectProps<T>, "children" | "onChange"> {
name?: string;
label?: string;
bottomText?: string;
errorText?: string;
initialOrganizationId?: number;
onChange?: (organization: OrganizationSearchResult | null) => void;
}
export const OrganizationSearch = React.forwardRef(function OrganizationSearch<
T extends object,
>(
{
name,
label,
bottomText,
errorText,
initialOrganizationId,
onChange,
...rest
}: OrganizationSearchProps<T>,
ref?: React.Ref<HTMLButtonElement>,
) {
const initialOrganization = useInitialOrganization(initialOrganizationId);
const search = useEntitySearch<OrganizationSearchResult>({
buildUrl: (query) => `/search?q=${query}&type=organizations&limit=6`,
parseResults: (data, query) =>
parseOrganizationResults(data, query, initialOrganization),
initialItem: initialOrganization,
initialSelectedId: initialOrganizationId,
onChange,
});
return (
<SearchSelect
{...rest}
name={name}
label={label}
bottomText={bottomText}
errorText={errorText}
ariaLabel="Organization search"
inputTestId="organization-search-input"
i18nKey="organizationSearch"
search={search}
buttonRef={ref}
renderItem={(item) => <OrganizationItem item={item} />}
/>
);
});
function parseOrganizationResults(
data: unknown,
query: string,
initialOrganization?: OrganizationSearchResult,
): OrganizationSearchResult[] | null {
const searchData = data as SearchLoaderData;
if (!searchData || searchData.query !== query) return null;
return searchData.results
.filter(
(result): result is OrganizationSearchResult =>
result.type === "organization",
)
.filter((org) => org.id !== initialOrganization?.id);
}
function useInitialOrganization(initialOrganizationId?: number) {
const fetcher = useFetcher<SearchLoaderData>();
React.useEffect(() => {
if (!initialOrganizationId || fetcher.state !== "idle" || fetcher.data) {
return;
}
fetcher.load(
`/search?q=${initialOrganizationId}&type=organizations&limit=1`,
);
}, [initialOrganizationId, fetcher]);
return fetcher.data?.results.find(
(result): result is OrganizationSearchResult =>
result.type === "organization",
);
}
function OrganizationItem({ item }: { item: OrganizationSearchResult }) {
return (
<ListBoxItem
id={item.id}
textValue={item.name}
className={({ isFocused, isSelected }) =>
clsx(searchSelectStyles.item, {
[selectStyles.itemFocused]: isFocused,
[selectStyles.itemSelected]: isSelected,
})
}
data-testid="organization-search-item"
>
<div className={searchSelectStyles.itemTextsContainer}>
{item.name}
<div className={searchSelectStyles.itemAdditionalText}>
/{item.slug}
</div>
</div>
</ListBoxItem>
);
}

View File

@@ -22,6 +22,10 @@ import selectStyles from "./Select.module.css";
import type { EntitySearch } from "./useEntitySearch";
const PLACEHOLDER_TEXTS = {
organizationSearch: {
placeholder: "common:forms.organizationSearch.placeholder",
noResults: "common:forms.organizationSearch.noResults",
},
teamSearch: {
placeholder: "common:forms.teamSearch.placeholder",
noResults: "common:forms.teamSearch.noResults",

View File

@@ -1,5 +1,5 @@
import clsx from "clsx";
import * as React from "react";
import type * as React from "react";
import { ListBoxItem, type SelectProps } from "react-aria-components";
import type { SearchLoaderData } from "~/features/search/routes/search";
import { SearchSelect } from "./SearchSelect";
@@ -22,22 +22,19 @@ interface TeamSearchProps<T extends object>
/** Team to preselect and display on mount (e.g. when editing a linked team). */
initialTeam?: { id: number; name: string; avatarUrl?: string | null };
onChange?: (team: TeamSearchResult | null) => void;
ref?: React.Ref<HTMLButtonElement>;
}
export const TeamSearch = React.forwardRef(function TeamSearch<
T extends object,
>(
{
name,
label,
bottomText,
errorText,
initialTeam,
onChange,
...rest
}: TeamSearchProps<T>,
ref?: React.Ref<HTMLButtonElement>,
) {
export function TeamSearch<T extends object>({
name,
label,
bottomText,
errorText,
initialTeam,
onChange,
ref,
...rest
}: TeamSearchProps<T>) {
const search = useEntitySearch<TeamSearchResult>({
buildUrl: (query) => `/search?q=${query}&type=teams&limit=6`,
parseResults: parseTeamResults,
@@ -61,7 +58,7 @@ export const TeamSearch = React.forwardRef(function TeamSearch<
renderItem={(item) => <TeamItem item={item} />}
/>
);
});
}
function parseTeamResults(
data: unknown,

View File

@@ -1,15 +1,16 @@
import clsx from "clsx";
import { sub } from "date-fns";
import * as React from "react";
import type * as React from "react";
import { ListBoxItem, type SelectProps } from "react-aria-components";
import type { TournamentSearchLoaderData } from "~/features/tournament/routes/to.search";
import { tournamentSearchSearchParams } from "~/features/tournament/tournament-search-params";
import { LocaleTime } from "../LocaleTime";
import { SearchSelect } from "./SearchSelect";
import searchSelectStyles from "./SearchSelect.module.css";
import selectStyles from "./Select.module.css";
import { useEntitySearch } from "./useEntitySearch";
type TournamentSearchItem = NonNullable<
export type TournamentSearchItem = NonNullable<
Extract<TournamentSearchLoaderData, { tournaments: unknown }>
>["tournaments"][number];
@@ -27,28 +28,33 @@ interface TournamentSearchProps<T extends object>
*/
pastOnly?: boolean;
onChange?: (tournament: TournamentSearchItem | null) => void;
ref?: React.Ref<HTMLButtonElement>;
}
export const TournamentSearch = React.forwardRef(function TournamentSearch<
T extends object,
>(
{
name,
label,
bottomText,
errorText,
initialTournamentId,
pastOnly,
onChange,
...rest
}: TournamentSearchProps<T>,
ref?: React.Ref<HTMLButtonElement>,
) {
export function TournamentSearch<T extends object>({
name,
label,
bottomText,
errorText,
initialTournamentId,
pastOnly,
onChange,
ref,
...rest
}: TournamentSearchProps<T>) {
const search = useEntitySearch<TournamentSearchItem>({
buildUrl: (query) =>
pastOnly
? `/to/search?q=${query}&limit=6&maxStartTime=${new Date().toISOString()}`
: `/to/search?q=${query}&limit=6&minStartTime=${sub(new Date(), { days: 7 }).toISOString()}`,
? tournamentSearchSearchParams.href("/to/search", {
q: query,
limit: 6,
maxStartTime: new Date(),
})
: tournamentSearchSearchParams.href("/to/search", {
q: query,
limit: 6,
minStartTime: sub(new Date(), { days: 7 }),
}),
parseResults: parseTournamentResults,
initialSelectedId: initialTournamentId,
onChange,
@@ -69,7 +75,7 @@ export const TournamentSearch = React.forwardRef(function TournamentSearch<
renderItem={(item) => <TournamentItem item={item} />}
/>
);
});
}
function parseTournamentResults(
data: unknown,
@@ -99,7 +105,7 @@ function TournamentItem({ item }: { item: TournamentSearchItem }) {
<div className={searchSelectStyles.itemTextsContainer}>
<span>{item.name}</span>
<LocaleTime
date={item.startTime}
date={item.startsAt}
options={{
day: "numeric",
month: "numeric",

View File

@@ -22,22 +22,19 @@ interface UserSearchProps<T extends object>
errorText?: string;
initialUserId?: number;
onChange?: (user: UserSearchResult | null) => void;
ref?: React.Ref<HTMLButtonElement>;
}
export const UserSearch = React.forwardRef(function UserSearch<
T extends object,
>(
{
name,
label,
bottomText,
errorText,
initialUserId,
onChange,
...rest
}: UserSearchProps<T>,
ref?: React.Ref<HTMLButtonElement>,
) {
export function UserSearch<T extends object>({
name,
label,
bottomText,
errorText,
initialUserId,
onChange,
ref,
...rest
}: UserSearchProps<T>) {
const initialUser = useInitialUser(initialUserId);
const search = useEntitySearch<UserSearchResult>({
@@ -64,7 +61,7 @@ export const UserSearch = React.forwardRef(function UserSearch<
renderItem={(item) => <UserItem item={item} />}
/>
);
});
}
function parseUserResults(
data: unknown,
@@ -78,16 +75,23 @@ function parseUserResults(
.filter((user) => user.id !== initialUser?.id);
}
/** Resolves the full user object for a preselected id so it can be displayed. */
/**
* Resolves the full user object for a preselected id so it can be displayed.
* Loads at most once per field: later id changes come from the user picking a
* result, which already carries the full user object.
*/
function useInitialUser(initialUserId?: number) {
const fetcher = useFetcher<SearchLoaderData>();
const { load } = fetcher;
const hasLoadedRef = React.useRef(false);
React.useEffect(() => {
if (!initialUserId || fetcher.state !== "idle" || fetcher.data) {
if (!initialUserId || hasLoadedRef.current) {
return;
}
fetcher.load(`/search?q=${initialUserId}&type=users&limit=1`);
}, [initialUserId, fetcher]);
hasLoadedRef.current = true;
load(`/search?q=${initialUserId}&type=users&limit=1`);
}, [initialUserId, load]);
return fetcher.data?.results.find(
(result): result is UserSearchResult => result.type === "user",

View File

@@ -64,11 +64,13 @@ export function useEntitySearch<TItem extends { id: number }>({
[query],
);
React.useEffect(() => {
const prevInitialSelectedId = React.useRef(initialSelectedId);
if (initialSelectedId !== prevInitialSelectedId.current) {
prevInitialSelectedId.current = initialSelectedId;
if (typeof initialSelectedId === "number") {
setSelectedKey(initialSelectedId);
}
}, [initialSelectedId]);
}
const items = withInitialItem(
toEntitySearchItems(parseResults(queryFetcher.data, query)),
@@ -80,19 +82,23 @@ export function useEntitySearch<TItem extends { id: number }>({
);
// clear the selection when its item is no longer among the results
const realItemIdsKey = realItems.map((item) => item.id).join(",");
const isSelectionValid =
!selectedKey ||
selectedKey === initialSelectedId ||
realItems.length === 0 ||
realItems.some((item) => item.id === selectedKey);
const effectiveSelectedKey = isSelectionValid ? selectedKey : null;
const prevEffectiveSelectedKey = React.useRef(effectiveSelectedKey);
React.useEffect(() => {
if (!realItemIdsKey) return;
const ids = realItemIdsKey.split(",").map(Number);
if (
selectedKey &&
selectedKey !== initialSelectedId &&
!ids.includes(selectedKey)
) {
const selectionInvalidated =
!isSelectionValid && prevEffectiveSelectedKey.current !== null;
prevEffectiveSelectedKey.current = effectiveSelectedKey;
if (selectionInvalidated) {
setSelectedKey(null);
onChange?.(null);
}
}, [realItemIdsKey, selectedKey, onChange, initialSelectedId]);
});
const onSelectionChange = (key: number) => {
setSelectedKey(key);
@@ -102,7 +108,13 @@ export function useEntitySearch<TItem extends { id: number }>({
}
};
return { filterText, setFilterText, items, selectedKey, onSelectionChange };
return {
filterText,
setFilterText,
items,
selectedKey: effectiveSelectedKey,
onSelectionChange,
};
}
function toEntitySearchItems<TItem extends { id: number }>(

View File

@@ -1,4 +1,4 @@
import { useEffect, useRef } from "react";
import { useEffect } from "react";
import { useLocation } from "react-router";
declare global {
@@ -20,18 +20,10 @@ function callPageInit() {
export function FusePageInit() {
const { pathname } = useLocation();
const previousPathname = useRef(pathname);
useEffect(() => {
callPageInit();
}, []);
useEffect(() => {
if (previousPathname.current === pathname) return;
previousPathname.current = pathname;
callPageInit();
});
}, [pathname]);
return null;
}

View File

@@ -1,10 +1,12 @@
import { Plus } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useUser } from "~/features/auth/core/user";
import { canAccessTrophies } from "~/features/trophies/trophies-utils";
import {
CALENDAR_NEW_PAGE,
lfgNewPostPage,
NEW_TEAM_PAGE,
NEW_TROPHY_PAGE,
navIconUrl,
newArtPage,
newAssociationsPage,
@@ -97,6 +99,14 @@ export function AnythingAdder({ compact }: { compact?: boolean }) {
imagePath: navIconUrl("plus"),
href: plusSuggestionsNewPage(),
},
canAccessTrophies(user)
? {
id: "trophy",
children: t("header.adder.trophy"),
imagePath: navIconUrl("trophies"),
href: NEW_TROPHY_PAGE,
}
: null,
].filter((item) => item !== null);
return (

View File

@@ -1,19 +1,19 @@
import type { TFunction } from "i18next";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { useSearchParams } from "react-router";
import { SendouDialog } from "~/components/elements/Dialog";
import { useUser } from "~/features/auth/core/user";
import { useHydrated } from "~/hooks/useHydrated";
import { useSearchParam } from "~/modules/search-params/hooks";
import { SENDOU_INK_DISCORD_URL } from "~/utils/urls";
import { authErrorSearchParams } from "./layout-search-params";
import styles from "./UserItem.module.css";
export function AuthErrorDialog() {
const { t } = useTranslation();
const isHydrated = useHydrated();
const user = useUser();
const [searchParams] = useSearchParams();
const authError = searchParams.get("authError");
const [authError] = useSearchParam(authErrorSearchParams, "authError");
if (authError == null || !isHydrated || user) return null;

View File

@@ -13,6 +13,7 @@ import {
SENDOU_LOVE_EMOJI_PATH,
SUPPORT_PAGE,
userPage,
WELCOME_PAGE,
} from "~/utils/urls";
declare const __GIT_COMMIT__: string;
@@ -37,6 +38,7 @@ export function Footer() {
<div className={styles.linkList}>
<Link to={CONTRIBUTIONS_PAGE}>{t("pages.contributors")}</Link>
<Link to={FAQ_PAGE}>{t("pages.faq")}</Link>
<Link to={WELCOME_PAGE}>{t("pages.welcome")}</Link>
<Link to={API_PAGE}>{t("pages.api")}</Link>
{showPrivacySettings ? <div data-fuse-privacy-tool /> : null}
</div>

View File

@@ -14,14 +14,17 @@ import {
RadioGroup,
} from "react-aria-components";
import { useTranslation } from "react-i18next";
import { useFetcher, useSearchParams } from "react-router";
import { useFetcher } from "react-router";
import { Avatar } from "~/components/Avatar";
import { Image } from "~/components/Image";
import { Input } from "~/components/Input";
import { LocaleTime } from "~/components/LocaleTime";
import type { SearchLoaderData } from "~/features/search/routes/search";
import { searchSearchParams } from "~/features/search/search-search-params";
import { useDebounce } from "~/hooks/useDebounce";
import { useHydrated } from "~/hooks/useHydrated";
import type { MainWeaponId } from "~/modules/in-game-lists/types";
import { useSearchParamsTyped } from "~/modules/search-params/hooks";
import {
navIconUrl,
teamPage,
@@ -30,25 +33,21 @@ import {
weaponCategoryUrl,
} from "~/utils/urls";
import styles from "./GlobalSearch.module.css";
import {
globalSearchSearchParams,
GLOBAL_SEARCH_TYPES as SEARCH_TYPES,
type GlobalSearchType as SearchType,
} from "./global-search-search-params";
import {
filterWeaponResults,
getRecentWeapons,
type SelectedWeapon,
saveRecentWeapon,
useRecentWeapons,
WeaponDestinationMenu,
WeaponResultsList,
weaponToSelectedWeapon,
} from "./WeaponSearch";
const SEARCH_TYPES = [
"weapons",
"users",
"teams",
"organizations",
"tournaments",
] as const;
type SearchType = (typeof SEARCH_TYPES)[number];
const SEARCH_TYPE_TO_PREFIX: Record<SearchType, string> = {
weapons: "w",
users: "u",
@@ -85,31 +84,29 @@ function getInitialSearchType(): SearchType {
return "weapons";
}
function persistSearchType(type: SearchType) {
try {
localStorage.setItem(STORAGE_KEY, type);
} catch {
// localStorage may be unavailable
}
}
export function GlobalSearch() {
const { t } = useTranslation(["common"]);
// TODO: use zod validated search params
const [searchParams, setSearchParams] = useSearchParams();
const [isMac, setIsMac] = React.useState(false);
const [params, setParams] = useSearchParamsTyped(globalSearchSearchParams);
const isHydrated = useHydrated();
const isMac = isHydrated && /Mac|iPhone|iPad|iPod/.test(navigator.userAgent);
const searchParamOpen = searchParams.get("search") === "open";
const searchParamType = searchParams.get("type");
const searchParamWeapon = searchParams.get("weapon");
const initialSearchType =
searchParamType && SEARCH_TYPES.includes(searchParamType as SearchType)
? (searchParamType as SearchType)
: null;
const searchParamOpen = params.search === "open";
const [isOpen, setIsOpen] = React.useState(searchParamOpen);
React.useEffect(() => {
if (searchParamOpen) {
setIsOpen(true);
}
}, [searchParamOpen]);
React.useEffect(() => {
setIsMac(/Mac|iPhone|iPad|iPod/.test(navigator.userAgent));
}, []);
const prevSearchParamOpen = React.useRef(searchParamOpen);
if (searchParamOpen && !prevSearchParamOpen.current) {
setIsOpen(true);
}
prevSearchParamOpen.current = searchParamOpen;
React.useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
@@ -126,13 +123,16 @@ export function GlobalSearch() {
const handleOpenChange = (open: boolean) => {
setIsOpen(open);
if (!open && (searchParamOpen || searchParamType || searchParamWeapon)) {
const newParams = new URLSearchParams(searchParams);
newParams.delete("search");
newParams.delete("type");
newParams.delete("weapon");
setSearchParams(newParams, { replace: true });
if (open) return;
if (
params.search === null &&
params.type === null &&
params.weapon === null
) {
return;
}
setParams({ search: null, type: null, weapon: null });
};
return (
@@ -147,8 +147,8 @@ export function GlobalSearch() {
<Dialog className={styles.dialog} aria-label={t("common:search")}>
<GlobalSearchContent
onClose={() => setIsOpen(false)}
initialSearchType={initialSearchType}
initialWeaponId={searchParamWeapon}
initialSearchType={params.type}
initialWeaponId={params.weapon}
/>
</Dialog>
</Modal>
@@ -158,15 +158,13 @@ export function GlobalSearch() {
}
function resolveInitialWeapon(
weaponIdStr: string | null,
weaponId: MainWeaponId | null,
t: TFunction<["common", "weapons"]>,
): SelectedWeapon | null {
if (!weaponIdStr) return null;
const id = Number(weaponIdStr) as MainWeaponId;
if (Number.isNaN(id)) return null;
const name = t(`weapons:MAIN_${id}`);
if (!name || name === `MAIN_${id}`) return null;
return weaponToSelectedWeapon(id, t);
if (weaponId === null) return null;
const name = t(`weapons:MAIN_${weaponId}`);
if (!name || name === `MAIN_${weaponId}`) return null;
return weaponToSelectedWeapon(weaponId, t);
}
function GlobalSearchContent({
@@ -176,7 +174,7 @@ function GlobalSearchContent({
}: {
onClose: () => void;
initialSearchType: SearchType | null;
initialWeaponId: string | null;
initialWeaponId: MainWeaponId | null;
}) {
const { t } = useTranslation(["common", "weapons"]);
const [query, setQuery] = React.useState("");
@@ -197,6 +195,7 @@ function GlobalSearchContent({
};
const fetcher = useFetcher<SearchLoaderData>();
const recentWeaponIds = useRecentWeapons();
React.useEffect(() => {
if (!selectedWeapon) {
@@ -204,20 +203,16 @@ function GlobalSearchContent({
}
}, [selectedWeapon]);
React.useEffect(() => {
try {
localStorage.setItem(STORAGE_KEY, searchType);
} catch {
// localStorage may be unavailable
}
}, [searchType]);
useDebounce(
() => {
if (searchType === "weapons") return;
if (query.length < 3) return;
fetcher.load(
`/search?q=${encodeURIComponent(query)}&type=${searchType}&limit=10`,
searchSearchParams.href("/search", {
q: query,
type: searchType as Exclude<SearchType, "weapons">,
limit: 10,
}),
);
},
300,
@@ -237,7 +232,7 @@ function GlobalSearchContent({
const recentWeapons: SelectedWeapon[] =
searchType === "weapons"
? getRecentWeapons().map((id) => weaponToSelectedWeapon(id, t))
? recentWeaponIds.map((id) => weaponToSelectedWeapon(id, t))
: [];
const handleSelect = (key: React.Key) => {
@@ -259,6 +254,7 @@ function GlobalSearchContent({
const handleSearchTypeChange = (value: string) => {
setSearchType(value as SearchType);
persistSearchType(value as SearchType);
setSelectedWeapon(null);
};
@@ -277,6 +273,7 @@ function GlobalSearchContent({
);
if (matchedType) {
setSearchType(matchedType);
persistSearchType(matchedType);
setSelectedWeapon(null);
setQuery("");
return;
@@ -509,7 +506,7 @@ function ResultItem({ result }: { result: SearchResult }) {
<div className={styles.resultTexts}>
<span className={styles.resultName}>{result.name}</span>
<LocaleTime
date={result.startTime}
date={result.startsAt}
options={{ day: "numeric", month: "long", year: "numeric" }}
className={styles.resultSecondary}
/>

View File

@@ -7,6 +7,7 @@ import { Config } from "~/config";
import { NZAP_TEST_ID } from "~/db/seed/constants";
import { ADMIN_ID } from "~/features/admin/admin-constants";
import { useUser } from "~/features/auth/core/user";
import { canAccessTrophies } from "~/features/trophies/trophies-utils";
import {
impersonateUrl,
navIconUrl,
@@ -22,6 +23,10 @@ const DEV_IMPERSONATE_ITEMS = [
{ name: "Logged out", icon: "log_in", action: STOP_IMPERSONATING_URL },
] as const;
const DEV_LINK_ITEMS = [
{ name: "Components", icon: "settings", url: "/components" },
] as const;
const NAV_CATEGORIES = [
{
name: "play",
@@ -61,7 +66,7 @@ const NAV_CATEGORIES = [
{ name: "art", url: "art" },
{ name: "articles", url: "a" },
{ name: "vods", url: "vods" },
{ name: "badges", url: "badges" },
{ name: "trophies", url: "trophies" },
{ name: "links", url: "links" },
{ name: "plus", url: "plus/suggestions" },
],
@@ -125,6 +130,25 @@ function DevMenu() {
</button>
</Form>
))}
{DEV_LINK_ITEMS.map((item) => (
<Link
key={item.name}
to={item.url}
className={styles.menuItem}
onClick={() => {
setIsOpen(false);
setIsPreviewSuppressed(true);
}}
>
<Image
path={navIconUrl(item.icon)}
alt=""
size={20}
className={styles.menuItemIcon}
/>
{item.name}
</Link>
))}
</div>
</SendouPopover>
{!isOpen && !isPreviewSuppressed ? (
@@ -149,6 +173,19 @@ function DevMenu() {
</button>
</Form>
))}
{DEV_LINK_ITEMS.map((item) => (
<Link
key={item.name}
to={item.url}
className={styles.previewIcon}
title={item.name}
aria-label={item.name}
tabIndex={-1}
onClick={() => setIsPreviewSuppressed(true)}
>
<Image path={navIconUrl(item.icon)} alt="" size={20} />
</Link>
))}
</div>
) : null}
</div>
@@ -167,9 +204,11 @@ function CategoryMenu({
const isStaff = user?.roles.includes("STAFF") ?? false;
const showStaffOnly = isStaff || process.env.NODE_ENV === "development";
const visibleItems = category.items.filter(
(item) => !("staffOnly" in item) || showStaffOnly,
);
const visibleItems = category.items.filter((item) => {
if ("staffOnly" in item && !showStaffOnly) return false;
if (item.name === "trophies" && !canAccessTrophies(user)) return false;
return true;
});
return (
<div className={styles.menuWrapper}>

View File

@@ -11,7 +11,7 @@ import {
Users,
Videotape,
} from "lucide-react";
import type * as React from "react";
import * as React from "react";
import { ListBox, ListBoxItem } from "react-aria-components";
import { useTranslation } from "react-i18next";
import { Image } from "~/components/Image";
@@ -311,12 +311,28 @@ export function WeaponResultsList({
const RECENT_WEAPONS_KEY = "command-palette-recent-weapons";
const MAX_RECENT_WEAPONS = 5;
export function getRecentWeapons(): MainWeaponId[] {
if (typeof window === "undefined") return [];
const recentWeaponsListeners = new Set<() => void>();
function subscribeRecentWeapons(listener: () => void) {
recentWeaponsListeners.add(listener);
window.addEventListener("storage", listener);
return () => {
recentWeaponsListeners.delete(listener);
window.removeEventListener("storage", listener);
};
}
function getRecentWeaponsSnapshot() {
try {
const stored = localStorage.getItem(RECENT_WEAPONS_KEY);
if (!stored) return [];
const parsed = JSON.parse(stored);
return localStorage.getItem(RECENT_WEAPONS_KEY) ?? "[]";
} catch {
return "[]";
}
}
function parseRecentWeapons(raw: string): MainWeaponId[] {
try {
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return parsed.filter(
(id): id is MainWeaponId =>
@@ -327,13 +343,25 @@ export function getRecentWeapons(): MainWeaponId[] {
}
}
export function useRecentWeapons(): MainWeaponId[] {
const raw = React.useSyncExternalStore(
subscribeRecentWeapons,
getRecentWeaponsSnapshot,
() => "[]",
);
return parseRecentWeapons(raw);
}
export function saveRecentWeapon(weaponId: MainWeaponId): void {
try {
const recent = getRecentWeapons();
const recent = parseRecentWeapons(getRecentWeaponsSnapshot());
const filtered = recent.filter((id) => id !== weaponId);
const updated = [weaponId, ...filtered].slice(0, MAX_RECENT_WEAPONS);
localStorage.setItem(RECENT_WEAPONS_KEY, JSON.stringify(updated));
} catch {
// localStorage may be unavailable
}
for (const listener of recentWeaponsListeners) {
listener();
}
}

View File

@@ -0,0 +1,25 @@
import { describe, it } from "vitest";
import {
assertDecodesToDefault,
assertRoundTrips,
} from "~/modules/search-params/search-params-test-utils";
import { globalSearchSearchParams } from "./global-search-search-params";
describe("globalSearchSearchParams", () => {
it("round-trips", () => {
assertRoundTrips(globalSearchSearchParams, {
search: [null, "open"],
type: [null, "weapons", "users", "teams", "organizations", "tournaments"],
weapon: [null, 0, 10, 8000],
});
});
it("malformed values decode to defaults", () => {
assertDecodesToDefault(globalSearchSearchParams, "search", [["closed"]]);
assertDecodesToDefault(globalSearchSearchParams, "type", [["USER"]]);
assertDecodesToDefault(globalSearchSearchParams, "weapon", [
["99999"],
["abc"],
]);
});
});

View File

@@ -0,0 +1,20 @@
import { z } from "zod";
import { mainWeaponIds } from "~/modules/in-game-lists/weapon-ids";
import * as SearchParams from "~/modules/search-params/search-params";
import { SP } from "~/modules/search-params/search-params";
import { numericEnum } from "~/utils/zod";
export const GLOBAL_SEARCH_TYPES = [
"weapons",
"users",
"teams",
"organizations",
"tournaments",
] as const;
export type GlobalSearchType = (typeof GLOBAL_SEARCH_TYPES)[number];
export const globalSearchSearchParams = SearchParams.define({
search: SP.param(z.enum(["open"]).nullable(), { loader: false }),
type: SP.param(z.enum(GLOBAL_SEARCH_TYPES).nullable(), { loader: false }),
weapon: SP.param(numericEnum(mainWeaponIds).nullable(), { loader: false }),
});

View File

@@ -31,6 +31,7 @@ import { useLayoutSize } from "~/hooks/useMainContentWidth";
import { usePrefersReducedMotion } from "~/hooks/usePrefersReducedMotion";
import { useUnseenFriendRequests } from "~/hooks/useUnseenFriendRequests";
import { useVisualViewportHeight } from "~/hooks/useVisualViewportHeight";
import { useSearchParam } from "~/modules/search-params/hooks";
import type { RootLoaderData } from "~/root";
import type { Breadcrumb, SendouRouteHandle } from "~/utils/remix.server";
import {
@@ -50,11 +51,11 @@ import { NotificationDot } from "../NotificationDot";
import { ListLink, SideNav, SideNavFooter, SideNavHeader } from "../SideNav";
import sideNavStyles from "../SideNav.module.css";
import { StreamListItems } from "../StreamListItems";
import { AuthErrorDialog } from "./AuthErrorDialog";
import { ChatSidebar } from "./ChatSidebar";
import { Footer } from "./Footer";
import styles from "./index.module.css";
import { LogInButtonContainer } from "./LogInButtonContainer";
import { authErrorSearchParams } from "./layout-search-params";
import { NotificationContent, useNotifications } from "./NotificationPopover";
import notificationPopoverStyles from "./NotificationPopover.module.css";
import { TopNavMenus } from "./TopNavMenus";
@@ -62,6 +63,14 @@ import { TopRightButtons } from "./TopRightButtons";
const MAX_DESKTOP_FRIENDS = 4;
// lazy loaded so the rarely needed auth error dialog stays out of the eager
// bundle loaded on every page
const AuthErrorDialog = React.lazy(() =>
import("./AuthErrorDialog").then((module) => ({
default: module.AuthErrorDialog,
})),
);
/** Id of the loading-bar track rendered inside the header. NProgress mounts its
* bar into it; the track sits just below the header border, spans only the area
* between the sidebars, and clips the bar so it never extends over a sidebar.
@@ -240,25 +249,29 @@ export function Layout({
const { formatRelativeDate } = useRelativeDayFormat();
const isHydrated = useHydrated();
const location = useLocation();
const [authError] = useSearchParam(authErrorSearchParams, "authError");
const headerRef = React.useRef<HTMLElement>(null);
const navOffset = useNavOffset(headerRef);
React.useEffect(() => {
const handleResize = () => {
if (window.innerWidth < 600 || window.innerWidth >= 1000) {
setSideNavModalOpen(false);
setChatSidebarModalOpen(false);
}
};
// modals only exist in the tablet layout, close them when resizing out of
// it or navigating to another page (setChatOpen is left as is on purpose,
// it belongs to a parent component and thus cannot be set during render)
const prevLayoutSize = React.useRef(layoutSize);
const prevPathname = React.useRef(location.pathname);
const leftTabletLayout =
prevLayoutSize.current === "tablet" && layoutSize !== "tablet";
const pathnameChanged = prevPathname.current !== location.pathname;
prevLayoutSize.current = layoutSize;
prevPathname.current = location.pathname;
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, []);
React.useEffect(() => {
setSideNavModalOpen(false);
setChatSidebarModalOpen(false);
}, [location.pathname]);
if (leftTabletLayout || pathnameChanged) {
if (sideNavModalOpen) {
setSideNavModalOpen(false);
}
if (chatSidebarModalOpen) {
setChatSidebarModalOpen(false);
}
}
const user = useUser();
const { unseenIds } = useNotifications();
@@ -304,9 +317,10 @@ export function Layout({
key={`${event.type}-${event.id}`}
to={event.url}
imageUrl={event.logoUrl ?? undefined}
user={event.user ?? undefined}
subtitle={
isHydrated ? (
formatRelativeDate(event.startTime)
formatRelativeDate(event.startsAt)
) : (
<span className="invisible">Placeholder</span>
)
@@ -488,7 +502,11 @@ export function Layout({
<ChatSidebar onClose={() => setChatSidebarOpen(false)} />
</div>
) : null}
<AuthErrorDialog />
{typeof authError === "string" ? (
<React.Suspense>
<AuthErrorDialog />
</React.Suspense>
) : null}
</>
);
}

View File

@@ -0,0 +1,7 @@
import { z } from "zod";
import * as SearchParams from "~/modules/search-params/search-params";
import { SP } from "~/modules/search-params/search-params";
export const authErrorSearchParams = SearchParams.define({
authError: SP.param(z.string().nullable(), { loader: false }),
});

View File

@@ -59,8 +59,8 @@ export const navItems = [
prefetch: false,
},
{
name: "badges",
url: "badges",
name: "trophies",
url: "trophies",
prefetch: false,
},
{

View File

@@ -21,7 +21,7 @@
}
}
.withPoints {
.withKo {
grid-template-areas:
"header header header"
"actions actions actions"

View File

@@ -39,8 +39,8 @@ interface MatchActionTabProps {
ownTeamId: number | null;
stageId: StageId;
mode: ModeShort;
withPoints: boolean;
onSubmit?: (data: { winnerId: number; points?: [number, number] }) => void;
withKo: boolean;
onSubmit?: (data: { winnerId: number; ko?: boolean }) => void;
isSubmitting?: boolean;
setEnding?: SetEndingData;
actionButtons?: React.ReactNode;
@@ -52,7 +52,7 @@ export function MatchActionTab({
ownTeamId,
stageId,
mode,
withPoints,
withKo,
onSubmit,
isSubmitting,
setEnding,
@@ -73,14 +73,7 @@ export function MatchActionTab({
const submit = () => {
if (winnerId === null) return;
const submitPoints: [number, number] | undefined = withPoints
? isKo
? winnerId === teams[0].id
? [100, 0]
: [0, 100]
: [0, 0]
: undefined;
onSubmit?.({ winnerId, points: submitPoints });
onSubmit?.({ winnerId, ko: withKo ? isKo : undefined });
};
return (
@@ -92,14 +85,14 @@ export function MatchActionTab({
mode={mode}
winnerId={winnerId}
teams={teams}
withPoints={withPoints}
withKo={withKo}
isKo={isKo}
isSubmitting={isSubmitting}
onBack={() => setConfirming(false)}
onConfirm={submit}
/>
) : (
<div className={clsx(styles.root, { [styles.withPoints]: withPoints })}>
<div className={clsx(styles.root, { [styles.withKo]: withKo })}>
<div className={styles.title}>{t("q:match.action.selectWinner")}</div>
{actionButtons ? (
<div className={styles.actionButtons}>{actionButtons}</div>
@@ -156,7 +149,7 @@ export function MatchActionTab({
/>
</RadioGroup>
{withPoints ? (
{withKo ? (
<div className={styles.ko}>
<label className={styles.koLabel}>
<input
@@ -199,7 +192,7 @@ function SetEndingConfirmation({
mode,
winnerId,
teams,
withPoints,
withKo,
isKo,
isSubmitting,
onBack,
@@ -210,7 +203,7 @@ function SetEndingConfirmation({
mode: ModeShort;
winnerId: number;
teams: [ActionTabTeam, ActionTabTeam];
withPoints: boolean;
withKo: boolean;
isKo: boolean;
isSubmitting?: boolean;
onBack: () => void;
@@ -226,11 +219,7 @@ function SetEndingConfirmation({
timestamp: Date.now(),
winner: winnerSide,
rosters: setEnding.currentRosters,
points: withPoints
? isKo
? [winnerSide === "ALPHA" ? 100 : 0, winnerSide === "BRAVO" ? 100 : 0]
: [0, 0]
: undefined,
ko: withKo ? isKo : undefined,
};
const updatedScore = {

View File

@@ -1,21 +1,43 @@
import { LocaleTime } from "~/components/LocaleTime";
import { LocaleTimeRange } from "~/components/LocaleTimeRange";
const FORMAT_OPTIONS: Intl.DateTimeFormatOptions = {
month: "numeric",
year: "2-digit",
day: "numeric",
hour: "numeric",
minute: "numeric",
};
const CLASS_NAME = "text-lighter font-semi-bold";
interface MatchBannerStartedAtProps {
time: Date;
/** When given, the time the match ended, shown as a range together with the start time */
endTime?: Date | null;
}
export function MatchBannerStartedAt({ time }: MatchBannerStartedAtProps) {
export function MatchBannerStartedAt({
time,
endTime,
}: MatchBannerStartedAtProps) {
if (endTime) {
return (
<LocaleTimeRange
from={time}
to={endTime}
options={FORMAT_OPTIONS}
className={CLASS_NAME}
inline
/>
);
}
return (
<LocaleTime
date={time}
options={{
month: "numeric",
year: "2-digit",
day: "numeric",
hour: "numeric",
minute: "numeric",
}}
className="text-lighter font-semi-bold"
options={FORMAT_OPTIONS}
className={CLASS_NAME}
inline
/>
);

View File

@@ -1,10 +1,11 @@
import { BarChart3, Key, ScrollText, Tally5, Users } from "lucide-react";
import type * as React from "react";
import { useTranslation } from "react-i18next";
import { useSearchParams } from "react-router";
import { useSearchParam } from "~/modules/search-params/hooks";
import invariant from "~/utils/invariant";
import { SendouTab, SendouTabList, SendouTabs } from "../elements/Tabs";
import styles from "./MatchTabs.module.css";
import { matchPageSearchParams } from "./match-page-search-params";
type MatchTabsKey = (typeof TAB_KEYS)[keyof typeof TAB_KEYS];
interface MatchTabsProps {
@@ -14,8 +15,6 @@ interface MatchTabsProps {
alertTabs?: Array<MatchTabsKey>;
}
const TAB_KEY = "tab";
export const TAB_KEYS = {
ROSTERS: "rosters",
ACTION: "action",
@@ -42,26 +41,16 @@ const TAB_TRANSLATION_KEYS = {
export function MatchTabs({ children, tabs, alertTabs }: MatchTabsProps) {
const { t } = useTranslation(["q", "common"]);
const [searchParams, setSearchParams] = useSearchParams();
const [tabParam, setTab] = useSearchParam(matchPageSearchParams, "tab");
const currentTab =
tabs.find((tab) => searchParams.get(TAB_KEY) === tab) ?? tabs.at(0);
const currentTab = tabs.find((tab) => tabParam === tab) ?? tabs.at(0);
invariant(currentTab);
return (
<div className={styles.root}>
<SendouTabs
selectedKey={currentTab}
onSelectionChange={(key) =>
setSearchParams(
{ [TAB_KEY]: key as string },
{
preventScrollReset: true,
defaultShouldRevalidate: false,
replace: true,
},
)
}
onSelectionChange={(key) => setTab(key as MatchTabsKey)}
disappearing={false}
padded={false}
>

View File

@@ -11,7 +11,10 @@ import {
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { LocaleTime } from "~/components/LocaleTime";
import type { GroupSkillDifference, UserSkillDifference } from "~/db/tables";
import type {
GroupSkillDifference,
UserSkillDifference,
} from "~/db/tables-json";
import { abilities } from "~/modules/in-game-lists/abilities";
import { shortStageName } from "~/modules/in-game-lists/stage-ids";
import type {
@@ -74,8 +77,8 @@ export interface TimelineMap {
alpha: WeaponPoolWeapon[];
bravo: WeaponPoolWeapon[];
};
/** Optional point values [alpha, bravo] */
points?: [number, number];
/** Whether the game ended in a knockout. Undefined if not collected. */
ko?: boolean;
/** Side that picked this map (counterpick / postGame map PICK). Renders a click indicator next to that side's WIN/LOSS label. */
pickedBy?: MatchSide;
/** Ingested end-of-game scoreboard rendered as an expandable stats section below the map row. */
@@ -215,7 +218,7 @@ function TimelineHeader({
) : null}
{isOngoing ? (
<span className={styles.headerScoreLive}>
{t("q:match.timeline.live")}
{t("q:match.timeline.ongoing")}
</span>
) : null}
</div>
@@ -249,15 +252,12 @@ function TimelineMapRow({
}) {
const { t } = useTranslation(["game-misc"]);
const alphaPoints = map.points?.[0];
const bravoPoints = map.points?.[1];
return (
<div className={styles.mapEvent}>
<div className={styles.mapSide}>
<SideResult
result={map.winner === "ALPHA" ? "WIN" : "LOSS"}
points={alphaPoints}
isKo={map.ko && map.winner === "ALPHA"}
scoreboardScore={map.scoreboard?.scores[0]}
weapons={map.weapons?.alpha}
isPicked={map.pickedBy === "ALPHA"}
@@ -282,7 +282,7 @@ function TimelineMapRow({
<div className={clsx(styles.mapSide, styles.mapSideBravo)}>
<SideResult
result={map.winner === "BRAVO" ? "WIN" : "LOSS"}
points={bravoPoints}
isKo={map.ko && map.winner === "BRAVO"}
scoreboardScore={map.scoreboard?.scores[1]}
weapons={map.weapons?.bravo}
isPicked={map.pickedBy === "BRAVO"}
@@ -297,13 +297,13 @@ function TimelineMapRow({
function SideResult({
result,
points,
isKo,
scoreboardScore,
weapons,
isPicked,
}: {
result: "WIN" | "LOSS";
points?: number;
isKo?: boolean;
/** in-game 0-500p team score from an ingested scoreboard (500 = knockout) */
scoreboardScore?: number | null;
weapons?: WeaponPoolWeapon[];
@@ -336,7 +336,7 @@ function SideResult({
? t("q:match.timeline.win")
: t("q:match.timeline.loss")}
</span>
{points === 100 && scoreboardScore == null ? (
{isKo && scoreboardScore == null ? (
<span className={styles.resultPoints}>
{t("q:match.action.ko")}
</span>

View File

@@ -15,12 +15,16 @@ import { WeaponSelect } from "../WeaponSelect";
import { SecondaryAction } from "./SecondaryAction";
import styles from "./WeaponReporter.module.css";
interface WeaponReporterMap {
export interface WeaponReporterMap {
/** Index of the map in the match's map list, which is what a weapon is reported for. */
mapIndex: number;
stageId: StageId;
mode: ModeShort;
}
export interface WeaponReporterProps {
/** Only the maps the viewer took part in, so someone who was subbed out is
* never asked for a weapon of a map they did not play. */
maps: WeaponReporterMap[];
pastReported: MainWeaponId[];
nextMapIndex: number;
@@ -51,7 +55,7 @@ export function WeaponReporter({
null,
);
const inputTargetMap = nextMapIndex >= 0 ? maps[nextMapIndex] : undefined;
const inputTargetMap = maps.find((map) => map.mapIndex === nextMapIndex);
const unreportedCount = inputTargetMap
? maps.length - pastReported.length - 1
: maps.length - pastReported.length;

View File

@@ -0,0 +1,18 @@
import { describe, it } from "vitest";
import {
assertDecodesToDefault,
assertRoundTrips,
} from "~/modules/search-params/search-params-test-utils";
import { matchPageSearchParams } from "./match-page-search-params";
describe("matchPageSearchParams", () => {
it("round-trips", () => {
assertRoundTrips(matchPageSearchParams, {
tab: [null, "rosters", "action", "result", "stats", "admin"],
});
});
it("malformed values decode to defaults", () => {
assertDecodesToDefault(matchPageSearchParams, "tab", [["garbage"]]);
});
});

View File

@@ -0,0 +1,15 @@
import { z } from "zod";
import * as SearchParams from "~/modules/search-params/search-params";
import { SP } from "~/modules/search-params/search-params";
const MATCH_PAGE_TABS = [
"rosters",
"action",
"result",
"stats",
"admin",
] as const;
export const matchPageSearchParams = SearchParams.define({
tab: SP.param(z.enum(MATCH_PAGE_TABS).nullable(), { loader: false }),
});

View File

@@ -1,26 +1,23 @@
import { useFetcher } from "react-router";
import { useRecentlyReportedWeapons } from "~/hooks/useRecentlyReportedWeapons";
import type {
MainWeaponId,
ModeShort,
StageId,
} from "~/modules/in-game-lists/types";
import type { WeaponReporterProps } from "./WeaponReporter";
import type { MainWeaponId } from "~/modules/in-game-lists/types";
import type { WeaponReporterMap, WeaponReporterProps } from "./WeaponReporter";
/**
* Wires the `<WeaponReporter />` component to the standard
* `REPORT_WEAPON` / `UNDO_WEAPON_REPORT` fetcher actions and to the
* locally persisted recently-reported weapons list.
*
* `maps` is the play order of maps the viewer can report a weapon for and
* `pastReported` is the weapons the viewer has already reported, paired
* with the `mapIndex` they were reported for.
* `maps` is the maps the viewer can report a weapon for, in play order, each
* carrying its `mapIndex` in the match's map list — a viewer who sat out a map
* simply has no entry for it. `pastReported` is the weapons the viewer has
* already reported, paired with the `mapIndex` they were reported for.
*/
export function useMatchWeaponReport({
maps,
pastReported,
}: {
maps: { stageId: StageId; mode: ModeShort }[];
maps: WeaponReporterMap[];
pastReported: { mapIndex: number; weaponSplId: MainWeaponId }[];
}): WeaponReporterProps {
const weaponFetcher = useFetcher();
@@ -28,12 +25,8 @@ export function useMatchWeaponReport({
useRecentlyReportedWeapons();
const reportedMapIndexes = new Set(pastReported.map((w) => w.mapIndex));
const nextMapIndex = (() => {
for (let i = 0; i < maps.length; i++) {
if (!reportedMapIndexes.has(i)) return i;
}
return -1;
})();
const nextMapIndex =
maps.find((map) => !reportedMapIndexes.has(map.mapIndex))?.mapIndex ?? -1;
const undoMapIndex = pastReported.reduce(
(max, w) => Math.max(max, w.mapIndex),
-1,

View File

@@ -1,5 +1,5 @@
import { z } from "zod";
import { formatEnvErrors, requiredInProd } from "./config-helpers";
import { formatEnvErrors, requiredInProd } from "./config-helpers.server";
import { IS_E2E_TEST_RUN } from "./utils/e2e";
/**

View File

@@ -1,5 +1,3 @@
import { z } from "zod";
import { formatEnvErrors, requiredInProd } from "./config-helpers";
import { IS_E2E_TEST_RUN } from "./utils/e2e";
/**
@@ -9,6 +7,9 @@ import { IS_E2E_TEST_RUN } from "./utils/e2e";
* Values are validated once when this module is first imported, surfacing a
* single clear error for any misconfigured variable. Variables required in
* production fall back to development defaults outside of production.
*
* Note: this module ships in the critical client bundle so it must stay free of
* heavy dependencies (e.g. zod, which the server config uses).
*/
// `import.meta.env` is undefined when Playwright bundles test code, so guard the
@@ -23,31 +24,35 @@ const isProd =
import.meta.env.PROD === true &&
!IS_E2E_TEST_RUN;
const schema = z.object({
VITE_SITE_DOMAIN: requiredInProd(isProd, "http://localhost:5173"),
const TRUTHY_STRINGS = ["true", "1", "yes", "on", "y", "enabled"];
const FALSY_STRINGS = ["false", "0", "no", "off", "n", "disabled"];
const issues: Array<{ name: string; message: string }> = [];
const values = {
VITE_SITE_DOMAIN: requiredInProd("VITE_SITE_DOMAIN", "http://localhost:5173"),
VITE_TOURNAMENT_DEFAULT_LOGO: requiredInProd(
isProd,
"VITE_TOURNAMENT_DEFAULT_LOGO",
"tournament-logo-default.avif",
),
VITE_STATIC_ASSETS_URL: z
.string()
.default("https://sendou-assets.nyc3.cdn.digitaloceanspaces.com"),
VITE_PROD_MODE: z.stringbool().default(false),
VITE_SHOW_LUTI_NAV_ITEM: z.stringbool().default(false),
VITE_FUSE_ENABLED: z.stringbool().default(false),
VITE_LEAGUE_GOOGLE_FORM_URL: z.string().optional(),
VITE_SHOW_BANNER_FOR_SEASON: z.string().optional(),
VITE_SENTRY_DSN: z.string().optional(),
VITE_SENTRY_ENABLED: z.stringbool().default(false),
VITE_SKALOP_WS_URL: z.string().optional(),
VITE_VAPID_PUBLIC_KEY: z.string().optional(),
});
VITE_STATIC_ASSETS_URL: withDefault(
"VITE_STATIC_ASSETS_URL",
"https://sendou-assets.nyc3.cdn.digitaloceanspaces.com",
),
VITE_PROD_MODE: stringBool("VITE_PROD_MODE"),
VITE_SHOW_LUTI_NAV_ITEM: stringBool("VITE_SHOW_LUTI_NAV_ITEM"),
VITE_FUSE_ENABLED: stringBool("VITE_FUSE_ENABLED"),
VITE_LEAGUE_GOOGLE_FORM_URL: env.VITE_LEAGUE_GOOGLE_FORM_URL,
VITE_SHOW_BANNER_FOR_SEASON: env.VITE_SHOW_BANNER_FOR_SEASON,
VITE_SENTRY_DSN: env.VITE_SENTRY_DSN,
VITE_SENTRY_ENABLED: stringBool("VITE_SENTRY_ENABLED"),
VITE_SKALOP_WS_URL: env.VITE_SKALOP_WS_URL,
VITE_VAPID_PUBLIC_KEY: env.VITE_VAPID_PUBLIC_KEY,
};
const parsed = schema.safeParse(env);
if (!parsed.success) {
throw formatEnvErrors("client", parsed.error);
if (issues.length > 0) {
throw envError(issues);
}
const values = parsed.data;
export const Config = {
/** Base URL of the site, e.g. `https://sendou.ink`. */
@@ -79,3 +84,51 @@ export const Config = {
publicKey: values.VITE_VAPID_PUBLIC_KEY,
},
};
function requiredInProd(name: string, devFallback: string): string {
const value = env[name];
if (!isProd) {
return value ?? devFallback;
}
if (value === undefined) {
issues.push({ name, message: "required in production" });
return "";
}
if (value.length === 0) {
issues.push({ name, message: "required in production (cannot be empty)" });
return "";
}
return value;
}
function withDefault(name: string, defaultValue: string): string {
return env[name] ?? defaultValue;
}
function stringBool(name: string): boolean {
const value = env[name];
if (value === undefined) return false;
const normalized = value.toLowerCase();
if (TRUTHY_STRINGS.includes(normalized)) return true;
if (FALSY_STRINGS.includes(normalized)) return false;
issues.push({
name,
message: `must be a boolean-like string (e.g. "true" or "false"), got "${value}"`,
});
return false;
}
function envError(issues: Array<{ name: string; message: string }>): Error {
const lines = issues.map((issue) => ` - ${issue.name}: ${issue.message}`);
return new Error(
`Invalid client environment configuration:\n${lines.join(
"\n",
)}\n\nSee .env.example for the full list of variables and how to set them.`,
);
}

View File

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

View File

@@ -0,0 +1,322 @@
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,
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;
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;
}
/**
* 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();
}
}
interface PreparedStatement {
statement: StatementSync;
/** Empty for statements that return no rows, which is how writes are detected. */
columnNames: string[];
}
class NodeSqliteConnection implements DatabaseConnection {
readonly #database: DatabaseSync;
readonly #cacheStatements: boolean;
readonly #cache = new Map<string, PreparedStatement>();
constructor(config: NodeSqliteDialectConfig) {
this.#database = config.database;
this.#cacheStatements = config.cacheStatements ?? false;
}
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);
const parameters = compiledQuery.parameters as SQLInputValue[];
for (const row of prepared.statement.iterate(...parameters)) {
yield {
rows: [
toRow<R>(prepared.columnNames, 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);
}
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);
if (this.#cache.size >= STATEMENT_CACHE_SIZE) {
this.#cache.delete(this.#cache.keys().next().value!);
}
this.#cache.set(sql, prepared);
return prepared;
}
}
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): PreparedStatement {
const statement = database.prepare(sql);
statement.setReturnArrays(true);
return { statement, columnNames: statement.columns().map((it) => it.name) };
}
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) {
prepared.columnNames = prepared.statement.columns().map((it) => it.name);
}
const rows = new Array<R>(rawRows.length);
for (let i = 0; i < rawRows.length; i++) {
rows[i] = toRow<R>(prepared.columnNames, rawRows[i]);
}
return rows;
}
function toRow<R>(columnNames: string[], rawRow: SQLOutputValue[]): R {
const row: Record<string, SQLOutputValue> = {};
for (let i = 0; i < columnNames.length; i++) {
row[columnNames[i]] = rawRow[i];
}
return row as R;
}
function savepointCommand(command: string, savepointName: string) {
return RawNode.createWithChildren([
RawNode.createWithSql(`${command} `),
IdentifierNode.create(savepointName),
]);
}

20
app/db/reset.ts Normal file
View File

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

View File

@@ -8,5 +8,3 @@ 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";
export const AMOUNT_OF_CALENDAR_EVENTS = 200;

View File

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

16
app/db/seed/core/actAs.ts Normal file
View File

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

View File

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

View File

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

55
app/db/seed/core/faker.ts Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

107
app/db/seed/dev/badges.ts Normal file
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),
});
}

39
app/db/seed/dev/builds.ts Normal file
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),
});
}
}

134
app/db/seed/dev/calendar.ts Normal file
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,
});
}

219
app/db/seed/dev/misc.ts Normal file
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"] }];
}

75
app/db/seed/dev/plus.ts Normal file
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]),
};
}

262
app/db/seed/dev/sendouq.ts Normal file
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);
}
}

81
app/db/seed/dev/teams.ts Normal file
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);
}

125
app/db/seed/dev/trophies.ts Normal file
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] },
);
await TrophyFactory.createManyPending(
DECLINED_COUNT,
(index) => ({
...submission(index),
name: `Declined trophy ${index + 1}`,
}),
{
declinedBy: {
userId: users.adminId,
reason: faker.lorem.sentence(),
},
},
);
}

362
app/db/seed/dev/users.ts Normal file
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);
}

64
app/db/seed/dev/vods.ts Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Some files were not shown because too many files have changed in this diff Show More