azeroth-shell 0.1.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/README.md +362 -85
  2. package/bin/azsh.mjs +35 -19
  3. package/package.json +12 -1
  4. package/src/autopilot/plan.ts +8 -11
  5. package/src/autopilot/view.ts +12 -11
  6. package/src/cli/combatLogFormat.ts +22 -0
  7. package/src/cli/gzLoader.mjs +47 -6
  8. package/src/cli/hdArt.mjs +81 -0
  9. package/src/cli/iconArt.mjs +145 -0
  10. package/src/cli/iconGrid.mjs +100 -0
  11. package/src/cli/modelArt.mjs +298 -0
  12. package/src/cli/oocPrep.mjs +32 -0
  13. package/src/cli/paperdoll.mjs +76 -0
  14. package/src/cli/perfCheck.mjs +56 -0
  15. package/src/cli/repl.mjs +48 -7
  16. package/src/cli/rotationText.mjs +6 -6
  17. package/src/cli/sixel.mjs +319 -0
  18. package/src/cli/term.mjs +106 -12
  19. package/src/cli/text.mjs +21 -3
  20. package/src/cli/tui.mjs +262 -102
  21. package/src/cli/tuiAutopilot.mjs +356 -0
  22. package/src/cli/tuiBags.mjs +37 -4
  23. package/src/cli/tuiBg.mjs +370 -41
  24. package/src/cli/tuiCheck.mjs +633 -37
  25. package/src/cli/tuiCity.mjs +146 -41
  26. package/src/cli/tuiCompanionKit.mjs +6 -6
  27. package/src/cli/tuiCompanions.mjs +211 -41
  28. package/src/cli/tuiCreate.mjs +230 -0
  29. package/src/cli/tuiDungeon.mjs +57 -21
  30. package/src/cli/tuiFight.mjs +467 -88
  31. package/src/cli/tuiFightPanes.mjs +42 -14
  32. package/src/cli/tuiFloor.mjs +21 -9
  33. package/src/cli/tuiGear.mjs +17 -14
  34. package/src/cli/tuiLoot.mjs +6 -6
  35. package/src/cli/tuiMap.mjs +214 -0
  36. package/src/cli/tuiPaperdoll.mjs +108 -0
  37. package/src/cli/tuiPartyStage.mjs +206 -0
  38. package/src/cli/tuiPet.mjs +16 -8
  39. package/src/cli/tuiProfessions.mjs +27 -25
  40. package/src/cli/tuiRotationCond.mjs +390 -0
  41. package/src/cli/tuiSaves.mjs +24 -1
  42. package/src/cli/tuiSearch.mjs +79 -27
  43. package/src/cli/tuiSettings.mjs +3 -4
  44. package/src/cli/tuiStage.mjs +360 -0
  45. package/src/cli/tuiTalentTree.mjs +124 -0
  46. package/src/cli/tuiTalents.mjs +3 -4
  47. package/src/cli/tuiViews.mjs +633 -172
  48. package/src/cli/tuiWiki.mjs +2 -3
  49. package/src/cli/tuiWorld.mjs +90 -7
  50. package/src/data/gear-art.bin.br +0 -0
  51. package/src/data/hd-art.json +114 -0
  52. package/src/data/icon-art.bin.gz +0 -0
  53. package/src/data/model-art-worn.bin.br +0 -0
  54. package/src/data/model-art.bin.gz +0 -0
  55. package/src/data/rotations/pve/paladin/retribution.json +2 -2
  56. package/src/data/tables/creatures.json.gz +0 -0
  57. package/src/data/world-map-sixel.bin.br +0 -0
  58. package/src/data/world-map.bin.gz +0 -0
  59. package/src/domain/bg/match.ts +85 -24
  60. package/src/domain/bg/strategies.ts +144 -29
  61. package/src/domain/bg/types.ts +2 -0
  62. package/src/domain/bg/wsg.ts +35 -20
  63. package/src/domain/character/character.ts +6 -0
  64. package/src/domain/pet/pet.ts +31 -0
  65. package/src/domain/quests/doQuest.ts +24 -6
  66. package/src/domain/rotation/build.ts +3031 -3031
  67. package/src/domain/rotation/classes/paladin.ts +16 -11
  68. package/src/domain/rotation/evaluate.ts +3 -0
  69. package/src/engine/combat/applySpell.ts +20 -9
  70. package/src/engine/combat/applySpellLate.ts +16 -12
  71. package/src/engine/combat/areaAura.ts +284 -283
  72. package/src/engine/combat/fighterFactory.ts +1 -1
  73. package/src/engine/combat/pickAction.ts +3494 -3449
  74. package/src/engine/combat/procsDamage.ts +31 -1
  75. package/src/engine/combat/sessionChase.ts +19 -2
  76. package/src/engine/combat/sessionMotionKit.ts +40 -2
  77. package/src/engine/game/loadReconcile.ts +21 -1
  78. package/src/engine/game/pets.ts +33 -1
  79. package/src/engine/game/types.ts +4 -0
  80. package/src/quests/quests.ts +60 -0
@@ -1,42 +1,58 @@
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, coinText } 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';
7
- import { moneyText } from '../domain/economy/money.ts';
6
+ import { className, raceName } from '../domain/core/ids.ts';
7
+ import { nativeDisplayId, wornDisplay } from '../domain/character/displayForm.ts';
8
+ import { formDisplayId } from '../domain/character/forms.ts';
9
+ import { factionOf } from '../domain/character/races.ts';
10
+ import { SHAPESHIFT_FORM, CREATURE_TYPE } from '../data/schema.ts';
8
11
  import { maxHealth, maxPower } from '../domain/character/character.ts';
9
12
  import { effectiveStats, effectiveArmor, attackPower, powerType } from '../domain/character/characterStats.ts';
10
13
  import { critChanceDisplay, dodgeChanceDisplay, hitChance, weaponSkillIdForHand, skillBonusFor } from '../domain/character/characterRatings.ts';
11
- import { COMBAT_SKILL, isLevelRangeCombatSkill, combatSkillDisplayName } from '../domain/character/combatSkills.ts';
14
+ import { COMBAT_SKILL, isLevelRangeCombatSkill, combatSkillDisplayName, isSpellFitByClassAndRace } from '../domain/character/combatSkills.ts';
12
15
  import { skillMaxForLevel } from '../engine/combat/mitigation.ts';
13
16
  import { knownUsableSpells } from '../domain/character/characterSpells.ts';
14
17
  import { bagCapacity, EQUIP_SLOTS, BAG_SLOTS, SLOT_LABEL, itemSlotTargets, DUAL_WIELD_SPELL_ID } from '../domain/items/equipment.ts';
15
18
  import { equippedEntry } from '../domain/items/itemInstance.ts';
16
- import { knows } from '../domain/character/spellbook.ts';
19
+ import { stackBoundToHolder } from '../domain/raid/partyVault.ts';
20
+ import { knows, learnGrantIds } from '../domain/character/spellbook.ts';
17
21
  import { availablePoints, canSpend, canRefund, spentInTree } from '../domain/character/talents.ts';
18
22
  import { offeredQuests, chainFollowUpOffer, grayLogQuests } from '../quests/quests.ts';
19
- import { isRepeatableQuest, questRepeatsAvailable } from '../domain/quests/questlog.ts';
23
+ import { isRepeatableQuest, questRepeatsAvailable, isTalkToNpcQuest, isEventTalkQuest, eventCreditIsTheRemainingWork } from '../domain/quests/questlog.ts';
24
+ import { GARMENTS_ENTRY_QUEST, SPELL_LESSER_HEAL_R2, SPELL_FORTITUDE_R1 } from '../domain/scriptdev/npcsSpecial.ts';
20
25
  import { questRewardChoices } from '../domain/quests/questRewardChoice.ts';
21
26
  import { pushBuyback, buyBack as buyBackDomain } from '../domain/economy/trading.ts';
22
- import { cityAccess, bankDialog, auctionDialog, mailDialog } from './tuiCity.mjs';
27
+ import { cityAccess, bankDialog, auctionDialog, mailDialog, tabStrip } from './tuiCity.mjs';
23
28
  import { professionsView, profTrainerDialog, worldGatherRows, openSendGoods, reagentUses } from './tuiProfessions.mjs';
24
29
  import { companionsView } from './tuiCompanions.mjs';
25
30
  import { companionLevel, companionCharacter, companionSpellbook } from '../domain/bots/squad.ts';
26
31
  import { squadJoinsPull } from './squad.mjs';
32
+ import { huntableCreatures, questTargets, isQuestMob, zoneWeaponTrainers } from '../world/world.ts';
33
+ import { canInteractWithCreature } from '../domain/creature/faction.ts';
27
34
  import { barsDialog, playerBarsOwner } from './tuiBars.mjs';
28
35
  import { petWorldRows, petDialog, hasPetClass } from './tuiPet.mjs';
29
36
  import { portalRows } from './tuiDungeon.mjs';
37
+ import { conditionEditor, defaultConditionsForSpell, defaultItemRule } from './tuiRotationCond.mjs';
38
+ import { allyIndexOfSide } from '../engine/core/events.ts';
30
39
  import { bulkDisenchantDialog, learnAllDialog } from './tuiBulk.mjs';
31
- import { gearExtraRows, durabilityText, repairOne, slotRepairLines, enchantLines } from './tuiGear.mjs';
40
+ import { gearExtraRows, durabilityText, repairOne, repairAll, slotRepairLines, enchantLines } from './tuiGear.mjs';
32
41
  import { outfitterDialog } from './tuiOutfitter.mjs';
33
42
  import { castWithPicker } from './tuiEnchantTarget.mjs';
34
43
  import { settingsDialog } from './tuiSettings.mjs';
35
44
  import { macrosDialog } from './tuiMacros.mjs';
36
- import { pickPocket, pickPocketTag, journeyRows, afterDeparture, ridingRows, objectRows, meetingStoneQueueRows, outdoorPvpRows } from './tuiWorld.mjs';
45
+ import { hereRows, pickPocket, pickPocketTag, tame, tameTag, tameState, questUseItem, journeyRows, afterDeparture, ridingRows, objectRows, meetingStoneQueueRows, outdoorPvpRows } from './tuiWorld.mjs';
37
46
  import { talentBuildsMenu, myTalentLink } from './tuiTalents.mjs';
38
47
  import { simLabDialog } from './tuiSimLab.mjs';
48
+ import { withWorldMap } from './tuiMap.mjs';
39
49
  import { autopilotSettingsDialog } from './tuiAutopilotSettings.mjs';
50
+ import { preflightCard } from './tuiAutopilot.mjs';
51
+ import { creatureArt } from './modelArt.mjs';
52
+ import { withIcon, iconsOn, miniIcon } from './iconArt.mjs';
53
+ import { paperdollGrid, paperdollPlan } from './tuiPaperdoll.mjs';
54
+ import { talentGrid, talentPlan, talentTone, talentIcon } from './tuiTalentTree.mjs';
55
+ import { paperdollArt } from './paperdoll.mjs';
40
56
  import { reputationDialog } from './tuiRep.mjs';
41
57
  import { honorDialog, honorRankLine } from './tuiHonor.mjs';
42
58
  import { openWorldSearch } from './tuiSearch.mjs';
@@ -56,7 +72,24 @@ export function conPaint(playerLevel, level) {
56
72
 
57
73
  // ---- detail panes --------------------------------------------------------------------------------
58
74
 
75
+ // A spell's tooltip text with the tokens the bake left in filled from its own effects: $v max target
76
+ // level, $q misc value (Resurrection's mana), $f damage multiplier (Ferocious Bite). A leading id reads
77
+ // another spell's effect ($3026q1). Anything else stays as written.
78
+ export function spellText(ds, sp) {
79
+ const text = sp?.description?.trim();
80
+ if (!text?.includes('$')) return text;
81
+ const fx = (id, i) => (id ? ds.spell(Number(id)) : sp)?.rawEffects?.[(Number(i) || 1) - 1];
82
+ return text
83
+ .replace(/\$v\b/g, (m) => sp.maxTargetLevel != null ? String(sp.maxTargetLevel) : m)
84
+ .replace(/\$(\d*)q(\d?)/g, (m, id, i) => fx(id, i)?.miscValue != null ? String(fx(id, i).miscValue) : m)
85
+ .replace(/\$(\d*)f(\d)/g, (m, id, i) => fx(id, i)?.dmgMultiplier != null ? String(fx(id, i).dmgMultiplier) : m);
86
+ }
87
+
59
88
  export function itemLines(app, item, extra = []) {
89
+ return withIcon(item?.icon, itemLinesText(app, item, extra));
90
+ }
91
+
92
+ function itemLinesText(app, item, extra = []) {
60
93
  if (!item) return ['Unknown item'];
61
94
  const out = [qualityPaint(item.quality)(bold(item.name))];
62
95
  const kind = [item.slot, item.typeName].filter(Boolean).join(' · ');
@@ -68,19 +101,48 @@ export function itemLines(app, item, extra = []) {
68
101
  if (item.armor) out.push(`${item.armor} Armor`);
69
102
  if (item.block) out.push(`${item.block} Block`);
70
103
  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.`));
104
+ for (const id of new Set(item.equipSpells ?? [])) {
105
+ const text = spellText(app.ds, app.ds.spell(id));
106
+ if (text) out.push(green(`Equip: ${text}`));
107
+ }
108
+ // ITEM_SPELLTRIGGER_CHANCE_ON_HIT 2.
109
+ for (const p of item.procs ?? []) {
110
+ const text = p.trigger === 2 && spellText(app.ds, app.ds.spell(p.spell));
111
+ if (text) out.push(green(`Chance on hit: ${text}`));
112
+ }
113
+ // The use spell's own tooltip text carries the Well Fed rider; a few still hold unresolved $ tokens.
114
+ const useText = spellText(app.ds, app.ds.spell(item.useSpell ?? item.useInCombat?.spellId));
115
+ if (useText && !useText.includes('$')) out.push(green(`Use: ${useText}`));
116
+ else {
117
+ // Same tick count rest.ts heals for: floor(duration / tick).
118
+ 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.`));
119
+ if (item.useInCombat?.effect === 'restore_health') out.push(green(`Use: Restores ${item.useInCombat.min} to ${item.useInCombat.max} health.`));
120
+ if (item.useInCombat?.effect === 'restore_power') out.push(green(`Use: Restores ${item.useInCombat.min} to ${item.useInCombat.max} mana.`));
121
+ }
74
122
  if (item.requiredLevel > 1) out.push(`Requires Level ${item.requiredLevel}`);
75
123
  if (item.allowClass?.length) out.push(`Classes: ${item.allowClass.map(className).join(', ')}`);
76
124
  if (item.bagSlots) out.push(`${item.bagSlots} Slot Bag`);
125
+ const set = item.setId && app.ds.itemSet(item.setId);
126
+ if (set) {
127
+ const worn = new Set(Object.values(app.ch?.equipped ?? {}).map(equippedEntry));
128
+ const have = set.items.filter((e) => worn.has(e)).length;
129
+ out.push('', yellow(`${set.name ?? 'Item set'} (${have}/${set.items.length})`));
130
+ for (const b of set.bonuses) {
131
+ const text = spellText(app.ds, app.ds.spell(b.spell));
132
+ if (text) out.push((have >= b.pieces ? green : gray)(`(${b.pieces}) Set: ${text}`));
133
+ }
134
+ }
77
135
  if (item.description) out.push(yellow(`"${item.description}"`));
78
- if (item.sellPrice) out.push(dim(`Sells for ${moneyText(item.sellPrice)}`));
136
+ if (item.sellPrice) out.push(dim(`Sells for ${coinText(item.sellPrice)}`));
79
137
  out.push(...extra);
80
138
  return out;
81
139
  }
82
140
 
83
141
  export function spellLines(app, sp, extra = []) {
142
+ return withIcon(sp?.icon, spellLinesText(app, sp, extra));
143
+ }
144
+
145
+ function spellLinesText(app, sp, extra = []) {
84
146
  if (!sp) return ['Unknown spell'];
85
147
  const out = [bold(sp.name) + (sp.rank ? dim(` Rank ${sp.rank}`) : '')];
86
148
  const bits = [];
@@ -90,7 +152,8 @@ export function spellLines(app, sp, extra = []) {
90
152
  if (sp.rangeMax) bits.push(`${sp.rangeMax} yd range`);
91
153
  out.push(dim(bits.join(' ')));
92
154
  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));
155
+ const text = spellText(app.ds, sp);
156
+ if (text) out.push(yellow(text)); // the pane wraps it, beside the icon while they share its rows
94
157
  const cd = app.g.spellCooldownRemaining(sp.id);
95
158
  if (cd > 0) out.push(red(`Cooldown: ${secondsText(cd)} left`));
96
159
  out.push(...extra);
@@ -104,7 +167,9 @@ export function creatureLines(app, c) {
104
167
  out.push(`${hp} health hits ${c.dmgMin}-${c.dmgMax} every ${(c.attackTimeMs / 1000).toFixed(1)} s`);
105
168
  if (c.armor) out.push(`${c.armor} armor`);
106
169
  if (c.xp) out.push(dim(`about ${c.xp} xp at your level`));
107
- if (c.tameable) out.push(green(app.ch.cls === 3 ? 'Tameable: t tames it' : 'Tameable'));
170
+ const tm = tameState(app, c);
171
+ if (tm === 'use') out.push(green(`t: use ${questUseItem(app).name} on it`));
172
+ else if (c.tameable) out.push(green({ tame: 'Tameable: t tames it', pet: `Tameable, once ${app.ch.pet?.name} is dismissed or stabled`, unlearned: 'Tameable once you learn Tame Beast' }[tm] ?? 'Tameable'));
108
173
  const wanted = app.g.state.questlog.quests.flatMap((qs) => {
109
174
  const q = app.ds.quest(qs.entry);
110
175
  const k = q?.killTargets.find((t) => t.creatureEntry === c.entry);
@@ -113,12 +178,25 @@ export function creatureLines(app, c) {
113
178
  out.push(...wanted);
114
179
  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
180
  out.push('', dim('Enter: fight live f: auto-resolve'));
181
+ // The creature itself under the text, as large as the pane has room for (modelArt.mjs).
182
+ const L = app.layout?.();
183
+ const art = L && c.displayId ? creatureArt(c.displayId, L.detailW - 2, L.mainH - out.length - 1) : null;
184
+ if (art) out.push('', ...art);
116
185
  return out;
117
186
  }
118
187
 
188
+ /** A person in the world: the text, then the NPC's creature drawn under it as large as the pane has room for. */
189
+ export function npcLines(app, entry, text) {
190
+ const L = app.layout?.(), id = app.ds.creature(entry)?.displayId;
191
+ if (!L || !id) return text;
192
+ const w = L.detailW - 2, used = text.reduce((n, line) => n + (vis(line) > w ? wrapLines(line, w).length : 1), 0);
193
+ const art = creatureArt(id, w, L.mainH - used - 1);
194
+ return art ? [...text, '', ...art] : text;
195
+ }
196
+
119
197
  export function questLines(app, q, qs) {
120
198
  const ch = app.ch;
121
- const out = [bold(q.title) + dim(` level ${q.questLevel}${q.minLevel > ch.level ? red(` requires ${q.minLevel}`) : ''}`)];
199
+ const out = [conPaint(ch.level, q.questLevel)(bold(q.title)) + dim(` level ${q.questLevel}${q.minLevel > ch.level ? red(` requires ${q.minLevel}`) : ''}`)];
122
200
  if (qs) out.push(qs.status === 'complete' ? green('Ready to turn in') : qs.status === 'failed' ? red('Failed') : yellow('In progress'));
123
201
  out.push(...objectiveLines(app, q, qs).map((o) => ` ${o}`));
124
202
  const rew = rewardLines(app, q);
@@ -142,29 +220,49 @@ export function objectiveLines(app, q, qs) {
142
220
 
143
221
  export function rewardLines(app, q) {
144
222
  const out = [];
145
- if (q.rewXP) out.push(`${q.rewXP} experience`);
146
- if (q.rewMoney) out.push(yellow(moneyText(q.rewMoney)));
147
- for (const it of q.rewItems) out.push(`${app.itemLabel(it.item)}${it.count > 1 ? ` x${it.count}` : ''}`);
148
- if (q.rewChoiceItems?.length) out.push('Choose one of: ' + q.rewChoiceItems.map((it) => app.itemLabel(it.item)).join(', '));
149
- for (const s of q.rewSpells ?? []) out.push(`Learn ${app.spellName(s)}`);
223
+ if (q.rewXP) out.push(magenta(`${q.rewXP} experience`));
224
+ if (q.rewMoney) out.push(coinText(q.rewMoney));
225
+ const item = (it) => `${miniIcon(app.ds.item(it.item)?.icon)}${app.itemLabel(it.item)}${it.count > 1 ? ` x${it.count}` : ''}`;
226
+ for (const it of q.rewItems) out.push(item(it));
227
+ if (q.rewChoiceItems?.length) out.push('Choose one of:', ...q.rewChoiceItems.map((it) => ` ${item(it)}`));
228
+ for (const s of q.rewSpells ?? []) out.push(`${miniIcon(app.ds.spell(s)?.icon)}Learn ${app.spellName(s)}`);
150
229
  return out;
151
230
  }
152
231
 
232
+ /** The rewards on the quest page: each item a row with its icon and its tooltip, the money in coins. */
233
+ function rewardRows(app, q) {
234
+ const P = (t) => ({ text: t, header: true, plain: true });
235
+ const item = (it) => {
236
+ const def = app.ds.item(it.item);
237
+ return { text: `${app.itemLabel(it.item)}${it.count > 1 ? ` x${it.count}` : ''}`, icon: def?.icon, sub: dim(def?.typeName ?? ''), detail: () => (def ? itemLines(app, def) : []) };
238
+ };
239
+ const rows = [];
240
+ if (q.rewXP) rows.push(P(` ${magenta(`${q.rewXP} experience`)}`));
241
+ if (q.rewMoney) rows.push(P(` ${coinText(q.rewMoney)}`));
242
+ rows.push(...q.rewItems.map(item));
243
+ if (q.rewChoiceItems?.length) rows.push(P(' Choose one of:'), ...q.rewChoiceItems.map(item));
244
+ for (const s of q.rewSpells ?? []) {
245
+ const sp = app.ds.spell(s);
246
+ rows.push(sp ? { text: `Learn ${app.spellName(s)}`, icon: sp.icon, sub: dim('spell'), detail: () => spellLines(app, sp) } : P(` Learn ${app.spellName(s)}`));
247
+ }
248
+ return rows;
249
+ }
250
+
153
251
  // ---- tabs ------------------------------------------------------------------------------------------
154
252
 
155
253
  export function makeTabs(app) {
156
254
  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) },
255
+ { label: 'World', icon: 'inv_misc_map_01', view: worldView(app) },
256
+ { label: 'Quests', icon: 'inv_misc_note_01', view: questsView(app) },
257
+ { label: 'Bags', icon: 'inv_misc_bag_08', view: bagsView(app) },
258
+ { label: 'Character', short: 'Char', icon: 'inv_misc_head_human_01', view: characterView(app) },
259
+ { label: 'Spells', icon: 'inv_misc_book_09', view: spellsView(app) },
260
+ { label: 'Rotation', icon: 'ability_dualwield', view: rotationView(app) },
261
+ { label: 'Talents', icon: 'spell_nature_naturetouchgrow', view: talentsView(app) },
262
+ { label: 'Travel', icon: 'spell_nature_swiftness', view: travelView(app) },
263
+ { label: 'Professions', short: 'Profs', icon: 'trade_blacksmithing', view: professionsView(app) },
264
+ { label: 'Companions', short: 'Party', icon: 'spell_holy_prayerofhealing', view: companionsView(app) },
265
+ { label: 'Settings', icon: 'inv_misc_gear_01', view: settingsDialog(app) },
168
266
  ];
169
267
  }
170
268
 
@@ -173,34 +271,44 @@ function worldView(app) {
173
271
  id: 'world', title: 'World', hint: 'Enter: open / fight',
174
272
  keys: {
175
273
  s: { hint: 'search', fn: (a) => openWorldSearch(a) },
274
+ S: { fn: (a) => openWorldSearch(a, '', true) },
176
275
  f: { hint: 'quick fight', fn: (a, row) => { if (row?.data?.creature) a.cmds.commands.fight(String(row.data.creature.entry)); } },
177
- 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}.`); } },
276
+ t: { fn: (a, row) => { if (row?.data?.creature) tame(a, row.data.creature); } },
178
277
  P: { fn: (a, row) => { if (row?.data?.creature) pickPocket(a, row.data.creature); } },
179
- a: { hint: 'autopilot', fn: (a) => startAutopilot(a) },
278
+ a: { hint: 'autopilot', fn: (a) => a.push(preflightCard(a)) },
279
+ c: { hint: 'characters', fn: (a) => { void a.logout(); } },
180
280
  },
181
281
  rows(a) {
182
282
  const ds = a.ds, g = a.g, z = a.zone(), ch = a.ch;
183
283
  if (!z) return [{ text: 'You are nowhere.', header: true }];
184
284
  const rows = [...journeyRows(a), ...meetingStoneQueueRows(a), { text: `${z.name} ${dim(`levels ${z.levelRange[0]}-${z.levelRange[1]}${z.faction ? `, ${z.faction}` : ''}`)}`, header: true }];
285
+ // Enemy-faction service NPCs in a contested zone, and any faction you declared war on, will not deal with you (Unit::CanInteract).
286
+ const atWar = g.atWarFactions(), canTalk = (entry) => canInteractWithCreature(ds, ch.race, entry, atWar);
185
287
  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)) });
288
+ for (const t of z.trainers ?? []) if (t.cls === ch.cls && canTalk(t.entry)) { 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)) }); }
289
+ for (const wm of zoneWeaponTrainers(ds, z.id)) if (canTalk(wm.entry)) people.push({ text: `${wm.name} ${dim(`<${wm.subname || 'Weapon Master'}>`)}`, detail: () => npcLines(a, wm.entry, [bold(wm.name), wm.subname || 'Weapon Master', 'Teaches weapon skills.', '', dim('Enter: see what you can learn')]), act: () => a.push(trainerDialog(a, wm)) });
290
+ const innEntries = new Set((z.innkeepers ?? []).map((i) => i.entry)); // innkeepers sell too, but list once, as the inn
291
+ for (const v of z.vendors ?? []) { const d = !innEntries.has(v) && canTalk(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)) }); }
292
+ for (const i of z.innkeepers ?? []) if (canTalk(i.entry)) 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
293
  for (const p of z.profTrainers ?? []) {
190
- const d = ds.profTrainer(p);
294
+ const d = canTalk(p) && ds.profTrainer(p);
191
295
  if (!d) continue;
192
296
  const prof = ds.profession(d.skill)?.name ?? d.skill;
193
297
  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)) });
298
+ 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
299
  }
196
300
  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) });
301
+ // A flight master serves you once you have found this zone's flight path.
302
+ const found = new Set(ch.discoveredTaxiNodes ?? []);
303
+ const taxiHere = ds.taxiNodes().some((n) => n.zone === z.id && found.has(n.id));
304
+ for (const f of taxiHere ? z.flightMasters ?? [] : []) if (canTalk(f.entry) || !ds.creature(f.entry)) 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
305
  // City services: the bank in capitals and banker towns, the auction house in capitals, the mailbox by every inn.
199
306
  const city = cityAccess(a);
200
307
  const services = [];
201
308
  if (city.bank) services.push({ text: `${brightBlue('Bank')} ${dim(`<Banker> ${g.state.bank.stacks.length} stacks stored`)}`, detail: () => [bold('Bank'), 'Store what you do not carry.', '', dim('Enter: open the vault (also b in Bags)')], act: () => a.push(bankDialog(a)) });
202
309
  if (city.ah) { const n = g.browseAuctions(Date.now()).length; services.push({ text: `${brightBlue('Auction House')} ${dim(`<Auctioneer>${n ? ` ${n} listings` : ''}`)}`, detail: () => [bold('Auction House'), 'Buy from and sell to the other players of this realm.', '', dim('Enter: browse, sell, see your auctions')], act: () => a.push(auctionDialog(a)) }); }
203
310
  if (city.mail) { const unread = g.unreadMailCount(Date.now()); services.push({ text: `${brightBlue('Mailbox')}${unread ? yellow(` ${unread} new`) : ''}`, detail: () => [bold('Mailbox'), 'Auction sales, refunds and overflow loot arrive here.', '', dim('Enter: read your mail')], act: () => a.push(mailDialog(a)) }); }
311
+ rows.push(...hereRows(a));
204
312
  if (services.length) rows.push({ text: '', header: true }, { text: 'Services', header: true }, ...services);
205
313
  rows.push(...portalRows(a));
206
314
  const petRows = petWorldRows(a);
@@ -215,27 +323,23 @@ function worldView(app) {
215
323
  if (!dest) continue;
216
324
  rows.push({ text: `${cyan(dest.name)} ${dim(`levels ${dest.levelRange[0]}-${dest.levelRange[1]}`)}`, detail: () => travelPreviewLines(a, dest), act: () => travelTo(a, dest) });
217
325
  }
218
- const mobs = z.huntable.map((e) => ds.creature(e)).filter(Boolean).sort((x, y) => x.level - y.level || x.name.localeCompare(y.name));
326
+ // What you can attack here: world bosses and quest-summoned targets in, friendly and trigger NPCs out.
327
+ const mobs = huntableCreatures(ds, z.id, g.state.questlog, ch.race).sort((x, y) => x.level - y.level || x.name.localeCompare(y.name));
219
328
  if (mobs.length) {
220
329
  rows.push({ text: '', header: true }, { text: `Creatures (${mobs.length})`, header: true });
330
+ const targets = questTargets(ds, g.state.questlog);
221
331
  for (const m of mobs) {
222
- const wanted = g.state.questlog.quests.some((qs) => qs.status === 'active' && ds.quest(qs.entry)?.killTargets.some((k) => k.creatureEntry === m.entry));
223
- 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) });
332
+ const wanted = isQuestMob(ds, m, targets); // a kill target, or a drop source for a quest item you still need
333
+ rows.push({ text: `${conPaint(ch.level, m.level)(m.name)} ${dim(`L${m.level}`)}${wanted ? yellow(' !') : ''}${ch.cls === 4 ? pickPocketTag(a, m) : ''}${tameTag(a, m)}`, data: { creature: m }, detail: () => creatureLines(a, m), act: () => startFight(a, m.entry) });
224
334
  }
225
335
  }
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));
336
+ // Last: hand-ins and the zone's offers (the Quests tab shows the same list under its log).
231
337
  const ready = g.state.questlog.quests.filter((qs) => qs.status === 'complete').map((qs) => ({ qs, q: ds.quest(qs.entry) })).filter((x) => x.q);
338
+ const offered = zoneOfferRows(a);
232
339
  if (offered.length || ready.length) {
233
340
  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
- }
341
+ 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')) });
342
+ rows.push(...offered);
239
343
  }
240
344
  return rows;
241
345
  },
@@ -245,10 +349,11 @@ function worldView(app) {
245
349
  export function travelPreviewLines(app, dest) {
246
350
  const pv = app.g.previewTravel(dest.id);
247
351
  const out = [bold(dest.name), dim(`levels ${dest.levelRange[0]}-${dest.levelRange[1]}${dest.faction ? `, ${dest.faction}` : ''}`), ''];
248
- if (!pv.ok) { out.push(red(pv.reason)); return out; }
249
- out.push(`${secondsText(pv.totalSeconds * 1000)} of travel${pv.totalCopper ? `, ${moneyText(pv.totalCopper)} in fares` : ''}`);
250
- for (const leg of pv.legs) out.push(dim(` ${leg.kind} ${app.ds.zone(leg.from)?.name ?? leg.from} → ${app.ds.zone(leg.to)?.name ?? leg.to} (${secondsText(leg.seconds * 1000)})`));
251
- return out;
352
+ if (!pv.ok) { out.push(red(pv.reason)); return withWorldMap(app, out, { dest }); }
353
+ out.push(`${secondsText(pv.totalSeconds * 1000)} of travel${pv.totalCopper ? `, ${coinText(pv.totalCopper)} in fares` : ''}`);
354
+ for (const leg of pv.legs) out.push(dim(` ${(leg.transportEntry && app.ds.transports().find((t) => t.entry === leg.transportEntry)?.name) || leg.kind} ${app.ds.zone(leg.from)?.name ?? leg.from} → ${app.ds.zone(leg.to)?.name ?? leg.to} (${secondsText(leg.seconds * 1000)})`));
355
+ // The world map under the plan: you, the destination and the route between them (tuiMap.mjs).
356
+ return withWorldMap(app, out, { dest, legs: pv.legs });
252
357
  }
253
358
 
254
359
  export function travelTo(app, dest) {
@@ -257,7 +362,7 @@ export function travelTo(app, dest) {
257
362
  const res = app.g.travelTo(dest.id);
258
363
  if (!app.result(res)) return;
259
364
  app.vendor = null; app.cmds.setVendor(null);
260
- afterDeparture(app, () => `You travel to ${bold(dest.name)}. ${dim(`${secondsText(pv.totalSeconds * 1000)}${pv.totalCopper ? `, ${moneyText(pv.totalCopper)}` : ''}`)}`);
365
+ afterDeparture(app, () => `You travel to ${bold(dest.name)}. ${dim(`${secondsText(pv.totalSeconds * 1000)}${pv.totalCopper ? `, ${coinText(pv.totalCopper)}` : ''}`)}`);
261
366
  landOnTab(app);
262
367
  }
263
368
 
@@ -270,9 +375,23 @@ export function landOnTab(app) {
270
375
  const v = app.tabs[i]?.view; if (v) { v.cursor = 0; v.scroll = 0; }
271
376
  }
272
377
 
378
+ /** The quests this zone offers that are not in the log: the same offer list the autopilot reads (chain, class,
379
+ * race and exclusive-group gates applied). `canAccept` false means visible but level- or reputation-locked
380
+ * (shown grey, like a silver !); quests more than five levels above you are left out. */
381
+ export function zoneOfferRows(a) {
382
+ const z = a.zone(), ch = a.ch;
383
+ if (!z) return [];
384
+ const have = new Set(a.g.state.questlog.quests.map((qs) => qs.entry));
385
+ 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));
386
+ return offered.map((o) => {
387
+ const q = o.quest;
388
+ 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')) };
389
+ });
390
+ }
391
+
273
392
  function questsView(app) {
274
393
  return {
275
- id: 'quests', title: 'Quest log', hint: 'Enter: open', empty: 'Your quest log is empty. Look for ! in the world.',
394
+ 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
395
  keys: {
277
396
  d: { hint: 'do quest', fn: (a, row) => { if (row?.data) doQuest(a, row.data.q); } },
278
397
  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 +404,14 @@ function questsView(app) {
285
404
  rows.push({ text: `${log.length} of 20 quests`, header: true });
286
405
  for (const x of log) {
287
406
  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')) });
407
+ 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
408
  }
409
+ // Below the log: what this zone offers that you have not taken, so you need not scroll the World tab for it.
410
+ const offered = zoneOfferRows(a);
411
+ if (offered.length) {
412
+ if (log.length) rows.push({ text: '', header: true });
413
+ rows.push({ text: `Available in ${a.zone()?.name ?? 'this zone'} (${offered.length})`, header: true }, ...offered);
414
+ } else if (!log.length) return [];
290
415
  return rows;
291
416
  },
292
417
  };
@@ -299,23 +424,33 @@ export function questDialog(app, q, mode) {
299
424
  rows(a) {
300
425
  const W = a.layout().listW - 4;
301
426
  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 });
427
+ // Each part reads apart: the title in its level colour, cyan section headings, the story in plain
428
+ // text, the objective in bold with its counts, rewards in their own paint.
429
+ const P = (t) => ({ text: t, header: true, plain: true });
430
+ const rows = [P(`${conPaint(ch.level, q.questLevel)(bold(q.title))} ${dim(`L${q.questLevel}`)}`)];
431
+ const text = (t, paint = (l) => l) => wrapLines(questText(t, ch), W).map((l) => P(paint(l)));
432
+ const section = (title, lines) => { if (lines.length) rows.push(P(''), { text: title, header: true }, ...lines); };
433
+ const story = () => section(s?.status === 'complete' ? 'Completion' : 'Description',
434
+ text(s?.status === 'complete' ? q.offerRewardText || q.endText || '' : q.detailsText || q.requestItemsText || ''));
435
+ const objectives = () => section('Objectives', [...text(q.objectivesText ?? '', bold), ...objectiveLines(a, q, s).map((o) => P(` ${o}`))]);
436
+ // Offered, the story leads as the giver tells it; in the log, what is left to do comes first.
437
+ if (mode === 'offer' || s?.status === 'complete') { story(); objectives(); } else { objectives(); story(); }
438
+ section('Rewards', rewardRows(a, q));
439
+ rows.push(P(''));
440
+ // The reward rows can take the cursor for their tooltips, but the page opens on what you came to do.
441
+ const lead = rows.length;
442
+ if (!view.placed) { view.placed = true; view.cursor = lead; }
310
443
  if (mode === 'offer') {
311
444
  rows.push({ text: green('Accept the quest'), act: () => { if (a.result(a.g.acceptQuest(q.entry), `Quest accepted: ${q.title}`)) a.pop(); } });
445
+ rows.push({ text: 'Accept and do the quest', act: () => { if (a.result(a.g.acceptQuest(q.entry), `Quest accepted: ${q.title}`)) doQuest(a, q); } });
312
446
  rows.push({ text: dim('Decline'), act: () => a.pop() });
313
447
  } else {
314
448
  if (s?.status === 'complete') rows.push({ text: green('Complete the quest'), act: () => turnIn(a, q) });
449
+ else if (s?.status === 'active') rows.push(...questStepRows(a, q, s));
315
450
  // A repeatable with a fixed reward hands in every round the bags pay for in one go.
316
451
  const rounds = s?.status === 'complete' && isRepeatableQuest(q) && questRewardChoices(q).length <= 1 ? questRepeatsAvailable(q, a.g.state.inventory, a.ch.zoneId) : 0;
317
452
  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) });
453
+ else rows.push({ text: 'Do the quest', act: () => doQuest(a, q) });
319
454
  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
455
  rows.push({ text: dim('Close'), act: () => a.pop() });
321
456
  }
@@ -326,12 +461,55 @@ export function questDialog(app, q, mode) {
326
461
  return view;
327
462
  }
328
463
 
464
+ // The Garments quests' patient, by quest (the script table is keyed by patient).
465
+ const GARMENTS_PATIENT = Object.fromEntries(Object.entries(GARMENTS_ENTRY_QUEST).map(([entry, quest]) => [quest, Number(entry)]));
466
+
467
+ /** Speak target for a gossip-event quest: the mid-event NPC its objectives name (Great Bear Spirit) before the
468
+ * turn-in ender (Dendrite). Ported from phonecraft src/ui/quests/questCardUi.ts eventTalkSpeakName. */
469
+ function eventTalkSpeakName(q) {
470
+ const text = q.objectivesText ?? '';
471
+ for (const re of [/\blisten as ([^.]+?) tells/i, /\bseek out the ([^.]+?) in /i, /\bafter speaking with ([^.]+?)\.?$/i, /\bspeak(?:ing)? with ([^.]+?)(?:\.|,| after)/i, /\bspeak to ([^.]+?)(?:\.|,| again)/i]) {
472
+ const m = text.match(re);
473
+ if (m?.[1]?.trim()) return m[1].trim();
474
+ }
475
+ return (q.enderName || q.starterName || '').trim();
476
+ }
477
+
478
+ /** The steps on an open quest's page that are a press, not a fight: speak to someone, examine the object that
479
+ * ends it, finish an escort the data has no path for, or cast on the Garments patient. Ref questCardUi.ts:254. */
480
+ function questStepRows(a, q, s) {
481
+ const g = a.g, ds = a.ds, rows = [];
482
+ const eventTalk = isEventTalkQuest(q);
483
+ const npc = eventTalk ? eventTalkSpeakName(q) : (q.enderName || q.starterName || '').trim();
484
+ if (npc && ((isTalkToNpcQuest(q) && s.talked !== true) || (eventTalk && s.explored !== true))) {
485
+ // On 72 of these the ender is an object: the credit is in using it, and a Speak would be refused.
486
+ const go = q.goEnder || (q.goEnderEntries ?? []).find((e) => e > 0) || 0;
487
+ const goName = go ? ds.gameObject?.(go)?.name ?? npc : '';
488
+ rows.push(go
489
+ ? { text: green(`Examine ${goName}`), act: () => a.result(g.interactObject(go), `You examine ${goName}.`) }
490
+ : { text: green(`Speak to ${npc}`), act: () => a.result(g.talkToNpc({ npcName: npc }), `You speak to ${npc}.`) });
491
+ }
492
+ if (eventCreditIsTheRemainingWork(q, s, g.state.inventory)) {
493
+ const kind = q.exploreTriggers?.length ? 'exploration' : 'escort';
494
+ rows.push({ text: green(`Complete the ${kind}`), detail: () => [bold(`Complete the ${kind}`), '', `The data has no path for this ${kind}, so it is credited in one step.`], act: () => a.result(g.finishEscort(q.entry), `${q.title}: ${kind} done.`) });
495
+ }
496
+ const patient = GARMENTS_PATIENT[q.entry];
497
+ if (patient != null) {
498
+ const who = ds.creature(patient)?.name ?? `NPC ${patient}`;
499
+ for (const [id, label] of [[SPELL_LESSER_HEAL_R2, `Heal ${who}`], [SPELL_FORTITUDE_R1, `Fortitude on ${who}`]]) {
500
+ if (!knows(g.state.spellbook, id)) continue;
501
+ rows.push({ text: green(label), act: () => { if (g.spellCooldownRemaining(id) > 0) { a.flashMsg('Not ready yet.'); return; } a.result(g.castOutOfCombat(id, { targetCreature: patient }), `You cast ${a.spellName(id)} on ${who}.`); } });
502
+ }
503
+ }
504
+ return rows;
505
+ }
506
+
329
507
  /** Do Quest: the autopilot works this one quest (travel, fights, objects, hand-in) and stops when it is done. */
330
508
  function doQuest(app, q) {
331
509
  const s = app.g.state.questlog.quests.find((x) => x.entry === q.entry);
332
510
  if (s?.status === 'complete' && q.rewChoiceItems?.length) { turnIn(app, q); return; }
333
511
  app.stack = [];
334
- startAutopilot(app, 500, undefined, q.entry);
512
+ startAutopilot(app, Infinity, undefined, q.entry);
335
513
  }
336
514
 
337
515
  function repeatTurnIn(app, q) {
@@ -339,7 +517,7 @@ function repeatTurnIn(app, q) {
339
517
  if (!app.result(r)) return;
340
518
  const bits = [`${r.turnedIn} times`];
341
519
  if (r.xp) bits.push(`+${r.xp} xp`);
342
- if (r.money) bits.push(`+${moneyText(r.money)}`);
520
+ if (r.money) bits.push(`+${coinText(r.money)}`);
343
521
  app.say(green(`Quest complete: ${q.title}`) + dim(` ${bits.join(', ')}`));
344
522
  for (const lv of r.leveledTo) { app.say(bold(green(`You have reached level ${lv}!`))); app.flashMsg(`Level ${lv}!`); }
345
523
  app.stack = [];
@@ -362,7 +540,7 @@ function turnIn(app, q) {
362
540
  if (!app.result(res, `Quest complete: ${q.title}`)) return;
363
541
  const bits = [];
364
542
  if (res.xp) bits.push(`+${res.xp} xp`);
365
- if (res.money) bits.push(`+${moneyText(res.money)}`);
543
+ if (res.money) bits.push(`+${coinText(res.money)}`);
366
544
  for (const it of res.items) bits.push(app.itemLabel(it.item) + (it.count > 1 ? ` x${it.count}` : ''));
367
545
  for (const s of res.spells ?? []) bits.push(`learned ${s.name ?? ''}`.trim());
368
546
  if (bits.length) app.say(' ' + bits.join(', '));
@@ -376,6 +554,7 @@ function turnIn(app, q) {
376
554
  if (q.rewChoiceItems?.length) {
377
555
  app.pick('Choose your reward', q.rewChoiceItems.map((it, i) => ({
378
556
  label: `${app.itemLabel(it.item)}${it.count > 1 ? ` x${it.count}` : ''}`,
557
+ icon: app.ds.item(it.item)?.icon, sub: dim(app.ds.item(it.item)?.typeName ?? ''),
379
558
  detail: () => itemLines(app, app.ds.item(it.item)),
380
559
  value: i,
381
560
  })), finish);
@@ -386,7 +565,7 @@ function bagsView(app) {
386
565
  const slotOf = (a, row) => (row?.data && !('keyring' in row.data) ? a.g.state.inventory.stacks.indexOf(row.data) : -1);
387
566
  const onStack = (fn) => (a, row) => { const i = slotOf(a, row); if (i >= 0) fn(a, i); };
388
567
  return {
389
- id: 'bags', title: 'Bags', hint: 'Enter: use / equip / open m: more', empty: 'Your bags are empty.',
568
+ id: 'bags', title: 'Bags', hint: 'Enter: use / equip / open', empty: 'Your bags are empty.',
390
569
  keys: {
391
570
  m: { hint: 'item menu', fn: onStack(itemMenu) },
392
571
  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); } },
@@ -406,7 +585,7 @@ function bagsView(app) {
406
585
  rows(a) {
407
586
  const st = a.g.state.inventory.stacks;
408
587
  const ups = new Set(a.g.suggestedEquipmentUpgrades().filter((u) => !u.holderId).map((u) => u.entry));
409
- const rows = [{ text: `${st.length}/${bagCapacity(a.ch, a.ds)} slots ${yellow(moneyText(a.ch.gold) || '0c')}${ups.size ? green(` ▲ ${plural(ups.size, 'upgrade')} to wear (u)`) : ''}`, header: true }];
588
+ const rows = [{ text: `${st.length}/${bagCapacity(a.ch, a.ds)} slots ${coinText(a.ch.gold)}${ups.size ? green(` ▲ ${plural(ups.size, 'upgrade')} to wear (u)`) : ''}`, header: true }];
410
589
  st.forEach((s, i) => {
411
590
  const it = a.ds.item(s.item);
412
591
  const name = qualityPaint(it?.quality)(it?.name ?? `item ${s.item}`);
@@ -414,6 +593,7 @@ function bagsView(app) {
414
593
  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
594
  rows.push({
416
595
  text: `${name}${s.count > 1 ? ` x${s.count}` : ''}${tag}${ups.has(s.item) ? green(' ▲') : ''}`, data: s,
596
+ icon: it?.icon, sub: dim(`${it?.typeName ?? ''}${it?.sellPrice ? ` sells ${coinText(it.sellPrice * s.count)}` : ''}`),
417
597
  detail: () => itemLines(a, it, [
418
598
  '', dim(`Enter: ${verb ? verb.toLowerCase() : 'more'} m: more${s.count > 1 ? ' /: split' : ''} [ ]: move d: destroy`),
419
599
  ...(a.vendor ? [dim(`s: sell to ${a.vendor.name}`)] : []),
@@ -433,9 +613,19 @@ function wornItem(app, slot) {
433
613
  return entry == null ? null : app.ds.item(entry) ?? null;
434
614
  }
435
615
 
436
- /** The engine's equipment helper: what in your bags (and the bank, when it is open) beats what you wear. */
616
+ /** The engine's equipment helper: what in your bags, or unbound in a companion's (a crafter's product), beats what you wear. */
437
617
  function upgradeList(app) {
438
- return app.g.suggestedEquipmentUpgrades().filter((u) => !u.holderId);
618
+ return app.g.suggestedEquipmentUpgrades();
619
+ }
620
+
621
+ /** Companions you own, squad first, each once. */
622
+ const ownedCompanions = (g) => [...new Map([...(g.state.squad ?? []), ...Object.values(g.state.botRoster ?? {})].map((c) => [c.id, c])).values()];
623
+
624
+ /** Wear `entry` in `slot`, first taking one unbound copy from companion `holderId` when it is theirs. */
625
+ function equipFrom(a, entry, slot, holderId) {
626
+ if (holderId && !a.result(a.g.takeItemFromBot(holderId, entry, 1), '')) return false;
627
+ const r = a.g.equip(entry, slot);
628
+ return a.result(r, r.ok ? `You equip ${a.ds.item(entry)?.name ?? entry}.` : '');
439
629
  }
440
630
 
441
631
  /** Lines comparing a candidate with the item worn in `slot`: armor, damage and stat deltas. */
@@ -454,47 +644,72 @@ function compareLines(app, it, worn) {
454
644
  return out;
455
645
  }
456
646
 
457
- function gearView(app) {
647
+ /** The Character tab: the paperdoll (your gear around your body), your numbers under the tooltip, s for the rest. */
648
+ function characterView(app) {
458
649
  return {
459
- id: 'gear', title: 'Equipment', hint: 'Enter: change what you wear',
650
+ id: 'gear', title: 'Character', hint: 'Enter: change what you wear',
651
+ detailFoot: (a, w) => statPanel(a, w),
460
652
  keys: {
653
+ s: { hint: 'stats', fn: (a) => a.push(characterDetailsDialog(a)) },
461
654
  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();
655
+ if (!upgradeList(a).length) { a.flashMsg("Nothing in your bags or your companions' beats what you wear."); return; }
656
+ // Best first, re-scored after each piece (two rings, a two-hander). Works in suggest mode too,
657
+ // where autoEquipUpgrades stands down. Each entry is tried once, so displaced pieces cannot ping-pong.
658
+ let n = 0;
659
+ const tried = new Set();
660
+ for (let i = 0; i < 16; i++) {
661
+ const u = upgradeList(a).find((x) => !tried.has(x.entry));
662
+ if (!u) break;
663
+ tried.add(u.entry);
664
+ if (u.holderId && !a.g.takeItemFromBot(u.holderId, u.entry, 1).ok) continue;
665
+ if (a.g.equip(u.entry, u.slot).ok) n++;
666
+ }
464
667
  a.say(n ? green(`You put on ${plural(n, 'upgrade')}.`) : red('The upgrades could not be equipped.'));
465
668
  } },
466
669
  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
670
  r: { hint: 'repair', fn: (a, row) => { if (row?.data?.it) repairOne(a, row.data.slot); } },
671
+ R: { hint: 'repair all', fn: (a) => repairAll(a) },
468
672
  o: { hint: 'outfits', fn: (a) => a.push(outfitterDialog(a)) },
469
673
  },
674
+ // Drawn as the character sheet (tuiPaperdoll.mjs) when colour is on and the window has room.
675
+ grid: (a, rows, w, h) => paperdollGrid(a, rows, w, h, (cols, lines) => playerArt(a, cols, lines)?.lines ?? null, characterTitle(a)),
470
676
  rows(a) {
471
677
  const rows = [];
472
678
  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 });
679
+ const extras = gearExtraRows(a);
680
+ const sheet = paperdollPlan(a, extras.filter((r) => !r.header).length);
681
+ if (!sheet) rows.push({ text: `${characterTitle(a)} ${dim('s: stats, skills, auras, reputation')}`, header: true, plain: true }, { text: '', header: true });
682
+ if (ups.length && !sheet) {
683
+ rows.push({ text: `${green('▲')} Better gear for you (${ups.length}) ${dim('Enter: put on u: put on all')}`, header: true });
475
684
  for (const u of ups) {
476
685
  const it = a.ds.item(u.entry), cur = u.currentEntry != null ? a.ds.item(u.currentEntry) : null;
477
686
  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(', ')}`) : ''}`,
687
+ 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
688
  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}.` : ''); },
689
+ detail: () => itemLines(a, it, [...compareLines(a, it, cur), ...(u.holderId ? ['', dim(`In ${u.holderName}'s bags. Enter takes it and puts it on.`)] : [])]),
690
+ act: () => equipFrom(a, u.entry, u.slot, u.holderId),
482
691
  });
483
692
  }
484
693
  rows.push({ text: '', header: true });
485
694
  }
486
- rows.push({ text: `What you wear ${dim('Enter: pick from bags' + (cityAccess(a).bank ? ' and bank' : '') + ' x: take off')}`, header: true });
695
+ 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
696
  for (const slot of [...EQUIP_SLOTS, ...BAG_SLOTS]) {
488
697
  const it = wornItem(a, slot);
489
698
  const up = ups.some((u) => u.slot === slot);
490
699
  const dur = it ? durabilityText(a, slot) : '';
700
+ const kind = it ? [it.typeName, it.armor ? `${it.armor} armor` : '', it.weapon ? `${it.weapon.dmgMin}-${it.weapon.dmgMax}` : ''].filter(Boolean).join(' ') : '';
701
+ // The best piece waiting for this slot, said in the tooltip (the sheet has no upgrade rows).
702
+ const best = ups.filter((u) => u.slot === slot).sort((x, y) => y.delta - x.delta)[0];
703
+ 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
704
  rows.push({
492
705
  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')]),
706
+ icon: it?.icon, sub: it ? dim(`${fit('', 10)} ${kind}`) : undefined,
707
+ cell: { slot, it, up, dur, sub: dim(kind) },
708
+ 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
709
  act: () => a.push(slotDialog(a, slot)),
495
710
  });
496
711
  }
497
- rows.push(...gearExtraRows(a));
712
+ rows.push(...(sheet ? extras.filter((r) => !r.header).slice(0, sheet.extras) : extras));
498
713
  return rows;
499
714
  },
500
715
  };
@@ -513,19 +728,18 @@ function slotDialog(app, slot) {
513
728
  const ups = new Map(upgradeList(a).filter((u) => u.slot === slot).map((u) => [u.entry, u]));
514
729
  const rows = [{ text: `${bold(label)}: ${worn ? qualityPaint(worn.quality)(worn.name) : dim('empty')}`, header: true }, { text: '', header: true }];
515
730
  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) => {
731
+ const row = (s, from, comp) => {
517
732
  const it = ds.item(s.item);
518
733
  const up = ups.get(s.item);
519
734
  const low = it.requiredLevel > ch.level;
520
735
  const name = qualityPaint(it.quality)(it.name);
521
736
  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') : ''}`,
523
- 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')]),
737
+ 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}`) : ''}`,
738
+ data: { s, it }, icon: it.icon, sub: dim([it.typeName, it.armor ? `${it.armor} armor` : '', it.weapon ? `${it.weapon.dmgMin}-${it.weapon.dmgMax}` : ''].filter(Boolean).join(' ')),
739
+ 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
740
  act: () => {
526
741
  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();
742
+ if (equipFrom(a, s.item, slot, comp?.id)) a.pop();
529
743
  },
530
744
  };
531
745
  };
@@ -539,30 +753,86 @@ function slotDialog(app, slot) {
539
753
  if (!bank.length) rows.push({ text: dim(' Nothing in the bank fits either.'), header: true });
540
754
  for (const s of bank) rows.push(row(s, 'bank'));
541
755
  }
756
+ // Unbound pieces a companion carries (a crafter's product, a BoE they looted) can come straight over.
757
+ const held = ownedCompanions(a.g).flatMap((c) => (c.bags?.stacks ?? []).filter((s) => fits(s) && !stackBoundToHolder(s, ds)).map((s) => ({ s, c })));
758
+ if (held.length) {
759
+ rows.push({ text: '', header: true }, { text: `In your companions' bags (${held.length})`, header: true });
760
+ for (const { s, c } of held) rows.push(row(s, 'companion', c));
761
+ }
542
762
  return rows;
543
763
  },
544
764
  };
545
765
  }
546
766
 
767
+ /**
768
+ * The spellbook's tabs, as the client draws them: General, then one per talent tree. A spell goes on the tab
769
+ * of its class skill line (SkillLineAbility, baked as combatSkillMeta.spellAbilities); the line is matched to
770
+ * its tree by name, since the trees carry no skill id ("Shadow" is Shadow Magic, "Elemental" is Elemental
771
+ * Combat). Everything else (Attack, racials, Poisons, Beast Training) is General.
772
+ */
773
+ function spellbookTabs(ch, ds) {
774
+ const meta = ds.combatSkillMeta(), bit = 1 << (ch.cls - 1);
775
+ const trees = ds.talentTrees(ch.cls).map((t) => t.name);
776
+ const treeOfSkill = (skillId) => {
777
+ if (meta?.categories?.[skillId] !== 7) return -1;
778
+ const n = (meta.skillNames?.[skillId] ?? '').toLowerCase();
779
+ return trees.findIndex((t) => n === t.toLowerCase() || n.startsWith(`${t.toLowerCase()} `));
780
+ };
781
+ const tabOf = (sp) => {
782
+ for (const r of meta?.spellAbilities?.[sp.id] ?? []) {
783
+ if (r.classMask && !(r.classMask & bit)) continue;
784
+ const k = treeOfSkill(r.skillId);
785
+ if (k >= 0) return k + 1;
786
+ }
787
+ return 0;
788
+ };
789
+ return { names: ['General', ...trees], tabOf };
790
+ }
791
+
547
792
  function spellsView(app) {
548
- return {
549
- id: 'spells', title: 'Spellbook', hint: 'Enter: cast',
550
- keys: { b: { hint: 'action bars', fn: (a) => a.push(barsDialog(a, playerBarsOwner)) }, m: { hint: 'macros', fn: (a) => a.push(macrosDialog(a)) } },
793
+ // Until a tab is picked the book opens on the first one with spells (a warrior's General can be empty).
794
+ let book = null;
795
+ const turn = (a, d) => { a.rowsOf(view); const n = spellbookTabs(a.ch, a.ds).names.length; book = (book + d + n) % n; view.scroll = 0; };
796
+ const view = {
797
+ id: 'spells', title: 'Spellbook', hint: 'Enter: cast [ ]: tab',
798
+ keys: {
799
+ b: { hint: 'action bars', fn: (a) => a.push(barsDialog(a, playerBarsOwner)) }, m: { hint: 'macros', fn: (a) => a.push(macrosDialog(a)) },
800
+ '[': { fn: (a) => turn(a, -1) }, ']': { fn: (a) => turn(a, +1) },
801
+ },
802
+ // Left Right on the tab strip turn the book's tabs; anywhere else they still change the window's tabs.
803
+ onKey(a, k, row) {
804
+ if (!row?.data?.strip || (k !== 'left' && k !== 'right')) return false;
805
+ turn(a, k === 'right' ? +1 : -1);
806
+ return true;
807
+ },
551
808
  rows(a) {
552
- const known = knownUsableSpells(a.ch, a.g.state.spellbook, a.ds).sort((x, y) => x.name.localeCompare(y.name));
809
+ const tabs = spellbookTabs(a.ch, a.ds);
810
+ const all = knownUsableSpells(a.ch, a.g.state.spellbook, a.ds);
811
+ const counts = tabs.names.map((_, k) => all.filter((sp) => tabs.tabOf(sp) === k).length);
812
+ book = book == null ? Math.max(0, counts.findIndex((n) => n > 0)) : Math.min(book, tabs.names.length - 1);
813
+ const known = all.filter((sp) => tabs.tabOf(sp) === book).sort((x, y) => x.name.localeCompare(y.name));
814
+ const L = a.layout(), width = (L.detailW ? L.listW : L.W) - 2;
553
815
  const head = [{ text: `Action bars ${dim(`${a.g.state.actionBars?.length ?? 0} bars b`)}`, detail: () => [bold('Action bars'), '', 'Bar 1 is the number row in a fight: put spells and bag items on it and bind keys.'], act: () => a.push(barsDialog(a, playerBarsOwner)) },
554
816
  { text: `Macros ${dim(`${a.g.macros().length} m`)}`, detail: () => [bold('Macros'), '', 'Several casts, uses or gear swaps on one key, with conditions. Out of a fight r runs one; in a fight its key on bar 1 fires it.'], act: () => a.push(macrosDialog(a)) },
555
817
  { text: '', header: true }];
818
+ head.push({
819
+ text: tabStrip(tabs.names.map((n, k) => `${n} ${counts[k]}`), book, width - 1, view.cursor === head.length), data: { strip: true },
820
+ detail: () => [bold('Spellbook tabs'), '', 'General, then one tab per talent tree, the way the trainer files them.', '', dim('Left Right here, or [ ] anywhere: turn the tabs'), dim('Down: the spells')],
821
+ act: (a2) => a2.moveCursor(view, a2.rowsOf(view), +1),
822
+ }, { text: '', header: true });
823
+ if (!known.length) head.push({ text: dim('No spells on this tab yet.'), header: true });
556
824
  return [...head, ...known.map((sp) => {
557
825
  const cd = a.g.spellCooldownRemaining(sp.id);
558
826
  return {
559
827
  text: `${sp.name}${sp.rank ? dim(` (Rank ${sp.rank})`) : ''}${sp.cost ? dim(` ${sp.cost} ${sp.powerType}`) : ''}${cd > 0 ? red(` ${secondsText(cd)}`) : ''}`,
828
+ 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
829
  detail: () => spellLines(a, sp),
561
830
  act: () => castWithPicker(a, sp.id),
562
831
  };
563
832
  })];
564
833
  },
565
834
  };
835
+ return view;
566
836
  }
567
837
 
568
838
  // ---- rotation ------------------------------------------------------------------------------------
@@ -575,6 +845,11 @@ function rotNames(app) {
575
845
  item: (id) => app.itemName(id),
576
846
  creature: (e) => app.ds.creature(e)?.name,
577
847
  object: (e) => app.ds.gameObject?.(e)?.name,
848
+ seat: (unit) => {
849
+ if (unit === 'player') return app.g.state.char.name || 'You';
850
+ const i = allyIndexOfSide(unit);
851
+ return (i >= 0 ? (app.g.state.squad ?? [])[i]?.name : undefined) ?? unit;
852
+ },
578
853
  };
579
854
  }
580
855
 
@@ -587,6 +862,7 @@ function rotOwner(app) {
587
862
  return {
588
863
  name: 'your', comp: null, get: () => g.rotation(), set: (r) => g.setRotation(r), reset: () => g.resetRotation(),
589
864
  spells: () => knownUsableSpells(app.ch, g.state.spellbook, app.ds), bag: () => g.state.inventory.stacks,
865
+ char: () => app.ch, spellbook: () => g.state.spellbook, equipped: () => app.ch.equipped,
590
866
  profiles: () => g.rotationProfiles(), add: (o) => g.addRotationProfile(o), rename: (pid, n) => g.renameRotationProfile(pid, n),
591
867
  del: (pid) => g.deleteRotationProfile(pid), select: (pid) => g.selectRotationProfile(pid),
592
868
  };
@@ -595,6 +871,7 @@ function rotOwner(app) {
595
871
  return {
596
872
  name: `${comp.name}'s`, comp, get: () => g.companionRotation(comp.id), set: (r) => g.setCompanionRotation(comp.id, r), reset: () => g.resetCompanionRotation(comp.id),
597
873
  spells: () => knownUsableSpells(companionCharacter(comp, lvl, app.ds), companionSpellbook(comp, lvl, app.ds), app.ds), bag: () => comp.bags?.stacks ?? [],
874
+ char: () => companionCharacter(comp, lvl, app.ds), spellbook: () => companionSpellbook(comp, lvl, app.ds), equipped: () => comp.equipped,
598
875
  profiles: () => g.companionRotationProfiles(comp.id), add: (o) => g.addCompanionRotationProfile(comp.id, o), rename: (pid, n) => g.renameCompanionRotationProfile(comp.id, pid, n),
599
876
  del: (pid) => g.deleteCompanionRotationProfile(comp.id, pid), select: (pid) => g.selectCompanionRotationProfile(comp.id, pid),
600
877
  };
@@ -645,8 +922,10 @@ function profileMenu(app) {
645
922
 
646
923
  function rotationView(app) {
647
924
  const rebuild = (a) => a.confirm('Rebuild the recommended rotation? The edits are lost.', () => { rotOwner(a).reset(); a.say(green('Rotation rebuilt.')); });
648
- return {
925
+ const view = {
649
926
  id: 'rotation', title: 'Rotation', hint: 'Enter: open',
927
+ // The phases Enter has opened; all start closed so the tab fits a screen.
928
+ open: new Set(),
650
929
  keys: {
651
930
  w: { hint: 'whose', fn: (a) => pickRotationOwner(a) },
652
931
  p: { hint: 'profiles', fn: (a) => profileMenu(a) },
@@ -660,6 +939,7 @@ function rotationView(app) {
660
939
  d: { fn: (a, row) => row?.data?.rule && a.confirm('Delete this rule?', () => deleteRule(a, row.data)) },
661
940
  s: { hint: 'spec', fn: (a) => { if (!rotOwner(a).comp) pickSpec(a); } },
662
941
  r: { hint: 'rebuild', fn: rebuild },
942
+ e: { hint: 'open all', fn: (a) => { const all = PHASES.filter((ph) => (rotOwner(a).get().phases[ph] ?? []).length || ph !== 'prebuff'); if (all.every((ph) => view.open.has(ph))) view.open.clear(); else all.forEach((ph) => view.open.add(ph)); } },
663
943
  },
664
944
  rows(a) {
665
945
  const g = a.g, o = rotOwner(a), rot = o.get(), names = rotNames(a);
@@ -669,23 +949,37 @@ function rotationView(app) {
669
949
  { text: `${bold(o.comp ? `${o.comp.name}: ${spec}` : `${spec} rotation`)}${rot.custom ? dim(' (edited)') : dim(' (recommended)')}${!o.comp && g.rotationNeedsRefresh() ? yellow(' a newer recommended list exists') : ''}`, header: true },
670
950
  ...((g.state.squad ?? []).length ? [{ text: `Whose: ${cyan(o.comp ? o.comp.name : 'You')} ${dim('w')}`, detail: () => [bold('Whose rotation'), '', "Your own, or a seated companion's: the same rules drive them in every fight."], act: () => pickRotationOwner(a) }] : []),
671
951
  { text: `Profile: ${cyan(prof?.name ?? 'Default')} ${dim(`${o.profiles().length} saved p`)}`, detail: () => [bold('Rotation profiles'), '', 'Keep several named rotations and switch which one plays.', ...o.profiles().map((p) => `${p.active ? green('●') : dim('·')} ${p.name}`)], act: () => profileMenu(a) },
672
- ...wrapLines('What auto-play casts for you. Each tick the rules are read top to bottom and the first one whose conditions hold fires. ✓ is on, · is off. Enter on a rule opens its menu.', a.layout().listW - 2).map((t) => ({ text: dim(t), header: true })),
952
+ ...wrapLines('What auto-play casts for you. Each tick the rules are read top to bottom and the first one whose conditions hold fires. ✓ is on, · is off. Enter on a phase opens it, Enter on a rule opens its menu.', a.layout().listW - 2).map((t) => ({ text: dim(t), header: true })),
673
953
  ];
674
954
  for (const phase of PHASES) {
675
955
  const rules = rot.phases[phase] ?? [];
676
956
  if (!rules.length && phase === 'prebuff') continue;
677
- rows.push({ text: '', header: true }, { text: PHASE_LABEL[phase], header: true });
957
+ // A phase is one row until Enter opens it; closed, it previews the first few spells it casts.
958
+ const open = view.open.has(phase), on = rules.filter((r) => r.enabled);
959
+ const [title, gloss = ''] = PHASE_LABEL[phase].split(/ (?=\()/);
960
+ const preview = on.slice(0, 4).map((r) => { const id = r.action?.spellId ?? r.before?.spellId; return id ? names.spell(id) : ruleSentence(r, names); }).join(', ') + (on.length > 4 ? ', …' : '');
961
+ rows.push({ text: '', header: true }, {
962
+ text: `${open ? '▾' : '▸'} ${bold(title)} ${dim(gloss)} ${cyan(plural(rules.length, 'rule'))}${!open && preview ? dim(` ${preview}`) : ''}`,
963
+ data: { phase, toggle: true },
964
+ detail: () => [bold(PHASE_LABEL[phase]), '', ...(rules.length ? rules.map((r, i) => `${dim(`${i + 1}.`)} ${r.enabled ? green('✓') : dim('·')} ${ruleSentence(r, names)}`) : [dim('No rules yet.')]), '', dim(open ? 'Enter: close a: add a rule' : 'Enter: open to see and edit the rules a: add a rule')],
965
+ act: () => { if (open) view.open.delete(phase); else view.open.add(phase); },
966
+ });
967
+ if (!open) continue;
678
968
  rules.forEach((rule, index) => {
679
969
  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}`) : ''}`;
970
+ // With icons a rule is two lines and the condition takes the second; without, it trails the sentence.
971
+ const icon = iconsOn() ? a.ds.spell(rule.action?.spellId ?? rule.before?.spellId)?.icon : undefined;
972
+ const when = conds !== 'always' ? dim(`when ${conds}`) : '';
973
+ const text = ` ${rule.enabled ? green('✓') : dim('·')} ${rule.enabled ? ruleSentence(rule, names) : dim(ruleSentence(rule, names))}${!icon && when ? ` ${when}` : ''}`;
681
974
  rows.push({
682
975
  text, data: { phase, index, rule },
976
+ icon, sub: when,
683
977
  detail: () => ruleLines(a, rule, names),
684
978
  act: () => ruleMenu(a, { phase, index, rule }),
685
979
  });
686
980
  });
687
981
  rows.push({
688
- text: dim(`+ Add a rule to ${PHASE_LABEL[phase].split(' (')[0].toLowerCase()}`), data: { phase },
982
+ text: dim(` + Add a rule to ${PHASE_LABEL[phase].split(' (')[0].toLowerCase()}`), data: { phase },
689
983
  detail: () => [bold(PHASE_LABEL[phase]), '', dim('Enter picks a spell or a combat item to add at the end of this list. Afterwards you can say when it fires.')],
690
984
  act: () => addRule(a, phase),
691
985
  });
@@ -704,6 +998,7 @@ function rotationView(app) {
704
998
  return rows;
705
999
  },
706
1000
  };
1001
+ return view;
707
1002
  }
708
1003
 
709
1004
  /** Enter on a rule: everything you can do with it, in one list. */
@@ -712,7 +1007,7 @@ function ruleMenu(app, data) {
712
1007
  const names = rotNames(app);
713
1008
  const options = [
714
1009
  { 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' },
1010
+ { 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
1011
  { label: 'Move up', detail: ['Earlier rules are tried first.'], value: 'up' },
717
1012
  { label: 'Move down', detail: ['Later rules only fire when everything above them is unusable or gated off.'], value: 'down' },
718
1013
  { label: 'Add a rule after this one', value: 'add' },
@@ -766,10 +1061,21 @@ function addRule(app, phase, afterIndex) {
766
1061
  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
1062
  const combatItems = [...new Set(o.bag().map((s) => s.item))].map((e) => app.ds.item(e)).filter((it) => it?.useInCombat);
768
1063
  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) => {
1064
+ app.pick(`Add to ${PHASE_LABEL[phase].split(' (')[0].toLowerCase()}: what to do`, options, (picked) => {
770
1065
  const id = `tui-${Date.now().toString(36)}-${Math.floor(Math.random() * 1e6).toString(36)}`;
1066
+ // A new rule starts with the engine's smart default for what it does: a seal or buff only when it is
1067
+ // missing, a DoT when the enemy lacks it, a form when not in it, an interrupt when the enemy casts, a
1068
+ // potion below a health band. The same gate the recommended list would give it; c changes it.
1069
+ let action = picked, conditions = [];
1070
+ if (picked.kind === 'spell') {
1071
+ const sp = app.ds.spell(picked.spellId);
1072
+ if (sp) conditions = defaultConditionsForSpell(sp, o.char());
1073
+ } else if (picked.kind === 'item') {
1074
+ ({ action, conditions } = defaultItemRule(app, o, picked.itemEntry));
1075
+ }
1076
+ app.tabs[5].view.open.add(phase);
771
1077
  editRotation(app, (rot) => {
772
- const rule = { id, action, conditions: [], enabled: true };
1078
+ const rule = { id, action, conditions, enabled: true };
773
1079
  const list = rot.phases[phase] ??= [];
774
1080
  list.splice(afterIndex == null ? list.length : afterIndex + 1, 0, rule);
775
1081
  });
@@ -777,39 +1083,14 @@ function addRule(app, phase, afterIndex) {
777
1083
  const view = app.tabs[5].view;
778
1084
  const at = app.rowsOf(view).findIndex((r) => r.data?.rule?.id === id);
779
1085
  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.`));
1086
+ const when = conditions.length ? `only when ${conditionsSentence(conditions, rotNames(app))}` : 'with no conditions';
1087
+ app.say(green(`Rule added to ${phase}, ${when}. Enter on it to change when it fires.`));
781
1088
  });
782
1089
  }
783
1090
 
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
- });
1091
+ /** Enter on a rule, "Change when it fires": the full condition editor (tuiRotationCond.mjs). */
1092
+ function editCondition(app, { phase, index }) {
1093
+ conditionEditor(app, { phase, index, owner: rotOwner(app), names: rotNames(app), mutate: (fn) => editRotation(app, fn) });
813
1094
  }
814
1095
 
815
1096
  function pickSpec(app) {
@@ -823,14 +1104,27 @@ function pickSpec(app) {
823
1104
  function talentsView(app) {
824
1105
  return {
825
1106
  id: 'talents', title: 'Talents', hint: 'Enter: spend a point',
1107
+ // Drawn as the three trees (tuiTalentTree.mjs) when colour is on and they fit; a narrower tooltip makes room.
1108
+ detailShare: 0.34,
1109
+ grid: (a, rows, w, h) => talentGrid(a, rows, w, h),
826
1110
  keys: {
827
1111
  u: { hint: 'refund', fn: (a, row) => row?.data && a.result(a.g.refundTalent(row.data.id), `Refunded a point from ${row.data.name}.`) },
828
- 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.')); } },
1112
+ R: { hint: 'respec', fn: (a) => { const cost = a.g.respecCost(); a.confirm(`Reset all talents for ${coinText(cost, 'free')}?`, () => a.result(a.g.respec(), 'Your talents are reset.')); } },
829
1113
  B: { hint: 'builds', fn: (a) => talentBuildsMenu(a) },
830
1114
  },
831
1115
  rows(a) {
832
1116
  const ch = a.ch;
833
1117
  const free = availablePoints(ch);
1118
+ if (talentPlan(a)) {
1119
+ return a.ds.talentTrees(ch.cls).flatMap((tree, k) => [...tree.talents].sort((x, y) => x.tier - y.tier || x.col - y.col).map((t) => {
1120
+ const rank = ch.talents?.[t.id] ?? 0;
1121
+ return {
1122
+ text: t.name, data: t, node: { tree: k, t, rank, tone: talentTone(ch, t, a.ds), icon: talentIcon(a.ds, t) },
1123
+ detail: () => talentLines(a, t, rank, canSpend(ch, t.id, a.ds)),
1124
+ act: () => a.result(a.g.spendTalent(t.id), `You learn ${t.name} (${(ch.talents?.[t.id] ?? 0) + 1}/${t.maxRank}).`),
1125
+ };
1126
+ }));
1127
+ }
834
1128
  const rows = [{ text: ch.level < 10 ? dim('Talents unlock at level 10.') : `${plural(free, 'point')} to spend`, header: true }];
835
1129
  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
1130
  for (const tree of a.ds.talentTrees(ch.cls)) {
@@ -842,6 +1136,7 @@ function talentsView(app) {
842
1136
  const paint = rank >= t.maxRank ? brightYellow : can.ok ? green : dim;
843
1137
  rows.push({
844
1138
  text: `${paint(t.name)} ${dim(`${rank}/${t.maxRank}`)}${t.prereq ? dim(` needs ${a.ds.talent(t.prereq.talentId)?.name ?? '?'}`) : ''}`, data: t,
1139
+ icon: talentIcon(a.ds, t), sub: dim(`tier ${t.tier + 1}`),
845
1140
  detail: () => talentLines(a, t, rank, can),
846
1141
  act: () => a.result(a.g.spendTalent(t.id), `You learn ${t.name} (${(ch.talents?.[t.id] ?? 0) + 1}/${t.maxRank}).`),
847
1142
  });
@@ -853,9 +1148,14 @@ function talentsView(app) {
853
1148
  }
854
1149
 
855
1150
  function talentLines(app, t, rank, can) {
1151
+ return withIcon(talentIcon(app.ds, t), talentLinesText(app, t, rank, can));
1152
+ }
1153
+
1154
+ function talentLinesText(app, t, rank, can) {
856
1155
  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));
1156
+ // The pane wraps the descriptions to its own width (beside the icon while they share its rows).
1157
+ if (rank > 0) out.push('', cyan('Current rank'), t.ranks[rank - 1]?.description ?? '');
1158
+ if (rank < t.maxRank) out.push('', cyan(rank ? 'Next rank' : 'Rank 1'), t.ranks[rank]?.description ?? '');
859
1159
  out.push('', can.ok ? green('Enter to spend a point') : red(can.reason ?? 'Cannot spend here yet'));
860
1160
  if (rank > 0 && canRefund(app.ch, t.id, app.ds).ok) out.push(dim('u refunds a point'));
861
1161
  return out;
@@ -866,27 +1166,40 @@ function talentLines(app, t, rank, can) {
866
1166
  function travelView(app) {
867
1167
  return {
868
1168
  id: 'travel', title: 'Travel', hint: 'Enter: go',
1169
+ // A row without its own detail (a teleport, a portal) still shows the world map with you on it.
1170
+ detail(a) { return withWorldMap(a, [bold(a.zone()?.name ?? a.ch.zoneId), dim('The world, and where you stand in it.')], {}); },
869
1171
  rows(a) {
870
1172
  const g = a.g, ds = a.ds, z = a.zone(), ch = a.ch;
871
- const rows = [...journeyRows(a), { text: `You are in ${cyan(z?.name ?? ch.zoneId)}`, header: true }, { text: '', header: true }, { text: 'On foot', header: true }];
872
- 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) }); }
1173
+ const rows = [...journeyRows(a), { text: `You are in ${cyan(z?.name ?? ch.zoneId)}`, header: true }];
1174
+ // A zone's neighbors are every travel edge out of it: a boat, a zeppelin or the tram is not a walk.
1175
+ const edgeTo = new Map(ds.travelEdges().filter((e) => e.a === ch.zoneId || e.b === ch.zoneId).map((e) => [e.a === ch.zoneId ? e.b : e.a, e]));
1176
+ const groups = [['On foot', ['ground', 'portal']], ['Boats and zeppelins', ['transport']], ['Deeprun Tram', ['tram']]];
1177
+ for (const [title, kinds] of groups) {
1178
+ const near = (z?.neighbors ?? []).filter((id) => kinds.includes(edgeTo.get(id)?.kind ?? 'ground')).map((id) => [ds.zone(id), edgeTo.get(id)]).filter(([d]) => d);
1179
+ if (!near.length) continue;
1180
+ rows.push({ text: '', header: true }, { text: title, header: true });
1181
+ for (const [d, e] of near) {
1182
+ const line = e?.kind === 'transport' ? ds.transports().find((t) => t.entry === e.transport)?.name : null;
1183
+ rows.push({ text: `${d.name} ${dim(`L${d.levelRange[0]}-${d.levelRange[1]}`)}${line ? dim(` ${line}`) : ''}`, detail: () => travelPreviewLines(a, d), act: () => travelTo(a, d) });
1184
+ }
1185
+ }
873
1186
  const taxi = g.taxiOptions();
874
1187
  if (taxi.length) {
875
1188
  rows.push({ text: '', header: true }, { text: 'Flight paths', header: true });
876
- for (const t of taxi) { const d = ds.zone(t.toZone); if (d) rows.push({ text: `${d.name} ${dim(`${secondsText(t.seconds * 1000)}, ${moneyText(t.priceCopper)}`)}`, detail: () => travelPreviewLines(a, d), act: () => travelTo(a, d) }); }
1189
+ for (const t of taxi) { const d = ds.zone(t.toZone); if (d) rows.push({ text: `${d.name} ${dim(`${secondsText(t.seconds * 1000)}, ${coinText(t.priceCopper)}`)}`, detail: () => travelPreviewLines(a, d), act: () => travelTo(a, d) }); }
877
1190
  }
878
1191
  rows.push({ text: '', header: true }, { text: 'Hearthstone', header: true });
879
1192
  const bind = ch.hearthBind;
880
1193
  const cd = Math.max(0, (ch.hearthCooldownUntil ?? 0) - Date.now());
881
1194
  rows.push({
882
1195
  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.'),
883
- detail: () => [bold('Hearthstone'), bind ? `Bound at ${bind.name ?? ds.zone(bind.zone)?.name}` : 'Not bound', cd > 0 ? red(`Ready in ${secondsText(cd)}`) : green('Ready')],
1196
+ detail: () => withWorldMap(a, [bold('Hearthstone'), bind ? `Bound at ${bind.name ?? ds.zone(bind.zone)?.name}` : 'Not bound', cd > 0 ? red(`Ready in ${secondsText(cd)}`) : green('Ready')], bind ? { dest: ds.zone(bind.zone), legs: [{ to: bind.zone }] } : {}),
884
1197
  act: () => { const r = g.castHearth(); if (a.result(r)) { a.vendor = null; afterDeparture(a, () => `You hearth to ${a.zone()?.name}.`); landOnTab(a); } },
885
1198
  });
886
1199
  const ports = g.classTravelOptions();
887
1200
  if (ports.length) {
888
1201
  rows.push({ text: '', header: true }, { text: 'Teleports', header: true });
889
- 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); } } });
1202
+ 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') : ''}`, detail: () => withWorldMap(a, [bold(p.name), dim(`to ${ds.zone(p.toZone)?.name ?? p.toZone}`)], { dest: ds.zone(p.toZone), legs: [{ to: p.toZone }] }), act: () => { const r = g.castTeleport(p.spellId); if (a.result(r)) { afterDeparture(a, () => `You teleport to ${a.zone()?.name}.`); landOnTab(a); } } });
890
1203
  }
891
1204
  rows.push(...portalRows(a));
892
1205
  return rows;
@@ -921,9 +1234,117 @@ function weaponSkillRows(a, L) {
921
1234
  return rows;
922
1235
  }
923
1236
 
924
- function charView(app) {
1237
+ // How tall a worn model stands against the body it replaces, which fills its box. Sprites are cropped to the
1238
+ // model, so a cat drawn into a person's box came out the size of a person. The client's collision heights are
1239
+ // one placeholder for most of these models, so this goes by shape: the druid's animals and Ghost Wolf stand on
1240
+ // four legs, a transform by its creature's type (a Polymorph sheep is a critter, a ninja costume a humanoid).
1241
+ const FORM_HEIGHT = {
1242
+ [SHAPESHIFT_FORM.CAT]: 0.6, [SHAPESHIFT_FORM.BEAR]: 0.7, [SHAPESHIFT_FORM.DIREBEAR]: 0.7,
1243
+ [SHAPESHIFT_FORM.TRAVEL]: 0.65, [SHAPESHIFT_FORM.AQUA]: 0.5, [SHAPESHIFT_FORM.GHOSTWOLF]: 0.6,
1244
+ };
1245
+ const TYPE_HEIGHT = { [CREATURE_TYPE.BEAST]: 0.6, [CREATURE_TYPE.CRITTER]: 0.35 };
1246
+ const typeByDisplay = new WeakMap(); // ds -> displayId -> the creature type of the first creature drawn with it
1247
+
1248
+ function displayType(ds, displayId) {
1249
+ let m = typeByDisplay.get(ds);
1250
+ if (!m) {
1251
+ m = new Map();
1252
+ for (const c of ds.creatures()) if (c.displayId && !m.has(c.displayId)) m.set(c.displayId, c.creatureType);
1253
+ typeByDisplay.set(ds, m);
1254
+ }
1255
+ return m.get(displayId);
1256
+ }
1257
+
1258
+ /**
1259
+ * The model `subject` (you, or a companion) wears right now, or null for its own body: { displayId, label,
1260
+ * height }, height the share of the box it stands in (FORM_HEIGHT). In a fight, `view` (its combat view) has
1261
+ * the live form and auras: a shift or a Polymorph lands there, not on the saved character, whose costumes are
1262
+ * still read under them.
1263
+ */
1264
+ export function wornNow(ds, subject, view) {
1265
+ const worn = wornModel(ds, subject, view);
1266
+ if (!worn) return null;
1267
+ const form = ((view ? view.form : subject.form) ?? 0) | 0;
1268
+ const height = form && worn.displayId === formDisplayId(form, factionOf(ds, subject.race))
1269
+ ? FORM_HEIGHT[form] ?? 1
1270
+ : TYPE_HEIGHT[displayType(ds, worn.displayId)] ?? 1;
1271
+ return { ...worn, height };
1272
+ }
1273
+
1274
+ /** A worn model (wornNow) as art in a box `maxCols` x `maxRows`, standing its height in it; null without art. */
1275
+ export function wornArt(worn, maxCols, maxRows) {
1276
+ return worn?.displayId ? creatureArt(worn.displayId, maxCols, Math.max(3, Math.round(maxRows * worn.height))) : null;
1277
+ }
1278
+
1279
+ function wornModel(ds, subject, view) {
1280
+ const now = Date.now();
1281
+ if (!view) return wornDisplay(subject, ds, now);
1282
+ // Polymorph and Hex bake as crowd control, the model swap left in their raw aura 56 row, which
1283
+ // wornDisplay reads past. Being turned into a sheep is the latest model change, so it wins.
1284
+ for (const d of view.debuffs ?? []) {
1285
+ const spell = d.spellId != null ? ds.spell(d.spellId) : null;
1286
+ const raw = spell?.rawEffects?.find((r) => (r.auraName | 0) === 56 && r.miscValue);
1287
+ const displayId = raw ? ds.creature(raw.miscValue | 0)?.displayId : 0;
1288
+ if (displayId) return { displayId, label: spell.name };
1289
+ }
1290
+ const live = [...(view.buffs ?? []), ...(view.debuffs ?? [])].filter((b) => b.spellId != null).map((b) => ({ spellId: b.spellId }));
1291
+ return wornDisplay({ ...subject, form: view.form ?? 0, activeAuras: [...live, ...(subject.activeAuras ?? [])] }, ds, now);
1292
+ }
1293
+
1294
+ /**
1295
+ * Your character as a sprite for a pane: the form or costume you wear when the creature pack has it, else
1296
+ * you in your gear (paperdoll.mjs), else your bare race and gender body. `view` is your combat view in a
1297
+ * fight (wornNow). `label` names the form when that is what shows; null without art.
1298
+ */
1299
+ export function playerArt(app, maxCols, maxRows, view = null) {
1300
+ const ch = app.ch;
1301
+ const worn = wornNow(app.ds, ch, view);
1302
+ const drawn = wornArt(worn, maxCols, maxRows);
1303
+ if (drawn) return { lines: drawn, label: worn.label };
1304
+ const geared = paperdollArt(ch, maxCols, maxRows);
1305
+ if (geared) return { lines: geared, label: null };
1306
+ const own = creatureArt(nativeDisplayId(ch.race, ch.gender), maxCols, maxRows);
1307
+ return own ? { lines: own, label: null } : null;
1308
+ }
1309
+
1310
+ /** Name, level, race and class: the Character tab's title line. */
1311
+ function characterTitle(a) {
1312
+ const ch = a.ch;
1313
+ return `${bold(ch.name)} ${dim(`level ${ch.level} ${raceName(ch.race)} ${className(ch.cls)}`)}`;
1314
+ }
1315
+
1316
+ /** Your numbers at the foot of the Character tab's tooltip pane: health, power, experience, money, then the
1317
+ * attributes beside the combat ratings, the way the client's stat frames sit under the paperdoll. */
1318
+ function statPanel(a, w) {
1319
+ const ch = a.ch, ds = a.ds;
1320
+ const st = effectiveStats(ch, ds);
1321
+ const pt = powerType(ch);
1322
+ const pct = (x) => `${((x ?? 0) * 100).toFixed(2)}%`;
1323
+ const combat = [
1324
+ ['Armor', effectiveArmor(ch, ds)], ['Attack pwr', attackPower(ch, ds)], ['Crit', pct(critChanceDisplay(ch, ds))],
1325
+ ['Hit', pct(hitChance(ch, ds))], ['Dodge', pct(dodgeChanceDisplay(ch, ds))],
1326
+ ];
1327
+ const attrs = Object.entries(STAT_LABEL).map(([k, name]) => `${fit(name, 10)}${String(st[k]).padStart(5)}`);
1328
+ return [
1329
+ dim('─'.repeat(Math.max(0, w))),
1330
+ `Health ${ch.health}/${maxHealth(ch, ds)} ${pt[0].toUpperCase() + pt.slice(1)} ${ch.power}/${maxPower(ch, ds)}`,
1331
+ `XP ${ch.xp}/${ds.xpToNext(ch.level)}${ch.restedXp ? dim(` (${ch.restedXp} rested)`) : ''} ${coinText(ch.gold)}`,
1332
+ '',
1333
+ ...attrs.map((t, i) => `${t} ${fit(combat[i][0], 10)} ${String(combat[i][1]).padStart(7)}`),
1334
+ ];
1335
+ }
1336
+
1337
+ /** s on the Character tab: the whole sheet in words: stats, weapon skills, professions, auras, home, pet, honor, reputation. */
1338
+ function characterDetailsDialog(app) {
925
1339
  return {
926
- id: 'char', title: 'Character', hint: '', noDetail: true,
1340
+ id: 'char', title: 'Stats', hint: 'Esc: back',
1341
+ /** The detail pane holds your character: the body of your race and gender, or the form you are in. */
1342
+ detail(a) {
1343
+ const L = a.layout(), ch = a.ch;
1344
+ const art = playerArt(a, L.detailW - 2, L.mainH - 2);
1345
+ if (!art) return [];
1346
+ return [`${bold(ch.name)} ${dim(`${raceName(ch.race)} ${className(ch.cls)}${art.label ? `, ${art.label}` : ''}`)}`, '', ...art.lines];
1347
+ },
927
1348
  rows(a) {
928
1349
  const ch = a.ch, ds = a.ds;
929
1350
  const st = effectiveStats(ch, ds);
@@ -933,7 +1354,7 @@ function charView(app) {
933
1354
  L(`${bold(ch.name)} level ${ch.level} ${className(ch.cls)}`),
934
1355
  L(''),
935
1356
  L(`Health ${ch.health}/${maxHealth(ch, ds)} ${pt[0].toUpperCase() + pt.slice(1)} ${ch.power}/${maxPower(ch, ds)}`),
936
- L(`Experience ${ch.xp}/${ds.xpToNext(ch.level)}${ch.restedXp ? dim(` (${ch.restedXp} rested)`) : ''} ${yellow(moneyText(ch.gold) || '0c')}`),
1357
+ L(`Experience ${ch.xp}/${ds.xpToNext(ch.level)}${ch.restedXp ? dim(` (${ch.restedXp} rested)`) : ''} ${coinText(ch.gold)}`),
937
1358
  L(''),
938
1359
  L(cyan('Attributes')),
939
1360
  ...Object.entries(STAT_LABEL).map(([k, name]) => L(` ${fit(name, 10)} ${st[k]}`)),
@@ -968,7 +1389,6 @@ function charView(app) {
968
1389
  if (hasPetClass(a)) rows.push(L(''), { text: `Pet: ${ch.pet ? ch.pet.name : dim('none out')} ${dim('Enter: the pet page')}`, act: () => a.push(petDialog(a)) });
969
1390
  rows.push(L(''), { text: `Honor ${dim(`${honorRankLine(a)} (:honor)`)}`, act: () => a.push(honorDialog(a)) });
970
1391
  rows.push({ text: `Reputation ${dim(`${a.ch.watchedFaction ? `watching ${ds.faction(a.ch.watchedFaction)?.name ?? ''} ` : ''}(:rep)`)}`, act: () => a.push(reputationDialog(a)) });
971
- rows.push({ text: `Settings ${dim('loot, upgrades, disenchanting, junk, gathering (:settings)')}`, act: () => a.push(settingsDialog(a)) });
972
1392
  return rows;
973
1393
  },
974
1394
  };
@@ -989,14 +1409,14 @@ export function sellStack(app, slot) {
989
1409
  const s = app.g.state.inventory.stacks[slot];
990
1410
  if (!s) return;
991
1411
  const r = app.g.sellItemStack(slot, s.count);
992
- if (app.result(r, r.ok ? `Sold ${app.itemLabel(s.item)}${r.qty > 1 ? ` x${r.qty}` : ''} for ${moneyText(r.earned)}.` : '')) noteSale(app, s.item, r.qty, r.earned);
1412
+ if (app.result(r, r.ok ? `Sold ${app.itemLabel(s.item)}${r.qty > 1 ? ` x${r.qty}` : ''} for ${coinText(r.earned)}.` : '')) noteSale(app, s.item, r.qty, r.earned);
993
1413
  }
994
1414
 
995
1415
  /** Sell every junk-filter match, each onto the buyback list. */
996
1416
  export function sellJunk(app) {
997
1417
  const preview = app.g.junkSellPreview();
998
1418
  const r = app.g.sellAllJunk();
999
- if (!app.result(r, r.ok ? `Sold ${plural(r.count, 'junk item')} for ${moneyText(r.earned)}.` : '')) return;
1419
+ if (!app.result(r, r.ok ? `Sold ${plural(r.count, 'junk item')} for ${coinText(r.earned)}.` : '')) return;
1000
1420
  for (const p of preview) noteSale(app, p.entry, p.count, p.unitSell * p.count);
1001
1421
  }
1002
1422
 
@@ -1012,7 +1432,7 @@ function buyBackAt(app, i) {
1012
1432
  const r = buyBackDomain(app.g.state.char, app.g.state.inventory, list, i, app.ds.item(e.entry)?.stackable, bagCapacity(app.ch, app.ds));
1013
1433
  if (!r.ok) { app.say(red(r.reason ?? 'Cannot buy that back.')); return; }
1014
1434
  app.g.state.char = r.char; app.g.state.inventory = r.inventory; app.buyback = r.buyback;
1015
- app.say(green(`You buy back ${app.itemLabel(e.entry)}${e.qty > 1 ? ` x${e.qty}` : ''} for ${moneyText(e.refundCopper)}.`));
1435
+ app.say(green(`You buy back ${app.itemLabel(e.entry)}${e.qty > 1 ? ` x${e.qty}` : ''} for ${coinText(e.refundCopper)}.`));
1016
1436
  }
1017
1437
 
1018
1438
  export function vendorDialog(app, vendor) {
@@ -1028,7 +1448,7 @@ export function vendorDialog(app, vendor) {
1028
1448
  const comp = row?.data?.comp;
1029
1449
  if (!comp) return;
1030
1450
  const r = a.g.sellCompanionJunk(comp.id);
1031
- if (a.result(r, r.ok ? `Sold ${plural(r.count, 'junk item')} of ${comp.name}'s for ${moneyText(r.earned)}.` : '')) for (const x of r.sold) noteSale(a, x.entry, x.qty, x.refundCopper, comp);
1451
+ if (a.result(r, r.ok ? `Sold ${plural(r.count, 'junk item')} of ${comp.name}'s for ${coinText(r.earned)}.` : '')) for (const x of r.sold) noteSale(a, x.entry, x.qty, x.refundCopper, comp);
1032
1452
  } },
1033
1453
  d: { hint: 'destroy', fn: (a, row) => {
1034
1454
  if (mode === 'sell' && row?.data) { const i = a.g.state.inventory.stacks.indexOf(row.data); if (i >= 0) destroyStack(a, i); return; }
@@ -1038,7 +1458,7 @@ export function vendorDialog(app, vendor) {
1038
1458
  const name = a.itemLabel(row.data.s.item);
1039
1459
  a.confirm(`Destroy ${name} from ${comp.name}'s bags?`, () => a.result(a.g.discardCompanionBagStack(comp.id, slot), `Destroyed ${name}.`));
1040
1460
  } },
1041
- ...(vendor.canRepair ? { r: { hint: 'repair', fn: (a) => { const r = a.g.repairAll(vendor.entry); a.result(r, r.ok ? (r.spent ? `Repaired for ${moneyText(r.spent)}.` : 'Nothing needs repair.') : ''); } } } : {}),
1461
+ ...(vendor.canRepair ? { r: { hint: 'repair', fn: (a) => { const r = a.g.repairAll(vendor.entry); a.result(r, r.ok ? (r.spent ? `Repaired for ${coinText(r.spent)}.` : 'Nothing needs repair.') : ''); } } } : {}),
1042
1462
  },
1043
1463
  onKey(a, k) {
1044
1464
  if (k !== 'tab' && k !== 'shift-tab') return false;
@@ -1050,7 +1470,7 @@ export function vendorDialog(app, vendor) {
1050
1470
  onClose(a) { /* the vendor stays "open" for :buy / s in bags until you travel */ },
1051
1471
  rows(a) {
1052
1472
  const tabs = `${bold(VENDOR_MODE_LABEL[mode])} ${dim(`${VENDOR_MODES.indexOf(mode) + 1}/${VENDOR_MODES.length}`)}`;
1053
- const rows = [{ text: `${bold(vendor.name)} ${dim(`<${vendor.subname ?? 'Vendor'}>`)} ${tabs} ${yellow(moneyText(a.ch.gold) || '0c')}`, header: true }, { text: '', header: true }];
1473
+ const rows = [{ text: `${bold(vendor.name)} ${dim(`<${vendor.subname ?? 'Vendor'}>`)} ${tabs} ${coinText(a.ch.gold)}`, header: true }, { text: '', header: true }];
1054
1474
  if (mode === 'buy') {
1055
1475
  const offers = a.g.vendorOffers(vendor.entry);
1056
1476
  const uses = reagentUses(a);
@@ -1059,12 +1479,13 @@ export function vendorDialog(app, vendor) {
1059
1479
  const it = a.ds.item(o.itemEntry);
1060
1480
  const ok = o.affordable && o.meetsRequirements && o.inStock && o.fits;
1061
1481
  const name = `${qualityPaint(it?.quality)(it?.name ?? o.itemEntry)}${o.unitsPerPurchase > 1 ? ` x${o.unitsPerPurchase}` : ''}`;
1062
- const price = o.honorPrice ? `${o.honorPrice} honor` : moneyText(o.pricePerPurchase);
1482
+ const price = o.honorPrice ? `${o.honorPrice} honor` : coinText(o.pricePerPurchase);
1063
1483
  const why = !o.meetsRequirements ? red(` ${o.requirementReason ?? 'requirements not met'}`) : !o.affordable ? red(' too expensive') : !o.inStock ? red(' out of stock') : !o.fits ? red(' bags full') : '';
1064
1484
  const usedBy = uses.get(o.itemEntry);
1065
1485
  rows.push({
1066
1486
  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,
1067
- detail: () => itemLines(a, it, ['', `Price: ${yellow(price)}`, ...(o.basePricePerPurchase !== o.pricePerPurchase ? [dim(`(${moneyText(o.basePricePerPurchase)} before reputation)`)] : []),
1487
+ icon: it?.icon, sub: dim([it?.typeName, it?.slot].filter(Boolean).join(' · ')),
1488
+ detail: () => itemLines(a, it, ['', `Price: ${yellow(price)}`, ...(o.basePricePerPurchase !== o.pricePerPurchase ? [dim(`(${coinText(o.basePricePerPurchase)} before reputation)`)] : []),
1068
1489
  ...(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
1490
  act: () => buy(a, o, 1),
1070
1491
  });
@@ -1072,13 +1493,14 @@ export function vendorDialog(app, vendor) {
1072
1493
  } else if (mode === 'sell') {
1073
1494
  const st = a.g.state.inventory.stacks;
1074
1495
  const junk = a.g.junkSellPreview();
1075
- if (junk.length) rows.push({ text: dim(`j sells ${plural(junk.reduce((n, p) => n + p.count, 0), 'junk item')} for ${moneyText(junk.reduce((n, p) => n + p.unitSell * p.count, 0))}`), header: true });
1496
+ if (junk.length) rows.push({ text: dim(`j sells ${plural(junk.reduce((n, p) => n + p.count, 0), 'junk item')} for ${coinText(junk.reduce((n, p) => n + p.unitSell * p.count, 0))}`), header: true });
1076
1497
  if (!st.length) rows.push({ text: dim('Your bags are empty.'), header: true });
1077
1498
  st.forEach((s, slot) => {
1078
1499
  const it = a.ds.item(s.item);
1079
1500
  const total = (it?.sellPrice ?? 0) * s.count;
1080
1501
  rows.push({
1081
- text: `${qualityPaint(it?.quality)(it?.name ?? s.item)}${s.count > 1 ? ` x${s.count}` : ''} ${total ? yellow(moneyText(total)) : dim('no value')}`, data: s,
1502
+ text: `${qualityPaint(it?.quality)(it?.name ?? s.item)}${s.count > 1 ? ` x${s.count}` : ''} ${total ? coinText(total) : dim('no value')}`, data: s,
1503
+ icon: it?.icon, sub: dim(it?.typeName ?? ''),
1082
1504
  detail: () => itemLines(a, it, ['', dim('Enter: sell the stack (you can buy it back on the Buyback tab) d: destroy')]),
1083
1505
  act: () => sellStack(a, slot),
1084
1506
  });
@@ -1089,8 +1511,8 @@ export function vendorDialog(app, vendor) {
1089
1511
  list.forEach((e, i) => {
1090
1512
  const it = a.ds.item(e.entry);
1091
1513
  rows.push({
1092
- text: `${qualityPaint(it?.quality)(it?.name ?? e.entry)}${e.qty > 1 ? ` x${e.qty}` : ''} ${yellow(moneyText(e.refundCopper))}${e.ownerName ? dim(` back to ${e.ownerName}`) : ''}`,
1093
- detail: () => itemLines(a, it, ['', `Buy back for ${yellow(moneyText(e.refundCopper))}`]),
1514
+ text: `${qualityPaint(it?.quality)(it?.name ?? e.entry)}${e.qty > 1 ? ` x${e.qty}` : ''} ${coinText(e.refundCopper)}${e.ownerName ? dim(` back to ${e.ownerName}`) : ''}`,
1515
+ detail: () => itemLines(a, it, ['', `Buy back for ${coinText(e.refundCopper)}`]),
1094
1516
  act: () => buyBackAt(a, i),
1095
1517
  });
1096
1518
  });
@@ -1105,9 +1527,9 @@ export function vendorDialog(app, vendor) {
1105
1527
  bags.forEach((s, slot) => {
1106
1528
  const it = a.ds.item(s.item);
1107
1529
  rows.push({
1108
- text: ` ${qualityPaint(it?.quality)(it?.name ?? s.item)}${s.count > 1 ? ` x${s.count}` : ''} ${it?.sellPrice ? yellow(moneyText(it.sellPrice * s.count)) : dim('no value')}`, data: { comp, s },
1530
+ text: ` ${qualityPaint(it?.quality)(it?.name ?? s.item)}${s.count > 1 ? ` x${s.count}` : ''} ${it?.sellPrice ? coinText(it.sellPrice * s.count) : dim('no value')}`, data: { comp, s },
1109
1531
  detail: () => itemLines(a, it, ['', dim(`Enter: sell it out of ${comp.name}'s bags j: sell their junk d: destroy`)]),
1110
- act: () => { const r = a.g.sellCompanionItemStack(comp.id, slot); if (a.result(r, r.ok ? `Sold ${comp.name}'s ${it?.name}${r.qty > 1 ? ` x${r.qty}` : ''} for ${moneyText(r.earned)}.` : '')) noteSale(a, s.item, r.qty, r.earned, comp); },
1532
+ act: () => { const r = a.g.sellCompanionItemStack(comp.id, slot); if (a.result(r, r.ok ? `Sold ${comp.name}'s ${it?.name}${r.qty > 1 ? ` x${r.qty}` : ''} for ${coinText(r.earned)}.` : '')) noteSale(a, s.item, r.qty, r.earned, comp); },
1111
1533
  });
1112
1534
  });
1113
1535
  }
@@ -1120,34 +1542,71 @@ export function vendorDialog(app, vendor) {
1120
1542
 
1121
1543
  function buy(app, offer, n) {
1122
1544
  const r = app.g.buyItem(app.vendor.entry, offer.itemEntry, n);
1123
- app.result(r, r.ok ? `Bought ${app.itemLabel(offer.itemEntry)}${n > 1 ? ` x${n}` : ''} for ${moneyText(r.spent)}.` : '');
1545
+ app.result(r, r.ok ? `Bought ${app.itemLabel(offer.itemEntry)}${n > 1 ? ` x${n}` : ''} for ${coinText(r.spent)}.` : '');
1124
1546
  }
1125
1547
 
1126
1548
  export function trainerDialog(app, trainer) {
1127
1549
  const known = () => new Set(app.g.state.spellbook.known);
1550
+ // Weapon masters list every class's weapons; hide the rows the class/race gate would refuse.
1551
+ 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)));
1552
+ // Higher ranks of a talent ability (Ice Barrier 2+) are sold here, but rank 1 only comes from the
1553
+ // talent point, so until it is known the trainer refuses them. Keyed by the chain's first rank.
1554
+ const talentOf = new Map();
1555
+ for (const tree of app.ds.talentTrees(app.ch.cls) ?? []) for (const t of tree.talents ?? []) for (const r of t.ranks ?? []) {
1556
+ for (const id of [r.spellId, ...(r.effects ?? []).filter((e) => e.kind === 'grant_spell').map((e) => e.spellId)]) if (id) talentOf.set(id, { t, tree });
1557
+ }
1558
+ const needsTalent = (ts) => {
1559
+ for (const g of learnGrantIds(ts.spell, app.ds)) {
1560
+ const first = app.ds.spellChain(g)?.first ?? g, hit = talentOf.get(first);
1561
+ if (hit && !knows(app.g.state.spellbook, first)) return hit;
1562
+ }
1563
+ return null;
1564
+ };
1128
1565
  const learnable = () => trainer.spells
1129
- .filter((ts) => ts.reqLevel <= app.ch.level && !known().has(ts.spell))
1566
+ .filter((ts) => ts.reqLevel <= app.ch.level && !known().has(ts.spell) && fits(ts) && !needsTalent(ts))
1130
1567
  .map((ts) => ({ ts, sp: app.ds.spell(ts.spell) })).filter((x) => x.sp)
1131
1568
  .sort((x, y) => x.ts.reqLevel - y.ts.reqLevel || x.sp.name.localeCompare(y.sp.name));
1132
1569
  return {
1133
1570
  id: `trainer-${trainer.entry}`, title: trainer.name, hint: 'Enter: learn',
1134
1571
  keys: { A: { hint: 'learn all', fn: (a) => { let n = 0; for (const { sp } of learnable()) if (a.g.learnFromTrainer(trainer.entry, sp.id).ok) { n++; a.say(green(`You have learned ${sp.name}${sp.rank ? ` (Rank ${sp.rank})` : ''}.`)); } if (!n) a.say('Nothing learned.'); } } },
1135
1572
  rows(a) {
1136
- const rows = [{ text: `${bold(trainer.name)} ${dim(`<${trainer.subname ?? `${className(a.ch.cls)} Trainer`}>`)} ${yellow(moneyText(a.ch.gold) || '0c')}`, header: true }, { text: '', header: true }, { text: 'Available now', header: true }];
1573
+ const rows = [{ text: `${bold(trainer.name)} ${dim(`<${trainer.subname ?? `${className(a.ch.cls)} Trainer`}>`)} ${coinText(a.ch.gold)}`, header: true }, { text: '', header: true }, { text: 'Available now', header: true }];
1137
1574
  const now = learnable();
1138
1575
  if (!now.length) rows.push({ text: dim(' Nothing new at your level.'), header: true });
1139
1576
  for (const { sp, ts } of now) {
1140
1577
  const can = ts.cost <= a.ch.gold;
1141
1578
  rows.push({
1142
- text: `${can ? sp.name : dim(sp.name)}${sp.rank ? dim(` (Rank ${sp.rank})`) : ''} ${yellow(moneyText(ts.cost) || 'free')}`, data: sp,
1143
- detail: () => spellLines(a, sp, ['', `Cost: ${yellow(moneyText(ts.cost) || 'free')}`]),
1579
+ text: `${can ? sp.name : dim(sp.name)}${sp.rank ? dim(` (Rank ${sp.rank})`) : ''} ${coinText(ts.cost, 'free')}`, data: sp,
1580
+ icon: sp.icon, sub: dim(`level ${ts.reqLevel}${sp.cost ? ` ${sp.cost} ${sp.powerType}` : ''}`),
1581
+ detail: () => spellLines(a, sp, ['', `Cost: ${coinText(ts.cost, 'free')}`]),
1144
1582
  act: () => a.result(a.g.learnFromTrainer(trainer.entry, sp.id), `You have learned ${sp.name}${sp.rank ? ` (Rank ${sp.rank})` : ''}.`),
1145
1583
  });
1146
1584
  }
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);
1585
+ const later = trainer.spells.filter((ts) => ts.reqLevel > a.ch.level && !known().has(ts.spell) && fits(ts) && !needsTalent(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
1586
  if (later.length) {
1149
1587
  rows.push({ text: '', header: true }, { text: 'Coming up', header: true });
1150
- 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 });
1588
+ for (const { sp, ts } of later) rows.push({ text: dim(`${sp.name}${sp.rank ? ` (Rank ${sp.rank})` : ''} level ${ts.reqLevel} ${coinText(ts.cost)}`), header: true });
1589
+ }
1590
+ // One row per talent, not per rank: the ranks are all locked behind the same point.
1591
+ const gated = new Map();
1592
+ for (const ts of trainer.spells) {
1593
+ const hit = !known().has(ts.spell) && fits(ts) && needsTalent(ts), sp = hit && a.ds.spell(ts.spell);
1594
+ if (!sp) continue;
1595
+ if (!gated.has(hit.t.id)) gated.set(hit.t.id, { ...hit, ranks: [] });
1596
+ gated.get(hit.t.id).ranks.push({ sp, ts });
1597
+ }
1598
+ if (gated.size) {
1599
+ rows.push({ text: '', header: true }, { text: 'Needs a talent', header: true });
1600
+ for (const { t, tree, ranks } of gated.values()) {
1601
+ ranks.sort((x, y) => x.ts.reqLevel - y.ts.reqLevel);
1602
+ const sp = ranks[0].sp, why = `Rank 1 comes from the ${t.name} talent in the ${tree.name} tree. Until you take it, the trainer cannot teach the higher ranks.`;
1603
+ rows.push({
1604
+ text: dim(`${sp.name} ${tree.name} talent, not taken`), data: sp, icon: sp.icon,
1605
+ sub: dim(ranks.map(({ sp: r, ts }) => `R${r.rank ?? '?'} lv${ts.reqLevel}`).join(' ')),
1606
+ detail: () => spellLines(a, sp, ['', yellow(why)]),
1607
+ act: () => a.say(why),
1608
+ });
1609
+ }
1151
1610
  }
1152
1611
  return rows;
1153
1612
  },
@@ -1158,11 +1617,13 @@ export function innDialog(app, inn) {
1158
1617
  return {
1159
1618
  id: `inn-${inn.entry}`, title: inn.name, hint: 'Enter: choose', noDetail: true,
1160
1619
  rows(a) {
1161
- const bound = a.ch.hearthBind?.name === inn.name;
1620
+ const bound = a.ch.hearthBind?.name === inn.name, wares = a.ds.vendor(inn.entry);
1162
1621
  return [
1163
- { text: `${bold(inn.name)} ${dim('<Innkeeper>')}`, header: true }, { text: '', header: true },
1622
+ { text: `${bold(inn.name)} ${dim('<Innkeeper>')}`, header: true },
1623
+ // Rested xp needs no button: it builds here in game, and at the full offline rate after logging out here.
1624
+ { 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 },
1625
+ ...(wares ? [{ text: 'Browse wares', act: () => a.push(vendorDialog(a, wares)) }] : []),
1164
1626
  { 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
1627
  { text: dim('Goodbye'), act: () => a.pop() },
1167
1628
  ];
1168
1629
  },