azeroth-shell 0.2.0 → 0.4.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/CHANGELOG.md +53 -0
- package/README.md +43 -10
- package/bin/azsh.mjs +15 -1
- package/package.json +7 -3
- package/src/cli/hdArt.mjs +1 -1
- package/src/cli/iconArt.mjs +11 -1
- package/src/cli/modelArt.mjs +7 -4
- package/src/cli/oocPrep.mjs +32 -0
- package/src/cli/repl.mjs +16 -0
- package/src/cli/sixel.mjs +26 -14
- package/src/cli/text.mjs +16 -0
- package/src/cli/tui.mjs +40 -13
- package/src/cli/tuiAutopilot.mjs +17 -8
- package/src/cli/tuiBags.mjs +37 -4
- package/src/cli/tuiBulk.mjs +83 -4
- package/src/cli/tuiCheck.mjs +328 -19
- package/src/cli/tuiCity.mjs +31 -31
- package/src/cli/tuiCompanionKit.mjs +26 -8
- package/src/cli/tuiCompanions.mjs +189 -29
- package/src/cli/tuiCreate.mjs +7 -0
- package/src/cli/tuiDungeon.mjs +113 -33
- package/src/cli/tuiFight.mjs +63 -21
- package/src/cli/tuiFightPanes.mjs +15 -6
- package/src/cli/tuiFloor.mjs +20 -8
- package/src/cli/tuiGear.mjs +9 -10
- package/src/cli/tuiLoot.mjs +5 -6
- package/src/cli/tuiMap.mjs +214 -0
- package/src/cli/tuiPaperdoll.mjs +6 -5
- package/src/cli/tuiPartyStage.mjs +36 -7
- package/src/cli/tuiPet.mjs +16 -8
- package/src/cli/tuiProfessions.mjs +23 -23
- package/src/cli/tuiSaves.mjs +14 -0
- package/src/cli/tuiSearch.mjs +4 -5
- package/src/cli/tuiSettings.mjs +3 -4
- package/src/cli/tuiStage.mjs +72 -16
- package/src/cli/tuiTalents.mjs +3 -4
- package/src/cli/tuiViews.mjs +374 -85
- package/src/cli/tuiWiki.mjs +2 -3
- package/src/cli/tuiWorld.mjs +88 -5
- package/src/data/gear-art.bin.br +0 -0
- package/src/data/hd-art.json +36 -36
- package/src/data/model-art-worn.bin.br +0 -0
- package/src/data/model-art.bin.gz +0 -0
- 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/character/character.ts +6 -0
- package/src/domain/pet/pet.ts +31 -0
- package/src/engine/combat/pickAction.ts +3494 -3487
- package/src/engine/combat/sessionChase.ts +12 -1
- package/src/engine/game/loadReconcile.ts +21 -1
- package/src/engine/game/pets.ts +33 -1
- package/src/engine/game/professions.ts +4 -0
- package/src/engine/game/squadQuestShare.ts +13 -1
- package/src/engine/game/types.ts +4 -0
package/src/cli/tuiCheck.mjs
CHANGED
|
@@ -9,16 +9,21 @@ import { iconCells } from './iconArt.mjs';
|
|
|
9
9
|
import { App } from './tui.mjs';
|
|
10
10
|
import { bootDataStore } from './bootData.mjs';
|
|
11
11
|
import { newGameState } from '../engine/game.ts';
|
|
12
|
-
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';
|
|
13
16
|
import { visibleBars } from './tuiBars.mjs';
|
|
14
17
|
import { defaultConditionsForSpell } from './tuiRotationCond.mjs';
|
|
15
18
|
import { creatureArt, hasArt, artWidth } from './modelArt.mjs';
|
|
16
19
|
import { nativeDisplayId } from '../domain/character/displayForm.ts';
|
|
17
20
|
import { paperdollLayers, paperdollArt } from './paperdoll.mjs';
|
|
21
|
+
import { playerArt, questDialog } from './tuiViews.mjs';
|
|
22
|
+
import { worldMapLines } from './tuiMap.mjs';
|
|
18
23
|
import { iconsOn, withIcon, wrapIconLines } from './iconArt.mjs';
|
|
19
24
|
import { addItem, countItem } from '../domain/items/inventory.ts';
|
|
20
|
-
import { moneyText as moneyTextOf } from '../domain/economy/money.ts';
|
|
21
25
|
import { offeredQuests } from '../quests/quests.ts';
|
|
26
|
+
import { isTalkToNpcQuest } from '../domain/quests/questlog.ts';
|
|
22
27
|
|
|
23
28
|
const showFrames = process.argv.includes('--frames');
|
|
24
29
|
let failures = 0;
|
|
@@ -75,13 +80,38 @@ check('rotation tab shows rules', (term.press('6'), /Combat \(checked/.test(fram
|
|
|
75
80
|
check('talents tab renders', (term.press('7'), /Talents unlock|point/.test(frame())));
|
|
76
81
|
check('0 opens the tenth tab', (term.press('0'), app.tab === 9));
|
|
77
82
|
const lastTab = app.tabs.length - 1;
|
|
78
|
-
check('
|
|
79
|
-
check('the character tab
|
|
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()));
|
|
80
87
|
{
|
|
81
88
|
// Starting gear is a shirt, pants, boots and a weapon or two: at least three of them have an appearance overlay.
|
|
82
89
|
const p = paperdollLayers(app.ch);
|
|
83
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)));
|
|
84
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
|
+
{
|
|
111
|
+
// What a fight turns into or brings in is drawn too: Dathrohan's morph to Balnazzar, a shaman's totem, a
|
|
112
|
+
// witch doctor's ward. None is spawned anywhere, so the bake follows each kit. Cite: bake-model-art.mjs addFightCreatures.
|
|
113
|
+
check('a boss morph, a totem and a ward have art', [10813, 3903, 3560].every((e) => hasArt(app.ds.creature(e)?.displayId)));
|
|
114
|
+
}
|
|
85
115
|
if (iconsOn()) {
|
|
86
116
|
// A big window: the Gear tab is the character sheet and the Talents tab the three trees, both icon grids
|
|
87
117
|
// the arrows walk in two dimensions. At the usual 110x34 both list instead (the checks above).
|
|
@@ -90,7 +120,8 @@ if (iconsOn()) {
|
|
|
90
120
|
const slot = () => app.rowsOf(app.top())[app.top().cursor]?.cell?.slot;
|
|
91
121
|
const rowIndexOfSlot = (s) => app.rowsOf(app.top()).findIndex((r) => r.cell?.slot === s);
|
|
92
122
|
app.switchTab(3);
|
|
93
|
-
check('a big window draws the
|
|
123
|
+
check('a big window draws the Character tab as the sheet', !!app.top().gridNav && frame().includes(app.ch.name) && /Head/.test(frame()));
|
|
124
|
+
check('the sheet keeps your stats under the tooltip', /Strength/.test(frame()) && /Attack pwr/.test(frame()));
|
|
94
125
|
app.top().cursor = 0; app.render();
|
|
95
126
|
term.press('shift-right');
|
|
96
127
|
const across = slot();
|
|
@@ -114,15 +145,16 @@ if (iconsOn()) {
|
|
|
114
145
|
const next = app.rowsOf(app.top())[app.top().cursor].node;
|
|
115
146
|
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
147
|
term.cols = cols; term.rows = rows;
|
|
117
|
-
app.switchTab(
|
|
148
|
+
app.switchTab(3); term.press('s');
|
|
118
149
|
// A tooltip's long sentence wraps beside its icon while they share rows, not back under the art.
|
|
119
150
|
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
151
|
check('tooltip text wraps beside its icon', tip.slice(0, 4).every((l) => l.startsWith('\x1b[38;2;')) && tip.length > 4);
|
|
121
152
|
}
|
|
122
153
|
{
|
|
123
154
|
const charRows = app.rowsOf(app.top()).map((r) => strip(r.text)).join('|');
|
|
124
|
-
check('the
|
|
155
|
+
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));
|
|
125
156
|
}
|
|
157
|
+
home(); app.switchTab(lastTab);
|
|
126
158
|
term.press('right'); check('Right wraps to the first tab', app.tab === 0);
|
|
127
159
|
term.press('left'); check('Left wraps to the last tab', app.tab === lastTab);
|
|
128
160
|
term.press('escape'); check('Esc on a tab returns to World', app.tab === 0);
|
|
@@ -320,6 +352,7 @@ if (app.auto) {
|
|
|
320
352
|
show('autopilot between fights');
|
|
321
353
|
app.fightPane = 'loot'; app.render();
|
|
322
354
|
check('between fights the autopilot keeps the fight pane', /Loot this sitting/.test(strip(frame())));
|
|
355
|
+
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())));
|
|
323
356
|
term.press('tab'); check('Tab between autopilot fights cycles the pane, not the tabs', app.fightPane === 'run' && !app.auto.browsing);
|
|
324
357
|
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
358
|
show('autopilot run pane');
|
|
@@ -331,7 +364,8 @@ if (app.auto) {
|
|
|
331
364
|
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
365
|
show('autopilot quests pane');
|
|
333
366
|
app.fightPane = 'bags'; app.render();
|
|
334
|
-
check('the Bags pane shows the bags with a cursor', /^ Bags\s/m.test(strip(frame())) &&
|
|
367
|
+
check('the Bags pane shows the bags with a cursor', /^ Bags\s/m.test(strip(frame())) && /^▶/m.test(strip(frame())));
|
|
368
|
+
if (iconsOn()) check('the Bags pane draws an icon beside each item', /^▶▀▀▀▀ \S/m.test(strip(frame())));
|
|
335
369
|
const bagCursor = app.bagsPane.cursor; term.press('down');
|
|
336
370
|
check('Down moves the Bags pane cursor', app.bagsPane.cursor > bagCursor && !!app.auto);
|
|
337
371
|
const logBefore = app.log.length; term.press('return');
|
|
@@ -406,6 +440,13 @@ if (doRow >= 0) {
|
|
|
406
440
|
// Rotation editing through the menu: toggle a rule, add one, give it a condition, delete it.
|
|
407
441
|
home(); term.press('6');
|
|
408
442
|
show('rotation');
|
|
443
|
+
check('rotation phases start closed', !app.rowsOf(app.top()).some((r) => r.data?.rule));
|
|
444
|
+
app.top().cursor = rowIndex((r) => r.data?.toggle && r.data.phase === 'pull'); term.press('return');
|
|
445
|
+
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);
|
|
446
|
+
term.press('return');
|
|
447
|
+
check('Enter again closes it', !app.rowsOf(app.top()).some((r) => r.data?.rule));
|
|
448
|
+
app.top().cursor = rowIndex((r) => r.data?.toggle && r.data.phase === 'combat'); term.press('return');
|
|
449
|
+
show('rotation, combat open');
|
|
409
450
|
const ruleRow = app.rowsOf(app.top()).findIndex((r) => r.data?.rule);
|
|
410
451
|
const ruleData = app.rowsOf(app.top())[ruleRow].data;
|
|
411
452
|
app.top().cursor = ruleRow; term.press('return');
|
|
@@ -655,7 +696,18 @@ check('b in Bags outside a bank town only flashes', app.stack.length === 0);
|
|
|
655
696
|
if (hire >= 0) {
|
|
656
697
|
app.top().cursor = hire; term.press('return'); show('hire list');
|
|
657
698
|
check('the hire list opened', app.top().id === 'hire' && rowIndex(() => true) >= 0);
|
|
658
|
-
check('hire rows name their trades', /(Mining|Herb|Skin|Tailor|Smith|Engi|Alchemy|Enchant|Leather) \d+/.test(strip(app.rowsOf(app.top()).
|
|
699
|
+
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 ?? '')));
|
|
700
|
+
const hireRows = () => app.rowsOf(app.top()).filter((r) => !r.header && r.data);
|
|
701
|
+
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))));
|
|
702
|
+
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 ?? '')));
|
|
703
|
+
term.press('right');
|
|
704
|
+
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))));
|
|
705
|
+
term.press('t'); term.press('t');
|
|
706
|
+
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))));
|
|
707
|
+
term.press('left'); term.press('left'); term.press('right');
|
|
708
|
+
check('the role tabs wrap, All is the start', app.top().title === 'Hire · Shabby');
|
|
709
|
+
term.press('T'); term.press('T');
|
|
710
|
+
check('T steps the kit back to any', app.top().title === 'Hire');
|
|
659
711
|
term.press('return'); show('candidate page');
|
|
660
712
|
check('Enter opens the candidate page', app.top().id.startsWith('cand-'));
|
|
661
713
|
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);
|
|
@@ -743,6 +795,16 @@ check('b in Bags outside a bank town only flashes', app.stack.length === 0);
|
|
|
743
795
|
}
|
|
744
796
|
const squad = g.state.squad ?? [];
|
|
745
797
|
check(`two companions are seated (${squad.length})`, squad.length >= 2);
|
|
798
|
+
if (iconsOn() && squad[0]) {
|
|
799
|
+
// c on a companion's page opens their character sheet: the Character tab's paperdoll, for them.
|
|
800
|
+
const [cols, rows] = [term.cols, term.rows];
|
|
801
|
+
term.cols = 160; term.rows = 54;
|
|
802
|
+
home(); app.switchTab(tabOf('companions'));
|
|
803
|
+
app.top().cursor = rowIndex((r, t) => t.startsWith(squad[0].name)); term.press('return'); term.press('c'); show('companion sheet');
|
|
804
|
+
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()));
|
|
805
|
+
term.cols = cols; term.rows = rows; home();
|
|
806
|
+
}
|
|
807
|
+
check('money reads in coins, an empty copper left off', strip(coinText(250000)) === (iconsOn() ? '25●' : '25g 0c') && strip(coinText(0, 'free')) === 'free');
|
|
746
808
|
home(); app.switchTab(tabOf('companions'));
|
|
747
809
|
term.press('m'); show('squad mode picker');
|
|
748
810
|
check('m on Companions opens the squad mode picker', app.top().id === 'pick' && /How do your companions fight/.test(app.top().title));
|
|
@@ -814,7 +876,7 @@ check('b in Bags outside a bank town only flashes', app.stack.length === 0);
|
|
|
814
876
|
// ---- dungeons: the Deadmines as a text run ------------------------------------------------------------------
|
|
815
877
|
{
|
|
816
878
|
const g = app.g;
|
|
817
|
-
const { hasFlag } = await import('../world/instances.ts');
|
|
879
|
+
const { hasFlag, isComplete } = await import('../world/instances.ts');
|
|
818
880
|
g.state.char.level = 20; g.state.char.gold = 10_000_000;
|
|
819
881
|
const bag = ds.items().find((i) => i.slot === 'bag' && i.itemClass !== 11 && (i.bagSlots ?? 0) >= 10 && (i.requiredLevel ?? 1) <= 20 && !i.allowClass?.length);
|
|
820
882
|
for (let i = 0; i < 4; i++) { g.state.inventory = addItem(g.state.inventory, bag.entry, 1, 1); g.equip(bag.entry); }
|
|
@@ -856,6 +918,15 @@ check('b in Bags outside a bank town only flashes', app.stack.length === 0);
|
|
|
856
918
|
}
|
|
857
919
|
check('a cast shows as a bar under the caster', !cast || strip(frame()).includes(cast.casting.name.slice(0, 8)));
|
|
858
920
|
if (cast) show('party stage cast');
|
|
921
|
+
// The last foe falls and the win holds: the party stays where it fought, it does not walk back to its start.
|
|
922
|
+
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
|
|
923
|
+
let before = '';
|
|
924
|
+
for (let k = 0; k < 3000 && app.fight && !app.fight.ended; k++) {
|
|
925
|
+
const left = app.fight.session.enemiesView().filter((v) => v.hp > 0);
|
|
926
|
+
if (left.length === 1 && left[0].hp < left[0].maxHp * 0.25) before = plates();
|
|
927
|
+
app.fight.tick();
|
|
928
|
+
}
|
|
929
|
+
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); }
|
|
859
930
|
}
|
|
860
931
|
finishFight();
|
|
861
932
|
check(`stop ${n} is cleared and the run window comes back`, g.state.instance.defeated.length === n && app.top()?.id === 'dungeon');
|
|
@@ -876,12 +947,22 @@ check('b in Bags outside a bank town only flashes', app.stack.length === 0);
|
|
|
876
947
|
check('finish instantly ends on the run report', app.top()?.id === 'run-summary' && /Run report|Cleared|Partial|Wiped/.test(strip(frame())) && (app.dungeonPulls?.length ?? 0) > 2);
|
|
877
948
|
term.press('escape');
|
|
878
949
|
const cleared = g.state.instance.defeated.length;
|
|
950
|
+
const allDown = isComplete(ds.instance('deadmines'), g.state.instance);
|
|
951
|
+
{
|
|
952
|
+
const { portalRows } = await import('./tuiDungeon.mjs');
|
|
953
|
+
const texts = portalRows(app).map((r) => strip(r.text));
|
|
954
|
+
check('inside a run, the World tab still lists the zone\'s dungeons, the one you are in marked', texts.some((t) => /The Deadmines .*you are in it/.test(t)) && !texts.some((t) => /Back into/.test(t)));
|
|
955
|
+
}
|
|
879
956
|
term.press('L');
|
|
880
957
|
check('L leaves and the run is held', !g.state.instance && !!g.state.instanceSaves?.deadmines);
|
|
881
|
-
check('
|
|
958
|
+
check('a 5-man holds the run for two hours', Math.abs(g.state.instanceSaves.deadmines.expiresAtMs - Date.now() - 2 * 3600_000) < 60_000);
|
|
959
|
+
check('the portal row shows the held run', allDown ? /cleared, resets in/.test(strip(frame())) : /your run is held/.test(strip(frame())));
|
|
882
960
|
app.top().cursor = rowIndex((r) => r.data?.dungeon?.id === 'deadmines'); term.press('return');
|
|
883
|
-
|
|
884
|
-
|
|
961
|
+
if (allDown) {
|
|
962
|
+
check('the entry card of a cleared run offers to go back in', /Go back in/.test(strip(frame())) && /You cleared it/.test(strip(frame())));
|
|
963
|
+
app.top().cursor = rowIndex((r, t) => /^Go back in/.test(t)); term.press('return');
|
|
964
|
+
}
|
|
965
|
+
check('a held run is walked straight back into', g.state.instance?.dungeonId === 'deadmines');
|
|
885
966
|
check('going back in keeps the cleared stops', g.state.instance?.defeated.length === cleared);
|
|
886
967
|
term.press('R'); term.press('return');
|
|
887
968
|
check('R resets the run', g.state.instance?.defeated.length === 0);
|
|
@@ -927,6 +1008,16 @@ check('b in Bags outside a bank town only flashes', app.stack.length === 0);
|
|
|
927
1008
|
app.top().cursor = rowIndex((r, t) => /^Raid group/.test(t)); term.press('return'); term.press('f');
|
|
928
1009
|
check('f fills the 20-man group', g.state.raidLineup?.size === 20 && g.state.raidLineup.ids.length > 0);
|
|
929
1010
|
term.press('c'); home();
|
|
1011
|
+
// Travel splits a zone's neighbors by how you get there: Grom'gol's zeppelins are not a walk to Tirisfal.
|
|
1012
|
+
app.switchTab(app.tabs.findIndex((t) => t.view.id === 'travel')); app.render();
|
|
1013
|
+
{
|
|
1014
|
+
const texts = app.rowsOf(app.top()).map((r) => strip(r.text));
|
|
1015
|
+
const foot = texts.indexOf('On foot'), boats = texts.indexOf('Boats and zeppelins');
|
|
1016
|
+
const tirisfal = texts.findIndex((t) => t.startsWith('Tirisfal Glades'));
|
|
1017
|
+
check('Travel lists the zeppelin to Tirisfal under boats, Duskwood on foot', foot >= 0 && boats > foot && tirisfal > boats
|
|
1018
|
+
&& /Undercity/.test(texts[tirisfal]) && texts.findIndex((t) => t.startsWith('Duskwood')) < boats);
|
|
1019
|
+
}
|
|
1020
|
+
home();
|
|
930
1021
|
g.state.char.zoneId = 'westfall';
|
|
931
1022
|
}
|
|
932
1023
|
|
|
@@ -974,6 +1065,27 @@ check('b in Bags outside a bank town only flashes', app.stack.length === 0);
|
|
|
974
1065
|
check('p adds a named companion rotation', g.companionRotationProfiles(comp().id).some((p) => p.name === 'Second' && p.active));
|
|
975
1066
|
term.press('w'); term.press('return');
|
|
976
1067
|
check('w back to your own rotation', !app.tabs[5].view.whose);
|
|
1068
|
+
// Spellbook tabs: General, then one per talent tree; [ ] and Left Right on the strip turn them.
|
|
1069
|
+
home(); term.press('5'); show('spellbook');
|
|
1070
|
+
{
|
|
1071
|
+
const rs = () => app.rowsOf(app.top());
|
|
1072
|
+
const stripAt = () => rs().findIndex((r) => r.data?.strip);
|
|
1073
|
+
const spells = () => rs().slice(stripAt() + 1).filter((r) => !r.header).map((r) => strip(r.text));
|
|
1074
|
+
const trees = ds.talentTrees(app.ch.cls).map((t) => t.name);
|
|
1075
|
+
check('spellbook has General and a tab per talent tree', stripAt() > 0 && ['General', ...trees].every((n) => strip(rs()[stripAt()].text).includes(n)));
|
|
1076
|
+
const first = spells();
|
|
1077
|
+
check(`the book opens on a tab with spells (${first.join(', ')})`, first.length > 0);
|
|
1078
|
+
// ] through every tab and back: each spell sits on exactly one tab, and the class spells are not on General.
|
|
1079
|
+
const seen = [];
|
|
1080
|
+
for (let i = 0; i <= trees.length; i++) { seen.push(...spells()); term.press(']'); }
|
|
1081
|
+
check('] walks every tab and comes back round', JSON.stringify(spells()) === JSON.stringify(first) && new Set(seen).size === seen.length);
|
|
1082
|
+
// This warrior's General is empty, so the book opens on Arms.
|
|
1083
|
+
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')));
|
|
1084
|
+
app.top().cursor = stripAt(); term.press('left');
|
|
1085
|
+
const back = spells();
|
|
1086
|
+
term.press('right');
|
|
1087
|
+
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));
|
|
1088
|
+
}
|
|
977
1089
|
// Player action bars: bar 1 from the Spells tab, and the fight's number row reads it.
|
|
978
1090
|
home(); term.press('5'); term.press('b'); show('player bars');
|
|
979
1091
|
check('b on Spells opens your action bars', app.top().id === 'bars' && /Bar 1/.test(strip(frame())));
|
|
@@ -1032,7 +1144,21 @@ check('b in Bags outside a bank town only flashes', app.stack.length === 0);
|
|
|
1032
1144
|
g2.state.char.level = 12; g2.state.char.gold = 1_000_000;
|
|
1033
1145
|
app2.render();
|
|
1034
1146
|
const at = (pred) => app2.rowsOf(app2.top()).findIndex((r) => !r.header && pred(r, strip(r.text)));
|
|
1035
|
-
|
|
1147
|
+
// One with an innate ability, so the pet page has an autocast row to toggle.
|
|
1148
|
+
const beast = () => at((r) => r.data?.creature?.tameable && r.data.creature.level <= 12 && r.data.creature.petDefaultSpells?.length);
|
|
1149
|
+
app2.top().cursor = beast(); term2.press('t');
|
|
1150
|
+
check('t refuses a hunter who has not learned Tame Beast', !g2.state.char.pet);
|
|
1151
|
+
// Taming the Beast (Dun Morogh): the rod goes on the Large Crag Boar the quest names, and t says so on its row.
|
|
1152
|
+
g2.state.char.level = 10; app2.result(g2.acceptQuest(6064));
|
|
1153
|
+
const boar = at((r) => r.data?.creature?.entry === 1126);
|
|
1154
|
+
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)));
|
|
1155
|
+
app2.top().cursor = boar; term2.press('t');
|
|
1156
|
+
check('t uses the rod and the quest completes', g2.state.questlog.quests.find((q) => q.entry === 6064)?.status === 'complete');
|
|
1157
|
+
g2.state.char.level = 12;
|
|
1158
|
+
g2.state.spellbook = { known: [...g2.state.spellbook.known, 1515] };
|
|
1159
|
+
app2.render();
|
|
1160
|
+
check('a hunter with Tame Beast sees t: tame on a beast', /t: tame/.test(strip(app2.rowsOf(app2.top())[beast()].text)));
|
|
1161
|
+
app2.top().cursor = beast(); term2.press('t');
|
|
1036
1162
|
check('t on a tameable beast tames it', !!g2.state.char.pet);
|
|
1037
1163
|
app2.top().cursor = at((r, t) => /^Pet:/.test(t)); term2.press('return');
|
|
1038
1164
|
if (showFrames) console.log(`\n=== pet page ===\n${term2.text()}`);
|
|
@@ -1057,13 +1183,56 @@ check('b in Bags outside a bank town only flashes', app.stack.length === 0);
|
|
|
1057
1183
|
check('Dun Morogh lists a pet trainer', trainer > 0);
|
|
1058
1184
|
if (trainer > 0) {
|
|
1059
1185
|
app2.top().cursor = trainer; term2.press('return');
|
|
1060
|
-
|
|
1186
|
+
check('the pet trainer offers to untrain the pet', at((r, t) => /^Untrain /.test(t)) > 0);
|
|
1187
|
+
const rank = at((r, t) => !/level \d+/.test(t) && !/^✓/.test(t) && !/^Untrain /.test(t));
|
|
1061
1188
|
app2.top().cursor = rank; term2.press('return');
|
|
1062
1189
|
check('a pet trainer rank can be bought', (g2.state.char.petSpells ?? []).length > 0);
|
|
1063
1190
|
}
|
|
1064
1191
|
term2.close?.();
|
|
1065
1192
|
}
|
|
1066
1193
|
|
|
1194
|
+
// ---- what phonecraft's World tab, quest card and bag menu offer: weapon masters, ring picker, hearth, quest steps --
|
|
1195
|
+
{
|
|
1196
|
+
const term2 = new HeadlessTerm(110, 34);
|
|
1197
|
+
const app2 = new App({ term: term2, ds, repo });
|
|
1198
|
+
term2.open();
|
|
1199
|
+
const dwarf = ds.races().find((r) => r.name === 'Dwarf');
|
|
1200
|
+
app2.startGame(newGameState(ds, dwarf.id, 1, 'Portcheck', 98, 0), 'portcheck', Date.now(), true);
|
|
1201
|
+
const g2 = app2.g;
|
|
1202
|
+
g2.state.char.level = 20; g2.state.char.zoneId = 'ironforge';
|
|
1203
|
+
app2.switchTab(0); app2.render();
|
|
1204
|
+
const at = (pred) => app2.rowsOf(app2.top()).findIndex((r) => !r.header && pred(r, strip(r.text)));
|
|
1205
|
+
check('Ironforge lists its weapon masters on the World tab', at((r, t) => /Weapon Master|Weapon Trainer/i.test(t)) >= 0);
|
|
1206
|
+
check('no Horde trainer or vendor in an Alliance capital', at((r, t) => /Orgrimmar|Thunder Bluff/.test(t) && /</.test(t)) < 0);
|
|
1207
|
+
// Two rings on, a third from the bags asks which to replace instead of overwriting the first.
|
|
1208
|
+
const rings = ds.allItems().filter((it) => it.slot === 'finger' && (it.requiredLevel ?? 0) <= 20 && !it.unique).slice(0, 3);
|
|
1209
|
+
if (rings.length === 3) {
|
|
1210
|
+
for (const r of rings.slice(0, 2)) { g2.state.inventory = addItem(g2.state.inventory, r.entry, 1, 1); g2.equip(r.entry); }
|
|
1211
|
+
g2.state.inventory = addItem(g2.state.inventory, rings[2].entry, 1, 1);
|
|
1212
|
+
app2.switchTab(2); app2.render();
|
|
1213
|
+
app2.top().cursor = at((r, t) => t.includes(rings[2].name)); term2.press('return');
|
|
1214
|
+
check('a third ring asks which ring to replace', app2.top().id === 'pick' && /replace which/.test(strip(app2.top().title)));
|
|
1215
|
+
term2.press('down'); term2.press('return');
|
|
1216
|
+
check('the picked ring slot takes it', Object.values(g2.state.char.equipped).some((e) => (e?.entry ?? e) === rings[2].entry));
|
|
1217
|
+
} else check('three plain rings exist for the slot picker check', false);
|
|
1218
|
+
// The Hearthstone from the bags takes you home, as the Travel tab's row does.
|
|
1219
|
+
g2.state.char.hearthBind = { zone: 'dunmorogh', name: 'Thunderbrew Distillery' };
|
|
1220
|
+
app2.stack = []; app2.switchTab(2); app2.render();
|
|
1221
|
+
const hs = at((r, t) => /Hearthstone/.test(t));
|
|
1222
|
+
if (hs >= 0) {
|
|
1223
|
+
app2.top().cursor = hs; term2.press('return');
|
|
1224
|
+
check('Enter on the Hearthstone in the bags hearths you', g2.state.char.zoneId !== 'ironforge' || !!g2.state.char.journey);
|
|
1225
|
+
} else check('a new dwarf carries a Hearthstone', false);
|
|
1226
|
+
// A speak-to quest in the log gets its Speak row on the quest page.
|
|
1227
|
+
const talk = ds.quests().find((q) => isTalkToNpcQuest(q) && !q.goEnder && !(q.goEnderEntries ?? []).some((e) => e > 0) && (q.enderName || q.starterName));
|
|
1228
|
+
if (talk) {
|
|
1229
|
+
g2.state.questlog = { ...g2.state.questlog, quests: [...g2.state.questlog.quests, { entry: talk.entry, status: 'active', kills: {} }] };
|
|
1230
|
+
app2.stack = []; app2.push(questDialog(app2, talk, 'log')); app2.render();
|
|
1231
|
+
check('a speak-to quest offers Speak to its NPC on the quest page', at((r, t) => /^Speak to /.test(t)) >= 0);
|
|
1232
|
+
}
|
|
1233
|
+
term2.close?.();
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1067
1236
|
// Loot window: auto-loot off holds the corpse's drops and coin; Enter takes a row, a takes the rest, Esc leaves it.
|
|
1068
1237
|
{
|
|
1069
1238
|
home(); term.press('1');
|
|
@@ -1197,6 +1366,39 @@ check('b in Bags outside a bank town only flashes', app.stack.length === 0);
|
|
|
1197
1366
|
home();
|
|
1198
1367
|
}
|
|
1199
1368
|
|
|
1369
|
+
// Destroy several: X marks stacks and G destroys them after a yes; Q opens on the quest leftovers, already marked.
|
|
1370
|
+
{
|
|
1371
|
+
const g = app.g;
|
|
1372
|
+
const { isRepeatableQuest, leftoverQuestItems } = await import('../domain/quests/questlog.ts');
|
|
1373
|
+
// Two grey items the bags do not already hold, so one stack each.
|
|
1374
|
+
const [linen, wool] = ds.allItems().filter((it) => it.quality === 0 && !it.slot && (it.stackable ?? 1) > 1 && countItem(g.state.inventory, it.entry) === 0).slice(0, 2);
|
|
1375
|
+
g.state.inventory = addItem(addItem(g.state.inventory, linen.entry, 3, 20), wool.entry, 2, 20);
|
|
1376
|
+
home(); app.switchTab(2); term.press('X'); show('destroy several');
|
|
1377
|
+
check('X opens Destroy with nothing marked', app.top().id === 'destroy-many' && /Nothing marked/.test(strip(frame())));
|
|
1378
|
+
for (const name of [linen.name, wool.name]) { app.top().cursor = rowIndex((r, t) => t.includes(name)); term.press('return'); }
|
|
1379
|
+
check('Enter marks two stacks', /2 stacks .* marked/.test(strip(frame())));
|
|
1380
|
+
term.press('G');
|
|
1381
|
+
check('G asks first', app.top().id === 'pick' && /Destroy 2 stacks/.test(strip(frame())));
|
|
1382
|
+
term.press('return');
|
|
1383
|
+
check('a yes destroys both marked stacks', countItem(g.state.inventory, linen.entry) === 0 && countItem(g.state.inventory, wool.entry) === 0 && app.top().id === 'destroy-many');
|
|
1384
|
+
term.press('escape');
|
|
1385
|
+
const open = new Set(g.state.questlog.quests.map((x) => x.entry));
|
|
1386
|
+
const q = ds.quests().find((d) => !isRepeatableQuest(d) && !open.has(d.entry) && d.itemTargets?.some((t) => ds.item(t.item)?.questItem) && !ds.quests().some((o) => o !== d && o.itemTargets?.some((t) => t.item === d.itemTargets[0].item)));
|
|
1387
|
+
const relic = q && q.itemTargets.find((t) => ds.item(t.item)?.questItem).item;
|
|
1388
|
+
if (relic) {
|
|
1389
|
+
g.state.questlog.quests.push({ entry: q.entry, status: 'turnedIn', kills: {}, objects: {} });
|
|
1390
|
+
g.state.inventory = addItem(g.state.inventory, relic, 1, 20);
|
|
1391
|
+
const left = leftoverQuestItems(g.state.questlog, g.state.inventory, (e) => ds.quest(e), (e) => ds.item(e), ds.quests());
|
|
1392
|
+
check('the turned-in quest leaves its item behind', left.some((r) => r.item === relic));
|
|
1393
|
+
term.press('Q'); show('quest leftovers');
|
|
1394
|
+
check('Q lists the leftover, marked', app.top().id === 'destroy-many' && rowIndex((r, t) => t.startsWith('✗') && t.includes(ds.item(relic).name)) > 0);
|
|
1395
|
+
term.press('G'); term.press('return');
|
|
1396
|
+
check('G and a yes destroy it', countItem(g.state.inventory, relic) === 0);
|
|
1397
|
+
term.press('escape');
|
|
1398
|
+
} else check('the data has a finished quest with a quest item to leave behind', false);
|
|
1399
|
+
home();
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1200
1402
|
// Gear upkeep: durability on the slots, repair one piece, repair all, load ammo, auto consumables.
|
|
1201
1403
|
{
|
|
1202
1404
|
const g = app.g;
|
|
@@ -1208,7 +1410,7 @@ check('b in Bags outside a bank town only flashes', app.stack.length === 0);
|
|
|
1208
1410
|
const slotRow = rowIndex((r) => r.data?.slot === worn);
|
|
1209
1411
|
check('a worn-down slot shows its durability', / 1\/\d+/.test(strip(app.rowsOf(app.top())[slotRow].text)));
|
|
1210
1412
|
const quote = g.repairQuote();
|
|
1211
|
-
check('the Repair all row prices the bill', rowIndex((r, t) => t.startsWith('Repair all') && t.includes(
|
|
1413
|
+
check('the Repair all row prices the bill', rowIndex((r, t) => t.startsWith('Repair all') && t.includes(strip(coinText(quote.cost)))) > 0);
|
|
1212
1414
|
if (quote.vendorName) {
|
|
1213
1415
|
app.top().cursor = slotRow; term.press('r');
|
|
1214
1416
|
check('r on a slot repairs that piece', g.state.char.durability?.[worn] == null);
|
|
@@ -1372,15 +1574,15 @@ check('b in Bags outside a bank town only flashes', app.stack.length === 0);
|
|
|
1372
1574
|
home();
|
|
1373
1575
|
}
|
|
1374
1576
|
|
|
1375
|
-
// Cancel an aura: from the Character tab, and with c inside a fight.
|
|
1577
|
+
// Cancel an aura: from the Character tab's stats page, and with c inside a fight.
|
|
1376
1578
|
{
|
|
1377
1579
|
const g = app.g;
|
|
1378
1580
|
const fort = ds.spell(1243);
|
|
1379
1581
|
const give = () => { g.state.char.activeAuras = [...(g.state.char.activeAuras ?? []).filter((x) => x.spellId !== 1243), { spellId: 1243, expiresAt: Date.now() + 600000 }]; };
|
|
1380
1582
|
give();
|
|
1381
|
-
home(); app.switchTab(
|
|
1583
|
+
home(); app.switchTab(3); term.press('s'); show('character auras');
|
|
1382
1584
|
const at = rowIndex((r, t) => t.includes(fort.name));
|
|
1383
|
-
check('the
|
|
1585
|
+
check('the stats page lists your auras', at > 0);
|
|
1384
1586
|
app.top().cursor = at; term.press('return');
|
|
1385
1587
|
check('Enter on a buff cancels it', !(g.state.char.activeAuras ?? []).some((x) => x.spellId === 1243));
|
|
1386
1588
|
give(); home(); term.press('1');
|
|
@@ -1512,6 +1714,21 @@ check('b in Bags outside a bank town only flashes', app.stack.length === 0);
|
|
|
1512
1714
|
home();
|
|
1513
1715
|
}
|
|
1514
1716
|
|
|
1717
|
+
// The Travel tab's detail pane carries the world map (tuiMap.mjs): the terrain as half-block cells, you and the
|
|
1718
|
+
// destination pinned on it, the planned route dotted between them.
|
|
1719
|
+
{
|
|
1720
|
+
home(); term.press('8');
|
|
1721
|
+
const here = app.g.state.char.zoneId;
|
|
1722
|
+
const dest = (ds.zone(here)?.neighbors ?? []).map((id) => ds.zone(id)).find((z) => z && app.g.previewTravel(z.id).ok);
|
|
1723
|
+
app.top().cursor = rowIndex((r, t) => t.startsWith(dest.name)); app.render();
|
|
1724
|
+
const pane = strip(frame()).split('\n').map((l) => l.split('│')[1] ?? '').join('\n');
|
|
1725
|
+
check('the Travel tab draws the world map under a destination, you pinned on it', /▀/.test(pane) && pane.includes('●') && /you .*destination/.test(pane));
|
|
1726
|
+
const far = ds.zones().find((z) => z.continent !== ds.zone(here).continent && app.g.previewTravel(z.id).ok);
|
|
1727
|
+
const lines = worldMapLines(app, { dest: far, legs: app.g.previewTravel(far.id).legs, maxCols: 60, maxRows: 24 });
|
|
1728
|
+
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('◆'));
|
|
1729
|
+
home();
|
|
1730
|
+
}
|
|
1731
|
+
|
|
1515
1732
|
// Real-time travel: with a 1x pace a trip takes time; the header counts down, the World tab carries the journey
|
|
1516
1733
|
// card (speed, pause, arrive now, turn back), and the game loop lands it.
|
|
1517
1734
|
{
|
|
@@ -2247,6 +2464,94 @@ check('b in Bags outside a bank town only flashes', app.stack.length === 0);
|
|
|
2247
2464
|
term.press('?'); check('help overlay', app.top().id === 'help'); term.press('escape');
|
|
2248
2465
|
check('help closed', app.stack.length === 0);
|
|
2249
2466
|
|
|
2467
|
+
// Pre-pull buffs: a mage buffs standing still, the autopilot drinks back what that cost, and the pull opens at
|
|
2468
|
+
// full mana on an attack. Casting the buffs phase on the fight's first ticks sent it in at three quarters.
|
|
2469
|
+
{
|
|
2470
|
+
const termB = new HeadlessTerm(110, 34);
|
|
2471
|
+
const appB = new App({ term: termB, ds, repo });
|
|
2472
|
+
termB.open();
|
|
2473
|
+
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);
|
|
2474
|
+
const gB = appB.g;
|
|
2475
|
+
const wolf = ds.zone(gB.state.char.zoneId).huntable[0];
|
|
2476
|
+
const hasAI = () => (gB.state.char.activeAuras ?? []).some((a) => ds.spell(a.spellId)?.name === 'Arcane Intellect');
|
|
2477
|
+
const full = () => gB.state.char.power === maxPower(gB.state.char, ds);
|
|
2478
|
+
let pulledFull = null, pulledBuffed = null;
|
|
2479
|
+
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 }; } });
|
|
2480
|
+
ap.next();
|
|
2481
|
+
check('autopilot buffs before the pull, then rests instead of pulling', ap.lastKind === 'buff' && hasAI() && !full());
|
|
2482
|
+
for (let i = 0; i < 5 && pulledFull == null; i++) ap.next();
|
|
2483
|
+
check('autopilot pulls buffed and at full mana', pulledFull === true && pulledBuffed === true);
|
|
2484
|
+
gB.state.char.activeAuras = (gB.state.char.activeAuras ?? []).filter((a) => ds.spell(a.spellId)?.name !== 'Arcane Intellect');
|
|
2485
|
+
gB.state.char.activeBuffs = (gB.state.char.activeBuffs ?? []).filter((id) => ds.spell(id)?.name !== 'Arcane Intellect');
|
|
2486
|
+
const from = appB.log.length;
|
|
2487
|
+
startFight(appB, wolf);
|
|
2488
|
+
const buffedFirst = hasAI();
|
|
2489
|
+
for (let i = 0; i < 2000 && appB.fight; i++) appB.fight.tick();
|
|
2490
|
+
const casts = appB.log.slice(from).map(strip).filter((l) => /^\s*[\d.]+s You cast /.test(l));
|
|
2491
|
+
check('a plain fight buffs before it starts and opens on an attack', buffedFirst && casts.length > 0 && !casts.some((l) => /Arcane Intellect/.test(l)));
|
|
2492
|
+
// A dungeon stop: the same buff, then the party drinks back to the rest line before the pull, and again before
|
|
2493
|
+
// the next one. Pulling straight after the armour and conjures opened Rhahk'Zor at 761 of 1966.
|
|
2494
|
+
const { openRun, pullStop } = await import('./tuiDungeon.mjs');
|
|
2495
|
+
const { nextEncounter } = await import('../world/instances.ts');
|
|
2496
|
+
for (const [cls, role] of [[1, 'tank'], [2, 'healer']]) gB.recruitCompanion(cls, role, 'twink');
|
|
2497
|
+
for (const c of gB.state.squad) c.level = 20;
|
|
2498
|
+
gB.state.char.activeAuras = []; gB.state.char.activeBuffs = [];
|
|
2499
|
+
gB.regenTick(600, true);
|
|
2500
|
+
const dm = ds.instance('deadmines');
|
|
2501
|
+
check('a level 20 mage party can enter the Deadmines', gB.enterInstance('deadmines', Date.now()).ok);
|
|
2502
|
+
appB.stack = []; openRun(appB);
|
|
2503
|
+
const openPct = [];
|
|
2504
|
+
for (let i = 0; i < 2; i++) {
|
|
2505
|
+
pullStop(appB, dm, nextEncounter(dm, gB.state.instance));
|
|
2506
|
+
const pv = appB.fight?.session.playerView();
|
|
2507
|
+
openPct.push(pv ? pv.power / maxPower(gB.state.char, ds) : 0);
|
|
2508
|
+
for (let n = 0; n < 8000 && appB.fight; n++) appB.fight.tick();
|
|
2509
|
+
appB.fight?.release?.();
|
|
2510
|
+
}
|
|
2511
|
+
check('a dungeon stop buffs, then drinks back before the pull', hasAI() && openPct[0] >= 0.9);
|
|
2512
|
+
check('the next stop rests back up too', openPct[1] >= 0.9);
|
|
2513
|
+
}
|
|
2514
|
+
|
|
2515
|
+
// A foe that resets ends the fight. The engine keeps the session open with the mob passive at full HP and the
|
|
2516
|
+
// rotation idle, which held a Razormane Scout fight for 970 s. Here a Quilboar is pinned 50 yd out once it is
|
|
2517
|
+
// in combat, so it cannot close and evades; the fight must settle as a stalemate a few seconds later.
|
|
2518
|
+
{
|
|
2519
|
+
const termQ = new HeadlessTerm(110, 34);
|
|
2520
|
+
const appQ = new App({ term: termQ, ds, repo });
|
|
2521
|
+
termQ.open();
|
|
2522
|
+
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);
|
|
2523
|
+
const boar = ds.creatures().find((c) => c.name === 'Razormane Quilboar');
|
|
2524
|
+
const from = appQ.log.length;
|
|
2525
|
+
startFight(appQ, boar.entry);
|
|
2526
|
+
const s = appQ.fight.session, side = s.enemiesView()[0].side;
|
|
2527
|
+
let engagedAt = null, ticks = 0;
|
|
2528
|
+
for (; ticks < 1200 && appQ.fight && !appQ.fight.ended; ticks++) {
|
|
2529
|
+
if (engagedAt == null && s.enemiesView()[0].engaged) engagedAt = s.t;
|
|
2530
|
+
s.setUnitPosYd('player', { x: 0, y: 0 });
|
|
2531
|
+
if (engagedAt != null) s.setUnitPosYd(side, { x: 50, y: 0 });
|
|
2532
|
+
appQ.fight.tick();
|
|
2533
|
+
}
|
|
2534
|
+
const said = appQ.log.slice(from).map(strip);
|
|
2535
|
+
const reset = said.some((l) => /cannot reach you and resets/.test(l));
|
|
2536
|
+
check(`a foe that resets ends the fight as a stalemate (${(s.t / 1000).toFixed(1)} s, reset ${reset})`,
|
|
2537
|
+
reset && said.some((l) => /resets and the fight is over/.test(l)) && said.some((l) => /^Stalemate against/.test(l)) && s.t < 20_000);
|
|
2538
|
+
appQ.fight?.release?.();
|
|
2539
|
+
}
|
|
2540
|
+
|
|
2541
|
+
// c on the World tab saves, lets go of the character and hands the screen back to the picker.
|
|
2542
|
+
{
|
|
2543
|
+
const termL = new HeadlessTerm(110, 34);
|
|
2544
|
+
const repoL = { saved: 0, unlocked: [], async save() { this.saved++; }, async list() { return []; }, async load() { return null; }, unlock(s) { this.unlocked.push(s); } };
|
|
2545
|
+
const appL = new App({ term: termL, ds, repo: repoL });
|
|
2546
|
+
termL.open();
|
|
2547
|
+
appL.startGame(newGameState(ds, human.id, 1, 'Logouttest', 777, 0), 'logouttest', Date.now(), true);
|
|
2548
|
+
let picked = 0;
|
|
2549
|
+
appL.toPicker = async () => { picked++; };
|
|
2550
|
+
termL.press('c');
|
|
2551
|
+
await new Promise((r) => setImmediate(r));
|
|
2552
|
+
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);
|
|
2553
|
+
}
|
|
2554
|
+
|
|
2250
2555
|
// Sixel art, last: once on it stays on for the process. The terminal says it draws sixel (DA1 parameter 4);
|
|
2251
2556
|
// art then travels as placeholders whose markers the terminal layer takes out and places.
|
|
2252
2557
|
{
|
|
@@ -2254,10 +2559,14 @@ check('help closed', app.stack.length === 0);
|
|
|
2254
2559
|
const art = creatureArt(2332, 28, 14);
|
|
2255
2560
|
const mark = art?.[0]?.match(/\x1b\[9999;(\d+);0m/);
|
|
2256
2561
|
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 ?? ''));
|
|
2562
|
+
// The shipped worn pack fills what the downloaded hd pack lacks: forms and costumes draw sharp too.
|
|
2563
|
+
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] ?? '')));
|
|
2257
2564
|
const placed = new Map();
|
|
2258
2565
|
const rows = art.map((l, r) => takeMarks(`ab${l}${l}`, 5 + r, placed));
|
|
2259
2566
|
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
2567
|
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);
|
|
2568
|
+
const map = worldMapLines(app, { maxCols: 40, maxRows: 12 });
|
|
2569
|
+
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])));
|
|
2261
2570
|
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
2571
|
const icon = iconCells('spell_holy_holysmite', 6, '', [{ x: 4, y: 2, w: 2 }]);
|
|
2263
2572
|
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 ?? ''));
|