azeroth-shell 0.1.2 → 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 (63) hide show
  1. package/README.md +326 -82
  2. package/bin/azsh.mjs +21 -19
  3. package/package.json +9 -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 +135 -0
  10. package/src/cli/iconGrid.mjs +100 -0
  11. package/src/cli/modelArt.mjs +295 -0
  12. package/src/cli/paperdoll.mjs +76 -0
  13. package/src/cli/perfCheck.mjs +56 -0
  14. package/src/cli/repl.mjs +32 -7
  15. package/src/cli/rotationText.mjs +6 -6
  16. package/src/cli/sixel.mjs +307 -0
  17. package/src/cli/term.mjs +106 -12
  18. package/src/cli/text.mjs +5 -3
  19. package/src/cli/tui.mjs +231 -95
  20. package/src/cli/tuiAutopilot.mjs +347 -0
  21. package/src/cli/tuiBg.mjs +370 -41
  22. package/src/cli/tuiCheck.mjs +362 -27
  23. package/src/cli/tuiCity.mjs +118 -13
  24. package/src/cli/tuiCompanions.mjs +25 -15
  25. package/src/cli/tuiCreate.mjs +223 -0
  26. package/src/cli/tuiDungeon.mjs +1 -1
  27. package/src/cli/tuiFight.mjs +423 -86
  28. package/src/cli/tuiFightPanes.mjs +27 -8
  29. package/src/cli/tuiFloor.mjs +1 -1
  30. package/src/cli/tuiGear.mjs +10 -6
  31. package/src/cli/tuiLoot.mjs +1 -0
  32. package/src/cli/tuiPaperdoll.mjs +107 -0
  33. package/src/cli/tuiPartyStage.mjs +177 -0
  34. package/src/cli/tuiProfessions.mjs +4 -2
  35. package/src/cli/tuiRotationCond.mjs +390 -0
  36. package/src/cli/tuiSaves.mjs +10 -1
  37. package/src/cli/tuiSearch.mjs +75 -22
  38. package/src/cli/tuiStage.mjs +304 -0
  39. package/src/cli/tuiTalentTree.mjs +124 -0
  40. package/src/cli/tuiViews.mjs +284 -110
  41. package/src/cli/tuiWorld.mjs +2 -2
  42. package/src/data/gear-art.bin.br +0 -0
  43. package/src/data/hd-art.json +114 -0
  44. package/src/data/icon-art.bin.gz +0 -0
  45. package/src/data/model-art.bin.gz +0 -0
  46. package/src/data/rotations/pve/paladin/retribution.json +2 -2
  47. package/src/domain/bg/match.ts +85 -24
  48. package/src/domain/bg/strategies.ts +144 -29
  49. package/src/domain/bg/types.ts +2 -0
  50. package/src/domain/bg/wsg.ts +35 -20
  51. package/src/domain/quests/doQuest.ts +24 -6
  52. package/src/domain/rotation/build.ts +3031 -3031
  53. package/src/domain/rotation/classes/paladin.ts +16 -11
  54. package/src/domain/rotation/evaluate.ts +3 -0
  55. package/src/engine/combat/applySpell.ts +20 -9
  56. package/src/engine/combat/applySpellLate.ts +16 -12
  57. package/src/engine/combat/areaAura.ts +284 -283
  58. package/src/engine/combat/fighterFactory.ts +1 -1
  59. package/src/engine/combat/pickAction.ts +39 -1
  60. package/src/engine/combat/procsDamage.ts +31 -1
  61. package/src/engine/combat/sessionChase.ts +7 -1
  62. package/src/engine/combat/sessionMotionKit.ts +40 -2
  63. 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
  },
@@ -270,9 +336,23 @@ export function landOnTab(app) {
270
336
  const v = app.tabs[i]?.view; if (v) { v.cursor = 0; v.scroll = 0; }
271
337
  }
272
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
+ });
351
+ }
352
+
273
353
  function questsView(app) {
274
354
  return {
275
- 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.',
276
356
  keys: {
277
357
  d: { hint: 'do quest', fn: (a, row) => { if (row?.data) doQuest(a, row.data.q); } },
278
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}.`)); } },
@@ -285,8 +365,14 @@ function questsView(app) {
285
365
  rows.push({ text: `${log.length} of 20 quests`, header: true });
286
366
  for (const x of log) {
287
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)') : '';
288
- 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')) });
289
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 [];
290
376
  return rows;
291
377
  },
292
378
  };
@@ -299,14 +385,19 @@ export function questDialog(app, q, mode) {
299
385
  rows(a) {
300
386
  const W = a.layout().listW - 4;
301
387
  const ch = a.ch, s = qs();
302
- const rows = [{ text: `${bold(q.title)} ${dim(`L${q.questLevel}`)}`, header: true }, { text: '', header: true }];
303
- const text = (t) => wrapLines(questText(t, ch), W).map((l) => ({ text: l, header: true }));
304
- if (mode === 'offer') rows.push(...text(q.detailsText), { text: '', header: true }, { text: 'Objectives', header: true }, ...text(q.objectivesText));
305
- else if (s?.status === 'complete') rows.push(...text(q.offerRewardText || q.endText || q.objectivesText));
306
- else rows.push(...text(q.objectivesText), { text: '', header: true }, ...(q.requestItemsText ? text(q.requestItemsText) : []));
307
- rows.push({ text: '', header: true });
308
- for (const o of objectiveLines(a, q, s)) rows.push({ text: ` ${o}`, header: true });
309
- 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(''));
310
401
  if (mode === 'offer') {
311
402
  rows.push({ text: green('Accept the quest'), act: () => { if (a.result(a.g.acceptQuest(q.entry), `Quest accepted: ${q.title}`)) a.pop(); } });
312
403
  rows.push({ text: dim('Decline'), act: () => a.pop() });
@@ -315,7 +406,7 @@ export function questDialog(app, q, mode) {
315
406
  // A repeatable with a fixed reward hands in every round the bags pay for in one go.
316
407
  const rounds = s?.status === 'complete' && isRepeatableQuest(q) && questRewardChoices(q).length <= 1 ? questRepeatsAvailable(q, a.g.state.inventory, a.ch.zoneId) : 0;
317
408
  if (rounds >= 2) rows.push({ text: green(`Hand it in ${rounds} times`), act: () => repeatTurnIn(a, q) });
318
- else rows.push({ text: cyan('Do the quest'), act: () => doQuest(a, q) });
409
+ else rows.push({ text: 'Do the quest', act: () => doQuest(a, q) });
319
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__'); }) });
320
411
  rows.push({ text: dim('Close'), act: () => a.pop() });
321
412
  }
@@ -331,7 +422,7 @@ function doQuest(app, q) {
331
422
  const s = app.g.state.questlog.quests.find((x) => x.entry === q.entry);
332
423
  if (s?.status === 'complete' && q.rewChoiceItems?.length) { turnIn(app, q); return; }
333
424
  app.stack = [];
334
- startAutopilot(app, 500, undefined, q.entry);
425
+ startAutopilot(app, Infinity, undefined, q.entry);
335
426
  }
336
427
 
337
428
  function repeatTurnIn(app, q) {
@@ -386,7 +477,7 @@ function bagsView(app) {
386
477
  const slotOf = (a, row) => (row?.data && !('keyring' in row.data) ? a.g.state.inventory.stacks.indexOf(row.data) : -1);
387
478
  const onStack = (fn) => (a, row) => { const i = slotOf(a, row); if (i >= 0) fn(a, i); };
388
479
  return {
389
- 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.',
390
481
  keys: {
391
482
  m: { hint: 'item menu', fn: onStack(itemMenu) },
392
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); } },
@@ -414,6 +505,7 @@ function bagsView(app) {
414
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') : '';
415
506
  rows.push({
416
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)}` : ''}`),
417
509
  detail: () => itemLines(a, it, [
418
510
  '', dim(`Enter: ${verb ? verb.toLowerCase() : 'more'} m: more${s.count > 1 ? ' /: split' : ''} [ ]: move d: destroy`),
419
511
  ...(a.vendor ? [dim(`s: sell to ${a.vendor.name}`)] : []),
@@ -433,9 +525,19 @@ function wornItem(app, slot) {
433
525
  return entry == null ? null : app.ds.item(entry) ?? null;
434
526
  }
435
527
 
436
- /** 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. */
437
529
  function upgradeList(app) {
438
- 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}.` : '');
439
541
  }
440
542
 
441
543
  /** Lines comparing a candidate with the item worn in `slot`: armor, damage and stat deltas. */
@@ -459,42 +561,63 @@ function gearView(app) {
459
561
  id: 'gear', title: 'Equipment', hint: 'Enter: change what you wear',
460
562
  keys: {
461
563
  u: { hint: 'equip all upgrades', fn: (a) => {
462
- if (!upgradeList(a).length) { a.flashMsg('Nothing in your bags beats what you wear.'); return; }
463
- 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
+ }
464
576
  a.say(n ? green(`You put on ${plural(n, 'upgrade')}.`) : red('The upgrades could not be equipped.'));
465
577
  } },
466
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}.`); } },
467
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) },
468
581
  o: { hint: 'outfits', fn: (a) => a.push(outfitterDialog(a)) },
469
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),
470
585
  rows(a) {
471
586
  const rows = [];
472
587
  const ups = upgradeList(a);
473
- if (ups.length) {
474
- 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 });
475
592
  for (const u of ups) {
476
593
  const it = a.ds.item(u.entry), cur = u.currentEntry != null ? a.ds.item(u.currentEntry) : null;
477
594
  rows.push({
478
- 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`) : ''}`,
479
596
  data: { upgrade: u },
480
- detail: () => itemLines(a, it, compareLines(a, it, cur)),
481
- 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),
482
599
  });
483
600
  }
484
601
  rows.push({ text: '', header: true });
485
602
  }
486
- 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 });
487
604
  for (const slot of [...EQUIP_SLOTS, ...BAG_SLOTS]) {
488
605
  const it = wornItem(a, slot);
489
606
  const up = ups.some((u) => u.slot === slot);
490
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')] : [];
491
612
  rows.push({
492
613
  text: `${fit(SLOT_LABEL[slot], 10)} ${it ? qualityPaint(it.quality)(it.name) : dim('—')}${up ? green(' ▲') : ''}${dur ? ` ${dur}` : ''}`, data: { slot, it },
493
- 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]),
494
617
  act: () => a.push(slotDialog(a, slot)),
495
618
  });
496
619
  }
497
- rows.push(...gearExtraRows(a));
620
+ rows.push(...(sheet ? extras.filter((r) => !r.header).slice(0, sheet.extras) : extras));
498
621
  return rows;
499
622
  },
500
623
  };
@@ -513,19 +636,18 @@ function slotDialog(app, slot) {
513
636
  const ups = new Map(upgradeList(a).filter((u) => u.slot === slot).map((u) => [u.entry, u]));
514
637
  const rows = [{ text: `${bold(label)}: ${worn ? qualityPaint(worn.quality)(worn.name) : dim('empty')}`, header: true }, { text: '', header: true }];
515
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(); } });
516
- const row = (s, from) => {
639
+ const row = (s, from, comp) => {
517
640
  const it = ds.item(s.item);
518
641
  const up = ups.get(s.item);
519
642
  const low = it.requiredLevel > ch.level;
520
643
  const name = qualityPaint(it.quality)(it.name);
521
644
  return {
522
- 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}`) : ''}`,
523
646
  data: { s, it },
524
- 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')]),
525
648
  act: () => {
526
649
  if (from === 'bank' && !a.result(a.g.bankWithdraw(s.item, 1), '')) return;
527
- const r = a.g.equip(s.item, slot);
528
- if (a.result(r, r.ok ? `You equip ${it.name}.` : '')) a.pop();
650
+ if (equipFrom(a, s.item, slot, comp?.id)) a.pop();
529
651
  },
530
652
  };
531
653
  };
@@ -539,6 +661,12 @@ function slotDialog(app, slot) {
539
661
  if (!bank.length) rows.push({ text: dim(' Nothing in the bank fits either.'), header: true });
540
662
  for (const s of bank) rows.push(row(s, 'bank'));
541
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
+ }
542
670
  return rows;
543
671
  },
544
672
  };
@@ -557,6 +685,7 @@ function spellsView(app) {
557
685
  const cd = a.g.spellCooldownRemaining(sp.id);
558
686
  return {
559
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` : ''}`),
560
689
  detail: () => spellLines(a, sp),
561
690
  act: () => castWithPicker(a, sp.id),
562
691
  };
@@ -575,6 +704,11 @@ function rotNames(app) {
575
704
  item: (id) => app.itemName(id),
576
705
  creature: (e) => app.ds.creature(e)?.name,
577
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
+ },
578
712
  };
579
713
  }
580
714
 
@@ -587,6 +721,7 @@ function rotOwner(app) {
587
721
  return {
588
722
  name: 'your', comp: null, get: () => g.rotation(), set: (r) => g.setRotation(r), reset: () => g.resetRotation(),
589
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,
590
725
  profiles: () => g.rotationProfiles(), add: (o) => g.addRotationProfile(o), rename: (pid, n) => g.renameRotationProfile(pid, n),
591
726
  del: (pid) => g.deleteRotationProfile(pid), select: (pid) => g.selectRotationProfile(pid),
592
727
  };
@@ -595,6 +730,7 @@ function rotOwner(app) {
595
730
  return {
596
731
  name: `${comp.name}'s`, comp, get: () => g.companionRotation(comp.id), set: (r) => g.setCompanionRotation(comp.id, r), reset: () => g.resetCompanionRotation(comp.id),
597
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,
598
734
  profiles: () => g.companionRotationProfiles(comp.id), add: (o) => g.addCompanionRotationProfile(comp.id, o), rename: (pid, n) => g.renameCompanionRotationProfile(comp.id, pid, n),
599
735
  del: (pid) => g.deleteCompanionRotationProfile(comp.id, pid), select: (pid) => g.selectCompanionRotationProfile(comp.id, pid),
600
736
  };
@@ -677,9 +813,13 @@ function rotationView(app) {
677
813
  rows.push({ text: '', header: true }, { text: PHASE_LABEL[phase], header: true });
678
814
  rules.forEach((rule, index) => {
679
815
  const conds = conditionsSentence(rule.conditions, names);
680
- 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}` : ''}`;
681
820
  rows.push({
682
821
  text, data: { phase, index, rule },
822
+ icon, sub: when,
683
823
  detail: () => ruleLines(a, rule, names),
684
824
  act: () => ruleMenu(a, { phase, index, rule }),
685
825
  });
@@ -712,7 +852,7 @@ function ruleMenu(app, data) {
712
852
  const names = rotNames(app);
713
853
  const options = [
714
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' },
715
- { 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' },
716
856
  { label: 'Move up', detail: ['Earlier rules are tried first.'], value: 'up' },
717
857
  { label: 'Move down', detail: ['Later rules only fire when everything above them is unusable or gated off.'], value: 'down' },
718
858
  { label: 'Add a rule after this one', value: 'add' },
@@ -766,10 +906,20 @@ function addRule(app, phase, afterIndex) {
766
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 } }));
767
907
  const combatItems = [...new Set(o.bag().map((s) => s.item))].map((e) => app.ds.item(e)).filter((it) => it?.useInCombat);
768
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 } });
769
- 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) => {
770
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
+ }
771
921
  editRotation(app, (rot) => {
772
- const rule = { id, action, conditions: [], enabled: true };
922
+ const rule = { id, action, conditions, enabled: true };
773
923
  const list = rot.phases[phase] ??= [];
774
924
  list.splice(afterIndex == null ? list.length : afterIndex + 1, 0, rule);
775
925
  });
@@ -777,39 +927,14 @@ function addRule(app, phase, afterIndex) {
777
927
  const view = app.tabs[5].view;
778
928
  const at = app.rowsOf(view).findIndex((r) => r.data?.rule?.id === id);
779
929
  if (at >= 0) { view.cursor = at; app.ensureVisible(view, app.rowsOf(view)); }
780
- 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.`));
781
932
  });
782
933
  }
783
934
 
784
- function editCondition(app, { phase, index, rule }) {
785
- const spellRef = rule.action.kind === 'spell' ? rule.action.spellId : undefined;
786
- const spellName = spellRef ? app.ds.spell(spellRef)?.name : 'this spell';
787
- const presets = [
788
- { label: 'Always', value: [] },
789
- { label: 'My health is below …%', value: 'hp', prompt: { metric: 'healthPct', subject: 'self', op: '<' } },
790
- { label: 'My mana/power is above …%', value: 'pw', prompt: { metric: 'powerPct', subject: 'self', op: '>' } },
791
- { label: "The enemy's health is below …%", value: 'ehp', prompt: { metric: 'healthPct', subject: 'target', op: '<' } },
792
- { label: "The enemy's health is above …%", value: 'ehp2', prompt: { metric: 'healthPct', subject: 'target', op: '>' } },
793
- ...(spellRef ? [
794
- { label: `The enemy does not have ${spellName}`, value: [{ subject: 'target', metric: 'debuffPresent', op: '==', value: 0, ref: spellRef }] },
795
- { label: `I do not have ${spellName}`, value: [{ subject: 'self', metric: 'buffPresent', op: '==', value: 0, ref: spellRef }] },
796
- { label: `${spellName} has under 3 s left on the enemy`, value: [{ subject: 'target', metric: 'debuffRemainingMs', op: '<', value: 3000, ref: spellRef }] },
797
- ] : []),
798
- { label: 'The enemy is casting', value: [{ subject: 'target', metric: 'castingPresent', op: '==', value: 1 }] },
799
- { label: 'More than one living enemy', value: [{ subject: 'self', metric: 'enemyCount', op: '>', value: 1 }] },
800
- { label: 'I am in melee range', value: [{ subject: 'self', metric: 'inMelee', op: '==', value: 1 }] },
801
- { label: 'I am at range', value: [{ subject: 'self', metric: 'inMelee', op: '==', value: 0 }] },
802
- ];
803
- const apply = (conds) => editRotation(app, (rot) => { rot.phases[phase][index].conditions = conds; });
804
- app.pick(`${spellName}: fire only when…`, presets.map((p) => ({ label: p.label, value: p })), (p) => {
805
- if (Array.isArray(p.value)) { apply(p.value); return; }
806
- app.prompt('Percent: ', (txt) => {
807
- const n = Number(txt);
808
- if (!(n >= 0 && n <= 100)) { app.flashMsg('Enter a number from 0 to 100.'); return; }
809
- apply([{ ...p.prompt, value: n }]);
810
- app.afterAction();
811
- });
812
- });
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) });
813
938
  }
814
939
 
815
940
  function pickSpec(app) {
@@ -823,6 +948,9 @@ function pickSpec(app) {
823
948
  function talentsView(app) {
824
949
  return {
825
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),
826
954
  keys: {
827
955
  u: { hint: 'refund', fn: (a, row) => row?.data && a.result(a.g.refundTalent(row.data.id), `Refunded a point from ${row.data.name}.`) },
828
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.')); } },
@@ -831,6 +959,16 @@ function talentsView(app) {
831
959
  rows(a) {
832
960
  const ch = a.ch;
833
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
+ }
834
972
  const rows = [{ text: ch.level < 10 ? dim('Talents unlock at level 10.') : `${plural(free, 'point')} to spend`, header: true }];
835
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) });
836
974
  for (const tree of a.ds.talentTrees(ch.cls)) {
@@ -842,6 +980,7 @@ function talentsView(app) {
842
980
  const paint = rank >= t.maxRank ? brightYellow : can.ok ? green : dim;
843
981
  rows.push({
844
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}`),
845
984
  detail: () => talentLines(a, t, rank, can),
846
985
  act: () => a.result(a.g.spendTalent(t.id), `You learn ${t.name} (${(ch.talents?.[t.id] ?? 0) + 1}/${t.maxRank}).`),
847
986
  });
@@ -853,9 +992,14 @@ function talentsView(app) {
853
992
  }
854
993
 
855
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) {
856
999
  const out = [bold(t.name) + dim(` tier ${t.tier + 1}, rank ${rank}/${t.maxRank}`)];
857
- if (rank > 0) out.push('', cyan('Current rank'), ...wrapLines(t.ranks[rank - 1]?.description ?? '', 60));
858
- 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 ?? '');
859
1003
  out.push('', can.ok ? green('Enter to spend a point') : red(can.reason ?? 'Cannot spend here yet'));
860
1004
  if (rank > 0 && canRefund(app.ch, t.id, app.ds).ok) out.push(dim('u refunds a point'));
861
1005
  return out;
@@ -921,9 +1065,32 @@ function weaponSkillRows(a, L) {
921
1065
  return rows;
922
1066
  }
923
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
+
924
1084
  function charView(app) {
925
1085
  return {
926
- 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
+ },
927
1094
  rows(a) {
928
1095
  const ch = a.ch, ds = a.ds;
929
1096
  const st = effectiveStats(ch, ds);
@@ -1064,6 +1231,7 @@ export function vendorDialog(app, vendor) {
1064
1231
  const usedBy = uses.get(o.itemEntry);
1065
1232
  rows.push({
1066
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(' · ')),
1067
1235
  detail: () => itemLines(a, it, ['', `Price: ${yellow(price)}`, ...(o.basePricePerPurchase !== o.pricePerPurchase ? [dim(`(${moneyText(o.basePricePerPurchase)} before reputation)`)] : []),
1068
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.')] : [])]),
1069
1237
  act: () => buy(a, o, 1),
@@ -1079,6 +1247,7 @@ export function vendorDialog(app, vendor) {
1079
1247
  const total = (it?.sellPrice ?? 0) * s.count;
1080
1248
  rows.push({
1081
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 ?? ''),
1082
1251
  detail: () => itemLines(a, it, ['', dim('Enter: sell the stack (you can buy it back on the Buyback tab) d: destroy')]),
1083
1252
  act: () => sellStack(a, slot),
1084
1253
  });
@@ -1125,8 +1294,10 @@ function buy(app, offer, n) {
1125
1294
 
1126
1295
  export function trainerDialog(app, trainer) {
1127
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)));
1128
1299
  const learnable = () => trainer.spells
1129
- .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))
1130
1301
  .map((ts) => ({ ts, sp: app.ds.spell(ts.spell) })).filter((x) => x.sp)
1131
1302
  .sort((x, y) => x.ts.reqLevel - y.ts.reqLevel || x.sp.name.localeCompare(y.sp.name));
1132
1303
  return {
@@ -1140,11 +1311,12 @@ export function trainerDialog(app, trainer) {
1140
1311
  const can = ts.cost <= a.ch.gold;
1141
1312
  rows.push({
1142
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}` : ''}`),
1143
1315
  detail: () => spellLines(a, sp, ['', `Cost: ${yellow(moneyText(ts.cost) || 'free')}`]),
1144
1316
  act: () => a.result(a.g.learnFromTrainer(trainer.entry, sp.id), `You have learned ${sp.name}${sp.rank ? ` (Rank ${sp.rank})` : ''}.`),
1145
1317
  });
1146
1318
  }
1147
- 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);
1148
1320
  if (later.length) {
1149
1321
  rows.push({ text: '', header: true }, { text: 'Coming up', header: true });
1150
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 });
@@ -1158,11 +1330,13 @@ export function innDialog(app, inn) {
1158
1330
  return {
1159
1331
  id: `inn-${inn.entry}`, title: inn.name, hint: 'Enter: choose', noDetail: true,
1160
1332
  rows(a) {
1161
- const bound = a.ch.hearthBind?.name === inn.name;
1333
+ const bound = a.ch.hearthBind?.name === inn.name, wares = a.ds.vendor(inn.entry);
1162
1334
  return [
1163
- { 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)) }] : []),
1164
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}.`); } },
1165
- { 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()}`)); } },
1166
1340
  { text: dim('Goodbye'), act: () => a.pop() },
1167
1341
  ];
1168
1342
  },