azeroth-shell 0.1.2 → 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.
- package/README.md +326 -82
- package/bin/azsh.mjs +21 -19
- package/package.json +9 -1
- package/src/autopilot/plan.ts +8 -11
- package/src/autopilot/view.ts +12 -11
- package/src/cli/combatLogFormat.ts +22 -0
- package/src/cli/gzLoader.mjs +47 -6
- package/src/cli/hdArt.mjs +81 -0
- package/src/cli/iconArt.mjs +135 -0
- package/src/cli/iconGrid.mjs +100 -0
- package/src/cli/modelArt.mjs +295 -0
- package/src/cli/paperdoll.mjs +76 -0
- package/src/cli/perfCheck.mjs +56 -0
- package/src/cli/repl.mjs +32 -7
- package/src/cli/rotationText.mjs +6 -6
- package/src/cli/sixel.mjs +307 -0
- package/src/cli/term.mjs +106 -12
- package/src/cli/text.mjs +5 -3
- package/src/cli/tui.mjs +231 -95
- package/src/cli/tuiAutopilot.mjs +347 -0
- package/src/cli/tuiBg.mjs +370 -41
- package/src/cli/tuiCheck.mjs +362 -27
- package/src/cli/tuiCity.mjs +118 -13
- package/src/cli/tuiCompanions.mjs +25 -15
- package/src/cli/tuiCreate.mjs +223 -0
- package/src/cli/tuiDungeon.mjs +1 -1
- package/src/cli/tuiFight.mjs +423 -86
- package/src/cli/tuiFightPanes.mjs +27 -8
- package/src/cli/tuiFloor.mjs +1 -1
- package/src/cli/tuiGear.mjs +10 -6
- package/src/cli/tuiLoot.mjs +1 -0
- package/src/cli/tuiPaperdoll.mjs +107 -0
- package/src/cli/tuiPartyStage.mjs +177 -0
- package/src/cli/tuiProfessions.mjs +4 -2
- package/src/cli/tuiRotationCond.mjs +390 -0
- package/src/cli/tuiSaves.mjs +10 -1
- package/src/cli/tuiSearch.mjs +75 -22
- package/src/cli/tuiStage.mjs +304 -0
- package/src/cli/tuiTalentTree.mjs +124 -0
- package/src/cli/tuiViews.mjs +284 -110
- package/src/cli/tuiWorld.mjs +2 -2
- package/src/data/gear-art.bin.br +0 -0
- package/src/data/hd-art.json +114 -0
- package/src/data/icon-art.bin.gz +0 -0
- package/src/data/model-art.bin.gz +0 -0
- package/src/data/rotations/pve/paladin/retribution.json +2 -2
- package/src/domain/bg/match.ts +85 -24
- package/src/domain/bg/strategies.ts +144 -29
- package/src/domain/bg/types.ts +2 -0
- package/src/domain/bg/wsg.ts +35 -20
- package/src/domain/quests/doQuest.ts +24 -6
- package/src/domain/rotation/build.ts +3031 -3031
- package/src/domain/rotation/classes/paladin.ts +16 -11
- package/src/domain/rotation/evaluate.ts +3 -0
- package/src/engine/combat/applySpell.ts +20 -9
- package/src/engine/combat/applySpellLate.ts +16 -12
- package/src/engine/combat/areaAura.ts +284 -283
- package/src/engine/combat/fighterFactory.ts +1 -1
- package/src/engine/combat/pickAction.ts +39 -1
- package/src/engine/combat/procsDamage.ts +31 -1
- package/src/engine/combat/sessionChase.ts +7 -1
- package/src/engine/combat/sessionMotionKit.ts +40 -2
- 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,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,
|
|
27
|
-
import {
|
|
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
|
|
47
|
-
'
|
|
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
|
|
54
|
-
'
|
|
55
|
-
'
|
|
56
|
-
'
|
|
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
|
|
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',
|
|
@@ -112,6 +125,43 @@ export class App {
|
|
|
112
125
|
this.lastActivity = null;
|
|
113
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(); } };
|
|
114
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
|
+
});
|
|
115
165
|
}
|
|
116
166
|
|
|
117
167
|
// ---- game lifecycle ---------------------------------------------------------------------------
|
|
@@ -124,7 +174,9 @@ export class App {
|
|
|
124
174
|
this.slot = slot; this.createdAt = createdAt ?? Date.now();
|
|
125
175
|
const awayMs = updatedAt > 0 ? Math.max(0, Date.now() - updatedAt) : 0;
|
|
126
176
|
// Travel pace is a setting (instant by default, like phonecraft); `skipping` lets Arrive now land a trip at any pace.
|
|
127
|
-
|
|
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) });
|
|
128
180
|
this.g = g;
|
|
129
181
|
if (g.state.char.journey) { if (this.prefs.get('travelPace') === 'instant') this.arriveNow(); else g.journeyTick(); }
|
|
130
182
|
// Real createInfo has no starting weapon (ETL gap KI-003); phonecraft's harness hands one over too.
|
|
@@ -268,25 +320,49 @@ export class App {
|
|
|
268
320
|
if (!this.stack.length) { this.fight?.onOrdersClosed?.(); this.render(); }
|
|
269
321
|
return;
|
|
270
322
|
}
|
|
271
|
-
if (this.fight
|
|
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
|
+
}
|
|
272
328
|
if (this.auto) {
|
|
273
|
-
// The run's
|
|
274
|
-
//
|
|
275
|
-
|
|
276
|
-
|
|
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; }
|
|
277
350
|
if (k === 'l') { this.openLog(); return; }
|
|
278
|
-
if (k === 'w') { auto.
|
|
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
|
|
279
354
|
if (k === '+' || k === '=') { this.fightSpeed = Math.min(8, this.fightSpeed * 2); this.render(); return; }
|
|
280
355
|
if (k === '-') { this.fightSpeed = Math.max(0.5, this.fightSpeed / 2); this.render(); return; }
|
|
281
356
|
if (/^[0-9]$/.test(k)) { auto.browsing = true; this.switchTab(digitTab(k)); return; }
|
|
282
|
-
|
|
283
|
-
if (k === '
|
|
284
|
-
if (
|
|
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; }
|
|
285
361
|
return;
|
|
286
362
|
}
|
|
287
|
-
if (!this.g)
|
|
363
|
+
if (!this.g) { if (!this.viewKey(k) && k === 'escape' && this.stack.length > 1) this.pop(); return; }
|
|
288
364
|
if (k === ':') { this.prompt(':', (line) => this.runCommand(line)); return; }
|
|
289
|
-
if (k === '?') { this.push(
|
|
365
|
+
if (k === '?') { this.push(this.helpView()); return; }
|
|
290
366
|
if (k === 'l' && this.top()?.id !== 'log' && !this.top()?.keys?.l) { this.openLog(); return; }
|
|
291
367
|
if (this.isDead() && this.top()?.id !== 'death') { this.push(deathView(this)); return; }
|
|
292
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; }
|
|
@@ -322,11 +398,20 @@ export class App {
|
|
|
322
398
|
if (row?.keys?.[k]) { row.keys[k](this, row); this.afterAction(); return true; }
|
|
323
399
|
if (view.keys?.[k]) { view.keys[k].fn(this, row); this.afterAction(); return true; }
|
|
324
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
|
+
}
|
|
325
410
|
switch (k) {
|
|
326
411
|
case 'up': case 'k': this.moveCursor(view, rows, -1); return true;
|
|
327
412
|
case 'down': case 'j': this.moveCursor(view, rows, +1); return true;
|
|
328
413
|
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 =
|
|
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; }
|
|
330
415
|
case 'pageup': case 'pagedown': this.pageView(view, rows, k === 'pageup' ? -1 : +1); return true;
|
|
331
416
|
case 'return': case 'space': if (row && !row.header && row.act) { row.act(this, row); this.afterAction(); } return true;
|
|
332
417
|
default: return false;
|
|
@@ -339,30 +424,48 @@ export class App {
|
|
|
339
424
|
if (i >= 0 && i < rows.length) { view.cursor = i; this.ensureVisible(view, rows); }
|
|
340
425
|
else {
|
|
341
426
|
// No selectable row that way: scroll the pane instead so long text can be read.
|
|
342
|
-
const
|
|
427
|
+
const L = this.layout();
|
|
428
|
+
const maxScroll = this.maxScrollOf(rows, L.mainH, this.listWOf(view, L));
|
|
343
429
|
view.scroll = Math.max(0, Math.min(maxScroll, view.scroll + delta));
|
|
344
430
|
}
|
|
345
431
|
this.render();
|
|
346
432
|
}
|
|
347
433
|
/** Scroll the pane by a page; the cursor follows onto a selectable row inside the new window when there is one. */
|
|
348
434
|
pageView(view, rows, dir) {
|
|
349
|
-
const
|
|
350
|
-
const maxScroll =
|
|
435
|
+
const L = this.layout(), h = L.mainH, listW = this.listWOf(view, L);
|
|
436
|
+
const maxScroll = this.maxScrollOf(rows, h, listW);
|
|
351
437
|
view.scroll = Math.max(0, Math.min(maxScroll, (view.scroll ?? 0) + dir * Math.max(1, h - 1)));
|
|
352
|
-
const
|
|
438
|
+
const end = this.windowEnd(rows, view.scroll, h, listW);
|
|
439
|
+
const inWindow = (i) => i >= view.scroll && i < end;
|
|
353
440
|
if (!inWindow(view.cursor)) {
|
|
354
|
-
const from = dir > 0 ? view.scroll :
|
|
441
|
+
const from = dir > 0 ? view.scroll : end - 1;
|
|
355
442
|
let i = from;
|
|
356
|
-
while (i >= view.scroll && i <
|
|
443
|
+
while (i >= view.scroll && i < end && rows[i]?.header) i += dir;
|
|
357
444
|
if (inWindow(i) && rows[i] && !rows[i].header) view.cursor = i;
|
|
358
445
|
}
|
|
359
446
|
this.render();
|
|
360
447
|
}
|
|
361
448
|
ensureVisible(view, rows) {
|
|
362
|
-
const
|
|
449
|
+
const L = this.layout(), h = L.mainH, listW = this.listWOf(view, L);
|
|
363
450
|
if (view.cursor < view.scroll) view.scroll = view.cursor;
|
|
364
|
-
|
|
365
|
-
view.scroll = Math.max(0, Math.min(view.scroll,
|
|
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;
|
|
366
469
|
}
|
|
367
470
|
rowsOf(view) {
|
|
368
471
|
const rows = view.rows(this) ?? [];
|
|
@@ -399,7 +502,15 @@ export class App {
|
|
|
399
502
|
const [, cmd = '', args = ''] = line.match(/^(\S+)\s*(.*)$/) ?? [];
|
|
400
503
|
const c = cmd.toLowerCase();
|
|
401
504
|
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
|
|
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
|
+
}
|
|
403
514
|
if (c === 'autoloot') {
|
|
404
515
|
const want = args.trim().toLowerCase();
|
|
405
516
|
if (want === 'on' || want === 'off') this.prefs.set('autoLoot', want === 'on');
|
|
@@ -451,10 +562,11 @@ export class App {
|
|
|
451
562
|
|
|
452
563
|
layout() {
|
|
453
564
|
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
|
|
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
|
|
456
567
|
const mainH = H - top - 1 - logH - 1;
|
|
457
|
-
|
|
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;
|
|
458
570
|
const listW = detailW ? W - detailW - 1 : W;
|
|
459
571
|
return { W, H, logH, top, mainH, listW, detailW };
|
|
460
572
|
}
|
|
@@ -474,7 +586,7 @@ export class App {
|
|
|
474
586
|
const L = this.layout();
|
|
475
587
|
const lines = [];
|
|
476
588
|
lines.push(...this.headerLines(L));
|
|
477
|
-
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));
|
|
478
590
|
else if (this.auto && !this.auto.browsing && !this.stack.length) lines.push(...this.auto.render(L.W, L.mainH));
|
|
479
591
|
else lines.push(...this.mainLines(L));
|
|
480
592
|
while (lines.length < L.top + L.mainH) lines.push('');
|
|
@@ -488,8 +600,11 @@ export class App {
|
|
|
488
600
|
|
|
489
601
|
headerLines(L) {
|
|
490
602
|
if (!this.g) {
|
|
491
|
-
|
|
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))];
|
|
492
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))];
|
|
493
608
|
const ch = this.ch, ds = this.ds;
|
|
494
609
|
const z = this.zone();
|
|
495
610
|
const dead = this.isDead() ? red(bold(' DEAD')) : ch.mountedItem != null ? dim(' mounted') : '';
|
|
@@ -497,26 +612,43 @@ export class App {
|
|
|
497
612
|
const road = journeyHeader(this);
|
|
498
613
|
const right = `${road ? yellow(road) : cyan(z?.name ?? ch.zoneId)} ${yellow(moneyText(ch.gold) || '0c')} `;
|
|
499
614
|
const l0 = fit(title, L.W - vis(right)) + right;
|
|
500
|
-
|
|
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);
|
|
501
619
|
const xpMax = ds.xpToNext(ch.level);
|
|
502
620
|
const seg = Math.max(8, Math.floor((L.W - 50) / 3));
|
|
503
|
-
const l1 = ` ${green('HP')} ${bar(
|
|
504
|
-
+ ` ${(POWER_PAINT[pt] ?? cyan)(pt === 'mana' ? 'MP' : pt === 'rage' ? 'RG' : 'EN')} ${bar(
|
|
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)}`
|
|
505
623
|
+ ` ${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
|
|
507
|
-
//
|
|
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.
|
|
508
628
|
const digit = (i) => (i < 9 ? String(i + 1) : i === 9 ? '0' : '');
|
|
509
|
-
const tabBar = (short) => this.tabs.map((t, i) => {
|
|
629
|
+
const tabBar = (short, icons) => (icons && sixelOn() ? ' ' : '') + this.tabs.map((t, i) => {
|
|
510
630
|
const label = short ? (t.short ?? t.label) : t.label, d = digit(i);
|
|
511
|
-
|
|
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;
|
|
512
634
|
}).join('');
|
|
513
635
|
const crumbs = this.stack.map((v) => ` › ${bold(v.title)}`).join('');
|
|
514
|
-
let tabs = tabBar(false);
|
|
515
|
-
if (vis(tabs + crumbs) > L.W) tabs = tabBar(
|
|
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);
|
|
516
638
|
if (vis(tabs + crumbs) > L.W && this.stack.length) { const d = digit(this.tab); tabs = ` ${d ? `${dim(d)} ` : ''}${this.tabs[this.tab]?.label ?? ''}`; }
|
|
517
|
-
|
|
639
|
+
const strip = this.showsRunStrip() ? [runStripLine(this, L.W)] : [];
|
|
640
|
+
return [l0, l1, cut(tabs + crumbs, L.W), ...strip, dim('─'.repeat(L.W))];
|
|
518
641
|
}
|
|
519
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
|
+
|
|
520
652
|
mainLines(L) {
|
|
521
653
|
const view = this.top();
|
|
522
654
|
const out = [];
|
|
@@ -524,26 +656,40 @@ export class App {
|
|
|
524
656
|
const rows = this.rowsOf(view);
|
|
525
657
|
const showDetail = L.detailW > 0 && !view.noDetail;
|
|
526
658
|
const listW = showDetail ? L.listW : L.W;
|
|
527
|
-
const maxScroll =
|
|
659
|
+
const maxScroll = this.maxScrollOf(rows, L.mainH, listW);
|
|
528
660
|
view.scroll = Math.max(0, Math.min(view.scroll ?? 0, maxScroll));
|
|
529
|
-
|
|
530
|
-
|
|
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++) {
|
|
531
667
|
const r = rows[i];
|
|
532
668
|
const sel = i === view.cursor && !r.header;
|
|
533
|
-
|
|
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}`;
|
|
534
680
|
text = fit(sel ? `${'▶'}${text.slice(1)}` : text, listW - 1);
|
|
535
681
|
listLines.push(sel ? inverse(text) : text);
|
|
536
682
|
}
|
|
537
683
|
// A cursor row scrolled out of view is echoed on the last line so Enter is never a mystery.
|
|
538
684
|
const cursorRow = rows[view.cursor];
|
|
539
|
-
if (cursorRow && !cursorRow.header && (view.cursor < view.scroll || view.cursor >=
|
|
685
|
+
if (!grid && cursorRow && !cursorRow.header && (view.cursor < view.scroll || view.cursor >= end) && listLines.length === L.mainH) {
|
|
540
686
|
listLines[L.mainH - 1] = inverse(fit(` ${view.cursor < view.scroll ? '▲' : '▼'} ${cursorRow.text}`, listW - 1));
|
|
541
687
|
}
|
|
542
688
|
if (!rows.length && view.empty) listLines.push(dim(` ${view.empty}`));
|
|
543
689
|
let detailLines = [];
|
|
544
690
|
if (showDetail) {
|
|
545
691
|
const d = cursorRow?.detail?.(this, cursorRow) ?? view.detail?.(this, cursorRow) ?? [];
|
|
546
|
-
|
|
692
|
+
detailLines = wrapIconLines(d, L.detailW - 2);
|
|
547
693
|
if (detailLines.length > L.mainH) detailLines = detailLines.slice(0, L.mainH - 1).concat(dim('…'));
|
|
548
694
|
}
|
|
549
695
|
for (let i = 0; i < L.mainH; i++) {
|
|
@@ -566,23 +712,43 @@ export class App {
|
|
|
566
712
|
hintLine(L) {
|
|
567
713
|
if (this.cmd) return ` ${bold(this.cmd.label)}${this.cmd.text}${inverse(' ')}`;
|
|
568
714
|
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
|
|
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 ?.
|
|
571
723
|
const view = this.top();
|
|
572
|
-
const
|
|
573
|
-
if (
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
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'];
|
|
577
731
|
return ` ${dim(cut(hints.join(' '), L.W - 2))}`;
|
|
578
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
|
+
}
|
|
579
740
|
}
|
|
580
741
|
|
|
581
742
|
// ---- boot ----------------------------------------------------------------------------------------
|
|
582
743
|
|
|
583
744
|
/** The save-pick screen, then the game. Resolves when the player quits. */
|
|
584
745
|
export async function runTui({ term = new RealTerm(), argv = [] } = {}) {
|
|
585
|
-
|
|
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);
|
|
586
752
|
const repo = new FileSaveRepository();
|
|
587
753
|
const app = new App({ term, ds, repo, prefs: new FilePrefs() });
|
|
588
754
|
// Engine diagnostics (console.warn from the combat layer) would print over the frame: while the screen is
|
|
@@ -591,7 +757,6 @@ export async function runTui({ term = new RealTerm(), argv = [] } = {}) {
|
|
|
591
757
|
const quiet = (...a) => { if (process.env.AZSH_DEBUG) app.say(dim(a.map(String).join(' '))); };
|
|
592
758
|
Object.assign(console, { log: quiet, warn: quiet, info: quiet, debug: quiet });
|
|
593
759
|
app.restoreConsole = () => Object.assign(console, saved);
|
|
594
|
-
term.open();
|
|
595
760
|
app.render();
|
|
596
761
|
const saves = await repo.list();
|
|
597
762
|
const begin = async (save) => {
|
|
@@ -606,39 +771,10 @@ export async function runTui({ term = new RealTerm(), argv = [] } = {}) {
|
|
|
606
771
|
app.startGame(state, `${state.char.name.toLowerCase()}-${Date.now().toString(36)}`, Date.now(), true);
|
|
607
772
|
void app.persist();
|
|
608
773
|
app.render();
|
|
609
|
-
});
|
|
774
|
+
}, { onCancel: saves.length ? reopenPicker : null });
|
|
610
775
|
if (saves.length && !argv.includes('--new')) await reopenPicker();
|
|
611
776
|
else createFlow();
|
|
612
777
|
return new Promise((resolve) => { app.onQuit = resolve; });
|
|
613
778
|
}
|
|
614
779
|
|
|
615
|
-
export
|
|
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
|
-
}
|
|
780
|
+
export { characterCreation } from './tuiCreate.mjs';
|