azeroth-shell 0.1.1 → 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 (66) hide show
  1. package/README.md +326 -82
  2. package/bin/azsh.mjs +21 -19
  3. package/package.json +19 -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/fileRepository.mjs +46 -1
  8. package/src/cli/gzLoader.mjs +47 -6
  9. package/src/cli/hdArt.mjs +81 -0
  10. package/src/cli/iconArt.mjs +135 -0
  11. package/src/cli/iconGrid.mjs +100 -0
  12. package/src/cli/modelArt.mjs +295 -0
  13. package/src/cli/paperdoll.mjs +76 -0
  14. package/src/cli/perfCheck.mjs +56 -0
  15. package/src/cli/repl.mjs +36 -7
  16. package/src/cli/rotationText.mjs +6 -6
  17. package/src/cli/sixel.mjs +307 -0
  18. package/src/cli/term.mjs +106 -12
  19. package/src/cli/text.mjs +5 -3
  20. package/src/cli/tui.mjs +252 -98
  21. package/src/cli/tuiAutopilot.mjs +347 -0
  22. package/src/cli/tuiBg.mjs +371 -42
  23. package/src/cli/tuiCheck.mjs +415 -28
  24. package/src/cli/tuiCity.mjs +118 -13
  25. package/src/cli/tuiCompanions.mjs +25 -15
  26. package/src/cli/tuiCreate.mjs +223 -0
  27. package/src/cli/tuiDungeon.mjs +1 -1
  28. package/src/cli/tuiFight.mjs +425 -88
  29. package/src/cli/tuiFightPanes.mjs +27 -8
  30. package/src/cli/tuiFloor.mjs +2 -2
  31. package/src/cli/tuiGear.mjs +10 -6
  32. package/src/cli/tuiLoot.mjs +1 -0
  33. package/src/cli/tuiPaperdoll.mjs +107 -0
  34. package/src/cli/tuiPartyStage.mjs +177 -0
  35. package/src/cli/tuiProfessions.mjs +4 -2
  36. package/src/cli/tuiRotationCond.mjs +390 -0
  37. package/src/cli/tuiSaves.mjs +22 -3
  38. package/src/cli/tuiSearch.mjs +75 -22
  39. package/src/cli/tuiSettings.mjs +15 -0
  40. package/src/cli/tuiSimLab.mjs +1 -1
  41. package/src/cli/tuiStage.mjs +304 -0
  42. package/src/cli/tuiTalentTree.mjs +124 -0
  43. package/src/cli/tuiViews.mjs +296 -115
  44. package/src/cli/tuiWorld.mjs +4 -5
  45. package/src/data/gear-art.bin.br +0 -0
  46. package/src/data/hd-art.json +114 -0
  47. package/src/data/icon-art.bin.gz +0 -0
  48. package/src/data/model-art.bin.gz +0 -0
  49. package/src/data/rotations/pve/paladin/retribution.json +2 -2
  50. package/src/domain/bg/match.ts +85 -24
  51. package/src/domain/bg/strategies.ts +144 -29
  52. package/src/domain/bg/types.ts +2 -0
  53. package/src/domain/bg/wsg.ts +35 -20
  54. package/src/domain/quests/doQuest.ts +24 -6
  55. package/src/domain/rotation/build.ts +3031 -3031
  56. package/src/domain/rotation/classes/paladin.ts +16 -11
  57. package/src/domain/rotation/evaluate.ts +3 -0
  58. package/src/engine/combat/applySpell.ts +20 -9
  59. package/src/engine/combat/applySpellLate.ts +16 -12
  60. package/src/engine/combat/areaAura.ts +284 -283
  61. package/src/engine/combat/fighterFactory.ts +1 -1
  62. package/src/engine/combat/pickAction.ts +39 -1
  63. package/src/engine/combat/procsDamage.ts +31 -1
  64. package/src/engine/combat/sessionChase.ts +7 -1
  65. package/src/engine/combat/sessionMotionKit.ts +40 -2
  66. package/src/quests/quests.ts +60 -0
@@ -5,13 +5,18 @@
5
5
  import { bold, dim, cyan, yellow, green, red, inverse, fit, vis, cut, bar, pct } from './text.mjs';
6
6
  import { createDpsMateMeter, partyMeterSnapshot } from '../domain/bots/dpsMate.ts';
7
7
  import { moneyText } from '../domain/economy/money.ts';
8
+ import { runPaneLines, questPaneLines, bagsPaneLines } from './tuiAutopilot.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;
@@ -277,7 +277,7 @@ function engage(app, f, ents) {
277
277
  if (!live.length) return false;
278
278
  const fight = app.fight;
279
279
  if (!fight) return fightBodies(app, f, live);
280
- if (!fight.floor) { f.waiting.push(...live); return false; }
280
+ if (!fight.floor || fight.ended) { f.waiting.push(...live); return false; } // a fight over (its win still on screen) takes nobody in
281
281
  let n = 0;
282
282
  for (const e of live) {
283
283
  if (fight.session.sideOfGuid?.(e.guid)) continue;
@@ -453,7 +453,7 @@ function floorView(app, f) {
453
453
  f.timer = a.term.setInterval(() => {
454
454
  if (a.top()?.id !== 'floor' || a.fight) return;
455
455
  for (let i = 0; i < f.speed; i++) if (stepFloor(a, f)) break;
456
- a.render();
456
+ a.renderSoon();
457
457
  }, TICK_MS);
458
458
  };
459
459
  const view = {
@@ -52,6 +52,15 @@ export function repairOne(app, slot) {
52
52
  app.result(r, r.ok ? `${q.vendorName} repairs it for ${moneyText(r.spent)}.` : '');
53
53
  }
54
54
 
55
+ /** Enter on Repair all, or R on the Gear tab: mend everything you wear at the repairer the quote prices against. */
56
+ export function repairAll(app) {
57
+ const q = app.g.repairQuote();
58
+ if (!q.cost) { app.flashMsg('Nothing needs repair.'); return; }
59
+ if (!q.vendorName) { app.flashMsg('Nobody here repairs.'); return; }
60
+ const r = app.g.repairAll(q.vendorEntry ?? undefined);
61
+ app.result(r, r.ok ? `Repaired for ${moneyText(r.spent)}.` : '');
62
+ }
63
+
55
64
  export function slotRepairLines(app, slot) {
56
65
  const cost = repairCostSlot(app.ch, slot, (e) => app.ds.item(e));
57
66
  if (!cost) return [];
@@ -125,12 +134,7 @@ export function gearExtraRows(app) {
125
134
  rows.push({
126
135
  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
136
  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
- },
137
+ act: (a) => repairAll(a),
134
138
  });
135
139
  const weapon = rangedWeapon(app);
136
140
  const hasAmmo = app.g.state.inventory.stacks.some((s) => isAmmoItem(app.ds.item(s.item)));
@@ -77,6 +77,7 @@ export function lootDialog(app) {
77
77
  L.rows.forEach((r, i) => {
78
78
  rows.push({
79
79
  text: `${rowName(a, r)}${r.count > 1 ? ` x${r.count}` : ''}${r.blocked ? red(' bags full') : ''}`,
80
+ icon: a.ds.item(r.item)?.icon, sub: dim(a.ds.item(r.item)?.typeName ?? ''),
80
81
  detail: () => itemLines(a, a.ds.item(r.item), r.blocked ? ['', red('No room in your bags for this.')] : []),
81
82
  act: (a2) => {
82
83
  const res = a2.g.takeLoot(i);
@@ -0,0 +1,107 @@
1
+ // The Gear tab as a character 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 Gear 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), plus where the arrows go; null when it does not fit.
48
+ */
49
+ export function paperdollGrid(app, rows, W, H, body) {
50
+ const extraRows = rows.map((r, i) => ({ r, i })).filter(({ r }) => !r.cell && !r.header);
51
+ const plan = iconsOn() ? sheetSize(W, H, extraRows.length) : null;
52
+ if (!plan) return null;
53
+ const { s } = plan, cw = s + 2, ch = s / 2;
54
+ const cur = app.top()?.cursor ?? 0;
55
+ const cv = canvas(W, H);
56
+ const pts = [];
57
+ const index = new Map(rows.map((r, i) => [r.cell?.slot, i]));
58
+ const top = 2, colH = SIDE * ch;
59
+
60
+ const ups = rows.filter((r) => r.cell?.up).length;
61
+ cv.text(1, 0, `${bold('Equipment')} ${ups ? green(`▲ ${plural(ups, 'upgrade')} in your bags u: put on all`) : dim(`Shift+arrows: move on the sheet Enter: change${plan.extras ? '' : ' R: repair all o: outfits'}`)}`);
62
+
63
+ // Labels take a share of the width beside each column; the body gets the middle.
64
+ const lw = Math.max(0, Math.min(22, Math.floor((W - 2 * cw) * 0.28)));
65
+ const midX = cw + lw + 1, midW = W - 2 * cw - 2 * lw - 2;
66
+ const art = midW >= 6 ? body(midW, colH) : null;
67
+ if (art?.length) cv.art(midX + Math.floor((midW - artWidth(art)) / 2), top + Math.max(0, Math.floor((colH - art.length) / 2)), art);
68
+
69
+ const cell = (slot, x, y, px, py, side) => {
70
+ const i = index.get(slot);
71
+ if (i == null) return;
72
+ const c = rows[i].cell, it = c.it;
73
+ const sel = i === cur;
74
+ drawCell(cv, x, y, s, {
75
+ icon: it?.icon, frame: it ? qualityCodes(it.quality) : '90', selected: sel, empty: SHORT[slot],
76
+ mark: c.up ? { text: '▲', codes: '92' } : null,
77
+ });
78
+ pts.push({ i, x: px, y: py });
79
+ if (!side || lw < 4) return;
80
+ const name = it ? qualityPaint(it.quality)(it.name) : dim(SLOT_LABEL[slot] ?? slot);
81
+ const lines = [sel ? bold(name) : name, ...(ch > 2 ? [c.sub ?? '', c.dur ?? ''] : [c.dur || c.sub || ''])].slice(0, ch);
82
+ lines.forEach((t, k) => {
83
+ const text = cut(t, lw - 1);
84
+ cv.text(side === 'left' ? x + cw + 1 : x - 1 - vis(text), y + k, text);
85
+ });
86
+ };
87
+ LEFT.forEach((slot, k) => cell(slot, 0, top + k * ch, 0, k, 'left'));
88
+ RIGHT.forEach((slot, k) => cell(slot, W - cw, top + k * ch, 4, k, 'right'));
89
+
90
+ // Under the columns: the weapons centred under the body, the bag bar at the right edge.
91
+ const by = top + colH + 1;
92
+ const bagsX = W - BAGS.length * cw;
93
+ const wx = Math.max(0, Math.min(Math.round(midX + midW / 2 - 1.5 * cw), bagsX - 3 * cw - 2));
94
+ WEAPONS.forEach((slot, k) => cell(slot, wx + k * cw, by, 1 + k, SIDE));
95
+ BAGS.forEach((slot, k) => cell(slot, bagsX + k * cw, by, 5 + k, SIDE));
96
+
97
+ // Upkeep rows (repair all, ammo, outfits, auto use) under the sheet when they fit.
98
+ const ey = by + ch + 1;
99
+ extraRows.slice(0, plan.extras).forEach(({ r, i }, k) => {
100
+ const sel = i === cur;
101
+ const text = ` ${sel ? '▶' : ' '} ${r.text}`;
102
+ cv.text(0, ey + k, sel ? inverse(fit(text, W - 1)) : text, W - 1);
103
+ pts.push({ i, x: 1, y: SIDE + 1 + k });
104
+ });
105
+
106
+ return { lines: cv.lines(), nav: (dir, from) => gridStep(pts, from, dir, { dyW: 1, dxW: 10 }) };
107
+ }
@@ -0,0 +1,177 @@
1
+ // The party stage: a dungeon pull or a squad fight drawn the way the duel is, every body small on one floor.
2
+ // The party stands down the left (you in your gear, the companions in theirs, the pets a size under), the pack
3
+ // down the right, and each column stands where its unit really is: its yards projected onto the line from
4
+ // where the party opened to where the pack stood, so the tank runs in, the healer hangs back and a mob that
5
+ // chases a caster crosses over. Under every sprite a narrow plate (name, HP, power) and a state row (a cast
6
+ // bar filling under the spell's name, crowd control, dead); hits rise over the head; missiles cross the lane.
7
+ // The boxes shrink with the crowd: the widest size whose rows and columns fit, down to the smallest, after
8
+ // which the furthest foes and the pets are hidden and counted on the floor.
9
+ import { dim, gray, fit } from './text.mjs';
10
+ import { SIZES, canvas, put, putLines, putRight, floorLine, narrowPlate, narrowState, auraRows, putMissiles, putPopsAbove, POP_MS, LUNGE_MS } from './tuiStage.mjs';
11
+ import { creatureArt, artWidth } from './modelArt.mjs';
12
+ import { paperdollArt } from './paperdoll.mjs';
13
+ import { playerArt } from './tuiViews.mjs';
14
+ import { iconsOn } from './iconArt.mjs';
15
+ import { allyIndexOfSide } from '../engine/core/events.ts';
16
+ import { combatDistanceYd, DEFAULT_OPEN_YD, MELEE_CONTACT_YD } from '../engine/combat/combatGeometry.ts';
17
+
18
+ const GAP = 1; // columns between two columns of the same side
19
+ const LANE = 6; // columns kept open between a party column and a foe column, for the glyphs that cross
20
+ const PLATE_MIN = 9; // a plate's narrowest: a 5-cell bar and "100%"
21
+ const PLATE_FULL = 14; // and its widest when the stage has width to spare: a 10-cell bar, a 12-letter name
22
+ const SIZE_ORDER = ['xs', 's', 'm', 'l'];
23
+ // Who stands nearest the foes among units on the same spot (the melee pile opens on one): the healer last.
24
+ const ROLE_RANK = { healer: 0, damage: 2, tank: 3 };
25
+
26
+ /** The companion an ally seat holds: ally_N is squad[N] (the humans first), else the one of that name. */
27
+ function companionOf(app, v) {
28
+ const squad = app.g.state.squad ?? [];
29
+ const i = allyIndexOfSide(v.side);
30
+ const byIndex = i >= 0 && i < squad.length ? squad[i] : null;
31
+ return byIndex?.name === v.name ? byIndex : squad.find((c) => c.name === v.name) ?? byIndex;
32
+ }
33
+
34
+ /** A unit's sprite in a box: you in your gear, a creature by its display id, a companion in its gear. */
35
+ function spriteOf(app, u, cols, rows) {
36
+ if (u.isPlayer) return playerArt(app, cols, rows)?.lines ?? null;
37
+ if (u.v.creatureEntry != null) return creatureArt(app.ds.creature(u.v.creatureEntry)?.displayId, cols, rows);
38
+ const comp = u.friend ? companionOf(app, u.v) : null;
39
+ return comp ? paperdollArt(comp, cols, rows) : null;
40
+ }
41
+
42
+ /** The box a unit gets when the big ones get `size`: pets a size under, a boss a size over. */
43
+ function sizeOf(u, size) {
44
+ const i = SIZE_ORDER.indexOf(size);
45
+ if (u.small) return SIZE_ORDER[Math.max(0, i - 1)];
46
+ if (u.rank === 3) return SIZE_ORDER[Math.min(SIZE_ORDER.length - 1, i + 1)];
47
+ return size;
48
+ }
49
+
50
+ /**
51
+ * The party stage W x H: H lines each W wide, or null when the rows or the columns leave the sprites no
52
+ * room (the frames list draws instead). `inline` is the autopilot's run panel (the ability bar's keys dim).
53
+ */
54
+ export function partyStage(app, ctl, W, H, { inline }) {
55
+ const s = ctl.session, now = ctl.clock(), mem = ctl.frames;
56
+ const pv = s.playerView(), pet = s.petView(), focus = s.enemyView();
57
+ const icons = iconsOn();
58
+ const petSides = new Set((s.companionPets?.() ?? []).map((p) => p.side));
59
+ const rankOf = (v) => { const c = v.creatureEntry != null ? app.ds.creature(v.creatureEntry) : null; return c?.rank ?? c?.scaling?.rank ?? 0; };
60
+ const order = (v, fallback) => ROLE_RANK[v.groupRole] ?? (v.mode === 'ranged' ? 1 : fallback);
61
+ const units = [
62
+ { v: pv, key: 'player', isPlayer: true, friend: true, role: pv.groupRole, order: order(pv, 2.5) },
63
+ ...(pet ? [{ v: pet, key: pet.side ?? pet.name, friend: true, small: true, order: 2 }] : []),
64
+ ...s.alliesView().map((v) => ({ v, key: v.side, friend: true, small: petSides.has(v.side) || !!v.summoned, role: v.groupRole, order: order(v, 2) })),
65
+ ...s.enemiesView().map((v) => ({ v, key: v.side, foe: true, focused: v === focus, rank: rankOf(v), order: v === focus ? 0 : 1 })),
66
+ ];
67
+ // Rows the scene keeps whatever the sprites get: the plate (3), the state row, the floor and the ability bar
68
+ // (dropped under auto-play when rows are short); the aura strip only when the sprites still get a box after it.
69
+ const barLines = ctl.abilityRows(W, W >= 140 ? 6 : 4, { live: !inline });
70
+ let barRows = barLines.length;
71
+ const auraH = icons ? 3 : 1;
72
+ let fixed = 3 + 1 + 1 + barRows;
73
+ if (H - fixed < SIZES.xs[1] && ctl.auto) { barRows = 0; fixed = 5; }
74
+ const showAuras = H - fixed - auraH >= SIZES.s[1];
75
+ if (showAuras) fixed += auraH;
76
+ const artRows = H - fixed;
77
+ if (artRows < SIZES.xs[1]) return null;
78
+ // The widest size whose rows fit and whose columns fit the width: every column its sprite's width or a plate
79
+ // at least PLATE_MIN wide, a gap between neighbours, one lane between the sides, a margin at each edge. When
80
+ // even the smallest does not fit, the foes you do not face go first (from the far end), then the pets, then
81
+ // the party's tail, and the floor counts them.
82
+ const margin = Math.max(GAP, Math.floor(W / 40));
83
+ const columnOf = (u, size) => {
84
+ const box = SIZES[sizeOf(u, size)];
85
+ const art = spriteOf(app, u, box[0], Math.min(box[1], artRows));
86
+ const cw = Math.max(PLATE_MIN, art ? artWidth(art) : 0);
87
+ return { ...u, art, aw: artWidth(art), cw, plateW: cw };
88
+ };
89
+ const widthOf = (list) => list.reduce((n, col) => n + col.cw, 0) + (list.length - 1) * GAP + (LANE - GAP) + 2 * margin;
90
+ let cols = null, hidden = 0;
91
+ for (const size of ['m', 's', 'xs'].filter((k) => SIZES[k][1] <= artRows)) {
92
+ const all = units.map((u) => columnOf(u, size));
93
+ if (widthOf(all) <= W) { cols = all; break; }
94
+ if (size !== 'xs') continue;
95
+ const drop = (pick) => { const i = all.findLastIndex(pick); if (i < 0) return false; all.splice(i, 1); hidden++; return true; };
96
+ while (widthOf(all) > W && (drop((u) => u.foe && !u.focused) || drop((u) => u.small) || drop((u) => u.friend && !u.isPlayer)));
97
+ if (widthOf(all) <= W) cols = all;
98
+ }
99
+ if (!cols) return null;
100
+ // Width to spare goes to the plates, up to PLATE_FULL each, so names and bars get longer as the crowd thins.
101
+ const spare = Math.floor((W - widthOf(cols)) / cols.length);
102
+ if (spare > 0) for (const col of cols) { col.cw = Math.max(col.cw, Math.min(col.cw + spare, PLATE_FULL)); col.plateW = col.cw; }
103
+ // Where each unit stands in its half: by its distance in yards to the nearest living unit of the other side,
104
+ // from the far edge at the widest gap this fight has seen to the lane at melee contact (the duel's rule, one
105
+ // unit at a time). The place it opened at is its own; the closing since then goes to whoever walked it, so
106
+ // a mob that runs onto the healer crosses to the lane while the healer stays put, and a mage walking into
107
+ // range slides in while its mob stands. Melee units park all round a mob, so this is read off distances, not
108
+ // bearings: a rogue behind its target still stands on the party's side.
109
+ const posOf = (u) => s.unitPosYd?.(u.key);
110
+ mem.openPos ??= new Map(); mem.openD ??= new Map();
111
+ const nearest = (u) => {
112
+ let best = null;
113
+ for (const o of units) {
114
+ if (!!o.friend === !!u.friend || o.v.hp <= 0) continue;
115
+ const d = combatDistanceYd(posOf(u), posOf(o));
116
+ if (d != null && (!best || d < best.d)) best = { o, d };
117
+ }
118
+ return best;
119
+ };
120
+ const near = new Map(units.map((u) => [u.key, nearest(u)]));
121
+ mem.openYd = Math.max(mem.openYd ?? DEFAULT_OPEN_YD, ...[...near.values()].map((n) => n?.d ?? 0));
122
+ const frac = (d) => Math.max(0, Math.min(1, (d - MELEE_CONTACT_YD) / Math.max(1, mem.openYd - MELEE_CONTACT_YD)));
123
+ const walked = (u) => combatDistanceYd(mem.openPos.get(u.key), posOf(u)) ?? 0;
124
+ const closingOf = (u) => {
125
+ const n = near.get(u.key), p = posOf(u);
126
+ if (p && !mem.openPos.has(u.key)) mem.openPos.set(u.key, { ...p });
127
+ if (!n) return mem.openD.has(u.key) ? 1 - frac(mem.openD.get(u.key)) : 1; // no positions: everyone at the lane
128
+ if (!mem.openD.has(u.key)) mem.openD.set(u.key, n.d);
129
+ const w = walked(u), wo = walked(n.o);
130
+ const share = w + wo > 0 ? w / (w + wo) : u.friend ? 1 : 0;
131
+ const c0 = 1 - frac(mem.openD.get(u.key));
132
+ return Math.max(0, Math.min(1, c0 + (1 - frac(n.d) - c0) * share));
133
+ };
134
+ for (const col of cols) col.c = closingOf(col);
135
+ // The party's half and the pack's, the free width shared by head count, the lane between. In each half the
136
+ // columns stand by their closing, the nearest to the lane innermost; on the same spot the healer, the
137
+ // ranged, the melee, then the tank, and the foe you face before the rest. Then nobody overlaps: a column
138
+ // is pushed away from the one before it, and what runs past the half's far edge is pushed back in turn.
139
+ const side = (friend) => cols.filter((col) => !!col.friend === friend);
140
+ const widthOfSide = (list) => list.reduce((n, col) => n + col.cw, 0) + Math.max(0, list.length - 1) * GAP;
141
+ const friends = side(true), foes = side(false);
142
+ const free = Math.max(0, W - 2 * margin - LANE - widthOfSide(friends) - widthOfSide(foes));
143
+ const partyR = margin + widthOfSide(friends) + Math.floor((free * friends.length) / Math.max(1, cols.length));
144
+ const foeL = Math.min(W - margin - widthOfSide(foes), partyR + LANE);
145
+ const q = (col) => Math.round(col.c * 50);
146
+ friends.sort((a, b) => q(a) - q(b) || a.order - b.order);
147
+ foes.sort((a, b) => q(b) - q(a) || a.order - b.order);
148
+ const place = (list, L, R) => {
149
+ for (const col of list) col.x = col.friend ? L + col.c * (R - col.cw - L) : R - col.cw - col.c * (R - col.cw - L);
150
+ for (let i = 1; i < list.length; i++) list[i].x = Math.max(list[i].x, list[i - 1].x + list[i - 1].cw + GAP);
151
+ for (let i = list.length - 1; i >= 0; i--) list[i].x = Math.min(list[i].x, i === list.length - 1 ? R - list[i].cw : list[i + 1].x - GAP - list[i].cw);
152
+ for (const col of list) col.x = Math.max(0, Math.round(col.x));
153
+ };
154
+ place(friends, margin, partyR);
155
+ place(foes, foeL, W - margin);
156
+ const zone = artRows, stateRow = zone + 3, auraTop = zone + 4, floorRow = auraTop + (showAuras ? auraH : 0), barTop = floorRow + 1;
157
+ const c = canvas(W, H);
158
+ for (const col of cols) {
159
+ const v = col.v, dead = v.hp <= 0;
160
+ // The sprite stands on its plate, centred over it; a swing lunges it two columns toward the other side.
161
+ if (col.art) {
162
+ const lunge = now - (mem.lunge?.get(col.key) ?? -Infinity) < LUNGE_MS ? (col.friend ? 2 : -2) : 0;
163
+ col.ax = col.x + Math.max(0, Math.floor((col.cw - col.aw) / 2)) + lunge; col.top = zone - col.art.length;
164
+ putLines(c, col.ax, col.top, col.art);
165
+ }
166
+ putLines(c, col.x, zone, narrowPlate(app, v, { ...col, dead, w: col.cw }));
167
+ const state = narrowState(v, now, col.cw);
168
+ if (state) put(c, col.x, stateRow, state);
169
+ if (showAuras && !col.small && !dead) putLines(c, col.x, auraTop, auraRows(app, mem, v, col.key, col.cw, icons));
170
+ putPopsAbove(c, col, (mem.pops.get(col.key) ?? []).filter((p) => now - p.t < POP_MS), now, zone);
171
+ }
172
+ putMissiles(c, s, cols, now, Math.max(0, zone - 1 - Math.floor(Math.min(artRows, 10) / 2)), (id) => app.ds.spell(id)?.name);
173
+ c[floorRow] = floorLine(W, gray);
174
+ if (hidden) putRight(c, W - 1, floorRow, dim(` +${hidden} more in the fight `));
175
+ if (barRows) putLines(c, 0, barTop, barLines);
176
+ return c.map((l) => fit(l, W));
177
+ }
@@ -268,6 +268,7 @@ export function recipeBook(app, crafterId, skill) {
268
268
  const name = DIFF_PAINT[diffOf(cr, skill, r)](r.name);
269
269
  rows.push({
270
270
  text: `${fit(name + (who.length ? green(' ▲') : ''), W)} ${craftChip(chk)}`, data: { recipe: r, chk, cr },
271
+ icon: prod?.icon, sub: dim((r.reagents ?? []).map((x) => `${a.ds.item(x.item)?.name ?? x.item}${x.count > 1 ? ` x${x.count}` : ''}`).join(', ')),
271
272
  detail: () => recipeDetail(a, cr, r, chk, who),
272
273
  act: () => (chk.ok ? craftMany(a, cr, r, 1) : a.say(red(chk.reason ?? `Cannot make ${prod?.name ?? r.name}.`))),
273
274
  });
@@ -751,6 +752,7 @@ export function professionsView(app) {
751
752
  const away = cr.comp?.mission?.skill === prof.id;
752
753
  return {
753
754
  text: `${cr.id ? `${fit(cr.name, 10)} ` : ''}${fit(prof.name, 15)} ${fit(`${sk.current}/${sk.max}`, 8)}${ready ? green(`${ready} ready`) : GATHER_LINES.has(prof.id) ? dim(prof.kind) : ''}${away ? yellow(' away') : ''}`,
755
+ icon: prof.icon, sub: dim(currentRank(cr.char, prof)?.name ?? ''),
754
756
  data: { cr, skill: prof.id },
755
757
  detail: () => profRowLines(a, cr, prof, sk, ready),
756
758
  act: () => a.push(recipeBook(a, cr.id, prof.id)),
@@ -785,13 +787,13 @@ export function professionsView(app) {
785
787
  const prof = ds.profession(t.skill);
786
788
  const sk = a.ch.skills[t.skill];
787
789
  const newOnes = sk ? ds.recipes().filter((r) => r.skill === t.skill && r.source === 'trainer' && !a.ch.knownRecipes.includes(r.id) && sk.current >= r.orange && a.ch.level >= (r.reqLevel ?? 0)).length : 0;
788
- rows.push({ text: `${t.name} ${dim(`<${prof?.name ?? t.skill} Trainer>`)}${newOnes ? green(` ${newOnes} to learn`) : ''}`, detail: () => [bold(t.name), `${prof?.name ?? t.skill} trainer`, '', dim('Enter: ranks and recipes')], act: () => a.push(profTrainerDialog(a, t)) });
790
+ rows.push({ text: `${t.name} ${dim(`<${prof?.name ?? t.skill} Trainer>`)}${newOnes ? green(` ${newOnes} to learn`) : ''}`, icon: prof?.icon, sub: dim(sk ? `your skill ${sk.current}/${sk.max}` : 'not learned'), detail: () => [bold(t.name), `${prof?.name ?? t.skill} trainer`, '', dim('Enter: ranks and recipes')], act: () => a.push(profTrainerDialog(a, t)) });
789
791
  }
790
792
  }
791
793
  const books2 = [];
792
794
  g.state.inventory.stacks.forEach((s) => {
793
795
  const it = ds.item(s.item), teach = itemTeachIntent(it, ds);
794
- if (teach?.kind === 'recipe' || teach?.kind === 'enchant') books2.push({ text: `${qualityPaint(it.quality)(it.name)}${s.count > 1 ? ` x${s.count}` : ''}`, detail: () => itemLines(a, it, ['', dim('Enter: pick who learns it (you or a companion with the craft)')]), act: () => teachFromItem(a, s) });
796
+ if (teach?.kind === 'recipe' || teach?.kind === 'enchant') books2.push({ text: `${qualityPaint(it.quality)(it.name)}${s.count > 1 ? ` x${s.count}` : ''}`, icon: it.icon, sub: dim(teach.kind), detail: () => itemLines(a, it, ['', dim('Enter: pick who learns it (you or a companion with the craft)')]), act: () => teachFromItem(a, s) });
795
797
  });
796
798
  if (books2.length) rows.push(H(''), H('Recipes in your bags'), ...books2);
797
799
  return rows;