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/tuiCheck.mjs
CHANGED
|
@@ -2,14 +2,28 @@
|
|
|
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 {
|
|
12
|
+
import { newGameStateFromWorldBot } from '../engine/game/newGameState.ts';
|
|
13
|
+
import { maxPower } from '../domain/character/character.ts';
|
|
14
|
+
import { startFight } from './tuiFight.mjs';
|
|
15
|
+
import { strip, vis, wrapLines, coinText } from './text.mjs';
|
|
10
16
|
import { visibleBars } from './tuiBars.mjs';
|
|
17
|
+
import { defaultConditionsForSpell } from './tuiRotationCond.mjs';
|
|
18
|
+
import { creatureArt, hasArt, artWidth } from './modelArt.mjs';
|
|
19
|
+
import { nativeDisplayId } from '../domain/character/displayForm.ts';
|
|
20
|
+
import { paperdollLayers, paperdollArt } from './paperdoll.mjs';
|
|
21
|
+
import { playerArt, questDialog } from './tuiViews.mjs';
|
|
22
|
+
import { worldMapLines } from './tuiMap.mjs';
|
|
23
|
+
import { iconsOn, withIcon, wrapIconLines } from './iconArt.mjs';
|
|
11
24
|
import { addItem, countItem } from '../domain/items/inventory.ts';
|
|
12
|
-
import {
|
|
25
|
+
import { offeredQuests } from '../quests/quests.ts';
|
|
26
|
+
import { isTalkToNpcQuest } from '../domain/quests/questlog.ts';
|
|
13
27
|
|
|
14
28
|
const showFrames = process.argv.includes('--frames');
|
|
15
29
|
let failures = 0;
|
|
@@ -40,12 +54,20 @@ const frame = () => term.text();
|
|
|
40
54
|
const home = () => { let n = 0; while (app.stack.length && n++ < 10) term.press('escape'); };
|
|
41
55
|
const show = (label) => { if (showFrames) console.log(`\n=== ${label} ===\n${frame()}`); };
|
|
42
56
|
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. */
|
|
57
|
+
/** Run a live fight to its end by hand, through the hold a win keeps on screen after it. */
|
|
44
58
|
const finishFight = () => { let n = 0; while (app.fight && n < 3000) { app.fight.tick(); n++; } return n; };
|
|
45
59
|
|
|
46
60
|
show('world');
|
|
47
61
|
check('header shows the character', frame().includes('Tuitest') && frame().includes('Elwynn Forest'));
|
|
48
62
|
check('world lists creatures', rowIndex((r, s) => r.data?.creature && /Kobold/.test(s)) >= 0);
|
|
63
|
+
{
|
|
64
|
+
// The People rows draw their NPC: the art pack bakes every vendor, trainer and innkeeper a zone lists.
|
|
65
|
+
const vendor = rowIndex((r, s) => /<General Supplies>/.test(s));
|
|
66
|
+
const saved = app.top().cursor;
|
|
67
|
+
app.top().cursor = vendor; app.render();
|
|
68
|
+
check('a vendor row draws the merchant beside their title', vendor >= 0 && /[▀▄]/.test(frame()));
|
|
69
|
+
app.top().cursor = saved; app.render();
|
|
70
|
+
}
|
|
49
71
|
{
|
|
50
72
|
const rs = app.rowsOf(app.top()).filter((r) => r.header).map((r) => strip(r.text));
|
|
51
73
|
check('world lists quests last, after creatures', rs.indexOf('Quests') > rs.findIndex((s) => s.startsWith('Creatures')));
|
|
@@ -53,15 +75,81 @@ check('world lists creatures', rowIndex((r, s) => r.data?.creature && /Kobold/.t
|
|
|
53
75
|
|
|
54
76
|
// Tabs 2..9 and 0 all render without throwing; Left/Right and Esc move between them.
|
|
55
77
|
for (const k of ['2', '3', '4', '5', '6', '7', '8', '9', '0']) { term.press(k); show(`tab ${k}`); }
|
|
56
|
-
|
|
78
|
+
// Rules are two lines each with their icons, so the Combat phase can start past the first window.
|
|
79
|
+
check('rotation tab shows rules', (term.press('6'), /Combat \(checked/.test(frame()) || (term.press('pagedown'), /Combat \(checked/.test(frame()))));
|
|
57
80
|
check('talents tab renders', (term.press('7'), /Talents unlock|point/.test(frame())));
|
|
58
81
|
check('0 opens the tenth tab', (term.press('0'), app.tab === 9));
|
|
59
82
|
const lastTab = app.tabs.length - 1;
|
|
60
|
-
check('
|
|
83
|
+
check('the last tab is Settings', (app.switchTab(lastTab), app.top().id === 'settings' && /Auto-loot/.test(frame())));
|
|
84
|
+
check('the character tab heads with your name and lists your gear', (app.switchTab(3), frame().includes(app.ch.name) && /Chest/.test(strip(frame()))));
|
|
85
|
+
check('s on the character tab opens your stats', (term.press('s'), app.top().id === 'char' && /Attributes/.test(frame())));
|
|
86
|
+
check('the stats page draws your body', !hasArt(nativeDisplayId(app.ch.race, app.ch.gender)) || /[▀▄]/.test(frame()));
|
|
87
|
+
{
|
|
88
|
+
// Starting gear is a shirt, pants, boots and a weapon or two: at least three of them have an appearance overlay.
|
|
89
|
+
const p = paperdollLayers(app.ch);
|
|
90
|
+
check('your starting gear stacks on your body', !p || (p.layers.filter((l) => /^\d/.test(l.key)).length >= 3 && !!paperdollArt(app.ch, 20, 12)));
|
|
91
|
+
}
|
|
92
|
+
{
|
|
93
|
+
// A form or a costume is the model you are drawn as: each one's model is in the pack, and a fight's live
|
|
94
|
+
// view (the form, a Polymorph debuff) beats the saved character. Cite: displayForm.ts wornDisplay.
|
|
95
|
+
const drawn = (view) => playerArt(app, 20, 12, view)?.lines.join('\n');
|
|
96
|
+
const as = (displayId, height = 1) => creatureArt(displayId, 20, Math.round(12 * height))?.join('\n');
|
|
97
|
+
const view = (o) => ({ buffs: [], debuffs: [], ...o });
|
|
98
|
+
const flipOut = app.ds.creature(app.ds.spell(8219)?.effects.find((e) => e.kind === 'transform')?.creatureEntry)?.displayId;
|
|
99
|
+
const saved = app.ch.activeAuras;
|
|
100
|
+
app.ch.activeAuras = [...(saved ?? []), { spellId: 8219, expiresAt: Date.now() + 60_000 }];
|
|
101
|
+
const costumed = drawn(null) === as(flipOut);
|
|
102
|
+
app.ch.activeAuras = saved;
|
|
103
|
+
check('every form and costume model is in the art pack', [892, 8571, 2281, 2289, 632, 2428, 4613, 15374, 15375, 864, 857, flipOut].every((id) => hasArt(id)));
|
|
104
|
+
check('cat form in a fight draws you as the cat', drawn(view({ form: 1 })) === as(892, 0.6));
|
|
105
|
+
check('the cat stands lower than you do on two legs', playerArt(app, 20, 12, view({ form: 1 })).lines.length < playerArt(app, 20, 12, view({ form: 0 })).lines.length);
|
|
106
|
+
check('a Polymorph on you draws the sheep over your form, critter-sized', drawn(view({ form: 1, debuffs: [{ spellId: 118 }] })) === as(857, 0.35));
|
|
107
|
+
check('a costume aura draws its model', costumed);
|
|
108
|
+
check('out of form you are in your gear again', drawn(view({ form: 0 })) === paperdollArt(app.ch, 20, 12)?.join('\n'));
|
|
109
|
+
}
|
|
110
|
+
if (iconsOn()) {
|
|
111
|
+
// A big window: the Gear tab is the character sheet and the Talents tab the three trees, both icon grids
|
|
112
|
+
// the arrows walk in two dimensions. At the usual 110x34 both list instead (the checks above).
|
|
113
|
+
const [cols, rows] = [term.cols, term.rows];
|
|
114
|
+
term.cols = 160; term.rows = 54;
|
|
115
|
+
const slot = () => app.rowsOf(app.top())[app.top().cursor]?.cell?.slot;
|
|
116
|
+
const rowIndexOfSlot = (s) => app.rowsOf(app.top()).findIndex((r) => r.cell?.slot === s);
|
|
117
|
+
app.switchTab(3);
|
|
118
|
+
check('a big window draws the Character tab as the sheet', !!app.top().gridNav && frame().includes(app.ch.name) && /Head/.test(frame()));
|
|
119
|
+
check('the sheet keeps your stats under the tooltip', /Strength/.test(frame()) && /Attack pwr/.test(frame()));
|
|
120
|
+
app.top().cursor = 0; app.render();
|
|
121
|
+
term.press('shift-right');
|
|
122
|
+
const across = slot();
|
|
123
|
+
term.press('shift-left', 'shift-down', 'shift-down', 'shift-down', 'shift-down', 'shift-down', 'shift-down', 'shift-down');
|
|
124
|
+
const below = slot();
|
|
125
|
+
term.press('down');
|
|
126
|
+
check('Shift+arrows walk the sheet: head across to hands, down the left to the main hand', across === 'hands' && below === 'mainhand' && slot() === 'offhand');
|
|
127
|
+
term.press('right');
|
|
128
|
+
check('Right still changes the tab on the sheet', app.tab === 4);
|
|
129
|
+
app.switchTab(3);
|
|
130
|
+
app.top().cursor = rowIndexOfSlot('mainhand');
|
|
131
|
+
term.press('return');
|
|
132
|
+
check('Enter on a sheet slot opens its picker', app.top().id === 'slot-mainhand');
|
|
133
|
+
home();
|
|
134
|
+
app.switchTab(6);
|
|
135
|
+
const names = app.ds.talentTrees(app.ch.cls).map((t) => t.name);
|
|
136
|
+
check('the Talents tab draws every tree side by side', !!app.top().gridNav && names.every((n) => frame().includes(n)));
|
|
137
|
+
app.top().cursor = 0; app.render();
|
|
138
|
+
const first = app.rowsOf(app.top())[0].node;
|
|
139
|
+
term.press('shift-right');
|
|
140
|
+
const next = app.rowsOf(app.top())[app.top().cursor].node;
|
|
141
|
+
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));
|
|
142
|
+
term.cols = cols; term.rows = rows;
|
|
143
|
+
app.switchTab(3); term.press('s');
|
|
144
|
+
// A tooltip's long sentence wraps beside its icon while they share rows, not back under the art.
|
|
145
|
+
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);
|
|
146
|
+
check('tooltip text wraps beside its icon', tip.slice(0, 4).every((l) => l.startsWith('\x1b[38;2;')) && tip.length > 4);
|
|
147
|
+
}
|
|
61
148
|
{
|
|
62
149
|
const charRows = app.rowsOf(app.top()).map((r) => strip(r.text)).join('|');
|
|
63
|
-
check('the
|
|
150
|
+
check('the stats page 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));
|
|
64
151
|
}
|
|
152
|
+
home(); app.switchTab(lastTab);
|
|
65
153
|
term.press('right'); check('Right wraps to the first tab', app.tab === 0);
|
|
66
154
|
term.press('left'); check('Left wraps to the last tab', app.tab === lastTab);
|
|
67
155
|
term.press('escape'); check('Esc on a tab returns to World', app.tab === 0);
|
|
@@ -88,6 +176,8 @@ if (firstQuest > 0) {
|
|
|
88
176
|
check('Enter on Accept closed the dialog', app.stack.length === 0);
|
|
89
177
|
term.press('2'); show('quest log');
|
|
90
178
|
check('quest log lists it', /1 of 20/.test(frame()));
|
|
179
|
+
const zoneOffers = offeredQuests(app.ds, app.ch.zoneId, app.ch, app.g.state.questlog).filter((o) => o.canAccept).length;
|
|
180
|
+
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
181
|
}
|
|
92
182
|
|
|
93
183
|
// Esc closes a window; the log window opens and closes.
|
|
@@ -130,9 +220,18 @@ const mobRow = app.rowsOf(app.top()).findIndex((r) => r.data?.creature);
|
|
|
130
220
|
app.top().cursor = mobRow; term.press('return');
|
|
131
221
|
check('live fight started', !!app.fight);
|
|
132
222
|
show('fight start');
|
|
223
|
+
{
|
|
224
|
+
const art = creatureArt(384, 20, 10); // Hogger
|
|
225
|
+
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));
|
|
226
|
+
const foeDisplay = ds.creature(app.fight?.enemyEntry)?.displayId;
|
|
227
|
+
check('the fight shows the foe beside the frames', !hasArt(foeDisplay) || /[▀▄]/.test(frame()));
|
|
228
|
+
}
|
|
133
229
|
const barName = app.fight?.abilityBar()[0]?.spell.name;
|
|
134
230
|
const hasBar = () => barName && frame().split('\n').some((l) => /^\s*1\s/.test(strip(l)) && strip(l).includes(barName));
|
|
135
|
-
|
|
231
|
+
// The duel stage keeps the ability bar on under auto-play too (its cooldowns are worth watching), with the
|
|
232
|
+
// plates under the sprites' feet: your name, level and class, then the bars; and a floor under it all.
|
|
233
|
+
check('auto-play shows the stage with the ability bar', hasBar() && new RegExp(`^\\s+${app.ch.name} L\\d+ Warrior`, 'm').test(strip(frame())));
|
|
234
|
+
check('the stage stands on a floor', /[▁▂_]{40}/.test(strip(frame())));
|
|
136
235
|
let ticks = 0;
|
|
137
236
|
let paneFrames = null;
|
|
138
237
|
while (app.fight && ticks < 3000) {
|
|
@@ -194,34 +293,108 @@ check('command line ran rest', app.log.some((l) => /You rest for 30 s/.test(stri
|
|
|
194
293
|
|
|
195
294
|
// Autopilot: steps by hand; its fights are live sessions that we tick to the end.
|
|
196
295
|
home(); term.press('1'); term.press('a');
|
|
197
|
-
check('
|
|
296
|
+
check('a opens the pre-flight card', app.top()?.id === 'autopilot-preflight' && /Stops\s+.*deaths in a row/.test(strip(frame())));
|
|
297
|
+
show('autopilot pre-flight');
|
|
298
|
+
app.fightSpeed = 8; // at 4x and up the combat log folds to one line a fight
|
|
299
|
+
const ticksBefore = app.log.filter((l) => /^\s*\d+\.\ds /.test(strip(l))).length;
|
|
300
|
+
term.press('return');
|
|
301
|
+
check('Enter on the card starts the autopilot', !!app.auto && app.stack.length === 0);
|
|
198
302
|
const autoCtl = app.auto;
|
|
199
|
-
let steps = 0, liveFights = 0, fightTicks = 0;
|
|
303
|
+
let steps = 0, liveFights = 0, fightTicks = 0, heldWin = false;
|
|
304
|
+
/** The first win: the fight stays on the stage with the foe at 0 and the drops beside it, the planner waits,
|
|
305
|
+
* and the screen moves on once the hold's ticks are spent. */
|
|
306
|
+
const checkWinHold = () => {
|
|
307
|
+
const f = app.fight, h = f.holding;
|
|
308
|
+
const stageRows = () => { const rows = strip(frame()).split('\n'); return rows.slice(2, rows.findIndex((l) => /^── Log/.test(l))); };
|
|
309
|
+
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)));
|
|
310
|
+
const stepBefore = autoCtl.planner.step; autoCtl.step();
|
|
311
|
+
check('the planner waits out the hold', autoCtl.planner.step === stepBefore && app.fight === f);
|
|
312
|
+
if (h.loot.length) {
|
|
313
|
+
while (app.fight === f && f.holdMs() < h.stagger + 300) f.tick();
|
|
314
|
+
const name = ds.item(h.loot[0].item)?.name;
|
|
315
|
+
check(`what it dropped slides in on the stage (${name})`, app.fight === f && stageRows().some((l) => l.includes(name)));
|
|
316
|
+
show('autopilot win hold');
|
|
317
|
+
}
|
|
318
|
+
let n = 0; while (app.fight === f && n++ < 200) f.tick();
|
|
319
|
+
check(`the hold lets go on its own (${n} ticks)`, !app.fight && n <= h.ticks);
|
|
320
|
+
};
|
|
200
321
|
while (app.auto && steps < 60) {
|
|
201
322
|
if (app.fight) {
|
|
202
|
-
if (!liveFights) {
|
|
203
|
-
|
|
323
|
+
if (!liveFights) {
|
|
324
|
+
show('autopilot fight');
|
|
325
|
+
check('an autopilot fight says what it is for', /(\d+\/\d+|hunting for xp|questing in)/.test(strip(frame()).split('\n')[2] ?? ''));
|
|
326
|
+
check('the run panel shows the foe beside the fight', !hasArt(ds.creature(app.fight.enemyEntry)?.displayId) || /[▀▄]/.test(frame()));
|
|
327
|
+
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())));
|
|
328
|
+
check('a run keeps only the XP line for a header', app.compactHeader() && /^ XP /.test(strip(frame())) && !/1 World/.test(strip(frame())));
|
|
329
|
+
check('your plate sits on the stage, once', (strip(frame()).match(new RegExp(`^\\s+${app.ch.name} L\\d+ `, 'mg')) ?? []).length === 1);
|
|
330
|
+
term.press('w'); check('w opens the full fight view', !!app.auto.watchFight && !app.inlineFight() && /^ Fighting /m.test(strip(frame())));
|
|
331
|
+
term.press('escape'); check('Esc comes back to the run panel without running', !!app.fight && !!app.auto && app.inlineFight());
|
|
332
|
+
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] ?? ''));
|
|
333
|
+
term.press('w'); check('w returns to the panel with the fight still on', !app.auto.browsing && app.inlineFight());
|
|
334
|
+
}
|
|
335
|
+
liveFights++;
|
|
336
|
+
if (!heldWin) { const f = app.fight; while (app.fight === f && !f.ended) { f.tick(); fightTicks++; } if (f.holding) { heldWin = true; checkWinHold(); } }
|
|
337
|
+
fightTicks += finishFight(); continue;
|
|
204
338
|
}
|
|
205
339
|
if (!app.auto.step()) break;
|
|
206
340
|
steps++;
|
|
207
341
|
}
|
|
342
|
+
check('the autopilot won a fight to hold on', heldWin);
|
|
208
343
|
show('autopilot');
|
|
209
344
|
if (app.auto) {
|
|
210
345
|
if (app.fight) { liveFights++; fightTicks += finishFight(); }
|
|
211
|
-
check('between fights the autopilot keeps its own panel',
|
|
346
|
+
check('between fights the autopilot keeps its own panel', app.compactHeader() && /^ Now\s/m.test(strip(frame())) && strip(frame()).includes(app.ch.name));
|
|
212
347
|
show('autopilot between fights');
|
|
348
|
+
app.fightPane = 'loot'; app.render();
|
|
349
|
+
check('between fights the autopilot keeps the fight pane', /Loot this sitting/.test(strip(frame())));
|
|
350
|
+
if (iconsOn() && app.lootTally?.rows.length) check('the Loot pane draws an icon beside each item', /Loot this sitting[^\n]*\n ▀▀▀▀ \S/.test(strip(frame())));
|
|
351
|
+
term.press('tab'); check('Tab between autopilot fights cycles the pane, not the tabs', app.fightPane === 'run' && !app.auto.browsing);
|
|
352
|
+
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))));
|
|
353
|
+
show('autopilot run pane');
|
|
354
|
+
term.press('tab'); check('Tab moves on to the Quests pane', app.fightPane === 'quests');
|
|
355
|
+
term.press('tab'); term.press('tab'); check('Tab wraps back to the log after Bags', app.fightPane === 'log');
|
|
356
|
+
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())));
|
|
357
|
+
check('the panel is five lines at most between fights', strip(frame()).split('\n').slice(2, 12).filter((l) => l.trim()).length <= 5);
|
|
358
|
+
app.fightPane = 'quests'; app.render();
|
|
359
|
+
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)));
|
|
360
|
+
show('autopilot quests pane');
|
|
361
|
+
app.fightPane = 'bags'; app.render();
|
|
362
|
+
check('the Bags pane shows the bags with a cursor', /^ Bags\s/m.test(strip(frame())) && /^▶/m.test(strip(frame())));
|
|
363
|
+
if (iconsOn()) check('the Bags pane draws an icon beside each item', /^▶▀▀▀▀ \S/m.test(strip(frame())));
|
|
364
|
+
const bagCursor = app.bagsPane.cursor; term.press('down');
|
|
365
|
+
check('Down moves the Bags pane cursor', app.bagsPane.cursor > bagCursor && !!app.auto);
|
|
366
|
+
const logBefore = app.log.length; term.press('return');
|
|
367
|
+
check('Enter on a bag row acts while the run plays on', (app.log.length > logBefore || app.stack.length > 0) && !!app.auto);
|
|
368
|
+
home(); show('autopilot bags pane');
|
|
369
|
+
app.fightPane = 'log';
|
|
370
|
+
term.press('space'); const stepBefore = autoCtl.planner.step; autoCtl.step();
|
|
371
|
+
check('space pauses the run between fights', autoCtl.paused && autoCtl.planner.step === stepBefore && /PAUSED/.test(strip(frame())));
|
|
372
|
+
term.press('space'); check('space resumes it', !autoCtl.paused);
|
|
213
373
|
term.press('3'); check('tabs can be viewed while the autopilot runs', app.tab === 2 && !!app.auto && /Bags|bag/i.test(strip(frame())));
|
|
214
|
-
|
|
374
|
+
check('the run strip sits under the tabs while browsing', /Autopilot.*fights.*back to the run/.test(strip(frame()).split('\n')[3] ?? ''));
|
|
375
|
+
show('autopilot run strip');
|
|
376
|
+
// A browsed tab works during the run: Enter acts on the row (here a bag item) instead of jumping back.
|
|
377
|
+
{ 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(); }
|
|
378
|
+
term.press('7'); check('the Talents tab opens during the run', app.tab === 6 && app.auto.browsing);
|
|
379
|
+
term.press('escape'); check('Esc on a tab comes back to the run instead of stopping it', !!app.auto && !app.auto.browsing);
|
|
380
|
+
term.press('3');
|
|
381
|
+
term.press('w'); check('w goes back to watching the run', !app.auto.browsing && app.compactHeader() && /^ Now\s/m.test(strip(frame())));
|
|
215
382
|
term.press('l'); check('log window opens over the autopilot', app.top().id === 'log');
|
|
216
383
|
term.press('escape'); check('Esc closes the log and leaves the autopilot running', app.stack.length === 0 && !!app.auto);
|
|
217
|
-
term.press('1');
|
|
384
|
+
term.press('1'); term.press('w'); // back on the panel, where Esc stops the run
|
|
218
385
|
}
|
|
219
386
|
check(`autopilot fought ${liveFights} live fights over ${steps} steps (${fightTicks} ticks)`, liveFights > 0 && app.log.some((l) => /hunting:|quest target:/.test(strip(l))));
|
|
220
387
|
check('autopilot counted its fights', autoCtl.fights === liveFights);
|
|
221
|
-
|
|
388
|
+
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))));
|
|
389
|
+
if (app.auto) {
|
|
390
|
+
term.press('escape'); check('Esc stops the autopilot', !app.auto);
|
|
391
|
+
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())));
|
|
392
|
+
show('autopilot end card');
|
|
393
|
+
term.press('return'); check('Enter closes the end card', app.stack.length === 0);
|
|
394
|
+
}
|
|
222
395
|
|
|
223
396
|
// Autopilot fight interrupted by Esc stops the run.
|
|
224
|
-
term.press('a');
|
|
397
|
+
term.press('a'); term.press('return');
|
|
225
398
|
while (app.auto && !app.fight && app.auto.step()) { /* reach a fight */ }
|
|
226
399
|
if (app.fight) { term.press('escape'); check('Esc in an autopilot fight stops the run', !app.fight && !app.auto); }
|
|
227
400
|
else { if (app.auto) app.auto.stop(); check('autopilot reached a fight to interrupt', false); }
|
|
@@ -235,6 +408,13 @@ else { if (app.auto) app.auto.stop(); check('autopilot reached a fight to interr
|
|
|
235
408
|
check('dying on a quest did not stop the run', !lost.done);
|
|
236
409
|
}
|
|
237
410
|
|
|
411
|
+
// The windowed run has no step cap: the Stop after setting ends it, on the run's real clock.
|
|
412
|
+
{
|
|
413
|
+
const timed = app.cmds.createAutopilot('', { maxSteps: Infinity, clock: () => 25 * 3_600_000, liveFight: () => ({ ok: true }) });
|
|
414
|
+
timed.next();
|
|
415
|
+
check('a run stops on the Stop after clock, not a step count', timed.steps === Infinity && timed.stopReason?.reason === 'timeUp');
|
|
416
|
+
}
|
|
417
|
+
|
|
238
418
|
// Do Quest: d on a quest log row runs the autopilot pinned to that quest, and clears its blacklist entry.
|
|
239
419
|
home(); term.press('2');
|
|
240
420
|
const doRow = rowIndex((r) => r.data?.qs?.status === 'active');
|
|
@@ -255,6 +435,13 @@ if (doRow >= 0) {
|
|
|
255
435
|
// Rotation editing through the menu: toggle a rule, add one, give it a condition, delete it.
|
|
256
436
|
home(); term.press('6');
|
|
257
437
|
show('rotation');
|
|
438
|
+
check('rotation phases start closed', !app.rowsOf(app.top()).some((r) => r.data?.rule));
|
|
439
|
+
app.top().cursor = rowIndex((r) => r.data?.toggle && r.data.phase === 'pull'); term.press('return');
|
|
440
|
+
check('Enter on Pull opens its rules', app.rowsOf(app.top()).some((r) => r.data?.rule && r.data.phase === 'pull') && rowIndex((r, t) => /\+ Add a rule to pull/.test(t)) > 0);
|
|
441
|
+
term.press('return');
|
|
442
|
+
check('Enter again closes it', !app.rowsOf(app.top()).some((r) => r.data?.rule));
|
|
443
|
+
app.top().cursor = rowIndex((r) => r.data?.toggle && r.data.phase === 'combat'); term.press('return');
|
|
444
|
+
show('rotation, combat open');
|
|
258
445
|
const ruleRow = app.rowsOf(app.top()).findIndex((r) => r.data?.rule);
|
|
259
446
|
const ruleData = app.rowsOf(app.top())[ruleRow].data;
|
|
260
447
|
app.top().cursor = ruleRow; term.press('return');
|
|
@@ -272,11 +459,38 @@ check('add-rule picker opened', app.top().id === 'pick');
|
|
|
272
459
|
term.press('return');
|
|
273
460
|
check('rule added to combat', app.g.rotation().phases.combat.length === before + 1);
|
|
274
461
|
check('cursor landed on the new rule', app.rowsOf(app.top())[app.top().cursor]?.data?.rule?.id?.startsWith('tui-'));
|
|
462
|
+
{
|
|
463
|
+
const added = app.rowsOf(app.top())[app.top().cursor].data.rule;
|
|
464
|
+
const want = defaultConditionsForSpell(ds.spell(added.action.spellId), app.ch);
|
|
465
|
+
check('new rule starts with the smart default conditions', JSON.stringify(added.conditions) === JSON.stringify(want));
|
|
466
|
+
}
|
|
275
467
|
term.press('return'); term.press('down'); term.press('return'); // menu → change when it fires
|
|
276
|
-
check('condition
|
|
277
|
-
|
|
278
|
-
|
|
468
|
+
check('condition editor opened', app.top().id === 'conds');
|
|
469
|
+
show('condition editor');
|
|
470
|
+
app.top().cursor = rowIndex((r, t) => /\+ Add a condition/.test(t)); term.press('return');
|
|
471
|
+
check('metric catalog opened', app.top().id === 'pick' && /What to check/.test(app.top().title));
|
|
472
|
+
app.top().cursor = rowIndex((r, t) => /living enemies/.test(t)); term.press('return');
|
|
473
|
+
check('condition added from the catalog', app.g.rotation().phases.combat.some((r) => r.id.startsWith('tui-') && r.conditions.some((c) => c.metric === 'enemyCount')));
|
|
474
|
+
check('one condition menu opened', app.top().id === 'cond');
|
|
475
|
+
app.top().cursor = rowIndex((r, t) => /^Value:/.test(t)); term.press('return'); term.type('3');
|
|
476
|
+
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)));
|
|
477
|
+
show('one condition');
|
|
478
|
+
app.top().cursor = rowIndex((r, t) => /^Check:/.test(t)); term.press('return'); term.press('/'); term.type('ally');
|
|
479
|
+
check('/ searches the catalog', app.top().id === 'pick' && /matching "ally"/.test(app.top().title));
|
|
480
|
+
app.top().cursor = rowIndex((r, t) => /^\s*health %$/.test(t)); term.press('return');
|
|
481
|
+
app.top().cursor = rowIndex((r, t) => /^Who:/.test(t)); term.press('return');
|
|
482
|
+
app.top().cursor = rowIndex((r, t) => /an ally/.test(t)); term.press('return');
|
|
483
|
+
check('an ally subject shows the ally filter', rowIndex((r, t) => /Which ally/.test(t)) >= 0);
|
|
484
|
+
app.top().cursor = rowIndex((r, t) => /Which ally/.test(t)); term.press('return');
|
|
485
|
+
app.top().cursor = rowIndex((r, t) => /a healer/.test(t)); term.press('return');
|
|
486
|
+
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'));
|
|
487
|
+
show('ally condition');
|
|
488
|
+
term.press('escape');
|
|
489
|
+
app.top().cursor = rowIndex((r, t) => /Quick presets/.test(t)); term.press('return');
|
|
490
|
+
app.top().cursor = rowIndex((r, t) => /My health is below/.test(t)); term.press('return'); term.type('50');
|
|
491
|
+
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
492
|
show('rotation edited');
|
|
493
|
+
term.press('escape'); // leave the condition editor
|
|
280
494
|
term.press('return'); term.press('end'); term.press('return'); // menu → delete
|
|
281
495
|
check('rule deleted through the menu', app.g.rotation().phases.combat.length === before);
|
|
282
496
|
|
|
@@ -385,6 +599,24 @@ check('b in Bags outside a bank town only flashes', app.stack.length === 0);
|
|
|
385
599
|
check('auction dialog opened', app.top().id === 'auction');
|
|
386
600
|
const listings = app.g.browseAuctions(Date.now()).length;
|
|
387
601
|
check(`auction house has bot listings (${listings})`, listings > 0);
|
|
602
|
+
// Categories: Left/Right picks an item class, [ ] a subclass inside it; Left back round to All shows everything.
|
|
603
|
+
const shown = () => app.rowsOf(app.top()).filter((r) => r.data?.auction).map((r) => app.ds.item(r.data.auction.item));
|
|
604
|
+
term.press('right'); show('auction category');
|
|
605
|
+
const inCat = shown(), cls = inCat[0]?.itemClass;
|
|
606
|
+
check(`Right picks a category (${inCat.length} of ${listings})`, inCat.length > 0 && inCat.length < listings && inCat.every((it) => it.itemClass === cls));
|
|
607
|
+
const onStrip = () => app.rowsOf(app.top())[app.top().cursor]?.data?.strip;
|
|
608
|
+
check('the cursor starts on a listing, not a strip', !onStrip());
|
|
609
|
+
term.press('up');
|
|
610
|
+
check(`Up from the first listing lands on a strip (${onStrip()})`, !!onStrip());
|
|
611
|
+
if (onStrip() === 'sub') {
|
|
612
|
+
term.press('right'); show('auction subcategory');
|
|
613
|
+
const inSub = shown(), subOf = (it) => it.itemSubclass ?? it.weaponSubclass ?? it.armorSubclass;
|
|
614
|
+
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])));
|
|
615
|
+
check('the cursor stays on the strip', onStrip() === 'sub');
|
|
616
|
+
term.press('down');
|
|
617
|
+
check('Down from the strip goes to the listings', !onStrip() && !!app.rowsOf(app.top())[app.top().cursor]?.data?.auction);
|
|
618
|
+
} else term.press('down');
|
|
619
|
+
term.press('left'); check('Left back to All lists every listing', shown().length === listings);
|
|
388
620
|
term.press('s'); term.type('cloth'); show('auction search');
|
|
389
621
|
check('search filters the boards', /"cloth"/.test(strip(frame())));
|
|
390
622
|
term.press('tab'); show('auction sell');
|
|
@@ -459,11 +691,24 @@ check('b in Bags outside a bank town only flashes', app.stack.length === 0);
|
|
|
459
691
|
if (hire >= 0) {
|
|
460
692
|
app.top().cursor = hire; term.press('return'); show('hire list');
|
|
461
693
|
check('the hire list opened', app.top().id === 'hire' && rowIndex(() => true) >= 0);
|
|
462
|
-
check('hire rows name their trades', /(Mining|Herb|Skin|Tailor|Smith|Engi|Alchemy|Enchant|Leather) \d+/.test(strip(app.rowsOf(app.top()).
|
|
694
|
+
check('hire rows name their trades', /(Mining|Herb|Skin|Tailor|Smith|Engi|Alchemy|Enchant|Leather) \d+/.test(strip(app.rowsOf(app.top())[app.top().cursor]?.text ?? '')));
|
|
695
|
+
const hireRows = () => app.rowsOf(app.top()).filter((r) => !r.header && r.data);
|
|
696
|
+
check('the hire list is the whole faction, every level', hireRows().length > 1000 && hireRows().some((r) => /^\S+\s+L1 /.test(strip(r.text))) && hireRows().some((r) => /^\S+\s+L60 /.test(strip(r.text))));
|
|
697
|
+
check('the hire list opens on your level', /^\S+\s+L(8|9|10|11|12) /.test(strip(app.rowsOf(app.top())[app.top().cursor]?.text ?? '')));
|
|
698
|
+
term.press('right');
|
|
699
|
+
check('Right steps to the DPS tab', app.top().title === 'Hire · DPS' && hireRows().every((r) => /^\S+\s+L\d+ \S+\s+dps /.test(strip(r.text))));
|
|
700
|
+
term.press('t'); term.press('t');
|
|
701
|
+
check('t narrows to one kit', app.top().title === 'Hire · DPS · Shabby' && hireRows().length > 0 && hireRows().every((r) => / dps\s+Shabby /.test(strip(r.text))));
|
|
702
|
+
term.press('left'); term.press('left'); term.press('right');
|
|
703
|
+
check('the role tabs wrap, All is the start', app.top().title === 'Hire · Shabby');
|
|
704
|
+
term.press('T'); term.press('T');
|
|
705
|
+
check('T steps the kit back to any', app.top().title === 'Hire');
|
|
463
706
|
term.press('return'); show('candidate page');
|
|
464
707
|
check('Enter opens the candidate page', app.top().id.startsWith('cand-'));
|
|
465
708
|
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));
|
|
709
|
+
app.top().cursor = rowIndex((r, t) => /^Hire as/.test(t)); app.render();
|
|
710
|
+
check('the hire row draws the candidate in their gear beside the cost', !hasArt(nativeDisplayId(human.id, 0)) || /[▀▄]/.test(frame()));
|
|
711
|
+
term.press('return'); show('after hire');
|
|
467
712
|
check('hiring returns to the hire list, not the tab', app.top()?.id === 'hire');
|
|
468
713
|
term.press('escape'); check('Esc closes the hire list', app.stack.length === 0);
|
|
469
714
|
const comp = (g.state.squad ?? [])[0];
|
|
@@ -472,6 +717,7 @@ check('b in Bags outside a bank town only flashes', app.stack.length === 0);
|
|
|
472
717
|
const row = rowIndex((r, t) => t.startsWith(comp.name));
|
|
473
718
|
app.top().cursor = row; term.press('return'); show('companion page');
|
|
474
719
|
check('Enter opens their page', app.top().id === `comp-${comp.id}`);
|
|
720
|
+
check('their page draws them beside the Role note', !hasArt(nativeDisplayId(comp.race, comp.gender)) || /[▀▄]/.test(frame()));
|
|
475
721
|
// Make room for Tailoring: drop one primary when they hold two.
|
|
476
722
|
const lines = g.companionProfessionsOf(comp.id)?.lines ?? [];
|
|
477
723
|
const primaries = lines.filter((l) => l.kind !== 'secondary');
|
|
@@ -544,6 +790,16 @@ check('b in Bags outside a bank town only flashes', app.stack.length === 0);
|
|
|
544
790
|
}
|
|
545
791
|
const squad = g.state.squad ?? [];
|
|
546
792
|
check(`two companions are seated (${squad.length})`, squad.length >= 2);
|
|
793
|
+
if (iconsOn() && squad[0]) {
|
|
794
|
+
// c on a companion's page opens their character sheet: the Character tab's paperdoll, for them.
|
|
795
|
+
const [cols, rows] = [term.cols, term.rows];
|
|
796
|
+
term.cols = 160; term.rows = 54;
|
|
797
|
+
home(); app.switchTab(tabOf('companions'));
|
|
798
|
+
app.top().cursor = rowIndex((r, t) => t.startsWith(squad[0].name)); term.press('return'); term.press('c'); show('companion sheet');
|
|
799
|
+
check('c on a companion opens their sheet as the paperdoll grid', app.top().id === `sheet-${squad[0].id}` && !!app.top().gridNav && frame().includes(squad[0].name) && /Attack pwr/.test(frame()));
|
|
800
|
+
term.cols = cols; term.rows = rows; home();
|
|
801
|
+
}
|
|
802
|
+
check('money reads in coins, an empty copper left off', strip(coinText(250000)) === (iconsOn() ? '25●' : '25g 0c') && strip(coinText(0, 'free')) === 'free');
|
|
547
803
|
home(); app.switchTab(tabOf('companions'));
|
|
548
804
|
term.press('m'); show('squad mode picker');
|
|
549
805
|
check('m on Companions opens the squad mode picker', app.top().id === 'pick' && /How do your companions fight/.test(app.top().title));
|
|
@@ -585,9 +841,31 @@ check('b in Bags outside a bank town only flashes', app.stack.length === 0);
|
|
|
585
841
|
check('a solo pull brings nobody', !!app.fight && app.fight.session.alliesView().length === 0);
|
|
586
842
|
finishFight();
|
|
587
843
|
term.press(':'); term.type('squad party');
|
|
588
|
-
const
|
|
589
|
-
term.press(':'); term.type(`fight ${
|
|
590
|
-
|
|
844
|
+
const quickEntry = app.rowsOf(app.top())[mobAt].data.creature.entry;
|
|
845
|
+
term.press(':'); term.type(`fight ${quickEntry}`);
|
|
846
|
+
// The log is capped at 2000 lines (say()), so read from the command's echo, not from a length taken before it.
|
|
847
|
+
const quickLog = app.log.map(strip);
|
|
848
|
+
const quickAt = quickLog.lastIndexOf(`> fight ${quickEntry}`);
|
|
849
|
+
check(':fight brings the party in Party mode', quickAt >= 0 && quickLog.slice(quickAt + 1).some((l) => squad.some((c) => l.includes(c.name))));
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
// ---- gear: a BoE a companion carries (a crafter's product) goes on from the Equipment tab, no trade first ----
|
|
853
|
+
{
|
|
854
|
+
const g = app.g, comp = g.state.squad[0];
|
|
855
|
+
const cloak = ds.items().find((i) => i.slot === 'back' && i.bonding === 2 && (i.requiredLevel ?? 1) <= app.ch.level && !i.allowClass?.length && i.entry > 100);
|
|
856
|
+
check('a BoE cloak exists to hand a companion', !!cloak && !!comp);
|
|
857
|
+
if (cloak && comp) {
|
|
858
|
+
comp.bags = addItem(comp.bags ?? { stacks: [] }, cloak.entry, 1, 1);
|
|
859
|
+
home(); app.switchTab(app.tabs.findIndex((t) => t.view.id === 'gear'));
|
|
860
|
+
app.top().cursor = rowIndex((r) => r.data?.slot === 'back' && !r.data?.upgrade); term.press('return'); show('back slot picker with companion bags');
|
|
861
|
+
const row = rowIndex((r, t) => t.includes(cloak.name) && t.includes(comp.name));
|
|
862
|
+
check("the slot picker lists the cloak from the companion's bags", row > 0);
|
|
863
|
+
if (row > 0) {
|
|
864
|
+
app.top().cursor = row; term.press('return');
|
|
865
|
+
const back = app.g.state.char.equipped.back;
|
|
866
|
+
check("Enter takes the companion's cloak and wears it", (back?.item ?? back) === cloak.entry && countItem(comp.bags, cloak.entry) === 0);
|
|
867
|
+
}
|
|
868
|
+
}
|
|
591
869
|
}
|
|
592
870
|
|
|
593
871
|
// ---- dungeons: the Deadmines as a text run ------------------------------------------------------------------
|
|
@@ -614,6 +892,37 @@ check('b in Bags outside a bank town only flashes', app.stack.length === 0);
|
|
|
614
892
|
for (let n = 1; n <= 2; n++) {
|
|
615
893
|
app.top().cursor = rowIndex((r, t) => /◀ next/.test(t)); term.press('return');
|
|
616
894
|
check(`stop ${n} starts a live fight with the party`, !!app.fight?.withSquad && app.fight.session.alliesView().length >= 4);
|
|
895
|
+
if (n === 1 && app.fight) {
|
|
896
|
+
// The party stage: every body on one floor, the party's plates on one row (role letter, name) left of the
|
|
897
|
+
// pack's, an HP percent under each, the foe you face marked, and a cast bar under a caster while it casts.
|
|
898
|
+
for (let k = 0; k < 8 && app.fight; k++) app.fight.tick();
|
|
899
|
+
show('party stage');
|
|
900
|
+
const rows = strip(frame()).split('\n');
|
|
901
|
+
const plateRow = rows.findIndex((l) => /\bT \S/.test(l) && /\bH \S/.test(l) && l.includes(app.ch.name));
|
|
902
|
+
check('the party stage puts the tank, the healer and you on one plate row', plateRow > 0);
|
|
903
|
+
const pct = (rows[plateRow + 1] ?? '').match(/\d+%/g) ?? [];
|
|
904
|
+
const bodies = 1 + app.fight.session.alliesView().length + 1; // you, the party, at least the foe you face
|
|
905
|
+
check(`an HP percent sits under every sprite (${pct.length} of ${bodies}+)`, pct.length >= bodies);
|
|
906
|
+
check('the pack stands right of the party with the faced foe marked', plateRow > 0 && rows[plateRow].indexOf('◀') > rows[plateRow].lastIndexOf(app.ch.name));
|
|
907
|
+
check('the party stage stands on a floor', /[▁▂_]{40}/.test(strip(frame())));
|
|
908
|
+
let cast = null;
|
|
909
|
+
for (let k = 0; k < 400 && app.fight && !cast; k++) {
|
|
910
|
+
app.fight.tick();
|
|
911
|
+
const s = app.fight.session;
|
|
912
|
+
cast = [...s.alliesView(), ...s.enemiesView()].find((v) => v.hp > 0 && v.casting && v.casting.endsAt - s.t >= 300) ?? null;
|
|
913
|
+
}
|
|
914
|
+
check('a cast shows as a bar under the caster', !cast || strip(frame()).includes(cast.casting.name.slice(0, 8)));
|
|
915
|
+
if (cast) show('party stage cast');
|
|
916
|
+
// The last foe falls and the win holds: the party stays where it fought, it does not walk back to its start.
|
|
917
|
+
const plates = () => strip(frame()).split('\n').find((l) => /\bT \S/.test(l) && l.includes(app.ch.name))?.replace(/ ◀/, '').replace(/\s+$/, '') ?? ''; // the dead foe loses its mark
|
|
918
|
+
let before = '';
|
|
919
|
+
for (let k = 0; k < 3000 && app.fight && !app.fight.ended; k++) {
|
|
920
|
+
const left = app.fight.session.enemiesView().filter((v) => v.hp > 0);
|
|
921
|
+
if (left.length === 1 && left[0].hp < left[0].maxHp * 0.25) before = plates();
|
|
922
|
+
app.fight.tick();
|
|
923
|
+
}
|
|
924
|
+
if (app.fight?.holding && before) { const after = plates(); check(`the party holds its ground over the win${after === before ? '' : `\n ${before}\n ${after}`}`, after === before); }
|
|
925
|
+
}
|
|
617
926
|
finishFight();
|
|
618
927
|
check(`stop ${n} is cleared and the run window comes back`, g.state.instance.defeated.length === n && app.top()?.id === 'dungeon');
|
|
619
928
|
}
|
|
@@ -684,6 +993,16 @@ check('b in Bags outside a bank town only flashes', app.stack.length === 0);
|
|
|
684
993
|
app.top().cursor = rowIndex((r, t) => /^Raid group/.test(t)); term.press('return'); term.press('f');
|
|
685
994
|
check('f fills the 20-man group', g.state.raidLineup?.size === 20 && g.state.raidLineup.ids.length > 0);
|
|
686
995
|
term.press('c'); home();
|
|
996
|
+
// Travel splits a zone's neighbors by how you get there: Grom'gol's zeppelins are not a walk to Tirisfal.
|
|
997
|
+
app.switchTab(app.tabs.findIndex((t) => t.view.id === 'travel')); app.render();
|
|
998
|
+
{
|
|
999
|
+
const texts = app.rowsOf(app.top()).map((r) => strip(r.text));
|
|
1000
|
+
const foot = texts.indexOf('On foot'), boats = texts.indexOf('Boats and zeppelins');
|
|
1001
|
+
const tirisfal = texts.findIndex((t) => t.startsWith('Tirisfal Glades'));
|
|
1002
|
+
check('Travel lists the zeppelin to Tirisfal under boats, Duskwood on foot', foot >= 0 && boats > foot && tirisfal > boats
|
|
1003
|
+
&& /Undercity/.test(texts[tirisfal]) && texts.findIndex((t) => t.startsWith('Duskwood')) < boats);
|
|
1004
|
+
}
|
|
1005
|
+
home();
|
|
687
1006
|
g.state.char.zoneId = 'westfall';
|
|
688
1007
|
}
|
|
689
1008
|
|
|
@@ -731,6 +1050,27 @@ check('b in Bags outside a bank town only flashes', app.stack.length === 0);
|
|
|
731
1050
|
check('p adds a named companion rotation', g.companionRotationProfiles(comp().id).some((p) => p.name === 'Second' && p.active));
|
|
732
1051
|
term.press('w'); term.press('return');
|
|
733
1052
|
check('w back to your own rotation', !app.tabs[5].view.whose);
|
|
1053
|
+
// Spellbook tabs: General, then one per talent tree; [ ] and Left Right on the strip turn them.
|
|
1054
|
+
home(); term.press('5'); show('spellbook');
|
|
1055
|
+
{
|
|
1056
|
+
const rs = () => app.rowsOf(app.top());
|
|
1057
|
+
const stripAt = () => rs().findIndex((r) => r.data?.strip);
|
|
1058
|
+
const spells = () => rs().slice(stripAt() + 1).filter((r) => !r.header).map((r) => strip(r.text));
|
|
1059
|
+
const trees = ds.talentTrees(app.ch.cls).map((t) => t.name);
|
|
1060
|
+
check('spellbook has General and a tab per talent tree', stripAt() > 0 && ['General', ...trees].every((n) => strip(rs()[stripAt()].text).includes(n)));
|
|
1061
|
+
const first = spells();
|
|
1062
|
+
check(`the book opens on a tab with spells (${first.join(', ')})`, first.length > 0);
|
|
1063
|
+
// ] through every tab and back: each spell sits on exactly one tab, and the class spells are not on General.
|
|
1064
|
+
const seen = [];
|
|
1065
|
+
for (let i = 0; i <= trees.length; i++) { seen.push(...spells()); term.press(']'); }
|
|
1066
|
+
check('] walks every tab and comes back round', JSON.stringify(spells()) === JSON.stringify(first) && new Set(seen).size === seen.length);
|
|
1067
|
+
// This warrior's General is empty, so the book opens on Arms.
|
|
1068
|
+
check('Heroic Strike is filed under Arms, not General', /General 0\s+Arms [1-9]/.test(strip(rs()[stripAt()].text)) && first.some((n) => n.startsWith('Heroic Strike')));
|
|
1069
|
+
app.top().cursor = stripAt(); term.press('left');
|
|
1070
|
+
const back = spells();
|
|
1071
|
+
term.press('right');
|
|
1072
|
+
check('Left Right on the strip turn the book, not the window', app.top().id === 'spells' && JSON.stringify(spells()) === JSON.stringify(first) && JSON.stringify(back) !== JSON.stringify(first));
|
|
1073
|
+
}
|
|
734
1074
|
// Player action bars: bar 1 from the Spells tab, and the fight's number row reads it.
|
|
735
1075
|
home(); term.press('5'); term.press('b'); show('player bars');
|
|
736
1076
|
check('b on Spells opens your action bars', app.top().id === 'bars' && /Bar 1/.test(strip(frame())));
|
|
@@ -789,7 +1129,21 @@ check('b in Bags outside a bank town only flashes', app.stack.length === 0);
|
|
|
789
1129
|
g2.state.char.level = 12; g2.state.char.gold = 1_000_000;
|
|
790
1130
|
app2.render();
|
|
791
1131
|
const at = (pred) => app2.rowsOf(app2.top()).findIndex((r) => !r.header && pred(r, strip(r.text)));
|
|
792
|
-
|
|
1132
|
+
// One with an innate ability, so the pet page has an autocast row to toggle.
|
|
1133
|
+
const beast = () => at((r) => r.data?.creature?.tameable && r.data.creature.level <= 12 && r.data.creature.petDefaultSpells?.length);
|
|
1134
|
+
app2.top().cursor = beast(); term2.press('t');
|
|
1135
|
+
check('t refuses a hunter who has not learned Tame Beast', !g2.state.char.pet);
|
|
1136
|
+
// Taming the Beast (Dun Morogh): the rod goes on the Large Crag Boar the quest names, and t says so on its row.
|
|
1137
|
+
g2.state.char.level = 10; app2.result(g2.acceptQuest(6064));
|
|
1138
|
+
const boar = at((r) => r.data?.creature?.entry === 1126);
|
|
1139
|
+
check('the taming quest puts its rod on the beast it names', boar >= 0 && /t: use Taming Rod/.test(strip(app2.rowsOf(app2.top())[boar].text)));
|
|
1140
|
+
app2.top().cursor = boar; term2.press('t');
|
|
1141
|
+
check('t uses the rod and the quest completes', g2.state.questlog.quests.find((q) => q.entry === 6064)?.status === 'complete');
|
|
1142
|
+
g2.state.char.level = 12;
|
|
1143
|
+
g2.state.spellbook = { known: [...g2.state.spellbook.known, 1515] };
|
|
1144
|
+
app2.render();
|
|
1145
|
+
check('a hunter with Tame Beast sees t: tame on a beast', /t: tame/.test(strip(app2.rowsOf(app2.top())[beast()].text)));
|
|
1146
|
+
app2.top().cursor = beast(); term2.press('t');
|
|
793
1147
|
check('t on a tameable beast tames it', !!g2.state.char.pet);
|
|
794
1148
|
app2.top().cursor = at((r, t) => /^Pet:/.test(t)); term2.press('return');
|
|
795
1149
|
if (showFrames) console.log(`\n=== pet page ===\n${term2.text()}`);
|
|
@@ -814,13 +1168,56 @@ check('b in Bags outside a bank town only flashes', app.stack.length === 0);
|
|
|
814
1168
|
check('Dun Morogh lists a pet trainer', trainer > 0);
|
|
815
1169
|
if (trainer > 0) {
|
|
816
1170
|
app2.top().cursor = trainer; term2.press('return');
|
|
817
|
-
|
|
1171
|
+
check('the pet trainer offers to untrain the pet', at((r, t) => /^Untrain /.test(t)) > 0);
|
|
1172
|
+
const rank = at((r, t) => !/level \d+/.test(t) && !/^✓/.test(t) && !/^Untrain /.test(t));
|
|
818
1173
|
app2.top().cursor = rank; term2.press('return');
|
|
819
1174
|
check('a pet trainer rank can be bought', (g2.state.char.petSpells ?? []).length > 0);
|
|
820
1175
|
}
|
|
821
1176
|
term2.close?.();
|
|
822
1177
|
}
|
|
823
1178
|
|
|
1179
|
+
// ---- what phonecraft's World tab, quest card and bag menu offer: weapon masters, ring picker, hearth, quest steps --
|
|
1180
|
+
{
|
|
1181
|
+
const term2 = new HeadlessTerm(110, 34);
|
|
1182
|
+
const app2 = new App({ term: term2, ds, repo });
|
|
1183
|
+
term2.open();
|
|
1184
|
+
const dwarf = ds.races().find((r) => r.name === 'Dwarf');
|
|
1185
|
+
app2.startGame(newGameState(ds, dwarf.id, 1, 'Portcheck', 98, 0), 'portcheck', Date.now(), true);
|
|
1186
|
+
const g2 = app2.g;
|
|
1187
|
+
g2.state.char.level = 20; g2.state.char.zoneId = 'ironforge';
|
|
1188
|
+
app2.switchTab(0); app2.render();
|
|
1189
|
+
const at = (pred) => app2.rowsOf(app2.top()).findIndex((r) => !r.header && pred(r, strip(r.text)));
|
|
1190
|
+
check('Ironforge lists its weapon masters on the World tab', at((r, t) => /Weapon Master|Weapon Trainer/i.test(t)) >= 0);
|
|
1191
|
+
check('no Horde trainer or vendor in an Alliance capital', at((r, t) => /Orgrimmar|Thunder Bluff/.test(t) && /</.test(t)) < 0);
|
|
1192
|
+
// Two rings on, a third from the bags asks which to replace instead of overwriting the first.
|
|
1193
|
+
const rings = ds.allItems().filter((it) => it.slot === 'finger' && (it.requiredLevel ?? 0) <= 20 && !it.unique).slice(0, 3);
|
|
1194
|
+
if (rings.length === 3) {
|
|
1195
|
+
for (const r of rings.slice(0, 2)) { g2.state.inventory = addItem(g2.state.inventory, r.entry, 1, 1); g2.equip(r.entry); }
|
|
1196
|
+
g2.state.inventory = addItem(g2.state.inventory, rings[2].entry, 1, 1);
|
|
1197
|
+
app2.switchTab(2); app2.render();
|
|
1198
|
+
app2.top().cursor = at((r, t) => t.includes(rings[2].name)); term2.press('return');
|
|
1199
|
+
check('a third ring asks which ring to replace', app2.top().id === 'pick' && /replace which/.test(strip(app2.top().title)));
|
|
1200
|
+
term2.press('down'); term2.press('return');
|
|
1201
|
+
check('the picked ring slot takes it', Object.values(g2.state.char.equipped).some((e) => (e?.entry ?? e) === rings[2].entry));
|
|
1202
|
+
} else check('three plain rings exist for the slot picker check', false);
|
|
1203
|
+
// The Hearthstone from the bags takes you home, as the Travel tab's row does.
|
|
1204
|
+
g2.state.char.hearthBind = { zone: 'dunmorogh', name: 'Thunderbrew Distillery' };
|
|
1205
|
+
app2.stack = []; app2.switchTab(2); app2.render();
|
|
1206
|
+
const hs = at((r, t) => /Hearthstone/.test(t));
|
|
1207
|
+
if (hs >= 0) {
|
|
1208
|
+
app2.top().cursor = hs; term2.press('return');
|
|
1209
|
+
check('Enter on the Hearthstone in the bags hearths you', g2.state.char.zoneId !== 'ironforge' || !!g2.state.char.journey);
|
|
1210
|
+
} else check('a new dwarf carries a Hearthstone', false);
|
|
1211
|
+
// A speak-to quest in the log gets its Speak row on the quest page.
|
|
1212
|
+
const talk = ds.quests().find((q) => isTalkToNpcQuest(q) && !q.goEnder && !(q.goEnderEntries ?? []).some((e) => e > 0) && (q.enderName || q.starterName));
|
|
1213
|
+
if (talk) {
|
|
1214
|
+
g2.state.questlog = { ...g2.state.questlog, quests: [...g2.state.questlog.quests, { entry: talk.entry, status: 'active', kills: {} }] };
|
|
1215
|
+
app2.stack = []; app2.push(questDialog(app2, talk, 'log')); app2.render();
|
|
1216
|
+
check('a speak-to quest offers Speak to its NPC on the quest page', at((r, t) => /^Speak to /.test(t)) >= 0);
|
|
1217
|
+
}
|
|
1218
|
+
term2.close?.();
|
|
1219
|
+
}
|
|
1220
|
+
|
|
824
1221
|
// Loot window: auto-loot off holds the corpse's drops and coin; Enter takes a row, a takes the rest, Esc leaves it.
|
|
825
1222
|
{
|
|
826
1223
|
home(); term.press('1');
|
|
@@ -965,7 +1362,7 @@ check('b in Bags outside a bank town only flashes', app.stack.length === 0);
|
|
|
965
1362
|
const slotRow = rowIndex((r) => r.data?.slot === worn);
|
|
966
1363
|
check('a worn-down slot shows its durability', / 1\/\d+/.test(strip(app.rowsOf(app.top())[slotRow].text)));
|
|
967
1364
|
const quote = g.repairQuote();
|
|
968
|
-
check('the Repair all row prices the bill', rowIndex((r, t) => t.startsWith('Repair all') && t.includes(
|
|
1365
|
+
check('the Repair all row prices the bill', rowIndex((r, t) => t.startsWith('Repair all') && t.includes(strip(coinText(quote.cost)))) > 0);
|
|
969
1366
|
if (quote.vendorName) {
|
|
970
1367
|
app.top().cursor = slotRow; term.press('r');
|
|
971
1368
|
check('r on a slot repairs that piece', g.state.char.durability?.[worn] == null);
|
|
@@ -1073,6 +1470,8 @@ check('b in Bags outside a bank town only flashes', app.stack.length === 0);
|
|
|
1073
1470
|
check('a global bar slot takes a bag item', (g.state.globalActionBar?.items ?? []).includes(potion.entry));
|
|
1074
1471
|
g.state.char.health = Math.max(1, Math.floor(g.state.char.health / 2));
|
|
1075
1472
|
const potions = countItem(g.state.inventory, potion.entry);
|
|
1473
|
+
// The kobold fight above may have drunk one, and the potion cooldown runs on the wall clock now.
|
|
1474
|
+
g.state.spellCooldowns = {}; g.state.categoryCooldowns = {};
|
|
1076
1475
|
app.top().cursor = rowIndex((r, t) => /Fire the whole global bar/.test(t)); term.press('return');
|
|
1077
1476
|
check('firing the global bar drinks the potion', countItem(g.state.inventory, potion.entry) === potions - 1);
|
|
1078
1477
|
app.top().cursor = rowIndex((r, t) => t.includes(potion.name) && !('b' in (r.data ?? {}))); term.press('return');
|
|
@@ -1127,15 +1526,15 @@ check('b in Bags outside a bank town only flashes', app.stack.length === 0);
|
|
|
1127
1526
|
home();
|
|
1128
1527
|
}
|
|
1129
1528
|
|
|
1130
|
-
// Cancel an aura: from the Character tab, and with c inside a fight.
|
|
1529
|
+
// Cancel an aura: from the Character tab's stats page, and with c inside a fight.
|
|
1131
1530
|
{
|
|
1132
1531
|
const g = app.g;
|
|
1133
1532
|
const fort = ds.spell(1243);
|
|
1134
1533
|
const give = () => { g.state.char.activeAuras = [...(g.state.char.activeAuras ?? []).filter((x) => x.spellId !== 1243), { spellId: 1243, expiresAt: Date.now() + 600000 }]; };
|
|
1135
1534
|
give();
|
|
1136
|
-
home(); app.switchTab(
|
|
1535
|
+
home(); app.switchTab(3); term.press('s'); show('character auras');
|
|
1137
1536
|
const at = rowIndex((r, t) => t.includes(fort.name));
|
|
1138
|
-
check('the
|
|
1537
|
+
check('the stats page lists your auras', at > 0);
|
|
1139
1538
|
app.top().cursor = at; term.press('return');
|
|
1140
1539
|
check('Enter on a buff cancels it', !(g.state.char.activeAuras ?? []).some((x) => x.spellId === 1243));
|
|
1141
1540
|
give(); home(); term.press('1');
|
|
@@ -1267,6 +1666,21 @@ check('b in Bags outside a bank town only flashes', app.stack.length === 0);
|
|
|
1267
1666
|
home();
|
|
1268
1667
|
}
|
|
1269
1668
|
|
|
1669
|
+
// The Travel tab's detail pane carries the world map (tuiMap.mjs): the terrain as half-block cells, you and the
|
|
1670
|
+
// destination pinned on it, the planned route dotted between them.
|
|
1671
|
+
{
|
|
1672
|
+
home(); term.press('8');
|
|
1673
|
+
const here = app.g.state.char.zoneId;
|
|
1674
|
+
const dest = (ds.zone(here)?.neighbors ?? []).map((id) => ds.zone(id)).find((z) => z && app.g.previewTravel(z.id).ok);
|
|
1675
|
+
app.top().cursor = rowIndex((r, t) => t.startsWith(dest.name)); app.render();
|
|
1676
|
+
const pane = strip(frame()).split('\n').map((l) => l.split('│')[1] ?? '').join('\n');
|
|
1677
|
+
check('the Travel tab draws the world map under a destination, you pinned on it', /▀/.test(pane) && pane.includes('●') && /you .*destination/.test(pane));
|
|
1678
|
+
const far = ds.zones().find((z) => z.continent !== ds.zone(here).continent && app.g.previewTravel(z.id).ok);
|
|
1679
|
+
const lines = worldMapLines(app, { dest: far, legs: app.g.previewTravel(far.id).legs, maxCols: 60, maxRows: 24 });
|
|
1680
|
+
check('a trip to the other continent draws its route, every line inside the room given', lines.length === 24 && lines.every((l) => vis(l) <= 60) && lines.slice(0, -1).join('').includes('·') && lines.join('').includes('◆'));
|
|
1681
|
+
home();
|
|
1682
|
+
}
|
|
1683
|
+
|
|
1270
1684
|
// Real-time travel: with a 1x pace a trip takes time; the header counts down, the World tab carries the journey
|
|
1271
1685
|
// card (speed, pause, arrive now, turn back), and the game loop lands it.
|
|
1272
1686
|
{
|
|
@@ -1654,9 +2068,49 @@ check('b in Bags outside a bank town only flashes', app.stack.length === 0);
|
|
|
1654
2068
|
const app9 = new App({ term: term9, ds, repo });
|
|
1655
2069
|
term9.open();
|
|
1656
2070
|
const { characterCreation } = await import('./tui.mjs');
|
|
1657
|
-
let created = null;
|
|
1658
|
-
characterCreation(app9, (state) => { created = state; });
|
|
1659
|
-
|
|
2071
|
+
let created = null, cancelled = 0;
|
|
2072
|
+
characterCreation(app9, (state) => { created = state; }, { onCancel: () => { cancelled++; } });
|
|
2073
|
+
const row9 = (id) => app9.rowsOf(app9.top()).findIndex((r) => r.id === id);
|
|
2074
|
+
const text9 = () => term9.text();
|
|
2075
|
+
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()));
|
|
2076
|
+
app9.top().cursor = row9('race'); term9.press('right');
|
|
2077
|
+
check('Right on Race steps to the next race of the faction', /Race\s+Dwarf/.test(text9()));
|
|
2078
|
+
app9.top().cursor = row9('faction'); term9.press('right');
|
|
2079
|
+
check('Right on Faction switches to its first race', /Faction\s+Horde/.test(text9()) && /Race\s+Orc/.test(text9()) && /Orc · Horde/.test(text9()));
|
|
2080
|
+
term9.press('left');
|
|
2081
|
+
check('flipping back returns to the race last chosen there', /Faction\s+Alliance/.test(text9()) && /Race\s+Dwarf/.test(text9()));
|
|
2082
|
+
term9.press('right');
|
|
2083
|
+
app9.top().cursor = row9('race'); term9.press('left');
|
|
2084
|
+
check('Left on Race wraps to the faction\'s last race', /Race\s+Troll/.test(text9()));
|
|
2085
|
+
// A troll cannot be a paladin or a druid: the class row only offers what the race may roll.
|
|
2086
|
+
app9.top().cursor = row9('class');
|
|
2087
|
+
const classes9 = new Set();
|
|
2088
|
+
for (let i = 0; i < 12; i++) { term9.press('right'); classes9.add(text9().match(/Class\s+(\w+)/)[1]); }
|
|
2089
|
+
check('Class cycles through the troll classes only', classes9.has('Hunter') && classes9.has('Shaman') && !classes9.has('Paladin') && !classes9.has('Druid'));
|
|
2090
|
+
term9.press('return');
|
|
2091
|
+
check('Enter on Class opens the class list over the screen', app9.top().title === 'Class' && app9.stack.length === 2 && /Hunter/.test(text9()));
|
|
2092
|
+
term9.press('escape');
|
|
2093
|
+
check('Esc closes the list before a game is open', app9.top().id === 'create' && app9.stack.length === 1);
|
|
2094
|
+
app9.top().cursor = row9('gender'); term9.press('right');
|
|
2095
|
+
check('Right on Gender flips it', /Gender\s+Female/.test(text9()) && /Female Troll/.test(text9()));
|
|
2096
|
+
app9.top().cursor = row9('skin');
|
|
2097
|
+
const skin9 = text9().match(/Skin Color\s+(\d+) of/)[1]; term9.press('right');
|
|
2098
|
+
check('Right on a look row changes it', text9().match(/Skin Color\s+(\d+) of/)[1] !== skin9);
|
|
2099
|
+
app9.top().cursor = row9('create'); term9.press('return');
|
|
2100
|
+
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()));
|
|
2101
|
+
term9.press('n');
|
|
2102
|
+
check('n rolls a random name', /Name\s+[A-Z][a-z]+/.test(text9()));
|
|
2103
|
+
app9.top().cursor = row9('name'); term9.press('return'); term9.type('x1');
|
|
2104
|
+
check('a bad name is refused', !!app9.cmd && /Letters only/.test(text9()));
|
|
2105
|
+
term9.type('thrallina');
|
|
2106
|
+
check('typing a name lands on the Create row', !app9.cmd && app9.top().cursor === row9('create') && /Create Thrallina, Female Troll/.test(text9()));
|
|
2107
|
+
term9.press('return');
|
|
2108
|
+
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);
|
|
2109
|
+
created = null; app9.stack = [];
|
|
2110
|
+
characterCreation(app9, (state) => { created = state; }, { onCancel: () => { cancelled++; } });
|
|
2111
|
+
term9.press('escape');
|
|
2112
|
+
check('Esc on the create screen goes back to the character list', cancelled === 1);
|
|
2113
|
+
app9.top().cursor = row9('random'); term9.press('return');
|
|
1660
2114
|
check('the create screen rolls a random world bot', !!created?.char?.name && created.char.level >= 1);
|
|
1661
2115
|
term9.close?.();
|
|
1662
2116
|
home();
|
|
@@ -1794,15 +2248,42 @@ check('b in Bags outside a bank town only flashes', app.stack.length === 0);
|
|
|
1794
2248
|
app12.top().cursor = at((r, t) => /Warsong Gulch/.test(t)); term12.press('return');
|
|
1795
2249
|
if (showFrames) console.log(`\n=== warsong gulch ===\n${term12.text()}`);
|
|
1796
2250
|
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) => /
|
|
2251
|
+
app12.top().cursor = at((r, t) => /Autopilot:/.test(t)); term12.press('return'); // the orders picker
|
|
2252
|
+
app12.top().cursor = at((r, t) => /Autopilot:/.test(t)); term12.press('return'); // its job option
|
|
1798
2253
|
app12.top().cursor = at((r, t) => /guard our flag/.test(t)); term12.press('return');
|
|
1799
2254
|
check('you can change your job', app12.bg.world.units.get(app12.bg.selfId).role === 'defense');
|
|
1800
2255
|
const bg = app12.bg;
|
|
2256
|
+
check('the gulch is drawn as a strip with you on it', /Horde.*mid.*Alliance/.test(strip(term12.text())) && /@/.test(strip(term12.text())));
|
|
2257
|
+
// Your own seat by place: an order takes you off autopilot, and once the gates open you walk toward mid.
|
|
2258
|
+
const { WSG_GEOMETRY } = await import('../domain/bg/wsg.ts');
|
|
2259
|
+
const me12 = bg.world.units.get(bg.selfId);
|
|
2260
|
+
const toMid = () => Math.hypot(me12.pos.x - WSG_GEOMETRY.mid.x, me12.pos.y - WSG_GEOMETRY.mid.y);
|
|
2261
|
+
const d0 = toMid();
|
|
2262
|
+
term12.press('o');
|
|
2263
|
+
app12.top().cursor = at((r, t) => /Go to mid/.test(t)); term12.press('return');
|
|
2264
|
+
check('an order by place takes your seat off autopilot', me12.human === true && me12.order.kind === 'attack-move');
|
|
2265
|
+
stepBg(app12, bg, 300);
|
|
2266
|
+
check('and you walk there yourself', toMid() < d0 - 50 && me12.order.kind === 'attack-move');
|
|
2267
|
+
// A team call holds your side against the strategy layer for a while, then hands them back.
|
|
2268
|
+
term12.press('t');
|
|
2269
|
+
app12.top().cursor = at((r, t) => /Everyone to mid/.test(t)); term12.press('return');
|
|
2270
|
+
const called = [...bg.commanded.keys()];
|
|
2271
|
+
stepBg(app12, bg, 25); // two strategy cycles
|
|
2272
|
+
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; });
|
|
2273
|
+
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);
|
|
2274
|
+
stepBg(app12, bg, 500);
|
|
2275
|
+
check('and lapses after its time', bg.commanded.size === 0);
|
|
2276
|
+
bg.speed = 8;
|
|
2277
|
+
term12.press('j');
|
|
2278
|
+
app12.top().cursor = at((r, t) => /guard our flag/.test(t)); term12.press('return');
|
|
2279
|
+
check('autopilot takes your seat back', me12.human === false && bg.my == null);
|
|
1801
2280
|
const marks0 = g12.state.inventory.stacks.length;
|
|
1802
2281
|
let n = 0;
|
|
1803
2282
|
while (!bg.world.ended && n < 40000) { stepBg(app12, bg, 200); n += 200; }
|
|
1804
2283
|
app12.render();
|
|
1805
2284
|
check('the match plays to an end', bg.world.ended && bg.settled);
|
|
2285
|
+
check('inside its 25 minute clock, with a winner', bg.world.t <= 25 * 60_000 + 10_000 && bg.world.winner != null);
|
|
2286
|
+
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
2287
|
check('honor and marks of honor settle', bg.marks > 0 && g12.state.inventory.stacks.length > marks0 && (g12.state.char.honor?.cp ?? 0) > 0);
|
|
1807
2288
|
check('and you are out of the battleground', !g12.state.battleground);
|
|
1808
2289
|
app12.top().cursor = at((r, t) => /Leave the battleground/.test(t)); term12.press('return');
|
|
@@ -1904,9 +2385,16 @@ check('b in Bags outside a bank town only flashes', app.stack.length === 0);
|
|
|
1904
2385
|
// 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
2386
|
{
|
|
1906
2387
|
const txt = () => app.rowsOf(app.top()).map((r) => strip(r.text)).join('|');
|
|
1907
|
-
const
|
|
2388
|
+
const find = (key, q) => { home(); term.press('1'); term.press(key); for (const ch of q) term.press(ch === ' ' ? 'space' : ch); term.press('return'); };
|
|
2389
|
+
const search = (q) => find('S', q);
|
|
2390
|
+
find('s', 'hogger'); show('zone search');
|
|
2391
|
+
check('s searches the zone you stand in', app.top().id === 'world-search' && /Search Elwynn Forest/.test(strip(frame())) && /Hogger L11.*here/.test(txt()));
|
|
2392
|
+
find('s', 'deadmines');
|
|
2393
|
+
check('the zone search leaves out what is elsewhere', /Nothing by that name in Elwynn Forest/.test(txt()));
|
|
2394
|
+
term.press('a');
|
|
2395
|
+
check('a widens it to the whole world', /Deadmines.*Dungeon.*entrance in Westfall/.test(txt()));
|
|
1908
2396
|
search('mining pick'); show('world search');
|
|
1909
|
-
check('
|
|
2397
|
+
check('S opens the world search', app.top().id === 'world-search' && /Mining Pick/.test(txt()));
|
|
1910
2398
|
check('an item lists the vendors that sell it and where they stand', /sold by .*<.*>.*(here|Stormwind|Westfall|Loch Modan|Redridge)/.test(txt()));
|
|
1911
2399
|
search('copper vein'); show('world search node');
|
|
1912
2400
|
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 +2416,114 @@ check('b in Bags outside a bank town only flashes', app.stack.length === 0);
|
|
|
1928
2416
|
term.press('?'); check('help overlay', app.top().id === 'help'); term.press('escape');
|
|
1929
2417
|
check('help closed', app.stack.length === 0);
|
|
1930
2418
|
|
|
2419
|
+
// Pre-pull buffs: a mage buffs standing still, the autopilot drinks back what that cost, and the pull opens at
|
|
2420
|
+
// full mana on an attack. Casting the buffs phase on the fight's first ticks sent it in at three quarters.
|
|
2421
|
+
{
|
|
2422
|
+
const termB = new HeadlessTerm(110, 34);
|
|
2423
|
+
const appB = new App({ term: termB, ds, repo });
|
|
2424
|
+
termB.open();
|
|
2425
|
+
appB.startGame(newGameStateFromWorldBot(ds, { id: 'buffcheck', index: 0, name: 'Buffcheck', gender: 0, race: 1, cls: 8, faction: undefined, level: 20, specIdx: 0, seed: 7 }, 7), 'buffcheck', Date.now(), true);
|
|
2426
|
+
const gB = appB.g;
|
|
2427
|
+
const wolf = ds.zone(gB.state.char.zoneId).huntable[0];
|
|
2428
|
+
const hasAI = () => (gB.state.char.activeAuras ?? []).some((a) => ds.spell(a.spellId)?.name === 'Arcane Intellect');
|
|
2429
|
+
const full = () => gB.state.char.power === maxPower(gB.state.char, ds);
|
|
2430
|
+
let pulledFull = null, pulledBuffed = null;
|
|
2431
|
+
const ap = appB.cmds.createAutopilot(`farm ${ds.creature(wolf).name}`, { maxSteps: 6, liveFight: (step, settle) => { pulledFull ??= full(); pulledBuffed ??= hasAI(); settle({ outcome: 'win' }); return { ok: true }; } });
|
|
2432
|
+
ap.next();
|
|
2433
|
+
check('autopilot buffs before the pull, then rests instead of pulling', ap.lastKind === 'buff' && hasAI() && !full());
|
|
2434
|
+
for (let i = 0; i < 5 && pulledFull == null; i++) ap.next();
|
|
2435
|
+
check('autopilot pulls buffed and at full mana', pulledFull === true && pulledBuffed === true);
|
|
2436
|
+
gB.state.char.activeAuras = (gB.state.char.activeAuras ?? []).filter((a) => ds.spell(a.spellId)?.name !== 'Arcane Intellect');
|
|
2437
|
+
gB.state.char.activeBuffs = (gB.state.char.activeBuffs ?? []).filter((id) => ds.spell(id)?.name !== 'Arcane Intellect');
|
|
2438
|
+
const from = appB.log.length;
|
|
2439
|
+
startFight(appB, wolf);
|
|
2440
|
+
const buffedFirst = hasAI();
|
|
2441
|
+
for (let i = 0; i < 2000 && appB.fight; i++) appB.fight.tick();
|
|
2442
|
+
const casts = appB.log.slice(from).map(strip).filter((l) => /^\s*[\d.]+s You cast /.test(l));
|
|
2443
|
+
check('a plain fight buffs before it starts and opens on an attack', buffedFirst && casts.length > 0 && !casts.some((l) => /Arcane Intellect/.test(l)));
|
|
2444
|
+
// A dungeon stop: the same buff, then the party drinks back to the rest line before the pull, and again before
|
|
2445
|
+
// the next one. Pulling straight after the armour and conjures opened Rhahk'Zor at 761 of 1966.
|
|
2446
|
+
const { openRun, pullStop } = await import('./tuiDungeon.mjs');
|
|
2447
|
+
const { nextEncounter } = await import('../world/instances.ts');
|
|
2448
|
+
for (const [cls, role] of [[1, 'tank'], [2, 'healer']]) gB.recruitCompanion(cls, role, 'twink');
|
|
2449
|
+
for (const c of gB.state.squad) c.level = 20;
|
|
2450
|
+
gB.state.char.activeAuras = []; gB.state.char.activeBuffs = [];
|
|
2451
|
+
gB.regenTick(600, true);
|
|
2452
|
+
const dm = ds.instance('deadmines');
|
|
2453
|
+
check('a level 20 mage party can enter the Deadmines', gB.enterInstance('deadmines', Date.now()).ok);
|
|
2454
|
+
appB.stack = []; openRun(appB);
|
|
2455
|
+
const openPct = [];
|
|
2456
|
+
for (let i = 0; i < 2; i++) {
|
|
2457
|
+
pullStop(appB, dm, nextEncounter(dm, gB.state.instance));
|
|
2458
|
+
const pv = appB.fight?.session.playerView();
|
|
2459
|
+
openPct.push(pv ? pv.power / maxPower(gB.state.char, ds) : 0);
|
|
2460
|
+
for (let n = 0; n < 8000 && appB.fight; n++) appB.fight.tick();
|
|
2461
|
+
appB.fight?.release?.();
|
|
2462
|
+
}
|
|
2463
|
+
check('a dungeon stop buffs, then drinks back before the pull', hasAI() && openPct[0] >= 0.9);
|
|
2464
|
+
check('the next stop rests back up too', openPct[1] >= 0.9);
|
|
2465
|
+
}
|
|
2466
|
+
|
|
2467
|
+
// A foe that resets ends the fight. The engine keeps the session open with the mob passive at full HP and the
|
|
2468
|
+
// rotation idle, which held a Razormane Scout fight for 970 s. Here a Quilboar is pinned 50 yd out once it is
|
|
2469
|
+
// in combat, so it cannot close and evades; the fight must settle as a stalemate a few seconds later.
|
|
2470
|
+
{
|
|
2471
|
+
const termQ = new HeadlessTerm(110, 34);
|
|
2472
|
+
const appQ = new App({ term: termQ, ds, repo });
|
|
2473
|
+
termQ.open();
|
|
2474
|
+
appQ.startGame(newGameStateFromWorldBot(ds, { id: 'quietcheck', index: 0, name: 'Quietcheck', gender: 0, race: 2, cls: 7, faction: undefined, level: 11, specIdx: 0, seed: 5 }, 5), 'quietcheck', Date.now(), true);
|
|
2475
|
+
const boar = ds.creatures().find((c) => c.name === 'Razormane Quilboar');
|
|
2476
|
+
const from = appQ.log.length;
|
|
2477
|
+
startFight(appQ, boar.entry);
|
|
2478
|
+
const s = appQ.fight.session, side = s.enemiesView()[0].side;
|
|
2479
|
+
let engagedAt = null, ticks = 0;
|
|
2480
|
+
for (; ticks < 1200 && appQ.fight && !appQ.fight.ended; ticks++) {
|
|
2481
|
+
if (engagedAt == null && s.enemiesView()[0].engaged) engagedAt = s.t;
|
|
2482
|
+
s.setUnitPosYd('player', { x: 0, y: 0 });
|
|
2483
|
+
if (engagedAt != null) s.setUnitPosYd(side, { x: 50, y: 0 });
|
|
2484
|
+
appQ.fight.tick();
|
|
2485
|
+
}
|
|
2486
|
+
const said = appQ.log.slice(from).map(strip);
|
|
2487
|
+
const reset = said.some((l) => /cannot reach you and resets/.test(l));
|
|
2488
|
+
check(`a foe that resets ends the fight as a stalemate (${(s.t / 1000).toFixed(1)} s, reset ${reset})`,
|
|
2489
|
+
reset && said.some((l) => /resets and the fight is over/.test(l)) && said.some((l) => /^Stalemate against/.test(l)) && s.t < 20_000);
|
|
2490
|
+
appQ.fight?.release?.();
|
|
2491
|
+
}
|
|
2492
|
+
|
|
2493
|
+
// c on the World tab saves, lets go of the character and hands the screen back to the picker.
|
|
2494
|
+
{
|
|
2495
|
+
const termL = new HeadlessTerm(110, 34);
|
|
2496
|
+
const repoL = { saved: 0, unlocked: [], async save() { this.saved++; }, async list() { return []; }, async load() { return null; }, unlock(s) { this.unlocked.push(s); } };
|
|
2497
|
+
const appL = new App({ term: termL, ds, repo: repoL });
|
|
2498
|
+
termL.open();
|
|
2499
|
+
appL.startGame(newGameState(ds, human.id, 1, 'Logouttest', 777, 0), 'logouttest', Date.now(), true);
|
|
2500
|
+
let picked = 0;
|
|
2501
|
+
appL.toPicker = async () => { picked++; };
|
|
2502
|
+
termL.press('c');
|
|
2503
|
+
await new Promise((r) => setImmediate(r));
|
|
2504
|
+
check('c on World saves, unlocks and goes back to the character list', !appL.g && picked === 1 && repoL.saved > 0 && repoL.unlocked.includes('logouttest') && !appL.timers.length);
|
|
2505
|
+
}
|
|
2506
|
+
|
|
2507
|
+
// Sixel art, last: once on it stays on for the process. The terminal says it draws sixel (DA1 parameter 4);
|
|
2508
|
+
// art then travels as placeholders whose markers the terminal layer takes out and places.
|
|
2509
|
+
{
|
|
2510
|
+
noteDeviceAttributes('?62;4;22');
|
|
2511
|
+
const art = creatureArt(2332, 28, 14);
|
|
2512
|
+
const mark = art?.[0]?.match(/\x1b\[9999;(\d+);0m/);
|
|
2513
|
+
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 ?? ''));
|
|
2514
|
+
// The shipped worn pack fills what the downloaded hd pack lacks: forms and costumes draw sharp too.
|
|
2515
|
+
check('sixel on: cat and bear form draw as pixels, not half-blocks', [892, 2281].every((id) => /\x1b\[9999;/.test(creatureArt(id, 28, 14)?.[0] ?? '')));
|
|
2516
|
+
const placed = new Map();
|
|
2517
|
+
const rows = art.map((l, r) => takeMarks(`ab${l}${l}`, 5 + r, placed));
|
|
2518
|
+
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')));
|
|
2519
|
+
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);
|
|
2520
|
+
const map = worldMapLines(app, { maxCols: 40, maxRows: 12 });
|
|
2521
|
+
check('the world map is a placeholder too, with its legend under it', !!map && map.length === 12 && /\x1b\[9999;/.test(map[0]) && /^\x1bP0;1;0q"1;1;\d+;\d+#/.test(imageOf(+map[0].match(/\x1b\[9999;(\d+);/)[1])?.sixel ?? '') && /you/.test(strip(map[11])));
|
|
2522
|
+
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')));
|
|
2523
|
+
const icon = iconCells('spell_holy_holysmite', 6, '', [{ x: 4, y: 2, w: 2 }]);
|
|
2524
|
+
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 ?? ''));
|
|
2525
|
+
}
|
|
2526
|
+
|
|
1931
2527
|
check('frames were drawn', term.frames > 20);
|
|
1932
2528
|
check('saves happened', repo.saved > 0);
|
|
1933
2529
|
console.log(failures ? `\n${failures} check(s) failed` : '\nall TUI checks passed');
|