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
@@ -3,10 +3,10 @@
3
3
  // Send goods. Ported from phonecraft's professionsPanel.ts and the vendorPanel.ts send-goods overlay.
4
4
  // A craft buys short vendor reagents (vials, thread, flux) from your purse on the way: the buy-in phonecraft
5
5
  // shows as a coin chip on Create. Views only read; every change is a Game facade call.
6
- import { bold, dim, cyan, yellow, green, red, gray, orange, qualityPaint, fit, plural, secondsText, wrapLines, itemText } from './text.mjs';
6
+ import { bold, dim, cyan, yellow, green, red, gray, orange, qualityPaint, fit, plural, secondsText, wrapLines, itemText, coinText } from './text.mjs';
7
7
  import { itemLines } from './tuiViews.mjs';
8
8
  import { missionDialog, companionDialog } from './tuiCompanions.mjs';
9
- import { moneyText, coinsText } from '../domain/economy/money.ts';
9
+ import { coinsText } from '../domain/economy/money.ts';
10
10
  import { className } from '../domain/core/ids.ts';
11
11
  import { countItem, emptyInventory } from '../domain/items/inventory.ts';
12
12
  import { equippedEntry } from '../domain/items/itemInstance.ts';
@@ -102,7 +102,7 @@ function craftCheck(app, cr, r, env) {
102
102
  if (chk.reason !== 'Missing reagents') return { ok: false, reason: chk.reason, cost: 0, buys: [], max: 0 };
103
103
  const b = vendorBuyInReady(app.ds, app.ch, r.reagents, cr.inv);
104
104
  if (!b.ok) {
105
- const reason = b.blocked.length ? `Missing ${b.blocked.map((i) => app.itemLabel(i)).join(', ')}` : `Need ${moneyText(b.cost)} for ${buyList(app, b.buys)}`;
105
+ const reason = b.blocked.length ? `Missing ${b.blocked.map((i) => app.itemLabel(i)).join(', ')}` : `Need ${coinText(b.cost)} for ${buyList(app, b.buys)}`;
106
106
  return { ok: false, short: true, reason, cost: b.cost, buys: b.buys, max: 0 };
107
107
  }
108
108
  const again = canCraft(cr.char, applyVendorBuys(app.ds, cr.inv, b.buys), r, env.focus, null, env.tools);
@@ -112,7 +112,7 @@ function craftEnv(app) { return { focus: zoneFocus(app), tools: sharedTools(app)
112
112
 
113
113
  /** One status chip for a recipe row: how many you can make, what the buy-in costs, or why not. */
114
114
  function craftChip(chk) {
115
- if (chk.ok) return green(`${chk.max} ready`) + (chk.cost ? yellow(` buys ${moneyText(chk.cost)}`) : '');
115
+ if (chk.ok) return green(`${chk.max} ready`) + (chk.cost ? yellow(` buys ${coinText(chk.cost)}`) : '');
116
116
  return (chk.short ? dim : red)(chk.reason ?? 'cannot make');
117
117
  }
118
118
 
@@ -143,7 +143,7 @@ function craftMany(app, cr, r, want, all = false) {
143
143
  if (!made) { app.say(red(reason ?? 'Cannot make that.')); return; }
144
144
  const prod = app.ds.item(r.product.item);
145
145
  app.say(green(`${cr.id ? `${cr.name} creates` : 'You create'} ${itemText(prod, r.name)}${units > 1 ? ` x${units}` : ''}.`)
146
- + (spent ? yellow(` Vendor reagents ${moneyText(spent)}.`) : '')
146
+ + (spent ? yellow(` Vendor reagents ${coinText(spent)}.`) : '')
147
147
  + (made < n && reason ? dim(` Stopped after ${made}: ${reason}`) : ''));
148
148
  if (skill != null) app.say(cyan(`${whose(cr)} ${app.ds.profession(r.skill)?.name ?? r.skill} skill is now ${skill}.`));
149
149
  for (const l of other.reverse()) app.say(dim('· ' + coinsText(l)));
@@ -193,7 +193,7 @@ function reagentLines(app, reagents, tools, inv) {
193
193
  const vendor = !ok && it && unlimitedVendorFor(app.ds, rg.item, app.ch.race) != null;
194
194
  const lot = Math.max(1, it?.buyCount ?? 1);
195
195
  const cost = vendor ? Math.ceil((rg.count - have) / lot) * it.buyPrice : 0;
196
- out.push(` ${(ok ? green : vendor ? yellow : red)(`${have}/${rg.count}`)} ${app.itemLabel(rg.item)}${vendor ? dim(` vendor ${moneyText(cost)}`) : ''}`);
196
+ out.push(` ${(ok ? green : vendor ? yellow : red)(`${have}/${rg.count}`)} ${app.itemLabel(rg.item)}${vendor ? dim(` vendor ${coinText(cost)}`) : ''}`);
197
197
  }
198
198
  for (const t of tools ?? []) {
199
199
  const ok = countItem(inv, t) > 0 || sharedTools(app).has(t);
@@ -214,7 +214,7 @@ function recipeDetail(app, cr, r, chk, who) {
214
214
  out.push(...wearerLines(app, r.product.item, who));
215
215
  out.push('');
216
216
  if (chk.ok) {
217
- if (chk.cost) out.push(yellow(`Buys ${buyList(app, chk.buys)} for ${moneyText(chk.cost)} from your purse.`));
217
+ if (chk.cost) out.push(yellow(`Buys ${buyList(app, chk.buys)} for ${coinText(chk.cost)} from your purse.`));
218
218
  out.push(green(`Enter: make one A: make ${chk.max} N: a number`));
219
219
  } else out.push(red(chk.reason ?? 'Cannot make this.'));
220
220
  if (cr.id) out.push('', dim(`${cr.name} crafts from the whole party's bags and banks. What they make lands in their bags.`));
@@ -246,7 +246,7 @@ export function recipeBook(app, crafterId, skill) {
246
246
  const cr = current();
247
247
  const sk = cr.char.skills[skill];
248
248
  const env = craftEnv(a);
249
- const rows = [H(`${bold(cr.name)} ${prof?.name ?? skill} ${sk ? `${sk.current}/${sk.max}` : dim('untrained')}${sk && prof ? dim(` ${currentRank(cr.char, prof)?.name ?? ''}`) : ''} ${yellow(moneyText(a.ch.gold) || '0c')}`)];
249
+ const rows = [H(`${bold(cr.name)} ${prof?.name ?? skill} ${sk ? `${sk.current}/${sk.max}` : dim('untrained')}${sk && prof ? dim(` ${currentRank(cr.char, prof)?.name ?? ''}`) : ''} ${coinText(a.ch.gold)}`)];
250
250
  const filters = [haveOnly && 'only what you can make', search && `matching "${search}"`].filter(Boolean);
251
251
  rows.push(H(dim(filters.length ? `Showing ${filters.join(', ')}.` : 'c: switch crafter h: only what you can make /: search')));
252
252
  if (GATHER_LINES.has(skill)) rows.push(...gatherRows(a, skill, cr));
@@ -359,7 +359,7 @@ function enchantCheck(app, cr, e, env) {
359
359
  if (e.reagents.every((rg) => countItem(cr.inv, rg.item) >= rg.count)) return { ok: true, cost: 0, buys: [] };
360
360
  const b = vendorBuyInReady(app.ds, app.ch, e.reagents, cr.inv);
361
361
  if (b.ok) return { ok: true, cost: b.cost, buys: b.buys };
362
- return { ok: false, short: true, reason: b.blocked.length ? `Missing ${b.blocked.map((i) => app.itemLabel(i)).join(', ')}` : `Need ${moneyText(b.cost)}`, cost: b.cost, buys: b.buys };
362
+ return { ok: false, short: true, reason: b.blocked.length ? `Missing ${b.blocked.map((i) => app.itemLabel(i)).join(', ')}` : `Need ${coinText(b.cost)}`, cost: b.cost, buys: b.buys };
363
363
  }
364
364
 
365
365
  /** Whose worn piece can take this enchant: you and the party, one option per matching slot. */
@@ -406,7 +406,7 @@ function enchantBench(app, crafterId) {
406
406
  const cr = current(), ds = a.ds;
407
407
  const sk = cr.char.skills.enchanting;
408
408
  const env = craftEnv(a);
409
- const rows = [H(`${bold(cr.name)} Enchanting ${sk ? `${sk.current}/${sk.max}` : dim('untrained')} ${dim(`rod: ${bestRodTier(cr.inv, ds, env.tools) ? rodNameForTier(bestRodTier(cr.inv, ds, env.tools), ds) : 'none'}`)} ${yellow(moneyText(a.ch.gold) || '0c')}`), H(dim('c: switch enchanter'))];
409
+ const rows = [H(`${bold(cr.name)} Enchanting ${sk ? `${sk.current}/${sk.max}` : dim('untrained')} ${dim(`rod: ${bestRodTier(cr.inv, ds, env.tools) ? rodNameForTier(bestRodTier(cr.inv, ds, env.tools), ds) : 'none'}`)} ${coinText(a.ch.gold)}`), H(dim('c: switch enchanter'))];
410
410
  const known = new Set(cr.char.knownEnchants ?? []);
411
411
  const formulas = ds.enchants().filter((e) => known.has(e.id)).sort((x, y) => y.orange - x.orange || x.name.localeCompare(y.name));
412
412
  const W = Math.max(20, Math.min(34, a.layout().listW - 31));
@@ -415,7 +415,7 @@ function enchantBench(app, crafterId) {
415
415
  for (const e of formulas) {
416
416
  const chk = enchantCheck(a, cr, e, env);
417
417
  rows.push({
418
- text: `${fit(DIFF_PAINT[diffOf(cr, 'enchanting', e)](e.name.replace(/^Enchant\s+/, '')), W)} ${chk.ok ? green('ready') + (chk.cost ? yellow(` buys ${moneyText(chk.cost)}`) : '') : (chk.short ? dim : red)(chk.reason)}`,
418
+ text: `${fit(DIFF_PAINT[diffOf(cr, 'enchanting', e)](e.name.replace(/^Enchant\s+/, '')), W)} ${chk.ok ? green('ready') + (chk.cost ? yellow(` buys ${coinText(chk.cost)}`) : '') : (chk.short ? dim : red)(chk.reason)}`,
419
419
  detail: () => [bold(e.name), dim(`goes on: ${e.slots.join(', ')} ${rodNameForTier(e.minRodTier, ds)}`), '', ...reagentLines(a, e.reagents, [], cr.inv), '',
420
420
  `${DIFF_PAINT[diffOf(cr, 'enchanting', e)](diffOf(cr, 'enchanting', e))} ${upPct(cr, 'enchanting', e) ? `${upPct(cr, 'enchanting', e)}% skill-up` : dim('no skill-up')}`, '',
421
421
  chk.ok ? green('Enter: pick whose gear to enchant') : red(chk.reason)],
@@ -489,7 +489,7 @@ function partyBook(app) {
489
489
  groups.set(r.skill, g);
490
490
  }
491
491
  const ready = [...byRecipe.values()].filter((o) => o.some((x) => x.chk.ok)).length;
492
- const rows = [H(`${plural(byRecipe.size, 'recipe')} known across ${plural(all.length, 'crafter')}, ${ready} ready ${yellow(moneyText(a.ch.gold) || '0c')}`), H(dim(haveOnly ? 'Showing what someone can make now. h shows everything.' : 'Showing every known recipe. h shows only what someone can make.'))];
492
+ const rows = [H(`${plural(byRecipe.size, 'recipe')} known across ${plural(all.length, 'crafter')}, ${ready} ready ${coinText(a.ch.gold)}`), H(dim(haveOnly ? 'Showing what someone can make now. h shows everything.' : 'Showing every known recipe. h shows only what someone can make.'))];
493
493
  if (!groups.size) rows.push(H(''), H(dim(byRecipe.size ? ' Nobody can make anything right now. h shows every recipe.' : ' Nobody in the party knows a recipe yet.')));
494
494
  const W = Math.max(20, Math.min(34, a.layout().listW - 34));
495
495
  for (const [skill, list] of [...groups].sort((x, y) => x[0].localeCompare(y[0]))) {
@@ -527,9 +527,9 @@ function specRows(app, trainer) {
527
527
  const name = sp?.name ?? it.kind.replace(/^(un)?learn_/, '');
528
528
  const cost = it.costCopper ?? 0;
529
529
  rows.push({
530
- text: `${unlearn ? red(`Unlearn ${name}`) : green(`Become a specialist: ${name}`)}${cost ? ` ${yellow(moneyText(cost))}` : ''}`,
531
- detail: () => [bold(name), '', unlearn ? 'Forget this specialisation and the recipes that go with it.' : 'A specialisation opens its own recipes. You can hold one at a time.', ...(cost ? ['', `Cost ${moneyText(cost)}`] : [])],
532
- act: (a) => (unlearn ? a.confirm(`Unlearn ${name}${cost ? ` for ${moneyText(cost)}` : ''}? Its recipes are forgotten.`, () => applySpec(a, trainer, it, name, true)) : applySpec(a, trainer, it, name, false)),
530
+ text: `${unlearn ? red(`Unlearn ${name}`) : green(`Become a specialist: ${name}`)}${cost ? ` ${coinText(cost)}` : ''}`,
531
+ detail: () => [bold(name), '', unlearn ? 'Forget this specialisation and the recipes that go with it.' : 'A specialisation opens its own recipes. You can hold one at a time.', ...(cost ? ['', `Cost ${coinText(cost)}`] : [])],
532
+ act: (a) => (unlearn ? a.confirm(`Unlearn ${name}${cost ? ` for ${coinText(cost)}` : ''}? Its recipes are forgotten.`, () => applySpec(a, trainer, it, name, true)) : applySpec(a, trainer, it, name, false)),
533
533
  });
534
534
  }
535
535
  rows.push(H(''));
@@ -569,21 +569,21 @@ export function profTrainerDialog(app, trainer) {
569
569
  const before = a.ch.gold;
570
570
  if (learn(a, t).ok) { n++; spent += before - a.ch.gold; }
571
571
  }
572
- a.say(n ? green(`You learn ${plural(n, 'recipe')}${spent ? ` for ${moneyText(spent)}` : ''}.`) : dim('Nothing you can learn yet.'));
572
+ a.say(n ? green(`You learn ${plural(n, 'recipe')}${spent ? ` for ${coinText(spent)}` : ''}.`) : dim('Nothing you can learn yet.'));
573
573
  } },
574
574
  },
575
575
  rows(a) {
576
576
  const ch = a.ch, sk = ch.skills[trainer.skill];
577
577
  const mod = vendorPriceMultiplierFor(ds, ch, trainer.entry);
578
- const rows = [H(`${bold(trainer.name)} ${dim(`<${prof?.name ?? trainer.skill} Trainer>`)} ${yellow(moneyText(ch.gold) || '0c')}`), H(dim(sk ? `Your ${prof?.name} skill: ${sk.current}/${sk.max}` : `You do not know ${prof?.name ?? trainer.skill} yet.`)), H('')];
578
+ const rows = [H(`${bold(trainer.name)} ${dim(`<${prof?.name ?? trainer.skill} Trainer>`)} ${coinText(ch.gold)}`), H(dim(sk ? `Your ${prof?.name} skill: ${sk.current}/${sk.max}` : `You do not know ${prof?.name ?? trainer.skill} yet.`)), H('')];
579
579
  const nr = prof ? nextTrainerRank(ch, prof) : undefined;
580
580
  if (nr) {
581
581
  const cost = trainerSpellCost(nr.cost, mod);
582
582
  const need = nextRankReqSkill(nr, sk);
583
583
  const why = ch.level < nr.reqLevel ? `requires level ${nr.reqLevel}` : (sk?.current ?? 0) < need ? `requires ${prof.name} ${need}` : ch.gold < cost ? 'not enough money' : '';
584
584
  rows.push(H('Rank'), {
585
- text: `${why ? dim(`${sk ? 'Train' : 'Learn'} ${nr.name} ${prof.name}`) : green(`${sk ? 'Train' : 'Learn'} ${nr.name} ${prof.name}`)} ${yellow(moneyText(cost) || 'free')}${why ? red(` ${why}`) : ''}`,
586
- detail: () => [bold(`${nr.name} ${prof.name}`), `Raises your ${prof.name} cap to ${nr.cap}.`, '', `Requires level ${nr.reqLevel}${need ? `, ${prof.name} ${need}` : ''}`, `Cost ${moneyText(cost) || 'free'}`, '', prof.kind !== 'secondary' && !sk ? dim('You can hold two primary professions. Cooking, First Aid and Fishing do not count.') : ''],
585
+ text: `${why ? dim(`${sk ? 'Train' : 'Learn'} ${nr.name} ${prof.name}`) : green(`${sk ? 'Train' : 'Learn'} ${nr.name} ${prof.name}`)} ${coinText(cost, 'free')}${why ? red(` ${why}`) : ''}`,
586
+ detail: () => [bold(`${nr.name} ${prof.name}`), `Raises your ${prof.name} cap to ${nr.cap}.`, '', `Requires level ${nr.reqLevel}${need ? `, ${prof.name} ${need}` : ''}`, `Cost ${coinText(cost, 'free')}`, '', prof.kind !== 'secondary' && !sk ? dim('You can hold two primary professions. Cooking, First Aid and Fishing do not count.') : ''],
587
587
  act: () => a.result(a.g.learnProfession(trainer.entry), `You are now a${/^[AEIOU]/.test(nr.name) ? 'n' : ''} ${nr.name} ${prof.name}.`),
588
588
  }, H(''));
589
589
  } else if (sk) rows.push(H(dim(prof?.kind === 'secondary' && sk.max < 300 ? 'Expert and Artisan come from books, not trainers.' : 'You hold the highest rank they teach.')), H(''));
@@ -598,14 +598,14 @@ export function profTrainerDialog(app, trainer) {
598
598
  const cost = trainerSpellCost(t.def.cost ?? 0, mod);
599
599
  const prod = t.kind === 'recipe' ? ds.item(t.def.product.item) : null;
600
600
  rows.push({
601
- text: `${fit(ch.gold >= cost ? t.def.name : dim(t.def.name), W)} ${yellow(moneyText(cost) || 'free')}`,
602
- detail: () => [...(prod ? itemLines(a, prod).filter((l) => !/Sells for/.test(l)) : [bold(t.def.name), dim(`goes on: ${t.def.slots.join(', ')}`)]), '', ...reagentLines(a, t.def.reagents, t.def.requiredTools, a.g.state.inventory), '', dim(`Requires ${prof?.name} ${t.def.orange} cost ${moneyText(cost) || 'free'}`)],
601
+ text: `${fit(ch.gold >= cost ? t.def.name : dim(t.def.name), W)} ${coinText(cost, 'free')}`,
602
+ detail: () => [...(prod ? itemLines(a, prod).filter((l) => !/Sells for/.test(l)) : [bold(t.def.name), dim(`goes on: ${t.def.slots.join(', ')}`)]), '', ...reagentLines(a, t.def.reagents, t.def.requiredTools, a.g.state.inventory), '', dim(`Requires ${prof?.name} ${t.def.orange} cost ${coinText(cost, 'free')}`)],
603
603
  act: () => a.result(learn(a, t), `You learn ${t.def.name}.`),
604
604
  });
605
605
  }
606
606
  if (later.length) {
607
607
  rows.push(H(''), H('Coming up'));
608
- for (const t of later) rows.push(H(dim(` ${fit(t.def.name, W)} ${prof?.name} ${t.def.orange}${t.reqLevel > ch.level ? `, level ${t.reqLevel}` : ''} ${moneyText(trainerSpellCost(t.def.cost ?? 0, mod))}`)));
608
+ for (const t of later) rows.push(H(dim(` ${fit(t.def.name, W)} ${prof?.name} ${t.def.orange}${t.reqLevel > ch.level ? `, level ${t.reqLevel}` : ''} ${coinText(trainerSpellCost(t.def.cost ?? 0, mod))}`)));
609
609
  }
610
610
  return rows;
611
611
  },
@@ -761,7 +761,7 @@ export function professionsView(app) {
761
761
  };
762
762
  const you = crafterOf(a, null);
763
763
  const mine = profRows(you);
764
- const rows = [H(`Your professions ${yellow(moneyText(a.ch.gold) || '0c')}`)];
764
+ const rows = [H(`Your professions ${coinText(a.ch.gold)}`)];
765
765
  if (mine.length) rows.push(...mine);
766
766
  else rows.push(H(dim(' You have no professions yet. Profession trainers stand in towns and capitals (World tab).')));
767
767
  const comps = companionsOf(g);
@@ -22,10 +22,24 @@ export async function savePicker(app, { onPlay, onNew }) {
22
22
  // Each hero stands in their gear under their line: the saves are read once here for the character they hold.
23
23
  const chars = new Map();
24
24
  for (const m of saves) { try { chars.set(m.slot, (await repo.load(m.slot))?.state?.char ?? null); } catch { /* unreadable: no sprite */ } }
25
+ // A hero's first drawing unpacks their body's art and can take a moment, so paging past them draws only
26
+ // the text: the art waits until the cursor rests on them. One drawn once comes back at once.
27
+ const drawn = new Set();
28
+ let settle = null;
29
+ const restingOn = (m) => {
30
+ if (drawn.has(m.slot)) return true;
31
+ clearTimeout(settle);
32
+ settle = setTimeout(() => {
33
+ if (app.top() !== view || options[view.cursor]?.value.meta?.slot !== m.slot) return;
34
+ drawn.add(m.slot); app.render();
35
+ }, 250);
36
+ return false;
37
+ };
25
38
  const options = saves.map((m) => ({
26
39
  label: `${bold(m.name)} ${dim(`L${m.level} ${className(m.cls)}`)}${busy(m) ? ` ${yellow('open in another terminal')}` : ''}`,
27
40
  detail: () => {
28
41
  const text = [bold(m.name), `Level ${m.level} ${className(m.cls)}`, dim(`last played ${when(m.updatedAt)}`), '', dim('Enter: play m: back up, restore, export, delete')];
42
+ if (!restingOn(m)) return text;
29
43
  const ch = chars.get(m.slot), L = app.layout();
30
44
  const art = ch ? paperdollArt(ch, L.detailW - 2, L.mainH - text.length - 1) : null;
31
45
  return art ? [...text, '', ...art] : text;
@@ -5,7 +5,7 @@
5
5
  // zones it grows in; a zone, a quest, an object. Enter acts on the place: open it when you are already there
6
6
  // (the vendor, the fight, the portal, the node), travel there when you are not. The matching is the wiki's
7
7
  // (substring, then character subsequence), over the wiki's name index plus a gather-node index of our own.
8
- import { bold, dim, yellow, green, red, cyan, qualityPaint, secondsText, plural } from './text.mjs';
8
+ import { bold, dim, yellow, green, red, cyan, qualityPaint, secondsText, plural, coinText } from './text.mjs';
9
9
  import { wikiIndex, wikiTokens, rowMatches } from './wikiIndex.ts';
10
10
  import { wikiSourceIndexSync } from './wikiSources.ts';
11
11
  import { wikiPage } from './tuiWiki.mjs';
@@ -13,7 +13,6 @@ import { itemLines, creatureLines, questLines, travelPreviewLines, travelTo, ven
13
13
  import { profTrainerDialog } from './tuiProfessions.mjs';
14
14
  import { entryDialog } from './tuiDungeon.mjs';
15
15
  import { startFight } from './tuiFight.mjs';
16
- import { moneyText } from '../domain/economy/money.ts';
17
16
  import { vendorPriceMultiplierFor } from '../engine/game/vendorOffers.ts';
18
17
  import { className } from '../domain/core/ids.ts';
19
18
 
@@ -162,7 +161,7 @@ function goTo(app, zoneId, here) {
162
161
  if (!dest) { app.flashMsg('No road leads there.'); return; }
163
162
  const pv = app.g.previewTravel(dest.id);
164
163
  if (!app.result(pv)) return;
165
- app.confirm(`Travel to ${dest.name}? ${secondsText(pv.totalSeconds * 1000)}${pv.totalCopper ? `, ${moneyText(pv.totalCopper)} in fares` : ''}`, () => travelTo(app, dest));
164
+ app.confirm(`Travel to ${dest.name}? ${secondsText(pv.totalSeconds * 1000)}${pv.totalCopper ? `, ${coinText(pv.totalCopper)} in fares` : ''}`, () => travelTo(app, dest));
166
165
  }
167
166
 
168
167
  /** Enter on a place with several zones: travel to the one you pick, the current zone first. */
@@ -183,7 +182,7 @@ function travelHint(app, zones) {
183
182
  const dest = app.ds.zone(zones[0]);
184
183
  const pv = dest ? app.g.previewTravel(dest.id) : null;
185
184
  if (pv && !pv.ok) return red(pv.reason);
186
- return dim(`Enter: travel to ${zoneName(app, zones[0])}${pv ? ` (${secondsText(pv.totalSeconds * 1000)}${pv.totalCopper ? `, ${moneyText(pv.totalCopper)}` : ''})` : ''}${zones.length > 1 ? ', or pick another zone' : ''}`);
185
+ return dim(`Enter: travel to ${zoneName(app, zones[0])}${pv ? ` (${secondsText(pv.totalSeconds * 1000)}${pv.totalCopper ? `, ${coinText(pv.totalCopper)}` : ''})` : ''}${zones.length > 1 ? ', or pick another zone' : ''}`);
187
186
  }
188
187
 
189
188
  // ---- rows per kind ------------------------------------------------------------------------------------------
@@ -328,7 +327,7 @@ function itemRows(app, entry, only) {
328
327
  if (s.kind === 'vendor') {
329
328
  const v = ds.vendor(s.ref);
330
329
  if (!v) continue;
331
- const price = it.buyPrice ? dim(` ${moneyText(Math.floor(it.buyPrice * vendorPriceMultiplierFor(ds, app.ch, v.entry)))}`) : '';
330
+ const price = it.buyPrice ? dim(` ${coinText(Math.floor(it.buyPrice * vendorPriceMultiplierFor(ds, app.ch, v.entry)))}`) : '';
332
331
  rows.push(npcRow(app, s.ref, { indent: `${indent}${dim('sold by')} `, note: price }));
333
332
  } else if (s.kind === 'drop' || s.kind === 'skin' || s.kind === 'pickpocket') {
334
333
  if (!ds.creature(s.ref)) continue;
@@ -2,9 +2,8 @@
2
2
  // disenchanting, the junk rules (what gets sold or destroyed on its own) and which gathering nodes the autopilot
3
3
  // takes. Enter flips or cycles a row. "Every character" settings live in ~/.azsh/prefs.json through the
4
4
  // createGame hooks; "this character" settings live in the save.
5
- import { bold, dim, yellow, green, red, cyan, qualityPaint } from './text.mjs';
5
+ import { bold, dim, yellow, green, red, cyan, qualityPaint, coinText } from './text.mjs';
6
6
  import { autoLootOn } from './tuiLoot.mjs';
7
- import { moneyText } from '../domain/economy/money.ts';
8
7
  import { JUNK_PROTECT_CATEGORIES } from '../domain/items/junkCategories.ts';
9
8
  import { autopilotSettingsDialog } from './tuiAutopilotSettings.mjs';
10
9
  import { paceLabel, nextPace } from './tuiWorld.mjs';
@@ -81,7 +80,7 @@ export function settingsDialog(app) {
81
80
  const eh = g.equipmentHelperConfig();
82
81
  rows.push(H(''), H(bold('Equipment helper')));
83
82
  rows.push({ text: `Helper ${onOff(eh.enabled)}`, detail: () => [bold('Equipment helper'), '', 'Scores what you pick up against what you wear and marks upgrades with ▲.'], act: (a2) => g.setEquipmentHelperConfig({ ...eh, enabled: !eh.enabled }) });
84
- rows.push({ text: `Upgrades ${eh.mode === 'auto' ? green('put on as they drop') : cyan('suggest only')}`, detail: () => [bold('Upgrades'), '', 'Suggest: ▲ on the Gear and Bags tabs, u puts them on.', 'Put on: strict upgrades are worn the moment they land in your bags.'], act: () => g.setEquipmentHelperConfig({ ...eh, mode: eh.mode === 'auto' ? 'suggest' : 'auto' }) });
83
+ rows.push({ text: `Upgrades ${eh.mode === 'auto' ? green('put on as they drop') : cyan('suggest only')}`, detail: () => [bold('Upgrades'), '', 'Suggest: ▲ on the Character and Bags tabs, u puts them on.', 'Put on: strict upgrades are worn the moment they land in your bags.'], act: () => g.setEquipmentHelperConfig({ ...eh, mode: eh.mode === 'auto' ? 'suggest' : 'auto' }) });
85
84
 
86
85
  const deMine = g.characterAutoDisenchantConfig(), deAll = g.globalAutoDisenchantConfig();
87
86
  rows.push(H(''), H(bold('Disenchant on its own')));
@@ -110,7 +109,7 @@ export function settingsDialog(app) {
110
109
  const n = jf[key].length;
111
110
  rows.push({ text: `${label.padEnd(26)}${n ? `${n} item${n === 1 ? '' : 's'}` : dim('none')}`, detail: () => [bold(label), '', ...(n ? jf[key].map((e) => a.itemLabel(e)) : [dim('Empty. m on a bag item adds to it.')]), '', dim('Enter: edit')], act: (a2) => a2.push(junkListDialog(a2, key, label)) });
112
111
  }
113
- rows.push({ text: green('Clear out junk now'), detail: () => [bold('Clear out junk now'), '', 'Sells (when a vendor is open or the rule allows) and destroys what the rules say, once.'], act: (a2) => { const r = g.runAutoJunk(); if (r.sold || r.destroyed) a2.say(green(`Junk: ${r.sold} sold${r.earned ? ` for ${moneyText(r.earned)}` : ''}, ${r.destroyed} destroyed.`)); else a2.flashMsg('Nothing in your bags is junk by these rules.'); } });
112
+ rows.push({ text: green('Clear out junk now'), detail: () => [bold('Clear out junk now'), '', 'Sells (when a vendor is open or the rule allows) and destroys what the rules say, once.'], act: (a2) => { const r = g.runAutoJunk(); if (r.sold || r.destroyed) a2.say(green(`Junk: ${r.sold} sold${r.earned ? ` for ${coinText(r.earned)}` : ''}, ${r.destroyed} destroyed.`)); else a2.flashMsg('Nothing in your bags is junk by these rules.'); } });
114
113
 
115
114
  const gf = g.gatherFilterConfig();
116
115
  rows.push(H(''), H(`${bold('Gathering')} ${dim('which nodes the autopilot mines, picks and skins, by skill colour')}`));
@@ -3,8 +3,7 @@
3
3
  // left and the foe down the right; the dungeon room and the pack are meant to be more actor lists through
4
4
  // the same canvas. Sizes come from the design page: a sprite's box says its rank, and the terminal's
5
5
  // width says which boxes it can afford.
6
- import { bold, dim, cyan, yellow, green, red, gray, inverse, fit, vis, cut, bar, brightBlue, orange, useColor, strip, itemText } from './text.mjs';
7
- import { moneyText } from '../domain/economy/money.ts';
6
+ import { bold, dim, cyan, yellow, green, red, gray, inverse, fit, vis, cut, bar, brightBlue, orange, useColor, strip, itemText, coinText } from './text.mjs';
8
7
  import { className } from '../domain/core/ids.ts';
9
8
  import { iconArt, iconsOn } from './iconArt.mjs';
10
9
  import { sixelOn } from './sixel.mjs';
@@ -15,7 +14,7 @@ export const POWER_PAINT = { mana: brightBlue, rage: red, energy: yellow, focus:
15
14
  export const POP_MS = 1500; // a hit or heal stays on screen this long (fight time)
16
15
  export const POP_RISE_MS = 400; // a pop climbs one row every this long
17
16
  export const LUNGE_MS = 250; // an auto-attack swing moves the sprite two columns toward its target this long
18
- export const SHORT_BUFF_MS = 60_000; // a frame shows only buffs about to run out; the long ones are on the Character tab
17
+ export const SHORT_BUFF_MS = 60_000; // a frame shows only buffs about to run out; the long ones are under s on the Character tab
19
18
  export const ROLE_GLYPH = { tank: cyan('T'), healer: green('H'), damage: red('D') };
20
19
 
21
20
  /** Sprite boxes, columns x rows: a pack add or a pet, dungeon trash, a normal foe, you or an elite, a boss. */
@@ -135,19 +134,19 @@ export function narrowPlate(app, v, { isPlayer, focused, friend, dead, role, ran
135
134
  }
136
135
 
137
136
  /** The state row of a narrow plate, `w` wide: a cast bar that fills under the spell's name (a channel drains
138
- * it), else crowd control, stealth, or "dead"; empty when idle. */
139
- export function narrowState(v, now, w) {
137
+ * it), else crowd control, the cast just done (castSlot), stealth, or "dead"; empty when idle. */
138
+ export function narrowState(v, now, w, recent) {
140
139
  if (v.hp <= 0) return gray('dead');
141
140
  if (v.casting) {
142
- const total = Math.max(1, v.casting.endsAt - v.casting.startedAt);
143
- const left = Math.max(0, v.casting.endsAt - now);
144
- const prog = Math.max(0, Math.min(1, v.casting.channeled ? left / total : 1 - left / total));
141
+ const { prog, left } = castProgress(v.casting, now);
145
142
  const secs = w >= 14 ? ` ${(left / 1000).toFixed(1)}s` : '';
146
143
  const text = fit(cut(v.casting.name, w - secs.length), w - secs.length);
147
144
  const filled = Math.round(prog * vis(text));
148
145
  return `${inverse(yellow(text.slice(0, filled)))}${yellow(text.slice(filled))}${dim(secs)}`;
149
146
  }
150
147
  if (v.controlled) return red(cut(v.controlled.control, w));
148
+ const last = freshCast(recent, now);
149
+ if (last) return cyan(bold(cut(last.name, w)));
151
150
  if (v.stealthed) return dim('stealthed');
152
151
  return '';
153
152
  }
@@ -176,17 +175,74 @@ export function auraRows(app, mem, v, key, plateW, icons) {
176
175
  return rows;
177
176
  }
178
177
 
179
- /** The cast slot's text: a cast bar, else crowd control, else stealth; empty when idle. */
180
- export function castSlot(v, now) {
178
+ export const CAST_NAME_MS = 2000; // a cast just done keeps its name in the cast slot this long (fight time)
179
+ export const CAST_TRAIL_MS = 8000; // and its icon in the trail beside it this long, greyed for the second half
180
+
181
+ /** The cast bar's progress and seconds left, 0..1. */
182
+ function castProgress(casting, now) {
183
+ const total = Math.max(1, casting.endsAt - casting.startedAt);
184
+ const left = Math.max(0, casting.endsAt - now);
185
+ return { prog: Math.max(0, Math.min(1, casting.channeled ? left / total : 1 - left / total)), left };
186
+ }
187
+
188
+ /** The last cast while its icon is still in the trail, with `fresh` set while it keeps its name lit. `recent`
189
+ * is the unit's casts, newest first ({ t, spellId, name, instant }, noteCasts in tuiFight). */
190
+ function lastCast(recent, now) {
191
+ const r = recent?.[0];
192
+ return r && now - r.t < CAST_TRAIL_MS ? { ...r, fresh: now - r.t < CAST_NAME_MS } : null;
193
+ }
194
+ const freshCast = (recent, now) => { const r = lastCast(recent, now); return r?.fresh ? r : null; };
195
+
196
+ /** The cast slot's text: a cast bar, else crowd control, else the cast just done (an instant says so, the
197
+ * casts before it trail after it), else stealth, else the last cast gone dim; empty when idle. */
198
+ export function castSlot(v, now, recent) {
181
199
  if (v.casting) {
182
- const total = v.casting.endsAt - v.casting.startedAt;
183
- const left = Math.max(0, v.casting.endsAt - now);
184
- const prog = v.casting.channeled ? left / total : 1 - left / total;
200
+ const { prog, left } = castProgress(v.casting, now);
185
201
  return `${fit(yellow(v.casting.name), 14)} ${bar(prog, 1, 10, yellow)} ${dim(`${(left / 1000).toFixed(1)}s`)}`;
186
202
  }
187
203
  if (v.controlled) return red(v.controlled.control);
188
- if (v.stealthed) return dim('stealthed');
189
- return '';
204
+ const last = lastCast(recent, now);
205
+ if (!last || (!last.fresh && v.stealthed)) return v.stealthed ? dim('stealthed') : '';
206
+ const before = recent.slice(1).filter((r) => now - r.t < CAST_TRAIL_MS).map((r) => r.name);
207
+ const trail = before.length ? dim(` ‹ ${before.join(' ‹ ')}`) : '';
208
+ return last.fresh ? `${cyan(bold(last.name))}${last.instant ? dim(' instant') : ''}${trail}` : `${dim(last.name)}${trail}`;
209
+ }
210
+
211
+ /**
212
+ * The cast slot two rows tall and `w` wide, where icons draw: the spell's icon, its name beside it and under
213
+ * the name the cast bar (a cast in progress) or "instant" (one just gone off, for CAST_NAME_MS; after that the
214
+ * name stays dim). The icons of the casts before it trail off to the right, newest first, grey once they are
215
+ * half way out, like an aura strip of what you pressed. Crowd control and stealth take the lead as castSlot has them.
216
+ */
217
+ export function castRows(app, v, recent, now, w) {
218
+ const spell = (id) => app.ds.spell(id);
219
+ let lead = null, top = '', under = '';
220
+ const last = lastCast(recent, now);
221
+ if (v.casting) {
222
+ const { prog, left } = castProgress(v.casting, now);
223
+ lead = { spellId: v.casting.spellId, since: v.casting.startedAt };
224
+ top = yellow(v.casting.name);
225
+ under = `${bar(prog, 1, 10, yellow)} ${dim(`${(left / 1000).toFixed(1)}s`)}`;
226
+ } else if (v.controlled) top = red(v.controlled.control);
227
+ else if (last && (last.fresh || !v.stealthed)) {
228
+ lead = { spellId: last.spellId, since: last.t, gray: now - last.t > CAST_TRAIL_MS / 2 };
229
+ top = !last.fresh ? dim(last.name) : now - last.t > CAST_NAME_MS / 2 ? cyan(last.name) : cyan(bold(last.name));
230
+ under = last.fresh && last.instant ? dim('instant') : '';
231
+ } else if (v.stealthed) top = dim('stealthed');
232
+ const nameW = Math.min(16, Math.max(8, w - 5));
233
+ const art = lead ? iconArt(spell(lead.spellId)?.icon, 4, lead.gray ? 'gray' : '') : null;
234
+ const rows = art ? [`${art[0]} ${fit(cut(top, nameW), nameW)}`, `${art[1]} ${fit(cut(under, nameW), nameW)}`] : [fit(cut(top, nameW + 5), nameW + 5), fit(cut(under, nameW + 5), nameW + 5)];
235
+ // The trail: what went off before the lead (a channel is in `recent` from its first tick, so it is the lead,
236
+ // not its own trail).
237
+ const room = Math.floor((w - vis(rows[0]) - 1) / 5);
238
+ const trail = (recent ?? []).filter((r) => now - r.t < CAST_TRAIL_MS && !(lead && r.spellId === lead.spellId && r.t >= lead.since)).slice(0, Math.max(0, room));
239
+ for (const r of trail) {
240
+ const old = now - r.t > CAST_TRAIL_MS / 2;
241
+ const ic = iconArt(spell(r.spellId)?.icon, 4, old ? 'gray' : '');
242
+ rows[0] += ` ${ic ? ic[0] : dim(fit(r.name, 4))}`;
243
+ rows[1] += ` ${ic ? ic[1] : dim(fit(r.name.slice(4), 4))}`;
244
+ }
245
+ return rows.every((l) => !strip(l).trim()) ? ['', ''] : rows;
190
246
  }
191
247
 
192
248
  /** Short buffs green then debuffs red, in the order they first showed up, so a refreshed one keeps its place. */
@@ -256,7 +312,7 @@ const coinIcon = (copper) => (copper < 100 ? 'inv_misc_coin_05' : copper < 10_00
256
312
  /** What the foe dropped, items then the coin: each with its icon name and its line in the quality colour. */
257
313
  export function lootToasts(app, hold) {
258
314
  const out = hold.loot.map((d) => { const it = app.ds.item(d.item); return { icon: it?.icon, text: `${itemText(it, String(d.item))}${d.count > 1 ? dim(` x${d.count}`) : ''}` }; });
259
- if (hold.copper) out.push({ icon: coinIcon(hold.copper), coin: true, text: yellow(moneyText(hold.copper)) });
315
+ if (hold.copper) out.push({ icon: coinIcon(hold.copper), coin: true, text: coinText(hold.copper) });
260
316
  return out;
261
317
  }
262
318
 
@@ -1,19 +1,18 @@
1
1
  // Talent builds for the Talents tab (B): the premade playerbot specs for your class, loadouts you saved, a pasted
2
2
  // talent string (Wowhead-style digits or a classicdb link), and your own string to copy. Applying any of them
3
3
  // is a respec at the usual price when points are already spent.
4
- import { bold, dim, yellow, green, red, cyan } from './text.mjs';
5
- import { moneyText } from '../domain/economy/money.ts';
4
+ import { bold, dim, yellow, green, red, cyan, coinText } from './text.mjs';
6
5
  import { talentLinkFromTalents } from '../domain/character/talentLink.ts';
7
6
  import { linkFromClassicdbHash } from '../domain/character/talentImport.ts';
8
7
 
9
8
  function applied(app, r) {
10
- app.result(r, r.ok ? `Your talents are now ${r.name}${r.cost ? ` (respec ${moneyText(r.cost)})` : ''}.` : '');
9
+ app.result(r, r.ok ? `Your talents are now ${r.name}${r.cost ? ` (respec ${coinText(r.cost)})` : ''}.` : '');
11
10
  }
12
11
 
13
12
  /** Spent points cost a respec; say the price before doing it. */
14
13
  function withRespec(app, label, run) {
15
14
  const cost = app.g.respecCost();
16
- if (cost > 0) app.confirm(`Switch to ${label}? The respec costs ${moneyText(cost)}.`, () => applied(app, run()));
15
+ if (cost > 0) app.confirm(`Switch to ${label}? The respec costs ${coinText(cost)}.`, () => applied(app, run()));
17
16
  else applied(app, run());
18
17
  }
19
18