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,24 +1,28 @@
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, inverse, qualityPaint, fit, cut, plural, itemText, vis, wrapLines, coinText, coin } from './text.mjs';
5
+ import { miniIcon } from './iconArt.mjs';
6
+ import { paperdollGrid, paperdollPlan } from './tuiPaperdoll.mjs';
5
7
  import { itemLines } from './tuiViews.mjs';
6
8
  import { recipeBook, openSendGoods, companionsOf } from './tuiProfessions.mjs';
7
9
  import { SQUAD_MODES, SQUAD_MODE_LABEL, squadModeOf, setSquadMode } from './squad.mjs';
8
10
  import { settingsRows, petRows, questRows, teachRow, bagRows, bankRows, shareDialog, vaultDialog, hireSearch, tavernDuel } from './tuiCompanionKit.mjs';
9
11
  import { barsDialog, companionBarsOwner } from './tuiBars.mjs';
10
12
  import { enchantLines } from './tuiGear.mjs';
11
- import { moneyText } from '../domain/economy/money.ts';
12
13
  import { className, raceName } from '../domain/core/ids.ts';
14
+ import { paperdollArt } from './paperdoll.mjs';
13
15
  import { bagCapacity, EQUIP_SLOTS, SLOT_LABEL, itemSlotTargets } from '../domain/items/equipment.ts';
14
16
  import { equippedEntry } from '../domain/items/itemInstance.ts';
15
17
  import { companionCharacter, companionLevel, hireSkillMap, MAX_COMPANIONS, SQUAD_ROLES } from '../domain/bots/squad.ts';
16
18
  import { companionEquipOpts } from '../domain/professions/autoDisenchant.ts';
17
19
  import { companionSkillCap, companionTrainedRank, SECONDARY_SKILLS } from '../domain/bots/botProfessions.ts';
18
- import { displayGearScore, gearProfileLabel } from '../domain/bots/companionHireCost.ts';
20
+ import { displayGearScore, gearProfileLabel, hireCostCopper } from '../domain/bots/companionHireCost.ts';
21
+ import { GEAR_PROFILES } from '../domain/bots/botGear.ts';
22
+ import { searchWorldBots, innZoneSalt, pickProfile, WORLD_BOT_COUNT } from '../domain/bots/botPool.ts';
19
23
  import { maxHealth, maxPower } from '../domain/character/character.ts';
20
24
  import { effectiveStats, effectiveArmor, attackPower, powerType } from '../domain/character/characterStats.ts';
21
- import { critChanceDisplay, dodgeChanceDisplay } from '../domain/character/characterRatings.ts';
25
+ import { critChanceDisplay, dodgeChanceDisplay, hitChance } from '../domain/character/characterRatings.ts';
22
26
  import {
23
27
  canCompanionLearnProfession, countDoneMissions, hoursAffordable, isMissionSkill, missionEtaLabel, missionHoursLabel,
24
28
  missionProgress, missionVerb, energyResetsAtMs, MAX_COMPANION_ENERGY,
@@ -62,9 +66,15 @@ function profTags(lines) {
62
66
 
63
67
  function profLines(app, lines) {
64
68
  if (!lines.length) return [` ${dim('none')}`];
65
- return lines.map((l) => ` ${fit(app.ds.profession(l.line)?.name ?? l.line, 15)} ${fit(`${l.current}/${l.max}`, 8)} ${dim(l.rank ?? '')}`);
69
+ return lines.map((l) => { const prof = app.ds.profession(l.line); return paneLine(app, ` ${miniIcon(prof?.icon)}${fit(prof?.name ?? l.line, 15)} ${fit(`${l.current}/${l.max}`, 8)} ${dim(l.rank ?? '')}`); });
66
70
  }
67
71
 
72
+ /** A detail-pane line kept to one row: a mini icon (sixel) is blank cells the pane's wrap would squeeze. */
73
+ function paneLine(app, line) { const w = (app.layout?.().detailW ?? 0) - 2; return w > 0 && vis(line) > w ? cut(line, w) : line; }
74
+
75
+ /** What a piece is, for the line under its name: Plate 412 armor, Sword 41-77. */
76
+ function gearKind(it) { return it ? [it.typeName, it.armor ? `${it.armor} armor` : '', it.weapon ? `${it.weapon.dmgMin}-${it.weapon.dmgMax}` : ''].filter(Boolean).join(' ') : ''; }
77
+
68
78
  function statLines(app, char) {
69
79
  const ds = app.ds, st = effectiveStats(char, ds), pt = powerType(char);
70
80
  return [
@@ -76,14 +86,22 @@ function statLines(app, char) {
76
86
 
77
87
  function statRows(app, char, note) {
78
88
  const lines = statLines(app, char);
79
- return lines.map((l) => ({ text: l.trim(), detail: () => [bold('Stats'), '', ...lines, '', dim(note)] }));
89
+ return lines.map((l) => ({ text: l.trim(), detail: () => withArt(app, char, [bold('Stats'), '', ...lines, '', dim(note)]) }));
90
+ }
91
+
92
+ /** 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. */
93
+ function withArt(app, subject, text) {
94
+ const L = app.layout(), w = L.detailW - 2;
95
+ const used = text.reduce((n, line) => n + (vis(line) > w ? wrapLines(line, w).length : 1), 0); // as the pane wraps them
96
+ const art = paperdollArt(subject, w, L.mainH - used - 1);
97
+ return art ? [...text, '', ...art] : text;
80
98
  }
81
99
 
82
100
  function wornLines(app, gear) {
83
101
  const out = [];
84
102
  for (const slot of EQUIP_SLOTS) {
85
103
  const e = equippedEntry(gear?.[slot]);
86
- if (e != null) { const it = app.ds.item(e); out.push(` ${fit(SLOT_LABEL[slot] ?? slot, 10)} ${qualityPaint(it?.quality)(it?.name ?? e)}`); }
104
+ if (e != null) { const it = app.ds.item(e); out.push(paneLine(app, ` ${miniIcon(it?.icon)}${fit(SLOT_LABEL[slot] ?? slot, 10)} ${qualityPaint(it?.quality)(it?.name ?? e)}`)); }
87
105
  }
88
106
  return out.length ? out : [` ${dim('nothing')}`];
89
107
  }
@@ -131,10 +149,10 @@ export function companionsView(app) {
131
149
  rows(a) {
132
150
  const g = a.g, squad = g.state.squad ?? [], bench = Object.values(g.state.botRoster ?? {});
133
151
  const energy = g.companionPartyEnergy(Date.now());
134
- const rows = [H(`Party ${squad.length}/${MAX_COMPANIONS} ${dim(`trip energy ${energy}/${MAX_COMPANION_ENERGY}`)} ${yellow(moneyText(a.ch.gold) || '0c')}`), squadModeRow(a)];
152
+ const rows = [H(`Party ${squad.length}/${MAX_COMPANIONS} ${dim(`trip energy ${energy}/${MAX_COMPANION_ENERGY}`)} ${coinText(a.ch.gold)}`), squadModeRow(a)];
135
153
  const row = (c) => ({
136
154
  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')],
155
+ detail: () => withArt(a, c, [...compLines(a, c), '', dim('Enter: open their page')]), // them in their gear
138
156
  act: () => a.push(companionDialog(a, c.id)),
139
157
  });
140
158
  if (!squad.length && !bench.length) rows.push(H(''), H(dim(' You travel alone. Innkeepers hire out companions: they fight beside you, carry and craft.')));
@@ -143,8 +161,8 @@ export function companionsView(app) {
143
161
  const inn = g.innkeeperHere();
144
162
  rows.push(H(''), H('Hire'));
145
163
  if (inn) {
146
- const n = g.innCandidates().length;
147
- rows.push({ text: `Hire a companion at ${inn.name} ${dim(`(${plural(n, 'face')})`)}`, detail: () => [bold(inn.name), 'Innkeeper', '', `${plural(n, 'adventurer')} near your level looking for work. A hire joins at their own level in their own gear; a bench companion comes back free.`], act: () => a.push(hireDialog(a)) });
164
+ const n = hirePool(a).list.length;
165
+ rows.push({ text: `Hire a companion at ${inn.name} ${dim(`(${plural(n, 'face')})`)}`, detail: () => [bold(inn.name), 'Innkeeper', '', `${plural(n, 'adventurer')} of your faction looking for work, every level, sorted by role and kit. A hire joins at their own level in their own gear; a bench companion comes back free.`], act: () => a.push(hireDialog(a)) });
148
166
  } else rows.push(H(dim(' No inn here. Companions are hired from innkeepers (Goldshire, Razor Hill, every capital).')));
149
167
  if (squad.length || bench.length) {
150
168
  const ups = g.squadUpgradePlan().length;
@@ -187,7 +205,41 @@ function yieldText(app, rows) {
187
205
 
188
206
  // ---- hiring -------------------------------------------------------------------------------------------
189
207
 
190
- function findCand(app, botId) { return [...app.g.innCandidates(), ...(app.hireResults ?? [])].find((c) => c.bot.id === botId) ?? null; }
208
+ // Every same-faction face not already yours, from the bot pool alone: who they are, their spec, kit and
209
+ // price. Their gear is the dear part (a gear walk each, 6 s for 3000), so a face gets it when you look at
210
+ // them. The inn's own slate rolls its kits as a set, and hiring prices a slate face by that roll, so those
211
+ // faces come from the slate.
212
+ function hirePool(app) {
213
+ const g = app.g, ch = g.state.char;
214
+ const taken = [...(g.state.squad ?? []).map((c) => c.id), ...Object.keys(g.state.botRoster ?? {})];
215
+ const key = `${ch.zoneId}|${ch.level}|${ch.race}|${g.innkeeperHere()?.entry}|${taken.sort().join(',')}`;
216
+ if (app.hirePoolMemo?.g === g && app.hirePoolMemo.key === key) return app.hirePoolMemo;
217
+ const slate = new Map(g.innCandidates().map((c) => [c.bot.id, c]));
218
+ const salt = innZoneSalt(ch.zoneId, ch.level);
219
+ const bots = g.innkeeperHere() ? searchWorldBots({ ds: app.ds, playerRace: ch.race, playerLevel: ch.level, zoneId: ch.zoneId, excludeIds: taken, filters: { roles: SQUAD_ROLES, limit: WORLD_BOT_COUNT } }) : [];
220
+ const list = bots.map((bot) => slate.get(bot.id) ?? (() => {
221
+ const spec = app.ds.botSpecs(bot.cls)?.[bot.specIdx], gearProfile = pickProfile(bot.id, salt, ch.level);
222
+ return { bot, ...(spec ? { specName: spec.name, specRole: spec.role } : {}), gearProfile, hireCostCopper: hireCostCopper(bot.level, gearProfile) };
223
+ })());
224
+ app.hirePoolMemo = { g, key, list, full: new Map(), tags: new Map() };
225
+ return app.hirePoolMemo;
226
+ }
227
+
228
+ /** A pool face with their gear on: built once, through the engine's own search so it is the body a hire gets. */
229
+ function fullCand(app, c) {
230
+ if (c.gear) return c;
231
+ const memo = hirePool(app);
232
+ if (!memo.full.has(c.bot.id)) {
233
+ const b = c.bot;
234
+ memo.full.set(b.id, app.g.searchInnCandidates({ name: b.name, classes: [b.cls], minLevel: b.level, maxLevel: b.level, limit: WORLD_BOT_COUNT }).find((x) => x.bot.id === b.id) ?? null);
235
+ }
236
+ return memo.full.get(c.bot.id) ?? null;
237
+ }
238
+
239
+ function findCand(app, botId) {
240
+ const c = [...app.g.innCandidates(), ...(app.hireResults ?? [])].find((x) => x.bot.id === botId) ?? hirePool(app).list.find((x) => x.bot.id === botId);
241
+ return c ? fullCand(app, c) : null;
242
+ }
191
243
 
192
244
  // The body they would bring: same shell hireCandidate builds, on their own spec, for the stat preview.
193
245
  function candCharacter(app, c) {
@@ -197,15 +249,16 @@ function candCharacter(app, c) {
197
249
 
198
250
  function hireText(app, c, short = false) {
199
251
  const cost = c.hireCostCopper ?? 0;
200
- return (app.ch.gold >= cost ? yellow : red)((short ? shortMoney(cost) : moneyText(cost)) || 'free');
252
+ if (!cost) return yellow('free');
253
+ return (app.ch.gold >= cost ? yellow : red)(short ? shortMoney(cost) : coinText(cost));
201
254
  }
202
255
 
203
256
  // The two biggest coins only, for the list column: 1g 20s, 51s, 76c.
204
257
  function shortMoney(copper) {
205
258
  const g = Math.floor(copper / 10000), sv = Math.floor((copper % 10000) / 100), c = copper % 100;
206
- if (g) return `${g}g${sv ? ` ${sv}s` : ''}`;
207
- if (sv) return `${sv}s`;
208
- return c ? `${c}c` : '';
259
+ if (g) return `${g}${coin('g')}${sv ? ` ${sv}${coin('s')}` : ''}`;
260
+ if (sv) return `${sv}${coin('s')}`;
261
+ return `${c}${coin('c')}`;
209
262
  }
210
263
 
211
264
  function candLines(app, c) {
@@ -223,7 +276,7 @@ function candLines(app, c) {
223
276
  function hireAs(app, c, role) {
224
277
  const r = app.g.hireCandidate(c.bot.id, role);
225
278
  if (r.ok && app.hireResults) app.hireResults = app.hireResults.filter((x) => x.bot.id !== c.bot.id);
226
- return app.result(r, `${r.name ?? c.bot.name} joins you as ${role}${r.costCopper ? ` for ${moneyText(r.costCopper)}` : ''}${r.benched ? ', on the bench' : ''}.`);
279
+ return app.result(r, `${r.name ?? c.bot.name} joins you as ${role}${r.costCopper ? ` for ${coinText(r.costCopper)}` : ''}${r.benched ? ', on the bench' : ''}.`);
227
280
  }
228
281
 
229
282
  function pickRoleAndHire(app, c, after) {
@@ -231,31 +284,77 @@ function pickRoleAndHire(app, c, after) {
231
284
  app.pick(`${c.bot.name}: what role?`, roles.map((r) => ({ label: `${roleText(r)}${r === c.specRole ? dim(' (their spec)') : ''}`, value: r })), (role) => { if (hireAs(app, c, role)) after?.(); });
232
285
  }
233
286
 
287
+ // The hire list's two filters: a role tab (Left and Right) and a kit (t), each with its count under the other.
288
+ const HIRE_ROLES = [{ role: null, label: 'All' }, { role: 'dps', label: 'DPS' }, { role: 'healer', label: 'Heal' }, { role: 'tank', label: 'Tank' }];
289
+ const KIT_SHORT = { naked: 'Naked', shabby: 'Shabby', field: 'Field', twink: 'Twink', 'raid-mixed': 'Raid', 'raid-t2': 'Raid T2', 'raid-t3': 'Raid T3' };
290
+ const roleOfCand = (c) => c.specRole ?? 'dps';
291
+
234
292
  function hireDialog(app, results) {
235
- return {
236
- id: results ? 'hire-search' : 'hire', title: results ? 'Search' : 'Hire', hint: 'Enter: look closer',
293
+ // The kits this list has, cheapest first; t steps through them and back to any.
294
+ const kitsOf = (list) => [null, ...GEAR_PROFILES.filter((p) => list.some((c) => c.gearProfile === p))];
295
+ const view = {
296
+ id: results ? 'hire-search' : 'hire', hint: 'Enter: look closer Left Right: role',
297
+ roleTab: 0, kit: null, placed: false,
298
+ // The filter rows scroll off a long list; the breadcrumb keeps them in sight.
299
+ get title() { return [results ? 'Search' : 'Hire', HIRE_ROLES[this.roleTab].role && HIRE_ROLES[this.roleTab].label, this.kit && KIT_SHORT[this.kit]].filter(Boolean).join(' · '); },
237
300
  keys: {
238
301
  h: { hint: 'hire', fn: (a, row) => { const c = row?.data && findCand(a, row.data); if (c) pickRoleAndHire(a, c); } },
302
+ t: { hint: 'kit', fn: (a) => stepKit(a, +1) },
303
+ T: { hint: 'kit back', fn: (a) => stepKit(a, -1) },
239
304
  '/': { hint: 'search', fn: (a) => hireSearch(a, (found) => { a.hireResults = found; a.push(hireDialog(a, found)); }) },
240
305
  D: { hint: 'duel', fn: (a, row) => { if (row?.data) tavernDuel(a, row.data); } },
241
306
  },
307
+ onKey(a, k) {
308
+ const d = { right: +1, tab: +1, left: -1, 'shift-tab': -1 }[k];
309
+ if (!d) return false;
310
+ view.roleTab = (view.roleTab + d + HIRE_ROLES.length) % HIRE_ROLES.length;
311
+ view.placed = false;
312
+ a.render();
313
+ return true;
314
+ },
242
315
  rows(a) {
243
- const g = a.g, list = results ?? g.innCandidates();
316
+ const g = a.g, all = results ?? hirePool(a).list, memo = hirePool(a);
244
317
  const squad = g.state.squad ?? [];
245
- const rows = [H(`${plural(list.length, 'adventurer')} for hire ${squad.length >= MAX_COMPANIONS ? yellow('party full: a new hire goes to the bench') : dim(`party ${squad.length}/${MAX_COMPANIONS}`)} ${yellow(moneyText(a.ch.gold) || '0c')}`), H('')];
246
- if (!list.length) rows.push(H(dim(' Nobody is looking for work here right now.')));
318
+ const { role } = HIRE_ROLES[view.roleTab];
319
+ const ofRole = (c) => !role || roleOfCand(c) === role, ofKit = (c) => !view.kit || c.gearProfile === view.kit;
320
+ const list = all.filter((c) => ofRole(c) && ofKit(c));
321
+ const roleTabs = HIRE_ROLES.map((t, i) => { const label = ` ${t.label} ${all.filter((c) => (!t.role || roleOfCand(c) === t.role) && ofKit(c)).length} `; return i === view.roleTab ? inverse(bold(label)) : dim(label); }).join(' ');
322
+ const kitTabs = kitsOf(all).map((p) => { const label = p ? `${KIT_SHORT[p] ?? p} ${all.filter((c) => ofRole(c) && c.gearProfile === p).length}` : 'Any'; return p === view.kit ? inverse(bold(` ${label} `)) : dim(` ${label} `); });
323
+ // Seven kits and their counts outrun the list pane at 60: they wrap onto a second row.
324
+ const kitW = a.listWOf(view, a.layout()) - 2, kitLines = [`Kit`];
325
+ for (const t of [...kitTabs, dim(' t')]) { if (vis(kitLines.at(-1)) + vis(t) > kitW) kitLines.push(' '); kitLines[kitLines.length - 1] += t; }
326
+ const rows = [
327
+ H(`${plural(list.length, 'adventurer')} for hire ${squad.length >= MAX_COMPANIONS ? yellow('party full: a new hire goes to the bench') : dim(`party ${squad.length}/${MAX_COMPANIONS}`)} ${coinText(a.ch.gold)}`),
328
+ H(`${roleTabs} ${dim('Left Right')}`), ...kitLines.map(H), H(''),
329
+ ];
330
+ if (!list.length) rows.push(H(dim(all.length ? ' Nobody here fits that role and kit.' : ' Nobody is looking for work here right now.')));
247
331
  for (const c of list) {
248
332
  const b = c.bot;
333
+ if (!memo.tags.has(b.id)) memo.tags.set(b.id, profTags(candProfLines(c)));
249
334
  rows.push({
250
- 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))}`,
335
+ text: `${fit(b.name, 12)} ${fit(dim(`L${b.level} ${className(b.cls)}`), 12)} ${fit(c.specRole ? roleText(c.specRole) : '', 6)} ${fit(dim(KIT_SHORT[c.gearProfile] ?? ''), 7)} ${fit(hireText(a, c, true), 7)} ${memo.tags.get(b.id)}`,
251
336
  data: b.id,
252
- detail: () => [...candLines(a, c), '', dim('Enter: their page h: hire')],
337
+ detail: () => { const full = fullCand(a, c); return full ? withArt(a, candCharacter(a, full), [...candLines(a, full), '', dim('Enter: their page h: hire')]) : []; },
253
338
  act: () => a.push(candidateDialog(a, b.id)),
254
339
  });
255
340
  }
341
+ // A new list opens on your own level (the slate's window), not on the level 1s at its top.
342
+ if (!view.placed && list.length) {
343
+ view.placed = true;
344
+ const at = list.findIndex((c) => c.bot.level >= a.ch.level - 2);
345
+ view.cursor = rows.length - list.length + (at < 0 ? list.length - 1 : at);
346
+ view.scroll = at > 0 ? view.cursor - 3 : 0; // a few lower levels above it, or the filter rows when it heads the list
347
+ }
256
348
  return rows;
257
349
  },
258
350
  };
351
+ const stepKit = (a, d) => {
352
+ const kits = kitsOf(results ?? hirePool(a).list);
353
+ view.kit = kits[(kits.indexOf(view.kit) + d + kits.length) % kits.length];
354
+ view.placed = false;
355
+ a.render();
356
+ };
357
+ return view;
259
358
  }
260
359
 
261
360
  /** One face at the inn: everything about them, and the hire buttons. */
@@ -266,7 +365,7 @@ function candidateDialog(app, botId) {
266
365
  rows(a) {
267
366
  const c = findCand(a, botId);
268
367
  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;
368
+ const b = c.bot, ds = a.ds, body = candCharacter(a, c);
270
369
  const rows = [H(`${bold(b.name)} ${dim(`L${b.level} ${raceName(b.race)} ${className(b.cls)}`)} ${c.specName ?? ''} ${hireText(a, c)}`), H('')];
271
370
 
272
371
  // Hire: their spec's role first; any role works, it rebuilds talents and rotation.
@@ -275,9 +374,9 @@ function candidateDialog(app, botId) {
275
374
  for (const role of roles) {
276
375
  rows.push({
277
376
  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')}`,
377
+ detail: () => withArt(a, body, [bold(`Hire ${b.name} as ${role}`), `Cost: ${hireText(a, c)} you have ${coinText(a.ch.gold)}`,
279
378
  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.')],
379
+ ...((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
380
  act: () => { if (hireAs(a, c, role)) a.pop(); },
282
381
  });
283
382
  }
@@ -286,21 +385,22 @@ function candidateDialog(app, botId) {
286
385
  for (const l of candProfLines(c)) {
287
386
  const prof = ds.profession(l.line);
288
387
  rows.push({
289
- text: `${fit(prof?.name ?? l.line, 16)} ${fit(`${l.current}/${l.max}`, 8)} ${dim(l.rank ?? '')}${isSecondary(l.line) ? dim(' secondary') : ''}`,
388
+ text: `${fit(prof?.name ?? l.line, 16)} ${fit(`${l.current}/${l.max}`, 8)} ${dim(l.rank ?? '')}`,
389
+ icon: prof?.icon, sub: dim(isSecondary(l.line) ? 'secondary' : prof?.kind ?? ''),
290
390
  detail: () => [bold(prof?.name ?? l.line), `${l.current}/${l.max}${l.rank ? ` ${l.rank}` : ''}`, '',
291
391
  isMissionSkill(l.line) ? `Once hired they can go on ${missionVerb(l.line).toLowerCase()} trips while you play.` : `Once hired they craft from their own ${prof?.name ?? l.line} recipe book with your materials.`,
292
392
  dim('Companions train for free as they level, and can swap trades later.')],
293
393
  });
294
394
  }
295
395
 
296
- rows.push(H(''), H('Stats'), ...statRows(a, candCharacter(a, c), 'With the gear they arrive in, on their own spec.'));
396
+ rows.push(H(''), H('Stats'), ...statRows(a, body, 'With the gear they arrive in, on their own spec.'));
297
397
 
298
398
  rows.push(H(''), H(`Wearing ${dim(c.gearProfile ? `${gearProfileLabel(c.gearProfile)} kit` : '')}`));
299
399
  for (const slot of EQUIP_SLOTS) {
300
400
  const e = equippedEntry(c.gear?.[slot]);
301
401
  if (e == null) continue;
302
402
  const it = ds.item(e);
303
- rows.push({ text: `${fit(SLOT_LABEL[slot] ?? slot, 10)} ${qualityPaint(it?.quality)(it?.name ?? e)}`, detail: () => (it ? itemLines(a, it) : []) });
403
+ rows.push({ text: `${fit(SLOT_LABEL[slot] ?? slot, 10)} ${qualityPaint(it?.quality)(it?.name ?? e)}`, icon: it?.icon, sub: dim(`${fit('', 10)} ${gearKind(it)}`), detail: () => (it ? itemLines(a, it) : []) });
304
404
  }
305
405
  return rows;
306
406
  },
@@ -313,6 +413,7 @@ export function companionDialog(app, id) {
313
413
  return {
314
414
  id: `comp-${id}`, title: findComp(app, id)?.name ?? 'Companion', hint: 'Enter: act',
315
415
  keys: {
416
+ c: { hint: 'character sheet', fn: (a) => a.push(companionSheet(a, id)) },
316
417
  x: { hint: 'take off / drop', fn: (a, row) => {
317
418
  const d = row?.data;
318
419
  if (d?.slot && d.it) a.result(a.g.unequipCompanionSlot(id, d.slot), `${findComp(a, id)?.name} takes off ${itemText(d.it)}.`);
@@ -330,15 +431,19 @@ export function companionDialog(app, id) {
330
431
  rows.push({ text: yellow(awayText(a, comp)), detail: () => missionStatusLines(a, comp), act: () => a.push(missionDialog(a, id, comp.mission.skill)) });
331
432
  }
332
433
  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.'],
434
+ const note = (title, ...lines) => withArt(a, comp, [bold(title), '', ...lines]);
435
+ rows.push({ text: `Character sheet ${dim('c')}`, icon: 'inv_misc_head_human_01', sub: dim('their gear on the paperdoll, and their stats'),
436
+ detail: () => note('Character sheet', 'What they wear on the paperdoll, the way your Character tab shows you: each piece in its slot, its tooltip beside, their numbers underneath.'),
437
+ act: () => a.push(companionSheet(a, id)) });
438
+ 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
439
  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
440
  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) });
441
+ ? { 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.`) }
442
+ : { 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.`) });
443
+ 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.`); } });
444
+ 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
445
  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)))) });
446
+ 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
447
  rows.push(...settingsRows(a, comp));
343
448
  rows.push(...petRows(a, comp));
344
449
  rows.push(...questRows(a, comp));
@@ -349,7 +454,8 @@ export function companionDialog(app, id) {
349
454
  for (const l of sortLines(profs?.lines ?? [])) {
350
455
  const prof = ds.profession(l.line);
351
456
  rows.push({
352
- text: `${fit(prof?.name ?? l.line, 16)} ${fit(`${l.current}/${l.max}`, 8)} ${dim(l.rank ?? '')}${isSecondary(l.line) ? dim(' secondary') : ''}${comp.mission?.skill === l.line ? yellow(' away') : ''}`, data: { line: l.line },
457
+ text: `${fit(prof?.name ?? l.line, 16)} ${fit(`${l.current}/${l.max}`, 8)} ${dim(l.rank ?? '')}${comp.mission?.skill === l.line ? yellow(' away') : ''}`, data: { line: l.line },
458
+ icon: prof?.icon, sub: dim(`${isSecondary(l.line) ? 'secondary' : prof?.kind ?? ''} ${isMissionSkill(l.line) ? 'Enter: trips' : 'Enter: recipe book'}`),
353
459
  detail: () => [bold(prof?.name ?? l.line), `${l.current}/${l.max}${l.rank ? ` ${l.rank}` : ''}`, '', dim(isMissionSkill(l.line) ? 'Enter: smelting and gathering trips' : 'Enter: their recipe book'), dim('x: drop it')],
354
460
  act: () => a.push(recipeBook(a, id, l.line)),
355
461
  });
@@ -374,6 +480,7 @@ export function companionDialog(app, id) {
374
480
  const it = e != null ? ds.item(e) : null;
375
481
  rows.push({
376
482
  text: `${fit(SLOT_LABEL[slot] ?? slot, 10)} ${it ? qualityPaint(it.quality)(it.name) : dim('—')}`, data: { slot, it },
483
+ icon: it?.icon, sub: it ? dim(`${fit('', 10)} ${gearKind(it)}`) : undefined,
377
484
  detail: () => (it ? itemLines(a, it, [...enchantLines(a, comp, slot), '', dim('Enter: change x: take off')]) : [dim(`Nothing in the ${SLOT_LABEL[slot] ?? slot} slot.`), '', dim('Enter: pick something for them to wear')]),
378
485
  act: () => slotPicker(a, id, slot),
379
486
  });
@@ -382,8 +489,71 @@ export function companionDialog(app, id) {
382
489
  rows.push(...bagRows(a, comp, bagCapacity(companionCharacter(comp, lvl, ds), ds)));
383
490
  rows.push(...bankRows(a, comp));
384
491
 
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.'],
386
- 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__'); }) });
492
+ 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.'),
493
+ 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 ${coinText(r.refundCopper)} back.` : ''}`)) a.popTo('__none__'); }) });
494
+ return rows;
495
+ },
496
+ };
497
+ }
498
+
499
+ // ---- their character sheet ------------------------------------------------------------------------------
500
+
501
+ /** Their numbers at the foot of the sheet's tooltip pane, laid out like yours under the Character tab. */
502
+ function sheetStats(app, char, w) {
503
+ const ds = app.ds, st = effectiveStats(char, ds), pt = powerType(char);
504
+ const pct = (x) => `${((x ?? 0) * 100).toFixed(2)}%`;
505
+ const attrs = [['Strength', st.str], ['Agility', st.agi], ['Stamina', st.sta], ['Intellect', st.int], ['Spirit', st.spi]];
506
+ const combat = [['Armor', effectiveArmor(char, ds)], ['Attack pwr', attackPower(char, ds)], ['Crit', pct(critChanceDisplay(char, ds))],
507
+ ['Hit', pct(hitChance(char, ds))], ['Dodge', pct(dodgeChanceDisplay(char, ds))]];
508
+ return [
509
+ dim('─'.repeat(Math.max(0, w))),
510
+ `Health ${maxHealth(char, ds)} ${pt[0].toUpperCase() + pt.slice(1)} ${maxPower(char, ds)}`,
511
+ '',
512
+ ...attrs.map(([name, v], i) => `${fit(name, 10)}${String(v).padStart(5)} ${fit(combat[i][0], 10)} ${String(combat[i][1]).padStart(7)}`),
513
+ ];
514
+ }
515
+
516
+ function sheetTitle(app, comp) {
517
+ const ilvl = displayGearScore(comp.equipped ?? {}, app.ds);
518
+ return `${bold(comp.name)} ${dim(`level ${companionLevel(comp, app.ch.level)} ${raceName(comp.race)} ${className(comp.cls)}`)} ${roleText(comp.role)}${ilvl ? dim(` ilvl ~${ilvl}`) : ''}`;
519
+ }
520
+
521
+ /**
522
+ * A companion's character sheet: the Character tab's paperdoll (tuiPaperdoll.mjs) for them. Their pieces
523
+ * framed in quality colour down both sides, them in their gear in the middle, the tooltip beside and their
524
+ * stats at its foot. Enter picks from their bags and yours, x takes a piece off, u puts on every upgrade.
525
+ * Too small a window or no colour, and it lists the slots with their icons instead.
526
+ */
527
+ export function companionSheet(app, id) {
528
+ const viewId = `sheet-${id}`;
529
+ const hint = 'Enter: change x: take off u: upgrades';
530
+ return {
531
+ id: viewId, title: 'Character sheet', hint: 'Enter: change',
532
+ detailFoot: (a, w) => { const c = findComp(a, id); return c ? sheetStats(a, companionCharacter(c, companionLevel(c, a.ch.level), a.ds), w) : []; },
533
+ keys: {
534
+ x: { hint: 'take off', fn: (a, row) => { const d = row?.data; if (d?.slot && d.it) a.result(a.g.unequipCompanionSlot(id, d.slot), `${findComp(a, id)?.name} takes off ${itemText(d.it)}.`); } },
535
+ u: { hint: 'put on upgrades', fn: (a) => { const c = findComp(a, id), r = a.g.equipCompanionBest(id); a.result(r, r.equipped ? `${c?.name} puts on ${plural(r.equipped, 'piece')}.` : `${c?.name} already wears the best they can.`); } },
536
+ },
537
+ grid: (a, rows, w, h) => { const c = findComp(a, id); return c ? paperdollGrid(a, rows, w, h, (cols, lines) => paperdollArt(c, cols, lines), sheetTitle(a, c), hint) : null; },
538
+ rows(a) {
539
+ const ds = a.ds, comp = findComp(a, id);
540
+ if (!comp) { setTimeout(() => { if (a.top()?.id === viewId) a.pop(); }, 0); return [H('They are gone.')]; }
541
+ const ups = a.g.companionUpgradesFor(id);
542
+ const rows = paperdollPlan(a, 0) ? [] : [{ text: sheetTitle(a, comp), header: true, plain: true }, H('')];
543
+ for (const slot of EQUIP_SLOTS) {
544
+ const e = equippedEntry(comp.equipped?.[slot]);
545
+ const it = e != null ? ds.item(e) : null;
546
+ const best = ups.filter((u) => u.slot === slot).sort((x, y) => y.delta - x.delta)[0];
547
+ const bestLines = best ? ['', green(`▲ Better: ${a.itemName(best.entry)} (+${Math.round(best.delta)})${best.from === 'player' ? ', in your bags' : ', in their bags'}`), dim('Enter: pick it u: put on every upgrade')] : [];
548
+ rows.push({
549
+ text: `${fit(SLOT_LABEL[slot] ?? slot, 10)} ${it ? qualityPaint(it.quality)(it.name) : dim('—')}${best ? green(' ▲') : ''}`, data: { slot, it },
550
+ icon: it?.icon, sub: it ? dim(`${fit('', 10)} ${gearKind(it)}`) : undefined,
551
+ cell: { slot, it, up: !!best, sub: dim(gearKind(it)) },
552
+ detail: () => (it ? itemLines(a, it, [...enchantLines(a, comp, slot), '', dim('Enter: change x: take off'), ...bestLines])
553
+ : [bold(SLOT_LABEL[slot] ?? slot), dim('Nothing worn here.'), '', dim('Enter: pick something for them to wear'), ...bestLines]),
554
+ act: () => slotPicker(a, id, slot),
555
+ });
556
+ }
387
557
  return rows;
388
558
  },
389
559
  };
@@ -396,7 +566,7 @@ function giveDialog(app, id) {
396
566
  if (!st.length) { app.flashMsg('Your bags are empty.'); return; }
397
567
  app.pick(`Give to ${comp?.name}`, st.map((s, slot) => {
398
568
  const it = app.ds.item(s.item);
399
- return { label: `${qualityPaint(it?.quality)(it?.name ?? s.item)}${s.count > 1 ? ` x${s.count}` : ''}`, detail: () => itemLines(app, it), value: { s, slot, it } };
569
+ return { label: `${qualityPaint(it?.quality)(it?.name ?? s.item)}${s.count > 1 ? ` x${s.count}` : ''}`, icon: it?.icon, sub: dim(it?.typeName ?? ''), detail: () => itemLines(app, it), value: { s, slot, it } };
400
570
  }), ({ slot, it }) => {
401
571
  const r = app.g.giveItemToBotStack(id, slot);
402
572
  app.result(r, `You give ${itemText(it, 'it')}${r.moved > 1 ? ` x${r.moved}` : ''} to ${comp?.name}.`);
@@ -414,7 +584,7 @@ function slotPicker(app, id, slot) {
414
584
  const options = [];
415
585
  const add = (s, from) => {
416
586
  const it = ds.item(s.item), up = ups.get(s.item);
417
- options.push({ label: `${qualityPaint(it.quality)(it.name)}${up ? green(` ▲ +${Math.round(up.delta)}`) : ''}${it.requiredLevel > lvl ? red(` L${it.requiredLevel}`) : ''}${dim(from === 'you' ? ' your bags' : ' their bags')}`, detail: () => itemLines(app, it), value: it });
587
+ options.push({ label: `${qualityPaint(it.quality)(it.name)}${up ? green(` ▲ +${Math.round(up.delta)}`) : ''}${it.requiredLevel > lvl ? red(` L${it.requiredLevel}`) : ''}${dim(from === 'you' ? ' your bags' : ' their bags')}`, icon: it.icon, sub: dim(gearKind(it)), detail: () => itemLines(app, it), value: it });
418
588
  };
419
589
  for (const s of comp.bags?.stacks ?? []) if (fits(s)) add(s, 'them');
420
590
  for (const s of app.g.state.inventory.stacks) if (fits(s)) add(s, 'you');