poi-plugin-senka-ledger 0.0.0-stage → 0.1.2

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
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 CHANGED
@@ -1,3 +1,72 @@
1
- # Temporary Holding Version
1
+ # 战果手账 · Senka Ledger
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ 面向 **poi 12.0.1** 的独立本地插件。按事件保存战果增量,可查看往月、每日和每小时的记录。界面目前使用简体中文。
4
+
5
+ ## 界面预览
6
+
7
+ 下图均使用模拟数据。
8
+
9
+ ![月度日历](https://raw.githubusercontent.com/asparagusThief/poi-plugin-senka-ledger/main/docs/month.png)
10
+
11
+ ![每日时段与明细](https://raw.githubusercontent.com/asparagusThief/poi-plugin-senka-ledger/main/docs/day.png)
12
+
13
+ ## 统计方式
14
+
15
+ - **本地确认**:出击结算的 `api_get_exp × 7 / 10000`,以及直接观测到的 EO、成功领取的常设季常战果任务奖励。
16
+ - **未定位增量**:总提督经验增长超过本场奖励的部分,记录「上次观测 → 本次观测」区间。可能来自其他客户端、插件关闭或漏包;无法仅凭差额判定具体原因。
17
+ - **不把发现时间当获得时间**:未定位记录只含经验产生的普通战果,不进入登录当天、登录小时或每日色块。本月内区间单列;跨月区间在相关月份显示区间总额,不分摊,也不能跨月相加。
18
+ - **首次使用**:总经验只建立基准,不把账号终身经验折算为本月战果。如果首次观测就是出击结算,可以记录该场明确给出的奖励。
19
+ - **EO**:直接响应中的 `api_get_exmap_rate` / `api_get_eo_rate` 按响应时刻记账,只在顶部汇总,不进入日历、小时色块或逐笔明细。不再根据地图完成状态推断未在本地记录的 EO,旧版日志中的此类记录也不展示。
20
+ - **任务**:仅在 poi 的成功领取响应后记账,支持任务 ID `854, 888, 893, 872, 284, 845, 903`。按日本时间 3/6/9/12 月 1 日 05:00 重置去重周期。其他战果任务尚未支持;其他客户端领取的任务奖励无法从经验差恢复。
21
+
22
+ 例如:昨天 22:00 总经验为 100000,今天 01:00 本地出击后总经验为 110000,本场经验为 1000。今天 01 时的色块只增加 **0.70**;剩余 **6.30** 作为昨天 22:00 到今天 01:00 的未定位增量单列。
23
+
24
+ 这里显示的是按自然日、自然月分组的**活动战果增量估算**,不是官方排名总战果。月末普通战果与 EO 截止、任务顺延、年内继承及官方排名四舍五入等规则不在此汇总中推算。月末截止后的本地活动仍保留,可回看其实际获得时间。
25
+
26
+ ## 查看与导出
27
+
28
+ 1. 启用插件后进入母港,等待实时账号数据建立基准。插件不会采用 poi 上一次运行缓存的账号或经验。
29
+ 2. 选择账号、月份和日本/中国时区。默认日本时间,原始日志统一保存 UTC 毫秒。
30
+ 3. 默认显示月度日历,每个日期色块直接显示当日普通战果;点击日期进入二级界面,查看 24 个小时的色块与数值。点击小时可筛选普通战果明细,「返回月览」回到日历。
31
+ 4. 月度和每日页面的顶部显示总战果、普通战果、EO 战果与任务战果。EO 和任务不出现在逐笔明细中;未在本地记录的增量只显示经验差额的区间、发现时间与数值。
32
+ 5. 「导出本月 CSV」顶部保留月度汇总,下方只有普通战果明细与涉及该月的经验未定位区间;「备份全部 JSON」包含该账号完整原始日志,与旧版数据兼容。
33
+
34
+ 历史从插件开始记录后积累,保留全部月份。游戏不提供往月逐场历史,因此无法自动恢复安装前的每场出击或其他客户端的准确刷取时刻。本地记录的时刻为 poi 接收到响应的时刻,系统时钟需要准确。
35
+
36
+ ## 安装
37
+
38
+ 无需编译或额外运行依赖,使用 poi 自带的 React。安装独立包,不会替换原战果计算插件,可同时运行。
39
+
40
+ npm 包名:`poi-plugin-senka-ledger`。在 poi 的插件管理中按此包名安装。也可从 [GitHub Releases](https://github.com/asparagusThief/poi-plugin-senka-ledger/releases) 下载本地插件包。
41
+
42
+ 在本目录运行:
43
+
44
+ ```powershell
45
+ & .\scripts\install-local.ps1
46
+ ```
47
+
48
+ 自定义 poi 数据目录可传 `-PoiDataDirectory '你的路径'`。安装脚本在目标已存在时停止,不覆盖任何现有插件。重启 poi,在插件设置中启用「战果手账 / Senka Ledger」,然后重新进入母港。
49
+
50
+ 也可使用 `dist/poi-plugin-senka-ledger-0.1.2.tgz` 通过 poi 的本地插件安装功能安装。在源码目录执行 `npm run pack:local` 后会生成此文件。
51
+
52
+ ## 数据与备份
53
+
54
+ 数据位于 poi 的 `APPDATA_PATH/senka-ledger/<账号ID>.jsonl`,Windows 通常是 `%APPDATA%\poi\senka-ledger\`。每个账号独立日志,逐笔追加并同步到磁盘;重启时重放日志,不按月份覆盖或清零。保存少量必要数值与标签,不保存 API token、完整响应、密码或游戏操作。
55
+
56
+ 停用、更新或卸载插件前建议备份整个 `senka-ledger` 数据文件夹。手动恢复时,关闭所有 poi 实例后将原始 `<账号ID>.jsonl` 放回数据目录。导出 JSON 的 `journal` 字段保存同一份原始日志文本,可原样恢复到相应账号文件;目前没有界面导入功能。
57
+
58
+ 同一数据目录只允许一个写入实例。异常退出留下的锁在确认进程不存在后自动恢复;若锁中的进程号已被其他进程复用,需要在关闭全部 poi 后手动移走 `.writer-lock`。日志存在损坏或未写完的末行时,只加载损坏前的有效数据并暂停该账号写入,保留文件供恢复,界面显示异常。
59
+
60
+ ## 开发验证
61
+
62
+ 需要 Node.js 20 或更新版本:
63
+
64
+ ```powershell
65
+ npm.cmd run check
66
+ npm.cmd test
67
+ npm.cmd run pack:local
68
+ ```
69
+
70
+ 测试覆盖跨日、跨月、跨年、重连、首次基准、联合舰队、未知增量、本场奖励缺失、重复响应、EO 对账、任务周期、时区分组、多账号、重启恢复与日志损坏。提供模拟数据预览生成器 `scripts/preview.js`;预览数据不写入真实账号文件。
71
+
72
+ 参考核对:[poi 12.0.1](https://github.com/poooi/poi/releases/tag/v12.0.1)、[现有战果计算插件](https://www.npmjs.com/package/poi-plugin-senka-calc)(5.6.2)。本插件为独立实现,未修改或覆盖现有插件。
@@ -0,0 +1 @@
1
+ {"Senka Ledger":"战果手账","Daily and hourly records with separate bonus totals":"每日与每小时战果记录"}
@@ -0,0 +1 @@
1
+ {"Senka Ledger":"戰果手帳","Daily and hourly records with separate bonus totals":"每日與每小時戰果紀錄"}
package/index.js ADDED
@@ -0,0 +1,73 @@
1
+ 'use strict'
2
+
3
+ const React = require('react')
4
+ const fs = require('fs')
5
+ const path = require('path')
6
+ const { Journal } = require('./src/storage')
7
+ const { createAdapter, decode } = require('./src/adapter')
8
+ const { createPanel } = require('./src/panel')
9
+ const { toCSV } = require('./src/export')
10
+ let journal = null
11
+ let adapter = createAdapter()
12
+ let error = null
13
+ let directory = ''
14
+ let loaded = false
15
+ const subscribers = new Set()
16
+ const snapshot = () => ({ account: adapter.account, accounts: [...new Set([...(journal ? journal.list() : []), ...(adapter.account ? [adapter.account] : [])])], error, directory })
17
+ const emit = () => { const value = snapshot(); for (const callback of subscribers) callback(value) }
18
+ function onResponse(event) {
19
+ try {
20
+ const previousAccount = adapter.account
21
+ const result = decode(adapter, event.detail)
22
+ if (result) for (const input of result.records) journal.append(result.account, input)
23
+ if (result?.records.length || adapter.account !== previousAccount) emit()
24
+ } catch (e) { error = e.message; emit() }
25
+ }
26
+ exports.pluginDidLoad = function pluginDidLoad() {
27
+ if (loaded) return
28
+ const { APPDATA_PATH, isMain } = require('views/env')
29
+ if (isMain === false) return
30
+ loaded = true
31
+ directory = path.join(APPDATA_PATH, 'senka-ledger')
32
+ try {
33
+ journal = new Journal(directory)
34
+ window.addEventListener('game.response', onResponse)
35
+ window.addEventListener('beforeunload', exports.pluginWillUnload)
36
+ } catch (e) { error = e.message }
37
+ emit()
38
+ }
39
+ exports.pluginWillUnload = function pluginWillUnload() {
40
+ window.removeEventListener('game.response', onResponse)
41
+ window.removeEventListener('beforeunload', exports.pluginWillUnload)
42
+ if (journal) journal.close()
43
+ journal = null
44
+ adapter = createAdapter()
45
+ error = null
46
+ loaded = false
47
+ }
48
+ const service = {
49
+ snapshot,
50
+ subscribe(callback) {
51
+ subscribers.add(callback)
52
+ exports.pluginDidLoad()
53
+ callback(snapshot())
54
+ return () => subscribers.delete(callback)
55
+ },
56
+ entry(account) {
57
+ if (!journal || !account) return null
58
+ try { return journal.load(account) } catch (e) { return { error: e.message } }
59
+ },
60
+ export(account, month, zone, type) {
61
+ if (!journal) throw new Error('存储尚未初始化')
62
+ const entry = journal.load(account)
63
+ const data = type === 'json' ? JSON.stringify({ format: 'poi-senka-ledger', version: 1, account, journal: fs.readFileSync(entry.file, 'utf8') }, null, 2) : toCSV(entry.state, month, zone)
64
+ const blob = new Blob([data], { type: type === 'json' ? 'application/json;charset=utf-8' : 'text/csv;charset=utf-8' })
65
+ const url = URL.createObjectURL(blob)
66
+ const a = document.createElement('a')
67
+ a.href = url
68
+ a.download = `senka-${account}-${type === 'json' ? 'all' : month}.${type}`
69
+ a.click()
70
+ setTimeout(() => URL.revokeObjectURL(url), 10000)
71
+ }
72
+ }
73
+ exports.reactClass = createPanel(React, service)
package/package.json CHANGED
@@ -1,6 +1,36 @@
1
1
  {
2
2
  "name": "poi-plugin-senka-ledger",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.2",
4
+ "description": "按事件记录战果,保留往月明细并分离来源不明的增量",
5
+ "main": "index.js",
6
+ "license": "MIT",
7
+ "author": "asparagusThief",
8
+ "homepage": "https://github.com/asparagusThief/poi-plugin-senka-ledger#readme",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/asparagusThief/poi-plugin-senka-ledger.git"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/asparagusThief/poi-plugin-senka-ledger/issues"
15
+ },
16
+ "keywords": ["poi", "poi-plugin", "kancolle", "senka", "战果"],
17
+ "publishConfig": {
18
+ "access": "public",
19
+ "registry": "https://registry.npmjs.org/"
20
+ },
21
+ "files": ["index.js", "src", "i18n", "README.md", "LICENSE"],
22
+ "scripts": {
23
+ "test": "node --test test/*.test.js",
24
+ "check": "node scripts/check.js",
25
+ "pack:local": "node scripts/pack.js"
26
+ },
27
+ "poiPlugin": {
28
+ "name": "Senka Ledger",
29
+ "id": "senka-ledger",
30
+ "description": "Daily and hourly records with separate bonus totals",
31
+ "i18nDir": "i18n",
32
+ "icon": "fa/line-chart",
33
+ "earliestCompatibleMain": "12.0.1",
34
+ "priority": 98
35
+ }
36
+ }
package/src/adapter.js ADDED
@@ -0,0 +1,69 @@
1
+ 'use strict'
2
+
3
+ const { EO, QUESTS } = require('./ledger')
4
+ const { parts } = require('./time')
5
+ const number = value => {
6
+ if (value === null || value === undefined || value === '' || typeof value === 'boolean') return null
7
+ const n = Number(value)
8
+ return Number.isSafeInteger(n) && n >= 0 ? n : null
9
+ }
10
+ const BATTLES = new Set(['/kcsapi/api_req_sortie/battleresult', '/kcsapi/api_req_combined_battle/battleresult'])
11
+ function createAdapter() {
12
+ return { account: null, mapId: null, node: null }
13
+ }
14
+ // Read only documented, relevant fields. Tokens and complete API bodies never enter the journal.
15
+ function decode(adapter, detail) {
16
+ const { path, body = {}, postBody = {}, time: at } = detail || {}
17
+ if (!Number.isSafeInteger(at) || at < 0) return null
18
+ const basic = path === '/kcsapi/api_port/port' || path === '/kcsapi/api_get_member/require_info' ? body.api_basic :
19
+ path === '/kcsapi/api_get_member/basic' ? body : null
20
+ if (basic && /^\d+$/.test(String(basic.api_member_id))) {
21
+ const account = String(basic.api_member_id)
22
+ if (adapter.account !== account) { adapter.mapId = null; adapter.node = null }
23
+ adapter.account = account
24
+ }
25
+ if (path === '/kcsapi/api_start2/getData') {
26
+ adapter.account = null
27
+ adapter.mapId = null
28
+ adapter.node = null
29
+ }
30
+ if (!adapter.account) return null
31
+ const records = []
32
+ const push = data => records.push({ v: 1, at, ...data })
33
+ let exp = null
34
+ if (basic) exp = number(basic.api_experience)
35
+ else if (path === '/kcsapi/api_get_member/record') exp = number(body.api_experience?.[0])
36
+ else if (BATTLES.has(path) || ['/kcsapi/api_req_practice/battle_result', '/kcsapi/api_req_mission/result'].includes(path)) exp = number(body.api_member_exp)
37
+ if (path === '/kcsapi/api_req_map/start' || path === '/kcsapi/api_req_map/next') {
38
+ const area = number(body.api_maparea_id) ?? number(postBody.api_maparea_id)
39
+ const map = number(body.api_mapinfo_no) ?? number(postBody.api_mapinfo_no)
40
+ if (path.endsWith('/start')) adapter.mapId = area !== null && map !== null ? area * 10 + map : null
41
+ else if (area !== null && map !== null) adapter.mapId = area * 10 + map
42
+ adapter.node = number(body.api_no)
43
+ }
44
+ const mapLabel = adapter.mapId === null ? '海域未知' : `${Math.floor(adapter.mapId / 10)}-${adapter.mapId % 10}`
45
+ if (exp !== null) push({ type: 'experience', exp,
46
+ gain: BATTLES.has(path) ? number(body.api_get_exp) : null,
47
+ label: BATTLES.has(path) ? `${mapLabel}${adapter.node === null ? '' : ` 节点 ${adapter.node}`} · ${body.api_win_rank || '?'} 胜败` : '总经验观测' })
48
+ const eoAmount = number(body.api_get_exmap_rate) || number(body.api_get_eo_rate)
49
+ if (eoAmount && (BATTLES.has(path) || path === '/kcsapi/api_req_map/next' || path === '/kcsapi/api_req_map/start')) {
50
+ const id = adapter.mapId !== null && Object.hasOwn(EO, adapter.mapId) ? adapter.mapId : null
51
+ push({ type: 'bonus', kind: 'eo', amount: eoAmount, mapId: id, questId: null,
52
+ key: id === null ? `eo-response:${at}:${eoAmount}` : `eo:${parts(at).month}:${id}`,
53
+ label: `${mapLabel} · EO 奖励` })
54
+ }
55
+ // Only a successful response is accepted. A request alone does not prove a reward was claimed.
56
+ if (path === '/kcsapi/api_req_quest/clearitemget') {
57
+ const id = number(postBody.api_quest_id)
58
+ if (Object.hasOwn(QUESTS, id)) {
59
+ const [amount, label] = QUESTS[id]
60
+ const date = new Date(at + 9 * 3600000 - 5 * 3600000)
61
+ const quarter = Math.floor((date.getUTCMonth() + 10) / 3)
62
+ const cycleYear = date.getUTCMonth() < 2 ? date.getUTCFullYear() - 1 : date.getUTCFullYear()
63
+ push({ type: 'bonus', kind: 'quest', amount, questId: id, mapId: null,
64
+ key: `quest:${cycleYear}:${quarter % 4}:${id}`, label: `${label} · 任务 ${id}` })
65
+ }
66
+ }
67
+ return { account: adapter.account, records }
68
+ }
69
+ module.exports = { number, createAdapter, decode }
package/src/export.js ADDED
@@ -0,0 +1,20 @@
1
+ 'use strict'
2
+
3
+ const { summarize } = require('./model')
4
+ const { formatTime } = require('./time')
5
+ // Spreadsheet applications interpret leading formula characters even inside CSV quotes.
6
+ function cell(value) {
7
+ const text = String(value ?? '')
8
+ return '"' + (/^[=+@\-\t\r]/.test(text) ? "'" + text : text).replaceAll('"', '""') + '"'
9
+ }
10
+ function toCSV(state, month, zone) {
11
+ const stats = summarize(state, month, zone)
12
+ const rows = [['月份', month, '时区', zone], ['本月战果', stats.total.toFixed(4)], ['普通战果', stats.normal.toFixed(4)],
13
+ ['EO 战果', stats.eo.toFixed(4)], ['任务战果', stats.quest.toFixed(4)], [],
14
+ ['来源', '种类', '战果增量', '提督经验', '记录或发现时间', '区间起点', '区间终点', '海域', '时区']]
15
+ const events = [...stats.days.flatMap(d => d.events), ...stats.unknown].sort((a, b) => a.at - b.at)
16
+ for (const e of events) rows.push([e.source === 'local' ? '本地确认' : '未定位', e.kind, e.amount.toFixed(4), e.exp ?? '', formatTime(e.at, zone),
17
+ e.from === undefined ? '' : formatTime(e.from, zone), e.to === undefined ? '' : formatTime(e.to, zone), e.source === 'local' ? e.label : '', zone])
18
+ return '\ufeff' + rows.map(row => row.map(cell).join(',')).join('\r\n')
19
+ }
20
+ module.exports = { toCSV, cell }
package/src/ledger.js ADDED
@@ -0,0 +1,116 @@
1
+ 'use strict'
2
+
3
+ const { parts, monthStart } = require('./time')
4
+ const RATE = 7 / 10000
5
+ const EO = { 15: 75, 16: 75, 25: 100, 35: 150, 45: 180, 55: 200, 56: 225, 65: 250, 75: 170 }
6
+ const QUESTS = {
7
+ 854: [350, 'Z作战前段'], 888: [200, '三川舰队'], 893: [300, '泊地周边'],
8
+ 872: [400, 'Z作战后段'], 284: [80, '海上警备'], 845: [330, '西方海域'], 903: [390, '六水战']
9
+ }
10
+ const integer = n => Number.isSafeInteger(n) && n >= 0
11
+ function validInput(input) {
12
+ if (!input || input.v !== 1 || !Number.isSafeInteger(input.at) || input.at < 0) return false
13
+ switch (input.type) {
14
+ case 'experience': return integer(input.exp) && (input.gain === null || integer(input.gain)) && typeof input.label === 'string'
15
+ case 'bonus': return ['eo', 'quest'].includes(input.kind) && integer(input.amount) && input.amount > 0 && typeof input.key === 'string' && typeof input.label === 'string' &&
16
+ (input.mapId === null || integer(input.mapId)) && (input.questId === null || integer(input.questId))
17
+ case 'maps': return Array.isArray(input.cleared) && input.cleared.every(n => integer(n) && Object.hasOwn(EO, n))
18
+ default: return false
19
+ }
20
+ }
21
+ function createLedger() {
22
+ return { events: [], baseline: null, eoMonths: {}, unlocatedEO: {}, ambiguousEO: {}, bonusKeys: new Set(), sequence: 0 }
23
+ }
24
+ function add(state, event) {
25
+ state.events.push({ id: ++state.sequence, ...event })
26
+ }
27
+ function unknown(state, input, amount, kind, from, label) {
28
+ add(state, { source: 'unknown', kind, amount, at: input.at, from, to: input.at, label })
29
+ }
30
+ function apply(state, input) {
31
+ if (!validInput(input)) throw new Error('Invalid ledger record')
32
+ const { at } = input
33
+ if (input.type === 'experience') {
34
+ const old = state.baseline
35
+ // Ignore stale observations: they must never move the cumulative baseline backwards.
36
+ if (old && at < old.at) return
37
+ const localGain = input.gain
38
+ if (!old) {
39
+ if (localGain !== null && localGain <= input.exp && localGain > 0) {
40
+ add(state, { source: 'local', kind: 'normal', amount: localGain * RATE, exp: localGain, at, label: input.label })
41
+ }
42
+ add(state, { source: 'baseline', kind: 'baseline', amount: 0, at, label: '首次观测:此前普通战果未收录' })
43
+ } else {
44
+ const delta = input.exp - old.exp
45
+ if (delta < 0) {
46
+ add(state, { source: 'warning', kind: 'warning', amount: 0, at, label: '总经验回退:已忽略异常观测并保留原基准' })
47
+ return
48
+ } else if (localGain !== null && localGain > delta) {
49
+ // Duplicate/overlapping results or inconsistent API values are not safe to attribute.
50
+ if (delta > 0) unknown(state, input, delta * RATE, 'normal', old.at, '经验与本场奖励不一致,无法定位')
51
+ } else {
52
+ const known = localGain || 0
53
+ if (delta > known) unknown(state, input, (delta - known) * RATE, 'normal', old.at, '未在本地记录的经验增量(可能来自其他客户端)')
54
+ if (known > 0) add(state, { source: 'local', kind: 'normal', amount: known * RATE, exp: known, at, label: input.label })
55
+ }
56
+ }
57
+ state.baseline = { exp: input.exp, at }
58
+ } else if (input.type === 'bonus') {
59
+ if (state.bonusKeys.has(input.key)) return
60
+ state.bonusKeys.add(input.key)
61
+ const month = parts(at).month
62
+ if (input.kind === 'eo' && input.mapId !== null) {
63
+ const seen = state.eoMonths[month] ||= {}
64
+ // A map snapshot may have arrived before this response. Upgrade the existing
65
+ // inferred event instead of counting the same EO reward twice.
66
+ const prior = seen[input.mapId]
67
+ if (prior) {
68
+ const event = state.events.find(e => e.id === prior)
69
+ if (event && event.source === 'unknown' && event.amount === input.amount) {
70
+ Object.assign(event, { source: 'local', at, label: input.label })
71
+ delete event.from
72
+ delete event.to
73
+ }
74
+ return
75
+ }
76
+ add(state, { source: 'local', kind: 'eo', amount: input.amount, at, label: input.label })
77
+ seen[input.mapId] = state.sequence
78
+ } else {
79
+ if (input.kind === 'eo') {
80
+ (state.ambiguousEO[month] ||= new Set()).add(input.amount)
81
+ for (const event of state.events) {
82
+ if (event.kind === 'eo' && event.source === 'unknown' && event.amount === input.amount && parts(event.at).month === month) {
83
+ event.label = `EO ${input.amount} 战果奖励,同额海域无法区分,完成时间未记录`
84
+ }
85
+ }
86
+ const prior = state.events.find(e => e.kind === 'eo' && e.source === 'unknown' && e.amount === input.amount && parts(e.at).month === month)
87
+ if (prior) {
88
+ Object.assign(prior, { source: 'local', at, label: input.label })
89
+ delete prior.from
90
+ delete prior.to
91
+ return
92
+ }
93
+ }
94
+ add(state, { source: 'local', kind: input.kind, amount: input.amount, at, label: input.label })
95
+ if (input.kind === 'eo') (state.unlocatedEO[month] ||= []).push({ amount: input.amount, id: state.sequence })
96
+ }
97
+ } else if (input.type === 'maps') {
98
+ const month = parts(at).month
99
+ const seen = state.eoMonths[month] ||= {}
100
+ for (const id of input.cleared) {
101
+ if (seen[id]) continue
102
+ // Starting the plugin in an ongoing sortie can leave the map identity
103
+ // unknown. Consume that direct reward before inferring any extra EO.
104
+ const credits = state.unlocatedEO[month] ||= []
105
+ const matching = credits.findIndex(credit => credit.amount === EO[id])
106
+ if (matching >= 0) {
107
+ seen[id] = credits.splice(matching, 1)[0].id
108
+ continue
109
+ }
110
+ const label = state.ambiguousEO[month]?.has(EO[id]) ? `EO ${EO[id]} 战果奖励,同额海域无法区分,完成时间未记录` : `EO ${Math.floor(id / 10)}-${id % 10} 已完成,完成时间未记录`
111
+ unknown(state, input, EO[id], 'eo', monthStart(at), label)
112
+ seen[id] = state.sequence
113
+ }
114
+ }
115
+ }
116
+ module.exports = { RATE, EO, QUESTS, validInput, createLedger, apply }
package/src/model.js ADDED
@@ -0,0 +1,50 @@
1
+ 'use strict'
2
+
3
+ const { parts, monthBounds } = require('./time')
4
+ function months(state, zone) {
5
+ const result = new Set()
6
+ for (const e of state.events) {
7
+ if (e.source === 'unknown' && e.kind !== 'normal') continue
8
+ result.add(parts(e.at, zone).month)
9
+ if (e.source === 'unknown') {
10
+ let cursor = e.from
11
+ while (parts(cursor, zone).month < parts(e.to, zone).month) {
12
+ result.add(parts(cursor, zone).month)
13
+ cursor = monthBounds(parts(cursor, zone).month, zone)[1]
14
+ }
15
+ }
16
+ }
17
+ return [...result].sort().reverse()
18
+ }
19
+ function summarize(state, month, zone = 'Asia/Tokyo', minutes = 60) {
20
+ const [start, end] = monthBounds(month, zone)
21
+ const count = Math.round((end - start) / 86400000)
22
+ const columns = 1440 / minutes
23
+ const days = Array.from({ length: count }, (_, i) => ({ day: `${month}-${String(i + 1).padStart(2, '0')}`, total: 0, normal: 0, eo: 0, quest: 0, slots: Array(columns).fill(0), events: [] }))
24
+ const unknown = []
25
+ let total = 0, normal = 0, eo = 0, quest = 0
26
+ for (const e of state.events) {
27
+ if (e.source === 'unknown') {
28
+ if (e.kind === 'normal' && e.from < end && e.to >= start) unknown.push({ ...e, withinMonth: e.from >= start && e.to < end })
29
+ continue
30
+ }
31
+ if (e.source !== 'local' || e.at < start || e.at >= end) continue
32
+ const p = parts(e.at, zone)
33
+ const day = days[Number(p.day.slice(-2)) - 1]
34
+ if (e.kind === 'normal') {
35
+ const minute = new Date(e.at + (zone === 'Asia/Shanghai' ? 8 : 9) * 3600000).getUTCMinutes()
36
+ day.slots[Math.floor((p.hour * 60 + minute) / minutes)] += e.amount
37
+ day.events.push(e)
38
+ }
39
+ day.total += e.amount
40
+ day[e.kind] += e.amount
41
+ total += e.amount
42
+ if (e.kind === 'normal') normal += e.amount
43
+ if (e.kind === 'eo') eo += e.amount
44
+ if (e.kind === 'quest') quest += e.amount
45
+ }
46
+ return { days, total, normal, eo, quest, unknown,
47
+ unknownWithinMonth: unknown.filter(e => e.withinMonth).reduce((s, e) => s + e.amount, 0),
48
+ unknownAcrossMonths: unknown.filter(e => !e.withinMonth).reduce((s, e) => s + e.amount, 0) }
49
+ }
50
+ module.exports = { months, summarize }
package/src/panel.js ADDED
@@ -0,0 +1,97 @@
1
+ 'use strict'
2
+
3
+ const { parts, formatTime, monthBounds, DAY } = require('./time')
4
+ const { months, summarize } = require('./model')
5
+ const { createLedger } = require('./ledger')
6
+ const style = `
7
+ .sl{font:13px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI","Microsoft YaHei",sans-serif;padding:18px;min-width:280px;--sl-line:rgba(140,155,170,.28);color:inherit;container-type:inline-size}
8
+ .sl *{box-sizing:border-box}.sl h2{font-size:22px;margin:0;font-weight:650}.sl h3{font-size:15px;margin:0;font-weight:600}.sl p{margin:6px 0}.sl .muted{opacity:.7}.sl .top,.sl .controls,.sl .navigation{display:flex;gap:10px;align-items:center;flex-wrap:wrap}.sl .top{justify-content:space-between}.sl .tag{color:#168777;background:rgba(40,178,153,.12);padding:3px 8px;border-radius:5px}.sl .controls{margin:16px 0}.sl select,.sl button{font:inherit;color:inherit;background:transparent;border:1px solid var(--sl-line);border-radius:5px;padding:5px 9px}.sl select option{color:#172d3b;background:#f6f9fb}.sl button{cursor:pointer}.sl button:disabled{opacity:.4;cursor:default}.sl button:focus-visible,.sl select:focus-visible{outline:2px solid #16a18a;outline-offset:2px}
9
+ .sl .cards{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px}.sl .card{border:1px solid var(--sl-line);border-radius:8px;padding:12px;min-width:0}.sl .value{font-size:clamp(18px,4cqi,28px);font-variant-numeric:tabular-nums;font-weight:650;overflow-wrap:anywhere}.sl .accent{color:#159985}.sl .amber{color:#c68d26}.sl .error{border:1px solid #d56b6b;padding:10px;white-space:pre-wrap}.sl .scroll{overflow:auto;max-width:100%}.sl table{border-collapse:collapse;width:100%;font-variant-numeric:tabular-nums}.sl th,.sl td{text-align:left;padding:7px 9px;border-bottom:1px solid var(--sl-line);white-space:nowrap}.sl th{font-size:12px;opacity:.75;font-weight:500}.sl .section{margin-top:24px}.sl .section-head{margin-bottom:12px}.sl .calendar{display:grid;grid-template-columns:repeat(7,minmax(0,1fr));gap:7px;min-width:0}.sl .weekday{text-align:center;opacity:.65;padding:3px 0 7px}.sl .tile{display:flex;flex-direction:column;align-items:center;justify-content:center;padding:8px;border:1px solid transparent;border-radius:8px;min-width:0;min-height:84px;gap:6px;text-align:center;transition:box-shadow .12s}.sl .tile:hover{box-shadow:inset 0 0 0 2px rgba(22,130,111,.5)}.sl .tile[aria-pressed="true"]{outline:2px solid #c68d26;outline-offset:1px}.sl .tile-label{font-size:18px;font-weight:600;line-height:1.3;white-space:nowrap}.sl .tile-value{font-size:clamp(10px,2.8cqi,23px);font-weight:650;line-height:1.3;font-variant-numeric:tabular-nums;overflow-wrap:anywhere}.sl .hours{display:grid;grid-template-columns:repeat(6,minmax(0,1fr));gap:8px}.sl .hours .tile{min-height:84px}.sl .legend{display:flex;align-items:center;gap:4px;justify-content:flex-end;margin-top:10px;font-size:11px;opacity:.75}.sl .swatch{display:inline-block;width:14px;height:12px;border-radius:2px}.sl .empty{padding:20px 12px;text-align:center;opacity:.65}.sl .foot{font-size:11px;opacity:.5;text-align:right;margin-top:20px}
10
+ @container(max-width:520px){.sl .calendar{gap:4px}.sl .calendar .tile{padding:4px}.sl .cards{grid-template-columns:repeat(2,minmax(0,1fr))}.sl .hours{grid-template-columns:repeat(4,minmax(0,1fr))}.sl .tile{padding:6px}.sl .hours .tile-value{font-size:18px}}
11
+ `
12
+ const colors = ['rgba(130,150,160,.13)', '#c4e9d8', '#81cdb2', '#36aa8f', '#087e70']
13
+ const fmt = n => n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
14
+ const tileNumber = n => n.toLocaleString('zh-CN', { useGrouping: false, minimumFractionDigits: 2, maximumFractionDigits: 2 })
15
+ function tileStyle(value, max) {
16
+ const level = value <= 0 ? 0 : Math.min(4, 1 + Math.floor(value / (max || 1) * 3))
17
+ return { background: colors[level], color: level === 4 ? '#fff' : level ? '#173e36' : 'inherit' }
18
+ }
19
+ function createPanel(React, service) {
20
+ const h = React.createElement
21
+ return function SenkaLedgerPanel() {
22
+ const [snapshot, setSnapshot] = React.useState(service.snapshot())
23
+ const [zone, setZone] = React.useState('Asia/Tokyo')
24
+ const [month, setMonth] = React.useState(parts(Date.now()).month)
25
+ const [day, setDay] = React.useState(null)
26
+ const [viewAccount, setViewAccount] = React.useState('')
27
+ const [slot, setSlot] = React.useState(null)
28
+ const [exportError, setExportError] = React.useState('')
29
+ React.useEffect(() => service.subscribe(setSnapshot), [])
30
+ const account = viewAccount || snapshot.account || snapshot.accounts[0] || ''
31
+ const entry = service.entry(account)
32
+ const state = entry?.state || createLedger()
33
+ const allMonths = [...new Set([parts(Date.now(), zone).month, month, ...months(state, zone)])].sort().reverse()
34
+ const stats = summarize(state, month, zone)
35
+ const selected = stats.days.find(d => d.day === day) || null
36
+ const totals = selected || stats
37
+ const prefix = selected ? '当日' : '本月'
38
+ const maxDay = Math.max(0, ...stats.days.map(d => d.normal))
39
+ const maxHour = selected ? Math.max(0, ...selected.slots) : 0
40
+ const timeline = selected ? selected.events.filter(e => slot === null || parts(e.at, zone).hour === slot).slice().sort((a, b) => b.at - a.at || b.id - a.id) : []
41
+ const [start] = monthBounds(month, zone)
42
+ const selectedStart = selected ? start + (Number(selected.day.slice(-2)) - 1) * DAY : null
43
+ const unknown = stats.unknown.filter(e => !selected || (e.from < selectedStart + DAY && e.to >= selectedStart)).slice().sort((a, b) => b.at - a.at)
44
+ const offset = (new Date(month + '-01T00:00:00Z').getUTCDay() + 6) % 7
45
+ const card = (label, value, accent = false) => h('div', { className: 'card', 'data-summary': label }, h('div', { className: 'muted' }, label), h('div', { className: `value${accent ? ' accent' : ''}` }, fmt(value)))
46
+ const th = labels => h('thead', null, h('tr', null, ...labels.map(s => h('th', { key: s }, s))))
47
+ const back = () => { setDay(null); setSlot(null) }
48
+ const openDay = value => { setDay(value); setSlot(null) }
49
+ const download = type => {
50
+ try { service.export(account, month, zone, type); setExportError('') } catch (error) { setExportError(error.message) }
51
+ }
52
+ const legend = h('div', { className: 'legend', 'aria-hidden': true }, '少 ', ...colors.map(c => h('span', { key: c, className: 'swatch', style: { background: c } })), ' 多')
53
+ return h('main', { className: 'sl', 'data-view': selected ? 'day' : 'month' }, h('style', null, style),
54
+ h('div', { className: 'top' }, h('h2', null, '战果手账'), h('span', { className: 'tag' }, snapshot.error || entry?.error ? '存储异常' : snapshot.account ? '记录中' : '等待游戏数据')),
55
+ (snapshot.error || entry?.error || exportError) && h('p', { role: 'alert', className: 'error' }, snapshot.error || entry?.error || exportError),
56
+ h('div', { className: 'controls' },
57
+ h('label', null, '账号 ', h('select', { 'aria-label': '账号', value: account, onChange: e => { setViewAccount(e.target.value); back() } },
58
+ !account && h('option', { value: '' }, '等待游戏数据'), ...snapshot.accounts.map(id => h('option', { key: id, value: id }, id + (id === snapshot.account ? ' · 当前' : ''))))),
59
+ h('label', null, '月份 ', h('select', { 'aria-label': '月份', value: month, onChange: e => { setMonth(e.target.value); back() } }, ...allMonths.map(m => h('option', { key: m }, m)))),
60
+ h('label', null, '时间 ', h('select', { 'aria-label': '时区', value: zone, onChange: e => { setZone(e.target.value); setSlot(null) } }, h('option', { value: 'Asia/Tokyo' }, '日本 UTC+9'), h('option', { value: 'Asia/Shanghai' }, '中国 UTC+8'))),
61
+ h('button', { disabled: !account, onClick: () => download('csv') }, '导出本月 CSV'),
62
+ h('button', { disabled: !account, onClick: () => download('json') }, '备份全部 JSON')),
63
+ selected && h('div', { className: 'top section-head navigation' },
64
+ h('button', { onClick: back, 'aria-label': '返回月览' }, '← 返回月览'), h('h3', null, selected.day),
65
+ h('div', { className: 'navigation' },
66
+ h('button', { disabled: selected === stats.days[0], onClick: () => openDay(stats.days[stats.days.indexOf(selected) - 1].day) }, '前一天'),
67
+ h('button', { disabled: selected === stats.days[stats.days.length - 1], onClick: () => openDay(stats.days[stats.days.indexOf(selected) + 1].day) }, '后一天'))),
68
+ h('div', { className: 'cards' }, card(`${prefix}战果`, totals.total, true), card('普通战果', totals.normal), card('EO 战果', totals.eo), card('任务战果', totals.quest)),
69
+ !selected ? h('section', { className: 'section', 'aria-label': '月度战果' },
70
+ h('h3', { className: 'section-head' }, `${month} · 每日普通战果`),
71
+ h('div', { className: 'scroll' }, h('div', { className: 'calendar', 'aria-label': '每日战果' },
72
+ ...['一', '二', '三', '四', '五', '六', '日'].map(s => h('div', { key: s, className: 'weekday' }, s)),
73
+ ...Array.from({ length: offset }, (_, i) => h('div', { key: `blank-${i}`, 'aria-hidden': true })),
74
+ ...stats.days.map(d => h('button', { key: d.day, className: 'tile', 'data-day': d.day,
75
+ 'aria-label': `${d.day} 普通战果 ${tileNumber(d.normal)}`, style: tileStyle(d.normal, maxDay), onClick: () => openDay(d.day) },
76
+ h('span', { className: 'tile-label' }, Number(d.day.slice(-2))), h('span', { className: 'tile-value' }, tileNumber(d.normal)))))), legend) :
77
+ h('section', { className: 'section', 'aria-label': '当日战果' },
78
+ h('h3', { className: 'section-head' }, '每小时普通战果'),
79
+ h('div', { className: 'hours', 'aria-label': '每小时战果' }, ...selected.slots.map((value, hour) => h('button', { key: hour, className: 'tile', 'data-hour': hour,
80
+ 'aria-label': `${selected.day} ${hour}时 普通战果 ${tileNumber(value)}`, 'aria-pressed': slot === hour,
81
+ style: tileStyle(value, maxHour), onClick: () => setSlot(slot === hour ? null : hour) },
82
+ h('span', { className: 'tile-label' }, `${String(hour).padStart(2, '0')}:00`), h('span', { className: 'tile-value' }, tileNumber(value))))), legend,
83
+ h('div', { className: 'top section section-head' }, h('h3', null, slot === null ? '当日明细' : `${String(slot).padStart(2, '0')}:00–${String(slot + 1).padStart(2, '0')}:00 · 明细`),
84
+ slot !== null && h('button', { onClick: () => setSlot(null) }, '查看全天')),
85
+ h('div', { className: 'scroll' }, h('table', { 'aria-label': '普通战果明细' }, th(['记录时间', '提督经验', '战果', '海域']),
86
+ h('tbody', null, ...timeline.slice(0, 300).map(e => h('tr', { key: e.id }, h('td', null, formatTime(e.at, zone).slice(11)), h('td', null, e.exp.toLocaleString()), h('td', null, fmt(e.amount)), h('td', null, e.label))),
87
+ !timeline.length && h('tr', null, h('td', { colSpan: 4, className: 'empty' }, '暂无记录'))))),
88
+ timeline.length > 300 && h('p', { className: 'muted' }, `共 ${timeline.length} 笔 · 显示最新 300 笔`)),
89
+ h('section', { className: 'section' }, h('h3', { className: 'section-head' }, '未在本地记录的增量'),
90
+ h('div', { className: 'scroll' }, h('table', { 'aria-label': '未在本地记录的增量' }, th(['可能发生区间', '发现于', '战果']), h('tbody', null,
91
+ ...unknown.slice(0, 200).map(e => h('tr', { key: e.id }, h('td', null, `${formatTime(e.from, zone)} → ${formatTime(e.to, zone)}`), h('td', null, formatTime(e.at, zone)), h('td', { className: 'amber' }, fmt(e.amount)))),
92
+ !unknown.length && h('tr', null, h('td', { colSpan: 3, className: 'empty' }, '暂无记录'))))),
93
+ unknown.length > 200 && h('p', { className: 'muted' }, `共 ${unknown.length} 笔 · 显示最新 200 笔`)),
94
+ h('div', { className: 'foot' }, 'v0.1.2'))
95
+ }
96
+ }
97
+ module.exports = { createPanel }
package/src/storage.js ADDED
@@ -0,0 +1,76 @@
1
+ 'use strict'
2
+
3
+ const fs = require('fs')
4
+ const path = require('path')
5
+ const { createLedger, apply, validInput } = require('./ledger')
6
+
7
+ class Journal {
8
+ constructor(directory) {
9
+ this.directory = directory
10
+ fs.mkdirSync(directory, { recursive: true })
11
+ this.accounts = new Map()
12
+ this.lockPath = path.join(directory, '.writer-lock')
13
+ // An advisory, process-scoped lock prevents two poi main windows from writing.
14
+ try { this.lock = fs.openSync(this.lockPath, 'wx'); fs.writeFileSync(this.lock, String(process.pid)) }
15
+ catch (error) {
16
+ if (error.code !== 'EEXIST') throw error
17
+ const pid = Number(fs.readFileSync(this.lockPath, 'utf8'))
18
+ if (!Number.isSafeInteger(pid) || pid <= 0) throw new Error('存储锁无效,请检查 .writer-lock')
19
+ try { process.kill(pid, 0) } catch (probe) {
20
+ if (probe.code === 'ESRCH') {
21
+ fs.unlinkSync(this.lockPath)
22
+ this.lock = fs.openSync(this.lockPath, 'wx')
23
+ fs.writeFileSync(this.lock, String(process.pid))
24
+ } else throw probe
25
+ }
26
+ if (this.lock === undefined) throw new Error('另一个 poi 实例正在记录战果,请关闭另一实例')
27
+ }
28
+ }
29
+ list() {
30
+ return fs.readdirSync(this.directory).filter(n => /^\d+\.jsonl$/.test(n)).map(n => n.slice(0, -6)).sort()
31
+ }
32
+ load(account) {
33
+ if (!/^\d+$/.test(account)) throw new Error('Invalid account')
34
+ if (this.accounts.has(account)) return this.accounts.get(account)
35
+ const entry = { state: createLedger(), file: path.join(this.directory, account + '.jsonl'), error: null }
36
+ let contents = ''
37
+ try { contents = fs.readFileSync(entry.file, 'utf8') } catch (error) { if (error.code !== 'ENOENT') throw error }
38
+ const lines = contents.split('\n')
39
+ for (let i = 0; i < lines.length; i++) {
40
+ if (i === lines.length - 1 && lines[i] === '') break
41
+ try {
42
+ if (i === lines.length - 1) throw new Error('末行写入未完成')
43
+ const input = JSON.parse(lines[i])
44
+ if (!validInput(input)) throw new Error('格式不兼容')
45
+ apply(entry.state, input)
46
+ } catch (error) {
47
+ entry.error = `记录第 ${i + 1} 行异常:${error.message}。已保留原文件并暂停写入。`
48
+ break
49
+ }
50
+ }
51
+ this.accounts.set(account, entry)
52
+ return entry
53
+ }
54
+ append(account, input) {
55
+ if (!validInput(input)) throw new Error('Invalid ledger record')
56
+ const entry = this.load(account)
57
+ if (entry.error) throw new Error(entry.error)
58
+ const fd = fs.openSync(entry.file, 'a')
59
+ try {
60
+ fs.writeFileSync(fd, JSON.stringify(input) + '\n', 'utf8')
61
+ fs.fsyncSync(fd)
62
+ } catch (error) {
63
+ entry.error = `保存失败,已暂停写入:${error.message}`
64
+ throw error
65
+ } finally { fs.closeSync(fd) }
66
+ apply(entry.state, input)
67
+ }
68
+ close() {
69
+ if (this.lock !== undefined) {
70
+ fs.closeSync(this.lock)
71
+ this.lock = undefined
72
+ fs.unlinkSync(this.lockPath)
73
+ }
74
+ }
75
+ }
76
+ module.exports = { Journal }
package/src/time.js ADDED
@@ -0,0 +1,24 @@
1
+ 'use strict'
2
+
3
+ const HOUR = 3600000
4
+ const DAY = 24 * HOUR
5
+ const OFFSETS = { 'Asia/Tokyo': 9, 'Asia/Shanghai': 8 }
6
+ function parts(at, zone = 'Asia/Tokyo') {
7
+ if (!Number.isFinite(at) || !Object.hasOwn(OFFSETS, zone)) throw new Error('Invalid time or timezone')
8
+ const date = new Date(at + OFFSETS[zone] * HOUR)
9
+ return { day: date.toISOString().slice(0, 10), month: date.toISOString().slice(0, 7), hour: date.getUTCHours() }
10
+ }
11
+ function formatTime(at, zone = 'Asia/Tokyo') {
12
+ return new Date(at + OFFSETS[zone] * HOUR).toISOString().slice(0, 19).replace('T', ' ')
13
+ }
14
+ function monthStart(at) {
15
+ return Date.parse(parts(at).month + '-01T00:00:00+09:00')
16
+ }
17
+ function monthBounds(month, zone = 'Asia/Tokyo') {
18
+ if (!/^\d{4}-(0[1-9]|1[0-2])$/.test(month)) throw new Error('Invalid month')
19
+ const start = Date.parse(month + '-01T00:00:00Z') - OFFSETS[zone] * HOUR
20
+ const date = new Date(start + OFFSETS[zone] * HOUR)
21
+ date.setUTCMonth(date.getUTCMonth() + 1)
22
+ return [start, date.getTime() - OFFSETS[zone] * HOUR]
23
+ }
24
+ module.exports = { HOUR, DAY, parts, formatTime, monthStart, monthBounds }