Merge remote-tracking branch 'origin/main' into css-rework-sidenav
115
.claude/skills/e2e/SKILL.md
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
---
|
||||
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.
|
||||
---
|
||||
|
||||
# E2E Test Runner
|
||||
|
||||
## 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`
|
||||
- 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 `npm 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):
|
||||
```
|
||||
lsof -i :4501-4504 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 `npm run test:e2e:generate-seeds`.
|
||||
|
||||
4. **Docker running** — MinIO requires Docker. Check with `docker info` if there are storage-related failures.
|
||||
|
||||
## Running tests
|
||||
|
||||
### Run all tests
|
||||
```bash
|
||||
npm run test:e2e
|
||||
```
|
||||
|
||||
### Run a specific test file
|
||||
```bash
|
||||
npx playwright test e2e/<name>.spec.ts
|
||||
```
|
||||
|
||||
### Flaky detection (repeats each test 10 times, stops on first failure)
|
||||
```bash
|
||||
npm run test:e2e:flaky-detect
|
||||
```
|
||||
|
||||
### Regenerate seed databases (after schema/migration changes)
|
||||
```bash
|
||||
npm run test:e2e:generate-seeds
|
||||
```
|
||||
|
||||
## Debugging failures
|
||||
|
||||
Follow this funnel when tests fail:
|
||||
|
||||
### Step 1: Read the error output
|
||||
- Look for the actual assertion or timeout that failed
|
||||
- Check if it's an infrastructure error (server didn't start, migration failed) vs. a test logic error
|
||||
|
||||
### Step 2: Check infrastructure issues
|
||||
Common infrastructure errors and fixes:
|
||||
- **"table already exists"** → Stale worker DBs. Run `rm -f db-test-e2e-*.sqlite3`
|
||||
- **"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 `npm run test:e2e:generate-seeds`
|
||||
|
||||
### 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:
|
||||
```bash
|
||||
E2E_DEBUG=true E2E_WORKERS=1 npx playwright test e2e/<failing-test>.spec.ts
|
||||
```
|
||||
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:
|
||||
```bash
|
||||
npx playwright show-trace test-results/<test-folder>/trace.zip
|
||||
```
|
||||
|
||||
## Test pattern reference
|
||||
|
||||
Every test follows this pattern — use these imports from `~/utils/playwright`, NOT raw Playwright APIs:
|
||||
|
||||
```typescript
|
||||
import { expect, impersonate, navigate, seed, test } from "~/utils/playwright";
|
||||
|
||||
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)
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
Key rules:
|
||||
- Use `navigate()` instead of `page.goto()` — it waits for hydration
|
||||
- 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)
|
||||
- Avoid `page.waitForTimeout` — use assertions or `waitFor` patterns instead
|
||||
- Import `test` from `~/utils/playwright` (not from `@playwright/test`) — it includes worker port fixtures
|
||||
|
||||
## Environment variables
|
||||
|
||||
| Variable | Purpose | Default |
|
||||
|----------|---------|---------|
|
||||
| `E2E_WORKERS` | Number of parallel workers | 4 |
|
||||
| `E2E_DEBUG` | Show server stdout/stderr when "true" | unset |
|
||||
| `PORT` | Base port for dev server (e2e adds 500) | 5173 |
|
||||
40
app/features/api-public/routes/tournament.$id.seeds.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import type { ActionFunctionArgs } from "react-router";
|
||||
import { z } from "zod";
|
||||
import { action as seedsAction } from "~/features/tournament/actions/to.$id.seeds.server";
|
||||
import { parseBody, parseParams } from "~/utils/remix.server";
|
||||
import { id } from "~/utils/zod";
|
||||
import { wrapActionForApi } from "../api-action-wrapper.server";
|
||||
|
||||
const paramsSchema = z.object({
|
||||
id,
|
||||
});
|
||||
|
||||
const bodySchema = z.object({
|
||||
tournamentTeamIds: z.array(id),
|
||||
});
|
||||
|
||||
export const action = async (args: ActionFunctionArgs) => {
|
||||
const { id: tournamentId } = parseParams({
|
||||
params: args.params,
|
||||
schema: paramsSchema,
|
||||
});
|
||||
const { tournamentTeamIds } = await parseBody({
|
||||
request: args.request,
|
||||
schema: bodySchema,
|
||||
});
|
||||
|
||||
const internalRequest = new Request(args.request.url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
_action: "UPDATE_SEEDS",
|
||||
seeds: tournamentTeamIds,
|
||||
}),
|
||||
});
|
||||
|
||||
return wrapActionForApi(seedsAction, {
|
||||
...args,
|
||||
params: { id: String(tournamentId) },
|
||||
request: internalRequest,
|
||||
});
|
||||
};
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
import type { ActionFunctionArgs } from "react-router";
|
||||
import { z } from "zod";
|
||||
import { action as seedsAction } from "~/features/tournament/actions/to.$id.seeds.server";
|
||||
import { parseBody, parseParams } from "~/utils/remix.server";
|
||||
import { id } from "~/utils/zod";
|
||||
import { wrapActionForApi } from "../api-action-wrapper.server";
|
||||
|
||||
const paramsSchema = z.object({
|
||||
id,
|
||||
});
|
||||
|
||||
const bodySchema = z.object({
|
||||
startingBrackets: z.array(
|
||||
z.object({
|
||||
tournamentTeamId: id,
|
||||
startingBracketIdx: z.number().int().min(0),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
export const action = async (args: ActionFunctionArgs) => {
|
||||
const { id: tournamentId } = parseParams({
|
||||
params: args.params,
|
||||
schema: paramsSchema,
|
||||
});
|
||||
const { startingBrackets } = await parseBody({
|
||||
request: args.request,
|
||||
schema: bodySchema,
|
||||
});
|
||||
|
||||
const internalRequest = new Request(args.request.url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
_action: "UPDATE_STARTING_BRACKETS",
|
||||
startingBrackets,
|
||||
}),
|
||||
});
|
||||
|
||||
return wrapActionForApi(seedsAction, {
|
||||
...args,
|
||||
params: { id: String(tournamentId) },
|
||||
request: internalRequest,
|
||||
});
|
||||
};
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
import type { ActionFunctionArgs } from "react-router";
|
||||
import { z } from "zod";
|
||||
import { action as adminAction } from "~/features/tournament/actions/to.$id.admin.server";
|
||||
import { IN_GAME_NAME_REGEXP } from "~/features/user-page/user-page-constants";
|
||||
import { parseBody, parseParams } from "~/utils/remix.server";
|
||||
import { id } from "~/utils/zod";
|
||||
import { wrapActionForApi } from "../api-action-wrapper.server";
|
||||
|
||||
const paramsSchema = z.object({
|
||||
id,
|
||||
teamId: id,
|
||||
});
|
||||
|
||||
const bodySchema = z.object({
|
||||
userId: id,
|
||||
inGameName: z.string().regex(IN_GAME_NAME_REGEXP),
|
||||
});
|
||||
|
||||
export const action = async (args: ActionFunctionArgs) => {
|
||||
const { id: tournamentId } = parseParams({
|
||||
params: args.params,
|
||||
schema: paramsSchema,
|
||||
});
|
||||
const { userId, inGameName } = await parseBody({
|
||||
request: args.request,
|
||||
schema: bodySchema,
|
||||
});
|
||||
|
||||
const hashIndex = inGameName.lastIndexOf("#");
|
||||
const inGameNameText = inGameName.slice(0, hashIndex);
|
||||
const inGameNameDiscriminator = inGameName.slice(hashIndex + 1);
|
||||
|
||||
const internalRequest = new Request(args.request.url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
_action: "UPDATE_IN_GAME_NAME",
|
||||
memberId: userId,
|
||||
inGameNameText,
|
||||
inGameNameDiscriminator,
|
||||
}),
|
||||
});
|
||||
|
||||
return wrapActionForApi(adminAction, {
|
||||
...args,
|
||||
params: { id: String(tournamentId) },
|
||||
request: internalRequest,
|
||||
});
|
||||
};
|
||||
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 166 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
|
@ -0,0 +1,43 @@
|
|||
import { z } from "zod";
|
||||
import * as PlusSuggestionRepository from "~/features/plus-suggestions/PlusSuggestionRepository.server";
|
||||
import {
|
||||
nextNonCompletedVoting,
|
||||
rangeToMonthYear,
|
||||
} from "~/features/plus-voting/core";
|
||||
import * as UserRepository from "~/features/user-page/UserRepository.server";
|
||||
import { newSuggestionFormSchema } from "./plus-suggestions-schemas";
|
||||
|
||||
export const newSuggestionFormSchemaServer =
|
||||
newSuggestionFormSchema.superRefine(async (data, ctx) => {
|
||||
const suggested = await UserRepository.findLeanById(data.userId);
|
||||
if (!suggested) return;
|
||||
|
||||
const targetPlusTier = Number(data.tier);
|
||||
|
||||
if (suggested.plusTier && suggested.plusTier <= targetPlusTier) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "forms:errors.plusAlreadyMember",
|
||||
path: ["userId"],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const voting = nextNonCompletedVoting(new Date());
|
||||
if (!voting) return;
|
||||
|
||||
const votingMonthYear = rangeToMonthYear(voting);
|
||||
const suggestions =
|
||||
await PlusSuggestionRepository.findAllByMonth(votingMonthYear);
|
||||
|
||||
const alreadySuggested = suggestions.some(
|
||||
(s) => s.suggested.id === suggested.id && s.tier === targetPlusTier,
|
||||
);
|
||||
if (alreadySuggested) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "forms:errors.plusAlreadySuggested",
|
||||
path: ["userId"],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-3);
|
||||
}
|
||||
|
||||
.selectedBadges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--s-2);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.count {
|
||||
font-size: var(--fonts-xs);
|
||||
color: var(--text-lighter);
|
||||
font-weight: var(--semi-bold);
|
||||
}
|
||||
|
||||
.badgeButton {
|
||||
all: unset;
|
||||
cursor: pointer;
|
||||
border-radius: var(--rounded-sm);
|
||||
padding: var(--s-0-5);
|
||||
transition: background-color 0.15s;
|
||||
}
|
||||
|
||||
.badgeButton:hover {
|
||||
background-color: var(--bg-lightest);
|
||||
}
|
||||
|
||||
.badgeButton:focus-visible {
|
||||
outline: 2px solid var(--theme);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.badgeImage {
|
||||
display: block;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.searchInput {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.resultsGrid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--s-1);
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
101
app/features/user-page/components/GameBadgeSelectField.tsx
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { CustomFieldRenderProps } from "~/form/FormField";
|
||||
import {
|
||||
GAME_BADGE_IDS,
|
||||
type GameBadgeId,
|
||||
} from "~/modules/in-game-lists/game-badge-ids";
|
||||
import { gameBadgeUrl } from "~/utils/urls";
|
||||
import styles from "./GameBadgeSelectField.module.css";
|
||||
|
||||
const MIN_SEARCH_LENGTH = 2;
|
||||
|
||||
export function GameBadgeSelectField({
|
||||
value,
|
||||
onChange,
|
||||
maxCount,
|
||||
}: CustomFieldRenderProps<string[]> & { maxCount: number }) {
|
||||
const { t } = useTranslation(["user", "game-badges"]);
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const selectedIds = value ?? [];
|
||||
|
||||
const filteredBadges =
|
||||
search.length >= MIN_SEARCH_LENGTH
|
||||
? GAME_BADGE_IDS.filter(
|
||||
(id) =>
|
||||
t(`game-badges:${id}`)
|
||||
.toLowerCase()
|
||||
.includes(search.toLowerCase()) && !selectedIds.includes(id),
|
||||
)
|
||||
: [];
|
||||
|
||||
const handleAdd = (id: string) => {
|
||||
if (selectedIds.length >= maxCount) return;
|
||||
if (selectedIds.includes(id)) return;
|
||||
onChange([...selectedIds, id]);
|
||||
};
|
||||
|
||||
const handleRemove = (id: string) => {
|
||||
onChange(selectedIds.filter((selectedId) => selectedId !== id));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div>
|
||||
<label>{t("user:widgets.forms.gameBadges")}</label>
|
||||
<div className={styles.selectedBadges}>
|
||||
{selectedIds.map((id) => {
|
||||
const badgeId = id as GameBadgeId;
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
className={styles.badgeButton}
|
||||
onClick={() => handleRemove(id)}
|
||||
title={t(`game-badges:${badgeId}`)}
|
||||
>
|
||||
<img
|
||||
src={gameBadgeUrl(id)}
|
||||
alt={t(`game-badges:${badgeId}`)}
|
||||
className={styles.badgeImage}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<span className={styles.count}>
|
||||
{selectedIds.length}/{maxCount}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={t("user:widgets.forms.gameBadgesSearch")}
|
||||
className={styles.searchInput}
|
||||
/>
|
||||
|
||||
{filteredBadges.length > 0 ? (
|
||||
<div className={styles.resultsGrid}>
|
||||
{filteredBadges.map((id) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
className={styles.badgeButton}
|
||||
onClick={() => handleAdd(id)}
|
||||
title={t(`game-badges:${id}`)}
|
||||
>
|
||||
<img
|
||||
src={gameBadgeUrl(id)}
|
||||
alt={t(`game-badges:${id}`)}
|
||||
className={styles.badgeImage}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
47
app/features/user-page/routes/u.$identifier.edit.test.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import type { MainWeaponId } from "~/modules/in-game-lists/types";
|
||||
import { dbInsertUsers, dbReset, wrappedAction } from "~/utils/Test";
|
||||
import type { userEditProfileBaseSchema } from "../user-page-schemas";
|
||||
import { action as editUserProfileAction } from "./u.$identifier.edit";
|
||||
|
||||
const action = wrappedAction<typeof userEditProfileBaseSchema>({
|
||||
action: editUserProfileAction,
|
||||
isJsonSubmission: true,
|
||||
});
|
||||
|
||||
const DEFAULT_FIELDS = {
|
||||
battlefy: null,
|
||||
bio: null,
|
||||
commissionsOpen: false,
|
||||
commissionText: null,
|
||||
country: "FI",
|
||||
customName: null,
|
||||
customUrl: null,
|
||||
favoriteBadgeIds: [],
|
||||
inGameName: null,
|
||||
sensitivity: [null, null] as [null, null],
|
||||
pronouns: [null, null] as [null, null],
|
||||
weapons: [{ id: 1 as MainWeaponId, isFavorite: false }],
|
||||
showDiscordUniqueName: true,
|
||||
newProfileEnabled: false,
|
||||
};
|
||||
|
||||
describe("user page editing", () => {
|
||||
beforeEach(async () => {
|
||||
await dbInsertUsers();
|
||||
});
|
||||
afterEach(() => {
|
||||
dbReset();
|
||||
});
|
||||
|
||||
it("saves profile with default fields", async () => {
|
||||
const response = await action(
|
||||
{
|
||||
...DEFAULT_FIELDS,
|
||||
},
|
||||
{ user: "regular", params: { identifier: "2" } },
|
||||
);
|
||||
|
||||
expect(response.status).toBe(302);
|
||||
});
|
||||
});
|
||||
39
app/features/user-page/user-page-constants.test.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { IN_GAME_NAME_REGEXP } from "./user-page-constants";
|
||||
|
||||
describe("IN_GAME_NAME_REGEXP", () => {
|
||||
it("should pass valid in-game names", () => {
|
||||
const validNames = [
|
||||
"Sendou#12345",
|
||||
"The Player#12345",
|
||||
" a#1234",
|
||||
"A#1234",
|
||||
"Player#abcd",
|
||||
"名前テスト1234#ab12c",
|
||||
];
|
||||
|
||||
for (const name of validNames) {
|
||||
expect(IN_GAME_NAME_REGEXP.test(name), `expected "${name}" to pass`).toBe(
|
||||
true,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("should not pass invalid in-game names", () => {
|
||||
const invalidNames = [
|
||||
"#1234",
|
||||
"Sendou1234",
|
||||
"Sendou#123",
|
||||
"Sendou# 1234",
|
||||
"Sendou#123456",
|
||||
"Sendou#ABCD",
|
||||
"12345678901#1234",
|
||||
];
|
||||
|
||||
for (const name of invalidNames) {
|
||||
expect(IN_GAME_NAME_REGEXP.test(name), `expected "${name}" to fail`).toBe(
|
||||
false,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
3
app/form/fields/SelectFormField.module.css
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
.searchable {
|
||||
--select-width: 100%;
|
||||
}
|
||||
1554
app/modules/in-game-lists/game-badge-ids.ts
Normal file
63
content/articles/interactive-LAN-map-now-available.md
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
---
|
||||
title: "Splatoon Stronghold’s Interactive LAN Map Now Available!"
|
||||
date: 2026-02-18
|
||||
author:
|
||||
- name: YELLOW
|
||||
link: https://sendou.ink/u/great-hero-yellow
|
||||
---
|
||||
|
||||
## *A resource to locate Splatoon LANs worldwide, now in one place*
|
||||
|
||||
<img width="1200" height="630" alt="Stronghold_Interactive_Map_Header" src="https://github.com/user-attachments/assets/6d411587-38f5-4cab-9d56-8919713f5eb0" />
|
||||
|
||||
On Wednesday, February 18th, 2026, Splatoon Stronghold published its latest community resource: an interactable map pinpointing Splatoon LAN locations around the world. Each map pin correlates to one LAN, and upon clicking on it, the map provides broad information at a glance.
|
||||
|
||||
At the most zoomed-out level, users will see a world map dotted with map pins of assorted colors and icons. A sidebar on the map can open up to show a list of every LAN on the map, visible and invisible. Immediately visible pins are for upcoming LANs, while invisible pins are for recent LANs which have already taken place.
|
||||
|
||||
There is an option in the sidebar to turn on all invisible pins so the user can see LANs past (within a year), present, and future.
|
||||
|
||||
LANs that have already passed will have a black map pin, and others will have one of several other colors: blue, green, orange, purple, gold. Blue pins denote small LANs (0-16 teams), green pins mean a medium-sized LAN (17 \- 32 teams), and orange pins indicate a large LAN (33+ teams). Purple pins show non-standard LANs.
|
||||
|
||||
Gold pins highlight which LAN is a LAN Championship Showdown qualifying event, and a star in any map pin means that the LAN is among the LCS network. An exclamation mark means that the LAN is once-per-year or brand new.
|
||||
|
||||
<img width="1000" height="426" alt="mapscreenshot(1)" src="https://github.com/user-attachments/assets/b2c2036e-4e73-4af9-b548-e5bde64145a5" />
|
||||
|
||||
*The map key and the LAN map.*
|
||||
|
||||
Upon selecting a LAN, either by clicking on the pin or choosing an event from the sidebar, the map will zoom in and open up an information box displaying this info:
|
||||
|
||||
- LAN name
|
||||
- Box to open the event’s registration page in a new tab
|
||||
- The date the LAN will take place
|
||||
- The venue’s address
|
||||
- If applicable during multi-day LANs: which days the Splatoon event is hosted
|
||||
|
||||
If the map is difficult to use, users can click on the “MapHub” box in the lower right corner to be taken to the source map hosted on MapHub’s website, which has the same functionality as the embedded map on the Splatoon Stronghold website.
|
||||
|
||||
The map will be maintained regularly, keeping it up-to-date with new LAN announcements and keeping the LANs which have finished out of the way, for a clearer, de-cluttered opening.
|
||||
|
||||
Sticking true to Splatoon Stronghold’s mission statement, beneath the map, there are additional resources for those unfamiliar with LANs or looking to volunteer or start their own.
|
||||
|
||||
<img width="1200" height="675" alt="upcoming_fall_winter_2026_LANs" src="https://github.com/user-attachments/assets/d372a082-309b-4e13-b47f-84628d3d74de" />
|
||||
|
||||
*The first Splatoon Stronghold LAN roundup article from November 7, 2025, which sparked the idea to create the LAN map.*
|
||||
|
||||
The catalyst for the map’s creation came about as Splatoon Stronghold began writing bi-monthly LAN articles; we noticed that finding LAN information publicly and visually-beneficial in one place was difficult.
|
||||
|
||||
Then, the feedback was twofold: LAN TOs thrilled that their events were becoming more visible, and Splatoon fans disappointed in the sparse LAN options outside of North America. This map aims to serve both concepts: continuing to promote LANs that TOs and volunteers work hard to produce, and help fans globally find their nearest LANs.
|
||||
|
||||
More than that, the goal is for this resource to encourage more fans to attend a LAN, no matter how big or small. The LAN experience truly cannot be replicated elsewhere in the scene, both as a competitor or just a spectator. Many players will often gas up LANs, and they aren’t exaggerating\! It really is all that they say it is\!
|
||||
|
||||
<img width="480" height="540" alt="S_Splatfest_World_Tour_vs_Space_Adventure_PNG_2" src="https://github.com/user-attachments/assets/40e61e1d-e55d-4cd2-af43-1010b3d08772" />
|
||||
|
||||
*A green Inkling from the 2016 World Tour vs. Space Adventure Splatfest, who was the initial candidate to be put on the header image for this article.*
|
||||
|
||||
The release of this map comes right as North America’s LAN Championship Showdown 2026 settles into its qualification round, to determine which teams advance to the final event in late April. Hopefully it helps players realize an event is close enough to participate in\!
|
||||
|
||||
The interactive LAN map can be found on Splatoon Stronghold’s website, here: [splatoonstronghold.com/lan-map](http://splatoonstronghold.com/lan-map).
|
||||
|
||||
If you have a LAN that you’d like to see added to the map, or a resource added to the page, please don’t hesitate to reach out to the map’s creator and maintainer: me\! You can find links to my socials in the credits section below.
|
||||
|
||||
Original Posting Date: Wednesday, February 18th, 2026 at [Splatoon Stronghold](https://www.splatoonstronghold.com/news/lan-map-available)
|
||||
|
||||
Written and formatted for publication by [YELLOW](https://bsky.app/profile/great-hero-yellow.bsky.social).
|
||||
79
content/articles/march-april-2026-LANs.md
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
---
|
||||
title: "March + April 2026 LANs"
|
||||
date: 2026-02-26
|
||||
author:
|
||||
- name: YELLOW
|
||||
link: https://sendou.ink/u/great-hero-yellow
|
||||
---
|
||||
|
||||
## *Two months of LANs so across the board that even Splatoon for the Wii U is making a comeback*
|
||||
|
||||
<img width="1200" height="630" alt="Stronghold_LANs_header_MarchApril" src="https://github.com/user-attachments/assets/361912ed-4676-4686-b57b-42e3a506babe" />
|
||||
|
||||
----------
|
||||
This article has been truncated. The full article can be found on [Splatoon Stronghold's website.](https://www.splatoonstronghold.com/news/march-april-2026-lans)
|
||||
----------
|
||||
|
||||
With North America’s LAN Championship Showdown a little more than two months away, the stakes are getting higher and higher. To top it off, a handful of new LANs and yearly gatherings are taking place, making it a great season to travel out\!
|
||||
|
||||
## **March 2026**
|
||||
|
||||
### **Midwest Battleground 2026 \- March 6–8 (Illinois, United States)**
|
||||
- Brand new!
|
||||
|
||||
### **VanCoral Mini 2 \- March 7 (Vancouver, Canada)**
|
||||
### **GatorLAN Spring 2026 \- March 7–8 (Florida, United States)**
|
||||
### **Chi-Shoals \#41 \- March 21 (Illinois, United States)**
|
||||
### **LAN of 10,000 Lakes \#1 \- March 21 (Minnesota, United States)**
|
||||
- Brand new!
|
||||
|
||||
### **Fullwipe 2026 \- March 21–22 (Lyon, France)**
|
||||
### **Anarchy NoVA 3 \- March 28 (Virginia, United States)**
|
||||
### **HI-SCORE Splatoon LAN \#1 \- March 29 (Hannover, Germany)**
|
||||
- Brand new!
|
||||
|
||||
## **April 2026**
|
||||
|
||||
### **SeaBATTLE\! Royale 2026 \- April 4 (Washington, United States)**
|
||||
### **H-Town Splashdown \#7: Blastoff \- April 11 (Texas, United States)**
|
||||
### **Big Dapple 10: The Golden Gala \- April 11 (New York, United States)**
|
||||
### **CALternative \- April 11 (California, United States)**
|
||||
- Splatoon for the Wii U only!
|
||||
|
||||
### **New InkLAN \#6 \- April 12 (Massachusetts, United States)**
|
||||
### **Sunset Surge: Blossom Blitz \- April 18 (Arizona, United States)**
|
||||
### **Don't Flounder at the Function 2 | Spring Break \- April 18 (North Carolina, United States)**
|
||||
### **Nittany Nouveau \- April 25–26 (Pennsylvania, United States)**
|
||||
|
||||
## **LAN Championship Showdown 2026 \- April 26 (Online)**
|
||||
You’ve heard the hype. You’ve seen it brewing over the first four months of 2026\. It’s time to find out who the best LAN local in North America is\! From Torontoroka to Don’t Flounder at the Function 2, twelve LANs have all sent their winners tickets to the LAN Championship Showdown invitational.
|
||||
|
||||
<img width="1000" height="667" alt="LCS2026Map" src="https://github.com/user-attachments/assets/ed37a854-8b0d-4243-ba60-d06d72a9df47" />
|
||||
|
||||
[*SplatoonTourney’s*](https://bsky.app/profile/splatoontourney.bsky.social) *LAN Championship Showdown 2026 Qualifiers Map.*
|
||||
|
||||
These LANs represent the LCS:
|
||||
|
||||
* Anarchy NoVA (Ashburn, Virginia)
|
||||
* Big Dapple (New York, New York)
|
||||
* Booyah Bay (San Jose, California)
|
||||
* Chi-Shoals (Chicago, Illinois)
|
||||
* Don’t Flounder at the Function (Cary, North Carolina)
|
||||
* GatorLAN (Gainesville, Florida)
|
||||
* H-Town Splashdown (Houston, Texas)
|
||||
* New InkLAN (Waltham, Massachusetts)
|
||||
* SeaBATTLE\! (Seattle, Washington)
|
||||
* SoCallie (Irvine, California)
|
||||
* Sunset Surge (Tempe, Arizona)
|
||||
* Torontoroka (Toronto, Ontario)
|
||||
|
||||
Last year, GatorLAN took home the first place gold, with Sunset Surge taking silver and Big Dapple getting bronze. LCS 2025 only had eight teams; 2026 has expanded to twelve teams. The action finds a home on [SplatoonTourney’s Twitch channel](https://www.twitch.tv/SplatoonTourney), on Sunday, April 26th, 2026\.
|
||||
|
||||
With all of the locals continuing in 2026 and new ones preparing to debut, we may see an even bigger LAN Championship Showdown in 2027, representing even more Splatoon communities across the continent\!
|
||||
|
||||
Want an easier way to see which LANs are coming up and nearest to you, without waiting for a bimonthly article? Splatoon Stronghold has an interactive LAN map, with LANs across the globe\! Find it at [https://www.splatoonstronghold.com/lan-map](https://www.splatoonstronghold.com/lan-map)\!
|
||||
|
||||
|
||||
Original Posting Date: February 26, 2026
|
||||
|
||||
Written and formatted for publication by [YELLOW](https://bsky.app/profile/great-hero-yellow.bsky.social).
|
||||
1552
locales/da/game-badges.json
Normal file
1552
locales/de/game-badges.json
Normal file
1552
locales/en/game-badges.json
Normal file
1552
locales/es-ES/game-badges.json
Normal file
1552
locales/es-US/game-badges.json
Normal file
1552
locales/fr-CA/game-badges.json
Normal file
1552
locales/fr-EU/game-badges.json
Normal file
1552
locales/he/game-badges.json
Normal file
1552
locales/it/game-badges.json
Normal file
1552
locales/ja/game-badges.json
Normal file
1552
locales/ko/game-badges.json
Normal file
1552
locales/nl/game-badges.json
Normal file
1552
locales/pl/game-badges.json
Normal file
1552
locales/pt-BR/game-badges.json
Normal file
1552
locales/ru/game-badges.json
Normal file
1552
locales/zh/game-badges.json
Normal file
BIN
public/static-assets/badges/shs00.avif
Normal file
BIN
public/static-assets/badges/shs00.gif
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
public/static-assets/badges/shs00.png
Normal file
|
After Width: | Height: | Size: 6.8 KiB |