azeroth-shell 0.2.0 → 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 (51) hide show
  1. package/README.md +43 -10
  2. package/bin/azsh.mjs +15 -1
  3. package/package.json +5 -2
  4. package/src/cli/hdArt.mjs +1 -1
  5. package/src/cli/iconArt.mjs +11 -1
  6. package/src/cli/modelArt.mjs +7 -4
  7. package/src/cli/oocPrep.mjs +32 -0
  8. package/src/cli/repl.mjs +16 -0
  9. package/src/cli/sixel.mjs +26 -14
  10. package/src/cli/text.mjs +16 -0
  11. package/src/cli/tui.mjs +33 -9
  12. package/src/cli/tuiAutopilot.mjs +17 -8
  13. package/src/cli/tuiBags.mjs +37 -4
  14. package/src/cli/tuiCheck.mjs +276 -15
  15. package/src/cli/tuiCity.mjs +31 -31
  16. package/src/cli/tuiCompanionKit.mjs +6 -6
  17. package/src/cli/tuiCompanions.mjs +189 -29
  18. package/src/cli/tuiCreate.mjs +7 -0
  19. package/src/cli/tuiDungeon.mjs +56 -20
  20. package/src/cli/tuiFight.mjs +63 -21
  21. package/src/cli/tuiFightPanes.mjs +15 -6
  22. package/src/cli/tuiFloor.mjs +20 -8
  23. package/src/cli/tuiGear.mjs +9 -10
  24. package/src/cli/tuiLoot.mjs +5 -6
  25. package/src/cli/tuiMap.mjs +214 -0
  26. package/src/cli/tuiPaperdoll.mjs +6 -5
  27. package/src/cli/tuiPartyStage.mjs +36 -7
  28. package/src/cli/tuiPet.mjs +16 -8
  29. package/src/cli/tuiProfessions.mjs +23 -23
  30. package/src/cli/tuiSaves.mjs +14 -0
  31. package/src/cli/tuiSearch.mjs +4 -5
  32. package/src/cli/tuiSettings.mjs +3 -4
  33. package/src/cli/tuiStage.mjs +72 -16
  34. package/src/cli/tuiTalents.mjs +3 -4
  35. package/src/cli/tuiViews.mjs +371 -84
  36. package/src/cli/tuiWiki.mjs +2 -3
  37. package/src/cli/tuiWorld.mjs +88 -5
  38. package/src/data/gear-art.bin.br +0 -0
  39. package/src/data/hd-art.json +36 -36
  40. package/src/data/model-art-worn.bin.br +0 -0
  41. package/src/data/model-art.bin.gz +0 -0
  42. package/src/data/tables/creatures.json.gz +0 -0
  43. package/src/data/world-map-sixel.bin.br +0 -0
  44. package/src/data/world-map.bin.gz +0 -0
  45. package/src/domain/character/character.ts +6 -0
  46. package/src/domain/pet/pet.ts +31 -0
  47. package/src/engine/combat/pickAction.ts +3494 -3487
  48. package/src/engine/combat/sessionChase.ts +12 -1
  49. package/src/engine/game/loadReconcile.ts +21 -1
  50. package/src/engine/game/pets.ts +33 -1
  51. package/src/engine/game/types.ts +4 -0
@@ -1,11 +1,13 @@
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, vis } 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
6
  import { className, raceName } from '../domain/core/ids.ts';
7
7
  import { nativeDisplayId, wornDisplay } from '../domain/character/displayForm.ts';
8
- import { moneyText } from '../domain/economy/money.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';
9
11
  import { maxHealth, maxPower } from '../domain/character/character.ts';
10
12
  import { effectiveStats, effectiveArmor, attackPower, powerType } from '../domain/character/characterStats.ts';
11
13
  import { critChanceDisplay, dodgeChanceDisplay, hitChance, weaponSkillIdForHand, skillBonusFor } from '../domain/character/characterRatings.ts';
@@ -18,14 +20,17 @@ import { stackBoundToHolder } from '../domain/raid/partyVault.ts';
18
20
  import { knows, learnGrantIds } from '../domain/character/spellbook.ts';
19
21
  import { availablePoints, canSpend, canRefund, spentInTree } from '../domain/character/talents.ts';
20
22
  import { offeredQuests, chainFollowUpOffer, grayLogQuests } from '../quests/quests.ts';
21
- 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';
22
25
  import { questRewardChoices } from '../domain/quests/questRewardChoice.ts';
23
26
  import { pushBuyback, buyBack as buyBackDomain } from '../domain/economy/trading.ts';
24
- import { cityAccess, bankDialog, auctionDialog, mailDialog } from './tuiCity.mjs';
27
+ import { cityAccess, bankDialog, auctionDialog, mailDialog, tabStrip } from './tuiCity.mjs';
25
28
  import { professionsView, profTrainerDialog, worldGatherRows, openSendGoods, reagentUses } from './tuiProfessions.mjs';
26
29
  import { companionsView } from './tuiCompanions.mjs';
27
30
  import { companionLevel, companionCharacter, companionSpellbook } from '../domain/bots/squad.ts';
28
31
  import { squadJoinsPull } from './squad.mjs';
32
+ import { huntableCreatures, questTargets, isQuestMob, zoneWeaponTrainers } from '../world/world.ts';
33
+ import { canInteractWithCreature } from '../domain/creature/faction.ts';
29
34
  import { barsDialog, playerBarsOwner } from './tuiBars.mjs';
30
35
  import { petWorldRows, petDialog, hasPetClass } from './tuiPet.mjs';
31
36
  import { portalRows } from './tuiDungeon.mjs';
@@ -37,13 +42,14 @@ import { outfitterDialog } from './tuiOutfitter.mjs';
37
42
  import { castWithPicker } from './tuiEnchantTarget.mjs';
38
43
  import { settingsDialog } from './tuiSettings.mjs';
39
44
  import { macrosDialog } from './tuiMacros.mjs';
40
- 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';
41
46
  import { talentBuildsMenu, myTalentLink } from './tuiTalents.mjs';
42
47
  import { simLabDialog } from './tuiSimLab.mjs';
48
+ import { withWorldMap } from './tuiMap.mjs';
43
49
  import { autopilotSettingsDialog } from './tuiAutopilotSettings.mjs';
44
50
  import { preflightCard } from './tuiAutopilot.mjs';
45
51
  import { creatureArt } from './modelArt.mjs';
46
- import { withIcon, iconsOn } from './iconArt.mjs';
52
+ import { withIcon, iconsOn, miniIcon } from './iconArt.mjs';
47
53
  import { paperdollGrid, paperdollPlan } from './tuiPaperdoll.mjs';
48
54
  import { talentGrid, talentPlan, talentTone, talentIcon } from './tuiTalentTree.mjs';
49
55
  import { paperdollArt } from './paperdoll.mjs';
@@ -127,7 +133,7 @@ function itemLinesText(app, item, extra = []) {
127
133
  }
128
134
  }
129
135
  if (item.description) out.push(yellow(`"${item.description}"`));
130
- if (item.sellPrice) out.push(dim(`Sells for ${moneyText(item.sellPrice)}`));
136
+ if (item.sellPrice) out.push(dim(`Sells for ${coinText(item.sellPrice)}`));
131
137
  out.push(...extra);
132
138
  return out;
133
139
  }
@@ -161,7 +167,9 @@ export function creatureLines(app, c) {
161
167
  out.push(`${hp} health hits ${c.dmgMin}-${c.dmgMax} every ${(c.attackTimeMs / 1000).toFixed(1)} s`);
162
168
  if (c.armor) out.push(`${c.armor} armor`);
163
169
  if (c.xp) out.push(dim(`about ${c.xp} xp at your level`));
164
- 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'));
165
173
  const wanted = app.g.state.questlog.quests.flatMap((qs) => {
166
174
  const q = app.ds.quest(qs.entry);
167
175
  const k = q?.killTargets.find((t) => t.creatureEntry === c.entry);
@@ -213,13 +221,33 @@ export function objectiveLines(app, q, qs) {
213
221
  export function rewardLines(app, q) {
214
222
  const out = [];
215
223
  if (q.rewXP) out.push(magenta(`${q.rewXP} experience`));
216
- if (q.rewMoney) out.push(yellow(moneyText(q.rewMoney)));
217
- for (const it of q.rewItems) out.push(`${app.itemLabel(it.item)}${it.count > 1 ? ` x${it.count}` : ''}`);
218
- if (q.rewChoiceItems?.length) out.push('Choose one of: ' + q.rewChoiceItems.map((it) => app.itemLabel(it.item)).join(', '));
219
- for (const s of q.rewSpells ?? []) out.push(`Learn ${app.spellName(s)}`);
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)}`);
220
229
  return out;
221
230
  }
222
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
+
223
251
  // ---- tabs ------------------------------------------------------------------------------------------
224
252
 
225
253
  export function makeTabs(app) {
@@ -227,14 +255,14 @@ export function makeTabs(app) {
227
255
  { label: 'World', icon: 'inv_misc_map_01', view: worldView(app) },
228
256
  { label: 'Quests', icon: 'inv_misc_note_01', view: questsView(app) },
229
257
  { label: 'Bags', icon: 'inv_misc_bag_08', view: bagsView(app) },
230
- { label: 'Gear', icon: 'inv_chest_chain_05', view: gearView(app) },
258
+ { label: 'Character', short: 'Char', icon: 'inv_misc_head_human_01', view: characterView(app) },
231
259
  { label: 'Spells', icon: 'inv_misc_book_09', view: spellsView(app) },
232
260
  { label: 'Rotation', icon: 'ability_dualwield', view: rotationView(app) },
233
261
  { label: 'Talents', icon: 'spell_nature_naturetouchgrow', view: talentsView(app) },
234
262
  { label: 'Travel', icon: 'spell_nature_swiftness', view: travelView(app) },
235
263
  { label: 'Professions', short: 'Profs', icon: 'trade_blacksmithing', view: professionsView(app) },
236
264
  { label: 'Companions', short: 'Party', icon: 'spell_holy_prayerofhealing', view: companionsView(app) },
237
- { label: 'Character', short: 'Char', icon: 'inv_misc_head_human_01', view: charView(app) },
265
+ { label: 'Settings', icon: 'inv_misc_gear_01', view: settingsDialog(app) },
238
266
  ];
239
267
  }
240
268
 
@@ -245,34 +273,42 @@ function worldView(app) {
245
273
  s: { hint: 'search', fn: (a) => openWorldSearch(a) },
246
274
  S: { fn: (a) => openWorldSearch(a, '', true) },
247
275
  f: { hint: 'quick fight', fn: (a, row) => { if (row?.data?.creature) a.cmds.commands.fight(String(row.data.creature.entry)); } },
248
- t: { fn: (a, row) => { const c = row?.data?.creature; if (!c) return; if (a.ch.cls !== 3) { a.flashMsg('Only hunters tame beasts.'); return; } a.result(a.g.tamePet(c.entry), `You tame ${c.name}.`); } },
276
+ t: { fn: (a, row) => { if (row?.data?.creature) tame(a, row.data.creature); } },
249
277
  P: { fn: (a, row) => { if (row?.data?.creature) pickPocket(a, row.data.creature); } },
250
278
  a: { hint: 'autopilot', fn: (a) => a.push(preflightCard(a)) },
279
+ c: { hint: 'characters', fn: (a) => { void a.logout(); } },
251
280
  },
252
281
  rows(a) {
253
282
  const ds = a.ds, g = a.g, z = a.zone(), ch = a.ch;
254
283
  if (!z) return [{ text: 'You are nowhere.', header: true }];
255
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);
256
287
  const people = [];
257
- for (const t of z.trainers ?? []) if (t.cls === ch.cls) { const d = ds.trainer(t.entry); if (d) people.push({ text: `${d.name} ${dim(`<${className(t.cls)} Trainer>`)}`, detail: () => npcLines(a, d.entry, [bold(d.name), `${className(t.cls)} trainer`, '', dim('Enter: see what you can learn')]), act: () => a.push(trainerDialog(a, d)) }); }
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)) });
258
290
  const innEntries = new Set((z.innkeepers ?? []).map((i) => i.entry)); // innkeepers sell too, but list once, as the inn
259
- for (const v of z.vendors ?? []) { const d = !innEntries.has(v) && ds.vendor(v); if (d) people.push({ text: `${d.name} ${dim(`<${d.subname ?? 'Vendor'}>`)}${d.canRepair ? dim(' repairs') : ''}`, detail: () => npcLines(a, d.entry, [bold(d.name), d.subname ?? 'Vendor', ...(d.canRepair ? ['Repairs armor and weapons'] : []), '', dim('Enter: browse wares')]), act: () => a.push(vendorDialog(a, d)) }); }
260
- for (const i of z.innkeepers ?? []) people.push({ text: `${i.name} ${dim('<Innkeeper>')}`, detail: () => npcLines(a, i.entry, [bold(i.name), 'Innkeeper', '', dim('Enter: browse wares, make this your home')]), act: () => a.push(innDialog(a, i)) });
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)) });
261
293
  for (const p of z.profTrainers ?? []) {
262
- const d = ds.profTrainer(p);
294
+ const d = canTalk(p) && ds.profTrainer(p);
263
295
  if (!d) continue;
264
296
  const prof = ds.profession(d.skill)?.name ?? d.skill;
265
297
  const sk = ch.skills[d.skill];
266
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)) });
267
299
  }
268
300
  people.push(...ridingRows(a));
269
- for (const f of z.flightMasters ?? []) people.push({ text: `${f.name} ${dim('<Flight Master>')}`, detail: () => npcLines(a, f.entry, [bold(f.name), 'Flight master', '', dim('Enter: open the travel tab')]), act: () => a.switchTab(7) });
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) });
270
305
  // City services: the bank in capitals and banker towns, the auction house in capitals, the mailbox by every inn.
271
306
  const city = cityAccess(a);
272
307
  const services = [];
273
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)) });
274
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)) }); }
275
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));
276
312
  if (services.length) rows.push({ text: '', header: true }, { text: 'Services', header: true }, ...services);
277
313
  rows.push(...portalRows(a));
278
314
  const petRows = petWorldRows(a);
@@ -287,12 +323,14 @@ function worldView(app) {
287
323
  if (!dest) continue;
288
324
  rows.push({ text: `${cyan(dest.name)} ${dim(`levels ${dest.levelRange[0]}-${dest.levelRange[1]}`)}`, detail: () => travelPreviewLines(a, dest), act: () => travelTo(a, dest) });
289
325
  }
290
- 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));
291
328
  if (mobs.length) {
292
329
  rows.push({ text: '', header: true }, { text: `Creatures (${mobs.length})`, header: true });
330
+ const targets = questTargets(ds, g.state.questlog);
293
331
  for (const m of mobs) {
294
- const wanted = g.state.questlog.quests.some((qs) => qs.status === 'active' && ds.quest(qs.entry)?.killTargets.some((k) => k.creatureEntry === m.entry));
295
- rows.push({ text: `${conPaint(ch.level, m.level)(m.name)} ${dim(`L${m.level}`)}${wanted ? yellow(' !') : ''}${ch.cls === 4 ? pickPocketTag(a, m) : ''}`, data: { creature: m }, detail: () => creatureLines(a, m), act: () => startFight(a, m.entry) });
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) });
296
334
  }
297
335
  }
298
336
  // Last: hand-ins and the zone's offers (the Quests tab shows the same list under its log).
@@ -311,10 +349,11 @@ function worldView(app) {
311
349
  export function travelPreviewLines(app, dest) {
312
350
  const pv = app.g.previewTravel(dest.id);
313
351
  const out = [bold(dest.name), dim(`levels ${dest.levelRange[0]}-${dest.levelRange[1]}${dest.faction ? `, ${dest.faction}` : ''}`), ''];
314
- if (!pv.ok) { out.push(red(pv.reason)); return out; }
315
- out.push(`${secondsText(pv.totalSeconds * 1000)} of travel${pv.totalCopper ? `, ${moneyText(pv.totalCopper)} in fares` : ''}`);
316
- 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)})`));
317
- 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 });
318
357
  }
319
358
 
320
359
  export function travelTo(app, dest) {
@@ -323,7 +362,7 @@ export function travelTo(app, dest) {
323
362
  const res = app.g.travelTo(dest.id);
324
363
  if (!app.result(res)) return;
325
364
  app.vendor = null; app.cmds.setVendor(null);
326
- 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)}` : ''}`)}`);
327
366
  landOnTab(app);
328
367
  }
329
368
 
@@ -396,13 +435,18 @@ export function questDialog(app, q, mode) {
396
435
  const objectives = () => section('Objectives', [...text(q.objectivesText ?? '', bold), ...objectiveLines(a, q, s).map((o) => P(` ${o}`))]);
397
436
  // Offered, the story leads as the giver tells it; in the log, what is left to do comes first.
398
437
  if (mode === 'offer' || s?.status === 'complete') { story(); objectives(); } else { objectives(); story(); }
399
- section('Rewards', rewardLines(a, q).map((r) => P(` ${r}`)));
438
+ section('Rewards', rewardRows(a, q));
400
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; }
401
443
  if (mode === 'offer') {
402
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); } });
403
446
  rows.push({ text: dim('Decline'), act: () => a.pop() });
404
447
  } else {
405
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));
406
450
  // A repeatable with a fixed reward hands in every round the bags pay for in one go.
407
451
  const rounds = s?.status === 'complete' && isRepeatableQuest(q) && questRewardChoices(q).length <= 1 ? questRepeatsAvailable(q, a.g.state.inventory, a.ch.zoneId) : 0;
408
452
  if (rounds >= 2) rows.push({ text: green(`Hand it in ${rounds} times`), act: () => repeatTurnIn(a, q) });
@@ -417,6 +461,49 @@ export function questDialog(app, q, mode) {
417
461
  return view;
418
462
  }
419
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
+
420
507
  /** Do Quest: the autopilot works this one quest (travel, fights, objects, hand-in) and stops when it is done. */
421
508
  function doQuest(app, q) {
422
509
  const s = app.g.state.questlog.quests.find((x) => x.entry === q.entry);
@@ -430,7 +517,7 @@ function repeatTurnIn(app, q) {
430
517
  if (!app.result(r)) return;
431
518
  const bits = [`${r.turnedIn} times`];
432
519
  if (r.xp) bits.push(`+${r.xp} xp`);
433
- if (r.money) bits.push(`+${moneyText(r.money)}`);
520
+ if (r.money) bits.push(`+${coinText(r.money)}`);
434
521
  app.say(green(`Quest complete: ${q.title}`) + dim(` ${bits.join(', ')}`));
435
522
  for (const lv of r.leveledTo) { app.say(bold(green(`You have reached level ${lv}!`))); app.flashMsg(`Level ${lv}!`); }
436
523
  app.stack = [];
@@ -453,7 +540,7 @@ function turnIn(app, q) {
453
540
  if (!app.result(res, `Quest complete: ${q.title}`)) return;
454
541
  const bits = [];
455
542
  if (res.xp) bits.push(`+${res.xp} xp`);
456
- if (res.money) bits.push(`+${moneyText(res.money)}`);
543
+ if (res.money) bits.push(`+${coinText(res.money)}`);
457
544
  for (const it of res.items) bits.push(app.itemLabel(it.item) + (it.count > 1 ? ` x${it.count}` : ''));
458
545
  for (const s of res.spells ?? []) bits.push(`learned ${s.name ?? ''}`.trim());
459
546
  if (bits.length) app.say(' ' + bits.join(', '));
@@ -467,6 +554,7 @@ function turnIn(app, q) {
467
554
  if (q.rewChoiceItems?.length) {
468
555
  app.pick('Choose your reward', q.rewChoiceItems.map((it, i) => ({
469
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 ?? ''),
470
558
  detail: () => itemLines(app, app.ds.item(it.item)),
471
559
  value: i,
472
560
  })), finish);
@@ -497,7 +585,7 @@ function bagsView(app) {
497
585
  rows(a) {
498
586
  const st = a.g.state.inventory.stacks;
499
587
  const ups = new Set(a.g.suggestedEquipmentUpgrades().filter((u) => !u.holderId).map((u) => u.entry));
500
- 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 }];
501
589
  st.forEach((s, i) => {
502
590
  const it = a.ds.item(s.item);
503
591
  const name = qualityPaint(it?.quality)(it?.name ?? `item ${s.item}`);
@@ -505,7 +593,7 @@ function bagsView(app) {
505
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') : '';
506
594
  rows.push({
507
595
  text: `${name}${s.count > 1 ? ` x${s.count}` : ''}${tag}${ups.has(s.item) ? green(' ▲') : ''}`, data: s,
508
- icon: it?.icon, sub: dim(`${it?.typeName ?? ''}${it?.sellPrice ? ` sells ${moneyText(it.sellPrice * s.count)}` : ''}`),
596
+ icon: it?.icon, sub: dim(`${it?.typeName ?? ''}${it?.sellPrice ? ` sells ${coinText(it.sellPrice * s.count)}` : ''}`),
509
597
  detail: () => itemLines(a, it, [
510
598
  '', dim(`Enter: ${verb ? verb.toLowerCase() : 'more'} m: more${s.count > 1 ? ' /: split' : ''} [ ]: move d: destroy`),
511
599
  ...(a.vendor ? [dim(`s: sell to ${a.vendor.name}`)] : []),
@@ -556,10 +644,13 @@ function compareLines(app, it, worn) {
556
644
  return out;
557
645
  }
558
646
 
559
- 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) {
560
649
  return {
561
- 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),
562
652
  keys: {
653
+ s: { hint: 'stats', fn: (a) => a.push(characterDetailsDialog(a)) },
563
654
  u: { hint: 'equip all upgrades', fn: (a) => {
564
655
  if (!upgradeList(a).length) { a.flashMsg("Nothing in your bags or your companions' beats what you wear."); return; }
565
656
  // Best first, re-scored after each piece (two rings, a two-hander). Works in suggest mode too,
@@ -581,12 +672,13 @@ function gearView(app) {
581
672
  o: { hint: 'outfits', fn: (a) => a.push(outfitterDialog(a)) },
582
673
  },
583
674
  // Drawn as the character sheet (tuiPaperdoll.mjs) when colour is on and the window has room.
584
- grid: (a, rows, w, h) => paperdollGrid(a, rows, w, h, (cols, lines) => playerArt(a, cols, lines)?.lines ?? null),
675
+ grid: (a, rows, w, h) => paperdollGrid(a, rows, w, h, (cols, lines) => playerArt(a, cols, lines)?.lines ?? null, characterTitle(a)),
585
676
  rows(a) {
586
677
  const rows = [];
587
678
  const ups = upgradeList(a);
588
679
  const extras = gearExtraRows(a);
589
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 });
590
682
  if (ups.length && !sheet) {
591
683
  rows.push({ text: `${green('▲')} Better gear for you (${ups.length}) ${dim('Enter: put on u: put on all')}`, header: true });
592
684
  for (const u of ups) {
@@ -642,8 +734,8 @@ function slotDialog(app, slot) {
642
734
  const low = it.requiredLevel > ch.level;
643
735
  const name = qualityPaint(it.quality)(it.name);
644
736
  return {
645
- text: ` ${low ? dim(name) : name}${s.count > 1 ? dim(` x${s.count}`) : ''}${up ? green(` ▲ +${Math.round(up.delta)}`) : ''}${low ? red(` L${it.requiredLevel}`) : ''}${from === 'bank' ? dim(' in bank') : ''}${comp ? cyan(` ${comp.name}`) : ''}`,
646
- data: { s, 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(' ')),
647
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')]),
648
740
  act: () => {
649
741
  if (from === 'bank' && !a.result(a.g.bankWithdraw(s.item, 1), '')) return;
@@ -672,15 +764,63 @@ function slotDialog(app, slot) {
672
764
  };
673
765
  }
674
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
+
675
792
  function spellsView(app) {
676
- return {
677
- id: 'spells', title: 'Spellbook', hint: 'Enter: cast',
678
- 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
+ },
679
808
  rows(a) {
680
- 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;
681
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)) },
682
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)) },
683
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 });
684
824
  return [...head, ...known.map((sp) => {
685
825
  const cd = a.g.spellCooldownRemaining(sp.id);
686
826
  return {
@@ -692,6 +832,7 @@ function spellsView(app) {
692
832
  })];
693
833
  },
694
834
  };
835
+ return view;
695
836
  }
696
837
 
697
838
  // ---- rotation ------------------------------------------------------------------------------------
@@ -781,8 +922,10 @@ function profileMenu(app) {
781
922
 
782
923
  function rotationView(app) {
783
924
  const rebuild = (a) => a.confirm('Rebuild the recommended rotation? The edits are lost.', () => { rotOwner(a).reset(); a.say(green('Rotation rebuilt.')); });
784
- return {
925
+ const view = {
785
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(),
786
929
  keys: {
787
930
  w: { hint: 'whose', fn: (a) => pickRotationOwner(a) },
788
931
  p: { hint: 'profiles', fn: (a) => profileMenu(a) },
@@ -796,6 +939,7 @@ function rotationView(app) {
796
939
  d: { fn: (a, row) => row?.data?.rule && a.confirm('Delete this rule?', () => deleteRule(a, row.data)) },
797
940
  s: { hint: 'spec', fn: (a) => { if (!rotOwner(a).comp) pickSpec(a); } },
798
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)); } },
799
943
  },
800
944
  rows(a) {
801
945
  const g = a.g, o = rotOwner(a), rot = o.get(), names = rotNames(a);
@@ -805,18 +949,28 @@ function rotationView(app) {
805
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 },
806
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) }] : []),
807
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) },
808
- ...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 })),
809
953
  ];
810
954
  for (const phase of PHASES) {
811
955
  const rules = rot.phases[phase] ?? [];
812
956
  if (!rules.length && phase === 'prebuff') continue;
813
- 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;
814
968
  rules.forEach((rule, index) => {
815
969
  const conds = conditionsSentence(rule.conditions, names);
816
970
  // With icons a rule is two lines and the condition takes the second; without, it trails the sentence.
817
971
  const icon = iconsOn() ? a.ds.spell(rule.action?.spellId ?? rule.before?.spellId)?.icon : undefined;
818
972
  const when = conds !== 'always' ? dim(`when ${conds}`) : '';
819
- const text = `${rule.enabled ? green('✓') : dim('·')} ${rule.enabled ? ruleSentence(rule, names) : dim(ruleSentence(rule, names))}${!icon && when ? ` ${when}` : ''}`;
973
+ const text = ` ${rule.enabled ? green('✓') : dim('·')} ${rule.enabled ? ruleSentence(rule, names) : dim(ruleSentence(rule, names))}${!icon && when ? ` ${when}` : ''}`;
820
974
  rows.push({
821
975
  text, data: { phase, index, rule },
822
976
  icon, sub: when,
@@ -825,7 +979,7 @@ function rotationView(app) {
825
979
  });
826
980
  });
827
981
  rows.push({
828
- 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 },
829
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.')],
830
984
  act: () => addRule(a, phase),
831
985
  });
@@ -844,6 +998,7 @@ function rotationView(app) {
844
998
  return rows;
845
999
  },
846
1000
  };
1001
+ return view;
847
1002
  }
848
1003
 
849
1004
  /** Enter on a rule: everything you can do with it, in one list. */
@@ -918,6 +1073,7 @@ function addRule(app, phase, afterIndex) {
918
1073
  } else if (picked.kind === 'item') {
919
1074
  ({ action, conditions } = defaultItemRule(app, o, picked.itemEntry));
920
1075
  }
1076
+ app.tabs[5].view.open.add(phase);
921
1077
  editRotation(app, (rot) => {
922
1078
  const rule = { id, action, conditions, enabled: true };
923
1079
  const list = rot.phases[phase] ??= [];
@@ -953,7 +1109,7 @@ function talentsView(app) {
953
1109
  grid: (a, rows, w, h) => talentGrid(a, rows, w, h),
954
1110
  keys: {
955
1111
  u: { hint: 'refund', fn: (a, row) => row?.data && a.result(a.g.refundTalent(row.data.id), `Refunded a point from ${row.data.name}.`) },
956
- R: { hint: 'respec', fn: (a) => { const cost = a.g.respecCost(); a.confirm(`Reset all talents for ${moneyText(cost) || 'free'}?`, () => a.result(a.g.respec(), 'Your talents are reset.')); } },
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.')); } },
957
1113
  B: { hint: 'builds', fn: (a) => talentBuildsMenu(a) },
958
1114
  },
959
1115
  rows(a) {
@@ -1010,27 +1166,40 @@ function talentLinesText(app, t, rank, can) {
1010
1166
  function travelView(app) {
1011
1167
  return {
1012
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.')], {}); },
1013
1171
  rows(a) {
1014
1172
  const g = a.g, ds = a.ds, z = a.zone(), ch = a.ch;
1015
- const rows = [...journeyRows(a), { text: `You are in ${cyan(z?.name ?? ch.zoneId)}`, header: true }, { text: '', header: true }, { text: 'On foot', header: true }];
1016
- for (const id of z?.neighbors ?? []) { const d = ds.zone(id); if (d) rows.push({ text: `${d.name} ${dim(`L${d.levelRange[0]}-${d.levelRange[1]}`)}`, detail: () => travelPreviewLines(a, d), act: () => travelTo(a, d) }); }
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
+ }
1017
1186
  const taxi = g.taxiOptions();
1018
1187
  if (taxi.length) {
1019
1188
  rows.push({ text: '', header: true }, { text: 'Flight paths', header: true });
1020
- 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) }); }
1021
1190
  }
1022
1191
  rows.push({ text: '', header: true }, { text: 'Hearthstone', header: true });
1023
1192
  const bind = ch.hearthBind;
1024
1193
  const cd = Math.max(0, (ch.hearthCooldownUntil ?? 0) - Date.now());
1025
1194
  rows.push({
1026
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.'),
1027
- detail: () => [bold('Hearthstone'), bind ? `Bound at ${bind.name ?? ds.zone(bind.zone)?.name}` : 'Not bound', cd > 0 ? red(`Ready in ${secondsText(cd)}`) : green('Ready')],
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 }] } : {}),
1028
1197
  act: () => { const r = g.castHearth(); if (a.result(r)) { a.vendor = null; afterDeparture(a, () => `You hearth to ${a.zone()?.name}.`); landOnTab(a); } },
1029
1198
  });
1030
1199
  const ports = g.classTravelOptions();
1031
1200
  if (ports.length) {
1032
1201
  rows.push({ text: '', header: true }, { text: 'Teleports', header: true });
1033
- for (const p of ports) rows.push({ text: `${p.name} → ${ds.zone(p.toZone)?.name ?? p.toZone}${p.reagents.some((r) => r.have < r.count) ? red(' no reagent') : ''}`, act: () => { const r = g.castTeleport(p.spellId); if (a.result(r)) { afterDeparture(a, () => `You teleport to ${a.zone()?.name}.`); landOnTab(a); } } });
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); } } });
1034
1203
  }
1035
1204
  rows.push(...portalRows(a));
1036
1205
  return rows;
@@ -1065,25 +1234,110 @@ function weaponSkillRows(a, L) {
1065
1234
  return rows;
1066
1235
  }
1067
1236
 
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
+
1068
1294
  /**
1069
1295
  * Your character as a sprite for a pane: the form or costume you wear when the creature pack has it, else
1070
- * you in your gear (paperdoll.mjs), else your bare race and gender body. `label` names the form when that
1071
- * is what shows; null without art.
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.
1072
1298
  */
1073
- export function playerArt(app, maxCols, maxRows) {
1299
+ export function playerArt(app, maxCols, maxRows, view = null) {
1074
1300
  const ch = app.ch;
1075
- const worn = wornDisplay(ch, app.ds, Date.now());
1076
- const wornArt = worn?.displayId ? creatureArt(worn.displayId, maxCols, maxRows) : null;
1077
- if (wornArt) return { lines: wornArt, label: worn.label };
1301
+ const worn = wornNow(app.ds, ch, view);
1302
+ const drawn = wornArt(worn, maxCols, maxRows);
1303
+ if (drawn) return { lines: drawn, label: worn.label };
1078
1304
  const geared = paperdollArt(ch, maxCols, maxRows);
1079
1305
  if (geared) return { lines: geared, label: null };
1080
1306
  const own = creatureArt(nativeDisplayId(ch.race, ch.gender), maxCols, maxRows);
1081
1307
  return own ? { lines: own, label: null } : null;
1082
1308
  }
1083
1309
 
1084
- function charView(app) {
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) {
1085
1339
  return {
1086
- id: 'char', title: 'Character', hint: '',
1340
+ id: 'char', title: 'Stats', hint: 'Esc: back',
1087
1341
  /** The detail pane holds your character: the body of your race and gender, or the form you are in. */
1088
1342
  detail(a) {
1089
1343
  const L = a.layout(), ch = a.ch;
@@ -1100,7 +1354,7 @@ function charView(app) {
1100
1354
  L(`${bold(ch.name)} level ${ch.level} ${className(ch.cls)}`),
1101
1355
  L(''),
1102
1356
  L(`Health ${ch.health}/${maxHealth(ch, ds)} ${pt[0].toUpperCase() + pt.slice(1)} ${ch.power}/${maxPower(ch, ds)}`),
1103
- 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)}`),
1104
1358
  L(''),
1105
1359
  L(cyan('Attributes')),
1106
1360
  ...Object.entries(STAT_LABEL).map(([k, name]) => L(` ${fit(name, 10)} ${st[k]}`)),
@@ -1135,7 +1389,6 @@ function charView(app) {
1135
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)) });
1136
1390
  rows.push(L(''), { text: `Honor ${dim(`${honorRankLine(a)} (:honor)`)}`, act: () => a.push(honorDialog(a)) });
1137
1391
  rows.push({ text: `Reputation ${dim(`${a.ch.watchedFaction ? `watching ${ds.faction(a.ch.watchedFaction)?.name ?? ''} ` : ''}(:rep)`)}`, act: () => a.push(reputationDialog(a)) });
1138
- rows.push({ text: `Settings ${dim('loot, upgrades, disenchanting, junk, gathering (:settings)')}`, act: () => a.push(settingsDialog(a)) });
1139
1392
  return rows;
1140
1393
  },
1141
1394
  };
@@ -1156,14 +1409,14 @@ export function sellStack(app, slot) {
1156
1409
  const s = app.g.state.inventory.stacks[slot];
1157
1410
  if (!s) return;
1158
1411
  const r = app.g.sellItemStack(slot, s.count);
1159
- 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);
1160
1413
  }
1161
1414
 
1162
1415
  /** Sell every junk-filter match, each onto the buyback list. */
1163
1416
  export function sellJunk(app) {
1164
1417
  const preview = app.g.junkSellPreview();
1165
1418
  const r = app.g.sellAllJunk();
1166
- 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;
1167
1420
  for (const p of preview) noteSale(app, p.entry, p.count, p.unitSell * p.count);
1168
1421
  }
1169
1422
 
@@ -1179,7 +1432,7 @@ function buyBackAt(app, i) {
1179
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));
1180
1433
  if (!r.ok) { app.say(red(r.reason ?? 'Cannot buy that back.')); return; }
1181
1434
  app.g.state.char = r.char; app.g.state.inventory = r.inventory; app.buyback = r.buyback;
1182
- 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)}.`));
1183
1436
  }
1184
1437
 
1185
1438
  export function vendorDialog(app, vendor) {
@@ -1195,7 +1448,7 @@ export function vendorDialog(app, vendor) {
1195
1448
  const comp = row?.data?.comp;
1196
1449
  if (!comp) return;
1197
1450
  const r = a.g.sellCompanionJunk(comp.id);
1198
- 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);
1199
1452
  } },
1200
1453
  d: { hint: 'destroy', fn: (a, row) => {
1201
1454
  if (mode === 'sell' && row?.data) { const i = a.g.state.inventory.stacks.indexOf(row.data); if (i >= 0) destroyStack(a, i); return; }
@@ -1205,7 +1458,7 @@ export function vendorDialog(app, vendor) {
1205
1458
  const name = a.itemLabel(row.data.s.item);
1206
1459
  a.confirm(`Destroy ${name} from ${comp.name}'s bags?`, () => a.result(a.g.discardCompanionBagStack(comp.id, slot), `Destroyed ${name}.`));
1207
1460
  } },
1208
- ...(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.') : ''); } } } : {}),
1209
1462
  },
1210
1463
  onKey(a, k) {
1211
1464
  if (k !== 'tab' && k !== 'shift-tab') return false;
@@ -1217,7 +1470,7 @@ export function vendorDialog(app, vendor) {
1217
1470
  onClose(a) { /* the vendor stays "open" for :buy / s in bags until you travel */ },
1218
1471
  rows(a) {
1219
1472
  const tabs = `${bold(VENDOR_MODE_LABEL[mode])} ${dim(`${VENDOR_MODES.indexOf(mode) + 1}/${VENDOR_MODES.length}`)}`;
1220
- 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 }];
1221
1474
  if (mode === 'buy') {
1222
1475
  const offers = a.g.vendorOffers(vendor.entry);
1223
1476
  const uses = reagentUses(a);
@@ -1226,13 +1479,13 @@ export function vendorDialog(app, vendor) {
1226
1479
  const it = a.ds.item(o.itemEntry);
1227
1480
  const ok = o.affordable && o.meetsRequirements && o.inStock && o.fits;
1228
1481
  const name = `${qualityPaint(it?.quality)(it?.name ?? o.itemEntry)}${o.unitsPerPurchase > 1 ? ` x${o.unitsPerPurchase}` : ''}`;
1229
- const price = o.honorPrice ? `${o.honorPrice} honor` : moneyText(o.pricePerPurchase);
1482
+ const price = o.honorPrice ? `${o.honorPrice} honor` : coinText(o.pricePerPurchase);
1230
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') : '';
1231
1484
  const usedBy = uses.get(o.itemEntry);
1232
1485
  rows.push({
1233
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,
1234
1487
  icon: it?.icon, sub: dim([it?.typeName, it?.slot].filter(Boolean).join(' · ')),
1235
- detail: () => itemLines(a, it, ['', `Price: ${yellow(price)}`, ...(o.basePricePerPurchase !== o.pricePerPurchase ? [dim(`(${moneyText(o.basePricePerPurchase)} before reputation)`)] : []),
1488
+ detail: () => itemLines(a, it, ['', `Price: ${yellow(price)}`, ...(o.basePricePerPurchase !== o.pricePerPurchase ? [dim(`(${coinText(o.basePricePerPurchase)} before reputation)`)] : []),
1236
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.')] : [])]),
1237
1490
  act: () => buy(a, o, 1),
1238
1491
  });
@@ -1240,13 +1493,13 @@ export function vendorDialog(app, vendor) {
1240
1493
  } else if (mode === 'sell') {
1241
1494
  const st = a.g.state.inventory.stacks;
1242
1495
  const junk = a.g.junkSellPreview();
1243
- 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 });
1244
1497
  if (!st.length) rows.push({ text: dim('Your bags are empty.'), header: true });
1245
1498
  st.forEach((s, slot) => {
1246
1499
  const it = a.ds.item(s.item);
1247
1500
  const total = (it?.sellPrice ?? 0) * s.count;
1248
1501
  rows.push({
1249
- 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,
1250
1503
  icon: it?.icon, sub: dim(it?.typeName ?? ''),
1251
1504
  detail: () => itemLines(a, it, ['', dim('Enter: sell the stack (you can buy it back on the Buyback tab) d: destroy')]),
1252
1505
  act: () => sellStack(a, slot),
@@ -1258,8 +1511,8 @@ export function vendorDialog(app, vendor) {
1258
1511
  list.forEach((e, i) => {
1259
1512
  const it = a.ds.item(e.entry);
1260
1513
  rows.push({
1261
- 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}`) : ''}`,
1262
- 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)}`]),
1263
1516
  act: () => buyBackAt(a, i),
1264
1517
  });
1265
1518
  });
@@ -1274,9 +1527,9 @@ export function vendorDialog(app, vendor) {
1274
1527
  bags.forEach((s, slot) => {
1275
1528
  const it = a.ds.item(s.item);
1276
1529
  rows.push({
1277
- 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 },
1278
1531
  detail: () => itemLines(a, it, ['', dim(`Enter: sell it out of ${comp.name}'s bags j: sell their junk d: destroy`)]),
1279
- 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); },
1280
1533
  });
1281
1534
  });
1282
1535
  }
@@ -1289,37 +1542,71 @@ export function vendorDialog(app, vendor) {
1289
1542
 
1290
1543
  function buy(app, offer, n) {
1291
1544
  const r = app.g.buyItem(app.vendor.entry, offer.itemEntry, n);
1292
- 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)}.` : '');
1293
1546
  }
1294
1547
 
1295
1548
  export function trainerDialog(app, trainer) {
1296
1549
  const known = () => new Set(app.g.state.spellbook.known);
1297
1550
  // Weapon masters list every class's weapons; hide the rows the class/race gate would refuse.
1298
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
+ };
1299
1565
  const learnable = () => trainer.spells
1300
- .filter((ts) => ts.reqLevel <= app.ch.level && !known().has(ts.spell) && fits(ts))
1566
+ .filter((ts) => ts.reqLevel <= app.ch.level && !known().has(ts.spell) && fits(ts) && !needsTalent(ts))
1301
1567
  .map((ts) => ({ ts, sp: app.ds.spell(ts.spell) })).filter((x) => x.sp)
1302
1568
  .sort((x, y) => x.ts.reqLevel - y.ts.reqLevel || x.sp.name.localeCompare(y.sp.name));
1303
1569
  return {
1304
1570
  id: `trainer-${trainer.entry}`, title: trainer.name, hint: 'Enter: learn',
1305
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.'); } } },
1306
1572
  rows(a) {
1307
- 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 }];
1308
1574
  const now = learnable();
1309
1575
  if (!now.length) rows.push({ text: dim(' Nothing new at your level.'), header: true });
1310
1576
  for (const { sp, ts } of now) {
1311
1577
  const can = ts.cost <= a.ch.gold;
1312
1578
  rows.push({
1313
- text: `${can ? sp.name : dim(sp.name)}${sp.rank ? dim(` (Rank ${sp.rank})`) : ''} ${yellow(moneyText(ts.cost) || 'free')}`, data: sp,
1579
+ text: `${can ? sp.name : dim(sp.name)}${sp.rank ? dim(` (Rank ${sp.rank})`) : ''} ${coinText(ts.cost, 'free')}`, data: sp,
1314
1580
  icon: sp.icon, sub: dim(`level ${ts.reqLevel}${sp.cost ? ` ${sp.cost} ${sp.powerType}` : ''}`),
1315
- detail: () => spellLines(a, sp, ['', `Cost: ${yellow(moneyText(ts.cost) || 'free')}`]),
1581
+ detail: () => spellLines(a, sp, ['', `Cost: ${coinText(ts.cost, 'free')}`]),
1316
1582
  act: () => a.result(a.g.learnFromTrainer(trainer.entry, sp.id), `You have learned ${sp.name}${sp.rank ? ` (Rank ${sp.rank})` : ''}.`),
1317
1583
  });
1318
1584
  }
1319
- const later = trainer.spells.filter((ts) => ts.reqLevel > a.ch.level && !known().has(ts.spell) && fits(ts)).map((ts) => ({ ts, sp: a.ds.spell(ts.spell) })).filter((x) => x.sp).sort((x, y) => x.ts.reqLevel - y.ts.reqLevel).slice(0, 12);
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);
1320
1586
  if (later.length) {
1321
1587
  rows.push({ text: '', header: true }, { text: 'Coming up', header: true });
1322
- for (const { sp, ts } of later) rows.push({ text: dim(`${sp.name}${sp.rank ? ` (Rank ${sp.rank})` : ''} level ${ts.reqLevel} ${moneyText(ts.cost)}`), header: true });
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
+ }
1323
1610
  }
1324
1611
  return rows;
1325
1612
  },