poi-plugin-compass 0.1.5 → 0.1.7

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/README.md CHANGED
@@ -49,3 +49,12 @@ npm install poi-plugin-compass
49
49
  - 地图、规则和敌舰资料随插件提供;舰船头像在 poi 图片缓存不可用时可能请求游戏图片服务。
50
50
 
51
51
  问题反馈:<https://github.com/RikaKagurasaka/poi-plugin-compass/issues>
52
+
53
+ ### 设置存储
54
+
55
+ 地图、阶段、舰队、显示偏好、路线覆盖和导出选择保存在 poi 数据目录下的
56
+ `compass/preferences.json`。首次使用新版时,自动从当前插件窗口的
57
+ `poi-plugin-compass.preferences.v1` localStorage 项导入;旧项保留作备份,
58
+ 已有文件优先,不会再次被旧设置覆盖。设置通过临时文件写入后替换正式文件;
59
+ 读取或迁移失败时保留原数据并在控制台报告错误,不会覆盖损坏文件。
60
+ 从前曾在主窗口和独立窗口保存不同设置时,以首次成功迁移的窗口为准。
@@ -1,12 +1,25 @@
1
1
  .poi-compass {
2
- min-height: 100%;
3
- padding: 10px;
2
+ height: 100%;
3
+ min-height: 0;
4
+ overflow: hidden;
4
5
  color: var(--color-text, #f1f5f9);
5
6
  background: var(--color-background, #20242b);
6
7
  box-sizing: border-box;
7
8
  font-size: 13px;
8
9
  }
9
10
 
11
+ .compass-scroll-viewport {
12
+ height: 100%;
13
+ max-height: 100vh;
14
+ min-height: 0;
15
+ box-sizing: border-box;
16
+ overflow-x: hidden;
17
+ overflow-y: auto;
18
+ overscroll-behavior: contain;
19
+ padding: 10px;
20
+ scrollbar-gutter: stable;
21
+ }
22
+
10
23
  .compass-titlebar {
11
24
  display: flex;
12
25
  align-items: center;
@@ -57,7 +70,10 @@
57
70
  .compass-los-icons { display: flex; gap: 6px; flex-shrink: 0; }
58
71
  .compass-los-item { display: inline-flex; align-items: center; gap: 4px; }
59
72
  .compass-los-value { color: #a9daf8; font-weight: 600; font-variant-numeric: tabular-nums; white-space: nowrap; }
60
- .compass-fleet-air { color: #91d9b2; font-weight: 650; font-variant-numeric: tabular-nums; white-space: nowrap; flex-shrink: 0; }
73
+ .compass-fleet-air { display: inline-flex; align-items: center; gap: 3px; color: #91d9b2; font-weight: 650; font-variant-numeric: tabular-nums; white-space: nowrap; flex-shrink: 0; }
74
+ .compass-fleet-air-icon,
75
+ .compass-fleet-air-icon img { display: inline-flex; width: 22px; height: 22px; align-items: center; justify-content: center; }
76
+ .compass-fleet-air-icon img { object-fit: contain; }
61
77
  .compass-los-icon { position: relative; display: inline-flex; width: 28px; height: 28px; align-items: center; justify-content: center; }
62
78
  .compass-los-icon img { width: 28px; height: 28px; }
63
79
  .compass-los-coefficient { position: absolute; right: -1px; bottom: -3px; font-size: 14px; font-weight: 800; color: white; -webkit-text-stroke: 3px #151a22; paint-order: stroke fill; }
@@ -456,7 +472,8 @@
456
472
 
457
473
  .compass-enemy-entry summary {
458
474
  display: grid;
459
- grid-template-columns: 58px 64px minmax(238px, 1fr) 190px;
475
+ grid-template-columns: 58px 64px max-content max-content;
476
+ justify-content: start;
460
477
  align-items: center;
461
478
  gap: 6px;
462
479
  padding: 7px 2px;
@@ -493,19 +510,72 @@
493
510
  overflow-wrap: anywhere;
494
511
  }
495
512
 
496
- .compass-enemy-entry { min-width: 590px; }
513
+ .compass-enemy-entry { min-width: 540px; }
497
514
  .compass-enemy-stats { margin-top: 8px; }
498
515
  .compass-enemy-stat-row {
499
516
  display: grid;
500
- grid-template-columns: 40px repeat(5, 54px) minmax(160px, 1fr);
517
+ grid-template-columns: 40px repeat(5, 42px) minmax(150px, 1fr);
501
518
  align-items: center;
502
- gap: 8px;
519
+ gap: 6px;
503
520
  padding: 4px 0;
504
521
  font-variant-numeric: tabular-nums;
505
522
  }
506
523
  .compass-enemy-stat-row > span:not(:first-child):not(:last-child) { text-align: right; }
507
524
  .compass-enemy-stat-row.is-escort-start { border-top: 1px solid #687587; margin-top: 5px; }
508
- .compass-enemy-air { text-align: right; white-space: nowrap; }
525
+ .compass-enemy-air { text-align: left; white-space: nowrap; }
526
+
527
+ .compass-enemy-equips {
528
+ display: grid;
529
+ grid-template-columns: repeat(5, 28px);
530
+ align-items: center;
531
+ gap: 2px;
532
+ min-width: 0;
533
+ }
534
+
535
+ .compass-enemy-equip {
536
+ display: inline-flex;
537
+ width: 28px;
538
+ height: 28px;
539
+ align-items: center;
540
+ justify-content: center;
541
+ }
542
+
543
+ .compass-enemy-equip img {
544
+ display: block;
545
+ width: 28px;
546
+ height: 28px;
547
+ object-fit: contain;
548
+ }
549
+
550
+ .compass-enemy-equip.is-empty { background: transparent; }
551
+
552
+ .compass-enemy-stat {
553
+ display: inline-flex;
554
+ min-width: 0;
555
+ align-items: center;
556
+ gap: 2px;
557
+ white-space: nowrap;
558
+ }
559
+
560
+ .compass-enemy-stat-icon {
561
+ display: inline-block;
562
+ width: 18px;
563
+ height: 18px;
564
+ flex: 0 0 18px;
565
+ object-fit: contain;
566
+ vertical-align: middle;
567
+ }
568
+
569
+ .compass-enemy-stat-icon.fa {
570
+ color: #f1f5f9;
571
+ font-size: 16px;
572
+ line-height: 18px;
573
+ text-align: center;
574
+ }
575
+
576
+ .compass-enemy-stat-value {
577
+ font-variant-numeric: tabular-nums;
578
+ }
509
579
 
510
580
  .compass-condition.is-unknown {
511
581
  border-left-color: #e0af56;
@@ -0,0 +1,11 @@
1
+ <?xml version="1.0" encoding="utf-8"?>
2
+ <!-- Generator: Adobe Illustrator 16.0.0, Export Plug-In . SVG Version: 6.00 Build 0) -->
3
+ <!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
4
+ <svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
5
+ width="800px" height="800px" viewBox="0 0 800 800" enable-background="new 0 0 800 800" xml:space="preserve">
6
+ <rect x="125" y="400" fill="#F2F2F2" stroke="#4D4D4D" stroke-width="10" stroke-miterlimit="10" width="601" height="200"/>
7
+ <line fill="none" stroke="#4D4D4D" stroke-width="10" stroke-miterlimit="10" x1="125" y1="441" x2="726" y2="441"/>
8
+ <path fill="#F2F2F2" stroke="#4D4D4D" stroke-width="10" stroke-miterlimit="10" d="M726,400H164c0-27.88,11.47-54.16,31.75-77.25
9
+ L73,200h30l107.71,107.71C261.01,262.66,347.12,233,445,233C600.32,233,726,307.69,726,400z"/>
10
+ <path fill="none" stroke="#4D4D4D" stroke-width="10" stroke-miterlimit="10" d="M303,400c0-88.605,98.869-160.304,221.054-160.304"/>
11
+ </svg>
@@ -0,0 +1,22 @@
1
+ <?xml version="1.0" encoding="utf-8"?>
2
+ <!-- Generator: Adobe Illustrator 16.0.0, Export Plug-In . SVG Version: 6.00 Build 0) -->
3
+ <!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
4
+ <svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
5
+ width="800px" height="800px" viewBox="0 0 800 800" enable-background="new 0 0 800 800" xml:space="preserve">
6
+ <polygon fill="#F2F2F2" stroke="#4D4D4D" stroke-width="10" stroke-miterlimit="10" points="615,643.5 586,656.5 215,643.5
7
+ 186,156.75 215,144 615,144 "/>
8
+ <rect x="186" y="157" fill="#F2F2F2" stroke="#4D4D4D" stroke-width="10" stroke-miterlimit="10" width="400" height="500"/>
9
+ <line fill="none" stroke="#4D4D4D" stroke-width="10" stroke-miterlimit="10" x1="586" y1="156.5" x2="615" y2="143.5"/>
10
+ <polygon fill="none" stroke="#4D4D4D" stroke-width="10" stroke-miterlimit="10" points="240.975,226 235.95,217 240.975,208
11
+ 251.025,208 256.05,217 251.025,226 "/>
12
+ <polygon fill="none" stroke="#4D4D4D" stroke-width="10" stroke-miterlimit="10" points="240.975,606 235.95,597 240.975,588
13
+ 251.025,588 256.05,597 251.025,606 "/>
14
+ <polygon fill="none" stroke="#4D4D4D" stroke-width="10" stroke-miterlimit="10" points="240.975,416 235.95,407 240.975,398
15
+ 251.025,398 256.05,407 251.025,416 "/>
16
+ <polygon fill="none" stroke="#4D4D4D" stroke-width="10" stroke-miterlimit="10" points="521.975,226 516.95,217 521.975,208
17
+ 532.025,208 537.05,217 532.025,226 "/>
18
+ <polygon fill="none" stroke="#4D4D4D" stroke-width="10" stroke-miterlimit="10" points="521.975,606 516.95,597 521.975,588
19
+ 532.025,588 537.05,597 532.025,606 "/>
20
+ <polygon fill="none" stroke="#4D4D4D" stroke-width="10" stroke-miterlimit="10" points="521.975,416 516.95,407 521.975,398
21
+ 532.025,398 537.05,407 532.025,416 "/>
22
+ </svg>
@@ -0,0 +1,14 @@
1
+ <?xml version="1.0" encoding="utf-8"?>
2
+ <!-- Generator: Adobe Illustrator 16.0.0, Export Plug-In . SVG Version: 6.00 Build 0) -->
3
+ <!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
4
+ <svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="800px"
5
+ height="800px" viewBox="0 0 800 800" enable-background="new 0 0 800 800" xml:space="preserve">
6
+ <g id="Layer_1">
7
+ </g>
8
+ <g id="Layer_2">
9
+ <polygon fill="#F2F2F2" stroke="#4D4D4D" stroke-width="10" stroke-miterlimit="10" points="748,424 748,547 708,547 708,661
10
+ 92,661 92,547 52,547 52,424 92,424 92,335 368.566,335 552.831,139 626.871,139 442.606,335 490.197,335 674.461,139 748.51,139
11
+ 564.246,335 708,335 708,424 ">
12
+ </polygon>
13
+ </g>
14
+ </svg>
@@ -0,0 +1,22 @@
1
+ <?xml version="1.0" encoding="utf-8"?>
2
+ <!-- Generator: Adobe Illustrator 16.0.0, Export Plug-In . SVG Version: 6.00 Build 0) -->
3
+ <!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
4
+ <svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
5
+ width="800px" height="800px" viewBox="0 0 800 800" enable-background="new 0 0 800 800" xml:space="preserve">
6
+ <g>
7
+ <polyline fill="#F2F2F2" stroke="#4D4D4D" stroke-width="10" stroke-miterlimit="10" points="339.943,481.163 420.951,562.173
8
+ 337.583,681.722 278.595,622.733 220.395,564.534 339.701,480.924 "/>
9
+ <path fill="#F2F2F2" stroke="#4D4D4D" stroke-width="10" stroke-miterlimit="10" d="M339.155,480.377
10
+ c-32.604,32.605-83.143,119.773-60.56,142.356c22.583,22.583,109.752-27.955,142.356-60.561L627.014,356.11
11
+ c47.386-47.383,67.413-104.042,44.829-126.625c-22.581-22.583-79.239-2.554-126.626,44.83L339.155,480.377z"/>
12
+ <line fill="#F2F2F2" stroke="#4D4D4D" stroke-width="10" stroke-miterlimit="10" x1="545.217" y1="274.314" x2="627.014" y2="356.11"/>
13
+ </g>
14
+ <g>
15
+ <polyline fill="#F2F2F2" stroke="#4D4D4D" stroke-width="10" stroke-miterlimit="10" points="238.324,379.087 319.333,460.097
16
+ 235.964,579.646 176.977,520.656 118.777,462.457 238.083,378.847 "/>
17
+ <path fill="#F2F2F2" stroke="#4D4D4D" stroke-width="10" stroke-miterlimit="10" d="M237.537,378.301
18
+ c-32.604,32.604-83.143,119.773-60.561,142.355c22.583,22.584,109.752-27.955,142.357-60.56l206.062-206.063
19
+ c47.385-47.385,67.412-104.043,44.83-126.626c-22.582-22.582-79.241-2.555-126.627,44.83L237.537,378.301z"/>
20
+ <line fill="#F2F2F2" stroke="#4D4D4D" stroke-width="10" stroke-miterlimit="10" x1="443.599" y1="172.238" x2="525.396" y2="254.034"/>
21
+ </g>
22
+ </svg>
@@ -507,7 +507,7 @@
507
507
  "G": 10,
508
508
  "H": 10,
509
509
  "I": 4,
510
- "J": 4,
510
+ "J": 11,
511
511
  "K": 90,
512
512
  "L": 90,
513
513
  "M": 5
@@ -1138,9 +1138,10 @@
1138
1138
  "name": "拉包尔方面海域",
1139
1139
  "start": "1",
1140
1140
  "boss": "G",
1141
- "manualNodes": ["I"],
1141
+ "manualNodes": ["I", "O"],
1142
1142
  "manualOutcomes": {
1143
- "I": [{ "to": "J", "probability": null }, { "to": "O", "probability": null }]
1143
+ "I": [{ "to": "J", "probability": null }, { "to": "O", "probability": null }],
1144
+ "O": [{ "to": "P", "probability": null }, { "to": "Q", "probability": null }]
1144
1145
  },
1145
1146
  "sourceRefs": [
1146
1147
  { "kind": "kcwiki", "url": "https://zh.kcwiki.cn/wiki/南方海域/5-6", "checkedAt": "2026-09-11" },
@@ -1186,7 +1187,7 @@
1186
1187
  { "id": "5-6-L-fallback", "node": "L", "priority": 999, "predicate": { "kind": "always", "label": "索敌不足" }, "outcomes": [{ "to": "M", "probability": 1 }], "confidence": "approximate" },
1187
1188
  { "id": "5-6-I-p2", "node": "I", "priority": 10, "predicate": { "kind": "phase", "phase": "P2", "label": "P2:向下前往 J" }, "outcomes": [{ "to": "J", "probability": 1 }], "confidence": "verified" },
1188
1189
  { "id": "5-6-I-p3", "node": "I", "priority": 20, "predicate": { "kind": "phase", "phase": "P3", "label": "P3:向上前往 O" }, "outcomes": [{ "to": "O", "probability": 1 }], "confidence": "verified" },
1189
- { "id": "5-6-O-fixed", "node": "O", "priority": 1, "predicate": { "kind": "always", "label": "固定前往 Q" }, "outcomes": [{ "to": "Q", "probability": 1 }], "confidence": "parsed" },
1190
+ { "id": "5-6-O-manual", "node": "O", "priority": 1, "predicate": { "kind": "always", "label": "能动分歧:选择 P / Q" }, "outcomes": [{ "to": "P", "probability": null }, { "to": "Q", "probability": null }], "confidence": "verified" },
1190
1191
  { "id": "5-6-Q-locked", "node": "Q", "priority": 10, "predicate": { "kind": "flag", "flag": "qRoutes", "value": false, "label": "Q-U/Q-W 路线未解锁" }, "outcomes": [{ "to": "Q2", "probability": 1 }], "confidence": "verified" },
1191
1192
  { "id": "5-6-Q-fast-plus", "node": "Q", "priority": 20, "predicate": { "kind": "speed", "mode": "highPlus" }, "outcomes": [{ "to": "W", "probability": 1 }], "confidence": "verified" },
1192
1193
  { "id": "5-6-Q-high-light", "node": "Q", "priority": 30, "predicate": { "kind": "all", "predicates": [{ "kind": "count", "category": "CL", "op": ">=", "value": 1 }, { "kind": "count", "category": "DD", "op": ">=", "value": 3 }, { "kind": "speed", "mode": "allHigh" }] }, "outcomes": [{ "to": "W", "probability": 1 }], "confidence": "verified" },
package/index.js CHANGED
@@ -27,7 +27,11 @@ const mapAreas = [
27
27
  ]
28
28
  const LOCAL_KCNAV_ASSET_ROOT = path.join(__dirname, 'assets', 'kcnav')
29
29
  const LOCAL_PROPHET_SPOT_ICON_ROOT = path.join(__dirname, 'assets', 'icons', 'spot')
30
- const PREFERENCES_STORAGE_KEY = 'poi-plugin-compass.preferences.v1'
30
+ const LOCAL_PROPHET_PARAMETER_ICON_ROOT = path.join(__dirname, 'assets', 'icons', 'parameter')
31
+ const preferencesStorage = require('./storage')
32
+ const preferencesBase = () => typeof APPDATA_PATH !== 'undefined'
33
+ ? APPDATA_PATH
34
+ : path.join(process.env.HOME || process.cwd(), '.poi-dark')
31
35
 
32
36
  const MAP_PHASES = {
33
37
  '5-6': [
@@ -50,6 +54,13 @@ const MAP_PHASES = {
50
54
  ],
51
55
  }
52
56
 
57
+ const MAP_ROUTE_OPTIONS = {
58
+ '5-6': [
59
+ { id: 'locked', label: '未解锁', flags: { qRoutes: false } },
60
+ { id: 'unlocked', label: '已解锁', flags: { qRoutes: true } },
61
+ ],
62
+ }
63
+
53
64
  function phaseOptionsForMap(mapId) {
54
65
  return MAP_PHASES[mapId] || []
55
66
  }
@@ -58,10 +69,25 @@ function phaseOptionForMap(mapId, phaseId) {
58
69
  return phaseOptionsForMap(mapId).find((phase) => phase.id === phaseId) || phaseOptionsForMap(mapId)[0] || null
59
70
  }
60
71
 
61
- function phaseFlags(mapId, phaseId) {
72
+ function routeOptionsForMap(mapId) {
73
+ return MAP_ROUTE_OPTIONS[mapId] || []
74
+ }
75
+
76
+ function defaultRouteOptionId(mapId) {
77
+ return routeOptionsForMap(mapId)[0]?.id || null
78
+ }
79
+
80
+ function routeOptionForMap(mapId, routeOptionId) {
81
+ return routeOptionsForMap(mapId).find((option) => option.id === routeOptionId)
82
+ || routeOptionsForMap(mapId)[0]
83
+ || null
84
+ }
85
+
86
+ function phaseFlags(mapId, phaseId, routeOptionId) {
62
87
  const flags = {}
63
88
  if (mapId === '7-3') flags.phase1 = phaseId === 'P1'
64
- if (mapId === '5-6') flags.qRoutes = phaseId === 'P3'
89
+ const routeOption = routeOptionForMap(mapId, routeOptionId)
90
+ if (routeOption?.flags) Object.assign(flags, routeOption.flags)
65
91
  return flags
66
92
  }
67
93
 
@@ -187,12 +213,10 @@ function normalizeOverrides(value) {
187
213
  }
188
214
 
189
215
  function readCompassPreferences() {
190
- const storage = compassStorage()
191
- if (!storage) return {}
192
216
  try {
193
- const parsed = JSON.parse(storage.getItem(PREFERENCES_STORAGE_KEY) || '{}')
194
- return isRecord(parsed) ? parsed : {}
195
- } catch (_) {
217
+ return preferencesStorage.load(preferencesBase(), compassStorage())
218
+ } catch (error) {
219
+ console.warn('[compass] 无法读取设置;原数据已保留', error)
196
220
  return {}
197
221
  }
198
222
  }
@@ -353,6 +377,10 @@ function prophetSpotIconUrl(icon) {
353
377
  return pathToFileURL(path.join(LOCAL_PROPHET_SPOT_ICON_ROOT, `${icon}.svg`)).toString()
354
378
  }
355
379
 
380
+ function prophetParameterIconUrl(icon) {
381
+ return pathToFileURL(path.join(LOCAL_PROPHET_PARAMETER_ICON_ROOT, `${icon}.svg`)).toString()
382
+ }
383
+
356
384
  function nodeMarker(nodeType, nodeInfo) {
357
385
  const prophetIcon = PROPHET_NODE_ICONS[nodeType]
358
386
  if (prophetIcon) return { source: 'prophet', url: prophetSpotIconUrl(prophetIcon) }
@@ -662,11 +690,14 @@ class Compass extends React.Component {
662
690
  const initialDeckId = [1, 2, 3, 4].includes(Number(preferences.deckId)) ? Number(preferences.deckId) : 1
663
691
  const preferredPhaseId = preferences.phaseByMap?.[initialMapId] ?? preferences.phaseId
664
692
  const initialPhaseId = phaseOptionForMap(initialMapId, preferredPhaseId)?.id || null
693
+ const initialRouteOptionId = routeOptionForMap(initialMapId, preferences.routeOptionByMap?.[initialMapId])?.id
694
+ || defaultRouteOptionId(initialMapId)
665
695
  const initialExportPreferences = storedExportPreferencesForView(preferences, initialMapId, initialPhaseId, initialDeckId)
666
696
  this.state = {
667
697
  deckId: initialDeckId,
668
698
  mapId: initialMapId,
669
699
  phaseId: initialPhaseId,
700
+ routeOptionId: initialRouteOptionId,
670
701
  selectedNode: pluginState.currentNode || null,
671
702
  selectedEdge: null,
672
703
  overrides: storedOverridesForView(preferences, initialMapId, initialPhaseId, initialDeckId),
@@ -703,10 +734,8 @@ class Compass extends React.Component {
703
734
  }
704
735
 
705
736
  persistPreferences() {
706
- const storage = compassStorage()
707
- if (!storage) return
708
737
  try {
709
- const previous = readCompassPreferences()
738
+ const previous = preferencesStorage.load(preferencesBase(), compassStorage())
710
739
  const overridesByView = isRecord(previous.overridesByView) ? { ...previous.overridesByView } : {}
711
740
  overridesByView[viewPreferenceKey(this.state.mapId, this.state.phaseId, this.state.deckId)] = normalizeOverrides(this.state.overrides)
712
741
  const exportByView = isRecord(previous.exportByView) ? { ...previous.exportByView } : {}
@@ -717,20 +746,23 @@ class Compass extends React.Component {
717
746
  }
718
747
  const phaseByMap = isRecord(previous.phaseByMap) ? { ...previous.phaseByMap } : {}
719
748
  phaseByMap[this.state.mapId] = this.state.phaseId
720
- storage.setItem(PREFERENCES_STORAGE_KEY, JSON.stringify({
749
+ const routeOptionByMap = isRecord(previous.routeOptionByMap) ? { ...previous.routeOptionByMap } : {}
750
+ if (this.state.routeOptionId) routeOptionByMap[this.state.mapId] = this.state.routeOptionId
751
+ preferencesStorage.save(preferencesBase(), {
721
752
  ...previous,
722
753
  mapId: this.state.mapId,
723
754
  phaseId: this.state.phaseId,
724
755
  phaseByMap,
756
+ routeOptionByMap,
725
757
  deckId: this.state.deckId,
726
758
  probabilityFormat: this.state.probabilityFormat,
727
759
  showEstimatedAir: this.state.showEstimatedAir,
728
760
  showEnemyAvatars: this.state.showEnemyAvatars,
729
761
  overridesByView,
730
762
  exportByView,
731
- }))
732
- } catch (_) {
733
- // Preferences are optional; a restricted localStorage must not break the plugin.
763
+ })
764
+ } catch (error) {
765
+ console.warn('[compass] 无法保存设置;原数据已保留', error)
734
766
  }
735
767
  }
736
768
 
@@ -743,10 +775,13 @@ class Compass extends React.Component {
743
775
  selectMap(mapId) {
744
776
  const preferences = readCompassPreferences()
745
777
  const phaseId = phaseOptionForMap(mapId, preferences.phaseByMap?.[mapId])?.id || null
778
+ const routeOptionId = routeOptionForMap(mapId, preferences.routeOptionByMap?.[mapId])?.id
779
+ || defaultRouteOptionId(mapId)
746
780
  const exportPreferences = storedExportPreferencesForView(preferences, mapId, phaseId, this.state.deckId)
747
781
  this.setState({
748
782
  mapId,
749
783
  phaseId,
784
+ routeOptionId,
750
785
  selectedNode: null,
751
786
  selectedEdge: null,
752
787
  overrides: storedOverridesForView(preferences, mapId, phaseId, this.state.deckId),
@@ -800,6 +835,11 @@ class Compass extends React.Component {
800
835
  this.setState({ phaseId, selectedNode: null, selectedEdge: null, overrides, ...exportPreferences, exportError: null })
801
836
  }
802
837
 
838
+ selectRouteOption(routeOptionId) {
839
+ if (!routeOptionForMap(this.state.mapId, routeOptionId)) return
840
+ this.setState({ routeOptionId, selectedNode: null, selectedEdge: null, exportRoute: null, exportError: null })
841
+ }
842
+
803
843
  selectNode(node) {
804
844
  this.setState({ selectedNode: node, selectedEdge: null })
805
845
  }
@@ -825,8 +865,15 @@ class Compass extends React.Component {
825
865
 
826
866
  renderFleetAir(air) {
827
867
  if (!air || !Number.isFinite(air.min) || !Number.isFinite(air.max) || air.max <= 0) return null
828
- return h('span', { className: 'compass-fleet-air', title: 'poi 当前舰队制空(熟练度范围)' },
829
- `制空 ${air.min === air.max ? air.min : `${air.min}~${air.max}`}`)
868
+ const { SlotitemIcon } = require('views/components/etc/icon')
869
+ const value = air.min === air.max ? air.min : `${air.min}~${air.max}`
870
+ return h('span', {
871
+ className: 'compass-fleet-air',
872
+ title: `poi 当前舰队制空(熟练度范围):${value}`,
873
+ 'aria-label': `当前舰队制空 ${value}`,
874
+ },
875
+ h('span', { className: 'compass-fleet-air-icon', 'aria-hidden': 'true' }, h(SlotitemIcon, { slotitemId: 6, alt: '' })),
876
+ h('span', { className: 'compass-fleet-air-value' }, value))
830
877
  }
831
878
 
832
879
  setManualOverride(node, to, edgeId = null, selectedNode = node) {
@@ -1181,6 +1228,45 @@ class Compass extends React.Component {
1181
1228
  return `${master.api_name}${rank}`
1182
1229
  }
1183
1230
  const equipName = (id) => masters.$equips?.[id]?.api_name || `#${id}`
1231
+ const equipIconId = (id) => {
1232
+ const iconId = Number(masters.$equips?.[id]?.api_type?.[3])
1233
+ return Number.isFinite(iconId) && iconId > 0 ? iconId : Number(id)
1234
+ }
1235
+ const { SlotitemIcon } = require('views/components/etc/icon')
1236
+ const FontAwesome = require('react-fontawesome')
1237
+ const statDefinitions = {
1238
+ hp: { label: 'HP', icon: 'heart' },
1239
+ fp: { label: '火力', icon: 'firepower' },
1240
+ torp: { label: '雷装', icon: 'torpedo' },
1241
+ aa: { label: '对空', icon: 'AA' },
1242
+ armor: { label: '装甲', icon: 'armor' },
1243
+ }
1244
+ const renderEnemyStat = (field, value) => {
1245
+ const definition = statDefinitions[field]
1246
+ const icon = definition.icon === 'heart'
1247
+ ? h(FontAwesome, { name: 'heart', className: 'compass-enemy-stat-icon', 'aria-hidden': 'true' })
1248
+ : h('img', { className: 'compass-enemy-stat-icon', src: prophetParameterIconUrl(definition.icon), alt: '' })
1249
+ return h('span', {
1250
+ key: field,
1251
+ className: `compass-enemy-stat compass-enemy-stat-${field}`,
1252
+ title: definition.label,
1253
+ 'aria-label': `${definition.label} ${value ?? '—'}`,
1254
+ }, icon, h('span', { className: 'compass-enemy-stat-value' }, value ?? '—'))
1255
+ }
1256
+ const renderEnemyEquip = (id, index) => {
1257
+ if (!(Number(id) > 0)) return h('span', {
1258
+ key: `empty-${index}`,
1259
+ className: 'compass-enemy-equip is-empty',
1260
+ 'aria-hidden': 'true',
1261
+ })
1262
+ const name = equipName(id)
1263
+ return h('span', {
1264
+ key: `${id}-${index}`,
1265
+ className: 'compass-enemy-equip',
1266
+ title: name,
1267
+ 'aria-label': name,
1268
+ }, h(SlotitemIcon, { slotitemId: equipIconId(id), alt: '' }))
1269
+ }
1184
1270
  return h('section', { className: 'compass-panel compass-enemies' }, heading,
1185
1271
  data.rows.length ? h('div', { className: 'compass-enemy-air-range' }, `空优 ${rangeText(data.superiority)} / 空确 ${rangeText(data.supremacy)}${data.uncertain ? '?' : ''}`) : null,
1186
1272
  !data.rows.length ? h('div', { className: 'compass-muted' }, '此期间无记录') : null,
@@ -1203,11 +1289,13 @@ class Compass extends React.Component {
1203
1289
  h('div', null, `陆航:${airText(entry.lbasThresholds)}`),
1204
1290
  entry.uncertain ? h('div', null, `装备不确定:${entry.uncertainAirpowerItems.join('、')}`) : null,
1205
1291
  h('div', { className: 'compass-enemy-stats' },
1206
- h('div', { className: 'compass-enemy-stat-row compass-muted' }, ...['', 'HP', '火力', '雷装', '对空', '装甲', '装备'].map((label, i) => h('span', { key: i }, label))),
1207
1292
  ...fleet.map((ship, shipIndex) => h('div', { key: shipIndex, className: `compass-enemy-stat-row${shipIndex === entry.mainFleet.length ? ' is-escort-start' : ''}` },
1208
1293
  this.renderShipPortrait(ship.id, shipName(ship), getRootState()),
1209
- ...['hp', 'fp', 'torp', 'aa', 'armor'].map(field => h('span', { key: field }, ship[field] ?? '—')),
1210
- h('span', { className: 'compass-enemy-equips' }, (ship.equips || []).filter(id => id > 0).map(equipName).join(' / ') || '—'),
1294
+ ...['hp', 'fp', 'torp', 'aa', 'armor'].map(field => renderEnemyStat(field, ship[field])),
1295
+ h('span', { className: 'compass-enemy-equips' },
1296
+ Array.isArray(ship.equips) && ship.equips.length
1297
+ ? ship.equips.map(renderEnemyEquip)
1298
+ : h('span', { className: 'compass-enemy-equip is-empty' }, '—')),
1211
1299
  )),
1212
1300
  ),
1213
1301
  ),
@@ -1299,7 +1387,7 @@ class Compass extends React.Component {
1299
1387
  admiralCoefficient: mapLosConfig.admiralCoefficient,
1300
1388
  passedNodes: pluginState.currentMapId === this.state.mapId ? pluginState.passedNodes : [],
1301
1389
  phase: phase?.id || null,
1302
- flags: phaseFlags(this.state.mapId, phase?.id),
1390
+ flags: phaseFlags(this.state.mapId, phase?.id, this.state.routeOptionId),
1303
1391
  })
1304
1392
  const geometry = mapGeometry(this.state.mapId)
1305
1393
  const displayMapDefinition = phase?.boss
@@ -1325,6 +1413,7 @@ class Compass extends React.Component {
1325
1413
  ].filter(Boolean)
1326
1414
 
1327
1415
  return h('div', { className: 'poi-compass' },
1416
+ h('div', { className: 'compass-scroll-viewport' },
1328
1417
  h('link', { rel: 'stylesheet', href: cssPath }),
1329
1418
  h('header', { className: 'compass-titlebar' }, h('h3', null, '舰队罗盘')),
1330
1419
  h('div', { className: 'compass-toolbar' },
@@ -1342,6 +1431,18 @@ class Compass extends React.Component {
1342
1431
  }, option.label)),
1343
1432
  )
1344
1433
  : null,
1434
+ routeOptionsForMap(this.state.mapId).length > 1
1435
+ ? h('div', { className: 'compass-phase-group', role: 'group', 'aria-label': '路线解锁状态' },
1436
+ h('span', { className: 'compass-option-label' }, 'Q路线'),
1437
+ routeOptionsForMap(this.state.mapId).map((option) => h('button', {
1438
+ key: option.id,
1439
+ type: 'button',
1440
+ className: routeOptionForMap(this.state.mapId, this.state.routeOptionId)?.id === option.id ? 'is-active' : '',
1441
+ 'aria-pressed': routeOptionForMap(this.state.mapId, this.state.routeOptionId)?.id === option.id,
1442
+ onClick: () => this.selectRouteOption(option.id),
1443
+ }, option.label)),
1444
+ )
1445
+ : null,
1345
1446
  h('div', { className: 'compass-option-group', role: 'group', 'aria-label': '概率标签格式' },
1346
1447
  h('span', { className: 'compass-option-label' }, '概率'),
1347
1448
  ...[
@@ -1398,6 +1499,7 @@ class Compass extends React.Component {
1398
1499
  ),
1399
1500
  ),
1400
1501
  h('div', { className: 'compass-enemy-region' }, this.renderEnemyPanel(this.state.mapId, selectedNode)),
1502
+ ),
1401
1503
  )
1402
1504
  }
1403
1505
  }
@@ -1423,6 +1525,8 @@ exports.__test = {
1423
1525
  nodeIconUrl,
1424
1526
  losConfig: losCatalog.maps,
1425
1527
  phaseOptionsForMap,
1528
+ routeOptionsForMap,
1529
+ routeOptionForMap,
1426
1530
  pluginState,
1427
1531
  routeEdges,
1428
1532
  ruleRows,
package/logic.js CHANGED
@@ -290,6 +290,17 @@ function hasPhaseRules(mapDefinition, node) {
290
290
  return rulesForNode(mapDefinition, node).some((rule) => containsPhasePredicate(rule.predicate))
291
291
  }
292
292
 
293
+ function containsVisitedPredicate(predicate) {
294
+ if (!predicate || typeof predicate !== 'object') return false
295
+ if (predicate.kind === 'visited') return true
296
+ if (Array.isArray(predicate.predicates) && predicate.predicates.some(containsVisitedPredicate)) return true
297
+ return containsVisitedPredicate(predicate.predicate)
298
+ }
299
+
300
+ function hasVisitedRules(mapDefinition) {
301
+ return (mapDefinition?.rules || []).some((rule) => containsVisitedPredicate(rule.predicate))
302
+ }
303
+
293
304
  function selectRouteRule(mapDefinition, node, context) {
294
305
  const rules = rulesForNode(mapDefinition, node)
295
306
  for (const rule of rules) {
@@ -562,6 +573,74 @@ function propagateProbability(start, decisions) {
562
573
  }
563
574
  }
564
575
 
576
+ function propagatePathProbability(start, mapDefinition, context = {}, overrides = {}) {
577
+ const reach = {}
578
+ const edgeMass = {}
579
+ const unknownNodes = new Set()
580
+ const unknownEdges = new Set()
581
+ const estimatedNodes = new Set()
582
+ const estimatedEdges = new Set()
583
+ const decisionVariants = new Map()
584
+ let visits = 0
585
+
586
+ function rememberDecision(node, passedNodes, decision) {
587
+ const variants = decisionVariants.get(node) || []
588
+ const key = passedNodes.join('\u0000')
589
+ if (!variants.some((variant) => variant.key === key)) {
590
+ variants.push({ key, passedNodes, decision })
591
+ decisionVariants.set(node, variants)
592
+ }
593
+ }
594
+
595
+ function walk(node, passedNodes, mass, path, estimatedPath) {
596
+ if (!Number.isFinite(mass) || mass <= 0) return
597
+ if (++visits > 10000) {
598
+ unknownNodes.add(node)
599
+ return
600
+ }
601
+ reach[node] = number(reach[node]) + mass
602
+ if (estimatedPath) estimatedNodes.add(node)
603
+ if (path.has(node)) {
604
+ unknownNodes.add(node)
605
+ return
606
+ }
607
+
608
+ const decision = routeDecision(mapDefinition, node, { ...context, passedNodes }, overrides)
609
+ rememberDecision(node, passedNodes, decision)
610
+ if (!decision || decision.status === 'terminal' && !decision.outcomes.length
611
+ || decision.status === 'unknown' && !decision.outcomes.length) {
612
+ if (decision?.status === 'unknown') unknownNodes.add(node)
613
+ return
614
+ }
615
+ if (decision.status === 'unknown' || Number(decision.unknownProbability) > 1e-9) unknownNodes.add(node)
616
+
617
+ const nextPath = new Set(path)
618
+ nextPath.add(node)
619
+ decision.outcomes.forEach((outcome) => {
620
+ const key = edgeKey(node, outcome.to)
621
+ if (outcome.probability == null) {
622
+ unknownEdges.add(key)
623
+ return
624
+ }
625
+ const edgeEstimated = estimatedPath || outcome.estimated === true
626
+ if (edgeEstimated) estimatedEdges.add(key)
627
+ edgeMass[key] = number(edgeMass[key]) + mass * outcome.probability
628
+ walk(outcome.to, [...passedNodes, node], mass * outcome.probability, nextPath, edgeEstimated)
629
+ })
630
+ }
631
+
632
+ walk(start, [], 1, new Set(), false)
633
+ return {
634
+ reach,
635
+ edgeMass,
636
+ unknownNodes: Array.from(unknownNodes),
637
+ unknownEdges: Array.from(unknownEdges),
638
+ estimatedNodes: Array.from(estimatedNodes),
639
+ estimatedEdges: Array.from(estimatedEdges),
640
+ decisionVariants,
641
+ }
642
+ }
643
+
565
644
  function evaluateMap(mapDefinition, context = {}, overrides = {}) {
566
645
  const nodes = new Set([mapDefinition?.start || '1'])
567
646
  ;(mapDefinition?.rules || []).forEach((rule) => nodes.add(rule.node))
@@ -574,7 +653,21 @@ function evaluateMap(mapDefinition, context = {}, overrides = {}) {
574
653
  Array.from(nodes).forEach((node) => {
575
654
  if (!decisions[node]) decisions[node] = routeDecision(mapDefinition, node, context, overrides)
576
655
  })
577
- const probability = propagateProbability(mapDefinition?.start || '1', decisions)
656
+ const probability = hasVisitedRules(mapDefinition)
657
+ ? propagatePathProbability(mapDefinition?.start || '1', mapDefinition, context, overrides)
658
+ : propagateProbability(mapDefinition?.start || '1', decisions)
659
+ if (probability.decisionVariants) {
660
+ const preferredPath = Array.isArray(context.passedNodes) ? context.passedNodes : []
661
+ probability.decisionVariants.forEach((variants, node) => {
662
+ const preferred = preferredPath.length
663
+ ? variants
664
+ .filter((variant) => variant.passedNodes.every((passedNode, index) => preferredPath[index] === passedNode))
665
+ .sort((left, right) => right.passedNodes.length - left.passedNodes.length)[0]
666
+ : null
667
+ decisions[node] = (preferred || variants[0]).decision
668
+ })
669
+ delete probability.decisionVariants
670
+ }
578
671
  return { decisions, probability }
579
672
  }
580
673
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "poi-plugin-compass",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "通常海域舰队罗盘",
5
5
  "main": "index.js",
6
6
  "repository": {
@@ -13,6 +13,7 @@
13
13
  "homepage": "https://github.com/RikaKagurasaka/poi-plugin-compass#readme",
14
14
  "files": [
15
15
  "index.js",
16
+ "storage.js",
16
17
  "logic.js",
17
18
  "enemies.js",
18
19
  "exporters.js",
@@ -31,8 +32,8 @@
31
32
  "cache:kcnav-enemies:long-low": "node scripts/cache-kcnav-enemies-long-low.js",
32
33
  "compress:kcnav-maps": "node scripts/compress-kcnav-maps.js --remove-png",
33
34
  "compress:kcnav-enemies": "node scripts/compact-kcnav-enemies.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",
35
- "test": "node test.js && node test-enemies.js && node test-exporters.js && node test-ui.js && node test-overview.js"
35
+ "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 && node --check storage.js",
36
+ "test": "node test.js && node test-enemies.js && node test-exporters.js && node test-ui.js && node test-overview.js && node --test test-storage.js"
36
37
  },
37
38
  "keywords": [
38
39
  "poi-plugin",
@@ -52,7 +53,7 @@
52
53
  "icon": "fa/compass",
53
54
  "priority": 20,
54
55
  "apiVer": {
55
- "11.99.99": "0.1.5"
56
+ "11.99.99": "0.1.7"
56
57
  }
57
58
  },
58
59
  "peerDependencies": {
@@ -17,6 +17,14 @@ const mapIds = Array.from(new Set(catalogs.flatMap((catalog) => Object.keys(cata
17
17
 
18
18
  const DEFAULT_INTERVAL_MS = 15_000
19
19
 
20
+ // KCNav currently exposes 6-5 J as a normal battle node, but the WikiWiki
21
+ // route table identifies it as an opening-night battle. Keep the raw response
22
+ // unchanged and apply only this verified presentation correction to the
23
+ // derived node-type cache.
24
+ const VERIFIED_NODE_TYPE_OVERRIDES = {
25
+ '6-5': { J: 11 },
26
+ }
27
+
20
28
  function apiUrl(mapId) {
21
29
  return `https://tsunkit.net/api/routing/maps/${mapId}/`
22
30
  }
@@ -32,7 +40,7 @@ function intervalFromArgs() {
32
40
  return Number.isFinite(value) && value >= 0 ? value : DEFAULT_INTERVAL_MS
33
41
  }
34
42
 
35
- function nodeTypesFromResult(result) {
43
+ function nodeTypesFromResult(result, mapId = null) {
36
44
  const nodeValues = {}
37
45
  const spots = result?.spots || {}
38
46
  Object.keys(spots).forEach((node) => { nodeValues[node] = -1 })
@@ -48,6 +56,7 @@ function nodeTypesFromResult(result) {
48
56
  const nodeType = Number(entry[2])
49
57
  if (Number.isFinite(nodeType)) nodeValues[entry[1]] = nodeType
50
58
  })
59
+ Object.assign(nodeValues, VERIFIED_NODE_TYPE_OVERRIDES[mapId] || {})
51
60
  return nodeValues
52
61
  }
53
62
 
@@ -82,7 +91,7 @@ async function main() {
82
91
  const content = `${JSON.stringify(payload, null, 2)}\n`
83
92
  const contentFile = `${mapId}.json`
84
93
  fs.writeFileSync(path.join(outputDir, contentFile), content, 'utf8')
85
- nodeTypesByMap[mapId] = nodeTypesFromResult(payload.result)
94
+ nodeTypesByMap[mapId] = nodeTypesFromResult(payload.result, mapId)
86
95
  manifest.push({
87
96
  mapId,
88
97
  apiUrl: url,
package/storage.js ADDED
@@ -0,0 +1,47 @@
1
+ 'use strict'
2
+ const fs = require('fs')
3
+ const path = require('path')
4
+ const { randomUUID } = require('crypto')
5
+ const legacyKey = 'poi-plugin-compass.preferences.v1'
6
+ const isRecord = value => value != null && typeof value === 'object' && !Array.isArray(value)
7
+
8
+ function parse(raw) {
9
+ const value = JSON.parse(raw)
10
+ if (!isRecord(value)) throw Error('罗盘设置文件格式错误;原数据已保留')
11
+ return value
12
+ }
13
+ function file(base) {
14
+ if (!base) throw Error('poi 数据目录尚未加载')
15
+ return path.join(base, 'compass', 'preferences.json')
16
+ }
17
+ function save(base, value) {
18
+ const target = file(base)
19
+ // Never replace an unreadable or malformed existing file with default preferences.
20
+ const previous = fs.existsSync(target) ? parse(fs.readFileSync(target, 'utf8')) : null
21
+ const raw = JSON.stringify(value, null, 2) + '\n'
22
+ parse(raw)
23
+ if (previous && JSON.stringify(previous, null, 2) + '\n' === raw) return
24
+ fs.mkdirSync(path.dirname(target), { recursive: true })
25
+ const temporary = `${target}.${randomUUID()}.tmp`
26
+ try {
27
+ fs.writeFileSync(temporary, raw, { flag: 'wx' })
28
+ fs.renameSync(temporary, target)
29
+ } finally {
30
+ if (fs.existsSync(temporary)) fs.unlinkSync(temporary)
31
+ }
32
+ }
33
+ function load(base, legacy) {
34
+ const target = file(base)
35
+ try {
36
+ return parse(fs.readFileSync(target, 'utf8'))
37
+ } catch (error) {
38
+ if (error.code !== 'ENOENT') throw error
39
+ }
40
+ const raw = legacy?.getItem(legacyKey)
41
+ if (raw == null) return {}
42
+ const value = parse(raw)
43
+ save(base, value)
44
+ // Keep the original localStorage entry intact as a migration backup.
45
+ return value
46
+ }
47
+ module.exports = { load, save, legacyKey }