Merge remote-tracking branch 'origin/main' into css-rework-sidenav

This commit is contained in:
hfcRed 2026-03-04 16:31:50 +01:00
parent 7505e3dae6
commit d38019367b
1614 changed files with 27300 additions and 0 deletions

115
.claude/skills/e2e/SKILL.md Normal file
View 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 |

View 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,
});
};

View File

@ -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,
});
};

View File

@ -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,
});
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

@ -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"],
});
}
});

View File

@ -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;
}

View 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>
);
}

View 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);
});
});

View 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,
);
}
});
});

View File

@ -0,0 +1,3 @@
.searchable {
--select-width: 100%;
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,63 @@
---
title: "Splatoon Strongholds 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 events registration page in a new tab
- The date the LAN will take place
- The venues 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 MapHubs 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 Strongholds 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 maps 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 arent 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 Americas 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 Strongholds website, here: [splatoonstronghold.com/lan-map](http://splatoonstronghold.com/lan-map).
If you have a LAN that youd like to see added to the map, or a resource added to the page, please dont hesitate to reach out to the maps 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).

View 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 Americas 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 68 (Illinois, United States)**
- Brand new!
### **VanCoral Mini 2 \- March 7 (Vancouver, Canada)**
### **GatorLAN Spring 2026 \- March 78 (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 2122 (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 2526 (Pennsylvania, United States)**
## **LAN Championship Showdown 2026 \- April 26 (Online)**
Youve heard the hype. Youve seen it brewing over the first four months of 2026\. Its time to find out who the best LAN local in North America is\! From Torontoroka to Dont 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" />
[*SplatoonTourneys*](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)
* Dont 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 [SplatoonTourneys 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

File diff suppressed because it is too large Load Diff

1552
locales/de/game-badges.json Normal file

File diff suppressed because it is too large Load Diff

1552
locales/en/game-badges.json Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

1552
locales/he/game-badges.json Normal file

File diff suppressed because it is too large Load Diff

1552
locales/it/game-badges.json Normal file

File diff suppressed because it is too large Load Diff

1552
locales/ja/game-badges.json Normal file

File diff suppressed because it is too large Load Diff

1552
locales/ko/game-badges.json Normal file

File diff suppressed because it is too large Load Diff

1552
locales/nl/game-badges.json Normal file

File diff suppressed because it is too large Load Diff

1552
locales/pl/game-badges.json Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

1552
locales/ru/game-badges.json Normal file

File diff suppressed because it is too large Load Diff

1552
locales/zh/game-badges.json Normal file

File diff suppressed because it is too large Load Diff

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

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