Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

## バックエンド
- Hono
- ​@cloudflare/wrangler
- Cloudflare Workers / Wrangler CLI

## フロントエンド/UI
- ​Preact​
Expand All @@ -17,4 +17,5 @@
- Vite​
- ​ESLint & StyleLint
- ​Prettier
- ​lefthook
- ​Husky
- lint-staged
62 changes: 62 additions & 0 deletions SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
---
name: splatoon-random-weapon
description: Use this skill when working in the splatoon-random-weapon repository. It covers the Preact + Vite frontend, the Hono API running on Cloudflare Pages Functions, and the D1/KV data flow used for random weapon selection and result history.
---

# Splatoon Random Weapon

Use this skill for feature work, bug fixes, refactors, or reviews in this repository.

## Repo map

- `src/main.tsx`: frontend entry point.
- `src/app.tsx`: main UI flow. This is the first place to inspect for product behavior.
- `src/components/`: presentational UI components.
- `functions/api/[[route]].ts`: API entry point for Cloudflare Pages Functions.
- `functions/api/routes/weapon.ts`: D1-backed weapon endpoints.
- `functions/api/routes/result.ts`: KV-backed result endpoint.
- `wrangler.toml`: Cloudflare bindings for D1 (`DB`) and KV (`RANDOM_WEAPONS`).

## Architecture

- Frontend uses `preact`, `vite`, `tailwindcss`, and `swr`.
- API uses `hono` on Cloudflare Pages Functions under `/api`.
- The frontend builds a typed client with `hc<AppType>('/')` and calls:
- `GET /api/weapons/random?count=<n>`
- `GET /api/results`
- Local Vite dev proxies `/api` to `http://localhost:3000`.

## Working rules for this repo

- Treat `src/app.tsx` as the current source of truth for user-visible behavior.
- Keep frontend and API changes aligned. If you change an API response shape, update the typed client usage in `src/app.tsx` in the same task.
- Be careful with the boundary between real data and placeholders:
- `useSWR('results', ...)` fetches live result data from KV.
- `cards` in `src/app.tsx` are currently hardcoded sample history entries.
- Do not assume the rendered history UI is already wired to backend data.
- `src/constants/weapon.ts` appears to be legacy or unused. Confirm usage before editing it.
- `tsconfig.json` includes only `src`, even though the frontend imports types from `functions/`. If type changes behave strangely, inspect this include boundary first.
- `package.json` is configured for `husky` and `lint-staged`. Avoid assuming hooks are consistently installed.

## Safe change workflow

1. Read `src/app.tsx` and the relevant route files before editing.
2. Prefer the existing API shape and component patterns unless the task clearly requires a change.
3. For backend changes, verify the corresponding Cloudflare binding exists in `wrangler.toml`.
4. For frontend changes, check whether data is live or placeholder before wiring UI logic.
5. Run the narrowest useful verification available after edits.

## Verification

- Install dependencies: `npm install`
- Frontend dev server: `npm run dev`
- Lint scripts: `npm run lint:script`
- Lint styles: `npm run lint:style`

If a task touches Cloudflare runtime behavior, note that local verification may also require a Pages/Workers dev setup that is not fully captured by current package scripts.

## Known pitfalls

- `functions/api/routes/weapon.ts` builds the SQL `LIMIT` clause from the query string directly. Be cautious when changing request handling there.
- `index.html` still has the default Vite title, so product polish tasks may need to update app metadata.
- `wrangler.toml` contains concrete binding IDs. Do not rotate or replace them casually.
45 changes: 41 additions & 4 deletions functions/api/routes/result.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,46 @@ type Bindings = {
RANDOM_WEAPONS: KVNamespace
}

const result = new Hono<{ Bindings: Bindings }>().get('/', async (c) => {
let results = await c.env.RANDOM_WEAPONS.get('results', 'json')
return c.json(results)
})
export type ResultHistoryItem = {
id: string
title: string
weaponList: string[]
createdAt: string
}

type CreateResultPayload = {
weaponList?: string[]
}

const result = new Hono<{ Bindings: Bindings }>()
.get('/', async (c) => {
const results =
(await c.env.RANDOM_WEAPONS.get<ResultHistoryItem[]>('results', 'json')) ??
[]
return c.json(results)
})
.post('/', async (c) => {
const body = (await c.req.json()) as CreateResultPayload

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

リクエストボディを as CreateResultPayload で型アサーションしていますが、これでは型安全性が保証されず、予期しない形式のデータが送られてきた場合にランタイムエラーが発生する可能性があります。
Honoには zodValidator などのバリデーションミドルウェアが用意されています。これらを利用してリクエストボディの検証と型付けを安全に行うことをお勧めします。これにより、コードの堅牢性が向上し、意図しないデータに対するエラーハンドリングも容易になります。
これは、スタイルガイドの「any の回避」(19行目)の原則にも合致する改善です。

References
  1. any の使用は極力避け、具体的な型を指定します。 (link)

const weaponList = body.weaponList?.filter(Boolean) ?? []

Comment on lines +27 to +29

Copilot AI Mar 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

POST /results assumes body.weaponList is an array and calls .filter on it. If the client sends a non-array value (or invalid JSON that still parses), this will throw and return a 500. Please validate the request body (e.g., ensure weaponList is an array) and return a 400 for invalid payloads.

Suggested change
const body = (await c.req.json()) as CreateResultPayload
const weaponList = body.weaponList?.filter(Boolean) ?? []
let body: CreateResultPayload
try {
body = (await c.req.json()) as CreateResultPayload
} catch {
return c.json({ message: 'Invalid JSON payload' }, 400)
}
if (!body || !Array.isArray(body.weaponList)) {
return c.json({ message: 'weaponList must be an array' }, 400)
}
const weaponList = body.weaponList.filter(Boolean)

Copilot uses AI. Check for mistakes.
if (weaponList.length === 0) {
return c.json({ message: 'weaponList is required' }, 400)
}

const currentResults =
(await c.env.RANDOM_WEAPONS.get<ResultHistoryItem[]>('results', 'json')) ??
[]
const nextResult: ResultHistoryItem = {
id: crypto.randomUUID(),
title: `結果 ${currentResults.length + 1}`,
weaponList,
createdAt: new Date().toISOString(),
}
const nextResults = [nextResult, ...currentResults].slice(0, 10)

await c.env.RANDOM_WEAPONS.put('results', JSON.stringify(nextResults))
Comment on lines +27 to +45

Copilot AI Mar 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

weaponList is currently accepted with no upper bound and without validating element types. Since this is persisted into KV, a malicious/accidental large payload could exceed KV limits or bloat storage; non-string items could also break frontend assumptions. Consider enforcing a max length (e.g., 4) and ensuring each entry is a non-empty string before storing.

Copilot uses AI. Check for mistakes.

return c.json(nextResult, 201)
})

export default result
8 changes: 7 additions & 1 deletion functions/api/routes/weapon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,14 @@ const weapon = new Hono<{ Bindings: Bindings }>()
})
.get('/random', async (c) => {
const { count } = c.req.query()
const parsedCount = Number.parseInt(count ?? '', 10)

if (!Number.isInteger(parsedCount) || parsedCount < 1 || parsedCount > 4) {
return c.json({ message: 'count must be an integer between 1 and 4' }, 400)
}

let { results }: Weapons = await c.env.DB.prepare(
`SELECT * FROM Weapons ORDER BY RANDOM() LIMIT ${count};`
`SELECT * FROM Weapons ORDER BY RANDOM() LIMIT ${parsedCount};`
).all()
Comment on lines 34 to 36

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

SQLインジェクションのリスクを完全に排除するため、D1のプリペアドステートメントでは、クエリ文字列に直接変数を埋め込むのではなく、bind()メソッドを使用したパラメータバインディングを利用することが強く推奨されます。
現在はparsedCountが整数であることが検証されていますが、パラメータバインディングを用いることで、より安全で標準的な実装になります。

    let { results }: Weapons = await c.env.DB.prepare(
      `SELECT * FROM Weapons ORDER BY RANDOM() LIMIT ?`
    )
      .bind(parsedCount)
      .all()

return c.json(results)
})
Expand Down
2 changes: 1 addition & 1 deletion index.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite App</title>
<title>Splatoon Random Weapon</title>
</head>
<body>
<div id="app"></div>
Expand Down
7 changes: 2 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,9 @@
},
"dependencies": {
"@cloudflare/workers-types": "^4.20240129.0",
"@cloudflare/wrangler": "^1.21.0",
"hono": "^3.12.10",
"miniflare": "^3.20240129.0",
"preact": "^10.7.1",
"swr": "^2.2.5",
"wrangler": "^3.26.0"
"swr": "^2.2.5"
},
"devDependencies": {
"@preact/preset-vite": "^2.0.0",
Expand All @@ -34,7 +31,7 @@
"stylelint-config-standard": "^24.0.0",
"tailwindcss": "^3.0.7",
"typescript": "^4.5.2",
"vite": "^5.0.0"
"vite": "^4.5.0"
},
"husky": {
"hooks": {
Expand Down
169 changes: 114 additions & 55 deletions src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,90 +11,149 @@ import { CardList } from '@/components/CardList'
import { SelectBox } from '@/components/SelectBox'

import { AppType } from '../functions/api/[[route]]'
import { ResultHistoryItem } from '../functions/api/routes/result'
import { Weapon } from '../functions/api/routes/weapon'

Copilot AI Mar 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This import is used only as a type. Prefer import type here to guarantee it’s erased from the client bundle (and to avoid accidentally pulling server-only modules into the browser build if it later becomes value-used).

Suggested change
import { Weapon } from '../functions/api/routes/weapon'
import type { Weapon } from '../functions/api/routes/weapon'

Copilot uses AI. Check for mistakes.

export function App() {
const client = hc<AppType>('/')
const $random = (count: string) =>
const [weaponList, setWeaponList] = useState<string[]>([])
const [person, setPerson] = useState('1')
const [isSubmitting, setIsSubmitting] = useState(false)
const [errorMessage, setErrorMessage] = useState('')
const $random = async (count: string) =>
client.api.weapons.random.$get({
query: {
count,
},
})
const $result = client.api.results.$get
const [weaponList, setWeaponList] = useState<string[]>([])
const [person, setPerson] = useState('1')
const fetcher = (arg: any) => async () => {
const res = await $result(arg)
return await res.json()
const fetchResults = async () => {
const res = await client.api.results.$get()

if (!res.ok) {
throw new Error('履歴の取得に失敗しました')
}

return (await res.json()) as ResultHistoryItem[]
}
const { data } = useSWR('results', fetcher({}), {
const { data, error, mutate, isLoading } = useSWR('results', fetchResults, {
revalidateOnFocus: false,
})
const cards = [
{
title: '結果1',
weaponList: ['52ガロン', 'N-ZAP85', 'スプラローラー', 'スプラシューター'],
},
{
title: '結果2',
weaponList: [
'スプラチャージャー',
'スプラスコープ',
'スプラマニューバー',
'スプラマニューバーコラボ',
],
},
{
title: '結果3',
weaponList: [
'スプラスピナー',
'スプラスピナーコラボ',
'スプラシューターコラボ',
'スプラローラー',
],
},
]

const optionList = [
{ label: '1人', value: '1' },
{ label: '2人', value: '2' },
{ label: '3人', value: '3' },
{ label: '4人', value: '4' },
]
// optionListで選択した人数分の武器をランダムで取得してて、それをweaponListに入れる

const createResult = async (nextWeaponList: string[]) => {
const res = await client.api.results.$post({
json: {
weaponList: nextWeaponList,
},
})

if (!res.ok) {
throw new Error('結果の保存に失敗しました')
}
}

const handleClick = async (person: string) => {
const randomResponse = await $random(person)
setIsSubmitting(true)
setErrorMessage('')

try {
const randomResponse = await $random(person)

if (!randomResponse.ok) {
throw new Error('武器の抽選に失敗しました')
}

//武器名のみの配列に変換
const randomWeaponList: string[] = (await randomResponse.json()).map(
(weapon: Weapon) => weapon.weaponName
)
setWeaponList(randomWeaponList)
const randomWeaponList: string[] = (await randomResponse.json()).map(
(weapon: Weapon) => weapon.weaponName
)

setWeaponList(randomWeaponList)
await createResult(randomWeaponList)
await mutate()
} catch (error) {
setErrorMessage(
error instanceof Error ? error.message : '処理中にエラーが発生しました'
)
} finally {
setIsSubmitting(false)
}
}
// 人数を選択するセレクトボックス

const setOnChangePerson = (value: string) => {
setPerson(value)
}

const historyCards =
data?.map((item) => ({
id: item.id,
title: item.title,
subtitle: new Date(item.createdAt).toLocaleString('ja-JP'),
weaponList: item.weaponList,
})) ?? []

return (
<>
<div class="flex m-6 justify-center">
<SelectBox
title="人数"
optionList={optionList}
onChange={(e) => setOnChangePerson(e)}
/>
</div>
<p>json: {JSON.stringify(data)}</p>
{weaponList.length > 0 && (
<div class="flex justify-center">
<Card title="結果" weaponList={weaponList} />
<div class="min-h-screen bg-slate-100">
<div class="mx-auto max-w-5xl px-4 py-8">
<div class="mb-8 text-center">
<h1 class="text-3xl font-bold text-slate-800">
Splatoon Random Weapon
</h1>
<p class="mt-2 text-slate-600">
人数を選ぶと、その場のチーム武器をランダムに決定します。
</p>
</div>

<div class="mb-8 rounded-xl bg-white p-6 shadow-md">
<div class="flex flex-col items-center gap-4 md:flex-row md:justify-center">
<SelectBox
title="人数"
optionList={optionList}
onChange={(e) => setOnChangePerson(e)}
value={person}
/>
<div class="pt-5">
<Button
text={isSubmitting ? '抽選中...' : 'スタート'}
onClick={() => handleClick(person)}
disabled={isSubmitting}
/>
</div>
</div>
{errorMessage && (
<p class="mt-4 text-center text-sm text-red-500">{errorMessage}</p>
)}
</div>

{weaponList.length > 0 && (
<div class="mb-8 flex justify-center">
<Card title="今回の結果" weaponList={weaponList} />
</div>
)}

<section>
<div class="mb-4 flex items-center justify-between">
<h2 class="text-2xl font-bold text-slate-800">履歴</h2>
{isLoading && <p class="text-sm text-slate-500">読み込み中...</p>}
</div>
{error && (
<p class="mb-4 text-sm text-red-500">履歴を取得できませんでした。</p>
)}
{!isLoading && !error && historyCards.length === 0 && (
<div class="rounded-xl bg-white p-6 text-center text-slate-500 shadow-md">
まだ履歴がありません。最初の抽選をしてみましょう。
</div>
)}
{historyCards.length > 0 && <CardList cards={historyCards} />}
</section>
</div>
)}
<div class="flex m-4 justify-center">
<Button text="スタート" onClick={() => handleClick(person)} />
</div>
<CardList cards={cards} />
</>
)
}
Loading