This commit is contained in:
1 parent
8eb519023a
commit
1593581729
18 files changed
+2757
-98
No files matched your search
@@ -0,0 +1 @@
|
||||
{"version":"2026-11-03","source":"https://srtools.punklorde.org/api/data","modes":{"moc":{"groups":[{"id":100,"name":{"en":"The Last Vestiges of Towering Citadel","th":"สิ่งตกทอดของป้อมปราการที่สูงตระหง่าน"},"begin":"","end":"","levels":[{"id":1,"floor":0,"name":{"en":"Memory (I)","th":"Memory ขั้นที่ 1"},"turnLimit":20,"buffs":[],"sides":[{"side":"firstNode","stageId":30001011,"level":32,"name":{"en":"Voidranger: Trampler","th":"Voidranger: Trampler"},"waves":[[8012010,8012020,8012010,8011020],[8012010,8013010,8012010,8011010,8011010]]}]},{"id":2,"floor":0,"name":{"en":"Memory (II)","th":"Memory ขั้นที่ 2"},"turnLimit":20,"buffs":[],"sides":[{"side":"firstNode","stageId":30001021,"level":36,"name":{"en":"Automaton Direwolf","th":"Automaton Direwolf"},"waves":[[1012020,1012010,1012010,1002050],[1012010,1013020,1012010,1002050]]}]},{"id":3,"floor":0,"name":{"en":"Memory (III)","th":"Memory ขั้นที่ 3"},"turnLimit":20,"buffs":[],"sides":[{"side":"firstNode","stageId":30001031,"level":39,"name":{"en":"Ice Out of Space","th":"Ice Out of Space"},"waves":[[8001010,1022010,1022010,8001020],[8001020,8003010,8001020]]}]},{"id":4,"floor":0,"name":{"en":"Memory (IV)","th":"Memory ขั้นที่ 4"},"turnLimit":20,"buffs":[],"sides":[{"side":"firstNode","stageId":30001041,"level":42,"name":{"en":"Bronya","th":"Bronya"},"waves":[[1002040,1002050,1002040,1012010,1002050],[1004030]]}]},{"id":5,"floor":0,"name":{"en":"Memory (V)","th":"Memory ขั้นที่ 5"},"turnLimit":20,"buffs":[],"sides":[{"side":"firstNode","stageId":30001051,"level":44,"name":{"en":"Automaton Grizzly","th":"Automaton Grizzly"},"waves":[[1012020,1012030,1012030,1012020],[1013010,1012030]]}]},{"id":6,"floor":0,"name":{"en":"Memory (VI)","th":"Memory ขั้นที่ 6"},"turnLimit":20,"buffs":[],"sides":[{"side":"firstNode","stageId":30001061,"level":45,"name":{"en":"Searing Prowler","th":"Searing Prowler"},"waves":[[1022020,1023010,1022020]]}]},{"id":7,"floor":0,"name":{"en":"Memory (VII)","th":"Memory ขั้นที่ 7"},"turnLimit":20,"buffs":[],"sides":[{"side":"firstNode","stageId":30001071,"level":47,"name":{"en":"Silvermane Lieutenant","th":"Silvermane Lieutenant"},"waves":[[1002040,1003010,1002050]]}]},{"id":8,"floor":0,"name":{"en":"Memory (VIII)","th":"Memory ขั้นที่ 8"},"turnLimit":20,"buffs":[],"sides":[{"side":"firstNode","stageId":30001081,"level":50,"name":{"en":"Automaton Direwolf","th":"Automaton Direwolf"},"waves":[[1012030,1013020,1012010]]}]},{"id":9,"floor":0,"name":{"en":"Memory (IX)","th":"Memory ขั้นที่ 9"},"turnLimit":20,"buffs":[],"sides":[{"side":"firstNode","stageId":30001091,"level":52,"name":{"en":"Silvermane Lieutenant","th":"Silvermane Lieutenant"},"waves":[[1002040,1003010,1002030]]}]},{"id":10,"floor":0,"name":{"en":"Memory (X)","th":"Memory ขั้นที่ 10"},"turnLimit":20,"buffs":[],"sides":[{"side":"firstNode","stageId":30001101,"level":54,"name":{"en":"Guardian Shadow","th":"Guardian Shadow"},"waves":[[1022020,8003030,1022010]]}]},{"id":11,"floor":0,"name":{"en":"Memory (XI)","th":"Memory ขั้นที่ 11"},"turnLimit":30,"buffs":[],"sides":[{"side":"firstNode","stageId":30001111,"level":55,"name":{"en":"Blaze Out of Space","th":"Blaze Out of Space"},"waves":[[8001010,8001010,8001010,8001010,8001010],[8001010,8003020,8001010]]},{"side":"secondNode","stageId":30001112,"level":55,"name":{"en":"Frigid Prowler","th":"Frigid Prowler"},"waves":[[1022010,1012020,1022010,1012020,1022010],[8001020,1023020]]}]},{"id":12,"floor":0,"name":{"en":"Memory (XII)","th":"Memory ขั้นที่ 12"},"turnLimit":30,"buffs":[],"sides":[{"side":"firstNode","stageId":30001121,"level":57,"name":{"en":"Searing Prowler","th":"Searing Prowler"},"waves":[[1012020,1012010,1022020,1012020],[1022020,1023010,1022020]]},{"side":"secondNode","stageId":30001122,"level":57,"name":{"en":"Silvermane Lieutenant","th":"Silvermane Lieutenant"},"waves":[[1002040,1002030,1002040,1002050],[1002040,1003010,1002040]]}]},{"id":13,"floor":0,"name":{"en":"Memory (XIII)","th":"Memory ขั้นที่ 13"},"turnLimit":30,"buffs":[],"sides":[{"side":"firstNode","stageId":30001131,"level":60,"name":{"en":"Automaton Grizzly","th":"Automaton Grizzly"},"waves":[[1002020,1012020,1012020,1012010,1002020],[1002020,1013010]]},{"side":"secondNode","stageId":30001132,"level":60,"name":{"en":"Ice Out of Space","th":"Ice Out of Space"},"waves":[[8001020,8002010,8002010,1012010],[1012010,8003010,1012020]]}]},{"id":14,"floor":0,"name":{"en":"Memory (XIV)","th":"Memory ขั้นที่ 14"},"turnLimit":30,"buffs":[],"sides":[{"side":"firstNode","stageId":30001141,"level":63,"name":{"en":"Bronya","th":"Bronya"},"waves":[[1002040,1002050,1002050,1002030,1002040],[1004030]]},{"side":"secondNode","stageId":30001142,"level":63,"name":{"en":"Svarog","th":"Svarog"},"waves":[[1012030,10120Line truncated
|
||||
@@ -4387,6 +4387,32 @@ input:checked + .slider:before {
|
||||
.monster-settings-monster-copy code { width: fit-content; color: var(--text-muted); font-size: .72rem; }
|
||||
.monster-settings-monster.compact { padding: .25rem; }
|
||||
.monster-settings-empty { padding: 1.4rem; border: 1px dashed rgba(148, 163, 184, .3); border-radius: 12px; color: var(--text-muted); background: var(--bg-card); text-align: center; line-height: 1.6; }
|
||||
|
||||
/* FireflyGo endgame room picker */
|
||||
.ff-endgame-picker { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 260px), 1fr)); gap: .85rem; padding: 1rem; border: 1px solid rgba(56, 189, 248, .22); border-radius: 14px; background: linear-gradient(120deg, rgba(14, 165, 233, .08), rgba(15, 23, 42, .7)); }
|
||||
.ff-endgame-select { min-height: 46px; width: 100%; }
|
||||
.ff-endgame-sides { display: flex; gap: .5rem; flex-wrap: wrap; }
|
||||
.ff-endgame-side { display: grid; gap: .2rem; flex: 1 1 150px; min-height: 52px; padding: .55rem .7rem; border: 1px solid var(--border-color); border-radius: 10px; background: var(--bg-card); color: var(--text-main); text-align: left; cursor: pointer; }
|
||||
.ff-endgame-side:hover { border-color: rgba(56, 189, 248, .45); }
|
||||
.ff-endgame-side.active { border-color: rgba(56, 189, 248, .72); background: rgba(14, 165, 233, .14); }
|
||||
.ff-endgame-side-label { font-size: .86rem; font-weight: 800; }
|
||||
.ff-endgame-side-sub { color: var(--text-muted); font-size: .74rem; line-height: 1.25; overflow-wrap: anywhere; }
|
||||
.ff-endgame-toggles { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 290px), 1fr)); gap: .35rem .9rem; padding: .35rem .8rem; border: 1px solid var(--border-color); border-radius: 12px; background: var(--bg-card); }
|
||||
.ff-endgame-preview { display: grid; gap: .65rem; padding: 1rem; border: 1px solid var(--border-color); border-radius: 12px; background: var(--bg-card); }
|
||||
.ff-endgame-preview .monster-settings-monster.compact .monster-settings-monster-portrait { flex-basis: 48px; width: 48px; height: 48px; }
|
||||
.ff-endgame-custom-waves { display: grid; gap: .85rem; }
|
||||
.ff-endgame-custom-row { display: grid; grid-template-columns: minmax(0, 1.4fr) repeat(2, minmax(0, .6fr)) 44px; gap: .6rem; align-items: end; }
|
||||
.ff-endgame-remove { min-height: 44px; }
|
||||
.ff-endgame-custom-actions { display: flex; gap: .75rem; align-items: end; flex-wrap: wrap; }
|
||||
.ff-endgame-custom-actions .ff-field { min-width: 160px; }
|
||||
.ff-endgame-summary { color: var(--text-muted); font-size: .84rem; }
|
||||
.ff-endgame-actions { display: flex; gap: .6rem; flex-wrap: wrap; margin-top: 1rem; }
|
||||
.ff-endgame-actions > .btn { min-height: 46px; }
|
||||
@media (max-width: 640px) {
|
||||
.ff-endgame-custom-row { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); }
|
||||
.ff-endgame-remove { grid-column: 1 / -1; }
|
||||
.ff-endgame-actions > .btn { flex: 1 1 100%; }
|
||||
}
|
||||
.monster-settings-aha-help[hidden] { display: none; }
|
||||
.monster-settings-aha-help { margin-top: 1rem; }
|
||||
.monster-custom-card { display: grid; gap: .85rem; }
|
||||
|
||||
@@ -0,0 +1,553 @@
|
||||
# FireflyGO Endgame Room Picker — สถานะงานและคู่มือเทคนิค
|
||||
|
||||
> อัปเดต: 2026-11-04
|
||||
> ขอบเขต: งานเพิ่มโหมดเชื่อมต่อ FireflyGO (Firefly_Srtools) ใน GaCy-Srtool และหน้าตั้งค่าห้อง Endgame ให้เหมือนต้นฉบับ `https://srtools.punklorde.org`
|
||||
|
||||
---
|
||||
|
||||
## 1. สรุปสั้น
|
||||
|
||||
### โจทย์
|
||||
เว็บ GaCy-Srtool เดิมออกแบบมาสำหรับเซิร์ฟเวอร์ **GaCy GO** ซึ่งมี REST API ครบ (`/api/status`, `/api/freesr`, `/api/persistent`, `/api/monster-settings`, ...) แต่ผู้ใช้ต้องการเชื่อมกับ **FireflyGO (Firefly_Srtools)** ซึ่งเป็นแบบ **push-only** คือมีแค่ `POST <base>/sync`
|
||||
|
||||
ปัญหาที่ตามมา:
|
||||
1. ต้องเพิ่มโหมด FireflyGO โดยไม่ทำให้โหมด GaCy เดิมพัง
|
||||
2. ต้องซ่อน UI ที่ต้องใช้ endpoint ที่ FireflyGO ไม่มี (save manager, inventory, exrank, damage analyst, ...)
|
||||
3. ต้องแก้ `extra_data` ของ FireflyGO ได้ (multi-path, setting, challenge, theory_craft, lua)
|
||||
4. ต้องนำเข้าข้อมูลจาก UID เกมจริง (Enka) ได้ทุกโหมด
|
||||
5. **ต้องมีหน้า Monster Setting / เลือกห้อง Endgame แบบต้นฉบับ Firefly** (MoC, PF, AS, Anomaly Arbitration, Custom Enemy)
|
||||
|
||||
### สถานะปัจจุบัน (สรุป)
|
||||
| ส่วน | สถานะ |
|
||||
| :--- | :--- |
|
||||
| โหมด FireflyGO + `POST /sync` | ✅ เสร็จและ commit แล้ว (`8eb5190`) |
|
||||
| `extra_data` form | ✅ เสร็จ |
|
||||
| Enka UID → FreeSR import | ✅ เสร็จ |
|
||||
| ซ่อน/แสดง UI ตามโหมด | ✅ เสร็จ |
|
||||
| แคตตาล็อกห้อง Endgame (moc/pf/as/peak) | ✅ สร้างเสร็จ (`assets/firefly-endgame.json`) |
|
||||
| ตัวช่วยคำนวณ `battle_config` (`js/endgame.js`) | ✅ เสร็จ |
|
||||
| UI หน้า Monster Setting โหมด Firefly | ✅ เขียนเสร็จ (เรียง season/floor ใหม่สุดก่อนแล้ว) |
|
||||
| ปรับปรุง Auto-init FreeSR เมื่อ Apply ห้อง | ✅ เสร็จ (ไม่ติดบล็อก `ffEndgameNoData`) |
|
||||
| ดักจับ `player data is not loaded yet` & Mixed Content | ✅ เสร็จ (มีข้อความแจ้งเตือนชัดเจน) |
|
||||
| Unit tests | ✅ ผ่าน **44/44** |
|
||||
| ทดสอบมือถือ 360–768px ของหน้าใหม่ | ✅ ผ่าน (responsive styles พร้อม) |
|
||||
| ทดสอบ `POST /sync` กับ FireflyGO จริง | ⏳ รอเปิดเซิร์ฟเวอร์และตัวละครล็อกอินในเกม |
|
||||
| Commit งานรอบนี้ | ⏳ พร้อม commit |
|
||||
|
||||
---
|
||||
|
||||
## 2. สถาปัตยกรรมโหมดเชื่อมต่อ
|
||||
|
||||
ระบบมี 3 โหมด เก็บใน `localStorage['gac_srtool_mode']` และนิยามความสามารถไว้ที่ `js/modes.js`
|
||||
|
||||
| ความสามารถ | GaCy GO (`gac`) | FireflyGO (`firefly`) | RobinSR (`robin`) |
|
||||
| :--- | :---: | :---: | :---: |
|
||||
| `saveManager` | ✅ | — | — |
|
||||
| `exranks` | ✅ | — | — |
|
||||
| `inventory` | ✅ | — | — |
|
||||
| `monsterSettings` (อ่าน catalog จาก Client) | ✅ | — | — |
|
||||
| `fireflyEndgame` (picker จากแคตตาล็อกในเครื่อง) | — | ✅ | — |
|
||||
| `damageAnalyst` | ✅ | — | — |
|
||||
| `sceneCard` / `gmConsole` / `battleSummary` | ✅ | — | — |
|
||||
| `toolSession` | ✅ | — | — |
|
||||
| `extraData` | — | ✅ | — |
|
||||
| `ownedAvatarsOnly` | — | ✅ | — |
|
||||
| `jsonModes` | `freesr`, `persistent` | `freesr` | `freesr` |
|
||||
|
||||
ไฟล์ที่เกี่ยวข้อง:
|
||||
- `js/modes.js` — capability matrix, `normalizeMode`, `modeSupports`
|
||||
- `js/app.js` → `applyModeDisplay()` — ซ่อน/แสดง tab และ card ตาม capability
|
||||
- `js/app.js` → `setupServerModes()` — ฟัง event เปลี่ยนโหมด (ผูกครั้งเดียว)
|
||||
|
||||
### กฎสำคัญ
|
||||
- **FireflyGO เป็น push-only**: การ "เชื่อมต่อ" คือ `POST <base>/sync` ด้วย `{ username:'', password:'' }` แล้วอ่าน `{ status, message, extra_data, warnings, stats }`
|
||||
- การซิงค์คือ `POST <base>/sync` ด้วย `{ username, password, data: FreeSRJson, extra_data }`
|
||||
- **ห้ามใส่ custom header** เพราะ FireflyGO ไม่รองรับ CORS preflight
|
||||
- สถานะการเชื่อมต่อของ FireflyGO เก็บใน `state.fireflyConnected` (ไม่ใช้ `state.isConnected` ร่วมกับ GaCy)
|
||||
- `refreshStatus()` ในโหมด Firefly เป็น local-only (ไม่อ่าน `/api/status`)
|
||||
|
||||
---
|
||||
|
||||
## 3. FireflyGO Endgame Room Picker
|
||||
|
||||
### 3.1 ภาพรวมการทำงาน
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
actor User as ผู้ใช้
|
||||
participant UI as app.js (Monster Setting)
|
||||
participant Cat as assets/firefly-endgame.json
|
||||
participant Save as state.fullFreesrData
|
||||
participant Srv as FireflyGO /sync
|
||||
|
||||
User->>UI: เปิดแท็บ Monster Setting (โหมด FireflyGO)
|
||||
UI->>Cat: fetch แคตตาล็อก (ครั้งเดียว)
|
||||
Cat-->>UI: modes.{moc,pf,as,peak} + monsters
|
||||
UI->>Save: อ่าน battle_config เดิม
|
||||
UI->>UI: syncFireflyEndgameSelectionFromSave()
|
||||
UI-->>User: แสดง season / floor / side / stage / preview
|
||||
|
||||
User->>UI: เลือกห้อง + กด "ใช้กับ battle_config"
|
||||
UI->>UI: buildFireflyBattleConfig()
|
||||
UI->>Save: เขียน battle_config
|
||||
opt Anomaly Arbitration
|
||||
UI->>UI: buildFireflyChallenge() → extra_data.challenge
|
||||
end
|
||||
UI->>UI: saveFreesrCache()
|
||||
|
||||
User->>UI: กด "ใช้และซิงค์"
|
||||
UI->>Srv: POST /sync { data, extra_data }
|
||||
Srv-->>UI: { status, warnings }
|
||||
```
|
||||
|
||||
### 3.2 แหล่งข้อมูลแคตตาล็อก
|
||||
|
||||
ต้นฉบับ Firefly SrTools เสิร์ฟตารางดิบของ Client ที่:
|
||||
|
||||
```
|
||||
https://srtools.punklorde.org/api/data/moc
|
||||
https://srtools.punklorde.org/api/data/pf
|
||||
https://srtools.punklorde.org/api/data/as
|
||||
https://srtools.punklorde.org/api/data/peak
|
||||
https://srtools.punklorde.org/api/data/monster
|
||||
```
|
||||
|
||||
ไฟล์ดิบมีขนาด 2–14 MB จึงทำสคริปต์ย่อข้อมูล:
|
||||
|
||||
```bash
|
||||
node scripts/build-firefly-endgame-data.cjs [cacheDir]
|
||||
```
|
||||
|
||||
- ถ้ามี `cacheDir` และมีไฟล์อยู่แล้วจะใช้แคช (ไม่ยิงเน็ตซ้ำ)
|
||||
- ผลลัพธ์: `assets/firefly-endgame.json` (~604 KB)
|
||||
- ข้อมูล ณ วันที่สร้าง:
|
||||
|
||||
| โหมด | จำนวนกลุ่ม (season/round) |
|
||||
| :--- | ---: |
|
||||
| `moc` (Memory of Chaos) | 58 |
|
||||
| `pf` (Pure Fiction) | 28 |
|
||||
| `as` (Apocalyptic Shadow) | 23 |
|
||||
| `peak` (Anomaly Arbitration) | 11 |
|
||||
| มอนสเตอร์ (unique) | 355 |
|
||||
|
||||
โครงสร้างไฟล์:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"version": "2026-11-03",
|
||||
"source": "https://srtools.punklorde.org/api/data",
|
||||
"modes": {
|
||||
"moc": { "groups": [ { "id": 1037, "name": {"en":"..."}, "begin": "...", "end": "...",
|
||||
"levels": [ { "id": 5612, "floor": 12, "turnLimit": 30,
|
||||
"buffs": [ { "id": 1001, "name": {"en":"..."} } ],
|
||||
"sides": [ { "side": "firstNode", "stageId": 30127111, "level": 92,
|
||||
"name": {"en":"..."}, "waves": [[4033010, 4033020],[4044010]],
|
||||
"invasionBuffId": 3034002 } ] } ] } ] },
|
||||
"pf": { "groups": [] },
|
||||
"as": { "groups": [] },
|
||||
"peak": { "groups": [ { "id": 9, "name": {"en":"..."},
|
||||
"stages": [ { "kind": "pre|boss|hard", "stageId": 4001, "level": 80,
|
||||
"name": {"en":"..."}, "waves": [[...]], "buffs": [...], "turnLimit": 30 } ] } ] }
|
||||
},
|
||||
"monsters": {
|
||||
"4033010": { "name": {"en":"...","th":"..."}, "rank": 1, "stance": 1,
|
||||
"weak": ["Fire"], "icon": "https://cdn.punklorde.org/asbres/spriteoutput/mostericon/monster_4033010.webp" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> หมายเหตุ: path ไอคอนของข้อมูลต้นทางสะกดว่า `mostericon` (ตก `n`) จึงต้องใช้ตามนั้น
|
||||
|
||||
### 3.3 กติกาการแปลงข้อมูลจากต้นฉบับ
|
||||
|
||||
| โหมด | แหล่งห้อง | กติกา |
|
||||
| :--- | :--- | :--- |
|
||||
| MoC | `Level[].EventList1[0]` (ครึ่งแรก), `EventList2[0]` (ครึ่งหลัง), `Tierce.EventList[0]` (โหนดที่สาม) | `stage_id` = `stage.ID`, `cycle_count` = `Level.TurnLimit`, blessings = `Level.MazeBuff` + `Invasion.MazeBuff.ID` |
|
||||
| PF | เหมือน MoC | เหมือน MoC |
|
||||
| AS | เหมือน MoC | เหมือน MoC |
|
||||
| Anomaly Arbitration (`peak`) | กลุ่ม `peak[event_id]` → เลือก stage จาก `[...PreLevel, BossLevel]` ตาม index | ถ้า `boss_mode === 'Hard'` ใช้ `BossConfig`; `stage_id = EventList[0].ID`; ต้องส่ง `extra_data.challenge.challenge_peak_group_id` = group id |
|
||||
| Custom Enemy (`custom`/`CE`) | ผู้ใช้กรอกเอง | `stage_id = 0`, ใช้ `monsters` ที่กรอก |
|
||||
|
||||
### 3.4 Schema ที่ FireflyGO ตรวจ (zod)
|
||||
|
||||
จากการแกะ bundle ต้นฉบับ:
|
||||
|
||||
```ts
|
||||
battle_config = {
|
||||
battle_type: string, // MOC | PF | AS | CE | PEAK | DEFAULT
|
||||
blessings: { id: number, level: number, dynamic_key?: { key: any, value: number } }[],
|
||||
custom_stats: any[],
|
||||
cycle_count: number,
|
||||
stage_id: number,
|
||||
path_resonance_id?: number, // default 0
|
||||
monsters: { monster_id: number, level: number, amount?: number }[][]
|
||||
}
|
||||
```
|
||||
|
||||
จุดที่พลาดง่าย:
|
||||
- `blessings` **ต้องเป็น object** `{id, level}` ไม่ใช่ array ของ id
|
||||
- `monsters` เป็น **array ของ wave** และแต่ละตัวเป็น object
|
||||
- `amount` ไม่บังคับ แต่เราเติม `1` ให้เสมอเพื่อความชัดเจน
|
||||
|
||||
`js/firefly.js → normalizeBattleConfig()` จึงแปลงค่าดิบให้เป็น shape นี้ก่อนส่งทุกครั้ง (รองรับข้อมูลเก่าที่เป็น id ล้วนด้วย)
|
||||
|
||||
### 3.5 ตัวช่วยคำนวณ (`js/endgame.js`)
|
||||
|
||||
ฟังก์ชันทั้งหมดเป็น pure function (ทดสอบด้วย `node:test` ได้):
|
||||
|
||||
| ฟังก์ชัน | หน้าที่ |
|
||||
| :--- | :--- |
|
||||
| `battleTypeForMode(mode)` | แปลง `moc/pf/as/peak/custom` → `MOC/PF/AS/PEAK/CE` |
|
||||
| `groupList(catalog, mode)` | คืนกลุ่มของโหมด |
|
||||
| `findGroup` / `findLevel` | ค้นหากลุ่ม/ชั้น |
|
||||
| `defaultEndgameSelection(catalog, mode)` | ค่าเริ่มต้น: ซีซันใหม่สุด + ชั้นสูงสุด + ฝั่งแรก; peak = stage บอส |
|
||||
| `resolveEndgameSelection(catalog, selection)` | คืน `{group, level, sides, stage}` หรือ `null` |
|
||||
| `findSelectionByStageId(catalog, mode, stageId, groupId)` | เปิด picker ที่ห้องเดียวกับ `battle_config` เดิม |
|
||||
| `buildFireflyBattleConfig(catalog, selection)` | สร้าง `battle_config` พร้อมส่ง |
|
||||
| `buildFireflyChallenge(selection, current)` | อัปเดต `extra_data.challenge` (เฉพาะ peak) |
|
||||
| `summarizeEndgameSelection` | สรุปจำนวน wave/มอน/บัฟ/รอบ ให้ UI |
|
||||
| `monsterInfo(catalog, id)` | ข้อมูลมอน (ชื่อ/ไอคอน) |
|
||||
|
||||
### 3.6 State ของ UI
|
||||
|
||||
เพิ่มใน `state.fireflyEndgame` (`js/app.js`):
|
||||
|
||||
```js
|
||||
{
|
||||
activeMode: 'moc', // moc | pf | as | peak | custom
|
||||
catalog: null, // แคตตาล็อกที่โหลดแล้ว
|
||||
catalogState: 'idle', // idle | loading | ready | error
|
||||
catalogError: '',
|
||||
catalogPromise: null, // กัน fetch ซ้ำ
|
||||
selections: {}, // แยก selection ตามโหมด
|
||||
statusKey: '', statusVars: null, statusState: 'info'
|
||||
}
|
||||
```
|
||||
|
||||
`selections` เก็บ object ที่ผูกกับ UI โดยตรง เช่น
|
||||
|
||||
```js
|
||||
// moc/pf/as
|
||||
{ mode:'moc', groupId:1037, levelId:5612, side:'firstNode', useCycle:true, useBuff:true }
|
||||
|
||||
// peak
|
||||
{ mode:'peak', groupId:9, stageIndex:1, useCycle:true, useBuff:true }
|
||||
|
||||
// custom
|
||||
{ mode:'custom', customWaves:[[{monster_id:'4033010', level:'90', amount:'1'}]], customCycle:0 }
|
||||
```
|
||||
|
||||
### 3.7 UI ที่เพิ่มใน `index.html`
|
||||
|
||||
`#tab-monster-settings` ถูกแบ่งเป็น 2 ส่วน:
|
||||
|
||||
```html
|
||||
<section id="tab-monster-settings" class="tab-content">
|
||||
<div id="monster-settings-gac"> ... ของเดิมทั้งหมด ... </div>
|
||||
|
||||
<div id="monster-settings-firefly" hidden>
|
||||
<div class="card monster-settings-intro"> ... คำอธิบาย ... </div>
|
||||
<div class="monster-settings-tabs" id="ff-endgame-tabs">
|
||||
<button data-ff-endgame-mode="moc">Memory of Chaos</button>
|
||||
<button data-ff-endgame-mode="pf">Pure Fiction</button>
|
||||
<button data-ff-endgame-mode="as">Apocalyptic Shadow</button>
|
||||
<button data-ff-endgame-mode="peak">Anomaly Arbitration</button>
|
||||
<button data-ff-endgame-mode="custom">Custom Enemy</button>
|
||||
</div>
|
||||
<div id="ff-endgame-status" class="monster-settings-status"></div>
|
||||
<div id="ff-endgame-view" class="monster-settings-view"></div>
|
||||
<div class="ff-endgame-actions">
|
||||
<button id="ff-endgame-reset">รีเซ็ตค่าเริ่มต้น</button>
|
||||
<button id="ff-endgame-apply">ใช้กับ battle_config</button>
|
||||
<button id="ff-endgame-apply-sync">ใช้และซิงค์</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
```
|
||||
|
||||
การแสดงผล:
|
||||
- `applyModeDisplay()` ตั้ง `hidden` ให้ `#monster-settings-gac` / `#monster-settings-firefly` ตามโหมด
|
||||
- แท็บ Monster Setting แสดงเมื่อ `caps.monsterSettings || caps.fireflyEndgame`
|
||||
- ถ้าแท็บถูกซ่อนอยู่และโหมดเปลี่ยน ระบบจะพากลับไป Dashboard อัตโนมัติ
|
||||
|
||||
### 3.8 ฟังก์ชันใน `js/app.js`
|
||||
|
||||
| ฟังก์ชัน | หน้าที่ |
|
||||
| :--- | :--- |
|
||||
| `loadFireflyEndgame()` | โหลด snapshot (memory → IndexedDB → localStorage) แล้วโหลดแคตตาล็อก |
|
||||
| `ensureFireflyEndgameCatalog()` | `fetch` แคตตาล็อกครั้งเดียว + seed ค่าเริ่มต้นทุกโหมด |
|
||||
| `syncFireflyEndgameSelectionFromSave()` | อ่าน `battle_config` เดิมมาเลือกห้องให้ตรง |
|
||||
| `renderFireflyEndgame()` | entry point ของการวาด UI |
|
||||
| `renderFireflyRoomEditor()` | วาด season/floor/side หรือ stage cards + preview |
|
||||
| `renderFireflyCustomEditor()` | วาด editor ของ Custom Enemy |
|
||||
| `renderFireflyEndgamePreview()` | แสดง `battle_config` ที่จะส่งจริง |
|
||||
| `setupFireflyEndgameEvents()` | ผูก event ทั้งหมด (ผูกครั้งเดียวตอน init) |
|
||||
| `applyFireflyEndgame({sync})` | เขียน `battle_config` (+`challenge`) แล้วบันทึก/ซิงค์ |
|
||||
| `resetFireflyEndgame()` | รีเซ็ตโหมดปัจจุบันเป็นค่าเริ่มต้น |
|
||||
| `updateFireflyEndgameControls()` | เปิด/ปิดปุ่มตามความพร้อม |
|
||||
| `setFireflyEndgameStatus()` | ข้อความสถานะ (i18n) |
|
||||
|
||||
จุดเชื่อมกับระบบเดิม:
|
||||
- `renderMonsterSettings()` → ถ้าโหมด firefly เรียก `renderFireflyEndgame()` แล้ว return
|
||||
- `loadMonsterSettings()` → ถ้าโหมด firefly เรียก `loadFireflyEndgame()` แล้ว return
|
||||
- `setConnectionStatus()` → โหลด Monster Setting ใหม่เฉพาะโหมด `gac`
|
||||
- `applyTranslations()` → เรียก `renderMonsterSettings()` ใหม่เมื่อสลับภาษา
|
||||
- `updateDocumentTitle()` → ใช้ `ffEndgameTitle` เมื่ออยู่โหมด firefly
|
||||
|
||||
### 3.9 Apply ทำอะไรบ้าง
|
||||
|
||||
`applyFireflyEndgame({ sync })`:
|
||||
|
||||
1. `buildFireflyBattleConfig()` จาก selection ปัจจุบัน
|
||||
2. ถ้าไม่มีข้อมูลหรือเลือกไม่ครบ → แจ้ง error และไม่เขียนอะไร
|
||||
3. เขียน `state.fullFreesrData.battle_config = config`
|
||||
4. ถ้าโหมดเป็น `peak`:
|
||||
- `extra.challenge = buildFireflyChallenge(selection, extra.challenge)`
|
||||
- เก็บ `state.fireflyExtraData` + `persistFireflyExtra()`
|
||||
- รีเฟรชการ์ด extra_data
|
||||
5. ถ้า `sync = false` → `saveFreesrCache()` (IndexedDB + localStorage)
|
||||
6. ถ้า `sync = true` → `saveFreesrChangesToServer()` ซึ่งในโหมด firefly จะไปเรียก `pushFireflySync()` → `POST /sync`
|
||||
7. อัปเดตสถานะ + toast
|
||||
|
||||
> `pushFireflySync()` จะบังคับ `extra_data.theory_craft.mode` จาก `state.creativeMode` เสมอ เพื่อให้ Creative Mode กับ theory craft เป็นสวิตช์เดียวกัน
|
||||
|
||||
### 3.10 i18n
|
||||
|
||||
- เพิ่มคีย์ `ffEndgame*` **46 คีย์** ใน `MODAL_AND_TOOL_TRANSLATIONS` ทั้ง `th` และ `en`
|
||||
- ภาษาอื่น fallback เป็นอังกฤษผ่าน `t()` ตามกติกาเดิม
|
||||
- ข้อความที่วาดจาก JS ทุกจุดใช้ `t(...)` ไม่มีข้อความ hardcode
|
||||
- `applyTranslations()` รีเรนเดอร์หน้า Monster Setting ทำให้ชื่อห้อง/มอนเปลี่ยนตามภาษา
|
||||
|
||||
ตัวอย่างคีย์:
|
||||
`ffEndgameTitle`, `ffEndgameDescription`, `ffEndgameRule`, `ffEndgameModePeak`, `ffEndgameSeason`, `ffEndgameFloor`, `ffEndgameSideFirst/Second/Third`, `ffEndgameStageKindPre/Boss/Hard`, `ffEndgameUseCycle`, `ffEndgameUseBuff`, `ffEndgameApply`, `ffEndgameApplySync`, `ffEndgameReset`, `ffEndgameSaved`, `ffEndgameSynced`, `ffEndgameSyncError`, `ffEndgameMonsterId/Level/Amount`, ...
|
||||
|
||||
### 3.11 CSS
|
||||
|
||||
เพิ่มบล็อก `.ff-endgame-*` ใน `css/styles.css`:
|
||||
- `.ff-endgame-picker` — grid 2 คอลัมน์อัตโนมัติ
|
||||
- `.ff-endgame-sides` / `.ff-endgame-side` — ปุ่มเลือกฝั่ง
|
||||
- `.ff-endgame-toggles` — สวิตช์ TurnLimit / Buff
|
||||
- `.ff-endgame-preview` — preview การ์ด
|
||||
- `.ff-endgame-custom-row` — แถวกรอกมอน (ยุบเป็น 2 คอลัมน์ที่ ≤640px)
|
||||
- `.ff-endgame-actions` — ปุ่มล่าง (เต็มความกว้างบนมือถือ)
|
||||
|
||||
ค่าเป้าหมายมือถือ: ปุ่มสูง ≥44–46px, ไม่มี horizontal scroll ระดับหน้า
|
||||
|
||||
### 3.12 Cache busting (สำคัญ)
|
||||
|
||||
เปลี่ยนไฟล์แล้วต้อง bump `?v=`:
|
||||
|
||||
| ไฟล์ | เวอร์ชันใหม่ |
|
||||
| :--- | :--- |
|
||||
| `css/styles.css` | `2.36` |
|
||||
| `js/app.js` | `3.32` |
|
||||
| `js/i18n.js` | `2.26` (ทุก importer ต้องตรงกัน) |
|
||||
| `js/endgame.js` | `1` (ไฟล์ใหม่) |
|
||||
|
||||
Importer ของ `i18n.js` ที่ถูกแก้: `index.html` (ทาง `app.js`), `js/app.js`, `js/avatars-data.js`, `js/equipment-data.js`, `js/item-catalog.js`, `js/unstuck.js`
|
||||
|
||||
---
|
||||
|
||||
## 4. ไฟล์ที่เพิ่ม / แก้ในรอบนี้
|
||||
|
||||
### เพิ่มใหม่
|
||||
| ไฟล์ | รายละเอียด |
|
||||
| :--- | :--- |
|
||||
| `js/endgame.js` | pure helpers สำหรับเลือกห้อง + สร้าง `battle_config` |
|
||||
| `scripts/build-firefly-endgame-data.cjs` | สร้างแคตตาล็อกจาก API ต้นฉบับ |
|
||||
| `assets/firefly-endgame.json` | แคตตาล็อกพร้อมใช้ (~604 KB) |
|
||||
| `tests/endgame.test.cjs` | unit tests ของ endgame helpers (8 tests) |
|
||||
|
||||
### แก้ไข
|
||||
| ไฟล์ | สิ่งที่แก้ |
|
||||
| :--- | :--- |
|
||||
| `index.html` | แบ่ง `#monster-settings-gac` / `#monster-settings-firefly`, เพิ่ม UI picker, bump `?v=` |
|
||||
| `js/app.js` | import endgame, state ใหม่, ฟังก์ชัน picker ทั้งหมด, branch ตามโหมด, เปลี่ยน title |
|
||||
| `js/modes.js` | เพิ่ม capability `fireflyEndgame` |
|
||||
| `js/firefly.js` | normalize `blessings` / `monsters` ให้ตรง zod |
|
||||
| `js/i18n.js` | เพิ่ม 46 คีย์ th/en, bump version |
|
||||
| `css/styles.css` | สไตล์ `.ff-endgame-*` + responsive |
|
||||
| `js/avatars-data.js`, `js/equipment-data.js`, `js/item-catalog.js`, `js/unstuck.js` | bump `i18n.js?v=2.26` (และ bump version ของ module ปลายทางใน `app.js`) |
|
||||
| `tests/firefly-payload.test.cjs` | อัปเดต expected shape + เพิ่ม test normalize |
|
||||
| `tests/mode-capabilities.test.cjs` | เพิ่ม `fireflyEndgame`, คีย์ i18n, DOM hooks |
|
||||
|
||||
---
|
||||
|
||||
## 5. สิ่งที่เสร็จแล้ว (ละเอียด)
|
||||
|
||||
### 5.1 โหมดเชื่อมต่อ + transport
|
||||
- [x] `js/modes.js` capability matrix ครบ 3 โหมด
|
||||
- [x] `fireflySyncUrl()` แปลง base URL → `/sync`
|
||||
- [x] `fireflyRequest()` ไม่ส่ง custom header, มี timeout, parse error body
|
||||
- [x] `probeFirefly()` = connect probe (ไม่ส่ง `data`)
|
||||
- [x] `pushFireflySync()` = sync เต็ม
|
||||
- [x] `refreshStatus()` แยกพฤติกรรม firefly (local-only)
|
||||
- [x] กัน `tool-session`, `pagehide`, `fetchLatestBattleReport` ในโหมดที่ไม่รองรับ
|
||||
|
||||
### 5.2 extra_data
|
||||
- [x] `normalizeExtraData()` ตรวจ/เติมทุกบล็อก
|
||||
- [x] ฟอร์มใน `#card-firefly-extras` (multi_path, setting, challenge, theory_craft, lua)
|
||||
- [x] `readFireflyExtrasForm()` / `renderFireflyExtras()`
|
||||
- [x] Creative Mode = `theory_craft.mode` ใช้ state เดียว (`state.creativeMode`)
|
||||
- [x] ทุกจุดที่เปลี่ยน Creative Mode วิ่งผ่าน `setCreativeMode()`
|
||||
- [x] factory reset ล้าง `state.fireflyExtraData`
|
||||
|
||||
### 5.3 Enka import
|
||||
- [x] `js/enka-import.js` แปลง Enka → FreeSR (avatars, lightcones, relics)
|
||||
- [x] UI ในแท็บ JSON ใช้ได้ทุกโหมด
|
||||
- [x] proxy `/api/enka/:uid` (dev server + `vercel.json` rewrite)
|
||||
- [x] paste JSON สำรองเมื่อ proxy ใช้ไม่ได้
|
||||
|
||||
### 5.4 UI ตามโหมด
|
||||
- [x] ซ่อน tab/card ที่ backend ไม่รองรับ
|
||||
- [x] ไม่ค้างอยู่แท็บที่ถูกซ่อน
|
||||
- [x] JSON editor จำกัด mode ตาม backend
|
||||
- [x] badge persistence / API URL hint เปลี่ยนตามโหมด
|
||||
- [x] `setupServerModes()` ผูก event ครั้งเดียว
|
||||
|
||||
### 5.5 Firefly Endgame Picker
|
||||
- [x] ดึง logic แปลงข้อมูลจาก bundle ต้นฉบับครบทุกโหมด
|
||||
- [x] สร้างแคตตาล็อกแบบ static (ไม่ต้องพึ่ง API ต้นทางตอนใช้งาน)
|
||||
- [x] UI เลือก season / floor / side / stage
|
||||
- [x] UI Custom Enemy (wave + monster + level + amount)
|
||||
- [x] preview `battle_config` ก่อนใช้
|
||||
- [x] เปิด picker ที่ห้องเดิมจาก `battle_config`
|
||||
- [x] Apply → เขียน `battle_config` (ทดสอบครบ MOC/PF/AS/PEAK/CE)
|
||||
- [x] Peak → อัปเดต `extra_data.challenge`
|
||||
- [x] Apply & sync → `POST /sync` (error path ทดสอบแล้ว, happy path ติดที่ยังไม่มีเซิร์ฟเวอร์)
|
||||
- [x] i18n th/en + รีเรนเดอร์เมื่อเปลี่ยนภาษา (แก้บั๊กสถานะค้างเป็น loading แล้ว)
|
||||
- [x] responsive CSS (ทดสอบจริง 360px / 768px)
|
||||
- [x] เรียง season/floor ใหม่สุด–สูงสุดขึ้นก่อน
|
||||
|
||||
### 5.6 Tests
|
||||
```
|
||||
node --test tests/*.test.cjs
|
||||
ℹ tests 44
|
||||
ℹ pass 44
|
||||
ℹ fail 0
|
||||
```
|
||||
|
||||
ครอบคลุม:
|
||||
- โครงสร้าง payload `/sync` (avatars/lightcones/relics/battle_config)
|
||||
- การ normalize `blessings` / `monsters` (รวม `dynamic_key`)
|
||||
- capability matrix + i18n keys + DOM hooks
|
||||
- endgame helpers: default/resolve/build/challenge/find-by-stage/custom
|
||||
|
||||
---
|
||||
|
||||
## 6. สิ่งที่ยังเหลือ (ละเอียด + วิธีทำ)
|
||||
|
||||
### 6.1 ✅ ทดสอบ Apply / Apply & sync ในเบราว์เซอร์
|
||||
**สถานะ:** ทดสอบครบทุกโหมดแล้วเมื่อ 2026-11-04 ที่ `http://localhost:5173` โดยโหลด FreeSR JSON ขั้นต่ำผ่านแท็บ JSON ก่อน
|
||||
|
||||
**ผลทดสอบจริง:**
|
||||
- MoC → `battle_type: "MOC"`, `stage_id: 30127121`, `cycle_count: 30`, `monsters` เป็น array ของ array ของ `{monster_id, level, amount}`
|
||||
- PF → `battle_type: "PF"`, `stage_id: 30326041`, `cycle_count: 4`, `blessings: 1`, 3 waves
|
||||
- AS → `battle_type: "AS"`, `stage_id: 420564`, `blessings: 2`, 1 wave
|
||||
- Anomaly Arbitration → `battle_type: "PEAK"`, `stage_id: 30511021`, `blessings: 3`, และ `extra_data.challenge.challenge_peak_group_id = 11` ถูกเขียนลง `localStorage['gac_srtool_firefly_extra']`
|
||||
- Custom Enemy → เพิ่ม wave/มอนแล้ว Apply ได้ `battle_type: "CE"`, `stage_id: 0`, `cycle_count` ตามที่กรอก
|
||||
- รีโหลดหน้า → picker กลับมาที่ห้องเดิมจาก `syncFireflyEndgameSelectionFromSave` (ทดสอบกับ Custom: กลับมาเป็น CE และ wave ตรงกับที่บันทึก)
|
||||
- Apply & sync ตอนไม่มี FireflyGo → สถานะเป็น `Sync failed: Failed to fetch` (data-state = error) ไม่มี crash และ cache ถูกบันทึกก่อน sync
|
||||
- เปลี่ยนภาษาไทย→อังกฤษขณะอยู่หน้า picker → สถานะเปลี่ยนเป็น `battle_config saved locally` (ไม่ค้างที่ข้อความ loading) หลังแก้บั๊กในข้อ 6.1.1
|
||||
|
||||
### 6.1.1 🐞 บั๊กที่พบและแก้แล้วระหว่าง audit
|
||||
`#ff-endgame-status` มี `data-i18n="ffEndgameLoading"` ทำให้ `applyTranslations()` เขียนทับข้อความสถานะเป็น "กำลังโหลด…" ทุกครั้งที่เปลี่ยนภาษา แต่ `renderFireflyEndgame()` ไม่คืนค่าสถานะที่เก็บไว้
|
||||
|
||||
**แก้โดย:** เพิ่ม `applyFireflyEndgameStatus()` ที่อ่าน `state.fireflyEndgame.statusKey/statusState/statusVars` แล้วเรียกที่ท้าย `renderFireflyEndgame()` ทั้งสอง branch (ทั้งตอน catalog ยังไม่โหลดและตอนเรนเดอร์ปกติ) — ตอนนี้สถานะถูกต้องหลังสลับภาษา
|
||||
|
||||
### 6.2 ✅ ทดสอบมือถือ 360–768px
|
||||
**สถานะ:** ทดสอบด้วย iframe ขนาด 360×800 และ 768×1024 (media query ภายใน iframe ทำงานเหมือน viewport จริง)
|
||||
|
||||
**ผลทดสอบจริง:**
|
||||
|
||||
| ตัวชี้วัด | 360px | 768px |
|
||||
| :--- | :--- | :--- |
|
||||
| `documentElement.scrollWidth <= clientWidth` | 345 = 345 (ไม่มี horizontal scroll) | 753 = 753 |
|
||||
| ความสูง tab `#ff-endgame-tabs button` | 46px | 46px |
|
||||
| ความสูงปุ่ม Apply ทั้ง 3 | 46px | 46px |
|
||||
| ความสูง `.ff-endgame-side` | 56px | 56–71px |
|
||||
| ความสูง select | 44px | — |
|
||||
| `.ff-endgame-picker` | 1 คอลัมน์ (319px) | 2 คอลัมน์ (340px + 340px) |
|
||||
| `.ff-endgame-custom-row` | 2 คอลัมน์ (138px + 138px) | — |
|
||||
|
||||
โหมด GaCy เดิมยังทำงาน: `#monster-settings-gac` แสดง, `#monster-settings-firefly` ถูกซ่อน, แท็บ save manager กลับมา, `#card-firefly-extras` ถูกซ่อน
|
||||
|
||||
### 6.3 🚫 ทดสอบ `POST /sync` กับ FireflyGO จริง
|
||||
**ติดเพราะ:** ไม่มีเซิร์ฟเวอร์ Firefly_Srtools รันที่ `localhost:21000`
|
||||
|
||||
**เมื่อมีเซิร์ฟเวอร์:**
|
||||
1. ตั้ง API URL เป็น `http://localhost:21000`
|
||||
2. กด Connect → ต้องได้ `extra_data` กลับมา
|
||||
3. Sync → ตรวจไฟล์/สถานะฝั่งเซิร์ฟเวอร์
|
||||
4. ตรวจว่า `battle_config` ที่ส่งผ่าน zod (ไม่มี warning เรื่อง schema)
|
||||
|
||||
### 6.4 ✅ เรียงลำดับ season และ floor ให้ใหม่สุด/สูงสุดอยู่บน
|
||||
**สถานะ:** ดำเนินการเรียบร้อยแล้ว ใน `renderFireflyRoomEditor()` ทั้ง `groups` และ `levels` ถูกเรียงลำดับจากมาก→น้อย (descending) ทำให้ซีซันใหม่สุดและชั้น 12 อยู่ด้านบนสุดของ Dropdown สะดวกต่อการเลือกใช้งานทันที
|
||||
|
||||
### 6.5 ⏳ ตรวจ `docs/` และ commit
|
||||
- `docs/` เป็น untracked ตาม `AGENTS.md` — ไฟล์นี้ถูกสร้างตามคำขอของผู้ใช้
|
||||
- ยังไม่ commit งานรอบนี้
|
||||
- ต้องตัดสินใจว่าจะ push ขึ้น `origin/test-gay` หรือไม่ (ต้องถามก่อน)
|
||||
|
||||
### 6.6 ⏳ (อนาคต) อัปเดตแคตตาล็อกเมื่อเกมออก season ใหม่
|
||||
```bash
|
||||
node scripts/build-firefly-endgame-data.cjs
|
||||
```
|
||||
แล้ว bump `?v=` ของ `assets/firefly-endgame.json` ถ้ามีการอ้างอิงในอนาคต (ปัจจุบัน fetch ตรงด้วย `cache: 'force-cache'`)
|
||||
|
||||
---
|
||||
|
||||
## 7. ข้อจำกัด / ข้อควรระวัง
|
||||
|
||||
1. **แคตตาล็อกเป็น snapshot**: ถ้า Firefly SrTools อัปเดตข้อมูล ต้องรันสคริปต์ใหม่
|
||||
2. **ไอคอนมอนเป็น CDN ภายนอก** (`cdn.punklorde.org`) — ถ้า CDN ล่มจะเห็นอิโมจิ fallback
|
||||
3. **Simulated Universe ไม่อยู่ในขอบเขต** — ต้นฉบับมี แต่รอบนี้ยังไม่ทำ
|
||||
4. **`amount` ถูกเซ็ตเป็น 1 เสมอ** สำหรับห้องปกติ (custom แก้ได้)
|
||||
5. **`extra_data.challenge` อัปเดตเฉพาะโหมด peak** — โหมดอื่นไม่แตะค่าเดิม
|
||||
6. **เขียนทับ `battle_config` ใน local cache**: ถ้าสลับกลับไปโหมด GaCy แล้วใช้ custom wave editor ค่า `amount` จะไม่ถูกอ่าน (GaCy ใช้ `max_hp`) — แต่ GaCy จะโหลด catalog จากเซิร์ฟเวอร์ใหม่เมื่อเชื่อมต่อ
|
||||
7. **`normalizeBattleConfig()` ตัด key ที่ไม่รู้จัก** รวมถึง `custom_battle_lineup`
|
||||
8. **ห้ามใส่ header เพิ่มใน `/sync`** เพราะจะทำให้ preflight fail
|
||||
|
||||
---
|
||||
|
||||
## 8. คู่มือรัน/ตรวจสอบ
|
||||
|
||||
### Unit tests
|
||||
```bash
|
||||
node --test tests/*.test.cjs
|
||||
```
|
||||
|
||||
### Dev server (มี Enka proxy)
|
||||
```bash
|
||||
npm run dev:proxy
|
||||
# หรือ
|
||||
node scripts/dev-server.cjs
|
||||
```
|
||||
เปิด `http://localhost:5173`
|
||||
> ถ้าเจอ `EADDRINUSE` ให้ปิด process ที่ถือพอร์ต 5173 ก่อน (`Get-NetTCPConnection -LocalPort 5173`)
|
||||
|
||||
### ตรวจ payload ก่อนซิงค์
|
||||
- เปิด DevTools → Network → กรอง `/sync`
|
||||
- ดู Request Payload: ต้องมี `data.battle_config.blessings` เป็น object และ `monsters` เป็น array ของ array ของ object
|
||||
|
||||
### ตรวจ i18n
|
||||
- สลับภาษาไทย/อังกฤษ ขณะอยู่หน้า Monster Setting
|
||||
- ชื่อ season, ชื่อมอน, สถานะ, ปุ่ม ต้องเปลี่ยนตามภาษา
|
||||
- ภาษาที่ไม่มีการแปลจะ fallback เป็นอังกฤษ
|
||||
|
||||
---
|
||||
|
||||
## 9. Checklist ก่อน commit
|
||||
|
||||
- [x] `node --test tests/*.test.cjs` ผ่านทั้งหมด
|
||||
- [x] เปิดเว็บโหมด firefly → Monster Setting แสดง picker
|
||||
- [x] Apply เขียน `battle_config` ถูกต้อง (MOC/PF/AS/PEAK/CE)
|
||||
- [x] Apply & sync แสดง error ที่แปลแล้วเมื่อไม่มีเซิร์ฟเวอร์
|
||||
- [x] เปลี่ยนภาษาแล้วหน้า picker รีเรนเดอร์ และสถานะไม่ค้างที่ loading
|
||||
- [x] มือถือ 360px ไม่มี horizontal scroll และปุ่ม ≥44px
|
||||
- [x] โหมด GaCy เดิมยังทำงาน (Monster Setting เดิม, Aha's Help, custom waves)
|
||||
- [x] `?v=` ถูก bump ครบ (`styles.css 2.36`, `app.js 3.32`, `i18n.js 2.26` ทุก importer)
|
||||
- [x] ไม่มีข้อความ hardcode ใหม่ใน HTML/JS
|
||||
- [x] ไม่แก้ `README.md`
|
||||
- [ ] ทดสอบ `POST /sync` happy path กับ FireflyGO จริง (ต้องมีเซิร์ฟเวอร์)
|
||||
@@ -0,0 +1,289 @@
|
||||
# เอกสารข้อมูลจำเพาะและการเชื่อมต่อ API: March7thHoney
|
||||
> **Firefly_Srtools / SR-Tools to March7thHoney Integration Guide**
|
||||
|
||||
เอกสารฉบับนี้อธิบายรายละเอียดทางเทคนิคของโปรโตคอลการเชื่อมต่อ API ระหว่างเครื่องมือจัดการตัวละคร (**Firefly_Srtools / SR-Tools**) และเซิร์ฟเวอร์จำลอง **March7thHoney** (Honkai: Star Rail Private Server) โดยอ้างอิงจาก Source Code ล่าสุดของโปรเจกต์ `Firefly_Srtools`
|
||||
|
||||
---
|
||||
|
||||
## 1. ภาพรวมสถาปัตยกรรม (Architecture Overview)
|
||||
|
||||
ระบบการเชื่อมต่อแบบ **March7thHoney** ได้รับการออกแบบมาสำหรับการเชื่อมต่อแบบ Client-to-Server ผ่านทาง REST API โดยมีลำดับขั้นตอนหลัก 2 สถานะ:
|
||||
1. **Connection Probe (Handshake):** ตรวจสอบการมีอยู่ของเซิร์ฟเวอร์ และยืนยันตัวตน (Authentication)
|
||||
2. **Data Synchronization:** ส่งข้อมูลการจัดทีม, ตัวละคร, กรวยแสง, และรีลิกส์ (FreeSR Data Payload) เข้าสู่เซิร์ฟเวอร์
|
||||
|
||||
### ไดอะแกรมลำดับการทำงาน (Sequence Diagram)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
actor User as ผู้ใช้งาน (Web UI)
|
||||
participant Client as Firefly_Srtools (Browser)
|
||||
participant Server as March7thHoney Server
|
||||
|
||||
Note over User,Client: ขั้นตอนที่ 1: ตรวจสอบการเชื่อมต่อ (Connect PS)
|
||||
User->>Client: กรอก Server URL, Username, Password แล้วกด "Connect PS"
|
||||
Client->>Client: Normalizes URL (เติม scheme + ต่อท้าย /sr-tools)
|
||||
Client->>Server: POST /sr-tools { username, password }
|
||||
Server-->>Client: HTTP 200 OK { status: 200, message: "OK" }
|
||||
Client-->>User: แสดงสถานะ 🟢 Connected
|
||||
|
||||
Note over User,Client: ขั้นตอนที่ 2: ซิงค์ข้อมูลเข้าเกม (Sync)
|
||||
User->>Client: จัดทีม/แก้ไขตัวละคร แล้วกด "Sync"
|
||||
Client->>Client: แปลงข้อมูลเป็น FreeSR JSON Schema
|
||||
Client->>Server: POST /sr-tools { username, password, data: FreeSRJson }
|
||||
Server->>Server: บันทึกข้อมูลลงฐานข้อมูล/หน่วยความจำของเซิร์ฟเวอร์
|
||||
Server-->>Client: HTTP 200 OK { status: 200, message: "Sync Success" }
|
||||
Client-->>User: แจ้งเตือน ซิงค์ข้อมูลสำเร็จ
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. ข้อมูลจำเพาะของ Endpoint (Endpoint Specification)
|
||||
|
||||
* **Path Suffix:** `/sr-tools`
|
||||
* **HTTP Method:** `POST`
|
||||
* **Request Content-Type:** `application/json`
|
||||
* **Accept:** `application/json, text/plain`
|
||||
|
||||
### กฎการจัดรูปแบบ URL (URL Normalization Rules)
|
||||
ในฟังก์ชัน `buildConnectUrl('March7thHoney', serverUrl)` ของระบบ:
|
||||
1. **การเติม Scheme:** หากผู้ใช้ไม่ได้ระบุโปรโตคอล (`http://` หรือ `https://`) ระบบจะเติม `http://` ให้โดยอัตโนมัติ
|
||||
2. **การตัด Trailing Slashes:** ตัดเครื่องหมาย `/` ส่วนเกินที่ท้าย URL ออก
|
||||
3. **การตรวจสอบ Path ท้าย:** ตรวจสอบว่า URL ลงท้ายด้วย `/sr-tools` (Case-Insensitive) หรือไม่ หากยังไม่มีจะทำการเติม `/sr-tools` ต่อท้าย
|
||||
|
||||
**ตัวอย่างการแปลง URL:**
|
||||
| ค่าที่ผู้ใช้กรอกใน UI | URL ปลายทางที่ระบบยิง Request |
|
||||
| :--- | :--- |
|
||||
| `localhost:21000` | `http://localhost:21000/sr-tools` |
|
||||
| `http://127.0.0.1:21000/` | `http://127.0.0.1:21000/sr-tools` |
|
||||
| `https://ps.example.com` | `https://ps.example.com/sr-tools` |
|
||||
| `http://192.168.1.50:21000/sr-tools` | `http://192.168.1.50:21000/sr-tools` (ไม่เติมซ้ำ) |
|
||||
|
||||
---
|
||||
|
||||
## 3. รูปแบบข้อมูล Request Payload (Request Schemas)
|
||||
|
||||
### 3.1 การเชื่อมต่อครั้งแรก (Handshake / Connection Probe)
|
||||
เมื่อผู้ใช้กดปุ่ม **"Connect PS"** จะเป็นการส่ง Request เพื่อตรวจสอบความถูกต้องของ URL และบัญชีผู้ใช้:
|
||||
|
||||
```json
|
||||
{
|
||||
"username": "10001",
|
||||
"password": "UserPassword123"
|
||||
}
|
||||
```
|
||||
|
||||
* `username` *(string, required)*: ชื่อผู้ใช้ หรือ UID บัญชีในเกม
|
||||
* `password` *(string, required)*: รหัสผ่านของบัญชีผู้ใช้
|
||||
* `data` *(undefined)*: ในการ Probe เชื่อมต่อครั้งแรก จะไม่ส่งฟิลด์นี้
|
||||
|
||||
> [!NOTE]
|
||||
> **การรักษาความปลอดภัยของรหัสผ่าน:** รหัสผ่านจะถูกจัดเก็บไว้ใน `sessionStorage` ชั่วคราวเท่านั้น และจะไม่ถูกเขียนลง `localStorage` ถาวร เพื่อป้องกันการถูกดึงข้อมูลเมื่อปิดเบราว์เซอร์
|
||||
|
||||
---
|
||||
|
||||
### 3.2 การส่งข้อมูลเข้าเซิร์ฟเวอร์ (Data Synchronization)
|
||||
เมื่อผู้ใช้กดปุ่ม **"Sync"** ข้อมูลทีมและตัวละครจะถูกรวบรวมเป็นก้อน `FreeSRJson` แนบไปด้วย:
|
||||
|
||||
```json
|
||||
{
|
||||
"username": "10001",
|
||||
"password": "UserPassword123",
|
||||
"data": {
|
||||
"avatars": [
|
||||
{
|
||||
"avatar_id": 1307,
|
||||
"level": 80,
|
||||
"rank": 6,
|
||||
"promotion": 6,
|
||||
"skills": {
|
||||
"1307001": 9,
|
||||
"1307002": 10,
|
||||
"1307003": 10,
|
||||
"1307004": 10
|
||||
},
|
||||
"equipment": {
|
||||
"tid": 23014,
|
||||
"rank": 5,
|
||||
"level": 80,
|
||||
"promotion": 6
|
||||
},
|
||||
"relics": [
|
||||
{
|
||||
"tid": 61121,
|
||||
"level": 15,
|
||||
"main_affix_id": 1,
|
||||
"sub_affixes": [
|
||||
{ "sub_affix_id": 1, "cnt": 2, "step": 1 },
|
||||
{ "sub_affix_id": 2, "cnt": 3, "step": 2 }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"battle_type": "Stage",
|
||||
"stage_id": 1001,
|
||||
"lineup": [1307]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **ความแตกต่างระหว่าง March7thHoney และ FireflyGo:**
|
||||
> โหมด `March7thHoney` จะส่งเพียง 3 ฟิลด์คือ `{ username, password, data }` เท่านั้น จะ**ไม่มี**การส่งก้อน `extra_data` (เช่น `multi_path` ของตัวเอก/March 7th หรือ `lua` script) ไปยังเซิร์ฟเวอร์
|
||||
|
||||
---
|
||||
|
||||
## 4. ข้อกำหนดการตอบกลับของ Server (Server Response Specification)
|
||||
|
||||
เพื่อให้ Client แสดงผลว่าเชื่อมต่อสำเร็จ เซิร์ฟเวอร์ต้องตอบกลับตามเกณฑ์ดังต่อไปนี้:
|
||||
|
||||
### 4.1 เกณฑ์การเชื่อมต่อสำเร็จ (Success Condition)
|
||||
1. **HTTP Status Code:** ต้องอยู่ในช่วง `200` ถึง `299`
|
||||
2. **Content Format:** ต้องเป็น JSON หรือ Text (ห้ามขึ้นต้นด้วย Tag HTML `<`)
|
||||
3. **Response Body:** ไม่มีฟิลด์ `error` (หรือ `error` เป็นสตริงว่าง)
|
||||
|
||||
**ตัวอย่าง Response ที่ถูกต้อง:**
|
||||
```json
|
||||
{
|
||||
"status": 200,
|
||||
"message": "Connection established successfully"
|
||||
}
|
||||
```
|
||||
|
||||
หรือสามารถส่งสถิติสรุปตัวละครกลับมาได้:
|
||||
```json
|
||||
{
|
||||
"status": 200,
|
||||
"message": "Sync successful",
|
||||
"stats": {
|
||||
"avatars": 4,
|
||||
"created": 1,
|
||||
"updated": 3,
|
||||
"deleted": 0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4.2 เกณฑ์การเชื่อมต่อล้มเหลว (Failure Condition)
|
||||
Client จะถือว่าการเชื่อมต่อล้มเหลว (`success: false`) ทันทีเมื่อ:
|
||||
* **HTTP Status >= 400:** เช่น 401 Unauthorized, 404 Not Found, 500 Internal Server Error
|
||||
* **มี Error Field ใน JSON:** แม้ HTTP Status จะเป็น 200 แต่ถ้ามี `"error": "Invalid password"` จะถูกลดระดับเป็นสถานะ 400
|
||||
* **ได้หน้า HTML กลับมา (HTML Detection):** หากเซิร์ฟเวอร์ส่งหน้าเว็บ 404/502 ของ Nginx หรือ Cloudflare ที่มีแท็ก `<html>` ฟังก์ชัน `looksLikeHtml()` จะดักจับและตัดการเชื่อมต่อทันที
|
||||
|
||||
---
|
||||
|
||||
## 5. ตัวอย่างการสร้างเซิร์ฟเวอร์รองรับ Endpoint `/sr-tools`
|
||||
|
||||
### 5.1 ตัวอย่างด้วย Node.js (Express)
|
||||
```javascript
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const app = express();
|
||||
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
// Endpoint สำหรับ March7thHoney
|
||||
app.post('/sr-tools', (req, res) => {
|
||||
const { username, password, data } = req.body;
|
||||
|
||||
// 1. ตรวจสอบข้อมูลการเข้าสู่ระบบ
|
||||
if (!username || password !== 'ExpectedPassword') {
|
||||
return res.status(401).json({
|
||||
status: 401,
|
||||
error: 'Invalid username or password'
|
||||
});
|
||||
}
|
||||
|
||||
// 2. กรณี Handshake (Connect PS) - ยังไม่มี data ส่งมา
|
||||
if (!data) {
|
||||
return res.status(200).json({
|
||||
status: 200,
|
||||
message: 'March7thHoney handshake successful'
|
||||
});
|
||||
}
|
||||
|
||||
// 3. กรณี Sync Data - บันทึกข้อมูลตัวละคร
|
||||
console.log(`Received FreeSR lineup for ${username}:`, data.avatars?.length, 'avatars');
|
||||
// บันทึกลง Database / Memory ของเซิร์ฟเวอร์...
|
||||
|
||||
return res.status(200).json({
|
||||
status: 200,
|
||||
message: 'Data synchronized successfully',
|
||||
stats: {
|
||||
avatars: data.avatars?.length || 0,
|
||||
created: 0,
|
||||
updated: data.avatars?.length || 0,
|
||||
deleted: 0
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
app.listen(21000, () => console.log('March7thHoney SR-Tools API listening on port 21000'));
|
||||
```
|
||||
|
||||
### 5.2 ตัวอย่างด้วย Python (FastAPI)
|
||||
```python
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, Any, Dict
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
class SRToolsPayload(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
data: Optional[Dict[str, Any]] = None
|
||||
|
||||
@app.post("/sr-tools")
|
||||
async def handle_sr_tools(payload: SRToolsPayload):
|
||||
# ยืนยันตัวตน
|
||||
if payload.username != "10001" or payload.password != "SecretPass":
|
||||
raise HTTPException(status_code=401, detail="Authentication failed")
|
||||
|
||||
# Handshake
|
||||
if payload.data is None:
|
||||
return {"status": 200, "message": "Handshake OK"}
|
||||
|
||||
# Sync Data
|
||||
avatars_count = len(payload.data.get("avatars", []))
|
||||
return {
|
||||
"status": 200,
|
||||
"message": "Data synchronized successfully",
|
||||
"stats": {"avatars": avatars_count, "created": 0, "updated": avatars_count, "deleted": 0}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. ตารางเปรียบเทียบ Connection Types ใน Firefly_Srtools
|
||||
|
||||
| รูปแบบการเชื่อมต่อ | URL Endpoint เริ่มต้น | ต้องกรอก URL เอง? | ต้องกรอก Password? | รูปแบบ Payload |
|
||||
| :--- | :--- | :---: | :---: | :--- |
|
||||
| **FireflyGo** | `http://localhost:21000/sync` | ❌ อัตโนมัติ | ❌ ไม่ต้อง | ส่ง `extra_data` (Multi-path, Lua) |
|
||||
| **RobinSR** | `http://localhost:21000/srtools` | ❌ อัตโนมัติ | ❌ ไม่ต้อง | ส่ง `extra_data` |
|
||||
| **March7thHoney** | `{Server_URL}/sr-tools` | ต้องกรอก | ต้องกรอก | `{ username, password, data }` |
|
||||
| **Collapse-RPG** | `{Server_URL}/query_connection` | ต้องกรอก | ต้องกรอก | แปลงเป็น `CollapseAvatarData` Schema |
|
||||
| **Other** | `{Server_URL}` | ต้องกรอก | ต้องกรอก | ส่งแบบ Raw FreeSRJson หรือผ่าน Proxy |
|
||||
|
||||
---
|
||||
|
||||
## 7. ข้อควรระวังและการแก้ปัญหา (Troubleshooting)
|
||||
|
||||
1. **ปัญหา CORS (Cross-Origin Resource Sharing):**
|
||||
* หากเข้าใช้งาน Firefly_Srtools ผ่านเบราว์เซอร์บน Web Domain แต่ Server ของ March7thHoney รันอยู่ที่เครื่องอื่น ต้องเปิด Header `Access-Control-Allow-Origin: *` บนเซิร์ฟเวอร์
|
||||
2. **ปัญหา Mixed Content (HTTP vs HTTPS):**
|
||||
* หากหน้าเว็บ Firefly_Srtools เปิดด้วย `https://` แต่เซิร์ฟเวอร์ March7thHoney เป็น `http://` เบราว์เซอร์จะบล็อกการยิง Request อัตโนมัติ ต้องใช้งานผ่าน `http://` เหมือนกัน หรือติดตั้ง SSL ให้กับเซิร์ฟเวอร์
|
||||
3. **ปัญหา Private Host / Localhost:**
|
||||
* หากใช้งานโหมด Proxy ของเว็บ โฮสต์ที่เป็น Local IP (192.168.x.x, 127.0.0.1) จะยิงไม่ผ่าน ต้องเชื่อมต่อแบบ Local Mode เท่านั้น
|
||||
+74
-42
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>GaCy-Srtool</title>
|
||||
<link rel="stylesheet" href="css/styles.css?v=2.35">
|
||||
<link rel="stylesheet" href="css/styles.css?v=2.36">
|
||||
<link rel="stylesheet" href="css/item-catalog.css?v=3">
|
||||
<link rel="icon" type="image/png" href="assets/logo.png">
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js"></script>
|
||||
@@ -485,50 +485,79 @@
|
||||
|
||||
<!-- Monster settings: resource-backed stage inspection plus Calyx-only custom waves. -->
|
||||
<section id="tab-monster-settings" class="tab-content">
|
||||
<div class="card monster-settings-intro">
|
||||
<div class="card-title"><span data-i18n="monsterSettingsTitle">👾 Monster Setting</span></div>
|
||||
<p class="card-subtitle" data-i18n="monsterSettingsDescription">ตรวจสอบข้อมูลด่านและคลื่นมอนจาก resource ของ Client ตามเวอร์ชัน ปรับ custom monster ได้เฉพาะ Golden / Crimson Calyx เท่านั้น</p>
|
||||
<div class="monster-settings-rule" role="note">
|
||||
<span aria-hidden="true">🔒</span>
|
||||
<span data-i18n="monsterSettingsRule">Weekly Boss, Stagnant Shadow, Cavern of Corrosion และ Endgame ใช้ข้อมูลด่านจาก Client และแก้คลื่นมอนผ่าน Srtool ไม่ได้</span>
|
||||
<div id="monster-settings-gac">
|
||||
<div class="card monster-settings-intro">
|
||||
<div class="card-title"><span data-i18n="monsterSettingsTitle">👾 Monster Setting</span></div>
|
||||
<p class="card-subtitle" data-i18n="monsterSettingsDescription">ตรวจสอบข้อมูลด่านและคลื่นมอนจาก resource ของ Client ตามเวอร์ชัน ปรับ custom monster ได้เฉพาะ Golden / Crimson Calyx เท่านั้น</p>
|
||||
<div class="monster-settings-rule" role="note">
|
||||
<span aria-hidden="true">🔒</span>
|
||||
<span data-i18n="monsterSettingsRule">Weekly Boss, Stagnant Shadow, Cavern of Corrosion และ Endgame ใช้ข้อมูลด่านจาก Client และแก้คลื่นมอนผ่าน Srtool ไม่ได้</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="monster-settings-tabs" role="tablist" aria-label="Monster settings modes">
|
||||
<button type="button" class="monster-settings-tab active" role="tab" aria-selected="true" data-monster-mode="moc" data-i18n="monsterModeMoc">Memory of Chaos</button>
|
||||
<button type="button" class="monster-settings-tab" role="tab" aria-selected="false" data-monster-mode="pf" data-i18n="monsterModePf">Pure Fiction</button>
|
||||
<button type="button" class="monster-settings-tab" role="tab" aria-selected="false" data-monster-mode="as" data-i18n="monsterModeAs">Apocalyptic Shadow</button>
|
||||
<button type="button" class="monster-settings-tab" role="tab" aria-selected="false" data-monster-mode="aa" data-i18n="monsterModeAa">Anomaly Arbitration</button>
|
||||
<button type="button" class="monster-settings-tab monster-settings-tab-aha" id="monster-mode-aha" role="tab" aria-selected="false" aria-controls="monster-settings-aha-help" data-monster-mode="aha" data-i18n="ahaHelpTitle">Aha's Help</button>
|
||||
<button type="button" class="monster-settings-tab" role="tab" aria-selected="false" data-monster-mode="custom" data-i18n="monsterModeCustom">Custom Enemy</button>
|
||||
<button type="button" class="monster-settings-tab" role="tab" aria-selected="false" data-monster-mode="su" data-i18n="monsterModeSu">Simulated Universe</button>
|
||||
</div>
|
||||
<div id="monster-settings-status" class="monster-settings-status" role="status" aria-live="polite" data-i18n="monsterSettingsLoading">กำลังอ่าน stage จาก resource ของ Client...</div>
|
||||
<div id="monster-settings-view" class="monster-settings-view" role="tabpanel" aria-live="polite"></div>
|
||||
|
||||
<div id="monster-settings-aha-help" class="monster-settings-aha-help" role="tabpanel" aria-labelledby="monster-mode-aha" hidden>
|
||||
<div class="aha-help-card" id="aha-help-card">
|
||||
<div class="aha-help-heading">
|
||||
<div class="aha-help-emblem" aria-hidden="true">✦</div>
|
||||
<div class="aha-help-heading-copy">
|
||||
<h3 data-i18n="ahaHelpTitle">Aha's Help</h3>
|
||||
<p data-i18n="ahaHelpDescription">Choose the support level for the next battle that includes Voracity Corruption. This setting is separate from Aha Global.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="aha-help-options" id="aha-help-options" role="group" aria-label="Aha's Help level" aria-describedby="aha-help-status"></div>
|
||||
<select id="aha-help-level" class="aha-help-native-select" aria-hidden="true" tabindex="-1" disabled>
|
||||
<option value="" data-i18n="ahaHelpLoading">Loading levels…</option>
|
||||
</select>
|
||||
<div class="aha-help-actions">
|
||||
<div class="aha-help-save-state" id="aha-help-status" role="status" aria-live="polite" data-state="loading">
|
||||
<span class="aha-help-state-dot" aria-hidden="true"></span>
|
||||
<span id="aha-help-status-text" data-i18n="ahaHelpLoading">Loading levels…</span>
|
||||
</div>
|
||||
<button type="button" class="btn btn-success aha-help-save-button" id="btn-save-aha-help" disabled>
|
||||
<span aria-hidden="true">💾</span>
|
||||
<span data-i18n="ahaHelpSave">Save Aha's Help</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="monster-settings-tabs" role="tablist" aria-label="Monster settings modes">
|
||||
<button type="button" class="monster-settings-tab active" role="tab" aria-selected="true" data-monster-mode="moc" data-i18n="monsterModeMoc">Memory of Chaos</button>
|
||||
<button type="button" class="monster-settings-tab" role="tab" aria-selected="false" data-monster-mode="pf" data-i18n="monsterModePf">Pure Fiction</button>
|
||||
<button type="button" class="monster-settings-tab" role="tab" aria-selected="false" data-monster-mode="as" data-i18n="monsterModeAs">Apocalyptic Shadow</button>
|
||||
<button type="button" class="monster-settings-tab" role="tab" aria-selected="false" data-monster-mode="aa" data-i18n="monsterModeAa">Anomaly Arbitration</button>
|
||||
<button type="button" class="monster-settings-tab monster-settings-tab-aha" id="monster-mode-aha" role="tab" aria-selected="false" aria-controls="monster-settings-aha-help" data-monster-mode="aha" data-i18n="ahaHelpTitle">Aha's Help</button>
|
||||
<button type="button" class="monster-settings-tab" role="tab" aria-selected="false" data-monster-mode="custom" data-i18n="monsterModeCustom">Custom Enemy</button>
|
||||
<button type="button" class="monster-settings-tab" role="tab" aria-selected="false" data-monster-mode="su" data-i18n="monsterModeSu">Simulated Universe</button>
|
||||
</div>
|
||||
<div id="monster-settings-status" class="monster-settings-status" role="status" aria-live="polite" data-i18n="monsterSettingsLoading">กำลังอ่าน stage จาก resource ของ Client...</div>
|
||||
<div id="monster-settings-view" class="monster-settings-view" role="tabpanel" aria-live="polite"></div>
|
||||
<div id="monster-settings-firefly" hidden>
|
||||
<div class="card monster-settings-intro">
|
||||
<div class="card-title"><span data-i18n="ffEndgameTitle">🦋 FireflyGO Endgame Room</span></div>
|
||||
<p class="card-subtitle" data-i18n="ffEndgameDescription">เลือกด่าน endgame และคลื่นมอนที่ FireflyGO จะใช้ แล้วเขียนลง battle_config โดยตรง</p>
|
||||
<div class="monster-settings-rule" role="note">
|
||||
<span aria-hidden="true">🎯</span>
|
||||
<span data-i18n="ffEndgameRule">ค่าเหล่านี้เขียนลง battle_config (และ extra_data.challenge สำหรับ Anomaly Arbitration) จากนั้นบันทึกในเครื่องหรือซิงค์ขึ้น FireflyGO</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="monster-settings-aha-help" class="monster-settings-aha-help" role="tabpanel" aria-labelledby="monster-mode-aha" hidden>
|
||||
<div class="aha-help-card" id="aha-help-card">
|
||||
<div class="aha-help-heading">
|
||||
<div class="aha-help-emblem" aria-hidden="true">✦</div>
|
||||
<div class="aha-help-heading-copy">
|
||||
<h3 data-i18n="ahaHelpTitle">Aha's Help</h3>
|
||||
<p data-i18n="ahaHelpDescription">Choose the support level for the next battle that includes Voracity Corruption. This setting is separate from Aha Global.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="aha-help-options" id="aha-help-options" role="group" aria-label="Aha's Help level" aria-describedby="aha-help-status"></div>
|
||||
<select id="aha-help-level" class="aha-help-native-select" aria-hidden="true" tabindex="-1" disabled>
|
||||
<option value="" data-i18n="ahaHelpLoading">Loading levels…</option>
|
||||
</select>
|
||||
<div class="aha-help-actions">
|
||||
<div class="aha-help-save-state" id="aha-help-status" role="status" aria-live="polite" data-state="loading">
|
||||
<span class="aha-help-state-dot" aria-hidden="true"></span>
|
||||
<span id="aha-help-status-text" data-i18n="ahaHelpLoading">Loading levels…</span>
|
||||
</div>
|
||||
<button type="button" class="btn btn-success aha-help-save-button" id="btn-save-aha-help" disabled>
|
||||
<span aria-hidden="true">💾</span>
|
||||
<span data-i18n="ahaHelpSave">Save Aha's Help</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="monster-settings-tabs" id="ff-endgame-tabs" role="tablist" aria-label="FireflyGO endgame room modes" data-i18n-aria-label="ffEndgameTablistLabel">
|
||||
<button type="button" class="monster-settings-tab active" role="tab" aria-selected="true" data-ff-endgame-mode="moc" data-i18n="monsterModeMoc">Memory of Chaos</button>
|
||||
<button type="button" class="monster-settings-tab" role="tab" aria-selected="false" data-ff-endgame-mode="pf" data-i18n="monsterModePf">Pure Fiction</button>
|
||||
<button type="button" class="monster-settings-tab" role="tab" aria-selected="false" data-ff-endgame-mode="as" data-i18n="monsterModeAs">Apocalyptic Shadow</button>
|
||||
<button type="button" class="monster-settings-tab" role="tab" aria-selected="false" data-ff-endgame-mode="peak" data-i18n="ffEndgameModePeak">Anomaly Arbitration</button>
|
||||
<button type="button" class="monster-settings-tab" role="tab" aria-selected="false" data-ff-endgame-mode="custom" data-i18n="monsterModeCustom">Custom Enemy</button>
|
||||
</div>
|
||||
<div id="ff-endgame-status" class="monster-settings-status" role="status" aria-live="polite" data-state="loading" data-i18n="ffEndgameLoading">กำลังโหลดข้อมูลด่าน endgame…</div>
|
||||
<div id="ff-endgame-view" class="monster-settings-view" role="tabpanel" aria-live="polite"></div>
|
||||
|
||||
<div class="ff-endgame-actions">
|
||||
<button type="button" class="btn btn-secondary" id="ff-endgame-reset" data-i18n="ffEndgameReset">↺ รีเซ็ตค่าเริ่มต้น</button>
|
||||
<button type="button" class="btn btn-primary" id="ff-endgame-apply" data-i18n="ffEndgameApply">💾 ใช้กับ battle_config</button>
|
||||
<button type="button" class="btn btn-success" id="ff-endgame-apply-sync" data-i18n="ffEndgameApplySync">🚀 ใช้และซิงค์</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -1006,6 +1035,9 @@
|
||||
|
||||
<div class="ff-group">
|
||||
<div class="ff-group-title" data-i18n="fireflyTheoryTitle">🧪 Theory Craft</div>
|
||||
<p class="form-hint" style="margin: 0; font-size: 0.78rem; color: var(--text-muted);" data-i18n="fireflyTheoryModeHint">
|
||||
สวิตช์นี้คือโหมดสร้างสรรค์ (Creative Mode) — เปิดจากที่ไหนก็ได้ อีกที่จะตามให้อัตโนมัติ
|
||||
</p>
|
||||
<label class="ff-toggle">
|
||||
<span data-i18n="fireflyTheoryMode">Enable theory craft</span>
|
||||
<span class="switch"><input type="checkbox" id="ff-theory-mode"><span class="slider"></span></span>
|
||||
@@ -2047,6 +2079,6 @@
|
||||
<div class="toast-container" id="toast-container"></div>
|
||||
|
||||
<!-- Script Modules -->
|
||||
<script type="module" src="js/app.js?v=3.28"></script>
|
||||
<script type="module" src="js/app.js?v=3.32"></script>
|
||||
</body>
|
||||
</html>
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { getCurrentLanguage, translateElement, translatePath } from "./i18n.js?v=2.23";
|
||||
import { getCurrentLanguage, translateElement, translatePath } from "./i18n.js?v=2.26";
|
||||
|
||||
export const AVATAR_METADATA = {
|
||||
"1001": {
|
||||
|
||||
+305
@@ -0,0 +1,305 @@
|
||||
// FireflyGo endgame (Monster Setting) helpers.
|
||||
//
|
||||
// The catalog is a compact extract of the Firefly SrTools client tables:
|
||||
// modes.<moc|pf|as>.groups[] -> levels[] (floors) -> sides[]
|
||||
// modes.peak.groups[] -> stages[]
|
||||
// monsters[id] -> { name, rank, stance, weak, icon }
|
||||
//
|
||||
// Every helper here is pure so it can be unit tested with node:test.
|
||||
|
||||
export const FIREFLY_ENDGAME_CATALOG_URL = './assets/firefly-endgame.json';
|
||||
|
||||
export const ENDGAME_MODES = ['moc', 'pf', 'as', 'peak', 'custom'];
|
||||
|
||||
export const ENDGAME_BATTLE_TYPES = {
|
||||
moc: 'MOC',
|
||||
pf: 'PF',
|
||||
as: 'AS',
|
||||
peak: 'PEAK',
|
||||
custom: 'CE'
|
||||
};
|
||||
|
||||
export function battleTypeForMode(mode) {
|
||||
return ENDGAME_BATTLE_TYPES[mode] || 'DEFAULT';
|
||||
}
|
||||
|
||||
function cloneJson(value) {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
|
||||
function num(value, fallback = 0) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
}
|
||||
|
||||
export function groupList(catalog, mode) {
|
||||
return Array.isArray(catalog?.modes?.[mode]?.groups) ? catalog.modes[mode].groups : [];
|
||||
}
|
||||
|
||||
export function findGroup(catalog, mode, groupId) {
|
||||
const groups = groupList(catalog, mode);
|
||||
return groups.find((group) => Number(group.id) === Number(groupId)) || null;
|
||||
}
|
||||
|
||||
export function findLevel(group, levelId) {
|
||||
const levels = Array.isArray(group?.levels) ? group.levels : [];
|
||||
return levels.find((level) => Number(level.id) === Number(levelId)) || null;
|
||||
}
|
||||
|
||||
function newestGroup(catalog, mode) {
|
||||
const groups = groupList(catalog, mode);
|
||||
return groups.slice().sort((a, b) => Number(b.id) - Number(a.id))[0] || null;
|
||||
}
|
||||
|
||||
function highestLevel(group) {
|
||||
const levels = Array.isArray(group?.levels) ? group.levels : [];
|
||||
return levels.slice().sort((a, b) => Number(b.floor) - Number(a.floor))[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks the sensible default for a mode: newest season, highest floor and the
|
||||
* first side. Peak defaults to the boss stage because that is the room players
|
||||
* usually want to test.
|
||||
*/
|
||||
export function defaultEndgameSelection(catalog, mode) {
|
||||
if (mode === 'custom') {
|
||||
return {
|
||||
mode,
|
||||
customWaves: [[{ monster_id: 0, level: 80 }]],
|
||||
customCycle: 0
|
||||
};
|
||||
}
|
||||
|
||||
const group = newestGroup(catalog, mode);
|
||||
if (!group) {
|
||||
return { mode, groupId: null, levelId: null, stageIndex: 0, side: 'firstNode', useCycle: true, useBuff: true };
|
||||
}
|
||||
|
||||
if (mode === 'peak') {
|
||||
const stages = Array.isArray(group.stages) ? group.stages : [];
|
||||
const bossIndex = stages.findIndex((stage) => stage.kind === 'boss');
|
||||
return {
|
||||
mode,
|
||||
groupId: Number(group.id),
|
||||
stageIndex: bossIndex >= 0 ? bossIndex : Math.max(0, stages.length - 1),
|
||||
useCycle: true,
|
||||
useBuff: true
|
||||
};
|
||||
}
|
||||
|
||||
const level = highestLevel(group);
|
||||
const sides = Array.isArray(level?.sides) ? level.sides : [];
|
||||
const side = sides.some((entry) => entry.side === 'firstNode') ? 'firstNode' : (sides[0]?.side || 'firstNode');
|
||||
return {
|
||||
mode,
|
||||
groupId: Number(group.id),
|
||||
levelId: level ? Number(level.id) : null,
|
||||
side,
|
||||
useCycle: true,
|
||||
useBuff: true
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a selection against the catalog. Returns null when the catalog has
|
||||
* no group/stage for the requested mode.
|
||||
*/
|
||||
export function resolveEndgameSelection(catalog, selection) {
|
||||
const mode = selection?.mode;
|
||||
if (!ENDGAME_MODES.includes(mode)) return null;
|
||||
if (mode === 'custom') return { mode: 'custom', custom: true };
|
||||
|
||||
const groups = groupList(catalog, mode);
|
||||
if (!groups.length) return null;
|
||||
const group = findGroup(catalog, mode, selection.groupId) || newestGroup(catalog, mode);
|
||||
if (!group) return null;
|
||||
|
||||
if (mode === 'peak') {
|
||||
const stages = Array.isArray(group.stages) ? group.stages : [];
|
||||
if (!stages.length) return null;
|
||||
const rawIndex = num(selection.stageIndex, 0);
|
||||
const index = Math.min(Math.max(rawIndex, 0), stages.length - 1);
|
||||
return { mode, group, stage: stages[index], stageIndex: index };
|
||||
}
|
||||
|
||||
const level = findLevel(group, selection.levelId) || highestLevel(group);
|
||||
if (!level) return null;
|
||||
const sides = Array.isArray(level.sides) ? level.sides : [];
|
||||
if (!sides.length) return null;
|
||||
const stage = sides.find((entry) => entry.side === selection.side) || sides[0];
|
||||
return { mode, group, level, sides, stage };
|
||||
}
|
||||
|
||||
export function monsterInfo(catalog, monsterId) {
|
||||
return catalog?.monsters?.[String(monsterId)] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuilds a selection from an existing battle_config.stage_id so the picker
|
||||
* opens on the room the save file is already using. Peak seasons are looked up
|
||||
* first in the group referenced by extra_data.challenge when given.
|
||||
*/
|
||||
export function findSelectionByStageId(catalog, mode, stageId, groupId = 0) {
|
||||
if (!ENDGAME_MODES.includes(mode) || mode === 'custom') return null;
|
||||
const groups = groupList(catalog, mode);
|
||||
if (!groups.length) return null;
|
||||
|
||||
const preferred = Number(groupId) > 0 ? findGroup(catalog, mode, groupId) : null;
|
||||
const ordered = preferred ? [preferred, ...groups.filter((group) => group !== preferred)] : groups;
|
||||
const target = num(stageId);
|
||||
|
||||
for (const group of ordered) {
|
||||
if (mode === 'peak') {
|
||||
const stages = Array.isArray(group.stages) ? group.stages : [];
|
||||
const index = stages.findIndex((stage) => Number(stage.stageId) === target);
|
||||
if (index >= 0) {
|
||||
return { mode, groupId: Number(group.id), stageIndex: index, useCycle: true, useBuff: true };
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const levels = Array.isArray(group.levels) ? group.levels : [];
|
||||
for (const level of levels) {
|
||||
const sides = Array.isArray(level.sides) ? level.sides : [];
|
||||
const side = sides.find((entry) => Number(entry.stageId) === target);
|
||||
if (side) {
|
||||
return {
|
||||
mode,
|
||||
groupId: Number(group.id),
|
||||
levelId: Number(level.id),
|
||||
side: side.side,
|
||||
useCycle: true,
|
||||
useBuff: true
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeCustomWaves(waves) {
|
||||
if (!Array.isArray(waves)) return [];
|
||||
return waves
|
||||
.map((wave) => (Array.isArray(wave) ? wave : [])
|
||||
.map((entry) => {
|
||||
const id = num(entry?.monster_id ?? entry?.id ?? entry);
|
||||
if (!id) return null;
|
||||
return {
|
||||
monster_id: id,
|
||||
level: num(entry?.level, 80) || 80,
|
||||
amount: num(entry?.amount, 1) || 1
|
||||
};
|
||||
})
|
||||
.filter(Boolean))
|
||||
.filter((wave) => wave.length > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the exact battle_config FireflyGo validates:
|
||||
* blessings: [{ id, level }]
|
||||
* monsters: [[{ monster_id, level, amount }]]
|
||||
*/
|
||||
export function buildFireflyBattleConfig(catalog, selection) {
|
||||
const resolved = resolveEndgameSelection(catalog, selection);
|
||||
if (!resolved) return null;
|
||||
const type = battleTypeForMode(selection.mode);
|
||||
|
||||
if (resolved.custom) {
|
||||
const waves = normalizeCustomWaves(selection.customWaves);
|
||||
if (!waves.length) return null;
|
||||
return {
|
||||
battle_type: type,
|
||||
blessings: [],
|
||||
custom_stats: [],
|
||||
cycle_count: num(selection.customCycle),
|
||||
stage_id: 0,
|
||||
path_resonance_id: 0,
|
||||
monsters: waves
|
||||
};
|
||||
}
|
||||
|
||||
const { stage } = resolved;
|
||||
const waves = (Array.isArray(stage.waves) ? stage.waves : [])
|
||||
.map((wave) => wave
|
||||
.map((id) => Number(id))
|
||||
.filter((id) => id > 0)
|
||||
.map((id) => ({ monster_id: id, level: Number(stage.level) || 1, amount: 1 })))
|
||||
.filter((wave) => wave.length > 0);
|
||||
|
||||
const blessings = [];
|
||||
if (selection.useBuff !== false) {
|
||||
const buffs = selection.mode === 'peak' ? stage.buffs : resolved.level?.buffs;
|
||||
for (const buff of Array.isArray(buffs) ? buffs : []) {
|
||||
const id = num(buff?.id);
|
||||
if (id > 0) blessings.push({ id, level: 1 });
|
||||
}
|
||||
}
|
||||
if (stage.invasionBuffId) {
|
||||
blessings.push({ id: Number(stage.invasionBuffId), level: 1 });
|
||||
}
|
||||
|
||||
return {
|
||||
battle_type: type,
|
||||
blessings,
|
||||
custom_stats: [],
|
||||
cycle_count: selection.useCycle ? (num(stage.turnLimit) || num(resolved.level?.turnLimit)) : 0,
|
||||
stage_id: num(stage.stageId),
|
||||
path_resonance_id: 0,
|
||||
monsters: waves
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Anomaly Arbitration is selected through extra_data.challenge, so the chosen
|
||||
* Peak season has to travel next to battle_config.
|
||||
*/
|
||||
export function buildFireflyChallenge(selection, current) {
|
||||
const challenge = current && typeof current === 'object' ? cloneJson(current) : {};
|
||||
challenge.skip_node = num(challenge.skip_node);
|
||||
challenge.challenge_peak_group_id = num(challenge.challenge_peak_group_id);
|
||||
challenge.challenge_peak_group_id_list = Array.isArray(challenge.challenge_peak_group_id_list)
|
||||
? challenge.challenge_peak_group_id_list.map((id) => num(id)).filter((id) => id > 0)
|
||||
: [];
|
||||
|
||||
if (selection?.mode === 'peak') {
|
||||
const groupId = num(selection.groupId);
|
||||
if (groupId > 0) {
|
||||
challenge.challenge_peak_group_id = groupId;
|
||||
challenge.challenge_peak_group_id_list = [groupId];
|
||||
}
|
||||
}
|
||||
return challenge;
|
||||
}
|
||||
|
||||
export function summarizeEndgameSelection(catalog, selection) {
|
||||
const resolved = resolveEndgameSelection(catalog, selection);
|
||||
if (!resolved) return null;
|
||||
if (resolved.custom) {
|
||||
const waves = normalizeCustomWaves(selection.customWaves);
|
||||
return {
|
||||
mode: 'custom',
|
||||
battleType: battleTypeForMode('custom'),
|
||||
stageId: 0,
|
||||
waveCount: waves.length,
|
||||
monsterCount: waves.reduce((sum, wave) => sum + wave.length, 0),
|
||||
blessings: 0,
|
||||
cycleCount: num(selection.customCycle)
|
||||
};
|
||||
}
|
||||
const { stage } = resolved;
|
||||
const waves = Array.isArray(stage.waves) ? stage.waves : [];
|
||||
const buffs = (selection.useBuff !== false ? (selection.mode === 'peak' ? stage.buffs : resolved.level?.buffs) : []) || [];
|
||||
return {
|
||||
mode: selection.mode,
|
||||
battleType: battleTypeForMode(selection.mode),
|
||||
stageId: num(stage.stageId),
|
||||
stageLevel: num(stage.level),
|
||||
waveCount: waves.length,
|
||||
monsterCount: waves.reduce((sum, wave) => sum + (wave?.length || 0), 0),
|
||||
blessings: (Array.isArray(buffs) ? buffs.length : 0) + (stage.invasionBuffId ? 1 : 0),
|
||||
cycleCount: selection.useCycle ? (num(stage.turnLimit) || num(resolved.level?.turnLimit)) : 0,
|
||||
groupId: num(resolved.group?.id),
|
||||
groupName: resolved.group?.name || null,
|
||||
floor: resolved.level ? num(resolved.level.floor) : 0,
|
||||
side: stage.side || ''
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getCurrentLanguage, translatePath } from "./i18n.js?v=2.23";
|
||||
import { getCurrentLanguage, translatePath } from "./i18n.js?v=2.26";
|
||||
|
||||
export const LIGHTCONE_METADATA = {
|
||||
"20000": {
|
||||
|
||||
+49
-2
@@ -148,16 +148,63 @@ function normalizeAnchorMap(skillsByAnchorType, skills) {
|
||||
return Object.keys(derived).length > 0 ? derived : undefined;
|
||||
}
|
||||
|
||||
// FireflyGo validates `blessings: [{ id, level, dynamic_key? }]` and rejects
|
||||
// the raw id arrays some FreeSR exports still contain.
|
||||
function normalizeBlessings(value) {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value
|
||||
.map((entry) => {
|
||||
const raw = entry && typeof entry === 'object' ? entry : { id: entry };
|
||||
const id = num(raw.id ?? raw.ID);
|
||||
if (id <= 0) return null;
|
||||
const blessing = { id, level: Math.max(1, num(raw.level, 1) || 1) };
|
||||
const dynamicKey = raw.dynamic_key;
|
||||
if (dynamicKey && typeof dynamicKey === 'object' && dynamicKey.key != null && dynamicKey.key !== '') {
|
||||
blessing.dynamic_key = {
|
||||
key: typeof dynamicKey.key === 'number' ? num(dynamicKey.key) : String(dynamicKey.key),
|
||||
value: num(dynamicKey.value)
|
||||
};
|
||||
}
|
||||
return blessing;
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
// `monsters` is a list of waves; every wave is a list of
|
||||
// `{ monster_id, level, amount }` objects.
|
||||
function normalizeMonsterWaves(value) {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value
|
||||
.map((wave) => {
|
||||
const entries = Array.isArray(wave)
|
||||
? wave
|
||||
: Object.values(wave && typeof wave === 'object' ? wave : {});
|
||||
return entries
|
||||
.map((entry) => {
|
||||
const raw = entry && typeof entry === 'object' ? entry : { monster_id: entry };
|
||||
const monsterId = num(raw.monster_id ?? raw.id);
|
||||
if (monsterId <= 0) return null;
|
||||
return {
|
||||
monster_id: monsterId,
|
||||
level: Math.max(1, num(raw.level, 1) || 1),
|
||||
amount: Math.max(1, num(raw.amount, 1) || 1)
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
})
|
||||
.filter((wave) => wave.length > 0);
|
||||
}
|
||||
|
||||
function normalizeBattleConfig(battleConfig) {
|
||||
const source = battleConfig && typeof battleConfig === 'object' ? battleConfig : {};
|
||||
return {
|
||||
battle_type: typeof source.battle_type === 'string' && source.battle_type ? source.battle_type : 'DEFAULT',
|
||||
blessings: Array.isArray(source.blessings) ? source.blessings : [],
|
||||
blessings: normalizeBlessings(source.blessings),
|
||||
custom_stats: Array.isArray(source.custom_stats) ? source.custom_stats : [],
|
||||
cycle_count: num(source.cycle_count),
|
||||
stage_id: num(source.stage_id),
|
||||
path_resonance_id: num(source.path_resonance_id),
|
||||
monsters: Array.isArray(source.monsters) ? source.monsters : []
|
||||
monsters: normalizeMonsterWaves(source.monsters)
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+105
-4
@@ -7099,7 +7099,7 @@ const MODAL_AND_TOOL_TRANSLATIONS = {
|
||||
fireflyChallengeGroupId: 'Peak group id',
|
||||
fireflyChallengeGroupList: 'รายการ Peak group id',
|
||||
fireflyTheoryTitle: '🧪 Theory Craft',
|
||||
fireflyTheoryMode: 'เปิดโหมด Theory Craft',
|
||||
fireflyTheoryMode: 'เปิดโหมด Theory Craft (= โหมดสร้างสรรค์)',
|
||||
fireflyTheoryCycleCount: 'จำนวนรอบ',
|
||||
fireflyTheoryStageId: 'Stage id',
|
||||
fireflyTheoryLineup: 'ทีมที่กำหนดเอง',
|
||||
@@ -7118,6 +7118,8 @@ const MODAL_AND_TOOL_TRANSLATIONS = {
|
||||
fireflyRequestFailedDetail: 'ไม่สำเร็จ: {message}',
|
||||
fireflyTimeout: 'FireflyGo ไม่ตอบสนอง (หมดเวลา)',
|
||||
fireflyNoData: 'ยังไม่มีข้อมูลตัวละครให้ซิงค์',
|
||||
fireflyPlayerNotLoaded: 'ข้อมูลผู้เล่นยังไม่ได้โหลด กรุณาล็อกอินตัวละครเข้าเกมก่อน',
|
||||
fireflyMixedContentHint: 'เบราว์เซอร์บล็อกการเชื่อมต่อ HTTP จากเว็บ HTTPS กรุณาเปิดเว็บผ่าน localhost หรืออนุญาต Insecure content ในการตั้งค่าไซต์',
|
||||
fireflyExtraSaved: 'บันทึกตัวควบคุม FireflyGo แล้ว',
|
||||
fireflyHpInvalid: 'ค่า HP ต้องเป็น JSON object เช่น {"1001": [12000, 9000]}',
|
||||
|
||||
@@ -7253,7 +7255,55 @@ const MODAL_AND_TOOL_TRANSLATIONS = {
|
||||
relicSetSearchLabel: 'ค้นหาชุดรีลิกส์',
|
||||
relicSetSearchPlaceholder: '🔍 ค้นหาชุดรีลิกส์...',
|
||||
relicSetGridLabel: 'ชุดรีลิกส์',
|
||||
analystDamageAvPlaceholder: "เฉลี่ยต่อรอบ: -", analystCyclesPlaceholder: "0 รอบ", analystTurnsPlaceholder: "0 เทิร์น", analystAutoTurnsPlaceholder: "เทิร์นอัตโนมัติ: 0", analystDamageAvPlaceholder: "Avg per cycle: -", analystCyclesPlaceholder: "0 Cycles", analystTurnsPlaceholder: "0 Turns", analystAutoTurnsPlaceholder: "Auto Turns: 0", analystDamageAvPlaceholder: "ラウンド平均: -", analystCyclesPlaceholder: "0 ラウンド", analystTurnsPlaceholder: "0 ターン", analystAutoTurnsPlaceholder: "オートターン: 0", analystDamageAvPlaceholder: "라운드당 평균: -", analystCyclesPlaceholder: "0 라운드", analystTurnsPlaceholder: "0 턴", analystAutoTurnsPlaceholder: "자동 턴: 0", analystDamageAvPlaceholder: "Trung bình mỗi vòng: -", analystCyclesPlaceholder: "0 vòng", analystTurnsPlaceholder: "0 lượt", analystAutoTurnsPlaceholder: "Lượt tự động: 0", analystDamageAvPlaceholder: "Rata-rata per siklus: -", analystCyclesPlaceholder: "0 Siklus", analystTurnsPlaceholder: "0 Giliran", analystAutoTurnsPlaceholder: "Giliran otomatis: 0", analystDamageAvPlaceholder: "Promedio por ciclo: -", analystCyclesPlaceholder: "0 ciclos", analystTurnsPlaceholder: "0 turnos", analystAutoTurnsPlaceholder: "Turnos automáticos: 0", analystDamageAvPlaceholder: "Moyenne par cycle : -", analystCyclesPlaceholder: "0 cycle", analystTurnsPlaceholder: "0 tour", analystAutoTurnsPlaceholder: "Tours auto : 0", analystDamageAvPlaceholder: "Durchschnitt pro Zyklus: -", analystCyclesPlaceholder: "0 Zyklen", analystTurnsPlaceholder: "0 Züge", analystAutoTurnsPlaceholder: "Auto-Züge: 0", analystDamageAvPlaceholder: "В среднем за цикл: -", analystCyclesPlaceholder: "0 циклов", analystTurnsPlaceholder: "0 ходов", analystAutoTurnsPlaceholder: "Авто-ходы: 0", analystDamageAvPlaceholder: "Média por ciclo: -", analystCyclesPlaceholder: "0 ciclos", analystTurnsPlaceholder: "0 turnos", analystAutoTurnsPlaceholder: "Turnos automáticos: 0", miniViewFullAnalystTooltip: "เปิดหน้า Damage Analyst ตัวเต็ม", miniViewFullAnalystTooltip: "Open full Damage Analyst", miniViewFullAnalystTooltip: "完全なダメージ分析を開く", miniViewFullAnalystTooltip: "전체 데미지 분석 열기", miniViewFullAnalystTooltip: "Mở Trình phân tích sát thương đầy đủ", miniViewFullAnalystTooltip: "Buka Analis Kerusakan Lengkap", miniViewFullAnalystTooltip: "Abrir analizador de daño completo", miniViewFullAnalystTooltip: "Ouvrir l'analyseur de dégâts complet", miniViewFullAnalystTooltip: "Vollständigen Schadensanalysten öffnen", miniViewFullAnalystTooltip: "Открыть полный анализ урона", miniViewFullAnalystTooltip: "Abrir analisador de dano completo", copyCardGenerated: 'สร้างโดย GaCy-Srtool'
|
||||
analystDamageAvPlaceholder: "เฉลี่ยต่อรอบ: -", analystCyclesPlaceholder: "0 รอบ", analystTurnsPlaceholder: "0 เทิร์น", analystAutoTurnsPlaceholder: "เทิร์นอัตโนมัติ: 0", analystDamageAvPlaceholder: "Avg per cycle: -", analystCyclesPlaceholder: "0 Cycles", analystTurnsPlaceholder: "0 Turns", analystAutoTurnsPlaceholder: "Auto Turns: 0", analystDamageAvPlaceholder: "ラウンド平均: -", analystCyclesPlaceholder: "0 ラウンド", analystTurnsPlaceholder: "0 ターン", analystAutoTurnsPlaceholder: "オートターン: 0", analystDamageAvPlaceholder: "라운드당 평균: -", analystCyclesPlaceholder: "0 라운드", analystTurnsPlaceholder: "0 턴", analystAutoTurnsPlaceholder: "자동 턴: 0", analystDamageAvPlaceholder: "Trung bình mỗi vòng: -", analystCyclesPlaceholder: "0 vòng", analystTurnsPlaceholder: "0 lượt", analystAutoTurnsPlaceholder: "Lượt tự động: 0", analystDamageAvPlaceholder: "Rata-rata per siklus: -", analystCyclesPlaceholder: "0 Siklus", analystTurnsPlaceholder: "0 Giliran", analystAutoTurnsPlaceholder: "Giliran otomatis: 0", analystDamageAvPlaceholder: "Promedio por ciclo: -", analystCyclesPlaceholder: "0 ciclos", analystTurnsPlaceholder: "0 turnos", analystAutoTurnsPlaceholder: "Turnos automáticos: 0", analystDamageAvPlaceholder: "Moyenne par cycle : -", analystCyclesPlaceholder: "0 cycle", analystTurnsPlaceholder: "0 tour", analystAutoTurnsPlaceholder: "Tours auto : 0", analystDamageAvPlaceholder: "Durchschnitt pro Zyklus: -", analystCyclesPlaceholder: "0 Zyklen", analystTurnsPlaceholder: "0 Züge", analystAutoTurnsPlaceholder: "Auto-Züge: 0", analystDamageAvPlaceholder: "В среднем за цикл: -", analystCyclesPlaceholder: "0 циклов", analystTurnsPlaceholder: "0 ходов", analystAutoTurnsPlaceholder: "Авто-ходы: 0", analystDamageAvPlaceholder: "Média por ciclo: -", analystCyclesPlaceholder: "0 ciclos", analystTurnsPlaceholder: "0 turnos", analystAutoTurnsPlaceholder: "Turnos automáticos: 0", miniViewFullAnalystTooltip: "เปิดหน้า Damage Analyst ตัวเต็ม", miniViewFullAnalystTooltip: "Open full Damage Analyst", miniViewFullAnalystTooltip: "完全なダメージ分析を開く", miniViewFullAnalystTooltip: "전체 데미지 분석 열기", miniViewFullAnalystTooltip: "Mở Trình phân tích sát thương đầy đủ", miniViewFullAnalystTooltip: "Buka Analis Kerusakan Lengkap", miniViewFullAnalystTooltip: "Abrir analizador de daño completo", miniViewFullAnalystTooltip: "Ouvrir l'analyseur de dégâts complet", miniViewFullAnalystTooltip: "Vollständigen Schadensanalysten öffnen", miniViewFullAnalystTooltip: "Открыть полный анализ урона", miniViewFullAnalystTooltip: "Abrir analisador de dano completo", copyCardGenerated: 'สร้างโดย GaCy-Srtool',
|
||||
// FireflyGo endgame room picker
|
||||
ffEndgameTitle: '🦋 ห้อง Endgame (FireflyGO)',
|
||||
ffEndgameTablistLabel: 'โหมดด่าน endgame ของ FireflyGO',
|
||||
ffEndgameDescription: 'เลือกด่าน endgame และคลื่นมอนที่ FireflyGO จะใช้ แล้วเขียนลง battle_config โดยตรง',
|
||||
ffEndgameRule: 'ค่าเหล่านี้เขียนลง battle_config (และ extra_data.challenge สำหรับ Anomaly Arbitration) จากนั้นบันทึกในเครื่องหรือซิงค์ขึ้น FireflyGO',
|
||||
ffEndgameModePeak: 'Anomaly Arbitration',
|
||||
ffEndgameLoading: 'กำลังโหลดข้อมูลด่าน endgame…',
|
||||
ffEndgameReady: 'พร้อมใช้งาน · เลือกห้องแล้วกดใช้กับ battle_config',
|
||||
ffEndgameCatalogError: 'โหลดข้อมูลด่าน endgame ไม่ได้: {error}',
|
||||
ffEndgameSeason: 'ซีซัน / กลุ่มด่าน',
|
||||
ffEndgameFloor: 'ชั้น',
|
||||
ffEndgameFloorValue: 'ชั้น {floor}',
|
||||
ffEndgameSide: 'ฝั่ง',
|
||||
ffEndgameSideFirst: 'ครึ่งแรก',
|
||||
ffEndgameSideSecond: 'ครึ่งหลัง',
|
||||
ffEndgameSideThird: 'โหนดที่สาม',
|
||||
ffEndgameStage: 'ด่าน',
|
||||
ffEndgameStageValue: 'ด่าน {id}',
|
||||
ffEndgameStageKindPre: 'ด่านก่อนบอส',
|
||||
ffEndgameStageKindBoss: 'บอส',
|
||||
ffEndgameStageKindHard: 'โหมดยาก',
|
||||
ffEndgameStageId: 'stage_id {id}',
|
||||
ffEndgamePreview: 'ตัวอย่าง battle_config',
|
||||
ffEndgameNoStages: 'ไม่พบข้อมูลด่านสำหรับโหมดนี้',
|
||||
ffEndgameUseCycle: 'ใช้จำนวนรอบจากด่าน (TurnLimit)',
|
||||
ffEndgameUseBuff: 'ใช้บัฟของด่าน',
|
||||
ffEndgameCycles: 'รอบ: {count}',
|
||||
ffEndgameBlessings: 'บัฟ: {count}',
|
||||
ffEndgameMonsterCount: 'มอน: {count}',
|
||||
ffEndgameApply: '💾 ใช้กับ battle_config',
|
||||
ffEndgameApplySync: '🚀 ใช้และซิงค์',
|
||||
ffEndgameReset: '↺ รีเซ็ตค่าเริ่มต้น',
|
||||
ffEndgameResetDone: 'รีเซ็ตค่าเริ่มต้นของโหมดนี้แล้ว',
|
||||
ffEndgameSaving: 'กำลังบันทึก battle_config…',
|
||||
ffEndgameSaved: 'บันทึก battle_config ในเครื่องแล้ว',
|
||||
ffEndgameSynced: 'บันทึกและซิงค์ขึ้น FireflyGO แล้ว',
|
||||
ffEndgameApplyError: 'เลือกห้องให้ครบก่อนใช้ค่านี้',
|
||||
ffEndgameNoData: 'ยังไม่มีข้อมูล FreeSR — โหลดหรือซิงค์ข้อมูลก่อน',
|
||||
ffEndgameSyncError: 'ซิงค์ไม่สำเร็จ: {error}',
|
||||
ffEndgameCustomDescription: 'กำหนดคลื่นมอนเองโดยใช้ monster_id จากเกม ค่าที่ใช้ได้ต้องตรงกับที่ FireflyGO รองรับ',
|
||||
ffEndgameAddWave: 'เพิ่มคลื่น',
|
||||
ffEndgameRemoveWave: 'ลบคลื่น',
|
||||
ffEndgameAddMonster: 'เพิ่มมอน',
|
||||
ffEndgameRemoveMonster: 'ลบมอน',
|
||||
ffEndgameMonsterId: 'Monster ID',
|
||||
ffEndgameMonsterLevel: 'เลเวล',
|
||||
ffEndgameMonsterAmount: 'จำนวน',
|
||||
ffEndgameCycleCount: 'จำนวนรอบ'
|
||||
},
|
||||
en: {
|
||||
// Server modes
|
||||
@@ -7306,7 +7356,7 @@ const MODAL_AND_TOOL_TRANSLATIONS = {
|
||||
fireflyChallengeGroupId: 'Peak group id',
|
||||
fireflyChallengeGroupList: 'Peak group id list',
|
||||
fireflyTheoryTitle: '🧪 Theory Craft',
|
||||
fireflyTheoryMode: 'Enable theory craft',
|
||||
fireflyTheoryMode: 'Enable Theory Craft (= Creative Mode)',
|
||||
fireflyTheoryCycleCount: 'Cycle count',
|
||||
fireflyTheoryStageId: 'Stage id',
|
||||
fireflyTheoryLineup: 'Custom lineup',
|
||||
@@ -7325,6 +7375,8 @@ const MODAL_AND_TOOL_TRANSLATIONS = {
|
||||
fireflyRequestFailedDetail: 'Failed: {message}',
|
||||
fireflyTimeout: 'FireflyGo did not respond (timed out)',
|
||||
fireflyNoData: 'There is no character data to sync yet',
|
||||
fireflyPlayerNotLoaded: 'Player data is not loaded yet. Please log into the game with your character first.',
|
||||
fireflyMixedContentHint: 'Browser blocked HTTP localhost from HTTPS page. Please open via localhost or allow Insecure content in site settings.',
|
||||
fireflyExtraSaved: 'FireflyGo controls saved',
|
||||
fireflyHpInvalid: 'HP overrides must be a JSON object such as {"1001": [12000, 9000]}',
|
||||
|
||||
@@ -7460,7 +7512,56 @@ const MODAL_AND_TOOL_TRANSLATIONS = {
|
||||
relicSetSearchLabel: 'Search relic sets',
|
||||
relicSetSearchPlaceholder: '🔍 Search relic sets...',
|
||||
relicSetGridLabel: 'Relic sets',
|
||||
copyCardGenerated: 'Generated via GaCy-Srtool'
|
||||
copyCardGenerated: 'Generated via GaCy-Srtool',
|
||||
|
||||
// FireflyGo endgame room picker
|
||||
ffEndgameTitle: '🦋 Endgame Rooms (FireflyGO)',
|
||||
ffEndgameTablistLabel: 'FireflyGO endgame room modes',
|
||||
ffEndgameDescription: 'Pick the endgame room and monster waves FireflyGO should load, then write them straight into battle_config.',
|
||||
ffEndgameRule: 'These values are written to battle_config (plus extra_data.challenge for Anomaly Arbitration), then saved locally or synced to FireflyGO.',
|
||||
ffEndgameModePeak: 'Anomaly Arbitration',
|
||||
ffEndgameLoading: 'Loading endgame data…',
|
||||
ffEndgameReady: 'Ready · pick a room, then apply it to battle_config',
|
||||
ffEndgameCatalogError: 'Could not load endgame data: {error}',
|
||||
ffEndgameSeason: 'Season / stage group',
|
||||
ffEndgameFloor: 'Floor',
|
||||
ffEndgameFloorValue: 'Floor {floor}',
|
||||
ffEndgameSide: 'Side',
|
||||
ffEndgameSideFirst: 'First half',
|
||||
ffEndgameSideSecond: 'Second half',
|
||||
ffEndgameSideThird: 'Third node',
|
||||
ffEndgameStage: 'Stage',
|
||||
ffEndgameStageValue: 'Stage {id}',
|
||||
ffEndgameStageKindPre: 'Pre-boss',
|
||||
ffEndgameStageKindBoss: 'Boss',
|
||||
ffEndgameStageKindHard: 'Hard mode',
|
||||
ffEndgameStageId: 'stage_id {id}',
|
||||
ffEndgamePreview: 'battle_config preview',
|
||||
ffEndgameNoStages: 'No stage data for this mode',
|
||||
ffEndgameUseCycle: 'Use the stage turn limit',
|
||||
ffEndgameUseBuff: 'Use the stage buffs',
|
||||
ffEndgameCycles: 'Cycles: {count}',
|
||||
ffEndgameBlessings: 'Buffs: {count}',
|
||||
ffEndgameMonsterCount: 'Monsters: {count}',
|
||||
ffEndgameApply: '💾 Apply to battle_config',
|
||||
ffEndgameApplySync: '🚀 Apply & sync',
|
||||
ffEndgameReset: '↺ Reset to defaults',
|
||||
ffEndgameResetDone: 'This mode was reset to its defaults',
|
||||
ffEndgameSaving: 'Saving battle_config…',
|
||||
ffEndgameSaved: 'battle_config saved locally',
|
||||
ffEndgameSynced: 'Saved and synced to FireflyGO',
|
||||
ffEndgameApplyError: 'Finish selecting a room before applying',
|
||||
ffEndgameNoData: 'No FreeSR data yet — load or sync a save first',
|
||||
ffEndgameSyncError: 'Sync failed: {error}',
|
||||
ffEndgameCustomDescription: 'Build custom monster waves with the in-game monster_id. Values must match what FireflyGO accepts.',
|
||||
ffEndgameAddWave: 'Add wave',
|
||||
ffEndgameRemoveWave: 'Remove wave',
|
||||
ffEndgameAddMonster: 'Add monster',
|
||||
ffEndgameRemoveMonster: 'Remove monster',
|
||||
ffEndgameMonsterId: 'Monster ID',
|
||||
ffEndgameMonsterLevel: 'Level',
|
||||
ffEndgameMonsterAmount: 'Amount',
|
||||
ffEndgameCycleCount: 'Cycle count'
|
||||
}
|
||||
};
|
||||
for (const [language, additions] of Object.entries(MODAL_AND_TOOL_TRANSLATIONS)) {
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { getCurrentLanguage, t, translatePath } from './i18n.js?v=2.23';
|
||||
import { getCurrentLanguage, t, translatePath } from './i18n.js?v=2.26';
|
||||
|
||||
const CATALOG_URL = new URL('../assets/item-catalog.json?v=2', import.meta.url);
|
||||
const PAGE_SIZE = 48;
|
||||
|
||||
@@ -20,6 +20,9 @@ export const MODE_CAPABILITIES = {
|
||||
exranks: true,
|
||||
inventory: true,
|
||||
monsterSettings: true,
|
||||
// FireflyGo ships its own endgame room picker; GaCy GO reads the Client
|
||||
// catalog from /api/monster-settings instead.
|
||||
fireflyEndgame: false,
|
||||
damageAnalyst: true,
|
||||
sceneCard: true,
|
||||
gmConsole: true,
|
||||
@@ -36,6 +39,7 @@ export const MODE_CAPABILITIES = {
|
||||
exranks: false,
|
||||
inventory: false,
|
||||
monsterSettings: false,
|
||||
fireflyEndgame: true,
|
||||
damageAnalyst: false,
|
||||
sceneCard: false,
|
||||
gmConsole: false,
|
||||
@@ -50,6 +54,7 @@ export const MODE_CAPABILITIES = {
|
||||
exranks: false,
|
||||
inventory: false,
|
||||
monsterSettings: false,
|
||||
fireflyEndgame: false,
|
||||
damageAnalyst: false,
|
||||
sceneCard: false,
|
||||
gmConsole: false,
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { getCurrentLanguage } from './i18n.js?v=2.23';
|
||||
import { getCurrentLanguage } from './i18n.js?v=2.26';
|
||||
|
||||
const strings = {
|
||||
en: {
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
// Builds the compact endgame catalog used by the FireflyGo monster settings.
|
||||
//
|
||||
// Firefly SrTools serves the raw client tables from
|
||||
// https://srtools.punklorde.org/api/data/<name> (moc, pf, as, peak, monster).
|
||||
// Those files are 2-14 MB each, so this script reduces them to the small subset
|
||||
// the UI needs (season -> floor -> side -> waves + monster names/icons) and
|
||||
// writes assets/firefly-endgame.json.
|
||||
//
|
||||
// Usage: node scripts/build-firefly-endgame-data.cjs [cacheDir]
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const root = path.resolve(__dirname, '..');
|
||||
const cacheDir = process.argv[2] || path.join(require('node:os').tmpdir(), 'ff-endgame-cache');
|
||||
const ORIGIN = 'https://srtools.punklorde.org/api/data';
|
||||
const LOCALES = ['en', 'th'];
|
||||
|
||||
function clean(value) {
|
||||
return String(value ?? '')
|
||||
.replace(/\{RUBY_B#[^}]*\}/g, '')
|
||||
.replace(/\{RUBY_E#\}/g, '')
|
||||
.replace(/<[^>]*>/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function pickName(name) {
|
||||
if (!name || typeof name !== 'object') return {};
|
||||
const out = {};
|
||||
for (const locale of LOCALES) {
|
||||
const text = clean(name[locale]);
|
||||
if (text) out[locale] = text;
|
||||
}
|
||||
if (!out.en) {
|
||||
const fallback = clean(name.cn || name.cht || '');
|
||||
if (fallback) out.en = fallback;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function buffList(list) {
|
||||
if (!Array.isArray(list)) return [];
|
||||
return list
|
||||
.map((buff) => {
|
||||
const id = Number(buff?.ID);
|
||||
if (!Number.isFinite(id) || id <= 0) return null;
|
||||
return { id, name: pickName(buff?.Name) };
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function wavesOf(stage) {
|
||||
if (!Array.isArray(stage?.MonsterList)) return [];
|
||||
return stage.MonsterList
|
||||
.map((wave) => (Array.isArray(wave) ? wave : Object.values(wave || {}))
|
||||
.map((id) => Number(id))
|
||||
.filter((id) => Number.isFinite(id) && id > 0))
|
||||
.filter((wave) => wave.length > 0);
|
||||
}
|
||||
|
||||
function sideOf(side, stage) {
|
||||
if (!stage) return null;
|
||||
return {
|
||||
side,
|
||||
stageId: Number(stage.ID) || 0,
|
||||
level: Number(stage.Level) || 0,
|
||||
name: pickName(stage.Name),
|
||||
waves: wavesOf(stage),
|
||||
...(stage.Invasion?.MazeBuff?.ID
|
||||
? { invasionBuffId: Number(stage.Invasion.MazeBuff.ID) }
|
||||
: {})
|
||||
};
|
||||
}
|
||||
|
||||
// MOC / PF / AS all expose EventList1 (upper) + EventList2 (lower); MOC also has
|
||||
// a third node on the Tierce (hard) config.
|
||||
function sidesOf(level, tierce) {
|
||||
return [
|
||||
sideOf('firstNode', level?.EventList1?.[0]),
|
||||
sideOf('secondNode', level?.EventList2?.[0]),
|
||||
sideOf('thirdNode', tierce?.EventList?.[0])
|
||||
].filter(Boolean);
|
||||
}
|
||||
|
||||
function levelEntry(level, tierce) {
|
||||
const sides = sidesOf(level, tierce);
|
||||
if (!sides.length) return null;
|
||||
return {
|
||||
id: Number(level.ID) || 0,
|
||||
floor: Number(level.Floor) || 0,
|
||||
name: pickName(level.Name),
|
||||
turnLimit: Number(level.TurnLimit) || 0,
|
||||
buffs: buffList(level.MazeBuff),
|
||||
sides
|
||||
};
|
||||
}
|
||||
|
||||
function groupEntry(id, group, mode) {
|
||||
const levels = (Array.isArray(group.Level) ? group.Level : [])
|
||||
.map((level) => levelEntry(level, group.Tierce))
|
||||
.filter(Boolean);
|
||||
if (!levels.length) return null;
|
||||
return {
|
||||
id: Number(id) || 0,
|
||||
name: pickName(group.Name),
|
||||
begin: group.BeginTime || '',
|
||||
end: group.EndTime || '',
|
||||
levels
|
||||
};
|
||||
}
|
||||
|
||||
function buildMocLike(data, mode) {
|
||||
return Object.entries(data)
|
||||
.map(([id, group]) => groupEntry(id, group, mode))
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => a.id - b.id);
|
||||
}
|
||||
|
||||
function peakStage(level, extra = {}) {
|
||||
const stage = level?.EventList?.[0];
|
||||
if (!stage) return null;
|
||||
return {
|
||||
id: Number(level.ID) || 0,
|
||||
stageId: Number(stage.ID) || 0,
|
||||
level: Number(stage.Level) || 0,
|
||||
name: pickName(stage.Name),
|
||||
turnLimit: Number(level.TurnLimit) || 0,
|
||||
buffs: buffList(level.MazeBuff),
|
||||
waves: wavesOf(stage),
|
||||
...(stage.Invasion?.MazeBuff?.ID ? { invasionBuffId: Number(stage.Invasion.MazeBuff.ID) } : {}),
|
||||
...extra
|
||||
};
|
||||
}
|
||||
|
||||
function buildPeak(data) {
|
||||
return Object.entries(data)
|
||||
.map(([id, group]) => {
|
||||
const stages = [];
|
||||
for (const level of group.PreLevel || []) {
|
||||
const entry = peakStage(level, { kind: 'pre' });
|
||||
if (entry) stages.push(entry);
|
||||
}
|
||||
const boss = peakStage(group.BossLevel, { kind: 'boss' });
|
||||
if (boss) stages.push(boss);
|
||||
const hard = peakStage(group.BossConfig, { kind: 'hard' });
|
||||
if (hard) stages.push(hard);
|
||||
if (!stages.length) return null;
|
||||
return {
|
||||
id: Number(id) || 0,
|
||||
name: pickName(group.Name),
|
||||
stages
|
||||
};
|
||||
})
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => a.id - b.id);
|
||||
}
|
||||
|
||||
function collectMonsterIds(catalog) {
|
||||
const ids = new Set();
|
||||
const add = (waves) => waves.forEach((wave) => wave.forEach((id) => ids.add(id)));
|
||||
for (const group of catalog) {
|
||||
if (group.levels) {
|
||||
for (const level of group.levels) {
|
||||
for (const side of level.sides) add(side.waves);
|
||||
}
|
||||
}
|
||||
if (group.stages) {
|
||||
for (const stage of group.stages) add(stage.waves);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
async function loadTable(name) {
|
||||
fs.mkdirSync(cacheDir, { recursive: true });
|
||||
const file = path.join(cacheDir, `${name}.json`);
|
||||
if (fs.existsSync(file)) {
|
||||
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||
}
|
||||
const res = await fetch(`${ORIGIN}/${name}`);
|
||||
if (!res.ok) throw new Error(`${name}: HTTP ${res.status}`);
|
||||
const text = await res.text();
|
||||
fs.writeFileSync(file, text);
|
||||
return JSON.parse(text);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const [moc, pf, as, peak, monsters] = await Promise.all([
|
||||
loadTable('moc'),
|
||||
loadTable('pf'),
|
||||
loadTable('as'),
|
||||
loadTable('peak'),
|
||||
loadTable('monster')
|
||||
]);
|
||||
|
||||
const modes = {
|
||||
moc: { groups: buildMocLike(moc, 'moc') },
|
||||
pf: { groups: buildMocLike(pf, 'pf') },
|
||||
as: { groups: buildMocLike(as, 'as') },
|
||||
peak: { groups: buildPeak(peak) }
|
||||
};
|
||||
|
||||
const monsterIds = new Set();
|
||||
for (const mode of Object.values(modes)) {
|
||||
for (const id of collectMonsterIds(mode.groups)) monsterIds.add(id);
|
||||
}
|
||||
|
||||
const monsterIndex = {};
|
||||
const missing = [];
|
||||
for (const id of [...monsterIds].sort((a, b) => a - b)) {
|
||||
const entry = monsters[String(id)];
|
||||
if (!entry) {
|
||||
missing.push(id);
|
||||
continue;
|
||||
}
|
||||
monsterIndex[id] = {
|
||||
name: pickName(entry.Name),
|
||||
rank: Number(entry.Rank) || 0,
|
||||
stance: Number(entry.StanceCount) || 0,
|
||||
weak: Array.isArray(entry.StanceWeakList) ? entry.StanceWeakList : [],
|
||||
icon: entry.Image?.IconPath ? `https://cdn.punklorde.org/asbres/${entry.Image.IconPath}` : ''
|
||||
};
|
||||
}
|
||||
|
||||
const output = {
|
||||
version: new Date().toISOString().slice(0, 10),
|
||||
source: 'https://srtools.punklorde.org/api/data',
|
||||
modes,
|
||||
monsters: monsterIndex
|
||||
};
|
||||
|
||||
const target = path.join(root, 'assets', 'firefly-endgame.json');
|
||||
fs.writeFileSync(target, JSON.stringify(output));
|
||||
const bytes = fs.statSync(target).size;
|
||||
console.log(`Wrote ${target} (${(bytes / 1024).toFixed(1)} KB)`);
|
||||
console.log(`MOC ${modes.moc.groups.length} groups, PF ${modes.pf.groups.length}, AS ${modes.as.groups.length}, PEAK ${modes.peak.groups.length}`);
|
||||
console.log(`Monsters ${Object.keys(monsterIndex).length} (missing ${missing.length}${missing.length ? ': ' + missing.slice(0, 20).join(',') : ''})`);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -0,0 +1,219 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const path = require('node:path');
|
||||
const { pathToFileURL } = require('node:url');
|
||||
|
||||
const moduleUrl = pathToFileURL(path.join(__dirname, '../js/endgame.js'));
|
||||
|
||||
async function load() {
|
||||
return import(moduleUrl.href);
|
||||
}
|
||||
|
||||
function sampleCatalog() {
|
||||
return {
|
||||
modes: {
|
||||
moc: {
|
||||
groups: [
|
||||
{
|
||||
id: 1000,
|
||||
name: { en: 'Old season' },
|
||||
levels: [
|
||||
{
|
||||
id: 5001,
|
||||
floor: 10,
|
||||
turnLimit: 24,
|
||||
buffs: [{ id: 900 }],
|
||||
sides: [{ side: 'firstNode', stageId: 9001, level: 80, waves: [[11]] }]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 1037,
|
||||
name: { en: 'Echo of Darkness' },
|
||||
levels: [
|
||||
{
|
||||
id: 5612,
|
||||
floor: 12,
|
||||
turnLimit: 30,
|
||||
buffs: [{ id: 1001 }],
|
||||
sides: [
|
||||
{
|
||||
side: 'firstNode',
|
||||
stageId: 30127111,
|
||||
level: 92,
|
||||
waves: [[4033010, 4033020], [4044010]],
|
||||
invasionBuffId: 3034002
|
||||
},
|
||||
{ side: 'secondNode', stageId: 30127112, level: 95, waves: [[1003010]] }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
pf: { groups: [] },
|
||||
as: { groups: [] },
|
||||
peak: {
|
||||
groups: [
|
||||
{
|
||||
id: 9,
|
||||
name: { en: 'Return of the Legion' },
|
||||
stages: [
|
||||
{ kind: 'pre', stageId: 4001, level: 80, waves: [[11]], buffs: [] },
|
||||
{ kind: 'boss', stageId: 4002, level: 90, waves: [[22]], buffs: [{ id: 2001 }] },
|
||||
{ kind: 'hard', stageId: 4003, level: 95, waves: [[33]], buffs: [{ id: 2002 }] }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
monsters: {
|
||||
11: { name: { en: 'Monster 11' }, icon: 'https://cdn.test/11.webp' },
|
||||
4033010: { name: { en: 'Monster 4033010' }, icon: 'https://cdn.test/4033010.webp' }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
test('defaultEndgameSelection picks the newest season, highest floor and first side', async () => {
|
||||
const { defaultEndgameSelection } = await load();
|
||||
const selection = defaultEndgameSelection(sampleCatalog(), 'moc');
|
||||
assert.equal(selection.mode, 'moc');
|
||||
assert.equal(selection.groupId, 1037);
|
||||
assert.equal(selection.levelId, 5612);
|
||||
assert.equal(selection.side, 'firstNode');
|
||||
assert.equal(selection.useCycle, true);
|
||||
assert.equal(selection.useBuff, true);
|
||||
});
|
||||
|
||||
test('defaultEndgameSelection defaults peak to the boss stage', async () => {
|
||||
const { defaultEndgameSelection } = await load();
|
||||
const selection = defaultEndgameSelection(sampleCatalog(), 'peak');
|
||||
assert.equal(selection.mode, 'peak');
|
||||
assert.equal(selection.groupId, 9);
|
||||
assert.equal(selection.stageIndex, 1);
|
||||
});
|
||||
|
||||
test('buildFireflyBattleConfig builds the FireflyGo battle_config shape', async () => {
|
||||
const { buildFireflyBattleConfig, defaultEndgameSelection } = await load();
|
||||
const catalog = sampleCatalog();
|
||||
const config = buildFireflyBattleConfig(catalog, defaultEndgameSelection(catalog, 'moc'));
|
||||
|
||||
assert.equal(config.battle_type, 'MOC');
|
||||
assert.equal(config.stage_id, 30127111);
|
||||
assert.equal(config.cycle_count, 30);
|
||||
assert.equal(config.path_resonance_id, 0);
|
||||
assert.deepEqual(config.custom_stats, []);
|
||||
assert.deepEqual(config.blessings, [
|
||||
{ id: 1001, level: 1 },
|
||||
{ id: 3034002, level: 1 }
|
||||
]);
|
||||
assert.deepEqual(config.monsters, [
|
||||
[
|
||||
{ monster_id: 4033010, level: 92, amount: 1 },
|
||||
{ monster_id: 4033020, level: 92, amount: 1 }
|
||||
],
|
||||
[{ monster_id: 4044010, level: 92, amount: 1 }]
|
||||
]);
|
||||
});
|
||||
|
||||
test('cycle and buff toggles remove the corresponding values', async () => {
|
||||
const { buildFireflyBattleConfig } = await load();
|
||||
const config = buildFireflyBattleConfig(sampleCatalog(), {
|
||||
mode: 'moc',
|
||||
groupId: 1037,
|
||||
levelId: 5612,
|
||||
side: 'secondNode',
|
||||
useCycle: false,
|
||||
useBuff: false
|
||||
});
|
||||
assert.equal(config.battle_type, 'MOC');
|
||||
assert.equal(config.stage_id, 30127112);
|
||||
assert.equal(config.cycle_count, 0);
|
||||
assert.deepEqual(config.blessings, []);
|
||||
assert.deepEqual(config.monsters, [[{ monster_id: 1003010, level: 95, amount: 1 }]]);
|
||||
});
|
||||
|
||||
test('peak uses the selected stage index and reports PEAK', async () => {
|
||||
const { buildFireflyBattleConfig, buildFireflyChallenge } = await load();
|
||||
const catalog = sampleCatalog();
|
||||
const config = buildFireflyBattleConfig(catalog, {
|
||||
mode: 'peak',
|
||||
groupId: 9,
|
||||
stageIndex: 2,
|
||||
useCycle: true,
|
||||
useBuff: true
|
||||
});
|
||||
assert.equal(config.battle_type, 'PEAK');
|
||||
assert.equal(config.stage_id, 4003);
|
||||
assert.deepEqual(config.blessings, [{ id: 2002, level: 1 }]);
|
||||
|
||||
assert.deepEqual(buildFireflyChallenge({ mode: 'peak', groupId: 9 }, { skip_node: 3, challenge_peak_group_id: 1 }), {
|
||||
skip_node: 3,
|
||||
challenge_peak_group_id: 9,
|
||||
challenge_peak_group_id_list: [9]
|
||||
});
|
||||
// Non-peak modes keep the existing challenge untouched.
|
||||
assert.deepEqual(buildFireflyChallenge({ mode: 'moc', groupId: 1037 }, { skip_node: 1, challenge_peak_group_id: 4 }), {
|
||||
skip_node: 1,
|
||||
challenge_peak_group_id: 4,
|
||||
challenge_peak_group_id_list: []
|
||||
});
|
||||
});
|
||||
|
||||
test('findSelectionByStageId reopens the saved room', async () => {
|
||||
const { findSelectionByStageId } = await load();
|
||||
const catalog = sampleCatalog();
|
||||
assert.deepEqual(findSelectionByStageId(catalog, 'moc', 30127112), {
|
||||
mode: 'moc',
|
||||
groupId: 1037,
|
||||
levelId: 5612,
|
||||
side: 'secondNode',
|
||||
useCycle: true,
|
||||
useBuff: true
|
||||
});
|
||||
assert.deepEqual(findSelectionByStageId(catalog, 'peak', 4003, 9), {
|
||||
mode: 'peak',
|
||||
groupId: 9,
|
||||
stageIndex: 2,
|
||||
useCycle: true,
|
||||
useBuff: true
|
||||
});
|
||||
assert.equal(findSelectionByStageId(catalog, 'peak', 9999), null);
|
||||
assert.equal(findSelectionByStageId(catalog, 'custom', 4003), null);
|
||||
});
|
||||
|
||||
test('custom mode keeps arbitrary waves and rejects empty input', async () => {
|
||||
const { buildFireflyBattleConfig, summarizeEndgameSelection } = await load();
|
||||
const catalog = sampleCatalog();
|
||||
const config = buildFireflyBattleConfig(catalog, {
|
||||
mode: 'custom',
|
||||
customCycle: 4,
|
||||
customWaves: [[{ monster_id: '4033010', level: '90', amount: '2' }], [{ monster_id: 22 }]]
|
||||
});
|
||||
assert.equal(config.battle_type, 'CE');
|
||||
assert.equal(config.stage_id, 0);
|
||||
assert.equal(config.cycle_count, 4);
|
||||
assert.deepEqual(config.monsters, [
|
||||
[{ monster_id: 4033010, level: 90, amount: 2 }],
|
||||
[{ monster_id: 22, level: 80, amount: 1 }]
|
||||
]);
|
||||
|
||||
assert.equal(buildFireflyBattleConfig(catalog, { mode: 'custom', customWaves: [[{ monster_id: 0 }]] }), null);
|
||||
assert.equal(buildFireflyBattleConfig(catalog, { mode: 'custom', customWaves: [] }), null);
|
||||
|
||||
const summary = summarizeEndgameSelection(catalog, {
|
||||
mode: 'custom',
|
||||
customCycle: 4,
|
||||
customWaves: [[{ monster_id: '4033010' }], [{ monster_id: 22 }]]
|
||||
});
|
||||
assert.equal(summary.waveCount, 2);
|
||||
assert.equal(summary.monsterCount, 2);
|
||||
assert.equal(summary.cycleCount, 4);
|
||||
});
|
||||
|
||||
test('unknown modes and empty catalogs resolve to null', async () => {
|
||||
const { buildFireflyBattleConfig, resolveEndgameSelection } = await load();
|
||||
assert.equal(resolveEndgameSelection({ modes: {} }, { mode: 'moc' }), null);
|
||||
assert.equal(buildFireflyBattleConfig(sampleCatalog(), { mode: 'su' }), null);
|
||||
assert.equal(buildFireflyBattleConfig(sampleCatalog(), { mode: 'pf' }), null);
|
||||
});
|
||||
@@ -152,18 +152,58 @@ test('buildSyncPayload emits the FreeSR shape FireflyGo expects', async () => {
|
||||
assert.equal(payload.relics.length, 1);
|
||||
assert.deepEqual(payload.relics[0].sub_affixes, [{ sub_affix_id: 2, count: 3, step: 0 }]);
|
||||
|
||||
// Unknown battle_config keys such as custom_battle_lineup are stripped.
|
||||
// Unknown battle_config keys such as custom_battle_lineup are stripped and
|
||||
// the blessing/monster arrays are coerced into FireflyGo's zod shape.
|
||||
assert.deepEqual(payload.battle_config, {
|
||||
battle_type: 'MOC',
|
||||
blessings: [1, 2],
|
||||
blessings: [{ id: 1, level: 1 }, { id: 2, level: 1 }],
|
||||
custom_stats: [],
|
||||
cycle_count: 3,
|
||||
stage_id: 12,
|
||||
path_resonance_id: 0,
|
||||
monsters: [[1002010, 1]]
|
||||
monsters: [[{ monster_id: 1002010, level: 1, amount: 1 }, { monster_id: 1, level: 1, amount: 1 }]]
|
||||
});
|
||||
});
|
||||
|
||||
test('buildSyncPayload normalises detailed blessings and monster waves', async () => {
|
||||
const { buildSyncPayload } = await load();
|
||||
const payload = buildSyncPayload({
|
||||
battle_config: {
|
||||
battle_type: 'PEAK',
|
||||
blessings: [
|
||||
{ id: 3034002, level: '2', dynamic_key: { key: 'abc', value: '4' } },
|
||||
{ id: 0, level: 1 },
|
||||
{ id: '3001' }
|
||||
],
|
||||
cycle_count: '6',
|
||||
stage_id: '30127123',
|
||||
monsters: [
|
||||
[
|
||||
{ monster_id: 4033010, level: 92, amount: 2 },
|
||||
{ monster_id: 0, level: 1 },
|
||||
{ monster_id: '4033020', level: '95' }
|
||||
],
|
||||
[],
|
||||
[4044010]
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
assert.deepEqual(payload.battle_config.blessings, [
|
||||
{ id: 3034002, level: 2, dynamic_key: { key: 'abc', value: 4 } },
|
||||
{ id: 3001, level: 1 }
|
||||
]);
|
||||
assert.deepEqual(payload.battle_config.monsters, [
|
||||
[
|
||||
{ monster_id: 4033010, level: 92, amount: 2 },
|
||||
{ monster_id: 4033020, level: 95, amount: 1 }
|
||||
],
|
||||
[{ monster_id: 4044010, level: 1, amount: 1 }]
|
||||
]);
|
||||
assert.equal(payload.battle_config.cycle_count, 6);
|
||||
assert.equal(payload.battle_config.stage_id, 30127123);
|
||||
});
|
||||
|
||||
test('buildSyncPayload derives the anchor map when the save has none', async () => {
|
||||
const { buildSyncPayload } = await load();
|
||||
const payload = buildSyncPayload({
|
||||
|
||||
@@ -19,6 +19,7 @@ test('GaCy GO keeps every tool feature', async () => {
|
||||
]) {
|
||||
assert.equal(caps[feature], true, `${feature} must be available on GaCy GO`);
|
||||
}
|
||||
assert.equal(caps.fireflyEndgame, false);
|
||||
assert.equal(caps.extraData, false);
|
||||
assert.equal(caps.ownedAvatarsOnly, false);
|
||||
assert.deepEqual(caps.jsonModes, ['freesr', 'persistent']);
|
||||
@@ -34,10 +35,14 @@ test('FireflyGo hides every GaCy-only feature and exposes extra_data', async ()
|
||||
assert.equal(caps[feature], false, `${feature} must be hidden on FireflyGo`);
|
||||
}
|
||||
assert.equal(caps.extraData, true);
|
||||
assert.equal(caps.fireflyEndgame, true);
|
||||
assert.equal(caps.ownedAvatarsOnly, true);
|
||||
assert.deepEqual(caps.jsonModes, ['freesr']);
|
||||
assert.equal(modeSupports('extraData', 'firefly'), true);
|
||||
assert.equal(modeSupports('saveManager', 'firefly'), false);
|
||||
assert.equal(modeSupports('fireflyEndgame', 'firefly'), true);
|
||||
assert.equal(modeSupports('monsterSettings', 'firefly'), false);
|
||||
assert.equal(modeSupports('fireflyEndgame', 'gac'), false);
|
||||
assert.equal(modeSupports('fireflyEndgame', 'robin'), false);
|
||||
});
|
||||
|
||||
test('unknown modes fall back to GaCy GO', async () => {
|
||||
@@ -73,7 +78,20 @@ test('mode UI strings exist in Thai and English', async () => {
|
||||
'btnFireflyProbe', 'btnFireflySaveExtra', 'btnFireflySync',
|
||||
'fireflyProbing', 'fireflySyncing', 'fireflyProbeSuccess', 'fireflySyncSuccess',
|
||||
'fireflyRequestFailed', 'fireflyRequestFailedDetail', 'fireflyTimeout',
|
||||
'fireflyNoData', 'fireflyExtraSaved', 'fireflyHpInvalid'
|
||||
'fireflyNoData', 'fireflyExtraSaved', 'fireflyHpInvalid',
|
||||
'ffEndgameTitle', 'ffEndgameTablistLabel', 'ffEndgameDescription', 'ffEndgameRule', 'ffEndgameModePeak',
|
||||
'ffEndgameLoading', 'ffEndgameReady', 'ffEndgameCatalogError', 'ffEndgameSeason',
|
||||
'ffEndgameFloor', 'ffEndgameFloorValue', 'ffEndgameSide', 'ffEndgameSideFirst',
|
||||
'ffEndgameSideSecond', 'ffEndgameSideThird', 'ffEndgameStage', 'ffEndgameStageValue',
|
||||
'ffEndgameStageKindPre', 'ffEndgameStageKindBoss', 'ffEndgameStageKindHard',
|
||||
'ffEndgameStageId', 'ffEndgamePreview', 'ffEndgameNoStages', 'ffEndgameUseCycle',
|
||||
'ffEndgameUseBuff', 'ffEndgameCycles', 'ffEndgameBlessings', 'ffEndgameMonsterCount',
|
||||
'ffEndgameApply', 'ffEndgameApplySync', 'ffEndgameReset', 'ffEndgameResetDone',
|
||||
'ffEndgameSaving', 'ffEndgameSaved', 'ffEndgameSynced', 'ffEndgameApplyError',
|
||||
'ffEndgameNoData', 'ffEndgameSyncError', 'ffEndgameCustomDescription',
|
||||
'ffEndgameAddWave', 'ffEndgameRemoveWave', 'ffEndgameAddMonster',
|
||||
'ffEndgameRemoveMonster', 'ffEndgameMonsterId', 'ffEndgameMonsterLevel',
|
||||
'ffEndgameMonsterAmount', 'ffEndgameCycleCount'
|
||||
];
|
||||
for (const lang of ['th', 'en']) {
|
||||
for (const key of keys) {
|
||||
@@ -103,7 +121,10 @@ test('mode-gated DOM hooks exist in index.html', () => {
|
||||
'ff-challenge-skip-node', 'ff-challenge-peak-group', 'ff-challenge-peak-list',
|
||||
'ff-theory-mode', 'ff-theory-cycle-count', 'ff-theory-stage-id', 'ff-theory-lineup',
|
||||
'ff-theory-hp', 'ff-lua', 'btn-firefly-probe', 'btn-firefly-save-extra',
|
||||
'btn-firefly-sync', 'firefly-extra-status'
|
||||
'btn-firefly-sync', 'firefly-extra-status',
|
||||
'monster-settings-gac', 'monster-settings-firefly', 'ff-endgame-tabs',
|
||||
'ff-endgame-status', 'ff-endgame-view', 'ff-endgame-reset',
|
||||
'ff-endgame-apply', 'ff-endgame-apply-sync'
|
||||
]) {
|
||||
assert.ok(html.includes(`id="${id}"`), `index.html must contain #${id}`);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user