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
@@ -1,9 +1,9 @@
1
1
  // City services: the bank, the auction house and the mailbox. Same view shape as tuiViews.mjs; every
2
2
  // change is a Game facade call. Where they open follows phonecraft's CITY_PANEL rule: the auction
3
3
  // house in the six capitals, the bank there plus the goblin and Crossroads banker towns.
4
- import { bold, dim, cyan, yellow, green, red, inverse, vis, useColor, qualityPaint, fit, secondsText, plural, itemText } from './text.mjs';
5
- import { itemLines } from './tuiViews.mjs';
4
+ import { bold, dim, cyan, yellow, green, red, inverse, vis, useColor, qualityPaint, fit, secondsText, plural, itemText, coinText } from './text.mjs';
6
5
  import { moneyText } from '../domain/economy/money.ts';
6
+ import { itemLines } from './tuiViews.mjs';
7
7
  import { bagCapacity } from '../domain/items/equipment.ts';
8
8
  import { countItem } from '../domain/items/inventory.ts';
9
9
  import { bankCapacity, nextBankBagSlotCost } from '../domain/items/bank.ts';
@@ -66,7 +66,7 @@ export function bankDialog(app, startMode = 'deposit') {
66
66
  '+': { hint: 'buy bag slot', fn: (a) => {
67
67
  const cost = nextBankBagSlotCost(a.g.state.bankBags);
68
68
  if (cost == null) { a.flashMsg('You already own every bank bag slot.'); return; }
69
- a.confirm(`Buy a bank bag slot for ${moneyText(cost)}?`, () => a.result(a.g.buyBankBagSlot(), `Bought a bank bag slot for ${moneyText(cost)}.`));
69
+ a.confirm(`Buy a bank bag slot for ${coinText(cost)}?`, () => a.result(a.g.buyBankBagSlot(), `Bought a bank bag slot for ${coinText(cost)}.`));
70
70
  } },
71
71
  D: { hint: 'deposit all of it', fn: (a, row) => { if (mode !== 'deposit' || !row?.data?.s) return; const e = row.data.s.item; const r = a.g.bankDeposit(e, countItem(a.g.state.inventory, e)); a.result(r, r.ok ? `Deposited every ${a.itemLabel(e)} (${r.moved}).` : ''); } },
72
72
  B: { hint: 'slot a bag', fn: (a, row) => {
@@ -84,8 +84,8 @@ export function bankDialog(app, startMode = 'deposit') {
84
84
  const bankCap = bankCapacity(st.bankBags, ds), bagCap = bagCapacity(a.ch, ds);
85
85
  const owned = st.bankBags.length, slotted = st.bankBags.filter((b) => b != null).length;
86
86
  const rows = [
87
- { text: `${bold('Bank')} ${dim(`${st.bank.stacks.length}/${bankCap}`)} ${bold('Bags')} ${dim(`${st.inventory.stacks.length}/${bagCap}`)} ${yellow(moneyText(a.ch.gold) || '0c')}`, header: true },
88
- { text: dim(`Bank bag slots: ${owned} owned, ${slotted} holding a bag${nextBankBagSlotCost(st.bankBags) != null ? `, next costs ${moneyText(nextBankBagSlotCost(st.bankBags))}` : ''}`), header: true },
87
+ { text: `${bold('Bank')} ${dim(`${st.bank.stacks.length}/${bankCap}`)} ${bold('Bags')} ${dim(`${st.inventory.stacks.length}/${bagCap}`)} ${coinText(a.ch.gold)}`, header: true },
88
+ { text: dim(`Bank bag slots: ${owned} owned, ${slotted} holding a bag${nextBankBagSlotCost(st.bankBags) != null ? `, next costs ${coinText(nextBankBagSlotCost(st.bankBags))}` : ''}`), header: true },
89
89
  ];
90
90
  // Each bank bag slot: Enter takes the bag in it back to your bags (it has to be empty).
91
91
  st.bankBags.forEach((b, slot) => {
@@ -103,7 +103,7 @@ export function bankDialog(app, startMode = 'deposit') {
103
103
  const it = ds.item(s.item);
104
104
  const tag = it?.slot ? dim(` ${it.slot}`) : it?.questItem ? dim(' quest') : '';
105
105
  rows.push({
106
- text: `${qualityPaint(it?.quality)(it?.name ?? `item ${s.item}`)}${s.count > 1 ? ` x${s.count}` : ''}${tag}`, data: { s, index },
106
+ text: `${qualityPaint(it?.quality)(it?.name ?? `item ${s.item}`)}${s.count > 1 ? ` x${s.count}` : ''}${tag}`, data: { s, index }, icon: it?.icon, sub: dim(it?.typeName ?? ''),
107
107
  detail: () => itemLines(a, it, ['', dim(mode === 'deposit' ? 'Enter: deposit n: deposit some' : 'Enter: withdraw n: withdraw some')]),
108
108
  act: () => move(a, index),
109
109
  });
@@ -146,7 +146,7 @@ function ahCategory(it) {
146
146
  * One line of tabs, the picked one inverse; when it does not fit it scrolls to keep the pick in view. A strip
147
147
  * under the cursor is drawn inverse whole, so there the pick is bracketed instead.
148
148
  */
149
- function tabStrip(labels, at, width, focused = false) {
149
+ export function tabStrip(labels, at, width, focused = false) {
150
150
  const cells = labels.map((l, i) => (i === at ? (useColor && !focused ? inverse(` ${l} `) : focused ? bold(`[${l}]`) : `[${l}]`) : ` ${l} `));
151
151
  let from = 0, to = cells.length;
152
152
  const len = () => cells.slice(from, to).reduce((n, c) => n + vis(c), 0) + (from > 0 ? 2 : 0) + (to < cells.length ? 2 : 0);
@@ -213,10 +213,10 @@ export function auctionDialog(app) {
213
213
  b: { hint: 'bid', fn: (a, row) => {
214
214
  if (mode !== 'browse' || !row?.data?.auction) return;
215
215
  const au = row.data.auction, min = minNextBid(au);
216
- a.prompt(`Bid on ${a.itemLabel(au.item)} (at least ${moneyText(min)}): `, (t) => {
216
+ a.prompt(`Bid on ${a.itemLabel(au.item)} (at least ${coinText(min)}): `, (t) => {
217
217
  const amount = parseMoney(t);
218
218
  if (amount == null) { a.say(red('Say a price like 2g 50s or 250.')); a.render(); return; }
219
- a.result(a.g.bidAuction(au.id, amount, Date.now()), `You bid ${moneyText(amount)} on ${a.itemLabel(au.item)}.`);
219
+ a.result(a.g.bidAuction(au.id, amount, Date.now()), `You bid ${coinText(amount)} on ${a.itemLabel(au.item)}.`);
220
220
  a.afterAction();
221
221
  }, moneyText(min));
222
222
  } },
@@ -236,7 +236,7 @@ export function auctionDialog(app) {
236
236
  const L = a.layout(), width = (L.detailW ? L.listW : L.W) - 2;
237
237
  const modeLabels = MODES.map((m) => ({ browse: 'Browse', sell: 'Sell', mine: 'My auctions' }[m]));
238
238
  const rows = [
239
- { text: `${bold('Auction House')} ${tabStrip(modeLabels, MODES.indexOf(mode), 40)} ${yellow(moneyText(a.ch.gold) || '0c')}${unread ? yellow(` ${unread} new mail`) : ''}`, header: true },
239
+ { text: `${bold('Auction House')} ${tabStrip(modeLabels, MODES.indexOf(mode), 40)} ${coinText(a.ch.gold)}${unread ? yellow(` ${unread} new mail`) : ''}`, header: true },
240
240
  ];
241
241
  if (mode === 'browse') {
242
242
  const b = browse(a, now);
@@ -261,10 +261,10 @@ export function auctionDialog(app) {
261
261
  const can = au.buyoutCopper <= a.ch.gold;
262
262
  const name = `${qualityPaint(it?.quality)(it?.name ?? au.item)}${au.quantity > 1 ? ` x${au.quantity}` : ''}`;
263
263
  rows.push({
264
- text: `${can ? name : dim(name)} ${yellow(moneyText(au.buyoutCopper))}${au.quantity > 1 ? dim(` (${moneyText(unit(au))} each)`) : ''} ${dim('bid')} ${moneyText(au.bidCopper)}${au.highBidder === a.ch.name ? green(' yours') : ''} ${timeLeft(au, now)}${it?.requiredLevel > a.ch.level ? red(` L${it.requiredLevel}`) : ''}`,
265
- data: { auction: au },
266
- detail: () => itemLines(a, it, ['', `Buyout: ${yellow(moneyText(au.buyoutCopper))}`, `Current bid: ${moneyText(au.bidCopper)}${au.highBidder ? dim(` (${au.highBidder === a.ch.name ? 'you' : au.highBidder})`) : dim(' (no bids)')}`, `Next bid: ${moneyText(minNextBid(au))}`, dim(`Seller: ${au.seller}`), '', dim('Enter: buy out b: bid')]),
267
- act: () => a.confirm(`Buy ${itemText(it, String(au.item))}${au.quantity > 1 ? ` x${au.quantity}` : ''} for ${moneyText(au.buyoutCopper)}?`, () => a.result(a.g.buyoutAuction(au.id), `Bought ${itemText(it)}${au.quantity > 1 ? ` x${au.quantity}` : ''} for ${moneyText(au.buyoutCopper)}.`)),
264
+ text: `${can ? name : dim(name)} ${coinText(au.buyoutCopper)}${au.quantity > 1 ? dim(` (${coinText(unit(au))} each)`) : ''} ${dim('bid')} ${coinText(au.bidCopper)}${au.highBidder === a.ch.name ? green(' yours') : ''} ${timeLeft(au, now)}${it?.requiredLevel > a.ch.level ? red(` L${it.requiredLevel}`) : ''}`,
265
+ data: { auction: au }, icon: it?.icon, sub: dim(it?.typeName ?? ''),
266
+ detail: () => itemLines(a, it, ['', `Buyout: ${coinText(au.buyoutCopper)}`, `Current bid: ${coinText(au.bidCopper)}${au.highBidder ? dim(` (${au.highBidder === a.ch.name ? 'you' : au.highBidder})`) : dim(' (no bids)')}`, `Next bid: ${coinText(minNextBid(au))}`, dim(`Seller: ${au.seller}`), '', dim('Enter: buy out b: bid')]),
267
+ act: () => a.confirm(`Buy ${itemText(it, String(au.item))}${au.quantity > 1 ? ` x${au.quantity}` : ''} for ${coinText(au.buyoutCopper)}?`, () => a.result(a.g.buyoutAuction(au.id), `Bought ${itemText(it)}${au.quantity > 1 ? ` x${au.quantity}` : ''} for ${coinText(au.buyoutCopper)}.`)),
268
268
  });
269
269
  }
270
270
  } else if (mode === 'sell') {
@@ -277,7 +277,7 @@ export function auctionDialog(app) {
277
277
  const soulbound = !!s.bound, quest = !!it?.questItem;
278
278
  const name = `${qualityPaint(it?.quality)(it?.name ?? s.item)}${s.count > 1 ? ` x${s.count}` : ''}`;
279
279
  rows.push({
280
- text: `${soulbound || quest ? dim(name) : name}${soulbound ? red(' soulbound') : quest ? dim(' quest') : ''}${it?.sellPrice ? dim(` vendor ${moneyText(it.sellPrice * s.count)}`) : ''}`, data: { s },
280
+ text: `${soulbound || quest ? dim(name) : name}${soulbound ? red(' soulbound') : quest ? dim(' quest') : ''}${it?.sellPrice ? dim(` vendor ${coinText(it.sellPrice * s.count)}`) : ''}`, data: { s }, icon: it?.icon, sub: dim(it?.typeName ?? ''),
281
281
  detail: () => itemLines(a, it, ['', dim('Enter: set prices and list it')]),
282
282
  act: () => { if (soulbound) a.flashMsg('Soulbound items cannot be auctioned.'); else if (quest) a.flashMsg('Quest items cannot be auctioned.'); else sellFlow(a, s, it); },
283
283
  });
@@ -292,9 +292,9 @@ export function auctionDialog(app) {
292
292
  for (const au of own) {
293
293
  const it = ds.item(au.item);
294
294
  rows.push({
295
- text: `${qualityPaint(it?.quality)(it?.name ?? au.item)}${au.quantity > 1 ? ` x${au.quantity}` : ''} ${dim('bid')} ${moneyText(au.bidCopper)}${au.highBidder ? green(' (bid placed)') : ''} ${dim('buyout')} ${yellow(moneyText(au.buyoutCopper))} ${timeLeft(au, now)}`,
296
- data: { auction: au },
297
- detail: () => itemLines(a, it, ['', `Buyout: ${yellow(moneyText(au.buyoutCopper))}`, `Bid: ${moneyText(au.bidCopper)}${au.highBidder ? ` by ${au.highBidder}` : ''}`, '', dim('Enter: cancel the listing (the deposit is lost)')]),
295
+ text: `${qualityPaint(it?.quality)(it?.name ?? au.item)}${au.quantity > 1 ? ` x${au.quantity}` : ''} ${dim('bid')} ${coinText(au.bidCopper)}${au.highBidder ? green(' (bid placed)') : ''} ${dim('buyout')} ${coinText(au.buyoutCopper)} ${timeLeft(au, now)}`,
296
+ data: { auction: au }, icon: it?.icon, sub: dim(it?.typeName ?? ''),
297
+ detail: () => itemLines(a, it, ['', `Buyout: ${coinText(au.buyoutCopper)}`, `Bid: ${coinText(au.bidCopper)}${au.highBidder ? ` by ${au.highBidder}` : ''}`, '', dim('Enter: cancel the listing (the deposit is lost)')]),
298
298
  act: () => a.confirm(`Cancel the ${itemText(it)} listing?`, () => a.result(a.g.cancelAuction(au.id), `Listing cancelled. ${itemText(it)} is back in your bags.`)),
299
299
  });
300
300
  }
@@ -302,7 +302,7 @@ export function auctionDialog(app) {
302
302
  if (!bids.length) rows.push({ text: dim(' You hold no high bids.'), header: true });
303
303
  for (const au of bids) {
304
304
  const it = ds.item(au.item);
305
- rows.push({ text: ` ${qualityPaint(it?.quality)(it?.name ?? au.item)}${au.quantity > 1 ? ` x${au.quantity}` : ''} ${dim('your bid')} ${moneyText(au.bidCopper)} ${timeLeft(au, now)}`, header: true });
305
+ rows.push({ text: ` ${qualityPaint(it?.quality)(it?.name ?? au.item)}${au.quantity > 1 ? ` x${au.quantity}` : ''} ${dim('your bid')} ${coinText(au.bidCopper)} ${timeLeft(au, now)}`, header: true });
306
306
  }
307
307
  }
308
308
  if (toList) {
@@ -322,11 +322,11 @@ function sellFlow(app, s, it) {
322
322
  const finish = (qty, bid, buyout) => {
323
323
  const opts = Object.entries(AH_DURATIONS_MS).map(([k, ms]) => {
324
324
  const dep = auctionDeposit(it?.sellPrice ?? 0, qty, ms / 1000);
325
- return { label: `${{ short: '2 hours', medium: '8 hours', long: '24 hours' }[k]} ${dim(`deposit ${moneyText(dep) || '0c'}`)}`, value: k };
325
+ return { label: `${{ short: '2 hours', medium: '8 hours', long: '24 hours' }[k]} ${dim(`deposit ${coinText(dep)}`)}`, value: k };
326
326
  });
327
327
  app.pick('How long should it run?', opts, (dur) => {
328
328
  const r = app.g.listAuction(s.item, qty, bid, buyout, dur, Date.now());
329
- app.result(r, r.ok ? `Listed ${itemText(it)}${qty > 1 ? ` x${qty}` : ''}: bid ${moneyText(bid)}, buyout ${moneyText(buyout)}.` : '');
329
+ app.result(r, r.ok ? `Listed ${itemText(it)}${qty > 1 ? ` x${qty}` : ''}: bid ${coinText(bid)}, buyout ${coinText(buyout)}.` : '');
330
330
  app.afterAction();
331
331
  });
332
332
  };
@@ -335,7 +335,7 @@ function sellFlow(app, s, it) {
335
335
  app.prompt(`Starting bid for ${itemText(it)}${qty > 1 ? ` x${qty}` : ''} (e.g. 2g 50s): `, (t1) => {
336
336
  const bid = parseMoney(t1);
337
337
  if (bid == null || bid < 1) { app.say(red('Say a price like 2g 50s or 250.')); app.render(); return; }
338
- app.prompt(`Buyout (at least ${moneyText(bid)}): `, (t2) => {
338
+ app.prompt(`Buyout (at least ${coinText(bid)}): `, (t2) => {
339
339
  const buyout = parseMoney(t2);
340
340
  if (buyout == null || buyout < bid) { app.say(red('The buyout must be at least the starting bid.')); app.render(); return; }
341
341
  finish(qty, bid, buyout);
@@ -375,16 +375,16 @@ export function mailDialog(app) {
375
375
  rows(a) {
376
376
  const now = Date.now();
377
377
  const mail = a.g.listMail(now);
378
- const rows = [{ text: `${bold('Mailbox')} ${dim(plural(mail.length, 'letter'))} ${yellow(moneyText(a.ch.gold) || '0c')}${mail.length ? dim(' a: take everything') : ''}`, header: true }, { text: '', header: true }];
378
+ const rows = [{ text: `${bold('Mailbox')} ${dim(plural(mail.length, 'letter'))} ${coinText(a.ch.gold)}${mail.length ? dim(' a: take everything') : ''}`, header: true }, { text: '', header: true }];
379
379
  if (!mail.length) rows.push({ text: dim('No mail.'), header: true });
380
380
  for (const m of mail) {
381
381
  const atts = m.attachments.map((x) => `${a.itemLabel(x.entry)}${x.quantity > 1 ? ` x${x.quantity}` : ''}`);
382
- const bits = [...(m.money ? [yellow(moneyText(m.money))] : []), ...atts];
382
+ const bits = [...(m.money ? [coinText(m.money)] : []), ...atts];
383
383
  const read = (m.checked ?? 0) & 1;
384
384
  rows.push({
385
- text: `${read ? dim(m.subject) : bold(m.subject)} ${dim(`from ${m.sender}`)}${bits.length ? ` ${bits.join(', ')}` : ''}${m.cod ? red(` COD ${moneyText(m.cod)}`) : ''}`,
386
- data: { mail: m },
387
- detail: () => [bold(m.subject), dim(`From ${m.sender}`), '', ...String(m.body ?? '').split('\n').filter(Boolean), ...(bits.length ? ['', cyan('Enclosed'), ...bits.map((b) => ` ${b}`)] : []), ...(m.cod ? ['', red(`Cash on delivery: ${moneyText(m.cod)}`)] : []), '', dim(`Expires in ${secondsText(Math.max(0, m.expiresAt - now))}`)],
385
+ text: `${read ? dim(m.subject) : bold(m.subject)} ${dim(`from ${m.sender}`)}${bits.length ? ` ${bits.join(', ')}` : ''}${m.cod ? red(` COD ${coinText(m.cod)}`) : ''}`,
386
+ data: { mail: m }, icon: (m.attachments.length ? a.ds.item(m.attachments[0].entry)?.icon : null) ?? (m.money ? 'inv_misc_coin_03' : 'inv_letter_01'),
387
+ detail: () => [bold(m.subject), dim(`From ${m.sender}`), '', ...String(m.body ?? '').split('\n').filter(Boolean), ...(bits.length ? ['', cyan('Enclosed'), ...bits.map((b) => ` ${b}`)] : []), ...(m.cod ? ['', red(`Cash on delivery: ${coinText(m.cod)}`)] : []), '', dim(`Expires in ${secondsText(Math.max(0, m.expiresAt - now))}`)],
388
388
  act: () => takeMail(a, m),
389
389
  });
390
390
  }
@@ -396,7 +396,7 @@ export function mailDialog(app) {
396
396
  function takeMail(app, m) {
397
397
  app.g.markMailRead(m.id);
398
398
  let took = 0;
399
- if (m.money) { const r = app.g.takeMailMoney(m.id); if (r.ok && r.taken) { took++; app.say(green(`You take ${moneyText(r.taken)}.`)); } else if (!r.ok) app.say(red(r.reason)); }
399
+ if (m.money) { const r = app.g.takeMailMoney(m.id); if (r.ok && r.taken) { took++; app.say(green(`You take ${coinText(r.taken)}.`)); } else if (!r.ok) app.say(red(r.reason)); }
400
400
  for (let i = 0; i < 16; i++) {
401
401
  const cur = app.g.listMail(Date.now()).find((x) => x.id === m.id);
402
402
  if (!cur?.attachments.length) break;
@@ -465,7 +465,7 @@ export function composeDialog(app) {
465
465
  dest.state.mailbox = r.destMailbox;
466
466
  dest.updatedAt = Date.now();
467
467
  await a.repo.save(dest);
468
- a.say(green(`Mail sent to ${dest.state.char.name}.`) + dim(` Postage ${moneyText(r.cost)}.`));
468
+ a.say(green(`Mail sent to ${dest.state.char.name}.`) + dim(` Postage ${coinText(r.cost)}.`));
469
469
  await a.persist();
470
470
  if (a.top()?.id === 'mail-compose') a.pop(); else a.render();
471
471
  };
@@ -476,7 +476,7 @@ export function composeDialog(app) {
476
476
  const rows = [{ text: bold('Send mail'), header: true }, { text: '', header: true }];
477
477
  rows.push({ text: `To ${m.to ? cyan(m.to.name) : dim('pick one of your characters')}`, act: (a2) => { view.picking = pickAlt(a2, (s) => { m.to = s; }); } });
478
478
  rows.push({ text: `Subject ${m.subject || dim('(the first item, or "A letter")')}`, act: (a2) => a2.prompt('Subject: ', (t) => { m.subject = t.slice(0, 64); a2.render(); }, m.subject) });
479
- rows.push({ text: `${m.cod ? 'COD ' : 'Money '} ${m.money ? yellow(moneyText(m.money)) : dim('none')}`, act: (a2) => a2.prompt(m.cod ? 'Cash on delivery: ' : 'Money to send: ', (t) => { const c = parseMoney(t); if (c == null) a2.flashMsg('Say a price like 2g 50s or 250.'); else m.money = c; a2.render(); }) });
479
+ rows.push({ text: `${m.cod ? 'COD ' : 'Money '} ${m.money ? coinText(m.money) : dim('none')}`, act: (a2) => a2.prompt(m.cod ? 'Cash on delivery: ' : 'Money to send: ', (t) => { const c = parseMoney(t); if (c == null) a2.flashMsg('Say a price like 2g 50s or 250.'); else m.money = c; a2.render(); }) });
480
480
  rows.push({ text: `Pay ${m.cod ? red('cash on delivery: they pay the money to take the items') : dim('the money is a gift')}`, act: () => { m.cod = !m.cod; } });
481
481
  rows.push({ text: '', header: true }, { text: `Enclose ${dim(`${m.attach.size}/${MAX_MAIL_ITEMS}`)}`, header: true });
482
482
  a.g.state.inventory.stacks.forEach((s, slot) => {
@@ -487,7 +487,7 @@ export function composeDialog(app) {
487
487
  act: () => { if (on) m.attach.delete(slot); else if (m.attach.size < MAX_MAIL_ITEMS) m.attach.add(slot); else a.flashMsg(`At most ${MAX_MAIL_ITEMS} stacks in one letter.`); },
488
488
  });
489
489
  });
490
- rows.push({ text: '', header: true }, { text: green(`Send ${dim(`S, postage ${moneyText(MAIL_POSTAGE_COPPER)}`)}`), act: () => { view.sending = send(a); } });
490
+ rows.push({ text: '', header: true }, { text: green(`Send ${dim(`S, postage ${coinText(MAIL_POSTAGE_COPPER)}`)}`), act: () => { view.sending = send(a); } });
491
491
  return rows;
492
492
  },
493
493
  };
@@ -1,15 +1,15 @@
1
1
  // The rest of a companion's page and the party-wide companion dialogs: spec, talent link, AoE, off-heal and xp
2
2
  // lock; their pet; their quests; their bags and bank; the tavern duel; consumable sharing, the party vault
3
3
  // and the hire search. Ports of phonecraft companionsPanel.ts, companionKitPanel.ts and consumableSharePanel.ts.
4
- import { bold, dim, cyan, yellow, green, red, qualityPaint, fit, plural, itemText } from './text.mjs';
4
+ import { bold, dim, cyan, yellow, green, red, qualityPaint, fit, plural, itemText, coinText } from './text.mjs';
5
5
  import { itemLines, noteSale } from './tuiViews.mjs';
6
6
  import { companionUseWithPicker } from './tuiEnchantTarget.mjs';
7
7
  import { startFight } from './tuiFight.mjs';
8
- import { moneyText } from '../domain/economy/money.ts';
9
8
  import { className } from '../domain/core/ids.ts';
10
9
  import { companionLevel, companionCharacter } from '../domain/bots/squad.ts';
11
10
  import { listTameablePets, unlockedWarlockDemons, WARLOCK_DEMONS, WARLOCK_DEMON_UNLOCK } from '../domain/bots/botPet.ts';
12
11
  import { petKnownSpells, petAutocastOn } from '../domain/pet/pet.ts';
12
+ import { MAX_QUEST_SLOTS } from '../domain/quests/questlog.ts';
13
13
  import { SHARE_CATEGORIES, SHARE_CATEGORY_LABEL, SHARE_GROUPS } from '../domain/bots/consumableShare.ts';
14
14
 
15
15
  const H = (text) => ({ text, header: true });
@@ -115,7 +115,23 @@ export function questRows(app, comp) {
115
115
  const offers = g.companionQuestOffers(id);
116
116
  const log = (comp.questLog?.quests ?? []).filter((q) => q.status !== 'turnedIn');
117
117
  if (!offers.length && !log.length) return [];
118
- 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
+ }
119
135
  for (const o of offers) {
120
136
  rows.push({
121
137
  text: o.kind === 'turn-in' ? `${green('?')} Hand in ${o.title}` : `${yellow('!')} Take ${o.title}${o.itemEntry ? dim(` from ${app.itemLabel(o.itemEntry)}`) : ''}`,
@@ -123,7 +139,7 @@ export function questRows(app, comp) {
123
139
  act: () => {
124
140
  if (o.kind === 'accept') { app.result(g.acceptCompanionQuest(id, o.quest), `${comp.name} takes ${o.title}.`); return; }
125
141
  const q = ds.quest(o.quest);
126
- const finish = (choice) => { const r = g.turnInCompanionQuest(id, o.quest, choice); app.result(r, r.ok ? `${comp.name} hands in ${o.title}${r.xp ? `: +${r.xp} xp` : ''}${r.money ? `, ${moneyText(r.money)} to you` : ''}.` : ''); };
142
+ const finish = (choice) => { const r = g.turnInCompanionQuest(id, o.quest, choice); app.result(r, r.ok ? `${comp.name} hands in ${o.title}${r.xp ? `: +${r.xp} xp` : ''}${r.money ? `, ${coinText(r.money)} to you` : ''}.` : ''); };
127
143
  if (q?.rewChoiceItems?.length > 1) app.pick(`${comp.name}: choose a reward`, q.rewChoiceItems.map((it, i) => ({ label: app.itemLabel(it.item), detail: () => itemLines(app, ds.item(it.item)), value: i })), finish);
128
144
  else finish(0);
129
145
  },
@@ -131,7 +147,8 @@ export function questRows(app, comp) {
131
147
  }
132
148
  for (const qs of log) {
133
149
  const q = ds.quest(qs.entry);
134
- 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}.`)) } });
135
152
  }
136
153
  return rows;
137
154
  }
@@ -165,12 +182,13 @@ export function bagRows(app, comp, cap) {
165
182
  const name = `${itemText(it, 'it')}${s.count > 1 ? ` x${s.count}` : ''}`;
166
183
  rows.push({
167
184
  text: `${qualityPaint(it?.quality)(it?.name ?? s.item)}${s.count > 1 ? ` x${s.count}` : ''}`,
185
+ icon: it?.icon, sub: dim(`${it?.typeName ?? ''}${it?.sellPrice ? ` sells ${coinText(it.sellPrice * s.count)}` : ''}`),
168
186
  detail: () => itemLines(app, it, ['', dim('Enter: take it u: use on them s: sell e: disenchant m: to another companion b: their bank d: destroy')]),
169
187
  act: () => { const r = g.takeItemFromBotStack(id, slot); app.result(r, `You take ${it?.name ?? 'it'}${r.moved > 1 ? ` x${r.moved}` : ''} from ${comp.name}.`); },
170
188
  keys: {
171
189
  u: (a) => companionUseWithPicker(a, comp, s.item),
172
190
  d: (a) => a.confirm(`Destroy ${name} from ${comp.name}'s bags?`, () => a.result(g.discardCompanionBagStack(id, slot), `Destroyed ${name}.`)),
173
- s: (a) => { if (!a.vendor) { a.flashMsg('Open a vendor first.'); return; } const r = g.sellCompanionItemStack(id, slot); if (a.result(r, r.ok ? `Sold ${name} for ${moneyText(r.earned)}.` : '')) noteSale(a, s.item, r.qty, r.earned, comp); },
191
+ s: (a) => { if (!a.vendor) { a.flashMsg('Open a vendor first.'); return; } const r = g.sellCompanionItemStack(id, slot); if (a.result(r, r.ok ? `Sold ${name} for ${coinText(r.earned)}.` : '')) noteSale(a, s.item, r.qty, r.earned, comp); },
174
192
  e: (a) => { const r = g.disenchantCompanionBagStack(id, slot); a.result(r, r.ok ? `It breaks into ${r.yielded.map((y) => `${a.itemLabel(y.item)}${y.count > 1 ? ` x${y.count}` : ''}`).join(', ')}.` : ''); },
175
193
  b: (a) => a.result(g.companionBankDepositStack(id, slot), `${name} goes into ${comp.name}'s bank.`),
176
194
  m: (a) => {
@@ -191,10 +209,10 @@ export function bankRows(app, comp) {
191
209
  const rows = [H(''), H(`Their bank ${dim(`${bank.length} stacks Enter: back to their bags`)}`)];
192
210
  bank.forEach((s, slot) => {
193
211
  const it = ds.item(s.item);
194
- rows.push({ text: `${qualityPaint(it?.quality)(it?.name ?? s.item)}${s.count > 1 ? ` x${s.count}` : ''}`, detail: () => itemLines(app, it), act: () => app.result(g.companionBankWithdrawStack(id, slot), `${it?.name} comes back out.`) });
212
+ rows.push({ text: `${qualityPaint(it?.quality)(it?.name ?? s.item)}${s.count > 1 ? ` x${s.count}` : ''}`, icon: it?.icon, sub: dim(it?.typeName ?? ''), detail: () => itemLines(app, it), act: () => app.result(g.companionBankWithdrawStack(id, slot), `${it?.name} comes back out.`) });
195
213
  });
196
214
  const bagSlots = comp.bankBags ?? [];
197
- rows.push({ text: dim('Buy them another bank bag slot'), detail: () => [bold('Bank bag slot'), '', 'Paid from your purse; the price climbs with each slot like your own.'], act: () => { const r = g.buyCompanionBankSlot(id); app.result(r, r.ok ? `A new bank bag slot for ${comp.name}: ${moneyText(r.cost)}.` : ''); } });
215
+ rows.push({ text: dim('Buy them another bank bag slot'), detail: () => [bold('Bank bag slot'), '', 'Paid from your purse; the price climbs with each slot like your own.'], act: () => { const r = g.buyCompanionBankSlot(id); app.result(r, r.ok ? `A new bank bag slot for ${comp.name}: ${coinText(r.cost)}.` : ''); } });
198
216
  const bagsInBags = g.state.inventory.stacks.map((s) => ds.item(s.item)).filter((it) => it?.slot === 'bag');
199
217
  if (bagsInBags.length) {
200
218
  const free = bagSlots.map((b, i) => (b == null ? i : -1)).filter((i) => i >= 0);