diff --git a/Makefile b/Makefile index 8d0e3a1..37106db 100644 --- a/Makefile +++ b/Makefile @@ -5,3 +5,7 @@ fmt: .PHONY: lint lint: ruff check lambda/ + +.PHONY: gen-priority-file +gen-priority-file: + ./gen_item_list_priority.py -i ../fgoscdata/hash_drop.json -o viewer/src/data/item_list_priority.json diff --git a/SPEC.md b/SPEC.md index e20af68..3f3be4a 100644 --- a/SPEC.md +++ b/SPEC.md @@ -217,6 +217,38 @@ FGO イベント報告データを集計し、アイテムドロップ率を算 - `合計周回数 = 有効な報告の runcount の総和` (アイテムごとに異なりうる。NaN 報告を除くため) - `ドロップ率 = 合計ドロップ数 / 合計周回数` +### 5.4 アイテム表示優先度 (`item_list_priority.json`) + +`viewer/src/data/item_list_priority.json` に配置され、viewer が TypeScript import でビルド時バンドルする。`gen_item_list_priority.py`(`make gen-priority-file`)で生成する。 + +各エントリのフィールド: + +| フィールド | 型 | 説明 | +|---|---|---| +| `id` | number | アイテム ID | +| `rarity` | number | レアリティ | +| `shortname` | string | アイテム名(報告 JSON の `items` キーと完全一致) | +| `dropPriority` | number | 表示優先度(大きいほど上位) | + +#### 表示順序 + +`dropPriority` 降順 → 同値の場合は `id` 降順でソートする。 + +#### フィルタリング(集計テーブルへの適用) + +以下をすべて満たすアイテムは集計テーブル(素材・イベントアイテム・ポイント・QP)の表示対象にならない: + +1. このリストに `shortname` が存在しない +2. 添字つきイベントアイテム `(xN)` でない +3. ポイント `ポイント(+N)` でない +4. QP `QP(+N)` でない + +報告一覧のカラムはこのフィルタの影響を受けない(未知アイテムもカラムとして表示される)。 + +#### 報告者サマリ アコーディオン明細 + +未知アイテム(上記フィルタ対象)は既知アイテムの後ろに順不同で末尾表示する。 + ## 6. 中間 JSON 出力フォーマット 集計 Lambda が S3 に出力する。クエストごとに 1ファイル。 diff --git a/gen_item_list_priority.py b/gen_item_list_priority.py new file mode 100755 index 0000000..e519a9a --- /dev/null +++ b/gen_item_list_priority.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 + +import argparse +import json +import logging +import sys + +logger = logging.getLogger(__name__) + + +def make_item_dict(item: dict) -> dict: + return { + "id": item["id"], + "rarity": item.get("rarity", 0), + "shortname": item["shortname"], + "dropPriority": item["dropPriority"], + } + + +def main(args: argparse.Namespace): + items = json.load(args.input) + filtered_items = [] + + # 特攻礼装 9005 + # ボーナス礼装 9005 + # 泥礼装 9005 + # 礼装 9005 + # ★4EXP礼装 9004 + # ★3EXP礼装 9003 + ce_cache: dict[str, dict] = {} + + for item in items: + if "shortname" not in item: + continue + item_id = item["id"] + if item_id < 10_000_000: + # 礼装のデータは1つだけあればよいので、同一の shortname であれば id が大きい方を優先する + if item["shortname"] in ["特攻礼装", "ボーナス礼装", "泥礼装", "礼装", "★4EXP礼装", "★3EXP礼装"]: + if item["shortname"] in ce_cache: + cached_item = ce_cache[item["shortname"]] + if item["id"] > cached_item["id"]: + ce_cache[item["shortname"]] = item + else: + pass + else: + ce_cache[item["shortname"]] = item + + # filtered_items への追加は最後にまとめて行うので、ここではスキップ + continue + + # 種火はレアリティ 4, 5 のみを対象とする + if item["type"] == "Exp. UP" and item["rarity"] < 4: + continue + + # 上記規則に当てはまらない礼装は無視 + if item["type"] == "Craft Essence": + logger.warning(f"Unknown Craft Essence: {item['name']} (id: {item['id']})") + continue + + filtered_items.append(make_item_dict(item)) + + # 礼装のデータを filtered_items に追加 + for item in ce_cache.values(): + filtered_items.append(make_item_dict(item)) + + sorted_items = sorted(filtered_items, key=lambda x: x["dropPriority"], reverse=True) + + if args.output_format == "tsv": + print("id\trarity\tshortname\tdropPriority", file=args.output) + for item in sorted_items: + print(f"{item['id']}\t{item['rarity']}\t{item['shortname']}\t{item['dropPriority']}", file=args.output) + else: + print(json.dumps(sorted_items, indent=2, ensure_ascii=False), file=args.output) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument("--input", "-i", type=argparse.FileType("r"), default=sys.stdin) + parser.add_argument("--output", "-o", type=argparse.FileType("w"), default=sys.stdout) + parser.add_argument("--output-format", "-f", choices=["json", "tsv"], default="json") + return parser.parse_args() + + +if __name__ == "__main__": + args = parse_args() + main(args) diff --git a/viewer/src/components/ReporterSummary.tsx b/viewer/src/components/ReporterSummary.tsx index db5d14b..70ac38a 100644 --- a/viewer/src/components/ReporterSummary.tsx +++ b/viewer/src/components/ReporterSummary.tsx @@ -4,6 +4,7 @@ import { formatTimestamp } from "../formatters"; import { useFetchData } from "../hooks/useFetchData"; import { useFixedSortState } from "../hooks/useSortState"; import { useToggleSet } from "../hooks/useToggleSet"; +import { sortItemNames } from "../itemPriority"; import type { ReportDetail, SortKey } from "../reporterSummaryUtils"; import { aggregateReporters, DEFAULT_SORT, sortRows } from "../reporterSummaryUtils"; import type { ExclusionsMap, Quest, QuestData } from "../types"; @@ -51,7 +52,7 @@ function DetailTable({ details }: { details: ReportDetail[] }) { itemNames.add(key); } } - const itemCols = [...itemNames]; + const itemCols = sortItemNames([...itemNames]); return ( diff --git a/viewer/src/components/SummaryTable.tsx b/viewer/src/components/SummaryTable.tsx index 858d0bb..aa8a67a 100644 --- a/viewer/src/components/SummaryTable.tsx +++ b/viewer/src/components/SummaryTable.tsx @@ -1,4 +1,5 @@ import { MAX_EVENT_BONUS } from "../constants"; +import { compareByDropPriority } from "../itemPriority"; import { calcEventItemExpected, classifyStats, @@ -107,7 +108,8 @@ function BonusTable({ title, items }: { title: string; items: ItemStats[] }) { export function SummaryTable({ stats }: Props) { if (stats.length === 0) return

データなし

; - const { normal, eventItems, points, qp } = classifyStats(stats); + const { normal: rawNormal, eventItems, points, qp } = classifyStats(stats); + const normal = [...rawNormal].sort((a, b) => compareByDropPriority(a.itemName, b.itemName)); return ( <> diff --git a/viewer/src/data/item_list_priority.json b/viewer/src/data/item_list_priority.json new file mode 100644 index 0000000..fa93742 --- /dev/null +++ b/viewer/src/data/item_list_priority.json @@ -0,0 +1,866 @@ +[ + { + "id": 5, + "rarity": 0, + "shortname": "報酬QP", + "dropPriority": 9018 + }, + { + "id": 100, + "rarity": 3, + "shortname": "金林檎", + "dropPriority": 9014 + }, + { + "id": 2109, + "rarity": 3, + "shortname": "EX2足跡", + "dropPriority": 9014 + }, + { + "id": 2108, + "rarity": 3, + "shortname": "EX1足跡", + "dropPriority": 9013 + }, + { + "id": 101, + "rarity": 2, + "shortname": "銀林檎", + "dropPriority": 9013 + }, + { + "id": 2107, + "rarity": 3, + "shortname": "狂足跡", + "dropPriority": 9012 + }, + { + "id": 104, + "rarity": 2, + "shortname": "青林檎", + "dropPriority": 9012 + }, + { + "id": 2106, + "rarity": 3, + "shortname": "殺足跡", + "dropPriority": 9011 + }, + { + "id": 102, + "rarity": 1, + "shortname": "銅林檎", + "dropPriority": 9011 + }, + { + "id": 2105, + "rarity": 3, + "shortname": "術足跡", + "dropPriority": 9010 + }, + { + "id": 2104, + "rarity": 3, + "shortname": "騎足跡", + "dropPriority": 9009 + }, + { + "id": 2103, + "rarity": 3, + "shortname": "槍足跡", + "dropPriority": 9008 + }, + { + "id": 2102, + "rarity": 3, + "shortname": "弓足跡", + "dropPriority": 9007 + }, + { + "id": 2101, + "rarity": 3, + "shortname": "剣足跡", + "dropPriority": 9006 + }, + { + "id": 2000, + "rarity": 3, + "shortname": "足跡", + "dropPriority": 9005 + }, + { + "id": 9409200, + "rarity": 5, + "shortname": "礼装", + "dropPriority": 9005 + }, + { + "id": 9408780, + "rarity": 5, + "shortname": "特攻礼装", + "dropPriority": 9005 + }, + { + "id": 9405310, + "rarity": 5, + "shortname": "ボーナス礼装", + "dropPriority": 9005 + }, + { + "id": 9408790, + "rarity": 5, + "shortname": "泥礼装", + "dropPriority": 9005 + }, + { + "id": 9809570, + "rarity": 4, + "shortname": "★4EXP礼装", + "dropPriority": 9004 + }, + { + "id": 9809580, + "rarity": 3, + "shortname": "★3EXP礼装", + "dropPriority": 9003 + }, + { + "id": 6563, + "rarity": 3, + "shortname": "箱", + "dropPriority": 8514 + }, + { + "id": 6562, + "rarity": 3, + "shortname": "聖水", + "dropPriority": 8513 + }, + { + "id": 6560, + "rarity": 3, + "shortname": "月光", + "dropPriority": 8511 + }, + { + "id": 6558, + "rarity": 3, + "shortname": "釜", + "dropPriority": 8510 + }, + { + "id": 6548, + "rarity": 3, + "shortname": "鬼灯", + "dropPriority": 8509 + }, + { + "id": 6544, + "rarity": 3, + "shortname": "カケラ", + "dropPriority": 8508 + }, + { + "id": 6542, + "rarity": 3, + "shortname": "卵", + "dropPriority": 8507 + }, + { + "id": 6540, + "rarity": 3, + "shortname": "鏡", + "dropPriority": 8506 + }, + { + "id": 6531, + "rarity": 3, + "shortname": "神酒", + "dropPriority": 8505 + }, + { + "id": 6529, + "rarity": 3, + "shortname": "胆石", + "dropPriority": 8504 + }, + { + "id": 6525, + "rarity": 3, + "shortname": "スカラベ", + "dropPriority": 8503 + }, + { + "id": 6518, + "rarity": 3, + "shortname": "根", + "dropPriority": 8502 + }, + { + "id": 6506, + "rarity": 3, + "shortname": "逆鱗", + "dropPriority": 8501 + }, + { + "id": 6517, + "rarity": 3, + "shortname": "心臓", + "dropPriority": 8500 + }, + { + "id": 6546, + "rarity": 3, + "shortname": "実", + "dropPriority": 8407 + }, + { + "id": 6539, + "rarity": 3, + "shortname": "炉心", + "dropPriority": 8406 + }, + { + "id": 6528, + "rarity": 3, + "shortname": "産毛", + "dropPriority": 8405 + }, + { + "id": 6523, + "rarity": 3, + "shortname": "ランプ", + "dropPriority": 8404 + }, + { + "id": 6521, + "rarity": 3, + "shortname": "脂", + "dropPriority": 8403 + }, + { + "id": 6520, + "rarity": 3, + "shortname": "涙石", + "dropPriority": 8402 + }, + { + "id": 6519, + "rarity": 3, + "shortname": "幼角", + "dropPriority": 8401 + }, + { + "id": 6507, + "rarity": 3, + "shortname": "爪", + "dropPriority": 8400 + }, + { + "id": 6561, + "rarity": 2, + "shortname": "レンズ", + "dropPriority": 8324 + }, + { + "id": 6559, + "rarity": 2, + "shortname": "キューブ", + "dropPriority": 8323 + }, + { + "id": 6557, + "rarity": 2, + "shortname": "花", + "dropPriority": 8322 + }, + { + "id": 6556, + "rarity": 2, + "shortname": "エーテル", + "dropPriority": 8321 + }, + { + "id": 6553, + "rarity": 2, + "shortname": "皮", + "dropPriority": 8320 + }, + { + "id": 6550, + "rarity": 2, + "shortname": "鱗粉", + "dropPriority": 8319 + }, + { + "id": 6547, + "rarity": 2, + "shortname": "糸玉", + "dropPriority": 8318 + }, + { + "id": 6545, + "rarity": 2, + "shortname": "霊子", + "dropPriority": 8317 + }, + { + "id": 6543, + "rarity": 2, + "shortname": "冠", + "dropPriority": 8316 + }, + { + "id": 6541, + "rarity": 2, + "shortname": "矢尻", + "dropPriority": 8315 + }, + { + "id": 6538, + "rarity": 2, + "shortname": "鈴", + "dropPriority": 8314 + }, + { + "id": 6536, + "rarity": 2, + "shortname": "オーロラ", + "dropPriority": 8313 + }, + { + "id": 6537, + "rarity": 2, + "shortname": "指輪", + "dropPriority": 8312 + }, + { + "id": 6535, + "rarity": 2, + "shortname": "結氷", + "dropPriority": 8311 + }, + { + "id": 6532, + "rarity": 2, + "shortname": "勾玉", + "dropPriority": 8310 + }, + { + "id": 6524, + "rarity": 2, + "shortname": "勲章", + "dropPriority": 8309 + }, + { + "id": 6526, + "rarity": 2, + "shortname": "貝殻", + "dropPriority": 8308 + }, + { + "id": 6509, + "rarity": 2, + "shortname": "蛇玉", + "dropPriority": 8307 + }, + { + "id": 6501, + "rarity": 2, + "shortname": "羽根", + "dropPriority": 8306 + }, + { + "id": 6513, + "rarity": 2, + "shortname": "蹄鉄", + "dropPriority": 8305 + }, + { + "id": 6514, + "rarity": 2, + "shortname": "ホム", + "dropPriority": 8304 + }, + { + "id": 6511, + "rarity": 2, + "shortname": "頁", + "dropPriority": 8303 + }, + { + "id": 6510, + "rarity": 2, + "shortname": "歯車", + "dropPriority": 8302 + }, + { + "id": 6515, + "rarity": 2, + "shortname": "八連", + "dropPriority": 8301 + }, + { + "id": 6508, + "rarity": 2, + "shortname": "ランタン", + "dropPriority": 8300 + }, + { + "id": 6502, + "rarity": 2, + "shortname": "種", + "dropPriority": 8203 + }, + { + "id": 6527, + "rarity": 1, + "shortname": "毒針", + "dropPriority": 8202 + }, + { + "id": 6505, + "rarity": 1, + "shortname": "塵", + "dropPriority": 8201 + }, + { + "id": 6512, + "rarity": 1, + "shortname": "牙", + "dropPriority": 8200 + }, + { + "id": 6555, + "rarity": 1, + "shortname": "残滓", + "dropPriority": 8110 + }, + { + "id": 6554, + "rarity": 1, + "shortname": "刃", + "dropPriority": 8109 + }, + { + "id": 6552, + "rarity": 1, + "shortname": "灰", + "dropPriority": 8108 + }, + { + "id": 6551, + "rarity": 1, + "shortname": "剣", + "dropPriority": 8107 + }, + { + "id": 6549, + "rarity": 1, + "shortname": "小鐘", + "dropPriority": 8106 + }, + { + "id": 6534, + "rarity": 1, + "shortname": "火薬", + "dropPriority": 8105 + }, + { + "id": 6533, + "rarity": 1, + "shortname": "鉄杭", + "dropPriority": 8104 + }, + { + "id": 6530, + "rarity": 1, + "shortname": "髄液", + "dropPriority": 8103 + }, + { + "id": 6522, + "rarity": 1, + "shortname": "鎖", + "dropPriority": 8102 + }, + { + "id": 6516, + "rarity": 1, + "shortname": "骨", + "dropPriority": 8101 + }, + { + "id": 6503, + "rarity": 1, + "shortname": "証", + "dropPriority": 8100 + }, + { + "id": 63, + "rarity": 3, + "shortname": "EX2結晶", + "dropPriority": 8008 + }, + { + "id": 62, + "rarity": 3, + "shortname": "EX1結晶", + "dropPriority": 8007 + }, + { + "id": 61, + "rarity": 3, + "shortname": "狂結晶", + "dropPriority": 8006 + }, + { + "id": 60, + "rarity": 3, + "shortname": "殺結晶", + "dropPriority": 8005 + }, + { + "id": 59, + "rarity": 3, + "shortname": "術結晶", + "dropPriority": 8004 + }, + { + "id": 58, + "rarity": 3, + "shortname": "騎結晶", + "dropPriority": 8003 + }, + { + "id": 57, + "rarity": 3, + "shortname": "槍結晶", + "dropPriority": 8002 + }, + { + "id": 56, + "rarity": 3, + "shortname": "弓結晶", + "dropPriority": 8001 + }, + { + "id": 55, + "rarity": 3, + "shortname": "剣結晶", + "dropPriority": 8000 + }, + { + "id": 6201, + "rarity": 3, + "shortname": "剣秘", + "dropPriority": 6300 + }, + { + "id": 6202, + "rarity": 3, + "shortname": "弓秘", + "dropPriority": 6299 + }, + { + "id": 6203, + "rarity": 3, + "shortname": "槍秘", + "dropPriority": 6298 + }, + { + "id": 6204, + "rarity": 3, + "shortname": "騎秘", + "dropPriority": 6297 + }, + { + "id": 6205, + "rarity": 3, + "shortname": "術秘", + "dropPriority": 6296 + }, + { + "id": 6206, + "rarity": 3, + "shortname": "殺秘", + "dropPriority": 6295 + }, + { + "id": 6207, + "rarity": 3, + "shortname": "狂秘", + "dropPriority": 6294 + }, + { + "id": 6101, + "rarity": 2, + "shortname": "剣魔", + "dropPriority": 6200 + }, + { + "id": 6102, + "rarity": 2, + "shortname": "弓魔", + "dropPriority": 6199 + }, + { + "id": 6103, + "rarity": 2, + "shortname": "槍魔", + "dropPriority": 6198 + }, + { + "id": 6104, + "rarity": 2, + "shortname": "騎魔", + "dropPriority": 6197 + }, + { + "id": 6105, + "rarity": 2, + "shortname": "術魔", + "dropPriority": 6196 + }, + { + "id": 6106, + "rarity": 2, + "shortname": "殺魔", + "dropPriority": 6195 + }, + { + "id": 6107, + "rarity": 2, + "shortname": "狂魔", + "dropPriority": 6194 + }, + { + "id": 6001, + "rarity": 1, + "shortname": "剣輝", + "dropPriority": 6100 + }, + { + "id": 6002, + "rarity": 1, + "shortname": "弓輝", + "dropPriority": 6099 + }, + { + "id": 6003, + "rarity": 1, + "shortname": "槍輝", + "dropPriority": 6098 + }, + { + "id": 6004, + "rarity": 1, + "shortname": "騎輝", + "dropPriority": 6097 + }, + { + "id": 6005, + "rarity": 1, + "shortname": "術輝", + "dropPriority": 6096 + }, + { + "id": 6006, + "rarity": 1, + "shortname": "殺輝", + "dropPriority": 6095 + }, + { + "id": 6007, + "rarity": 1, + "shortname": "狂輝", + "dropPriority": 6094 + }, + { + "id": 7101, + "rarity": 3, + "shortname": "剣モ", + "dropPriority": 5300 + }, + { + "id": 7102, + "rarity": 3, + "shortname": "弓モ", + "dropPriority": 5299 + }, + { + "id": 7103, + "rarity": 3, + "shortname": "槍モ", + "dropPriority": 5298 + }, + { + "id": 7104, + "rarity": 3, + "shortname": "騎モ", + "dropPriority": 5297 + }, + { + "id": 7105, + "rarity": 3, + "shortname": "術モ", + "dropPriority": 5296 + }, + { + "id": 7106, + "rarity": 3, + "shortname": "殺モ", + "dropPriority": 5295 + }, + { + "id": 7107, + "rarity": 3, + "shortname": "狂モ", + "dropPriority": 5294 + }, + { + "id": 7001, + "rarity": 2, + "shortname": "剣ピ", + "dropPriority": 5200 + }, + { + "id": 7002, + "rarity": 2, + "shortname": "弓ピ", + "dropPriority": 5199 + }, + { + "id": 7003, + "rarity": 2, + "shortname": "槍ピ", + "dropPriority": 5198 + }, + { + "id": 7004, + "rarity": 2, + "shortname": "騎ピ", + "dropPriority": 5197 + }, + { + "id": 7005, + "rarity": 2, + "shortname": "術ピ", + "dropPriority": 5196 + }, + { + "id": 7006, + "rarity": 2, + "shortname": "殺ピ", + "dropPriority": 5195 + }, + { + "id": 7007, + "rarity": 2, + "shortname": "狂ピ", + "dropPriority": 5194 + }, + { + "id": 9700400, + "rarity": 4, + "shortname": "全猛火", + "dropPriority": 696 + }, + { + "id": 9700500, + "rarity": 5, + "shortname": "全業火", + "dropPriority": 695 + }, + { + "id": 9701400, + "rarity": 4, + "shortname": "剣猛火", + "dropPriority": 691 + }, + { + "id": 9701500, + "rarity": 5, + "shortname": "剣業火", + "dropPriority": 690 + }, + { + "id": 9702400, + "rarity": 4, + "shortname": "槍猛火", + "dropPriority": 686 + }, + { + "id": 9702500, + "rarity": 5, + "shortname": "槍業火", + "dropPriority": 685 + }, + { + "id": 9703400, + "rarity": 4, + "shortname": "弓猛火", + "dropPriority": 681 + }, + { + "id": 9703500, + "rarity": 5, + "shortname": "弓業火", + "dropPriority": 680 + }, + { + "id": 9704400, + "rarity": 4, + "shortname": "騎猛火", + "dropPriority": 676 + }, + { + "id": 9704500, + "rarity": 5, + "shortname": "騎業火", + "dropPriority": 675 + }, + { + "id": 9705400, + "rarity": 4, + "shortname": "術猛火", + "dropPriority": 671 + }, + { + "id": 9705500, + "rarity": 5, + "shortname": "術業火", + "dropPriority": 670 + }, + { + "id": 9706400, + "rarity": 4, + "shortname": "殺猛火", + "dropPriority": 666 + }, + { + "id": 9706500, + "rarity": 5, + "shortname": "殺業火", + "dropPriority": 665 + }, + { + "id": 9707400, + "rarity": 4, + "shortname": "狂猛火", + "dropPriority": 661 + }, + { + "id": 9707500, + "rarity": 5, + "shortname": "狂業火", + "dropPriority": 660 + }, + { + "id": 4, + "rarity": 0, + "shortname": "FP", + "dropPriority": 512 + } +] diff --git a/viewer/src/itemPriority.test.ts b/viewer/src/itemPriority.test.ts new file mode 100644 index 0000000..cf087e4 --- /dev/null +++ b/viewer/src/itemPriority.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, test } from "vitest"; +import { compareByDropPriority, isKnownItem, sortItemNames } from "./itemPriority"; + +describe("isKnownItem", () => { + test("item_list_priority.json に掲載されているアイテムは true を返す", () => { + expect(isKnownItem("心臓")).toBe(true); + expect(isKnownItem("灰")).toBe(true); + expect(isKnownItem("鉄杭")).toBe(true); + expect(isKnownItem("骨")).toBe(true); + expect(isKnownItem("礼装")).toBe(true); + }); + + test("リストにないアイテムは false を返す", () => { + expect(isKnownItem("未知のアイテム")).toBe(false); + expect(isKnownItem("")).toBe(false); + }); + + test("イベントアイテム形式のキーはリストに含まれないので false を返す", () => { + // item_list_priority.json には "(xN)" 形式のキーは存在しない + expect(isKnownItem("ぐん肥(x3)")).toBe(false); + }); +}); + +describe("compareByDropPriority", () => { + test("dropPriority 降順でソートされる", () => { + // 心臓: dp=8500, 灰: dp=8108, 鉄杭: dp=8104, 骨: dp=8101 + expect(compareByDropPriority("心臓", "灰")).toBeLessThan(0); + expect(compareByDropPriority("灰", "心臓")).toBeGreaterThan(0); + expect(compareByDropPriority("灰", "鉄杭")).toBeLessThan(0); + expect(compareByDropPriority("鉄杭", "骨")).toBeLessThan(0); + }); + + test("dropPriority が同じ場合は id 降順でソートされる", () => { + // 金林檎: id=100, dp=9014 / EX2足跡: id=2109, dp=9014 + // id 降順なので EX2足跡 (2109) が前 + expect(compareByDropPriority("EX2足跡", "金林檎")).toBeLessThan(0); + expect(compareByDropPriority("金林檎", "EX2足跡")).toBeGreaterThan(0); + }); + + test("同一アイテムは 0 を返す", () => { + expect(compareByDropPriority("心臓", "心臓")).toBe(0); + }); + + test("未知アイテムはリスト内アイテムの後ろに配置される", () => { + expect(compareByDropPriority("未知", "心臓")).toBeGreaterThan(0); + expect(compareByDropPriority("心臓", "未知")).toBeLessThan(0); + }); + + test("未知アイテム同士は順序を問わない(0 を返す)", () => { + expect(compareByDropPriority("未知A", "未知B")).toBe(0); + }); +}); + +describe("sortItemNames", () => { + test("dropPriority 降順にソートされる", () => { + const items = ["鉄杭", "心臓", "骨", "灰"]; + const sorted = sortItemNames(items); + // 心臓(8500) > 灰(8108) > 鉄杭(8104) > 骨(8101) + expect(sorted).toEqual(["心臓", "灰", "鉄杭", "骨"]); + }); + + test("リスト内アイテムが未知アイテムより前に並ぶ", () => { + const items = ["未知のアイテム", "心臓", "別の未知"]; + const sorted = sortItemNames(items); + expect(sorted[0]).toBe("心臓"); + // 未知アイテムは末尾(順序不定) + expect(sorted.slice(1)).toContain("未知のアイテム"); + expect(sorted.slice(1)).toContain("別の未知"); + }); + + test("元の配列を変更しない", () => { + const items = ["骨", "心臓", "灰"]; + const original = [...items]; + sortItemNames(items); + expect(items).toEqual(original); + }); + + test("空配列では空を返す", () => { + expect(sortItemNames([])).toEqual([]); + }); +}); diff --git a/viewer/src/itemPriority.ts b/viewer/src/itemPriority.ts new file mode 100644 index 0000000..1d3d23b --- /dev/null +++ b/viewer/src/itemPriority.ts @@ -0,0 +1,48 @@ +import itemListPriority from "./data/item_list_priority.json"; + +interface PriorityEntry { + id: number; + rarity: number; + shortname: string; + dropPriority: number; +} + +const entries = itemListPriority as PriorityEntry[]; + +const priorityMap = new Map( + entries.map((e) => [e.shortname, { id: e.id, dropPriority: e.dropPriority }]), +); + +/** item_list_priority.json に掲載されているアイテム名のセット */ +export const knownItemNames: Set = new Set(entries.map((e) => e.shortname)); + +/** アイテム名が item_list_priority.json に掲載されているか判定する */ +export function isKnownItem(itemName: string): boolean { + return knownItemNames.has(itemName); +} + +/** + * dropPriority 降順 → id 降順のコンパレータ。 + * リストにないアイテムはリスト内アイテムの後ろに配置する。 + */ +export function compareByDropPriority(a: string, b: string): number { + const pa = priorityMap.get(a); + const pb = priorityMap.get(b); + + if (!pa && !pb) return 0; + if (!pb) return -1; + if (!pa) return 1; + + if (pb.dropPriority !== pa.dropPriority) { + return pb.dropPriority - pa.dropPriority; + } + return pb.id - pa.id; +} + +/** + * アイテム名配列を dropPriority 降順でソートして返す。 + * リスト内アイテム → リスト外アイテム(順不同)の順になる。 + */ +export function sortItemNames(itemNames: string[]): string[] { + return [...itemNames].sort(compareByDropPriority); +} diff --git a/viewer/src/summaryUtils.test.ts b/viewer/src/summaryUtils.test.ts index 2de3d9b..4444ce5 100644 --- a/viewer/src/summaryUtils.test.ts +++ b/viewer/src/summaryUtils.test.ts @@ -54,11 +54,22 @@ describe("classifyStats", () => { makeStats("QP(+150000)"), ]; const { normal, eventItems, points, qp } = classifyStats(stats); + // 鉄杭・骨は item_list_priority.json に掲載されているため normal に含まれる expect(normal).toHaveLength(2); expect(eventItems).toHaveLength(2); expect(points).toHaveLength(1); expect(qp).toHaveLength(1); }); + + test("item_list_priority.json に掲載されていない通常アイテムは除外される", () => { + const stats = [makeStats("未知のアイテム"), makeStats("鉄杭"), makeStats("ぐん肥(x3)")]; + const { normal, eventItems } = classifyStats(stats); + // 未知のアイテムは normal に含まれない + expect(normal).toHaveLength(1); + expect(normal[0].itemName).toBe("鉄杭"); + // イベントアイテムはリストに関係なく分類される + expect(eventItems).toHaveLength(1); + }); }); describe("extractBaseName", () => { diff --git a/viewer/src/summaryUtils.ts b/viewer/src/summaryUtils.ts index 8cbac61..e7c7814 100644 --- a/viewer/src/summaryUtils.ts +++ b/viewer/src/summaryUtils.ts @@ -1,4 +1,5 @@ import { RE_EVENT_ITEM, RE_POINT, RE_QP } from "./constants"; +import { isKnownItem } from "./itemPriority"; import type { ItemStats } from "./types"; /** @@ -20,9 +21,10 @@ export function classifyStats(stats: ItemStats[]) { qp.push(s); } else if (RE_POINT.test(s.itemName)) { points.push(s); - } else { + } else if (isKnownItem(s.itemName)) { normal.push(s); } + // 未知アイテムは全カテゴリから除外 } return { normal, eventItems, points, qp }; diff --git a/viewer/tsconfig.json b/viewer/tsconfig.json index 20fb0a0..def71f6 100644 --- a/viewer/tsconfig.json +++ b/viewer/tsconfig.json @@ -7,6 +7,7 @@ "skipLibCheck": true, "moduleResolution": "bundler", "allowImportingTsExtensions": true, + "resolveJsonModule": true, "isolatedModules": true, "moduleDetection": "force", "noEmit": true,