azeroth-shell 0.2.0 → 0.4.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/CHANGELOG.md +53 -0
- package/README.md +43 -10
- package/bin/azsh.mjs +15 -1
- package/package.json +7 -3
- package/src/cli/hdArt.mjs +1 -1
- package/src/cli/iconArt.mjs +11 -1
- package/src/cli/modelArt.mjs +7 -4
- package/src/cli/oocPrep.mjs +32 -0
- package/src/cli/repl.mjs +16 -0
- package/src/cli/sixel.mjs +26 -14
- package/src/cli/text.mjs +16 -0
- package/src/cli/tui.mjs +40 -13
- package/src/cli/tuiAutopilot.mjs +17 -8
- package/src/cli/tuiBags.mjs +37 -4
- package/src/cli/tuiBulk.mjs +83 -4
- package/src/cli/tuiCheck.mjs +328 -19
- package/src/cli/tuiCity.mjs +31 -31
- package/src/cli/tuiCompanionKit.mjs +26 -8
- package/src/cli/tuiCompanions.mjs +189 -29
- package/src/cli/tuiCreate.mjs +7 -0
- package/src/cli/tuiDungeon.mjs +113 -33
- package/src/cli/tuiFight.mjs +63 -21
- package/src/cli/tuiFightPanes.mjs +15 -6
- package/src/cli/tuiFloor.mjs +20 -8
- package/src/cli/tuiGear.mjs +9 -10
- package/src/cli/tuiLoot.mjs +5 -6
- package/src/cli/tuiMap.mjs +214 -0
- package/src/cli/tuiPaperdoll.mjs +6 -5
- package/src/cli/tuiPartyStage.mjs +36 -7
- package/src/cli/tuiPet.mjs +16 -8
- package/src/cli/tuiProfessions.mjs +23 -23
- package/src/cli/tuiSaves.mjs +14 -0
- package/src/cli/tuiSearch.mjs +4 -5
- package/src/cli/tuiSettings.mjs +3 -4
- package/src/cli/tuiStage.mjs +72 -16
- package/src/cli/tuiTalents.mjs +3 -4
- package/src/cli/tuiViews.mjs +374 -85
- package/src/cli/tuiWiki.mjs +2 -3
- package/src/cli/tuiWorld.mjs +88 -5
- package/src/data/gear-art.bin.br +0 -0
- package/src/data/hd-art.json +36 -36
- package/src/data/model-art-worn.bin.br +0 -0
- package/src/data/model-art.bin.gz +0 -0
- package/src/data/tables/creatures.json.gz +0 -0
- package/src/data/world-map-sixel.bin.br +0 -0
- package/src/data/world-map.bin.gz +0 -0
- package/src/domain/character/character.ts +6 -0
- package/src/domain/pet/pet.ts +31 -0
- package/src/engine/combat/pickAction.ts +3494 -3487
- package/src/engine/combat/sessionChase.ts +12 -1
- package/src/engine/game/loadReconcile.ts +21 -1
- package/src/engine/game/pets.ts +33 -1
- package/src/engine/game/professions.ts +4 -0
- package/src/engine/game/squadQuestShare.ts +13 -1
- package/src/engine/game/types.ts +4 -0
package/src/cli/tui.mjs
CHANGED
|
@@ -9,7 +9,7 @@ import { createCommands } from './repl.mjs';
|
|
|
9
9
|
import { makeTabs, deathView } from './tuiViews.mjs';
|
|
10
10
|
import { cityAccess, bankDialog, auctionDialog, mailDialog } from './tuiCity.mjs';
|
|
11
11
|
import { startFight, startAutopilot } from './tuiFight.mjs';
|
|
12
|
-
import { openRun } from './tuiDungeon.mjs';
|
|
12
|
+
import { openRun, runCleared, holdRunLonger } from './tuiDungeon.mjs';
|
|
13
13
|
import { openLoot } from './tuiLoot.mjs';
|
|
14
14
|
import { fightPaneRule, fightPaneLines, cycleFightPane, nextPaneId, fightPane } from './tuiFightPanes.mjs';
|
|
15
15
|
import { MemoryPrefs, FilePrefs } from './prefs.mjs';
|
|
@@ -24,7 +24,7 @@ import { honorDialog, weeklyHonorPass } from './tuiHonor.mjs';
|
|
|
24
24
|
import { devCommand } from './tuiDev.mjs';
|
|
25
25
|
import { savePicker } from './tuiSaves.mjs';
|
|
26
26
|
import { wikiDialog } from './tuiWiki.mjs';
|
|
27
|
-
import { bold, dim, cyan, yellow, green, red, inverse, fit, vis, cut, bar, pct, brightYellow, magenta, brightBlue, strip, qualityPaint } from './text.mjs';
|
|
27
|
+
import { bold, dim, cyan, yellow, green, red, inverse, fit, vis, cut, bar, pct, brightYellow, magenta, brightBlue, strip, qualityPaint, coinText } from './text.mjs';
|
|
28
28
|
import { iconsOn, iconArt, iconBlank, wrapIconLines } from './iconArt.mjs';
|
|
29
29
|
import { createGame } from '../engine/game.ts';
|
|
30
30
|
import { toSave } from '../persistence/saveShape.ts';
|
|
@@ -33,7 +33,7 @@ import { characterCreation } from './tuiCreate.mjs';
|
|
|
33
33
|
import { missingHdBytes, downloadHdArt, downloadDir } from './hdArt.mjs';
|
|
34
34
|
import { forgetHdArt } from './modelArt.mjs';
|
|
35
35
|
import { sixelOn } from './sixel.mjs';
|
|
36
|
-
import {
|
|
36
|
+
import { coinsText } from '../domain/economy/money.ts';
|
|
37
37
|
import { maxHealth, maxPower } from '../domain/character/character.ts';
|
|
38
38
|
import { powerType } from '../domain/character/characterStats.ts';
|
|
39
39
|
import { addItem } from '../domain/items/inventory.ts';
|
|
@@ -55,6 +55,7 @@ const HELP_LINES = [
|
|
|
55
55
|
' a in the search (or S on World) widens it to the whole world. Enter goes or opens.',
|
|
56
56
|
' z sit and rest 30 s ? this help',
|
|
57
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',
|
|
58
59
|
'',
|
|
59
60
|
'In the world',
|
|
60
61
|
' Enter on a creature starts a live fight; f fights it instantly. Enter on a quest, vendor, trainer or',
|
|
@@ -75,22 +76,24 @@ const HELP_LINES = [
|
|
|
75
76
|
' W walks the floor instead: pick a place, the party walks the nav mesh, patrols walk, and what sees you pulls.',
|
|
76
77
|
'',
|
|
77
78
|
'Gear and bags',
|
|
78
|
-
'
|
|
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.',
|
|
79
81
|
' ▲ marks an upgrade the equipment helper found; u puts on every upgrade at once, x takes a piece off.',
|
|
80
82
|
' 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
|
|
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.',
|
|
82
84
|
' Bags tab: Enter uses, equips, opens, unwraps or learns; m lists every verb for the stack. s sells to the open',
|
|
83
85
|
' vendor, j sells junk, b opens the bank, d destroys, S sorts, / splits a stack, [ ] move it, A uses the whole stack.',
|
|
84
86
|
' D lists every green, blue or epic the group can disenchant; L lists every book someone can learn. G runs it.',
|
|
87
|
+
' X marks several stacks to destroy at once; Q marks the quest items left over from quests you turned in. G destroys.',
|
|
85
88
|
' :dev item|quest|recipe|prof skips the rules (testing). New character offers a random world bot.',
|
|
86
|
-
' :settings sets auto-loot, the upgrade helper, auto-disenchant, the junk rules and which nodes get gathered.',
|
|
89
|
+
' Settings tab (or :settings) sets auto-loot, the upgrade helper, auto-disenchant, the junk rules and which nodes get gathered.',
|
|
87
90
|
' 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.',
|
|
88
91
|
' Loot: with :autoloot off a corpse opens the loot window. Enter takes a row, a takes all, Esc leaves the rest.',
|
|
89
92
|
'',
|
|
90
93
|
'In a fight',
|
|
91
94
|
' 1-9 press an ability a toggle auto-play space pause + - speed l log Esc run away',
|
|
92
95
|
' t gives a companion an order: attack a target, cast one ability, or hold ground (the fight waits).',
|
|
93
|
-
' c cancels one of your buffs (also from the Character tab, out of a fight).',
|
|
96
|
+
' c cancels one of your buffs (also from s on the Character tab, out of a fight).',
|
|
94
97
|
' r (in a fight you started with Enter) fights the same creature again after each win, until r again or Esc.',
|
|
95
98
|
' The ability bar runs along the bottom of the stage with its cooldowns; a takes the keys off the rotation.',
|
|
96
99
|
'',
|
|
@@ -188,10 +191,11 @@ export class App {
|
|
|
188
191
|
this.tabs = makeTabs(this);
|
|
189
192
|
this.stack = [];
|
|
190
193
|
this.lastActivity = g.state.activity[0] ?? null;
|
|
194
|
+
this.inRunId = g.state.instance?.dungeonId ?? null; // holdRunLonger's last seen run, per character
|
|
191
195
|
this.say(bold(`Welcome${isNew ? '' : ' back'}, ${g.state.char.name}.`) + dim(' Press ? for the keys.'));
|
|
192
196
|
if (awayMs >= 60_000) this.offlineRest(Math.floor(awayMs / 1000));
|
|
193
197
|
weeklyHonorPass(this); // a maintenance day passed while away scores that week
|
|
194
|
-
if (g.state.instance) openRun(this);
|
|
198
|
+
if (g.state.instance && !runCleared(this)) openRun(this);
|
|
195
199
|
openLoot(this); // an opened bag item's remainder is still claimable after a reload
|
|
196
200
|
if (!this.term.isTTY) return;
|
|
197
201
|
// Out-of-combat regeneration ticks in real time, like standing still in the world.
|
|
@@ -239,6 +243,22 @@ export class App {
|
|
|
239
243
|
this.onQuit?.();
|
|
240
244
|
}
|
|
241
245
|
|
|
246
|
+
/** Save, let go of the character and go back to the character picker. Not in the middle of a fight or a run. */
|
|
247
|
+
async logout() {
|
|
248
|
+
if (!this.g) return;
|
|
249
|
+
if (this.fight || this.auto) { this.flashMsg('Finish the fight or stop the run first.'); this.render(); return; }
|
|
250
|
+
for (const t of this.timers) this.term.clearInterval(t);
|
|
251
|
+
this.timers = [];
|
|
252
|
+
await this.persist();
|
|
253
|
+
if (this.slot != null) this.repo.unlock?.(this.slot);
|
|
254
|
+
this.say(dim(`Saved ${this.g.state.char.name}.`));
|
|
255
|
+
this.g = null; this.slot = null; this.cmds = null; this.vendor = null;
|
|
256
|
+
this.tabs = []; this.tab = 0; this.stack = [];
|
|
257
|
+
this.meter = null; this.lootTally = null; this.lastActivity = null;
|
|
258
|
+
await this.toPicker?.();
|
|
259
|
+
this.render();
|
|
260
|
+
}
|
|
261
|
+
|
|
242
262
|
// ---- small helpers the views lean on -------------------------------------------------------------
|
|
243
263
|
|
|
244
264
|
get ch() { return this.g.state.char; }
|
|
@@ -291,7 +311,7 @@ export class App {
|
|
|
291
311
|
pick(title, options, onPick, { hint, keepOpen } = {}) {
|
|
292
312
|
const view = {
|
|
293
313
|
id: 'pick', title, hint: hint ?? 'Enter: choose Esc: back',
|
|
294
|
-
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); } })),
|
|
314
|
+
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); } })),
|
|
295
315
|
noDetail: !options.some((o) => o.detail),
|
|
296
316
|
};
|
|
297
317
|
this.push(view);
|
|
@@ -481,6 +501,7 @@ export class App {
|
|
|
481
501
|
/** After any state-changing key: pull activity, save, repaint. */
|
|
482
502
|
afterAction() {
|
|
483
503
|
journeyTick(this);
|
|
504
|
+
holdRunLonger(this);
|
|
484
505
|
this.flushActivity();
|
|
485
506
|
if (this.g) void this.persist();
|
|
486
507
|
if (this.g && this.isDead() && this.top()?.id !== 'death' && !this.fight) this.push(deathView(this));
|
|
@@ -502,6 +523,7 @@ export class App {
|
|
|
502
523
|
const [, cmd = '', args = ''] = line.match(/^(\S+)\s*(.*)$/) ?? [];
|
|
503
524
|
const c = cmd.toLowerCase();
|
|
504
525
|
if (c === 'quit' || c === 'exit' || c === 'q') { await this.quit(); return; }
|
|
526
|
+
if (c === 'logout' || c === 'characters') { await this.logout(); return; }
|
|
505
527
|
if (c === 'auto') { const m = args.match(/^(\d+)?\s*(?:farm\s+(.+))?$/i); startAutopilot(this, m?.[1] ? Number(m[1]) : Infinity, m?.[2]); return; }
|
|
506
528
|
if (c === 'art') {
|
|
507
529
|
const missing = missingHdBytes();
|
|
@@ -601,7 +623,7 @@ export class App {
|
|
|
601
623
|
headerLines(L) {
|
|
602
624
|
if (!this.g) {
|
|
603
625
|
const crumbs = this.stack.map((v) => bold(v.title ?? '')).join(dim(' › '));
|
|
604
|
-
return [bold(' azsh') + dim(' Azeroth
|
|
626
|
+
return [bold(' azsh') + dim(' Azeroth Shell'), crumbs ? cut(` ${crumbs}`, L.W) : '', '', dim('─'.repeat(L.W))];
|
|
605
627
|
}
|
|
606
628
|
// 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
629
|
if (this.compactHeader()) return [runHeaderLine(this, L.W), dim('─'.repeat(L.W))];
|
|
@@ -610,7 +632,7 @@ export class App {
|
|
|
610
632
|
const dead = this.isDead() ? red(bold(' DEAD')) : ch.mountedItem != null ? dim(' mounted') : '';
|
|
611
633
|
const title = ` ${bold(ch.name)} ${dim(`L${ch.level} ${raceName(ch.race)} ${className(ch.cls)}`)}${dead}`;
|
|
612
634
|
const road = journeyHeader(this);
|
|
613
|
-
const right = `${road ? yellow(road) : cyan(z?.name ?? ch.zoneId)} ${
|
|
635
|
+
const right = `${road ? yellow(road) : cyan(z?.name ?? ch.zoneId)} ${coinText(ch.gold)} `;
|
|
614
636
|
const l0 = fit(title, L.W - vis(right)) + right;
|
|
615
637
|
// In a fight the bars follow the live session, so the header is the player's frame and no panel needs another.
|
|
616
638
|
const live = this.fight?.session?.playerView?.() ?? null;
|
|
@@ -690,7 +712,11 @@ export class App {
|
|
|
690
712
|
if (showDetail) {
|
|
691
713
|
const d = cursorRow?.detail?.(this, cursorRow) ?? view.detail?.(this, cursorRow) ?? [];
|
|
692
714
|
detailLines = wrapIconLines(d, L.detailW - 2);
|
|
693
|
-
|
|
715
|
+
// A view's foot (the Character tab's stats) sits at the bottom of the pane when the tooltip keeps room above it.
|
|
716
|
+
const foot = view.detailFoot ? wrapIconLines(view.detailFoot(this, L.detailW - 2), L.detailW - 2) : [];
|
|
717
|
+
const room = foot.length && L.mainH - foot.length >= 16 ? L.mainH - foot.length - 1 : L.mainH;
|
|
718
|
+
if (detailLines.length > room) detailLines = detailLines.slice(0, room - 1).concat(dim('…'));
|
|
719
|
+
if (room < L.mainH) detailLines = [...detailLines, ...Array(L.mainH - foot.length - detailLines.length).fill(''), ...foot];
|
|
694
720
|
}
|
|
695
721
|
for (let i = 0; i < L.mainH; i++) {
|
|
696
722
|
const left = fit(listLines[i] ?? '', listW);
|
|
@@ -745,7 +771,7 @@ export class App {
|
|
|
745
771
|
export async function runTui({ term = new RealTerm(), argv = [] } = {}) {
|
|
746
772
|
term.open();
|
|
747
773
|
// 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
|
|
774
|
+
const loading = (stage) => term.draw([bold(' azsh') + dim(' Azeroth Shell'), '', dim(` Loading ${stage}…`)]);
|
|
749
775
|
loading('the game');
|
|
750
776
|
await new Promise((r) => setImmediate(r)); // the first frame leaves before the synchronous load holds the loop
|
|
751
777
|
const ds = bootDataStore(loading);
|
|
@@ -766,6 +792,7 @@ export async function runTui({ term = new RealTerm(), argv = [] } = {}) {
|
|
|
766
792
|
app.render();
|
|
767
793
|
};
|
|
768
794
|
const reopenPicker = () => savePicker(app, { onPlay: (save) => { void begin(save); }, onNew: createFlow });
|
|
795
|
+
app.toPicker = reopenPicker;
|
|
769
796
|
const createFlow = () => characterCreation(app, (state) => {
|
|
770
797
|
app.stack = [];
|
|
771
798
|
app.startGame(state, `${state.char.name.toLowerCase()}-${Date.now().toString(36)}`, Date.now(), true);
|
package/src/cli/tuiAutopilot.mjs
CHANGED
|
@@ -2,12 +2,12 @@
|
|
|
2
2
|
// you browse, the run panel between fights (now, next, quests, this run's numbers, what stops it), the Run
|
|
3
3
|
// pane at the bottom (one row per planner step) and the end card. The run itself is the controller in
|
|
4
4
|
// tuiFight.mjs (startAutopilot); the planner is repl.mjs createAutopilot. Everything here only reads.
|
|
5
|
-
import { bold, dim, cyan, yellow, green, red, magenta, inverse, fit, vis, cut, bar, strip, pct, secondsText, wrapLines } from './text.mjs';
|
|
5
|
+
import { bold, dim, cyan, yellow, green, red, magenta, inverse, fit, vis, cut, bar, strip, pct, secondsText, wrapLines, coinText } from './text.mjs';
|
|
6
6
|
import { idlePlayerFrame, startAutopilot, fightArt, flankArt } from './tuiFight.mjs';
|
|
7
7
|
import { objectiveLines, questDialog } from './tuiViews.mjs';
|
|
8
8
|
import { autopilotSettingsDialog } from './tuiAutopilotSettings.mjs';
|
|
9
9
|
import { squadModeOf } from './squad.mjs';
|
|
10
|
-
import {
|
|
10
|
+
import { iconsOn, iconArt, iconBlank } from './iconArt.mjs';
|
|
11
11
|
import { maxHealth, maxPower } from '../domain/character/character.ts';
|
|
12
12
|
|
|
13
13
|
const H = (text) => ({ text, header: true, plain: true });
|
|
@@ -15,7 +15,7 @@ const H = (text) => ({ text, header: true, plain: true });
|
|
|
15
15
|
const wrapRows = (lead, text, W) => wrapLines(text, Math.max(20, W - 13)).map((l, i) => H(`${i ? ' '.repeat(11) : lead}${l}`));
|
|
16
16
|
const num = (n) => Math.round(n).toLocaleString('en-US');
|
|
17
17
|
const clock = (ms) => { const s = Math.max(0, Math.floor(ms / 1000)); return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`; };
|
|
18
|
-
const signedMoney = (copper) => (copper < 0 ? `-${
|
|
18
|
+
const signedMoney = (copper) => (copper < 0 ? `-${coinText(-copper)}` : `+${coinText(copper)}`);
|
|
19
19
|
|
|
20
20
|
/** "questing in Redridge Mountains", "farming Redridge Mystic in …", "doing A Baying of Gnolls". */
|
|
21
21
|
export function runWhat(app, ctl) {
|
|
@@ -149,14 +149,23 @@ export function bagsPaneLines(app, L) {
|
|
|
149
149
|
const items = rows.map((r, i) => i).filter((i) => !rows[i].header);
|
|
150
150
|
if (!items.length) return [` ${bold('Bags')}`, dim(' Nothing in your bags.')];
|
|
151
151
|
if (!items.includes(st.cursor)) st.cursor = items.find((i) => i >= st.cursor) ?? items[items.length - 1];
|
|
152
|
-
|
|
152
|
+
// With icons a row is two lines like the Bags tab's list: the icon down the left, the name beside the top
|
|
153
|
+
// and the row's kind and price beside the bottom.
|
|
154
|
+
const icons = iconsOn() && L.W >= 40;
|
|
155
|
+
const room = Math.floor((L.logH - 1) / (icons ? 2 : 1));
|
|
153
156
|
const at = items.indexOf(st.cursor);
|
|
154
157
|
const start = Math.max(0, Math.min(at - Math.floor(room / 2), items.length - room));
|
|
155
158
|
const lines = [` ${bold('Bags')} ${dim('Up Down: pick Enter: use, equip, open the run plays on')}`];
|
|
156
159
|
for (const i of items.slice(start, start + room)) {
|
|
157
|
-
const r = rows[i];
|
|
158
|
-
|
|
159
|
-
|
|
160
|
+
const r = rows[i], sel = i === st.cursor;
|
|
161
|
+
if (icons) {
|
|
162
|
+
const art = iconArt(r.icon, 4) ?? iconBlank(4);
|
|
163
|
+
const t1 = fit(` ${r.text}`, L.W - 6), t2 = fit(` ${r.sub ?? ''}`, L.W - 6);
|
|
164
|
+
lines.push(`${sel ? '▶' : ' '}${art[0]}${sel ? inverse(t1) : t1}`, ` ${art[1]}${sel ? inverse(t2) : t2}`);
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
const text = fit(sel ? `▶ ${r.text}` : ` ${r.text}`, L.W - 1);
|
|
168
|
+
lines.push(sel ? inverse(text) : ` ${text}`);
|
|
160
169
|
}
|
|
161
170
|
return lines;
|
|
162
171
|
}
|
|
@@ -229,7 +238,7 @@ function runLine(app, ctl) {
|
|
|
229
238
|
// ---- the Run pane ----------------------------------------------------------------------------------
|
|
230
239
|
|
|
231
240
|
const GLYPH = {
|
|
232
|
-
fight: red('⚔'), wait: dim('~'), consume: dim('~'), cannibalize: dim('~'), turnin: green('✓'), accept: green('+'), travel: cyan('→'),
|
|
241
|
+
fight: red('⚔'), buff: cyan('↑'), wait: dim('~'), consume: dim('~'), cannibalize: dim('~'), turnin: green('✓'), accept: green('+'), travel: cyan('→'),
|
|
233
242
|
gather: yellow('⚒'), skin: yellow('⚒'), buy: yellow('$'), equip: green('▲'), skip: yellow('✗'), dead: red('☠'), stop: yellow('■'),
|
|
234
243
|
};
|
|
235
244
|
|
package/src/cli/tuiBags.mjs
CHANGED
|
@@ -3,17 +3,23 @@
|
|
|
3
3
|
// held-item menu (panels/heldItemMenu.ts) is the reference; every verb is a Game facade call.
|
|
4
4
|
import { bold, dim, yellow, green, red, qualityPaint, itemText } from './text.mjs';
|
|
5
5
|
import { autoLootOn, openLoot, takeAll } from './tuiLoot.mjs';
|
|
6
|
-
import { SLOT_LABEL, bagCapacity } from '../domain/items/equipment.ts';
|
|
6
|
+
import { SLOT_LABEL, bagCapacity, equipSlotPickTargets, DUAL_WIELD_SPELL_ID } from '../domain/items/equipment.ts';
|
|
7
|
+
import { equippedEntry } from '../domain/items/itemInstance.ts';
|
|
8
|
+
import { knows } from '../domain/character/spellbook.ts';
|
|
9
|
+
import { isDisenchantable, disenchantRuleFor } from '../domain/professions/enchanting.ts';
|
|
10
|
+
import { resolveRosterEnchanters, groupHasEnchanting, partyDisenchantCheck } from '../domain/professions/partyDisenchant.ts';
|
|
7
11
|
import { freeSlots, isWrappingPaper, isFilledGift, canWrapTarget } from '../domain/items/inventory.ts';
|
|
8
12
|
import { isOpenableContainer } from '../domain/items/itemContainer.ts';
|
|
9
13
|
import { isAmmoItem } from '../domain/items/ammo.ts';
|
|
10
14
|
import { itemTeachIntent } from '../domain/items/itemTeaches.ts';
|
|
11
15
|
import { junkListOptions } from './tuiSettings.mjs';
|
|
12
|
-
import { isMountItem, toggleMount } from './tuiWorld.mjs';
|
|
13
|
-
import { questDialog } from './tuiViews.mjs';
|
|
16
|
+
import { isMountItem, toggleMount, afterDeparture } from './tuiWorld.mjs';
|
|
17
|
+
import { questDialog, landOnTab } from './tuiViews.mjs';
|
|
14
18
|
import { useItemWithPicker, isPermanentEnchantItem } from './tuiEnchantTarget.mjs';
|
|
15
19
|
import { bagQuestStartState } from '../quests/quests.ts';
|
|
16
20
|
|
|
21
|
+
const HEARTHSTONE_ITEM_ENTRY = 6948; // phonecraft src/ui/map/travelUi.ts
|
|
22
|
+
|
|
17
23
|
const stacks = (app) => app.g.state.inventory.stacks;
|
|
18
24
|
const hasLoot = (app) => (entry) => app.ds.lootTable(`item:${entry}`).length > 0;
|
|
19
25
|
const isContainer = (app, it) => isOpenableContainer(it, hasLoot(app));
|
|
@@ -55,7 +61,8 @@ export function useStack(app, slot) {
|
|
|
55
61
|
}
|
|
56
62
|
if (isWrappingPaper(it)) { wrapWith(app, slot); return; }
|
|
57
63
|
if (isContainer(app, it)) { openContainer(app, slot); return; }
|
|
58
|
-
if (
|
|
64
|
+
if (s.item === HEARTHSTONE_ITEM_ENTRY) { const r = app.g.castHearth(); if (app.result(r)) { app.vendor = null; afterDeparture(app, () => `You hearth to ${app.zone()?.name}.`); landOnTab(app); } return; }
|
|
65
|
+
if (it?.slot) { equipFromBags(app, it); return; }
|
|
59
66
|
if (isAmmoItem(it)) {
|
|
60
67
|
if (app.ch.ammoItem === it.entry) app.result(app.g.clearAmmo(), 'You put your ammo away.');
|
|
61
68
|
else app.result(app.g.setAmmo(s.item), `You load ${itemText(it)}.`);
|
|
@@ -67,6 +74,30 @@ export function useStack(app, slot) {
|
|
|
67
74
|
useItemWithPicker(app, s.item, (r) => (r.learned ? green(`You learn ${r.learned.name}.`) : green(`You use ${itemText(it)}. HP ${app.hpText()} ${app.pwText()}`)));
|
|
68
75
|
}
|
|
69
76
|
|
|
77
|
+
/** Equip from the bags. With both rings, both trinkets or both hands full it asks which to replace rather than
|
|
78
|
+
* overwrite the first (phonecraft equipSlotPicker.ts); an empty one of the pair still fills by itself. */
|
|
79
|
+
function equipFromBags(app, it) {
|
|
80
|
+
const done = (r) => app.result(r, r.ok ? (it.slot === 'bag' ? `You equip ${itemText(it)} as ${SLOT_LABEL[r.slot] ?? r.slot}.` : `You equip ${itemText(it)}.`) : '');
|
|
81
|
+
const ch = app.ch, slots = equipSlotPickTargets(ch, it, { canDualWield: knows(app.g.state.spellbook, DUAL_WIELD_SPELL_ID) });
|
|
82
|
+
if (slots.length < 2) { done(app.g.equip(it.entry)); return; }
|
|
83
|
+
const opts = slots.map((slot) => { const cur = equippedEntry(ch.equipped[slot]); return { label: `${SLOT_LABEL[slot] ?? slot}: ${cur != null ? itemText(app.ds.item(cur)) : dim('empty')}`, value: slot }; });
|
|
84
|
+
app.pick(`Equip ${itemText(it)}: replace which?`, opts, (slot) => done(app.g.equip(it.entry, slot)));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Disenchant on one stack, for anyone in the group with the skill: hidden when nobody has Enchanting or the item
|
|
88
|
+
* yields nothing, the reason shown when the group is short of the skill. Ref heldItemMenu.ts disenchantRow. */
|
|
89
|
+
function disenchantOption(app, slot, s, it) {
|
|
90
|
+
if (!it || !isDisenchantable(it) || it.quality < 2 || !disenchantRuleFor(it, app.ds)) return null;
|
|
91
|
+
const g = app.g, enchanters = resolveRosterEnchanters(app.ch, g.state.squad, g.state.botRoster, g.state.raidRoster?.members, app.ds);
|
|
92
|
+
if (!groupHasEnchanting(enchanters)) return null;
|
|
93
|
+
const chk = partyDisenchantCheck(s.item, g.state.inventory, app.ds, enchanters);
|
|
94
|
+
if (!chk.ok) return chk.kind === 'skill' ? { label: `Disenchant ${dim(chk.reason)}`, value: () => app.flashMsg(chk.reason) } : null;
|
|
95
|
+
return { label: `Disenchant${chk.who.kind === 'companion' ? dim(` ${chk.who.name}, Enchanting ${chk.who.skill}`) : ''}`, value: () => {
|
|
96
|
+
const r = g.disenchantStack(slot);
|
|
97
|
+
if (app.result(r)) app.say(green(`${r.worker ? `${r.worker} breaks down` : 'You break down'} ${itemText(it)}: ${r.yielded.map((y) => `${app.itemLabel(y.item)}${y.count > 1 ? ` x${y.count}` : ''}`).join(', ') || 'nothing'}.`) + (r.skillUp != null && !r.worker ? dim(` Enchanting ${r.skillUp}`) : ''));
|
|
98
|
+
} };
|
|
99
|
+
}
|
|
100
|
+
|
|
70
101
|
/** Open a clam, crate or lockbox into the loot window; auto-loot takes what fits straight away. */
|
|
71
102
|
export function openContainer(app, slot) {
|
|
72
103
|
const name = app.itemLabel(stacks(app)[slot]?.item);
|
|
@@ -159,6 +190,8 @@ export function itemMenu(app, slot) {
|
|
|
159
190
|
if (slot > 0) opts.push({ label: `Move up ${dim('[')}`, value: () => moveStack(app, slot, -1) });
|
|
160
191
|
if (slot < stacks(app).length - 1) opts.push({ label: `Move down ${dim(']')}`, value: () => moveStack(app, slot, +1) });
|
|
161
192
|
opts.push({ label: `Move to the end`, value: () => app.result(app.g.moveItem(slot, stacks(app).length)) });
|
|
193
|
+
const de = disenchantOption(app, slot, s, it);
|
|
194
|
+
if (de) opts.push(de);
|
|
162
195
|
opts.push(...junkListOptions(app, s.item));
|
|
163
196
|
opts.push({ label: red(`Destroy ${dim('d')}`), value: () => destroyStack(app, slot) });
|
|
164
197
|
app.pick(`${itemText(it, 'Item')}${countText(s)}`, opts, (fn) => fn());
|
package/src/cli/tuiBulk.mjs
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
|
-
// The
|
|
2
|
-
// down a quality band,
|
|
3
|
-
//
|
|
1
|
+
// The bulk bag verbs from phonecraft's bag bar (panels/bagBulkPanel.ts, app/appInteractBulkBags.ts): break
|
|
2
|
+
// down a quality band, read every recipe, formula and book in the bags onto whoever can take it, and destroy
|
|
3
|
+
// several stacks at once (the trash skull's multi-select, and the leftover-quest-items sweep). All list first;
|
|
4
|
+
// Enter switches a row off or on, G runs what is on. Rows a quest still needs or that are still an upgrade
|
|
4
5
|
// start off. p switches between your bags and the whole party's. The runs go through the same engine verbs a
|
|
5
6
|
// single press uses, so a bulk pass never accepts what one-at-a-time would refuse.
|
|
6
7
|
import { bold, dim, yellow, green, red, qualityPaint, plural, itemText } from './text.mjs';
|
|
7
8
|
import { planBulkDisenchant, resolveRosterEnchanters, activeBulkDisenchantRows, bulkDisenchantRowKey } from '../domain/professions/partyDisenchant.ts';
|
|
8
9
|
import { companionsForUpgradeGuard, partyUpgradeEntries, playerEquipOptsFrom } from '../domain/professions/autoDisenchant.ts';
|
|
9
10
|
import { planLearnAllFromBags, activeLearnAllRows } from '../domain/professions/learnAllRecipes.ts';
|
|
10
|
-
import { questNeededItems } from '../domain/quests/questlog.ts';
|
|
11
|
+
import { questNeededItems, leftoverQuestItems } from '../domain/quests/questlog.ts';
|
|
11
12
|
import { itemTeachIntent } from '../domain/items/itemTeaches.ts';
|
|
12
13
|
import { learnOnCompanionPool } from '../domain/spells/learnOnTargets.ts';
|
|
13
14
|
|
|
@@ -160,3 +161,81 @@ function runLearnAll(app, state) {
|
|
|
160
161
|
const spare = rows.reduce((n, r) => n + r.spare, 0);
|
|
161
162
|
app.say(green(`Learned ${plural(learned, 'book')}.`) + (spare ? dim(` ${spare} duplicate${spare === 1 ? '' : 's'} left to sell.`) : '') + (firstRefusal ? yellow(` One refused: ${firstRefusal}.`) : ''));
|
|
162
163
|
}
|
|
164
|
+
|
|
165
|
+
const DESTROY_TABS = [['all', 'Everything'], ['quest', 'Leftover quest items']];
|
|
166
|
+
|
|
167
|
+
/** Every stack in scope with its owner, plus the quests a leftover one belonged to (turned in, not repeatable,
|
|
168
|
+
* not wanted by an open quest: phonecraft's leftoverQuestItems). */
|
|
169
|
+
function destroyCandidates(app, scope) {
|
|
170
|
+
const st = app.g.state, out = [];
|
|
171
|
+
for (const { inventory, owner } of bagsInScope(app.g, scope)) {
|
|
172
|
+
const left = new Map(leftoverQuestItems(st.questlog, inventory, (e) => app.ds.quest(e), (e) => app.ds.item(e), app.ds.quests()).map((r) => [r.slot, r.quests]));
|
|
173
|
+
inventory.stacks.forEach((stack, slot) => { if (stack) out.push({ stack, owner, quests: left.get(slot) ?? null }); });
|
|
174
|
+
}
|
|
175
|
+
return out;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Destroy several stacks at once: the trash skull's multi-select from phonecraft's bags (bagsPanel.ts
|
|
179
|
+
* destroySelectMode) and its leftover-quest-items sweep in one list. Marks are the stack objects themselves,
|
|
180
|
+
* so a slot shifting under a destroy never lands on the wrong item. */
|
|
181
|
+
export function destroyManyDialog(app, tab = 'all', scope = 'player') {
|
|
182
|
+
const state = { tab, scope, marked: new Set() };
|
|
183
|
+
const shown = (a) => destroyCandidates(a, state.scope).filter((c) => state.tab === 'all' || c.quests);
|
|
184
|
+
const markLeftovers = (a) => { for (const c of destroyCandidates(a, state.scope)) if (c.quests) state.marked.add(c.stack); };
|
|
185
|
+
if (tab === 'quest') markLeftovers(app);
|
|
186
|
+
return {
|
|
187
|
+
id: 'destroy-many', title: 'Destroy', hint: 'Enter: mark / unmark',
|
|
188
|
+
keys: {
|
|
189
|
+
G: { hint: 'destroy', fn: (a) => confirmDestroyMany(a, state) },
|
|
190
|
+
tab: { hint: 'everything / quest leftovers', fn: (a) => { state.tab = state.tab === 'all' ? 'quest' : 'all'; a.top().cursor = 0; } },
|
|
191
|
+
a: { hint: 'mark all shown', fn: (a) => { for (const c of shown(a)) state.marked.add(c.stack); } },
|
|
192
|
+
c: { hint: 'clear', fn: () => state.marked.clear() },
|
|
193
|
+
p: { hint: 'yours / party', fn: (a) => { state.scope = state.scope === 'party' ? 'player' : 'party'; state.marked.clear(); if (state.tab === 'quest') markLeftovers(a); } },
|
|
194
|
+
},
|
|
195
|
+
rows(a) {
|
|
196
|
+
const all = destroyCandidates(a, state.scope);
|
|
197
|
+
const rows = state.tab === 'all' ? all : all.filter((c) => c.quests);
|
|
198
|
+
const on = all.filter((c) => state.marked.has(c.stack));
|
|
199
|
+
const tabs = DESTROY_TABS.map(([t, label]) => (t === state.tab ? bold(`[${label}]`) : dim(label))).join(' ');
|
|
200
|
+
const out = [H(`${tabs} ${dim(state.scope === 'party' ? 'the party\'s bags' : 'your bags')}`), H(on.length ? red(`${plural(on.length, 'stack')} (${plural(on.reduce((n, c) => n + c.stack.count, 0), 'item')}) marked. G: destroy`) : dim('Nothing marked. Enter marks a stack, a marks every one shown.')), H('')];
|
|
201
|
+
if (!rows.length) out.push(H(dim(state.tab === 'quest' ? ' No quest items left over from quests you have turned in.' : ' The bags are empty.')));
|
|
202
|
+
for (const c of rows) {
|
|
203
|
+
const it = a.ds.item(c.stack.item);
|
|
204
|
+
const isOn = state.marked.has(c.stack);
|
|
205
|
+
const from = c.quests ? c.quests.map((q) => q.title).join(', ') : '';
|
|
206
|
+
out.push({
|
|
207
|
+
text: `${isOn ? red('✗') : dim('·')} ${qualityPaint(it?.quality)(it?.name ?? c.stack.item)}${c.stack.count > 1 ? ` x${c.stack.count}` : ''}${c.owner ? dim(` ${c.owner.name}'s bags`) : ''}${from ? dim(` ${from}`) : ''}`,
|
|
208
|
+
detail: () => [bold(itemText(it, '')), ...(from ? ['', dim(`Left over from ${from}.`)] : []), '', dim(isOn ? 'Enter: keep it' : 'Enter: mark it to destroy')],
|
|
209
|
+
act: () => { if (isOn) state.marked.delete(c.stack); else state.marked.add(c.stack); },
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
return out;
|
|
213
|
+
},
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function confirmDestroyMany(app, state) {
|
|
218
|
+
// Slots resolved now, before anything splices: highest first per owner, as phonecraft's doDiscardSelected.
|
|
219
|
+
const targets = [];
|
|
220
|
+
for (const { inventory, owner } of bagsInScope(app.g, state.scope)) {
|
|
221
|
+
inventory.stacks.forEach((s, slot) => { if (state.marked.has(s)) targets.push({ owner: owner?.id, slot, item: s.item, count: s.count }); });
|
|
222
|
+
}
|
|
223
|
+
if (!targets.length) { app.flashMsg('Nothing marked.'); return; }
|
|
224
|
+
const pieces = targets.reduce((n, t) => n + t.count, 0);
|
|
225
|
+
const what = targets.length === 1 ? `${app.itemLabel(targets[0].item)}${pieces > 1 ? ` x${pieces}` : ''}` : `${plural(targets.length, 'stack')} (${plural(pieces, 'item')})`;
|
|
226
|
+
app.confirm(`Destroy ${what}? This cannot be undone.`, () => {
|
|
227
|
+
const g = app.g;
|
|
228
|
+
let stacks = 0, gone = 0, firstRefusal = '';
|
|
229
|
+
targets.sort((a, b) => (a.owner ?? '').localeCompare(b.owner ?? '') || b.slot - a.slot);
|
|
230
|
+
for (const t of targets) {
|
|
231
|
+
const bags = t.owner ? bagsInScope(g, 'party').find((b) => b.owner.id === t.owner)?.inventory : g.state.inventory;
|
|
232
|
+
if (bags?.stacks[t.slot]?.item !== t.item) continue;
|
|
233
|
+
const r = t.owner ? g.discardCompanionBagStack(t.owner, t.slot) : g.discardBagStack(t.slot);
|
|
234
|
+
if (!r.ok) { firstRefusal ||= r.reason ?? 'Cannot destroy'; continue; }
|
|
235
|
+
stacks++; gone += r.discarded;
|
|
236
|
+
}
|
|
237
|
+
state.marked.clear();
|
|
238
|
+
if (!stacks) { app.say(red(firstRefusal || 'Nothing was destroyed.')); return; }
|
|
239
|
+
app.say(green(stacks === 1 ? `Destroyed ${what}.` : `Destroyed ${plural(stacks, 'stack')} (${plural(gone, 'item')}).`) + (firstRefusal ? yellow(` One refused: ${firstRefusal}.`) : ''));
|
|
240
|
+
});
|
|
241
|
+
}
|