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
@@ -2,16 +2,21 @@
2
2
  // and the loot taken so far. The log is the game's own log (tui.mjs); the meter is phonecraft's DPSMate
3
3
  // aggregator fed every tick's events; threat reads the engine's ThreatManager through the session; loot is a
4
4
  // session-long tally of what the "You receive" lines handed over. The choice sticks between fights.
5
- import { bold, dim, cyan, yellow, green, red, inverse, fit, vis, cut, bar, pct } from './text.mjs';
5
+ import { bold, dim, cyan, yellow, green, red, inverse, fit, vis, cut, bar, pct, coinText } from './text.mjs';
6
6
  import { createDpsMateMeter, partyMeterSnapshot } from '../domain/bots/dpsMate.ts';
7
- import { moneyText } from '../domain/economy/money.ts';
7
+ import { runPaneLines, questPaneLines, bagsPaneLines } from './tuiAutopilot.mjs';
8
+ import { iconsOn, iconArt, iconBlank } from './iconArt.mjs';
8
9
 
9
10
  export const FIGHT_PANES = [
10
11
  { id: 'log', label: 'Log' },
11
12
  { id: 'meter', label: 'Meter' },
12
13
  { id: 'threat', label: 'Threat' },
13
14
  { id: 'loot', label: 'Loot' },
15
+ { id: 'run', label: 'Run' }, // the autopilot's ledger: only once a run exists
16
+ { id: 'quests', label: 'Quests' }, // during a run: the quests it works on
17
+ { id: 'bags', label: 'Bags' }, // during a run: the bags, with a cursor, so items can be used while it plays
14
18
  ];
19
+ const panesOf = (app) => FIGHT_PANES.filter((p) => (p.id === 'run' ? app.auto || app.lastRun : p.id === 'quests' || p.id === 'bags' ? !!app.auto : true));
15
20
  // The meter's views, like a classic meter addon's modes. Each ranks the party by its own number.
16
21
  const METER_VIEWS = [
17
22
  { id: 'damage', title: 'Damage done', value: (a) => a.damage },
@@ -26,10 +31,22 @@ const meterView = (app) => METER_VIEWS.find((v) => v.id === app.meterSort) ?? ME
26
31
 
27
32
  export const fightPane = (app) => app.fightPane ?? 'log';
28
33
 
34
+ /** The pane shows in a fight and on the autopilot's between-fight panel, so a run holds the chosen pane
35
+ * instead of dropping to the log after every kill. */
36
+ export const showsPanes = (app) => !app.stack.length && (app.auto ? !app.auto.browsing : !!app.fight);
37
+
38
+ /** What Tab moves to next ("meter", "threat", "loot", "log"), for the hint line. */
39
+ export function nextPaneId(app) {
40
+ const panes = panesOf(app);
41
+ const i = Math.max(0, panes.findIndex((p) => p.id === fightPane(app)));
42
+ return panes[(i + 1) % panes.length].id;
43
+ }
44
+
29
45
  /** Tab / Shift-Tab in a fight: the next or previous pane. */
30
46
  export function cycleFightPane(app, dir = 1) {
31
- const i = Math.max(0, FIGHT_PANES.findIndex((p) => p.id === fightPane(app)));
32
- app.fightPane = FIGHT_PANES[(i + dir + FIGHT_PANES.length) % FIGHT_PANES.length].id;
47
+ const panes = panesOf(app);
48
+ const i = Math.max(0, panes.findIndex((p) => p.id === fightPane(app)));
49
+ app.fightPane = panes[(i + dir + panes.length) % panes.length].id;
33
50
  app.render();
34
51
  }
35
52
 
@@ -67,21 +84,23 @@ export function noteLoot(app, taken = [], copper = 0) {
67
84
 
68
85
  /** The rule between the frames and the pane. In a fight it carries the pane tabs. */
69
86
  export function fightPaneRule(app, L) {
70
- if (!app.fight || app.stack.length) return dim('─'.repeat(L.W));
87
+ if (!showsPanes(app)) return dim('─'.repeat(L.W));
71
88
  const cur = fightPane(app);
72
- const tabs = FIGHT_PANES.map((p) => (p.id === cur ? inverse(` ${p.label} `) : dim(` ${p.label} `))).join(dim('─'));
89
+ const tabs = panesOf(app).map((p) => (p.id === cur ? inverse(` ${p.label} `) : dim(` ${p.label} `))).join(dim('─'));
73
90
  const left = `${dim('──')}${tabs}`;
74
- const right = `${dim('Tab: switch')}${dim('──')}`;
91
+ const folded = cur === 'log' && app.fight?.folded?.() ? dim(`log folded at ${app.fight.speed}x, - unfolds it `) : '';
92
+ const right = `${folded}${dim('Tab: switch')}${dim('──')}`;
75
93
  const mid = Math.max(0, L.W - vis(left) - vis(right));
76
94
  return cut(`${left}${dim('─'.repeat(mid))}${right}`, L.W);
77
95
  }
78
96
 
79
97
  /** The pane's lines (exactly L.logH of them). */
80
98
  export function fightPaneLines(app, L) {
81
- const ctl = app.fight;
82
- if (!ctl || app.stack.length || fightPane(app) === 'log') return app.logLines(L);
99
+ if (!showsPanes(app) || fightPane(app) === 'log') return app.logLines(L);
100
+ const ctl = app.fight ?? app.lastFight; // between autopilot fights: the one just over
83
101
  const pane = fightPane(app);
84
- const lines = pane === 'meter' ? meterLines(app, ctl, L) : pane === 'threat' ? threatLines(app, ctl, L) : lootLines(app, L);
102
+ const lines = pane === 'run' ? runPaneLines(app, L) : pane === 'quests' ? questPaneLines(app, L) : pane === 'bags' ? bagsPaneLines(app, L)
103
+ : pane === 'loot' ? lootLines(app, L) : !ctl ? [dim(' No fight yet.')] : pane === 'meter' ? meterLines(app, ctl, L) : threatLines(app, ctl, L);
85
104
  const out = lines.slice(0, L.logH).map((l) => fit(l, L.W));
86
105
  while (out.length < L.logH) out.push('');
87
106
  return out;
@@ -211,16 +230,25 @@ function threatLines(app, ctl, L) {
211
230
  function lootLines(app, L) {
212
231
  const t = app.lootTally ?? { rows: [], copper: 0 };
213
232
  const n = t.rows.reduce((a, r) => a + r.count, 0);
214
- const lines = [` ${bold('Loot this sitting')} ${dim(`${n} item${n === 1 ? '' : 's'}${t.copper ? ` · ${moneyText(t.copper)}` : ''}`)}`];
233
+ const lines = [` ${bold('Loot this sitting')} ${dim(`${n} item${n === 1 ? '' : 's'}${t.copper ? ` · ${coinText(t.copper)}` : ''}`)}`];
215
234
  if (!t.rows.length && !t.copper) { lines.push(dim(' Nothing looted yet. Whatever you take shows up here.')); return lines; }
216
235
  const colW = Math.max(24, Math.floor((L.W - 2) / Math.max(1, Math.min(3, Math.floor(L.W / 30)))));
217
236
  const cols = Math.max(1, Math.floor((L.W - 2) / colW));
218
- const cells = t.rows.map((r) => `${app.itemLabel(r.item)}${r.count > 1 ? dim(` x${r.count}`) : ''}`);
219
- const rowsAvail = L.logH - 1;
237
+ // With icons each cell is two lines: a 4px icon down its left, the name beside the top and the count and
238
+ // kind beside the bottom.
239
+ const icons = iconsOn() && L.W >= 40;
240
+ const h = icons ? 2 : 1;
241
+ const cells = t.rows.map((r) => {
242
+ if (!icons) return [`${app.itemLabel(r.item)}${r.count > 1 ? dim(` x${r.count}`) : ''}`];
243
+ const it = app.ds.item(r.item);
244
+ const art = iconArt(it?.icon, 4) ?? iconBlank(4);
245
+ return [`${art[0]} ${app.itemLabel(r.item)}`, `${art[1]} ${dim([r.count > 1 ? `x${r.count}` : '', it?.typeName].filter(Boolean).join(' · '))}`];
246
+ });
247
+ const rowsAvail = Math.floor((L.logH - 1) / h);
220
248
  for (let i = 0; i < rowsAvail; i++) {
221
249
  const slice = cells.slice(i * cols, i * cols + cols);
222
250
  if (!slice.length) break;
223
- lines.push(` ${slice.map((c) => fit(c, colW - 1)).join(' ')}`);
251
+ for (let k = 0; k < h; k++) lines.push(` ${slice.map((c) => fit(c[k], colW - 1)).join(' ')}`);
224
252
  }
225
253
  const shown = Math.min(cells.length, rowsAvail * cols);
226
254
  if (shown < cells.length) lines[lines.length - 1] = fit(lines[lines.length - 1], L.W - 10) + dim(`+${cells.length - shown} more`);
@@ -21,7 +21,7 @@ import { dirname, join } from 'node:path';
21
21
  import { fileURLToPath } from 'node:url';
22
22
  import { bold, dim, yellow, green, red, cyan, fit } from './text.mjs';
23
23
  import { startFight } from './tuiFight.mjs';
24
- import { pullStop, restBetween } from './tuiDungeon.mjs';
24
+ import { pullStop, restBetween, readyUp } from './tuiDungeon.mjs';
25
25
  import { prePull } from './squad.mjs';
26
26
  import { decodeNavMesh, registerNavMeshForMap, navMeshForMap } from '../domain/nav/navmesh.ts';
27
27
  import { bakeSlugForInstance } from '../world/bakeSlug.ts';
@@ -82,9 +82,13 @@ function stampTemplates(app, ents) {
82
82
  /** The floor for the current run, built on first use and kept on the app while the run lasts. */
83
83
  function floorFor(app, def) {
84
84
  const run = app.g.state.instance;
85
- if (app.floor?.dungeonId === run.dungeonId) return app.floor;
86
- // Bodies of stops already cleared stay dead on the rebuilt floor, with any loose kills from before.
87
- const cleared = new Set(app.floorKills?.[run.dungeonId] ?? []);
85
+ // One floor per entry: a hearth or a taxi out never cleared app.floor, so the next run of the dungeon walked the old one.
86
+ const enteredAt = app.g.state.instanceEnteredAtMs ?? null;
87
+ if (app.floor?.dungeonId === run.dungeonId && app.floor.enteredAt === enteredAt) return app.floor;
88
+ // Bodies of stops already cleared stay dead on the rebuilt floor, with any loose kills from before. The kills
89
+ // ride on the run, not the dungeon: a fresh run of the same dungeon stood the last run's dead back up, and an
90
+ // event's body (the Ring of Law's champion, one guid every run) was born dead and paid out unfought.
91
+ const cleared = new Set(run.floorClearedGuids ?? []);
88
92
  for (const enc of def.encounters) if (isCleared(run, enc.id)) for (const gd of enc.spawnGuids ?? []) cleared.add(gd);
89
93
  const bossEntries = new Set(def.encounters.filter((e) => e.kind === 'boss' && e.creatureEntry > 0).map((e) => e.creatureEntry));
90
94
  const world = createContinuousWorld({
@@ -96,7 +100,7 @@ function floorFor(app, def) {
96
100
  skipEntry: (entry) => !bossEntries.has(entry) && isTechnicalTriggerCreature(app.ds.creature(entry)),
97
101
  });
98
102
  stampTemplates(app, world.entities);
99
- const f = { dungeonId: run.dungeonId, def, world, runMs: 0, patrolIndex: new Map(), target: null, walking: false, follow: true, speed: 1, timer: null, script: null, questScanMs: -Infinity, waiting: [], eventHoldUntil: 0,
103
+ const f = { dungeonId: run.dungeonId, enteredAt, def, world, runMs: 0, patrolIndex: new Map(), target: null, walking: false, follow: true, speed: 1, timer: null, script: null, questScanMs: -Infinity, waiting: [], eventHoldUntil: 0,
100
104
  rng: mulberry32(((app.g.state.rngSeed ?? 1) ^ 0x9e3779b9) >>> 0) };
101
105
  f.script = scriptContext(app, f);
102
106
  app.floor = f;
@@ -158,8 +162,13 @@ function markEngaged(f, ents) {
158
162
 
159
163
  function remember(app, f, guids) {
160
164
  resolveKill(f.world, guids);
161
- app.floorKills ??= {};
162
- app.floorKills[f.dungeonId] = [...new Set([...(app.floorKills[f.dungeonId] ?? []), ...guids])];
165
+ keepKills(app, f);
166
+ }
167
+
168
+ /** The floor's dead onto the run, so a step outside keeps them and a fresh run starts without them. */
169
+ function keepKills(app, f) {
170
+ const run = app.g.state.instance;
171
+ if (run?.dungeonId === f.dungeonId) app.g.state.instance = { ...run, floorClearedGuids: [...f.world.clearedGuids] };
163
172
  }
164
173
 
165
174
  /** One floor tick: patrols, the walk, the scripts, then what sees you. Returns true when a pull started. */
@@ -277,7 +286,7 @@ function engage(app, f, ents) {
277
286
  if (!live.length) return false;
278
287
  const fight = app.fight;
279
288
  if (!fight) return fightBodies(app, f, live);
280
- if (!fight.floor) { f.waiting.push(...live); return false; }
289
+ if (!fight.floor || fight.ended) { f.waiting.push(...live); return false; } // a fight over (its win still on screen) takes nobody in
281
290
  let n = 0;
282
291
  for (const e of live) {
283
292
  if (fight.session.sideOfGuid?.(e.guid)) continue;
@@ -339,6 +348,7 @@ function fightBodies(app, f, ents) {
339
348
  /** The event is lost: its summoned bodies go, claimed ones stand down, and it waits a minute before it arms again. */
340
349
  function failEvent(app, f) {
341
350
  f.script.resetGauntletOnWipe();
351
+ keepKills(app, f); // the event's fallen came off the floor's dead; a rebuilt floor must not bring them back dead
342
352
  f.eventHoldUntil = f.runMs + EVENT_HOLD_MS;
343
353
  app.say(red('The event fails. Rest (z) before you go again; it waits a minute.'));
344
354
  }
@@ -398,6 +408,8 @@ function reopen(app) {
398
408
  export function walkTo(app, f, enc) {
399
409
  const dest = enc.origin;
400
410
  if (!dest) { app.flashMsg(`${enc.name} has no place on the floor.`); return; }
411
+ // Buff and drink before setting off: whatever sees you on the way pulls with no time for it.
412
+ readyUp(app);
401
413
  const r = setMoveOrder(f.world, dest, f.def.mapId);
402
414
  f.target = enc.id;
403
415
  f.walking = true;
@@ -407,7 +419,7 @@ export function walkTo(app, f, enc) {
407
419
 
408
420
  /** P on a stop: an event starts where it stands, anything else is pulled from here. */
409
421
  function pullFromHere(app, f, enc) {
410
- if (!floorGauntletWaves(enc)) { pullStop(app, f.def, enc, { floor: true, onTick: () => stepInFight(app, f), onDone: (r) => settle(app, f, r) }); if (app.fight) app.fight.floor = f; return; }
422
+ if (!floorGauntletWaves(enc)) { pullStop(app, f.def, enc, { floor: true, ready: true, onTick: () => stepInFight(app, f), onDone: (r) => settle(app, f, r) }); if (app.fight) app.fight.floor = f; return; }
411
423
  const run = app.g.state.instance;
412
424
  const need = requiredFlags(enc).filter((fl) => !hasFlag(run, fl));
413
425
  if (need.length) { app.flashMsg(`${enc.name} has not been set off yet (${need.join(', ')}).`); return; }
@@ -1,7 +1,6 @@
1
- // The Gear tab's lower rows: what repairs cost and where, the ammo you shoot, and the food, water and elixirs the
1
+ // The Character tab's lower rows: what repairs cost and where, the ammo you shoot, and the food, water and elixirs the
2
2
  // game uses for you out of combat. Per-slot durability and r (repair this one) ride on the slot rows themselves.
3
- import { bold, dim, yellow, green, red, qualityPaint, itemText, secondsText } from './text.mjs';
4
- import { moneyText } from '../domain/economy/money.ts';
3
+ import { bold, dim, yellow, green, red, qualityPaint, itemText, secondsText, coinText } from './text.mjs';
5
4
  import { equippedEntry, equippedInstance } from '../domain/items/itemInstance.ts';
6
5
  import { gearEnchantDisplayName } from '../domain/professions/gearEnchant.ts';
7
6
  import { hasDurability, maxDurability, currentDurability, repairCostSlot } from '../domain/items/durability.ts';
@@ -49,14 +48,23 @@ export function repairOne(app, slot) {
49
48
  const cost = repairCostSlot(app.ch, slot, (e) => app.ds.item(e));
50
49
  if (!cost) { app.flashMsg('That piece needs no repair.'); return; }
51
50
  const r = app.g.repairSlot(slot, q.vendorEntry ?? undefined);
52
- app.result(r, r.ok ? `${q.vendorName} repairs it for ${moneyText(r.spent)}.` : '');
51
+ app.result(r, r.ok ? `${q.vendorName} repairs it for ${coinText(r.spent)}.` : '');
52
+ }
53
+
54
+ /** Enter on Repair all, or R on the Character tab: mend everything you wear at the repairer the quote prices against. */
55
+ export function repairAll(app) {
56
+ const q = app.g.repairQuote();
57
+ if (!q.cost) { app.flashMsg('Nothing needs repair.'); return; }
58
+ if (!q.vendorName) { app.flashMsg('Nobody here repairs.'); return; }
59
+ const r = app.g.repairAll(q.vendorEntry ?? undefined);
60
+ app.result(r, r.ok ? `Repaired for ${coinText(r.spent)}.` : '');
53
61
  }
54
62
 
55
63
  export function slotRepairLines(app, slot) {
56
64
  const cost = repairCostSlot(app.ch, slot, (e) => app.ds.item(e));
57
65
  if (!cost) return [];
58
66
  const q = app.g.repairQuote();
59
- return [q.vendorName ? dim(`r: repair this piece for ${moneyText(cost)} at ${q.vendorName}`) : dim(`Repairing this piece costs about ${moneyText(cost)}.`)];
67
+ return [q.vendorName ? dim(`r: repair this piece for ${coinText(cost)} at ${q.vendorName}`) : dim(`Repairing this piece costs about ${coinText(cost)}.`)];
60
68
  }
61
69
 
62
70
  const rangedWeapon = (app) => { const e = equippedEntry(app.ch.equipped.ranged); return e != null ? app.ds.item(e) : undefined; };
@@ -118,19 +126,14 @@ export function autoConsumablesDialog(app) {
118
126
  };
119
127
  }
120
128
 
121
- /** The rows under the paperdoll on the Gear tab. */
129
+ /** The rows under the paperdoll on the Character tab. */
122
130
  export function gearExtraRows(app) {
123
131
  const rows = [H(''), H('Upkeep')];
124
132
  const q = app.g.repairQuote();
125
133
  rows.push({
126
- text: `Repair all ${q.cost ? yellow(moneyText(q.cost)) : dim('nothing to mend')}${q.cost ? dim(q.vendorName ? ` at ${q.vendorName}` : ' nobody here repairs') : ''}`,
127
- detail: () => [bold('Repair'), '', q.cost ? `Mending everything you wear costs ${moneyText(q.cost)}.` : 'Everything you wear is in good shape.', q.vendorName ? dim(`Priced at ${q.vendorName}.`) : dim('A smith or general goods vendor repairs.'), '', dim('Enter: repair all r on a slot: repair one piece')],
128
- act: (a) => {
129
- if (!q.cost) { a.flashMsg('Nothing needs repair.'); return; }
130
- if (!q.vendorName) { a.flashMsg('Nobody here repairs.'); return; }
131
- const r = a.g.repairAll(q.vendorEntry ?? undefined);
132
- a.result(r, r.ok ? `Repaired for ${moneyText(r.spent)}.` : '');
133
- },
134
+ text: `Repair all ${q.cost ? coinText(q.cost) : dim('nothing to mend')}${q.cost ? dim(q.vendorName ? ` at ${q.vendorName}` : ' nobody here repairs') : ''}`,
135
+ detail: () => [bold('Repair'), '', q.cost ? `Mending everything you wear costs ${coinText(q.cost)}.` : 'Everything you wear is in good shape.', q.vendorName ? dim(`Priced at ${q.vendorName}.`) : dim('A smith or general goods vendor repairs.'), '', dim('Enter: repair all r on a slot: repair one piece')],
136
+ act: (a) => repairAll(a),
134
137
  });
135
138
  const weapon = rangedWeapon(app);
136
139
  const hasAmmo = app.g.state.inventory.stacks.some((s) => isAmmoItem(app.ds.item(s.item)));
@@ -2,9 +2,8 @@
2
2
  // keeps the haul in `state.pendingLoot`; Enter takes one row into the bags, a takes everything that fits, Esc
3
3
  // closes it (outdoor leftovers are gone then, an opened bag item keeps its remainder). With auto-loot on (the
4
4
  // default) fights never hold their loot and this window does not open.
5
- import { bold, dim, yellow, green, red, qualityPaint } from './text.mjs';
5
+ import { bold, dim, yellow, green, red, qualityPaint, coinText } from './text.mjs';
6
6
  import { itemLines } from './tuiViews.mjs';
7
- import { moneyText } from '../domain/economy/money.ts';
8
7
  import { formatItemName } from '../domain/items/itemDisplayName.ts';
9
8
  import { farmNext } from './tuiFight.mjs';
10
9
  import { noteLoot } from './tuiFightPanes.mjs';
@@ -32,7 +31,7 @@ export function takeAll(app) {
32
31
  if (!app.result(r)) return;
33
32
  noteLoot(app, r.taken, r.copper);
34
33
  for (const t of r.taken) app.say(` ${yellow('You receive')} ${app.itemLabel(t.item)}${t.count > 1 ? ` x${t.count}` : ''}`);
35
- if (r.copper) app.say(` ${yellow('You loot')} ${moneyText(r.copper)}`);
34
+ if (r.copper) app.say(` ${yellow('You loot')} ${coinText(r.copper)}`);
36
35
  if (r.remaining) app.say(red(` ${r.remaining} left in there: your bags are full.`));
37
36
  if (r.emptied && app.top()?.id === 'loot') app.pop();
38
37
  }
@@ -69,14 +68,15 @@ export function lootDialog(app) {
69
68
  const rows = [{ text: `${KIND_LABEL[L.kind] ?? L.kind}: ${L.title}`, header: true }];
70
69
  if (L.copper > 0) {
71
70
  rows.push({
72
- text: yellow(moneyText(L.copper)),
73
- detail: () => [bold('Money'), moneyText(L.copper)],
74
- act: (a2) => { const r = a2.g.takeLootMoney(); if (a2.result(r)) { noteLoot(a2, [], r.copper); a2.say(` ${yellow('You loot')} ${moneyText(r.copper)}`); if (r.emptied) a2.pop(); } },
71
+ text: coinText(L.copper),
72
+ detail: () => [bold('Money'), coinText(L.copper)],
73
+ act: (a2) => { const r = a2.g.takeLootMoney(); if (a2.result(r)) { noteLoot(a2, [], r.copper); a2.say(` ${yellow('You loot')} ${coinText(r.copper)}`); if (r.emptied) a2.pop(); } },
75
74
  });
76
75
  }
77
76
  L.rows.forEach((r, i) => {
78
77
  rows.push({
79
78
  text: `${rowName(a, r)}${r.count > 1 ? ` x${r.count}` : ''}${r.blocked ? red(' bags full') : ''}`,
79
+ icon: a.ds.item(r.item)?.icon, sub: dim(a.ds.item(r.item)?.typeName ?? ''),
80
80
  detail: () => itemLines(a, a.ds.item(r.item), r.blocked ? ['', red('No room in your bags for this.')] : []),
81
81
  act: (a2) => {
82
82
  const res = a2.g.takeLoot(i);
@@ -0,0 +1,214 @@
1
+ // The world map on the Travel tab: phonecraft's combined Azeroth terrain map (the picture every zone's
2
+ // `mapPos` is measured on; scripts/bake-world-map.mjs) drawn in the detail pane with pins for where you
3
+ // stand, the zone under the cursor and the route a journey takes between them. On the road the pin walks its
4
+ // leg. Two packs, like the creature art: src/data/world-map.bin.gz as truecolour half-block cells (two map
5
+ // pixels a row), or src/data/world-map-sixel.bin.br as the terminal's own pixels where it draws sixel
6
+ // (sixel.mjs). No pack, no colour, or no room: no map, never an error.
7
+ import { readFileSync } from 'node:fs';
8
+ import { gunzipSync, brotliDecompressSync } from 'node:zlib';
9
+ import { useColor, cyan, yellow, dim, vis } from './text.mjs';
10
+ import { sixelImage, sixelOn, cellSize } from './sixel.mjs';
11
+
12
+ const SMALL_URL = new URL('../data/world-map.bin.gz', import.meta.url);
13
+ const SIXEL_URL = new URL('../data/world-map-sixel.bin.br', import.meta.url);
14
+
15
+ // ---- the packs ----------------------------------------------------------------------------------------
16
+
17
+ /** A pack as { w, h, pal, idx } (a palette index per pixel, every pixel drawn), or null. */
18
+ function parse(buf) {
19
+ if (buf.toString('ascii', 0, 5) !== 'AZMAP' || buf[5] !== 1) return null;
20
+ const w = buf.readUInt16LE(6), h = buf.readUInt16LE(8), palLen = buf[10], palEnd = 11 + palLen * 3;
21
+ const idx = buf.subarray(palEnd, palEnd + w * h);
22
+ return idx.length === w * h ? { w, h, pal: buf.subarray(11, palEnd), idx } : null;
23
+ }
24
+
25
+ let small, big; // undefined: not read yet; null: not there
26
+ function smallMap() {
27
+ if (small === undefined) { try { small = parse(gunzipSync(readFileSync(SMALL_URL))); } catch { small = null; } }
28
+ return small;
29
+ }
30
+ function sixelMap() {
31
+ if (big === undefined) { try { big = parse(brotliDecompressSync(readFileSync(SIXEL_URL))); } catch { big = null; } }
32
+ return big;
33
+ }
34
+
35
+ /** Is there a map to draw? */
36
+ export const hasWorldMap = () => useColor && !!smallMap();
37
+
38
+ /** The map area-averaged to tw x th, as packed rgb. */
39
+ function resample(m, tw, th) {
40
+ const out = new Uint8Array(tw * th * 3), sx = m.w / tw, sy = m.h / th;
41
+ for (let ty = 0; ty < th; ty++) {
42
+ const y0 = Math.floor(ty * sy), y1 = Math.max(y0 + 1, Math.floor((ty + 1) * sy));
43
+ for (let tx = 0; tx < tw; tx++) {
44
+ const x0 = Math.floor(tx * sx), x1 = Math.max(x0 + 1, Math.floor((tx + 1) * sx));
45
+ let r = 0, g = 0, b = 0, n = 0;
46
+ for (let y = y0; y < y1 && y < m.h; y++) for (let x = x0; x < x1 && x < m.w; x++) {
47
+ const p = m.idx[y * m.w + x] * 3;
48
+ r += m.pal[p]; g += m.pal[p + 1]; b += m.pal[p + 2]; n++;
49
+ }
50
+ if (!n) continue;
51
+ const d = (ty * tw + tx) * 3;
52
+ out[d] = r / n; out[d + 1] = g / n; out[d + 2] = b / n;
53
+ }
54
+ }
55
+ return out;
56
+ }
57
+
58
+ // ---- what to pin -----------------------------------------------------------------------------------------
59
+
60
+ /**
61
+ * The pins in map fractions (0..1 from the top-left, a zone's `mapPos`): `you`, walking its leg when a journey
62
+ * is under way; `dest`, the zone under the cursor (a journey's destination when none is given); `route`, the
63
+ * zones the trip passes after you, in order. `legs` is a travel preview's plan (or any list of { to }); a
64
+ * journey under way brings its own legs.
65
+ */
66
+ function marks(app, dest, legs) {
67
+ const ds = app.ds, pos = (id) => ds.zone(id)?.mapPos ?? null;
68
+ const st = app.g.state.char.journey ? app.g.journeyStatus() : null;
69
+ let you = pos(app.ch.zoneId);
70
+ if (st) {
71
+ const i = st.progress.legIndex, leg = st.legs[i];
72
+ const a = leg ? pos(leg.from) : null, b = leg ? pos(leg.to) : null;
73
+ const t = leg?.seconds > 0 ? Math.min(1, Math.max(0, st.progress.legElapsedSeconds / leg.seconds)) : 1;
74
+ // Snapped to 1/250 of the map, so a tick that moves the pin under a pixel does not redraw (or re-encode) it.
75
+ you = a && b ? [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t].map((v) => Math.round(v * 250) / 250) : b ?? a ?? you;
76
+ legs = st.legs.slice(i);
77
+ dest ??= ds.zone(st.destination);
78
+ }
79
+ const route = [];
80
+ for (const l of legs ?? []) { const p = pos(l.to); if (p) route.push(p); }
81
+ return { you, dest: dest?.mapPos ?? null, route };
82
+ }
83
+
84
+ const at3 = (p) => (p ? `${p[0].toFixed(3)},${p[1].toFixed(3)}` : '-');
85
+
86
+ // ---- half-block cells -------------------------------------------------------------------------------------
87
+
88
+ const RESET = '\x1b[0m';
89
+ const YOU = { glyph: '●', rgb: '255;210;60' }, DEST = { glyph: '◆', rgb: '90;220;255' }, ROUTE = { glyph: '·', rgb: '255;240;190' };
90
+
91
+ /** The cells a straight line crosses between two cells (Bresenham), both ends included. */
92
+ function lineCells([x0, y0], [x1, y1]) {
93
+ const out = [], dx = Math.abs(x1 - x0), dy = -Math.abs(y1 - y0), sx = x0 < x1 ? 1 : -1, sy = y0 < y1 ? 1 : -1;
94
+ let err = dx + dy, x = x0, y = y0;
95
+ for (;;) {
96
+ out.push([x, y]);
97
+ if (x === x1 && y === y1) return out;
98
+ const e2 = 2 * err;
99
+ if (e2 >= dy) { err += dy; x += sx; }
100
+ if (e2 <= dx) { err += dx; y += sy; }
101
+ }
102
+ }
103
+
104
+ /** The map as half-block lines that fit maxCols x maxRows keeping its proportions, the pins as glyphs on
105
+ * their cells (the cell's own colour behind them). */
106
+ function halfBlockLines(m, mk, maxCols, maxRows) {
107
+ const scale = Math.min(maxCols / m.w, (maxRows * 2) / m.h);
108
+ const gw = Math.max(2, Math.round(m.w * scale)), gh = Math.max(2, Math.round(m.h * scale) + 1) & ~1, rows = gh / 2;
109
+ const s = resample(m, gw, gh);
110
+ const cell = (p) => [Math.min(gw - 1, Math.floor(p[0] * gw)), Math.min(rows - 1, Math.floor(p[1] * rows))];
111
+ const over = new Map();
112
+ const pts = [mk.you, ...mk.route].filter(Boolean).map(cell);
113
+ for (let i = 0; i + 1 < pts.length; i++) for (const c of lineCells(pts[i], pts[i + 1])) over.set(c.join(), ROUTE);
114
+ if (mk.dest) over.set(cell(mk.dest).join(), DEST);
115
+ if (mk.you) over.set(cell(mk.you).join(), YOU);
116
+ const lines = [];
117
+ for (let y = 0; y < rows; y++) {
118
+ let line = '';
119
+ for (let x = 0; x < gw; x++) {
120
+ const t = (y * 2 * gw + x) * 3, u = ((y * 2 + 1) * gw + x) * 3, o = over.get(`${x},${y}`);
121
+ if (o) line += `\x1b[38;2;${o.rgb}m\x1b[48;2;${(s[t] + s[u]) >> 1};${(s[t + 1] + s[u + 1]) >> 1};${(s[t + 2] + s[u + 2]) >> 1}m${o.glyph}${RESET}`;
122
+ else line += `\x1b[38;2;${s[t]};${s[t + 1]};${s[t + 2]}m\x1b[48;2;${s[u]};${s[u + 1]};${s[u + 2]}m▀${RESET}`;
123
+ }
124
+ lines.push(line);
125
+ }
126
+ return lines;
127
+ }
128
+
129
+ const cache = new Map();
130
+ const CACHE_MAX = 48;
131
+ function remember(key, lines) {
132
+ if (cache.size >= CACHE_MAX) cache.delete(cache.keys().next().value);
133
+ cache.set(key, lines);
134
+ return lines;
135
+ }
136
+
137
+ // ---- sixel pixels ------------------------------------------------------------------------------------------
138
+
139
+ const fitted = new Map(); // `${w}|${tw}x${th}` -> the map resampled to that size, kept for the next pin move
140
+ const FIT_MAX = 4;
141
+
142
+ /** The pins painted on the fitted map: a dashed route, a cyan diamond at the destination, a gold disc on you,
143
+ * each with a dark rim so it reads on any terrain. Sizes follow the picture's width. */
144
+ function paintPixels(base, tw, th, mk) {
145
+ const rgba = new Uint8Array(tw * th * 4);
146
+ for (let i = 0; i < tw * th; i++) { rgba[i * 4] = base[i * 3]; rgba[i * 4 + 1] = base[i * 3 + 1]; rgba[i * 4 + 2] = base[i * 3 + 2]; rgba[i * 4 + 3] = 255; }
147
+ const put = (x, y, c) => { if (x >= 0 && y >= 0 && x < tw && y < th) { const d = (y * tw + x) * 4; rgba[d] = c[0]; rgba[d + 1] = c[1]; rgba[d + 2] = c[2]; } };
148
+ const disc = (cx, cy, r, c) => { for (let y = Math.floor(cy - r); y <= cy + r; y++) for (let x = Math.floor(cx - r); x <= cx + r; x++) if ((x - cx) ** 2 + (y - cy) ** 2 <= r * r) put(x, y, c); };
149
+ const diamond = (cx, cy, r, c) => { for (let y = Math.floor(cy - r); y <= cy + r; y++) for (let x = Math.floor(cx - r); x <= cx + r; x++) if (Math.abs(x - cx) + Math.abs(y - cy) <= r) put(x, y, c); };
150
+ const DARK = [24, 20, 16], r = Math.max(3, tw / 90), px = (p) => [p[0] * tw, p[1] * th];
151
+ const pts = [mk.you, ...mk.route].filter(Boolean).map(px);
152
+ const dash = r * 2.2, gap = r * 1.6;
153
+ for (const [rad, c] of [[r / 2.6 + 1, DARK], [r / 2.6, [255, 240, 190]]]) {
154
+ let along = 0;
155
+ for (let i = 0; i + 1 < pts.length; i++) {
156
+ const [x0, y0] = pts[i], [x1, y1] = pts[i + 1], len = Math.hypot(x1 - x0, y1 - y0);
157
+ for (let d = 0; d <= len; d++) if ((along + d) % (dash + gap) < dash) disc(x0 + ((x1 - x0) * d) / len, y0 + ((y1 - y0) * d) / len, rad, c);
158
+ along += len;
159
+ }
160
+ }
161
+ if (mk.dest) { const [x, y] = px(mk.dest); diamond(x, y, r * 1.5 + 1.5, DARK); diamond(x, y, r * 1.5, [90, 220, 255]); }
162
+ if (mk.you) { const [x, y] = px(mk.you); disc(x, y, r + 1.5, DARK); disc(x, y, r, [255, 210, 60]); disc(x, y, r / 3, DARK); }
163
+ return rgba;
164
+ }
165
+
166
+ /** The map as sixel placeholder lines (sixel.mjs) fitted to maxCols x maxRows, or null when sixel is off. */
167
+ function sixelLines(m, mk, key, maxCols, maxRows) {
168
+ if (!sixelOn()) return null;
169
+ const { w: cw, h: ch } = cellSize();
170
+ const scale = Math.min((maxCols * cw) / m.w, (maxRows * ch) / m.h);
171
+ const tw = Math.max(1, Math.round(m.w * scale)), th = Math.max(1, Math.round(m.h * scale));
172
+ return sixelImage(`map|${m.w}|${tw}x${th}|${key}`, () => {
173
+ const fitKey = `${m.w}|${tw}x${th}`;
174
+ let base = fitted.get(fitKey);
175
+ if (!base) {
176
+ if (fitted.size >= FIT_MAX) fitted.delete(fitted.keys().next().value);
177
+ fitted.set(fitKey, (base = resample(m, tw, th)));
178
+ }
179
+ return { w: tw, h: th, rgba: paintPixels(base, tw, th, mk) };
180
+ }, maxCols, maxRows);
181
+ }
182
+
183
+ // ---- the lines a pane draws ---------------------------------------------------------------------------------
184
+
185
+ /**
186
+ * The world map for a detail pane, as lines no wider than `maxCols` and no more than `maxRows` of them (the
187
+ * last a legend): you pinned where you stand, `dest` (a zone) pinned if given, the `legs` of a planned trip
188
+ * (or the journey under way) drawn as a route. Null without colour, a pack, a position, or room.
189
+ */
190
+ export function worldMapLines(app, { dest = null, legs = null, maxCols, maxRows } = {}) {
191
+ if (!useColor || !(maxCols >= 10) || !(maxRows >= 6)) return null;
192
+ const m = smallMap();
193
+ if (!m) return null;
194
+ const mk = marks(app, dest, legs);
195
+ if (!mk.you && !mk.dest) return null;
196
+ const key = [mk.you, mk.dest, ...mk.route].map(at3).join('|');
197
+ const rows = maxRows - 1;
198
+ let art = sixelLines(sixelMap() ?? m, mk, key, maxCols, rows);
199
+ if (!art) {
200
+ const ck = `${maxCols}x${rows}|${key}`;
201
+ art = cache.get(ck) ?? remember(ck, halfBlockLines(m, mk, maxCols, rows));
202
+ }
203
+ const legend = `${yellow(YOU.glyph)} you${mk.dest ? ` ${cyan(DEST.glyph)} destination` : ''}${mk.route.length ? dim(' ┄ route') : ''}`;
204
+ return [...art, legend];
205
+ }
206
+
207
+ /** `lines` with the map under them, as large as the detail pane has room for; `lines` alone when there is none. */
208
+ export function withWorldMap(app, lines, opts = {}) {
209
+ const L = app.layout?.();
210
+ if (!L || !(L.detailW > 2)) return lines;
211
+ const w = L.detailW - 2, used = lines.reduce((n, l) => n + Math.max(1, Math.ceil(vis(l) / w)), 0);
212
+ const map = worldMapLines(app, { ...opts, maxCols: w, maxRows: L.mainH - used - 1 });
213
+ return map ? [...lines, '', ...map] : lines;
214
+ }
@@ -0,0 +1,108 @@
1
+ // The Character tab as a sheet: you in your gear in the middle, the worn pieces' icons down both sides
2
+ // the way the client's paperdoll lays them out (head to wrists on the left, hands to trinkets on the right,
3
+ // the weapons and the bag bar underneath), each framed in its quality colour. Arrows move between the
4
+ // slots (Up and Down in order, Shift+arrows across the picture), the detail pane is the piece's tooltip, and Enter, x and r act on the slot as they do in the list.
5
+ // The rows are the gear view's own (a slot row carries `cell`); this file only places them. No colour, or a
6
+ // window too small for the sheet, and the view lists its rows as before.
7
+ import { canvas, drawCell, gridStep } from './iconGrid.mjs';
8
+ import { iconsOn } from './iconArt.mjs';
9
+ import { artWidth } from './modelArt.mjs';
10
+ import { bold, dim, green, inverse, fit, cut, vis, qualityPaint, qualityCodes, plural } from './text.mjs';
11
+ import { SLOT_LABEL } from '../domain/items/equipment.ts';
12
+
13
+ const LEFT = ['head', 'neck', 'shoulder', 'back', 'chest', 'shirt', 'wrist'];
14
+ const RIGHT = ['hands', 'waist', 'legs', 'feet', 'finger1', 'finger2', 'trinket1', 'trinket2'];
15
+ const WEAPONS = ['mainhand', 'offhand', 'ranged'];
16
+ const BAGS = ['bag1', 'bag2', 'bag3', 'bag4'];
17
+ const SIDE = RIGHT.length;
18
+ // What an empty slot says on its dark square (four columns at most).
19
+ const SHORT = {
20
+ head: 'Head', neck: 'Neck', shoulder: 'Shld', back: 'Back', chest: 'Chst', shirt: 'Shrt', wrist: 'Wrst',
21
+ hands: 'Hand', waist: 'Wst', legs: 'Legs', feet: 'Feet', finger1: 'Ring', finger2: 'Ring', trinket1: 'Trk', trinket2: 'Trk',
22
+ mainhand: 'MH', offhand: 'OH', ranged: 'Rng', bag1: 'Bag', bag2: 'Bag', bag3: 'Bag', bag4: 'Bag',
23
+ };
24
+
25
+ /** The icon size the sheet takes in a `w` x `h` list pane and how many of the `extras` rows (repair, ammo,
26
+ * outfits) fit under it, or null when it does not fit and the view should list instead. Big icons win over
27
+ * the upkeep rows: R and o reach those from the keys, and the view leaves undrawn ones out of its rows. */
28
+ function sheetSize(w, h, extras) {
29
+ for (const s of [6, 4]) {
30
+ for (const room of [extras, 0]) {
31
+ const need = 2 + SIDE * (s / 2) + 1 + s / 2 + (room ? 1 + room : 0);
32
+ if (h >= need && w >= 7 * (s + 2) + 6) return { s, extras: room };
33
+ }
34
+ }
35
+ return null;
36
+ }
37
+
38
+ /** Will the Character tab draw as the sheet right now? `extras` is how many rows follow the slots. */
39
+ export function paperdollPlan(app, extras) {
40
+ if (!iconsOn()) return null;
41
+ const L = app.layout();
42
+ return L.detailW ? sheetSize(L.listW, L.mainH, extras) : null;
43
+ }
44
+
45
+ /**
46
+ * The sheet's lines for the gear view's `rows` in a `W` x `H` pane, with `body(cols, lines)` drawing the
47
+ * character (playerArt) and `title` heading it, plus where the arrows go; null when it does not fit. `hint`
48
+ * replaces the keys line (a companion's sheet has no repair or outfits).
49
+ */
50
+ export function paperdollGrid(app, rows, W, H, body, title, hint) {
51
+ const extraRows = rows.map((r, i) => ({ r, i })).filter(({ r }) => !r.cell && !r.header);
52
+ const plan = iconsOn() ? sheetSize(W, H, extraRows.length) : null;
53
+ if (!plan) return null;
54
+ const { s } = plan, cw = s + 2, ch = s / 2;
55
+ const cur = app.top()?.cursor ?? 0;
56
+ const cv = canvas(W, H);
57
+ const pts = [];
58
+ const index = new Map(rows.map((r, i) => [r.cell?.slot, i]));
59
+ const top = 2, colH = SIDE * ch;
60
+
61
+ const ups = rows.filter((r) => r.cell?.up).length;
62
+ cv.text(1, 0, `${title ?? bold('Equipment')} ${ups ? green(`▲ ${plural(ups, 'upgrade')} in your bags u: put on all`) : dim(hint ?? `s: stats Shift+arrows: move Enter: change${plan.extras ? '' : ' R: repair all o: outfits'}`)}`);
63
+
64
+ // Labels take a share of the width beside each column; the body gets the middle.
65
+ const lw = Math.max(0, Math.min(22, Math.floor((W - 2 * cw) * 0.28)));
66
+ const midX = cw + lw + 1, midW = W - 2 * cw - 2 * lw - 2;
67
+ const art = midW >= 6 ? body(midW, colH) : null;
68
+ if (art?.length) cv.art(midX + Math.floor((midW - artWidth(art)) / 2), top + Math.max(0, Math.floor((colH - art.length) / 2)), art);
69
+
70
+ const cell = (slot, x, y, px, py, side) => {
71
+ const i = index.get(slot);
72
+ if (i == null) return;
73
+ const c = rows[i].cell, it = c.it;
74
+ const sel = i === cur;
75
+ drawCell(cv, x, y, s, {
76
+ icon: it?.icon, frame: it ? qualityCodes(it.quality) : '90', selected: sel, empty: SHORT[slot],
77
+ mark: c.up ? { text: '▲', codes: '92' } : null,
78
+ });
79
+ pts.push({ i, x: px, y: py });
80
+ if (!side || lw < 4) return;
81
+ const name = it ? qualityPaint(it.quality)(it.name) : dim(SLOT_LABEL[slot] ?? slot);
82
+ const lines = [sel ? bold(name) : name, ...(ch > 2 ? [c.sub ?? '', c.dur ?? ''] : [c.dur || c.sub || ''])].slice(0, ch);
83
+ lines.forEach((t, k) => {
84
+ const text = cut(t, lw - 1);
85
+ cv.text(side === 'left' ? x + cw + 1 : x - 1 - vis(text), y + k, text);
86
+ });
87
+ };
88
+ LEFT.forEach((slot, k) => cell(slot, 0, top + k * ch, 0, k, 'left'));
89
+ RIGHT.forEach((slot, k) => cell(slot, W - cw, top + k * ch, 4, k, 'right'));
90
+
91
+ // Under the columns: the weapons centred under the body, the bag bar at the right edge.
92
+ const by = top + colH + 1;
93
+ const bagsX = W - BAGS.length * cw;
94
+ const wx = Math.max(0, Math.min(Math.round(midX + midW / 2 - 1.5 * cw), bagsX - 3 * cw - 2));
95
+ WEAPONS.forEach((slot, k) => cell(slot, wx + k * cw, by, 1 + k, SIDE));
96
+ BAGS.forEach((slot, k) => cell(slot, bagsX + k * cw, by, 5 + k, SIDE));
97
+
98
+ // Upkeep rows (repair all, ammo, outfits, auto use) under the sheet when they fit.
99
+ const ey = by + ch + 1;
100
+ extraRows.slice(0, plan.extras).forEach(({ r, i }, k) => {
101
+ const sel = i === cur;
102
+ const text = ` ${sel ? '▶' : ' '} ${r.text}`;
103
+ cv.text(0, ey + k, sel ? inverse(fit(text, W - 1)) : text, W - 1);
104
+ pts.push({ i, x: 1, y: SIDE + 1 + k });
105
+ });
106
+
107
+ return { lines: cv.lines(), nav: (dir, from) => gridStep(pts, from, dir, { dyW: 1, dxW: 10 }) };
108
+ }