azeroth-shell 0.1.2 → 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 +9 -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 +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 +32 -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 +231 -95
- package/src/cli/tuiAutopilot.mjs +347 -0
- package/src/cli/tuiBg.mjs +370 -41
- package/src/cli/tuiCheck.mjs +362 -27
- 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 +423 -86
- package/src/cli/tuiFightPanes.mjs +27 -8
- package/src/cli/tuiFloor.mjs +1 -1
- 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 +10 -1
- package/src/cli/tuiSearch.mjs +75 -22
- package/src/cli/tuiStage.mjs +304 -0
- package/src/cli/tuiTalentTree.mjs +124 -0
- package/src/cli/tuiViews.mjs +284 -110
- package/src/cli/tuiWorld.mjs +2 -2
- 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
package/src/cli/tuiCheck.mjs
CHANGED
|
@@ -2,14 +2,23 @@
|
|
|
2
2
|
// quest, a vendor and a trainer, pages through quest text, runs a live fight tick by tick, runs the autopilot
|
|
3
3
|
// through a live fight, and edits the rotation through its menu. Run with `npm run check:tui`; pass --frames
|
|
4
4
|
// to see every screen.
|
|
5
|
-
import { HeadlessTerm, decodeKeys } from './term.mjs';
|
|
5
|
+
import { HeadlessTerm, decodeKeys, takeMarks } from './term.mjs';
|
|
6
|
+
import { noteDeviceAttributes, imageOf } from './sixel.mjs';
|
|
7
|
+
import { splitCells } from './iconGrid.mjs';
|
|
8
|
+
import { iconCells } from './iconArt.mjs';
|
|
6
9
|
import { App } from './tui.mjs';
|
|
7
10
|
import { bootDataStore } from './bootData.mjs';
|
|
8
11
|
import { newGameState } from '../engine/game.ts';
|
|
9
|
-
import { strip } from './text.mjs';
|
|
12
|
+
import { strip, wrapLines } from './text.mjs';
|
|
10
13
|
import { visibleBars } from './tuiBars.mjs';
|
|
14
|
+
import { defaultConditionsForSpell } from './tuiRotationCond.mjs';
|
|
15
|
+
import { creatureArt, hasArt, artWidth } from './modelArt.mjs';
|
|
16
|
+
import { nativeDisplayId } from '../domain/character/displayForm.ts';
|
|
17
|
+
import { paperdollLayers, paperdollArt } from './paperdoll.mjs';
|
|
18
|
+
import { iconsOn, withIcon, wrapIconLines } from './iconArt.mjs';
|
|
11
19
|
import { addItem, countItem } from '../domain/items/inventory.ts';
|
|
12
20
|
import { moneyText as moneyTextOf } from '../domain/economy/money.ts';
|
|
21
|
+
import { offeredQuests } from '../quests/quests.ts';
|
|
13
22
|
|
|
14
23
|
const showFrames = process.argv.includes('--frames');
|
|
15
24
|
let failures = 0;
|
|
@@ -40,12 +49,20 @@ const frame = () => term.text();
|
|
|
40
49
|
const home = () => { let n = 0; while (app.stack.length && n++ < 10) term.press('escape'); };
|
|
41
50
|
const show = (label) => { if (showFrames) console.log(`\n=== ${label} ===\n${frame()}`); };
|
|
42
51
|
const rowIndex = (pred) => app.rowsOf(app.top()).findIndex((r) => !r.header && pred(r, strip(r.text)));
|
|
43
|
-
/** Run a live fight to its end by hand. */
|
|
52
|
+
/** Run a live fight to its end by hand, through the hold a win keeps on screen after it. */
|
|
44
53
|
const finishFight = () => { let n = 0; while (app.fight && n < 3000) { app.fight.tick(); n++; } return n; };
|
|
45
54
|
|
|
46
55
|
show('world');
|
|
47
56
|
check('header shows the character', frame().includes('Tuitest') && frame().includes('Elwynn Forest'));
|
|
48
57
|
check('world lists creatures', rowIndex((r, s) => r.data?.creature && /Kobold/.test(s)) >= 0);
|
|
58
|
+
{
|
|
59
|
+
// The People rows draw their NPC: the art pack bakes every vendor, trainer and innkeeper a zone lists.
|
|
60
|
+
const vendor = rowIndex((r, s) => /<General Supplies>/.test(s));
|
|
61
|
+
const saved = app.top().cursor;
|
|
62
|
+
app.top().cursor = vendor; app.render();
|
|
63
|
+
check('a vendor row draws the merchant beside their title', vendor >= 0 && /[▀▄]/.test(frame()));
|
|
64
|
+
app.top().cursor = saved; app.render();
|
|
65
|
+
}
|
|
49
66
|
{
|
|
50
67
|
const rs = app.rowsOf(app.top()).filter((r) => r.header).map((r) => strip(r.text));
|
|
51
68
|
check('world lists quests last, after creatures', rs.indexOf('Quests') > rs.findIndex((s) => s.startsWith('Creatures')));
|
|
@@ -53,11 +70,55 @@ check('world lists creatures', rowIndex((r, s) => r.data?.creature && /Kobold/.t
|
|
|
53
70
|
|
|
54
71
|
// Tabs 2..9 and 0 all render without throwing; Left/Right and Esc move between them.
|
|
55
72
|
for (const k of ['2', '3', '4', '5', '6', '7', '8', '9', '0']) { term.press(k); show(`tab ${k}`); }
|
|
56
|
-
|
|
73
|
+
// Rules are two lines each with their icons, so the Combat phase can start past the first window.
|
|
74
|
+
check('rotation tab shows rules', (term.press('6'), /Combat \(checked/.test(frame()) || (term.press('pagedown'), /Combat \(checked/.test(frame()))));
|
|
57
75
|
check('talents tab renders', (term.press('7'), /Talents unlock|point/.test(frame())));
|
|
58
76
|
check('0 opens the tenth tab', (term.press('0'), app.tab === 9));
|
|
59
77
|
const lastTab = app.tabs.length - 1;
|
|
60
78
|
check('character tab renders', (app.switchTab(lastTab), /Attributes/.test(frame())));
|
|
79
|
+
check('the character tab draws your body', !hasArt(nativeDisplayId(app.ch.race, app.ch.gender)) || /[▀▄]/.test(frame()));
|
|
80
|
+
{
|
|
81
|
+
// Starting gear is a shirt, pants, boots and a weapon or two: at least three of them have an appearance overlay.
|
|
82
|
+
const p = paperdollLayers(app.ch);
|
|
83
|
+
check('your starting gear stacks on your body', !p || (p.layers.filter((l) => /^\d/.test(l.key)).length >= 3 && !!paperdollArt(app.ch, 20, 12)));
|
|
84
|
+
}
|
|
85
|
+
if (iconsOn()) {
|
|
86
|
+
// A big window: the Gear tab is the character sheet and the Talents tab the three trees, both icon grids
|
|
87
|
+
// the arrows walk in two dimensions. At the usual 110x34 both list instead (the checks above).
|
|
88
|
+
const [cols, rows] = [term.cols, term.rows];
|
|
89
|
+
term.cols = 160; term.rows = 54;
|
|
90
|
+
const slot = () => app.rowsOf(app.top())[app.top().cursor]?.cell?.slot;
|
|
91
|
+
const rowIndexOfSlot = (s) => app.rowsOf(app.top()).findIndex((r) => r.cell?.slot === s);
|
|
92
|
+
app.switchTab(3);
|
|
93
|
+
check('a big window draws the Gear tab as the sheet', !!app.top().gridNav && /Equipment/.test(frame()) && /Head/.test(frame()));
|
|
94
|
+
app.top().cursor = 0; app.render();
|
|
95
|
+
term.press('shift-right');
|
|
96
|
+
const across = slot();
|
|
97
|
+
term.press('shift-left', 'shift-down', 'shift-down', 'shift-down', 'shift-down', 'shift-down', 'shift-down', 'shift-down');
|
|
98
|
+
const below = slot();
|
|
99
|
+
term.press('down');
|
|
100
|
+
check('Shift+arrows walk the sheet: head across to hands, down the left to the main hand', across === 'hands' && below === 'mainhand' && slot() === 'offhand');
|
|
101
|
+
term.press('right');
|
|
102
|
+
check('Right still changes the tab on the sheet', app.tab === 4);
|
|
103
|
+
app.switchTab(3);
|
|
104
|
+
app.top().cursor = rowIndexOfSlot('mainhand');
|
|
105
|
+
term.press('return');
|
|
106
|
+
check('Enter on a sheet slot opens its picker', app.top().id === 'slot-mainhand');
|
|
107
|
+
home();
|
|
108
|
+
app.switchTab(6);
|
|
109
|
+
const names = app.ds.talentTrees(app.ch.cls).map((t) => t.name);
|
|
110
|
+
check('the Talents tab draws every tree side by side', !!app.top().gridNav && names.every((n) => frame().includes(n)));
|
|
111
|
+
app.top().cursor = 0; app.render();
|
|
112
|
+
const first = app.rowsOf(app.top())[0].node;
|
|
113
|
+
term.press('shift-right');
|
|
114
|
+
const next = app.rowsOf(app.top())[app.top().cursor].node;
|
|
115
|
+
check('Shift+Right walks to the next talent on the tier', next.tree === first.tree && next.t.tier === first.t.tier && next.t.col > first.t.col && frame().includes(next.t.name));
|
|
116
|
+
term.cols = cols; term.rows = rows;
|
|
117
|
+
app.switchTab(lastTab);
|
|
118
|
+
// A tooltip's long sentence wraps beside its icon while they share rows, not back under the art.
|
|
119
|
+
const tip = wrapIconLines(withIcon('ability_rogue_ambush', ['Ambush', 'Ambush the target, causing 250% weapon damage plus 230 to the target. Must be stealthed and behind the target.']), 36);
|
|
120
|
+
check('tooltip text wraps beside its icon', tip.slice(0, 4).every((l) => l.startsWith('\x1b[38;2;')) && tip.length > 4);
|
|
121
|
+
}
|
|
61
122
|
{
|
|
62
123
|
const charRows = app.rowsOf(app.top()).map((r) => strip(r.text)).join('|');
|
|
63
124
|
check('the character tab lists weapon skills with Defense and the hand in use', /Weapon skills\|\s+Defense\s+\d+\/\d+\|/.test(charRows) && /\d+\/\d+( \+\d+)? +main hand/.test(charRows));
|
|
@@ -88,6 +149,8 @@ if (firstQuest > 0) {
|
|
|
88
149
|
check('Enter on Accept closed the dialog', app.stack.length === 0);
|
|
89
150
|
term.press('2'); show('quest log');
|
|
90
151
|
check('quest log lists it', /1 of 20/.test(frame()));
|
|
152
|
+
const zoneOffers = offeredQuests(app.ds, app.ch.zoneId, app.ch, app.g.state.questlog).filter((o) => o.canAccept).length;
|
|
153
|
+
check('quest log also lists what the zone offers', !zoneOffers || (/Available in /.test(frame()) && app.rowsOf(app.top()).filter((r) => !r.header).length > 1));
|
|
91
154
|
}
|
|
92
155
|
|
|
93
156
|
// Esc closes a window; the log window opens and closes.
|
|
@@ -130,9 +193,18 @@ const mobRow = app.rowsOf(app.top()).findIndex((r) => r.data?.creature);
|
|
|
130
193
|
app.top().cursor = mobRow; term.press('return');
|
|
131
194
|
check('live fight started', !!app.fight);
|
|
132
195
|
show('fight start');
|
|
196
|
+
{
|
|
197
|
+
const art = creatureArt(384, 20, 10); // Hogger
|
|
198
|
+
check('creature art scales to the room it gets', !!art && art.length <= 10 && art.every((l) => strip(l).length === strip(art[0]).length && strip(l).length <= 20));
|
|
199
|
+
const foeDisplay = ds.creature(app.fight?.enemyEntry)?.displayId;
|
|
200
|
+
check('the fight shows the foe beside the frames', !hasArt(foeDisplay) || /[▀▄]/.test(frame()));
|
|
201
|
+
}
|
|
133
202
|
const barName = app.fight?.abilityBar()[0]?.spell.name;
|
|
134
203
|
const hasBar = () => barName && frame().split('\n').some((l) => /^\s*1\s/.test(strip(l)) && strip(l).includes(barName));
|
|
135
|
-
|
|
204
|
+
// The duel stage keeps the ability bar on under auto-play too (its cooldowns are worth watching), with the
|
|
205
|
+
// plates under the sprites' feet: your name, level and class, then the bars; and a floor under it all.
|
|
206
|
+
check('auto-play shows the stage with the ability bar', hasBar() && new RegExp(`^\\s+${app.ch.name} L\\d+ Warrior`, 'm').test(strip(frame())));
|
|
207
|
+
check('the stage stands on a floor', /[▁▂_]{40}/.test(strip(frame())));
|
|
136
208
|
let ticks = 0;
|
|
137
209
|
let paneFrames = null;
|
|
138
210
|
while (app.fight && ticks < 3000) {
|
|
@@ -194,34 +266,106 @@ check('command line ran rest', app.log.some((l) => /You rest for 30 s/.test(stri
|
|
|
194
266
|
|
|
195
267
|
// Autopilot: steps by hand; its fights are live sessions that we tick to the end.
|
|
196
268
|
home(); term.press('1'); term.press('a');
|
|
197
|
-
check('
|
|
269
|
+
check('a opens the pre-flight card', app.top()?.id === 'autopilot-preflight' && /Stops\s+.*deaths in a row/.test(strip(frame())));
|
|
270
|
+
show('autopilot pre-flight');
|
|
271
|
+
app.fightSpeed = 8; // at 4x and up the combat log folds to one line a fight
|
|
272
|
+
const ticksBefore = app.log.filter((l) => /^\s*\d+\.\ds /.test(strip(l))).length;
|
|
273
|
+
term.press('return');
|
|
274
|
+
check('Enter on the card starts the autopilot', !!app.auto && app.stack.length === 0);
|
|
198
275
|
const autoCtl = app.auto;
|
|
199
|
-
let steps = 0, liveFights = 0, fightTicks = 0;
|
|
276
|
+
let steps = 0, liveFights = 0, fightTicks = 0, heldWin = false;
|
|
277
|
+
/** The first win: the fight stays on the stage with the foe at 0 and the drops beside it, the planner waits,
|
|
278
|
+
* and the screen moves on once the hold's ticks are spent. */
|
|
279
|
+
const checkWinHold = () => {
|
|
280
|
+
const f = app.fight, h = f.holding;
|
|
281
|
+
const stageRows = () => { const rows = strip(frame()).split('\n'); return rows.slice(2, rows.findIndex((l) => /^── Log/.test(l))); };
|
|
282
|
+
check('a win holds the stage with the foe dead at 0', f.ended && stageRows().some((l) => / dead\b/.test(l)) && stageRows().some((l) => / 0\/\d+/.test(l)));
|
|
283
|
+
const stepBefore = autoCtl.planner.step; autoCtl.step();
|
|
284
|
+
check('the planner waits out the hold', autoCtl.planner.step === stepBefore && app.fight === f);
|
|
285
|
+
if (h.loot.length) {
|
|
286
|
+
while (app.fight === f && f.holdMs() < h.stagger + 300) f.tick();
|
|
287
|
+
const name = ds.item(h.loot[0].item)?.name;
|
|
288
|
+
check(`what it dropped slides in on the stage (${name})`, app.fight === f && stageRows().some((l) => l.includes(name)));
|
|
289
|
+
show('autopilot win hold');
|
|
290
|
+
}
|
|
291
|
+
let n = 0; while (app.fight === f && n++ < 200) f.tick();
|
|
292
|
+
check(`the hold lets go on its own (${n} ticks)`, !app.fight && n <= h.ticks);
|
|
293
|
+
};
|
|
200
294
|
while (app.auto && steps < 60) {
|
|
201
295
|
if (app.fight) {
|
|
202
|
-
if (!liveFights) {
|
|
203
|
-
|
|
296
|
+
if (!liveFights) {
|
|
297
|
+
show('autopilot fight');
|
|
298
|
+
check('an autopilot fight says what it is for', /(\d+\/\d+|hunting for xp|questing in)/.test(strip(frame()).split('\n')[2] ?? ''));
|
|
299
|
+
check('the run panel shows the foe beside the fight', !hasArt(ds.creature(app.fight.enemyEntry)?.displayId) || /[▀▄]/.test(frame()));
|
|
300
|
+
check('the fight draws inside the run panel, without a head line of its own', app.inlineFight() && app.compactHeader() && / ◀ /.test(strip(frame())) && !/^ Fighting /m.test(strip(frame())));
|
|
301
|
+
check('a run keeps only the XP line for a header', app.compactHeader() && /^ XP /.test(strip(frame())) && !/1 World/.test(strip(frame())));
|
|
302
|
+
check('your plate sits on the stage, once', (strip(frame()).match(new RegExp(`^\\s+${app.ch.name} L\\d+ `, 'mg')) ?? []).length === 1);
|
|
303
|
+
term.press('w'); check('w opens the full fight view', !!app.auto.watchFight && !app.inlineFight() && /^ Fighting /m.test(strip(frame())));
|
|
304
|
+
term.press('escape'); check('Esc comes back to the run panel without running', !!app.fight && !!app.auto && app.inlineFight());
|
|
305
|
+
term.press('3'); check('a tab can be browsed during an autopilot fight, under the run strip', app.auto.browsing && !!app.fight && /Autopilot.*back to the run/.test(strip(frame()).split('\n')[3] ?? ''));
|
|
306
|
+
term.press('w'); check('w returns to the panel with the fight still on', !app.auto.browsing && app.inlineFight());
|
|
307
|
+
}
|
|
308
|
+
liveFights++;
|
|
309
|
+
if (!heldWin) { const f = app.fight; while (app.fight === f && !f.ended) { f.tick(); fightTicks++; } if (f.holding) { heldWin = true; checkWinHold(); } }
|
|
310
|
+
fightTicks += finishFight(); continue;
|
|
204
311
|
}
|
|
205
312
|
if (!app.auto.step()) break;
|
|
206
313
|
steps++;
|
|
207
314
|
}
|
|
315
|
+
check('the autopilot won a fight to hold on', heldWin);
|
|
208
316
|
show('autopilot');
|
|
209
317
|
if (app.auto) {
|
|
210
318
|
if (app.fight) { liveFights++; fightTicks += finishFight(); }
|
|
211
|
-
check('between fights the autopilot keeps its own panel',
|
|
319
|
+
check('between fights the autopilot keeps its own panel', app.compactHeader() && /^ Now\s/m.test(strip(frame())) && strip(frame()).includes(app.ch.name));
|
|
212
320
|
show('autopilot between fights');
|
|
321
|
+
app.fightPane = 'loot'; app.render();
|
|
322
|
+
check('between fights the autopilot keeps the fight pane', /Loot this sitting/.test(strip(frame())));
|
|
323
|
+
term.press('tab'); check('Tab between autopilot fights cycles the pane, not the tabs', app.fightPane === 'run' && !app.auto.browsing);
|
|
324
|
+
check('the Run pane lists the planner steps with the fights folded', /^ Run\s/m.test(strip(frame())) && autoCtl.ledger.some((r) => r.kind === 'fight' && /won|lost|stalemate/.test(strip(r.body))));
|
|
325
|
+
show('autopilot run pane');
|
|
326
|
+
term.press('tab'); check('Tab moves on to the Quests pane', app.fightPane === 'quests');
|
|
327
|
+
term.press('tab'); term.press('tab'); check('Tab wraps back to the log after Bags', app.fightPane === 'log');
|
|
328
|
+
check('the run panel says what is now, what is next and how the quest in hand stands', /^ Now\s/m.test(strip(frame())) && /^ Next\s/m.test(strip(frame())) && /(\d+\/\d+|ready to turn in)/.test(strip(frame()).split('\n')[2] ?? '') && !/stops at/.test(strip(frame())));
|
|
329
|
+
check('the panel is five lines at most between fights', strip(frame()).split('\n').slice(2, 12).filter((l) => l.trim()).length <= 5);
|
|
330
|
+
app.fightPane = 'quests'; app.render();
|
|
331
|
+
check('the Quests pane lists the quests the run works on', /^ Quests\s+next: /m.test(strip(frame())) && app.g.state.questlog.quests.some((qs) => qs.status !== 'turnedIn' && strip(frame()).includes(ds.quest(qs.entry).title)));
|
|
332
|
+
show('autopilot quests pane');
|
|
333
|
+
app.fightPane = 'bags'; app.render();
|
|
334
|
+
check('the Bags pane shows the bags with a cursor', /^ Bags\s/m.test(strip(frame())) && /▶ /.test(strip(frame())));
|
|
335
|
+
const bagCursor = app.bagsPane.cursor; term.press('down');
|
|
336
|
+
check('Down moves the Bags pane cursor', app.bagsPane.cursor > bagCursor && !!app.auto);
|
|
337
|
+
const logBefore = app.log.length; term.press('return');
|
|
338
|
+
check('Enter on a bag row acts while the run plays on', (app.log.length > logBefore || app.stack.length > 0) && !!app.auto);
|
|
339
|
+
home(); show('autopilot bags pane');
|
|
340
|
+
app.fightPane = 'log';
|
|
341
|
+
term.press('space'); const stepBefore = autoCtl.planner.step; autoCtl.step();
|
|
342
|
+
check('space pauses the run between fights', autoCtl.paused && autoCtl.planner.step === stepBefore && /PAUSED/.test(strip(frame())));
|
|
343
|
+
term.press('space'); check('space resumes it', !autoCtl.paused);
|
|
213
344
|
term.press('3'); check('tabs can be viewed while the autopilot runs', app.tab === 2 && !!app.auto && /Bags|bag/i.test(strip(frame())));
|
|
214
|
-
|
|
345
|
+
check('the run strip sits under the tabs while browsing', /Autopilot.*fights.*back to the run/.test(strip(frame()).split('\n')[3] ?? ''));
|
|
346
|
+
show('autopilot run strip');
|
|
347
|
+
// A browsed tab works during the run: Enter acts on the row (here a bag item) instead of jumping back.
|
|
348
|
+
{ const logBefore = app.log.length; app.top().cursor = 1; term.press('return'); check('Enter acts on a row while browsing during the run', !!app.auto && app.auto.browsing && (app.log.length > logBefore || app.stack.length > 0)); home(); }
|
|
349
|
+
term.press('7'); check('the Talents tab opens during the run', app.tab === 6 && app.auto.browsing);
|
|
350
|
+
term.press('escape'); check('Esc on a tab comes back to the run instead of stopping it', !!app.auto && !app.auto.browsing);
|
|
351
|
+
term.press('3');
|
|
352
|
+
term.press('w'); check('w goes back to watching the run', !app.auto.browsing && app.compactHeader() && /^ Now\s/m.test(strip(frame())));
|
|
215
353
|
term.press('l'); check('log window opens over the autopilot', app.top().id === 'log');
|
|
216
354
|
term.press('escape'); check('Esc closes the log and leaves the autopilot running', app.stack.length === 0 && !!app.auto);
|
|
217
|
-
term.press('1');
|
|
355
|
+
term.press('1'); term.press('w'); // back on the panel, where Esc stops the run
|
|
218
356
|
}
|
|
219
357
|
check(`autopilot fought ${liveFights} live fights over ${steps} steps (${fightTicks} ticks)`, liveFights > 0 && app.log.some((l) => /hunting:|quest target:/.test(strip(l))));
|
|
220
358
|
check('autopilot counted its fights', autoCtl.fights === liveFights);
|
|
221
|
-
|
|
359
|
+
check('at 8x the combat log folds to one line a fight', app.log.filter((l) => /^\s*\d+\.\ds /.test(strip(l))).length === ticksBefore && app.log.some((l) => /^Victory|^Defeat/.test(strip(l))));
|
|
360
|
+
if (app.auto) {
|
|
361
|
+
term.press('escape'); check('Esc stops the autopilot', !app.auto);
|
|
362
|
+
check('the end card sums the run up', app.top()?.id === 'autopilot-done' && /Autopilot done/.test(strip(frame())) && /Stopped: you stopped it/.test(strip(frame())));
|
|
363
|
+
show('autopilot end card');
|
|
364
|
+
term.press('return'); check('Enter closes the end card', app.stack.length === 0);
|
|
365
|
+
}
|
|
222
366
|
|
|
223
367
|
// Autopilot fight interrupted by Esc stops the run.
|
|
224
|
-
term.press('a');
|
|
368
|
+
term.press('a'); term.press('return');
|
|
225
369
|
while (app.auto && !app.fight && app.auto.step()) { /* reach a fight */ }
|
|
226
370
|
if (app.fight) { term.press('escape'); check('Esc in an autopilot fight stops the run', !app.fight && !app.auto); }
|
|
227
371
|
else { if (app.auto) app.auto.stop(); check('autopilot reached a fight to interrupt', false); }
|
|
@@ -235,6 +379,13 @@ else { if (app.auto) app.auto.stop(); check('autopilot reached a fight to interr
|
|
|
235
379
|
check('dying on a quest did not stop the run', !lost.done);
|
|
236
380
|
}
|
|
237
381
|
|
|
382
|
+
// The windowed run has no step cap: the Stop after setting ends it, on the run's real clock.
|
|
383
|
+
{
|
|
384
|
+
const timed = app.cmds.createAutopilot('', { maxSteps: Infinity, clock: () => 25 * 3_600_000, liveFight: () => ({ ok: true }) });
|
|
385
|
+
timed.next();
|
|
386
|
+
check('a run stops on the Stop after clock, not a step count', timed.steps === Infinity && timed.stopReason?.reason === 'timeUp');
|
|
387
|
+
}
|
|
388
|
+
|
|
238
389
|
// Do Quest: d on a quest log row runs the autopilot pinned to that quest, and clears its blacklist entry.
|
|
239
390
|
home(); term.press('2');
|
|
240
391
|
const doRow = rowIndex((r) => r.data?.qs?.status === 'active');
|
|
@@ -272,11 +423,38 @@ check('add-rule picker opened', app.top().id === 'pick');
|
|
|
272
423
|
term.press('return');
|
|
273
424
|
check('rule added to combat', app.g.rotation().phases.combat.length === before + 1);
|
|
274
425
|
check('cursor landed on the new rule', app.rowsOf(app.top())[app.top().cursor]?.data?.rule?.id?.startsWith('tui-'));
|
|
426
|
+
{
|
|
427
|
+
const added = app.rowsOf(app.top())[app.top().cursor].data.rule;
|
|
428
|
+
const want = defaultConditionsForSpell(ds.spell(added.action.spellId), app.ch);
|
|
429
|
+
check('new rule starts with the smart default conditions', JSON.stringify(added.conditions) === JSON.stringify(want));
|
|
430
|
+
}
|
|
275
431
|
term.press('return'); term.press('down'); term.press('return'); // menu → change when it fires
|
|
276
|
-
check('condition
|
|
277
|
-
|
|
278
|
-
|
|
432
|
+
check('condition editor opened', app.top().id === 'conds');
|
|
433
|
+
show('condition editor');
|
|
434
|
+
app.top().cursor = rowIndex((r, t) => /\+ Add a condition/.test(t)); term.press('return');
|
|
435
|
+
check('metric catalog opened', app.top().id === 'pick' && /What to check/.test(app.top().title));
|
|
436
|
+
app.top().cursor = rowIndex((r, t) => /living enemies/.test(t)); term.press('return');
|
|
437
|
+
check('condition added from the catalog', app.g.rotation().phases.combat.some((r) => r.id.startsWith('tui-') && r.conditions.some((c) => c.metric === 'enemyCount')));
|
|
438
|
+
check('one condition menu opened', app.top().id === 'cond');
|
|
439
|
+
app.top().cursor = rowIndex((r, t) => /^Value:/.test(t)); term.press('return'); term.type('3');
|
|
440
|
+
check('value changed through the prompt', app.g.rotation().phases.combat.some((r) => r.id.startsWith('tui-') && r.conditions.some((c) => c.metric === 'enemyCount' && c.value === 3)));
|
|
441
|
+
show('one condition');
|
|
442
|
+
app.top().cursor = rowIndex((r, t) => /^Check:/.test(t)); term.press('return'); term.press('/'); term.type('ally');
|
|
443
|
+
check('/ searches the catalog', app.top().id === 'pick' && /matching "ally"/.test(app.top().title));
|
|
444
|
+
app.top().cursor = rowIndex((r, t) => /^\s*health %$/.test(t)); term.press('return');
|
|
445
|
+
app.top().cursor = rowIndex((r, t) => /^Who:/.test(t)); term.press('return');
|
|
446
|
+
app.top().cursor = rowIndex((r, t) => /an ally/.test(t)); term.press('return');
|
|
447
|
+
check('an ally subject shows the ally filter', rowIndex((r, t) => /Which ally/.test(t)) >= 0);
|
|
448
|
+
app.top().cursor = rowIndex((r, t) => /Which ally/.test(t)); term.press('return');
|
|
449
|
+
app.top().cursor = rowIndex((r, t) => /a healer/.test(t)); term.press('return');
|
|
450
|
+
check('ally filter stored on the condition', app.g.rotation().phases.combat.find((r) => r.id.startsWith('tui-')).conditions.some((c) => c.subject === 'ally' && c.ally?.role === 'healer'));
|
|
451
|
+
show('ally condition');
|
|
452
|
+
term.press('escape');
|
|
453
|
+
app.top().cursor = rowIndex((r, t) => /Quick presets/.test(t)); term.press('return');
|
|
454
|
+
app.top().cursor = rowIndex((r, t) => /My health is below/.test(t)); term.press('return'); term.type('50');
|
|
455
|
+
check('preset replaced the conditions', JSON.stringify(app.g.rotation().phases.combat.find((r) => r.id.startsWith('tui-')).conditions) === JSON.stringify([{ metric: 'healthPct', subject: 'self', op: '<', value: 50 }]));
|
|
279
456
|
show('rotation edited');
|
|
457
|
+
term.press('escape'); // leave the condition editor
|
|
280
458
|
term.press('return'); term.press('end'); term.press('return'); // menu → delete
|
|
281
459
|
check('rule deleted through the menu', app.g.rotation().phases.combat.length === before);
|
|
282
460
|
|
|
@@ -385,6 +563,24 @@ check('b in Bags outside a bank town only flashes', app.stack.length === 0);
|
|
|
385
563
|
check('auction dialog opened', app.top().id === 'auction');
|
|
386
564
|
const listings = app.g.browseAuctions(Date.now()).length;
|
|
387
565
|
check(`auction house has bot listings (${listings})`, listings > 0);
|
|
566
|
+
// Categories: Left/Right picks an item class, [ ] a subclass inside it; Left back round to All shows everything.
|
|
567
|
+
const shown = () => app.rowsOf(app.top()).filter((r) => r.data?.auction).map((r) => app.ds.item(r.data.auction.item));
|
|
568
|
+
term.press('right'); show('auction category');
|
|
569
|
+
const inCat = shown(), cls = inCat[0]?.itemClass;
|
|
570
|
+
check(`Right picks a category (${inCat.length} of ${listings})`, inCat.length > 0 && inCat.length < listings && inCat.every((it) => it.itemClass === cls));
|
|
571
|
+
const onStrip = () => app.rowsOf(app.top())[app.top().cursor]?.data?.strip;
|
|
572
|
+
check('the cursor starts on a listing, not a strip', !onStrip());
|
|
573
|
+
term.press('up');
|
|
574
|
+
check(`Up from the first listing lands on a strip (${onStrip()})`, !!onStrip());
|
|
575
|
+
if (onStrip() === 'sub') {
|
|
576
|
+
term.press('right'); show('auction subcategory');
|
|
577
|
+
const inSub = shown(), subOf = (it) => it.itemSubclass ?? it.weaponSubclass ?? it.armorSubclass;
|
|
578
|
+
check(`Right on the subcategory strip picks one (${inSub.length} of ${inCat.length})`, inSub.length > 0 && inSub.length <= inCat.length && inSub.every((it) => it.itemClass === cls && subOf(it) === subOf(inSub[0])));
|
|
579
|
+
check('the cursor stays on the strip', onStrip() === 'sub');
|
|
580
|
+
term.press('down');
|
|
581
|
+
check('Down from the strip goes to the listings', !onStrip() && !!app.rowsOf(app.top())[app.top().cursor]?.data?.auction);
|
|
582
|
+
} else term.press('down');
|
|
583
|
+
term.press('left'); check('Left back to All lists every listing', shown().length === listings);
|
|
388
584
|
term.press('s'); term.type('cloth'); show('auction search');
|
|
389
585
|
check('search filters the boards', /"cloth"/.test(strip(frame())));
|
|
390
586
|
term.press('tab'); show('auction sell');
|
|
@@ -463,7 +659,9 @@ check('b in Bags outside a bank town only flashes', app.stack.length === 0);
|
|
|
463
659
|
term.press('return'); show('candidate page');
|
|
464
660
|
check('Enter opens the candidate page', app.top().id.startsWith('cand-'));
|
|
465
661
|
check('the candidate page lists professions and stats', app.rowsOf(app.top()).some((r) => r.header && strip(r.text) === 'Professions') && rowIndex((r, t) => /^Health \d+/.test(t)) >= 0);
|
|
466
|
-
app.top().cursor = rowIndex((r, t) => /^Hire as/.test(t));
|
|
662
|
+
app.top().cursor = rowIndex((r, t) => /^Hire as/.test(t)); app.render();
|
|
663
|
+
check('the hire row draws the candidate in their gear beside the cost', !hasArt(nativeDisplayId(human.id, 0)) || /[▀▄]/.test(frame()));
|
|
664
|
+
term.press('return'); show('after hire');
|
|
467
665
|
check('hiring returns to the hire list, not the tab', app.top()?.id === 'hire');
|
|
468
666
|
term.press('escape'); check('Esc closes the hire list', app.stack.length === 0);
|
|
469
667
|
const comp = (g.state.squad ?? [])[0];
|
|
@@ -472,6 +670,7 @@ check('b in Bags outside a bank town only flashes', app.stack.length === 0);
|
|
|
472
670
|
const row = rowIndex((r, t) => t.startsWith(comp.name));
|
|
473
671
|
app.top().cursor = row; term.press('return'); show('companion page');
|
|
474
672
|
check('Enter opens their page', app.top().id === `comp-${comp.id}`);
|
|
673
|
+
check('their page draws them beside the Role note', !hasArt(nativeDisplayId(comp.race, comp.gender)) || /[▀▄]/.test(frame()));
|
|
475
674
|
// Make room for Tailoring: drop one primary when they hold two.
|
|
476
675
|
const lines = g.companionProfessionsOf(comp.id)?.lines ?? [];
|
|
477
676
|
const primaries = lines.filter((l) => l.kind !== 'secondary');
|
|
@@ -585,9 +784,31 @@ check('b in Bags outside a bank town only flashes', app.stack.length === 0);
|
|
|
585
784
|
check('a solo pull brings nobody', !!app.fight && app.fight.session.alliesView().length === 0);
|
|
586
785
|
finishFight();
|
|
587
786
|
term.press(':'); term.type('squad party');
|
|
588
|
-
const
|
|
589
|
-
term.press(':'); term.type(`fight ${
|
|
590
|
-
|
|
787
|
+
const quickEntry = app.rowsOf(app.top())[mobAt].data.creature.entry;
|
|
788
|
+
term.press(':'); term.type(`fight ${quickEntry}`);
|
|
789
|
+
// The log is capped at 2000 lines (say()), so read from the command's echo, not from a length taken before it.
|
|
790
|
+
const quickLog = app.log.map(strip);
|
|
791
|
+
const quickAt = quickLog.lastIndexOf(`> fight ${quickEntry}`);
|
|
792
|
+
check(':fight brings the party in Party mode', quickAt >= 0 && quickLog.slice(quickAt + 1).some((l) => squad.some((c) => l.includes(c.name))));
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
// ---- gear: a BoE a companion carries (a crafter's product) goes on from the Equipment tab, no trade first ----
|
|
796
|
+
{
|
|
797
|
+
const g = app.g, comp = g.state.squad[0];
|
|
798
|
+
const cloak = ds.items().find((i) => i.slot === 'back' && i.bonding === 2 && (i.requiredLevel ?? 1) <= app.ch.level && !i.allowClass?.length && i.entry > 100);
|
|
799
|
+
check('a BoE cloak exists to hand a companion', !!cloak && !!comp);
|
|
800
|
+
if (cloak && comp) {
|
|
801
|
+
comp.bags = addItem(comp.bags ?? { stacks: [] }, cloak.entry, 1, 1);
|
|
802
|
+
home(); app.switchTab(app.tabs.findIndex((t) => t.view.id === 'gear'));
|
|
803
|
+
app.top().cursor = rowIndex((r) => r.data?.slot === 'back' && !r.data?.upgrade); term.press('return'); show('back slot picker with companion bags');
|
|
804
|
+
const row = rowIndex((r, t) => t.includes(cloak.name) && t.includes(comp.name));
|
|
805
|
+
check("the slot picker lists the cloak from the companion's bags", row > 0);
|
|
806
|
+
if (row > 0) {
|
|
807
|
+
app.top().cursor = row; term.press('return');
|
|
808
|
+
const back = app.g.state.char.equipped.back;
|
|
809
|
+
check("Enter takes the companion's cloak and wears it", (back?.item ?? back) === cloak.entry && countItem(comp.bags, cloak.entry) === 0);
|
|
810
|
+
}
|
|
811
|
+
}
|
|
591
812
|
}
|
|
592
813
|
|
|
593
814
|
// ---- dungeons: the Deadmines as a text run ------------------------------------------------------------------
|
|
@@ -614,6 +835,28 @@ check('b in Bags outside a bank town only flashes', app.stack.length === 0);
|
|
|
614
835
|
for (let n = 1; n <= 2; n++) {
|
|
615
836
|
app.top().cursor = rowIndex((r, t) => /◀ next/.test(t)); term.press('return');
|
|
616
837
|
check(`stop ${n} starts a live fight with the party`, !!app.fight?.withSquad && app.fight.session.alliesView().length >= 4);
|
|
838
|
+
if (n === 1 && app.fight) {
|
|
839
|
+
// The party stage: every body on one floor, the party's plates on one row (role letter, name) left of the
|
|
840
|
+
// pack's, an HP percent under each, the foe you face marked, and a cast bar under a caster while it casts.
|
|
841
|
+
for (let k = 0; k < 8 && app.fight; k++) app.fight.tick();
|
|
842
|
+
show('party stage');
|
|
843
|
+
const rows = strip(frame()).split('\n');
|
|
844
|
+
const plateRow = rows.findIndex((l) => /\bT \S/.test(l) && /\bH \S/.test(l) && l.includes(app.ch.name));
|
|
845
|
+
check('the party stage puts the tank, the healer and you on one plate row', plateRow > 0);
|
|
846
|
+
const pct = (rows[plateRow + 1] ?? '').match(/\d+%/g) ?? [];
|
|
847
|
+
const bodies = 1 + app.fight.session.alliesView().length + 1; // you, the party, at least the foe you face
|
|
848
|
+
check(`an HP percent sits under every sprite (${pct.length} of ${bodies}+)`, pct.length >= bodies);
|
|
849
|
+
check('the pack stands right of the party with the faced foe marked', plateRow > 0 && rows[plateRow].indexOf('◀') > rows[plateRow].lastIndexOf(app.ch.name));
|
|
850
|
+
check('the party stage stands on a floor', /[▁▂_]{40}/.test(strip(frame())));
|
|
851
|
+
let cast = null;
|
|
852
|
+
for (let k = 0; k < 400 && app.fight && !cast; k++) {
|
|
853
|
+
app.fight.tick();
|
|
854
|
+
const s = app.fight.session;
|
|
855
|
+
cast = [...s.alliesView(), ...s.enemiesView()].find((v) => v.hp > 0 && v.casting && v.casting.endsAt - s.t >= 300) ?? null;
|
|
856
|
+
}
|
|
857
|
+
check('a cast shows as a bar under the caster', !cast || strip(frame()).includes(cast.casting.name.slice(0, 8)));
|
|
858
|
+
if (cast) show('party stage cast');
|
|
859
|
+
}
|
|
617
860
|
finishFight();
|
|
618
861
|
check(`stop ${n} is cleared and the run window comes back`, g.state.instance.defeated.length === n && app.top()?.id === 'dungeon');
|
|
619
862
|
}
|
|
@@ -1073,6 +1316,8 @@ check('b in Bags outside a bank town only flashes', app.stack.length === 0);
|
|
|
1073
1316
|
check('a global bar slot takes a bag item', (g.state.globalActionBar?.items ?? []).includes(potion.entry));
|
|
1074
1317
|
g.state.char.health = Math.max(1, Math.floor(g.state.char.health / 2));
|
|
1075
1318
|
const potions = countItem(g.state.inventory, potion.entry);
|
|
1319
|
+
// The kobold fight above may have drunk one, and the potion cooldown runs on the wall clock now.
|
|
1320
|
+
g.state.spellCooldowns = {}; g.state.categoryCooldowns = {};
|
|
1076
1321
|
app.top().cursor = rowIndex((r, t) => /Fire the whole global bar/.test(t)); term.press('return');
|
|
1077
1322
|
check('firing the global bar drinks the potion', countItem(g.state.inventory, potion.entry) === potions - 1);
|
|
1078
1323
|
app.top().cursor = rowIndex((r, t) => t.includes(potion.name) && !('b' in (r.data ?? {}))); term.press('return');
|
|
@@ -1654,9 +1899,49 @@ check('b in Bags outside a bank town only flashes', app.stack.length === 0);
|
|
|
1654
1899
|
const app9 = new App({ term: term9, ds, repo });
|
|
1655
1900
|
term9.open();
|
|
1656
1901
|
const { characterCreation } = await import('./tui.mjs');
|
|
1657
|
-
let created = null;
|
|
1658
|
-
characterCreation(app9, (state) => { created = state; });
|
|
1659
|
-
|
|
1902
|
+
let created = null, cancelled = 0;
|
|
1903
|
+
characterCreation(app9, (state) => { created = state; }, { onCancel: () => { cancelled++; } });
|
|
1904
|
+
const row9 = (id) => app9.rowsOf(app9.top()).findIndex((r) => r.id === id);
|
|
1905
|
+
const text9 = () => term9.text();
|
|
1906
|
+
check('the create screen opens on the faction row with the hero drawn', app9.top().id === 'create' && app9.top().cursor === row9('faction') && row9('faction') < row9('race') && /Faction\s+Alliance/.test(text9()) && /Unnamed\s+Male Human Warrior/.test(text9()) && /Look/.test(text9()) && /New character/.test(text9()));
|
|
1907
|
+
app9.top().cursor = row9('race'); term9.press('right');
|
|
1908
|
+
check('Right on Race steps to the next race of the faction', /Race\s+Dwarf/.test(text9()));
|
|
1909
|
+
app9.top().cursor = row9('faction'); term9.press('right');
|
|
1910
|
+
check('Right on Faction switches to its first race', /Faction\s+Horde/.test(text9()) && /Race\s+Orc/.test(text9()) && /Orc · Horde/.test(text9()));
|
|
1911
|
+
term9.press('left');
|
|
1912
|
+
check('flipping back returns to the race last chosen there', /Faction\s+Alliance/.test(text9()) && /Race\s+Dwarf/.test(text9()));
|
|
1913
|
+
term9.press('right');
|
|
1914
|
+
app9.top().cursor = row9('race'); term9.press('left');
|
|
1915
|
+
check('Left on Race wraps to the faction\'s last race', /Race\s+Troll/.test(text9()));
|
|
1916
|
+
// A troll cannot be a paladin or a druid: the class row only offers what the race may roll.
|
|
1917
|
+
app9.top().cursor = row9('class');
|
|
1918
|
+
const classes9 = new Set();
|
|
1919
|
+
for (let i = 0; i < 12; i++) { term9.press('right'); classes9.add(text9().match(/Class\s+(\w+)/)[1]); }
|
|
1920
|
+
check('Class cycles through the troll classes only', classes9.has('Hunter') && classes9.has('Shaman') && !classes9.has('Paladin') && !classes9.has('Druid'));
|
|
1921
|
+
term9.press('return');
|
|
1922
|
+
check('Enter on Class opens the class list over the screen', app9.top().title === 'Class' && app9.stack.length === 2 && /Hunter/.test(text9()));
|
|
1923
|
+
term9.press('escape');
|
|
1924
|
+
check('Esc closes the list before a game is open', app9.top().id === 'create' && app9.stack.length === 1);
|
|
1925
|
+
app9.top().cursor = row9('gender'); term9.press('right');
|
|
1926
|
+
check('Right on Gender flips it', /Gender\s+Female/.test(text9()) && /Female Troll/.test(text9()));
|
|
1927
|
+
app9.top().cursor = row9('skin');
|
|
1928
|
+
const skin9 = text9().match(/Skin Color\s+(\d+) of/)[1]; term9.press('right');
|
|
1929
|
+
check('Right on a look row changes it', text9().match(/Skin Color\s+(\d+) of/)[1] !== skin9);
|
|
1930
|
+
app9.top().cursor = row9('create'); term9.press('return');
|
|
1931
|
+
check('Create without a name asks for one and moves to the Name row', !created && app9.top().cursor === row9('name') && /Give them a name/.test(text9()));
|
|
1932
|
+
term9.press('n');
|
|
1933
|
+
check('n rolls a random name', /Name\s+[A-Z][a-z]+/.test(text9()));
|
|
1934
|
+
app9.top().cursor = row9('name'); term9.press('return'); term9.type('x1');
|
|
1935
|
+
check('a bad name is refused', !!app9.cmd && /Letters only/.test(text9()));
|
|
1936
|
+
term9.type('thrallina');
|
|
1937
|
+
check('typing a name lands on the Create row', !app9.cmd && app9.top().cursor === row9('create') && /Create Thrallina, Female Troll/.test(text9()));
|
|
1938
|
+
term9.press('return');
|
|
1939
|
+
check('Enter on Create makes the character as chosen', created?.char?.name === 'Thrallina' && created.char.race === 8 && created.char.gender === 1 && created.char.level === 1 && created.char.appearance != null);
|
|
1940
|
+
created = null; app9.stack = [];
|
|
1941
|
+
characterCreation(app9, (state) => { created = state; }, { onCancel: () => { cancelled++; } });
|
|
1942
|
+
term9.press('escape');
|
|
1943
|
+
check('Esc on the create screen goes back to the character list', cancelled === 1);
|
|
1944
|
+
app9.top().cursor = row9('random'); term9.press('return');
|
|
1660
1945
|
check('the create screen rolls a random world bot', !!created?.char?.name && created.char.level >= 1);
|
|
1661
1946
|
term9.close?.();
|
|
1662
1947
|
home();
|
|
@@ -1794,15 +2079,42 @@ check('b in Bags outside a bank town only flashes', app.stack.length === 0);
|
|
|
1794
2079
|
app12.top().cursor = at((r, t) => /Warsong Gulch/.test(t)); term12.press('return');
|
|
1795
2080
|
if (showFrames) console.log(`\n=== warsong gulch ===\n${term12.text()}`);
|
|
1796
2081
|
check('the honor window starts Warsong Gulch', app12.top()?.id === 'bg' && !!g12.state.battleground && /flag/.test(strip(term12.text())));
|
|
1797
|
-
app12.top().cursor = at((r, t) => /
|
|
2082
|
+
app12.top().cursor = at((r, t) => /Autopilot:/.test(t)); term12.press('return'); // the orders picker
|
|
2083
|
+
app12.top().cursor = at((r, t) => /Autopilot:/.test(t)); term12.press('return'); // its job option
|
|
1798
2084
|
app12.top().cursor = at((r, t) => /guard our flag/.test(t)); term12.press('return');
|
|
1799
2085
|
check('you can change your job', app12.bg.world.units.get(app12.bg.selfId).role === 'defense');
|
|
1800
2086
|
const bg = app12.bg;
|
|
2087
|
+
check('the gulch is drawn as a strip with you on it', /Horde.*mid.*Alliance/.test(strip(term12.text())) && /@/.test(strip(term12.text())));
|
|
2088
|
+
// Your own seat by place: an order takes you off autopilot, and once the gates open you walk toward mid.
|
|
2089
|
+
const { WSG_GEOMETRY } = await import('../domain/bg/wsg.ts');
|
|
2090
|
+
const me12 = bg.world.units.get(bg.selfId);
|
|
2091
|
+
const toMid = () => Math.hypot(me12.pos.x - WSG_GEOMETRY.mid.x, me12.pos.y - WSG_GEOMETRY.mid.y);
|
|
2092
|
+
const d0 = toMid();
|
|
2093
|
+
term12.press('o');
|
|
2094
|
+
app12.top().cursor = at((r, t) => /Go to mid/.test(t)); term12.press('return');
|
|
2095
|
+
check('an order by place takes your seat off autopilot', me12.human === true && me12.order.kind === 'attack-move');
|
|
2096
|
+
stepBg(app12, bg, 300);
|
|
2097
|
+
check('and you walk there yourself', toMid() < d0 - 50 && me12.order.kind === 'attack-move');
|
|
2098
|
+
// A team call holds your side against the strategy layer for a while, then hands them back.
|
|
2099
|
+
term12.press('t');
|
|
2100
|
+
app12.top().cursor = at((r, t) => /Everyone to mid/.test(t)); term12.press('return');
|
|
2101
|
+
const called = [...bg.commanded.keys()];
|
|
2102
|
+
stepBg(app12, bg, 25); // two strategy cycles
|
|
2103
|
+
const stillOnIt = called.filter((id) => { const u = bg.world.units.get(id); return u.order.kind === 'attack-move' && Math.abs(u.order.to.x - WSG_GEOMETRY.mid.x) < 1; });
|
|
2104
|
+
check('a team call holds your side past the strategy cycle', called.length >= 5 && stillOnIt.length === called.filter((id) => bg.world.alive(bg.world.units.get(id))).length);
|
|
2105
|
+
stepBg(app12, bg, 500);
|
|
2106
|
+
check('and lapses after its time', bg.commanded.size === 0);
|
|
2107
|
+
bg.speed = 8;
|
|
2108
|
+
term12.press('j');
|
|
2109
|
+
app12.top().cursor = at((r, t) => /guard our flag/.test(t)); term12.press('return');
|
|
2110
|
+
check('autopilot takes your seat back', me12.human === false && bg.my == null);
|
|
1801
2111
|
const marks0 = g12.state.inventory.stacks.length;
|
|
1802
2112
|
let n = 0;
|
|
1803
2113
|
while (!bg.world.ended && n < 40000) { stepBg(app12, bg, 200); n += 200; }
|
|
1804
2114
|
app12.render();
|
|
1805
2115
|
check('the match plays to an end', bg.world.ended && bg.settled);
|
|
2116
|
+
check('inside its 25 minute clock, with a winner', bg.world.t <= 25 * 60_000 + 10_000 && bg.world.winner != null);
|
|
2117
|
+
check('both sides scored kills and the header counts them', bg.teamKills.alliance > 0 && bg.teamKills.horde > 0 && /kills [0-9]+-[0-9]+/.test(strip(term12.text())));
|
|
1806
2118
|
check('honor and marks of honor settle', bg.marks > 0 && g12.state.inventory.stacks.length > marks0 && (g12.state.char.honor?.cp ?? 0) > 0);
|
|
1807
2119
|
check('and you are out of the battleground', !g12.state.battleground);
|
|
1808
2120
|
app12.top().cursor = at((r, t) => /Leave the battleground/.test(t)); term12.press('return');
|
|
@@ -1904,9 +2216,16 @@ check('b in Bags outside a bank town only flashes', app.stack.length === 0);
|
|
|
1904
2216
|
// World search: s on the World tab, a vendor item lists who sells it and where, a node its zones, a dungeon its door.
|
|
1905
2217
|
{
|
|
1906
2218
|
const txt = () => app.rowsOf(app.top()).map((r) => strip(r.text)).join('|');
|
|
1907
|
-
const
|
|
2219
|
+
const find = (key, q) => { home(); term.press('1'); term.press(key); for (const ch of q) term.press(ch === ' ' ? 'space' : ch); term.press('return'); };
|
|
2220
|
+
const search = (q) => find('S', q);
|
|
2221
|
+
find('s', 'hogger'); show('zone search');
|
|
2222
|
+
check('s searches the zone you stand in', app.top().id === 'world-search' && /Search Elwynn Forest/.test(strip(frame())) && /Hogger L11.*here/.test(txt()));
|
|
2223
|
+
find('s', 'deadmines');
|
|
2224
|
+
check('the zone search leaves out what is elsewhere', /Nothing by that name in Elwynn Forest/.test(txt()));
|
|
2225
|
+
term.press('a');
|
|
2226
|
+
check('a widens it to the whole world', /Deadmines.*Dungeon.*entrance in Westfall/.test(txt()));
|
|
1908
2227
|
search('mining pick'); show('world search');
|
|
1909
|
-
check('
|
|
2228
|
+
check('S opens the world search', app.top().id === 'world-search' && /Mining Pick/.test(txt()));
|
|
1910
2229
|
check('an item lists the vendors that sell it and where they stand', /sold by .*<.*>.*(here|Stormwind|Westfall|Loch Modan|Redridge)/.test(txt()));
|
|
1911
2230
|
search('copper vein'); show('world search node');
|
|
1912
2231
|
check('a gather node lists the zones it grows in', /Copper Vein.*Mining.*(here|Dun Morogh|Durotar|Mulgore|Westfall|Loch Modan)/.test(txt()));
|
|
@@ -1928,6 +2247,22 @@ check('b in Bags outside a bank town only flashes', app.stack.length === 0);
|
|
|
1928
2247
|
term.press('?'); check('help overlay', app.top().id === 'help'); term.press('escape');
|
|
1929
2248
|
check('help closed', app.stack.length === 0);
|
|
1930
2249
|
|
|
2250
|
+
// Sixel art, last: once on it stays on for the process. The terminal says it draws sixel (DA1 parameter 4);
|
|
2251
|
+
// art then travels as placeholders whose markers the terminal layer takes out and places.
|
|
2252
|
+
{
|
|
2253
|
+
noteDeviceAttributes('?62;4;22');
|
|
2254
|
+
const art = creatureArt(2332, 28, 14);
|
|
2255
|
+
const mark = art?.[0]?.match(/\x1b\[9999;(\d+);0m/);
|
|
2256
|
+
check('sixel on: a creature is a placeholder of marked blank rows', !!mark && art.every((l, r) => l.includes(`\x1b[9999;${mark[1]};${r}m`)) && /^\x1bP0;1;0q"1;1;\d+;\d+#/.test(imageOf(+mark[1])?.sixel ?? ''));
|
|
2257
|
+
const placed = new Map();
|
|
2258
|
+
const rows = art.map((l, r) => takeMarks(`ab${l}${l}`, 5 + r, placed));
|
|
2259
|
+
check('the same picture twice on a row is two placements, at their columns, markers gone', placed.size === 2 && [...placed.values()].every((p) => p.y === 5 && p.bottom) && [...placed.values()].map((p) => p.x).join() === `2,${2 + artWidth(art)}` && rows.every((l) => !l.includes('\x1b[9999')));
|
|
2260
|
+
check('a marker rides one cell in a grid, not every cell after it', splitCells(art[0]).filter((c) => c.includes('\x1b[9999')).length === 1);
|
|
2261
|
+
check('a wrapped line does not carry a marker to the next', wrapLines(`${art[0]} ${'word '.repeat(20)}`, 40).slice(1).every((l) => !l.includes('\x1b[9999')));
|
|
2262
|
+
const icon = iconCells('spell_holy_holysmite', 6, '', [{ x: 4, y: 2, w: 2 }]);
|
|
2263
|
+
check('an icon is a placeholder too, with its badge corner see-through', !!icon && icon.length === 3 && icon[0][0].includes('\x1b[9999') && /#\d+;2;/.test(imageOf(+icon[0][0].match(/\x1b\[9999;(\d+);/)[1])?.sixel ?? ''));
|
|
2264
|
+
}
|
|
2265
|
+
|
|
1931
2266
|
check('frames were drawn', term.frames > 20);
|
|
1932
2267
|
check('saves happened', repo.saved > 0);
|
|
1933
2268
|
console.log(failures ? `\n${failures} check(s) failed` : '\nall TUI checks passed');
|