azeroth-shell 0.2.0 → 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 +43 -10
- package/bin/azsh.mjs +15 -1
- package/package.json +5 -2
- 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 +33 -9
- package/src/cli/tuiAutopilot.mjs +17 -8
- package/src/cli/tuiBags.mjs +37 -4
- package/src/cli/tuiCheck.mjs +276 -15
- package/src/cli/tuiCity.mjs +31 -31
- package/src/cli/tuiCompanionKit.mjs +6 -6
- package/src/cli/tuiCompanions.mjs +189 -29
- package/src/cli/tuiCreate.mjs +7 -0
- package/src/cli/tuiDungeon.mjs +56 -20
- 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 +371 -84
- 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/types.ts +4 -0
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
// The Companions tab: your party and bench, hiring at an inn, one page per companion (role, gear, bags,
|
|
2
2
|
// professions) and their gathering trips, and the squad mode that decides who fights beside you. A trimmed
|
|
3
3
|
// port of phonecraft's companionsPanel.ts, companionMissionsDesk.ts and squadModePicker.ts.
|
|
4
|
-
import { bold, dim, cyan, yellow, green, red, qualityPaint, fit, plural, itemText, vis, wrapLines } from './text.mjs';
|
|
4
|
+
import { bold, dim, cyan, yellow, green, red, inverse, qualityPaint, fit, cut, plural, itemText, vis, wrapLines, coinText, coin } from './text.mjs';
|
|
5
|
+
import { miniIcon } from './iconArt.mjs';
|
|
6
|
+
import { paperdollGrid, paperdollPlan } from './tuiPaperdoll.mjs';
|
|
5
7
|
import { itemLines } from './tuiViews.mjs';
|
|
6
8
|
import { recipeBook, openSendGoods, companionsOf } from './tuiProfessions.mjs';
|
|
7
9
|
import { SQUAD_MODES, SQUAD_MODE_LABEL, squadModeOf, setSquadMode } from './squad.mjs';
|
|
8
10
|
import { settingsRows, petRows, questRows, teachRow, bagRows, bankRows, shareDialog, vaultDialog, hireSearch, tavernDuel } from './tuiCompanionKit.mjs';
|
|
9
11
|
import { barsDialog, companionBarsOwner } from './tuiBars.mjs';
|
|
10
12
|
import { enchantLines } from './tuiGear.mjs';
|
|
11
|
-
import { moneyText } from '../domain/economy/money.ts';
|
|
12
13
|
import { className, raceName } from '../domain/core/ids.ts';
|
|
13
14
|
import { paperdollArt } from './paperdoll.mjs';
|
|
14
15
|
import { bagCapacity, EQUIP_SLOTS, SLOT_LABEL, itemSlotTargets } from '../domain/items/equipment.ts';
|
|
@@ -16,10 +17,12 @@ import { equippedEntry } from '../domain/items/itemInstance.ts';
|
|
|
16
17
|
import { companionCharacter, companionLevel, hireSkillMap, MAX_COMPANIONS, SQUAD_ROLES } from '../domain/bots/squad.ts';
|
|
17
18
|
import { companionEquipOpts } from '../domain/professions/autoDisenchant.ts';
|
|
18
19
|
import { companionSkillCap, companionTrainedRank, SECONDARY_SKILLS } from '../domain/bots/botProfessions.ts';
|
|
19
|
-
import { displayGearScore, gearProfileLabel } from '../domain/bots/companionHireCost.ts';
|
|
20
|
+
import { displayGearScore, gearProfileLabel, hireCostCopper } from '../domain/bots/companionHireCost.ts';
|
|
21
|
+
import { GEAR_PROFILES } from '../domain/bots/botGear.ts';
|
|
22
|
+
import { searchWorldBots, innZoneSalt, pickProfile, WORLD_BOT_COUNT } from '../domain/bots/botPool.ts';
|
|
20
23
|
import { maxHealth, maxPower } from '../domain/character/character.ts';
|
|
21
24
|
import { effectiveStats, effectiveArmor, attackPower, powerType } from '../domain/character/characterStats.ts';
|
|
22
|
-
import { critChanceDisplay, dodgeChanceDisplay } from '../domain/character/characterRatings.ts';
|
|
25
|
+
import { critChanceDisplay, dodgeChanceDisplay, hitChance } from '../domain/character/characterRatings.ts';
|
|
23
26
|
import {
|
|
24
27
|
canCompanionLearnProfession, countDoneMissions, hoursAffordable, isMissionSkill, missionEtaLabel, missionHoursLabel,
|
|
25
28
|
missionProgress, missionVerb, energyResetsAtMs, MAX_COMPANION_ENERGY,
|
|
@@ -63,9 +66,15 @@ function profTags(lines) {
|
|
|
63
66
|
|
|
64
67
|
function profLines(app, lines) {
|
|
65
68
|
if (!lines.length) return [` ${dim('none')}`];
|
|
66
|
-
return lines.map((l) =>
|
|
69
|
+
return lines.map((l) => { const prof = app.ds.profession(l.line); return paneLine(app, ` ${miniIcon(prof?.icon)}${fit(prof?.name ?? l.line, 15)} ${fit(`${l.current}/${l.max}`, 8)} ${dim(l.rank ?? '')}`); });
|
|
67
70
|
}
|
|
68
71
|
|
|
72
|
+
/** A detail-pane line kept to one row: a mini icon (sixel) is blank cells the pane's wrap would squeeze. */
|
|
73
|
+
function paneLine(app, line) { const w = (app.layout?.().detailW ?? 0) - 2; return w > 0 && vis(line) > w ? cut(line, w) : line; }
|
|
74
|
+
|
|
75
|
+
/** What a piece is, for the line under its name: Plate 412 armor, Sword 41-77. */
|
|
76
|
+
function gearKind(it) { return it ? [it.typeName, it.armor ? `${it.armor} armor` : '', it.weapon ? `${it.weapon.dmgMin}-${it.weapon.dmgMax}` : ''].filter(Boolean).join(' ') : ''; }
|
|
77
|
+
|
|
69
78
|
function statLines(app, char) {
|
|
70
79
|
const ds = app.ds, st = effectiveStats(char, ds), pt = powerType(char);
|
|
71
80
|
return [
|
|
@@ -92,7 +101,7 @@ function wornLines(app, gear) {
|
|
|
92
101
|
const out = [];
|
|
93
102
|
for (const slot of EQUIP_SLOTS) {
|
|
94
103
|
const e = equippedEntry(gear?.[slot]);
|
|
95
|
-
if (e != null) { const it = app.ds.item(e); out.push(` ${fit(SLOT_LABEL[slot] ?? slot, 10)} ${qualityPaint(it?.quality)(it?.name ?? e)}`); }
|
|
104
|
+
if (e != null) { const it = app.ds.item(e); out.push(paneLine(app, ` ${miniIcon(it?.icon)}${fit(SLOT_LABEL[slot] ?? slot, 10)} ${qualityPaint(it?.quality)(it?.name ?? e)}`)); }
|
|
96
105
|
}
|
|
97
106
|
return out.length ? out : [` ${dim('nothing')}`];
|
|
98
107
|
}
|
|
@@ -140,7 +149,7 @@ export function companionsView(app) {
|
|
|
140
149
|
rows(a) {
|
|
141
150
|
const g = a.g, squad = g.state.squad ?? [], bench = Object.values(g.state.botRoster ?? {});
|
|
142
151
|
const energy = g.companionPartyEnergy(Date.now());
|
|
143
|
-
const rows = [H(`Party ${squad.length}/${MAX_COMPANIONS} ${dim(`trip energy ${energy}/${MAX_COMPANION_ENERGY}`)} ${
|
|
152
|
+
const rows = [H(`Party ${squad.length}/${MAX_COMPANIONS} ${dim(`trip energy ${energy}/${MAX_COMPANION_ENERGY}`)} ${coinText(a.ch.gold)}`), squadModeRow(a)];
|
|
144
153
|
const row = (c) => ({
|
|
145
154
|
text: `${fit(c.name, 12)} ${fit(dim(`L${companionLevel(c, a.ch.level)} ${className(c.cls)}`), 12)} ${fit(roleText(c.role), 6)} ${c.mission ? yellow('away ') : ''}${profTags(compProfLines(a, c))}`,
|
|
146
155
|
detail: () => withArt(a, c, [...compLines(a, c), '', dim('Enter: open their page')]), // them in their gear
|
|
@@ -152,8 +161,8 @@ export function companionsView(app) {
|
|
|
152
161
|
const inn = g.innkeeperHere();
|
|
153
162
|
rows.push(H(''), H('Hire'));
|
|
154
163
|
if (inn) {
|
|
155
|
-
const n =
|
|
156
|
-
rows.push({ text: `Hire a companion at ${inn.name} ${dim(`(${plural(n, 'face')})`)}`, detail: () => [bold(inn.name), 'Innkeeper', '', `${plural(n, 'adventurer')}
|
|
164
|
+
const n = hirePool(a).list.length;
|
|
165
|
+
rows.push({ text: `Hire a companion at ${inn.name} ${dim(`(${plural(n, 'face')})`)}`, detail: () => [bold(inn.name), 'Innkeeper', '', `${plural(n, 'adventurer')} of your faction looking for work, every level, sorted by role and kit. A hire joins at their own level in their own gear; a bench companion comes back free.`], act: () => a.push(hireDialog(a)) });
|
|
157
166
|
} else rows.push(H(dim(' No inn here. Companions are hired from innkeepers (Goldshire, Razor Hill, every capital).')));
|
|
158
167
|
if (squad.length || bench.length) {
|
|
159
168
|
const ups = g.squadUpgradePlan().length;
|
|
@@ -196,7 +205,41 @@ function yieldText(app, rows) {
|
|
|
196
205
|
|
|
197
206
|
// ---- hiring -------------------------------------------------------------------------------------------
|
|
198
207
|
|
|
199
|
-
|
|
208
|
+
// Every same-faction face not already yours, from the bot pool alone: who they are, their spec, kit and
|
|
209
|
+
// price. Their gear is the dear part (a gear walk each, 6 s for 3000), so a face gets it when you look at
|
|
210
|
+
// them. The inn's own slate rolls its kits as a set, and hiring prices a slate face by that roll, so those
|
|
211
|
+
// faces come from the slate.
|
|
212
|
+
function hirePool(app) {
|
|
213
|
+
const g = app.g, ch = g.state.char;
|
|
214
|
+
const taken = [...(g.state.squad ?? []).map((c) => c.id), ...Object.keys(g.state.botRoster ?? {})];
|
|
215
|
+
const key = `${ch.zoneId}|${ch.level}|${ch.race}|${g.innkeeperHere()?.entry}|${taken.sort().join(',')}`;
|
|
216
|
+
if (app.hirePoolMemo?.g === g && app.hirePoolMemo.key === key) return app.hirePoolMemo;
|
|
217
|
+
const slate = new Map(g.innCandidates().map((c) => [c.bot.id, c]));
|
|
218
|
+
const salt = innZoneSalt(ch.zoneId, ch.level);
|
|
219
|
+
const bots = g.innkeeperHere() ? searchWorldBots({ ds: app.ds, playerRace: ch.race, playerLevel: ch.level, zoneId: ch.zoneId, excludeIds: taken, filters: { roles: SQUAD_ROLES, limit: WORLD_BOT_COUNT } }) : [];
|
|
220
|
+
const list = bots.map((bot) => slate.get(bot.id) ?? (() => {
|
|
221
|
+
const spec = app.ds.botSpecs(bot.cls)?.[bot.specIdx], gearProfile = pickProfile(bot.id, salt, ch.level);
|
|
222
|
+
return { bot, ...(spec ? { specName: spec.name, specRole: spec.role } : {}), gearProfile, hireCostCopper: hireCostCopper(bot.level, gearProfile) };
|
|
223
|
+
})());
|
|
224
|
+
app.hirePoolMemo = { g, key, list, full: new Map(), tags: new Map() };
|
|
225
|
+
return app.hirePoolMemo;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** A pool face with their gear on: built once, through the engine's own search so it is the body a hire gets. */
|
|
229
|
+
function fullCand(app, c) {
|
|
230
|
+
if (c.gear) return c;
|
|
231
|
+
const memo = hirePool(app);
|
|
232
|
+
if (!memo.full.has(c.bot.id)) {
|
|
233
|
+
const b = c.bot;
|
|
234
|
+
memo.full.set(b.id, app.g.searchInnCandidates({ name: b.name, classes: [b.cls], minLevel: b.level, maxLevel: b.level, limit: WORLD_BOT_COUNT }).find((x) => x.bot.id === b.id) ?? null);
|
|
235
|
+
}
|
|
236
|
+
return memo.full.get(c.bot.id) ?? null;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function findCand(app, botId) {
|
|
240
|
+
const c = [...app.g.innCandidates(), ...(app.hireResults ?? [])].find((x) => x.bot.id === botId) ?? hirePool(app).list.find((x) => x.bot.id === botId);
|
|
241
|
+
return c ? fullCand(app, c) : null;
|
|
242
|
+
}
|
|
200
243
|
|
|
201
244
|
// The body they would bring: same shell hireCandidate builds, on their own spec, for the stat preview.
|
|
202
245
|
function candCharacter(app, c) {
|
|
@@ -206,15 +249,16 @@ function candCharacter(app, c) {
|
|
|
206
249
|
|
|
207
250
|
function hireText(app, c, short = false) {
|
|
208
251
|
const cost = c.hireCostCopper ?? 0;
|
|
209
|
-
|
|
252
|
+
if (!cost) return yellow('free');
|
|
253
|
+
return (app.ch.gold >= cost ? yellow : red)(short ? shortMoney(cost) : coinText(cost));
|
|
210
254
|
}
|
|
211
255
|
|
|
212
256
|
// The two biggest coins only, for the list column: 1g 20s, 51s, 76c.
|
|
213
257
|
function shortMoney(copper) {
|
|
214
258
|
const g = Math.floor(copper / 10000), sv = Math.floor((copper % 10000) / 100), c = copper % 100;
|
|
215
|
-
if (g) return `${g}g${sv ? ` ${sv}s` : ''}`;
|
|
216
|
-
if (sv) return `${sv}s`;
|
|
217
|
-
return
|
|
259
|
+
if (g) return `${g}${coin('g')}${sv ? ` ${sv}${coin('s')}` : ''}`;
|
|
260
|
+
if (sv) return `${sv}${coin('s')}`;
|
|
261
|
+
return `${c}${coin('c')}`;
|
|
218
262
|
}
|
|
219
263
|
|
|
220
264
|
function candLines(app, c) {
|
|
@@ -232,7 +276,7 @@ function candLines(app, c) {
|
|
|
232
276
|
function hireAs(app, c, role) {
|
|
233
277
|
const r = app.g.hireCandidate(c.bot.id, role);
|
|
234
278
|
if (r.ok && app.hireResults) app.hireResults = app.hireResults.filter((x) => x.bot.id !== c.bot.id);
|
|
235
|
-
return app.result(r, `${r.name ?? c.bot.name} joins you as ${role}${r.costCopper ? ` for ${
|
|
279
|
+
return app.result(r, `${r.name ?? c.bot.name} joins you as ${role}${r.costCopper ? ` for ${coinText(r.costCopper)}` : ''}${r.benched ? ', on the bench' : ''}.`);
|
|
236
280
|
}
|
|
237
281
|
|
|
238
282
|
function pickRoleAndHire(app, c, after) {
|
|
@@ -240,31 +284,77 @@ function pickRoleAndHire(app, c, after) {
|
|
|
240
284
|
app.pick(`${c.bot.name}: what role?`, roles.map((r) => ({ label: `${roleText(r)}${r === c.specRole ? dim(' (their spec)') : ''}`, value: r })), (role) => { if (hireAs(app, c, role)) after?.(); });
|
|
241
285
|
}
|
|
242
286
|
|
|
287
|
+
// The hire list's two filters: a role tab (Left and Right) and a kit (t), each with its count under the other.
|
|
288
|
+
const HIRE_ROLES = [{ role: null, label: 'All' }, { role: 'dps', label: 'DPS' }, { role: 'healer', label: 'Heal' }, { role: 'tank', label: 'Tank' }];
|
|
289
|
+
const KIT_SHORT = { naked: 'Naked', shabby: 'Shabby', field: 'Field', twink: 'Twink', 'raid-mixed': 'Raid', 'raid-t2': 'Raid T2', 'raid-t3': 'Raid T3' };
|
|
290
|
+
const roleOfCand = (c) => c.specRole ?? 'dps';
|
|
291
|
+
|
|
243
292
|
function hireDialog(app, results) {
|
|
244
|
-
|
|
245
|
-
|
|
293
|
+
// The kits this list has, cheapest first; t steps through them and back to any.
|
|
294
|
+
const kitsOf = (list) => [null, ...GEAR_PROFILES.filter((p) => list.some((c) => c.gearProfile === p))];
|
|
295
|
+
const view = {
|
|
296
|
+
id: results ? 'hire-search' : 'hire', hint: 'Enter: look closer Left Right: role',
|
|
297
|
+
roleTab: 0, kit: null, placed: false,
|
|
298
|
+
// The filter rows scroll off a long list; the breadcrumb keeps them in sight.
|
|
299
|
+
get title() { return [results ? 'Search' : 'Hire', HIRE_ROLES[this.roleTab].role && HIRE_ROLES[this.roleTab].label, this.kit && KIT_SHORT[this.kit]].filter(Boolean).join(' · '); },
|
|
246
300
|
keys: {
|
|
247
301
|
h: { hint: 'hire', fn: (a, row) => { const c = row?.data && findCand(a, row.data); if (c) pickRoleAndHire(a, c); } },
|
|
302
|
+
t: { hint: 'kit', fn: (a) => stepKit(a, +1) },
|
|
303
|
+
T: { hint: 'kit back', fn: (a) => stepKit(a, -1) },
|
|
248
304
|
'/': { hint: 'search', fn: (a) => hireSearch(a, (found) => { a.hireResults = found; a.push(hireDialog(a, found)); }) },
|
|
249
305
|
D: { hint: 'duel', fn: (a, row) => { if (row?.data) tavernDuel(a, row.data); } },
|
|
250
306
|
},
|
|
307
|
+
onKey(a, k) {
|
|
308
|
+
const d = { right: +1, tab: +1, left: -1, 'shift-tab': -1 }[k];
|
|
309
|
+
if (!d) return false;
|
|
310
|
+
view.roleTab = (view.roleTab + d + HIRE_ROLES.length) % HIRE_ROLES.length;
|
|
311
|
+
view.placed = false;
|
|
312
|
+
a.render();
|
|
313
|
+
return true;
|
|
314
|
+
},
|
|
251
315
|
rows(a) {
|
|
252
|
-
const g = a.g,
|
|
316
|
+
const g = a.g, all = results ?? hirePool(a).list, memo = hirePool(a);
|
|
253
317
|
const squad = g.state.squad ?? [];
|
|
254
|
-
const
|
|
255
|
-
|
|
318
|
+
const { role } = HIRE_ROLES[view.roleTab];
|
|
319
|
+
const ofRole = (c) => !role || roleOfCand(c) === role, ofKit = (c) => !view.kit || c.gearProfile === view.kit;
|
|
320
|
+
const list = all.filter((c) => ofRole(c) && ofKit(c));
|
|
321
|
+
const roleTabs = HIRE_ROLES.map((t, i) => { const label = ` ${t.label} ${all.filter((c) => (!t.role || roleOfCand(c) === t.role) && ofKit(c)).length} `; return i === view.roleTab ? inverse(bold(label)) : dim(label); }).join(' ');
|
|
322
|
+
const kitTabs = kitsOf(all).map((p) => { const label = p ? `${KIT_SHORT[p] ?? p} ${all.filter((c) => ofRole(c) && c.gearProfile === p).length}` : 'Any'; return p === view.kit ? inverse(bold(` ${label} `)) : dim(` ${label} `); });
|
|
323
|
+
// Seven kits and their counts outrun the list pane at 60: they wrap onto a second row.
|
|
324
|
+
const kitW = a.listWOf(view, a.layout()) - 2, kitLines = [`Kit`];
|
|
325
|
+
for (const t of [...kitTabs, dim(' t')]) { if (vis(kitLines.at(-1)) + vis(t) > kitW) kitLines.push(' '); kitLines[kitLines.length - 1] += t; }
|
|
326
|
+
const rows = [
|
|
327
|
+
H(`${plural(list.length, 'adventurer')} for hire ${squad.length >= MAX_COMPANIONS ? yellow('party full: a new hire goes to the bench') : dim(`party ${squad.length}/${MAX_COMPANIONS}`)} ${coinText(a.ch.gold)}`),
|
|
328
|
+
H(`${roleTabs} ${dim('Left Right')}`), ...kitLines.map(H), H(''),
|
|
329
|
+
];
|
|
330
|
+
if (!list.length) rows.push(H(dim(all.length ? ' Nobody here fits that role and kit.' : ' Nobody is looking for work here right now.')));
|
|
256
331
|
for (const c of list) {
|
|
257
332
|
const b = c.bot;
|
|
333
|
+
if (!memo.tags.has(b.id)) memo.tags.set(b.id, profTags(candProfLines(c)));
|
|
258
334
|
rows.push({
|
|
259
|
-
text: `${fit(b.name, 12)} ${fit(dim(`L${b.level} ${className(b.cls)}`), 12)} ${fit(c.specRole ? roleText(c.specRole) : '', 6)} ${fit(hireText(a, c, true), 7)} ${
|
|
335
|
+
text: `${fit(b.name, 12)} ${fit(dim(`L${b.level} ${className(b.cls)}`), 12)} ${fit(c.specRole ? roleText(c.specRole) : '', 6)} ${fit(dim(KIT_SHORT[c.gearProfile] ?? ''), 7)} ${fit(hireText(a, c, true), 7)} ${memo.tags.get(b.id)}`,
|
|
260
336
|
data: b.id,
|
|
261
|
-
detail: () => withArt(a, candCharacter(a,
|
|
337
|
+
detail: () => { const full = fullCand(a, c); return full ? withArt(a, candCharacter(a, full), [...candLines(a, full), '', dim('Enter: their page h: hire')]) : []; },
|
|
262
338
|
act: () => a.push(candidateDialog(a, b.id)),
|
|
263
339
|
});
|
|
264
340
|
}
|
|
341
|
+
// A new list opens on your own level (the slate's window), not on the level 1s at its top.
|
|
342
|
+
if (!view.placed && list.length) {
|
|
343
|
+
view.placed = true;
|
|
344
|
+
const at = list.findIndex((c) => c.bot.level >= a.ch.level - 2);
|
|
345
|
+
view.cursor = rows.length - list.length + (at < 0 ? list.length - 1 : at);
|
|
346
|
+
view.scroll = at > 0 ? view.cursor - 3 : 0; // a few lower levels above it, or the filter rows when it heads the list
|
|
347
|
+
}
|
|
265
348
|
return rows;
|
|
266
349
|
},
|
|
267
350
|
};
|
|
351
|
+
const stepKit = (a, d) => {
|
|
352
|
+
const kits = kitsOf(results ?? hirePool(a).list);
|
|
353
|
+
view.kit = kits[(kits.indexOf(view.kit) + d + kits.length) % kits.length];
|
|
354
|
+
view.placed = false;
|
|
355
|
+
a.render();
|
|
356
|
+
};
|
|
357
|
+
return view;
|
|
268
358
|
}
|
|
269
359
|
|
|
270
360
|
/** One face at the inn: everything about them, and the hire buttons. */
|
|
@@ -284,7 +374,7 @@ function candidateDialog(app, botId) {
|
|
|
284
374
|
for (const role of roles) {
|
|
285
375
|
rows.push({
|
|
286
376
|
text: `Hire as ${roleText(role)} for ${hireText(a, c)}${role === c.specRole ? dim(' their spec') : ''}`,
|
|
287
|
-
detail: () => withArt(a, body, [bold(`Hire ${b.name} as ${role}`), `Cost: ${hireText(a, c)} you have ${
|
|
377
|
+
detail: () => withArt(a, body, [bold(`Hire ${b.name} as ${role}`), `Cost: ${hireText(a, c)} you have ${coinText(a.ch.gold)}`,
|
|
288
378
|
role === c.specRole ? dim(`They already play ${c.specName}.`) : dim('They respec for the role: talents and rotation rebuild for free.'),
|
|
289
379
|
...((a.g.state.squad ?? []).length >= MAX_COMPANIONS ? [yellow('Your party is full: they start on the bench.')] : []), dim('A released hire refunds the price.')]),
|
|
290
380
|
act: () => { if (hireAs(a, c, role)) a.pop(); },
|
|
@@ -295,7 +385,8 @@ function candidateDialog(app, botId) {
|
|
|
295
385
|
for (const l of candProfLines(c)) {
|
|
296
386
|
const prof = ds.profession(l.line);
|
|
297
387
|
rows.push({
|
|
298
|
-
text: `${fit(prof?.name ?? l.line, 16)} ${fit(`${l.current}/${l.max}`, 8)} ${dim(l.rank ?? '')}
|
|
388
|
+
text: `${fit(prof?.name ?? l.line, 16)} ${fit(`${l.current}/${l.max}`, 8)} ${dim(l.rank ?? '')}`,
|
|
389
|
+
icon: prof?.icon, sub: dim(isSecondary(l.line) ? 'secondary' : prof?.kind ?? ''),
|
|
299
390
|
detail: () => [bold(prof?.name ?? l.line), `${l.current}/${l.max}${l.rank ? ` ${l.rank}` : ''}`, '',
|
|
300
391
|
isMissionSkill(l.line) ? `Once hired they can go on ${missionVerb(l.line).toLowerCase()} trips while you play.` : `Once hired they craft from their own ${prof?.name ?? l.line} recipe book with your materials.`,
|
|
301
392
|
dim('Companions train for free as they level, and can swap trades later.')],
|
|
@@ -309,7 +400,7 @@ function candidateDialog(app, botId) {
|
|
|
309
400
|
const e = equippedEntry(c.gear?.[slot]);
|
|
310
401
|
if (e == null) continue;
|
|
311
402
|
const it = ds.item(e);
|
|
312
|
-
rows.push({ text: `${fit(SLOT_LABEL[slot] ?? slot, 10)} ${qualityPaint(it?.quality)(it?.name ?? e)}`, detail: () => (it ? itemLines(a, it) : []) });
|
|
403
|
+
rows.push({ text: `${fit(SLOT_LABEL[slot] ?? slot, 10)} ${qualityPaint(it?.quality)(it?.name ?? e)}`, icon: it?.icon, sub: dim(`${fit('', 10)} ${gearKind(it)}`), detail: () => (it ? itemLines(a, it) : []) });
|
|
313
404
|
}
|
|
314
405
|
return rows;
|
|
315
406
|
},
|
|
@@ -322,6 +413,7 @@ export function companionDialog(app, id) {
|
|
|
322
413
|
return {
|
|
323
414
|
id: `comp-${id}`, title: findComp(app, id)?.name ?? 'Companion', hint: 'Enter: act',
|
|
324
415
|
keys: {
|
|
416
|
+
c: { hint: 'character sheet', fn: (a) => a.push(companionSheet(a, id)) },
|
|
325
417
|
x: { hint: 'take off / drop', fn: (a, row) => {
|
|
326
418
|
const d = row?.data;
|
|
327
419
|
if (d?.slot && d.it) a.result(a.g.unequipCompanionSlot(id, d.slot), `${findComp(a, id)?.name} takes off ${itemText(d.it)}.`);
|
|
@@ -340,6 +432,9 @@ export function companionDialog(app, id) {
|
|
|
340
432
|
}
|
|
341
433
|
rows.push(H(''));
|
|
342
434
|
const note = (title, ...lines) => withArt(a, comp, [bold(title), '', ...lines]);
|
|
435
|
+
rows.push({ text: `Character sheet ${dim('c')}`, icon: 'inv_misc_head_human_01', sub: dim('their gear on the paperdoll, and their stats'),
|
|
436
|
+
detail: () => note('Character sheet', 'What they wear on the paperdoll, the way your Character tab shows you: each piece in its slot, its tooltip beside, their numbers underneath.'),
|
|
437
|
+
act: () => a.push(companionSheet(a, id)) });
|
|
343
438
|
rows.push({ text: `Role: ${roleText(comp.role)}`, detail: () => note('Role', 'Tank, healer or damage. Changing it is free and rebuilds their talents and rotation for the role.'),
|
|
344
439
|
act: () => a.pick(`${comp.name}: what role?`, SQUAD_ROLES.map((r) => ({ label: roleText(r), value: r })), (r) => a.result(g.setCompanionRole(id, r), `${comp.name} is now your ${r}.`)) });
|
|
345
440
|
rows.push(seated
|
|
@@ -359,7 +454,8 @@ export function companionDialog(app, id) {
|
|
|
359
454
|
for (const l of sortLines(profs?.lines ?? [])) {
|
|
360
455
|
const prof = ds.profession(l.line);
|
|
361
456
|
rows.push({
|
|
362
|
-
text: `${fit(prof?.name ?? l.line, 16)} ${fit(`${l.current}/${l.max}`, 8)} ${dim(l.rank ?? '')}${
|
|
457
|
+
text: `${fit(prof?.name ?? l.line, 16)} ${fit(`${l.current}/${l.max}`, 8)} ${dim(l.rank ?? '')}${comp.mission?.skill === l.line ? yellow(' away') : ''}`, data: { line: l.line },
|
|
458
|
+
icon: prof?.icon, sub: dim(`${isSecondary(l.line) ? 'secondary' : prof?.kind ?? ''} ${isMissionSkill(l.line) ? 'Enter: trips' : 'Enter: recipe book'}`),
|
|
363
459
|
detail: () => [bold(prof?.name ?? l.line), `${l.current}/${l.max}${l.rank ? ` ${l.rank}` : ''}`, '', dim(isMissionSkill(l.line) ? 'Enter: smelting and gathering trips' : 'Enter: their recipe book'), dim('x: drop it')],
|
|
364
460
|
act: () => a.push(recipeBook(a, id, l.line)),
|
|
365
461
|
});
|
|
@@ -384,6 +480,7 @@ export function companionDialog(app, id) {
|
|
|
384
480
|
const it = e != null ? ds.item(e) : null;
|
|
385
481
|
rows.push({
|
|
386
482
|
text: `${fit(SLOT_LABEL[slot] ?? slot, 10)} ${it ? qualityPaint(it.quality)(it.name) : dim('—')}`, data: { slot, it },
|
|
483
|
+
icon: it?.icon, sub: it ? dim(`${fit('', 10)} ${gearKind(it)}`) : undefined,
|
|
387
484
|
detail: () => (it ? itemLines(a, it, [...enchantLines(a, comp, slot), '', dim('Enter: change x: take off')]) : [dim(`Nothing in the ${SLOT_LABEL[slot] ?? slot} slot.`), '', dim('Enter: pick something for them to wear')]),
|
|
388
485
|
act: () => slotPicker(a, id, slot),
|
|
389
486
|
});
|
|
@@ -393,7 +490,70 @@ export function companionDialog(app, id) {
|
|
|
393
490
|
rows.push(...bankRows(a, comp));
|
|
394
491
|
|
|
395
492
|
rows.push(H(''), { text: red(`Release ${comp.name} for good`), detail: () => note('Release', 'They leave your roster for good. You get the hire price back. Their bags and gear go with them.'),
|
|
396
|
-
act: () => a.confirm(`Release ${comp.name} for good? Their bags and gear go with them.`, () => { const r = g.releaseCompanions({ ids: [id] }); if (a.result(r, `${comp.name} leaves.${r.refundCopper ? ` You get ${
|
|
493
|
+
act: () => a.confirm(`Release ${comp.name} for good? Their bags and gear go with them.`, () => { const r = g.releaseCompanions({ ids: [id] }); if (a.result(r, `${comp.name} leaves.${r.refundCopper ? ` You get ${coinText(r.refundCopper)} back.` : ''}`)) a.popTo('__none__'); }) });
|
|
494
|
+
return rows;
|
|
495
|
+
},
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// ---- their character sheet ------------------------------------------------------------------------------
|
|
500
|
+
|
|
501
|
+
/** Their numbers at the foot of the sheet's tooltip pane, laid out like yours under the Character tab. */
|
|
502
|
+
function sheetStats(app, char, w) {
|
|
503
|
+
const ds = app.ds, st = effectiveStats(char, ds), pt = powerType(char);
|
|
504
|
+
const pct = (x) => `${((x ?? 0) * 100).toFixed(2)}%`;
|
|
505
|
+
const attrs = [['Strength', st.str], ['Agility', st.agi], ['Stamina', st.sta], ['Intellect', st.int], ['Spirit', st.spi]];
|
|
506
|
+
const combat = [['Armor', effectiveArmor(char, ds)], ['Attack pwr', attackPower(char, ds)], ['Crit', pct(critChanceDisplay(char, ds))],
|
|
507
|
+
['Hit', pct(hitChance(char, ds))], ['Dodge', pct(dodgeChanceDisplay(char, ds))]];
|
|
508
|
+
return [
|
|
509
|
+
dim('─'.repeat(Math.max(0, w))),
|
|
510
|
+
`Health ${maxHealth(char, ds)} ${pt[0].toUpperCase() + pt.slice(1)} ${maxPower(char, ds)}`,
|
|
511
|
+
'',
|
|
512
|
+
...attrs.map(([name, v], i) => `${fit(name, 10)}${String(v).padStart(5)} ${fit(combat[i][0], 10)} ${String(combat[i][1]).padStart(7)}`),
|
|
513
|
+
];
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function sheetTitle(app, comp) {
|
|
517
|
+
const ilvl = displayGearScore(comp.equipped ?? {}, app.ds);
|
|
518
|
+
return `${bold(comp.name)} ${dim(`level ${companionLevel(comp, app.ch.level)} ${raceName(comp.race)} ${className(comp.cls)}`)} ${roleText(comp.role)}${ilvl ? dim(` ilvl ~${ilvl}`) : ''}`;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* A companion's character sheet: the Character tab's paperdoll (tuiPaperdoll.mjs) for them. Their pieces
|
|
523
|
+
* framed in quality colour down both sides, them in their gear in the middle, the tooltip beside and their
|
|
524
|
+
* stats at its foot. Enter picks from their bags and yours, x takes a piece off, u puts on every upgrade.
|
|
525
|
+
* Too small a window or no colour, and it lists the slots with their icons instead.
|
|
526
|
+
*/
|
|
527
|
+
export function companionSheet(app, id) {
|
|
528
|
+
const viewId = `sheet-${id}`;
|
|
529
|
+
const hint = 'Enter: change x: take off u: upgrades';
|
|
530
|
+
return {
|
|
531
|
+
id: viewId, title: 'Character sheet', hint: 'Enter: change',
|
|
532
|
+
detailFoot: (a, w) => { const c = findComp(a, id); return c ? sheetStats(a, companionCharacter(c, companionLevel(c, a.ch.level), a.ds), w) : []; },
|
|
533
|
+
keys: {
|
|
534
|
+
x: { hint: 'take off', fn: (a, row) => { const d = row?.data; if (d?.slot && d.it) a.result(a.g.unequipCompanionSlot(id, d.slot), `${findComp(a, id)?.name} takes off ${itemText(d.it)}.`); } },
|
|
535
|
+
u: { hint: 'put on upgrades', fn: (a) => { const c = findComp(a, id), r = a.g.equipCompanionBest(id); a.result(r, r.equipped ? `${c?.name} puts on ${plural(r.equipped, 'piece')}.` : `${c?.name} already wears the best they can.`); } },
|
|
536
|
+
},
|
|
537
|
+
grid: (a, rows, w, h) => { const c = findComp(a, id); return c ? paperdollGrid(a, rows, w, h, (cols, lines) => paperdollArt(c, cols, lines), sheetTitle(a, c), hint) : null; },
|
|
538
|
+
rows(a) {
|
|
539
|
+
const ds = a.ds, comp = findComp(a, id);
|
|
540
|
+
if (!comp) { setTimeout(() => { if (a.top()?.id === viewId) a.pop(); }, 0); return [H('They are gone.')]; }
|
|
541
|
+
const ups = a.g.companionUpgradesFor(id);
|
|
542
|
+
const rows = paperdollPlan(a, 0) ? [] : [{ text: sheetTitle(a, comp), header: true, plain: true }, H('')];
|
|
543
|
+
for (const slot of EQUIP_SLOTS) {
|
|
544
|
+
const e = equippedEntry(comp.equipped?.[slot]);
|
|
545
|
+
const it = e != null ? ds.item(e) : null;
|
|
546
|
+
const best = ups.filter((u) => u.slot === slot).sort((x, y) => y.delta - x.delta)[0];
|
|
547
|
+
const bestLines = best ? ['', green(`▲ Better: ${a.itemName(best.entry)} (+${Math.round(best.delta)})${best.from === 'player' ? ', in your bags' : ', in their bags'}`), dim('Enter: pick it u: put on every upgrade')] : [];
|
|
548
|
+
rows.push({
|
|
549
|
+
text: `${fit(SLOT_LABEL[slot] ?? slot, 10)} ${it ? qualityPaint(it.quality)(it.name) : dim('—')}${best ? green(' ▲') : ''}`, data: { slot, it },
|
|
550
|
+
icon: it?.icon, sub: it ? dim(`${fit('', 10)} ${gearKind(it)}`) : undefined,
|
|
551
|
+
cell: { slot, it, up: !!best, sub: dim(gearKind(it)) },
|
|
552
|
+
detail: () => (it ? itemLines(a, it, [...enchantLines(a, comp, slot), '', dim('Enter: change x: take off'), ...bestLines])
|
|
553
|
+
: [bold(SLOT_LABEL[slot] ?? slot), dim('Nothing worn here.'), '', dim('Enter: pick something for them to wear'), ...bestLines]),
|
|
554
|
+
act: () => slotPicker(a, id, slot),
|
|
555
|
+
});
|
|
556
|
+
}
|
|
397
557
|
return rows;
|
|
398
558
|
},
|
|
399
559
|
};
|
|
@@ -406,7 +566,7 @@ function giveDialog(app, id) {
|
|
|
406
566
|
if (!st.length) { app.flashMsg('Your bags are empty.'); return; }
|
|
407
567
|
app.pick(`Give to ${comp?.name}`, st.map((s, slot) => {
|
|
408
568
|
const it = app.ds.item(s.item);
|
|
409
|
-
return { label: `${qualityPaint(it?.quality)(it?.name ?? s.item)}${s.count > 1 ? ` x${s.count}` : ''}`, detail: () => itemLines(app, it), value: { s, slot, it } };
|
|
569
|
+
return { label: `${qualityPaint(it?.quality)(it?.name ?? s.item)}${s.count > 1 ? ` x${s.count}` : ''}`, icon: it?.icon, sub: dim(it?.typeName ?? ''), detail: () => itemLines(app, it), value: { s, slot, it } };
|
|
410
570
|
}), ({ slot, it }) => {
|
|
411
571
|
const r = app.g.giveItemToBotStack(id, slot);
|
|
412
572
|
app.result(r, `You give ${itemText(it, 'it')}${r.moved > 1 ? ` x${r.moved}` : ''} to ${comp?.name}.`);
|
|
@@ -424,7 +584,7 @@ function slotPicker(app, id, slot) {
|
|
|
424
584
|
const options = [];
|
|
425
585
|
const add = (s, from) => {
|
|
426
586
|
const it = ds.item(s.item), up = ups.get(s.item);
|
|
427
|
-
options.push({ label: `${qualityPaint(it.quality)(it.name)}${up ? green(` ▲ +${Math.round(up.delta)}`) : ''}${it.requiredLevel > lvl ? red(` L${it.requiredLevel}`) : ''}${dim(from === 'you' ? ' your bags' : ' their bags')}`, detail: () => itemLines(app, it), value: it });
|
|
587
|
+
options.push({ label: `${qualityPaint(it.quality)(it.name)}${up ? green(` ▲ +${Math.round(up.delta)}`) : ''}${it.requiredLevel > lvl ? red(` L${it.requiredLevel}`) : ''}${dim(from === 'you' ? ' your bags' : ' their bags')}`, icon: it.icon, sub: dim(gearKind(it)), detail: () => itemLines(app, it), value: it });
|
|
428
588
|
};
|
|
429
589
|
for (const s of comp.bags?.stacks ?? []) if (fits(s)) add(s, 'them');
|
|
430
590
|
for (const s of app.g.state.inventory.stacks) if (fits(s)) add(s, 'you');
|
package/src/cli/tuiCreate.mjs
CHANGED
|
@@ -13,6 +13,7 @@ import { equippedEntry } from '../domain/items/itemInstance.ts';
|
|
|
13
13
|
import { nativeDisplayId } from '../domain/character/displayForm.ts';
|
|
14
14
|
import { creatureArt } from './modelArt.mjs';
|
|
15
15
|
import { paperdollArt } from './paperdoll.mjs';
|
|
16
|
+
import { characterNamesEqual } from '../domain/character/characterName.ts';
|
|
16
17
|
|
|
17
18
|
// A sentence on each race and class, in the voice of the client's create screen; the racials, the start
|
|
18
19
|
// zone and everything else on the card come from the data.
|
|
@@ -97,9 +98,14 @@ export function characterCreation(app, done, { onCancel } = {}) {
|
|
|
97
98
|
const drawn = (cls, maxCols, maxRows) => paperdollArt(hero(cls), maxCols, maxRows) ?? creatureArt(nativeDisplayId(st.race.id, st.gender), maxCols, maxRows);
|
|
98
99
|
const withBody = (text, art) => (art ? [...text, '', ...art] : text);
|
|
99
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));
|
|
100
105
|
const askName = () => app.prompt('Name (2-12 letters): ', (raw) => {
|
|
101
106
|
const name = raw.trim();
|
|
102
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; }
|
|
103
109
|
st.name = properName(name);
|
|
104
110
|
goTo('create'); // named: Enter again creates
|
|
105
111
|
}); // the line starts empty: Esc keeps the name they had
|
|
@@ -107,6 +113,7 @@ export function characterCreation(app, done, { onCancel } = {}) {
|
|
|
107
113
|
const shuffle = () => { st.look = randomLook(st.race.id, st.gender); app.render(); };
|
|
108
114
|
const create = () => {
|
|
109
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; }
|
|
110
117
|
done(newGameState(ds, st.race.id, st.cls, st.name, newSeed(), st.gender, st.look));
|
|
111
118
|
};
|
|
112
119
|
const randomCharacter = () => {
|
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
|
}
|
|
@@ -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);
|