poi-plugin-compass 0.1.0 → 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.
Files changed (85) hide show
  1. package/README.md +33 -61
  2. package/assets/compass.css +20 -2
  3. package/data/sources/kcnav/enemies/2025-09-11_2026-09-11/1-4/I.json +1 -0
  4. package/data/sources/kcnav/enemies/2025-09-11_2026-09-11/1-5/H.json +1 -0
  5. package/data/sources/kcnav/enemies/2025-09-11_2026-09-11/1-5/I.json +1 -0
  6. package/data/sources/kcnav/enemies/2025-09-11_2026-09-11/1-6/I.json +1 -0
  7. package/data/sources/kcnav/enemies/2025-09-11_2026-09-11/1-6/L.json +1 -0
  8. package/data/sources/kcnav/enemies/2025-09-11_2026-09-11/2-1/F.json +1 -0
  9. package/data/sources/kcnav/enemies/2025-09-11_2026-09-11/2-3/M.json +1 -0
  10. package/data/sources/kcnav/enemies/2025-09-11_2026-09-11/2-4/E.json +1 -0
  11. package/data/sources/kcnav/enemies/2025-09-11_2026-09-11/3-1/B.json +1 -0
  12. package/data/sources/kcnav/enemies/2025-09-11_2026-09-11/3-1/D.json +1 -0
  13. package/data/sources/kcnav/enemies/2025-09-11_2026-09-11/3-2/J.json +1 -0
  14. package/data/sources/kcnav/enemies/2025-09-11_2026-09-11/3-2/K.json +1 -0
  15. package/data/sources/kcnav/enemies/2025-09-11_2026-09-11/3-3/K.json +1 -0
  16. package/data/sources/kcnav/enemies/2025-09-11_2026-09-11/3-4/B.json +1 -0
  17. package/data/sources/kcnav/enemies/2025-09-11_2026-09-11/3-4/I.json +1 -0
  18. package/data/sources/kcnav/enemies/2025-09-11_2026-09-11/3-4/M.json +1 -0
  19. package/data/sources/kcnav/enemies/2025-09-11_2026-09-11/3-4/N.json +1 -0
  20. package/data/sources/kcnav/enemies/2025-09-11_2026-09-11/4-1/E.json +1 -0
  21. package/data/sources/kcnav/enemies/2025-09-11_2026-09-11/4-3/A.json +1 -0
  22. package/data/sources/kcnav/enemies/2025-09-11_2026-09-11/4-3/G.json +1 -0
  23. package/data/sources/kcnav/enemies/2025-09-11_2026-09-11/4-3/I.json +1 -0
  24. package/data/sources/kcnav/enemies/2025-09-11_2026-09-11/4-3/L.json +1 -0
  25. package/data/sources/kcnav/enemies/2025-09-11_2026-09-11/4-4/B.json +1 -0
  26. package/data/sources/kcnav/enemies/2025-09-11_2026-09-11/4-4/F.json +1 -0
  27. package/data/sources/kcnav/enemies/2025-09-11_2026-09-11/4-4/G.json +1 -0
  28. package/data/sources/kcnav/enemies/2025-09-11_2026-09-11/4-4/H.json +1 -0
  29. package/data/sources/kcnav/enemies/2025-09-11_2026-09-11/4-5/E.json +1 -0
  30. package/data/sources/kcnav/enemies/2025-09-11_2026-09-11/4-5/F.json +1 -0
  31. package/data/sources/kcnav/enemies/2025-09-11_2026-09-11/4-5/G.json +1 -0
  32. package/data/sources/kcnav/enemies/2025-09-11_2026-09-11/4-5/J.json +1 -0
  33. package/data/sources/kcnav/enemies/2025-09-11_2026-09-11/4-5/N.json +1 -0
  34. package/data/sources/kcnav/enemies/2025-09-11_2026-09-11/4-5/O.json +1 -0
  35. package/data/sources/kcnav/enemies/2025-09-11_2026-09-11/4-5/S.json +1 -0
  36. package/data/sources/kcnav/enemies/2025-09-11_2026-09-11/5-2/I.json +1 -0
  37. package/data/sources/kcnav/enemies/2025-09-11_2026-09-11/5-2/K.json +1 -0
  38. package/data/sources/kcnav/enemies/2025-09-11_2026-09-11/5-2/L.json +1 -0
  39. package/data/sources/kcnav/enemies/2025-09-11_2026-09-11/5-3/C.json +1 -0
  40. package/data/sources/kcnav/enemies/2025-09-11_2026-09-11/5-3/M.json +1 -0
  41. package/data/sources/kcnav/enemies/2025-09-11_2026-09-11/5-3/N.json +1 -0
  42. package/data/sources/kcnav/enemies/2025-09-11_2026-09-11/5-4/F.json +1 -0
  43. package/data/sources/kcnav/enemies/2025-09-11_2026-09-11/5-4/G.json +1 -0
  44. package/data/sources/kcnav/enemies/2025-09-11_2026-09-11/5-4/L.json +1 -0
  45. package/data/sources/kcnav/enemies/2025-09-11_2026-09-11/5-5/J.json +1 -0
  46. package/data/sources/kcnav/enemies/2025-09-11_2026-09-11/5-6/A1.json +1 -0
  47. package/data/sources/kcnav/enemies/2025-09-11_2026-09-11/5-6/C1.json +1 -0
  48. package/data/sources/kcnav/enemies/2025-09-11_2026-09-11/5-6/P.json +1 -0
  49. package/data/sources/kcnav/enemies/2025-09-11_2026-09-11/5-6/Q1.json +1 -0
  50. package/data/sources/kcnav/enemies/2025-09-11_2026-09-11/5-6/Q2.json +1 -0
  51. package/data/sources/kcnav/enemies/2025-09-11_2026-09-11/5-6/T.json +1 -0
  52. package/data/sources/kcnav/enemies/2025-09-11_2026-09-11/5-6/V.json +1 -0
  53. package/data/sources/kcnav/enemies/2025-09-11_2026-09-11/6-1/D.json +1 -0
  54. package/data/sources/kcnav/enemies/2025-09-11_2026-09-11/6-1/I.json +1 -0
  55. package/data/sources/kcnav/enemies/2025-09-11_2026-09-11/6-1/J.json +1 -0
  56. package/data/sources/kcnav/enemies/2025-09-11_2026-09-11/6-2/B.json +1 -0
  57. package/data/sources/kcnav/enemies/2025-09-11_2026-09-11/6-2/F.json +1 -0
  58. package/data/sources/kcnav/enemies/2025-09-11_2026-09-11/6-2/H.json +1 -0
  59. package/data/sources/kcnav/enemies/2025-09-11_2026-09-11/6-2/I.json +1 -0
  60. package/data/sources/kcnav/enemies/2025-09-11_2026-09-11/6-4/E.json +1 -0
  61. package/data/sources/kcnav/enemies/2025-09-11_2026-09-11/6-4/G.json +1 -0
  62. package/data/sources/kcnav/enemies/2025-09-11_2026-09-11/6-4/H.json +1 -0
  63. package/data/sources/kcnav/enemies/2025-09-11_2026-09-11/6-4/L.json +1 -0
  64. package/data/sources/kcnav/enemies/2025-09-11_2026-09-11/7-1/B.json +1 -0
  65. package/data/sources/kcnav/enemies/2025-09-11_2026-09-11/7-1/C.json +1 -0
  66. package/data/sources/kcnav/enemies/2025-09-11_2026-09-11/7-2/H.json +1 -0
  67. package/data/sources/kcnav/enemies/2025-09-11_2026-09-11/7-2/J.json +1 -0
  68. package/data/sources/kcnav/enemies/2025-09-11_2026-09-11/7-3/B.json +1 -0
  69. package/data/sources/kcnav/enemies/2025-09-11_2026-09-11/7-3/J.json +1 -0
  70. package/data/sources/kcnav/enemies/2025-09-11_2026-09-11/7-3/M.json +1 -0
  71. package/data/sources/kcnav/enemies/2025-09-11_2026-09-11/7-3/N.json +1 -0
  72. package/data/sources/kcnav/enemies/2025-09-11_2026-09-11/7-4/G.json +1 -0
  73. package/data/sources/kcnav/enemies/2025-09-11_2026-09-11/7-4/H.json +1 -0
  74. package/data/sources/kcnav/enemies/2025-09-11_2026-09-11/7-5/C.json +1 -0
  75. package/data/sources/kcnav/enemies/2025-09-11_2026-09-11/7-5/E.json +1 -0
  76. package/data/sources/kcnav/enemies/2025-09-11_2026-09-11/7-5/L.json +1 -0
  77. package/data/sources/kcnav/enemies/2025-09-11_2026-09-11/7-5/N.json +1 -0
  78. package/data/sources/kcnav/enemies/2025-09-11_2026-09-11/7-5/R.json +1 -0
  79. package/data/sources/kcnav/enemies/manifest.json +771 -381
  80. package/docs/sources.md +2 -2
  81. package/enemies.js +1 -1
  82. package/index.js +35 -13
  83. package/package.json +4 -3
  84. package/scripts/cache-kcnav-enemies-long-low.js +115 -0
  85. package/scripts/cache-kcnav-enemies.js +9 -1
package/docs/sources.md CHANGED
@@ -31,9 +31,9 @@ data/kcnav-node-types-by-map.json
31
31
 
32
32
  ### 敌编成日期筛选(2026-09-11)
33
33
 
34
- 核对 `https://tsunkit.net/nav/scripts/routing.js`:`startDate`/`endDate` 分别使用参数 `start`/`end`,格式为 `YYYY-MM-DD`;`enemies` 面板将同一组筛选参数传给节点的 `enemycomps` 接口。本插件固定本轮起止日期,仅发送这两个参数,不发送司令部等级、血条、phase、舰队或路线限制。最近一个月采用当天 UTC 日期向前一个日历月(月末截到上月末日)。
34
+ 核对 `https://tsunkit.net/nav/scripts/routing.js`:`startDate`/`endDate` 分别使用参数 `start`/`end`,格式为 `YYYY-MM-DD`;`enemies` 面板将同一组筛选参数传给节点的 `enemycomps` 接口。本插件固定本轮起止日期,仅发送这两个参数,不发送司令部等级、血条、phase、舰队或路线限制。通常节点采用当天 UTC 日期向前一个日历月(月末截到上月末日);月度样本总数小于 100 的战斗节点另使用前一年同日到当天的窗口,并按节点保存实际使用的日期窗口。
35
35
 
36
- `https://tsunkit.net/lang/en/kcnav.json` 的 `panelEnemiesAirPowerHeader` 顺序为 `termAirAD / termAirAP / termAirAS / termAirASPlus`;与前端 `airpower[0..3]` 对应,分别为劣势、均势、优势、确保。`lbasAirpower` 使用相同顺序但单独显示。原始响应按日期窗口/海图/节点保存,manifest 记录请求 URL、SHA-256、每节点抓取时间和完成状态。遇到请求失败即停止,保留进度供重跑;运行时只读取本地缓存。
36
+ `https://tsunkit.net/lang/en/kcnav.json` 的 `panelEnemiesAirPowerHeader` 顺序为 `termAirAD / termAirAP / termAirAS / termAirASPlus`;与前端 `airpower[0..3]` 对应,分别为劣势、均势、优势、确保。`lbasAirpower` 使用相同顺序但单独显示。原始响应按日期窗口/海图/节点保存,manifest 记录请求 URL、SHA-256、每节点抓取时间和完成状态。遇到请求失败即停止,保留进度供重跑;运行时只读取本地缓存。低样本节点的一年窗口补采由 `npm run cache:kcnav-enemies:long-low` 执行,默认间隔 15 秒。
37
37
 
38
38
  每个 API entry 原样保留舰船顺序、装备、阵型及编成 masterId;舰船实体用 `mainFleet[].id` / `escortFleet[].id`。界面按 `count` 降序显示,以本节点返回记录的 count 总和计算样本占比。零样本不生成百分比;四档制空缺失时显示未知;任何编成缺制空时,节点整体范围也显示未知。混合阶段和司令部等级的占比仅描述当前抓取样本。
39
39
 
package/enemies.js CHANGED
@@ -44,7 +44,7 @@ function loadEnemies(mapId, node) {
44
44
  const source = manifest.nodes[`${mapId}/${node}`]
45
45
  if (!source) return { status: 'missing', window: manifest.window }
46
46
  const payload = readLocalJson(path.join(cacheRoot, source.file))
47
- return { status: 'ready', window: manifest.window, source, ...summarizeEntries(payload.result.entries) }
47
+ return { status: 'ready', window: source.window || manifest.window, source, ...summarizeEntries(payload.result.entries) }
48
48
  } catch (error) {
49
49
  return { status: error.code === 'ENOENT' ? 'missing' : 'invalid' }
50
50
  }
package/index.js CHANGED
@@ -496,6 +496,7 @@ class Compass extends React.Component {
496
496
  : mapIds[0],
497
497
  phaseId: phaseOptionsForMap(mapIds[0])[0]?.id || null,
498
498
  selectedNode: pluginState.currentNode || null,
499
+ selectedEdge: null,
499
500
  overrides: {},
500
501
  exportOpen: false,
501
502
  exportRoute: null,
@@ -535,6 +536,7 @@ class Compass extends React.Component {
535
536
  mapId,
536
537
  phaseId: phaseOptionsForMap(mapId)[0]?.id || null,
537
538
  selectedNode: null,
539
+ selectedEdge: null,
538
540
  overrides: {},
539
541
  exportRoute: null,
540
542
  exportEnemies: {},
@@ -582,15 +584,15 @@ class Compass extends React.Component {
582
584
  }
583
585
 
584
586
  selectPhase(phaseId) {
585
- this.setState({ phaseId, selectedNode: null, overrides: {}, exportRoute: null, exportEnemies: {}, exportFormations: {}, exportError: null })
587
+ this.setState({ phaseId, selectedNode: null, selectedEdge: null, overrides: {}, exportRoute: null, exportEnemies: {}, exportFormations: {}, exportError: null })
586
588
  }
587
589
 
588
590
  selectNode(node) {
589
- this.setState({ selectedNode: node })
591
+ this.setState({ selectedNode: node, selectedEdge: null })
590
592
  }
591
593
 
592
594
  selectFleet(deckId) {
593
- this.setState({ deckId, selectedNode: null, overrides: {}, exportRoute: null, exportEnemies: {}, exportFormations: {}, exportError: null })
595
+ this.setState({ deckId, selectedNode: null, selectedEdge: null, overrides: {}, exportRoute: null, exportEnemies: {}, exportFormations: {}, exportError: null })
594
596
  }
595
597
 
596
598
  renderLosIcons(scores) {
@@ -611,15 +613,20 @@ class Compass extends React.Component {
611
613
  `制空 ${air.min === air.max ? air.min : `${air.min}~${air.max}`}`)
612
614
  }
613
615
 
614
- setManualOverride(node, to) {
616
+ setManualOverride(node, to, edgeId = null) {
615
617
  this.setState((state) => ({
616
618
  overrides: { ...state.overrides, [node]: to },
617
619
  selectedNode: node,
620
+ selectedEdge: edgeId,
618
621
  exportRoute: null,
619
622
  exportError: null,
620
623
  }))
621
624
  }
622
625
 
626
+ selectEdge(edgeId) {
627
+ this.setState({ selectedEdge: edgeId })
628
+ }
629
+
623
630
  renderMap(mapDefinition, geometry, evaluation, selectedNode, currentNode, passedNodes = [], mapId = null) {
624
631
  if (!mapGeometryAvailable(mapDefinition, geometry)) {
625
632
  return h('div', { className: 'compass-empty-panel' }, 'poi 地图数据不可用')
@@ -658,6 +665,7 @@ class Compass extends React.Component {
658
665
  || []
659
666
  const manualChoice = evaluation.decisions[edge.from]?.manual
660
667
  && manualOutcomes.some((outcome) => outcome.to === edge.to)
668
+ const selected = this.state.selectedEdge === edge.cell
661
669
  const edgeClassNames = [
662
670
  'compass-edge',
663
671
  probability.global != null ? 'is-reachable' : '',
@@ -665,21 +673,33 @@ class Compass extends React.Component {
665
673
  probabilityTier(probability.global),
666
674
  passedEdges.has(`${edge.from}->${edge.to}`) ? 'is-passed' : '',
667
675
  manualChoice ? 'is-manual-choice' : '',
676
+ selected ? 'is-selected' : '',
668
677
  ].filter(Boolean).join(' ')
669
678
  const activateEdge = (event) => {
670
679
  if (event.key === 'Enter' || event.key === ' ') {
671
680
  event.preventDefault()
672
- this.setManualOverride(edge.from, edge.to)
681
+ if (manualChoice) this.setManualOverride(edge.from, edge.to, edge.cell)
682
+ else this.selectEdge(edge.cell)
673
683
  }
674
684
  }
675
685
  return h('g', {
676
686
  key: `edge-${edge.cell}`,
677
687
  className: edgeClassNames,
678
- role: manualChoice ? 'button' : undefined,
679
- tabIndex: manualChoice ? 0 : undefined,
680
- onClick: manualChoice ? () => this.setManualOverride(edge.from, edge.to) : undefined,
681
- onKeyDown: manualChoice ? activateEdge : undefined,
688
+ role: 'button',
689
+ tabIndex: 0,
690
+ 'aria-label': `边 ID ${edge.cell}:${edge.from}→${edge.to}`,
691
+ onClick: () => manualChoice
692
+ ? this.setManualOverride(edge.from, edge.to, edge.cell)
693
+ : this.selectEdge(edge.cell),
694
+ onKeyDown: activateEdge,
682
695
  },
696
+ h('title', null, [
697
+ `边 ID ${edge.cell}:${edge.from} → ${edge.to}`,
698
+ manualChoice ? '点击选择方向' : null,
699
+ probability.global != null && Number(probability.global) > 0
700
+ ? `局部 ${formatPercent(probability.local, probability.estimated)}`
701
+ : null,
702
+ ].filter(Boolean).join(' · ')),
683
703
  h('line', {
684
704
  x1: from.x,
685
705
  y1: from.y,
@@ -694,10 +714,12 @@ class Compass extends React.Component {
694
714
  className: 'compass-edge-label',
695
715
  }, formatPercent(probability.global, probability.estimated))
696
716
  : null,
697
- probability.global != null && Number(probability.global) > 0
698
- ? h('title', null, manualChoice
699
- ? `能动分歧:选择 ${edge.to}`
700
- : `局部 ${formatPercent(probability.local, probability.estimated)}`)
717
+ selected
718
+ ? h('text', {
719
+ x: (from.x + to.x) / 2,
720
+ y: (from.y + to.y) / 2 - 18,
721
+ className: 'compass-edge-id-label',
722
+ }, `ID ${edge.cell}`)
701
723
  : null,
702
724
  )
703
725
  }),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "poi-plugin-compass",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "通常海域舰队罗盘",
5
5
  "main": "index.js",
6
6
  "repository": {
@@ -28,9 +28,10 @@
28
28
  "cache:kcwiki": "node scripts/cache-kcwiki-routes.js",
29
29
  "cache:kcnav-maps": "node scripts/cache-kcnav-maps.js",
30
30
  "cache:kcnav-enemies": "node scripts/cache-kcnav-enemies.js",
31
+ "cache:kcnav-enemies:long-low": "node scripts/cache-kcnav-enemies-long-low.js",
31
32
  "compress:kcnav-maps": "node scripts/compress-kcnav-maps.js --remove-png",
32
33
  "compress:kcnav-enemies": "node scripts/compact-kcnav-enemies.js",
33
- "check:syntax": "node --check index.js && node --check logic.js && node --check enemies.js && node --check exporters.js && node --check scripts/cache-kcnav-enemies.js && node --check scripts/compress-kcnav-maps.js && node --check scripts/compact-kcnav-enemies.js && node --check test.js && node --check test-enemies.js && node --check test-exporters.js",
34
+ "check:syntax": "node --check index.js && node --check logic.js && node --check enemies.js && node --check exporters.js && node --check scripts/cache-kcnav-enemies.js && node --check scripts/cache-kcnav-enemies-long-low.js && node --check scripts/compress-kcnav-maps.js && node --check scripts/compact-kcnav-enemies.js && node --check test.js && node --check test-enemies.js && node --check test-exporters.js",
34
35
  "test": "node test.js && node test-enemies.js && node test-exporters.js && node test-ui.js && node test-overview.js"
35
36
  },
36
37
  "keywords": [
@@ -51,7 +52,7 @@
51
52
  "icon": "fa/compass",
52
53
  "priority": 20,
53
54
  "apiVer": {
54
- "11.99.99": "0.1.0"
55
+ "11.99.99": "0.1.2"
55
56
  }
56
57
  },
57
58
  "peerDependencies": {
@@ -0,0 +1,115 @@
1
+ 'use strict'
2
+
3
+ const crypto = require('crypto')
4
+ const fs = require('fs')
5
+ const path = require('path')
6
+ const nodeTypes = require('../data/kcnav-node-types-by-map.json')
7
+ const styles = require('../data/kcnav-node-types.json')
8
+ const { enemyUrl, validateResponse } = require('./cache-kcnav-enemies')
9
+
10
+ const root = path.join(__dirname, '../data/sources/kcnav/enemies')
11
+ const manifestPath = path.join(root, 'manifest.json')
12
+ const LOW_SAMPLE_THRESHOLD = 100
13
+ const INTERVAL_MS = 15000
14
+
15
+ function oneYearWindow(now = new Date()) {
16
+ const end = now.toISOString().slice(0, 10)
17
+ const startDate = new Date(`${end}T00:00:00Z`)
18
+ startDate.setUTCFullYear(startDate.getUTCFullYear() - 1)
19
+ return { start: startDate.toISOString().slice(0, 10), end }
20
+ }
21
+
22
+ function readJson(file) {
23
+ return JSON.parse(fs.readFileSync(file, 'utf8'))
24
+ }
25
+
26
+ function sampleCount(payload) {
27
+ return (payload?.result?.entries || []).reduce((sum, entry) => sum + entry.count, 0)
28
+ }
29
+
30
+ function writeJson(file, value, compact = false) {
31
+ const content = `${JSON.stringify(value, null, compact ? 0 : 2)}\n`
32
+ fs.mkdirSync(path.dirname(file), { recursive: true })
33
+ fs.writeFileSync(`${file}.tmp`, content)
34
+ fs.renameSync(`${file}.tmp`, file)
35
+ return crypto.createHash('sha256').update(content).digest('hex')
36
+ }
37
+
38
+ function combatTargets() {
39
+ return Object.entries(nodeTypes).sort(([a], [b]) => a.localeCompare(b, undefined, { numeric: true }))
40
+ .flatMap(([mapId, nodes]) => Object.entries(nodes)
41
+ .filter(([, type]) => styles[type]?.hasEnemies)
42
+ .map(([node]) => ({ mapId, node })))
43
+ }
44
+
45
+ function currentSamples(manifest, target) {
46
+ const source = manifest.nodes[`${target.mapId}/${target.node}`]
47
+ if (!source) return 0
48
+ try {
49
+ return sampleCount(readJson(path.join(root, source.file)))
50
+ } catch (error) {
51
+ return 0
52
+ }
53
+ }
54
+
55
+ async function main() {
56
+ const manifest = readJson(manifestPath)
57
+ const window = oneYearWindow()
58
+ const targets = combatTargets().filter((target) => currentSamples(manifest, target) < LOW_SAMPLE_THRESHOLD)
59
+ const previousRun = manifest.longWindow
60
+ manifest.longWindow = {
61
+ start: window.start,
62
+ end: window.end,
63
+ threshold: LOW_SAMPLE_THRESHOLD,
64
+ intervalMs: INTERVAL_MS,
65
+ targetCount: targets.length,
66
+ complete: false,
67
+ lastRequestAt: previousRun?.start === window.start && previousRun?.end === window.end
68
+ ? previousRun.lastRequestAt : null,
69
+ }
70
+ writeJson(manifestPath, manifest)
71
+
72
+ for (const [index, target] of targets.entries()) {
73
+ const key = `${target.mapId}/${target.node}`
74
+ const existing = manifest.nodes[key]
75
+ const expectedFile = `${window.start}_${window.end}/${target.mapId}/${target.node}.json`
76
+ const existingFile = existing?.file === expectedFile ? path.join(root, existing.file) : null
77
+ if (existingFile && fs.existsSync(existingFile)) {
78
+ const hash = crypto.createHash('sha256').update(fs.readFileSync(existingFile)).digest('hex')
79
+ if (hash === existing.sha256) continue
80
+ }
81
+
82
+ const elapsed = Date.now() - Date.parse(manifest.longWindow.lastRequestAt || '1970-01-01')
83
+ const wait = Math.max(0, INTERVAL_MS - elapsed)
84
+ if (wait) await new Promise((resolve) => setTimeout(resolve, wait))
85
+ manifest.longWindow.lastRequestAt = new Date().toISOString()
86
+ writeJson(manifestPath, manifest)
87
+
88
+ const url = enemyUrl(target.mapId, target.node, window)
89
+ console.log(`${index + 1}/${targets.length} ${key} ${window.start}..${window.end}`)
90
+ const response = await fetch(url, { signal: AbortSignal.timeout(60000) })
91
+ if (!response.ok) throw new Error(`${key}: HTTP ${response.status}; cache retained, rerun to resume`)
92
+ const payload = await response.json()
93
+ const result = validateResponse(payload)
94
+ const file = expectedFile
95
+ const sha256 = writeJson(path.join(root, file), payload, true)
96
+ manifest.nodes[key] = {
97
+ file,
98
+ url,
99
+ sha256,
100
+ fetchedAt: new Date().toISOString(),
101
+ window,
102
+ entries: result.entries.length,
103
+ samples: sampleCount(payload),
104
+ }
105
+ writeJson(manifestPath, manifest)
106
+ }
107
+
108
+ manifest.longWindow.complete = true
109
+ manifest.longWindow.finishedAt = new Date().toISOString()
110
+ writeJson(manifestPath, manifest)
111
+ console.log(`Cached ${targets.length} low-sample enemy nodes for one year`)
112
+ }
113
+
114
+ if (require.main === module) main().catch((error) => { console.error(error.message); process.exitCode = 1 })
115
+ module.exports = { oneYearWindow, sampleCount, combatTargets }
@@ -85,7 +85,15 @@ async function main() {
85
85
  const result = validateResponse(payload)
86
86
  const file = `${window.start}_${window.end}/${mapId}/${node}.json`
87
87
  const sha256 = writeJson(path.join(root, file), payload, true)
88
- manifest.nodes[key] = { file, url, sha256, fetchedAt: new Date().toISOString(), entries: result.entries.length }
88
+ manifest.nodes[key] = {
89
+ file,
90
+ url,
91
+ sha256,
92
+ fetchedAt: new Date().toISOString(),
93
+ window,
94
+ entries: result.entries.length,
95
+ samples: result.entries.reduce((sum, entry) => sum + entry.count, 0),
96
+ }
89
97
  writeJson(manifestPath, manifest)
90
98
  }
91
99
  manifest.complete = true