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
|
@@ -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 = () => {
|