diff --git a/admin/src/api/client.ts b/admin/src/api/client.ts index bb376c9..4169bd4 100644 --- a/admin/src/api/client.ts +++ b/admin/src/api/client.ts @@ -3,6 +3,10 @@ import type { EventData, EventsResponse, Exclusion, HarvestQuest } from "../type const API_URL = import.meta.env.VITE_API_URL as string; +/** + * Cognito の idToken を Bearer トークンとして含む認証ヘッダーを生成する。 + * すべての API リクエストの前処理として request() から呼び出される。 + */ async function authHeaders(): Promise> { const session = await fetchAuthSession(); const token = session.tokens?.idToken?.toString() ?? ""; @@ -12,6 +16,12 @@ async function authHeaders(): Promise> { }; } +/** + * 認証ヘッダーを付けて管理 API へリクエストを送る汎用関数。 + * レスポンスが ok でない場合はレスポンスボディを含むエラーをスローする。 + * @param path API パス(VITE_API_URL からの相対パス) + * @param init fetch の追加オプション(method, body など) + */ async function request(path: string, init?: RequestInit): Promise { const headers = await authHeaders(); const res = await fetch(`${API_URL}${path}`, { @@ -25,10 +35,12 @@ async function request(path: string, init?: RequestInit): Promise { return res.json() as Promise; } +/** イベント一覧を取得する。 */ export function getEvents() { return request("/events"); } +/** イベントを新規作成する。作成されたイベント(eventId 付き)を返す。 */ export function createEvent(data: Omit) { return request("/events", { method: "POST", @@ -36,6 +48,11 @@ export function createEvent(data: Omit) { }); } +/** + * 指定イベントを更新する。更新後のイベントデータを返す。 + * @param eventId 更新対象のイベント ID + * @param data イベントデータ(eventId を除く) + */ export function updateEvent(eventId: string, data: Omit) { return request(`/events/${eventId}`, { method: "PUT", @@ -43,16 +60,24 @@ export function updateEvent(eventId: string, data: Omit) { }); } +/** 指定イベントを削除する。 */ export function deleteEvent(eventId: string) { return request<{ message: string }>(`/events/${eventId}`, { method: "DELETE", }); } +/** 指定クエストの除外リストを取得する。 */ export function getExclusions(questId: string) { return request(`/exclusions/${questId}`); } +/** + * 指定クエストの除外リストを更新する(全件置き換え)。 + * 更新後の除外リストを返す。 + * @param questId クエスト ID + * @param exclusions 除外リスト(全件置き換え) + */ export function updateExclusions(questId: string, exclusions: Exclusion[]) { return request(`/exclusions/${questId}`, { method: "PUT", @@ -60,6 +85,10 @@ export function updateExclusions(questId: string, exclusions: Exclusion[]) { }); } +/** + * Harvest のクエスト一覧を取得する。 + * EventFormPage でイベントクエスト候補を絞り込む際に使用する。 + */ export function fetchHarvestQuests() { return request("/harvest/quests"); } diff --git a/admin/src/auth/AuthProvider.tsx b/admin/src/auth/AuthProvider.tsx index 97e32ef..0ffa27f 100644 --- a/admin/src/auth/AuthProvider.tsx +++ b/admin/src/auth/AuthProvider.tsx @@ -11,10 +11,16 @@ interface AuthContextValue { const AuthContext = createContext(null!); +/** 認証コンテキスト(isAuthenticated, username, login, logout など)を取得するカスタムフック。 */ export function useAuth() { return useContext(AuthContext); } +/** + * AWS Amplify を使った認証状態を管理し、子コンポーネントに提供するプロバイダー。 + * マウント時に getCurrentUser() で既存セッションを確認し、認証状態を初期化する。 + * login / logout を AuthContext 経由で子コンポーネントに公開する。 + */ export function AuthProvider({ children }: { children: ReactNode }) { const [isAuthenticated, setIsAuthenticated] = useState(false); const [isLoading, setIsLoading] = useState(true); diff --git a/admin/src/pages/EventFormPage.tsx b/admin/src/pages/EventFormPage.tsx index fd969d1..4c9dc70 100644 --- a/admin/src/pages/EventFormPage.tsx +++ b/admin/src/pages/EventFormPage.tsx @@ -365,6 +365,11 @@ export function EventFormPage() { const SUFFIX_ORDER = ["", "+", "++", "+++", "\u2605", "\u2605\u2605", "\u2605\u2605\u2605"]; +/** + * クエストレベル文字列を比較用の [数値部分, サフィックス順序] タプルに変換する。 + * サフィックスは SUFFIX_ORDER の定義順("", "+", "++", ... , "★★★")で数値化する。 + * パターンにマッチしない場合は [0, 0] を返す。 + */ function parseLevelKey(level: string): [number, number] { const m = level.match(/^(\d+)(.*)/); if (!m) return [0, 0]; @@ -374,6 +379,12 @@ function parseLevelKey(level: string): [number, number] { return [num, suffixIdx >= 0 ? suffixIdx : SUFFIX_ORDER.length]; } +/** + * クエストをレベル昇順で並び替える比較関数。Array.sort() に渡して使用する。 + * 数値が同じ場合はサフィックス("+" < "++" < "★" など)の定義順で比較する。 + * @param a 比較対象のクエスト + * @param b 比較対象のクエスト + */ function sortByLevel(a: Quest, b: Quest): number { const [aNum, aSuf] = parseLevelKey(a.level); const [bNum, bSuf] = parseLevelKey(b.level); @@ -381,6 +392,10 @@ function sortByLevel(a: Quest, b: Quest): number { return aSuf - bSuf; } +/** + * ISO 形式の日時文字列を `` の値形式(JST, "YYYY-MM-DDTHH:mm")に変換する。 + * API から取得した期間データをフォームに初期表示する際に使用する。 + */ function toLocalInput(iso: string): string { const d = new Date(iso); const offset = 9 * 60; @@ -388,6 +403,11 @@ function toLocalInput(iso: string): string { return local.toISOString().slice(0, 16); } +/** + * `` の値("YYYY-MM-DDTHH:mm")を + * JST オフセット付きの ISO 形式("YYYY-MM-DDTHH:mm:00+09:00")に変換する。 + * フォームの入力値を API へ送信するペイロードに変換する際に使用する。 + */ function toISO(localInput: string): string { return `${localInput}:00+09:00`; } diff --git a/admin/src/pages/EventListPage.tsx b/admin/src/pages/EventListPage.tsx index 3d2b071..c85bdd8 100644 --- a/admin/src/pages/EventListPage.tsx +++ b/admin/src/pages/EventListPage.tsx @@ -103,6 +103,7 @@ export function EventListPage() { ); } +/** ISO 形式の日時文字列を日本時間のロケール文字列に変換する。 */ function formatDate(iso: string): string { const d = new Date(iso); return d.toLocaleString("ja-JP", { timeZone: "Asia/Tokyo" }); diff --git a/lambda/admin_api/handler.py b/lambda/admin_api/handler.py index eb0c1f1..09821d9 100644 --- a/lambda/admin_api/handler.py +++ b/lambda/admin_api/handler.py @@ -14,6 +14,11 @@ def lambda_handler(event, context): + """管理 API Lambda のエントリーポイント。 + + HTTP メソッドとパスに基づいてルーティングし、対応するハンドラ関数を呼び出す。 + 未定義のルートは 404、ハンドラ内の例外は 500 を返す。 + """ method = event["requestContext"]["http"]["method"] path = event["requestContext"]["http"]["path"] @@ -45,6 +50,7 @@ def lambda_handler(event, context): def read_json(key): + """S3 から指定キーの JSON を読み込む。キーが存在しない場合は None を返す。""" try: obj = s3.get_object(Bucket=BUCKET, Key=key) return json.loads(obj["Body"].read().decode("utf-8")) @@ -55,6 +61,7 @@ def read_json(key): def write_json(key, data): + """data を JSON シリアライズして S3 の指定キーに書き込む。""" s3.put_object( Bucket=BUCKET, Key=key, @@ -67,6 +74,7 @@ def write_json(key, data): def get_events(): + """イベント一覧を返す。events.json が存在しない場合は空のイベントリストを返す。""" data = read_json(EVENTS_KEY) if data is None: data = {"events": []} @@ -74,6 +82,7 @@ def get_events(): def post_event(body): + """イベントを新規作成する。eventId が未指定または空の場合は UUID を自動生成する。""" data = read_json(EVENTS_KEY) if data is None: data = {"events": []} @@ -87,6 +96,7 @@ def post_event(body): def put_event(event_id, body): + """指定 eventId のイベントを更新する。イベントが存在しない場合は 404 を返す。""" data = read_json(EVENTS_KEY) if data is None: return response(404, {"error": "Event not found"}) @@ -102,6 +112,7 @@ def put_event(event_id, body): def delete_event(event_id): + """指定 eventId のイベントを削除する。イベントが存在しない場合は 404 を返す。""" data = read_json(EVENTS_KEY) if data is None: return response(404, {"error": "Event not found"}) @@ -119,6 +130,7 @@ def delete_event(event_id): def get_exclusions(quest_id): + """指定クエストの除外リストを返す。exclusions.json が存在しない場合は空リストを返す。""" data = read_json(EXCLUSIONS_KEY) if data is None: data = {} @@ -127,6 +139,7 @@ def get_exclusions(quest_id): def put_exclusions(quest_id, body): + """指定クエストの除外リストを更新する(全件置き換え)。""" data = read_json(EXCLUSIONS_KEY) if data is None: data = {} @@ -140,6 +153,7 @@ def put_exclusions(quest_id, body): def get_harvest_quests(): + """Harvest API から全クエスト一覧を取得してそのまま返す(プロキシ)。""" with urlopen(HARVEST_ALL_URL) as resp: data = json.loads(resp.read().decode("utf-8")) return response(200, data) @@ -149,6 +163,7 @@ def get_harvest_quests(): def response(status_code, body): + """API Gateway (HTTP API) 互換のレスポンス辞書を生成する。""" return { "statusCode": status_code, "headers": {"Content-Type": "application/json"}, diff --git a/lambda/aggregator/handler.py b/lambda/aggregator/handler.py index 5199f8d..665afaa 100644 --- a/lambda/aggregator/handler.py +++ b/lambda/aggregator/handler.py @@ -22,6 +22,7 @@ def read_json(key: str) -> dict | list | None: + """S3 から指定キーの JSON を読み込む。キーが存在しない場合は None を返す。""" try: obj = s3.get_object(Bucket=BUCKET, Key=key) return json.loads(obj["Body"].read().decode("utf-8")) @@ -32,6 +33,7 @@ def read_json(key: str) -> dict | list | None: def write_json(key: str, data: dict | list) -> None: + """data を JSON シリアライズして S3 の指定キーに書き込む。""" s3.put_object( Bucket=BUCKET, Key=key, @@ -204,6 +206,15 @@ def process_quest(event_id: str, quest: dict, event_items: set[str]) -> None: def lambda_handler(event: Any, context: Any) -> dict[str, int]: + """集計 Lambda のエントリーポイント。 + + events.json を読み込み、現在時刻にアクティブなイベントのクエストを順に処理する。 + イベント終了直前の報告や Harvest への反映遅延を考慮し、 + 終了後5時間はグレースピリオドとして集計を継続する。 + + Returns: + {"processed": <処理したクエスト数>} + """ data = read_json(EVENTS_KEY) if data is None: logger.info("No events.json found, exiting") diff --git a/viewer/src/aggregate.ts b/viewer/src/aggregate.ts index 5c53aee..2a1c9cc 100644 --- a/viewer/src/aggregate.ts +++ b/viewer/src/aggregate.ts @@ -3,6 +3,12 @@ import type { Exclusion, ItemOutlierStats, ItemStats, Report } from "./types"; const Z = 1.96; // 95% confidence +/** + * Wilson スコア法による二項比率の 95% 信頼区間を計算する。 + * @param successes ドロップ数(成功回数) + * @param n 試行回数(周回数) + * @returns 信頼区間の下限・上限(0〜1) + */ function wilsonCI(successes: number, n: number): { lower: number; upper: number } { if (n === 0) return { lower: 0, upper: 0 }; const p = successes / n; @@ -16,10 +22,21 @@ function wilsonCI(successes: number, n: number): { lower: number; upper: number }; } +/** + * 除外リストから reportId の Set を作成する。 + * 集計・外れ値計算の前処理として有効報告のフィルタリングに使用する。 + */ export function createExcludedIdSet(exclusions: Exclusion[]): Set { return new Set(exclusions.map((e) => e.reportId)); } +/** + * 有効な報告を集計し、アイテムごとのドロップ数・ドロップ率・Wilson 95% 信頼区間を計算する。 + * exclusions に含まれる報告は集計から除外される。 + * @param reports クエストの全報告リスト + * @param exclusions 除外対象の報告リスト + * @returns アイテムごとの集計結果(ItemStats)の配列 + */ export function aggregate(reports: Report[], exclusions: Exclusion[]): ItemStats[] { const excludedIds = createExcludedIdSet(exclusions); const validReports = reports.filter((r) => !excludedIds.has(r.id)); @@ -57,10 +74,21 @@ export function aggregate(reports: Report[], exclusions: Exclusion[]): ItemStats return stats; } +/** + * アイテムが外れ値検出の常時対象かどうかを判定する。 + * イベントアイテム・ポイント・QP は低ドロップ率でも外れ値チェックを行うため、true を返す。 + */ function isAlwaysTargetItem(itemName: string): boolean { return RE_EVENT_ITEM.test(itemName) || RE_POINT.test(itemName) || RE_QP.test(itemName); } +/** + * アイテムごとに「1周あたりドロップ数」の平均・標準偏差を計算する。 + * isOutlier() による外れ値判定の基準値として使用する。 + * @param reports クエストの全報告リスト + * @param exclusions 除外対象の報告リスト + * @returns アイテムごとの外れ値統計(ItemOutlierStats)の配列 + */ export function calcOutlierStats(reports: Report[], exclusions: Exclusion[]): ItemOutlierStats[] { const excludedIds = createExcludedIdSet(exclusions); const validReports = reports.filter((r) => !excludedIds.has(r.id)); @@ -102,6 +130,22 @@ const MIN_SAMPLE_COUNT = 5; const MIN_RUNCOUNT = 20; const MIN_DROP_RATE_FOR_NORMAL = 0.2; +/** + * 報告の値が外れ値かどうかを z スコアで判定する。 + * + * 以下のいずれかに該当する場合は外れ値チェックをスキップして null を返す: + * - value が null(そのアイテムの報告が存在しない) + * - 周回数が少ない(MIN_RUNCOUNT 未満) + * - サンプル数が少ない(MIN_SAMPLE_COUNT 未満) + * - 標準偏差がほぼゼロ(全報告が同一値) + * - 通常アイテムかつドロップ率が低い(MIN_DROP_RATE_FOR_NORMAL 未満) + * + * @param value 報告のアイテムドロップ数 + * @param runcount 報告の周回数 + * @param outlierStats そのアイテムの全体統計(平均・標準偏差) + * @param dropRate そのアイテムの全体ドロップ率 + * @returns 外れ値の場合は z スコア、外れ値でないまたはスキップの場合は null + */ export function isOutlier( value: number | null, runcount: number, diff --git a/viewer/src/api.ts b/viewer/src/api.ts index ee049ed..07c44a6 100644 --- a/viewer/src/api.ts +++ b/viewer/src/api.ts @@ -1,23 +1,40 @@ import type { EventsResponse, ExclusionsMap, QuestData } from "./types"; +/** + * 環境変数 VITE_DATA_URL からデータ取得先のベース URL を返す。 + * 未設定の場合は例外をスローする。 + */ function getDataUrl(): string { const url = import.meta.env.VITE_DATA_URL; if (!url) throw new Error("VITE_DATA_URL is not set"); return url; } +/** events.json を取得してイベント一覧を返す。 */ export async function fetchEvents(signal?: AbortSignal): Promise { const res = await fetch(`${getDataUrl()}/events.json`, { signal }); if (!res.ok) throw new Error(`Failed to fetch events: ${res.status}`); return res.json(); } +/** + * exclusions.json を取得して除外リストを返す。 + * ファイルが存在しない場合(403/404 等)は空オブジェクトを返す。 + */ export async function fetchExclusions(signal?: AbortSignal): Promise { const res = await fetch(`${getDataUrl()}/exclusions.json`, { signal }); if (!res.ok) return {}; return res.json(); } +/** + * 指定クエストの集計 JSON を取得する。 + * S3 + CloudFront 構成では未作成オブジェクトに 403 が返るため、 + * 403/404 はデータ未登録として null を返す(エラーにしない)。 + * @param eventId イベント ID(JSON パスの一部) + * @param questId クエスト ID(JSON パスの一部) + * @param signal フェッチのキャンセル用シグナル + */ export async function fetchQuestData( eventId: string, questId: string, diff --git a/viewer/src/components/tableUtils.ts b/viewer/src/components/tableUtils.ts index 19a410d..be8b779 100644 --- a/viewer/src/components/tableUtils.ts +++ b/viewer/src/components/tableUtils.ts @@ -1,5 +1,13 @@ import type React from "react"; +/** + * ソート状態に応じてカラムヘッダに表示するインジケータ文字列を返す。 + * - アクティブ昇順: " ▲" + * - アクティブ降順: " ▼" + * - 非アクティブ: " △" + * @param sort 現在のソート状態(null はソートなし) + * @param key このカラムのソートキー + */ export function sortIndicator( sort: { key: string; dir: "asc" | "desc" } | null, key: string, diff --git a/viewer/src/formatters.tsx b/viewer/src/formatters.tsx index 26bab7b..7e7928f 100644 --- a/viewer/src/formatters.tsx +++ b/viewer/src/formatters.tsx @@ -35,6 +35,7 @@ export function formatItemHeader(name: string): ReactNode { ); } +/** ISO 形式の日時文字列を日本時間の「年/月/日 時:分」表記に変換する。 */ export function formatDateTime(iso: string): string { const d = new Date(iso); return d.toLocaleString("ja-JP", { @@ -47,10 +48,12 @@ export function formatDateTime(iso: string): string { }); } +/** ISO 形式の日時文字列を日本時間のロケール文字列(秒まで)に変換する。 */ export function formatTimestamp(iso: string): string { return new Date(iso).toLocaleString("ja-JP", { timeZone: "Asia/Tokyo" }); } +/** イベント期間を「開始日時 〜 終了日時」形式の文字列に変換する。 */ export function formatPeriod(period: EventPeriod): string { return `${formatDateTime(period.start)} 〜 ${formatDateTime(period.end)}`; } diff --git a/viewer/src/reportTableUtils.ts b/viewer/src/reportTableUtils.ts index 7e513ce..284c9f0 100644 --- a/viewer/src/reportTableUtils.ts +++ b/viewer/src/reportTableUtils.ts @@ -3,10 +3,20 @@ import type { Report, SortDir } from "./types"; export type SortKey = "reporter" | "runcount" | "timestamp"; export type SortState = { key: SortKey; dir: SortDir } | null; +/** + * 報告者名を返す。reporterName → reporter → "匿名" の優先順で解決する。 + * reporterName は表示名、reporter は X の ID(@なし)を格納するフィールド。 + */ export function getReporterName(r: Report): string { return r.reporterName || r.reporter || "匿名"; } +/** + * ソートキー・方向に従って報告リストを並び替える。 + * sort が null の場合は元の順序のまま返す。 + * @param reports 並び替え対象の報告リスト + * @param sort ソートキーと方向、または null(ソートなし) + */ export function sortReports(reports: Report[], sort: SortState): Report[] { if (!sort) return reports; const { key, dir } = sort; diff --git a/viewer/src/reporterSummaryUtils.ts b/viewer/src/reporterSummaryUtils.ts index 0493372..16c85b7 100644 --- a/viewer/src/reporterSummaryUtils.ts +++ b/viewer/src/reporterSummaryUtils.ts @@ -22,6 +22,15 @@ export type SortState = { key: SortKey; dir: SortDir }; export const DEFAULT_SORT: SortState = { key: "totalRuns", dir: "desc" }; +/** + * 全クエストデータから報告者ごとの集計行を作成する。 + * 各クエストの除外リストに含まれる報告はスキップする。 + * 同一報告者(reporterName 優先、なければ reporter)の報告をまとめ、 + * 報告回数・合計周回数・明細リストを集計する。 + * @param allQuestData 全クエストのデータ(クエスト情報 + 報告リスト) + * @param exclusions クエスト ID をキーとする除外リストのマップ + * @returns 報告者ごとの集計行(ReporterRow)の配列 + */ export function aggregateReporters( allQuestData: QuestData[], exclusions: ExclusionsMap, @@ -58,6 +67,11 @@ export function aggregateReporters( return [...map.values()]; } +/** + * ソートキー・方向に従って報告者行を並び替える。 + * @param rows 並び替え対象の報告者行リスト + * @param sort ソートキー("reportCount" または "totalRuns")と方向 + */ export function sortRows(rows: ReporterRow[], sort: SortState): ReporterRow[] { return [...rows].sort((a, b) => { const cmp = a[sort.key] - b[sort.key]; diff --git a/viewer/src/routeUtils.ts b/viewer/src/routeUtils.ts index 6e2a703..8827af5 100644 --- a/viewer/src/routeUtils.ts +++ b/viewer/src/routeUtils.ts @@ -1,16 +1,30 @@ import type { EventData, Quest } from "./types"; +/** + * イベントリストから開催開始日時が最新のイベントを返す。 + * リストが空の場合は undefined を返す。 + */ export function getLatestEvent(events: EventData[]): EventData | undefined { return [...events].sort( (a, b) => new Date(b.period.start).getTime() - new Date(a.period.start).getTime(), )[0]; } +/** + * クエストレベルの文字列を比較可能な数値に変換する。 + * "+" サフィックスは 0.5 を加算することで上位扱いにする。 + * 例: "90" → 90、"90+" → 90.5 + */ export function parseLevel(level: string): number { const base = Number.parseInt(level, 10); return level.endsWith("+") ? base + 0.5 : base; } +/** + * クエストリストからレベルが最も高いクエストを返す。 + * レベル比較には parseLevel() を使用する。 + * リストが空の場合は undefined を返す。 + */ export function getHighestQuest(quests: Quest[]): Quest | undefined { if (quests.length === 0) return undefined; const sorted = [...quests].sort((a, b) => parseLevel(a.level) - parseLevel(b.level)); diff --git a/viewer/src/summaryUtils.ts b/viewer/src/summaryUtils.ts index 4883b6a..8cbac61 100644 --- a/viewer/src/summaryUtils.ts +++ b/viewer/src/summaryUtils.ts @@ -1,6 +1,12 @@ import { RE_EVENT_ITEM, RE_POINT, RE_QP } from "./constants"; import type { ItemStats } from "./types"; +/** + * アイテム統計リストを種別ごとに分類する。 + * アイテム名のパターン(RE_EVENT_ITEM / RE_POINT / RE_QP)で判定し、 + * 該当しないものは通常素材(normal)に分類する。 + * @returns `{ normal, eventItems, points, qp }` の各配列 + */ export function classifyStats(stats: ItemStats[]) { const normal: ItemStats[] = []; const eventItems: ItemStats[] = []; @@ -22,6 +28,13 @@ export function classifyStats(stats: ItemStats[]) { return { normal, eventItems, points, qp }; } +/** + * アイテム名から修飾子部分を除いたベース名を返す。 + * - "ミトン(x3)" → "ミトン" + * - "ポイント(+600)" → "ポイント" + * - "QP(+150000)" → "QP" + * - 修飾子なし → そのまま返す + */ export function extractBaseName(name: string): string { const mBox = RE_EVENT_ITEM.exec(name); if (mBox) return name.slice(0, mBox.index); @@ -30,6 +43,13 @@ export function extractBaseName(name: string): string { return name; } +/** + * アイテム名から修飾子の数値を取り出す。 + * - "ミトン(x3)" → 3(個数) + * - "ポイント(+600)" → 600(加算量) + * - "QP(+150000)" → 150000(加算量) + * - 修飾子なし → 0 + */ export function extractModifier(name: string): number { const mBox = RE_EVENT_ITEM.exec(name); if (mBox) return Number.parseInt(mBox[1], 10); @@ -40,6 +60,10 @@ export function extractModifier(name: string): number { return 0; } +/** + * アイテムリストをベース名の昇順、同名の場合は修飾子の数値昇順で並び替える。 + * 例: "ミトン(x1)", "ミトン(x3)", "帽子(x2)" の順になる。 + */ export function sortByBaseAndModifier(items: ItemStats[]): ItemStats[] { return [...items].sort((a, b) => { const baseA = extractBaseName(a.itemName); @@ -58,6 +82,17 @@ export interface EventItemExpected { base: number; } +/** + * イベントアイテムの統計リストから、baseName ごとに 1周あたり枠数・期待値を集計する。 + * + * 同一 baseName を持つ複数キー(例: "ミトン(x1)" と "ミトン(x3)")を合算する。 + * - `slots`: 1周あたりの平均枠数(baseName 全体) + * - `base`: 1周あたりの期待獲得数(枠数 × 個数の合計) + * - `totalSlots`: 全報告の合計ドロップ枠数 + * - `totalRuns`: 合算に用いた最大周回数(キーごとのずれを MAX で吸収) + * + * @param eventItems `classifyStats()` で分類されたイベントアイテムの統計リスト + */ export function calcEventItemExpected(eventItems: ItemStats[]): EventItemExpected[] { const grouped = new Map< string,