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.
- package/README.md +362 -85
- package/bin/azsh.mjs +35 -19
- package/package.json +12 -1
- package/src/autopilot/plan.ts +8 -11
- package/src/autopilot/view.ts +12 -11
- package/src/cli/combatLogFormat.ts +22 -0
- package/src/cli/gzLoader.mjs +47 -6
- package/src/cli/hdArt.mjs +81 -0
- package/src/cli/iconArt.mjs +145 -0
- package/src/cli/iconGrid.mjs +100 -0
- package/src/cli/modelArt.mjs +298 -0
- package/src/cli/oocPrep.mjs +32 -0
- package/src/cli/paperdoll.mjs +76 -0
- package/src/cli/perfCheck.mjs +56 -0
- package/src/cli/repl.mjs +48 -7
- package/src/cli/rotationText.mjs +6 -6
- package/src/cli/sixel.mjs +319 -0
- package/src/cli/term.mjs +106 -12
- package/src/cli/text.mjs +21 -3
- package/src/cli/tui.mjs +262 -102
- package/src/cli/tuiAutopilot.mjs +356 -0
- package/src/cli/tuiBags.mjs +37 -4
- package/src/cli/tuiBg.mjs +370 -41
- package/src/cli/tuiCheck.mjs +633 -37
- package/src/cli/tuiCity.mjs +146 -41
- package/src/cli/tuiCompanionKit.mjs +6 -6
- package/src/cli/tuiCompanions.mjs +211 -41
- package/src/cli/tuiCreate.mjs +230 -0
- package/src/cli/tuiDungeon.mjs +57 -21
- package/src/cli/tuiFight.mjs +467 -88
- package/src/cli/tuiFightPanes.mjs +42 -14
- package/src/cli/tuiFloor.mjs +21 -9
- package/src/cli/tuiGear.mjs +17 -14
- package/src/cli/tuiLoot.mjs +6 -6
- package/src/cli/tuiMap.mjs +214 -0
- package/src/cli/tuiPaperdoll.mjs +108 -0
- package/src/cli/tuiPartyStage.mjs +206 -0
- package/src/cli/tuiPet.mjs +16 -8
- package/src/cli/tuiProfessions.mjs +27 -25
- package/src/cli/tuiRotationCond.mjs +390 -0
- package/src/cli/tuiSaves.mjs +24 -1
- package/src/cli/tuiSearch.mjs +79 -27
- package/src/cli/tuiSettings.mjs +3 -4
- package/src/cli/tuiStage.mjs +360 -0
- package/src/cli/tuiTalentTree.mjs +124 -0
- package/src/cli/tuiTalents.mjs +3 -4
- package/src/cli/tuiViews.mjs +633 -172
- package/src/cli/tuiWiki.mjs +2 -3
- package/src/cli/tuiWorld.mjs +90 -7
- package/src/data/gear-art.bin.br +0 -0
- package/src/data/hd-art.json +114 -0
- package/src/data/icon-art.bin.gz +0 -0
- package/src/data/model-art-worn.bin.br +0 -0
- package/src/data/model-art.bin.gz +0 -0
- package/src/data/rotations/pve/paladin/retribution.json +2 -2
- package/src/data/tables/creatures.json.gz +0 -0
- package/src/data/world-map-sixel.bin.br +0 -0
- package/src/data/world-map.bin.gz +0 -0
- package/src/domain/bg/match.ts +85 -24
- package/src/domain/bg/strategies.ts +144 -29
- package/src/domain/bg/types.ts +2 -0
- package/src/domain/bg/wsg.ts +35 -20
- package/src/domain/character/character.ts +6 -0
- package/src/domain/pet/pet.ts +31 -0
- package/src/domain/quests/doQuest.ts +24 -6
- package/src/domain/rotation/build.ts +3031 -3031
- package/src/domain/rotation/classes/paladin.ts +16 -11
- package/src/domain/rotation/evaluate.ts +3 -0
- package/src/engine/combat/applySpell.ts +20 -9
- package/src/engine/combat/applySpellLate.ts +16 -12
- package/src/engine/combat/areaAura.ts +284 -283
- package/src/engine/combat/fighterFactory.ts +1 -1
- package/src/engine/combat/pickAction.ts +3494 -3449
- package/src/engine/combat/procsDamage.ts +31 -1
- package/src/engine/combat/sessionChase.ts +19 -2
- package/src/engine/combat/sessionMotionKit.ts +40 -2
- package/src/engine/game/loadReconcile.ts +21 -1
- package/src/engine/game/pets.ts +33 -1
- package/src/engine/game/types.ts +4 -0
- package/src/quests/quests.ts +60 -0
package/src/cli/tuiFight.mjs
CHANGED
|
@@ -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,
|
|
3
|
-
//
|
|
4
|
-
// bar
|
|
5
|
-
|
|
6
|
-
|
|
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
|
-
|
|
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 ? '
|
|
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')} ${
|
|
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.
|
|
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(`+${
|
|
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
|
-
|
|
237
|
-
|
|
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
|
-
|
|
240
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
282
|
-
|
|
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
|
-
|
|
305
|
-
if (
|
|
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:
|
|
405
|
-
*
|
|
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
|
|
580
|
+
// The player's own aura order carries across fights, so their frame never reshuffles.
|
|
408
581
|
app.playerAuraOrder ??= new Map();
|
|
409
|
-
return {
|
|
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
|
|
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
|
-
//
|
|
459
|
-
//
|
|
460
|
-
|
|
461
|
-
if (
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
const
|
|
465
|
-
|
|
466
|
-
|
|
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
|
|
479
|
-
const
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
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
|
-
/**
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
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
|
|
492
|
-
function idlePlayerFrame(app, mem, W) {
|
|
493
|
-
const ch = app.ch, ds = app.ds
|
|
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
|
-
|
|
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.
|
|
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) {
|
|
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
|
-
/**
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
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'}: ${
|
|
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
|
-
|
|
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'} ${
|
|
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(
|
|
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;
|