azeroth-shell 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +326 -82
- package/bin/azsh.mjs +21 -19
- package/package.json +19 -1
- package/src/autopilot/plan.ts +8 -11
- package/src/autopilot/view.ts +12 -11
- package/src/cli/combatLogFormat.ts +22 -0
- package/src/cli/fileRepository.mjs +46 -1
- package/src/cli/gzLoader.mjs +47 -6
- package/src/cli/hdArt.mjs +81 -0
- package/src/cli/iconArt.mjs +135 -0
- package/src/cli/iconGrid.mjs +100 -0
- package/src/cli/modelArt.mjs +295 -0
- package/src/cli/paperdoll.mjs +76 -0
- package/src/cli/perfCheck.mjs +56 -0
- package/src/cli/repl.mjs +36 -7
- package/src/cli/rotationText.mjs +6 -6
- package/src/cli/sixel.mjs +307 -0
- package/src/cli/term.mjs +106 -12
- package/src/cli/text.mjs +5 -3
- package/src/cli/tui.mjs +252 -98
- package/src/cli/tuiAutopilot.mjs +347 -0
- package/src/cli/tuiBg.mjs +371 -42
- package/src/cli/tuiCheck.mjs +415 -28
- package/src/cli/tuiCity.mjs +118 -13
- package/src/cli/tuiCompanions.mjs +25 -15
- package/src/cli/tuiCreate.mjs +223 -0
- package/src/cli/tuiDungeon.mjs +1 -1
- package/src/cli/tuiFight.mjs +425 -88
- package/src/cli/tuiFightPanes.mjs +27 -8
- package/src/cli/tuiFloor.mjs +2 -2
- package/src/cli/tuiGear.mjs +10 -6
- package/src/cli/tuiLoot.mjs +1 -0
- package/src/cli/tuiPaperdoll.mjs +107 -0
- package/src/cli/tuiPartyStage.mjs +177 -0
- package/src/cli/tuiProfessions.mjs +4 -2
- package/src/cli/tuiRotationCond.mjs +390 -0
- package/src/cli/tuiSaves.mjs +22 -3
- package/src/cli/tuiSearch.mjs +75 -22
- package/src/cli/tuiSettings.mjs +15 -0
- package/src/cli/tuiSimLab.mjs +1 -1
- package/src/cli/tuiStage.mjs +304 -0
- package/src/cli/tuiTalentTree.mjs +124 -0
- package/src/cli/tuiViews.mjs +296 -115
- package/src/cli/tuiWorld.mjs +4 -5
- package/src/data/gear-art.bin.br +0 -0
- package/src/data/hd-art.json +114 -0
- package/src/data/icon-art.bin.gz +0 -0
- package/src/data/model-art.bin.gz +0 -0
- package/src/data/rotations/pve/paladin/retribution.json +2 -2
- package/src/domain/bg/match.ts +85 -24
- package/src/domain/bg/strategies.ts +144 -29
- package/src/domain/bg/types.ts +2 -0
- package/src/domain/bg/wsg.ts +35 -20
- package/src/domain/quests/doQuest.ts +24 -6
- package/src/domain/rotation/build.ts +3031 -3031
- package/src/domain/rotation/classes/paladin.ts +16 -11
- package/src/domain/rotation/evaluate.ts +3 -0
- package/src/engine/combat/applySpell.ts +20 -9
- package/src/engine/combat/applySpellLate.ts +16 -12
- package/src/engine/combat/areaAura.ts +284 -283
- package/src/engine/combat/fighterFactory.ts +1 -1
- package/src/engine/combat/pickAction.ts +39 -1
- package/src/engine/combat/procsDamage.ts +31 -1
- package/src/engine/combat/sessionChase.ts +7 -1
- package/src/engine/combat/sessionMotionKit.ts +40 -2
- package/src/quests/quests.ts +60 -0
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
|
}
|
|
@@ -1071,6 +1314,15 @@ check('b in Bags outside a bank town only flashes', app.stack.length === 0);
|
|
|
1071
1314
|
app.top().cursor = rowIndex((r, t) => /^\d+\s+empty$/.test(t.trim()) && !('b' in (r.data ?? {}))); term.press('return');
|
|
1072
1315
|
app.top().cursor = rowIndex((r, t) => t.trim() === potion.name); term.press('return');
|
|
1073
1316
|
check('a global bar slot takes a bag item', (g.state.globalActionBar?.items ?? []).includes(potion.entry));
|
|
1317
|
+
g.state.char.health = Math.max(1, Math.floor(g.state.char.health / 2));
|
|
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 = {};
|
|
1321
|
+
app.top().cursor = rowIndex((r, t) => /Fire the whole global bar/.test(t)); term.press('return');
|
|
1322
|
+
check('firing the global bar drinks the potion', countItem(g.state.inventory, potion.entry) === potions - 1);
|
|
1323
|
+
app.top().cursor = rowIndex((r, t) => t.includes(potion.name) && !('b' in (r.data ?? {}))); term.press('return');
|
|
1324
|
+
app.top().cursor = rowIndex((r, t) => t.trim() === 'Clear'); term.press('return');
|
|
1325
|
+
check('Clear empties the global slot', !(g.state.globalActionBar?.items ?? []).includes(potion.entry));
|
|
1074
1326
|
term.press('escape'); home(); term.press('1');
|
|
1075
1327
|
g.state.char.health = Math.max(1, Math.floor(g.state.char.health / 2));
|
|
1076
1328
|
const mob = app.rowsOf(app.top()).findIndex((r) => r.data?.creature);
|
|
@@ -1268,6 +1520,17 @@ check('b in Bags outside a bank town only flashes', app.stack.length === 0);
|
|
|
1268
1520
|
home(); term.press(':'); for (const ch of 'settings') term.press(ch); term.press('return');
|
|
1269
1521
|
pickRow(/^Travel pace/);
|
|
1270
1522
|
check('Settings sets a real-time travel pace', app.prefs.get('travelPace') === 1);
|
|
1523
|
+
pickRow(/^Screen refresh/);
|
|
1524
|
+
check('Settings steps the screen refresh up from 100 ms', app.prefs.get('refreshMs') === 250);
|
|
1525
|
+
// On a real terminal a game loop's repaint waits out the refresh; a key's render paints at once and drops it.
|
|
1526
|
+
term.isTTY = true;
|
|
1527
|
+
app.render(); const painted = term.frames;
|
|
1528
|
+
app.renderSoon(); app.renderSoon();
|
|
1529
|
+
check('renderSoon holds a repaint inside the refresh window', term.frames === painted && app.paintTimer != null);
|
|
1530
|
+
app.render();
|
|
1531
|
+
check('render paints and cancels the held repaint', term.frames === painted + 1 && app.paintTimer == null);
|
|
1532
|
+
term.isTTY = false;
|
|
1533
|
+
app.prefs.set('refreshMs', 100);
|
|
1271
1534
|
home();
|
|
1272
1535
|
const here = g.state.char.zoneId;
|
|
1273
1536
|
const dest = (ds.zone(here)?.neighbors ?? []).map((id) => ds.zone(id)).find((z) => z && g.previewTravel(z.id).ok);
|
|
@@ -1275,7 +1538,8 @@ check('b in Bags outside a bank town only flashes', app.stack.length === 0);
|
|
|
1275
1538
|
show('on the road');
|
|
1276
1539
|
check('a trip at 1x takes time', !!g.state.char.journey && g.state.char.zoneId !== dest.id);
|
|
1277
1540
|
check('the header counts it down', strip(frame()).split('\n')[0].includes(`→ ${dest.name}`));
|
|
1278
|
-
check('the
|
|
1541
|
+
check('the Travel tab stays open, on the journey card', app.tab === 7 && /On the road to/.test(strip(frame())));
|
|
1542
|
+
term.press('1'); check('the World tab carries the card too', /On the road to/.test(strip(frame()))); term.press('8');
|
|
1279
1543
|
app.top().cursor = rowIndex((r, t) => /journey options/.test(t)); term.press('return');
|
|
1280
1544
|
pickRow(/^Travel at 4x/);
|
|
1281
1545
|
check('the trip can be sped up', g.journeyStatus()?.timeScale === 4);
|
|
@@ -1635,9 +1899,49 @@ check('b in Bags outside a bank town only flashes', app.stack.length === 0);
|
|
|
1635
1899
|
const app9 = new App({ term: term9, ds, repo });
|
|
1636
1900
|
term9.open();
|
|
1637
1901
|
const { characterCreation } = await import('./tui.mjs');
|
|
1638
|
-
let created = null;
|
|
1639
|
-
characterCreation(app9, (state) => { created = state; });
|
|
1640
|
-
|
|
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');
|
|
1641
1945
|
check('the create screen rolls a random world bot', !!created?.char?.name && created.char.level >= 1);
|
|
1642
1946
|
term9.close?.();
|
|
1643
1947
|
home();
|
|
@@ -1685,6 +1989,39 @@ check('b in Bags outside a bank town only flashes', app.stack.length === 0);
|
|
|
1685
1989
|
await open();
|
|
1686
1990
|
await choose(/^Keeper/);
|
|
1687
1991
|
check('Enter on a save still plays it', played?.state?.char?.name === 'Keeper');
|
|
1992
|
+
// Locks: one character per process. A second repo stands in for a second terminal (same pid, so a
|
|
1993
|
+
// hand-written lock with a foreign live pid plays the other side; a dead pid is a stale lock).
|
|
1994
|
+
const { writeFileSync: writeLock, readFileSync: readLock } = await import('node:fs');
|
|
1995
|
+
const other = new FileSaveRepository(join(dir, 'saves'));
|
|
1996
|
+
check('lock() takes a free slot', repo10.lock('keeper').ok === true && existsSync(repo10.lockPath('keeper')));
|
|
1997
|
+
check('a lock we hold reads as free to us', repo10.lockedBy('keeper') === null);
|
|
1998
|
+
check('and as free to a second repo in the same process too', other.lockedBy('keeper') === null); // same pid: a reopen in-process is not a second terminal
|
|
1999
|
+
check('a different slot locks independently', other.lock('elsewhere').ok === true);
|
|
2000
|
+
writeLock(other.lockPath('ghost'), JSON.stringify({ pid: 2_000_000_000, at: 1 })); // no such process
|
|
2001
|
+
check('a stale lock (dead pid) is taken over', other.lock('ghost').ok === true && JSON.parse(readLock(other.lockPath('ghost'), 'utf8')).pid === process.pid);
|
|
2002
|
+
const livePid = process.ppid || process.pid + 1; // the parent shell is alive and is not us
|
|
2003
|
+
const foreign = JSON.stringify({ pid: livePid, at: Date.now() });
|
|
2004
|
+
writeLock(other.lockPath('taken'), foreign);
|
|
2005
|
+
const refused = other.lock('taken');
|
|
2006
|
+
check('a live lock from another process is refused', refused.ok === false && refused.pid === livePid && readLock(other.lockPath('taken'), 'utf8') === foreign);
|
|
2007
|
+
other.unlock('taken');
|
|
2008
|
+
check('unlock() never removes a lock we do not hold', existsSync(other.lockPath('taken')));
|
|
2009
|
+
repo10.unlock('keeper');
|
|
2010
|
+
check('unlock() releases ours', !existsSync(repo10.lockPath('keeper')));
|
|
2011
|
+
// The picker: a character held by another live process is marked and refuses to play or be managed.
|
|
2012
|
+
writeLock(repo10.lockPath('keeper'), foreign);
|
|
2013
|
+
played = null;
|
|
2014
|
+
await open(); show('save picker, keeper open elsewhere');
|
|
2015
|
+
const marked = () => app10.rowsOf(app10.top()).some((r) => /open in another terminal/.test(strip(r.text)));
|
|
2016
|
+
check('the picker marks a character open in another terminal', marked());
|
|
2017
|
+
await choose(/^Keeper.*open in another terminal/); // the imported copy is a Keeper too
|
|
2018
|
+
check('Enter refuses to play it', played === null && app10.log.map(strip).some((l) => /Keeper is open in another terminal/.test(l)));
|
|
2019
|
+
app10.top().cursor = at((r, t) => /^Keeper.*open in another terminal/.test(t)); term10.press('m'); await tick();
|
|
2020
|
+
check('m refuses to manage it', app10.top()?.title === 'Choose a character' && app10.log.map(strip).filter((l) => /Keeper is open in another terminal/.test(l)).length === 2);
|
|
2021
|
+
check('startGame on a held slot backs out', app10.startGame(newGameState(ds, human.id, 1, 'Keeper', 31, 0), 'keeper', Date.now(), true) === false && app10.g == null);
|
|
2022
|
+
writeLock(repo10.lockPath('keeper'), JSON.stringify({ pid: 2_000_000_000, at: 1 }));
|
|
2023
|
+
await open();
|
|
2024
|
+
check('a stale lock does not mark the character', !marked());
|
|
1688
2025
|
term10.close?.();
|
|
1689
2026
|
rmSync(dir, { recursive: true, force: true });
|
|
1690
2027
|
if (exported) rmSync(exported, { force: true });
|
|
@@ -1742,15 +2079,42 @@ check('b in Bags outside a bank town only flashes', app.stack.length === 0);
|
|
|
1742
2079
|
app12.top().cursor = at((r, t) => /Warsong Gulch/.test(t)); term12.press('return');
|
|
1743
2080
|
if (showFrames) console.log(`\n=== warsong gulch ===\n${term12.text()}`);
|
|
1744
2081
|
check('the honor window starts Warsong Gulch', app12.top()?.id === 'bg' && !!g12.state.battleground && /flag/.test(strip(term12.text())));
|
|
1745
|
-
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
|
|
1746
2084
|
app12.top().cursor = at((r, t) => /guard our flag/.test(t)); term12.press('return');
|
|
1747
2085
|
check('you can change your job', app12.bg.world.units.get(app12.bg.selfId).role === 'defense');
|
|
1748
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);
|
|
1749
2111
|
const marks0 = g12.state.inventory.stacks.length;
|
|
1750
2112
|
let n = 0;
|
|
1751
2113
|
while (!bg.world.ended && n < 40000) { stepBg(app12, bg, 200); n += 200; }
|
|
1752
2114
|
app12.render();
|
|
1753
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())));
|
|
1754
2118
|
check('honor and marks of honor settle', bg.marks > 0 && g12.state.inventory.stacks.length > marks0 && (g12.state.char.honor?.cp ?? 0) > 0);
|
|
1755
2119
|
check('and you are out of the battleground', !g12.state.battleground);
|
|
1756
2120
|
app12.top().cursor = at((r, t) => /Leave the battleground/.test(t)); term12.press('return');
|
|
@@ -1852,9 +2216,16 @@ check('b in Bags outside a bank town only flashes', app.stack.length === 0);
|
|
|
1852
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.
|
|
1853
2217
|
{
|
|
1854
2218
|
const txt = () => app.rowsOf(app.top()).map((r) => strip(r.text)).join('|');
|
|
1855
|
-
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()));
|
|
1856
2227
|
search('mining pick'); show('world search');
|
|
1857
|
-
check('
|
|
2228
|
+
check('S opens the world search', app.top().id === 'world-search' && /Mining Pick/.test(txt()));
|
|
1858
2229
|
check('an item lists the vendors that sell it and where they stand', /sold by .*<.*>.*(here|Stormwind|Westfall|Loch Modan|Redridge)/.test(txt()));
|
|
1859
2230
|
search('copper vein'); show('world search node');
|
|
1860
2231
|
check('a gather node lists the zones it grows in', /Copper Vein.*Mining.*(here|Dun Morogh|Durotar|Mulgore|Westfall|Loch Modan)/.test(txt()));
|
|
@@ -1876,6 +2247,22 @@ check('b in Bags outside a bank town only flashes', app.stack.length === 0);
|
|
|
1876
2247
|
term.press('?'); check('help overlay', app.top().id === 'help'); term.press('escape');
|
|
1877
2248
|
check('help closed', app.stack.length === 0);
|
|
1878
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
|
+
|
|
1879
2266
|
check('frames were drawn', term.frames > 20);
|
|
1880
2267
|
check('saves happened', repo.saved > 0);
|
|
1881
2268
|
console.log(failures ? `\n${failures} check(s) failed` : '\nall TUI checks passed');
|