diff --git a/.agents/skills/add-contest-table-provider/SKILL.md b/.agents/skills/add-contest-table-provider/SKILL.md index 8fd84db23..286fefe9c 100644 --- a/.agents/skills/add-contest-table-provider/SKILL.md +++ b/.agents/skills/add-contest-table-provider/SKILL.md @@ -8,6 +8,6 @@ Add a new contest table provider for: $ARGUMENTS > When in doubt at any step, use AskUserQuestion before proceeding. -0. **Seed check** — grep `prisma/tasks.ts` for the contest_id(s); report count + task_ids; if absent or incomplete, ask the user to add missing rows (reference: `https://kenkoooo.com/atcoder/resources/problems.json`) +0. **Seed check** — grep `prisma/tasks.ts` for the contest_id(s); report count + task_ids; if absent or incomplete, ask the user to paste the missing rows (source: `https://kenkoooo.com/atcoder/resources/problems.json`). Do not fetch it yourself: atcoder.jp is unreachable, and WebFetch truncates problems.json, so a miss there is not evidence of absence 1. **Gather requirements** — infer the implementation pattern; confirm per [instructions.md §Requirements](instructions.md) 2. **Implement** — follow [instructions.md](instructions.md) for the confirmed pattern across 5 layers (TDD) diff --git a/.agents/skills/add-contest-table-provider/instructions.md b/.agents/skills/add-contest-table-provider/instructions.md index 1bd29cc75..c052159f7 100644 --- a/.agents/skills/add-contest-table-provider/instructions.md +++ b/.agents/skills/add-contest-table-provider/instructions.md @@ -15,6 +15,7 @@ Step 0 (seed check) is already done. Confirm the following before touching code: - Pattern 2: single fixed contest_id (e.g. NDPC, TDPC, FPS_24) - Pattern 3: multiple contest_ids unified in one table (e.g. ABS, ABC-Like) - Pattern 4: one class instantiated N times via constructor parameter (e.g. ICPC by year) +- task_id format? Do not derive it from contest_id (`game-24` → `game_a`, but `fps-24` → `fps_24_a`) - Nearest neighbor ContestType for insertion order in `contestTypePriorities`? - New group or merge into existing? If new: group name / `buttonLabel` / `ariaLabel`? @@ -32,7 +33,7 @@ Step 0 (seed check) is already done. Confirm the following before touching code: > Skip when an existing ContestType already covers this contest family (e.g. `AOJ_JAG` for JAG Prelim). -- [ ] Add to `prisma/schema.prisma` ContestType enum → `pnpm exec prisma generate` +- [ ] Add to `prisma/schema.prisma` ContestType enum → `pnpm exec prisma migrate dev --name add__to_contest_type` (also regenerates the client) - [ ] Add to `ContestType` in `src/lib/contests/types/contest.ts` (same position as schema) - [ ] `pnpm check` — confirm error resolved @@ -51,7 +52,7 @@ Step 0 (seed check) is already done. Confirm the following before touching code: - [ ] `CONTEST_TYPES_BY_ID` or `CLASSIFICATION_RULES` in `src/lib/contests/utils/classification.ts` - [ ] `contestTypePriorities` in `src/lib/contests/utils/priority.ts` - After priority insertion, all later entries shift +1 → **update JSDoc numeric ranges** (4 category names are immutable) - - Fix hardcoded priority-diff expected values in `src/test/lib/utils/task.test.ts` (-1 per shifted entry) + - Fix hardcoded priority-diff expected values in `src/test/lib/utils/task.test.ts` only when they involve a shifted entry (±1 each) - [ ] `LABEL_GENERATORS` in `src/lib/contests/utils/labels/index.ts` (add label generator, create per-type file if complex) - [ ] **GREEN** diff --git a/docs/guides/agent-rules/prisma-db.md b/docs/guides/agent-rules/prisma-db.md index d2b8126b9..1a0b3891f 100644 --- a/docs/guides/agent-rules/prisma-db.md +++ b/docs/guides/agent-rules/prisma-db.md @@ -13,6 +13,7 @@ paths: 1. Edit `prisma/schema.prisma` 2. Run `pnpm exec prisma migrate dev --name ` +3. Production needs no manual step: CI (`.github/workflows/ci.yml`) runs `prisma migrate deploy` on push to `main`, before deploying. Do not add a manual deploy step to plans ## Naming @@ -21,7 +22,6 @@ paths: ## Server-Only Code - Import the DB client (`$lib/server/database`) only in `src/lib/server/**` and in server-only services under `src/lib/services/**` and `src/features/**/services/**` -- Route handlers must not import the DB client directly — call service methods instead - Never import server-only code in client components ## Service Layer diff --git a/docs/guides/agent-rules/svelte-components.md b/docs/guides/agent-rules/svelte-components.md index f7093edc3..f3d5519c8 100644 --- a/docs/guides/agent-rules/svelte-components.md +++ b/docs/guides/agent-rules/svelte-components.md @@ -26,6 +26,7 @@ Use `$props()`, `$state()`, `$derived()`, `$effect()` in all components: - Components: `PascalCase.svelte` - Stores: `snake_case.svelte.ts`; class-based with `$state()`, singleton export +- Values persisted to `localStorage` or URL params are a contract: rename display labels freely, but changing a stored key/value (e.g. preset key `dps`) silently drops users' saved state ## Props & Reactivity @@ -37,7 +38,7 @@ Use `$props()`, `$state()`, `$derived()`, `$effect()` in all components: ## `{#each}` Patterns - Always key: `(item.id)` or `(i)` -- **Key MUST be unique per iteration** — if domain allows duplicates, use composite key (e.g. `contest_id + '-' + task_id`) +- **Key MUST be unique per iteration** — if domain allows duplicates, use composite key. Trap: `task_id` alone repeats when a task appears in multiple contests; use `contest_id + '-' + task_id` (#3460, #3442) - `(i)` is only safe when the list's order and length never change (e.g. a fixed split of a string into lines). For a mutable list — reorderable, insertable, or deletable — use a persistent unique ID or composite key; `(i)` there causes Svelte to reuse DOM/state across the wrong items after a mutation. - Filter **before**, not inside with `{#if}` - Use `{:else}` for empty lists @@ -50,8 +51,6 @@ Use `$props()`, `$state()`, `$derived()`, `$effect()` in all components: {/each} ``` -**Common Trap:** `task_id` alone is NOT unique when same task appears in multiple contests. Use `contest_id + '-' + task_id` as composite key (see issue #3460 & PR #3442). - ## Snippets vs Components - **Snippet**: needs `$state` access, pure display, same-file; params require explicit type `{#snippet label(item: Item)}` diff --git a/docs/guides/agent-rules/testing.md b/docs/guides/agent-rules/testing.md index 85ecca23e..f26d0f821 100644 --- a/docs/guides/agent-rules/testing.md +++ b/docs/guides/agent-rules/testing.md @@ -37,7 +37,7 @@ paths: - Enum membership: `Object.hasOwn(Enum, value)`, not `in` (avoids prototype chain) - `Promise`: use `await fn()` to assert no throw, or `.resolves.toBeUndefined()`. **Never** bare `.resolves` (false-positive) - **Stubs**: parameter types must match production signature — use domain types (`TaskGrade`), not `string` -- **Test data**: realistic values (real task IDs, grade names). Extract shared fixtures to file/describe scope; inline for single-use +- **Test data**: realistic values (real task IDs, grade names). Extract shared fixtures to file/describe scope; inline for single-use. When copying a neighbor's tests, grep each fixture/helper's usages first — do not replicate unreferenced ones - **Registry exhaustiveness**: `Record` and `new Map([...])` give no compile-time enum coverage — assert it at runtime, with a named, commented exception list so deliberate gaps stay documented: `expect(Object.values(Enum).filter((key) => !REGISTRY.has(key))).toEqual(KNOWN_GAPS)` (`Record` registries: swap `REGISTRY.has(key)` for the `Object.hasOwn` form above) - **Test count as a proxy**: after a file split, compare counts (`--reporter=verbose`) — a drop means a describe block was lost in the move. A deliberate drop is valid only when mutation analysis shows detection is unchanged; record the evidence in the commit diff --git a/docs/guides/how-to-add-contest-table-provider.md b/docs/guides/how-to-add-contest-table-provider.md index 5a72e7463..f1b028c1c 100644 --- a/docs/guides/how-to-add-contest-table-provider.md +++ b/docs/guides/how-to-add-contest-table-provider.md @@ -115,12 +115,15 @@ AOJ/JAG 固有の詳細(`AOJ_LABEL_OVERRIDES`、`titleStyle`、同一年2回 | TDPC | `'tdpc'` | 26問 | A~Z | | NDPC | `'ndpc'` | 20問 | A~T | | FPS_24 | `'fps-24'` | 24問 | A~X | +| GAME_24‡ | `'game-24'` | 24問 | A~X | | ACL_PRACTICE | `'practice2'` | 12問 | A~L | | ACL_BEGINNER / ACL_CONTEST1 | `'abl'`/`'acl1'` | 6問 | A~F | | AWC0100 / AWC0150† | `'awc0100'`/`'awc0150'` | 15問 | A~O | †注: ContestType.AWC を再利用し、section(`'0100'`, `'0150'`, `'0151Onwards'`)で provider key を一意化。ACL 系は `Acl` グループ、DP 系は `dps` グループで統一管理。 +‡注: task_id は `game_a`〜`game_x` で、contest_id の数字を含まない(`fps-24` は `fps_24_a`)。 + ### コンストラクタパラメータ型 / regex フィルタ型 | コンテスト | フィルタ方式 | 備考 | diff --git a/e2e/problems_contest_table.spec.ts b/e2e/problems_contest_table.spec.ts new file mode 100644 index 000000000..669eb54f4 --- /dev/null +++ b/e2e/problems_contest_table.spec.ts @@ -0,0 +1,21 @@ +import { expect, test } from '@playwright/test'; + +const XDPC_GROUP_ARIA_LABEL = 'xDPC and x 24 contests'; +const XDPC_GROUP_TABLE_TITLES = [ + 'Educational DP Contest / DP まとめコンテスト', + 'Typical DP Contest', + 'Next DP Contest', + 'FPS 24 題', + '組合せゲーム 24 題', +]; + +test.describe('xDPC group in contest table', () => { + test('shows Game 24 table right after FPS 24 in the xDPC group', async ({ page }) => { + await page.goto('/problems'); + + await page.getByRole('button', { name: XDPC_GROUP_ARIA_LABEL }).click(); + + // Headings render even when the seed has no tasks for a provider, so this stays seed-independent. + await expect(page.getByRole('heading', { level: 2 })).toHaveText(XDPC_GROUP_TABLE_TITLES); + }); +}); diff --git a/prisma/migrations/20261006125938_add_game_24_to_contest_type/migration.sql b/prisma/migrations/20261006125938_add_game_24_to_contest_type/migration.sql new file mode 100644 index 000000000..42fbe31bd --- /dev/null +++ b/prisma/migrations/20261006125938_add_game_24_to_contest_type/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "ContestType" ADD VALUE 'GAME_24'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 1dde3079e..61114f9d1 100755 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -314,6 +314,7 @@ enum ContestType { AWC // AtCoder Weekday Contest UNIVERSITY // University Programming Contests in AtCoder (e.g., UTPC) FPS_24 // 24 Problems on Formal Power Series + GAME_24 // 24 Problems on Combinatorial Games ATCODER_MAIN_OFFICIAL_ONSITE // AtCoder World Tour Finals (official onsite finals) OTHERS // AtCoder (その他) AOJ_COURSES // AIZU ONLINE JUDGE Courses diff --git a/prisma/tasks.ts b/prisma/tasks.ts index 1828cc8c0..657357df0 100755 --- a/prisma/tasks.ts +++ b/prisma/tasks.ts @@ -8809,6 +8809,174 @@ export const tasks = [ name: 'Bacteria Growth Experiment', title: 'A. Bacteria Growth Experiment', }, + { + id: 'game_x', + contest_id: 'game-24', + problem_index: 'X', + name: 'XOR Bag', + title: 'X. XOR Bag', + }, + { + id: 'game_w', + contest_id: 'game-24', + problem_index: 'W', + name: 'Parity Game', + title: 'W. Parity Game', + }, + { + id: 'game_v', + contest_id: 'game-24', + problem_index: 'V', + name: 'Delete Game', + title: 'V. Delete Game', + }, + { + id: 'game_u', + contest_id: 'game-24', + problem_index: 'U', + name: 'Rectangle Flip Game', + title: 'U. Rectangle Flip Game', + }, + { + id: 'game_t', + contest_id: 'game-24', + problem_index: 'T', + name: 'Consecutive Move Game', + title: 'T. Consecutive Move Game', + }, + { + id: 'game_s', + contest_id: 'game-24', + problem_index: 'S', + name: 'Odd and Even', + title: 'S. Odd and Even', + }, + { + id: 'game_r', + contest_id: 'game-24', + problem_index: 'R', + name: 'Delete and Divide', + title: 'R. Delete and Divide', + }, + { + id: 'game_q', + contest_id: 'game-24', + problem_index: 'Q', + name: 'Moving Pieces on Graph', + title: 'Q. Moving Pieces on Graph', + }, + { + id: 'game_p', + contest_id: 'game-24', + problem_index: 'P', + name: 'Game on Tree', + title: 'P. Game on Tree', + }, + { + id: 'game_o', + contest_id: 'game-24', + problem_index: 'O', + name: 'Moving Pieces on Grid', + title: 'O. Moving Pieces on Grid', + }, + { + id: 'game_n', + contest_id: 'game-24', + problem_index: 'N', + name: 'One and All', + title: 'N. One and All', + }, + { + id: 'game_m', + contest_id: 'game-24', + problem_index: 'M', + name: 'LRUD Game', + title: 'M. LRUD Game', + }, + { + id: 'game_l', + contest_id: 'game-24', + problem_index: 'L', + name: 'Unfair Nim', + title: 'L. Unfair Nim', + }, + { + id: 'game_k', + contest_id: 'game-24', + problem_index: 'K', + name: 'Domino in Tetromino 2', + title: 'K. Domino in Tetromino 2', + }, + { + id: 'game_j', + contest_id: 'game-24', + problem_index: 'J', + name: 'Restricted Nim 2', + title: 'J. Restricted Nim 2', + }, + { + id: 'game_i', + contest_id: 'game-24', + problem_index: 'I', + name: 'Necklace 2', + title: 'I. Necklace 2', + }, + { + id: 'game_h', + contest_id: 'game-24', + problem_index: 'H', + name: 'Lexicographic Order Game', + title: 'H. Lexicographic Order Game', + }, + { + id: 'game_g', + contest_id: 'game-24', + problem_index: 'G', + name: 'Round Nim', + title: 'G. Round Nim', + }, + { + id: 'game_f', + contest_id: 'game-24', + problem_index: 'F', + name: 'Necklace 1', + title: 'F. Necklace 1', + }, + { + id: 'game_e', + contest_id: 'game-24', + problem_index: 'E', + name: 'Restricted Nim 1', + title: 'E. Restricted Nim 1', + }, + { + id: 'game_d', + contest_id: 'game-24', + problem_index: 'D', + name: 'Parallel Nim', + title: 'D. Parallel Nim', + }, + { + id: 'game_c', + contest_id: 'game-24', + problem_index: 'C', + name: 'Domino in Tetromino 1', + title: 'C. Domino in Tetromino 1', + }, + { + id: 'game_b', + contest_id: 'game-24', + problem_index: 'B', + name: 'Chocolate', + title: 'B. Chocolate', + }, + { + id: 'game_a', + contest_id: 'game-24', + problem_index: 'A', + name: 'Nim', + title: 'A. Nim', + }, { id: 'fps_24_x', contest_id: 'fps-24', diff --git a/src/features/tasks/utils/contest-table/contest_table_provider_groups.test.ts b/src/features/tasks/utils/contest-table/contest_table_provider_groups.test.ts index f879b1efd..0c6d0c6f1 100644 --- a/src/features/tasks/utils/contest-table/contest_table_provider_groups.test.ts +++ b/src/features/tasks/utils/contest-table/contest_table_provider_groups.test.ts @@ -168,21 +168,22 @@ describe('prepareContestProviderPresets', () => { } }); - test('dps registers EDPC, TDPC, NDPC, and FPS 24 providers', () => { + test('dps registers EDPC, TDPC, NDPC, FPS 24, and Game 24 providers', () => { const group = presets.dps(); - expect(group.getGroupName()).toBe('xDPC・FPS 24'); + expect(group.getGroupName()).toBe('xDPC・x 24 題'); expect(group.getMetadata()).toEqual({ - buttonLabel: 'xDPC・FPS 24', - ariaLabel: 'xDPC and FPS 24 contests', + buttonLabel: 'xDPC・x 24 題', + ariaLabel: 'xDPC and x 24 contests', }); - expect(group.getSize()).toBe(4); + expect(group.getSize()).toBe(5); const contestTypes = [ ContestType.EDPC, ContestType.TDPC, ContestType.NDPC, ContestType.FPS_24, + ContestType.GAME_24, ]; for (const contestType of contestTypes) { diff --git a/src/features/tasks/utils/contest-table/contest_table_provider_groups.ts b/src/features/tasks/utils/contest-table/contest_table_provider_groups.ts index 98e7da299..f1733a393 100644 --- a/src/features/tasks/utils/contest-table/contest_table_provider_groups.ts +++ b/src/features/tasks/utils/contest-table/contest_table_provider_groups.ts @@ -25,6 +25,7 @@ import { import { MathAndAlgorithmProvider } from './math_and_algorithm_provider'; import { EDPCProvider, TDPCProvider, NDPCProvider } from './dp_providers'; import { FPS24Provider } from './fps24_provider'; +import { Game24Provider } from './game24_provider'; import { ACLPracticeProvider, ACLBeginnerProvider, ACLProvider } from './acl_providers'; import { JOIFirstQualRoundProvider, @@ -239,17 +240,18 @@ export const prepareContestProviderPresets = () => { }).addProvider(new MathAndAlgorithmProvider(ContestType.MATH_AND_ALGORITHM)), /** - * DP group (EDPC, TDPC, NDPC, and FPS 24) + * DP group (EDPC, TDPC, NDPC, FPS 24, and Game 24) */ dps: () => - new ContestTableProviderGroup(`xDPC・FPS 24`, { - buttonLabel: 'xDPC・FPS 24', - ariaLabel: 'xDPC and FPS 24 contests', + new ContestTableProviderGroup(`xDPC・x 24 題`, { + buttonLabel: 'xDPC・x 24 題', + ariaLabel: 'xDPC and x 24 contests', }).addProviders( new EDPCProvider(ContestType.EDPC), new TDPCProvider(ContestType.TDPC), new NDPCProvider(ContestType.NDPC), new FPS24Provider(ContestType.FPS_24), + new Game24Provider(ContestType.GAME_24), ), /** diff --git a/src/features/tasks/utils/contest-table/game24_provider.test.ts b/src/features/tasks/utils/contest-table/game24_provider.test.ts new file mode 100644 index 000000000..d45805754 --- /dev/null +++ b/src/features/tasks/utils/contest-table/game24_provider.test.ts @@ -0,0 +1,109 @@ +import { describe, test, expect } from 'vitest'; + +import { ContestType } from '$lib/contests/types/contest'; +import type { TaskResults } from '$lib/types/task'; + +import { Game24Provider } from './game24_provider'; + +// Task IDs omit the contest number (game_a, not game_24_a), unlike fps-24. +describe('Game24Provider', () => { + test('expects to get correct metadata', () => { + const provider = new Game24Provider(ContestType.GAME_24); + const metadata = provider.getMetadata(); + + expect(metadata.title).toBe('組合せゲーム 24 題'); + expect(metadata.abbreviationName).toBe('game-24'); + }); + + test('expects to get correct display configuration', () => { + const provider = new Game24Provider(ContestType.GAME_24); + const displayConfig = provider.getDisplayConfig(); + + expect(displayConfig.isShownHeader).toBe(false); + expect(displayConfig.isShownRoundLabel).toBe(false); + expect(displayConfig.roundLabelWidth).toBe(''); + expect(displayConfig.tableBodyCellsWidth).toBe( + 'w-1/2 xs:w-1/3 sm:w-1/4 md:w-1/5 lg:w-1/6 2xl:w-1/7 px-1 py-2', + ); + expect(displayConfig.isShownTaskIndex).toBe(true); + }); + + test('expects to format contest round label correctly', () => { + const provider = new Game24Provider(ContestType.GAME_24); + const label = provider.getContestRoundLabel(''); + + expect(label).toBe(''); + }); + + test('expects to filter tasks to include only game-24 contest', () => { + const provider = new Game24Provider(ContestType.GAME_24); + const mixedTasks = [ + { contest_id: 'abc123', task_id: 'abc123_a', task_table_index: 'A' }, + { contest_id: 'game-24', task_id: 'game_a', task_table_index: 'A' }, + { contest_id: 'fps-24', task_id: 'fps_24_a', task_table_index: 'A' }, + { contest_id: 'game-24', task_id: 'game_b', task_table_index: 'B' }, + ]; + const filtered = provider.filter(mixedTasks as TaskResults); + + expect(filtered?.map((task) => task.task_id)).toEqual(['game_a', 'game_b']); + }); + + test('expects to generate correct table structure', () => { + const provider = new Game24Provider(ContestType.GAME_24); + const tasks = [ + { contest_id: 'game-24', task_id: 'game_a', task_table_index: 'A' }, + { contest_id: 'game-24', task_id: 'game_b', task_table_index: 'B' }, + { contest_id: 'game-24', task_id: 'game_x', task_table_index: 'X' }, + ]; + const table = provider.generateTable(tasks as TaskResults); + + expect(table).toHaveProperty('game-24'); + expect(table['game-24']).toHaveProperty('A'); + expect(table['game-24']).toHaveProperty('B'); + expect(table['game-24']).toHaveProperty('X'); + expect(table['game-24']['A']).toEqual(expect.objectContaining({ task_id: 'game_a' })); + }); + + test('expects to get contest round IDs correctly', () => { + const provider = new Game24Provider(ContestType.GAME_24); + const tasks = [ + { contest_id: 'game-24', task_id: 'game_a', task_table_index: 'A' }, + { contest_id: 'game-24', task_id: 'game_x', task_table_index: 'X' }, + ]; + const roundIds = provider.getContestRoundIds(tasks as TaskResults); + + expect(roundIds).toEqual(['game-24']); + }); + + test('expects to get header IDs for tasks correctly in ascending order', () => { + const provider = new Game24Provider(ContestType.GAME_24); + const tasks = [ + { contest_id: 'game-24', task_id: 'game_a', task_table_index: 'A' }, + { contest_id: 'game-24', task_id: 'game_x', task_table_index: 'X' }, + { contest_id: 'game-24', task_id: 'game_m', task_table_index: 'M' }, + { contest_id: 'game-24', task_id: 'game_b', task_table_index: 'B' }, + ]; + const headerIds = provider.getHeaderIdsForTask(tasks as TaskResults); + + expect(headerIds).toEqual(['A', 'B', 'M', 'X']); + }); + + test('expects to handle empty task results', () => { + const provider = new Game24Provider(ContestType.GAME_24); + const filtered = provider.filter([] as TaskResults); + + expect(filtered).toEqual([] as TaskResults); + }); + + test('expects to handle task results with different contest types', () => { + const provider = new Game24Provider(ContestType.GAME_24); + const mockMixedTasks = [ + { contest_id: 'abc123', task_id: 'abc123_a', task_table_index: 'A' }, + { contest_id: 'dp', task_id: 'dp_a', task_table_index: 'A' }, + { contest_id: 'fps-24', task_id: 'fps_24_a', task_table_index: 'A' }, + ]; + const filtered = provider.filter(mockMixedTasks as TaskResults); + + expect(filtered).toEqual([] as TaskResults); + }); +}); diff --git a/src/features/tasks/utils/contest-table/game24_provider.ts b/src/features/tasks/utils/contest-table/game24_provider.ts new file mode 100644 index 000000000..8d8060650 --- /dev/null +++ b/src/features/tasks/utils/contest-table/game24_provider.ts @@ -0,0 +1,42 @@ +import { + type ContestTableMetaData, + type ContestTableDisplayConfig, +} from '$features/tasks/types/contest-table/contest_table_provider'; +import type { TaskResult } from '$lib/types/task'; + +import { classifyContest } from '$lib/contests'; + +import { ContestTableProviderBase } from './contest_table_provider_base'; + +export class Game24Provider extends ContestTableProviderBase { + protected setFilterCondition(): (taskResult: TaskResult) => boolean { + return (taskResult: TaskResult) => { + if (classifyContest(taskResult.contest_id) !== this.contestType) { + return false; + } + + return taskResult.contest_id === 'game-24'; + }; + } + + getMetadata(): ContestTableMetaData { + return { + title: '組合せゲーム 24 題', + abbreviationName: 'game-24', + }; + } + + getDisplayConfig(): ContestTableDisplayConfig { + return { + isShownHeader: false, + isShownRoundLabel: false, + roundLabelWidth: '', // No specific width for task index in Game 24 + tableBodyCellsWidth: 'w-1/2 xs:w-1/3 sm:w-1/4 md:w-1/5 lg:w-1/6 2xl:w-1/7 px-1 py-2', + isShownTaskIndex: true, + }; + } + + getContestRoundLabel(_contestId: string): string { + return ''; + } +} diff --git a/src/lib/contests/fixtures/contest_name_labels.ts b/src/lib/contests/fixtures/contest_name_labels.ts index 6ff6f13c2..5b32407d7 100644 --- a/src/lib/contests/fixtures/contest_name_labels.ts +++ b/src/lib/contests/fixtures/contest_name_labels.ts @@ -139,6 +139,13 @@ export const fps24 = [ }), ]; +export const game24 = [ + createTestCaseForContestNameLabel('Game 24')({ + contestId: 'game-24', + expected: '組合せゲーム 24 題', + }), +]; + export const aojCourses = [ createTestCaseForContestNameLabel('AOJ, ITP1')({ contestId: 'ITP1', diff --git a/src/lib/contests/fixtures/contest_type.ts b/src/lib/contests/fixtures/contest_type.ts index de2be1afb..32663ea60 100644 --- a/src/lib/contests/fixtures/contest_type.ts +++ b/src/lib/contests/fixtures/contest_type.ts @@ -170,6 +170,13 @@ export const fps24 = [ }), ]; +export const game24 = [ + createTestCaseForContestType('Game 24')({ + contestId: 'game-24', + expected: ContestType.GAME_24, + }), +]; + const arcContestIds = [ 'arc001', 'arc002', diff --git a/src/lib/contests/types/contest.ts b/src/lib/contests/types/contest.ts index 7f59933f1..98200fff5 100644 --- a/src/lib/contests/types/contest.ts +++ b/src/lib/contests/types/contest.ts @@ -48,6 +48,7 @@ export const ContestType: { [key in ContestTypeOrigin]: key } = { AWC: 'AWC', // AtCoder Weekday Contest UNIVERSITY: 'UNIVERSITY', // University Programming Contests in AtCoder (e.g., UTPC) FPS_24: 'FPS_24', // 24 Problems on Formal Power Series + GAME_24: 'GAME_24', // 24 Problems on Combinatorial Games ATCODER_MAIN_OFFICIAL_ONSITE: 'ATCODER_MAIN_OFFICIAL_ONSITE', // AtCoder World Tour Finals (official onsite finals) OTHERS: 'OTHERS', // AtCoder (その他) AOJ_COURSES: 'AOJ_COURSES', // AIZU ONLINE JUDGE Courses diff --git a/src/lib/contests/utils/classification.test.ts b/src/lib/contests/utils/classification.test.ts index a5ab90258..2dd2e0d4e 100644 --- a/src/lib/contests/utils/classification.test.ts +++ b/src/lib/contests/utils/classification.test.ts @@ -112,6 +112,14 @@ describe('classify contest', () => { }); }); + describe('when contest_id is game-24', () => { + TestCasesForContestType.game24.forEach(({ name, value }) => { + runTests(`${name}`, [value], ({ contestId, expected }: TestCaseForContestType) => { + expect(classifyContest(contestId)).toEqual(expected); + }); + }); + }); + describe('when contest_id contains arc', () => { TestCasesForContestType.arc.forEach(({ name, value }) => { runTests(`${name}`, [value], ({ contestId, expected }: TestCaseForContestType) => { diff --git a/src/lib/contests/utils/classification.ts b/src/lib/contests/utils/classification.ts index 13d293864..e4eb58ab0 100644 --- a/src/lib/contests/utils/classification.ts +++ b/src/lib/contests/utils/classification.ts @@ -31,6 +31,7 @@ const CONTEST_TYPES_BY_ID: ReadonlyMap = new Map([ ['tessoku-book', ContestType.TESSOKU_BOOK], ['math-and-algorithm', ContestType.MATH_AND_ALGORITHM], ['fps-24', ContestType.FPS_24], + ['game-24', ContestType.GAME_24], ]); type ClassificationRule = { diff --git a/src/lib/contests/utils/labels/index.test.ts b/src/lib/contests/utils/labels/index.test.ts index 7c45fc57e..c6fd1856e 100644 --- a/src/lib/contests/utils/labels/index.test.ts +++ b/src/lib/contests/utils/labels/index.test.ts @@ -98,6 +98,14 @@ describe('get contest name label', () => { }); }); + describe('when contest_id is game-24', () => { + TestCasesForContestNameLabel.game24.forEach(({ name, value }) => { + runTests(`${name}`, [value], ({ contestId, expected }: TestCaseForContestNameLabel) => { + expect(getContestNameLabel(contestId)).toEqual(expected); + }); + }); + }); + describe('when contest_id contains awc', () => { TestCasesForContestNameLabel.awc.forEach(({ name, value }) => { runTests(`${name}`, [value], ({ contestId, expected }: TestCaseForContestNameLabel) => { diff --git a/src/lib/contests/utils/labels/index.ts b/src/lib/contests/utils/labels/index.ts index 5d1bc2fdd..4f04ccd94 100644 --- a/src/lib/contests/utils/labels/index.ts +++ b/src/lib/contests/utils/labels/index.ts @@ -34,6 +34,7 @@ export const LABEL_GENERATORS: ReadonlyMap = new Ma [ContestType.TESSOKU_BOOK, () => '競技プログラミングの鉄則'], [ContestType.MATH_AND_ALGORITHM, () => 'アルゴリズムと数学'], [ContestType.FPS_24, () => 'FPS 24 題'], + [ContestType.GAME_24, () => '組合せゲーム 24 題'], [ContestType.ATCODER_MAIN_OFFICIAL_ONSITE, (id) => getWorldTourFinalsLabel(id)], [ContestType.UNIVERSITY, (id) => getAtCoderUniversityContestLabel(id)], [ContestType.OTHERS, generateOthersLabel], diff --git a/src/lib/contests/utils/priority.test.ts b/src/lib/contests/utils/priority.test.ts index e59927e38..8782099f1 100644 --- a/src/lib/contests/utils/priority.test.ts +++ b/src/lib/contests/utils/priority.test.ts @@ -124,5 +124,9 @@ describe('contest type priorities', () => { expect(priorityOf(ContestType.ARC_LIKE)).toBeGreaterThan(priorityOf(ContestType.ARC)); expect(priorityOf(ContestType.AGC_LIKE)).toBeGreaterThan(priorityOf(ContestType.AGC)); }); + + test('ranks GAME_24 directly below FPS_24', () => { + expect(priorityOf(ContestType.GAME_24)).toBe(priorityOf(ContestType.FPS_24) + 1); + }); }); }); diff --git a/src/lib/contests/utils/priority.ts b/src/lib/contests/utils/priority.ts index 9b96e4feb..97ce14819 100644 --- a/src/lib/contests/utils/priority.ts +++ b/src/lib/contests/utils/priority.ts @@ -2,13 +2,13 @@ import { ContestType } from '$lib/contests/types/contest'; import { classifyContest } from './classification'; /** - * Contest type priorities (0 = Highest, 26 = Lowest) + * Contest type priorities (0 = Highest, 27 = Lowest) * * Priority assignment rationale: * - Educational contests (0-11, 17): ABS, ABC, APG4B and AWC etc. * - Contests for genius (12-16): ARC, AGC, and their variants - * - Special contests (18-21): UNIVERSITY, FPS_24, ATCODER_MAIN_OFFICIAL_ONSITE, OTHERS - * - External platforms (22-26): AOJ_COURSES, AOJ_PCK, AOJ_ICPC, AOJ_JAG, AOJ_UNIVERSITY + * - Special contests (18-22): UNIVERSITY, FPS_24, GAME_24, ATCODER_MAIN_OFFICIAL_ONSITE, OTHERS + * - External platforms (23-27): AOJ_COURSES, AOJ_PCK, AOJ_ICPC, AOJ_JAG, AOJ_UNIVERSITY * * @remarks * HACK: The priorities for ARC, AGC, UNIVERSITY, AOJ_COURSES, and AOJ_PCK are temporary @@ -38,13 +38,14 @@ export const contestTypePriorities: Map = new Map([ [ContestType.AWC, 17], [ContestType.UNIVERSITY, 18], [ContestType.FPS_24, 19], - [ContestType.ATCODER_MAIN_OFFICIAL_ONSITE, 20], - [ContestType.OTHERS, 21], // AtCoder (その他) - [ContestType.AOJ_COURSES, 22], - [ContestType.AOJ_PCK, 23], - [ContestType.AOJ_ICPC, 24], - [ContestType.AOJ_JAG, 25], - [ContestType.AOJ_UNIVERSITY, 26], + [ContestType.GAME_24, 20], + [ContestType.ATCODER_MAIN_OFFICIAL_ONSITE, 21], + [ContestType.OTHERS, 22], // AtCoder (その他) + [ContestType.AOJ_COURSES, 23], + [ContestType.AOJ_PCK, 24], + [ContestType.AOJ_ICPC, 25], + [ContestType.AOJ_JAG, 26], + [ContestType.AOJ_UNIVERSITY, 27], ]); /** Priority given to a contest_id that no classification rule matches, so it sorts last. */