azeroth-shell 0.1.2 → 0.3.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.
Files changed (80) hide show
  1. package/README.md +362 -85
  2. package/bin/azsh.mjs +35 -19
  3. package/package.json +12 -1
  4. package/src/autopilot/plan.ts +8 -11
  5. package/src/autopilot/view.ts +12 -11
  6. package/src/cli/combatLogFormat.ts +22 -0
  7. package/src/cli/gzLoader.mjs +47 -6
  8. package/src/cli/hdArt.mjs +81 -0
  9. package/src/cli/iconArt.mjs +145 -0
  10. package/src/cli/iconGrid.mjs +100 -0
  11. package/src/cli/modelArt.mjs +298 -0
  12. package/src/cli/oocPrep.mjs +32 -0
  13. package/src/cli/paperdoll.mjs +76 -0
  14. package/src/cli/perfCheck.mjs +56 -0
  15. package/src/cli/repl.mjs +48 -7
  16. package/src/cli/rotationText.mjs +6 -6
  17. package/src/cli/sixel.mjs +319 -0
  18. package/src/cli/term.mjs +106 -12
  19. package/src/cli/text.mjs +21 -3
  20. package/src/cli/tui.mjs +262 -102
  21. package/src/cli/tuiAutopilot.mjs +356 -0
  22. package/src/cli/tuiBags.mjs +37 -4
  23. package/src/cli/tuiBg.mjs +370 -41
  24. package/src/cli/tuiCheck.mjs +633 -37
  25. package/src/cli/tuiCity.mjs +146 -41
  26. package/src/cli/tuiCompanionKit.mjs +6 -6
  27. package/src/cli/tuiCompanions.mjs +211 -41
  28. package/src/cli/tuiCreate.mjs +230 -0
  29. package/src/cli/tuiDungeon.mjs +57 -21
  30. package/src/cli/tuiFight.mjs +467 -88
  31. package/src/cli/tuiFightPanes.mjs +42 -14
  32. package/src/cli/tuiFloor.mjs +21 -9
  33. package/src/cli/tuiGear.mjs +17 -14
  34. package/src/cli/tuiLoot.mjs +6 -6
  35. package/src/cli/tuiMap.mjs +214 -0
  36. package/src/cli/tuiPaperdoll.mjs +108 -0
  37. package/src/cli/tuiPartyStage.mjs +206 -0
  38. package/src/cli/tuiPet.mjs +16 -8
  39. package/src/cli/tuiProfessions.mjs +27 -25
  40. package/src/cli/tuiRotationCond.mjs +390 -0
  41. package/src/cli/tuiSaves.mjs +24 -1
  42. package/src/cli/tuiSearch.mjs +79 -27
  43. package/src/cli/tuiSettings.mjs +3 -4
  44. package/src/cli/tuiStage.mjs +360 -0
  45. package/src/cli/tuiTalentTree.mjs +124 -0
  46. package/src/cli/tuiTalents.mjs +3 -4
  47. package/src/cli/tuiViews.mjs +633 -172
  48. package/src/cli/tuiWiki.mjs +2 -3
  49. package/src/cli/tuiWorld.mjs +90 -7
  50. package/src/data/gear-art.bin.br +0 -0
  51. package/src/data/hd-art.json +114 -0
  52. package/src/data/icon-art.bin.gz +0 -0
  53. package/src/data/model-art-worn.bin.br +0 -0
  54. package/src/data/model-art.bin.gz +0 -0
  55. package/src/data/rotations/pve/paladin/retribution.json +2 -2
  56. package/src/data/tables/creatures.json.gz +0 -0
  57. package/src/data/world-map-sixel.bin.br +0 -0
  58. package/src/data/world-map.bin.gz +0 -0
  59. package/src/domain/bg/match.ts +85 -24
  60. package/src/domain/bg/strategies.ts +144 -29
  61. package/src/domain/bg/types.ts +2 -0
  62. package/src/domain/bg/wsg.ts +35 -20
  63. package/src/domain/character/character.ts +6 -0
  64. package/src/domain/pet/pet.ts +31 -0
  65. package/src/domain/quests/doQuest.ts +24 -6
  66. package/src/domain/rotation/build.ts +3031 -3031
  67. package/src/domain/rotation/classes/paladin.ts +16 -11
  68. package/src/domain/rotation/evaluate.ts +3 -0
  69. package/src/engine/combat/applySpell.ts +20 -9
  70. package/src/engine/combat/applySpellLate.ts +16 -12
  71. package/src/engine/combat/areaAura.ts +284 -283
  72. package/src/engine/combat/fighterFactory.ts +1 -1
  73. package/src/engine/combat/pickAction.ts +3494 -3449
  74. package/src/engine/combat/procsDamage.ts +31 -1
  75. package/src/engine/combat/sessionChase.ts +19 -2
  76. package/src/engine/combat/sessionMotionKit.ts +40 -2
  77. package/src/engine/game/loadReconcile.ts +21 -1
  78. package/src/engine/game/pets.ts +33 -1
  79. package/src/engine/game/types.ts +4 -0
  80. package/src/quests/quests.ts +60 -0
package/src/cli/tui.mjs CHANGED
@@ -11,10 +11,11 @@ import { cityAccess, bankDialog, auctionDialog, mailDialog } from './tuiCity.mjs
11
11
  import { startFight, startAutopilot } from './tuiFight.mjs';
12
12
  import { openRun } from './tuiDungeon.mjs';
13
13
  import { openLoot } from './tuiLoot.mjs';
14
- import { fightPaneRule, fightPaneLines } from './tuiFightPanes.mjs';
14
+ import { fightPaneRule, fightPaneLines, cycleFightPane, nextPaneId, fightPane } from './tuiFightPanes.mjs';
15
15
  import { MemoryPrefs, FilePrefs } from './prefs.mjs';
16
16
  import { settingsDialog, globalSettingsOpts, refreshMs } from './tuiSettings.mjs';
17
17
  import { autopilotSettingsDialog } from './tuiAutopilotSettings.mjs';
18
+ import { runStripLine, runHeaderLine, bagsPaneKey } from './tuiAutopilot.mjs';
18
19
  import { macrosDialog, runMacro } from './tuiMacros.mjs';
19
20
  import { outfitterDialog } from './tuiOutfitter.mjs';
20
21
  import { journeyHeader, journeyTick } from './tuiWorld.mjs';
@@ -23,11 +24,16 @@ import { honorDialog, weeklyHonorPass } from './tuiHonor.mjs';
23
24
  import { devCommand } from './tuiDev.mjs';
24
25
  import { savePicker } from './tuiSaves.mjs';
25
26
  import { wikiDialog } from './tuiWiki.mjs';
26
- import { bold, dim, cyan, yellow, green, red, inverse, fit, vis, cut, bar, pct, wrapLines, brightYellow, magenta, brightBlue, strip, qualityPaint } from './text.mjs';
27
- import { createGame, newGameState, newGameStateFromWorldBot, pickRandomWorldBot } from '../engine/game.ts';
27
+ import { bold, dim, cyan, yellow, green, red, inverse, fit, vis, cut, bar, pct, brightYellow, magenta, brightBlue, strip, qualityPaint, coinText } from './text.mjs';
28
+ import { iconsOn, iconArt, iconBlank, wrapIconLines } from './iconArt.mjs';
29
+ import { createGame } from '../engine/game.ts';
28
30
  import { toSave } from '../persistence/saveShape.ts';
29
31
  import { className, raceName } from '../domain/core/ids.ts';
30
- import { moneyText, coinsText } from '../domain/economy/money.ts';
32
+ import { characterCreation } from './tuiCreate.mjs';
33
+ import { missingHdBytes, downloadHdArt, downloadDir } from './hdArt.mjs';
34
+ import { forgetHdArt } from './modelArt.mjs';
35
+ import { sixelOn } from './sixel.mjs';
36
+ import { coinsText } from '../domain/economy/money.ts';
31
37
  import { maxHealth, maxPower } from '../domain/character/character.ts';
32
38
  import { powerType } from '../domain/character/characterStats.ts';
33
39
  import { addItem } from '../domain/items/inventory.ts';
@@ -36,6 +42,8 @@ const POWER_PAINT = { mana: brightBlue, rage: red, energy: yellow, focus: yellow
36
42
  /** Digit key → tab index: 1-9 are the first nine, 0 the tenth. */
37
43
  const digitTab = (k) => (k === '0' ? 9 : Number(k) - 1);
38
44
  const MOVE_KEYS = new Set(['up', 'down', 'j', 'k', 'pageup', 'pagedown', 'home', 'end']);
45
+ /** A view's hint and its labelled keys, as "k: what it does" items. */
46
+ const viewKeyHints = (view) => [...(view?.hint ? [view.hint] : []), ...Object.entries(view?.keys ?? {}).filter(([, v]) => v.hint).map(([k, v]) => `${k === 'space' ? 'Space' : k}: ${v.hint}`)];
39
47
  const HELP_LINES = [
40
48
  'Moving around',
41
49
  ' 1-9 0 / Tab / Left Right switch tab Up Down j k move the cursor',
@@ -43,17 +51,22 @@ const HELP_LINES = [
43
51
  ' PgUp PgDn page the list or text Home End first / last row',
44
52
  ' l open the full log : command line (same verbs as the line game)',
45
53
  ' / search the wiki: items, spells, quests, NPCs, zones, dungeons (:wiki <words>)',
46
- ' s (World tab) find a thing in the world: who sells an item and where, what drops it, where an',
47
- ' ore or herb grows, a dungeon entrance, an NPC. Enter travels there or opens it.',
54
+ ' s (World tab) find a thing in this zone: a vendor, a trainer, who sells an item, what drops it.',
55
+ ' a in the search (or S on World) widens it to the whole world. Enter goes or opens.',
48
56
  ' z sit and rest 30 s ? this help',
49
57
  ' Ctrl-C / :quit save and leave Ctrl-L repaint a garbled screen',
58
+ ' c (World tab) / :logout save and go back to the character list',
50
59
  '',
51
60
  'In the world',
52
61
  ' Enter on a creature starts a live fight; f fights it instantly. Enter on a quest, vendor, trainer or',
53
- ' exit opens it. a hands the character to the autopilot: it quests and hunts on its own, and every',
54
- ' fight plays out live on screen. Esc stops it. A quest it dies on 2 times or cannot finish is skipped',
55
- ' for the rest of the session. Quests tab: d (or Do the quest in the quest window) runs the autopilot on',
56
- ' that one quest. :autopilot (or A on the Rotation tab) sets how it rests, pulls, fights and quests.',
62
+ ' exit opens it. a shows what the autopilot will do; Enter hands the character over: it quests and hunts',
63
+ ' on its own and its panel shows what it is doing, what comes next and what will stop it. Its fights play',
64
+ ' live inside that panel (w takes the keys: 1-0 press the action bar; w or Esc hands them back). Esc stops the run, space',
65
+ ' pauses it, 1-9 browse the tabs under a run strip, Tab cycles the bottom pane (the Run pane lists every',
66
+ ' step). At 4x and up the log folds to one line a fight. For a CRT look, turn on your terminal\'s retro',
67
+ ' effect (Windows Terminal: "experimental.retroTerminalEffect": true on the profile).',
68
+ ' A quest it dies on 2 times or cannot finish is skipped for the rest of the session. Quests tab: d (or Do',
69
+ ' the quest in the quest window) runs it on that one quest. :autopilot (or A) sets how it rests, pulls, fights and quests.',
57
70
  ' In a capital the World tab lists the Bank, the Auction House and the Mailbox under Services.',
58
71
  ' Rogues: P on a creature picks its pocket (Stealth first). A resist starts the fight.',
59
72
  ' :honor: your PvP rank, the arena (1v1 to 5v5), Warsong Gulch and Arathi Basin (pick your job, watch it play,',
@@ -63,23 +76,25 @@ const HELP_LINES = [
63
76
  ' W walks the floor instead: pick a place, the party walks the nav mesh, patrols walk, and what sees you pulls.',
64
77
  '',
65
78
  'Gear and bags',
66
- ' Gear tab: Enter on a slot lists everything in your bags (and the bank, in a bank town) that fits it.',
79
+ ' Character tab: your gear around you. Enter on a slot lists everything in your bags (and the bank, in a bank town) that fits it.',
80
+ ' Your stats sit under the tooltip; s opens the rest: weapon skills, professions, auras, home, pet, honor, reputation.',
67
81
  ' ▲ marks an upgrade the equipment helper found; u puts on every upgrade at once, x takes a piece off.',
68
- ' r repairs one piece; o opens the Outfitter (gear sets). :outfit <name> wears a set from anywhere.',
82
+ ' r repairs one piece, R everything; o opens the Outfitter (gear sets). :outfit <name> wears a set from anywhere.',
83
+ ' In a big enough window the Character tab is a paperdoll (the Talents tab the three trees): Up and Down walk the icons in order, Shift+arrows move across them.',
69
84
  ' Bags tab: Enter uses, equips, opens, unwraps or learns; m lists every verb for the stack. s sells to the open',
70
85
  ' vendor, j sells junk, b opens the bank, d destroys, S sorts, / splits a stack, [ ] move it, A uses the whole stack.',
71
86
  ' D lists every green, blue or epic the group can disenchant; L lists every book someone can learn. G runs it.',
72
87
  ' :dev item|quest|recipe|prof skips the rules (testing). New character offers a random world bot.',
73
- ' :settings sets auto-loot, the upgrade helper, auto-disenchant, the junk rules and which nodes get gathered.',
88
+ ' Settings tab (or :settings) sets auto-loot, the upgrade helper, auto-disenchant, the junk rules and which nodes get gathered.',
74
89
  ' Spells tab: b edits your action bars, m your macros. :macro <name> runs one; a macro on bar 1 fires by its key in a fight.',
75
90
  ' Loot: with :autoloot off a corpse opens the loot window. Enter takes a row, a takes all, Esc leaves the rest.',
76
91
  '',
77
92
  'In a fight',
78
93
  ' 1-9 press an ability a toggle auto-play space pause + - speed l log Esc run away',
79
94
  ' t gives a companion an order: attack a target, cast one ability, or hold ground (the fight waits).',
80
- ' c cancels one of your buffs (also from the Character tab, out of a fight).',
95
+ ' c cancels one of your buffs (also from s on the Character tab, out of a fight).',
81
96
  ' r (in a fight you started with Enter) fights the same creature again after each win, until r again or Esc.',
82
- ' The numbered ability bar shows in manual play (a); auto-play keeps the screen to the unit frames.',
97
+ ' The ability bar runs along the bottom of the stage with its cooldowns; a takes the keys off the rotation.',
83
98
  '',
84
99
  'Professions and Companions',
85
100
  ' Professions: Enter on a profession opens its recipe book. Enter makes one, A makes all you can, N asks',
@@ -112,6 +127,43 @@ export class App {
112
127
  this.lastActivity = null;
113
128
  term.onKey = (k) => { try { this.onKey(k); } catch (err) { this.say(red(`Error: ${err.message}`)); if (process.env.AZSH_DEBUG) this.say(dim(String(err.stack).split('\n').slice(1, 4).join(' '))); this.render(); } };
114
129
  term.onResize = () => this.render();
130
+ term.onSixel = () => setImmediate(() => this.offerHdArt());
131
+ }
132
+
133
+ // ---- full-size art -----------------------------------------------------------------------------
134
+
135
+ /** The terminal draws sixel and the full-size art is not here: offer it once a run, unless told never. */
136
+ offerHdArt() {
137
+ const bytes = missingHdBytes();
138
+ if (!bytes || this.closed || this.hdOffered || this.prefs.get('hdArt') === 'never') return;
139
+ this.hdOffered = true;
140
+ const mb = Math.round(bytes / 1e6);
141
+ this.pick(`Your terminal draws real pixels. Download the full-size art (${mb} MB)?`, [
142
+ { label: `Download now ${dim(`${mb} MB into ${downloadDir()}`)}`, value: 'yes' },
143
+ { label: `Not now ${dim('asked again next time; :art download fetches it')}`, value: 'later' },
144
+ { label: `Never ask ${dim(':art download still fetches it')}`, value: 'never' },
145
+ ], (v) => { if (v === 'yes') this.fetchHdArt(); else if (v === 'never') this.prefs.set('hdArt', 'never'); });
146
+ }
147
+
148
+ /** Fetch the full-size art in the background, progress on the hint row; the screen repaints in it when done. */
149
+ fetchHdArt() {
150
+ if (this.hdFetching) return;
151
+ this.hdFetching = true;
152
+ let shown = 0;
153
+ this.flashMsg('Downloading the full-size art...');
154
+ this.render();
155
+ void downloadHdArt((done, total) => {
156
+ if (Date.now() - shown < 250) return;
157
+ shown = Date.now();
158
+ this.flashMsg(`Downloading the full-size art: ${Math.round(done / 1e6)} of ${Math.round(total / 1e6)} MB`);
159
+ this.render();
160
+ }).then((res) => {
161
+ this.hdFetching = false;
162
+ if (this.closed) return;
163
+ if (res.ok) { forgetHdArt(); this.flash = null; this.say(green('Full-size art downloaded: creatures, characters and icons draw in real pixels.')); this.term.refresh?.(); }
164
+ else this.say(red(`The art download stopped: ${res.reason}. :art download tries again.`));
165
+ this.render();
166
+ });
115
167
  }
116
168
 
117
169
  // ---- game lifecycle ---------------------------------------------------------------------------
@@ -124,7 +176,9 @@ export class App {
124
176
  this.slot = slot; this.createdAt = createdAt ?? Date.now();
125
177
  const awayMs = updatedAt > 0 ? Math.max(0, Date.now() - updatedAt) : 0;
126
178
  // Travel pace is a setting (instant by default, like phonecraft); `skipping` lets Arrive now land a trip at any pace.
127
- const g = createGame(this.ds, state, { travelPace: () => (this.skipping ? 'instant' : this.prefs.get('travelPace') ?? 'instant'), offlineTimeMs: awayMs, ...globalSettingsOpts(this.prefs) });
179
+ // wallWorldClock: aura and cooldown stamps on Date.now(), the clock every screen reads. Without it they ride the
180
+ // fight chain, which runs hours ahead once fights resolve faster than real time (Forbearance showing 8h).
181
+ const g = createGame(this.ds, state, { travelPace: () => (this.skipping ? 'instant' : this.prefs.get('travelPace') ?? 'instant'), offlineTimeMs: awayMs, wallWorldClock: true, ...globalSettingsOpts(this.prefs) });
128
182
  this.g = g;
129
183
  if (g.state.char.journey) { if (this.prefs.get('travelPace') === 'instant') this.arriveNow(); else g.journeyTick(); }
130
184
  // Real createInfo has no starting weapon (ETL gap KI-003); phonecraft's harness hands one over too.
@@ -187,6 +241,22 @@ export class App {
187
241
  this.onQuit?.();
188
242
  }
189
243
 
244
+ /** Save, let go of the character and go back to the character picker. Not in the middle of a fight or a run. */
245
+ async logout() {
246
+ if (!this.g) return;
247
+ if (this.fight || this.auto) { this.flashMsg('Finish the fight or stop the run first.'); this.render(); return; }
248
+ for (const t of this.timers) this.term.clearInterval(t);
249
+ this.timers = [];
250
+ await this.persist();
251
+ if (this.slot != null) this.repo.unlock?.(this.slot);
252
+ this.say(dim(`Saved ${this.g.state.char.name}.`));
253
+ this.g = null; this.slot = null; this.cmds = null; this.vendor = null;
254
+ this.tabs = []; this.tab = 0; this.stack = [];
255
+ this.meter = null; this.lootTally = null; this.lastActivity = null;
256
+ await this.toPicker?.();
257
+ this.render();
258
+ }
259
+
190
260
  // ---- small helpers the views lean on -------------------------------------------------------------
191
261
 
192
262
  get ch() { return this.g.state.char; }
@@ -239,7 +309,7 @@ export class App {
239
309
  pick(title, options, onPick, { hint, keepOpen } = {}) {
240
310
  const view = {
241
311
  id: 'pick', title, hint: hint ?? 'Enter: choose Esc: back',
242
- rows: () => options.map((o) => ({ text: o.label, data: o.value, detail: o.detail ? () => (typeof o.detail === 'function' ? o.detail() : o.detail) : undefined, act: () => { if (!keepOpen) this.pop(); onPick(o.value, o); } })),
312
+ rows: () => options.map((o) => ({ text: o.label, icon: o.icon, sub: o.sub, data: o.value, detail: o.detail ? () => (typeof o.detail === 'function' ? o.detail() : o.detail) : undefined, act: () => { if (!keepOpen) this.pop(); onPick(o.value, o); } })),
243
313
  noDetail: !options.some((o) => o.detail),
244
314
  };
245
315
  this.push(view);
@@ -268,25 +338,49 @@ export class App {
268
338
  if (!this.stack.length) { this.fight?.onOrdersClosed?.(); this.render(); }
269
339
  return;
270
340
  }
271
- if (this.fight) { this.fight.key(k); return; }
341
+ if (this.fight && !this.inlineFight()) {
342
+ // The full fight view. Under the autopilot Esc goes back to the run panel; the fight plays on.
343
+ if ((k === 'escape' || k === 'w') && this.auto && this.fight.label === 'autopilot') { this.auto.watchFight = false; this.render(); return; }
344
+ this.fight.key(k); return;
345
+ }
272
346
  if (this.auto) {
273
- // The run's own panel shows between fights; a tab key browses the tabs instead (only acting is locked
274
- // out while it plays) and w goes back to watching. The next fight always comes back on screen.
275
- const auto = this.auto;
276
- if (k === 'escape' || k === 'q') { auto.stop(); this.say(yellow('Autopilot stopped.')); this.render(); return; }
347
+ // The run's panel holds the screen, fights included. A tab key browses the tabs, and a browsed tab works as
348
+ // it does without a run (Enter acts, its own keys act): the run plays on underneath, under the run strip.
349
+ // Esc or w on a tab comes back to the run; Esc on the panel stops it; w on a fight opens the full view.
350
+ const auto = this.auto, f = this.fight;
351
+ // A window on top (a dialog a tab or the Bags pane opened) takes the keys until it closes.
352
+ if (this.stack.length && this.top()?.id !== 'log') { if (k === 'escape') this.pop(); else this.viewKey(k); return; }
353
+ if (auto.browsing) {
354
+ if (k === 'escape' || k === 'w') { auto.browsing = false; this.stack = []; this.render(); return; }
355
+ if (k === 'l' && !this.top()?.keys?.l) { this.openLog(); return; }
356
+ if (/^[0-9]$/.test(k)) { this.switchTab(digitTab(k)); return; }
357
+ if (k === 'tab' || k === 'right') { this.switchTab((this.tab + 1) % this.tabs.length); return; }
358
+ if (k === 'shift-tab' || k === 'left') { this.switchTab((this.tab + this.tabs.length - 1) % this.tabs.length); return; }
359
+ if (k === '?') { this.push(this.helpView()); return; }
360
+ this.viewKey(k);
361
+ return;
362
+ }
363
+ // The Bags pane at the bottom has its own cursor; Enter there uses the item under it.
364
+ if (fightPane(this) === 'bags' && (MOVE_KEYS.has(k) || k === 'return') && bagsPaneKey(this, k)) return;
365
+ // Over a win's hold there is nothing to run from: the hold lets go and the run stops.
366
+ if (k === 'escape' || k === 'q') { if (f && !f.holding) f.flee(); else { f?.release(); if (this.auto === auto) auto.end('you stopped it'); } return; }
367
+ if (k === 'space') { auto.paused = !auto.paused; if (f) f.paused = auto.paused; this.render(); return; }
277
368
  if (k === 'l') { this.openLog(); return; }
278
- if (k === 'w') { auto.browsing = false; this.stack = []; this.render(); return; }
369
+ if ((k === 'w' || k === 'return') && f) { auto.watchFight = true; this.render(); return; }
370
+ if (k === 'a' && f) { auto.watchFight = true; f.key('a'); return; } // play it yourself: needs the ability bar
371
+ if ((k === '+' || k === '=' || k === '-') && f) { f.key(k); return; } // the fight owns the clock while it runs
279
372
  if (k === '+' || k === '=') { this.fightSpeed = Math.min(8, this.fightSpeed * 2); this.render(); return; }
280
373
  if (k === '-') { this.fightSpeed = Math.max(0.5, this.fightSpeed / 2); this.render(); return; }
281
374
  if (/^[0-9]$/.test(k)) { auto.browsing = true; this.switchTab(digitTab(k)); return; }
282
- if (k === 'tab' || k === 'right') { auto.browsing = true; this.switchTab((this.tab + 1) % this.tabs.length); return; }
283
- if (k === 'shift-tab' || k === 'left') { auto.browsing = true; this.switchTab((this.tab + this.tabs.length - 1) % this.tabs.length); return; }
284
- if (MOVE_KEYS.has(k) && auto.browsing) { this.stack = []; this.viewKey(k); return; }
375
+ // Watching the run, Tab cycles the bottom pane; the arrows step onto the tabs.
376
+ if (k === 'tab' || k === 'shift-tab') { cycleFightPane(this, k === 'tab' ? +1 : -1); return; }
377
+ if (k === 'right') { auto.browsing = true; this.switchTab((this.tab + 1) % this.tabs.length); return; }
378
+ if (k === 'left') { auto.browsing = true; this.switchTab((this.tab + this.tabs.length - 1) % this.tabs.length); return; }
285
379
  return;
286
380
  }
287
- if (!this.g) return this.viewKey(k);
381
+ if (!this.g) { if (!this.viewKey(k) && k === 'escape' && this.stack.length > 1) this.pop(); return; }
288
382
  if (k === ':') { this.prompt(':', (line) => this.runCommand(line)); return; }
289
- if (k === '?') { this.push({ id: 'help', title: 'Keys', hint: 'Esc: back', noDetail: true, rows: () => HELP_LINES.map((t) => ({ text: t, header: true })) }); return; }
383
+ if (k === '?') { this.push(this.helpView()); return; }
290
384
  if (k === 'l' && this.top()?.id !== 'log' && !this.top()?.keys?.l) { this.openLog(); return; }
291
385
  if (this.isDead() && this.top()?.id !== 'death') { this.push(deathView(this)); return; }
292
386
  if (k === 'z' && !this.stack.length) { this.idleTick(30, true); this.say(dim(`You sit and rest for 30 s. HP ${this.hpText()} ${this.pwText()}`)); this.afterAction(); return; }
@@ -322,11 +416,20 @@ export class App {
322
416
  if (row?.keys?.[k]) { row.keys[k](this, row); this.afterAction(); return true; }
323
417
  if (view.keys?.[k]) { view.keys[k].fn(this, row); this.afterAction(); return true; }
324
418
  if (view.onKey?.(this, k, row)) { this.afterAction(); return true; }
419
+ // A view drawn as an icon grid (the paperdoll, the talent trees): Up and Down still walk its rows in order
420
+ // and Left and Right still change tabs; Shift+arrows move on the picture in two dimensions.
421
+ const gridDir = { 'shift-up': 'up', 'shift-down': 'down', 'shift-left': 'left', 'shift-right': 'right' }[k];
422
+ if (view.gridNav && gridDir) {
423
+ const to = view.gridNav(gridDir, view.cursor);
424
+ if (to != null) view.cursor = to;
425
+ this.render();
426
+ return true;
427
+ }
325
428
  switch (k) {
326
429
  case 'up': case 'k': this.moveCursor(view, rows, -1); return true;
327
430
  case 'down': case 'j': this.moveCursor(view, rows, +1); return true;
328
431
  case 'home': view.cursor = Math.max(0, rows.findIndex((r) => !r.header)); view.scroll = 0; this.render(); return true;
329
- case 'end': { let i = rows.length - 1; while (i > 0 && rows[i].header) i--; view.cursor = i; this.ensureVisible(view, rows); view.scroll = Math.max(0, rows.length - this.layout().mainH); this.render(); return true; }
432
+ case 'end': { let i = rows.length - 1; while (i > 0 && rows[i].header) i--; view.cursor = i; this.ensureVisible(view, rows); const L = this.layout(); view.scroll = this.maxScrollOf(rows, L.mainH, this.listWOf(view, L)); this.render(); return true; }
330
433
  case 'pageup': case 'pagedown': this.pageView(view, rows, k === 'pageup' ? -1 : +1); return true;
331
434
  case 'return': case 'space': if (row && !row.header && row.act) { row.act(this, row); this.afterAction(); } return true;
332
435
  default: return false;
@@ -339,30 +442,48 @@ export class App {
339
442
  if (i >= 0 && i < rows.length) { view.cursor = i; this.ensureVisible(view, rows); }
340
443
  else {
341
444
  // No selectable row that way: scroll the pane instead so long text can be read.
342
- const maxScroll = Math.max(0, rows.length - this.layout().mainH);
445
+ const L = this.layout();
446
+ const maxScroll = this.maxScrollOf(rows, L.mainH, this.listWOf(view, L));
343
447
  view.scroll = Math.max(0, Math.min(maxScroll, view.scroll + delta));
344
448
  }
345
449
  this.render();
346
450
  }
347
451
  /** Scroll the pane by a page; the cursor follows onto a selectable row inside the new window when there is one. */
348
452
  pageView(view, rows, dir) {
349
- const h = this.layout().mainH;
350
- const maxScroll = Math.max(0, rows.length - h);
453
+ const L = this.layout(), h = L.mainH, listW = this.listWOf(view, L);
454
+ const maxScroll = this.maxScrollOf(rows, h, listW);
351
455
  view.scroll = Math.max(0, Math.min(maxScroll, (view.scroll ?? 0) + dir * Math.max(1, h - 1)));
352
- const inWindow = (i) => i >= view.scroll && i < view.scroll + h;
456
+ const end = this.windowEnd(rows, view.scroll, h, listW);
457
+ const inWindow = (i) => i >= view.scroll && i < end;
353
458
  if (!inWindow(view.cursor)) {
354
- const from = dir > 0 ? view.scroll : view.scroll + h - 1;
459
+ const from = dir > 0 ? view.scroll : end - 1;
355
460
  let i = from;
356
- while (i >= view.scroll && i < view.scroll + h && rows[i]?.header) i += dir;
461
+ while (i >= view.scroll && i < end && rows[i]?.header) i += dir;
357
462
  if (inWindow(i) && rows[i] && !rows[i].header) view.cursor = i;
358
463
  }
359
464
  this.render();
360
465
  }
361
466
  ensureVisible(view, rows) {
362
- const h = this.layout().mainH;
467
+ const L = this.layout(), h = L.mainH, listW = this.listWOf(view, L);
363
468
  if (view.cursor < view.scroll) view.scroll = view.cursor;
364
- if (view.cursor >= view.scroll + h) view.scroll = view.cursor - h + 1;
365
- view.scroll = Math.max(0, Math.min(view.scroll, Math.max(0, rows.length - h)));
469
+ while (view.scroll < view.cursor && this.windowEnd(rows, view.scroll, h, listW) <= view.cursor) view.scroll++;
470
+ view.scroll = Math.max(0, Math.min(view.scroll, this.maxScrollOf(rows, h, listW)));
471
+ }
472
+ /** The list's width in a layout: the whole pane when the view has no detail column. */
473
+ listWOf(view, L) { return L.detailW > 0 && !view.noDetail ? L.listW : L.W; }
474
+ /** Lines a row takes: two for a row with an icon when icons draw and the list has room for them. */
475
+ rowH(r, listW) { return r.icon && listW >= 40 && iconsOn() ? 2 : 1; }
476
+ /** The first row a window of `h` lines from `scroll` does not show (rows fit whole). */
477
+ windowEnd(rows, scroll, h, listW) {
478
+ let used = 0, i = scroll;
479
+ while (i < rows.length && used + this.rowH(rows[i], listW) <= h) { used += this.rowH(rows[i], listW); i++; }
480
+ return Math.max(i, scroll + 1);
481
+ }
482
+ /** The largest scroll that still fills the window from the end. */
483
+ maxScrollOf(rows, h, listW) {
484
+ let used = 0, s = rows.length;
485
+ while (s > 0 && used + this.rowH(rows[s - 1], listW) <= h) { used += this.rowH(rows[s - 1], listW); s--; }
486
+ return s;
366
487
  }
367
488
  rowsOf(view) {
368
489
  const rows = view.rows(this) ?? [];
@@ -399,7 +520,16 @@ export class App {
399
520
  const [, cmd = '', args = ''] = line.match(/^(\S+)\s*(.*)$/) ?? [];
400
521
  const c = cmd.toLowerCase();
401
522
  if (c === 'quit' || c === 'exit' || c === 'q') { await this.quit(); return; }
402
- if (c === 'auto') { const m = args.match(/^(\d+)?\s*(?:farm\s+(.+))?$/i); startAutopilot(this, Number(m?.[1] ?? 200), m?.[2]); return; }
523
+ if (c === 'logout' || c === 'characters') { await this.logout(); return; }
524
+ if (c === 'auto') { const m = args.match(/^(\d+)?\s*(?:farm\s+(.+))?$/i); startAutopilot(this, m?.[1] ? Number(m[1]) : Infinity, m?.[2]); return; }
525
+ if (c === 'art') {
526
+ const missing = missingHdBytes();
527
+ if (args.trim().toLowerCase() === 'download') { if (missing) this.fetchHdArt(); else this.say('The full-size art is all here.'); this.render(); return; }
528
+ const how = sixelOn() ? green('this terminal draws real pixels') : yellow('this terminal draws half-block art (no sixel)');
529
+ this.say(`Art: ${how}; ${missing ? `${Math.round(missing / 1e6)} MB of full-size art not downloaded (:art download)` : `full-size art in ${downloadDir()}`}.`);
530
+ this.render();
531
+ return;
532
+ }
403
533
  if (c === 'autoloot') {
404
534
  const want = args.trim().toLowerCase();
405
535
  if (want === 'on' || want === 'off') this.prefs.set('autoLoot', want === 'on');
@@ -451,10 +581,11 @@ export class App {
451
581
 
452
582
  layout() {
453
583
  const W = this.term.cols, H = this.term.rows;
454
- const logH = Math.max(5, Math.min(12, Math.floor(H * 0.3)));
455
- const top = 4; // title, bars, tabs, rule
584
+ const logH = this.g ? Math.max(5, Math.min(12, Math.floor(H * 0.3))) : 3;
585
+ const top = this.compactHeader() ? 2 : 4 + (this.showsRunStrip() ? 1 : 0); // title, bars, tabs, (the run strip), rule; a run keeps only XP
456
586
  const mainH = H - top - 1 - logH - 1;
457
- const detailW = W >= 90 ? Math.floor(W * 0.4) : 0;
587
+ // A view can ask for a narrower detail pane (the talent trees want three side by side).
588
+ const detailW = W >= 90 ? Math.floor(W * (this.top()?.detailShare ?? 0.4)) : 0;
458
589
  const listW = detailW ? W - detailW - 1 : W;
459
590
  return { W, H, logH, top, mainH, listW, detailW };
460
591
  }
@@ -474,7 +605,7 @@ export class App {
474
605
  const L = this.layout();
475
606
  const lines = [];
476
607
  lines.push(...this.headerLines(L));
477
- if (this.fight && !this.stack.length) lines.push(...this.fight.render(L.W, L.mainH));
608
+ if (this.fight && !this.stack.length && !this.inlineFight()) lines.push(...this.fight.render(L.W, L.mainH));
478
609
  else if (this.auto && !this.auto.browsing && !this.stack.length) lines.push(...this.auto.render(L.W, L.mainH));
479
610
  else lines.push(...this.mainLines(L));
480
611
  while (lines.length < L.top + L.mainH) lines.push('');
@@ -488,35 +619,55 @@ export class App {
488
619
 
489
620
  headerLines(L) {
490
621
  if (!this.g) {
491
- return [bold(' azsh') + dim(' Azeroth shell. WoW Classic 1.12 via CMaNGOS data.'), '', '', dim('─'.repeat(L.W))];
622
+ const crumbs = this.stack.map((v) => bold(v.title ?? '')).join(dim(' › '));
623
+ return [bold(' azsh') + dim(' Azeroth shell. WoW Classic 1.12 via CMaNGOS data.'), crumbs ? cut(` ${crumbs}`, L.W) : '', '', dim('─'.repeat(L.W))];
492
624
  }
625
+ // Watching a run, the name, bars and tabs go: the panel has your frame, and the XP line is what a run is for.
626
+ if (this.compactHeader()) return [runHeaderLine(this, L.W), dim('─'.repeat(L.W))];
493
627
  const ch = this.ch, ds = this.ds;
494
628
  const z = this.zone();
495
629
  const dead = this.isDead() ? red(bold(' DEAD')) : ch.mountedItem != null ? dim(' mounted') : '';
496
630
  const title = ` ${bold(ch.name)} ${dim(`L${ch.level} ${raceName(ch.race)} ${className(ch.cls)}`)}${dead}`;
497
631
  const road = journeyHeader(this);
498
- const right = `${road ? yellow(road) : cyan(z?.name ?? ch.zoneId)} ${yellow(moneyText(ch.gold) || '0c')} `;
632
+ const right = `${road ? yellow(road) : cyan(z?.name ?? ch.zoneId)} ${coinText(ch.gold)} `;
499
633
  const l0 = fit(title, L.W - vis(right)) + right;
500
- const hpMax = maxHealth(ch, ds), pwMax = maxPower(ch, ds), pt = powerType(ch);
634
+ // In a fight the bars follow the live session, so the header is the player's frame and no panel needs another.
635
+ const live = this.fight?.session?.playerView?.() ?? null;
636
+ const hp = live ? live.hp : ch.health, hpMax = live ? live.maxHp : maxHealth(ch, ds);
637
+ const pw = live ? live.power : ch.power, pwMax = live ? live.maxPower : maxPower(ch, ds), pt = powerType(ch);
501
638
  const xpMax = ds.xpToNext(ch.level);
502
639
  const seg = Math.max(8, Math.floor((L.W - 50) / 3));
503
- const l1 = ` ${green('HP')} ${bar(ch.health, hpMax, seg, green)} ${fit(`${ch.health}/${hpMax}`, 9)}`
504
- + ` ${(POWER_PAINT[pt] ?? cyan)(pt === 'mana' ? 'MP' : pt === 'rage' ? 'RG' : 'EN')} ${bar(ch.power, pwMax, seg, POWER_PAINT[pt] ?? cyan)} ${fit(`${ch.power}/${pwMax}`, 9)}`
640
+ const l1 = ` ${green('HP')} ${bar(hp, hpMax, seg, hp / Math.max(1, hpMax) < 0.25 ? red : green)} ${fit(`${hp}/${hpMax}`, 9)}`
641
+ + ` ${(POWER_PAINT[pt] ?? cyan)(pt === 'mana' ? 'MP' : pt === 'rage' ? 'RG' : 'EN')} ${bar(pw, pwMax, seg, POWER_PAINT[pt] ?? cyan)} ${fit(`${pw}/${pwMax}`, 9)}`
505
642
  + ` ${magenta('XP')} ${bar(ch.xp, xpMax, seg, magenta)} ${dim(`${pct(ch.xp, xpMax)}%${ch.restedXp ? ' rested' : ''}`)}`;
506
- // Tabs 1-9 and 0 have a digit. Short labels take over when the full ones do not fit, and inside a
507
- // dialog the bar shrinks to the current tab so the breadcrumb stays readable.
643
+ // Tabs 1-9 and 0 have a digit, and where the terminal draws sixel a one-line icon in front while there is
644
+ // room (as half-block cells an icon two wide is a smudge of colour, so those get none). Short labels take
645
+ // over when the full ones do not fit, then the icons go, and inside a dialog the bar shrinks to the
646
+ // current tab so the breadcrumb stays readable.
508
647
  const digit = (i) => (i < 9 ? String(i + 1) : i === 9 ? '0' : '');
509
- const tabBar = (short) => this.tabs.map((t, i) => {
648
+ const tabBar = (short, icons) => (icons && sixelOn() ? ' ' : '') + this.tabs.map((t, i) => {
510
649
  const label = short ? (t.short ?? t.label) : t.label, d = digit(i);
511
- return i === this.tab && !this.stack.length ? inverse(` ${d ? `${d} ` : ''}${label} `) : ` ${d ? `${dim(d)} ` : ''}${label} `;
650
+ const art = icons && sixelOn() ? iconArt(t.icon, 2)?.[0] : null;
651
+ const tab = i === this.tab && !this.stack.length ? inverse(` ${d ? `${d} ` : ''}${label} `) : ` ${d ? `${dim(d)} ` : ''}${label} `;
652
+ return art ? `${art}${tab}` : tab;
512
653
  }).join('');
513
654
  const crumbs = this.stack.map((v) => ` › ${bold(v.title)}`).join('');
514
- let tabs = tabBar(false);
515
- if (vis(tabs + crumbs) > L.W) tabs = tabBar(true);
655
+ let tabs = tabBar(false, true);
656
+ for (const [short, icons] of [[true, true], [false, false], [true, false]]) if (vis(tabs + crumbs) > L.W) tabs = tabBar(short, icons);
516
657
  if (vis(tabs + crumbs) > L.W && this.stack.length) { const d = digit(this.tab); tabs = ` ${d ? `${dim(d)} ` : ''}${this.tabs[this.tab]?.label ?? ''}`; }
517
- return [l0, l1, cut(tabs + crumbs, L.W), dim('─'.repeat(L.W))];
658
+ const strip = this.showsRunStrip() ? [runStripLine(this, L.W)] : [];
659
+ return [l0, l1, cut(tabs + crumbs, L.W), ...strip, dim('─'.repeat(L.W))];
518
660
  }
519
661
 
662
+ /** The run strip sits under the tabs whenever a run is alive and neither its panel nor its fight fills the middle. */
663
+ showsRunStrip() { return !!this.auto && (this.stack.length > 0 || this.auto.browsing) && !(this.fight && !this.stack.length && !this.inlineFight()); }
664
+
665
+ /** An autopilot fight draws inside the run panel unless w asked for the full fight view. */
666
+ inlineFight() { return !!this.fight && this.fight.label === 'autopilot' && !!this.auto && !this.auto.watchFight; }
667
+
668
+ /** Watching a run (its panel or its fight on screen): the header is only the XP line. */
669
+ compactHeader() { return !!this.auto && !this.auto.browsing && !this.stack.length; }
670
+
520
671
  mainLines(L) {
521
672
  const view = this.top();
522
673
  const out = [];
@@ -524,27 +675,45 @@ export class App {
524
675
  const rows = this.rowsOf(view);
525
676
  const showDetail = L.detailW > 0 && !view.noDetail;
526
677
  const listW = showDetail ? L.listW : L.W;
527
- const maxScroll = Math.max(0, rows.length - L.mainH);
678
+ const maxScroll = this.maxScrollOf(rows, L.mainH, listW);
528
679
  view.scroll = Math.max(0, Math.min(view.scroll ?? 0, maxScroll));
529
- const listLines = [];
530
- for (let i = view.scroll; i < Math.min(rows.length, view.scroll + L.mainH); i++) {
680
+ // A grid view draws its own list side when it fits (null when it does not, and the rows list as usual).
681
+ const grid = view.grid?.(this, rows, listW, L.mainH) ?? null;
682
+ view.gridNav = grid?.nav ?? null;
683
+ const listLines = grid ? grid.lines.slice(0, L.mainH) : [];
684
+ const end = grid ? rows.length : this.windowEnd(rows, view.scroll, L.mainH, listW);
685
+ for (let i = grid ? end : view.scroll; i < end; i++) {
531
686
  const r = rows[i];
532
687
  const sel = i === view.cursor && !r.header;
533
- let text = r.header ? (r.text ? cyan(bold(r.text)) : '') : ` ${r.text}`;
688
+ // A row with an icon is two lines: the icon's cells down the left, the text beside the top row and
689
+ // the row's `sub` (a kind, a cost) beside the bottom one. The icon keeps its own colours when selected.
690
+ if (this.rowH(r, listW) === 2) {
691
+ const art = iconArt(r.icon, 4) ?? iconBlank(4);
692
+ const w = listW - 6;
693
+ const t1 = fit(` ${r.text}`, w), t2 = fit(` ${r.sub ?? ''}`, w);
694
+ listLines.push(`${sel ? '▶' : ' '}${art[0]}${sel ? inverse(t1) : t1}`, ` ${art[1]}${sel ? inverse(t2) : t2}`);
695
+ continue;
696
+ }
697
+ // A header row is a cyan title; a plain one (a card's line of text) keeps its own paint.
698
+ let text = r.header ? (!r.text ? '' : r.plain ? r.text : cyan(bold(r.text))) : ` ${r.text}`;
534
699
  text = fit(sel ? `${'▶'}${text.slice(1)}` : text, listW - 1);
535
700
  listLines.push(sel ? inverse(text) : text);
536
701
  }
537
702
  // A cursor row scrolled out of view is echoed on the last line so Enter is never a mystery.
538
703
  const cursorRow = rows[view.cursor];
539
- if (cursorRow && !cursorRow.header && (view.cursor < view.scroll || view.cursor >= view.scroll + L.mainH) && listLines.length === L.mainH) {
704
+ if (!grid && cursorRow && !cursorRow.header && (view.cursor < view.scroll || view.cursor >= end) && listLines.length === L.mainH) {
540
705
  listLines[L.mainH - 1] = inverse(fit(` ${view.cursor < view.scroll ? '▲' : '▼'} ${cursorRow.text}`, listW - 1));
541
706
  }
542
707
  if (!rows.length && view.empty) listLines.push(dim(` ${view.empty}`));
543
708
  let detailLines = [];
544
709
  if (showDetail) {
545
710
  const d = cursorRow?.detail?.(this, cursorRow) ?? view.detail?.(this, cursorRow) ?? [];
546
- for (const line of d) detailLines.push(...(vis(line) > L.detailW - 2 ? wrapLines(line, L.detailW - 2) : [line]));
547
- if (detailLines.length > L.mainH) detailLines = detailLines.slice(0, L.mainH - 1).concat(dim('…'));
711
+ detailLines = wrapIconLines(d, L.detailW - 2);
712
+ // A view's foot (the Character tab's stats) sits at the bottom of the pane when the tooltip keeps room above it.
713
+ const foot = view.detailFoot ? wrapIconLines(view.detailFoot(this, L.detailW - 2), L.detailW - 2) : [];
714
+ const room = foot.length && L.mainH - foot.length >= 16 ? L.mainH - foot.length - 1 : L.mainH;
715
+ if (detailLines.length > room) detailLines = detailLines.slice(0, room - 1).concat(dim('…'));
716
+ if (room < L.mainH) detailLines = [...detailLines, ...Array(L.mainH - foot.length - detailLines.length).fill(''), ...foot];
548
717
  }
549
718
  for (let i = 0; i < L.mainH; i++) {
550
719
  const left = fit(listLines[i] ?? '', listW);
@@ -566,23 +735,43 @@ export class App {
566
735
  hintLine(L) {
567
736
  if (this.cmd) return ` ${bold(this.cmd.label)}${this.cmd.text}${inverse(' ')}`;
568
737
  if (this.flash && Date.now() < this.flash.until) return ` ${brightYellow(this.flash.text)}`;
569
- if (this.fight && !this.stack.length) return ` ${dim(this.fight.hint())}`;
570
- if (this.auto && !this.stack.length) return ` ${dim(`autopilot running: it quests, hunts and rests on its own. Esc: stop l: log + -: speed ${this.auto.browsing ? 'w: watch the run' : '1-9: look at a tab'}`)}`;
738
+ if (this.fight && !this.stack.length && !this.inlineFight()) return ` ${dim(this.fight.hint())}`;
739
+ if (this.auto && !this.stack.length && !this.auto.browsing) {
740
+ const f = this.inlineFight() ? this.fight : null;
741
+ const pace = f ? f.speed : this.fightSpeed;
742
+ const bags = fightPane(this) === 'bags' && !this.auto.browsing ? 'Up Down: pick an item Enter: use it ' : '';
743
+ return ` ${dim(`${bags}Esc: stop space: ${(f ? f.paused : this.auto.paused) ? 'resume' : 'pause'} + -: pace ${pace}x l: log ${this.auto.browsing ? 'w: watch the run' : `${f ? 'w: take the keys (1-0: action bar) a: play it yourself ' : ''}Tab: ${nextPaneId(this)} 1-9: look at a tab`}`)}`;
744
+ }
745
+ // The view's own keys fill the line while they fit; the way out and ? always stay. What does not fit is on ?.
571
746
  const view = this.top();
572
- const hints = [];
573
- if (view?.hint) hints.push(view.hint);
574
- if (view?.keys) hints.push(...Object.entries(view.keys).filter(([, v]) => v.hint).map(([k, v]) => `${k === 'space' ? 'Space' : k}: ${v.hint}`));
575
- if (view?.id !== 'log') hints.push('l: log');
576
- hints.push(this.stack.length ? 'Esc: back' : '1-9: tabs', '?: help');
747
+ const head = viewKeyHints(view);
748
+ if (!this.g) return ` ${dim(cut([...new Set([...head, ...(this.stack.length > 1 ? ['Esc: back'] : [])])].join(' '), L.W - 2))}`;
749
+ const tail = [...(view?.id !== 'log' ? ['l: log'] : []), this.stack.length ? 'Esc: back' : this.auto ? 'Esc: back to the run 1-9: tabs' : '1-9: tabs'];
750
+ const room = L.W - 2 - vis([...tail, '?: more keys'].join(' ')) - 3;
751
+ const shown = [];
752
+ for (const h of head) { if (vis([...shown, h].join(' ')) > room) break; shown.push(h); }
753
+ const hints = [...shown, ...tail, shown.length < head.length ? '?: more keys' : '?: help'];
577
754
  return ` ${dim(cut(hints.join(' '), L.W - 2))}`;
578
755
  }
756
+
757
+ /** The key list: the open window's own keys first, then the global ones. */
758
+ helpView() {
759
+ const own = viewKeyHints(this.top());
760
+ const lines = own.length ? [bold(`This window (${strip(this.top().title ?? this.top().id)})`), ...own.map((h) => ` ${h}`), ''] : [];
761
+ return { id: 'help', title: 'Keys', hint: 'Esc: back', noDetail: true, rows: () => [...lines, ...HELP_LINES].map((t) => ({ text: t, header: true })) };
762
+ }
579
763
  }
580
764
 
581
765
  // ---- boot ----------------------------------------------------------------------------------------
582
766
 
583
767
  /** The save-pick screen, then the game. Resolves when the player quits. */
584
768
  export async function runTui({ term = new RealTerm(), argv = [] } = {}) {
585
- const ds = bootDataStore();
769
+ term.open();
770
+ // The tables take a second or two to load: say so, stage by stage, instead of leaving the screen blank.
771
+ const loading = (stage) => term.draw([bold(' azsh') + dim(' Azeroth shell. WoW Classic 1.12 via CMaNGOS data.'), '', dim(` Loading ${stage}…`)]);
772
+ loading('the game');
773
+ await new Promise((r) => setImmediate(r)); // the first frame leaves before the synchronous load holds the loop
774
+ const ds = bootDataStore(loading);
586
775
  const repo = new FileSaveRepository();
587
776
  const app = new App({ term, ds, repo, prefs: new FilePrefs() });
588
777
  // Engine diagnostics (console.warn from the combat layer) would print over the frame: while the screen is
@@ -591,7 +780,6 @@ export async function runTui({ term = new RealTerm(), argv = [] } = {}) {
591
780
  const quiet = (...a) => { if (process.env.AZSH_DEBUG) app.say(dim(a.map(String).join(' '))); };
592
781
  Object.assign(console, { log: quiet, warn: quiet, info: quiet, debug: quiet });
593
782
  app.restoreConsole = () => Object.assign(console, saved);
594
- term.open();
595
783
  app.render();
596
784
  const saves = await repo.list();
597
785
  const begin = async (save) => {
@@ -601,44 +789,16 @@ export async function runTui({ term = new RealTerm(), argv = [] } = {}) {
601
789
  app.render();
602
790
  };
603
791
  const reopenPicker = () => savePicker(app, { onPlay: (save) => { void begin(save); }, onNew: createFlow });
792
+ app.toPicker = reopenPicker;
604
793
  const createFlow = () => characterCreation(app, (state) => {
605
794
  app.stack = [];
606
795
  app.startGame(state, `${state.char.name.toLowerCase()}-${Date.now().toString(36)}`, Date.now(), true);
607
796
  void app.persist();
608
797
  app.render();
609
- });
798
+ }, { onCancel: saves.length ? reopenPicker : null });
610
799
  if (saves.length && !argv.includes('--new')) await reopenPicker();
611
800
  else createFlow();
612
801
  return new Promise((resolve) => { app.onQuit = resolve; });
613
802
  }
614
803
 
615
- export function characterCreation(app, done) {
616
- const ds = app.ds;
617
- const races = ds.races();
618
- // A random world bot: a whole character at its own level, geared, with its name (phonecraft's dev roll).
619
- const randomBot = { label: `${dim('Random character')} ${dim('a world bot at its own level')}`, value: 'random' };
620
- app.pick('Choose a race', [...races.map((r) => ({
621
- label: `${r.name.padEnd(10)} ${dim(r.faction)}`,
622
- detail: [bold(r.name), r.faction, `Starts in ${ds.zone(r.startZone)?.name ?? r.startZone}`, '', dim('Classes: ') + r.classes.filter((c) => ds.createInfo(r.id, c)).map(className).join(', ')],
623
- value: r,
624
- })), randomBot], (race) => {
625
- if (race === 'random') {
626
- const seed = (Date.now() ^ (Math.random() * 0x7fffffff)) >>> 0;
627
- const bot = pickRandomWorldBot(ds, seed);
628
- done(newGameStateFromWorldBot(ds, bot, seed));
629
- return;
630
- }
631
- const classes = race.classes.filter((c) => ds.createInfo(race.id, c));
632
- app.pick(`${race.name}: choose a class`, classes.map((c) => ({ label: className(c), value: c })), (cls) => {
633
- app.pick('Gender', [{ label: 'Male', value: 0 }, { label: 'Female', value: 1 }], (gender) => {
634
- const askName = () => app.prompt('Name (2-12 letters): ', (raw) => {
635
- const name = raw.trim();
636
- if (!/^[A-Za-z]{2,12}$/.test(name)) { app.flashMsg('Letters only, 2 to 12 of them.'); askName(); return; }
637
- const seed = (Date.now() ^ (Math.random() * 0x7fffffff)) >>> 0;
638
- done(newGameState(ds, race.id, cls, name[0].toUpperCase() + name.slice(1).toLowerCase(), seed, gender));
639
- });
640
- askName();
641
- });
642
- });
643
- });
644
- }
804
+ export { characterCreation } from './tuiCreate.mjs';