azeroth-shell 0.1.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +362 -85
- package/bin/azsh.mjs +35 -19
- package/package.json +12 -1
- package/src/autopilot/plan.ts +8 -11
- package/src/autopilot/view.ts +12 -11
- package/src/cli/combatLogFormat.ts +22 -0
- package/src/cli/gzLoader.mjs +47 -6
- package/src/cli/hdArt.mjs +81 -0
- package/src/cli/iconArt.mjs +145 -0
- package/src/cli/iconGrid.mjs +100 -0
- package/src/cli/modelArt.mjs +298 -0
- package/src/cli/oocPrep.mjs +32 -0
- package/src/cli/paperdoll.mjs +76 -0
- package/src/cli/perfCheck.mjs +56 -0
- package/src/cli/repl.mjs +48 -7
- package/src/cli/rotationText.mjs +6 -6
- package/src/cli/sixel.mjs +319 -0
- package/src/cli/term.mjs +106 -12
- package/src/cli/text.mjs +21 -3
- package/src/cli/tui.mjs +262 -102
- package/src/cli/tuiAutopilot.mjs +356 -0
- package/src/cli/tuiBags.mjs +37 -4
- package/src/cli/tuiBg.mjs +370 -41
- package/src/cli/tuiCheck.mjs +633 -37
- package/src/cli/tuiCity.mjs +146 -41
- package/src/cli/tuiCompanionKit.mjs +6 -6
- package/src/cli/tuiCompanions.mjs +211 -41
- package/src/cli/tuiCreate.mjs +230 -0
- package/src/cli/tuiDungeon.mjs +57 -21
- package/src/cli/tuiFight.mjs +467 -88
- package/src/cli/tuiFightPanes.mjs +42 -14
- package/src/cli/tuiFloor.mjs +21 -9
- package/src/cli/tuiGear.mjs +17 -14
- package/src/cli/tuiLoot.mjs +6 -6
- package/src/cli/tuiMap.mjs +214 -0
- package/src/cli/tuiPaperdoll.mjs +108 -0
- package/src/cli/tuiPartyStage.mjs +206 -0
- package/src/cli/tuiPet.mjs +16 -8
- package/src/cli/tuiProfessions.mjs +27 -25
- package/src/cli/tuiRotationCond.mjs +390 -0
- package/src/cli/tuiSaves.mjs +24 -1
- package/src/cli/tuiSearch.mjs +79 -27
- package/src/cli/tuiSettings.mjs +3 -4
- package/src/cli/tuiStage.mjs +360 -0
- package/src/cli/tuiTalentTree.mjs +124 -0
- package/src/cli/tuiTalents.mjs +3 -4
- package/src/cli/tuiViews.mjs +633 -172
- package/src/cli/tuiWiki.mjs +2 -3
- package/src/cli/tuiWorld.mjs +90 -7
- package/src/data/gear-art.bin.br +0 -0
- package/src/data/hd-art.json +114 -0
- package/src/data/icon-art.bin.gz +0 -0
- package/src/data/model-art-worn.bin.br +0 -0
- package/src/data/model-art.bin.gz +0 -0
- package/src/data/rotations/pve/paladin/retribution.json +2 -2
- package/src/data/tables/creatures.json.gz +0 -0
- package/src/data/world-map-sixel.bin.br +0 -0
- package/src/data/world-map.bin.gz +0 -0
- package/src/domain/bg/match.ts +85 -24
- package/src/domain/bg/strategies.ts +144 -29
- package/src/domain/bg/types.ts +2 -0
- package/src/domain/bg/wsg.ts +35 -20
- package/src/domain/character/character.ts +6 -0
- package/src/domain/pet/pet.ts +31 -0
- package/src/domain/quests/doQuest.ts +24 -6
- package/src/domain/rotation/build.ts +3031 -3031
- package/src/domain/rotation/classes/paladin.ts +16 -11
- package/src/domain/rotation/evaluate.ts +3 -0
- package/src/engine/combat/applySpell.ts +20 -9
- package/src/engine/combat/applySpellLate.ts +16 -12
- package/src/engine/combat/areaAura.ts +284 -283
- package/src/engine/combat/fighterFactory.ts +1 -1
- package/src/engine/combat/pickAction.ts +3494 -3449
- package/src/engine/combat/procsDamage.ts +31 -1
- package/src/engine/combat/sessionChase.ts +19 -2
- package/src/engine/combat/sessionMotionKit.ts +40 -2
- package/src/engine/game/loadReconcile.ts +21 -1
- package/src/engine/game/pets.ts +33 -1
- package/src/engine/game/types.ts +4 -0
- package/src/quests/quests.ts +60 -0
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
// The character create screen: faction, race, class, gender, name and the five looks of the body on one form, the
|
|
2
|
+
// hero drawn in the starting gear beside it, and under the form a card of what they start with (the race
|
|
3
|
+
// and the class in a sentence, racials, pools and attributes at level 1, spells, gear). Left and Right
|
|
4
|
+
// change the row under the cursor; Enter on Race or Class opens a list with each choice drawn; Enter on
|
|
5
|
+
// Name types one; Enter on Create rolls the character. Esc goes back to the character list.
|
|
6
|
+
import { bold, dim, green, red, wrapLines, fit } from './text.mjs';
|
|
7
|
+
import { className, raceName } from '../domain/core/ids.ts';
|
|
8
|
+
import { newGameState, newGameStateFromWorldBot, pickRandomWorldBot } from '../engine/game.ts';
|
|
9
|
+
import { maxHealth, maxPower, knownUsableSpells } from '../domain/character/character.ts';
|
|
10
|
+
import { effectiveStats, powerType } from '../domain/character/characterStats.ts';
|
|
11
|
+
import { bodyOptions, clampAppearance, cycleAppearance, lowestSelectable, slotCount, APPEARANCE_SLOTS } from '../domain/character/appearance.ts';
|
|
12
|
+
import { equippedEntry } from '../domain/items/itemInstance.ts';
|
|
13
|
+
import { nativeDisplayId } from '../domain/character/displayForm.ts';
|
|
14
|
+
import { creatureArt } from './modelArt.mjs';
|
|
15
|
+
import { paperdollArt } from './paperdoll.mjs';
|
|
16
|
+
import { characterNamesEqual } from '../domain/character/characterName.ts';
|
|
17
|
+
|
|
18
|
+
// A sentence on each race and class, in the voice of the client's create screen; the racials, the start
|
|
19
|
+
// zone and everything else on the card come from the data.
|
|
20
|
+
const RACE_BLURB = {
|
|
21
|
+
1: 'The people of Stormwind, adaptable and diplomatic: their spirit runs deeper, reputation comes sooner, and they see through stealth a little further.',
|
|
22
|
+
2: 'The Horde of Thrall, hardy and fierce. Blood Fury hardens their attacks, stuns slide off them more often, their axes bite deeper, and their pets strike harder.',
|
|
23
|
+
3: 'The stout folk of Ironforge. Stoneform shrugs off poison, disease and bleeding; they resist frost, shoot guns truer, and can find treasure in the earth.',
|
|
24
|
+
4: 'The Kaldorei of Teldrassil, ancient and watchful. They melt into Shadowmeld, are harder to hit, resist nature, and walk as wisps when they fall.',
|
|
25
|
+
5: 'The Forsaken of the Undercity, free of the Lich King. Will of the Forsaken breaks fear, charm and sleep; they eat the dead to heal, breathe underwater longer, and resist shadow.',
|
|
26
|
+
6: 'The Shu\'halo of Mulgore, peaceful until roused. War Stomp stuns everything around them, they stand with more health, resist nature, and gather herbs faster.',
|
|
27
|
+
7: 'The tinkers of Gnomeregan, guests of Ironforge. Escape Artist slips any snare, their minds hold more mana, they resist arcane, and they are born engineers.',
|
|
28
|
+
8: 'The Darkspear, who followed Thrall from the Echo Isles. Berserking quickens blows and spells, they regenerate even in a fight, slay beasts harder, and throw and shoot bows truer.',
|
|
29
|
+
};
|
|
30
|
+
const CLASS_BLURB = {
|
|
31
|
+
1: 'Steel and rage. Warriors wear the heaviest armour, hold the line behind a shield or swing a two-hander, and build rage by hitting and being hit.',
|
|
32
|
+
2: 'The Light\'s champion in plate: a healer, a protector and a slow but sure fighter, with blessings for the party and a bubble for when it goes wrong.',
|
|
33
|
+
3: 'A ranger with a bow or gun and a pet tamed from the wild. The pet holds the foe while the hunter shoots, and the map shows what they track.',
|
|
34
|
+
4: 'A stealthy fighter who builds combo points with blades and spends them on finishing moves. Poisons, lockpicking and picking pockets come with the trade.',
|
|
35
|
+
5: 'The great healer, in cloth. Holy mends and shields the party; Shadow drains and torments from Shadowform, trading the healing for the dark.',
|
|
36
|
+
6: 'The Horde\'s keeper of the elements, in mail: totems, lightning and shocks, heals and weapon imbues, half melee and half spell at every turn.',
|
|
37
|
+
7: 'The master of frost, fire and arcane, fragile in cloth and lethal at range. Conjures food and water, sheeps a foe, and opens portals between cities.',
|
|
38
|
+
8: 'A wielder of fel magic in cloth: curses and damage over time, a demon summoned to tank or drain, and health, soul shards and healthstones traded for power.',
|
|
39
|
+
9: 'The shapeshifter: a bear to tank, a cat to claw, healing and nature magic in caster form, and travel form for the road.',
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const NAME_RE = /^[A-Za-z]{2,12}$/;
|
|
43
|
+
const properName = (s) => s[0].toUpperCase() + s.slice(1).toLowerCase();
|
|
44
|
+
const rand = (n) => Math.floor(Math.random() * n);
|
|
45
|
+
const newSeed = () => (Date.now() ^ (Math.random() * 0x7fffffff)) >>> 0;
|
|
46
|
+
|
|
47
|
+
/** A random look within the body's legal choices (the create screen's shuffle; bald and hornless allowed where offered). */
|
|
48
|
+
export function randomLook(race, gender) {
|
|
49
|
+
const look = {};
|
|
50
|
+
APPEARANCE_SLOTS.forEach((key, s) => {
|
|
51
|
+
const floor = lowestSelectable(race, gender, s), count = slotCount(race, gender, s);
|
|
52
|
+
look[key] = count > floor ? floor + rand(count - floor) : 0;
|
|
53
|
+
});
|
|
54
|
+
return clampAppearance(look, race, gender);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** A name from the world bots' pool for the gender, fit for a character. */
|
|
58
|
+
export function randomName(ds, gender) {
|
|
59
|
+
const pool = ds.botNames()[gender === 1 ? 'female' : 'male'];
|
|
60
|
+
for (let i = 0; i < 50 && pool.length; i++) { const n = pool[rand(pool.length)]; if (NAME_RE.test(n)) return properName(n); }
|
|
61
|
+
return gender === 1 ? 'Aalina' : 'Aalart';
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The screen. `done(state)` gets the new character's game state; `onCancel` is Esc (back to the character list). */
|
|
65
|
+
export function characterCreation(app, done, { onCancel } = {}) {
|
|
66
|
+
const ds = app.ds;
|
|
67
|
+
const races = ds.races();
|
|
68
|
+
const classesOf = (race) => race.classes.filter((c) => ds.createInfo(race.id, c));
|
|
69
|
+
const st = { race: races[0], cls: classesOf(races[0])[0], gender: 0, name: '', look: null };
|
|
70
|
+
st.look = randomLook(st.race.id, st.gender);
|
|
71
|
+
// The state a Create would make for a body and class, less the name and the look: its character carries the
|
|
72
|
+
// starting gear, pools and attributes the card and the drawing read. One per combination, kept.
|
|
73
|
+
const previews = new Map();
|
|
74
|
+
const previewOf = (race, cls, gender) => {
|
|
75
|
+
const key = `${race}-${cls}-${gender}`;
|
|
76
|
+
let p = previews.get(key);
|
|
77
|
+
if (!p) { p = newGameState(ds, race, cls, 'Preview', 1, gender); previews.set(key, p); }
|
|
78
|
+
return p;
|
|
79
|
+
};
|
|
80
|
+
const preview = () => previewOf(st.race.id, st.cls, st.gender);
|
|
81
|
+
const hero = (cls = st.cls) => ({ ...previewOf(st.race.id, cls, st.gender).char, name: st.name || 'Preview', appearance: st.look });
|
|
82
|
+
const setRace = (r) => { st.race = r; lastRace[r.faction] = r; const cs = classesOf(r); if (!cs.includes(st.cls)) st.cls = cs[0]; st.look = clampAppearance(st.look, r.id, st.gender); };
|
|
83
|
+
// Faction comes first: Race cycles and lists only its races, and flipping back returns to the race last chosen there.
|
|
84
|
+
const factions = [...new Set(races.map((r) => r.faction))];
|
|
85
|
+
const racesOf = (f) => races.filter((r) => r.faction === f);
|
|
86
|
+
const lastRace = { [st.race.faction]: st.race };
|
|
87
|
+
const setFaction = (f) => setRace(lastRace[f] ?? racesOf(f)[0]);
|
|
88
|
+
const setGender = (g) => { st.gender = g; st.look = clampAppearance(st.look, st.race.id, g); };
|
|
89
|
+
const cycle = (list, cur, dir) => list[(list.indexOf(cur) + dir + list.length) % list.length];
|
|
90
|
+
const genderName = () => (st.gender === 1 ? 'Female' : 'Male');
|
|
91
|
+
const factionName = (r) => (r.faction === 'horde' ? 'Horde' : 'Alliance');
|
|
92
|
+
|
|
93
|
+
const view = { id: 'create', title: 'New character', hint: 'Left Right: change Enter: pick or type' + (onCancel ? ' Esc: character list' : '') };
|
|
94
|
+
const rowAt = (id) => app.rowsOf(view).findIndex((r) => r.id === id);
|
|
95
|
+
const goTo = (id) => { const i = rowAt(id); if (i >= 0) { view.cursor = i; app.ensureVisible(view, app.rowsOf(view)); } app.render(); };
|
|
96
|
+
|
|
97
|
+
// The hero drawn for the pane: the body in its starting gear with the look, or the bare body when the gear pack lacks it.
|
|
98
|
+
const drawn = (cls, maxCols, maxRows) => paperdollArt(hero(cls), maxCols, maxRows) ?? creatureArt(nativeDisplayId(st.race.id, st.gender), maxCols, maxRows);
|
|
99
|
+
const withBody = (text, art) => (art ? [...text, '', ...art] : text);
|
|
100
|
+
|
|
101
|
+
// Mail to an alt finds them by name, so two of yours may not share one. Read once as the screen opens.
|
|
102
|
+
let taken = [];
|
|
103
|
+
void Promise.resolve(app.repo?.list?.()).then((l) => { taken = (l ?? []).map((s) => s.name ?? ''); }).catch(() => {});
|
|
104
|
+
const nameTaken = (name) => taken.some((n) => characterNamesEqual(n, name));
|
|
105
|
+
const askName = () => app.prompt('Name (2-12 letters): ', (raw) => {
|
|
106
|
+
const name = raw.trim();
|
|
107
|
+
if (!NAME_RE.test(name)) { app.say(red('Letters only, 2 to 12 of them.')); askName(); return; } // said in the log: the prompt covers the hint line
|
|
108
|
+
if (nameTaken(name)) { app.say(red(`${properName(name)} is already one of your characters.`)); askName(); return; }
|
|
109
|
+
st.name = properName(name);
|
|
110
|
+
goTo('create'); // named: Enter again creates
|
|
111
|
+
}); // the line starts empty: Esc keeps the name they had
|
|
112
|
+
const rollName = () => { st.name = randomName(ds, st.gender); app.render(); };
|
|
113
|
+
const shuffle = () => { st.look = randomLook(st.race.id, st.gender); app.render(); };
|
|
114
|
+
const create = () => {
|
|
115
|
+
if (!st.name) { app.flashMsg('Give them a name first.'); goTo('name'); return; }
|
|
116
|
+
if (nameTaken(st.name)) { app.flashMsg(`${st.name} is already one of your characters. Pick another name.`); goTo('name'); return; }
|
|
117
|
+
done(newGameState(ds, st.race.id, st.cls, st.name, newSeed(), st.gender, st.look));
|
|
118
|
+
};
|
|
119
|
+
const randomCharacter = () => {
|
|
120
|
+
const seed = newSeed();
|
|
121
|
+
done(newGameStateFromWorldBot(ds, pickRandomWorldBot(ds, seed), seed));
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
// Enter on Race: the faction's races listed with each body drawn; on Class: every class this race may be, drawn in its gear.
|
|
125
|
+
const racePicker = () => {
|
|
126
|
+
const list = racesOf(st.race.faction);
|
|
127
|
+
const p = app.pick('Race', list.map((r) => ({
|
|
128
|
+
label: `${fit(r.name, 10)} ${dim(factionName(r))}`,
|
|
129
|
+
detail: () => {
|
|
130
|
+
const L = app.layout();
|
|
131
|
+
const text = [bold(r.name), dim(factionName(r)), `Starts in ${ds.zone(r.startZone)?.name ?? r.startZone}`, dim('Classes: ') + classesOf(r).map(className).join(', ')];
|
|
132
|
+
return withBody(text, creatureArt(nativeDisplayId(r.id, st.gender), L.detailW - 2, L.mainH - text.length - 1));
|
|
133
|
+
},
|
|
134
|
+
value: r,
|
|
135
|
+
})), (r) => { setRace(r); app.render(); }, { hint: 'Enter: choose' });
|
|
136
|
+
p.cursor = list.indexOf(st.race);
|
|
137
|
+
};
|
|
138
|
+
const classPicker = () => {
|
|
139
|
+
const classes = classesOf(st.race);
|
|
140
|
+
const p = app.pick('Class', classes.map((c) => ({
|
|
141
|
+
label: className(c),
|
|
142
|
+
detail: () => {
|
|
143
|
+
const L = app.layout();
|
|
144
|
+
const text = [bold(className(c)), ...wrapLines(dim(CLASS_BLURB[c] ?? ''), L.detailW - 2)];
|
|
145
|
+
return withBody(text, drawn(c, L.detailW - 2, L.mainH - text.length - 1));
|
|
146
|
+
},
|
|
147
|
+
value: c,
|
|
148
|
+
})), (c) => { st.cls = c; app.render(); }, { hint: 'Enter: choose' });
|
|
149
|
+
p.cursor = classes.indexOf(st.cls);
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
// A form row: the label, the value, and the arrows when Left and Right change it.
|
|
153
|
+
const field = (id, label, value, { cycle: cyc, act, arrows = true }) => ({ id, text: `${fit(label, 12)} ${fit(bold(value), 10)}${arrows ? ` ${dim('◂ ▸')}` : ''}`, cycle: cyc, act });
|
|
154
|
+
const H = (text = '', plain = false) => ({ text, header: true, plain });
|
|
155
|
+
|
|
156
|
+
// Under the form: what the race and the class are, and what a level 1 of them starts with.
|
|
157
|
+
const card = () => {
|
|
158
|
+
const L = app.layout();
|
|
159
|
+
const w = Math.max(24, (L.detailW ? L.listW : L.W) - 3);
|
|
160
|
+
const wrap = (t) => wrapLines(t, w).map((l) => H(l, true));
|
|
161
|
+
const r = st.race, p = preview(), ch = p.char;
|
|
162
|
+
const out = [H(`${r.name} · ${factionName(r)}`)];
|
|
163
|
+
if (RACE_BLURB[r.id]) out.push(...wrap(RACE_BLURB[r.id]));
|
|
164
|
+
out.push(...wrap(dim(`Starts in ${ds.zone(r.startZone)?.name ?? r.startZone}; home city ${r.city}.`)));
|
|
165
|
+
const racials = [...new Set(r.racials.map((id) => ds.spell(id)?.name).filter(Boolean))];
|
|
166
|
+
if (racials.length) out.push(...wrap(`${dim('Racials:')} ${racials.join(', ')}`));
|
|
167
|
+
out.push(H(), H(className(st.cls)));
|
|
168
|
+
if (CLASS_BLURB[st.cls]) out.push(...wrap(CLASS_BLURB[st.cls]));
|
|
169
|
+
const s = effectiveStats(ch, ds), pt = powerType(ch);
|
|
170
|
+
out.push(...wrap(`${dim('Level 1:')} ${maxHealth(ch, ds)} health, ${maxPower(ch, ds)} ${pt}`));
|
|
171
|
+
out.push(...wrap(`${dim('Str')} ${s.str} ${dim('Agi')} ${s.agi} ${dim('Sta')} ${s.sta} ${dim('Int')} ${s.int} ${dim('Spi')} ${s.spi}`));
|
|
172
|
+
const spells = [...new Set(knownUsableSpells(ch, p.spellbook, ds).map((sp) => sp.name))];
|
|
173
|
+
if (spells.length) out.push(...wrap(`${dim('Knows:')} ${spells.join(', ')}`));
|
|
174
|
+
const worn = new Set(Object.values(ch.equipped ?? {}).map(equippedEntry));
|
|
175
|
+
const wears = [], carries = [];
|
|
176
|
+
for (const it of ds.createInfo(r.id, st.cls)?.items ?? []) {
|
|
177
|
+
const def = ds.item(it.item);
|
|
178
|
+
if (def) (worn.has(it.item) ? wears : carries).push(it.amount > 1 ? `${def.name} ×${it.amount}` : def.name);
|
|
179
|
+
}
|
|
180
|
+
if (wears.length) out.push(...wrap(`${dim('Wears:')} ${wears.join(', ')}`));
|
|
181
|
+
if (carries.length) out.push(...wrap(`${dim('Carries:')} ${carries.join(', ')}`));
|
|
182
|
+
return out;
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
view.rows = () => {
|
|
186
|
+
const rows = [
|
|
187
|
+
field('faction', 'Faction', factionName(st.race), { cycle: (d) => setFaction(cycle(factions, st.race.faction, d)), act: () => { setFaction(cycle(factions, st.race.faction, 1)); app.render(); } }),
|
|
188
|
+
field('race', 'Race', st.race.name, { cycle: (d) => setRace(cycle(racesOf(st.race.faction), st.race, d)), act: racePicker }),
|
|
189
|
+
field('class', 'Class', className(st.cls), { cycle: (d) => { st.cls = cycle(classesOf(st.race), st.cls, d); }, act: classPicker }),
|
|
190
|
+
field('gender', 'Gender', genderName(), { cycle: () => setGender(st.gender === 1 ? 0 : 1), act: () => { setGender(st.gender === 1 ? 0 : 1); app.render(); } }),
|
|
191
|
+
{ id: 'name', text: `${fit('Name', 12)} ${st.name ? bold(st.name) : dim('Enter: type one n: a random one')}`, act: askName },
|
|
192
|
+
H(), H('Look'),
|
|
193
|
+
];
|
|
194
|
+
// The body's own slots, by the names the client gives them (Horn Style, Tusks…); a slot with one choice is left out.
|
|
195
|
+
const body = bodyOptions(st.race.id, st.gender);
|
|
196
|
+
APPEARANCE_SLOTS.forEach((key, s) => {
|
|
197
|
+
const opt = body?.options[s];
|
|
198
|
+
if (!opt) return;
|
|
199
|
+
const floor = lowestSelectable(st.race.id, st.gender, s), count = opt.count;
|
|
200
|
+
if (count - floor <= 1) return;
|
|
201
|
+
const i = st.look[key];
|
|
202
|
+
const value = i === 0 && opt.none ? 'None' : `${i + 1 - floor} of ${count - floor}`;
|
|
203
|
+
rows.push(field(key, opt.name, value, { cycle: (d) => { st.look = cycleAppearance(st.look, st.race.id, st.gender, s, d); }, act: () => { st.look = cycleAppearance(st.look, st.race.id, st.gender, s, 1); app.render(); } }));
|
|
204
|
+
});
|
|
205
|
+
rows.push({ id: 'shuffle', text: `${fit('', 12)} ${dim('r: a random look')}`, act: shuffle });
|
|
206
|
+
rows.push(H());
|
|
207
|
+
rows.push({ id: 'create', text: green(bold(st.name ? `Create ${st.name}, ${genderName()} ${st.race.name} ${className(st.cls)}` : 'Create (needs a name)')), act: create });
|
|
208
|
+
rows.push({ id: 'random', text: `Random character ${dim('a world bot at its own level, geared and named')}`, act: randomCharacter });
|
|
209
|
+
rows.push(H(), ...card());
|
|
210
|
+
return rows;
|
|
211
|
+
};
|
|
212
|
+
// The pane: the hero as they will stand on the Character tab, in the starting gear, as large as the pane allows.
|
|
213
|
+
view.detail = () => {
|
|
214
|
+
const L = app.layout();
|
|
215
|
+
const head = [`${st.name ? bold(st.name) : dim('Unnamed')} ${dim(`${genderName()} ${raceName(st.race.id)} ${className(st.cls)}`)}`, ''];
|
|
216
|
+
const art = drawn(st.cls, L.detailW - 2, L.mainH - head.length);
|
|
217
|
+
return art ? [...head, ...art] : head;
|
|
218
|
+
};
|
|
219
|
+
view.keys = {
|
|
220
|
+
n: { hint: 'random name', fn: rollName },
|
|
221
|
+
r: { hint: 'random look', fn: shuffle },
|
|
222
|
+
};
|
|
223
|
+
view.onKey = (a, k, row) => {
|
|
224
|
+
if ((k === 'left' || k === 'right') && row?.cycle) { row.cycle(k === 'left' ? -1 : 1); a.render(); return true; }
|
|
225
|
+
if (k === 'escape') { onCancel?.(); return true; }
|
|
226
|
+
return false;
|
|
227
|
+
};
|
|
228
|
+
app.push(view);
|
|
229
|
+
return view;
|
|
230
|
+
}
|
package/src/cli/tuiDungeon.mjs
CHANGED
|
@@ -3,14 +3,14 @@
|
|
|
3
3
|
// stops, gates, chests and NPCs; Enter on an unlocked stop fights it live with the squad. Ports phonecraft
|
|
4
4
|
// instanceView.ts (the entry card), liveDungeonHeadless.ts (pulls, gates, rest, Finish instantly) and the
|
|
5
5
|
// floor's object clicks (liveObjectLoot.ts) onto the sequential route.
|
|
6
|
-
import { bold, dim, cyan, yellow, green, red, gray, qualityPaint, fit, plural, secondsText, wrapLines, itemText } from './text.mjs';
|
|
6
|
+
import { bold, dim, cyan, yellow, green, red, gray, qualityPaint, fit, plural, secondsText, wrapLines, itemText, coinText } from './text.mjs';
|
|
7
7
|
import { openFloor } from './tuiFloor.mjs';
|
|
8
8
|
import { startFight } from './tuiFight.mjs';
|
|
9
9
|
import { noteLoot } from './tuiFightPanes.mjs';
|
|
10
10
|
import { prePull } from './squad.mjs';
|
|
11
|
+
import { prepOoc, prepText } from './oocPrep.mjs';
|
|
11
12
|
import { questDialog } from './tuiViews.mjs';
|
|
12
13
|
import { nextDoQuestStep } from '../domain/quests/doQuest.ts';
|
|
13
|
-
import { moneyText } from '../domain/economy/money.ts';
|
|
14
14
|
import { maxHealth } from '../domain/character/character.ts';
|
|
15
15
|
import { bagCapacity } from '../domain/items/equipment.ts';
|
|
16
16
|
import { companionLevel, companionCharacter } from '../domain/bots/squad.ts';
|
|
@@ -27,10 +27,12 @@ import { isSkippableBeginFailure } from '../engine/game/types.ts';
|
|
|
27
27
|
import {
|
|
28
28
|
instancePortals, routeEncounterList, routeEncounterIdList, encounterOf, isCleared, isUnlocked, nextEncounter,
|
|
29
29
|
isComplete, requiredFlags, hasFlag, routeBossProgress, applyEncounterCleared, autoAdvanceRouteGates,
|
|
30
|
-
itemDropsInsideInstance, lockRemainingMs, restoreSavedRun,
|
|
30
|
+
itemDropsInsideInstance, lockRemainingMs, restoreSavedRun, canEnter,
|
|
31
31
|
} from '../world/instances.ts';
|
|
32
32
|
import { instanceObjectPins, lootObjectPins, objectGatesFor } from '../world/instanceObjects.ts';
|
|
33
33
|
import { dungeonNpcsForMap } from '../data/dungeonSpawns.ts';
|
|
34
|
+
import { extraEntrancesForInstance } from '../data/dungeons/pack-from-clusters.ts';
|
|
35
|
+
import { buildEntryLockInputs } from '../domain/run/canEnterContext.ts';
|
|
34
36
|
import { getDungeonGameObject } from '../data/dungeonGameObjects.ts';
|
|
35
37
|
import { countRouteTrash, selectRouteEncounters, DEFAULT_ROUTE_DENSITY, DEFAULT_PULL_FUSION } from '../world/dungeonRoute.ts';
|
|
36
38
|
import { WIPE_RETRY_PER_PULL, WIPE_CAP_PER_RUN } from '../world/dungeonPreset.ts';
|
|
@@ -53,6 +55,15 @@ function packSize(enc) {
|
|
|
53
55
|
|
|
54
56
|
// ---- portals and entry ------------------------------------------------------------------------------------
|
|
55
57
|
|
|
58
|
+
/** The engine's own door check (level, attunement, key, quest chain, lockout), so the row says no before Enter does. Ref worldView.ts:626. */
|
|
59
|
+
function entryCheck(app, def, now = Date.now()) {
|
|
60
|
+
const g = app.g;
|
|
61
|
+
return canEnter(def, app.ch.level, g.state.lockouts ?? {}, now, { teleports: app.ds.areatriggerTeleports(), instanceSaves: g.state.instanceSaves, ...buildEntryLockInputs(g.state, def.name) });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The other doors into this instance (Maraudon's orange side, Gnomeregan's train depot, Uldaman's back door...). */
|
|
65
|
+
const extraDoors = (def) => extraEntrancesForInstance(def.id).map((row) => ({ id: row.id, name: String(row.name ?? '').replace(/\s*-\s*Entering.*$/i, '').trim() || def.name }));
|
|
66
|
+
|
|
56
67
|
function lockText(app, def, now = Date.now()) {
|
|
57
68
|
const g = app.g;
|
|
58
69
|
const held = restoreSavedRun(g.state.instanceSaves, def.id, now);
|
|
@@ -78,10 +89,11 @@ export function portalRows(app) {
|
|
|
78
89
|
if (!portals.length) return rows;
|
|
79
90
|
rows.push(H(''), H(`Dungeons (${portals.length})`));
|
|
80
91
|
for (const def of portals) {
|
|
81
|
-
const
|
|
92
|
+
const chk = entryCheck(app, def);
|
|
93
|
+
const low = !chk.ok && !restoreSavedRun(g.state.instanceSaves, def.id, Date.now());
|
|
82
94
|
const lock = lockText(app, def);
|
|
83
95
|
rows.push({
|
|
84
|
-
text: `${low ? dim(def.name) : cyan(def.name)} ${dim(`L${def.levelRange[0]}-${def.levelRange[1]}${isRaid(def) ? ` ${def.maxPlayers}-man raid` : ''}`)}${low ? red(` requires ${def.minLevel}`) : ''}${lock ? ` ${lock}` : ''}`,
|
|
96
|
+
text: `${low ? dim(def.name) : cyan(def.name)} ${dim(`L${def.levelRange[0]}-${def.levelRange[1]}${isRaid(def) ? ` ${def.maxPlayers}-man raid` : ''}`)}${low && !lock ? red(` ${chk.reason ?? `requires ${def.minLevel}`}`) : ''}${lock ? ` ${lock}` : ''}`,
|
|
85
97
|
data: { dungeon: def },
|
|
86
98
|
detail: () => portalLines(app, def),
|
|
87
99
|
act: () => app.push(entryDialog(app, def)),
|
|
@@ -95,6 +107,8 @@ function portalLines(app, def) {
|
|
|
95
107
|
const out = [bold(def.name), dim(`levels ${def.levelRange[0]}-${def.levelRange[1]}, requires ${def.minLevel}${isRaid(def) ? `, ${def.maxPlayers} players` : ''}`)];
|
|
96
108
|
const lock = lockText(app, def);
|
|
97
109
|
if (lock) out.push(lock);
|
|
110
|
+
const chk = entryCheck(app, def);
|
|
111
|
+
if (!chk.ok && !lock) out.push(red(chk.reason ?? 'You cannot enter yet.'));
|
|
98
112
|
out.push('', cyan(`Bosses (${bosses.length})`), ...bosses.slice(0, 14).map((b) => ` ${b.name}`), ...(bosses.length > 14 ? [dim(` and ${bosses.length - 14} more`)] : []));
|
|
99
113
|
out.push('', dim('Enter: route, party and supplies, then go in'));
|
|
100
114
|
return out;
|
|
@@ -102,7 +116,8 @@ function portalLines(app, def) {
|
|
|
102
116
|
|
|
103
117
|
/** The card before the door: route density, party or raid, food and water, Enter. */
|
|
104
118
|
export function entryDialog(app, def) {
|
|
105
|
-
const opts = { routeDensity: DEFAULT_ROUTE_DENSITY, asRaid: isRaid(def) };
|
|
119
|
+
const opts = { routeDensity: DEFAULT_ROUTE_DENSITY, asRaid: isRaid(def), door: null };
|
|
120
|
+
const doors = extraDoors(def);
|
|
106
121
|
return {
|
|
107
122
|
id: `enter-${def.id}`, title: def.name, hint: 'Enter: choose',
|
|
108
123
|
rows(a) {
|
|
@@ -114,6 +129,7 @@ export function entryDialog(app, def) {
|
|
|
114
129
|
rows.push({ text: green(held ? 'Go back in' : 'Enter the dungeon'), detail: () => [bold('Enter'), '', `${opts.routeDensity === 'full' ? 'Every pack on the way' : 'Bosses and what guards them'}; ${opts.asRaid ? 'as a raid, the bench comes too' : `you and ${plural(squad.length, 'companion')}`}.`], act: () => enter(a, def, opts) });
|
|
115
130
|
const pace = (d) => ({ text: `Route: ${cyan(d === 'full' ? 'Full' : 'Bosses')}`, detail: () => [bold('Route density'), '', 'Bosses: the bosses and the packs that stand in their way.', 'Full: every pack in the dungeon.', '', dim('Enter: switch')], act: () => { opts.routeDensity = opts.routeDensity === 'full' ? 'bosses' : 'full'; } });
|
|
116
131
|
rows.push(pace(opts.routeDensity));
|
|
132
|
+
if (doors.length) { const all = [null, ...doors]; rows.push({ text: `Door: ${cyan(opts.door?.name ?? 'Main entrance')}`, detail: () => [bold('Which door'), '', 'Some instances have more than one way in; each starts you at its own end of the route.', ...doors.map((d) => ` ${d.name}`), '', dim('Enter: next door')], act: () => { opts.door = all[(all.indexOf(opts.door) + 1) % all.length]; } }); }
|
|
117
133
|
rows.push({ text: `Group: ${cyan(opts.asRaid ? 'Raid' : 'Party')}`, detail: () => [bold('Party or raid'), '', 'Party: you and the up to four companions you have seated.', `Raid: the bench comes too, up to ${(def.maxPlayers ?? 5) - 1} companions.`, '', dim('Enter: switch')], act: () => { opts.asRaid = !opts.asRaid; } });
|
|
118
134
|
const walk = walkingIn(g, def, opts.asRaid);
|
|
119
135
|
rows.push({ text: `Who walks in: ${cyan(`${walk.ids.length} of ${walk.cap}`)}${walk.picked ? '' : dim(' (auto)')}`, detail: () => [bold('Who walks in'), '', 'Pick from every companion you own, or Fill to match what this instance wants.', '', dim('Enter: pick')], act: () => a.push(rosterDialog(a, def, () => opts.asRaid)) });
|
|
@@ -131,7 +147,7 @@ export function entryDialog(app, def) {
|
|
|
131
147
|
}
|
|
132
148
|
|
|
133
149
|
function enter(app, def, opts) {
|
|
134
|
-
const r = app.g.enterInstance(def.id, Date.now(), { routeDensity: opts.routeDensity, asRaid: opts.asRaid });
|
|
150
|
+
const r = app.g.enterInstance(def.id, Date.now(), { routeDensity: opts.routeDensity, asRaid: opts.asRaid, ...(opts.door ? { entranceTriggerId: opts.door.id } : {}) });
|
|
135
151
|
if (!app.result(r, `You enter ${def.name}.`)) return;
|
|
136
152
|
app.dungeonPulls = [];
|
|
137
153
|
app.stack = [];
|
|
@@ -141,17 +157,17 @@ function enter(app, def, opts) {
|
|
|
141
157
|
// Food and water before the door: who is short, the vendor ladder, what it costs (runSupplyPanel.ts).
|
|
142
158
|
function supplyRows(app) {
|
|
143
159
|
const plan = app.g.runSupplyPlan();
|
|
144
|
-
const rows = [H(''), H(`Food and water ${dim(plan.mage ? '(a mage conjures both on the way)' : plan.rows.length ? `${
|
|
160
|
+
const rows = [H(''), H(`Food and water ${dim(plan.mage ? '(a mage conjures both on the way)' : plan.rows.length ? `${coinText(plan.cost)} for everyone` : 'everyone has enough')}`)];
|
|
145
161
|
if (plan.mage || !plan.rows.length) return rows;
|
|
146
162
|
for (const r of plan.rows) {
|
|
147
163
|
const rung = r.ladder[r.rung];
|
|
148
|
-
rows.push(H(` ${fit(r.memberName, 12)} ${fit(r.kind, 6)} ${dim(`has ${r.have}, short ${r.short}`)} ${itemText(rung?.item, '?')} x${r.units} ${
|
|
164
|
+
rows.push(H(` ${fit(r.memberName, 12)} ${fit(r.kind, 6)} ${dim(`has ${r.have}, short ${r.short}`)} ${itemText(rung?.item, '?')} x${r.units} ${coinText(r.cost)}`));
|
|
149
165
|
}
|
|
150
166
|
const buys = supplyBuys(plan.rows, app.ch.gold, true);
|
|
151
167
|
rows.push({
|
|
152
|
-
text: buys.length ? `Buy them ${yellow(`(${
|
|
168
|
+
text: buys.length ? `Buy them ${yellow(`(${coinText(buys.reduce((s, b) => s + b.cost, 0))})`)}` : dim('Buy them (not enough gold)'),
|
|
153
169
|
detail: () => [bold('Buy supplies'), '', 'The vendor price, straight into each member\'s own bags. What does not fit is left out.', ...(plan.full.length ? ['', yellow(`Bags full: ${plan.full.map((f) => f.name).join(', ')}`)] : [])],
|
|
154
|
-
act: () => { if (!buys.length) { app.flashMsg('Not enough gold for any of it.'); return; } const r = app.g.buyRunSupplies(buys); app.result(r, r.ok ? `Bought ${r.units} food and water for ${
|
|
170
|
+
act: () => { if (!buys.length) { app.flashMsg('Not enough gold for any of it.'); return; } const r = app.g.buyRunSupplies(buys); app.result(r, r.ok ? `Bought ${r.units} food and water for ${coinText(r.spent)}.` : ''); },
|
|
155
171
|
});
|
|
156
172
|
return rows;
|
|
157
173
|
}
|
|
@@ -280,7 +296,7 @@ function openChest(app, pin) {
|
|
|
280
296
|
app.say(`You open ${pin.name}.${r.loot.length ? '' : ' It is empty.'}`);
|
|
281
297
|
if (took.ok) noteLoot(app, took.taken, took.copper);
|
|
282
298
|
for (const d of took.ok ? took.taken : []) app.say(` ${yellow('You receive')} ${app.itemLabel(d.item)}${d.count > 1 ? ` x${d.count}` : ''}`);
|
|
283
|
-
if (took.ok && took.copper) app.say(` ${yellow('You receive')} ${
|
|
299
|
+
if (took.ok && took.copper) app.say(` ${yellow('You receive')} ${coinText(took.copper)}`);
|
|
284
300
|
if (took.ok && took.remaining) app.say(red(` ${plural(took.remaining, 'item')} did not fit in your bags.`));
|
|
285
301
|
return true;
|
|
286
302
|
}
|
|
@@ -314,7 +330,7 @@ function objectRow(app, def, o) {
|
|
|
314
330
|
const name = spawn.name ?? ds.creature(spawn.entry)?.name ?? `npc ${spawn.entry}`;
|
|
315
331
|
const marks = `${quests.turnIn.length ? green(' ?') : ''}${quests.offer.length ? yellow(' !') : ''}${quests.escort.length ? cyan(' escort') : ''}`;
|
|
316
332
|
return {
|
|
317
|
-
text: `${dim('
|
|
333
|
+
text: `${dim('•')} ${name}${marks}`,
|
|
318
334
|
detail: () => [bold(name), ...quests.offer.map((q) => `${yellow('!')} ${q.title}`), ...quests.turnIn.map((q) => `${green('?')} ${q.title}`), ...quests.escort.map((q) => `${cyan('escort')} ${q.title}`), '', dim('Enter: speak with them')],
|
|
319
335
|
act: () => npcDialog(app, spawn.entry, name),
|
|
320
336
|
};
|
|
@@ -362,7 +378,7 @@ export function runView(app) {
|
|
|
362
378
|
F: { hint: 'finish instantly', fn: (a) => a.confirm('Run every remaining stop now, without watching?', () => finishInstantly(a)) },
|
|
363
379
|
L: { hint: 'leave', fn: (a) => leaveRun(a) },
|
|
364
380
|
P: { hint: 'blessings', fn: (a) => a.push(paladinDialog(a)) },
|
|
365
|
-
R: { hint: 'reset', fn: (a) => a.confirm('Reset the run? Every stop comes back.', () => {
|
|
381
|
+
R: { hint: 'reset', fn: (a) => a.confirm('Reset the run? Every stop comes back.', () => { if (a.result(a.g.resetInstance(), 'The dungeon resets.')) { a.dungeonPulls = []; a.floor = null; } }) },
|
|
366
382
|
},
|
|
367
383
|
rows(a) {
|
|
368
384
|
const g = a.g, run = g.state.instance, def = runDef(a);
|
|
@@ -399,13 +415,15 @@ export function runView(app) {
|
|
|
399
415
|
|
|
400
416
|
// ---- pulls ---------------------------------------------------------------------------------------------
|
|
401
417
|
|
|
402
|
-
/** Pull one stop. `floor`: the continuous floor's pull (no sequential lock: you walked into it); `
|
|
403
|
-
*
|
|
404
|
-
|
|
418
|
+
/** Pull one stop. `floor`: the continuous floor's pull (no sequential lock: you walked into it); `ready`: buff
|
|
419
|
+
* and rest first, a pull you chose (a floor pack that saw you is already on you); `onDone(r)` runs after the
|
|
420
|
+
* fight with its outcome (null when you ran). */
|
|
421
|
+
export function pullStop(app, def, enc, { floor = false, ready = !floor, onDone, onTick } = {}) {
|
|
405
422
|
const run = app.g.state.instance;
|
|
406
423
|
if (!run) return;
|
|
407
424
|
if (isCleared(run, enc.id)) { app.flashMsg(`${enc.name} is already cleared.`); return; }
|
|
408
425
|
if (!floor && !isUnlocked(def, run, enc.id)) { app.flashMsg(`${enc.name} is locked.`); return; }
|
|
426
|
+
if (ready) readyUp(app);
|
|
409
427
|
for (const l of prePull(app.g, app.ds, app.cmds.buffPace)) app.say(cyan(l));
|
|
410
428
|
let playerMax = 0, session = null;
|
|
411
429
|
const f = startFight(app, enc.creatureEntry, {
|
|
@@ -458,16 +476,34 @@ function recordPull(app, def, enc, r, playerMax, session, downtimeSec) {
|
|
|
458
476
|
});
|
|
459
477
|
}
|
|
460
478
|
|
|
461
|
-
/**
|
|
462
|
-
|
|
479
|
+
/** Eat and drink from the bags, then sit, up to the squad strategy's rest line (betweenPullRest.ts). */
|
|
480
|
+
function eatAndDrink(app) {
|
|
463
481
|
const g = app.g;
|
|
464
482
|
const r = betweenPullConsumeLoop({ char: g.state.char, inventory: g.state.inventory, ds: app.ds, strategy: g.state.squadStrategy, deathFlow: g.state.deathFlow });
|
|
465
483
|
g.state.char = r.char; g.state.inventory = r.inventory;
|
|
484
|
+
app.dungeonDowntime = (app.dungeonDowntime ?? 0) + r.seconds;
|
|
485
|
+
return r;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/** Between pulls: eat and drink to the rest line, then sit. */
|
|
489
|
+
export function restBetween(app, seconds) {
|
|
490
|
+
const r = eatAndDrink(app);
|
|
466
491
|
app.idleTick(seconds, true);
|
|
467
|
-
app.dungeonDowntime
|
|
492
|
+
app.dungeonDowntime += seconds;
|
|
468
493
|
app.say(dim(`The party rests${r.seconds ? ` and eats (${Math.round(r.seconds)} s)` : ''}. HP ${app.hpText()} ${app.pwText()}`));
|
|
469
494
|
}
|
|
470
495
|
|
|
496
|
+
/** Before a pull you chose: the buffs phase cast standing still, then eat and drink back to the rest line, as
|
|
497
|
+
* runFullDungeon rests between pulls. Pulling on the mana the armour, Intellect and conjures just took sent
|
|
498
|
+
* a level 20 mage in at 761 of 1966, and nothing between stops gave it back. */
|
|
499
|
+
export function readyUp(app) {
|
|
500
|
+
const buffed = prepText(prepOoc(app.g, app.ds));
|
|
501
|
+
if (buffed) app.say(cyan(buffed));
|
|
502
|
+
const r = eatAndDrink(app);
|
|
503
|
+
if (r.seconds > 0) app.say(dim(`The party rests before the pull (${Math.round(r.seconds)} s). HP ${app.hpText()} ${app.pwText()}`));
|
|
504
|
+
for (const m of r.messages) if (/^Out of /.test(m)) app.say(yellow(` ${m}.`));
|
|
505
|
+
}
|
|
506
|
+
|
|
471
507
|
/** Open gates that need no item, and route flags the density skipped (liveDungeonHeadless.ts autoFireGates). */
|
|
472
508
|
function autoFireGates(app) {
|
|
473
509
|
const g = app.g, ds = app.ds, inst = g.state.instance;
|
|
@@ -576,7 +612,7 @@ export function summaryView(app, s) {
|
|
|
576
612
|
const deaths = s.pulls.filter((p) => p.outcome === 'defeat').length;
|
|
577
613
|
const rows = [H(`${bold(s.dungeonName)} ${tag}`), H('')];
|
|
578
614
|
rows.push(H(` ${s.encountersCleared}/${s.encounterTotal} stops, bosses ${s.bossesDown}/${s.bossTotal}, ${plural(s.pulls.length, 'pull')}, ${plural(deaths, 'wipe')}`));
|
|
579
|
-
rows.push(H(` ${secondsText(s.combatMs)} fighting, ${secondsText(s.downtimeSec * 1000)} resting +${s.totalXp} xp ${
|
|
615
|
+
rows.push(H(` ${secondsText(s.combatMs)} fighting, ${secondsText(s.downtimeSec * 1000)} resting +${s.totalXp} xp ${coinText(s.totalGold)}`));
|
|
580
616
|
if (s.stopReason) rows.push(H(red(` Stopped: ${s.stopReason}`)));
|
|
581
617
|
if (s.memberTotals?.length) {
|
|
582
618
|
const secs = Math.max(1, s.combatMs / 1000);
|