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 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
+ ![本次出击页的舰队经验合计和逐舰经验](https://raw.githubusercontent.com/yamatosaki/poi-plugin-sortie-exp/main/docs/images/sortie.png)
12
+
13
+ **总累计**显示累计出击场数和全部舰船经验合计。表格列出每艘舰娘的累计经验和占比,每页最多显示 10 艘,可以翻页查看,也可以通过「自定义显示」筛选舰娘。
14
+
15
+ ![总累计页的出击场数和逐舰累计经验](https://raw.githubusercontent.com/yamatosaki/poi-plugin-sortie-exp/main/docs/images/totals.png)
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 }