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.
- package/README.md +326 -82
- package/bin/azsh.mjs +21 -19
- package/package.json +19 -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/fileRepository.mjs +46 -1
- package/src/cli/gzLoader.mjs +47 -6
- package/src/cli/hdArt.mjs +81 -0
- package/src/cli/iconArt.mjs +135 -0
- package/src/cli/iconGrid.mjs +100 -0
- package/src/cli/modelArt.mjs +295 -0
- package/src/cli/paperdoll.mjs +76 -0
- package/src/cli/perfCheck.mjs +56 -0
- package/src/cli/repl.mjs +36 -7
- package/src/cli/rotationText.mjs +6 -6
- package/src/cli/sixel.mjs +307 -0
- package/src/cli/term.mjs +106 -12
- package/src/cli/text.mjs +5 -3
- package/src/cli/tui.mjs +252 -98
- package/src/cli/tuiAutopilot.mjs +347 -0
- package/src/cli/tuiBg.mjs +371 -42
- package/src/cli/tuiCheck.mjs +415 -28
- package/src/cli/tuiCity.mjs +118 -13
- package/src/cli/tuiCompanions.mjs +25 -15
- package/src/cli/tuiCreate.mjs +223 -0
- package/src/cli/tuiDungeon.mjs +1 -1
- package/src/cli/tuiFight.mjs +425 -88
- package/src/cli/tuiFightPanes.mjs +27 -8
- package/src/cli/tuiFloor.mjs +2 -2
- package/src/cli/tuiGear.mjs +10 -6
- package/src/cli/tuiLoot.mjs +1 -0
- package/src/cli/tuiPaperdoll.mjs +107 -0
- package/src/cli/tuiPartyStage.mjs +177 -0
- package/src/cli/tuiProfessions.mjs +4 -2
- package/src/cli/tuiRotationCond.mjs +390 -0
- package/src/cli/tuiSaves.mjs +22 -3
- package/src/cli/tuiSearch.mjs +75 -22
- package/src/cli/tuiSettings.mjs +15 -0
- package/src/cli/tuiSimLab.mjs +1 -1
- package/src/cli/tuiStage.mjs +304 -0
- package/src/cli/tuiTalentTree.mjs +124 -0
- package/src/cli/tuiViews.mjs +296 -115
- package/src/cli/tuiWorld.mjs +4 -5
- 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.bin.gz +0 -0
- package/src/data/rotations/pve/paladin/retribution.json +2 -2
- 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/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 +39 -1
- package/src/engine/combat/procsDamage.ts +31 -1
- package/src/engine/combat/sessionChase.ts +7 -1
- package/src/engine/combat/sessionMotionKit.ts +40 -2
- package/src/quests/quests.ts +60 -0
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
// How the autopilot run looks on screen: the pre-flight card a opens, the run strip under the tabs while
|
|
2
|
+
// you browse, the run panel between fights (now, next, quests, this run's numbers, what stops it), the Run
|
|
3
|
+
// pane at the bottom (one row per planner step) and the end card. The run itself is the controller in
|
|
4
|
+
// tuiFight.mjs (startAutopilot); the planner is repl.mjs createAutopilot. Everything here only reads.
|
|
5
|
+
import { bold, dim, cyan, yellow, green, red, magenta, inverse, fit, vis, cut, bar, strip, pct, secondsText, wrapLines } from './text.mjs';
|
|
6
|
+
import { idlePlayerFrame, startAutopilot, fightArt, flankArt } from './tuiFight.mjs';
|
|
7
|
+
import { objectiveLines, questDialog } from './tuiViews.mjs';
|
|
8
|
+
import { autopilotSettingsDialog } from './tuiAutopilotSettings.mjs';
|
|
9
|
+
import { squadModeOf } from './squad.mjs';
|
|
10
|
+
import { moneyText } from '../domain/economy/money.ts';
|
|
11
|
+
import { maxHealth, maxPower } from '../domain/character/character.ts';
|
|
12
|
+
|
|
13
|
+
const H = (text) => ({ text, header: true, plain: true });
|
|
14
|
+
/** A labelled line that may run long (a list of quests): wrapped, the later lines indented under the text. */
|
|
15
|
+
const wrapRows = (lead, text, W) => wrapLines(text, Math.max(20, W - 13)).map((l, i) => H(`${i ? ' '.repeat(11) : lead}${l}`));
|
|
16
|
+
const num = (n) => Math.round(n).toLocaleString('en-US');
|
|
17
|
+
const clock = (ms) => { const s = Math.max(0, Math.floor(ms / 1000)); return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`; };
|
|
18
|
+
const signedMoney = (copper) => (copper < 0 ? `-${moneyText(-copper)}` : `+${moneyText(copper) || '0c'}`);
|
|
19
|
+
|
|
20
|
+
/** "questing in Redridge Mountains", "farming Redridge Mystic in …", "doing A Baying of Gnolls". */
|
|
21
|
+
export function runWhat(app, ctl) {
|
|
22
|
+
const zone = app.zone()?.name ?? app.ch.zoneId;
|
|
23
|
+
return ctl.quest != null ? ctl.what : `${ctl.what} in ${zone}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The quest the run is on (the pinned one, else the one the last step served) and its counters: "The Algaz
|
|
27
|
+
* Gauntlet Dragonmaw Scout 2/8 · Dragonmaw Grunt 0/6". Empty while the run has no quest in hand. */
|
|
28
|
+
function questProgress(app, ctl) {
|
|
29
|
+
const log = app.g.state.questlog.quests;
|
|
30
|
+
const open = (e) => log.find((x) => x.entry === e && x.status !== 'turnedIn');
|
|
31
|
+
// The pinned quest, else the one the last step served; once that is handed in or skipped, the next one in the log with work.
|
|
32
|
+
const last = ctl.planner.skips(ctl.planner.lastQuest) ? null : ctl.planner.lastQuest;
|
|
33
|
+
let qs = open(ctl.quest ?? last) ?? log.find((x) => x.status === 'active' && !ctl.planner.skips(x.entry)) ?? log.find((x) => x.status === 'complete');
|
|
34
|
+
const q = qs ? app.ds.quest(qs.entry) : null;
|
|
35
|
+
if (!q) return '';
|
|
36
|
+
const counts = qs.status === 'complete' ? green('ready to turn in') : objectiveLines(app, q, qs).map((o) => o.replace(' slain: ', ' ')).join(sep);
|
|
37
|
+
return `${cyan(q.title)} ${counts}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Why the run ended, in a sentence. The planner's reasons come first; the controller's own (Esc, running) are plain text. */
|
|
41
|
+
export function stopText(app, ctl) {
|
|
42
|
+
if (ctl.ended) return ctl.ended;
|
|
43
|
+
const r = ctl.planner.stopReason;
|
|
44
|
+
if (!r) return 'the run ended';
|
|
45
|
+
const d = r.detail ? ` (${r.detail})` : '';
|
|
46
|
+
switch (r.reason) {
|
|
47
|
+
case 'levelCap': return `level ${app.ch.level} reached`;
|
|
48
|
+
case 'timeUp': return 'the time limit ran out';
|
|
49
|
+
case 'defeated': return `${ctl.planner.defeats} deaths in a row`;
|
|
50
|
+
case 'steps': return r.detail;
|
|
51
|
+
case 'questComplete': return r.detail ?? 'the quest is done';
|
|
52
|
+
case 'outOfConsumables': return 'out of food and drink';
|
|
53
|
+
case 'noTargets': return `nothing left to do${d}`;
|
|
54
|
+
default: return `${r.reason}${d}`;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The run is over and wants a person: the pinned quest is ready to hand in with a reward to choose. */
|
|
59
|
+
export function needsYou(ctl) {
|
|
60
|
+
const r = ctl.planner.stopReason;
|
|
61
|
+
return !!r && r.reason === 'questComplete' && /^Ready to hand in/.test(r.detail ?? '') ? ctl.quest : null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ---- the strip under the tabs ----------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
/** One line while the run is alive and something else fills the middle: what, doing, the two numbers, the keys. */
|
|
67
|
+
export function runStripLine(app, W) {
|
|
68
|
+
const ctl = app.auto;
|
|
69
|
+
const head = ctl.paused ? yellow('‖ Autopilot paused') : cyan('▶ Autopilot');
|
|
70
|
+
const keys = dim('Esc, w: back to the run');
|
|
71
|
+
const bits = [ctl.what, cut(ctl.status, Math.max(16, W - 70)) || dim('planning'), `${ctl.fights} fights ${yellow(`+${num(ctl.xp)} xp`)}`, dim(clock(Date.now() - ctl.startedAt))];
|
|
72
|
+
return fit(` ${head} ${bits.join(dim(' · '))}`, W - vis(keys) - 3) + ' ' + keys + ' ';
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ---- the panel between fights ----------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
const label = (s) => ` ${cyan(fit(s, 9))} `;
|
|
78
|
+
const tag = (s) => ` ${cyan(fit(s, 5))} `;
|
|
79
|
+
const sep = dim(' · ');
|
|
80
|
+
|
|
81
|
+
/** The middle, kept small: one title line (what, where it stands, what stops it), your frame (the header is
|
|
82
|
+
* down to the XP line during a run), then the fight when one is on or Now and Next between fights. The run's
|
|
83
|
+
* numbers, the quests and the bags are panes at the bottom. */
|
|
84
|
+
export function runPanelLines(app, ctl, W, height) {
|
|
85
|
+
const cfg = app.g.autopilotConfig();
|
|
86
|
+
// The title is the quest the run is on and how it stands; what the run is only when it has no quest in hand.
|
|
87
|
+
// Steps, clock, pace and the stop limits are not progress: the pre-flight card and the Run pane have them.
|
|
88
|
+
// A fight on takes the Now/Next slot with the foes and the party, and its clock sits on the title's right.
|
|
89
|
+
// Your frame stays on the same rows either way. No "Autopilot" label: the screen's shape says so.
|
|
90
|
+
// A fight on draws its stage under the title (you left, the foe right, plates under their feet); when the
|
|
91
|
+
// stage does not fit, the sprites take the sides (fightArt) and the frames keep the middle.
|
|
92
|
+
// A win holds its fight on the stage a moment (the fight's own hold): the foe at 0, the drops sliding in.
|
|
93
|
+
const live = app.fight?.label === 'autopilot' && !ctl.watchFight ? app.fight : null;
|
|
94
|
+
const stage = live ? live.stage(W, height - 1, { inline: true }) : null;
|
|
95
|
+
if (stage) {
|
|
96
|
+
const status = `${dim(`${(live.session.t / 1000).toFixed(1)}s`)}${live.auto ? '' : yellow(' you play')}${live.paused ? red(' PAUSED') : ''} `;
|
|
97
|
+
const quest = questProgress(app, ctl);
|
|
98
|
+
return [fit(` ${quest || dim(runWhat(app, ctl))}`, W - vis(status)) + status, ...stage];
|
|
99
|
+
}
|
|
100
|
+
const { me, foe, innerW } = live ? fightArt(app, live, W, height) : { me: null, foe: null, innerW: W };
|
|
101
|
+
const fight = live ? live.inlineLines(innerW, Math.max(2, height - 3)) : null;
|
|
102
|
+
const quest = questProgress(app, ctl);
|
|
103
|
+
const right = fight ? `${fight.status} ` : ctl.paused ? `${red('PAUSED')} ` : '';
|
|
104
|
+
const l0 = fit(` ${quest || dim(runWhat(app, ctl))}`, innerW - vis(right)) + right;
|
|
105
|
+
const frame = fight ? fight.player : idlePlayerFrame(app, ctl.frames, innerW);
|
|
106
|
+
const loot = live?.lootLine();
|
|
107
|
+
const middle = fight ? [...(loot ? [loot] : []), ...fight.fight] : [nowLine(app, ctl, cfg), nextLine(app, ctl)];
|
|
108
|
+
const lines = [l0, ...frame, ...middle].slice(0, height).map((l) => cut(l, innerW));
|
|
109
|
+
return flankArt(lines, me, foe, W, height);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The one-line XP header a run keeps in place of the name, bars and tabs. */
|
|
113
|
+
export function runHeaderLine(app, W) {
|
|
114
|
+
const ch = app.ch, xpMax = app.ds.xpToNext(ch.level);
|
|
115
|
+
const seg = Math.max(10, Math.min(40, Math.floor(W / 3)));
|
|
116
|
+
return cut(` ${magenta('XP')} ${bar(ch.xp, xpMax, seg, magenta)} ${dim(`${pct(ch.xp, xpMax)}%${ch.restedXp ? ' rested' : ''} ${num(xpMax - ch.xp)} to level ${ch.level + 1}`)}`, W);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ---- the Quests pane -------------------------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
/** The quests the run works on, with their counters, under a line that says what comes next. */
|
|
122
|
+
export function questPaneLines(app, L) {
|
|
123
|
+
const ctl = app.auto ?? app.lastRun;
|
|
124
|
+
const ds = app.ds, log = app.g.state.questlog.quests;
|
|
125
|
+
const mine = log.filter((qs) => qs.status !== 'turnedIn').sort((a, b) => (a.entry === ctl?.quest ? -1 : b.entry === ctl?.quest ? 1 : 0));
|
|
126
|
+
const lines = [` ${bold('Quests')} ${ctl ? dim(strip(nextLine(app, ctl)).replace(/^\s*Next\s+/, 'next: ')) : dim(`${mine.length} in the log`)}`];
|
|
127
|
+
if (!mine.length) return [...lines, dim(' Nothing in the quest log.')];
|
|
128
|
+
const titleW = Math.min(32, Math.max(16, Math.floor(L.W * 0.3)));
|
|
129
|
+
for (const qs of mine.slice(0, L.logH - 1)) {
|
|
130
|
+
const q = ds.quest(qs.entry);
|
|
131
|
+
if (!q) continue;
|
|
132
|
+
const state = qs.status === 'complete' ? green('ready to turn in') : qs.status === 'failed' ? red('failed')
|
|
133
|
+
: (ctl?.planner.skips(qs.entry) ?? app.cmds.questBlacklist.has(qs.entry)) ? dim(`skipped${ctl?.planner.questDefeats(qs.entry) ? `: died ${ctl.planner.questDefeats(qs.entry)} times` : ''}`)
|
|
134
|
+
: objectiveLines(app, q, qs).map((o) => o.replace(' slain: ', ' ')).join(dim(' '));
|
|
135
|
+
lines.push(` ${qs.entry === ctl?.quest ? cyan('▸') : ' '}${fit(q.title, titleW)} ${state}`);
|
|
136
|
+
}
|
|
137
|
+
if (mine.length > L.logH - 1) lines[lines.length - 1] = fit(lines[lines.length - 1], L.W - 10) + dim(`+${mine.length - (L.logH - 1)} more`);
|
|
138
|
+
return lines;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ---- the Bags pane ---------------------------------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
/** The Bags tab's rows at the bottom, with their own cursor, so an item can be used while the run plays. */
|
|
144
|
+
const bagsRows = (app) => { const v = app.tabs.find((t) => t.view.id === 'bags')?.view; return v ? (v.rows(app) ?? []).filter((r) => !r.header || r.text) : []; };
|
|
145
|
+
|
|
146
|
+
export function bagsPaneLines(app, L) {
|
|
147
|
+
const rows = bagsRows(app);
|
|
148
|
+
const st = (app.bagsPane ??= { cursor: 0 });
|
|
149
|
+
const items = rows.map((r, i) => i).filter((i) => !rows[i].header);
|
|
150
|
+
if (!items.length) return [` ${bold('Bags')}`, dim(' Nothing in your bags.')];
|
|
151
|
+
if (!items.includes(st.cursor)) st.cursor = items.find((i) => i >= st.cursor) ?? items[items.length - 1];
|
|
152
|
+
const room = L.logH - 1;
|
|
153
|
+
const at = items.indexOf(st.cursor);
|
|
154
|
+
const start = Math.max(0, Math.min(at - Math.floor(room / 2), items.length - room));
|
|
155
|
+
const lines = [` ${bold('Bags')} ${dim('Up Down: pick Enter: use, equip, open the run plays on')}`];
|
|
156
|
+
for (const i of items.slice(start, start + room)) {
|
|
157
|
+
const r = rows[i];
|
|
158
|
+
const text = fit(i === st.cursor ? `▶ ${r.text}` : ` ${r.text}`, L.W - 1);
|
|
159
|
+
lines.push(i === st.cursor ? inverse(text) : ` ${text}`);
|
|
160
|
+
}
|
|
161
|
+
return lines;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Keys on the Bags pane: the cursor moves over the items, Enter acts on the one under it. True when handled. */
|
|
165
|
+
export function bagsPaneKey(app, k) {
|
|
166
|
+
const rows = bagsRows(app);
|
|
167
|
+
const st = (app.bagsPane ??= { cursor: 0 });
|
|
168
|
+
const items = rows.map((r, i) => i).filter((i) => !rows[i].header);
|
|
169
|
+
if (!items.length) return false;
|
|
170
|
+
const at = Math.max(0, items.indexOf(st.cursor));
|
|
171
|
+
const move = (d) => { st.cursor = items[Math.max(0, Math.min(items.length - 1, at + d))]; app.render(); return true; };
|
|
172
|
+
if (k === 'up' || k === 'k') return move(-1);
|
|
173
|
+
if (k === 'down' || k === 'j') return move(+1);
|
|
174
|
+
if (k === 'pageup') return move(-5);
|
|
175
|
+
if (k === 'pagedown') return move(+5);
|
|
176
|
+
if (k === 'home') { st.cursor = items[0]; app.render(); return true; }
|
|
177
|
+
if (k === 'end') { st.cursor = items[items.length - 1]; app.render(); return true; }
|
|
178
|
+
if (k === 'return') { const r = rows[st.cursor]; if (r?.act) { r.act(app, r); app.afterAction(); } return true; }
|
|
179
|
+
return false;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function nowLine(app, ctl, cfg) {
|
|
183
|
+
const ch = app.ch, ds = app.ds;
|
|
184
|
+
if (/^resting/.test(ctl.status)) {
|
|
185
|
+
const hpMax = maxHealth(ch, ds), pwMax = maxPower(ch, ds);
|
|
186
|
+
const goal = Math.round(cfg.recoverToPct * 100);
|
|
187
|
+
const low = ch.power / Math.max(1, pwMax) < cfg.recoverToPct && pwMax > 0 ? ` MP ${pct(ch.power, pwMax)}% → ${goal}%` : '';
|
|
188
|
+
return `${tag('Now')}${ctl.status} ${bar(ch.health, hpMax * cfg.recoverToPct, 12, green)} ${dim(`HP ${pct(ch.health, hpMax)}% → ${goal}%${low}`)}`;
|
|
189
|
+
}
|
|
190
|
+
return `${tag('Now')}${ctl.status || dim('planning the next step')}`;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** What the planner has queued up: quests ready to hand in, then the first unmet objective of each active quest. */
|
|
194
|
+
function nextLine(app, ctl) {
|
|
195
|
+
const ds = app.ds, log = app.g.state.questlog.quests;
|
|
196
|
+
const pieces = [];
|
|
197
|
+
if (ctl.farmQuery) pieces.push(`the next ${ctl.farmQuery}`);
|
|
198
|
+
const mine = log.filter((qs) => qs.status !== 'turnedIn' && (ctl.quest == null || qs.entry === ctl.quest) && !ctl.planner.skips(qs.entry));
|
|
199
|
+
for (const qs of mine) { const q = ds.quest(qs.entry); if (q && qs.status === 'complete') pieces.push(`turn in ${q.title}${q.enderName ? ` at ${q.enderName}` : ''}`); }
|
|
200
|
+
for (const qs of mine) {
|
|
201
|
+
const q = ds.quest(qs.entry);
|
|
202
|
+
if (!q || qs.status !== 'active') continue;
|
|
203
|
+
const k = q.killTargets.find((t) => (qs.kills?.[String(t.creatureEntry)] ?? 0) < t.count);
|
|
204
|
+
if (k) { pieces.push(`hunt ${ds.creature(k.creatureEntry)?.name ?? k.creatureEntry} ${Math.min(qs.kills?.[String(k.creatureEntry)] ?? 0, k.count)}/${k.count}`); continue; }
|
|
205
|
+
const o = q.objectTargets.find((t) => (qs.objects?.[String(t.objectEntry)] ?? 0) < t.count);
|
|
206
|
+
if (o) { pieces.push(`find ${ds.gameObject?.(o.objectEntry)?.name ?? 'the objects'} for ${q.title}`); continue; }
|
|
207
|
+
if (q.itemTargets.length) { pieces.push(`collect for ${q.title}`); continue; }
|
|
208
|
+
if (q.enderName) pieces.push(`speak with ${q.enderName}`);
|
|
209
|
+
}
|
|
210
|
+
if (!pieces.length) pieces.push(ctl.quest != null ? 'finish the quest' : 'find a quest to take');
|
|
211
|
+
return `${tag('Next')}${pieces.slice(0, 3).join(sep)}${pieces.length > 3 ? dim(` +${pieces.length - 3} more`) : ''}`;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** This run in one line: fights, kills, deaths, xp with its rate and the time to the next level, gold and items. */
|
|
215
|
+
function runLine(app, ctl) {
|
|
216
|
+
const ch = app.ch, ds = app.ds;
|
|
217
|
+
const hours = (Date.now() - ctl.startedAt) / 3_600_000;
|
|
218
|
+
const xph = hours >= 1 / 60 && ctl.xp > 0 ? ctl.xp / hours : 0;
|
|
219
|
+
const need = ds.xpToNext(ch.level) - ch.xp;
|
|
220
|
+
const eta = xph > 0 ? Math.max(1, Math.round((need / xph) * 60)) : 0;
|
|
221
|
+
const bits = [
|
|
222
|
+
`${ctl.fights} fights`, `${ctl.kills} kills`, ctl.deaths ? red(`${ctl.deaths} deaths`) : dim('0 deaths'),
|
|
223
|
+
yellow(`+${num(ctl.xp)} xp`), ...(xph ? [dim(`${num(xph)} xp/h`), `level ${ch.level + 1} in ${bold(`~${eta} min`)}`] : []),
|
|
224
|
+
yellow(signedMoney(ch.gold - ctl.gold0)), `${ctl.items} item${ctl.items === 1 ? '' : 's'}`,
|
|
225
|
+
];
|
|
226
|
+
return `${tag('Run')}${bits.join(sep)}`;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ---- the Run pane ----------------------------------------------------------------------------------
|
|
230
|
+
|
|
231
|
+
const GLYPH = {
|
|
232
|
+
fight: red('⚔'), wait: dim('~'), consume: dim('~'), cannibalize: dim('~'), turnin: green('✓'), accept: green('+'), travel: cyan('→'),
|
|
233
|
+
gather: yellow('⚒'), skin: yellow('⚒'), buy: yellow('$'), equip: green('▲'), skip: yellow('✗'), dead: red('☠'), stop: yellow('■'),
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
/** The planner's ledger, newest last: one row per step, fights folded to their outcome. */
|
|
237
|
+
export function runPaneLines(app, L) {
|
|
238
|
+
const ctl = app.auto ?? app.lastRun;
|
|
239
|
+
if (!ctl) return [` ${bold('Run')}`, dim(' No autopilot run yet. a on the World tab starts one.')];
|
|
240
|
+
const ap = ctl.planner;
|
|
241
|
+
const title = fit(`${runLine(app, ctl)}${sep}${dim(`${clock(Date.now() - ctl.startedAt)} · ${ap.step} steps`)}${ctl.ended || ap.done ? dim(`${sep}stopped: ${stopText(app, ctl)}`) : ''}`, L.W);
|
|
242
|
+
const rows = ctl.ledger.slice(-(L.logH - 1));
|
|
243
|
+
if (!rows.length) return [title, dim(' Nothing yet.')];
|
|
244
|
+
return [title, ...rows.map((r) => ` ${dim(`${String(r.n).padStart(4)}.`)} ${GLYPH[r.kind] ?? dim('·')} ${r.body}`)];
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ---- the end card ----------------------------------------------------------------------------------
|
|
248
|
+
|
|
249
|
+
/** What the run did, why it stopped, and the keys for what comes next. */
|
|
250
|
+
export function endCard(app, ctl) {
|
|
251
|
+
const ap = ctl.planner, ds = app.ds, ch = app.ch;
|
|
252
|
+
const pin = needsYou(ctl);
|
|
253
|
+
const q = pin != null ? ds.quest(pin) : null;
|
|
254
|
+
const keys = {
|
|
255
|
+
q: { hint: 'quests', fn: (a) => { a.stack = []; a.switchTab(1); } },
|
|
256
|
+
b: { hint: 'bags', fn: (a) => { a.stack = []; a.switchTab(2); } },
|
|
257
|
+
a: { hint: 'run again', fn: (a) => { a.stack = []; startAutopilot(a, ctl.steps, ctl.farmQuery, ctl.quest); } },
|
|
258
|
+
};
|
|
259
|
+
if (q) keys.t = { hint: 'hand it in', fn: (a) => { a.stack = []; a.push(questDialog(a, q)); } };
|
|
260
|
+
return {
|
|
261
|
+
id: 'autopilot-done', title: 'Autopilot done', noDetail: true, keys,
|
|
262
|
+
hint: 'Enter: close',
|
|
263
|
+
onKey: (a, k) => { if (k === 'return' || k === 'space') { a.pop(); return true; } return false; },
|
|
264
|
+
rows(a) {
|
|
265
|
+
const ms = Date.now() - ctl.startedAt;
|
|
266
|
+
const xph = ms >= 60_000 && ctl.xp > 0 ? ` ${dim(`${num(ctl.xp / (ms / 3_600_000))} xp/h`)}` : '';
|
|
267
|
+
const skipped = ap.skipped.map((e) => `${ds.quest(e)?.title ?? `quest ${e}`}${ap.questDefeats(e) ? dim(` (died ${ap.questDefeats(e)} times)`) : ''}`);
|
|
268
|
+
const why = q ? yellow(`needs you: ${q.title} is ready to hand in and the reward is yours to pick`) : stopText(a, ctl);
|
|
269
|
+
const rows = [
|
|
270
|
+
H(` ${bold('Autopilot done')} ${dim(`${secondsText(ms)} · ${ap.step} steps`)}`),
|
|
271
|
+
H(` Stopped: ${why}`),
|
|
272
|
+
H(''),
|
|
273
|
+
H(`${label('Fights')}${ctl.fights - ctl.deaths} won ${ctl.deaths ? red(`${ctl.deaths} lost`) : dim('0 lost')} ${cyan('XP')} ${yellow(`+${num(ctl.xp)}`)}${ctl.level0 !== ch.level ? ` level ${ctl.level0} → ${bold(String(ch.level))}` : ''}${xph}`),
|
|
274
|
+
...wrapRows(label('Quests'), ctl.turnedIn.length ? `${ctl.turnedIn.length} turned in: ${ctl.turnedIn.join(', ')}` : dim('none turned in'), a.layout().W),
|
|
275
|
+
...(skipped.length ? wrapRows(' '.repeat(11), `${skipped.length} skipped: ${skipped.join(', ')}`, a.layout().W) : []),
|
|
276
|
+
H(`${label('Loot')}${ctl.items} item${ctl.items === 1 ? '' : 's'} ${yellow(signedMoney(ch.gold - ctl.gold0))}`),
|
|
277
|
+
H(`${label('Now')}${a.zone()?.name ?? ch.zoneId} ${dim(`HP ${a.hpText()} ${a.pwText()}`)}`),
|
|
278
|
+
H(''),
|
|
279
|
+
H(` ${dim(`Enter: close${q ? ' t: hand it in' : ''} q: quests b: bags a: run again`)}`),
|
|
280
|
+
];
|
|
281
|
+
return rows;
|
|
282
|
+
},
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// ---- the pre-flight card ---------------------------------------------------------------------------
|
|
287
|
+
|
|
288
|
+
/** a on the World tab: what the run will do, read from the settings, before it does it. Enter starts it. */
|
|
289
|
+
export function preflightCard(app) {
|
|
290
|
+
return {
|
|
291
|
+
id: 'autopilot-preflight', title: 'Autopilot', noDetail: true,
|
|
292
|
+
hint: 'Enter: go',
|
|
293
|
+
keys: {
|
|
294
|
+
A: { hint: 'settings', fn: (a) => a.push(autopilotSettingsDialog(a)) },
|
|
295
|
+
f: { hint: 'farm a creature', fn: (a) => farmPicker(a) },
|
|
296
|
+
u: { fn: (a) => { if (!a.cmds.questBlacklist.size) return; a.cmds.questBlacklist.clear(); a.flashMsg('Skipped quests are back on the list.'); } },
|
|
297
|
+
},
|
|
298
|
+
onKey: (a, k) => { if (k === 'return' || k === 'space') { a.stack = []; startAutopilot(a); return true; } return false; },
|
|
299
|
+
rows(a) {
|
|
300
|
+
const cfg = a.g.autopilotConfig(), ds = a.ds, log = a.g.state.questlog.quests;
|
|
301
|
+
const active = log.filter((qs) => qs.status === 'active').length, ready = log.filter((qs) => qs.status === 'complete').length;
|
|
302
|
+
const mode = squadModeOf(cfg);
|
|
303
|
+
const squad = (a.g.state.squad ?? []).map((c) => c.name);
|
|
304
|
+
const fights = mode === 'party' && squad.length ? `with the party ${dim(`(${squad.join(', ')})`)}` : mode === 'elite' && squad.length ? `alone, the party joins for elites` : squad.length ? 'alone' : `alone ${dim('(no companions hired)')}`;
|
|
305
|
+
const role = { dps: 'damage', tank: 'tank', offtank: 'off-tank' }[cfg.combatRole] ?? cfg.combatRole;
|
|
306
|
+
const stops = [cfg.stopAtLevel > 0 ? `at level ${cfg.stopAtLevel}` : '', `after ${cfg.maxConsecutiveDefeats} deaths in a row`, cfg.sessionMs > 0 ? `after ${Math.round(cfg.sessionMs / 60_000)} min` : ''].filter(Boolean);
|
|
307
|
+
const skipped = [...a.cmds.questBlacklist].map((e) => ds.quest(e)?.title ?? `quest ${e}`);
|
|
308
|
+
const rows = [
|
|
309
|
+
H(` ${bold('Autopilot')} ${dim('what it will do')}`),
|
|
310
|
+
H(''),
|
|
311
|
+
H(`${label('Questing')}in ${a.zone()?.name ?? a.ch.zoneId} ${dim(`${active} active quest${active === 1 ? '' : 's'}${ready ? `, ${ready} ready to turn in` : ''}`)}`),
|
|
312
|
+
H(`${label('Fights')}${fights} role: ${role}`),
|
|
313
|
+
H(`${label('Rest')}below ${Math.round(cfg.healthEnterPct * 100)}% health or ${Math.round(cfg.manaEnterPct * 100)}% mana, until ${Math.round(cfg.recoverToPct * 100)}%`),
|
|
314
|
+
H(`${label('Stops')}${stops.join(dim(' · '))}`),
|
|
315
|
+
H(`${label('Quests')}${cfg.questAutoAccept ? 'takes quests on its own' : 'only the quests in the log'} ${dim('·')} rewards: ${cfg.questRewardChoice === 'wait' ? 'asks you' : 'picks the best'} ${dim('·')} zones: ${{ 'level-safe': 'when the level fits', any: 'wherever the quests go', off: 'stays here' }[cfg.questZoneSwap] ?? cfg.questZoneSwap}`),
|
|
316
|
+
...(skipped.length ? [H(`${label('Skips')}${skipped.join(', ')} ${dim('u: take them again')}`)] : []),
|
|
317
|
+
H(''),
|
|
318
|
+
H(` ${dim('Enter: go A: settings f: farm a creature instead Esc: not now')}`),
|
|
319
|
+
];
|
|
320
|
+
return rows;
|
|
321
|
+
},
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** f on the pre-flight card: hunt one creature of this zone over and over. */
|
|
326
|
+
function farmPicker(app) {
|
|
327
|
+
const z = app.zone();
|
|
328
|
+
const mobs = (z?.huntable ?? []).map((e) => app.ds.creature(e)).filter(Boolean).sort((x, y) => x.level - y.level || x.name.localeCompare(y.name));
|
|
329
|
+
if (!mobs.length) { app.flashMsg('Nothing to hunt here.'); return; }
|
|
330
|
+
app.pick('Farm which creature?', mobs.map((c) => ({ label: `${c.name} ${dim(`L${c.level}`)}`, value: c.name })), (name) => { app.stack = []; startAutopilot(app, Infinity, name); });
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** Strip the planner's " 93. " prefix off a step line so the ledger can number it itself. */
|
|
334
|
+
export function stepBody(line) {
|
|
335
|
+
const m = strip(line).match(/^\s*\d+\.\s/);
|
|
336
|
+
return m ? dropVisible(line, m[0].length) : line;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** The painted string without its first `n` visible characters; the colour codes among them stay. */
|
|
340
|
+
function dropVisible(s, n) {
|
|
341
|
+
let i = 0, seen = 0;
|
|
342
|
+
while (i < s.length && seen < n) {
|
|
343
|
+
if (s[i] === '\x1b') { const m = s.slice(i).match(/^\x1b\[[0-9;]*m/); if (m) { i += m[0].length; continue; } }
|
|
344
|
+
i++; seen++;
|
|
345
|
+
}
|
|
346
|
+
return s.slice(i);
|
|
347
|
+
}
|