azeroth-shell 0.2.0 → 0.4.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 (55) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/README.md +43 -10
  3. package/bin/azsh.mjs +15 -1
  4. package/package.json +7 -3
  5. package/src/cli/hdArt.mjs +1 -1
  6. package/src/cli/iconArt.mjs +11 -1
  7. package/src/cli/modelArt.mjs +7 -4
  8. package/src/cli/oocPrep.mjs +32 -0
  9. package/src/cli/repl.mjs +16 -0
  10. package/src/cli/sixel.mjs +26 -14
  11. package/src/cli/text.mjs +16 -0
  12. package/src/cli/tui.mjs +40 -13
  13. package/src/cli/tuiAutopilot.mjs +17 -8
  14. package/src/cli/tuiBags.mjs +37 -4
  15. package/src/cli/tuiBulk.mjs +83 -4
  16. package/src/cli/tuiCheck.mjs +328 -19
  17. package/src/cli/tuiCity.mjs +31 -31
  18. package/src/cli/tuiCompanionKit.mjs +26 -8
  19. package/src/cli/tuiCompanions.mjs +189 -29
  20. package/src/cli/tuiCreate.mjs +7 -0
  21. package/src/cli/tuiDungeon.mjs +113 -33
  22. package/src/cli/tuiFight.mjs +63 -21
  23. package/src/cli/tuiFightPanes.mjs +15 -6
  24. package/src/cli/tuiFloor.mjs +20 -8
  25. package/src/cli/tuiGear.mjs +9 -10
  26. package/src/cli/tuiLoot.mjs +5 -6
  27. package/src/cli/tuiMap.mjs +214 -0
  28. package/src/cli/tuiPaperdoll.mjs +6 -5
  29. package/src/cli/tuiPartyStage.mjs +36 -7
  30. package/src/cli/tuiPet.mjs +16 -8
  31. package/src/cli/tuiProfessions.mjs +23 -23
  32. package/src/cli/tuiSaves.mjs +14 -0
  33. package/src/cli/tuiSearch.mjs +4 -5
  34. package/src/cli/tuiSettings.mjs +3 -4
  35. package/src/cli/tuiStage.mjs +72 -16
  36. package/src/cli/tuiTalents.mjs +3 -4
  37. package/src/cli/tuiViews.mjs +374 -85
  38. package/src/cli/tuiWiki.mjs +2 -3
  39. package/src/cli/tuiWorld.mjs +88 -5
  40. package/src/data/gear-art.bin.br +0 -0
  41. package/src/data/hd-art.json +36 -36
  42. package/src/data/model-art-worn.bin.br +0 -0
  43. package/src/data/model-art.bin.gz +0 -0
  44. package/src/data/tables/creatures.json.gz +0 -0
  45. package/src/data/world-map-sixel.bin.br +0 -0
  46. package/src/data/world-map.bin.gz +0 -0
  47. package/src/domain/character/character.ts +6 -0
  48. package/src/domain/pet/pet.ts +31 -0
  49. package/src/engine/combat/pickAction.ts +3494 -3487
  50. package/src/engine/combat/sessionChase.ts +12 -1
  51. package/src/engine/game/loadReconcile.ts +21 -1
  52. package/src/engine/game/pets.ts +33 -1
  53. package/src/engine/game/professions.ts +4 -0
  54. package/src/engine/game/squadQuestShare.ts +13 -1
  55. 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,32 +20,36 @@ 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';
32
37
  import { conditionEditor, defaultConditionsForSpell, defaultItemRule } from './tuiRotationCond.mjs';
33
38
  import { allyIndexOfSide } from '../engine/core/events.ts';
34
- import { bulkDisenchantDialog, learnAllDialog } from './tuiBulk.mjs';
39
+ import { bulkDisenchantDialog, learnAllDialog, destroyManyDialog } from './tuiBulk.mjs';
35
40
  import { gearExtraRows, durabilityText, repairOne, repairAll, slotRepairLines, enchantLines } from './tuiGear.mjs';
36
41
  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);
@@ -486,6 +574,8 @@ function bagsView(app) {
486
574
  S: { hint: 'sort', fn: (a) => sortBags(a) },
487
575
  D: { hint: 'disenchant all', fn: (a) => a.push(bulkDisenchantDialog(a)) },
488
576
  L: { hint: 'learn all', fn: (a) => a.push(learnAllDialog(a)) },
577
+ X: { hint: 'destroy several', fn: (a) => a.push(destroyManyDialog(a)) },
578
+ Q: { hint: 'destroy quest leftovers', fn: (a) => a.push(destroyManyDialog(a, 'quest')) },
489
579
  A: { fn: onStack(useAll) },
490
580
  '/': { fn: onStack(splitStack) },
491
581
  '[': { fn: onStack((a, i) => moveStack(a, i, -1)) },
@@ -497,7 +587,7 @@ function bagsView(app) {
497
587
  rows(a) {
498
588
  const st = a.g.state.inventory.stacks;
499
589
  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 }];
590
+ 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
591
  st.forEach((s, i) => {
502
592
  const it = a.ds.item(s.item);
503
593
  const name = qualityPaint(it?.quality)(it?.name ?? `item ${s.item}`);
@@ -505,7 +595,7 @@ function bagsView(app) {
505
595
  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
596
  rows.push({
507
597
  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)}` : ''}`),
598
+ icon: it?.icon, sub: dim(`${it?.typeName ?? ''}${it?.sellPrice ? ` sells ${coinText(it.sellPrice * s.count)}` : ''}`),
509
599
  detail: () => itemLines(a, it, [
510
600
  '', dim(`Enter: ${verb ? verb.toLowerCase() : 'more'} m: more${s.count > 1 ? ' /: split' : ''} [ ]: move d: destroy`),
511
601
  ...(a.vendor ? [dim(`s: sell to ${a.vendor.name}`)] : []),
@@ -556,10 +646,13 @@ function compareLines(app, it, worn) {
556
646
  return out;
557
647
  }
558
648
 
559
- function gearView(app) {
649
+ /** The Character tab: the paperdoll (your gear around your body), your numbers under the tooltip, s for the rest. */
650
+ function characterView(app) {
560
651
  return {
561
- id: 'gear', title: 'Equipment', hint: 'Enter: change what you wear',
652
+ id: 'gear', title: 'Character', hint: 'Enter: change what you wear',
653
+ detailFoot: (a, w) => statPanel(a, w),
562
654
  keys: {
655
+ s: { hint: 'stats', fn: (a) => a.push(characterDetailsDialog(a)) },
563
656
  u: { hint: 'equip all upgrades', fn: (a) => {
564
657
  if (!upgradeList(a).length) { a.flashMsg("Nothing in your bags or your companions' beats what you wear."); return; }
565
658
  // Best first, re-scored after each piece (two rings, a two-hander). Works in suggest mode too,
@@ -581,12 +674,13 @@ function gearView(app) {
581
674
  o: { hint: 'outfits', fn: (a) => a.push(outfitterDialog(a)) },
582
675
  },
583
676
  // 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),
677
+ grid: (a, rows, w, h) => paperdollGrid(a, rows, w, h, (cols, lines) => playerArt(a, cols, lines)?.lines ?? null, characterTitle(a)),
585
678
  rows(a) {
586
679
  const rows = [];
587
680
  const ups = upgradeList(a);
588
681
  const extras = gearExtraRows(a);
589
682
  const sheet = paperdollPlan(a, extras.filter((r) => !r.header).length);
683
+ if (!sheet) rows.push({ text: `${characterTitle(a)} ${dim('s: stats, skills, auras, reputation')}`, header: true, plain: true }, { text: '', header: true });
590
684
  if (ups.length && !sheet) {
591
685
  rows.push({ text: `${green('▲')} Better gear for you (${ups.length}) ${dim('Enter: put on u: put on all')}`, header: true });
592
686
  for (const u of ups) {
@@ -642,8 +736,8 @@ function slotDialog(app, slot) {
642
736
  const low = it.requiredLevel > ch.level;
643
737
  const name = qualityPaint(it.quality)(it.name);
644
738
  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 },
739
+ 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}`) : ''}`,
740
+ 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
741
  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
742
  act: () => {
649
743
  if (from === 'bank' && !a.result(a.g.bankWithdraw(s.item, 1), '')) return;
@@ -672,15 +766,63 @@ function slotDialog(app, slot) {
672
766
  };
673
767
  }
674
768
 
769
+ /**
770
+ * The spellbook's tabs, as the client draws them: General, then one per talent tree. A spell goes on the tab
771
+ * of its class skill line (SkillLineAbility, baked as combatSkillMeta.spellAbilities); the line is matched to
772
+ * its tree by name, since the trees carry no skill id ("Shadow" is Shadow Magic, "Elemental" is Elemental
773
+ * Combat). Everything else (Attack, racials, Poisons, Beast Training) is General.
774
+ */
775
+ function spellbookTabs(ch, ds) {
776
+ const meta = ds.combatSkillMeta(), bit = 1 << (ch.cls - 1);
777
+ const trees = ds.talentTrees(ch.cls).map((t) => t.name);
778
+ const treeOfSkill = (skillId) => {
779
+ if (meta?.categories?.[skillId] !== 7) return -1;
780
+ const n = (meta.skillNames?.[skillId] ?? '').toLowerCase();
781
+ return trees.findIndex((t) => n === t.toLowerCase() || n.startsWith(`${t.toLowerCase()} `));
782
+ };
783
+ const tabOf = (sp) => {
784
+ for (const r of meta?.spellAbilities?.[sp.id] ?? []) {
785
+ if (r.classMask && !(r.classMask & bit)) continue;
786
+ const k = treeOfSkill(r.skillId);
787
+ if (k >= 0) return k + 1;
788
+ }
789
+ return 0;
790
+ };
791
+ return { names: ['General', ...trees], tabOf };
792
+ }
793
+
675
794
  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)) } },
795
+ // Until a tab is picked the book opens on the first one with spells (a warrior's General can be empty).
796
+ let book = null;
797
+ const turn = (a, d) => { a.rowsOf(view); const n = spellbookTabs(a.ch, a.ds).names.length; book = (book + d + n) % n; view.scroll = 0; };
798
+ const view = {
799
+ id: 'spells', title: 'Spellbook', hint: 'Enter: cast [ ]: tab',
800
+ keys: {
801
+ b: { hint: 'action bars', fn: (a) => a.push(barsDialog(a, playerBarsOwner)) }, m: { hint: 'macros', fn: (a) => a.push(macrosDialog(a)) },
802
+ '[': { fn: (a) => turn(a, -1) }, ']': { fn: (a) => turn(a, +1) },
803
+ },
804
+ // Left Right on the tab strip turn the book's tabs; anywhere else they still change the window's tabs.
805
+ onKey(a, k, row) {
806
+ if (!row?.data?.strip || (k !== 'left' && k !== 'right')) return false;
807
+ turn(a, k === 'right' ? +1 : -1);
808
+ return true;
809
+ },
679
810
  rows(a) {
680
- const known = knownUsableSpells(a.ch, a.g.state.spellbook, a.ds).sort((x, y) => x.name.localeCompare(y.name));
811
+ const tabs = spellbookTabs(a.ch, a.ds);
812
+ const all = knownUsableSpells(a.ch, a.g.state.spellbook, a.ds);
813
+ const counts = tabs.names.map((_, k) => all.filter((sp) => tabs.tabOf(sp) === k).length);
814
+ book = book == null ? Math.max(0, counts.findIndex((n) => n > 0)) : Math.min(book, tabs.names.length - 1);
815
+ const known = all.filter((sp) => tabs.tabOf(sp) === book).sort((x, y) => x.name.localeCompare(y.name));
816
+ const L = a.layout(), width = (L.detailW ? L.listW : L.W) - 2;
681
817
  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
818
  { 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
819
  { text: '', header: true }];
820
+ head.push({
821
+ text: tabStrip(tabs.names.map((n, k) => `${n} ${counts[k]}`), book, width - 1, view.cursor === head.length), data: { strip: true },
822
+ 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')],
823
+ act: (a2) => a2.moveCursor(view, a2.rowsOf(view), +1),
824
+ }, { text: '', header: true });
825
+ if (!known.length) head.push({ text: dim('No spells on this tab yet.'), header: true });
684
826
  return [...head, ...known.map((sp) => {
685
827
  const cd = a.g.spellCooldownRemaining(sp.id);
686
828
  return {
@@ -692,6 +834,7 @@ function spellsView(app) {
692
834
  })];
693
835
  },
694
836
  };
837
+ return view;
695
838
  }
696
839
 
697
840
  // ---- rotation ------------------------------------------------------------------------------------
@@ -781,8 +924,10 @@ function profileMenu(app) {
781
924
 
782
925
  function rotationView(app) {
783
926
  const rebuild = (a) => a.confirm('Rebuild the recommended rotation? The edits are lost.', () => { rotOwner(a).reset(); a.say(green('Rotation rebuilt.')); });
784
- return {
927
+ const view = {
785
928
  id: 'rotation', title: 'Rotation', hint: 'Enter: open',
929
+ // The phases Enter has opened; all start closed so the tab fits a screen.
930
+ open: new Set(),
786
931
  keys: {
787
932
  w: { hint: 'whose', fn: (a) => pickRotationOwner(a) },
788
933
  p: { hint: 'profiles', fn: (a) => profileMenu(a) },
@@ -796,6 +941,7 @@ function rotationView(app) {
796
941
  d: { fn: (a, row) => row?.data?.rule && a.confirm('Delete this rule?', () => deleteRule(a, row.data)) },
797
942
  s: { hint: 'spec', fn: (a) => { if (!rotOwner(a).comp) pickSpec(a); } },
798
943
  r: { hint: 'rebuild', fn: rebuild },
944
+ 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
945
  },
800
946
  rows(a) {
801
947
  const g = a.g, o = rotOwner(a), rot = o.get(), names = rotNames(a);
@@ -805,18 +951,28 @@ function rotationView(app) {
805
951
  { 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
952
  ...((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
953
  { 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 })),
954
+ ...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
955
  ];
810
956
  for (const phase of PHASES) {
811
957
  const rules = rot.phases[phase] ?? [];
812
958
  if (!rules.length && phase === 'prebuff') continue;
813
- rows.push({ text: '', header: true }, { text: PHASE_LABEL[phase], header: true });
959
+ // A phase is one row until Enter opens it; closed, it previews the first few spells it casts.
960
+ const open = view.open.has(phase), on = rules.filter((r) => r.enabled);
961
+ const [title, gloss = ''] = PHASE_LABEL[phase].split(/ (?=\()/);
962
+ 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 ? ', …' : '');
963
+ rows.push({ text: '', header: true }, {
964
+ text: `${open ? '▾' : '▸'} ${bold(title)} ${dim(gloss)} ${cyan(plural(rules.length, 'rule'))}${!open && preview ? dim(` ${preview}`) : ''}`,
965
+ data: { phase, toggle: true },
966
+ 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')],
967
+ act: () => { if (open) view.open.delete(phase); else view.open.add(phase); },
968
+ });
969
+ if (!open) continue;
814
970
  rules.forEach((rule, index) => {
815
971
  const conds = conditionsSentence(rule.conditions, names);
816
972
  // With icons a rule is two lines and the condition takes the second; without, it trails the sentence.
817
973
  const icon = iconsOn() ? a.ds.spell(rule.action?.spellId ?? rule.before?.spellId)?.icon : undefined;
818
974
  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}` : ''}`;
975
+ const text = ` ${rule.enabled ? green('✓') : dim('·')} ${rule.enabled ? ruleSentence(rule, names) : dim(ruleSentence(rule, names))}${!icon && when ? ` ${when}` : ''}`;
820
976
  rows.push({
821
977
  text, data: { phase, index, rule },
822
978
  icon, sub: when,
@@ -825,7 +981,7 @@ function rotationView(app) {
825
981
  });
826
982
  });
827
983
  rows.push({
828
- text: dim(`+ Add a rule to ${PHASE_LABEL[phase].split(' (')[0].toLowerCase()}`), data: { phase },
984
+ text: dim(` + Add a rule to ${PHASE_LABEL[phase].split(' (')[0].toLowerCase()}`), data: { phase },
829
985
  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
986
  act: () => addRule(a, phase),
831
987
  });
@@ -844,6 +1000,7 @@ function rotationView(app) {
844
1000
  return rows;
845
1001
  },
846
1002
  };
1003
+ return view;
847
1004
  }
848
1005
 
849
1006
  /** Enter on a rule: everything you can do with it, in one list. */
@@ -918,6 +1075,7 @@ function addRule(app, phase, afterIndex) {
918
1075
  } else if (picked.kind === 'item') {
919
1076
  ({ action, conditions } = defaultItemRule(app, o, picked.itemEntry));
920
1077
  }
1078
+ app.tabs[5].view.open.add(phase);
921
1079
  editRotation(app, (rot) => {
922
1080
  const rule = { id, action, conditions, enabled: true };
923
1081
  const list = rot.phases[phase] ??= [];
@@ -953,7 +1111,7 @@ function talentsView(app) {
953
1111
  grid: (a, rows, w, h) => talentGrid(a, rows, w, h),
954
1112
  keys: {
955
1113
  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.')); } },
1114
+ 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
1115
  B: { hint: 'builds', fn: (a) => talentBuildsMenu(a) },
958
1116
  },
959
1117
  rows(a) {
@@ -1010,27 +1168,40 @@ function talentLinesText(app, t, rank, can) {
1010
1168
  function travelView(app) {
1011
1169
  return {
1012
1170
  id: 'travel', title: 'Travel', hint: 'Enter: go',
1171
+ // A row without its own detail (a teleport, a portal) still shows the world map with you on it.
1172
+ detail(a) { return withWorldMap(a, [bold(a.zone()?.name ?? a.ch.zoneId), dim('The world, and where you stand in it.')], {}); },
1013
1173
  rows(a) {
1014
1174
  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) }); }
1175
+ const rows = [...journeyRows(a), { text: `You are in ${cyan(z?.name ?? ch.zoneId)}`, header: true }];
1176
+ // A zone's neighbors are every travel edge out of it: a boat, a zeppelin or the tram is not a walk.
1177
+ 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]));
1178
+ const groups = [['On foot', ['ground', 'portal']], ['Boats and zeppelins', ['transport']], ['Deeprun Tram', ['tram']]];
1179
+ for (const [title, kinds] of groups) {
1180
+ const near = (z?.neighbors ?? []).filter((id) => kinds.includes(edgeTo.get(id)?.kind ?? 'ground')).map((id) => [ds.zone(id), edgeTo.get(id)]).filter(([d]) => d);
1181
+ if (!near.length) continue;
1182
+ rows.push({ text: '', header: true }, { text: title, header: true });
1183
+ for (const [d, e] of near) {
1184
+ const line = e?.kind === 'transport' ? ds.transports().find((t) => t.entry === e.transport)?.name : null;
1185
+ rows.push({ text: `${d.name} ${dim(`L${d.levelRange[0]}-${d.levelRange[1]}`)}${line ? dim(` ${line}`) : ''}`, detail: () => travelPreviewLines(a, d), act: () => travelTo(a, d) });
1186
+ }
1187
+ }
1017
1188
  const taxi = g.taxiOptions();
1018
1189
  if (taxi.length) {
1019
1190
  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) }); }
1191
+ 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
1192
  }
1022
1193
  rows.push({ text: '', header: true }, { text: 'Hearthstone', header: true });
1023
1194
  const bind = ch.hearthBind;
1024
1195
  const cd = Math.max(0, (ch.hearthCooldownUntil ?? 0) - Date.now());
1025
1196
  rows.push({
1026
1197
  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')],
1198
+ 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
1199
  act: () => { const r = g.castHearth(); if (a.result(r)) { a.vendor = null; afterDeparture(a, () => `You hearth to ${a.zone()?.name}.`); landOnTab(a); } },
1029
1200
  });
1030
1201
  const ports = g.classTravelOptions();
1031
1202
  if (ports.length) {
1032
1203
  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); } } });
1204
+ 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
1205
  }
1035
1206
  rows.push(...portalRows(a));
1036
1207
  return rows;
@@ -1065,25 +1236,110 @@ function weaponSkillRows(a, L) {
1065
1236
  return rows;
1066
1237
  }
1067
1238
 
1239
+ // How tall a worn model stands against the body it replaces, which fills its box. Sprites are cropped to the
1240
+ // model, so a cat drawn into a person's box came out the size of a person. The client's collision heights are
1241
+ // one placeholder for most of these models, so this goes by shape: the druid's animals and Ghost Wolf stand on
1242
+ // four legs, a transform by its creature's type (a Polymorph sheep is a critter, a ninja costume a humanoid).
1243
+ const FORM_HEIGHT = {
1244
+ [SHAPESHIFT_FORM.CAT]: 0.6, [SHAPESHIFT_FORM.BEAR]: 0.7, [SHAPESHIFT_FORM.DIREBEAR]: 0.7,
1245
+ [SHAPESHIFT_FORM.TRAVEL]: 0.65, [SHAPESHIFT_FORM.AQUA]: 0.5, [SHAPESHIFT_FORM.GHOSTWOLF]: 0.6,
1246
+ };
1247
+ const TYPE_HEIGHT = { [CREATURE_TYPE.BEAST]: 0.6, [CREATURE_TYPE.CRITTER]: 0.35 };
1248
+ const typeByDisplay = new WeakMap(); // ds -> displayId -> the creature type of the first creature drawn with it
1249
+
1250
+ function displayType(ds, displayId) {
1251
+ let m = typeByDisplay.get(ds);
1252
+ if (!m) {
1253
+ m = new Map();
1254
+ for (const c of ds.creatures()) if (c.displayId && !m.has(c.displayId)) m.set(c.displayId, c.creatureType);
1255
+ typeByDisplay.set(ds, m);
1256
+ }
1257
+ return m.get(displayId);
1258
+ }
1259
+
1260
+ /**
1261
+ * The model `subject` (you, or a companion) wears right now, or null for its own body: { displayId, label,
1262
+ * height }, height the share of the box it stands in (FORM_HEIGHT). In a fight, `view` (its combat view) has
1263
+ * the live form and auras: a shift or a Polymorph lands there, not on the saved character, whose costumes are
1264
+ * still read under them.
1265
+ */
1266
+ export function wornNow(ds, subject, view) {
1267
+ const worn = wornModel(ds, subject, view);
1268
+ if (!worn) return null;
1269
+ const form = ((view ? view.form : subject.form) ?? 0) | 0;
1270
+ const height = form && worn.displayId === formDisplayId(form, factionOf(ds, subject.race))
1271
+ ? FORM_HEIGHT[form] ?? 1
1272
+ : TYPE_HEIGHT[displayType(ds, worn.displayId)] ?? 1;
1273
+ return { ...worn, height };
1274
+ }
1275
+
1276
+ /** A worn model (wornNow) as art in a box `maxCols` x `maxRows`, standing its height in it; null without art. */
1277
+ export function wornArt(worn, maxCols, maxRows) {
1278
+ return worn?.displayId ? creatureArt(worn.displayId, maxCols, Math.max(3, Math.round(maxRows * worn.height))) : null;
1279
+ }
1280
+
1281
+ function wornModel(ds, subject, view) {
1282
+ const now = Date.now();
1283
+ if (!view) return wornDisplay(subject, ds, now);
1284
+ // Polymorph and Hex bake as crowd control, the model swap left in their raw aura 56 row, which
1285
+ // wornDisplay reads past. Being turned into a sheep is the latest model change, so it wins.
1286
+ for (const d of view.debuffs ?? []) {
1287
+ const spell = d.spellId != null ? ds.spell(d.spellId) : null;
1288
+ const raw = spell?.rawEffects?.find((r) => (r.auraName | 0) === 56 && r.miscValue);
1289
+ const displayId = raw ? ds.creature(raw.miscValue | 0)?.displayId : 0;
1290
+ if (displayId) return { displayId, label: spell.name };
1291
+ }
1292
+ const live = [...(view.buffs ?? []), ...(view.debuffs ?? [])].filter((b) => b.spellId != null).map((b) => ({ spellId: b.spellId }));
1293
+ return wornDisplay({ ...subject, form: view.form ?? 0, activeAuras: [...live, ...(subject.activeAuras ?? [])] }, ds, now);
1294
+ }
1295
+
1068
1296
  /**
1069
1297
  * 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.
1298
+ * you in your gear (paperdoll.mjs), else your bare race and gender body. `view` is your combat view in a
1299
+ * fight (wornNow). `label` names the form when that is what shows; null without art.
1072
1300
  */
1073
- export function playerArt(app, maxCols, maxRows) {
1301
+ export function playerArt(app, maxCols, maxRows, view = null) {
1074
1302
  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 };
1303
+ const worn = wornNow(app.ds, ch, view);
1304
+ const drawn = wornArt(worn, maxCols, maxRows);
1305
+ if (drawn) return { lines: drawn, label: worn.label };
1078
1306
  const geared = paperdollArt(ch, maxCols, maxRows);
1079
1307
  if (geared) return { lines: geared, label: null };
1080
1308
  const own = creatureArt(nativeDisplayId(ch.race, ch.gender), maxCols, maxRows);
1081
1309
  return own ? { lines: own, label: null } : null;
1082
1310
  }
1083
1311
 
1084
- function charView(app) {
1312
+ /** Name, level, race and class: the Character tab's title line. */
1313
+ function characterTitle(a) {
1314
+ const ch = a.ch;
1315
+ return `${bold(ch.name)} ${dim(`level ${ch.level} ${raceName(ch.race)} ${className(ch.cls)}`)}`;
1316
+ }
1317
+
1318
+ /** Your numbers at the foot of the Character tab's tooltip pane: health, power, experience, money, then the
1319
+ * attributes beside the combat ratings, the way the client's stat frames sit under the paperdoll. */
1320
+ function statPanel(a, w) {
1321
+ const ch = a.ch, ds = a.ds;
1322
+ const st = effectiveStats(ch, ds);
1323
+ const pt = powerType(ch);
1324
+ const pct = (x) => `${((x ?? 0) * 100).toFixed(2)}%`;
1325
+ const combat = [
1326
+ ['Armor', effectiveArmor(ch, ds)], ['Attack pwr', attackPower(ch, ds)], ['Crit', pct(critChanceDisplay(ch, ds))],
1327
+ ['Hit', pct(hitChance(ch, ds))], ['Dodge', pct(dodgeChanceDisplay(ch, ds))],
1328
+ ];
1329
+ const attrs = Object.entries(STAT_LABEL).map(([k, name]) => `${fit(name, 10)}${String(st[k]).padStart(5)}`);
1330
+ return [
1331
+ dim('─'.repeat(Math.max(0, w))),
1332
+ `Health ${ch.health}/${maxHealth(ch, ds)} ${pt[0].toUpperCase() + pt.slice(1)} ${ch.power}/${maxPower(ch, ds)}`,
1333
+ `XP ${ch.xp}/${ds.xpToNext(ch.level)}${ch.restedXp ? dim(` (${ch.restedXp} rested)`) : ''} ${coinText(ch.gold)}`,
1334
+ '',
1335
+ ...attrs.map((t, i) => `${t} ${fit(combat[i][0], 10)} ${String(combat[i][1]).padStart(7)}`),
1336
+ ];
1337
+ }
1338
+
1339
+ /** s on the Character tab: the whole sheet in words: stats, weapon skills, professions, auras, home, pet, honor, reputation. */
1340
+ function characterDetailsDialog(app) {
1085
1341
  return {
1086
- id: 'char', title: 'Character', hint: '',
1342
+ id: 'char', title: 'Stats', hint: 'Esc: back',
1087
1343
  /** The detail pane holds your character: the body of your race and gender, or the form you are in. */
1088
1344
  detail(a) {
1089
1345
  const L = a.layout(), ch = a.ch;
@@ -1100,7 +1356,7 @@ function charView(app) {
1100
1356
  L(`${bold(ch.name)} level ${ch.level} ${className(ch.cls)}`),
1101
1357
  L(''),
1102
1358
  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')}`),
1359
+ L(`Experience ${ch.xp}/${ds.xpToNext(ch.level)}${ch.restedXp ? dim(` (${ch.restedXp} rested)`) : ''} ${coinText(ch.gold)}`),
1104
1360
  L(''),
1105
1361
  L(cyan('Attributes')),
1106
1362
  ...Object.entries(STAT_LABEL).map(([k, name]) => L(` ${fit(name, 10)} ${st[k]}`)),
@@ -1135,7 +1391,6 @@ function charView(app) {
1135
1391
  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
1392
  rows.push(L(''), { text: `Honor ${dim(`${honorRankLine(a)} (:honor)`)}`, act: () => a.push(honorDialog(a)) });
1137
1393
  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
1394
  return rows;
1140
1395
  },
1141
1396
  };
@@ -1156,14 +1411,14 @@ export function sellStack(app, slot) {
1156
1411
  const s = app.g.state.inventory.stacks[slot];
1157
1412
  if (!s) return;
1158
1413
  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);
1414
+ 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
1415
  }
1161
1416
 
1162
1417
  /** Sell every junk-filter match, each onto the buyback list. */
1163
1418
  export function sellJunk(app) {
1164
1419
  const preview = app.g.junkSellPreview();
1165
1420
  const r = app.g.sellAllJunk();
1166
- if (!app.result(r, r.ok ? `Sold ${plural(r.count, 'junk item')} for ${moneyText(r.earned)}.` : '')) return;
1421
+ if (!app.result(r, r.ok ? `Sold ${plural(r.count, 'junk item')} for ${coinText(r.earned)}.` : '')) return;
1167
1422
  for (const p of preview) noteSale(app, p.entry, p.count, p.unitSell * p.count);
1168
1423
  }
1169
1424
 
@@ -1179,7 +1434,7 @@ function buyBackAt(app, i) {
1179
1434
  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
1435
  if (!r.ok) { app.say(red(r.reason ?? 'Cannot buy that back.')); return; }
1181
1436
  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)}.`));
1437
+ app.say(green(`You buy back ${app.itemLabel(e.entry)}${e.qty > 1 ? ` x${e.qty}` : ''} for ${coinText(e.refundCopper)}.`));
1183
1438
  }
1184
1439
 
1185
1440
  export function vendorDialog(app, vendor) {
@@ -1195,7 +1450,7 @@ export function vendorDialog(app, vendor) {
1195
1450
  const comp = row?.data?.comp;
1196
1451
  if (!comp) return;
1197
1452
  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);
1453
+ 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
1454
  } },
1200
1455
  d: { hint: 'destroy', fn: (a, row) => {
1201
1456
  if (mode === 'sell' && row?.data) { const i = a.g.state.inventory.stacks.indexOf(row.data); if (i >= 0) destroyStack(a, i); return; }
@@ -1205,7 +1460,7 @@ export function vendorDialog(app, vendor) {
1205
1460
  const name = a.itemLabel(row.data.s.item);
1206
1461
  a.confirm(`Destroy ${name} from ${comp.name}'s bags?`, () => a.result(a.g.discardCompanionBagStack(comp.id, slot), `Destroyed ${name}.`));
1207
1462
  } },
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.') : ''); } } } : {}),
1463
+ ...(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
1464
  },
1210
1465
  onKey(a, k) {
1211
1466
  if (k !== 'tab' && k !== 'shift-tab') return false;
@@ -1217,7 +1472,7 @@ export function vendorDialog(app, vendor) {
1217
1472
  onClose(a) { /* the vendor stays "open" for :buy / s in bags until you travel */ },
1218
1473
  rows(a) {
1219
1474
  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 }];
1475
+ const rows = [{ text: `${bold(vendor.name)} ${dim(`<${vendor.subname ?? 'Vendor'}>`)} ${tabs} ${coinText(a.ch.gold)}`, header: true }, { text: '', header: true }];
1221
1476
  if (mode === 'buy') {
1222
1477
  const offers = a.g.vendorOffers(vendor.entry);
1223
1478
  const uses = reagentUses(a);
@@ -1226,13 +1481,13 @@ export function vendorDialog(app, vendor) {
1226
1481
  const it = a.ds.item(o.itemEntry);
1227
1482
  const ok = o.affordable && o.meetsRequirements && o.inStock && o.fits;
1228
1483
  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);
1484
+ const price = o.honorPrice ? `${o.honorPrice} honor` : coinText(o.pricePerPurchase);
1230
1485
  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
1486
  const usedBy = uses.get(o.itemEntry);
1232
1487
  rows.push({
1233
1488
  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
1489
  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)`)] : []),
1490
+ detail: () => itemLines(a, it, ['', `Price: ${yellow(price)}`, ...(o.basePricePerPurchase !== o.pricePerPurchase ? [dim(`(${coinText(o.basePricePerPurchase)} before reputation)`)] : []),
1236
1491
  ...(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
1492
  act: () => buy(a, o, 1),
1238
1493
  });
@@ -1240,13 +1495,13 @@ export function vendorDialog(app, vendor) {
1240
1495
  } else if (mode === 'sell') {
1241
1496
  const st = a.g.state.inventory.stacks;
1242
1497
  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 });
1498
+ 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
1499
  if (!st.length) rows.push({ text: dim('Your bags are empty.'), header: true });
1245
1500
  st.forEach((s, slot) => {
1246
1501
  const it = a.ds.item(s.item);
1247
1502
  const total = (it?.sellPrice ?? 0) * s.count;
1248
1503
  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,
1504
+ text: `${qualityPaint(it?.quality)(it?.name ?? s.item)}${s.count > 1 ? ` x${s.count}` : ''} ${total ? coinText(total) : dim('no value')}`, data: s,
1250
1505
  icon: it?.icon, sub: dim(it?.typeName ?? ''),
1251
1506
  detail: () => itemLines(a, it, ['', dim('Enter: sell the stack (you can buy it back on the Buyback tab) d: destroy')]),
1252
1507
  act: () => sellStack(a, slot),
@@ -1258,8 +1513,8 @@ export function vendorDialog(app, vendor) {
1258
1513
  list.forEach((e, i) => {
1259
1514
  const it = a.ds.item(e.entry);
1260
1515
  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))}`]),
1516
+ text: `${qualityPaint(it?.quality)(it?.name ?? e.entry)}${e.qty > 1 ? ` x${e.qty}` : ''} ${coinText(e.refundCopper)}${e.ownerName ? dim(` back to ${e.ownerName}`) : ''}`,
1517
+ detail: () => itemLines(a, it, ['', `Buy back for ${coinText(e.refundCopper)}`]),
1263
1518
  act: () => buyBackAt(a, i),
1264
1519
  });
1265
1520
  });
@@ -1274,9 +1529,9 @@ export function vendorDialog(app, vendor) {
1274
1529
  bags.forEach((s, slot) => {
1275
1530
  const it = a.ds.item(s.item);
1276
1531
  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 },
1532
+ 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
1533
  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); },
1534
+ 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
1535
  });
1281
1536
  });
1282
1537
  }
@@ -1289,37 +1544,71 @@ export function vendorDialog(app, vendor) {
1289
1544
 
1290
1545
  function buy(app, offer, n) {
1291
1546
  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)}.` : '');
1547
+ app.result(r, r.ok ? `Bought ${app.itemLabel(offer.itemEntry)}${n > 1 ? ` x${n}` : ''} for ${coinText(r.spent)}.` : '');
1293
1548
  }
1294
1549
 
1295
1550
  export function trainerDialog(app, trainer) {
1296
1551
  const known = () => new Set(app.g.state.spellbook.known);
1297
1552
  // Weapon masters list every class's weapons; hide the rows the class/race gate would refuse.
1298
1553
  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)));
1554
+ // Higher ranks of a talent ability (Ice Barrier 2+) are sold here, but rank 1 only comes from the
1555
+ // talent point, so until it is known the trainer refuses them. Keyed by the chain's first rank.
1556
+ const talentOf = new Map();
1557
+ for (const tree of app.ds.talentTrees(app.ch.cls) ?? []) for (const t of tree.talents ?? []) for (const r of t.ranks ?? []) {
1558
+ 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 });
1559
+ }
1560
+ const needsTalent = (ts) => {
1561
+ for (const g of learnGrantIds(ts.spell, app.ds)) {
1562
+ const first = app.ds.spellChain(g)?.first ?? g, hit = talentOf.get(first);
1563
+ if (hit && !knows(app.g.state.spellbook, first)) return hit;
1564
+ }
1565
+ return null;
1566
+ };
1299
1567
  const learnable = () => trainer.spells
1300
- .filter((ts) => ts.reqLevel <= app.ch.level && !known().has(ts.spell) && fits(ts))
1568
+ .filter((ts) => ts.reqLevel <= app.ch.level && !known().has(ts.spell) && fits(ts) && !needsTalent(ts))
1301
1569
  .map((ts) => ({ ts, sp: app.ds.spell(ts.spell) })).filter((x) => x.sp)
1302
1570
  .sort((x, y) => x.ts.reqLevel - y.ts.reqLevel || x.sp.name.localeCompare(y.sp.name));
1303
1571
  return {
1304
1572
  id: `trainer-${trainer.entry}`, title: trainer.name, hint: 'Enter: learn',
1305
1573
  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
1574
  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 }];
1575
+ 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
1576
  const now = learnable();
1309
1577
  if (!now.length) rows.push({ text: dim(' Nothing new at your level.'), header: true });
1310
1578
  for (const { sp, ts } of now) {
1311
1579
  const can = ts.cost <= a.ch.gold;
1312
1580
  rows.push({
1313
- text: `${can ? sp.name : dim(sp.name)}${sp.rank ? dim(` (Rank ${sp.rank})`) : ''} ${yellow(moneyText(ts.cost) || 'free')}`, data: sp,
1581
+ text: `${can ? sp.name : dim(sp.name)}${sp.rank ? dim(` (Rank ${sp.rank})`) : ''} ${coinText(ts.cost, 'free')}`, data: sp,
1314
1582
  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')}`]),
1583
+ detail: () => spellLines(a, sp, ['', `Cost: ${coinText(ts.cost, 'free')}`]),
1316
1584
  act: () => a.result(a.g.learnFromTrainer(trainer.entry, sp.id), `You have learned ${sp.name}${sp.rank ? ` (Rank ${sp.rank})` : ''}.`),
1317
1585
  });
1318
1586
  }
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);
1587
+ 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
1588
  if (later.length) {
1321
1589
  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 });
1590
+ 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 });
1591
+ }
1592
+ // One row per talent, not per rank: the ranks are all locked behind the same point.
1593
+ const gated = new Map();
1594
+ for (const ts of trainer.spells) {
1595
+ const hit = !known().has(ts.spell) && fits(ts) && needsTalent(ts), sp = hit && a.ds.spell(ts.spell);
1596
+ if (!sp) continue;
1597
+ if (!gated.has(hit.t.id)) gated.set(hit.t.id, { ...hit, ranks: [] });
1598
+ gated.get(hit.t.id).ranks.push({ sp, ts });
1599
+ }
1600
+ if (gated.size) {
1601
+ rows.push({ text: '', header: true }, { text: 'Needs a talent', header: true });
1602
+ for (const { t, tree, ranks } of gated.values()) {
1603
+ ranks.sort((x, y) => x.ts.reqLevel - y.ts.reqLevel);
1604
+ 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.`;
1605
+ rows.push({
1606
+ text: dim(`${sp.name} ${tree.name} talent, not taken`), data: sp, icon: sp.icon,
1607
+ sub: dim(ranks.map(({ sp: r, ts }) => `R${r.rank ?? '?'} lv${ts.reqLevel}`).join(' ')),
1608
+ detail: () => spellLines(a, sp, ['', yellow(why)]),
1609
+ act: () => a.say(why),
1610
+ });
1611
+ }
1323
1612
  }
1324
1613
  return rows;
1325
1614
  },