azeroth-shell 0.3.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 ADDED
@@ -0,0 +1,53 @@
1
+ # Changelog
2
+
3
+ What changed in each release of `azeroth-shell` on npm. Update with `npm install -g azeroth-shell@latest`.
4
+
5
+ ## Unreleased
6
+
7
+ ## 0.4.0 — 2026-11-03
8
+
9
+ - Bags: `X` destroys several stacks at once, and `Q` destroys every quest item left over from quests you handed in.
10
+ - Dungeons: a 5-man you leave is held for two hours (it was 30 minutes). On the World tab, Enter on a run you
11
+ left half done takes you straight back in. The tab also lists the zone's dungeons while you are inside one,
12
+ and a cleared run says so. Picking another dungeon from inside one leaves the current run, which is held.
13
+ - Companions get their own copy of a quest item that comes from an object. If their copy never finished, they
14
+ drop it when you hand the quest in, so it no longer fills their log. Their quest list offers to drop old ones.
15
+ - Art for what a fight turns into or brings in: boss morphs, totems, wards and adds were invisible before.
16
+ - The title line reads "Azeroth Shell".
17
+
18
+ ## 0.3.0 — 2026-11-03
19
+
20
+ - HD gear art at 256 px with a palette per body. Shapeshift forms and costumes draw sharp under sixel.
21
+ - The Travel tab has a world map, in half-block and sixel.
22
+ - Tame and quest-item uses on World rows; dungeon entry locks and extra entrances; Hearthstone and ring/trinket
23
+ picks in Bags. The Character tab gets a stats page, and buffs are cast before a pull.
24
+ - Hunter pets untrain at the trainer's prices and keep their innate abilities.
25
+ - A foe still targeting you keeps the fight going.
26
+ - `azsh --art` downloads the full-size art up front.
27
+
28
+ ## 0.2.0 — 2026-11-02
29
+
30
+ - Art: creatures, icons and your geared character are drawn as half-block sprites, or as real pixels in a
31
+ terminal that supports sixel.
32
+ - A fight stage, the Gear tab as a character sheet, talent trees, and a one-page character creation screen.
33
+ - Party fights on the stage, each body at its real distance. Casters no longer stall on immune foes.
34
+ - The autopilot on screen: pre-flight card, run strip, run panel, Run/Quests/Bags panes, end card, and the
35
+ rotation condition editor.
36
+ - Auction house browsing by category; world search covers the zone you stand in.
37
+ - Warsong Gulch: strip map, your own seat, side calls, and matches that actually end.
38
+ - Faster start: the engine is cached under `~/.azsh/cache`, with a loading line while it imports.
39
+
40
+ ## 0.1.2 — 2026-11-01
41
+
42
+ - A character open in another terminal cannot be opened a second time.
43
+ - The Travel tab stays open after you depart.
44
+ - A screen refresh setting: game loops repaint at most once per refresh.
45
+
46
+ ## 0.1.1 — 2026-11-01
47
+
48
+ - Vendor window: `d` destroys a stack from your bags or a companion's.
49
+ - Terminal: Ctrl-L repaints, a full repaint on screen change, and a notice when the window is too small.
50
+
51
+ ## 0.1.0 — 2026-11-01
52
+
53
+ - First release on npm.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "azeroth-shell",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "description": "Azeroth shell. A WoW Classic 1.12 text RPG for the terminal, built on CMaNGOS data.",
6
6
  "keywords": [
@@ -32,7 +32,8 @@
32
32
  "!src/data/gear-art-hd/",
33
33
  "!src/data/*.only.bin.br",
34
34
  "!src/data/gear-art-hd.only/",
35
- "docs/"
35
+ "docs/",
36
+ "CHANGELOG.md"
36
37
  ],
37
38
  "engines": {
38
39
  "node": ">=22.18"
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';
@@ -84,6 +84,7 @@ const HELP_LINES = [
84
84
  ' Bags tab: Enter uses, equips, opens, unwraps or learns; m lists every verb for the stack. s sells to the open',
85
85
  ' vendor, j sells junk, b opens the bank, d destroys, S sorts, / splits a stack, [ ] move it, A uses the whole stack.',
86
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.',
87
88
  ' :dev item|quest|recipe|prof skips the rules (testing). New character offers a random world bot.',
88
89
  ' Settings tab (or :settings) sets auto-loot, the upgrade helper, auto-disenchant, the junk rules and which nodes get gathered.',
89
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.',
@@ -190,10 +191,11 @@ export class App {
190
191
  this.tabs = makeTabs(this);
191
192
  this.stack = [];
192
193
  this.lastActivity = g.state.activity[0] ?? null;
194
+ this.inRunId = g.state.instance?.dungeonId ?? null; // holdRunLonger's last seen run, per character
193
195
  this.say(bold(`Welcome${isNew ? '' : ' back'}, ${g.state.char.name}.`) + dim(' Press ? for the keys.'));
194
196
  if (awayMs >= 60_000) this.offlineRest(Math.floor(awayMs / 1000));
195
197
  weeklyHonorPass(this); // a maintenance day passed while away scores that week
196
- if (g.state.instance) openRun(this);
198
+ if (g.state.instance && !runCleared(this)) openRun(this);
197
199
  openLoot(this); // an opened bag item's remainder is still claimable after a reload
198
200
  if (!this.term.isTTY) return;
199
201
  // Out-of-combat regeneration ticks in real time, like standing still in the world.
@@ -499,6 +501,7 @@ export class App {
499
501
  /** After any state-changing key: pull activity, save, repaint. */
500
502
  afterAction() {
501
503
  journeyTick(this);
504
+ holdRunLonger(this);
502
505
  this.flushActivity();
503
506
  if (this.g) void this.persist();
504
507
  if (this.g && this.isDead() && this.top()?.id !== 'death' && !this.fight) this.push(deathView(this));
@@ -620,7 +623,7 @@ export class App {
620
623
  headerLines(L) {
621
624
  if (!this.g) {
622
625
  const crumbs = this.stack.map((v) => bold(v.title ?? '')).join(dim(' › '));
623
- return [bold(' azsh') + dim(' Azeroth shell. WoW Classic 1.12 via CMaNGOS data.'), crumbs ? cut(` ${crumbs}`, L.W) : '', '', dim('─'.repeat(L.W))];
626
+ return [bold(' azsh') + dim(' Azeroth Shell'), crumbs ? cut(` ${crumbs}`, L.W) : '', '', dim('─'.repeat(L.W))];
624
627
  }
625
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.
626
629
  if (this.compactHeader()) return [runHeaderLine(this, L.W), dim('─'.repeat(L.W))];
@@ -768,7 +771,7 @@ export class App {
768
771
  export async function runTui({ term = new RealTerm(), argv = [] } = {}) {
769
772
  term.open();
770
773
  // The tables take a second or two to load: say so, stage by stage, instead of leaving the screen blank.
771
- const loading = (stage) => term.draw([bold(' azsh') + dim(' Azeroth shell. WoW Classic 1.12 via CMaNGOS data.'), '', dim(` Loading ${stage}…`)]);
774
+ const loading = (stage) => term.draw([bold(' azsh') + dim(' Azeroth Shell'), '', dim(` Loading ${stage}…`)]);
772
775
  loading('the game');
773
776
  await new Promise((r) => setImmediate(r)); // the first frame leaves before the synchronous load holds the loop
774
777
  const ds = bootDataStore(loading);
@@ -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
+ }
@@ -107,6 +107,11 @@ check('the stats page draws your body', !hasArt(nativeDisplayId(app.ch.race, app
107
107
  check('a costume aura draws its model', costumed);
108
108
  check('out of form you are in your gear again', drawn(view({ form: 0 })) === paperdollArt(app.ch, 20, 12)?.join('\n'));
109
109
  }
110
+ {
111
+ // What a fight turns into or brings in is drawn too: Dathrohan's morph to Balnazzar, a shaman's totem, a
112
+ // witch doctor's ward. None is spawned anywhere, so the bake follows each kit. Cite: bake-model-art.mjs addFightCreatures.
113
+ check('a boss morph, a totem and a ward have art', [10813, 3903, 3560].every((e) => hasArt(app.ds.creature(e)?.displayId)));
114
+ }
110
115
  if (iconsOn()) {
111
116
  // A big window: the Gear tab is the character sheet and the Talents tab the three trees, both icon grids
112
117
  // the arrows walk in two dimensions. At the usual 110x34 both list instead (the checks above).
@@ -871,7 +876,7 @@ check('b in Bags outside a bank town only flashes', app.stack.length === 0);
871
876
  // ---- dungeons: the Deadmines as a text run ------------------------------------------------------------------
872
877
  {
873
878
  const g = app.g;
874
- const { hasFlag } = await import('../world/instances.ts');
879
+ const { hasFlag, isComplete } = await import('../world/instances.ts');
875
880
  g.state.char.level = 20; g.state.char.gold = 10_000_000;
876
881
  const bag = ds.items().find((i) => i.slot === 'bag' && i.itemClass !== 11 && (i.bagSlots ?? 0) >= 10 && (i.requiredLevel ?? 1) <= 20 && !i.allowClass?.length);
877
882
  for (let i = 0; i < 4; i++) { g.state.inventory = addItem(g.state.inventory, bag.entry, 1, 1); g.equip(bag.entry); }
@@ -942,12 +947,22 @@ check('b in Bags outside a bank town only flashes', app.stack.length === 0);
942
947
  check('finish instantly ends on the run report', app.top()?.id === 'run-summary' && /Run report|Cleared|Partial|Wiped/.test(strip(frame())) && (app.dungeonPulls?.length ?? 0) > 2);
943
948
  term.press('escape');
944
949
  const cleared = g.state.instance.defeated.length;
950
+ const allDown = isComplete(ds.instance('deadmines'), g.state.instance);
951
+ {
952
+ const { portalRows } = await import('./tuiDungeon.mjs');
953
+ const texts = portalRows(app).map((r) => strip(r.text));
954
+ check('inside a run, the World tab still lists the zone\'s dungeons, the one you are in marked', texts.some((t) => /The Deadmines .*you are in it/.test(t)) && !texts.some((t) => /Back into/.test(t)));
955
+ }
945
956
  term.press('L');
946
957
  check('L leaves and the run is held', !g.state.instance && !!g.state.instanceSaves?.deadmines);
947
- check('the portal row shows the held run', /your run is held/.test(strip(frame())));
958
+ check('a 5-man holds the run for two hours', Math.abs(g.state.instanceSaves.deadmines.expiresAtMs - Date.now() - 2 * 3600_000) < 60_000);
959
+ check('the portal row shows the held run', allDown ? /cleared, resets in/.test(strip(frame())) : /your run is held/.test(strip(frame())));
948
960
  app.top().cursor = rowIndex((r) => r.data?.dungeon?.id === 'deadmines'); term.press('return');
949
- check('the entry card offers to go back in', /Go back in/.test(strip(frame())));
950
- app.top().cursor = rowIndex((r, t) => /^Go back in/.test(t)); term.press('return');
961
+ if (allDown) {
962
+ check('the entry card of a cleared run offers to go back in', /Go back in/.test(strip(frame())) && /You cleared it/.test(strip(frame())));
963
+ app.top().cursor = rowIndex((r, t) => /^Go back in/.test(t)); term.press('return');
964
+ }
965
+ check('a held run is walked straight back into', g.state.instance?.dungeonId === 'deadmines');
951
966
  check('going back in keeps the cleared stops', g.state.instance?.defeated.length === cleared);
952
967
  term.press('R'); term.press('return');
953
968
  check('R resets the run', g.state.instance?.defeated.length === 0);
@@ -1351,6 +1366,39 @@ check('b in Bags outside a bank town only flashes', app.stack.length === 0);
1351
1366
  home();
1352
1367
  }
1353
1368
 
1369
+ // Destroy several: X marks stacks and G destroys them after a yes; Q opens on the quest leftovers, already marked.
1370
+ {
1371
+ const g = app.g;
1372
+ const { isRepeatableQuest, leftoverQuestItems } = await import('../domain/quests/questlog.ts');
1373
+ // Two grey items the bags do not already hold, so one stack each.
1374
+ const [linen, wool] = ds.allItems().filter((it) => it.quality === 0 && !it.slot && (it.stackable ?? 1) > 1 && countItem(g.state.inventory, it.entry) === 0).slice(0, 2);
1375
+ g.state.inventory = addItem(addItem(g.state.inventory, linen.entry, 3, 20), wool.entry, 2, 20);
1376
+ home(); app.switchTab(2); term.press('X'); show('destroy several');
1377
+ check('X opens Destroy with nothing marked', app.top().id === 'destroy-many' && /Nothing marked/.test(strip(frame())));
1378
+ for (const name of [linen.name, wool.name]) { app.top().cursor = rowIndex((r, t) => t.includes(name)); term.press('return'); }
1379
+ check('Enter marks two stacks', /2 stacks .* marked/.test(strip(frame())));
1380
+ term.press('G');
1381
+ check('G asks first', app.top().id === 'pick' && /Destroy 2 stacks/.test(strip(frame())));
1382
+ term.press('return');
1383
+ check('a yes destroys both marked stacks', countItem(g.state.inventory, linen.entry) === 0 && countItem(g.state.inventory, wool.entry) === 0 && app.top().id === 'destroy-many');
1384
+ term.press('escape');
1385
+ const open = new Set(g.state.questlog.quests.map((x) => x.entry));
1386
+ const q = ds.quests().find((d) => !isRepeatableQuest(d) && !open.has(d.entry) && d.itemTargets?.some((t) => ds.item(t.item)?.questItem) && !ds.quests().some((o) => o !== d && o.itemTargets?.some((t) => t.item === d.itemTargets[0].item)));
1387
+ const relic = q && q.itemTargets.find((t) => ds.item(t.item)?.questItem).item;
1388
+ if (relic) {
1389
+ g.state.questlog.quests.push({ entry: q.entry, status: 'turnedIn', kills: {}, objects: {} });
1390
+ g.state.inventory = addItem(g.state.inventory, relic, 1, 20);
1391
+ const left = leftoverQuestItems(g.state.questlog, g.state.inventory, (e) => ds.quest(e), (e) => ds.item(e), ds.quests());
1392
+ check('the turned-in quest leaves its item behind', left.some((r) => r.item === relic));
1393
+ term.press('Q'); show('quest leftovers');
1394
+ check('Q lists the leftover, marked', app.top().id === 'destroy-many' && rowIndex((r, t) => t.startsWith('✗') && t.includes(ds.item(relic).name)) > 0);
1395
+ term.press('G'); term.press('return');
1396
+ check('G and a yes destroy it', countItem(g.state.inventory, relic) === 0);
1397
+ term.press('escape');
1398
+ } else check('the data has a finished quest with a quest item to leave behind', false);
1399
+ home();
1400
+ }
1401
+
1354
1402
  // Gear upkeep: durability on the slots, repair one piece, repair all, load ammo, auto consumables.
1355
1403
  {
1356
1404
  const g = app.g;
@@ -9,6 +9,7 @@ import { className } from '../domain/core/ids.ts';
9
9
  import { companionLevel, companionCharacter } from '../domain/bots/squad.ts';
10
10
  import { listTameablePets, unlockedWarlockDemons, WARLOCK_DEMONS, WARLOCK_DEMON_UNLOCK } from '../domain/bots/botPet.ts';
11
11
  import { petKnownSpells, petAutocastOn } from '../domain/pet/pet.ts';
12
+ import { MAX_QUEST_SLOTS } from '../domain/quests/questlog.ts';
12
13
  import { SHARE_CATEGORIES, SHARE_CATEGORY_LABEL, SHARE_GROUPS } from '../domain/bots/consumableShare.ts';
13
14
 
14
15
  const H = (text) => ({ text, header: true });
@@ -114,7 +115,23 @@ export function questRows(app, comp) {
114
115
  const offers = g.companionQuestOffers(id);
115
116
  const log = (comp.questLog?.quests ?? []).filter((q) => q.status !== 'turnedIn');
116
117
  if (!offers.length && !log.length) return [];
117
- const rows = [H(''), H(`Their quests ${dim(`${log.length} in the log`)}`)];
118
+ // Ones you already handed in: their copy never completed (the items never reached their bags), so it
119
+ // only holds a slot now.
120
+ const yoursDone = new Set(g.state.questlog.quests.filter((q) => q.status === 'turnedIn').map((q) => q.entry));
121
+ const stale = log.filter((qs) => qs.status !== 'complete' && yoursDone.has(qs.entry));
122
+ const rows = [H(''), H(`Their quests ${dim(`${log.length}/${MAX_QUEST_SLOTS} in the log x: abandon`)}`)];
123
+ if (stale.length) {
124
+ const titles = stale.map((qs) => ds.quest(qs.entry)?.title ?? qs.entry);
125
+ rows.push({
126
+ text: yellow(`Drop the ${plural(stale.length, 'quest')} you already handed in`),
127
+ detail: () => [bold('Handed in without them'), '', 'You turned these in, but their copy never completed, usually because the quest item never reached their bags. They hold log slots, and a full log refuses shared quests.', '', ...titles.map((t) => ` ${t}`)],
128
+ act: () => app.confirm(`${comp.name} abandons ${plural(stale.length, 'quest')}: ${titles.join(', ')}?`, () => {
129
+ let n = 0;
130
+ for (const qs of stale) if (g.abandonCompanionQuest(id, qs.entry).ok) n++;
131
+ app.say(dim(`${comp.name} abandons ${plural(n, 'quest')}.`));
132
+ }),
133
+ });
134
+ }
118
135
  for (const o of offers) {
119
136
  rows.push({
120
137
  text: o.kind === 'turn-in' ? `${green('?')} Hand in ${o.title}` : `${yellow('!')} Take ${o.title}${o.itemEntry ? dim(` from ${app.itemLabel(o.itemEntry)}`) : ''}`,
@@ -130,7 +147,8 @@ export function questRows(app, comp) {
130
147
  }
131
148
  for (const qs of log) {
132
149
  const q = ds.quest(qs.entry);
133
- rows.push({ text: ` ${q?.title ?? qs.entry} ${qs.status === 'complete' ? green('(Complete)') : ''}`, detail: () => [bold(q?.title ?? ''), '', dim('x: abandon')], keys: { x: (a) => a.confirm(`${comp.name} abandons ${q?.title}?`, () => a.result(g.abandonCompanionQuest(id, qs.entry), `${comp.name} abandons ${q?.title}.`)) } });
150
+ const done = qs.status === 'complete' ? green('(Complete)') : yoursDone.has(qs.entry) ? dim('(you handed it in)') : '';
151
+ rows.push({ text: ` ${q?.title ?? qs.entry} ${done}`, detail: () => [bold(q?.title ?? ''), '', dim('x: abandon')], keys: { x: (a) => a.confirm(`${comp.name} abandons ${q?.title}?`, () => a.result(g.abandonCompanionQuest(id, qs.entry), `${comp.name} abandons ${q?.title}.`)) } });
134
152
  }
135
153
  return rows;
136
154
  }
@@ -27,7 +27,7 @@ import { isSkippableBeginFailure } from '../engine/game/types.ts';
27
27
  import {
28
28
  instancePortals, routeEncounterList, routeEncounterIdList, encounterOf, isCleared, isUnlocked, nextEncounter,
29
29
  isComplete, requiredFlags, hasFlag, routeBossProgress, applyEncounterCleared, autoAdvanceRouteGates,
30
- itemDropsInsideInstance, lockRemainingMs, restoreSavedRun, canEnter,
30
+ itemDropsInsideInstance, lockRemainingMs, restoreSavedRun, canEnter, NORMAL_INSTANCE_RESET_TIME_MS,
31
31
  } from '../world/instances.ts';
32
32
  import { instanceObjectPins, lootObjectPins, objectGatesFor } from '../world/instanceObjects.ts';
33
33
  import { dungeonNpcsForMap } from '../data/dungeonSpawns.ts';
@@ -47,6 +47,12 @@ const hasOwned = (app) => (entry) => countItem(ownedInventory(app.g.state.invent
47
47
  const bagCapacityOf = (app) => bagCapacity(app.ch, app.ds);
48
48
  const isRaid = (def) => (def.maxPlayers ?? 5) > 5;
49
49
 
50
+ /** Inside a run whose every required stop is down: nothing left but the way out. */
51
+ export function runCleared(app) {
52
+ const def = runDef(app);
53
+ return !!def && isComplete(def, app.g.state.instance);
54
+ }
55
+
50
56
  /** Bodies in a stop: the pack's counts, or the primary alone. */
51
57
  function packSize(enc) {
52
58
  const n = (enc.pack ?? []).reduce((s, m) => s + (m.count ?? 1), 0);
@@ -70,38 +76,74 @@ function lockText(app, def, now = Date.now()) {
70
76
  const lock = lockRemainingMs(g.state.lockouts ?? {}, def.id, now);
71
77
  if (held) {
72
78
  const left = (g.state.instanceSaves?.[def.id]?.expiresAtMs ?? now) - now;
79
+ if (isComplete(def, held)) return green(`cleared, resets in ${secondsText(left)}`);
73
80
  return yellow(`your run is held, ${held.defeated.length} cleared, ${secondsText(left)} left`);
74
81
  }
75
82
  if (lock > 0) return red(`saved, ${secondsText(lock)} left`);
76
83
  return '';
77
84
  }
78
85
 
79
- /** World tab rows: the instance portals in this zone. */
86
+ /** World tab rows: the instance portals in this zone, the one you are in among them. */
80
87
  export function portalRows(app) {
81
88
  const g = app.g, ds = app.ds, ch = app.ch;
82
- const rows = [];
83
- if (g.state.instance) {
84
- const def = runDef(app);
85
- rows.push(H(''), H('Dungeon'), { text: `${green('▶')} Back into ${bold(def?.name ?? g.state.instance.dungeonId)}`, detail: () => [bold(def?.name ?? ''), 'You are inside this dungeon.', '', dim('Enter: the run window')], act: () => openRun(app) });
86
- return rows;
87
- }
89
+ const inside = runDef(app);
88
90
  const portals = instancePortals(ds, ch.zoneId).filter((d) => !d.id.startsWith('alterac'));
89
- if (!portals.length) return rows;
90
- rows.push(H(''), H(`Dungeons (${portals.length})`));
91
+ // The run you are in is listed even when its door is in another zone.
92
+ if (inside && !portals.some((d) => d.id === inside.id)) portals.unshift(inside);
93
+ if (!portals.length) return [];
94
+ const rows = [H(''), H(`Dungeons (${portals.length})`)];
91
95
  for (const def of portals) {
96
+ const range = dim(`L${def.levelRange[0]}-${def.levelRange[1]}${isRaid(def) ? ` ${def.maxPlayers}-man raid` : ''}`);
97
+ if (def.id === inside?.id) {
98
+ rows.push({ text: `${cyan(def.name)} ${range} ${green('▶ you are in it')}`, data: { dungeon: def }, detail: () => portalLines(app, def), act: () => openRun(app) });
99
+ continue;
100
+ }
92
101
  const chk = entryCheck(app, def);
93
102
  const low = !chk.ok && !restoreSavedRun(g.state.instanceSaves, def.id, Date.now());
94
103
  const lock = lockText(app, def);
95
104
  rows.push({
96
- text: `${low ? dim(def.name) : cyan(def.name)} ${dim(`L${def.levelRange[0]}-${def.levelRange[1]}${isRaid(def) ? ` ${def.maxPlayers}-man raid` : ''}`)}${low && !lock ? red(` ${chk.reason ?? `requires ${def.minLevel}`}`) : ''}${lock ? ` ${lock}` : ''}`,
105
+ text: `${low ? dim(def.name) : cyan(def.name)} ${range}${low && !lock ? red(` ${chk.reason ?? `requires ${def.minLevel}`}`) : ''}${lock ? ` ${lock}` : ''}`,
97
106
  data: { dungeon: def },
98
107
  detail: () => portalLines(app, def),
99
- act: () => app.push(entryDialog(app, def)),
108
+ act: () => goToDungeon(app, def),
100
109
  });
101
110
  }
102
111
  return rows;
103
112
  }
104
113
 
114
+ /** Enter on a portal: out of the run you are in (it is held), then straight back into a run you left
115
+ * half done, or the entry card for a fresh one. */
116
+ function goToDungeon(app, def) {
117
+ if (app.g.state.instance) {
118
+ const was = runDef(app);
119
+ if (!app.result(app.g.leaveInstance(Date.now()), `You leave ${was?.name ?? 'the dungeon'}.`)) return;
120
+ app.dungeonPulls = [];
121
+ app.floor = null;
122
+ app.stack = [];
123
+ }
124
+ const held = restoreSavedRun(app.g.state.instanceSaves, def.id, Date.now());
125
+ if (held && !isComplete(def, held)) enter(app, def, { routeDensity: held.routeDensity ?? DEFAULT_ROUTE_DENSITY, asRaid: isRaid(def), door: null });
126
+ else app.push(entryDialog(app, def));
127
+ }
128
+
129
+ // A 5-man you walk out of stays as you left it for two hours here, not the engine's 30 minutes
130
+ // (NORMAL_INSTANCE_RESET_TIME): time to hearth, sell, repair and come back. Raids keep their lockout.
131
+ const RUN_HOLD_MS = 2 * 60 * 60 * 1000;
132
+
133
+ /** After every action: when the run you were in is gone (leave, hearth, taxi, another door), stretch its hold. */
134
+ export function holdRunLonger(app) {
135
+ const g = app.g;
136
+ if (!g) { app.inRunId = null; return; }
137
+ const was = app.inRunId, now = g.state.instance?.dungeonId ?? null;
138
+ app.inRunId = now;
139
+ if (!was || was === now) return;
140
+ const def = app.ds.instance(was), save = g.state.instanceSaves?.[was];
141
+ if (!def || def.lockoutMs > 0 || !save) return;
142
+ g.state.instanceSaves = { ...g.state.instanceSaves, [was]: { ...save, expiresAtMs: save.expiresAtMs - NORMAL_INSTANCE_RESET_TIME_MS + RUN_HOLD_MS } };
143
+ const i = g.state.activity.findIndex((l, j) => j < 5 && l.endsWith('It resets in 30 minutes.'));
144
+ if (i >= 0) g.state.activity[i] = g.state.activity[i].replace('30 minutes', '2 hours');
145
+ }
146
+
105
147
  function portalLines(app, def) {
106
148
  const bosses = def.encounters.filter((e) => e.kind === 'boss');
107
149
  const out = [bold(def.name), dim(`levels ${def.levelRange[0]}-${def.levelRange[1]}, requires ${def.minLevel}${isRaid(def) ? `, ${def.maxPlayers} players` : ''}`)];
@@ -125,8 +167,10 @@ export function entryDialog(app, def) {
125
167
  const squad = g.state.squad ?? [];
126
168
  const rows = [H(`${bold(def.name)} ${dim(`L${def.levelRange[0]}-${def.levelRange[1]}`)} ${lockText(a, def)}`), H('')];
127
169
  const held = restoreSavedRun(g.state.instanceSaves, def.id, Date.now());
128
- if (held) rows.push(H(yellow(`Your earlier run is held: ${held.defeated.length} stops cleared. Entering continues it.`)), H(''));
129
- rows.push({ text: green(held ? 'Go back in' : 'Enter the dungeon'), detail: () => [bold('Enter'), '', `${opts.routeDensity === 'full' ? 'Every pack on the way' : 'Bosses and what guards them'}; ${opts.asRaid ? 'as a raid, the bench comes too' : `you and ${plural(squad.length, 'companion')}`}.`], act: () => enter(a, def, opts) });
170
+ const heldDone = held && isComplete(def, held);
171
+ if (heldDone) rows.push(H(green('You cleared it. The halls stay empty until it resets.')), H(''));
172
+ else if (held) rows.push(H(yellow(`Your earlier run is held: ${held.defeated.length} stops cleared. Entering continues it.`)), H(''));
173
+ rows.push({ text: heldDone ? dim('Go back in (nothing left to fight)') : green(held ? 'Go back in' : 'Enter the dungeon'), detail: () => [bold('Enter'), '', `${opts.routeDensity === 'full' ? 'Every pack on the way' : 'Bosses and what guards them'}; ${opts.asRaid ? 'as a raid, the bench comes too' : `you and ${plural(squad.length, 'companion')}`}.`], act: () => enter(a, def, opts) });
130
174
  const pace = (d) => ({ text: `Route: ${cyan(d === 'full' ? 'Full' : 'Bosses')}`, detail: () => [bold('Route density'), '', 'Bosses: the bosses and the packs that stand in their way.', 'Full: every pack in the dungeon.', '', dim('Enter: switch')], act: () => { opts.routeDensity = opts.routeDensity === 'full' ? 'bosses' : 'full'; } });
131
175
  rows.push(pace(opts.routeDensity));
132
176
  if (doors.length) { const all = [null, ...doors]; rows.push({ text: `Door: ${cyan(opts.door?.name ?? 'Main entrance')}`, detail: () => [bold('Which door'), '', 'Some instances have more than one way in; each starts you at its own end of the route.', ...doors.map((d) => ` ${d.name}`), '', dim('Enter: next door')], act: () => { opts.door = all[(all.indexOf(opts.door) + 1) % all.length]; } }); }
@@ -407,7 +451,7 @@ export function runView(app) {
407
451
  }
408
452
  for (const o of objects.get('_') ?? []) rows.push({ ...objectRow(a, def, o), data: { object: o } });
409
453
  rows.push(H(''), { text: `Walk the floor ${dim('go anywhere; patrols and packs you pass pull on their own (W)')}`, detail: () => [bold('The floor'), '', 'Instead of picking stops, walk the dungeon on its nav mesh. Patrols walk their routes, and whatever sees you on the way pulls.'], act: () => openFloor(a) });
410
- rows.push({ text: dim('Leave the dungeon'), detail: () => [bold('Leave'), '', isRaid(def) ? 'A raid holds your progress until the lockout resets.' : 'A 5-man holds your progress for 30 minutes.'], act: () => leaveRun(a) });
454
+ rows.push({ text: dim('Leave the dungeon'), detail: () => [bold('Leave'), '', isRaid(def) ? 'A raid holds your progress until the lockout resets.' : 'A 5-man holds your progress for 2 hours.'], act: () => leaveRun(a) });
411
455
  return rows;
412
456
  },
413
457
  };
@@ -36,7 +36,7 @@ import { petWorldRows, petDialog, hasPetClass } from './tuiPet.mjs';
36
36
  import { portalRows } from './tuiDungeon.mjs';
37
37
  import { conditionEditor, defaultConditionsForSpell, defaultItemRule } from './tuiRotationCond.mjs';
38
38
  import { allyIndexOfSide } from '../engine/core/events.ts';
39
- import { bulkDisenchantDialog, learnAllDialog } from './tuiBulk.mjs';
39
+ import { bulkDisenchantDialog, learnAllDialog, destroyManyDialog } from './tuiBulk.mjs';
40
40
  import { gearExtraRows, durabilityText, repairOne, repairAll, slotRepairLines, enchantLines } from './tuiGear.mjs';
41
41
  import { outfitterDialog } from './tuiOutfitter.mjs';
42
42
  import { castWithPicker } from './tuiEnchantTarget.mjs';
@@ -574,6 +574,8 @@ function bagsView(app) {
574
574
  S: { hint: 'sort', fn: (a) => sortBags(a) },
575
575
  D: { hint: 'disenchant all', fn: (a) => a.push(bulkDisenchantDialog(a)) },
576
576
  L: { hint: 'learn all', fn: (a) => a.push(learnAllDialog(a)) },
577
+ X: { hint: 'destroy several', fn: (a) => a.push(destroyManyDialog(a)) },
578
+ Q: { hint: 'destroy quest leftovers', fn: (a) => a.push(destroyManyDialog(a, 'quest')) },
577
579
  A: { fn: onStack(useAll) },
578
580
  '/': { fn: onStack(splitStack) },
579
581
  '[': { fn: onStack((a, i) => moveStack(a, i, -1)) },
Binary file
@@ -167,6 +167,7 @@ import {
167
167
  creditSquadGoEnder,
168
168
  creditSquadKill,
169
169
  creditSquadLogs,
170
+ giveSquadQuestItem,
170
171
  } from './squadQuestShare.ts';
171
172
  import { applyGoSpellcasterCast } from './applyGoSpellcasterCast.ts';
172
173
  import { moneyMarked } from '../../domain/economy/money.ts';
@@ -1558,6 +1559,9 @@ export function createProfessionsModule(
1558
1559
  const yielded: LootDrop[] = goLootTable.length
1559
1560
  ? mergeLootDrops(Array.from({ length: miningTaps }, () => rollLoot(goLootTable, rng, goLootCtx)).flat())
1560
1561
  : [];
1562
+ // Each companion on the quest takes their own copy, as kill loot and dungeon chests already do.
1563
+ // Without it a quest item from an object never reached them and their copy could not finish.
1564
+ for (const y of yielded) giveSquadQuestItem(state, ds, y.item, y.count);
1561
1565
  // HOLD: the object keeps its contents until the player clicks them (the loot window rolls
1562
1566
  // each item's identity as it fills, so taking never re-rolls a suffix).
1563
1567
  let heldLoot: PendingLoot | undefined;
@@ -206,6 +206,10 @@ function isPermanentQuestRefusal(reason: string | undefined): boolean {
206
206
  return !!reason && (reason.startsWith('Not a quest for ') || reason === 'Unknown quest');
207
207
  }
208
208
 
209
+ function isUnfinishedTurnIn(reason: string | undefined): boolean {
210
+ return reason === 'Quest is not complete' || reason === 'Quest failed';
211
+ }
212
+
209
213
  export function shareSquadAccept(
210
214
  state: GameState,
211
215
  ds: DataStore,
@@ -237,7 +241,15 @@ export function shareSquadTurnIn(
237
241
  if (!r.ok) {
238
242
  // Only for one who HAS the quest — a companion who never took it was named at accept.
239
243
  const held = getQuest(companionQuestLog(comp), entry);
240
- if (held && held.status !== 'turnedIn') feed?.(`${comp.name} cannot hand it in: ${r.reason}.`);
244
+ if (!held || held.status === 'turnedIn') continue;
245
+ // The party moves on once the player hands in, so a copy that never finished (its item never
246
+ // reached their bags) would sit in their log for good and fill it. Bags full keeps the quest.
247
+ if (isUnfinishedTurnIn(r.reason)) {
248
+ comp.questLog = abandonQuest(companionQuestLog(comp), entry);
249
+ feed?.(`${comp.name} never finished it and drops it.`);
250
+ } else {
251
+ feed?.(`${comp.name} cannot hand it in: ${r.reason}.`);
252
+ }
241
253
  continue;
242
254
  }
243
255
  if ((r.money ?? 0) > 0) state.char = { ...state.char, gold: state.char.gold + (r.money ?? 0) };