azeroth-shell 0.1.1 → 0.2.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 (66) hide show
  1. package/README.md +326 -82
  2. package/bin/azsh.mjs +21 -19
  3. package/package.json +19 -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/fileRepository.mjs +46 -1
  8. package/src/cli/gzLoader.mjs +47 -6
  9. package/src/cli/hdArt.mjs +81 -0
  10. package/src/cli/iconArt.mjs +135 -0
  11. package/src/cli/iconGrid.mjs +100 -0
  12. package/src/cli/modelArt.mjs +295 -0
  13. package/src/cli/paperdoll.mjs +76 -0
  14. package/src/cli/perfCheck.mjs +56 -0
  15. package/src/cli/repl.mjs +36 -7
  16. package/src/cli/rotationText.mjs +6 -6
  17. package/src/cli/sixel.mjs +307 -0
  18. package/src/cli/term.mjs +106 -12
  19. package/src/cli/text.mjs +5 -3
  20. package/src/cli/tui.mjs +252 -98
  21. package/src/cli/tuiAutopilot.mjs +347 -0
  22. package/src/cli/tuiBg.mjs +371 -42
  23. package/src/cli/tuiCheck.mjs +415 -28
  24. package/src/cli/tuiCity.mjs +118 -13
  25. package/src/cli/tuiCompanions.mjs +25 -15
  26. package/src/cli/tuiCreate.mjs +223 -0
  27. package/src/cli/tuiDungeon.mjs +1 -1
  28. package/src/cli/tuiFight.mjs +425 -88
  29. package/src/cli/tuiFightPanes.mjs +27 -8
  30. package/src/cli/tuiFloor.mjs +2 -2
  31. package/src/cli/tuiGear.mjs +10 -6
  32. package/src/cli/tuiLoot.mjs +1 -0
  33. package/src/cli/tuiPaperdoll.mjs +107 -0
  34. package/src/cli/tuiPartyStage.mjs +177 -0
  35. package/src/cli/tuiProfessions.mjs +4 -2
  36. package/src/cli/tuiRotationCond.mjs +390 -0
  37. package/src/cli/tuiSaves.mjs +22 -3
  38. package/src/cli/tuiSearch.mjs +75 -22
  39. package/src/cli/tuiSettings.mjs +15 -0
  40. package/src/cli/tuiSimLab.mjs +1 -1
  41. package/src/cli/tuiStage.mjs +304 -0
  42. package/src/cli/tuiTalentTree.mjs +124 -0
  43. package/src/cli/tuiViews.mjs +296 -115
  44. package/src/cli/tuiWorld.mjs +4 -5
  45. package/src/data/gear-art.bin.br +0 -0
  46. package/src/data/hd-art.json +114 -0
  47. package/src/data/icon-art.bin.gz +0 -0
  48. package/src/data/model-art.bin.gz +0 -0
  49. package/src/data/rotations/pve/paladin/retribution.json +2 -2
  50. package/src/domain/bg/match.ts +85 -24
  51. package/src/domain/bg/strategies.ts +144 -29
  52. package/src/domain/bg/types.ts +2 -0
  53. package/src/domain/bg/wsg.ts +35 -20
  54. package/src/domain/quests/doQuest.ts +24 -6
  55. package/src/domain/rotation/build.ts +3031 -3031
  56. package/src/domain/rotation/classes/paladin.ts +16 -11
  57. package/src/domain/rotation/evaluate.ts +3 -0
  58. package/src/engine/combat/applySpell.ts +20 -9
  59. package/src/engine/combat/applySpellLate.ts +16 -12
  60. package/src/engine/combat/areaAura.ts +284 -283
  61. package/src/engine/combat/fighterFactory.ts +1 -1
  62. package/src/engine/combat/pickAction.ts +39 -1
  63. package/src/engine/combat/procsDamage.ts +31 -1
  64. package/src/engine/combat/sessionChase.ts +7 -1
  65. package/src/engine/combat/sessionMotionKit.ts +40 -2
  66. 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
- import { settingsDialog, globalSettingsOpts } from './tuiSettings.mjs';
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,10 +24,15 @@ 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 } 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';
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';
30
36
  import { moneyText, 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';
@@ -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,21 @@ 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',
50
58
  '',
51
59
  'In the world',
52
60
  ' 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.',
61
+ ' exit opens it. a shows what the autopilot will do; Enter hands the character over: it quests and hunts',
62
+ ' on its own and its panel shows what it is doing, what comes next and what will stop it. Its fights play',
63
+ ' 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',
64
+ ' pauses it, 1-9 browse the tabs under a run strip, Tab cycles the bottom pane (the Run pane lists every',
65
+ ' step). At 4x and up the log folds to one line a fight. For a CRT look, turn on your terminal\'s retro',
66
+ ' effect (Windows Terminal: "experimental.retroTerminalEffect": true on the profile).',
67
+ ' A quest it dies on 2 times or cannot finish is skipped for the rest of the session. Quests tab: d (or Do',
68
+ ' the quest in the quest window) runs it on that one quest. :autopilot (or A) sets how it rests, pulls, fights and quests.',
57
69
  ' In a capital the World tab lists the Bank, the Auction House and the Mailbox under Services.',
58
70
  ' Rogues: P on a creature picks its pocket (Stealth first). A resist starts the fight.',
59
71
  ' :honor: your PvP rank, the arena (1v1 to 5v5), Warsong Gulch and Arathi Basin (pick your job, watch it play,',
@@ -65,7 +77,8 @@ const HELP_LINES = [
65
77
  'Gear and bags',
66
78
  ' Gear tab: Enter on a slot lists everything in your bags (and the bank, in a bank town) that fits it.',
67
79
  ' ▲ 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.',
80
+ ' r repairs one piece, R everything; o opens the Outfitter (gear sets). :outfit <name> wears a set from anywhere.',
81
+ ' In a big enough window the Gear tab is a character sheet (the Talents tab the three trees): Up and Down walk the icons in order, Shift+arrows move across them.',
69
82
  ' Bags tab: Enter uses, equips, opens, unwraps or learns; m lists every verb for the stack. s sells to the open',
70
83
  ' vendor, j sells junk, b opens the bank, d destroys, S sorts, / splits a stack, [ ] move it, A uses the whole stack.',
71
84
  ' D lists every green, blue or epic the group can disenchant; L lists every book someone can learn. G runs it.',
@@ -79,7 +92,7 @@ const HELP_LINES = [
79
92
  ' t gives a companion an order: attack a target, cast one ability, or hold ground (the fight waits).',
80
93
  ' c cancels one of your buffs (also from the Character tab, out of a fight).',
81
94
  ' 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.',
95
+ ' The ability bar runs along the bottom of the stage with its cooldowns; a takes the keys off the rotation.',
83
96
  '',
84
97
  'Professions and Companions',
85
98
  ' Professions: Enter on a profession opens its recipe book. Enter makes one, A makes all you can, N asks',
@@ -108,19 +121,62 @@ export class App {
108
121
  this.cmd = null;
109
122
  this.closed = false;
110
123
  this.timers = [];
124
+ this.lastPaint = 0; this.paintTimer = null; // renderSoon holds timer-driven repaints to the Screen refresh setting
111
125
  this.lastActivity = null;
112
126
  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(); } };
113
127
  term.onResize = () => this.render();
128
+ term.onSixel = () => setImmediate(() => this.offerHdArt());
129
+ }
130
+
131
+ // ---- full-size art -----------------------------------------------------------------------------
132
+
133
+ /** The terminal draws sixel and the full-size art is not here: offer it once a run, unless told never. */
134
+ offerHdArt() {
135
+ const bytes = missingHdBytes();
136
+ if (!bytes || this.closed || this.hdOffered || this.prefs.get('hdArt') === 'never') return;
137
+ this.hdOffered = true;
138
+ const mb = Math.round(bytes / 1e6);
139
+ this.pick(`Your terminal draws real pixels. Download the full-size art (${mb} MB)?`, [
140
+ { label: `Download now ${dim(`${mb} MB into ${downloadDir()}`)}`, value: 'yes' },
141
+ { label: `Not now ${dim('asked again next time; :art download fetches it')}`, value: 'later' },
142
+ { label: `Never ask ${dim(':art download still fetches it')}`, value: 'never' },
143
+ ], (v) => { if (v === 'yes') this.fetchHdArt(); else if (v === 'never') this.prefs.set('hdArt', 'never'); });
144
+ }
145
+
146
+ /** Fetch the full-size art in the background, progress on the hint row; the screen repaints in it when done. */
147
+ fetchHdArt() {
148
+ if (this.hdFetching) return;
149
+ this.hdFetching = true;
150
+ let shown = 0;
151
+ this.flashMsg('Downloading the full-size art...');
152
+ this.render();
153
+ void downloadHdArt((done, total) => {
154
+ if (Date.now() - shown < 250) return;
155
+ shown = Date.now();
156
+ this.flashMsg(`Downloading the full-size art: ${Math.round(done / 1e6)} of ${Math.round(total / 1e6)} MB`);
157
+ this.render();
158
+ }).then((res) => {
159
+ this.hdFetching = false;
160
+ if (this.closed) return;
161
+ 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?.(); }
162
+ else this.say(red(`The art download stopped: ${res.reason}. :art download tries again.`));
163
+ this.render();
164
+ });
114
165
  }
115
166
 
116
167
  // ---- game lifecycle ---------------------------------------------------------------------------
117
168
 
118
169
  /** `updatedAt` is when the save was last written: the gap since is offline rest and regen. */
119
170
  startGame(state, slot, createdAt, isNew, updatedAt) {
171
+ // Another terminal already playing this character would trade overwrites with us on every save.
172
+ const held = this.repo.lock?.(slot) ?? { ok: true };
173
+ if (!held.ok) { this.say(red(`${state.char.name} is open in another terminal (pid ${held.pid}). Close it there first.`)); return false; }
120
174
  this.slot = slot; this.createdAt = createdAt ?? Date.now();
121
175
  const awayMs = updatedAt > 0 ? Math.max(0, Date.now() - updatedAt) : 0;
122
176
  // Travel pace is a setting (instant by default, like phonecraft); `skipping` lets Arrive now land a trip at any pace.
123
- const g = createGame(this.ds, state, { travelPace: () => (this.skipping ? 'instant' : this.prefs.get('travelPace') ?? 'instant'), offlineTimeMs: awayMs, ...globalSettingsOpts(this.prefs) });
177
+ // wallWorldClock: aura and cooldown stamps on Date.now(), the clock every screen reads. Without it they ride the
178
+ // fight chain, which runs hours ahead once fights resolve faster than real time (Forbearance showing 8h).
179
+ const g = createGame(this.ds, state, { travelPace: () => (this.skipping ? 'instant' : this.prefs.get('travelPace') ?? 'instant'), offlineTimeMs: awayMs, wallWorldClock: true, ...globalSettingsOpts(this.prefs) });
124
180
  this.g = g;
125
181
  if (g.state.char.journey) { if (this.prefs.get('travelPace') === 'instant') this.arriveNow(); else g.journeyTick(); }
126
182
  // Real createInfo has no starting weapon (ETL gap KI-003); phonecraft's harness hands one over too.
@@ -173,8 +229,10 @@ export class App {
173
229
  if (this.closed) return;
174
230
  this.closed = true;
175
231
  for (const t of this.timers) this.term.clearInterval(t);
232
+ clearTimeout(this.paintTimer);
176
233
  this.fight?.stop?.(); this.auto?.stop?.();
177
234
  await this.persist();
235
+ if (this.slot != null) this.repo.unlock?.(this.slot);
178
236
  this.term.close();
179
237
  this.restoreConsole?.();
180
238
  if (this.g) console.log(dim(`Saved ${this.g.state.char.name}. Farewell.`));
@@ -262,25 +320,49 @@ export class App {
262
320
  if (!this.stack.length) { this.fight?.onOrdersClosed?.(); this.render(); }
263
321
  return;
264
322
  }
265
- if (this.fight) { this.fight.key(k); return; }
323
+ if (this.fight && !this.inlineFight()) {
324
+ // The full fight view. Under the autopilot Esc goes back to the run panel; the fight plays on.
325
+ if ((k === 'escape' || k === 'w') && this.auto && this.fight.label === 'autopilot') { this.auto.watchFight = false; this.render(); return; }
326
+ this.fight.key(k); return;
327
+ }
266
328
  if (this.auto) {
267
- // The run's own panel shows between fights; a tab key browses the tabs instead (only acting is locked
268
- // out while it plays) and w goes back to watching. The next fight always comes back on screen.
269
- const auto = this.auto;
270
- if (k === 'escape' || k === 'q') { auto.stop(); this.say(yellow('Autopilot stopped.')); this.render(); return; }
329
+ // The run's panel holds the screen, fights included. A tab key browses the tabs, and a browsed tab works as
330
+ // it does without a run (Enter acts, its own keys act): the run plays on underneath, under the run strip.
331
+ // 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.
332
+ const auto = this.auto, f = this.fight;
333
+ // A window on top (a dialog a tab or the Bags pane opened) takes the keys until it closes.
334
+ if (this.stack.length && this.top()?.id !== 'log') { if (k === 'escape') this.pop(); else this.viewKey(k); return; }
335
+ if (auto.browsing) {
336
+ if (k === 'escape' || k === 'w') { auto.browsing = false; this.stack = []; this.render(); return; }
337
+ if (k === 'l' && !this.top()?.keys?.l) { this.openLog(); return; }
338
+ if (/^[0-9]$/.test(k)) { this.switchTab(digitTab(k)); return; }
339
+ if (k === 'tab' || k === 'right') { this.switchTab((this.tab + 1) % this.tabs.length); return; }
340
+ if (k === 'shift-tab' || k === 'left') { this.switchTab((this.tab + this.tabs.length - 1) % this.tabs.length); return; }
341
+ if (k === '?') { this.push(this.helpView()); return; }
342
+ this.viewKey(k);
343
+ return;
344
+ }
345
+ // The Bags pane at the bottom has its own cursor; Enter there uses the item under it.
346
+ if (fightPane(this) === 'bags' && (MOVE_KEYS.has(k) || k === 'return') && bagsPaneKey(this, k)) return;
347
+ // Over a win's hold there is nothing to run from: the hold lets go and the run stops.
348
+ if (k === 'escape' || k === 'q') { if (f && !f.holding) f.flee(); else { f?.release(); if (this.auto === auto) auto.end('you stopped it'); } return; }
349
+ if (k === 'space') { auto.paused = !auto.paused; if (f) f.paused = auto.paused; this.render(); return; }
271
350
  if (k === 'l') { this.openLog(); return; }
272
- if (k === 'w') { auto.browsing = false; this.stack = []; this.render(); return; }
351
+ if ((k === 'w' || k === 'return') && f) { auto.watchFight = true; this.render(); return; }
352
+ if (k === 'a' && f) { auto.watchFight = true; f.key('a'); return; } // play it yourself: needs the ability bar
353
+ if ((k === '+' || k === '=' || k === '-') && f) { f.key(k); return; } // the fight owns the clock while it runs
273
354
  if (k === '+' || k === '=') { this.fightSpeed = Math.min(8, this.fightSpeed * 2); this.render(); return; }
274
355
  if (k === '-') { this.fightSpeed = Math.max(0.5, this.fightSpeed / 2); this.render(); return; }
275
356
  if (/^[0-9]$/.test(k)) { auto.browsing = true; this.switchTab(digitTab(k)); return; }
276
- if (k === 'tab' || k === 'right') { auto.browsing = true; this.switchTab((this.tab + 1) % this.tabs.length); return; }
277
- if (k === 'shift-tab' || k === 'left') { auto.browsing = true; this.switchTab((this.tab + this.tabs.length - 1) % this.tabs.length); return; }
278
- if (MOVE_KEYS.has(k) && auto.browsing) { this.stack = []; this.viewKey(k); return; }
357
+ // Watching the run, Tab cycles the bottom pane; the arrows step onto the tabs.
358
+ if (k === 'tab' || k === 'shift-tab') { cycleFightPane(this, k === 'tab' ? +1 : -1); return; }
359
+ if (k === 'right') { auto.browsing = true; this.switchTab((this.tab + 1) % this.tabs.length); return; }
360
+ if (k === 'left') { auto.browsing = true; this.switchTab((this.tab + this.tabs.length - 1) % this.tabs.length); return; }
279
361
  return;
280
362
  }
281
- if (!this.g) return this.viewKey(k);
363
+ if (!this.g) { if (!this.viewKey(k) && k === 'escape' && this.stack.length > 1) this.pop(); return; }
282
364
  if (k === ':') { this.prompt(':', (line) => this.runCommand(line)); return; }
283
- if (k === '?') { this.push({ id: 'help', title: 'Keys', hint: 'Esc: back', noDetail: true, rows: () => HELP_LINES.map((t) => ({ text: t, header: true })) }); return; }
365
+ if (k === '?') { this.push(this.helpView()); return; }
284
366
  if (k === 'l' && this.top()?.id !== 'log' && !this.top()?.keys?.l) { this.openLog(); return; }
285
367
  if (this.isDead() && this.top()?.id !== 'death') { this.push(deathView(this)); return; }
286
368
  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; }
@@ -316,11 +398,20 @@ export class App {
316
398
  if (row?.keys?.[k]) { row.keys[k](this, row); this.afterAction(); return true; }
317
399
  if (view.keys?.[k]) { view.keys[k].fn(this, row); this.afterAction(); return true; }
318
400
  if (view.onKey?.(this, k, row)) { this.afterAction(); return true; }
401
+ // A view drawn as an icon grid (the paperdoll, the talent trees): Up and Down still walk its rows in order
402
+ // and Left and Right still change tabs; Shift+arrows move on the picture in two dimensions.
403
+ const gridDir = { 'shift-up': 'up', 'shift-down': 'down', 'shift-left': 'left', 'shift-right': 'right' }[k];
404
+ if (view.gridNav && gridDir) {
405
+ const to = view.gridNav(gridDir, view.cursor);
406
+ if (to != null) view.cursor = to;
407
+ this.render();
408
+ return true;
409
+ }
319
410
  switch (k) {
320
411
  case 'up': case 'k': this.moveCursor(view, rows, -1); return true;
321
412
  case 'down': case 'j': this.moveCursor(view, rows, +1); return true;
322
413
  case 'home': view.cursor = Math.max(0, rows.findIndex((r) => !r.header)); view.scroll = 0; this.render(); return true;
323
- 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; }
414
+ 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; }
324
415
  case 'pageup': case 'pagedown': this.pageView(view, rows, k === 'pageup' ? -1 : +1); return true;
325
416
  case 'return': case 'space': if (row && !row.header && row.act) { row.act(this, row); this.afterAction(); } return true;
326
417
  default: return false;
@@ -333,30 +424,48 @@ export class App {
333
424
  if (i >= 0 && i < rows.length) { view.cursor = i; this.ensureVisible(view, rows); }
334
425
  else {
335
426
  // No selectable row that way: scroll the pane instead so long text can be read.
336
- const maxScroll = Math.max(0, rows.length - this.layout().mainH);
427
+ const L = this.layout();
428
+ const maxScroll = this.maxScrollOf(rows, L.mainH, this.listWOf(view, L));
337
429
  view.scroll = Math.max(0, Math.min(maxScroll, view.scroll + delta));
338
430
  }
339
431
  this.render();
340
432
  }
341
433
  /** Scroll the pane by a page; the cursor follows onto a selectable row inside the new window when there is one. */
342
434
  pageView(view, rows, dir) {
343
- const h = this.layout().mainH;
344
- const maxScroll = Math.max(0, rows.length - h);
435
+ const L = this.layout(), h = L.mainH, listW = this.listWOf(view, L);
436
+ const maxScroll = this.maxScrollOf(rows, h, listW);
345
437
  view.scroll = Math.max(0, Math.min(maxScroll, (view.scroll ?? 0) + dir * Math.max(1, h - 1)));
346
- const inWindow = (i) => i >= view.scroll && i < view.scroll + h;
438
+ const end = this.windowEnd(rows, view.scroll, h, listW);
439
+ const inWindow = (i) => i >= view.scroll && i < end;
347
440
  if (!inWindow(view.cursor)) {
348
- const from = dir > 0 ? view.scroll : view.scroll + h - 1;
441
+ const from = dir > 0 ? view.scroll : end - 1;
349
442
  let i = from;
350
- while (i >= view.scroll && i < view.scroll + h && rows[i]?.header) i += dir;
443
+ while (i >= view.scroll && i < end && rows[i]?.header) i += dir;
351
444
  if (inWindow(i) && rows[i] && !rows[i].header) view.cursor = i;
352
445
  }
353
446
  this.render();
354
447
  }
355
448
  ensureVisible(view, rows) {
356
- const h = this.layout().mainH;
449
+ const L = this.layout(), h = L.mainH, listW = this.listWOf(view, L);
357
450
  if (view.cursor < view.scroll) view.scroll = view.cursor;
358
- if (view.cursor >= view.scroll + h) view.scroll = view.cursor - h + 1;
359
- view.scroll = Math.max(0, Math.min(view.scroll, Math.max(0, rows.length - h)));
451
+ while (view.scroll < view.cursor && this.windowEnd(rows, view.scroll, h, listW) <= view.cursor) view.scroll++;
452
+ view.scroll = Math.max(0, Math.min(view.scroll, this.maxScrollOf(rows, h, listW)));
453
+ }
454
+ /** The list's width in a layout: the whole pane when the view has no detail column. */
455
+ listWOf(view, L) { return L.detailW > 0 && !view.noDetail ? L.listW : L.W; }
456
+ /** Lines a row takes: two for a row with an icon when icons draw and the list has room for them. */
457
+ rowH(r, listW) { return r.icon && listW >= 40 && iconsOn() ? 2 : 1; }
458
+ /** The first row a window of `h` lines from `scroll` does not show (rows fit whole). */
459
+ windowEnd(rows, scroll, h, listW) {
460
+ let used = 0, i = scroll;
461
+ while (i < rows.length && used + this.rowH(rows[i], listW) <= h) { used += this.rowH(rows[i], listW); i++; }
462
+ return Math.max(i, scroll + 1);
463
+ }
464
+ /** The largest scroll that still fills the window from the end. */
465
+ maxScrollOf(rows, h, listW) {
466
+ let used = 0, s = rows.length;
467
+ while (s > 0 && used + this.rowH(rows[s - 1], listW) <= h) { used += this.rowH(rows[s - 1], listW); s--; }
468
+ return s;
360
469
  }
361
470
  rowsOf(view) {
362
471
  const rows = view.rows(this) ?? [];
@@ -393,7 +502,15 @@ export class App {
393
502
  const [, cmd = '', args = ''] = line.match(/^(\S+)\s*(.*)$/) ?? [];
394
503
  const c = cmd.toLowerCase();
395
504
  if (c === 'quit' || c === 'exit' || c === 'q') { await this.quit(); return; }
396
- if (c === 'auto') { const m = args.match(/^(\d+)?\s*(?:farm\s+(.+))?$/i); startAutopilot(this, Number(m?.[1] ?? 200), m?.[2]); return; }
505
+ if (c === 'auto') { const m = args.match(/^(\d+)?\s*(?:farm\s+(.+))?$/i); startAutopilot(this, m?.[1] ? Number(m[1]) : Infinity, m?.[2]); return; }
506
+ if (c === 'art') {
507
+ const missing = missingHdBytes();
508
+ if (args.trim().toLowerCase() === 'download') { if (missing) this.fetchHdArt(); else this.say('The full-size art is all here.'); this.render(); return; }
509
+ const how = sixelOn() ? green('this terminal draws real pixels') : yellow('this terminal draws half-block art (no sixel)');
510
+ this.say(`Art: ${how}; ${missing ? `${Math.round(missing / 1e6)} MB of full-size art not downloaded (:art download)` : `full-size art in ${downloadDir()}`}.`);
511
+ this.render();
512
+ return;
513
+ }
397
514
  if (c === 'autoloot') {
398
515
  const want = args.trim().toLowerCase();
399
516
  if (want === 'on' || want === 'off') this.prefs.set('autoLoot', want === 'on');
@@ -445,20 +562,31 @@ export class App {
445
562
 
446
563
  layout() {
447
564
  const W = this.term.cols, H = this.term.rows;
448
- const logH = Math.max(5, Math.min(12, Math.floor(H * 0.3)));
449
- const top = 4; // title, bars, tabs, rule
565
+ const logH = this.g ? Math.max(5, Math.min(12, Math.floor(H * 0.3))) : 3;
566
+ const top = this.compactHeader() ? 2 : 4 + (this.showsRunStrip() ? 1 : 0); // title, bars, tabs, (the run strip), rule; a run keeps only XP
450
567
  const mainH = H - top - 1 - logH - 1;
451
- const detailW = W >= 90 ? Math.floor(W * 0.4) : 0;
568
+ // A view can ask for a narrower detail pane (the talent trees want three side by side).
569
+ const detailW = W >= 90 ? Math.floor(W * (this.top()?.detailShare ?? 0.4)) : 0;
452
570
  const listW = detailW ? W - detailW - 1 : W;
453
571
  return { W, H, logH, top, mainH, listW, detailW };
454
572
  }
455
573
 
574
+ /** A repaint from a game loop (a fight tick, the autopilot, a dungeon walk). The game keeps ticking every
575
+ * 100 ms; the screen catches up at most once per Screen refresh, and the last change is always painted. */
576
+ renderSoon() {
577
+ if (!this.term.isTTY) { this.render(); return; }
578
+ const wait = this.lastPaint + refreshMs(this.prefs) - Date.now();
579
+ if (wait <= 0) this.render();
580
+ else if (!this.paintTimer) this.paintTimer = setTimeout(() => { this.paintTimer = null; this.render(); }, wait);
581
+ }
582
+
456
583
  render() {
457
584
  if (this.closed) return;
585
+ clearTimeout(this.paintTimer); this.paintTimer = null; this.lastPaint = Date.now();
458
586
  const L = this.layout();
459
587
  const lines = [];
460
588
  lines.push(...this.headerLines(L));
461
- if (this.fight && !this.stack.length) lines.push(...this.fight.render(L.W, L.mainH));
589
+ if (this.fight && !this.stack.length && !this.inlineFight()) lines.push(...this.fight.render(L.W, L.mainH));
462
590
  else if (this.auto && !this.auto.browsing && !this.stack.length) lines.push(...this.auto.render(L.W, L.mainH));
463
591
  else lines.push(...this.mainLines(L));
464
592
  while (lines.length < L.top + L.mainH) lines.push('');
@@ -472,8 +600,11 @@ export class App {
472
600
 
473
601
  headerLines(L) {
474
602
  if (!this.g) {
475
- return [bold(' azsh') + dim(' Azeroth shell. WoW Classic 1.12 via CMaNGOS data.'), '', '', dim('─'.repeat(L.W))];
603
+ const crumbs = this.stack.map((v) => bold(v.title ?? '')).join(dim(' › '));
604
+ return [bold(' azsh') + dim(' Azeroth shell. WoW Classic 1.12 via CMaNGOS data.'), crumbs ? cut(` ${crumbs}`, L.W) : '', '', dim('─'.repeat(L.W))];
476
605
  }
606
+ // Watching a run, the name, bars and tabs go: the panel has your frame, and the XP line is what a run is for.
607
+ if (this.compactHeader()) return [runHeaderLine(this, L.W), dim('─'.repeat(L.W))];
477
608
  const ch = this.ch, ds = this.ds;
478
609
  const z = this.zone();
479
610
  const dead = this.isDead() ? red(bold(' DEAD')) : ch.mountedItem != null ? dim(' mounted') : '';
@@ -481,26 +612,43 @@ export class App {
481
612
  const road = journeyHeader(this);
482
613
  const right = `${road ? yellow(road) : cyan(z?.name ?? ch.zoneId)} ${yellow(moneyText(ch.gold) || '0c')} `;
483
614
  const l0 = fit(title, L.W - vis(right)) + right;
484
- const hpMax = maxHealth(ch, ds), pwMax = maxPower(ch, ds), pt = powerType(ch);
615
+ // In a fight the bars follow the live session, so the header is the player's frame and no panel needs another.
616
+ const live = this.fight?.session?.playerView?.() ?? null;
617
+ const hp = live ? live.hp : ch.health, hpMax = live ? live.maxHp : maxHealth(ch, ds);
618
+ const pw = live ? live.power : ch.power, pwMax = live ? live.maxPower : maxPower(ch, ds), pt = powerType(ch);
485
619
  const xpMax = ds.xpToNext(ch.level);
486
620
  const seg = Math.max(8, Math.floor((L.W - 50) / 3));
487
- const l1 = ` ${green('HP')} ${bar(ch.health, hpMax, seg, green)} ${fit(`${ch.health}/${hpMax}`, 9)}`
488
- + ` ${(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)}`
621
+ const l1 = ` ${green('HP')} ${bar(hp, hpMax, seg, hp / Math.max(1, hpMax) < 0.25 ? red : green)} ${fit(`${hp}/${hpMax}`, 9)}`
622
+ + ` ${(POWER_PAINT[pt] ?? cyan)(pt === 'mana' ? 'MP' : pt === 'rage' ? 'RG' : 'EN')} ${bar(pw, pwMax, seg, POWER_PAINT[pt] ?? cyan)} ${fit(`${pw}/${pwMax}`, 9)}`
489
623
  + ` ${magenta('XP')} ${bar(ch.xp, xpMax, seg, magenta)} ${dim(`${pct(ch.xp, xpMax)}%${ch.restedXp ? ' rested' : ''}`)}`;
490
- // Tabs 1-9 and 0 have a digit. Short labels take over when the full ones do not fit, and inside a
491
- // dialog the bar shrinks to the current tab so the breadcrumb stays readable.
624
+ // Tabs 1-9 and 0 have a digit, and where the terminal draws sixel a one-line icon in front while there is
625
+ // room (as half-block cells an icon two wide is a smudge of colour, so those get none). Short labels take
626
+ // over when the full ones do not fit, then the icons go, and inside a dialog the bar shrinks to the
627
+ // current tab so the breadcrumb stays readable.
492
628
  const digit = (i) => (i < 9 ? String(i + 1) : i === 9 ? '0' : '');
493
- const tabBar = (short) => this.tabs.map((t, i) => {
629
+ const tabBar = (short, icons) => (icons && sixelOn() ? ' ' : '') + this.tabs.map((t, i) => {
494
630
  const label = short ? (t.short ?? t.label) : t.label, d = digit(i);
495
- return i === this.tab && !this.stack.length ? inverse(` ${d ? `${d} ` : ''}${label} `) : ` ${d ? `${dim(d)} ` : ''}${label} `;
631
+ const art = icons && sixelOn() ? iconArt(t.icon, 2)?.[0] : null;
632
+ const tab = i === this.tab && !this.stack.length ? inverse(` ${d ? `${d} ` : ''}${label} `) : ` ${d ? `${dim(d)} ` : ''}${label} `;
633
+ return art ? `${art}${tab}` : tab;
496
634
  }).join('');
497
635
  const crumbs = this.stack.map((v) => ` › ${bold(v.title)}`).join('');
498
- let tabs = tabBar(false);
499
- if (vis(tabs + crumbs) > L.W) tabs = tabBar(true);
636
+ let tabs = tabBar(false, true);
637
+ for (const [short, icons] of [[true, true], [false, false], [true, false]]) if (vis(tabs + crumbs) > L.W) tabs = tabBar(short, icons);
500
638
  if (vis(tabs + crumbs) > L.W && this.stack.length) { const d = digit(this.tab); tabs = ` ${d ? `${dim(d)} ` : ''}${this.tabs[this.tab]?.label ?? ''}`; }
501
- return [l0, l1, cut(tabs + crumbs, L.W), dim('─'.repeat(L.W))];
639
+ const strip = this.showsRunStrip() ? [runStripLine(this, L.W)] : [];
640
+ return [l0, l1, cut(tabs + crumbs, L.W), ...strip, dim('─'.repeat(L.W))];
502
641
  }
503
642
 
643
+ /** The run strip sits under the tabs whenever a run is alive and neither its panel nor its fight fills the middle. */
644
+ showsRunStrip() { return !!this.auto && (this.stack.length > 0 || this.auto.browsing) && !(this.fight && !this.stack.length && !this.inlineFight()); }
645
+
646
+ /** An autopilot fight draws inside the run panel unless w asked for the full fight view. */
647
+ inlineFight() { return !!this.fight && this.fight.label === 'autopilot' && !!this.auto && !this.auto.watchFight; }
648
+
649
+ /** Watching a run (its panel or its fight on screen): the header is only the XP line. */
650
+ compactHeader() { return !!this.auto && !this.auto.browsing && !this.stack.length; }
651
+
504
652
  mainLines(L) {
505
653
  const view = this.top();
506
654
  const out = [];
@@ -508,26 +656,40 @@ export class App {
508
656
  const rows = this.rowsOf(view);
509
657
  const showDetail = L.detailW > 0 && !view.noDetail;
510
658
  const listW = showDetail ? L.listW : L.W;
511
- const maxScroll = Math.max(0, rows.length - L.mainH);
659
+ const maxScroll = this.maxScrollOf(rows, L.mainH, listW);
512
660
  view.scroll = Math.max(0, Math.min(view.scroll ?? 0, maxScroll));
513
- const listLines = [];
514
- for (let i = view.scroll; i < Math.min(rows.length, view.scroll + L.mainH); i++) {
661
+ // A grid view draws its own list side when it fits (null when it does not, and the rows list as usual).
662
+ const grid = view.grid?.(this, rows, listW, L.mainH) ?? null;
663
+ view.gridNav = grid?.nav ?? null;
664
+ const listLines = grid ? grid.lines.slice(0, L.mainH) : [];
665
+ const end = grid ? rows.length : this.windowEnd(rows, view.scroll, L.mainH, listW);
666
+ for (let i = grid ? end : view.scroll; i < end; i++) {
515
667
  const r = rows[i];
516
668
  const sel = i === view.cursor && !r.header;
517
- let text = r.header ? (r.text ? cyan(bold(r.text)) : '') : ` ${r.text}`;
669
+ // A row with an icon is two lines: the icon's cells down the left, the text beside the top row and
670
+ // the row's `sub` (a kind, a cost) beside the bottom one. The icon keeps its own colours when selected.
671
+ if (this.rowH(r, listW) === 2) {
672
+ const art = iconArt(r.icon, 4) ?? iconBlank(4);
673
+ const w = listW - 6;
674
+ const t1 = fit(` ${r.text}`, w), t2 = fit(` ${r.sub ?? ''}`, w);
675
+ listLines.push(`${sel ? '▶' : ' '}${art[0]}${sel ? inverse(t1) : t1}`, ` ${art[1]}${sel ? inverse(t2) : t2}`);
676
+ continue;
677
+ }
678
+ // A header row is a cyan title; a plain one (a card's line of text) keeps its own paint.
679
+ let text = r.header ? (!r.text ? '' : r.plain ? r.text : cyan(bold(r.text))) : ` ${r.text}`;
518
680
  text = fit(sel ? `${'▶'}${text.slice(1)}` : text, listW - 1);
519
681
  listLines.push(sel ? inverse(text) : text);
520
682
  }
521
683
  // A cursor row scrolled out of view is echoed on the last line so Enter is never a mystery.
522
684
  const cursorRow = rows[view.cursor];
523
- if (cursorRow && !cursorRow.header && (view.cursor < view.scroll || view.cursor >= view.scroll + L.mainH) && listLines.length === L.mainH) {
685
+ if (!grid && cursorRow && !cursorRow.header && (view.cursor < view.scroll || view.cursor >= end) && listLines.length === L.mainH) {
524
686
  listLines[L.mainH - 1] = inverse(fit(` ${view.cursor < view.scroll ? '▲' : '▼'} ${cursorRow.text}`, listW - 1));
525
687
  }
526
688
  if (!rows.length && view.empty) listLines.push(dim(` ${view.empty}`));
527
689
  let detailLines = [];
528
690
  if (showDetail) {
529
691
  const d = cursorRow?.detail?.(this, cursorRow) ?? view.detail?.(this, cursorRow) ?? [];
530
- for (const line of d) detailLines.push(...(vis(line) > L.detailW - 2 ? wrapLines(line, L.detailW - 2) : [line]));
692
+ detailLines = wrapIconLines(d, L.detailW - 2);
531
693
  if (detailLines.length > L.mainH) detailLines = detailLines.slice(0, L.mainH - 1).concat(dim('…'));
532
694
  }
533
695
  for (let i = 0; i < L.mainH; i++) {
@@ -550,23 +712,43 @@ export class App {
550
712
  hintLine(L) {
551
713
  if (this.cmd) return ` ${bold(this.cmd.label)}${this.cmd.text}${inverse(' ')}`;
552
714
  if (this.flash && Date.now() < this.flash.until) return ` ${brightYellow(this.flash.text)}`;
553
- if (this.fight && !this.stack.length) return ` ${dim(this.fight.hint())}`;
554
- 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'}`)}`;
715
+ if (this.fight && !this.stack.length && !this.inlineFight()) return ` ${dim(this.fight.hint())}`;
716
+ if (this.auto && !this.stack.length && !this.auto.browsing) {
717
+ const f = this.inlineFight() ? this.fight : null;
718
+ const pace = f ? f.speed : this.fightSpeed;
719
+ const bags = fightPane(this) === 'bags' && !this.auto.browsing ? 'Up Down: pick an item Enter: use it ' : '';
720
+ 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`}`)}`;
721
+ }
722
+ // The view's own keys fill the line while they fit; the way out and ? always stay. What does not fit is on ?.
555
723
  const view = this.top();
556
- const hints = [];
557
- if (view?.hint) hints.push(view.hint);
558
- if (view?.keys) hints.push(...Object.entries(view.keys).filter(([, v]) => v.hint).map(([k, v]) => `${k === 'space' ? 'Space' : k}: ${v.hint}`));
559
- if (view?.id !== 'log') hints.push('l: log');
560
- hints.push(this.stack.length ? 'Esc: back' : '1-9: tabs', '?: help');
724
+ const head = viewKeyHints(view);
725
+ if (!this.g) return ` ${dim(cut([...new Set([...head, ...(this.stack.length > 1 ? ['Esc: back'] : [])])].join(' '), L.W - 2))}`;
726
+ 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'];
727
+ const room = L.W - 2 - vis([...tail, '?: more keys'].join(' ')) - 3;
728
+ const shown = [];
729
+ for (const h of head) { if (vis([...shown, h].join(' ')) > room) break; shown.push(h); }
730
+ const hints = [...shown, ...tail, shown.length < head.length ? '?: more keys' : '?: help'];
561
731
  return ` ${dim(cut(hints.join(' '), L.W - 2))}`;
562
732
  }
733
+
734
+ /** The key list: the open window's own keys first, then the global ones. */
735
+ helpView() {
736
+ const own = viewKeyHints(this.top());
737
+ const lines = own.length ? [bold(`This window (${strip(this.top().title ?? this.top().id)})`), ...own.map((h) => ` ${h}`), ''] : [];
738
+ return { id: 'help', title: 'Keys', hint: 'Esc: back', noDetail: true, rows: () => [...lines, ...HELP_LINES].map((t) => ({ text: t, header: true })) };
739
+ }
563
740
  }
564
741
 
565
742
  // ---- boot ----------------------------------------------------------------------------------------
566
743
 
567
744
  /** The save-pick screen, then the game. Resolves when the player quits. */
568
745
  export async function runTui({ term = new RealTerm(), argv = [] } = {}) {
569
- const ds = bootDataStore();
746
+ term.open();
747
+ // The tables take a second or two to load: say so, stage by stage, instead of leaving the screen blank.
748
+ const loading = (stage) => term.draw([bold(' azsh') + dim(' Azeroth shell. WoW Classic 1.12 via CMaNGOS data.'), '', dim(` Loading ${stage}…`)]);
749
+ loading('the game');
750
+ await new Promise((r) => setImmediate(r)); // the first frame leaves before the synchronous load holds the loop
751
+ const ds = bootDataStore(loading);
570
752
  const repo = new FileSaveRepository();
571
753
  const app = new App({ term, ds, repo, prefs: new FilePrefs() });
572
754
  // Engine diagnostics (console.warn from the combat layer) would print over the frame: while the screen is
@@ -575,52 +757,24 @@ export async function runTui({ term = new RealTerm(), argv = [] } = {}) {
575
757
  const quiet = (...a) => { if (process.env.AZSH_DEBUG) app.say(dim(a.map(String).join(' '))); };
576
758
  Object.assign(console, { log: quiet, warn: quiet, info: quiet, debug: quiet });
577
759
  app.restoreConsole = () => Object.assign(console, saved);
578
- term.open();
579
760
  app.render();
580
761
  const saves = await repo.list();
581
762
  const begin = async (save) => {
582
763
  app.stack = [];
583
- app.startGame(save.state, save.slot, save.createdAt, false, save.updatedAt);
764
+ // false: another terminal took this character between the picker's check and now; back to the picker.
765
+ if (app.startGame(save.state, save.slot, save.createdAt, false, save.updatedAt) === false) return reopenPicker();
584
766
  app.render();
585
767
  };
768
+ const reopenPicker = () => savePicker(app, { onPlay: (save) => { void begin(save); }, onNew: createFlow });
586
769
  const createFlow = () => characterCreation(app, (state) => {
587
770
  app.stack = [];
588
771
  app.startGame(state, `${state.char.name.toLowerCase()}-${Date.now().toString(36)}`, Date.now(), true);
589
772
  void app.persist();
590
773
  app.render();
591
- });
592
- if (saves.length && !argv.includes('--new')) await savePicker(app, { onPlay: (save) => { void begin(save); }, onNew: createFlow });
774
+ }, { onCancel: saves.length ? reopenPicker : null });
775
+ if (saves.length && !argv.includes('--new')) await reopenPicker();
593
776
  else createFlow();
594
777
  return new Promise((resolve) => { app.onQuit = resolve; });
595
778
  }
596
779
 
597
- export function characterCreation(app, done) {
598
- const ds = app.ds;
599
- const races = ds.races();
600
- // A random world bot: a whole character at its own level, geared, with its name (phonecraft's dev roll).
601
- const randomBot = { label: `${dim('Random character')} ${dim('a world bot at its own level')}`, value: 'random' };
602
- app.pick('Choose a race', [...races.map((r) => ({
603
- label: `${r.name.padEnd(10)} ${dim(r.faction)}`,
604
- 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(', ')],
605
- value: r,
606
- })), randomBot], (race) => {
607
- if (race === 'random') {
608
- const seed = (Date.now() ^ (Math.random() * 0x7fffffff)) >>> 0;
609
- const bot = pickRandomWorldBot(ds, seed);
610
- done(newGameStateFromWorldBot(ds, bot, seed));
611
- return;
612
- }
613
- const classes = race.classes.filter((c) => ds.createInfo(race.id, c));
614
- app.pick(`${race.name}: choose a class`, classes.map((c) => ({ label: className(c), value: c })), (cls) => {
615
- app.pick('Gender', [{ label: 'Male', value: 0 }, { label: 'Female', value: 1 }], (gender) => {
616
- const askName = () => app.prompt('Name (2-12 letters): ', (raw) => {
617
- const name = raw.trim();
618
- if (!/^[A-Za-z]{2,12}$/.test(name)) { app.flashMsg('Letters only, 2 to 12 of them.'); askName(); return; }
619
- const seed = (Date.now() ^ (Math.random() * 0x7fffffff)) >>> 0;
620
- done(newGameState(ds, race.id, cls, name[0].toUpperCase() + name.slice(1).toLowerCase(), seed, gender));
621
- });
622
- askName();
623
- });
624
- });
625
- });
626
- }
780
+ export { characterCreation } from './tuiCreate.mjs';