azeroth-shell 0.1.2 → 0.3.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 (80) hide show
  1. package/README.md +362 -85
  2. package/bin/azsh.mjs +35 -19
  3. package/package.json +12 -1
  4. package/src/autopilot/plan.ts +8 -11
  5. package/src/autopilot/view.ts +12 -11
  6. package/src/cli/combatLogFormat.ts +22 -0
  7. package/src/cli/gzLoader.mjs +47 -6
  8. package/src/cli/hdArt.mjs +81 -0
  9. package/src/cli/iconArt.mjs +145 -0
  10. package/src/cli/iconGrid.mjs +100 -0
  11. package/src/cli/modelArt.mjs +298 -0
  12. package/src/cli/oocPrep.mjs +32 -0
  13. package/src/cli/paperdoll.mjs +76 -0
  14. package/src/cli/perfCheck.mjs +56 -0
  15. package/src/cli/repl.mjs +48 -7
  16. package/src/cli/rotationText.mjs +6 -6
  17. package/src/cli/sixel.mjs +319 -0
  18. package/src/cli/term.mjs +106 -12
  19. package/src/cli/text.mjs +21 -3
  20. package/src/cli/tui.mjs +262 -102
  21. package/src/cli/tuiAutopilot.mjs +356 -0
  22. package/src/cli/tuiBags.mjs +37 -4
  23. package/src/cli/tuiBg.mjs +370 -41
  24. package/src/cli/tuiCheck.mjs +633 -37
  25. package/src/cli/tuiCity.mjs +146 -41
  26. package/src/cli/tuiCompanionKit.mjs +6 -6
  27. package/src/cli/tuiCompanions.mjs +211 -41
  28. package/src/cli/tuiCreate.mjs +230 -0
  29. package/src/cli/tuiDungeon.mjs +57 -21
  30. package/src/cli/tuiFight.mjs +467 -88
  31. package/src/cli/tuiFightPanes.mjs +42 -14
  32. package/src/cli/tuiFloor.mjs +21 -9
  33. package/src/cli/tuiGear.mjs +17 -14
  34. package/src/cli/tuiLoot.mjs +6 -6
  35. package/src/cli/tuiMap.mjs +214 -0
  36. package/src/cli/tuiPaperdoll.mjs +108 -0
  37. package/src/cli/tuiPartyStage.mjs +206 -0
  38. package/src/cli/tuiPet.mjs +16 -8
  39. package/src/cli/tuiProfessions.mjs +27 -25
  40. package/src/cli/tuiRotationCond.mjs +390 -0
  41. package/src/cli/tuiSaves.mjs +24 -1
  42. package/src/cli/tuiSearch.mjs +79 -27
  43. package/src/cli/tuiSettings.mjs +3 -4
  44. package/src/cli/tuiStage.mjs +360 -0
  45. package/src/cli/tuiTalentTree.mjs +124 -0
  46. package/src/cli/tuiTalents.mjs +3 -4
  47. package/src/cli/tuiViews.mjs +633 -172
  48. package/src/cli/tuiWiki.mjs +2 -3
  49. package/src/cli/tuiWorld.mjs +90 -7
  50. package/src/data/gear-art.bin.br +0 -0
  51. package/src/data/hd-art.json +114 -0
  52. package/src/data/icon-art.bin.gz +0 -0
  53. package/src/data/model-art-worn.bin.br +0 -0
  54. package/src/data/model-art.bin.gz +0 -0
  55. package/src/data/rotations/pve/paladin/retribution.json +2 -2
  56. package/src/data/tables/creatures.json.gz +0 -0
  57. package/src/data/world-map-sixel.bin.br +0 -0
  58. package/src/data/world-map.bin.gz +0 -0
  59. package/src/domain/bg/match.ts +85 -24
  60. package/src/domain/bg/strategies.ts +144 -29
  61. package/src/domain/bg/types.ts +2 -0
  62. package/src/domain/bg/wsg.ts +35 -20
  63. package/src/domain/character/character.ts +6 -0
  64. package/src/domain/pet/pet.ts +31 -0
  65. package/src/domain/quests/doQuest.ts +24 -6
  66. package/src/domain/rotation/build.ts +3031 -3031
  67. package/src/domain/rotation/classes/paladin.ts +16 -11
  68. package/src/domain/rotation/evaluate.ts +3 -0
  69. package/src/engine/combat/applySpell.ts +20 -9
  70. package/src/engine/combat/applySpellLate.ts +16 -12
  71. package/src/engine/combat/areaAura.ts +284 -283
  72. package/src/engine/combat/fighterFactory.ts +1 -1
  73. package/src/engine/combat/pickAction.ts +3494 -3449
  74. package/src/engine/combat/procsDamage.ts +31 -1
  75. package/src/engine/combat/sessionChase.ts +19 -2
  76. package/src/engine/combat/sessionMotionKit.ts +40 -2
  77. package/src/engine/game/loadReconcile.ts +21 -1
  78. package/src/engine/game/pets.ts +33 -1
  79. package/src/engine/game/types.ts +4 -0
  80. package/src/quests/quests.ts +60 -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, 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
  });
@@ -116,61 +116,159 @@ export function bankDialog(app, startMode = 'deposit') {
116
116
 
117
117
  // ---- auction house ------------------------------------------------------------------------------
118
118
 
119
+ // Browse categories: ItemClass.dbc in AuctionFrame.lua order, as phonecraft's auctionModel.ts. Subclass names are
120
+ // ItemSubClass.dbc; the TBC-era consumable and trade goods subclasses the item table carries get their names too.
121
+ const AH_CLASSES = [
122
+ [2, 'Weapons'], [4, 'Armor'], [1, 'Container'], [0, 'Consumable'], [7, 'Trade Goods'],
123
+ [6, 'Projectile'], [11, 'Quiver'], [9, 'Recipe'], [5, 'Reagent'], [15, 'Miscellaneous'],
124
+ ];
125
+ const AH_SUBCLASSES = {
126
+ 0: ['Consumable', 'Potion', 'Elixir', 'Flask', 'Scroll', 'Food & Drink', 'Item Enhancement', 'Bandage', 'Other'],
127
+ 1: ['Bag', 'Soul Bag', 'Herb Bag', 'Enchanting Bag', 'Engineering Bag'],
128
+ 2: { 0: 'One-Handed Axes', 1: 'Two-Handed Axes', 2: 'Bows', 3: 'Guns', 4: 'One-Handed Maces', 5: 'Two-Handed Maces', 6: 'Polearms', 7: 'One-Handed Swords', 8: 'Two-Handed Swords', 10: 'Staves', 13: 'Fist Weapons', 14: 'Miscellaneous', 15: 'Daggers', 16: 'Thrown', 17: 'Spears', 18: 'Crossbows', 19: 'Wands', 20: 'Fishing Poles' },
129
+ 4: { 0: 'Miscellaneous', 1: 'Cloth', 2: 'Leather', 3: 'Mail', 4: 'Plate', 6: 'Shields', 7: 'Librams', 8: 'Idols', 9: 'Totems' },
130
+ 5: ['Reagent'],
131
+ 6: { 2: 'Arrows', 3: 'Bullets' },
132
+ 7: ['Trade Goods', 'Parts', 'Explosives', 'Devices', 'Jewelcrafting', 'Cloth', 'Leather', 'Metal & Stone', 'Meat', 'Herbs', 'Elemental', 'Other', 'Enchanting'],
133
+ 9: ['Books', 'Leatherworking', 'Tailoring', 'Engineering', 'Blacksmithing', 'Cooking', 'Alchemy', 'First Aid', 'Enchanting', 'Fishing'],
134
+ 11: { 2: 'Quivers', 3: 'Ammo Pouches' },
135
+ 15: ['Junk'],
136
+ };
137
+
138
+ /** [category, subcategory] of an item: a browse class (15 catches the rest) and a subclass name. */
139
+ function ahCategory(it) {
140
+ const cls = AH_CLASSES.some(([c]) => c === it?.itemClass) ? it.itemClass : 15;
141
+ const sub = it?.itemSubclass ?? it?.weaponSubclass ?? it?.armorSubclass ?? 0;
142
+ return [cls, AH_SUBCLASSES[cls]?.[sub] ?? 'Other'];
143
+ }
144
+
145
+ /**
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
+ * under the cursor is drawn inverse whole, so there the pick is bracketed instead.
148
+ */
149
+ export function tabStrip(labels, at, width, focused = false) {
150
+ const cells = labels.map((l, i) => (i === at ? (useColor && !focused ? inverse(` ${l} `) : focused ? bold(`[${l}]`) : `[${l}]`) : ` ${l} `));
151
+ let from = 0, to = cells.length;
152
+ const len = () => cells.slice(from, to).reduce((n, c) => n + vis(c), 0) + (from > 0 ? 2 : 0) + (to < cells.length ? 2 : 0);
153
+ while (len() > width && to - from > 1) { if (at - from > to - 1 - at) from++; else to--; }
154
+ return `${from > 0 ? dim('‹ ') : ''}${cells.slice(from, to).join('')}${to < cells.length ? dim(' ›') : ''}`;
155
+ }
156
+
119
157
  export function auctionDialog(app) {
120
158
  app.g.refillAuctions(Date.now());
121
159
  let mode = 'browse';
122
160
  let filter = '';
161
+ let cat = 'all', sub = 'all';
123
162
  const MODES = ['browse', 'sell', 'mine'];
124
- const hint = () => ({ browse: 'Enter: buyout Tab: sell', sell: 'Enter: list for sale Tab: my auctions', mine: 'Enter: cancel listing Tab: browse' })[mode];
163
+ // The category and subcategory strips are cursor rows: Up from the listings lands on them, Left Right turns them.
164
+ let lastRows = [];
165
+ const stripAt = () => lastRows[view.cursor]?.data?.strip;
166
+ const hint = () => {
167
+ if (mode === 'browse') {
168
+ const s = stripAt(), hasSub = lastRows.some((r) => r.data?.strip === 'sub');
169
+ if (s === 'cat') return `Left Right: category Down: ${hasSub ? 'subcategory' : 'listings'} Tab: sell`;
170
+ if (s === 'sub') return 'Left Right: subcategory Up: category Down: listings Tab: sell';
171
+ return `Enter: buyout Left Right: category Up to the tabs: ${hasSub ? 'subcategory' : 'category'} Tab: sell`;
172
+ }
173
+ return { sell: 'Enter: list for sale Tab: my auctions', mine: 'Enter: cancel listing Tab: browse' }[mode];
174
+ };
125
175
  const unit = (x) => Math.round(x.buyoutCopper / x.quantity);
176
+ // Back to the top of the list; in browse the cursor goes to the first listing, past the strips.
177
+ let toList = true;
178
+ const top = () => { toList = true; view.scroll = 0; };
179
+ // The listings the search leaves, the categories they fall in (the picked one stays even when it empties)
180
+ // and the subcategories of the picked category, each with its count.
181
+ const browse = (a, now) => {
182
+ let all = a.g.browseAuctions(now).map((au) => { const it = a.ds.item(au.item); return { au, it, c: ahCategory(it) }; });
183
+ if (filter) all = all.filter((x) => (x.it?.name ?? '').toLowerCase().includes(filter));
184
+ const count = (pred) => all.filter(pred).length;
185
+ const cats = [{ key: 'all', label: 'All', n: all.length }];
186
+ for (const [c, label] of AH_CLASSES) { const n = count((x) => x.c[0] === c); if (n || c === cat) cats.push({ key: c, label, n }); }
187
+ const inCat = cat === 'all' ? all : all.filter((x) => x.c[0] === cat);
188
+ const subs = [{ key: 'all', label: 'All', n: inCat.length }];
189
+ for (const x of inCat) { const s = subs.find((y) => y.key === x.c[1]); if (s) s.n++; else subs.push({ key: x.c[1], label: x.c[1], n: 1 }); }
190
+ if (sub !== 'all' && !subs.some((s) => s.key === sub)) subs.push({ key: sub, label: sub, n: 0 });
191
+ const order = Object.values(AH_SUBCLASSES[cat] ?? {}), rank = (s) => (s.key === 'all' ? -1 : order.includes(s.key) ? order.indexOf(s.key) : order.length);
192
+ subs.sort((x, y) => rank(x) - rank(y));
193
+ const list = sub === 'all' ? inCat : inCat.filter((x) => x.c[1] === sub);
194
+ return { cats, subs, list };
195
+ };
196
+ // Turn a strip; on a strip the cursor stays put so it can keep turning, from a listing it goes back to the top.
197
+ const step = (a, which, d) => {
198
+ const b = browse(a, Date.now());
199
+ const opts = which === 'cat' ? b.cats : b.subs;
200
+ if (opts.length < 2) return;
201
+ const i = opts.findIndex((o) => o.key === (which === 'cat' ? cat : sub));
202
+ const next = opts[(i + d + opts.length) % opts.length].key;
203
+ if (which === 'cat') { cat = next; sub = 'all'; } else sub = next;
204
+ if (stripAt()) view.scroll = 0; else top();
205
+ };
126
206
  const view = {
127
- id: 'auction', title: 'Auction House', hint: hint(),
207
+ id: 'auction', title: 'Auction House',
208
+ get hint() { return hint(); },
128
209
  keys: {
129
- s: { hint: 'search', fn: (a) => { if (mode !== 'browse') return; a.prompt('Search: ', (t) => { filter = t.trim().toLowerCase(); view.cursor = 0; view.scroll = 0; a.render(); }, filter); } },
210
+ s: { hint: 'search', fn: (a) => { if (mode !== 'browse') return; a.prompt('Search: ', (t) => { filter = t.trim().toLowerCase(); top(); a.render(); }, filter); } },
211
+ '[': { fn: (a) => { if (mode === 'browse') step(a, 'sub', -1); } },
212
+ ']': { fn: (a) => { if (mode === 'browse') step(a, 'sub', +1); } },
130
213
  b: { hint: 'bid', fn: (a, row) => {
131
214
  if (mode !== 'browse' || !row?.data?.auction) return;
132
215
  const au = row.data.auction, min = minNextBid(au);
133
- 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) => {
134
217
  const amount = parseMoney(t);
135
218
  if (amount == null) { a.say(red('Say a price like 2g 50s or 250.')); a.render(); return; }
136
- 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)}.`);
137
220
  a.afterAction();
138
221
  }, moneyText(min));
139
222
  } },
140
223
  m: { hint: 'mailbox', fn: (a) => a.push(mailDialog(a)) },
141
224
  },
142
225
  onKey(a, k) {
143
- if (k !== 'tab') return false;
144
- mode = MODES[(MODES.indexOf(mode) + 1) % MODES.length];
226
+ if (mode === 'browse' && (k === 'left' || k === 'right')) { step(a, stripAt() ?? 'cat', k === 'right' ? +1 : -1); return true; }
227
+ if (k !== 'tab' && k !== 'shift-tab') return false;
228
+ mode = MODES[(MODES.indexOf(mode) + (k === 'tab' ? 1 : MODES.length - 1)) % MODES.length];
145
229
  if (mode === 'browse') a.g.refillAuctions(Date.now());
146
- view.cursor = 0; view.scroll = 0; view.hint = hint();
230
+ top();
147
231
  return true;
148
232
  },
149
233
  rows(a) {
150
234
  const now = Date.now(), ds = a.ds;
151
235
  const unread = a.g.unreadMailCount(now);
236
+ const L = a.layout(), width = (L.detailW ? L.listW : L.W) - 2;
237
+ const modeLabels = MODES.map((m) => ({ browse: 'Browse', sell: 'Sell', mine: 'My auctions' }[m]));
152
238
  const rows = [
153
- { text: `${bold('Auction House')} ${MODES.map((m) => (m === mode ? bold : dim)({ browse: 'Browse', sell: 'Sell', mine: 'My auctions' }[m])).join(dim(' | '))} ${yellow(moneyText(a.ch.gold) || '0c')}${unread ? yellow(` ${unread} new mail`) : ''}`, header: true },
154
- { text: '', header: true },
239
+ { text: `${bold('Auction House')} ${tabStrip(modeLabels, MODES.indexOf(mode), 40)} ${coinText(a.ch.gold)}${unread ? yellow(` ${unread} new mail`) : ''}`, header: true },
155
240
  ];
156
241
  if (mode === 'browse') {
157
- let list = a.g.browseAuctions(now);
158
- if (filter) list = list.filter((x) => (ds.item(x.item)?.name ?? '').toLowerCase().includes(filter));
242
+ const b = browse(a, now);
243
+ const strip = (which, opts, key, label) => {
244
+ const focused = !toList && view.cursor === rows.length;
245
+ rows.push({
246
+ text: tabStrip(opts.map((o) => `${o.label} ${o.n}`), opts.findIndex((o) => o.key === key), width - 1, focused), data: { strip: which },
247
+ detail: () => [bold(label), '', dim('Left Right: pick one'), dim(`Down${which === 'cat' && rows.some((r) => r.data?.strip === 'sub') ? ': subcategories' : ' or Enter: the listings'}`)],
248
+ act: (a2) => a2.moveCursor(view, a2.rowsOf(view), +1),
249
+ });
250
+ };
251
+ strip('cat', b.cats, cat, 'Category');
252
+ if (cat !== 'all' && b.subs.length > 2) strip('sub', b.subs, sub, 'Subcategory');
253
+ rows.push({ text: '', header: true });
254
+ const list = b.list.map((x) => x.au);
159
255
  list.sort((x, y) => (ds.item(x.item)?.name ?? '').localeCompare(ds.item(y.item)?.name ?? '') || unit(x) - unit(y));
160
- rows.push({ text: `${filter ? `"${filter}": ` : ''}${plural(list.length, 'listing')}${filter ? dim(' s: change search') : dim(' s: search by name')}`, header: true });
161
- if (!list.length) rows.push({ text: dim('Nothing on the boards right now.'), header: true });
256
+ const where = cat === 'all' ? '' : `${b.cats.find((c) => c.key === cat)?.label}${sub === 'all' ? '' : ` › ${sub}`}: `;
257
+ rows.push({ text: `${where}${filter ? `"${filter}": ` : ''}${plural(list.length, 'listing')}${filter ? dim(' s: change search') : dim(' s: search by name')}`, header: true });
258
+ if (!list.length) rows.push({ text: dim(filter || cat !== 'all' ? 'Nothing listed here.' : 'Nothing on the boards right now.'), header: true });
162
259
  for (const au of list) {
163
260
  const it = ds.item(au.item);
164
261
  const can = au.buyoutCopper <= a.ch.gold;
165
262
  const name = `${qualityPaint(it?.quality)(it?.name ?? au.item)}${au.quantity > 1 ? ` x${au.quantity}` : ''}`;
166
263
  rows.push({
167
- 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}`) : ''}`,
168
- data: { auction: au },
169
- 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')]),
170
- 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)}.`)),
171
268
  });
172
269
  }
173
270
  } else if (mode === 'sell') {
271
+ rows.push({ text: '', header: true });
174
272
  const st = a.g.state.inventory.stacks;
175
273
  rows.push({ text: dim('Pick something from your bags to put up for auction.'), header: true });
176
274
  if (!st.length) rows.push({ text: dim('Your bags are empty.'), header: true });
@@ -179,12 +277,13 @@ export function auctionDialog(app) {
179
277
  const soulbound = !!s.bound, quest = !!it?.questItem;
180
278
  const name = `${qualityPaint(it?.quality)(it?.name ?? s.item)}${s.count > 1 ? ` x${s.count}` : ''}`;
181
279
  rows.push({
182
- 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 ?? ''),
183
281
  detail: () => itemLines(a, it, ['', dim('Enter: set prices and list it')]),
184
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); },
185
283
  });
186
284
  }
187
285
  } else {
286
+ rows.push({ text: '', header: true });
188
287
  const own = a.g.ownAuctions(), bids = a.g.myBidAuctions();
189
288
  const mail = a.g.listMail(now);
190
289
  rows.push({ text: `Mailbox: ${mail.length ? `${plural(mail.length, 'letter')} waiting` : 'empty'}${dim(' (sales and refunds arrive here)')}`, act: () => a.push(mailDialog(a)), data: {} });
@@ -193,9 +292,9 @@ export function auctionDialog(app) {
193
292
  for (const au of own) {
194
293
  const it = ds.item(au.item);
195
294
  rows.push({
196
- 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)}`,
197
- data: { auction: au },
198
- 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)')]),
199
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.`)),
200
299
  });
201
300
  }
@@ -203,9 +302,15 @@ export function auctionDialog(app) {
203
302
  if (!bids.length) rows.push({ text: dim(' You hold no high bids.'), header: true });
204
303
  for (const au of bids) {
205
304
  const it = ds.item(au.item);
206
- 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 });
207
306
  }
208
307
  }
308
+ if (toList) {
309
+ toList = false;
310
+ const first = mode === 'browse' ? rows.findIndex((r) => r.data?.auction) : -1;
311
+ view.cursor = first >= 0 ? first : 0;
312
+ }
313
+ lastRows = rows;
209
314
  return rows;
210
315
  },
211
316
  };
@@ -217,11 +322,11 @@ function sellFlow(app, s, it) {
217
322
  const finish = (qty, bid, buyout) => {
218
323
  const opts = Object.entries(AH_DURATIONS_MS).map(([k, ms]) => {
219
324
  const dep = auctionDeposit(it?.sellPrice ?? 0, qty, ms / 1000);
220
- 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 };
221
326
  });
222
327
  app.pick('How long should it run?', opts, (dur) => {
223
328
  const r = app.g.listAuction(s.item, qty, bid, buyout, dur, Date.now());
224
- 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)}.` : '');
225
330
  app.afterAction();
226
331
  });
227
332
  };
@@ -230,7 +335,7 @@ function sellFlow(app, s, it) {
230
335
  app.prompt(`Starting bid for ${itemText(it)}${qty > 1 ? ` x${qty}` : ''} (e.g. 2g 50s): `, (t1) => {
231
336
  const bid = parseMoney(t1);
232
337
  if (bid == null || bid < 1) { app.say(red('Say a price like 2g 50s or 250.')); app.render(); return; }
233
- app.prompt(`Buyout (at least ${moneyText(bid)}): `, (t2) => {
338
+ app.prompt(`Buyout (at least ${coinText(bid)}): `, (t2) => {
234
339
  const buyout = parseMoney(t2);
235
340
  if (buyout == null || buyout < bid) { app.say(red('The buyout must be at least the starting bid.')); app.render(); return; }
236
341
  finish(qty, bid, buyout);
@@ -270,16 +375,16 @@ export function mailDialog(app) {
270
375
  rows(a) {
271
376
  const now = Date.now();
272
377
  const mail = a.g.listMail(now);
273
- 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 }];
274
379
  if (!mail.length) rows.push({ text: dim('No mail.'), header: true });
275
380
  for (const m of mail) {
276
381
  const atts = m.attachments.map((x) => `${a.itemLabel(x.entry)}${x.quantity > 1 ? ` x${x.quantity}` : ''}`);
277
- const bits = [...(m.money ? [yellow(moneyText(m.money))] : []), ...atts];
382
+ const bits = [...(m.money ? [coinText(m.money)] : []), ...atts];
278
383
  const read = (m.checked ?? 0) & 1;
279
384
  rows.push({
280
- text: `${read ? dim(m.subject) : bold(m.subject)} ${dim(`from ${m.sender}`)}${bits.length ? ` ${bits.join(', ')}` : ''}${m.cod ? red(` COD ${moneyText(m.cod)}`) : ''}`,
281
- data: { mail: m },
282
- 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))}`)],
283
388
  act: () => takeMail(a, m),
284
389
  });
285
390
  }
@@ -291,7 +396,7 @@ export function mailDialog(app) {
291
396
  function takeMail(app, m) {
292
397
  app.g.markMailRead(m.id);
293
398
  let took = 0;
294
- 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)); }
295
400
  for (let i = 0; i < 16; i++) {
296
401
  const cur = app.g.listMail(Date.now()).find((x) => x.id === m.id);
297
402
  if (!cur?.attachments.length) break;
@@ -360,7 +465,7 @@ export function composeDialog(app) {
360
465
  dest.state.mailbox = r.destMailbox;
361
466
  dest.updatedAt = Date.now();
362
467
  await a.repo.save(dest);
363
- 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)}.`));
364
469
  await a.persist();
365
470
  if (a.top()?.id === 'mail-compose') a.pop(); else a.render();
366
471
  };
@@ -371,7 +476,7 @@ export function composeDialog(app) {
371
476
  const rows = [{ text: bold('Send mail'), header: true }, { text: '', header: true }];
372
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; }); } });
373
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) });
374
- 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(); }) });
375
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; } });
376
481
  rows.push({ text: '', header: true }, { text: `Enclose ${dim(`${m.attach.size}/${MAX_MAIL_ITEMS}`)}`, header: true });
377
482
  a.g.state.inventory.stacks.forEach((s, slot) => {
@@ -382,7 +487,7 @@ export function composeDialog(app) {
382
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.`); },
383
488
  });
384
489
  });
385
- 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); } });
386
491
  return rows;
387
492
  },
388
493
  };
@@ -1,11 +1,10 @@
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';
@@ -123,7 +122,7 @@ export function questRows(app, comp) {
123
122
  act: () => {
124
123
  if (o.kind === 'accept') { app.result(g.acceptCompanionQuest(id, o.quest), `${comp.name} takes ${o.title}.`); return; }
125
124
  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` : ''}.` : ''); };
125
+ 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
126
  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
127
  else finish(0);
129
128
  },
@@ -165,12 +164,13 @@ export function bagRows(app, comp, cap) {
165
164
  const name = `${itemText(it, 'it')}${s.count > 1 ? ` x${s.count}` : ''}`;
166
165
  rows.push({
167
166
  text: `${qualityPaint(it?.quality)(it?.name ?? s.item)}${s.count > 1 ? ` x${s.count}` : ''}`,
167
+ icon: it?.icon, sub: dim(`${it?.typeName ?? ''}${it?.sellPrice ? ` sells ${coinText(it.sellPrice * s.count)}` : ''}`),
168
168
  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
169
  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
170
  keys: {
171
171
  u: (a) => companionUseWithPicker(a, comp, s.item),
172
172
  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); },
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 ${coinText(r.earned)}.` : '')) noteSale(a, s.item, r.qty, r.earned, comp); },
174
174
  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
175
  b: (a) => a.result(g.companionBankDepositStack(id, slot), `${name} goes into ${comp.name}'s bank.`),
176
176
  m: (a) => {
@@ -191,10 +191,10 @@ export function bankRows(app, comp) {
191
191
  const rows = [H(''), H(`Their bank ${dim(`${bank.length} stacks Enter: back to their bags`)}`)];
192
192
  bank.forEach((s, slot) => {
193
193
  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.`) });
194
+ 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
195
  });
196
196
  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)}.` : ''); } });
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}: ${coinText(r.cost)}.` : ''); } });
198
198
  const bagsInBags = g.state.inventory.stacks.map((s) => ds.item(s.item)).filter((it) => it?.slot === 'bag');
199
199
  if (bagsInBags.length) {
200
200
  const free = bagSlots.map((b, i) => (b == null ? i : -1)).filter((i) => i >= 0);