azeroth-shell 0.1.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/README.md +362 -85
  2. package/bin/azsh.mjs +35 -19
  3. package/package.json +12 -1
  4. package/src/autopilot/plan.ts +8 -11
  5. package/src/autopilot/view.ts +12 -11
  6. package/src/cli/combatLogFormat.ts +22 -0
  7. package/src/cli/gzLoader.mjs +47 -6
  8. package/src/cli/hdArt.mjs +81 -0
  9. package/src/cli/iconArt.mjs +145 -0
  10. package/src/cli/iconGrid.mjs +100 -0
  11. package/src/cli/modelArt.mjs +298 -0
  12. package/src/cli/oocPrep.mjs +32 -0
  13. package/src/cli/paperdoll.mjs +76 -0
  14. package/src/cli/perfCheck.mjs +56 -0
  15. package/src/cli/repl.mjs +48 -7
  16. package/src/cli/rotationText.mjs +6 -6
  17. package/src/cli/sixel.mjs +319 -0
  18. package/src/cli/term.mjs +106 -12
  19. package/src/cli/text.mjs +21 -3
  20. package/src/cli/tui.mjs +262 -102
  21. package/src/cli/tuiAutopilot.mjs +356 -0
  22. package/src/cli/tuiBags.mjs +37 -4
  23. package/src/cli/tuiBg.mjs +370 -41
  24. package/src/cli/tuiCheck.mjs +633 -37
  25. package/src/cli/tuiCity.mjs +146 -41
  26. package/src/cli/tuiCompanionKit.mjs +6 -6
  27. package/src/cli/tuiCompanions.mjs +211 -41
  28. package/src/cli/tuiCreate.mjs +230 -0
  29. package/src/cli/tuiDungeon.mjs +57 -21
  30. package/src/cli/tuiFight.mjs +467 -88
  31. package/src/cli/tuiFightPanes.mjs +42 -14
  32. package/src/cli/tuiFloor.mjs +21 -9
  33. package/src/cli/tuiGear.mjs +17 -14
  34. package/src/cli/tuiLoot.mjs +6 -6
  35. package/src/cli/tuiMap.mjs +214 -0
  36. package/src/cli/tuiPaperdoll.mjs +108 -0
  37. package/src/cli/tuiPartyStage.mjs +206 -0
  38. package/src/cli/tuiPet.mjs +16 -8
  39. package/src/cli/tuiProfessions.mjs +27 -25
  40. package/src/cli/tuiRotationCond.mjs +390 -0
  41. package/src/cli/tuiSaves.mjs +24 -1
  42. package/src/cli/tuiSearch.mjs +79 -27
  43. package/src/cli/tuiSettings.mjs +3 -4
  44. package/src/cli/tuiStage.mjs +360 -0
  45. package/src/cli/tuiTalentTree.mjs +124 -0
  46. package/src/cli/tuiTalents.mjs +3 -4
  47. package/src/cli/tuiViews.mjs +633 -172
  48. package/src/cli/tuiWiki.mjs +2 -3
  49. package/src/cli/tuiWorld.mjs +90 -7
  50. package/src/data/gear-art.bin.br +0 -0
  51. package/src/data/hd-art.json +114 -0
  52. package/src/data/icon-art.bin.gz +0 -0
  53. package/src/data/model-art-worn.bin.br +0 -0
  54. package/src/data/model-art.bin.gz +0 -0
  55. package/src/data/rotations/pve/paladin/retribution.json +2 -2
  56. package/src/data/tables/creatures.json.gz +0 -0
  57. package/src/data/world-map-sixel.bin.br +0 -0
  58. package/src/data/world-map.bin.gz +0 -0
  59. package/src/domain/bg/match.ts +85 -24
  60. package/src/domain/bg/strategies.ts +144 -29
  61. package/src/domain/bg/types.ts +2 -0
  62. package/src/domain/bg/wsg.ts +35 -20
  63. package/src/domain/character/character.ts +6 -0
  64. package/src/domain/pet/pet.ts +31 -0
  65. package/src/domain/quests/doQuest.ts +24 -6
  66. package/src/domain/rotation/build.ts +3031 -3031
  67. package/src/domain/rotation/classes/paladin.ts +16 -11
  68. package/src/domain/rotation/evaluate.ts +3 -0
  69. package/src/engine/combat/applySpell.ts +20 -9
  70. package/src/engine/combat/applySpellLate.ts +16 -12
  71. package/src/engine/combat/areaAura.ts +284 -283
  72. package/src/engine/combat/fighterFactory.ts +1 -1
  73. package/src/engine/combat/pickAction.ts +3494 -3449
  74. package/src/engine/combat/procsDamage.ts +31 -1
  75. package/src/engine/combat/sessionChase.ts +19 -2
  76. package/src/engine/combat/sessionMotionKit.ts +40 -2
  77. package/src/engine/game/loadReconcile.ts +21 -1
  78. package/src/engine/game/pets.ts +33 -1
  79. package/src/engine/game/types.ts +4 -0
  80. package/src/quests/quests.ts +60 -0
@@ -1,10 +1,11 @@
1
- // World search (s on the World tab): "where do I find X?" Type a name and every place that answers is a
1
+ // World search (s on the World tab): "where do I find X?" It starts on the zone you stand in (its vendors,
2
+ // trainers, wares, nodes, creatures); a in the window widens it to the whole world. Every place that answers is a
2
3
  // row: the vendors that sell an item and the zone each stands in, the creatures that drop it, the node that
3
4
  // yields it, who crafts it; an NPC by name and where it is; a dungeon and its entrance; a gather node and the
4
5
  // zones it grows in; a zone, a quest, an object. Enter acts on the place: open it when you are already there
5
6
  // (the vendor, the fight, the portal, the node), travel there when you are not. The matching is the wiki's
6
7
  // (substring, then character subsequence), over the wiki's name index plus a gather-node index of our own.
7
- 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';
8
9
  import { wikiIndex, wikiTokens, rowMatches } from './wikiIndex.ts';
9
10
  import { wikiSourceIndexSync } from './wikiSources.ts';
10
11
  import { wikiPage } from './tuiWiki.mjs';
@@ -12,7 +13,6 @@ import { itemLines, creatureLines, questLines, travelPreviewLines, travelTo, ven
12
13
  import { profTrainerDialog } from './tuiProfessions.mjs';
13
14
  import { entryDialog } from './tuiDungeon.mjs';
14
15
  import { startFight } from './tuiFight.mjs';
15
- import { moneyText } from '../domain/economy/money.ts';
16
16
  import { vendorPriceMultiplierFor } from '../engine/game/vendorOffers.ts';
17
17
  import { className } from '../domain/core/ids.ts';
18
18
 
@@ -63,6 +63,51 @@ function searchPool(ds) {
63
63
  return [...ix.all.filter((r) => WORLD_KINDS.has(r.kind) && !(r.kind === 'object' && nodeNames.has(r.lower))), ...pi.nodeRows];
64
64
  }
65
65
 
66
+ const zoneCache = new WeakMap();
67
+
68
+ /**
69
+ * What one zone holds: the search pool cut to its NPCs, nodes, objects, dungeon doors and quest givers,
70
+ * plus every item its vendors sell, its nodes yield and its creatures and objects drop, each with the
71
+ * sources here (`items`: item entry → sources, the itemSources shape). Keyed on the wiki index so a
72
+ * rebuilt index (catalogs arriving) rebuilds this too.
73
+ */
74
+ export function zoneSearchIndex(ds, zoneId) {
75
+ const ix = wikiIndex(ds);
76
+ let byZone = zoneCache.get(ix);
77
+ if (!byZone) zoneCache.set(ix, (byZone = new Map()));
78
+ const hit = byZone.get(zoneId);
79
+ if (hit) return hit;
80
+ const z = ds.zone(zoneId), pi = placeIndex(ds);
81
+ const nodeNames = new Set(pi.nodeRows.map((r) => r.lower));
82
+ const items = new Map();
83
+ const add = (item, src) => {
84
+ const list = items.get(item);
85
+ if (!list) { items.set(item, [src]); return; }
86
+ const same = list.find((s) => s.kind === src.kind && s.ref === src.ref);
87
+ if (!same) list.push(src);
88
+ else if ((src.chance ?? 0) > (same.chance ?? 0)) Object.assign(same, src);
89
+ };
90
+ const loot = (key, holder) => { for (const row of ds.lootTable(key)) add(row.item, { ...holder, chance: Math.abs(row.chance), ...(row.chance < 0 || row.questRequired ? { questOnly: true } : {}) }); };
91
+ for (const e of z?.vendors ?? []) for (const item of ds.vendor(e)?.items ?? []) add(item, { kind: 'vendor', ref: e });
92
+ for (const id of z?.nodes ?? []) for (const y of ds.gatherNode(id)?.yield ?? []) add(y.item, { kind: 'gather', ref: id, zone: zoneId, chance: y.chance });
93
+ for (const e of z?.huntable ?? []) { const c = ds.creature(e); if (c?.lootId) loot(c.lootId, { kind: 'drop', ref: e }); }
94
+ for (const ge of z?.objects ?? []) { const go = ds.gameObject(ge); if (go?.lootId && !nodeNames.has(go.name.toLowerCase())) loot(`go:${go.lootId}`, { kind: 'object', ref: ge }); }
95
+ const isHere = (r) => {
96
+ switch (r.kind) {
97
+ case 'item': return items.has(r.id);
98
+ case 'npc': return (pi.npcZones.get(r.id) ?? []).includes(zoneId);
99
+ case 'node': return r.zones.includes(zoneId);
100
+ case 'object': return (pi.objectZones.get(r.id) ?? []).includes(zoneId);
101
+ case 'dungeon': return ds.instance(r.id)?.portalZone === zoneId;
102
+ case 'quest': return (ds.quest(r.id)?.starterZones ?? []).includes(zoneId);
103
+ default: return false;
104
+ }
105
+ };
106
+ const built = { pool: searchPool(ds).filter(isHere), items };
107
+ byZone.set(zoneId, built);
108
+ return built;
109
+ }
110
+
66
111
  /**
67
112
  * wikiSearch's buckets (exact, prefix, substring, then subsequence only when nothing contains the words),
68
113
  * with one change for typos: the loose bucket ranks by how little longer the name is than what was typed,
@@ -116,7 +161,7 @@ function goTo(app, zoneId, here) {
116
161
  if (!dest) { app.flashMsg('No road leads there.'); return; }
117
162
  const pv = app.g.previewTravel(dest.id);
118
163
  if (!app.result(pv)) return;
119
- 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));
120
165
  }
121
166
 
122
167
  /** Enter on a place with several zones: travel to the one you pick, the current zone first. */
@@ -137,7 +182,7 @@ function travelHint(app, zones) {
137
182
  const dest = app.ds.zone(zones[0]);
138
183
  const pv = dest ? app.g.previewTravel(dest.id) : null;
139
184
  if (pv && !pv.ok) return red(pv.reason);
140
- 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' : ''}`);
141
186
  }
142
187
 
143
188
  // ---- rows per kind ------------------------------------------------------------------------------------------
@@ -155,10 +200,10 @@ function npcRow(app, entry, { indent = '', note = '' } = {}) {
155
200
  const where = dungeon ? `${cyan(dungeon.name)}${dungeon.portalZone ? dim(`, entrance in ${zoneName(app, dungeon.portalZone)}`) : ''}` : zonesLabel(app, zones);
156
201
  const paint = hostile && c ? conPaint(ch.level, c.level) : (s) => s;
157
202
  const here = () => {
203
+ if (inn) { const i = (app.zone()?.innkeepers ?? []).find((x) => x.entry === entry); if (i) { app.push(innDialog(app, i)); return; } }
158
204
  if (v) { app.push(vendorDialog(app, v)); return; }
159
205
  if (pt) { app.push(profTrainerDialog(app, pt)); return; }
160
206
  if (tr) { app.push(trainerDialog(app, tr)); return; }
161
- if (inn) { const i = (app.zone()?.innkeepers ?? []).find((x) => x.entry === entry); if (i) app.push(innDialog(app, i)); return; }
162
207
  if (hostile && app.zone()?.huntable.includes(entry)) { startFight(app, entry); return; }
163
208
  app.flashMsg(`${name} is around here somewhere.`);
164
209
  };
@@ -232,7 +277,7 @@ function questRow(app, q, { indent = '', note = '' } = {}) {
232
277
  const zones = q.starterZones ?? [];
233
278
  const qs = app.g.state.questlog.quests.find((s) => s.entry === q.entry);
234
279
  return {
235
- text: `${indent}${yellow('!')} ${q.title} ${dim(`Quest L${q.questLevel}`)}${note} ${dim('from')} ${q.starterName || '?'}${zones.length ? `, ${zonesLabel(app, zones)}` : ''}`,
280
+ text: `${indent}${yellow('!')} ${conPaint(app.ch.level, q.questLevel)(q.title)} ${dim(`Quest L${q.questLevel}`)}${note} ${dim('from')} ${q.starterName || '?'}${zones.length ? `, ${zonesLabel(app, zones)}` : ''}`,
236
281
  detail: () => [...questLines(app, q, qs), '', cyan('Starts in'), ...(zones.length ? zones.map((z) => ` ${zoneName(app, z)}`) : [dim(' unknown')]), '', travelHint(app, zones)],
237
282
  act: () => goToOne(app, zones, `Where ${q.title} starts`, () => app.push(questDialog(app, q, qs ? 'log' : 'offer'))),
238
283
  };
@@ -249,12 +294,12 @@ function recipeRow(app, r, { indent = '', note = '' } = {}) {
249
294
  };
250
295
  }
251
296
 
252
- /** An item hit: the item line, then every place that gives it, best first. */
253
- function itemRows(app, entry) {
297
+ /** An item hit: the item line, then every place that gives it, best first. `only` limits it to those sources (a zone search). */
298
+ function itemRows(app, entry, only) {
254
299
  const ds = app.ds, it = ds.item(entry);
255
300
  if (!it) return [];
256
301
  const rows = [{ text: `${qualityPaint(it.quality)(bold(it.name))} ${dim(`Item${it.typeName ? ` ${it.typeName}` : ''}${it.itemLevel ? ` ilvl ${it.itemLevel}` : ''}`)}`, detail: () => [...itemLines(app, it), '', dim('Enter: the wiki page, every source')], act: () => app.push(wikiPage(app, 'item', entry)) }];
257
- const { sources, more } = wikiSourceIndexSync(ds).itemSources(entry);
302
+ const { sources, more } = only ? { sources: only, more: 0 } : wikiSourceIndexSync(ds).itemSources(entry);
258
303
  const here = app.ch.zoneId;
259
304
  const pi = placeIndex(ds);
260
305
  const nodeNames = new Set(pi.nodeRows.map((r) => r.lower));
@@ -282,7 +327,7 @@ function itemRows(app, entry) {
282
327
  if (s.kind === 'vendor') {
283
328
  const v = ds.vendor(s.ref);
284
329
  if (!v) continue;
285
- 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)))}`) : '';
286
331
  rows.push(npcRow(app, s.ref, { indent: `${indent}${dim('sold by')} `, note: price }));
287
332
  } else if (s.kind === 'drop' || s.kind === 'skin' || s.kind === 'pickpocket') {
288
333
  if (!ds.creature(s.ref)) continue;
@@ -312,11 +357,11 @@ function itemRows(app, entry) {
312
357
  return rows;
313
358
  }
314
359
 
315
- /** The rows for one search hit. */
316
- function hitRows(app, r) {
360
+ /** The rows for one search hit. `zoneItems` (a zone search) keeps an item to its sources in that zone. */
361
+ function hitRows(app, r, zoneItems) {
317
362
  const ds = app.ds;
318
363
  switch (r.kind) {
319
- case 'item': return itemRows(app, r.id);
364
+ case 'item': return itemRows(app, r.id, zoneItems?.get(r.id));
320
365
  case 'npc': return [npcRow(app, r.id)];
321
366
  case 'zone': { const z = ds.zone(r.id); return z ? [zoneRow(app, z)] : []; }
322
367
  case 'dungeon': { const d = ds.instance(r.id); return d ? [dungeonRow(app, d)] : []; }
@@ -330,24 +375,31 @@ function hitRows(app, r) {
330
375
 
331
376
  // ---- the dialog ----------------------------------------------------------------------------------------------
332
377
 
333
- /** The search window. `query` starts it filled in. */
334
- export function worldSearchDialog(app, query = '') {
335
- const s = { query };
378
+ /** The search window. `query` starts it filled in; `all` searches the whole world instead of the zone you stand in. */
379
+ export function worldSearchDialog(app, query = '', all = false) {
380
+ const s = { query, all };
381
+ const top = () => { view.cursor = 0; view.scroll = 0; };
336
382
  const view = {
337
- id: 'world-search', title: 'Search the world', hint: 'Enter: go there / open',
383
+ id: 'world-search', title: 'Search', hint: 'Enter: go there / open',
338
384
  keys: {
339
- s: { hint: 'search', fn: (a) => a.prompt('Find in the world: ', (t) => { s.query = t; view.cursor = 0; view.scroll = 0; a.render(); }, s.query) },
385
+ s: { hint: 'search', fn: (a) => a.prompt(s.all ? 'Find in the world: ' : `Find in ${zoneName(a, a.ch.zoneId)}: `, (t) => { s.query = t; top(); a.render(); }, s.query) },
386
+ a: { hint: 'this zone / all', fn: (a) => { s.all = !s.all; top(); a.flashMsg(s.all ? 'Searching the whole world.' : `Searching ${zoneName(a, a.ch.zoneId)} only.`); } },
340
387
  w: { hint: 'wiki', fn: (a, row) => { if (row?.data?.entry) a.push(wikiPage(a, 'npc', row.data.entry)); else a.flashMsg('Open the wiki with /.'); } },
341
388
  },
342
389
  rows(a) {
343
- const rows = [H(s.query ? `Find: ${yellow(s.query)}` : dim('Press s and type a name: an item, a vendor, a creature, a dungeon, an ore or herb, a zone...')), H('')];
390
+ const zone = zoneName(a, a.ch.zoneId);
391
+ view.title = s.all ? 'Search the world' : `Search ${zone}`;
392
+ const scope = s.all ? dim(' (whole world, a: this zone only)') : dim(` (in ${zone}, a: search everywhere)`);
393
+ const rows = [H(s.query ? `Find: ${yellow(s.query)}${scope}` : dim(`Press s and type a name: ${s.all ? 'an item, a vendor, a creature, a dungeon, an ore or herb, a zone...' : `a vendor, a trainer, an item sold or dropped in ${zone}...`}`)), H('')];
344
394
  if (!s.query.trim()) {
345
- rows.push(H(dim(' "mining pick" lists who sells one and where they stand; "copper vein" the zones it is mined in;')), H(dim(' "deadmines" the entrance; "hogger" where he prowls. Enter travels there, or opens it when you are there.')));
395
+ if (s.all) rows.push(H(dim(' "mining pick" lists who sells one and where they stand; "copper vein" the zones it is mined in;')), H(dim(' "deadmines" the entrance; "hogger" where he prowls. Enter travels there, or opens it when you are there.')));
396
+ else rows.push(H(dim(` Only what is in ${zone}: its people, its wares, its nodes and creatures. Enter opens it.`)), H(dim(' a searches the whole world instead.')));
346
397
  return rows;
347
398
  }
348
- const hits = worldSearch(searchPool(a.ds), wikiTokens(s.query));
349
- if (!hits.length) { rows.push(H(dim(' Nothing by that name. The wiki (/) searches spells and talents too.'))); return rows; }
350
- for (const r of hits.slice(0, MAX_HITS)) rows.push(...hitRows(a, r));
399
+ const zi = s.all ? null : zoneSearchIndex(a.ds, a.ch.zoneId);
400
+ const hits = worldSearch(zi ? zi.pool : searchPool(a.ds), wikiTokens(s.query));
401
+ if (!hits.length) { rows.push(H(dim(s.all ? ' Nothing by that name. The wiki (/) searches spells and talents too.' : ` Nothing by that name in ${zone}. Press a to search the whole world.`))); return rows; }
402
+ for (const r of hits.slice(0, MAX_HITS)) rows.push(...hitRows(a, r, zi?.items));
351
403
  if (hits.length > MAX_HITS) rows.push(H(''), H(dim(` ${hits.length - MAX_HITS} more matches. Narrow the search.`)));
352
404
  return rows;
353
405
  },
@@ -355,9 +407,9 @@ export function worldSearchDialog(app, query = '') {
355
407
  return view;
356
408
  }
357
409
 
358
- /** s on the World tab: open the search and start typing. */
359
- export function openWorldSearch(app, query = '') {
360
- const v = worldSearchDialog(app, query);
410
+ /** s on the World tab: open the search on the zone you stand in and start typing (`all`: the whole world). */
411
+ export function openWorldSearch(app, query = '', all = false) {
412
+ const v = worldSearchDialog(app, query, all);
361
413
  app.push(v);
362
414
  if (!query) v.keys.s.fn(app);
363
415
  }
@@ -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')}`));
@@ -0,0 +1,360 @@
1
+ // The stage under a fight: a canvas of W x H cells that sprites, plates, numbers and bars are drawn onto
2
+ // at a column and a row, instead of a list of frames. The duel (tuiFight duelStage) places you down the
3
+ // left and the foe down the right; the dungeon room and the pack are meant to be more actor lists through
4
+ // the same canvas. Sizes come from the design page: a sprite's box says its rank, and the terminal's
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, coinText } from './text.mjs';
7
+ import { className } from '../domain/core/ids.ts';
8
+ import { iconArt, iconsOn } from './iconArt.mjs';
9
+ import { sixelOn } from './sixel.mjs';
10
+
11
+ const RESET = '\x1b[0m';
12
+
13
+ export const POWER_PAINT = { mana: brightBlue, rage: red, energy: yellow, focus: yellow };
14
+ export const POP_MS = 1500; // a hit or heal stays on screen this long (fight time)
15
+ export const POP_RISE_MS = 400; // a pop climbs one row every this long
16
+ export const LUNGE_MS = 250; // an auto-attack swing moves the sprite two columns toward its target this long
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
18
+ export const ROLE_GLYPH = { tank: cyan('T'), healer: green('H'), damage: red('D') };
19
+
20
+ /** Sprite boxes, columns x rows: a pack add or a pet, dungeon trash, a normal foe, you or an elite, a boss. */
21
+ export const SIZES = { xs: [10, 5], s: [14, 7], m: [20, 10], l: [28, 14], xl: [40, 22] };
22
+
23
+ /** The box your own sprite gets at this width: large from 140 columns, medium from 110, a bust under that. */
24
+ export const youSize = (W) => (W >= 140 ? 'l' : W >= 110 ? 'm' : 'xs');
25
+
26
+ /** The foe's box by creature rank (0 normal, 1 elite, 2 rare elite, 3 boss, 4 rare) and the width:
27
+ * a normal foe is medium, an elite or rare large, a boss the largest thing on screen. */
28
+ export function foeSize(W, rank = 0) {
29
+ const byRank = rank === 3 ? 'xl' : rank > 0 ? 'l' : 'm';
30
+ if (W >= 140) return byRank;
31
+ if (W >= 110) return byRank === 'xl' ? 'l' : byRank;
32
+ return 'm';
33
+ }
34
+
35
+ /** The word a rank prints after the level on a plate; nothing for a normal creature. */
36
+ export const RANK_WORD = { 1: 'elite', 2: 'rare elite', 3: 'boss', 4: 'rare' };
37
+
38
+ /** A blank canvas: H lines of W spaces. */
39
+ export const canvas = (W, H) => Array.from({ length: Math.max(0, H) }, () => ' '.repeat(W));
40
+
41
+ /** The painted string without its first `n` visible characters; the colour codes among them stay, so the
42
+ * rest keeps the colour it was in. */
43
+ export function dropVisible(s, n) {
44
+ let i = 0, seen = 0;
45
+ while (i < s.length && seen < n) {
46
+ if (s[i] === '\x1b') { const m = s.slice(i).match(/^\x1b\[[0-9;]*m/); if (m) { i += m[0].length; continue; } }
47
+ i++; seen++;
48
+ }
49
+ return s.slice(i);
50
+ }
51
+
52
+ /** `line` with `text` drawn over it from visible column `x`. The line keeps its width: text past the
53
+ * right edge is cut, and a negative x cuts the text's left. Half-block art cells are self-contained
54
+ * (each resets), so drawing over a sprite leaves the cells around the text intact. */
55
+ export function overlay(line, x, text) {
56
+ const w = vis(line);
57
+ if (x < 0) { text = dropVisible(text, -x); x = 0; }
58
+ const tw = Math.min(vis(text), w - x);
59
+ if (tw <= 0) return line;
60
+ if (vis(text) > tw) text = cut(text, tw);
61
+ const head = cut(line, x), pad = ' '.repeat(Math.max(0, x - vis(head)));
62
+ const tail = dropVisible(line, x + tw);
63
+ return `${head}${pad}${text}${useColor ? RESET : ''}${tail}`;
64
+ }
65
+
66
+ /** Draw `text` at (x, y) on the canvas; nothing when y is off it. */
67
+ export function put(c, x, y, text) {
68
+ if (y < 0 || y >= c.length || !text || !Number.isFinite(x)) return;
69
+ c[y] = overlay(c[y], x, text);
70
+ }
71
+
72
+ /** Draw `lines` down from (x, y). */
73
+ export function putLines(c, x, y, lines) {
74
+ for (let i = 0; i < lines.length; i++) put(c, x, y + i, lines[i]);
75
+ }
76
+
77
+ /** Draw `text` so that its right edge sits at column `right`. */
78
+ export function putRight(c, right, y, text) {
79
+ put(c, right - vis(text), y, text);
80
+ }
81
+
82
+ /** Draw `text` centred on column `mid`. */
83
+ export function putCentred(c, mid, y, text) {
84
+ put(c, Math.round(mid - vis(text) / 2), y, text);
85
+ }
86
+
87
+ /** The ground line: a dark run of low blocks with a bump every so often, so the stage has a floor. */
88
+ export function floorLine(W, paint) {
89
+ if (process.env.AZSH_ASCII) return paint('_'.repeat(W));
90
+ let s = '';
91
+ for (let x = 0; x < W; x++) s += (x * 7) % 23 === 5 ? '▂' : '▁';
92
+ return paint(s);
93
+ }
94
+
95
+ // ---- plates, casts and auras: what hangs under a sprite on any stage, and what a frame row reuses --------
96
+
97
+ /** A unit's plate: name, level and class or rank (the faced foe marked); HP; power. `small` keeps to the
98
+ * first two. Every line is `plateW` wide. */
99
+ export function plateLines(app, v, { isPlayer, focused, friend, small, dead, barW, plateW, rank = 0 }) {
100
+ const paint = dead ? gray : isPlayer ? green : friend ? cyan : focused ? red : yellow;
101
+ const tag = isPlayer ? dim(className(app.ch.cls)) : RANK_WORD[rank] ? orange(RANK_WORD[rank]) : '';
102
+ let head = `${paint(bold(v.name))} ${dim(`L${v.level}`)}${tag ? ` ${tag}` : ''}${dead ? gray(' dead') : ''}`;
103
+ head = focused && !dead ? `${fit(head, plateW - 10)} ${dim('◀ target')}` : fit(head, plateW);
104
+ const hp = `${bar(v.hp, v.maxHp, barW, dead ? gray : v.hp / Math.max(1, v.maxHp) < 0.25 ? red : green)} ${(dead ? gray : (t) => t)(String(v.hp))}${dim(`/${v.maxHp}`)}`;
105
+ if (small) return [head, fit(hp, plateW)];
106
+ const pp = POWER_PAINT[v.powerType] ?? cyan;
107
+ const power = v.maxPower > 0 && !dead ? `${bar(v.power, v.maxPower, barW, pp)} ${pp(String(v.power))}${v.powerType !== 'mana' ? dim(` ${v.powerType}`) : ''}` : '';
108
+ return [head, fit(hp, plateW), fit(power, plateW)];
109
+ }
110
+
111
+ /** A number in up to four cells: 999, 1.2k, 12k, 123k. */
112
+ export function shortNum(n) {
113
+ n = Math.round(n);
114
+ if (n < 1000) return String(n);
115
+ if (n < 10_000) return `${(n / 1000).toFixed(1)}k`;
116
+ return `${Math.round(n / 1000)}k`;
117
+ }
118
+
119
+ /**
120
+ * The narrow plate of a crowded stage, `w` wide: the name (a party member's role letter first, the faced foe
121
+ * red), the HP bar with its percent, the power bar with its figure (blank for a unit without power or dead).
122
+ * The bars are what the plate is for, so nothing else squeezes in; the level and the rank go when `w` is short.
123
+ */
124
+ export function narrowPlate(app, v, { isPlayer, focused, friend, dead, role, rank = 0, w }) {
125
+ const paint = dead ? gray : isPlayer ? green : friend ? cyan : focused ? red : yellow;
126
+ const tag = friend || isPlayer ? `${ROLE_GLYPH[role] ?? dim('·')} ` : '';
127
+ const after = dead ? '' : focused ? red(' ◀') : RANK_WORD[rank] && w >= 18 ? ` ${orange(RANK_WORD[rank])}` : w >= 16 ? ` ${dim(`L${v.level}`)}` : '';
128
+ const room = w - vis(tag) - vis(after);
129
+ const head = `${tag}${paint(bold(cut(v.name, Math.max(1, room))))}${after}`;
130
+ const hp = `${bar(v.hp, v.maxHp, w - 4, dead ? gray : v.hp / Math.max(1, v.maxHp) < 0.25 ? red : green)}${(dead ? gray : (t) => t)(fit(`${Math.round((100 * v.hp) / Math.max(1, v.maxHp))}%`, 4, true))}`;
131
+ const pp = POWER_PAINT[v.powerType] ?? cyan;
132
+ const power = v.maxPower > 0 && !dead ? `${bar(v.power, v.maxPower, w - 5, pp)}${pp(fit(shortNum(v.power), 5, true))}` : '';
133
+ return [fit(head, w), fit(hp, w), fit(power, w)];
134
+ }
135
+
136
+ /** The state row of a narrow plate, `w` wide: a cast bar that fills under the spell's name (a channel drains
137
+ * it), else crowd control, the cast just done (castSlot), stealth, or "dead"; empty when idle. */
138
+ export function narrowState(v, now, w, recent) {
139
+ if (v.hp <= 0) return gray('dead');
140
+ if (v.casting) {
141
+ const { prog, left } = castProgress(v.casting, now);
142
+ const secs = w >= 14 ? ` ${(left / 1000).toFixed(1)}s` : '';
143
+ const text = fit(cut(v.casting.name, w - secs.length), w - secs.length);
144
+ const filled = Math.round(prog * vis(text));
145
+ return `${inverse(yellow(text.slice(0, filled)))}${yellow(text.slice(filled))}${dim(secs)}`;
146
+ }
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)));
150
+ if (v.stealthed) return dim('stealthed');
151
+ return '';
152
+ }
153
+
154
+ /** The aura strip under a plate: short buffs then debuffs in the order they first showed up. With icons
155
+ * three rows (the icon's two, then its time or stacks); without, one row of names. */
156
+ export function auraRows(app, mem, v, key, plateW, icons) {
157
+ if (!icons) return [cut(auraText(app, mem, v, key), plateW)];
158
+ const order = mem.auraOrder.get(key) ?? new Map();
159
+ mem.auraOrder.set(key, order);
160
+ const placed = (list) => {
161
+ for (const a of list) if (!order.has(a.spellId)) order.set(a.spellId, order.size);
162
+ return [...list].sort((a, b) => order.get(a.spellId) - order.get(b.spellId));
163
+ };
164
+ const short = v.buffs.filter((a) => a.remainingMs != null && a.remainingMs < SHORT_BUFF_MS);
165
+ const all = [...placed(short).slice(0, 6).map((a) => [a, green]), ...placed(v.debuffs).slice(0, 6).map((a) => [a, red])].slice(0, Math.floor((plateW + 1) / 5));
166
+ const rows = ['', '', ''];
167
+ for (const [a, paint] of all) {
168
+ const sp = app.ds.spell(a.spellId);
169
+ const art = iconArt(sp?.icon, 4);
170
+ const name = sp?.name ?? String(a.spellId);
171
+ rows[0] += `${art ? art[0] : paint(fit(name, 4))} `;
172
+ rows[1] += `${art ? art[1] : paint(fit(name.slice(4), 4))} `;
173
+ rows[2] += `${a.stacks > 1 ? yellow(fit(`x${a.stacks}`, 4)) : paint(fit(a.remainingMs != null ? auraTime(a.remainingMs).trim() : '', 4))} `;
174
+ }
175
+ return rows;
176
+ }
177
+
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) {
199
+ if (v.casting) {
200
+ const { prog, left } = castProgress(v.casting, now);
201
+ return `${fit(yellow(v.casting.name), 14)} ${bar(prog, 1, 10, yellow)} ${dim(`${(left / 1000).toFixed(1)}s`)}`;
202
+ }
203
+ if (v.controlled) return red(v.controlled.control);
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;
246
+ }
247
+
248
+ /** Short buffs green then debuffs red, in the order they first showed up, so a refreshed one keeps its place. */
249
+ export function auraText(app, mem, v, key) {
250
+ const order = mem.auraOrder.get(key) ?? new Map();
251
+ mem.auraOrder.set(key, order);
252
+ const placed = (list) => {
253
+ for (const a of list) if (!order.has(a.spellId)) order.set(a.spellId, order.size);
254
+ return [...list].sort((a, b) => order.get(a.spellId) - order.get(b.spellId));
255
+ };
256
+ const aura = (a, paint) => paint(`${app.ds.spell(a.spellId)?.name ?? a.spellId}${a.stacks > 1 ? ` x${a.stacks}` : ''}${a.remainingMs != null ? dim(` ${auraTime(a.remainingMs)}`) : ''}`);
257
+ const short = v.buffs.filter((a) => a.remainingMs != null && a.remainingMs < SHORT_BUFF_MS);
258
+ return [...placed(short).slice(0, 6).map((a) => aura(a, green)), ...placed(v.debuffs).slice(0, 6).map((a) => aura(a, red))].join(' ');
259
+ }
260
+
261
+ /** Aura time left at a fixed width: " 9s", " 12s", " 30m". */
262
+ export function auraTime(ms) {
263
+ const s = Math.ceil(ms / 1000);
264
+ return (s < 60 ? `${s}s` : `${Math.ceil(s / 60)}m`).padStart(3);
265
+ }
266
+
267
+ // ---- what moves over a stage ------------------------------------------------------------------------------
268
+
269
+ /**
270
+ * The engine's missiles in flight (spells with a travel speed: a Fireball, an arrow; never a strike or a
271
+ * seal): a glyph with a two-dot trail crossing from the caster's column to the target's, the spell's name
272
+ * over it. A column is `{ key, isPlayer, x, plateW, art, ax, aw }`; `laneRow` is the row the glyphs fly on.
273
+ */
274
+ export function putMissiles(c, s, cols, now, laneRow, spellName) {
275
+ const colOf = (side) => cols.find((x) => x.key === side || (side === 'player' && x.isPlayer));
276
+ for (const m of s.missilesInFlight?.() ?? []) {
277
+ if (now < m.launchAt || m.landAt <= m.launchAt) continue;
278
+ const from = colOf(m.casterSide), to = colOf(m.targetSide);
279
+ if (!from || !to || from === to) continue;
280
+ const edge = (col, side) => (side === 'r' ? (col.art ? col.ax + col.aw : col.x + col.plateW) + 1 : (col.art ? col.ax : col.x) - 1);
281
+ const rightward = from.x < to.x;
282
+ const a = edge(rightward ? from : to, 'r'), b = edge(rightward ? to : from, 'l');
283
+ const glyph = rightward ? `${dim('· ·')} ${yellow(bold('✦'))}` : `${yellow(bold('✦'))} ${dim('· ·')}`;
284
+ const run = Math.max(0, b - a - vis(glyph)), step = Math.floor(Math.min(1, (now - m.launchAt) / (m.landAt - m.launchAt)) * run);
285
+ const x = rightward ? a + step : b - vis(glyph) - step;
286
+ put(c, x, laneRow, glyph);
287
+ const name = spellName(m.spellId);
288
+ if (name && laneRow > 0) put(c, x + (rightward ? 2 : 0), laneRow - 1, dim(cut(name, Math.max(0, b - a))));
289
+ }
290
+ }
291
+
292
+ /** The hits and heals still up on a column's unit, rising one row per POP_RISE_MS above its head, centred on
293
+ * the sprite (above the plate when it has none); the older half of a pop's life is dim. */
294
+ export function putPopsAbove(c, col, pops, now, zone) {
295
+ pops.forEach((p, i) => {
296
+ const text = now - p.t > POP_MS / 2 ? dim(p.text) : p.text;
297
+ const top = col.art ? col.top : zone;
298
+ const mid = col.art ? col.ax + col.aw / 2 : col.x + col.plateW / 2;
299
+ put(c, Math.round(mid - vis(text) / 2), top - 1 - Math.floor((now - p.t) / POP_RISE_MS) - i, text);
300
+ });
301
+ }
302
+
303
+ // ---- the drops after a win -------------------------------------------------------------------------
304
+
305
+ const TOAST_IN_MS = 200; // a drop slides in from the right over this long (phonecraft's loot toast fades in)
306
+ const TOAST_SLIDE = 6; // columns it slides
307
+ const TOAST_OUT_MS = 400; // and the stack greys out over the last this long of the hold
308
+ const POP_GAP = 7; // columns left for the last hit's number between the stack and the corpse
309
+ // The pack has the copper and silver piles; gold has none and draws as a yellow dot.
310
+ const coinIcon = (copper) => (copper < 100 ? 'inv_misc_coin_05' : copper < 10_000 ? 'inv_misc_coin_03' : null);
311
+
312
+ /** What the foe dropped, items then the coin: each with its icon name and its line in the quality colour. */
313
+ export function lootToasts(app, hold) {
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}`) : ''}` }; });
315
+ if (hold.copper) out.push({ icon: coinIcon(hold.copper), coin: true, text: coinText(hold.copper) });
316
+ return out;
317
+ }
318
+
319
+ /** The drops as toasts stacked on the right, like loot in combat text. Each slides out from the right a beat
320
+ * after the one above, icon beside the name, and the stack greys out as the hold ends. Drawn over `lines`
321
+ * (the stage) above the plates, so the 0 under the foe's feet stays in view. */
322
+ export function putLootToasts(app, lines, hold, W, now) {
323
+ const drops = lootToasts(app, hold);
324
+ if (!drops.length) return;
325
+ const icons = iconsOn(), rowH = icons ? 2 : 1, top = 1;
326
+ const textW = Math.min(28, Math.max(...drops.map((d) => vis(d.text))));
327
+ const boxW = (icons ? 5 : 2) + textW, lane = lines.lane;
328
+ const room = Math.max(1, Math.floor(((lane?.rows ?? Math.floor(lines.length * 0.55)) - top) / rowH));
329
+ const shown = drops.length > room ? [...drops.slice(0, room - 1), { more: drops.length - room + 1 }] : drops;
330
+ // Where: the duel's open lane against the corpse, clear of the killing blow's number still rising off its
331
+ // shoulder; else open ground on the stage's right half (not under sixel, whose pictures are blank cells in
332
+ // the text); else the right edge on a cleared card. A win leaves only corpses on the right, so the card
333
+ // covers the dead, never the party.
334
+ const fits = (gap) => lane && lane.right - lane.left - 3 - gap >= boxW;
335
+ const open = fits(POP_GAP) ? lane.right - 1 - POP_GAP - boxW : fits(0) ? lane.right - 1 - boxW : sixelOn() ? -1 : openGround(lines, boxW, shown.length * rowH, top, W);
336
+ const card = open < 0, x0 = card ? W - boxW - 2 : open;
337
+ const fading = now > hold.until - TOAST_OUT_MS;
338
+ shown.forEach((d, i) => {
339
+ const age = now - (i + 1) * hold.stagger;
340
+ if (age < 0) return;
341
+ const sliding = age < TOAST_IN_MS, x = x0 + (sliding ? Math.round((1 - age / TOAST_IN_MS) * TOAST_SLIDE) : 0), y = top + i * rowH;
342
+ const quiet = fading || sliding;
343
+ if (card) for (let r = y - (i ? 0 : 1); r < y + rowH; r++) put(lines, x - 1, r, ' '.repeat(boxW + 2));
344
+ if (d.more) { put(lines, x, y, dim(`+${d.more} more`)); return; }
345
+ const text = cut(quiet ? dim(strip(d.text)) : d.text, textW);
346
+ const dot = d.coin ? (quiet ? dim('●') : yellow('●')) : dim('•');
347
+ const art = icons && d.icon ? iconArt(d.icon, 4, quiet ? 'gray' : '') : null;
348
+ if (art) putLines(lines, x, y, [`${art[0]} ${text}`, art[1]]);
349
+ else put(lines, x + (icons ? 3 : 0), y, `${dot} ${text}`); // no art: the mark sits by the name, the sprite shows round it
350
+ });
351
+ }
352
+
353
+ /** The rightmost column on the stage's right half where a box `w` x `h` from row `top`, with a column of air
354
+ * either side, covers only blank cells; -1 when there is none. */
355
+ function openGround(lines, w, h, top, W) {
356
+ const rows = lines.slice(top, top + h).map(strip);
357
+ if (rows.length < h) return -1;
358
+ for (let x = W - w - 2; x >= Math.floor(W * 0.45); x--) if (rows.every((r) => !r.slice(x - 1, x + w + 1).trim())) return x;
359
+ return -1;
360
+ }