azeroth-shell 0.1.2 → 0.2.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 (63) hide show
  1. package/README.md +326 -82
  2. package/bin/azsh.mjs +21 -19
  3. package/package.json +9 -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 +135 -0
  10. package/src/cli/iconGrid.mjs +100 -0
  11. package/src/cli/modelArt.mjs +295 -0
  12. package/src/cli/paperdoll.mjs +76 -0
  13. package/src/cli/perfCheck.mjs +56 -0
  14. package/src/cli/repl.mjs +32 -7
  15. package/src/cli/rotationText.mjs +6 -6
  16. package/src/cli/sixel.mjs +307 -0
  17. package/src/cli/term.mjs +106 -12
  18. package/src/cli/text.mjs +5 -3
  19. package/src/cli/tui.mjs +231 -95
  20. package/src/cli/tuiAutopilot.mjs +347 -0
  21. package/src/cli/tuiBg.mjs +370 -41
  22. package/src/cli/tuiCheck.mjs +362 -27
  23. package/src/cli/tuiCity.mjs +118 -13
  24. package/src/cli/tuiCompanions.mjs +25 -15
  25. package/src/cli/tuiCreate.mjs +223 -0
  26. package/src/cli/tuiDungeon.mjs +1 -1
  27. package/src/cli/tuiFight.mjs +423 -86
  28. package/src/cli/tuiFightPanes.mjs +27 -8
  29. package/src/cli/tuiFloor.mjs +1 -1
  30. package/src/cli/tuiGear.mjs +10 -6
  31. package/src/cli/tuiLoot.mjs +1 -0
  32. package/src/cli/tuiPaperdoll.mjs +107 -0
  33. package/src/cli/tuiPartyStage.mjs +177 -0
  34. package/src/cli/tuiProfessions.mjs +4 -2
  35. package/src/cli/tuiRotationCond.mjs +390 -0
  36. package/src/cli/tuiSaves.mjs +10 -1
  37. package/src/cli/tuiSearch.mjs +75 -22
  38. package/src/cli/tuiStage.mjs +304 -0
  39. package/src/cli/tuiTalentTree.mjs +124 -0
  40. package/src/cli/tuiViews.mjs +284 -110
  41. package/src/cli/tuiWorld.mjs +2 -2
  42. package/src/data/gear-art.bin.br +0 -0
  43. package/src/data/hd-art.json +114 -0
  44. package/src/data/icon-art.bin.gz +0 -0
  45. package/src/data/model-art.bin.gz +0 -0
  46. package/src/data/rotations/pve/paladin/retribution.json +2 -2
  47. package/src/domain/bg/match.ts +85 -24
  48. package/src/domain/bg/strategies.ts +144 -29
  49. package/src/domain/bg/types.ts +2 -0
  50. package/src/domain/bg/wsg.ts +35 -20
  51. package/src/domain/quests/doQuest.ts +24 -6
  52. package/src/domain/rotation/build.ts +3031 -3031
  53. package/src/domain/rotation/classes/paladin.ts +16 -11
  54. package/src/domain/rotation/evaluate.ts +3 -0
  55. package/src/engine/combat/applySpell.ts +20 -9
  56. package/src/engine/combat/applySpellLate.ts +16 -12
  57. package/src/engine/combat/areaAura.ts +284 -283
  58. package/src/engine/combat/fighterFactory.ts +1 -1
  59. package/src/engine/combat/pickAction.ts +39 -1
  60. package/src/engine/combat/procsDamage.ts +31 -1
  61. package/src/engine/combat/sessionChase.ts +7 -1
  62. package/src/engine/combat/sessionMotionKit.ts +40 -2
  63. package/src/quests/quests.ts +60 -0
@@ -0,0 +1,304 @@
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 } from './text.mjs';
7
+ import { moneyText } from '../domain/economy/money.ts';
8
+ import { className } from '../domain/core/ids.ts';
9
+ import { iconArt, iconsOn } from './iconArt.mjs';
10
+ import { sixelOn } from './sixel.mjs';
11
+
12
+ const RESET = '\x1b[0m';
13
+
14
+ export const POWER_PAINT = { mana: brightBlue, rage: red, energy: yellow, focus: yellow };
15
+ export const POP_MS = 1500; // a hit or heal stays on screen this long (fight time)
16
+ export const POP_RISE_MS = 400; // a pop climbs one row every this long
17
+ 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
19
+ export const ROLE_GLYPH = { tank: cyan('T'), healer: green('H'), damage: red('D') };
20
+
21
+ /** Sprite boxes, columns x rows: a pack add or a pet, dungeon trash, a normal foe, you or an elite, a boss. */
22
+ export const SIZES = { xs: [10, 5], s: [14, 7], m: [20, 10], l: [28, 14], xl: [40, 22] };
23
+
24
+ /** The box your own sprite gets at this width: large from 140 columns, medium from 110, a bust under that. */
25
+ export const youSize = (W) => (W >= 140 ? 'l' : W >= 110 ? 'm' : 'xs');
26
+
27
+ /** The foe's box by creature rank (0 normal, 1 elite, 2 rare elite, 3 boss, 4 rare) and the width:
28
+ * a normal foe is medium, an elite or rare large, a boss the largest thing on screen. */
29
+ export function foeSize(W, rank = 0) {
30
+ const byRank = rank === 3 ? 'xl' : rank > 0 ? 'l' : 'm';
31
+ if (W >= 140) return byRank;
32
+ if (W >= 110) return byRank === 'xl' ? 'l' : byRank;
33
+ return 'm';
34
+ }
35
+
36
+ /** The word a rank prints after the level on a plate; nothing for a normal creature. */
37
+ export const RANK_WORD = { 1: 'elite', 2: 'rare elite', 3: 'boss', 4: 'rare' };
38
+
39
+ /** A blank canvas: H lines of W spaces. */
40
+ export const canvas = (W, H) => Array.from({ length: Math.max(0, H) }, () => ' '.repeat(W));
41
+
42
+ /** The painted string without its first `n` visible characters; the colour codes among them stay, so the
43
+ * rest keeps the colour it was in. */
44
+ export function dropVisible(s, n) {
45
+ let i = 0, seen = 0;
46
+ while (i < s.length && seen < n) {
47
+ if (s[i] === '\x1b') { const m = s.slice(i).match(/^\x1b\[[0-9;]*m/); if (m) { i += m[0].length; continue; } }
48
+ i++; seen++;
49
+ }
50
+ return s.slice(i);
51
+ }
52
+
53
+ /** `line` with `text` drawn over it from visible column `x`. The line keeps its width: text past the
54
+ * right edge is cut, and a negative x cuts the text's left. Half-block art cells are self-contained
55
+ * (each resets), so drawing over a sprite leaves the cells around the text intact. */
56
+ export function overlay(line, x, text) {
57
+ const w = vis(line);
58
+ if (x < 0) { text = dropVisible(text, -x); x = 0; }
59
+ const tw = Math.min(vis(text), w - x);
60
+ if (tw <= 0) return line;
61
+ if (vis(text) > tw) text = cut(text, tw);
62
+ const head = cut(line, x), pad = ' '.repeat(Math.max(0, x - vis(head)));
63
+ const tail = dropVisible(line, x + tw);
64
+ return `${head}${pad}${text}${useColor ? RESET : ''}${tail}`;
65
+ }
66
+
67
+ /** Draw `text` at (x, y) on the canvas; nothing when y is off it. */
68
+ export function put(c, x, y, text) {
69
+ if (y < 0 || y >= c.length || !text || !Number.isFinite(x)) return;
70
+ c[y] = overlay(c[y], x, text);
71
+ }
72
+
73
+ /** Draw `lines` down from (x, y). */
74
+ export function putLines(c, x, y, lines) {
75
+ for (let i = 0; i < lines.length; i++) put(c, x, y + i, lines[i]);
76
+ }
77
+
78
+ /** Draw `text` so that its right edge sits at column `right`. */
79
+ export function putRight(c, right, y, text) {
80
+ put(c, right - vis(text), y, text);
81
+ }
82
+
83
+ /** Draw `text` centred on column `mid`. */
84
+ export function putCentred(c, mid, y, text) {
85
+ put(c, Math.round(mid - vis(text) / 2), y, text);
86
+ }
87
+
88
+ /** The ground line: a dark run of low blocks with a bump every so often, so the stage has a floor. */
89
+ export function floorLine(W, paint) {
90
+ if (process.env.AZSH_ASCII) return paint('_'.repeat(W));
91
+ let s = '';
92
+ for (let x = 0; x < W; x++) s += (x * 7) % 23 === 5 ? '▂' : '▁';
93
+ return paint(s);
94
+ }
95
+
96
+ // ---- plates, casts and auras: what hangs under a sprite on any stage, and what a frame row reuses --------
97
+
98
+ /** A unit's plate: name, level and class or rank (the faced foe marked); HP; power. `small` keeps to the
99
+ * first two. Every line is `plateW` wide. */
100
+ export function plateLines(app, v, { isPlayer, focused, friend, small, dead, barW, plateW, rank = 0 }) {
101
+ const paint = dead ? gray : isPlayer ? green : friend ? cyan : focused ? red : yellow;
102
+ const tag = isPlayer ? dim(className(app.ch.cls)) : RANK_WORD[rank] ? orange(RANK_WORD[rank]) : '';
103
+ let head = `${paint(bold(v.name))} ${dim(`L${v.level}`)}${tag ? ` ${tag}` : ''}${dead ? gray(' dead') : ''}`;
104
+ head = focused && !dead ? `${fit(head, plateW - 10)} ${dim('◀ target')}` : fit(head, plateW);
105
+ 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}`)}`;
106
+ if (small) return [head, fit(hp, plateW)];
107
+ const pp = POWER_PAINT[v.powerType] ?? cyan;
108
+ const power = v.maxPower > 0 && !dead ? `${bar(v.power, v.maxPower, barW, pp)} ${pp(String(v.power))}${v.powerType !== 'mana' ? dim(` ${v.powerType}`) : ''}` : '';
109
+ return [head, fit(hp, plateW), fit(power, plateW)];
110
+ }
111
+
112
+ /** A number in up to four cells: 999, 1.2k, 12k, 123k. */
113
+ export function shortNum(n) {
114
+ n = Math.round(n);
115
+ if (n < 1000) return String(n);
116
+ if (n < 10_000) return `${(n / 1000).toFixed(1)}k`;
117
+ return `${Math.round(n / 1000)}k`;
118
+ }
119
+
120
+ /**
121
+ * The narrow plate of a crowded stage, `w` wide: the name (a party member's role letter first, the faced foe
122
+ * red), the HP bar with its percent, the power bar with its figure (blank for a unit without power or dead).
123
+ * The bars are what the plate is for, so nothing else squeezes in; the level and the rank go when `w` is short.
124
+ */
125
+ export function narrowPlate(app, v, { isPlayer, focused, friend, dead, role, rank = 0, w }) {
126
+ const paint = dead ? gray : isPlayer ? green : friend ? cyan : focused ? red : yellow;
127
+ const tag = friend || isPlayer ? `${ROLE_GLYPH[role] ?? dim('·')} ` : '';
128
+ const after = dead ? '' : focused ? red(' ◀') : RANK_WORD[rank] && w >= 18 ? ` ${orange(RANK_WORD[rank])}` : w >= 16 ? ` ${dim(`L${v.level}`)}` : '';
129
+ const room = w - vis(tag) - vis(after);
130
+ const head = `${tag}${paint(bold(cut(v.name, Math.max(1, room))))}${after}`;
131
+ 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))}`;
132
+ const pp = POWER_PAINT[v.powerType] ?? cyan;
133
+ const power = v.maxPower > 0 && !dead ? `${bar(v.power, v.maxPower, w - 5, pp)}${pp(fit(shortNum(v.power), 5, true))}` : '';
134
+ return [fit(head, w), fit(hp, w), fit(power, w)];
135
+ }
136
+
137
+ /** 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) {
140
+ if (v.hp <= 0) return gray('dead');
141
+ 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));
145
+ const secs = w >= 14 ? ` ${(left / 1000).toFixed(1)}s` : '';
146
+ const text = fit(cut(v.casting.name, w - secs.length), w - secs.length);
147
+ const filled = Math.round(prog * vis(text));
148
+ return `${inverse(yellow(text.slice(0, filled)))}${yellow(text.slice(filled))}${dim(secs)}`;
149
+ }
150
+ if (v.controlled) return red(cut(v.controlled.control, w));
151
+ if (v.stealthed) return dim('stealthed');
152
+ return '';
153
+ }
154
+
155
+ /** The aura strip under a plate: short buffs then debuffs in the order they first showed up. With icons
156
+ * three rows (the icon's two, then its time or stacks); without, one row of names. */
157
+ export function auraRows(app, mem, v, key, plateW, icons) {
158
+ if (!icons) return [cut(auraText(app, mem, v, key), plateW)];
159
+ const order = mem.auraOrder.get(key) ?? new Map();
160
+ mem.auraOrder.set(key, order);
161
+ const placed = (list) => {
162
+ for (const a of list) if (!order.has(a.spellId)) order.set(a.spellId, order.size);
163
+ return [...list].sort((a, b) => order.get(a.spellId) - order.get(b.spellId));
164
+ };
165
+ const short = v.buffs.filter((a) => a.remainingMs != null && a.remainingMs < SHORT_BUFF_MS);
166
+ 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));
167
+ const rows = ['', '', ''];
168
+ for (const [a, paint] of all) {
169
+ const sp = app.ds.spell(a.spellId);
170
+ const art = iconArt(sp?.icon, 4);
171
+ const name = sp?.name ?? String(a.spellId);
172
+ rows[0] += `${art ? art[0] : paint(fit(name, 4))} `;
173
+ rows[1] += `${art ? art[1] : paint(fit(name.slice(4), 4))} `;
174
+ rows[2] += `${a.stacks > 1 ? yellow(fit(`x${a.stacks}`, 4)) : paint(fit(a.remainingMs != null ? auraTime(a.remainingMs).trim() : '', 4))} `;
175
+ }
176
+ return rows;
177
+ }
178
+
179
+ /** The cast slot's text: a cast bar, else crowd control, else stealth; empty when idle. */
180
+ export function castSlot(v, now) {
181
+ 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;
185
+ return `${fit(yellow(v.casting.name), 14)} ${bar(prog, 1, 10, yellow)} ${dim(`${(left / 1000).toFixed(1)}s`)}`;
186
+ }
187
+ if (v.controlled) return red(v.controlled.control);
188
+ if (v.stealthed) return dim('stealthed');
189
+ return '';
190
+ }
191
+
192
+ /** Short buffs green then debuffs red, in the order they first showed up, so a refreshed one keeps its place. */
193
+ export function auraText(app, mem, v, key) {
194
+ const order = mem.auraOrder.get(key) ?? new Map();
195
+ mem.auraOrder.set(key, order);
196
+ const placed = (list) => {
197
+ for (const a of list) if (!order.has(a.spellId)) order.set(a.spellId, order.size);
198
+ return [...list].sort((a, b) => order.get(a.spellId) - order.get(b.spellId));
199
+ };
200
+ 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)}`) : ''}`);
201
+ const short = v.buffs.filter((a) => a.remainingMs != null && a.remainingMs < SHORT_BUFF_MS);
202
+ return [...placed(short).slice(0, 6).map((a) => aura(a, green)), ...placed(v.debuffs).slice(0, 6).map((a) => aura(a, red))].join(' ');
203
+ }
204
+
205
+ /** Aura time left at a fixed width: " 9s", " 12s", " 30m". */
206
+ export function auraTime(ms) {
207
+ const s = Math.ceil(ms / 1000);
208
+ return (s < 60 ? `${s}s` : `${Math.ceil(s / 60)}m`).padStart(3);
209
+ }
210
+
211
+ // ---- what moves over a stage ------------------------------------------------------------------------------
212
+
213
+ /**
214
+ * The engine's missiles in flight (spells with a travel speed: a Fireball, an arrow; never a strike or a
215
+ * seal): a glyph with a two-dot trail crossing from the caster's column to the target's, the spell's name
216
+ * over it. A column is `{ key, isPlayer, x, plateW, art, ax, aw }`; `laneRow` is the row the glyphs fly on.
217
+ */
218
+ export function putMissiles(c, s, cols, now, laneRow, spellName) {
219
+ const colOf = (side) => cols.find((x) => x.key === side || (side === 'player' && x.isPlayer));
220
+ for (const m of s.missilesInFlight?.() ?? []) {
221
+ if (now < m.launchAt || m.landAt <= m.launchAt) continue;
222
+ const from = colOf(m.casterSide), to = colOf(m.targetSide);
223
+ if (!from || !to || from === to) continue;
224
+ const edge = (col, side) => (side === 'r' ? (col.art ? col.ax + col.aw : col.x + col.plateW) + 1 : (col.art ? col.ax : col.x) - 1);
225
+ const rightward = from.x < to.x;
226
+ const a = edge(rightward ? from : to, 'r'), b = edge(rightward ? to : from, 'l');
227
+ const glyph = rightward ? `${dim('· ·')} ${yellow(bold('✦'))}` : `${yellow(bold('✦'))} ${dim('· ·')}`;
228
+ const run = Math.max(0, b - a - vis(glyph)), step = Math.floor(Math.min(1, (now - m.launchAt) / (m.landAt - m.launchAt)) * run);
229
+ const x = rightward ? a + step : b - vis(glyph) - step;
230
+ put(c, x, laneRow, glyph);
231
+ const name = spellName(m.spellId);
232
+ if (name && laneRow > 0) put(c, x + (rightward ? 2 : 0), laneRow - 1, dim(cut(name, Math.max(0, b - a))));
233
+ }
234
+ }
235
+
236
+ /** The hits and heals still up on a column's unit, rising one row per POP_RISE_MS above its head, centred on
237
+ * the sprite (above the plate when it has none); the older half of a pop's life is dim. */
238
+ export function putPopsAbove(c, col, pops, now, zone) {
239
+ pops.forEach((p, i) => {
240
+ const text = now - p.t > POP_MS / 2 ? dim(p.text) : p.text;
241
+ const top = col.art ? col.top : zone;
242
+ const mid = col.art ? col.ax + col.aw / 2 : col.x + col.plateW / 2;
243
+ put(c, Math.round(mid - vis(text) / 2), top - 1 - Math.floor((now - p.t) / POP_RISE_MS) - i, text);
244
+ });
245
+ }
246
+
247
+ // ---- the drops after a win -------------------------------------------------------------------------
248
+
249
+ const TOAST_IN_MS = 200; // a drop slides in from the right over this long (phonecraft's loot toast fades in)
250
+ const TOAST_SLIDE = 6; // columns it slides
251
+ const TOAST_OUT_MS = 400; // and the stack greys out over the last this long of the hold
252
+ const POP_GAP = 7; // columns left for the last hit's number between the stack and the corpse
253
+ // The pack has the copper and silver piles; gold has none and draws as a yellow dot.
254
+ const coinIcon = (copper) => (copper < 100 ? 'inv_misc_coin_05' : copper < 10_000 ? 'inv_misc_coin_03' : null);
255
+
256
+ /** What the foe dropped, items then the coin: each with its icon name and its line in the quality colour. */
257
+ export function lootToasts(app, hold) {
258
+ 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)) });
260
+ return out;
261
+ }
262
+
263
+ /** The drops as toasts stacked on the right, like loot in combat text. Each slides out from the right a beat
264
+ * after the one above, icon beside the name, and the stack greys out as the hold ends. Drawn over `lines`
265
+ * (the stage) above the plates, so the 0 under the foe's feet stays in view. */
266
+ export function putLootToasts(app, lines, hold, W, now) {
267
+ const drops = lootToasts(app, hold);
268
+ if (!drops.length) return;
269
+ const icons = iconsOn(), rowH = icons ? 2 : 1, top = 1;
270
+ const textW = Math.min(28, Math.max(...drops.map((d) => vis(d.text))));
271
+ const boxW = (icons ? 5 : 2) + textW, lane = lines.lane;
272
+ const room = Math.max(1, Math.floor(((lane?.rows ?? Math.floor(lines.length * 0.55)) - top) / rowH));
273
+ const shown = drops.length > room ? [...drops.slice(0, room - 1), { more: drops.length - room + 1 }] : drops;
274
+ // Where: the duel's open lane against the corpse, clear of the killing blow's number still rising off its
275
+ // shoulder; else open ground on the stage's right half (not under sixel, whose pictures are blank cells in
276
+ // the text); else the right edge on a cleared card. A win leaves only corpses on the right, so the card
277
+ // covers the dead, never the party.
278
+ const fits = (gap) => lane && lane.right - lane.left - 3 - gap >= boxW;
279
+ 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);
280
+ const card = open < 0, x0 = card ? W - boxW - 2 : open;
281
+ const fading = now > hold.until - TOAST_OUT_MS;
282
+ shown.forEach((d, i) => {
283
+ const age = now - (i + 1) * hold.stagger;
284
+ if (age < 0) return;
285
+ const sliding = age < TOAST_IN_MS, x = x0 + (sliding ? Math.round((1 - age / TOAST_IN_MS) * TOAST_SLIDE) : 0), y = top + i * rowH;
286
+ const quiet = fading || sliding;
287
+ if (card) for (let r = y - (i ? 0 : 1); r < y + rowH; r++) put(lines, x - 1, r, ' '.repeat(boxW + 2));
288
+ if (d.more) { put(lines, x, y, dim(`+${d.more} more`)); return; }
289
+ const text = cut(quiet ? dim(strip(d.text)) : d.text, textW);
290
+ const dot = d.coin ? (quiet ? dim('●') : yellow('●')) : dim('•');
291
+ const art = icons && d.icon ? iconArt(d.icon, 4, quiet ? 'gray' : '') : null;
292
+ if (art) putLines(lines, x, y, [`${art[0]} ${text}`, art[1]]);
293
+ else put(lines, x + (icons ? 3 : 0), y, `${dot} ${text}`); // no art: the mark sits by the name, the sprite shows round it
294
+ });
295
+ }
296
+
297
+ /** The rightmost column on the stage's right half where a box `w` x `h` from row `top`, with a column of air
298
+ * either side, covers only blank cells; -1 when there is none. */
299
+ function openGround(lines, w, h, top, W) {
300
+ const rows = lines.slice(top, top + h).map(strip);
301
+ if (rows.length < h) return -1;
302
+ 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;
303
+ return -1;
304
+ }
@@ -0,0 +1,124 @@
1
+ // The Talents tab as the client's trees: each tree a 4x7 grid of talent icons with prerequisite arrows, the
2
+ // rank in the corner, framed gold when maxed, green when you can put a point in and grey (the icon greyed
3
+ // too) when you cannot yet. As many trees as fit stand side by side; Up and Down walk the talents in reading
4
+ // order, Shift+arrows move on the trees (crossing into the next one at the edge). The rows are the talents view's own (a talent row carries `node`); this file
5
+ // only places them, so Enter, u and the tooltip work as in the list. No colour or no room: the list.
6
+ import { canvas, drawCell, gridStep } from './iconGrid.mjs';
7
+ import { iconsOn } from './iconArt.mjs';
8
+ import { availablePoints, spentInTree, TALENT_TIER_POINTS } from '../domain/character/talents.ts';
9
+ import { bold, dim, cyan, fit, plural } from './text.mjs';
10
+
11
+ const TIERS = 7, COLS = 4, GAP = 2;
12
+
13
+ /** maxed, up or locked, the client's three node states. Phonecraft talentTreeGrid.ts talentNodeTone. */
14
+ export function talentTone(ch, t, ds) {
15
+ const cur = ch.talents?.[t.id] ?? 0;
16
+ if (cur >= t.maxRank) return 'maxed';
17
+ if (cur <= 0 && availablePoints(ch) <= 0) return 'locked';
18
+ if (spentInTree(ch, t.tabId, ds) < t.tier * TALENT_TIER_POINTS) return 'locked';
19
+ if (t.prereq && (ch.talents?.[t.prereq.talentId] ?? 0) < t.prereq.rank) return 'locked';
20
+ return 'up';
21
+ }
22
+
23
+ /** The talent's icon: its own, else its first rank spell's. */
24
+ export const talentIcon = (ds, t) => t.icon ?? ds.spell(t.ranks?.[0]?.spellId)?.icon;
25
+
26
+ const treeW = (s) => COLS * (s + 2);
27
+ const treeH = (s) => TIERS * (s / 2) + TIERS - 1;
28
+
29
+ /** Icon size and trees side by side for a `w` x `h` list pane: three trees first, big icons next. */
30
+ function treeSize(w, h) {
31
+ for (const [s, n] of [[6, 3], [4, 3], [6, 2], [4, 2], [6, 1], [4, 1]]) {
32
+ if (h >= 2 + treeH(s) && w >= n * treeW(s) + (n - 1) * GAP + 1) return { s, n };
33
+ }
34
+ return null;
35
+ }
36
+
37
+ /** Will the Talents tab draw as trees right now? */
38
+ export function talentPlan(app) {
39
+ if (!iconsOn()) return null;
40
+ const L = app.layout();
41
+ return L.detailW ? treeSize(L.listW, L.mainH) : null;
42
+ }
43
+
44
+ const FRAME = { maxed: '33', up: '32', locked: '90' };
45
+ const BADGE = { maxed: '93', up: '92', locked: '37' };
46
+
47
+ /** The trees' lines for the talents view's `rows` in a `W` x `H` pane, and where the arrows go. */
48
+ export function talentGrid(app, rows, W, H) {
49
+ const plan = iconsOn() ? treeSize(W, H) : null;
50
+ if (!plan) return null;
51
+ const { s, n } = plan, ch = s / 2, cw = s + 2;
52
+ const view = app.top();
53
+ const cur = view?.cursor ?? 0;
54
+ const trees = app.ds.talentTrees(app.ch.cls);
55
+ const nodes = rows.map((r, i) => ({ ...r.node, i })).filter((x) => x.t);
56
+ const curTree = nodes.find((x) => x.i === cur)?.tree ?? 0;
57
+ // The trees on screen follow the cursor when they do not all fit.
58
+ let start = Math.max(0, Math.min(view?.treeStart ?? 0, trees.length - n));
59
+ if (curTree < start) start = curTree;
60
+ if (curTree >= start + n) start = curTree - n + 1;
61
+ if (view) view.treeStart = start;
62
+
63
+ const cv = canvas(W, H);
64
+ const free = availablePoints(app.ch);
65
+ cv.text(1, 0, app.ch.level < 10 ? dim('Talents unlock at level 10.') : `${bold(plural(free, 'point'))} to spend ${dim('Enter: learn u: refund Shift+arrows: move on the trees B: builds')}`);
66
+ const top = 2;
67
+ // Room left over spreads the trees apart (up to a few columns), so three narrow trees do not huddle left.
68
+ const gap = n > 1 ? Math.min(6, Math.floor((W - 1 - n * treeW(s)) / (n - 1))) : 0;
69
+ const ox = (k) => (k - start) * (treeW(s) + gap);
70
+ const at = new Map(nodes.map((x) => [x.t.id, x]));
71
+ const cellX = (x) => ox(x.tree) + x.t.col * cw;
72
+ const cellY = (x) => top + x.t.tier * (ch + 1);
73
+ const paint = (codes, c) => `\x1b[${codes}m${c}\x1b[0m`;
74
+
75
+ for (let k = start; k < Math.min(trees.length, start + n); k++) {
76
+ const tr = trees[k];
77
+ const title = `${tr.name} ${spentInTree(app.ch, tr.tabId, app.ds)}`;
78
+ const pad = Math.max(0, Math.floor((treeW(s) - title.length) / 2));
79
+ const arrows = n < trees.length ? `${k === start && start > 0 ? '◀' : ' '}${' '.repeat(Math.max(0, pad - 1))}` : ' '.repeat(pad);
80
+ cv.text(ox(k), 1, fit(`${arrows}${k === curTree ? cyan(bold(title)) : dim(title)}`, treeW(s) - 1));
81
+ if (k === start + n - 1 && start + n < trees.length) cv.text(ox(k) + treeW(s) - 1, 1, '▶');
82
+ }
83
+
84
+ const visible = (x) => x.tree >= start && x.tree < start + n;
85
+ const draw = (x) => drawCell(cv, cellX(x), cellY(x), s, {
86
+ icon: x.icon, tone: x.tone === 'locked' && !x.rank ? 'gray' : '', frame: FRAME[x.tone], selected: x.i === cur,
87
+ badge: x.tone === 'locked' && !x.rank ? null : { text: String(x.rank), codes: BADGE[x.tone] },
88
+ });
89
+ for (const x of nodes) if (visible(x)) draw(x);
90
+
91
+ // Prerequisite arrows: along a tier, down a column, or down then across into the node (a diagonal).
92
+ const taken = new Set(nodes.filter(visible).map((x) => `${x.tree}:${x.t.tier}:${x.t.col}`));
93
+ for (const x of nodes) {
94
+ const pre = x.t.prereq && at.get(x.t.prereq.talentId);
95
+ if (!pre || !visible(x)) continue;
96
+ const codes = (app.ch.talents?.[pre.t.id] ?? 0) >= x.t.prereq.rank ? '93' : '90';
97
+ const py = cellY(pre), ty = cellY(x), mid = Math.floor((ch - 1) / 2);
98
+ if (pre.t.tier === x.t.tier) {
99
+ const right = x.t.col > pre.t.col;
100
+ const from = right ? cellX(pre) + s + 1 : cellX(x) + s + 1, to = right ? cellX(x) : cellX(pre);
101
+ for (let c = from; c <= to; c++) cv.put(c, py + mid, paint(codes, c === (right ? to : from) ? (right ? '►' : '◄') : '─'));
102
+ continue;
103
+ }
104
+ const pcx = cellX(pre) + s / 2, tcx = cellX(x) + s / 2;
105
+ const bend = pre.t.col !== x.t.col;
106
+ for (let y = py + ch; y < ty - (bend ? 1 : 0); y++) {
107
+ const tier = Math.floor((y - top) / (ch + 1));
108
+ const onNode = (y - top) % (ch + 1) < ch && taken.has(`${x.tree}:${tier}:${pre.t.col}`);
109
+ if (!onNode) cv.put(pcx, y, paint(codes, y === ty - 1 ? '▼' : '│'));
110
+ }
111
+ if (bend) {
112
+ const y = ty - 1, right = tcx > pcx;
113
+ cv.put(pcx, y, paint(codes, right ? '└' : '┘'));
114
+ for (let c = Math.min(pcx, tcx) + 1; c < Math.max(pcx, tcx); c++) cv.put(c, y, paint(codes, '─'));
115
+ cv.put(tcx, y, paint(codes, '▼'));
116
+ }
117
+ }
118
+ // The cursor's frame goes on last so no arrow cuts it.
119
+ const sel = nodes.find((x) => x.i === cur);
120
+ if (sel && visible(sel)) draw(sel);
121
+
122
+ const pts = nodes.map((x) => ({ i: x.i, x: x.tree * (COLS + 1) + x.t.col, y: x.t.tier, g: x.tree }));
123
+ return { lines: cv.lines(), nav: (dir, from) => gridStep(pts, from, dir, { dyW: 10, dxW: 1, slack: TIERS }) };
124
+ }