poi-plugin-sortie-exp 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +43 -0
- package/index.js +58 -0
- package/package.json +29 -0
- package/src/experience.js +308 -0
- package/src/panel.css +225 -0
- package/src/panel.js +275 -0
- package/src/poi-adapter.js +163 -0
- package/src/poi-ship-data.js +67 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Yahagi contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# 出击经验记录 · poi 插件
|
|
2
|
+
|
|
3
|
+
**出击经验记录**是从 Yahagi 首页的「出击经验」功能独立出来、为 poi 适配的插件。它用来查看每艘舰娘在普通出击和演习中获得了多少经验。[原项目 Yahagi](https://github.com/yamatosaki/yahagi-kancolle-browser)
|
|
4
|
+
|
|
5
|
+
普通出击每场战斗结算后,插件会更新逐舰经验、占比和舰队经验合计。返港后保留上一趟记录。演习结算也计为一次出击。
|
|
6
|
+
|
|
7
|
+
## 界面预览
|
|
8
|
+
|
|
9
|
+
**本次出击**显示海域、舰队、已结算场数和舰队经验合计。表格列出舰娘头像、等级、获得经验和占比。
|
|
10
|
+
|
|
11
|
+

|
|
12
|
+
|
|
13
|
+
**总累计**显示累计出击场数和全部舰船经验合计。表格列出每艘舰娘的累计经验和占比,每页最多显示 10 艘,可以翻页查看,也可以通过「自定义显示」筛选舰娘。
|
|
14
|
+
|
|
15
|
+

|
|
16
|
+
|
|
17
|
+
## 安装
|
|
18
|
+
|
|
19
|
+
在 poi 的「设置 → 插件」中找到「直接从 npm 服务器安装」,输入 `poi-plugin-sortie-exp` 并点击「安装」。安装后打开「出击经验记录」。也可以下载 `.tgz` 安装包,在同一页面从文件安装。[GitHub Releases](https://github.com/yamatosaki/poi-plugin-sortie-exp/releases) · [poi 插件安装入口](https://github.com/poooi/poi/blob/master/i18n/setting/zh-CN.json#L917-L923)
|
|
20
|
+
|
|
21
|
+
已在 Windows 上的 poi 12.0.1 验证。
|
|
22
|
+
|
|
23
|
+
## 使用说明
|
|
24
|
+
|
|
25
|
+
- **总累计默认关闭。** 开启后,插件统计开关开启期间结算的经验。普通出击在返港时入账,演习在结算时入账。
|
|
26
|
+
- **记录按账号保存在本机。** 换号后进入母港,插件会加载该账号的上一趟记录、总累计、累计开关和显示设置。
|
|
27
|
+
- **可自定义总累计显示的舰娘。** 在「自定义显示」中搜索和勾选舰娘,点击「完成」后应用。隐藏的舰娘仍会累计经验,顶部合计和占比仍按全部舰娘计算。
|
|
28
|
+
- **总累计每页最多显示 10 艘。** 使用表格底部的「上一页」「下一页」翻页。确认新的显示名单或切换账号后回到第一页。
|
|
29
|
+
- **自动清理已失去的舰娘。** 游戏确认解体或近代化改修素材消耗后,插件会移除对应舰娘的总累计记录和经验,并按剩余全部舰娘重新计算占比。进入母港时,也会根据完整名簿清理失效记录。
|
|
30
|
+
- **清零只作用于当前账号。** 在弹窗中确认后,插件会清除总累计场数、逐舰累计经验,以及本趟尚未入账的经验。
|
|
31
|
+
- **逐舰经验上限为 9,999,999。** 刚好达到上限时保留,超过时该舰的对应经验归零,从下一场战斗重新累计。
|
|
32
|
+
|
|
33
|
+
## 本地开发
|
|
34
|
+
|
|
35
|
+
可将项目放到 poi 的 `plugins-extra/node_modules/poi-plugin-sortie-exp` 目录,重启 poi 后加载。
|
|
36
|
+
|
|
37
|
+
```powershell
|
|
38
|
+
npm test
|
|
39
|
+
npm pack --dry-run
|
|
40
|
+
npm pack
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
项目采用 [MIT 许可证](LICENSE)。
|
package/index.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs')
|
|
4
|
+
const path = require('node:path')
|
|
5
|
+
const { createExperience } = require('./src/experience')
|
|
6
|
+
const { createPoiAdapter, storagePath } = require('./src/poi-adapter')
|
|
7
|
+
const { createPanel } = require('./src/panel')
|
|
8
|
+
const { createShipProjection } = require('./src/poi-ship-data')
|
|
9
|
+
const { store } = require(path.join(window.ROOT, 'views', 'create-store'))
|
|
10
|
+
const { ShipAvatar, Gradient, ShipBasic, ShipName, ShipSubText, ShipLabel } =
|
|
11
|
+
require(path.join(window.ROOT, 'views', 'components', 'ship-parts', 'styled-components'))
|
|
12
|
+
const { selectShipAvatarColor } = require(path.join(window.ROOT, 'views', 'utils', 'game-utils'))
|
|
13
|
+
const { useTranslation } = require(path.join(window.ROOT, 'node_modules', 'react-i18next'))
|
|
14
|
+
|
|
15
|
+
const listeners = new Set()
|
|
16
|
+
const tracker = createExperience({
|
|
17
|
+
load: id => window.config.get(storagePath(id), null),
|
|
18
|
+
save: (id, value) => window.config.set(storagePath(id), value),
|
|
19
|
+
notify: () => {
|
|
20
|
+
for (const listener of listeners) listener()
|
|
21
|
+
},
|
|
22
|
+
})
|
|
23
|
+
const getGameSnapshot = createShipProjection(store.getState, () => tracker.getSnapshot().accountId, selectShipAvatarColor)
|
|
24
|
+
|
|
25
|
+
function subscribe(listener) {
|
|
26
|
+
listeners.add(listener)
|
|
27
|
+
return () => listeners.delete(listener)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
let adapter
|
|
31
|
+
const onResponse = event => adapter?.onResponse(event.detail)
|
|
32
|
+
const onGameStart = () => adapter?.reset()
|
|
33
|
+
const onInterrupt = () => adapter?.interrupt()
|
|
34
|
+
|
|
35
|
+
exports.pluginDidLoad = () => {
|
|
36
|
+
adapter = createPoiAdapter({ tracker, getState: store.getState, getShipSnapshot: getGameSnapshot })
|
|
37
|
+
window.addEventListener('game.response', onResponse)
|
|
38
|
+
window.addEventListener('game.start', onGameStart)
|
|
39
|
+
window.addEventListener('network.error', onInterrupt)
|
|
40
|
+
window.addEventListener('network.invalid.result', onInterrupt)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
exports.pluginWillUnload = () => {
|
|
44
|
+
window.removeEventListener('game.response', onResponse)
|
|
45
|
+
window.removeEventListener('game.start', onGameStart)
|
|
46
|
+
window.removeEventListener('network.error', onInterrupt)
|
|
47
|
+
window.removeEventListener('network.invalid.result', onInterrupt)
|
|
48
|
+
adapter = undefined
|
|
49
|
+
tracker.handle({ type: 'account' })
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
exports.reactClass = createPanel(
|
|
53
|
+
require('react'), tracker, subscribe,
|
|
54
|
+
fs.readFileSync(path.join(__dirname, 'src', 'panel.css'), 'utf8'),
|
|
55
|
+
{ ShipAvatar, Gradient, ShipBasic, ShipName, ShipSubText, ShipLabel, useTranslation,
|
|
56
|
+
subscribeGame: store.subscribe, getGameSnapshot },
|
|
57
|
+
)
|
|
58
|
+
exports.windowMode = false
|
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "poi-plugin-sortie-exp",
|
|
3
|
+
"version": "0.1.4",
|
|
4
|
+
"description": "Per-ship sortie and exercise EXP records for poi",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"main": "index.js",
|
|
7
|
+
"keywords": ["poi-plugin", "kancolle", "experience"],
|
|
8
|
+
"files": ["index.js", "src/"],
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/yamatosaki/poi-plugin-sortie-exp.git"
|
|
12
|
+
},
|
|
13
|
+
"homepage": "https://github.com/yamatosaki/poi-plugin-sortie-exp#readme",
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/yamatosaki/poi-plugin-sortie-exp/issues"
|
|
16
|
+
},
|
|
17
|
+
"publishConfig": {
|
|
18
|
+
"access": "public",
|
|
19
|
+
"registry": "https://registry.npmjs.org/"
|
|
20
|
+
},
|
|
21
|
+
"scripts": {
|
|
22
|
+
"test": "node --test"
|
|
23
|
+
},
|
|
24
|
+
"poiPlugin": {
|
|
25
|
+
"name": "出击经验记录",
|
|
26
|
+
"description": "查看本次和上次出击、演习的逐舰经验,以及按开关统计的总累计。",
|
|
27
|
+
"icon": "fa/line-chart"
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const emptyArchive = () => ({ last: null, tracking: false, totals: [], totalSorties: 0 })
|
|
4
|
+
const copy = value => JSON.parse(JSON.stringify(value))
|
|
5
|
+
const maxShipExp = 9_999_999
|
|
6
|
+
const isExp = value => Number.isSafeInteger(value) && value >= 0
|
|
7
|
+
const addShipExp = (previous, reward) => reward > maxShipExp - previous ? 0 : previous + reward
|
|
8
|
+
const byExp = (a, b) => b.exp - a.exp || a.id - b.id
|
|
9
|
+
const isShip = ship => ship && Number.isSafeInteger(ship.id) && ship.id > 0 &&
|
|
10
|
+
typeof ship.name === 'string' && ['main', 'escort'].includes(ship.group) &&
|
|
11
|
+
Number.isSafeInteger(ship.slot) && ship.slot >= 0 && ship.slot < 7
|
|
12
|
+
const normalizeShipSelection = value => Array.isArray(value)
|
|
13
|
+
? [...new Set(value.filter(id => Number.isSafeInteger(id) && id > 0))]
|
|
14
|
+
: null
|
|
15
|
+
|
|
16
|
+
function makeRun(kind, event) {
|
|
17
|
+
const { deckId, map, node, roster } = event
|
|
18
|
+
if (!Number.isSafeInteger(deckId) || deckId < 1 || !Array.isArray(roster) ||
|
|
19
|
+
!roster.length || !roster.every(isShip) ||
|
|
20
|
+
new Set(roster.map(ship => ship.id)).size !== roster.length ||
|
|
21
|
+
new Set(roster.map(ship => `${ship.group}:${ship.slot}`)).size !== roster.length) return null
|
|
22
|
+
if (kind === 'sortie' && (!/^\d+-\d+$/.test(map) || !Number.isSafeInteger(node) || node < 1)) return null
|
|
23
|
+
return {
|
|
24
|
+
kind, deckId, map: kind === 'sortie' ? map : null, node, battles: 0,
|
|
25
|
+
incomplete: event.incomplete === true, expectsEscort: event.expectsEscort === true,
|
|
26
|
+
ships: roster.map(ship => ({ ...ship, exp: 0, retreated: false })),
|
|
27
|
+
phase: kind === 'practice' ? 'ready' : 'waiting', known: new Set(), pendingTotals: new Map(),
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function snapshot(run) {
|
|
32
|
+
if (!run) return null
|
|
33
|
+
const { kind, deckId, map, battles, incomplete, ships } = run
|
|
34
|
+
return { kind, deckId, map, battles, incomplete, ships: ships.map(ship => ({
|
|
35
|
+
...ship, exp: battles > 0 && !run.known.has(ship.id) ? null : ship.exp,
|
|
36
|
+
})) }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function decodeRewards(ships, group, raw) {
|
|
40
|
+
const fleet = ships.filter(ship => ship.group === group)
|
|
41
|
+
if (!fleet.length) return new Map()
|
|
42
|
+
if (!Array.isArray(raw) || raw[0] !== -1) return null
|
|
43
|
+
const bySlot = new Map(fleet.map(ship => [ship.slot, ship]))
|
|
44
|
+
if (fleet.some(ship => !ship.retreated && ship.slot + 1 >= raw.length)) return null
|
|
45
|
+
|
|
46
|
+
const rewards = new Map()
|
|
47
|
+
for (let index = 1; index < raw.length; index += 1) {
|
|
48
|
+
const ship = bySlot.get(index - 1)
|
|
49
|
+
const value = raw[index]
|
|
50
|
+
if (ship && !ship.retreated) {
|
|
51
|
+
if (!isExp(value)) return null
|
|
52
|
+
rewards.set(ship.id, value)
|
|
53
|
+
} else if (value !== -1 && value !== 0) return null
|
|
54
|
+
}
|
|
55
|
+
return rewards
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function restoreArchive(raw) {
|
|
59
|
+
if (raw == null) return emptyArchive()
|
|
60
|
+
const validRow = ship => isShip(ship) && isExp(ship.exp)
|
|
61
|
+
const validLast = raw.last === null || (raw.last &&
|
|
62
|
+
['sortie', 'practice'].includes(raw.last.kind) && isExp(raw.last.battles) &&
|
|
63
|
+
Array.isArray(raw.last.ships) && raw.last.ships.every(ship =>
|
|
64
|
+
isShip(ship) && (ship.exp === null || isExp(ship.exp))))
|
|
65
|
+
if (typeof raw.tracking !== 'boolean' || !isExp(raw.totalSorties) ||
|
|
66
|
+
!Array.isArray(raw.totals) || !raw.totals.every(validRow) || !validLast) {
|
|
67
|
+
throw new Error('Invalid sortie experience archive')
|
|
68
|
+
}
|
|
69
|
+
const restored = copy(raw)
|
|
70
|
+
if (restored.totalShipSelection !== undefined) {
|
|
71
|
+
restored.totalShipSelection = normalizeShipSelection(restored.totalShipSelection)
|
|
72
|
+
}
|
|
73
|
+
return restored
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function createExperience({ load = () => null, save = () => {}, notify = () => {} } = {}) {
|
|
77
|
+
let archive = emptyArchive()
|
|
78
|
+
let accountId = null
|
|
79
|
+
let current = null
|
|
80
|
+
let readFailed = false
|
|
81
|
+
let dirty = false
|
|
82
|
+
let storageError = false
|
|
83
|
+
let view
|
|
84
|
+
|
|
85
|
+
function update() {
|
|
86
|
+
view = {
|
|
87
|
+
accountId,
|
|
88
|
+
accountReady: accountId !== null,
|
|
89
|
+
current: snapshot(current),
|
|
90
|
+
last: archive.last,
|
|
91
|
+
tracking: archive.tracking,
|
|
92
|
+
totals: archive.totals,
|
|
93
|
+
totalSorties: archive.totalSorties,
|
|
94
|
+
totalShipSelection: archive.totalShipSelection ?? null,
|
|
95
|
+
storageError,
|
|
96
|
+
}
|
|
97
|
+
notify(view)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function persist() {
|
|
101
|
+
if (!accountId || !dirty || readFailed) return
|
|
102
|
+
try {
|
|
103
|
+
save(accountId, copy(archive))
|
|
104
|
+
dirty = false
|
|
105
|
+
storageError = false
|
|
106
|
+
} catch (_) {
|
|
107
|
+
storageError = true
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function changeAccount(id) {
|
|
112
|
+
if (id === accountId) return false
|
|
113
|
+
persist()
|
|
114
|
+
current = null
|
|
115
|
+
accountId = id
|
|
116
|
+
archive = emptyArchive()
|
|
117
|
+
dirty = false
|
|
118
|
+
readFailed = false
|
|
119
|
+
storageError = false
|
|
120
|
+
if (id) {
|
|
121
|
+
try {
|
|
122
|
+
archive = restoreArchive(load(id))
|
|
123
|
+
for (const ship of [...archive.totals, ...(archive.last?.ships ?? [])]) {
|
|
124
|
+
if (ship.exp <= maxShipExp) continue
|
|
125
|
+
ship.exp = 0
|
|
126
|
+
dirty = true
|
|
127
|
+
}
|
|
128
|
+
if (dirty) archive.totals.sort(byExp)
|
|
129
|
+
} catch (_) {
|
|
130
|
+
readFailed = true
|
|
131
|
+
storageError = true
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return true
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function reward(run, values) {
|
|
138
|
+
for (const ship of run.ships) {
|
|
139
|
+
const value = values.get(ship.id)
|
|
140
|
+
if (value === undefined) continue
|
|
141
|
+
run.known.add(ship.id)
|
|
142
|
+
ship.exp = addShipExp(ship.exp, value)
|
|
143
|
+
if (archive.tracking) {
|
|
144
|
+
// Stage each settlement so a later battle can count again after a reset.
|
|
145
|
+
const total = run.pendingTotals.get(ship.id) ?? archive.totals.find(row => row.id === ship.id)?.exp ?? 0
|
|
146
|
+
run.pendingTotals.set(ship.id, addShipExp(total, value))
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function finish(shipDisplay) {
|
|
152
|
+
const totalById = new Map(archive.totals.map(ship => [ship.id, ship]))
|
|
153
|
+
for (const ship of current.ships) {
|
|
154
|
+
const observed = shipDisplay?.[ship.id]
|
|
155
|
+
if (observed && (ship.masterId === undefined || observed.masterId === ship.masterId)) {
|
|
156
|
+
Object.assign(ship, observed)
|
|
157
|
+
}
|
|
158
|
+
const value = current.pendingTotals.get(ship.id)
|
|
159
|
+
if (value === undefined) continue
|
|
160
|
+
const previous = totalById.get(ship.id)
|
|
161
|
+
totalById.set(ship.id, { ...previous, ...ship, exp: value, retreated: false })
|
|
162
|
+
}
|
|
163
|
+
archive = {
|
|
164
|
+
...archive,
|
|
165
|
+
last: snapshot(current),
|
|
166
|
+
totals: [...totalById.values()].sort(byExp),
|
|
167
|
+
totalSorties: archive.totalSorties + Number(current.pendingTotals.size > 0),
|
|
168
|
+
}
|
|
169
|
+
current = null
|
|
170
|
+
dirty = true
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function accept(event) {
|
|
174
|
+
const { type } = event
|
|
175
|
+
if (type === 'account') return changeAccount(/^\d+$/.test(event.accountId ?? '') && Number(event.accountId) > 0 ? String(event.accountId) : null)
|
|
176
|
+
if (!accountId) return false
|
|
177
|
+
if (event.accountId != null && String(event.accountId) !== accountId) return false
|
|
178
|
+
if (type === 'removeShips') {
|
|
179
|
+
if (!Array.isArray(event.ids) || !event.ids.length ||
|
|
180
|
+
!event.ids.every(id => Number.isSafeInteger(id) && id > 0)) return false
|
|
181
|
+
const removed = new Set(event.ids)
|
|
182
|
+
let pendingChanged = false
|
|
183
|
+
for (const id of removed) {
|
|
184
|
+
if (current?.pendingTotals.delete(id)) pendingChanged = true
|
|
185
|
+
}
|
|
186
|
+
const totals = archive.totals.filter(ship => !removed.has(ship.id))
|
|
187
|
+
const selection = archive.totalShipSelection?.filter(id => !removed.has(id))
|
|
188
|
+
if (totals.length === archive.totals.length &&
|
|
189
|
+
selection?.length === archive.totalShipSelection?.length) return pendingChanged
|
|
190
|
+
archive = { ...archive, totals, ...(selection !== undefined && { totalShipSelection: selection }) }
|
|
191
|
+
dirty = true
|
|
192
|
+
return true
|
|
193
|
+
}
|
|
194
|
+
if (type === 'sortie' || type === 'practice') {
|
|
195
|
+
current = makeRun(type, event)
|
|
196
|
+
return true
|
|
197
|
+
}
|
|
198
|
+
if (!current) return false
|
|
199
|
+
if (type === 'interrupt') {
|
|
200
|
+
if (current.kind === 'practice') current = null
|
|
201
|
+
else {
|
|
202
|
+
current.incomplete = true
|
|
203
|
+
current.phase = 'interrupted'
|
|
204
|
+
}
|
|
205
|
+
return true
|
|
206
|
+
}
|
|
207
|
+
if (type === 'port') {
|
|
208
|
+
if (current.kind === 'sortie') {
|
|
209
|
+
if (current.phase === 'ready') current.incomplete = true
|
|
210
|
+
finish(event.shipDisplay)
|
|
211
|
+
} else current = null
|
|
212
|
+
return true
|
|
213
|
+
}
|
|
214
|
+
if (type === 'practiceResult' && current.kind === 'practice') {
|
|
215
|
+
const values = decodeRewards(current.ships, 'main', event.main)
|
|
216
|
+
if (values?.size && (event.deckId == null || event.deckId === current.deckId)) {
|
|
217
|
+
current.battles = 1
|
|
218
|
+
reward(current, values)
|
|
219
|
+
finish(event.shipDisplay)
|
|
220
|
+
} else current = null
|
|
221
|
+
return true
|
|
222
|
+
}
|
|
223
|
+
if (type === 'practiceNight' && current.kind === 'practice' &&
|
|
224
|
+
event.deckId != null && event.deckId !== current.deckId) {
|
|
225
|
+
current = null
|
|
226
|
+
return true
|
|
227
|
+
}
|
|
228
|
+
if (current.kind !== 'sortie') return false
|
|
229
|
+
if (type === 'next') {
|
|
230
|
+
if (!Number.isSafeInteger(event.node) || event.node < 1 ||
|
|
231
|
+
(event.map && event.map !== current.map)) {
|
|
232
|
+
current.incomplete = true
|
|
233
|
+
current.phase = 'interrupted'
|
|
234
|
+
return true
|
|
235
|
+
}
|
|
236
|
+
if (current.node === event.node && current.phase !== 'interrupted') return false
|
|
237
|
+
if (current.phase === 'ready') current.incomplete = true
|
|
238
|
+
current.node = event.node
|
|
239
|
+
current.phase = 'waiting'
|
|
240
|
+
return true
|
|
241
|
+
}
|
|
242
|
+
if (type === 'phase' && current.phase === 'waiting') {
|
|
243
|
+
if (event.deckId != null && event.deckId !== current.deckId) {
|
|
244
|
+
current.incomplete = true
|
|
245
|
+
current.phase = 'interrupted'
|
|
246
|
+
} else current.phase = 'ready'
|
|
247
|
+
return true
|
|
248
|
+
}
|
|
249
|
+
if (type === 'retreat') {
|
|
250
|
+
const ids = new Set(event.ids ?? [])
|
|
251
|
+
const slots = event.slots ?? []
|
|
252
|
+
for (const ship of current.ships) {
|
|
253
|
+
if (ids.has(ship.id) || slots.some(slot => slot.group === ship.group && slot.slot === ship.slot)) ship.retreated = true
|
|
254
|
+
}
|
|
255
|
+
return true
|
|
256
|
+
}
|
|
257
|
+
if (type !== 'result' || current.phase !== 'ready') return false
|
|
258
|
+
current.phase = 'settled'
|
|
259
|
+
current.battles += 1
|
|
260
|
+
const hasEscort = current.ships.some(ship => ship.group === 'escort')
|
|
261
|
+
if (!hasEscort && !current.expectsEscort && Array.isArray(event.escort) && event.escort.some(value => value > 0)) {
|
|
262
|
+
current.incomplete = true
|
|
263
|
+
return true
|
|
264
|
+
}
|
|
265
|
+
for (const [group, raw] of [['main', event.main], ['escort', event.escort]]) {
|
|
266
|
+
const values = decodeRewards(current.ships, group, raw)
|
|
267
|
+
if (values) reward(current, values)
|
|
268
|
+
else current.incomplete = true
|
|
269
|
+
}
|
|
270
|
+
return true
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
update()
|
|
274
|
+
return {
|
|
275
|
+
handle(event) {
|
|
276
|
+
if (!event || typeof event.type !== 'string') return
|
|
277
|
+
const changed = accept(event)
|
|
278
|
+
const retrying = dirty
|
|
279
|
+
persist()
|
|
280
|
+
if (changed || retrying) update()
|
|
281
|
+
},
|
|
282
|
+
setTracking(enabled) {
|
|
283
|
+
if (!accountId || readFailed) return
|
|
284
|
+
archive = { ...archive, tracking: Boolean(enabled) }
|
|
285
|
+
dirty = true
|
|
286
|
+
persist()
|
|
287
|
+
update()
|
|
288
|
+
},
|
|
289
|
+
setTotalShipSelection(ids) {
|
|
290
|
+
if (!accountId || readFailed) return
|
|
291
|
+
archive = { ...archive, totalShipSelection: normalizeShipSelection(ids) }
|
|
292
|
+
dirty = true
|
|
293
|
+
persist()
|
|
294
|
+
update()
|
|
295
|
+
},
|
|
296
|
+
clearTotal() {
|
|
297
|
+
if (!accountId || readFailed) return
|
|
298
|
+
current?.pendingTotals.clear()
|
|
299
|
+
archive = { ...archive, totalSorties: 0, totals: [] }
|
|
300
|
+
dirty = true
|
|
301
|
+
persist()
|
|
302
|
+
update()
|
|
303
|
+
},
|
|
304
|
+
getSnapshot: () => view,
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
module.exports = { createExperience }
|
package/src/panel.css
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
.pex {
|
|
2
|
+
--bg: #10171f;
|
|
3
|
+
--surface: #17222e;
|
|
4
|
+
--row: #192633;
|
|
5
|
+
--raised: #202f3e;
|
|
6
|
+
--line: #2b3b4b;
|
|
7
|
+
--text: #ebf2f8;
|
|
8
|
+
--muted: #a1b3c4;
|
|
9
|
+
--accent: #9bc9f6;
|
|
10
|
+
--live: #f3cc7d;
|
|
11
|
+
--done: #8bdbbf;
|
|
12
|
+
--danger-text: #ff8492;
|
|
13
|
+
box-sizing: border-box;
|
|
14
|
+
width: 100%;
|
|
15
|
+
min-width: 0;
|
|
16
|
+
max-width: 1464px;
|
|
17
|
+
margin: 0 auto;
|
|
18
|
+
padding: clamp(15px, 2.5%, 36px);
|
|
19
|
+
color: var(--text);
|
|
20
|
+
background: var(--bg);
|
|
21
|
+
font: 14px/1.5 "Segoe UI", "Microsoft YaHei UI", sans-serif;
|
|
22
|
+
container-type: inline-size;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
.pex *, .pex *::before, .pex *::after { box-sizing: border-box; }
|
|
26
|
+
body:not(.bp6-dark):not(.bp5-dark) .pex {
|
|
27
|
+
--bg: #eff5fb;
|
|
28
|
+
--surface: #fff;
|
|
29
|
+
--row: #f4f8fb;
|
|
30
|
+
--raised: #e8f1f9;
|
|
31
|
+
--line: #c9d9e7;
|
|
32
|
+
--text: #1c3143;
|
|
33
|
+
--muted: #4e6476;
|
|
34
|
+
--accent: #145f8a;
|
|
35
|
+
--live: #855a00;
|
|
36
|
+
--done: #116751;
|
|
37
|
+
--danger-text: #b42336;
|
|
38
|
+
}
|
|
39
|
+
.pex button { font: inherit; cursor: pointer; }
|
|
40
|
+
.pex button:disabled { cursor: default; opacity: .48; }
|
|
41
|
+
.pex button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
42
|
+
.pex h2, .pex h3, .pex p { margin: 0; }
|
|
43
|
+
.pex-card-heading {
|
|
44
|
+
display: flex;
|
|
45
|
+
justify-content: space-between;
|
|
46
|
+
align-items: center;
|
|
47
|
+
flex-wrap: wrap;
|
|
48
|
+
gap: 8px 12px;
|
|
49
|
+
}
|
|
50
|
+
.pex-card-controls { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
|
|
51
|
+
.pex-pagination {
|
|
52
|
+
display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 8px;
|
|
53
|
+
padding: 10px 16px; border-top: 1px solid var(--line);
|
|
54
|
+
}
|
|
55
|
+
.pex-header { margin-bottom: 16px; }
|
|
56
|
+
.pex .pex-header h2 { font-size: 30px; font-weight: 720; margin-top: 3px; line-height: 1.24; letter-spacing: -.035em; }
|
|
57
|
+
.pex-kicker { color: var(--accent); font-size: 11px; font-weight: 700; letter-spacing: .17em; }
|
|
58
|
+
.pex-button, .pex-switch {
|
|
59
|
+
border: 1px solid var(--line);
|
|
60
|
+
border-radius: 6px;
|
|
61
|
+
background: var(--raised);
|
|
62
|
+
color: var(--text);
|
|
63
|
+
padding: 6px 10px;
|
|
64
|
+
white-space: nowrap;
|
|
65
|
+
}
|
|
66
|
+
.pex .pex-button, .pex .pex-switch { font-size: 12px; }
|
|
67
|
+
.pex-button:hover:enabled, .pex-switch:hover:enabled { border-color: var(--accent); }
|
|
68
|
+
.pex-button.pex-clear-button {
|
|
69
|
+
background: #b42336;
|
|
70
|
+
border-color: #b42336;
|
|
71
|
+
color: #fff;
|
|
72
|
+
}
|
|
73
|
+
.pex-button.pex-clear-button:hover:enabled { background: #932032; border-color: #932032; }
|
|
74
|
+
.pex-clear-dialog, .pex-filter-dialog {
|
|
75
|
+
width: min(360px, calc(100% - 32px));
|
|
76
|
+
padding: 20px;
|
|
77
|
+
background: var(--surface);
|
|
78
|
+
color: var(--text);
|
|
79
|
+
border: 1px solid var(--line);
|
|
80
|
+
border-radius: 10px;
|
|
81
|
+
}
|
|
82
|
+
.pex-clear-dialog::backdrop, .pex-filter-dialog::backdrop { background: rgb(0 0 0 / .55); }
|
|
83
|
+
.pex-clear-dialog h3, .pex-filter-dialog h3 { font-size: 18px; font-weight: 680; }
|
|
84
|
+
.pex .pex-clear-dialog p, .pex .pex-filter-dialog p { margin-top: 8px; color: var(--muted); }
|
|
85
|
+
.pex-clear-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 18px; }
|
|
86
|
+
.pex-button.pex-clear-confirm { color: var(--danger-text); }
|
|
87
|
+
.pex-button.pex-clear-confirm:hover:enabled { border-color: var(--danger-text); }
|
|
88
|
+
.pex-filter-dialog { width: min(960px, calc(100% - 24px)); padding: clamp(10px, 3vw, 20px); }
|
|
89
|
+
.pex-filter-search {
|
|
90
|
+
display: block; width: 100%; margin-top: 14px; padding: 7px 10px;
|
|
91
|
+
border: 1px solid var(--line); border-radius: 6px;
|
|
92
|
+
background: var(--bg); color: var(--text); font: inherit;
|
|
93
|
+
}
|
|
94
|
+
.pex-filter-search:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
95
|
+
.pex-filter-tools { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin-top: 10px; }
|
|
96
|
+
.pex-filter-tools > span { margin-right: auto; }
|
|
97
|
+
.pex-filter-list {
|
|
98
|
+
max-height: min(48vh, 420px); overflow: auto; margin-top: 10px;
|
|
99
|
+
border: 1px solid var(--line); border-radius: 6px; container-type: inline-size;
|
|
100
|
+
}
|
|
101
|
+
.pex-button.pex-filter-done { border-color: var(--accent); color: var(--accent); }
|
|
102
|
+
.pex-tabs {
|
|
103
|
+
display: flex;
|
|
104
|
+
gap: 27px;
|
|
105
|
+
border-bottom: 1px solid var(--line);
|
|
106
|
+
margin-bottom: 18px;
|
|
107
|
+
}
|
|
108
|
+
.pex-tabs button {
|
|
109
|
+
position: relative; background: none; border: 0; color: var(--muted);
|
|
110
|
+
font-weight: 600; padding: 9px 2px 11px; min-width: 72px; text-align: left;
|
|
111
|
+
}
|
|
112
|
+
.pex-tabs button[aria-pressed="true"] { color: var(--text); }
|
|
113
|
+
.pex-tabs button[aria-pressed="true"]::after {
|
|
114
|
+
content: ""; position: absolute; inset: auto 0 -1px; height: 2px; background: var(--accent); border-radius: 2px;
|
|
115
|
+
}
|
|
116
|
+
.pex-content { --value: var(--done); }
|
|
117
|
+
.pex-content[data-tone="live"] { --value: var(--live); }
|
|
118
|
+
.pex-card { min-width: 0; background: var(--surface); border: 1px solid var(--line); border-radius: 10px; overflow: hidden; }
|
|
119
|
+
.pex-context {
|
|
120
|
+
display: grid; grid-template-columns: repeat(3, minmax(0,1fr)) minmax(0,1.4fr) auto;
|
|
121
|
+
align-items: center; margin-bottom: 18px; padding: 14px 16px;
|
|
122
|
+
}
|
|
123
|
+
.pex-context-total { grid-template-columns: repeat(2, minmax(0,1fr)) auto; gap: 12px; }
|
|
124
|
+
.pex-metric { min-width: 0; padding: 0 17px; border-left: 1px solid var(--line); }
|
|
125
|
+
.pex-metric:first-child { padding-left: 0; border-left: 0; }
|
|
126
|
+
.pex-muted { color: var(--muted); font-size: 12px; }
|
|
127
|
+
.pex-metric-value { display: block; margin-top: 3px; font-size: 19px; font-weight: 650; overflow-wrap: anywhere; }
|
|
128
|
+
.pex-metric-value small { color: var(--muted); font-size: 12px; font-weight: 500; white-space: nowrap; }
|
|
129
|
+
.pex-status {
|
|
130
|
+
display: inline-flex; align-items: center; gap: 7px; justify-self: end; padding: 5px 9px;
|
|
131
|
+
border: 1px solid color-mix(in srgb, var(--value) 43%, transparent);
|
|
132
|
+
background: color-mix(in srgb, var(--value) 8%, transparent); color: var(--value);
|
|
133
|
+
border-radius: 6px; font-size: 12px; font-weight: 650; white-space: nowrap;
|
|
134
|
+
}
|
|
135
|
+
.pex-status::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
|
|
136
|
+
.pex-total-controls { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; }
|
|
137
|
+
.pex-switch { display: inline-flex; align-items: center; gap: 8px; }
|
|
138
|
+
.pex-switch::before {
|
|
139
|
+
content: ""; width: 23px; height: 12px; border-radius: 10px;
|
|
140
|
+
background: #425266; box-shadow: inset 6px 0 0 #b7c6d4; transition: box-shadow .14s;
|
|
141
|
+
}
|
|
142
|
+
.pex-switch[aria-checked="true"] { color: var(--done); }
|
|
143
|
+
.pex-switch[aria-checked="true"]::before { background: #335a52; box-shadow: inset -6px 0 0 var(--done); }
|
|
144
|
+
.pex-card-heading { align-items: baseline; padding: 15px 16px; }
|
|
145
|
+
.pex .pex-card h3 { font-size: 16px; line-height: 1.5; font-weight: 680; }
|
|
146
|
+
.pex-table {
|
|
147
|
+
--table-inset: 16px;
|
|
148
|
+
/* Reserve space for borders, padding, and the optional selection column. */
|
|
149
|
+
--cell-width: max(1px, calc((100cqi - 3px - var(--selection-width, 0px)) / 3 - 2 * var(--table-inset)));
|
|
150
|
+
--tile-scale: min(1, calc(var(--cell-width) / 226px));
|
|
151
|
+
--exp-size: 28.5px;
|
|
152
|
+
width: 100%;
|
|
153
|
+
border-collapse: collapse; table-layout: fixed; font-variant-numeric: tabular-nums;
|
|
154
|
+
}
|
|
155
|
+
.pex-table th, .pex-table td { text-align: left; padding: 8px var(--table-inset); }
|
|
156
|
+
.pex-table thead th, .pex-table .pex-fleet th {
|
|
157
|
+
background: var(--raised); color: var(--muted); font-size: 12px; font-weight: 600; white-space: nowrap;
|
|
158
|
+
}
|
|
159
|
+
.pex-table thead th { font-size: min(12px, calc(var(--cell-width) / 5)); }
|
|
160
|
+
.pex-table td {
|
|
161
|
+
height: min(65px, calc(var(--cell-width) * 58 / 226 + 7px));
|
|
162
|
+
padding-block: 3px;
|
|
163
|
+
}
|
|
164
|
+
.pex-table tr + tr td { box-shadow: inset 0 1px var(--line); }
|
|
165
|
+
.pex-table tbody tr:nth-child(even) { background: color-mix(in srgb, var(--surface), var(--row)); }
|
|
166
|
+
.pex-table tbody tr:hover { background: var(--row); }
|
|
167
|
+
.pex-filter-table { --selection-width: clamp(26px, 5cqi, 40px); }
|
|
168
|
+
.pex-filter-column { width: var(--selection-width); }
|
|
169
|
+
.pex-filter-table thead th { position: sticky; top: 0; z-index: 1; }
|
|
170
|
+
.pex-filter-table .pex-filter-checkbox { padding-inline: 0; text-align: center; }
|
|
171
|
+
.pex-filter-checkbox label { display: grid; min-height: 24px; place-items: center; cursor: pointer; }
|
|
172
|
+
.pex-filter-checkbox input { margin: 0; accent-color: var(--accent); cursor: pointer; }
|
|
173
|
+
.pex .pex-ship-tile {
|
|
174
|
+
display: grid;
|
|
175
|
+
grid-template-columns: 70px 150px;
|
|
176
|
+
grid-template-rows: 16px 10px 16px 16px;
|
|
177
|
+
column-gap: 6px;
|
|
178
|
+
align-items: center;
|
|
179
|
+
position: relative;
|
|
180
|
+
width: 226px;
|
|
181
|
+
height: 58px;
|
|
182
|
+
zoom: var(--tile-scale);
|
|
183
|
+
padding: 0;
|
|
184
|
+
border-radius: 0;
|
|
185
|
+
box-shadow: none;
|
|
186
|
+
overflow: hidden;
|
|
187
|
+
white-space: nowrap;
|
|
188
|
+
font-size: 15px;
|
|
189
|
+
line-height: 1.4286;
|
|
190
|
+
font-variant-numeric: normal;
|
|
191
|
+
}
|
|
192
|
+
.pex-avatar-unavailable { opacity: 0; }
|
|
193
|
+
.pex-ship-tile .ship-avatar { max-width: none; }
|
|
194
|
+
.pex-exp, .pex-share {
|
|
195
|
+
font-size: min(var(--exp-size), calc(var(--cell-width) / 7));
|
|
196
|
+
font-weight: 700; line-height: 1.25; white-space: nowrap;
|
|
197
|
+
}
|
|
198
|
+
.pex-exp { color: var(--value); }
|
|
199
|
+
.pex-share { color: #fff; }
|
|
200
|
+
body:not(.bp6-dark):not(.bp5-dark) .pex-share {
|
|
201
|
+
-webkit-text-stroke: 2px var(--text);
|
|
202
|
+
paint-order: stroke fill;
|
|
203
|
+
}
|
|
204
|
+
.pex-exp small { font-size: .58em; font-weight: 650; }
|
|
205
|
+
.pex-exp.pex-missing { color: var(--muted); }
|
|
206
|
+
.pex-exp.pex-missing, .pex-exp .pex-muted { font-size: min(12px, calc(var(--cell-width) / 3.5)); }
|
|
207
|
+
.pex .pex-warning { padding: 6px 0; margin-bottom: 9px; color: var(--live); font-size: 12px; }
|
|
208
|
+
.pex-empty { display: grid; gap: 4px; padding: 24px 16px; text-align: center; }
|
|
209
|
+
.pex-empty span { color: var(--muted); font-size: 12px; }
|
|
210
|
+
@container (max-width: 880px) {
|
|
211
|
+
.pex-table { --table-inset: 8px; --exp-size: 25.5px; }
|
|
212
|
+
}
|
|
213
|
+
@container (max-width: 690px) {
|
|
214
|
+
.pex-metric { padding-inline: 9px; }
|
|
215
|
+
}
|
|
216
|
+
@container (max-width: 560px) {
|
|
217
|
+
.pex-context { grid-template-columns: repeat(2, minmax(0,1fr)); gap: 10px 0; }
|
|
218
|
+
.pex-context:not(.pex-context-total) .pex-metric:nth-child(3) { padding-left: 0; border-left: 0; }
|
|
219
|
+
.pex-context .pex-status, .pex-total-controls { justify-self: start; grid-column: 1 / -1; }
|
|
220
|
+
}
|
|
221
|
+
@container (max-width: 430px) {
|
|
222
|
+
.pex .pex-header h2 { font-size: 23px; }
|
|
223
|
+
.pex-context { padding-inline: 10px; }
|
|
224
|
+
.pex-table { --table-inset: 4px; --exp-size: 22.5px; }
|
|
225
|
+
}
|
package/src/panel.js
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { poiPlugin } = require('../package.json')
|
|
4
|
+
const number = new Intl.NumberFormat('zh-CN')
|
|
5
|
+
const percent = new Intl.NumberFormat('zh-CN', { style: 'percent', minimumFractionDigits: 1, maximumFractionDigits: 1 })
|
|
6
|
+
const noShips = Object.freeze({})
|
|
7
|
+
const noGameSnapshot = () => noShips
|
|
8
|
+
const noGameSubscription = () => () => {}
|
|
9
|
+
const knownExp = value => Number.isSafeInteger(value) && value >= 0
|
|
10
|
+
const positive = value => Number.isSafeInteger(value) && value > 0
|
|
11
|
+
const shipsPerPage = 10
|
|
12
|
+
|
|
13
|
+
function matchingGameShip(ship, gameShips) {
|
|
14
|
+
const candidate = gameShips[ship.id]
|
|
15
|
+
return candidate && (!positive(ship.masterId) || ship.masterId === candidate.masterId) ? candidate : undefined
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function displayName(ship, live, t) {
|
|
19
|
+
return t(`resources:${live?.name ?? ship.name}`, { keySeparator: 'chiba' })
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function sumExperience(ships) {
|
|
23
|
+
return ships.length ? ships.reduce((sum, ship) => knownExp(ship.exp) ? (sum ?? 0) + ship.exp : sum, null) : 0
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function createPanel(React, tracker, subscribe, css, {
|
|
27
|
+
ShipAvatar, Gradient, ShipBasic, ShipName, ShipSubText, ShipLabel, useTranslation,
|
|
28
|
+
subscribeGame = noGameSubscription, getGameSnapshot = noGameSnapshot,
|
|
29
|
+
}) {
|
|
30
|
+
const h = React.createElement
|
|
31
|
+
const button = (label, onClick, options = {}) => h('button', { type: 'button', onClick, ...options }, label)
|
|
32
|
+
const expText = value => knownExp(value)
|
|
33
|
+
? [`${value > 0 ? '+' : ''}${number.format(value)}`, h('small', { key: 'unit' }, ' EXP')]
|
|
34
|
+
: '未捕获'
|
|
35
|
+
|
|
36
|
+
function ShipTile({ ship, live, name, t }) {
|
|
37
|
+
const masterId = live?.masterId ?? ship.masterId
|
|
38
|
+
const isDamaged = Boolean(live?.isDamaged ?? ship.isDamaged)
|
|
39
|
+
const level = live?.level ?? ship.level
|
|
40
|
+
const kind = live?.kind ?? ship.kind
|
|
41
|
+
const nextExp = live?.nextExp ?? ship.nextExp
|
|
42
|
+
const [failed, setFailed] = React.useState(null)
|
|
43
|
+
const artKey = `${masterId}:${isDamaged}`
|
|
44
|
+
return h('div', {
|
|
45
|
+
className: 'bp6-card pex-ship-tile',
|
|
46
|
+
onErrorCapture: () => setFailed(artKey),
|
|
47
|
+
onLoadCapture: () => setFailed(null),
|
|
48
|
+
},
|
|
49
|
+
positive(masterId) ? h(ShipAvatar, {
|
|
50
|
+
className: `pex-native-avatar${failed === artKey ? ' pex-avatar-unavailable' : ''}`,
|
|
51
|
+
mstId: masterId, isDamaged, height: 58, useFixedWidth: false, useDefaultBG: false,
|
|
52
|
+
}) : null,
|
|
53
|
+
h(Gradient, { color: live?.avatarColor ?? ship.avatarColor }),
|
|
54
|
+
h(ShipBasic, { avatar: true, className: 'ship-basic' },
|
|
55
|
+
h('span', { className: 'ship-lv' }, 'Lv. ', positive(level) ? level : '??'),
|
|
56
|
+
h(ShipLabel, { className: 'ship-type' }, kind ? t(`resources:${kind}`) : '??')),
|
|
57
|
+
h(ShipName, { avatar: true, className: 'ship-name', title: name }, name),
|
|
58
|
+
h(ShipSubText, { avatar: true, className: 'ship-exp' }, 'Next. ', knownExp(nextExp) ? nextExp : '—'))
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function ShipTable({ ships, fleet, total, sum, gameShips, selection }) {
|
|
62
|
+
const { t } = useTranslation(['main', 'resources'])
|
|
63
|
+
return h('table', {
|
|
64
|
+
className: selection ? 'pex-table pex-filter-table' : 'pex-table',
|
|
65
|
+
'aria-label': selection ? '已统计舰娘' : '逐舰获得经验',
|
|
66
|
+
},
|
|
67
|
+
selection ? h('colgroup', null, h('col', { className: 'pex-filter-column' }), h('col', { span: 3 })) : null,
|
|
68
|
+
h('thead', null, h('tr', null,
|
|
69
|
+
selection ? h('th', { scope: 'col', className: 'pex-filter-checkbox' }, '显示') : null,
|
|
70
|
+
h('th', { scope: 'col' }, '舰娘'),
|
|
71
|
+
h('th', { scope: 'col' }, total ? '总累计经验' : '获得经验'),
|
|
72
|
+
h('th', { scope: 'col' }, '占比'))),
|
|
73
|
+
h('tbody', null, ships.flatMap((ship, index) => {
|
|
74
|
+
const live = matchingGameShip(ship, gameShips)
|
|
75
|
+
const name = displayName(ship, live, t)
|
|
76
|
+
const share = knownExp(ship.exp) ? percent.format(sum ? ship.exp / sum : 0) : '—'
|
|
77
|
+
const row = h('tr', { key: ship.id, 'data-ship': ship.id },
|
|
78
|
+
selection ? h('td', { className: 'pex-filter-checkbox' },
|
|
79
|
+
h('label', null, h('input', {
|
|
80
|
+
type: 'checkbox', 'aria-label': `显示 ${name}`, checked: selection.ids.has(ship.id),
|
|
81
|
+
onChange: event => selection.toggle(ship.id, event.target.checked),
|
|
82
|
+
}))) : null,
|
|
83
|
+
h('td', null, h(ShipTile, { ship, live, name, t })),
|
|
84
|
+
h('td', { className: `pex-exp${knownExp(ship.exp) ? '' : ' pex-missing'}` }, expText(ship.exp),
|
|
85
|
+
ship.retreated ? h('div', { className: 'pex-muted' }, '已退避') : null),
|
|
86
|
+
h('td', { className: 'pex-share' }, share))
|
|
87
|
+
if (fleet && (index === 0 || ship.group !== ships[index - 1].group)) {
|
|
88
|
+
return [h('tr', { key: `fleet-${ship.id}`, className: 'pex-fleet' },
|
|
89
|
+
h('th', { colSpan: 3 }, ship.group === 'main' ? '主力舰队' : '护卫舰队')), row]
|
|
90
|
+
}
|
|
91
|
+
return [row]
|
|
92
|
+
})))
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function PaginatedShipTable({ ships, sum, gameShips }) {
|
|
96
|
+
const [pageIndex, setPageIndex] = React.useState(0)
|
|
97
|
+
const pageCount = Math.ceil(ships.length / shipsPerPage)
|
|
98
|
+
const page = Math.min(pageIndex, pageCount - 1)
|
|
99
|
+
const first = page * shipsPerPage
|
|
100
|
+
React.useEffect(() => {
|
|
101
|
+
setPageIndex(index => Math.min(index, pageCount - 1))
|
|
102
|
+
}, [pageCount])
|
|
103
|
+
return h(React.Fragment, null,
|
|
104
|
+
h(ShipTable, { ships: ships.slice(first, first + shipsPerPage), total: true, sum, gameShips }),
|
|
105
|
+
pageCount > 1 ? h('nav', { className: 'pex-pagination', 'aria-label': '总累计舰娘分页' },
|
|
106
|
+
button('上一页', () => setPageIndex(page - 1), { className: 'pex-button', disabled: page === 0 }),
|
|
107
|
+
h('span', { className: 'pex-muted', 'aria-live': 'polite' }, `第 ${page + 1} / ${pageCount} 页`),
|
|
108
|
+
button('下一页', () => setPageIndex(page + 1), { className: 'pex-button', disabled: page === pageCount - 1 }))
|
|
109
|
+
: null)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function metric(label, value, unit) {
|
|
113
|
+
return h('div', { className: 'pex-metric' },
|
|
114
|
+
h('div', { className: 'pex-muted' }, label),
|
|
115
|
+
h('strong', { className: 'pex-metric-value' }, value, unit ? h('small', null, unit) : null))
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function details(ships, { total = false, sum, gameShips, totalCount = ships.length, customize, pageKey }) {
|
|
119
|
+
const count = ships.length === totalCount ? `${number.format(totalCount)} 艘舰船`
|
|
120
|
+
: `显示 ${number.format(ships.length)} / ${number.format(totalCount)} 艘`
|
|
121
|
+
const empty = totalCount ? '当前未显示任何舰娘' : total ? '暂无累计经验' : '暂无舰船记录'
|
|
122
|
+
const table = total ? h(PaginatedShipTable, { key: pageKey, ships, sum, gameShips })
|
|
123
|
+
: h(ShipTable, { ships, fleet: ships.some(ship => ship.group === 'escort'), sum, gameShips })
|
|
124
|
+
return h('section', { className: 'pex-card' },
|
|
125
|
+
h('header', { className: 'pex-card-heading' }, h('h3', null, total ? '逐舰总累计' : '舰队明细'),
|
|
126
|
+
h('div', { className: 'pex-card-controls' }, h('span', { className: 'pex-muted' }, count), customize)),
|
|
127
|
+
ships.length ? table : h('div', { className: 'pex-empty' }, h('strong', null, empty)))
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function runView(view, gameShips) {
|
|
131
|
+
const run = view.current ?? view.last
|
|
132
|
+
if (!run) return h('div', { className: 'pex-card pex-empty' },
|
|
133
|
+
h('strong', null, view.accountReady ? '暂无出击记录' : '等待游戏账号'),
|
|
134
|
+
h('span', null, view.accountReady ? '普通出击或演习结算后,这里会显示逐舰经验。' : '登录游戏并进入母港即可开始记录。'))
|
|
135
|
+
const live = Boolean(view.current)
|
|
136
|
+
const practice = run.kind === 'practice'
|
|
137
|
+
const status = practice ? (live ? '演习中' : '演习已结算') : (live ? '出击中' : '已返港')
|
|
138
|
+
const sum = sumExperience(run.ships)
|
|
139
|
+
return h('div', null,
|
|
140
|
+
h('div', { className: 'pex-card pex-context', 'aria-live': 'polite' },
|
|
141
|
+
metric(practice ? '出击类型' : '海域', practice ? '演习' : run.map),
|
|
142
|
+
metric('出击舰队', `第 ${run.deckId} 舰队`),
|
|
143
|
+
metric('已结算', number.format(run.battles), ' 场战斗'),
|
|
144
|
+
metric(practice ? '本次演习合计' : '本次舰队合计', sum === null ? '—' : expText(sum)),
|
|
145
|
+
h('span', { className: 'pex-status' }, status)),
|
|
146
|
+
run.incomplete ? h('p', { className: 'pex-warning' }, '数据不完整,仅显示已确认经验。') : null,
|
|
147
|
+
details(run.ships, { sum, gameShips }))
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function ClearTotalButton({ accountId, disabled }) {
|
|
151
|
+
const dialog = React.useRef(null)
|
|
152
|
+
const titleId = React.useId()
|
|
153
|
+
const descriptionId = React.useId()
|
|
154
|
+
const close = () => dialog.current.close()
|
|
155
|
+
const confirm = () => {
|
|
156
|
+
close()
|
|
157
|
+
if (tracker.getSnapshot().accountId === accountId) tracker.clearTotal()
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return h(React.Fragment, null,
|
|
161
|
+
button('清零', () => dialog.current.showModal(), {
|
|
162
|
+
className: 'pex-button pex-clear-button', 'aria-haspopup': 'dialog', disabled,
|
|
163
|
+
}),
|
|
164
|
+
h('dialog', {
|
|
165
|
+
ref: dialog, className: 'pex-clear-dialog', 'aria-labelledby': titleId, 'aria-describedby': descriptionId,
|
|
166
|
+
},
|
|
167
|
+
h('h3', { id: titleId }, '是否清零总累计记录?'),
|
|
168
|
+
h('p', { id: descriptionId }, '将清除当前账号的累计出击场数和经验。'),
|
|
169
|
+
h('div', { className: 'pex-clear-actions' },
|
|
170
|
+
button('取消', close, { className: 'pex-button' }),
|
|
171
|
+
button('确认', confirm, { className: 'pex-button pex-clear-confirm' }))))
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function TotalShipFilter({ view, sum, gameShips }) {
|
|
175
|
+
const { t } = useTranslation(['main', 'resources'])
|
|
176
|
+
const dialog = React.useRef(null)
|
|
177
|
+
const titleId = React.useId()
|
|
178
|
+
const [query, setQuery] = React.useState('')
|
|
179
|
+
const [isOpen, setIsOpen] = React.useState(false)
|
|
180
|
+
const [draft, setDraft] = React.useState(null)
|
|
181
|
+
const ships = view.totals
|
|
182
|
+
const selected = new Set(draft ?? ships.map(ship => ship.id))
|
|
183
|
+
const shown = ships.filter(ship => selected.has(ship.id)).length
|
|
184
|
+
const search = query.trim().toLocaleLowerCase()
|
|
185
|
+
const matches = search ? ships.filter(ship => {
|
|
186
|
+
const live = matchingGameShip(ship, gameShips)
|
|
187
|
+
return displayName(ship, live, t).toLocaleLowerCase().includes(search)
|
|
188
|
+
}) : ships
|
|
189
|
+
const toggle = (id, checked) => {
|
|
190
|
+
setDraft(previous => {
|
|
191
|
+
const next = new Set(previous ?? ships.map(ship => ship.id))
|
|
192
|
+
if (checked) next.add(id)
|
|
193
|
+
else next.delete(id)
|
|
194
|
+
return ships.every(ship => next.has(ship.id)) ? null : [...next]
|
|
195
|
+
})
|
|
196
|
+
}
|
|
197
|
+
const open = () => {
|
|
198
|
+
setQuery('')
|
|
199
|
+
setDraft(view.totalShipSelection)
|
|
200
|
+
dialog.current.showModal()
|
|
201
|
+
setIsOpen(true)
|
|
202
|
+
}
|
|
203
|
+
const close = () => dialog.current.close()
|
|
204
|
+
const confirm = () => {
|
|
205
|
+
const current = tracker.getSnapshot()
|
|
206
|
+
if (current.accountId === view.accountId && draft !== current.totalShipSelection) {
|
|
207
|
+
const available = new Set(current.totals.map(ship => ship.id))
|
|
208
|
+
tracker.setTotalShipSelection(draft?.filter(id => available.has(id)) ?? null)
|
|
209
|
+
}
|
|
210
|
+
close()
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return h(React.Fragment, null,
|
|
214
|
+
button('自定义显示', open, { className: 'pex-button', 'aria-haspopup': 'dialog', disabled: !view.accountReady }),
|
|
215
|
+
h('dialog', {
|
|
216
|
+
ref: dialog, className: 'pex-filter-dialog', 'aria-labelledby': titleId,
|
|
217
|
+
onClose: event => setIsOpen(event.currentTarget.open),
|
|
218
|
+
},
|
|
219
|
+
h('h3', { id: titleId }, '自定义显示舰娘'),
|
|
220
|
+
h('p', null, '点击「完成」后应用显示设置。隐藏的舰娘仍会累计经验。'),
|
|
221
|
+
h('input', {
|
|
222
|
+
className: 'pex-filter-search', type: 'search', value: query, placeholder: '搜索舰娘',
|
|
223
|
+
'aria-label': '搜索舰娘', onChange: event => setQuery(event.target.value),
|
|
224
|
+
}),
|
|
225
|
+
h('div', { className: 'pex-filter-tools' },
|
|
226
|
+
h('span', { className: 'pex-muted', 'aria-live': 'polite' }, `已选 ${shown} / ${ships.length} 艘`),
|
|
227
|
+
button('显示全部', () => setDraft(null), { className: 'pex-button' }),
|
|
228
|
+
button('全部隐藏', () => setDraft([]), { className: 'pex-button', disabled: !ships.length })),
|
|
229
|
+
isOpen && (matches.length ? h('div', { className: 'pex-filter-list' },
|
|
230
|
+
h(ShipTable, { ships: matches, total: true, sum, gameShips, selection: { ids: selected, toggle } }))
|
|
231
|
+
: h('p', null, ships.length ? '没有匹配的舰娘' : '暂无已统计的舰娘')),
|
|
232
|
+
h('div', { className: 'pex-clear-actions' },
|
|
233
|
+
button('取消', close, { className: 'pex-button' }),
|
|
234
|
+
button('完成', confirm, { className: 'pex-button pex-filter-done' }))))
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function totalView(view, gameShips) {
|
|
238
|
+
const disabled = !view.accountReady
|
|
239
|
+
const sum = sumExperience(view.totals)
|
|
240
|
+
const selection = view.totalShipSelection === null ? null : new Set(view.totalShipSelection)
|
|
241
|
+
const ships = selection === null ? view.totals : view.totals.filter(ship => selection.has(ship.id))
|
|
242
|
+
return h('div', null,
|
|
243
|
+
h('div', { className: 'pex-card pex-context pex-context-total' },
|
|
244
|
+
metric('累计记录', `总累计 ${number.format(view.totalSorties)} 场`, ' 出击'),
|
|
245
|
+
metric('全部舰船合计', expText(sum)),
|
|
246
|
+
h('div', { className: 'pex-total-controls' },
|
|
247
|
+
button(view.tracking ? '累计开启' : '累计关闭', () => tracker.setTracking(!view.tracking), {
|
|
248
|
+
className: 'pex-switch', role: 'switch', 'aria-label': '累计开关', 'aria-checked': view.tracking, disabled,
|
|
249
|
+
}),
|
|
250
|
+
h(ClearTotalButton, { key: view.accountId, accountId: view.accountId, disabled }))),
|
|
251
|
+
details(ships, {
|
|
252
|
+
total: true, sum, gameShips, totalCount: view.totals.length,
|
|
253
|
+
pageKey: `${view.accountId}:${JSON.stringify(view.totalShipSelection)}`,
|
|
254
|
+
customize: h(TotalShipFilter, { key: view.accountId, view, sum, gameShips }),
|
|
255
|
+
}))
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return function ExperiencePanel() {
|
|
259
|
+
const view = React.useSyncExternalStore(subscribe, tracker.getSnapshot, tracker.getSnapshot)
|
|
260
|
+
const gameShips = React.useSyncExternalStore(subscribeGame, getGameSnapshot, getGameSnapshot) ?? noShips
|
|
261
|
+
const [tab, setTab] = React.useState('run')
|
|
262
|
+
return h('section', { className: 'pex', 'aria-label': poiPlugin.name },
|
|
263
|
+
h('style', null, css),
|
|
264
|
+
h('header', { className: 'pex-header' },
|
|
265
|
+
h('div', { className: 'pex-kicker' }, 'SORTIE EXPERIENCE'), h('h2', null, poiPlugin.name)),
|
|
266
|
+
h('div', { className: 'pex-tabs', 'aria-label': '经验统计视图' },
|
|
267
|
+
button(view.current ? '本次出击' : '上次出击', () => setTab('run'), { 'aria-pressed': tab === 'run' }),
|
|
268
|
+
button('总累计', () => setTab('total'), { 'aria-pressed': tab === 'total' })),
|
|
269
|
+
h('div', { className: 'pex-content', 'data-tone': tab === 'run' && view.current ? 'live' : 'done' },
|
|
270
|
+
view.storageError ? h('p', { className: 'pex-warning', role: 'status' }, '本地统计暂时无法读取或保存。') : null,
|
|
271
|
+
tab === 'run' ? runView(view, gameShips) : totalView(view, gameShips)))
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
module.exports = { createPanel }
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { createShipProjection, positive, readShipDisplay } = require('./poi-ship-data')
|
|
4
|
+
|
|
5
|
+
const prefix = '/kcsapi/'
|
|
6
|
+
const phases = new Set([
|
|
7
|
+
...['battle', 'airbattle', 'ld_airbattle', 'ld_shooting'].map(name => `api_req_sortie/${name}`),
|
|
8
|
+
...[
|
|
9
|
+
'battle', 'battle_water', 'airbattle', 'ld_airbattle', 'ld_shooting', 'ec_battle',
|
|
10
|
+
'each_battle', 'each_battle_water', 'midnight_battle', 'sp_midnight',
|
|
11
|
+
'ec_midnight_battle', 'ec_night_to_day',
|
|
12
|
+
].map(name => `api_req_combined_battle/${name}`),
|
|
13
|
+
'api_req_battle_midnight/battle', 'api_req_battle_midnight/sp_midnight',
|
|
14
|
+
])
|
|
15
|
+
|
|
16
|
+
function getMap(body, post) {
|
|
17
|
+
const area = positive(body.api_maparea_id ?? post.api_maparea_id)
|
|
18
|
+
const map = positive(body.api_mapinfo_no ?? post.api_mapinfo_no)
|
|
19
|
+
return area && map ? `${area}-${map}` : undefined
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function practiceDeck(body, post) {
|
|
23
|
+
const returned = positive(body.api_deck_id)
|
|
24
|
+
const requested = positive(post.api_deck_id)
|
|
25
|
+
return returned && requested && returned !== requested ? 0 : returned ?? requested
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function deckRoster(state, deckId, group) {
|
|
29
|
+
const fleets = Object.values(state?.info?.fleets ?? {})
|
|
30
|
+
const fleet = fleets.find(row => positive(row?.api_id) === deckId)
|
|
31
|
+
if (!Array.isArray(fleet?.api_ship)) return []
|
|
32
|
+
return fleet.api_ship.flatMap((rawId, slot) => {
|
|
33
|
+
const id = positive(rawId)
|
|
34
|
+
if (id === undefined) return []
|
|
35
|
+
return [{ id, slot, group, name: '未知舰船', ...readShipDisplay(state, state.info.ships?.[id]) }]
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function createPoiAdapter({ tracker, getState, getShipSnapshot }) {
|
|
40
|
+
let memberId
|
|
41
|
+
let awaitingPortFor
|
|
42
|
+
let combined = false
|
|
43
|
+
const seen = new Set()
|
|
44
|
+
const emit = (type, data) => tracker.handle({ type, ...data })
|
|
45
|
+
const shipSnapshot = getShipSnapshot ?? createShipProjection(getState, () => memberId)
|
|
46
|
+
|
|
47
|
+
function account(id) {
|
|
48
|
+
if (id !== memberId) combined = false
|
|
49
|
+
memberId = id
|
|
50
|
+
emit('account', { accountId: id })
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function awaitPort(id) {
|
|
54
|
+
awaitingPortFor = id
|
|
55
|
+
account(undefined)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function reconcileShips(ships) {
|
|
59
|
+
if (!Array.isArray(ships)) return
|
|
60
|
+
const owned = new Set(ships.map(ship => positive(ship?.api_id)))
|
|
61
|
+
if (owned.has(undefined) || owned.size !== ships.length) return
|
|
62
|
+
const view = tracker.getSnapshot()
|
|
63
|
+
const recorded = new Set([...view.totals.map(ship => ship.id), ...(view.totalShipSelection ?? [])])
|
|
64
|
+
emit('removeShips', { accountId: memberId, ids: [...recorded].filter(id => !owned.has(id)) })
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
onResponse(detail) {
|
|
69
|
+
if (!detail?.path?.startsWith(prefix)) return
|
|
70
|
+
const path = detail.path.slice(prefix.length)
|
|
71
|
+
const envelope = detail.body ?? {}
|
|
72
|
+
if (envelope.api_result != null && Number(envelope.api_result) !== 1) return
|
|
73
|
+
const body = envelope.api_data ?? envelope
|
|
74
|
+
const post = detail.postBody ?? {}
|
|
75
|
+
|
|
76
|
+
// The host time identifies re-delivery of the same response. Distinct
|
|
77
|
+
// results still need an active phase, even when the time differs.
|
|
78
|
+
if (Number.isFinite(detail.time)) {
|
|
79
|
+
const key = `${path}:${detail.time}`
|
|
80
|
+
if (seen.has(key)) return
|
|
81
|
+
seen.add(key)
|
|
82
|
+
if (seen.size > 96) seen.delete(seen.values().next().value)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (path === 'api_start2/getData') {
|
|
86
|
+
awaitPort()
|
|
87
|
+
return
|
|
88
|
+
}
|
|
89
|
+
if (path === 'api_get_member/require_info' || path === 'api_get_member/basic') {
|
|
90
|
+
const id = positive(body.api_basic?.api_member_id ?? body.api_member_id)
|
|
91
|
+
if (id && String(id) !== memberId) awaitPort(String(id))
|
|
92
|
+
return
|
|
93
|
+
}
|
|
94
|
+
if (path === 'api_port/port') {
|
|
95
|
+
const id = positive(body.api_basic?.api_member_id)
|
|
96
|
+
if (!id) return
|
|
97
|
+
const confirmed = String(id)
|
|
98
|
+
if (awaitingPortFor && awaitingPortFor !== confirmed) return
|
|
99
|
+
awaitingPortFor = undefined
|
|
100
|
+
account(confirmed)
|
|
101
|
+
emit('port', { accountId: memberId, shipDisplay: shipSnapshot() })
|
|
102
|
+
reconcileShips(body.api_ship)
|
|
103
|
+
return
|
|
104
|
+
}
|
|
105
|
+
if (!memberId) return
|
|
106
|
+
|
|
107
|
+
// Poi updates Redux before emitting game.response, including for account changes.
|
|
108
|
+
const state = getState()
|
|
109
|
+
const hostMemberId = positive(state?.info?.basic?.api_member_id)
|
|
110
|
+
if (hostMemberId && String(hostMemberId) !== memberId) {
|
|
111
|
+
awaitPort(String(hostMemberId))
|
|
112
|
+
return
|
|
113
|
+
}
|
|
114
|
+
if (path === 'api_req_kousyou/destroyship' || path === 'api_req_kaisou/powerup') {
|
|
115
|
+
const modernization = path === 'api_req_kaisou/powerup'
|
|
116
|
+
const target = positive(body.api_ship?.api_id)
|
|
117
|
+
if (modernization && (!target || (post.api_id != null && positive(post.api_id) !== target))) return
|
|
118
|
+
const rawIds = modernization ? post.api_id_items : post.api_ship_id
|
|
119
|
+
if (typeof rawIds !== 'string' && typeof rawIds !== 'number') return
|
|
120
|
+
const ids = String(rawIds).split(',').map(positive)
|
|
121
|
+
if (ids.every(id => id !== undefined) && (!modernization || !ids.includes(target))) {
|
|
122
|
+
emit('removeShips', { accountId: memberId, ids })
|
|
123
|
+
}
|
|
124
|
+
return
|
|
125
|
+
}
|
|
126
|
+
if (path === 'api_req_map/start' || path === 'api_req_practice/battle') {
|
|
127
|
+
const isPractice = path === 'api_req_practice/battle'
|
|
128
|
+
const deckId = isPractice ? practiceDeck(body, post) : positive(post.api_deck_id ?? body.api_deck_id)
|
|
129
|
+
combined = !isPractice && deckId === 1 && positive(state?.sortie?.combinedFlag) !== undefined
|
|
130
|
+
const main = deckRoster(state, deckId, 'main')
|
|
131
|
+
const escort = combined ? deckRoster(state, 2, 'escort') : []
|
|
132
|
+
emit(isPractice ? 'practice' : 'sortie', {
|
|
133
|
+
deckId, map: getMap(body, post), node: positive(body.api_no),
|
|
134
|
+
roster: main.length ? [...main, ...escort] : [],
|
|
135
|
+
expectsEscort: combined, incomplete: combined && !escort.length,
|
|
136
|
+
})
|
|
137
|
+
} else if (path === 'api_req_map/next') {
|
|
138
|
+
emit('next', { map: getMap(body, post), node: positive(body.api_no) })
|
|
139
|
+
} else if (phases.has(path)) {
|
|
140
|
+
const deckId = path.startsWith('api_req_sortie/') ? positive(post.api_deck_id ?? body.api_deck_id) : undefined
|
|
141
|
+
emit('phase', { deckId })
|
|
142
|
+
} else if (path === 'api_req_practice/midnight_battle') {
|
|
143
|
+
emit('practiceNight', { deckId: practiceDeck(body, post) })
|
|
144
|
+
} else if (path === 'api_req_practice/battle_result') {
|
|
145
|
+
emit('practiceResult', { deckId: practiceDeck(body, post), main: body.api_get_ship_exp, shipDisplay: shipSnapshot() })
|
|
146
|
+
} else if (path === 'api_req_sortie/battleresult' || path === 'api_req_combined_battle/battleresult') {
|
|
147
|
+
emit('result', { main: body.api_get_ship_exp, escort: body.api_get_ship_exp_combined })
|
|
148
|
+
} else if (path === 'api_req_sortie/goback_port' || path === 'api_req_combined_battle/goback_port') {
|
|
149
|
+
const positions = state?.sortie?.escapedPos ?? []
|
|
150
|
+
const slots = positions.filter(slot => Number.isSafeInteger(slot) && slot >= 0).map(slot =>
|
|
151
|
+
combined && slot >= 6 ? { group: 'escort', slot: slot - 6 } : { group: 'main', slot })
|
|
152
|
+
emit('retreat', { slots })
|
|
153
|
+
}
|
|
154
|
+
},
|
|
155
|
+
reset: () => awaitPort(),
|
|
156
|
+
interrupt: () => emit('interrupt'),
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// poi writes dotted config paths with lodash, where numeric keys imply arrays.
|
|
161
|
+
const storagePath = id => `plugin.poi-plugin-sortie-exp.experience.v1.member_${id}`
|
|
162
|
+
|
|
163
|
+
module.exports = { createPoiAdapter, storagePath }
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const noShips = Object.freeze({})
|
|
4
|
+
const noTagColors = Object.freeze([])
|
|
5
|
+
|
|
6
|
+
function positive(value) {
|
|
7
|
+
if (typeof value !== 'number' && typeof value !== 'string') return undefined
|
|
8
|
+
const number = Number(value)
|
|
9
|
+
return Number.isSafeInteger(number) && number > 0 ? number : undefined
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function readShipDisplay(state, owned) {
|
|
13
|
+
const masterId = positive(owned?.api_ship_id)
|
|
14
|
+
const level = positive(owned?.api_lv)
|
|
15
|
+
const nextExp = owned?.api_exp?.[1]
|
|
16
|
+
const master = state?.const?.$ships?.[masterId]
|
|
17
|
+
const kind = state?.const?.$shipTypes?.[master?.api_stype]?.api_name
|
|
18
|
+
const maxHp = positive(owned?.api_maxhp)
|
|
19
|
+
const nowHp = owned?.api_nowhp == null ? NaN : Number(owned.api_nowhp)
|
|
20
|
+
return {
|
|
21
|
+
...(masterId !== undefined && { masterId }),
|
|
22
|
+
...(level !== undefined && { level }),
|
|
23
|
+
...(Number.isSafeInteger(nextExp) && nextExp >= 0 && { nextExp }),
|
|
24
|
+
...(typeof master?.api_name === 'string' && master.api_name && { name: master.api_name }),
|
|
25
|
+
...(typeof kind === 'string' && kind && { kind }),
|
|
26
|
+
...(maxHp !== undefined && Number.isFinite(nowHp) && nowHp >= 0 && { isDamaged: nowHp * 2 <= maxHp }),
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function createShipProjection(getState, getAccountId, selectAvatarColor) {
|
|
31
|
+
let previousAccount
|
|
32
|
+
let previousOwned
|
|
33
|
+
let previousMaster
|
|
34
|
+
let previousAvatarType
|
|
35
|
+
let previousTagColors
|
|
36
|
+
let projection = noShips
|
|
37
|
+
|
|
38
|
+
// useSyncExternalStore needs the same reference until the relevant data changes.
|
|
39
|
+
return () => {
|
|
40
|
+
const state = getState()
|
|
41
|
+
const owner = positive(state?.info?.basic?.api_member_id)
|
|
42
|
+
const account = getAccountId()
|
|
43
|
+
const owned = state?.info?.ships
|
|
44
|
+
const master = state?.const
|
|
45
|
+
if (!owner || String(owner) !== account || !owned) return noShips
|
|
46
|
+
const avatarType = state.config?.poi?.appearance?.avatarType ?? ''
|
|
47
|
+
const tagColors = state.fcd?.shiptag?.color ?? noTagColors
|
|
48
|
+
if (account === previousAccount && owned === previousOwned && master === previousMaster &&
|
|
49
|
+
avatarType === previousAvatarType && tagColors === previousTagColors) return projection
|
|
50
|
+
previousAccount = account
|
|
51
|
+
previousOwned = owned
|
|
52
|
+
previousMaster = master
|
|
53
|
+
previousAvatarType = avatarType
|
|
54
|
+
previousTagColors = tagColors
|
|
55
|
+
projection = Object.fromEntries(Object.entries(owned).flatMap(([id, ship]) => {
|
|
56
|
+
const ownedId = positive(ship?.api_id ?? id)
|
|
57
|
+
if (!ship || ownedId === undefined) return []
|
|
58
|
+
const display = readShipDisplay(state, ship)
|
|
59
|
+
const masterShip = master?.$ships?.[display.masterId]
|
|
60
|
+
if (selectAvatarColor && masterShip) display.avatarColor = selectAvatarColor(ship, masterShip, tagColors, avatarType)
|
|
61
|
+
return [[ownedId, display]]
|
|
62
|
+
}))
|
|
63
|
+
return projection
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
module.exports = { createShipProjection, positive, readShipDisplay }
|