azeroth-shell 0.1.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/README.md +362 -85
  2. package/bin/azsh.mjs +35 -19
  3. package/package.json +12 -1
  4. package/src/autopilot/plan.ts +8 -11
  5. package/src/autopilot/view.ts +12 -11
  6. package/src/cli/combatLogFormat.ts +22 -0
  7. package/src/cli/gzLoader.mjs +47 -6
  8. package/src/cli/hdArt.mjs +81 -0
  9. package/src/cli/iconArt.mjs +145 -0
  10. package/src/cli/iconGrid.mjs +100 -0
  11. package/src/cli/modelArt.mjs +298 -0
  12. package/src/cli/oocPrep.mjs +32 -0
  13. package/src/cli/paperdoll.mjs +76 -0
  14. package/src/cli/perfCheck.mjs +56 -0
  15. package/src/cli/repl.mjs +48 -7
  16. package/src/cli/rotationText.mjs +6 -6
  17. package/src/cli/sixel.mjs +319 -0
  18. package/src/cli/term.mjs +106 -12
  19. package/src/cli/text.mjs +21 -3
  20. package/src/cli/tui.mjs +262 -102
  21. package/src/cli/tuiAutopilot.mjs +356 -0
  22. package/src/cli/tuiBags.mjs +37 -4
  23. package/src/cli/tuiBg.mjs +370 -41
  24. package/src/cli/tuiCheck.mjs +633 -37
  25. package/src/cli/tuiCity.mjs +146 -41
  26. package/src/cli/tuiCompanionKit.mjs +6 -6
  27. package/src/cli/tuiCompanions.mjs +211 -41
  28. package/src/cli/tuiCreate.mjs +230 -0
  29. package/src/cli/tuiDungeon.mjs +57 -21
  30. package/src/cli/tuiFight.mjs +467 -88
  31. package/src/cli/tuiFightPanes.mjs +42 -14
  32. package/src/cli/tuiFloor.mjs +21 -9
  33. package/src/cli/tuiGear.mjs +17 -14
  34. package/src/cli/tuiLoot.mjs +6 -6
  35. package/src/cli/tuiMap.mjs +214 -0
  36. package/src/cli/tuiPaperdoll.mjs +108 -0
  37. package/src/cli/tuiPartyStage.mjs +206 -0
  38. package/src/cli/tuiPet.mjs +16 -8
  39. package/src/cli/tuiProfessions.mjs +27 -25
  40. package/src/cli/tuiRotationCond.mjs +390 -0
  41. package/src/cli/tuiSaves.mjs +24 -1
  42. package/src/cli/tuiSearch.mjs +79 -27
  43. package/src/cli/tuiSettings.mjs +3 -4
  44. package/src/cli/tuiStage.mjs +360 -0
  45. package/src/cli/tuiTalentTree.mjs +124 -0
  46. package/src/cli/tuiTalents.mjs +3 -4
  47. package/src/cli/tuiViews.mjs +633 -172
  48. package/src/cli/tuiWiki.mjs +2 -3
  49. package/src/cli/tuiWorld.mjs +90 -7
  50. package/src/data/gear-art.bin.br +0 -0
  51. package/src/data/hd-art.json +114 -0
  52. package/src/data/icon-art.bin.gz +0 -0
  53. package/src/data/model-art-worn.bin.br +0 -0
  54. package/src/data/model-art.bin.gz +0 -0
  55. package/src/data/rotations/pve/paladin/retribution.json +2 -2
  56. package/src/data/tables/creatures.json.gz +0 -0
  57. package/src/data/world-map-sixel.bin.br +0 -0
  58. package/src/data/world-map.bin.gz +0 -0
  59. package/src/domain/bg/match.ts +85 -24
  60. package/src/domain/bg/strategies.ts +144 -29
  61. package/src/domain/bg/types.ts +2 -0
  62. package/src/domain/bg/wsg.ts +35 -20
  63. package/src/domain/character/character.ts +6 -0
  64. package/src/domain/pet/pet.ts +31 -0
  65. package/src/domain/quests/doQuest.ts +24 -6
  66. package/src/domain/rotation/build.ts +3031 -3031
  67. package/src/domain/rotation/classes/paladin.ts +16 -11
  68. package/src/domain/rotation/evaluate.ts +3 -0
  69. package/src/engine/combat/applySpell.ts +20 -9
  70. package/src/engine/combat/applySpellLate.ts +16 -12
  71. package/src/engine/combat/areaAura.ts +284 -283
  72. package/src/engine/combat/fighterFactory.ts +1 -1
  73. package/src/engine/combat/pickAction.ts +3494 -3449
  74. package/src/engine/combat/procsDamage.ts +31 -1
  75. package/src/engine/combat/sessionChase.ts +19 -2
  76. package/src/engine/combat/sessionMotionKit.ts +40 -2
  77. package/src/engine/game/loadReconcile.ts +21 -1
  78. package/src/engine/game/pets.ts +33 -1
  79. package/src/engine/game/types.ts +4 -0
  80. package/src/quests/quests.ts +60 -0
@@ -1,22 +1,34 @@
1
1
  // Live combat and the animated autopilot for the windowed game. A fight is the engine's real-time
2
- // session (Game.beginFight) ticked every 100 ms, drawn as unit frames with a cast slot and auras. Auto-play
3
- // runs the character's rotation; a number key presses an ability over it for one GCD. The numbered ability
4
- // bar only shows in manual play, where you need it.
5
- import { bold, dim, cyan, yellow, green, red, gray, inverse, fit, vis, cut, bar, strip, brightBlue, itemText } from './text.mjs';
6
- import { formatCombatLog, combatLogPlainText, POWER_MERGE_MS } from './combatLogFormat.ts';
2
+ // session (Game.beginFight) ticked every 100 ms. A duel (you, maybe your pet, the foes) is drawn on a stage
3
+ // (duelStage): the sprites face each other, each unit's plate sits under its feet, hits rise from the chest,
4
+ // your cast bar is centred under the floor and the keyed ability bar runs along the bottom. A party fight or
5
+ // a narrow terminal falls back to the list of unit frames (renderFrames). Auto-play runs the character's
6
+ // rotation; a number key presses an ability over it for one GCD.
7
+ import { bold, dim, cyan, yellow, green, red, gray, inverse, fit, vis, cut, bar, strip, brightBlue, itemText, orange, coinText } from './text.mjs';
8
+ import { className } from '../domain/core/ids.ts';
9
+ import { SIZES, youSize, foeSize, canvas, put, putLines, floorLine, POWER_PAINT, POP_MS, POP_RISE_MS, LUNGE_MS, SHORT_BUFF_MS, ROLE_GLYPH, plateLines, auraRows, castSlot, castRows, auraText, auraTime, putMissiles, lootToasts, putLootToasts } from './tuiStage.mjs';
10
+ import { partyStage } from './tuiPartyStage.mjs';
11
+ import { combatDistanceYd, DEFAULT_OPEN_YD, MELEE_CONTACT_YD } from '../engine/combat/combatGeometry.ts';
12
+ import { formatCombatLog, combatLogPlainText, dropLogRepeats, POWER_MERGE_MS } from './combatLogFormat.ts';
7
13
  import { impliedDest } from './combatLogFilter.ts';
8
- import { moneyText } from '../domain/economy/money.ts';
9
14
  import { maxHealth, maxPower } from '../domain/character/character.ts';
10
15
  import { powerType } from '../domain/character/characterStats.ts';
11
16
  import { pullOpts, sessionLogNames } from './squad.mjs';
17
+ import { prepOoc, prepText } from './oocPrep.mjs';
12
18
  import { slotContent, visibleBars } from './tuiBars.mjs';
13
19
  import { autoLootOn, openLoot } from './tuiLoot.mjs';
14
20
  import { createCombatMacroHost } from './macroHostCombat.ts';
15
- import { ensureMeter, noteLoot, cycleFightPane, cycleMeterSort, toggleMeterSegment, fightPane } from './tuiFightPanes.mjs';
16
- import { objectiveLines } from './tuiViews.mjs';
21
+ import { ensureMeter, noteLoot, cycleFightPane, cycleMeterSort, toggleMeterSegment, fightPane, nextPaneId } from './tuiFightPanes.mjs';
22
+ import { objectiveLines, playerArt } from './tuiViews.mjs';
23
+ import { runPanelLines, endCard, stopText, stepBody } from './tuiAutopilot.mjs';
24
+ import { creatureArt, artWidth } from './modelArt.mjs';
25
+ import { iconArt, iconsOn } from './iconArt.mjs';
17
26
 
18
27
  export const TICK_MS = 100; // one engine tick per 100 ms of real time, like the web client
19
- const POWER_PAINT = { mana: brightBlue, rage: red, energy: yellow, focus: yellow };
28
+ // Fight time with every living foe out of combat (an evade, a leash) before the fight ends as a stalemate.
29
+ export const QUIET_END_MS = 3000;
30
+ // A win holds the fight on screen this long (real time), plus a beat for each drop as it slides in.
31
+ const WIN_HOLD_MS = 1000, DROP_HOLD_MS = 450, DROP_STAGGER_MS = 220, WIN_HOLD_MAX_MS = 3500;
20
32
 
21
33
  /** Start a live fight. Returns `{ ok: true, ctl }` or `{ ok: false, reason }`. `onEnd(outcome)` runs after the
22
34
  * fight resolves (outcome null when the player ran); `label` names who is driving (the autopilot). `begin` replaces
@@ -28,6 +40,8 @@ const POWER_PAINT = { mana: brightBlue, rage: red, energy: yellow, focus: yellow
28
40
  export function startFight(app, creatureEntry, { onEnd, label, begin, after, resolve, onTick, why } = {}) {
29
41
  if (app.fight) return { ok: false, reason: 'Already in a fight.' };
30
42
  if (app.isDead()) { app.flashMsg('You are dead.'); return { ok: false, reason: 'You are dead.' }; }
43
+ // A pull you chose buffs first, standing still; a dungeon stop does it in pullStop, an ambush (begin) cannot.
44
+ if (!begin && !resolve) { const buffed = prepText(prepOoc(app.g, app.ds)); if (buffed) app.say(cyan(buffed)); }
31
45
  const opts = begin ? { withSquad: true } : pullOpts(app.g, app.ds, app.cmds.buffPace, creatureEntry, (l) => app.say(cyan(l)));
32
46
  const b = begin ? begin() : app.g.beginFight(creatureEntry, opts);
33
47
  if (!app.result(b)) { app.render(); return { ok: false, reason: b.reason || 'Cannot fight that.' }; }
@@ -43,7 +57,7 @@ export function startFight(app, creatureEntry, { onEnd, label, begin, after, res
43
57
  auto: true, paused: false, speed: app.fightSpeed ?? 1, pressed: null, pressedTicks: 0, follow: [], focus: null, macros: null, timer: null, pend: null, ended: false,
44
58
  frames: newFrameMemory(app), compact: false, squeeze: 0,
45
59
  names: sessionLogNames(session, { playerName: app.ch.name, enemyName: b.enemyName, spellName: (id) => app.ds.spell(id)?.name }),
46
- hint() { return `${this.plain ? (app.farmEntry === creatureEntry ? 'r: stop repeating ' : 'r: fight again after ') : ''}${this.auto ? 'a: manual play' : '1-0: your action bar a: auto-play'}${session.alliesView().length ? ' t: orders' : ''} c: cancel a buff${app.g.state.instance ? ' p: pull +1' : ''} space: ${this.paused ? 'resume' : 'pause'} + -: speed ${this.speed}x Tab: ${fightPane(app) === 'log' ? 'meter' : fightPane(app) === 'meter' ? 'threat' : fightPane(app) === 'threat' ? 'loot' : 'log'} l: log Esc: ${this.label ? 'run away, stop autopilot' : 'run away'}`; },
60
+ hint() { if (this.holding) return `Enter, Esc: go on Tab: ${nextPaneId(app)} l: log`; return `${this.plain ? (app.farmEntry === creatureEntry ? 'r: stop repeating ' : 'r: fight again after ') : ''}1-0: your action bar a: ${this.auto ? 'manual play' : 'auto-play'}${session.alliesView().length ? ' t: orders' : ''} c: cancel a buff${app.g.state.instance ? ' p: pull +1' : ''} space: ${this.paused ? 'resume' : 'pause'} + -: speed ${this.speed}x Tab: ${nextPaneId(app)} l: log ${this.label === 'autopilot' ? 'w, Esc: back to the run panel' : 'Esc: run away'}`; },
47
61
  schedule() {
48
62
  if (this.timer) app.term.clearInterval(this.timer);
49
63
  this.timer = app.term.setInterval(() => this.tick(), Math.max(10, TICK_MS / this.speed));
@@ -51,6 +65,7 @@ export function startFight(app, creatureEntry, { onEnd, label, begin, after, res
51
65
  stop() { if (this.timer) app.term.clearInterval(this.timer); this.timer = null; },
52
66
  /** One engine tick (the check script calls this by hand). */
53
67
  tick() {
68
+ if (this.holding && !this.paused) { if (++this.heldTicks >= this.holding.ticks) this.release(); app.renderSoon(); return; }
54
69
  if (this.ended || this.paused) return;
55
70
  let action;
56
71
  // A macro's later steps go out one per tick once the step before has left the queue.
@@ -70,15 +85,48 @@ export function startFight(app, creatureEntry, { onEnd, label, begin, after, res
70
85
  else events = session.tick();
71
86
  this.logEvents(events);
72
87
  if (!session.ended) this.onTick?.();
88
+ if (!session.ended && !begin && !resolve) this.watchQuiet();
73
89
  if (session.ended) this.finish();
74
90
  app.renderSoon();
75
91
  },
92
+ /** A pull that went quiet: something was in combat, and every living foe has been out of it for QUIET_END_MS.
93
+ * The engine keeps that session open (a reset mob stands passive and the rotation leaves it be, so a Scout
94
+ * that evaded once held the fight for 970 s), so it ends here as a timeout, which settles as a stalemate:
95
+ * the web client's "Enemies evade" settle. Not on a dungeon floor, whose session holds unpulled packs. */
96
+ wasEngaged: false, quietSince: null,
97
+ watchQuiet() {
98
+ const live = session.enemiesView().filter((e) => e.hp > 0);
99
+ if (live.some((e) => e.engaged)) { this.wasEngaged = true; this.quietSince = null; return; }
100
+ if (!this.wasEngaged || !live.length || !session.mirrorOutcome) return;
101
+ this.quietSince ??= session.t;
102
+ if (session.t - this.quietSince < QUIET_END_MS) return;
103
+ app.say(yellow(`${live.length > 1 ? 'Your foes reset' : `${live[0].name} resets`} and the fight is over.`));
104
+ session.mirrorOutcome(true, 'timeout');
105
+ },
106
+ /** A win holds the fight on screen a moment before it gives the screen back: the foe at 0 on its plate and
107
+ * what it dropped sliding in beside it (putLootToasts). The timer keeps ticking through it, so the hold is
108
+ * counted in ticks: the speed keys and the check script drive it like the fight. */
109
+ holding: null, heldTicks: 0,
110
+ /** The stage's clock: the fight's own, running on through the hold so the last hits finish rising. */
111
+ clock() { return session.t + this.heldTicks * TICK_MS; },
112
+ /** Real milliseconds into the hold (the toasts' clock). */
113
+ holdMs() { return this.heldTicks * (TICK_MS / this.speed); },
114
+ /** The drops in one line, for the frames list when the stage does not fit. Null outside a hold or with none. */
115
+ lootLine() {
116
+ const drops = this.holding ? lootToasts(app, this.holding) : [];
117
+ return drops.length ? ` ${cyan('Loot')} ${drops.map((d) => d.text).join(dim(' · '))}` : null;
118
+ },
119
+ /** At 4x and up under the autopilot nobody reads the ticks: the log speaks in fights (the Victory line). */
120
+ folded() { return this.label === 'autopilot' && this.speed >= 4; },
76
121
  logEvents(events) {
77
122
  app.meter?.ingest(events);
78
123
  notePops(this.frames, events);
124
+ let known = null;
125
+ noteCasts(this.frames, events, (id) => (known ??= new Set(session.abilities().map((a) => a.spell.id))).has(id));
126
+ if (this.folded()) return;
79
127
  const flush = () => { if (this.pend && this.pend.amount > 0) app.say(line(this.pend.t, this.pend.remake(this.pend.amount))); this.pend = null; };
80
- const line = (t, row) => `${dim((t / 1000).toFixed(1).padStart(5) + 's')} ${combatLogPlainText(row)}`;
81
- for (const ev of events) {
128
+ const line = (t, row) => `${dim((t / 1000).toFixed(1).padStart(5) + 's')} ${paintLog(row)}`;
129
+ for (const ev of dropLogRepeats(events)) {
82
130
  const row = formatCombatLog(ev, this.names);
83
131
  if (!row) continue;
84
132
  if (row.merge) {
@@ -93,19 +141,17 @@ export function startFight(app, creatureEntry, { onEnd, label, begin, after, res
93
141
  if (this.pend && session.t - this.pend.t0 >= POWER_MERGE_MS) flush();
94
142
  },
95
143
  finish() {
144
+ if (this.ended) return;
96
145
  this.ended = true;
97
- this.stop();
98
- if (this.pend) { if (this.pend.amount > 0) app.say(`${dim((this.pend.t / 1000).toFixed(1).padStart(5) + 's')} ${combatLogPlainText(this.pend.remake(this.pend.amount))}`); this.pend = null; }
146
+ if (this.pend) { if (this.pend.amount > 0) app.say(`${dim((this.pend.t / 1000).toFixed(1).padStart(5) + 's')} ${paintLog(this.pend.remake(this.pend.amount))}`); this.pend = null; }
99
147
  // Auto-loot off: the corpse holds its drops and coin for the loot window. The autopilot always takes them.
100
148
  const hold = !this.resolve && this.label !== 'autopilot' && !autoLootOn(app);
101
149
  const r = this.resolve ? this.resolve(session) : app.g.resolveFight(session, hold ? { holdLoot: true } : undefined);
102
- app.fight = null;
103
- app.stack = this.savedStack;
104
150
  this.after?.(r);
105
151
  const tag = r.outcome === 'win' ? green(bold('Victory')) : r.outcome === 'defeat' ? red(bold('Defeat')) : yellow(bold('Stalemate'));
106
152
  const bits = [`${(r.result.durationMs / 1000).toFixed(1)} s`, `HP ${app.hpText()}`];
107
153
  if (r.xpGained) bits.push(`+${r.xpGained} xp`);
108
- if (r.goldGained && !hold) bits.push(`+${moneyText(r.goldGained)}`);
154
+ if (r.goldGained && !hold) bits.push(`+${coinText(r.goldGained)}`);
109
155
  app.say(`${tag} against ${this.enemyName} ${dim(bits.join(' '))}`);
110
156
  if (!hold) { noteLoot(app, r.loot, r.goldGained); for (const d of r.loot) app.say(` ${yellow('You receive')} ${app.itemLabel(d.item)}${d.count > 1 ? ` x${d.count}` : ''}`); }
111
157
  const gained = (app.g.state.squad ?? []).filter((c) => (c.xp ?? 0) !== squadXp.get(c.id)).map((c) => `${c.name} ${(c.xp ?? 0) > (squadXp.get(c.id) ?? 0) ? `+${(c.xp ?? 0) - (squadXp.get(c.id) ?? 0)} xp` : 'levels up'}`);
@@ -114,6 +160,23 @@ export function startFight(app, creatureEntry, { onEnd, label, begin, after, res
114
160
  if (r.outcome === 'win') app.flashMsg(`${this.enemyName} dies.${r.xpGained ? ` +${r.xpGained} xp` : ''}`);
115
161
  // The lines above already tell the story; the engine's activity rows for this fight would repeat it.
116
162
  app.lastActivity = app.g.state.activity[0] ?? null;
163
+ this.done = { r, hold };
164
+ if (r.outcome !== 'win') { this.release(); return; }
165
+ // Real time: a second, a beat more for each drop as it slides in; at 4x and up it shortens with the speed.
166
+ const loot = hold ? [] : r.loot, copper = hold ? 0 : r.goldGained ?? 0;
167
+ const pace = Math.max(1, this.speed / 2), ms = Math.min(WIN_HOLD_MAX_MS, WIN_HOLD_MS + (loot.length + (copper ? 1 : 0)) * DROP_HOLD_MS) / pace;
168
+ this.holding = { loot, copper, until: ms, stagger: DROP_STAGGER_MS / pace, ticks: Math.max(1, Math.ceil(ms / (TICK_MS / this.speed))) };
169
+ app.renderSoon();
170
+ },
171
+ /** The fight is over and its hold too: the screen goes back to whoever opened the fight. */
172
+ release() {
173
+ if (!this.done || app.fight !== this) return;
174
+ const { r, hold } = this.done;
175
+ this.holding = null;
176
+ this.stop();
177
+ app.fight = null;
178
+ app.lastFight = this; // the meter and threat panes keep reading it between autopilot fights
179
+ app.stack = this.savedStack;
117
180
  if (hold) openLoot(app);
118
181
  app.afterAction();
119
182
  this.onEnd?.(r);
@@ -123,6 +186,7 @@ export function startFight(app, creatureEntry, { onEnd, label, begin, after, res
123
186
  }
124
187
  },
125
188
  flee() {
189
+ if (this.holding) { this.release(); return; }
126
190
  if (this.resolve) { session.forfeit('player'); for (let i = 0; i < 50 && !session.ended; i++) session.tick(); this.finish(); return; }
127
191
  this.ended = true;
128
192
  this.stop();
@@ -136,6 +200,11 @@ export function startFight(app, creatureEntry, { onEnd, label, begin, after, res
136
200
  this.onEnd?.(null);
137
201
  },
138
202
  key(k) {
203
+ // Over a win's hold Enter or Esc goes on at once; the panes, the log and the speed still answer.
204
+ if (this.holding) {
205
+ if (k === 'escape' || k === 'return') { this.release(); return; }
206
+ if (!['space', 'tab', 'shift-tab', 'l', 'pageup', 'pagedown', '+', '=', '-'].includes(k)) return;
207
+ }
139
208
  if (k === 'escape') { this.flee(); return; }
140
209
  if (k === 'space') { this.paused = !this.paused; app.render(); return; }
141
210
  if (k === 'a') { this.auto = !this.auto; session.aiPlayer = this.auto; app.say(dim(this.auto ? 'Auto-play on: your rotation plays.' : 'Auto-play off: you press every ability.')); app.render(); return; }
@@ -233,22 +302,78 @@ export function startFight(app, creatureEntry, { onEnd, label, begin, after, res
233
302
  if (out.length) return out;
234
303
  return abs.filter((x) => !x.spell.passive).slice(0, 9).map((ab, i) => ({ key: String(i + 1), spell: ab.spell, label: ab.spell.name, usable: ab.usable, reason: ab.reason, cooldownRemainingMs: ab.cooldownRemainingMs }));
235
304
  },
236
- render(W, H) {
237
- const lines = [];
305
+ /** The title line (who, how long, who drives) with why the fight is on its right, and the objective line under it. */
306
+ headLines(W, inline = false) {
238
307
  const t = (session.t / 1000).toFixed(1);
239
- const mode = this.auto ? green('AUTO') : yellow('MANUAL');
240
- let head = ` ${bold(`Fighting ${this.enemyName}`)} ${dim(`${t}s`)} ${mode}${this.paused ? red(' PAUSED') : ''}${this.speed !== 1 ? dim(` ${this.speed}x`) : ''}${this.label ? cyan(` ${this.label}`) : ''}`;
308
+ // One badge says who is driving. Under the autopilot the rotation flag only shows when you took it over;
309
+ // inside the run panel the panel's title already says who drives, so the head keeps to the fight.
310
+ const run = this.label === 'autopilot' ? app.auto : null;
311
+ const mode = inline ? (this.auto ? '' : yellow('you play'))
312
+ : run ? `${cyan(bold('AUTOPILOT'))}${this.speed !== 1 ? cyan(` ${this.speed}x`) : ''}${this.auto ? '' : yellow(' you play')}${dim(` fight ${run.fights + 1} · step ${run.planner.step}${Number.isFinite(run.planner.steps) ? `/${run.planner.steps}` : ''}`)}`
313
+ : `${this.auto ? green('AUTO') : yellow('MANUAL')}${this.speed !== 1 ? dim(` ${this.speed}x`) : ''}${this.label ? cyan(` ${this.label}`) : ''}`;
314
+ let head = ` ${bold(`Fighting ${this.enemyName}`)} ${dim(`${t}s`)}${mode ? ` ${mode}` : ''}${this.paused ? red(' PAUSED') : ''}`;
241
315
  // Why this fight, on the right of the title: the quest it serves, with its objective counters on the line below.
242
316
  const why = this.why?.();
243
317
  if (why?.right && W - vis(head) - 4 >= 12) { const r = cut(why.right, W - vis(head) - 4); head = fit(head, W - vis(r) - 1) + r + ' '; }
244
318
  const below = why?.below ? cut(` ${why.below}`, W) : '';
319
+ return { head, below };
320
+ },
321
+ /** The fight drawn inside the autopilot's run panel, so a pull never swaps the screen: the head, the objective
322
+ * line, the enemies, then the party one line each, cut to `room` lines. `player` is the live player frame for
323
+ * the panel's own slot. w opens the full view (render) with the party frames and the ability bar. */
324
+ inlineLines(W, room) {
325
+ const now = this.clock();
326
+ // No head line: the panel's title names the quest and its counts, the enemy frame names the foe. What is
327
+ // left of the head (the clock, paused, you play) goes on the title's right as `status`.
328
+ const status = `${dim(`${(now / 1000).toFixed(1)}s`)}${this.auto ? '' : yellow(' you play')}${this.paused ? red(' PAUSED') : ''}`;
329
+ const enemies = session.enemiesView(), allies = session.alliesView(), pet = session.petView();
330
+ // `player` is the live frame for the panel's own slot (the run's header has no bars of its own).
331
+ const player = unitFrame(app, this.frames, session.playerView(), W, now, true, false, {});
332
+ const foes = (compact) => enemies.flatMap((e) => unitFrame(app, this.frames, e, W, now, false, e === session.enemyView(), { compact }));
333
+ const nameOfSide = (side) => (side === 'player' ? app.ch.name : [...allies, ...enemies].find((v) => v.side === side)?.name ?? (pet?.side === side ? pet.name : null));
334
+ const party = [...(pet ? [pet] : []), ...allies].map((v) => {
335
+ const t = v !== pet && v.hp > 0 ? session.targetOf?.(v.side) : null;
336
+ return unitFrame(app, this.frames, v, W, now, false, false, { compact: true, party: v !== pet, role: v.groupRole, target: t ? nameOfSide(t) : null })[0];
337
+ });
338
+ let fight = [...foes(enemies.length > 1), ...party];
339
+ if (fight.length > room) {
340
+ const shown = Math.max(0, room - foes(true).length);
341
+ fight = [...foes(true), ...party.slice(0, shown), ...(party.length > shown ? [dim(` +${party.length - shown} more in the party`)] : [])];
342
+ }
343
+ return { player, status, fight: fight.slice(0, Math.max(1, room)) };
344
+ },
345
+ /** The fight on its stage when the scene fits, 90 columns and up: a fight without allies on the duel stage
346
+ * (duelStage), one with the party on the party stage (partyStage, every body small on one floor). Null when
347
+ * the rows or the columns leave the sprites no room, and the frames list draws instead. `inline` is the
348
+ * autopilot's run panel, where the number keys browse tabs, so the ability bar shows its keys dim. */
349
+ stage(W, H, { inline = false } = {}) {
350
+ if (W < 90) return null;
351
+ const lines = session.alliesView().length ? partyStage(app, this, W, H, { inline }) : duelStage(app, this, W, H, { inline });
352
+ if (lines && this.holding) putLootToasts(app, lines, this.holding, W, this.holdMs());
353
+ return lines;
354
+ },
355
+ /** The full fight view: the head line, then the stage; or the frames between the sprites (the foe down the
356
+ * right, you in your gear down the left, fightArt) when the stage does not fit. */
357
+ render(W, H) {
358
+ const stage = this.stage(W, H - 1);
359
+ if (stage) return [this.headLines(W).head, ...stage];
360
+ const { me, foe, innerW } = fightArt(app, this, W, H);
361
+ const frames = this.renderFrames(innerW, H), loot = this.lootLine();
362
+ if (loot) frames.splice(1, 0, loot);
363
+ return flankArt(frames.slice(0, H), me, foe, W, H);
364
+ },
365
+ renderFrames(W, H) {
366
+ const lines = [];
367
+ const { head, below } = this.headLines(W);
245
368
  const pv = session.playerView(), now = session.t;
246
369
  const pet = session.petView();
247
370
  const allies = session.alliesView();
248
371
  const enemies = session.enemiesView();
249
372
  // Four companions and their pets do not fit two lines each: once the party outgrows the pane every
250
373
  // ally drops to one line for the rest of the fight, so frames never jump between the two shapes.
251
- const need = 3 + 2 * (1 + (pet ? 1 : 0) + allies.length + enemies.length) + (this.auto ? 0 : 3);
374
+ // With icons a full frame is three lines (the aura icons and their times) and the ability bar two.
375
+ const fh = iconsOn() ? 3 : 2;
376
+ const need = 3 + fh * (1 + (pet ? 1 : 0) + allies.length + enemies.length) + (this.auto ? 0 : fh + 1);
252
377
  if (need > H) this.compact = true;
253
378
  const nameOfSide = (side) => (side === 'player' ? app.ch.name : [...allies, ...enemies].find((v) => v.side === side)?.name ?? (pet?.side === side ? pet.name : null));
254
379
  const party = [];
@@ -278,10 +403,44 @@ export function startFight(app, creatureEntry, { onEnd, label, begin, after, res
278
403
  lines.push(...foeLines, ...tail);
279
404
  return lines.slice(0, H);
280
405
  },
281
- /** Ability bar (manual play only): numbered, dim when unusable, cooldown shown. */
282
- abilityRows(W) {
406
+ /** The ability bar: numbered, dim when unusable. With icons each slot is `cols / 2` lines (a 4 or 6 pixel
407
+ * icon) of a fixed width, so nothing on the bar moves between ticks: the key, the icon and the name, and
408
+ * under the name only a cooldown (seconds), the cast in progress, or nothing. The slot of a spell just
409
+ * used flashes. `live` false (the autopilot's panel, where digits browse tabs) keeps the keys dim and says
410
+ * what takes them. Slots fold onto more rows when W is short. */
411
+ abilityRows(W, cols = 4, { live = true } = {}) {
283
412
  const lines = [];
284
413
  const abs = this.abilityBar();
414
+ if (iconsOn()) {
415
+ const now = this.clock(), mem = this.frames, casting = session.playerView().casting;
416
+ const rows = cols / 2, blank = Array.from({ length: rows }, () => ' '.repeat(cols)), gap = ' '.repeat(4);
417
+ const nameW = 14, slotW = 4 + cols + 1 + nameW;
418
+ const slot = (key, art, label, state) => [`${key} ${art[0]} ${fit(label, nameW)}`, `${gap}${art[1]} ${fit(state, nameW)}`, ...art.slice(2).map((l) => `${gap}${l}`)];
419
+ const cells = abs.map((ab) => {
420
+ const icon = ab.spell ? ab.spell.icon : ab.item != null ? app.ds.item(ab.item)?.icon : null;
421
+ const art = iconArt(icon, cols) ?? blank;
422
+ const id = ab.spell?.id;
423
+ const isCasting = id != null && casting?.spellId === id;
424
+ const flash = id != null && now - (mem.used?.get(id) ?? -Infinity) < USED_FLASH_MS;
425
+ const cd = ab.cooldownRemainingMs > 0 ? `${Math.ceil(ab.cooldownRemainingMs / 1000)}s` : '';
426
+ const state = isCasting ? yellow(`${(Math.max(0, casting.endsAt - now) / 1000).toFixed(1)}s`) : cd ? dim(`◔ ${cd}`) : '';
427
+ const name = cut(ab.label, nameW);
428
+ const label = flash || (this.pressed != null && this.pressed === id) ? inverse(name) : isCasting ? yellow(name) : ab.usable ? name : dim(name);
429
+ const key = live && ab.usable ? inverse(` ${ab.key} `) : dim(` ${ab.key} `);
430
+ return slot(key, art, label, state);
431
+ });
432
+ const items = session.items().filter((x) => x.usable);
433
+ if (items.length) cells.push(slot(live ? inverse(' 0 ') : dim(' 0 '), iconArt(items[0].item?.icon, cols) ?? blank, cut(itemText(items[0].item), nameW), dim(`x${items[0].count}`)));
434
+ if (!live) cells.push([dim('w: take the keys'), '', '']);
435
+ let row = Array.from({ length: rows }, () => ' ');
436
+ for (const cell of cells) {
437
+ const w = Math.max(slotW, ...cell.map(vis));
438
+ if (vis(row[0]) + w + 3 > W && row[0].trim()) { lines.push(...row); row = Array.from({ length: rows }, () => ' '); }
439
+ row = row.map((r, i) => `${r}${fit(cell[i] ?? '', w)} `);
440
+ }
441
+ lines.push(...row);
442
+ return lines;
443
+ }
285
444
  let row = ' ';
286
445
  for (let i = 0; i < abs.length; i++) {
287
446
  const ab = abs[i];
@@ -301,8 +460,8 @@ export function startFight(app, creatureEntry, { onEnd, label, begin, after, res
301
460
  };
302
461
  session.aiPlayer = true;
303
462
  app.fight = ctl;
304
- app.stack = [];
305
- if (app.auto) app.auto.browsing = false; // an autopilot fight always comes back on screen
463
+ // An autopilot fight draws inside the run panel (or under the run strip), so whatever you were reading stays up.
464
+ if (label !== 'autopilot') app.stack = [];
306
465
  app.say(cyan(`You attack ${b.enemyName}.`) + (b.packAssist?.length ? yellow(` ${b.packAssist.map((p) => p.name).join(', ')} join${b.packAssist.length === 1 ? 's' : ''} the fight!`) : ''));
307
466
  ctl.schedule();
308
467
  app.render();
@@ -399,23 +558,57 @@ function partyOrders(app, ctl) {
399
558
  });
400
559
  }
401
560
 
561
+ /** A combat log row with its parts painted, like a classic client's log: you green, friends and pets cyan, foes
562
+ * yellow, spells cyan, damage red, healing green. The words between stay plain so the line still reads. */
563
+ function paintLog(row) {
564
+ return row.segs.map((s) => {
565
+ const c = s.cls ?? '';
566
+ if (c.includes('tw-clog-amt')) return c.includes('sch-heal') ? green(s.text) : red(s.text);
567
+ if (c.includes('tw-clog-u-mine')) return green(s.text);
568
+ if (c.includes('tw-clog-u-friend') || c.includes('tw-clog-u-pet')) return cyan(s.text);
569
+ if (c.includes('tw-clog-u-hostile')) return yellow(s.text);
570
+ if (c.includes('tw-clog-sp')) return cyan(s.text);
571
+ return s.text;
572
+ }).join('');
573
+ }
574
+
402
575
  const CAST_W = 30; // the cast/control slot on a frame's second line, so auras never move when a cast starts
403
576
 
404
- /** Per-fight memory that keeps frames steady: which units have used the cast slot, and the order auras
405
- * first showed up in, so a refreshed buff keeps its place instead of jumping to the end. */
577
+ /** Per-fight memory that keeps frames steady: the order auras first showed up in, so a refreshed buff keeps
578
+ * its place instead of jumping to the end. */
406
579
  function newFrameMemory(app) {
407
- // The player's own slot and aura order carry across fights, so their frame never reshuffles.
580
+ // The player's own aura order carries across fights, so their frame never reshuffles.
408
581
  app.playerAuraOrder ??= new Map();
409
- return { castSlot: new Set(app.playerCastSlot ? ['player'] : []), auraOrder: new Map([['player', app.playerAuraOrder]]), pops: new Map() };
582
+ return { auraOrder: new Map([['player', app.playerAuraOrder]]), pops: new Map(), used: new Map(), lunge: new Map(), casts: new Map(), barred: new Map() };
583
+ }
584
+
585
+ /** Every unit's casts as they go off, newest first, for its cast slot (castRows): a cast_finish with no
586
+ * cast_start before it is an instant. `own(id)` keeps yours to the spells you know, so a proc's triggered
587
+ * spell never shows as something you pressed. */
588
+ function noteCasts(mem, events, own) {
589
+ for (const ev of events) {
590
+ if (!ev.actor || ev.spellId == null) continue;
591
+ if (ev.kind === 'cast_start') { mem.barred.set(ev.actor, ev.spellId); continue; }
592
+ if (ev.kind !== 'cast_finish' || (ev.actor === 'player' && !own(ev.spellId))) continue;
593
+ const instant = mem.barred.get(ev.actor) !== ev.spellId;
594
+ mem.barred.delete(ev.actor);
595
+ mem.casts.set(ev.actor, [{ t: ev.t, spellId: ev.spellId, name: ev.name, instant }, ...(mem.casts.get(ev.actor) ?? [])].slice(0, 8));
596
+ }
410
597
  }
411
598
 
412
- const POP_MS = 1500, POP_W = 18; // a hit or heal stays beside the bars this long (fight time), in a slot this wide
599
+ const POP_W = 18; // a hit or heal stays beside the bars for POP_MS (fight time), in a slot this wide
600
+ const USED_FLASH_MS = 500; // the ability bar slot of a spell just cast stays inverted this long
413
601
  const MISS_WORD = { miss: 'Miss', dodge: 'Dodge', parry: 'Parry', immune: 'Immune', deflect: 'Deflect', block: 'Block' };
414
602
 
415
- /** Damage and healing landing on each unit, newest first, for the numbers that pop up beside its bars. */
603
+ /** Damage and healing landing on each unit, newest first, for the numbers that pop up beside its bars; the
604
+ * player's casts, for the flash on the ability bar; and every melee auto-attack, for the lunge. */
416
605
  function notePops(mem, events) {
417
606
  for (const ev of events) {
418
607
  let side, text;
608
+ if (ev.actor === 'player' && ev.spellId != null && (ev.kind === 'cast_start' || ev.kind === 'cast_finish' || ev.kind === 'spell_hit' || ev.kind === 'cast_self')) mem.used?.set(ev.spellId, ev.t);
609
+ // Only a melee auto-attack lunges: spells, strikes and thorns do not, nor a ranged white (Auto Shot, Shoot
610
+ // or Throw), which the engine sends as a melee event tagged with the shoot spell's id and name.
611
+ if (ev.kind === 'melee' && ev.actor && ev.spellId == null && !ev.name) mem.lunge?.set(ev.actor, ev.t);
419
612
  if (ev.kind === 'melee' || ev.kind === 'spell_hit' || ev.kind === 'dot_tick') {
420
613
  side = ev.target ?? impliedDest(ev.actor);
421
614
  if (ev.amount > 0) text = ev.result === 'crit' ? bold(red(`-${ev.amount}!`)) : red(`-${ev.amount}`);
@@ -439,12 +632,18 @@ function popText(mem, key, now) {
439
632
  return out;
440
633
  }
441
634
 
442
- const ROLE_GLYPH = { tank: cyan('T'), healer: green('H'), damage: red('D') };
443
-
444
635
  /** Two lines: name/level/HP/power, then a fixed cast slot + auras. `now` is the clock `casting` runs on. A party
445
636
  * frame carries its role letter and target; `compact` keeps it to the first line with the cast in it. */
446
- function unitFrame(app, mem, v, W, now, isPlayer, focused = false, { compact = false, party = false, role, target } = {}) {
637
+ function unitFrame(app, mem, v, W, now, isPlayer, focused = false, { compact = false, party = false, role, target, bars = true } = {}) {
447
638
  const dead = v.hp <= 0;
639
+ // No bars (the header already shows them): one line of name, cast slot, hits landing and auras.
640
+ if (!bars) {
641
+ const key = isPlayer ? 'player' : (v.side ?? v.name);
642
+ const name = `${(dead ? gray : isPlayer ? green : yellow)(bold(v.name))} ${dim(`L${v.level}`)}`;
643
+ const slot = castSlot(v, now, mem.casts?.get(key));
644
+ const auras = auraText(app, mem, v, key);
645
+ return [cut(` ${fit(name, 20)} ${fit(slot, CAST_W)} ${fit(popText(mem, key, now), POP_W)} ${auras}`, W)];
646
+ }
448
647
  const namePaint = dead ? gray : isPlayer ? green : focused ? red : party ? cyan : yellow;
449
648
  const barW = Math.max(10, Math.min(30, Math.floor((W - 48 - POP_W) / 2)));
450
649
  const hpBar = bar(v.hp, v.maxHp, barW, dead ? gray : v.hp / v.maxHp < 0.25 ? red : green);
@@ -455,50 +654,201 @@ function unitFrame(app, mem, v, W, now, isPlayer, focused = false, { compact = f
455
654
  const key = isPlayer ? 'player' : (v.side ?? v.name);
456
655
  const head = ` ${fit(`${tag}${namePaint(bold(v.name))} ${dim(`L${v.level}`)}${focused ? red(' ◀') : ''}`, 26)} ${hpBar} ${fit(`${v.hp}/${v.maxHp}`, 11)}${pwBar} ${fit(popText(mem, key, now), POP_W)}`;
457
656
  if (compact) return [cut(`${head}${v.casting && !dead ? ` ${yellow(v.casting.name)}` : after}`, W)];
458
- // Cast slot: a cast bar, else crowd control, else stealth. Once a unit has used it the slot stays
459
- // reserved (blank when idle), so the auras after it hold still.
460
- let slot = '';
461
- if (v.casting) {
462
- const total = v.casting.endsAt - v.casting.startedAt;
463
- const left = Math.max(0, v.casting.endsAt - now);
464
- const prog = v.casting.channeled ? left / total : 1 - left / total;
465
- slot = `${fit(yellow(v.casting.name), 14)} ${bar(prog, 1, 10, yellow)} ${dim(`${(left / 1000).toFixed(1)}s`)}`;
466
- } else if (v.controlled) slot = red(v.controlled.control);
467
- else if (v.stealthed) slot = dim('stealthed');
468
- if (slot) {
469
- mem.castSlot.add(key);
470
- if (isPlayer) app.playerCastSlot = true; // kept across fights and the autopilot's between-fight frame
657
+ // The second line is always there, cast slot first (a cast bar, else crowd control, else stealth, blank when
658
+ // idle) and the auras after it, so gaining a buff or starting a cast never moves this frame or the ones below.
659
+ const recent = mem.casts?.get(key);
660
+ if (iconsOn()) {
661
+ // Icons: the cast slot two lines tall (the spell's icon, its name, the casts before it), then a strip of
662
+ // 4x4 icons, each aura's time (or stacks) under it on the third line.
663
+ const [top, bottom] = auraIcons(app, mem, v, key);
664
+ const [slotTop, slotUnder] = dead ? ['', ''] : castRows(app, v, recent, now, CAST_W);
665
+ return [cut(`${head}${after}`, W), cut(` ${fit(slotTop, CAST_W)} ${top}`, W), cut(` ${fit(slotUnder, CAST_W)} ${bottom}`, W)];
471
666
  }
667
+ const slot = castSlot(v, now, recent);
668
+ const auras = auraText(app, mem, v, key);
669
+ return [cut(`${head}${after}`, W), cut(` ${fit(slot, CAST_W)} ${auras}`, W)];
670
+ }
671
+
672
+ /** The auras auraText lists, as two lines: icons (a 4-letter stub when one has no art) and their times. */
673
+ function auraIcons(app, mem, v, key) {
472
674
  const order = mem.auraOrder.get(key) ?? new Map();
473
675
  mem.auraOrder.set(key, order);
474
676
  const placed = (list) => {
475
677
  for (const a of list) if (!order.has(a.spellId)) order.set(a.spellId, order.size);
476
678
  return [...list].sort((a, b) => order.get(a.spellId) - order.get(b.spellId));
477
679
  };
478
- 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)}`) : ''}`);
479
- const auras = [...placed(v.buffs).slice(0, 6).map((a) => aura(a, green)), ...placed(v.debuffs).slice(0, 6).map((a) => aura(a, red))].join(' ');
480
- const reserve = mem.castSlot.has(key);
481
- if (!reserve && !auras) return [cut(`${head}${after}`, W)];
482
- return [cut(`${head}${after}`, W), cut(` ${reserve ? `${fit(slot, CAST_W)} ` : ''}${auras}`, W)];
680
+ const short = v.buffs.filter((a) => a.remainingMs != null && a.remainingMs < SHORT_BUFF_MS);
681
+ const all = [...placed(short).slice(0, 6).map((a) => [a, green]), ...placed(v.debuffs).slice(0, 6).map((a) => [a, red])];
682
+ let top = '', bottom = '';
683
+ for (const [a, paint] of all) {
684
+ const sp = app.ds.spell(a.spellId);
685
+ const art = iconArt(sp?.icon, 4);
686
+ const name = sp?.name ?? String(a.spellId);
687
+ top += `${art ? art[0] : paint(fit(name, 4))} `;
688
+ bottom += `${art ? (a.stacks > 1 ? yellow(fit(`x${a.stacks}`, 4)) : paint(fit(a.remainingMs != null ? auraTime(a.remainingMs).trim() : '', 4))) : paint(fit(name.slice(4), 4))} `;
689
+ }
690
+ return [top, bottom];
483
691
  }
484
692
 
485
- /** Aura time left at a fixed width: " 9s", " 12s", " 30m". */
486
- function auraTime(ms) {
487
- const s = Math.ceil(ms / 1000);
488
- return (s < 60 ? `${s}s` : `${Math.ceil(s / 60)}m`).padStart(3);
693
+ /** Columns an art column gets in a pane W wide: none under 90 columns, up to 28. */
694
+ const artCols = (W) => (W >= 90 ? Math.min(28, Math.floor(W * 0.22)) : 0);
695
+
696
+ /**
697
+ * The sprites round a fight pane W x H: the foe a controller faces down the right from 90 columns, you in
698
+ * your gear (playerArt) down the left from 130; `innerW` is what the frames keep. The full fight view and
699
+ * the autopilot's run panel both use it, so a run shows the same scene either way.
700
+ */
701
+ export function fightArt(app, ctl, W, H) {
702
+ const cols = artCols(W);
703
+ const entry = ctl.session?.enemyView()?.creatureEntry ?? ctl.enemyEntry;
704
+ const foe = cols && entry != null ? creatureArt(app.ds.creature(entry)?.displayId, cols, H) : null;
705
+ const me = cols && W >= 130 ? playerArt(app, cols, H, ctl.session?.playerView())?.lines ?? null : null;
706
+ const innerW = W - (foe ? artWidth(foe) + 2 : 0) - (me ? artWidth(me) + 2 : 0);
707
+ return { me, foe, innerW };
708
+ }
709
+
710
+ /** `lines` (drawn to the width the art leaves) between `left` and `right` art, each centred on the pane's height; every line W wide. */
711
+ export function flankArt(lines, left, right, W, H) {
712
+ if (!left && !right) return lines;
713
+ const lw = artWidth(left), rw = artWidth(right);
714
+ const innerW = W - (left ? lw + 2 : 0) - (right ? rw + 2 : 0);
715
+ const out = lines.slice(0, H);
716
+ while (out.length < H) out.push('');
717
+ const lt = Math.max(0, Math.floor((H - (left?.length ?? 0)) / 2)), rt = Math.max(0, Math.floor((H - (right?.length ?? 0)) / 2));
718
+ return out.map((l, i) => `${left ? `${fit(left[i - lt] ?? '', lw)} ` : ''}${fit(l, innerW)}${right ? ` ${right[i - rt] ?? ''}` : ''}`);
719
+ }
720
+
721
+ // ---- the stage ----------------------------------------------------------------------------------------
722
+
723
+ const PLATE_PAD = 12; // columns a plate needs past its bar: the numbers after it
724
+ const GAP = 2; // columns between the stage's edge and a column, and between columns
725
+ const LANE_MIN = 8; // the gap kept open between the two sides for the glyphs that cross it
726
+
727
+ /**
728
+ * The duel on a stage W x H (the design page's §2): you down the left with your pet beside you, the foes
729
+ * down the right with the one you face outermost. Under each sprite sits its plate (name, level, class or
730
+ * rank; HP; power), its state (a cast bar, crowd control, stealth; a row kept even when empty, so the auras
731
+ * never move) and its aura strip. Hits rise from the chest, a spell crosses the gap as a glyph, the floor
732
+ * runs under the plates and the ability bar closes the scene. Returns H lines each W wide, or null when the rows leave
733
+ * the sprites no room (the frames list draws instead).
734
+ */
735
+ function duelStage(app, ctl, W, H, { inline }) {
736
+ const s = ctl.session, now = ctl.clock(), mem = ctl.frames;
737
+ const pv = s.playerView(), pet = s.petView(), focus = s.enemyView();
738
+ const icons = iconsOn();
739
+ const barW = W >= 140 ? 30 : W >= 110 ? 24 : 18;
740
+ const plateW = barW + PLATE_PAD;
741
+ const auraH = icons ? 3 : 1, stateH = icons ? 2 : 1;
742
+ // Rows the scene keeps whatever the sprites get: the plate (3), the state rows, the auras and the floor.
743
+ // The state rows are there idle or casting, so a cast never moves the auras under them; with icons they are
744
+ // two, the spell's icon beside its name and the trail of casts before it (castRows). The ability bar goes
745
+ // when rows are short, and only under auto-play.
746
+ const barLines = ctl.abilityRows(W, W >= 140 ? 6 : 4, { live: !inline });
747
+ let barRows = barLines.length;
748
+ const fixed = () => 3 + stateH + auraH + 1 + barRows;
749
+ if (H - fixed() < 4 && ctl.auto) barRows = 0;
750
+ const artRows = H - fixed();
751
+ if (artRows < 4) return null;
752
+ const rankOf = (v) => { const c = v.creatureEntry != null ? app.ds.creature(v.creatureEntry) : null; return c?.rank ?? c?.scaling?.rank ?? 0; };
753
+ const creatureLines = (v, c, r) => (v.creatureEntry != null ? creatureArt(app.ds.creature(v.creatureEntry)?.displayId, c, r) : null);
754
+ /** One column of the stage: the unit, its box, its art and its plate; placed by x later. */
755
+ const column = (v, key, size, artOf, o) => {
756
+ const box = SIZES[size];
757
+ const art = artOf(box[0], Math.min(box[1], artRows));
758
+ const pw = (o.barW ?? barW) + PLATE_PAD;
759
+ return { v, key, art, aw: artWidth(art), cw: Math.max(pw, art ? artWidth(art) : 0), plateW: pw, ...o, barW: o.barW ?? barW };
760
+ };
761
+ const cols = [];
762
+ const you = column(pv, 'player', youSize(W), (c, r) => playerArt(app, c, r, pv)?.lines ?? null, { isPlayer: true });
763
+ const margin = Math.max(GAP, Math.floor(W / 16)); // the actors stand a little in from the edges
764
+ you.x = margin; cols.push(you);
765
+ let left = you.x + you.cw + GAP, right = W - margin, petCol = null;
766
+ if (pet) {
767
+ const c = column(pet, pet.side ?? pet.name, 'xs', (cc, r) => creatureLines(pet, cc, r), { friend: true, small: true, barW: 10 });
768
+ if (left + c.cw + LANE_MIN + plateW <= right) { c.x = left; left += c.cw + GAP; cols.push(c); petCol = c; }
769
+ }
770
+ // The other foes stand at the far edge. You and the foe you face stand where you really are: the engine
771
+ // keeps every unit's position in yards, so the gap between you is the real one, scaled from melee contact
772
+ // to the widest it has been this fight. Whoever walked closes it on screen: a melee character runs up to
773
+ // the mob (it stands where it was pulled), a mob chases the caster that hit it. Nobody slides who did not move.
774
+ let hidden = 0;
775
+ for (const e of s.enemiesView().filter((x) => x !== focus)) {
776
+ const c = column(e, e.side ?? e.name, 's', (cc, r) => creatureLines(e, cc, r), { small: true, barW: 12, rank: rankOf(e) });
777
+ if (right - c.cw - LANE_MIN - plateW < left) { hidden++; continue; }
778
+ c.x = right - c.cw; right = c.x - GAP; cols.push(c);
779
+ }
780
+ const foe = focus ? column(focus, focus.side ?? focus.name, foeSize(W, rankOf(focus)), (c, r) => creatureLines(focus, c, r), { focused: true, rank: rankOf(focus) }) : null;
781
+ if (foe) {
782
+ const near = left + LANE_MIN, far = Math.max(near, right - foe.cw);
783
+ const pP = s.unitPosYd?.('player'), pF = s.unitPosYd?.(focus.side);
784
+ const yd = combatDistanceYd(pP, pF);
785
+ mem.openYd = Math.max(mem.openYd ?? DEFAULT_OPEN_YD, yd ?? 0);
786
+ mem.openPos ??= new Map();
787
+ if (pP && !mem.openPos.has('player')) mem.openPos.set('player', { ...pP });
788
+ if (pF && !mem.openPos.has(foe.key)) mem.openPos.set(foe.key, { ...pF });
789
+ const walkedP = combatDistanceYd(mem.openPos.get('player'), pP) ?? 0, walkedF = combatDistanceYd(mem.openPos.get(foe.key), pF) ?? 0;
790
+ const share = walkedP + walkedF > 0 ? walkedP / (walkedP + walkedF) : 1; // your share of the closing; yours when nobody has walked yet
791
+ const frac = yd == null ? 1 : Math.max(0, Math.min(1, (yd - MELEE_CONTACT_YD) / Math.max(1, mem.openYd - MELEE_CONTACT_YD)));
792
+ const closing = Math.round((1 - frac) * (far - near)), yours = Math.round(closing * share);
793
+ for (const col of cols) if (col.isPlayer || col.friend) col.x += yours; // your pet walks beside you
794
+ foe.x = far - (closing - yours); foe.yd = yd;
795
+ // The pet has its own yards too: it runs out from your side to the foe and back. Its distance to the
796
+ // foe, from your gap down to melee contact, slides it from its place beside you to just in front of
797
+ // the foe's plate (the lane is at least LANE_MIN wide, so the two never overlap).
798
+ if (petCol && yd != null) {
799
+ const dPet = combatDistanceYd(s.unitPosYd?.(petCol.key), pF);
800
+ if (dPet != null) {
801
+ const out = Math.max(0, Math.min(1, (yd - dPet) / Math.max(1, yd - MELEE_CONTACT_YD)));
802
+ petCol.x += Math.round(out * Math.max(0, foe.x - GAP - (petCol.x + petCol.cw)));
803
+ }
804
+ }
805
+ cols.push(foe);
806
+ }
807
+ const zone = artRows, stateRow = zone + 3, auraTop = stateRow + stateH, floorRow = auraTop + auraH, barTop = floorRow + 1;
808
+ const c = canvas(W, H);
809
+ for (const col of cols) {
810
+ const v = col.v, dead = v.hp <= 0;
811
+ // The sprite stands on the plate, centred over it; the plate hangs under its feet. A swing lunges it two
812
+ // columns toward the other side for a moment.
813
+ if (col.art) {
814
+ const lunge = now - (mem.lunge?.get(col.key) ?? -Infinity) < LUNGE_MS ? (col.isPlayer || col.friend ? 2 : -2) : 0;
815
+ col.ax = col.x + Math.max(0, Math.floor((col.cw - col.aw) / 2)) + lunge; col.top = zone - col.art.length; putLines(c, col.ax, col.top, col.art);
816
+ }
817
+ putLines(c, col.x, zone, plateLines(app, v, { ...col, dead }));
818
+ // Your cast sits where the foe's does: under the power bar, in the state rows every plate keeps.
819
+ const recent = mem.casts.get(col.key);
820
+ const slot = dead ? [''] : icons ? castRows(app, v, recent, now, col.plateW) : [castSlot(v, now, recent)];
821
+ if (slot[0] || slot[1]) putLines(c, col.x, stateRow, slot.map((l) => cut(l, col.plateW)));
822
+ else if (col === foe && hidden) put(c, col.x, stateRow, dim(`+${hidden} more in the fight`));
823
+ if (!col.small && !dead) putLines(c, col.x, auraTop, auraRows(app, mem, v, col.key, col.plateW, icons));
824
+ // Hits and heals rise from the chest, the newest lowest; without art they sit beside the name.
825
+ const pops = (mem.pops.get(col.key) ?? []).filter((p) => now - p.t < POP_MS);
826
+ pops.forEach((p, i) => {
827
+ const text = now - p.t > POP_MS / 2 ? dim(p.text) : p.text;
828
+ // On the sprite's inner shoulder, spilling toward the middle of the stage, so the body stays readable.
829
+ if (col.art) put(c, col.isPlayer || col.friend ? col.ax + col.aw - 1 : col.ax + 1 - vis(text), col.top + Math.floor(col.art.length / 3) - Math.floor((now - p.t) / POP_RISE_MS) - i, text);
830
+ else put(c, col.x + col.plateW + 1 + i * (vis(text) + 1), zone, text);
831
+ });
832
+ }
833
+ // The engine's missiles in flight cross the gap as glyphs (putMissiles), on a row through the sprites' chests.
834
+ putMissiles(c, s, cols, now, Math.max(0, zone - 1 - Math.floor(Math.min(artRows, 14) / 2)), (id) => app.ds.spell(id)?.name);
835
+ c[floorRow] = floorLine(W, gray);
836
+ if (barRows) putLines(c, 0, barTop, barLines);
837
+ const out = c.map((l) => fit(l, W));
838
+ // The open lane between your side's sprites and the faced foe's, for what is drawn over the stage later
839
+ // (the drops after a win, putLootToasts).
840
+ if (foe) out.lane = { left: Math.max(...cols.filter((x) => x.isPlayer || x.friend).map((x) => (x.art ? x.ax + x.aw : x.x))), right: foe.art ? foe.ax : foe.x, rows: zone };
841
+ return out;
489
842
  }
490
843
 
491
- /** The player's frame from the character between fights, shaped like the in-fight one so it stays put. */
492
- function idlePlayerFrame(app, mem, W) {
493
- const ch = app.ch, ds = app.ds, now = Date.now();
494
- const buffs = (ch.activeAuras ?? (ch.activeBuffs ?? []).map((spellId) => ({ spellId })))
495
- .filter((a) => a.expiresAt == null || a.expiresAt > now)
496
- .map((a) => ({ spellId: a.spellId, ...(a.expiresAt != null ? { remainingMs: a.expiresAt - now } : {}) }));
844
+ /** The player's frame between fights: one line of name, level and bars. No auras here, the fight shows those. */
845
+ export function idlePlayerFrame(app, mem, W) {
846
+ const ch = app.ch, ds = app.ds;
497
847
  const v = {
498
848
  name: ch.name, level: ch.level, hp: ch.health, maxHp: maxHealth(ch, ds), power: ch.power, maxPower: maxPower(ch, ds),
499
- powerType: powerType(ch), casting: null, controlled: null, buffs, debuffs: [],
849
+ powerType: powerType(ch), casting: null, controlled: null, buffs: [], debuffs: [],
500
850
  };
501
- return unitFrame(app, mem, v, W, 0, true);
851
+ return unitFrame(app, mem, v, W, 0, true, false, { compact: true });
502
852
  }
503
853
 
504
854
  export const AUTOPILOT_STEP_MS = 350;
@@ -506,19 +856,32 @@ const REST_SECONDS_PER_STEP = 10; // a rest step folds this much regen in; the l
506
856
 
507
857
  /** Watchable autopilot: the planner picks a step every few hundred ms; a fight step runs as a live session
508
858
  * (the same frames and combat log as pressing Enter on a creature) and the planner waits for it to end.
509
- * Esc stops the run; in a fight Esc runs away and stops it. `quest` pins the run to that quest (Do Quest). */
510
- export function startAutopilot(app, steps = 200, farmQuery, quest) {
859
+ * Esc stops the run; in a fight Esc runs away and stops it. `quest` pins the run to that quest (Do Quest).
860
+ * No step cap unless `steps` asks for one: the Stop after setting ends the run, in real minutes. */
861
+ export function startAutopilot(app, steps = Infinity, farmQuery, quest) {
511
862
  if (app.fight || app.auto) return;
512
863
  const what = farmQuery ? `farming ${farmQuery}` : quest != null ? `doing ${app.ds.quest(quest)?.title ?? `quest ${quest}`}` : 'questing';
513
864
  const ctl = {
514
- timer: null, fights: 0, kills: 0, xp: 0, restSteps: 0, status: '', browsing: false, frames: newFrameMemory(app),
865
+ timer: null, fights: 0, kills: 0, xp: 0, deaths: 0, items: 0, restSteps: 0, status: '', browsing: false, paused: false, frames: newFrameMemory(app),
866
+ // What the run strip, the run panel, the Run pane and the end card read (tuiAutopilot.mjs).
867
+ what, steps, farmQuery, quest, startedAt: Date.now(), gold0: app.ch.gold, level0: app.ch.level, ledger: [], turnedIn: [], ended: null,
868
+ get planner() { return ap; },
869
+ /** One row in the Run pane per planner step; a rest keeps updating its one row. */
870
+ note(kind, body, { replace = false } = {}) {
871
+ const last = this.ledger[this.ledger.length - 1];
872
+ if (replace && last && last.kind === kind) { last.n = ap.step; last.body = body; return last; }
873
+ const row = { n: ap.step, kind, body };
874
+ this.ledger.push(row);
875
+ if (this.ledger.length > 500) this.ledger.splice(0, this.ledger.length - 500);
876
+ return row;
877
+ },
515
878
  /** One planner step (the check script calls this by hand). Returns false when the run is over. */
516
879
  step() {
517
- if (app.fight) return true; // the live fight calls back through onEnd
880
+ if (app.fight || this.paused) return true; // the live fight calls back through onEnd
518
881
  let line;
519
882
  try { line = ap.next(); }
520
883
  catch (err) { app.say(red(`autopilot error: ${err.message}`)); line = null; }
521
- if (line == null) { this.stop(); app.say(`${cyan('Autopilot done.')} ${ap.summary()} ${dim(`${this.fights} fights, ${this.kills} kills, +${this.xp} xp`)}`); app.afterAction(); return false; }
884
+ if (line == null) { this.end(); return false; }
522
885
  if (ap.lastKind === 'wait') {
523
886
  // One rest line that keeps updating instead of a column of them.
524
887
  this.restSteps++;
@@ -526,48 +889,64 @@ export function startAutopilot(app, steps = 200, farmQuery, quest) {
526
889
  const text = `${dim(' resting')} ${dim(`${this.restSteps * REST_SECONDS_PER_STEP} s`)} HP ${app.hpText()} ${app.pwText()}`;
527
890
  if (this.restSteps > 1 && app.log.length && app.log[app.log.length - 1].includes('resting')) app.log[app.log.length - 1] = text;
528
891
  else app.say(text);
892
+ this.note('wait', `rest ${this.restSteps * REST_SECONDS_PER_STEP} s ${dim(`HP ${app.hpText()} ${app.pwText()}`)}`, { replace: true });
529
893
  } else {
530
894
  this.restSteps = 0;
531
- if (line) { app.say(line); this.status = strip(line).trim(); }
895
+ if (line) {
896
+ app.say(line);
897
+ const body = stepBody(line), plain = strip(body).trim();
898
+ this.status = plain;
899
+ const kind = /^skipping /.test(plain) ? 'skip' : /^back to life/.test(plain) ? 'dead' : /^autopilot stopped/.test(plain) ? 'stop' : ap.lastKind ?? 'note';
900
+ this.note(kind, body);
901
+ if (kind === 'turnin' && /^turned in /.test(plain)) this.turnedIn.push(plain.slice('turned in '.length));
902
+ }
532
903
  }
533
904
  // The step line already tells the story; the engine's activity rows for it would repeat it.
534
905
  app.lastActivity = app.g.state.activity[0] ?? null;
535
906
  app.renderSoon();
536
907
  return true;
537
908
  },
538
- stop() { if (this.timer) app.term.clearInterval(this.timer); this.timer = null; if (app.auto === this) app.auto = null; void app.persist(); app.render(); },
539
- /** Between fights: the same top rows as a fight (header, player frame), so fast runs don't flash the menus. */
540
- render(W, H) {
541
- const speed = app.fightSpeed ?? 1;
542
- const lines = [
543
- ` ${bold('Autopilot')} ${dim(what)} ${dim(`${this.fights} fights ${this.kills} kills +${this.xp} xp`)}${speed !== 1 ? dim(` ${speed}x`) : ''}`,
544
- '',
545
- ...idlePlayerFrame(app, this.frames, W),
546
- '',
547
- dim(` ${cut(this.status || 'planning the next step', W - 2)}`),
548
- ];
549
- return lines.slice(0, H);
909
+ stop() { if (this.timer) app.term.clearInterval(this.timer); this.timer = null; if (app.auto === this) { app.auto = null; app.lastRun = this; } void app.persist(); app.render(); },
910
+ /** The run is over (the planner stopped, or `why` says the player did): the log line, then the end card
911
+ * once there is something to sum up. */
912
+ end(why) {
913
+ this.ended = why ?? null;
914
+ this.stop();
915
+ app.say(`${cyan(why ? 'Autopilot stopped.' : 'Autopilot done.')} ${dim(stopText(app, this))} ${ap.summary()} ${dim(`${this.fights} fights, ${this.kills} kills, +${this.xp} xp`)}`);
916
+ if (!app.closed && (this.fights > 0 || ap.step > 3)) { app.stack = []; app.push(endCard(app, this)); }
917
+ app.afterAction();
550
918
  },
919
+ /** Between fights: the run panel (tuiAutopilot.mjs) fills the middle. */
920
+ render(W, H) { return runPanelLines(app, this, W, H); },
551
921
  };
552
922
  const liveFight = (step, settle) => {
553
923
  const c = app.ds.creature(step.creatureEntry);
924
+ const name = c?.name ?? String(step.creatureEntry);
554
925
  const why = () => fightReason(app, step);
555
926
  const reason = why();
556
- ctl.status = `${step.forQuest ? 'quest target' : 'hunting'}: ${c?.name ?? step.creatureEntry}${reason && step.forQuest ? ` ${reason.short}` : ''}`;
927
+ ctl.status = `${step.forQuest ? 'quest target' : 'hunting'}: ${name}${reason && step.forQuest ? ` ${reason.short}` : ''}`;
557
928
  app.say(`${dim(String(ap.step).padStart(3) + '.')} ${ctl.status}`);
929
+ const row = ctl.note('fight', `${fit(name, 24)} ${cyan('fighting')}${reason ? ` ${dim(reason.short)}` : ''}`);
558
930
  const f = startFight(app, step.creatureEntry, {
559
931
  label: 'autopilot', why,
560
932
  onEnd: (r) => {
561
- if (!r) { app.say(yellow('Autopilot stopped: you ran.')); ctl.stop(); return; }
933
+ ctl.watchFight = false; // w watches one fight; the next one draws inside the panel again
934
+ if (!r) { row.body = `${fit(name, 24)} ${yellow('ran')}`; ctl.end('you ran'); return; }
935
+ const skippedBefore = ap.skipped.length;
562
936
  settle(r);
563
- ctl.status = `${r.outcome === 'win' ? 'beat' : r.outcome === 'defeat' ? 'lost to' : 'stalemate with'} ${c?.name ?? step.creatureEntry}`;
937
+ ctl.status = `${r.outcome === 'win' ? 'beat' : r.outcome === 'defeat' ? 'lost to' : 'stalemate with'} ${name}`;
564
938
  ctl.fights++;
565
- if (r.outcome === 'win') { ctl.kills += r.killCount ?? 1; ctl.xp += r.xpGained; }
939
+ if (r.outcome === 'win') { ctl.kills += r.killCount ?? 1; ctl.xp += r.xpGained; ctl.items += r.loot.reduce((n, d) => n + (d.count ?? 1), 0); }
940
+ if (r.outcome === 'defeat') ctl.deaths++;
941
+ const outcome = r.outcome === 'win' ? green('won') : r.outcome === 'defeat' ? red('lost') : yellow('stalemate');
942
+ const loot = r.loot.map((d) => `${app.itemLabel(d.item)}${d.count > 1 ? ` x${d.count}` : ''}`).join(', ');
943
+ row.body = `${fit(name, 24)} ${outcome} ${dim(`${(r.result.durationMs / 1000).toFixed(1)} s`)}${r.xpGained ? ` ${yellow(`+${r.xpGained} xp`)}` : ''}${loot ? ` ${loot}` : ''}`;
944
+ for (const entry of ap.skipped.slice(skippedBefore)) ctl.note('skip', `skipped ${app.ds.quest(entry)?.title ?? `quest ${entry}`} ${dim(`died ${ap.questDefeats(entry)} times`)}`);
566
945
  },
567
946
  });
568
947
  return f.ok ? f : { ok: false, reason: f.reason };
569
948
  };
570
- const ap = app.cmds.createAutopilot(`${steps}${farmQuery ? ` farm ${farmQuery}` : ''}`, { liveFight, quest });
949
+ const ap = app.cmds.createAutopilot(farmQuery ? `farm ${farmQuery}` : '', { liveFight, quest, maxSteps: steps, clock: () => Date.now() - ctl.startedAt });
571
950
  if (ap.error) { app.say(red(ap.error)); app.render(); return; }
572
951
  app.say(cyan(`Autopilot: ${what}, fights play live. Esc stops it.`));
573
952
  app.auto = ctl;