azeroth-shell 0.1.1 → 0.2.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 (66) hide show
  1. package/README.md +326 -82
  2. package/bin/azsh.mjs +21 -19
  3. package/package.json +19 -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/fileRepository.mjs +46 -1
  8. package/src/cli/gzLoader.mjs +47 -6
  9. package/src/cli/hdArt.mjs +81 -0
  10. package/src/cli/iconArt.mjs +135 -0
  11. package/src/cli/iconGrid.mjs +100 -0
  12. package/src/cli/modelArt.mjs +295 -0
  13. package/src/cli/paperdoll.mjs +76 -0
  14. package/src/cli/perfCheck.mjs +56 -0
  15. package/src/cli/repl.mjs +36 -7
  16. package/src/cli/rotationText.mjs +6 -6
  17. package/src/cli/sixel.mjs +307 -0
  18. package/src/cli/term.mjs +106 -12
  19. package/src/cli/text.mjs +5 -3
  20. package/src/cli/tui.mjs +252 -98
  21. package/src/cli/tuiAutopilot.mjs +347 -0
  22. package/src/cli/tuiBg.mjs +371 -42
  23. package/src/cli/tuiCheck.mjs +415 -28
  24. package/src/cli/tuiCity.mjs +118 -13
  25. package/src/cli/tuiCompanions.mjs +25 -15
  26. package/src/cli/tuiCreate.mjs +223 -0
  27. package/src/cli/tuiDungeon.mjs +1 -1
  28. package/src/cli/tuiFight.mjs +425 -88
  29. package/src/cli/tuiFightPanes.mjs +27 -8
  30. package/src/cli/tuiFloor.mjs +2 -2
  31. package/src/cli/tuiGear.mjs +10 -6
  32. package/src/cli/tuiLoot.mjs +1 -0
  33. package/src/cli/tuiPaperdoll.mjs +107 -0
  34. package/src/cli/tuiPartyStage.mjs +177 -0
  35. package/src/cli/tuiProfessions.mjs +4 -2
  36. package/src/cli/tuiRotationCond.mjs +390 -0
  37. package/src/cli/tuiSaves.mjs +22 -3
  38. package/src/cli/tuiSearch.mjs +75 -22
  39. package/src/cli/tuiSettings.mjs +15 -0
  40. package/src/cli/tuiSimLab.mjs +1 -1
  41. package/src/cli/tuiStage.mjs +304 -0
  42. package/src/cli/tuiTalentTree.mjs +124 -0
  43. package/src/cli/tuiViews.mjs +296 -115
  44. package/src/cli/tuiWorld.mjs +4 -5
  45. package/src/data/gear-art.bin.br +0 -0
  46. package/src/data/hd-art.json +114 -0
  47. package/src/data/icon-art.bin.gz +0 -0
  48. package/src/data/model-art.bin.gz +0 -0
  49. package/src/data/rotations/pve/paladin/retribution.json +2 -2
  50. package/src/domain/bg/match.ts +85 -24
  51. package/src/domain/bg/strategies.ts +144 -29
  52. package/src/domain/bg/types.ts +2 -0
  53. package/src/domain/bg/wsg.ts +35 -20
  54. package/src/domain/quests/doQuest.ts +24 -6
  55. package/src/domain/rotation/build.ts +3031 -3031
  56. package/src/domain/rotation/classes/paladin.ts +16 -11
  57. package/src/domain/rotation/evaluate.ts +3 -0
  58. package/src/engine/combat/applySpell.ts +20 -9
  59. package/src/engine/combat/applySpellLate.ts +16 -12
  60. package/src/engine/combat/areaAura.ts +284 -283
  61. package/src/engine/combat/fighterFactory.ts +1 -1
  62. package/src/engine/combat/pickAction.ts +39 -1
  63. package/src/engine/combat/procsDamage.ts +31 -1
  64. package/src/engine/combat/sessionChase.ts +7 -1
  65. package/src/engine/combat/sessionMotionKit.ts +40 -2
  66. package/src/quests/quests.ts +60 -0
@@ -1,7 +1,7 @@
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';
4
+ import { bold, dim, cyan, yellow, green, red, inverse, vis, useColor, qualityPaint, fit, secondsText, plural, itemText } from './text.mjs';
5
5
  import { itemLines } from './tuiViews.mjs';
6
6
  import { moneyText } from '../domain/economy/money.ts';
7
7
  import { bagCapacity } from '../domain/items/equipment.ts';
@@ -116,17 +116,100 @@ 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
+ 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);
@@ -140,25 +223,39 @@ export function auctionDialog(app) {
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)} ${yellow(moneyText(a.ch.gold) || '0c')}${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;
@@ -171,6 +268,7 @@ export function auctionDialog(app) {
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 });
@@ -185,6 +283,7 @@ export function auctionDialog(app) {
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: {} });
@@ -206,6 +305,12 @@ export function auctionDialog(app) {
206
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 });
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
  };
@@ -1,7 +1,7 @@
1
1
  // The Companions tab: your party and bench, hiring at an inn, one page per companion (role, gear, bags,
2
2
  // professions) and their gathering trips, and the squad mode that decides who fights beside you. A trimmed
3
3
  // port of phonecraft's companionsPanel.ts, companionMissionsDesk.ts and squadModePicker.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, vis, wrapLines } from './text.mjs';
5
5
  import { itemLines } from './tuiViews.mjs';
6
6
  import { recipeBook, openSendGoods, companionsOf } from './tuiProfessions.mjs';
7
7
  import { SQUAD_MODES, SQUAD_MODE_LABEL, squadModeOf, setSquadMode } from './squad.mjs';
@@ -10,6 +10,7 @@ import { barsDialog, companionBarsOwner } from './tuiBars.mjs';
10
10
  import { enchantLines } from './tuiGear.mjs';
11
11
  import { moneyText } from '../domain/economy/money.ts';
12
12
  import { className, raceName } from '../domain/core/ids.ts';
13
+ import { paperdollArt } from './paperdoll.mjs';
13
14
  import { bagCapacity, EQUIP_SLOTS, SLOT_LABEL, itemSlotTargets } from '../domain/items/equipment.ts';
14
15
  import { equippedEntry } from '../domain/items/itemInstance.ts';
15
16
  import { companionCharacter, companionLevel, hireSkillMap, MAX_COMPANIONS, SQUAD_ROLES } from '../domain/bots/squad.ts';
@@ -76,7 +77,15 @@ function statLines(app, char) {
76
77
 
77
78
  function statRows(app, char, note) {
78
79
  const lines = statLines(app, char);
79
- return lines.map((l) => ({ text: l.trim(), detail: () => [bold('Stats'), '', ...lines, '', dim(note)] }));
80
+ return lines.map((l) => ({ text: l.trim(), detail: () => withArt(app, char, [bold('Stats'), '', ...lines, '', dim(note)]) }));
81
+ }
82
+
83
+ /** The text, then `subject` (a companion, a candidate's body, any { race, gender, name, equipped }) drawn in their gear under it when the pane has room. */
84
+ function withArt(app, subject, text) {
85
+ const L = app.layout(), w = L.detailW - 2;
86
+ const used = text.reduce((n, line) => n + (vis(line) > w ? wrapLines(line, w).length : 1), 0); // as the pane wraps them
87
+ const art = paperdollArt(subject, w, L.mainH - used - 1);
88
+ return art ? [...text, '', ...art] : text;
80
89
  }
81
90
 
82
91
  function wornLines(app, gear) {
@@ -134,7 +143,7 @@ export function companionsView(app) {
134
143
  const rows = [H(`Party ${squad.length}/${MAX_COMPANIONS} ${dim(`trip energy ${energy}/${MAX_COMPANION_ENERGY}`)} ${yellow(moneyText(a.ch.gold) || '0c')}`), squadModeRow(a)];
135
144
  const row = (c) => ({
136
145
  text: `${fit(c.name, 12)} ${fit(dim(`L${companionLevel(c, a.ch.level)} ${className(c.cls)}`), 12)} ${fit(roleText(c.role), 6)} ${c.mission ? yellow('away ') : ''}${profTags(compProfLines(a, c))}`,
137
- detail: () => [...compLines(a, c), '', dim('Enter: open their page')],
146
+ detail: () => withArt(a, c, [...compLines(a, c), '', dim('Enter: open their page')]), // them in their gear
138
147
  act: () => a.push(companionDialog(a, c.id)),
139
148
  });
140
149
  if (!squad.length && !bench.length) rows.push(H(''), H(dim(' You travel alone. Innkeepers hire out companions: they fight beside you, carry and craft.')));
@@ -249,7 +258,7 @@ function hireDialog(app, results) {
249
258
  rows.push({
250
259
  text: `${fit(b.name, 12)} ${fit(dim(`L${b.level} ${className(b.cls)}`), 12)} ${fit(c.specRole ? roleText(c.specRole) : '', 6)} ${fit(hireText(a, c, true), 7)} ${profTags(candProfLines(c))}`,
251
260
  data: b.id,
252
- detail: () => [...candLines(a, c), '', dim('Enter: their page h: hire')],
261
+ detail: () => withArt(a, candCharacter(a, c), [...candLines(a, c), '', dim('Enter: their page h: hire')]),
253
262
  act: () => a.push(candidateDialog(a, b.id)),
254
263
  });
255
264
  }
@@ -266,7 +275,7 @@ function candidateDialog(app, botId) {
266
275
  rows(a) {
267
276
  const c = findCand(a, botId);
268
277
  if (!c) { setTimeout(() => { if (a.top()?.id === viewId) a.pop(); }, 0); return [H('They are no longer looking for work.')]; }
269
- const b = c.bot, ds = a.ds;
278
+ const b = c.bot, ds = a.ds, body = candCharacter(a, c);
270
279
  const rows = [H(`${bold(b.name)} ${dim(`L${b.level} ${raceName(b.race)} ${className(b.cls)}`)} ${c.specName ?? ''} ${hireText(a, c)}`), H('')];
271
280
 
272
281
  // Hire: their spec's role first; any role works, it rebuilds talents and rotation.
@@ -275,9 +284,9 @@ function candidateDialog(app, botId) {
275
284
  for (const role of roles) {
276
285
  rows.push({
277
286
  text: `Hire as ${roleText(role)} for ${hireText(a, c)}${role === c.specRole ? dim(' their spec') : ''}`,
278
- detail: () => [bold(`Hire ${b.name} as ${role}`), '', `Cost: ${hireText(a, c)} you have ${yellow(moneyText(a.ch.gold) || '0c')}`,
287
+ detail: () => withArt(a, body, [bold(`Hire ${b.name} as ${role}`), `Cost: ${hireText(a, c)} you have ${yellow(moneyText(a.ch.gold) || '0c')}`,
279
288
  role === c.specRole ? dim(`They already play ${c.specName}.`) : dim('They respec for the role: talents and rotation rebuild for free.'),
280
- (a.g.state.squad ?? []).length >= MAX_COMPANIONS ? yellow('Your party is full: they start on the bench.') : '', '', dim('A released hire refunds the price.')],
289
+ ...((a.g.state.squad ?? []).length >= MAX_COMPANIONS ? [yellow('Your party is full: they start on the bench.')] : []), dim('A released hire refunds the price.')]),
281
290
  act: () => { if (hireAs(a, c, role)) a.pop(); },
282
291
  });
283
292
  }
@@ -293,7 +302,7 @@ function candidateDialog(app, botId) {
293
302
  });
294
303
  }
295
304
 
296
- rows.push(H(''), H('Stats'), ...statRows(a, candCharacter(a, c), 'With the gear they arrive in, on their own spec.'));
305
+ rows.push(H(''), H('Stats'), ...statRows(a, body, 'With the gear they arrive in, on their own spec.'));
297
306
 
298
307
  rows.push(H(''), H(`Wearing ${dim(c.gearProfile ? `${gearProfileLabel(c.gearProfile)} kit` : '')}`));
299
308
  for (const slot of EQUIP_SLOTS) {
@@ -330,15 +339,16 @@ export function companionDialog(app, id) {
330
339
  rows.push({ text: yellow(awayText(a, comp)), detail: () => missionStatusLines(a, comp), act: () => a.push(missionDialog(a, id, comp.mission.skill)) });
331
340
  }
332
341
  rows.push(H(''));
333
- rows.push({ text: `Role: ${roleText(comp.role)}`, detail: () => [bold('Role'), '', 'Tank, healer or damage. Changing it is free and rebuilds their talents and rotation for the role.'],
342
+ const note = (title, ...lines) => withArt(a, comp, [bold(title), '', ...lines]);
343
+ rows.push({ text: `Role: ${roleText(comp.role)}`, detail: () => note('Role', 'Tank, healer or damage. Changing it is free and rebuilds their talents and rotation for the role.'),
334
344
  act: () => a.pick(`${comp.name}: what role?`, SQUAD_ROLES.map((r) => ({ label: roleText(r), value: r })), (r) => a.result(g.setCompanionRole(id, r), `${comp.name} is now your ${r}.`)) });
335
345
  rows.push(seated
336
- ? { text: 'Move to the bench', detail: () => [bold('Bench'), '', 'They stay yours with their gear, level and bags, and come back free from any inn or right here.'], act: () => a.result(g.dismissCompanion(id), `${comp.name} waits on the bench.`) }
337
- : { text: green('Seat in the party'), detail: () => [bold('Party'), '', `Up to ${MAX_COMPANIONS} companions travel with you.`], act: () => a.result(g.activateCompanion(id), `${comp.name} joins the party.`) });
338
- rows.push({ text: 'Put on their best gear', detail: () => [bold('Best gear'), '', 'Strict upgrades from their own bags first, then from yours. What you hand over becomes theirs.'], act: () => { const r = g.equipCompanionBest(id); a.result(r, r.equipped ? `${comp.name} puts on ${plural(r.equipped, 'piece')}.` : `${comp.name} already wears the best they can.`); } });
339
- rows.push({ text: 'Give them something from your bags…', detail: () => [bold('Give'), '', 'Anything you hand over is theirs. A Plans or Pattern they can use is learned on the spot.'], act: () => giveDialog(a, id) });
346
+ ? { text: 'Move to the bench', detail: () => note('Bench', 'They stay yours with their gear, level and bags, and come back free from any inn or right here.'), act: () => a.result(g.dismissCompanion(id), `${comp.name} waits on the bench.`) }
347
+ : { text: green('Seat in the party'), detail: () => note('Party', `Up to ${MAX_COMPANIONS} companions travel with you.`), act: () => a.result(g.activateCompanion(id), `${comp.name} joins the party.`) });
348
+ rows.push({ text: 'Put on their best gear', detail: () => note('Best gear', 'Strict upgrades from their own bags first, then from yours. What you hand over becomes theirs.'), act: () => { const r = g.equipCompanionBest(id); a.result(r, r.equipped ? `${comp.name} puts on ${plural(r.equipped, 'piece')}.` : `${comp.name} already wears the best they can.`); } });
349
+ rows.push({ text: 'Give them something from your bags…', detail: () => note('Give', 'Anything you hand over is theirs. A Plans or Pattern they can use is learned on the spot.'), act: () => giveDialog(a, id) });
340
350
  rows.push(...teachRow(a, comp));
341
- rows.push({ text: 'Action bars…', detail: () => [bold('Action bars'), '', 'Their kit: what they can fire when you take them over, and the bag items their autopilot preps with.'], act: () => a.push(barsDialog(a, (x) => companionBarsOwner(x, findComp(x, id)))) });
351
+ rows.push({ text: 'Action bars…', detail: () => note('Action bars', 'Their kit: what they can fire when you take them over, and the bag items their autopilot preps with.'), act: () => a.push(barsDialog(a, (x) => companionBarsOwner(x, findComp(x, id)))) });
342
352
  rows.push(...settingsRows(a, comp));
343
353
  rows.push(...petRows(a, comp));
344
354
  rows.push(...questRows(a, comp));
@@ -382,7 +392,7 @@ export function companionDialog(app, id) {
382
392
  rows.push(...bagRows(a, comp, bagCapacity(companionCharacter(comp, lvl, ds), ds)));
383
393
  rows.push(...bankRows(a, comp));
384
394
 
385
- rows.push(H(''), { text: red(`Release ${comp.name} for good`), detail: () => [bold('Release'), '', 'They leave your roster for good. You get the hire price back. Their bags and gear go with them.'],
395
+ rows.push(H(''), { text: red(`Release ${comp.name} for good`), detail: () => note('Release', 'They leave your roster for good. You get the hire price back. Their bags and gear go with them.'),
386
396
  act: () => a.confirm(`Release ${comp.name} for good? Their bags and gear go with them.`, () => { const r = g.releaseCompanions({ ids: [id] }); if (a.result(r, `${comp.name} leaves.${r.refundCopper ? ` You get ${moneyText(r.refundCopper)} back.` : ''}`)) a.popTo('__none__'); }) });
387
397
  return rows;
388
398
  },
@@ -0,0 +1,223 @@
1
+ // The character create screen: faction, race, class, gender, name and the five looks of the body on one form, the
2
+ // hero drawn in the starting gear beside it, and under the form a card of what they start with (the race
3
+ // and the class in a sentence, racials, pools and attributes at level 1, spells, gear). Left and Right
4
+ // change the row under the cursor; Enter on Race or Class opens a list with each choice drawn; Enter on
5
+ // Name types one; Enter on Create rolls the character. Esc goes back to the character list.
6
+ import { bold, dim, green, red, wrapLines, fit } from './text.mjs';
7
+ import { className, raceName } from '../domain/core/ids.ts';
8
+ import { newGameState, newGameStateFromWorldBot, pickRandomWorldBot } from '../engine/game.ts';
9
+ import { maxHealth, maxPower, knownUsableSpells } from '../domain/character/character.ts';
10
+ import { effectiveStats, powerType } from '../domain/character/characterStats.ts';
11
+ import { bodyOptions, clampAppearance, cycleAppearance, lowestSelectable, slotCount, APPEARANCE_SLOTS } from '../domain/character/appearance.ts';
12
+ import { equippedEntry } from '../domain/items/itemInstance.ts';
13
+ import { nativeDisplayId } from '../domain/character/displayForm.ts';
14
+ import { creatureArt } from './modelArt.mjs';
15
+ import { paperdollArt } from './paperdoll.mjs';
16
+
17
+ // A sentence on each race and class, in the voice of the client's create screen; the racials, the start
18
+ // zone and everything else on the card come from the data.
19
+ const RACE_BLURB = {
20
+ 1: 'The people of Stormwind, adaptable and diplomatic: their spirit runs deeper, reputation comes sooner, and they see through stealth a little further.',
21
+ 2: 'The Horde of Thrall, hardy and fierce. Blood Fury hardens their attacks, stuns slide off them more often, their axes bite deeper, and their pets strike harder.',
22
+ 3: 'The stout folk of Ironforge. Stoneform shrugs off poison, disease and bleeding; they resist frost, shoot guns truer, and can find treasure in the earth.',
23
+ 4: 'The Kaldorei of Teldrassil, ancient and watchful. They melt into Shadowmeld, are harder to hit, resist nature, and walk as wisps when they fall.',
24
+ 5: 'The Forsaken of the Undercity, free of the Lich King. Will of the Forsaken breaks fear, charm and sleep; they eat the dead to heal, breathe underwater longer, and resist shadow.',
25
+ 6: 'The Shu\'halo of Mulgore, peaceful until roused. War Stomp stuns everything around them, they stand with more health, resist nature, and gather herbs faster.',
26
+ 7: 'The tinkers of Gnomeregan, guests of Ironforge. Escape Artist slips any snare, their minds hold more mana, they resist arcane, and they are born engineers.',
27
+ 8: 'The Darkspear, who followed Thrall from the Echo Isles. Berserking quickens blows and spells, they regenerate even in a fight, slay beasts harder, and throw and shoot bows truer.',
28
+ };
29
+ const CLASS_BLURB = {
30
+ 1: 'Steel and rage. Warriors wear the heaviest armour, hold the line behind a shield or swing a two-hander, and build rage by hitting and being hit.',
31
+ 2: 'The Light\'s champion in plate: a healer, a protector and a slow but sure fighter, with blessings for the party and a bubble for when it goes wrong.',
32
+ 3: 'A ranger with a bow or gun and a pet tamed from the wild. The pet holds the foe while the hunter shoots, and the map shows what they track.',
33
+ 4: 'A stealthy fighter who builds combo points with blades and spends them on finishing moves. Poisons, lockpicking and picking pockets come with the trade.',
34
+ 5: 'The great healer, in cloth. Holy mends and shields the party; Shadow drains and torments from Shadowform, trading the healing for the dark.',
35
+ 6: 'The Horde\'s keeper of the elements, in mail: totems, lightning and shocks, heals and weapon imbues, half melee and half spell at every turn.',
36
+ 7: 'The master of frost, fire and arcane, fragile in cloth and lethal at range. Conjures food and water, sheeps a foe, and opens portals between cities.',
37
+ 8: 'A wielder of fel magic in cloth: curses and damage over time, a demon summoned to tank or drain, and health, soul shards and healthstones traded for power.',
38
+ 9: 'The shapeshifter: a bear to tank, a cat to claw, healing and nature magic in caster form, and travel form for the road.',
39
+ };
40
+
41
+ const NAME_RE = /^[A-Za-z]{2,12}$/;
42
+ const properName = (s) => s[0].toUpperCase() + s.slice(1).toLowerCase();
43
+ const rand = (n) => Math.floor(Math.random() * n);
44
+ const newSeed = () => (Date.now() ^ (Math.random() * 0x7fffffff)) >>> 0;
45
+
46
+ /** A random look within the body's legal choices (the create screen's shuffle; bald and hornless allowed where offered). */
47
+ export function randomLook(race, gender) {
48
+ const look = {};
49
+ APPEARANCE_SLOTS.forEach((key, s) => {
50
+ const floor = lowestSelectable(race, gender, s), count = slotCount(race, gender, s);
51
+ look[key] = count > floor ? floor + rand(count - floor) : 0;
52
+ });
53
+ return clampAppearance(look, race, gender);
54
+ }
55
+
56
+ /** A name from the world bots' pool for the gender, fit for a character. */
57
+ export function randomName(ds, gender) {
58
+ const pool = ds.botNames()[gender === 1 ? 'female' : 'male'];
59
+ for (let i = 0; i < 50 && pool.length; i++) { const n = pool[rand(pool.length)]; if (NAME_RE.test(n)) return properName(n); }
60
+ return gender === 1 ? 'Aalina' : 'Aalart';
61
+ }
62
+
63
+ /** The screen. `done(state)` gets the new character's game state; `onCancel` is Esc (back to the character list). */
64
+ export function characterCreation(app, done, { onCancel } = {}) {
65
+ const ds = app.ds;
66
+ const races = ds.races();
67
+ const classesOf = (race) => race.classes.filter((c) => ds.createInfo(race.id, c));
68
+ const st = { race: races[0], cls: classesOf(races[0])[0], gender: 0, name: '', look: null };
69
+ st.look = randomLook(st.race.id, st.gender);
70
+ // The state a Create would make for a body and class, less the name and the look: its character carries the
71
+ // starting gear, pools and attributes the card and the drawing read. One per combination, kept.
72
+ const previews = new Map();
73
+ const previewOf = (race, cls, gender) => {
74
+ const key = `${race}-${cls}-${gender}`;
75
+ let p = previews.get(key);
76
+ if (!p) { p = newGameState(ds, race, cls, 'Preview', 1, gender); previews.set(key, p); }
77
+ return p;
78
+ };
79
+ const preview = () => previewOf(st.race.id, st.cls, st.gender);
80
+ const hero = (cls = st.cls) => ({ ...previewOf(st.race.id, cls, st.gender).char, name: st.name || 'Preview', appearance: st.look });
81
+ const setRace = (r) => { st.race = r; lastRace[r.faction] = r; const cs = classesOf(r); if (!cs.includes(st.cls)) st.cls = cs[0]; st.look = clampAppearance(st.look, r.id, st.gender); };
82
+ // Faction comes first: Race cycles and lists only its races, and flipping back returns to the race last chosen there.
83
+ const factions = [...new Set(races.map((r) => r.faction))];
84
+ const racesOf = (f) => races.filter((r) => r.faction === f);
85
+ const lastRace = { [st.race.faction]: st.race };
86
+ const setFaction = (f) => setRace(lastRace[f] ?? racesOf(f)[0]);
87
+ const setGender = (g) => { st.gender = g; st.look = clampAppearance(st.look, st.race.id, g); };
88
+ const cycle = (list, cur, dir) => list[(list.indexOf(cur) + dir + list.length) % list.length];
89
+ const genderName = () => (st.gender === 1 ? 'Female' : 'Male');
90
+ const factionName = (r) => (r.faction === 'horde' ? 'Horde' : 'Alliance');
91
+
92
+ const view = { id: 'create', title: 'New character', hint: 'Left Right: change Enter: pick or type' + (onCancel ? ' Esc: character list' : '') };
93
+ const rowAt = (id) => app.rowsOf(view).findIndex((r) => r.id === id);
94
+ const goTo = (id) => { const i = rowAt(id); if (i >= 0) { view.cursor = i; app.ensureVisible(view, app.rowsOf(view)); } app.render(); };
95
+
96
+ // The hero drawn for the pane: the body in its starting gear with the look, or the bare body when the gear pack lacks it.
97
+ const drawn = (cls, maxCols, maxRows) => paperdollArt(hero(cls), maxCols, maxRows) ?? creatureArt(nativeDisplayId(st.race.id, st.gender), maxCols, maxRows);
98
+ const withBody = (text, art) => (art ? [...text, '', ...art] : text);
99
+
100
+ const askName = () => app.prompt('Name (2-12 letters): ', (raw) => {
101
+ const name = raw.trim();
102
+ if (!NAME_RE.test(name)) { app.say(red('Letters only, 2 to 12 of them.')); askName(); return; } // said in the log: the prompt covers the hint line
103
+ st.name = properName(name);
104
+ goTo('create'); // named: Enter again creates
105
+ }); // the line starts empty: Esc keeps the name they had
106
+ const rollName = () => { st.name = randomName(ds, st.gender); app.render(); };
107
+ const shuffle = () => { st.look = randomLook(st.race.id, st.gender); app.render(); };
108
+ const create = () => {
109
+ if (!st.name) { app.flashMsg('Give them a name first.'); goTo('name'); return; }
110
+ done(newGameState(ds, st.race.id, st.cls, st.name, newSeed(), st.gender, st.look));
111
+ };
112
+ const randomCharacter = () => {
113
+ const seed = newSeed();
114
+ done(newGameStateFromWorldBot(ds, pickRandomWorldBot(ds, seed), seed));
115
+ };
116
+
117
+ // Enter on Race: the faction's races listed with each body drawn; on Class: every class this race may be, drawn in its gear.
118
+ const racePicker = () => {
119
+ const list = racesOf(st.race.faction);
120
+ const p = app.pick('Race', list.map((r) => ({
121
+ label: `${fit(r.name, 10)} ${dim(factionName(r))}`,
122
+ detail: () => {
123
+ const L = app.layout();
124
+ const text = [bold(r.name), dim(factionName(r)), `Starts in ${ds.zone(r.startZone)?.name ?? r.startZone}`, dim('Classes: ') + classesOf(r).map(className).join(', ')];
125
+ return withBody(text, creatureArt(nativeDisplayId(r.id, st.gender), L.detailW - 2, L.mainH - text.length - 1));
126
+ },
127
+ value: r,
128
+ })), (r) => { setRace(r); app.render(); }, { hint: 'Enter: choose' });
129
+ p.cursor = list.indexOf(st.race);
130
+ };
131
+ const classPicker = () => {
132
+ const classes = classesOf(st.race);
133
+ const p = app.pick('Class', classes.map((c) => ({
134
+ label: className(c),
135
+ detail: () => {
136
+ const L = app.layout();
137
+ const text = [bold(className(c)), ...wrapLines(dim(CLASS_BLURB[c] ?? ''), L.detailW - 2)];
138
+ return withBody(text, drawn(c, L.detailW - 2, L.mainH - text.length - 1));
139
+ },
140
+ value: c,
141
+ })), (c) => { st.cls = c; app.render(); }, { hint: 'Enter: choose' });
142
+ p.cursor = classes.indexOf(st.cls);
143
+ };
144
+
145
+ // A form row: the label, the value, and the arrows when Left and Right change it.
146
+ const field = (id, label, value, { cycle: cyc, act, arrows = true }) => ({ id, text: `${fit(label, 12)} ${fit(bold(value), 10)}${arrows ? ` ${dim('◂ ▸')}` : ''}`, cycle: cyc, act });
147
+ const H = (text = '', plain = false) => ({ text, header: true, plain });
148
+
149
+ // Under the form: what the race and the class are, and what a level 1 of them starts with.
150
+ const card = () => {
151
+ const L = app.layout();
152
+ const w = Math.max(24, (L.detailW ? L.listW : L.W) - 3);
153
+ const wrap = (t) => wrapLines(t, w).map((l) => H(l, true));
154
+ const r = st.race, p = preview(), ch = p.char;
155
+ const out = [H(`${r.name} · ${factionName(r)}`)];
156
+ if (RACE_BLURB[r.id]) out.push(...wrap(RACE_BLURB[r.id]));
157
+ out.push(...wrap(dim(`Starts in ${ds.zone(r.startZone)?.name ?? r.startZone}; home city ${r.city}.`)));
158
+ const racials = [...new Set(r.racials.map((id) => ds.spell(id)?.name).filter(Boolean))];
159
+ if (racials.length) out.push(...wrap(`${dim('Racials:')} ${racials.join(', ')}`));
160
+ out.push(H(), H(className(st.cls)));
161
+ if (CLASS_BLURB[st.cls]) out.push(...wrap(CLASS_BLURB[st.cls]));
162
+ const s = effectiveStats(ch, ds), pt = powerType(ch);
163
+ out.push(...wrap(`${dim('Level 1:')} ${maxHealth(ch, ds)} health, ${maxPower(ch, ds)} ${pt}`));
164
+ out.push(...wrap(`${dim('Str')} ${s.str} ${dim('Agi')} ${s.agi} ${dim('Sta')} ${s.sta} ${dim('Int')} ${s.int} ${dim('Spi')} ${s.spi}`));
165
+ const spells = [...new Set(knownUsableSpells(ch, p.spellbook, ds).map((sp) => sp.name))];
166
+ if (spells.length) out.push(...wrap(`${dim('Knows:')} ${spells.join(', ')}`));
167
+ const worn = new Set(Object.values(ch.equipped ?? {}).map(equippedEntry));
168
+ const wears = [], carries = [];
169
+ for (const it of ds.createInfo(r.id, st.cls)?.items ?? []) {
170
+ const def = ds.item(it.item);
171
+ if (def) (worn.has(it.item) ? wears : carries).push(it.amount > 1 ? `${def.name} ×${it.amount}` : def.name);
172
+ }
173
+ if (wears.length) out.push(...wrap(`${dim('Wears:')} ${wears.join(', ')}`));
174
+ if (carries.length) out.push(...wrap(`${dim('Carries:')} ${carries.join(', ')}`));
175
+ return out;
176
+ };
177
+
178
+ view.rows = () => {
179
+ const rows = [
180
+ field('faction', 'Faction', factionName(st.race), { cycle: (d) => setFaction(cycle(factions, st.race.faction, d)), act: () => { setFaction(cycle(factions, st.race.faction, 1)); app.render(); } }),
181
+ field('race', 'Race', st.race.name, { cycle: (d) => setRace(cycle(racesOf(st.race.faction), st.race, d)), act: racePicker }),
182
+ field('class', 'Class', className(st.cls), { cycle: (d) => { st.cls = cycle(classesOf(st.race), st.cls, d); }, act: classPicker }),
183
+ field('gender', 'Gender', genderName(), { cycle: () => setGender(st.gender === 1 ? 0 : 1), act: () => { setGender(st.gender === 1 ? 0 : 1); app.render(); } }),
184
+ { id: 'name', text: `${fit('Name', 12)} ${st.name ? bold(st.name) : dim('Enter: type one n: a random one')}`, act: askName },
185
+ H(), H('Look'),
186
+ ];
187
+ // The body's own slots, by the names the client gives them (Horn Style, Tusks…); a slot with one choice is left out.
188
+ const body = bodyOptions(st.race.id, st.gender);
189
+ APPEARANCE_SLOTS.forEach((key, s) => {
190
+ const opt = body?.options[s];
191
+ if (!opt) return;
192
+ const floor = lowestSelectable(st.race.id, st.gender, s), count = opt.count;
193
+ if (count - floor <= 1) return;
194
+ const i = st.look[key];
195
+ const value = i === 0 && opt.none ? 'None' : `${i + 1 - floor} of ${count - floor}`;
196
+ rows.push(field(key, opt.name, value, { cycle: (d) => { st.look = cycleAppearance(st.look, st.race.id, st.gender, s, d); }, act: () => { st.look = cycleAppearance(st.look, st.race.id, st.gender, s, 1); app.render(); } }));
197
+ });
198
+ rows.push({ id: 'shuffle', text: `${fit('', 12)} ${dim('r: a random look')}`, act: shuffle });
199
+ rows.push(H());
200
+ rows.push({ id: 'create', text: green(bold(st.name ? `Create ${st.name}, ${genderName()} ${st.race.name} ${className(st.cls)}` : 'Create (needs a name)')), act: create });
201
+ rows.push({ id: 'random', text: `Random character ${dim('a world bot at its own level, geared and named')}`, act: randomCharacter });
202
+ rows.push(H(), ...card());
203
+ return rows;
204
+ };
205
+ // The pane: the hero as they will stand on the Character tab, in the starting gear, as large as the pane allows.
206
+ view.detail = () => {
207
+ const L = app.layout();
208
+ const head = [`${st.name ? bold(st.name) : dim('Unnamed')} ${dim(`${genderName()} ${raceName(st.race.id)} ${className(st.cls)}`)}`, ''];
209
+ const art = drawn(st.cls, L.detailW - 2, L.mainH - head.length);
210
+ return art ? [...head, ...art] : head;
211
+ };
212
+ view.keys = {
213
+ n: { hint: 'random name', fn: rollName },
214
+ r: { hint: 'random look', fn: shuffle },
215
+ };
216
+ view.onKey = (a, k, row) => {
217
+ if ((k === 'left' || k === 'right') && row?.cycle) { row.cycle(k === 'left' ? -1 : 1); a.render(); return true; }
218
+ if (k === 'escape') { onCancel?.(); return true; }
219
+ return false;
220
+ };
221
+ app.push(view);
222
+ return view;
223
+ }
@@ -314,7 +314,7 @@ function objectRow(app, def, o) {
314
314
  const name = spawn.name ?? ds.creature(spawn.entry)?.name ?? `npc ${spawn.entry}`;
315
315
  const marks = `${quests.turnIn.length ? green(' ?') : ''}${quests.offer.length ? yellow(' !') : ''}${quests.escort.length ? cyan(' escort') : ''}`;
316
316
  return {
317
- text: `${dim('☺')} ${name}${marks}`,
317
+ text: `${dim('•')} ${name}${marks}`,
318
318
  detail: () => [bold(name), ...quests.offer.map((q) => `${yellow('!')} ${q.title}`), ...quests.turnIn.map((q) => `${green('?')} ${q.title}`), ...quests.escort.map((q) => `${cyan('escort')} ${q.title}`), '', dim('Enter: speak with them')],
319
319
  act: () => npcDialog(app, spawn.entry, name),
320
320
  };