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
@@ -1,9 +1,15 @@
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 } 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, 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
14
  import { moneyText } from '../domain/economy/money.ts';
9
15
  import { maxHealth, maxPower } from '../domain/character/character.ts';
@@ -12,11 +18,15 @@ import { pullOpts, sessionLogNames } from './squad.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
+ // A win holds the fight on screen this long (real time), plus a beat for each drop as it slides in.
29
+ const WIN_HOLD_MS = 1000, DROP_HOLD_MS = 450, DROP_STAGGER_MS = 220, WIN_HOLD_MAX_MS = 3500;
20
30
 
21
31
  /** Start a live fight. Returns `{ ok: true, ctl }` or `{ ok: false, reason }`. `onEnd(outcome)` runs after the
22
32
  * fight resolves (outcome null when the player ran); `label` names who is driving (the autopilot). `begin` replaces
@@ -43,7 +53,7 @@ export function startFight(app, creatureEntry, { onEnd, label, begin, after, res
43
53
  auto: true, paused: false, speed: app.fightSpeed ?? 1, pressed: null, pressedTicks: 0, follow: [], focus: null, macros: null, timer: null, pend: null, ended: false,
44
54
  frames: newFrameMemory(app), compact: false, squeeze: 0,
45
55
  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'}`; },
56
+ 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
57
  schedule() {
48
58
  if (this.timer) app.term.clearInterval(this.timer);
49
59
  this.timer = app.term.setInterval(() => this.tick(), Math.max(10, TICK_MS / this.speed));
@@ -51,6 +61,7 @@ export function startFight(app, creatureEntry, { onEnd, label, begin, after, res
51
61
  stop() { if (this.timer) app.term.clearInterval(this.timer); this.timer = null; },
52
62
  /** One engine tick (the check script calls this by hand). */
53
63
  tick() {
64
+ if (this.holding && !this.paused) { if (++this.heldTicks >= this.holding.ticks) this.release(); app.renderSoon(); return; }
54
65
  if (this.ended || this.paused) return;
55
66
  let action;
56
67
  // A macro's later steps go out one per tick once the step before has left the queue.
@@ -71,14 +82,30 @@ export function startFight(app, creatureEntry, { onEnd, label, begin, after, res
71
82
  this.logEvents(events);
72
83
  if (!session.ended) this.onTick?.();
73
84
  if (session.ended) this.finish();
74
- app.render();
85
+ app.renderSoon();
86
+ },
87
+ /** A win holds the fight on screen a moment before it gives the screen back: the foe at 0 on its plate and
88
+ * what it dropped sliding in beside it (putLootToasts). The timer keeps ticking through it, so the hold is
89
+ * counted in ticks: the speed keys and the check script drive it like the fight. */
90
+ holding: null, heldTicks: 0,
91
+ /** The stage's clock: the fight's own, running on through the hold so the last hits finish rising. */
92
+ clock() { return session.t + this.heldTicks * TICK_MS; },
93
+ /** Real milliseconds into the hold (the toasts' clock). */
94
+ holdMs() { return this.heldTicks * (TICK_MS / this.speed); },
95
+ /** The drops in one line, for the frames list when the stage does not fit. Null outside a hold or with none. */
96
+ lootLine() {
97
+ const drops = this.holding ? lootToasts(app, this.holding) : [];
98
+ return drops.length ? ` ${cyan('Loot')} ${drops.map((d) => d.text).join(dim(' · '))}` : null;
75
99
  },
100
+ /** At 4x and up under the autopilot nobody reads the ticks: the log speaks in fights (the Victory line). */
101
+ folded() { return this.label === 'autopilot' && this.speed >= 4; },
76
102
  logEvents(events) {
77
103
  app.meter?.ingest(events);
78
104
  notePops(this.frames, events);
105
+ if (this.folded()) return;
79
106
  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) {
107
+ const line = (t, row) => `${dim((t / 1000).toFixed(1).padStart(5) + 's')} ${paintLog(row)}`;
108
+ for (const ev of dropLogRepeats(events)) {
82
109
  const row = formatCombatLog(ev, this.names);
83
110
  if (!row) continue;
84
111
  if (row.merge) {
@@ -93,14 +120,12 @@ export function startFight(app, creatureEntry, { onEnd, label, begin, after, res
93
120
  if (this.pend && session.t - this.pend.t0 >= POWER_MERGE_MS) flush();
94
121
  },
95
122
  finish() {
123
+ if (this.ended) return;
96
124
  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; }
125
+ 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
126
  // Auto-loot off: the corpse holds its drops and coin for the loot window. The autopilot always takes them.
100
127
  const hold = !this.resolve && this.label !== 'autopilot' && !autoLootOn(app);
101
128
  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
129
  this.after?.(r);
105
130
  const tag = r.outcome === 'win' ? green(bold('Victory')) : r.outcome === 'defeat' ? red(bold('Defeat')) : yellow(bold('Stalemate'));
106
131
  const bits = [`${(r.result.durationMs / 1000).toFixed(1)} s`, `HP ${app.hpText()}`];
@@ -114,6 +139,23 @@ export function startFight(app, creatureEntry, { onEnd, label, begin, after, res
114
139
  if (r.outcome === 'win') app.flashMsg(`${this.enemyName} dies.${r.xpGained ? ` +${r.xpGained} xp` : ''}`);
115
140
  // The lines above already tell the story; the engine's activity rows for this fight would repeat it.
116
141
  app.lastActivity = app.g.state.activity[0] ?? null;
142
+ this.done = { r, hold };
143
+ if (r.outcome !== 'win') { this.release(); return; }
144
+ // Real time: a second, a beat more for each drop as it slides in; at 4x and up it shortens with the speed.
145
+ const loot = hold ? [] : r.loot, copper = hold ? 0 : r.goldGained ?? 0;
146
+ 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;
147
+ this.holding = { loot, copper, until: ms, stagger: DROP_STAGGER_MS / pace, ticks: Math.max(1, Math.ceil(ms / (TICK_MS / this.speed))) };
148
+ app.renderSoon();
149
+ },
150
+ /** The fight is over and its hold too: the screen goes back to whoever opened the fight. */
151
+ release() {
152
+ if (!this.done || app.fight !== this) return;
153
+ const { r, hold } = this.done;
154
+ this.holding = null;
155
+ this.stop();
156
+ app.fight = null;
157
+ app.lastFight = this; // the meter and threat panes keep reading it between autopilot fights
158
+ app.stack = this.savedStack;
117
159
  if (hold) openLoot(app);
118
160
  app.afterAction();
119
161
  this.onEnd?.(r);
@@ -123,6 +165,7 @@ export function startFight(app, creatureEntry, { onEnd, label, begin, after, res
123
165
  }
124
166
  },
125
167
  flee() {
168
+ if (this.holding) { this.release(); return; }
126
169
  if (this.resolve) { session.forfeit('player'); for (let i = 0; i < 50 && !session.ended; i++) session.tick(); this.finish(); return; }
127
170
  this.ended = true;
128
171
  this.stop();
@@ -136,6 +179,11 @@ export function startFight(app, creatureEntry, { onEnd, label, begin, after, res
136
179
  this.onEnd?.(null);
137
180
  },
138
181
  key(k) {
182
+ // Over a win's hold Enter or Esc goes on at once; the panes, the log and the speed still answer.
183
+ if (this.holding) {
184
+ if (k === 'escape' || k === 'return') { this.release(); return; }
185
+ if (!['space', 'tab', 'shift-tab', 'l', 'pageup', 'pagedown', '+', '=', '-'].includes(k)) return;
186
+ }
139
187
  if (k === 'escape') { this.flee(); return; }
140
188
  if (k === 'space') { this.paused = !this.paused; app.render(); return; }
141
189
  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 +281,78 @@ export function startFight(app, creatureEntry, { onEnd, label, begin, after, res
233
281
  if (out.length) return out;
234
282
  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
283
  },
236
- render(W, H) {
237
- const lines = [];
284
+ /** The title line (who, how long, who drives) with why the fight is on its right, and the objective line under it. */
285
+ headLines(W, inline = false) {
238
286
  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}`) : ''}`;
287
+ // One badge says who is driving. Under the autopilot the rotation flag only shows when you took it over;
288
+ // inside the run panel the panel's title already says who drives, so the head keeps to the fight.
289
+ const run = this.label === 'autopilot' ? app.auto : null;
290
+ const mode = inline ? (this.auto ? '' : yellow('you play'))
291
+ : 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}` : ''}`)}`
292
+ : `${this.auto ? green('AUTO') : yellow('MANUAL')}${this.speed !== 1 ? dim(` ${this.speed}x`) : ''}${this.label ? cyan(` ${this.label}`) : ''}`;
293
+ let head = ` ${bold(`Fighting ${this.enemyName}`)} ${dim(`${t}s`)}${mode ? ` ${mode}` : ''}${this.paused ? red(' PAUSED') : ''}`;
241
294
  // Why this fight, on the right of the title: the quest it serves, with its objective counters on the line below.
242
295
  const why = this.why?.();
243
296
  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
297
  const below = why?.below ? cut(` ${why.below}`, W) : '';
298
+ return { head, below };
299
+ },
300
+ /** The fight drawn inside the autopilot's run panel, so a pull never swaps the screen: the head, the objective
301
+ * line, the enemies, then the party one line each, cut to `room` lines. `player` is the live player frame for
302
+ * the panel's own slot. w opens the full view (render) with the party frames and the ability bar. */
303
+ inlineLines(W, room) {
304
+ const now = this.clock();
305
+ // No head line: the panel's title names the quest and its counts, the enemy frame names the foe. What is
306
+ // left of the head (the clock, paused, you play) goes on the title's right as `status`.
307
+ const status = `${dim(`${(now / 1000).toFixed(1)}s`)}${this.auto ? '' : yellow(' you play')}${this.paused ? red(' PAUSED') : ''}`;
308
+ const enemies = session.enemiesView(), allies = session.alliesView(), pet = session.petView();
309
+ // `player` is the live frame for the panel's own slot (the run's header has no bars of its own).
310
+ const player = unitFrame(app, this.frames, session.playerView(), W, now, true, false, {});
311
+ const foes = (compact) => enemies.flatMap((e) => unitFrame(app, this.frames, e, W, now, false, e === session.enemyView(), { compact }));
312
+ const nameOfSide = (side) => (side === 'player' ? app.ch.name : [...allies, ...enemies].find((v) => v.side === side)?.name ?? (pet?.side === side ? pet.name : null));
313
+ const party = [...(pet ? [pet] : []), ...allies].map((v) => {
314
+ const t = v !== pet && v.hp > 0 ? session.targetOf?.(v.side) : null;
315
+ return unitFrame(app, this.frames, v, W, now, false, false, { compact: true, party: v !== pet, role: v.groupRole, target: t ? nameOfSide(t) : null })[0];
316
+ });
317
+ let fight = [...foes(enemies.length > 1), ...party];
318
+ if (fight.length > room) {
319
+ const shown = Math.max(0, room - foes(true).length);
320
+ fight = [...foes(true), ...party.slice(0, shown), ...(party.length > shown ? [dim(` +${party.length - shown} more in the party`)] : [])];
321
+ }
322
+ return { player, status, fight: fight.slice(0, Math.max(1, room)) };
323
+ },
324
+ /** The fight on its stage when the scene fits, 90 columns and up: a fight without allies on the duel stage
325
+ * (duelStage), one with the party on the party stage (partyStage, every body small on one floor). Null when
326
+ * the rows or the columns leave the sprites no room, and the frames list draws instead. `inline` is the
327
+ * autopilot's run panel, where the number keys browse tabs, so the ability bar shows its keys dim. */
328
+ stage(W, H, { inline = false } = {}) {
329
+ if (W < 90) return null;
330
+ const lines = session.alliesView().length ? partyStage(app, this, W, H, { inline }) : duelStage(app, this, W, H, { inline });
331
+ if (lines && this.holding) putLootToasts(app, lines, this.holding, W, this.holdMs());
332
+ return lines;
333
+ },
334
+ /** The full fight view: the head line, then the stage; or the frames between the sprites (the foe down the
335
+ * right, you in your gear down the left, fightArt) when the stage does not fit. */
336
+ render(W, H) {
337
+ const stage = this.stage(W, H - 1);
338
+ if (stage) return [this.headLines(W).head, ...stage];
339
+ const { me, foe, innerW } = fightArt(app, this, W, H);
340
+ const frames = this.renderFrames(innerW, H), loot = this.lootLine();
341
+ if (loot) frames.splice(1, 0, loot);
342
+ return flankArt(frames.slice(0, H), me, foe, W, H);
343
+ },
344
+ renderFrames(W, H) {
345
+ const lines = [];
346
+ const { head, below } = this.headLines(W);
245
347
  const pv = session.playerView(), now = session.t;
246
348
  const pet = session.petView();
247
349
  const allies = session.alliesView();
248
350
  const enemies = session.enemiesView();
249
351
  // Four companions and their pets do not fit two lines each: once the party outgrows the pane every
250
352
  // 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);
353
+ // With icons a full frame is three lines (the aura icons and their times) and the ability bar two.
354
+ const fh = iconsOn() ? 3 : 2;
355
+ const need = 3 + fh * (1 + (pet ? 1 : 0) + allies.length + enemies.length) + (this.auto ? 0 : fh + 1);
252
356
  if (need > H) this.compact = true;
253
357
  const nameOfSide = (side) => (side === 'player' ? app.ch.name : [...allies, ...enemies].find((v) => v.side === side)?.name ?? (pet?.side === side ? pet.name : null));
254
358
  const party = [];
@@ -278,10 +382,44 @@ export function startFight(app, creatureEntry, { onEnd, label, begin, after, res
278
382
  lines.push(...foeLines, ...tail);
279
383
  return lines.slice(0, H);
280
384
  },
281
- /** Ability bar (manual play only): numbered, dim when unusable, cooldown shown. */
282
- abilityRows(W) {
385
+ /** The ability bar: numbered, dim when unusable. With icons each slot is `cols / 2` lines (a 4 or 6 pixel
386
+ * icon) of a fixed width, so nothing on the bar moves between ticks: the key, the icon and the name, and
387
+ * under the name only a cooldown (seconds), the cast in progress, or nothing. The slot of a spell just
388
+ * used flashes. `live` false (the autopilot's panel, where digits browse tabs) keeps the keys dim and says
389
+ * what takes them. Slots fold onto more rows when W is short. */
390
+ abilityRows(W, cols = 4, { live = true } = {}) {
283
391
  const lines = [];
284
392
  const abs = this.abilityBar();
393
+ if (iconsOn()) {
394
+ const now = this.clock(), mem = this.frames, casting = session.playerView().casting;
395
+ const rows = cols / 2, blank = Array.from({ length: rows }, () => ' '.repeat(cols)), gap = ' '.repeat(4);
396
+ const nameW = 14, slotW = 4 + cols + 1 + nameW;
397
+ 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}`)];
398
+ const cells = abs.map((ab) => {
399
+ const icon = ab.spell ? ab.spell.icon : ab.item != null ? app.ds.item(ab.item)?.icon : null;
400
+ const art = iconArt(icon, cols) ?? blank;
401
+ const id = ab.spell?.id;
402
+ const isCasting = id != null && casting?.spellId === id;
403
+ const flash = id != null && now - (mem.used?.get(id) ?? -Infinity) < USED_FLASH_MS;
404
+ const cd = ab.cooldownRemainingMs > 0 ? `${Math.ceil(ab.cooldownRemainingMs / 1000)}s` : '';
405
+ const state = isCasting ? yellow(`${(Math.max(0, casting.endsAt - now) / 1000).toFixed(1)}s`) : cd ? dim(`◔ ${cd}`) : '';
406
+ const name = cut(ab.label, nameW);
407
+ const label = flash || (this.pressed != null && this.pressed === id) ? inverse(name) : isCasting ? yellow(name) : ab.usable ? name : dim(name);
408
+ const key = live && ab.usable ? inverse(` ${ab.key} `) : dim(` ${ab.key} `);
409
+ return slot(key, art, label, state);
410
+ });
411
+ const items = session.items().filter((x) => x.usable);
412
+ 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}`)));
413
+ if (!live) cells.push([dim('w: take the keys'), '', '']);
414
+ let row = Array.from({ length: rows }, () => ' ');
415
+ for (const cell of cells) {
416
+ const w = Math.max(slotW, ...cell.map(vis));
417
+ if (vis(row[0]) + w + 3 > W && row[0].trim()) { lines.push(...row); row = Array.from({ length: rows }, () => ' '); }
418
+ row = row.map((r, i) => `${r}${fit(cell[i] ?? '', w)} `);
419
+ }
420
+ lines.push(...row);
421
+ return lines;
422
+ }
285
423
  let row = ' ';
286
424
  for (let i = 0; i < abs.length; i++) {
287
425
  const ab = abs[i];
@@ -301,8 +439,8 @@ export function startFight(app, creatureEntry, { onEnd, label, begin, after, res
301
439
  };
302
440
  session.aiPlayer = true;
303
441
  app.fight = ctl;
304
- app.stack = [];
305
- if (app.auto) app.auto.browsing = false; // an autopilot fight always comes back on screen
442
+ // An autopilot fight draws inside the run panel (or under the run strip), so whatever you were reading stays up.
443
+ if (label !== 'autopilot') app.stack = [];
306
444
  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
445
  ctl.schedule();
308
446
  app.render();
@@ -399,23 +537,41 @@ function partyOrders(app, ctl) {
399
537
  });
400
538
  }
401
539
 
540
+ /** A combat log row with its parts painted, like a classic client's log: you green, friends and pets cyan, foes
541
+ * yellow, spells cyan, damage red, healing green. The words between stay plain so the line still reads. */
542
+ function paintLog(row) {
543
+ return row.segs.map((s) => {
544
+ const c = s.cls ?? '';
545
+ if (c.includes('tw-clog-amt')) return c.includes('sch-heal') ? green(s.text) : red(s.text);
546
+ if (c.includes('tw-clog-u-mine')) return green(s.text);
547
+ if (c.includes('tw-clog-u-friend') || c.includes('tw-clog-u-pet')) return cyan(s.text);
548
+ if (c.includes('tw-clog-u-hostile')) return yellow(s.text);
549
+ if (c.includes('tw-clog-sp')) return cyan(s.text);
550
+ return s.text;
551
+ }).join('');
552
+ }
553
+
402
554
  const CAST_W = 30; // the cast/control slot on a frame's second line, so auras never move when a cast starts
403
555
 
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. */
556
+ /** Per-fight memory that keeps frames steady: the order auras first showed up in, so a refreshed buff keeps
557
+ * its place instead of jumping to the end. */
406
558
  function newFrameMemory(app) {
407
- // The player's own slot and aura order carry across fights, so their frame never reshuffles.
559
+ // The player's own aura order carries across fights, so their frame never reshuffles.
408
560
  app.playerAuraOrder ??= new Map();
409
- return { castSlot: new Set(app.playerCastSlot ? ['player'] : []), auraOrder: new Map([['player', app.playerAuraOrder]]), pops: new Map() };
561
+ return { auraOrder: new Map([['player', app.playerAuraOrder]]), pops: new Map(), used: new Map(), lunge: new Map() };
410
562
  }
411
563
 
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
564
+ const POP_W = 18; // a hit or heal stays beside the bars for POP_MS (fight time), in a slot this wide
565
+ const USED_FLASH_MS = 500; // the ability bar slot of a spell just cast stays inverted this long
413
566
  const MISS_WORD = { miss: 'Miss', dodge: 'Dodge', parry: 'Parry', immune: 'Immune', deflect: 'Deflect', block: 'Block' };
414
567
 
415
- /** Damage and healing landing on each unit, newest first, for the numbers that pop up beside its bars. */
568
+ /** Damage and healing landing on each unit, newest first, for the numbers that pop up beside its bars; the
569
+ * player's casts, for the flash on the ability bar; and every melee swing, for the lunge. */
416
570
  function notePops(mem, events) {
417
571
  for (const ev of events) {
418
572
  let side, text;
573
+ 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);
574
+ if (ev.kind === 'melee' && ev.actor) mem.lunge?.set(ev.actor, ev.t); // only a weapon swing lunges; spells, strikes and thorns do not
419
575
  if (ev.kind === 'melee' || ev.kind === 'spell_hit' || ev.kind === 'dot_tick') {
420
576
  side = ev.target ?? impliedDest(ev.actor);
421
577
  if (ev.amount > 0) text = ev.result === 'crit' ? bold(red(`-${ev.amount}!`)) : red(`-${ev.amount}`);
@@ -439,12 +595,18 @@ function popText(mem, key, now) {
439
595
  return out;
440
596
  }
441
597
 
442
- const ROLE_GLYPH = { tank: cyan('T'), healer: green('H'), damage: red('D') };
443
-
444
598
  /** Two lines: name/level/HP/power, then a fixed cast slot + auras. `now` is the clock `casting` runs on. A party
445
599
  * 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 } = {}) {
600
+ function unitFrame(app, mem, v, W, now, isPlayer, focused = false, { compact = false, party = false, role, target, bars = true } = {}) {
447
601
  const dead = v.hp <= 0;
602
+ // No bars (the header already shows them): one line of name, cast slot, hits landing and auras.
603
+ if (!bars) {
604
+ const key = isPlayer ? 'player' : (v.side ?? v.name);
605
+ const name = `${(dead ? gray : isPlayer ? green : yellow)(bold(v.name))} ${dim(`L${v.level}`)}`;
606
+ const slot = castSlot(v, now);
607
+ const auras = auraText(app, mem, v, key);
608
+ return [cut(` ${fit(name, 20)} ${fit(slot, CAST_W)} ${fit(popText(mem, key, now), POP_W)} ${auras}`, W)];
609
+ }
448
610
  const namePaint = dead ? gray : isPlayer ? green : focused ? red : party ? cyan : yellow;
449
611
  const barW = Math.max(10, Math.min(30, Math.floor((W - 48 - POP_W) / 2)));
450
612
  const hpBar = bar(v.hp, v.maxHp, barW, dead ? gray : v.hp / v.maxHp < 0.25 ? red : green);
@@ -455,50 +617,196 @@ function unitFrame(app, mem, v, W, now, isPlayer, focused = false, { compact = f
455
617
  const key = isPlayer ? 'player' : (v.side ?? v.name);
456
618
  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
619
  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
620
+ // The second line is always there, cast slot first (a cast bar, else crowd control, else stealth, blank when
621
+ // idle) and the auras after it, so gaining a buff or starting a cast never moves this frame or the ones below.
622
+ const slot = castSlot(v, now);
623
+ if (iconsOn()) {
624
+ // Icons: a strip of 4x4 icons beside the cast slot, each one's time (or stacks) under it on a third line.
625
+ const [top, bottom] = auraIcons(app, mem, v, key);
626
+ return [cut(`${head}${after}`, W), cut(` ${fit(slot, CAST_W)} ${top}`, W), cut(` ${' '.repeat(CAST_W)} ${bottom}`, W)];
471
627
  }
628
+ const auras = auraText(app, mem, v, key);
629
+ return [cut(`${head}${after}`, W), cut(` ${fit(slot, CAST_W)} ${auras}`, W)];
630
+ }
631
+
632
+ /** The auras auraText lists, as two lines: icons (a 4-letter stub when one has no art) and their times. */
633
+ function auraIcons(app, mem, v, key) {
472
634
  const order = mem.auraOrder.get(key) ?? new Map();
473
635
  mem.auraOrder.set(key, order);
474
636
  const placed = (list) => {
475
637
  for (const a of list) if (!order.has(a.spellId)) order.set(a.spellId, order.size);
476
638
  return [...list].sort((a, b) => order.get(a.spellId) - order.get(b.spellId));
477
639
  };
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)];
640
+ const short = v.buffs.filter((a) => a.remainingMs != null && a.remainingMs < SHORT_BUFF_MS);
641
+ const all = [...placed(short).slice(0, 6).map((a) => [a, green]), ...placed(v.debuffs).slice(0, 6).map((a) => [a, red])];
642
+ let top = '', bottom = '';
643
+ for (const [a, paint] of all) {
644
+ const sp = app.ds.spell(a.spellId);
645
+ const art = iconArt(sp?.icon, 4);
646
+ const name = sp?.name ?? String(a.spellId);
647
+ top += `${art ? art[0] : paint(fit(name, 4))} `;
648
+ 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))} `;
649
+ }
650
+ return [top, bottom];
651
+ }
652
+
653
+ /** Columns an art column gets in a pane W wide: none under 90 columns, up to 28. */
654
+ const artCols = (W) => (W >= 90 ? Math.min(28, Math.floor(W * 0.22)) : 0);
655
+
656
+ /**
657
+ * The sprites round a fight pane W x H: the foe a controller faces down the right from 90 columns, you in
658
+ * your gear (playerArt) down the left from 130; `innerW` is what the frames keep. The full fight view and
659
+ * the autopilot's run panel both use it, so a run shows the same scene either way.
660
+ */
661
+ export function fightArt(app, ctl, W, H) {
662
+ const cols = artCols(W);
663
+ const entry = ctl.session?.enemyView()?.creatureEntry ?? ctl.enemyEntry;
664
+ const foe = cols && entry != null ? creatureArt(app.ds.creature(entry)?.displayId, cols, H) : null;
665
+ const me = cols && W >= 130 ? playerArt(app, cols, H)?.lines ?? null : null;
666
+ const innerW = W - (foe ? artWidth(foe) + 2 : 0) - (me ? artWidth(me) + 2 : 0);
667
+ return { me, foe, innerW };
483
668
  }
484
669
 
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);
670
+ /** `lines` (drawn to the width the art leaves) between `left` and `right` art, each centred on the pane's height; every line W wide. */
671
+ export function flankArt(lines, left, right, W, H) {
672
+ if (!left && !right) return lines;
673
+ const lw = artWidth(left), rw = artWidth(right);
674
+ const innerW = W - (left ? lw + 2 : 0) - (right ? rw + 2 : 0);
675
+ const out = lines.slice(0, H);
676
+ while (out.length < H) out.push('');
677
+ const lt = Math.max(0, Math.floor((H - (left?.length ?? 0)) / 2)), rt = Math.max(0, Math.floor((H - (right?.length ?? 0)) / 2));
678
+ return out.map((l, i) => `${left ? `${fit(left[i - lt] ?? '', lw)} ` : ''}${fit(l, innerW)}${right ? ` ${right[i - rt] ?? ''}` : ''}`);
679
+ }
680
+
681
+ // ---- the stage ----------------------------------------------------------------------------------------
682
+
683
+ const PLATE_PAD = 12; // columns a plate needs past its bar: the numbers after it
684
+ const GAP = 2; // columns between the stage's edge and a column, and between columns
685
+ const LANE_MIN = 8; // the gap kept open between the two sides for the glyphs that cross it
686
+
687
+ /**
688
+ * The duel on a stage W x H (the design page's §2): you down the left with your pet beside you, the foes
689
+ * down the right with the one you face outermost. Under each sprite sits its plate (name, level, class or
690
+ * rank; HP; power), its state (a cast bar, crowd control, stealth; a row kept even when empty, so the auras
691
+ * never move) and its aura strip. Hits rise from the chest, a spell crosses the gap as a glyph, the floor
692
+ * runs under the plates and the ability bar closes the scene. Returns H lines each W wide, or null when the rows leave
693
+ * the sprites no room (the frames list draws instead).
694
+ */
695
+ function duelStage(app, ctl, W, H, { inline }) {
696
+ const s = ctl.session, now = ctl.clock(), mem = ctl.frames;
697
+ const pv = s.playerView(), pet = s.petView(), focus = s.enemyView();
698
+ const icons = iconsOn();
699
+ const barW = W >= 140 ? 30 : W >= 110 ? 24 : 18;
700
+ const plateW = barW + PLATE_PAD;
701
+ const auraH = icons ? 3 : 1;
702
+ // Rows the scene keeps whatever the sprites get: the plate (3), the state row, the auras and the floor.
703
+ // The state row is there idle or casting, so a cast never moves the auras under it. The ability bar goes
704
+ // when rows are short, and only under auto-play.
705
+ const barLines = ctl.abilityRows(W, W >= 140 ? 6 : 4, { live: !inline });
706
+ let barRows = barLines.length;
707
+ const fixed = () => 3 + 1 + auraH + 1 + barRows;
708
+ if (H - fixed() < 4 && ctl.auto) barRows = 0;
709
+ const artRows = H - fixed();
710
+ if (artRows < 4) return null;
711
+ const rankOf = (v) => { const c = v.creatureEntry != null ? app.ds.creature(v.creatureEntry) : null; return c?.rank ?? c?.scaling?.rank ?? 0; };
712
+ const creatureLines = (v, c, r) => (v.creatureEntry != null ? creatureArt(app.ds.creature(v.creatureEntry)?.displayId, c, r) : null);
713
+ /** One column of the stage: the unit, its box, its art and its plate; placed by x later. */
714
+ const column = (v, key, size, artOf, o) => {
715
+ const box = SIZES[size];
716
+ const art = artOf(box[0], Math.min(box[1], artRows));
717
+ const pw = (o.barW ?? barW) + PLATE_PAD;
718
+ return { v, key, art, aw: artWidth(art), cw: Math.max(pw, art ? artWidth(art) : 0), plateW: pw, ...o, barW: o.barW ?? barW };
719
+ };
720
+ const cols = [];
721
+ const you = column(pv, 'player', youSize(W), (c, r) => playerArt(app, c, r)?.lines ?? null, { isPlayer: true });
722
+ const margin = Math.max(GAP, Math.floor(W / 16)); // the actors stand a little in from the edges
723
+ you.x = margin; cols.push(you);
724
+ let left = you.x + you.cw + GAP, right = W - margin, petCol = null;
725
+ if (pet) {
726
+ const c = column(pet, pet.side ?? pet.name, 'xs', (cc, r) => creatureLines(pet, cc, r), { friend: true, small: true, barW: 10 });
727
+ if (left + c.cw + LANE_MIN + plateW <= right) { c.x = left; left += c.cw + GAP; cols.push(c); petCol = c; }
728
+ }
729
+ // The other foes stand at the far edge. You and the foe you face stand where you really are: the engine
730
+ // keeps every unit's position in yards, so the gap between you is the real one, scaled from melee contact
731
+ // to the widest it has been this fight. Whoever walked closes it on screen: a melee character runs up to
732
+ // the mob (it stands where it was pulled), a mob chases the caster that hit it. Nobody slides who did not move.
733
+ let hidden = 0;
734
+ for (const e of s.enemiesView().filter((x) => x !== focus)) {
735
+ const c = column(e, e.side ?? e.name, 's', (cc, r) => creatureLines(e, cc, r), { small: true, barW: 12, rank: rankOf(e) });
736
+ if (right - c.cw - LANE_MIN - plateW < left) { hidden++; continue; }
737
+ c.x = right - c.cw; right = c.x - GAP; cols.push(c);
738
+ }
739
+ 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;
740
+ if (foe) {
741
+ const near = left + LANE_MIN, far = Math.max(near, right - foe.cw);
742
+ const pP = s.unitPosYd?.('player'), pF = s.unitPosYd?.(focus.side);
743
+ const yd = combatDistanceYd(pP, pF);
744
+ mem.openYd = Math.max(mem.openYd ?? DEFAULT_OPEN_YD, yd ?? 0);
745
+ mem.openPos ??= new Map();
746
+ if (pP && !mem.openPos.has('player')) mem.openPos.set('player', { ...pP });
747
+ if (pF && !mem.openPos.has(foe.key)) mem.openPos.set(foe.key, { ...pF });
748
+ const walkedP = combatDistanceYd(mem.openPos.get('player'), pP) ?? 0, walkedF = combatDistanceYd(mem.openPos.get(foe.key), pF) ?? 0;
749
+ const share = walkedP + walkedF > 0 ? walkedP / (walkedP + walkedF) : 1; // your share of the closing; yours when nobody has walked yet
750
+ const frac = yd == null ? 1 : Math.max(0, Math.min(1, (yd - MELEE_CONTACT_YD) / Math.max(1, mem.openYd - MELEE_CONTACT_YD)));
751
+ const closing = Math.round((1 - frac) * (far - near)), yours = Math.round(closing * share);
752
+ for (const col of cols) if (col.isPlayer || col.friend) col.x += yours; // your pet walks beside you
753
+ foe.x = far - (closing - yours); foe.yd = yd;
754
+ // The pet has its own yards too: it runs out from your side to the foe and back. Its distance to the
755
+ // foe, from your gap down to melee contact, slides it from its place beside you to just in front of
756
+ // the foe's plate (the lane is at least LANE_MIN wide, so the two never overlap).
757
+ if (petCol && yd != null) {
758
+ const dPet = combatDistanceYd(s.unitPosYd?.(petCol.key), pF);
759
+ if (dPet != null) {
760
+ const out = Math.max(0, Math.min(1, (yd - dPet) / Math.max(1, yd - MELEE_CONTACT_YD)));
761
+ petCol.x += Math.round(out * Math.max(0, foe.x - GAP - (petCol.x + petCol.cw)));
762
+ }
763
+ }
764
+ cols.push(foe);
765
+ }
766
+ const zone = artRows, stateRow = zone + 3, auraTop = zone + 4, floorRow = auraTop + auraH, barTop = floorRow + 1;
767
+ const c = canvas(W, H);
768
+ for (const col of cols) {
769
+ const v = col.v, dead = v.hp <= 0;
770
+ // The sprite stands on the plate, centred over it; the plate hangs under its feet. A swing lunges it two
771
+ // columns toward the other side for a moment.
772
+ if (col.art) {
773
+ const lunge = now - (mem.lunge?.get(col.key) ?? -Infinity) < LUNGE_MS ? (col.isPlayer || col.friend ? 2 : -2) : 0;
774
+ 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);
775
+ }
776
+ putLines(c, col.x, zone, plateLines(app, v, { ...col, dead }));
777
+ // Your cast sits where the foe's does: under the power bar, in the state row every plate keeps.
778
+ const slot = castSlot(v, now);
779
+ if (slot && !dead) put(c, col.x, stateRow, cut(slot, col.plateW));
780
+ else if (col === foe && hidden) put(c, col.x, stateRow, dim(`+${hidden} more in the fight`));
781
+ if (!col.small && !dead) putLines(c, col.x, auraTop, auraRows(app, mem, v, col.key, col.plateW, icons));
782
+ // Hits and heals rise from the chest, the newest lowest; without art they sit beside the name.
783
+ const pops = (mem.pops.get(col.key) ?? []).filter((p) => now - p.t < POP_MS);
784
+ pops.forEach((p, i) => {
785
+ const text = now - p.t > POP_MS / 2 ? dim(p.text) : p.text;
786
+ // On the sprite's inner shoulder, spilling toward the middle of the stage, so the body stays readable.
787
+ 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);
788
+ else put(c, col.x + col.plateW + 1 + i * (vis(text) + 1), zone, text);
789
+ });
790
+ }
791
+ // The engine's missiles in flight cross the gap as glyphs (putMissiles), on a row through the sprites' chests.
792
+ putMissiles(c, s, cols, now, Math.max(0, zone - 1 - Math.floor(Math.min(artRows, 14) / 2)), (id) => app.ds.spell(id)?.name);
793
+ c[floorRow] = floorLine(W, gray);
794
+ if (barRows) putLines(c, 0, barTop, barLines);
795
+ const out = c.map((l) => fit(l, W));
796
+ // The open lane between your side's sprites and the faced foe's, for what is drawn over the stage later
797
+ // (the drops after a win, putLootToasts).
798
+ 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 };
799
+ return out;
489
800
  }
490
801
 
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 } : {}) }));
802
+ /** The player's frame between fights: one line of name, level and bars. No auras here, the fight shows those. */
803
+ export function idlePlayerFrame(app, mem, W) {
804
+ const ch = app.ch, ds = app.ds;
497
805
  const v = {
498
806
  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: [],
807
+ powerType: powerType(ch), casting: null, controlled: null, buffs: [], debuffs: [],
500
808
  };
501
- return unitFrame(app, mem, v, W, 0, true);
809
+ return unitFrame(app, mem, v, W, 0, true, false, { compact: true });
502
810
  }
503
811
 
504
812
  export const AUTOPILOT_STEP_MS = 350;
@@ -506,19 +814,32 @@ const REST_SECONDS_PER_STEP = 10; // a rest step folds this much regen in; the l
506
814
 
507
815
  /** Watchable autopilot: the planner picks a step every few hundred ms; a fight step runs as a live session
508
816
  * (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) {
817
+ * Esc stops the run; in a fight Esc runs away and stops it. `quest` pins the run to that quest (Do Quest).
818
+ * No step cap unless `steps` asks for one: the Stop after setting ends the run, in real minutes. */
819
+ export function startAutopilot(app, steps = Infinity, farmQuery, quest) {
511
820
  if (app.fight || app.auto) return;
512
821
  const what = farmQuery ? `farming ${farmQuery}` : quest != null ? `doing ${app.ds.quest(quest)?.title ?? `quest ${quest}`}` : 'questing';
513
822
  const ctl = {
514
- timer: null, fights: 0, kills: 0, xp: 0, restSteps: 0, status: '', browsing: false, frames: newFrameMemory(app),
823
+ timer: null, fights: 0, kills: 0, xp: 0, deaths: 0, items: 0, restSteps: 0, status: '', browsing: false, paused: false, frames: newFrameMemory(app),
824
+ // What the run strip, the run panel, the Run pane and the end card read (tuiAutopilot.mjs).
825
+ what, steps, farmQuery, quest, startedAt: Date.now(), gold0: app.ch.gold, level0: app.ch.level, ledger: [], turnedIn: [], ended: null,
826
+ get planner() { return ap; },
827
+ /** One row in the Run pane per planner step; a rest keeps updating its one row. */
828
+ note(kind, body, { replace = false } = {}) {
829
+ const last = this.ledger[this.ledger.length - 1];
830
+ if (replace && last && last.kind === kind) { last.n = ap.step; last.body = body; return last; }
831
+ const row = { n: ap.step, kind, body };
832
+ this.ledger.push(row);
833
+ if (this.ledger.length > 500) this.ledger.splice(0, this.ledger.length - 500);
834
+ return row;
835
+ },
515
836
  /** One planner step (the check script calls this by hand). Returns false when the run is over. */
516
837
  step() {
517
- if (app.fight) return true; // the live fight calls back through onEnd
838
+ if (app.fight || this.paused) return true; // the live fight calls back through onEnd
518
839
  let line;
519
840
  try { line = ap.next(); }
520
841
  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; }
842
+ if (line == null) { this.end(); return false; }
522
843
  if (ap.lastKind === 'wait') {
523
844
  // One rest line that keeps updating instead of a column of them.
524
845
  this.restSteps++;
@@ -526,48 +847,64 @@ export function startAutopilot(app, steps = 200, farmQuery, quest) {
526
847
  const text = `${dim(' resting')} ${dim(`${this.restSteps * REST_SECONDS_PER_STEP} s`)} HP ${app.hpText()} ${app.pwText()}`;
527
848
  if (this.restSteps > 1 && app.log.length && app.log[app.log.length - 1].includes('resting')) app.log[app.log.length - 1] = text;
528
849
  else app.say(text);
850
+ this.note('wait', `rest ${this.restSteps * REST_SECONDS_PER_STEP} s ${dim(`HP ${app.hpText()} ${app.pwText()}`)}`, { replace: true });
529
851
  } else {
530
852
  this.restSteps = 0;
531
- if (line) { app.say(line); this.status = strip(line).trim(); }
853
+ if (line) {
854
+ app.say(line);
855
+ const body = stepBody(line), plain = strip(body).trim();
856
+ this.status = plain;
857
+ const kind = /^skipping /.test(plain) ? 'skip' : /^back to life/.test(plain) ? 'dead' : /^autopilot stopped/.test(plain) ? 'stop' : ap.lastKind ?? 'note';
858
+ this.note(kind, body);
859
+ if (kind === 'turnin' && /^turned in /.test(plain)) this.turnedIn.push(plain.slice('turned in '.length));
860
+ }
532
861
  }
533
862
  // The step line already tells the story; the engine's activity rows for it would repeat it.
534
863
  app.lastActivity = app.g.state.activity[0] ?? null;
535
- app.render();
864
+ app.renderSoon();
536
865
  return true;
537
866
  },
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);
867
+ 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(); },
868
+ /** The run is over (the planner stopped, or `why` says the player did): the log line, then the end card
869
+ * once there is something to sum up. */
870
+ end(why) {
871
+ this.ended = why ?? null;
872
+ this.stop();
873
+ app.say(`${cyan(why ? 'Autopilot stopped.' : 'Autopilot done.')} ${dim(stopText(app, this))} ${ap.summary()} ${dim(`${this.fights} fights, ${this.kills} kills, +${this.xp} xp`)}`);
874
+ if (!app.closed && (this.fights > 0 || ap.step > 3)) { app.stack = []; app.push(endCard(app, this)); }
875
+ app.afterAction();
550
876
  },
877
+ /** Between fights: the run panel (tuiAutopilot.mjs) fills the middle. */
878
+ render(W, H) { return runPanelLines(app, this, W, H); },
551
879
  };
552
880
  const liveFight = (step, settle) => {
553
881
  const c = app.ds.creature(step.creatureEntry);
882
+ const name = c?.name ?? String(step.creatureEntry);
554
883
  const why = () => fightReason(app, step);
555
884
  const reason = why();
556
- ctl.status = `${step.forQuest ? 'quest target' : 'hunting'}: ${c?.name ?? step.creatureEntry}${reason && step.forQuest ? ` ${reason.short}` : ''}`;
885
+ ctl.status = `${step.forQuest ? 'quest target' : 'hunting'}: ${name}${reason && step.forQuest ? ` ${reason.short}` : ''}`;
557
886
  app.say(`${dim(String(ap.step).padStart(3) + '.')} ${ctl.status}`);
887
+ const row = ctl.note('fight', `${fit(name, 24)} ${cyan('fighting')}${reason ? ` ${dim(reason.short)}` : ''}`);
558
888
  const f = startFight(app, step.creatureEntry, {
559
889
  label: 'autopilot', why,
560
890
  onEnd: (r) => {
561
- if (!r) { app.say(yellow('Autopilot stopped: you ran.')); ctl.stop(); return; }
891
+ ctl.watchFight = false; // w watches one fight; the next one draws inside the panel again
892
+ if (!r) { row.body = `${fit(name, 24)} ${yellow('ran')}`; ctl.end('you ran'); return; }
893
+ const skippedBefore = ap.skipped.length;
562
894
  settle(r);
563
- ctl.status = `${r.outcome === 'win' ? 'beat' : r.outcome === 'defeat' ? 'lost to' : 'stalemate with'} ${c?.name ?? step.creatureEntry}`;
895
+ ctl.status = `${r.outcome === 'win' ? 'beat' : r.outcome === 'defeat' ? 'lost to' : 'stalemate with'} ${name}`;
564
896
  ctl.fights++;
565
- if (r.outcome === 'win') { ctl.kills += r.killCount ?? 1; ctl.xp += r.xpGained; }
897
+ 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); }
898
+ if (r.outcome === 'defeat') ctl.deaths++;
899
+ const outcome = r.outcome === 'win' ? green('won') : r.outcome === 'defeat' ? red('lost') : yellow('stalemate');
900
+ const loot = r.loot.map((d) => `${app.itemLabel(d.item)}${d.count > 1 ? ` x${d.count}` : ''}`).join(', ');
901
+ row.body = `${fit(name, 24)} ${outcome} ${dim(`${(r.result.durationMs / 1000).toFixed(1)} s`)}${r.xpGained ? ` ${yellow(`+${r.xpGained} xp`)}` : ''}${loot ? ` ${loot}` : ''}`;
902
+ 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
903
  },
567
904
  });
568
905
  return f.ok ? f : { ok: false, reason: f.reason };
569
906
  };
570
- const ap = app.cmds.createAutopilot(`${steps}${farmQuery ? ` farm ${farmQuery}` : ''}`, { liveFight, quest });
907
+ const ap = app.cmds.createAutopilot(farmQuery ? `farm ${farmQuery}` : '', { liveFight, quest, maxSteps: steps, clock: () => Date.now() - ctl.startedAt });
571
908
  if (ap.error) { app.say(red(ap.error)); app.render(); return; }
572
909
  app.say(cyan(`Autopilot: ${what}, fights play live. Esc stops it.`));
573
910
  app.auto = ctl;