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 +53 -0
- package/package.json +3 -2
- package/src/cli/tui.mjs +7 -4
- package/src/cli/tuiBulk.mjs +83 -4
- package/src/cli/tuiCheck.mjs +52 -4
- package/src/cli/tuiCompanionKit.mjs +20 -2
- package/src/cli/tuiDungeon.mjs +59 -15
- package/src/cli/tuiViews.mjs +3 -1
- package/src/data/model-art.bin.gz +0 -0
- package/src/engine/game/professions.ts +4 -0
- package/src/engine/game/squadQuestShare.ts +13 -1
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
|
+
"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
|
|
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
|
|
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);
|
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
|
+
}
|
package/src/cli/tuiCheck.mjs
CHANGED
|
@@ -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('
|
|
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
|
-
|
|
950
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/cli/tuiDungeon.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
90
|
-
|
|
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)} ${
|
|
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: () =>
|
|
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
|
-
|
|
129
|
-
rows.push(
|
|
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
|
|
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
|
};
|
package/src/cli/tuiViews.mjs
CHANGED
|
@@ -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
|
|
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) };
|