ai-notify 0.7.0 → 0.8.0

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.ja.md CHANGED
@@ -205,6 +205,19 @@ ai-notify tsundere test # T3/T2/T1/T0 のサンプルを試聴
205
205
 
206
206
  **無API・決定論・オフライン**(テンプレートで生成。課金ゼロ)。緊急度はエージェントの文面からのキーワード推定(厳密な重大度ではなくベストエフォート)で、デスクトップ通知は素の文面のまま。**VOICEVOX**利用時は、強さに応じて同じキャラの**ツンツン/あまあま**スタイルを選ぶので、声色そのものがツン・デレに変わります。`lang` は `ja` / `en` 対応。
207
207
 
208
+ ## ⚔️ 戦争モード(任意・遊び心)
209
+
210
+ ツンデレとは別の読み上げスキン。**作戦司令室**を演じます。レベルで状況が変わり、**ツンデレレベル(=オペレーターの好感度)との組合せ**で台詞が変化します:
211
+
212
+ ```sh
213
+ ai-notify war on
214
+ ai-notify war level 0.5 # 0 = 平時 ・ 0.5 = 戦闘中/第一種戦闘配置 ・ 1 = 危機(短く絶叫)
215
+ ai-notify war test
216
+ ```
217
+
218
+ - **平時** — 落ち着いた無線。**戦闘中** — 第一種戦闘配置、緊迫。**危機** — 短い絶叫、音量↑・速度↑。
219
+ - **ツンデレレベルが各段を味付け**(デレ=優しいオペレーター/ツン=厳しいオペレーター)。戦争×ツンデレで9通りの空気感。メニューバーにもトグル+スライダーあり。(ツンデレモード自体はOFFでも、そのスライダーは戦争モードの好感度入力として機能します。)
220
+
208
221
  ## ⏳ どの窓が・何を求めているか
209
222
 
210
223
  各通知のタイトルに窓ラベルが付きます — 入力待ちは `⏳ <label>`、完了は `✓ <label>`。本文には**何を**(翻訳されたプロンプト、または作業内容の要約)が出ます。各ペインに短い `AI_NOTIFY_LABEL` を設定すれば、10個のターミナルもひと目で見分けられます。
package/README.md CHANGED
@@ -206,6 +206,19 @@ ai-notify tsundere test # hear T3/T2/T1/T0 samples
206
206
 
207
207
  It's **deterministic and offline** — phrase banks, no API, no cost. The urgency is a keyword heuristic over the agent's text (so it's best-effort, not a real severity signal), and the desktop banner stays factual. With **VOICEVOX** the level also picks the character's own **ツンツン / あまあま** style, so the same character actually *sounds* harsher or sweeter. `lang` supports `ja` and `en`.
208
208
 
209
+ ## ⚔️ War mode (optional, fun)
210
+
211
+ A separate read-out skin: a **military ops room**. The level sets the situation, and — combined with the tsundere level (the operator's 好感度) — picks the line:
212
+
213
+ ```sh
214
+ ai-notify war on
215
+ ai-notify war level 0.5 # 0 = 平時 (calm) · 0.5 = 戦闘中 / 第一種戦闘配置 · 1 = 危機 (short, shouted)
216
+ ai-notify war test
217
+ ```
218
+
219
+ - **平時** — calm radio chatter. **戦闘中** — general quarters, urgent. **危機** — short shouts, louder and faster.
220
+ - The **tsundere level flavors every band** (a warm デレ operator vs a harsh ツン one), so war × tsundere gives 9 distinct moods. Toggle + slider are in the menu bar too. (Tsundere mode itself can be off; its slider still acts as the affection input for war.)
221
+
209
222
  ## ⏳ Which window, and what it's asking
210
223
 
211
224
  Each notification is titled with the window label — `⏳ <label>` when an agent is waiting, `✓ <label>` when it's done — and the body says **what** (the translated prompt, or a summary of what was done). Set a short `AI_NOTIFY_LABEL` per pane and you can tell ten terminals apart at a glance.
@@ -193,8 +193,16 @@ final class PopupCard: NSObject {
193
193
  var onClick: () -> Void = {}
194
194
 
195
195
  override init() {
196
- window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 300, height: 96),
197
- styleMask: [.borderless], backing: .buffered, defer: false)
196
+ // A non-activating floating panel: it receives clicks (the close button
197
+ // and the card's mouseDown both fire) without the background menu-bar app
198
+ // ever becoming active — the reliable way to make a HUD-style window
199
+ // clickable. A plain NSWindow drops the first click while inactive.
200
+ let panel = NSPanel(contentRect: NSRect(x: 0, y: 0, width: 300, height: 96),
201
+ styleMask: [.borderless, .nonactivatingPanel], backing: .buffered, defer: false)
202
+ panel.isFloatingPanel = true
203
+ panel.becomesKeyOnlyIfNeeded = true
204
+ panel.worksWhenModal = true
205
+ window = panel
198
206
  super.init()
199
207
  window.isOpaque = false
200
208
  window.backgroundColor = .clear
@@ -544,6 +552,38 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
544
552
  return item
545
553
  }
546
554
 
555
+ // War mode on/off checkbox + 平時⇄危機 slider (level 0–1). Separate axis from
556
+ // tsundere; the tsundere level flavors it.
557
+ private func warToggleRow(on: Bool) -> NSMenuItem {
558
+ let row = NSView(frame: NSRect(x: 0, y: 0, width: 220, height: 24))
559
+ let btn = NSButton(checkboxWithTitle: "戦争モード", target: self, action: #selector(warToggled(_:)))
560
+ btn.frame = NSRect(x: 12, y: 2, width: 196, height: 20)
561
+ btn.state = on ? .on : .off
562
+ row.addSubview(btn)
563
+ let item = NSMenuItem(); item.view = row
564
+ return item
565
+ }
566
+
567
+ private func warRow(value: Double) -> NSMenuItem {
568
+ let row = NSView(frame: NSRect(x: 0, y: 0, width: 220, height: 26))
569
+ let left = NSTextField(labelWithString: "平時")
570
+ left.frame = NSRect(x: 12, y: 5, width: 30, height: 16)
571
+ left.font = .systemFont(ofSize: 10); left.textColor = .secondaryLabelColor
572
+ let slider = NSSlider(value: value, minValue: 0, maxValue: 1, target: self, action: #selector(warLevelChanged(_:)))
573
+ slider.frame = NSRect(x: 46, y: 3, width: 128, height: 20)
574
+ slider.isContinuous = false
575
+ slider.trackFillColor = NSColor(srgbRed: 0.85, green: 0.2, blue: 0.15, alpha: 1) // war red
576
+ let right = NSTextField(labelWithString: "危機")
577
+ right.frame = NSRect(x: 178, y: 5, width: 30, height: 16)
578
+ right.font = .systemFont(ofSize: 10); right.textColor = .secondaryLabelColor
579
+ row.addSubview(left); row.addSubview(slider); row.addSubview(right)
580
+ let item = NSMenuItem(); item.view = row
581
+ return item
582
+ }
583
+
584
+ @objc private func warToggled(_ b: NSButton) { State.cli(["war", "toggle"]) }
585
+ @objc private func warLevelChanged(_ s: NSSlider) { State.cli(["war", "level", String(format: "%.2f", s.doubleValue)]) }
586
+
547
587
  // representedObject is the full CLI arg array to run.
548
588
  @objc private func runItem(_ item: NSMenuItem) {
549
589
  if let cmd = item.representedObject as? [String] { State.cli(cmd) }
@@ -575,6 +615,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
575
615
  let tsunLevel = (tsun?["level"] as? Double) ?? 0.5
576
616
  menu.addItem(tsundereToggleRow(on: tsunOn))
577
617
  menu.addItem(tsundereRow(value: tsunLevel))
618
+
619
+ // War mode: checkbox + 平時⇄危機 slider (a separate read-out skin).
620
+ let warJson = json?["war"] as? [String: Any]
621
+ menu.addItem(warToggleRow(on: (warJson?["enabled"] as? Bool) ?? false))
622
+ menu.addItem(warRow(value: (warJson?["level"] as? Double) ?? 0.5))
578
623
  menu.addItem(.separator())
579
624
 
580
625
  // VOICEVOX base prosody (speed / pitch / intonation) — only when VOICEVOX
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-notify",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Desktop, sound, and spoken notifications for terminal AI coding agents (Claude Code, Codex, Gemini, ...) — with one mute switch that covers all of them, across every terminal.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.mjs CHANGED
@@ -13,6 +13,7 @@ import { translate } from './translate.mjs';
13
13
  import { diagnose as highlightDiagnose, clearHighlight } from './highlight.mjs';
14
14
  import * as voicevox from './voicevox.mjs';
15
15
  import * as tsundere from './tsundere.mjs';
16
+ import * as war from './war.mjs';
16
17
  import {
17
18
  isMuted,
18
19
  setMuted,
@@ -45,6 +46,10 @@ import {
45
46
  getNotifyKinds,
46
47
  setNotifyKind,
47
48
  isNotifyKindEnabled,
49
+ isWarEnabled,
50
+ setWarEnabled,
51
+ readWarLevel,
52
+ setWarLevel,
48
53
  } from './state.mjs';
49
54
  import { resolve as resolvePath, join as pathJoin } from 'node:path';
50
55
 
@@ -446,6 +451,84 @@ const cmds = {
446
451
  if (!ts.enabled) log('\nEnable: ai-notify tsundere on 試聴: ai-notify tsundere test');
447
452
  },
448
453
 
454
+ // War mode: a military-ops-room read-out skin. Level: min 平時 / mid 戦闘中 /
455
+ // max 危機的. Combined with the tsundere level for the operator's 好感度.
456
+ // war [on|off|toggle|level <0-1>|test|status]
457
+ war() {
458
+ const sub = positionals[0] || 'status';
459
+ const config = readConfig();
460
+ const ts = config.tsundere || {};
461
+ const url = config.voicevox?.url || voicevox.DEFAULT_URL;
462
+
463
+ if (sub === 'on' || sub === 'off' || sub === 'toggle') {
464
+ const enabled = sub === 'toggle' ? !isWarEnabled() : sub === 'on';
465
+ setWarEnabled(enabled);
466
+ // Cache the VOICEVOX tsun/dere style map so fire-time skips the lookup.
467
+ if (enabled && config.tts === 'voicevox') {
468
+ const sm = voicevox.resolveStyles(config.voicevox?.speaker, url);
469
+ if (sm) {
470
+ config.tsundere = { ...ts, styleMap: sm };
471
+ writeConfig(config);
472
+ }
473
+ }
474
+ log(enabled ? '⚔️ 戦争モード ON(平時⇄戦闘⇄危機・ツンデレ好感度で口調変化)' : '戦争モード OFF');
475
+ if (enabled) {
476
+ log(' レベル: ai-notify war level <0=平時 〜 0.5=戦闘 〜 1=危機>');
477
+ log(' 試聴: ai-notify war test');
478
+ }
479
+ return;
480
+ }
481
+ if (sub === 'level') {
482
+ const arg = positionals[1];
483
+ if (arg === undefined) return log(`war level: ${readWarLevel()} (0=平時 〜 0.5=戦闘 〜 1=危機)`);
484
+ return log(`⚔️ war level → ${setWarLevel(arg)} (0=平時 〜 0.5=戦闘 〜 1=危機)`);
485
+ }
486
+ if (sub === 'test') {
487
+ const lang = ts.lang || 'ja';
488
+ const level = readWarLevel();
489
+ const aff = readTsundereLevel() != null ? readTsundereLevel() : ts.level ?? 0.5;
490
+ const sm = config.tts === 'voicevox' ? ts.styleMap || voicevox.resolveStyles(config.voicevox?.speaker, url) : null;
491
+ log(`war test (level ${level} = ${war.band(level)}, 好感度 ${aff}, lang ${lang}):\n`);
492
+ const rows =
493
+ lang === 'ja'
494
+ ? [
495
+ { tier: 'T3', body: 'ビルドが失敗' },
496
+ { tier: 'T2', body: '許可待ち' },
497
+ { tier: 'T1', body: '3ファイルを更新' },
498
+ { tier: 'T0', body: 'テスト全部パス' },
499
+ ]
500
+ : [
501
+ { tier: 'T3', body: 'the build failed' },
502
+ { tier: 'T2', body: 'waiting for approval' },
503
+ { tier: 'T1', body: 'updated 3 files' },
504
+ { tier: 'T0', body: 'all tests passed' },
505
+ ];
506
+ for (const s of rows) {
507
+ const eff = tsundere.effectiveLevel(aff, s.tier, ts.urgencyShift !== false);
508
+ const text = war.wrap(s.body, level, eff, lang, 0);
509
+ const mul = war.volumeMul(level, s.tier);
510
+ const tone = tsundere.axisFor(eff);
511
+ log(` [${s.tier} ×${mul.toFixed(2)} ${tone}] ${text}`);
512
+ if (sm) {
513
+ const speaker = sm[tone] ?? config.voicevox?.speaker;
514
+ voicevox.speak(text, speaker, url, mul, undefined, war.effectiveProsody(level, tsundere.effectiveProsody(tone, readVoiceProsody())));
515
+ } else {
516
+ try {
517
+ execFileSync('say', config.voice ? ['-v', config.voice, tsundere.decorateForSay(text, tone)] : [tsundere.decorateForSay(text, tone)], { stdio: 'ignore' });
518
+ } catch {
519
+ /* non-mac / no say */
520
+ }
521
+ }
522
+ }
523
+ return;
524
+ }
525
+ // status
526
+ log(`war mode: ${isWarEnabled() ? '⚔️ ON' : 'OFF'}`);
527
+ log(` level: ${readWarLevel()} → ${war.band(readWarLevel())} (0=平時 〜 0.5=戦闘 〜 1=危機)`);
528
+ log(` 好感度 (tsundere level): ${readTsundereLevel() != null ? readTsundereLevel() : ts.level ?? 0.5}`);
529
+ if (!isWarEnabled()) log('\nEnable: ai-notify war on 試聴: ai-notify war test');
530
+ },
531
+
449
532
  // Assign a voice to a specific pane (by tty), from the menu bar.
450
533
  // voice-pane <tty> voicevox <id> | say <name> | clear
451
534
  'voice-pane'() {
@@ -786,6 +869,7 @@ const cmds = {
786
869
  voices,
787
870
  panes,
788
871
  tsundere: { enabled: !!config.tsundere?.enabled, level: tsLevel },
872
+ war: { enabled: isWarEnabled(), level: readWarLevel() },
789
873
  tts: config.tts || 'say',
790
874
  prosody: readVoiceProsody(),
791
875
  prosodyRange: VOICE_PROSODY_RANGE,
@@ -948,6 +1032,7 @@ Usage:
948
1032
  ai-notify voice [number|name|preview|default] pick the spoken voice
949
1033
  ai-notify voicevox [setup|on <id>|off|speakers|test] speak in VOICEVOX character voices
950
1034
  ai-notify tsundere [on|off|level <0-1>|test|status] tsundere persona (ツン⇄デレ by urgency)
1035
+ ai-notify war [on|off|level <0-1>|test|status] war mode (平時⇄戦闘⇄危機; tsundere level = 好感度)
951
1036
  ai-notify voice-prosody [speed|pitch|intonation <v>|reset] VOICEVOX read-out tuning
952
1037
  ai-notify menubar [install|uninstall|status] native menu bar bell (macOS)
953
1038
  ai-notify notify [<kind> on|off] which events alert: input|permission|info|done|subagent-done
package/src/notify.mjs CHANGED
@@ -18,12 +18,15 @@ import {
18
18
  readTsundereLevel,
19
19
  readVoiceProsody,
20
20
  nextCounter,
21
+ isWarEnabled,
22
+ readWarLevel,
21
23
  } from './state.mjs';
22
24
  import { controllingTty } from './util.mjs';
23
25
  import { translate } from './translate.mjs';
24
26
  import { highlightWaiting, clearHighlight } from './highlight.mjs';
25
27
  import * as voicevox from './voicevox.mjs';
26
28
  import * as tsundere from './tsundere.mjs';
29
+ import * as war from './war.mjs';
27
30
 
28
31
  const platform = process.platform; // 'darwin' | 'linux' | 'win32'
29
32
 
@@ -234,29 +237,44 @@ export const emit = ({ provider = 'default', event = 'done', label = '', message
234
237
  let outText = speakText;
235
238
  let outVol = vol;
236
239
  let outSpeaker = speaker;
237
- let speakTone = 'normal'; // delivery contour; tsundere sets it to tsun/dere
240
+ let speakTone = 'normal'; // delivery contour; tsundere/war set it to tsun/dere
241
+ let warActive = false;
242
+ let warLevel = 0.5;
238
243
  const ts = config.tsundere;
239
- if (ts && ts.enabled) {
240
- const tier = tsundere.classifyUrgency(event, message, fullBody);
244
+ const tier = tsundere.classifyUrgency(event, message, fullBody);
245
+ // The tsundere LEVEL doubles as the operator's 好感度 for war mode, so resolve
246
+ // it even when tsundere skinning is off.
247
+ const tsBaseLevel = (() => {
241
248
  const envLevel = parseFloat(process.env.AI_NOTIFY_TSUNDERE_LEVEL);
242
- const baseLevel = Number.isFinite(envLevel)
243
- ? Math.min(1, Math.max(0, envLevel))
244
- : typeof pane.tsundere === 'number'
245
- ? pane.tsundere
246
- : readTsundereLevel() != null
247
- ? readTsundereLevel()
248
- : typeof ts.level === 'number'
249
- ? ts.level
250
- : 0.5;
251
- const eff = tsundere.effectiveLevel(baseLevel, tier, ts.urgencyShift !== false);
249
+ if (Number.isFinite(envLevel)) return Math.min(1, Math.max(0, envLevel));
250
+ if (typeof pane.tsundere === 'number') return pane.tsundere;
251
+ const f = readTsundereLevel();
252
+ if (f != null) return f;
253
+ return typeof ts?.level === 'number' ? ts.level : 0.5;
254
+ })();
255
+
256
+ if (isWarEnabled()) {
257
+ // War mode skins the read-out (ops room); the tsundere level flavors it.
258
+ warActive = true;
259
+ warLevel = readWarLevel();
260
+ const eff = tsundere.effectiveLevel(tsBaseLevel, tier, ts?.urgencyShift !== false);
261
+ speakTone = tsundere.axisFor(eff);
262
+ outVol = Math.min(2, Math.max(0, vol * war.volumeMul(warLevel, tier)));
263
+ outText = war.wrap(spokenBody, warLevel, eff, ts?.lang || 'ja', nextCounter('war'));
264
+ if (spokenName) outText = joinName(spokenName, outText);
265
+ if (tts === 'voicevox') {
266
+ const sm = ts?.styleMap || voicevox.resolveStyles(outSpeaker, config.voicevox?.url);
267
+ if (sm && sm[speakTone] != null) outSpeaker = sm[speakTone];
268
+ }
269
+ } else if (ts && ts.enabled) {
270
+ const eff = tsundere.effectiveLevel(tsBaseLevel, tier, ts.urgencyShift !== false);
252
271
  speakTone = tsundere.axisFor(eff);
253
272
  outVol = Math.min(2, Math.max(0, vol * tsundere.volumeMul(tier, ts.volumeBoost !== false)));
254
273
  outText = tsundere.wrap(spokenBody, eff, tier, ts.lang || 'ja', nextCounter('tsundere'));
255
274
  if (spokenName) outText = joinName(spokenName, outText);
256
275
  if (tts === 'voicevox') {
257
276
  const sm = ts.styleMap || voicevox.resolveStyles(outSpeaker, config.voicevox?.url);
258
- const axis = tsundere.axisFor(eff);
259
- if (sm && sm[axis] != null) outSpeaker = sm[axis];
277
+ if (sm && sm[speakTone] != null) outSpeaker = sm[speakTone];
260
278
  }
261
279
  }
262
280
 
@@ -265,7 +283,8 @@ export const emit = ({ provider = 'default', event = 'done', label = '', message
265
283
  if (config.speak && outVol > 0) {
266
284
  let spoken = false;
267
285
  if (tts === 'voicevox') {
268
- const prosody = tsundere.effectiveProsody(speakTone, readVoiceProsody());
286
+ let prosody = tsundere.effectiveProsody(speakTone, readVoiceProsody());
287
+ if (warActive) prosody = war.effectiveProsody(warLevel, prosody); // band scale on top
269
288
  spoken = voicevox.speak(outText, outSpeaker, config.voicevox?.url, outVol, undefined, prosody);
270
289
  }
271
290
  if (!spoken) speak(outText, voice, outVol, speakTone); // OS `say` (also the VOICEVOX fallback)
package/src/state.mjs CHANGED
@@ -90,6 +90,33 @@ export const setTsundereLevel = (v) => {
90
90
  return n;
91
91
  };
92
92
 
93
+ // --- War mode --------------------------------------------------------------
94
+ // A separate read-out skin (military ops room). enabled flag + 0–1 level:
95
+ // min 平時 / mid 戦闘中 / max 危機的. Combined with the tsundere level for the
96
+ // operator's 好感度. Same small-file pattern as the mute flag / tsundere level.
97
+ const warFlagPath = () => join(stateDir(), 'war-enabled');
98
+ const warLevelPath = () => join(stateDir(), 'war-level');
99
+ export const isWarEnabled = () => existsSync(warFlagPath());
100
+ export const setWarEnabled = (on) => {
101
+ ensureDir(stateDir());
102
+ if (on) writeFileSync(warFlagPath(), '');
103
+ else rmSync(warFlagPath(), { force: true });
104
+ };
105
+ export const readWarLevel = () => {
106
+ try {
107
+ const v = parseFloat(readFileSync(warLevelPath(), 'utf8'));
108
+ return Number.isFinite(v) ? Math.min(1, Math.max(0, v)) : 0.5;
109
+ } catch {
110
+ return 0.5;
111
+ }
112
+ };
113
+ export const setWarLevel = (v) => {
114
+ const n = Math.min(1, Math.max(0, Number(v)));
115
+ ensureDir(stateDir());
116
+ writeFileSync(warLevelPath(), String(n));
117
+ return n;
118
+ };
119
+
93
120
  // --- VOICEVOX base prosody -------------------------------------------------
94
121
  // User-tunable BASE scales for the VOICEVOX read-out — the values used at the
95
122
  // NORMAL tone; tsundere tones nudge from here. Written by the menu bar sliders /
package/src/war.mjs ADDED
@@ -0,0 +1,118 @@
1
+ // War mode: skin the spoken read-out as a military operations room. A separate
2
+ // axis from tsundere — the WAR LEVEL sets the situation, the tsundere level (if
3
+ // on) sets the operator's 好感度 (affection), and the combination picks the line:
4
+ //
5
+ // war level min → 平時 (peacetime, calm radio chatter)
6
+ // mid → 戦闘中 / 第一種戦闘配置 (general quarters, urgent)
7
+ // max → 危機的状況 (no slack — short, shouted)
8
+ // affection dere (warm) ⇄ normal ⇄ tsun (harsh) — flavors every band
9
+ //
10
+ // Deterministic, offline, SFW. Like tsundere.mjs, only the spoken text is
11
+ // wrapped; the desktop banner stays factual.
12
+
13
+ import { axisFor } from './tsundere.mjs';
14
+
15
+ // War situation band from the 0–1 level.
16
+ export const band = (level) => {
17
+ const v = Number.isFinite(level) ? level : 0.5;
18
+ if (v < 0.34) return 'peace';
19
+ if (v < 0.67) return 'combat';
20
+ return 'crisis';
21
+ };
22
+
23
+ // Crisis shouts at full volume; combat is raised; peace is normal. Urgency (tier)
24
+ // nudges it a little more. Multiplies the user's volume.
25
+ const BAND_VOL = { peace: 1.0, combat: 1.18, crisis: 1.4 };
26
+ const TIER_VOL = { T3: 1.12, T2: 1.04, T1: 1, T0: 0.98 };
27
+ export const volumeMul = (level, tier) => (BAND_VOL[band(level)] || 1) * (TIER_VOL[tier] || 1);
28
+
29
+ // A VOICEVOX prosody nudge per band (combat/crisis = faster, sharper). Combined
30
+ // on top of the user's base scales by effectiveProsody below.
31
+ const BAND_PROSODY = {
32
+ peace: { speed: 0.98, pitch: 0.0, intonation: 1.0 },
33
+ combat: { speed: 1.1, pitch: 0.0, intonation: 1.2 },
34
+ crisis: { speed: 1.22, pitch: 0.02, intonation: 1.35 },
35
+ };
36
+ export const effectiveProsody = (level, base = {}) => {
37
+ const t = BAND_PROSODY[band(level)] || BAND_PROSODY.peace;
38
+ const b = { speed: 1, pitch: 0, intonation: 1, ...base };
39
+ return { speed: b.speed * t.speed, pitch: b.pitch + t.pitch, intonation: b.intonation * t.intonation };
40
+ };
41
+
42
+ // BANK[lang][band][tone] = [lines]. `{body}` keeps the task gist so it stays
43
+ // informative. Crisis lines are short and shouted; peace lines are calm.
44
+ const BANK = {
45
+ ja: {
46
+ peace: {
47
+ tsun: [
48
+ '司令部より各局。{body}。…別に労ってるわけじゃないけど、引き続き警戒を怠るな。',
49
+ '状況、異常なし。{body}だ。気を抜くんじゃないわよ、当然でしょ。',
50
+ '定時報告。{body}。…ふん、これくらい当たり前。次も抜かりなくね。',
51
+ ],
52
+ normal: [
53
+ '司令部より入電。{body}。現状、戦線は静穏。警戒態勢を維持する。',
54
+ '通信。{body}。各員、配置のまま待機。以上。',
55
+ '定時連絡。{body}。状況に変化なし、平常運転だ。',
56
+ ],
57
+ dere: [
58
+ '司令部より各局へ。{body}だよ。落ち着いてるね、いい調子。少し休んでも大丈夫。',
59
+ '報告ありがと。{body}。今は穏やかだから、ゆっくりいこう?',
60
+ '通信。{body}。順調だね。…無理しないで、そばで見てるから。',
61
+ ],
62
+ },
63
+ combat: {
64
+ tsun: [
65
+ '第一種戦闘配置!{body}よ。ぼーっとしてないで持ち場につきなさい!',
66
+ '総員戦闘配置。{body}。…ヘマしたら承知しないからね、急いで!',
67
+ '戦闘開始。{body}だ。手が止まってるわよ、さっさと動く!',
68
+ ],
69
+ normal: [
70
+ '第一種戦闘配置。{body}。総員、対応急げ。',
71
+ '戦闘配置につけ。{body}。各局、状況を共有し対処せよ。',
72
+ '交戦中。{body}。手順どおり、迅速に。',
73
+ ],
74
+ dere: [
75
+ '第一種戦闘配置だよ!{body}。大丈夫、一緒に乗り切ろう、急いで!',
76
+ '戦闘配置。{body}。落ち着いて、でも急いで。…ちゃんと支えるから。',
77
+ '交戦中だよ。{body}。焦らないで、でも手は止めないで!',
78
+ ],
79
+ },
80
+ crisis: {
81
+ tsun: ['緊急!{body}!早く!', '被弾!{body}!何やってんの、急いで!', '危機的状況!{body}!もたもたしない!'],
82
+ normal: ['緊急事態!{body}!対応急げ!', '警報!{body}!即応せよ!', '危機!{body}!直ちに対処!'],
83
+ dere: ['緊急だよ!{body}!お願い、急いで!', '危ない、{body}!すぐ動こう、今すぐ!', '大変、{body}!一緒に、早く!'],
84
+ },
85
+ },
86
+ en: {
87
+ peace: {
88
+ tsun: ['Command, all stations. {body}. …Not that I care, but stay sharp.', 'Status nominal. {body}. Don’t slack off.'],
89
+ normal: ['Command. {body}. Lines quiet, holding posture.', 'Comms. {body}. All hands, maintain station.'],
90
+ dere: ['Command to all. {body}. Calm out there — nice. Take a breather.', 'Report received. {body}. Steady. Don’t overdo it.'],
91
+ },
92
+ combat: {
93
+ tsun: ['General quarters! {body}. Stop dawdling, to your posts!', 'Battle stations. {body}. Don’t mess this up — move!'],
94
+ normal: ['General quarters. {body}. All hands, respond.', 'Engaged. {body}. By the numbers, quickly.'],
95
+ dere: ['Battle stations! {body}. We’ve got this — hurry!', 'Engaged. {body}. Easy, but keep moving. I’ve got you.'],
96
+ },
97
+ crisis: {
98
+ tsun: ['Emergency! {body}! Now!', 'We’re hit! {body}! Move it!'],
99
+ normal: ['Emergency! {body}! Respond now!', 'Alert! {body}! Immediate action!'],
100
+ dere: ['Emergency! {body}! Please, hurry!', 'It’s bad — {body}! Move, now!'],
101
+ },
102
+ },
103
+ };
104
+
105
+ export const isLangSupported = (lang) => !!BANK[lang];
106
+
107
+ // Wrap `body` as a war read-out. `affectionEff` is the tsundere effective level
108
+ // (0 デレ – 1 ツン); when tsundere is off, pass 0.5 for a neutral operator.
109
+ // `rot` rotates the phrase choice so repeats vary.
110
+ export const wrap = (body, level, affectionEff = 0.5, lang = 'ja', rot = 0) => {
111
+ const bank = BANK[lang];
112
+ if (!bank || !body) return body;
113
+ const tone = axisFor(affectionEff); // tsun | normal | dere
114
+ const cell = (bank[band(level)] || bank.peace);
115
+ const arr = cell[tone] || cell.normal || ['{body}'];
116
+ const phrase = arr[((rot % arr.length) + arr.length) % arr.length];
117
+ return phrase.replace('{body}', body);
118
+ };