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.
Files changed (55) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/README.md +43 -10
  3. package/bin/azsh.mjs +15 -1
  4. package/package.json +7 -3
  5. package/src/cli/hdArt.mjs +1 -1
  6. package/src/cli/iconArt.mjs +11 -1
  7. package/src/cli/modelArt.mjs +7 -4
  8. package/src/cli/oocPrep.mjs +32 -0
  9. package/src/cli/repl.mjs +16 -0
  10. package/src/cli/sixel.mjs +26 -14
  11. package/src/cli/text.mjs +16 -0
  12. package/src/cli/tui.mjs +40 -13
  13. package/src/cli/tuiAutopilot.mjs +17 -8
  14. package/src/cli/tuiBags.mjs +37 -4
  15. package/src/cli/tuiBulk.mjs +83 -4
  16. package/src/cli/tuiCheck.mjs +328 -19
  17. package/src/cli/tuiCity.mjs +31 -31
  18. package/src/cli/tuiCompanionKit.mjs +26 -8
  19. package/src/cli/tuiCompanions.mjs +189 -29
  20. package/src/cli/tuiCreate.mjs +7 -0
  21. package/src/cli/tuiDungeon.mjs +113 -33
  22. package/src/cli/tuiFight.mjs +63 -21
  23. package/src/cli/tuiFightPanes.mjs +15 -6
  24. package/src/cli/tuiFloor.mjs +20 -8
  25. package/src/cli/tuiGear.mjs +9 -10
  26. package/src/cli/tuiLoot.mjs +5 -6
  27. package/src/cli/tuiMap.mjs +214 -0
  28. package/src/cli/tuiPaperdoll.mjs +6 -5
  29. package/src/cli/tuiPartyStage.mjs +36 -7
  30. package/src/cli/tuiPet.mjs +16 -8
  31. package/src/cli/tuiProfessions.mjs +23 -23
  32. package/src/cli/tuiSaves.mjs +14 -0
  33. package/src/cli/tuiSearch.mjs +4 -5
  34. package/src/cli/tuiSettings.mjs +3 -4
  35. package/src/cli/tuiStage.mjs +72 -16
  36. package/src/cli/tuiTalents.mjs +3 -4
  37. package/src/cli/tuiViews.mjs +374 -85
  38. package/src/cli/tuiWiki.mjs +2 -3
  39. package/src/cli/tuiWorld.mjs +88 -5
  40. package/src/data/gear-art.bin.br +0 -0
  41. package/src/data/hd-art.json +36 -36
  42. package/src/data/model-art-worn.bin.br +0 -0
  43. package/src/data/model-art.bin.gz +0 -0
  44. package/src/data/tables/creatures.json.gz +0 -0
  45. package/src/data/world-map-sixel.bin.br +0 -0
  46. package/src/data/world-map.bin.gz +0 -0
  47. package/src/domain/character/character.ts +6 -0
  48. package/src/domain/pet/pet.ts +31 -0
  49. package/src/engine/combat/pickAction.ts +3494 -3487
  50. package/src/engine/combat/sessionChase.ts +12 -1
  51. package/src/engine/game/loadReconcile.ts +21 -1
  52. package/src/engine/game/pets.ts +33 -1
  53. package/src/engine/game/professions.ts +4 -0
  54. package/src/engine/game/squadQuestShare.ts +13 -1
  55. 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 { moneyText, coinsText } from '../domain/economy/money.ts';
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
- ' Gear tab: Enter on a slot lists everything in your bags (and the bank, in a bank town) that fits it.',
79
+ ' Character tab: your gear around you. Enter on a slot lists everything in your bags (and the bank, in a bank town) that fits it.',
80
+ ' Your stats sit under the tooltip; s opens the rest: weapon skills, professions, auras, home, pet, honor, reputation.',
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 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.',
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 shell. WoW Classic 1.12 via CMaNGOS data.'), crumbs ? cut(` ${crumbs}`, L.W) : '', '', dim('─'.repeat(L.W))];
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)} ${yellow(moneyText(ch.gold) || '0c')} `;
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
- if (detailLines.length > L.mainH) detailLines = detailLines.slice(0, L.mainH - 1).concat(dim('…'));
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 shell. WoW Classic 1.12 via CMaNGOS data.'), '', dim(` Loading ${stage}…`)]);
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);
@@ -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 { moneyText } from '../domain/economy/money.ts';
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 ? `-${moneyText(-copper)}` : `+${moneyText(copper) || '0c'}`);
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
- const room = L.logH - 1;
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
- const text = fit(i === st.cursor ? `▶ ${r.text}` : ` ${r.text}`, L.W - 1);
159
- lines.push(i === st.cursor ? inverse(text) : ` ${text}`);
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
 
@@ -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 (it?.slot) { const r = app.g.equip(s.item); app.result(r, r.ok ? (it.slot === 'bag' ? `You equip ${itemText(it)} as ${SLOT_LABEL[r.slot] ?? r.slot}.` : `You equip ${itemText(it)}.`) : ''); return; }
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());
@@ -1,13 +1,14 @@
1
- // The two bulk bag verbs from phonecraft's bag bar (panels/bagBulkPanel.ts, app/appInteractBulkBags.ts): break
2
- // down a quality band, and read every recipe, formula and book in the bags onto whoever can take it. Both list
3
- // first; Enter switches a row off or on, G runs what is on. Rows a quest still needs or that are still an upgrade
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
+ }