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,19 +1,21 @@
1
1
  // The tabs and dialogs of the windowed game. A view is `{ id, title, hint, rows(app), keys, detail }`; a row is
2
2
  // `{ text, header?, act?, detail?, keys? }`. Views read the Game facade and DataStore; every change is a facade call.
3
- import { bold, dim, cyan, yellow, green, red, gray, magenta, brightBlue, brightYellow, qualityPaint, QUALITY, questText, wrapLines, fit, secondsText, plural } from './text.mjs';
3
+ import { bold, dim, cyan, yellow, green, red, gray, magenta, brightBlue, brightYellow, qualityPaint, QUALITY, questText, wrapLines, fit, secondsText, plural, vis } from './text.mjs';
4
4
  import { ruleSentence, conditionsSentence, condSentence, PHASE_LABEL } from './rotationText.mjs';
5
5
  import { startFight, startAutopilot } from './tuiFight.mjs';
6
- import { className } from '../domain/core/ids.ts';
6
+ import { className, raceName } from '../domain/core/ids.ts';
7
+ import { nativeDisplayId, wornDisplay } from '../domain/character/displayForm.ts';
7
8
  import { moneyText } from '../domain/economy/money.ts';
8
9
  import { maxHealth, maxPower } from '../domain/character/character.ts';
9
10
  import { effectiveStats, effectiveArmor, attackPower, powerType } from '../domain/character/characterStats.ts';
10
11
  import { critChanceDisplay, dodgeChanceDisplay, hitChance, weaponSkillIdForHand, skillBonusFor } from '../domain/character/characterRatings.ts';
11
- import { COMBAT_SKILL, isLevelRangeCombatSkill, combatSkillDisplayName } from '../domain/character/combatSkills.ts';
12
+ import { COMBAT_SKILL, isLevelRangeCombatSkill, combatSkillDisplayName, isSpellFitByClassAndRace } from '../domain/character/combatSkills.ts';
12
13
  import { skillMaxForLevel } from '../engine/combat/mitigation.ts';
13
14
  import { knownUsableSpells } from '../domain/character/characterSpells.ts';
14
15
  import { bagCapacity, EQUIP_SLOTS, BAG_SLOTS, SLOT_LABEL, itemSlotTargets, DUAL_WIELD_SPELL_ID } from '../domain/items/equipment.ts';
15
16
  import { equippedEntry } from '../domain/items/itemInstance.ts';
16
- import { knows } from '../domain/character/spellbook.ts';
17
+ import { stackBoundToHolder } from '../domain/raid/partyVault.ts';
18
+ import { knows, learnGrantIds } from '../domain/character/spellbook.ts';
17
19
  import { availablePoints, canSpend, canRefund, spentInTree } from '../domain/character/talents.ts';
18
20
  import { offeredQuests, chainFollowUpOffer, grayLogQuests } from '../quests/quests.ts';
19
21
  import { isRepeatableQuest, questRepeatsAvailable } from '../domain/quests/questlog.ts';
@@ -27,8 +29,10 @@ import { squadJoinsPull } from './squad.mjs';
27
29
  import { barsDialog, playerBarsOwner } from './tuiBars.mjs';
28
30
  import { petWorldRows, petDialog, hasPetClass } from './tuiPet.mjs';
29
31
  import { portalRows } from './tuiDungeon.mjs';
32
+ import { conditionEditor, defaultConditionsForSpell, defaultItemRule } from './tuiRotationCond.mjs';
33
+ import { allyIndexOfSide } from '../engine/core/events.ts';
30
34
  import { bulkDisenchantDialog, learnAllDialog } from './tuiBulk.mjs';
31
- import { gearExtraRows, durabilityText, repairOne, slotRepairLines, enchantLines } from './tuiGear.mjs';
35
+ import { gearExtraRows, durabilityText, repairOne, repairAll, slotRepairLines, enchantLines } from './tuiGear.mjs';
32
36
  import { outfitterDialog } from './tuiOutfitter.mjs';
33
37
  import { castWithPicker } from './tuiEnchantTarget.mjs';
34
38
  import { settingsDialog } from './tuiSettings.mjs';
@@ -37,6 +41,12 @@ import { pickPocket, pickPocketTag, journeyRows, afterDeparture, ridingRows, obj
37
41
  import { talentBuildsMenu, myTalentLink } from './tuiTalents.mjs';
38
42
  import { simLabDialog } from './tuiSimLab.mjs';
39
43
  import { autopilotSettingsDialog } from './tuiAutopilotSettings.mjs';
44
+ import { preflightCard } from './tuiAutopilot.mjs';
45
+ import { creatureArt } from './modelArt.mjs';
46
+ import { withIcon, iconsOn } from './iconArt.mjs';
47
+ import { paperdollGrid, paperdollPlan } from './tuiPaperdoll.mjs';
48
+ import { talentGrid, talentPlan, talentTone, talentIcon } from './tuiTalentTree.mjs';
49
+ import { paperdollArt } from './paperdoll.mjs';
40
50
  import { reputationDialog } from './tuiRep.mjs';
41
51
  import { honorDialog, honorRankLine } from './tuiHonor.mjs';
42
52
  import { openWorldSearch } from './tuiSearch.mjs';
@@ -56,7 +66,24 @@ export function conPaint(playerLevel, level) {
56
66
 
57
67
  // ---- detail panes --------------------------------------------------------------------------------
58
68
 
69
+ // A spell's tooltip text with the tokens the bake left in filled from its own effects: $v max target
70
+ // level, $q misc value (Resurrection's mana), $f damage multiplier (Ferocious Bite). A leading id reads
71
+ // another spell's effect ($3026q1). Anything else stays as written.
72
+ export function spellText(ds, sp) {
73
+ const text = sp?.description?.trim();
74
+ if (!text?.includes('$')) return text;
75
+ const fx = (id, i) => (id ? ds.spell(Number(id)) : sp)?.rawEffects?.[(Number(i) || 1) - 1];
76
+ return text
77
+ .replace(/\$v\b/g, (m) => sp.maxTargetLevel != null ? String(sp.maxTargetLevel) : m)
78
+ .replace(/\$(\d*)q(\d?)/g, (m, id, i) => fx(id, i)?.miscValue != null ? String(fx(id, i).miscValue) : m)
79
+ .replace(/\$(\d*)f(\d)/g, (m, id, i) => fx(id, i)?.dmgMultiplier != null ? String(fx(id, i).dmgMultiplier) : m);
80
+ }
81
+
59
82
  export function itemLines(app, item, extra = []) {
83
+ return withIcon(item?.icon, itemLinesText(app, item, extra));
84
+ }
85
+
86
+ function itemLinesText(app, item, extra = []) {
60
87
  if (!item) return ['Unknown item'];
61
88
  const out = [qualityPaint(item.quality)(bold(item.name))];
62
89
  const kind = [item.slot, item.typeName].filter(Boolean).join(' · ');
@@ -68,12 +95,37 @@ export function itemLines(app, item, extra = []) {
68
95
  if (item.armor) out.push(`${item.armor} Armor`);
69
96
  if (item.block) out.push(`${item.block} Block`);
70
97
  for (const s of item.stats ?? []) if (s.value) out.push(`${s.value > 0 ? '+' : ''}${s.value} ${STAT_LABEL[s.stat] ?? s.stat}`);
71
- if (item.consumable) out.push(green(`Use: Restores ${item.consumable.amountPerTick * Math.round(item.consumable.durationMs / item.consumable.tickMs)} ${item.consumable.effect === 'drink' ? 'mana' : 'health'} over ${Math.round(item.consumable.durationMs / 1000)} sec. Must remain seated.`));
72
- if (item.useInCombat?.effect === 'restore_health') out.push(green(`Use: Restores ${item.useInCombat.min} to ${item.useInCombat.max} health.`));
73
- if (item.useInCombat?.effect === 'restore_power') out.push(green(`Use: Restores ${item.useInCombat.min} to ${item.useInCombat.max} mana.`));
98
+ for (const id of new Set(item.equipSpells ?? [])) {
99
+ const text = spellText(app.ds, app.ds.spell(id));
100
+ if (text) out.push(green(`Equip: ${text}`));
101
+ }
102
+ // ITEM_SPELLTRIGGER_CHANCE_ON_HIT 2.
103
+ for (const p of item.procs ?? []) {
104
+ const text = p.trigger === 2 && spellText(app.ds, app.ds.spell(p.spell));
105
+ if (text) out.push(green(`Chance on hit: ${text}`));
106
+ }
107
+ // The use spell's own tooltip text carries the Well Fed rider; a few still hold unresolved $ tokens.
108
+ const useText = spellText(app.ds, app.ds.spell(item.useSpell ?? item.useInCombat?.spellId));
109
+ if (useText && !useText.includes('$')) out.push(green(`Use: ${useText}`));
110
+ else {
111
+ // Same tick count rest.ts heals for: floor(duration / tick).
112
+ if (item.consumable) out.push(green(`Use: Restores ${item.consumable.amountPerTick * Math.max(1, Math.floor(item.consumable.durationMs / item.consumable.tickMs))} ${item.consumable.effect === 'drink' ? 'mana' : 'health'} over ${Math.round(item.consumable.durationMs / 1000)} sec. Must remain seated.`));
113
+ if (item.useInCombat?.effect === 'restore_health') out.push(green(`Use: Restores ${item.useInCombat.min} to ${item.useInCombat.max} health.`));
114
+ if (item.useInCombat?.effect === 'restore_power') out.push(green(`Use: Restores ${item.useInCombat.min} to ${item.useInCombat.max} mana.`));
115
+ }
74
116
  if (item.requiredLevel > 1) out.push(`Requires Level ${item.requiredLevel}`);
75
117
  if (item.allowClass?.length) out.push(`Classes: ${item.allowClass.map(className).join(', ')}`);
76
118
  if (item.bagSlots) out.push(`${item.bagSlots} Slot Bag`);
119
+ const set = item.setId && app.ds.itemSet(item.setId);
120
+ if (set) {
121
+ const worn = new Set(Object.values(app.ch?.equipped ?? {}).map(equippedEntry));
122
+ const have = set.items.filter((e) => worn.has(e)).length;
123
+ out.push('', yellow(`${set.name ?? 'Item set'} (${have}/${set.items.length})`));
124
+ for (const b of set.bonuses) {
125
+ const text = spellText(app.ds, app.ds.spell(b.spell));
126
+ if (text) out.push((have >= b.pieces ? green : gray)(`(${b.pieces}) Set: ${text}`));
127
+ }
128
+ }
77
129
  if (item.description) out.push(yellow(`"${item.description}"`));
78
130
  if (item.sellPrice) out.push(dim(`Sells for ${moneyText(item.sellPrice)}`));
79
131
  out.push(...extra);
@@ -81,6 +133,10 @@ export function itemLines(app, item, extra = []) {
81
133
  }
82
134
 
83
135
  export function spellLines(app, sp, extra = []) {
136
+ return withIcon(sp?.icon, spellLinesText(app, sp, extra));
137
+ }
138
+
139
+ function spellLinesText(app, sp, extra = []) {
84
140
  if (!sp) return ['Unknown spell'];
85
141
  const out = [bold(sp.name) + (sp.rank ? dim(` Rank ${sp.rank}`) : '')];
86
142
  const bits = [];
@@ -90,7 +146,8 @@ export function spellLines(app, sp, extra = []) {
90
146
  if (sp.rangeMax) bits.push(`${sp.rangeMax} yd range`);
91
147
  out.push(dim(bits.join(' ')));
92
148
  if (sp.reagents?.length) out.push(dim('Reagents: ' + sp.reagents.map((r) => `${app.itemLabel(r.item)}${r.count > 1 ? ` x${r.count}` : ''}`).join(', ')));
93
- if (sp.description) out.push(...wrapLines(yellow(sp.description), 60));
149
+ const text = spellText(app.ds, sp);
150
+ if (text) out.push(yellow(text)); // the pane wraps it, beside the icon while they share its rows
94
151
  const cd = app.g.spellCooldownRemaining(sp.id);
95
152
  if (cd > 0) out.push(red(`Cooldown: ${secondsText(cd)} left`));
96
153
  out.push(...extra);
@@ -113,12 +170,25 @@ export function creatureLines(app, c) {
113
170
  out.push(...wanted);
114
171
  if ((app.g.state.squad ?? []).length) out.push(squadJoinsPull(app.g, app.ds, c.entry) ? cyan('Your companions join this fight.') : dim('You take this one alone (squad mode, m on Companions).'));
115
172
  out.push('', dim('Enter: fight live f: auto-resolve'));
173
+ // The creature itself under the text, as large as the pane has room for (modelArt.mjs).
174
+ const L = app.layout?.();
175
+ const art = L && c.displayId ? creatureArt(c.displayId, L.detailW - 2, L.mainH - out.length - 1) : null;
176
+ if (art) out.push('', ...art);
116
177
  return out;
117
178
  }
118
179
 
180
+ /** A person in the world: the text, then the NPC's creature drawn under it as large as the pane has room for. */
181
+ export function npcLines(app, entry, text) {
182
+ const L = app.layout?.(), id = app.ds.creature(entry)?.displayId;
183
+ if (!L || !id) return text;
184
+ const w = L.detailW - 2, used = text.reduce((n, line) => n + (vis(line) > w ? wrapLines(line, w).length : 1), 0);
185
+ const art = creatureArt(id, w, L.mainH - used - 1);
186
+ return art ? [...text, '', ...art] : text;
187
+ }
188
+
119
189
  export function questLines(app, q, qs) {
120
190
  const ch = app.ch;
121
- const out = [bold(q.title) + dim(` level ${q.questLevel}${q.minLevel > ch.level ? red(` requires ${q.minLevel}`) : ''}`)];
191
+ const out = [conPaint(ch.level, q.questLevel)(bold(q.title)) + dim(` level ${q.questLevel}${q.minLevel > ch.level ? red(` requires ${q.minLevel}`) : ''}`)];
122
192
  if (qs) out.push(qs.status === 'complete' ? green('Ready to turn in') : qs.status === 'failed' ? red('Failed') : yellow('In progress'));
123
193
  out.push(...objectiveLines(app, q, qs).map((o) => ` ${o}`));
124
194
  const rew = rewardLines(app, q);
@@ -142,7 +212,7 @@ export function objectiveLines(app, q, qs) {
142
212
 
143
213
  export function rewardLines(app, q) {
144
214
  const out = [];
145
- if (q.rewXP) out.push(`${q.rewXP} experience`);
215
+ if (q.rewXP) out.push(magenta(`${q.rewXP} experience`));
146
216
  if (q.rewMoney) out.push(yellow(moneyText(q.rewMoney)));
147
217
  for (const it of q.rewItems) out.push(`${app.itemLabel(it.item)}${it.count > 1 ? ` x${it.count}` : ''}`);
148
218
  if (q.rewChoiceItems?.length) out.push('Choose one of: ' + q.rewChoiceItems.map((it) => app.itemLabel(it.item)).join(', '));
@@ -154,17 +224,17 @@ export function rewardLines(app, q) {
154
224
 
155
225
  export function makeTabs(app) {
156
226
  return [
157
- { label: 'World', view: worldView(app) },
158
- { label: 'Quests', view: questsView(app) },
159
- { label: 'Bags', view: bagsView(app) },
160
- { label: 'Gear', view: gearView(app) },
161
- { label: 'Spells', view: spellsView(app) },
162
- { label: 'Rotation', view: rotationView(app) },
163
- { label: 'Talents', view: talentsView(app) },
164
- { label: 'Travel', view: travelView(app) },
165
- { label: 'Professions', short: 'Profs', view: professionsView(app) },
166
- { label: 'Companions', short: 'Party', view: companionsView(app) },
167
- { label: 'Character', short: 'Char', view: charView(app) },
227
+ { label: 'World', icon: 'inv_misc_map_01', view: worldView(app) },
228
+ { label: 'Quests', icon: 'inv_misc_note_01', view: questsView(app) },
229
+ { label: 'Bags', icon: 'inv_misc_bag_08', view: bagsView(app) },
230
+ { label: 'Gear', icon: 'inv_chest_chain_05', view: gearView(app) },
231
+ { label: 'Spells', icon: 'inv_misc_book_09', view: spellsView(app) },
232
+ { label: 'Rotation', icon: 'ability_dualwield', view: rotationView(app) },
233
+ { label: 'Talents', icon: 'spell_nature_naturetouchgrow', view: talentsView(app) },
234
+ { label: 'Travel', icon: 'spell_nature_swiftness', view: travelView(app) },
235
+ { label: 'Professions', short: 'Profs', icon: 'trade_blacksmithing', view: professionsView(app) },
236
+ { label: 'Companions', short: 'Party', icon: 'spell_holy_prayerofhealing', view: companionsView(app) },
237
+ { label: 'Character', short: 'Char', icon: 'inv_misc_head_human_01', view: charView(app) },
168
238
  ];
169
239
  }
170
240
 
@@ -173,28 +243,30 @@ function worldView(app) {
173
243
  id: 'world', title: 'World', hint: 'Enter: open / fight',
174
244
  keys: {
175
245
  s: { hint: 'search', fn: (a) => openWorldSearch(a) },
246
+ S: { fn: (a) => openWorldSearch(a, '', true) },
176
247
  f: { hint: 'quick fight', fn: (a, row) => { if (row?.data?.creature) a.cmds.commands.fight(String(row.data.creature.entry)); } },
177
248
  t: { fn: (a, row) => { const c = row?.data?.creature; if (!c) return; if (a.ch.cls !== 3) { a.flashMsg('Only hunters tame beasts.'); return; } a.result(a.g.tamePet(c.entry), `You tame ${c.name}.`); } },
178
249
  P: { fn: (a, row) => { if (row?.data?.creature) pickPocket(a, row.data.creature); } },
179
- a: { hint: 'autopilot', fn: (a) => startAutopilot(a) },
250
+ a: { hint: 'autopilot', fn: (a) => a.push(preflightCard(a)) },
180
251
  },
181
252
  rows(a) {
182
253
  const ds = a.ds, g = a.g, z = a.zone(), ch = a.ch;
183
254
  if (!z) return [{ text: 'You are nowhere.', header: true }];
184
255
  const rows = [...journeyRows(a), ...meetingStoneQueueRows(a), { text: `${z.name} ${dim(`levels ${z.levelRange[0]}-${z.levelRange[1]}${z.faction ? `, ${z.faction}` : ''}`)}`, header: true }];
185
256
  const people = [];
186
- for (const t of z.trainers ?? []) if (t.cls === ch.cls) { const d = ds.trainer(t.entry); if (d) people.push({ text: `${d.name} ${dim(`<${className(t.cls)} Trainer>`)}`, detail: () => [bold(d.name), `${className(t.cls)} trainer`, '', dim('Enter: see what you can learn')], act: () => a.push(trainerDialog(a, d)) }); }
187
- for (const v of z.vendors ?? []) { const d = ds.vendor(v); if (d) people.push({ text: `${d.name} ${dim(`<${d.subname ?? 'Vendor'}>`)}${d.canRepair ? dim(' repairs') : ''}`, detail: () => [bold(d.name), d.subname ?? 'Vendor', d.canRepair ? 'Repairs armor and weapons' : '', '', dim('Enter: browse wares')], act: () => a.push(vendorDialog(a, d)) }); }
188
- for (const i of z.innkeepers ?? []) people.push({ text: `${i.name} ${dim('<Innkeeper>')}`, detail: () => [bold(i.name), 'Innkeeper', '', dim('Enter: make this your home, rest')], act: () => a.push(innDialog(a, i)) });
257
+ for (const t of z.trainers ?? []) if (t.cls === ch.cls) { const d = ds.trainer(t.entry); if (d) people.push({ text: `${d.name} ${dim(`<${className(t.cls)} Trainer>`)}`, detail: () => npcLines(a, d.entry, [bold(d.name), `${className(t.cls)} trainer`, '', dim('Enter: see what you can learn')]), act: () => a.push(trainerDialog(a, d)) }); }
258
+ const innEntries = new Set((z.innkeepers ?? []).map((i) => i.entry)); // innkeepers sell too, but list once, as the inn
259
+ for (const v of z.vendors ?? []) { const d = !innEntries.has(v) && ds.vendor(v); if (d) people.push({ text: `${d.name} ${dim(`<${d.subname ?? 'Vendor'}>`)}${d.canRepair ? dim(' repairs') : ''}`, detail: () => npcLines(a, d.entry, [bold(d.name), d.subname ?? 'Vendor', ...(d.canRepair ? ['Repairs armor and weapons'] : []), '', dim('Enter: browse wares')]), act: () => a.push(vendorDialog(a, d)) }); }
260
+ for (const i of z.innkeepers ?? []) people.push({ text: `${i.name} ${dim('<Innkeeper>')}`, detail: () => npcLines(a, i.entry, [bold(i.name), 'Innkeeper', '', dim('Enter: browse wares, make this your home')]), act: () => a.push(innDialog(a, i)) });
189
261
  for (const p of z.profTrainers ?? []) {
190
262
  const d = ds.profTrainer(p);
191
263
  if (!d) continue;
192
264
  const prof = ds.profession(d.skill)?.name ?? d.skill;
193
265
  const sk = ch.skills[d.skill];
194
- people.push({ text: `${d.name} ${dim(`<${prof} Trainer>`)}${sk ? dim(` ${sk.current}/${sk.max}`) : ''}`, detail: () => [bold(d.name), `${prof} trainer`, sk ? `Your skill: ${sk.current}/${sk.max}` : `You do not know ${prof} yet.`, '', dim('Enter: ranks and recipes')], act: () => a.push(profTrainerDialog(a, d)) });
266
+ people.push({ text: `${d.name} ${dim(`<${prof} Trainer>`)}${sk ? dim(` ${sk.current}/${sk.max}`) : ''}`, detail: () => npcLines(a, d.entry, [bold(d.name), `${prof} trainer`, sk ? `Your skill: ${sk.current}/${sk.max}` : `You do not know ${prof} yet.`, '', dim('Enter: ranks and recipes')]), act: () => a.push(profTrainerDialog(a, d)) });
195
267
  }
196
268
  people.push(...ridingRows(a));
197
- for (const f of z.flightMasters ?? []) people.push({ text: `${f.name} ${dim('<Flight Master>')}`, detail: () => [bold(f.name), 'Flight master', '', dim('Enter: open the travel tab')], act: () => a.switchTab(7) });
269
+ for (const f of z.flightMasters ?? []) people.push({ text: `${f.name} ${dim('<Flight Master>')}`, detail: () => npcLines(a, f.entry, [bold(f.name), 'Flight master', '', dim('Enter: open the travel tab')]), act: () => a.switchTab(7) });
198
270
  // City services: the bank in capitals and banker towns, the auction house in capitals, the mailbox by every inn.
199
271
  const city = cityAccess(a);
200
272
  const services = [];
@@ -223,19 +295,13 @@ function worldView(app) {
223
295
  rows.push({ text: `${conPaint(ch.level, m.level)(m.name)} ${dim(`L${m.level}`)}${wanted ? yellow(' !') : ''}${ch.cls === 4 ? pickPocketTag(a, m) : ''}`, data: { creature: m }, detail: () => creatureLines(a, m), act: () => startFight(a, m.entry) });
224
296
  }
225
297
  }
226
- // Last, since the Quests tab is the main place for these: the same offer list the autopilot reads (chain,
227
- // class, race and exclusive-group gates applied); `canAccept` false means visible but level- or
228
- // reputation-locked (shown grey, like a silver !).
229
- const have = new Map(g.state.questlog.quests.map((qs) => [qs.entry, qs]));
230
- const offered = offeredQuests(ds, z.id, ch, g.state.questlog).filter((o) => !have.has(o.quest.entry) && (o.canAccept || o.quest.minLevel <= ch.level + 5));
298
+ // Last: hand-ins and the zone's offers (the Quests tab shows the same list under its log).
231
299
  const ready = g.state.questlog.quests.filter((qs) => qs.status === 'complete').map((qs) => ({ qs, q: ds.quest(qs.entry) })).filter((x) => x.q);
300
+ const offered = zoneOfferRows(a);
232
301
  if (offered.length || ready.length) {
233
302
  rows.push({ text: '', header: true }, { text: 'Quests', header: true });
234
- for (const { q, qs } of ready) rows.push({ text: `${green('?')} ${q.title} ${dim(`L${q.questLevel}`)} ${green('turn in')}`, detail: () => questLines(a, q, qs), act: () => a.push(questDialog(a, q, 'log')) });
235
- for (const o of offered) {
236
- const q = o.quest;
237
- rows.push({ text: `${o.canAccept ? yellow('!') : dim('·')} ${o.canAccept ? q.title : dim(q.title)} ${dim(`L${q.questLevel}`)}${!o.canAccept && q.minLevel > ch.level ? dim(` at level ${q.minLevel}`) : ''}`, detail: () => questLines(a, q), act: () => a.push(questDialog(a, q, 'offer')) });
238
- }
303
+ for (const { q, qs } of ready) rows.push({ text: `${green('?')} ${conPaint(ch.level, q.questLevel)(`${q.title} L${q.questLevel}`)} ${green('turn in')}`, detail: () => questLines(a, q, qs), act: () => a.push(questDialog(a, q, 'log')) });
304
+ rows.push(...offered);
239
305
  }
240
306
  return rows;
241
307
  },
@@ -258,14 +324,35 @@ export function travelTo(app, dest) {
258
324
  if (!app.result(res)) return;
259
325
  app.vendor = null; app.cmds.setVendor(null);
260
326
  afterDeparture(app, () => `You travel to ${bold(dest.name)}. ${dim(`${secondsText(pv.totalSeconds * 1000)}${pv.totalCopper ? `, ${moneyText(pv.totalCopper)}` : ''}`)}`);
327
+ landOnTab(app);
328
+ }
329
+
330
+ /** After a departure: stay on the Travel tab when the trip started there (you may want to go on), else open World. */
331
+ export function landOnTab(app) {
332
+ const travel = app.tabs.findIndex((t) => t.view?.id === 'travel');
333
+ const i = app.tab === travel && !app.stack.length ? travel : 0;
261
334
  app.stack = [];
262
- app.switchTab(0);
263
- app.tabs[0].view.cursor = 0; app.tabs[0].view.scroll = 0;
335
+ app.switchTab(i);
336
+ const v = app.tabs[i]?.view; if (v) { v.cursor = 0; v.scroll = 0; }
337
+ }
338
+
339
+ /** The quests this zone offers that are not in the log: the same offer list the autopilot reads (chain, class,
340
+ * race and exclusive-group gates applied). `canAccept` false means visible but level- or reputation-locked
341
+ * (shown grey, like a silver !); quests more than five levels above you are left out. */
342
+ export function zoneOfferRows(a) {
343
+ const z = a.zone(), ch = a.ch;
344
+ if (!z) return [];
345
+ const have = new Set(a.g.state.questlog.quests.map((qs) => qs.entry));
346
+ const offered = offeredQuests(a.ds, z.id, ch, a.g.state.questlog).filter((o) => !have.has(o.quest.entry) && (o.canAccept || o.quest.minLevel <= ch.level + 5));
347
+ return offered.map((o) => {
348
+ const q = o.quest;
349
+ return { text: `${o.canAccept ? yellow('!') : dim('·')} ${o.canAccept ? conPaint(ch.level, q.questLevel)(`${q.title} L${q.questLevel}`) : dim(`${q.title} L${q.questLevel}`)}${!o.canAccept && q.minLevel > ch.level ? dim(` at level ${q.minLevel}`) : ''}`, detail: () => questLines(a, q), act: () => a.push(questDialog(a, q, 'offer')) };
350
+ });
264
351
  }
265
352
 
266
353
  function questsView(app) {
267
354
  return {
268
- id: 'quests', title: 'Quest log', hint: 'Enter: open', empty: 'Your quest log is empty. Look for ! in the world.',
355
+ id: 'quests', title: 'Quest log', hint: 'Enter: open', empty: 'Your quest log is empty and this zone offers nothing. Look for ! in the world.',
269
356
  keys: {
270
357
  d: { hint: 'do quest', fn: (a, row) => { if (row?.data) doQuest(a, row.data.q); } },
271
358
  x: { hint: 'abandon', fn: (a, row) => { if (row?.data) a.confirm(`Abandon ${row.data.q.title}?`, () => a.result(a.g.abandonQuest(row.data.q.entry), `Abandoned ${row.data.q.title}.`)); } },
@@ -278,8 +365,14 @@ function questsView(app) {
278
365
  rows.push({ text: `${log.length} of 20 quests`, header: true });
279
366
  for (const x of log) {
280
367
  const st = x.qs.status === 'complete' ? green('(Complete)') : x.qs.status === 'failed' ? red('(Failed)') : a.cmds.questBlacklist.has(x.q.entry) ? dim('(autopilot skips)') : '';
281
- rows.push({ text: `${x.q.title} ${dim(`L${x.q.questLevel}`)} ${st}`, data: x, detail: () => questLines(a, x.q, x.qs), act: () => a.push(questDialog(a, x.q, 'log')) });
368
+ rows.push({ text: `${conPaint(a.ch.level, x.q.questLevel)(`${x.q.title} L${x.q.questLevel}`)} ${st}`, data: x, detail: () => questLines(a, x.q, x.qs), act: () => a.push(questDialog(a, x.q, 'log')) });
282
369
  }
370
+ // Below the log: what this zone offers that you have not taken, so you need not scroll the World tab for it.
371
+ const offered = zoneOfferRows(a);
372
+ if (offered.length) {
373
+ if (log.length) rows.push({ text: '', header: true });
374
+ rows.push({ text: `Available in ${a.zone()?.name ?? 'this zone'} (${offered.length})`, header: true }, ...offered);
375
+ } else if (!log.length) return [];
283
376
  return rows;
284
377
  },
285
378
  };
@@ -292,14 +385,19 @@ export function questDialog(app, q, mode) {
292
385
  rows(a) {
293
386
  const W = a.layout().listW - 4;
294
387
  const ch = a.ch, s = qs();
295
- const rows = [{ text: `${bold(q.title)} ${dim(`L${q.questLevel}`)}`, header: true }, { text: '', header: true }];
296
- const text = (t) => wrapLines(questText(t, ch), W).map((l) => ({ text: l, header: true }));
297
- if (mode === 'offer') rows.push(...text(q.detailsText), { text: '', header: true }, { text: 'Objectives', header: true }, ...text(q.objectivesText));
298
- else if (s?.status === 'complete') rows.push(...text(q.offerRewardText || q.endText || q.objectivesText));
299
- else rows.push(...text(q.objectivesText), { text: '', header: true }, ...(q.requestItemsText ? text(q.requestItemsText) : []));
300
- rows.push({ text: '', header: true });
301
- for (const o of objectiveLines(a, q, s)) rows.push({ text: ` ${o}`, header: true });
302
- rows.push({ text: '', header: true });
388
+ // Each part reads apart: the title in its level colour, cyan section headings, the story in plain
389
+ // text, the objective in bold with its counts, rewards in their own paint.
390
+ const P = (t) => ({ text: t, header: true, plain: true });
391
+ const rows = [P(`${conPaint(ch.level, q.questLevel)(bold(q.title))} ${dim(`L${q.questLevel}`)}`)];
392
+ const text = (t, paint = (l) => l) => wrapLines(questText(t, ch), W).map((l) => P(paint(l)));
393
+ const section = (title, lines) => { if (lines.length) rows.push(P(''), { text: title, header: true }, ...lines); };
394
+ const story = () => section(s?.status === 'complete' ? 'Completion' : 'Description',
395
+ text(s?.status === 'complete' ? q.offerRewardText || q.endText || '' : q.detailsText || q.requestItemsText || ''));
396
+ const objectives = () => section('Objectives', [...text(q.objectivesText ?? '', bold), ...objectiveLines(a, q, s).map((o) => P(` ${o}`))]);
397
+ // Offered, the story leads as the giver tells it; in the log, what is left to do comes first.
398
+ if (mode === 'offer' || s?.status === 'complete') { story(); objectives(); } else { objectives(); story(); }
399
+ section('Rewards', rewardLines(a, q).map((r) => P(` ${r}`)));
400
+ rows.push(P(''));
303
401
  if (mode === 'offer') {
304
402
  rows.push({ text: green('Accept the quest'), act: () => { if (a.result(a.g.acceptQuest(q.entry), `Quest accepted: ${q.title}`)) a.pop(); } });
305
403
  rows.push({ text: dim('Decline'), act: () => a.pop() });
@@ -308,7 +406,7 @@ export function questDialog(app, q, mode) {
308
406
  // A repeatable with a fixed reward hands in every round the bags pay for in one go.
309
407
  const rounds = s?.status === 'complete' && isRepeatableQuest(q) && questRewardChoices(q).length <= 1 ? questRepeatsAvailable(q, a.g.state.inventory, a.ch.zoneId) : 0;
310
408
  if (rounds >= 2) rows.push({ text: green(`Hand it in ${rounds} times`), act: () => repeatTurnIn(a, q) });
311
- else rows.push({ text: cyan('Do the quest'), act: () => doQuest(a, q) });
409
+ else rows.push({ text: 'Do the quest', act: () => doQuest(a, q) });
312
410
  rows.push({ text: red('Abandon the quest'), act: () => a.confirm(`Abandon ${q.title}?`, () => { if (a.result(a.g.abandonQuest(q.entry), `Abandoned ${q.title}.`)) a.popTo('__none__'); }) });
313
411
  rows.push({ text: dim('Close'), act: () => a.pop() });
314
412
  }
@@ -324,7 +422,7 @@ function doQuest(app, q) {
324
422
  const s = app.g.state.questlog.quests.find((x) => x.entry === q.entry);
325
423
  if (s?.status === 'complete' && q.rewChoiceItems?.length) { turnIn(app, q); return; }
326
424
  app.stack = [];
327
- startAutopilot(app, 500, undefined, q.entry);
425
+ startAutopilot(app, Infinity, undefined, q.entry);
328
426
  }
329
427
 
330
428
  function repeatTurnIn(app, q) {
@@ -379,7 +477,7 @@ function bagsView(app) {
379
477
  const slotOf = (a, row) => (row?.data && !('keyring' in row.data) ? a.g.state.inventory.stacks.indexOf(row.data) : -1);
380
478
  const onStack = (fn) => (a, row) => { const i = slotOf(a, row); if (i >= 0) fn(a, i); };
381
479
  return {
382
- id: 'bags', title: 'Bags', hint: 'Enter: use / equip / open m: more', empty: 'Your bags are empty.',
480
+ id: 'bags', title: 'Bags', hint: 'Enter: use / equip / open', empty: 'Your bags are empty.',
383
481
  keys: {
384
482
  m: { hint: 'item menu', fn: onStack(itemMenu) },
385
483
  s: { hint: 'sell', fn: (a, row) => { if (!a.vendor) { a.flashMsg('Open a vendor first (World tab).'); return; } const i = slotOf(a, row); if (i >= 0) sellStack(a, i); } },
@@ -407,6 +505,7 @@ function bagsView(app) {
407
505
  const tag = s.wrappedItemEntry ? dim(' gift') : it?.slot ? dim(` ${it.slot}`) : it?.consumable ? dim(` ${it.consumable.effect}`) : verb === 'Open' ? dim(' open') : verb === 'Learn' ? dim(' learn') : it?.questItem ? dim(' quest') : '';
408
506
  rows.push({
409
507
  text: `${name}${s.count > 1 ? ` x${s.count}` : ''}${tag}${ups.has(s.item) ? green(' ▲') : ''}`, data: s,
508
+ icon: it?.icon, sub: dim(`${it?.typeName ?? ''}${it?.sellPrice ? ` sells ${moneyText(it.sellPrice * s.count)}` : ''}`),
410
509
  detail: () => itemLines(a, it, [
411
510
  '', dim(`Enter: ${verb ? verb.toLowerCase() : 'more'} m: more${s.count > 1 ? ' /: split' : ''} [ ]: move d: destroy`),
412
511
  ...(a.vendor ? [dim(`s: sell to ${a.vendor.name}`)] : []),
@@ -426,9 +525,19 @@ function wornItem(app, slot) {
426
525
  return entry == null ? null : app.ds.item(entry) ?? null;
427
526
  }
428
527
 
429
- /** The engine's equipment helper: what in your bags (and the bank, when it is open) beats what you wear. */
528
+ /** The engine's equipment helper: what in your bags, or unbound in a companion's (a crafter's product), beats what you wear. */
430
529
  function upgradeList(app) {
431
- return app.g.suggestedEquipmentUpgrades().filter((u) => !u.holderId);
530
+ return app.g.suggestedEquipmentUpgrades();
531
+ }
532
+
533
+ /** Companions you own, squad first, each once. */
534
+ const ownedCompanions = (g) => [...new Map([...(g.state.squad ?? []), ...Object.values(g.state.botRoster ?? {})].map((c) => [c.id, c])).values()];
535
+
536
+ /** Wear `entry` in `slot`, first taking one unbound copy from companion `holderId` when it is theirs. */
537
+ function equipFrom(a, entry, slot, holderId) {
538
+ if (holderId && !a.result(a.g.takeItemFromBot(holderId, entry, 1), '')) return false;
539
+ const r = a.g.equip(entry, slot);
540
+ return a.result(r, r.ok ? `You equip ${a.ds.item(entry)?.name ?? entry}.` : '');
432
541
  }
433
542
 
434
543
  /** Lines comparing a candidate with the item worn in `slot`: armor, damage and stat deltas. */
@@ -452,42 +561,63 @@ function gearView(app) {
452
561
  id: 'gear', title: 'Equipment', hint: 'Enter: change what you wear',
453
562
  keys: {
454
563
  u: { hint: 'equip all upgrades', fn: (a) => {
455
- if (!upgradeList(a).length) { a.flashMsg('Nothing in your bags beats what you wear.'); return; }
456
- const n = a.g.autoEquipUpgrades();
564
+ if (!upgradeList(a).length) { a.flashMsg("Nothing in your bags or your companions' beats what you wear."); return; }
565
+ // Best first, re-scored after each piece (two rings, a two-hander). Works in suggest mode too,
566
+ // where autoEquipUpgrades stands down. Each entry is tried once, so displaced pieces cannot ping-pong.
567
+ let n = 0;
568
+ const tried = new Set();
569
+ for (let i = 0; i < 16; i++) {
570
+ const u = upgradeList(a).find((x) => !tried.has(x.entry));
571
+ if (!u) break;
572
+ tried.add(u.entry);
573
+ if (u.holderId && !a.g.takeItemFromBot(u.holderId, u.entry, 1).ok) continue;
574
+ if (a.g.equip(u.entry, u.slot).ok) n++;
575
+ }
457
576
  a.say(n ? green(`You put on ${plural(n, 'upgrade')}.`) : red('The upgrades could not be equipped.'));
458
577
  } },
459
578
  x: { hint: 'take off', fn: (a, row) => { if (row?.data?.it) a.result(a.g.unequip(row.data.slot), `You take off ${row.data.it.name}.`); } },
460
579
  r: { hint: 'repair', fn: (a, row) => { if (row?.data?.it) repairOne(a, row.data.slot); } },
580
+ R: { hint: 'repair all', fn: (a) => repairAll(a) },
461
581
  o: { hint: 'outfits', fn: (a) => a.push(outfitterDialog(a)) },
462
582
  },
583
+ // Drawn as the character sheet (tuiPaperdoll.mjs) when colour is on and the window has room.
584
+ grid: (a, rows, w, h) => paperdollGrid(a, rows, w, h, (cols, lines) => playerArt(a, cols, lines)?.lines ?? null),
463
585
  rows(a) {
464
586
  const rows = [];
465
587
  const ups = upgradeList(a);
466
- if (ups.length) {
467
- rows.push({ text: `${green('▲')} Better gear in your bags (${ups.length}) ${dim('Enter: put on u: put on all')}`, header: true });
588
+ const extras = gearExtraRows(a);
589
+ const sheet = paperdollPlan(a, extras.filter((r) => !r.header).length);
590
+ if (ups.length && !sheet) {
591
+ rows.push({ text: `${green('▲')} Better gear for you (${ups.length}) ${dim('Enter: put on u: put on all')}`, header: true });
468
592
  for (const u of ups) {
469
593
  const it = a.ds.item(u.entry), cur = u.currentEntry != null ? a.ds.item(u.currentEntry) : null;
470
594
  rows.push({
471
- text: ` ${fit(SLOT_LABEL[u.slot] ?? u.slot, 10)} ${qualityPaint(it?.quality)(it?.name ?? u.entry)} ${green(`+${Math.round(u.delta)}`)}${cur ? dim(` replaces ${cur.name}`) : ''}${u.displaces?.length ? yellow(` frees ${u.displaces.map((s) => SLOT_LABEL[s] ?? s).join(', ')}`) : ''}`,
595
+ text: ` ${fit(SLOT_LABEL[u.slot] ?? u.slot, 10)} ${qualityPaint(it?.quality)(it?.name ?? u.entry)} ${green(`+${Math.round(u.delta)}`)}${cur ? dim(` replaces ${cur.name}`) : ''}${u.displaces?.length ? yellow(` frees ${u.displaces.map((s) => SLOT_LABEL[s] ?? s).join(', ')}`) : ''}${u.holderId ? cyan(` ${u.holderName} has it`) : ''}`,
472
596
  data: { upgrade: u },
473
- detail: () => itemLines(a, it, compareLines(a, it, cur)),
474
- act: () => { const r = a.g.equip(u.entry, u.slot); a.result(r, r.ok ? `You equip ${it?.name}.` : ''); },
597
+ detail: () => itemLines(a, it, [...compareLines(a, it, cur), ...(u.holderId ? ['', dim(`In ${u.holderName}'s bags. Enter takes it and puts it on.`)] : [])]),
598
+ act: () => equipFrom(a, u.entry, u.slot, u.holderId),
475
599
  });
476
600
  }
477
601
  rows.push({ text: '', header: true });
478
602
  }
479
- rows.push({ text: `What you wear ${dim('Enter: pick from bags' + (cityAccess(a).bank ? ' and bank' : '') + ' x: take off')}`, header: true });
603
+ if (!sheet) rows.push({ text: `What you wear ${dim('Enter: pick from bags' + (cityAccess(a).bank ? ', bank' : '') + ' and companions x: take off')}`, header: true });
480
604
  for (const slot of [...EQUIP_SLOTS, ...BAG_SLOTS]) {
481
605
  const it = wornItem(a, slot);
482
606
  const up = ups.some((u) => u.slot === slot);
483
607
  const dur = it ? durabilityText(a, slot) : '';
608
+ const kind = it ? [it.typeName, it.armor ? `${it.armor} armor` : '', it.weapon ? `${it.weapon.dmgMin}-${it.weapon.dmgMax}` : ''].filter(Boolean).join(' ') : '';
609
+ // The best piece waiting for this slot, said in the tooltip (the sheet has no upgrade rows).
610
+ const best = ups.filter((u) => u.slot === slot).sort((x, y) => y.delta - x.delta)[0];
611
+ const bestLines = best ? ['', green(`▲ Better: ${a.itemName(best.entry)} (+${Math.round(best.delta)})${best.holderId ? `, ${best.holderName} has it` : ''}`), dim('Enter: pick it u: put on every upgrade')] : [];
484
612
  rows.push({
485
613
  text: `${fit(SLOT_LABEL[slot], 10)} ${it ? qualityPaint(it.quality)(it.name) : dim('—')}${up ? green(' ▲') : ''}${dur ? ` ${dur}` : ''}`, data: { slot, it },
486
- detail: () => (it ? itemLines(a, it, [...enchantLines(a, a.ch, slot), '', ...(dur ? [`Durability ${dur}`] : []), dim('Enter: change x: take off'), ...slotRepairLines(a, slot)]) : [dim(`Nothing in the ${SLOT_LABEL[slot]} slot.`), '', dim('Enter: pick something to wear')]),
614
+ icon: it?.icon, sub: it ? dim(`${fit('', 10)} ${kind}`) : undefined,
615
+ cell: { slot, it, up, dur, sub: dim(kind) },
616
+ detail: () => (it ? itemLines(a, it, [...enchantLines(a, a.ch, slot), '', ...(dur ? [`Durability ${dur}`] : []), dim('Enter: change x: take off'), ...slotRepairLines(a, slot), ...bestLines]) : [bold(SLOT_LABEL[slot]), dim('Nothing worn here.'), '', dim('Enter: pick something to wear'), ...bestLines]),
487
617
  act: () => a.push(slotDialog(a, slot)),
488
618
  });
489
619
  }
490
- rows.push(...gearExtraRows(a));
620
+ rows.push(...(sheet ? extras.filter((r) => !r.header).slice(0, sheet.extras) : extras));
491
621
  return rows;
492
622
  },
493
623
  };
@@ -506,19 +636,18 @@ function slotDialog(app, slot) {
506
636
  const ups = new Map(upgradeList(a).filter((u) => u.slot === slot).map((u) => [u.entry, u]));
507
637
  const rows = [{ text: `${bold(label)}: ${worn ? qualityPaint(worn.quality)(worn.name) : dim('empty')}`, header: true }, { text: '', header: true }];
508
638
  if (worn) rows.push({ text: dim(`Take off ${worn.name}`), act: () => { if (a.result(a.g.unequip(slot), `You take off ${worn.name}.`)) a.pop(); } });
509
- const row = (s, from) => {
639
+ const row = (s, from, comp) => {
510
640
  const it = ds.item(s.item);
511
641
  const up = ups.get(s.item);
512
642
  const low = it.requiredLevel > ch.level;
513
643
  const name = qualityPaint(it.quality)(it.name);
514
644
  return {
515
- text: ` ${low ? dim(name) : name}${s.count > 1 ? dim(` x${s.count}`) : ''}${up ? green(` ▲ +${Math.round(up.delta)}`) : ''}${low ? red(` L${it.requiredLevel}`) : ''}${from === 'bank' ? dim(' in bank') : ''}`,
645
+ text: ` ${low ? dim(name) : name}${s.count > 1 ? dim(` x${s.count}`) : ''}${up ? green(` ▲ +${Math.round(up.delta)}`) : ''}${low ? red(` L${it.requiredLevel}`) : ''}${from === 'bank' ? dim(' in bank') : ''}${comp ? cyan(` ${comp.name}`) : ''}`,
516
646
  data: { s, it },
517
- detail: () => itemLines(a, it, [...compareLines(a, it, worn), '', dim(from === 'bank' ? 'Enter: take it out of the bank and wear it' : 'Enter: wear it')]),
647
+ detail: () => itemLines(a, it, [...compareLines(a, it, worn), '', dim(from === 'bank' ? 'Enter: take it out of the bank and wear it' : comp ? `Enter: take it from ${comp.name} and wear it` : 'Enter: wear it')]),
518
648
  act: () => {
519
649
  if (from === 'bank' && !a.result(a.g.bankWithdraw(s.item, 1), '')) return;
520
- const r = a.g.equip(s.item, slot);
521
- if (a.result(r, r.ok ? `You equip ${it.name}.` : '')) a.pop();
650
+ if (equipFrom(a, s.item, slot, comp?.id)) a.pop();
522
651
  },
523
652
  };
524
653
  };
@@ -532,6 +661,12 @@ function slotDialog(app, slot) {
532
661
  if (!bank.length) rows.push({ text: dim(' Nothing in the bank fits either.'), header: true });
533
662
  for (const s of bank) rows.push(row(s, 'bank'));
534
663
  }
664
+ // Unbound pieces a companion carries (a crafter's product, a BoE they looted) can come straight over.
665
+ const held = ownedCompanions(a.g).flatMap((c) => (c.bags?.stacks ?? []).filter((s) => fits(s) && !stackBoundToHolder(s, ds)).map((s) => ({ s, c })));
666
+ if (held.length) {
667
+ rows.push({ text: '', header: true }, { text: `In your companions' bags (${held.length})`, header: true });
668
+ for (const { s, c } of held) rows.push(row(s, 'companion', c));
669
+ }
535
670
  return rows;
536
671
  },
537
672
  };
@@ -550,6 +685,7 @@ function spellsView(app) {
550
685
  const cd = a.g.spellCooldownRemaining(sp.id);
551
686
  return {
552
687
  text: `${sp.name}${sp.rank ? dim(` (Rank ${sp.rank})`) : ''}${sp.cost ? dim(` ${sp.cost} ${sp.powerType}`) : ''}${cd > 0 ? red(` ${secondsText(cd)}`) : ''}`,
688
+ icon: sp.icon, sub: dim(`${sp.castTimeMs ? `${(sp.castTimeMs / 1000).toFixed(1)}s cast` : sp.channelMs ? 'channeled' : 'instant'}${sp.cooldownMs ? ` ${secondsText(sp.cooldownMs)} cooldown` : ''}`),
553
689
  detail: () => spellLines(a, sp),
554
690
  act: () => castWithPicker(a, sp.id),
555
691
  };
@@ -568,6 +704,11 @@ function rotNames(app) {
568
704
  item: (id) => app.itemName(id),
569
705
  creature: (e) => app.ds.creature(e)?.name,
570
706
  object: (e) => app.ds.gameObject?.(e)?.name,
707
+ seat: (unit) => {
708
+ if (unit === 'player') return app.g.state.char.name || 'You';
709
+ const i = allyIndexOfSide(unit);
710
+ return (i >= 0 ? (app.g.state.squad ?? [])[i]?.name : undefined) ?? unit;
711
+ },
571
712
  };
572
713
  }
573
714
 
@@ -580,6 +721,7 @@ function rotOwner(app) {
580
721
  return {
581
722
  name: 'your', comp: null, get: () => g.rotation(), set: (r) => g.setRotation(r), reset: () => g.resetRotation(),
582
723
  spells: () => knownUsableSpells(app.ch, g.state.spellbook, app.ds), bag: () => g.state.inventory.stacks,
724
+ char: () => app.ch, spellbook: () => g.state.spellbook, equipped: () => app.ch.equipped,
583
725
  profiles: () => g.rotationProfiles(), add: (o) => g.addRotationProfile(o), rename: (pid, n) => g.renameRotationProfile(pid, n),
584
726
  del: (pid) => g.deleteRotationProfile(pid), select: (pid) => g.selectRotationProfile(pid),
585
727
  };
@@ -588,6 +730,7 @@ function rotOwner(app) {
588
730
  return {
589
731
  name: `${comp.name}'s`, comp, get: () => g.companionRotation(comp.id), set: (r) => g.setCompanionRotation(comp.id, r), reset: () => g.resetCompanionRotation(comp.id),
590
732
  spells: () => knownUsableSpells(companionCharacter(comp, lvl, app.ds), companionSpellbook(comp, lvl, app.ds), app.ds), bag: () => comp.bags?.stacks ?? [],
733
+ char: () => companionCharacter(comp, lvl, app.ds), spellbook: () => companionSpellbook(comp, lvl, app.ds), equipped: () => comp.equipped,
591
734
  profiles: () => g.companionRotationProfiles(comp.id), add: (o) => g.addCompanionRotationProfile(comp.id, o), rename: (pid, n) => g.renameCompanionRotationProfile(comp.id, pid, n),
592
735
  del: (pid) => g.deleteCompanionRotationProfile(comp.id, pid), select: (pid) => g.selectCompanionRotationProfile(comp.id, pid),
593
736
  };
@@ -670,9 +813,13 @@ function rotationView(app) {
670
813
  rows.push({ text: '', header: true }, { text: PHASE_LABEL[phase], header: true });
671
814
  rules.forEach((rule, index) => {
672
815
  const conds = conditionsSentence(rule.conditions, names);
673
- const text = `${rule.enabled ? green('✓') : dim('·')} ${rule.enabled ? ruleSentence(rule, names) : dim(ruleSentence(rule, names))}${conds !== 'always' ? dim(` when ${conds}`) : ''}`;
816
+ // With icons a rule is two lines and the condition takes the second; without, it trails the sentence.
817
+ const icon = iconsOn() ? a.ds.spell(rule.action?.spellId ?? rule.before?.spellId)?.icon : undefined;
818
+ const when = conds !== 'always' ? dim(`when ${conds}`) : '';
819
+ const text = `${rule.enabled ? green('✓') : dim('·')} ${rule.enabled ? ruleSentence(rule, names) : dim(ruleSentence(rule, names))}${!icon && when ? ` ${when}` : ''}`;
674
820
  rows.push({
675
821
  text, data: { phase, index, rule },
822
+ icon, sub: when,
676
823
  detail: () => ruleLines(a, rule, names),
677
824
  act: () => ruleMenu(a, { phase, index, rule }),
678
825
  });
@@ -705,7 +852,7 @@ function ruleMenu(app, data) {
705
852
  const names = rotNames(app);
706
853
  const options = [
707
854
  { label: rule.enabled ? 'Turn it off' : green('Turn it on'), detail: [rule.enabled ? 'Auto-play skips a rule that is off.' : 'Auto-play considers this rule again.'], value: 'toggle' },
708
- { label: 'Change when it fires', detail: ['Pick a condition: health thresholds, a missing buff or debuff, the enemy casting, more than one enemy, melee or range.', '', dim(`Now: ${conditionsSentence(rule.conditions, names)}`)], value: 'cond' },
855
+ { label: 'Change when it fires', detail: ['Every condition the game knows: health and power, auras and stacks, casting, crowd control, enemy and ally counts, distances, battleground and raid state. Combine them with and / or.', '', dim(`Now: ${conditionsSentence(rule.conditions, names)}`)], value: 'cond' },
709
856
  { label: 'Move up', detail: ['Earlier rules are tried first.'], value: 'up' },
710
857
  { label: 'Move down', detail: ['Later rules only fire when everything above them is unusable or gated off.'], value: 'down' },
711
858
  { label: 'Add a rule after this one', value: 'add' },
@@ -759,10 +906,20 @@ function addRule(app, phase, afterIndex) {
759
906
  const options = known.map((sp) => ({ label: `${sp.name}${sp.rank ? dim(` (Rank ${sp.rank})`) : ''}`, detail: () => spellLines(app, sp), value: { kind: 'spell', spellId: sp.id } }));
760
907
  const combatItems = [...new Set(o.bag().map((s) => s.item))].map((e) => app.ds.item(e)).filter((it) => it?.useInCombat);
761
908
  for (const it of combatItems) options.push({ label: `Use ${qualityPaint(it.quality)(it.name)}`, detail: () => itemLines(app, it), value: { kind: 'item', itemEntry: it.entry } });
762
- app.pick(`Add to ${PHASE_LABEL[phase].split(' (')[0].toLowerCase()}: what to do`, options, (action) => {
909
+ app.pick(`Add to ${PHASE_LABEL[phase].split(' (')[0].toLowerCase()}: what to do`, options, (picked) => {
763
910
  const id = `tui-${Date.now().toString(36)}-${Math.floor(Math.random() * 1e6).toString(36)}`;
911
+ // A new rule starts with the engine's smart default for what it does: a seal or buff only when it is
912
+ // missing, a DoT when the enemy lacks it, a form when not in it, an interrupt when the enemy casts, a
913
+ // potion below a health band. The same gate the recommended list would give it; c changes it.
914
+ let action = picked, conditions = [];
915
+ if (picked.kind === 'spell') {
916
+ const sp = app.ds.spell(picked.spellId);
917
+ if (sp) conditions = defaultConditionsForSpell(sp, o.char());
918
+ } else if (picked.kind === 'item') {
919
+ ({ action, conditions } = defaultItemRule(app, o, picked.itemEntry));
920
+ }
764
921
  editRotation(app, (rot) => {
765
- const rule = { id, action, conditions: [], enabled: true };
922
+ const rule = { id, action, conditions, enabled: true };
766
923
  const list = rot.phases[phase] ??= [];
767
924
  list.splice(afterIndex == null ? list.length : afterIndex + 1, 0, rule);
768
925
  });
@@ -770,39 +927,14 @@ function addRule(app, phase, afterIndex) {
770
927
  const view = app.tabs[5].view;
771
928
  const at = app.rowsOf(view).findIndex((r) => r.data?.rule?.id === id);
772
929
  if (at >= 0) { view.cursor = at; app.ensureVisible(view, app.rowsOf(view)); }
773
- app.say(green(`Rule added to ${phase}. Press c to set when it fires.`));
930
+ const when = conditions.length ? `only when ${conditionsSentence(conditions, rotNames(app))}` : 'with no conditions';
931
+ app.say(green(`Rule added to ${phase}, ${when}. Enter on it to change when it fires.`));
774
932
  });
775
933
  }
776
934
 
777
- function editCondition(app, { phase, index, rule }) {
778
- const spellRef = rule.action.kind === 'spell' ? rule.action.spellId : undefined;
779
- const spellName = spellRef ? app.ds.spell(spellRef)?.name : 'this spell';
780
- const presets = [
781
- { label: 'Always', value: [] },
782
- { label: 'My health is below …%', value: 'hp', prompt: { metric: 'healthPct', subject: 'self', op: '<' } },
783
- { label: 'My mana/power is above …%', value: 'pw', prompt: { metric: 'powerPct', subject: 'self', op: '>' } },
784
- { label: "The enemy's health is below …%", value: 'ehp', prompt: { metric: 'healthPct', subject: 'target', op: '<' } },
785
- { label: "The enemy's health is above …%", value: 'ehp2', prompt: { metric: 'healthPct', subject: 'target', op: '>' } },
786
- ...(spellRef ? [
787
- { label: `The enemy does not have ${spellName}`, value: [{ subject: 'target', metric: 'debuffPresent', op: '==', value: 0, ref: spellRef }] },
788
- { label: `I do not have ${spellName}`, value: [{ subject: 'self', metric: 'buffPresent', op: '==', value: 0, ref: spellRef }] },
789
- { label: `${spellName} has under 3 s left on the enemy`, value: [{ subject: 'target', metric: 'debuffRemainingMs', op: '<', value: 3000, ref: spellRef }] },
790
- ] : []),
791
- { label: 'The enemy is casting', value: [{ subject: 'target', metric: 'castingPresent', op: '==', value: 1 }] },
792
- { label: 'More than one living enemy', value: [{ subject: 'self', metric: 'enemyCount', op: '>', value: 1 }] },
793
- { label: 'I am in melee range', value: [{ subject: 'self', metric: 'inMelee', op: '==', value: 1 }] },
794
- { label: 'I am at range', value: [{ subject: 'self', metric: 'inMelee', op: '==', value: 0 }] },
795
- ];
796
- const apply = (conds) => editRotation(app, (rot) => { rot.phases[phase][index].conditions = conds; });
797
- app.pick(`${spellName}: fire only when…`, presets.map((p) => ({ label: p.label, value: p })), (p) => {
798
- if (Array.isArray(p.value)) { apply(p.value); return; }
799
- app.prompt('Percent: ', (txt) => {
800
- const n = Number(txt);
801
- if (!(n >= 0 && n <= 100)) { app.flashMsg('Enter a number from 0 to 100.'); return; }
802
- apply([{ ...p.prompt, value: n }]);
803
- app.afterAction();
804
- });
805
- });
935
+ /** Enter on a rule, "Change when it fires": the full condition editor (tuiRotationCond.mjs). */
936
+ function editCondition(app, { phase, index }) {
937
+ conditionEditor(app, { phase, index, owner: rotOwner(app), names: rotNames(app), mutate: (fn) => editRotation(app, fn) });
806
938
  }
807
939
 
808
940
  function pickSpec(app) {
@@ -816,6 +948,9 @@ function pickSpec(app) {
816
948
  function talentsView(app) {
817
949
  return {
818
950
  id: 'talents', title: 'Talents', hint: 'Enter: spend a point',
951
+ // Drawn as the three trees (tuiTalentTree.mjs) when colour is on and they fit; a narrower tooltip makes room.
952
+ detailShare: 0.34,
953
+ grid: (a, rows, w, h) => talentGrid(a, rows, w, h),
819
954
  keys: {
820
955
  u: { hint: 'refund', fn: (a, row) => row?.data && a.result(a.g.refundTalent(row.data.id), `Refunded a point from ${row.data.name}.`) },
821
956
  R: { hint: 'respec', fn: (a) => { const cost = a.g.respecCost(); a.confirm(`Reset all talents for ${moneyText(cost) || 'free'}?`, () => a.result(a.g.respec(), 'Your talents are reset.')); } },
@@ -824,6 +959,16 @@ function talentsView(app) {
824
959
  rows(a) {
825
960
  const ch = a.ch;
826
961
  const free = availablePoints(ch);
962
+ if (talentPlan(a)) {
963
+ return a.ds.talentTrees(ch.cls).flatMap((tree, k) => [...tree.talents].sort((x, y) => x.tier - y.tier || x.col - y.col).map((t) => {
964
+ const rank = ch.talents?.[t.id] ?? 0;
965
+ return {
966
+ text: t.name, data: t, node: { tree: k, t, rank, tone: talentTone(ch, t, a.ds), icon: talentIcon(a.ds, t) },
967
+ detail: () => talentLines(a, t, rank, canSpend(ch, t.id, a.ds)),
968
+ act: () => a.result(a.g.spendTalent(t.id), `You learn ${t.name} (${(ch.talents?.[t.id] ?? 0) + 1}/${t.maxRank}).`),
969
+ };
970
+ }));
971
+ }
827
972
  const rows = [{ text: ch.level < 10 ? dim('Talents unlock at level 10.') : `${plural(free, 'point')} to spend`, header: true }];
828
973
  if (ch.level >= 10) rows.push({ text: `Builds ${dim(`premade specs, saved loadouts, talent strings ${myTalentLink(a) || 'nothing spent yet'}`)}`, detail: () => [bold('Talent builds'), '', 'Switch to a premade spec, a build you saved, or a pasted talent string. Spent points cost a respec.', '', dim('Enter (or B): open')], act: () => talentBuildsMenu(a) });
829
974
  for (const tree of a.ds.talentTrees(ch.cls)) {
@@ -835,6 +980,7 @@ function talentsView(app) {
835
980
  const paint = rank >= t.maxRank ? brightYellow : can.ok ? green : dim;
836
981
  rows.push({
837
982
  text: `${paint(t.name)} ${dim(`${rank}/${t.maxRank}`)}${t.prereq ? dim(` needs ${a.ds.talent(t.prereq.talentId)?.name ?? '?'}`) : ''}`, data: t,
983
+ icon: talentIcon(a.ds, t), sub: dim(`tier ${t.tier + 1}`),
838
984
  detail: () => talentLines(a, t, rank, can),
839
985
  act: () => a.result(a.g.spendTalent(t.id), `You learn ${t.name} (${(ch.talents?.[t.id] ?? 0) + 1}/${t.maxRank}).`),
840
986
  });
@@ -846,9 +992,14 @@ function talentsView(app) {
846
992
  }
847
993
 
848
994
  function talentLines(app, t, rank, can) {
995
+ return withIcon(talentIcon(app.ds, t), talentLinesText(app, t, rank, can));
996
+ }
997
+
998
+ function talentLinesText(app, t, rank, can) {
849
999
  const out = [bold(t.name) + dim(` tier ${t.tier + 1}, rank ${rank}/${t.maxRank}`)];
850
- if (rank > 0) out.push('', cyan('Current rank'), ...wrapLines(t.ranks[rank - 1]?.description ?? '', 60));
851
- if (rank < t.maxRank) out.push('', cyan(rank ? 'Next rank' : 'Rank 1'), ...wrapLines(t.ranks[rank]?.description ?? '', 60));
1000
+ // The pane wraps the descriptions to its own width (beside the icon while they share its rows).
1001
+ if (rank > 0) out.push('', cyan('Current rank'), t.ranks[rank - 1]?.description ?? '');
1002
+ if (rank < t.maxRank) out.push('', cyan(rank ? 'Next rank' : 'Rank 1'), t.ranks[rank]?.description ?? '');
852
1003
  out.push('', can.ok ? green('Enter to spend a point') : red(can.reason ?? 'Cannot spend here yet'));
853
1004
  if (rank > 0 && canRefund(app.ch, t.id, app.ds).ok) out.push(dim('u refunds a point'));
854
1005
  return out;
@@ -861,7 +1012,7 @@ function travelView(app) {
861
1012
  id: 'travel', title: 'Travel', hint: 'Enter: go',
862
1013
  rows(a) {
863
1014
  const g = a.g, ds = a.ds, z = a.zone(), ch = a.ch;
864
- const rows = [{ text: `You are in ${cyan(z?.name ?? ch.zoneId)}`, header: true }, { text: '', header: true }, { text: 'On foot', header: true }];
1015
+ const rows = [...journeyRows(a), { text: `You are in ${cyan(z?.name ?? ch.zoneId)}`, header: true }, { text: '', header: true }, { text: 'On foot', header: true }];
865
1016
  for (const id of z?.neighbors ?? []) { const d = ds.zone(id); if (d) rows.push({ text: `${d.name} ${dim(`L${d.levelRange[0]}-${d.levelRange[1]}`)}`, detail: () => travelPreviewLines(a, d), act: () => travelTo(a, d) }); }
866
1017
  const taxi = g.taxiOptions();
867
1018
  if (taxi.length) {
@@ -874,12 +1025,12 @@ function travelView(app) {
874
1025
  rows.push({
875
1026
  text: bind ? `Hearth to ${ds.zone(bind.zone)?.name ?? bind.zone}${bind.name ? dim(` (${bind.name})`) : ''}${cd > 0 ? red(` ${secondsText(cd)}`) : ''}` : dim('No home set: speak with an innkeeper.'),
876
1027
  detail: () => [bold('Hearthstone'), bind ? `Bound at ${bind.name ?? ds.zone(bind.zone)?.name}` : 'Not bound', cd > 0 ? red(`Ready in ${secondsText(cd)}`) : green('Ready')],
877
- act: () => { const r = g.castHearth(); if (a.result(r)) { a.vendor = null; afterDeparture(a, () => `You hearth to ${a.zone()?.name}.`); a.switchTab(0); } },
1028
+ act: () => { const r = g.castHearth(); if (a.result(r)) { a.vendor = null; afterDeparture(a, () => `You hearth to ${a.zone()?.name}.`); landOnTab(a); } },
878
1029
  });
879
1030
  const ports = g.classTravelOptions();
880
1031
  if (ports.length) {
881
1032
  rows.push({ text: '', header: true }, { text: 'Teleports', header: true });
882
- for (const p of ports) rows.push({ text: `${p.name} → ${ds.zone(p.toZone)?.name ?? p.toZone}${p.reagents.some((r) => r.have < r.count) ? red(' no reagent') : ''}`, act: () => { const r = g.castTeleport(p.spellId); if (a.result(r)) { afterDeparture(a, () => `You teleport to ${a.zone()?.name}.`); a.switchTab(0); } } });
1033
+ for (const p of ports) rows.push({ text: `${p.name} → ${ds.zone(p.toZone)?.name ?? p.toZone}${p.reagents.some((r) => r.have < r.count) ? red(' no reagent') : ''}`, act: () => { const r = g.castTeleport(p.spellId); if (a.result(r)) { afterDeparture(a, () => `You teleport to ${a.zone()?.name}.`); landOnTab(a); } } });
883
1034
  }
884
1035
  rows.push(...portalRows(a));
885
1036
  return rows;
@@ -914,9 +1065,32 @@ function weaponSkillRows(a, L) {
914
1065
  return rows;
915
1066
  }
916
1067
 
1068
+ /**
1069
+ * Your character as a sprite for a pane: the form or costume you wear when the creature pack has it, else
1070
+ * you in your gear (paperdoll.mjs), else your bare race and gender body. `label` names the form when that
1071
+ * is what shows; null without art.
1072
+ */
1073
+ export function playerArt(app, maxCols, maxRows) {
1074
+ const ch = app.ch;
1075
+ const worn = wornDisplay(ch, app.ds, Date.now());
1076
+ const wornArt = worn?.displayId ? creatureArt(worn.displayId, maxCols, maxRows) : null;
1077
+ if (wornArt) return { lines: wornArt, label: worn.label };
1078
+ const geared = paperdollArt(ch, maxCols, maxRows);
1079
+ if (geared) return { lines: geared, label: null };
1080
+ const own = creatureArt(nativeDisplayId(ch.race, ch.gender), maxCols, maxRows);
1081
+ return own ? { lines: own, label: null } : null;
1082
+ }
1083
+
917
1084
  function charView(app) {
918
1085
  return {
919
- id: 'char', title: 'Character', hint: '', noDetail: true,
1086
+ id: 'char', title: 'Character', hint: '',
1087
+ /** The detail pane holds your character: the body of your race and gender, or the form you are in. */
1088
+ detail(a) {
1089
+ const L = a.layout(), ch = a.ch;
1090
+ const art = playerArt(a, L.detailW - 2, L.mainH - 2);
1091
+ if (!art) return [];
1092
+ return [`${bold(ch.name)} ${dim(`${raceName(ch.race)} ${className(ch.cls)}${art.label ? `, ${art.label}` : ''}`)}`, '', ...art.lines];
1093
+ },
920
1094
  rows(a) {
921
1095
  const ch = a.ch, ds = a.ds;
922
1096
  const st = effectiveStats(ch, ds);
@@ -1057,6 +1231,7 @@ export function vendorDialog(app, vendor) {
1057
1231
  const usedBy = uses.get(o.itemEntry);
1058
1232
  rows.push({
1059
1233
  text: `${ok ? name : dim(name)} ${yellow(price)}${o.stockRemaining != null ? dim(` ${o.stockRemaining} left`) : ''}${it?.requiredLevel > a.ch.level ? red(` L${it.requiredLevel}`) : ''}${usedBy ? cyan(' reagent') : ''}${why}`, data: o,
1234
+ icon: it?.icon, sub: dim([it?.typeName, it?.slot].filter(Boolean).join(' · ')),
1060
1235
  detail: () => itemLines(a, it, ['', `Price: ${yellow(price)}`, ...(o.basePricePerPurchase !== o.pricePerPurchase ? [dim(`(${moneyText(o.basePricePerPurchase)} before reputation)`)] : []),
1061
1236
  ...(usedBy ? ['', cyan('Your party crafts with it'), ...usedBy.slice(0, 6).map((n) => ` ${n}`), ...(usedBy.length > 6 ? [dim(` and ${usedBy.length - 6} more`)] : []), dim('Crafting buys it for you when you run short.')] : [])]),
1062
1237
  act: () => buy(a, o, 1),
@@ -1072,6 +1247,7 @@ export function vendorDialog(app, vendor) {
1072
1247
  const total = (it?.sellPrice ?? 0) * s.count;
1073
1248
  rows.push({
1074
1249
  text: `${qualityPaint(it?.quality)(it?.name ?? s.item)}${s.count > 1 ? ` x${s.count}` : ''} ${total ? yellow(moneyText(total)) : dim('no value')}`, data: s,
1250
+ icon: it?.icon, sub: dim(it?.typeName ?? ''),
1075
1251
  detail: () => itemLines(a, it, ['', dim('Enter: sell the stack (you can buy it back on the Buyback tab) d: destroy')]),
1076
1252
  act: () => sellStack(a, slot),
1077
1253
  });
@@ -1118,8 +1294,10 @@ function buy(app, offer, n) {
1118
1294
 
1119
1295
  export function trainerDialog(app, trainer) {
1120
1296
  const known = () => new Set(app.g.state.spellbook.known);
1297
+ // Weapon masters list every class's weapons; hide the rows the class/race gate would refuse.
1298
+ const fits = (ts) => learnGrantIds(ts.spell, app.ds).every((g) => isSpellFitByClassAndRace(g, app.ch.race, app.ch.cls, app.ds.combatSkillMeta(), Math.max(app.ch.level, ts.reqLevel)));
1121
1299
  const learnable = () => trainer.spells
1122
- .filter((ts) => ts.reqLevel <= app.ch.level && !known().has(ts.spell))
1300
+ .filter((ts) => ts.reqLevel <= app.ch.level && !known().has(ts.spell) && fits(ts))
1123
1301
  .map((ts) => ({ ts, sp: app.ds.spell(ts.spell) })).filter((x) => x.sp)
1124
1302
  .sort((x, y) => x.ts.reqLevel - y.ts.reqLevel || x.sp.name.localeCompare(y.sp.name));
1125
1303
  return {
@@ -1133,11 +1311,12 @@ export function trainerDialog(app, trainer) {
1133
1311
  const can = ts.cost <= a.ch.gold;
1134
1312
  rows.push({
1135
1313
  text: `${can ? sp.name : dim(sp.name)}${sp.rank ? dim(` (Rank ${sp.rank})`) : ''} ${yellow(moneyText(ts.cost) || 'free')}`, data: sp,
1314
+ icon: sp.icon, sub: dim(`level ${ts.reqLevel}${sp.cost ? ` ${sp.cost} ${sp.powerType}` : ''}`),
1136
1315
  detail: () => spellLines(a, sp, ['', `Cost: ${yellow(moneyText(ts.cost) || 'free')}`]),
1137
1316
  act: () => a.result(a.g.learnFromTrainer(trainer.entry, sp.id), `You have learned ${sp.name}${sp.rank ? ` (Rank ${sp.rank})` : ''}.`),
1138
1317
  });
1139
1318
  }
1140
- const later = trainer.spells.filter((ts) => ts.reqLevel > a.ch.level && !known().has(ts.spell)).map((ts) => ({ ts, sp: a.ds.spell(ts.spell) })).filter((x) => x.sp).sort((x, y) => x.ts.reqLevel - y.ts.reqLevel).slice(0, 12);
1319
+ const later = trainer.spells.filter((ts) => ts.reqLevel > a.ch.level && !known().has(ts.spell) && fits(ts)).map((ts) => ({ ts, sp: a.ds.spell(ts.spell) })).filter((x) => x.sp).sort((x, y) => x.ts.reqLevel - y.ts.reqLevel).slice(0, 12);
1141
1320
  if (later.length) {
1142
1321
  rows.push({ text: '', header: true }, { text: 'Coming up', header: true });
1143
1322
  for (const { sp, ts } of later) rows.push({ text: dim(`${sp.name}${sp.rank ? ` (Rank ${sp.rank})` : ''} level ${ts.reqLevel} ${moneyText(ts.cost)}`), header: true });
@@ -1151,11 +1330,13 @@ export function innDialog(app, inn) {
1151
1330
  return {
1152
1331
  id: `inn-${inn.entry}`, title: inn.name, hint: 'Enter: choose', noDetail: true,
1153
1332
  rows(a) {
1154
- const bound = a.ch.hearthBind?.name === inn.name;
1333
+ const bound = a.ch.hearthBind?.name === inn.name, wares = a.ds.vendor(inn.entry);
1155
1334
  return [
1156
- { text: `${bold(inn.name)} ${dim('<Innkeeper>')}`, header: true }, { text: '', header: true },
1335
+ { text: `${bold(inn.name)} ${dim('<Innkeeper>')}`, header: true },
1336
+ // Rested xp needs no button: it builds here in game, and at the full offline rate after logging out here.
1337
+ { text: dim(`${a.ch.restedXp ? `You are rested (${a.ch.restedXp} xp).` : 'You are not rested.'} Staying here builds rest, logging out here builds it faster.`), header: true }, { text: '', header: true },
1338
+ ...(wares ? [{ text: 'Browse wares', act: () => a.push(vendorDialog(a, wares)) }] : []),
1157
1339
  { text: bound ? dim('This inn is already your home.') : 'Make this inn your home', act: () => { if (!bound) a.result(a.g.bindHearth(), `${inn.name} sets your hearthstone to ${a.zone()?.name}.`); } },
1158
- { text: 'Rest here for a while (60 s)', act: () => { a.idleTick(60, true); a.say(dim(`You rest at the inn. HP ${a.hpText()} ${a.pwText()}`)); } },
1159
1340
  { text: dim('Goodbye'), act: () => a.pop() },
1160
1341
  ];
1161
1342
  },